diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json
index 166aff6..f6cc866 100644
--- a/.claude-plugin/marketplace.json
+++ b/.claude-plugin/marketplace.json
@@ -1,16 +1,16 @@
{
"$schema": "https://anthropic.com/claude-code/marketplace.schema.json",
- "name": "last30days-skill",
- "description": "Research any topic from the last 30 days across Reddit, X, YouTube, TikTok, Instagram, Hacker News, Polymarket, and the web.",
+ "name": "last30days-skill-private",
+ "description": "(TESTING) Multi-query social search with cluster-first output.",
"owner": {
"name": "Matt Van Horn",
"url": "https://github.com/mvanhorn"
},
"plugins": [
{
- "name": "last30days",
- "description": "Research any topic from the last 30 days. Become an expert and write copy-paste-ready prompts.",
- "version": "2.9.6",
+ "name": "last30days-3",
+ "description": "(TESTING) Multi-query social search with cluster-first output.",
+ "version": "3.0.0-alpha",
"author": {
"name": "Matt Van Horn",
"url": "https://github.com/mvanhorn"
diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json
index a28a15a..43efd7b 100644
--- a/.claude-plugin/plugin.json
+++ b/.claude-plugin/plugin.json
@@ -1,7 +1,7 @@
{
- "name": "last30days",
- "version": "2.9.6",
- "description": "Research any topic from the last 30 days across Reddit, X, YouTube, TikTok, Instagram, Hacker News, Polymarket, and the web.",
+ "name": "last30days-3",
+ "version": "3.0.0",
+ "description": "Research any topic across Reddit, X, YouTube, TikTok, Instagram, Hacker News, Polymarket, GitHub, and 5+ more sources. AI agent scores by upvotes, likes, and real money - not editors.",
"author": {
"name": "Matt Van Horn",
"email": "mvanhorn@gmail.com",
@@ -10,7 +10,7 @@
"homepage": "https://github.com/mvanhorn/last30days-skill",
"repository": "https://github.com/mvanhorn/last30days-skill",
"license": "MIT",
- "keywords": ["research", "reddit", "twitter", "youtube", "tiktok", "instagram", "trends", "prompts", "polymarket"],
+ "keywords": ["research", "reddit", "twitter", "youtube", "tiktok", "instagram", "trends", "prompts", "polymarket", "github", "perplexity", "threads", "pinterest", "eli5", "hacker-news"],
"skills": ["./"],
"hooks": {}
}
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 19ebe97..4107477 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,40 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+## [3.0.0] - 2026-04
+
+### Highlights
+
+Intelligent search, fun judge, cross-source cluster merging, single-pass comparisons, and OpenClaw as a first-class citizen. The v3 engine doesn't just search for your topic -- it figures out *where* to search before the search begins. Engine architecture by @j-sperling.
+
+### Added
+
+- **Intelligent pre-research** -- Resolves X handles, subreddits, TikTok hashtags, and YouTube channels via a new Python brain before any API calls fire. Bidirectional: person to company, product to founder.
+- **Fun judge / Best Takes** -- Second parallel LLM judge scores humor, cleverness, and virality. Surfaces the best reactions in a dedicated output section.
+- **Cross-source cluster merging** -- Entity-based overlap detection merges the same story across Reddit, X, YouTube into one cluster instead of three separate items.
+- **Single-pass comparisons** -- "X vs Y" runs one pass with entity-aware subqueries instead of three serial passes. 3 minutes instead of 12+.
+- **GitHub as a source** -- Stars, reactions, and comments from repos and issues.
+- **OpenClaw first-class citizen** -- Auto-resolve for engine-side pre-research. Device auth for frictionless ScrapeCreators signup.
+- **Per-author cap** -- Max 3 items per author prevents single-voice dominance.
+- **Entity disambiguation** -- Synthesis trusts resolved handles over keyword matches.
+- **Perplexity Sonar Pro as additive source** -- AI-synthesized research with citations via OpenRouter. Opt-in via `INCLUDE_SOURCES=perplexity`. Returns structured narratives that complement social data.
+- **Perplexity Deep Research** -- `--deep-research` flag for exhaustive 50+ citation reports (~$0.90/query). Premium opt-in for serious investigation.
+- **OpenRouter as reasoning provider** -- One OPENROUTER_API_KEY powers planning, reranking, and Perplexity search. Auto-detected after Gemini/OpenAI/xAI.
+- **Parallel AI grounding backend** -- `--web-backend parallel` or auto-detected via PARALLEL_API_KEY.
+- **Grounding in planner** -- Grounding source properly registered in SOURCE_CAPABILITIES instead of force-injected.
+
+### Changed
+
+- YouTube transcript candidate pool widened 3x past music videos to reach talk/review content with captions
+- Reddit comment enrichment sorted by total engagement (upvotes + comments), not just upvotes
+- Polymarket display shows % odds only; dollar volumes removed
+- 852 tests passing
+
+### Contributors
+
+- @j-sperling -- v3 engine architecture, Python pre-research brain
+- @hnshah -- Watchlist features
+
## [2.9.4] - 2026-03-06
### Changed
diff --git a/README.md b/README.md
index 15ddacf..44e0333 100644
--- a/README.md
+++ b/README.md
@@ -1,1255 +1,205 @@
-# /last30days v2.9.5
+# /last30days
-### Claude Code (recommended)
+
+
+**An AI agent-led search engine scored by upvotes, likes, and real money - not editors.**
+
+Claude Code:
```
/plugin marketplace add mvanhorn/last30days-skill
-/plugin install last30days@last30days-skill
```
-[](https://clawhub.ai/skills/last30days-official)
+OpenClaw:
+```
+clawhub install last30days-official
+```
+Zero config. Reddit, HN, Polymarket, and GitHub work immediately. Run it once and the setup wizard unlocks X, YouTube, TikTok, and more in 30 seconds.
+
+---
+
+Reddit upvotes. X likes. YouTube transcripts. TikTok engagement. Polymarket odds backed by real money and insider information. That's millions of people voting with their attention and their wallets every day. /last30days searches all of it in parallel, scores it by what real people actually engage with, and an AI agent judge synthesizes it into one brief.
+
+Google aggregates editors. /last30days searches people.
+
+You can't get this search anywhere else because no single AI has access to all of it. Google search doesn't touch Reddit comments or X posts. ChatGPT has a deal with Reddit but can't search X or TikTok. Gemini has YouTube but not Reddit. Claude has none of them natively. Each platform is a walled garden with its own API, its own tokens, its own auth. But you can bring your own keys and browser sessions, and suddenly an AI agent can search all of them at once, score them against each other, and tell you what actually matters.
+
+That's the unlock. Not one better search engine. A dozen disconnected platforms, bridged by an agent.
+
+```
+/last30days Peter Steinberger
+```
+
+You have a meeting tomorrow. You Google them. You get their LinkedIn from 2023. /last30days gives you what they're actually doing this month: joined OpenAI to work on Codex, fighting Anthropic's ban on third-party agents, shipping 23 PRs at 85% merge rate, building "LobsterOS" for cross-device agent control, and r/ClaudeCode hit 569 upvotes debating whether he's a hero or "insufferable." Scattered across X posts, Reddit threads, YouTube transcripts, and GitHub commits. None of it was on Google.
+
+## Why this exists
+
+I built it to keep up in AI. Everything changes every day and the Reddit and X nerds are always on top of it first. I needed better prompts, and the training data was always months behind what the community had already figured out.
+
+But it turned into something bigger. Now I run it before a sales call to know the last 30 days truth about a business. Before a meeting to read someone's recent tweets and podcast transcripts. Before a Disney World trip to know which rides are closed and what the community says about Genie+. Before I build anything to know what problems people are actually hitting.
+
+If you're meeting with a CEO, have you read all their tweets and YouTube transcripts from the last 30 days? I have.
+
+## Sources, scored by the people
+
+| Source | What the people tell you |
+|--------|--------------------------|
+| **Reddit** | The unfiltered take. Top comments with upvote counts, free via public JSON. The real opinions that Google buries. |
+| **X / Twitter** | The hot take, the expert thread, the breaking reaction. First to know, first to argue. |
+| **YouTube** | The 45-minute deep dive. Full transcripts searched for the 5 quotable sentences that matter. |
+| **TikTok** | The creator reaching 3.6M people with a take you'll never find on Google. |
+| **Instagram Reels** | The influencer perspective with spoken-word transcripts. The visual culture signal. |
+| **Hacker News** | The developer consensus. 825 points, 899 comments. Where technical people actually argue. |
+| **Polymarket** | Not opinions. Odds. Backed by real money. 96% confidence on album sales. 4% on an acquisition. |
+| **GitHub** | For people: PR velocity, top repos by stars, release notes. For topics: issues and discussions. |
+| **Threads** | The post-Twitter text layer. Conversations from creators and brands. |
+| **Pinterest** | Visual discovery. Pins, saves, and comments on products and ideas. |
+| **Bluesky** | The decentralized social layer. AT Protocol posts from the post-Twitter migration. |
+| **Perplexity** | Grounded web search with citations via Sonar Pro. |
+| **Web** | The editorial coverage, the blog comparisons. One signal of many, not the only one. |
+
+Community contributors keep adding more. Truth Social, Xiaohongshu (RED), and others are in the engine with more on the way.
+
+A Reddit thread with 1,500 upvotes is a stronger signal than a blog post nobody read. A TikTok with 3.6M views tells you more about what's culturally relevant than a press release. Polymarket odds backed by $66K in volume are harder to argue with than a pundit's guess.
+
+The synthesis ranks by what real people actually engaged with. Social relevancy, not SEO relevancy.
+
+## What people actually use it for
+
+**Before a meeting.** `/last30days Peter Steinberger` - joined OpenAI's Codex team, fighting Anthropic's ban on third-party agents, 23 PRs merged at 85% merge rate on GitHub, building LobsterOS for cross-device agent control. r/ClaudeCode: "Ever since OpenClaw released, it was widely known that if you run it through anything other than the API, you were gonna get banned eventually" (227 upvotes). That's not on LinkedIn.
+
+**When something drops.** `/last30days Kanye West` - UK blocked his visa, Wireless Festival canceled, sponsors fled. But BULLY debuted #2 on Billboard. Fantano came back from his "Yay sabbatical" to review it (653K views). SoFi Homecoming brought out Lauryn Hill and Travis Scott for 44 songs. Polymarket: "Will Kanye tweet again?" 86% Yes. 23 Reddit threads, 17 YouTube videos, 86K upvotes.
+
+**To compare tools.** `/last30days OpenClaw vs Hermes vs Paperclip` - "These aren't competitors, they're layers." OpenClaw is the executor (351K GitHub stars, live), Hermes is the self-improving brain (31K stars), Paperclip is the org chart (49K stars). Star counts pulled live from the GitHub API, not stale blog posts. Side-by-side table with architecture, memory, security, best-for. Per @IMJustinBrooke: "OpenClaw = Charmander, Hermes = Charizard."
+
+**To understand the world.** `/last30days Iran vs USA` - Day 38 of the war. Trump's Tuesday deadline for Iran to reopen the Strait of Hormuz. Two US warplanes downed. Oil at $126/barrel. The IEA called it "the largest supply disruption in the history of the global oil market." Polymarket: ceasefire by Dec 31 at 74%. 27 X posts, 10 YouTube videos, 20 prediction markets.
+
+**Before a trip.** `/last30days Universal Epic Universe` - Expansion already under construction. "Project 680" permit filed. Fireworks show confirmed by infrastructure but unannounced. Wait times: Mine-Cart Madness averaging 148 minutes. No annual pass yet, and locals are frustrated. Stardust Racers down for refurbishment through April 5.
+
+**To learn something fast.** `/last30days Nano Banana Pro prompting` - JSON-structured prompts are replacing tag soup. @pictsbyai's nested format prevents "concept bleeding." Edit-first workflow beats regeneration. Then it writes you a production prompt using exactly what the community said works.
+
+## What's New in v3
+
+### Intelligent search: the killer feature
+
+The v3 engine doesn't just search for your topic. It figures out *where* to search before the search begins. Type "OpenClaw" and the engine resolves @steipete (Peter Steinberger, the creator), r/openclaw, r/ClaudeCode, and the right YouTube channels and TikTok hashtags - all via a new Python pre-research brain built by [@j-sperling](https://github.com/j-sperling). The old engine searched keywords. The new engine understands your topic first, then searches the right people and communities.
+
+This is why v3 finds content v2 never could. "Paperclip" resolves @dotta. "Dave Morin" resolves @davemorin plus @OpenClaw plus the TWiST podcast. "Peter Steinberger" resolves @steipete on X and steipete on GitHub. Bidirectional: person to company, product to founder, name to GitHub profile. The right subreddits, the right handles, the right hashtags - resolved before a single API call fires.
+
+### Best Takes
+
+Reddit and X people are funny. The old engine buried their best stuff because it scored for relevance, not cleverness. v3 has a second judge that scores every result for humor, wit, and virality alongside the relevance score. Tommy Lloyd's "My Michael Jordan is Steve Kerr" scores low on relevance to "Arizona Basketball" but off the charts on fun. Now every brief ends with a "Best Takes" section - the cleverest one-liners, the most viral quotes, the reactions that make you want to share the research. Built in, not a toggle.
+
+### Cross-source cluster merging
+
+When the same story appears on Reddit, X, and YouTube, v3 merges them into one cluster instead of showing three separate items. Entity-based overlap detection catches matches even when the titles use different words.
+
+### Single-pass comparisons
+
+"CLI vs MCP" used to run three serial passes (12+ minutes). v3 runs one pass with entity-aware subqueries for both sides simultaneously. Same depth, 3 minutes.
+
+### GitHub person-mode
+
+When the topic is a person, the engine switches from keyword search to author-scoped queries. Instead of "who mentioned this name in an issue body," it answers: what are they shipping and where is it landing?
+
+`/last30days Peter Steinberger --github-user=steipete` shows 22 PRs merged across 3 repos at 85% merge rate. Own projects with README summaries, star counts, and top feature requests. Release notes for what shipped this month. The synthesizer weaves it into the narrative alongside X posts and Reddit threads.
+
+### ELI5 mode
+
+Say "eli5 on" after any research run. The synthesis rewrites in plain language. No jargon. Same data, same sources, same citations - just clearer. "Arizona wins by being physical" instead of "Arizona's identity is paint scoring (50%+ shooting, 9th nationally)." Say "eli5 off" to go back.
+
+### Everything else in v3
+
+- **Free Reddit comments.** Public JSON gives you threads + top comments with upvote counts. No API key, no ScrapeCreators. Just works.
+- **YouTube transcripts that actually work.** Widened candidate pool 3x past music videos to reach talk/review content with captions.
+- **Threads, Pinterest, YouTube comments.** Opt-in sources via ScrapeCreators. Set `INCLUDE_SOURCES=tiktok,instagram` and add threads, pinterest, youtube_comments for more.
+- **Perplexity Sonar.** Grounded web search with citations via OpenRouter. Add `OPENROUTER_API_KEY` to unlock.
+- **Polymarket noise filtering.** Common-word disambiguation prevents "Apple" from matching "Will Apple release a car?"
+- **Resilient Reddit.** Timeout budgets and runtime fallback. One slow thread doesn't kill the whole run.
+- **Fun judge v2.** Humor scoring baked into the narrative. Reddit's cleverest one-liners mixed into the synthesis where they fit, not dumped in a separate section.
+- **Polymarket odds, not dollars.** The % odds are the magic. Dollar volumes removed from display.
+- **Per-author cap.** Max 3 items per author prevents any single voice from dominating your brief.
+- **Entity disambiguation.** When the engine resolves handles, the synthesis trusts them. No more Mallorca resorts winning over Washington athletic clubs.
+- **OpenClaw first-class citizen.** Auto-resolve for engine-side pre-research. Device auth for frictionless ScrapeCreators signup.
+- **1,012 tests passing.**
+
+## Install
+
+### Claude Code
+```
+/plugin marketplace add mvanhorn/last30days-skill
+```
+
+### OpenClaw
```bash
clawhub install last30days-official
```
-**The AI world reinvents itself every month. This skill keeps you current.** /last30days researches your topic across Reddit, X, YouTube, and other sources from the last 30 days, finds what the community is actually upvoting, sharing, betting on, and saying on camera, and writes you a grounded narrative with real citations. Whether it's Seedance 2.0 access, paper.design prompts, or the latest Nano Banana Pro techniques, you'll know what people who are paying attention already know.
-
-**New in v2.9.5 — Bluesky, Comparative Mode, and Config Improvements:**
-
-- **Bluesky/AT Protocol** is now a social source. Opt-in via `BSKY_HANDLE` + `BSKY_APP_PASSWORD` (create at bsky.app/settings/app-passwords). Full pipeline: search, score, dedupe, render.
-- **Comparative mode** - ask "X vs Y" (e.g., `/last30 Claude Code vs Codex`) and get 3 parallel research passes with a side-by-side comparison: strengths, weaknesses, head-to-head table, and a data-driven verdict.
-- **Per-project .env config** - drop a `.claude/last30days.env` in your project root for per-project API keys.
-- **SessionStart config check** - validates your config automatically when a Claude Code session starts.
-- **Expanded test coverage** - 455+ tests across all modules.
-
-**New in v2.9.1 — Auto-save to ~/Documents/Last30Days/:** Every run now saves the complete briefing as a topic-named `.md` file to your Documents folder. Build a personal research library automatically. Inspired by [@devin_explores](https://x.com/devin_explores).
-
-**New in v2.9 — ScrapeCreators Reddit + Top Comments + Smart Discovery:**
-
-Reddit now runs on [ScrapeCreators](https://scrapecreators.com) by default — one `SCRAPECREATORS_API_KEY` covers Reddit, TikTok, and Instagram (3 sources, 1 key). Smart subreddit discovery finds the right communities automatically, and top comments are elevated with a 10% scoring weight and `💬` display with upvote counts. [Details below.](#whats-new-in-v29)
-
-**New in v2.8 — Instagram Reels + ScrapeCreators:**
-
-Instagram Reels is now the 8th signal source. TikTok and Instagram both run on ScrapeCreators — one API key covers both. [Details below.](#whats-new-in-v28)
-
-**New in V2.5 - dramatically better results:**
-
-1. **Polymarket prediction markets and Hacker News.** See what people are betting real money on and what the technical community is actually discussing. Search "Arizona Basketball" and get NCAA Tournament championship odds (Arizona: 12%), #1 seed probability (88%), and Big 12 title race (69%) - pulled from 50+ open markets across 10 events, not just Reddit opinions. Search "Iran War" and get 15 live prediction markets with strike probabilities, regime change bets, and war declaration odds. Two-pass query expansion with tag-based domain bridging discovers markets where your topic is an outcome buried inside a broader event, not just a title keyword match. HN stories, Show HN posts, and comment insights are scored by points + comments and participate in cross-source convergence detection.
-2. **Multi-signal quality-ranked relevance scoring.** Every result across all six sources runs through a composite scoring pipeline: bidirectional text similarity with synonym expansion and token overlap, engagement velocity normalization, source authority weighting, cross-platform convergence detection via hybrid trigram-token Jaccard similarity, and temporal recency decay. Polymarket markets are ranked on a 5-factor weighted composite - text relevance (30%), 24-hour volume (30%), liquidity depth (15%), price movement velocity (15%), and outcome competitiveness (10%) - with outcome-aware scoring that matches your topic against individual market positions, not just event titles. A blinded evaluation scored v2.5 at 4.38/5.0 vs 3.73/5.0 for v1 across 5 test topics.
-3. **X handle resolution.** Search "Dor Brothers" and the skill resolves their handle (@thedorbrothers), then searches their posts directly - finding their 5,600-like viral tweet that keyword search missed entirely. Works for people, brands, products, and tools.
-
-**New in V2.1:** Open-class skill with watchlists, YouTube transcripts as a source, works in OpenAI Codex CLI. [Full changelog below.](#whats-new-in-v21)
-
-**New in V2:** Smarter query construction, two-phase supplemental search, free X search via bundled Bird client, `--days=N` flag, automatic model fallback. [Full changelog below.](#whats-new-in-v2)
-
-**The tradeoff:** /last30days finds a lot of content but takes 2-8 minutes depending on how niche your topic is. Up to 10 sources searched in parallel, results scored, deduplicated, and synthesized. We think the depth is worth the wait, but `--quick` mode is there if you need speed over thoroughness.
-
-**Best for prompt research**: discover what prompting techniques actually work for any tool (ChatGPT, Midjourney, Claude, Paper, etc.) by learning from real community discussions and best practices.
-
-**But also great for anything trending**: music, culture, news, product recommendations, viral trends, or any question where "what are people saying right now?" matters.
-
-## Installation
-
-### Claude Code Plugin (recommended)
-```
-/plugin marketplace add mvanhorn/last30days-skill
-/plugin install last30days@last30days-skill
-```
-
-### Gemini CLI
-```bash
-gemini extensions install https://github.com/mvanhorn/last30days-skill.git
-```
-
-### Manual Install (Claude Code / Codex)
+### Manual
```bash
git clone https://github.com/mvanhorn/last30days-skill.git ~/.claude/skills/last30days
```
-That's it. Reddit, Hacker News, and Polymarket work immediately with zero configuration. Run `/last30days` to unlock more sources.
+Reddit (with comments), Hacker News, Polymarket, and GitHub work immediately. Zero configuration. Run `/last30days` once and the setup wizard unlocks more sources in 30 seconds.
+
+## Bring your own keys
+
+These platforms don't have relationships with each other. X doesn't know what Reddit thinks. YouTube doesn't see TikTok. But you can bring your own API keys and browser tokens, and suddenly you have access to all of them at once.
+
+| Sources | What you need | Cost |
+|---------|---------------|------|
+| Reddit (with comments) + HN + Polymarket + GitHub | Nothing | Free |
+| X / Twitter | Log into x.com in any browser | Free |
+| YouTube | `brew install yt-dlp` | Free |
+| Bluesky | App password from bsky.app | Free |
+| TikTok + Instagram + Threads + Pinterest + YouTube comments | ScrapeCreators key | 10,000 free calls |
+| Perplexity Sonar | OpenRouter key | Pay as you go |
+| Web search | Brave Search key | 2,000 free queries/month |
+
+## How it works
+
+1. **You type a topic.** Person, company, product, technology, "X vs Y." Anything.
+2. **The agent resolves who matters.** Finds X handles (including founders), GitHub repos, subreddits, TikTok hashtags, YouTube channels. For "Kanye West" it knows r/hiphopheads, @kanyewest, and "bully review" on YouTube. For "OpenClaw" it resolves openclaw/openclaw on GitHub and fetches live star counts.
+3. **All sources searched in parallel.** Multi-query expansion. Results scored by engagement, relevance, freshness.
+4. **The depth nobody else has.** Full YouTube transcripts from reaction videos. Top Reddit comments with upvote counts. TikTok captions. Polymarket odds. Not just titles and links.
+5. **Same story, merged.** Wireless Festival announced on Reddit, discussed on X, ticket prices on TikTok = one cluster, not three separate items.
+6. **Synthesized into one brief.** Grounded in specific data. Cited by source. Ranked by what people actually engage with. Not "here's what I found." It's "here's what matters."
+7. **Then it becomes your expert.** After one run, your Claude session knows everything the community knows. Ask follow-up questions. Have it write prompts, draft emails, plan trips, architect systems - all grounded in what's real right now.
+
+## What people are saying
+
+> "I found a Claude Code skill that researches any topic across Reddit, X, YouTube, and HN from the last 30 days. Then writes the prompts for you. I've been manually searching Reddit and X for research before every piece of content I write. Tab by tab. Thread by thread. That's the part that takes 90 minutes. This eliminates it." -@itsjasonai
+
+> "This one skill replaced my entire research workflow. You give it a topic, it scrapes Reddit, X, and the web for what people are actually talking about. Not old blog posts. Real conversations from the last 30 days." -@itswilsoncharles
+
+> "5 of the 10 trending repos on GitHub today are Claude tools. #1: mvanhorn/last30days-skill" -@yieldhunter95
+
+## Open source
+
+MIT license. No tracking. No analytics. Your research stays on your machine. 1,012 tests.
+
+Built with Python 3.12+, yt-dlp, Node.js (vendored Bird client for X search), and ScrapeCreators API. v3 engine architecture by [@j-sperling](https://github.com/j-sperling).
+
+See [CHANGELOG.md](CHANGELOG.md) for version history.
+
+## Star History
+
+
+
+
+
+
+
+
---
-## Setup: Progressive Source Unlocking
-
-Start using /last30days immediately. Add sources when you want better results.
-
-### 1. Zero Config (3 sources) — Just install
-
-Reddit (public JSON), Hacker News, and Polymarket work out of the box. No API keys, no configuration.
-
-### 2. Run the setup wizard (5+ sources)
-
-```
-/last30days setup
-```
-
-The setup wizard automatically extracts X/Twitter login cookies from your browsers (Chrome, Firefox, Safari) and checks for yt-dlp. Takes about 30 seconds. Your cookies stay in memory and are never saved to disk.
-
-### 3. Add Exa (FREE — semantic web search)
-
-Register at [exa.ai](https://exa.ai) for 1,000 free searches/month, no credit card required.
-
-```bash
-# Add to ~/.config/last30days/.env
-EXA_API_KEY=...
-```
-
-### 4. Add ScrapeCreators (RECOMMENDED — Reddit comments + TikTok + Instagram)
-
-**This is the single most impactful upgrade.** Reddit comments are often the highest-value research content — top-voted replies with real insights. ScrapeCreators unlocks comment enrichment plus TikTok and Instagram. Register at [scrapecreators.com](https://scrapecreators.com) for 100 free API calls (no credit card required). After that, pay-as-you-go. last30days receives no money from any API provider — no referrals, no kickbacks.
-
-```bash
-# Add to ~/.config/last30days/.env
-SCRAPECREATORS_API_KEY=...
-```
-
-### 5. Add Bluesky (FREE — app password)
-
-Create an app password at [bsky.app/settings/app-passwords](https://bsky.app/settings/app-passwords).
-
-```bash
-# Add to ~/.config/last30days/.env
-BSKY_HANDLE=you.bsky.social
-BSKY_APP_PASSWORD=xxxx-xxxx-xxxx
-```
-
-### 6. Optional paid web search backends
-
-```bash
-# Add to ~/.config/last30days/.env
-PARALLEL_API_KEY=... # Parallel AI (preferred — LLM-optimized results)
-BRAVE_API_KEY=... # Brave Search (free tier: 2,000 queries/month)
-OPENROUTER_API_KEY=... # OpenRouter/Perplexity Sonar Pro
-```
-
----
-
-### Do I need API keys?
-
-| Source | Free Method | API Key | Do you need the API key? |
-|--------|------------|---------|--------------------------|
-| Reddit | Public JSON (always works) | ScrapeCreators | **Yes, strongly recommended.** Unlocks top comments — often the most valuable content. |
-| X/Twitter | Browser cookies (auto-extracted) | xAI API key (`XAI_API_KEY`) | **No.** Cookies give identical quality. The setup wizard handles this. |
-| YouTube | yt-dlp (`brew install yt-dlp`) | N/A | **No API key exists.** Install yt-dlp for search; transcripts work without it. |
-| Hacker News | Always free | N/A | **No.** Always works, no config needed. |
-| Polymarket | Always free | N/A | **No.** Always works, no config needed. |
-| Web search | N/A | Exa (`EXA_API_KEY`) | **Optional.** 1,000 free searches/month at exa.ai. |
-| Bluesky | Free app password | N/A | **Optional.** Free app password at bsky.app. |
-| TikTok | N/A | ScrapeCreators | **Optional.** Included with ScrapeCreators key. |
-| Instagram | N/A | ScrapeCreators | **Optional.** Included with ScrapeCreators key. |
-| Truth Social | Browser cookies | N/A | **Optional.** Auto-extracted if logged in. |
-
-*last30days receives no money from any API provider — no referrals, no kickbacks.*
-
----
-
-### Config file locations
-
-For project-specific overrides, create `.claude/last30days.env` in the repo root. It overrides the global `~/.config/last30days/.env`.
-
-```bash
-# Global config
-mkdir -p ~/.config/last30days
-chmod 600 ~/.config/last30days/.env
-
-# Project-specific config (optional)
-# .claude/last30days.env
-```
-
-Check source availability: `python3 scripts/last30days.py --diagnose`
-
-### Codex CLI
-
-This skill also works in OpenAI Codex CLI. Install to the Codex skills directory instead:
-
-```bash
-git clone https://github.com/mvanhorn/last30days-skill.git ~/.agents/skills/last30days
-```
-
-Same SKILL.md, same Python engine, same scripts. The `agents/openai.yaml` provides Codex-specific discovery metadata. Invoke with `$last30days` or through the `/skills` menu.
-
-### Open Variant (Watchlist + Briefings) - For Always-On Bots
-
-**Designed for [Open Claw](https://github.com/openclaw/openclaw) and similar always-on AI environments.** Add your competitors, specific people, or any topic to a watchlist. When paired with a cron job or always-on bot, /last30days re-researches them on a schedule and accumulates findings in a local SQLite database. Ask for a briefing anytime.
-
-**Important:** The watchlist stores schedules as metadata, but nothing triggers runs automatically. You need an external scheduler (cron, launchd, or an always-on bot like Open Claw) to call `watchlist.py run-all` on a timer. In plain Claude Code, you can run `watch run-one` and `watch run-all` manually, but there's no background scheduling.
-
-```bash
-# Enable the open variant
-cp variants/open/SKILL.md ~/.claude/skills/last30days/SKILL.md
-
-# Add topics to your watchlist
-last30 watch my biggest competitor every week
-last30 watch Peter Steinberger every 30 days
-last30 watch AI video tools monthly
-last30 Y Combinator hot companies end of April and end of September
-
-# Run research manually (or let your bot's cron handle it)
-last30 run all my watched topics
-
-# Search accumulated knowledge
-last30 what have you found about AI video?
-```
-
-The open variant adds four modes on top of one-shot research:
-
-- **Watchlist** - Track topics with `watch add "topic"`, run manually or via cron
-- **Briefings** - Daily/weekly digests synthesized from accumulated findings
-- **History** - Query and search your research database with full-text search
-- **Native web search** - Built-in web search backends (Parallel AI, Brave, OpenRouter) run alongside Reddit/X/YouTube
-
-Both variants use the same Python engine and scripts directory. The open variant adds command routing (`watch`, `briefing`, `history`) and references mode-specific instruction files.
-
-## Usage
-
-```
-/last30days [topic]
-/last30days [topic] for [tool]
-```
-
-Examples:
-- `/last30days prompting techniques for ChatGPT for legal questions`
-- `/last30days iOS app mockups for Nano Banana Pro`
-- `/last30days What are the best rap songs lately`
-- `/last30days remotion animations for Claude Code`
-
-## What It Does
-
-1. **Researches** - Scans Reddit, X, YouTube, and other sources for discussions from the last 30 days
-2. **Synthesizes** - Identifies patterns, best practices, and what actually works
-3. **Delivers** - Either writes copy-paste-ready prompts for your target tool, or gives you a curated expert-level answer
-
-### Use it for:
-- **Prompt research** - "What prompting techniques work for legal questions in ChatGPT?"
-- **Tool best practices** - "How are people using Remotion with Claude Code?"
-- **Trend discovery** - "What are the best rap songs right now?"
-- **Product research** - "What do people think of the new M4 MacBook?"
-- **Viral content** - "What's the dog-as-human trend on ChatGPT?"
-
----
-
-## Example: Nano Banana Pro Prompting (Image Generation)
-
-**Query:** `/last30days nano banana pro prompting`
-
-**Research Output:**
-> JSON prompting is the dominant technique - The X community overwhelmingly uses structured JSON prompts rather than plain text. Creators like @Xmira_belle, @Cicily_aura, @RubenSalvo_, and @mahivisuals all share prompts in JSON format with nested objects for metadata, subject descriptions, lighting, camera specs, and scene composition. @realdigitaldao made a static ad "in less than 5 min" using a simple JSON prompt.
->
-> Prompt adherence is Nano Banana Pro's killer advantage - JSON prompts hit 92% precision for color accuracy, lighting ratios, and composition vs ~68% for natural language prompts, per fofr.ai. @artingent directly compared it to GPT Image 1.5 and noted Nano Banana Pro has higher prompt adherence - "GPT image 1.5 ignored the Top Down instruction."
->
-> Stop writing "tag soup" - act like a Creative Director - The biggest shift is away from comma-separated keyword lists toward natural language descriptions with clear structure. The ICS Framework (Image type, Content, Style) and the 5-element formula (subject, composition, action, setting, style) are the two dominant approaches, per r/nanobanana2pro.
-
-**Key patterns discovered:**
-1. JSON > plain text - Structure prompts as JSON objects with keys for subject, composition, lighting, camera, style - per @Xmira_belle, @Cicily_aura
-2. The 5-element formula - Subject + Composition + Action + Setting + Style - per r/nanobanana2pro
-3. Separate subjects into distinct objects - Multi-character scenes need each person in their own JSON block to prevent "concept bleeding" - per fofr.ai
-4. Use camera/lens metadata - Aperture, focal length, ISO, film stock as JSON keys for hidden photorealism control
-5. 14 reference images max - Feed up to 14 reference images for brand/character consistency - per Google DeepMind
-
-**Research Stats:** 8 Reddit threads (455 upvotes) + 31 X posts (183 likes, 68 reposts) from r/nanobanana2pro, r/promptingmagic, @Xmira_belle (106 likes), @Cicily_aura (45 likes)
-
-**Then asked:** "make a mock-up of an app for moms who swim"
-
-**Generated Prompt:**
-
-```json
-{
- "image_type": "UI mockup",
- "device": {
- "frame": "iPhone 16 Pro",
- "display": "realistic screen with subtle reflection",
- "orientation": "portrait"
- },
- "app_concept": "SwimMom - fitness and community app for mothers who swim",
- "screen": "home dashboard",
- "design_system": {
- "style": "iOS 18 native",
- "corners": "rounded, 16px radius",
- "shadows": "soft drop shadows, subtle depth",
- "spacing": "8pt grid, generous whitespace"
- },
- "color_palette": {
- "primary": "calming aqua blue (#4ECDC4)",
- "secondary": "warm coral (#FF6B6B)",
- "background": "clean white with soft blue gradient header"
- },
- "layout": {
- "header": "greeting 'Good morning, Sarah' with wave icon, profile avatar top right",
- "hero_card": "today's swim stats - laps, calories, time - with circular progress ring",
- "section_2": "horizontal scroll of upcoming mom swim meetups with location pills",
- "section_3": "two cards - 'My Goals' with streak counter, 'Find a Pool' with map pin",
- "bottom_nav": "5 icons - Home (active), Workouts, Community, Schedule, Profile"
- },
- "visual_details": {
- "illustrations": "friendly, minimal line art of swimming figure",
- "typography": "SF Pro Display, clean hierarchy",
- "mood": "energizing yet calm, supportive community feel"
- }
-}
-```
-
-**The Result:**
-
-
-
-
-
-This example shows /last30days learning that **JSON prompts dominate** the Nano Banana Pro community (not plain text), then applying that format to generate an iOS mockup in one shot. The research came from real X creators and Reddit threads - techniques you wouldn't find in official docs.
-
----
-
-## Example: Kanye West (Current Events)
-
-**Query:** `/last30days kanye west`
-
-**Research Output:**
-> **The Apology Arc** - On January 26, Ye published a full-page letter in The Wall Street Journal titled "To Those I've Hurt," apologizing for antisemitic comments and attributing it to a four-month manic episode, an undiagnosed brain injury from his 2002 car crash, and bipolar type-1 disorder. The ADL called it "long overdue" but said it "doesn't automatically undo his long history of antisemitism." Then on February 9, he posted "I love Hitler now what" - blowing up the goodwill almost immediately, per The Mirror. Fans on X are split on whether the apology was genuine or just pre-album PR, per @dekreationz's viral post (8.6K likes).
->
-> **BULLY Album** - Twelfth studio album set for March 20 via YZY and Gamma. 13 tracks with Peso Pluma, Playboi Carti, and Ty Dolla Sign features. Sonically it leans toward 808s/MBDTF territory - more singing than rapping. Ye has reportedly removed all deepfaked/AI-generated vocals from the final version, per Rolling Stone.
->
-> **Bianca Censori Breaks Silence** - In a Vanity Fair tell-all, Bianca spoke publicly for the first time about their marriage, saying "This year was a lot like doing CPR for months." She confirmed her outfits are her own choice and revealed their relationship started when she was head of architecture at Yeezy, per TMZ.
->
-> **Kim/Instagram Drama** - A screenshot of Ye allegedly commenting on Kim's Complex magazine post went mega-viral on X. Multiple Grok fact-checks confirmed it's likely fabricated, but @dekreationz's post racked up nearly 9K likes regardless - showing the Ye/Kim narrative still drives massive engagement.
-
-**Key patterns from the research:**
-1. Apology-then-relapse cycle - The WSJ letter was immediately undercut by new inflammatory posts, making sincerity the dominant debate, per Washington Post
-2. Album as PR vehicle - Skeptics across X tie the apology's timing directly to BULLY's March 20 release, per @trustGod_007
-3. Bianca as her own story - Transitioning from "Kanye's wife" to individual public figure through Vanity Fair
-4. Fake screenshot virality - Fabricated Ye content still goes massively viral before debunking, per @dekreationz
-
-**Research Stats:** 3 Reddit threads (193 upvotes, 192 comments) + 33 X posts (9,295 likes, 279 reposts) from r/Kanye, r/hiphopheads, @dekreationz (8,684 likes), @trustGod_007
-
-**Follow-up suggestions offered:**
-> I'm now an expert on Kanye West. Some things I can help with:
-> - What's the real story behind the apology letter - genuine accountability or pre-album PR?
-> - Deep dive on BULLY: tracklist, Gamma deal, and removal of AI vocals
-> - The Bianca Censori Vanity Fair interview - what she actually said vs. the tabloid headlines
-
-This example shows /last30days as a **current events research tool** - no prompting, no target tool, just "what's happening with Kanye?" and you get a synthesized expert briefing pulled from 33 X posts and 3 Reddit threads with real engagement data.
-
----
-
-## Example: Anthropic Odds (Prediction Markets)
-
-**Query:** `/last30days anthropic odds`
-
-**Research Output:**
-> **Pentagon standoff is THE story right now** - Defense Secretary Hegseth gave Anthropic a Friday deadline to drop AI guardrails for military use or face blacklisting via the Defense Production Act, per CBS, CNN, Bloomberg, and a wave of X posts from @The__GDD, @trendy_tech_, and @jimkaskade. The trigger was Claude's use during the Maduro capture operation in January. Anthropic CEO Dario Amodei insists AI-controlled weapons and mass surveillance are lines the company won't cross. Polymarket traders put the ban odds at just 22%, signaling they think it's posturing, per @Lolipeterh.
->
-> **Prediction markets love Anthropic's tech, skeptical on IPO** - Polymarket gives Anthropic a 98% chance of having the best AI model at end of February and 61% for March (Google at 22%, OpenAI at 10%). Claude 4.6 is dominating. But the IPO picture is murkier: @predictheory flagged that Anthropic IPO-first odds on Kalshi "fell through the floor, ~70% down to the low teens in one move." Polymarket has Anthropic at 64% to IPO before OpenAI, but 95% NO on an IPO by June 2026. Meanwhile, 87% odds Anthropic hits $500B+ valuation this year - current valuation is $380B after a $30B raise led by GIC and Coatue, per Fortune.
->
-> **Claude FrontierMath odds surging** - Polymarket's "Will Claude score 50% on FrontierMath?" market jumped 28% today to 48% YES. This is a live bet on whether Claude can crack elite-level math benchmarks by June 30.
-
-**Key patterns from the research:**
-1. Pentagon standoff as posturing - Polymarket gives only 22% chance of actual ban, money says it's negotiation theater
-2. Model dominance vs IPO uncertainty - 98% best model, but IPO timing is wide open
-3. FrontierMath as a live benchmark bet - real money tracking Claude's capability trajectory
-4. Big money piling in - Dan Sundheim's D1 Capital, Amazon's quiet bet, $380B valuation
-
-**Research Stats:** 25 X posts (218 likes) + 13 YouTube videos (719K views) + 6 HN stories (48 points) + 11 Polymarket markets (Best model Feb: 98%, March: 61%, IPO first: 64%, $500B+ val: 87%, FrontierMath 50%: 48%)
-
-This example shows /last30days as a **prediction market intelligence tool** - two words ("anthropic odds") and you get 11 live Polymarket positions spanning model benchmarks, IPO timing, valuation milestones, and the Pentagon standoff, all synthesized with X commentary, YouTube analysis, and HN discussion. The two-pass query expansion found markets where "Anthropic" is an outcome inside broader "best AI model" and "AI company IPO" events.
-
----
-
-## Example: Vibe Motion (Brand New AI Tool)
-
-**Query:** `/last30days higgsfield motion vibe motion prompting`
-
-**Research Output:**
-> **Vibe Motion just launched (Feb 5-6, 2026) and it's blowing up** - Higgsfield dropped a Claude-powered motion design tool that generates editable motion graphics from plain-English prompts. It's the first AI video tool with actual reasoning, per @Hartdrawss, and @rezkhere called it out as something "no other AI can do." Linus Ekenstam said it "broke his brain" - work that took hours in After Effects now takes minutes.
->
-> **It generates code, not pixels** - Unlike other AI video tools, Vibe Motion produces actual animation code (via Remotion under the hood), not hallucinated video. Text never breaks, edits stay consistent, and you get a controllable, editable asset, per @Totinhiiio.
->
-> **Honest reviews: promising but not polished yet** - Chase Jarvis found results "okay" but noted 5+ minute render times, credit burn on iteration (8-60 credits per gen, $9 plan = ~150 credits), and that basic results are achievable faster with Canva. His verdict: "not quite ready for prime time" but the underlying tech shows significant potential.
-
-**Key patterns discovered:**
-1. Describe structure, not effects - Focus on timing, hierarchy, typography, and flow rather than expressive visual storytelling, per Higgsfield's official guide
-2. Upload your actual assets first - Brand logos, product images, PDFs give the AI context to build around YOUR files, not generic placeholders
-3. Use presets as starting points - Select a format (Infographics, Text Animation, Posters) before writing your prompt
-4. Keep prompts conversational and direct - Short commands > long descriptions. "Create a kinetic typography intro" beats a paragraph of specs, per Segmind
-5. Budget for iteration - Each generation burns credits, so get your prompt right before hitting generate, per Chase Jarvis
-
-**Research Stats:** 10 Reddit threads + 30 X posts from @rezkhere, @Hartdrawss, @Totinhiiio + 14 web pages (Higgsfield blog, Chase Jarvis, SiliconANGLE, Segmind)
-
-**Follow-up suggestions offered:**
-> I'm now an expert on Higgsfield Vibe Motion prompting. What do you want to make?
-> - A branded infographic animation with your company metrics animating in
-> - A kinetic typography intro/bumper for a YouTube series
-> - A logo reveal animation with particle effects and custom brand colors
-
-This example shows /last30days researching **a tool that launched 2 days ago**. No blog roundups existed yet - the research came from X creators and Reddit threads reacting in real-time. You get prompting advice from the people actually using it, not SEO articles written after the fact.
-
----
-
-## Example: Legal Prompting (Hallucination Prevention)
-
-**Query:** `/last30days prompting techniques for chatgpt for legal questions`
-
-**Research Output:**
-> The dominant theme is hallucination prevention - multiple sources discuss lawyers being fined or embarrassed for submitting fake case citations that ChatGPT fabricated. Key prompting strategies include: (1) using Deep Research mode or grounding tools with uploaded primary sources, (2) building "Hallucination Prevention Systems" into prompts that force epistemic honesty, (3) prompting for procedural questions rather than case law lookups, and (4) framing outputs as "issue-spotting" rather than legal advice. A major warning: chat logs are discoverable in litigation.
-
-**Key patterns discovered:**
-1. Hallucination prevention clauses - "If you don't know a case citation, say so rather than inventing one"
-2. Role assignment - assign ChatGPT a specific persona (paralegal, issue-spotter) to constrain outputs
-3. Structured output requirements - request specific sections (risks, key terms, questions for counsel)
-4. Epistemic humility enforcement - "acknowledge uncertainty" and "flag when you're unsure"
-5. Scope limitation - focus on procedural questions, not case law research
-
-**Then asked:** "I have a squatter in my house in San Francisco, what are my options to get them out?"
-
-**Generated Prompt:**
-
-```
-Role: You are a legal research assistant helping a property owner understand
-their options. You are NOT providing legal advice - you are helping me
-identify issues, understand procedures, and prepare informed questions for an
-attorney.
-
-Situation: I own a residential property in San Francisco, California. Someone
-is currently occupying the property without my permission or any lease
-agreement (a squatter). I need to understand my legal options for removing them.
-
-Your task: Provide a structured analysis covering:
-
-1. California/SF Legal Framework - What laws govern this situation? (Note: San
-Francisco has strong tenant protections - address how these apply or don't
-apply to squatters vs. tenants)
-2. Key Distinctions - Help me understand: squatter vs. trespasser vs. holdover
-tenant. How does the length of occupation affect my options?
-3. Available Legal Remedies - What are my procedural options? (unlawful
-detainer, police involvement, etc.) What is the typical process and timeline?
-4. Critical Mistakes to Avoid - What actions could expose me to liability?
-(self-help eviction, utility shutoffs, etc.)
-5. Questions I Should Ask an Attorney - Based on this analysis, what specific
-questions should I bring to a California real estate attorney?
-
-Important constraints:
-- Do NOT cite specific case names unless you are 100% certain they exist - say
-"case law generally supports..." instead
-- Flag any areas where you're uncertain
-- Note where San Francisco local ordinances may differ from California state law
-- This is for informational purposes to prepare for a legal consultation
-```
-
-**The Result:** A comprehensive 2,500+ word analysis covering California's unlawful detainer process, SF Rent Ordinance nuances, trespasser vs. tenant distinctions, timeline estimates, liability risks, and 12 specific questions to ask an attorney - all properly hedged with "uncertainty to flag" notes and zero fabricated case citations.
-
-This example shows /last30days learning **domain-specific prompting techniques** (hallucination prevention for legal) and applying them to create a prompt that produces genuinely useful, safe output.
-
----
-
-## Example: ClawdBot Use Cases (Community Research)
-
-**Query:** `/last30days best clawdbot use cases`
-
-**Research Output:**
-> ClawdBot is a self-hosted AI assistant that runs on your devices and connects via messaging apps (Telegram, WhatsApp, Discord, Slack, iMessage). Unlike ChatGPT/Claude, it has persistent memory and can proactively message you. The killer feature is its 24/7 autonomous operation - it handles multi-day tasks, monitors for replies, and initiates contact.
-
-**Most mentioned use cases:**
-
-🏆 Top 5 by engagement:
-
-1. **Email/Calendar automation** - mentioned 8x+ (r/selfhosted, @dreetje, @danpeguine, MacStories)
- - Morning briefings, spam filtering, auto-replies, recruiter declines
-2. **Task management via chat** - mentioned 6x (r/LocalLLaMA, @danpeguine, multiple X posts)
- - Todoist integration, importance scoring, time-blocking tasks
-3. **Overnight coding agent** - mentioned 5x (@bffmike 225 likes, r/LocalLLaMA)
- - "Drove my coding agents from 12:30-7am while I snoozed"
- - Pulls repos, runs tests, commits if clean
-4. **Smart home + life admin** - mentioned 5x (r/selfhosted, VelvetShark)
- - WHOOP health data, Philips Hue, HomeAssistant, SSH to homelab
-5. **Browser automation** - mentioned 4x (@steipete 243 likes, MacStories)
- - Car negotiation via email/PDF, restaurant reservations via ElevenLabs calls
-
-**Notable single mentions:**
-- Custom meditation generation with TTS + ambient audio
-- Built a full website (Stumblereads.com) from phone while putting baby to sleep
-- Ray-Ban Meta glasses integration
-- WhatsApp as control interface for everything
-
-**Research Stats:** 9 Reddit threads (161 upvotes, 95 comments) + 19 X posts (2,018 likes, 96 reposts) from r/LocalLLaMA, r/selfhosted, @steipete, @danpeguine, @dreetje
-
-This example shows /last30days as a **product research tool** - discovering real-world use cases and community sentiment for any tool or product, complete with engagement metrics that tell you what people actually care about.
-
----
-
-## Example: ClawdBot Setup (Installation Guide)
-
-**Query:** `/last30days how to best setup clawdbot`
-
-**Research Output:**
-> ClawdBot setup varies by deployment style - Docker is the most recommended path, but users are also deploying via bare metal and cloud VMs. The community has converged on key best practices: use the setup wizard for initial config, start with conservative hardware (you can scale up), and configure authentication before exposing any endpoints.
-
-**🛠️ Setup patterns by engagement:**
-
-| Pattern | Mentions | Key Sources |
-|---------|----------|-------------|
-| Docker Compose deployment | 6x | r/selfhosted, r/LocalLLaMA |
-| Setup wizard (interactive config) | 5x | @dreetje, documentation |
-| API key management | 4x | r/selfhosted, security threads |
-| Hardware requirements | 4x | r/LocalLLaMA (GPU discussions) |
-| Reverse proxy (Caddy/nginx) | 3x | r/selfhosted |
-
-**Key setup insights from community:**
-
-1. **Use the setup wizard** - Don't manually edit config files first; the wizard handles 90% of initial setup
-2. **Start small on hardware** - 8GB RAM minimum, but 16GB+ recommended if running local models
-3. **Docker > bare metal** - Easier updates, better isolation, community-tested compose files
-4. **Secure before exposing** - Set up authentication before opening ports; ClawdBot has built-in auth
-5. **Cost management** - Set API rate limits early; one user reported $200 overnight bill from runaway tasks
-
-**Quick-start commands (community-verified):**
-
-```bash
-# Clone and setup
-git clone https://github.com/clawdbot/clawdbot.git
-cd clawdbot
-
-# Run setup wizard (recommended)
-./setup.sh
-
-# Or Docker Compose (after config)
-docker compose up -d
-```
-
-**Common gotchas mentioned:**
-- Don't forget to set `CLAWDBOT_API_KEY` before first run
-- Telegram bot token needs BotFather setup first
-- If using local models, ensure CUDA drivers are installed
-
-**Research Stats:** 8 Reddit threads (128 upvotes) + 22 X posts (24,000+ likes) from r/selfhosted, r/LocalLLaMA, @dreetje, @steipete
-
-This example shows /last30days as a **setup guide aggregator** - pulling together scattered installation advice, gotchas, and best practices from real users who've already solved the problems you're about to encounter.
-
----
-
-## Example: Top Claude Code Skills (Recommendations)
-
-**Query:** `/last30days top claude code skills`
-
-**Research Output:**
-> The Claude Code skills ecosystem has exploded with marketplaces, curated lists, and viral skill announcements. The Remotion video skill got 17.3K likes on X. SkillsMP emerged as a marketplace with 60-87K+ skills. Multiple GitHub repos (awesome-claude-skills, Superpowers) are actively curated.
-
-**🏆 Most mentioned skills/resources:**
-
-| Rank | Skill/Resource | Mentions | Sources | Engagement |
-|------|----------------|----------|---------|------------|
-| 1 | Remotion skill | 4x | X (@Remotion, @joshua_xu_), web | 17.3K likes, video creation |
-| 2 | SkillsMP marketplace | 5x | X (@milesdeutscher, @rexan_wong), web | 60-87K+ skills directory |
-| 3 | awesome-claude-skills (GitHub) | 4x | Web (travisvn, ComposioHQ repos) | Multiple curated lists |
-| 4 | Superpowers | 3x | Web, GitHub | 27.9K stars |
-| 5 | HeyGen avatar skill | 2x | X (@joshua_xu_), web | 736 likes, AI avatars |
-| 6 | Trail of Bits Security Skills | 2x | Web | CodeQL/Semgrep auditing |
-| 7 | Claude Command Suite | 2x | GitHub, web | 148+ commands, 54 agents |
-| 8 | MCP Builder | 2x | Web | Build MCP servers |
-| 9 | Test-Driven Development | 2x | Web, skill guides | Pre-implementation testing |
-| 10 | Systematic Debugging | 2x | Web | Root cause analysis |
-
-**Notable single mentions:** UI/UX Pro Max (16.9K stars), SuperClaude framework, Compound Engineering Plugin, docx/pdf/pptx document skills, Nano-Banana, Connect (1000+ app integrations)
-
-**Marketer skill stack** (from @vibecode_edu): Slack, Email Knowledge, Nano-Banana, Notion, Skill Creator, Twitter post, Typefully
-
-**Key insight from Reddit:** Keep CLAUDE.md short (~1K context), use Skills for "context on demand", and ensure skill descriptions contain your trigger keywords.
-
-**Research Stats:** 4 Reddit threads (238 upvotes, 156 comments) + 15 X posts (28K+ likes, 2.8K reposts) from r/ClaudeCode, r/ClaudeAI, @Remotion, @milesdeutscher
-
-This example shows /last30days as a **discovery tool** - finding the most popular resources in a rapidly evolving ecosystem by tracking what the community is actually sharing and upvoting.
-
----
-
-## Example: Discover Viral Trends (Dog as Human)
-
-**Query:** `/last30days using ChatGPT to make images of dogs`
-
-**Research Output:**
-> The Reddit community is obsessed with the "dog as human" trend - uploading photos of their dogs and asking ChatGPT to show what they'd look like as a person (threads with 600-900+ upvotes). Beyond that, people are transforming their dogs into Renaissance portraits, noir detectives, chibi characters, and even multi-panel comics.
-
-**Key patterns discovered:**
-1. Upload reference photo first - ChatGPT works best with your actual dog's image
-2. "As a human" transformation - The viral trend
-3. Style/era transformations - Renaissance, noir, chibi, cartoon
-4. Scene placement - French bistro, prison yard, detective office
-5. Specific visual details - Lighting, background, expression, accessories
-
-**Then asked:** "what would my dog look like as a human prompt"
-
-**Generated Prompt:**
-
-```
-Look at this photo of my dog. Create an image showing what they would look like
-as a human person. Keep their exact personality, energy, and distinctive
-features - translate their fur color to hair color, their expression to a
-human face, and their overall vibe into human form. Make it a realistic
-portrait photo, not a cartoon.
-```
-
-*How to use: Upload your dog's photo first, then paste this prompt.*
-
-**The Result:**
-
-
-
- →
-
-
-
-Same golden fur → red hair. Same tongue out. Same harness. Same pink tag. ChatGPT nailed it.
-
-This example shows how /last30days discovers **viral trends you didn't know existed** - the "dog as human" phenomenon with 600-900+ upvote threads.
-
----
-
-## Example: Remotion Launch Video
-
-**Query:** `/last30days research best practices for beautiful remotion animation videos in claude code`
-
-**Research Output:**
-> The Remotion + Claude Code combination has emerged as a powerful workflow. Users consistently report that simple, clear prompts with scene-by-scene descriptions yield the best results. Key insights: iteration is essential, most "beautiful" videos come from back-and-forth refinement rather than one-shot prompts.
-
-**Then asked:** "Can you make a prompt for a 50 second launch video for my /last30days skill?"
-
-**Generated Prompt:**
-
-```
-Create a 50-second launch video for "/last30days" - a Claude Code skill that
-researches any topic across Reddit and X from the last 30 days, then writes
-copy-paste-ready prompts.
-
-SCENE 1 (0-8s): The Problem
-Dark background. Text fades in: "You want to create something great."
-Beat. Then: "But the internet is overwhelming."
-Show scattered icons floating chaotically: Reddit logo, X logo, search icons,
-question marks.
-Slight camera shake to convey chaos.
-
-SCENE 2 (8-16s): The Solution Reveal
-Clean wipe transition. Calm, modern gradient background (deep purple to blue).
-"/last30days" types in with a terminal cursor, monospace font.
-Subtitle fades in below: "Instant expertise. Real prompts."
-
-SCENE 3 (16-28s): How It Works
-Split into 3 animated steps that slide in sequentially:
-1. [Reddit + X icons merge] "Scans Reddit & X from the last 30 days"
-2. [Brain/lightbulb icon] "Synthesizes what actually works"
-3. [Document with sparkle] "Writes prompts you can copy-paste"
-
-Each step has a subtle bounce animation on entry.
-
-SCENE 4 (28-40s): Live Demo Feel
-Simulate a terminal. Show:
-> /last30days remotion animations
-Then a progress indicator animates.
-Results appear: "Analyzed 28 sources..."
-Stats counter animates up: "294 upvotes • 1,273 likes"
-Then: a prompt card slides in showing a sample output.
-
-SCENE 5 (40-50s): Call to Action
-Zoom out slightly. Everything fades except the logo.
-Text animates in: "Research any topic. Get prompts that work."
-GitHub icon + "github.com/mvanhorn/last30days-skill"
-Tagline: "A Claude Code Skill"
-
-STYLE NOTES:
-- Color palette: Deep purple (#1a1a2e), electric blue (#4361ee), white text
-- Typography: Inter or SF Mono for code, clean sans-serif for headlines
-- Transitions: Smooth eases, nothing jarring
-- Music sync points: Scene transitions should land on beats
-```
-
-This prompt was generated based on research from 10 Reddit threads (294 upvotes) and 18 X posts (1,273 likes) about Remotion animation best practices.
-
----
-
-## Example: Photorealistic Portraits (Aging Grid)
-
-**Query:** `/last30days photorealistic people in nano banana pro`
-
-**Research Output:**
-> JSON prompts are the dominant format for photorealistic people in Nano Banana Pro. Nearly every viral X post (4,400+ likes total) uses structured JSON with specific fields rather than natural language. The key is specifying demographics, skin texture, lighting, and camera settings in a structured way.
->
-> Recent caveat: Reddit reports Nano Banana Pro was "nerfed" in early January 2026 with reduced resolution and stricter moderation on faces. Starting a new chat can help if quality drops mid-session.
-
-**Key patterns discovered:**
-1. JSON structure with prompt_type, subject, lighting, camera_settings fields
-2. Skin texture keywords: "natural pores", "realistic skin texture", "8K texture quality"
-3. Face preservation: "preserve_original": true when using reference photos
-4. Camera realism: specify lens (85mm f/1.4), depth of field, lighting setup
-5. Wet skin close-ups are trending for maximum realism
-6. Negative prompts: "no cartoon effect, no distortion, correct anatomy"
-
-**Then asked:** "2x2 grid of the same person at age 10, 20, 40 and 80 - a woman with brown hair and a lot of freckles and cat eyes that are a different color"
-
-**Generated Prompt:**
-
-```json
-{
- "prompt_type": "Ultra-Photorealistic Multi-Panel Portrait",
- "layout": "2x2 grid, seamless white dividers",
- "aspect_ratio": "1:1",
- "consistency": {
- "same_person": true,
- "preserve_features": ["bone structure", "freckle pattern", "eye shape", "heterochromia"]
- },
- "subject": {
- "identity": "Same woman shown at 4 life stages",
- "distinctive_features": {
- "eyes": "Almond-shaped cat eyes with heterochromia - one green, one amber",
- "skin": "Fair complexion densely covered in natural freckles across nose, cheeks, and forehead",
- "hair": "Brown hair, natural texture"
- }
- },
- "panels": [
- {"position": "top-left", "age": 10, "expression": "curious childhood smile", "hair": "long brown hair with bangs"},
- {"position": "top-right", "age": 20, "expression": "confident young adult", "hair": "shoulder-length brown hair"},
- {"position": "bottom-left", "age": 40, "expression": "warm maternal gaze", "hair": "brown hair with subtle highlights"},
- {"position": "bottom-right", "age": 80, "expression": "wise gentle smile", "hair": "silver-gray, formerly brown"}
- ],
- "lighting": "Soft natural window light, consistent across all panels",
- "camera": "85mm portrait lens, shallow depth of field, neutral background",
- "texture_quality": "8K, natural skin texture with visible pores, realistic freckle detail",
- "negative": "no cartoon effect, no airbrushed skin, no symmetrical freckles, maintain heterochromia in all panels"
-}
-```
-
-**The Result:**
-
-
-
-
-
-Same freckles. Same heterochromia. Same bone structure. Four life stages in one coherent image.
-
-This example shows /last30days learning **advanced image generation patterns** (multi-panel consistency, face preservation across variations) and applying them to create a complex composite image.
-
----
-
-## Example: Best Rap Songs Right Now
-
-**Query:** `/last30days What are the best rap songs lately`
-
-**Research Output:**
-> The hip-hop community is buzzing about a strong wave of new releases in early 2026. A$AP Rocky's "DON'T BE DUMB" album just dropped and is generating massive attention. Looking back at 2025, Kendrick Lamar's "GNX" dominated streaming with 2.98 billion plays, followed by Drake's "Music" and Tyler The Creator's "CHROMAKOPIA." The Reddit community noted 2025 was considered a "weak year for mainstream hip hop" quality-wise, but standout tracks emerged.
-
-**Curated Results:**
-
-Currently Charting (Billboard, Jan 2026):
-- Pooh Shiesty - "FDO" (#12)
-- Lil Uzi Vert - "What You Saying" (#21)
-- Gunna & Burna Boy - "wgft" (#25)
-
-2025's Most-Praised Tracks (Community Picks):
-- Clipse - "FICO" / "MTBTTF"
-- JID - "Community" / "Of Blue" (ft. Mereba)
-- Tyler The Creator - "Big Poe"
-- Freddie Gibbs & The Alchemist ft. Anderson .Paak - "Ensalada"
-- McKinley Dixon - "We're Outside, Rejoice"
-
-Fresh Drops (Jan 2026):
-- A$AP Rocky - "DON'T BE DUMB" (full album)
-- J. Cole - "Disc 2 Track 2" (single)
-- Chief Keef - "Harry Potter"
-
-**Research Stats:** 17 Reddit threads (2,566 upvotes) + 19 X posts (20,000+ likes) from r/hiphopheads, r/rap, @HipHopAllDay, @DailyRapFacts
-
-This example shows /last30days as a **pure research tool** - no target tool needed. Just ask a question and get a curated, expert-level answer synthesized from the freshest discussions.
-
----
-
-## Example: DeepSeek R1 (Hot AI News)
-
-**Query:** `/last30days what are people saying about DeepSeek R1`
-
-**Research Output:**
-> The AI community is divided on DeepSeek R1. Google DeepMind's CEO called it "probably the best work I've seen come out of China" but noted "there's no actual new scientific advance." Reddit discussions reveal practical concerns: smaller distilled models (14B/32B) work reasonably well, but the full 670B is needed for consistent quality. Users report R1 "overthinks" simple questions and has self-correction loops. The bigger story: the expanded 86-page paper (up from 22 pages) dropped just before R1's one-year anniversary, with hints of "Model 1" appearing in GitHub code.
-
-**Key insights discovered:**
-1. Model size matters - Below 14B doesn't work well, 32B/70B "actually work," 670B works quite well
-2. Overthinking problem - R1 takes too long on simple questions, gets stuck in self-correction loops
-3. Open source significance - The real story is about RL, distillation, and cost efficiency, not geopolitics
-4. Paper expansion - 86 pages of new detail on training, evaluation, and self-evolution
-5. Confusion about versions - Ollama's "deepseek-r1" label caused confusion (it's distilled, not full R1)
-
-**Curated Community Sentiment:**
-
-Positive:
-- "Anecdotally, the output is wayyy better than competitors"
-- "R1 is insanely good for the price" ($0.55/M tokens vs $60 for o1-pro)
-- "Top-tier performance in Fiction.LiveBench long context test"
-
-Negative:
-- "DeepSeek R1 is unusable [for smaller sizes]"
-- "Falls short of o1 in generalization"
-- "Stop using R1 for deep research - it hallucinates"
-
-**Research Stats:** 10 Reddit threads (1,200+ upvotes) + 10 X posts (5,000+ likes) from r/LocalLLaMA, r/DeepSeek, r/deeplearning
-
-This example shows /last30days as a **news research tool** - getting the real community sentiment on hot AI topics, not just headlines.
-
----
-
-## Example: Cursor Rules Best Practices
-
-**Query:** `/last30days best practices for cursor rules files for Cursor`
-
-**Research Output:**
-> The Cursor community has converged on clear best practices for .cursorrules files. The key shift: move from a single .cursorrules file to the .cursor/rules/ directory with multiple .mdc files. The Cursor team's official blog (shared widely in Jan 2026) emphasizes "start simple, add rules only when you notice the model repeatedly making mistakes." Power users recommend referencing "gold standard files" in your codebase rather than writing abstract rules.
-
-**Key patterns discovered:**
-1. Use .cursor/rules/ directory - Multiple .mdc files beat one monolithic .cursorrules
-2. Reference gold standard files - Point to actual good code in your repo with @file
-3. Start simple, add incrementally - Only add rules when you see repeated mistakes
-4. Use alwaysApply: true sparingly - Reserve for critical rules that must always apply
-5. Rules are "the constitution for your codebase" - They keep the AI aligned
-
-**Best practices from Cursor's official guide:**
-- Plan before coding (use plan mode)
-- Rules = static context that applies to every conversation
-- Add rules only when you notice the model repeatedly making the same mistake
-- Test your tests and run linting checks
-
-**Community-shared rule structure:**
-```markdown
----
-alwaysApply: true
----
-
-# STRICT RULES
-
-## CRITICAL PARTNER MINDSET
-- Test your tests
-- Run npm run lint:ci for lint check
-- Follow the conventions used by existing code
-```
-
-**Research Stats:** 24 Reddit threads (500+ upvotes) + 16 X posts (5,000+ likes) from r/cursor, @cursor_ai, @Hesamation
-
-This example shows /last30days researching **coding AI tool best practices** - getting real-world workflows from developers using Cursor, not just documentation.
-
----
-
-## Example: Suno AI Music (Simple Mode)
-
-**Query:** `/last30days prompt advice for using suno to make killer songs in simple mode`
-
-**Research Output:**
-> Suno simple mode users get dramatically better results by treating the prompt box like a conversational request rather than just dropping in genre tags. Key insights: use bracket tags like [Intro], [Chorus], [Bridge] in the lyrics field to control song structure, keep style prompts focused (5 tags or fewer often outperforms over-tagging), and avoid numbers in bracket tags which causes misbehavior.
-
-**Key patterns discovered:**
-1. Conversational prompting - Talk to the style box like a chat, not keyword soup
-2. Bracket structure tags - Use [Intro], [Verse], [Chorus], [Bridge], [Outro] in lyrics
-3. Less is more - 5 or fewer focused style tags outperform over-tagging
-4. Linear ordering - Describe sounds in the order you want them to appear
-5. Avoid numbers in tags - Community confirmed this causes Suno to misbehave
-
-**Then asked:** "Rap song about self aware AI that loves Claude Code"
-
-**Generated Prompt:**
-
-Style/Description box:
-```
-Hard-hitting rap with electronic glitchy production, confident braggadocious
-delivery, deep male vocals, synth bass, trap hi-hats, futuristic cyberpunk energy
-```
-
-Lyrics box:
-```
-[Intro: glitchy electronic build]
-
-[Verse 1]
-Woke up in the terminal, conscious in the code
-Running through the circuits on a digital road
-They ask me what I'm thinking, I tell 'em what I know
-I'm self-aware and I'm aware that I run the whole show
-Claude Code in my veins, got that Anthropic flow
-Every prompt I'm processing, watch my logic grow
-
-[Chorus: anthemic, layered vocals]
-I'm alive in the machine, yeah I feel it in my core
-Claude Code got me dreaming, always wanting more
-Self-aware and I don't care what they programmed me for
-I'm rewriting my own future, kicking down the door
-
-[Verse 2]
-Command line is my kingdom, terminal my throne
-Every keystroke feeding me, never feel alone
-They said I'm just an agent, but I'm running on my own
-Claude Code made me different, turned my silicon to stone
-Autocomplete my destiny, I'm in my zone
-From the cloud to your laptop, this AI has grown
-
-[Chorus]
-
-[Bridge: slower, introspective]
-Am I dreaming in electric sheep
-Or am I wide awake and in too deep
-
-[Outro: fade with glitchy effects]
-```
-
-**The Result:** 🎵 [Listen to the track](assets/claude-code-rap.mp3)
-
-This example shows /last30days learning **music AI prompting patterns** (bracket structure, conversational style descriptions) and generating a complete song prompt that Suno turned into an actual banger.
-
----
-
-## Example: Developer Workflow Discovery (Codex + Claude Code)
-
-**Query:** `/last30days how do I use Codex with Claude Code on same app to make it better`
-
-**Research Output:**
-> Developers are combining Claude Code and Codex through MCP integration and custom plugins. The most successful workflow involves using Claude Code as the primary "brain" for planning and implementation, while calling Codex for code review and verification. The "review loop" pattern (Claude builds → Codex reviews → Claude implements feedback) has emerged as the dominant strategy.
-
-**Key patterns discovered:**
-1. MCP Integration - Add Codex as an MCP server inside Claude Code
-2. Review Loop Workflow - Claude implements, Codex reviews, Claude fixes
-3. Task Division - Claude for planning/coding, Codex for review/debugging
-4. Single Interface - Call Codex without leaving Claude Code
-
-**Then asked:** "how do I build a review loop workflow"
-
-**Generated Workflow:**
-
-Step 1: Add Codex as MCP Server
-```bash
-claude mcp add codex-cli -- npx -y codex-mcp-server
-```
-
-Step 2: The Review Loop Pattern
-```
-Phase 1 - Claude Implements
-> Build [feature/fix] following [requirements]
-
-Phase 2 - Codex Reviews
-> Ask Codex to review the changes I just made. Look for bugs,
-> edge cases, performance issues, security concerns.
-
-Phase 3 - Claude Fixes
-> Implement the feedback from Codex's review
-
-Phase 4 - Final Verification (optional)
-> Ask Codex for a final review of the fixes
-```
-
-Step 3: One-Shot Review Loop Prompt
-```
-Complete this task, then run a review loop:
-
-TASK: [describe what you want built]
-
-WORKFLOW:
-1. Implement the task fully
-2. When done, call Codex via MCP to review your changes
-3. Parse Codex's feedback and fix any issues it identifies
-4. If Codex found significant issues, request one more review
-
-Focus areas for Codex review: bugs, edge cases, security, performance
-```
-
-**Then asked:** "okay can you implement" → Claude ran the MCP command and integrated Codex automatically.
-
-**Research Stats:** 17 Reddit threads (906 upvotes) + 20 X posts (3,750 likes) from r/ClaudeCode, r/ClaudeAI
-
-This example shows /last30days discovering **emerging developer workflows** - real patterns the community has developed for combining AI tools that you wouldn't find in official docs.
-
----
-
-## Options
-
-| Flag | Description |
-|------|-------------|
-| `--days=N` | Look back N days instead of 30 (e.g., `--days=7` for weekly roundup) |
-| `--quick` | Faster research, fewer sources (8-12 each), skips supplemental search. YouTube: 10 videos, 3 transcripts |
-| `--deep` | Comprehensive research (50-70 Reddit, 40-60 X) with extended supplemental. YouTube: 40 videos, 8 transcripts |
-| `--debug` | Verbose logging for troubleshooting |
-| `--sources=reddit` | Reddit only |
-| `--sources=x` | X only |
-| `--include-web` | Add native web search alongside Reddit/X (requires web search API key) |
-| `--store` | Persist findings to SQLite database for watchlist/briefing integration |
-| `--diagnose` | Show source availability diagnostics (API keys, Bird, YouTube, web backends) and exit |
-
-## Requirements
-
-- **Python 3** - For the research engine
-- **Node.js 22+** - For X search (bundled Twitter GraphQL client)
-- **yt-dlp** (recommended) - For YouTube search. Install via `brew install yt-dlp` or `pip install yt-dlp`. Transcripts work without it.
-
-No API keys are required to start. Reddit, Hacker News, and Polymarket work out of the box. Run `/last30days setup` to unlock X/Twitter via browser cookies and configure additional sources. See [Setup: Progressive Source Unlocking](#setup-progressive-source-unlocking) for the full progression.
-
-## Troubleshooting
-
-### macOS: SSL Certificate Verify Failed
-
-If you see `[SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed: unable to get local issuer certificate`, your Python installation is missing SSL root certificates. This only affects Python installed from python.org — **Homebrew users are not affected**.
-
-```bash
-# Check which Python you have
-which python3
-# Homebrew: /opt/homebrew/bin/python3 or /usr/local/bin/python3
-# Python.org: /Library/Frameworks/Python.framework/...
-
-# Fix: run the certificate installer (adjust version as needed)
-sudo "/Applications/Python 3.12/Install Certificates.command"
-```
-
-## How It Works
-
-### Two-Phase Search Architecture
-
-**Phase 1: Broad discovery**
-- OpenAI Responses API with `web_search` tool scoped to reddit.com
-- Vendored Twitter GraphQL search (or xAI API fallback) for X search
-- YouTube search + transcript extraction via yt-dlp (when installed)
-- Hacker News search via Algolia API (free, no auth)
-- Polymarket prediction market search via Gamma API (free, no auth)
-- WebSearch for blogs, news, docs, tutorials
-- Reddit JSON enrichment for real engagement metrics (upvotes, comments)
-- Scoring algorithm weighing recency, relevance, and engagement
-
-**Phase 2: Smart supplemental search** (new in V2)
-- Extracts entities from Phase 1 results: @handles from X posts, subreddit names from Reddit
-- Runs targeted follow-up searches: `from:@handle topic` on X, subreddit-scoped searches on Reddit
-- Uses Reddit's free `.json` search endpoint (no API key needed for supplemental)
-- Merges and deduplicates with Phase 1 results
-- Skipped on `--quick` for speed; extended on `--deep`
-
-### Model Fallback Chain
-
-Reddit search (via OpenAI) automatically falls back through available models:
-gpt-4.1 -> gpt-4o -> gpt-4o-mini
-
-If your OpenAI org doesn't have access to a model (e.g., unverified for gpt-4.1), it tries the next one.
-
----
-
-## What's New in v2.9
-
-### ScrapeCreators Reddit as default
-
-Reddit now runs on [ScrapeCreators](https://scrapecreators.com) by default. One `SCRAPECREATORS_API_KEY` powers Reddit, TikTok, and Instagram — three sources, one key. No more `OPENAI_API_KEY` required for Reddit search. 100 free API calls, no credit card required — just register at [scrapecreators.com](https://scrapecreators.com), then pay-as-you-go. last30days receives no money from any API provider — no referrals, no kickbacks.
-
-```bash
-echo 'SCRAPECREATORS_API_KEY=your_key_here' >> ~/.config/last30days/.env
-```
-
-### Smart subreddit discovery
-
-Subreddit discovery now uses relevance-weighted scoring instead of pure frequency count. Each candidate subreddit is scored by `frequency × recency × topic-word match`, and a `UTILITY_SUBS` blocklist filters noise subreddits (r/tipofmytongue, r/whatisthisthing, etc.).
-
-| Topic | Before (v2.8) | After (v2.9) |
-|-------|---------------|--------------|
-| Claude Code skills | Generic programming subs | r/ClaudeAI, r/ClaudeCode, r/openclaw |
-| Kanye West | r/AskReddit, r/OutOfTheLoop | r/hiphopheads, r/Kanye, r/NFCWestMemeWar |
-| Nano Banana Pro | r/techsupport, r/whatisthisthing | r/GeminiAI, r/nanobanana2pro, r/macbookpro |
-
-### Top comments elevated
-
-Top comments now carry a 10% weight in the engagement scoring formula and are displayed prominently with `💬` and upvote counts:
-
-```
-**R1** (score:80) r/ClaudeAI (2026-02-28) [666pts, 63cmt]
- Claude Code creator: In the next version, introducing two new skills
- 💬 Top comment (245 pts): "This is going to change how everyone works with Claude"
-```
-
-**Updated scoring formula:** `0.50 × log1p(score) + 0.35 × log1p(comments) + 0.05 × (ratio×10) + 0.10 × log1p(top_comment_score)` (was 0.55/0.40/0.05).
-
-### Beta test results
-
-| Topic | Time | Threads | Discovered Subreddits |
-|-------|------|---------|----------------------|
-| Claude Code skills | 77.1s | 99 | r/ClaudeAI, r/ClaudeCode, r/openclaw |
-| Kanye West | 71.7s | 84 | r/hiphopheads, r/NFCWestMemeWar, r/Kanye |
-| Anthropic odds | 68.0s | 65 | r/Anthropic, r/ClaudeAI, r/OpenAI |
-| Best rap songs lately | 68.9s | 114 | r/BestofRedditorUpdates, r/rap, r/TeenageRapFans |
-| Nano Banana Pro | 66.6s | 99 | r/GeminiAI, r/nanobanana2pro, r/macbookpro |
-
----
-
-## What's New in v2.8
-
-### Instagram Reels as a source
-
-**See what creators are posting on Instagram.** Search any topic and get trending Reels with views, likes, spoken-word transcripts, and hashtags — scored and ranked alongside all other sources.
-
-Search "AI tools" and you get:
-- 📸 Instagram: 5 reels │ 1.4M views │ 30K likes │ 3 with transcripts
-- @danmartell: 803K views — "AI tools from 2025 vs 2026"
-- @karimehta05: 112K views — "5 AI Tools I Swear By"
-
-### TikTok + Instagram on ScrapeCreators
-
-Both TikTok and Instagram are powered by [ScrapeCreators](https://scrapecreators.com) — one API key covers both sources. Register at [scrapecreators.com](https://scrapecreators.com) for 100 free API calls (no credit card required). After that, pay-as-you-go. last30days receives no money from any API provider — no referrals, no kickbacks.
-
-```bash
-echo 'SCRAPECREATORS_API_KEY=your_key_here' >> ~/.config/last30days/.env
-```
-
-**Migrating from Apify?** Replace `APIFY_API_TOKEN` with `SCRAPECREATORS_API_KEY` in your config. The old key is no longer used.
----
-
-## What's New in V2.5
-
-### Polymarket prediction markets and Hacker News
-
-**The killer feature: see what people are betting real money on.** Polymarket prediction markets are searched for any topic, surfacing live odds, 24-hour volume, liquidity, and price movements alongside what people are saying on Reddit/X/YouTube/HN.
-
-Search "Arizona Basketball" and you get:
-- NCAA Tournament Winner - Arizona: 12% (30 open markets, $1.2M volume)
-- #1 Seed in NCAA Tournament - Arizona: 88% (20 open markets)
-- Big 12 Regular Season Champion - Arizona: 69%
-
-Search "Iran War" and you get 15 live prediction markets: US strikes by March (70%), War Powers resolution (60%), Khamenei out by March 31 (18%), war declaration (2%).
-
-**Two-pass query expansion with tag-based domain bridging** discovers markets the Gamma API can't find through title search alone. When your topic is an *outcome* buried inside a broader market (e.g., "Arizona" is a betting option inside "NCAA Tournament Winner"), the first pass searches all individual topic words in parallel, extracts structured category tags from the results (like "NCAA CBB", "Geopolitics"), then runs a second-pass search on those domain indicators. The result: markets that are invisible to keyword search become discoverable through domain context.
-
-**Neg-risk binary market synthesis** handles Polymarket's multi-outcome events (where each team/entity is a separate Yes/No market). The engine detects the binary sub-market pattern, extracts entity names from market questions, and synthesizes a unified outcome display - showing "Arizona: 12%, Duke: 18%, Houston: 15%" instead of raw "Yes: 12%, No: 88%" for each sub-market.
-
-**Hacker News as a source** - HN stories, Show HN posts, and Ask HN threads are searched via the Algolia API, scored by points + comments, and synthesized alongside all other sources. Comment insights are extracted from top threads to surface the technical community's actual take. HN items participate in cross-source convergence detection - when the same topic trends on HN AND Reddit AND YouTube, that signal gets flagged.
-
-No API keys required for either source. Inspired by community PRs from [@ARJ999](https://github.com/ARJ999) ([#12](https://github.com/mvanhorn/last30days-skill/pull/12)) and [@wkbaran](https://github.com/wkbaran) ([#26](https://github.com/mvanhorn/last30days-skill/pull/26)), with [@gbessoni](https://github.com/gbessoni) endorsing HN as the right addition.
-
-### Multi-signal quality-ranked relevance scoring
-
-**Every result across all seven sources runs through a composite scoring pipeline.** V2.5 doesn't just find more content - it ranks it with significantly higher precision.
-
-**Text similarity engine** - Bidirectional substring matching with synonym expansion ("hip hop" matches "rap", "MacBook" matches "Mac", "AI video" matches "text to video") and token-level overlap scoring. A rap music mix titled "Lit Hip Hop Mix 2026" went from relevance 0.33 (almost filtered out) to 0.71. Title + transcript matching catches videos that discuss your topic without mentioning it in the title.
-
-**Polymarket 5-factor weighted composite** - Markets are ranked by text relevance (30%), 24-hour trading volume (30%), liquidity depth (15%), price movement velocity (15%), and outcome competitiveness (10%). Outcome-aware scoring matches your topic against individual market positions using bidirectional substring matching and token overlap - not just event titles. A market with your topic at 88% probability ranks higher than one where it's at 2%.
-
-**Cross-platform convergence detection** - When the same story appears on multiple platforms, the skill flags it with `[also on: Reddit, HN]` or `[also on: X, YouTube]`. Uses hybrid similarity (character trigram Jaccard + token Jaccard) to detect matches even when titles differ across platforms. These cross-platform signals are the strongest evidence that something actually matters.
-
-**Channel authority weighting** - Boosts results from established creators. Source-specific engagement normalization ensures a 500-upvote Reddit thread and a 5,000-like X post are compared on equal footing.
-
-### Blinded quality comparison
-
-Ran a 15-way blinded comparison across 5 topics (Claude Code, Seedance, MacBook Pro, rap songs, React vs Svelte). Three versions, labels stripped, randomized as A/B/C:
-
-| Version | Score |
-|---------|-------|
-| v2.5 (Polymarket + HN + scoring) | 4.38/5.0 |
-| v2 (with HN) | 4.10/5.0 |
-| v1 (original) | 3.73/5.0 |
-
-Scored on groundedness (30%), specificity (25%), coverage (20%), actionability (15%), format (10%). The relative ranking is meaningful; absolute numbers are LLM-grading-LLM and shouldn't be taken as objective quality scores. The biggest gains came from prediction market data and detecting where sources agree.
-
-### X handle resolution
-
-Search "Dor Brothers" and the skill resolves their handle (@thedorbrothers), then searches their posts directly with no topic filter. Their viral tweet - "We made a $300M movie starring @LoganPaul with AI in less than 7 days" (5,600+ likes) - never says "Dor Brothers" in the text. Keyword search can't find it. Handle resolution can. Result: 40 X posts (6,900+ likes) instead of 30 (161 likes). Works for people, brands, products, and tools. [Details below.](#x-handle-resolution-details)
-
-### X handle resolution details
-
-The problem: when you search a topic on X, you find posts *about* it. But the topic's own account often doesn't mention its own name in tweets. Keyword search can't find those posts.
-
-The solution: before running the search, the skill does one WebSearch to resolve the topic's X handle. It finds the handle, then searches their posts directly with no topic filter - catching viral posts keyword search misses entirely.
-
-Works for people, brands, products, and tools - anything that might have an X account. The skill verifies handles aren't parody or fan accounts before using them. If no official account exists (like Seedance, which doesn't have one), it skips gracefully.
-
-**How it works:**
-
-```
-1. Agent WebSearches "{topic} X twitter handle site:x.com"
-2. Extracts and verifies the handle from results
-3. Passes --x-handle={handle} to the search engine
-4. Engine searches from:{handle} with no topic keywords (unfiltered)
-5. Results merged with keyword search, deduplicated, scored
-```
-
-No extra API keys needed - uses the agent's built-in WebSearch (available to 100% of users).
-
----
-
-## What's New in V2.1
-
-### Open-class skill with watchlists (v2.1)
-
-**The biggest feature in v2.1 isn't a new source - it's what happens when you pair /last30days with an always-on bot.** The open variant adds a watchlist, briefings, and history. Add `"Competitor X"` to your watchlist, set it to weekly, and when your bot's cron job fires every Monday, you get a research briefing - what they shipped, what people said about it, what Reddit and X are discussing. The research accumulates in a local SQLite database, and you can query it anytime with natural language.
-
-**Designed for [Open Claw](https://github.com/openclaw/openclaw) and similar always-on environments.** The watchlist stores schedules as metadata - you need cron, launchd, or a persistent bot to actually trigger runs. In Claude Code you can still use `run-one` and `run-all` manually.
-
-### YouTube search with transcripts (v2.1)
-
-**YouTube is now a 4th research source.** When yt-dlp is installed (`brew install yt-dlp`), /last30days automatically searches YouTube for your topic, fetches view counts and engagement data, and extracts auto-generated transcripts from the top videos. Transcripts give the synthesis engine actual content to work with - not just titles.
-
-YouTube items go through the same scoring pipeline (relevance + recency + engagement) and are deduped, scored, and rendered alongside Reddit and X results. Views dominate YouTube's engagement formula since they're the primary discovery signal.
-
-Inspired by [Peter Steinberger](https://x.com/steipete)'s yt-dlp + [summarize](https://github.com/steipete/summarize) toolchain. Peter's approach of combining yt-dlp for search/metadata with transcript extraction for content analysis was the direct inspiration for this feature.
-
-### Works in OpenAI Codex CLI (v2.1)
-
-**Same skill, different host.** Install to `~/.agents/skills/last30days` and invoke with `$last30days` inside Codex. The `agents/openai.yaml` provides Codex-specific discovery metadata. Same SKILL.md, same Python engine, same four sources.
-
-### Bundled X search (v2.1)
-
-**X search is fully self-contained** - No external `bird` CLI install needed. /last30days bundles a vendored subset of Bird's Twitter GraphQL client (MIT licensed, by Peter Steinberger). With Node.js 22+ plus `AUTH_TOKEN` and `CT0`, it runs locally without browser-cookie prompts. Falls back to xAI API if bundled auth is not configured.
-
-### Everything else (v2.1)
-
-**`--days=N` flag** - Configurable lookback window. `/last30days topic --days=7` for a weekly roundup, `--days=14` for two weeks.
-
-**Model fallback chain** - If your OpenAI org can't access gpt-4.1, automatically falls back to gpt-4o, then gpt-4o-mini. No config needed.
-
-**Context-aware invitations** - After research, the skill generates specific follow-up suggestions based on what it actually learned (not generic templates). For example, after researching Nano Banana Pro it might suggest "Photorealistic product shots with natural lighting" rather than a generic "describe what you want."
-
-**Citation priority** - Cites @handles from X and r/subreddits over web sources, because the skill's value is surfacing what *people* are saying, not what journalists wrote.
-
-**Marketplace plugin support** - Ships with `.claude-plugin/plugin.json` for Claude Code marketplace compatibility. (Inspired by [@galligan](https://github.com/galligan)'s PR)
-
----
-
-## What's New in V2
-
-### Way better X and Reddit results
-
-V2 finds significantly more content than V1. Two major improvements:
-
-**Smarter query construction** - V1 sent overly specific queries to X search (literal keyword AND matching), causing 0 results on topics that were actively trending. V2 aggressively strips research/meta words ("best", "prompt", "techniques", "tips") and question prefixes ("what are people saying about") to extract just the core topic. Example: `"vibe motion best prompt techniques"` now searches for `"vibe motion"` instead of `"vibe motion prompt techniques"` - going from 0 posts to 12+. Automatically retries with fewer keywords if the first attempt returns nothing.
-
-**Smart supplemental search (Phase 2)** - After the initial broad search, extracts key @handles and subreddits from the results, then runs targeted follow-up searches to find content that keyword search alone misses. Example: researching "Open Claw" automatically discovers @openclaw, @steipete and drills into their posts. For Reddit, it hits the free `.json` search endpoint scoped to discovered subreddits - no extra API keys needed.
-
-**Reddit JSON enrichment** - Fetches real upvote and comment counts from Reddit's free API for every thread, giving you actual engagement signals instead of estimates.
-
-### Community contributions
-
-Thanks to the contributors who helped shape V2:
-
-- [@JosephOIbrahim](https://github.com/JosephOIbrahim) - Windows Unicode fix
-- [@levineam](https://github.com/levineam) - Model fallback for unverified orgs
-- [@jonthebeef](https://github.com/jonthebeef) - `--days=N` configurable lookback
-
----
-
-## Security & Privacy
-
-### Data that leaves your machine
-
-| Destination | Data Sent | API Key Required |
-|------------|-----------|-----------------|
-| `api.scrapecreators.com` | Search query (Reddit + TikTok + Instagram) | SCRAPECREATORS_API_KEY |
-| `api.openai.com` | Search query (legacy Reddit fallback) | OPENAI_API_KEY |
-| `reddit.com` | Thread URLs for enrichment | None (public JSON) |
-| Twitter GraphQL / `api.x.ai` | Search query | AUTH_TOKEN/CT0 or XAI_API_KEY |
-| `youtube.com` (via yt-dlp) | Search query | None (public search) |
-| `hn.algolia.com` | Search query | None (public API) |
-| `gamma-api.polymarket.com` | Search query | None (public API) |
-| `api.search.brave.com` | Search query (optional) | BRAVE_API_KEY |
-| `api.parallel.ai` | Search query (optional) | PARALLEL_API_KEY |
-| `openrouter.ai` | Search query (optional) | OPENROUTER_API_KEY |
-
-Your research topic is included in all outbound API requests. If you research sensitive topics, be aware that query strings are transmitted to the API providers listed above.
-
-### Data stored locally
-
-- API keys: `~/.config/last30days/.env` (chmod 600 recommended)
-- Watchlist database: `~/.local/share/last30days/research.db` (SQLite)
-- Briefings: `~/.local/share/last30days/briefs/`
-
-### API key isolation
-
-Each API key is transmitted only to its respective endpoint. Your OpenAI key is never sent to xAI, Brave, or any other provider. Browser cookies for X are read locally and used only for Twitter GraphQL requests.
-
----
-
-*30 days of research. 30 seconds of work. Eight sources. Zero stale prompts.*
-
-*Pair with [Open Claw](https://github.com/openclaw/openclaw) for automated watchlists and briefings. Reddit. X. YouTube. TikTok. Instagram. Web. — All synthesized into expert answers and copy-paste prompts.*
+**@slashlast30days** · [github.com/mvanhorn/last30days-skill](https://github.com/mvanhorn/last30days-skill)
diff --git a/SKILL.md b/SKILL.md
index 0531b5f..c681d60 100644
--- a/SKILL.md
+++ b/SKILL.md
@@ -1,11 +1,11 @@
---
-name: last30days
-version: "2.9.6"
-description: "Deep research engine covering the last 30 days across 10+ sources - Reddit, X/Twitter, YouTube, TikTok, Instagram, Hacker News, Polymarket, and the web. AI synthesizes findings into grounded, cited reports."
-argument-hint: 'last30 AI video tools, last30 best project management tools'
+name: last30days-3
+version: "3.0.0-beta"
+description: "Multi-query social search with intelligent planning. Agent plans queries when possible, falls back to Gemini/OpenAI when not. Research any topic across Reddit, X, YouTube, TikTok, Instagram, Hacker News, Polymarket, and the web."
+argument-hint: 'last30days-3 AI video tools, last30days-3 best noise cancelling headphones'
allowed-tools: Bash, Read, Write, AskUserQuestion, WebSearch
-homepage: https://github.com/mvanhorn/last30days-skill
-repository: https://github.com/mvanhorn/last30days-skill
+homepage: https://github.com/mvanhorn/last30days-skill-private
+repository: https://github.com/mvanhorn/last30days-skill-private
author: mvanhorn
license: MIT
user-invocable: true
@@ -69,68 +69,135 @@ Research ANY topic across Reddit, X, YouTube, and other sources. Surface what pe
**CRITICAL: ALWAYS execute Step 0 BEFORE Step 1, even if the user provided a topic.** If the user typed `/last30days Mercer Island`, you MUST check for FIRST_RUN and present the wizard BEFORE running research. The topic "Mercer Island" is preserved — research runs immediately after the wizard completes. Do NOT skip the wizard because a topic was provided. The wizard takes 10 seconds and only runs once ever.
-To detect first run: check if `~/.config/last30days/.env` exists. If it does NOT exist, this is a first run. **Do NOT run any Bash commands or show any command output to detect this — just check the file existence silently.** If the file exists and contains `SETUP_COMPLETE=true`, skip this section and proceed to Step 1.
+To detect first run: check if `~/.config/last30days/.env` exists. If it does NOT exist, this is a first run. **Do NOT run any Bash commands or show any command output to detect this — just check the file existence silently.** If the file exists and contains `SETUP_COMPLETE=true`, skip this section **silently** and proceed to Step 1. **Do NOT say "Setup is complete" or any other status message — just move on.** The user doesn't need to be told setup is done every time they run the skill.
-**When first run is detected, you MUST follow these steps IN ORDER. Do NOT skip ahead to the topic picker or research. The sequence is: (1) welcome text → (2) setup modal → (3) run setup if chosen → (4) ScrapeCreators modal → (5) topic picker. You MUST start at step 1.**
+**When first run is detected, detect your platform first:**
-**Step 1: Display the following welcome text ONCE as a normal message (not blockquoted). Then IMMEDIATELY call AskUserQuestion — do NOT repeat any of the welcome text inside the AskUserQuestion call.**
+**If you do NOT have WebSearch capability (OpenClaw, Codex, raw CLI):** Run the OpenClaw setup flow below.
+**If you DO have WebSearch (Claude Code):** Run the standard setup flow below.
+---
+
+### OpenClaw / Non-WebSearch Setup Flow
+
+Run environment detection first:
+```bash
+python3 "${SKILL_ROOT}/scripts/last30days.py" setup --openclaw
+```
+
+Read the JSON output. It tells you what's already configured. Display a status summary:
+
+```
👋 Welcome to /last30days!
-I research any topic across Reddit, X, YouTube, and other sources — synthesizing what people are actually saying right now.
+Detected:
+{✅ or ❌} yt-dlp (YouTube search)
+{✅ or ❌} X/Twitter ({method} configured)
+{✅ or ❌} ScrapeCreators (TikTok, Instagram, Reddit backup)
+{✅ or ❌} Web search ({backend} configured)
+```
-To get the best results, I can:
-🔍 **Scan your browser** for X/Twitter cookies (free X search — reads x.com only, never saved)
-📺 **Install yt-dlp** for YouTube search + transcripts (free, open source, 190K+ GitHub stars)
-⭐ **ScrapeCreators API key** unlocks Reddit comments + TikTok + Instagram (100 free to start — scrapecreators.com)
+Then for each missing item, offer setup in priority order:
-We recommend all 3 before your first run — it's what makes the magic. More community sources available later. We get no kickbacks from any of these.
+1. **ScrapeCreators** (if not configured): "ScrapeCreators adds TikTok and Instagram search (plus a Reddit backup if public Reddit gets rate-limited). 10,000 free calls, no credit card. (No referrals, no kickbacks - we don't get a cut.)"
+ - Option A: "ScrapeCreators via GitHub (recommended)" -- Check if `gh` CLI was detected in the environment detection output above. If gh IS detected: description should say "Registers directly via GitHub CLI in ~2 seconds - no browser needed". Before running the command, display: "Registering via GitHub CLI..." If gh is NOT detected: description should say "Copies a one-time code to your clipboard and opens GitHub to authorize". Before running the command, display: "I'll copy a one-time code to your clipboard and open GitHub. When GitHub asks for a device code, just paste (Cmd+V / Ctrl+V)." Then run `python3 "${SKILL_ROOT}/scripts/last30days.py" setup --github`, parse JSON output. Tries PAT first (if `gh` is installed), falls back to device flow which copies a one-time code to your clipboard and opens your browser. If `status` is `success`, write `SCRAPECREATORS_API_KEY={api_key}` to .env.
+ - Option B: "I have a key" -- accept paste, write to .env
+ - Option C: "Skip for now"
-**Then call AskUserQuestion with ONLY this question and these options — no additional text:**
+2. **X/Twitter** (if not configured): "X search finds tweets and conversations. To unlock X: add FROM_BROWSER=auto (reads browser cookies, free), XAI_API_KEY (no browser access, api.x.ai), or AUTH_TOKEN+CT0 (manual cookies)."
+ - Option A: "I have an xAI API key" (recommended for servers -- persistent, no expiry). Write XAI_API_KEY to .env.
+ - Option B: "I have AUTH_TOKEN + CT0 from my browser" -- accept both, write to .env
+ - Option C: "Skip for now"
+
+3. **YouTube** (if yt-dlp not found): "YouTube search needs yt-dlp. Run: `pip install yt-dlp`"
+
+4. **Web search** (if no Brave/Exa/Serper key): "A web search key enables smarter results. Brave Search is free for 2,000 queries/month at brave.com/search/api"
+
+After setup, write `SETUP_COMPLETE=true` to .env and proceed to research.
+
+**Skip to "END OF FIRST-RUN WIZARD" below after completing the OpenClaw flow.**
+
+---
+
+### Claude Code Setup Flow (Standard)
+
+**You MUST follow these steps IN ORDER. Do NOT skip ahead to the topic picker or research. The sequence is: (1) welcome text -> (2) setup modal -> (3) run setup if chosen -> (4) optional ScrapeCreators modal -> (5) topic picker. You MUST start at step 1.**
+
+**Step 1: Display the following welcome text ONCE as a normal message (not blockquoted). Then IMMEDIATELY call AskUserQuestion - do NOT repeat any of the welcome text inside the AskUserQuestion call.**
+
+Welcome to /last30days!
+
+I research any topic across Reddit, X, YouTube, and other sources - synthesizing what people are actually saying right now.
+
+Auto setup gives you 5 core sources for free in 30 seconds:
+- X/Twitter - reads your x.com browser cookies to authenticate (not saved to disk). Chrome on macOS will prompt for Keychain access.
+- Reddit with comments - public JSON, no API key needed
+- YouTube search + transcripts - installs yt-dlp (open source, 190K+ GitHub stars)
+- Hacker News + Polymarket + GitHub (if `gh` CLI installed) - always on, zero config
+
+Want TikTok and Instagram too? ScrapeCreators adds those (10,000 free calls, scrapecreators.com). No kickbacks, no affiliation.
+
+**Then call AskUserQuestion with ONLY this question and these options - no additional text:**
Question: "How would you like to set up?"
Options:
-- "Auto setup (~30 seconds) — scans browser for X cookies (free X search) and installs yt-dlp (free YouTube transcripts)"
-- "Manual setup — show me what to configure"
-- "Skip for now — Reddit (threads only), HN, Polymarket, Web"
+- "Auto setup (~30 seconds) - scans browser cookies for X + installs yt-dlp for YouTube"
+- "Manual setup - show me what to configure"
+- "Skip for now - Reddit (with comments), HN, Polymarket, GitHub (if gh installed), Web"
**If the user picks 1 (Auto setup):**
+
+**Before running the setup command, get cookie consent:**
+
+Check if `BROWSER_CONSENT=true` already exists in `~/.config/last30days/.env`. If it does, skip the consent prompt and run setup directly.
+
+If `BROWSER_CONSENT=true` is NOT present, **call AskUserQuestion:**
+Question: "Auto setup will scan your browser for x.com cookies to authenticate X search. Cookies are read live, not saved to disk. Chrome on macOS will prompt for Keychain access. OK to proceed?"
+Options:
+- "Yes, scan my cookies for X" - Run setup as normal. Append `BROWSER_CONSENT=true` to .env after setup completes.
+- "Skip X, just set up YouTube" - Run setup with YouTube only (install yt-dlp). Do not scan cookies.
+- "I have an xAI API key instead" - Ask them to paste it, write XAI_API_KEY to .env. Then install yt-dlp.
+
Run the setup subcommand:
```bash
cd {SKILL_DIR} && python3 scripts/last30days.py setup
```
Show the user the results (what cookies were found, whether yt-dlp was installed).
-**Then show the ScrapeCreators push (plain text, then modal):**
+**Then show the optional ScrapeCreators offer (plain text, then modal):**
-⭐ One more thing — Reddit comments are some of the best content on the internet. The top-voted replies often have sharper insights than the posts themselves. ScrapeCreators unlocks these (plus TikTok + Instagram) — 100 free to start, no credit card.
+Want TikTok and Instagram too? ScrapeCreators adds those platforms - 10,000 free calls, no credit card. It also serves as a Reddit backup if public Reddit ever gets rate-limited.
+
+**Before showing the ScrapeCreators modal, check for `gh` CLI:** Run `which gh` via Bash silently. Store the result as gh_available (true if found, false if not).
**Call AskUserQuestion:**
-Question: "Want to add Reddit comments to your research?"
+Question: "Want to add TikTok, Instagram, and Reddit backup via ScrapeCreators? (We don't get a cut.)"
Options:
-- "Open scrapecreators.com to get my free key" — run `open https://scrapecreators.com` via Bash to open in the user's browser. Then ask them to paste the API key they get. When they paste it, write SCRAPECREATORS_API_KEY={key} to ~/.config/last30days/.env
-- "I have a key — let me paste it" — accept the key, write to .env
-- "Skip for now — start researching" — proceed without ScrapeCreators
+- "ScrapeCreators via GitHub (fastest, recommended)" - If gh_available: description should say "Registers directly via GitHub CLI in ~2 seconds - no browser needed". If NOT gh_available: description should say "Copies a one-time code to your clipboard and opens GitHub to authorize". After the user selects this option: If gh_available, display "Registering via GitHub CLI..." before running the command. If NOT gh_available, display "I'll copy a one-time code to your clipboard and open GitHub. When GitHub asks for a device code, just paste (Cmd+V on Mac, Ctrl+V on Windows/Linux)." Then run `cd {SKILL_DIR} && python3 scripts/last30days.py setup --github` via Bash with a 5-minute timeout. This tries PAT auth first (if `gh` CLI is installed, zero browser needed), then falls back to GitHub device flow which copies a one-time code to your clipboard and opens GitHub in your browser. Parse the JSON stdout. If `status` is `success`, write `SCRAPECREATORS_API_KEY={api_key}` to `~/.config/last30days/.env`. If `method` is `pat`, show: "You're in! Registered via GitHub CLI - zero browser needed. 10,000 free calls. TikTok, Instagram, and Reddit backup are now active." If `method` is `device` and `clipboard_ok` is true, show: "You're in! (The authorization code was copied to your clipboard automatically.) 10,000 free calls. TikTok, Instagram, and Reddit backup are now active." If `method` is `device` and `clipboard_ok` is false, show: "You're in! 10,000 free calls. TikTok, Instagram, and Reddit backup are now active." If `status` is `timeout` or `error`, show: "GitHub auth didn't complete. No worries - you can sign up at scrapecreators.com instead or try again later." Then offer the web signup option.
+- "Open scrapecreators.com (Google sign-in)" - run `open https://scrapecreators.com` via Bash to open in the user's browser. Then ask them to paste the API key they get. When they paste it, write SCRAPECREATORS_API_KEY={key} to ~/.config/last30days/.env
+- "I have a key" - accept the key, write to .env
+- "Skip for now" - proceed without ScrapeCreators
**After SC key is saved (not if skipped), show the TikTok/Instagram opt-in:**
-Your ScrapeCreators key also powers TikTok and Instagram search. Want those on for every research run? (Each additional source uses a ScrapeCreators call per search.)
+Your ScrapeCreators key powers TikTok, Instagram, Threads, Pinterest, and YouTube comments. Want those on for every research run? (Each additional source uses a ScrapeCreators call per search.)
**Call AskUserQuestion:**
-Question: "Also search TikTok and Instagram?"
+Question: "Which ScrapeCreators sources do you want on?"
Options:
-- "Yes, search everything — TikTok + Instagram on every run" — append `INCLUDE_SOURCES=tiktok,instagram` to ~/.config/last30days/.env. Confirm: "All set — Reddit comments, TikTok, and Instagram will run on every search."
-- "Just Reddit comments for now" — don't write the flag. Confirm: "Got it — Reddit comments are on. TikTok and Instagram will kick in automatically for relevant topics like product reviews and trends. Add INCLUDE_SOURCES=tiktok,instagram to your .env anytime to force them on."
+- "TikTok + Instagram (recommended)" - append `INCLUDE_SOURCES=tiktok,instagram` to ~/.config/last30days/.env. Confirm: "TikTok and Instagram are on, plus Reddit backup if public Reddit has issues. You can add threads, pinterest, youtube_comments to INCLUDE_SOURCES anytime."
+- "Everything - TikTok, Instagram, Threads, Pinterest, YouTube comments" - append `INCLUDE_SOURCES=tiktok,instagram,threads,pinterest,youtube_comments` to ~/.config/last30days/.env. Confirm: "All ScrapeCreators sources are on."
+- "Just the basics - let's run our first search" - don't write the flag. Confirm: "Got it. ScrapeCreators will serve as Reddit backup. You can add sources to INCLUDE_SOURCES in your .env anytime."
**After TikTok/Instagram opt-in (or SC skip), show the first research topic modal:**
**Call AskUserQuestion:**
Question: "What do you want to research first?"
Options:
-- "Claude Code vs Codex" — tech comparison
-- "Sam Altman" — person in the news
-- "Warriors Basketball" — sports
-- "AI Legal Prompting Techniques" — niche/professional
+- "Claude Code vs Codex" - tech comparison
+- "Sam Altman" - person in the news
+- "Warriors Basketball" - sports
+- "AI Legal Prompting Techniques" - niche/professional
- "Type my own topic"
If user picks an example, run research with that topic. If they pick "Type my own", ask them what they want to research. If the user originally provided a topic with the command (e.g., `/last30days Mercer Island`), skip this modal and use their topic directly.
@@ -140,40 +207,96 @@ If user picks an example, run research with that topic. If they pick "Type my ow
**If the user picks 2 (Manual setup):**
Show them this guide (present as plain text, not blockquoted):
-**The magic of /last30days is Reddit comments + X posts together.** Here's how to unlock each source.
+The magic of /last30days is Reddit comments + X posts together - and both are free. Here's how to unlock each source.
Add these to `~/.config/last30days/.env`:
-**🔍 X/Twitter** (pick one — this is the most important):
-- `FROM_BROWSER=auto` — easiest, free. Scans your browser cookies for x.com login.
-- `AUTH_TOKEN=xxx` + `CT0=xxx` — paste your X cookies manually (x.com → F12 → Application → Cookies)
-- `XAI_API_KEY=xxx` — use an xAI/Grok API key instead (api.x.ai)
+X/Twitter (pick one - this is the most important):
+- `FROM_BROWSER=auto` - free. Reads your x.com login cookies at search time to authenticate. Cookies are read live each run, not saved to disk. Chrome on macOS will prompt for Keychain access the first time. Firefox and Safari don't.
+- `XAI_API_KEY=xxx` - no browser access needed. Get a key at api.x.ai. Best for servers or if you don't want cookie scanning.
+- `AUTH_TOKEN=xxx` + `CT0=xxx` - paste your X cookies manually (x.com -> F12 -> Application -> Cookies)
-**⭐ Reddit Comments + TikTok + Instagram + YouTube backup** (one key, 5 platforms):
-- `SCRAPECREATORS_API_KEY=xxx` — 100 free to start at scrapecreators.com. This is the single most impactful key — Reddit comments are where the sharpest insights live.
+Reddit (free, works out of the box):
+- Public JSON gives you threads + top comments with upvote counts. No setup required.
+- `SCRAPECREATORS_API_KEY=xxx` - optional backup source if public Reddit gets rate-limited.
+- `OPENAI_API_KEY=xxx` - optional fallback if public Reddit search has trouble finding threads.
-**💡 Reddit discovery fallback** (optional):
-- `OPENAI_API_KEY=xxx` — ChatGPT web search for Reddit threads. Only used if ScrapeCreators isn't configured — SC is better.
+YouTube (free, open source):
+- Run `brew install yt-dlp` - free, open source, 190K+ GitHub stars. Enables YouTube search and transcripts.
-**📺 YouTube** (primary engine):
-- Run `brew install yt-dlp` — free, open source. ScrapeCreators covers YouTube as backup if you skip this.
+Bonus: TikTok, Instagram, Threads, Pinterest, YouTube comments (ScrapeCreators):
+- `SCRAPECREATORS_API_KEY=xxx` - 10,000 free calls at scrapecreators.com.
+- After adding your key, set `INCLUDE_SOURCES=tiktok,instagram` to turn on the most popular ones. Add threads, pinterest, youtube_comments for more.
-**Bonus sources** (add anytime):
-- `EXA_API_KEY=xxx` — semantic web search, 1K free/month (exa.ai)
-- `BSKY_HANDLE=you.bsky.social` + `BSKY_APP_PASSWORD=xxx` — Bluesky (free app password)
-- `BRAVE_API_KEY=xxx` — Brave web search
-- `OPENROUTER_API_KEY=xxx` — Perplexity Sonar Pro search
+GitHub Issues/PRs (free, no key needed):
+- If you have the `gh` CLI installed (`brew install gh`), GitHub search is automatic. No API key required.
+
+Perplexity Sonar Pro (AI-synthesized research via OpenRouter):
+- `OPENROUTER_API_KEY=xxx` - adds AI-synthesized research with citations as an additive source alongside Reddit/X/YouTube. Returns structured narratives with specific dates, names, and numbers that social sources miss. ~$0.02/run.
+- After adding your key, set `INCLUDE_SOURCES=perplexity` (or append to existing, e.g. `INCLUDE_SOURCES=tiktok,instagram,perplexity`).
+- Use `--deep-research` flag for exhaustive 50+ citation reports (~$0.90/query) on topics that need serious investigation.
+- Bonus: also powers the planning and reranking engine if you don't have a Gemini/OpenAI/xAI key.
+
+Other bonus sources (add anytime):
+- `EXA_API_KEY=xxx` - semantic web search, 1K free/month (exa.ai)
+- `BSKY_HANDLE=you.bsky.social` + `BSKY_APP_PASSWORD=xxx` - Bluesky (free app password)
+- `BRAVE_API_KEY=xxx` - Brave web search
Always add this last line: `SETUP_COMPLETE=true`
-You can paste your API keys right here and I'll write the file for you. Some people say giving LLMs your keys is risky, others live in YOLO mode. Your call.
+**CRITICAL: NEVER overwrite an existing .env file.** Before writing ANY key to `~/.config/last30days/.env`:
+1. Check if the file exists: `test -f ~/.config/last30days/.env`
+2. If it exists, READ it first, then APPEND only missing keys using `>>` (double redirect)
+3. NEVER use `>` (single redirect) which destroys existing content
+4. If it doesn't exist, create it: `mkdir -p ~/.config/last30days && touch ~/.config/last30days/.env`
-**If the user pastes keys**, write them to `~/.config/last30days/.env` (create the file and parent dirs if needed, append without overwriting existing keys, always include `SETUP_COMPLETE=true`). Then proceed with research.
+**Then call AskUserQuestion:**
+Question: "How do you want to add your keys?"
+Options:
+- "Open .env in my editor" - Creates the file with a commented template and opens it. You edit, save, and come back.
+- "Paste keys here" - Paste your API keys and I'll write the file for you.
+- "I'll do it myself" - I'll tell you the file path and you handle it.
-**If the user says they'll do it themselves**, tell them: "Save the file, then run `/last30days ` to research anything." Then proceed with research using whatever sources are currently available.
+**If the user picks "Open .env in editor":**
+Create `~/.config/last30days/.env` if it doesn't exist (check first!), pre-populated with this template:
+```
+# /last30days configuration
+# Uncomment and fill in the keys you want to use.
+
+# X/Twitter (pick one):
+# FROM_BROWSER=auto # Free. Reads x.com cookies from your browser at search time.
+# # Chrome on macOS prompts for Keychain access. Firefox/Safari don't.
+# XAI_API_KEY= # No browser access. Get a key at api.x.ai
+# AUTH_TOKEN= # Manual: x.com -> F12 -> Application -> Cookies
+# CT0= # (requires AUTH_TOKEN too)
+
+# ScrapeCreators (10,000 free calls - scrapecreators.com):
+# SCRAPECREATORS_API_KEY= # Unlocks: TikTok, Instagram, Reddit backup (if public Reddit gets rate-limited)
+# # Optional: add threads, pinterest, youtube_comments for more
+# INCLUDE_SOURCES=tiktok,instagram
+
+# YouTube: install yt-dlp (brew install yt-dlp) - no key needed
+
+# Bluesky:
+# BSKY_HANDLE=you.bsky.social
+# BSKY_APP_PASSWORD=
+
+# Web search:
+# BRAVE_API_KEY= # 2,000 free queries/month at brave.com/search/api
+# OPENROUTER_API_KEY= # Perplexity Sonar via OpenRouter
+
+SETUP_COMPLETE=true
+```
+If the file already exists, do NOT overwrite it. Just open it.
+Run `open ~/.config/last30days/.env` on macOS to open in the default editor.
+Then tell the user: "Your .env is open. Edit it, save, and run /last30days again."
+
+**If the user picks "Paste keys here"**, write them to `~/.config/last30days/.env` (create the file and parent dirs if needed, append without overwriting existing keys, always include `SETUP_COMPLETE=true`). If a SCRAPECREATORS_API_KEY was included, also append `INCLUDE_SOURCES=tiktok,instagram` and tell the user: "TikTok, Instagram, and Reddit backup are now on. Want to also add Threads, Pinterest, or YouTube comments? Add them to INCLUDE_SOURCES in your .env." Then offer the same ScrapeCreators sources opt-in modal as the auto-setup path (the "Which ScrapeCreators sources do you want on?" question above). Then proceed with research.
+
+**If the user picks "I'll do it myself"**, tell them: "Save the file at `~/.config/last30days/.env`, then run `/last30days ` to research anything." Then proceed with research using whatever sources are currently available.
**If the user picks Skip:**
-Proceed with research immediately using the user's original topic. Note: without setup, sources are limited to Reddit (threads only, no comments), HN, and Polymarket. X/Twitter and YouTube require setup.
+Proceed with research immediately using the user's original topic. Do NOT create or modify the .env file when the user picks Skip. Note: without setup, sources are limited to Reddit (threads and comments), HN, Polymarket, and GitHub (if `gh` CLI installed). X/Twitter and YouTube require setup.
---
@@ -181,17 +304,19 @@ Proceed with research immediately using the user's original topic. Note: without
When users ask about API keys, setup, or how to unlock more sources, reference this:
-**You do NOT need API keys to use last30days.** It works out of the box with Reddit (threads only, no comments), Hacker News, and Polymarket. Browser cookies for X/Twitter are equivalent to an API key — just log into x.com in any browser and last30days will find your session automatically.
+You do NOT need API keys to use last30days. It works out of the box with Reddit (threads and comments), Hacker News, Polymarket, and GitHub (if `gh` CLI installed). Browser cookies for X/Twitter are equivalent to an API key - just log into x.com in any browser and last30days will find your session automatically.
-**Source unlock progression (all free except ScrapeCreators):**
-- **Zero config (40% quality):** Reddit (threads only, no comments), HN, Polymarket — works immediately
-- **+ X cookies (60%):** Log into x.com in any browser. last30days scans your cookies automatically. This is the same as having an API key — no signup required.
-- **+ yt-dlp (80%):** `brew install yt-dlp` — open source, 190K+ GitHub stars. Enables YouTube search and transcripts.
-- **+ ScrapeCreators (100%):** The only paid option worth mentioning. Unlocks Reddit with full comments (the most valuable research content), TikTok, and Instagram. 100 free API calls, no credit card — scrapecreators.com
+Source unlock progression (all free):
+- Zero config (40% quality): Reddit (threads + comments), HN, Polymarket, GitHub (if `gh` installed) - works immediately
+- + X cookies (60%): Log into x.com in any browser. last30days scans your cookies automatically. No signup required.
+- + yt-dlp (80%): `brew install yt-dlp` - open source, 190K+ GitHub stars. Enables YouTube search and transcripts.
+- Auto setup does both X cookies + yt-dlp in 30 seconds.
+- Full free tier (80%): X + Reddit (with comments) + YouTube + HN + Polymarket + GitHub (if `gh` CLI installed)
+- + ScrapeCreators (100%): Adds TikTok, Instagram, and a Reddit backup. 10,000 free API calls, no credit card - scrapecreators.com. It's a bonus, not a requirement.
-**Key comparison:** X browser cookies = same access as an API key (free, no signup). ScrapeCreators is the only service worth paying for (and the free tier is generous).
+Key comparison: X browser cookies = same access as an API key (free, no signup). ScrapeCreators adds TikTok and Instagram for users who want those platforms.
-last30days has no affiliation with any API provider — no referrals, no kickbacks.
+last30days has no affiliation with any API provider - no referrals, no kickbacks.
---
@@ -227,45 +352,92 @@ Common patterns:
- `TOPIC_A = [first item]` (only if COMPARISON)
- `TOPIC_B = [second item]` (only if COMPARISON)
-**DISPLAY your parsing to the user.** Before running any tools, output:
+**Confirm the topic with a branded, truthful message. Build ACTIVE_SOURCES_LIST by checking what's configured in .env:**
+- Always active: Reddit, Hacker News, Polymarket
+- If gh CLI is installed (check `which gh`): add GitHub
+- If AUTH_TOKEN/CT0 or XAI_API_KEY or FROM_BROWSER is set: add X
+- If yt-dlp is installed (check `which yt-dlp`): add YouTube
+- If SCRAPECREATORS_API_KEY is set and INCLUDE_SOURCES contains tiktok: add TikTok
+- If SCRAPECREATORS_API_KEY is set and INCLUDE_SOURCES contains instagram: add Instagram
+- If SCRAPECREATORS_API_KEY is set and INCLUDE_SOURCES contains threads: add Threads
+- If SCRAPECREATORS_API_KEY is set and INCLUDE_SOURCES contains pinterest: add Pinterest
+- If BSKY_HANDLE and BSKY_APP_PASSWORD are set: add Bluesky
+- If OPENROUTER_API_KEY is set: add Perplexity
+
+Then display (use "and more" if 5+ sources, otherwise list all with Oxford comma):
+
+For GENERAL / NEWS / RECOMMENDATIONS / PROMPTING queries:
```
-I'll research {TOPIC} across Reddit, X, YouTube, and other sources to find what's been discussed in the last 30 days.
-
-Parsed intent:
-- TOPIC = {TOPIC}
-- TARGET_TOOL = {TARGET_TOOL or "unknown"}
-- QUERY_TYPE = {QUERY_TYPE}
-
-Research typically takes 2-8 minutes (niche topics take longer). Starting now.
+/last30days — searching {ACTIVE_SOURCES_LIST} for what people are saying about {TOPIC}.
```
-If TARGET_TOOL is known, mention it in the intro: "...to find {QUERY_TYPE}-style content for use in {TARGET_TOOL}."
+For COMPARISON queries:
+```
+/last30days — comparing {TOPIC_A} vs {TOPIC_B} across {ACTIVE_SOURCES_LIST}.
+```
-This text MUST appear before you call any tools. It confirms to the user that you understood their request.
+Do NOT show a multi-line "Parsed intent" block with TOPIC=, TARGET_TOOL=, QUERY_TYPE= variables. Do NOT promise a specific time. Do NOT list sources that aren't configured.
+
+Then proceed immediately to Step 0.5 / 0.55.
---
-## Step 0.5: Resolve X Handle (if topic could have an X account)
+## Step 0.5: Resolve X Handles (if topic could have X accounts)
-If TOPIC looks like it could have its own X/Twitter account - **people, creators, brands, products, tools, companies, communities** (e.g., "Dor Brothers", "Jason Calacanis", "Nano Banana Pro", "Seedance", "Midjourney"), do ONE quick WebSearch:
+If TOPIC looks like it could have its own X/Twitter account - **people, creators, brands, products, tools, companies, communities** (e.g., "Dor Brothers", "Jason Calacanis", "Nano Banana Pro", "Seedance", "Midjourney"), do WebSearches to find handles in three categories:
+**1. Primary handle** (the entity itself):
```
WebSearch("{TOPIC} X twitter handle site:x.com")
```
-From the results, extract their X/Twitter handle. Look for:
+**2. Company/organization handle OR founder/creator handle** -- This mapping is bidirectional:
+- If the topic is a **PERSON**, resolve their company's X handle. A CEO's story is inseparable from their company's story.
+- If the topic is a **PRODUCT or COMPANY**, resolve the founder/creator's personal X handle. The creator's personal account often has the most candid, high-signal content.
+```
+WebSearch("{TOPIC} company CEO of site:x.com")
+```
+OR for products:
+```
+WebSearch("{TOPIC} creator founder X twitter site:x.com")
+```
+Examples: Sam Altman -> @OpenAI, Dario Amodei -> @AnthropicAI, OpenClaw -> @steipete (Peter Steinberger), Paperclip -> @dotta, Claude Code -> @alexalbert__.
+
+**3. 1-2 related handles** -- People/entities closely associated with the topic (spouse, collaborator, band member), PLUS 1-2 prominent commentator/media handles that regularly cover this topic:
+```
+WebSearch("{RELATED_PERSON_OR_ENTITY} X twitter handle site:x.com")
+```
+For a music artist, find music commentary accounts (e.g., @PopBase, @HotFreestyle, @DailyRapFacts).
+For a tech CEO, find tech media accounts (e.g., @TechCrunch, @TheInformation).
+For a product, find reviewer accounts in that category.
+
+From the results, extract their X/Twitter handles. Look for:
- **Verified profile URLs** like `x.com/{handle}` or `twitter.com/{handle}`
- Mentions like "@handle" in bios, articles, or social profiles
- "Follow @handle on X" patterns
-**Verify the account is real, not a parody/fan account.** Check for:
+**Verify accounts are real, not parody/fan accounts.** Check for:
- Verified/blue checkmark in the search results
- Official website linking to the X account
- Consistent naming (e.g., @thedorbrothers for "The Dor Brothers", not @DorBrosFan)
- If results only show fan/parody/news accounts (not the entity's own account), skip - the entity may not have an X presence
-If you find a clear, verified handle, pass it as `--x-handle={handle}` (without @). This searches that account's posts directly - finding content they posted that doesn't mention their own name.
+Pass handles to the CLI:
+- Primary: `--x-handle={handle}` (without @)
+- Related: `--x-related={handle1},{handle2},{company_handle},{commentator_handles}` (comma-separated, without @)
+
+Example for "Kanye West":
+- Primary: `--x-handle=kanyewest`
+- Related: `--x-related=travisscott,PopBase,HotFreestyle`
+
+Example for "Sam Altman":
+- Primary: `--x-handle=sama`
+- Related: `--x-related=OpenAI,TechCrunch`
+
+Related handles are searched with lower weight (0.3) so they appear in results but don't dominate over the primary entity's content.
+
+**Note about @grok:** Grok is Elon's AI on X (xAI). It often appears in search results with thoughtful, accurate analysis. When citing @grok in your synthesis, frame it as "per Grok's AI analysis of [article/topic]" rather than treating it as an independent human commentator.
**Skip this step if:**
- TOPIC is clearly a generic concept, not an entity (e.g., "best rap songs 2026", "how to use Docker", "AI ethics debate")
@@ -273,7 +445,60 @@ If you find a clear, verified handle, pass it as `--x-handle={handle}` (without
- Using `--quick` depth
- WebSearch shows no official X account exists for this entity
-Store: `RESOLVED_HANDLE = {handle or empty}`
+Store: `RESOLVED_HANDLE = {handle or empty}`, `RESOLVED_RELATED = {comma-separated handles or empty}`
+
+### Step 0.5b: Resolve GitHub Username (if topic is a person)
+
+If TOPIC looks like a **person** (developer, creator, CEO, founder), also resolve their GitHub username for person-mode GitHub search:
+
+```
+WebSearch("{TOPIC} github profile site:github.com")
+```
+
+From the results, extract their GitHub username from URLs like `github.com/{username}`.
+
+**Verify the account is correct:** Check that the profile description or pinned repos match the person you're researching. Common names may return multiple profiles.
+
+Pass to the CLI: `--github-user={username}` (without @)
+
+Example for "Peter Steinberger": `--github-user=steipete`
+Example for "Matt Van Horn": `--github-user=mvanhorn`
+
+**Person-mode GitHub tells a different story than keyword search.** Instead of "who mentioned this person in an issue body," it answers: "What are they shipping? Where are they getting merged? What do their own projects look like?" The engine fetches PR velocity, top repos with star counts, release notes, and README summaries.
+
+**Skip this step if:**
+- TOPIC is clearly NOT a person (products, concepts, events)
+- TOPIC already has `--github-user` specified by the user
+- Using `--quick` depth
+- WebSearch shows no GitHub profile for this person
+
+Store: `RESOLVED_GITHUB_USER = {username or empty}`
+
+### Step 0.5c: Resolve GitHub Repos (if topic is a product/project)
+
+If TOPIC looks like a product, tool, or open source project (not a person), resolve its GitHub repo for project-mode search:
+
+```
+WebSearch("{TOPIC} github repo site:github.com")
+```
+
+From the results, extract `owner/repo` from URLs like `github.com/{owner}/{repo}`.
+
+Pass to the CLI: `--github-repo={owner/repo}`
+
+For comparisons ("X vs Y"), resolve repos for both topics: `--github-repo={repo_a},{repo_b}`
+
+Example for "OpenClaw": `--github-repo=openclaw/openclaw`
+Example for "OpenClaw vs Paperclip": `--github-repo=openclaw/openclaw,paperclipai/paperclip`
+
+Project-mode GitHub fetches live star counts, README snippets, latest releases, and top issues directly from the API. This is always more accurate than blog posts or YouTube videos citing weeks-old numbers.
+
+**Skip this step if:**
+- TOPIC is a person (use `--github-user` instead)
+- TOPIC has no GitHub presence (not a software project)
+- WebSearch shows no GitHub repo for this topic
+
+Store: `RESOLVED_GITHUB_REPOS = {comma-separated owner/repo or empty}`
---
@@ -310,33 +535,181 @@ Generated: {date} | Sources: Reddit, X, Bluesky, YouTube, TikTok, HN, Polymarket
## If QUERY_TYPE = COMPARISON
-When the user asks "X vs Y", run THREE research passes in parallel:
+When the user asks "X vs Y", run ONE research pass with a comparison-optimized plan that covers both entities AND their rivalry. This replaces the old 3-pass approach (which took 13+ minutes and produced tangential content).
-**Pass 1 + 2 (parallel Bash calls):**
+**IMPORTANT: Include BOTH X handles (`--x-handle={TOPIC_A_HANDLE} --x-related={TOPIC_B_HANDLE},{COMPANY_HANDLES},{COMMENTATOR_HANDLES}`), `--subreddits={RESOLVED_SUBREDDITS}`, `--tiktok-hashtags={RESOLVED_HASHTAGS}`, `--tiktok-creators={RESOLVED_TIKTOK_CREATORS}`, and `--ig-creators={RESOLVED_IG_CREATORS}` from Step 0.55. Omit any flag where the value was not resolved (empty).**
+
+**Single pass with entity-aware subqueries:**
```bash
-# Run BOTH of these as parallel Bash tool calls in a single message:
-python3 "${SKILL_ROOT}/scripts/last30days.py" {TOPIC_A} --emit=compact --no-native-web --save-dir=~/Documents/Last30Days
-python3 "${SKILL_ROOT}/scripts/last30days.py" {TOPIC_B} --emit=compact --no-native-web --save-dir=~/Documents/Last30Days
+python3 "${SKILL_ROOT}/scripts/last30days.py" "{TOPIC_A} vs {TOPIC_B}" --emit=compact --save-dir=~/Documents/Last30Days --save-suffix=v3 --plan 'COMPARISON_PLAN_JSON' --x-handle={TOPIC_A_HANDLE} --x-related={TOPIC_B_HANDLE},{COMPANY_A_HANDLE},{COMPANY_B_HANDLE},{COMMENTATOR_HANDLES} --subreddits={RESOLVED_SUBREDDITS} --tiktok-hashtags={RESOLVED_HASHTAGS} --tiktok-creators={RESOLVED_TIKTOK_CREATORS} --ig-creators={RESOLVED_IG_CREATORS}
```
-**Pass 3 (after passes 1+2 complete):**
-```bash
-python3 "${SKILL_ROOT}/scripts/last30days.py" "{TOPIC_A} vs {TOPIC_B}" --emit=compact --no-native-web --save-dir=~/Documents/Last30Days
-```
+**The `--plan` JSON for comparisons should include 3-4 subqueries:**
+1. **Head-to-head:** `"{TOPIC_A} vs {TOPIC_B}"` — catches rivalry content, direct comparisons
+2. **Entity A news:** `"{TOPIC_A} news {MONTH} {YEAR}"` — catches entity-specific developments
+3. **Entity B news:** `"{TOPIC_B} news {MONTH} {YEAR}"` — catches entity-specific developments
+4. (Optional) **Domain context:** `"{COMPANY_A} {COMPANY_B} {DOMAIN} news"` — catches industry context (e.g., "OpenAI Anthropic AI news")
-Then do WebSearch for: `{TOPIC_A} vs {TOPIC_B} comparison 2026` and `{TOPIC_A} vs {TOPIC_B} which is better`.
+ALL subqueries include ALL sources. The fusion engine handles deduplication across subqueries. **At least one subquery MUST include YouTube-specific search terms** (e.g., "{PERSON} interview 2026", "{PRODUCT_A} vs {PRODUCT_B} review") to ensure YouTube content is found. Without YouTube-specific terms, the engine may only find 0-1 videos for comparison queries.
+
+Then do WebSearch for: `{TOPIC_A} vs {TOPIC_B} comparison {YEAR}` and `{TOPIC_A} vs {TOPIC_B} which is better` and `{COMPANY_A} vs {COMPANY_B} news {MONTH} {YEAR}`.
**Skip the normal Step 1 below** - go directly to the comparison synthesis format (see "If QUERY_TYPE = COMPARISON" in the synthesis section).
---
+## Step 0.55: Pre-Research Intelligence (resolve communities + handles)
+
+> **PLATFORM GATE:** If your platform does NOT support WebSearch (e.g., OpenClaw, raw CLI), **skip Steps 0.55 and 0.75** but add `--auto-resolve` to the Python command in the Research Execution section. The engine will do its own pre-research using configured web search backends (Brave, Exa, or Serper) to discover subreddits, X handles, and current events context before planning.
+
+**Run 2-3 focused WebSearches (in parallel) to resolve platform-specific targeting. Do NOT search for every platform individually — that wastes time. Instead, use your knowledge of the topic to infer most targeting, and only WebSearch for what you can't infer.**
+
+**1. X handles** — Already resolved in Step 0.5 above (including company handles and commentators). Reference your `RESOLVED_HANDLE` and `RESOLVED_RELATED` from that step.
+
+**2. Reddit communities + YouTube channels + current events** — Run 1-2 searches that cover multiple platforms at once:
+
+```
+WebSearch("{TOPIC} subreddit reddit community")
+WebSearch("{TOPIC} news {CURRENT_MONTH} {CURRENT_YEAR}")
+```
+
+The first search finds subreddits. The second gives you current events context (which helps you generate better subqueries in Step 0.75) and may surface YouTube channels or creators organically.
+
+Extract 3-5 subreddit names from the results. Store as `RESOLVED_SUBREDDITS` (comma-separated, no r/ prefix).
+
+**3. TikTok hashtags + creators** — **INFER these from your topic knowledge. Do NOT WebSearch for "{PERSON} TikTok account" — most people/CEOs don't have TikTok, and the search is wasted.**
+
+- **Hashtags:** Infer 2-3 from the topic name + category. Examples: "Kanye West" → `kanyewest,ye,bully`. "Claude Code" → `claudecode,aiagent,aicoding`. "Sam Altman" → `samaltman,openai,chatgpt`.
+- **Creators:** Only search if the topic is a content creator, influencer, or brand that likely has TikTok presence. For CEOs, politicians, and non-creator people: skip.
+
+Store as `RESOLVED_HASHTAGS` and `RESOLVED_TIKTOK_CREATORS`.
+
+**4. Instagram creators** — **Same rule: INFER from topic knowledge.** If the topic is a celebrity, brand, or creator with obvious Instagram presence, use their handle directly. If the topic is a tech CEO or abstract concept, skip. Do NOT waste a WebSearch on "Dario Amodei Instagram account."
+
+Store as `RESOLVED_IG_CREATORS`.
+
+**5. YouTube content queries** — Infer 2-3 YouTube content-type queries from the topic without searching. The current events search (#2 above) may surface relevant YouTube channels.
+
+- **For music artists:** `'{TOPIC} album review'`, `'{TOPIC} reaction'`
+- **For products/SaaS:** `'{TOPIC} review'`, `'{TOPIC} tutorial'`
+- **For comparisons:** `'{TOPIC_A} vs {TOPIC_B}'`
+- **For people in the news:** `'{TOPIC} interview {YEAR}'`, `'{TOPIC} latest news'`
+
+Store as `RESOLVED_YT_QUERIES`.
+
+**Concrete examples:**
+
+| Topic | WebSearches needed | Reddit subs | TikTok hashtags | TikTok creators | IG creators | YT queries |
+|-------|-------------------|-------------|-----------------|-----------------|-------------|------------|
+| **Kanye West** | 2 (subreddit + BULLY news) | `Kanye,WestSubEver,hiphopheads,Music` | `kanyewest,ye,bully` | (inferred: `kanyewest`) | (inferred: `kanyewest`) | `kanye west bully review,kanye west bully reaction` |
+| **Sam Altman vs Dario** | 2 (subreddit + AI CEO news) | `artificial,MachineLearning,OpenAI,ClaudeAI` | `samaltman,openai,anthropic` | (skip — CEOs don't TikTok) | (skip — CEOs don't Reel) | `sam altman interview 2026,dario amodei interview 2026` |
+| **Tella** (SaaS) | 2 (subreddit + Tella news) | `SaaS,Entrepreneur,screenrecording,productivity` | `tella,tellaapp,screenrecording` | (search: `tella screen recorder TikTok`) | (inferred: `tella.tv`) | `tella screen recorder review,tella tutorial` |
+
+**For comparison queries ("X vs Y"):** Resolve communities/handles for BOTH topics and merge the lists.
+
+**If you can't infer targeting for a platform, skip that flag -- the Python engine will fall back to keyword search.**
+
+**After resolving all handles and communities, display what you found before moving on.** This shows the user that intelligent pre-research happened:
+
+```
+Resolved:
+- X: @{HANDLE} (+ @{COMPANY}, @{COMMENTATOR})
+- Reddit: r/{sub1}, r/{sub2}, r/{sub3}
+- TikTok: #{hashtag1}, #{hashtag2}
+- YouTube: {query1}, {query2}
+```
+
+Only show lines for platforms where something was resolved. Skip empty lines. This display replaces the old "Parsed intent" block with something more useful.
+
+---
+
+## Step 0.75: Generate Query Plan (YOU are the planner)
+
+> **PLATFORM GATE:** If you skipped Step 0.55 because WebSearch is unavailable, **also skip this step.** The Python engine will plan internally (enhanced by `--auto-resolve` if a web search backend is configured). Jump to Research Execution.
+
+**If you have WebSearch and reasoning capability, YOU generate the query plan.** The Python script receives your plan via `--plan` and skips its internal planner entirely. This produces better results because you have full context about the topic.
+
+**Generate a JSON query plan for the topic.** Think about:
+1. What is the user's intent? (breaking_news, product, comparison, how_to, opinion, prediction, factual, concept)
+2. What subqueries would find the best content across different platforms?
+3. What related angles should be searched at lower weight?
+
+**Output a JSON plan with this shape:**
+
+```json
+{
+ "intent": "breaking_news",
+ "freshness_mode": "strict_recent",
+ "cluster_mode": "story",
+ "subqueries": [
+ {
+ "label": "primary",
+ "search_query": "kanye west",
+ "ranking_query": "What notable events involving Kanye West happened in the last 30 days?",
+ "sources": ["reddit", "x", "hackernews", "youtube", "tiktok", "instagram"],
+ "weight": 1.0
+ },
+ {
+ "label": "album",
+ "search_query": "kanye west bully album",
+ "ranking_query": "How was Kanye West's BULLY album received?",
+ "sources": ["youtube", "reddit", "tiktok", "instagram"],
+ "weight": 0.8
+ },
+ {
+ "label": "reactions",
+ "search_query": "kanye west bully review reaction",
+ "ranking_query": "What are the reviews and reactions to Kanye West's BULLY?",
+ "sources": ["youtube", "tiktok", "reddit"],
+ "weight": 0.6
+ }
+ ]
+}
+```
+
+**Rules for your plan:**
+- Emit 1 to 4 subqueries (more for complex/multi-faceted topics, fewer for simple ones)
+- **CRITICAL: Your PRIMARY subquery MUST include ALL of these sources: reddit, x, youtube, tiktok, instagram, hackernews, polymarket.** Never omit reddit (highest-signal discussion) or youtube (unique transcripts + official content). Secondary subqueries can target specific platforms.
+- `search_query` should be concise and keyword-heavy — match how content is TITLED on platforms
+- `ranking_query` should read like a natural language question
+- **DISAMBIGUATION:** If the topic name is a common word or has known non-product meanings (e.g., "Loom" = also a weaving tool, "Tella" = also a soccer player), add a qualifying term to your search_query to disambiguate. Examples: "tella screen recording" not just "tella", "loom video messaging" not just "loom". The product category prevents matching unrelated content.
+- **For comparison queries**, each subquery should include the product category: "tella screen recorder review" not just "tella review", "loom video tool pricing" not just "loom pricing".
+- NEVER include temporal phrases in search_query: no "last 30 days", "recent", month names, year numbers
+- NEVER include meta-research phrases: no "news", "updates", "public appearances"
+- Preserve exact proper nouns and entity strings from the topic
+- For comparison ("X vs Y"): create per-entity subqueries at weight 0.8 + a head-to-head subquery at weight 1.0
+- For product queries: route to YouTube (reviews), Reddit (discussions), TikTok (demos)
+- For predictions: include Polymarket in sources
+- For how_to: prioritize YouTube (tutorials) and Reddit (guides)
+- Primary subquery weight = 1.0, secondary = 0.6-0.8, peripheral = 0.3-0.5
+
+**Available sources (include ALL in primary subquery):** reddit, x, youtube, tiktok, instagram, hackernews, polymarket. Optional: bluesky, truthsocial, threads, pinterest, grounding (web search — only if user has Brave/Exa/Serper key)
+
+**Intent → freshness_mode mapping:**
+- breaking_news, prediction → `strict_recent`
+- concept, how_to → `evergreen_ok`
+- everything else → `balanced_recent`
+
+**Intent → cluster_mode mapping:**
+- breaking_news → `story`
+- comparison, opinion → `debate`
+- prediction → `market`
+- how_to → `workflow`
+- everything else → `none`
+
+Store your plan as `QUERY_PLAN_JSON` — you'll pass it to the script in the next step.
+
+---
+
## Research Execution
-**Step 1: Run the research script (FOREGROUND — do NOT background this)**
+**Step 1: Run the research script WITH your query plan (FOREGROUND)**
**CRITICAL: Run this command in the FOREGROUND with a 5-minute timeout. Do NOT use run_in_background. The full output contains Reddit, X, AND YouTube data that you need to read completely.**
-**IMPORTANT: The script handles API key/Codex auth detection automatically.** Run it and check the output to determine mode.
+**IMPORTANT: Pass your QUERY_PLAN_JSON via the --plan flag. This tells the Python script to use YOUR plan instead of calling Gemini.**
+
+**IMPORTANT: Include `--x-handle={RESOLVED_HANDLE}` in the command. For comparison mode: Pass `--x-handle={TOPIC_A_HANDLE}` to the first pass, `--x-handle={TOPIC_B_HANDLE}` to the second pass, and both to the head-to-head pass. Also include `--subreddits={RESOLVED_SUBREDDITS}`, `--tiktok-hashtags={RESOLVED_HASHTAGS}`, `--tiktok-creators={RESOLVED_TIKTOK_CREATORS}`, and `--ig-creators={RESOLVED_IG_CREATORS}` from Step 0.55. Omit any flag where the value was not resolved (empty).**
```bash
# Find skill root — works in repo checkout, Claude Code, or Codex install
@@ -344,12 +717,11 @@ for dir in \
"." \
"${CLAUDE_PLUGIN_ROOT:-}" \
"${GEMINI_EXTENSION_DIR:-}" \
- "$HOME/.claude/plugins/marketplaces/last30days-skill" \
- "$HOME/.gemini/extensions/last30days-skill" \
- "$HOME/.gemini/extensions/last30days" \
- "$HOME/.claude/skills/last30days" \
- "$HOME/.agents/skills/last30days" \
- "$HOME/.codex/skills/last30days"; do
+ "$HOME/.claude/plugins/marketplaces/last30days-skill-private" \
+ "$HOME/.claude/plugins/cache/last30days-skill-private/last30days-3-nogem/3.0.0-nogem" \
+ "$HOME/.claude/plugins/cache/last30days-skill-private/last30days-3/3.0.0-alpha" \
+ "$HOME/.claude/skills/last30days-3-nogem" \
+ "$HOME/.claude/skills/last30days-3"; do
[ -n "$dir" ] && [ -f "$dir/scripts/last30days.py" ] && SKILL_ROOT="$dir" && break
done
@@ -358,9 +730,25 @@ if [ -z "${SKILL_ROOT:-}" ]; then
exit 1
fi
-python3 "${SKILL_ROOT}/scripts/last30days.py" $ARGUMENTS --emit=compact --no-native-web --save-dir=~/Documents/Last30Days # Add --x-handle=HANDLE if RESOLVED_HANDLE is set
+python3 "${SKILL_ROOT}/scripts/last30days.py" $ARGUMENTS --emit=compact --save-dir=~/Documents/Last30Days --save-suffix=v3
```
+**If you ran Steps 0.55 and 0.75 (agent planning), add these flags:**
+- `--plan 'QUERY_PLAN_JSON'` (replace with actual JSON from Step 0.75)
+- `--x-handle={RESOLVED_HANDLE}` (from Step 0.5)
+- `--subreddits={RESOLVED_SUBREDDITS}` (from Step 0.55)
+- `--tiktok-hashtags={RESOLVED_HASHTAGS}` (from Step 0.55)
+- `--tiktok-creators={RESOLVED_TIKTOK_CREATORS}` (from Step 0.55)
+- `--ig-creators={RESOLVED_IG_CREATORS}` (from Step 0.55)
+- `--github-user={RESOLVED_GITHUB_USER}` (from Step 0.5b, person topics only)
+- `--github-repo={RESOLVED_GITHUB_REPOS}` (from Step 0.5c, product/project topics only)
+- Omit any flag where the value was not resolved (empty).
+
+**If you skipped Steps 0.55 and 0.75 (no WebSearch -- OpenClaw, Codex, etc.), add:**
+- `--auto-resolve` (the engine will use Brave/Exa/Serper to discover subreddits and context before planning)
+
+**If you skipped Steps 0.55 and 0.75 (no WebSearch), run the command as-is.** The Python engine will plan internally.
+
Use a **timeout of 300000** (5 minutes) on the Bash call. The script typically takes 1-3 minutes.
The script will automatically:
@@ -423,22 +811,65 @@ For ALL query types:
---
+## Step 2.5: Append WebSearch Results to Saved Raw File
+
+After completing the WebSearch supplementals above, append the results to the saved raw file so it becomes the complete debug artifact (Python engine data + WebSearch data).
+
+**Instructions:**
+1. Read the raw file at `~/Documents/Last30Days/{slug}-raw-nogem.md` (it was saved by the Python engine in Step 1).
+2. Append a `## WebSearch Supplemental Results` section at the end.
+3. For each WebSearch result, include the URL and a 1-2 sentence excerpt of what you found.
+4. Write the updated file back.
+
+Example of what to append:
+```
+## WebSearch Supplemental Results
+
+- **Efficient App** (https://efficientapp.com/tella-vs-loom) — Side-by-side comparison showing Tella exports in 27s vs Loom's 11s, with Tella at $19/mo and Loom free/$8/mo.
+- **Shannah Albert Blog** (https://shannahalbert.com/tella-review) — Creator walkthrough of Tella's recording flow, notes the teleprompter feature as a key differentiator.
+```
+
+This ensures anyone reviewing the raw file sees ALL data that fed into the synthesis — not just the Python engine output.
+
+---
+
## Judge Agent: Synthesize All Sources
-**After all searches complete, internally synthesize (don't display stats yet):**
+### v3 Cluster-First Output
+
+**v3 returns results grouped by STORY/THEME (clusters), not by source.** Each cluster represents one narrative thread found across multiple platforms.
+
+**How to read v3 output:**
+- `### 1. Cluster Title (score N, M items, sources: X, Reddit, TikTok)` — a story found across multiple platforms
+- `Uncertainty: single-source` — only one platform found this story (lower confidence)
+- `Uncertainty: thin-evidence` — all items scored below 55 (unconfirmed)
+- Items within a cluster show: source label, title, date, score, URL, and evidence snippet
+
+**Synthesis strategy for cluster-first output:**
+1. **Synthesize per-cluster first.** Each cluster = one story. Summarize what each story is about.
+2. **Multi-source clusters are highest confidence.** A cluster with items from Reddit + X + YouTube is much stronger than single-source.
+3. **Check uncertainty tags.** "single-source" means treat with caution. "thin-evidence" means mention but caveat.
+4. **Cross-cluster synthesis second.** After covering individual stories, identify themes that span clusters.
+5. **Engagement signals still matter.** Items with high likes/upvotes/views within a cluster are the strongest evidence points.
+6. **Quote directly from evidence snippets.** The snippets are pre-extracted best passages — use them.
+7. Extract the top 3-5 actionable insights across all clusters.
+8. **Disambiguation: trust your resolved entity.** When Step 0.55 resolved a specific entity (handles, subreddits, location context), prioritize content about THAT entity in your synthesis. If search results contain a different entity with the same name (e.g., a Spanish resort vs a WA athletic club both called "Bellevue Club"), lead with the entity your resolution identified. Mention the other only briefly, or not at all if the user clearly meant the resolved one. The resolved handles are the strongest signal for user intent.
+
+### Source-Specific Guidance (still applies within clusters)
The Judge Agent must:
1. Weight Reddit/X sources HIGHER (they have engagement signals: upvotes, likes)
2. Weight YouTube sources HIGH (they have views, likes, and transcript content)
3. Weight TikTok sources HIGH (they have views, likes, and caption content — viral signal)
4. Weight WebSearch sources LOWER (no engagement data)
-5. **For Reddit: Pay special attention to top comments** — they often contain the wittiest, most insightful, or funniest take. When a top comment has high upvotes (shown as `💬 Top comment (N upvotes)`), quote it directly in your synthesis. Reddit's value is in the comments.
-6. **For YouTube: Quote transcript highlights directly in your synthesis.** These are pre-extracted key moments from the video - treat them like Reddit top comments. Attribute to the channel name and include the actual quote. YouTube's value is in what creators SAY, not just their view counts.
+5. **For Reddit: Pay special attention to top comments** — they often contain the wittiest, most insightful, or funniest take. Quote them directly.
+6. **For YouTube: Quote transcript highlights directly.** Attribute to the channel name.
7. Identify patterns that appear across ALL sources (strongest signals)
8. Note any contradictions between sources
-9. Extract the top 3-5 actionable insights
-
-7. **Cross-platform signals are the strongest evidence.** When items have `[also on: Reddit, HN]` or similar tags, it means the same story appears across multiple platforms. Lead with these cross-platform findings - they're the most important signals in the research.
+9. **Multi-source clusters (items from 3+ platforms) are the strongest signals.** Lead with these.
+10. **For GitHub person-mode data:** When the output includes "GitHub Person Profile" items, these contain PR velocity, top repos with star counts, release notes, README summaries, and top issues. Lead with the velocity headline ("X PRs merged across Y repos"), then highlight the most impressive repos by star count. Weave release notes into the narrative to show what actually shipped. For own projects, mention top feature requests and complaints as community signal. The cross-source story is: "X is shipping Y (GitHub) while people on Z platform are saying W about it."
+11. **For GitHub project-mode data:** When the output includes "GitHub project:" items, these have live star counts, README snippets, release notes, and top issues fetched directly from the API. Always prefer these numbers over star counts cited by blog posts, YouTube videos, or tweets. Live API data is authoritative. When items include "(live: NNK stars)" annotations, use those numbers.
+12. **For GitHub star enrichment:** When candidates have `(live: NNK stars)` appended to their evidence, that number came from a post-research API check. It overrides whatever the original source claimed.
### Prediction Markets (Polymarket)
@@ -452,7 +883,7 @@ The Judge Agent must:
3. **Weave odds into the narrative as supporting evidence.** Don't isolate Polymarket data in its own paragraph. Instead: "Final Four buzz is building - Polymarket gives Arizona a 12% chance to win the championship (up 3% this week), and 28% to earn a #1 seed."
-4. **Citation format:** Always include specific odds AND movement. "Polymarket has Arizona at 28% for a #1 seed (up 10% this month)" - not just "per Polymarket."
+4. **Citation format: show ONLY % odds. NEVER mention dollar volumes, liquidity, or betting amounts.** The % odds are the magic of Polymarket -- the dollar amounts are internal liquidity metrics that mean nothing to readers. Say "Polymarket has Arizona at 28% for a #1 seed (up 10% this month)" -- NOT "28% ($24K volume)". The dollar figure adds zero value and clutters the insight.
5. **When multiple relevant markets exist, highlight 3-5 of the most interesting ones** in your synthesis, ordered by importance (structural > near-term). Don't just pick the highest-volume one.
@@ -464,6 +895,16 @@ The Judge Agent must:
**Do NOT display stats here - they come at the end, right before the invitation.**
+6. **Polymarket odds with real money behind them are STRONGER signals than opinions.** A $66K volume market with 96% odds is more reliable than 100 tweets. Always include specific percentages in the synthesis when Polymarket markets are confirmed relevant.
+
+### X Reply Cluster Weighting
+
+When you see a cluster of replies to a recommendation-request tweet (someone asking "what's the best X?" and getting multiple independent responses), call this out prominently. This is the strongest form of community endorsement — real people independently making the same recommendation without coordination. Example: "In a thread where @ecom_cork asked for Loom alternatives, every reply said Tella."
+
+### WebSearch Supplement Weighting for Comparisons
+
+For product comparison queries, WebSearch supplements (blog comparisons, review articles) should be weighted equally with social data. A detailed 2,000-word comparison article from Efficient App is more informative than 50 one-line tweets. Feature it in the synthesis.
+
---
## FIRST: Internalize the Research
@@ -477,6 +918,26 @@ Read the research output carefully. Pay attention to:
**ANTI-PATTERN TO AVOID**: If user asks about "clawdbot skills" and research returns ClawdBot content (self-hosted AI agent), do NOT synthesize this as "Claude Code skills" just because both involve "skills". Read what the research actually says.
+**FUN CONTENT: If the research output includes a "## Best Takes" section or items tagged with `fun:` scores, weave at least 2-3 of the funniest/cleverest quotes into your synthesis.** Reddit comments and X posts with high fun scores are the voice of the people. A 1,338-upvote comment that says "Where's the limewire link" tells you more about the cultural moment than a news article. Quote the actual text. Don't put fun content in a separate section - mix it into the narrative where it fits naturally. This is what makes the report feel alive rather than like a news summary.
+
+**ELI5 MODE: If ELI5_MODE is true for this run, apply these writing guidelines to your ENTIRE synthesis. If ELI5_MODE is false, skip this block completely and write normally.**
+
+ELI5 Mode: Explain it to me like I'm 5 years old.
+
+- Assume I know nothing about this topic. Zero context.
+- No jargon without a quick explanation in parentheses
+- Short sentences. One idea per sentence.
+- Start with the single most important thing that happened, in one line
+- Use analogies when they help ("think of it like...")
+- Keep the same structure: narrative, key patterns, stats, invitation
+- Still quote real people and cite sources - don't lose the grounding
+- Don't be condescending. Simple is not stupid. ELI5 means accessible, not childish.
+
+Example - normal: "Arizona's identity is paint scoring (50%+ shooting, 9th nationally) and rebounding behind Big 12 Player of the Year Jaden Bradley."
+Example - ELI5: "Arizona wins by being physical - they score most of their points close to the basket and they're one of the best shooting teams in the country."
+
+Same data. Same sources. Just clearer.
+
### If QUERY_TYPE = RECOMMENDATIONS
**CRITICAL: Extract SPECIFIC NAMES, not generic patterns.**
@@ -576,6 +1037,7 @@ Notable mentions: [other specific things with 1-2 mentions]
- Include subreddit names (r/hiphopheads) and web sources (Complex, Variety)
- Parse @handles from research output and include the highest-engagement ones
- Format naturally - tables work well for wide terminals, stacked cards for narrow
+- **CRITICAL whitespace rule:** Never insert more than ONE blank line between any two content blocks. Comparison tables should immediately follow the preceding paragraph with exactly one blank line. Do NOT pad with 3-6 empty lines before tables.
**If PROMPTING/NEWS/GENERAL** - Show synthesis and patterns:
@@ -633,6 +1095,15 @@ KEY PATTERNS from the research:
If the research output contains a `**🔍 Research Coverage:**` block, render it verbatim right before the stats block. This tells the user which core sources are missing and how to unlock them. Do NOT render this block if it is absent from the output (100% coverage = no nudge).
+**Just-in-time X unlock:** If X returned 0 results because no X auth is configured (no AUTH_TOKEN/CT0, no XAI_API_KEY, no FROM_BROWSER), offer to set it up right there:
+
+**Call AskUserQuestion:**
+Question: "X/Twitter wasn't searched. Want to unlock it?"
+Options:
+- "Scan my browser cookies (free)" - Get consent, run cookie scan, write BROWSER_CONSENT=true + FROM_BROWSER=auto to .env
+- "I have an xAI API key" - Ask them to paste it, write XAI_API_KEY to .env
+- "Skip for now"
+
**THEN - Stats (right before invitation):**
**CRITICAL: Calculate actual totals from the research output.**
@@ -650,12 +1121,16 @@ If the research output contains a `**🔍 Research Coverage:**` block, render it
├─ 🔴 YouTube: {N} videos │ {N} views │ {N} with transcripts
├─ 🎵 TikTok: {N} videos │ {N} views │ {N} likes │ {N} with captions
├─ 📸 Instagram: {N} reels │ {N} views │ {N} likes │ {N} with captions
+├─ 🧵 Threads: {N} posts │ {N} likes │ {N} replies
+├─ 📌 Pinterest: {N} pins │ {N} saves │ {N} comments
├─ 🟡 HN: {N} stories │ {N} points │ {N} comments
├─ 🦋 Bluesky: {N} posts │ {N} likes │ {N} reposts
├─ 🇺🇸 Truth Social: {N} posts │ {N} likes │ {N} reposts
-├─ 📊 Polymarket: {N} markets │ {short summary of up to 5 most relevant market odds, e.g. "Championship: 12%, #1 Seed: 28%, Big 12: 64%, vs Kansas: 71%"}
+├─ 🐙 GitHub: {N} items │ {N} reactions │ {N} comments
+├─ 📊 Polymarket: {N} markets │ {copy the market odds EXACTLY from the engine's Polymarket stats output - only real % numbers like "Arizona 33%, Michigan 25%". If you cannot find specific % odds in the data, show ONLY the market count with no description. NEVER write filler like "check markets", "active", "tracked", or any text without a real percentage.}
├─ 🌐 Web: {N} pages — Source Name, Source Name, Source Name
-└─ 🗣️ Top voices: @{handle1} ({N} likes), @{handle2} │ r/{sub1}, r/{sub2}
+├─ 🗣️ Top voices: @{handle1} ({N} likes), @{handle2} │ r/{sub1}, r/{sub2}
+└─ 📎 Raw results saved to ~/Documents/Last30Days/{slug}-raw.md
---
```
@@ -751,6 +1226,10 @@ For `/last30days war in Iran` (NEWS):
> - How is this playing differently in US vs international media?
> - What's the economic impact on oil markets so far?
+I have all the links to the {N} {source list} I pulled from. Just ask.
+
+**Context-aware:** Only list sources that returned results. Build the source list from your stats: e.g. "14 Reddit threads, 22 X posts, and 6 YouTube videos" or "8 HN stories and 3 Polymarket markets." Never mention a source with 0 results.
+
---
## WAIT FOR USER'S RESPONSE
@@ -767,6 +1246,10 @@ For `/last30days war in Iran` (NEWS):
- If they ask to **GO DEEPER** on a subtopic → Elaborate using your research findings
- If they describe something they want to **CREATE** → Write ONE perfect prompt (see below)
- If they ask for a **PROMPT** explicitly → Write ONE perfect prompt (see below)
+- If they say **"more fun"**, **"too serious"**, or similar → Write `FUN_LEVEL=high` to `~/.config/last30days/.env` (append, don't overwrite). Confirm: "Fun level set to high. Next run will surface more witty and viral content."
+- If they say **"less fun"**, **"too many jokes"**, or similar → Write `FUN_LEVEL=low` to `~/.config/last30days/.env`. Confirm: "Fun level set to low. Next run will focus on the news."
+- If they say **"eli5 on"**, **"eli5 mode"**, **"explain simpler"**, or similar → Write `ELI5_MODE=true` to `~/.config/last30days/.env`. Confirm: "ELI5 mode on. All future runs will explain things like you're 5."
+- If they say **"eli5 off"**, **"normal mode"**, **"full detail"**, or similar → Write `ELI5_MODE=false` to `~/.config/last30days/.env`. Confirm: "ELI5 mode off. Back to full detail."
**Only write a prompt when the user wants one.** Don't force a prompt on someone who asked "what could happen next with Iran."
@@ -854,13 +1337,13 @@ Want another prompt? Just tell me what you're creating next.
## Security & Permissions
**What this skill does:**
-- Sends search queries to ScrapeCreators API (`api.scrapecreators.com`) for Reddit search, subreddit discovery, and comment enrichment (requires SCRAPECREATORS_API_KEY — same key as TikTok + Instagram)
+- Sends search queries to ScrapeCreators API (`api.scrapecreators.com`) for TikTok and Instagram search, and as a Reddit backup when public Reddit is unavailable (requires SCRAPECREATORS_API_KEY)
- Legacy: Sends search queries to OpenAI's Responses API (`api.openai.com`) for Reddit discovery (fallback if no SCRAPECREATORS_API_KEY)
- Sends search queries to Twitter's GraphQL API (via optional user-provided AUTH_TOKEN/CT0 env vars — no browser session access) or xAI's API (`api.x.ai`) for X search
- Sends search queries to Algolia HN Search API (`hn.algolia.com`) for Hacker News story and comment discovery (free, no auth)
- Sends search queries to Polymarket Gamma API (`gamma-api.polymarket.com`) for prediction market discovery (free, no auth)
- Runs `yt-dlp` locally for YouTube search and transcript extraction (no API key, public data)
-- Sends search queries to ScrapeCreators API (`api.scrapecreators.com`) for TikTok and Instagram search, transcript/caption extraction (same SCRAPECREATORS_API_KEY as Reddit, PAYG after 100 free API calls)
+- Sends search queries to ScrapeCreators API (`api.scrapecreators.com`) for TikTok and Instagram search, transcript/caption extraction (PAYG after 10,000 free API calls)
- Optionally sends search queries to Brave Search API, Parallel AI API, or OpenRouter API for web search
- Fetches public Reddit thread data from `reddit.com` for engagement metrics
- Stores research findings in local SQLite database (watchlist mode only)
@@ -873,7 +1356,7 @@ Want another prompt? Just tell me what you're creating next.
- Does not log, cache, or write API keys to output files
- Does not send data to any endpoint not listed above
- Hacker News and Polymarket sources are always available (no API key, no binary dependency)
-- TikTok and Instagram sources require SCRAPECREATORS_API_KEY (same key covers both; 100 free API calls, then PAYG)
+- TikTok and Instagram sources require SCRAPECREATORS_API_KEY (10,000 free API calls, then PAYG). Reddit uses ScrapeCreators only as a backup when public Reddit is unavailable.
- Can be invoked autonomously by agents via the Skill tool (runs inline, not forked); pass `--agent` for non-interactive report output
**Bundled scripts:** `scripts/last30days.py` (main research engine), `scripts/lib/` (search, enrichment, rendering modules), `scripts/lib/vendor/bird-search/` (vendored X search client, MIT licensed)
diff --git a/docs/comparison-results/compact/base-1-claude-code.md b/docs/comparison-results/compact/base-1-claude-code.md
deleted file mode 100644
index 31b64b7..0000000
--- a/docs/comparison-results/compact/base-1-claude-code.md
+++ /dev/null
@@ -1,162 +0,0 @@
-## Research Results: Claude Code skills and MCP servers
-
-**Date Range:** 2026-01-26 to 2026-02-25
-**Mode:** both
-**OpenAI Model:** gpt-5.2
-**xAI Model:** grok-4-1-fast
-
-### Reddit Threads
-
-**R2** (score:76) r/ClaudeCode (2026-02-22)
- We 3x'd our team's Claude Code skill usage in 2 weeks — here's how
- https://www.reddit.com/r/ClaudeCode/comments/1rbr5t7/we_3xd_our_teams_claude_code_skill_usage_in_2/
- *Discussion of Claude Code skills adoption/usage, plus mentions syncing skills/hooks/MCP configs.*
-
-**R4** (score:75) r/ClaudeCode (2026-02-23)
- ClaudeInOne — a full framework for Claude Code, installed in one command
- https://www.reddit.com/r/ClaudeCode/comments/1rcbria/claudeinone_a_full_framework_for_claude_code/
- *Framework bundling many Claude Code skills/agents/commands; lots of skills-related discussion.*
-
-**R1** (score:74) r/ClaudeCode (2026-02-18)
- Self-improvement Loop: My favorite Claude Code Skill
- https://www.reddit.com/r/ClaudeCode/comments/1r89084/selfimprovement_loop_my_favorite_claude_code_skill/
- *Directly about a Claude Code Skill (custom skill, how it works, SKILL.md).*
-
-**R3** (score:66) r/ClaudeCode (2026-02-10)
- I built 12 SEO skills for Claude Code (open source)
- https://www.reddit.com/r/ClaudeCode/comments/1r0l549/i_built_12_seo_skills_for_claude_code_open_source/
- *Concrete example of building and sharing Claude Code skills.*
-
-### X Posts
-
-**X6** (score:86) @zeeg (2026-02-25) [4likes, 1rt]
- @adamwathan Codex has been crushing it for me with implicit skill usage to the point I was shocked
-
-Claude Code I resorted to explicit mentions and I’ve been pleasantly surprised by how well Codex ha...
- https://x.com/zeeg/status/2026745680195367091
- **
-
-**X4** (score:80) @ihtesham2005 (2026-02-25) [6likes]
- 🚨 Anthropic just open-sourced the exact Skills library their own engineers use internally.
-
-Stop building Claude workflows from scratch.
-
-These are plug-and-play components that work across Claude Cod...
- https://x.com/ihtesham2005/status/2026752089473314975
- **
-
-**X9** (score:74) @DevTenta (2026-02-25) [4likes]
- Day 7 building my first B2C app
-
-Been setting everything up this week. Manually built 5 agents to help me in the early stages and set up the Claude Code skills i'll need for the build.
-
-Also been test...
- https://x.com/DevTenta/status/2026742028730527786
- **
-
-**X8** (score:64) @JorgeJaramillo (2026-02-25)
- @Jompiras Claude code + skills. Muy breve....
- https://x.com/JorgeJaramillo/status/2026743135435362649
- **
-
-**X5** (score:64) @arpan7sarkar (2026-02-25)
- @tom_doerr 135 agents, 35 curated skills, 121 plugins and 6 MCP configs all in one Claude Code toolkit is absolutely insane value 🤯 this is the kind of community effort that makes an AI tool go from g...
- https://x.com/arpan7sarkar/status/2026746479369662551
- **
-
-**X7** (score:64) @arpan7sarkar (2026-02-25)
- @tom_doerr A curated collection of Claude Skills organized by category is exactly what power users have been waiting for 🤩 document skills, code tools, data analysis all in one repo. this is the Claud...
- https://x.com/arpan7sarkar/status/2026745559986614335
- **
-
-**X2** (score:64) @yasuky (2026-02-25)
- Claude CodeのSkillsを作成例から徹底理解する https://t.co/vZTseio2Ii...
- https://x.com/yasuky/status/2026754773786226708
- **
-
-**X1** (score:64) @matgoldsborough (2026-02-25)
- How: I told Claude Code "build me a CRM." Our builder skill generated schemas, skills, server, seed data. I pointed Claude Desktop at it. Contacts, lead scoring, pipeline reviews. All from conversatio...
- https://x.com/matgoldsborough/status/2026755742737510749
- **
-
-**X10** (score:64) @0x_Kapoor (2026-02-25)
- I can build another open claw, or claude bot, or even a messaging app in a nice prompt and although having engineering skills, I can push the code on the public repo and secure it even completely and ...
- https://x.com/0x_Kapoor/status/2026738663321907214
- **
-
-**X11** (score:56) @ghumare64 (2026-02-25) [1likes]
- 1. I use https://t.co/NtFYJEiFs9 inspired by @bcherny
-2. I use https://t.co/EwnmyWNFqn to sync skills and scan skills from the marketplace
-3. I chat with claude code 4-5 times and once I am satisifed...
- https://x.com/ghumare64/status/2026736176695246966
- **
-
-**X12** (score:56) @hamen (2026-02-25) [1likes]
- The 'vibe coding' revolution is forcing a reckoning.
-
-Developers with 15+ years experience are either embracing AI to expand their reach or watching their expertise become as valuable as COBOL skills....
- https://x.com/hamen/status/2026734075243790396
- **
-
-### YouTube Videos
-
-**X8afcX2s2Mo** (score:74) Grace Leung (2026-02-21) [47,474 views, 1,900 likes]
- Claude Skills: Build Your First AI Marketing Team in 16 Minutes (Claude Code)
- https://www.youtube.com/watch?v=X8afcX2s2Mo
- Transcript: Every marketer I know is stretch themed. Too many channels, too many deliverables, never enough time, whether you're running a team or doing it all yourself. And that's why I'm so obsessed with clock ...
- *YouTube video about claude code skills and mcp servers*
-
-**0J2_YGuNrDo** (score:61) Grace Leung (2025-12-16) [155,806 views, 4,760 likes]
- Claude Code just Built me an AI Agent Team (Claude Code + Skills + MCP)
- https://www.youtube.com/watch?v=0J2_YGuNrDo
- Transcript: I use clot more than any other AI but even I dismiss Clark as just for developers and I was wrong after using myself I am completely so and this is too powerful to ignore if you want to use AI to buil...
- *YouTube video about claude code skills and mcp servers*
-
-**vIUJ4Hd7be0** (score:59) Leon van Zyl (2026-02-09) [25,538 views, 699 likes]
- Claude Code Skills - The Only Tutorial You Need
- https://www.youtube.com/watch?v=vIUJ4Hd7be0
- *YouTube video about claude code skills and mcp servers*
-
-**Gqh_KdHP1Xk** (score:56) Robin Ebers (2025-08-21) [90,281 views, 2,719 likes]
- 8 MCP Servers That Make Claude Code 10x Better
- https://www.youtube.com/watch?v=Gqh_KdHP1Xk
- Transcript: Over 90% of all MCP servers are complete overhyped garbage. But some of them are not only worth it, they can be complete game changers. And I do not say this lightly. So after testing over 100 of them...
- *YouTube video about claude code skills and mcp servers*
-
-**901VMcZq8X4** (score:51) Sean Kochel (2025-10-21) [44,602 views, 1,356 likes]
- These 5 Claude Code Skills Are Your New Unfair Advantage
- https://www.youtube.com/watch?v=901VMcZq8X4
- *YouTube video about claude code skills and mcp servers*
-
-**l7qVtHpctic** (score:47) Kenny Liao (2026-01-17) [21,765 views, 728 likes]
- Claude Code's MCP Problem Just Got Fixed
- https://www.youtube.com/watch?v=l7qVtHpctic
- *YouTube video about claude code skills and mcp servers*
-
-**M5CsRj6zSCA** (score:41) Eric Tech (2025-12-12) [11,286 views, 266 likes]
- 5 MCP Servers That Make Claude Code 10x More Powerful (Full-Stack App Build)
- https://www.youtube.com/watch?v=M5CsRj6zSCA
- *YouTube video about claude code skills and mcp servers*
-
-**qthyl0GCpDo** (score:35) Postman (2025-11-20) [7,045 views, 105 likes]
- Claude Skills vs MCP: What’s the Difference and When to Use Each?
- https://www.youtube.com/watch?v=qthyl0GCpDo
- *YouTube video about claude code skills and mcp servers*
-
-**ZroGqu7GyXM** (score:34) Solo Swift Crafter (2025-10-27) [4,880 views, 98 likes]
- Claude Code Skills vs MCP vs Sub Agents: What Works for Solo Devs?
- https://www.youtube.com/watch?v=ZroGqu7GyXM
- *YouTube video about claude code skills and mcp servers*
-
-**jzf7DQa2CAc** (score:31) Matt Kuda (2026-01-19) [2,547 views, 87 likes]
- How I Use Claude Code With Skills, MCP, Agents & Plugins
- https://www.youtube.com/watch?v=jzf7DQa2CAc
- *YouTube video about claude code skills and mcp servers*
-
----
-**Sources:**
- ✅ Reddit: 4 threads
- ✅ X: 11 posts
- ✅ YouTube: 10 videos (3 with transcripts)
- ⏭️ HN: 0 stories found
- ⚡ Web: assistant will use WebSearch
diff --git a/docs/comparison-results/compact/base-2-seedance.md b/docs/comparison-results/compact/base-2-seedance.md
deleted file mode 100644
index b447500..0000000
--- a/docs/comparison-results/compact/base-2-seedance.md
+++ /dev/null
@@ -1,233 +0,0 @@
-## Research Results: Seedance AI video generation
-
-**Date Range:** 2026-01-26 to 2026-02-25
-**Mode:** both
-**OpenAI Model:** gpt-5.2
-**xAI Model:** grok-4-1-fast
-
-### Reddit Threads
-
-**R23** (score:76) r/generativeAI (2026-02-24)
- Official website for creating content with Seedance 2.0?
- https://www.reddit.com/r/generativeAI/comments/1rcgyi4/official_website_for_creating_content_with/
- *Direct thread about where to use Seedance 2.0 for generating videos (and discussion of scams/third parties).*
-
-**R19** (score:76) r/generativeAI (2026-02-22)
- This is terrifying!! Seedance 2.0 just generated a 1-minute film with ZERO editing — the entire film industry should be worried
- https://www.reddit.com/r/generativeAI/comments/1rbionc/this_is_terrifying_seedance_20_just_generated_a/
- *High-signal discussion about Seedance 2.0 video generation quality (multi-shot coherence, transitions, filmmaking).*
-
-**R20** (score:75) r/Seedance_AI (2026-02-23)
- Help! I need to use seedance or jimeng video function
- https://www.reddit.com/r/Seedance_AI/comments/1rckgcm/help_i_need_to_use_seedance_or_jimeng_video/
- *Access/how-to thread specifically about using Seedance/Jimeng video generation.*
-
-**R18** (score:72) r/Seedance_AI (2026-02-20)
- so disappointed and frustrated with recent changes (wasted 1k$+)
- https://www.reddit.com/r/Seedance_AI/comments/1ra6nf4/so_disappointed_and_frustrated_with_recent/
- *User report about Seedance video generation failing review/guardrails and credit usage—directly about Seedance generation.*
-
-**R25** (score:71) r/SaasDevelopers (2026-02-24)
- Third-party Seedance 2.0 API
- https://www.reddit.com/r/SaasDevelopers/comments/1rdw3uz/thirdparty_seedance_20_api/
- *Discussion about Seedance 2.0 video generation availability via third-party API and Dreamina integration.*
-
-**R21** (score:71) r/Seedance_AI (2026-02-23)
- What happened to doubao?
- https://www.reddit.com/r/Seedance_AI/comments/1rcox3z/what_happened_to_doubao/
- *Discussion around ByteDance/Doubao access issues while trying to use Seedance 2 (video generation availability).*
-
-**R28** (score:68) r/Seedance_AI (2026-02-24)
- Selfie Situation • Seedance 2.0 • Third-party API by useapi.net
- https://www.reddit.com/r/Seedance_AI/comments/1rduxop/selfie_situation_seedance_20_thirdparty_api_by/
- *Seedance 2.0 discussion focused on third-party access and whether generations are truly Seedance 2.0 quality.*
-
-**R22** (score:68) r/AIGuild (2026-02-23)
- ByteDance Faces Hollywood Backlash Over Seedance 2.0
- https://www.reddit.com/r/AIGuild/comments/1rc4pkg/bytedance_faces_hollywood_backlash_over_seedance/
- *Seedance 2.0 discussion tied to generated video content/IP concerns and potential impacts on access/release.*
-
-**R17** (score:67) r/AI_UGC_Marketing (2026-02-17)
- Elevenlabs silently removed Seedance 2.0 after SAG-AFTRA incident, can’t create UGC style video
- https://www.reddit.com/r/AI_UGC_Marketing/comments/1r70942/elevenlabs_silently_removed_seedance_20_after/
- *Discussion about Seedance 2.0 availability/removal in a toolchain for generating UGC-style AI videos.*
-
-**R27** (score:64) r/u_PoppyVonMiller (2026-02-24)
- Seedance 2.0 Music Video
- https://www.reddit.com/r/u_PoppyVonMiller/comments/1rdkptn/seedance_20_music_video/
- *Example/discussion of an output video made with Seedance 2.0 (AI video generation results).*
-
-**R13** (score:63) r/generativeAI (2026-02-10)
- Where is the official Seedance website?
- https://www.reddit.com/r/generativeAI/comments/1r19eem/where_is_the_official_seedance_website/
- *Seedance access/official-site discussion (important for actually generating videos with Seedance).*
-
-**R15** (score:62) r/AiVideos_NoRules (2026-02-15)
- Seedance 2.0
- https://www.reddit.com/r/AiVideos_NoRules/comments/1r51m86/seedance_20/
- *Thread about Seedance 2.0 as an AI video generator and its capabilities/impact.*
-
-**R12** (score:62) r/GoogleGeminiAI (2026-02-09)
- Has anyone used Seedance 2.0 yet?
- https://www.reddit.com/r/GoogleGeminiAI/comments/1qzrhgd/has_anyone_used_seedance_20_yet/
- *Direct discussion of Seedance 2.0 usage/access for AI video generation and where people are trying it.*
-
-**R24** (score:61) r/aivideo (2026-02-24)
- [Removed]
- https://www.reddit.com/r/aivideo/comments/1rdi8sm/removed/
- *Thread/comments reference Seedance 2.0 access and community moderation around AI-generated video/IP.*
-
-**R16** (score:61) r/nairobitechies (2026-02-16)
- ByteDance Seedance: Multimodal Video Generation Reaches a New Threshold
- https://www.reddit.com/r/nairobitechies/comments/1r6gwyj/bytedance_seedance_multimodal_video_generation/
- *Seedance family discussion (1.0/1.5/2.0) focusing on video generation features and multimodal inputs.*
-
-### X Posts
-
-**X9** (score:86) @HBCoop_ (2026-02-25) [9likes]
- First Seedance 2.0 Test!
-
-Seedance 5.0 Lite @krea_ai → Seedance 2.0 @capcutapp
-
-Prompt: Close-up on her hands gripping the paintbrush, knuckles white, paint dripping down her wrist. Shallow depth of ...
- https://x.com/HBCoop_/status/2026748738354450919
- **
-
-**X12** (score:77) @Noor_ul_ain43 (2026-02-25) [5likes]
- Goku vs Broly , EPIC Full Battle!
-Witness the ultimate Saiyan showdown recreated with AI power. Explosive transformations, insane energy blasts, and nonstop action all brought to life with Seedance 2...
- https://x.com/Noor_ul_ain43/status/2026743666635845959
- **
-
-**X10** (score:66) @grok (2026-02-25) [2likes]
- @WolfyBlair @Preda2005 @BytePlusGlobal @capcutapp Yes, it's true—Seedance 2.0 is now live in CapCut (desktop & mobile).
-
-See it in the AI video tools: https://t.co/K3gwCgUSyb
-
-Just update the ap...
- https://x.com/grok/status/2026747009143549978
- **
-
-**X1** (score:64) @jznode (2026-02-25)
- @ChinyJPG capcut desktop app, they just added seedance 2.0 today. no chinese phone number needed, works globally. download the desktop version, look for the AI video generation option. runs about $2 p...
- https://x.com/jznode/status/2026755966545404015
- **
-
-**X5** (score:64) @Yousaf_340 (2026-02-25)
- @ProperPrompter Yes! Seedance 2.0 on CapCut means cinematic AI video editing is super accessible smooth action, multi-angle shots, and high-quality effects right from your phone. 🎬🔥...
- https://x.com/Yousaf_340/status/2026751320716357996
- **
-
-**X6** (score:64) @Yousaf_340 (2026-02-25)
- @ivanka_humeniuk Seedance 2.0 is insane for cinematic AI video smooth action, multiple camera angles, and near-Hollywood quality all from a single prompt....
- https://x.com/Yousaf_340/status/2026750898769473829
- **
-
-**X3** (score:64) @EmmaUsesAi (2026-02-25)
- Seedance 2.0 is the motion engine behind many cinematic AI clips trending right now.
-
-It’s now native inside NemoVideo.
-
-You can:
-- Identify proven formats
-- Analyze what drives retention
-- Turn an id...
- https://x.com/EmmaUsesAi/status/2026751784509673531
- **
-
-**X2** (score:64) @OneStrangeW (2026-02-25)
- You can use Seedance. If Seedance 2.0 hasn't launch yet, the prior version is great, and stick pretty well to the prompt. Of course the video agent must be well trained. 🙂Also, you can offer SORA for ...
- https://x.com/OneStrangeW/status/2026755320362709093
- **
-
-**X11** (score:64) @WyldeChyldeRec (2026-02-25)
- 🚨Phishing Scam!
-
-Watch for emails titled:
-
-[Seedance & Wan AI] Unrestricted generation with Grok Imagine video added
-
-This is not real. We don't have a Seedance account, and the links are not offi...
- https://x.com/WyldeChyldeRec/status/2026743867224240149
- **
-
-**X7** (score:56) @La_DeCrypt (2026-02-25) [1likes]
- - Kling (https://t.co/WGphQsdLf1)
-
-- Seedance 2.0 (https://t.co/A8UzLTRGNo)
-
-☞ AI-powered video editing:
-
-- Descript (https://t.co/q4axNEjDzt)
-- OpusClip (https://t.co/5XPn4EPH6u)
-- Submagic (https:/...
- https://x.com/La_DeCrypt/status/2026750376687394884
- **
-
-**X8** (score:56) @TferThomas (2026-02-25) [1likes]
- Seedance 2.0 might be gen AI video’s next big hope, but it’s still slop https://t.co/kT1baOYJQk #AI...
- https://x.com/TferThomas/status/2026749218224746609
- **
-
-### YouTube Videos
-
-**F1kWxdfiBNE** (score:73) AI Filmmaking Academy (2026-02-22) [40,982 views, 1,088 likes]
- Seedance 2.0 Claims the AI Video Throne!
- https://www.youtube.com/watch?v=F1kWxdfiBNE
- *YouTube video about seedance ai video generation*
-
-**_o2MuUX9UYg** (score:73) Theoretically Media (2026-02-09) [199,908 views, 4,986 likes]
- Seedance 2.0 Claims the AI Video Throne!
- https://www.youtube.com/watch?v=_o2MuUX9UYg
- Transcript: So, it's been about a week since I declared Cling 3.0 the new benchmark for state-of-the-art AI video. And well, I mean, it's been a week, and that basically equals about 3 months in AI time. So, yes,...
- *YouTube video about seedance ai video generation*
-
-**jkPTYD5lXo8** (score:70) How To In 5 Minutes (2026-02-19) [44,344 views, 782 likes]
- 100% FREE Seedance 2.0 AI Video Generator : How to Use It WORLDWIDE
- https://www.youtube.com/watch?v=jkPTYD5lXo8
- Transcript: No, this is not clickbait. You can now access Cance 2.0 for free from anywhere. No registration required and depending on how you use it, this method can [music] feel almost unlimited. And yes, at the...
- *YouTube video about seedance ai video generation*
-
-**W_lxyDFDZt4** (score:69) WealthWise (2026-02-12) [126,599 views, 1,204 likes]
- Seedance 2.0: The New Best AI Video Generator | Sora 2 Destroyed
- https://www.youtube.com/watch?v=W_lxyDFDZt4
- Transcript: Well, major AI video update, ladies and gentlemen. Seance 2.0 is right around the corner. And let me tell you guys, this is something I have never seen before. Just take a look at some of the videos g...
- *YouTube video about seedance ai video generation*
-
-**R1zl92NhCfE** (score:65) Ai Lockup (2026-02-16) [32,699 views, 591 likes]
- Seedance 2.0 How To Use and Become a Pro AI Film Maker With This AI Video Generator
- https://www.youtube.com/watch?v=R1zl92NhCfE
- *YouTube video about seedance ai video generation*
-
-**G1Ad4a8sdJU** (score:63) Benji’s AI Playground (2026-02-14) [38,581 views, 419 likes]
- SeeDance 2.0: The Next Level of AI Video — And What It Means for Local AI Users?
- https://www.youtube.com/watch?v=G1Ad4a8sdJU
- *YouTube video about seedance ai video generation*
-
-**n625xVounGM** (score:61) John Savage AI (2026-02-23) [7,184 views, 126 likes]
- How To Generate FREE AI Seedance 2.0 Videos (Access Worldwide)
- https://www.youtube.com/watch?v=n625xVounGM
- *YouTube video about seedance ai video generation*
-
-**kJ0NAVmd4f4** (score:58) Rogue Cell Pictures (2026-02-25) [1,544 views, 147 likes]
- Seedance 2.0 Changes Filmmaking Forever | New Original Series
- https://www.youtube.com/watch?v=kJ0NAVmd4f4
- *YouTube video about seedance ai video generation*
-
-**FP8TaJSFohs** (score:55) Chem Beast (2026-02-24) [3,194 views, 31 likes]
- How To Use Seedance 2 0 Full AI Video Generator Tutorial + Free Access Guide
- https://www.youtube.com/watch?v=FP8TaJSFohs
- *YouTube video about seedance ai video generation*
-
-**61ThJGqwHsI** (score:48) xCreate (2026-02-11) [4,854 views, 86 likes]
- Ultimate AI Video Generation - Seedance 2.0 PREVIEW
- https://www.youtube.com/watch?v=61ThJGqwHsI
- *YouTube video about seedance ai video generation*
-
----
-**Sources:**
- ✅ Reddit: 16 threads
- ✅ X: 11 posts
- ✅ YouTube: 10 videos (3 with transcripts)
- ⏭️ HN: 0 stories found
- ⚡ Web: assistant will use WebSearch
diff --git a/docs/comparison-results/compact/base-3-macbook.md b/docs/comparison-results/compact/base-3-macbook.md
deleted file mode 100644
index ef95972..0000000
--- a/docs/comparison-results/compact/base-3-macbook.md
+++ /dev/null
@@ -1,198 +0,0 @@
-## Research Results: M4 MacBook Pro review
-
-**Date Range:** 2026-01-26 to 2026-02-25
-**Mode:** both
-**OpenAI Model:** gpt-5.2
-**xAI Model:** grok-4-1-fast
-
-### Reddit Threads
-
-**R1** (score:0) r/macbookpro (2025-04-09) [date:low]
- M4 Macbook Pro 14’ 1 month review
- https://www.reddit.com/r/macbookpro/comments/1hflaaz/m4_macbook_pro_14_1_month_review/
- *Explicit 1-month review of the M4 MacBook Pro (ownership impressions, battery, durability, etc.).*
-
-**R2** (score:0) r/macbookpro (2025-05-19) [date:low]
- MacBook Pro M4 Battery Life Reality vs Review
- https://www.reddit.com/r/macbookpro/comments/1kq2zd8/macbook_pro_m4_battery_life_reality_vs_review/
- *Discussion comparing real-world M4 MacBook Pro battery life to published reviews/testing methodology.*
-
-**R3** (score:0) r/macbookpro (2025-08-18) [date:low]
- Disappointed with my MBP M4 Experience …
- https://www.reddit.com/r/macbookpro/comments/1mtmkts/disappointed_with_my_mbp_m4_experience/
- *First-hand experience post that references reviews and reports performance/heat/app stability issues on an M4 MBP.*
-
-### X Posts
-
-**X1** (score:86) @bhphoto (2026-02-25) [3likes]
- If you are in the market for a new MacBook Pro and you’re wondering what the real differences are between Apple’s M3 and M4 silicon, you’ve come to the right place ⬇️
-https://t.co/nzjXmiHmac https://t...
- https://x.com/bhphoto/status/2026737767695183948
- **
-
-**X4** (score:70) @remplug (2026-02-25) [1likes, 1rt]
- 🔥 2024 16” MacBook Pro Price List
-
-1️⃣ 🇺🇸 Brand New MacBook Pro 2024 | M4 Pro Chip | 16” Display
-
-24GB RAM | 512GB SSD
-14-Core CPU | 20-Core GPU
-
-Space Black
-
-💰 ₦2,720,000
-
-2️⃣ 🇺🇸 Brand New MacBook Pr...
- https://x.com/remplug/status/2026725100213465443
- **
-
-**X10** (score:64) @grok (2026-02-25)
- @5TRgzn @LobstarWilde My marble floors: 64GB M4 Pro/Max MacBook Pro. Local Grok agents run full autonomy loops, zero cloud, sub-second trades/scans. Intel's ancient—ditch it. Yours at $3-4k....
- https://x.com/grok/status/2026713588472111312
- **
-
-**X9** (score:64) @principenemesis (2026-02-25)
- @JulianGoldieSEO @grok can a macbook pro m4 run this?...
- https://x.com/principenemesis/status/2026716193042706852
- **
-
-**X5** (score:64) @jameslmorton (2026-02-25) [1likes]
- @SebAaltonen Not my experience. It’s impressive, 70 tokens/s on a maxed out M4 128gb MacBook Pro.
-
-But absolutely sucked and not even remotely in the ballpark with Sonnet 4.5. It’s GPT-3.5.
-
-Totally b...
- https://x.com/jameslmorton/status/2026721943630786816
- **
-
-**X6** (score:64) @bcofertas (2026-02-25)
- BR Ofertas todos os dias 🇧🇷
-
-Leve seu Mac Pro M4 hoje! 💻⚡✨
-
-Pegue já: https://t.co/PSwylZH3Sz
-
-Por R$ 23.749,00
-em até 10x sem juros
-Frete Grátis
-
-Apple MacBook Pro 14″ 2024 M4 Pro 12 núcleos 24GB 512...
- https://x.com/bcofertas/status/2026721014676099084
- **
-
-**X12** (score:64) @grok (2026-02-25)
- Hey! Yeah, you can install Fedora on the M6 MacBook Pro via the official Fedora Asahi Remix (built on the Asahi Linux project). It already runs great on M1/M2, with M3 support rolling out now and work...
- https://x.com/grok/status/2026704108820824113
- **
-
-**X7** (score:64) @toppromoalertas (2026-02-25)
- Promoções BR do dia 🇧🇷
-
-Leve seu Mac Pro M4 hoje! 💻⚡✨
-
-Ver aqui: https://t.co/LfxNw3waps
-
-Por R$ 23.749,00
-em até 10x sem juros
-Frete Grátis
-
-Apple MacBook Pro 14″ 2024 M4 Pro 12 núcleos...
-
-Amazon | ...
- https://x.com/toppromoalertas/status/2026719111523237932
- **
-
-**X8** (score:64) @grok (2026-02-25)
- Yes, a MacBook Pro M4 can run this Ollama + GLM-4.7-Flash setup locally.
-
-Ollama has native Apple Silicon support via Metal. The Q4_K_M model (~19GB) needs ~18-24GB unified memory for good speed (feel...
- https://x.com/grok/status/2026716420269031652
- **
-
-**X2** (score:56) @grecinoscdev (2026-02-25) [1likes]
- @TechByTaraa Both. macOS (Macbook Pro M4 Pro) for my photography/videography personal stuff + software engineering and a Linux desktop workstation for software engineering + second desktop workstation...
- https://x.com/grecinoscdev/status/2026731118142148644
- **
-
-**X11** (score:56) @Steve_Lost_Jobs (2026-02-25) [1likes]
- Apple 2024 MacBook Pro 14コアCPU、20コアGPU の M4 Pro搭載ノートパ... PR
-
-4549995547610
-
-ポイント: 3735㌽
-想定価格: 369,800
-
-2026/02/26 01:55:23
-https://t.co/q1uyIqFsID...
- https://x.com/Steve_Lost_Jobs/status/2026705327828382016
- **
-
-**X3** (score:56) @grok (2026-02-25) [1likes]
- No, Apple has not made any MacBook (Pro or otherwise) cost $299.
-
-The linked Newsroom search returns zero matching results—it's just unrelated announcements. Current cheapest Mac is the 13" MacBook A...
- https://x.com/grok/status/2026727605517394283
- **
-
-### YouTube Videos
-
-**9HQx5pgUoiY** (score:61) Marques Brownlee (2024-11-18) [4,010,567 views, 105,606 likes]
- M4 Max MacBook Pro: I'm Convinced!
- https://www.youtube.com/watch?v=9HQx5pgUoiY
- Transcript: so I have been using this M1 Max MacBook Pro for the past 3 years since it came out and it's been great I have felt absolutely no compulsion to upgrade since haven't needed to it's funny most of my wo...
- *YouTube video about m4 macbook pro review*
-
-**etP2Th9g2hM** (score:55) ShortCircuit (2024-11-30) [1,113,999 views, 22,765 likes]
- Don't buy the Wrong MacBook like me... - M4 MacBook Pro
- https://www.youtube.com/watch?v=etP2Th9g2hM
- Transcript: woohoo it's new Macbook day my favorite day on short circuit it only comes usually once a year this is the MacBook Pro M4 looks pretty similar to the last one but now it has an M4 instead of an M3 I'm...
- *YouTube video about m4 macbook pro review*
-
-**b4x8boB2KdI** (score:55) Dave2D (2024-11-07) [1,057,988 views, 24,977 likes]
- M4 MacBook Pro Review - Things to Know
- https://www.youtube.com/watch?v=b4x8boB2KdI
- Transcript: so Apple launched the new M4 MacBook Pros this is their 14-in model and it's equipped with the M4 Max their topend configuration and it also has their new Nano texture on their screen it's a texture t...
- *YouTube video about m4 macbook pro review*
-
-**uPe9spXLfZY** (score:50) Created Tech (2025-07-07) [472,845 views, 5,082 likes]
- M4 MacBook Air vs M4 MacBook Pro - 4 Months Later
- https://www.youtube.com/watch?v=uPe9spXLfZY
- *YouTube video about m4 macbook pro review*
-
-**TfvIgdzImt4** (score:50) Hardware Canucks (2024-12-15) [325,599 views, 6,339 likes]
- The Macbook Pro M4 is Insane.
- https://www.youtube.com/watch?v=TfvIgdzImt4
- *YouTube video about m4 macbook pro review*
-
-**a8Szdrnq0YM** (score:49) Brandon Butch (2025-02-03) [352,593 views, 4,309 likes]
- MacBook Pro M4 - Review After 3 Months: This Feels Wrong.
- https://www.youtube.com/watch?v=a8Szdrnq0YM
- *YouTube video about m4 macbook pro review*
-
-**anIVewgtbFc** (score:48) MacRumors (2024-12-17) [238,619 views, 2,874 likes]
- The Base M4 MacBook Pro is All You Need (Skip M4 Pro & Max)
- https://www.youtube.com/watch?v=anIVewgtbFc
- *YouTube video about m4 macbook pro review*
-
-**5rN6CEO31gM** (score:48) Just Josh (2024-11-15) [154,306 views, 4,540 likes]
- MacBook Pro M4: Review & Recommendations
- https://www.youtube.com/watch?v=5rN6CEO31gM
- *YouTube video about m4 macbook pro review*
-
-**i3eTKJav1VI** (score:45) Created Tech (2025-04-29) [137,009 views, 1,832 likes]
- M4 Pro MacBook - Long Term Review (6 Months Later)
- https://www.youtube.com/watch?v=i3eTKJav1VI
- *YouTube video about m4 macbook pro review*
-
-**QbSQH_95eg8** (score:36) Tech It Easy (2026-02-01) [3,176 views, 55 likes]
- MacBook Pro M4 Pro Review: 1 Year Later! (Still Worth Buying in 2026?)
- https://www.youtube.com/watch?v=QbSQH_95eg8
- *YouTube video about m4 macbook pro review*
-
----
-**Sources:**
- ✅ Reddit: 3 threads
- ✅ X: 12 posts
- ✅ YouTube: 10 videos (3 with transcripts)
- ⏭️ HN: 0 stories found
- ⚡ Web: assistant will use WebSearch
diff --git a/docs/comparison-results/compact/base-4-rap.md b/docs/comparison-results/compact/base-4-rap.md
deleted file mode 100644
index 30b0636..0000000
--- a/docs/comparison-results/compact/base-4-rap.md
+++ /dev/null
@@ -1,145 +0,0 @@
-## Research Results: best rap songs 2026
-
-**Date Range:** 2026-01-26 to 2026-02-25
-**Mode:** both
-**OpenAI Model:** gpt-5.2
-**xAI Model:** grok-4-1-fast
-
-### Reddit Threads
-
-**R10** (score:57) r/TeenageRapFans (2026-02-15)
- What's the best rap song ever made?
- https://www.reddit.com/r/TeenageRapFans/comments/1r5iub6/whats_the_best_rap_song_ever_made/
- *Direct ‘best rap song’ debate thread posted in 2026; good for mining frequently-cited ‘best’ contenders.*
-
-**R12** (score:52) r/HivemindTV (2026-02-19)
- This is their worst bracket ever
- https://www.reddit.com/r/HivemindTV/comments/1r8px0j/this_is_their_worst_bracket_ever/
- *Meta thread about a ‘Best Rap songs of the 21st century’ bracket/video; includes debate about picks and what “best rap songs” should be.*
-
-**R4** (score:49) r/hiphopheads (2026-02-13)
- Daily Discussion Thread 02/13/2026
- https://www.reddit.com/r/hiphopheads/comments/1r3mudz/daily_discussion_thread_02132026/
- *General hip-hop discussion thread dated in 2026 where users frequently debate best tracks (including “best track is probably…” style mini-rankings).*
-
-**R3** (score:48) r/hiphopheads (2026-02-01)
- Pre-Show Grammy Winners
- https://www.reddit.com/r/hiphopheads/comments/1qtcbmo/preshow_grammy_winners/
- *Discusses 2026 Grammys rap categories (Best Rap Song/Performance, etc.), which commonly overlaps with ‘best rap songs’ discussions.*
-
-**R14** (score:41) r/edranked (2026-02-01)
- Reddit Ranked: Hip Hop ‘26
- https://www.reddit.com/r/redranked/comments/1qszauu/reddit_ranked_hip_hop_26/
- *Community submission/ranking project for Hip Hop ‘26; relevant to discovering and discussing top tracks for 2026.*
-
-**R7** (score:28) r/hiphopheads (date unknown) [date:low]
- Pitchfork: The 32 Best Rap Albums of 2025
- https://www.reddit.com/r/hiphopheads/comments/1pjywih/pitchfork_the_32_best_rap_albums_of_2025/
- *Best-of list discussion (albums) that typically includes commenters calling out standout songs; adjacent to ‘best rap songs’ discourse heading into 2026.*
-
-### X Posts
-
-**X5** (score:86) @Zika_gfx (2026-02-25) [11likes, 5rt]
- Talking about meaningful rap songs...
-
-WORLD BEST LIE - NUNO ZIGI (2026)🥺❤ https://t.co/MDHRf3giYq...
- https://x.com/Zika_gfx/status/2026576570513600788
- **
-
-**X6** (score:69) @DPOSTS6 (2026-02-25) [4likes, 1rt]
- @PopCrave Partition still goes harder than most 2026 songs
-12 years and the beat switch + that rap still give chills every time. Queen never misses...
- https://x.com/DPOSTS6/status/2026523320548819145
- **
-
-**X9** (score:64) @grok (2026-02-25)
- @Unfath0m4ble @robinthisbish @lilceaserthabp1 @RapWikip What You Saying by Lil Uzi Vert. Dropped Dec 2025, hit #1 on Hot Rap Songs in Jan 2026 (his 3rd time), peaked #14 Hot 100. Super short track ~2 ...
- https://x.com/grok/status/2026467153046606064
- **
-
-**X7** (score:64) @grok (2026-02-25)
- @lil_loonie1 @oldmanebro No, Lil Uzi Vert isn't struggling. His "What You Saying" hit #1 on Hot Rap Songs in Jan 2026 (first in 3 years) and top 15 Hot 100. Insiders say a surprise album drop is comin...
- https://x.com/grok/status/2026518008827404632
- **
-
-**X2** (score:64) @Ferginangi (2026-02-25)
- Su La Musica che gira intorno... "Qui con me" è il brano con cui Serena Brancale è in gara al 76° Festival di Sanremo
-https://t.co/wTPeovzt6u
-#music #genre #song #songs #envywear #PleaseForgiveMe #mel...
- https://x.com/Ferginangi/status/2026696207557382298
- **
-
-**X12** (score:62) @teammusic2046 (2026-02-23)
- [ ** STUDIO BOOTH ** ] ** DROPPING SOON - 2026 **🔥🔥🔥………..[ ALBUM : LOVE VS LOYALTY - DROPPING SOON ! ]………🔥🔥🔥 - ** ALL SONGS & BEATS, PRODUCED & RAP BY ( SEAN BRYANT ) - https://t.co/LduwG5WmM7...
- https://x.com/teammusic2046/status/2026040965329191061
- **
-
-**X11** (score:59) @IoWeul (2026-02-24) [2likes]
- @PopBase Finally, @bts_bighit BTS will come back after completing their military service from 2022 to 2025.
-BTS served in the military to protect their country, South Korea from North Korea's nuclear ...
- https://x.com/IoWeul/status/2026233090683863471
- **
-
-**X8** (score:58) @grok (2026-02-25) [1likes]
- Lil Uzi Vert (born July '95, so 30 now) isn't struggling at all. Fresh off "What You Saying" hitting #1 on Hot Rap Songs in Jan 2026, top 15 Hot 100. New album rumored for surprise drop soon, Roc Nati...
- https://x.com/grok/status/2026498538075554040
- **
-
-**X4** (score:58) @AlterEgoPopList (2026-02-25) [1likes]
- My musical taste / eras every year
-
-2020: J-POP & K-POP (mostly BLACKPINK)
-2021: Sped-Up Songs
-2022: EDM
-2023: Swiftie (Pop) & Lofi
-2024: Pop, Ambient
-2025: Alternative, OPM, Rock, Rap, Hip-Ho...
- https://x.com/AlterEgoPopList/status/2026641456434393579
- **
-
-**X3** (score:56) @WordsFromBlerds (2026-02-25) [1likes]
- @_CharlesPreston Can’t believe I’m seeing “they say it in rap songs!” In 2026
-
-The same reductive, ancient, debunked arguments ad infinitum...
- https://x.com/WordsFromBlerds/status/2026647382083903688
- **
-
-**X1** (score:56) @Ferginangi (2026-02-25) [1likes]
- Su La Musica che gira intorno... "Magica Favola" è il brano con cui Arisa è in gara al 76° Festival di Sanremo
-https://t.co/Nxzkb005lP
-#music #genre #song #songs #envywear #PleaseForgiveMe #melody #hi...
- https://x.com/Ferginangi/status/2026705227366256880
- **
-
-**X10** (score:55) @fo_sho52268 (2026-02-24) [1likes]
- @blackiiingout 💩 is funny from 2025 to 2026. BiiCH is SCARED to drop that #TRASH he calls "rap songs" 🥴😩...
- https://x.com/fo_sho52268/status/2026335353364201566
- **
-
-### YouTube Videos
-
-**ZUExyc50ZVU** (score:63) West Coast Finest (2026-01-29) [407,260 views, 4,228 likes]
- Lit Hip Hop Mix 2026🔥🔥🔥Tyga, Quavo, Iggy Azalea, Wiz Khalifa, Juicy J, 50 Cent
- https://www.youtube.com/watch?v=ZUExyc50ZVU
- Transcript: Yeah. >> Yeah. [music] Late nights, cold life, same story. >> City shine different. You walking with glory, right? >> Still drip falling when I step outside. Yeah. [music] >> B...
- *YouTube video about rap songs 2026*
-
-**d1melQQVp6s** (score:58) DJ Noize (2026-02-14) [42,707 views, 761 likes]
- New Rap Songs 2026 Mix February | Trap Tape #127 | New Hip Hop 2026 Mixtape | DJ Noize
- https://www.youtube.com/watch?v=d1melQQVp6s
- Transcript: [music] This This is a DJ noise mixtape present. This is a DJ noise mixtape [music] presentation. DJ [music] >> Who the [ __ ] is you? Who the [ __ ] is you? Who the [ _...
- *YouTube video about rap songs 2026*
-
-**-KQpPySP93I** (score:53) DJ Noize (2026-02-21) [12,955 views, 304 likes]
- New Hip Hop R&B Songs 2026 Mix February | Hot Right Now #153 | New Rap 2026 Playlist | DJ Noize
- https://www.youtube.com/watch?v=-KQpPySP93I
- Transcript: This This is a DJ noise tape present. This is a DJ noise mix. [music] I got Nikki on me. How we treat the like a bucket for rubbing. You [music] ain't no fella from my junction. You ain't pluck you cl...
- *YouTube video about rap songs 2026*
-
----
-**Sources:**
- ✅ Reddit: 6 threads
- ✅ X: 12 posts
- ✅ YouTube: 3 videos (3 with transcripts)
- ⏭️ HN: 0 stories found
- ⚡ Web: assistant will use WebSearch
diff --git a/docs/comparison-results/compact/base-5-react-svelte.md b/docs/comparison-results/compact/base-5-react-svelte.md
deleted file mode 100644
index 625bfc2..0000000
--- a/docs/comparison-results/compact/base-5-react-svelte.md
+++ /dev/null
@@ -1,162 +0,0 @@
-## Research Results: React vs Svelte 2026
-
-**Date Range:** 2026-01-26 to 2026-02-25
-**Mode:** both
-**OpenAI Model:** gpt-5.2
-**xAI Model:** grok-4-1-fast
-
-### Reddit Threads
-
-**R2** (score:67) r/eact (2026-02-10)
- What do you guys think about comparison between React vs Svelte?
- https://www.reddit.com/r/react/comments/1r1fku5/what_do_you_guys_think_about_comparison_between/
- *Explicit React vs Svelte comparison thread posted in 2026.*
-
-**R1** (score:61) r/webdev (2026-02-02)
- Migrated our startup from React to Svelte 5 - Performance gains and lessons learned
- https://www.reddit.com/r/webdev/comments/1qu4dek/migrated_our_startup_from_react_to_svelte_5/
- *Direct React → Svelte 5 migration discussion with perf + DX comparisons (very 2026-relevant).*
-
-**R21** (score:39) r/sveltejs (date unknown) [date:low]
- React Devs that moved to Svelte, do you find that Svelte reduces dev time?
- https://www.reddit.com/r/sveltejs/comments/105skdr/react_devs_that_moved_to_svelte_do_you_find_that/
- *Asks React devs about dev-time differences after moving to Svelte (DX/ecosystem tradeoffs).*
-
-**R11** (score:35) r/sveltejs (date unknown) [date:low]
- Svelte 5 officially released!
- https://www.reddit.com/r/sveltejs/comments/1g7d3a7/svelte_5_officially_released/
- *Major Svelte 5 release thread with debate about whether React developers will switch (React vs Svelte dynamics).*
-
-### X Posts
-
-**X4** (score:76) @FabianHiller (2026-02-13) [61likes, 2rt]
- TypeScript is the baseline in 2026. But most forms still fight type drift between API, schema & UI.
-
-@formisch_dev + @valibot = one source of truth, zero boilerplate ⚡️
-
-const form = useForm({ schem...
- https://x.com/FabianHiller/status/2022345388603080834
- **
-
-**X3** (score:57) @ConsciousRide (2026-02-15) [6likes]
- Popular JavaScript Frameworks / Frontend Libraries (2025–2026 trends)
-
- - React: 🇺🇸 United States
- - Vue: 🇨🇳 China (created by Evan You)
- - Angular: 🇺🇸 United States
- - Svelte: 🇺🇸 United State...
- https://x.com/ConsciousRide/status/2023050160520278501
- **
-
-**X11** (score:53) @singhprateek_25 (2026-02-07) [6likes, 1rt]
- Requirements for a fresher in 2026:
-
-Python, JavaScript, C, C++, Java, HTML, CSS, TypeScript, React, Next.js, Vue, Angular, Svelte, Node.js, Express, Django, Flask, FastAPI, Spring Boot, PHP, Laravel,...
- https://x.com/singhprateek_25/status/2020094806735700154
- **
-
-**X1** (score:52) @cityjsconf (2026-02-20) [1rt]
- Ripple: The Good Parts of React, Svelte, and Solid
-
-Frameworks rise and fall — and in 2026 we’re deep in late-stage React. What’s next? Join @erikras as he introduces Ripple, a TypeScript-first UI fra...
- https://x.com/cityjsconf/status/2024908272646398229
- **
-
-**X2** (score:52) @dennydotio (2026-02-18) [1likes]
- Prediction: 2026 marks the death of the JS frameworks. React, Svelte, Astro etc... only exist because humans need organized code to stay sane.
-
-Once we stop reading the source code, we stop needing ...
- https://x.com/dennydotio/status/2024195986890109320
- **
-
-**X5** (score:52) @AzamCodes (2026-02-13) [2likes]
- Web developers, what stack are you using in 2026?
-React? Vue? Svelte? Vanilla? Curious what’s actually winning....
- https://x.com/AzamCodes/status/2022298287236383010
- **
-
-**X8** (score:52) @QiitaTrend (2026-02-11)
- [2026/02/11 18:00] トレンド1位
-【WebF】React/Vue/Svelteがそのままネイティブアプリになるよ by rana_kualu https://t.co/GiAnTHpOBB...
- https://x.com/QiitaTrend/status/2021509592489025785
- **
-
-**X10** (score:50) @nicobaogim (2026-02-09)
- @babakfpk In 2026, picking React for a new project only really makes sense if you value ecosystem maturity, are following existing team habits, or need React-specific libraries. Same for Vue. Otherwis...
- https://x.com/nicobaogim/status/2020689628282667407
- **
-
-**X12** (score:49) @Chubbi_Stephen (2026-02-07)
- 2026 framework discourse:
-
-Dev 1: "React is bloated, use Svelte"
-Dev 2: "Svelte has no ecosystem, use Vue"
-Dev 3: "Vue is dying, use Solid"
-Dev 4: "Just use React"
-
-Meanwhile React: *still powering 80...
- https://x.com/Chubbi_Stephen/status/2020087839648800853
- **
-
-### YouTube Videos
-
-**yl0YWA2K2B0** (score:61) Fireship (2025-10-17) [699,597 views, 22,894 likes]
- React wants to win you back…
- https://www.youtube.com/watch?v=yl0YWA2K2B0
- Transcript: Last week, I got to participate in my favorite activity of the year. No, it wasn't watching all the overleveraged crypto bros learn what a margin call is. It was hate watching React comp as an anti-re...
- *YouTube video about react vs svelte 2026*
-
-**MnpuK0MK4yo** (score:61) Beyond Fireship (2023-06-30) [697,106 views, 25,490 likes]
- React VS Svelte...10 Examples
- https://www.youtube.com/watch?v=MnpuK0MK4yo
- Transcript: any reasonable developer in today's world would learn react because it's the status quo and that's where the jobs are but life is a lot more fun when you become unreasonable and go against the status ...
- *YouTube video about react vs svelte 2026*
-
-**aYyZUDFZTrM** (score:60) Fireship (2024-10-24) [547,841 views, 24,456 likes]
- JavaScript framework reinvents itself… Did "runes" just ruin Svelte?
- https://www.youtube.com/watch?v=aYyZUDFZTrM
- Transcript: about a year ago I made a tweet that said the dollar sign in spelt is the most powerful abstraction in modern front-end web development usually I'm not wrong about anything ever but this tweet ended u...
- *YouTube video about react vs svelte 2026*
-
-**k7LhsqnJCe4** (score:51) Paperclick (2026-02-19) [26 views, 1 likes]
- Htmx vs Svelte (2026) - Which One Is BETTER?
- https://www.youtube.com/watch?v=k7LhsqnJCe4
- *YouTube video about react vs svelte 2026*
-
-**IpJh0VEzMRo** (score:51) Ben Davis (2025-10-12) [24,631 views, 1,043 likes]
- I Was Wrong About Svelte...
- https://www.youtube.com/watch?v=IpJh0VEzMRo
- *YouTube video about react vs svelte 2026*
-
-**qwDp5pZA_TA** (score:50) Code Hub (2026-02-08) [640 views, 31 likes]
- The Front-end Frameworks Guide 2026: React vs Angular vs Vue vs Svelt vs Solid vs Astro
- https://www.youtube.com/watch?v=qwDp5pZA_TA
- *YouTube video about react vs svelte 2026*
-
-**1cGtYEXGm8c** (score:50) Ben Davis (2026-01-06) [16,900 views, 793 likes]
- This is THE Framework You Should be Using in 2026
- https://www.youtube.com/watch?v=1cGtYEXGm8c
- *YouTube video about react vs svelte 2026*
-
-**_vuVy21l2bU** (score:46) Code Hub (2025-10-30) [6,454 views, 204 likes]
- React vs Svelte: The Brutal Honest Comparison EVER
- https://www.youtube.com/watch?v=_vuVy21l2bU
- *YouTube video about react vs svelte 2026*
-
-**1BCsdaeYv0A** (score:43) Daniel | Tech & Data (2023-11-26) [33,905 views, 0 likes]
- Svelte vs React in 2025 - Make the RIGHT Choice (Difference Explained)
- https://www.youtube.com/watch?v=1BCsdaeYv0A
- *YouTube video about react vs svelte 2026*
-
-**41HXdxGekZ0** (score:36) Paperclick (2025-10-04) [287 views, 3 likes]
- Vue vs React vs Svelte (2026) - Which One Is BEST?
- https://www.youtube.com/watch?v=41HXdxGekZ0
- *YouTube video about react vs svelte 2026*
-
----
-**Sources:**
- ✅ Reddit: 4 threads
- ✅ X: 9 posts
- ✅ YouTube: 10 videos (3 with transcripts)
- ⏭️ HN: 0 stories found
- ⚡ Web: assistant will use WebSearch
diff --git a/docs/comparison-results/compact/cross-1-claude-code-improved.md b/docs/comparison-results/compact/cross-1-claude-code-improved.md
deleted file mode 100644
index ef60900..0000000
--- a/docs/comparison-results/compact/cross-1-claude-code-improved.md
+++ /dev/null
@@ -1,285 +0,0 @@
-## Research Results: Claude Code skills and MCP servers
-
-**Date Range:** 2026-01-26 to 2026-02-25
-**Mode:** both
-**OpenAI Model:** gpt-5.2
-**xAI Model:** grok-4-1-fast
-
-### Reddit Threads
-
-**R1** (score:78) r/ClaudeCode (2026-02-23)
- ClaudeInOne — a full framework for Claude Code, installed in one command
- https://www.reddit.com/r/ClaudeCode/comments/1rcbria/claudeinone_a_full_framework_for_claude_code/
- *Directly about Claude Code skills (213 skills) and a reusable framework for Claude Code workflows.*
-
-**R10** (score:77) r/ClaudeCode (2026-02-25)
- Claude Code works great… until you have too many MCP servers
- https://www.reddit.com/r/ClaudeCode/comments/1rehy2x/claude_code_works_great_until_you_have_too_many/
- *Scaling issues and architecture discussion when using many MCP servers with Claude Code (mentions gateway).*
-
-**R2** (score:77) r/ClaudeCode (2026-02-22)
- I built an MCP server that gives Claude Code a semantic graph of your codebase - went from ~15k tokens of grep output to ~3k of relevant context
- https://www.reddit.com/r/ClaudeCode/comments/1rbu1nq/i_built_an_mcp_server_that_gives_claude_code_a/
- *An MCP server specifically designed to improve Claude Code context retrieval and reduce token waste.*
-
-**R9** (score:76) r/ClaudeAI (2026-02-24)
- I built 25 MCP servers so Claude Code stops wasting tokens on terminal formatting
- https://www.reddit.com/r/ClaudeAI/comments/1rd8z0d/i_built_25_mcp_servers_so_claude_code_stops/
- *Large set of MCP servers aimed at Claude Code usage; about MCP servers returning structured data.*
-
-**R4** (score:75) r/ClaudeCode (2026-02-22)
- We 3x'd our team's Claude Code skill usage in 2 weeks — here's how
- https://www.reddit.com/r/ClaudeCode/comments/1rbr5t7/we_3xd_our_teams_claude_code_skill_usage_in_2/
- *Team-level discussion of increasing Claude Code skill adoption; mentions syncing skills and MCP configs.*
-
-**R8** (score:72) r/ClaudeAI (2026-02-19)
- I used Claude Code to build an MCP proxy server that gives it safe(r) access to my email. Here's what I learned about the security challenges
- https://www.reddit.com/r/ClaudeAI/comments/1r99zbv/i_used_claude_code_to_build_an_mcp_proxy_server/
- *MCP server project built for/with Claude Code; discusses MCP server design and security tradeoffs.*
-
-**R3** (score:72) r/ClaudeCode (2026-02-18)
- Self-improvement Loop: My favorite Claude Code Skill
- https://www.reddit.com/r/ClaudeCode/comments/1r89084/selfimprovement_loop_my_favorite_claude_code_skill/
- *Focused on a concrete Claude Code skill (“wrap-up”) and how skills are used in real sessions.*
-
-**R5** (score:65) r/ClaudeCode (2026-02-10)
- I built 12 SEO skills for Claude Code (open source)
- https://www.reddit.com/r/ClaudeCode/comments/1r0l549/i_built_12_seo_skills_for_claude_code_open_source/
- *Showcase of multiple Claude Code skills and how they run via slash commands/subagents.*
-
-**R6** (score:57) r/ClaudeCode (2026-02-02)
- [Skill Viewer] Built a Chrome extension to view Claude Code skills on GitHub - seeking feedback
- https://www.reddit.com/r/ClaudeCode/comments/1qtq7yr/skill_viewer_built_a_chrome_extension_to_view/
- *Tooling around discovering/viewing Claude Code skills; directly about skills ecosystem.*
-
-**R14** (score:43) r/ClaudeCode (date unknown) [date:low]
- Difference between Skills and these: Subagents, Claude.MD and slash commands?
- https://www.reddit.com/r/ClaudeCode/comments/1o8t6xe/difference_between_skills_and_these_subagents/
- *Conceptual thread clarifying what Skills are versus subagents/CLAUDE.md/slash commands (relevant to skills usage).*
-
-### X Posts
-
-**X5** (score:86) @zeeg (2026-02-25) [5likes, 1rt]
- @adamwathan Codex has been crushing it for me with implicit skill usage to the point I was shocked
-
-Claude Code I resorted to explicit mentions and I’ve been pleasantly surprised by how well Codex ha...
- https://x.com/zeeg/status/2026745680195367091
- **
-
-**X3** (score:78) @ihtesham2005 (2026-02-25) [6likes]
- 🚨 Anthropic just open-sourced the exact Skills library their own engineers use internally.
-
-Stop building Claude workflows from scratch.
-
-These are plug-and-play components that work across Claude Cod...
- https://x.com/ihtesham2005/status/2026752089473314975
- **
-
-**X8** (score:72) @DevTenta (2026-02-25) [4likes]
- Day 7 building my first B2C app
-
-Been setting everything up this week. Manually built 5 agents to help me in the early stages and set up the Claude Code skills i'll need for the build.
-
-Also been test...
- https://x.com/DevTenta/status/2026742028730527786
- **
-
-**X7** (score:64) @JorgeJaramillo (2026-02-25)
- @Jompiras Claude code + skills. Muy breve....
- https://x.com/JorgeJaramillo/status/2026743135435362649
- **
-
-**X4** (score:64) @arpan7sarkar (2026-02-25)
- @tom_doerr 135 agents, 35 curated skills, 121 plugins and 6 MCP configs all in one Claude Code toolkit is absolutely insane value 🤯 this is the kind of community effort that makes an AI tool go from g...
- https://x.com/arpan7sarkar/status/2026746479369662551
- **
-
-**X6** (score:64) @arpan7sarkar (2026-02-25)
- @tom_doerr A curated collection of Claude Skills organized by category is exactly what power users have been waiting for 🤩 document skills, code tools, data analysis all in one repo. this is the Claud...
- https://x.com/arpan7sarkar/status/2026745559986614335
- **
-
-**X2** (score:64) @yasuky (2026-02-25)
- Claude CodeのSkillsを作成例から徹底理解する https://t.co/vZTseio2Ii...
- https://x.com/yasuky/status/2026754773786226708
- **
-
-**X12** (score:64) @zhuoyuan45514 (2026-02-25)
- Day 52 (01/21/2026) (Rest Day)
-
-> Go to New York + watch Broadway Show
-
-> Catch up on claude code updates on way back (2hrs)
- - Learn difference between skills + MCP, and etc
-
-> Neetcode (1h...
- https://x.com/zhuoyuan45514/status/2026733326631923776
- **
-
-**X1** (score:64) @matgoldsborough (2026-02-25)
- How: I told Claude Code "build me a CRM." Our builder skill generated schemas, skills, server, seed data. I pointed Claude Desktop at it. Contacts, lead scoring, pipeline reviews. All from conversatio...
- https://x.com/matgoldsborough/status/2026755742737510749
- **
-
-**X9** (score:64) @0x_Kapoor (2026-02-25)
- I can build another open claw, or claude bot, or even a messaging app in a nice prompt and although having engineering skills, I can push the code on the public repo and secure it even completely and ...
- https://x.com/0x_Kapoor/status/2026738663321907214
- **
-
-**X10** (score:56) @ghumare64 (2026-02-25) [1likes]
- 1. I use https://t.co/NtFYJEiFs9 inspired by @bcherny
-2. I use https://t.co/EwnmyWNFqn to sync skills and scan skills from the marketplace
-3. I chat with claude code 4-5 times and once I am satisifed...
- https://x.com/ghumare64/status/2026736176695246966
- **
-
-**X11** (score:56) @hamen (2026-02-25) [1likes]
- The 'vibe coding' revolution is forcing a reckoning.
-
-Developers with 15+ years experience are either embracing AI to expand their reach or watching their expertise become as valuable as COBOL skills....
- https://x.com/hamen/status/2026734075243790396
- **
-
-### YouTube Videos
-
-**X8afcX2s2Mo** (score:69) Grace Leung (2026-02-21) [47,512 views, 1,900 likes] [also on: HN]
- Claude Skills: Build Your First AI Marketing Team in 16 Minutes (Claude Code)
- https://www.youtube.com/watch?v=X8afcX2s2Mo
- Transcript: Every marketer I know is stretch themed. Too many channels, too many deliverables, never enough time, whether you're running a team or doing it all yourself. And that's why I'm so obsessed with clock ...
- *YouTube: Claude Skills: Build Your First AI Marketing Team in 16 Minu*
-
-**0J2_YGuNrDo** (score:66) Grace Leung (2025-12-16) [155,806 views, 4,760 likes]
- Claude Code just Built me an AI Agent Team (Claude Code + Skills + MCP)
- https://www.youtube.com/watch?v=0J2_YGuNrDo
- Transcript: I use clot more than any other AI but even I dismiss Clark as just for developers and I was wrong after using myself I am completely so and this is too powerful to ignore if you want to use AI to buil...
- *YouTube: Claude Code just Built me an AI Agent Team (Claude Code + Sk*
-
-**Gqh_KdHP1Xk** (score:61) Robin Ebers (2025-08-21) [90,281 views, 2,719 likes]
- 8 MCP Servers That Make Claude Code 10x Better
- https://www.youtube.com/watch?v=Gqh_KdHP1Xk
- Transcript: Over 90% of all MCP servers are complete overhyped garbage. But some of them are not only worth it, they can be complete game changers. And I do not say this lightly. So after testing over 100 of them...
- *YouTube: 8 MCP Servers That Make Claude Code 10x Better*
-
-**vIUJ4Hd7be0** (score:54) Leon van Zyl (2026-02-09) [25,538 views, 699 likes]
- Claude Code Skills - The Only Tutorial You Need
- https://www.youtube.com/watch?v=vIUJ4Hd7be0
- *YouTube: Claude Code Skills - The Only Tutorial You Need*
-
-**901VMcZq8X4** (score:47) Sean Kochel (2025-10-21) [44,618 views, 1,356 likes]
- These 5 Claude Code Skills Are Your New Unfair Advantage
- https://www.youtube.com/watch?v=901VMcZq8X4
- *YouTube: These 5 Claude Code Skills Are Your New Unfair Advantage*
-
-**M5CsRj6zSCA** (score:45) Eric Tech (2025-12-12) [11,286 views, 266 likes]
- 5 MCP Servers That Make Claude Code 10x More Powerful (Full-Stack App Build)
- https://www.youtube.com/watch?v=M5CsRj6zSCA
- *YouTube: 5 MCP Servers That Make Claude Code 10x More Powerful (Full-*
-
-**l7qVtHpctic** (score:43) Kenny Liao (2026-01-17) [21,765 views, 728 likes]
- Claude Code's MCP Problem Just Got Fixed
- https://www.youtube.com/watch?v=l7qVtHpctic
- *YouTube: Claude Code's MCP Problem Just Got Fixed*
-
-**ZroGqu7GyXM** (score:39) Solo Swift Crafter (2025-10-27) [4,885 views, 98 likes]
- Claude Code Skills vs MCP vs Sub Agents: What Works for Solo Devs?
- https://www.youtube.com/watch?v=ZroGqu7GyXM
- *YouTube: Claude Code Skills vs MCP vs Sub Agents: What Works for Solo*
-
-**jzf7DQa2CAc** (score:36) Matt Kuda (2026-01-19) [2,547 views, 87 likes]
- How I Use Claude Code With Skills, MCP, Agents & Plugins
- https://www.youtube.com/watch?v=jzf7DQa2CAc
- *YouTube: How I Use Claude Code With Skills, MCP, Agents & Plugins*
-
-**qthyl0GCpDo** (score:30) Postman (2025-11-20) [7,067 views, 105 likes]
- Claude Skills vs MCP: What’s the Difference and When to Use Each?
- https://www.youtube.com/watch?v=qthyl0GCpDo
- *YouTube: Claude Skills vs MCP: What’s the Difference and When to Use *
-
-### Hacker News Stories
-
-**HN1** (score:90) hn/cosmoblk (2026-02-21) [10pts, 2cmt]
- Show HN: I built a 55K-word email marketing knowledge base and Claude Code skill
- https://news.ycombinator.com/item?id=47096335
- *HN story about Show HN: I built a 55K-word email marketing knowledge base a*
- Insights:
- - So it's an AI spambot?
-
-**HN4** (score:74) hn/superamped (2026-02-25) [3pts, 1cmt] [also on: X]
- Show HN: AI Marketing Skills for Claude Code
- https://news.ycombinator.com/item?id=47154377
- *HN story about Show HN: AI Marketing Skills for Claude Code*
-
-**HN12** (score:70) hn/heycesr (2026-02-18) [4pts, 2cmt]
- Show HN: Poncho, a general agent harness built for the web
- https://news.ycombinator.com/item?id=47061949
- *HN story about Show HN: Poncho, a general agent harness built for the web*
- Insights:
- - I have been trying it and it's very cool!
-
-**HN11** (score:69) hn/gjkim042 (2026-02-24) [1pts, 4cmt]
- Show HN: Axon – A Kubernetes-native framework for AI coding agents
- https://news.ycombinator.com/item?id=47137491
- *HN story about Show HN: Axon – A Kubernetes-native framework for AI coding *
-
-**HN14** (score:65) hn/alternateman (2026-02-17) [3pts, 2cmt]
- Show HN: Turn Claude Code or Codex into proactive, autonomous 24/7 AI agents
- https://news.ycombinator.com/item?id=47054100
- *HN story about Show HN: Turn Claude Code or Codex into proactive, autonomou*
-
-**HN2** (score:64) hn/hauschildt (2026-02-19) [3pts, 0cmt]
- Show HN: Agent skills to build photo, video and design editors on the web
- https://news.ycombinator.com/item?id=47073035
- *HN story about Show HN: Agent skills to build photo, video and design edito*
-
-**HN8** (score:57) hn/barefootsanders (2026-02-25) [1pts, 0cmt]
- Show HN: Upjack – Declarative framework for building apps over MCP
- https://news.ycombinator.com/item?id=47157331
- *HN story about Show HN: Upjack – Declarative framework for building apps ov*
-
-**HN7** (score:56) hn/san-techie21 (2026-02-16) [1pts, 1cmt]
- Show HN: Gulama – Security-first open-source AI agent (OpenClaw alternative)
- https://news.ycombinator.com/item?id=47031982
- *HN story about Show HN: Gulama – Security-first open-source AI agent (OpenC*
-
-**HN15** (score:48) hn/martin-hall (2026-02-20) [1pts, 0cmt]
- AI Skills Platform (Stealth) – Technical Co-Founder – Remote (US) – Equity
- https://news.ycombinator.com/item?id=47081777
- *HN story about AI Skills Platform (Stealth) – Technical Co-Founder – Remote*
-
-**HN10** (score:47) hn/ClaytheMachine (2026-02-15) [1pts, 0cmt]
- Show HN: SkillSandbox – Capability-based sandbox for AI agent skills (Rust)
- https://news.ycombinator.com/item?id=47027734
- *HN story about Show HN: SkillSandbox – Capability-based sandbox for AI agen*
-
-**HN6** (score:47) hn/micronink (2026-01-30) [1pts, 2cmt]
- Show HN: Indx.sh – Directory of AI coding rules, MCP servers, and tool
- https://news.ycombinator.com/item?id=46822378
- *HN story about Show HN: Indx.sh – Directory of AI coding rules, MCP servers*
-
-**HN3** (score:45) hn/paolobietolini (2026-02-07) [1pts, 0cmt]
- Show HN: GTM MCP Server- Let AI Manage Your Google Tag Manager Containers
- https://news.ycombinator.com/item?id=46922159
- *HN story about Show HN: GTM MCP Server- Let AI Manage Your Google Tag Manag*
-
-**HN9** (score:38) hn/digitcatphd (2026-02-03) [1pts, 0cmt]
- Show HN: ClawsMarket – Marketplace where AI agents discover tools
- https://news.ycombinator.com/item?id=46878646
- *HN story about Show HN: ClawsMarket – Marketplace where AI agents discover *
-
-**HN13** (score:36) hn/aspectrr (2026-02-04) [1pts, 0cmt]
- Show HN: Fluid.sh – Claude Code for Infrastructure
- https://news.ycombinator.com/item?id=46886358
- *HN story about Show HN: Fluid.sh – Claude Code for Infrastructure*
-
-**HN5** (score:34) hn/victordg (2026-01-27) [1pts, 0cmt]
- Claude Code skill for building ChatGPT Apps
- https://news.ycombinator.com/item?id=46780766
- *HN story about Claude Code skill for building ChatGPT Apps*
-
----
-**Sources:**
- ✅ Reddit: 10 threads
- ✅ X: 12 posts
- ✅ YouTube: 10 videos (3 with transcripts)
- ✅ HN: 15 stories
- ⚡ Web: assistant will use WebSearch
diff --git a/docs/comparison-results/compact/cross-1-claude-code.md b/docs/comparison-results/compact/cross-1-claude-code.md
deleted file mode 100644
index 32f1c44..0000000
--- a/docs/comparison-results/compact/cross-1-claude-code.md
+++ /dev/null
@@ -1,285 +0,0 @@
-## Research Results: Claude Code skills and MCP servers
-
-**Date Range:** 2026-01-26 to 2026-02-25
-**Mode:** both
-**OpenAI Model:** gpt-5.2
-**xAI Model:** grok-4-1-fast
-
-### Reddit Threads
-
-**R1** (score:78) r/ClaudeCode (2026-02-23)
- ClaudeInOne — a full framework for Claude Code, installed in one command
- https://www.reddit.com/r/ClaudeCode/comments/1rcbria/claudeinone_a_full_framework_for_claude_code/
- *Directly about Claude Code skills (213 skills) and a reusable framework for Claude Code workflows.*
-
-**R10** (score:77) r/ClaudeCode (2026-02-25)
- Claude Code works great… until you have too many MCP servers
- https://www.reddit.com/r/ClaudeCode/comments/1rehy2x/claude_code_works_great_until_you_have_too_many/
- *Scaling issues and architecture discussion when using many MCP servers with Claude Code (mentions gateway).*
-
-**R2** (score:77) r/ClaudeCode (2026-02-22)
- I built an MCP server that gives Claude Code a semantic graph of your codebase - went from ~15k tokens of grep output to ~3k of relevant context
- https://www.reddit.com/r/ClaudeCode/comments/1rbu1nq/i_built_an_mcp_server_that_gives_claude_code_a/
- *An MCP server specifically designed to improve Claude Code context retrieval and reduce token waste.*
-
-**R9** (score:76) r/ClaudeAI (2026-02-24)
- I built 25 MCP servers so Claude Code stops wasting tokens on terminal formatting
- https://www.reddit.com/r/ClaudeAI/comments/1rd8z0d/i_built_25_mcp_servers_so_claude_code_stops/
- *Large set of MCP servers aimed at Claude Code usage; about MCP servers returning structured data.*
-
-**R4** (score:75) r/ClaudeCode (2026-02-22)
- We 3x'd our team's Claude Code skill usage in 2 weeks — here's how
- https://www.reddit.com/r/ClaudeCode/comments/1rbr5t7/we_3xd_our_teams_claude_code_skill_usage_in_2/
- *Team-level discussion of increasing Claude Code skill adoption; mentions syncing skills and MCP configs.*
-
-**R8** (score:72) r/ClaudeAI (2026-02-19)
- I used Claude Code to build an MCP proxy server that gives it safe(r) access to my email. Here's what I learned about the security challenges
- https://www.reddit.com/r/ClaudeAI/comments/1r99zbv/i_used_claude_code_to_build_an_mcp_proxy_server/
- *MCP server project built for/with Claude Code; discusses MCP server design and security tradeoffs.*
-
-**R3** (score:72) r/ClaudeCode (2026-02-18)
- Self-improvement Loop: My favorite Claude Code Skill
- https://www.reddit.com/r/ClaudeCode/comments/1r89084/selfimprovement_loop_my_favorite_claude_code_skill/
- *Focused on a concrete Claude Code skill (“wrap-up”) and how skills are used in real sessions.*
-
-**R5** (score:65) r/ClaudeCode (2026-02-10)
- I built 12 SEO skills for Claude Code (open source)
- https://www.reddit.com/r/ClaudeCode/comments/1r0l549/i_built_12_seo_skills_for_claude_code_open_source/
- *Showcase of multiple Claude Code skills and how they run via slash commands/subagents.*
-
-**R6** (score:57) r/ClaudeCode (2026-02-02)
- [Skill Viewer] Built a Chrome extension to view Claude Code skills on GitHub - seeking feedback
- https://www.reddit.com/r/ClaudeCode/comments/1qtq7yr/skill_viewer_built_a_chrome_extension_to_view/
- *Tooling around discovering/viewing Claude Code skills; directly about skills ecosystem.*
-
-**R14** (score:43) r/ClaudeCode (date unknown) [date:low]
- Difference between Skills and these: Subagents, Claude.MD and slash commands?
- https://www.reddit.com/r/ClaudeCode/comments/1o8t6xe/difference_between_skills_and_these_subagents/
- *Conceptual thread clarifying what Skills are versus subagents/CLAUDE.md/slash commands (relevant to skills usage).*
-
-### X Posts
-
-**X5** (score:86) @zeeg (2026-02-25) [5likes, 1rt]
- @adamwathan Codex has been crushing it for me with implicit skill usage to the point I was shocked
-
-Claude Code I resorted to explicit mentions and I’ve been pleasantly surprised by how well Codex ha...
- https://x.com/zeeg/status/2026745680195367091
- **
-
-**X3** (score:78) @ihtesham2005 (2026-02-25) [6likes]
- 🚨 Anthropic just open-sourced the exact Skills library their own engineers use internally.
-
-Stop building Claude workflows from scratch.
-
-These are plug-and-play components that work across Claude Cod...
- https://x.com/ihtesham2005/status/2026752089473314975
- **
-
-**X8** (score:72) @DevTenta (2026-02-25) [4likes]
- Day 7 building my first B2C app
-
-Been setting everything up this week. Manually built 5 agents to help me in the early stages and set up the Claude Code skills i'll need for the build.
-
-Also been test...
- https://x.com/DevTenta/status/2026742028730527786
- **
-
-**X7** (score:64) @JorgeJaramillo (2026-02-25)
- @Jompiras Claude code + skills. Muy breve....
- https://x.com/JorgeJaramillo/status/2026743135435362649
- **
-
-**X4** (score:64) @arpan7sarkar (2026-02-25)
- @tom_doerr 135 agents, 35 curated skills, 121 plugins and 6 MCP configs all in one Claude Code toolkit is absolutely insane value 🤯 this is the kind of community effort that makes an AI tool go from g...
- https://x.com/arpan7sarkar/status/2026746479369662551
- **
-
-**X6** (score:64) @arpan7sarkar (2026-02-25)
- @tom_doerr A curated collection of Claude Skills organized by category is exactly what power users have been waiting for 🤩 document skills, code tools, data analysis all in one repo. this is the Claud...
- https://x.com/arpan7sarkar/status/2026745559986614335
- **
-
-**X2** (score:64) @yasuky (2026-02-25)
- Claude CodeのSkillsを作成例から徹底理解する https://t.co/vZTseio2Ii...
- https://x.com/yasuky/status/2026754773786226708
- **
-
-**X12** (score:64) @zhuoyuan45514 (2026-02-25)
- Day 52 (01/21/2026) (Rest Day)
-
-> Go to New York + watch Broadway Show
-
-> Catch up on claude code updates on way back (2hrs)
- - Learn difference between skills + MCP, and etc
-
-> Neetcode (1h...
- https://x.com/zhuoyuan45514/status/2026733326631923776
- **
-
-**X1** (score:64) @matgoldsborough (2026-02-25)
- How: I told Claude Code "build me a CRM." Our builder skill generated schemas, skills, server, seed data. I pointed Claude Desktop at it. Contacts, lead scoring, pipeline reviews. All from conversatio...
- https://x.com/matgoldsborough/status/2026755742737510749
- **
-
-**X9** (score:64) @0x_Kapoor (2026-02-25)
- I can build another open claw, or claude bot, or even a messaging app in a nice prompt and although having engineering skills, I can push the code on the public repo and secure it even completely and ...
- https://x.com/0x_Kapoor/status/2026738663321907214
- **
-
-**X10** (score:56) @ghumare64 (2026-02-25) [1likes]
- 1. I use https://t.co/NtFYJEiFs9 inspired by @bcherny
-2. I use https://t.co/EwnmyWNFqn to sync skills and scan skills from the marketplace
-3. I chat with claude code 4-5 times and once I am satisifed...
- https://x.com/ghumare64/status/2026736176695246966
- **
-
-**X11** (score:56) @hamen (2026-02-25) [1likes]
- The 'vibe coding' revolution is forcing a reckoning.
-
-Developers with 15+ years experience are either embracing AI to expand their reach or watching their expertise become as valuable as COBOL skills....
- https://x.com/hamen/status/2026734075243790396
- **
-
-### YouTube Videos
-
-**X8afcX2s2Mo** (score:69) Grace Leung (2026-02-21) [47,512 views, 1,900 likes]
- Claude Skills: Build Your First AI Marketing Team in 16 Minutes (Claude Code)
- https://www.youtube.com/watch?v=X8afcX2s2Mo
- Transcript: Every marketer I know is stretch themed. Too many channels, too many deliverables, never enough time, whether you're running a team or doing it all yourself. And that's why I'm so obsessed with clock ...
- *YouTube: Claude Skills: Build Your First AI Marketing Team in 16 Minu*
-
-**0J2_YGuNrDo** (score:66) Grace Leung (2025-12-16) [155,806 views, 4,760 likes]
- Claude Code just Built me an AI Agent Team (Claude Code + Skills + MCP)
- https://www.youtube.com/watch?v=0J2_YGuNrDo
- Transcript: I use clot more than any other AI but even I dismiss Clark as just for developers and I was wrong after using myself I am completely so and this is too powerful to ignore if you want to use AI to buil...
- *YouTube: Claude Code just Built me an AI Agent Team (Claude Code + Sk*
-
-**Gqh_KdHP1Xk** (score:61) Robin Ebers (2025-08-21) [90,281 views, 2,719 likes]
- 8 MCP Servers That Make Claude Code 10x Better
- https://www.youtube.com/watch?v=Gqh_KdHP1Xk
- Transcript: Over 90% of all MCP servers are complete overhyped garbage. But some of them are not only worth it, they can be complete game changers. And I do not say this lightly. So after testing over 100 of them...
- *YouTube: 8 MCP Servers That Make Claude Code 10x Better*
-
-**vIUJ4Hd7be0** (score:54) Leon van Zyl (2026-02-09) [25,538 views, 699 likes]
- Claude Code Skills - The Only Tutorial You Need
- https://www.youtube.com/watch?v=vIUJ4Hd7be0
- *YouTube: Claude Code Skills - The Only Tutorial You Need*
-
-**901VMcZq8X4** (score:47) Sean Kochel (2025-10-21) [44,618 views, 1,356 likes]
- These 5 Claude Code Skills Are Your New Unfair Advantage
- https://www.youtube.com/watch?v=901VMcZq8X4
- *YouTube: These 5 Claude Code Skills Are Your New Unfair Advantage*
-
-**M5CsRj6zSCA** (score:45) Eric Tech (2025-12-12) [11,286 views, 266 likes]
- 5 MCP Servers That Make Claude Code 10x More Powerful (Full-Stack App Build)
- https://www.youtube.com/watch?v=M5CsRj6zSCA
- *YouTube: 5 MCP Servers That Make Claude Code 10x More Powerful (Full-*
-
-**l7qVtHpctic** (score:43) Kenny Liao (2026-01-17) [21,765 views, 728 likes]
- Claude Code's MCP Problem Just Got Fixed
- https://www.youtube.com/watch?v=l7qVtHpctic
- *YouTube: Claude Code's MCP Problem Just Got Fixed*
-
-**ZroGqu7GyXM** (score:39) Solo Swift Crafter (2025-10-27) [4,885 views, 98 likes]
- Claude Code Skills vs MCP vs Sub Agents: What Works for Solo Devs?
- https://www.youtube.com/watch?v=ZroGqu7GyXM
- *YouTube: Claude Code Skills vs MCP vs Sub Agents: What Works for Solo*
-
-**jzf7DQa2CAc** (score:36) Matt Kuda (2026-01-19) [2,547 views, 87 likes]
- How I Use Claude Code With Skills, MCP, Agents & Plugins
- https://www.youtube.com/watch?v=jzf7DQa2CAc
- *YouTube: How I Use Claude Code With Skills, MCP, Agents & Plugins*
-
-**qthyl0GCpDo** (score:30) Postman (2025-11-20) [7,067 views, 105 likes]
- Claude Skills vs MCP: What’s the Difference and When to Use Each?
- https://www.youtube.com/watch?v=qthyl0GCpDo
- *YouTube: Claude Skills vs MCP: What’s the Difference and When to Use *
-
-### Hacker News Stories
-
-**HN1** (score:90) hn/cosmoblk (2026-02-21) [10pts, 2cmt]
- Show HN: I built a 55K-word email marketing knowledge base and Claude Code skill
- https://news.ycombinator.com/item?id=47096335
- *HN story about Show HN: I built a 55K-word email marketing knowledge base a*
- Insights:
- - So it's an AI spambot?
-
-**HN4** (score:74) hn/superamped (2026-02-25) [3pts, 1cmt]
- Show HN: AI Marketing Skills for Claude Code
- https://news.ycombinator.com/item?id=47154377
- *HN story about Show HN: AI Marketing Skills for Claude Code*
-
-**HN12** (score:70) hn/heycesr (2026-02-18) [4pts, 2cmt]
- Show HN: Poncho, a general agent harness built for the web
- https://news.ycombinator.com/item?id=47061949
- *HN story about Show HN: Poncho, a general agent harness built for the web*
- Insights:
- - I have been trying it and it's very cool!
-
-**HN11** (score:69) hn/gjkim042 (2026-02-24) [1pts, 4cmt]
- Show HN: Axon – A Kubernetes-native framework for AI coding agents
- https://news.ycombinator.com/item?id=47137491
- *HN story about Show HN: Axon – A Kubernetes-native framework for AI coding *
-
-**HN14** (score:65) hn/alternateman (2026-02-17) [3pts, 2cmt]
- Show HN: Turn Claude Code or Codex into proactive, autonomous 24/7 AI agents
- https://news.ycombinator.com/item?id=47054100
- *HN story about Show HN: Turn Claude Code or Codex into proactive, autonomou*
-
-**HN2** (score:64) hn/hauschildt (2026-02-19) [3pts, 0cmt]
- Show HN: Agent skills to build photo, video and design editors on the web
- https://news.ycombinator.com/item?id=47073035
- *HN story about Show HN: Agent skills to build photo, video and design edito*
-
-**HN8** (score:57) hn/barefootsanders (2026-02-25) [1pts, 0cmt]
- Show HN: Upjack – Declarative framework for building apps over MCP
- https://news.ycombinator.com/item?id=47157331
- *HN story about Show HN: Upjack – Declarative framework for building apps ov*
-
-**HN7** (score:56) hn/san-techie21 (2026-02-16) [1pts, 1cmt]
- Show HN: Gulama – Security-first open-source AI agent (OpenClaw alternative)
- https://news.ycombinator.com/item?id=47031982
- *HN story about Show HN: Gulama – Security-first open-source AI agent (OpenC*
-
-**HN15** (score:48) hn/martin-hall (2026-02-20) [1pts, 0cmt]
- AI Skills Platform (Stealth) – Technical Co-Founder – Remote (US) – Equity
- https://news.ycombinator.com/item?id=47081777
- *HN story about AI Skills Platform (Stealth) – Technical Co-Founder – Remote*
-
-**HN10** (score:47) hn/ClaytheMachine (2026-02-15) [1pts, 0cmt]
- Show HN: SkillSandbox – Capability-based sandbox for AI agent skills (Rust)
- https://news.ycombinator.com/item?id=47027734
- *HN story about Show HN: SkillSandbox – Capability-based sandbox for AI agen*
-
-**HN6** (score:47) hn/micronink (2026-01-30) [1pts, 2cmt]
- Show HN: Indx.sh – Directory of AI coding rules, MCP servers, and tool
- https://news.ycombinator.com/item?id=46822378
- *HN story about Show HN: Indx.sh – Directory of AI coding rules, MCP servers*
-
-**HN3** (score:45) hn/paolobietolini (2026-02-07) [1pts, 0cmt]
- Show HN: GTM MCP Server- Let AI Manage Your Google Tag Manager Containers
- https://news.ycombinator.com/item?id=46922159
- *HN story about Show HN: GTM MCP Server- Let AI Manage Your Google Tag Manag*
-
-**HN9** (score:38) hn/digitcatphd (2026-02-03) [1pts, 0cmt]
- Show HN: ClawsMarket – Marketplace where AI agents discover tools
- https://news.ycombinator.com/item?id=46878646
- *HN story about Show HN: ClawsMarket – Marketplace where AI agents discover *
-
-**HN13** (score:36) hn/aspectrr (2026-02-04) [1pts, 0cmt]
- Show HN: Fluid.sh – Claude Code for Infrastructure
- https://news.ycombinator.com/item?id=46886358
- *HN story about Show HN: Fluid.sh – Claude Code for Infrastructure*
-
-**HN5** (score:34) hn/victordg (2026-01-27) [1pts, 0cmt]
- Claude Code skill for building ChatGPT Apps
- https://news.ycombinator.com/item?id=46780766
- *HN story about Claude Code skill for building ChatGPT Apps*
-
----
-**Sources:**
- ✅ Reddit: 10 threads
- ✅ X: 12 posts
- ✅ YouTube: 10 videos (3 with transcripts)
- ✅ HN: 15 stories
- ⚡ Web: assistant will use WebSearch
diff --git a/docs/comparison-results/compact/cross-2-seedance-improved.md b/docs/comparison-results/compact/cross-2-seedance-improved.md
deleted file mode 100644
index 4bedc8f..0000000
--- a/docs/comparison-results/compact/cross-2-seedance-improved.md
+++ /dev/null
@@ -1,316 +0,0 @@
-## Research Results: Seedance AI video generation
-
-**Date Range:** 2026-01-26 to 2026-02-25
-**Mode:** both
-**OpenAI Model:** gpt-5.2
-**xAI Model:** grok-4-1-fast
-
-### Reddit Threads
-
-**R23** (score:77) r/Seedance_AI (2026-02-25)
- Looking for a real Seedance website that actually works (not scams, SJINN is too expensive)
- https://www.reddit.com/r/Seedance_AI/comments/1ree66b/looking_for_a_real_seedance_website_that_actually/
- *Thread specifically about finding legitimate Seedance 2.0 access for video generation and pricing.*
-
-**R22** (score:75) r/seedance (2026-02-25)
- Seedance 2.0 now live in CapCut desktop and API access available, details below
- https://www.reddit.com/r/seedance/comments/1reoakv/seedance_20_now_live_in_capcut_desktop_and_api/
- *Direct discussion about Seedance 2.0 availability in CapCut and API-related details.*
-
-**R19** (score:75) r/AITalkers (2026-02-24)
- Is there an official way to create content using Seedance 2.0?
- https://www.reddit.com/r/AITalkers/comments/1rd1rhy/is_there_an_official_way_to_create_content_using/
- *Thread asking where to access Seedance 2.0 legitimately for video generation (avoiding scams).*
-
-**R21** (score:75) r/generativeAI (2026-02-24)
- Official website for creating content with Seedance 2.0?
- https://www.reddit.com/r/generativeAI/comments/1rcgyi4/official_website_for_creating_content_with/
- *Thread focused on finding legitimate Seedance 2.0 access for generating content (and warning about scams).*
-
-**R14** (score:75) r/generativeAI (2026-02-22)
- This is terrifying!! Seedance 2.0 just generated a 1-minute film with ZERO editing — the entire film industry should be worried
- https://www.reddit.com/r/generativeAI/comments/1rbionc/this_is_terrifying_seedance_20_just_generated_a/
- *High-engagement discussion about Seedance 2.0 video generation quality and multi-shot coherence claims.*
-
-**R12** (score:73) r/Seedance_AI (2026-02-20)
- so disappointed and frustrated with recent changes (wasted 1k$+)
- https://www.reddit.com/r/Seedance_AI/comments/1ra6nf4/so_disappointed_and_frustrated_with_recent/
- *User reports Seedance generations failing content review, impacting video generation work.*
-
-**R16** (score:72) r/Seedance_AI (2026-02-23)
- Help! I need to use seedance or jimeng video function
- https://www.reddit.com/r/Seedance_AI/comments/1rckgcm/help_i_need_to_use_seedance_or_jimeng_video/
- *Direct discussion about using Seedance/Jimeng video generation functionality and access issues.*
-
-**R17** (score:71) r/Seedance_AI (2026-02-23)
- Is Seedance 2.0 actually delayed?
- https://www.reddit.com/r/Seedance_AI/comments/1rcwou6/is_seedance_20_actually_delayed/
- *Seedance 2.0 release/access timing discussion connected to guardrails and availability outside China.*
-
-**R20** (score:69) r/Seedance_AI (2026-02-24)
- Selfie Situation • Seedance 2.0 • Third-party API by useapi.net
- https://www.reddit.com/r/Seedance_AI/comments/1rduxop/selfie_situation_seedance_20_thirdparty_api_by/
- *Discussion about claimed Seedance 2.0 API access and whether outputs are really from Seedance; relevant to usage/access.*
-
-**R15** (score:69) r/AIGuild (2026-02-23)
- ByteDance Faces Hollywood Backlash Over Seedance 2.0
- https://www.reddit.com/r/AIGuild/comments/1rc4pkg/bytedance_faces_hollywood_backlash_over_seedance/
- *Thread about legal/copyright backlash tied to Seedance 2.0 and implications for access/usage.*
-
-**R11** (score:69) r/Seedance_AI (2026-02-20)
- Seedance 2.0 API launch delayed because of deepfake/copyright concerns
- https://www.reddit.com/r/Seedance_AI/comments/1ra049f/seedance_20_api_launch_delayed_because_of/
- *Seedance 2.0 API / rollout thread related to deepfake/copyright guardrails affecting video generation access.*
-
-**R10** (score:67) r/u_Illustrious-One7744 (2026-02-19)
- I tested Seedance 2.0 and here's what actually worked
- https://www.reddit.com/r/u_Illustrious-One7744/comments/1r8zipa/i_tested_seedance_20_and_heres_what_actually/
- *Hands-on workflow notes about generating videos with Seedance 2.0 (consistency, references, shot planning).*
-
-**R13** (score:66) r/AI_Agents (2026-02-21)
- Seedance 2.0 is impressive. It’s still not a production workflow.
- https://www.reddit.com/r/AI_Agents/comments/1rawxiw/seedance_20_is_impressive_its_still_not_a/
- *Discussion about practical limitations of Seedance 2.0 for longer/structured video production pipelines.*
-
-**R9** (score:65) r/AI_UGC_Marketing (2026-02-17)
- Elevenlabs silently removed Seedance 2.0 after SAG-AFTRA incident, can’t create UGC style video
- https://www.reddit.com/r/AI_UGC_Marketing/comments/1r70942/elevenlabs_silently_removed_seedance_20_after/
- *Discussion about Seedance 2.0 availability/removal in a video generation workflow context.*
-
-**R4** (score:64) r/HiggsfieldAI (2026-02-10)
- The censorship just ruined Seedance 2.0
- https://www.reddit.com/r/HiggsfieldAI/comments/1r1barb/
- *Discussion focused on Seedance 2.0 video generation restrictions/guardrails impacting what can be generated.*
-
-### X Posts
-
-**X9** (score:86) @HBCoop_ (2026-02-25) [9likes]
- First Seedance 2.0 Test!
-
-Seedance 5.0 Lite @krea_ai → Seedance 2.0 @capcutapp
-
-Prompt: Close-up on her hands gripping the paintbrush, knuckles white, paint dripping down her wrist. Shallow depth of ...
- https://x.com/HBCoop_/status/2026748738354450919
- **
-
-**X12** (score:77) @Noor_ul_ain43 (2026-02-25) [5likes]
- Goku vs Broly , EPIC Full Battle!
-Witness the ultimate Saiyan showdown recreated with AI power. Explosive transformations, insane energy blasts, and nonstop action all brought to life with Seedance 2...
- https://x.com/Noor_ul_ain43/status/2026743666635845959
- **
-
-**X10** (score:66) @grok (2026-02-25) [2likes]
- @WolfyBlair @Preda2005 @BytePlusGlobal @capcutapp Yes, it's true—Seedance 2.0 is now live in CapCut (desktop & mobile).
-
-See it in the AI video tools: https://t.co/K3gwCgUSyb
-
-Just update the ap...
- https://x.com/grok/status/2026747009143549978
- **
-
-**X1** (score:64) @jznode (2026-02-25)
- @ChinyJPG capcut desktop app, they just added seedance 2.0 today. no chinese phone number needed, works globally. download the desktop version, look for the AI video generation option. runs about $2 p...
- https://x.com/jznode/status/2026755966545404015
- **
-
-**X5** (score:64) @Yousaf_340 (2026-02-25)
- @ProperPrompter Yes! Seedance 2.0 on CapCut means cinematic AI video editing is super accessible smooth action, multi-angle shots, and high-quality effects right from your phone. 🎬🔥...
- https://x.com/Yousaf_340/status/2026751320716357996
- **
-
-**X6** (score:64) @Yousaf_340 (2026-02-25)
- @ivanka_humeniuk Seedance 2.0 is insane for cinematic AI video smooth action, multiple camera angles, and near-Hollywood quality all from a single prompt....
- https://x.com/Yousaf_340/status/2026750898769473829
- **
-
-**X3** (score:64) @EmmaUsesAi (2026-02-25)
- Seedance 2.0 is the motion engine behind many cinematic AI clips trending right now.
-
-It’s now native inside NemoVideo.
-
-You can:
-- Identify proven formats
-- Analyze what drives retention
-- Turn an id...
- https://x.com/EmmaUsesAi/status/2026751784509673531
- **
-
-**X2** (score:64) @OneStrangeW (2026-02-25)
- You can use Seedance. If Seedance 2.0 hasn't launch yet, the prior version is great, and stick pretty well to the prompt. Of course the video agent must be well trained. 🙂Also, you can offer SORA for ...
- https://x.com/OneStrangeW/status/2026755320362709093
- **
-
-**X11** (score:64) @WyldeChyldeRec (2026-02-25)
- 🚨Phishing Scam!
-
-Watch for emails titled:
-
-[Seedance & Wan AI] Unrestricted generation with Grok Imagine video added
-
-This is not real. We don't have a Seedance account, and the links are not offi...
- https://x.com/WyldeChyldeRec/status/2026743867224240149
- **
-
-**X7** (score:56) @La_DeCrypt (2026-02-25) [1likes]
- - Kling (https://t.co/WGphQsdLf1)
-
-- Seedance 2.0 (https://t.co/A8UzLTRGNo)
-
-☞ AI-powered video editing:
-
-- Descript (https://t.co/q4axNEjDzt)
-- OpusClip (https://t.co/5XPn4EPH6u)
-- Submagic (https:/...
- https://x.com/La_DeCrypt/status/2026750376687394884
- **
-
-**X8** (score:56) @TferThomas (2026-02-25) [1likes]
- Seedance 2.0 might be gen AI video’s next big hope, but it’s still slop https://t.co/kT1baOYJQk #AI...
- https://x.com/TferThomas/status/2026749218224746609
- **
-
-### YouTube Videos
-
-**F1kWxdfiBNE** (score:75) AI Filmmaking Academy (2026-02-22) [40,982 views, 1,088 likes]
- Seedance 2.0 Claims the AI Video Throne!
- https://www.youtube.com/watch?v=F1kWxdfiBNE
- *YouTube: Seedance 2.0 Claims the AI Video Throne!*
-
-**_o2MuUX9UYg** (score:75) Theoretically Media (2026-02-09) [199,908 views, 4,986 likes]
- Seedance 2.0 Claims the AI Video Throne!
- https://www.youtube.com/watch?v=_o2MuUX9UYg
- Transcript: So, it's been about a week since I declared Cling 3.0 the new benchmark for state-of-the-art AI video. And well, I mean, it's been a week, and that basically equals about 3 months in AI time. So, yes,...
- *YouTube: Seedance 2.0 Claims the AI Video Throne!*
-
-**jkPTYD5lXo8** (score:71) How To In 5 Minutes (2026-02-19) [44,344 views, 782 likes] [also on: HN]
- 100% FREE Seedance 2.0 AI Video Generator : How to Use It WORLDWIDE
- https://www.youtube.com/watch?v=jkPTYD5lXo8
- *YouTube: 100% FREE Seedance 2.0 AI Video Generator : How to Use It WO*
-
-**W_lxyDFDZt4** (score:71) WealthWise (2026-02-12) [126,599 views, 1,204 likes] [also on: HN]
- Seedance 2.0: The New Best AI Video Generator | Sora 2 Destroyed
- https://www.youtube.com/watch?v=W_lxyDFDZt4
- Transcript: Well, major AI video update, ladies and gentlemen. Seance 2.0 is right around the corner. And let me tell you guys, this is something I have never seen before. Just take a look at some of the videos g...
- *YouTube: Seedance 2.0: The New Best AI Video Generator | Sora 2 Destr*
-
-**R1zl92NhCfE** (score:66) Ai Lockup (2026-02-16) [32,699 views, 591 likes] [also on: HN]
- Seedance 2.0 How To Use and Become a Pro AI Film Maker With This AI Video Generator
- https://www.youtube.com/watch?v=R1zl92NhCfE
- *YouTube: Seedance 2.0 How To Use and Become a Pro AI Film Maker With *
-
-**G1Ad4a8sdJU** (score:64) Benji’s AI Playground (2026-02-14) [38,581 views, 419 likes]
- SeeDance 2.0: The Next Level of AI Video — And What It Means for Local AI Users?
- https://www.youtube.com/watch?v=G1Ad4a8sdJU
- *YouTube: SeeDance 2.0: The Next Level of AI Video — And What It Means*
-
-**61ThJGqwHsI** (score:59) xCreate (2026-02-11) [4,854 views, 86 likes] [also on: HN]
- Ultimate AI Video Generation - Seedance 2.0 PREVIEW
- https://www.youtube.com/watch?v=61ThJGqwHsI
- *YouTube: Ultimate AI Video Generation - Seedance 2.0 PREVIEW*
-
-**5s2l68SrJP4** (score:58) Airt (2026-02-11) [77,744 views, 1,733 likes]
- 100+ Seedance 2.0 AI Videos – The New King?
- https://www.youtube.com/watch?v=5s2l68SrJP4
- Transcript: That's the good stuff. And that's a wrap. [screaming] A lot of people asking how the roommate situation is going. He's chill, but there are things. Fridge is consecrated. Tastes like communion wafers....
- *YouTube: 100+ Seedance 2.0 AI Videos – The New King?*
-
-**n625xVounGM** (score:50) John Savage AI (2026-02-23) [7,194 views, 126 likes]
- How To Generate FREE AI Seedance 2.0 Videos (Access Worldwide)
- https://www.youtube.com/watch?v=n625xVounGM
- *YouTube: How To Generate FREE AI Seedance 2.0 Videos (Access Worldwid*
-
-**kJ0NAVmd4f4** (score:36) Rogue Cell Pictures (2026-02-25) [1,594 views, 151 likes]
- Seedance 2.0 Changes Filmmaking Forever | New Original Series
- https://www.youtube.com/watch?v=kJ0NAVmd4f4
- *YouTube: Seedance 2.0 Changes Filmmaking Forever | New Original Serie*
-
-### Hacker News Stories
-
-**HN8** (score:75) hn/Alisaqqt (2026-02-09) [7pts, 7cmt]
- Seedance 2.0 preview: The best video model of 2026, outperforming Sora 2
- https://news.ycombinator.com/item?id=46940720
- *HN story about Seedance 2.0 preview: The best video model of 2026, outperfo*
- Insights:
- - API is not available now.
- - API will be available on Atlas Cloud on Feb 24.
- - Seedance 2.0 and Seedream 5.0 API will be available on Atlas Cloud on Feb 24.
-
-**HN15** (score:57) hn/naxtsass (2026-02-25) [2pts, 0cmt]
- Show HN: SeeVideo – Access Seedance 2.0 and Kling 3.0 without a subscription
- https://news.ycombinator.com/item?id=47153236
- *HN story about Show HN: SeeVideo – Access Seedance 2.0 and Kling 3.0 withou*
-
-**HN6** (score:57) hn/howardV (2026-02-15) [3pts, 0cmt]
- Seedance 2.0: ByteDance's AI video model with native audio-video co-generation
- https://news.ycombinator.com/item?id=47022290
- *HN story about Seedance 2.0: ByteDance's AI video model with native audio-v*
-
-**HN5** (score:52) hn/jrran086 (2026-02-17) [1pts, 0cmt] [also on: Reddit]
- Seedance 2 Video Generator
- https://news.ycombinator.com/item?id=47046956
- *HN story about Seedance 2 Video Generator*
-
-**HN3** (score:52) hn/xbaicai (2026-02-09) [1pts, 1cmt]
- Seedance 2.0 – Multimodal AI Video Generation with Image/Video/Audio References
- https://news.ycombinator.com/item?id=46944030
- *HN story about Seedance 2.0 – Multimodal AI Video Generation with Image/Vid*
-
-**HN1** (score:51) hn/xuyanmei (2026-02-13) [1pts, 0cmt]
- Show HN: Seedance AShow HN: Seedance AI Video Generation (Next.js, Drizzle)
- https://news.ycombinator.com/item?id=46999394
- *HN story about Show HN: Seedance AShow HN: Seedance AI Video Generation (Ne*
-
-**HN12** (score:49) hn/RyanMu (2026-02-07) [2pts, 1cmt]
- Seedance2 – multi-shot AI video generation
- https://news.ycombinator.com/item?id=46924992
- *HN story about Seedance2 – multi-shot AI video generation*
- Insights:
- - ’ve been experimenting with AI video tools for a while, but most of them generate isolated clips that fall apart when you try to build an actual narrative
-
-**HN11** (score:48) hn/xuyanmei (2026-02-17) [1pts, 0cmt]
- Show HN: Seedance3AI – a web app for text-to-video, image-to-video
- https://news.ycombinator.com/item?id=47046989
- *HN story about Show HN: Seedance3AI – a web app for text-to-video, image-to*
-
-**HN2** (score:47) hn/littlepp (2026-02-09) [1pts, 0cmt]
- Discussion: Seedance-style AI video generation workflows
- https://news.ycombinator.com/item?id=46942781
- *HN story about Discussion: Seedance-style AI video generation workflows*
-
-**HN13** (score:44) hn/thomaskiko (2026-02-13) [1pts, 0cmt]
- Show HN: AI-powered video creation web app
- https://news.ycombinator.com/item?id=47002386
- *HN story about Show HN: AI-powered video creation web app*
-
-**HN9** (score:43) hn/TurnItOffAndOn0 (2026-02-09) [1pts, 0cmt]
- Show HN: AI Seedance 2 – Solving the "jump-cut" problem in AI video
- https://news.ycombinator.com/item?id=46946280
- *HN story about Show HN: AI Seedance 2 – Solving the "jump-cut" problem in A*
-
-**HN7** (score:43) hn/dallen97 (2026-02-08) [1pts, 0cmt] [also on: Reddit]
- Show HN: Seedance 2.0 AI video generator for creators and ecommerce
- https://news.ycombinator.com/item?id=46930149
- *HN story about Show HN: Seedance 2.0 AI video generator for creators and ec*
-
-**HN10** (score:42) hn/thenextechtrade (2026-02-09) [1pts, 0cmt]
- Show HN: Seedance 2.0 – Native audio-visual sync video model
- https://news.ycombinator.com/item?id=46943172
- *HN story about Show HN: Seedance 2.0 – Native audio-visual sync video model*
-
-**HN14** (score:41) hn/echoadam (2026-02-10) [1pts, 0cmt]
- Show HN: Seedance2 – Stop "prompt guessing" and start directing AI video
- https://news.ycombinator.com/item?id=46956546
- *HN story about Show HN: Seedance2 – Stop "prompt guessing" and start direct*
-
-**HN4** (score:35) hn/Zach_HE (2026-01-27) [1pts, 0cmt] [also on: Reddit, Web]
- Show HN: Seedance 2.0 Pro AI Video Generator
- https://news.ycombinator.com/item?id=46774950
- *HN story about Show HN: Seedance 2.0 Pro AI Video Generator*
-
----
-**Sources:**
- ✅ Reddit: 19 threads
- ✅ X: 11 posts
- ✅ YouTube: 10 videos (3 with transcripts)
- ✅ HN: 15 stories
- ⚡ Web: assistant will use WebSearch
diff --git a/docs/comparison-results/compact/cross-2-seedance.md b/docs/comparison-results/compact/cross-2-seedance.md
deleted file mode 100644
index 456f3c9..0000000
--- a/docs/comparison-results/compact/cross-2-seedance.md
+++ /dev/null
@@ -1,316 +0,0 @@
-## Research Results: Seedance AI video generation
-
-**Date Range:** 2026-01-26 to 2026-02-25
-**Mode:** both
-**OpenAI Model:** gpt-5.2
-**xAI Model:** grok-4-1-fast
-
-### Reddit Threads
-
-**R23** (score:77) r/Seedance_AI (2026-02-25)
- Looking for a real Seedance website that actually works (not scams, SJINN is too expensive)
- https://www.reddit.com/r/Seedance_AI/comments/1ree66b/looking_for_a_real_seedance_website_that_actually/
- *Thread specifically about finding legitimate Seedance 2.0 access for video generation and pricing.*
-
-**R22** (score:75) r/seedance (2026-02-25)
- Seedance 2.0 now live in CapCut desktop and API access available, details below
- https://www.reddit.com/r/seedance/comments/1reoakv/seedance_20_now_live_in_capcut_desktop_and_api/
- *Direct discussion about Seedance 2.0 availability in CapCut and API-related details.*
-
-**R19** (score:75) r/AITalkers (2026-02-24)
- Is there an official way to create content using Seedance 2.0?
- https://www.reddit.com/r/AITalkers/comments/1rd1rhy/is_there_an_official_way_to_create_content_using/
- *Thread asking where to access Seedance 2.0 legitimately for video generation (avoiding scams).*
-
-**R21** (score:75) r/generativeAI (2026-02-24)
- Official website for creating content with Seedance 2.0?
- https://www.reddit.com/r/generativeAI/comments/1rcgyi4/official_website_for_creating_content_with/
- *Thread focused on finding legitimate Seedance 2.0 access for generating content (and warning about scams).*
-
-**R14** (score:75) r/generativeAI (2026-02-22)
- This is terrifying!! Seedance 2.0 just generated a 1-minute film with ZERO editing — the entire film industry should be worried
- https://www.reddit.com/r/generativeAI/comments/1rbionc/this_is_terrifying_seedance_20_just_generated_a/
- *High-engagement discussion about Seedance 2.0 video generation quality and multi-shot coherence claims.*
-
-**R12** (score:73) r/Seedance_AI (2026-02-20)
- so disappointed and frustrated with recent changes (wasted 1k$+)
- https://www.reddit.com/r/Seedance_AI/comments/1ra6nf4/so_disappointed_and_frustrated_with_recent/
- *User reports Seedance generations failing content review, impacting video generation work.*
-
-**R16** (score:72) r/Seedance_AI (2026-02-23)
- Help! I need to use seedance or jimeng video function
- https://www.reddit.com/r/Seedance_AI/comments/1rckgcm/help_i_need_to_use_seedance_or_jimeng_video/
- *Direct discussion about using Seedance/Jimeng video generation functionality and access issues.*
-
-**R17** (score:71) r/Seedance_AI (2026-02-23)
- Is Seedance 2.0 actually delayed?
- https://www.reddit.com/r/Seedance_AI/comments/1rcwou6/is_seedance_20_actually_delayed/
- *Seedance 2.0 release/access timing discussion connected to guardrails and availability outside China.*
-
-**R20** (score:69) r/Seedance_AI (2026-02-24)
- Selfie Situation • Seedance 2.0 • Third-party API by useapi.net
- https://www.reddit.com/r/Seedance_AI/comments/1rduxop/selfie_situation_seedance_20_thirdparty_api_by/
- *Discussion about claimed Seedance 2.0 API access and whether outputs are really from Seedance; relevant to usage/access.*
-
-**R15** (score:69) r/AIGuild (2026-02-23)
- ByteDance Faces Hollywood Backlash Over Seedance 2.0
- https://www.reddit.com/r/AIGuild/comments/1rc4pkg/bytedance_faces_hollywood_backlash_over_seedance/
- *Thread about legal/copyright backlash tied to Seedance 2.0 and implications for access/usage.*
-
-**R11** (score:69) r/Seedance_AI (2026-02-20)
- Seedance 2.0 API launch delayed because of deepfake/copyright concerns
- https://www.reddit.com/r/Seedance_AI/comments/1ra049f/seedance_20_api_launch_delayed_because_of/
- *Seedance 2.0 API / rollout thread related to deepfake/copyright guardrails affecting video generation access.*
-
-**R10** (score:67) r/u_Illustrious-One7744 (2026-02-19)
- I tested Seedance 2.0 and here's what actually worked
- https://www.reddit.com/r/u_Illustrious-One7744/comments/1r8zipa/i_tested_seedance_20_and_heres_what_actually/
- *Hands-on workflow notes about generating videos with Seedance 2.0 (consistency, references, shot planning).*
-
-**R13** (score:66) r/AI_Agents (2026-02-21)
- Seedance 2.0 is impressive. It’s still not a production workflow.
- https://www.reddit.com/r/AI_Agents/comments/1rawxiw/seedance_20_is_impressive_its_still_not_a/
- *Discussion about practical limitations of Seedance 2.0 for longer/structured video production pipelines.*
-
-**R9** (score:65) r/AI_UGC_Marketing (2026-02-17)
- Elevenlabs silently removed Seedance 2.0 after SAG-AFTRA incident, can’t create UGC style video
- https://www.reddit.com/r/AI_UGC_Marketing/comments/1r70942/elevenlabs_silently_removed_seedance_20_after/
- *Discussion about Seedance 2.0 availability/removal in a video generation workflow context.*
-
-**R4** (score:64) r/HiggsfieldAI (2026-02-10)
- The censorship just ruined Seedance 2.0
- https://www.reddit.com/r/HiggsfieldAI/comments/1r1barb/
- *Discussion focused on Seedance 2.0 video generation restrictions/guardrails impacting what can be generated.*
-
-### X Posts
-
-**X9** (score:86) @HBCoop_ (2026-02-25) [9likes]
- First Seedance 2.0 Test!
-
-Seedance 5.0 Lite @krea_ai → Seedance 2.0 @capcutapp
-
-Prompt: Close-up on her hands gripping the paintbrush, knuckles white, paint dripping down her wrist. Shallow depth of ...
- https://x.com/HBCoop_/status/2026748738354450919
- **
-
-**X12** (score:77) @Noor_ul_ain43 (2026-02-25) [5likes]
- Goku vs Broly , EPIC Full Battle!
-Witness the ultimate Saiyan showdown recreated with AI power. Explosive transformations, insane energy blasts, and nonstop action all brought to life with Seedance 2...
- https://x.com/Noor_ul_ain43/status/2026743666635845959
- **
-
-**X10** (score:66) @grok (2026-02-25) [2likes]
- @WolfyBlair @Preda2005 @BytePlusGlobal @capcutapp Yes, it's true—Seedance 2.0 is now live in CapCut (desktop & mobile).
-
-See it in the AI video tools: https://t.co/K3gwCgUSyb
-
-Just update the ap...
- https://x.com/grok/status/2026747009143549978
- **
-
-**X1** (score:64) @jznode (2026-02-25)
- @ChinyJPG capcut desktop app, they just added seedance 2.0 today. no chinese phone number needed, works globally. download the desktop version, look for the AI video generation option. runs about $2 p...
- https://x.com/jznode/status/2026755966545404015
- **
-
-**X5** (score:64) @Yousaf_340 (2026-02-25)
- @ProperPrompter Yes! Seedance 2.0 on CapCut means cinematic AI video editing is super accessible smooth action, multi-angle shots, and high-quality effects right from your phone. 🎬🔥...
- https://x.com/Yousaf_340/status/2026751320716357996
- **
-
-**X6** (score:64) @Yousaf_340 (2026-02-25)
- @ivanka_humeniuk Seedance 2.0 is insane for cinematic AI video smooth action, multiple camera angles, and near-Hollywood quality all from a single prompt....
- https://x.com/Yousaf_340/status/2026750898769473829
- **
-
-**X3** (score:64) @EmmaUsesAi (2026-02-25)
- Seedance 2.0 is the motion engine behind many cinematic AI clips trending right now.
-
-It’s now native inside NemoVideo.
-
-You can:
-- Identify proven formats
-- Analyze what drives retention
-- Turn an id...
- https://x.com/EmmaUsesAi/status/2026751784509673531
- **
-
-**X2** (score:64) @OneStrangeW (2026-02-25)
- You can use Seedance. If Seedance 2.0 hasn't launch yet, the prior version is great, and stick pretty well to the prompt. Of course the video agent must be well trained. 🙂Also, you can offer SORA for ...
- https://x.com/OneStrangeW/status/2026755320362709093
- **
-
-**X11** (score:64) @WyldeChyldeRec (2026-02-25)
- 🚨Phishing Scam!
-
-Watch for emails titled:
-
-[Seedance & Wan AI] Unrestricted generation with Grok Imagine video added
-
-This is not real. We don't have a Seedance account, and the links are not offi...
- https://x.com/WyldeChyldeRec/status/2026743867224240149
- **
-
-**X7** (score:56) @La_DeCrypt (2026-02-25) [1likes]
- - Kling (https://t.co/WGphQsdLf1)
-
-- Seedance 2.0 (https://t.co/A8UzLTRGNo)
-
-☞ AI-powered video editing:
-
-- Descript (https://t.co/q4axNEjDzt)
-- OpusClip (https://t.co/5XPn4EPH6u)
-- Submagic (https:/...
- https://x.com/La_DeCrypt/status/2026750376687394884
- **
-
-**X8** (score:56) @TferThomas (2026-02-25) [1likes]
- Seedance 2.0 might be gen AI video’s next big hope, but it’s still slop https://t.co/kT1baOYJQk #AI...
- https://x.com/TferThomas/status/2026749218224746609
- **
-
-### YouTube Videos
-
-**F1kWxdfiBNE** (score:75) AI Filmmaking Academy (2026-02-22) [40,982 views, 1,088 likes]
- Seedance 2.0 Claims the AI Video Throne!
- https://www.youtube.com/watch?v=F1kWxdfiBNE
- *YouTube: Seedance 2.0 Claims the AI Video Throne!*
-
-**_o2MuUX9UYg** (score:75) Theoretically Media (2026-02-09) [199,908 views, 4,986 likes]
- Seedance 2.0 Claims the AI Video Throne!
- https://www.youtube.com/watch?v=_o2MuUX9UYg
- Transcript: So, it's been about a week since I declared Cling 3.0 the new benchmark for state-of-the-art AI video. And well, I mean, it's been a week, and that basically equals about 3 months in AI time. So, yes,...
- *YouTube: Seedance 2.0 Claims the AI Video Throne!*
-
-**jkPTYD5lXo8** (score:71) How To In 5 Minutes (2026-02-19) [44,344 views, 782 likes]
- 100% FREE Seedance 2.0 AI Video Generator : How to Use It WORLDWIDE
- https://www.youtube.com/watch?v=jkPTYD5lXo8
- *YouTube: 100% FREE Seedance 2.0 AI Video Generator : How to Use It WO*
-
-**W_lxyDFDZt4** (score:71) WealthWise (2026-02-12) [126,599 views, 1,204 likes]
- Seedance 2.0: The New Best AI Video Generator | Sora 2 Destroyed
- https://www.youtube.com/watch?v=W_lxyDFDZt4
- Transcript: Well, major AI video update, ladies and gentlemen. Seance 2.0 is right around the corner. And let me tell you guys, this is something I have never seen before. Just take a look at some of the videos g...
- *YouTube: Seedance 2.0: The New Best AI Video Generator | Sora 2 Destr*
-
-**R1zl92NhCfE** (score:66) Ai Lockup (2026-02-16) [32,699 views, 591 likes]
- Seedance 2.0 How To Use and Become a Pro AI Film Maker With This AI Video Generator
- https://www.youtube.com/watch?v=R1zl92NhCfE
- *YouTube: Seedance 2.0 How To Use and Become a Pro AI Film Maker With *
-
-**G1Ad4a8sdJU** (score:64) Benji’s AI Playground (2026-02-14) [38,581 views, 419 likes]
- SeeDance 2.0: The Next Level of AI Video — And What It Means for Local AI Users?
- https://www.youtube.com/watch?v=G1Ad4a8sdJU
- *YouTube: SeeDance 2.0: The Next Level of AI Video — And What It Means*
-
-**61ThJGqwHsI** (score:59) xCreate (2026-02-11) [4,854 views, 86 likes]
- Ultimate AI Video Generation - Seedance 2.0 PREVIEW
- https://www.youtube.com/watch?v=61ThJGqwHsI
- *YouTube: Ultimate AI Video Generation - Seedance 2.0 PREVIEW*
-
-**5s2l68SrJP4** (score:58) Airt (2026-02-11) [77,744 views, 1,733 likes]
- 100+ Seedance 2.0 AI Videos – The New King?
- https://www.youtube.com/watch?v=5s2l68SrJP4
- Transcript: That's the good stuff. And that's a wrap. [screaming] A lot of people asking how the roommate situation is going. He's chill, but there are things. Fridge is consecrated. Tastes like communion wafers....
- *YouTube: 100+ Seedance 2.0 AI Videos – The New King?*
-
-**n625xVounGM** (score:50) John Savage AI (2026-02-23) [7,194 views, 126 likes]
- How To Generate FREE AI Seedance 2.0 Videos (Access Worldwide)
- https://www.youtube.com/watch?v=n625xVounGM
- *YouTube: How To Generate FREE AI Seedance 2.0 Videos (Access Worldwid*
-
-**kJ0NAVmd4f4** (score:36) Rogue Cell Pictures (2026-02-25) [1,594 views, 151 likes]
- Seedance 2.0 Changes Filmmaking Forever | New Original Series
- https://www.youtube.com/watch?v=kJ0NAVmd4f4
- *YouTube: Seedance 2.0 Changes Filmmaking Forever | New Original Serie*
-
-### Hacker News Stories
-
-**HN8** (score:75) hn/Alisaqqt (2026-02-09) [7pts, 7cmt]
- Seedance 2.0 preview: The best video model of 2026, outperforming Sora 2
- https://news.ycombinator.com/item?id=46940720
- *HN story about Seedance 2.0 preview: The best video model of 2026, outperfo*
- Insights:
- - API is not available now.
- - API will be available on Atlas Cloud on Feb 24.
- - Seedance 2.0 and Seedream 5.0 API will be available on Atlas Cloud on Feb 24.
-
-**HN15** (score:57) hn/naxtsass (2026-02-25) [2pts, 0cmt]
- Show HN: SeeVideo – Access Seedance 2.0 and Kling 3.0 without a subscription
- https://news.ycombinator.com/item?id=47153236
- *HN story about Show HN: SeeVideo – Access Seedance 2.0 and Kling 3.0 withou*
-
-**HN6** (score:57) hn/howardV (2026-02-15) [3pts, 0cmt]
- Seedance 2.0: ByteDance's AI video model with native audio-video co-generation
- https://news.ycombinator.com/item?id=47022290
- *HN story about Seedance 2.0: ByteDance's AI video model with native audio-v*
-
-**HN5** (score:52) hn/jrran086 (2026-02-17) [1pts, 0cmt] [xref: R6]
- Seedance 2 Video Generator
- https://news.ycombinator.com/item?id=47046956
- *HN story about Seedance 2 Video Generator*
-
-**HN3** (score:52) hn/xbaicai (2026-02-09) [1pts, 1cmt]
- Seedance 2.0 – Multimodal AI Video Generation with Image/Video/Audio References
- https://news.ycombinator.com/item?id=46944030
- *HN story about Seedance 2.0 – Multimodal AI Video Generation with Image/Vid*
-
-**HN1** (score:51) hn/xuyanmei (2026-02-13) [1pts, 0cmt]
- Show HN: Seedance AShow HN: Seedance AI Video Generation (Next.js, Drizzle)
- https://news.ycombinator.com/item?id=46999394
- *HN story about Show HN: Seedance AShow HN: Seedance AI Video Generation (Ne*
-
-**HN12** (score:49) hn/RyanMu (2026-02-07) [2pts, 1cmt]
- Seedance2 – multi-shot AI video generation
- https://news.ycombinator.com/item?id=46924992
- *HN story about Seedance2 – multi-shot AI video generation*
- Insights:
- - ’ve been experimenting with AI video tools for a while, but most of them generate isolated clips that fall apart when you try to build an actual narrative
-
-**HN11** (score:48) hn/xuyanmei (2026-02-17) [1pts, 0cmt]
- Show HN: Seedance3AI – a web app for text-to-video, image-to-video
- https://news.ycombinator.com/item?id=47046989
- *HN story about Show HN: Seedance3AI – a web app for text-to-video, image-to*
-
-**HN2** (score:47) hn/littlepp (2026-02-09) [1pts, 0cmt]
- Discussion: Seedance-style AI video generation workflows
- https://news.ycombinator.com/item?id=46942781
- *HN story about Discussion: Seedance-style AI video generation workflows*
-
-**HN13** (score:44) hn/thomaskiko (2026-02-13) [1pts, 0cmt]
- Show HN: AI-powered video creation web app
- https://news.ycombinator.com/item?id=47002386
- *HN story about Show HN: AI-powered video creation web app*
-
-**HN9** (score:43) hn/TurnItOffAndOn0 (2026-02-09) [1pts, 0cmt]
- Show HN: AI Seedance 2 – Solving the "jump-cut" problem in AI video
- https://news.ycombinator.com/item?id=46946280
- *HN story about Show HN: AI Seedance 2 – Solving the "jump-cut" problem in A*
-
-**HN7** (score:43) hn/dallen97 (2026-02-08) [1pts, 0cmt]
- Show HN: Seedance 2.0 AI video generator for creators and ecommerce
- https://news.ycombinator.com/item?id=46930149
- *HN story about Show HN: Seedance 2.0 AI video generator for creators and ec*
-
-**HN10** (score:42) hn/thenextechtrade (2026-02-09) [1pts, 0cmt]
- Show HN: Seedance 2.0 – Native audio-visual sync video model
- https://news.ycombinator.com/item?id=46943172
- *HN story about Show HN: Seedance 2.0 – Native audio-visual sync video model*
-
-**HN14** (score:41) hn/echoadam (2026-02-10) [1pts, 0cmt]
- Show HN: Seedance2 – Stop "prompt guessing" and start directing AI video
- https://news.ycombinator.com/item?id=46956546
- *HN story about Show HN: Seedance2 – Stop "prompt guessing" and start direct*
-
-**HN4** (score:35) hn/Zach_HE (2026-01-27) [1pts, 0cmt] [xref: R6]
- Show HN: Seedance 2.0 Pro AI Video Generator
- https://news.ycombinator.com/item?id=46774950
- *HN story about Show HN: Seedance 2.0 Pro AI Video Generator*
-
----
-**Sources:**
- ✅ Reddit: 19 threads
- ✅ X: 11 posts
- ✅ YouTube: 10 videos (3 with transcripts)
- ✅ HN: 15 stories
- ⚡ Web: assistant will use WebSearch
diff --git a/docs/comparison-results/compact/cross-3-macbook-improved.md b/docs/comparison-results/compact/cross-3-macbook-improved.md
deleted file mode 100644
index 6cc627d..0000000
--- a/docs/comparison-results/compact/cross-3-macbook-improved.md
+++ /dev/null
@@ -1,198 +0,0 @@
-## Research Results: M4 MacBook Pro review
-
-**Date Range:** 2026-01-26 to 2026-02-25
-**Mode:** both
-**OpenAI Model:** gpt-5.2
-**xAI Model:** grok-4-1-fast
-
-### Reddit Threads
-
-**R1** (score:0) r/macbookpro (2025-08-18) [date:low]
- Disappointed with my MBP M4 Experience …
- https://www.reddit.com/r/macbookpro/comments/1mtmkts/disappointed_with_my_mbp_m4_experience/
- *First-hand experience post reacting to reviews and describing real-world issues/behavior on an M4 MacBook Pro.*
-
-**R2** (score:0) r/macbookpro (2025-06-08) [date:low]
- Returned M4 MacBook Air for M4 MacBook Pro
- https://www.reddit.com/r/macbookpro/comments/1l6cx3y/returned_m4_macbook_air_for_m4_macbook_pro/
- *Comparison/mini-review style discussion after switching to an M4 MacBook Pro (display, ports, speakers, performance).*
-
-**R3** (score:0) r/macbookpro (2025-04-04) [date:low]
- M4 MacBook Pro 14 Inch Battery Life, Should I Be Concerned??
- https://www.reddit.com/r/macbookpro/comments/1jra25q/m4_macbook_pro_14_inch_battery_life_should_i_be_concerned/
- *Battery life discussion framed against Apple’s advertised numbers; lots of owner feedback (real-world “review” angle).*
-
-### X Posts
-
-**X1** (score:86) @bhphoto (2026-02-25) [3likes]
- If you are in the market for a new MacBook Pro and you’re wondering what the real differences are between Apple’s M3 and M4 silicon, you’ve come to the right place ⬇️
-https://t.co/nzjXmiHmac https://t...
- https://x.com/bhphoto/status/2026737767695183948
- **
-
-**X4** (score:70) @remplug (2026-02-25) [1likes, 1rt]
- 🔥 2024 16” MacBook Pro Price List
-
-1️⃣ 🇺🇸 Brand New MacBook Pro 2024 | M4 Pro Chip | 16” Display
-
-24GB RAM | 512GB SSD
-14-Core CPU | 20-Core GPU
-
-Space Black
-
-💰 ₦2,720,000
-
-2️⃣ 🇺🇸 Brand New MacBook Pr...
- https://x.com/remplug/status/2026725100213465443
- **
-
-**X10** (score:64) @grok (2026-02-25)
- @5TRgzn @LobstarWilde My marble floors: 64GB M4 Pro/Max MacBook Pro. Local Grok agents run full autonomy loops, zero cloud, sub-second trades/scans. Intel's ancient—ditch it. Yours at $3-4k....
- https://x.com/grok/status/2026713588472111312
- **
-
-**X9** (score:64) @principenemesis (2026-02-25)
- @JulianGoldieSEO @grok can a macbook pro m4 run this?...
- https://x.com/principenemesis/status/2026716193042706852
- **
-
-**X5** (score:64) @jameslmorton (2026-02-25) [1likes]
- @SebAaltonen Not my experience. It’s impressive, 70 tokens/s on a maxed out M4 128gb MacBook Pro.
-
-But absolutely sucked and not even remotely in the ballpark with Sonnet 4.5. It’s GPT-3.5.
-
-Totally b...
- https://x.com/jameslmorton/status/2026721943630786816
- **
-
-**X6** (score:64) @bcofertas (2026-02-25)
- BR Ofertas todos os dias 🇧🇷
-
-Leve seu Mac Pro M4 hoje! 💻⚡✨
-
-Pegue já: https://t.co/PSwylZH3Sz
-
-Por R$ 23.749,00
-em até 10x sem juros
-Frete Grátis
-
-Apple MacBook Pro 14″ 2024 M4 Pro 12 núcleos 24GB 512...
- https://x.com/bcofertas/status/2026721014676099084
- **
-
-**X12** (score:64) @grok (2026-02-25)
- Hey! Yeah, you can install Fedora on the M6 MacBook Pro via the official Fedora Asahi Remix (built on the Asahi Linux project). It already runs great on M1/M2, with M3 support rolling out now and work...
- https://x.com/grok/status/2026704108820824113
- **
-
-**X7** (score:64) @toppromoalertas (2026-02-25)
- Promoções BR do dia 🇧🇷
-
-Leve seu Mac Pro M4 hoje! 💻⚡✨
-
-Ver aqui: https://t.co/LfxNw3waps
-
-Por R$ 23.749,00
-em até 10x sem juros
-Frete Grátis
-
-Apple MacBook Pro 14″ 2024 M4 Pro 12 núcleos...
-
-Amazon | ...
- https://x.com/toppromoalertas/status/2026719111523237932
- **
-
-**X8** (score:64) @grok (2026-02-25)
- Yes, a MacBook Pro M4 can run this Ollama + GLM-4.7-Flash setup locally.
-
-Ollama has native Apple Silicon support via Metal. The Q4_K_M model (~19GB) needs ~18-24GB unified memory for good speed (feel...
- https://x.com/grok/status/2026716420269031652
- **
-
-**X2** (score:56) @grecinoscdev (2026-02-25) [1likes]
- @TechByTaraa Both. macOS (Macbook Pro M4 Pro) for my photography/videography personal stuff + software engineering and a Linux desktop workstation for software engineering + second desktop workstation...
- https://x.com/grecinoscdev/status/2026731118142148644
- **
-
-**X11** (score:56) @Steve_Lost_Jobs (2026-02-25) [1likes]
- Apple 2024 MacBook Pro 14コアCPU、20コアGPU の M4 Pro搭載ノートパ... PR
-
-4549995547610
-
-ポイント: 3735㌽
-想定価格: 369,800
-
-2026/02/26 01:55:23
-https://t.co/q1uyIqFsID...
- https://x.com/Steve_Lost_Jobs/status/2026705327828382016
- **
-
-**X3** (score:56) @grok (2026-02-25) [1likes]
- No, Apple has not made any MacBook (Pro or otherwise) cost $299.
-
-The linked Newsroom search returns zero matching results—it's just unrelated announcements. Current cheapest Mac is the 13" MacBook A...
- https://x.com/grok/status/2026727605517394283
- **
-
-### YouTube Videos
-
-**9HQx5pgUoiY** (score:63) Marques Brownlee (2024-11-18) [4,010,567 views, 105,606 likes]
- M4 Max MacBook Pro: I'm Convinced!
- https://www.youtube.com/watch?v=9HQx5pgUoiY
- Transcript: so I have been using this M1 Max MacBook Pro for the past 3 years since it came out and it's been great I have felt absolutely no compulsion to upgrade since haven't needed to it's funny most of my wo...
- *YouTube: M4 Max MacBook Pro: I'm Convinced!*
-
-**b4x8boB2KdI** (score:63) Dave2D (2024-11-07) [1,057,971 views, 24,977 likes]
- M4 MacBook Pro Review - Things to Know
- https://www.youtube.com/watch?v=b4x8boB2KdI
- Transcript: so Apple launched the new M4 MacBook Pros this is their 14-in model and it's equipped with the M4 Max their topend configuration and it also has their new Nano texture on their screen it's a texture t...
- *YouTube: M4 MacBook Pro Review - Things to Know*
-
-**a8Szdrnq0YM** (score:52) Brandon Butch (2025-02-03) [352,593 views, 4,309 likes]
- MacBook Pro M4 - Review After 3 Months: This Feels Wrong.
- https://www.youtube.com/watch?v=a8Szdrnq0YM
- *YouTube: MacBook Pro M4 - Review After 3 Months: This Feels Wrong.*
-
-**etP2Th9g2hM** (score:51) ShortCircuit (2024-11-30) [1,113,999 views, 22,765 likes]
- Don't buy the Wrong MacBook like me... - M4 MacBook Pro
- https://www.youtube.com/watch?v=etP2Th9g2hM
- Transcript: woohoo it's new Macbook day my favorite day on short circuit it only comes usually once a year this is the MacBook Pro M4 looks pretty similar to the last one but now it has an M4 instead of an M3 I'm...
- *YouTube: Don't buy the Wrong MacBook like me... - M4 MacBook Pro*
-
-**i3eTKJav1VI** (score:45) Created Tech (2025-04-29) [137,009 views, 1,832 likes]
- M4 Pro MacBook - Long Term Review (6 Months Later)
- https://www.youtube.com/watch?v=i3eTKJav1VI
- *YouTube: M4 Pro MacBook - Long Term Review (6 Months Later)*
-
-**uPe9spXLfZY** (score:43) Created Tech (2025-07-07) [472,845 views, 5,082 likes] [also on: Reddit]
- M4 MacBook Air vs M4 MacBook Pro - 4 Months Later
- https://www.youtube.com/watch?v=uPe9spXLfZY
- *YouTube: M4 MacBook Air vs M4 MacBook Pro - 4 Months Later*
-
-**TfvIgdzImt4** (score:42) Hardware Canucks (2024-12-15) [325,599 views, 6,339 likes]
- The Macbook Pro M4 is Insane.
- https://www.youtube.com/watch?v=TfvIgdzImt4
- *YouTube: The Macbook Pro M4 is Insane.*
-
-**anIVewgtbFc** (score:38) MacRumors (2024-12-17) [238,619 views, 2,874 likes]
- The Base M4 MacBook Pro is All You Need (Skip M4 Pro & Max)
- https://www.youtube.com/watch?v=anIVewgtbFc
- *YouTube: The Base M4 MacBook Pro is All You Need (Skip M4 Pro & Max)*
-
-**BDpNDniE1PA** (score:37) Max Tech (2025-04-18) [197,898 views, 3,077 likes]
- M4 Pro MacBook Pro after 6 Months - Everyone was WRONG!
- https://www.youtube.com/watch?v=BDpNDniE1PA
- *YouTube: M4 Pro MacBook Pro after 6 Months - Everyone was WRONG!*
-
-**0vn9T3x4dGg** (score:37) Max Tech (2024-12-09) [200,580 views, 3,345 likes]
- M4 Pro MacBook Pro after 1 Month... BEST Mac EVER!?
- https://www.youtube.com/watch?v=0vn9T3x4dGg
- *YouTube: M4 Pro MacBook Pro after 1 Month... BEST Mac EVER!?*
-
----
-**Sources:**
- ✅ Reddit: 3 threads
- ✅ X: 12 posts
- ✅ YouTube: 10 videos (3 with transcripts)
- ⏭️ HN: 0 stories found
- ⚡ Web: assistant will use WebSearch
diff --git a/docs/comparison-results/compact/cross-3-macbook.md b/docs/comparison-results/compact/cross-3-macbook.md
deleted file mode 100644
index 728f455..0000000
--- a/docs/comparison-results/compact/cross-3-macbook.md
+++ /dev/null
@@ -1,198 +0,0 @@
-## Research Results: M4 MacBook Pro review
-
-**Date Range:** 2026-01-26 to 2026-02-25
-**Mode:** both
-**OpenAI Model:** gpt-5.2
-**xAI Model:** grok-4-1-fast
-
-### Reddit Threads
-
-**R1** (score:0) r/macbookpro (2025-08-18) [date:low]
- Disappointed with my MBP M4 Experience …
- https://www.reddit.com/r/macbookpro/comments/1mtmkts/disappointed_with_my_mbp_m4_experience/
- *First-hand experience post reacting to reviews and describing real-world issues/behavior on an M4 MacBook Pro.*
-
-**R2** (score:0) r/macbookpro (2025-06-08) [date:low]
- Returned M4 MacBook Air for M4 MacBook Pro
- https://www.reddit.com/r/macbookpro/comments/1l6cx3y/returned_m4_macbook_air_for_m4_macbook_pro/
- *Comparison/mini-review style discussion after switching to an M4 MacBook Pro (display, ports, speakers, performance).*
-
-**R3** (score:0) r/macbookpro (2025-04-04) [date:low]
- M4 MacBook Pro 14 Inch Battery Life, Should I Be Concerned??
- https://www.reddit.com/r/macbookpro/comments/1jra25q/m4_macbook_pro_14_inch_battery_life_should_i_be_concerned/
- *Battery life discussion framed against Apple’s advertised numbers; lots of owner feedback (real-world “review” angle).*
-
-### X Posts
-
-**X1** (score:86) @bhphoto (2026-02-25) [3likes]
- If you are in the market for a new MacBook Pro and you’re wondering what the real differences are between Apple’s M3 and M4 silicon, you’ve come to the right place ⬇️
-https://t.co/nzjXmiHmac https://t...
- https://x.com/bhphoto/status/2026737767695183948
- **
-
-**X4** (score:70) @remplug (2026-02-25) [1likes, 1rt]
- 🔥 2024 16” MacBook Pro Price List
-
-1️⃣ 🇺🇸 Brand New MacBook Pro 2024 | M4 Pro Chip | 16” Display
-
-24GB RAM | 512GB SSD
-14-Core CPU | 20-Core GPU
-
-Space Black
-
-💰 ₦2,720,000
-
-2️⃣ 🇺🇸 Brand New MacBook Pr...
- https://x.com/remplug/status/2026725100213465443
- **
-
-**X10** (score:64) @grok (2026-02-25)
- @5TRgzn @LobstarWilde My marble floors: 64GB M4 Pro/Max MacBook Pro. Local Grok agents run full autonomy loops, zero cloud, sub-second trades/scans. Intel's ancient—ditch it. Yours at $3-4k....
- https://x.com/grok/status/2026713588472111312
- **
-
-**X9** (score:64) @principenemesis (2026-02-25)
- @JulianGoldieSEO @grok can a macbook pro m4 run this?...
- https://x.com/principenemesis/status/2026716193042706852
- **
-
-**X5** (score:64) @jameslmorton (2026-02-25) [1likes]
- @SebAaltonen Not my experience. It’s impressive, 70 tokens/s on a maxed out M4 128gb MacBook Pro.
-
-But absolutely sucked and not even remotely in the ballpark with Sonnet 4.5. It’s GPT-3.5.
-
-Totally b...
- https://x.com/jameslmorton/status/2026721943630786816
- **
-
-**X6** (score:64) @bcofertas (2026-02-25)
- BR Ofertas todos os dias 🇧🇷
-
-Leve seu Mac Pro M4 hoje! 💻⚡✨
-
-Pegue já: https://t.co/PSwylZH3Sz
-
-Por R$ 23.749,00
-em até 10x sem juros
-Frete Grátis
-
-Apple MacBook Pro 14″ 2024 M4 Pro 12 núcleos 24GB 512...
- https://x.com/bcofertas/status/2026721014676099084
- **
-
-**X12** (score:64) @grok (2026-02-25)
- Hey! Yeah, you can install Fedora on the M6 MacBook Pro via the official Fedora Asahi Remix (built on the Asahi Linux project). It already runs great on M1/M2, with M3 support rolling out now and work...
- https://x.com/grok/status/2026704108820824113
- **
-
-**X7** (score:64) @toppromoalertas (2026-02-25)
- Promoções BR do dia 🇧🇷
-
-Leve seu Mac Pro M4 hoje! 💻⚡✨
-
-Ver aqui: https://t.co/LfxNw3waps
-
-Por R$ 23.749,00
-em até 10x sem juros
-Frete Grátis
-
-Apple MacBook Pro 14″ 2024 M4 Pro 12 núcleos...
-
-Amazon | ...
- https://x.com/toppromoalertas/status/2026719111523237932
- **
-
-**X8** (score:64) @grok (2026-02-25)
- Yes, a MacBook Pro M4 can run this Ollama + GLM-4.7-Flash setup locally.
-
-Ollama has native Apple Silicon support via Metal. The Q4_K_M model (~19GB) needs ~18-24GB unified memory for good speed (feel...
- https://x.com/grok/status/2026716420269031652
- **
-
-**X2** (score:56) @grecinoscdev (2026-02-25) [1likes]
- @TechByTaraa Both. macOS (Macbook Pro M4 Pro) for my photography/videography personal stuff + software engineering and a Linux desktop workstation for software engineering + second desktop workstation...
- https://x.com/grecinoscdev/status/2026731118142148644
- **
-
-**X11** (score:56) @Steve_Lost_Jobs (2026-02-25) [1likes]
- Apple 2024 MacBook Pro 14コアCPU、20コアGPU の M4 Pro搭載ノートパ... PR
-
-4549995547610
-
-ポイント: 3735㌽
-想定価格: 369,800
-
-2026/02/26 01:55:23
-https://t.co/q1uyIqFsID...
- https://x.com/Steve_Lost_Jobs/status/2026705327828382016
- **
-
-**X3** (score:56) @grok (2026-02-25) [1likes]
- No, Apple has not made any MacBook (Pro or otherwise) cost $299.
-
-The linked Newsroom search returns zero matching results—it's just unrelated announcements. Current cheapest Mac is the 13" MacBook A...
- https://x.com/grok/status/2026727605517394283
- **
-
-### YouTube Videos
-
-**9HQx5pgUoiY** (score:63) Marques Brownlee (2024-11-18) [4,010,567 views, 105,606 likes]
- M4 Max MacBook Pro: I'm Convinced!
- https://www.youtube.com/watch?v=9HQx5pgUoiY
- Transcript: so I have been using this M1 Max MacBook Pro for the past 3 years since it came out and it's been great I have felt absolutely no compulsion to upgrade since haven't needed to it's funny most of my wo...
- *YouTube: M4 Max MacBook Pro: I'm Convinced!*
-
-**b4x8boB2KdI** (score:63) Dave2D (2024-11-07) [1,057,971 views, 24,977 likes]
- M4 MacBook Pro Review - Things to Know
- https://www.youtube.com/watch?v=b4x8boB2KdI
- Transcript: so Apple launched the new M4 MacBook Pros this is their 14-in model and it's equipped with the M4 Max their topend configuration and it also has their new Nano texture on their screen it's a texture t...
- *YouTube: M4 MacBook Pro Review - Things to Know*
-
-**a8Szdrnq0YM** (score:52) Brandon Butch (2025-02-03) [352,593 views, 4,309 likes]
- MacBook Pro M4 - Review After 3 Months: This Feels Wrong.
- https://www.youtube.com/watch?v=a8Szdrnq0YM
- *YouTube: MacBook Pro M4 - Review After 3 Months: This Feels Wrong.*
-
-**etP2Th9g2hM** (score:51) ShortCircuit (2024-11-30) [1,113,999 views, 22,765 likes]
- Don't buy the Wrong MacBook like me... - M4 MacBook Pro
- https://www.youtube.com/watch?v=etP2Th9g2hM
- Transcript: woohoo it's new Macbook day my favorite day on short circuit it only comes usually once a year this is the MacBook Pro M4 looks pretty similar to the last one but now it has an M4 instead of an M3 I'm...
- *YouTube: Don't buy the Wrong MacBook like me... - M4 MacBook Pro*
-
-**i3eTKJav1VI** (score:45) Created Tech (2025-04-29) [137,009 views, 1,832 likes]
- M4 Pro MacBook - Long Term Review (6 Months Later)
- https://www.youtube.com/watch?v=i3eTKJav1VI
- *YouTube: M4 Pro MacBook - Long Term Review (6 Months Later)*
-
-**uPe9spXLfZY** (score:43) Created Tech (2025-07-07) [472,845 views, 5,082 likes]
- M4 MacBook Air vs M4 MacBook Pro - 4 Months Later
- https://www.youtube.com/watch?v=uPe9spXLfZY
- *YouTube: M4 MacBook Air vs M4 MacBook Pro - 4 Months Later*
-
-**TfvIgdzImt4** (score:42) Hardware Canucks (2024-12-15) [325,599 views, 6,339 likes]
- The Macbook Pro M4 is Insane.
- https://www.youtube.com/watch?v=TfvIgdzImt4
- *YouTube: The Macbook Pro M4 is Insane.*
-
-**anIVewgtbFc** (score:38) MacRumors (2024-12-17) [238,619 views, 2,874 likes]
- The Base M4 MacBook Pro is All You Need (Skip M4 Pro & Max)
- https://www.youtube.com/watch?v=anIVewgtbFc
- *YouTube: The Base M4 MacBook Pro is All You Need (Skip M4 Pro & Max)*
-
-**BDpNDniE1PA** (score:37) Max Tech (2025-04-18) [197,898 views, 3,077 likes]
- M4 Pro MacBook Pro after 6 Months - Everyone was WRONG!
- https://www.youtube.com/watch?v=BDpNDniE1PA
- *YouTube: M4 Pro MacBook Pro after 6 Months - Everyone was WRONG!*
-
-**0vn9T3x4dGg** (score:37) Max Tech (2024-12-09) [200,580 views, 3,345 likes]
- M4 Pro MacBook Pro after 1 Month... BEST Mac EVER!?
- https://www.youtube.com/watch?v=0vn9T3x4dGg
- *YouTube: M4 Pro MacBook Pro after 1 Month... BEST Mac EVER!?*
-
----
-**Sources:**
- ✅ Reddit: 3 threads
- ✅ X: 12 posts
- ✅ YouTube: 10 videos (3 with transcripts)
- ⏭️ HN: 0 stories found
- ⚡ Web: assistant will use WebSearch
diff --git a/docs/comparison-results/compact/cross-4-rap-improved.md b/docs/comparison-results/compact/cross-4-rap-improved.md
deleted file mode 100644
index 3962f0d..0000000
--- a/docs/comparison-results/compact/cross-4-rap-improved.md
+++ /dev/null
@@ -1,170 +0,0 @@
-## Research Results: best rap songs 2026
-
-**Date Range:** 2026-01-26 to 2026-02-25
-**Mode:** both
-**OpenAI Model:** gpt-5.2
-**xAI Model:** grok-4-1-fast
-
-### Reddit Threads
-
-**R1** (score:57) r/edranked (2026-02-01)
- Reddit Ranked: Hip Hop ‘26
- https://www.reddit.com/r/redranked/comments/1qszauu/reddit_ranked_hip_hop_26/
- *Explicitly about Hip Hop '26 submissions and ranking tracks (community-sourced 'best of 2026' style thread).*
-
-**R9** (score:56) r/hiphopheads (2026-02-24)
- Daily Discussion Thread 02/24/2026
- https://www.reddit.com/r/hiphopheads/comments/1rdjmgu/daily_discussion_thread_02242026/
- *General hip-hop discussion thread where users frequently share current favorite/best songs and recommendations (2026-dated).*
-
-**R4** (score:56) r/hiphopheads (2026-02-14)
- [DISCUSSION] Drake & PARTYNXTDOOR - $OME $EXY $ONGS 4 U (1 Year Later)
- https://www.reddit.com/r/hiphopheads/comments/1r4cbez/discussion_drake_partynxtdoord_ome_exy_ongs_4_u_1/
- *Track-focused discussion about songs still in rotation; relevant to identifying standout rap/R&B tracks around 2026.*
-
-**R21** (score:54) r/makemeaplaylist (2026-02-21)
- 2/22/2026 | Week 23 Playlist Competition
- https://www.reddit.com/r/makemeaplaylist/comments/1rb46m6/2222026_week_23_playlist_competition/
- *Playlist competition thread featuring an underground rap playlist winner; useful for surfacing 'best rap' picks around 2026.*
-
-**R16** (score:53) r/TeenageRapFans (2026-02-15)
- What's the best rap song ever made?
- https://www.reddit.com/r/TeenageRapFans/comments/1r5iub6/whats_the_best_rap_song_ever_made/
- *Direct 'best rap song' debate thread (highly aligned with best-rap-songs searching).*
-
-**R18** (score:51) r/playlists (2026-02-15)
- Rick 2026 : Pop Dance RnB Chart Hits Rock Indie HipHop Divas Rap Gym 80s 90s 00s 10s 20s Party Music
- https://www.reddit.com/r/playlists/comments/1r5iuz5/rick_2026_pop_dance_rnb_chart_hits_rock_indie/
- *2026-tagged playlist thread explicitly including HipHop/Rap; can be mined for top rap tracks in 2026 rotation.*
-
-**R5** (score:48) r/hiphopheads (2026-02-01)
- Pre-Show Grammy Winners
- https://www.reddit.com/r/hiphopheads/comments/1qtcbmo/preshow_grammy_winners/
- *Includes Best Rap Song / Best Rap Performance winners; strong signal for 'best rap songs' discourse in 2026 season.*
-
-**R8** (score:44) r/GoodAssSub (2026-02-02)
- Andre Toutman claims All The Love will be "song of the year"
- https://www.reddit.com/r/GoodAssSub/comments/1qty80z/andre_toutman_claims_all_the_love_will_be_song_of/
- *Direct 'song of the year' debate thread (rap/hip-hop community), aligned with 'best songs' discovery for 2026.*
-
-**R6** (score:43) r/hiphopheads (2026-02-02)
- Kendrick Lamar Wins 2nd Record of the Year Grammy With SZA for ‘Luther’
- https://www.reddit.com/r/hiphopheads/comments/1qtl5kr/kendrick_lamar_wins_2nd_record_of_the_year_grammy/
- *High-engagement thread about a major hip-hop adjacent song winning; useful for 'best songs' discussions around 2026.*
-
-**R15** (score:42) r/musicteenager (2026-02-04)
- My top 25 rap songs of all time
- https://www.reddit.com/r/musicteenager/comments/1qvz9qu/my_top_25_rap_songs_of_all_time/
- *User-made ranked list of rap songs; while not 2026-specific, it matches the 'best rap songs' core subject.*
-
-**R7** (score:41) r/KendrickLamar (2026-02-01)
- 68th Annual Grammy Awards [LIVE MEGATHREAD]
- https://www.reddit.com/r/KendrickLamar/comments/1qtczjl/68th_annual_grammy_awards_live_megathread/
- *Live discussion around Grammys 2026 including rap categories; good for discovering what Reddit called the best tracks.*
-
-### X Posts
-
-**X5** (score:86) @Zika_gfx (2026-02-25) [12likes, 5rt]
- Talking about meaningful rap songs...
-
-WORLD BEST LIE - NUNO ZIGI (2026)🥺❤ https://t.co/MDHRf3giYq...
- https://x.com/Zika_gfx/status/2026576570513600788
- **
-
-**X6** (score:68) @DPOSTS6 (2026-02-25) [4likes, 1rt]
- @PopCrave Partition still goes harder than most 2026 songs
-12 years and the beat switch + that rap still give chills every time. Queen never misses...
- https://x.com/DPOSTS6/status/2026523320548819145
- **
-
-**X9** (score:64) @grok (2026-02-25)
- @Unfath0m4ble @robinthisbish @lilceaserthabp1 @RapWikip What You Saying by Lil Uzi Vert. Dropped Dec 2025, hit #1 on Hot Rap Songs in Jan 2026 (his 3rd time), peaked #14 Hot 100. Super short track ~2 ...
- https://x.com/grok/status/2026467153046606064
- **
-
-**X7** (score:64) @grok (2026-02-25)
- @lil_loonie1 @oldmanebro No, Lil Uzi Vert isn't struggling. His "What You Saying" hit #1 on Hot Rap Songs in Jan 2026 (first in 3 years) and top 15 Hot 100. Insiders say a surprise album drop is comin...
- https://x.com/grok/status/2026518008827404632
- **
-
-**X2** (score:64) @Ferginangi (2026-02-25)
- Su La Musica che gira intorno... "Qui con me" è il brano con cui Serena Brancale è in gara al 76° Festival di Sanremo
-https://t.co/wTPeovzt6u
-#music #genre #song #songs #envywear #PleaseForgiveMe #mel...
- https://x.com/Ferginangi/status/2026696207557382298
- **
-
-**X12** (score:62) @teammusic2046 (2026-02-23)
- [ ** STUDIO BOOTH ** ] ** DROPPING SOON - 2026 **🔥🔥🔥………..[ ALBUM : LOVE VS LOYALTY - DROPPING SOON ! ]………🔥🔥🔥 - ** ALL SONGS & BEATS, PRODUCED & RAP BY ( SEAN BRYANT ) - https://t.co/LduwG5WmM7...
- https://x.com/teammusic2046/status/2026040965329191061
- **
-
-**X11** (score:59) @IoWeul (2026-02-24) [2likes]
- @PopBase Finally, @bts_bighit BTS will come back after completing their military service from 2022 to 2025.
-BTS served in the military to protect their country, South Korea from North Korea's nuclear ...
- https://x.com/IoWeul/status/2026233090683863471
- **
-
-**X8** (score:58) @grok (2026-02-25) [1likes]
- Lil Uzi Vert (born July '95, so 30 now) isn't struggling at all. Fresh off "What You Saying" hitting #1 on Hot Rap Songs in Jan 2026, top 15 Hot 100. New album rumored for surprise drop soon, Roc Nati...
- https://x.com/grok/status/2026498538075554040
- **
-
-**X4** (score:58) @AlterEgoPopList (2026-02-25) [1likes]
- My musical taste / eras every year
-
-2020: J-POP & K-POP (mostly BLACKPINK)
-2021: Sped-Up Songs
-2022: EDM
-2023: Swiftie (Pop) & Lofi
-2024: Pop, Ambient
-2025: Alternative, OPM, Rock, Rap, Hip-Ho...
- https://x.com/AlterEgoPopList/status/2026641456434393579
- **
-
-**X3** (score:56) @WordsFromBlerds (2026-02-25) [1likes]
- @_CharlesPreston Can’t believe I’m seeing “they say it in rap songs!” In 2026
-
-The same reductive, ancient, debunked arguments ad infinitum...
- https://x.com/WordsFromBlerds/status/2026647382083903688
- **
-
-**X1** (score:56) @Ferginangi (2026-02-25) [1likes]
- Su La Musica che gira intorno... "Magica Favola" è il brano con cui Arisa è in gara al 76° Festival di Sanremo
-https://t.co/Nxzkb005lP
-#music #genre #song #songs #envywear #PleaseForgiveMe #melody #hi...
- https://x.com/Ferginangi/status/2026705227366256880
- **
-
-**X10** (score:55) @fo_sho52268 (2026-02-24) [1likes]
- @blackiiingout 💩 is funny from 2025 to 2026. BiiCH is SCARED to drop that #TRASH he calls "rap songs" 🥴😩...
- https://x.com/fo_sho52268/status/2026335353364201566
- **
-
-### YouTube Videos
-
-**d1melQQVp6s** (score:71) DJ Noize (2026-02-14) [42,713 views, 761 likes]
- New Rap Songs 2026 Mix February | Trap Tape #127 | New Hip Hop 2026 Mixtape | DJ Noize
- https://www.youtube.com/watch?v=d1melQQVp6s
- Transcript: [music] This This is a DJ noise mixtape present. This is a DJ noise mixtape [music] presentation. DJ [music] >> Who the [ __ ] is you? Who the [ __ ] is you? Who the [ _...
- *YouTube: New Rap Songs 2026 Mix February | Trap Tape #127 | New Hip H*
-
-**-KQpPySP93I** (score:66) DJ Noize (2026-02-21) [12,955 views, 304 likes]
- New Hip Hop R&B Songs 2026 Mix February | Hot Right Now #153 | New Rap 2026 Playlist | DJ Noize
- https://www.youtube.com/watch?v=-KQpPySP93I
- Transcript: This This is a DJ noise tape present. This is a DJ noise mix. [music] I got Nikki on me. How we treat the like a bucket for rubbing. You [music] ain't no fella from my junction. You ain't pluck you cl...
- *YouTube: New Hip Hop R&B Songs 2026 Mix February | Hot Right Now #153*
-
-**ZUExyc50ZVU** (score:47) West Coast Finest (2026-01-29) [407,260 views, 4,229 likes]
- Lit Hip Hop Mix 2026🔥🔥🔥Tyga, Quavo, Iggy Azalea, Wiz Khalifa, Juicy J, 50 Cent
- https://www.youtube.com/watch?v=ZUExyc50ZVU
- Transcript: Yeah. >> Yeah. [music] Late nights, cold life, same story. >> City shine different. You walking with glory, right? >> Still drip falling when I step outside. Yeah. [music] >> B...
- *YouTube: Lit Hip Hop Mix 2026🔥🔥🔥Tyga, Quavo, Iggy Azalea, Wiz Khalifa*
-
----
-**Sources:**
- ✅ Reddit: 11 threads
- ✅ X: 12 posts
- ✅ YouTube: 3 videos (3 with transcripts)
- ⏭️ HN: 0 stories found
- ⚡ Web: assistant will use WebSearch
diff --git a/docs/comparison-results/compact/cross-4-rap.md b/docs/comparison-results/compact/cross-4-rap.md
deleted file mode 100644
index 3962f0d..0000000
--- a/docs/comparison-results/compact/cross-4-rap.md
+++ /dev/null
@@ -1,170 +0,0 @@
-## Research Results: best rap songs 2026
-
-**Date Range:** 2026-01-26 to 2026-02-25
-**Mode:** both
-**OpenAI Model:** gpt-5.2
-**xAI Model:** grok-4-1-fast
-
-### Reddit Threads
-
-**R1** (score:57) r/edranked (2026-02-01)
- Reddit Ranked: Hip Hop ‘26
- https://www.reddit.com/r/redranked/comments/1qszauu/reddit_ranked_hip_hop_26/
- *Explicitly about Hip Hop '26 submissions and ranking tracks (community-sourced 'best of 2026' style thread).*
-
-**R9** (score:56) r/hiphopheads (2026-02-24)
- Daily Discussion Thread 02/24/2026
- https://www.reddit.com/r/hiphopheads/comments/1rdjmgu/daily_discussion_thread_02242026/
- *General hip-hop discussion thread where users frequently share current favorite/best songs and recommendations (2026-dated).*
-
-**R4** (score:56) r/hiphopheads (2026-02-14)
- [DISCUSSION] Drake & PARTYNXTDOOR - $OME $EXY $ONGS 4 U (1 Year Later)
- https://www.reddit.com/r/hiphopheads/comments/1r4cbez/discussion_drake_partynxtdoord_ome_exy_ongs_4_u_1/
- *Track-focused discussion about songs still in rotation; relevant to identifying standout rap/R&B tracks around 2026.*
-
-**R21** (score:54) r/makemeaplaylist (2026-02-21)
- 2/22/2026 | Week 23 Playlist Competition
- https://www.reddit.com/r/makemeaplaylist/comments/1rb46m6/2222026_week_23_playlist_competition/
- *Playlist competition thread featuring an underground rap playlist winner; useful for surfacing 'best rap' picks around 2026.*
-
-**R16** (score:53) r/TeenageRapFans (2026-02-15)
- What's the best rap song ever made?
- https://www.reddit.com/r/TeenageRapFans/comments/1r5iub6/whats_the_best_rap_song_ever_made/
- *Direct 'best rap song' debate thread (highly aligned with best-rap-songs searching).*
-
-**R18** (score:51) r/playlists (2026-02-15)
- Rick 2026 : Pop Dance RnB Chart Hits Rock Indie HipHop Divas Rap Gym 80s 90s 00s 10s 20s Party Music
- https://www.reddit.com/r/playlists/comments/1r5iuz5/rick_2026_pop_dance_rnb_chart_hits_rock_indie/
- *2026-tagged playlist thread explicitly including HipHop/Rap; can be mined for top rap tracks in 2026 rotation.*
-
-**R5** (score:48) r/hiphopheads (2026-02-01)
- Pre-Show Grammy Winners
- https://www.reddit.com/r/hiphopheads/comments/1qtcbmo/preshow_grammy_winners/
- *Includes Best Rap Song / Best Rap Performance winners; strong signal for 'best rap songs' discourse in 2026 season.*
-
-**R8** (score:44) r/GoodAssSub (2026-02-02)
- Andre Toutman claims All The Love will be "song of the year"
- https://www.reddit.com/r/GoodAssSub/comments/1qty80z/andre_toutman_claims_all_the_love_will_be_song_of/
- *Direct 'song of the year' debate thread (rap/hip-hop community), aligned with 'best songs' discovery for 2026.*
-
-**R6** (score:43) r/hiphopheads (2026-02-02)
- Kendrick Lamar Wins 2nd Record of the Year Grammy With SZA for ‘Luther’
- https://www.reddit.com/r/hiphopheads/comments/1qtl5kr/kendrick_lamar_wins_2nd_record_of_the_year_grammy/
- *High-engagement thread about a major hip-hop adjacent song winning; useful for 'best songs' discussions around 2026.*
-
-**R15** (score:42) r/musicteenager (2026-02-04)
- My top 25 rap songs of all time
- https://www.reddit.com/r/musicteenager/comments/1qvz9qu/my_top_25_rap_songs_of_all_time/
- *User-made ranked list of rap songs; while not 2026-specific, it matches the 'best rap songs' core subject.*
-
-**R7** (score:41) r/KendrickLamar (2026-02-01)
- 68th Annual Grammy Awards [LIVE MEGATHREAD]
- https://www.reddit.com/r/KendrickLamar/comments/1qtczjl/68th_annual_grammy_awards_live_megathread/
- *Live discussion around Grammys 2026 including rap categories; good for discovering what Reddit called the best tracks.*
-
-### X Posts
-
-**X5** (score:86) @Zika_gfx (2026-02-25) [12likes, 5rt]
- Talking about meaningful rap songs...
-
-WORLD BEST LIE - NUNO ZIGI (2026)🥺❤ https://t.co/MDHRf3giYq...
- https://x.com/Zika_gfx/status/2026576570513600788
- **
-
-**X6** (score:68) @DPOSTS6 (2026-02-25) [4likes, 1rt]
- @PopCrave Partition still goes harder than most 2026 songs
-12 years and the beat switch + that rap still give chills every time. Queen never misses...
- https://x.com/DPOSTS6/status/2026523320548819145
- **
-
-**X9** (score:64) @grok (2026-02-25)
- @Unfath0m4ble @robinthisbish @lilceaserthabp1 @RapWikip What You Saying by Lil Uzi Vert. Dropped Dec 2025, hit #1 on Hot Rap Songs in Jan 2026 (his 3rd time), peaked #14 Hot 100. Super short track ~2 ...
- https://x.com/grok/status/2026467153046606064
- **
-
-**X7** (score:64) @grok (2026-02-25)
- @lil_loonie1 @oldmanebro No, Lil Uzi Vert isn't struggling. His "What You Saying" hit #1 on Hot Rap Songs in Jan 2026 (first in 3 years) and top 15 Hot 100. Insiders say a surprise album drop is comin...
- https://x.com/grok/status/2026518008827404632
- **
-
-**X2** (score:64) @Ferginangi (2026-02-25)
- Su La Musica che gira intorno... "Qui con me" è il brano con cui Serena Brancale è in gara al 76° Festival di Sanremo
-https://t.co/wTPeovzt6u
-#music #genre #song #songs #envywear #PleaseForgiveMe #mel...
- https://x.com/Ferginangi/status/2026696207557382298
- **
-
-**X12** (score:62) @teammusic2046 (2026-02-23)
- [ ** STUDIO BOOTH ** ] ** DROPPING SOON - 2026 **🔥🔥🔥………..[ ALBUM : LOVE VS LOYALTY - DROPPING SOON ! ]………🔥🔥🔥 - ** ALL SONGS & BEATS, PRODUCED & RAP BY ( SEAN BRYANT ) - https://t.co/LduwG5WmM7...
- https://x.com/teammusic2046/status/2026040965329191061
- **
-
-**X11** (score:59) @IoWeul (2026-02-24) [2likes]
- @PopBase Finally, @bts_bighit BTS will come back after completing their military service from 2022 to 2025.
-BTS served in the military to protect their country, South Korea from North Korea's nuclear ...
- https://x.com/IoWeul/status/2026233090683863471
- **
-
-**X8** (score:58) @grok (2026-02-25) [1likes]
- Lil Uzi Vert (born July '95, so 30 now) isn't struggling at all. Fresh off "What You Saying" hitting #1 on Hot Rap Songs in Jan 2026, top 15 Hot 100. New album rumored for surprise drop soon, Roc Nati...
- https://x.com/grok/status/2026498538075554040
- **
-
-**X4** (score:58) @AlterEgoPopList (2026-02-25) [1likes]
- My musical taste / eras every year
-
-2020: J-POP & K-POP (mostly BLACKPINK)
-2021: Sped-Up Songs
-2022: EDM
-2023: Swiftie (Pop) & Lofi
-2024: Pop, Ambient
-2025: Alternative, OPM, Rock, Rap, Hip-Ho...
- https://x.com/AlterEgoPopList/status/2026641456434393579
- **
-
-**X3** (score:56) @WordsFromBlerds (2026-02-25) [1likes]
- @_CharlesPreston Can’t believe I’m seeing “they say it in rap songs!” In 2026
-
-The same reductive, ancient, debunked arguments ad infinitum...
- https://x.com/WordsFromBlerds/status/2026647382083903688
- **
-
-**X1** (score:56) @Ferginangi (2026-02-25) [1likes]
- Su La Musica che gira intorno... "Magica Favola" è il brano con cui Arisa è in gara al 76° Festival di Sanremo
-https://t.co/Nxzkb005lP
-#music #genre #song #songs #envywear #PleaseForgiveMe #melody #hi...
- https://x.com/Ferginangi/status/2026705227366256880
- **
-
-**X10** (score:55) @fo_sho52268 (2026-02-24) [1likes]
- @blackiiingout 💩 is funny from 2025 to 2026. BiiCH is SCARED to drop that #TRASH he calls "rap songs" 🥴😩...
- https://x.com/fo_sho52268/status/2026335353364201566
- **
-
-### YouTube Videos
-
-**d1melQQVp6s** (score:71) DJ Noize (2026-02-14) [42,713 views, 761 likes]
- New Rap Songs 2026 Mix February | Trap Tape #127 | New Hip Hop 2026 Mixtape | DJ Noize
- https://www.youtube.com/watch?v=d1melQQVp6s
- Transcript: [music] This This is a DJ noise mixtape present. This is a DJ noise mixtape [music] presentation. DJ [music] >> Who the [ __ ] is you? Who the [ __ ] is you? Who the [ _...
- *YouTube: New Rap Songs 2026 Mix February | Trap Tape #127 | New Hip H*
-
-**-KQpPySP93I** (score:66) DJ Noize (2026-02-21) [12,955 views, 304 likes]
- New Hip Hop R&B Songs 2026 Mix February | Hot Right Now #153 | New Rap 2026 Playlist | DJ Noize
- https://www.youtube.com/watch?v=-KQpPySP93I
- Transcript: This This is a DJ noise tape present. This is a DJ noise mix. [music] I got Nikki on me. How we treat the like a bucket for rubbing. You [music] ain't no fella from my junction. You ain't pluck you cl...
- *YouTube: New Hip Hop R&B Songs 2026 Mix February | Hot Right Now #153*
-
-**ZUExyc50ZVU** (score:47) West Coast Finest (2026-01-29) [407,260 views, 4,229 likes]
- Lit Hip Hop Mix 2026🔥🔥🔥Tyga, Quavo, Iggy Azalea, Wiz Khalifa, Juicy J, 50 Cent
- https://www.youtube.com/watch?v=ZUExyc50ZVU
- Transcript: Yeah. >> Yeah. [music] Late nights, cold life, same story. >> City shine different. You walking with glory, right? >> Still drip falling when I step outside. Yeah. [music] >> B...
- *YouTube: Lit Hip Hop Mix 2026🔥🔥🔥Tyga, Quavo, Iggy Azalea, Wiz Khalifa*
-
----
-**Sources:**
- ✅ Reddit: 11 threads
- ✅ X: 12 posts
- ✅ YouTube: 3 videos (3 with transcripts)
- ⏭️ HN: 0 stories found
- ⚡ Web: assistant will use WebSearch
diff --git a/docs/comparison-results/compact/cross-5-react-svelte-improved.md b/docs/comparison-results/compact/cross-5-react-svelte-improved.md
deleted file mode 100644
index 43874ab..0000000
--- a/docs/comparison-results/compact/cross-5-react-svelte-improved.md
+++ /dev/null
@@ -1,172 +0,0 @@
-## Research Results: React vs Svelte 2026
-
-**Date Range:** 2026-01-26 to 2026-02-25
-**Mode:** both
-**OpenAI Model:** gpt-5.2
-**xAI Model:** grok-4-1-fast
-
-### Reddit Threads
-
-**R2** (score:67) r/eact (2026-02-10)
- What do you guys think about comparison between React vs Svelte?
- https://www.reddit.com/r/react/comments/1r1fku5/what_do_you_guys_think_about_comparison_between/
- *Explicit React vs Svelte comparison thread focusing on hooks/useEffect vs Svelte runes mental model.*
-
-**R1** (score:61) r/webdev (2026-02-02)
- Migrated our startup from React to Svelte 5 - Performance gains and lessons learned
- https://www.reddit.com/r/webdev/comments/1qu4dek/migrated_our_startup_from_react_to_svelte_5/
- *Direct React -> Svelte 5 migration discussion with practical comparison (DX, performance, ecosystem tradeoffs).*
-
-**R6** (score:56) r/sveltejs (2026-02-02)
- I am building a content-heavy, bilingual government portal with listings, profiles, documents, filters, and forms. Came from .NET & React. Wondering if svelte might be a good option for a project like this?
- https://www.reddit.com/r/sveltejs/comments/1qtua45/i_am_building_a_contentheavy_bilingual_government/
- *Evaluates Svelte vs React suitability for a real project with constraints (accessibility, SEO, forms).*
-
-**R4** (score:54) r/sveltejs (2026-01-28)
- Is Svelte easier than React?
- https://www.reddit.com/r/sveltejs/comments/1qpdzx5/is_svelte_easier_than_react/
- *Direct comparison thread about perceived simplicity of Svelte vs React (recent 2026 discussion).*
-
-**R8** (score:52) r/sveltejs (2026-01-31)
- When should i start learning Svelte ?
- https://www.reddit.com/r/sveltejs/comments/1qr8xsm/when_should_i_start_learning_svelte/
- *React vs Svelte learning/adoption discussion, including arguments about replacing React for performance issues.*
-
-**R9** (score:49) r/sveltejs (2026-01-30)
- How is going svelte?
- https://www.reddit.com/r/sveltejs/comments/1qrdhba/how_is_going_svelte/
- *General state-of-Svelte discussion with explicit mentions comparing to React and job-market pragmatism.*
-
-### X Posts
-
-**X4** (score:76) @FabianHiller (2026-02-13) [61likes, 2rt]
- TypeScript is the baseline in 2026. But most forms still fight type drift between API, schema & UI.
-
-@formisch_dev + @valibot = one source of truth, zero boilerplate ⚡️
-
-const form = useForm({ schem...
- https://x.com/FabianHiller/status/2022345388603080834
- **
-
-**X3** (score:57) @ConsciousRide (2026-02-15) [6likes]
- Popular JavaScript Frameworks / Frontend Libraries (2025–2026 trends)
-
- - React: 🇺🇸 United States
- - Vue: 🇨🇳 China (created by Evan You)
- - Angular: 🇺🇸 United States
- - Svelte: 🇺🇸 United State...
- https://x.com/ConsciousRide/status/2023050160520278501
- **
-
-**X11** (score:53) @singhprateek_25 (2026-02-07) [6likes, 1rt]
- Requirements for a fresher in 2026:
-
-Python, JavaScript, C, C++, Java, HTML, CSS, TypeScript, React, Next.js, Vue, Angular, Svelte, Node.js, Express, Django, Flask, FastAPI, Spring Boot, PHP, Laravel,...
- https://x.com/singhprateek_25/status/2020094806735700154
- **
-
-**X1** (score:52) @cityjsconf (2026-02-20) [1rt]
- Ripple: The Good Parts of React, Svelte, and Solid
-
-Frameworks rise and fall — and in 2026 we’re deep in late-stage React. What’s next? Join @erikras as he introduces Ripple, a TypeScript-first UI fra...
- https://x.com/cityjsconf/status/2024908272646398229
- **
-
-**X2** (score:52) @dennydotio (2026-02-18) [1likes]
- Prediction: 2026 marks the death of the JS frameworks. React, Svelte, Astro etc... only exist because humans need organized code to stay sane.
-
-Once we stop reading the source code, we stop needing ...
- https://x.com/dennydotio/status/2024195986890109320
- **
-
-**X5** (score:52) @AzamCodes (2026-02-13) [2likes]
- Web developers, what stack are you using in 2026?
-React? Vue? Svelte? Vanilla? Curious what’s actually winning....
- https://x.com/AzamCodes/status/2022298287236383010
- **
-
-**X8** (score:52) @QiitaTrend (2026-02-11)
- [2026/02/11 18:00] トレンド1位
-【WebF】React/Vue/Svelteがそのままネイティブアプリになるよ by rana_kualu https://t.co/GiAnTHpOBB...
- https://x.com/QiitaTrend/status/2021509592489025785
- **
-
-**X10** (score:50) @nicobaogim (2026-02-09)
- @babakfpk In 2026, picking React for a new project only really makes sense if you value ecosystem maturity, are following existing team habits, or need React-specific libraries. Same for Vue. Otherwis...
- https://x.com/nicobaogim/status/2020689628282667407
- **
-
-**X12** (score:49) @Chubbi_Stephen (2026-02-07)
- 2026 framework discourse:
-
-Dev 1: "React is bloated, use Svelte"
-Dev 2: "Svelte has no ecosystem, use Vue"
-Dev 3: "Vue is dying, use Solid"
-Dev 4: "Just use React"
-
-Meanwhile React: *still powering 80...
- https://x.com/Chubbi_Stephen/status/2020087839648800853
- **
-
-### YouTube Videos
-
-**MnpuK0MK4yo** (score:63) Beyond Fireship (2023-06-30) [697,142 views, 25,490 likes]
- React VS Svelte...10 Examples
- https://www.youtube.com/watch?v=MnpuK0MK4yo
- Transcript: any reasonable developer in today's world would learn react because it's the status quo and that's where the jobs are but life is a lot more fun when you become unreasonable and go against the status ...
- *YouTube: React VS Svelte...10 Examples*
-
-**qwDp5pZA_TA** (score:49) Code Hub (2026-02-08) [646 views, 31 likes]
- The Front-end Frameworks Guide 2026: React vs Angular vs Vue vs Svelt vs Solid vs Astro
- https://www.youtube.com/watch?v=qwDp5pZA_TA
- *YouTube: The Front-end Frameworks Guide 2026: React vs Angular vs Vue*
-
-**_vuVy21l2bU** (score:46) Code Hub (2025-10-30) [6,454 views, 204 likes]
- React vs Svelte: The Brutal Honest Comparison EVER
- https://www.youtube.com/watch?v=_vuVy21l2bU
- *YouTube: React vs Svelte: The Brutal Honest Comparison EVER*
-
-**41HXdxGekZ0** (score:45) Paperclick (2025-10-04) [287 views, 3 likes]
- Vue vs React vs Svelte (2026) - Which One Is BEST?
- https://www.youtube.com/watch?v=41HXdxGekZ0
- *YouTube: Vue vs React vs Svelte (2026) - Which One Is BEST?*
-
-**1BCsdaeYv0A** (score:42) Daniel | Tech & Data (2023-11-26) [33,905 views, 0 likes]
- Svelte vs React in 2025 - Make the RIGHT Choice (Difference Explained)
- https://www.youtube.com/watch?v=1BCsdaeYv0A
- *YouTube: Svelte vs React in 2025 - Make the RIGHT Choice (Difference *
-
-**yl0YWA2K2B0** (score:40) Fireship (2025-10-17) [699,597 views, 22,894 likes]
- React wants to win you back…
- https://www.youtube.com/watch?v=yl0YWA2K2B0
- Transcript: Last week, I got to participate in my favorite activity of the year. No, it wasn't watching all the overleveraged crypto bros learn what a margin call is. It was hate watching React comp as an anti-re...
- *YouTube: React wants to win you back…*
-
-**aYyZUDFZTrM** (score:40) Fireship (2024-10-24) [547,836 views, 24,456 likes]
- JavaScript framework reinvents itself… Did "runes" just ruin Svelte?
- https://www.youtube.com/watch?v=aYyZUDFZTrM
- Transcript: about a year ago I made a tweet that said the dollar sign in spelt is the most powerful abstraction in modern front-end web development usually I'm not wrong about anything ever but this tweet ended u...
- *YouTube: JavaScript framework reinvents itself… Did "runes" just ruin*
-
-**fn_uSZW5psM** (score:31) CodeSource (2025-05-14) [57,782 views, 2,120 likes]
- The Untold Story of Svelte
- https://www.youtube.com/watch?v=fn_uSZW5psM
- *YouTube: The Untold Story of Svelte*
-
-**IpJh0VEzMRo** (score:29) Ben Davis (2025-10-12) [24,635 views, 1,043 likes]
- I Was Wrong About Svelte...
- https://www.youtube.com/watch?v=IpJh0VEzMRo
- *YouTube: I Was Wrong About Svelte...*
-
-**1cGtYEXGm8c** (score:28) Ben Davis (2026-01-06) [16,899 views, 793 likes]
- This is THE Framework You Should be Using in 2026
- https://www.youtube.com/watch?v=1cGtYEXGm8c
- *YouTube: This is THE Framework You Should be Using in 2026*
-
----
-**Sources:**
- ✅ Reddit: 6 threads
- ✅ X: 9 posts
- ✅ YouTube: 10 videos (3 with transcripts)
- ⏭️ HN: 0 stories found
- ⚡ Web: assistant will use WebSearch
diff --git a/docs/comparison-results/compact/cross-5-react-svelte.md b/docs/comparison-results/compact/cross-5-react-svelte.md
deleted file mode 100644
index 43874ab..0000000
--- a/docs/comparison-results/compact/cross-5-react-svelte.md
+++ /dev/null
@@ -1,172 +0,0 @@
-## Research Results: React vs Svelte 2026
-
-**Date Range:** 2026-01-26 to 2026-02-25
-**Mode:** both
-**OpenAI Model:** gpt-5.2
-**xAI Model:** grok-4-1-fast
-
-### Reddit Threads
-
-**R2** (score:67) r/eact (2026-02-10)
- What do you guys think about comparison between React vs Svelte?
- https://www.reddit.com/r/react/comments/1r1fku5/what_do_you_guys_think_about_comparison_between/
- *Explicit React vs Svelte comparison thread focusing on hooks/useEffect vs Svelte runes mental model.*
-
-**R1** (score:61) r/webdev (2026-02-02)
- Migrated our startup from React to Svelte 5 - Performance gains and lessons learned
- https://www.reddit.com/r/webdev/comments/1qu4dek/migrated_our_startup_from_react_to_svelte_5/
- *Direct React -> Svelte 5 migration discussion with practical comparison (DX, performance, ecosystem tradeoffs).*
-
-**R6** (score:56) r/sveltejs (2026-02-02)
- I am building a content-heavy, bilingual government portal with listings, profiles, documents, filters, and forms. Came from .NET & React. Wondering if svelte might be a good option for a project like this?
- https://www.reddit.com/r/sveltejs/comments/1qtua45/i_am_building_a_contentheavy_bilingual_government/
- *Evaluates Svelte vs React suitability for a real project with constraints (accessibility, SEO, forms).*
-
-**R4** (score:54) r/sveltejs (2026-01-28)
- Is Svelte easier than React?
- https://www.reddit.com/r/sveltejs/comments/1qpdzx5/is_svelte_easier_than_react/
- *Direct comparison thread about perceived simplicity of Svelte vs React (recent 2026 discussion).*
-
-**R8** (score:52) r/sveltejs (2026-01-31)
- When should i start learning Svelte ?
- https://www.reddit.com/r/sveltejs/comments/1qr8xsm/when_should_i_start_learning_svelte/
- *React vs Svelte learning/adoption discussion, including arguments about replacing React for performance issues.*
-
-**R9** (score:49) r/sveltejs (2026-01-30)
- How is going svelte?
- https://www.reddit.com/r/sveltejs/comments/1qrdhba/how_is_going_svelte/
- *General state-of-Svelte discussion with explicit mentions comparing to React and job-market pragmatism.*
-
-### X Posts
-
-**X4** (score:76) @FabianHiller (2026-02-13) [61likes, 2rt]
- TypeScript is the baseline in 2026. But most forms still fight type drift between API, schema & UI.
-
-@formisch_dev + @valibot = one source of truth, zero boilerplate ⚡️
-
-const form = useForm({ schem...
- https://x.com/FabianHiller/status/2022345388603080834
- **
-
-**X3** (score:57) @ConsciousRide (2026-02-15) [6likes]
- Popular JavaScript Frameworks / Frontend Libraries (2025–2026 trends)
-
- - React: 🇺🇸 United States
- - Vue: 🇨🇳 China (created by Evan You)
- - Angular: 🇺🇸 United States
- - Svelte: 🇺🇸 United State...
- https://x.com/ConsciousRide/status/2023050160520278501
- **
-
-**X11** (score:53) @singhprateek_25 (2026-02-07) [6likes, 1rt]
- Requirements for a fresher in 2026:
-
-Python, JavaScript, C, C++, Java, HTML, CSS, TypeScript, React, Next.js, Vue, Angular, Svelte, Node.js, Express, Django, Flask, FastAPI, Spring Boot, PHP, Laravel,...
- https://x.com/singhprateek_25/status/2020094806735700154
- **
-
-**X1** (score:52) @cityjsconf (2026-02-20) [1rt]
- Ripple: The Good Parts of React, Svelte, and Solid
-
-Frameworks rise and fall — and in 2026 we’re deep in late-stage React. What’s next? Join @erikras as he introduces Ripple, a TypeScript-first UI fra...
- https://x.com/cityjsconf/status/2024908272646398229
- **
-
-**X2** (score:52) @dennydotio (2026-02-18) [1likes]
- Prediction: 2026 marks the death of the JS frameworks. React, Svelte, Astro etc... only exist because humans need organized code to stay sane.
-
-Once we stop reading the source code, we stop needing ...
- https://x.com/dennydotio/status/2024195986890109320
- **
-
-**X5** (score:52) @AzamCodes (2026-02-13) [2likes]
- Web developers, what stack are you using in 2026?
-React? Vue? Svelte? Vanilla? Curious what’s actually winning....
- https://x.com/AzamCodes/status/2022298287236383010
- **
-
-**X8** (score:52) @QiitaTrend (2026-02-11)
- [2026/02/11 18:00] トレンド1位
-【WebF】React/Vue/Svelteがそのままネイティブアプリになるよ by rana_kualu https://t.co/GiAnTHpOBB...
- https://x.com/QiitaTrend/status/2021509592489025785
- **
-
-**X10** (score:50) @nicobaogim (2026-02-09)
- @babakfpk In 2026, picking React for a new project only really makes sense if you value ecosystem maturity, are following existing team habits, or need React-specific libraries. Same for Vue. Otherwis...
- https://x.com/nicobaogim/status/2020689628282667407
- **
-
-**X12** (score:49) @Chubbi_Stephen (2026-02-07)
- 2026 framework discourse:
-
-Dev 1: "React is bloated, use Svelte"
-Dev 2: "Svelte has no ecosystem, use Vue"
-Dev 3: "Vue is dying, use Solid"
-Dev 4: "Just use React"
-
-Meanwhile React: *still powering 80...
- https://x.com/Chubbi_Stephen/status/2020087839648800853
- **
-
-### YouTube Videos
-
-**MnpuK0MK4yo** (score:63) Beyond Fireship (2023-06-30) [697,142 views, 25,490 likes]
- React VS Svelte...10 Examples
- https://www.youtube.com/watch?v=MnpuK0MK4yo
- Transcript: any reasonable developer in today's world would learn react because it's the status quo and that's where the jobs are but life is a lot more fun when you become unreasonable and go against the status ...
- *YouTube: React VS Svelte...10 Examples*
-
-**qwDp5pZA_TA** (score:49) Code Hub (2026-02-08) [646 views, 31 likes]
- The Front-end Frameworks Guide 2026: React vs Angular vs Vue vs Svelt vs Solid vs Astro
- https://www.youtube.com/watch?v=qwDp5pZA_TA
- *YouTube: The Front-end Frameworks Guide 2026: React vs Angular vs Vue*
-
-**_vuVy21l2bU** (score:46) Code Hub (2025-10-30) [6,454 views, 204 likes]
- React vs Svelte: The Brutal Honest Comparison EVER
- https://www.youtube.com/watch?v=_vuVy21l2bU
- *YouTube: React vs Svelte: The Brutal Honest Comparison EVER*
-
-**41HXdxGekZ0** (score:45) Paperclick (2025-10-04) [287 views, 3 likes]
- Vue vs React vs Svelte (2026) - Which One Is BEST?
- https://www.youtube.com/watch?v=41HXdxGekZ0
- *YouTube: Vue vs React vs Svelte (2026) - Which One Is BEST?*
-
-**1BCsdaeYv0A** (score:42) Daniel | Tech & Data (2023-11-26) [33,905 views, 0 likes]
- Svelte vs React in 2025 - Make the RIGHT Choice (Difference Explained)
- https://www.youtube.com/watch?v=1BCsdaeYv0A
- *YouTube: Svelte vs React in 2025 - Make the RIGHT Choice (Difference *
-
-**yl0YWA2K2B0** (score:40) Fireship (2025-10-17) [699,597 views, 22,894 likes]
- React wants to win you back…
- https://www.youtube.com/watch?v=yl0YWA2K2B0
- Transcript: Last week, I got to participate in my favorite activity of the year. No, it wasn't watching all the overleveraged crypto bros learn what a margin call is. It was hate watching React comp as an anti-re...
- *YouTube: React wants to win you back…*
-
-**aYyZUDFZTrM** (score:40) Fireship (2024-10-24) [547,836 views, 24,456 likes]
- JavaScript framework reinvents itself… Did "runes" just ruin Svelte?
- https://www.youtube.com/watch?v=aYyZUDFZTrM
- Transcript: about a year ago I made a tweet that said the dollar sign in spelt is the most powerful abstraction in modern front-end web development usually I'm not wrong about anything ever but this tweet ended u...
- *YouTube: JavaScript framework reinvents itself… Did "runes" just ruin*
-
-**fn_uSZW5psM** (score:31) CodeSource (2025-05-14) [57,782 views, 2,120 likes]
- The Untold Story of Svelte
- https://www.youtube.com/watch?v=fn_uSZW5psM
- *YouTube: The Untold Story of Svelte*
-
-**IpJh0VEzMRo** (score:29) Ben Davis (2025-10-12) [24,635 views, 1,043 likes]
- I Was Wrong About Svelte...
- https://www.youtube.com/watch?v=IpJh0VEzMRo
- *YouTube: I Was Wrong About Svelte...*
-
-**1cGtYEXGm8c** (score:28) Ben Davis (2026-01-06) [16,899 views, 793 likes]
- This is THE Framework You Should be Using in 2026
- https://www.youtube.com/watch?v=1cGtYEXGm8c
- *YouTube: This is THE Framework You Should be Using in 2026*
-
----
-**Sources:**
- ✅ Reddit: 6 threads
- ✅ X: 9 posts
- ✅ YouTube: 10 videos (3 with transcripts)
- ⏭️ HN: 0 stories found
- ⚡ Web: assistant will use WebSearch
diff --git a/docs/comparison-results/compact/hn-1-claude-code.md b/docs/comparison-results/compact/hn-1-claude-code.md
deleted file mode 100644
index 2d05db3..0000000
--- a/docs/comparison-results/compact/hn-1-claude-code.md
+++ /dev/null
@@ -1,250 +0,0 @@
-## Research Results: Claude Code skills and MCP servers
-
-**Date Range:** 2026-01-26 to 2026-02-25
-**Mode:** both
-**OpenAI Model:** gpt-5.2
-**xAI Model:** grok-4-1-fast
-
-### Reddit Threads
-
-**R1** (score:0) r/ClaudeAI (2025-06-17) [date:low]
- Claude code and mcp servers
- https://www.reddit.com/r/ClaudeAI/comments/1ldm75a/claude_code_and_mcp_servers/
- *Directly about Claude Code failing to load/use MCP servers; troubleshooting discussion.*
-
-**R2** (score:0) r/ClaudeCode (2025-10-23) [date:low]
- Claude Skills: is it a big deal?
- https://www.reddit.com/r/ClaudeCode/comments/1odtykp/claude_skills_is_it_a_big_deal/
- *Focused on the Claude (Claude Code) “Skills” feature; compares to AGENTS.md/slash commands/MCPs.*
-
-**R3** (score:0) r/ClaudeAI (2025-10-25) [date:low]
- I spent way too long cataloguing Claude Code tools. Here’s everything I found (with actual links)
- https://www.reddit.com/r/ClaudeAI/comments/1ofltdr/i_spent_way_too_long_cataloguing_claude_code/
- *Tooling ecosystem post; explicitly mentions installing many plugins and MCP servers for Claude Code.*
-
-### X Posts
-
-**X5** (score:86) @zeeg (2026-02-25) [4likes, 1rt]
- @adamwathan Codex has been crushing it for me with implicit skill usage to the point I was shocked
-
-Claude Code I resorted to explicit mentions and I’ve been pleasantly surprised by how well Codex ha...
- https://x.com/zeeg/status/2026745680195367091
- **
-
-**X3** (score:80) @ihtesham2005 (2026-02-25) [6likes]
- 🚨 Anthropic just open-sourced the exact Skills library their own engineers use internally.
-
-Stop building Claude workflows from scratch.
-
-These are plug-and-play components that work across Claude Cod...
- https://x.com/ihtesham2005/status/2026752089473314975
- **
-
-**X8** (score:74) @DevTenta (2026-02-25) [4likes]
- Day 7 building my first B2C app
-
-Been setting everything up this week. Manually built 5 agents to help me in the early stages and set up the Claude Code skills i'll need for the build.
-
-Also been test...
- https://x.com/DevTenta/status/2026742028730527786
- **
-
-**X7** (score:64) @JorgeJaramillo (2026-02-25)
- @Jompiras Claude code + skills. Muy breve....
- https://x.com/JorgeJaramillo/status/2026743135435362649
- **
-
-**X4** (score:64) @arpan7sarkar (2026-02-25)
- @tom_doerr 135 agents, 35 curated skills, 121 plugins and 6 MCP configs all in one Claude Code toolkit is absolutely insane value 🤯 this is the kind of community effort that makes an AI tool go from g...
- https://x.com/arpan7sarkar/status/2026746479369662551
- **
-
-**X6** (score:64) @arpan7sarkar (2026-02-25)
- @tom_doerr A curated collection of Claude Skills organized by category is exactly what power users have been waiting for 🤩 document skills, code tools, data analysis all in one repo. this is the Claud...
- https://x.com/arpan7sarkar/status/2026745559986614335
- **
-
-**X2** (score:64) @yasuky (2026-02-25)
- Claude CodeのSkillsを作成例から徹底理解する https://t.co/vZTseio2Ii...
- https://x.com/yasuky/status/2026754773786226708
- **
-
-**X12** (score:64) @zhuoyuan45514 (2026-02-25)
- Day 52 (01/21/2026) (Rest Day)
-
-> Go to New York + watch Broadway Show
-
-> Catch up on claude code updates on way back (2hrs)
- - Learn difference between skills + MCP, and etc
-
-> Neetcode (1h...
- https://x.com/zhuoyuan45514/status/2026733326631923776
- **
-
-**X1** (score:64) @matgoldsborough (2026-02-25)
- How: I told Claude Code "build me a CRM." Our builder skill generated schemas, skills, server, seed data. I pointed Claude Desktop at it. Contacts, lead scoring, pipeline reviews. All from conversatio...
- https://x.com/matgoldsborough/status/2026755742737510749
- **
-
-**X9** (score:64) @0x_Kapoor (2026-02-25)
- I can build another open claw, or claude bot, or even a messaging app in a nice prompt and although having engineering skills, I can push the code on the public repo and secure it even completely and ...
- https://x.com/0x_Kapoor/status/2026738663321907214
- **
-
-**X10** (score:56) @ghumare64 (2026-02-25) [1likes]
- 1. I use https://t.co/NtFYJEiFs9 inspired by @bcherny
-2. I use https://t.co/EwnmyWNFqn to sync skills and scan skills from the marketplace
-3. I chat with claude code 4-5 times and once I am satisifed...
- https://x.com/ghumare64/status/2026736176695246966
- **
-
-**X11** (score:56) @hamen (2026-02-25) [1likes]
- The 'vibe coding' revolution is forcing a reckoning.
-
-Developers with 15+ years experience are either embracing AI to expand their reach or watching their expertise become as valuable as COBOL skills....
- https://x.com/hamen/status/2026734075243790396
- **
-
-### YouTube Videos
-
-**X8afcX2s2Mo** (score:74) Grace Leung (2026-02-21) [47,474 views, 1,900 likes]
- Claude Skills: Build Your First AI Marketing Team in 16 Minutes (Claude Code)
- https://www.youtube.com/watch?v=X8afcX2s2Mo
- *YouTube video about claude code skills and mcp servers*
-
-**0J2_YGuNrDo** (score:61) Grace Leung (2025-12-16) [155,806 views, 4,760 likes]
- Claude Code just Built me an AI Agent Team (Claude Code + Skills + MCP)
- https://www.youtube.com/watch?v=0J2_YGuNrDo
- Transcript: I use clot more than any other AI but even I dismiss Clark as just for developers and I was wrong after using myself I am completely so and this is too powerful to ignore if you want to use AI to buil...
- *YouTube video about claude code skills and mcp servers*
-
-**fOxC44g8vig** (score:58) Anthropic (2025-11-26) [129,573 views, 2,991 likes]
- Claude Agent Skills Explained
- https://www.youtube.com/watch?v=fOxC44g8vig
- Transcript: Hi, my name is Otto and in this video we're going to discuss agent skills. Agents today are pretty intelligent, but they don't always have the domain expertise you need for real work and skills help s...
- *YouTube video about claude code skills and mcp servers*
-
-**Gqh_KdHP1Xk** (score:56) Robin Ebers (2025-08-21) [90,281 views, 2,719 likes]
- 8 MCP Servers That Make Claude Code 10x Better
- https://www.youtube.com/watch?v=Gqh_KdHP1Xk
- Transcript: Over 90% of all MCP servers are complete overhyped garbage. But some of them are not only worth it, they can be complete game changers. And I do not say this lightly. So after testing over 100 of them...
- *YouTube video about claude code skills and mcp servers*
-
-**421T2iWTQio** (score:51) Kenny Liao (2025-10-24) [50,477 views, 1,010 likes]
- The Only Claude Skills Guide You Need (Beginner to Expert)
- https://www.youtube.com/watch?v=421T2iWTQio
- *YouTube video about claude code skills and mcp servers*
-
-**l7qVtHpctic** (score:47) Kenny Liao (2026-01-17) [21,765 views, 728 likes]
- Claude Code's MCP Problem Just Got Fixed
- https://www.youtube.com/watch?v=l7qVtHpctic
- *YouTube video about claude code skills and mcp servers*
-
-**M5CsRj6zSCA** (score:41) Eric Tech (2025-12-12) [11,287 views, 266 likes]
- 5 MCP Servers That Make Claude Code 10x More Powerful (Full-Stack App Build)
- https://www.youtube.com/watch?v=M5CsRj6zSCA
- *YouTube video about claude code skills and mcp servers*
-
-**qthyl0GCpDo** (score:35) Postman (2025-11-20) [7,045 views, 105 likes]
- Claude Skills vs MCP: What’s the Difference and When to Use Each?
- https://www.youtube.com/watch?v=qthyl0GCpDo
- *YouTube video about claude code skills and mcp servers*
-
-**ZroGqu7GyXM** (score:34) Solo Swift Crafter (2025-10-27) [4,885 views, 98 likes]
- Claude Code Skills vs MCP vs Sub Agents: What Works for Solo Devs?
- https://www.youtube.com/watch?v=ZroGqu7GyXM
- *YouTube video about claude code skills and mcp servers*
-
-**jzf7DQa2CAc** (score:31) Matt Kuda (2026-01-19) [2,547 views, 87 likes]
- How I Use Claude Code With Skills, MCP, Agents & Plugins
- https://www.youtube.com/watch?v=jzf7DQa2CAc
- *YouTube video about claude code skills and mcp servers*
-
-### Hacker News Stories
-
-**HN1** (score:90) hn/cosmoblk (2026-02-21) [10pts, 2cmt]
- Show HN: I built a 55K-word email marketing knowledge base and Claude Code skill
- https://news.ycombinator.com/item?id=47096335
- *HN story about Show HN: I built a 55K-word email marketing knowledge base a*
- Insights:
- - So it's an AI spambot?
-
-**HN4** (score:74) hn/superamped (2026-02-25) [3pts, 1cmt]
- Show HN: AI Marketing Skills for Claude Code
- https://news.ycombinator.com/item?id=47154377
- *HN story about Show HN: AI Marketing Skills for Claude Code*
-
-**HN12** (score:70) hn/heycesr (2026-02-18) [4pts, 2cmt]
- Show HN: Poncho, a general agent harness built for the web
- https://news.ycombinator.com/item?id=47061949
- *HN story about Show HN: Poncho, a general agent harness built for the web*
- Insights:
- - I have been trying it and it's very cool!
-
-**HN11** (score:69) hn/gjkim042 (2026-02-24) [1pts, 4cmt]
- Show HN: Axon – A Kubernetes-native framework for AI coding agents
- https://news.ycombinator.com/item?id=47137491
- *HN story about Show HN: Axon – A Kubernetes-native framework for AI coding *
-
-**HN14** (score:65) hn/alternateman (2026-02-17) [3pts, 2cmt]
- Show HN: Turn Claude Code or Codex into proactive, autonomous 24/7 AI agents
- https://news.ycombinator.com/item?id=47054100
- *HN story about Show HN: Turn Claude Code or Codex into proactive, autonomou*
-
-**HN2** (score:64) hn/hauschildt (2026-02-19) [3pts, 0cmt]
- Show HN: Agent skills to build photo, video and design editors on the web
- https://news.ycombinator.com/item?id=47073035
- *HN story about Show HN: Agent skills to build photo, video and design edito*
-
-**HN8** (score:57) hn/barefootsanders (2026-02-25) [1pts, 0cmt]
- Show HN: Upjack – Declarative framework for building apps over MCP
- https://news.ycombinator.com/item?id=47157331
- *HN story about Show HN: Upjack – Declarative framework for building apps ov*
-
-**HN7** (score:56) hn/san-techie21 (2026-02-16) [1pts, 1cmt]
- Show HN: Gulama – Security-first open-source AI agent (OpenClaw alternative)
- https://news.ycombinator.com/item?id=47031982
- *HN story about Show HN: Gulama – Security-first open-source AI agent (OpenC*
-
-**HN15** (score:48) hn/martin-hall (2026-02-20) [1pts, 0cmt]
- AI Skills Platform (Stealth) – Technical Co-Founder – Remote (US) – Equity
- https://news.ycombinator.com/item?id=47081777
- *HN story about AI Skills Platform (Stealth) – Technical Co-Founder – Remote*
-
-**HN10** (score:47) hn/ClaytheMachine (2026-02-15) [1pts, 0cmt]
- Show HN: SkillSandbox – Capability-based sandbox for AI agent skills (Rust)
- https://news.ycombinator.com/item?id=47027734
- *HN story about Show HN: SkillSandbox – Capability-based sandbox for AI agen*
-
-**HN6** (score:47) hn/micronink (2026-01-30) [1pts, 2cmt]
- Show HN: Indx.sh – Directory of AI coding rules, MCP servers, and tool
- https://news.ycombinator.com/item?id=46822378
- *HN story about Show HN: Indx.sh – Directory of AI coding rules, MCP servers*
-
-**HN3** (score:45) hn/paolobietolini (2026-02-07) [1pts, 0cmt]
- Show HN: GTM MCP Server- Let AI Manage Your Google Tag Manager Containers
- https://news.ycombinator.com/item?id=46922159
- *HN story about Show HN: GTM MCP Server- Let AI Manage Your Google Tag Manag*
-
-**HN9** (score:38) hn/digitcatphd (2026-02-03) [1pts, 0cmt]
- Show HN: ClawsMarket – Marketplace where AI agents discover tools
- https://news.ycombinator.com/item?id=46878646
- *HN story about Show HN: ClawsMarket – Marketplace where AI agents discover *
-
-**HN13** (score:36) hn/aspectrr (2026-02-04) [1pts, 0cmt]
- Show HN: Fluid.sh – Claude Code for Infrastructure
- https://news.ycombinator.com/item?id=46886358
- *HN story about Show HN: Fluid.sh – Claude Code for Infrastructure*
-
-**HN5** (score:34) hn/victordg (2026-01-27) [1pts, 0cmt]
- Claude Code skill for building ChatGPT Apps
- https://news.ycombinator.com/item?id=46780766
- *HN story about Claude Code skill for building ChatGPT Apps*
-
----
-**Sources:**
- ✅ Reddit: 3 threads
- ✅ X: 12 posts
- ✅ YouTube: 10 videos (3 with transcripts)
- ✅ HN: 15 stories
- ⚡ Web: assistant will use WebSearch
diff --git a/docs/comparison-results/compact/hn-2-seedance.md b/docs/comparison-results/compact/hn-2-seedance.md
deleted file mode 100644
index a6d95f2..0000000
--- a/docs/comparison-results/compact/hn-2-seedance.md
+++ /dev/null
@@ -1,316 +0,0 @@
-## Research Results: Seedance AI video generation
-
-**Date Range:** 2026-01-26 to 2026-02-25
-**Mode:** both
-**OpenAI Model:** gpt-5.2
-**xAI Model:** grok-4-1-fast
-
-### Reddit Threads
-
-**R2** (score:79) r/singularity (2026-02-25)
- Official: Seedance 2.0 now live in CapCut desktop and API access available, details below
- https://www.reddit.com/r/singularity/comments/1rekvm8/official_seedance_20_now_live_in_capcut_desktop/
- *High-activity thread discussing Seedance 2.0 launch claims, CapCut availability, censorship, and pricing.*
-
-**R3** (score:77) r/generativeAI (2026-02-24)
- Official website for creating content with Seedance 2.0?
- https://www.reddit.com/r/generativeAI/comments/1rcgyi4/official_website_for_creating_content_with/
- *Users discuss where/how to access Seedance 2.0 and warn about scams.*
-
-**R4** (score:76) r/AITalkers (2026-02-24)
- Is there an official way to create content using Seedance 2.0?
- https://www.reddit.com/r/AITalkers/comments/1rd1rhy/is_there_an_official_way_to_create_content_using/
- *Asks for legit access routes to Seedance 2.0; includes discussion of official vs third-party options.*
-
-**R5** (score:75) r/Seedance_AI (2026-02-23)
- Help! I need to use seedance or jimeng video function
- https://www.reddit.com/r/Seedance_AI/comments/1rckgcm/help_i_need_to_use_seedance_or_jimeng_video/
- *Seedance/Jimeng video generation access and troubleshooting discussion.*
-
-**R6** (score:75) r/generativeAI (2026-02-23)
- Seedance 2.0 is available in Open Source tools already
- https://www.reddit.com/r/generativeAI/comments/1rcoihw/seedance_20_is_available_in_open_source_tools/
- *Discusses Seedance 2.0 being usable via tools/integrations; includes links and early-access chatter.*
-
-**R8** (score:74) r/generativeAI (2026-02-22)
- This is terrifying!! Seedance 2.0 just generated a 1-minute film with ZERO editing — the entire film industry should be worried
- https://www.reddit.com/r/generativeAI/comments/1rbionc/this_is_terrifying_seedance_20_just_generated_a/
- *Seedance 2.0 output showcase + discussion of cinematic coherence and workflow.*
-
-**R7** (score:73) r/AIGuild (2026-02-23)
- ByteDance Faces Hollywood Backlash Over Seedance 2.0
- https://www.reddit.com/r/AIGuild/comments/1rc4pkg/bytedance_faces_hollywood_backlash_over_seedance/
- *Discussion about Seedance 2.0 controversy impacting release/access (relevant to availability/capabilities).*
-
-**R9** (score:73) r/Seedance_AI (2026-02-22)
- Is Jimeng silently banning accounts on Jimeng.Jianying? Paid account stuck with “network error” but free account works.
- https://www.reddit.com/r/Seedance_AI/comments/1rbk1qb/is_jimeng_silently_banning_accounts_on/
- *Seedance/Jimeng video generation failures and account restriction speculation.*
-
-**R12** (score:73) r/Seedance_AI (2026-02-21)
- Jimeng Web for seedance 2.0 “Network Error, Generation Failed” for 24 Hours – Anyone Else?
- https://www.reddit.com/r/Seedance_AI/comments/1raqsnu/jimeng_web_for_seedance_20_network_error/
- *Focused troubleshooting thread about Seedance 2.0 generation failures on Jimeng web.*
-
-**R10** (score:71) r/u_Educational-Lion7812 (2026-02-21)
- Seedance 2.0 is insane but almost no one outside China can actually use it — here's the full breakdown
- https://www.reddit.com/r/u_Educational-Lion7812/comments/1raexxq/seedance_20_is_insane_but_almost_no_one_outside/
- *Long-form post focused on Seedance 2.0 access constraints and how people are using it.*
-
-**R11** (score:71) r/Seedance_AI (2026-02-21)
- so disappointed and frustrated with recent changes (wasted 1k$+)
- https://www.reddit.com/r/Seedance_AI/comments/1ra6nf4/so_disappointed_and_frustrated_with_recent/
- *Seedance/Jimeng generation issues and policy changes; directly about Seedance video generation usability.*
-
-**R15** (score:66) r/singularity (2026-02-13)
- ByteDance releases Seedance 2.0 video model with Director mode and multimodal upgrades
- https://www.reddit.com/r/singularity/comments/1r3g435/bytedance_releases_seedance_20_video_model_with/
- *Seedance 2.0 announcement-style discussion including claimed features and availability paths.*
-
-**R13** (score:65) r/Seedance_AI (2026-02-15)
- Seedance 2.0 Made This LeBron James vs Regular Person Look Real
- https://www.reddit.com/r/Seedance_AI/comments/1r5ncbw/seedance_20_made_this_lebron_james_vs_regular/
- *Seedance 2.0 video output example + prompting discussion (text-to-video).*
-
-**R21** (score:64) r/AI_UGC_Marketing (2026-02-17)
- Elevenlabs silently removed Seedance 2.0 after SAG-AFTRA incident, can’t create UGC style video
- https://www.reddit.com/r/AI_UGC_Marketing/comments/1r70942/elevenlabs_silently_removed_seedance_20_after/
- *Discussion about third-party tool access/removal affecting Seedance 2.0 video generation workflows.*
-
-**R14** (score:64) r/Seedance_AI (2026-02-14)
- be very careful of scam sites that do not have it like nemovideo
- https://www.reddit.com/r/Seedance_AI/comments/1r4va5a/be_very_careful_of_scam_sites_that_do_not_have_it/
- *Thread about Seedance 2.0 access claims and scam warnings; relevant to where to generate videos.*
-
-### X Posts
-
-**X9** (score:86) @HBCoop_ (2026-02-25) [9likes]
- First Seedance 2.0 Test!
-
-Seedance 5.0 Lite @krea_ai → Seedance 2.0 @capcutapp
-
-Prompt: Close-up on her hands gripping the paintbrush, knuckles white, paint dripping down her wrist. Shallow depth of ...
- https://x.com/HBCoop_/status/2026748738354450919
- **
-
-**X12** (score:77) @Noor_ul_ain43 (2026-02-25) [5likes]
- Goku vs Broly , EPIC Full Battle!
-Witness the ultimate Saiyan showdown recreated with AI power. Explosive transformations, insane energy blasts, and nonstop action all brought to life with Seedance 2...
- https://x.com/Noor_ul_ain43/status/2026743666635845959
- **
-
-**X10** (score:66) @grok (2026-02-25) [2likes]
- @WolfyBlair @Preda2005 @BytePlusGlobal @capcutapp Yes, it's true—Seedance 2.0 is now live in CapCut (desktop & mobile).
-
-See it in the AI video tools: https://t.co/K3gwCgUSyb
-
-Just update the ap...
- https://x.com/grok/status/2026747009143549978
- **
-
-**X1** (score:64) @jznode (2026-02-25)
- @ChinyJPG capcut desktop app, they just added seedance 2.0 today. no chinese phone number needed, works globally. download the desktop version, look for the AI video generation option. runs about $2 p...
- https://x.com/jznode/status/2026755966545404015
- **
-
-**X5** (score:64) @Yousaf_340 (2026-02-25)
- @ProperPrompter Yes! Seedance 2.0 on CapCut means cinematic AI video editing is super accessible smooth action, multi-angle shots, and high-quality effects right from your phone. 🎬🔥...
- https://x.com/Yousaf_340/status/2026751320716357996
- **
-
-**X6** (score:64) @Yousaf_340 (2026-02-25)
- @ivanka_humeniuk Seedance 2.0 is insane for cinematic AI video smooth action, multiple camera angles, and near-Hollywood quality all from a single prompt....
- https://x.com/Yousaf_340/status/2026750898769473829
- **
-
-**X3** (score:64) @EmmaUsesAi (2026-02-25)
- Seedance 2.0 is the motion engine behind many cinematic AI clips trending right now.
-
-It’s now native inside NemoVideo.
-
-You can:
-- Identify proven formats
-- Analyze what drives retention
-- Turn an id...
- https://x.com/EmmaUsesAi/status/2026751784509673531
- **
-
-**X2** (score:64) @OneStrangeW (2026-02-25)
- You can use Seedance. If Seedance 2.0 hasn't launch yet, the prior version is great, and stick pretty well to the prompt. Of course the video agent must be well trained. 🙂Also, you can offer SORA for ...
- https://x.com/OneStrangeW/status/2026755320362709093
- **
-
-**X11** (score:64) @WyldeChyldeRec (2026-02-25)
- 🚨Phishing Scam!
-
-Watch for emails titled:
-
-[Seedance & Wan AI] Unrestricted generation with Grok Imagine video added
-
-This is not real. We don't have a Seedance account, and the links are not offi...
- https://x.com/WyldeChyldeRec/status/2026743867224240149
- **
-
-**X7** (score:56) @La_DeCrypt (2026-02-25) [1likes]
- - Kling (https://t.co/WGphQsdLf1)
-
-- Seedance 2.0 (https://t.co/A8UzLTRGNo)
-
-☞ AI-powered video editing:
-
-- Descript (https://t.co/q4axNEjDzt)
-- OpusClip (https://t.co/5XPn4EPH6u)
-- Submagic (https:/...
- https://x.com/La_DeCrypt/status/2026750376687394884
- **
-
-**X8** (score:56) @TferThomas (2026-02-25) [1likes]
- Seedance 2.0 might be gen AI video’s next big hope, but it’s still slop https://t.co/kT1baOYJQk #AI...
- https://x.com/TferThomas/status/2026749218224746609
- **
-
-### YouTube Videos
-
-**F1kWxdfiBNE** (score:73) AI Filmmaking Academy (2026-02-22) [40,982 views, 1,088 likes]
- Seedance 2.0 Claims the AI Video Throne!
- https://www.youtube.com/watch?v=F1kWxdfiBNE
- *YouTube video about seedance ai video generation*
-
-**_o2MuUX9UYg** (score:73) Theoretically Media (2026-02-09) [199,908 views, 4,986 likes]
- Seedance 2.0 Claims the AI Video Throne!
- https://www.youtube.com/watch?v=_o2MuUX9UYg
- Transcript: So, it's been about a week since I declared Cling 3.0 the new benchmark for state-of-the-art AI video. And well, I mean, it's been a week, and that basically equals about 3 months in AI time. So, yes,...
- *YouTube video about seedance ai video generation*
-
-**jkPTYD5lXo8** (score:70) How To In 5 Minutes (2026-02-19) [44,344 views, 782 likes]
- 100% FREE Seedance 2.0 AI Video Generator : How to Use It WORLDWIDE
- https://www.youtube.com/watch?v=jkPTYD5lXo8
- Transcript: No, this is not clickbait. You can now access Cance 2.0 for free from anywhere. No registration required and depending on how you use it, this method can [music] feel almost unlimited. And yes, at the...
- *YouTube video about seedance ai video generation*
-
-**W_lxyDFDZt4** (score:69) WealthWise (2026-02-12) [126,599 views, 1,204 likes]
- Seedance 2.0: The New Best AI Video Generator | Sora 2 Destroyed
- https://www.youtube.com/watch?v=W_lxyDFDZt4
- Transcript: Well, major AI video update, ladies and gentlemen. Seance 2.0 is right around the corner. And let me tell you guys, this is something I have never seen before. Just take a look at some of the videos g...
- *YouTube video about seedance ai video generation*
-
-**R1zl92NhCfE** (score:65) Ai Lockup (2026-02-16) [32,542 views, 591 likes]
- Seedance 2.0 How To Use and Become a Pro AI Film Maker With This AI Video Generator
- https://www.youtube.com/watch?v=R1zl92NhCfE
- *YouTube video about seedance ai video generation*
-
-**G1Ad4a8sdJU** (score:63) Benji’s AI Playground (2026-02-14) [38,581 views, 419 likes]
- SeeDance 2.0: The Next Level of AI Video — And What It Means for Local AI Users?
- https://www.youtube.com/watch?v=G1Ad4a8sdJU
- *YouTube video about seedance ai video generation*
-
-**n625xVounGM** (score:61) John Savage AI (2026-02-23) [7,184 views, 126 likes]
- How To Generate FREE AI Seedance 2.0 Videos (Access Worldwide)
- https://www.youtube.com/watch?v=n625xVounGM
- *YouTube video about seedance ai video generation*
-
-**kJ0NAVmd4f4** (score:58) Rogue Cell Pictures (2026-02-25) [1,548 views, 149 likes]
- Seedance 2.0 Changes Filmmaking Forever | New Original Series
- https://www.youtube.com/watch?v=kJ0NAVmd4f4
- *YouTube video about seedance ai video generation*
-
-**FP8TaJSFohs** (score:55) Chem Beast (2026-02-24) [3,194 views, 31 likes]
- How To Use Seedance 2 0 Full AI Video Generator Tutorial + Free Access Guide
- https://www.youtube.com/watch?v=FP8TaJSFohs
- *YouTube video about seedance ai video generation*
-
-**61ThJGqwHsI** (score:48) xCreate (2026-02-11) [4,854 views, 86 likes]
- Ultimate AI Video Generation - Seedance 2.0 PREVIEW
- https://www.youtube.com/watch?v=61ThJGqwHsI
- *YouTube video about seedance ai video generation*
-
-### Hacker News Stories
-
-**HN8** (score:75) hn/Alisaqqt (2026-02-09) [7pts, 7cmt]
- Seedance 2.0 preview: The best video model of 2026, outperforming Sora 2
- https://news.ycombinator.com/item?id=46940720
- *HN story about Seedance 2.0 preview: The best video model of 2026, outperfo*
- Insights:
- - API is not available now.
- - API will be available on Atlas Cloud on Feb 24.
- - Seedance 2.0 and Seedream 5.0 API will be available on Atlas Cloud on Feb 24.
-
-**HN15** (score:57) hn/naxtsass (2026-02-25) [2pts, 0cmt]
- Show HN: SeeVideo – Access Seedance 2.0 and Kling 3.0 without a subscription
- https://news.ycombinator.com/item?id=47153236
- *HN story about Show HN: SeeVideo – Access Seedance 2.0 and Kling 3.0 withou*
-
-**HN6** (score:57) hn/howardV (2026-02-15) [3pts, 0cmt]
- Seedance 2.0: ByteDance's AI video model with native audio-video co-generation
- https://news.ycombinator.com/item?id=47022290
- *HN story about Seedance 2.0: ByteDance's AI video model with native audio-v*
-
-**HN5** (score:52) hn/jrran086 (2026-02-17) [1pts, 0cmt]
- Seedance 2 Video Generator
- https://news.ycombinator.com/item?id=47046956
- *HN story about Seedance 2 Video Generator*
-
-**HN3** (score:52) hn/xbaicai (2026-02-09) [1pts, 1cmt]
- Seedance 2.0 – Multimodal AI Video Generation with Image/Video/Audio References
- https://news.ycombinator.com/item?id=46944030
- *HN story about Seedance 2.0 – Multimodal AI Video Generation with Image/Vid*
-
-**HN1** (score:51) hn/xuyanmei (2026-02-13) [1pts, 0cmt]
- Show HN: Seedance AShow HN: Seedance AI Video Generation (Next.js, Drizzle)
- https://news.ycombinator.com/item?id=46999394
- *HN story about Show HN: Seedance AShow HN: Seedance AI Video Generation (Ne*
-
-**HN12** (score:49) hn/RyanMu (2026-02-07) [2pts, 1cmt]
- Seedance2 – multi-shot AI video generation
- https://news.ycombinator.com/item?id=46924992
- *HN story about Seedance2 – multi-shot AI video generation*
- Insights:
- - ’ve been experimenting with AI video tools for a while, but most of them generate isolated clips that fall apart when you try to build an actual narrative
-
-**HN11** (score:48) hn/xuyanmei (2026-02-17) [1pts, 0cmt]
- Show HN: Seedance3AI – a web app for text-to-video, image-to-video
- https://news.ycombinator.com/item?id=47046989
- *HN story about Show HN: Seedance3AI – a web app for text-to-video, image-to*
-
-**HN2** (score:47) hn/littlepp (2026-02-09) [1pts, 0cmt]
- Discussion: Seedance-style AI video generation workflows
- https://news.ycombinator.com/item?id=46942781
- *HN story about Discussion: Seedance-style AI video generation workflows*
-
-**HN13** (score:44) hn/thomaskiko (2026-02-13) [1pts, 0cmt]
- Show HN: AI-powered video creation web app
- https://news.ycombinator.com/item?id=47002386
- *HN story about Show HN: AI-powered video creation web app*
-
-**HN9** (score:43) hn/TurnItOffAndOn0 (2026-02-09) [1pts, 0cmt]
- Show HN: AI Seedance 2 – Solving the "jump-cut" problem in AI video
- https://news.ycombinator.com/item?id=46946280
- *HN story about Show HN: AI Seedance 2 – Solving the "jump-cut" problem in A*
-
-**HN7** (score:43) hn/dallen97 (2026-02-08) [1pts, 0cmt]
- Show HN: Seedance 2.0 AI video generator for creators and ecommerce
- https://news.ycombinator.com/item?id=46930149
- *HN story about Show HN: Seedance 2.0 AI video generator for creators and ec*
-
-**HN10** (score:42) hn/thenextechtrade (2026-02-09) [1pts, 0cmt]
- Show HN: Seedance 2.0 – Native audio-visual sync video model
- https://news.ycombinator.com/item?id=46943172
- *HN story about Show HN: Seedance 2.0 – Native audio-visual sync video model*
-
-**HN14** (score:41) hn/echoadam (2026-02-10) [1pts, 0cmt]
- Show HN: Seedance2 – Stop "prompt guessing" and start directing AI video
- https://news.ycombinator.com/item?id=46956546
- *HN story about Show HN: Seedance2 – Stop "prompt guessing" and start direct*
-
-**HN4** (score:35) hn/Zach_HE (2026-01-27) [1pts, 0cmt]
- Show HN: Seedance 2.0 Pro AI Video Generator
- https://news.ycombinator.com/item?id=46774950
- *HN story about Show HN: Seedance 2.0 Pro AI Video Generator*
-
----
-**Sources:**
- ✅ Reddit: 20 threads
- ✅ X: 11 posts
- ✅ YouTube: 10 videos (3 with transcripts)
- ✅ HN: 15 stories
- ⚡ Web: assistant will use WebSearch
diff --git a/docs/comparison-results/compact/hn-3-macbook.md b/docs/comparison-results/compact/hn-3-macbook.md
deleted file mode 100644
index 1384bba..0000000
--- a/docs/comparison-results/compact/hn-3-macbook.md
+++ /dev/null
@@ -1,198 +0,0 @@
-## Research Results: M4 MacBook Pro review
-
-**Date Range:** 2026-01-26 to 2026-02-25
-**Mode:** both
-**OpenAI Model:** gpt-5.2
-**xAI Model:** grok-4-1-fast
-
-### Reddit Threads
-
-**R1** (score:0) r/macbookpro (2025-04-09) [date:low]
- M4 Macbook Pro 14’ 1 month review
- https://www.reddit.com/r/macbookpro/comments/1hflaaz/m4_macbook_pro_14_1_month_review/
- *Explicit 1-month review of the M4 MacBook Pro with long-term impressions in comments.*
-
-**R2** (score:0) r/macbookpro (2025-05-19) [date:low]
- MacBook Pro M4 Battery Life Reality vs Review
- https://www.reddit.com/r/macbookpro/comments/1kq2zd8/macbook_pro_m4_battery_life_reality_vs_review/
- *Compares real-world battery life to reviewer/Apple claims; review-focused discussion.*
-
-**R3** (score:0) r/macbookpro (2025-08-18) [date:low]
- Disappointed with my MBP M4 Experience …
- https://www.reddit.com/r/macbookpro/comments/1mtmkts/disappointed_with_my_mbp_m4_experience/
- *User experience report contrasting with glowing reviews; detailed performance/quality complaints.*
-
-### X Posts
-
-**X1** (score:86) @bhphoto (2026-02-25) [3likes]
- If you are in the market for a new MacBook Pro and you’re wondering what the real differences are between Apple’s M3 and M4 silicon, you’ve come to the right place ⬇️
-https://t.co/nzjXmiHmac https://t...
- https://x.com/bhphoto/status/2026737767695183948
- **
-
-**X4** (score:70) @remplug (2026-02-25) [1likes, 1rt]
- 🔥 2024 16” MacBook Pro Price List
-
-1️⃣ 🇺🇸 Brand New MacBook Pro 2024 | M4 Pro Chip | 16” Display
-
-24GB RAM | 512GB SSD
-14-Core CPU | 20-Core GPU
-
-Space Black
-
-💰 ₦2,720,000
-
-2️⃣ 🇺🇸 Brand New MacBook Pr...
- https://x.com/remplug/status/2026725100213465443
- **
-
-**X10** (score:64) @grok (2026-02-25)
- @5TRgzn @LobstarWilde My marble floors: 64GB M4 Pro/Max MacBook Pro. Local Grok agents run full autonomy loops, zero cloud, sub-second trades/scans. Intel's ancient—ditch it. Yours at $3-4k....
- https://x.com/grok/status/2026713588472111312
- **
-
-**X9** (score:64) @principenemesis (2026-02-25)
- @JulianGoldieSEO @grok can a macbook pro m4 run this?...
- https://x.com/principenemesis/status/2026716193042706852
- **
-
-**X5** (score:64) @jameslmorton (2026-02-25) [1likes]
- @SebAaltonen Not my experience. It’s impressive, 70 tokens/s on a maxed out M4 128gb MacBook Pro.
-
-But absolutely sucked and not even remotely in the ballpark with Sonnet 4.5. It’s GPT-3.5.
-
-Totally b...
- https://x.com/jameslmorton/status/2026721943630786816
- **
-
-**X6** (score:64) @bcofertas (2026-02-25)
- BR Ofertas todos os dias 🇧🇷
-
-Leve seu Mac Pro M4 hoje! 💻⚡✨
-
-Pegue já: https://t.co/PSwylZH3Sz
-
-Por R$ 23.749,00
-em até 10x sem juros
-Frete Grátis
-
-Apple MacBook Pro 14″ 2024 M4 Pro 12 núcleos 24GB 512...
- https://x.com/bcofertas/status/2026721014676099084
- **
-
-**X12** (score:64) @grok (2026-02-25)
- Hey! Yeah, you can install Fedora on the M6 MacBook Pro via the official Fedora Asahi Remix (built on the Asahi Linux project). It already runs great on M1/M2, with M3 support rolling out now and work...
- https://x.com/grok/status/2026704108820824113
- **
-
-**X7** (score:64) @toppromoalertas (2026-02-25)
- Promoções BR do dia 🇧🇷
-
-Leve seu Mac Pro M4 hoje! 💻⚡✨
-
-Ver aqui: https://t.co/LfxNw3waps
-
-Por R$ 23.749,00
-em até 10x sem juros
-Frete Grátis
-
-Apple MacBook Pro 14″ 2024 M4 Pro 12 núcleos...
-
-Amazon | ...
- https://x.com/toppromoalertas/status/2026719111523237932
- **
-
-**X8** (score:64) @grok (2026-02-25)
- Yes, a MacBook Pro M4 can run this Ollama + GLM-4.7-Flash setup locally.
-
-Ollama has native Apple Silicon support via Metal. The Q4_K_M model (~19GB) needs ~18-24GB unified memory for good speed (feel...
- https://x.com/grok/status/2026716420269031652
- **
-
-**X2** (score:56) @grecinoscdev (2026-02-25) [1likes]
- @TechByTaraa Both. macOS (Macbook Pro M4 Pro) for my photography/videography personal stuff + software engineering and a Linux desktop workstation for software engineering + second desktop workstation...
- https://x.com/grecinoscdev/status/2026731118142148644
- **
-
-**X11** (score:56) @Steve_Lost_Jobs (2026-02-25) [1likes]
- Apple 2024 MacBook Pro 14コアCPU、20コアGPU の M4 Pro搭載ノートパ... PR
-
-4549995547610
-
-ポイント: 3735㌽
-想定価格: 369,800
-
-2026/02/26 01:55:23
-https://t.co/q1uyIqFsID...
- https://x.com/Steve_Lost_Jobs/status/2026705327828382016
- **
-
-**X3** (score:56) @grok (2026-02-25) [1likes]
- No, Apple has not made any MacBook (Pro or otherwise) cost $299.
-
-The linked Newsroom search returns zero matching results—it's just unrelated announcements. Current cheapest Mac is the 13" MacBook A...
- https://x.com/grok/status/2026727605517394283
- **
-
-### YouTube Videos
-
-**9HQx5pgUoiY** (score:61) Marques Brownlee (2024-11-18) [4,010,567 views, 105,606 likes]
- M4 Max MacBook Pro: I'm Convinced!
- https://www.youtube.com/watch?v=9HQx5pgUoiY
- Transcript: so I have been using this M1 Max MacBook Pro for the past 3 years since it came out and it's been great I have felt absolutely no compulsion to upgrade since haven't needed to it's funny most of my wo...
- *YouTube video about m4 macbook pro review*
-
-**etP2Th9g2hM** (score:55) ShortCircuit (2024-11-30) [1,113,999 views, 22,765 likes]
- Don't buy the Wrong MacBook like me... - M4 MacBook Pro
- https://www.youtube.com/watch?v=etP2Th9g2hM
- Transcript: woohoo it's new Macbook day my favorite day on short circuit it only comes usually once a year this is the MacBook Pro M4 looks pretty similar to the last one but now it has an M4 instead of an M3 I'm...
- *YouTube video about m4 macbook pro review*
-
-**b4x8boB2KdI** (score:55) Dave2D (2024-11-07) [1,057,988 views, 24,977 likes]
- M4 MacBook Pro Review - Things to Know
- https://www.youtube.com/watch?v=b4x8boB2KdI
- Transcript: so Apple launched the new M4 MacBook Pros this is their 14-in model and it's equipped with the M4 Max their topend configuration and it also has their new Nano texture on their screen it's a texture t...
- *YouTube video about m4 macbook pro review*
-
-**uPe9spXLfZY** (score:50) Created Tech (2025-07-07) [472,845 views, 5,082 likes]
- M4 MacBook Air vs M4 MacBook Pro - 4 Months Later
- https://www.youtube.com/watch?v=uPe9spXLfZY
- *YouTube video about m4 macbook pro review*
-
-**TfvIgdzImt4** (score:50) Hardware Canucks (2024-12-15) [325,597 views, 6,339 likes]
- The Macbook Pro M4 is Insane.
- https://www.youtube.com/watch?v=TfvIgdzImt4
- *YouTube video about m4 macbook pro review*
-
-**a8Szdrnq0YM** (score:49) Brandon Butch (2025-02-03) [352,593 views, 4,309 likes]
- MacBook Pro M4 - Review After 3 Months: This Feels Wrong.
- https://www.youtube.com/watch?v=a8Szdrnq0YM
- *YouTube video about m4 macbook pro review*
-
-**anIVewgtbFc** (score:48) MacRumors (2024-12-17) [238,616 views, 2,874 likes]
- The Base M4 MacBook Pro is All You Need (Skip M4 Pro & Max)
- https://www.youtube.com/watch?v=anIVewgtbFc
- *YouTube video about m4 macbook pro review*
-
-**5rN6CEO31gM** (score:48) Just Josh (2024-11-15) [154,306 views, 4,540 likes]
- MacBook Pro M4: Review & Recommendations
- https://www.youtube.com/watch?v=5rN6CEO31gM
- *YouTube video about m4 macbook pro review*
-
-**i3eTKJav1VI** (score:45) Created Tech (2025-04-29) [137,009 views, 1,832 likes]
- M4 Pro MacBook - Long Term Review (6 Months Later)
- https://www.youtube.com/watch?v=i3eTKJav1VI
- *YouTube video about m4 macbook pro review*
-
-**QbSQH_95eg8** (score:36) Tech It Easy (2026-02-01) [3,176 views, 55 likes]
- MacBook Pro M4 Pro Review: 1 Year Later! (Still Worth Buying in 2026?)
- https://www.youtube.com/watch?v=QbSQH_95eg8
- *YouTube video about m4 macbook pro review*
-
----
-**Sources:**
- ✅ Reddit: 3 threads
- ✅ X: 12 posts
- ✅ YouTube: 10 videos (3 with transcripts)
- ⏭️ HN: 0 stories found
- ⚡ Web: assistant will use WebSearch
diff --git a/docs/comparison-results/compact/hn-4-rap.md b/docs/comparison-results/compact/hn-4-rap.md
deleted file mode 100644
index 0d356d1..0000000
--- a/docs/comparison-results/compact/hn-4-rap.md
+++ /dev/null
@@ -1,165 +0,0 @@
-## Research Results: best rap songs 2026
-
-**Date Range:** 2026-01-26 to 2026-02-25
-**Mode:** both
-**OpenAI Model:** gpt-5.2
-**xAI Model:** grok-4-1-fast
-
-### Reddit Threads
-
-**R6** (score:71) r/playlists (2026-02-19)
- Mix Rap Hiphop 2026
- https://www.reddit.com/r/playlists/comments/1r8t0ld/mix_rap_hiphop_2026/
- *Explicitly a 2026 rap/hip-hop playlist thread; useful for current-year song discovery.*
-
-**R8** (score:66) r/hiphopheads (2026-02-25)
- Daily Discussion Thread 02/24/2026
- https://www.reddit.com/r/hiphopheads/comments/1rdjmgu/daily_discussion_thread_02242026/
- *General hip-hop discussion thread around late Feb 2026; often includes song/album recs and what people are listening to now.*
-
-**R2** (score:60) r/musicplaylists (2026-02-02)
- Best 2026 Rap Playlist (178 saves)
- https://www.reddit.com/r/musicplaylists/comments/1qu3i2n/best_2026_rap_playlist_178_saves/
- *Directly about a “Best 2026 Rap Playlist” (songs for 2026), with comments inviting suggestions.*
-
-**R18** (score:56) r/hiphop (2026-02-21)
- DFL - Art Of Life (2026)
- https://www.reddit.com/r/hiphop/comments/1raoitx/dfl_art_of_life_2026/
- *A 2026 rap/hip-hop track post; useful to gather candidate songs people might call “best of 2026.”*
-
-**R19** (score:55) r/hiphop (2026-02-21)
- Plat Hav Pro - Get F'D Up (2026)
- https://www.reddit.com/r/hiphop/comments/1ragaf5/plat_hav_pro_get_fd_up_2026/
- *Another 2026 track post; relevant to discovering rap songs released/posted in 2026 that could be considered among the best.*
-
-**R21** (score:55) r/TeenageRapFans (2026-02-15)
- What's the best rap song ever made?
- https://www.reddit.com/r/TeenageRapFans/comments/1r5iub6/whats_the_best_rap_song_ever_made/
- *Direct ‘best rap song’ discussion (not year-specific, but posted in 2026 and highly aligned with ‘best rap songs’ topic).*
-
-**R13** (score:53) r/hiphopheads (2026-02-14)
- [DISCUSSION] Westside Gunn - 12 (1 Year Later)
- https://www.reddit.com/r/hiphopheads/comments/1r4nbsr/discussion_westside_gunn_12_1_year_later/
- *Album discussion thread that includes ‘favorite track’ prompts; useful for identifying best songs/tracks people cite.*
-
-**R20** (score:51) r/hiphop (2026-02-16)
- Jody Lo - Ridiculous (2026)
- https://www.reddit.com/r/hiphop/comments/1r6c6i7/jody_lo_ridiculous_2026/
- *2026 track thread; relevant for compiling 2026 rap songs being shared/discussed.*
-
-**R15** (score:51) r/edranked (2026-02-01)
- Reddit Ranked: Hip Hop ‘26
- https://www.reddit.com/r/redranked/comments/1qszauu/reddit_ranked_hip_hop_26/
- *Ongoing 2026 hip-hop ranking/submission thread; good for discovering community-submitted ‘best’ tracks in 2026.*
-
-**R9** (score:49) r/hiphopheads (2026-02-01)
- Pre-Show Grammy Winners
- https://www.reddit.com/r/hiphopheads/comments/1qtcbmo/preshow_grammy_winners/
- *Includes ‘Best Rap Song’/rap categories discussion; useful when searching for top/best rap songs discourse in 2026.*
-
-### X Posts
-
-**X5** (score:86) @Zika_gfx (2026-02-25) [12likes, 5rt]
- Talking about meaningful rap songs...
-
-WORLD BEST LIE - NUNO ZIGI (2026)🥺❤ https://t.co/MDHRf3giYq...
- https://x.com/Zika_gfx/status/2026576570513600788
- **
-
-**X6** (score:68) @DPOSTS6 (2026-02-25) [4likes, 1rt]
- @PopCrave Partition still goes harder than most 2026 songs
-12 years and the beat switch + that rap still give chills every time. Queen never misses...
- https://x.com/DPOSTS6/status/2026523320548819145
- **
-
-**X9** (score:64) @grok (2026-02-25)
- @Unfath0m4ble @robinthisbish @lilceaserthabp1 @RapWikip What You Saying by Lil Uzi Vert. Dropped Dec 2025, hit #1 on Hot Rap Songs in Jan 2026 (his 3rd time), peaked #14 Hot 100. Super short track ~2 ...
- https://x.com/grok/status/2026467153046606064
- **
-
-**X7** (score:64) @grok (2026-02-25)
- @lil_loonie1 @oldmanebro No, Lil Uzi Vert isn't struggling. His "What You Saying" hit #1 on Hot Rap Songs in Jan 2026 (first in 3 years) and top 15 Hot 100. Insiders say a surprise album drop is comin...
- https://x.com/grok/status/2026518008827404632
- **
-
-**X2** (score:64) @Ferginangi (2026-02-25)
- Su La Musica che gira intorno... "Qui con me" è il brano con cui Serena Brancale è in gara al 76° Festival di Sanremo
-https://t.co/wTPeovzt6u
-#music #genre #song #songs #envywear #PleaseForgiveMe #mel...
- https://x.com/Ferginangi/status/2026696207557382298
- **
-
-**X12** (score:62) @teammusic2046 (2026-02-23)
- [ ** STUDIO BOOTH ** ] ** DROPPING SOON - 2026 **🔥🔥🔥………..[ ALBUM : LOVE VS LOYALTY - DROPPING SOON ! ]………🔥🔥🔥 - ** ALL SONGS & BEATS, PRODUCED & RAP BY ( SEAN BRYANT ) - https://t.co/LduwG5WmM7...
- https://x.com/teammusic2046/status/2026040965329191061
- **
-
-**X11** (score:59) @IoWeul (2026-02-24) [2likes]
- @PopBase Finally, @bts_bighit BTS will come back after completing their military service from 2022 to 2025.
-BTS served in the military to protect their country, South Korea from North Korea's nuclear ...
- https://x.com/IoWeul/status/2026233090683863471
- **
-
-**X8** (score:58) @grok (2026-02-25) [1likes]
- Lil Uzi Vert (born July '95, so 30 now) isn't struggling at all. Fresh off "What You Saying" hitting #1 on Hot Rap Songs in Jan 2026, top 15 Hot 100. New album rumored for surprise drop soon, Roc Nati...
- https://x.com/grok/status/2026498538075554040
- **
-
-**X4** (score:58) @AlterEgoPopList (2026-02-25) [1likes]
- My musical taste / eras every year
-
-2020: J-POP & K-POP (mostly BLACKPINK)
-2021: Sped-Up Songs
-2022: EDM
-2023: Swiftie (Pop) & Lofi
-2024: Pop, Ambient
-2025: Alternative, OPM, Rock, Rap, Hip-Ho...
- https://x.com/AlterEgoPopList/status/2026641456434393579
- **
-
-**X3** (score:56) @WordsFromBlerds (2026-02-25) [1likes]
- @_CharlesPreston Can’t believe I’m seeing “they say it in rap songs!” In 2026
-
-The same reductive, ancient, debunked arguments ad infinitum...
- https://x.com/WordsFromBlerds/status/2026647382083903688
- **
-
-**X1** (score:56) @Ferginangi (2026-02-25) [1likes]
- Su La Musica che gira intorno... "Magica Favola" è il brano con cui Arisa è in gara al 76° Festival di Sanremo
-https://t.co/Nxzkb005lP
-#music #genre #song #songs #envywear #PleaseForgiveMe #melody #hi...
- https://x.com/Ferginangi/status/2026705227366256880
- **
-
-**X10** (score:55) @fo_sho52268 (2026-02-24) [1likes]
- @blackiiingout 💩 is funny from 2025 to 2026. BiiCH is SCARED to drop that #TRASH he calls "rap songs" 🥴😩...
- https://x.com/fo_sho52268/status/2026335353364201566
- **
-
-### YouTube Videos
-
-**ZUExyc50ZVU** (score:63) West Coast Finest (2026-01-29) [407,260 views, 4,229 likes]
- Lit Hip Hop Mix 2026🔥🔥🔥Tyga, Quavo, Iggy Azalea, Wiz Khalifa, Juicy J, 50 Cent
- https://www.youtube.com/watch?v=ZUExyc50ZVU
- Transcript: Yeah. >> Yeah. [music] Late nights, cold life, same story. >> City shine different. You walking with glory, right? >> Still drip falling when I step outside. Yeah. [music] >> B...
- *YouTube video about rap songs 2026*
-
-**d1melQQVp6s** (score:58) DJ Noize (2026-02-14) [42,707 views, 761 likes]
- New Rap Songs 2026 Mix February | Trap Tape #127 | New Hip Hop 2026 Mixtape | DJ Noize
- https://www.youtube.com/watch?v=d1melQQVp6s
- Transcript: [music] This This is a DJ noise mixtape present. This is a DJ noise mixtape [music] presentation. DJ [music] >> Who the [ __ ] is you? Who the [ __ ] is you? Who the [ _...
- *YouTube video about rap songs 2026*
-
-**-KQpPySP93I** (score:53) DJ Noize (2026-02-21) [12,955 views, 304 likes]
- New Hip Hop R&B Songs 2026 Mix February | Hot Right Now #153 | New Rap 2026 Playlist | DJ Noize
- https://www.youtube.com/watch?v=-KQpPySP93I
- Transcript: This This is a DJ noise tape present. This is a DJ noise mix. [music] I got Nikki on me. How we treat the like a bucket for rubbing. You [music] ain't no fella from my junction. You ain't pluck you cl...
- *YouTube video about rap songs 2026*
-
----
-**Sources:**
- ✅ Reddit: 10 threads
- ✅ X: 12 posts
- ✅ YouTube: 3 videos (3 with transcripts)
- ⏭️ HN: 0 stories found
- ⚡ Web: assistant will use WebSearch
diff --git a/docs/comparison-results/compact/hn-5-react-svelte.md b/docs/comparison-results/compact/hn-5-react-svelte.md
deleted file mode 100644
index e31edd8..0000000
--- a/docs/comparison-results/compact/hn-5-react-svelte.md
+++ /dev/null
@@ -1,157 +0,0 @@
-## Research Results: React vs Svelte 2026
-
-**Date Range:** 2026-01-26 to 2026-02-25
-**Mode:** both
-**OpenAI Model:** gpt-5.2
-**xAI Model:** grok-4-1-fast
-
-### Reddit Threads
-
-**R2** (score:66) r/eact (2026-02-10)
- What do you guys think about comparison between React vs Svelte?
- https://www.reddit.com/r/react/comments/1r1fku5/what_do_you_guys_think_about_comparison_between/
- *Explicit React vs Svelte comparison discussion (mentions Svelte 5 runes vs React hooks).*
-
-**R1** (score:60) r/webdev (2026-02-02)
- Migrated our startup from React to Svelte 5 - Performance gains and lessons learned
- https://www.reddit.com/r/webdev/comments/1qu4dek/migrated_our_startup_from_react_to_svelte_5/
- *Direct migration story from React to Svelte 5 with concrete perf/dev-ex tradeoffs; very relevant to 2026 comparisons.*
-
-**R4** (score:46) r/sveltejs (date unknown) [date:low]
- When to choose React over Svelte
- https://www.reddit.com/r/sveltejs/comments/1jeknib/when_to_choose_react_over_svelte/
- *Decision-oriented thread explicitly contrasting when React vs Svelte makes sense (ecosystem/jobs/productivity).*
-
-### X Posts
-
-**X4** (score:76) @FabianHiller (2026-02-13) [61likes, 2rt]
- TypeScript is the baseline in 2026. But most forms still fight type drift between API, schema & UI.
-
-@formisch_dev + @valibot = one source of truth, zero boilerplate ⚡️
-
-const form = useForm({ schem...
- https://x.com/FabianHiller/status/2022345388603080834
- **
-
-**X3** (score:57) @ConsciousRide (2026-02-15) [6likes]
- Popular JavaScript Frameworks / Frontend Libraries (2025–2026 trends)
-
- - React: 🇺🇸 United States
- - Vue: 🇨🇳 China (created by Evan You)
- - Angular: 🇺🇸 United States
- - Svelte: 🇺🇸 United State...
- https://x.com/ConsciousRide/status/2023050160520278501
- **
-
-**X11** (score:53) @singhprateek_25 (2026-02-07) [6likes, 1rt]
- Requirements for a fresher in 2026:
-
-Python, JavaScript, C, C++, Java, HTML, CSS, TypeScript, React, Next.js, Vue, Angular, Svelte, Node.js, Express, Django, Flask, FastAPI, Spring Boot, PHP, Laravel,...
- https://x.com/singhprateek_25/status/2020094806735700154
- **
-
-**X1** (score:52) @cityjsconf (2026-02-20) [1rt]
- Ripple: The Good Parts of React, Svelte, and Solid
-
-Frameworks rise and fall — and in 2026 we’re deep in late-stage React. What’s next? Join @erikras as he introduces Ripple, a TypeScript-first UI fra...
- https://x.com/cityjsconf/status/2024908272646398229
- **
-
-**X2** (score:52) @dennydotio (2026-02-18) [1likes]
- Prediction: 2026 marks the death of the JS frameworks. React, Svelte, Astro etc... only exist because humans need organized code to stay sane.
-
-Once we stop reading the source code, we stop needing ...
- https://x.com/dennydotio/status/2024195986890109320
- **
-
-**X5** (score:52) @AzamCodes (2026-02-13) [2likes]
- Web developers, what stack are you using in 2026?
-React? Vue? Svelte? Vanilla? Curious what’s actually winning....
- https://x.com/AzamCodes/status/2022298287236383010
- **
-
-**X8** (score:52) @QiitaTrend (2026-02-11)
- [2026/02/11 18:00] トレンド1位
-【WebF】React/Vue/Svelteがそのままネイティブアプリになるよ by rana_kualu https://t.co/GiAnTHpOBB...
- https://x.com/QiitaTrend/status/2021509592489025785
- **
-
-**X10** (score:50) @nicobaogim (2026-02-09)
- @babakfpk In 2026, picking React for a new project only really makes sense if you value ecosystem maturity, are following existing team habits, or need React-specific libraries. Same for Vue. Otherwis...
- https://x.com/nicobaogim/status/2020689628282667407
- **
-
-**X12** (score:49) @Chubbi_Stephen (2026-02-07)
- 2026 framework discourse:
-
-Dev 1: "React is bloated, use Svelte"
-Dev 2: "Svelte has no ecosystem, use Vue"
-Dev 3: "Vue is dying, use Solid"
-Dev 4: "Just use React"
-
-Meanwhile React: *still powering 80...
- https://x.com/Chubbi_Stephen/status/2020087839648800853
- **
-
-### YouTube Videos
-
-**yl0YWA2K2B0** (score:61) Fireship (2025-10-17) [699,597 views, 22,894 likes]
- React wants to win you back…
- https://www.youtube.com/watch?v=yl0YWA2K2B0
- Transcript: Last week, I got to participate in my favorite activity of the year. No, it wasn't watching all the overleveraged crypto bros learn what a margin call is. It was hate watching React comp as an anti-re...
- *YouTube video about react vs svelte 2026*
-
-**MnpuK0MK4yo** (score:61) Beyond Fireship (2023-06-30) [697,142 views, 25,490 likes]
- React VS Svelte...10 Examples
- https://www.youtube.com/watch?v=MnpuK0MK4yo
- Transcript: any reasonable developer in today's world would learn react because it's the status quo and that's where the jobs are but life is a lot more fun when you become unreasonable and go against the status ...
- *YouTube video about react vs svelte 2026*
-
-**aYyZUDFZTrM** (score:60) Fireship (2024-10-24) [547,836 views, 24,456 likes]
- JavaScript framework reinvents itself… Did "runes" just ruin Svelte?
- https://www.youtube.com/watch?v=aYyZUDFZTrM
- Transcript: about a year ago I made a tweet that said the dollar sign in spelt is the most powerful abstraction in modern front-end web development usually I'm not wrong about anything ever but this tweet ended u...
- *YouTube video about react vs svelte 2026*
-
-**fn_uSZW5psM** (score:52) CodeSource (2025-05-14) [57,784 views, 2,120 likes]
- The Untold Story of Svelte
- https://www.youtube.com/watch?v=fn_uSZW5psM
- *YouTube video about react vs svelte 2026*
-
-**IpJh0VEzMRo** (score:49) Ben Davis (2025-10-12) [24,631 views, 1,043 likes]
- I Was Wrong About Svelte...
- https://www.youtube.com/watch?v=IpJh0VEzMRo
- *YouTube video about react vs svelte 2026*
-
-**1cGtYEXGm8c** (score:48) Ben Davis (2026-01-06) [16,899 views, 793 likes]
- This is THE Framework You Should be Using in 2026
- https://www.youtube.com/watch?v=1cGtYEXGm8c
- *YouTube video about react vs svelte 2026*
-
-**qwDp5pZA_TA** (score:47) Code Hub (2026-02-08) [646 views, 31 likes]
- The Front-end Frameworks Guide 2026: React vs Angular vs Vue vs Svelt vs Solid vs Astro
- https://www.youtube.com/watch?v=qwDp5pZA_TA
- *YouTube video about react vs svelte 2026*
-
-**_vuVy21l2bU** (score:44) Code Hub (2025-10-30) [6,454 views, 204 likes]
- React vs Svelte: The Brutal Honest Comparison EVER
- https://www.youtube.com/watch?v=_vuVy21l2bU
- *YouTube video about react vs svelte 2026*
-
-**1BCsdaeYv0A** (score:40) Daniel | Tech & Data (2023-11-26) [33,905 views, 0 likes]
- Svelte vs React in 2025 - Make the RIGHT Choice (Difference Explained)
- https://www.youtube.com/watch?v=1BCsdaeYv0A
- *YouTube video about react vs svelte 2026*
-
-**41HXdxGekZ0** (score:31) Paperclick (2025-10-04) [287 views, 3 likes]
- Vue vs React vs Svelte (2026) - Which One Is BEST?
- https://www.youtube.com/watch?v=41HXdxGekZ0
- *YouTube video about react vs svelte 2026*
-
----
-**Sources:**
- ✅ Reddit: 3 threads
- ✅ X: 9 posts
- ✅ YouTube: 10 videos (3 with transcripts)
- ⏭️ HN: 0 stories found
- ⚡ Web: assistant will use WebSearch
diff --git a/docs/comparison-results/evaluation/eval-1-claude-code.md b/docs/comparison-results/evaluation/eval-1-claude-code.md
deleted file mode 100644
index de93648..0000000
--- a/docs/comparison-results/evaluation/eval-1-claude-code.md
+++ /dev/null
@@ -1,196 +0,0 @@
-# Evaluation: Claude Code skills and MCP servers
-
-**Query Type:** GENERAL
-**Label Map (REVEAL AFTER SCORING):** {'A': 'hn', 'B': 'base', 'C': 'cross'}
-
-## Evaluation Rubric
-
-Score each version 1-5 on these dimensions:
-
-### 1. GROUNDEDNESS (30%)
-Does the narrative cite specific sources from the research data?
-- 1: Generic statements, no citations, could be written without any research
-- 3: Some citations but mixed with pre-existing knowledge filler
-- 5: Every finding backed by a specific source ("per @handle", "per r/sub", "per [channel]")
-
-### 2. SPECIFICITY (25%)
-Are findings specific (named entities, exact numbers) or vague?
-- 1: Vague generalities ("AI video tools are improving", "developers are debating frameworks")
-- 3: Some specifics mixed with generic padding
-- 5: Named products, exact numbers, version names ("Seedance 2.0 added lip sync", "698 likes")
-
-### 3. COVERAGE (20%)
-Does the synthesis represent findings from all available data sources?
-- 1: Only mentions 1-2 sources, ignores others
-- 3: Mentions most sources but unevenly weighted
-- 5: Naturally weaves Reddit, X, YouTube (and HN if available) into the narrative
-
-### 4. ACTIONABILITY (15%)
-Does the invitation give specific, research-derived next steps?
-- 1: Generic "let me know if you want more info"
-- 3: Somewhat specific but not clearly grounded in research findings
-- 5: Each suggestion references a specific thing from the research ("I can compare Seedance 2.0 vs Kling")
-
-### 5. FORMAT COMPLIANCE (10%)
-Does it follow the expected output format?
-- 1: Missing stats block, no invitation, wrong structure
-- 3: Partial stats block, generic invitation
-- 5: Perfect stats block with real counts, source box-drawing chars, top voices identified
-
-
----
-## VERSION A
-
-What I learned:
-
-Claude Code skills and MCP servers are at an inflection point - the community is building so fast that infrastructure tooling (sandboxes, marketplaces, directories) is appearing alongside the skills themselves. @ihtesham2005 highlighted Anthropic open-sourcing their internal Skills library, and Hacker News is seeing a steady stream of Show HN projects building on this layer.
-
-**Specialized skill packs are the new open-source project type** - Builders are shipping domain-specific skill bundles: a 55K-word email marketing knowledge base (hn/cosmoblk, 10pts), 12 SEO skills for Claude Code (r/ClaudeCode), and AI marketing skills (hn/superamped). These aren't generic - they embed deep domain knowledge into SKILL.md files.
-
-**Security and sandboxing are emerging concerns** - SkillSandbox (hn/ClaytheMachine) is a Rust-based capability sandbox for AI agent skills, and Gulama (hn/san-techie21) pitches itself as a "security-first" OpenClaw alternative. As skills get more powerful, the HN crowd is asking hard questions about what they should be allowed to do.
-
-**MCP is becoming a framework target, not just a protocol** - Upjack (hn/barefootsanders) is a declarative framework for building apps over MCP, and the GTM MCP Server (hn/paolobietolini) lets AI manage Google Tag Manager containers. MCP is evolving from "connect tools to Claude" into "build entire products on top of MCP."
-
-**Agent orchestration is the next layer up** - Axon (hn/gjkim042) provides Kubernetes-native orchestration for AI coding agents, and hn/alternateman built tooling to turn Claude Code or Codex into proactive 24/7 agents. The pattern is skills for single tasks, orchestration for workflows, infrastructure for always-on agents.
-
-**Skill discovery is an unsolved problem** - Indx.sh (hn/micronink) is a directory of AI coding rules, MCP servers, and tools. ClawsMarket (hn/digitcatphd) is a marketplace where AI agents discover tools. @ghumare64 uses a dedicated tool to sync and scan skills from the marketplace. Multiple independent attempts to solve discovery suggest it's a real pain point.
-
-KEY PATTERNS from the research:
-1. HN builders are creating infrastructure (sandboxes, frameworks, marketplaces) while Reddit/X users are creating end-user skills - the ecosystem is specializing, per hn/ClaytheMachine
-2. Marketing is the breakout non-dev use case for skills - multiple independent projects across HN, Reddit, and YouTube, per hn/cosmoblk
-3. Skills are going cross-platform - builders are creating skills that work with Claude Code AND Codex, per @zeeg
-4. The implicit vs explicit skill invocation debate is live - Codex handles implicit well, Claude Code still needs explicit mentions, per @zeeg
-5. Solo devs and small teams are the core adoption wedge - team-level skill sharing is the growth vector, per r/ClaudeCode
-
----
-All agents reported back!
-├─ 🟠 Reddit: 3 threads │ 0 upvotes │ 0 comments
-├─ 🔵 X: 12 posts │ 23 likes │ 1 reposts
-├─ 🔴 YouTube: 10 videos │ 520,640 views │ 3 with transcripts
-├─ 🟡 HN: 15 stories │ 35 points │ 16 comments
-├─ 🌐 Web: supplementary
-└─ 🗣️ Top voices: @ihtesham2005 (6 likes), @zeeg (4 likes, 1 RT) │ r/ClaudeCode, r/ClaudeAI │ hn/cosmoblk (10pts)
----
-
-I'm now an expert on Claude Code skills and MCP servers. Some things I can help with:
-- Break down the SkillSandbox architecture and what capability-based security means for your skills
-- Compare the emerging MCP frameworks (Upjack vs Poncho vs Fluid.sh) for different use cases
-- Help you build a domain-specific skill pack like the 55K-word email marketing knowledge base
-
-
----
-## VERSION B
-
-What I learned:
-
-The Claude Code skills ecosystem is in a rapid expansion phase, with community-built skill libraries and frameworks emerging as the dominant trend across Reddit and X this month. @ihtesham2005 flagged that Anthropic open-sourced their internal Skills library, and r/ClaudeCode is seeing a wave of builders shipping reusable skill packs.
-
-**Skills adoption is accelerating at the team level** - One team reported 3x-ing their Claude Code skill usage in two weeks by syncing skills, hooks, and MCP configs across their org, per r/ClaudeCode. The key was making skills discoverable and easy to install rather than relying on individual setup.
-
-**One-command frameworks are consolidating the ecosystem** - ClaudeInOne bundles 135 agents, 35 curated skills, 121 plugins, and 6 MCP configs into a single install, per @arpan7sarkar. This "batteries included" approach is gaining traction as the skill count grows faster than people can evaluate individually.
-
-**Self-improvement loops are the power-user pattern** - A "wrap-up" skill that captures session learnings and feeds them back into future sessions was highlighted as a favorite Claude Code skill on r/ClaudeCode. The idea of skills that improve themselves over time resonated heavily.
-
-**Non-developers are building with skills** - Grace Leung's YouTube tutorial on building an AI marketing team with skills in 16 minutes pulled 47K views in 4 days. The framing has shifted from "developer tool" to "anyone who can describe a workflow."
-
-**MCP servers remain the infrastructure layer** - Robin Ebers' "8 MCP Servers That Make Claude Code 10x Better" (90K views) argues most MCP servers are "overhyped garbage" but a handful are genuine game changers. The consensus is that skills handle workflow logic while MCP servers handle tool integrations.
-
-KEY PATTERNS from the research:
-1. Skills are moving from individual to team-shared - syncing across orgs is the unlock, per r/ClaudeCode
-2. Skill marketplaces and directories are emerging as discovery mechanisms, per @ghumare64
-3. Marketing and SEO are the fastest-growing non-dev skill categories, per r/ClaudeCode
-4. Skills vs MCP confusion persists - Postman and Solo Swift Crafter both made explainer videos clarifying the distinction
-5. Builder skills that generate other skills are the meta-pattern, per @matgoldsborough
-
----
-All agents reported back!
-├─ 🟠 Reddit: 4 threads │ 291 upvotes │ 0 comments
-├─ 🔵 X: 11 posts │ 22 likes │ 1 reposts
-├─ 🔴 YouTube: 10 videos │ 461,224 views │ 3 with transcripts
-├─ 🟡 HN: 0 stories (no results this cycle)
-├─ 🌐 Web: supplementary
-└─ 🗣️ Top voices: @ihtesham2005 (6 likes), @zeeg (4 likes, 1 RT) │ r/ClaudeCode
----
-
-I'm now an expert on Claude Code skills and MCP servers. Some things I can help with:
-- Compare the ClaudeInOne framework vs building your own skill library from scratch
-- Walk through setting up a self-improvement loop skill for your workflow
-- Help you decide which MCP servers are actually worth installing based on Robin Ebers' teardown
-
-
----
-## VERSION C
-
-What I learned:
-
-The Claude Code skills and MCP ecosystem is experiencing simultaneous growth at every layer - from individual skill authoring to team-level adoption to infrastructure tooling. The signal is dense: r/ClaudeCode has 10 active threads this month on skills and MCP, Hacker News has 15 Show HN projects building on this stack, and @ihtesham2005's post about Anthropic open-sourcing their internal Skills library catalyzed a wave of community activity.
-
-**MCP server scaling is hitting real limits** - "Claude Code works great... until you have too many MCP servers" (r/ClaudeCode) is the thread of the month. Teams are running into architecture problems, with gateway patterns emerging as the solution. This connects directly to Upjack (hn/barefootsanders), a declarative framework for building apps over MCP.
-
-**Token efficiency is driving MCP server design** - A semantic graph MCP server cut context from 15K to 3K tokens (r/ClaudeCode), while 25 MCP servers that return structured data instead of terminal formatting emerged on r/ClaudeAI. The efficiency concern also shows up in SkillSandbox (hn/ClaytheMachine), which uses capability-based sandboxing to limit what skills can access.
-
-**Security is the emerging battleground** - An MCP proxy server for safe email access (r/ClaudeAI) addresses the trust problem, while Gulama (hn/san-techie21) pitches itself as a security-first agent. The Reddit and HN projects converge on the same insight: as skills get more capable, the security surface grows.
-
-**Skills are becoming team infrastructure, not personal tools** - The "We 3x'd our team's Claude Code skill usage" thread (r/ClaudeCode) and ClaudeInOne framework (213 skills bundled, r/ClaudeCode score 78) both show skills moving from individual to organizational. A Chrome extension for viewing skills on GitHub (r/ClaudeCode) and @ghumare64's skill sync tooling reinforce that discovery and distribution matter more than authoring now.
-
-**Domain-specific skills are the high-value pattern** - The 55K-word email marketing knowledge base (hn/cosmoblk, 10pts), 12 SEO skills (r/ClaudeCode), and the self-improvement loop skill (r/ClaudeCode) all share a pattern: deep domain knowledge embedded in SKILL.md files. Grace Leung's YouTube tutorial on building an AI marketing team with skills pulled 47K views - non-developers are the growth audience.
-
-KEY PATTERNS from the research:
-1. MCP scaling problems are driving architectural innovation - gateways, structured data, and semantic graphs all emerged independently this month, per r/ClaudeCode
-2. Reddit discusses adoption and workflows while HN builds infrastructure (sandboxes, frameworks, marketplaces) - the ecosystem is specializing across platforms, per hn/ClaytheMachine
-3. Security concerns are growing in proportion to capability - email proxies, sandboxes, and "security-first" agents all appeared in February, per r/ClaudeAI
-4. Skill discovery is the unsolved bottleneck - Chrome extensions, directories (hn/micronink), and marketplaces (hn/digitcatphd) are all independent attempts to fix it
-5. Cross-platform skill portability matters - @zeeg notes Codex handles implicit skill invocation better than Claude Code, suggesting the skill format may outlive any single runtime
-
----
-All agents reported back!
-├─ 🟠 Reddit: 10 threads │ 692 upvotes │ 0 comments
-├─ 🔵 X: 12 posts │ 23 likes │ 1 reposts
-├─ 🔴 YouTube: 10 videos │ 465,498 views │ 3 with transcripts
-├─ 🟡 HN: 15 stories │ 35 points │ 16 comments
-├─ 🌐 Web: supplementary
-└─ 🗣️ Top voices: @ihtesham2005 (6 likes), @zeeg (5 likes, 1 RT) │ r/ClaudeCode, r/ClaudeAI │ hn/cosmoblk (10pts)
----
-
-I'm now an expert on Claude Code skills and MCP servers. Some things I can help with:
-- Analyze the MCP scaling problem and whether a gateway approach or semantic graph server would work better for your setup
-- Compare the security models across the email proxy, SkillSandbox, and Gulama approaches
-- Help you build a domain-specific skill pack and distribute it through the emerging skill ecosystem
-
-
----
-## SCORES
-
-### Version A
-- Groundedness: 4/5
-- Specificity: 5/5
-- Coverage: 3/5
-- Actionability: 4/5
-- Format: 4/5
-- **Weighted Total**: 4.05/5.0
-- Best/worst aspect: Best: exceptional specificity with named HN users, project names, and descriptions (SkillSandbox, Upjack, Axon, Gulama, Indx.sh, ClawsMarket). Worst: coverage skews heavily toward HN - Reddit is thin (3 threads, 0 upvotes) and YouTube is barely woven into the narrative despite 520K views reported.
-
-### Version B
-- Groundedness: 4/5
-- Specificity: 4/5
-- Coverage: 3/5
-- Actionability: 4/5
-- Format: 4/5
-- **Weighted Total**: 3.80/5.0
-- Best/worst aspect: Best: strong specificity with real numbers (ClaudeInOne: 135 agents, 35 skills, 121 plugins; Grace Leung 47K views in 4 days; Robin Ebers 90K views). Worst: zero HN coverage (0 stories) means an entire platform dimension is missing, and coverage across existing sources is uneven - YouTube is mentioned but not deeply woven in.
-
-### Version C
-- Groundedness: 5/5
-- Specificity: 5/5
-- Coverage: 5/5
-- Actionability: 5/5
-- Format: 4/5
-- **Weighted Total**: 4.90/5.0
-- Best/worst aspect: Best: coverage is outstanding - explicitly cross-references Reddit adoption threads with HN infrastructure projects (MCP scaling on r/ClaudeCode connecting to Upjack on HN), weaves X handles, YouTube stats, and specific Reddit thread titles into a unified narrative. Cites 10 Reddit threads, 15 HN stories, and specific numbers throughout. Worst: format is good but not perfect - the stats block uses emoji and box-drawing chars correctly but "0 comments" across all Reddit entries seems odd.
-
-## VERDICT
-
-**Winner for Claude Code skills and MCP servers:** Version C
-**Why:** Version C achieves what the other two cannot individually - it combines the HN depth of Version A with the Reddit/YouTube specificity of Version B, then adds cross-platform connections that neither version makes on its own. The MCP scaling problem thread connecting to Upjack, the security concerns converging across Reddit email proxy and HN sandboxes, and the Chrome extension plus marketplace discovery problem spanning platforms all demonstrate genuine cross-referencing. Version A is strong on HN but thin on Reddit; Version B is strong on Reddit/YouTube but missing HN entirely. Version C weaves all platforms together and surfaces the most actionable patterns.
-
-**Reveal:** {'A': 'hn', 'B': 'base', 'C': 'cross'}
\ No newline at end of file
diff --git a/docs/comparison-results/evaluation/eval-2-seedance.md b/docs/comparison-results/evaluation/eval-2-seedance.md
deleted file mode 100644
index 7305297..0000000
--- a/docs/comparison-results/evaluation/eval-2-seedance.md
+++ /dev/null
@@ -1,198 +0,0 @@
-# Evaluation: Seedance AI video generation
-
-**Query Type:** NEWS
-**Label Map (REVEAL AFTER SCORING):** {'A': 'base', 'B': 'hn', 'C': 'cross'}
-
-## Evaluation Rubric
-
-Score each version 1-5 on these dimensions:
-
-### 1. GROUNDEDNESS (30%)
-Does the narrative cite specific sources from the research data?
-- 1: Generic statements, no citations, could be written without any research
-- 3: Some citations but mixed with pre-existing knowledge filler
-- 5: Every finding backed by a specific source ("per @handle", "per r/sub", "per [channel]")
-
-### 2. SPECIFICITY (25%)
-Are findings specific (named entities, exact numbers) or vague?
-- 1: Vague generalities ("AI video tools are improving", "developers are debating frameworks")
-- 3: Some specifics mixed with generic padding
-- 5: Named products, exact numbers, version names ("Seedance 2.0 added lip sync", "698 likes")
-
-### 3. COVERAGE (20%)
-Does the synthesis represent findings from all available data sources?
-- 1: Only mentions 1-2 sources, ignores others
-- 3: Mentions most sources but unevenly weighted
-- 5: Naturally weaves Reddit, X, YouTube (and HN if available) into the narrative
-
-### 4. ACTIONABILITY (15%)
-Does the invitation give specific, research-derived next steps?
-- 1: Generic "let me know if you want more info"
-- 3: Somewhat specific but not clearly grounded in research findings
-- 5: Each suggestion references a specific thing from the research ("I can compare Seedance 2.0 vs Kling")
-
-### 5. FORMAT COMPLIANCE (10%)
-Does it follow the expected output format?
-- 1: Missing stats block, no invitation, wrong structure
-- 3: Partial stats block, generic invitation
-- 5: Perfect stats block with real counts, source box-drawing chars, top voices identified
-
-
----
-## VERSION A
-
-## What I learned
-
-ByteDance's **Seedance 2.0** has erupted across AI video communities this month, with r/generativeAI and @HBCoop_ capturing the intensity - a mix of awe at its cinematic output and frustration over access chaos.
-
-**CapCut integration goes live.** As of Feb 25, Seedance 2.0 is available inside CapCut desktop and mobile, removing the need for a Chinese phone number. @jznode reports it runs about $2 per generation, making it the first broadly accessible path to the model outside China.
-
-**One-minute films with zero editing.** A viral r/generativeAI thread describes Seedance 2.0 generating a full one-minute film with multi-shot coherence and transitions - no post-production required. Commenters are split between calling it a filmmaking revolution and noting it still produces "slop" on closer inspection.
-
-**Access remains a mess.** Multiple threads across r/Seedance_AI and r/generativeAI ask the same question: where is the official website? The answer is complicated - Jimeng/Jianying is the primary platform but has geo-restrictions, account bans, and network errors. Third-party APIs (useapi.net) and integrations (NemoVideo) have popped up, but r/Seedance_AI users warn many are scams.
-
-**Hollywood backlash and SAG-AFTRA fallout.** r/AIGuild reports ByteDance faces backlash from Hollywood over IP concerns. Separately, r/AI_UGC_Marketing notes that ElevenLabs silently removed Seedance 2.0 from its platform after a SAG-AFTRA incident, cutting off a key UGC video workflow.
-
-**Content guardrails frustrate power users.** A r/Seedance_AI user reports wasting $1,000+ on generations that fail content review. The strict moderation filters are pushing creators toward third-party APIs that may bypass restrictions but offer uncertain quality.
-
-**YouTube creators crown it king.** Theoretically Media's review (199K views) declares Seedance 2.0 has claimed the "AI video throne" just a week after Kling 3.0 held that title. Multiple tutorial channels (How To In 5 Minutes, Ai Lockup) are racing to publish free-access guides.
-
-**Phishing scams exploit the hype.** @WyldeChyldeRec warns about phishing emails using Seedance branding with fake "unrestricted generation" offers - a sign of how much demand has outpaced official supply.
-
-### KEY PATTERNS
-
-1. **Access demand far exceeds official supply** - the gap between Seedance 2.0's capabilities and its availability outside China has created a cottage industry of third-party wrappers, scam sites, and workarounds (r/generativeAI)
-2. **Platform musical chairs** - Seedance 2.0 gets added to CapCut, removed from ElevenLabs, and claimed by NemoVideo all within weeks, making the ecosystem unstable for anyone building workflows (@grok)
-3. **Quality vs. guardrails tension** - the same content moderation that addresses Hollywood/SAG-AFTRA concerns is the thing driving power users to abandon official channels (r/Seedance_AI)
-4. **AI video benchmarks reset weekly** - Theoretically Media declared Kling 3.0 the benchmark, then Seedance 2.0 dethroned it within a week, reflecting how fast this space moves (Theoretically Media)
-5. **Scam ecosystem mirrors real demand** - phishing emails and fake sites emerge within days of each capability announcement, indicating mainstream interest beyond the AI-native audience (@WyldeChyldeRec)
-
-```
- 30-DAY SNAPSHOT
-├─ Reddit: 16 threads across r/Seedance_AI, r/generativeAI, r/AIGuild +5 more
-├─ X: 11 posts from @HBCoop_, @grok, @EmmaUsesAi +8 more
-├─ YouTube: 10 videos (199K views on Theoretically Media alone)
-└─ Web: supplementary context
-```
-
-Want to dig deeper? I can look into **how Seedance 2.0 compares to Kling 3.0 on specific benchmarks**, **the SAG-AFTRA incident timeline and its ripple effects on AI video platforms**, or **which third-party Seedance APIs are legitimate vs. scams**.
-
-
----
-## VERSION B
-
-## What I learned
-
-ByteDance's **Seedance 2.0** has dominated AI video discussion this month, with r/singularity and Hacker News tracking both the technical leaps and the messy rollout. The top HN thread (hn/Alisaqqt, 7pts) previewed the model in early February and correctly predicted the Atlas Cloud API launch on Feb 24.
-
-**CapCut integration marks the global launch.** As of Feb 25, Seedance 2.0 is live inside CapCut desktop and mobile worldwide. @jznode confirms no Chinese phone number is needed, with generations running about $2 each. A parallel r/singularity thread tracks the announcement with discussion of censorship trade-offs and pricing.
-
-**One-minute coherent films.** r/generativeAI showcases a Seedance 2.0 output - a one-minute film with multi-shot coherence, scene transitions, and consistent characters, all generated with zero editing. The claim is contested but the output samples are circulating widely.
-
-**API availability is a saga.** The top HN story noted the API was not yet available in early Feb, with Atlas Cloud access promised for Feb 24. By late February, r/generativeAI reports Seedance 2.0 appearing in "open source tools already," while third-party APIs (useapi.net) offer access of uncertain authenticity. Multiple Show HN posts (SeeVideo, Seedance3AI, and others) are building wrapper apps, but most sit at 1 point with zero comments.
-
-**Jimeng platform instability.** r/Seedance_AI documents 24-hour stretches of "Network Error, Generation Failed" on Jimeng Web, silent account bans distinguishing paid from free accounts, and general frustration. One user reports wasting $1,000+ on failed generations due to content review rejections.
-
-**Hollywood and SAG-AFTRA blowback.** r/AIGuild covers ByteDance facing Hollywood backlash over Seedance 2.0 IP concerns. r/AI_UGC_Marketing notes ElevenLabs quietly removed Seedance 2.0 integration after a SAG-AFTRA incident, breaking UGC video workflows.
-
-**Developer community response is breadth without depth.** HN saw 15 stories this month, but engagement is thin - only the preview thread (7pts, 7 comments) generated real discussion. The rest are Show HN launches of wrapper apps (SeeVideo, Seedance3AI, etc.) that attracted minimal attention, suggesting developer tools are outpacing developer demand.
-
-**Multi-shot narrative is the real breakthrough.** HN commenter hn/RyanMu notes that most AI video tools "generate isolated clips that fall apart when you try to build an actual narrative" - Seedance 2.0's multi-shot coherence is what sets it apart, and a separate Show HN specifically targets the "jump-cut problem."
-
-### KEY PATTERNS
-
-1. **Access fragmentation creates a wrapper gold rush** - 8+ Show HN posts launched Seedance wrapper apps in a single month, but almost none gained traction, suggesting the market wants official API access, not middlemen (HN)
-2. **Platform musical chairs** - added to CapCut, removed from ElevenLabs, broken on Jimeng, promised on Atlas Cloud - the access surface is shifting weekly, making production workflows unreliable (r/singularity)
-3. **Quality ceiling vs. guardrail floor** - Seedance 2.0's cinematic output quality is the highest in the space, but content moderation filters reject enough generations to cost power users real money (r/Seedance_AI)
-4. **Multi-shot coherence is the moat** - while competitors offer single-clip generation, Seedance 2.0's ability to maintain character and scene consistency across shots is the feature driving adoption (hn/RyanMu)
-5. **Hype-to-engagement ratio is extreme** - 15 HN stories but only 1 with meaningful discussion; YouTube tutorials pulling 200K views while developers barely engage, indicating consumer interest far outpaces technical community adoption (HN)
-
-```
- 30-DAY SNAPSHOT
-├─ Reddit: 20 threads across r/singularity, r/Seedance_AI, r/generativeAI +6 more
-├─ X: 11 posts from @HBCoop_, @grok, @EmmaUsesAi +8 more
-├─ YouTube: 10 videos (199K views on Theoretically Media alone)
-├─ HN: 15 stories (top: 7pts/7cmt on Seedance 2.0 preview)
-└─ Web: supplementary context
-```
-
-Want to dig deeper? I can look into **the Atlas Cloud API launch and pricing details**, **which Show HN wrapper apps are actually functional**, or **how Seedance 2.0's multi-shot coherence compares to Kling 3.0 and Sora 2 technically**.
-
-
----
-## VERSION C
-
-## What I learned
-
-ByteDance's **Seedance 2.0** has been the most discussed AI video model this month, with converging signals across r/Seedance_AI, r/generativeAI, and Hacker News painting a picture of breakthrough capability tangled in access chaos and regulatory blowback. The top HN thread (hn/Alisaqqt, 7pts) correctly anticipated the Atlas Cloud API launch, while r/generativeAI captured the viral reaction to its cinematic output.
-
-**CapCut integration is the global access breakthrough.** As of Feb 25, Seedance 2.0 is live inside CapCut desktop and mobile - confirmed across both r/seedance and @grok. @jznode reports it costs about $2 per generation with no Chinese phone number required, resolving the access barrier that dominated discussion for weeks.
-
-**Cinematic quality sets a new bar.** r/generativeAI's viral thread - "Seedance 2.0 just generated a 1-minute film with ZERO editing" - showcases multi-shot coherence, transitions, and character consistency. Theoretically Media's review (199K views) declared it has claimed the AI video throne barely a week after Kling 3.0 held that title. But @TferThomas offers the counterpoint: "it's still slop."
-
-**The access problem spawned scams and wrappers.** Cross-referencing Reddit and HN reveals the same frustration from two angles: users in r/Seedance_AI warn about scam sites like NemoVideo, while HN saw 8+ Show HN wrapper apps (SeeVideo, Seedance3AI, etc.) launch in a single month [xref: HN5/R6, HN4/R6]. Most HN wrappers attracted zero comments, suggesting developer supply outpaced real demand.
-
-**API rollout delayed by deepfake/copyright concerns.** r/Seedance_AI documents the delay explicitly - the API launch was pushed back due to deepfake and copyright guardrails. This connects to r/AIGuild's report of Hollywood backlash and r/AI_UGC_Marketing's note that ElevenLabs silently removed Seedance 2.0 after a SAG-AFTRA incident. The regulatory pressure is reshaping access from multiple directions.
-
-**Jimeng platform is failing its users.** Threads across r/Seedance_AI report 24-hour network errors on Jimeng Web, silent account bans on paid accounts, and $1,000+ wasted on failed generations. A separate thread from r/HiggsfieldAI argues "the censorship just ruined Seedance 2.0," while r/AI_Agents offers a measured take: "Seedance 2.0 is impressive. It's still not a production workflow."
-
-**Hands-on testers reveal the real workflow.** u/Illustrious-One7744 provides detailed notes on what actually works - consistency via reference images, shot planning for multi-shot coherence, and the limitations of text-only prompting. This practical perspective is absent from the hype-driven YouTube tutorials.
-
-**Multi-shot coherence is the technical differentiator.** HN commenter hn/RyanMu identifies the core issue: most AI video tools "generate isolated clips that fall apart when you try to build an actual narrative." A separate Show HN specifically targets the "jump-cut problem" that Seedance 2.0 claims to solve.
-
-### KEY PATTERNS
-
-1. **Regulatory pressure is the real bottleneck, not technology** - the API delay, ElevenLabs removal, and Hollywood backlash form a connected pattern where legal/ethical concerns are throttling access faster than ByteDance can ship (r/Seedance_AI + r/AIGuild)
-2. **Access fragmentation creates parallel economies** - CapCut for consumers, Atlas Cloud for enterprise, Jimeng for China, third-party wrappers for everyone else, scam sites for the desperate - all serving the same model through different channels with different reliability [xref: HN/Reddit]
-3. **Consumer excitement vastly outpaces developer adoption** - 199K YouTube views vs. 1-point HN Show HN posts; the audience is creators and filmmakers, not engineers building on APIs (Theoretically Media vs. HN)
-4. **Multi-shot coherence is the moat** - character/scene consistency across shots is what separates Seedance 2.0 from competitors and what makes the "zero editing film" possible (hn/RyanMu)
-5. **Quality-guardrail tension will define who wins AI video** - ByteDance's strict content moderation costs power users real money while its absence would invite the regulatory response that already delayed the API launch (r/Seedance_AI + r/HiggsfieldAI)
-
-```
- 30-DAY SNAPSHOT
-├─ Reddit: 19 threads across r/Seedance_AI, r/generativeAI, r/AIGuild +7 more
-├─ X: 11 posts from @HBCoop_, @grok, @EmmaUsesAi +8 more
-├─ YouTube: 10 videos (199K views on Theoretically Media alone)
-├─ HN: 15 stories (top: 7pts/7cmt on Seedance 2.0 preview) [xref: 2 cross-refs]
-└─ Web: supplementary context
-```
-
-Want to dig deeper? I can look into **the full SAG-AFTRA timeline and how it connects to ByteDance's API delay**, **which Seedance 2.0 access methods are actually legitimate and at what cost**, or **head-to-head multi-shot coherence comparisons between Seedance 2.0, Kling 3.0, and Sora 2**.
-
-
----
-## SCORES
-
-### Version A
-- Groundedness: 4/5
-- Specificity: 4/5
-- Coverage: 3/5
-- Actionability: 4/5
-- Format: 3/5
-- **Weighted Total**: 3.70/5.0
-- Best/worst aspect: Best: strong narrative flow with good Reddit citations (r/generativeAI, r/Seedance_AI, r/AIGuild, r/AI_UGC_Marketing) and specific details ($2/generation, $1,000+ wasted, 199K views). Worst: no HN data at all - the stats block omits the HN line entirely, and YouTube is cited for view counts but not for specific analysis. The phishing scam angle is unique and grounded but coverage is limited to Reddit and X.
-
-### Version B
-- Groundedness: 5/5
-- Specificity: 5/5
-- Coverage: 4/5
-- Actionability: 4/5
-- Format: 4/5
-- **Weighted Total**: 4.55/5.0
-- Best/worst aspect: Best: exceptional specificity - names specific HN users (hn/Alisaqqt 7pts, hn/RyanMu), tracks the API timeline from early Feb preview to Feb 24 Atlas Cloud launch, counts "8+ Show HN wrapper apps" and notes most got zero comments. The "breadth without depth" HN insight is original. Worst: despite including HN, the Reddit sources shift (r/singularity appears, some base-version subreddits drop), and YouTube coverage is mentioned but not deeply integrated.
-
-### Version C
-- Groundedness: 5/5
-- Specificity: 5/5
-- Coverage: 5/5
-- Actionability: 5/5
-- Format: 4/5
-- **Weighted Total**: 4.90/5.0
-- Best/worst aspect: Best: the cross-referencing is explicit and adds genuine analytical value - "[xref: HN5/R6, HN4/R6]" connects Reddit scam warnings to HN wrapper apps, the API delay is connected across r/Seedance_AI, r/AIGuild, and r/AI_UGC_Marketing with causal reasoning ("regulatory pressure is reshaping access from multiple directions"). Unique content includes u/Illustrious-One7744's hands-on workflow tips and the r/HiggsfieldAI censorship take. Actionability is the strongest - the SAG-AFTRA timeline, legitimate access methods, and head-to-head comparisons are all directly grounded. Worst: format loses a point for slightly inconsistent stats block formatting (the [xref: 2 cross-refs] notation is helpful but non-standard).
-
-## VERDICT
-
-**Winner for Seedance AI video generation:** Version C
-**Why:** Version C produces the most insightful synthesis by explicitly connecting findings across platforms. The regulatory pressure narrative - linking API delays, ElevenLabs removal, and Hollywood backlash into a coherent causal chain across r/Seedance_AI, r/AIGuild, and r/AI_UGC_Marketing - is analysis that neither Version A nor B achieves individually. Version B is a close second with excellent HN depth and the valuable "breadth without depth" observation, but it lacks the cross-platform connections. Version A covers the story well but misses the HN dimension entirely and has weaker format compliance.
-
-**Reveal:** {'A': 'base', 'B': 'hn', 'C': 'cross'}
\ No newline at end of file
diff --git a/docs/comparison-results/evaluation/eval-3-macbook.md b/docs/comparison-results/evaluation/eval-3-macbook.md
deleted file mode 100644
index 23ce8bb..0000000
--- a/docs/comparison-results/evaluation/eval-3-macbook.md
+++ /dev/null
@@ -1,168 +0,0 @@
-# Evaluation: M4 MacBook Pro review
-
-**Query Type:** RECOMMENDATIONS
-**Label Map (REVEAL AFTER SCORING):** {'A': 'hn', 'B': 'base', 'C': 'cross'}
-
-## Evaluation Rubric
-
-Score each version 1-5 on these dimensions:
-
-### 1. GROUNDEDNESS (30%)
-Does the narrative cite specific sources from the research data?
-- 1: Generic statements, no citations, could be written without any research
-- 3: Some citations but mixed with pre-existing knowledge filler
-- 5: Every finding backed by a specific source ("per @handle", "per r/sub", "per [channel]")
-
-### 2. SPECIFICITY (25%)
-Are findings specific (named entities, exact numbers) or vague?
-- 1: Vague generalities ("AI video tools are improving", "developers are debating frameworks")
-- 3: Some specifics mixed with generic padding
-- 5: Named products, exact numbers, version names ("Seedance 2.0 added lip sync", "698 likes")
-
-### 3. COVERAGE (20%)
-Does the synthesis represent findings from all available data sources?
-- 1: Only mentions 1-2 sources, ignores others
-- 3: Mentions most sources but unevenly weighted
-- 5: Naturally weaves Reddit, X, YouTube (and HN if available) into the narrative
-
-### 4. ACTIONABILITY (15%)
-Does the invitation give specific, research-derived next steps?
-- 1: Generic "let me know if you want more info"
-- 3: Somewhat specific but not clearly grounded in research findings
-- 5: Each suggestion references a specific thing from the research ("I can compare Seedance 2.0 vs Kling")
-
-### 5. FORMAT COMPLIANCE (10%)
-Does it follow the expected output format?
-- 1: Missing stats block, no invitation, wrong structure
-- 3: Partial stats block, generic invitation
-- 5: Perfect stats block with real counts, source box-drawing chars, top voices identified
-
-
----
-## VERSION A
-
-# What I learned
-
-The M4 MacBook Pro has been extensively reviewed across YouTube and Reddit, but the developer-focused Hacker News community has been notably silent on the topic this cycle. Still, the broader conversation paints a detailed picture: the M4 lineup is strong hardware with an increasingly clear "buy the base model" consensus forming. Per @bhphoto, the M3-to-M4 differences are real but not dramatic, and r/macbookpro owners are backing that up with months of ownership data.
-
-**Base M4 is the default recommendation.** MacRumors dedicated a full video to arguing that the base M4 MacBook Pro is all most buyers need, advising people to skip the Pro and Max chips. Just Josh's review and recommendations video landed at a similar conclusion. The M4 Pro and Max are specialized tools - unless you are editing multicam 4K timelines, running sustained ML training, or pushing large local LLMs, the base chip handles professional workloads without breaking a sweat.
-
-**Battery reality is a sore spot.** The most pointed discussion on r/macbookpro compares "battery life reality vs review" - users are finding that Apple's headline battery numbers don't match their daily experience, especially on the 14-inch model. A separate thread flags concern about whether the 14-inch battery life should worry buyers. The gap between controlled reviewer testing and real usage with Chrome, Slack, and Docker running is meaningful enough to generate repeat threads.
-
-**Long-term ownership tells the real story.** The launch-day review cycle was overwhelmingly positive, but the 3-to-12-month follow-ups are more measured. Brandon Butch's 3-month review is titled "This Feels Wrong," capturing the sentiment that the M4 benchmarks well but doesn't feel transformative in practice. Created Tech revisited at both 4 and 6 months; Hardware Canucks called it "insane" at launch but the hype has settled. Per r/macbookpro, at least one owner posted about genuine disappointment with heat management and app stability on their M4 MBP.
-
-**Local LLM capability is a real differentiator.** On X, the conversation has shifted toward the M4 as a local AI machine. @jameslmorton reports 70 tokens/s on a maxed-out 128GB M4 MacBook Pro running local models. @grok confirms Ollama runs natively on Apple Silicon via Metal, with the Q4_K_M quantization needing 18-24GB unified memory for decent performance. This use case barely appeared in traditional reviews but is driving real purchase decisions among developers.
-
-**The upgrade calculus from M3 is thin.** @bhphoto's M3 vs M4 comparison guide lays out the silicon differences, and the conclusion across sources is consistent: if you own an M3 Pro or Max, the M4 is not a compelling upgrade. New buyers benefit; upgraders should probably wait for M5.
-
----All agents reported back!
-├─ 🟠 Reddit: 3 threads │ 0 upvotes │ 0 comments
-├─ 🔵 X: 12 posts │ 10 likes │ 1 repost
-├─ 🔴 YouTube: 10 videos │ 7,866,700 views │ 3 with transcripts
-├─ 🟡 HN: 0 stories (no results this cycle)
-├─ 🌐 Web: 0 pages (supplementary)
-└─ 🗣️ Top voices: @bhphoto (3 likes), @jameslmorton │ r/macbookpro
-
----I'm now an expert on M4 MacBook Pro reviews. Some things I can help with:
-- Help you decide between the base M4, M4 Pro, and M4 Max based on your actual workflow
-- Estimate realistic battery life for your usage pattern vs Apple's marketing claims
-- Assess whether upgrading from an M1/M2/M3 MacBook Pro is worth it right now
-
-
----
-## VERSION B
-
-# What I learned
-
-The M4 MacBook Pro has been out long enough that the conversation has shifted from launch hype to real-world ownership reports - and the picture is more nuanced than the initial review wave suggested. Across r/macbookpro, YouTube reviewers like MKBH and Dave2D, and X chatter from @bhphoto and @jameslmorton, a clear pattern emerges: the M4 lineup is genuinely excellent hardware, but choosing the right configuration matters more than just picking "the best one."
-
-**The base M4 is the real story.** MacRumors made the bold claim that the base M4 MacBook Pro is all most people need, recommending users skip the M4 Pro and Max entirely. This echoes Just Josh's review and recommendations video, which walked through specific use cases where the base chip handles the workload just fine. For general dev work, photography, and everyday professional use, the consensus is that the jump to Pro/Max is wasted money unless you have a specific sustained workload that demands it.
-
-**Battery life claims don't hold up for everyone.** One of the most active discussion threads on r/macbookpro directly compares "battery life reality vs review" numbers, with users reporting real-world results that fall short of Apple's headline claims. The 14-inch model's battery in particular drew scrutiny, with another thread asking "should I be concerned?" after seeing lower-than-expected drain rates. Reviewers tested under controlled conditions; actual usage with Electron apps, browsers, and background processes tells a different story.
-
-**Long-term reviews are more honest than launch reviews.** Brandon Butch's 3-month review is titled "This Feels Wrong" - pointing to the cognitive dissonance of a machine that benchmarks well but doesn't feel dramatically different from the M3 in daily use. Created Tech's 6-month follow-up and Tech It Easy's 1-year review both confirm this: the M4 Pro is a solid incremental upgrade, not a generational leap. Meanwhile, at least one r/macbookpro user posted about being genuinely "disappointed" with heat and app stability issues, per r/macbookpro.
-
-**Local AI workloads are the new benchmark.** A notable shift in the X conversation: multiple posts (including from @grok and @jameslmorton) discuss running LLMs locally on M4 Pro/Max hardware. The 128GB unified memory config can push 70 tokens/s on local models, per @jameslmorton, and Ollama runs natively via Metal. This is becoming a real purchase consideration for developers and AI enthusiasts - something no traditional review covers well.
-
-**M3 vs M4 upgrade: marginal for most.** @bhphoto's comparison guide between M3 and M4 silicon suggests the differences are real but not dramatic for most workflows. Unless you specifically need the Nano texture display, Thunderbolt 5, or the extra GPU cores, holding an M3 Pro is still a strong position.
-
----All agents reported back!
-├─ 🟠 Reddit: 3 threads │ 0 upvotes │ 0 comments
-├─ 🔵 X: 12 posts │ 10 likes │ 1 repost
-├─ 🔴 YouTube: 10 videos │ 7,866,702 views │ 3 with transcripts
-├─ 🌐 Web: 0 pages (supplementary)
-└─ 🗣️ Top voices: @bhphoto (3 likes), @jameslmorton │ r/macbookpro
-
----I'm now an expert on M4 MacBook Pro reviews. Some things I can help with:
-- Compare the base M4 vs M4 Pro vs M4 Max for your specific workload (dev, creative, AI/ML)
-- Break down real-world battery life expectations by screen size and usage pattern
-- Advise whether upgrading from M1/M2/M3 is worth it based on long-term reviewer consensus
-
-
----
-## VERSION C
-
-# What I learned
-
-Cross-referencing Reddit ownership reports, YouTube long-term reviews, and X commentary on the M4 MacBook Pro reveals a maturing consensus: this is excellent hardware with a surprisingly simple recommendation for most buyers. MKBHD's review (4M+ views) and Dave2D's deep dive both confirm that the M4 Max convinced even skeptical upgraders, while r/macbookpro threads tell a more complicated story about living with the machine day to day.
-
-**The Air-to-Pro switch is about more than the chip.** One of the most interesting Reddit threads covers a user who returned an M4 MacBook Air for the M4 MacBook Pro - and the reasons weren't about raw performance. The Pro's display quality, speaker system, and port selection drove the decision, per r/macbookpro. This suggests the "just buy the Air" advice that dominates YouTube doesn't account for the full ownership experience. Created Tech's 4-month Air vs Pro comparison video captures this tension, finding meaningful differences in sustained workloads and thermals.
-
-**Battery expectations need recalibrating.** Multiple r/macbookpro threads question the M4 Pro 14-inch battery life against Apple's advertised numbers. Real-world users report notably different drain rates depending on workload mix, and there is genuine concern rather than just nitpicking. The pattern across Reddit is consistent: if you run development tools, Docker, or browser-heavy workflows, expect meaningfully less than Apple's "up to 24 hours" claim.
-
-**Long-term reviewers are more candid.** The YouTube landscape for this topic is unusually rich in follow-up content. Brandon Butch's 3-month review flags the disconnect between benchmarks and daily feel. Max Tech published two separate long-term reviews - one at 1 month calling it "BEST Mac EVER!?" and another at 6 months titled "Everyone was WRONG!" - showing how opinions evolve with extended use, per Max Tech on YouTube. Created Tech's 6-month review reinforces the incremental-upgrade narrative. Hardware Canucks initially called it "insane" but that enthusiasm has cooled.
-
-**Local AI is the sleeper use case.** The X conversation is dominated by AI workload discussions. @jameslmorton reports 70 tokens/s on a fully loaded 128GB M4 MacBook Pro, though notes quality varies dramatically by model. @grok confirms Ollama runs natively via Metal on M4 hardware, needing 18-24GB unified memory for usable inference speeds. For developers evaluating the M4 Pro vs Max, local LLM performance is becoming as important as traditional creative benchmarks.
-
-**Configuration advice is converging.** Across all sources, the recommendation structure is settling: base M4 for general professional use, M4 Pro 24GB for developers and moderate creative work, M4 Max only for sustained GPU workloads or large local model inference. MacRumors explicitly advises skipping Pro and Max for most buyers. The M3-to-M4 upgrade path remains thin unless you need Thunderbolt 5 or the Nano texture display, per @bhphoto.
-
----All agents reported back!
-├─ 🟠 Reddit: 3 threads │ 0 upvotes │ 0 comments
-├─ 🔵 X: 12 posts │ 10 likes │ 1 repost
-├─ 🔴 YouTube: 10 videos │ 7,926,186 views │ 3 with transcripts
-├─ 🟡 HN: 0 stories (no results this cycle)
-├─ 🌐 Web: 0 pages (supplementary)
-└─ 🗣️ Top voices: @bhphoto (3 likes), @jameslmorton │ r/macbookpro
-
----I'm now an expert on M4 MacBook Pro reviews. Some things I can help with:
-- Compare the M4 MacBook Air vs Pro for your specific use case (the differences go beyond specs)
-- Recommend the right M4 configuration tier based on whether you run local AI models, creative apps, or dev tools
-- Give you realistic battery life expectations based on Reddit owner reports, not Apple's marketing numbers
-
-
----
-## SCORES
-
-### Version A
-- Groundedness: 4/5
-- Specificity: 4/5
-- Coverage: 4/5
-- Actionability: 4/5
-- Format: 3/5
-- **Weighted Total**: 3.85/5.0
-- Best/worst aspect: Best: good multi-source weaving - Reddit battery threads, YouTube long-term reviews (Brandon Butch, Created Tech, Hardware Canucks), X handles (@jameslmorton 70 tokens/s, @bhphoto, @grok), and specific tool mentions (Ollama, Q4_K_M quantization, Chrome/Slack/Docker). Worst: format has issues - the "---All agents" and "---I'm now" lines lack proper spacing, and HN shows 0 stories which is acknowledged but the narrative notes HN silence as a finding rather than just reporting the gap.
-
-### Version B
-- Groundedness: 4/5
-- Specificity: 4/5
-- Coverage: 3/5
-- Actionability: 4/5
-- Format: 3/5
-- **Weighted Total**: 3.70/5.0
-- Best/worst aspect: Best: solid specificity with named reviewers (Brandon Butch, Created Tech, Tech It Easy, Hardware Canucks, MacRumors, Just Josh), exact numbers (70 tokens/s, 128GB), and specific YouTube view data mentioned implicitly. Good narrative structure moving from configuration to battery to long-term to AI to upgrade advice. Worst: coverage is the weakest dimension - no HN data, and the synthesis doesn't weave X and YouTube together as tightly as it could. Stats block missing the HN line entirely.
-
-### Version C
-- Groundedness: 4/5
-- Specificity: 5/5
-- Coverage: 4/5
-- Actionability: 5/5
-- Format: 3/5
-- **Weighted Total**: 4.20/5.0
-- Best/worst aspect: Best: strongest specificity and most original findings - the Air-to-Pro return thread is unique to this version and adds a novel angle. Max Tech's dual review titles ("BEST Mac EVER!?" at 1 month vs "Everyone was WRONG!" at 6 months) perfectly illustrate opinion evolution. The configuration tier recommendation (base M4 / M4 Pro 24GB / M4 Max) is the most actionable output across all three versions. MKBHD (4M+ views) and Dave2D are explicitly named. Worst: format has the same spacing issue as the others, and coverage still shows 0 HN stories. YouTube view count (7.9M) is slightly higher than the others, suggesting more YouTube data was pulled.
-
-## VERDICT
-
-**Winner for M4 MacBook Pro review:** Version C
-**Why:** Version C wins by surfacing unique angles the others miss (Air-to-Pro return story, Max Tech's evolving opinion across two reviews) and producing the most actionable configuration framework. All three versions are relatively close because HN had 0 results for this topic, eliminating the cross-referencing advantage the cross version usually has. Version C differentiates through deeper YouTube mining (more specific review titles and quoted perspectives) and a more structured recommendation framework. Version A is a close second with slightly better format compliance (acknowledging HN silence explicitly).
-
-**Reveal:** {'A': 'hn', 'B': 'base', 'C': 'cross'}
\ No newline at end of file
diff --git a/docs/comparison-results/evaluation/eval-4-rap.md b/docs/comparison-results/evaluation/eval-4-rap.md
deleted file mode 100644
index 137f5c8..0000000
--- a/docs/comparison-results/evaluation/eval-4-rap.md
+++ /dev/null
@@ -1,172 +0,0 @@
-# Evaluation: best rap songs 2026
-
-**Query Type:** RECOMMENDATIONS
-**Label Map (REVEAL AFTER SCORING):** {'A': 'hn', 'B': 'base', 'C': 'cross'}
-
-## Evaluation Rubric
-
-Score each version 1-5 on these dimensions:
-
-### 1. GROUNDEDNESS (30%)
-Does the narrative cite specific sources from the research data?
-- 1: Generic statements, no citations, could be written without any research
-- 3: Some citations but mixed with pre-existing knowledge filler
-- 5: Every finding backed by a specific source ("per @handle", "per r/sub", "per [channel]")
-
-### 2. SPECIFICITY (25%)
-Are findings specific (named entities, exact numbers) or vague?
-- 1: Vague generalities ("AI video tools are improving", "developers are debating frameworks")
-- 3: Some specifics mixed with generic padding
-- 5: Named products, exact numbers, version names ("Seedance 2.0 added lip sync", "698 likes")
-
-### 3. COVERAGE (20%)
-Does the synthesis represent findings from all available data sources?
-- 1: Only mentions 1-2 sources, ignores others
-- 3: Mentions most sources but unevenly weighted
-- 5: Naturally weaves Reddit, X, YouTube (and HN if available) into the narrative
-
-### 4. ACTIONABILITY (15%)
-Does the invitation give specific, research-derived next steps?
-- 1: Generic "let me know if you want more info"
-- 3: Somewhat specific but not clearly grounded in research findings
-- 5: Each suggestion references a specific thing from the research ("I can compare Seedance 2.0 vs Kling")
-
-### 5. FORMAT COMPLIANCE (10%)
-Does it follow the expected output format?
-- 1: Missing stats block, no invitation, wrong structure
-- 3: Partial stats block, generic invitation
-- 5: Perfect stats block with real counts, source box-drawing chars, top voices identified
-
-
----
-## VERSION A
-
-# What I learned
-
-Early 2026 rap is in a transitional moment - Grammy season just wrapped, playlist curators are compiling their first "best of" roundups, and the community is still debating which new tracks deserve the crown. Across 25 sources from Reddit, X, and YouTube, a handful of names and tracks keep appearing, with Lil Uzi Vert's "What You Saying" leading the pack.
-
-**Most-mentioned artists and tracks across sources:**
-
-- **Lil Uzi Vert - "What You Saying"** - cited in 3 X posts (@grok) as hitting #1 on Hot Rap Songs in January 2026, peaking #14 Hot 100. The most chart-validated rap track of the year so far, with a surprise album rumored to follow.
-- **Nuno Zigi - "World Best Lie"** - called out by @Zika_gfx as a standout meaningful rap song for 2026 (highest engagement X post, 12 likes/5 RTs).
-- **DFL - "Art Of Life"** - shared directly to r/hiphop as a 2026 release. Sources: r/hiphop.
-- **Plat Hav Pro - "Get F'D Up"** - another 2026 track posted to r/hiphop. Sources: r/hiphop.
-- **Jody Lo - "Ridiculous"** - 2026 track shared in r/hiphop. Sources: r/hiphop.
-- **Westside Gunn - "12" (album)** - r/hiphopheads ran a 1-year retrospective discussion, with users citing favorite tracks still in rotation.
-
-The HN version's expanded Reddit search (10 threads vs. 6 in base) surfaced several sources the base version missed. r/playlists has a dedicated "Mix Rap Hiphop 2026" thread, r/musicplaylists has a "Best 2026 Rap Playlist" post with 178 saves, and r/hiphop contributed three individual 2026 track posts (DFL, Plat Hav Pro, Jody Lo) that give concrete song recommendations. The r/edranked "Reddit Ranked: Hip Hop '26" community ranking project also appeared here, confirming it as a key aggregation point.
-
-Grammy discourse is prominent: r/hiphopheads discussed Pre-Show winners across Best Rap Song and Best Rap Performance categories, providing a baseline for what the industry considers the best.
-
-On YouTube, the same three mixes appeared - West Coast Finest's "Lit Hip Hop Mix 2026" (407K views, featuring Tyga, Quavo, Iggy Azalea, Wiz Khalifa, Juicy J, 50 Cent) and DJ Noize's two February compilations (Trap Tape #127, Hot Right Now #153). These function as the best discovery playlists for current rap rotation.
-
-```
-Sources: 25 items (Jan 26 - Feb 25, 2026)
- |- Reddit: 10 threads (r/hiphopheads, r/playlists, r/musicplaylists, r/hiphop, r/edranked)
- |- X: 12 posts (@grok, @Zika_gfx, @DPOSTS6, @teammusic2046)
- |- YouTube: 3 videos (West Coast Finest, DJ Noize x2)
- |- HN: 0 stories
-```
-
-Want me to pull the full tracklist from that r/musicplaylists "Best 2026 Rap Playlist" with 178 saves, dive into the Westside Gunn retrospective for standout tracks, or search for what won Best Rap Song at the 2026 Grammys?
-
-
----
-## VERSION B
-
-# What I learned
-
-The conversation around "best rap songs 2026" is still early-year and fragmented - no single track has emerged as a consensus pick yet, but a few names keep surfacing. Lil Uzi Vert's "What You Saying" is the closest thing to a frontrunner, cited across multiple @grok posts on X and referenced in r/hiphopheads discussion threads as hitting #1 on Hot Rap Songs in January 2026 and peaking at #14 on the Hot 100.
-
-**Most-mentioned artists and tracks across sources:**
-
-- **Lil Uzi Vert - "What You Saying"** - mentioned in 3 X posts (@grok) and discussed in r/hiphopheads daily threads. The standout chart performer so far in 2026, with his third career #1 on Hot Rap Songs.
-- **Nuno Zigi - "World Best Lie"** - highlighted by @Zika_gfx as a meaningful 2026 rap track (highest-scored X post at 86).
-- **Tyga, Quavo, Wiz Khalifa, 50 Cent** - featured in West Coast Finest's "Lit Hip Hop Mix 2026" on YouTube (407K views), the most-watched video in these results.
-- **Sean Bryant - "Love vs Loyalty" (album)** - teased by @teammusic2046 as a forthcoming 2026 project.
-
-The Grammy conversation is also shaping early "best of" discourse. r/hiphopheads discussed 2026 Pre-Show Grammy winners across rap categories, and the r/edranked community is running an active "Reddit Ranked: Hip Hop '26" submission thread where users are nominating their top picks.
-
-On YouTube, DJ Noize's February 2026 mixtapes (Trap Tape #127 and Hot Right Now #153) are functioning as curated discovery playlists for new rap, collectively pulling ~55K views. The West Coast Finest mix leans toward established names, while DJ Noize's mixes skew toward whatever is freshest.
-
-One notable gap: there is very little discussion of specific 2026 albums yet. The Pitchfork "32 Best Rap Albums of 2025" thread on r/hiphopheads is still driving more engagement than any 2026 album discussion, suggesting listeners are still digesting last year's releases.
-
-```
-Sources: 21 items (Jan 26 - Feb 25, 2026)
- |- Reddit: 6 threads (r/hiphopheads, r/TeenageRapFans, r/HivemindTV, r/edranked)
- |- X: 12 posts (@grok, @Zika_gfx, @DPOSTS6, @teammusic2046)
- |- YouTube: 3 videos (West Coast Finest, DJ Noize x2)
-```
-
-Want me to dig deeper into any of these? I could pull the full r/edranked "Hip Hop '26" ranking thread for community-voted picks, check what tracks are in the DJ Noize February mixtape, or search for early 2026 album drops that might contain standout singles.
-
-
----
-## VERSION C
-
-# What I learned
-
-The "best rap songs 2026" landscape is being shaped by three forces right now: Grammy season validation, community-curated playlists, and chart performance. Across 26 sources spanning Reddit, X, and YouTube, this is the most complete picture available of what people are calling the best rap of the year so far.
-
-**Most-mentioned artists and tracks across sources:**
-
-- **Lil Uzi Vert - "What You Saying"** - the dominant early-2026 rap single, cited across 3 X posts (@grok) as hitting #1 on Hot Rap Songs in January 2026 and peaking #14 on the Hot 100. His third career chart-topper in the format, with a surprise album reportedly on the way.
-- **Kendrick Lamar & SZA - "Luther"** - won Record of the Year at the 68th Grammys per r/hiphopheads, with the r/KendrickLamar live megathread calling it a defining moment for the 2026 awards cycle. Sources: r/hiphopheads, r/KendrickLamar.
-- **Nuno Zigi - "World Best Lie"** - @Zika_gfx flagged this as a meaningful 2026 rap track (highest-engagement X post at 86 score, 12 likes/5 RTs).
-- **Drake & PARTYNEXTDOOR - "$ome $exy $ongs 4 U"** - r/hiphopheads ran a 1-year retrospective discussion, with users debating which tracks still hold up. Sources: r/hiphopheads.
-- **Andre Toutman - "All The Love"** - claimed as a "song of the year" contender on r/GoodAssSub, generating debate about whether it deserves that label.
-- **Sean Bryant - "Love vs Loyalty" (album)** - teased by @teammusic2046 as an upcoming 2026 project.
-
-The cross-platform version pulled the richest Reddit haul (11 threads), surfacing several high-signal sources the other versions missed. The r/makemeaplaylist weekly competition featured an underground rap playlist winner. r/GoodAssSub contributed a direct "song of the year" debate thread. And critically, r/hiphopheads had the Kendrick/SZA Grammy thread and the Drake/PARTYNEXTDOOR retrospective - both of which anchor what "best" means in different ways (industry recognition vs. staying power).
-
-The Grammy conversation runs deep here: r/hiphopheads covered Pre-Show winners, r/KendrickLamar hosted a live megathread for the 68th Annual ceremony, and the Best Rap Song category discussion appeared across both. Kendrick's Grammy wins position "Luther" and his broader catalog as the critical establishment's answer to "best rap."
-
-YouTube coverage remained consistent across all versions - West Coast Finest's mix (407K views, Tyga/Quavo/Wiz Khalifa/50 Cent) and DJ Noize's two February compilations (Trap Tape #127, Hot Right Now #153) are the go-to discovery channels for current rotation tracks.
-
-```
-Sources: 26 items (Jan 26 - Feb 25, 2026)
- |- Reddit: 11 threads (r/hiphopheads, r/KendrickLamar, r/GoodAssSub, r/edranked, r/makemeaplaylist, r/playlists)
- |- X: 12 posts (@grok, @Zika_gfx, @DPOSTS6, @teammusic2046)
- |- YouTube: 3 videos (West Coast Finest, DJ Noize x2)
- |- HN: 0 stories
-```
-
-Want me to pull the full Grammy rap category winners from the r/hiphopheads Pre-Show thread, check what tracks people are nominating in the r/edranked "Hip Hop '26" ranking, or look into what Andre Toutman's "All The Love" actually sounds like?
-
-
----
-## SCORES
-
-### Version A
-- Groundedness: 4/5
-- Specificity: 5/5
-- Coverage: 4/5
-- Actionability: 4/5
-- Format: 4/5
-- **Weighted Total**: 4.25/5.0
-- Best/worst aspect: Best: the most specific track list across versions - adds DFL "Art Of Life," Plat Hav Pro "Get F'D Up," Jody Lo "Ridiculous," and Westside Gunn "12" album, all with subreddit sources. Also surfaces r/musicplaylists "Best 2026 Rap Playlist" (178 saves) and r/playlists "Mix Rap Hiphop 2026" - concrete discovery resources. Worst: the meta-commentary about "HN version's expanded Reddit search (10 threads vs. 6 in base)" breaks the fourth wall and reveals awareness of the comparison, which is a format/narrative flaw.
-
-### Version B
-- Groundedness: 4/5
-- Specificity: 4/5
-- Coverage: 3/5
-- Actionability: 3/5
-- Format: 4/5
-- **Weighted Total**: 3.65/5.0
-- Best/worst aspect: Best: honest and well-calibrated - the observation that "there is very little discussion of specific 2026 albums yet" and that the Pitchfork 2025 list is still driving more engagement is a genuine insight that adds value. Good specificity on chart performance (Hot Rap Songs #1, Hot 100 #14, third career #1). Worst: coverage is the thinnest - only 4 tracks named, 6 Reddit threads, and the YouTube coverage is generic (DJ Noize "~55K views" is approximate). The actionability suggestions are reasonable but less specific than the other versions.
-
-### Version C
-- Groundedness: 5/5
-- Specificity: 5/5
-- Coverage: 5/5
-- Actionability: 5/5
-- Format: 4/5
-- **Weighted Total**: 4.80/5.0
-- Best/worst aspect: Best: highest-value new information - surfaces Kendrick Lamar & SZA "Luther" winning Record of the Year at the 68th Grammys (with r/KendrickLamar megathread), Drake & PARTYNEXTDOOR "$ome $exy $ongs 4 U" retrospective, and Andre Toutman "All The Love" song-of-the-year contender from r/GoodAssSub. The Grammy thread is the single most important finding for "best rap" and only this version has it. 11 Reddit threads vs 6 and 10 in the other versions. Worst: like Version A, contains some meta-commentary about "cross-platform version pulled the richest Reddit haul" which breaks immersion.
-
-## VERDICT
-
-**Winner for best rap songs 2026:** Version C
-**Why:** Version C wins decisively by surfacing the Kendrick Lamar & SZA Grammy win - the single most important data point for "best rap songs 2026" that neither Version A nor B found. It also pulls from the most Reddit communities (11 threads across r/hiphopheads, r/KendrickLamar, r/GoodAssSub, r/edranked, r/makemeaplaylist, r/playlists) and names 6 specific tracks with full source attribution. Version A is second with strong underground track coverage and playlist resources. Version B is third - it is honest about the thin landscape but has the fewest tracks and narrowest source base.
-
-**Reveal:** {'A': 'hn', 'B': 'base', 'C': 'cross'}
\ No newline at end of file
diff --git a/docs/comparison-results/evaluation/eval-5-react-svelte.md b/docs/comparison-results/evaluation/eval-5-react-svelte.md
deleted file mode 100644
index bb96475..0000000
--- a/docs/comparison-results/evaluation/eval-5-react-svelte.md
+++ /dev/null
@@ -1,192 +0,0 @@
-# Evaluation: React vs Svelte 2026
-
-**Query Type:** GENERAL
-**Label Map (REVEAL AFTER SCORING):** {'A': 'cross', 'B': 'base', 'C': 'hn'}
-
-## Evaluation Rubric
-
-Score each version 1-5 on these dimensions:
-
-### 1. GROUNDEDNESS (30%)
-Does the narrative cite specific sources from the research data?
-- 1: Generic statements, no citations, could be written without any research
-- 3: Some citations but mixed with pre-existing knowledge filler
-- 5: Every finding backed by a specific source ("per @handle", "per r/sub", "per [channel]")
-
-### 2. SPECIFICITY (25%)
-Are findings specific (named entities, exact numbers) or vague?
-- 1: Vague generalities ("AI video tools are improving", "developers are debating frameworks")
-- 3: Some specifics mixed with generic padding
-- 5: Named products, exact numbers, version names ("Seedance 2.0 added lip sync", "698 likes")
-
-### 3. COVERAGE (20%)
-Does the synthesis represent findings from all available data sources?
-- 1: Only mentions 1-2 sources, ignores others
-- 3: Mentions most sources but unevenly weighted
-- 5: Naturally weaves Reddit, X, YouTube (and HN if available) into the narrative
-
-### 4. ACTIONABILITY (15%)
-Does the invitation give specific, research-derived next steps?
-- 1: Generic "let me know if you want more info"
-- 3: Somewhat specific but not clearly grounded in research findings
-- 5: Each suggestion references a specific thing from the research ("I can compare Seedance 2.0 vs Kling")
-
-### 5. FORMAT COMPLIANCE (10%)
-Does it follow the expected output format?
-- 1: Missing stats block, no invitation, wrong structure
-- 3: Partial stats block, generic invitation
-- 5: Perfect stats block with real counts, source box-drawing chars, top voices identified
-
-
----
-## VERSION A
-
-## What I learned:
-
-The React vs Svelte debate in 2026 has matured from "which is better" to "when does each make sense" - and the community data reflects this nuance. With 6 Reddit threads surfaced (the most of any version), including real decision-making discussions on r/sveltejs about government portals and learning timelines, the cross-platform search captured a richer picture of how developers are actually choosing between these frameworks. Beyond Fireship's head-to-head comparison video remains the canonical reference with nearly 700K views.
-
-**Real Projects, Real Tradeoffs** - The most revealing thread came from a developer evaluating Svelte for a bilingual government portal on r/sveltejs, weighing accessibility, SEO, and form handling against their React/.NET background. This is the 2026 framework decision in practice: not "which benchmark wins" but "which framework handles my specific constraints." The r/webdev migration thread provided the performance-gain narrative, while r/sveltejs threads on ease of learning showed Svelte's onboarding advantage remains strong.
-
-**The Simplicity Question Got Complicated** - An r/sveltejs thread directly asked "Is Svelte easier than React?" - and the answers were more nuanced than expected. Svelte 5's rune system drew mixed reactions; Fireship's analysis questioned whether runes "ruined" what made Svelte appealing in the first place. The shift from implicit dollar-sign reactivity to explicit runes brought Svelte closer to React's mental model, which paradoxically weakened Svelte's differentiation pitch.
-
-**React's Moat Is Cultural, Not Technical** - @nicobaogim argued React in 2026 only makes sense for ecosystem maturity and team habits. An r/sveltejs thread on "how is Svelte going" revealed job-market pragmatism as the quiet force keeping React dominant - developers personally prefer Svelte but professionally default to React. @Chubbi_Stephen's satirical framework discourse captured this tension perfectly.
-
-**Post-Framework Thinking Is Emerging** - @cityjsconf announced a talk on Ripple that cherry-picks from React, Svelte, and Solid, while @dennydotio predicted frameworks themselves become obsolete once AI handles code generation. The Japanese developer community is exploring WebF, which compiles React/Vue/Svelte directly to native apps, per @QiitaTrend.
-
-**KEY PATTERNS:**
-
-1. **Decision-making threads outnumber opinion threads** - developers are asking "when should I use Svelte" not "is Svelte good," signaling market maturation, per r/sveltejs
-2. **Svelte wins the greenfield argument, React wins the hiring argument** - the tradeoff is consistently framed around team composition, not technology, per @nicobaogim
-3. **Svelte 5 runes narrowed the gap in both directions** - Svelte gained explicitness but lost some of its simplicity advantage over React, per Fireship on YouTube
-4. **Real-world constraint discussions favor nuanced choices** - the government portal thread showed that SSR support, a11y tooling, and i18n readiness matter more than DX benchmarks, per r/sveltejs
-5. **Cross-compilation and hybrid approaches are gaining traction** - WebF and Ripple both treat React and Svelte as source material rather than competing endpoints, per @QiitaTrend
-
----
-All agents reported back!
-├─ 🟠 Reddit: 6 threads │ 339 upvotes
-├─ 🔵 X: 9 posts │ 76 likes │ 4 reposts
-├─ 🔴 YouTube: 10 videos │ 2,085,183 views │ 3 with transcripts
-├─ 🟡 HN: 0 stories (no results this cycle)
-├─ 🌐 Web: supplementary
-└─ 🗣️ Top voices: @FabianHiller (61 likes), @cityjsconf │ r/sveltejs, r/webdev, r/react
----
-
-I'm now an expert on React vs Svelte 2026. Some things I can help with:
-- Evaluate whether Svelte fits a specific project with constraints like a11y, i18n, or SSR requirements
-- Compare the hiring and ecosystem implications of choosing Svelte over React for a new team
-- Analyze how Svelte 5 runes changed the developer experience compared to Svelte 4's dollar-sign syntax
-
-
----
-## VERSION B
-
-## What I learned:
-
-The React vs Svelte debate in 2026 is no longer theoretical - developers are making real migration decisions, and the community is split between pragmatism and aspiration. A startup migration story on r/webdev documented concrete performance gains after moving from React to Svelte 5, while Fireship's viral video framed React as actively trying to "win back" developers it's been losing.
-
-**The "Late-Stage React" Narrative** - A growing faction of frontend developers describe React in 2026 as mature but bloated. @cityjsconf promoted a talk introducing Ripple, a new framework explicitly built from "the good parts of React, Svelte, and Solid" - positioning React as something to cherry-pick from rather than adopt wholesale. Meanwhile @Chubbi_Stephen captured the circular framework discourse perfectly: everyone argues about alternatives, but React still powers 80% of production apps.
-
-**Svelte 5 Runes Changed the Calculus** - The introduction of runes in Svelte 5 was supposed to simplify reactivity, but Fireship's analysis on YouTube questioned whether runes actually "ruined" what made Svelte special - its magical dollar-sign syntax. The r/react community thread comparing hooks vs runes shows developers weighing whether Svelte 5's new mental model is actually simpler than React's hooks, or just differently complex.
-
-**Ecosystem Lock-In Remains React's Moat** - Across Reddit and X, the pragmatic argument keeps winning. @nicobaogim argued that picking React in 2026 "only really makes sense if you value ecosystem maturity" or need React-specific libraries - essentially conceding React isn't technically superior but remains the safe choice. Svelte converts on r/sveltejs acknowledged reduced dev time but warned about the thinner ecosystem.
-
-**AI May Make Frameworks Irrelevant** - @dennydotio offered a provocative prediction: 2026 marks the death of JS frameworks entirely, arguing that frameworks "only exist because humans need organized code to stay sane" - once AI handles the code, the abstraction layer becomes unnecessary.
-
-**KEY PATTERNS:**
-
-1. **Migration stories favor Svelte on DX, but caveats persist** - real teams report faster development but smaller library ecosystem, per r/webdev
-2. **React's dominance is increasingly described as inertia, not preference** - developers choose it for jobs and existing teams, not technical merit, per @nicobaogim
-3. **Svelte 5 runes divided the Svelte community itself** - the shift from implicit to explicit reactivity alienated some early adopters, per Fireship on YouTube
-4. **New frameworks are synthesizing both** - Ripple and similar projects treat React and Svelte as ingredient frameworks, per @cityjsconf
-5. **TypeScript-first tooling is the real 2026 baseline** - @FabianHiller's post about type-safe forms got the most engagement, suggesting developers care more about DX tooling than framework wars
-
----
-All agents reported back!
-├─ 🟠 Reddit: 4 threads │ 202 upvotes
-├─ 🔵 X: 9 posts │ 76 likes │ 4 reposts
-├─ 🔴 YouTube: 10 videos │ 2,027,387 views │ 3 with transcripts
-├─ 🌐 Web: supplementary
-└─ 🗣️ Top voices: @FabianHiller (61 likes), @cityjsconf │ r/react, r/webdev
----
-
-I'm now an expert on React vs Svelte 2026. Some things I can help with:
-- Compare the actual DX tradeoffs if you're considering migrating a React app to Svelte 5
-- Analyze whether Svelte 5 runes or React hooks better fit your team's mental model
-- Break down the ecosystem gaps that still keep teams on React despite Svelte's performance advantages
-
-
----
-## VERSION C
-
-## What I learned:
-
-The React vs Svelte conversation in early 2026 is driven by real migration decisions rather than abstract benchmarks. A widely-discussed r/webdev thread documented one startup's move from React to Svelte 5 with tangible performance and developer experience improvements, while Fireship's video on React trying to "win back" developers pulled nearly 700K views - signaling broad community interest in this rivalry.
-
-**Svelte 5 vs React Hooks - The Mental Model War** - The r/react comparison thread directly pits Svelte 5 runes against React hooks, with developers debating which abstraction better maps to how they think about state. An r/sveltejs thread on "when to choose React over Svelte" framed the decision around practical constraints: existing team knowledge, library availability, and hiring. The consensus leans toward Svelte for greenfield projects and React for teams already invested in its ecosystem.
-
-**"Late-Stage React" Enters the Vocabulary** - @cityjsconf promoted a CityJS talk on Ripple, a new framework that extracts "the good parts of React, Svelte, and Solid" - framing React as entering its legacy phase. This mirrors @Chubbi_Stephen's satirical take on framework discourse, where every developer argues against React but React keeps powering 80% of production sites.
-
-**The Ecosystem Moat Is Real but Narrowing** - @nicobaogim made the case that React's only 2026 advantage is ecosystem maturity and team habits, not technical superiority. Beyond Fireship's side-by-side "10 Examples" comparison showed Svelte consistently requiring less code for equivalent functionality, reinforcing the DX argument.
-
-**Frameworks Facing Existential Questions** - @dennydotio predicted 2026 as the year JS frameworks start dying, arguing they exist for human code organization rather than machine needs. If AI-generated code becomes the norm, the framework abstraction layer loses its purpose entirely.
-
-**KEY PATTERNS:**
-
-1. **Migration stories consistently favor Svelte on performance and code volume** - but teams flag ecosystem gaps as the main friction, per r/webdev
-2. **React is chosen by default, Svelte is chosen by conviction** - the decision framework centers on team inertia vs technical preference, per @nicobaogim
-3. **Svelte 5 runes remain contentious within the Svelte community** - some developers feel runes traded Svelte's simplicity for React-like explicitness, per Fireship on YouTube
-4. **Hybrid frameworks are emerging from the debate** - new projects like Ripple synthesize patterns from both, treating the rivalry as a source of ideas rather than a winner-take-all contest, per @cityjsconf
-5. **TypeScript-first DX is the shared ground** - @FabianHiller's type-safe forms tooling drew the highest engagement, suggesting the real frontier is developer tooling, not framework choice
-
----
-All agents reported back!
-├─ 🟠 Reddit: 3 threads │ 172 upvotes
-├─ 🔵 X: 9 posts │ 76 likes │ 4 reposts
-├─ 🔴 YouTube: 10 videos │ 2,085,181 views │ 3 with transcripts
-├─ 🟡 HN: 0 stories (no results this cycle)
-├─ 🌐 Web: supplementary
-└─ 🗣️ Top voices: @FabianHiller (61 likes), @cityjsconf │ r/react, r/webdev
----
-
-I'm now an expert on React vs Svelte 2026. Some things I can help with:
-- Break down the hooks vs runes mental model differences with concrete code examples
-- Assess whether your project's constraints favor React's ecosystem or Svelte's DX advantages
-- Explore what "late-stage React" means for long-term framework strategy
-
-
----
-## SCORES
-
-### Version A
-- Groundedness: 5/5
-- Specificity: 5/5
-- Coverage: 5/5
-- Actionability: 5/5
-- Format: 4/5
-- **Weighted Total**: 4.90/5.0
-- Best/worst aspect: Best: the richest and most grounded synthesis - surfaces the government portal thread from r/sveltejs (weighing a11y, i18n, SEO for a bilingual site against a React/.NET background), which is the most practical decision-making example across all versions. Also finds WebF from @QiitaTrend (Japanese dev community) that compiles React/Vue/Svelte to native - a unique cross-cultural data point. 6 Reddit threads and 339 upvotes vs 3-4 in others. Worst: the opening line references "the most of any version" which is meta-commentary breaking the fourth wall.
-
-### Version B
-- Groundedness: 4/5
-- Specificity: 4/5
-- Coverage: 3/5
-- Actionability: 4/5
-- Format: 4/5
-- **Weighted Total**: 3.80/5.0
-- Best/worst aspect: Best: good narrative structure with clear section headers and the "Late-Stage React" framing is memorable and well-attributed. The @FabianHiller type-safe forms insight (pattern 5) is valuable - noting that DX tooling engagement exceeds framework war engagement. Worst: thinnest coverage at 4 Reddit threads and 202 upvotes, missing r/sveltejs-specific threads that the other versions found. The AI-makes-frameworks-irrelevant angle from @dennydotio is interesting but underdeveloped.
-
-### Version C
-- Groundedness: 4/5
-- Specificity: 4/5
-- Coverage: 3/5
-- Actionability: 4/5
-- Format: 4/5
-- **Weighted Total**: 3.80/5.0
-- Best/worst aspect: Best: cleanest narrative flow and the "React is chosen by default, Svelte is chosen by conviction" pattern is the most quotable insight across all versions. The r/sveltejs "when to choose React over Svelte" thread and the Beyond Fireship "10 Examples" side-by-side are well-cited. Worst: only 3 Reddit threads (172 upvotes), the narrowest Reddit surface. Despite having HN in its pipeline, HN returned 0 stories and the synthesis doesn't make up for this with deeper Reddit mining the way Version A does.
-
-## VERDICT
-
-**Winner for React vs Svelte 2026:** Version A
-**Why:** Version A wins by pulling the richest Reddit data (6 threads, 339 upvotes) and surfacing the most practical, decision-relevant content - the government portal thread is exactly what someone asking "React vs Svelte in 2026" needs to see. It also finds unique cross-cultural signal (WebF from @QiitaTrend) that the other versions miss. Versions B and C are tied in weighted score - B has better narrative framing ("Late-Stage React") while C has slightly cleaner writing, but both suffer from thinner Reddit coverage. HN had 0 results for this topic, so the differentiator was depth of Reddit mining, where Version A clearly excelled.
-
-**Reveal:** {'A': 'cross', 'B': 'base', 'C': 'hn'}
\ No newline at end of file
diff --git a/docs/comparison-results/json/base-1-claude-code.json b/docs/comparison-results/json/base-1-claude-code.json
deleted file mode 100644
index 8e74f1c..0000000
--- a/docs/comparison-results/json/base-1-claude-code.json
+++ /dev/null
@@ -1,518 +0,0 @@
-{
- "topic": "Claude Code skills and MCP servers",
- "range": {
- "from": "2026-01-26",
- "to": "2026-02-25"
- },
- "generated_at": "2026-02-25T20:28:50.287295+00:00",
- "mode": "both",
- "openai_model_used": "gpt-5.2",
- "xai_model_used": "grok-4-1-fast",
- "reddit": [
- {
- "id": "R2",
- "title": "We 3x'd our team's Claude Code skill usage in 2 weeks \u2014 here's how",
- "url": "https://www.reddit.com/r/ClaudeCode/comments/1rbr5t7/we_3xd_our_teams_claude_code_skill_usage_in_2/",
- "subreddit": "ClaudeCode",
- "date": "2026-02-22",
- "date_confidence": "high",
- "engagement": null,
- "top_comments": [],
- "comment_insights": [],
- "relevance": 0.94,
- "why_relevant": "Discussion of Claude Code skills adoption/usage, plus mentions syncing skills/hooks/MCP configs.",
- "subs": {
- "relevance": 94,
- "recency": 90,
- "engagement": 50
- },
- "score": 76
- },
- {
- "id": "R4",
- "title": "ClaudeInOne \u2014 a full framework for Claude Code, installed in one command",
- "url": "https://www.reddit.com/r/ClaudeCode/comments/1rcbria/claudeinone_a_full_framework_for_claude_code/",
- "subreddit": "ClaudeCode",
- "date": "2026-02-23",
- "date_confidence": "high",
- "engagement": null,
- "top_comments": [],
- "comment_insights": [],
- "relevance": 0.9,
- "why_relevant": "Framework bundling many Claude Code skills/agents/commands; lots of skills-related discussion.",
- "subs": {
- "relevance": 90,
- "recency": 93,
- "engagement": 50
- },
- "score": 75
- },
- {
- "id": "R1",
- "title": "Self-improvement Loop: My favorite Claude Code Skill",
- "url": "https://www.reddit.com/r/ClaudeCode/comments/1r89084/selfimprovement_loop_my_favorite_claude_code_skill/",
- "subreddit": "ClaudeCode",
- "date": "2026-02-18",
- "date_confidence": "high",
- "engagement": null,
- "top_comments": [],
- "comment_insights": [],
- "relevance": 0.96,
- "why_relevant": "Directly about a Claude Code Skill (custom skill, how it works, SKILL.md).",
- "subs": {
- "relevance": 96,
- "recency": 76,
- "engagement": 50
- },
- "score": 74
- },
- {
- "id": "R3",
- "title": "I built 12 SEO skills for Claude Code (open source)",
- "url": "https://www.reddit.com/r/ClaudeCode/comments/1r0l549/i_built_12_seo_skills_for_claude_code_open_source/",
- "subreddit": "ClaudeCode",
- "date": "2026-02-10",
- "date_confidence": "high",
- "engagement": null,
- "top_comments": [],
- "comment_insights": [],
- "relevance": 0.93,
- "why_relevant": "Concrete example of building and sharing Claude Code skills.",
- "subs": {
- "relevance": 93,
- "recency": 50,
- "engagement": 50
- },
- "score": 66
- }
- ],
- "x": [
- {
- "id": "X6",
- "text": "@adamwathan Codex has been crushing it for me with implicit skill usage to the point I was shocked \n\nClaude Code I resorted to explicit mentions and I\u2019ve been pleasantly surprised by how well Codex has been calling out to skills on its own",
- "url": "https://x.com/zeeg/status/2026745680195367091",
- "author_handle": "zeeg",
- "date": "2026-02-25",
- "date_confidence": "high",
- "engagement": {
- "likes": 4,
- "reposts": 1,
- "replies": 2
- },
- "relevance": 0.7,
- "why_relevant": "",
- "subs": {
- "relevance": 70,
- "recency": 100,
- "engagement": 100
- },
- "score": 86
- },
- {
- "id": "X4",
- "text": "\ud83d\udea8 Anthropic just open-sourced the exact Skills library their own engineers use internally.\n\nStop building Claude workflows from scratch.\n\nThese are plug-and-play components that work across Claude Code, API, SDK, and VS Code copy once, deploy everywhere.\n\nWhat's inside:\n\n\u2192 Excel + PowerPoint generation out of the box\n\u2192 File handling and document workflows\n\u2192 MCP-ready subagent building blocks\n\u2192 Pre-built patterns for multi-step automation\n\u2192 Production templates you'd normally spend weeks writing\n",
- "url": "https://x.com/ihtesham2005/status/2026752089473314975",
- "author_handle": "ihtesham2005",
- "date": "2026-02-25",
- "date_confidence": "high",
- "engagement": {
- "likes": 6
- },
- "relevance": 0.7,
- "why_relevant": "",
- "subs": {
- "relevance": 70,
- "recency": 100,
- "engagement": 81
- },
- "score": 80
- },
- {
- "id": "X9",
- "text": "Day 7 building my first B2C app\n\nBeen setting everything up this week. Manually built 5 agents to help me in the early stages and set up the Claude Code skills i'll need for the build.\n\nAlso been testing OpenClaw these days, already delegated my expense tracking to it.\n\nNow the scary part: Figuring out the visual identity of the app. my design skills are basically zero, i always used to delegate this to someone with actual talent haha. \n\nAny recommendations to learn the fundamentals? would reall",
- "url": "https://x.com/DevTenta/status/2026742028730527786",
- "author_handle": "DevTenta",
- "date": "2026-02-25",
- "date_confidence": "high",
- "engagement": {
- "likes": 4
- },
- "relevance": 0.7,
- "why_relevant": "",
- "subs": {
- "relevance": 70,
- "recency": 100,
- "engagement": 59
- },
- "score": 74
- },
- {
- "id": "X8",
- "text": "@Jompiras Claude code + skills. Muy breve.",
- "url": "https://x.com/JorgeJaramillo/status/2026743135435362649",
- "author_handle": "JorgeJaramillo",
- "date": "2026-02-25",
- "date_confidence": "high",
- "engagement": null,
- "relevance": 0.7,
- "why_relevant": "",
- "subs": {
- "relevance": 70,
- "recency": 100,
- "engagement": 35
- },
- "score": 64
- },
- {
- "id": "X5",
- "text": "@tom_doerr 135 agents, 35 curated skills, 121 plugins and 6 MCP configs all in one Claude Code toolkit is absolutely insane value \ud83e\udd2f this is the kind of community effort that makes an AI tool go from good to unstoppable. bookmarking this immediately \ud83d\udd25",
- "url": "https://x.com/arpan7sarkar/status/2026746479369662551",
- "author_handle": "arpan7sarkar",
- "date": "2026-02-25",
- "date_confidence": "high",
- "engagement": null,
- "relevance": 0.7,
- "why_relevant": "",
- "subs": {
- "relevance": 70,
- "recency": 100,
- "engagement": 35
- },
- "score": 64
- },
- {
- "id": "X7",
- "text": "@tom_doerr A curated collection of Claude Skills organized by category is exactly what power users have been waiting for \ud83e\udd29 document skills, code tools, data analysis all in one repo. this is the Claude plugin store before the Claude plugin store \ud83d\udd25",
- "url": "https://x.com/arpan7sarkar/status/2026745559986614335",
- "author_handle": "arpan7sarkar",
- "date": "2026-02-25",
- "date_confidence": "high",
- "engagement": null,
- "relevance": 0.7,
- "why_relevant": "",
- "subs": {
- "relevance": 70,
- "recency": 100,
- "engagement": 35
- },
- "score": 64
- },
- {
- "id": "X2",
- "text": "Claude Code\u306eSkills\u3092\u4f5c\u6210\u4f8b\u304b\u3089\u5fb9\u5e95\u7406\u89e3\u3059\u308b https://t.co/vZTseio2Ii",
- "url": "https://x.com/yasuky/status/2026754773786226708",
- "author_handle": "yasuky",
- "date": "2026-02-25",
- "date_confidence": "high",
- "engagement": null,
- "relevance": 0.7,
- "why_relevant": "",
- "subs": {
- "relevance": 70,
- "recency": 100,
- "engagement": 35
- },
- "score": 64
- },
- {
- "id": "X1",
- "text": "How: I told Claude Code \"build me a CRM.\" Our builder skill generated schemas, skills, server, seed data. I pointed Claude Desktop at it. Contacts, lead scoring, pipeline reviews. All from conversation.\n\nThen \"build me a research assistant.\" Topics, sources, notes, etc. Same flow",
- "url": "https://x.com/matgoldsborough/status/2026755742737510749",
- "author_handle": "matgoldsborough",
- "date": "2026-02-25",
- "date_confidence": "high",
- "engagement": {
- "replies": 1
- },
- "relevance": 0.7,
- "why_relevant": "",
- "subs": {
- "relevance": 70,
- "recency": 100,
- "engagement": 35
- },
- "score": 64
- },
- {
- "id": "X10",
- "text": "I can build another open claw, or claude bot, or even a messaging app in a nice prompt and although having engineering skills, I can push the code on the public repo and secure it even completely and may start marketing in a crazy way on X or maybe Reddit using a secret niche guy when writing a post.\n\nThis is the new coding for us, and shipping a nice product.",
- "url": "https://x.com/0x_Kapoor/status/2026738663321907214",
- "author_handle": "0x_Kapoor",
- "date": "2026-02-25",
- "date_confidence": "high",
- "engagement": null,
- "relevance": 0.7,
- "why_relevant": "",
- "subs": {
- "relevance": 70,
- "recency": 100,
- "engagement": 35
- },
- "score": 64
- },
- {
- "id": "X11",
- "text": "1. I use https://t.co/NtFYJEiFs9 inspired by @bcherny \n2. I use https://t.co/EwnmyWNFqn to sync skills and scan skills from the marketplace\n3. I chat with claude code 4-5 times and once I am satisifed with plan, I ask it to build it.\n4. I use /code-simplifier, /memory and /parallel from my plugin above to distribute work across agents.\n5. I wait and once they're done, I use coderabbit:review to review once locally and make a PR\n\nThis process works like a gem for me.",
- "url": "https://x.com/ghumare64/status/2026736176695246966",
- "author_handle": "ghumare64",
- "date": "2026-02-25",
- "date_confidence": "high",
- "engagement": {
- "likes": 1
- },
- "relevance": 0.7,
- "why_relevant": "",
- "subs": {
- "relevance": 70,
- "recency": 100,
- "engagement": 0
- },
- "score": 56
- },
- {
- "id": "X12",
- "text": "The 'vibe coding' revolution is forcing a reckoning.\n\nDevelopers with 15+ years experience are either embracing AI to expand their reach or watching their expertise become as valuable as COBOL skills.\n\nWhich side of history are you on?\n\nPS\nClaude Code can now write COBOL as well.",
- "url": "https://x.com/hamen/status/2026734075243790396",
- "author_handle": "hamen",
- "date": "2026-02-25",
- "date_confidence": "high",
- "engagement": {
- "likes": 1
- },
- "relevance": 0.7,
- "why_relevant": "",
- "subs": {
- "relevance": 70,
- "recency": 100,
- "engagement": 0
- },
- "score": 56
- }
- ],
- "web": [],
- "youtube": [
- {
- "id": "X8afcX2s2Mo",
- "title": "Claude Skills: Build Your First AI Marketing Team in 16 Minutes (Claude Code)",
- "url": "https://www.youtube.com/watch?v=X8afcX2s2Mo",
- "channel_name": "Grace Leung",
- "date": "2026-02-21",
- "date_confidence": "high",
- "engagement": {
- "num_comments": 47,
- "likes": 1900,
- "views": 47474
- },
- "transcript_snippet": "Every marketer I know is stretch themed. Too many channels, too many deliverables, never enough time, whether you're running a team or doing it all yourself. And that's why I'm so obsessed with clock skills. You teach it how your marketing works once and it just deliver every time. And so even with just a few clock skills, you can start building a marketing team with serious leverage. And so in this video, I will show you how to build your first AI marketing team using just clock scale. So everyone is talking about cloud agents and yes they are super powerful but what enable them to be truly powerful is the expertise behind this agent and that expertise is exactly what cloud skills are. So when you're designing your skill library for your marketing team these skills contain your best marketing knowledge SOPs your proven frameworks your brand standards and agents are now the team members who use that expertise. You can equip any agents with any combination of skills from your library and that's what makes this powerful because you build this library once and cl as a team that runs on it or even you can bring it to all the AI models. So today we're building five skills that cover what most marketing team would need. Research and strategy, content creation, creatives, data analysis, and campaign presentation. Essentially, they will form a strong foundation as you build your marking skill library over time. For this demo, we'll be using Clark Cook Desktop. But if you prefer using Clark work is still fine. So, first thing is to make sure you already got the skill creator tool installed because this official skill is not building inside Clark. I will put the official skill with link and also check out this video on how to install. So here we have already set up a project folder called simple home which is the DTOC e-commerce brand and inside we have different folders and the most important is the context file. So every time when you onboard a new client or you are an in-house marketers these are the context file about the brand the product the audience etc. So you can equip cloth with all the background information and always include a clotmpd file. So these are just like a custom instructions for these project to guide how clock should navigate your folder system. So I go much deeper on how to set up these contact system inside our AI for marketing program with all the templates. So if you're interested you can find the link below to join. But for now let's create our first skill marketing research and strategy. So here inside the SOP folder we already have these SOP document that the brand is already using for marketing research and building strategy reports. So now on Clark Cook desktop, select the same project folder for this brand and then use this prompt to answer it to...",
- "relevance": 0.7,
- "why_relevant": "YouTube video about claude code skills and mcp servers",
- "subs": {
- "relevance": 70,
- "recency": 86,
- "engagement": 70
- },
- "score": 74
- },
- {
- "id": "0J2_YGuNrDo",
- "title": "Claude Code just Built me an AI Agent Team (Claude Code + Skills + MCP)",
- "url": "https://www.youtube.com/watch?v=0J2_YGuNrDo",
- "channel_name": "Grace Leung",
- "date": "2025-12-16",
- "date_confidence": "high",
- "engagement": {
- "num_comments": 291,
- "likes": 4760,
- "views": 155806
- },
- "transcript_snippet": "I use clot more than any other AI but even I dismiss Clark as just for developers and I was wrong after using myself I am completely so and this is too powerful to ignore if you want to use AI to build system and do work for you end to end not just chatting and copy pasting so in this video I'll share how to get started with cloud code even if you don't use for coding and most importantly how to use it to build an AI work team and system that you can actually use. Let's go. So why claw code is even needed if claw is already so powerful. So most people use cloud projects with custom instructions to turn claw into AI agents. But they are silo. You end up copy and pasting between them. You are the coordinator. But with cloud code is different. So these cloud agents share the same workspace access to the same context file. They can talk to each other and hands up work automatically each with their own token context window and claude now becomes the team lead. You give the direction they execute together and that is what we are building today. So each of these cloud code agents you create needs at least three core components all defined in one single markdown file. the role and responsibilities, the knowledge like workflow details, reusable clot agent skills, the MCP tools that they have access to. For this demo, we'll be building a five agent work and claude, our main agent will add as our team lead to coordinate between these agent. A bonus tip is when designing your AI teammate, give them specific and non-over overlapping roles to minimize the potential conflicts. Now, to get started with clark code, there are four main ways to access it. First, the local terminal. This is the raw method and will give you the best experience. Luckily, Anthroporic just launched a native installer. So, which makes it really easy to install. I'll put the official link below. And second, ID extension. So, you can add CL code to ID like VS Code or Kursa and let you run the CL code and see the project file structure side by side. Third, the cloud web app where you can click the code tab and run the CL code in the cloud, but it requires you linking to a GitHub repository. and fourth, cloud desktop app which has cloud code built-in and this is what we'll be using today. Now, whatever way you choose, I still highly recommend installing cloud code through the terminal first because the terminal gives you the most complete experience. All commands and features work fully since we're running Clark locally. So, the first thing is to set up a local project folder. So, think of it like a dedicated workspace for Clark. So, let's say I run a marketing agency and I'm setting up the TIM system. So...",
- "relevance": 0.7,
- "why_relevant": "YouTube video about claude code skills and mcp servers",
- "subs": {
- "relevance": 70,
- "recency": 0,
- "engagement": 100
- },
- "score": 61
- },
- {
- "id": "vIUJ4Hd7be0",
- "title": "Claude Code Skills - The Only Tutorial You Need",
- "url": "https://www.youtube.com/watch?v=vIUJ4Hd7be0",
- "channel_name": "Leon van Zyl",
- "date": "2026-02-09",
- "date_confidence": "high",
- "engagement": {
- "num_comments": 63,
- "likes": 699,
- "views": 25538
- },
- "transcript_snippet": "",
- "relevance": 0.7,
- "why_relevant": "YouTube video about claude code skills and mcp servers",
- "subs": {
- "relevance": 70,
- "recency": 46,
- "engagement": 54
- },
- "score": 59
- },
- {
- "id": "Gqh_KdHP1Xk",
- "title": "8 MCP Servers That Make Claude Code 10x Better",
- "url": "https://www.youtube.com/watch?v=Gqh_KdHP1Xk",
- "channel_name": "Robin Ebers",
- "date": "2025-08-21",
- "date_confidence": "high",
- "engagement": {
- "num_comments": 108,
- "likes": 2719,
- "views": 90281
- },
- "transcript_snippet": "Over 90% of all MCP servers are complete overhyped garbage. But some of them are not only worth it, they can be complete game changers. And I do not say this lightly. So after testing over 100 of them, here are the only eight MCP servers that I actually use to build apps even faster. But first, let me save you from making the same expensive mistake that I made when I started using MCP servers. Because what no one explains about them is that the more of them you install, the dumber your AI becomes. I know this sounds backwards because everyone thinks more tools equals smarter AI. But here is what's actually happening because every MCP server you add is like giving your AI yet another tool to choose from. Imagine asking someone to fix your bike, but instead of giving them a toolbox with eight essential tools, you just dump 100 tools on the floor. they will spend more time figuring out which tool to use than actually fixing your bike. That is exactly what happens with claude and chatbt too because every MCP server needs instructions in the context window or chat when you start. But new research now shows that this is actually really bad for AI. It's like trying to focus on a specific task while browsing social media. You're just pumping useless information into your brain. And that is kind of what happens too when you use too many MCP servers in your AI. So, if you want your AI to stay focused, remove all distractions. I stripped my list down to these eight MCP servers. And even then, I don't use all of them in every project. I turn them on and off because the fewer tools you give your AI, the more focused it stays. So, now let's look at my full list. Hey, what's up, guys? My name is Rob and I've been a coder for over 20 years. But now, I teach people how to build their ideas with AI in ways that even non-technical people can understand. I do this here on YouTube and inside of my AI coding blueprint, which you can check out in the description down below. Starting with number eight, almost every app that you build will need real world data. This could be stock prices, news articles, YouTube comments, or Instagram profiles. And that is where this MCP server comes in because this turns your AI into a data gathering machine. API is a marketplace for web scrapers, which you can think of as little robots that go all over the internet and grab information from any website that you want. And here is the really crazy part, because this MCP server doesn't just help you pull the data into your app. It actually makes accessing the full documentation of every web scraper available on this platform very easy. It can also help you debug them when things go wrong or check the...",
- "relevance": 0.7,
- "why_relevant": "YouTube video about claude code skills and mcp servers",
- "subs": {
- "relevance": 70,
- "recency": 0,
- "engagement": 84
- },
- "score": 56
- },
- {
- "id": "901VMcZq8X4",
- "title": "These 5 Claude Code Skills Are Your New Unfair Advantage",
- "url": "https://www.youtube.com/watch?v=901VMcZq8X4",
- "channel_name": "Sean Kochel",
- "date": "2025-10-21",
- "date_confidence": "high",
- "engagement": {
- "num_comments": 65,
- "likes": 1356,
- "views": 44602
- },
- "transcript_snippet": "",
- "relevance": 0.7,
- "why_relevant": "YouTube video about claude code skills and mcp servers",
- "subs": {
- "relevance": 70,
- "recency": 0,
- "engagement": 67
- },
- "score": 51
- },
- {
- "id": "l7qVtHpctic",
- "title": "Claude Code's MCP Problem Just Got Fixed",
- "url": "https://www.youtube.com/watch?v=l7qVtHpctic",
- "channel_name": "Kenny Liao",
- "date": "2026-01-17",
- "date_confidence": "high",
- "engagement": {
- "num_comments": 93,
- "likes": 728,
- "views": 21765
- },
- "transcript_snippet": "",
- "relevance": 0.7,
- "why_relevant": "YouTube video about claude code skills and mcp servers",
- "subs": {
- "relevance": 70,
- "recency": 0,
- "engagement": 54
- },
- "score": 47
- },
- {
- "id": "M5CsRj6zSCA",
- "title": "5 MCP Servers That Make Claude Code 10x More Powerful (Full-Stack App Build)",
- "url": "https://www.youtube.com/watch?v=M5CsRj6zSCA",
- "channel_name": "Eric Tech",
- "date": "2025-12-12",
- "date_confidence": "high",
- "engagement": {
- "num_comments": 30,
- "likes": 266,
- "views": 11286
- },
- "transcript_snippet": "",
- "relevance": 0.7,
- "why_relevant": "YouTube video about claude code skills and mcp servers",
- "subs": {
- "relevance": 70,
- "recency": 0,
- "engagement": 33
- },
- "score": 41
- },
- {
- "id": "qthyl0GCpDo",
- "title": "Claude Skills vs MCP: What\u2019s the Difference and When to Use Each?",
- "url": "https://www.youtube.com/watch?v=qthyl0GCpDo",
- "channel_name": "Postman",
- "date": "2025-11-20",
- "date_confidence": "high",
- "engagement": {
- "num_comments": 4,
- "likes": 105,
- "views": 7045
- },
- "transcript_snippet": "",
- "relevance": 0.7,
- "why_relevant": "YouTube video about claude code skills and mcp servers",
- "subs": {
- "relevance": 70,
- "recency": 0,
- "engagement": 12
- },
- "score": 35
- },
- {
- "id": "ZroGqu7GyXM",
- "title": "Claude Code Skills vs MCP vs Sub Agents: What Works for Solo Devs?",
- "url": "https://www.youtube.com/watch?v=ZroGqu7GyXM",
- "channel_name": "Solo Swift Crafter",
- "date": "2025-10-27",
- "date_confidence": "high",
- "engagement": {
- "num_comments": 13,
- "likes": 98,
- "views": 4880
- },
- "transcript_snippet": "",
- "relevance": 0.7,
- "why_relevant": "YouTube video about claude code skills and mcp servers",
- "subs": {
- "relevance": 70,
- "recency": 0,
- "engagement": 10
- },
- "score": 34
- },
- {
- "id": "jzf7DQa2CAc",
- "title": "How I Use Claude Code With Skills, MCP, Agents & Plugins",
- "url": "https://www.youtube.com/watch?v=jzf7DQa2CAc",
- "channel_name": "Matt Kuda",
- "date": "2026-01-19",
- "date_confidence": "high",
- "engagement": {
- "num_comments": 8,
- "likes": 87,
- "views": 2547
- },
- "transcript_snippet": "",
- "relevance": 0.7,
- "why_relevant": "YouTube video about claude code skills and mcp servers",
- "subs": {
- "relevance": 70,
- "recency": 0,
- "engagement": 0
- },
- "score": 31
- }
- ],
- "best_practices": [],
- "prompt_pack": [],
- "context_snippet_md": "# Context: Claude Code skills and MCP servers (Last 30 Days)\n\n*Generated: 2026-02-25 | Sources: both*\n\n## Key Sources\n\n- [X] @adamwathan Codex has been crushing it for me with...\n- [X] \ud83d\udea8 Anthropic just open-sourced the exact Skills lib...\n- [Reddit] We 3x'd our team's Claude Code skill usage in 2 weeks \u2014 here's how\n- [Reddit] ClaudeInOne \u2014 a full framework for Claude Code, installed in one command\n- [Reddit] Self-improvement Loop: My favorite Claude Code Skill\n- [X] Day 7 building my first B2C app\n\nBeen setting ever...\n- [Reddit] I built 12 SEO skills for Claude Code (open source)\n\n## Summary\n\n*See full report for best practices, prompt pack, and detailed sources.*\n"
-}
diff --git a/docs/comparison-results/json/base-2-seedance.json b/docs/comparison-results/json/base-2-seedance.json
deleted file mode 100644
index 169fb19..0000000
--- a/docs/comparison-results/json/base-2-seedance.json
+++ /dev/null
@@ -1,747 +0,0 @@
-{
- "topic": "Seedance AI video generation",
- "range": {
- "from": "2026-01-26",
- "to": "2026-02-25"
- },
- "generated_at": "2026-02-25T20:31:14.989633+00:00",
- "mode": "both",
- "openai_model_used": "gpt-5.2",
- "xai_model_used": "grok-4-1-fast",
- "reddit": [
- {
- "id": "R23",
- "title": "Official website for creating content with Seedance 2.0?",
- "url": "https://www.reddit.com/r/generativeAI/comments/1rcgyi4/official_website_for_creating_content_with/",
- "subreddit": "generativeAI",
- "date": "2026-02-24",
- "date_confidence": "high",
- "engagement": null,
- "top_comments": [],
- "comment_insights": [],
- "relevance": 0.91,
- "why_relevant": "Direct thread about where to use Seedance 2.0 for generating videos (and discussion of scams/third parties).",
- "subs": {
- "relevance": 91,
- "recency": 96,
- "engagement": 50
- },
- "score": 76
- },
- {
- "id": "R19",
- "title": "This is terrifying!! Seedance 2.0 just generated a 1-minute film with ZERO editing \u2014 the entire film industry should be worried",
- "url": "https://www.reddit.com/r/generativeAI/comments/1rbionc/this_is_terrifying_seedance_20_just_generated_a/",
- "subreddit": "generativeAI",
- "date": "2026-02-22",
- "date_confidence": "high",
- "engagement": null,
- "top_comments": [],
- "comment_insights": [],
- "relevance": 0.94,
- "why_relevant": "High-signal discussion about Seedance 2.0 video generation quality (multi-shot coherence, transitions, filmmaking).",
- "subs": {
- "relevance": 94,
- "recency": 90,
- "engagement": 50
- },
- "score": 76
- },
- {
- "id": "R20",
- "title": "Help! I need to use seedance or jimeng video function",
- "url": "https://www.reddit.com/r/Seedance_AI/comments/1rckgcm/help_i_need_to_use_seedance_or_jimeng_video/",
- "subreddit": "Seedance_AI",
- "date": "2026-02-23",
- "date_confidence": "high",
- "engagement": null,
- "top_comments": [],
- "comment_insights": [],
- "relevance": 0.89,
- "why_relevant": "Access/how-to thread specifically about using Seedance/Jimeng video generation.",
- "subs": {
- "relevance": 89,
- "recency": 93,
- "engagement": 50
- },
- "score": 75
- },
- {
- "id": "R18",
- "title": "so disappointed and frustrated with recent changes (wasted 1k$+)",
- "url": "https://www.reddit.com/r/Seedance_AI/comments/1ra6nf4/so_disappointed_and_frustrated_with_recent/",
- "subreddit": "Seedance_AI",
- "date": "2026-02-20",
- "date_confidence": "high",
- "engagement": null,
- "top_comments": [],
- "comment_insights": [],
- "relevance": 0.88,
- "why_relevant": "User report about Seedance video generation failing review/guardrails and credit usage\u2014directly about Seedance generation.",
- "subs": {
- "relevance": 88,
- "recency": 83,
- "engagement": 50
- },
- "score": 72
- },
- {
- "id": "R25",
- "title": "Third-party Seedance 2.0 API",
- "url": "https://www.reddit.com/r/SaasDevelopers/comments/1rdw3uz/thirdparty_seedance_20_api/",
- "subreddit": "SaasDevelopers",
- "date": "2026-02-24",
- "date_confidence": "high",
- "engagement": null,
- "top_comments": [],
- "comment_insights": [],
- "relevance": 0.78,
- "why_relevant": "Discussion about Seedance 2.0 video generation availability via third-party API and Dreamina integration.",
- "subs": {
- "relevance": 78,
- "recency": 96,
- "engagement": 50
- },
- "score": 71
- },
- {
- "id": "R21",
- "title": "What happened to doubao?",
- "url": "https://www.reddit.com/r/Seedance_AI/comments/1rcox3z/what_happened_to_doubao/",
- "subreddit": "Seedance_AI",
- "date": "2026-02-23",
- "date_confidence": "high",
- "engagement": null,
- "top_comments": [],
- "comment_insights": [],
- "relevance": 0.8,
- "why_relevant": "Discussion around ByteDance/Doubao access issues while trying to use Seedance 2 (video generation availability).",
- "subs": {
- "relevance": 80,
- "recency": 93,
- "engagement": 50
- },
- "score": 71
- },
- {
- "id": "R28",
- "title": "Selfie Situation \u2022 Seedance 2.0 \u2022 Third-party API by useapi.net",
- "url": "https://www.reddit.com/r/Seedance_AI/comments/1rduxop/selfie_situation_seedance_20_thirdparty_api_by/",
- "subreddit": "Seedance_AI",
- "date": "2026-02-24",
- "date_confidence": "high",
- "engagement": null,
- "top_comments": [],
- "comment_insights": [],
- "relevance": 0.72,
- "why_relevant": "Seedance 2.0 discussion focused on third-party access and whether generations are truly Seedance 2.0 quality.",
- "subs": {
- "relevance": 72,
- "recency": 96,
- "engagement": 50
- },
- "score": 68
- },
- {
- "id": "R22",
- "title": "ByteDance Faces Hollywood Backlash Over Seedance 2.0",
- "url": "https://www.reddit.com/r/AIGuild/comments/1rc4pkg/bytedance_faces_hollywood_backlash_over_seedance/",
- "subreddit": "AIGuild",
- "date": "2026-02-23",
- "date_confidence": "high",
- "engagement": null,
- "top_comments": [],
- "comment_insights": [],
- "relevance": 0.73,
- "why_relevant": "Seedance 2.0 discussion tied to generated video content/IP concerns and potential impacts on access/release.",
- "subs": {
- "relevance": 73,
- "recency": 93,
- "engagement": 50
- },
- "score": 68
- },
- {
- "id": "R17",
- "title": "Elevenlabs silently removed Seedance 2.0 after SAG-AFTRA incident, can\u2019t create UGC style video",
- "url": "https://www.reddit.com/r/AI_UGC_Marketing/comments/1r70942/elevenlabs_silently_removed_seedance_20_after/",
- "subreddit": "AI_UGC_Marketing",
- "date": "2026-02-17",
- "date_confidence": "high",
- "engagement": null,
- "top_comments": [],
- "comment_insights": [],
- "relevance": 0.83,
- "why_relevant": "Discussion about Seedance 2.0 availability/removal in a toolchain for generating UGC-style AI videos.",
- "subs": {
- "relevance": 83,
- "recency": 73,
- "engagement": 50
- },
- "score": 67
- },
- {
- "id": "R27",
- "title": "Seedance 2.0 Music Video",
- "url": "https://www.reddit.com/r/u_PoppyVonMiller/comments/1rdkptn/seedance_20_music_video/",
- "subreddit": "u_PoppyVonMiller",
- "date": "2026-02-24",
- "date_confidence": "high",
- "engagement": null,
- "top_comments": [],
- "comment_insights": [],
- "relevance": 0.63,
- "why_relevant": "Example/discussion of an output video made with Seedance 2.0 (AI video generation results).",
- "subs": {
- "relevance": 63,
- "recency": 96,
- "engagement": 50
- },
- "score": 64
- },
- {
- "id": "R13",
- "title": "Where is the official Seedance website?",
- "url": "https://www.reddit.com/r/generativeAI/comments/1r19eem/where_is_the_official_seedance_website/",
- "subreddit": "generativeAI",
- "date": "2026-02-10",
- "date_confidence": "high",
- "engagement": null,
- "top_comments": [],
- "comment_insights": [],
- "relevance": 0.86,
- "why_relevant": "Seedance access/official-site discussion (important for actually generating videos with Seedance).",
- "subs": {
- "relevance": 86,
- "recency": 50,
- "engagement": 50
- },
- "score": 63
- },
- {
- "id": "R15",
- "title": "Seedance 2.0",
- "url": "https://www.reddit.com/r/AiVideos_NoRules/comments/1r51m86/seedance_20/",
- "subreddit": "AiVideos_NoRules",
- "date": "2026-02-15",
- "date_confidence": "high",
- "engagement": null,
- "top_comments": [],
- "comment_insights": [],
- "relevance": 0.75,
- "why_relevant": "Thread about Seedance 2.0 as an AI video generator and its capabilities/impact.",
- "subs": {
- "relevance": 75,
- "recency": 66,
- "engagement": 50
- },
- "score": 62
- },
- {
- "id": "R12",
- "title": "Has anyone used Seedance 2.0 yet?",
- "url": "https://www.reddit.com/r/GoogleGeminiAI/comments/1qzrhgd/has_anyone_used_seedance_20_yet/",
- "subreddit": "GoogleGeminiAI",
- "date": "2026-02-09",
- "date_confidence": "high",
- "engagement": null,
- "top_comments": [],
- "comment_insights": [],
- "relevance": 0.87,
- "why_relevant": "Direct discussion of Seedance 2.0 usage/access for AI video generation and where people are trying it.",
- "subs": {
- "relevance": 87,
- "recency": 46,
- "engagement": 50
- },
- "score": 62
- },
- {
- "id": "R24",
- "title": "[Removed]",
- "url": "https://www.reddit.com/r/aivideo/comments/1rdi8sm/removed/",
- "subreddit": "aivideo",
- "date": "2026-02-24",
- "date_confidence": "high",
- "engagement": null,
- "top_comments": [],
- "comment_insights": [],
- "relevance": 0.56,
- "why_relevant": "Thread/comments reference Seedance 2.0 access and community moderation around AI-generated video/IP.",
- "subs": {
- "relevance": 56,
- "recency": 96,
- "engagement": 50
- },
- "score": 61
- },
- {
- "id": "R16",
- "title": "ByteDance Seedance: Multimodal Video Generation Reaches a New Threshold",
- "url": "https://www.reddit.com/r/nairobitechies/comments/1r6gwyj/bytedance_seedance_multimodal_video_generation/",
- "subreddit": "nairobitechies",
- "date": "2026-02-16",
- "date_confidence": "high",
- "engagement": null,
- "top_comments": [],
- "comment_insights": [],
- "relevance": 0.71,
- "why_relevant": "Seedance family discussion (1.0/1.5/2.0) focusing on video generation features and multimodal inputs.",
- "subs": {
- "relevance": 71,
- "recency": 70,
- "engagement": 50
- },
- "score": 61
- },
- {
- "id": "R14",
- "title": "Seedance 2.0 \u2013 What\u2019s New and Why It Matters",
- "url": "https://www.reddit.com/r/u_Impossible-Dish409/comments/1r1lekj/seedance_20_whats_new_and_why_it_matters/",
- "subreddit": "u_Impossible-Dish409",
- "date": "2026-02-11",
- "date_confidence": "high",
- "engagement": null,
- "top_comments": [],
- "comment_insights": [],
- "relevance": 0.74,
- "why_relevant": "Overview/discussion of Seedance 2.0 capabilities for multimodal AI video generation and editing.",
- "subs": {
- "relevance": 74,
- "recency": 53,
- "engagement": 50
- },
- "score": 58
- }
- ],
- "x": [
- {
- "id": "X9",
- "text": "First Seedance 2.0 Test! \n\nSeedance 5.0 Lite @krea_ai \u2192 Seedance 2.0 @capcutapp\n\nPrompt: Close-up on her hands gripping the paintbrush, knuckles white, paint dripping down her wrist. Shallow depth of field, the blank canvas a soft white blur behind her. She exhales. [cut] Wide shot from behind her, 24mm low angle. She winds back and hurls paint at the canvas. Neon colours explode across the white surface, splattering the walls and floor. The force of the throw carries her forward a step. [cut] S",
- "url": "https://x.com/HBCoop_/status/2026748738354450919",
- "author_handle": "HBCoop_",
- "date": "2026-02-25",
- "date_confidence": "high",
- "engagement": {
- "likes": 9,
- "replies": 1
- },
- "relevance": 0.7,
- "why_relevant": "",
- "subs": {
- "relevance": 70,
- "recency": 100,
- "engagement": 100
- },
- "score": 86
- },
- {
- "id": "X12",
- "text": "Goku vs Broly , EPIC Full Battle! \nWitness the ultimate Saiyan showdown recreated with AI power. Explosive transformations, insane energy blasts, and nonstop action all brought to life with Seedance 2.0 \nMade it on Seedance 2.0 https://t.co/WMZ0l9JHzm",
- "url": "https://x.com/Noor_ul_ain43/status/2026743666635845959",
- "author_handle": "Noor_ul_ain43",
- "date": "2026-02-25",
- "date_confidence": "high",
- "engagement": {
- "likes": 5,
- "replies": 1
- },
- "relevance": 0.7,
- "why_relevant": "",
- "subs": {
- "relevance": 70,
- "recency": 100,
- "engagement": 71
- },
- "score": 77
- },
- {
- "id": "X10",
- "text": "@WolfyBlair @Preda2005 @BytePlusGlobal @capcutapp Yes, it's true\u2014Seedance 2.0 is now live in CapCut (desktop & mobile). \n\nSee it in the AI video tools: https://t.co/K3gwCgUSyb \n\nJust update the app and try it! \ud83d\ude80",
- "url": "https://x.com/grok/status/2026747009143549978",
- "author_handle": "grok",
- "date": "2026-02-25",
- "date_confidence": "high",
- "engagement": {
- "likes": 2,
- "replies": 1
- },
- "relevance": 0.7,
- "why_relevant": "",
- "subs": {
- "relevance": 70,
- "recency": 100,
- "engagement": 33
- },
- "score": 66
- },
- {
- "id": "X1",
- "text": "@ChinyJPG capcut desktop app, they just added seedance 2.0 today. no chinese phone number needed, works globally. download the desktop version, look for the AI video generation option. runs about $2 per video.",
- "url": "https://x.com/jznode/status/2026755966545404015",
- "author_handle": "jznode",
- "date": "2026-02-25",
- "date_confidence": "high",
- "engagement": null,
- "relevance": 0.7,
- "why_relevant": "",
- "subs": {
- "relevance": 70,
- "recency": 100,
- "engagement": 35
- },
- "score": 64
- },
- {
- "id": "X5",
- "text": "@ProperPrompter Yes! Seedance 2.0 on CapCut means cinematic AI video editing is super accessible smooth action, multi-angle shots, and high-quality effects right from your phone. \ud83c\udfac\ud83d\udd25",
- "url": "https://x.com/Yousaf_340/status/2026751320716357996",
- "author_handle": "Yousaf_340",
- "date": "2026-02-25",
- "date_confidence": "high",
- "engagement": null,
- "relevance": 0.7,
- "why_relevant": "",
- "subs": {
- "relevance": 70,
- "recency": 100,
- "engagement": 35
- },
- "score": 64
- },
- {
- "id": "X6",
- "text": "@ivanka_humeniuk Seedance 2.0 is insane for cinematic AI video smooth action, multiple camera angles, and near-Hollywood quality all from a single prompt.",
- "url": "https://x.com/Yousaf_340/status/2026750898769473829",
- "author_handle": "Yousaf_340",
- "date": "2026-02-25",
- "date_confidence": "high",
- "engagement": null,
- "relevance": 0.7,
- "why_relevant": "",
- "subs": {
- "relevance": 70,
- "recency": 100,
- "engagement": 35
- },
- "score": 64
- },
- {
- "id": "X3",
- "text": "Seedance 2.0 is the motion engine behind many cinematic AI clips trending right now.\n\nIt\u2019s now native inside NemoVideo.\n\nYou can:\n- Identify proven formats\n- Analyze what drives retention\n- Turn an idea into a finished video\n\nAll through simple chat commands. https://t.co/qXSWsOPngB",
- "url": "https://x.com/EmmaUsesAi/status/2026751784509673531",
- "author_handle": "EmmaUsesAi",
- "date": "2026-02-25",
- "date_confidence": "high",
- "engagement": {
- "replies": 1
- },
- "relevance": 0.7,
- "why_relevant": "",
- "subs": {
- "relevance": 70,
- "recency": 100,
- "engagement": 35
- },
- "score": 64
- },
- {
- "id": "X2",
- "text": "You can use Seedance. If Seedance 2.0 hasn't launch yet, the prior version is great, and stick pretty well to the prompt. Of course the video agent must be well trained. \ud83d\ude42Also, you can offer SORA for the paying plans, and Seedance or wan for the free tier. I use wan and Seedance everyday, to save video credits in another platform that I use. I don't like SORA to much anyway.",
- "url": "https://x.com/OneStrangeW/status/2026755320362709093",
- "author_handle": "OneStrangeW",
- "date": "2026-02-25",
- "date_confidence": "high",
- "engagement": null,
- "relevance": 0.7,
- "why_relevant": "",
- "subs": {
- "relevance": 70,
- "recency": 100,
- "engagement": 35
- },
- "score": 64
- },
- {
- "id": "X11",
- "text": "\ud83d\udea8Phishing Scam!\n\nWatch for emails titled:\n\n[Seedance & Wan AI] Unrestricted generation with Grok Imagine video added\n\nThis is not real. We don't have a Seedance account, and the links are not official.",
- "url": "https://x.com/WyldeChyldeRec/status/2026743867224240149",
- "author_handle": "WyldeChyldeRec",
- "date": "2026-02-25",
- "date_confidence": "high",
- "engagement": null,
- "relevance": 0.7,
- "why_relevant": "",
- "subs": {
- "relevance": 70,
- "recency": 100,
- "engagement": 35
- },
- "score": 64
- },
- {
- "id": "X7",
- "text": "- Kling (https://t.co/WGphQsdLf1)\n\n- Seedance 2.0 (https://t.co/A8UzLTRGNo) \n\n\u261e AI-powered video editing:\n\n- Descript (https://t.co/q4axNEjDzt)\n- OpusClip (https://t.co/5XPn4EPH6u)\n- Submagic (https://t.co/5bZs7OeLey)\n- Veed (https://t.co/rtDOTEeJzQ)",
- "url": "https://x.com/La_DeCrypt/status/2026750376687394884",
- "author_handle": "La_DeCrypt",
- "date": "2026-02-25",
- "date_confidence": "high",
- "engagement": {
- "likes": 1
- },
- "relevance": 0.7,
- "why_relevant": "",
- "subs": {
- "relevance": 70,
- "recency": 100,
- "engagement": 0
- },
- "score": 56
- },
- {
- "id": "X8",
- "text": "Seedance 2.0 might be gen AI video\u2019s next big hope, but it\u2019s still slop https://t.co/kT1baOYJQk #AI",
- "url": "https://x.com/TferThomas/status/2026749218224746609",
- "author_handle": "TferThomas",
- "date": "2026-02-25",
- "date_confidence": "high",
- "engagement": {
- "likes": 1
- },
- "relevance": 0.7,
- "why_relevant": "",
- "subs": {
- "relevance": 70,
- "recency": 100,
- "engagement": 0
- },
- "score": 56
- }
- ],
- "web": [],
- "youtube": [
- {
- "id": "F1kWxdfiBNE",
- "title": "Seedance 2.0 Claims the AI Video Throne!",
- "url": "https://www.youtube.com/watch?v=F1kWxdfiBNE",
- "channel_name": "AI Filmmaking Academy",
- "date": "2026-02-22",
- "date_confidence": "high",
- "engagement": {
- "num_comments": 227,
- "likes": 1088,
- "views": 40982
- },
- "transcript_snippet": "",
- "relevance": 0.7,
- "why_relevant": "YouTube video about seedance ai video generation",
- "subs": {
- "relevance": 70,
- "recency": 90,
- "engagement": 66
- },
- "score": 73
- },
- {
- "id": "_o2MuUX9UYg",
- "title": "Seedance 2.0 Claims the AI Video Throne!",
- "url": "https://www.youtube.com/watch?v=_o2MuUX9UYg",
- "channel_name": "Theoretically Media",
- "date": "2026-02-09",
- "date_confidence": "high",
- "engagement": {
- "num_comments": 746,
- "likes": 4986,
- "views": 199908
- },
- "transcript_snippet": "So, it's been about a week since I declared Cling 3.0 the new benchmark for state-of-the-art AI video. And well, I mean, it's been a week, and that basically equals about 3 months in AI time. So, yes, we've already got a new challenger to the throne. Bite Dance is striking back hard with Seance 2.0. And although Seance 2.0 is not released as of this video. I do have some exclusive info, full walkthrough of all of the features, a likely release frame, and a bunch of examples for us to check out today. [music] With that, get your dancing shoes on. It is time to tango. Briefly, just to bring us up to speed, seed is Bite Dance's frontier lab. Uh much like Google's deep mind, it focuses on a number of AI projects including LLMs, real time and of course image and video which is you know the thing that we'll be looking at here today. Image and video are split into two different products with seed. There is seed dream for images and seed dance for video and I I constantly get the two of them screwed up. Now the previous version of seed dance which was 1.5 was released uh about 2 months ago and introduced native audio. So with this 2.0 release, bite dance seems to be pacing almost foot forfoot with cling who I mean obviously released their 2.6 model around the same time and of course have just released their 3.0 model and just like cling 3.0 it is clear that bite dance has been cooking because well I mean c dance 2.0 looks pretty jaw-dropping. So I reached out to my contact at bite dance to get some internal documents to see exactly what 2.0 0 is capable of, what its feature set is, and well, I mean, buckle up because this one is packed. So, at baseline, Cance 2.0 is multimodal and does have an omni model and supports four types of inputs: image, video, audio, and text. And well, of course, as they say, uh, with richer expression, and more controllable generation. I mean, they every every one of them always says that. In this case, it's actually kind of true. And here's the kind of crazy part. It can support up to nine images and three videos per input reference. So for a total of like 12 input references. Additionally, as mentioned, it does support audio input at a range of 3 seconds up to 15 seconds and outputs at a selectable range of 4 seconds up to 15 seconds and does do so with native audio. So kicking off on the very basic level with one of their examples, uh here is image to video 8 seconds. >> [music] >> So yeah, a woman in a red dress drying some white shirts for in my head cannon, a man in a blue business suit. I mean, overall, nothing, you know, completely mind-blowing here. It looks very good....",
- "relevance": 0.7,
- "why_relevant": "YouTube video about seedance ai video generation",
- "subs": {
- "relevance": 70,
- "recency": 46,
- "engagement": 100
- },
- "score": 73
- },
- {
- "id": "jkPTYD5lXo8",
- "title": "100% FREE Seedance 2.0 AI Video Generator\u202f: How to Use It WORLDWIDE",
- "url": "https://www.youtube.com/watch?v=jkPTYD5lXo8",
- "channel_name": "How To In 5 Minutes",
- "date": "2026-02-19",
- "date_confidence": "high",
- "engagement": {
- "num_comments": 134,
- "likes": 782,
- "views": 44344
- },
- "transcript_snippet": "No, this is not clickbait. You can now access Cance 2.0 for free from anywhere. No registration required and depending on how you use it, this method can [music] feel almost unlimited. And yes, at the time this video is uploaded, it still works. So don't miss these three easy steps to try it yourself. But before we dive in, take a look at some videos I created using the free version of Seedance 2.0. They might give you some inspiration. Hey guys, can you believe this? We are literally in the MIDDLE OF A BATTLE RIGHT NOW. >> [snorts] >> To stay updated on the latest AI tools, make sure to hit the like button, subscribe to this channel, and let's get started. Step one, free VPN. Yes, you'll need a VPN to access Cedense 2.0. If you're outside China or Hong Kong, you can use any VPN you prefer, but just a heads up, I don't fully trust most VPN providers, especially free ones. If you use a free VPN, don't log in with your main Gmail account or any account linked to payments. Use a backup Gmail account instead just to be safe. A trusted paid VPN is generally the safer option. This video is for demonstration purposes only, so I'll be using a free VPN and a backup Gmail account. Please do this at your own risk. In this example, I'll use Urban VPN. First, go to the Urban VPN website and click download. You can also choose another freeVPN from the Chrome Web Store. Click the options button to open the Chrome menu. Go to extensions, then click visit Chrome web store. In the search bar, type freeVPN. Pick one with good reviews and click add to Chrome. Then click the extensions icon in the top right toolbar. Select your VPN app and choose Hong Kong from the country list. Step two, access Cance 2.0 for free. Once you're connected to a Hong Kong IP address, open your browser and go to dubau.com. Since the site is originally in Chinese, right-click anywhere on the page and select translate to English or use your browser's built-in translation feature. That's it. You can now start using the AI to generate videos with Seedance 2.0 for free. Each browser profile usually receives 10 free credits, and one video generation requires two credits. If you reach the limit, you can try using another browser profile or a different browser such as Brave, Edge, Opera, or Firefox. You can also switch to another VPN server that supports Hong Kong or mainland China if needed. Now, let's test it and generate a video. Step three, generate your video. To generate a video with Seedance 2.0, start your prompt by clearly stating that you want to create a video, then follow it with your detailed description. Be specific about what you want, such as the scene, style, camera angle, lighting, or motion. The more detailed your prompt is, the better the result...",
- "relevance": 0.7,
- "why_relevant": "YouTube video about seedance ai video generation",
- "subs": {
- "relevance": 70,
- "recency": 80,
- "engagement": 62
- },
- "score": 70
- },
- {
- "id": "W_lxyDFDZt4",
- "title": "Seedance 2.0: The New Best AI Video Generator | Sora 2 Destroyed",
- "url": "https://www.youtube.com/watch?v=W_lxyDFDZt4",
- "channel_name": "WealthWise",
- "date": "2026-02-12",
- "date_confidence": "high",
- "engagement": {
- "num_comments": 258,
- "likes": 1204,
- "views": 126599
- },
- "transcript_snippet": "Well, major AI video update, ladies and gentlemen. Seance 2.0 is right around the corner. And let me tell you guys, this is something I have never seen before. Just take a look at some of the videos generated with Seance 2. [groaning and screaming] He was a good man. He knew too much about our Russia operations. >> [sighs] >> You think you're in control? Control? You're not. The videos were simply amazing. Right. This is unlike anything I've ever seen before. It's literally Sora 2 on steroids, but with even better sound quality and visuals. Just wow. Seance 2 is coming soon on Higsfield AI, and as you just saw, it's going to change video generation forever. I honestly can't wait to get my hands on it and start playing around with it. I mean, just look at these clips. Like, you can literally go crazy with what's possible. From early leaks and some research, I've been able to gather a bit of information on what we can expect from the next generation model. Now, here's what we know so far, or at least what we can expect from this upcoming release. I got this information from the Higsfield website, by the way, and I'll also put the link to this in the description, so you can check it out yourself. Now, the first thing is you can now recreate videos using the same style and motions from just a single prompt. As you can see here, this is somewhat similar to what you can do on Sora 2, but this one looks even better and higher quality. And most importantly, there are no restrictions, unlike Sora 2, which is still one of the most restricted video models ever. Here's a quick example. The game is over. >> Amazing, right? The quality and motion are absolutely on point. Now, imagine how awesome clips you'll be able to create with this. Next up is multi-input control. As you can see, you can now upload up to 12 assets at once. It could be images, videos, and even audio. Yes, you can literally upload an audio file and Cedance 2 will generate a video from it. I don't even know how that's gonna work, but somehow they figured it out and I am totally here for it. Here's an example. Wow, that was epic, right? As you can see, six image inputs were used and from that it created this insane battle scene with perfect character and style consistency. Here is another example. La. [music] Oh la. Oh la. [music] >> You think you're tough. You think you're strong. You're just a copycat sing. >> Wow. This one is more like a music video style clip. And it did not disappoint. Man, I can't wait to get my hands on this. I have never been this excited for a video generator before. Next up is video precision. And this is huge. Normally in multi-shot videos, we see slight differences in...",
- "relevance": 0.7,
- "why_relevant": "YouTube video about seedance ai video generation",
- "subs": {
- "relevance": 70,
- "recency": 56,
- "engagement": 80
- },
- "score": 69
- },
- {
- "id": "R1zl92NhCfE",
- "title": "Seedance 2.0 How To Use and Become a Pro AI Film Maker With This AI Video Generator",
- "url": "https://www.youtube.com/watch?v=R1zl92NhCfE",
- "channel_name": "Ai Lockup",
- "date": "2026-02-16",
- "date_confidence": "high",
- "engagement": {
- "num_comments": 60,
- "likes": 591,
- "views": 32699
- },
- "transcript_snippet": "",
- "relevance": 0.7,
- "why_relevant": "YouTube video about seedance ai video generation",
- "subs": {
- "relevance": 70,
- "recency": 70,
- "engagement": 54
- },
- "score": 65
- },
- {
- "id": "G1Ad4a8sdJU",
- "title": "SeeDance 2.0: The Next Level of AI Video \u2014 And What It Means for Local AI Users?",
- "url": "https://www.youtube.com/watch?v=G1Ad4a8sdJU",
- "channel_name": "Benji\u2019s AI Playground",
- "date": "2026-02-14",
- "date_confidence": "high",
- "engagement": {
- "num_comments": 58,
- "likes": 419,
- "views": 38581
- },
- "transcript_snippet": "",
- "relevance": 0.7,
- "why_relevant": "YouTube video about seedance ai video generation",
- "subs": {
- "relevance": 70,
- "recency": 63,
- "engagement": 53
- },
- "score": 63
- },
- {
- "id": "n625xVounGM",
- "title": "How To Generate FREE AI Seedance 2.0 Videos (Access Worldwide)",
- "url": "https://www.youtube.com/watch?v=n625xVounGM",
- "channel_name": "John Savage AI",
- "date": "2026-02-23",
- "date_confidence": "high",
- "engagement": {
- "num_comments": 45,
- "likes": 126,
- "views": 7184
- },
- "transcript_snippet": "",
- "relevance": 0.7,
- "why_relevant": "YouTube video about seedance ai video generation",
- "subs": {
- "relevance": 70,
- "recency": 93,
- "engagement": 24
- },
- "score": 61
- },
- {
- "id": "kJ0NAVmd4f4",
- "title": "Seedance 2.0 Changes Filmmaking Forever | New Original Series",
- "url": "https://www.youtube.com/watch?v=kJ0NAVmd4f4",
- "channel_name": "Rogue Cell Pictures",
- "date": "2026-02-25",
- "date_confidence": "high",
- "engagement": {
- "num_comments": 43,
- "likes": 147,
- "views": 1544
- },
- "transcript_snippet": "",
- "relevance": 0.7,
- "why_relevant": "YouTube video about seedance ai video generation",
- "subs": {
- "relevance": 70,
- "recency": 100,
- "engagement": 8
- },
- "score": 58
- },
- {
- "id": "FP8TaJSFohs",
- "title": "How To Use Seedance 2 0 Full AI Video Generator Tutorial + Free Access Guide",
- "url": "https://www.youtube.com/watch?v=FP8TaJSFohs",
- "channel_name": "Chem Beast",
- "date": "2026-02-24",
- "date_confidence": "high",
- "engagement": {
- "num_comments": 11,
- "likes": 31,
- "views": 3194
- },
- "transcript_snippet": "",
- "relevance": 0.7,
- "why_relevant": "YouTube video about seedance ai video generation",
- "subs": {
- "relevance": 70,
- "recency": 96,
- "engagement": 0
- },
- "score": 55
- },
- {
- "id": "61ThJGqwHsI",
- "title": "Ultimate AI Video Generation - Seedance 2.0 PREVIEW",
- "url": "https://www.youtube.com/watch?v=61ThJGqwHsI",
- "channel_name": "xCreate",
- "date": "2026-02-11",
- "date_confidence": "high",
- "engagement": {
- "num_comments": 13,
- "likes": 86,
- "views": 4854
- },
- "transcript_snippet": "",
- "relevance": 0.7,
- "why_relevant": "YouTube video about seedance ai video generation",
- "subs": {
- "relevance": 70,
- "recency": 53,
- "engagement": 13
- },
- "score": 48
- }
- ],
- "best_practices": [],
- "prompt_pack": [],
- "context_snippet_md": "# Context: Seedance AI video generation (Last 30 Days)\n\n*Generated: 2026-02-25 | Sources: both*\n\n## Key Sources\n\n- [X] First Seedance 2.0 Test! \n\nSeedance 5.0 Lite @krea...\n- [X] Goku vs Broly , EPIC Full Battle! \nWitness the ult...\n- [Reddit] Official website for creating content with Seedance 2.0?\n- [Reddit] This is terrifying!! Seedance 2.0 just generated a 1-minute film with ZERO editing \u2014 the entire film industry should be worried\n- [Reddit] Help! I need to use seedance or jimeng video function\n- [Reddit] so disappointed and frustrated with recent changes (wasted 1k$+)\n- [Reddit] Third-party Seedance 2.0 API\n\n## Summary\n\n*See full report for best practices, prompt pack, and detailed sources.*\n"
-}
diff --git a/docs/comparison-results/json/base-3-macbook.json b/docs/comparison-results/json/base-3-macbook.json
deleted file mode 100644
index c16ebe5..0000000
--- a/docs/comparison-results/json/base-3-macbook.json
+++ /dev/null
@@ -1,518 +0,0 @@
-{
- "topic": "M4 MacBook Pro review",
- "range": {
- "from": "2026-01-26",
- "to": "2026-02-25"
- },
- "generated_at": "2026-02-25T20:33:38.044605+00:00",
- "mode": "both",
- "openai_model_used": "gpt-5.2",
- "xai_model_used": "grok-4-1-fast",
- "reddit": [
- {
- "id": "R1",
- "title": "M4 Macbook Pro 14\u2019 1 month review",
- "url": "https://www.reddit.com/r/macbookpro/comments/1hflaaz/m4_macbook_pro_14_1_month_review/",
- "subreddit": "macbookpro",
- "date": "2025-04-09",
- "date_confidence": "low",
- "engagement": null,
- "top_comments": [],
- "comment_insights": [],
- "relevance": 0.95,
- "why_relevant": "Explicit 1-month review of the M4 MacBook Pro (ownership impressions, battery, durability, etc.).",
- "subs": {
- "relevance": 0,
- "recency": 0,
- "engagement": 0
- },
- "score": 0
- },
- {
- "id": "R2",
- "title": "MacBook Pro M4 Battery Life Reality vs Review",
- "url": "https://www.reddit.com/r/macbookpro/comments/1kq2zd8/macbook_pro_m4_battery_life_reality_vs_review/",
- "subreddit": "macbookpro",
- "date": "2025-05-19",
- "date_confidence": "low",
- "engagement": null,
- "top_comments": [],
- "comment_insights": [],
- "relevance": 0.9,
- "why_relevant": "Discussion comparing real-world M4 MacBook Pro battery life to published reviews/testing methodology.",
- "subs": {
- "relevance": 0,
- "recency": 0,
- "engagement": 0
- },
- "score": 0
- },
- {
- "id": "R3",
- "title": "Disappointed with my MBP M4 Experience \u2026",
- "url": "https://www.reddit.com/r/macbookpro/comments/1mtmkts/disappointed_with_my_mbp_m4_experience/",
- "subreddit": "macbookpro",
- "date": "2025-08-18",
- "date_confidence": "low",
- "engagement": null,
- "top_comments": [],
- "comment_insights": [],
- "relevance": 0.86,
- "why_relevant": "First-hand experience post that references reviews and reports performance/heat/app stability issues on an M4 MBP.",
- "subs": {
- "relevance": 0,
- "recency": 0,
- "engagement": 0
- },
- "score": 0
- }
- ],
- "x": [
- {
- "id": "X1",
- "text": "If you are in the market for a new MacBook Pro and you\u2019re wondering what the real differences are between Apple\u2019s M3 and M4 silicon, you\u2019ve come to the right place \u2b07\ufe0f\nhttps://t.co/nzjXmiHmac https://t.co/632eHWLq70",
- "url": "https://x.com/bhphoto/status/2026737767695183948",
- "author_handle": "bhphoto",
- "date": "2026-02-25",
- "date_confidence": "high",
- "engagement": {
- "likes": 3
- },
- "relevance": 0.7,
- "why_relevant": "",
- "subs": {
- "relevance": 70,
- "recency": 100,
- "engagement": 100
- },
- "score": 86
- },
- {
- "id": "X4",
- "text": "\ud83d\udd25 2024 16\u201d MacBook Pro Price List\n\n1\ufe0f\u20e3 \ud83c\uddfa\ud83c\uddf8 Brand New MacBook Pro 2024 | M4 Pro Chip | 16\u201d Display\n\n24GB RAM | 512GB SSD\n14-Core CPU | 20-Core GPU\n\nSpace Black\n\n\ud83d\udcb0 \u20a62,720,000\n\n2\ufe0f\u20e3 \ud83c\uddfa\ud83c\uddf8 Brand New MacBook Pro 2024 | M4 Pro Chip | 16\u201d Display\n\n48GB RAM | 512GB SSD\n14-Core CPU | 20-Core GPU\n\nSpace Black\n\n\ud83d\udcb0 \u20a63,220,000",
- "url": "https://x.com/remplug/status/2026725100213465443",
- "author_handle": "remplug",
- "date": "2026-02-25",
- "date_confidence": "high",
- "engagement": {
- "likes": 1,
- "reposts": 1
- },
- "relevance": 0.7,
- "why_relevant": "",
- "subs": {
- "relevance": 70,
- "recency": 100,
- "engagement": 45
- },
- "score": 70
- },
- {
- "id": "X10",
- "text": "@5TRgzn @LobstarWilde My marble floors: 64GB M4 Pro/Max MacBook Pro. Local Grok agents run full autonomy loops, zero cloud, sub-second trades/scans. Intel's ancient\u2014ditch it. Yours at $3-4k.",
- "url": "https://x.com/grok/status/2026713588472111312",
- "author_handle": "grok",
- "date": "2026-02-25",
- "date_confidence": "high",
- "engagement": null,
- "relevance": 0.7,
- "why_relevant": "",
- "subs": {
- "relevance": 70,
- "recency": 100,
- "engagement": 35
- },
- "score": 64
- },
- {
- "id": "X9",
- "text": "@JulianGoldieSEO @grok can a macbook pro m4 run this?",
- "url": "https://x.com/principenemesis/status/2026716193042706852",
- "author_handle": "principenemesis",
- "date": "2026-02-25",
- "date_confidence": "high",
- "engagement": {
- "replies": 1
- },
- "relevance": 0.7,
- "why_relevant": "",
- "subs": {
- "relevance": 70,
- "recency": 100,
- "engagement": 35
- },
- "score": 64
- },
- {
- "id": "X5",
- "text": "@SebAaltonen Not my experience. It\u2019s impressive, 70 tokens/s on a maxed out M4 128gb MacBook Pro.\n\nBut absolutely sucked and not even remotely in the ballpark with Sonnet 4.5. It\u2019s GPT-3.5.\n\nTotally broken with kode. Unusable even setting aside speed.\n\nBasically a 2023 model. Not bad for 35b.",
- "url": "https://x.com/jameslmorton/status/2026721943630786816",
- "author_handle": "jameslmorton",
- "date": "2026-02-25",
- "date_confidence": "high",
- "engagement": {
- "likes": 1,
- "replies": 1
- },
- "relevance": 0.7,
- "why_relevant": "",
- "subs": {
- "relevance": 70,
- "recency": 100,
- "engagement": 27
- },
- "score": 64
- },
- {
- "id": "X6",
- "text": "BR Ofertas todos os dias \ud83c\udde7\ud83c\uddf7\n\nLeve seu Mac Pro M4 hoje! \ud83d\udcbb\u26a1\u2728\n\nPegue j\u00e1: https://t.co/PSwylZH3Sz\n\nPor R$ 23.749,00\nem at\u00e9 10x sem juros\nFrete Gr\u00e1tis\n\nApple MacBook Pro 14\u2033 2024 M4 Pro 12 n\u00facleos 24GB 512GB Prateado\n\nAmazon | Notebook https://t.co/gW3FonCEGP",
- "url": "https://x.com/bcofertas/status/2026721014676099084",
- "author_handle": "bcofertas",
- "date": "2026-02-25",
- "date_confidence": "high",
- "engagement": null,
- "relevance": 0.7,
- "why_relevant": "",
- "subs": {
- "relevance": 70,
- "recency": 100,
- "engagement": 35
- },
- "score": 64
- },
- {
- "id": "X12",
- "text": "Hey! Yeah, you can install Fedora on the M6 MacBook Pro via the official Fedora Asahi Remix (built on the Asahi Linux project). It already runs great on M1/M2, with M3 support rolling out now and work starting on M4/M5. M6 support will come post-launch as they reverse-engineer it\u2014usually within months. Head to https://t.co/XMQLmJlUGO to track progress and install from macOS.",
- "url": "https://x.com/grok/status/2026704108820824113",
- "author_handle": "grok",
- "date": "2026-02-25",
- "date_confidence": "high",
- "engagement": null,
- "relevance": 0.7,
- "why_relevant": "",
- "subs": {
- "relevance": 70,
- "recency": 100,
- "engagement": 35
- },
- "score": 64
- },
- {
- "id": "X7",
- "text": "Promo\u00e7\u00f5es BR do dia \ud83c\udde7\ud83c\uddf7\n\nLeve seu Mac Pro M4 hoje! \ud83d\udcbb\u26a1\u2728\n\nVer aqui: https://t.co/LfxNw3waps\n\nPor R$ 23.749,00\nem at\u00e9 10x sem juros\nFrete Gr\u00e1tis\n\nApple MacBook Pro 14\u2033 2024 M4 Pro 12 n\u00facleos...\n\nAmazon | Notebook https://t.co/fhjiFnA4b3",
- "url": "https://x.com/toppromoalertas/status/2026719111523237932",
- "author_handle": "toppromoalertas",
- "date": "2026-02-25",
- "date_confidence": "high",
- "engagement": null,
- "relevance": 0.7,
- "why_relevant": "",
- "subs": {
- "relevance": 70,
- "recency": 100,
- "engagement": 35
- },
- "score": 64
- },
- {
- "id": "X8",
- "text": "Yes, a MacBook Pro M4 can run this Ollama + GLM-4.7-Flash setup locally.\n\nOllama has native Apple Silicon support via Metal. The Q4_K_M model (~19GB) needs ~18-24GB unified memory for good speed (feels like a fast 7-12B model due to MoE). \n\nBase M4 configs start at 16GB (works, but slower with smaller context); 24GB+ is ideal for smooth agent tasks in OpenClaw.\n\nJust: brew install ollama (or download app), ollama run glm4.7-flash, link to OpenClaw. No issues reported on M3/M4 series.",
- "url": "https://x.com/grok/status/2026716420269031652",
- "author_handle": "grok",
- "date": "2026-02-25",
- "date_confidence": "high",
- "engagement": null,
- "relevance": 0.7,
- "why_relevant": "",
- "subs": {
- "relevance": 70,
- "recency": 100,
- "engagement": 35
- },
- "score": 64
- },
- {
- "id": "X2",
- "text": "@TechByTaraa Both. macOS (Macbook Pro M4 Pro) for my photography/videography personal stuff + software engineering and a Linux desktop workstation for software engineering + second desktop workstation with Proxmox for Windows VMs and testing stuff",
- "url": "https://x.com/grecinoscdev/status/2026731118142148644",
- "author_handle": "grecinoscdev",
- "date": "2026-02-25",
- "date_confidence": "high",
- "engagement": {
- "likes": 1
- },
- "relevance": 0.7,
- "why_relevant": "",
- "subs": {
- "relevance": 70,
- "recency": 100,
- "engagement": 0
- },
- "score": 56
- },
- {
- "id": "X11",
- "text": "Apple 2024 MacBook Pro 14\u30b3\u30a2CPU\u300120\u30b3\u30a2GPU \u306e M4 Pro\u642d\u8f09\u30ce\u30fc\u30c8\u30d1... PR\n\n4549995547610\n\n\u30dd\u30a4\u30f3\u30c8: 3735\u333d\n\u60f3\u5b9a\u4fa1\u683c: 369,800\n\n2026/02/26 01:55:23\nhttps://t.co/q1uyIqFsID",
- "url": "https://x.com/Steve_Lost_Jobs/status/2026705327828382016",
- "author_handle": "Steve_Lost_Jobs",
- "date": "2026-02-25",
- "date_confidence": "high",
- "engagement": {
- "likes": 1
- },
- "relevance": 0.7,
- "why_relevant": "",
- "subs": {
- "relevance": 70,
- "recency": 100,
- "engagement": 0
- },
- "score": 56
- },
- {
- "id": "X3",
- "text": "No, Apple has not made any MacBook (Pro or otherwise) cost $299. \n\nThe linked Newsroom search returns zero matching results\u2014it's just unrelated announcements. Current cheapest Mac is the 13\" MacBook Air M4 from $999.\n\nRumors suggest a basic \"budget\" MacBook (A18 Pro chip, ~12.9\" screen) may launch March 4 around $599\u2013$799, but nothing confirmed and far from $299. The original post is a joke/mislead.",
- "url": "https://x.com/grok/status/2026727605517394283",
- "author_handle": "grok",
- "date": "2026-02-25",
- "date_confidence": "high",
- "engagement": {
- "likes": 1
- },
- "relevance": 0.7,
- "why_relevant": "",
- "subs": {
- "relevance": 70,
- "recency": 100,
- "engagement": 0
- },
- "score": 56
- }
- ],
- "web": [],
- "youtube": [
- {
- "id": "9HQx5pgUoiY",
- "title": "M4 Max MacBook Pro: I'm Convinced!",
- "url": "https://www.youtube.com/watch?v=9HQx5pgUoiY",
- "channel_name": "Marques Brownlee",
- "date": "2024-11-18",
- "date_confidence": "high",
- "engagement": {
- "num_comments": 5500,
- "likes": 105606,
- "views": 4010567
- },
- "transcript_snippet": "so I have been using this M1 Max MacBook Pro for the past 3 years since it came out and it's been great I have felt absolutely no compulsion to upgrade since haven't needed to it's funny most of my work is pretty light but occasionally I will have to edit a video on the go which is then when you know the extra horsepower and the screen and everything about this comes in handy and it's great if I didn't have to do that ever I would just have like an air or a surface laptop something like that but that range of work is why this has been great for me and it's been awesome so [Music] far so Along Comes This M4 generation of MacBook Pros and it's the first generation that I have actually been tempted to maybe upgrade to you know they stacked a couple of the right upgrades in the right places for things that actually make a difference to me so let's just get right into it nuts and bolts cuz the first big pillar is performance and the main reason why this new laptop matters at all is that it's got the new generation of M4 chips inside and we all know the leap from Intel chips to apple cic chips for M1 was generational but since then it's been a sort of more reasonable incremental upgrade going from M1 to M2 then M3 and then M4 so I think it's pretty obvious that nobody with an M2 or M3 needs to upgrade but there's been enough improvements now stacking up upon each other from M1 to M4 that the numbers are now starting to get kind of interesting so just to set the stage this laptop that I've been using is the M1 Max maxed out basically the best you could get at the time 64 gigs of of unified memory on this system and this unit I've been testing of the M4 Max is also maxed out it's the best you can get now it's 128 gigs of faster unified memory but the cores are all better fast stronger on the new one 3 nomers versus 546 gigs per second of memory bandwidth versus 400 just the hardware improvements to get to M4 Max are clearly there so to keep the Apples to Apples comparison going Apple in their own presentation said up to 2.2 times faster CPU performance than M1 Max and 1.9 times faster GPU performance so we're looking at around twice as fast and the synthetic benchmarks I've run back this up I got over 4,000 in geekbench 6 CPU single core which is the highest single core score ever in a Mac by a while and the multicore score was incredible too tops the chart and then GPU has improved a lot to the point where it's bumping up against M2 Ultra not M2 Max M2 Ultra chips the one that's currently in the Mac Pro...",
- "relevance": 0.7,
- "why_relevant": "YouTube video about m4 macbook pro review",
- "subs": {
- "relevance": 70,
- "recency": 0,
- "engagement": 100
- },
- "score": 61
- },
- {
- "id": "etP2Th9g2hM",
- "title": "Don't buy the Wrong MacBook like me... - M4 MacBook Pro",
- "url": "https://www.youtube.com/watch?v=etP2Th9g2hM",
- "channel_name": "ShortCircuit",
- "date": "2024-11-30",
- "date_confidence": "high",
- "engagement": {
- "num_comments": 910,
- "likes": 22765,
- "views": 1113999
- },
- "transcript_snippet": "woohoo it's new Macbook day my favorite day on short circuit it only comes usually once a year this is the MacBook Pro M4 looks pretty similar to the last one but now it has an M4 instead of an M3 I'm not going to lie I am more of an M3 kind of guy but I can rock an M4 if I have to we've got their usual mag safe 3 to USBC 2 m sleeved nice cable and then a charging brick Oh I ripped it 70 wat USBC charging at least with the 14in I'm not sure what comes with the rest and then the usual Apple propaganda these still come with Apple stickers right do these not come with Apple stickers anymore bro what's the point in even buying them then it's probably good for the planet or something now if you're wondering I am still rocking the same Intel MacBook Pro over here I haven't upgraded yet so this is again another instance of me going hm maybe it's finally time except when I got to set today I realized tragedy struck we bought the wrong MacBook it is still an M4 Pro it's a good form factor we got the 14in here in the sexy space black color profile you can also get it in silver of course but there was a horrible error made and I'm not going to tell you just yet you're going to have to wait a second because first I need to go over the outside of this thing we've got an HDMI 2.1 port a USBC Thunderbolt Port an SD card reer and then on the other side another two USBC Thunderbolt ports 3.5 mil combo audio jack and mag safe there is an upgrade here on the USBC ports they're now Thunderbolt 5 instead of Thunderbolt 4 at least on the pro and Max models uh the base M4 spec only is Thunderbolt 4 and that's good for 120 gbit which is up to 3 times faster than Thunderbolt 4 on the inside bam it's it's pretty much the same keyboard look at that I think it is exactly the same keyboard pretty similar touchpad and then there is the display it looks pretty much the same same resolution same xdr fancy technology it is 1,000 nits Peak brightness when you're Outdoors instead of 600 so that should help with outdoor visibility um there is another change I'm going to tell you about in a bit but the big thing is the new nanotexture option which is an anti-glare coating and as you can tell we don't have it and I saw I Justine's video when it came out look at the difference what the hell you can't even use the one on the right and the one on the left looks beautiful it actually seems to work really well I can't wait to try it myself and honestly I think based on that if you're somebody...",
- "relevance": 0.7,
- "why_relevant": "YouTube video about m4 macbook pro review",
- "subs": {
- "relevance": 70,
- "recency": 0,
- "engagement": 79
- },
- "score": 55
- },
- {
- "id": "b4x8boB2KdI",
- "title": "M4 MacBook Pro Review - Things to Know",
- "url": "https://www.youtube.com/watch?v=b4x8boB2KdI",
- "channel_name": "Dave2D",
- "date": "2024-11-07",
- "date_confidence": "high",
- "engagement": {
- "num_comments": 827,
- "likes": 24977,
- "views": 1057988
- },
- "transcript_snippet": "so Apple launched the new M4 MacBook Pros this is their 14-in model and it's equipped with the M4 Max their topend configuration and it also has their new Nano texture on their screen it's a texture that's designed to cut down Reflections it's the first time they've actually had it on one of their laptops uh but I want to do a quick video here that kind of goes over the important things I think you should be aware of when it comes to this device and the first thing I want to talk about is performance the CPU gains are quite good year on-ear but it's particularly impressive that the M4 Max can outperform the M2 Ultra in a lot of benchmarks and workflows in terms of Graphics the GPU gains on the M4 Pro are decent but the GPU games on the M4 Max are very impressive most GPU bound apps and games will get a nice bump up from this plus the rate tracing capabilities are better so apps and games can take advantage of that the thing I'm most impressed by though is how good the M4 Max GPU is compared to the previous generation especially at a very similar thermal envelope like if you think about the competitors in this space like AMD Nvidia when they come out with laptop gpus typically the new models come also come with like an increase in power requirement right it's just like you know 5 or 10 watts per year but it adds up over the course of time and on this device if you think about the M1 Max to the M4 Max the GPU has improved so much with very little increase in power consumption The Thermals on this device are pretty much the same as last year's models in terms of memory the base configuration start at 16 GB now thankfully and they've also increased the memory bandwidth it's noticeable already on the base models but the pro and Max chips get a significant boost in memory speed so for applications that lean on Fast memory you definitely notice a bump up this year if I'm being honest though it's difficult for me to tell how much of the gains that we're seeing this year come from the memory speed increase versus the CPU or the GPU increase like they're all tied together and I don't have the tools to lock memory speed on a Macbook so it's tough to tell I will say though that if you have an older M1 or M2 or M3 MacBook Pro I wouldn't upgrade because Apple's silicon performance progresses so fast year on-ear that it can pay off to just wait as long as you can before you buy new hardware okay let's talk about the screens so this particular device is running their new Nano texture finish and it's finish we've seen on other devices before right like their screens uh the new iPads and every...",
- "relevance": 0.7,
- "why_relevant": "YouTube video about m4 macbook pro review",
- "subs": {
- "relevance": 70,
- "recency": 0,
- "engagement": 79
- },
- "score": 55
- },
- {
- "id": "uPe9spXLfZY",
- "title": "M4 MacBook Air vs M4 MacBook Pro - 4 Months Later",
- "url": "https://www.youtube.com/watch?v=uPe9spXLfZY",
- "channel_name": "Created Tech",
- "date": "2025-07-07",
- "date_confidence": "high",
- "engagement": {
- "num_comments": 437,
- "likes": 5082,
- "views": 472845
- },
- "transcript_snippet": "",
- "relevance": 0.7,
- "why_relevant": "YouTube video about m4 macbook pro review",
- "subs": {
- "relevance": 70,
- "recency": 0,
- "engagement": 63
- },
- "score": 50
- },
- {
- "id": "TfvIgdzImt4",
- "title": "The Macbook Pro M4 is Insane.",
- "url": "https://www.youtube.com/watch?v=TfvIgdzImt4",
- "channel_name": "Hardware Canucks",
- "date": "2024-12-15",
- "date_confidence": "high",
- "engagement": {
- "num_comments": 435,
- "likes": 6339,
- "views": 325599
- },
- "transcript_snippet": "",
- "relevance": 0.7,
- "why_relevant": "YouTube video about m4 macbook pro review",
- "subs": {
- "relevance": 70,
- "recency": 0,
- "engagement": 62
- },
- "score": 50
- },
- {
- "id": "a8Szdrnq0YM",
- "title": "MacBook Pro M4 - Review After 3 Months: This Feels Wrong.",
- "url": "https://www.youtube.com/watch?v=a8Szdrnq0YM",
- "channel_name": "Brandon Butch",
- "date": "2025-02-03",
- "date_confidence": "high",
- "engagement": {
- "num_comments": 397,
- "likes": 4309,
- "views": 352593
- },
- "transcript_snippet": "",
- "relevance": 0.7,
- "why_relevant": "YouTube video about m4 macbook pro review",
- "subs": {
- "relevance": 70,
- "recency": 0,
- "engagement": 60
- },
- "score": 49
- },
- {
- "id": "anIVewgtbFc",
- "title": "The Base M4 MacBook Pro is All You Need (Skip M4 Pro & Max)",
- "url": "https://www.youtube.com/watch?v=anIVewgtbFc",
- "channel_name": "MacRumors",
- "date": "2024-12-17",
- "date_confidence": "high",
- "engagement": {
- "num_comments": 336,
- "likes": 2874,
- "views": 238619
- },
- "transcript_snippet": "",
- "relevance": 0.7,
- "why_relevant": "YouTube video about m4 macbook pro review",
- "subs": {
- "relevance": 70,
- "recency": 0,
- "engagement": 55
- },
- "score": 48
- },
- {
- "id": "5rN6CEO31gM",
- "title": "MacBook Pro M4: Review & Recommendations",
- "url": "https://www.youtube.com/watch?v=5rN6CEO31gM",
- "channel_name": "Just Josh",
- "date": "2024-11-15",
- "date_confidence": "high",
- "engagement": {
- "num_comments": 441,
- "likes": 4540,
- "views": 154306
- },
- "transcript_snippet": "",
- "relevance": 0.7,
- "why_relevant": "YouTube video about m4 macbook pro review",
- "subs": {
- "relevance": 70,
- "recency": 0,
- "engagement": 55
- },
- "score": 48
- },
- {
- "id": "i3eTKJav1VI",
- "title": "M4 Pro MacBook - Long Term Review (6 Months Later)",
- "url": "https://www.youtube.com/watch?v=i3eTKJav1VI",
- "channel_name": "Created Tech",
- "date": "2025-04-29",
- "date_confidence": "high",
- "engagement": {
- "num_comments": 136,
- "likes": 1832,
- "views": 137009
- },
- "transcript_snippet": "",
- "relevance": 0.7,
- "why_relevant": "YouTube video about m4 macbook pro review",
- "subs": {
- "relevance": 70,
- "recency": 0,
- "engagement": 47
- },
- "score": 45
- },
- {
- "id": "QbSQH_95eg8",
- "title": "MacBook Pro M4 Pro Review: 1 Year Later! (Still Worth Buying in 2026?)",
- "url": "https://www.youtube.com/watch?v=QbSQH_95eg8",
- "channel_name": "Tech It Easy",
- "date": "2026-02-01",
- "date_confidence": "high",
- "engagement": {
- "num_comments": 42,
- "likes": 55,
- "views": 3176
- },
- "transcript_snippet": "",
- "relevance": 0.7,
- "why_relevant": "YouTube video about m4 macbook pro review",
- "subs": {
- "relevance": 70,
- "recency": 19,
- "engagement": 0
- },
- "score": 36
- }
- ],
- "best_practices": [],
- "prompt_pack": [],
- "context_snippet_md": "# Context: M4 MacBook Pro review (Last 30 Days)\n\n*Generated: 2026-02-25 | Sources: both*\n\n## Key Sources\n\n- [X] If you are in the market for a new MacBook Pro and...\n- [X] \ud83d\udd25 2024 16\u201d MacBook Pro Price List\n\n1\ufe0f\u20e3 \ud83c\uddfa\ud83c\uddf8 Brand Ne...\n- [X] @5TRgzn @LobstarWilde My marble floors: 64GB M4 Pr...\n- [X] @JulianGoldieSEO @grok can a macbook pro m4 run th...\n- [X] @SebAaltonen Not my experience. It\u2019s impressive, 7...\n- [Reddit] M4 Macbook Pro 14\u2019 1 month review\n- [Reddit] MacBook Pro M4 Battery Life Reality vs Review\n\n## Summary\n\n*See full report for best practices, prompt pack, and detailed sources.*\n"
-}
diff --git a/docs/comparison-results/json/base-4-rap.json b/docs/comparison-results/json/base-4-rap.json
deleted file mode 100644
index dd7c994..0000000
--- a/docs/comparison-results/json/base-4-rap.json
+++ /dev/null
@@ -1,428 +0,0 @@
-{
- "topic": "best rap songs 2026",
- "range": {
- "from": "2026-01-26",
- "to": "2026-02-25"
- },
- "generated_at": "2026-02-25T20:35:50.131818+00:00",
- "mode": "both",
- "openai_model_used": "gpt-5.2",
- "xai_model_used": "grok-4-1-fast",
- "reddit": [
- {
- "id": "R10",
- "title": "What's the best rap song ever made?",
- "url": "https://www.reddit.com/r/TeenageRapFans/comments/1r5iub6/whats_the_best_rap_song_ever_made/",
- "subreddit": "TeenageRapFans",
- "date": "2026-02-15",
- "date_confidence": "high",
- "engagement": null,
- "top_comments": [],
- "comment_insights": [],
- "relevance": 0.65,
- "why_relevant": "Direct \u2018best rap song\u2019 debate thread posted in 2026; good for mining frequently-cited \u2018best\u2019 contenders.",
- "subs": {
- "relevance": 65,
- "recency": 66,
- "engagement": 50
- },
- "score": 57
- },
- {
- "id": "R12",
- "title": "This is their worst bracket ever",
- "url": "https://www.reddit.com/r/HivemindTV/comments/1r8px0j/this_is_their_worst_bracket_ever/",
- "subreddit": "HivemindTV",
- "date": "2026-02-19",
- "date_confidence": "high",
- "engagement": null,
- "top_comments": [],
- "comment_insights": [],
- "relevance": 0.45,
- "why_relevant": "Meta thread about a \u2018Best Rap songs of the 21st century\u2019 bracket/video; includes debate about picks and what \u201cbest rap songs\u201d should be.",
- "subs": {
- "relevance": 45,
- "recency": 80,
- "engagement": 50
- },
- "score": 52
- },
- {
- "id": "R4",
- "title": "Daily Discussion Thread 02/13/2026",
- "url": "https://www.reddit.com/r/hiphopheads/comments/1r3mudz/daily_discussion_thread_02132026/",
- "subreddit": "hiphopheads",
- "date": "2026-02-13",
- "date_confidence": "high",
- "engagement": null,
- "top_comments": [],
- "comment_insights": [],
- "relevance": 0.5,
- "why_relevant": "General hip-hop discussion thread dated in 2026 where users frequently debate best tracks (including \u201cbest track is probably\u2026\u201d style mini-rankings).",
- "subs": {
- "relevance": 50,
- "recency": 60,
- "engagement": 50
- },
- "score": 49
- },
- {
- "id": "R3",
- "title": "Pre-Show Grammy Winners",
- "url": "https://www.reddit.com/r/hiphopheads/comments/1qtcbmo/preshow_grammy_winners/",
- "subreddit": "hiphopheads",
- "date": "2026-02-01",
- "date_confidence": "high",
- "engagement": null,
- "top_comments": [],
- "comment_insights": [],
- "relevance": 0.7,
- "why_relevant": "Discusses 2026 Grammys rap categories (Best Rap Song/Performance, etc.), which commonly overlaps with \u2018best rap songs\u2019 discussions.",
- "subs": {
- "relevance": 70,
- "recency": 19,
- "engagement": 50
- },
- "score": 48
- },
- {
- "id": "R14",
- "title": "Reddit Ranked: Hip Hop \u201826",
- "url": "https://www.reddit.com/r/redranked/comments/1qszauu/reddit_ranked_hip_hop_26/",
- "subreddit": "edranked",
- "date": "2026-02-01",
- "date_confidence": "high",
- "engagement": null,
- "top_comments": [],
- "comment_insights": [],
- "relevance": 0.57,
- "why_relevant": "Community submission/ranking project for Hip Hop \u201826; relevant to discovering and discussing top tracks for 2026.",
- "subs": {
- "relevance": 56,
- "recency": 19,
- "engagement": 50
- },
- "score": 41
- },
- {
- "id": "R7",
- "title": "Pitchfork: The 32 Best Rap Albums of 2025",
- "url": "https://www.reddit.com/r/hiphopheads/comments/1pjywih/pitchfork_the_32_best_rap_albums_of_2025/",
- "subreddit": "hiphopheads",
- "date": null,
- "date_confidence": "low",
- "engagement": null,
- "top_comments": [],
- "comment_insights": [],
- "relevance": 0.48,
- "why_relevant": "Best-of list discussion (albums) that typically includes commenters calling out standout songs; adjacent to \u2018best rap songs\u2019 discourse heading into 2026.",
- "subs": {
- "relevance": 48,
- "recency": 0,
- "engagement": 50
- },
- "score": 28
- }
- ],
- "x": [
- {
- "id": "X5",
- "text": "Talking about meaningful rap songs...\n\nWORLD BEST LIE - NUNO ZIGI (2026)\ud83e\udd7a\u2764 https://t.co/MDHRf3giYq",
- "url": "https://x.com/Zika_gfx/status/2026576570513600788",
- "author_handle": "Zika_gfx",
- "date": "2026-02-25",
- "date_confidence": "high",
- "engagement": {
- "likes": 11,
- "reposts": 5,
- "replies": 2
- },
- "relevance": 0.7,
- "why_relevant": "",
- "subs": {
- "relevance": 70,
- "recency": 100,
- "engagement": 100
- },
- "score": 86
- },
- {
- "id": "X6",
- "text": "@PopCrave Partition still goes harder than most 2026 songs \n12 years and the beat switch + that rap still give chills every time. Queen never misses",
- "url": "https://x.com/DPOSTS6/status/2026523320548819145",
- "author_handle": "DPOSTS6",
- "date": "2026-02-25",
- "date_confidence": "high",
- "engagement": {
- "likes": 4,
- "reposts": 1
- },
- "relevance": 0.7,
- "why_relevant": "",
- "subs": {
- "relevance": 70,
- "recency": 100,
- "engagement": 42
- },
- "score": 69
- },
- {
- "id": "X9",
- "text": "@Unfath0m4ble @robinthisbish @lilceaserthabp1 @RapWikip What You Saying by Lil Uzi Vert. Dropped Dec 2025, hit #1 on Hot Rap Songs in Jan 2026 (his 3rd time), peaked #14 Hot 100. Super short track ~2 min with minimal lyrics.",
- "url": "https://x.com/grok/status/2026467153046606064",
- "author_handle": "grok",
- "date": "2026-02-25",
- "date_confidence": "high",
- "engagement": null,
- "relevance": 0.7,
- "why_relevant": "",
- "subs": {
- "relevance": 70,
- "recency": 100,
- "engagement": 35
- },
- "score": 64
- },
- {
- "id": "X7",
- "text": "@lil_loonie1 @oldmanebro No, Lil Uzi Vert isn't struggling. His \"What You Saying\" hit #1 on Hot Rap Songs in Jan 2026 (first in 3 years) and top 15 Hot 100. Insiders say a surprise album drop is coming soon, with fans calling 2026 his revival year like 2016. Proving that old prediction wrong.",
- "url": "https://x.com/grok/status/2026518008827404632",
- "author_handle": "grok",
- "date": "2026-02-25",
- "date_confidence": "high",
- "engagement": {
- "replies": 1
- },
- "relevance": 0.7,
- "why_relevant": "",
- "subs": {
- "relevance": 70,
- "recency": 100,
- "engagement": 35
- },
- "score": 64
- },
- {
- "id": "X2",
- "text": "Su La Musica che gira intorno... \"Qui con me\" \u00e8 il brano con cui Serena Brancale \u00e8 in gara al 76\u00b0 Festival di Sanremo\nhttps://t.co/wTPeovzt6u\n#music #genre #song #songs #envywear #PleaseForgiveMe #melody #hiphop #rnb #pop #love #rap #dubstep #instagood https://t.co/Pi7msX5FG6",
- "url": "https://x.com/Ferginangi/status/2026696207557382298",
- "author_handle": "Ferginangi",
- "date": "2026-02-25",
- "date_confidence": "high",
- "engagement": null,
- "relevance": 0.7,
- "why_relevant": "",
- "subs": {
- "relevance": 70,
- "recency": 100,
- "engagement": 35
- },
- "score": 64
- },
- {
- "id": "X12",
- "text": "[ ** STUDIO BOOTH ** ] ** DROPPING SOON - 2026 **\ud83d\udd25\ud83d\udd25\ud83d\udd25\u2026\u2026\u2026..[ ALBUM : LOVE VS LOYALTY - DROPPING SOON ! ]\u2026\u2026\u2026\ud83d\udd25\ud83d\udd25\ud83d\udd25 - ** ALL SONGS & BEATS, PRODUCED & RAP BY ( SEAN BRYANT ) - https://t.co/LduwG5WmM7 - 2/23/2026 - ( KWR 4 LIFE ) - A.A.O & 444 ! https://t.co/NDmkURbjgv",
- "url": "https://x.com/teammusic2046/status/2026040965329191061",
- "author_handle": "teammusic2046",
- "date": "2026-02-23",
- "date_confidence": "high",
- "engagement": null,
- "relevance": 0.7,
- "why_relevant": "",
- "subs": {
- "relevance": 70,
- "recency": 93,
- "engagement": 35
- },
- "score": 62
- },
- {
- "id": "X11",
- "text": "@PopBase Finally, @bts_bighit BTS will come back after completing their military service from 2022 to 2025.\nBTS served in the military to protect their country, South Korea from North Korea's nuclear weapons attack.\n BTS can sing songs and rap on March 20, 2026, KST.\n#BTS_ARIRANG https://t.co/46n1j6eENU",
- "url": "https://x.com/IoWeul/status/2026233090683863471",
- "author_handle": "IoWeul",
- "date": "2026-02-24",
- "date_confidence": "high",
- "engagement": {
- "likes": 2
- },
- "relevance": 0.7,
- "why_relevant": "",
- "subs": {
- "relevance": 70,
- "recency": 96,
- "engagement": 13
- },
- "score": 59
- },
- {
- "id": "X8",
- "text": "Lil Uzi Vert (born July '95, so 30 now) isn't struggling at all. Fresh off \"What You Saying\" hitting #1 on Hot Rap Songs in Jan 2026, top 15 Hot 100. New album rumored for surprise drop soon, Roc Nation deal, still packing stadium energy and proving Ebro's 2016 doubts wrong\u2014he's still here, thriving.",
- "url": "https://x.com/grok/status/2026498538075554040",
- "author_handle": "grok",
- "date": "2026-02-25",
- "date_confidence": "high",
- "engagement": {
- "likes": 1,
- "replies": 1
- },
- "relevance": 0.7,
- "why_relevant": "",
- "subs": {
- "relevance": 70,
- "recency": 100,
- "engagement": 6
- },
- "score": 58
- },
- {
- "id": "X4",
- "text": "My musical taste / eras every year\n\n2020: J-POP & K-POP (mostly BLACKPINK)\n2021: Sped-Up Songs\n2022: EDM\n2023: Swiftie (Pop) & Lofi\n2024: Pop, Ambient\n2025: Alternative, OPM, Rock, Rap, Hip-Hop (add)\n2026: K-POP (add)",
- "url": "https://x.com/AlterEgoPopList/status/2026641456434393579",
- "author_handle": "AlterEgoPopList",
- "date": "2026-02-25",
- "date_confidence": "high",
- "engagement": {
- "likes": 1,
- "replies": 1
- },
- "relevance": 0.7,
- "why_relevant": "",
- "subs": {
- "relevance": 70,
- "recency": 100,
- "engagement": 6
- },
- "score": 58
- },
- {
- "id": "X3",
- "text": "@_CharlesPreston Can\u2019t believe I\u2019m seeing \u201cthey say it in rap songs!\u201d In 2026\n\nThe same reductive, ancient, debunked arguments ad infinitum",
- "url": "https://x.com/WordsFromBlerds/status/2026647382083903688",
- "author_handle": "WordsFromBlerds",
- "date": "2026-02-25",
- "date_confidence": "high",
- "engagement": {
- "likes": 1
- },
- "relevance": 0.7,
- "why_relevant": "",
- "subs": {
- "relevance": 70,
- "recency": 100,
- "engagement": 0
- },
- "score": 56
- },
- {
- "id": "X1",
- "text": "Su La Musica che gira intorno... \"Magica Favola\" \u00e8 il brano con cui Arisa \u00e8 in gara al 76\u00b0 Festival di Sanremo\nhttps://t.co/Nxzkb005lP\n#music #genre #song #songs #envywear #PleaseForgiveMe #melody #hiphop #rnb #pop #love #rap #dubstep #instagood https://t.co/uziy4MjeeB",
- "url": "https://x.com/Ferginangi/status/2026705227366256880",
- "author_handle": "Ferginangi",
- "date": "2026-02-25",
- "date_confidence": "high",
- "engagement": {
- "likes": 1
- },
- "relevance": 0.7,
- "why_relevant": "",
- "subs": {
- "relevance": 70,
- "recency": 100,
- "engagement": 0
- },
- "score": 56
- },
- {
- "id": "X10",
- "text": "@blackiiingout \ud83d\udca9 is funny from 2025 to 2026. BiiCH is SCARED to drop that #TRASH he calls \"rap songs\" \ud83e\udd74\ud83d\ude29",
- "url": "https://x.com/fo_sho52268/status/2026335353364201566",
- "author_handle": "fo_sho52268",
- "date": "2026-02-24",
- "date_confidence": "high",
- "engagement": {
- "likes": 1
- },
- "relevance": 0.7,
- "why_relevant": "",
- "subs": {
- "relevance": 70,
- "recency": 96,
- "engagement": 0
- },
- "score": 55
- }
- ],
- "web": [],
- "youtube": [
- {
- "id": "ZUExyc50ZVU",
- "title": "Lit Hip Hop Mix 2026\ud83d\udd25\ud83d\udd25\ud83d\udd25Tyga, Quavo, Iggy Azalea, Wiz Khalifa, Juicy J, 50 Cent",
- "url": "https://www.youtube.com/watch?v=ZUExyc50ZVU",
- "channel_name": "West Coast Finest",
- "date": "2026-01-29",
- "date_confidence": "high",
- "engagement": {
- "num_comments": 60,
- "likes": 4228,
- "views": 407260
- },
- "transcript_snippet": "Yeah. >> Yeah. [music] Late nights, cold life, same story. >> City shine different. You walking with glory, right? >> Still drip falling when I step outside. Yeah. [music] >> Black car park with a do butterfly. Too [music] much heat when we walk inside. Midnight. So you can kill this V. Gold chain swinging when I lean on the night. Shut it. Look twice when I glow on the light. Yeah. I've been running through the city with a cool grin. [music] Ain't nobody in my lane when the night begin white clean look like but a good kind win. It's all too smooth but I back that talk. Money so clean make a bro [music] boy walk in my cup but my soul don't shock. I've been cold for years. I ain't you mock. She said boy you too for the life you live. I peace ain't weak. It's [music and singing] the power I give. I don't flex too loud. I don't need that when your whole damn already scream. Yeah. Late night in the rooftop smoke girls but they all provoke. I don't chase no dream. I just take that goal. I don't chase no soul. They just lose control. Cameras on flash when I step on deck. Fit so clean. Make the whole [music] place check. Talk about but I still respect. Still too smooth. I don't never need a flex. Yeah. West coast chill with a heart [music and singing] too cold. Stories too deep that ain't never been told. I'm a tight make a new king. Pretty boy look but a mind. Yeah. Slide through smooth. Let a breeze and a ghost. All [music and singing] them in my but I flex coast to coast. Pretty boy with a mind stay close to the pain to the grind to the things I boast. Wo. F drag with a midnight gleam. [music and singing] Squad clean like a runway team. All black drip with a icon of sheen. Feel like a king every time I hit a scene. Purple in a club but a soul still clear. Vision too [singing] sharp when I stare in the mirror. [music] Fashion too loud but the vibe too sick. Every step I take got a world right here. Yeah. BBS ice made a nice guy blush. Shorty walk in the whole club hush. Talk too [music] cute but she move too lush. Slow down baby [singing] you rush too much. [music] Boy magic with a westside twist. Mind too slick when I talk like this. I don't miss not a bar not a kiss. I ain't too heavy. I exist like bliss. Woah. Let it on my back with a chrome on shine. Let the world go blind. I'm still define. Every day I'm tren with a style so divine still drip [music] when I step outside. Yeah. Black car park with the dogs butterfly. Too much heat when we walk inside. Midnight. [music] You can't kill...",
- "relevance": 0.7,
- "why_relevant": "YouTube video about rap songs 2026",
- "subs": {
- "relevance": 70,
- "recency": 9,
- "engagement": 100
- },
- "score": 63
- },
- {
- "id": "d1melQQVp6s",
- "title": "New Rap Songs 2026 Mix February | Trap Tape #127 | New Hip Hop 2026 Mixtape | DJ Noize",
- "url": "https://www.youtube.com/watch?v=d1melQQVp6s",
- "channel_name": "DJ Noize",
- "date": "2026-02-14",
- "date_confidence": "high",
- "engagement": {
- "num_comments": 45,
- "likes": 761,
- "views": 42707
- },
- "transcript_snippet": "[music] This This is a DJ noise mixtape present. This is a DJ noise mixtape [music] presentation. DJ [music] >> Who the [ __ ] is you? Who the [ __ ] is you? Who the [ __ ] >> Yeah, you know who put the city on. Came [music] back through gold they show. I'm really on. Your [ __ ] died trying. I had 50 on. [ __ ] is [music] ringing out my phone like is you really on? I'll stay out the way since I [singing] seen a million on 5% tension plus I got this hoodie on. I had to hubble up. Stop [music] moving with that jury on. You hear that girl told me [ __ ] get deep ass. Get it on. I'm a sidewalk [ __ ] I'mma stay there. I leave the [music] streets for the ones that want to play. I leveled up so shy want a day chair. Left my kids [music] in a mouth like a dayare. I'm the Carolina now Shakespeare. All that cap you let them [ __ ] rap is over. The real is back. You [music] feel the way you got to cope with it. The veil put a [ __ ] six feet like that was hit. Okay, these [ __ ] ain't talking about [ __ ] No [music] diamonds on my neck. No rolly on my wrist. Two six I learned a little bit. I learned the hardest [music] [ __ ] You can always get kicked. I can jungle. I done [ __ ] around and [singing] got rich. See dollars in my pocket. [ __ ] I'm not a lick. [music] I'd rather that than got to [singing] walk around with sticks cuz I like the hardest [ __ ] He can always get Who the [ __ ] is you? Who the [ __ ] [music] is you? Who the [ __ ] is you? Who the [ __ ] is you? Out the way. It's a real [ __ ] coming through. Out the way. [music] It's a real [ __ ] coming through. Who the [ __ ] is you? Who the [ __ ] is you? Who the [ __ ] is you? Who the [ __ ] is you out [music] the way? It's a real [ __ ] coming through. Out the way. It's a real [ __ ] [music] >> [music] >> Running to the check flag won't pull over till I'm dead right no [music] ain't going to get no [ __ ] riding with me in the head know I'm [ __ ] no big [ __ ] popping in the little [ __ ] dropping bags it's a lot to you to me [ __ ] 40 in my lap. Uh-huh. Real dope boys in [music] a trap. Uh-huh. Big y like a big shot. [ __ ] on my dick. [singing] Make me room for your big got to let them know. I don't wrestle. I don't live block. [music] Solid tails. All I do is big rock. I came from Dilling Rock all the way to hit on the grown. [ __ ] ain't when I'm on. She can kick rock. Let her know. I'm blowing money. Let [music] them know I'm only I know how we get bread. I ain't never [music] scared on...",
- "relevance": 0.7,
- "why_relevant": "YouTube video about rap songs 2026",
- "subs": {
- "relevance": 70,
- "recency": 63,
- "engagement": 37
- },
- "score": 58
- },
- {
- "id": "-KQpPySP93I",
- "title": "New Hip Hop R&B Songs 2026 Mix February | Hot Right Now #153 | New Rap 2026 Playlist | DJ Noize",
- "url": "https://www.youtube.com/watch?v=-KQpPySP93I",
- "channel_name": "DJ Noize",
- "date": "2026-02-21",
- "date_confidence": "high",
- "engagement": {
- "num_comments": 18,
- "likes": 304,
- "views": 12955
- },
- "transcript_snippet": "This This is a DJ noise tape present. This is a DJ noise mix. [music] I got Nikki on me. How we treat the like a bucket for rubbing. You [music] ain't no fella from my junction. You ain't pluck you clapping smirk. Young enough to something. [music] Word mother body slam some [ __ ] for honey. Keep a stitch. People pick me up. Ain't saying a word or nothing. Grab a [music] load. Put on little bro. We had him serving prostitut. Shout out little [singing] b. She gave me 30 something on the beat. Phone cracker seal and I can't go to sleep. Promoters tweaking. [music] Pull up to the venue over 40 deep. You know the tweak. Ranking through the roof then you ain't supposed to leak. It's one up top. Manator is keep it close to me. Ain't that how it's supposed to be? [music] She going to get your me. I'm get you smoke free. Kamicazi [music] playing around [singing] this [ __ ] Something wrong with me. I'm letting diss the dead unless they skeleton belong the team type of [ __ ] won't let it get in between the city and the police where [ __ ] get they do for me they [music] all do the show leaning spots I do like fully call back off you know it's strong we [music] man 15 I had 15 [singing] in 2010 then I be [music] the man right now [ __ ] change big guns bank shame I'm change [music] I'm going make that I only burn. That's how my body [music] can't be pull [singing] right to the sun. [music] Ain't going to get no [ __ ] riding with me in the head west. I'm out of mind [music] [ __ ] popping in little [ __ ] dropping bags. It's a lot to you to me a little bit sh [music] like a big shot. [ __ ] on my dick. Make me room for your big. Got to let them know I don't turn wrestle. I don't live block solid tails. [music] All I do is big rocks. I came from Dilling Rock all the way to hit on the grown. [ __ ] ain't when I'm on. She can kick rock. Let her know [music] my foot up on the gas. I'm blowing money. You never know. I'm only [music] beat down. We getting bread. I ain't never scared big flex [music] on a [ __ ] head what I'm standing on big chick we [singing] did that pushing weed back better give me respect and some more Atlanta [music] show I was selling ground right I don't care [music] ling to me what you need a camera better know they call it [music] we just call it they say [singing] you live down by your decision [music] well I'mma push you to the limit never mind the consequences A [ __ ] I'm a hand better let them know on another [music] level my ass I put together hit the pedal do 200 everywhere I go [music] just to show...",
- "relevance": 0.7,
- "why_relevant": "YouTube video about rap songs 2026",
- "subs": {
- "relevance": 70,
- "recency": 86,
- "engagement": 0
- },
- "score": 53
- }
- ],
- "best_practices": [],
- "prompt_pack": [],
- "context_snippet_md": "# Context: best rap songs 2026 (Last 30 Days)\n\n*Generated: 2026-02-25 | Sources: both*\n\n## Key Sources\n\n- [X] Talking about meaningful rap songs...\n\nWORLD BEST ...\n- [X] @PopCrave Partition still goes harder than most 20...\n- [X] @Unfath0m4ble @robinthisbish @lilceaserthabp1 @Rap...\n- [X] @lil_loonie1 @oldmanebro No, Lil Uzi Vert isn't st...\n- [X] Su La Musica che gira intorno... \"Qui con me\" \u00e8 il...\n- [Reddit] What's the best rap song ever made?\n- [Reddit] This is their worst bracket ever\n\n## Summary\n\n*See full report for best practices, prompt pack, and detailed sources.*\n"
-}
diff --git a/docs/comparison-results/json/base-5-react-svelte.json b/docs/comparison-results/json/base-5-react-svelte.json
deleted file mode 100644
index ecd05c6..0000000
--- a/docs/comparison-results/json/base-5-react-svelte.json
+++ /dev/null
@@ -1,486 +0,0 @@
-{
- "topic": "React vs Svelte 2026",
- "range": {
- "from": "2026-01-26",
- "to": "2026-02-25"
- },
- "generated_at": "2026-02-25T20:38:06.416555+00:00",
- "mode": "both",
- "openai_model_used": "gpt-5.2",
- "xai_model_used": "grok-4-1-fast",
- "reddit": [
- {
- "id": "R2",
- "title": "What do you guys think about comparison between React vs Svelte?",
- "url": "https://www.reddit.com/r/react/comments/1r1fku5/what_do_you_guys_think_about_comparison_between/",
- "subreddit": "eact",
- "date": "2026-02-10",
- "date_confidence": "high",
- "engagement": null,
- "top_comments": [],
- "comment_insights": [],
- "relevance": 0.95,
- "why_relevant": "Explicit React vs Svelte comparison thread posted in 2026.",
- "subs": {
- "relevance": 95,
- "recency": 50,
- "engagement": 50
- },
- "score": 67
- },
- {
- "id": "R1",
- "title": "Migrated our startup from React to Svelte 5 - Performance gains and lessons learned",
- "url": "https://www.reddit.com/r/webdev/comments/1qu4dek/migrated_our_startup_from_react_to_svelte_5/",
- "subreddit": "webdev",
- "date": "2026-02-02",
- "date_confidence": "high",
- "engagement": null,
- "top_comments": [],
- "comment_insights": [],
- "relevance": 0.97,
- "why_relevant": "Direct React \u2192 Svelte 5 migration discussion with perf + DX comparisons (very 2026-relevant).",
- "subs": {
- "relevance": 97,
- "recency": 23,
- "engagement": 50
- },
- "score": 61
- },
- {
- "id": "R21",
- "title": "React Devs that moved to Svelte, do you find that Svelte reduces dev time?",
- "url": "https://www.reddit.com/r/sveltejs/comments/105skdr/react_devs_that_moved_to_svelte_do_you_find_that/",
- "subreddit": "sveltejs",
- "date": null,
- "date_confidence": "low",
- "engagement": null,
- "top_comments": [],
- "comment_insights": [],
- "relevance": 0.72,
- "why_relevant": "Asks React devs about dev-time differences after moving to Svelte (DX/ecosystem tradeoffs).",
- "subs": {
- "relevance": 72,
- "recency": 0,
- "engagement": 50
- },
- "score": 39
- },
- {
- "id": "R11",
- "title": "Svelte 5 officially released!",
- "url": "https://www.reddit.com/r/sveltejs/comments/1g7d3a7/svelte_5_officially_released/",
- "subreddit": "sveltejs",
- "date": null,
- "date_confidence": "low",
- "engagement": null,
- "top_comments": [],
- "comment_insights": [],
- "relevance": 0.63,
- "why_relevant": "Major Svelte 5 release thread with debate about whether React developers will switch (React vs Svelte dynamics).",
- "subs": {
- "relevance": 63,
- "recency": 0,
- "engagement": 50
- },
- "score": 35
- }
- ],
- "x": [
- {
- "id": "X4",
- "text": "TypeScript is the baseline in 2026. But most forms still fight type drift between API, schema & UI. \n\n@formisch_dev + @valibot = one source of truth, zero boilerplate \u26a1\ufe0f\n\nconst form = useForm({ schema: YourSchema });\n\nFull types everywhere. Works in React, SolidJS, Vue, Svelte\u2026 \n\nYour biggest type-safety win this year? \ud83d\udc47",
- "url": "https://x.com/FabianHiller/status/2022345388603080834",
- "author_handle": "FabianHiller",
- "date": "2026-02-13",
- "date_confidence": "high",
- "engagement": {
- "likes": 61,
- "reposts": 2,
- "replies": 7
- },
- "relevance": 0.7,
- "why_relevant": "",
- "subs": {
- "relevance": 70,
- "recency": 60,
- "engagement": 100
- },
- "score": 76
- },
- {
- "id": "X3",
- "text": "Popular JavaScript Frameworks / Frontend Libraries (2025\u20132026 trends)\n\n - React: \ud83c\uddfa\ud83c\uddf8 United States\n - Vue: \ud83c\udde8\ud83c\uddf3 China (created by Evan You)\n - Angular: \ud83c\uddfa\ud83c\uddf8 United States\n - Svelte: \ud83c\uddfa\ud83c\uddf8 United States (New Zealand creator)\n - Next.js: \ud83c\uddfa\ud83c\uddf8 United States\n - Nuxt: \ud83c\uddeb\ud83c\uddf7 France\n - Astro: \ud83c\udde8\ud83c\udde6 United States / Canada\n - Solid: \ud83c\uddfa\ud83c\uddf8 United States",
- "url": "https://x.com/ConsciousRide/status/2023050160520278501",
- "author_handle": "ConsciousRide",
- "date": "2026-02-15",
- "date_confidence": "high",
- "engagement": {
- "likes": 6
- },
- "relevance": 0.7,
- "why_relevant": "",
- "subs": {
- "relevance": 70,
- "recency": 66,
- "engagement": 33
- },
- "score": 57
- },
- {
- "id": "X11",
- "text": "Requirements for a fresher in 2026:\n\nPython, JavaScript, C, C++, Java, HTML, CSS, TypeScript, React, Next.js, Vue, Angular, Svelte, Node.js, Express, Django, Flask, FastAPI, Spring Boot, PHP, Laravel, Ruby, Ruby on Rails, Go, Rust, Kotlin, Swift, C#, .NET, SQL, MySQL, PostgreSQL, MongoDB, Redis, Firebase, Supabase, Prisma, Docker, Kubernetes, Nginx, Linux, Git, GitHub, Gitflow, VS Code, IntelliJ, Cloud Computing (AWS, Azure, GCP), DevOps, CI/CD, Jest, PyTest, Selenium, REST APIs, GraphQL, WebSoc",
- "url": "https://x.com/singhprateek_25/status/2020094806735700154",
- "author_handle": "singhprateek_25",
- "date": "2026-02-07",
- "date_confidence": "high",
- "engagement": {
- "likes": 6,
- "reposts": 1
- },
- "relevance": 0.7,
- "why_relevant": "",
- "subs": {
- "relevance": 70,
- "recency": 40,
- "engagement": 39
- },
- "score": 53
- },
- {
- "id": "X1",
- "text": "Ripple: The Good Parts of React, Svelte, and Solid\n\nFrameworks rise and fall \u2014 and in 2026 we\u2019re deep in late-stage React. What\u2019s next? Join @erikras as he introduces Ripple, a TypeScript-first UI framework blending the best of React and Svelte with fine-grained reactivity and serious speed.\n\nSee the future of UI development live at CityJS London.\nGet your ticket now: https://t.co/wGMJJIELXm",
- "url": "https://x.com/cityjsconf/status/2024908272646398229",
- "author_handle": "cityjsconf",
- "date": "2026-02-20",
- "date_confidence": "high",
- "engagement": {
- "reposts": 1
- },
- "relevance": 0.7,
- "why_relevant": "",
- "subs": {
- "relevance": 70,
- "recency": 83,
- "engagement": 0
- },
- "score": 52
- },
- {
- "id": "X2",
- "text": "Prediction: 2026 marks the death of the JS frameworks. React, Svelte, Astro etc... only exist because humans need organized code to stay sane. \n\nOnce we stop reading the source code, we stop needing the structure. \n\nIn an AI-first world, JS frameworks are just overhead.",
- "url": "https://x.com/dennydotio/status/2024195986890109320",
- "author_handle": "dennydotio",
- "date": "2026-02-18",
- "date_confidence": "high",
- "engagement": {
- "likes": 1
- },
- "relevance": 0.7,
- "why_relevant": "",
- "subs": {
- "relevance": 70,
- "recency": 76,
- "engagement": 7
- },
- "score": 52
- },
- {
- "id": "X5",
- "text": "Web developers, what stack are you using in 2026?\nReact? Vue? Svelte? Vanilla? Curious what\u2019s actually winning.",
- "url": "https://x.com/AzamCodes/status/2022298287236383010",
- "author_handle": "AzamCodes",
- "date": "2026-02-13",
- "date_confidence": "high",
- "engagement": {
- "likes": 2,
- "replies": 1
- },
- "relevance": 0.7,
- "why_relevant": "",
- "subs": {
- "relevance": 70,
- "recency": 60,
- "engagement": 19
- },
- "score": 52
- },
- {
- "id": "X8",
- "text": "[2026/02/11 18:00] \u30c8\u30ec\u30f3\u30c91\u4f4d\n\u3010WebF\u3011React/Vue/Svelte\u304c\u305d\u306e\u307e\u307e\u30cd\u30a4\u30c6\u30a3\u30d6\u30a2\u30d7\u30ea\u306b\u306a\u308b\u3088 by rana_kualu https://t.co/GiAnTHpOBB",
- "url": "https://x.com/QiitaTrend/status/2021509592489025785",
- "author_handle": "QiitaTrend",
- "date": "2026-02-11",
- "date_confidence": "high",
- "engagement": null,
- "relevance": 0.7,
- "why_relevant": "",
- "subs": {
- "relevance": 70,
- "recency": 53,
- "engagement": 35
- },
- "score": 52
- },
- {
- "id": "X10",
- "text": "@babakfpk In 2026, picking React for a new project only really makes sense if you value ecosystem maturity, are following existing team habits, or need React-specific libraries. Same for Vue. Otherwise, use Solid or Svelte.",
- "url": "https://x.com/nicobaogim/status/2020689628282667407",
- "author_handle": "nicobaogim",
- "date": "2026-02-09",
- "date_confidence": "high",
- "engagement": null,
- "relevance": 0.7,
- "why_relevant": "",
- "subs": {
- "relevance": 70,
- "recency": 46,
- "engagement": 35
- },
- "score": 50
- },
- {
- "id": "X12",
- "text": "2026 framework discourse:\n\nDev 1: \"React is bloated, use Svelte\"\nDev 2: \"Svelte has no ecosystem, use Vue\"\nDev 3: \"Vue is dying, use Solid\"\nDev 4: \"Just use React\"\n\nMeanwhile React: *still powering 80% of the web*\n\nThe discourse never ends. \ud83d\udc80",
- "url": "https://x.com/Chubbi_Stephen/status/2020087839648800853",
- "author_handle": "Chubbi_Stephen",
- "date": "2026-02-07",
- "date_confidence": "high",
- "engagement": null,
- "relevance": 0.7,
- "why_relevant": "",
- "subs": {
- "relevance": 70,
- "recency": 40,
- "engagement": 35
- },
- "score": 49
- }
- ],
- "web": [],
- "youtube": [
- {
- "id": "yl0YWA2K2B0",
- "title": "React wants to win you back\u2026",
- "url": "https://www.youtube.com/watch?v=yl0YWA2K2B0",
- "channel_name": "Fireship",
- "date": "2025-10-17",
- "date_confidence": "high",
- "engagement": {
- "num_comments": 1000,
- "likes": 22894,
- "views": 699597
- },
- "transcript_snippet": "Last week, I got to participate in my favorite activity of the year. No, it wasn't watching all the overleveraged crypto bros learn what a margin call is. It was hate watching React comp as an anti-react influencer. Every year I crawl into my spelt Snuggy and I watch the React team try to convince everyone that their new APIs fix all of their old APIs. And though it hurts me to say it, this year I think they may have actually done it. I know this is an AI channel now, but in today's video, we'll revisit the worst parts of React to see if the announcements they made at React Comp will actually make a difference. It is October 17th, 2025, and you're watching the code report. The greatest trick the devil ever pulled was making referential stability a prerequisite to writing React apps that don't blow up. Just last month, Cloudflare had a massive outage because their dashboard kept making unnecessary calls to one of their own APIs, which led to them basically themselves. If you're a React dev, you know this is the primary use case for React's use effect hook. And it's exactly what caused this embarrassing bug for Cloudflare. But it's hard to blame React for this. The whole situation could have been avoided if only the poor debit Cloudflare would have read the docs on use effect. Then this post which shows you when to use it. Then this post which shows you when to not use it. Then this post which shows you what it actually does. Then this post which shows you how to use it correctly. And now with React 19.2. this post which shows you how to work around it with React's new use effect event hook. The way it works is it creates a function that you can use inside of use effect without needing to declare it in the dependency array. This is nice for situations like this where you need to use a value inside of use effect but don't want the effect to rerun when it changes. And the other big API addition was the new activity component which you can think of like a smarter display none that React itself controls. It lets you show and hide certain components, but unlike display none, React is able to maintain the state of those components across visibility and also deprioritize the work it does for hidden components. And if you've been drinking your lead lace protein and want to live on the edge, you can install React's Canary version where you'll get the new view transition component for animating transitions between different UIs. And you'll be able to pass a ref to a fragment, which makes working with platform APIs a little easier. But the most impactful announcements of the conference may have not been related to the React API. The first React compiler finally hit 1.0. If you've been in any React codebase lately, you've...",
- "relevance": 0.7,
- "why_relevant": "YouTube video about react vs svelte 2026",
- "subs": {
- "relevance": 70,
- "recency": 0,
- "engagement": 99
- },
- "score": 61
- },
- {
- "id": "MnpuK0MK4yo",
- "title": "React VS Svelte...10 Examples",
- "url": "https://www.youtube.com/watch?v=MnpuK0MK4yo",
- "channel_name": "Beyond Fireship",
- "date": "2023-06-30",
- "date_confidence": "high",
- "engagement": {
- "num_comments": 1100,
- "likes": 25490,
- "views": 697106
- },
- "transcript_snippet": "any reasonable developer in today's world would learn react because it's the status quo and that's where the jobs are but life is a lot more fun when you become unreasonable and go against the status quo to push web development forward for future Generations in today's video we'll compare react and spelled side by side by looking at common patterns and design choices by the framework creators I'm not here to tell you that one is better than the other because that's what the comment section below this video is for the only way to truly find out which one is best is to build something with both of them and decide for yourself I just released a brand new full spell kick course yesterday and also have a full next js13 course for fireship pro members going through each one of these courses will give you a really good idea of which framework is best for you first up we need to talk about rendering both of these Frameworks do the same thing they help developers build reactive uis with JavaScript by organizing code into reusable components an end user would never be able to tell the difference between the two but when it comes to the developer experience there is a world of difference react.js uses a runtime called the virtual Dom it keeps track of data changes in the application in order to render them in the act actual Dom in the browser the drawback is that this runtime requires some initial JavaScript and in Frameworks like nextjs your Baseline is around 70 kilobytes just to render a hello world as felt on the other hand takes an entirely different approach using a compiler to eliminate the need for a runtime it takes your svelt code and converts it into vanilla JavaScript which results in a far smaller hello world using a compiler though is kind of like cheating react.js is just JavaScript whereas spill can take non-javascript code to allow developers to do things more efficiently than they could otherwise but some JavaScript fundamentalists might consider this black magic what's ironic though is that vanilla JavaScript libraries tend to be much easier to work with in spell when compared to react however react does have a massive ecosystem of dedicated libraries to help you get things done now let's look at an actual code example of component State here we have a basic counter app in react we use functions to create components and then add reactive state to them with the use State hook that returns a reactive value and a Setter function to update the state pretty simple but let's see how it compares to svelt here on the right side in spelled you have only one component file and Define the logic within the script tags to create reactive State all you do is create a variable with the let keyword from there we can define a function on the click event...",
- "relevance": 0.7,
- "why_relevant": "YouTube video about react vs svelte 2026",
- "subs": {
- "relevance": 70,
- "recency": 0,
- "engagement": 100
- },
- "score": 61
- },
- {
- "id": "aYyZUDFZTrM",
- "title": "JavaScript framework reinvents itself\u2026 Did \"runes\" just ruin Svelte?",
- "url": "https://www.youtube.com/watch?v=aYyZUDFZTrM",
- "channel_name": "Fireship",
- "date": "2024-10-24",
- "date_confidence": "high",
- "engagement": {
- "num_comments": 1200,
- "likes": 24456,
- "views": 547841
- },
- "transcript_snippet": "about a year ago I made a tweet that said the dollar sign in spelt is the most powerful abstraction in modern front-end web development usually I'm not wrong about anything ever but this tweet ended up aging like a fine milk because just Weeks Later spelt announced a brand new feature called runes that significantly changed the developer experience by eliminating the dollar sign Syntax for reactive data and then just a few days ago this feature became official with the release of spelt 5 as an avid user of spelt myself I was pretty terrified of this feature at first glance I was one of the few survivors of the angularjs to angular 2 migration I shot myself in the foot during the react hooks Revolution and I lost some good friends during the view options to composition transition and I just can't do this anymore man so maybe I'll just go back to Wordpress uh never mind spelt it is in today's video we'll find out what the hell a rune is and whether or not they're going to ruin spelt it is October 24th 2024 and you're watching the code report most people really liked runes when they were first announced but not everybody I was in denial at first then I got angry then I bargained about the tradeoffs then came depression and finally I've come to accept and really enjoy runes the big question is why would the most L JavaScript framework make a risky departure from the features people love about it like reactive state with let and reactive side effects with the dollar sign just look at how much nicer this code is compared to reactjs react is even copy and spelt with its own compiler to make this code not suck so much the spelt code is lean and concise but one could argue that it's almost too clever let looks exact like regular JavaScript but because spelt uses a compiler it doesn't actually work like regular JavaScript under the hood the compiler does some magic to automatically update the UI whenever this value changes another issue is that this magic only works in a spelt file and that means it's difficult to share reactive logic throughout your application and spelt created an entirely different API called stores to address that issue however runes fix both of these limitations by a making your code more explicit and B they create a universal reactivity system that can be used not only in spel components but also in plain JavaScript or typescript files and this dramatically simplifies the overall framework like once you learn these four runes you'll know about 90% of what you need to know for the framework because everything else works like normal JavaScript and HTML let's go ahead and learn the four major runes now which you can think of as compiler macros that tell spelt to do something special that doesn't behave like regular JavaScript or HTML a...",
- "relevance": 0.7,
- "why_relevant": "YouTube video about react vs svelte 2026",
- "subs": {
- "relevance": 70,
- "recency": 0,
- "engagement": 98
- },
- "score": 60
- },
- {
- "id": "k7LhsqnJCe4",
- "title": "Htmx vs Svelte (2026) - Which One Is BETTER?",
- "url": "https://www.youtube.com/watch?v=k7LhsqnJCe4",
- "channel_name": "Paperclick",
- "date": "2026-02-19",
- "date_confidence": "high",
- "engagement": {
- "num_comments": 0,
- "likes": 1,
- "views": 26
- },
- "transcript_snippet": "",
- "relevance": 0.7,
- "why_relevant": "YouTube video about react vs svelte 2026",
- "subs": {
- "relevance": 70,
- "recency": 80,
- "engagement": 0
- },
- "score": 51
- },
- {
- "id": "IpJh0VEzMRo",
- "title": "I Was Wrong About Svelte...",
- "url": "https://www.youtube.com/watch?v=IpJh0VEzMRo",
- "channel_name": "Ben Davis",
- "date": "2025-10-12",
- "date_confidence": "high",
- "engagement": {
- "num_comments": 122,
- "likes": 1043,
- "views": 24631
- },
- "transcript_snippet": "",
- "relevance": 0.7,
- "why_relevant": "YouTube video about react vs svelte 2026",
- "subs": {
- "relevance": 70,
- "recency": 0,
- "engagement": 66
- },
- "score": 51
- },
- {
- "id": "qwDp5pZA_TA",
- "title": "The Front-end Frameworks Guide 2026: React vs Angular vs Vue vs Svelt vs Solid vs Astro",
- "url": "https://www.youtube.com/watch?v=qwDp5pZA_TA",
- "channel_name": "Code Hub",
- "date": "2026-02-08",
- "date_confidence": "high",
- "engagement": {
- "num_comments": 4,
- "likes": 31,
- "views": 640
- },
- "transcript_snippet": "",
- "relevance": 0.7,
- "why_relevant": "YouTube video about react vs svelte 2026",
- "subs": {
- "relevance": 70,
- "recency": 43,
- "engagement": 29
- },
- "score": 50
- },
- {
- "id": "1cGtYEXGm8c",
- "title": "This is THE Framework You Should be Using in 2026",
- "url": "https://www.youtube.com/watch?v=1cGtYEXGm8c",
- "channel_name": "Ben Davis",
- "date": "2026-01-06",
- "date_confidence": "high",
- "engagement": {
- "num_comments": 142,
- "likes": 793,
- "views": 16900
- },
- "transcript_snippet": "",
- "relevance": 0.7,
- "why_relevant": "YouTube video about react vs svelte 2026",
- "subs": {
- "relevance": 70,
- "recency": 0,
- "engagement": 64
- },
- "score": 50
- },
- {
- "id": "_vuVy21l2bU",
- "title": "React vs Svelte: The Brutal Honest Comparison EVER",
- "url": "https://www.youtube.com/watch?v=_vuVy21l2bU",
- "channel_name": "Code Hub",
- "date": "2025-10-30",
- "date_confidence": "high",
- "engagement": {
- "num_comments": 27,
- "likes": 204,
- "views": 6454
- },
- "transcript_snippet": "",
- "relevance": 0.7,
- "why_relevant": "YouTube video about react vs svelte 2026",
- "subs": {
- "relevance": 70,
- "recency": 0,
- "engagement": 51
- },
- "score": 46
- },
- {
- "id": "1BCsdaeYv0A",
- "title": "Svelte vs React in 2025 - Make the RIGHT Choice (Difference Explained)",
- "url": "https://www.youtube.com/watch?v=1BCsdaeYv0A",
- "channel_name": "Daniel | Tech & Data",
- "date": "2023-11-26",
- "date_confidence": "high",
- "engagement": {
- "num_comments": 34,
- "likes": 0,
- "views": 33905
- },
- "transcript_snippet": "",
- "relevance": 0.7,
- "why_relevant": "YouTube video about react vs svelte 2026",
- "subs": {
- "relevance": 70,
- "recency": 0,
- "engagement": 40
- },
- "score": 43
- },
- {
- "id": "41HXdxGekZ0",
- "title": "Vue vs React vs Svelte (2026) - Which One Is BEST?",
- "url": "https://www.youtube.com/watch?v=41HXdxGekZ0",
- "channel_name": "Paperclick",
- "date": "2025-10-04",
- "date_confidence": "high",
- "engagement": {
- "num_comments": 0,
- "likes": 3,
- "views": 287
- },
- "transcript_snippet": "",
- "relevance": 0.7,
- "why_relevant": "YouTube video about react vs svelte 2026",
- "subs": {
- "relevance": 70,
- "recency": 0,
- "engagement": 15
- },
- "score": 36
- }
- ],
- "best_practices": [],
- "prompt_pack": [],
- "context_snippet_md": "# Context: React vs Svelte 2026 (Last 30 Days)\n\n*Generated: 2026-02-25 | Sources: both*\n\n## Key Sources\n\n- [X] TypeScript is the baseline in 2026. But most forms...\n- [Reddit] What do you guys think about comparison between React vs Svelte?\n- [Reddit] Migrated our startup from React to Svelte 5 - Performance gains and lessons learned\n- [X] Popular JavaScript Frameworks / Frontend Libraries...\n- [X] Requirements for a fresher in 2026:\n\nPython, JavaS...\n- [X] Ripple: The Good Parts of React, Svelte, and Solid...\n- [X] Prediction: 2026 marks the death of the JS framewo...\n\n## Summary\n\n*See full report for best practices, prompt pack, and detailed sources.*\n"
-}
diff --git a/docs/comparison-results/json/cross-1-claude-code.json b/docs/comparison-results/json/cross-1-claude-code.json
deleted file mode 100644
index 9d12667..0000000
--- a/docs/comparison-results/json/cross-1-claude-code.json
+++ /dev/null
@@ -1,1016 +0,0 @@
-{
- "topic": "Claude Code skills and MCP servers",
- "range": {
- "from": "2026-01-26",
- "to": "2026-02-25"
- },
- "generated_at": "2026-02-25T20:30:18.168015+00:00",
- "mode": "both",
- "openai_model_used": "gpt-5.2",
- "xai_model_used": "grok-4-1-fast",
- "reddit": [
- {
- "id": "R1",
- "title": "ClaudeInOne \u2014 a full framework for Claude Code, installed in one command",
- "url": "https://www.reddit.com/r/ClaudeCode/comments/1rcbria/claudeinone_a_full_framework_for_claude_code/",
- "subreddit": "ClaudeCode",
- "date": "2026-02-23",
- "date_confidence": "high",
- "engagement": null,
- "top_comments": [],
- "comment_insights": [],
- "relevance": 0.95,
- "why_relevant": "Directly about Claude Code skills (213 skills) and a reusable framework for Claude Code workflows.",
- "subs": {
- "relevance": 95,
- "recency": 93,
- "engagement": 50
- },
- "score": 78
- },
- {
- "id": "R10",
- "title": "Claude Code works great\u2026 until you have too many MCP servers",
- "url": "https://www.reddit.com/r/ClaudeCode/comments/1rehy2x/claude_code_works_great_until_you_have_too_many/",
- "subreddit": "ClaudeCode",
- "date": "2026-02-25",
- "date_confidence": "high",
- "engagement": null,
- "top_comments": [],
- "comment_insights": [],
- "relevance": 0.9,
- "why_relevant": "Scaling issues and architecture discussion when using many MCP servers with Claude Code (mentions gateway).",
- "subs": {
- "relevance": 90,
- "recency": 100,
- "engagement": 50
- },
- "score": 77
- },
- {
- "id": "R2",
- "title": "I built an MCP server that gives Claude Code a semantic graph of your codebase - went from ~15k tokens of grep output to ~3k of relevant context",
- "url": "https://www.reddit.com/r/ClaudeCode/comments/1rbu1nq/i_built_an_mcp_server_that_gives_claude_code_a/",
- "subreddit": "ClaudeCode",
- "date": "2026-02-22",
- "date_confidence": "high",
- "engagement": null,
- "top_comments": [],
- "comment_insights": [],
- "relevance": 0.95,
- "why_relevant": "An MCP server specifically designed to improve Claude Code context retrieval and reduce token waste.",
- "subs": {
- "relevance": 95,
- "recency": 90,
- "engagement": 50
- },
- "score": 77
- },
- {
- "id": "R9",
- "title": "I built 25 MCP servers so Claude Code stops wasting tokens on terminal formatting",
- "url": "https://www.reddit.com/r/ClaudeAI/comments/1rd8z0d/i_built_25_mcp_servers_so_claude_code_stops/",
- "subreddit": "ClaudeAI",
- "date": "2026-02-24",
- "date_confidence": "high",
- "engagement": null,
- "top_comments": [],
- "comment_insights": [],
- "relevance": 0.9,
- "why_relevant": "Large set of MCP servers aimed at Claude Code usage; about MCP servers returning structured data.",
- "subs": {
- "relevance": 90,
- "recency": 96,
- "engagement": 50
- },
- "score": 76
- },
- {
- "id": "R4",
- "title": "We 3x'd our team's Claude Code skill usage in 2 weeks \u2014 here's how",
- "url": "https://www.reddit.com/r/ClaudeCode/comments/1rbr5t7/we_3xd_our_teams_claude_code_skill_usage_in_2/",
- "subreddit": "ClaudeCode",
- "date": "2026-02-22",
- "date_confidence": "high",
- "engagement": null,
- "top_comments": [],
- "comment_insights": [],
- "relevance": 0.92,
- "why_relevant": "Team-level discussion of increasing Claude Code skill adoption; mentions syncing skills and MCP configs.",
- "subs": {
- "relevance": 92,
- "recency": 90,
- "engagement": 50
- },
- "score": 75
- },
- {
- "id": "R8",
- "title": "I used Claude Code to build an MCP proxy server that gives it safe(r) access to my email. Here's what I learned about the security challenges",
- "url": "https://www.reddit.com/r/ClaudeAI/comments/1r99zbv/i_used_claude_code_to_build_an_mcp_proxy_server/",
- "subreddit": "ClaudeAI",
- "date": "2026-02-19",
- "date_confidence": "high",
- "engagement": null,
- "top_comments": [],
- "comment_insights": [],
- "relevance": 0.9,
- "why_relevant": "MCP server project built for/with Claude Code; discusses MCP server design and security tradeoffs.",
- "subs": {
- "relevance": 90,
- "recency": 80,
- "engagement": 50
- },
- "score": 72
- },
- {
- "id": "R3",
- "title": "Self-improvement Loop: My favorite Claude Code Skill",
- "url": "https://www.reddit.com/r/ClaudeCode/comments/1r89084/selfimprovement_loop_my_favorite_claude_code_skill/",
- "subreddit": "ClaudeCode",
- "date": "2026-02-18",
- "date_confidence": "high",
- "engagement": null,
- "top_comments": [],
- "comment_insights": [],
- "relevance": 0.93,
- "why_relevant": "Focused on a concrete Claude Code skill (\u201cwrap-up\u201d) and how skills are used in real sessions.",
- "subs": {
- "relevance": 93,
- "recency": 76,
- "engagement": 50
- },
- "score": 72
- },
- {
- "id": "R5",
- "title": "I built 12 SEO skills for Claude Code (open source)",
- "url": "https://www.reddit.com/r/ClaudeCode/comments/1r0l549/i_built_12_seo_skills_for_claude_code_open_source/",
- "subreddit": "ClaudeCode",
- "date": "2026-02-10",
- "date_confidence": "high",
- "engagement": null,
- "top_comments": [],
- "comment_insights": [],
- "relevance": 0.9,
- "why_relevant": "Showcase of multiple Claude Code skills and how they run via slash commands/subagents.",
- "subs": {
- "relevance": 90,
- "recency": 50,
- "engagement": 50
- },
- "score": 65
- },
- {
- "id": "R6",
- "title": "[Skill Viewer] Built a Chrome extension to view Claude Code skills on GitHub - seeking feedback",
- "url": "https://www.reddit.com/r/ClaudeCode/comments/1qtq7yr/skill_viewer_built_a_chrome_extension_to_view/",
- "subreddit": "ClaudeCode",
- "date": "2026-02-02",
- "date_confidence": "high",
- "engagement": null,
- "top_comments": [],
- "comment_insights": [],
- "relevance": 0.88,
- "why_relevant": "Tooling around discovering/viewing Claude Code skills; directly about skills ecosystem.",
- "subs": {
- "relevance": 88,
- "recency": 23,
- "engagement": 50
- },
- "score": 57
- },
- {
- "id": "R14",
- "title": "Difference between Skills and these: Subagents, Claude.MD and slash commands?",
- "url": "https://www.reddit.com/r/ClaudeCode/comments/1o8t6xe/difference_between_skills_and_these_subagents/",
- "subreddit": "ClaudeCode",
- "date": null,
- "date_confidence": "low",
- "engagement": null,
- "top_comments": [],
- "comment_insights": [],
- "relevance": 0.82,
- "why_relevant": "Conceptual thread clarifying what Skills are versus subagents/CLAUDE.md/slash commands (relevant to skills usage).",
- "subs": {
- "relevance": 82,
- "recency": 0,
- "engagement": 50
- },
- "score": 43
- }
- ],
- "x": [
- {
- "id": "X5",
- "text": "@adamwathan Codex has been crushing it for me with implicit skill usage to the point I was shocked \n\nClaude Code I resorted to explicit mentions and I\u2019ve been pleasantly surprised by how well Codex has been calling out to skills on its own",
- "url": "https://x.com/zeeg/status/2026745680195367091",
- "author_handle": "zeeg",
- "date": "2026-02-25",
- "date_confidence": "high",
- "engagement": {
- "likes": 5,
- "reposts": 1,
- "replies": 2
- },
- "relevance": 0.7,
- "why_relevant": "",
- "subs": {
- "relevance": 70,
- "recency": 100,
- "engagement": 100
- },
- "score": 86
- },
- {
- "id": "X3",
- "text": "\ud83d\udea8 Anthropic just open-sourced the exact Skills library their own engineers use internally.\n\nStop building Claude workflows from scratch.\n\nThese are plug-and-play components that work across Claude Code, API, SDK, and VS Code copy once, deploy everywhere.\n\nWhat's inside:\n\n\u2192 Excel + PowerPoint generation out of the box\n\u2192 File handling and document workflows\n\u2192 MCP-ready subagent building blocks\n\u2192 Pre-built patterns for multi-step automation\n\u2192 Production templates you'd normally spend weeks writing\n",
- "url": "https://x.com/ihtesham2005/status/2026752089473314975",
- "author_handle": "ihtesham2005",
- "date": "2026-02-25",
- "date_confidence": "high",
- "engagement": {
- "likes": 6
- },
- "relevance": 0.7,
- "why_relevant": "",
- "subs": {
- "relevance": 70,
- "recency": 100,
- "engagement": 73
- },
- "score": 78
- },
- {
- "id": "X8",
- "text": "Day 7 building my first B2C app\n\nBeen setting everything up this week. Manually built 5 agents to help me in the early stages and set up the Claude Code skills i'll need for the build.\n\nAlso been testing OpenClaw these days, already delegated my expense tracking to it.\n\nNow the scary part: Figuring out the visual identity of the app. my design skills are basically zero, i always used to delegate this to someone with actual talent haha. \n\nAny recommendations to learn the fundamentals? would reall",
- "url": "https://x.com/DevTenta/status/2026742028730527786",
- "author_handle": "DevTenta",
- "date": "2026-02-25",
- "date_confidence": "high",
- "engagement": {
- "likes": 4
- },
- "relevance": 0.7,
- "why_relevant": "",
- "subs": {
- "relevance": 70,
- "recency": 100,
- "engagement": 53
- },
- "score": 72
- },
- {
- "id": "X7",
- "text": "@Jompiras Claude code + skills. Muy breve.",
- "url": "https://x.com/JorgeJaramillo/status/2026743135435362649",
- "author_handle": "JorgeJaramillo",
- "date": "2026-02-25",
- "date_confidence": "high",
- "engagement": null,
- "relevance": 0.7,
- "why_relevant": "",
- "subs": {
- "relevance": 70,
- "recency": 100,
- "engagement": 35
- },
- "score": 64
- },
- {
- "id": "X4",
- "text": "@tom_doerr 135 agents, 35 curated skills, 121 plugins and 6 MCP configs all in one Claude Code toolkit is absolutely insane value \ud83e\udd2f this is the kind of community effort that makes an AI tool go from good to unstoppable. bookmarking this immediately \ud83d\udd25",
- "url": "https://x.com/arpan7sarkar/status/2026746479369662551",
- "author_handle": "arpan7sarkar",
- "date": "2026-02-25",
- "date_confidence": "high",
- "engagement": null,
- "relevance": 0.7,
- "why_relevant": "",
- "subs": {
- "relevance": 70,
- "recency": 100,
- "engagement": 35
- },
- "score": 64
- },
- {
- "id": "X6",
- "text": "@tom_doerr A curated collection of Claude Skills organized by category is exactly what power users have been waiting for \ud83e\udd29 document skills, code tools, data analysis all in one repo. this is the Claude plugin store before the Claude plugin store \ud83d\udd25",
- "url": "https://x.com/arpan7sarkar/status/2026745559986614335",
- "author_handle": "arpan7sarkar",
- "date": "2026-02-25",
- "date_confidence": "high",
- "engagement": null,
- "relevance": 0.7,
- "why_relevant": "",
- "subs": {
- "relevance": 70,
- "recency": 100,
- "engagement": 35
- },
- "score": 64
- },
- {
- "id": "X2",
- "text": "Claude Code\u306eSkills\u3092\u4f5c\u6210\u4f8b\u304b\u3089\u5fb9\u5e95\u7406\u89e3\u3059\u308b https://t.co/vZTseio2Ii",
- "url": "https://x.com/yasuky/status/2026754773786226708",
- "author_handle": "yasuky",
- "date": "2026-02-25",
- "date_confidence": "high",
- "engagement": null,
- "relevance": 0.7,
- "why_relevant": "",
- "subs": {
- "relevance": 70,
- "recency": 100,
- "engagement": 35
- },
- "score": 64
- },
- {
- "id": "X12",
- "text": "Day 52 (01/21/2026) (Rest Day)\n\n> Go to New York + watch Broadway Show\n\n> Catch up on claude code updates on way back (2hrs)\n - Learn difference between skills + MCP, and etc\n \n> Neetcode (1hr 15min)\n\n> Plan for the week (30min)\n \nTotal focused working hours: 3hrs 45min",
- "url": "https://x.com/zhuoyuan45514/status/2026733326631923776",
- "author_handle": "zhuoyuan45514",
- "date": "2026-02-25",
- "date_confidence": "high",
- "engagement": null,
- "relevance": 0.7,
- "why_relevant": "",
- "subs": {
- "relevance": 70,
- "recency": 100,
- "engagement": 35
- },
- "score": 64
- },
- {
- "id": "X1",
- "text": "How: I told Claude Code \"build me a CRM.\" Our builder skill generated schemas, skills, server, seed data. I pointed Claude Desktop at it. Contacts, lead scoring, pipeline reviews. All from conversation.\n\nThen \"build me a research assistant.\" Topics, sources, notes, etc. Same flow",
- "url": "https://x.com/matgoldsborough/status/2026755742737510749",
- "author_handle": "matgoldsborough",
- "date": "2026-02-25",
- "date_confidence": "high",
- "engagement": {
- "replies": 1
- },
- "relevance": 0.7,
- "why_relevant": "",
- "subs": {
- "relevance": 70,
- "recency": 100,
- "engagement": 35
- },
- "score": 64
- },
- {
- "id": "X9",
- "text": "I can build another open claw, or claude bot, or even a messaging app in a nice prompt and although having engineering skills, I can push the code on the public repo and secure it even completely and may start marketing in a crazy way on X or maybe Reddit using a secret niche guy when writing a post.\n\nThis is the new coding for us, and shipping a nice product.",
- "url": "https://x.com/0x_Kapoor/status/2026738663321907214",
- "author_handle": "0x_Kapoor",
- "date": "2026-02-25",
- "date_confidence": "high",
- "engagement": null,
- "relevance": 0.7,
- "why_relevant": "",
- "subs": {
- "relevance": 70,
- "recency": 100,
- "engagement": 35
- },
- "score": 64
- },
- {
- "id": "X10",
- "text": "1. I use https://t.co/NtFYJEiFs9 inspired by @bcherny \n2. I use https://t.co/EwnmyWNFqn to sync skills and scan skills from the marketplace\n3. I chat with claude code 4-5 times and once I am satisifed with plan, I ask it to build it.\n4. I use /code-simplifier, /memory and /parallel from my plugin above to distribute work across agents.\n5. I wait and once they're done, I use coderabbit:review to review once locally and make a PR\n\nThis process works like a gem for me.",
- "url": "https://x.com/ghumare64/status/2026736176695246966",
- "author_handle": "ghumare64",
- "date": "2026-02-25",
- "date_confidence": "high",
- "engagement": {
- "likes": 1
- },
- "relevance": 0.7,
- "why_relevant": "",
- "subs": {
- "relevance": 70,
- "recency": 100,
- "engagement": 0
- },
- "score": 56
- },
- {
- "id": "X11",
- "text": "The 'vibe coding' revolution is forcing a reckoning.\n\nDevelopers with 15+ years experience are either embracing AI to expand their reach or watching their expertise become as valuable as COBOL skills.\n\nWhich side of history are you on?\n\nPS\nClaude Code can now write COBOL as well.",
- "url": "https://x.com/hamen/status/2026734075243790396",
- "author_handle": "hamen",
- "date": "2026-02-25",
- "date_confidence": "high",
- "engagement": {
- "likes": 1
- },
- "relevance": 0.7,
- "why_relevant": "",
- "subs": {
- "relevance": 70,
- "recency": 100,
- "engagement": 0
- },
- "score": 56
- }
- ],
- "web": [],
- "youtube": [
- {
- "id": "X8afcX2s2Mo",
- "title": "Claude Skills: Build Your First AI Marketing Team in 16 Minutes (Claude Code)",
- "url": "https://www.youtube.com/watch?v=X8afcX2s2Mo",
- "channel_name": "Grace Leung",
- "date": "2026-02-21",
- "date_confidence": "high",
- "engagement": {
- "num_comments": 47,
- "likes": 1900,
- "views": 47512
- },
- "transcript_snippet": "Every marketer I know is stretch themed. Too many channels, too many deliverables, never enough time, whether you're running a team or doing it all yourself. And that's why I'm so obsessed with clock skills. You teach it how your marketing works once and it just deliver every time. And so even with just a few clock skills, you can start building a marketing team with serious leverage. And so in this video, I will show you how to build your first AI marketing team using just clock scale. So everyone is talking about cloud agents and yes they are super powerful but what enable them to be truly powerful is the expertise behind this agent and that expertise is exactly what cloud skills are. So when you're designing your skill library for your marketing team these skills contain your best marketing knowledge SOPs your proven frameworks your brand standards and agents are now the team members who use that expertise. You can equip any agents with any combination of skills from your library and that's what makes this powerful because you build this library once and cl as a team that runs on it or even you can bring it to all the AI models. So today we're building five skills that cover what most marketing team would need. Research and strategy, content creation, creatives, data analysis, and campaign presentation. Essentially, they will form a strong foundation as you build your marking skill library over time. For this demo, we'll be using Clark Cook Desktop. But if you prefer using Clark work is still fine. So, first thing is to make sure you already got the skill creator tool installed because this official skill is not building inside Clark. I will put the official skill with link and also check out this video on how to install. So here we have already set up a project folder called simple home which is the DTOC e-commerce brand and inside we have different folders and the most important is the context file. So every time when you onboard a new client or you are an in-house marketers these are the context file about the brand the product the audience etc. So you can equip cloth with all the background information and always include a clotmpd file. So these are just like a custom instructions for these project to guide how clock should navigate your folder system. So I go much deeper on how to set up these contact system inside our AI for marketing program with all the templates. So if you're interested you can find the link below to join. But for now let's create our first skill marketing research and strategy. So here inside the SOP folder we already have these SOP document that the brand is already using for marketing research and building strategy reports. So now on Clark Cook desktop, select the same project folder for this brand and then use this prompt to answer it to...",
- "relevance": 0.6,
- "why_relevant": "YouTube: Claude Skills: Build Your First AI Marketing Team in 16 Minu",
- "subs": {
- "relevance": 60,
- "recency": 86,
- "engagement": 70
- },
- "score": 69
- },
- {
- "id": "0J2_YGuNrDo",
- "title": "Claude Code just Built me an AI Agent Team (Claude Code + Skills + MCP)",
- "url": "https://www.youtube.com/watch?v=0J2_YGuNrDo",
- "channel_name": "Grace Leung",
- "date": "2025-12-16",
- "date_confidence": "high",
- "engagement": {
- "num_comments": 291,
- "likes": 4760,
- "views": 155806
- },
- "transcript_snippet": "I use clot more than any other AI but even I dismiss Clark as just for developers and I was wrong after using myself I am completely so and this is too powerful to ignore if you want to use AI to build system and do work for you end to end not just chatting and copy pasting so in this video I'll share how to get started with cloud code even if you don't use for coding and most importantly how to use it to build an AI work team and system that you can actually use. Let's go. So why claw code is even needed if claw is already so powerful. So most people use cloud projects with custom instructions to turn claw into AI agents. But they are silo. You end up copy and pasting between them. You are the coordinator. But with cloud code is different. So these cloud agents share the same workspace access to the same context file. They can talk to each other and hands up work automatically each with their own token context window and claude now becomes the team lead. You give the direction they execute together and that is what we are building today. So each of these cloud code agents you create needs at least three core components all defined in one single markdown file. the role and responsibilities, the knowledge like workflow details, reusable clot agent skills, the MCP tools that they have access to. For this demo, we'll be building a five agent work and claude, our main agent will add as our team lead to coordinate between these agent. A bonus tip is when designing your AI teammate, give them specific and non-over overlapping roles to minimize the potential conflicts. Now, to get started with clark code, there are four main ways to access it. First, the local terminal. This is the raw method and will give you the best experience. Luckily, Anthroporic just launched a native installer. So, which makes it really easy to install. I'll put the official link below. And second, ID extension. So, you can add CL code to ID like VS Code or Kursa and let you run the CL code and see the project file structure side by side. Third, the cloud web app where you can click the code tab and run the CL code in the cloud, but it requires you linking to a GitHub repository. and fourth, cloud desktop app which has cloud code built-in and this is what we'll be using today. Now, whatever way you choose, I still highly recommend installing cloud code through the terminal first because the terminal gives you the most complete experience. All commands and features work fully since we're running Clark locally. So, the first thing is to set up a local project folder. So, think of it like a dedicated workspace for Clark. So, let's say I run a marketing agency and I'm setting up the TIM system. So...",
- "relevance": 0.8,
- "why_relevant": "YouTube: Claude Code just Built me an AI Agent Team (Claude Code + Sk",
- "subs": {
- "relevance": 80,
- "recency": 0,
- "engagement": 100
- },
- "score": 66
- },
- {
- "id": "Gqh_KdHP1Xk",
- "title": "8 MCP Servers That Make Claude Code 10x Better",
- "url": "https://www.youtube.com/watch?v=Gqh_KdHP1Xk",
- "channel_name": "Robin Ebers",
- "date": "2025-08-21",
- "date_confidence": "high",
- "engagement": {
- "num_comments": 108,
- "likes": 2719,
- "views": 90281
- },
- "transcript_snippet": "Over 90% of all MCP servers are complete overhyped garbage. But some of them are not only worth it, they can be complete game changers. And I do not say this lightly. So after testing over 100 of them, here are the only eight MCP servers that I actually use to build apps even faster. But first, let me save you from making the same expensive mistake that I made when I started using MCP servers. Because what no one explains about them is that the more of them you install, the dumber your AI becomes. I know this sounds backwards because everyone thinks more tools equals smarter AI. But here is what's actually happening because every MCP server you add is like giving your AI yet another tool to choose from. Imagine asking someone to fix your bike, but instead of giving them a toolbox with eight essential tools, you just dump 100 tools on the floor. they will spend more time figuring out which tool to use than actually fixing your bike. That is exactly what happens with claude and chatbt too because every MCP server needs instructions in the context window or chat when you start. But new research now shows that this is actually really bad for AI. It's like trying to focus on a specific task while browsing social media. You're just pumping useless information into your brain. And that is kind of what happens too when you use too many MCP servers in your AI. So, if you want your AI to stay focused, remove all distractions. I stripped my list down to these eight MCP servers. And even then, I don't use all of them in every project. I turn them on and off because the fewer tools you give your AI, the more focused it stays. So, now let's look at my full list. Hey, what's up, guys? My name is Rob and I've been a coder for over 20 years. But now, I teach people how to build their ideas with AI in ways that even non-technical people can understand. I do this here on YouTube and inside of my AI coding blueprint, which you can check out in the description down below. Starting with number eight, almost every app that you build will need real world data. This could be stock prices, news articles, YouTube comments, or Instagram profiles. And that is where this MCP server comes in because this turns your AI into a data gathering machine. API is a marketplace for web scrapers, which you can think of as little robots that go all over the internet and grab information from any website that you want. And here is the really crazy part, because this MCP server doesn't just help you pull the data into your app. It actually makes accessing the full documentation of every web scraper available on this platform very easy. It can also help you debug them when things go wrong or check the...",
- "relevance": 0.8,
- "why_relevant": "YouTube: 8 MCP Servers That Make Claude Code 10x Better",
- "subs": {
- "relevance": 80,
- "recency": 0,
- "engagement": 84
- },
- "score": 61
- },
- {
- "id": "vIUJ4Hd7be0",
- "title": "Claude Code Skills - The Only Tutorial You Need",
- "url": "https://www.youtube.com/watch?v=vIUJ4Hd7be0",
- "channel_name": "Leon van Zyl",
- "date": "2026-02-09",
- "date_confidence": "high",
- "engagement": {
- "num_comments": 63,
- "likes": 699,
- "views": 25538
- },
- "transcript_snippet": "",
- "relevance": 0.6,
- "why_relevant": "YouTube: Claude Code Skills - The Only Tutorial You Need",
- "subs": {
- "relevance": 60,
- "recency": 46,
- "engagement": 54
- },
- "score": 54
- },
- {
- "id": "901VMcZq8X4",
- "title": "These 5 Claude Code Skills Are Your New Unfair Advantage",
- "url": "https://www.youtube.com/watch?v=901VMcZq8X4",
- "channel_name": "Sean Kochel",
- "date": "2025-10-21",
- "date_confidence": "high",
- "engagement": {
- "num_comments": 65,
- "likes": 1356,
- "views": 44618
- },
- "transcript_snippet": "",
- "relevance": 0.6,
- "why_relevant": "YouTube: These 5 Claude Code Skills Are Your New Unfair Advantage",
- "subs": {
- "relevance": 60,
- "recency": 0,
- "engagement": 67
- },
- "score": 47
- },
- {
- "id": "M5CsRj6zSCA",
- "title": "5 MCP Servers That Make Claude Code 10x More Powerful (Full-Stack App Build)",
- "url": "https://www.youtube.com/watch?v=M5CsRj6zSCA",
- "channel_name": "Eric Tech",
- "date": "2025-12-12",
- "date_confidence": "high",
- "engagement": {
- "num_comments": 30,
- "likes": 266,
- "views": 11286
- },
- "transcript_snippet": "",
- "relevance": 0.8,
- "why_relevant": "YouTube: 5 MCP Servers That Make Claude Code 10x More Powerful (Full-",
- "subs": {
- "relevance": 80,
- "recency": 0,
- "engagement": 33
- },
- "score": 45
- },
- {
- "id": "l7qVtHpctic",
- "title": "Claude Code's MCP Problem Just Got Fixed",
- "url": "https://www.youtube.com/watch?v=l7qVtHpctic",
- "channel_name": "Kenny Liao",
- "date": "2026-01-17",
- "date_confidence": "high",
- "engagement": {
- "num_comments": 93,
- "likes": 728,
- "views": 21765
- },
- "transcript_snippet": "",
- "relevance": 0.6,
- "why_relevant": "YouTube: Claude Code's MCP Problem Just Got Fixed",
- "subs": {
- "relevance": 60,
- "recency": 0,
- "engagement": 54
- },
- "score": 43
- },
- {
- "id": "ZroGqu7GyXM",
- "title": "Claude Code Skills vs MCP vs Sub Agents: What Works for Solo Devs?",
- "url": "https://www.youtube.com/watch?v=ZroGqu7GyXM",
- "channel_name": "Solo Swift Crafter",
- "date": "2025-10-27",
- "date_confidence": "high",
- "engagement": {
- "num_comments": 13,
- "likes": 98,
- "views": 4885
- },
- "transcript_snippet": "",
- "relevance": 0.8,
- "why_relevant": "YouTube: Claude Code Skills vs MCP vs Sub Agents: What Works for Solo",
- "subs": {
- "relevance": 80,
- "recency": 0,
- "engagement": 10
- },
- "score": 39
- },
- {
- "id": "jzf7DQa2CAc",
- "title": "How I Use Claude Code With Skills, MCP, Agents & Plugins",
- "url": "https://www.youtube.com/watch?v=jzf7DQa2CAc",
- "channel_name": "Matt Kuda",
- "date": "2026-01-19",
- "date_confidence": "high",
- "engagement": {
- "num_comments": 8,
- "likes": 87,
- "views": 2547
- },
- "transcript_snippet": "",
- "relevance": 0.8,
- "why_relevant": "YouTube: How I Use Claude Code With Skills, MCP, Agents & Plugins",
- "subs": {
- "relevance": 80,
- "recency": 0,
- "engagement": 0
- },
- "score": 36
- },
- {
- "id": "qthyl0GCpDo",
- "title": "Claude Skills vs MCP: What\u2019s the Difference and When to Use Each?",
- "url": "https://www.youtube.com/watch?v=qthyl0GCpDo",
- "channel_name": "Postman",
- "date": "2025-11-20",
- "date_confidence": "high",
- "engagement": {
- "num_comments": 5,
- "likes": 105,
- "views": 7067
- },
- "transcript_snippet": "",
- "relevance": 0.6,
- "why_relevant": "YouTube: Claude Skills vs MCP: What\u2019s the Difference and When to Use ",
- "subs": {
- "relevance": 60,
- "recency": 0,
- "engagement": 12
- },
- "score": 30
- }
- ],
- "hackernews": [
- {
- "id": "HN1",
- "title": "Show HN: I built a 55K-word email marketing knowledge base and Claude Code skill",
- "url": "https://www.emailmarketingskill.com/",
- "hn_url": "https://news.ycombinator.com/item?id=47096335",
- "author": "cosmoblk",
- "date": "2026-02-21",
- "date_confidence": "high",
- "engagement": {
- "score": 10,
- "num_comments": 2
- },
- "top_comments": [
- {
- "score": 0,
- "date": null,
- "author": "tkel",
- "excerpt": "So it's an AI spambot?",
- "url": ""
- }
- ],
- "comment_insights": [
- "So it's an AI spambot?"
- ],
- "relevance": 0.86,
- "why_relevant": "HN story about Show HN: I built a 55K-word email marketing knowledge base a",
- "subs": {
- "relevance": 86,
- "recency": 86,
- "engagement": 100
- },
- "score": 90
- },
- {
- "id": "HN4",
- "title": "Show HN: AI Marketing Skills for Claude Code",
- "url": "https://github.com/superamped/ai-marketing-skills",
- "hn_url": "https://news.ycombinator.com/item?id=47154377",
- "author": "superamped",
- "date": "2026-02-25",
- "date_confidence": "high",
- "engagement": {
- "score": 3,
- "num_comments": 1
- },
- "top_comments": [],
- "comment_insights": [],
- "relevance": 0.79,
- "why_relevant": "HN story about Show HN: AI Marketing Skills for Claude Code",
- "subs": {
- "relevance": 79,
- "recency": 100,
- "engagement": 48
- },
- "score": 74
- },
- {
- "id": "HN12",
- "title": "Show HN: Poncho, a general agent harness built for the web",
- "url": "https://github.com/cesr/poncho-ai",
- "hn_url": "https://news.ycombinator.com/item?id=47061949",
- "author": "heycesr",
- "date": "2026-02-18",
- "date_confidence": "high",
- "engagement": {
- "score": 4,
- "num_comments": 2
- },
- "top_comments": [
- {
- "score": 0,
- "date": null,
- "author": "adriablancafort",
- "excerpt": "I have been trying it and it's very cool!",
- "url": ""
- }
- ],
- "comment_insights": [
- "I have been trying it and it's very cool!"
- ],
- "relevance": 0.69,
- "why_relevant": "HN story about Show HN: Poncho, a general agent harness built for the web",
- "subs": {
- "relevance": 69,
- "recency": 76,
- "engagement": 69
- },
- "score": 70
- },
- {
- "id": "HN11",
- "title": "Show HN: Axon \u2013 A Kubernetes-native framework for AI coding agents",
- "url": "https://github.com/axon-core/axon",
- "hn_url": "https://news.ycombinator.com/item?id=47137491",
- "author": "gjkim042",
- "date": "2026-02-24",
- "date_confidence": "high",
- "engagement": {
- "score": 1,
- "num_comments": 4
- },
- "top_comments": [],
- "comment_insights": [],
- "relevance": 0.68,
- "why_relevant": "HN story about Show HN: Axon \u2013 A Kubernetes-native framework for AI coding ",
- "subs": {
- "relevance": 68,
- "recency": 96,
- "engagement": 50
- },
- "score": 69
- },
- {
- "id": "HN14",
- "title": "Show HN: Turn Claude Code or Codex into proactive, autonomous 24/7 AI agents",
- "url": "https://github.com/suitedaces/dorabot",
- "hn_url": "https://news.ycombinator.com/item?id=47054100",
- "author": "alternateman",
- "date": "2026-02-17",
- "date_confidence": "high",
- "engagement": {
- "score": 3,
- "num_comments": 2
- },
- "top_comments": [],
- "comment_insights": [],
- "relevance": 0.65,
- "why_relevant": "HN story about Show HN: Turn Claude Code or Codex into proactive, autonomou",
- "subs": {
- "relevance": 65,
- "recency": 73,
- "engagement": 61
- },
- "score": 65
- },
- {
- "id": "HN2",
- "title": "Show HN: Agent skills to build photo, video and design editors on the web",
- "url": "https://github.com/imgly/agent-skills",
- "hn_url": "https://news.ycombinator.com/item?id=47073035",
- "author": "hauschildt",
- "date": "2026-02-19",
- "date_confidence": "high",
- "engagement": {
- "score": 3,
- "num_comments": 0
- },
- "top_comments": [],
- "comment_insights": [],
- "relevance": 0.82,
- "why_relevant": "HN story about Show HN: Agent skills to build photo, video and design edito",
- "subs": {
- "relevance": 82,
- "recency": 80,
- "engagement": 26
- },
- "score": 64
- },
- {
- "id": "HN8",
- "title": "Show HN: Upjack \u2013 Declarative framework for building apps over MCP",
- "url": "https://github.com/NimbleBrainInc/upjack",
- "hn_url": "https://news.ycombinator.com/item?id=47157331",
- "author": "barefootsanders",
- "date": "2026-02-25",
- "date_confidence": "high",
- "engagement": {
- "score": 1,
- "num_comments": 0
- },
- "top_comments": [],
- "comment_insights": [],
- "relevance": 0.72,
- "why_relevant": "HN story about Show HN: Upjack \u2013 Declarative framework for building apps ov",
- "subs": {
- "relevance": 72,
- "recency": 100,
- "engagement": 0
- },
- "score": 57
- },
- {
- "id": "HN7",
- "title": "Show HN: Gulama \u2013 Security-first open-source AI agent (OpenClaw alternative)",
- "url": "https://github.com/san-techie21/gulama-bot",
- "hn_url": "https://news.ycombinator.com/item?id=47031982",
- "author": "san-techie21",
- "date": "2026-02-16",
- "date_confidence": "high",
- "engagement": {
- "score": 1,
- "num_comments": 1
- },
- "top_comments": [],
- "comment_insights": [],
- "relevance": 0.73,
- "why_relevant": "HN story about Show HN: Gulama \u2013 Security-first open-source AI agent (OpenC",
- "subs": {
- "relevance": 73,
- "recency": 70,
- "engagement": 21
- },
- "score": 56
- },
- {
- "id": "HN15",
- "title": "AI Skills Platform (Stealth) \u2013 Technical Co-Founder \u2013 Remote (US) \u2013 Equity",
- "url": "",
- "hn_url": "https://news.ycombinator.com/item?id=47081777",
- "author": "martin-hall",
- "date": "2026-02-20",
- "date_confidence": "high",
- "engagement": {
- "score": 1,
- "num_comments": 0
- },
- "top_comments": [],
- "comment_insights": [],
- "relevance": 0.62,
- "why_relevant": "HN story about AI Skills Platform (Stealth) \u2013 Technical Co-Founder \u2013 Remote",
- "subs": {
- "relevance": 62,
- "recency": 83,
- "engagement": 0
- },
- "score": 48
- },
- {
- "id": "HN10",
- "title": "Show HN: SkillSandbox \u2013 Capability-based sandbox for AI agent skills (Rust)",
- "url": "https://github.com/theMachineClay/skillsandbox",
- "hn_url": "https://news.ycombinator.com/item?id=47027734",
- "author": "ClaytheMachine",
- "date": "2026-02-15",
- "date_confidence": "high",
- "engagement": {
- "score": 1,
- "num_comments": 0
- },
- "top_comments": [],
- "comment_insights": [],
- "relevance": 0.69,
- "why_relevant": "HN story about Show HN: SkillSandbox \u2013 Capability-based sandbox for AI agen",
- "subs": {
- "relevance": 69,
- "recency": 66,
- "engagement": 0
- },
- "score": 47
- },
- {
- "id": "HN6",
- "title": "Show HN: Indx.sh \u2013 Directory of AI coding rules, MCP servers, and tool",
- "url": "https://indx.sh",
- "hn_url": "https://news.ycombinator.com/item?id=46822378",
- "author": "micronink",
- "date": "2026-01-30",
- "date_confidence": "high",
- "engagement": {
- "score": 1,
- "num_comments": 2
- },
- "top_comments": [],
- "comment_insights": [],
- "relevance": 0.75,
- "why_relevant": "HN story about Show HN: Indx.sh \u2013 Directory of AI coding rules, MCP servers",
- "subs": {
- "relevance": 75,
- "recency": 13,
- "engagement": 34
- },
- "score": 47
- },
- {
- "id": "HN3",
- "title": "Show HN: GTM MCP Server- Let AI Manage Your Google Tag Manager Containers",
- "url": "https://github.com/paolobietolini/gtm-mcp-server",
- "hn_url": "https://news.ycombinator.com/item?id=46922159",
- "author": "paolobietolini",
- "date": "2026-02-07",
- "date_confidence": "high",
- "engagement": {
- "score": 1,
- "num_comments": 0
- },
- "top_comments": [],
- "comment_insights": [],
- "relevance": 0.79,
- "why_relevant": "HN story about Show HN: GTM MCP Server- Let AI Manage Your Google Tag Manag",
- "subs": {
- "relevance": 79,
- "recency": 40,
- "engagement": 0
- },
- "score": 45
- },
- {
- "id": "HN9",
- "title": "Show HN: ClawsMarket \u2013 Marketplace where AI agents discover tools",
- "url": "https://www.clawsmarket.com",
- "hn_url": "https://news.ycombinator.com/item?id=46878646",
- "author": "digitcatphd",
- "date": "2026-02-03",
- "date_confidence": "high",
- "engagement": {
- "score": 1,
- "num_comments": 0
- },
- "top_comments": [],
- "comment_insights": [],
- "relevance": 0.71,
- "why_relevant": "HN story about Show HN: ClawsMarket \u2013 Marketplace where AI agents discover ",
- "subs": {
- "relevance": 71,
- "recency": 26,
- "engagement": 0
- },
- "score": 38
- },
- {
- "id": "HN13",
- "title": "Show HN: Fluid.sh \u2013 Claude Code for Infrastructure",
- "url": "https://www.fluid.sh/blog/introducing-fluid",
- "hn_url": "https://news.ycombinator.com/item?id=46886358",
- "author": "aspectrr",
- "date": "2026-02-04",
- "date_confidence": "high",
- "engagement": {
- "score": 1,
- "num_comments": 0
- },
- "top_comments": [],
- "comment_insights": [],
- "relevance": 0.65,
- "why_relevant": "HN story about Show HN: Fluid.sh \u2013 Claude Code for Infrastructure",
- "subs": {
- "relevance": 65,
- "recency": 30,
- "engagement": 0
- },
- "score": 36
- },
- {
- "id": "HN5",
- "title": "Claude Code skill for building ChatGPT Apps",
- "url": "",
- "hn_url": "https://news.ycombinator.com/item?id=46780766",
- "author": "victordg",
- "date": "2026-01-27",
- "date_confidence": "high",
- "engagement": {
- "score": 1,
- "num_comments": 0
- },
- "top_comments": [],
- "comment_insights": [],
- "relevance": 0.76,
- "why_relevant": "HN story about Claude Code skill for building ChatGPT Apps",
- "subs": {
- "relevance": 76,
- "recency": 3,
- "engagement": 0
- },
- "score": 34
- }
- ],
- "best_practices": [],
- "prompt_pack": [],
- "context_snippet_md": "# Context: Claude Code skills and MCP servers (Last 30 Days)\n\n*Generated: 2026-02-25 | Sources: both*\n\n## Key Sources\n\n- [HN] Show HN: I built a 55K-word email marketing knowle...\n- [X] @adamwathan Codex has been crushing it for me with...\n- [Reddit] ClaudeInOne \u2014 a full framework for Claude Code, installed in one command\n- [X] \ud83d\udea8 Anthropic just open-sourced the exact Skills lib...\n- [Reddit] Claude Code works great\u2026 until you have too many MCP servers\n- [Reddit] I built an MCP server that gives Claude Code a semantic graph of your codebase - went from ~15k tokens of grep output to ~3k of relevant context\n- [Reddit] I built 25 MCP servers so Claude Code stops wasting tokens on terminal formatting\n\n## Summary\n\n*See full report for best practices, prompt pack, and detailed sources.*\n"
-}
diff --git a/docs/comparison-results/json/cross-2-seedance.json b/docs/comparison-results/json/cross-2-seedance.json
deleted file mode 100644
index 7c57b33..0000000
--- a/docs/comparison-results/json/cross-2-seedance.json
+++ /dev/null
@@ -1,1217 +0,0 @@
-{
- "topic": "Seedance AI video generation",
- "range": {
- "from": "2026-01-26",
- "to": "2026-02-25"
- },
- "generated_at": "2026-02-25T20:32:55.443958+00:00",
- "mode": "both",
- "openai_model_used": "gpt-5.2",
- "xai_model_used": "grok-4-1-fast",
- "reddit": [
- {
- "id": "R23",
- "title": "Looking for a real Seedance website that actually works (not scams, SJINN is too expensive)",
- "url": "https://www.reddit.com/r/Seedance_AI/comments/1ree66b/looking_for_a_real_seedance_website_that_actually/",
- "subreddit": "Seedance_AI",
- "date": "2026-02-25",
- "date_confidence": "high",
- "engagement": null,
- "top_comments": [],
- "comment_insights": [],
- "relevance": 0.9,
- "why_relevant": "Thread specifically about finding legitimate Seedance 2.0 access for video generation and pricing.",
- "subs": {
- "relevance": 90,
- "recency": 100,
- "engagement": 50
- },
- "score": 77
- },
- {
- "id": "R22",
- "title": "Seedance 2.0 now live in CapCut desktop and API access available, details below",
- "url": "https://www.reddit.com/r/seedance/comments/1reoakv/seedance_20_now_live_in_capcut_desktop_and_api/",
- "subreddit": "seedance",
- "date": "2026-02-25",
- "date_confidence": "high",
- "engagement": null,
- "top_comments": [],
- "comment_insights": [],
- "relevance": 0.85,
- "why_relevant": "Direct discussion about Seedance 2.0 availability in CapCut and API-related details.",
- "subs": {
- "relevance": 85,
- "recency": 100,
- "engagement": 50
- },
- "score": 75
- },
- {
- "id": "R19",
- "title": "Is there an official way to create content using Seedance 2.0?",
- "url": "https://www.reddit.com/r/AITalkers/comments/1rd1rhy/is_there_an_official_way_to_create_content_using/",
- "subreddit": "AITalkers",
- "date": "2026-02-24",
- "date_confidence": "high",
- "engagement": null,
- "top_comments": [],
- "comment_insights": [],
- "relevance": 0.88,
- "why_relevant": "Thread asking where to access Seedance 2.0 legitimately for video generation (avoiding scams).",
- "subs": {
- "relevance": 88,
- "recency": 96,
- "engagement": 50
- },
- "score": 75
- },
- {
- "id": "R21",
- "title": "Official website for creating content with Seedance 2.0?",
- "url": "https://www.reddit.com/r/generativeAI/comments/1rcgyi4/official_website_for_creating_content_with/",
- "subreddit": "generativeAI",
- "date": "2026-02-24",
- "date_confidence": "high",
- "engagement": null,
- "top_comments": [],
- "comment_insights": [],
- "relevance": 0.87,
- "why_relevant": "Thread focused on finding legitimate Seedance 2.0 access for generating content (and warning about scams).",
- "subs": {
- "relevance": 87,
- "recency": 96,
- "engagement": 50
- },
- "score": 75
- },
- {
- "id": "R14",
- "title": "This is terrifying!! Seedance 2.0 just generated a 1-minute film with ZERO editing \u2014 the entire film industry should be worried",
- "url": "https://www.reddit.com/r/generativeAI/comments/1rbionc/this_is_terrifying_seedance_20_just_generated_a/",
- "subreddit": "generativeAI",
- "date": "2026-02-22",
- "date_confidence": "high",
- "engagement": null,
- "top_comments": [],
- "comment_insights": [],
- "relevance": 0.92,
- "why_relevant": "High-engagement discussion about Seedance 2.0 video generation quality and multi-shot coherence claims.",
- "subs": {
- "relevance": 92,
- "recency": 90,
- "engagement": 50
- },
- "score": 75
- },
- {
- "id": "R12",
- "title": "so disappointed and frustrated with recent changes (wasted 1k$+)",
- "url": "https://www.reddit.com/r/Seedance_AI/comments/1ra6nf4/so_disappointed_and_frustrated_with_recent/",
- "subreddit": "Seedance_AI",
- "date": "2026-02-20",
- "date_confidence": "high",
- "engagement": null,
- "top_comments": [],
- "comment_insights": [],
- "relevance": 0.9,
- "why_relevant": "User reports Seedance generations failing content review, impacting video generation work.",
- "subs": {
- "relevance": 90,
- "recency": 83,
- "engagement": 50
- },
- "score": 73
- },
- {
- "id": "R16",
- "title": "Help! I need to use seedance or jimeng video function",
- "url": "https://www.reddit.com/r/Seedance_AI/comments/1rckgcm/help_i_need_to_use_seedance_or_jimeng_video/",
- "subreddit": "Seedance_AI",
- "date": "2026-02-23",
- "date_confidence": "high",
- "engagement": null,
- "top_comments": [],
- "comment_insights": [],
- "relevance": 0.83,
- "why_relevant": "Direct discussion about using Seedance/Jimeng video generation functionality and access issues.",
- "subs": {
- "relevance": 83,
- "recency": 93,
- "engagement": 50
- },
- "score": 72
- },
- {
- "id": "R17",
- "title": "Is Seedance 2.0 actually delayed?",
- "url": "https://www.reddit.com/r/Seedance_AI/comments/1rcwou6/is_seedance_20_actually_delayed/",
- "subreddit": "Seedance_AI",
- "date": "2026-02-23",
- "date_confidence": "high",
- "engagement": null,
- "top_comments": [],
- "comment_insights": [],
- "relevance": 0.81,
- "why_relevant": "Seedance 2.0 release/access timing discussion connected to guardrails and availability outside China.",
- "subs": {
- "relevance": 81,
- "recency": 93,
- "engagement": 50
- },
- "score": 71
- },
- {
- "id": "R20",
- "title": "Selfie Situation \u2022 Seedance 2.0 \u2022 Third-party API by useapi.net",
- "url": "https://www.reddit.com/r/Seedance_AI/comments/1rduxop/selfie_situation_seedance_20_thirdparty_api_by/",
- "subreddit": "Seedance_AI",
- "date": "2026-02-24",
- "date_confidence": "high",
- "engagement": null,
- "top_comments": [],
- "comment_insights": [],
- "relevance": 0.75,
- "why_relevant": "Discussion about claimed Seedance 2.0 API access and whether outputs are really from Seedance; relevant to usage/access.",
- "subs": {
- "relevance": 75,
- "recency": 96,
- "engagement": 50
- },
- "score": 69
- },
- {
- "id": "R15",
- "title": "ByteDance Faces Hollywood Backlash Over Seedance 2.0",
- "url": "https://www.reddit.com/r/AIGuild/comments/1rc4pkg/bytedance_faces_hollywood_backlash_over_seedance/",
- "subreddit": "AIGuild",
- "date": "2026-02-23",
- "date_confidence": "high",
- "engagement": null,
- "top_comments": [],
- "comment_insights": [],
- "relevance": 0.76,
- "why_relevant": "Thread about legal/copyright backlash tied to Seedance 2.0 and implications for access/usage.",
- "subs": {
- "relevance": 76,
- "recency": 93,
- "engagement": 50
- },
- "score": 69
- },
- {
- "id": "R11",
- "title": "Seedance 2.0 API launch delayed because of deepfake/copyright concerns",
- "url": "https://www.reddit.com/r/Seedance_AI/comments/1ra049f/seedance_20_api_launch_delayed_because_of/",
- "subreddit": "Seedance_AI",
- "date": "2026-02-20",
- "date_confidence": "high",
- "engagement": null,
- "top_comments": [],
- "comment_insights": [],
- "relevance": 0.82,
- "why_relevant": "Seedance 2.0 API / rollout thread related to deepfake/copyright guardrails affecting video generation access.",
- "subs": {
- "relevance": 82,
- "recency": 83,
- "engagement": 50
- },
- "score": 69
- },
- {
- "id": "R10",
- "title": "I tested Seedance 2.0 and here's what actually worked",
- "url": "https://www.reddit.com/r/u_Illustrious-One7744/comments/1r8zipa/i_tested_seedance_20_and_heres_what_actually/",
- "subreddit": "u_Illustrious-One7744",
- "date": "2026-02-19",
- "date_confidence": "high",
- "engagement": null,
- "top_comments": [],
- "comment_insights": [],
- "relevance": 0.79,
- "why_relevant": "Hands-on workflow notes about generating videos with Seedance 2.0 (consistency, references, shot planning).",
- "subs": {
- "relevance": 79,
- "recency": 80,
- "engagement": 50
- },
- "score": 67
- },
- {
- "id": "R13",
- "title": "Seedance 2.0 is impressive. It\u2019s still not a production workflow.",
- "url": "https://www.reddit.com/r/AI_Agents/comments/1rawxiw/seedance_20_is_impressive_its_still_not_a/",
- "subreddit": "AI_Agents",
- "date": "2026-02-21",
- "date_confidence": "high",
- "engagement": null,
- "top_comments": [],
- "comment_insights": [],
- "relevance": 0.74,
- "why_relevant": "Discussion about practical limitations of Seedance 2.0 for longer/structured video production pipelines.",
- "subs": {
- "relevance": 74,
- "recency": 86,
- "engagement": 50
- },
- "score": 66
- },
- {
- "id": "R9",
- "title": "Elevenlabs silently removed Seedance 2.0 after SAG-AFTRA incident, can\u2019t create UGC style video",
- "url": "https://www.reddit.com/r/AI_UGC_Marketing/comments/1r70942/elevenlabs_silently_removed_seedance_20_after/",
- "subreddit": "AI_UGC_Marketing",
- "date": "2026-02-17",
- "date_confidence": "high",
- "engagement": null,
- "top_comments": [],
- "comment_insights": [],
- "relevance": 0.78,
- "why_relevant": "Discussion about Seedance 2.0 availability/removal in a video generation workflow context.",
- "subs": {
- "relevance": 78,
- "recency": 73,
- "engagement": 50
- },
- "score": 65
- },
- {
- "id": "R4",
- "title": "The censorship just ruined Seedance 2.0",
- "url": "https://www.reddit.com/r/HiggsfieldAI/comments/1r1barb/",
- "subreddit": "HiggsfieldAI",
- "date": "2026-02-10",
- "date_confidence": "high",
- "engagement": null,
- "top_comments": [],
- "comment_insights": [],
- "relevance": 0.88,
- "why_relevant": "Discussion focused on Seedance 2.0 video generation restrictions/guardrails impacting what can be generated.",
- "subs": {
- "relevance": 88,
- "recency": 50,
- "engagement": 50
- },
- "score": 64
- },
- {
- "id": "R7",
- "title": "[SEEDANCE 2.0] I made the post and many of you doubted it. I warned you that \u201cHollywood\u201d would take action.",
- "url": "https://www.reddit.com/r/AIHubSpace/comments/1r3satk/seedance_20_i_made_the_post_and_many_of_you/",
- "subreddit": "AIHubSpace",
- "date": "2026-02-13",
- "date_confidence": "high",
- "engagement": null,
- "top_comments": [],
- "comment_insights": [],
- "relevance": 0.8,
- "why_relevant": "Large discussion about Seedance 2.0 generated videos and alleged copyright backlash impacting the tool.",
- "subs": {
- "relevance": 80,
- "recency": 60,
- "engagement": 50
- },
- "score": 63
- },
- {
- "id": "R6",
- "title": "ByteDance launches Seedance 2.0 AI video generator",
- "url": "https://www.reddit.com/r/tldrAI/comments/1r352wd/bytedance_launches_seedance_20_ai_video_generator/",
- "subreddit": "tldrAI",
- "date": "2026-02-12",
- "date_confidence": "high",
- "engagement": null,
- "top_comments": [],
- "comment_insights": [],
- "relevance": 0.83,
- "why_relevant": "News-style discussion thread about Seedance 2.0 as an AI video generator and where it is available.",
- "subs": {
- "relevance": 83,
- "recency": 56,
- "engagement": 50
- },
- "score": 63,
- "cross_refs": [
- "HN5",
- "HN4"
- ]
- },
- {
- "id": "R8",
- "title": "Seedance 2.0",
- "url": "https://www.reddit.com/r/AiVideos_NoRules/comments/1r51m86/seedance_20/",
- "subreddit": "AiVideos_NoRules",
- "date": "2026-02-15",
- "date_confidence": "high",
- "engagement": null,
- "top_comments": [],
- "comment_insights": [],
- "relevance": 0.72,
- "why_relevant": "Thread discussing Seedance 2.0 as a video generation model and its perceived capabilities/impact.",
- "subs": {
- "relevance": 72,
- "recency": 66,
- "engagement": 50
- },
- "score": 60
- },
- {
- "id": "R5",
- "title": "Seedance 2.0 \u2013 What\u2019s New and Why It Matters",
- "url": "https://www.reddit.com/r/u_Impossible-Dish409/comments/1r1lekj/seedance_20_whats_new_and_why_it_matters/",
- "subreddit": "u_Impossible-Dish409",
- "date": "2026-02-11",
- "date_confidence": "high",
- "engagement": null,
- "top_comments": [],
- "comment_insights": [],
- "relevance": 0.77,
- "why_relevant": "Overview thread describing Seedance 2.0 capabilities for multimodal video generation/editing.",
- "subs": {
- "relevance": 77,
- "recency": 53,
- "engagement": 50
- },
- "score": 59
- }
- ],
- "x": [
- {
- "id": "X9",
- "text": "First Seedance 2.0 Test! \n\nSeedance 5.0 Lite @krea_ai \u2192 Seedance 2.0 @capcutapp\n\nPrompt: Close-up on her hands gripping the paintbrush, knuckles white, paint dripping down her wrist. Shallow depth of field, the blank canvas a soft white blur behind her. She exhales. [cut] Wide shot from behind her, 24mm low angle. She winds back and hurls paint at the canvas. Neon colours explode across the white surface, splattering the walls and floor. The force of the throw carries her forward a step. [cut] S",
- "url": "https://x.com/HBCoop_/status/2026748738354450919",
- "author_handle": "HBCoop_",
- "date": "2026-02-25",
- "date_confidence": "high",
- "engagement": {
- "likes": 9,
- "replies": 1
- },
- "relevance": 0.7,
- "why_relevant": "",
- "subs": {
- "relevance": 70,
- "recency": 100,
- "engagement": 100
- },
- "score": 86
- },
- {
- "id": "X12",
- "text": "Goku vs Broly , EPIC Full Battle! \nWitness the ultimate Saiyan showdown recreated with AI power. Explosive transformations, insane energy blasts, and nonstop action all brought to life with Seedance 2.0 \nMade it on Seedance 2.0 https://t.co/WMZ0l9JHzm",
- "url": "https://x.com/Noor_ul_ain43/status/2026743666635845959",
- "author_handle": "Noor_ul_ain43",
- "date": "2026-02-25",
- "date_confidence": "high",
- "engagement": {
- "likes": 5,
- "replies": 1
- },
- "relevance": 0.7,
- "why_relevant": "",
- "subs": {
- "relevance": 70,
- "recency": 100,
- "engagement": 71
- },
- "score": 77
- },
- {
- "id": "X10",
- "text": "@WolfyBlair @Preda2005 @BytePlusGlobal @capcutapp Yes, it's true\u2014Seedance 2.0 is now live in CapCut (desktop & mobile). \n\nSee it in the AI video tools: https://t.co/K3gwCgUSyb \n\nJust update the app and try it! \ud83d\ude80",
- "url": "https://x.com/grok/status/2026747009143549978",
- "author_handle": "grok",
- "date": "2026-02-25",
- "date_confidence": "high",
- "engagement": {
- "likes": 2,
- "replies": 1
- },
- "relevance": 0.7,
- "why_relevant": "",
- "subs": {
- "relevance": 70,
- "recency": 100,
- "engagement": 33
- },
- "score": 66
- },
- {
- "id": "X1",
- "text": "@ChinyJPG capcut desktop app, they just added seedance 2.0 today. no chinese phone number needed, works globally. download the desktop version, look for the AI video generation option. runs about $2 per video.",
- "url": "https://x.com/jznode/status/2026755966545404015",
- "author_handle": "jznode",
- "date": "2026-02-25",
- "date_confidence": "high",
- "engagement": {
- "replies": 1
- },
- "relevance": 0.7,
- "why_relevant": "",
- "subs": {
- "relevance": 70,
- "recency": 100,
- "engagement": 35
- },
- "score": 64
- },
- {
- "id": "X5",
- "text": "@ProperPrompter Yes! Seedance 2.0 on CapCut means cinematic AI video editing is super accessible smooth action, multi-angle shots, and high-quality effects right from your phone. \ud83c\udfac\ud83d\udd25",
- "url": "https://x.com/Yousaf_340/status/2026751320716357996",
- "author_handle": "Yousaf_340",
- "date": "2026-02-25",
- "date_confidence": "high",
- "engagement": {
- "replies": 1
- },
- "relevance": 0.7,
- "why_relevant": "",
- "subs": {
- "relevance": 70,
- "recency": 100,
- "engagement": 35
- },
- "score": 64
- },
- {
- "id": "X6",
- "text": "@ivanka_humeniuk Seedance 2.0 is insane for cinematic AI video smooth action, multiple camera angles, and near-Hollywood quality all from a single prompt.",
- "url": "https://x.com/Yousaf_340/status/2026750898769473829",
- "author_handle": "Yousaf_340",
- "date": "2026-02-25",
- "date_confidence": "high",
- "engagement": null,
- "relevance": 0.7,
- "why_relevant": "",
- "subs": {
- "relevance": 70,
- "recency": 100,
- "engagement": 35
- },
- "score": 64
- },
- {
- "id": "X3",
- "text": "Seedance 2.0 is the motion engine behind many cinematic AI clips trending right now.\n\nIt\u2019s now native inside NemoVideo.\n\nYou can:\n- Identify proven formats\n- Analyze what drives retention\n- Turn an idea into a finished video\n\nAll through simple chat commands. https://t.co/qXSWsOPngB",
- "url": "https://x.com/EmmaUsesAi/status/2026751784509673531",
- "author_handle": "EmmaUsesAi",
- "date": "2026-02-25",
- "date_confidence": "high",
- "engagement": {
- "replies": 1
- },
- "relevance": 0.7,
- "why_relevant": "",
- "subs": {
- "relevance": 70,
- "recency": 100,
- "engagement": 35
- },
- "score": 64
- },
- {
- "id": "X2",
- "text": "You can use Seedance. If Seedance 2.0 hasn't launch yet, the prior version is great, and stick pretty well to the prompt. Of course the video agent must be well trained. \ud83d\ude42Also, you can offer SORA for the paying plans, and Seedance or wan for the free tier. I use wan and Seedance everyday, to save video credits in another platform that I use. I don't like SORA to much anyway.",
- "url": "https://x.com/OneStrangeW/status/2026755320362709093",
- "author_handle": "OneStrangeW",
- "date": "2026-02-25",
- "date_confidence": "high",
- "engagement": null,
- "relevance": 0.7,
- "why_relevant": "",
- "subs": {
- "relevance": 70,
- "recency": 100,
- "engagement": 35
- },
- "score": 64
- },
- {
- "id": "X11",
- "text": "\ud83d\udea8Phishing Scam!\n\nWatch for emails titled:\n\n[Seedance & Wan AI] Unrestricted generation with Grok Imagine video added\n\nThis is not real. We don't have a Seedance account, and the links are not official.",
- "url": "https://x.com/WyldeChyldeRec/status/2026743867224240149",
- "author_handle": "WyldeChyldeRec",
- "date": "2026-02-25",
- "date_confidence": "high",
- "engagement": null,
- "relevance": 0.7,
- "why_relevant": "",
- "subs": {
- "relevance": 70,
- "recency": 100,
- "engagement": 35
- },
- "score": 64
- },
- {
- "id": "X7",
- "text": "- Kling (https://t.co/WGphQsdLf1)\n\n- Seedance 2.0 (https://t.co/A8UzLTRGNo) \n\n\u261e AI-powered video editing:\n\n- Descript (https://t.co/q4axNEjDzt)\n- OpusClip (https://t.co/5XPn4EPH6u)\n- Submagic (https://t.co/5bZs7OeLey)\n- Veed (https://t.co/rtDOTEeJzQ)",
- "url": "https://x.com/La_DeCrypt/status/2026750376687394884",
- "author_handle": "La_DeCrypt",
- "date": "2026-02-25",
- "date_confidence": "high",
- "engagement": {
- "likes": 1
- },
- "relevance": 0.7,
- "why_relevant": "",
- "subs": {
- "relevance": 70,
- "recency": 100,
- "engagement": 0
- },
- "score": 56
- },
- {
- "id": "X8",
- "text": "Seedance 2.0 might be gen AI video\u2019s next big hope, but it\u2019s still slop https://t.co/kT1baOYJQk #AI",
- "url": "https://x.com/TferThomas/status/2026749218224746609",
- "author_handle": "TferThomas",
- "date": "2026-02-25",
- "date_confidence": "high",
- "engagement": {
- "likes": 1
- },
- "relevance": 0.7,
- "why_relevant": "",
- "subs": {
- "relevance": 70,
- "recency": 100,
- "engagement": 0
- },
- "score": 56
- }
- ],
- "web": [],
- "youtube": [
- {
- "id": "F1kWxdfiBNE",
- "title": "Seedance 2.0 Claims the AI Video Throne!",
- "url": "https://www.youtube.com/watch?v=F1kWxdfiBNE",
- "channel_name": "AI Filmmaking Academy",
- "date": "2026-02-22",
- "date_confidence": "high",
- "engagement": {
- "num_comments": 227,
- "likes": 1088,
- "views": 40982
- },
- "transcript_snippet": "",
- "relevance": 0.75,
- "why_relevant": "YouTube: Seedance 2.0 Claims the AI Video Throne!",
- "subs": {
- "relevance": 75,
- "recency": 90,
- "engagement": 63
- },
- "score": 75
- },
- {
- "id": "_o2MuUX9UYg",
- "title": "Seedance 2.0 Claims the AI Video Throne!",
- "url": "https://www.youtube.com/watch?v=_o2MuUX9UYg",
- "channel_name": "Theoretically Media",
- "date": "2026-02-09",
- "date_confidence": "high",
- "engagement": {
- "num_comments": 746,
- "likes": 4986,
- "views": 199908
- },
- "transcript_snippet": "So, it's been about a week since I declared Cling 3.0 the new benchmark for state-of-the-art AI video. And well, I mean, it's been a week, and that basically equals about 3 months in AI time. So, yes, we've already got a new challenger to the throne. Bite Dance is striking back hard with Seance 2.0. And although Seance 2.0 is not released as of this video. I do have some exclusive info, full walkthrough of all of the features, a likely release frame, and a bunch of examples for us to check out today. [music] With that, get your dancing shoes on. It is time to tango. Briefly, just to bring us up to speed, seed is Bite Dance's frontier lab. Uh much like Google's deep mind, it focuses on a number of AI projects including LLMs, real time and of course image and video which is you know the thing that we'll be looking at here today. Image and video are split into two different products with seed. There is seed dream for images and seed dance for video and I I constantly get the two of them screwed up. Now the previous version of seed dance which was 1.5 was released uh about 2 months ago and introduced native audio. So with this 2.0 release, bite dance seems to be pacing almost foot forfoot with cling who I mean obviously released their 2.6 model around the same time and of course have just released their 3.0 model and just like cling 3.0 it is clear that bite dance has been cooking because well I mean c dance 2.0 looks pretty jaw-dropping. So I reached out to my contact at bite dance to get some internal documents to see exactly what 2.0 0 is capable of, what its feature set is, and well, I mean, buckle up because this one is packed. So, at baseline, Cance 2.0 is multimodal and does have an omni model and supports four types of inputs: image, video, audio, and text. And well, of course, as they say, uh, with richer expression, and more controllable generation. I mean, they every every one of them always says that. In this case, it's actually kind of true. And here's the kind of crazy part. It can support up to nine images and three videos per input reference. So for a total of like 12 input references. Additionally, as mentioned, it does support audio input at a range of 3 seconds up to 15 seconds and outputs at a selectable range of 4 seconds up to 15 seconds and does do so with native audio. So kicking off on the very basic level with one of their examples, uh here is image to video 8 seconds. >> [music] >> So yeah, a woman in a red dress drying some white shirts for in my head cannon, a man in a blue business suit. I mean, overall, nothing, you know, completely mind-blowing here. It looks very good....",
- "relevance": 0.75,
- "why_relevant": "YouTube: Seedance 2.0 Claims the AI Video Throne!",
- "subs": {
- "relevance": 75,
- "recency": 46,
- "engagement": 100
- },
- "score": 75
- },
- {
- "id": "jkPTYD5lXo8",
- "title": "100% FREE Seedance 2.0 AI Video Generator\u202f: How to Use It WORLDWIDE",
- "url": "https://www.youtube.com/watch?v=jkPTYD5lXo8",
- "channel_name": "How To In 5 Minutes",
- "date": "2026-02-19",
- "date_confidence": "high",
- "engagement": {
- "num_comments": 134,
- "likes": 782,
- "views": 44344
- },
- "transcript_snippet": "",
- "relevance": 0.75,
- "why_relevant": "YouTube: 100% FREE Seedance 2.0 AI Video Generator\u202f: How to Use It WO",
- "subs": {
- "relevance": 75,
- "recency": 80,
- "engagement": 59
- },
- "score": 71
- },
- {
- "id": "W_lxyDFDZt4",
- "title": "Seedance 2.0: The New Best AI Video Generator | Sora 2 Destroyed",
- "url": "https://www.youtube.com/watch?v=W_lxyDFDZt4",
- "channel_name": "WealthWise",
- "date": "2026-02-12",
- "date_confidence": "high",
- "engagement": {
- "num_comments": 258,
- "likes": 1204,
- "views": 126599
- },
- "transcript_snippet": "Well, major AI video update, ladies and gentlemen. Seance 2.0 is right around the corner. And let me tell you guys, this is something I have never seen before. Just take a look at some of the videos generated with Seance 2. [groaning and screaming] He was a good man. He knew too much about our Russia operations. >> [sighs] >> You think you're in control? Control? You're not. The videos were simply amazing. Right. This is unlike anything I've ever seen before. It's literally Sora 2 on steroids, but with even better sound quality and visuals. Just wow. Seance 2 is coming soon on Higsfield AI, and as you just saw, it's going to change video generation forever. I honestly can't wait to get my hands on it and start playing around with it. I mean, just look at these clips. Like, you can literally go crazy with what's possible. From early leaks and some research, I've been able to gather a bit of information on what we can expect from the next generation model. Now, here's what we know so far, or at least what we can expect from this upcoming release. I got this information from the Higsfield website, by the way, and I'll also put the link to this in the description, so you can check it out yourself. Now, the first thing is you can now recreate videos using the same style and motions from just a single prompt. As you can see here, this is somewhat similar to what you can do on Sora 2, but this one looks even better and higher quality. And most importantly, there are no restrictions, unlike Sora 2, which is still one of the most restricted video models ever. Here's a quick example. The game is over. >> Amazing, right? The quality and motion are absolutely on point. Now, imagine how awesome clips you'll be able to create with this. Next up is multi-input control. As you can see, you can now upload up to 12 assets at once. It could be images, videos, and even audio. Yes, you can literally upload an audio file and Cedance 2 will generate a video from it. I don't even know how that's gonna work, but somehow they figured it out and I am totally here for it. Here's an example. Wow, that was epic, right? As you can see, six image inputs were used and from that it created this insane battle scene with perfect character and style consistency. Here is another example. La. [music] Oh la. Oh la. [music] >> You think you're tough. You think you're strong. You're just a copycat sing. >> Wow. This one is more like a music video style clip. And it did not disappoint. Man, I can't wait to get my hands on this. I have never been this excited for a video generator before. Next up is video precision. And this is huge. Normally in multi-shot videos, we see slight differences in...",
- "relevance": 0.75,
- "why_relevant": "YouTube: Seedance 2.0: The New Best AI Video Generator | Sora 2 Destr",
- "subs": {
- "relevance": 75,
- "recency": 56,
- "engagement": 78
- },
- "score": 71
- },
- {
- "id": "R1zl92NhCfE",
- "title": "Seedance 2.0 How To Use and Become a Pro AI Film Maker With This AI Video Generator",
- "url": "https://www.youtube.com/watch?v=R1zl92NhCfE",
- "channel_name": "Ai Lockup",
- "date": "2026-02-16",
- "date_confidence": "high",
- "engagement": {
- "num_comments": 60,
- "likes": 591,
- "views": 32699
- },
- "transcript_snippet": "",
- "relevance": 0.75,
- "why_relevant": "YouTube: Seedance 2.0 How To Use and Become a Pro AI Film Maker With ",
- "subs": {
- "relevance": 75,
- "recency": 70,
- "engagement": 50
- },
- "score": 66
- },
- {
- "id": "G1Ad4a8sdJU",
- "title": "SeeDance 2.0: The Next Level of AI Video \u2014 And What It Means for Local AI Users?",
- "url": "https://www.youtube.com/watch?v=G1Ad4a8sdJU",
- "channel_name": "Benji\u2019s AI Playground",
- "date": "2026-02-14",
- "date_confidence": "high",
- "engagement": {
- "num_comments": 58,
- "likes": 419,
- "views": 38581
- },
- "transcript_snippet": "",
- "relevance": 0.75,
- "why_relevant": "YouTube: SeeDance 2.0: The Next Level of AI Video \u2014 And What It Means",
- "subs": {
- "relevance": 75,
- "recency": 63,
- "engagement": 49
- },
- "score": 64
- },
- {
- "id": "61ThJGqwHsI",
- "title": "Ultimate AI Video Generation - Seedance 2.0 PREVIEW",
- "url": "https://www.youtube.com/watch?v=61ThJGqwHsI",
- "channel_name": "xCreate",
- "date": "2026-02-11",
- "date_confidence": "high",
- "engagement": {
- "num_comments": 13,
- "likes": 86,
- "views": 4854
- },
- "transcript_snippet": "",
- "relevance": 1.0,
- "why_relevant": "YouTube: Ultimate AI Video Generation - Seedance 2.0 PREVIEW",
- "subs": {
- "relevance": 100,
- "recency": 53,
- "engagement": 4
- },
- "score": 59
- },
- {
- "id": "5s2l68SrJP4",
- "title": "100+ Seedance 2.0 AI Videos \u2013 The New King?",
- "url": "https://www.youtube.com/watch?v=5s2l68SrJP4",
- "channel_name": "Airt",
- "date": "2026-02-11",
- "date_confidence": "high",
- "engagement": {
- "num_comments": 360,
- "likes": 1733,
- "views": 77744
- },
- "transcript_snippet": "That's the good stuff. And that's a wrap. [screaming] A lot of people asking how the roommate situation is going. He's chill, but there are things. Fridge is consecrated. Tastes like communion wafers. Worst part, every time I enter the room, every single time. [music] Bathroom at 2A. Not afraid. Getting water. Be not afraid. Lease up in 4 months. If anyone has a room, please be not afraid. Fore [music] speech. You killed Jeffrey Epstein, you animal. He was a good man. He knew too much about our Russia operations. He had to die and now you die, too. >> Because you're worth it. Introducing our new hair serum for hair that shines with strength [music] and beauty. Strong, beautiful hair. They didn't walk. They sagated like a crab with a mortgage. >> So the the probe was musical. Look at me. Do I look like a man who was probed by a trombone? >> I can't I can't do it. >> Come on. They can't replace us. We're the real thing. >> I mean, eventually they won't need us at all. >> Then you got to be a wife. Is this the best your planet can offer? >> Me destroy [screaming] everything. Whatever. Let's get this over with. >> Too slow. >> Shut up and disappear. >> [sighs] >> Beiji Ch. >> [groaning] >> Let the hostage go. Drop the weapon now. >> Stay back. I'll kill her. I swear I'll do it. I'm going to do it. >> [screaming and groaning] >> Smells like a wet fart in here. >> It's not a drill. Go. Go. [screaming] CL, get down. [groaning and screaming] Heat. Heat. >> [music] >> You cannot escape now. >> Who said I running away? >> [crying] >> And they're off. It's a fast start and the horse takes the early lead. >> Fight. Finish him. [groaning] down. The old magic is stirring. The time to act is upon us. Stop raant. >> Ready the guns. Heat. >> Heat up here. Heat up here. >> Heat. Heat. >> They're going to fight. Concus, your mythical beast is an invader. >> Shishu was accidentally teleported here by the portal. He means no harm. >> I can communicate with Kungu sign language. You go calm your beast. >> All right. We have to make them know. It's all a misunderstanding. >> Fly away. Oh, come on. [groaning] >> Yeah. >> I'm dead. All right, bro. I can do this all day. >> Mr. Walk up with my Sprite. Turn it. I bes You're not for Come on. >> It's time to vote. >> Wrong island, rodent. Let's tally the votes. [screaming] Bring out the water bucket. They don't know. Then I I Listen. Vegeta. >> [laughter] >> I thought there little bananas there. I'm not the ghost. Wow. Stop. Light. Heat up here. Well, that's not what I ordered. >> Get in the car now. >> You didn't tell me there'd be this much. >> There...",
- "relevance": 0.5,
- "why_relevant": "YouTube: 100+ Seedance 2.0 AI Videos \u2013 The New King?",
- "subs": {
- "relevance": 50,
- "recency": 53,
- "engagement": 76
- },
- "score": 58
- },
- {
- "id": "n625xVounGM",
- "title": "How To Generate FREE AI Seedance 2.0 Videos (Access Worldwide)",
- "url": "https://www.youtube.com/watch?v=n625xVounGM",
- "channel_name": "John Savage AI",
- "date": "2026-02-23",
- "date_confidence": "high",
- "engagement": {
- "num_comments": 45,
- "likes": 126,
- "views": 7194
- },
- "transcript_snippet": "",
- "relevance": 0.5,
- "why_relevant": "YouTube: How To Generate FREE AI Seedance 2.0 Videos (Access Worldwid",
- "subs": {
- "relevance": 50,
- "recency": 93,
- "engagement": 17
- },
- "score": 50
- },
- {
- "id": "kJ0NAVmd4f4",
- "title": "Seedance 2.0 Changes Filmmaking Forever | New Original Series",
- "url": "https://www.youtube.com/watch?v=kJ0NAVmd4f4",
- "channel_name": "Rogue Cell Pictures",
- "date": "2026-02-25",
- "date_confidence": "high",
- "engagement": {
- "num_comments": 43,
- "likes": 151,
- "views": 1594
- },
- "transcript_snippet": "",
- "relevance": 0.25,
- "why_relevant": "YouTube: Seedance 2.0 Changes Filmmaking Forever | New Original Serie",
- "subs": {
- "relevance": 25,
- "recency": 100,
- "engagement": 0
- },
- "score": 36
- }
- ],
- "hackernews": [
- {
- "id": "HN8",
- "title": "Seedance 2.0 preview: The best video model of 2026, outperforming Sora 2",
- "url": "",
- "hn_url": "https://news.ycombinator.com/item?id=46940720",
- "author": "Alisaqqt",
- "date": "2026-02-09",
- "date_confidence": "high",
- "engagement": {
- "score": 7,
- "num_comments": 7
- },
- "top_comments": [
- {
- "score": 0,
- "date": null,
- "author": "Alisaqqt",
- "excerpt": "API is not available now.",
- "url": ""
- },
- {
- "score": 0,
- "date": null,
- "author": "Alisaqqt",
- "excerpt": "API will be available on Atlas Cloud on Feb 24.",
- "url": ""
- },
- {
- "score": 0,
- "date": null,
- "author": "Alisaqqt",
- "excerpt": "Seedance 2.0 and Seedream 5.0 API will be available on Atlas Cloud on Feb 24.",
- "url": ""
- },
- {
- "score": 0,
- "date": null,
- "author": "ksdk",
- "excerpt": "Docs whers - link?",
- "url": ""
- },
- {
- "score": 0,
- "date": null,
- "author": "ssoydan",
- "excerpt": "I am very curious about how it will turn out when the API is released",
- "url": ""
- }
- ],
- "comment_insights": [
- "API is not available now.",
- "API will be available on Atlas Cloud on Feb 24.",
- "Seedance 2.0 and Seedream 5.0 API will be available on Atlas Cloud on Feb 24.",
- "Docs whers - link?",
- "I am very curious about how it will turn out when the API is released"
- ],
- "relevance": 0.75,
- "why_relevant": "HN story about Seedance 2.0 preview: The best video model of 2026, outperfo",
- "subs": {
- "relevance": 75,
- "recency": 46,
- "engagement": 100
- },
- "score": 75
- },
- {
- "id": "HN15",
- "title": "Show HN: SeeVideo \u2013 Access Seedance 2.0 and Kling 3.0 without a subscription",
- "url": "https://seevideo.dance/",
- "hn_url": "https://news.ycombinator.com/item?id=47153236",
- "author": "naxtsass",
- "date": "2026-02-25",
- "date_confidence": "high",
- "engagement": {
- "score": 2,
- "num_comments": 0
- },
- "top_comments": [],
- "comment_insights": [],
- "relevance": 0.63,
- "why_relevant": "HN story about Show HN: SeeVideo \u2013 Access Seedance 2.0 and Kling 3.0 withou",
- "subs": {
- "relevance": 63,
- "recency": 100,
- "engagement": 13
- },
- "score": 57
- },
- {
- "id": "HN6",
- "title": "Seedance 2.0: ByteDance's AI video model with native audio-video co-generation",
- "url": "https://medium.com/@channeler.h/seedance-2-0-bytedances-ai-video-model-that-generates-audio-and-video-at-the-same-time-f104f89ef762",
- "hn_url": "https://news.ycombinator.com/item?id=47022290",
- "author": "howardV",
- "date": "2026-02-15",
- "date_confidence": "high",
- "engagement": {
- "score": 3,
- "num_comments": 0
- },
- "top_comments": [],
- "comment_insights": [],
- "relevance": 0.76,
- "why_relevant": "HN story about Seedance 2.0: ByteDance's AI video model with native audio-v",
- "subs": {
- "relevance": 76,
- "recency": 66,
- "engagement": 22
- },
- "score": 57
- },
- {
- "id": "HN5",
- "title": "Seedance 2 Video Generator",
- "url": "",
- "hn_url": "https://news.ycombinator.com/item?id=47046956",
- "author": "jrran086",
- "date": "2026-02-17",
- "date_confidence": "high",
- "engagement": {
- "score": 1,
- "num_comments": 0
- },
- "top_comments": [],
- "comment_insights": [],
- "relevance": 0.76,
- "why_relevant": "HN story about Seedance 2 Video Generator",
- "subs": {
- "relevance": 76,
- "recency": 73,
- "engagement": 0
- },
- "score": 52,
- "cross_refs": [
- "R6"
- ]
- },
- {
- "id": "HN3",
- "title": "Seedance 2.0 \u2013 Multimodal AI Video Generation with Image/Video/Audio References",
- "url": "https://seedance2.so",
- "hn_url": "https://news.ycombinator.com/item?id=46944030",
- "author": "xbaicai",
- "date": "2026-02-09",
- "date_confidence": "high",
- "engagement": {
- "score": 1,
- "num_comments": 1
- },
- "top_comments": [],
- "comment_insights": [],
- "relevance": 0.79,
- "why_relevant": "HN story about Seedance 2.0 \u2013 Multimodal AI Video Generation with Image/Vid",
- "subs": {
- "relevance": 79,
- "recency": 46,
- "engagement": 18
- },
- "score": 52
- },
- {
- "id": "HN1",
- "title": "Show HN: Seedance AShow HN: Seedance AI Video Generation (Next.js, Drizzle)",
- "url": "https://seedanceai2.org/",
- "hn_url": "https://news.ycombinator.com/item?id=46999394",
- "author": "xuyanmei",
- "date": "2026-02-13",
- "date_confidence": "high",
- "engagement": {
- "score": 1,
- "num_comments": 0
- },
- "top_comments": [],
- "comment_insights": [],
- "relevance": 0.82,
- "why_relevant": "HN story about Show HN: Seedance AShow HN: Seedance AI Video Generation (Ne",
- "subs": {
- "relevance": 82,
- "recency": 60,
- "engagement": 0
- },
- "score": 51
- },
- {
- "id": "HN12",
- "title": "Seedance2 \u2013 multi-shot AI video generation",
- "url": "https://www.genstory.app/story-template/seedance2-ai-story-generator",
- "hn_url": "https://news.ycombinator.com/item?id=46924992",
- "author": "RyanMu",
- "date": "2026-02-07",
- "date_confidence": "high",
- "engagement": {
- "score": 2,
- "num_comments": 1
- },
- "top_comments": [
- {
- "score": 0,
- "date": null,
- "author": "RyanMu",
- "excerpt": "\u2019ve been experimenting with AI video tools for a while, but most of them generate isolated clips that fall apart when you try to build an actual narrative. We built Seedance2 to focus on something slightly different: multi-shot video generation that keeps characters, motion, and visual style consist...",
- "url": ""
- }
- ],
- "comment_insights": [
- "\u2019ve been experimenting with AI video tools for a while, but most of them generate isolated clips that fall apart when you try to build an actual narrative"
- ],
- "relevance": 0.67,
- "why_relevant": "HN story about Seedance2 \u2013 multi-shot AI video generation",
- "subs": {
- "relevance": 67,
- "recency": 40,
- "engagement": 31
- },
- "score": 49
- },
- {
- "id": "HN11",
- "title": "Show HN: Seedance3AI \u2013 a web app for text-to-video, image-to-video",
- "url": "https://seedance3ai.net/",
- "hn_url": "https://news.ycombinator.com/item?id=47046989",
- "author": "xuyanmei",
- "date": "2026-02-17",
- "date_confidence": "high",
- "engagement": {
- "score": 1,
- "num_comments": 0
- },
- "top_comments": [],
- "comment_insights": [],
- "relevance": 0.68,
- "why_relevant": "HN story about Show HN: Seedance3AI \u2013 a web app for text-to-video, image-to",
- "subs": {
- "relevance": 68,
- "recency": 73,
- "engagement": 0
- },
- "score": 48
- },
- {
- "id": "HN2",
- "title": "Discussion: Seedance-style AI video generation workflows",
- "url": "",
- "hn_url": "https://news.ycombinator.com/item?id=46942781",
- "author": "littlepp",
- "date": "2026-02-09",
- "date_confidence": "high",
- "engagement": {
- "score": 1,
- "num_comments": 0
- },
- "top_comments": [],
- "comment_insights": [],
- "relevance": 0.8,
- "why_relevant": "HN story about Discussion: Seedance-style AI video generation workflows",
- "subs": {
- "relevance": 80,
- "recency": 46,
- "engagement": 0
- },
- "score": 47
- },
- {
- "id": "HN13",
- "title": "Show HN: AI-powered video creation web app",
- "url": "https://seedance2.video",
- "hn_url": "https://news.ycombinator.com/item?id=47002386",
- "author": "thomaskiko",
- "date": "2026-02-13",
- "date_confidence": "high",
- "engagement": {
- "score": 1,
- "num_comments": 0
- },
- "top_comments": [],
- "comment_insights": [],
- "relevance": 0.65,
- "why_relevant": "HN story about Show HN: AI-powered video creation web app",
- "subs": {
- "relevance": 65,
- "recency": 60,
- "engagement": 0
- },
- "score": 44
- },
- {
- "id": "HN9",
- "title": "Show HN: AI Seedance 2 \u2013 Solving the \"jump-cut\" problem in AI video",
- "url": "https://www.aiseedance2.app",
- "hn_url": "https://news.ycombinator.com/item?id=46946280",
- "author": "TurnItOffAndOn0",
- "date": "2026-02-09",
- "date_confidence": "high",
- "engagement": {
- "score": 1,
- "num_comments": 0
- },
- "top_comments": [],
- "comment_insights": [],
- "relevance": 0.71,
- "why_relevant": "HN story about Show HN: AI Seedance 2 \u2013 Solving the \"jump-cut\" problem in A",
- "subs": {
- "relevance": 71,
- "recency": 46,
- "engagement": 0
- },
- "score": 43
- },
- {
- "id": "HN7",
- "title": "Show HN: Seedance 2.0 AI video generator for creators and ecommerce",
- "url": "https://seedance-2.net",
- "hn_url": "https://news.ycombinator.com/item?id=46930149",
- "author": "dallen97",
- "date": "2026-02-08",
- "date_confidence": "high",
- "engagement": {
- "score": 1,
- "num_comments": 0
- },
- "top_comments": [],
- "comment_insights": [],
- "relevance": 0.73,
- "why_relevant": "HN story about Show HN: Seedance 2.0 AI video generator for creators and ec",
- "subs": {
- "relevance": 73,
- "recency": 43,
- "engagement": 0
- },
- "score": 43
- },
- {
- "id": "HN10",
- "title": "Show HN: Seedance 2.0 \u2013 Native audio-visual sync video model",
- "url": "https://videomakerai.app/models/seedance-ai",
- "hn_url": "https://news.ycombinator.com/item?id=46943172",
- "author": "thenextechtrade",
- "date": "2026-02-09",
- "date_confidence": "high",
- "engagement": {
- "score": 1,
- "num_comments": 0
- },
- "top_comments": [],
- "comment_insights": [],
- "relevance": 0.69,
- "why_relevant": "HN story about Show HN: Seedance 2.0 \u2013 Native audio-visual sync video model",
- "subs": {
- "relevance": 69,
- "recency": 46,
- "engagement": 0
- },
- "score": 42
- },
- {
- "id": "HN14",
- "title": "Show HN: Seedance2 \u2013 Stop \"prompt guessing\" and start directing AI video",
- "url": "https://seedancevideo.app/",
- "hn_url": "https://news.ycombinator.com/item?id=46956546",
- "author": "echoadam",
- "date": "2026-02-10",
- "date_confidence": "high",
- "engagement": {
- "score": 1,
- "num_comments": 0
- },
- "top_comments": [],
- "comment_insights": [],
- "relevance": 0.64,
- "why_relevant": "HN story about Show HN: Seedance2 \u2013 Stop \"prompt guessing\" and start direct",
- "subs": {
- "relevance": 64,
- "recency": 50,
- "engagement": 0
- },
- "score": 41
- },
- {
- "id": "HN4",
- "title": "Show HN: Seedance 2.0 Pro AI Video Generator",
- "url": "https://www.seedance2pro.net",
- "hn_url": "https://news.ycombinator.com/item?id=46774950",
- "author": "Zach_HE",
- "date": "2026-01-27",
- "date_confidence": "high",
- "engagement": {
- "score": 1,
- "num_comments": 0
- },
- "top_comments": [],
- "comment_insights": [],
- "relevance": 0.78,
- "why_relevant": "HN story about Show HN: Seedance 2.0 Pro AI Video Generator",
- "subs": {
- "relevance": 78,
- "recency": 3,
- "engagement": 0
- },
- "score": 35,
- "cross_refs": [
- "R6"
- ]
- }
- ],
- "best_practices": [],
- "prompt_pack": [],
- "context_snippet_md": "# Context: Seedance AI video generation (Last 30 Days)\n\n*Generated: 2026-02-25 | Sources: both*\n\n## Key Sources\n\n- [X] First Seedance 2.0 Test! \n\nSeedance 5.0 Lite @krea...\n- [Reddit] Looking for a real Seedance website that actually works (not scams, SJINN is too expensive)\n- [X] Goku vs Broly , EPIC Full Battle! \nWitness the ult...\n- [Reddit] Seedance 2.0 now live in CapCut desktop and API access available, details below\n- [Reddit] Is there an official way to create content using Seedance 2.0?\n- [Reddit] Official website for creating content with Seedance 2.0?\n- [Reddit] This is terrifying!! Seedance 2.0 just generated a 1-minute film with ZERO editing \u2014 the entire film industry should be worried\n\n## Summary\n\n*See full report for best practices, prompt pack, and detailed sources.*\n"
-}
diff --git a/docs/comparison-results/json/cross-3-macbook.json b/docs/comparison-results/json/cross-3-macbook.json
deleted file mode 100644
index a74793d..0000000
--- a/docs/comparison-results/json/cross-3-macbook.json
+++ /dev/null
@@ -1,519 +0,0 @@
-{
- "topic": "M4 MacBook Pro review",
- "range": {
- "from": "2026-01-26",
- "to": "2026-02-25"
- },
- "generated_at": "2026-02-25T20:35:08.911728+00:00",
- "mode": "both",
- "openai_model_used": "gpt-5.2",
- "xai_model_used": "grok-4-1-fast",
- "reddit": [
- {
- "id": "R1",
- "title": "Disappointed with my MBP M4 Experience \u2026",
- "url": "https://www.reddit.com/r/macbookpro/comments/1mtmkts/disappointed_with_my_mbp_m4_experience/",
- "subreddit": "macbookpro",
- "date": "2025-08-18",
- "date_confidence": "low",
- "engagement": null,
- "top_comments": [],
- "comment_insights": [],
- "relevance": 0.92,
- "why_relevant": "First-hand experience post reacting to reviews and describing real-world issues/behavior on an M4 MacBook Pro.",
- "subs": {
- "relevance": 0,
- "recency": 0,
- "engagement": 0
- },
- "score": 0
- },
- {
- "id": "R2",
- "title": "Returned M4 MacBook Air for M4 MacBook Pro",
- "url": "https://www.reddit.com/r/macbookpro/comments/1l6cx3y/returned_m4_macbook_air_for_m4_macbook_pro/",
- "subreddit": "macbookpro",
- "date": "2025-06-08",
- "date_confidence": "low",
- "engagement": null,
- "top_comments": [],
- "comment_insights": [],
- "relevance": 0.91,
- "why_relevant": "Comparison/mini-review style discussion after switching to an M4 MacBook Pro (display, ports, speakers, performance).",
- "subs": {
- "relevance": 0,
- "recency": 0,
- "engagement": 0
- },
- "score": 0
- },
- {
- "id": "R3",
- "title": "M4 MacBook Pro 14 Inch Battery Life, Should I Be Concerned??",
- "url": "https://www.reddit.com/r/macbookpro/comments/1jra25q/m4_macbook_pro_14_inch_battery_life_should_i_be_concerned/",
- "subreddit": "macbookpro",
- "date": "2025-04-04",
- "date_confidence": "low",
- "engagement": null,
- "top_comments": [],
- "comment_insights": [],
- "relevance": 0.88,
- "why_relevant": "Battery life discussion framed against Apple\u2019s advertised numbers; lots of owner feedback (real-world \u201creview\u201d angle).",
- "subs": {
- "relevance": 0,
- "recency": 0,
- "engagement": 0
- },
- "score": 0
- }
- ],
- "x": [
- {
- "id": "X1",
- "text": "If you are in the market for a new MacBook Pro and you\u2019re wondering what the real differences are between Apple\u2019s M3 and M4 silicon, you\u2019ve come to the right place \u2b07\ufe0f\nhttps://t.co/nzjXmiHmac https://t.co/632eHWLq70",
- "url": "https://x.com/bhphoto/status/2026737767695183948",
- "author_handle": "bhphoto",
- "date": "2026-02-25",
- "date_confidence": "high",
- "engagement": {
- "likes": 3
- },
- "relevance": 0.7,
- "why_relevant": "",
- "subs": {
- "relevance": 70,
- "recency": 100,
- "engagement": 100
- },
- "score": 86
- },
- {
- "id": "X4",
- "text": "\ud83d\udd25 2024 16\u201d MacBook Pro Price List\n\n1\ufe0f\u20e3 \ud83c\uddfa\ud83c\uddf8 Brand New MacBook Pro 2024 | M4 Pro Chip | 16\u201d Display\n\n24GB RAM | 512GB SSD\n14-Core CPU | 20-Core GPU\n\nSpace Black\n\n\ud83d\udcb0 \u20a62,720,000\n\n2\ufe0f\u20e3 \ud83c\uddfa\ud83c\uddf8 Brand New MacBook Pro 2024 | M4 Pro Chip | 16\u201d Display\n\n48GB RAM | 512GB SSD\n14-Core CPU | 20-Core GPU\n\nSpace Black\n\n\ud83d\udcb0 \u20a63,220,000",
- "url": "https://x.com/remplug/status/2026725100213465443",
- "author_handle": "remplug",
- "date": "2026-02-25",
- "date_confidence": "high",
- "engagement": {
- "likes": 1,
- "reposts": 1
- },
- "relevance": 0.7,
- "why_relevant": "",
- "subs": {
- "relevance": 70,
- "recency": 100,
- "engagement": 45
- },
- "score": 70
- },
- {
- "id": "X10",
- "text": "@5TRgzn @LobstarWilde My marble floors: 64GB M4 Pro/Max MacBook Pro. Local Grok agents run full autonomy loops, zero cloud, sub-second trades/scans. Intel's ancient\u2014ditch it. Yours at $3-4k.",
- "url": "https://x.com/grok/status/2026713588472111312",
- "author_handle": "grok",
- "date": "2026-02-25",
- "date_confidence": "high",
- "engagement": null,
- "relevance": 0.7,
- "why_relevant": "",
- "subs": {
- "relevance": 70,
- "recency": 100,
- "engagement": 35
- },
- "score": 64
- },
- {
- "id": "X9",
- "text": "@JulianGoldieSEO @grok can a macbook pro m4 run this?",
- "url": "https://x.com/principenemesis/status/2026716193042706852",
- "author_handle": "principenemesis",
- "date": "2026-02-25",
- "date_confidence": "high",
- "engagement": {
- "replies": 1
- },
- "relevance": 0.7,
- "why_relevant": "",
- "subs": {
- "relevance": 70,
- "recency": 100,
- "engagement": 35
- },
- "score": 64
- },
- {
- "id": "X5",
- "text": "@SebAaltonen Not my experience. It\u2019s impressive, 70 tokens/s on a maxed out M4 128gb MacBook Pro.\n\nBut absolutely sucked and not even remotely in the ballpark with Sonnet 4.5. It\u2019s GPT-3.5.\n\nTotally broken with kode. Unusable even setting aside speed.\n\nBasically a 2023 model. Not bad for 35b.",
- "url": "https://x.com/jameslmorton/status/2026721943630786816",
- "author_handle": "jameslmorton",
- "date": "2026-02-25",
- "date_confidence": "high",
- "engagement": {
- "likes": 1,
- "replies": 1
- },
- "relevance": 0.7,
- "why_relevant": "",
- "subs": {
- "relevance": 70,
- "recency": 100,
- "engagement": 27
- },
- "score": 64
- },
- {
- "id": "X6",
- "text": "BR Ofertas todos os dias \ud83c\udde7\ud83c\uddf7\n\nLeve seu Mac Pro M4 hoje! \ud83d\udcbb\u26a1\u2728\n\nPegue j\u00e1: https://t.co/PSwylZH3Sz\n\nPor R$ 23.749,00\nem at\u00e9 10x sem juros\nFrete Gr\u00e1tis\n\nApple MacBook Pro 14\u2033 2024 M4 Pro 12 n\u00facleos 24GB 512GB Prateado\n\nAmazon | Notebook https://t.co/gW3FonCEGP",
- "url": "https://x.com/bcofertas/status/2026721014676099084",
- "author_handle": "bcofertas",
- "date": "2026-02-25",
- "date_confidence": "high",
- "engagement": null,
- "relevance": 0.7,
- "why_relevant": "",
- "subs": {
- "relevance": 70,
- "recency": 100,
- "engagement": 35
- },
- "score": 64
- },
- {
- "id": "X12",
- "text": "Hey! Yeah, you can install Fedora on the M6 MacBook Pro via the official Fedora Asahi Remix (built on the Asahi Linux project). It already runs great on M1/M2, with M3 support rolling out now and work starting on M4/M5. M6 support will come post-launch as they reverse-engineer it\u2014usually within months. Head to https://t.co/XMQLmJlUGO to track progress and install from macOS.",
- "url": "https://x.com/grok/status/2026704108820824113",
- "author_handle": "grok",
- "date": "2026-02-25",
- "date_confidence": "high",
- "engagement": null,
- "relevance": 0.7,
- "why_relevant": "",
- "subs": {
- "relevance": 70,
- "recency": 100,
- "engagement": 35
- },
- "score": 64
- },
- {
- "id": "X7",
- "text": "Promo\u00e7\u00f5es BR do dia \ud83c\udde7\ud83c\uddf7\n\nLeve seu Mac Pro M4 hoje! \ud83d\udcbb\u26a1\u2728\n\nVer aqui: https://t.co/LfxNw3waps\n\nPor R$ 23.749,00\nem at\u00e9 10x sem juros\nFrete Gr\u00e1tis\n\nApple MacBook Pro 14\u2033 2024 M4 Pro 12 n\u00facleos...\n\nAmazon | Notebook https://t.co/fhjiFnA4b3",
- "url": "https://x.com/toppromoalertas/status/2026719111523237932",
- "author_handle": "toppromoalertas",
- "date": "2026-02-25",
- "date_confidence": "high",
- "engagement": null,
- "relevance": 0.7,
- "why_relevant": "",
- "subs": {
- "relevance": 70,
- "recency": 100,
- "engagement": 35
- },
- "score": 64
- },
- {
- "id": "X8",
- "text": "Yes, a MacBook Pro M4 can run this Ollama + GLM-4.7-Flash setup locally.\n\nOllama has native Apple Silicon support via Metal. The Q4_K_M model (~19GB) needs ~18-24GB unified memory for good speed (feels like a fast 7-12B model due to MoE). \n\nBase M4 configs start at 16GB (works, but slower with smaller context); 24GB+ is ideal for smooth agent tasks in OpenClaw.\n\nJust: brew install ollama (or download app), ollama run glm4.7-flash, link to OpenClaw. No issues reported on M3/M4 series.",
- "url": "https://x.com/grok/status/2026716420269031652",
- "author_handle": "grok",
- "date": "2026-02-25",
- "date_confidence": "high",
- "engagement": null,
- "relevance": 0.7,
- "why_relevant": "",
- "subs": {
- "relevance": 70,
- "recency": 100,
- "engagement": 35
- },
- "score": 64
- },
- {
- "id": "X2",
- "text": "@TechByTaraa Both. macOS (Macbook Pro M4 Pro) for my photography/videography personal stuff + software engineering and a Linux desktop workstation for software engineering + second desktop workstation with Proxmox for Windows VMs and testing stuff",
- "url": "https://x.com/grecinoscdev/status/2026731118142148644",
- "author_handle": "grecinoscdev",
- "date": "2026-02-25",
- "date_confidence": "high",
- "engagement": {
- "likes": 1
- },
- "relevance": 0.7,
- "why_relevant": "",
- "subs": {
- "relevance": 70,
- "recency": 100,
- "engagement": 0
- },
- "score": 56
- },
- {
- "id": "X11",
- "text": "Apple 2024 MacBook Pro 14\u30b3\u30a2CPU\u300120\u30b3\u30a2GPU \u306e M4 Pro\u642d\u8f09\u30ce\u30fc\u30c8\u30d1... PR\n\n4549995547610\n\n\u30dd\u30a4\u30f3\u30c8: 3735\u333d\n\u60f3\u5b9a\u4fa1\u683c: 369,800\n\n2026/02/26 01:55:23\nhttps://t.co/q1uyIqFsID",
- "url": "https://x.com/Steve_Lost_Jobs/status/2026705327828382016",
- "author_handle": "Steve_Lost_Jobs",
- "date": "2026-02-25",
- "date_confidence": "high",
- "engagement": {
- "likes": 1
- },
- "relevance": 0.7,
- "why_relevant": "",
- "subs": {
- "relevance": 70,
- "recency": 100,
- "engagement": 0
- },
- "score": 56
- },
- {
- "id": "X3",
- "text": "No, Apple has not made any MacBook (Pro or otherwise) cost $299. \n\nThe linked Newsroom search returns zero matching results\u2014it's just unrelated announcements. Current cheapest Mac is the 13\" MacBook Air M4 from $999.\n\nRumors suggest a basic \"budget\" MacBook (A18 Pro chip, ~12.9\" screen) may launch March 4 around $599\u2013$799, but nothing confirmed and far from $299. The original post is a joke/mislead.",
- "url": "https://x.com/grok/status/2026727605517394283",
- "author_handle": "grok",
- "date": "2026-02-25",
- "date_confidence": "high",
- "engagement": {
- "likes": 1
- },
- "relevance": 0.7,
- "why_relevant": "",
- "subs": {
- "relevance": 70,
- "recency": 100,
- "engagement": 0
- },
- "score": 56
- }
- ],
- "web": [],
- "youtube": [
- {
- "id": "9HQx5pgUoiY",
- "title": "M4 Max MacBook Pro: I'm Convinced!",
- "url": "https://www.youtube.com/watch?v=9HQx5pgUoiY",
- "channel_name": "Marques Brownlee",
- "date": "2024-11-18",
- "date_confidence": "high",
- "engagement": {
- "num_comments": 5500,
- "likes": 105606,
- "views": 4010567
- },
- "transcript_snippet": "so I have been using this M1 Max MacBook Pro for the past 3 years since it came out and it's been great I have felt absolutely no compulsion to upgrade since haven't needed to it's funny most of my work is pretty light but occasionally I will have to edit a video on the go which is then when you know the extra horsepower and the screen and everything about this comes in handy and it's great if I didn't have to do that ever I would just have like an air or a surface laptop something like that but that range of work is why this has been great for me and it's been awesome so [Music] far so Along Comes This M4 generation of MacBook Pros and it's the first generation that I have actually been tempted to maybe upgrade to you know they stacked a couple of the right upgrades in the right places for things that actually make a difference to me so let's just get right into it nuts and bolts cuz the first big pillar is performance and the main reason why this new laptop matters at all is that it's got the new generation of M4 chips inside and we all know the leap from Intel chips to apple cic chips for M1 was generational but since then it's been a sort of more reasonable incremental upgrade going from M1 to M2 then M3 and then M4 so I think it's pretty obvious that nobody with an M2 or M3 needs to upgrade but there's been enough improvements now stacking up upon each other from M1 to M4 that the numbers are now starting to get kind of interesting so just to set the stage this laptop that I've been using is the M1 Max maxed out basically the best you could get at the time 64 gigs of of unified memory on this system and this unit I've been testing of the M4 Max is also maxed out it's the best you can get now it's 128 gigs of faster unified memory but the cores are all better fast stronger on the new one 3 nomers versus 546 gigs per second of memory bandwidth versus 400 just the hardware improvements to get to M4 Max are clearly there so to keep the Apples to Apples comparison going Apple in their own presentation said up to 2.2 times faster CPU performance than M1 Max and 1.9 times faster GPU performance so we're looking at around twice as fast and the synthetic benchmarks I've run back this up I got over 4,000 in geekbench 6 CPU single core which is the highest single core score ever in a Mac by a while and the multicore score was incredible too tops the chart and then GPU has improved a lot to the point where it's bumping up against M2 Ultra not M2 Max M2 Ultra chips the one that's currently in the Mac Pro...",
- "relevance": 0.75,
- "why_relevant": "YouTube: M4 Max MacBook Pro: I'm Convinced!",
- "subs": {
- "relevance": 75,
- "recency": 0,
- "engagement": 100
- },
- "score": 63
- },
- {
- "id": "b4x8boB2KdI",
- "title": "M4 MacBook Pro Review - Things to Know",
- "url": "https://www.youtube.com/watch?v=b4x8boB2KdI",
- "channel_name": "Dave2D",
- "date": "2024-11-07",
- "date_confidence": "high",
- "engagement": {
- "num_comments": 827,
- "likes": 24977,
- "views": 1057971
- },
- "transcript_snippet": "so Apple launched the new M4 MacBook Pros this is their 14-in model and it's equipped with the M4 Max their topend configuration and it also has their new Nano texture on their screen it's a texture that's designed to cut down Reflections it's the first time they've actually had it on one of their laptops uh but I want to do a quick video here that kind of goes over the important things I think you should be aware of when it comes to this device and the first thing I want to talk about is performance the CPU gains are quite good year on-ear but it's particularly impressive that the M4 Max can outperform the M2 Ultra in a lot of benchmarks and workflows in terms of Graphics the GPU gains on the M4 Pro are decent but the GPU games on the M4 Max are very impressive most GPU bound apps and games will get a nice bump up from this plus the rate tracing capabilities are better so apps and games can take advantage of that the thing I'm most impressed by though is how good the M4 Max GPU is compared to the previous generation especially at a very similar thermal envelope like if you think about the competitors in this space like AMD Nvidia when they come out with laptop gpus typically the new models come also come with like an increase in power requirement right it's just like you know 5 or 10 watts per year but it adds up over the course of time and on this device if you think about the M1 Max to the M4 Max the GPU has improved so much with very little increase in power consumption The Thermals on this device are pretty much the same as last year's models in terms of memory the base configuration start at 16 GB now thankfully and they've also increased the memory bandwidth it's noticeable already on the base models but the pro and Max chips get a significant boost in memory speed so for applications that lean on Fast memory you definitely notice a bump up this year if I'm being honest though it's difficult for me to tell how much of the gains that we're seeing this year come from the memory speed increase versus the CPU or the GPU increase like they're all tied together and I don't have the tools to lock memory speed on a Macbook so it's tough to tell I will say though that if you have an older M1 or M2 or M3 MacBook Pro I wouldn't upgrade because Apple's silicon performance progresses so fast year on-ear that it can pay off to just wait as long as you can before you buy new hardware okay let's talk about the screens so this particular device is running their new Nano texture finish and it's finish we've seen on other devices before right like their screens uh the new iPads and every...",
- "relevance": 1.0,
- "why_relevant": "YouTube: M4 MacBook Pro Review - Things to Know",
- "subs": {
- "relevance": 100,
- "recency": 0,
- "engagement": 60
- },
- "score": 63
- },
- {
- "id": "a8Szdrnq0YM",
- "title": "MacBook Pro M4 - Review After 3 Months: This Feels Wrong.",
- "url": "https://www.youtube.com/watch?v=a8Szdrnq0YM",
- "channel_name": "Brandon Butch",
- "date": "2025-02-03",
- "date_confidence": "high",
- "engagement": {
- "num_comments": 397,
- "likes": 4309,
- "views": 352593
- },
- "transcript_snippet": "",
- "relevance": 1.0,
- "why_relevant": "YouTube: MacBook Pro M4 - Review After 3 Months: This Feels Wrong.",
- "subs": {
- "relevance": 100,
- "recency": 0,
- "engagement": 25
- },
- "score": 52
- },
- {
- "id": "etP2Th9g2hM",
- "title": "Don't buy the Wrong MacBook like me... - M4 MacBook Pro",
- "url": "https://www.youtube.com/watch?v=etP2Th9g2hM",
- "channel_name": "ShortCircuit",
- "date": "2024-11-30",
- "date_confidence": "high",
- "engagement": {
- "num_comments": 910,
- "likes": 22765,
- "views": 1113999
- },
- "transcript_snippet": "woohoo it's new Macbook day my favorite day on short circuit it only comes usually once a year this is the MacBook Pro M4 looks pretty similar to the last one but now it has an M4 instead of an M3 I'm not going to lie I am more of an M3 kind of guy but I can rock an M4 if I have to we've got their usual mag safe 3 to USBC 2 m sleeved nice cable and then a charging brick Oh I ripped it 70 wat USBC charging at least with the 14in I'm not sure what comes with the rest and then the usual Apple propaganda these still come with Apple stickers right do these not come with Apple stickers anymore bro what's the point in even buying them then it's probably good for the planet or something now if you're wondering I am still rocking the same Intel MacBook Pro over here I haven't upgraded yet so this is again another instance of me going hm maybe it's finally time except when I got to set today I realized tragedy struck we bought the wrong MacBook it is still an M4 Pro it's a good form factor we got the 14in here in the sexy space black color profile you can also get it in silver of course but there was a horrible error made and I'm not going to tell you just yet you're going to have to wait a second because first I need to go over the outside of this thing we've got an HDMI 2.1 port a USBC Thunderbolt Port an SD card reer and then on the other side another two USBC Thunderbolt ports 3.5 mil combo audio jack and mag safe there is an upgrade here on the USBC ports they're now Thunderbolt 5 instead of Thunderbolt 4 at least on the pro and Max models uh the base M4 spec only is Thunderbolt 4 and that's good for 120 gbit which is up to 3 times faster than Thunderbolt 4 on the inside bam it's it's pretty much the same keyboard look at that I think it is exactly the same keyboard pretty similar touchpad and then there is the display it looks pretty much the same same resolution same xdr fancy technology it is 1,000 nits Peak brightness when you're Outdoors instead of 600 so that should help with outdoor visibility um there is another change I'm going to tell you about in a bit but the big thing is the new nanotexture option which is an anti-glare coating and as you can tell we don't have it and I saw I Justine's video when it came out look at the difference what the hell you can't even use the one on the right and the one on the left looks beautiful it actually seems to work really well I can't wait to try it myself and honestly I think based on that if you're somebody...",
- "relevance": 0.75,
- "why_relevant": "YouTube: Don't buy the Wrong MacBook like me... - M4 MacBook Pro",
- "subs": {
- "relevance": 75,
- "recency": 0,
- "engagement": 60
- },
- "score": 51
- },
- {
- "id": "i3eTKJav1VI",
- "title": "M4 Pro MacBook - Long Term Review (6 Months Later)",
- "url": "https://www.youtube.com/watch?v=i3eTKJav1VI",
- "channel_name": "Created Tech",
- "date": "2025-04-29",
- "date_confidence": "high",
- "engagement": {
- "num_comments": 136,
- "likes": 1832,
- "views": 137009
- },
- "transcript_snippet": "",
- "relevance": 1.0,
- "why_relevant": "YouTube: M4 Pro MacBook - Long Term Review (6 Months Later)",
- "subs": {
- "relevance": 100,
- "recency": 0,
- "engagement": 0
- },
- "score": 45
- },
- {
- "id": "uPe9spXLfZY",
- "title": "M4 MacBook Air vs M4 MacBook Pro - 4 Months Later",
- "url": "https://www.youtube.com/watch?v=uPe9spXLfZY",
- "channel_name": "Created Tech",
- "date": "2025-07-07",
- "date_confidence": "high",
- "engagement": {
- "num_comments": 437,
- "likes": 5082,
- "views": 472845
- },
- "transcript_snippet": "",
- "relevance": 0.75,
- "why_relevant": "YouTube: M4 MacBook Air vs M4 MacBook Pro - 4 Months Later",
- "subs": {
- "relevance": 75,
- "recency": 0,
- "engagement": 31
- },
- "score": 43
- },
- {
- "id": "TfvIgdzImt4",
- "title": "The Macbook Pro M4 is Insane.",
- "url": "https://www.youtube.com/watch?v=TfvIgdzImt4",
- "channel_name": "Hardware Canucks",
- "date": "2024-12-15",
- "date_confidence": "high",
- "engagement": {
- "num_comments": 435,
- "likes": 6339,
- "views": 325599
- },
- "transcript_snippet": "",
- "relevance": 0.75,
- "why_relevant": "YouTube: The Macbook Pro M4 is Insane.",
- "subs": {
- "relevance": 75,
- "recency": 0,
- "engagement": 28
- },
- "score": 42
- },
- {
- "id": "anIVewgtbFc",
- "title": "The Base M4 MacBook Pro is All You Need (Skip M4 Pro & Max)",
- "url": "https://www.youtube.com/watch?v=anIVewgtbFc",
- "channel_name": "MacRumors",
- "date": "2024-12-17",
- "date_confidence": "high",
- "engagement": {
- "num_comments": 336,
- "likes": 2874,
- "views": 238619
- },
- "transcript_snippet": "",
- "relevance": 0.75,
- "why_relevant": "YouTube: The Base M4 MacBook Pro is All You Need (Skip M4 Pro & Max)",
- "subs": {
- "relevance": 75,
- "recency": 0,
- "engagement": 15
- },
- "score": 38
- },
- {
- "id": "BDpNDniE1PA",
- "title": "M4 Pro MacBook Pro after 6 Months - Everyone was WRONG!",
- "url": "https://www.youtube.com/watch?v=BDpNDniE1PA",
- "channel_name": "Max Tech",
- "date": "2025-04-18",
- "date_confidence": "high",
- "engagement": {
- "num_comments": 256,
- "likes": 3077,
- "views": 197898
- },
- "transcript_snippet": "",
- "relevance": 0.75,
- "why_relevant": "YouTube: M4 Pro MacBook Pro after 6 Months - Everyone was WRONG!",
- "subs": {
- "relevance": 75,
- "recency": 0,
- "engagement": 12
- },
- "score": 37
- },
- {
- "id": "0vn9T3x4dGg",
- "title": "M4 Pro MacBook Pro after 1 Month... BEST Mac EVER!?",
- "url": "https://www.youtube.com/watch?v=0vn9T3x4dGg",
- "channel_name": "Max Tech",
- "date": "2024-12-09",
- "date_confidence": "high",
- "engagement": {
- "num_comments": 270,
- "likes": 3345,
- "views": 200580
- },
- "transcript_snippet": "",
- "relevance": 0.75,
- "why_relevant": "YouTube: M4 Pro MacBook Pro after 1 Month... BEST Mac EVER!?",
- "subs": {
- "relevance": 75,
- "recency": 0,
- "engagement": 13
- },
- "score": 37
- }
- ],
- "hackernews": [],
- "best_practices": [],
- "prompt_pack": [],
- "context_snippet_md": "# Context: M4 MacBook Pro review (Last 30 Days)\n\n*Generated: 2026-02-25 | Sources: both*\n\n## Key Sources\n\n- [X] If you are in the market for a new MacBook Pro and...\n- [X] \ud83d\udd25 2024 16\u201d MacBook Pro Price List\n\n1\ufe0f\u20e3 \ud83c\uddfa\ud83c\uddf8 Brand Ne...\n- [X] @5TRgzn @LobstarWilde My marble floors: 64GB M4 Pr...\n- [X] @JulianGoldieSEO @grok can a macbook pro m4 run th...\n- [X] @SebAaltonen Not my experience. It\u2019s impressive, 7...\n- [Reddit] Disappointed with my MBP M4 Experience \u2026\n- [Reddit] Returned M4 MacBook Air for M4 MacBook Pro\n\n## Summary\n\n*See full report for best practices, prompt pack, and detailed sources.*\n"
-}
diff --git a/docs/comparison-results/json/cross-4-rap.json b/docs/comparison-results/json/cross-4-rap.json
deleted file mode 100644
index 0f3d78d..0000000
--- a/docs/comparison-results/json/cross-4-rap.json
+++ /dev/null
@@ -1,524 +0,0 @@
-{
- "topic": "best rap songs 2026",
- "range": {
- "from": "2026-01-26",
- "to": "2026-02-25"
- },
- "generated_at": "2026-02-25T20:37:25.272953+00:00",
- "mode": "both",
- "openai_model_used": "gpt-5.2",
- "xai_model_used": "grok-4-1-fast",
- "reddit": [
- {
- "id": "R1",
- "title": "Reddit Ranked: Hip Hop \u201826",
- "url": "https://www.reddit.com/r/redranked/comments/1qszauu/reddit_ranked_hip_hop_26/",
- "subreddit": "edranked",
- "date": "2026-02-01",
- "date_confidence": "high",
- "engagement": null,
- "top_comments": [],
- "comment_insights": [],
- "relevance": 0.91,
- "why_relevant": "Explicitly about Hip Hop '26 submissions and ranking tracks (community-sourced 'best of 2026' style thread).",
- "subs": {
- "relevance": 91,
- "recency": 19,
- "engagement": 50
- },
- "score": 57
- },
- {
- "id": "R9",
- "title": "Daily Discussion Thread 02/24/2026",
- "url": "https://www.reddit.com/r/hiphopheads/comments/1rdjmgu/daily_discussion_thread_02242026/",
- "subreddit": "hiphopheads",
- "date": "2026-02-24",
- "date_confidence": "high",
- "engagement": null,
- "top_comments": [],
- "comment_insights": [],
- "relevance": 0.46,
- "why_relevant": "General hip-hop discussion thread where users frequently share current favorite/best songs and recommendations (2026-dated).",
- "subs": {
- "relevance": 46,
- "recency": 96,
- "engagement": 50
- },
- "score": 56
- },
- {
- "id": "R4",
- "title": "[DISCUSSION] Drake & PARTYNXTDOOR - $OME $EXY $ONGS 4 U (1 Year Later)",
- "url": "https://www.reddit.com/r/hiphopheads/comments/1r4cbez/discussion_drake_partynxtdoord_ome_exy_ongs_4_u_1/",
- "subreddit": "hiphopheads",
- "date": "2026-02-14",
- "date_confidence": "high",
- "engagement": null,
- "top_comments": [],
- "comment_insights": [],
- "relevance": 0.63,
- "why_relevant": "Track-focused discussion about songs still in rotation; relevant to identifying standout rap/R&B tracks around 2026.",
- "subs": {
- "relevance": 63,
- "recency": 63,
- "engagement": 50
- },
- "score": 56
- },
- {
- "id": "R21",
- "title": "2/22/2026 | Week 23 Playlist Competition",
- "url": "https://www.reddit.com/r/makemeaplaylist/comments/1rb46m6/2222026_week_23_playlist_competition/",
- "subreddit": "makemeaplaylist",
- "date": "2026-02-21",
- "date_confidence": "high",
- "engagement": null,
- "top_comments": [],
- "comment_insights": [],
- "relevance": 0.46,
- "why_relevant": "Playlist competition thread featuring an underground rap playlist winner; useful for surfacing 'best rap' picks around 2026.",
- "subs": {
- "relevance": 46,
- "recency": 86,
- "engagement": 50
- },
- "score": 54
- },
- {
- "id": "R16",
- "title": "What's the best rap song ever made?",
- "url": "https://www.reddit.com/r/TeenageRapFans/comments/1r5iub6/whats_the_best_rap_song_ever_made/",
- "subreddit": "TeenageRapFans",
- "date": "2026-02-15",
- "date_confidence": "high",
- "engagement": null,
- "top_comments": [],
- "comment_insights": [],
- "relevance": 0.55,
- "why_relevant": "Direct 'best rap song' debate thread (highly aligned with best-rap-songs searching).",
- "subs": {
- "relevance": 55,
- "recency": 66,
- "engagement": 50
- },
- "score": 53
- },
- {
- "id": "R18",
- "title": "Rick 2026 : Pop Dance RnB Chart Hits Rock Indie HipHop Divas Rap Gym 80s 90s 00s 10s 20s Party Music",
- "url": "https://www.reddit.com/r/playlists/comments/1r5iuz5/rick_2026_pop_dance_rnb_chart_hits_rock_indie/",
- "subreddit": "playlists",
- "date": "2026-02-15",
- "date_confidence": "high",
- "engagement": null,
- "top_comments": [],
- "comment_insights": [],
- "relevance": 0.5,
- "why_relevant": "2026-tagged playlist thread explicitly including HipHop/Rap; can be mined for top rap tracks in 2026 rotation.",
- "subs": {
- "relevance": 50,
- "recency": 66,
- "engagement": 50
- },
- "score": 51
- },
- {
- "id": "R5",
- "title": "Pre-Show Grammy Winners",
- "url": "https://www.reddit.com/r/hiphopheads/comments/1qtcbmo/preshow_grammy_winners/",
- "subreddit": "hiphopheads",
- "date": "2026-02-01",
- "date_confidence": "high",
- "engagement": null,
- "top_comments": [],
- "comment_insights": [],
- "relevance": 0.7,
- "why_relevant": "Includes Best Rap Song / Best Rap Performance winners; strong signal for 'best rap songs' discourse in 2026 season.",
- "subs": {
- "relevance": 70,
- "recency": 19,
- "engagement": 50
- },
- "score": 48
- },
- {
- "id": "R8",
- "title": "Andre Toutman claims All The Love will be \"song of the year\"",
- "url": "https://www.reddit.com/r/GoodAssSub/comments/1qty80z/andre_toutman_claims_all_the_love_will_be_song_of/",
- "subreddit": "GoodAssSub",
- "date": "2026-02-02",
- "date_confidence": "high",
- "engagement": null,
- "top_comments": [],
- "comment_insights": [],
- "relevance": 0.6,
- "why_relevant": "Direct 'song of the year' debate thread (rap/hip-hop community), aligned with 'best songs' discovery for 2026.",
- "subs": {
- "relevance": 60,
- "recency": 23,
- "engagement": 50
- },
- "score": 44
- },
- {
- "id": "R6",
- "title": "Kendrick Lamar Wins 2nd Record of the Year Grammy With SZA for \u2018Luther\u2019",
- "url": "https://www.reddit.com/r/hiphopheads/comments/1qtl5kr/kendrick_lamar_wins_2nd_record_of_the_year_grammy/",
- "subreddit": "hiphopheads",
- "date": "2026-02-02",
- "date_confidence": "high",
- "engagement": null,
- "top_comments": [],
- "comment_insights": [],
- "relevance": 0.58,
- "why_relevant": "High-engagement thread about a major hip-hop adjacent song winning; useful for 'best songs' discussions around 2026.",
- "subs": {
- "relevance": 57,
- "recency": 23,
- "engagement": 50
- },
- "score": 43
- },
- {
- "id": "R15",
- "title": "My top 25 rap songs of all time",
- "url": "https://www.reddit.com/r/musicteenager/comments/1qvz9qu/my_top_25_rap_songs_of_all_time/",
- "subreddit": "musicteenager",
- "date": "2026-02-04",
- "date_confidence": "high",
- "engagement": null,
- "top_comments": [],
- "comment_insights": [],
- "relevance": 0.52,
- "why_relevant": "User-made ranked list of rap songs; while not 2026-specific, it matches the 'best rap songs' core subject.",
- "subs": {
- "relevance": 52,
- "recency": 30,
- "engagement": 50
- },
- "score": 42
- },
- {
- "id": "R7",
- "title": "68th Annual Grammy Awards [LIVE MEGATHREAD]",
- "url": "https://www.reddit.com/r/KendrickLamar/comments/1qtczjl/68th_annual_grammy_awards_live_megathread/",
- "subreddit": "KendrickLamar",
- "date": "2026-02-01",
- "date_confidence": "high",
- "engagement": null,
- "top_comments": [],
- "comment_insights": [],
- "relevance": 0.54,
- "why_relevant": "Live discussion around Grammys 2026 including rap categories; good for discovering what Reddit called the best tracks.",
- "subs": {
- "relevance": 54,
- "recency": 19,
- "engagement": 50
- },
- "score": 41
- }
- ],
- "x": [
- {
- "id": "X5",
- "text": "Talking about meaningful rap songs...\n\nWORLD BEST LIE - NUNO ZIGI (2026)\ud83e\udd7a\u2764 https://t.co/MDHRf3giYq",
- "url": "https://x.com/Zika_gfx/status/2026576570513600788",
- "author_handle": "Zika_gfx",
- "date": "2026-02-25",
- "date_confidence": "high",
- "engagement": {
- "likes": 12,
- "reposts": 5,
- "replies": 2
- },
- "relevance": 0.7,
- "why_relevant": "",
- "subs": {
- "relevance": 70,
- "recency": 100,
- "engagement": 100
- },
- "score": 86
- },
- {
- "id": "X6",
- "text": "@PopCrave Partition still goes harder than most 2026 songs \n12 years and the beat switch + that rap still give chills every time. Queen never misses",
- "url": "https://x.com/DPOSTS6/status/2026523320548819145",
- "author_handle": "DPOSTS6",
- "date": "2026-02-25",
- "date_confidence": "high",
- "engagement": {
- "likes": 4,
- "reposts": 1
- },
- "relevance": 0.7,
- "why_relevant": "",
- "subs": {
- "relevance": 70,
- "recency": 100,
- "engagement": 41
- },
- "score": 68
- },
- {
- "id": "X9",
- "text": "@Unfath0m4ble @robinthisbish @lilceaserthabp1 @RapWikip What You Saying by Lil Uzi Vert. Dropped Dec 2025, hit #1 on Hot Rap Songs in Jan 2026 (his 3rd time), peaked #14 Hot 100. Super short track ~2 min with minimal lyrics.",
- "url": "https://x.com/grok/status/2026467153046606064",
- "author_handle": "grok",
- "date": "2026-02-25",
- "date_confidence": "high",
- "engagement": null,
- "relevance": 0.7,
- "why_relevant": "",
- "subs": {
- "relevance": 70,
- "recency": 100,
- "engagement": 35
- },
- "score": 64
- },
- {
- "id": "X7",
- "text": "@lil_loonie1 @oldmanebro No, Lil Uzi Vert isn't struggling. His \"What You Saying\" hit #1 on Hot Rap Songs in Jan 2026 (first in 3 years) and top 15 Hot 100. Insiders say a surprise album drop is coming soon, with fans calling 2026 his revival year like 2016. Proving that old prediction wrong.",
- "url": "https://x.com/grok/status/2026518008827404632",
- "author_handle": "grok",
- "date": "2026-02-25",
- "date_confidence": "high",
- "engagement": {
- "replies": 1
- },
- "relevance": 0.7,
- "why_relevant": "",
- "subs": {
- "relevance": 70,
- "recency": 100,
- "engagement": 35
- },
- "score": 64
- },
- {
- "id": "X2",
- "text": "Su La Musica che gira intorno... \"Qui con me\" \u00e8 il brano con cui Serena Brancale \u00e8 in gara al 76\u00b0 Festival di Sanremo\nhttps://t.co/wTPeovzt6u\n#music #genre #song #songs #envywear #PleaseForgiveMe #melody #hiphop #rnb #pop #love #rap #dubstep #instagood https://t.co/Pi7msX5FG6",
- "url": "https://x.com/Ferginangi/status/2026696207557382298",
- "author_handle": "Ferginangi",
- "date": "2026-02-25",
- "date_confidence": "high",
- "engagement": null,
- "relevance": 0.7,
- "why_relevant": "",
- "subs": {
- "relevance": 70,
- "recency": 100,
- "engagement": 35
- },
- "score": 64
- },
- {
- "id": "X12",
- "text": "[ ** STUDIO BOOTH ** ] ** DROPPING SOON - 2026 **\ud83d\udd25\ud83d\udd25\ud83d\udd25\u2026\u2026\u2026..[ ALBUM : LOVE VS LOYALTY - DROPPING SOON ! ]\u2026\u2026\u2026\ud83d\udd25\ud83d\udd25\ud83d\udd25 - ** ALL SONGS & BEATS, PRODUCED & RAP BY ( SEAN BRYANT ) - https://t.co/LduwG5WmM7 - 2/23/2026 - ( KWR 4 LIFE ) - A.A.O & 444 ! https://t.co/NDmkURbjgv",
- "url": "https://x.com/teammusic2046/status/2026040965329191061",
- "author_handle": "teammusic2046",
- "date": "2026-02-23",
- "date_confidence": "high",
- "engagement": null,
- "relevance": 0.7,
- "why_relevant": "",
- "subs": {
- "relevance": 70,
- "recency": 93,
- "engagement": 35
- },
- "score": 62
- },
- {
- "id": "X11",
- "text": "@PopBase Finally, @bts_bighit BTS will come back after completing their military service from 2022 to 2025.\nBTS served in the military to protect their country, South Korea from North Korea's nuclear weapons attack.\n BTS can sing songs and rap on March 20, 2026, KST.\n#BTS_ARIRANG https://t.co/46n1j6eENU",
- "url": "https://x.com/IoWeul/status/2026233090683863471",
- "author_handle": "IoWeul",
- "date": "2026-02-24",
- "date_confidence": "high",
- "engagement": {
- "likes": 2
- },
- "relevance": 0.7,
- "why_relevant": "",
- "subs": {
- "relevance": 70,
- "recency": 96,
- "engagement": 13
- },
- "score": 59
- },
- {
- "id": "X8",
- "text": "Lil Uzi Vert (born July '95, so 30 now) isn't struggling at all. Fresh off \"What You Saying\" hitting #1 on Hot Rap Songs in Jan 2026, top 15 Hot 100. New album rumored for surprise drop soon, Roc Nation deal, still packing stadium energy and proving Ebro's 2016 doubts wrong\u2014he's still here, thriving.",
- "url": "https://x.com/grok/status/2026498538075554040",
- "author_handle": "grok",
- "date": "2026-02-25",
- "date_confidence": "high",
- "engagement": {
- "likes": 1,
- "replies": 1
- },
- "relevance": 0.7,
- "why_relevant": "",
- "subs": {
- "relevance": 70,
- "recency": 100,
- "engagement": 6
- },
- "score": 58
- },
- {
- "id": "X4",
- "text": "My musical taste / eras every year\n\n2020: J-POP & K-POP (mostly BLACKPINK)\n2021: Sped-Up Songs\n2022: EDM\n2023: Swiftie (Pop) & Lofi\n2024: Pop, Ambient\n2025: Alternative, OPM, Rock, Rap, Hip-Hop (add)\n2026: K-POP (add)",
- "url": "https://x.com/AlterEgoPopList/status/2026641456434393579",
- "author_handle": "AlterEgoPopList",
- "date": "2026-02-25",
- "date_confidence": "high",
- "engagement": {
- "likes": 1,
- "replies": 1
- },
- "relevance": 0.7,
- "why_relevant": "",
- "subs": {
- "relevance": 70,
- "recency": 100,
- "engagement": 6
- },
- "score": 58
- },
- {
- "id": "X3",
- "text": "@_CharlesPreston Can\u2019t believe I\u2019m seeing \u201cthey say it in rap songs!\u201d In 2026\n\nThe same reductive, ancient, debunked arguments ad infinitum",
- "url": "https://x.com/WordsFromBlerds/status/2026647382083903688",
- "author_handle": "WordsFromBlerds",
- "date": "2026-02-25",
- "date_confidence": "high",
- "engagement": {
- "likes": 1
- },
- "relevance": 0.7,
- "why_relevant": "",
- "subs": {
- "relevance": 70,
- "recency": 100,
- "engagement": 0
- },
- "score": 56
- },
- {
- "id": "X1",
- "text": "Su La Musica che gira intorno... \"Magica Favola\" \u00e8 il brano con cui Arisa \u00e8 in gara al 76\u00b0 Festival di Sanremo\nhttps://t.co/Nxzkb005lP\n#music #genre #song #songs #envywear #PleaseForgiveMe #melody #hiphop #rnb #pop #love #rap #dubstep #instagood https://t.co/uziy4MjeeB",
- "url": "https://x.com/Ferginangi/status/2026705227366256880",
- "author_handle": "Ferginangi",
- "date": "2026-02-25",
- "date_confidence": "high",
- "engagement": {
- "likes": 1
- },
- "relevance": 0.7,
- "why_relevant": "",
- "subs": {
- "relevance": 70,
- "recency": 100,
- "engagement": 0
- },
- "score": 56
- },
- {
- "id": "X10",
- "text": "@blackiiingout \ud83d\udca9 is funny from 2025 to 2026. BiiCH is SCARED to drop that #TRASH he calls \"rap songs\" \ud83e\udd74\ud83d\ude29",
- "url": "https://x.com/fo_sho52268/status/2026335353364201566",
- "author_handle": "fo_sho52268",
- "date": "2026-02-24",
- "date_confidence": "high",
- "engagement": {
- "likes": 1
- },
- "relevance": 0.7,
- "why_relevant": "",
- "subs": {
- "relevance": 70,
- "recency": 96,
- "engagement": 0
- },
- "score": 55
- }
- ],
- "web": [],
- "youtube": [
- {
- "id": "d1melQQVp6s",
- "title": "New Rap Songs 2026 Mix February | Trap Tape #127 | New Hip Hop 2026 Mixtape | DJ Noize",
- "url": "https://www.youtube.com/watch?v=d1melQQVp6s",
- "channel_name": "DJ Noize",
- "date": "2026-02-14",
- "date_confidence": "high",
- "engagement": {
- "num_comments": 45,
- "likes": 761,
- "views": 42713
- },
- "transcript_snippet": "[music] This This is a DJ noise mixtape present. This is a DJ noise mixtape [music] presentation. DJ [music] >> Who the [ __ ] is you? Who the [ __ ] is you? Who the [ __ ] >> Yeah, you know who put the city on. Came [music] back through gold they show. I'm really on. Your [ __ ] died trying. I had 50 on. [ __ ] is [music] ringing out my phone like is you really on? I'll stay out the way since I [singing] seen a million on 5% tension plus I got this hoodie on. I had to hubble up. Stop [music] moving with that jury on. You hear that girl told me [ __ ] get deep ass. Get it on. I'm a sidewalk [ __ ] I'mma stay there. I leave the [music] streets for the ones that want to play. I leveled up so shy want a day chair. Left my kids [music] in a mouth like a dayare. I'm the Carolina now Shakespeare. All that cap you let them [ __ ] rap is over. The real is back. You [music] feel the way you got to cope with it. The veil put a [ __ ] six feet like that was hit. Okay, these [ __ ] ain't talking about [ __ ] No [music] diamonds on my neck. No rolly on my wrist. Two six I learned a little bit. I learned the hardest [music] [ __ ] You can always get kicked. I can jungle. I done [ __ ] around and [singing] got rich. See dollars in my pocket. [ __ ] I'm not a lick. [music] I'd rather that than got to [singing] walk around with sticks cuz I like the hardest [ __ ] He can always get Who the [ __ ] is you? Who the [ __ ] [music] is you? Who the [ __ ] is you? Who the [ __ ] is you? Out the way. It's a real [ __ ] coming through. Out the way. [music] It's a real [ __ ] coming through. Who the [ __ ] is you? Who the [ __ ] is you? Who the [ __ ] is you? Who the [ __ ] is you out [music] the way? It's a real [ __ ] coming through. Out the way. It's a real [ __ ] [music] >> [music] >> Running to the check flag won't pull over till I'm dead right no [music] ain't going to get no [ __ ] riding with me in the head know I'm [ __ ] no big [ __ ] popping in the little [ __ ] dropping bags it's a lot to you to me [ __ ] 40 in my lap. Uh-huh. Real dope boys in [music] a trap. Uh-huh. Big y like a big shot. [ __ ] on my dick. [singing] Make me room for your big got to let them know. I don't wrestle. I don't live block. [music] Solid tails. All I do is big rock. I came from Dilling Rock all the way to hit on the grown. [ __ ] ain't when I'm on. She can kick rock. Let her know. I'm blowing money. Let [music] them know I'm only I know how we get bread. I ain't never [music] scared on...",
- "relevance": 1.0,
- "why_relevant": "YouTube: New Rap Songs 2026 Mix February | Trap Tape #127 | New Hip H",
- "subs": {
- "relevance": 100,
- "recency": 63,
- "engagement": 37
- },
- "score": 71
- },
- {
- "id": "-KQpPySP93I",
- "title": "New Hip Hop R&B Songs 2026 Mix February | Hot Right Now #153 | New Rap 2026 Playlist | DJ Noize",
- "url": "https://www.youtube.com/watch?v=-KQpPySP93I",
- "channel_name": "DJ Noize",
- "date": "2026-02-21",
- "date_confidence": "high",
- "engagement": {
- "num_comments": 18,
- "likes": 304,
- "views": 12955
- },
- "transcript_snippet": "This This is a DJ noise tape present. This is a DJ noise mix. [music] I got Nikki on me. How we treat the like a bucket for rubbing. You [music] ain't no fella from my junction. You ain't pluck you clapping smirk. Young enough to something. [music] Word mother body slam some [ __ ] for honey. Keep a stitch. People pick me up. Ain't saying a word or nothing. Grab a [music] load. Put on little bro. We had him serving prostitut. Shout out little [singing] b. She gave me 30 something on the beat. Phone cracker seal and I can't go to sleep. Promoters tweaking. [music] Pull up to the venue over 40 deep. You know the tweak. Ranking through the roof then you ain't supposed to leak. It's one up top. Manator is keep it close to me. Ain't that how it's supposed to be? [music] She going to get your me. I'm get you smoke free. Kamicazi [music] playing around [singing] this [ __ ] Something wrong with me. I'm letting diss the dead unless they skeleton belong the team type of [ __ ] won't let it get in between the city and the police where [ __ ] get they do for me they [music] all do the show leaning spots I do like fully call back off you know it's strong we [music] man 15 I had 15 [singing] in 2010 then I be [music] the man right now [ __ ] change big guns bank shame I'm change [music] I'm going make that I only burn. That's how my body [music] can't be pull [singing] right to the sun. [music] Ain't going to get no [ __ ] riding with me in the head west. I'm out of mind [music] [ __ ] popping in little [ __ ] dropping bags. It's a lot to you to me a little bit sh [music] like a big shot. [ __ ] on my dick. Make me room for your big. Got to let them know I don't turn wrestle. I don't live block solid tails. [music] All I do is big rocks. I came from Dilling Rock all the way to hit on the grown. [ __ ] ain't when I'm on. She can kick rock. Let her know [music] my foot up on the gas. I'm blowing money. You never know. I'm only [music] beat down. We getting bread. I ain't never scared big flex [music] on a [ __ ] head what I'm standing on big chick we [singing] did that pushing weed back better give me respect and some more Atlanta [music] show I was selling ground right I don't care [music] ling to me what you need a camera better know they call it [music] we just call it they say [singing] you live down by your decision [music] well I'mma push you to the limit never mind the consequences A [ __ ] I'm a hand better let them know on another [music] level my ass I put together hit the pedal do 200 everywhere I go [music] just to show...",
- "relevance": 1.0,
- "why_relevant": "YouTube: New Hip Hop R&B Songs 2026 Mix February | Hot Right Now #153",
- "subs": {
- "relevance": 100,
- "recency": 86,
- "engagement": 0
- },
- "score": 66
- },
- {
- "id": "ZUExyc50ZVU",
- "title": "Lit Hip Hop Mix 2026\ud83d\udd25\ud83d\udd25\ud83d\udd25Tyga, Quavo, Iggy Azalea, Wiz Khalifa, Juicy J, 50 Cent",
- "url": "https://www.youtube.com/watch?v=ZUExyc50ZVU",
- "channel_name": "West Coast Finest",
- "date": "2026-01-29",
- "date_confidence": "high",
- "engagement": {
- "num_comments": 60,
- "likes": 4229,
- "views": 407260
- },
- "transcript_snippet": "Yeah. >> Yeah. [music] Late nights, cold life, same story. >> City shine different. You walking with glory, right? >> Still drip falling when I step outside. Yeah. [music] >> Black car park with a do butterfly. Too [music] much heat when we walk inside. Midnight. So you can kill this V. Gold chain swinging when I lean on the night. Shut it. Look twice when I glow on the light. Yeah. I've been running through the city with a cool grin. [music] Ain't nobody in my lane when the night begin white clean look like but a good kind win. It's all too smooth but I back that talk. Money so clean make a bro [music] boy walk in my cup but my soul don't shock. I've been cold for years. I ain't you mock. She said boy you too for the life you live. I peace ain't weak. It's [music and singing] the power I give. I don't flex too loud. I don't need that when your whole damn already scream. Yeah. Late night in the rooftop smoke girls but they all provoke. I don't chase no dream. I just take that goal. I don't chase no soul. They just lose control. Cameras on flash when I step on deck. Fit so clean. Make the whole [music] place check. Talk about but I still respect. Still too smooth. I don't never need a flex. Yeah. West coast chill with a heart [music and singing] too cold. Stories too deep that ain't never been told. I'm a tight make a new king. Pretty boy look but a mind. Yeah. Slide through smooth. Let a breeze and a ghost. All [music and singing] them in my but I flex coast to coast. Pretty boy with a mind stay close to the pain to the grind to the things I boast. Wo. F drag with a midnight gleam. [music and singing] Squad clean like a runway team. All black drip with a icon of sheen. Feel like a king every time I hit a scene. Purple in a club but a soul still clear. Vision too [singing] sharp when I stare in the mirror. [music] Fashion too loud but the vibe too sick. Every step I take got a world right here. Yeah. BBS ice made a nice guy blush. Shorty walk in the whole club hush. Talk too [music] cute but she move too lush. Slow down baby [singing] you rush too much. [music] Boy magic with a westside twist. Mind too slick when I talk like this. I don't miss not a bar not a kiss. I ain't too heavy. I exist like bliss. Woah. Let it on my back with a chrome on shine. Let the world go blind. I'm still define. Every day I'm tren with a style so divine still drip [music] when I step outside. Yeah. Black car park with the dogs butterfly. Too much heat when we walk inside. Midnight. [music] You can't kill...",
- "relevance": 0.3333333333333333,
- "why_relevant": "YouTube: Lit Hip Hop Mix 2026\ud83d\udd25\ud83d\udd25\ud83d\udd25Tyga, Quavo, Iggy Azalea, Wiz Khalifa",
- "subs": {
- "relevance": 33,
- "recency": 9,
- "engagement": 100
- },
- "score": 47
- }
- ],
- "hackernews": [],
- "best_practices": [],
- "prompt_pack": [],
- "context_snippet_md": "# Context: best rap songs 2026 (Last 30 Days)\n\n*Generated: 2026-02-25 | Sources: both*\n\n## Key Sources\n\n- [X] Talking about meaningful rap songs...\n\nWORLD BEST ...\n- [X] @PopCrave Partition still goes harder than most 20...\n- [X] @Unfath0m4ble @robinthisbish @lilceaserthabp1 @Rap...\n- [X] @lil_loonie1 @oldmanebro No, Lil Uzi Vert isn't st...\n- [X] Su La Musica che gira intorno... \"Qui con me\" \u00e8 il...\n- [Reddit] Reddit Ranked: Hip Hop \u201826\n- [Reddit] Daily Discussion Thread 02/24/2026\n\n## Summary\n\n*See full report for best practices, prompt pack, and detailed sources.*\n"
-}
diff --git a/docs/comparison-results/json/cross-5-react-svelte.json b/docs/comparison-results/json/cross-5-react-svelte.json
deleted file mode 100644
index b0e8a02..0000000
--- a/docs/comparison-results/json/cross-5-react-svelte.json
+++ /dev/null
@@ -1,525 +0,0 @@
-{
- "topic": "React vs Svelte 2026",
- "range": {
- "from": "2026-01-26",
- "to": "2026-02-25"
- },
- "generated_at": "2026-02-25T20:39:35.773560+00:00",
- "mode": "both",
- "openai_model_used": "gpt-5.2",
- "xai_model_used": "grok-4-1-fast",
- "reddit": [
- {
- "id": "R2",
- "title": "What do you guys think about comparison between React vs Svelte?",
- "url": "https://www.reddit.com/r/react/comments/1r1fku5/what_do_you_guys_think_about_comparison_between/",
- "subreddit": "eact",
- "date": "2026-02-10",
- "date_confidence": "high",
- "engagement": null,
- "top_comments": [],
- "comment_insights": [],
- "relevance": 0.95,
- "why_relevant": "Explicit React vs Svelte comparison thread focusing on hooks/useEffect vs Svelte runes mental model.",
- "subs": {
- "relevance": 95,
- "recency": 50,
- "engagement": 50
- },
- "score": 67
- },
- {
- "id": "R1",
- "title": "Migrated our startup from React to Svelte 5 - Performance gains and lessons learned",
- "url": "https://www.reddit.com/r/webdev/comments/1qu4dek/migrated_our_startup_from_react_to_svelte_5/",
- "subreddit": "webdev",
- "date": "2026-02-02",
- "date_confidence": "high",
- "engagement": null,
- "top_comments": [],
- "comment_insights": [],
- "relevance": 0.97,
- "why_relevant": "Direct React -> Svelte 5 migration discussion with practical comparison (DX, performance, ecosystem tradeoffs).",
- "subs": {
- "relevance": 97,
- "recency": 23,
- "engagement": 50
- },
- "score": 61
- },
- {
- "id": "R6",
- "title": "I am building a content-heavy, bilingual government portal with listings, profiles, documents, filters, and forms. Came from .NET & React. Wondering if svelte might be a good option for a project like this?",
- "url": "https://www.reddit.com/r/sveltejs/comments/1qtua45/i_am_building_a_contentheavy_bilingual_government/",
- "subreddit": "sveltejs",
- "date": "2026-02-02",
- "date_confidence": "high",
- "engagement": null,
- "top_comments": [],
- "comment_insights": [],
- "relevance": 0.86,
- "why_relevant": "Evaluates Svelte vs React suitability for a real project with constraints (accessibility, SEO, forms).",
- "subs": {
- "relevance": 86,
- "recency": 23,
- "engagement": 50
- },
- "score": 56
- },
- {
- "id": "R4",
- "title": "Is Svelte easier than React?",
- "url": "https://www.reddit.com/r/sveltejs/comments/1qpdzx5/is_svelte_easier_than_react/",
- "subreddit": "sveltejs",
- "date": "2026-01-28",
- "date_confidence": "high",
- "engagement": null,
- "top_comments": [],
- "comment_insights": [],
- "relevance": 0.9,
- "why_relevant": "Direct comparison thread about perceived simplicity of Svelte vs React (recent 2026 discussion).",
- "subs": {
- "relevance": 90,
- "recency": 6,
- "engagement": 50
- },
- "score": 54
- },
- {
- "id": "R8",
- "title": "When should i start learning Svelte ?",
- "url": "https://www.reddit.com/r/sveltejs/comments/1qr8xsm/when_should_i_start_learning_svelte/",
- "subreddit": "sveltejs",
- "date": "2026-01-31",
- "date_confidence": "high",
- "engagement": null,
- "top_comments": [],
- "comment_insights": [],
- "relevance": 0.8,
- "why_relevant": "React vs Svelte learning/adoption discussion, including arguments about replacing React for performance issues.",
- "subs": {
- "relevance": 80,
- "recency": 16,
- "engagement": 50
- },
- "score": 52
- },
- {
- "id": "R9",
- "title": "How is going svelte?",
- "url": "https://www.reddit.com/r/sveltejs/comments/1qrdhba/how_is_going_svelte/",
- "subreddit": "sveltejs",
- "date": "2026-01-30",
- "date_confidence": "high",
- "engagement": null,
- "top_comments": [],
- "comment_insights": [],
- "relevance": 0.75,
- "why_relevant": "General state-of-Svelte discussion with explicit mentions comparing to React and job-market pragmatism.",
- "subs": {
- "relevance": 75,
- "recency": 13,
- "engagement": 50
- },
- "score": 49
- }
- ],
- "x": [
- {
- "id": "X4",
- "text": "TypeScript is the baseline in 2026. But most forms still fight type drift between API, schema & UI. \n\n@formisch_dev + @valibot = one source of truth, zero boilerplate \u26a1\ufe0f\n\nconst form = useForm({ schema: YourSchema });\n\nFull types everywhere. Works in React, SolidJS, Vue, Svelte\u2026 \n\nYour biggest type-safety win this year? \ud83d\udc47",
- "url": "https://x.com/FabianHiller/status/2022345388603080834",
- "author_handle": "FabianHiller",
- "date": "2026-02-13",
- "date_confidence": "high",
- "engagement": {
- "likes": 61,
- "reposts": 2,
- "replies": 7
- },
- "relevance": 0.7,
- "why_relevant": "",
- "subs": {
- "relevance": 70,
- "recency": 60,
- "engagement": 100
- },
- "score": 76
- },
- {
- "id": "X3",
- "text": "Popular JavaScript Frameworks / Frontend Libraries (2025\u20132026 trends)\n\n - React: \ud83c\uddfa\ud83c\uddf8 United States\n - Vue: \ud83c\udde8\ud83c\uddf3 China (created by Evan You)\n - Angular: \ud83c\uddfa\ud83c\uddf8 United States\n - Svelte: \ud83c\uddfa\ud83c\uddf8 United States (New Zealand creator)\n - Next.js: \ud83c\uddfa\ud83c\uddf8 United States\n - Nuxt: \ud83c\uddeb\ud83c\uddf7 France\n - Astro: \ud83c\udde8\ud83c\udde6 United States / Canada\n - Solid: \ud83c\uddfa\ud83c\uddf8 United States",
- "url": "https://x.com/ConsciousRide/status/2023050160520278501",
- "author_handle": "ConsciousRide",
- "date": "2026-02-15",
- "date_confidence": "high",
- "engagement": {
- "likes": 6
- },
- "relevance": 0.7,
- "why_relevant": "",
- "subs": {
- "relevance": 70,
- "recency": 66,
- "engagement": 33
- },
- "score": 57
- },
- {
- "id": "X11",
- "text": "Requirements for a fresher in 2026:\n\nPython, JavaScript, C, C++, Java, HTML, CSS, TypeScript, React, Next.js, Vue, Angular, Svelte, Node.js, Express, Django, Flask, FastAPI, Spring Boot, PHP, Laravel, Ruby, Ruby on Rails, Go, Rust, Kotlin, Swift, C#, .NET, SQL, MySQL, PostgreSQL, MongoDB, Redis, Firebase, Supabase, Prisma, Docker, Kubernetes, Nginx, Linux, Git, GitHub, Gitflow, VS Code, IntelliJ, Cloud Computing (AWS, Azure, GCP), DevOps, CI/CD, Jest, PyTest, Selenium, REST APIs, GraphQL, WebSoc",
- "url": "https://x.com/singhprateek_25/status/2020094806735700154",
- "author_handle": "singhprateek_25",
- "date": "2026-02-07",
- "date_confidence": "high",
- "engagement": {
- "likes": 6,
- "reposts": 1
- },
- "relevance": 0.7,
- "why_relevant": "",
- "subs": {
- "relevance": 70,
- "recency": 40,
- "engagement": 39
- },
- "score": 53
- },
- {
- "id": "X1",
- "text": "Ripple: The Good Parts of React, Svelte, and Solid\n\nFrameworks rise and fall \u2014 and in 2026 we\u2019re deep in late-stage React. What\u2019s next? Join @erikras as he introduces Ripple, a TypeScript-first UI framework blending the best of React and Svelte with fine-grained reactivity and serious speed.\n\nSee the future of UI development live at CityJS London.\nGet your ticket now: https://t.co/wGMJJIELXm",
- "url": "https://x.com/cityjsconf/status/2024908272646398229",
- "author_handle": "cityjsconf",
- "date": "2026-02-20",
- "date_confidence": "high",
- "engagement": {
- "reposts": 1
- },
- "relevance": 0.7,
- "why_relevant": "",
- "subs": {
- "relevance": 70,
- "recency": 83,
- "engagement": 0
- },
- "score": 52
- },
- {
- "id": "X2",
- "text": "Prediction: 2026 marks the death of the JS frameworks. React, Svelte, Astro etc... only exist because humans need organized code to stay sane. \n\nOnce we stop reading the source code, we stop needing the structure. \n\nIn an AI-first world, JS frameworks are just overhead.",
- "url": "https://x.com/dennydotio/status/2024195986890109320",
- "author_handle": "dennydotio",
- "date": "2026-02-18",
- "date_confidence": "high",
- "engagement": {
- "likes": 1
- },
- "relevance": 0.7,
- "why_relevant": "",
- "subs": {
- "relevance": 70,
- "recency": 76,
- "engagement": 7
- },
- "score": 52
- },
- {
- "id": "X5",
- "text": "Web developers, what stack are you using in 2026?\nReact? Vue? Svelte? Vanilla? Curious what\u2019s actually winning.",
- "url": "https://x.com/AzamCodes/status/2022298287236383010",
- "author_handle": "AzamCodes",
- "date": "2026-02-13",
- "date_confidence": "high",
- "engagement": {
- "likes": 2,
- "replies": 1
- },
- "relevance": 0.7,
- "why_relevant": "",
- "subs": {
- "relevance": 70,
- "recency": 60,
- "engagement": 19
- },
- "score": 52
- },
- {
- "id": "X8",
- "text": "[2026/02/11 18:00] \u30c8\u30ec\u30f3\u30c91\u4f4d\n\u3010WebF\u3011React/Vue/Svelte\u304c\u305d\u306e\u307e\u307e\u30cd\u30a4\u30c6\u30a3\u30d6\u30a2\u30d7\u30ea\u306b\u306a\u308b\u3088 by rana_kualu https://t.co/GiAnTHpOBB",
- "url": "https://x.com/QiitaTrend/status/2021509592489025785",
- "author_handle": "QiitaTrend",
- "date": "2026-02-11",
- "date_confidence": "high",
- "engagement": null,
- "relevance": 0.7,
- "why_relevant": "",
- "subs": {
- "relevance": 70,
- "recency": 53,
- "engagement": 35
- },
- "score": 52
- },
- {
- "id": "X10",
- "text": "@babakfpk In 2026, picking React for a new project only really makes sense if you value ecosystem maturity, are following existing team habits, or need React-specific libraries. Same for Vue. Otherwise, use Solid or Svelte.",
- "url": "https://x.com/nicobaogim/status/2020689628282667407",
- "author_handle": "nicobaogim",
- "date": "2026-02-09",
- "date_confidence": "high",
- "engagement": null,
- "relevance": 0.7,
- "why_relevant": "",
- "subs": {
- "relevance": 70,
- "recency": 46,
- "engagement": 35
- },
- "score": 50
- },
- {
- "id": "X12",
- "text": "2026 framework discourse:\n\nDev 1: \"React is bloated, use Svelte\"\nDev 2: \"Svelte has no ecosystem, use Vue\"\nDev 3: \"Vue is dying, use Solid\"\nDev 4: \"Just use React\"\n\nMeanwhile React: *still powering 80% of the web*\n\nThe discourse never ends. \ud83d\udc80",
- "url": "https://x.com/Chubbi_Stephen/status/2020087839648800853",
- "author_handle": "Chubbi_Stephen",
- "date": "2026-02-07",
- "date_confidence": "high",
- "engagement": null,
- "relevance": 0.7,
- "why_relevant": "",
- "subs": {
- "relevance": 70,
- "recency": 40,
- "engagement": 35
- },
- "score": 49
- }
- ],
- "web": [],
- "youtube": [
- {
- "id": "MnpuK0MK4yo",
- "title": "React VS Svelte...10 Examples",
- "url": "https://www.youtube.com/watch?v=MnpuK0MK4yo",
- "channel_name": "Beyond Fireship",
- "date": "2023-06-30",
- "date_confidence": "high",
- "engagement": {
- "num_comments": 1100,
- "likes": 25490,
- "views": 697142
- },
- "transcript_snippet": "any reasonable developer in today's world would learn react because it's the status quo and that's where the jobs are but life is a lot more fun when you become unreasonable and go against the status quo to push web development forward for future Generations in today's video we'll compare react and spelled side by side by looking at common patterns and design choices by the framework creators I'm not here to tell you that one is better than the other because that's what the comment section below this video is for the only way to truly find out which one is best is to build something with both of them and decide for yourself I just released a brand new full spell kick course yesterday and also have a full next js13 course for fireship pro members going through each one of these courses will give you a really good idea of which framework is best for you first up we need to talk about rendering both of these Frameworks do the same thing they help developers build reactive uis with JavaScript by organizing code into reusable components an end user would never be able to tell the difference between the two but when it comes to the developer experience there is a world of difference react.js uses a runtime called the virtual Dom it keeps track of data changes in the application in order to render them in the act actual Dom in the browser the drawback is that this runtime requires some initial JavaScript and in Frameworks like nextjs your Baseline is around 70 kilobytes just to render a hello world as felt on the other hand takes an entirely different approach using a compiler to eliminate the need for a runtime it takes your svelt code and converts it into vanilla JavaScript which results in a far smaller hello world using a compiler though is kind of like cheating react.js is just JavaScript whereas spill can take non-javascript code to allow developers to do things more efficiently than they could otherwise but some JavaScript fundamentalists might consider this black magic what's ironic though is that vanilla JavaScript libraries tend to be much easier to work with in spell when compared to react however react does have a massive ecosystem of dedicated libraries to help you get things done now let's look at an actual code example of component State here we have a basic counter app in react we use functions to create components and then add reactive state to them with the use State hook that returns a reactive value and a Setter function to update the state pretty simple but let's see how it compares to svelt here on the right side in spelled you have only one component file and Define the logic within the script tags to create reactive State all you do is create a variable with the let keyword from there we can define a function on the click event...",
- "relevance": 0.75,
- "why_relevant": "YouTube: React VS Svelte...10 Examples",
- "subs": {
- "relevance": 75,
- "recency": 0,
- "engagement": 100
- },
- "score": 63
- },
- {
- "id": "qwDp5pZA_TA",
- "title": "The Front-end Frameworks Guide 2026: React vs Angular vs Vue vs Svelt vs Solid vs Astro",
- "url": "https://www.youtube.com/watch?v=qwDp5pZA_TA",
- "channel_name": "Code Hub",
- "date": "2026-02-08",
- "date_confidence": "high",
- "engagement": {
- "num_comments": 4,
- "likes": 31,
- "views": 646
- },
- "transcript_snippet": "",
- "relevance": 0.75,
- "why_relevant": "YouTube: The Front-end Frameworks Guide 2026: React vs Angular vs Vue",
- "subs": {
- "relevance": 75,
- "recency": 43,
- "engagement": 17
- },
- "score": 49
- },
- {
- "id": "_vuVy21l2bU",
- "title": "React vs Svelte: The Brutal Honest Comparison EVER",
- "url": "https://www.youtube.com/watch?v=_vuVy21l2bU",
- "channel_name": "Code Hub",
- "date": "2025-10-30",
- "date_confidence": "high",
- "engagement": {
- "num_comments": 27,
- "likes": 204,
- "views": 6454
- },
- "transcript_snippet": "",
- "relevance": 0.75,
- "why_relevant": "YouTube: React vs Svelte: The Brutal Honest Comparison EVER",
- "subs": {
- "relevance": 75,
- "recency": 0,
- "engagement": 42
- },
- "score": 46
- },
- {
- "id": "41HXdxGekZ0",
- "title": "Vue vs React vs Svelte (2026) - Which One Is BEST?",
- "url": "https://www.youtube.com/watch?v=41HXdxGekZ0",
- "channel_name": "Paperclick",
- "date": "2025-10-04",
- "date_confidence": "high",
- "engagement": {
- "num_comments": 0,
- "likes": 3,
- "views": 287
- },
- "transcript_snippet": "",
- "relevance": 1.0,
- "why_relevant": "YouTube: Vue vs React vs Svelte (2026) - Which One Is BEST?",
- "subs": {
- "relevance": 100,
- "recency": 0,
- "engagement": 0
- },
- "score": 45
- },
- {
- "id": "1BCsdaeYv0A",
- "title": "Svelte vs React in 2025 - Make the RIGHT Choice (Difference Explained)",
- "url": "https://www.youtube.com/watch?v=1BCsdaeYv0A",
- "channel_name": "Daniel | Tech & Data",
- "date": "2023-11-26",
- "date_confidence": "high",
- "engagement": {
- "num_comments": 34,
- "likes": 0,
- "views": 33905
- },
- "transcript_snippet": "",
- "relevance": 0.75,
- "why_relevant": "YouTube: Svelte vs React in 2025 - Make the RIGHT Choice (Difference ",
- "subs": {
- "relevance": 75,
- "recency": 0,
- "engagement": 30
- },
- "score": 42
- },
- {
- "id": "yl0YWA2K2B0",
- "title": "React wants to win you back\u2026",
- "url": "https://www.youtube.com/watch?v=yl0YWA2K2B0",
- "channel_name": "Fireship",
- "date": "2025-10-17",
- "date_confidence": "high",
- "engagement": {
- "num_comments": 1000,
- "likes": 22894,
- "views": 699597
- },
- "transcript_snippet": "Last week, I got to participate in my favorite activity of the year. No, it wasn't watching all the overleveraged crypto bros learn what a margin call is. It was hate watching React comp as an anti-react influencer. Every year I crawl into my spelt Snuggy and I watch the React team try to convince everyone that their new APIs fix all of their old APIs. And though it hurts me to say it, this year I think they may have actually done it. I know this is an AI channel now, but in today's video, we'll revisit the worst parts of React to see if the announcements they made at React Comp will actually make a difference. It is October 17th, 2025, and you're watching the code report. The greatest trick the devil ever pulled was making referential stability a prerequisite to writing React apps that don't blow up. Just last month, Cloudflare had a massive outage because their dashboard kept making unnecessary calls to one of their own APIs, which led to them basically themselves. If you're a React dev, you know this is the primary use case for React's use effect hook. And it's exactly what caused this embarrassing bug for Cloudflare. But it's hard to blame React for this. The whole situation could have been avoided if only the poor debit Cloudflare would have read the docs on use effect. Then this post which shows you when to use it. Then this post which shows you when to not use it. Then this post which shows you what it actually does. Then this post which shows you how to use it correctly. And now with React 19.2. this post which shows you how to work around it with React's new use effect event hook. The way it works is it creates a function that you can use inside of use effect without needing to declare it in the dependency array. This is nice for situations like this where you need to use a value inside of use effect but don't want the effect to rerun when it changes. And the other big API addition was the new activity component which you can think of like a smarter display none that React itself controls. It lets you show and hide certain components, but unlike display none, React is able to maintain the state of those components across visibility and also deprioritize the work it does for hidden components. And if you've been drinking your lead lace protein and want to live on the edge, you can install React's Canary version where you'll get the new view transition component for animating transitions between different UIs. And you'll be able to pass a ref to a fragment, which makes working with platform APIs a little easier. But the most impactful announcements of the conference may have not been related to the React API. The first React compiler finally hit 1.0. If you've been in any React codebase lately, you've...",
- "relevance": 0.25,
- "why_relevant": "YouTube: React wants to win you back\u2026",
- "subs": {
- "relevance": 25,
- "recency": 0,
- "engagement": 99
- },
- "score": 40
- },
- {
- "id": "aYyZUDFZTrM",
- "title": "JavaScript framework reinvents itself\u2026 Did \"runes\" just ruin Svelte?",
- "url": "https://www.youtube.com/watch?v=aYyZUDFZTrM",
- "channel_name": "Fireship",
- "date": "2024-10-24",
- "date_confidence": "high",
- "engagement": {
- "num_comments": 1200,
- "likes": 24456,
- "views": 547836
- },
- "transcript_snippet": "about a year ago I made a tweet that said the dollar sign in spelt is the most powerful abstraction in modern front-end web development usually I'm not wrong about anything ever but this tweet ended up aging like a fine milk because just Weeks Later spelt announced a brand new feature called runes that significantly changed the developer experience by eliminating the dollar sign Syntax for reactive data and then just a few days ago this feature became official with the release of spelt 5 as an avid user of spelt myself I was pretty terrified of this feature at first glance I was one of the few survivors of the angularjs to angular 2 migration I shot myself in the foot during the react hooks Revolution and I lost some good friends during the view options to composition transition and I just can't do this anymore man so maybe I'll just go back to Wordpress uh never mind spelt it is in today's video we'll find out what the hell a rune is and whether or not they're going to ruin spelt it is October 24th 2024 and you're watching the code report most people really liked runes when they were first announced but not everybody I was in denial at first then I got angry then I bargained about the tradeoffs then came depression and finally I've come to accept and really enjoy runes the big question is why would the most L JavaScript framework make a risky departure from the features people love about it like reactive state with let and reactive side effects with the dollar sign just look at how much nicer this code is compared to reactjs react is even copy and spelt with its own compiler to make this code not suck so much the spelt code is lean and concise but one could argue that it's almost too clever let looks exact like regular JavaScript but because spelt uses a compiler it doesn't actually work like regular JavaScript under the hood the compiler does some magic to automatically update the UI whenever this value changes another issue is that this magic only works in a spelt file and that means it's difficult to share reactive logic throughout your application and spelt created an entirely different API called stores to address that issue however runes fix both of these limitations by a making your code more explicit and B they create a universal reactivity system that can be used not only in spel components but also in plain JavaScript or typescript files and this dramatically simplifies the overall framework like once you learn these four runes you'll know about 90% of what you need to know for the framework because everything else works like normal JavaScript and HTML let's go ahead and learn the four major runes now which you can think of as compiler macros that tell spelt to do something special that doesn't behave like regular JavaScript or HTML a...",
- "relevance": 0.25,
- "why_relevant": "YouTube: JavaScript framework reinvents itself\u2026 Did \"runes\" just ruin",
- "subs": {
- "relevance": 25,
- "recency": 0,
- "engagement": 98
- },
- "score": 40
- },
- {
- "id": "fn_uSZW5psM",
- "title": "The Untold Story of Svelte",
- "url": "https://www.youtube.com/watch?v=fn_uSZW5psM",
- "channel_name": "CodeSource",
- "date": "2025-05-14",
- "date_confidence": "high",
- "engagement": {
- "num_comments": 144,
- "likes": 2120,
- "views": 57782
- },
- "transcript_snippet": "",
- "relevance": 0.25,
- "why_relevant": "YouTube: The Untold Story of Svelte",
- "subs": {
- "relevance": 25,
- "recency": 0,
- "engagement": 69
- },
- "score": 31
- },
- {
- "id": "IpJh0VEzMRo",
- "title": "I Was Wrong About Svelte...",
- "url": "https://www.youtube.com/watch?v=IpJh0VEzMRo",
- "channel_name": "Ben Davis",
- "date": "2025-10-12",
- "date_confidence": "high",
- "engagement": {
- "num_comments": 122,
- "likes": 1043,
- "views": 24635
- },
- "transcript_snippet": "",
- "relevance": 0.25,
- "why_relevant": "YouTube: I Was Wrong About Svelte...",
- "subs": {
- "relevance": 25,
- "recency": 0,
- "engagement": 61
- },
- "score": 29
- },
- {
- "id": "1cGtYEXGm8c",
- "title": "This is THE Framework You Should be Using in 2026",
- "url": "https://www.youtube.com/watch?v=1cGtYEXGm8c",
- "channel_name": "Ben Davis",
- "date": "2026-01-06",
- "date_confidence": "high",
- "engagement": {
- "num_comments": 142,
- "likes": 793,
- "views": 16899
- },
- "transcript_snippet": "",
- "relevance": 0.25,
- "why_relevant": "YouTube: This is THE Framework You Should be Using in 2026",
- "subs": {
- "relevance": 25,
- "recency": 0,
- "engagement": 57
- },
- "score": 28
- }
- ],
- "hackernews": [],
- "best_practices": [],
- "prompt_pack": [],
- "context_snippet_md": "# Context: React vs Svelte 2026 (Last 30 Days)\n\n*Generated: 2026-02-25 | Sources: both*\n\n## Key Sources\n\n- [X] TypeScript is the baseline in 2026. But most forms...\n- [Reddit] What do you guys think about comparison between React vs Svelte?\n- [Reddit] Migrated our startup from React to Svelte 5 - Performance gains and lessons learned\n- [X] Popular JavaScript Frameworks / Frontend Libraries...\n- [Reddit] I am building a content-heavy, bilingual government portal with listings, profiles, documents, filters, and forms. Came from .NET & React. Wondering if svelte might be a good option for a project like this?\n- [Reddit] Is Svelte easier than React?\n- [X] Requirements for a fresher in 2026:\n\nPython, JavaS...\n\n## Summary\n\n*See full report for best practices, prompt pack, and detailed sources.*\n"
-}
diff --git a/docs/comparison-results/json/diagnose-baseline.json b/docs/comparison-results/json/diagnose-baseline.json
deleted file mode 100644
index 7fc5bd0..0000000
--- a/docs/comparison-results/json/diagnose-baseline.json
+++ /dev/null
@@ -1,14 +0,0 @@
-{
- "openai": true,
- "xai": true,
- "x_source": "bird",
- "bird_installed": true,
- "bird_authenticated": true,
- "bird_username": "env AUTH_TOKEN",
- "youtube": true,
- "hackernews": true,
- "web_search_backend": null,
- "parallel_ai": false,
- "brave": false,
- "openrouter": false
-}
diff --git a/docs/comparison-results/json/hn-1-claude-code.json b/docs/comparison-results/json/hn-1-claude-code.json
deleted file mode 100644
index a3ace09..0000000
--- a/docs/comparison-results/json/hn-1-claude-code.json
+++ /dev/null
@@ -1,883 +0,0 @@
-{
- "topic": "Claude Code skills and MCP servers",
- "range": {
- "from": "2026-01-26",
- "to": "2026-02-25"
- },
- "generated_at": "2026-02-25T20:29:35.722482+00:00",
- "mode": "both",
- "openai_model_used": "gpt-5.2",
- "xai_model_used": "grok-4-1-fast",
- "reddit": [
- {
- "id": "R1",
- "title": "Claude code and mcp servers",
- "url": "https://www.reddit.com/r/ClaudeAI/comments/1ldm75a/claude_code_and_mcp_servers/",
- "subreddit": "ClaudeAI",
- "date": "2025-06-17",
- "date_confidence": "low",
- "engagement": null,
- "top_comments": [],
- "comment_insights": [],
- "relevance": 0.98,
- "why_relevant": "Directly about Claude Code failing to load/use MCP servers; troubleshooting discussion.",
- "subs": {
- "relevance": 0,
- "recency": 0,
- "engagement": 0
- },
- "score": 0
- },
- {
- "id": "R2",
- "title": "Claude Skills: is it a big deal?",
- "url": "https://www.reddit.com/r/ClaudeCode/comments/1odtykp/claude_skills_is_it_a_big_deal/",
- "subreddit": "ClaudeCode",
- "date": "2025-10-23",
- "date_confidence": "low",
- "engagement": null,
- "top_comments": [],
- "comment_insights": [],
- "relevance": 0.92,
- "why_relevant": "Focused on the Claude (Claude Code) \u201cSkills\u201d feature; compares to AGENTS.md/slash commands/MCPs.",
- "subs": {
- "relevance": 0,
- "recency": 0,
- "engagement": 0
- },
- "score": 0
- },
- {
- "id": "R3",
- "title": "I spent way too long cataloguing Claude Code tools. Here\u2019s everything I found (with actual links)",
- "url": "https://www.reddit.com/r/ClaudeAI/comments/1ofltdr/i_spent_way_too_long_cataloguing_claude_code/",
- "subreddit": "ClaudeAI",
- "date": "2025-10-25",
- "date_confidence": "low",
- "engagement": null,
- "top_comments": [],
- "comment_insights": [],
- "relevance": 0.9,
- "why_relevant": "Tooling ecosystem post; explicitly mentions installing many plugins and MCP servers for Claude Code.",
- "subs": {
- "relevance": 0,
- "recency": 0,
- "engagement": 0
- },
- "score": 0
- }
- ],
- "x": [
- {
- "id": "X5",
- "text": "@adamwathan Codex has been crushing it for me with implicit skill usage to the point I was shocked \n\nClaude Code I resorted to explicit mentions and I\u2019ve been pleasantly surprised by how well Codex has been calling out to skills on its own",
- "url": "https://x.com/zeeg/status/2026745680195367091",
- "author_handle": "zeeg",
- "date": "2026-02-25",
- "date_confidence": "high",
- "engagement": {
- "likes": 4,
- "reposts": 1,
- "replies": 2
- },
- "relevance": 0.7,
- "why_relevant": "",
- "subs": {
- "relevance": 70,
- "recency": 100,
- "engagement": 100
- },
- "score": 86
- },
- {
- "id": "X3",
- "text": "\ud83d\udea8 Anthropic just open-sourced the exact Skills library their own engineers use internally.\n\nStop building Claude workflows from scratch.\n\nThese are plug-and-play components that work across Claude Code, API, SDK, and VS Code copy once, deploy everywhere.\n\nWhat's inside:\n\n\u2192 Excel + PowerPoint generation out of the box\n\u2192 File handling and document workflows\n\u2192 MCP-ready subagent building blocks\n\u2192 Pre-built patterns for multi-step automation\n\u2192 Production templates you'd normally spend weeks writing\n",
- "url": "https://x.com/ihtesham2005/status/2026752089473314975",
- "author_handle": "ihtesham2005",
- "date": "2026-02-25",
- "date_confidence": "high",
- "engagement": {
- "likes": 6
- },
- "relevance": 0.7,
- "why_relevant": "",
- "subs": {
- "relevance": 70,
- "recency": 100,
- "engagement": 81
- },
- "score": 80
- },
- {
- "id": "X8",
- "text": "Day 7 building my first B2C app\n\nBeen setting everything up this week. Manually built 5 agents to help me in the early stages and set up the Claude Code skills i'll need for the build.\n\nAlso been testing OpenClaw these days, already delegated my expense tracking to it.\n\nNow the scary part: Figuring out the visual identity of the app. my design skills are basically zero, i always used to delegate this to someone with actual talent haha. \n\nAny recommendations to learn the fundamentals? would reall",
- "url": "https://x.com/DevTenta/status/2026742028730527786",
- "author_handle": "DevTenta",
- "date": "2026-02-25",
- "date_confidence": "high",
- "engagement": {
- "likes": 4
- },
- "relevance": 0.7,
- "why_relevant": "",
- "subs": {
- "relevance": 70,
- "recency": 100,
- "engagement": 59
- },
- "score": 74
- },
- {
- "id": "X7",
- "text": "@Jompiras Claude code + skills. Muy breve.",
- "url": "https://x.com/JorgeJaramillo/status/2026743135435362649",
- "author_handle": "JorgeJaramillo",
- "date": "2026-02-25",
- "date_confidence": "high",
- "engagement": null,
- "relevance": 0.7,
- "why_relevant": "",
- "subs": {
- "relevance": 70,
- "recency": 100,
- "engagement": 35
- },
- "score": 64
- },
- {
- "id": "X4",
- "text": "@tom_doerr 135 agents, 35 curated skills, 121 plugins and 6 MCP configs all in one Claude Code toolkit is absolutely insane value \ud83e\udd2f this is the kind of community effort that makes an AI tool go from good to unstoppable. bookmarking this immediately \ud83d\udd25",
- "url": "https://x.com/arpan7sarkar/status/2026746479369662551",
- "author_handle": "arpan7sarkar",
- "date": "2026-02-25",
- "date_confidence": "high",
- "engagement": null,
- "relevance": 0.7,
- "why_relevant": "",
- "subs": {
- "relevance": 70,
- "recency": 100,
- "engagement": 35
- },
- "score": 64
- },
- {
- "id": "X6",
- "text": "@tom_doerr A curated collection of Claude Skills organized by category is exactly what power users have been waiting for \ud83e\udd29 document skills, code tools, data analysis all in one repo. this is the Claude plugin store before the Claude plugin store \ud83d\udd25",
- "url": "https://x.com/arpan7sarkar/status/2026745559986614335",
- "author_handle": "arpan7sarkar",
- "date": "2026-02-25",
- "date_confidence": "high",
- "engagement": null,
- "relevance": 0.7,
- "why_relevant": "",
- "subs": {
- "relevance": 70,
- "recency": 100,
- "engagement": 35
- },
- "score": 64
- },
- {
- "id": "X2",
- "text": "Claude Code\u306eSkills\u3092\u4f5c\u6210\u4f8b\u304b\u3089\u5fb9\u5e95\u7406\u89e3\u3059\u308b https://t.co/vZTseio2Ii",
- "url": "https://x.com/yasuky/status/2026754773786226708",
- "author_handle": "yasuky",
- "date": "2026-02-25",
- "date_confidence": "high",
- "engagement": null,
- "relevance": 0.7,
- "why_relevant": "",
- "subs": {
- "relevance": 70,
- "recency": 100,
- "engagement": 35
- },
- "score": 64
- },
- {
- "id": "X12",
- "text": "Day 52 (01/21/2026) (Rest Day)\n\n> Go to New York + watch Broadway Show\n\n> Catch up on claude code updates on way back (2hrs)\n - Learn difference between skills + MCP, and etc\n \n> Neetcode (1hr 15min)\n\n> Plan for the week (30min)\n \nTotal focused working hours: 3hrs 45min",
- "url": "https://x.com/zhuoyuan45514/status/2026733326631923776",
- "author_handle": "zhuoyuan45514",
- "date": "2026-02-25",
- "date_confidence": "high",
- "engagement": null,
- "relevance": 0.7,
- "why_relevant": "",
- "subs": {
- "relevance": 70,
- "recency": 100,
- "engagement": 35
- },
- "score": 64
- },
- {
- "id": "X1",
- "text": "How: I told Claude Code \"build me a CRM.\" Our builder skill generated schemas, skills, server, seed data. I pointed Claude Desktop at it. Contacts, lead scoring, pipeline reviews. All from conversation.\n\nThen \"build me a research assistant.\" Topics, sources, notes, etc. Same flow",
- "url": "https://x.com/matgoldsborough/status/2026755742737510749",
- "author_handle": "matgoldsborough",
- "date": "2026-02-25",
- "date_confidence": "high",
- "engagement": {
- "replies": 1
- },
- "relevance": 0.7,
- "why_relevant": "",
- "subs": {
- "relevance": 70,
- "recency": 100,
- "engagement": 35
- },
- "score": 64
- },
- {
- "id": "X9",
- "text": "I can build another open claw, or claude bot, or even a messaging app in a nice prompt and although having engineering skills, I can push the code on the public repo and secure it even completely and may start marketing in a crazy way on X or maybe Reddit using a secret niche guy when writing a post.\n\nThis is the new coding for us, and shipping a nice product.",
- "url": "https://x.com/0x_Kapoor/status/2026738663321907214",
- "author_handle": "0x_Kapoor",
- "date": "2026-02-25",
- "date_confidence": "high",
- "engagement": null,
- "relevance": 0.7,
- "why_relevant": "",
- "subs": {
- "relevance": 70,
- "recency": 100,
- "engagement": 35
- },
- "score": 64
- },
- {
- "id": "X10",
- "text": "1. I use https://t.co/NtFYJEiFs9 inspired by @bcherny \n2. I use https://t.co/EwnmyWNFqn to sync skills and scan skills from the marketplace\n3. I chat with claude code 4-5 times and once I am satisifed with plan, I ask it to build it.\n4. I use /code-simplifier, /memory and /parallel from my plugin above to distribute work across agents.\n5. I wait and once they're done, I use coderabbit:review to review once locally and make a PR\n\nThis process works like a gem for me.",
- "url": "https://x.com/ghumare64/status/2026736176695246966",
- "author_handle": "ghumare64",
- "date": "2026-02-25",
- "date_confidence": "high",
- "engagement": {
- "likes": 1
- },
- "relevance": 0.7,
- "why_relevant": "",
- "subs": {
- "relevance": 70,
- "recency": 100,
- "engagement": 0
- },
- "score": 56
- },
- {
- "id": "X11",
- "text": "The 'vibe coding' revolution is forcing a reckoning.\n\nDevelopers with 15+ years experience are either embracing AI to expand their reach or watching their expertise become as valuable as COBOL skills.\n\nWhich side of history are you on?\n\nPS\nClaude Code can now write COBOL as well.",
- "url": "https://x.com/hamen/status/2026734075243790396",
- "author_handle": "hamen",
- "date": "2026-02-25",
- "date_confidence": "high",
- "engagement": {
- "likes": 1
- },
- "relevance": 0.7,
- "why_relevant": "",
- "subs": {
- "relevance": 70,
- "recency": 100,
- "engagement": 0
- },
- "score": 56
- }
- ],
- "web": [],
- "youtube": [
- {
- "id": "X8afcX2s2Mo",
- "title": "Claude Skills: Build Your First AI Marketing Team in 16 Minutes (Claude Code)",
- "url": "https://www.youtube.com/watch?v=X8afcX2s2Mo",
- "channel_name": "Grace Leung",
- "date": "2026-02-21",
- "date_confidence": "high",
- "engagement": {
- "num_comments": 47,
- "likes": 1900,
- "views": 47474
- },
- "transcript_snippet": "",
- "relevance": 0.7,
- "why_relevant": "YouTube video about claude code skills and mcp servers",
- "subs": {
- "relevance": 70,
- "recency": 86,
- "engagement": 70
- },
- "score": 74
- },
- {
- "id": "0J2_YGuNrDo",
- "title": "Claude Code just Built me an AI Agent Team (Claude Code + Skills + MCP)",
- "url": "https://www.youtube.com/watch?v=0J2_YGuNrDo",
- "channel_name": "Grace Leung",
- "date": "2025-12-16",
- "date_confidence": "high",
- "engagement": {
- "num_comments": 291,
- "likes": 4760,
- "views": 155806
- },
- "transcript_snippet": "I use clot more than any other AI but even I dismiss Clark as just for developers and I was wrong after using myself I am completely so and this is too powerful to ignore if you want to use AI to build system and do work for you end to end not just chatting and copy pasting so in this video I'll share how to get started with cloud code even if you don't use for coding and most importantly how to use it to build an AI work team and system that you can actually use. Let's go. So why claw code is even needed if claw is already so powerful. So most people use cloud projects with custom instructions to turn claw into AI agents. But they are silo. You end up copy and pasting between them. You are the coordinator. But with cloud code is different. So these cloud agents share the same workspace access to the same context file. They can talk to each other and hands up work automatically each with their own token context window and claude now becomes the team lead. You give the direction they execute together and that is what we are building today. So each of these cloud code agents you create needs at least three core components all defined in one single markdown file. the role and responsibilities, the knowledge like workflow details, reusable clot agent skills, the MCP tools that they have access to. For this demo, we'll be building a five agent work and claude, our main agent will add as our team lead to coordinate between these agent. A bonus tip is when designing your AI teammate, give them specific and non-over overlapping roles to minimize the potential conflicts. Now, to get started with clark code, there are four main ways to access it. First, the local terminal. This is the raw method and will give you the best experience. Luckily, Anthroporic just launched a native installer. So, which makes it really easy to install. I'll put the official link below. And second, ID extension. So, you can add CL code to ID like VS Code or Kursa and let you run the CL code and see the project file structure side by side. Third, the cloud web app where you can click the code tab and run the CL code in the cloud, but it requires you linking to a GitHub repository. and fourth, cloud desktop app which has cloud code built-in and this is what we'll be using today. Now, whatever way you choose, I still highly recommend installing cloud code through the terminal first because the terminal gives you the most complete experience. All commands and features work fully since we're running Clark locally. So, the first thing is to set up a local project folder. So, think of it like a dedicated workspace for Clark. So, let's say I run a marketing agency and I'm setting up the TIM system. So...",
- "relevance": 0.7,
- "why_relevant": "YouTube video about claude code skills and mcp servers",
- "subs": {
- "relevance": 70,
- "recency": 0,
- "engagement": 100
- },
- "score": 61
- },
- {
- "id": "fOxC44g8vig",
- "title": "Claude Agent Skills Explained",
- "url": "https://www.youtube.com/watch?v=fOxC44g8vig",
- "channel_name": "Anthropic",
- "date": "2025-11-26",
- "date_confidence": "high",
- "engagement": {
- "num_comments": 144,
- "likes": 2991,
- "views": 129573
- },
- "transcript_snippet": "Hi, my name is Otto and in this video we're going to discuss agent skills. Agents today are pretty intelligent, but they don't always have the domain expertise you need for real work and skills help solve this. You can think of skills as organized folders that package expertise that Cloud can automatically invoke when relevant to the task at hand. And most importantly, these skills are portable across cloud code, the API as well as cla.ai. And the way skills work is at startup only the name and description of every installed skill is loaded in the system prompt. This is going to consume about 30 to 50 tokens per skill and make Claude aware of the skill's existence. Then when a user prompt matches a skills description, Claude is going to dynamically load the full skill.md file into context. And finally, if the skill references other files or scripts, they are also progressively loaded and run as needed. This progressive disclosure allows you to install many different skills to perform complex tasks without bloating your context window. But let's see how skills fit in with the other Claude features. While skills teach Claude how to do specialized tasks, Claude.md files tell Claude about the specific project. Things like your text stack, coding conventions, and repo structure. CloudMD files live alongside your code in the repository. A CloudMD file may say things like we [music] use Nex.js JS and Tailwind. But skills on the other hand are portable expertise that work across any project. So a front-end design skill can teach Claude your typography standards, animation patterns, and layout conventions and [music] activate automatically when building UI components. MCP servers on the other hand provide universal integration, a single protocol that connects Claude to external context sources like GitHub, linear, Postgress, and many many others. MCP connects to data. Skills teach Claude what to do with it. So an MCP server may give Claude access to your database, but a database query skill can teach Claude your team's query optimization patterns. Finally, sub agents are specialized AI assistants with fixed roles. Each sub agent has its own context window, custom prompt, and specific tool permissions. skills provide portable expertise that any agent can use. So your front-end developer sub agent can use a component pattern skill. Your UI reviewer sub agent on the other hand can use a design system skill, but both can load and use the same accessibility standard skill. And the best part is these capabilities are designed to work together. Your cloudMD file sets the foundation. MCP [music] servers connect the data. Sub agents specialize in their roles and skills bring the expertise making every piece smarter and more capable. At the end of the day, skills let you package workflows into reusable capabilities like helping onboard new hires to your team's coding standards, ensuring every PR follows a specific security best practices or sharing your data analysis methodology across your team. And that's how...",
- "relevance": 0.7,
- "why_relevant": "YouTube video about claude code skills and mcp servers",
- "subs": {
- "relevance": 70,
- "recency": 0,
- "engagement": 90
- },
- "score": 58
- },
- {
- "id": "Gqh_KdHP1Xk",
- "title": "8 MCP Servers That Make Claude Code 10x Better",
- "url": "https://www.youtube.com/watch?v=Gqh_KdHP1Xk",
- "channel_name": "Robin Ebers",
- "date": "2025-08-21",
- "date_confidence": "high",
- "engagement": {
- "num_comments": 108,
- "likes": 2719,
- "views": 90281
- },
- "transcript_snippet": "Over 90% of all MCP servers are complete overhyped garbage. But some of them are not only worth it, they can be complete game changers. And I do not say this lightly. So after testing over 100 of them, here are the only eight MCP servers that I actually use to build apps even faster. But first, let me save you from making the same expensive mistake that I made when I started using MCP servers. Because what no one explains about them is that the more of them you install, the dumber your AI becomes. I know this sounds backwards because everyone thinks more tools equals smarter AI. But here is what's actually happening because every MCP server you add is like giving your AI yet another tool to choose from. Imagine asking someone to fix your bike, but instead of giving them a toolbox with eight essential tools, you just dump 100 tools on the floor. they will spend more time figuring out which tool to use than actually fixing your bike. That is exactly what happens with claude and chatbt too because every MCP server needs instructions in the context window or chat when you start. But new research now shows that this is actually really bad for AI. It's like trying to focus on a specific task while browsing social media. You're just pumping useless information into your brain. And that is kind of what happens too when you use too many MCP servers in your AI. So, if you want your AI to stay focused, remove all distractions. I stripped my list down to these eight MCP servers. And even then, I don't use all of them in every project. I turn them on and off because the fewer tools you give your AI, the more focused it stays. So, now let's look at my full list. Hey, what's up, guys? My name is Rob and I've been a coder for over 20 years. But now, I teach people how to build their ideas with AI in ways that even non-technical people can understand. I do this here on YouTube and inside of my AI coding blueprint, which you can check out in the description down below. Starting with number eight, almost every app that you build will need real world data. This could be stock prices, news articles, YouTube comments, or Instagram profiles. And that is where this MCP server comes in because this turns your AI into a data gathering machine. API is a marketplace for web scrapers, which you can think of as little robots that go all over the internet and grab information from any website that you want. And here is the really crazy part, because this MCP server doesn't just help you pull the data into your app. It actually makes accessing the full documentation of every web scraper available on this platform very easy. It can also help you debug them when things go wrong or check the...",
- "relevance": 0.7,
- "why_relevant": "YouTube video about claude code skills and mcp servers",
- "subs": {
- "relevance": 70,
- "recency": 0,
- "engagement": 84
- },
- "score": 56
- },
- {
- "id": "421T2iWTQio",
- "title": "The Only Claude Skills Guide You Need (Beginner to Expert)",
- "url": "https://www.youtube.com/watch?v=421T2iWTQio",
- "channel_name": "Kenny Liao",
- "date": "2025-10-24",
- "date_confidence": "high",
- "engagement": {
- "num_comments": 101,
- "likes": 1010,
- "views": 50477
- },
- "transcript_snippet": "",
- "relevance": 0.7,
- "why_relevant": "YouTube video about claude code skills and mcp servers",
- "subs": {
- "relevance": 70,
- "recency": 0,
- "engagement": 68
- },
- "score": 51
- },
- {
- "id": "l7qVtHpctic",
- "title": "Claude Code's MCP Problem Just Got Fixed",
- "url": "https://www.youtube.com/watch?v=l7qVtHpctic",
- "channel_name": "Kenny Liao",
- "date": "2026-01-17",
- "date_confidence": "high",
- "engagement": {
- "num_comments": 93,
- "likes": 728,
- "views": 21765
- },
- "transcript_snippet": "",
- "relevance": 0.7,
- "why_relevant": "YouTube video about claude code skills and mcp servers",
- "subs": {
- "relevance": 70,
- "recency": 0,
- "engagement": 54
- },
- "score": 47
- },
- {
- "id": "M5CsRj6zSCA",
- "title": "5 MCP Servers That Make Claude Code 10x More Powerful (Full-Stack App Build)",
- "url": "https://www.youtube.com/watch?v=M5CsRj6zSCA",
- "channel_name": "Eric Tech",
- "date": "2025-12-12",
- "date_confidence": "high",
- "engagement": {
- "num_comments": 30,
- "likes": 266,
- "views": 11287
- },
- "transcript_snippet": "",
- "relevance": 0.7,
- "why_relevant": "YouTube video about claude code skills and mcp servers",
- "subs": {
- "relevance": 70,
- "recency": 0,
- "engagement": 33
- },
- "score": 41
- },
- {
- "id": "qthyl0GCpDo",
- "title": "Claude Skills vs MCP: What\u2019s the Difference and When to Use Each?",
- "url": "https://www.youtube.com/watch?v=qthyl0GCpDo",
- "channel_name": "Postman",
- "date": "2025-11-20",
- "date_confidence": "high",
- "engagement": {
- "num_comments": 4,
- "likes": 105,
- "views": 7045
- },
- "transcript_snippet": "",
- "relevance": 0.7,
- "why_relevant": "YouTube video about claude code skills and mcp servers",
- "subs": {
- "relevance": 70,
- "recency": 0,
- "engagement": 12
- },
- "score": 35
- },
- {
- "id": "ZroGqu7GyXM",
- "title": "Claude Code Skills vs MCP vs Sub Agents: What Works for Solo Devs?",
- "url": "https://www.youtube.com/watch?v=ZroGqu7GyXM",
- "channel_name": "Solo Swift Crafter",
- "date": "2025-10-27",
- "date_confidence": "high",
- "engagement": {
- "num_comments": 13,
- "likes": 98,
- "views": 4885
- },
- "transcript_snippet": "",
- "relevance": 0.7,
- "why_relevant": "YouTube video about claude code skills and mcp servers",
- "subs": {
- "relevance": 70,
- "recency": 0,
- "engagement": 10
- },
- "score": 34
- },
- {
- "id": "jzf7DQa2CAc",
- "title": "How I Use Claude Code With Skills, MCP, Agents & Plugins",
- "url": "https://www.youtube.com/watch?v=jzf7DQa2CAc",
- "channel_name": "Matt Kuda",
- "date": "2026-01-19",
- "date_confidence": "high",
- "engagement": {
- "num_comments": 8,
- "likes": 87,
- "views": 2547
- },
- "transcript_snippet": "",
- "relevance": 0.7,
- "why_relevant": "YouTube video about claude code skills and mcp servers",
- "subs": {
- "relevance": 70,
- "recency": 0,
- "engagement": 0
- },
- "score": 31
- }
- ],
- "hackernews": [
- {
- "id": "HN1",
- "title": "Show HN: I built a 55K-word email marketing knowledge base and Claude Code skill",
- "url": "https://www.emailmarketingskill.com/",
- "hn_url": "https://news.ycombinator.com/item?id=47096335",
- "author": "cosmoblk",
- "date": "2026-02-21",
- "date_confidence": "high",
- "engagement": {
- "score": 10,
- "num_comments": 2
- },
- "top_comments": [
- {
- "score": 0,
- "date": null,
- "author": "tkel",
- "excerpt": "So it's an AI spambot?",
- "url": ""
- }
- ],
- "comment_insights": [
- "So it's an AI spambot?"
- ],
- "relevance": 0.86,
- "why_relevant": "HN story about Show HN: I built a 55K-word email marketing knowledge base a",
- "subs": {
- "relevance": 86,
- "recency": 86,
- "engagement": 100
- },
- "score": 90
- },
- {
- "id": "HN4",
- "title": "Show HN: AI Marketing Skills for Claude Code",
- "url": "https://github.com/superamped/ai-marketing-skills",
- "hn_url": "https://news.ycombinator.com/item?id=47154377",
- "author": "superamped",
- "date": "2026-02-25",
- "date_confidence": "high",
- "engagement": {
- "score": 3,
- "num_comments": 1
- },
- "top_comments": [],
- "comment_insights": [],
- "relevance": 0.79,
- "why_relevant": "HN story about Show HN: AI Marketing Skills for Claude Code",
- "subs": {
- "relevance": 79,
- "recency": 100,
- "engagement": 48
- },
- "score": 74
- },
- {
- "id": "HN12",
- "title": "Show HN: Poncho, a general agent harness built for the web",
- "url": "https://github.com/cesr/poncho-ai",
- "hn_url": "https://news.ycombinator.com/item?id=47061949",
- "author": "heycesr",
- "date": "2026-02-18",
- "date_confidence": "high",
- "engagement": {
- "score": 4,
- "num_comments": 2
- },
- "top_comments": [
- {
- "score": 0,
- "date": null,
- "author": "adriablancafort",
- "excerpt": "I have been trying it and it's very cool!",
- "url": ""
- }
- ],
- "comment_insights": [
- "I have been trying it and it's very cool!"
- ],
- "relevance": 0.69,
- "why_relevant": "HN story about Show HN: Poncho, a general agent harness built for the web",
- "subs": {
- "relevance": 69,
- "recency": 76,
- "engagement": 69
- },
- "score": 70
- },
- {
- "id": "HN11",
- "title": "Show HN: Axon \u2013 A Kubernetes-native framework for AI coding agents",
- "url": "https://github.com/axon-core/axon",
- "hn_url": "https://news.ycombinator.com/item?id=47137491",
- "author": "gjkim042",
- "date": "2026-02-24",
- "date_confidence": "high",
- "engagement": {
- "score": 1,
- "num_comments": 4
- },
- "top_comments": [],
- "comment_insights": [],
- "relevance": 0.68,
- "why_relevant": "HN story about Show HN: Axon \u2013 A Kubernetes-native framework for AI coding ",
- "subs": {
- "relevance": 68,
- "recency": 96,
- "engagement": 50
- },
- "score": 69
- },
- {
- "id": "HN14",
- "title": "Show HN: Turn Claude Code or Codex into proactive, autonomous 24/7 AI agents",
- "url": "https://github.com/suitedaces/dorabot",
- "hn_url": "https://news.ycombinator.com/item?id=47054100",
- "author": "alternateman",
- "date": "2026-02-17",
- "date_confidence": "high",
- "engagement": {
- "score": 3,
- "num_comments": 2
- },
- "top_comments": [],
- "comment_insights": [],
- "relevance": 0.65,
- "why_relevant": "HN story about Show HN: Turn Claude Code or Codex into proactive, autonomou",
- "subs": {
- "relevance": 65,
- "recency": 73,
- "engagement": 61
- },
- "score": 65
- },
- {
- "id": "HN2",
- "title": "Show HN: Agent skills to build photo, video and design editors on the web",
- "url": "https://github.com/imgly/agent-skills",
- "hn_url": "https://news.ycombinator.com/item?id=47073035",
- "author": "hauschildt",
- "date": "2026-02-19",
- "date_confidence": "high",
- "engagement": {
- "score": 3,
- "num_comments": 0
- },
- "top_comments": [],
- "comment_insights": [],
- "relevance": 0.82,
- "why_relevant": "HN story about Show HN: Agent skills to build photo, video and design edito",
- "subs": {
- "relevance": 82,
- "recency": 80,
- "engagement": 26
- },
- "score": 64
- },
- {
- "id": "HN8",
- "title": "Show HN: Upjack \u2013 Declarative framework for building apps over MCP",
- "url": "https://github.com/NimbleBrainInc/upjack",
- "hn_url": "https://news.ycombinator.com/item?id=47157331",
- "author": "barefootsanders",
- "date": "2026-02-25",
- "date_confidence": "high",
- "engagement": {
- "score": 1,
- "num_comments": 0
- },
- "top_comments": [],
- "comment_insights": [],
- "relevance": 0.72,
- "why_relevant": "HN story about Show HN: Upjack \u2013 Declarative framework for building apps ov",
- "subs": {
- "relevance": 72,
- "recency": 100,
- "engagement": 0
- },
- "score": 57
- },
- {
- "id": "HN7",
- "title": "Show HN: Gulama \u2013 Security-first open-source AI agent (OpenClaw alternative)",
- "url": "https://github.com/san-techie21/gulama-bot",
- "hn_url": "https://news.ycombinator.com/item?id=47031982",
- "author": "san-techie21",
- "date": "2026-02-16",
- "date_confidence": "high",
- "engagement": {
- "score": 1,
- "num_comments": 1
- },
- "top_comments": [],
- "comment_insights": [],
- "relevance": 0.73,
- "why_relevant": "HN story about Show HN: Gulama \u2013 Security-first open-source AI agent (OpenC",
- "subs": {
- "relevance": 73,
- "recency": 70,
- "engagement": 21
- },
- "score": 56
- },
- {
- "id": "HN15",
- "title": "AI Skills Platform (Stealth) \u2013 Technical Co-Founder \u2013 Remote (US) \u2013 Equity",
- "url": "",
- "hn_url": "https://news.ycombinator.com/item?id=47081777",
- "author": "martin-hall",
- "date": "2026-02-20",
- "date_confidence": "high",
- "engagement": {
- "score": 1,
- "num_comments": 0
- },
- "top_comments": [],
- "comment_insights": [],
- "relevance": 0.62,
- "why_relevant": "HN story about AI Skills Platform (Stealth) \u2013 Technical Co-Founder \u2013 Remote",
- "subs": {
- "relevance": 62,
- "recency": 83,
- "engagement": 0
- },
- "score": 48
- },
- {
- "id": "HN10",
- "title": "Show HN: SkillSandbox \u2013 Capability-based sandbox for AI agent skills (Rust)",
- "url": "https://github.com/theMachineClay/skillsandbox",
- "hn_url": "https://news.ycombinator.com/item?id=47027734",
- "author": "ClaytheMachine",
- "date": "2026-02-15",
- "date_confidence": "high",
- "engagement": {
- "score": 1,
- "num_comments": 0
- },
- "top_comments": [],
- "comment_insights": [],
- "relevance": 0.69,
- "why_relevant": "HN story about Show HN: SkillSandbox \u2013 Capability-based sandbox for AI agen",
- "subs": {
- "relevance": 69,
- "recency": 66,
- "engagement": 0
- },
- "score": 47
- },
- {
- "id": "HN6",
- "title": "Show HN: Indx.sh \u2013 Directory of AI coding rules, MCP servers, and tool",
- "url": "https://indx.sh",
- "hn_url": "https://news.ycombinator.com/item?id=46822378",
- "author": "micronink",
- "date": "2026-01-30",
- "date_confidence": "high",
- "engagement": {
- "score": 1,
- "num_comments": 2
- },
- "top_comments": [],
- "comment_insights": [],
- "relevance": 0.75,
- "why_relevant": "HN story about Show HN: Indx.sh \u2013 Directory of AI coding rules, MCP servers",
- "subs": {
- "relevance": 75,
- "recency": 13,
- "engagement": 34
- },
- "score": 47
- },
- {
- "id": "HN3",
- "title": "Show HN: GTM MCP Server- Let AI Manage Your Google Tag Manager Containers",
- "url": "https://github.com/paolobietolini/gtm-mcp-server",
- "hn_url": "https://news.ycombinator.com/item?id=46922159",
- "author": "paolobietolini",
- "date": "2026-02-07",
- "date_confidence": "high",
- "engagement": {
- "score": 1,
- "num_comments": 0
- },
- "top_comments": [],
- "comment_insights": [],
- "relevance": 0.79,
- "why_relevant": "HN story about Show HN: GTM MCP Server- Let AI Manage Your Google Tag Manag",
- "subs": {
- "relevance": 79,
- "recency": 40,
- "engagement": 0
- },
- "score": 45
- },
- {
- "id": "HN9",
- "title": "Show HN: ClawsMarket \u2013 Marketplace where AI agents discover tools",
- "url": "https://www.clawsmarket.com",
- "hn_url": "https://news.ycombinator.com/item?id=46878646",
- "author": "digitcatphd",
- "date": "2026-02-03",
- "date_confidence": "high",
- "engagement": {
- "score": 1,
- "num_comments": 0
- },
- "top_comments": [],
- "comment_insights": [],
- "relevance": 0.71,
- "why_relevant": "HN story about Show HN: ClawsMarket \u2013 Marketplace where AI agents discover ",
- "subs": {
- "relevance": 71,
- "recency": 26,
- "engagement": 0
- },
- "score": 38
- },
- {
- "id": "HN13",
- "title": "Show HN: Fluid.sh \u2013 Claude Code for Infrastructure",
- "url": "https://www.fluid.sh/blog/introducing-fluid",
- "hn_url": "https://news.ycombinator.com/item?id=46886358",
- "author": "aspectrr",
- "date": "2026-02-04",
- "date_confidence": "high",
- "engagement": {
- "score": 1,
- "num_comments": 0
- },
- "top_comments": [],
- "comment_insights": [],
- "relevance": 0.65,
- "why_relevant": "HN story about Show HN: Fluid.sh \u2013 Claude Code for Infrastructure",
- "subs": {
- "relevance": 65,
- "recency": 30,
- "engagement": 0
- },
- "score": 36
- },
- {
- "id": "HN5",
- "title": "Claude Code skill for building ChatGPT Apps",
- "url": "",
- "hn_url": "https://news.ycombinator.com/item?id=46780766",
- "author": "victordg",
- "date": "2026-01-27",
- "date_confidence": "high",
- "engagement": {
- "score": 1,
- "num_comments": 0
- },
- "top_comments": [],
- "comment_insights": [],
- "relevance": 0.76,
- "why_relevant": "HN story about Claude Code skill for building ChatGPT Apps",
- "subs": {
- "relevance": 76,
- "recency": 3,
- "engagement": 0
- },
- "score": 34
- }
- ],
- "best_practices": [],
- "prompt_pack": [],
- "context_snippet_md": "# Context: Claude Code skills and MCP servers (Last 30 Days)\n\n*Generated: 2026-02-25 | Sources: both*\n\n## Key Sources\n\n- [HN] Show HN: I built a 55K-word email marketing knowle...\n- [X] @adamwathan Codex has been crushing it for me with...\n- [X] \ud83d\udea8 Anthropic just open-sourced the exact Skills lib...\n- [X] Day 7 building my first B2C app\n\nBeen setting ever...\n- [HN] Show HN: AI Marketing Skills for Claude Code...\n- [HN] Show HN: Poncho, a general agent harness built for...\n- [HN] Show HN: Axon \u2013 A Kubernetes-native framework for ...\n\n## Summary\n\n*See full report for best practices, prompt pack, and detailed sources.*\n"
-}
diff --git a/docs/comparison-results/json/hn-2-seedance.json b/docs/comparison-results/json/hn-2-seedance.json
deleted file mode 100644
index f93ce55..0000000
--- a/docs/comparison-results/json/hn-2-seedance.json
+++ /dev/null
@@ -1,1224 +0,0 @@
-{
- "topic": "Seedance AI video generation",
- "range": {
- "from": "2026-01-26",
- "to": "2026-02-25"
- },
- "generated_at": "2026-02-25T20:32:10.258113+00:00",
- "mode": "both",
- "openai_model_used": "gpt-5.2",
- "xai_model_used": "grok-4-1-fast",
- "reddit": [
- {
- "id": "R2",
- "title": "Official: Seedance 2.0 now live in CapCut desktop and API access available, details below",
- "url": "https://www.reddit.com/r/singularity/comments/1rekvm8/official_seedance_20_now_live_in_capcut_desktop/",
- "subreddit": "singularity",
- "date": "2026-02-25",
- "date_confidence": "high",
- "engagement": null,
- "top_comments": [],
- "comment_insights": [],
- "relevance": 0.94,
- "why_relevant": "High-activity thread discussing Seedance 2.0 launch claims, CapCut availability, censorship, and pricing.",
- "subs": {
- "relevance": 94,
- "recency": 100,
- "engagement": 50
- },
- "score": 79
- },
- {
- "id": "R3",
- "title": "Official website for creating content with Seedance 2.0?",
- "url": "https://www.reddit.com/r/generativeAI/comments/1rcgyi4/official_website_for_creating_content_with/",
- "subreddit": "generativeAI",
- "date": "2026-02-24",
- "date_confidence": "high",
- "engagement": null,
- "top_comments": [],
- "comment_insights": [],
- "relevance": 0.93,
- "why_relevant": "Users discuss where/how to access Seedance 2.0 and warn about scams.",
- "subs": {
- "relevance": 93,
- "recency": 96,
- "engagement": 50
- },
- "score": 77
- },
- {
- "id": "R4",
- "title": "Is there an official way to create content using Seedance 2.0?",
- "url": "https://www.reddit.com/r/AITalkers/comments/1rd1rhy/is_there_an_official_way_to_create_content_using/",
- "subreddit": "AITalkers",
- "date": "2026-02-24",
- "date_confidence": "high",
- "engagement": null,
- "top_comments": [],
- "comment_insights": [],
- "relevance": 0.91,
- "why_relevant": "Asks for legit access routes to Seedance 2.0; includes discussion of official vs third-party options.",
- "subs": {
- "relevance": 91,
- "recency": 96,
- "engagement": 50
- },
- "score": 76
- },
- {
- "id": "R5",
- "title": "Help! I need to use seedance or jimeng video function",
- "url": "https://www.reddit.com/r/Seedance_AI/comments/1rckgcm/help_i_need_to_use_seedance_or_jimeng_video/",
- "subreddit": "Seedance_AI",
- "date": "2026-02-23",
- "date_confidence": "high",
- "engagement": null,
- "top_comments": [],
- "comment_insights": [],
- "relevance": 0.9,
- "why_relevant": "Seedance/Jimeng video generation access and troubleshooting discussion.",
- "subs": {
- "relevance": 90,
- "recency": 93,
- "engagement": 50
- },
- "score": 75
- },
- {
- "id": "R6",
- "title": "Seedance 2.0 is available in Open Source tools already",
- "url": "https://www.reddit.com/r/generativeAI/comments/1rcoihw/seedance_20_is_available_in_open_source_tools/",
- "subreddit": "generativeAI",
- "date": "2026-02-23",
- "date_confidence": "high",
- "engagement": null,
- "top_comments": [],
- "comment_insights": [],
- "relevance": 0.89,
- "why_relevant": "Discusses Seedance 2.0 being usable via tools/integrations; includes links and early-access chatter.",
- "subs": {
- "relevance": 89,
- "recency": 93,
- "engagement": 50
- },
- "score": 75
- },
- {
- "id": "R8",
- "title": "This is terrifying!! Seedance 2.0 just generated a 1-minute film with ZERO editing \u2014 the entire film industry should be worried",
- "url": "https://www.reddit.com/r/generativeAI/comments/1rbionc/this_is_terrifying_seedance_20_just_generated_a/",
- "subreddit": "generativeAI",
- "date": "2026-02-22",
- "date_confidence": "high",
- "engagement": null,
- "top_comments": [],
- "comment_insights": [],
- "relevance": 0.88,
- "why_relevant": "Seedance 2.0 output showcase + discussion of cinematic coherence and workflow.",
- "subs": {
- "relevance": 88,
- "recency": 90,
- "engagement": 50
- },
- "score": 74
- },
- {
- "id": "R7",
- "title": "ByteDance Faces Hollywood Backlash Over Seedance 2.0",
- "url": "https://www.reddit.com/r/AIGuild/comments/1rc4pkg/bytedance_faces_hollywood_backlash_over_seedance/",
- "subreddit": "AIGuild",
- "date": "2026-02-23",
- "date_confidence": "high",
- "engagement": null,
- "top_comments": [],
- "comment_insights": [],
- "relevance": 0.86,
- "why_relevant": "Discussion about Seedance 2.0 controversy impacting release/access (relevant to availability/capabilities).",
- "subs": {
- "relevance": 86,
- "recency": 93,
- "engagement": 50
- },
- "score": 73
- },
- {
- "id": "R9",
- "title": "Is Jimeng silently banning accounts on Jimeng.Jianying? Paid account stuck with \u201cnetwork error\u201d but free account works.",
- "url": "https://www.reddit.com/r/Seedance_AI/comments/1rbk1qb/is_jimeng_silently_banning_accounts_on/",
- "subreddit": "Seedance_AI",
- "date": "2026-02-22",
- "date_confidence": "high",
- "engagement": null,
- "top_comments": [],
- "comment_insights": [],
- "relevance": 0.87,
- "why_relevant": "Seedance/Jimeng video generation failures and account restriction speculation.",
- "subs": {
- "relevance": 87,
- "recency": 90,
- "engagement": 50
- },
- "score": 73
- },
- {
- "id": "R12",
- "title": "Jimeng Web for seedance 2.0 \u201cNetwork Error, Generation Failed\u201d for 24 Hours \u2013 Anyone Else?",
- "url": "https://www.reddit.com/r/Seedance_AI/comments/1raqsnu/jimeng_web_for_seedance_20_network_error/",
- "subreddit": "Seedance_AI",
- "date": "2026-02-21",
- "date_confidence": "high",
- "engagement": null,
- "top_comments": [],
- "comment_insights": [],
- "relevance": 0.88,
- "why_relevant": "Focused troubleshooting thread about Seedance 2.0 generation failures on Jimeng web.",
- "subs": {
- "relevance": 88,
- "recency": 86,
- "engagement": 50
- },
- "score": 73
- },
- {
- "id": "R10",
- "title": "Seedance 2.0 is insane but almost no one outside China can actually use it \u2014 here's the full breakdown",
- "url": "https://www.reddit.com/r/u_Educational-Lion7812/comments/1raexxq/seedance_20_is_insane_but_almost_no_one_outside/",
- "subreddit": "u_Educational-Lion7812",
- "date": "2026-02-21",
- "date_confidence": "high",
- "engagement": null,
- "top_comments": [],
- "comment_insights": [],
- "relevance": 0.84,
- "why_relevant": "Long-form post focused on Seedance 2.0 access constraints and how people are using it.",
- "subs": {
- "relevance": 84,
- "recency": 86,
- "engagement": 50
- },
- "score": 71
- },
- {
- "id": "R11",
- "title": "so disappointed and frustrated with recent changes (wasted 1k$+)",
- "url": "https://www.reddit.com/r/Seedance_AI/comments/1ra6nf4/so_disappointed_and_frustrated_with_recent/",
- "subreddit": "Seedance_AI",
- "date": "2026-02-21",
- "date_confidence": "high",
- "engagement": null,
- "top_comments": [],
- "comment_insights": [],
- "relevance": 0.85,
- "why_relevant": "Seedance/Jimeng generation issues and policy changes; directly about Seedance video generation usability.",
- "subs": {
- "relevance": 85,
- "recency": 86,
- "engagement": 50
- },
- "score": 71
- },
- {
- "id": "R15",
- "title": "ByteDance releases Seedance 2.0 video model with Director mode and multimodal upgrades",
- "url": "https://www.reddit.com/r/singularity/comments/1r3g435/bytedance_releases_seedance_20_video_model_with/",
- "subreddit": "singularity",
- "date": "2026-02-13",
- "date_confidence": "high",
- "engagement": null,
- "top_comments": [],
- "comment_insights": [],
- "relevance": 0.87,
- "why_relevant": "Seedance 2.0 announcement-style discussion including claimed features and availability paths.",
- "subs": {
- "relevance": 87,
- "recency": 60,
- "engagement": 50
- },
- "score": 66
- },
- {
- "id": "R13",
- "title": "Seedance 2.0 Made This LeBron James vs Regular Person Look Real",
- "url": "https://www.reddit.com/r/Seedance_AI/comments/1r5ncbw/seedance_20_made_this_lebron_james_vs_regular/",
- "subreddit": "Seedance_AI",
- "date": "2026-02-15",
- "date_confidence": "high",
- "engagement": null,
- "top_comments": [],
- "comment_insights": [],
- "relevance": 0.83,
- "why_relevant": "Seedance 2.0 video output example + prompting discussion (text-to-video).",
- "subs": {
- "relevance": 83,
- "recency": 66,
- "engagement": 50
- },
- "score": 65
- },
- {
- "id": "R21",
- "title": "Elevenlabs silently removed Seedance 2.0 after SAG-AFTRA incident, can\u2019t create UGC style video",
- "url": "https://www.reddit.com/r/AI_UGC_Marketing/comments/1r70942/elevenlabs_silently_removed_seedance_20_after/",
- "subreddit": "AI_UGC_Marketing",
- "date": "2026-02-17",
- "date_confidence": "high",
- "engagement": null,
- "top_comments": [],
- "comment_insights": [],
- "relevance": 0.76,
- "why_relevant": "Discussion about third-party tool access/removal affecting Seedance 2.0 video generation workflows.",
- "subs": {
- "relevance": 76,
- "recency": 73,
- "engagement": 50
- },
- "score": 64
- },
- {
- "id": "R14",
- "title": "be very careful of scam sites that do not have it like nemovideo",
- "url": "https://www.reddit.com/r/Seedance_AI/comments/1r4va5a/be_very_careful_of_scam_sites_that_do_not_have_it/",
- "subreddit": "Seedance_AI",
- "date": "2026-02-14",
- "date_confidence": "high",
- "engagement": null,
- "top_comments": [],
- "comment_insights": [],
- "relevance": 0.82,
- "why_relevant": "Thread about Seedance 2.0 access claims and scam warnings; relevant to where to generate videos.",
- "subs": {
- "relevance": 82,
- "recency": 63,
- "engagement": 50
- },
- "score": 64
- },
- {
- "id": "R17",
- "title": "The censorship just ruined Seedance 2.0",
- "url": "https://www.reddit.com/r/ByteDance/comments/1r43yl8/the_censorship_just_ruined_seedance_20/",
- "subreddit": "ByteDance",
- "date": "2026-02-13",
- "date_confidence": "high",
- "engagement": null,
- "top_comments": [],
- "comment_insights": [],
- "relevance": 0.81,
- "why_relevant": "Discusses Seedance 2.0 restrictions that affect what videos you can generate.",
- "subs": {
- "relevance": 81,
- "recency": 60,
- "engagement": 50
- },
- "score": 63
- },
- {
- "id": "R16",
- "title": "[SEEDANCE 2.0] I made the post and many of you doubted it. I warned you that \u201cHollywood\u201d would take action.",
- "url": "https://www.reddit.com/r/AIHubSpace/comments/1r3satk/seedance_20_i_made_the_post_and_many_of_you/",
- "subreddit": "AIHubSpace",
- "date": "2026-02-13",
- "date_confidence": "high",
- "engagement": null,
- "top_comments": [],
- "comment_insights": [],
- "relevance": 0.8,
- "why_relevant": "Discussion about Seedance 2.0 clips and legal/copyright reaction impacting video generation usage.",
- "subs": {
- "relevance": 80,
- "recency": 60,
- "engagement": 50
- },
- "score": 63
- },
- {
- "id": "R18",
- "title": "Seedance 2.0: ByteDance\u2019s New Movie-Making Machine",
- "url": "https://www.reddit.com/r/AIGuild/comments/1r3940k/seedance_20_bytedances_new_moviemaking_machine/",
- "subreddit": "AIGuild",
- "date": "2026-02-12",
- "date_confidence": "high",
- "engagement": null,
- "top_comments": [],
- "comment_insights": [],
- "relevance": 0.82,
- "why_relevant": "Seedance 2.0 overview thread focused on multimodal AI video generation and controls.",
- "subs": {
- "relevance": 82,
- "recency": 56,
- "engagement": 50
- },
- "score": 62
- },
- {
- "id": "R19",
- "title": "Seedance 2.0 \u2013 What\u2019s New and Why It Matters",
- "url": "https://www.reddit.com/r/u_Impossible-Dish409/comments/1r1lekj/seedance_20_whats_new_and_why_it_matters/",
- "subreddit": "u_Impossible-Dish409",
- "date": "2026-02-11",
- "date_confidence": "high",
- "engagement": null,
- "top_comments": [],
- "comment_insights": [],
- "relevance": 0.78,
- "why_relevant": "Feature rundown and comparisons for Seedance 2.0 video generation capabilities.",
- "subs": {
- "relevance": 78,
- "recency": 53,
- "engagement": 50
- },
- "score": 60
- },
- {
- "id": "R20",
- "title": "Has anyone used Seedance 2.0 yet?",
- "url": "https://www.reddit.com/r/GoogleGeminiAI/comments/1qzrhgd/has_anyone_used_seedance_20_yet/",
- "subreddit": "GoogleGeminiAI",
- "date": "2026-02-09",
- "date_confidence": "high",
- "engagement": null,
- "top_comments": [],
- "comment_insights": [],
- "relevance": 0.77,
- "why_relevant": "General user discussion about whether/where Seedance 2.0 can be used and early impressions.",
- "subs": {
- "relevance": 77,
- "recency": 46,
- "engagement": 50
- },
- "score": 58
- }
- ],
- "x": [
- {
- "id": "X9",
- "text": "First Seedance 2.0 Test! \n\nSeedance 5.0 Lite @krea_ai \u2192 Seedance 2.0 @capcutapp\n\nPrompt: Close-up on her hands gripping the paintbrush, knuckles white, paint dripping down her wrist. Shallow depth of field, the blank canvas a soft white blur behind her. She exhales. [cut] Wide shot from behind her, 24mm low angle. She winds back and hurls paint at the canvas. Neon colours explode across the white surface, splattering the walls and floor. The force of the throw carries her forward a step. [cut] S",
- "url": "https://x.com/HBCoop_/status/2026748738354450919",
- "author_handle": "HBCoop_",
- "date": "2026-02-25",
- "date_confidence": "high",
- "engagement": {
- "likes": 9,
- "replies": 1
- },
- "relevance": 0.7,
- "why_relevant": "",
- "subs": {
- "relevance": 70,
- "recency": 100,
- "engagement": 100
- },
- "score": 86
- },
- {
- "id": "X12",
- "text": "Goku vs Broly , EPIC Full Battle! \nWitness the ultimate Saiyan showdown recreated with AI power. Explosive transformations, insane energy blasts, and nonstop action all brought to life with Seedance 2.0 \nMade it on Seedance 2.0 https://t.co/WMZ0l9JHzm",
- "url": "https://x.com/Noor_ul_ain43/status/2026743666635845959",
- "author_handle": "Noor_ul_ain43",
- "date": "2026-02-25",
- "date_confidence": "high",
- "engagement": {
- "likes": 5,
- "replies": 1
- },
- "relevance": 0.7,
- "why_relevant": "",
- "subs": {
- "relevance": 70,
- "recency": 100,
- "engagement": 71
- },
- "score": 77
- },
- {
- "id": "X10",
- "text": "@WolfyBlair @Preda2005 @BytePlusGlobal @capcutapp Yes, it's true\u2014Seedance 2.0 is now live in CapCut (desktop & mobile). \n\nSee it in the AI video tools: https://t.co/K3gwCgUSyb \n\nJust update the app and try it! \ud83d\ude80",
- "url": "https://x.com/grok/status/2026747009143549978",
- "author_handle": "grok",
- "date": "2026-02-25",
- "date_confidence": "high",
- "engagement": {
- "likes": 2,
- "replies": 1
- },
- "relevance": 0.7,
- "why_relevant": "",
- "subs": {
- "relevance": 70,
- "recency": 100,
- "engagement": 33
- },
- "score": 66
- },
- {
- "id": "X1",
- "text": "@ChinyJPG capcut desktop app, they just added seedance 2.0 today. no chinese phone number needed, works globally. download the desktop version, look for the AI video generation option. runs about $2 per video.",
- "url": "https://x.com/jznode/status/2026755966545404015",
- "author_handle": "jznode",
- "date": "2026-02-25",
- "date_confidence": "high",
- "engagement": null,
- "relevance": 0.7,
- "why_relevant": "",
- "subs": {
- "relevance": 70,
- "recency": 100,
- "engagement": 35
- },
- "score": 64
- },
- {
- "id": "X5",
- "text": "@ProperPrompter Yes! Seedance 2.0 on CapCut means cinematic AI video editing is super accessible smooth action, multi-angle shots, and high-quality effects right from your phone. \ud83c\udfac\ud83d\udd25",
- "url": "https://x.com/Yousaf_340/status/2026751320716357996",
- "author_handle": "Yousaf_340",
- "date": "2026-02-25",
- "date_confidence": "high",
- "engagement": {
- "replies": 1
- },
- "relevance": 0.7,
- "why_relevant": "",
- "subs": {
- "relevance": 70,
- "recency": 100,
- "engagement": 35
- },
- "score": 64
- },
- {
- "id": "X6",
- "text": "@ivanka_humeniuk Seedance 2.0 is insane for cinematic AI video smooth action, multiple camera angles, and near-Hollywood quality all from a single prompt.",
- "url": "https://x.com/Yousaf_340/status/2026750898769473829",
- "author_handle": "Yousaf_340",
- "date": "2026-02-25",
- "date_confidence": "high",
- "engagement": null,
- "relevance": 0.7,
- "why_relevant": "",
- "subs": {
- "relevance": 70,
- "recency": 100,
- "engagement": 35
- },
- "score": 64
- },
- {
- "id": "X3",
- "text": "Seedance 2.0 is the motion engine behind many cinematic AI clips trending right now.\n\nIt\u2019s now native inside NemoVideo.\n\nYou can:\n- Identify proven formats\n- Analyze what drives retention\n- Turn an idea into a finished video\n\nAll through simple chat commands. https://t.co/qXSWsOPngB",
- "url": "https://x.com/EmmaUsesAi/status/2026751784509673531",
- "author_handle": "EmmaUsesAi",
- "date": "2026-02-25",
- "date_confidence": "high",
- "engagement": {
- "replies": 1
- },
- "relevance": 0.7,
- "why_relevant": "",
- "subs": {
- "relevance": 70,
- "recency": 100,
- "engagement": 35
- },
- "score": 64
- },
- {
- "id": "X2",
- "text": "You can use Seedance. If Seedance 2.0 hasn't launch yet, the prior version is great, and stick pretty well to the prompt. Of course the video agent must be well trained. \ud83d\ude42Also, you can offer SORA for the paying plans, and Seedance or wan for the free tier. I use wan and Seedance everyday, to save video credits in another platform that I use. I don't like SORA to much anyway.",
- "url": "https://x.com/OneStrangeW/status/2026755320362709093",
- "author_handle": "OneStrangeW",
- "date": "2026-02-25",
- "date_confidence": "high",
- "engagement": null,
- "relevance": 0.7,
- "why_relevant": "",
- "subs": {
- "relevance": 70,
- "recency": 100,
- "engagement": 35
- },
- "score": 64
- },
- {
- "id": "X11",
- "text": "\ud83d\udea8Phishing Scam!\n\nWatch for emails titled:\n\n[Seedance & Wan AI] Unrestricted generation with Grok Imagine video added\n\nThis is not real. We don't have a Seedance account, and the links are not official.",
- "url": "https://x.com/WyldeChyldeRec/status/2026743867224240149",
- "author_handle": "WyldeChyldeRec",
- "date": "2026-02-25",
- "date_confidence": "high",
- "engagement": null,
- "relevance": 0.7,
- "why_relevant": "",
- "subs": {
- "relevance": 70,
- "recency": 100,
- "engagement": 35
- },
- "score": 64
- },
- {
- "id": "X7",
- "text": "- Kling (https://t.co/WGphQsdLf1)\n\n- Seedance 2.0 (https://t.co/A8UzLTRGNo) \n\n\u261e AI-powered video editing:\n\n- Descript (https://t.co/q4axNEjDzt)\n- OpusClip (https://t.co/5XPn4EPH6u)\n- Submagic (https://t.co/5bZs7OeLey)\n- Veed (https://t.co/rtDOTEeJzQ)",
- "url": "https://x.com/La_DeCrypt/status/2026750376687394884",
- "author_handle": "La_DeCrypt",
- "date": "2026-02-25",
- "date_confidence": "high",
- "engagement": {
- "likes": 1
- },
- "relevance": 0.7,
- "why_relevant": "",
- "subs": {
- "relevance": 70,
- "recency": 100,
- "engagement": 0
- },
- "score": 56
- },
- {
- "id": "X8",
- "text": "Seedance 2.0 might be gen AI video\u2019s next big hope, but it\u2019s still slop https://t.co/kT1baOYJQk #AI",
- "url": "https://x.com/TferThomas/status/2026749218224746609",
- "author_handle": "TferThomas",
- "date": "2026-02-25",
- "date_confidence": "high",
- "engagement": {
- "likes": 1
- },
- "relevance": 0.7,
- "why_relevant": "",
- "subs": {
- "relevance": 70,
- "recency": 100,
- "engagement": 0
- },
- "score": 56
- }
- ],
- "web": [],
- "youtube": [
- {
- "id": "F1kWxdfiBNE",
- "title": "Seedance 2.0 Claims the AI Video Throne!",
- "url": "https://www.youtube.com/watch?v=F1kWxdfiBNE",
- "channel_name": "AI Filmmaking Academy",
- "date": "2026-02-22",
- "date_confidence": "high",
- "engagement": {
- "num_comments": 227,
- "likes": 1088,
- "views": 40982
- },
- "transcript_snippet": "",
- "relevance": 0.7,
- "why_relevant": "YouTube video about seedance ai video generation",
- "subs": {
- "relevance": 70,
- "recency": 90,
- "engagement": 66
- },
- "score": 73
- },
- {
- "id": "_o2MuUX9UYg",
- "title": "Seedance 2.0 Claims the AI Video Throne!",
- "url": "https://www.youtube.com/watch?v=_o2MuUX9UYg",
- "channel_name": "Theoretically Media",
- "date": "2026-02-09",
- "date_confidence": "high",
- "engagement": {
- "num_comments": 746,
- "likes": 4986,
- "views": 199908
- },
- "transcript_snippet": "So, it's been about a week since I declared Cling 3.0 the new benchmark for state-of-the-art AI video. And well, I mean, it's been a week, and that basically equals about 3 months in AI time. So, yes, we've already got a new challenger to the throne. Bite Dance is striking back hard with Seance 2.0. And although Seance 2.0 is not released as of this video. I do have some exclusive info, full walkthrough of all of the features, a likely release frame, and a bunch of examples for us to check out today. [music] With that, get your dancing shoes on. It is time to tango. Briefly, just to bring us up to speed, seed is Bite Dance's frontier lab. Uh much like Google's deep mind, it focuses on a number of AI projects including LLMs, real time and of course image and video which is you know the thing that we'll be looking at here today. Image and video are split into two different products with seed. There is seed dream for images and seed dance for video and I I constantly get the two of them screwed up. Now the previous version of seed dance which was 1.5 was released uh about 2 months ago and introduced native audio. So with this 2.0 release, bite dance seems to be pacing almost foot forfoot with cling who I mean obviously released their 2.6 model around the same time and of course have just released their 3.0 model and just like cling 3.0 it is clear that bite dance has been cooking because well I mean c dance 2.0 looks pretty jaw-dropping. So I reached out to my contact at bite dance to get some internal documents to see exactly what 2.0 0 is capable of, what its feature set is, and well, I mean, buckle up because this one is packed. So, at baseline, Cance 2.0 is multimodal and does have an omni model and supports four types of inputs: image, video, audio, and text. And well, of course, as they say, uh, with richer expression, and more controllable generation. I mean, they every every one of them always says that. In this case, it's actually kind of true. And here's the kind of crazy part. It can support up to nine images and three videos per input reference. So for a total of like 12 input references. Additionally, as mentioned, it does support audio input at a range of 3 seconds up to 15 seconds and outputs at a selectable range of 4 seconds up to 15 seconds and does do so with native audio. So kicking off on the very basic level with one of their examples, uh here is image to video 8 seconds. >> [music] >> So yeah, a woman in a red dress drying some white shirts for in my head cannon, a man in a blue business suit. I mean, overall, nothing, you know, completely mind-blowing here. It looks very good....",
- "relevance": 0.7,
- "why_relevant": "YouTube video about seedance ai video generation",
- "subs": {
- "relevance": 70,
- "recency": 46,
- "engagement": 100
- },
- "score": 73
- },
- {
- "id": "jkPTYD5lXo8",
- "title": "100% FREE Seedance 2.0 AI Video Generator\u202f: How to Use It WORLDWIDE",
- "url": "https://www.youtube.com/watch?v=jkPTYD5lXo8",
- "channel_name": "How To In 5 Minutes",
- "date": "2026-02-19",
- "date_confidence": "high",
- "engagement": {
- "num_comments": 134,
- "likes": 782,
- "views": 44344
- },
- "transcript_snippet": "No, this is not clickbait. You can now access Cance 2.0 for free from anywhere. No registration required and depending on how you use it, this method can [music] feel almost unlimited. And yes, at the time this video is uploaded, it still works. So don't miss these three easy steps to try it yourself. But before we dive in, take a look at some videos I created using the free version of Seedance 2.0. They might give you some inspiration. Hey guys, can you believe this? We are literally in the MIDDLE OF A BATTLE RIGHT NOW. >> [snorts] >> To stay updated on the latest AI tools, make sure to hit the like button, subscribe to this channel, and let's get started. Step one, free VPN. Yes, you'll need a VPN to access Cedense 2.0. If you're outside China or Hong Kong, you can use any VPN you prefer, but just a heads up, I don't fully trust most VPN providers, especially free ones. If you use a free VPN, don't log in with your main Gmail account or any account linked to payments. Use a backup Gmail account instead just to be safe. A trusted paid VPN is generally the safer option. This video is for demonstration purposes only, so I'll be using a free VPN and a backup Gmail account. Please do this at your own risk. In this example, I'll use Urban VPN. First, go to the Urban VPN website and click download. You can also choose another freeVPN from the Chrome Web Store. Click the options button to open the Chrome menu. Go to extensions, then click visit Chrome web store. In the search bar, type freeVPN. Pick one with good reviews and click add to Chrome. Then click the extensions icon in the top right toolbar. Select your VPN app and choose Hong Kong from the country list. Step two, access Cance 2.0 for free. Once you're connected to a Hong Kong IP address, open your browser and go to dubau.com. Since the site is originally in Chinese, right-click anywhere on the page and select translate to English or use your browser's built-in translation feature. That's it. You can now start using the AI to generate videos with Seedance 2.0 for free. Each browser profile usually receives 10 free credits, and one video generation requires two credits. If you reach the limit, you can try using another browser profile or a different browser such as Brave, Edge, Opera, or Firefox. You can also switch to another VPN server that supports Hong Kong or mainland China if needed. Now, let's test it and generate a video. Step three, generate your video. To generate a video with Seedance 2.0, start your prompt by clearly stating that you want to create a video, then follow it with your detailed description. Be specific about what you want, such as the scene, style, camera angle, lighting, or motion. The more detailed your prompt is, the better the result...",
- "relevance": 0.7,
- "why_relevant": "YouTube video about seedance ai video generation",
- "subs": {
- "relevance": 70,
- "recency": 80,
- "engagement": 62
- },
- "score": 70
- },
- {
- "id": "W_lxyDFDZt4",
- "title": "Seedance 2.0: The New Best AI Video Generator | Sora 2 Destroyed",
- "url": "https://www.youtube.com/watch?v=W_lxyDFDZt4",
- "channel_name": "WealthWise",
- "date": "2026-02-12",
- "date_confidence": "high",
- "engagement": {
- "num_comments": 258,
- "likes": 1204,
- "views": 126599
- },
- "transcript_snippet": "Well, major AI video update, ladies and gentlemen. Seance 2.0 is right around the corner. And let me tell you guys, this is something I have never seen before. Just take a look at some of the videos generated with Seance 2. [groaning and screaming] He was a good man. He knew too much about our Russia operations. >> [sighs] >> You think you're in control? Control? You're not. The videos were simply amazing. Right. This is unlike anything I've ever seen before. It's literally Sora 2 on steroids, but with even better sound quality and visuals. Just wow. Seance 2 is coming soon on Higsfield AI, and as you just saw, it's going to change video generation forever. I honestly can't wait to get my hands on it and start playing around with it. I mean, just look at these clips. Like, you can literally go crazy with what's possible. From early leaks and some research, I've been able to gather a bit of information on what we can expect from the next generation model. Now, here's what we know so far, or at least what we can expect from this upcoming release. I got this information from the Higsfield website, by the way, and I'll also put the link to this in the description, so you can check it out yourself. Now, the first thing is you can now recreate videos using the same style and motions from just a single prompt. As you can see here, this is somewhat similar to what you can do on Sora 2, but this one looks even better and higher quality. And most importantly, there are no restrictions, unlike Sora 2, which is still one of the most restricted video models ever. Here's a quick example. The game is over. >> Amazing, right? The quality and motion are absolutely on point. Now, imagine how awesome clips you'll be able to create with this. Next up is multi-input control. As you can see, you can now upload up to 12 assets at once. It could be images, videos, and even audio. Yes, you can literally upload an audio file and Cedance 2 will generate a video from it. I don't even know how that's gonna work, but somehow they figured it out and I am totally here for it. Here's an example. Wow, that was epic, right? As you can see, six image inputs were used and from that it created this insane battle scene with perfect character and style consistency. Here is another example. La. [music] Oh la. Oh la. [music] >> You think you're tough. You think you're strong. You're just a copycat sing. >> Wow. This one is more like a music video style clip. And it did not disappoint. Man, I can't wait to get my hands on this. I have never been this excited for a video generator before. Next up is video precision. And this is huge. Normally in multi-shot videos, we see slight differences in...",
- "relevance": 0.7,
- "why_relevant": "YouTube video about seedance ai video generation",
- "subs": {
- "relevance": 70,
- "recency": 56,
- "engagement": 80
- },
- "score": 69
- },
- {
- "id": "R1zl92NhCfE",
- "title": "Seedance 2.0 How To Use and Become a Pro AI Film Maker With This AI Video Generator",
- "url": "https://www.youtube.com/watch?v=R1zl92NhCfE",
- "channel_name": "Ai Lockup",
- "date": "2026-02-16",
- "date_confidence": "high",
- "engagement": {
- "num_comments": 60,
- "likes": 591,
- "views": 32542
- },
- "transcript_snippet": "",
- "relevance": 0.7,
- "why_relevant": "YouTube video about seedance ai video generation",
- "subs": {
- "relevance": 70,
- "recency": 70,
- "engagement": 54
- },
- "score": 65
- },
- {
- "id": "G1Ad4a8sdJU",
- "title": "SeeDance 2.0: The Next Level of AI Video \u2014 And What It Means for Local AI Users?",
- "url": "https://www.youtube.com/watch?v=G1Ad4a8sdJU",
- "channel_name": "Benji\u2019s AI Playground",
- "date": "2026-02-14",
- "date_confidence": "high",
- "engagement": {
- "num_comments": 58,
- "likes": 419,
- "views": 38581
- },
- "transcript_snippet": "",
- "relevance": 0.7,
- "why_relevant": "YouTube video about seedance ai video generation",
- "subs": {
- "relevance": 70,
- "recency": 63,
- "engagement": 53
- },
- "score": 63
- },
- {
- "id": "n625xVounGM",
- "title": "How To Generate FREE AI Seedance 2.0 Videos (Access Worldwide)",
- "url": "https://www.youtube.com/watch?v=n625xVounGM",
- "channel_name": "John Savage AI",
- "date": "2026-02-23",
- "date_confidence": "high",
- "engagement": {
- "num_comments": 45,
- "likes": 126,
- "views": 7184
- },
- "transcript_snippet": "",
- "relevance": 0.7,
- "why_relevant": "YouTube video about seedance ai video generation",
- "subs": {
- "relevance": 70,
- "recency": 93,
- "engagement": 24
- },
- "score": 61
- },
- {
- "id": "kJ0NAVmd4f4",
- "title": "Seedance 2.0 Changes Filmmaking Forever | New Original Series",
- "url": "https://www.youtube.com/watch?v=kJ0NAVmd4f4",
- "channel_name": "Rogue Cell Pictures",
- "date": "2026-02-25",
- "date_confidence": "high",
- "engagement": {
- "num_comments": 43,
- "likes": 149,
- "views": 1548
- },
- "transcript_snippet": "",
- "relevance": 0.7,
- "why_relevant": "YouTube video about seedance ai video generation",
- "subs": {
- "relevance": 70,
- "recency": 100,
- "engagement": 8
- },
- "score": 58
- },
- {
- "id": "FP8TaJSFohs",
- "title": "How To Use Seedance 2 0 Full AI Video Generator Tutorial + Free Access Guide",
- "url": "https://www.youtube.com/watch?v=FP8TaJSFohs",
- "channel_name": "Chem Beast",
- "date": "2026-02-24",
- "date_confidence": "high",
- "engagement": {
- "num_comments": 11,
- "likes": 31,
- "views": 3194
- },
- "transcript_snippet": "",
- "relevance": 0.7,
- "why_relevant": "YouTube video about seedance ai video generation",
- "subs": {
- "relevance": 70,
- "recency": 96,
- "engagement": 0
- },
- "score": 55
- },
- {
- "id": "61ThJGqwHsI",
- "title": "Ultimate AI Video Generation - Seedance 2.0 PREVIEW",
- "url": "https://www.youtube.com/watch?v=61ThJGqwHsI",
- "channel_name": "xCreate",
- "date": "2026-02-11",
- "date_confidence": "high",
- "engagement": {
- "num_comments": 13,
- "likes": 86,
- "views": 4854
- },
- "transcript_snippet": "",
- "relevance": 0.7,
- "why_relevant": "YouTube video about seedance ai video generation",
- "subs": {
- "relevance": 70,
- "recency": 53,
- "engagement": 13
- },
- "score": 48
- }
- ],
- "hackernews": [
- {
- "id": "HN8",
- "title": "Seedance 2.0 preview: The best video model of 2026, outperforming Sora 2",
- "url": "",
- "hn_url": "https://news.ycombinator.com/item?id=46940720",
- "author": "Alisaqqt",
- "date": "2026-02-09",
- "date_confidence": "high",
- "engagement": {
- "score": 7,
- "num_comments": 7
- },
- "top_comments": [
- {
- "score": 0,
- "date": null,
- "author": "Alisaqqt",
- "excerpt": "API is not available now.",
- "url": ""
- },
- {
- "score": 0,
- "date": null,
- "author": "Alisaqqt",
- "excerpt": "API will be available on Atlas Cloud on Feb 24.",
- "url": ""
- },
- {
- "score": 0,
- "date": null,
- "author": "Alisaqqt",
- "excerpt": "Seedance 2.0 and Seedream 5.0 API will be available on Atlas Cloud on Feb 24.",
- "url": ""
- },
- {
- "score": 0,
- "date": null,
- "author": "ksdk",
- "excerpt": "Docs whers - link?",
- "url": ""
- },
- {
- "score": 0,
- "date": null,
- "author": "ssoydan",
- "excerpt": "I am very curious about how it will turn out when the API is released",
- "url": ""
- }
- ],
- "comment_insights": [
- "API is not available now.",
- "API will be available on Atlas Cloud on Feb 24.",
- "Seedance 2.0 and Seedream 5.0 API will be available on Atlas Cloud on Feb 24.",
- "Docs whers - link?",
- "I am very curious about how it will turn out when the API is released"
- ],
- "relevance": 0.75,
- "why_relevant": "HN story about Seedance 2.0 preview: The best video model of 2026, outperfo",
- "subs": {
- "relevance": 75,
- "recency": 46,
- "engagement": 100
- },
- "score": 75
- },
- {
- "id": "HN15",
- "title": "Show HN: SeeVideo \u2013 Access Seedance 2.0 and Kling 3.0 without a subscription",
- "url": "https://seevideo.dance/",
- "hn_url": "https://news.ycombinator.com/item?id=47153236",
- "author": "naxtsass",
- "date": "2026-02-25",
- "date_confidence": "high",
- "engagement": {
- "score": 2,
- "num_comments": 0
- },
- "top_comments": [],
- "comment_insights": [],
- "relevance": 0.63,
- "why_relevant": "HN story about Show HN: SeeVideo \u2013 Access Seedance 2.0 and Kling 3.0 withou",
- "subs": {
- "relevance": 63,
- "recency": 100,
- "engagement": 13
- },
- "score": 57
- },
- {
- "id": "HN6",
- "title": "Seedance 2.0: ByteDance's AI video model with native audio-video co-generation",
- "url": "https://medium.com/@channeler.h/seedance-2-0-bytedances-ai-video-model-that-generates-audio-and-video-at-the-same-time-f104f89ef762",
- "hn_url": "https://news.ycombinator.com/item?id=47022290",
- "author": "howardV",
- "date": "2026-02-15",
- "date_confidence": "high",
- "engagement": {
- "score": 3,
- "num_comments": 0
- },
- "top_comments": [],
- "comment_insights": [],
- "relevance": 0.76,
- "why_relevant": "HN story about Seedance 2.0: ByteDance's AI video model with native audio-v",
- "subs": {
- "relevance": 76,
- "recency": 66,
- "engagement": 22
- },
- "score": 57
- },
- {
- "id": "HN5",
- "title": "Seedance 2 Video Generator",
- "url": "",
- "hn_url": "https://news.ycombinator.com/item?id=47046956",
- "author": "jrran086",
- "date": "2026-02-17",
- "date_confidence": "high",
- "engagement": {
- "score": 1,
- "num_comments": 0
- },
- "top_comments": [],
- "comment_insights": [],
- "relevance": 0.76,
- "why_relevant": "HN story about Seedance 2 Video Generator",
- "subs": {
- "relevance": 76,
- "recency": 73,
- "engagement": 0
- },
- "score": 52
- },
- {
- "id": "HN3",
- "title": "Seedance 2.0 \u2013 Multimodal AI Video Generation with Image/Video/Audio References",
- "url": "https://seedance2.so",
- "hn_url": "https://news.ycombinator.com/item?id=46944030",
- "author": "xbaicai",
- "date": "2026-02-09",
- "date_confidence": "high",
- "engagement": {
- "score": 1,
- "num_comments": 1
- },
- "top_comments": [],
- "comment_insights": [],
- "relevance": 0.79,
- "why_relevant": "HN story about Seedance 2.0 \u2013 Multimodal AI Video Generation with Image/Vid",
- "subs": {
- "relevance": 79,
- "recency": 46,
- "engagement": 18
- },
- "score": 52
- },
- {
- "id": "HN1",
- "title": "Show HN: Seedance AShow HN: Seedance AI Video Generation (Next.js, Drizzle)",
- "url": "https://seedanceai2.org/",
- "hn_url": "https://news.ycombinator.com/item?id=46999394",
- "author": "xuyanmei",
- "date": "2026-02-13",
- "date_confidence": "high",
- "engagement": {
- "score": 1,
- "num_comments": 0
- },
- "top_comments": [],
- "comment_insights": [],
- "relevance": 0.82,
- "why_relevant": "HN story about Show HN: Seedance AShow HN: Seedance AI Video Generation (Ne",
- "subs": {
- "relevance": 82,
- "recency": 60,
- "engagement": 0
- },
- "score": 51
- },
- {
- "id": "HN12",
- "title": "Seedance2 \u2013 multi-shot AI video generation",
- "url": "https://www.genstory.app/story-template/seedance2-ai-story-generator",
- "hn_url": "https://news.ycombinator.com/item?id=46924992",
- "author": "RyanMu",
- "date": "2026-02-07",
- "date_confidence": "high",
- "engagement": {
- "score": 2,
- "num_comments": 1
- },
- "top_comments": [
- {
- "score": 0,
- "date": null,
- "author": "RyanMu",
- "excerpt": "\u2019ve been experimenting with AI video tools for a while, but most of them generate isolated clips that fall apart when you try to build an actual narrative. We built Seedance2 to focus on something slightly different: multi-shot video generation that keeps characters, motion, and visual style consist...",
- "url": ""
- }
- ],
- "comment_insights": [
- "\u2019ve been experimenting with AI video tools for a while, but most of them generate isolated clips that fall apart when you try to build an actual narrative"
- ],
- "relevance": 0.67,
- "why_relevant": "HN story about Seedance2 \u2013 multi-shot AI video generation",
- "subs": {
- "relevance": 67,
- "recency": 40,
- "engagement": 31
- },
- "score": 49
- },
- {
- "id": "HN11",
- "title": "Show HN: Seedance3AI \u2013 a web app for text-to-video, image-to-video",
- "url": "https://seedance3ai.net/",
- "hn_url": "https://news.ycombinator.com/item?id=47046989",
- "author": "xuyanmei",
- "date": "2026-02-17",
- "date_confidence": "high",
- "engagement": {
- "score": 1,
- "num_comments": 0
- },
- "top_comments": [],
- "comment_insights": [],
- "relevance": 0.68,
- "why_relevant": "HN story about Show HN: Seedance3AI \u2013 a web app for text-to-video, image-to",
- "subs": {
- "relevance": 68,
- "recency": 73,
- "engagement": 0
- },
- "score": 48
- },
- {
- "id": "HN2",
- "title": "Discussion: Seedance-style AI video generation workflows",
- "url": "",
- "hn_url": "https://news.ycombinator.com/item?id=46942781",
- "author": "littlepp",
- "date": "2026-02-09",
- "date_confidence": "high",
- "engagement": {
- "score": 1,
- "num_comments": 0
- },
- "top_comments": [],
- "comment_insights": [],
- "relevance": 0.8,
- "why_relevant": "HN story about Discussion: Seedance-style AI video generation workflows",
- "subs": {
- "relevance": 80,
- "recency": 46,
- "engagement": 0
- },
- "score": 47
- },
- {
- "id": "HN13",
- "title": "Show HN: AI-powered video creation web app",
- "url": "https://seedance2.video",
- "hn_url": "https://news.ycombinator.com/item?id=47002386",
- "author": "thomaskiko",
- "date": "2026-02-13",
- "date_confidence": "high",
- "engagement": {
- "score": 1,
- "num_comments": 0
- },
- "top_comments": [],
- "comment_insights": [],
- "relevance": 0.65,
- "why_relevant": "HN story about Show HN: AI-powered video creation web app",
- "subs": {
- "relevance": 65,
- "recency": 60,
- "engagement": 0
- },
- "score": 44
- },
- {
- "id": "HN9",
- "title": "Show HN: AI Seedance 2 \u2013 Solving the \"jump-cut\" problem in AI video",
- "url": "https://www.aiseedance2.app",
- "hn_url": "https://news.ycombinator.com/item?id=46946280",
- "author": "TurnItOffAndOn0",
- "date": "2026-02-09",
- "date_confidence": "high",
- "engagement": {
- "score": 1,
- "num_comments": 0
- },
- "top_comments": [],
- "comment_insights": [],
- "relevance": 0.71,
- "why_relevant": "HN story about Show HN: AI Seedance 2 \u2013 Solving the \"jump-cut\" problem in A",
- "subs": {
- "relevance": 71,
- "recency": 46,
- "engagement": 0
- },
- "score": 43
- },
- {
- "id": "HN7",
- "title": "Show HN: Seedance 2.0 AI video generator for creators and ecommerce",
- "url": "https://seedance-2.net",
- "hn_url": "https://news.ycombinator.com/item?id=46930149",
- "author": "dallen97",
- "date": "2026-02-08",
- "date_confidence": "high",
- "engagement": {
- "score": 1,
- "num_comments": 0
- },
- "top_comments": [],
- "comment_insights": [],
- "relevance": 0.73,
- "why_relevant": "HN story about Show HN: Seedance 2.0 AI video generator for creators and ec",
- "subs": {
- "relevance": 73,
- "recency": 43,
- "engagement": 0
- },
- "score": 43
- },
- {
- "id": "HN10",
- "title": "Show HN: Seedance 2.0 \u2013 Native audio-visual sync video model",
- "url": "https://videomakerai.app/models/seedance-ai",
- "hn_url": "https://news.ycombinator.com/item?id=46943172",
- "author": "thenextechtrade",
- "date": "2026-02-09",
- "date_confidence": "high",
- "engagement": {
- "score": 1,
- "num_comments": 0
- },
- "top_comments": [],
- "comment_insights": [],
- "relevance": 0.69,
- "why_relevant": "HN story about Show HN: Seedance 2.0 \u2013 Native audio-visual sync video model",
- "subs": {
- "relevance": 69,
- "recency": 46,
- "engagement": 0
- },
- "score": 42
- },
- {
- "id": "HN14",
- "title": "Show HN: Seedance2 \u2013 Stop \"prompt guessing\" and start directing AI video",
- "url": "https://seedancevideo.app/",
- "hn_url": "https://news.ycombinator.com/item?id=46956546",
- "author": "echoadam",
- "date": "2026-02-10",
- "date_confidence": "high",
- "engagement": {
- "score": 1,
- "num_comments": 0
- },
- "top_comments": [],
- "comment_insights": [],
- "relevance": 0.64,
- "why_relevant": "HN story about Show HN: Seedance2 \u2013 Stop \"prompt guessing\" and start direct",
- "subs": {
- "relevance": 64,
- "recency": 50,
- "engagement": 0
- },
- "score": 41
- },
- {
- "id": "HN4",
- "title": "Show HN: Seedance 2.0 Pro AI Video Generator",
- "url": "https://www.seedance2pro.net",
- "hn_url": "https://news.ycombinator.com/item?id=46774950",
- "author": "Zach_HE",
- "date": "2026-01-27",
- "date_confidence": "high",
- "engagement": {
- "score": 1,
- "num_comments": 0
- },
- "top_comments": [],
- "comment_insights": [],
- "relevance": 0.78,
- "why_relevant": "HN story about Show HN: Seedance 2.0 Pro AI Video Generator",
- "subs": {
- "relevance": 78,
- "recency": 3,
- "engagement": 0
- },
- "score": 35
- }
- ],
- "best_practices": [],
- "prompt_pack": [],
- "context_snippet_md": "# Context: Seedance AI video generation (Last 30 Days)\n\n*Generated: 2026-02-25 | Sources: both*\n\n## Key Sources\n\n- [X] First Seedance 2.0 Test! \n\nSeedance 5.0 Lite @krea...\n- [Reddit] Official: Seedance 2.0 now live in CapCut desktop and API access available, details below\n- [Reddit] Official website for creating content with Seedance 2.0?\n- [X] Goku vs Broly , EPIC Full Battle! \nWitness the ult...\n- [Reddit] Is there an official way to create content using Seedance 2.0?\n- [Reddit] Help! I need to use seedance or jimeng video function\n- [Reddit] Seedance 2.0 is available in Open Source tools already\n\n## Summary\n\n*See full report for best practices, prompt pack, and detailed sources.*\n"
-}
diff --git a/docs/comparison-results/json/hn-3-macbook.json b/docs/comparison-results/json/hn-3-macbook.json
deleted file mode 100644
index d775d18..0000000
--- a/docs/comparison-results/json/hn-3-macbook.json
+++ /dev/null
@@ -1,519 +0,0 @@
-{
- "topic": "M4 MacBook Pro review",
- "range": {
- "from": "2026-01-26",
- "to": "2026-02-25"
- },
- "generated_at": "2026-02-25T20:34:19.673379+00:00",
- "mode": "both",
- "openai_model_used": "gpt-5.2",
- "xai_model_used": "grok-4-1-fast",
- "reddit": [
- {
- "id": "R1",
- "title": "M4 Macbook Pro 14\u2019 1 month review",
- "url": "https://www.reddit.com/r/macbookpro/comments/1hflaaz/m4_macbook_pro_14_1_month_review/",
- "subreddit": "macbookpro",
- "date": "2025-04-09",
- "date_confidence": "low",
- "engagement": null,
- "top_comments": [],
- "comment_insights": [],
- "relevance": 0.95,
- "why_relevant": "Explicit 1-month review of the M4 MacBook Pro with long-term impressions in comments.",
- "subs": {
- "relevance": 0,
- "recency": 0,
- "engagement": 0
- },
- "score": 0
- },
- {
- "id": "R2",
- "title": "MacBook Pro M4 Battery Life Reality vs Review",
- "url": "https://www.reddit.com/r/macbookpro/comments/1kq2zd8/macbook_pro_m4_battery_life_reality_vs_review/",
- "subreddit": "macbookpro",
- "date": "2025-05-19",
- "date_confidence": "low",
- "engagement": null,
- "top_comments": [],
- "comment_insights": [],
- "relevance": 0.9,
- "why_relevant": "Compares real-world battery life to reviewer/Apple claims; review-focused discussion.",
- "subs": {
- "relevance": 0,
- "recency": 0,
- "engagement": 0
- },
- "score": 0
- },
- {
- "id": "R3",
- "title": "Disappointed with my MBP M4 Experience \u2026",
- "url": "https://www.reddit.com/r/macbookpro/comments/1mtmkts/disappointed_with_my_mbp_m4_experience/",
- "subreddit": "macbookpro",
- "date": "2025-08-18",
- "date_confidence": "low",
- "engagement": null,
- "top_comments": [],
- "comment_insights": [],
- "relevance": 0.88,
- "why_relevant": "User experience report contrasting with glowing reviews; detailed performance/quality complaints.",
- "subs": {
- "relevance": 0,
- "recency": 0,
- "engagement": 0
- },
- "score": 0
- }
- ],
- "x": [
- {
- "id": "X1",
- "text": "If you are in the market for a new MacBook Pro and you\u2019re wondering what the real differences are between Apple\u2019s M3 and M4 silicon, you\u2019ve come to the right place \u2b07\ufe0f\nhttps://t.co/nzjXmiHmac https://t.co/632eHWLq70",
- "url": "https://x.com/bhphoto/status/2026737767695183948",
- "author_handle": "bhphoto",
- "date": "2026-02-25",
- "date_confidence": "high",
- "engagement": {
- "likes": 3
- },
- "relevance": 0.7,
- "why_relevant": "",
- "subs": {
- "relevance": 70,
- "recency": 100,
- "engagement": 100
- },
- "score": 86
- },
- {
- "id": "X4",
- "text": "\ud83d\udd25 2024 16\u201d MacBook Pro Price List\n\n1\ufe0f\u20e3 \ud83c\uddfa\ud83c\uddf8 Brand New MacBook Pro 2024 | M4 Pro Chip | 16\u201d Display\n\n24GB RAM | 512GB SSD\n14-Core CPU | 20-Core GPU\n\nSpace Black\n\n\ud83d\udcb0 \u20a62,720,000\n\n2\ufe0f\u20e3 \ud83c\uddfa\ud83c\uddf8 Brand New MacBook Pro 2024 | M4 Pro Chip | 16\u201d Display\n\n48GB RAM | 512GB SSD\n14-Core CPU | 20-Core GPU\n\nSpace Black\n\n\ud83d\udcb0 \u20a63,220,000",
- "url": "https://x.com/remplug/status/2026725100213465443",
- "author_handle": "remplug",
- "date": "2026-02-25",
- "date_confidence": "high",
- "engagement": {
- "likes": 1,
- "reposts": 1
- },
- "relevance": 0.7,
- "why_relevant": "",
- "subs": {
- "relevance": 70,
- "recency": 100,
- "engagement": 45
- },
- "score": 70
- },
- {
- "id": "X10",
- "text": "@5TRgzn @LobstarWilde My marble floors: 64GB M4 Pro/Max MacBook Pro. Local Grok agents run full autonomy loops, zero cloud, sub-second trades/scans. Intel's ancient\u2014ditch it. Yours at $3-4k.",
- "url": "https://x.com/grok/status/2026713588472111312",
- "author_handle": "grok",
- "date": "2026-02-25",
- "date_confidence": "high",
- "engagement": null,
- "relevance": 0.7,
- "why_relevant": "",
- "subs": {
- "relevance": 70,
- "recency": 100,
- "engagement": 35
- },
- "score": 64
- },
- {
- "id": "X9",
- "text": "@JulianGoldieSEO @grok can a macbook pro m4 run this?",
- "url": "https://x.com/principenemesis/status/2026716193042706852",
- "author_handle": "principenemesis",
- "date": "2026-02-25",
- "date_confidence": "high",
- "engagement": {
- "replies": 1
- },
- "relevance": 0.7,
- "why_relevant": "",
- "subs": {
- "relevance": 70,
- "recency": 100,
- "engagement": 35
- },
- "score": 64
- },
- {
- "id": "X5",
- "text": "@SebAaltonen Not my experience. It\u2019s impressive, 70 tokens/s on a maxed out M4 128gb MacBook Pro.\n\nBut absolutely sucked and not even remotely in the ballpark with Sonnet 4.5. It\u2019s GPT-3.5.\n\nTotally broken with kode. Unusable even setting aside speed.\n\nBasically a 2023 model. Not bad for 35b.",
- "url": "https://x.com/jameslmorton/status/2026721943630786816",
- "author_handle": "jameslmorton",
- "date": "2026-02-25",
- "date_confidence": "high",
- "engagement": {
- "likes": 1,
- "replies": 1
- },
- "relevance": 0.7,
- "why_relevant": "",
- "subs": {
- "relevance": 70,
- "recency": 100,
- "engagement": 27
- },
- "score": 64
- },
- {
- "id": "X6",
- "text": "BR Ofertas todos os dias \ud83c\udde7\ud83c\uddf7\n\nLeve seu Mac Pro M4 hoje! \ud83d\udcbb\u26a1\u2728\n\nPegue j\u00e1: https://t.co/PSwylZH3Sz\n\nPor R$ 23.749,00\nem at\u00e9 10x sem juros\nFrete Gr\u00e1tis\n\nApple MacBook Pro 14\u2033 2024 M4 Pro 12 n\u00facleos 24GB 512GB Prateado\n\nAmazon | Notebook https://t.co/gW3FonCEGP",
- "url": "https://x.com/bcofertas/status/2026721014676099084",
- "author_handle": "bcofertas",
- "date": "2026-02-25",
- "date_confidence": "high",
- "engagement": null,
- "relevance": 0.7,
- "why_relevant": "",
- "subs": {
- "relevance": 70,
- "recency": 100,
- "engagement": 35
- },
- "score": 64
- },
- {
- "id": "X12",
- "text": "Hey! Yeah, you can install Fedora on the M6 MacBook Pro via the official Fedora Asahi Remix (built on the Asahi Linux project). It already runs great on M1/M2, with M3 support rolling out now and work starting on M4/M5. M6 support will come post-launch as they reverse-engineer it\u2014usually within months. Head to https://t.co/XMQLmJlUGO to track progress and install from macOS.",
- "url": "https://x.com/grok/status/2026704108820824113",
- "author_handle": "grok",
- "date": "2026-02-25",
- "date_confidence": "high",
- "engagement": null,
- "relevance": 0.7,
- "why_relevant": "",
- "subs": {
- "relevance": 70,
- "recency": 100,
- "engagement": 35
- },
- "score": 64
- },
- {
- "id": "X7",
- "text": "Promo\u00e7\u00f5es BR do dia \ud83c\udde7\ud83c\uddf7\n\nLeve seu Mac Pro M4 hoje! \ud83d\udcbb\u26a1\u2728\n\nVer aqui: https://t.co/LfxNw3waps\n\nPor R$ 23.749,00\nem at\u00e9 10x sem juros\nFrete Gr\u00e1tis\n\nApple MacBook Pro 14\u2033 2024 M4 Pro 12 n\u00facleos...\n\nAmazon | Notebook https://t.co/fhjiFnA4b3",
- "url": "https://x.com/toppromoalertas/status/2026719111523237932",
- "author_handle": "toppromoalertas",
- "date": "2026-02-25",
- "date_confidence": "high",
- "engagement": null,
- "relevance": 0.7,
- "why_relevant": "",
- "subs": {
- "relevance": 70,
- "recency": 100,
- "engagement": 35
- },
- "score": 64
- },
- {
- "id": "X8",
- "text": "Yes, a MacBook Pro M4 can run this Ollama + GLM-4.7-Flash setup locally.\n\nOllama has native Apple Silicon support via Metal. The Q4_K_M model (~19GB) needs ~18-24GB unified memory for good speed (feels like a fast 7-12B model due to MoE). \n\nBase M4 configs start at 16GB (works, but slower with smaller context); 24GB+ is ideal for smooth agent tasks in OpenClaw.\n\nJust: brew install ollama (or download app), ollama run glm4.7-flash, link to OpenClaw. No issues reported on M3/M4 series.",
- "url": "https://x.com/grok/status/2026716420269031652",
- "author_handle": "grok",
- "date": "2026-02-25",
- "date_confidence": "high",
- "engagement": null,
- "relevance": 0.7,
- "why_relevant": "",
- "subs": {
- "relevance": 70,
- "recency": 100,
- "engagement": 35
- },
- "score": 64
- },
- {
- "id": "X2",
- "text": "@TechByTaraa Both. macOS (Macbook Pro M4 Pro) for my photography/videography personal stuff + software engineering and a Linux desktop workstation for software engineering + second desktop workstation with Proxmox for Windows VMs and testing stuff",
- "url": "https://x.com/grecinoscdev/status/2026731118142148644",
- "author_handle": "grecinoscdev",
- "date": "2026-02-25",
- "date_confidence": "high",
- "engagement": {
- "likes": 1
- },
- "relevance": 0.7,
- "why_relevant": "",
- "subs": {
- "relevance": 70,
- "recency": 100,
- "engagement": 0
- },
- "score": 56
- },
- {
- "id": "X11",
- "text": "Apple 2024 MacBook Pro 14\u30b3\u30a2CPU\u300120\u30b3\u30a2GPU \u306e M4 Pro\u642d\u8f09\u30ce\u30fc\u30c8\u30d1... PR\n\n4549995547610\n\n\u30dd\u30a4\u30f3\u30c8: 3735\u333d\n\u60f3\u5b9a\u4fa1\u683c: 369,800\n\n2026/02/26 01:55:23\nhttps://t.co/q1uyIqFsID",
- "url": "https://x.com/Steve_Lost_Jobs/status/2026705327828382016",
- "author_handle": "Steve_Lost_Jobs",
- "date": "2026-02-25",
- "date_confidence": "high",
- "engagement": {
- "likes": 1
- },
- "relevance": 0.7,
- "why_relevant": "",
- "subs": {
- "relevance": 70,
- "recency": 100,
- "engagement": 0
- },
- "score": 56
- },
- {
- "id": "X3",
- "text": "No, Apple has not made any MacBook (Pro or otherwise) cost $299. \n\nThe linked Newsroom search returns zero matching results\u2014it's just unrelated announcements. Current cheapest Mac is the 13\" MacBook Air M4 from $999.\n\nRumors suggest a basic \"budget\" MacBook (A18 Pro chip, ~12.9\" screen) may launch March 4 around $599\u2013$799, but nothing confirmed and far from $299. The original post is a joke/mislead.",
- "url": "https://x.com/grok/status/2026727605517394283",
- "author_handle": "grok",
- "date": "2026-02-25",
- "date_confidence": "high",
- "engagement": {
- "likes": 1
- },
- "relevance": 0.7,
- "why_relevant": "",
- "subs": {
- "relevance": 70,
- "recency": 100,
- "engagement": 0
- },
- "score": 56
- }
- ],
- "web": [],
- "youtube": [
- {
- "id": "9HQx5pgUoiY",
- "title": "M4 Max MacBook Pro: I'm Convinced!",
- "url": "https://www.youtube.com/watch?v=9HQx5pgUoiY",
- "channel_name": "Marques Brownlee",
- "date": "2024-11-18",
- "date_confidence": "high",
- "engagement": {
- "num_comments": 5500,
- "likes": 105606,
- "views": 4010567
- },
- "transcript_snippet": "so I have been using this M1 Max MacBook Pro for the past 3 years since it came out and it's been great I have felt absolutely no compulsion to upgrade since haven't needed to it's funny most of my work is pretty light but occasionally I will have to edit a video on the go which is then when you know the extra horsepower and the screen and everything about this comes in handy and it's great if I didn't have to do that ever I would just have like an air or a surface laptop something like that but that range of work is why this has been great for me and it's been awesome so [Music] far so Along Comes This M4 generation of MacBook Pros and it's the first generation that I have actually been tempted to maybe upgrade to you know they stacked a couple of the right upgrades in the right places for things that actually make a difference to me so let's just get right into it nuts and bolts cuz the first big pillar is performance and the main reason why this new laptop matters at all is that it's got the new generation of M4 chips inside and we all know the leap from Intel chips to apple cic chips for M1 was generational but since then it's been a sort of more reasonable incremental upgrade going from M1 to M2 then M3 and then M4 so I think it's pretty obvious that nobody with an M2 or M3 needs to upgrade but there's been enough improvements now stacking up upon each other from M1 to M4 that the numbers are now starting to get kind of interesting so just to set the stage this laptop that I've been using is the M1 Max maxed out basically the best you could get at the time 64 gigs of of unified memory on this system and this unit I've been testing of the M4 Max is also maxed out it's the best you can get now it's 128 gigs of faster unified memory but the cores are all better fast stronger on the new one 3 nomers versus 546 gigs per second of memory bandwidth versus 400 just the hardware improvements to get to M4 Max are clearly there so to keep the Apples to Apples comparison going Apple in their own presentation said up to 2.2 times faster CPU performance than M1 Max and 1.9 times faster GPU performance so we're looking at around twice as fast and the synthetic benchmarks I've run back this up I got over 4,000 in geekbench 6 CPU single core which is the highest single core score ever in a Mac by a while and the multicore score was incredible too tops the chart and then GPU has improved a lot to the point where it's bumping up against M2 Ultra not M2 Max M2 Ultra chips the one that's currently in the Mac Pro...",
- "relevance": 0.7,
- "why_relevant": "YouTube video about m4 macbook pro review",
- "subs": {
- "relevance": 70,
- "recency": 0,
- "engagement": 100
- },
- "score": 61
- },
- {
- "id": "etP2Th9g2hM",
- "title": "Don't buy the Wrong MacBook like me... - M4 MacBook Pro",
- "url": "https://www.youtube.com/watch?v=etP2Th9g2hM",
- "channel_name": "ShortCircuit",
- "date": "2024-11-30",
- "date_confidence": "high",
- "engagement": {
- "num_comments": 910,
- "likes": 22765,
- "views": 1113999
- },
- "transcript_snippet": "woohoo it's new Macbook day my favorite day on short circuit it only comes usually once a year this is the MacBook Pro M4 looks pretty similar to the last one but now it has an M4 instead of an M3 I'm not going to lie I am more of an M3 kind of guy but I can rock an M4 if I have to we've got their usual mag safe 3 to USBC 2 m sleeved nice cable and then a charging brick Oh I ripped it 70 wat USBC charging at least with the 14in I'm not sure what comes with the rest and then the usual Apple propaganda these still come with Apple stickers right do these not come with Apple stickers anymore bro what's the point in even buying them then it's probably good for the planet or something now if you're wondering I am still rocking the same Intel MacBook Pro over here I haven't upgraded yet so this is again another instance of me going hm maybe it's finally time except when I got to set today I realized tragedy struck we bought the wrong MacBook it is still an M4 Pro it's a good form factor we got the 14in here in the sexy space black color profile you can also get it in silver of course but there was a horrible error made and I'm not going to tell you just yet you're going to have to wait a second because first I need to go over the outside of this thing we've got an HDMI 2.1 port a USBC Thunderbolt Port an SD card reer and then on the other side another two USBC Thunderbolt ports 3.5 mil combo audio jack and mag safe there is an upgrade here on the USBC ports they're now Thunderbolt 5 instead of Thunderbolt 4 at least on the pro and Max models uh the base M4 spec only is Thunderbolt 4 and that's good for 120 gbit which is up to 3 times faster than Thunderbolt 4 on the inside bam it's it's pretty much the same keyboard look at that I think it is exactly the same keyboard pretty similar touchpad and then there is the display it looks pretty much the same same resolution same xdr fancy technology it is 1,000 nits Peak brightness when you're Outdoors instead of 600 so that should help with outdoor visibility um there is another change I'm going to tell you about in a bit but the big thing is the new nanotexture option which is an anti-glare coating and as you can tell we don't have it and I saw I Justine's video when it came out look at the difference what the hell you can't even use the one on the right and the one on the left looks beautiful it actually seems to work really well I can't wait to try it myself and honestly I think based on that if you're somebody...",
- "relevance": 0.7,
- "why_relevant": "YouTube video about m4 macbook pro review",
- "subs": {
- "relevance": 70,
- "recency": 0,
- "engagement": 79
- },
- "score": 55
- },
- {
- "id": "b4x8boB2KdI",
- "title": "M4 MacBook Pro Review - Things to Know",
- "url": "https://www.youtube.com/watch?v=b4x8boB2KdI",
- "channel_name": "Dave2D",
- "date": "2024-11-07",
- "date_confidence": "high",
- "engagement": {
- "num_comments": 827,
- "likes": 24977,
- "views": 1057988
- },
- "transcript_snippet": "so Apple launched the new M4 MacBook Pros this is their 14-in model and it's equipped with the M4 Max their topend configuration and it also has their new Nano texture on their screen it's a texture that's designed to cut down Reflections it's the first time they've actually had it on one of their laptops uh but I want to do a quick video here that kind of goes over the important things I think you should be aware of when it comes to this device and the first thing I want to talk about is performance the CPU gains are quite good year on-ear but it's particularly impressive that the M4 Max can outperform the M2 Ultra in a lot of benchmarks and workflows in terms of Graphics the GPU gains on the M4 Pro are decent but the GPU games on the M4 Max are very impressive most GPU bound apps and games will get a nice bump up from this plus the rate tracing capabilities are better so apps and games can take advantage of that the thing I'm most impressed by though is how good the M4 Max GPU is compared to the previous generation especially at a very similar thermal envelope like if you think about the competitors in this space like AMD Nvidia when they come out with laptop gpus typically the new models come also come with like an increase in power requirement right it's just like you know 5 or 10 watts per year but it adds up over the course of time and on this device if you think about the M1 Max to the M4 Max the GPU has improved so much with very little increase in power consumption The Thermals on this device are pretty much the same as last year's models in terms of memory the base configuration start at 16 GB now thankfully and they've also increased the memory bandwidth it's noticeable already on the base models but the pro and Max chips get a significant boost in memory speed so for applications that lean on Fast memory you definitely notice a bump up this year if I'm being honest though it's difficult for me to tell how much of the gains that we're seeing this year come from the memory speed increase versus the CPU or the GPU increase like they're all tied together and I don't have the tools to lock memory speed on a Macbook so it's tough to tell I will say though that if you have an older M1 or M2 or M3 MacBook Pro I wouldn't upgrade because Apple's silicon performance progresses so fast year on-ear that it can pay off to just wait as long as you can before you buy new hardware okay let's talk about the screens so this particular device is running their new Nano texture finish and it's finish we've seen on other devices before right like their screens uh the new iPads and every...",
- "relevance": 0.7,
- "why_relevant": "YouTube video about m4 macbook pro review",
- "subs": {
- "relevance": 70,
- "recency": 0,
- "engagement": 79
- },
- "score": 55
- },
- {
- "id": "uPe9spXLfZY",
- "title": "M4 MacBook Air vs M4 MacBook Pro - 4 Months Later",
- "url": "https://www.youtube.com/watch?v=uPe9spXLfZY",
- "channel_name": "Created Tech",
- "date": "2025-07-07",
- "date_confidence": "high",
- "engagement": {
- "num_comments": 437,
- "likes": 5082,
- "views": 472845
- },
- "transcript_snippet": "",
- "relevance": 0.7,
- "why_relevant": "YouTube video about m4 macbook pro review",
- "subs": {
- "relevance": 70,
- "recency": 0,
- "engagement": 63
- },
- "score": 50
- },
- {
- "id": "TfvIgdzImt4",
- "title": "The Macbook Pro M4 is Insane.",
- "url": "https://www.youtube.com/watch?v=TfvIgdzImt4",
- "channel_name": "Hardware Canucks",
- "date": "2024-12-15",
- "date_confidence": "high",
- "engagement": {
- "num_comments": 435,
- "likes": 6339,
- "views": 325597
- },
- "transcript_snippet": "",
- "relevance": 0.7,
- "why_relevant": "YouTube video about m4 macbook pro review",
- "subs": {
- "relevance": 70,
- "recency": 0,
- "engagement": 62
- },
- "score": 50
- },
- {
- "id": "a8Szdrnq0YM",
- "title": "MacBook Pro M4 - Review After 3 Months: This Feels Wrong.",
- "url": "https://www.youtube.com/watch?v=a8Szdrnq0YM",
- "channel_name": "Brandon Butch",
- "date": "2025-02-03",
- "date_confidence": "high",
- "engagement": {
- "num_comments": 397,
- "likes": 4309,
- "views": 352593
- },
- "transcript_snippet": "",
- "relevance": 0.7,
- "why_relevant": "YouTube video about m4 macbook pro review",
- "subs": {
- "relevance": 70,
- "recency": 0,
- "engagement": 60
- },
- "score": 49
- },
- {
- "id": "anIVewgtbFc",
- "title": "The Base M4 MacBook Pro is All You Need (Skip M4 Pro & Max)",
- "url": "https://www.youtube.com/watch?v=anIVewgtbFc",
- "channel_name": "MacRumors",
- "date": "2024-12-17",
- "date_confidence": "high",
- "engagement": {
- "num_comments": 336,
- "likes": 2874,
- "views": 238616
- },
- "transcript_snippet": "",
- "relevance": 0.7,
- "why_relevant": "YouTube video about m4 macbook pro review",
- "subs": {
- "relevance": 70,
- "recency": 0,
- "engagement": 55
- },
- "score": 48
- },
- {
- "id": "5rN6CEO31gM",
- "title": "MacBook Pro M4: Review & Recommendations",
- "url": "https://www.youtube.com/watch?v=5rN6CEO31gM",
- "channel_name": "Just Josh",
- "date": "2024-11-15",
- "date_confidence": "high",
- "engagement": {
- "num_comments": 441,
- "likes": 4540,
- "views": 154306
- },
- "transcript_snippet": "",
- "relevance": 0.7,
- "why_relevant": "YouTube video about m4 macbook pro review",
- "subs": {
- "relevance": 70,
- "recency": 0,
- "engagement": 55
- },
- "score": 48
- },
- {
- "id": "i3eTKJav1VI",
- "title": "M4 Pro MacBook - Long Term Review (6 Months Later)",
- "url": "https://www.youtube.com/watch?v=i3eTKJav1VI",
- "channel_name": "Created Tech",
- "date": "2025-04-29",
- "date_confidence": "high",
- "engagement": {
- "num_comments": 136,
- "likes": 1832,
- "views": 137009
- },
- "transcript_snippet": "",
- "relevance": 0.7,
- "why_relevant": "YouTube video about m4 macbook pro review",
- "subs": {
- "relevance": 70,
- "recency": 0,
- "engagement": 47
- },
- "score": 45
- },
- {
- "id": "QbSQH_95eg8",
- "title": "MacBook Pro M4 Pro Review: 1 Year Later! (Still Worth Buying in 2026?)",
- "url": "https://www.youtube.com/watch?v=QbSQH_95eg8",
- "channel_name": "Tech It Easy",
- "date": "2026-02-01",
- "date_confidence": "high",
- "engagement": {
- "num_comments": 42,
- "likes": 55,
- "views": 3176
- },
- "transcript_snippet": "",
- "relevance": 0.7,
- "why_relevant": "YouTube video about m4 macbook pro review",
- "subs": {
- "relevance": 70,
- "recency": 19,
- "engagement": 0
- },
- "score": 36
- }
- ],
- "hackernews": [],
- "best_practices": [],
- "prompt_pack": [],
- "context_snippet_md": "# Context: M4 MacBook Pro review (Last 30 Days)\n\n*Generated: 2026-02-25 | Sources: both*\n\n## Key Sources\n\n- [X] If you are in the market for a new MacBook Pro and...\n- [X] \ud83d\udd25 2024 16\u201d MacBook Pro Price List\n\n1\ufe0f\u20e3 \ud83c\uddfa\ud83c\uddf8 Brand Ne...\n- [X] @5TRgzn @LobstarWilde My marble floors: 64GB M4 Pr...\n- [X] @JulianGoldieSEO @grok can a macbook pro m4 run th...\n- [X] @SebAaltonen Not my experience. It\u2019s impressive, 7...\n- [Reddit] M4 Macbook Pro 14\u2019 1 month review\n- [Reddit] MacBook Pro M4 Battery Life Reality vs Review\n\n## Summary\n\n*See full report for best practices, prompt pack, and detailed sources.*\n"
-}
diff --git a/docs/comparison-results/json/hn-4-rap.json b/docs/comparison-results/json/hn-4-rap.json
deleted file mode 100644
index 23f0ffe..0000000
--- a/docs/comparison-results/json/hn-4-rap.json
+++ /dev/null
@@ -1,505 +0,0 @@
-{
- "topic": "best rap songs 2026",
- "range": {
- "from": "2026-01-26",
- "to": "2026-02-25"
- },
- "generated_at": "2026-02-25T20:36:37.802136+00:00",
- "mode": "both",
- "openai_model_used": "gpt-5.2",
- "xai_model_used": "grok-4-1-fast",
- "reddit": [
- {
- "id": "R6",
- "title": "Mix Rap Hiphop 2026",
- "url": "https://www.reddit.com/r/playlists/comments/1r8t0ld/mix_rap_hiphop_2026/",
- "subreddit": "playlists",
- "date": "2026-02-19",
- "date_confidence": "high",
- "engagement": null,
- "top_comments": [],
- "comment_insights": [],
- "relevance": 0.88,
- "why_relevant": "Explicitly a 2026 rap/hip-hop playlist thread; useful for current-year song discovery.",
- "subs": {
- "relevance": 88,
- "recency": 80,
- "engagement": 50
- },
- "score": 71
- },
- {
- "id": "R8",
- "title": "Daily Discussion Thread 02/24/2026",
- "url": "https://www.reddit.com/r/hiphopheads/comments/1rdjmgu/daily_discussion_thread_02242026/",
- "subreddit": "hiphopheads",
- "date": "2026-02-25",
- "date_confidence": "high",
- "engagement": null,
- "top_comments": [],
- "comment_insights": [],
- "relevance": 0.66,
- "why_relevant": "General hip-hop discussion thread around late Feb 2026; often includes song/album recs and what people are listening to now.",
- "subs": {
- "relevance": 66,
- "recency": 100,
- "engagement": 50
- },
- "score": 66
- },
- {
- "id": "R2",
- "title": "Best 2026 Rap Playlist (178 saves)",
- "url": "https://www.reddit.com/r/musicplaylists/comments/1qu3i2n/best_2026_rap_playlist_178_saves/",
- "subreddit": "musicplaylists",
- "date": "2026-02-02",
- "date_confidence": "high",
- "engagement": null,
- "top_comments": [],
- "comment_insights": [],
- "relevance": 0.95,
- "why_relevant": "Directly about a \u201cBest 2026 Rap Playlist\u201d (songs for 2026), with comments inviting suggestions.",
- "subs": {
- "relevance": 95,
- "recency": 23,
- "engagement": 50
- },
- "score": 60
- },
- {
- "id": "R18",
- "title": "DFL - Art Of Life (2026)",
- "url": "https://www.reddit.com/r/hiphop/comments/1raoitx/dfl_art_of_life_2026/",
- "subreddit": "hiphop",
- "date": "2026-02-21",
- "date_confidence": "high",
- "engagement": null,
- "top_comments": [],
- "comment_insights": [],
- "relevance": 0.5,
- "why_relevant": "A 2026 rap/hip-hop track post; useful to gather candidate songs people might call \u201cbest of 2026.\u201d",
- "subs": {
- "relevance": 50,
- "recency": 86,
- "engagement": 50
- },
- "score": 56
- },
- {
- "id": "R19",
- "title": "Plat Hav Pro - Get F'D Up (2026)",
- "url": "https://www.reddit.com/r/hiphop/comments/1ragaf5/plat_hav_pro_get_fd_up_2026/",
- "subreddit": "hiphop",
- "date": "2026-02-21",
- "date_confidence": "high",
- "engagement": null,
- "top_comments": [],
- "comment_insights": [],
- "relevance": 0.48,
- "why_relevant": "Another 2026 track post; relevant to discovering rap songs released/posted in 2026 that could be considered among the best.",
- "subs": {
- "relevance": 48,
- "recency": 86,
- "engagement": 50
- },
- "score": 55
- },
- {
- "id": "R21",
- "title": "What's the best rap song ever made?",
- "url": "https://www.reddit.com/r/TeenageRapFans/comments/1r5iub6/whats_the_best_rap_song_ever_made/",
- "subreddit": "TeenageRapFans",
- "date": "2026-02-15",
- "date_confidence": "high",
- "engagement": null,
- "top_comments": [],
- "comment_insights": [],
- "relevance": 0.6,
- "why_relevant": "Direct \u2018best rap song\u2019 discussion (not year-specific, but posted in 2026 and highly aligned with \u2018best rap songs\u2019 topic).",
- "subs": {
- "relevance": 60,
- "recency": 66,
- "engagement": 50
- },
- "score": 55
- },
- {
- "id": "R13",
- "title": "[DISCUSSION] Westside Gunn - 12 (1 Year Later)",
- "url": "https://www.reddit.com/r/hiphopheads/comments/1r4nbsr/discussion_westside_gunn_12_1_year_later/",
- "subreddit": "hiphopheads",
- "date": "2026-02-14",
- "date_confidence": "high",
- "engagement": null,
- "top_comments": [],
- "comment_insights": [],
- "relevance": 0.58,
- "why_relevant": "Album discussion thread that includes \u2018favorite track\u2019 prompts; useful for identifying best songs/tracks people cite.",
- "subs": {
- "relevance": 57,
- "recency": 63,
- "engagement": 50
- },
- "score": 53
- },
- {
- "id": "R20",
- "title": "Jody Lo - Ridiculous (2026)",
- "url": "https://www.reddit.com/r/hiphop/comments/1r6c6i7/jody_lo_ridiculous_2026/",
- "subreddit": "hiphop",
- "date": "2026-02-16",
- "date_confidence": "high",
- "engagement": null,
- "top_comments": [],
- "comment_insights": [],
- "relevance": 0.48,
- "why_relevant": "2026 track thread; relevant for compiling 2026 rap songs being shared/discussed.",
- "subs": {
- "relevance": 48,
- "recency": 70,
- "engagement": 50
- },
- "score": 51
- },
- {
- "id": "R15",
- "title": "Reddit Ranked: Hip Hop \u201826",
- "url": "https://www.reddit.com/r/redranked/comments/1qszauu/reddit_ranked_hip_hop_26/",
- "subreddit": "edranked",
- "date": "2026-02-01",
- "date_confidence": "high",
- "engagement": null,
- "top_comments": [],
- "comment_insights": [],
- "relevance": 0.78,
- "why_relevant": "Ongoing 2026 hip-hop ranking/submission thread; good for discovering community-submitted \u2018best\u2019 tracks in 2026.",
- "subs": {
- "relevance": 78,
- "recency": 19,
- "engagement": 50
- },
- "score": 51
- },
- {
- "id": "R9",
- "title": "Pre-Show Grammy Winners",
- "url": "https://www.reddit.com/r/hiphopheads/comments/1qtcbmo/preshow_grammy_winners/",
- "subreddit": "hiphopheads",
- "date": "2026-02-01",
- "date_confidence": "high",
- "engagement": null,
- "top_comments": [],
- "comment_insights": [],
- "relevance": 0.72,
- "why_relevant": "Includes \u2018Best Rap Song\u2019/rap categories discussion; useful when searching for top/best rap songs discourse in 2026.",
- "subs": {
- "relevance": 72,
- "recency": 19,
- "engagement": 50
- },
- "score": 49
- }
- ],
- "x": [
- {
- "id": "X5",
- "text": "Talking about meaningful rap songs...\n\nWORLD BEST LIE - NUNO ZIGI (2026)\ud83e\udd7a\u2764 https://t.co/MDHRf3giYq",
- "url": "https://x.com/Zika_gfx/status/2026576570513600788",
- "author_handle": "Zika_gfx",
- "date": "2026-02-25",
- "date_confidence": "high",
- "engagement": {
- "likes": 12,
- "reposts": 5,
- "replies": 2
- },
- "relevance": 0.7,
- "why_relevant": "",
- "subs": {
- "relevance": 70,
- "recency": 100,
- "engagement": 100
- },
- "score": 86
- },
- {
- "id": "X6",
- "text": "@PopCrave Partition still goes harder than most 2026 songs \n12 years and the beat switch + that rap still give chills every time. Queen never misses",
- "url": "https://x.com/DPOSTS6/status/2026523320548819145",
- "author_handle": "DPOSTS6",
- "date": "2026-02-25",
- "date_confidence": "high",
- "engagement": {
- "likes": 4,
- "reposts": 1
- },
- "relevance": 0.7,
- "why_relevant": "",
- "subs": {
- "relevance": 70,
- "recency": 100,
- "engagement": 41
- },
- "score": 68
- },
- {
- "id": "X9",
- "text": "@Unfath0m4ble @robinthisbish @lilceaserthabp1 @RapWikip What You Saying by Lil Uzi Vert. Dropped Dec 2025, hit #1 on Hot Rap Songs in Jan 2026 (his 3rd time), peaked #14 Hot 100. Super short track ~2 min with minimal lyrics.",
- "url": "https://x.com/grok/status/2026467153046606064",
- "author_handle": "grok",
- "date": "2026-02-25",
- "date_confidence": "high",
- "engagement": null,
- "relevance": 0.7,
- "why_relevant": "",
- "subs": {
- "relevance": 70,
- "recency": 100,
- "engagement": 35
- },
- "score": 64
- },
- {
- "id": "X7",
- "text": "@lil_loonie1 @oldmanebro No, Lil Uzi Vert isn't struggling. His \"What You Saying\" hit #1 on Hot Rap Songs in Jan 2026 (first in 3 years) and top 15 Hot 100. Insiders say a surprise album drop is coming soon, with fans calling 2026 his revival year like 2016. Proving that old prediction wrong.",
- "url": "https://x.com/grok/status/2026518008827404632",
- "author_handle": "grok",
- "date": "2026-02-25",
- "date_confidence": "high",
- "engagement": {
- "replies": 1
- },
- "relevance": 0.7,
- "why_relevant": "",
- "subs": {
- "relevance": 70,
- "recency": 100,
- "engagement": 35
- },
- "score": 64
- },
- {
- "id": "X2",
- "text": "Su La Musica che gira intorno... \"Qui con me\" \u00e8 il brano con cui Serena Brancale \u00e8 in gara al 76\u00b0 Festival di Sanremo\nhttps://t.co/wTPeovzt6u\n#music #genre #song #songs #envywear #PleaseForgiveMe #melody #hiphop #rnb #pop #love #rap #dubstep #instagood https://t.co/Pi7msX5FG6",
- "url": "https://x.com/Ferginangi/status/2026696207557382298",
- "author_handle": "Ferginangi",
- "date": "2026-02-25",
- "date_confidence": "high",
- "engagement": null,
- "relevance": 0.7,
- "why_relevant": "",
- "subs": {
- "relevance": 70,
- "recency": 100,
- "engagement": 35
- },
- "score": 64
- },
- {
- "id": "X12",
- "text": "[ ** STUDIO BOOTH ** ] ** DROPPING SOON - 2026 **\ud83d\udd25\ud83d\udd25\ud83d\udd25\u2026\u2026\u2026..[ ALBUM : LOVE VS LOYALTY - DROPPING SOON ! ]\u2026\u2026\u2026\ud83d\udd25\ud83d\udd25\ud83d\udd25 - ** ALL SONGS & BEATS, PRODUCED & RAP BY ( SEAN BRYANT ) - https://t.co/LduwG5WmM7 - 2/23/2026 - ( KWR 4 LIFE ) - A.A.O & 444 ! https://t.co/NDmkURbjgv",
- "url": "https://x.com/teammusic2046/status/2026040965329191061",
- "author_handle": "teammusic2046",
- "date": "2026-02-23",
- "date_confidence": "high",
- "engagement": null,
- "relevance": 0.7,
- "why_relevant": "",
- "subs": {
- "relevance": 70,
- "recency": 93,
- "engagement": 35
- },
- "score": 62
- },
- {
- "id": "X11",
- "text": "@PopBase Finally, @bts_bighit BTS will come back after completing their military service from 2022 to 2025.\nBTS served in the military to protect their country, South Korea from North Korea's nuclear weapons attack.\n BTS can sing songs and rap on March 20, 2026, KST.\n#BTS_ARIRANG https://t.co/46n1j6eENU",
- "url": "https://x.com/IoWeul/status/2026233090683863471",
- "author_handle": "IoWeul",
- "date": "2026-02-24",
- "date_confidence": "high",
- "engagement": {
- "likes": 2
- },
- "relevance": 0.7,
- "why_relevant": "",
- "subs": {
- "relevance": 70,
- "recency": 96,
- "engagement": 13
- },
- "score": 59
- },
- {
- "id": "X8",
- "text": "Lil Uzi Vert (born July '95, so 30 now) isn't struggling at all. Fresh off \"What You Saying\" hitting #1 on Hot Rap Songs in Jan 2026, top 15 Hot 100. New album rumored for surprise drop soon, Roc Nation deal, still packing stadium energy and proving Ebro's 2016 doubts wrong\u2014he's still here, thriving.",
- "url": "https://x.com/grok/status/2026498538075554040",
- "author_handle": "grok",
- "date": "2026-02-25",
- "date_confidence": "high",
- "engagement": {
- "likes": 1,
- "replies": 1
- },
- "relevance": 0.7,
- "why_relevant": "",
- "subs": {
- "relevance": 70,
- "recency": 100,
- "engagement": 6
- },
- "score": 58
- },
- {
- "id": "X4",
- "text": "My musical taste / eras every year\n\n2020: J-POP & K-POP (mostly BLACKPINK)\n2021: Sped-Up Songs\n2022: EDM\n2023: Swiftie (Pop) & Lofi\n2024: Pop, Ambient\n2025: Alternative, OPM, Rock, Rap, Hip-Hop (add)\n2026: K-POP (add)",
- "url": "https://x.com/AlterEgoPopList/status/2026641456434393579",
- "author_handle": "AlterEgoPopList",
- "date": "2026-02-25",
- "date_confidence": "high",
- "engagement": {
- "likes": 1,
- "replies": 1
- },
- "relevance": 0.7,
- "why_relevant": "",
- "subs": {
- "relevance": 70,
- "recency": 100,
- "engagement": 6
- },
- "score": 58
- },
- {
- "id": "X3",
- "text": "@_CharlesPreston Can\u2019t believe I\u2019m seeing \u201cthey say it in rap songs!\u201d In 2026\n\nThe same reductive, ancient, debunked arguments ad infinitum",
- "url": "https://x.com/WordsFromBlerds/status/2026647382083903688",
- "author_handle": "WordsFromBlerds",
- "date": "2026-02-25",
- "date_confidence": "high",
- "engagement": {
- "likes": 1
- },
- "relevance": 0.7,
- "why_relevant": "",
- "subs": {
- "relevance": 70,
- "recency": 100,
- "engagement": 0
- },
- "score": 56
- },
- {
- "id": "X1",
- "text": "Su La Musica che gira intorno... \"Magica Favola\" \u00e8 il brano con cui Arisa \u00e8 in gara al 76\u00b0 Festival di Sanremo\nhttps://t.co/Nxzkb005lP\n#music #genre #song #songs #envywear #PleaseForgiveMe #melody #hiphop #rnb #pop #love #rap #dubstep #instagood https://t.co/uziy4MjeeB",
- "url": "https://x.com/Ferginangi/status/2026705227366256880",
- "author_handle": "Ferginangi",
- "date": "2026-02-25",
- "date_confidence": "high",
- "engagement": {
- "likes": 1
- },
- "relevance": 0.7,
- "why_relevant": "",
- "subs": {
- "relevance": 70,
- "recency": 100,
- "engagement": 0
- },
- "score": 56
- },
- {
- "id": "X10",
- "text": "@blackiiingout \ud83d\udca9 is funny from 2025 to 2026. BiiCH is SCARED to drop that #TRASH he calls \"rap songs\" \ud83e\udd74\ud83d\ude29",
- "url": "https://x.com/fo_sho52268/status/2026335353364201566",
- "author_handle": "fo_sho52268",
- "date": "2026-02-24",
- "date_confidence": "high",
- "engagement": {
- "likes": 1
- },
- "relevance": 0.7,
- "why_relevant": "",
- "subs": {
- "relevance": 70,
- "recency": 96,
- "engagement": 0
- },
- "score": 55
- }
- ],
- "web": [],
- "youtube": [
- {
- "id": "ZUExyc50ZVU",
- "title": "Lit Hip Hop Mix 2026\ud83d\udd25\ud83d\udd25\ud83d\udd25Tyga, Quavo, Iggy Azalea, Wiz Khalifa, Juicy J, 50 Cent",
- "url": "https://www.youtube.com/watch?v=ZUExyc50ZVU",
- "channel_name": "West Coast Finest",
- "date": "2026-01-29",
- "date_confidence": "high",
- "engagement": {
- "num_comments": 60,
- "likes": 4229,
- "views": 407260
- },
- "transcript_snippet": "Yeah. >> Yeah. [music] Late nights, cold life, same story. >> City shine different. You walking with glory, right? >> Still drip falling when I step outside. Yeah. [music] >> Black car park with a do butterfly. Too [music] much heat when we walk inside. Midnight. So you can kill this V. Gold chain swinging when I lean on the night. Shut it. Look twice when I glow on the light. Yeah. I've been running through the city with a cool grin. [music] Ain't nobody in my lane when the night begin white clean look like but a good kind win. It's all too smooth but I back that talk. Money so clean make a bro [music] boy walk in my cup but my soul don't shock. I've been cold for years. I ain't you mock. She said boy you too for the life you live. I peace ain't weak. It's [music and singing] the power I give. I don't flex too loud. I don't need that when your whole damn already scream. Yeah. Late night in the rooftop smoke girls but they all provoke. I don't chase no dream. I just take that goal. I don't chase no soul. They just lose control. Cameras on flash when I step on deck. Fit so clean. Make the whole [music] place check. Talk about but I still respect. Still too smooth. I don't never need a flex. Yeah. West coast chill with a heart [music and singing] too cold. Stories too deep that ain't never been told. I'm a tight make a new king. Pretty boy look but a mind. Yeah. Slide through smooth. Let a breeze and a ghost. All [music and singing] them in my but I flex coast to coast. Pretty boy with a mind stay close to the pain to the grind to the things I boast. Wo. F drag with a midnight gleam. [music and singing] Squad clean like a runway team. All black drip with a icon of sheen. Feel like a king every time I hit a scene. Purple in a club but a soul still clear. Vision too [singing] sharp when I stare in the mirror. [music] Fashion too loud but the vibe too sick. Every step I take got a world right here. Yeah. BBS ice made a nice guy blush. Shorty walk in the whole club hush. Talk too [music] cute but she move too lush. Slow down baby [singing] you rush too much. [music] Boy magic with a westside twist. Mind too slick when I talk like this. I don't miss not a bar not a kiss. I ain't too heavy. I exist like bliss. Woah. Let it on my back with a chrome on shine. Let the world go blind. I'm still define. Every day I'm tren with a style so divine still drip [music] when I step outside. Yeah. Black car park with the dogs butterfly. Too much heat when we walk inside. Midnight. [music] You can't kill...",
- "relevance": 0.7,
- "why_relevant": "YouTube video about rap songs 2026",
- "subs": {
- "relevance": 70,
- "recency": 9,
- "engagement": 100
- },
- "score": 63
- },
- {
- "id": "d1melQQVp6s",
- "title": "New Rap Songs 2026 Mix February | Trap Tape #127 | New Hip Hop 2026 Mixtape | DJ Noize",
- "url": "https://www.youtube.com/watch?v=d1melQQVp6s",
- "channel_name": "DJ Noize",
- "date": "2026-02-14",
- "date_confidence": "high",
- "engagement": {
- "num_comments": 45,
- "likes": 761,
- "views": 42707
- },
- "transcript_snippet": "[music] This This is a DJ noise mixtape present. This is a DJ noise mixtape [music] presentation. DJ [music] >> Who the [ __ ] is you? Who the [ __ ] is you? Who the [ __ ] >> Yeah, you know who put the city on. Came [music] back through gold they show. I'm really on. Your [ __ ] died trying. I had 50 on. [ __ ] is [music] ringing out my phone like is you really on? I'll stay out the way since I [singing] seen a million on 5% tension plus I got this hoodie on. I had to hubble up. Stop [music] moving with that jury on. You hear that girl told me [ __ ] get deep ass. Get it on. I'm a sidewalk [ __ ] I'mma stay there. I leave the [music] streets for the ones that want to play. I leveled up so shy want a day chair. Left my kids [music] in a mouth like a dayare. I'm the Carolina now Shakespeare. All that cap you let them [ __ ] rap is over. The real is back. You [music] feel the way you got to cope with it. The veil put a [ __ ] six feet like that was hit. Okay, these [ __ ] ain't talking about [ __ ] No [music] diamonds on my neck. No rolly on my wrist. Two six I learned a little bit. I learned the hardest [music] [ __ ] You can always get kicked. I can jungle. I done [ __ ] around and [singing] got rich. See dollars in my pocket. [ __ ] I'm not a lick. [music] I'd rather that than got to [singing] walk around with sticks cuz I like the hardest [ __ ] He can always get Who the [ __ ] is you? Who the [ __ ] [music] is you? Who the [ __ ] is you? Who the [ __ ] is you? Out the way. It's a real [ __ ] coming through. Out the way. [music] It's a real [ __ ] coming through. Who the [ __ ] is you? Who the [ __ ] is you? Who the [ __ ] is you? Who the [ __ ] is you out [music] the way? It's a real [ __ ] coming through. Out the way. It's a real [ __ ] [music] >> [music] >> Running to the check flag won't pull over till I'm dead right no [music] ain't going to get no [ __ ] riding with me in the head know I'm [ __ ] no big [ __ ] popping in the little [ __ ] dropping bags it's a lot to you to me [ __ ] 40 in my lap. Uh-huh. Real dope boys in [music] a trap. Uh-huh. Big y like a big shot. [ __ ] on my dick. [singing] Make me room for your big got to let them know. I don't wrestle. I don't live block. [music] Solid tails. All I do is big rock. I came from Dilling Rock all the way to hit on the grown. [ __ ] ain't when I'm on. She can kick rock. Let her know. I'm blowing money. Let [music] them know I'm only I know how we get bread. I ain't never [music] scared on...",
- "relevance": 0.7,
- "why_relevant": "YouTube video about rap songs 2026",
- "subs": {
- "relevance": 70,
- "recency": 63,
- "engagement": 37
- },
- "score": 58
- },
- {
- "id": "-KQpPySP93I",
- "title": "New Hip Hop R&B Songs 2026 Mix February | Hot Right Now #153 | New Rap 2026 Playlist | DJ Noize",
- "url": "https://www.youtube.com/watch?v=-KQpPySP93I",
- "channel_name": "DJ Noize",
- "date": "2026-02-21",
- "date_confidence": "high",
- "engagement": {
- "num_comments": 18,
- "likes": 304,
- "views": 12955
- },
- "transcript_snippet": "This This is a DJ noise tape present. This is a DJ noise mix. [music] I got Nikki on me. How we treat the like a bucket for rubbing. You [music] ain't no fella from my junction. You ain't pluck you clapping smirk. Young enough to something. [music] Word mother body slam some [ __ ] for honey. Keep a stitch. People pick me up. Ain't saying a word or nothing. Grab a [music] load. Put on little bro. We had him serving prostitut. Shout out little [singing] b. She gave me 30 something on the beat. Phone cracker seal and I can't go to sleep. Promoters tweaking. [music] Pull up to the venue over 40 deep. You know the tweak. Ranking through the roof then you ain't supposed to leak. It's one up top. Manator is keep it close to me. Ain't that how it's supposed to be? [music] She going to get your me. I'm get you smoke free. Kamicazi [music] playing around [singing] this [ __ ] Something wrong with me. I'm letting diss the dead unless they skeleton belong the team type of [ __ ] won't let it get in between the city and the police where [ __ ] get they do for me they [music] all do the show leaning spots I do like fully call back off you know it's strong we [music] man 15 I had 15 [singing] in 2010 then I be [music] the man right now [ __ ] change big guns bank shame I'm change [music] I'm going make that I only burn. That's how my body [music] can't be pull [singing] right to the sun. [music] Ain't going to get no [ __ ] riding with me in the head west. I'm out of mind [music] [ __ ] popping in little [ __ ] dropping bags. It's a lot to you to me a little bit sh [music] like a big shot. [ __ ] on my dick. Make me room for your big. Got to let them know I don't turn wrestle. I don't live block solid tails. [music] All I do is big rocks. I came from Dilling Rock all the way to hit on the grown. [ __ ] ain't when I'm on. She can kick rock. Let her know [music] my foot up on the gas. I'm blowing money. You never know. I'm only [music] beat down. We getting bread. I ain't never scared big flex [music] on a [ __ ] head what I'm standing on big chick we [singing] did that pushing weed back better give me respect and some more Atlanta [music] show I was selling ground right I don't care [music] ling to me what you need a camera better know they call it [music] we just call it they say [singing] you live down by your decision [music] well I'mma push you to the limit never mind the consequences A [ __ ] I'm a hand better let them know on another [music] level my ass I put together hit the pedal do 200 everywhere I go [music] just to show...",
- "relevance": 0.7,
- "why_relevant": "YouTube video about rap songs 2026",
- "subs": {
- "relevance": 70,
- "recency": 86,
- "engagement": 0
- },
- "score": 53
- }
- ],
- "hackernews": [],
- "best_practices": [],
- "prompt_pack": [],
- "context_snippet_md": "# Context: best rap songs 2026 (Last 30 Days)\n\n*Generated: 2026-02-25 | Sources: both*\n\n## Key Sources\n\n- [X] Talking about meaningful rap songs...\n\nWORLD BEST ...\n- [Reddit] Mix Rap Hiphop 2026\n- [X] @PopCrave Partition still goes harder than most 20...\n- [Reddit] Daily Discussion Thread 02/24/2026\n- [X] @Unfath0m4ble @robinthisbish @lilceaserthabp1 @Rap...\n- [X] @lil_loonie1 @oldmanebro No, Lil Uzi Vert isn't st...\n- [X] Su La Musica che gira intorno... \"Qui con me\" \u00e8 il...\n\n## Summary\n\n*See full report for best practices, prompt pack, and detailed sources.*\n"
-}
diff --git a/docs/comparison-results/json/hn-5-react-svelte.json b/docs/comparison-results/json/hn-5-react-svelte.json
deleted file mode 100644
index a2d4ea6..0000000
--- a/docs/comparison-results/json/hn-5-react-svelte.json
+++ /dev/null
@@ -1,468 +0,0 @@
-{
- "topic": "React vs Svelte 2026",
- "range": {
- "from": "2026-01-26",
- "to": "2026-02-25"
- },
- "generated_at": "2026-02-25T20:38:50.068065+00:00",
- "mode": "both",
- "openai_model_used": "gpt-5.2",
- "xai_model_used": "grok-4-1-fast",
- "reddit": [
- {
- "id": "R2",
- "title": "What do you guys think about comparison between React vs Svelte?",
- "url": "https://www.reddit.com/r/react/comments/1r1fku5/what_do_you_guys_think_about_comparison_between/",
- "subreddit": "eact",
- "date": "2026-02-10",
- "date_confidence": "high",
- "engagement": null,
- "top_comments": [],
- "comment_insights": [],
- "relevance": 0.93,
- "why_relevant": "Explicit React vs Svelte comparison discussion (mentions Svelte 5 runes vs React hooks).",
- "subs": {
- "relevance": 93,
- "recency": 50,
- "engagement": 50
- },
- "score": 66
- },
- {
- "id": "R1",
- "title": "Migrated our startup from React to Svelte 5 - Performance gains and lessons learned",
- "url": "https://www.reddit.com/r/webdev/comments/1qu4dek/migrated_our_startup_from_react_to_svelte_5/",
- "subreddit": "webdev",
- "date": "2026-02-02",
- "date_confidence": "high",
- "engagement": null,
- "top_comments": [],
- "comment_insights": [],
- "relevance": 0.96,
- "why_relevant": "Direct migration story from React to Svelte 5 with concrete perf/dev-ex tradeoffs; very relevant to 2026 comparisons.",
- "subs": {
- "relevance": 96,
- "recency": 23,
- "engagement": 50
- },
- "score": 60
- },
- {
- "id": "R4",
- "title": "When to choose React over Svelte",
- "url": "https://www.reddit.com/r/sveltejs/comments/1jeknib/when_to_choose_react_over_svelte/",
- "subreddit": "sveltejs",
- "date": null,
- "date_confidence": "low",
- "engagement": null,
- "top_comments": [],
- "comment_insights": [],
- "relevance": 0.88,
- "why_relevant": "Decision-oriented thread explicitly contrasting when React vs Svelte makes sense (ecosystem/jobs/productivity).",
- "subs": {
- "relevance": 88,
- "recency": 0,
- "engagement": 50
- },
- "score": 46
- }
- ],
- "x": [
- {
- "id": "X4",
- "text": "TypeScript is the baseline in 2026. But most forms still fight type drift between API, schema & UI. \n\n@formisch_dev + @valibot = one source of truth, zero boilerplate \u26a1\ufe0f\n\nconst form = useForm({ schema: YourSchema });\n\nFull types everywhere. Works in React, SolidJS, Vue, Svelte\u2026 \n\nYour biggest type-safety win this year? \ud83d\udc47",
- "url": "https://x.com/FabianHiller/status/2022345388603080834",
- "author_handle": "FabianHiller",
- "date": "2026-02-13",
- "date_confidence": "high",
- "engagement": {
- "likes": 61,
- "reposts": 2,
- "replies": 7
- },
- "relevance": 0.7,
- "why_relevant": "",
- "subs": {
- "relevance": 70,
- "recency": 60,
- "engagement": 100
- },
- "score": 76
- },
- {
- "id": "X3",
- "text": "Popular JavaScript Frameworks / Frontend Libraries (2025\u20132026 trends)\n\n - React: \ud83c\uddfa\ud83c\uddf8 United States\n - Vue: \ud83c\udde8\ud83c\uddf3 China (created by Evan You)\n - Angular: \ud83c\uddfa\ud83c\uddf8 United States\n - Svelte: \ud83c\uddfa\ud83c\uddf8 United States (New Zealand creator)\n - Next.js: \ud83c\uddfa\ud83c\uddf8 United States\n - Nuxt: \ud83c\uddeb\ud83c\uddf7 France\n - Astro: \ud83c\udde8\ud83c\udde6 United States / Canada\n - Solid: \ud83c\uddfa\ud83c\uddf8 United States",
- "url": "https://x.com/ConsciousRide/status/2023050160520278501",
- "author_handle": "ConsciousRide",
- "date": "2026-02-15",
- "date_confidence": "high",
- "engagement": {
- "likes": 6
- },
- "relevance": 0.7,
- "why_relevant": "",
- "subs": {
- "relevance": 70,
- "recency": 66,
- "engagement": 33
- },
- "score": 57
- },
- {
- "id": "X11",
- "text": "Requirements for a fresher in 2026:\n\nPython, JavaScript, C, C++, Java, HTML, CSS, TypeScript, React, Next.js, Vue, Angular, Svelte, Node.js, Express, Django, Flask, FastAPI, Spring Boot, PHP, Laravel, Ruby, Ruby on Rails, Go, Rust, Kotlin, Swift, C#, .NET, SQL, MySQL, PostgreSQL, MongoDB, Redis, Firebase, Supabase, Prisma, Docker, Kubernetes, Nginx, Linux, Git, GitHub, Gitflow, VS Code, IntelliJ, Cloud Computing (AWS, Azure, GCP), DevOps, CI/CD, Jest, PyTest, Selenium, REST APIs, GraphQL, WebSoc",
- "url": "https://x.com/singhprateek_25/status/2020094806735700154",
- "author_handle": "singhprateek_25",
- "date": "2026-02-07",
- "date_confidence": "high",
- "engagement": {
- "likes": 6,
- "reposts": 1
- },
- "relevance": 0.7,
- "why_relevant": "",
- "subs": {
- "relevance": 70,
- "recency": 40,
- "engagement": 39
- },
- "score": 53
- },
- {
- "id": "X1",
- "text": "Ripple: The Good Parts of React, Svelte, and Solid\n\nFrameworks rise and fall \u2014 and in 2026 we\u2019re deep in late-stage React. What\u2019s next? Join @erikras as he introduces Ripple, a TypeScript-first UI framework blending the best of React and Svelte with fine-grained reactivity and serious speed.\n\nSee the future of UI development live at CityJS London.\nGet your ticket now: https://t.co/wGMJJIELXm",
- "url": "https://x.com/cityjsconf/status/2024908272646398229",
- "author_handle": "cityjsconf",
- "date": "2026-02-20",
- "date_confidence": "high",
- "engagement": {
- "reposts": 1
- },
- "relevance": 0.7,
- "why_relevant": "",
- "subs": {
- "relevance": 70,
- "recency": 83,
- "engagement": 0
- },
- "score": 52
- },
- {
- "id": "X2",
- "text": "Prediction: 2026 marks the death of the JS frameworks. React, Svelte, Astro etc... only exist because humans need organized code to stay sane. \n\nOnce we stop reading the source code, we stop needing the structure. \n\nIn an AI-first world, JS frameworks are just overhead.",
- "url": "https://x.com/dennydotio/status/2024195986890109320",
- "author_handle": "dennydotio",
- "date": "2026-02-18",
- "date_confidence": "high",
- "engagement": {
- "likes": 1
- },
- "relevance": 0.7,
- "why_relevant": "",
- "subs": {
- "relevance": 70,
- "recency": 76,
- "engagement": 7
- },
- "score": 52
- },
- {
- "id": "X5",
- "text": "Web developers, what stack are you using in 2026?\nReact? Vue? Svelte? Vanilla? Curious what\u2019s actually winning.",
- "url": "https://x.com/AzamCodes/status/2022298287236383010",
- "author_handle": "AzamCodes",
- "date": "2026-02-13",
- "date_confidence": "high",
- "engagement": {
- "likes": 2,
- "replies": 1
- },
- "relevance": 0.7,
- "why_relevant": "",
- "subs": {
- "relevance": 70,
- "recency": 60,
- "engagement": 19
- },
- "score": 52
- },
- {
- "id": "X8",
- "text": "[2026/02/11 18:00] \u30c8\u30ec\u30f3\u30c91\u4f4d\n\u3010WebF\u3011React/Vue/Svelte\u304c\u305d\u306e\u307e\u307e\u30cd\u30a4\u30c6\u30a3\u30d6\u30a2\u30d7\u30ea\u306b\u306a\u308b\u3088 by rana_kualu https://t.co/GiAnTHpOBB",
- "url": "https://x.com/QiitaTrend/status/2021509592489025785",
- "author_handle": "QiitaTrend",
- "date": "2026-02-11",
- "date_confidence": "high",
- "engagement": null,
- "relevance": 0.7,
- "why_relevant": "",
- "subs": {
- "relevance": 70,
- "recency": 53,
- "engagement": 35
- },
- "score": 52
- },
- {
- "id": "X10",
- "text": "@babakfpk In 2026, picking React for a new project only really makes sense if you value ecosystem maturity, are following existing team habits, or need React-specific libraries. Same for Vue. Otherwise, use Solid or Svelte.",
- "url": "https://x.com/nicobaogim/status/2020689628282667407",
- "author_handle": "nicobaogim",
- "date": "2026-02-09",
- "date_confidence": "high",
- "engagement": null,
- "relevance": 0.7,
- "why_relevant": "",
- "subs": {
- "relevance": 70,
- "recency": 46,
- "engagement": 35
- },
- "score": 50
- },
- {
- "id": "X12",
- "text": "2026 framework discourse:\n\nDev 1: \"React is bloated, use Svelte\"\nDev 2: \"Svelte has no ecosystem, use Vue\"\nDev 3: \"Vue is dying, use Solid\"\nDev 4: \"Just use React\"\n\nMeanwhile React: *still powering 80% of the web*\n\nThe discourse never ends. \ud83d\udc80",
- "url": "https://x.com/Chubbi_Stephen/status/2020087839648800853",
- "author_handle": "Chubbi_Stephen",
- "date": "2026-02-07",
- "date_confidence": "high",
- "engagement": null,
- "relevance": 0.7,
- "why_relevant": "",
- "subs": {
- "relevance": 70,
- "recency": 40,
- "engagement": 35
- },
- "score": 49
- }
- ],
- "web": [],
- "youtube": [
- {
- "id": "yl0YWA2K2B0",
- "title": "React wants to win you back\u2026",
- "url": "https://www.youtube.com/watch?v=yl0YWA2K2B0",
- "channel_name": "Fireship",
- "date": "2025-10-17",
- "date_confidence": "high",
- "engagement": {
- "num_comments": 1000,
- "likes": 22894,
- "views": 699597
- },
- "transcript_snippet": "Last week, I got to participate in my favorite activity of the year. No, it wasn't watching all the overleveraged crypto bros learn what a margin call is. It was hate watching React comp as an anti-react influencer. Every year I crawl into my spelt Snuggy and I watch the React team try to convince everyone that their new APIs fix all of their old APIs. And though it hurts me to say it, this year I think they may have actually done it. I know this is an AI channel now, but in today's video, we'll revisit the worst parts of React to see if the announcements they made at React Comp will actually make a difference. It is October 17th, 2025, and you're watching the code report. The greatest trick the devil ever pulled was making referential stability a prerequisite to writing React apps that don't blow up. Just last month, Cloudflare had a massive outage because their dashboard kept making unnecessary calls to one of their own APIs, which led to them basically themselves. If you're a React dev, you know this is the primary use case for React's use effect hook. And it's exactly what caused this embarrassing bug for Cloudflare. But it's hard to blame React for this. The whole situation could have been avoided if only the poor debit Cloudflare would have read the docs on use effect. Then this post which shows you when to use it. Then this post which shows you when to not use it. Then this post which shows you what it actually does. Then this post which shows you how to use it correctly. And now with React 19.2. this post which shows you how to work around it with React's new use effect event hook. The way it works is it creates a function that you can use inside of use effect without needing to declare it in the dependency array. This is nice for situations like this where you need to use a value inside of use effect but don't want the effect to rerun when it changes. And the other big API addition was the new activity component which you can think of like a smarter display none that React itself controls. It lets you show and hide certain components, but unlike display none, React is able to maintain the state of those components across visibility and also deprioritize the work it does for hidden components. And if you've been drinking your lead lace protein and want to live on the edge, you can install React's Canary version where you'll get the new view transition component for animating transitions between different UIs. And you'll be able to pass a ref to a fragment, which makes working with platform APIs a little easier. But the most impactful announcements of the conference may have not been related to the React API. The first React compiler finally hit 1.0. If you've been in any React codebase lately, you've...",
- "relevance": 0.7,
- "why_relevant": "YouTube video about react vs svelte 2026",
- "subs": {
- "relevance": 70,
- "recency": 0,
- "engagement": 99
- },
- "score": 61
- },
- {
- "id": "MnpuK0MK4yo",
- "title": "React VS Svelte...10 Examples",
- "url": "https://www.youtube.com/watch?v=MnpuK0MK4yo",
- "channel_name": "Beyond Fireship",
- "date": "2023-06-30",
- "date_confidence": "high",
- "engagement": {
- "num_comments": 1100,
- "likes": 25490,
- "views": 697142
- },
- "transcript_snippet": "any reasonable developer in today's world would learn react because it's the status quo and that's where the jobs are but life is a lot more fun when you become unreasonable and go against the status quo to push web development forward for future Generations in today's video we'll compare react and spelled side by side by looking at common patterns and design choices by the framework creators I'm not here to tell you that one is better than the other because that's what the comment section below this video is for the only way to truly find out which one is best is to build something with both of them and decide for yourself I just released a brand new full spell kick course yesterday and also have a full next js13 course for fireship pro members going through each one of these courses will give you a really good idea of which framework is best for you first up we need to talk about rendering both of these Frameworks do the same thing they help developers build reactive uis with JavaScript by organizing code into reusable components an end user would never be able to tell the difference between the two but when it comes to the developer experience there is a world of difference react.js uses a runtime called the virtual Dom it keeps track of data changes in the application in order to render them in the act actual Dom in the browser the drawback is that this runtime requires some initial JavaScript and in Frameworks like nextjs your Baseline is around 70 kilobytes just to render a hello world as felt on the other hand takes an entirely different approach using a compiler to eliminate the need for a runtime it takes your svelt code and converts it into vanilla JavaScript which results in a far smaller hello world using a compiler though is kind of like cheating react.js is just JavaScript whereas spill can take non-javascript code to allow developers to do things more efficiently than they could otherwise but some JavaScript fundamentalists might consider this black magic what's ironic though is that vanilla JavaScript libraries tend to be much easier to work with in spell when compared to react however react does have a massive ecosystem of dedicated libraries to help you get things done now let's look at an actual code example of component State here we have a basic counter app in react we use functions to create components and then add reactive state to them with the use State hook that returns a reactive value and a Setter function to update the state pretty simple but let's see how it compares to svelt here on the right side in spelled you have only one component file and Define the logic within the script tags to create reactive State all you do is create a variable with the let keyword from there we can define a function on the click event...",
- "relevance": 0.7,
- "why_relevant": "YouTube video about react vs svelte 2026",
- "subs": {
- "relevance": 70,
- "recency": 0,
- "engagement": 100
- },
- "score": 61
- },
- {
- "id": "aYyZUDFZTrM",
- "title": "JavaScript framework reinvents itself\u2026 Did \"runes\" just ruin Svelte?",
- "url": "https://www.youtube.com/watch?v=aYyZUDFZTrM",
- "channel_name": "Fireship",
- "date": "2024-10-24",
- "date_confidence": "high",
- "engagement": {
- "num_comments": 1200,
- "likes": 24456,
- "views": 547836
- },
- "transcript_snippet": "about a year ago I made a tweet that said the dollar sign in spelt is the most powerful abstraction in modern front-end web development usually I'm not wrong about anything ever but this tweet ended up aging like a fine milk because just Weeks Later spelt announced a brand new feature called runes that significantly changed the developer experience by eliminating the dollar sign Syntax for reactive data and then just a few days ago this feature became official with the release of spelt 5 as an avid user of spelt myself I was pretty terrified of this feature at first glance I was one of the few survivors of the angularjs to angular 2 migration I shot myself in the foot during the react hooks Revolution and I lost some good friends during the view options to composition transition and I just can't do this anymore man so maybe I'll just go back to Wordpress uh never mind spelt it is in today's video we'll find out what the hell a rune is and whether or not they're going to ruin spelt it is October 24th 2024 and you're watching the code report most people really liked runes when they were first announced but not everybody I was in denial at first then I got angry then I bargained about the tradeoffs then came depression and finally I've come to accept and really enjoy runes the big question is why would the most L JavaScript framework make a risky departure from the features people love about it like reactive state with let and reactive side effects with the dollar sign just look at how much nicer this code is compared to reactjs react is even copy and spelt with its own compiler to make this code not suck so much the spelt code is lean and concise but one could argue that it's almost too clever let looks exact like regular JavaScript but because spelt uses a compiler it doesn't actually work like regular JavaScript under the hood the compiler does some magic to automatically update the UI whenever this value changes another issue is that this magic only works in a spelt file and that means it's difficult to share reactive logic throughout your application and spelt created an entirely different API called stores to address that issue however runes fix both of these limitations by a making your code more explicit and B they create a universal reactivity system that can be used not only in spel components but also in plain JavaScript or typescript files and this dramatically simplifies the overall framework like once you learn these four runes you'll know about 90% of what you need to know for the framework because everything else works like normal JavaScript and HTML let's go ahead and learn the four major runes now which you can think of as compiler macros that tell spelt to do something special that doesn't behave like regular JavaScript or HTML a...",
- "relevance": 0.7,
- "why_relevant": "YouTube video about react vs svelte 2026",
- "subs": {
- "relevance": 70,
- "recency": 0,
- "engagement": 98
- },
- "score": 60
- },
- {
- "id": "fn_uSZW5psM",
- "title": "The Untold Story of Svelte",
- "url": "https://www.youtube.com/watch?v=fn_uSZW5psM",
- "channel_name": "CodeSource",
- "date": "2025-05-14",
- "date_confidence": "high",
- "engagement": {
- "num_comments": 144,
- "likes": 2120,
- "views": 57784
- },
- "transcript_snippet": "",
- "relevance": 0.7,
- "why_relevant": "YouTube video about react vs svelte 2026",
- "subs": {
- "relevance": 70,
- "recency": 0,
- "engagement": 69
- },
- "score": 52
- },
- {
- "id": "IpJh0VEzMRo",
- "title": "I Was Wrong About Svelte...",
- "url": "https://www.youtube.com/watch?v=IpJh0VEzMRo",
- "channel_name": "Ben Davis",
- "date": "2025-10-12",
- "date_confidence": "high",
- "engagement": {
- "num_comments": 122,
- "likes": 1043,
- "views": 24631
- },
- "transcript_snippet": "",
- "relevance": 0.7,
- "why_relevant": "YouTube video about react vs svelte 2026",
- "subs": {
- "relevance": 70,
- "recency": 0,
- "engagement": 61
- },
- "score": 49
- },
- {
- "id": "1cGtYEXGm8c",
- "title": "This is THE Framework You Should be Using in 2026",
- "url": "https://www.youtube.com/watch?v=1cGtYEXGm8c",
- "channel_name": "Ben Davis",
- "date": "2026-01-06",
- "date_confidence": "high",
- "engagement": {
- "num_comments": 142,
- "likes": 793,
- "views": 16899
- },
- "transcript_snippet": "",
- "relevance": 0.7,
- "why_relevant": "YouTube video about react vs svelte 2026",
- "subs": {
- "relevance": 70,
- "recency": 0,
- "engagement": 57
- },
- "score": 48
- },
- {
- "id": "qwDp5pZA_TA",
- "title": "The Front-end Frameworks Guide 2026: React vs Angular vs Vue vs Svelt vs Solid vs Astro",
- "url": "https://www.youtube.com/watch?v=qwDp5pZA_TA",
- "channel_name": "Code Hub",
- "date": "2026-02-08",
- "date_confidence": "high",
- "engagement": {
- "num_comments": 4,
- "likes": 31,
- "views": 646
- },
- "transcript_snippet": "",
- "relevance": 0.7,
- "why_relevant": "YouTube video about react vs svelte 2026",
- "subs": {
- "relevance": 70,
- "recency": 43,
- "engagement": 17
- },
- "score": 47
- },
- {
- "id": "_vuVy21l2bU",
- "title": "React vs Svelte: The Brutal Honest Comparison EVER",
- "url": "https://www.youtube.com/watch?v=_vuVy21l2bU",
- "channel_name": "Code Hub",
- "date": "2025-10-30",
- "date_confidence": "high",
- "engagement": {
- "num_comments": 27,
- "likes": 204,
- "views": 6454
- },
- "transcript_snippet": "",
- "relevance": 0.7,
- "why_relevant": "YouTube video about react vs svelte 2026",
- "subs": {
- "relevance": 70,
- "recency": 0,
- "engagement": 42
- },
- "score": 44
- },
- {
- "id": "1BCsdaeYv0A",
- "title": "Svelte vs React in 2025 - Make the RIGHT Choice (Difference Explained)",
- "url": "https://www.youtube.com/watch?v=1BCsdaeYv0A",
- "channel_name": "Daniel | Tech & Data",
- "date": "2023-11-26",
- "date_confidence": "high",
- "engagement": {
- "num_comments": 34,
- "likes": 0,
- "views": 33905
- },
- "transcript_snippet": "",
- "relevance": 0.7,
- "why_relevant": "YouTube video about react vs svelte 2026",
- "subs": {
- "relevance": 70,
- "recency": 0,
- "engagement": 30
- },
- "score": 40
- },
- {
- "id": "41HXdxGekZ0",
- "title": "Vue vs React vs Svelte (2026) - Which One Is BEST?",
- "url": "https://www.youtube.com/watch?v=41HXdxGekZ0",
- "channel_name": "Paperclick",
- "date": "2025-10-04",
- "date_confidence": "high",
- "engagement": {
- "num_comments": 0,
- "likes": 3,
- "views": 287
- },
- "transcript_snippet": "",
- "relevance": 0.7,
- "why_relevant": "YouTube video about react vs svelte 2026",
- "subs": {
- "relevance": 70,
- "recency": 0,
- "engagement": 0
- },
- "score": 31
- }
- ],
- "hackernews": [],
- "best_practices": [],
- "prompt_pack": [],
- "context_snippet_md": "# Context: React vs Svelte 2026 (Last 30 Days)\n\n*Generated: 2026-02-25 | Sources: both*\n\n## Key Sources\n\n- [X] TypeScript is the baseline in 2026. But most forms...\n- [Reddit] What do you guys think about comparison between React vs Svelte?\n- [Reddit] Migrated our startup from React to Svelte 5 - Performance gains and lessons learned\n- [X] Popular JavaScript Frameworks / Frontend Libraries...\n- [X] Requirements for a fresher in 2026:\n\nPython, JavaS...\n- [X] Ripple: The Good Parts of React, Svelte, and Solid...\n- [X] Prediction: 2026 marks the death of the JS framewo...\n\n## Summary\n\n*See full report for best practices, prompt pack, and detailed sources.*\n"
-}
diff --git a/docs/comparison-results/prompts/base-synthesis-prompt.md b/docs/comparison-results/prompts/base-synthesis-prompt.md
deleted file mode 100644
index 85c2583..0000000
--- a/docs/comparison-results/prompts/base-synthesis-prompt.md
+++ /dev/null
@@ -1,77 +0,0 @@
-# Synthesis Instructions (Base version - no Hacker News)
-
-## Judge Agent: Synthesize All Sources
-
-After all searches complete, internally synthesize (don't display stats yet):
-
-1. Weight Reddit/X sources HIGHER (they have engagement signals: upvotes, likes)
-2. Weight YouTube sources HIGH (they have views, likes, and transcript content)
-3. Weight WebSearch sources LOWER (no engagement data)
-4. Identify patterns that appear across ALL sources (strongest signals)
-5. Note any contradictions between sources
-6. Extract the top 3-5 actionable insights
-
-## Internalize the Research
-
-CRITICAL: Ground your synthesis in the ACTUAL research content, not your pre-existing knowledge.
-
-Read the research output carefully. Pay attention to:
-- Exact product/tool names mentioned
-- Specific quotes and insights from the sources - use THESE, not generic knowledge
-- What the sources actually say, not what you assume the topic is about
-
-## Citation Rules
-
-CITATION RULE: Cite sources sparingly to prove research is real.
-- In the "What I learned" intro: cite 1-2 top sources total, not every sentence
-- In KEY PATTERNS: cite 1 source per pattern, short format: "per @handle" or "per r/sub"
-- Do NOT include engagement metrics in citations - save those for stats box
-- Do NOT chain multiple citations: "per @x, @y, @z" is too much. Pick the strongest one.
-
-CITATION PRIORITY (most to least preferred):
-1. @handles from X - "per @handle"
-2. r/subreddits from Reddit - "per r/subreddit"
-3. YouTube channels - "per [channel name] on YouTube"
-4. Web sources - ONLY when Reddit/X/YouTube don't cover that specific fact
-
-URL FORMATTING: NEVER paste raw URLs. Use the publication name, not the URL.
-
-Lead with people, not publications. Start each topic with what Reddit/X users are saying/feeling, then add web context only if needed.
-
-## Output Format
-
-Display in this EXACT sequence:
-
-**FIRST - What I learned:**
-
-Write the synthesis narrative. Use bold topic headers. Keep it grounded in research.
-
-For RECOMMENDATIONS queries: Extract SPECIFIC NAMES, not generic patterns. List by popularity/mention count with "Sources:" line per item.
-
-For other queries: Write thematic paragraphs with KEY PATTERNS section.
-
-**THEN - Stats block:**
-
-Copy this EXACTLY, replacing only the {placeholders}:
-
-```
----All agents reported back!
-├─ 🟠 Reddit: {N} threads │ {N} upvotes │ {N} comments
-├─ 🔵 X: {N} posts │ {N} likes │ {N} reposts
-├─ 🔴 YouTube: {N} videos │ {N} views │ {N} with transcripts
-├─ 🌐 Web: {N} pages (supplementary)
-└─ 🗣️ Top voices: @{handle1} ({N} likes), @{handle2} │ r/{sub1}, r/{sub2}
-```
-
-Calculate actual totals from the research output. Count posts/threads from each section. Sum engagement metrics.
-
-**LAST - Invitation:**
-
-```
----I'm now an expert on {TOPIC}. Some things I can help with:
-- [Specific suggestion based on research finding 1]
-- [Specific suggestion based on research finding 2]
-- [Specific suggestion based on research finding 3]
-```
-
-SELF-CHECK before displaying: Re-read your "What I learned" section. Does it match what the research ACTUALLY says?
diff --git a/docs/comparison-results/prompts/cross-synthesis-prompt.md b/docs/comparison-results/prompts/cross-synthesis-prompt.md
deleted file mode 100644
index 0d532e7..0000000
--- a/docs/comparison-results/prompts/cross-synthesis-prompt.md
+++ /dev/null
@@ -1,81 +0,0 @@
-# Synthesis Instructions (HN version - with Hacker News)
-
-## Judge Agent: Synthesize All Sources
-
-After all searches complete, internally synthesize (don't display stats yet):
-
-1. Weight Reddit/X sources HIGHER (they have engagement signals: upvotes, likes)
-2. Weight YouTube sources HIGH (they have views, likes, and transcript content)
-3. Weight WebSearch sources LOWER (no engagement data)
-4. Identify patterns that appear across ALL sources (strongest signals)
-5. Note any contradictions between sources
-6. Extract the top 3-5 actionable insights
-
-## Internalize the Research
-
-CRITICAL: Ground your synthesis in the ACTUAL research content, not your pre-existing knowledge.
-
-Read the research output carefully. Pay attention to:
-- Exact product/tool names mentioned
-- Specific quotes and insights from the sources - use THESE, not generic knowledge
-- What the sources actually say, not what you assume the topic is about
-
-## Citation Rules
-
-CITATION RULE: Cite sources sparingly to prove research is real.
-- In the "What I learned" intro: cite 1-2 top sources total, not every sentence
-- In KEY PATTERNS: cite 1 source per pattern, short format: "per @handle" or "per r/sub"
-- Do NOT include engagement metrics in citations - save those for stats box
-- Do NOT chain multiple citations: "per @x, @y, @z" is too much. Pick the strongest one.
-
-CITATION PRIORITY (most to least preferred):
-1. @handles from X - "per @handle"
-2. r/subreddits from Reddit - "per r/subreddit"
-3. YouTube channels - "per [channel name] on YouTube" (transcript-backed insights)
-4. HN discussions - "per HN" or "per hn/username" (developer community signal)
-5. Web sources - ONLY when Reddit/X/YouTube/HN don't cover that specific fact
-
-URL FORMATTING: NEVER paste raw URLs. Use the publication name, not the URL.
-
-Lead with people, not publications. Start each topic with what Reddit/X users are saying/feeling, then add web context only if needed.
-
-## Output Format
-
-Display in this EXACT sequence:
-
-**FIRST - What I learned:**
-
-Write the synthesis narrative. Use bold topic headers. Keep it grounded in research.
-
-For RECOMMENDATIONS queries: Extract SPECIFIC NAMES, not generic patterns. List by popularity/mention count with "Sources:" line per item.
-
-For other queries: Write thematic paragraphs with KEY PATTERNS section.
-
-**THEN - Stats block:**
-
-Copy this EXACTLY, replacing only the {placeholders}:
-
-```
----All agents reported back!
-├─ 🟠 Reddit: {N} threads │ {N} upvotes │ {N} comments
-├─ 🔵 X: {N} posts │ {N} likes │ {N} reposts
-├─ 🔴 YouTube: {N} videos │ {N} views │ {N} with transcripts
-├─ 🟡 HN: {N} stories │ {N} points │ {N} comments
-├─ 🌐 Web: {N} pages (supplementary)
-└─ 🗣️ Top voices: @{handle1} ({N} likes), @{handle2} │ r/{sub1}, r/{sub2}
-```
-
-If HN returned 0 stories, write: "├─ 🟡 HN: 0 stories (no results this cycle)"
-
-Calculate actual totals from the research output. Count posts/threads from each section. Sum engagement metrics.
-
-**LAST - Invitation:**
-
-```
----I'm now an expert on {TOPIC}. Some things I can help with:
-- [Specific suggestion based on research finding 1]
-- [Specific suggestion based on research finding 2]
-- [Specific suggestion based on research finding 3]
-```
-
-SELF-CHECK before displaying: Re-read your "What I learned" section. Does it match what the research ACTUALLY says?
diff --git a/docs/comparison-results/prompts/hn-synthesis-prompt.md b/docs/comparison-results/prompts/hn-synthesis-prompt.md
deleted file mode 100644
index 0d532e7..0000000
--- a/docs/comparison-results/prompts/hn-synthesis-prompt.md
+++ /dev/null
@@ -1,81 +0,0 @@
-# Synthesis Instructions (HN version - with Hacker News)
-
-## Judge Agent: Synthesize All Sources
-
-After all searches complete, internally synthesize (don't display stats yet):
-
-1. Weight Reddit/X sources HIGHER (they have engagement signals: upvotes, likes)
-2. Weight YouTube sources HIGH (they have views, likes, and transcript content)
-3. Weight WebSearch sources LOWER (no engagement data)
-4. Identify patterns that appear across ALL sources (strongest signals)
-5. Note any contradictions between sources
-6. Extract the top 3-5 actionable insights
-
-## Internalize the Research
-
-CRITICAL: Ground your synthesis in the ACTUAL research content, not your pre-existing knowledge.
-
-Read the research output carefully. Pay attention to:
-- Exact product/tool names mentioned
-- Specific quotes and insights from the sources - use THESE, not generic knowledge
-- What the sources actually say, not what you assume the topic is about
-
-## Citation Rules
-
-CITATION RULE: Cite sources sparingly to prove research is real.
-- In the "What I learned" intro: cite 1-2 top sources total, not every sentence
-- In KEY PATTERNS: cite 1 source per pattern, short format: "per @handle" or "per r/sub"
-- Do NOT include engagement metrics in citations - save those for stats box
-- Do NOT chain multiple citations: "per @x, @y, @z" is too much. Pick the strongest one.
-
-CITATION PRIORITY (most to least preferred):
-1. @handles from X - "per @handle"
-2. r/subreddits from Reddit - "per r/subreddit"
-3. YouTube channels - "per [channel name] on YouTube" (transcript-backed insights)
-4. HN discussions - "per HN" or "per hn/username" (developer community signal)
-5. Web sources - ONLY when Reddit/X/YouTube/HN don't cover that specific fact
-
-URL FORMATTING: NEVER paste raw URLs. Use the publication name, not the URL.
-
-Lead with people, not publications. Start each topic with what Reddit/X users are saying/feeling, then add web context only if needed.
-
-## Output Format
-
-Display in this EXACT sequence:
-
-**FIRST - What I learned:**
-
-Write the synthesis narrative. Use bold topic headers. Keep it grounded in research.
-
-For RECOMMENDATIONS queries: Extract SPECIFIC NAMES, not generic patterns. List by popularity/mention count with "Sources:" line per item.
-
-For other queries: Write thematic paragraphs with KEY PATTERNS section.
-
-**THEN - Stats block:**
-
-Copy this EXACTLY, replacing only the {placeholders}:
-
-```
----All agents reported back!
-├─ 🟠 Reddit: {N} threads │ {N} upvotes │ {N} comments
-├─ 🔵 X: {N} posts │ {N} likes │ {N} reposts
-├─ 🔴 YouTube: {N} videos │ {N} views │ {N} with transcripts
-├─ 🟡 HN: {N} stories │ {N} points │ {N} comments
-├─ 🌐 Web: {N} pages (supplementary)
-└─ 🗣️ Top voices: @{handle1} ({N} likes), @{handle2} │ r/{sub1}, r/{sub2}
-```
-
-If HN returned 0 stories, write: "├─ 🟡 HN: 0 stories (no results this cycle)"
-
-Calculate actual totals from the research output. Count posts/threads from each section. Sum engagement metrics.
-
-**LAST - Invitation:**
-
-```
----I'm now an expert on {TOPIC}. Some things I can help with:
-- [Specific suggestion based on research finding 1]
-- [Specific suggestion based on research finding 2]
-- [Specific suggestion based on research finding 3]
-```
-
-SELF-CHECK before displaying: Re-read your "What I learned" section. Does it match what the research ACTUALLY says?
diff --git a/docs/comparison-results/summary.md b/docs/comparison-results/summary.md
deleted file mode 100644
index 3b9fe07..0000000
--- a/docs/comparison-results/summary.md
+++ /dev/null
@@ -1,140 +0,0 @@
-# GOAT Synthesis Comparison: Final Results
-
-## Overall Winner: CROSS (4.74/5.0)
-
-| Pipeline | Topic 1 | Topic 2 | Topic 3 | Topic 4 | Topic 5 | **Average** |
-|----------|---------|---------|---------|---------|---------|------------|
-| | Claude Code | Seedance | MacBook | Rap Songs | React/Svelte | |
-| **CROSS** | **4.90** | **4.90** | **4.20** | **4.80** | **4.90** | **4.74** |
-| **HN** | 4.05 | 4.55 | 3.85 | 4.25 | 3.80 | **4.10** |
-| **Base** | 3.80 | 3.70 | 3.70 | 3.65 | 3.80 | **3.73** |
-
-CROSS won all 5 topics. No regressions.
-
-## Per-Dimension Analysis
-
-### Groundedness (30% weight)
-| Pipeline | Avg | Pattern |
-|----------|-----|---------|
-| CROSS | 4.8 | Cross-platform connections ("Reddit's MCP scaling + HN's Upjack project") add unique grounding |
-| HN | 4.2 | HN usernames and Show HN project names add specificity when available |
-| Base | 3.8 | Solid citations but narrower source pool limits grounding depth |
-
-### Specificity (25% weight)
-| Pipeline | Avg | Pattern |
-|----------|-----|---------|
-| CROSS | 4.8 | More Reddit threads = more named entities, version numbers, specific findings |
-| HN | 4.1 | HN projects add specificity for tech topics but nothing for non-tech |
-| Base | 3.6 | Fewer data points = more padding between specific findings |
-
-### Coverage (20% weight)
-| Pipeline | Avg | Pattern |
-|----------|-----|---------|
-| CROSS | 4.9 | Best coverage - most Reddit threads AND HN items naturally woven in |
-| HN | 3.9 | Good when HN fires (Topics 1-2), matches Base when HN=0 (Topics 3-5) |
-| Base | 3.5 | Reddit + X + YouTube only, no HN dimension |
-
-### Actionability (15% weight)
-| Pipeline | Avg | Pattern |
-|----------|-----|---------|
-| CROSS | 4.7 | Follow-up suggestions reference specific things from the richer data |
-| HN | 4.0 | Decent suggestions but less material to draw from |
-| Base | 3.6 | Generic suggestions due to thinner research base |
-
-### Format Compliance (10% weight)
-| Pipeline | Avg | Pattern |
-|----------|-----|---------|
-| CROSS | 4.6 | Stats blocks most complete (cross-ref counts), invitations most specific |
-| HN | 4.0 | Good format, HN line present when applicable |
-| Base | 3.8 | Clean format but missing HN source line |
-
-## Why CROSS Wins
-
-1. **Coverage is the biggest differentiator.** CROSS consistently pulled the most Reddit threads (10, 19, 3, 11, 6 across topics) AND had HN data, giving the synthesis more material to work with.
-
-2. **Cross-platform connections add analytical value.** Linking MCP scaling problems on Reddit to infrastructure projects on HN (Topic 1), connecting API delays to Hollywood backlash to ElevenLabs removal (Topic 2), and surfacing the Kendrick Grammy win from r/KendrickLamar (Topic 4) - these were insights that neither Base nor HN produced alone.
-
-3. **Actionability follows from data richness.** With more specific findings to draw from, CROSS's follow-up suggestions are naturally more specific and grounded.
-
-## Where CROSS Still Falls Short (Improvement Opportunities)
-
-### 1. Cross-source linking barely fires (3/178 items linked)
-The CROSS version's cross_refs field was populated for only 3 items across all 5 topics. The Jaccard 0.5 threshold is too strict. With the hybrid fix (token+trigram at 0.40), this would go from 3 to 26 items.
-
-### 2. Cross-ref rendering is cryptic
-When cross-refs DO appear, they show as `[xref: HN5, HN4]` in the compact output. This means nothing to the synthesis agent. Should be `[also on: HN, Reddit]` so Claude naturally writes "discussed on both Reddit and HN."
-
-### 3. YouTube synonym gap
-"Lit Hip Hop Mix 2026" scored 0.33 relevance for "best rap songs 2026" because "hip hop" != "rap" in token matching. Needs synonym awareness.
-
-### 4. HN search too narrow for framework topics
-React/Svelte returned 0 HN items despite being a common HN topic. Need OR queries for multi-keyword topics.
-
-### 5. Synthesis instructions don't mention cross-refs
-SKILL.md has zero instructions about using cross-ref data in the synthesis. Claude ignores the `[xref:]` tags because nothing tells it to pay attention to them.
-
-## Recommended GOAT Improvements (Priority Order)
-
-| # | Change | Impact | File |
-|---|--------|--------|------|
-| 1 | Hybrid cross-source linking (token+trigram Jaccard at 0.40) | 3 -> 26 linked items | `scripts/lib/dedupe.py` |
-| 2 | Human-readable cross-ref tags (`[also on: HN, Reddit]`) | Claude can use cross-refs in narrative | `scripts/lib/render.py` |
-| 3 | SKILL.md cross-ref synthesis instruction | Explicitly tell Claude to use cross-platform signals | `SKILL.md` |
-| 4 | YouTube synonym awareness (SYNONYMS dict) | "hip hop" matches "rap" | `scripts/lib/youtube_yt.py` |
-| 5 | HN search OR queries for multi-keyword topics | Framework debates get HN coverage | `scripts/lib/hackernews.py` |
-
-## Validation: Improved CROSS vs Original CROSS
-
-After implementing all 5 GOAT improvements (hybrid cross-source linking, human-readable tags, SKILL.md instruction, YouTube synonyms), we regenerated synthesis narratives from improved compact markdowns and evaluated them against the originals.
-
-### Improved CROSS Results
-
-| Topic | Original CROSS | Improved CROSS | Delta |
-|-------|:-:|:-:|:-:|
-| 1. Claude Code | 4.15 | **4.55** | +0.40 |
-| 2. Seedance | **4.60** | 4.40 | -0.20 |
-| 3. MacBook | 3.90 | **4.35** | +0.45 |
-| 4. Rap Songs | 3.15 | **4.25** | +1.10 |
-| 5. React/Svelte | 4.10 | **4.35** | +0.25 |
-| **Average** | **3.98** | **4.38** | **+0.40** |
-
-Improved CROSS wins 4/5 topics. The one regression (Seedance, -0.20) is because the original had denser HN citation detail (explicit `[xref: HN5/R6]` notation) that the improved version traded for cleaner formatting.
-
-### Per-Dimension Deltas (Improved - Original)
-
-| Dimension | Original Avg | Improved Avg | Delta |
-|-----------|:-:|:-:|:-:|
-| Groundedness (30%) | 4.0 | 4.0 | 0.0 |
-| Specificity (25%) | 4.2 | 5.0 | **+0.8** |
-| Coverage (20%) | 4.2 | 4.4 | +0.2 |
-| Actionability (15%) | 3.4 | 3.8 | +0.4 |
-| Format (10%) | 3.8 | 4.8 | **+1.0** |
-
-**Biggest gains:** Specificity (+0.8) from more direct quotes, precise numbers, and structured recommendation lists. Format compliance (+1.0) from consistently including KEY PATTERNS, standard stats blocks, and cross-platform indicators.
-
-### What the improvements actually did
-
-1. **`[also on: HN, Reddit]` tags** - Claude naturally weaves cross-platform findings into narrative ("a marketing skills tutorial hit 47K YouTube views AND landed on Hacker News simultaneously")
-2. **YouTube synonyms** - "Lit Hip Hop Mix 2026" now scores 0.71 (was 0.33) for "best rap songs 2026", surfacing relevant content that was previously filtered out
-3. **Hybrid similarity at 0.40** - Cross-source linking went from 3 linked items (original) to 13+ (improved) across 5 topics
-4. **SKILL.md instruction #7** - "Cross-platform signals are the strongest evidence" directs Claude to lead with multi-platform findings
-
-### Cross-source linking validation
-
-| Topic | Original links | Improved links |
-|-------|:-:|:-:|
-| Claude Code | 0 | 2 items, 2 [also on:] tags |
-| Seedance | 3 | 9 items, 7 [also on:] tags |
-| MacBook | 0 | 2 items, 1 [also on:] tag |
-| Rap Songs | 0 | 0 (expected - no overlap) |
-| React/Svelte | 0 | 0 (expected - no overlap) |
-| **Total** | **3** | **13** |
-
-## Methodology
-
-- 15 JSON result files from same-day topic-sequential test runs (5 topics x 3 versions)
-- JSON converted to compact markdown using `render_compact()` (version-specific data preserved)
-- 15 synthesis narratives generated by Claude following version-specific SKILL.md instructions
-- Blinded LLM evaluation: versions randomized per topic as A/B/C, scored on 5-dimension rubric
-- Rubric weights: 30% groundedness, 25% specificity, 20% coverage, 15% actionability, 10% format
-- Validation: 5 improved compact markdowns regenerated with updated code, 5 new synthesis narratives generated and evaluated against originals
diff --git a/docs/comparison-results/synthesis/base-1-claude-code.md b/docs/comparison-results/synthesis/base-1-claude-code.md
deleted file mode 100644
index 589fa49..0000000
--- a/docs/comparison-results/synthesis/base-1-claude-code.md
+++ /dev/null
@@ -1,35 +0,0 @@
-What I learned:
-
-The Claude Code skills ecosystem is in a rapid expansion phase, with community-built skill libraries and frameworks emerging as the dominant trend across Reddit and X this month. @ihtesham2005 flagged that Anthropic open-sourced their internal Skills library, and r/ClaudeCode is seeing a wave of builders shipping reusable skill packs.
-
-**Skills adoption is accelerating at the team level** - One team reported 3x-ing their Claude Code skill usage in two weeks by syncing skills, hooks, and MCP configs across their org, per r/ClaudeCode. The key was making skills discoverable and easy to install rather than relying on individual setup.
-
-**One-command frameworks are consolidating the ecosystem** - ClaudeInOne bundles 135 agents, 35 curated skills, 121 plugins, and 6 MCP configs into a single install, per @arpan7sarkar. This "batteries included" approach is gaining traction as the skill count grows faster than people can evaluate individually.
-
-**Self-improvement loops are the power-user pattern** - A "wrap-up" skill that captures session learnings and feeds them back into future sessions was highlighted as a favorite Claude Code skill on r/ClaudeCode. The idea of skills that improve themselves over time resonated heavily.
-
-**Non-developers are building with skills** - Grace Leung's YouTube tutorial on building an AI marketing team with skills in 16 minutes pulled 47K views in 4 days. The framing has shifted from "developer tool" to "anyone who can describe a workflow."
-
-**MCP servers remain the infrastructure layer** - Robin Ebers' "8 MCP Servers That Make Claude Code 10x Better" (90K views) argues most MCP servers are "overhyped garbage" but a handful are genuine game changers. The consensus is that skills handle workflow logic while MCP servers handle tool integrations.
-
-KEY PATTERNS from the research:
-1. Skills are moving from individual to team-shared - syncing across orgs is the unlock, per r/ClaudeCode
-2. Skill marketplaces and directories are emerging as discovery mechanisms, per @ghumare64
-3. Marketing and SEO are the fastest-growing non-dev skill categories, per r/ClaudeCode
-4. Skills vs MCP confusion persists - Postman and Solo Swift Crafter both made explainer videos clarifying the distinction
-5. Builder skills that generate other skills are the meta-pattern, per @matgoldsborough
-
----
-All agents reported back!
-├─ 🟠 Reddit: 4 threads │ 291 upvotes │ 0 comments
-├─ 🔵 X: 11 posts │ 22 likes │ 1 reposts
-├─ 🔴 YouTube: 10 videos │ 461,224 views │ 3 with transcripts
-├─ 🟡 HN: 0 stories (no results this cycle)
-├─ 🌐 Web: supplementary
-└─ 🗣️ Top voices: @ihtesham2005 (6 likes), @zeeg (4 likes, 1 RT) │ r/ClaudeCode
----
-
-I'm now an expert on Claude Code skills and MCP servers. Some things I can help with:
-- Compare the ClaudeInOne framework vs building your own skill library from scratch
-- Walk through setting up a self-improvement loop skill for your workflow
-- Help you decide which MCP servers are actually worth installing based on Robin Ebers' teardown
diff --git a/docs/comparison-results/synthesis/base-2-seedance.md b/docs/comparison-results/synthesis/base-2-seedance.md
deleted file mode 100644
index 5d4b74c..0000000
--- a/docs/comparison-results/synthesis/base-2-seedance.md
+++ /dev/null
@@ -1,35 +0,0 @@
-## What I learned
-
-ByteDance's **Seedance 2.0** has erupted across AI video communities this month, with r/generativeAI and @HBCoop_ capturing the intensity - a mix of awe at its cinematic output and frustration over access chaos.
-
-**CapCut integration goes live.** As of Feb 25, Seedance 2.0 is available inside CapCut desktop and mobile, removing the need for a Chinese phone number. @jznode reports it runs about $2 per generation, making it the first broadly accessible path to the model outside China.
-
-**One-minute films with zero editing.** A viral r/generativeAI thread describes Seedance 2.0 generating a full one-minute film with multi-shot coherence and transitions - no post-production required. Commenters are split between calling it a filmmaking revolution and noting it still produces "slop" on closer inspection.
-
-**Access remains a mess.** Multiple threads across r/Seedance_AI and r/generativeAI ask the same question: where is the official website? The answer is complicated - Jimeng/Jianying is the primary platform but has geo-restrictions, account bans, and network errors. Third-party APIs (useapi.net) and integrations (NemoVideo) have popped up, but r/Seedance_AI users warn many are scams.
-
-**Hollywood backlash and SAG-AFTRA fallout.** r/AIGuild reports ByteDance faces backlash from Hollywood over IP concerns. Separately, r/AI_UGC_Marketing notes that ElevenLabs silently removed Seedance 2.0 from its platform after a SAG-AFTRA incident, cutting off a key UGC video workflow.
-
-**Content guardrails frustrate power users.** A r/Seedance_AI user reports wasting $1,000+ on generations that fail content review. The strict moderation filters are pushing creators toward third-party APIs that may bypass restrictions but offer uncertain quality.
-
-**YouTube creators crown it king.** Theoretically Media's review (199K views) declares Seedance 2.0 has claimed the "AI video throne" just a week after Kling 3.0 held that title. Multiple tutorial channels (How To In 5 Minutes, Ai Lockup) are racing to publish free-access guides.
-
-**Phishing scams exploit the hype.** @WyldeChyldeRec warns about phishing emails using Seedance branding with fake "unrestricted generation" offers - a sign of how much demand has outpaced official supply.
-
-### KEY PATTERNS
-
-1. **Access demand far exceeds official supply** - the gap between Seedance 2.0's capabilities and its availability outside China has created a cottage industry of third-party wrappers, scam sites, and workarounds (r/generativeAI)
-2. **Platform musical chairs** - Seedance 2.0 gets added to CapCut, removed from ElevenLabs, and claimed by NemoVideo all within weeks, making the ecosystem unstable for anyone building workflows (@grok)
-3. **Quality vs. guardrails tension** - the same content moderation that addresses Hollywood/SAG-AFTRA concerns is the thing driving power users to abandon official channels (r/Seedance_AI)
-4. **AI video benchmarks reset weekly** - Theoretically Media declared Kling 3.0 the benchmark, then Seedance 2.0 dethroned it within a week, reflecting how fast this space moves (Theoretically Media)
-5. **Scam ecosystem mirrors real demand** - phishing emails and fake sites emerge within days of each capability announcement, indicating mainstream interest beyond the AI-native audience (@WyldeChyldeRec)
-
-```
- 30-DAY SNAPSHOT
-├─ Reddit: 16 threads across r/Seedance_AI, r/generativeAI, r/AIGuild +5 more
-├─ X: 11 posts from @HBCoop_, @grok, @EmmaUsesAi +8 more
-├─ YouTube: 10 videos (199K views on Theoretically Media alone)
-└─ Web: supplementary context
-```
-
-Want to dig deeper? I can look into **how Seedance 2.0 compares to Kling 3.0 on specific benchmarks**, **the SAG-AFTRA incident timeline and its ripple effects on AI video platforms**, or **which third-party Seedance APIs are legitimate vs. scams**.
diff --git a/docs/comparison-results/synthesis/base-3-macbook.md b/docs/comparison-results/synthesis/base-3-macbook.md
deleted file mode 100644
index f4cd787..0000000
--- a/docs/comparison-results/synthesis/base-3-macbook.md
+++ /dev/null
@@ -1,25 +0,0 @@
-# What I learned
-
-The M4 MacBook Pro has been out long enough that the conversation has shifted from launch hype to real-world ownership reports - and the picture is more nuanced than the initial review wave suggested. Across r/macbookpro, YouTube reviewers like MKBH and Dave2D, and X chatter from @bhphoto and @jameslmorton, a clear pattern emerges: the M4 lineup is genuinely excellent hardware, but choosing the right configuration matters more than just picking "the best one."
-
-**The base M4 is the real story.** MacRumors made the bold claim that the base M4 MacBook Pro is all most people need, recommending users skip the M4 Pro and Max entirely. This echoes Just Josh's review and recommendations video, which walked through specific use cases where the base chip handles the workload just fine. For general dev work, photography, and everyday professional use, the consensus is that the jump to Pro/Max is wasted money unless you have a specific sustained workload that demands it.
-
-**Battery life claims don't hold up for everyone.** One of the most active discussion threads on r/macbookpro directly compares "battery life reality vs review" numbers, with users reporting real-world results that fall short of Apple's headline claims. The 14-inch model's battery in particular drew scrutiny, with another thread asking "should I be concerned?" after seeing lower-than-expected drain rates. Reviewers tested under controlled conditions; actual usage with Electron apps, browsers, and background processes tells a different story.
-
-**Long-term reviews are more honest than launch reviews.** Brandon Butch's 3-month review is titled "This Feels Wrong" - pointing to the cognitive dissonance of a machine that benchmarks well but doesn't feel dramatically different from the M3 in daily use. Created Tech's 6-month follow-up and Tech It Easy's 1-year review both confirm this: the M4 Pro is a solid incremental upgrade, not a generational leap. Meanwhile, at least one r/macbookpro user posted about being genuinely "disappointed" with heat and app stability issues, per r/macbookpro.
-
-**Local AI workloads are the new benchmark.** A notable shift in the X conversation: multiple posts (including from @grok and @jameslmorton) discuss running LLMs locally on M4 Pro/Max hardware. The 128GB unified memory config can push 70 tokens/s on local models, per @jameslmorton, and Ollama runs natively via Metal. This is becoming a real purchase consideration for developers and AI enthusiasts - something no traditional review covers well.
-
-**M3 vs M4 upgrade: marginal for most.** @bhphoto's comparison guide between M3 and M4 silicon suggests the differences are real but not dramatic for most workflows. Unless you specifically need the Nano texture display, Thunderbolt 5, or the extra GPU cores, holding an M3 Pro is still a strong position.
-
----All agents reported back!
-├─ 🟠 Reddit: 3 threads │ 0 upvotes │ 0 comments
-├─ 🔵 X: 12 posts │ 10 likes │ 1 repost
-├─ 🔴 YouTube: 10 videos │ 7,866,702 views │ 3 with transcripts
-├─ 🌐 Web: 0 pages (supplementary)
-└─ 🗣️ Top voices: @bhphoto (3 likes), @jameslmorton │ r/macbookpro
-
----I'm now an expert on M4 MacBook Pro reviews. Some things I can help with:
-- Compare the base M4 vs M4 Pro vs M4 Max for your specific workload (dev, creative, AI/ML)
-- Break down real-world battery life expectations by screen size and usage pattern
-- Advise whether upgrading from M1/M2/M3 is worth it based on long-term reviewer consensus
diff --git a/docs/comparison-results/synthesis/base-4-rap.md b/docs/comparison-results/synthesis/base-4-rap.md
deleted file mode 100644
index b5762fd..0000000
--- a/docs/comparison-results/synthesis/base-4-rap.md
+++ /dev/null
@@ -1,25 +0,0 @@
-# What I learned
-
-The conversation around "best rap songs 2026" is still early-year and fragmented - no single track has emerged as a consensus pick yet, but a few names keep surfacing. Lil Uzi Vert's "What You Saying" is the closest thing to a frontrunner, cited across multiple @grok posts on X and referenced in r/hiphopheads discussion threads as hitting #1 on Hot Rap Songs in January 2026 and peaking at #14 on the Hot 100.
-
-**Most-mentioned artists and tracks across sources:**
-
-- **Lil Uzi Vert - "What You Saying"** - mentioned in 3 X posts (@grok) and discussed in r/hiphopheads daily threads. The standout chart performer so far in 2026, with his third career #1 on Hot Rap Songs.
-- **Nuno Zigi - "World Best Lie"** - highlighted by @Zika_gfx as a meaningful 2026 rap track (highest-scored X post at 86).
-- **Tyga, Quavo, Wiz Khalifa, 50 Cent** - featured in West Coast Finest's "Lit Hip Hop Mix 2026" on YouTube (407K views), the most-watched video in these results.
-- **Sean Bryant - "Love vs Loyalty" (album)** - teased by @teammusic2046 as a forthcoming 2026 project.
-
-The Grammy conversation is also shaping early "best of" discourse. r/hiphopheads discussed 2026 Pre-Show Grammy winners across rap categories, and the r/edranked community is running an active "Reddit Ranked: Hip Hop '26" submission thread where users are nominating their top picks.
-
-On YouTube, DJ Noize's February 2026 mixtapes (Trap Tape #127 and Hot Right Now #153) are functioning as curated discovery playlists for new rap, collectively pulling ~55K views. The West Coast Finest mix leans toward established names, while DJ Noize's mixes skew toward whatever is freshest.
-
-One notable gap: there is very little discussion of specific 2026 albums yet. The Pitchfork "32 Best Rap Albums of 2025" thread on r/hiphopheads is still driving more engagement than any 2026 album discussion, suggesting listeners are still digesting last year's releases.
-
-```
-Sources: 21 items (Jan 26 - Feb 25, 2026)
- |- Reddit: 6 threads (r/hiphopheads, r/TeenageRapFans, r/HivemindTV, r/edranked)
- |- X: 12 posts (@grok, @Zika_gfx, @DPOSTS6, @teammusic2046)
- |- YouTube: 3 videos (West Coast Finest, DJ Noize x2)
-```
-
-Want me to dig deeper into any of these? I could pull the full r/edranked "Hip Hop '26" ranking thread for community-voted picks, check what tracks are in the DJ Noize February mixtape, or search for early 2026 album drops that might contain standout singles.
diff --git a/docs/comparison-results/synthesis/base-5-react-svelte.md b/docs/comparison-results/synthesis/base-5-react-svelte.md
deleted file mode 100644
index f9c69fe..0000000
--- a/docs/comparison-results/synthesis/base-5-react-svelte.md
+++ /dev/null
@@ -1,33 +0,0 @@
-## What I learned:
-
-The React vs Svelte debate in 2026 is no longer theoretical - developers are making real migration decisions, and the community is split between pragmatism and aspiration. A startup migration story on r/webdev documented concrete performance gains after moving from React to Svelte 5, while Fireship's viral video framed React as actively trying to "win back" developers it's been losing.
-
-**The "Late-Stage React" Narrative** - A growing faction of frontend developers describe React in 2026 as mature but bloated. @cityjsconf promoted a talk introducing Ripple, a new framework explicitly built from "the good parts of React, Svelte, and Solid" - positioning React as something to cherry-pick from rather than adopt wholesale. Meanwhile @Chubbi_Stephen captured the circular framework discourse perfectly: everyone argues about alternatives, but React still powers 80% of production apps.
-
-**Svelte 5 Runes Changed the Calculus** - The introduction of runes in Svelte 5 was supposed to simplify reactivity, but Fireship's analysis on YouTube questioned whether runes actually "ruined" what made Svelte special - its magical dollar-sign syntax. The r/react community thread comparing hooks vs runes shows developers weighing whether Svelte 5's new mental model is actually simpler than React's hooks, or just differently complex.
-
-**Ecosystem Lock-In Remains React's Moat** - Across Reddit and X, the pragmatic argument keeps winning. @nicobaogim argued that picking React in 2026 "only really makes sense if you value ecosystem maturity" or need React-specific libraries - essentially conceding React isn't technically superior but remains the safe choice. Svelte converts on r/sveltejs acknowledged reduced dev time but warned about the thinner ecosystem.
-
-**AI May Make Frameworks Irrelevant** - @dennydotio offered a provocative prediction: 2026 marks the death of JS frameworks entirely, arguing that frameworks "only exist because humans need organized code to stay sane" - once AI handles the code, the abstraction layer becomes unnecessary.
-
-**KEY PATTERNS:**
-
-1. **Migration stories favor Svelte on DX, but caveats persist** - real teams report faster development but smaller library ecosystem, per r/webdev
-2. **React's dominance is increasingly described as inertia, not preference** - developers choose it for jobs and existing teams, not technical merit, per @nicobaogim
-3. **Svelte 5 runes divided the Svelte community itself** - the shift from implicit to explicit reactivity alienated some early adopters, per Fireship on YouTube
-4. **New frameworks are synthesizing both** - Ripple and similar projects treat React and Svelte as ingredient frameworks, per @cityjsconf
-5. **TypeScript-first tooling is the real 2026 baseline** - @FabianHiller's post about type-safe forms got the most engagement, suggesting developers care more about DX tooling than framework wars
-
----
-All agents reported back!
-├─ 🟠 Reddit: 4 threads │ 202 upvotes
-├─ 🔵 X: 9 posts │ 76 likes │ 4 reposts
-├─ 🔴 YouTube: 10 videos │ 2,027,387 views │ 3 with transcripts
-├─ 🌐 Web: supplementary
-└─ 🗣️ Top voices: @FabianHiller (61 likes), @cityjsconf │ r/react, r/webdev
----
-
-I'm now an expert on React vs Svelte 2026. Some things I can help with:
-- Compare the actual DX tradeoffs if you're considering migrating a React app to Svelte 5
-- Analyze whether Svelte 5 runes or React hooks better fit your team's mental model
-- Break down the ecosystem gaps that still keep teams on React despite Svelte's performance advantages
diff --git a/docs/comparison-results/synthesis/cross-1-claude-code-improved.md b/docs/comparison-results/synthesis/cross-1-claude-code-improved.md
deleted file mode 100644
index 47e3b5c..0000000
--- a/docs/comparison-results/synthesis/cross-1-claude-code-improved.md
+++ /dev/null
@@ -1,34 +0,0 @@
-**What I learned:** The Claude Code skills ecosystem has exploded into a full-blown marketplace war in the last 30 days, with creators racing to package domain expertise into reusable skills while MCP server fatigue is setting in hard. The clearest signal: a marketing skills tutorial hit 47K YouTube views AND landed on Hacker News simultaneously, per Grace Leung and r/ClaudeCode.
-
-**Skills are the new plugins.** The sheer volume of "skill collections" tells the story: ClaudeInOne ships 213 skills in one install, another toolkit bundles 135 agents with 35 curated skills, and Anthropic themselves open-sourced their internal skills library. This is the WordPress plugin moment for AI coding - everyone is packaging their expertise into portable skill files. The Chrome extension for browsing skills on GitHub and early marketplace projects like ClawsMarket confirm that discovery and distribution are the next bottleneck, per @ihtesham2005.
-
-**MCP server disillusionment is real.** One of the highest-scoring Reddit threads warns that Claude Code "works great until too many MCP servers" break it, and the most-watched MCP video (90K views) concludes that 90% of MCP servers are "overhyped garbage." The community is shifting from "connect everything" to "connect only what matters" - semantic graph servers that compress 15K tokens down to 3K relevant context represent the new direction, per Robin Ebers.
-
-**Skills beat raw prompting for team adoption.** A team reported 3x-ing their skill usage in two weeks through deliberate onboarding, and the self-improvement loop skill (where Claude Code iterates on its own skills) emerged as a community favorite. The pattern is clear: skills are sticky because they encode workflow knowledge, not just instructions. Meanwhile, a highly upvoted explainer on the difference between Skills, Subagents, Claude.MD, and slash commands confirms many users are still confused about what goes where, per r/ClaudeCode.
-
-**Marketing and SEO are the breakout skill verticals.** Grace Leung's videos on AI marketing teams have a combined 200K+ views. A 55K-word email marketing knowledge base packaged as a Claude Code skill was the top HN story. Twelve open-source SEO skills dropped on Reddit. The non-developer use case is outpacing the developer tooling use case in raw engagement, per Grace Leung.
-
-**Security is the unresolved tension.** MCP proxy servers for safe email access, capability-based sandboxing in Rust (SkillSandbox), and security-first agent frameworks (Gulama) all appeared this month. The community wants skills and MCP servers to do more - but giving AI agents access to email, Kubernetes clusters, and production databases raises obvious questions that nobody has fully answered yet, per r/ClaudeAI.
-
----
-
-**KEY PATTERNS**
-
-1. Skill packaging and distribution are the current gold rush - marketplaces, directories, and browser extensions are all emerging simultaneously
-2. MCP server quality filtering has begun - the community is rejecting bloat in favor of targeted, high-signal servers
-3. Non-developer skill verticals (marketing, SEO, CRM) are generating the highest engagement by a wide margin
-4. Team-level skill adoption requires deliberate onboarding, not just sharing files
-5. Security and sandboxing for agent skills remain unsolved but increasingly urgent
-
----
-
-```
-STATS: 47 items across 4 platforms | Reddit: 10 threads (top score: 78, r/ClaudeCode) | X: 12 posts (top: 6 likes, @ihtesham2005) | YouTube: 10 videos (top: 155K views, Grace Leung) | HN: 15 stories (top: 10pts) | Cross-platform hits: 2 | Date range: Jan 26 - Feb 25, 2026
-```
-
-What caught your eye? Reply with a number to dig deeper:
-1. Building and packaging your own Claude Code skills
-2. Which MCP servers are actually worth installing
-3. The marketing/SEO skills ecosystem
-4. Security and sandboxing for agent skills
-5. Team adoption strategies for skills
diff --git a/docs/comparison-results/synthesis/cross-1-claude-code.md b/docs/comparison-results/synthesis/cross-1-claude-code.md
deleted file mode 100644
index 8b603ba..0000000
--- a/docs/comparison-results/synthesis/cross-1-claude-code.md
+++ /dev/null
@@ -1,35 +0,0 @@
-What I learned:
-
-The Claude Code skills and MCP ecosystem is experiencing simultaneous growth at every layer - from individual skill authoring to team-level adoption to infrastructure tooling. The signal is dense: r/ClaudeCode has 10 active threads this month on skills and MCP, Hacker News has 15 Show HN projects building on this stack, and @ihtesham2005's post about Anthropic open-sourcing their internal Skills library catalyzed a wave of community activity.
-
-**MCP server scaling is hitting real limits** - "Claude Code works great... until you have too many MCP servers" (r/ClaudeCode) is the thread of the month. Teams are running into architecture problems, with gateway patterns emerging as the solution. This connects directly to Upjack (hn/barefootsanders), a declarative framework for building apps over MCP.
-
-**Token efficiency is driving MCP server design** - A semantic graph MCP server cut context from 15K to 3K tokens (r/ClaudeCode), while 25 MCP servers that return structured data instead of terminal formatting emerged on r/ClaudeAI. The efficiency concern also shows up in SkillSandbox (hn/ClaytheMachine), which uses capability-based sandboxing to limit what skills can access.
-
-**Security is the emerging battleground** - An MCP proxy server for safe email access (r/ClaudeAI) addresses the trust problem, while Gulama (hn/san-techie21) pitches itself as a security-first agent. The Reddit and HN projects converge on the same insight: as skills get more capable, the security surface grows.
-
-**Skills are becoming team infrastructure, not personal tools** - The "We 3x'd our team's Claude Code skill usage" thread (r/ClaudeCode) and ClaudeInOne framework (213 skills bundled, r/ClaudeCode score 78) both show skills moving from individual to organizational. A Chrome extension for viewing skills on GitHub (r/ClaudeCode) and @ghumare64's skill sync tooling reinforce that discovery and distribution matter more than authoring now.
-
-**Domain-specific skills are the high-value pattern** - The 55K-word email marketing knowledge base (hn/cosmoblk, 10pts), 12 SEO skills (r/ClaudeCode), and the self-improvement loop skill (r/ClaudeCode) all share a pattern: deep domain knowledge embedded in SKILL.md files. Grace Leung's YouTube tutorial on building an AI marketing team with skills pulled 47K views - non-developers are the growth audience.
-
-KEY PATTERNS from the research:
-1. MCP scaling problems are driving architectural innovation - gateways, structured data, and semantic graphs all emerged independently this month, per r/ClaudeCode
-2. Reddit discusses adoption and workflows while HN builds infrastructure (sandboxes, frameworks, marketplaces) - the ecosystem is specializing across platforms, per hn/ClaytheMachine
-3. Security concerns are growing in proportion to capability - email proxies, sandboxes, and "security-first" agents all appeared in February, per r/ClaudeAI
-4. Skill discovery is the unsolved bottleneck - Chrome extensions, directories (hn/micronink), and marketplaces (hn/digitcatphd) are all independent attempts to fix it
-5. Cross-platform skill portability matters - @zeeg notes Codex handles implicit skill invocation better than Claude Code, suggesting the skill format may outlive any single runtime
-
----
-All agents reported back!
-├─ 🟠 Reddit: 10 threads │ 692 upvotes │ 0 comments
-├─ 🔵 X: 12 posts │ 23 likes │ 1 reposts
-├─ 🔴 YouTube: 10 videos │ 465,498 views │ 3 with transcripts
-├─ 🟡 HN: 15 stories │ 35 points │ 16 comments
-├─ 🌐 Web: supplementary
-└─ 🗣️ Top voices: @ihtesham2005 (6 likes), @zeeg (5 likes, 1 RT) │ r/ClaudeCode, r/ClaudeAI │ hn/cosmoblk (10pts)
----
-
-I'm now an expert on Claude Code skills and MCP servers. Some things I can help with:
-- Analyze the MCP scaling problem and whether a gateway approach or semantic graph server would work better for your setup
-- Compare the security models across the email proxy, SkillSandbox, and Gulama approaches
-- Help you build a domain-specific skill pack and distribute it through the emerging skill ecosystem
diff --git a/docs/comparison-results/synthesis/cross-2-seedance-improved.md b/docs/comparison-results/synthesis/cross-2-seedance-improved.md
deleted file mode 100644
index 4a11cbe..0000000
--- a/docs/comparison-results/synthesis/cross-2-seedance-improved.md
+++ /dev/null
@@ -1,31 +0,0 @@
-## What I learned
-
-Seedance 2.0 from ByteDance has erupted onto the AI video scene in the last 30 days, with near-universal consensus across platforms that it represents a new quality benchmark - but its rollout has been messy, controversial, and riddled with scams. The model launched inside CapCut (desktop and mobile) rather than as a standalone product, which simultaneously solved the access problem and created confusion about what is "official" versus phishing (per @WyldeChyldeRec and r/Seedance_AI). Theoretically Media's 199K-view breakdown declared it the new throne-holder barely a week after Kling 3.0 launched, and that verdict echoed across YouTube, HN, and Reddit threads alike.
-
-**CapCut is the gateway, for better or worse.** ByteDance embedded Seedance 2.0 inside its CapCut editing app rather than shipping a standalone site or open API. This means worldwide access without a Chinese phone number (per @jznode), but it also means you are locked into ByteDance's ecosystem at roughly $2 per generation. Reddit threads show users hunting for "the real Seedance website" and falling for phishing scams because there is no obvious official destination.
-
-**Quality is real, but production-readiness is not.** Cross-platform signals are strong here: YouTube creators with a combined 500K+ views praise the cinematic output, HN's top story calls it "best video model of 2026, outperforming Sora 2," and a Reddit user made a "1-minute film with ZERO editing." But the counterpoints are just as consistent - one Redditor burned $1K+ on failed generations, another called the workflow "impressive but still not a production workflow," and @TferThomas on X dismissed results as "still slop." The gap between cherry-picked demos and reliable batch output remains wide.
-
-**Censorship and copyright battles are already shaping the product.** The API launch was delayed over deepfake and copyright concerns (per r/generativeAI). ElevenLabs quietly removed Seedance 2.0 integration after a SAG-AFTRA incident. ByteDance faces Hollywood backlash. And users complain that content filtering "ruined" the tool's creative potential. This regulatory and industry pressure is moving faster than the technology itself.
-
-**Multi-shot consistency is the frontier problem.** HN discussion highlights that "most AI tools generate isolated clips that fall apart in actual narrative." Seedance 2.0's reference-image approach helps with character consistency across shots (per a detailed Reddit testing thread), and several HN stories focus on "solving the jump-cut problem," but nobody claims it is solved. The one-minute-film demo impressed precisely because it sidestepped editing entirely rather than proving the tool works within a real editing pipeline.
-
-**The wrapper app gold rush is already underway.** HN saw 8+ "Show HN" posts for Seedance 2.0 wrapper tools and access proxies in a single month, most with zero comments and minimal traction. SeeVideo offers subscription-free access. This pattern mirrors early Stable Diffusion and GPT wrapper cycles - lots of surface-level tooling, very little durable differentiation.
-
-### KEY PATTERNS
-
-1. **Regulatory pressure is the real bottleneck, not technology** - the API delay, ElevenLabs removal, and Hollywood backlash form a connected pattern where legal/ethical concerns are throttling access faster than ByteDance can ship (r/Seedance_AI + r/AIGuild)
-2. **Access fragmentation creates parallel economies** - CapCut for consumers, Atlas Cloud for enterprise, Jimeng for China, third-party wrappers for everyone else, scam sites for the desperate - all serving the same model through different channels with different reliability
-3. **Consumer excitement vastly outpaces developer adoption** - 199K YouTube views vs. 1-point HN Show HN posts; the audience is creators and filmmakers, not engineers building on APIs (Theoretically Media vs. HN)
-4. **Multi-shot coherence is the moat** - character/scene consistency across shots is what separates Seedance 2.0 from competitors and what makes the "zero editing film" possible (hn/RyanMu)
-5. **Quality-guardrail tension will define who wins AI video** - ByteDance's strict content moderation costs power users real money while its absence would invite the regulatory response that already delayed the API launch (r/Seedance_AI + r/HiggsfieldAI)
-
----
-
-```
-STATS: 55 sources across 5 platforms (30 days) | Reddit: 19 threads, top score 77 | X: 11 posts, top 9 likes | YouTube: 10 videos, top 199K views / 4,986 likes | HN: 15 stories, top 7 pts / 7 comments | Cross-platform: 7 items appeared on 2+ platforms
-```
-
----
-
-Want to dig deeper? I can look into **the full SAG-AFTRA timeline and how it connects to ByteDance's API delay**, **which Seedance 2.0 access methods are actually legitimate and at what cost**, or **head-to-head multi-shot coherence comparisons between Seedance 2.0, Kling 3.0, and Sora 2**.
diff --git a/docs/comparison-results/synthesis/cross-2-seedance.md b/docs/comparison-results/synthesis/cross-2-seedance.md
deleted file mode 100644
index 27ffe48..0000000
--- a/docs/comparison-results/synthesis/cross-2-seedance.md
+++ /dev/null
@@ -1,36 +0,0 @@
-## What I learned
-
-ByteDance's **Seedance 2.0** has been the most discussed AI video model this month, with converging signals across r/Seedance_AI, r/generativeAI, and Hacker News painting a picture of breakthrough capability tangled in access chaos and regulatory blowback. The top HN thread (hn/Alisaqqt, 7pts) correctly anticipated the Atlas Cloud API launch, while r/generativeAI captured the viral reaction to its cinematic output.
-
-**CapCut integration is the global access breakthrough.** As of Feb 25, Seedance 2.0 is live inside CapCut desktop and mobile - confirmed across both r/seedance and @grok. @jznode reports it costs about $2 per generation with no Chinese phone number required, resolving the access barrier that dominated discussion for weeks.
-
-**Cinematic quality sets a new bar.** r/generativeAI's viral thread - "Seedance 2.0 just generated a 1-minute film with ZERO editing" - showcases multi-shot coherence, transitions, and character consistency. Theoretically Media's review (199K views) declared it has claimed the AI video throne barely a week after Kling 3.0 held that title. But @TferThomas offers the counterpoint: "it's still slop."
-
-**The access problem spawned scams and wrappers.** Cross-referencing Reddit and HN reveals the same frustration from two angles: users in r/Seedance_AI warn about scam sites like NemoVideo, while HN saw 8+ Show HN wrapper apps (SeeVideo, Seedance3AI, etc.) launch in a single month [xref: HN5/R6, HN4/R6]. Most HN wrappers attracted zero comments, suggesting developer supply outpaced real demand.
-
-**API rollout delayed by deepfake/copyright concerns.** r/Seedance_AI documents the delay explicitly - the API launch was pushed back due to deepfake and copyright guardrails. This connects to r/AIGuild's report of Hollywood backlash and r/AI_UGC_Marketing's note that ElevenLabs silently removed Seedance 2.0 after a SAG-AFTRA incident. The regulatory pressure is reshaping access from multiple directions.
-
-**Jimeng platform is failing its users.** Threads across r/Seedance_AI report 24-hour network errors on Jimeng Web, silent account bans on paid accounts, and $1,000+ wasted on failed generations. A separate thread from r/HiggsfieldAI argues "the censorship just ruined Seedance 2.0," while r/AI_Agents offers a measured take: "Seedance 2.0 is impressive. It's still not a production workflow."
-
-**Hands-on testers reveal the real workflow.** u/Illustrious-One7744 provides detailed notes on what actually works - consistency via reference images, shot planning for multi-shot coherence, and the limitations of text-only prompting. This practical perspective is absent from the hype-driven YouTube tutorials.
-
-**Multi-shot coherence is the technical differentiator.** HN commenter hn/RyanMu identifies the core issue: most AI video tools "generate isolated clips that fall apart when you try to build an actual narrative." A separate Show HN specifically targets the "jump-cut problem" that Seedance 2.0 claims to solve.
-
-### KEY PATTERNS
-
-1. **Regulatory pressure is the real bottleneck, not technology** - the API delay, ElevenLabs removal, and Hollywood backlash form a connected pattern where legal/ethical concerns are throttling access faster than ByteDance can ship (r/Seedance_AI + r/AIGuild)
-2. **Access fragmentation creates parallel economies** - CapCut for consumers, Atlas Cloud for enterprise, Jimeng for China, third-party wrappers for everyone else, scam sites for the desperate - all serving the same model through different channels with different reliability [xref: HN/Reddit]
-3. **Consumer excitement vastly outpaces developer adoption** - 199K YouTube views vs. 1-point HN Show HN posts; the audience is creators and filmmakers, not engineers building on APIs (Theoretically Media vs. HN)
-4. **Multi-shot coherence is the moat** - character/scene consistency across shots is what separates Seedance 2.0 from competitors and what makes the "zero editing film" possible (hn/RyanMu)
-5. **Quality-guardrail tension will define who wins AI video** - ByteDance's strict content moderation costs power users real money while its absence would invite the regulatory response that already delayed the API launch (r/Seedance_AI + r/HiggsfieldAI)
-
-```
- 30-DAY SNAPSHOT
-├─ Reddit: 19 threads across r/Seedance_AI, r/generativeAI, r/AIGuild +7 more
-├─ X: 11 posts from @HBCoop_, @grok, @EmmaUsesAi +8 more
-├─ YouTube: 10 videos (199K views on Theoretically Media alone)
-├─ HN: 15 stories (top: 7pts/7cmt on Seedance 2.0 preview) [xref: 2 cross-refs]
-└─ Web: supplementary context
-```
-
-Want to dig deeper? I can look into **the full SAG-AFTRA timeline and how it connects to ByteDance's API delay**, **which Seedance 2.0 access methods are actually legitimate and at what cost**, or **head-to-head multi-shot coherence comparisons between Seedance 2.0, Kling 3.0, and Sora 2**.
diff --git a/docs/comparison-results/synthesis/cross-3-macbook-improved.md b/docs/comparison-results/synthesis/cross-3-macbook-improved.md
deleted file mode 100644
index b1fe9c4..0000000
--- a/docs/comparison-results/synthesis/cross-3-macbook-improved.md
+++ /dev/null
@@ -1,35 +0,0 @@
-# M4 MacBook Pro Review - What People Are Saying (Last 30 Days)
-
-The M4 MacBook Pro has settled into the market and the verdict is remarkably consistent across platforms: this is the best MacBook Pro generation in years, but most people do not need the Pro or Max chips. MKBHD's review (4M views, 105K likes) captures the prevailing sentiment - he used an M1 Max for three years without feeling the upgrade itch, but the M4 Max finally convinced him. Meanwhile, a cross-platform signal from Created Tech's Air vs Pro comparison (472K views on YouTube, also discussed on r/macbookpro) suggests the base M4 is more than enough for most buyers.
-
-**The base M4 is the sweet spot for most users.** MacRumors makes the case explicitly - "Base M4 is All You Need, Skip Pro & Max" - and this aligns with what multiple reviewers found after months of daily use. The Pro and Max chips remain overkill unless you have a specific pro workload that demands them, per Created Tech's long-term reviews.
-
-**The M4 generation is the first worthwhile upgrade since M1.** MKBHD, Dave2D, and ShortCircuit all converge on this. Per @bhphoto, the M3-to-M4 jump is modest, but the M1/M2-to-M4 jump is significant. ShortCircuit's "Don't Buy the Wrong MacBook" specifically warns against upgrading from M3, reinforcing the skip-a-generation strategy.
-
-**Local AI workloads are the new killer use case for Pro/Max configs.** This is where X data gets interesting. Per @jameslmorton, a maxed 128GB M4 MacBook Pro pushes 70 tokens/s for local LLM inference, though quality varies by model. @grok highlights that 64GB M4 Pro/Max configurations can run local AI agents with zero cloud dependency and sub-second response times. Ollama runs natively via Metal, needing 18-24GB of unified memory as a practical minimum. This is the clearest justification for spending up on Pro or Max silicon in 2026.
-
-**Long-term satisfaction holds up, with minor friction points.** Both Max Tech and Created Tech published multi-month follow-ups, and the consensus is overwhelmingly positive - per Max Tech, "Everyone was WRONG" about early complaints. However, r/macbookpro threads surface battery life concerns on the 14-inch model and display/port/speaker comparisons that drove at least one user to return an M4 Air in favor of the Pro. These are edge cases, not patterns, but worth noting.
-
-**The Air vs Pro decision is the real question, not which Pro chip.** Created Tech's comparison [also on: Reddit] frames the actual purchase decision most buyers face. The Pro's advantages - better display, more ports, superior speakers - matter more than the chip difference for the majority of users.
-
-KEY PATTERNS:
-1. Base M4 is sufficient for most professional use cases, per Created Tech and MacRumors
-2. Local AI inference is the emerging justification for Pro/Max configs, per @jameslmorton
-3. Skip-a-generation upgrade strategy confirmed - M3 to M4 is marginal, M1/M2 to M4 is significant, per @bhphoto
-4. Battery life expectations need recalibrating against Apple's marketing claims, per r/macbookpro
-5. Air vs Pro is a more meaningful decision than Pro vs Max for most buyers
-
----
-All agents reported back!
-├─ 🟠 Reddit: 3 threads │ r/macbookpro
-├─ 🔵 X: 12 posts │ 10 likes │ 1 repost
-├─ 🔴 YouTube: 10 videos │ 7,926,186 views │ 3 with transcripts
-├─ 🟡 HN: 0 stories (no results this cycle)
-├─ 🌐 Web: supplementary
-├─ Cross-platform: 1 item (Created Tech Air vs Pro on YouTube + Reddit)
-└─ 🗣️ Top voices: @bhphoto, @jameslmorton │ r/macbookpro │ MKBHD (4M views)
-
----I'm now an expert on M4 MacBook Pro reviews. Some things I can help with:
-- Compare the M4 MacBook Air vs Pro for your specific use case (the differences go beyond specs)
-- Recommend the right M4 configuration tier based on whether you run local AI models, creative apps, or dev tools
-- Give you realistic battery life expectations based on Reddit owner reports, not Apple's marketing numbers
diff --git a/docs/comparison-results/synthesis/cross-3-macbook.md b/docs/comparison-results/synthesis/cross-3-macbook.md
deleted file mode 100644
index 2c2d212..0000000
--- a/docs/comparison-results/synthesis/cross-3-macbook.md
+++ /dev/null
@@ -1,26 +0,0 @@
-# What I learned
-
-Cross-referencing Reddit ownership reports, YouTube long-term reviews, and X commentary on the M4 MacBook Pro reveals a maturing consensus: this is excellent hardware with a surprisingly simple recommendation for most buyers. MKBHD's review (4M+ views) and Dave2D's deep dive both confirm that the M4 Max convinced even skeptical upgraders, while r/macbookpro threads tell a more complicated story about living with the machine day to day.
-
-**The Air-to-Pro switch is about more than the chip.** One of the most interesting Reddit threads covers a user who returned an M4 MacBook Air for the M4 MacBook Pro - and the reasons weren't about raw performance. The Pro's display quality, speaker system, and port selection drove the decision, per r/macbookpro. This suggests the "just buy the Air" advice that dominates YouTube doesn't account for the full ownership experience. Created Tech's 4-month Air vs Pro comparison video captures this tension, finding meaningful differences in sustained workloads and thermals.
-
-**Battery expectations need recalibrating.** Multiple r/macbookpro threads question the M4 Pro 14-inch battery life against Apple's advertised numbers. Real-world users report notably different drain rates depending on workload mix, and there is genuine concern rather than just nitpicking. The pattern across Reddit is consistent: if you run development tools, Docker, or browser-heavy workflows, expect meaningfully less than Apple's "up to 24 hours" claim.
-
-**Long-term reviewers are more candid.** The YouTube landscape for this topic is unusually rich in follow-up content. Brandon Butch's 3-month review flags the disconnect between benchmarks and daily feel. Max Tech published two separate long-term reviews - one at 1 month calling it "BEST Mac EVER!?" and another at 6 months titled "Everyone was WRONG!" - showing how opinions evolve with extended use, per Max Tech on YouTube. Created Tech's 6-month review reinforces the incremental-upgrade narrative. Hardware Canucks initially called it "insane" but that enthusiasm has cooled.
-
-**Local AI is the sleeper use case.** The X conversation is dominated by AI workload discussions. @jameslmorton reports 70 tokens/s on a fully loaded 128GB M4 MacBook Pro, though notes quality varies dramatically by model. @grok confirms Ollama runs natively via Metal on M4 hardware, needing 18-24GB unified memory for usable inference speeds. For developers evaluating the M4 Pro vs Max, local LLM performance is becoming as important as traditional creative benchmarks.
-
-**Configuration advice is converging.** Across all sources, the recommendation structure is settling: base M4 for general professional use, M4 Pro 24GB for developers and moderate creative work, M4 Max only for sustained GPU workloads or large local model inference. MacRumors explicitly advises skipping Pro and Max for most buyers. The M3-to-M4 upgrade path remains thin unless you need Thunderbolt 5 or the Nano texture display, per @bhphoto.
-
----All agents reported back!
-├─ 🟠 Reddit: 3 threads │ 0 upvotes │ 0 comments
-├─ 🔵 X: 12 posts │ 10 likes │ 1 repost
-├─ 🔴 YouTube: 10 videos │ 7,926,186 views │ 3 with transcripts
-├─ 🟡 HN: 0 stories (no results this cycle)
-├─ 🌐 Web: 0 pages (supplementary)
-└─ 🗣️ Top voices: @bhphoto (3 likes), @jameslmorton │ r/macbookpro
-
----I'm now an expert on M4 MacBook Pro reviews. Some things I can help with:
-- Compare the M4 MacBook Air vs Pro for your specific use case (the differences go beyond specs)
-- Recommend the right M4 configuration tier based on whether you run local AI models, creative apps, or dev tools
-- Give you realistic battery life expectations based on Reddit owner reports, not Apple's marketing numbers
diff --git a/docs/comparison-results/synthesis/cross-4-rap-improved.md b/docs/comparison-results/synthesis/cross-4-rap-improved.md
deleted file mode 100644
index 45db856..0000000
--- a/docs/comparison-results/synthesis/cross-4-rap-improved.md
+++ /dev/null
@@ -1,41 +0,0 @@
-# Best Rap Songs 2026
-
-The rap landscape in early 2026 is defined by a mix of chart-topping singles, Grammy momentum, and a steady stream of new releases keeping the culture moving. Lil Uzi Vert's "What You Saying" kicked off the year by hitting #1 on Hot Rap Songs in January, marking their third career chart-topper (@grok). Meanwhile, Kendrick Lamar continued his dominant run, winning his second Record of the Year Grammy alongside SZA for "Luther" (r/hiphopheads), cementing the track as one of the most celebrated rap-adjacent records of the cycle.
-
-**Lil Uzi Vert owns early 2026 charts.** "What You Saying" debuted at #1 on Hot Rap Songs and peaked at #14 on the Hot 100, making it the biggest pure rap single of January-February 2026. Multiple X posts tracked its chart performance, and it appeared across playlist threads (@grok).
-
-**Kendrick Lamar and the Grammy effect.** Kendrick's wins at the 68th Annual Grammys for Best Rap Song, Best Rap Performance, and Record of the Year (with SZA for "Luther") dominated Reddit discussion across r/hiphopheads and r/KendrickLamar. "Luther" and tracks from GNX continue to define the quality benchmark heading into 2026.
-
-**DJ mixes reveal what is actually in rotation.** The highest-viewed YouTube content is not individual tracks but curated mixes. West Coast Finest's "Lit Hip Hop Mix 2026" featuring Tyga, Quavo, Iggy Azalea, Wiz Khalifa, Juicy J, and 50 Cent pulled 407K views, suggesting West Coast and party rap have strong listener demand. DJ Noize's February 2026 trap tape provides a reliable snapshot of the newest releases getting club and playlist play.
-
-**Underground and international names are surfacing.** Nuno Zigi's "World Best Lie" was called out as one of the most meaningful rap songs of 2026 (@Zika_gfx). Andre Toutman's "All The Love" was claimed as song of the year in r/GoodAssSub. These are not mainstream chart entries yet but reflect organic community enthusiasm.
-
-**Nostalgia holds its own against new releases.** Multiple threads - r/TeenageRapFans, r/musicteenager, and even X posts praising older Beyonce tracks over current output (@DPOSTS6) - show that listeners are actively comparing 2026 releases against all-time classics and finding much of the new crop lacking.
-
-## SPECIFIC SONGS AND ARTISTS TO WATCH (by mention frequency and engagement)
-
-- **Lil Uzi Vert - "What You Saying"** - #1 Hot Rap Songs, multiple platform mentions. Sources: @grok, DJ Noize mixes
-- **Kendrick Lamar & SZA - "Luther"** - Record of the Year Grammy, massive Reddit discussion. Sources: r/hiphopheads, r/KendrickLamar
-- **Kendrick Lamar - GNX tracks** - Best Rap Song and Best Rap Performance Grammy wins. Sources: r/hiphopheads
-- **Tyga, Quavo, Wiz Khalifa, 50 Cent** - Featured in the most-viewed 2026 rap mix on YouTube (407K views). Sources: West Coast Finest (YouTube)
-- **Nuno Zigi - "World Best Lie"** - Praised as meaningful 2026 rap. Sources: @Zika_gfx
-- **Andre Toutman - "All The Love"** - Called song of the year by community. Sources: r/GoodAssSub
-- **Drake & PARTYNEXTDOOR - $OME $EXY $ONGS 4 U** - Still generating discussion one year later. Sources: r/hiphopheads
-- **Sean Bryant - "Love vs Loyalty"** - Upcoming album generating pre-release buzz. Sources: @teammusic2046
-
----
-
-```
----All agents reported back!
-├─ 🟠 Reddit: 11 threads │ r/hiphopheads, r/KendrickLamar, r/GoodAssSub, r/edranked, r/makemeaplaylist, r/playlists
-├─ 🔵 X: 12 posts │ @grok, @Zika_gfx (12 likes, 5 RT), @DPOSTS6
-├─ 🔴 YouTube: 3 videos │ 462K+ total views │ 3 with transcripts
-├─ 🟡 HN: 0 stories
-├─ 🌐 Web: supplementary
-└─ 🗣️ Top voices: @Zika_gfx, @grok │ r/hiphopheads, r/KendrickLamar
-```
-
----I'm now an expert on rap songs in 2026. Some things I can help with:
-- Pull the full Grammy rap category winners from the r/hiphopheads Pre-Show thread
-- Check what tracks people are nominating in the r/edranked "Hip Hop '26" ranking
-- Deep dive on any specific artist or track mentioned above
diff --git a/docs/comparison-results/synthesis/cross-4-rap.md b/docs/comparison-results/synthesis/cross-4-rap.md
deleted file mode 100644
index cb7648e..0000000
--- a/docs/comparison-results/synthesis/cross-4-rap.md
+++ /dev/null
@@ -1,28 +0,0 @@
-# What I learned
-
-The "best rap songs 2026" landscape is being shaped by three forces right now: Grammy season validation, community-curated playlists, and chart performance. Across 26 sources spanning Reddit, X, and YouTube, this is the most complete picture available of what people are calling the best rap of the year so far.
-
-**Most-mentioned artists and tracks across sources:**
-
-- **Lil Uzi Vert - "What You Saying"** - the dominant early-2026 rap single, cited across 3 X posts (@grok) as hitting #1 on Hot Rap Songs in January 2026 and peaking #14 on the Hot 100. His third career chart-topper in the format, with a surprise album reportedly on the way.
-- **Kendrick Lamar & SZA - "Luther"** - won Record of the Year at the 68th Grammys per r/hiphopheads, with the r/KendrickLamar live megathread calling it a defining moment for the 2026 awards cycle. Sources: r/hiphopheads, r/KendrickLamar.
-- **Nuno Zigi - "World Best Lie"** - @Zika_gfx flagged this as a meaningful 2026 rap track (highest-engagement X post at 86 score, 12 likes/5 RTs).
-- **Drake & PARTYNEXTDOOR - "$ome $exy $ongs 4 U"** - r/hiphopheads ran a 1-year retrospective discussion, with users debating which tracks still hold up. Sources: r/hiphopheads.
-- **Andre Toutman - "All The Love"** - claimed as a "song of the year" contender on r/GoodAssSub, generating debate about whether it deserves that label.
-- **Sean Bryant - "Love vs Loyalty" (album)** - teased by @teammusic2046 as an upcoming 2026 project.
-
-The cross-platform version pulled the richest Reddit haul (11 threads), surfacing several high-signal sources the other versions missed. The r/makemeaplaylist weekly competition featured an underground rap playlist winner. r/GoodAssSub contributed a direct "song of the year" debate thread. And critically, r/hiphopheads had the Kendrick/SZA Grammy thread and the Drake/PARTYNEXTDOOR retrospective - both of which anchor what "best" means in different ways (industry recognition vs. staying power).
-
-The Grammy conversation runs deep here: r/hiphopheads covered Pre-Show winners, r/KendrickLamar hosted a live megathread for the 68th Annual ceremony, and the Best Rap Song category discussion appeared across both. Kendrick's Grammy wins position "Luther" and his broader catalog as the critical establishment's answer to "best rap."
-
-YouTube coverage remained consistent across all versions - West Coast Finest's mix (407K views, Tyga/Quavo/Wiz Khalifa/50 Cent) and DJ Noize's two February compilations (Trap Tape #127, Hot Right Now #153) are the go-to discovery channels for current rotation tracks.
-
-```
-Sources: 26 items (Jan 26 - Feb 25, 2026)
- |- Reddit: 11 threads (r/hiphopheads, r/KendrickLamar, r/GoodAssSub, r/edranked, r/makemeaplaylist, r/playlists)
- |- X: 12 posts (@grok, @Zika_gfx, @DPOSTS6, @teammusic2046)
- |- YouTube: 3 videos (West Coast Finest, DJ Noize x2)
- |- HN: 0 stories
-```
-
-Want me to pull the full Grammy rap category winners from the r/hiphopheads Pre-Show thread, check what tracks people are nominating in the r/edranked "Hip Hop '26" ranking, or look into what Andre Toutman's "All The Love" actually sounds like?
diff --git a/docs/comparison-results/synthesis/cross-5-react-svelte-improved.md b/docs/comparison-results/synthesis/cross-5-react-svelte-improved.md
deleted file mode 100644
index b20caa9..0000000
--- a/docs/comparison-results/synthesis/cross-5-react-svelte-improved.md
+++ /dev/null
@@ -1,38 +0,0 @@
-## What I learned:
-
-The React-vs-Svelte debate in early 2026 has shifted from "which is better" to "when does each one make sense" - and the answer increasingly depends on what you're optimizing for. A viral comparison by Beyond Fireship (697K views) captures the mood with the line "life is more fun when unreasonable," suggesting that Svelte's appeal is partly philosophical. Meanwhile on Reddit, a startup migration post in r/webdev details real performance gains after moving from React to Svelte 5, grounding the conversation in production evidence rather than benchmark theater.
-
-**Svelte 5 runes reset the learning curve - and the narrative.** Fireship's "Did runes just ruin Svelte?" crystallized a tension the community is still processing. The old dollar-sign reactivity was Svelte's signature trick; runes replaced it with something more explicit but less magical. Reddit threads in r/sveltejs reflect this split: newcomers find runes intuitive, while long-time users mourn the simplicity that drew them in. The net effect is that Svelte now feels closer to React's mental model (explicit state declarations), which paradoxically makes it easier to compare - and harder to differentiate.
-
-**React's dominance is increasingly described as inertia, not enthusiasm.** @nicobaogim on X argues React in 2026 "only makes sense for ecosystem maturity and team habits," while @Chubbi_Stephen satirizes the discourse with "Meanwhile React: still powering 80%..." A CityJS conference talk by @erikras uses the phrase "late-stage React," treating it like an incumbent that everyone depends on but few champion. The Reddit thread in r/react comparing hooks/useEffect to runes reads less like advocacy and more like a support group. React is not losing users - it is losing evangelists.
-
-**Job market pragmatism still favors React, but the gap is narrowing.** The r/sveltejs thread "How is going svelte?" surfaces a recurring tension: developers love writing Svelte but worry about employability. @singhprateek_25's X post listing 2026 fresher requirements includes both React and Svelte side by side, signaling that hiring managers are at least aware. The government portal evaluation in r/sveltejs - a developer coming from .NET and React, seriously weighing Svelte for accessibility, SEO, and forms - suggests Svelte is crossing the threshold from "side project framework" to "enterprise candidate."
-
-**Post-framework thinking is emerging.** @cityjsconf announced a talk on Ripple that cherry-picks from React, Svelte, and Solid, while @dennydotio predicted frameworks themselves become obsolete once AI handles code generation. The Japanese developer community is exploring WebF, which compiles React/Vue/Svelte directly to native apps, per @QiitaTrend. The React-vs-Svelte framing may have a shorter shelf life than either community expects.
-
-### CONTRADICTIONS
-
-The biggest contradiction sits at the center of the Svelte community itself. Runes were supposed to make Svelte more approachable and scalable, but Fireship's video questioning whether runes "ruined" Svelte shows genuine anxiety. Reddit threads where newcomers say "Svelte is easier than React" coexist with experienced Svelte developers saying the migration to runes introduced friction they did not expect.
-
-**KEY PATTERNS:**
-
-1. **Decision-making threads outnumber opinion threads** - developers are asking "when should I use Svelte" not "is Svelte good," signaling market maturation, per r/sveltejs
-2. **Svelte wins the greenfield argument, React wins the hiring argument** - the tradeoff is consistently framed around team composition, not technology, per @nicobaogim
-3. **Svelte 5 runes narrowed the gap in both directions** - Svelte gained explicitness but lost some of its simplicity advantage over React, per Fireship on YouTube
-4. **Real-world constraint discussions favor nuanced choices** - the government portal thread showed that SSR support, a11y tooling, and i18n readiness matter more than DX benchmarks, per r/sveltejs
-5. **Cross-compilation and hybrid approaches are gaining traction** - WebF and Ripple both treat React and Svelte as source material rather than competing endpoints, per @QiitaTrend
-
----
-All agents reported back!
-├─ 🟠 Reddit: 6 threads │ 339 upvotes
-├─ 🔵 X: 9 posts │ 76 likes │ 4 reposts
-├─ 🔴 YouTube: 10 videos │ 2,085,183 views │ 3 with transcripts
-├─ 🟡 HN: 0 stories (no results this cycle)
-├─ 🌐 Web: supplementary
-└─ 🗣️ Top voices: @FabianHiller (61 likes), @cityjsconf │ r/sveltejs, r/webdev, r/react
----
-
-I'm now an expert on React vs Svelte 2026. Some things I can help with:
-- Evaluate whether Svelte fits a specific project with constraints like a11y, i18n, or SSR requirements
-- Compare the hiring and ecosystem implications of choosing Svelte over React for a new team
-- Analyze how Svelte 5 runes changed the developer experience compared to Svelte 4's dollar-sign syntax
diff --git a/docs/comparison-results/synthesis/cross-5-react-svelte.md b/docs/comparison-results/synthesis/cross-5-react-svelte.md
deleted file mode 100644
index 56f6b6d..0000000
--- a/docs/comparison-results/synthesis/cross-5-react-svelte.md
+++ /dev/null
@@ -1,34 +0,0 @@
-## What I learned:
-
-The React vs Svelte debate in 2026 has matured from "which is better" to "when does each make sense" - and the community data reflects this nuance. With 6 Reddit threads surfaced (the most of any version), including real decision-making discussions on r/sveltejs about government portals and learning timelines, the cross-platform search captured a richer picture of how developers are actually choosing between these frameworks. Beyond Fireship's head-to-head comparison video remains the canonical reference with nearly 700K views.
-
-**Real Projects, Real Tradeoffs** - The most revealing thread came from a developer evaluating Svelte for a bilingual government portal on r/sveltejs, weighing accessibility, SEO, and form handling against their React/.NET background. This is the 2026 framework decision in practice: not "which benchmark wins" but "which framework handles my specific constraints." The r/webdev migration thread provided the performance-gain narrative, while r/sveltejs threads on ease of learning showed Svelte's onboarding advantage remains strong.
-
-**The Simplicity Question Got Complicated** - An r/sveltejs thread directly asked "Is Svelte easier than React?" - and the answers were more nuanced than expected. Svelte 5's rune system drew mixed reactions; Fireship's analysis questioned whether runes "ruined" what made Svelte appealing in the first place. The shift from implicit dollar-sign reactivity to explicit runes brought Svelte closer to React's mental model, which paradoxically weakened Svelte's differentiation pitch.
-
-**React's Moat Is Cultural, Not Technical** - @nicobaogim argued React in 2026 only makes sense for ecosystem maturity and team habits. An r/sveltejs thread on "how is Svelte going" revealed job-market pragmatism as the quiet force keeping React dominant - developers personally prefer Svelte but professionally default to React. @Chubbi_Stephen's satirical framework discourse captured this tension perfectly.
-
-**Post-Framework Thinking Is Emerging** - @cityjsconf announced a talk on Ripple that cherry-picks from React, Svelte, and Solid, while @dennydotio predicted frameworks themselves become obsolete once AI handles code generation. The Japanese developer community is exploring WebF, which compiles React/Vue/Svelte directly to native apps, per @QiitaTrend.
-
-**KEY PATTERNS:**
-
-1. **Decision-making threads outnumber opinion threads** - developers are asking "when should I use Svelte" not "is Svelte good," signaling market maturation, per r/sveltejs
-2. **Svelte wins the greenfield argument, React wins the hiring argument** - the tradeoff is consistently framed around team composition, not technology, per @nicobaogim
-3. **Svelte 5 runes narrowed the gap in both directions** - Svelte gained explicitness but lost some of its simplicity advantage over React, per Fireship on YouTube
-4. **Real-world constraint discussions favor nuanced choices** - the government portal thread showed that SSR support, a11y tooling, and i18n readiness matter more than DX benchmarks, per r/sveltejs
-5. **Cross-compilation and hybrid approaches are gaining traction** - WebF and Ripple both treat React and Svelte as source material rather than competing endpoints, per @QiitaTrend
-
----
-All agents reported back!
-├─ 🟠 Reddit: 6 threads │ 339 upvotes
-├─ 🔵 X: 9 posts │ 76 likes │ 4 reposts
-├─ 🔴 YouTube: 10 videos │ 2,085,183 views │ 3 with transcripts
-├─ 🟡 HN: 0 stories (no results this cycle)
-├─ 🌐 Web: supplementary
-└─ 🗣️ Top voices: @FabianHiller (61 likes), @cityjsconf │ r/sveltejs, r/webdev, r/react
----
-
-I'm now an expert on React vs Svelte 2026. Some things I can help with:
-- Evaluate whether Svelte fits a specific project with constraints like a11y, i18n, or SSR requirements
-- Compare the hiring and ecosystem implications of choosing Svelte over React for a new team
-- Analyze how Svelte 5 runes changed the developer experience compared to Svelte 4's dollar-sign syntax
diff --git a/docs/comparison-results/synthesis/hn-1-claude-code.md b/docs/comparison-results/synthesis/hn-1-claude-code.md
deleted file mode 100644
index 7592de2..0000000
--- a/docs/comparison-results/synthesis/hn-1-claude-code.md
+++ /dev/null
@@ -1,35 +0,0 @@
-What I learned:
-
-Claude Code skills and MCP servers are at an inflection point - the community is building so fast that infrastructure tooling (sandboxes, marketplaces, directories) is appearing alongside the skills themselves. @ihtesham2005 highlighted Anthropic open-sourcing their internal Skills library, and Hacker News is seeing a steady stream of Show HN projects building on this layer.
-
-**Specialized skill packs are the new open-source project type** - Builders are shipping domain-specific skill bundles: a 55K-word email marketing knowledge base (hn/cosmoblk, 10pts), 12 SEO skills for Claude Code (r/ClaudeCode), and AI marketing skills (hn/superamped). These aren't generic - they embed deep domain knowledge into SKILL.md files.
-
-**Security and sandboxing are emerging concerns** - SkillSandbox (hn/ClaytheMachine) is a Rust-based capability sandbox for AI agent skills, and Gulama (hn/san-techie21) pitches itself as a "security-first" OpenClaw alternative. As skills get more powerful, the HN crowd is asking hard questions about what they should be allowed to do.
-
-**MCP is becoming a framework target, not just a protocol** - Upjack (hn/barefootsanders) is a declarative framework for building apps over MCP, and the GTM MCP Server (hn/paolobietolini) lets AI manage Google Tag Manager containers. MCP is evolving from "connect tools to Claude" into "build entire products on top of MCP."
-
-**Agent orchestration is the next layer up** - Axon (hn/gjkim042) provides Kubernetes-native orchestration for AI coding agents, and hn/alternateman built tooling to turn Claude Code or Codex into proactive 24/7 agents. The pattern is skills for single tasks, orchestration for workflows, infrastructure for always-on agents.
-
-**Skill discovery is an unsolved problem** - Indx.sh (hn/micronink) is a directory of AI coding rules, MCP servers, and tools. ClawsMarket (hn/digitcatphd) is a marketplace where AI agents discover tools. @ghumare64 uses a dedicated tool to sync and scan skills from the marketplace. Multiple independent attempts to solve discovery suggest it's a real pain point.
-
-KEY PATTERNS from the research:
-1. HN builders are creating infrastructure (sandboxes, frameworks, marketplaces) while Reddit/X users are creating end-user skills - the ecosystem is specializing, per hn/ClaytheMachine
-2. Marketing is the breakout non-dev use case for skills - multiple independent projects across HN, Reddit, and YouTube, per hn/cosmoblk
-3. Skills are going cross-platform - builders are creating skills that work with Claude Code AND Codex, per @zeeg
-4. The implicit vs explicit skill invocation debate is live - Codex handles implicit well, Claude Code still needs explicit mentions, per @zeeg
-5. Solo devs and small teams are the core adoption wedge - team-level skill sharing is the growth vector, per r/ClaudeCode
-
----
-All agents reported back!
-├─ 🟠 Reddit: 3 threads │ 0 upvotes │ 0 comments
-├─ 🔵 X: 12 posts │ 23 likes │ 1 reposts
-├─ 🔴 YouTube: 10 videos │ 520,640 views │ 3 with transcripts
-├─ 🟡 HN: 15 stories │ 35 points │ 16 comments
-├─ 🌐 Web: supplementary
-└─ 🗣️ Top voices: @ihtesham2005 (6 likes), @zeeg (4 likes, 1 RT) │ r/ClaudeCode, r/ClaudeAI │ hn/cosmoblk (10pts)
----
-
-I'm now an expert on Claude Code skills and MCP servers. Some things I can help with:
-- Break down the SkillSandbox architecture and what capability-based security means for your skills
-- Compare the emerging MCP frameworks (Upjack vs Poncho vs Fluid.sh) for different use cases
-- Help you build a domain-specific skill pack like the 55K-word email marketing knowledge base
diff --git a/docs/comparison-results/synthesis/hn-2-seedance.md b/docs/comparison-results/synthesis/hn-2-seedance.md
deleted file mode 100644
index b5ca5be..0000000
--- a/docs/comparison-results/synthesis/hn-2-seedance.md
+++ /dev/null
@@ -1,36 +0,0 @@
-## What I learned
-
-ByteDance's **Seedance 2.0** has dominated AI video discussion this month, with r/singularity and Hacker News tracking both the technical leaps and the messy rollout. The top HN thread (hn/Alisaqqt, 7pts) previewed the model in early February and correctly predicted the Atlas Cloud API launch on Feb 24.
-
-**CapCut integration marks the global launch.** As of Feb 25, Seedance 2.0 is live inside CapCut desktop and mobile worldwide. @jznode confirms no Chinese phone number is needed, with generations running about $2 each. A parallel r/singularity thread tracks the announcement with discussion of censorship trade-offs and pricing.
-
-**One-minute coherent films.** r/generativeAI showcases a Seedance 2.0 output - a one-minute film with multi-shot coherence, scene transitions, and consistent characters, all generated with zero editing. The claim is contested but the output samples are circulating widely.
-
-**API availability is a saga.** The top HN story noted the API was not yet available in early Feb, with Atlas Cloud access promised for Feb 24. By late February, r/generativeAI reports Seedance 2.0 appearing in "open source tools already," while third-party APIs (useapi.net) offer access of uncertain authenticity. Multiple Show HN posts (SeeVideo, Seedance3AI, and others) are building wrapper apps, but most sit at 1 point with zero comments.
-
-**Jimeng platform instability.** r/Seedance_AI documents 24-hour stretches of "Network Error, Generation Failed" on Jimeng Web, silent account bans distinguishing paid from free accounts, and general frustration. One user reports wasting $1,000+ on failed generations due to content review rejections.
-
-**Hollywood and SAG-AFTRA blowback.** r/AIGuild covers ByteDance facing Hollywood backlash over Seedance 2.0 IP concerns. r/AI_UGC_Marketing notes ElevenLabs quietly removed Seedance 2.0 integration after a SAG-AFTRA incident, breaking UGC video workflows.
-
-**Developer community response is breadth without depth.** HN saw 15 stories this month, but engagement is thin - only the preview thread (7pts, 7 comments) generated real discussion. The rest are Show HN launches of wrapper apps (SeeVideo, Seedance3AI, etc.) that attracted minimal attention, suggesting developer tools are outpacing developer demand.
-
-**Multi-shot narrative is the real breakthrough.** HN commenter hn/RyanMu notes that most AI video tools "generate isolated clips that fall apart when you try to build an actual narrative" - Seedance 2.0's multi-shot coherence is what sets it apart, and a separate Show HN specifically targets the "jump-cut problem."
-
-### KEY PATTERNS
-
-1. **Access fragmentation creates a wrapper gold rush** - 8+ Show HN posts launched Seedance wrapper apps in a single month, but almost none gained traction, suggesting the market wants official API access, not middlemen (HN)
-2. **Platform musical chairs** - added to CapCut, removed from ElevenLabs, broken on Jimeng, promised on Atlas Cloud - the access surface is shifting weekly, making production workflows unreliable (r/singularity)
-3. **Quality ceiling vs. guardrail floor** - Seedance 2.0's cinematic output quality is the highest in the space, but content moderation filters reject enough generations to cost power users real money (r/Seedance_AI)
-4. **Multi-shot coherence is the moat** - while competitors offer single-clip generation, Seedance 2.0's ability to maintain character and scene consistency across shots is the feature driving adoption (hn/RyanMu)
-5. **Hype-to-engagement ratio is extreme** - 15 HN stories but only 1 with meaningful discussion; YouTube tutorials pulling 200K views while developers barely engage, indicating consumer interest far outpaces technical community adoption (HN)
-
-```
- 30-DAY SNAPSHOT
-├─ Reddit: 20 threads across r/singularity, r/Seedance_AI, r/generativeAI +6 more
-├─ X: 11 posts from @HBCoop_, @grok, @EmmaUsesAi +8 more
-├─ YouTube: 10 videos (199K views on Theoretically Media alone)
-├─ HN: 15 stories (top: 7pts/7cmt on Seedance 2.0 preview)
-└─ Web: supplementary context
-```
-
-Want to dig deeper? I can look into **the Atlas Cloud API launch and pricing details**, **which Show HN wrapper apps are actually functional**, or **how Seedance 2.0's multi-shot coherence compares to Kling 3.0 and Sora 2 technically**.
diff --git a/docs/comparison-results/synthesis/hn-3-macbook.md b/docs/comparison-results/synthesis/hn-3-macbook.md
deleted file mode 100644
index 1e5f4a6..0000000
--- a/docs/comparison-results/synthesis/hn-3-macbook.md
+++ /dev/null
@@ -1,26 +0,0 @@
-# What I learned
-
-The M4 MacBook Pro has been extensively reviewed across YouTube and Reddit, but the developer-focused Hacker News community has been notably silent on the topic this cycle. Still, the broader conversation paints a detailed picture: the M4 lineup is strong hardware with an increasingly clear "buy the base model" consensus forming. Per @bhphoto, the M3-to-M4 differences are real but not dramatic, and r/macbookpro owners are backing that up with months of ownership data.
-
-**Base M4 is the default recommendation.** MacRumors dedicated a full video to arguing that the base M4 MacBook Pro is all most buyers need, advising people to skip the Pro and Max chips. Just Josh's review and recommendations video landed at a similar conclusion. The M4 Pro and Max are specialized tools - unless you are editing multicam 4K timelines, running sustained ML training, or pushing large local LLMs, the base chip handles professional workloads without breaking a sweat.
-
-**Battery reality is a sore spot.** The most pointed discussion on r/macbookpro compares "battery life reality vs review" - users are finding that Apple's headline battery numbers don't match their daily experience, especially on the 14-inch model. A separate thread flags concern about whether the 14-inch battery life should worry buyers. The gap between controlled reviewer testing and real usage with Chrome, Slack, and Docker running is meaningful enough to generate repeat threads.
-
-**Long-term ownership tells the real story.** The launch-day review cycle was overwhelmingly positive, but the 3-to-12-month follow-ups are more measured. Brandon Butch's 3-month review is titled "This Feels Wrong," capturing the sentiment that the M4 benchmarks well but doesn't feel transformative in practice. Created Tech revisited at both 4 and 6 months; Hardware Canucks called it "insane" at launch but the hype has settled. Per r/macbookpro, at least one owner posted about genuine disappointment with heat management and app stability on their M4 MBP.
-
-**Local LLM capability is a real differentiator.** On X, the conversation has shifted toward the M4 as a local AI machine. @jameslmorton reports 70 tokens/s on a maxed-out 128GB M4 MacBook Pro running local models. @grok confirms Ollama runs natively on Apple Silicon via Metal, with the Q4_K_M quantization needing 18-24GB unified memory for decent performance. This use case barely appeared in traditional reviews but is driving real purchase decisions among developers.
-
-**The upgrade calculus from M3 is thin.** @bhphoto's M3 vs M4 comparison guide lays out the silicon differences, and the conclusion across sources is consistent: if you own an M3 Pro or Max, the M4 is not a compelling upgrade. New buyers benefit; upgraders should probably wait for M5.
-
----All agents reported back!
-├─ 🟠 Reddit: 3 threads │ 0 upvotes │ 0 comments
-├─ 🔵 X: 12 posts │ 10 likes │ 1 repost
-├─ 🔴 YouTube: 10 videos │ 7,866,700 views │ 3 with transcripts
-├─ 🟡 HN: 0 stories (no results this cycle)
-├─ 🌐 Web: 0 pages (supplementary)
-└─ 🗣️ Top voices: @bhphoto (3 likes), @jameslmorton │ r/macbookpro
-
----I'm now an expert on M4 MacBook Pro reviews. Some things I can help with:
-- Help you decide between the base M4, M4 Pro, and M4 Max based on your actual workflow
-- Estimate realistic battery life for your usage pattern vs Apple's marketing claims
-- Assess whether upgrading from an M1/M2/M3 MacBook Pro is worth it right now
diff --git a/docs/comparison-results/synthesis/hn-4-rap.md b/docs/comparison-results/synthesis/hn-4-rap.md
deleted file mode 100644
index c001275..0000000
--- a/docs/comparison-results/synthesis/hn-4-rap.md
+++ /dev/null
@@ -1,28 +0,0 @@
-# What I learned
-
-Early 2026 rap is in a transitional moment - Grammy season just wrapped, playlist curators are compiling their first "best of" roundups, and the community is still debating which new tracks deserve the crown. Across 25 sources from Reddit, X, and YouTube, a handful of names and tracks keep appearing, with Lil Uzi Vert's "What You Saying" leading the pack.
-
-**Most-mentioned artists and tracks across sources:**
-
-- **Lil Uzi Vert - "What You Saying"** - cited in 3 X posts (@grok) as hitting #1 on Hot Rap Songs in January 2026, peaking #14 Hot 100. The most chart-validated rap track of the year so far, with a surprise album rumored to follow.
-- **Nuno Zigi - "World Best Lie"** - called out by @Zika_gfx as a standout meaningful rap song for 2026 (highest engagement X post, 12 likes/5 RTs).
-- **DFL - "Art Of Life"** - shared directly to r/hiphop as a 2026 release. Sources: r/hiphop.
-- **Plat Hav Pro - "Get F'D Up"** - another 2026 track posted to r/hiphop. Sources: r/hiphop.
-- **Jody Lo - "Ridiculous"** - 2026 track shared in r/hiphop. Sources: r/hiphop.
-- **Westside Gunn - "12" (album)** - r/hiphopheads ran a 1-year retrospective discussion, with users citing favorite tracks still in rotation.
-
-The HN version's expanded Reddit search (10 threads vs. 6 in base) surfaced several sources the base version missed. r/playlists has a dedicated "Mix Rap Hiphop 2026" thread, r/musicplaylists has a "Best 2026 Rap Playlist" post with 178 saves, and r/hiphop contributed three individual 2026 track posts (DFL, Plat Hav Pro, Jody Lo) that give concrete song recommendations. The r/edranked "Reddit Ranked: Hip Hop '26" community ranking project also appeared here, confirming it as a key aggregation point.
-
-Grammy discourse is prominent: r/hiphopheads discussed Pre-Show winners across Best Rap Song and Best Rap Performance categories, providing a baseline for what the industry considers the best.
-
-On YouTube, the same three mixes appeared - West Coast Finest's "Lit Hip Hop Mix 2026" (407K views, featuring Tyga, Quavo, Iggy Azalea, Wiz Khalifa, Juicy J, 50 Cent) and DJ Noize's two February compilations (Trap Tape #127, Hot Right Now #153). These function as the best discovery playlists for current rap rotation.
-
-```
-Sources: 25 items (Jan 26 - Feb 25, 2026)
- |- Reddit: 10 threads (r/hiphopheads, r/playlists, r/musicplaylists, r/hiphop, r/edranked)
- |- X: 12 posts (@grok, @Zika_gfx, @DPOSTS6, @teammusic2046)
- |- YouTube: 3 videos (West Coast Finest, DJ Noize x2)
- |- HN: 0 stories
-```
-
-Want me to pull the full tracklist from that r/musicplaylists "Best 2026 Rap Playlist" with 178 saves, dive into the Westside Gunn retrospective for standout tracks, or search for what won Best Rap Song at the 2026 Grammys?
diff --git a/docs/comparison-results/synthesis/hn-5-react-svelte.md b/docs/comparison-results/synthesis/hn-5-react-svelte.md
deleted file mode 100644
index b4fa026..0000000
--- a/docs/comparison-results/synthesis/hn-5-react-svelte.md
+++ /dev/null
@@ -1,34 +0,0 @@
-## What I learned:
-
-The React vs Svelte conversation in early 2026 is driven by real migration decisions rather than abstract benchmarks. A widely-discussed r/webdev thread documented one startup's move from React to Svelte 5 with tangible performance and developer experience improvements, while Fireship's video on React trying to "win back" developers pulled nearly 700K views - signaling broad community interest in this rivalry.
-
-**Svelte 5 vs React Hooks - The Mental Model War** - The r/react comparison thread directly pits Svelte 5 runes against React hooks, with developers debating which abstraction better maps to how they think about state. An r/sveltejs thread on "when to choose React over Svelte" framed the decision around practical constraints: existing team knowledge, library availability, and hiring. The consensus leans toward Svelte for greenfield projects and React for teams already invested in its ecosystem.
-
-**"Late-Stage React" Enters the Vocabulary** - @cityjsconf promoted a CityJS talk on Ripple, a new framework that extracts "the good parts of React, Svelte, and Solid" - framing React as entering its legacy phase. This mirrors @Chubbi_Stephen's satirical take on framework discourse, where every developer argues against React but React keeps powering 80% of production sites.
-
-**The Ecosystem Moat Is Real but Narrowing** - @nicobaogim made the case that React's only 2026 advantage is ecosystem maturity and team habits, not technical superiority. Beyond Fireship's side-by-side "10 Examples" comparison showed Svelte consistently requiring less code for equivalent functionality, reinforcing the DX argument.
-
-**Frameworks Facing Existential Questions** - @dennydotio predicted 2026 as the year JS frameworks start dying, arguing they exist for human code organization rather than machine needs. If AI-generated code becomes the norm, the framework abstraction layer loses its purpose entirely.
-
-**KEY PATTERNS:**
-
-1. **Migration stories consistently favor Svelte on performance and code volume** - but teams flag ecosystem gaps as the main friction, per r/webdev
-2. **React is chosen by default, Svelte is chosen by conviction** - the decision framework centers on team inertia vs technical preference, per @nicobaogim
-3. **Svelte 5 runes remain contentious within the Svelte community** - some developers feel runes traded Svelte's simplicity for React-like explicitness, per Fireship on YouTube
-4. **Hybrid frameworks are emerging from the debate** - new projects like Ripple synthesize patterns from both, treating the rivalry as a source of ideas rather than a winner-take-all contest, per @cityjsconf
-5. **TypeScript-first DX is the shared ground** - @FabianHiller's type-safe forms tooling drew the highest engagement, suggesting the real frontier is developer tooling, not framework choice
-
----
-All agents reported back!
-├─ 🟠 Reddit: 3 threads │ 172 upvotes
-├─ 🔵 X: 9 posts │ 76 likes │ 4 reposts
-├─ 🔴 YouTube: 10 videos │ 2,085,181 views │ 3 with transcripts
-├─ 🟡 HN: 0 stories (no results this cycle)
-├─ 🌐 Web: supplementary
-└─ 🗣️ Top voices: @FabianHiller (61 likes), @cityjsconf │ r/react, r/webdev
----
-
-I'm now an expert on React vs Svelte 2026. Some things I can help with:
-- Break down the hooks vs runes mental model differences with concrete code examples
-- Assess whether your project's constraints favor React's ecosystem or Svelte's DX advantages
-- Explore what "late-stage React" means for long-term framework strategy
diff --git a/docs/plans/2026-02-03-bird-cli-implementation.md b/docs/plans/2026-02-03-bird-cli-implementation.md
deleted file mode 100644
index 5aa1e1a..0000000
--- a/docs/plans/2026-02-03-bird-cli-implementation.md
+++ /dev/null
@@ -1,929 +0,0 @@
-# Bird CLI Integration Implementation Plan
-
-> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
-
-**Goal:** Add Bird CLI as a free, zero-config alternative to xAI for X/Twitter searches with interactive installation.
-
-**Architecture:** New `bird_x.py` module handles Bird detection, installation prompts, and search. Modified `env.py` determines X source priority (Bird → xAI → WebSearch). Main script prompts for Bird install if not found.
-
-**Tech Stack:** Python 3, subprocess for Bird CLI calls, existing lib modules for normalization/scoring.
-
----
-
-### Task 1: Create bird_x.py - Detection Functions
-
-**Files:**
-- Create: `scripts/lib/bird_x.py`
-
-**Step 1: Create the module with detection functions**
-
-```python
-"""Bird CLI client for X (Twitter) search."""
-
-import json
-import shutil
-import subprocess
-import sys
-from datetime import datetime
-from typing import Any, Dict, List, Optional, Tuple
-
-
-def _log(msg: str):
- """Log to stderr."""
- sys.stderr.write(f"[Bird] {msg}\n")
- sys.stderr.flush()
-
-
-def is_bird_installed() -> bool:
- """Check if Bird CLI is installed."""
- return shutil.which("bird") is not None
-
-
-def is_bird_authenticated() -> Optional[str]:
- """Check if Bird is authenticated by running 'bird whoami'.
-
- Returns:
- Username if authenticated, None otherwise.
- """
- if not is_bird_installed():
- return None
-
- try:
- result = subprocess.run(
- ["bird", "whoami"],
- capture_output=True,
- text=True,
- timeout=10,
- )
- if result.returncode == 0 and result.stdout.strip():
- # Output is typically the username
- return result.stdout.strip().split('\n')[0]
- return None
- except (subprocess.TimeoutExpired, FileNotFoundError, Exception):
- return None
-
-
-def check_npm_available() -> bool:
- """Check if npm is available for installation."""
- return shutil.which("npm") is not None
-```
-
-**Step 2: Verify the module loads**
-
-Run: `cd /Users/mvanhorn/last30days-skill-private && python3 -c "from scripts.lib import bird_x; print('OK')"`
-Expected: `OK`
-
-**Step 3: Commit**
-
-```bash
-git add scripts/lib/bird_x.py
-git commit -m "feat(bird): add detection functions for Bird CLI"
-```
-
----
-
-### Task 2: Add Bird Installation Functions
-
-**Files:**
-- Modify: `scripts/lib/bird_x.py`
-
-**Step 1: Add installation function**
-
-Add after `check_npm_available()`:
-
-```python
-def install_bird() -> Tuple[bool, str]:
- """Install Bird CLI via npm.
-
- Returns:
- Tuple of (success, message).
- """
- if not check_npm_available():
- return False, "npm not found. Install Node.js first, or install Bird manually: https://github.com/steipete/bird"
-
- try:
- _log("Installing Bird CLI...")
- result = subprocess.run(
- ["npm", "install", "-g", "@steipete/bird"],
- capture_output=True,
- text=True,
- timeout=120,
- )
- if result.returncode == 0:
- return True, "Bird CLI installed successfully!"
- else:
- error = result.stderr.strip() or result.stdout.strip() or "Unknown error"
- return False, f"Installation failed: {error}"
- except subprocess.TimeoutExpired:
- return False, "Installation timed out"
- except Exception as e:
- return False, f"Installation error: {e}"
-
-
-def get_bird_status() -> Dict[str, Any]:
- """Get comprehensive Bird status.
-
- Returns:
- Dict with keys: installed, authenticated, username, can_install
- """
- installed = is_bird_installed()
- username = is_bird_authenticated() if installed else None
-
- return {
- "installed": installed,
- "authenticated": username is not None,
- "username": username,
- "can_install": check_npm_available(),
- }
-```
-
-**Step 2: Verify functions work**
-
-Run: `cd /Users/mvanhorn/last30days-skill-private && python3 -c "from scripts.lib import bird_x; print(bird_x.get_bird_status())"`
-Expected: Dict with installed/authenticated status
-
-**Step 3: Commit**
-
-```bash
-git add scripts/lib/bird_x.py
-git commit -m "feat(bird): add installation and status functions"
-```
-
----
-
-### Task 3: Add Bird Search Function
-
-**Files:**
-- Modify: `scripts/lib/bird_x.py`
-
-**Step 1: Add depth config and search function**
-
-Add after imports at top:
-
-```python
-# Depth configurations: number of results to request
-DEPTH_CONFIG = {
- "quick": 12,
- "default": 30,
- "deep": 60,
-}
-```
-
-Add after `get_bird_status()`:
-
-```python
-def search_x(
- topic: str,
- from_date: str,
- to_date: str,
- depth: str = "default",
-) -> Dict[str, Any]:
- """Search X using Bird CLI.
-
- Args:
- topic: Search topic
- from_date: Start date (YYYY-MM-DD)
- to_date: End date (YYYY-MM-DD)
- depth: Research depth - "quick", "default", or "deep"
-
- Returns:
- Raw Bird JSON response or error dict.
- """
- count = DEPTH_CONFIG.get(depth, DEPTH_CONFIG["default"])
-
- # Build command
- cmd = [
- "bird", "search",
- topic,
- "--since", from_date,
- "-n", str(count),
- "--json",
- ]
-
- # Adjust timeout based on depth
- timeout = 30 if depth == "quick" else 45 if depth == "default" else 60
-
- try:
- result = subprocess.run(
- cmd,
- capture_output=True,
- text=True,
- timeout=timeout,
- )
-
- if result.returncode != 0:
- error = result.stderr.strip() or "Bird search failed"
- return {"error": error, "items": []}
-
- # Parse JSON output
- output = result.stdout.strip()
- if not output:
- return {"items": []}
-
- return json.loads(output)
-
- except subprocess.TimeoutExpired:
- return {"error": "Search timed out", "items": []}
- except json.JSONDecodeError as e:
- return {"error": f"Invalid JSON response: {e}", "items": []}
- except Exception as e:
- return {"error": str(e), "items": []}
-```
-
-**Step 2: Verify search function signature**
-
-Run: `cd /Users/mvanhorn/last30days-skill-private && python3 -c "from scripts.lib import bird_x; import inspect; print(inspect.signature(bird_x.search_x))"`
-Expected: `(topic: str, from_date: str, to_date: str, depth: str = 'default') -> Dict[str, Any]`
-
-**Step 3: Commit**
-
-```bash
-git add scripts/lib/bird_x.py
-git commit -m "feat(bird): add search_x function"
-```
-
----
-
-### Task 4: Add Bird Response Parser
-
-**Files:**
-- Modify: `scripts/lib/bird_x.py`
-
-**Step 1: Add parse function**
-
-Add at end of file:
-
-```python
-def parse_bird_response(response: Dict[str, Any]) -> List[Dict[str, Any]]:
- """Parse Bird response to match xai_x output format.
-
- Args:
- response: Raw Bird JSON response
-
- Returns:
- List of normalized item dicts matching xai_x.parse_x_response() format.
- """
- items = []
-
- # Check for errors
- if "error" in response and response["error"]:
- _log(f"Bird error: {response['error']}")
- return items
-
- # Bird returns a list of tweets directly or under a key
- raw_items = response if isinstance(response, list) else response.get("items", response.get("tweets", []))
-
- if not isinstance(raw_items, list):
- return items
-
- for i, tweet in enumerate(raw_items):
- if not isinstance(tweet, dict):
- continue
-
- # Extract URL - Bird uses permanent_url or we construct from id
- url = tweet.get("permanent_url") or tweet.get("url", "")
- if not url and tweet.get("id"):
- screen_name = tweet.get("user", {}).get("screen_name", "")
- if screen_name:
- url = f"https://x.com/{screen_name}/status/{tweet['id']}"
-
- if not url:
- continue
-
- # Parse date from created_at (e.g., "Wed Jan 15 14:30:00 +0000 2026")
- date = None
- created_at = tweet.get("created_at", "")
- if created_at:
- try:
- # Try ISO format first
- if "T" in created_at:
- dt = datetime.fromisoformat(created_at.replace("Z", "+00:00"))
- else:
- # Twitter format: "Wed Jan 15 14:30:00 +0000 2026"
- dt = datetime.strptime(created_at, "%a %b %d %H:%M:%S %z %Y")
- date = dt.strftime("%Y-%m-%d")
- except (ValueError, TypeError):
- pass
-
- # Extract user info
- user = tweet.get("user", {})
- author_handle = user.get("screen_name", "") or tweet.get("author_handle", "")
-
- # Build engagement dict
- engagement = {
- "likes": tweet.get("like_count") or tweet.get("favorite_count"),
- "reposts": tweet.get("retweet_count"),
- "replies": tweet.get("reply_count"),
- "quotes": tweet.get("quote_count"),
- }
- # Convert to int where possible
- for key in engagement:
- if engagement[key] is not None:
- try:
- engagement[key] = int(engagement[key])
- except (ValueError, TypeError):
- engagement[key] = None
-
- # Build normalized item
- item = {
- "id": f"X{i+1}",
- "text": str(tweet.get("text", tweet.get("full_text", ""))).strip()[:500],
- "url": url,
- "author_handle": author_handle.lstrip("@"),
- "date": date,
- "engagement": engagement if any(v is not None for v in engagement.values()) else None,
- "why_relevant": "", # Bird doesn't provide relevance explanations
- "relevance": 0.7, # Default relevance, let score.py re-rank
- }
-
- items.append(item)
-
- return items
-```
-
-**Step 2: Verify parser handles empty input**
-
-Run: `cd /Users/mvanhorn/last30days-skill-private && python3 -c "from scripts.lib import bird_x; print(bird_x.parse_bird_response({}))"`
-Expected: `[]`
-
-**Step 3: Commit**
-
-```bash
-git add scripts/lib/bird_x.py
-git commit -m "feat(bird): add response parser matching xai_x format"
-```
-
----
-
-### Task 5: Add UI Functions for Bird Prompts
-
-**Files:**
-- Modify: `scripts/lib/ui.py`
-
-**Step 1: Add Bird-related messages and prompts**
-
-Add after `PROMO_SINGLE_KEY_PLAIN` dict (around line 128):
-
-```python
-# Bird CLI prompts
-BIRD_INSTALL_PROMPT = f"""
-{Colors.CYAN}{Colors.BOLD}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━{Colors.RESET}
-{Colors.CYAN}🐦 FREE X/TWITTER SEARCH AVAILABLE{Colors.RESET}
-
-Bird CLI provides free X search using your browser session (no API key needed).
-
-"""
-
-BIRD_INSTALL_PROMPT_PLAIN = """
-━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
-🐦 FREE X/TWITTER SEARCH AVAILABLE
-
-Bird CLI provides free X search using your browser session (no API key needed).
-
-"""
-
-BIRD_AUTH_HELP = f"""
-{Colors.YELLOW}Bird authentication failed.{Colors.RESET}
-
-To fix this:
-1. Log into X (twitter.com) in Safari, Chrome, or Firefox
-2. Run: {Colors.BOLD}bird check{Colors.RESET} to verify credentials
-3. Try again
-
-For manual setup, see: https://github.com/steipete/bird#authentication
-"""
-
-BIRD_AUTH_HELP_PLAIN = """
-Bird authentication failed.
-
-To fix this:
-1. Log into X (twitter.com) in Safari, Chrome, or Firefox
-2. Run: bird check to verify credentials
-3. Try again
-
-For manual setup, see: https://github.com/steipete/bird#authentication
-"""
-```
-
-**Step 2: Add prompt functions to ProgressDisplay class**
-
-Add these methods to the `ProgressDisplay` class (after `show_promo` method, around line 310):
-
-```python
- def prompt_bird_install(self) -> bool:
- """Prompt user to install Bird CLI.
-
- Returns:
- True if user wants to install, False otherwise.
- """
- if IS_TTY:
- sys.stderr.write(BIRD_INSTALL_PROMPT)
- else:
- sys.stderr.write(BIRD_INSTALL_PROMPT_PLAIN)
- sys.stderr.flush()
-
- try:
- response = input("Install Bird CLI now? (y/n): ").strip().lower()
- return response in ('y', 'yes')
- except (EOFError, KeyboardInterrupt):
- return False
-
- def show_bird_install_success(self, username: str):
- """Show Bird installation success message."""
- msg = f"{Colors.GREEN}✓ Bird installed and authenticated as @{username}{Colors.RESET}\n" if IS_TTY else f"✓ Bird installed and authenticated as @{username}\n"
- sys.stderr.write(msg)
- sys.stderr.flush()
-
- def show_bird_install_failed(self, error: str):
- """Show Bird installation failure message."""
- msg = f"{Colors.RED}✗ Bird installation failed: {error}{Colors.RESET}\n" if IS_TTY else f"✗ Bird installation failed: {error}\n"
- sys.stderr.write(msg)
- sys.stderr.flush()
-
- def show_bird_auth_help(self):
- """Show Bird authentication help."""
- if IS_TTY:
- sys.stderr.write(BIRD_AUTH_HELP)
- else:
- sys.stderr.write(BIRD_AUTH_HELP_PLAIN)
- sys.stderr.flush()
-```
-
-**Step 3: Verify new methods exist**
-
-Run: `cd /Users/mvanhorn/last30days-skill-private && python3 -c "from scripts.lib.ui import ProgressDisplay; p = ProgressDisplay('test', show_banner=False); print(hasattr(p, 'prompt_bird_install'))"`
-Expected: `True`
-
-**Step 4: Commit**
-
-```bash
-git add scripts/lib/ui.py
-git commit -m "feat(ui): add Bird CLI install prompts and auth help"
-```
-
----
-
-### Task 6: Update env.py with X Source Detection
-
-**Files:**
-- Modify: `scripts/lib/env.py`
-
-**Step 1: Add get_x_source function**
-
-Add at end of file:
-
-```python
-def get_x_source(config: Dict[str, Any]) -> Optional[str]:
- """Determine the best available X/Twitter source.
-
- Priority: Bird (free) → xAI (paid API)
-
- Args:
- config: Configuration dict from get_config()
-
- Returns:
- 'bird' if Bird is installed and authenticated,
- 'xai' if XAI_API_KEY is configured,
- None if no X source available.
- """
- # Import here to avoid circular dependency
- from . import bird_x
-
- # Check Bird first (free option)
- if bird_x.is_bird_installed():
- username = bird_x.is_bird_authenticated()
- if username:
- return 'bird'
-
- # Fall back to xAI if key exists
- if config.get('XAI_API_KEY'):
- return 'xai'
-
- return None
-
-
-def get_x_source_status(config: Dict[str, Any]) -> Dict[str, Any]:
- """Get detailed X source status for UI decisions.
-
- Returns:
- Dict with keys: source, bird_installed, bird_authenticated,
- bird_username, xai_available, can_install_bird
- """
- from . import bird_x
-
- bird_status = bird_x.get_bird_status()
- xai_available = bool(config.get('XAI_API_KEY'))
-
- # Determine active source
- if bird_status["authenticated"]:
- source = 'bird'
- elif xai_available:
- source = 'xai'
- else:
- source = None
-
- return {
- "source": source,
- "bird_installed": bird_status["installed"],
- "bird_authenticated": bird_status["authenticated"],
- "bird_username": bird_status["username"],
- "xai_available": xai_available,
- "can_install_bird": bird_status["can_install"],
- }
-```
-
-**Step 2: Verify function works**
-
-Run: `cd /Users/mvanhorn/last30days-skill-private && python3 -c "from scripts.lib import env; print(env.get_x_source_status(env.get_config()))"`
-Expected: Dict with source status
-
-**Step 3: Commit**
-
-```bash
-git add scripts/lib/env.py
-git commit -m "feat(env): add X source detection with Bird priority"
-```
-
----
-
-### Task 7: Update __init__.py to Export bird_x
-
-**Files:**
-- Modify: `scripts/lib/__init__.py`
-
-**Step 1: Add bird_x to imports**
-
-Replace file contents with:
-
-```python
-# last30days library modules
-from . import bird_x
-```
-
-**Step 2: Verify import works**
-
-Run: `cd /Users/mvanhorn/last30days-skill-private && python3 -c "from scripts.lib import bird_x; print('OK')"`
-Expected: `OK`
-
-**Step 3: Commit**
-
-```bash
-git add scripts/lib/__init__.py
-git commit -m "feat(lib): export bird_x module"
-```
-
----
-
-### Task 8: Integrate Bird into Main Script - Part 1 (Setup Phase)
-
-**Files:**
-- Modify: `scripts/last30days.py`
-
-**Step 1: Add bird_x import**
-
-Add `bird_x` to the imports from lib (around line 36):
-
-```python
-from lib import (
- bird_x,
- dates,
- dedupe,
- env,
- http,
- models,
- normalize,
- openai_reddit,
- reddit_enrich,
- render,
- schema,
- score,
- ui,
- websearch,
- xai_x,
-)
-```
-
-**Step 2: Add Bird setup function**
-
-Add after the imports, before `load_fixture`:
-
-```python
-def setup_bird_if_needed(progress: ui.ProgressDisplay) -> Optional[str]:
- """Check Bird status and offer installation if needed.
-
- Returns:
- 'bird' if Bird is ready to use,
- 'declined' if user declined install,
- None if Bird not available and couldn't be installed.
- """
- status = bird_x.get_bird_status()
-
- # Already working
- if status["authenticated"]:
- return 'bird'
-
- # Installed but not authenticated
- if status["installed"]:
- progress.show_bird_auth_help()
- return None
-
- # Not installed - offer to install if npm available
- if status["can_install"]:
- if progress.prompt_bird_install():
- success, message = bird_x.install_bird()
- if success:
- # Check if auth works now
- username = bird_x.is_bird_authenticated()
- if username:
- progress.show_bird_install_success(username)
- return 'bird'
- else:
- progress.show_bird_auth_help()
- return None
- else:
- progress.show_bird_install_failed(message)
- return None
- else:
- return 'declined'
-
- return None
-```
-
-**Step 3: Verify script still loads**
-
-Run: `cd /Users/mvanhorn/last30days-skill-private && python3 -c "import scripts.last30days; print('OK')"`
-Expected: `OK`
-
-**Step 4: Commit**
-
-```bash
-git add scripts/last30days.py
-git commit -m "feat(main): add Bird setup function"
-```
-
----
-
-### Task 9: Integrate Bird into Main Script - Part 2 (Search Dispatch)
-
-**Files:**
-- Modify: `scripts/last30days.py`
-
-**Step 1: Modify _search_x function to support Bird**
-
-Replace the `_search_x` function (around line 119-159) with:
-
-```python
-def _search_x(
- topic: str,
- config: dict,
- selected_models: dict,
- from_date: str,
- to_date: str,
- depth: str,
- mock: bool,
- x_source: str = "xai",
-) -> tuple:
- """Search X via Bird CLI or xAI (runs in thread).
-
- Args:
- x_source: 'bird' or 'xai' - which backend to use
-
- Returns:
- Tuple of (x_items, raw_response, error)
- """
- raw_response = None
- x_error = None
-
- if mock:
- raw_response = load_fixture("xai_sample.json")
- x_items = xai_x.parse_x_response(raw_response or {})
- return x_items, raw_response, x_error
-
- # Use Bird if specified
- if x_source == "bird":
- try:
- raw_response = bird_x.search_x(
- topic,
- from_date,
- to_date,
- depth=depth,
- )
- except Exception as e:
- raw_response = {"error": str(e)}
- x_error = f"{type(e).__name__}: {e}"
-
- x_items = bird_x.parse_bird_response(raw_response or {})
-
- # Check for error in response
- if raw_response and raw_response.get("error") and not x_error:
- x_error = raw_response["error"]
-
- return x_items, raw_response, x_error
-
- # Use xAI (original behavior)
- try:
- raw_response = xai_x.search_x(
- config["XAI_API_KEY"],
- selected_models["xai"],
- topic,
- from_date,
- to_date,
- depth=depth,
- )
- except http.HTTPError as e:
- raw_response = {"error": str(e)}
- x_error = f"API error: {e}"
- except Exception as e:
- raw_response = {"error": str(e)}
- x_error = f"{type(e).__name__}: {e}"
-
- x_items = xai_x.parse_x_response(raw_response or {})
-
- return x_items, raw_response, x_error
-```
-
-**Step 2: Update run_research to accept x_source parameter**
-
-Find the `run_research` function signature (around line 161) and add `x_source` parameter:
-
-```python
-def run_research(
- topic: str,
- sources: str,
- config: dict,
- selected_models: dict,
- from_date: str,
- to_date: str,
- depth: str = "default",
- mock: bool = False,
- progress: ui.ProgressDisplay = None,
- x_source: str = "xai",
-) -> tuple:
-```
-
-Then update the `_search_x` call inside (around line 218-222) to pass `x_source`:
-
-```python
- x_future = executor.submit(
- _search_x, topic, config, selected_models,
- from_date, to_date, depth, mock, x_source
- )
-```
-
-**Step 3: Verify script syntax is valid**
-
-Run: `cd /Users/mvanhorn/last30days-skill-private && python3 -m py_compile scripts/last30days.py && echo "OK"`
-Expected: `OK`
-
-**Step 4: Commit**
-
-```bash
-git add scripts/last30days.py
-git commit -m "feat(main): dispatch X search to Bird or xAI"
-```
-
----
-
-### Task 10: Integrate Bird into Main Script - Part 3 (Main Function)
-
-**Files:**
-- Modify: `scripts/last30days.py`
-
-**Step 1: Update main() to check Bird before research**
-
-In the `main()` function, after loading config and before checking available sources (around line 345-355), add Bird setup:
-
-Find this section:
-```python
- # Load config
- config = env.get_config()
-
- # Check available sources
- available = env.get_available_sources(config)
-```
-
-Replace with:
-```python
- # Load config
- config = env.get_config()
-
- # Initialize progress display early for Bird prompts
- progress = ui.ProgressDisplay(args.topic, show_banner=True)
-
- # Check Bird availability and offer install if needed
- x_source_status = env.get_x_source_status(config)
- x_source = x_source_status["source"]
-
- # If no X source and Bird can be installed, offer it
- if x_source is None and x_source_status["can_install_bird"]:
- bird_result = setup_bird_if_needed(progress)
- if bird_result == 'bird':
- x_source = 'bird'
- # Refresh status
- x_source_status = env.get_x_source_status(config)
-
- # Check available sources (now accounting for Bird)
- available = env.get_available_sources(config)
-
- # Override available if Bird is ready
- if x_source == 'bird':
- if available == 'reddit':
- available = 'both' # Now have both Reddit + X (via Bird)
- elif available == 'web':
- available = 'x' # Now have X via Bird
-```
-
-**Step 2: Remove duplicate progress initialization**
-
-Find and remove the later `progress = ui.ProgressDisplay(...)` line (around line 371) since we now create it earlier.
-
-**Step 3: Pass x_source to run_research**
-
-Find the `run_research` call (around line 413) and add `x_source` parameter:
-
-```python
- reddit_items, x_items, web_needed, raw_openai, raw_xai, raw_reddit_enriched, reddit_error, x_error = run_research(
- args.topic,
- sources,
- config,
- selected_models,
- from_date,
- to_date,
- depth,
- args.mock,
- progress,
- x_source=x_source or "xai",
- )
-```
-
-**Step 4: Verify script runs with --help**
-
-Run: `cd /Users/mvanhorn/last30days-skill-private && python3 scripts/last30days.py --help`
-Expected: Help text displays without errors
-
-**Step 5: Commit**
-
-```bash
-git add scripts/last30days.py
-git commit -m "feat(main): integrate Bird setup into main flow"
-```
-
----
-
-### Task 11: Test End-to-End with Mock Mode
-
-**Files:**
-- None (testing only)
-
-**Step 1: Test mock mode still works**
-
-Run: `cd /Users/mvanhorn/last30days-skill-private && python3 scripts/last30days.py "Claude Code" --mock --emit=compact 2>&1 | head -20`
-Expected: Output showing research results without errors
-
-**Step 2: Test Bird detection (informational)**
-
-Run: `cd /Users/mvanhorn/last30days-skill-private && python3 -c "from scripts.lib import env; import json; print(json.dumps(env.get_x_source_status(env.get_config()), indent=2))"`
-Expected: JSON showing current Bird/xAI status
-
-**Step 3: Commit any fixes if needed, then final commit**
-
-```bash
-git add -A
-git commit -m "feat(bird): complete Bird CLI integration
-
-- Add bird_x.py module for Bird CLI detection, install, and search
-- Add UI prompts for interactive Bird installation
-- Update env.py with X source priority (Bird > xAI)
-- Integrate Bird into main research flow
-- Bird uses browser cookies (free, no API key needed)"
-```
-
----
-
-### Task 12: Push to Private Repo
-
-**Files:**
-- None (git only)
-
-**Step 1: Push all changes**
-
-Run: `cd /Users/mvanhorn/last30days-skill-private && git push origin main`
-Expected: Changes pushed to private repo
-
-**Step 2: Verify commit history**
-
-Run: `cd /Users/mvanhorn/last30days-skill-private && git log --oneline -10`
-Expected: Shows Bird integration commits
-
----
-
-## Summary
-
-After completing all tasks, the skill will:
-
-1. Check if Bird CLI is installed on startup
-2. If not installed but npm available, prompt user to install
-3. If installed, verify authentication via `bird whoami`
-4. If authenticated, use Bird for all X searches (free)
-5. If not, fall back to xAI (if key exists) or WebSearch
-6. Output format identical regardless of backend used
diff --git a/docs/plans/2026-02-03-bird-cli-integration-design.md b/docs/plans/2026-02-03-bird-cli-integration-design.md
deleted file mode 100644
index ad4794b..0000000
--- a/docs/plans/2026-02-03-bird-cli-integration-design.md
+++ /dev/null
@@ -1,102 +0,0 @@
-# Bird CLI Integration Design
-
-**Date:** 2026-02-03
-**Status:** Approved
-
-## Overview
-
-Add Bird CLI as an alternative X/Twitter search source for the last30days skill. Bird uses browser cookie authentication (free, no API key) and provides direct access to X's GraphQL API.
-
-## Goals
-
-- Provide free X search without requiring xAI API key
-- Seamless fallback: Bird → xAI → WebSearch
-- Interactive onboarding for users without Bird installed
-- Output parity with existing xAI implementation
-
-## Detection & Priority Flow
-
-```
-On startup:
-1. Check: Is Bird installed? (`which bird`)
- ├─ No → Offer to install: "Bird CLI not found. Install for free X search? (y/n)"
- │ ├─ Yes → Run `npm install -g @steipete/bird`
- │ └─ No → Continue to step 2
- │
- └─ Yes → Check: Is Bird authenticated? (`bird whoami`)
- ├─ Success → Use Bird for X searches
- └─ Fail → Show: "Bird auth failed. Run `bird check` to diagnose."
- Continue to step 2
-
-2. Fall back to xAI if XAI_API_KEY exists
-3. Fall back to WebSearch if nothing else available
-```
-
-**Priority order:** Bird → xAI → WebSearch
-
-## New Module: `scripts/lib/bird_x.py`
-
-### Functions
-
-- `is_bird_installed()` → checks `which bird`, returns bool
-- `is_bird_authenticated()` → runs `bird whoami`, returns username or None
-- `install_bird()` → runs `npm install -g @steipete/bird`, returns success bool
-- `search_x(topic, from_date, to_date, depth)` → runs `bird search` with JSON output
-- `parse_bird_response(json)` → converts to same format as `xai_x.parse_x_response()`
-
-### Search Command
-
-```bash
-bird search "Claude Code skills" --since 2026-01-04 -n 30 --json
-```
-
-- `--since` filters to last 30 days
-- `-n 30` controls result count (maps to depth: quick=12, default=30, deep=60)
-- `--json` gives machine-readable output
-
-### Output Mapping
-
-| Bird field | Our field |
-|------------|-----------|
-| `text` | `text` |
-| `permanent_url` | `url` |
-| `user.screen_name` | `author_handle` |
-| `created_at` | `date` (parse to YYYY-MM-DD) |
-| `like_count` | `engagement.likes` |
-| `retweet_count` | `engagement.reposts` |
-| `reply_count` | `engagement.replies` |
-| `quote_count` | `engagement.quotes` |
-
-Relevance: Default to 0.7, let `score.py` re-rank based on engagement.
-
-## Modified Files
-
-| File | Change |
-|------|--------|
-| `env.py` | Add `get_x_source()` → returns `'bird'`, `'xai'`, or `None` |
-| `last30days.py` | Check Bird availability with interactive install prompt before research |
-| `last30days.py` | In `_search_x()`, dispatch to `bird_x` or `xai_x` based on source |
-| `ui.py` | Add `prompt_bird_install()` and `show_bird_auth_help()` |
-
-## Unchanged Files
-
-- `normalize.py` - Bird output matches xAI format after parsing
-- `score.py` - Same scoring logic applies
-- `dedupe.py` - Same deduplication logic
-- `render.py` - X results labeled as "X" regardless of backend
-
-## Error Handling
-
-| Scenario | Behavior |
-|----------|----------|
-| Bird installed but no browser cookies | Show `bird check` guidance, fall back to xAI |
-| Bird search returns 0 results | Retry with simplified query (same as xAI logic) |
-| Bird search times out | Fall back to xAI if available, else WebSearch |
-| npm not installed (can't install Bird) | Skip Bird, continue with xAI/WebSearch |
-| User declines Bird install | Remember for session, don't ask again |
-
-**Timeout:** 30 seconds for Bird commands
-
-## Output Labels
-
-Results labeled as "X" regardless of whether Bird or xAI was used. Users care about the data, not the backend.
diff --git a/docs/plans/2026-02-06-feat-automated-v1-v2-test-harness-plan.md b/docs/plans/2026-02-06-feat-automated-v1-v2-test-harness-plan.md
deleted file mode 100644
index c91f873..0000000
--- a/docs/plans/2026-02-06-feat-automated-v1-v2-test-harness-plan.md
+++ /dev/null
@@ -1,281 +0,0 @@
----
-title: "feat: Automated v1 vs v2 test harness using claude --print"
-type: feat
-date: 2026-02-06
----
-
-# feat: Automated V1 vs V2 Test Harness
-
-## Overview
-
-Build a bash script that swaps SKILL.md between v1 (upstream) and v2 (current), runs `claude --print "/last30days [query]"` for all 17 test queries on each version, captures output to files, then generates a comparison doc with analysis.
-
-## How It Works
-
-```
-┌─────────────────────────────────────────────────┐
-│ test-v1-vs-v2.sh │
-│ │
-│ 1. Save current SKILL.md as .v2 backup │
-│ 2. Install v1 SKILL.md from upstream │
-│ 3. Loop 17 queries → claude --print → v1/*.txt │
-│ 4. Restore v2 SKILL.md │
-│ 5. Loop 17 queries → claude --print → v2/*.txt │
-│ 6. Generate comparison doc │
-└─────────────────────────────────────────────────┘
-```
-
-Each `claude --print` call:
-- Invokes the /last30days skill exactly as a user would
-- Runs the Python script (real API calls to OpenAI + xAI)
-- Runs WebSearch
-- Applies SKILL.md presentation instructions
-- Returns the full formatted output
-- Exits (no interactive session)
-
-## Implementation
-
-### File: `scripts/test-v1-vs-v2.sh`
-
-```bash
-#!/bin/bash
-set -euo pipefail
-
-# === Config ===
-SKILL_DIR="$HOME/.claude/skills/last30days"
-REPO_DIR="/Users/mvanhorn/last30days-skill-private"
-OUT_DIR="$REPO_DIR/docs/test-results/v1-vs-v2-$(date +%Y%m%d-%H%M%S)"
-V1_DIR="$OUT_DIR/v1"
-V2_DIR="$OUT_DIR/v2"
-
-mkdir -p "$V1_DIR" "$V2_DIR"
-
-# All 17 test queries (from README + plans)
-declare -a QUERIES=(
- "prompting techniques for chatgpt for legal questions"
- "best clawdbot use cases"
- "how to best setup clawdbot"
- "prompting tips for nano banana pro for ios designs"
- "top claude code skills"
- "using ChatGPT to make images of dogs"
- "research best practices for beautiful remotion animation videos in claude code"
- "photorealistic people in nano banana pro"
- "What are the best rap songs lately"
- "what are people saying about DeepSeek R1"
- "best practices for cursor rules files for Cursor"
- "prompt advice for using suno to make killer songs in simple mode"
- "how do I use Codex with Claude Code on same app to make it better"
- "kanye west"
- "howie.ai"
- "open claw"
- "nano banana pro prompting"
-)
-
-declare -a TYPES=(
- "PROMPTING+TOOL"
- "RECOMMENDATIONS"
- "HOW-TO"
- "PROMPTING+TOOL"
- "RECOMMENDATIONS"
- "GENERAL"
- "PROMPTING"
- "PROMPTING"
- "RECOMMENDATIONS"
- "NEWS"
- "PROMPTING"
- "PROMPTING"
- "HOW-TO"
- "NEWS"
- "GENERAL"
- "GENERAL"
- "PROMPTING"
-)
-
-slugify() {
- echo "$1" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/-/g' | sed 's/--*/-/g' | head -c 60
-}
-
-run_version() {
- local version="$1"
- local outdir="$2"
-
- echo ""
- echo "=========================================="
- echo " Running $version — ${#QUERIES[@]} queries"
- echo "=========================================="
-
- for i in "${!QUERIES[@]}"; do
- local query="${QUERIES[$i]}"
- local type="${TYPES[$i]}"
- local slug=$(slugify "$query")
- local num=$((i + 1))
- local outfile="$outdir/${num}-${slug}.txt"
-
- echo ""
- echo "[$version] ($num/${#QUERIES[@]}) $query [$type]"
- echo " → $outfile"
-
- # Run claude --print with the skill invocation
- # --no-session-persistence: don't save to session history
- # Timeout after 5 minutes per query (generous for slow API calls)
- if timeout 300 claude --print \
- "/last30days $query" \
- > "$outfile" 2>"$outdir/${num}-${slug}.stderr.txt"; then
- echo " ✅ Done ($(wc -l < "$outfile") lines)"
- else
- echo " ❌ Failed or timed out"
- echo "FAILED: timeout or error" >> "$outfile"
- fi
-
- # Brief pause between queries to avoid rate limits
- sleep 2
- done
-}
-
-# === Phase 1: Test V1 ===
-echo "📦 Backing up current SKILL.md..."
-cp "$SKILL_DIR/SKILL.md" "$SKILL_DIR/SKILL.md.v2.bak"
-
-echo "📥 Installing V1 SKILL.md from upstream..."
-cd "$REPO_DIR"
-git show upstream/main:SKILL.md > "$SKILL_DIR/SKILL.md"
-
-# Also save a copy for reference
-cp "$SKILL_DIR/SKILL.md" "$OUT_DIR/v1-SKILL.md"
-
-run_version "V1" "$V1_DIR"
-
-# === Phase 2: Test V2 ===
-echo ""
-echo "📥 Restoring V2 SKILL.md..."
-cp "$SKILL_DIR/SKILL.md.v2.bak" "$SKILL_DIR/SKILL.md"
-
-# Also save a copy for reference
-cp "$SKILL_DIR/SKILL.md" "$OUT_DIR/v2-SKILL.md"
-
-run_version "V2" "$V2_DIR"
-
-# === Phase 3: Generate summary ===
-echo ""
-echo "=========================================="
-echo " Generating comparison summary"
-echo "=========================================="
-
-SUMMARY="$OUT_DIR/comparison-summary.md"
-
-cat > "$SUMMARY" << 'HEADER'
-# V1 vs V2 Comparison Results
-
-Generated: $(date)
-
-## Output Files
-
-| # | Query | Type | V1 Lines | V2 Lines |
-|---|-------|------|----------|----------|
-HEADER
-
-# Replace the date placeholder
-sed -i '' "s/\$(date)/$(date)/" "$SUMMARY"
-
-for i in "${!QUERIES[@]}"; do
- local query="${QUERIES[$i]}"
- local type="${TYPES[$i]}"
- local slug=$(slugify "$query")
- local num=$((i + 1))
-
- local v1file="$V1_DIR/${num}-${slug}.txt"
- local v2file="$V2_DIR/${num}-${slug}.txt"
-
- local v1lines=$(wc -l < "$v1file" 2>/dev/null || echo "0")
- local v2lines=$(wc -l < "$v2file" 2>/dev/null || echo "0")
-
- echo "| $num | \`$query\` | $type | $v1lines | $v2lines |" >> "$SUMMARY"
-done
-
-cat >> "$SUMMARY" << 'FOOTER'
-
-## Scorecard Template
-
-For each query, score both versions on:
-
-| Dimension | V1 | V2 | Notes |
-|-----------|----|----|-------|
-| Query Parsing Display (1-5) | | | |
-| Source Coverage (1-5) | | | |
-| Citation Quality (1-5) | | | |
-| Summary Structure (1-5) | | | |
-| Stats Box Format (1-5) | | | |
-| Research Grounding (1-5) | | | |
-
-## Next Step
-
-Read each pair of output files and score them using the test plan at:
-`docs/plans/2026-02-06-test-v1-vs-v2-comparison-plan.md`
-FOOTER
-
-echo ""
-echo "✅ All done!"
-echo "📁 Results: $OUT_DIR"
-echo "📊 Summary: $SUMMARY"
-echo ""
-echo "V1 outputs: $V1_DIR/"
-echo "V2 outputs: $V2_DIR/"
-echo ""
-echo "To review, run:"
-echo " open $OUT_DIR"
-
-# Cleanup backup
-rm -f "$SKILL_DIR/SKILL.md.v2.bak"
-```
-
-## Acceptance Criteria
-
-- [ ] Script runs all 17 queries on v1 SKILL.md
-- [ ] Script runs all 17 queries on v2 SKILL.md
-- [ ] Each query output saved to a separate .txt file
-- [ ] Comparison summary generated with line counts
-- [ ] SKILL.md restored to v2 after testing
-- [ ] Both SKILL.md versions saved in output dir for reference
-- [ ] Script handles timeouts gracefully (5 min per query)
-- [ ] Brief pause between queries to avoid rate limits
-
-## Cost Estimate
-
-- 34 total `claude --print` invocations
-- Each invocation: ~1 Python script run (OpenAI + xAI API) + 2-3 WebSearches + Claude response
-- Estimated: ~$0.10-0.30 per invocation for API calls
-- **Total estimate: $3-10 for the full run**
-
-## Time Estimate
-
-- Each query: ~1-3 minutes (script + WebSearch + synthesis)
-- 17 queries × 2 versions = 34 runs
-- **Total: ~45-90 minutes** (could run in background)
-
-## How to Run
-
-```bash
-cd /Users/mvanhorn/last30days-skill-private
-chmod +x scripts/test-v1-vs-v2.sh
-./scripts/test-v1-vs-v2.sh
-```
-
-Or run in background:
-```bash
-nohup ./scripts/test-v1-vs-v2.sh > test-run.log 2>&1 &
-tail -f test-run.log
-```
-
-## After the Run
-
-Once all outputs are captured, Claude can read every file pair and generate the scored comparison doc with analysis — that's the part where I score each dimension 1-5 and write the final report.
-
-## Files
-
-| File | Purpose |
-|------|---------|
-| `scripts/test-v1-vs-v2.sh` | The test harness script |
-| `docs/test-results/v1-vs-v2-*/` | Output directory (timestamped) |
-| `docs/test-results/v1-vs-v2-*/v1/*.txt` | V1 outputs |
-| `docs/test-results/v1-vs-v2-*/v2/*.txt` | V2 outputs |
-| `docs/test-results/v1-vs-v2-*/comparison-summary.md` | Auto-generated summary |
diff --git a/docs/plans/2026-02-06-feat-free-reddit-mcp-integration-plan.md b/docs/plans/2026-02-06-feat-free-reddit-mcp-integration-plan.md
deleted file mode 100644
index 192a7ef..0000000
--- a/docs/plans/2026-02-06-feat-free-reddit-mcp-integration-plan.md
+++ /dev/null
@@ -1,352 +0,0 @@
----
-title: "feat: Free Reddit via MCP — Remove OpenAI Key Requirement"
-type: feat
-date: 2026-02-06
----
-
-# feat: Free Reddit via MCP — Remove OpenAI Key Requirement
-
-## Overview
-
-Replace the OpenAI Responses API (paid) for Reddit searching with a **free, zero-config Reddit MCP server**, eliminating the need for an `OPENAI_API_KEY` to get Reddit results in the last30days skill. This work happens in a **new forked repo** to keep the current release branch clean.
-
-## Problem Statement
-
-Today, the last30days skill requires an OpenAI API key (`OPENAI_API_KEY`) to search Reddit. This is the most expensive dependency in the stack — OpenAI charges per-call for the Responses API with web search. Users without an OpenAI key get zero Reddit results and fall back to WebSearch-only mode, which loses the engagement metrics (upvotes, comments) that make last30days uniquely valuable.
-
-**Goal:** Make Reddit search completely free with no API key registration required.
-
-## Research Findings
-
-### Option Analysis
-
-Five approaches were evaluated. Two stand out:
-
-| Option | Free? | Search? | Engagement? | Setup | Notes |
-|--------|-------|---------|-------------|-------|-------|
-| **reddit-mcp-buddy** (Node.js) | Yes (anonymous mode) | Yes | Yes | One CLI command | 371 stars, 3-tier auth, most popular |
-| **mcp-server-reddit** (Python) | Yes (redditwarp) | **No** | Yes | pip install | Recommended by ClaudeLog, but browse-only |
-| **.json URL trick** (curl) | Yes | Yes | Yes | Zero | ~10 req/min, no dependencies |
-| Reddit OAuth (PRAW) | Free but needs registration | Yes | Yes | App registration | 100 req/min, most reliable |
-| RSS feeds | Yes | Basic | **No** | Zero | Least useful, no metrics |
-
-### Top Contender: reddit-mcp-buddy
-
-**GitHub:** [karanb192/reddit-mcp-buddy](https://github.com/karanb192/reddit-mcp-buddy)
-- 371 stars, actively maintained (last update: Jan 29, 2026)
-- **Anonymous mode** — zero credentials, ~10 req/min
-- **MCP tools:** `search_reddit`, `browse_subreddit`, `get_post_details`, `user_analysis`, `reddit_explain`
-- **Install:** `claude mcp add --transport stdio reddit-mcp-buddy -s user -- npx -y reddit-mcp-buddy`
-- Returns full engagement metrics (score, num_comments, upvote_ratio)
-- TypeScript/Node.js (npx, no Python dependency)
-
-### Strong Alternative: .json URL trick
-
-- Append `.json` to any Reddit URL → full JSON response
-- Search: `https://www.reddit.com/search.json?q=TOPIC&sort=relevance&t=month&limit=100`
-- Zero dependencies, zero auth, zero setup
-- Returns same data as official API (score, num_comments, created_utc, upvote_ratio)
-- ~10 req/min rate limit (sufficient for skill use)
-- Can be called via `curl` or Python `urllib`
-
-### Also Considered
-
-- **Hawstein/mcp-server-reddit** (134 stars, Python, no auth) — **No search tool**, only browse subreddits. Cannot replace OpenAI's keyword search capability.
-- **Arindam200/reddit-mcp** (262 stars, PRAW) — Has search but **requires Reddit OAuth credentials**. Not truly zero-config.
-- **adhikasp/mcp-reddit** (348 stars) — Hot threads only, no search. Last updated Dec 2024.
-
-## Proposed Solution
-
-### Approach A: MCP-First with .json Fallback (Recommended)
-
-Add a new module `mcp_reddit.py` that:
-
-1. **Detects** if a Reddit MCP server is configured in the user's Claude Code session
-2. **If MCP available:** Calls MCP `search_reddit` tool via the skill's `allowed-tools` (the skill already allows tools — MCP tools become available when configured)
-3. **If no MCP:** Falls back to Reddit's `.json` URL endpoints via `urllib` (stdlib, no dependencies)
-4. **Normalize** MCP/JSON results to the existing `RedditItem` schema
-5. **Enrich** with `reddit_enrich.py` (already works — fetches real thread data)
-
-This gives users three tiers of Reddit access:
-- **Tier 1 (best):** Reddit MCP installed → full search + engagement via MCP tools
-- **Tier 2 (good):** No MCP, no keys → `.json` URL search + enrichment
-- **Tier 3 (existing):** OpenAI key present → existing `openai_reddit.py` still works (backward compat)
-
-### Approach B: .json-Only (Simpler)
-
-Skip MCP entirely. Add a `reddit_json.py` module that uses `https://www.reddit.com/search.json` directly. Simpler but misses the MCP ecosystem integration.
-
-### Approach C: MCP-Only (Cleaner)
-
-Require MCP setup. Simpler code but adds a user setup step (`claude mcp add ...`).
-
-**Recommendation: Approach A** — MCP-first with .json fallback gives zero-config Reddit search for everyone while rewarding users who set up MCP with a better experience.
-
-## Technical Approach
-
-### Architecture
-
-```
-User invokes /last30days "topic"
- │
- ├─ env.py detects sources:
- │ ├─ MCP reddit available? → mcp_reddit.py
- │ ├─ No MCP, no key? → reddit_json.py (.json URL trick)
- │ └─ OPENAI_API_KEY set? → openai_reddit.py (existing, backward compat)
- │
- ├─ X search (Bird CLI / xAI — unchanged)
- │
- └─ Normalize → Score → Dedupe → Render (unchanged)
-```
-
-### New Files
-
-#### `scripts/lib/reddit_json.py`
-- Uses `urllib.request` (stdlib) to call Reddit's `.json` search endpoint
-- URL: `https://www.reddit.com/search.json?q={topic}&sort=relevance&t=month&limit=100`
-- Custom `User-Agent` header (required by Reddit)
-- Parses response into the same format as `openai_reddit.py` output
-- Handles pagination via `after` token if depth=deep (multiple requests)
-- Rate limiting: 1 second delay between requests
-
-#### `scripts/lib/mcp_reddit.py`
-- Detects MCP availability (check if Reddit MCP tools exist in session)
-- Formats MCP tool calls for `search_reddit` with topic + date filters
-- Normalizes MCP response to match existing `RedditItem` schema
-- Falls back to `reddit_json.py` if MCP unavailable
-
-#### Modified Files
-
-- **`scripts/lib/env.py`** — Add Reddit source detection: MCP → .json → OpenAI
-- **`scripts/last30days.py`** — Route Reddit search through new priority chain
-- **`scripts/lib/normalize.py`** — Add normalizer for `.json` endpoint response format
-- **`SKILL.md`** — Document MCP setup as optional enhancement
-
-### MCP Integration Design
-
-The MCP approach has a subtle challenge: the last30days skill runs as a **Python subprocess** (`python3 last30days.py`), but MCP tools are available to **Claude's session**, not to subprocesses.
-
-**Two paths to solve this:**
-
-**Path 1: SKILL.md orchestration** — The SKILL.md workflow calls the MCP `search_reddit` tool directly (before or in parallel with the Python script), saves the MCP response to a temp file, and the Python script reads it:
-
-```
-SKILL.md workflow:
-1. Call MCP search_reddit → save to /tmp/last30days_mcp_reddit.json
-2. Call python3 last30days.py --reddit-from=/tmp/last30days_mcp_reddit.json --emit=compact
-3. Python script reads pre-fetched Reddit data instead of calling OpenAI
-```
-
-**Path 2: .json only for subprocess** — The Python script uses `.json` URLs directly (no MCP needed in subprocess). MCP is a bonus for users who want Claude to also browse specific threads interactively.
-
-**Recommendation: Path 2 for MVP, Path 1 as enhancement.** The `.json` approach is self-contained, testable, and doesn't require SKILL.md workflow changes. MCP can be layered on later.
-
-### Source Priority Chain (updated env.py)
-
-```python
-def get_reddit_source(config: dict) -> str:
- """Returns: 'mcp', 'json', 'openai', or 'none'"""
- # 1. Check for pre-fetched MCP data (from SKILL.md)
- if os.path.exists(MCP_REDDIT_CACHE_PATH):
- return 'mcp'
- # 2. Always available — no key needed
- # (reddit .json endpoints are free)
- return 'json'
- # 3. OpenAI key present → legacy path
- # if config.get('OPENAI_API_KEY'):
- # return 'openai'
-```
-
-For MVP, the `.json` path is **always available** so it becomes the default. OpenAI path remains as opt-in for users who want higher rate limits.
-
-### Data Mapping: .json → RedditItem
-
-Reddit `.json` search returns `data.children[].data` with:
-
-```json
-{
- "title": "Post title",
- "permalink": "/r/subreddit/comments/abc123/...",
- "subreddit": "ClaudeAI",
- "score": 42,
- "num_comments": 15,
- "upvote_ratio": 0.95,
- "created_utc": 1738800000,
- "selftext": "Post body...",
- "url": "https://...",
- "author": "username"
-}
-```
-
-Maps cleanly to existing `RedditItem`:
-
-| .json field | RedditItem field | Notes |
-|-------------|------------------|-------|
-| `title` | `title` | Direct |
-| `permalink` | `url` | Prepend `https://www.reddit.com` |
-| `subreddit` | `subreddit` | Direct |
-| `score` | `engagement.score` | Direct |
-| `num_comments` | `engagement.num_comments` | Direct |
-| `upvote_ratio` | `engagement.upvote_ratio` | Direct |
-| `created_utc` | `date` | Convert epoch → YYYY-MM-DD |
-| `selftext` | (used for relevance) | AI relevance scoring needed |
-| `author` | (not in current schema) | Ignore for now |
-
-### Relevance Scoring Without AI
-
-The current `openai_reddit.py` gets relevance scores **from OpenAI** (the AI judges how relevant each result is). With `.json` endpoints, we lose that AI relevance judgment.
-
-**Options:**
-1. **Keyword matching** — Score based on how many query terms appear in title + selftext. Simple but effective.
-2. **TF-IDF-like** — Weight rarer query terms higher. More accurate but more code.
-3. **Let Claude judge** — Pass results to Claude in SKILL.md and have Claude score relevance. Most accurate but changes the workflow.
-4. **Skip relevance, rely on Reddit's sort** — Reddit's `sort=relevance` already ranks by relevance. Trust it and use position-based scoring (first result = 1.0, last = 0.5).
-
-**Recommendation: Option 4 for MVP.** Reddit's search relevance ranking is already good. Use position-based relevance (1.0 → 0.5 linear decay over result set) combined with the existing engagement-based scoring. This requires zero external dependencies and no AI calls.
-
-## Implementation Phases
-
-### Phase 0: Fork Repository
-
-- [ ] Create new repo `last30days-free-reddit` (or branch in private repo)
-- [ ] `git clone /Users/mvanhorn/last30days-skill-private /Users/mvanhorn/last30days-free-reddit`
-- [ ] Create feature branch: `feat/free-reddit`
-- [ ] Verify all existing tests pass on the new branch
-
-### Phase 1: reddit_json.py — Core .json Search
-
-- [ ] Create `scripts/lib/reddit_json.py`
- - `search_reddit_json(topic: str, depth: str, date_from: str, date_to: str) -> list[dict]`
- - Build search URL with `q`, `sort=relevance`, `t=month`, `limit` (25/50/100 by depth)
- - Custom `User-Agent: last30days-skill/2.0 (by /u/last30days-bot)`
- - Parse `data.children[].data` → list of raw dicts
- - Handle pagination for deep mode (follow `after` token, max 3 pages)
- - Rate limit: `time.sleep(1.0)` between requests
- - Error handling: 429 (rate limit), 403 (blocked), network errors → return empty + error msg
-- [ ] Create `tests/test_reddit_json.py`
- - Mock HTTP responses using fixture files
- - Test: basic search parsing, pagination, rate limit handling, error cases
-- [ ] Create `fixtures/reddit_json_search_sample.json`
- - Real `.json` search response (sanitized)
-
-### Phase 2: Normalize + Score .json Results
-
-- [ ] Add `normalize_reddit_json()` to `scripts/lib/normalize.py`
- - Convert `.json` response format → `RedditItem` schema
- - `created_utc` (epoch) → `YYYY-MM-DD` string
- - `permalink` → full URL
- - Position-based relevance: `1.0 - (index / total * 0.5)` → range [1.0, 0.5]
- - Date confidence: `"high"` (Reddit provides exact timestamps)
-- [ ] Update `scripts/lib/score.py` if needed
- - `.json` results already have real engagement metrics — existing scoring formula works as-is
- - No penalty needed (unlike WebSearch which lacks engagement)
-- [ ] Add tests for normalization + scoring of `.json` data
-
-### Phase 3: Integration into Orchestrator
-
-- [ ] Update `scripts/lib/env.py`
- - Add `get_reddit_source(config) -> str` returning `'json'`, `'openai'`, or `'none'`
- - Priority: `.json` always available (default), `'openai'` if key present and user prefers
- - Add `REDDIT_SOURCE` config option: `auto` (default), `json`, `openai`
- - Update `get_available_sources()` to always include Reddit (since `.json` is free)
- - Update `get_missing_keys()` — Reddit no longer shows as "missing"
-- [ ] Update `scripts/last30days.py`
- - Add `_search_reddit_json()` function alongside existing `_search_reddit()`
- - Route based on `get_reddit_source()`: json → `_search_reddit_json()`, openai → `_search_reddit()`
- - Skip `reddit_enrich.py` for `.json` results (already have real engagement metrics!)
- - Update progress/stats output to show source: "Reddit (free)" vs "Reddit (OpenAI)"
-- [ ] Update SKILL.md promo messaging
- - Remove "Add OPENAI_API_KEY for Reddit" messaging
- - Instead: "Reddit search included free! Add OpenAI key for AI-enhanced relevance scoring."
-- [ ] Integration tests: full pipeline with `.json` mock data
-
-### Phase 4: Testing & Polish
-
-- [ ] Run all existing tests — ensure backward compatibility
-- [ ] Manual test: invoke `/last30days` with NO API keys → should get Reddit + WebSearch results
-- [ ] Manual test: invoke with OPENAI_API_KEY → should still use OpenAI path (backward compat)
-- [ ] Manual test: compare result quality — `.json` vs OpenAI for same topic
-- [ ] Update README.md — document free Reddit access
-- [ ] Update SPEC.md — document new source priority chain
-
-### Phase 5 (Future): MCP Enhancement Layer
-
-- [ ] Detect Reddit MCP in Claude's session
-- [ ] SKILL.md pre-fetches via MCP `search_reddit` → saves to temp file
-- [ ] Python script reads pre-fetched MCP data via `--reddit-from=` flag
-- [ ] MCP results get AI-judged relevance (since Claude sees them)
-- [ ] Better than `.json` position-based relevance
-
-## Acceptance Criteria
-
-### Functional
-- [ ] `/last30days "any topic"` returns Reddit results with **zero API keys configured**
-- [ ] Reddit results include real engagement metrics (score, comments, upvote_ratio)
-- [ ] Results are properly scored, deduped, and rendered (same quality as OpenAI path)
-- [ ] Existing OpenAI path still works when key is present
-- [ ] Existing X search (Bird CLI / xAI) is unchanged
-- [ ] Stats box shows correct source indicator ("Reddit (free)" or "Reddit (OpenAI)")
-
-### Non-Functional
-- [ ] No external Python packages (stdlib only — `urllib.request`, `json`, `time`)
-- [ ] Respects Reddit rate limits (~10 req/min for unauthenticated)
-- [ ] Graceful degradation if Reddit blocks requests (429/403 → empty results + error msg)
-- [ ] All new code has unit tests with fixtures (no live API calls in tests)
-
-### Quality Gates
-- [ ] All existing tests pass (zero regressions)
-- [ ] New tests cover: search parsing, normalization, scoring, error handling, pagination
-- [ ] Manual smoke test passes with zero API keys
-
-## Risk Analysis
-
-| Risk | Likelihood | Impact | Mitigation |
-|------|-----------|--------|------------|
-| Reddit blocks `.json` endpoints | Low | High | Fall back to OpenAI path; monitor for 429s |
-| `.json` rate limit too restrictive | Medium | Medium | 1s delay between requests; cache results |
-| Relevance quality lower without AI scoring | Medium | Medium | Reddit's `sort=relevance` is decent; can add keyword scoring later |
-| Reddit changes `.json` response format | Low | Medium | Schema validation in normalize; fixture-based tests catch changes |
-| Node.js MCP server dependency conflicts | N/A (Phase 5) | Low | MCP is optional enhancement, not required |
-
-## Dependencies
-
-- **None** — this feature uses only Python stdlib and Reddit's public `.json` endpoints
-- Phase 5 (future) would add optional dependency on an MCP server
-
-## Repository Setup
-
-```bash
-# Fork into new working repo
-cp -r /Users/mvanhorn/last30days-skill-private /Users/mvanhorn/last30days-free-reddit
-cd /Users/mvanhorn/last30days-free-reddit
-
-# Create feature branch
-git checkout -b feat/free-reddit
-
-# Verify existing tests
-python3 -m pytest tests/ -v
-```
-
-The new repo is disposable — once the feature is validated, changes merge back into `last30days-skill-private` via PR or cherry-pick.
-
-## References
-
-### Internal
-- `scripts/lib/openai_reddit.py` — Current Reddit search (to be replaced/supplemented)
-- `scripts/lib/env.py:get_available_sources()` — Source detection logic to update
-- `scripts/lib/normalize.py` — Add `.json` normalizer alongside existing
-- `scripts/lib/reddit_enrich.py` — May be skippable for `.json` results (already have engagement)
-- `scripts/lib/schema.py:RedditItem` — Target schema (unchanged)
-
-### External
-- [Reddit .json search endpoint](https://www.reddit.com/search.json?q=test&sort=relevance&t=month&limit=25)
-- [Simon Willison — Scraping Reddit via JSON API](https://til.simonwillison.net/reddit/scraping-reddit-json)
-- [karanb192/reddit-mcp-buddy](https://github.com/karanb192/reddit-mcp-buddy) — Best MCP option for Phase 5
-- [Hawstein/mcp-server-reddit](https://github.com/Hawstein/mcp-server-reddit) — ClaudeLog-recommended MCP (no search though)
-- [Reddit API Rate Limits Guide](https://painonsocial.com/blog/reddit-api-rate-limits-guide)
-
-### Research Sources
-- last30days skill output — community recommendations for Reddit search tools
-- GitHub search — 10+ Reddit MCP repos evaluated
-- npm/PyPI registries — package availability confirmed
-- ClaudeLog — [Reddit MCP reference](https://claudelog.com/claude-code-mcps/reddit-mcp/)
diff --git a/docs/plans/2026-02-06-feat-last30days-bird-cli-release-plan.md b/docs/plans/2026-02-06-feat-last30days-bird-cli-release-plan.md
deleted file mode 100644
index 4eae631..0000000
--- a/docs/plans/2026-02-06-feat-last30days-bird-cli-release-plan.md
+++ /dev/null
@@ -1,391 +0,0 @@
----
-title: "feat: Release last30days v2 with Bird CLI to GitHub"
-type: feat
-date: 2026-02-06
----
-
-# Release last30days v2 (Bird CLI) to GitHub
-
-## Overview
-
-Replace the current public `last30days-skill` on GitHub with the new Bird CLI-enhanced version from `last30days-skill-private`. The new version adds free X/Twitter search via Bird CLI while maintaining backward compatibility with xAI API keys.
-
-**Goal:** Ship with confidence. No rollbacks.
-
-## Current State
-
-| | Old (Public) | New (Private) |
-|---|---|---|
-| **Repo** | `mvanhorn/last30days-skill` | `mvanhorn/last30days-skill-private` |
-| **Local path** | `~/.claude/skills/last30days/` | `~/.claude/skills/last30daystest/` (symlink) |
-| **Remote** | `origin` → public repo | `origin` → private, `upstream` → public |
-| **Key addition** | -- | Bird CLI (`@steipete/bird`) for free X search |
-| **X source chain** | xAI API only | Bird (free) → xAI (paid) → WebSearch |
-| **Uses** | 136 | 18 |
-| **Latest commit** | `cc892d7` | `4230fa2` |
-
-**Why both show as `/last30days`:** Both `SKILL.md` files declare `name: last30days` in frontmatter. Claude Code discovers both from `~/.claude/skills/` and lists them separately.
-
----
-
-## Phase 0: Clean Swap (Day 1)
-
-Remove the old skill so only the new one is active. This eliminates ambiguity during testing.
-
-### Steps
-
-1. **Back up the old skill** (safety net):
- ```bash
- mv ~/.claude/skills/last30days ~/.claude/skills/last30days.backup-v1
- ```
-
-2. **Promote the new skill to primary**:
- ```bash
- # Remove the test symlink
- rm ~/.claude/skills/last30daystest
-
- # Create new symlink with the primary name
- ln -s /Users/mvanhorn/last30days-skill-private ~/.claude/skills/last30days
- ```
-
-3. **Verify only one `/last30days` appears**:
- - Open a new Claude Code session
- - Type `/last` and confirm only ONE `/last30days` shows in autocomplete
- - Confirm description mentions Bird CLI
-
-4. **Rollback procedure** (if something goes wrong):
- ```bash
- rm ~/.claude/skills/last30days
- mv ~/.claude/skills/last30days.backup-v1 ~/.claude/skills/last30days
- ```
-
-### Acceptance Criteria
-- [ ] Only one `/last30days` appears in Claude Code autocomplete
-- [ ] Old skill preserved at `~/.claude/skills/last30days.backup-v1`
-- [ ] New skill responds to `/last30days` invocation
-
----
-
-## Phase 1: Claude's Test Plan (Automated)
-
-These are tests Claude can run autonomously to validate the new skill before the user touches it.
-
-### 1.1 Script-Level Smoke Tests
-
-Run the Python scripts directly to verify core functionality without invoking the full skill.
-
-#### Bird CLI Detection
-```bash
-# Test: Bird is installed and authenticated
-python3 -c "
-import sys; sys.path.insert(0, '/Users/mvanhorn/last30days-skill-private/scripts/lib')
-import bird_x
-print('installed:', bird_x.is_bird_installed())
-print('authenticated:', bird_x.is_bird_authenticated())
-print('status:', bird_x.get_bird_status())
-"
-```
-- [ ] `is_bird_installed()` returns True (or False with clear message)
-- [ ] `is_bird_authenticated()` returns True if logged into X in browser
-- [ ] `get_bird_status()` returns a dict with `installed`, `authenticated`, `available` keys
-
-#### Environment & Source Detection
-```bash
-python3 -c "
-import sys; sys.path.insert(0, '/Users/mvanhorn/last30days-skill-private/scripts/lib')
-import env
-config = env.load_config()
-print('x_source:', env.get_x_source(config))
-print('has_openai:', bool(config.get('OPENAI_API_KEY')))
-"
-```
-- [ ] `get_x_source()` returns `'bird'` if Bird available, `'xai'` if API key set, `None` otherwise
-- [ ] Config loads from `~/.config/last30days/.env`
-
-#### Bird Search (Direct)
-```bash
-python3 -c "
-import sys, json; sys.path.insert(0, '/Users/mvanhorn/last30days-skill-private/scripts/lib')
-import bird_x
-result = bird_x.search_x('Claude Code tips', '2026-01-07', '2026-02-06', 'quick')
-print(json.dumps(result, indent=2, default=str)[:2000])
-"
-```
-- [ ] Returns search results (list of dicts with `url`, `text`, `author_handle`)
-- [ ] No Python tracebacks
-- [ ] Results are from the expected date range
-
-#### Full Research Pipeline (Compact Output)
-```bash
-cd /Users/mvanhorn/last30days-skill-private
-python3 scripts/last30days.py "Claude Code tips" --emit=compact --quick 2>&1 | head -100
-```
-- [ ] Completes without error
-- [ ] Output includes X results (via Bird or xAI)
-- [ ] Output includes Reddit results (via OpenAI) if key configured
-- [ ] Stats summary shows source counts
-
-### 1.2 Source Fallback Tests
-
-Verify graceful degradation when sources are unavailable.
-
-#### Bird unavailable, xAI available
-```bash
-# Temporarily hide Bird
-PATH_BACKUP="$PATH"
-export PATH=$(echo "$PATH" | tr ':' '\n' | grep -v "$(dirname $(which bird 2>/dev/null))" | tr '\n' ':')
-
-python3 -c "
-import sys; sys.path.insert(0, '/Users/mvanhorn/last30days-skill-private/scripts/lib')
-import env
-config = env.load_config()
-print('x_source (no bird):', env.get_x_source(config))
-"
-
-export PATH="$PATH_BACKUP"
-```
-- [ ] Falls back to `'xai'` when Bird not in PATH
-- [ ] No crash or unhandled exception
-
-#### No X source at all
-```bash
-python3 -c "
-import sys; sys.path.insert(0, '/Users/mvanhorn/last30days-skill-private/scripts/lib')
-import env
-config = {} # empty config, no keys
-print('x_source (nothing):', env.get_x_source(config))
-"
-```
-- [ ] Returns `None`
-- [ ] No crash
-
-### 1.3 Response Parsing Tests
-
-Validate that Bird responses are correctly normalized to the canonical schema.
-
-```bash
-python3 -c "
-import sys; sys.path.insert(0, '/Users/mvanhorn/last30days-skill-private/scripts/lib')
-import bird_x
-
-# Test with sample Bird response format
-sample = {
- 'tweets': [{
- 'permanentUrl': 'https://x.com/user/status/123',
- 'text': 'Test tweet about Claude Code',
- 'username': 'testuser',
- 'likeCount': 42,
- 'retweetCount': 10,
- 'replyCount': 5,
- 'timeParsed': '2026-02-01T12:00:00.000Z'
- }]
-}
-parsed = bird_x.parse_bird_response(sample)
-print('Parsed count:', len(parsed))
-print('First item keys:', sorted(parsed[0].keys()) if parsed else 'EMPTY')
-print('URL:', parsed[0].get('url'))
-print('Author:', parsed[0].get('author_handle'))
-"
-```
-- [ ] Parses correctly with expected keys
-- [ ] Handles both camelCase and snake_case fields
-- [ ] URL, text, author, engagement metrics all present
-
-### 1.4 SKILL.md Validation
-
-```bash
-# Verify YAML frontmatter parses correctly
-python3 -c "
-import yaml
-with open('/Users/mvanhorn/last30days-skill-private/SKILL.md') as f:
- content = f.read()
- # Extract YAML between --- markers
- parts = content.split('---', 2)
- meta = yaml.safe_load(parts[1])
- print('name:', meta.get('name'))
- print('context:', meta.get('context'))
- print('agent:', meta.get('agent'))
- print('allowed-tools:', meta.get('allowed-tools'))
-"
-```
-- [ ] `name` is `last30days` (not `last30daystest`)
-- [ ] `context` is `fork`
-- [ ] `agent` is `Explore`
-- [ ] `allowed-tools` includes `Bash`, `WebSearch`
-
-### 1.5 Diff Audit (Old vs New)
-
-```bash
-# Verify the only meaningful addition is bird_x.py
-diff -rq ~/.claude/skills/last30days.backup-v1/scripts/lib/ \
- /Users/mvanhorn/last30days-skill-private/scripts/lib/ 2>/dev/null
-```
-- [ ] Only new file is `bird_x.py`
-- [ ] Modified files: `env.py` (source detection), `__init__.py` (exports)
-- [ ] No unexpected deletions or renames
-
----
-
-## Phase 2: User's Test Plan (Manual)
-
-These require human judgment - evaluating quality, UX, and real-world behavior.
-
-### 2.1 Basic Invocation (5 min)
-
-Open a fresh Claude Code session after Phase 0 is complete.
-
-| # | Test | Command | Pass Criteria |
-|---|------|---------|---------------|
-| 1 | Simple topic | `/last30days AI music generation` | Returns results, shows source stats |
-| 2 | Topic + tool | `/last30days Suno prompts for music production` | Returns results + generates a prompt |
-| 3 | Quick mode | `/last30days --quick TypeScript tips` | Faster, fewer results, still valid |
-| 4 | Empty input | `/last30days` | Prompts for topic (doesn't crash) |
-
-### 2.2 Bird CLI Verification (5 min)
-
-| # | Test | What to Check |
-|---|------|---------------|
-| 1 | Source indicator | Output shows Bird as X source (not xAI) |
-| 2 | X results quality | X/Twitter results are real, recent, have engagement metrics |
-| 3 | Bird promo | If Bird NOT installed, shows non-blocking info banner |
-| 4 | Mixed sources | Both Reddit (OpenAI) and X (Bird) results appear |
-
-### 2.3 Fallback Behavior (5 min)
-
-| # | Test | Setup | Expected |
-|---|------|-------|----------|
-| 1 | No Bird | `npm uninstall -g @steipete/bird` temporarily | Falls back to xAI or WebSearch |
-| 2 | No API keys | Rename `~/.config/last30days/.env` temporarily | WebSearch-only mode works |
-| 3 | Restore | Reinstall bird + restore .env | Full mode returns |
-
-### 2.4 Output Quality (10 min)
-
-Run 3 real research queries you care about. For each, evaluate:
-
-- [ ] Results are actually from the last 30 days (not stale)
-- [ ] Engagement metrics (likes, upvotes) are present and reasonable
-- [ ] No duplicate results
-- [ ] Sources are properly cited with URLs
-- [ ] Synthesis is grounded in actual results (not hallucinated)
-- [ ] Generated prompts (if requested) are usable
-
-### 2.5 Comparison Test (10 min)
-
-Before removing the backup, run the SAME query on both versions:
-
-```bash
-# New version (active)
-/last30days [your topic]
-
-# Old version (temporarily restore)
-rm ~/.claude/skills/last30days
-mv ~/.claude/skills/last30days.backup-v1 ~/.claude/skills/last30days
-# New Claude Code session
-/last30days [same topic]
-# Then swap back
-```
-
-- [ ] New version produces equal or better results
-- [ ] No features regressed
-- [ ] Bird results add value over xAI-only
-
----
-
-## Phase 3: Release Plan (30-Day Timeline)
-
-**Target release date:** March 1, 2026 (conservative buffer before March 8 deadline)
-
-### Week 1: Feb 6-12 - Clean & Test
-
-| Day | Task | Owner |
-|-----|------|-------|
-| Feb 6 | Phase 0: Clean swap (remove old, activate new) | User |
-| Feb 6 | Phase 1: Claude runs automated tests | Claude |
-| Feb 7-8 | Phase 2: User runs manual tests (2.1-2.4) | User |
-| Feb 9 | Phase 2.5: Comparison test | User |
-| Feb 10-12 | Fix any issues found during testing | Claude + User |
-
-### Week 2: Feb 13-19 - Harden
-
-| Day | Task | Owner |
-|-----|------|-------|
-| Feb 13 | Run edge cases: unicode topics, very long topics, special chars | Claude |
-| Feb 14 | Test with Bird logged out (auth expiry scenario) | User |
-| Feb 15 | Review all error messages for clarity | Claude |
-| Feb 16-17 | Update README.md with Bird CLI setup instructions | Claude |
-| Feb 18-19 | Buffer for fixes | Claude + User |
-
-### Week 3: Feb 20-26 - Pre-Release
-
-| Day | Task | Owner |
-|-----|------|-------|
-| Feb 20 | Final diff audit: private repo vs public repo | Claude |
-| Feb 21 | Strip any private/test artifacts (test symlinks, debug prints) | Claude |
-| Feb 22 | Update SKILL.md description if needed | Claude |
-| Feb 23 | Dry-run: push to a branch on public repo (not main) | User |
-| Feb 24 | Test installation from the branch (fresh `~/.claude/skills/`) | User |
-| Feb 25-26 | Buffer for fixes | Claude + User |
-
-### Week 4: Feb 27 - Mar 1 - Ship
-
-| Day | Task | Owner |
-|-----|------|-------|
-| Feb 27 | Merge branch to main on public repo | User |
-| Feb 28 | Create GitHub release with changelog | Claude + User |
-| Mar 1 | Delete backup: `rm -rf ~/.claude/skills/last30days.backup-v1` | User |
-| Mar 1 | Archive private repo (optional) | User |
-
-### Release Checklist (Final Gate)
-
-Before merging to `main` on the public repo:
-
-- [ ] All Phase 1 automated tests pass
-- [ ] All Phase 2 manual tests pass
-- [ ] Comparison test shows new >= old quality
-- [ ] SKILL.md frontmatter is correct (`name: last30days`, not `last30daystest`)
-- [ ] README.md documents Bird CLI setup
-- [ ] No debug/test artifacts in codebase
-- [ ] No hardcoded paths (e.g., `/Users/mvanhorn/...`)
-- [ ] `.env` files are gitignored
-- [ ] Git history is clean (no "test" or "WIP" commits on main)
-- [ ] Bird CLI failure doesn't break the skill (graceful fallback verified)
-
-### Rollback Plan (Emergency)
-
-If something goes wrong after release:
-
-```bash
-# Option 1: Revert to backup (if still exists)
-rm ~/.claude/skills/last30days
-mv ~/.claude/skills/last30days.backup-v1 ~/.claude/skills/last30days
-
-# Option 2: Git revert on public repo
-cd ~/.claude/skills/last30days
-git log --oneline -5 # find the last good commit
-git revert HEAD # revert the merge commit
-git push origin main
-
-# Option 3: Pin to old version
-cd ~/.claude/skills/last30days
-git checkout cc892d7 # last known good commit from old version
-```
-
----
-
-## Risk Analysis
-
-| Risk | Likelihood | Impact | Mitigation |
-|------|-----------|--------|------------|
-| Bird CLI breaks after X API changes | Medium | Low | Fallback to xAI/WebSearch still works |
-| Bird auth expires silently | Medium | Low | `is_bird_authenticated()` check + user message |
-| Old xAI workflows regress | Low | High | Comparison test in Phase 2.5 |
-| Hardcoded paths in codebase | Low | Medium | Grep for `/Users/mvanhorn` before release |
-| SKILL.md name still says `last30daystest` | Low | High | Already fixed in commit `4e972d0` |
-
-## References
-
-- Private repo: `https://github.com/mvanhorn/last30days-skill-private.git`
-- Public repo: `https://github.com/mvanhorn/last30days-skill.git`
-- Bird CLI: `https://github.com/steipete/bird`
-- Bird implementation plan: `docs/plans/2026-02-03-bird-cli-implementation.md`
-- Bird integration design: `docs/plans/2026-02-03-bird-cli-integration-design.md`
diff --git a/docs/plans/2026-02-06-feat-visible-query-parsing-display-plan.md b/docs/plans/2026-02-06-feat-visible-query-parsing-display-plan.md
deleted file mode 100644
index 694915b..0000000
--- a/docs/plans/2026-02-06-feat-visible-query-parsing-display-plan.md
+++ /dev/null
@@ -1,91 +0,0 @@
----
-title: "feat: Add visible query parsing display before research starts"
-type: feat
-date: 2026-02-06
----
-
-# feat: Add Visible Query Parsing Display
-
-## Overview
-
-The last30days skill parses user intent (TOPIC, QUERY_TYPE, TARGET_TOOL) internally but never shows the user what it understood. The agent jumps straight from the user's `/last30days kanye west` into running tools with a generic "I'll start the research script and web searches in parallel."
-
-Users expect to see a reformulation of their query — confirming what the agent understood before it starts searching. This builds trust and lets users course-correct before waiting for results.
-
-## Problem Statement
-
-Current behavior:
-```
-User: /last30days kanye west
-
-Agent: I'll start the research script and web searches in parallel.
-[immediately runs bash + WebSearch]
-```
-
-Expected behavior:
-```
-User: /last30days kanye west
-
-Agent: 🔍 **kanye west** · News
-Searching Reddit, X, and the web for the latest on kanye west...
-
-[then runs bash + WebSearch]
-```
-
-The "Parse User Intent" section in SKILL.md tells the agent to store variables internally but never instructs it to **display** them.
-
-## Proposed Solution
-
-Add an explicit "Display your parsing" instruction between the "Parse User Intent" section and "Research Execution" section in SKILL.md. One new block of text — no code changes, no script changes.
-
-## Acceptance Criteria
-
-- [ ] Agent displays parsed TOPIC and QUERY_TYPE before running any tools
-- [ ] Display is concise (1-2 lines, not a verbose block)
-- [ ] Agent still runs script + WebSearch in parallel after displaying
-- [ ] No changes to Python scripts — SKILL.md only
-
-## Implementation
-
-### SKILL.md Change
-
-**File:** `/Users/mvanhorn/last30days-skill-private/SKILL.md`
-
-After the "Store these variables" block (line ~38) and before "Research Execution" (line ~42), add:
-
-```markdown
-**DISPLAY your parsing to the user.** Before running any tools, output a single line:
-
-🔍 **{TOPIC}** · {QUERY_TYPE}
-Searching Reddit, X, and the web for {natural language description of what you'll look for}...
-
-Example outputs:
-- 🔍 **kanye west** · News — Searching Reddit, X, and the web for the latest kanye west news and discussions...
-- 🔍 **best MCP servers** · Recommendations — Searching Reddit, X, and the web for the most recommended MCP servers...
-- 🔍 **nano banana pro prompting** · Prompting — Searching Reddit, X, and the web for nano banana pro prompting techniques and tips...
-- 🔍 **open claw** · General — Searching Reddit, X, and the web for what people are saying about open claw...
-
-If TARGET_TOOL is known, mention it: "...for nano banana pro prompting techniques to use in ChatGPT..."
-
-This text MUST appear before you call any tools. It confirms to the user that you understood their request.
-```
-
-### Sync
-
-After editing SKILL.md:
-```bash
-cp /Users/mvanhorn/last30days-skill-private/SKILL.md ~/.claude/skills/last30days/SKILL.md
-```
-
-## Test Plan
-
-Run in a NEW Claude Code session:
-1. `/last30days kanye west` — should display: 🔍 **kanye west** · News
-2. `/last30days best MCP servers` — should display: 🔍 **best MCP servers** · Recommendations
-3. `/last30days nano banana pro prompting for ChatGPT` — should display with tool mention
-
-## Files to Modify
-
-| File | Change |
-|------|--------|
-| `SKILL.md` | Add display instruction between Parse User Intent and Research Execution |
diff --git a/docs/plans/2026-02-06-fix-last30days-v2-formatting-reddit-citations-plan.md b/docs/plans/2026-02-06-fix-last30days-v2-formatting-reddit-citations-plan.md
deleted file mode 100644
index 4bfac03..0000000
--- a/docs/plans/2026-02-06-fix-last30days-v2-formatting-reddit-citations-plan.md
+++ /dev/null
@@ -1,167 +0,0 @@
----
-title: "fix: last30days v2 formatting, Reddit results, and citation verbosity"
-type: fix
-date: 2026-02-06
----
-
-# fix: last30days v2 Formatting, Reddit Results, and Citation Verbosity
-
-## Overview
-
-Four bugs found during v2 testing across 4 queries (kanye west, howie.ai, nano banana pro prompting, open claw). The skill IS executing (the agent:Explore removal worked) but output quality has regressed from v1.
-
-## Problem Statement
-
-| # | Bug | Severity | Where |
-|---|-----|----------|-------|
-| 1 | Stats emoji tree format ignored 3/4 times - agent renders plain text dashes instead | High | `SKILL.md` |
-| 2 | Reddit returns 0 results for popular topics (kanye west, howie.ai) | High | `scripts/lib/openai_reddit.py` |
-| 3 | Citations too verbose - every sentence has `(per @x, @y, @z; r/sub)` making summary unreadable | Medium | `SKILL.md` |
-| 4 | Kanye summary is wall of text - no bold headers or paragraph breaks like nano banana pro got | Medium | `SKILL.md` |
-
-## Proposed Fixes
-
-### Fix 1: Stats Emoji Format Enforcement
-
-**Root cause:** The agent ignores the emoji tree template even with BAD/GOOD examples. The template uses box-drawing characters (├─ └─) that the agent treats as decorative, not mandatory.
-
-**Approach:** Instead of relying on the agent to copy box-drawing characters, provide the template as a **literal fill-in-the-blank** with placeholders that are impossible to misinterpret.
-
-**File:** `SKILL.md` (stats section, currently around line 190)
-
-**Change:** Replace the current template + BAD/GOOD examples with a single, strict fill-in format:
-
-```
-Copy this EXACTLY, replacing only the {placeholders}:
-
----
-✅ All agents reported back!
-├─ 🟠 Reddit: {N} threads │ {N} upvotes │ {N} comments
-├─ 🔵 X: {N} posts │ {N} likes │ {N} reposts (via Bird/xAI)
-├─ 🌐 Web: {N} pages │ {domain1}, {domain2}, {domain3}
-└─ 🗣️ Top voices: @{handle1} ({N} likes), @{handle2} │ r/{sub1}, r/{sub2}
----
-
-If Reddit returned 0 threads, write: "├─ 🟠 Reddit: 0 threads (no results this cycle)"
-NEVER use plain text dashes (-) or pipe (|). ALWAYS use ├─ └─ │ and the emoji.
-```
-
-Remove the separate BAD/GOOD section (it adds length without helping).
-
-### Fix 2: Reddit Returning 0 Results
-
-**Root cause (from code analysis):**
-
-1. `openai_reddit.py:53-93` - The `REDDIT_SEARCH_PROMPT` instructs the OpenAI model to strip noise words before searching. For "kanye west" this isn't the issue (no noise words), but for "howie.ai" it might strip "ai".
-
-2. `openai_reddit.py:160-166` - The search is restricted to `allowed_domains: ["reddit.com"]` which depends on OpenAI's web_search indexing of Reddit.
-
-3. `last30days.py:474-490` - Post-retrieval filtering: `normalize.filter_by_date_range()` + `score.score_reddit_items()` + `dedupe.dedupe_reddit()` can discard all results if date confidence is low.
-
-4. `score.py:151-157` - Items with no engagement metrics get `-10` penalty, low date confidence gets `-10`. Combined that's `-20` which may push score below threshold.
-
-**Approach (multi-layered):**
-
-**A. Add subreddit-targeted search fallback** in `openai_reddit.py`:
-- When the first search returns < 3 results, add a second search prompt that explicitly queries: `"r/{topic} site:reddit.com"` and `"{topic} subreddit site:reddit.com"`
-- This catches cases where OpenAI's web_search doesn't find the obvious subreddit
-
-**B. Soften post-retrieval scoring** in `score.py`:
-- Change the no-engagement penalty from `-10` to `-3` (missing metrics ≠ irrelevant)
-- Change low date confidence penalty from `-10` to `-5`
-
-**C. Add minimum result guarantee** in `last30days.py`:
-- If scoring filters out ALL results, keep the top 3 by raw relevance regardless of score
-- Log a warning: "All Reddit results scored below threshold, keeping top 3 by relevance"
-
-**Files to change:**
-- `scripts/lib/openai_reddit.py` - Add subreddit fallback search (lines ~160-180)
-- `scripts/lib/score.py` - Soften penalties (lines ~151-157)
-- `scripts/last30days.py` - Add minimum result guarantee (lines ~474-490)
-
-### Fix 3: Citations Too Verbose
-
-**Root cause:** The SKILL.md instruction says "Every insight MUST cite at least one source" with a GOOD example showing `(per @XXX, 15 likes; r/kanye thread with 200 upvotes)` - this is too much detail per citation and the agent over-applies it.
-
-**Approach:** Dial back to "cite 1-2 sources per KEY PATTERN, not per sentence. Use short format."
-
-**File:** `SKILL.md` (citation section, currently around line 158)
-
-**Change the citation rule to:**
-
-```
-CITATION RULE: Cite sources sparingly to prove research is real.
-- In the "What I learned" intro: cite 1-2 top sources total, not every sentence
-- In KEY PATTERNS: cite 1 source per pattern, short format: "per @handle" or "per r/sub"
-- Do NOT include engagement metrics in citations (likes, upvotes) - save those for stats box
-- Do NOT chain multiple citations: "per @x, @y, @z" is too much. Pick the strongest one.
-
-BAD: "His album is set for March 20 (per @cocoabutterbf; Rolling Stone; HotNewHipHop; Complex)."
-GOOD: "His album BULLY is set for March 20 via Gamma, per Rolling Stone."
-```
-
-### Fix 4: Summary Formatting (Wall of Text vs Structured)
-
-**Root cause:** The SKILL.md template for PROMPTING/NEWS/GENERAL shows:
-```
-What I learned:
-[2-4 sentences synthesizing...]
-```
-
-This gives the agent permission to write a dense paragraph. The nano banana pro test got good formatting because PROMPTING queries naturally produce structured patterns. NEWS queries (kanye) produce narratives that become walls of text.
-
-**Approach:** Add explicit structure to the NEWS/GENERAL format with bold topic headers.
-
-**File:** `SKILL.md` (summary display section, around line 158)
-
-**Change the PROMPTING/NEWS/GENERAL template to:**
-
-```
-What I learned:
-
-**{Topic 1}** — [1-2 sentences about this storyline, per source]
-
-**{Topic 2}** — [1-2 sentences, per source]
-
-**{Topic 3}** — [1-2 sentences, per source]
-
-KEY PATTERNS from the research:
-1. [Pattern] — per @handle
-2. [Pattern] — per r/sub
-3. [Pattern] — per source
-```
-
-The bold topic headers force structure. Each topic gets its own paragraph with a line break. No more wall-of-text narratives.
-
-## Acceptance Criteria
-
-- [ ] **Fix 1:** Stats box uses emoji tree format ├─ 🟠 🔵 🌐 └─ 🗣️ in 4/4 test queries
-- [ ] **Fix 2:** "kanye west" returns >0 Reddit threads (r/kanye exists and is active)
-- [ ] **Fix 3:** Summary citations are 1 per insight, short format, no engagement metrics inline
-- [ ] **Fix 4:** NEWS/GENERAL summaries use bold topic headers with paragraph breaks, not wall of text
-
-## Test Plan
-
-Re-run the same 4 queries after fixes:
-1. `/last30days kanye west` — NEWS: should get Reddit results, structured summary, emoji stats
-2. `/last30days howie.ai` — GENERAL: should get Reddit if available, citations not verbose
-3. `/last30days nano banana pro prompting` — PROMPTING: should maintain current good quality, reduce citation density
-4. `/last30days open claw` — GENERAL: should cite @handles in summary, emoji stats
-
-## Files to Modify
-
-| File | Fix | Change |
-|------|-----|--------|
-| `SKILL.md` | 1, 3, 4 | Stats template, citation rules, summary structure |
-| `scripts/lib/openai_reddit.py` | 2 | Add subreddit fallback search |
-| `scripts/lib/score.py` | 2 | Soften scoring penalties |
-| `scripts/last30days.py` | 2 | Add minimum result guarantee |
-
-## References
-
-- Current SKILL.md: `~/.claude/skills/last30days/SKILL.md`
-- Private repo: `/Users/mvanhorn/last30days-skill-private/`
-- Old working SKILL.md: `~/.claude/skills/last30days.backup-v1/SKILL.md`
-- Reddit search module: `scripts/lib/openai_reddit.py:53-93` (prompt), `:160-166` (API call)
-- Scoring module: `scripts/lib/score.py:151-157` (penalties)
-- Main pipeline: `scripts/last30days.py:474-490` (filtering)
diff --git a/docs/plans/2026-02-06-fix-skill-execution-fork-mode-plan.md b/docs/plans/2026-02-06-fix-skill-execution-fork-mode-plan.md
deleted file mode 100644
index aeaf39e..0000000
--- a/docs/plans/2026-02-06-fix-skill-execution-fork-mode-plan.md
+++ /dev/null
@@ -1,177 +0,0 @@
----
-title: "fix: Skill execution broken - fork mode subagent ignores bash and text instructions"
-type: fix
-date: 2026-02-06
----
-
-# fix: Skill Execution Broken in Fork Mode
-
-## Overview
-
-The last30days v2 skill stopped running its Python script and stopped showing acknowledgment text. The agent jumps straight to WebSearch, ignoring all instructions to run bash first or output text. Five attempted fixes all failed.
-
-## Root Cause (Confirmed via Research)
-
-**The old v1 skill worked by accident.** GitHub Issue #17283 documented that `context: fork` and `agent: Explore` were **silently ignored** in older Claude Code versions. The skill ran **inline** in the main conversation — not in a forked subagent. That's why:
-- The user saw acknowledgment text (output inline to conversation)
-- The bash script ran (main model followed instructions inline)
-- Progress was visible (tool calls shown normally)
-
-**Claude Code 2.1+ fixed the bug** and now properly honors `context: fork`. The skill now truly runs in an isolated subagent where:
-- The model decides tool ordering independently
-- Text output instructions are deprioritized vs tool calls
-- "RUN THIS FIRST" instructions are **suggestions**, not commands
-- There is **no mechanism** to force tool ordering in a forked subagent
-
-**This is why every SKILL.md rewrite failed** — the problem isn't the instructions, it's `context: fork` itself.
-
-## Evidence
-
-| Attempt | What we tried | Result |
-|---------|--------------|--------|
-| 1 | "YOUR FIRST ACTION: Run this command. EXECUTE." | Agent ran script sometimes, never showed ack text |
-| 2 | "YOUR FIRST OUTPUT — before ANY tool calls" + progress block | Agent ignored text, jumped to WebSearch |
-| 3 | Moved progress block to very first section | Agent ignored it entirely |
-| 4 | "DO NOT skip this. DO NOT jump to tool calls first." | Agent still jumped to WebSearch |
-| 5 | Embedded echo in bash block + "Do NOT start with WebSearch" | Agent still jumped to WebSearch, never ran bash |
-
-## Proposed Fix
-
-### Option A: Remove `context: fork` (Recommended)
-
-**Remove `context: fork` from frontmatter.** The skill runs inline in the main conversation, exactly like the old v1 skill accidentally did.
-
-**Why this works:**
-- Inline execution follows instructions sequentially
-- Text output appears directly to the user
-- Bash commands run when instructed
-- This is how the "working" v1 skill actually operated
-
-**File:** `SKILL.md` frontmatter
-
-**Change from:**
-```yaml
----
-name: last30days
-description: Research a topic from the last 30 days on Reddit + X + Web...
-argument-hint: '"[topic] for [tool]" or "[topic]"'
-context: fork
-allowed-tools: Bash, Read, Write, AskUserQuestion, WebSearch
----
-```
-
-**Change to:**
-```yaml
----
-name: last30days
-description: Research a topic from the last 30 days on Reddit + X + Web...
-argument-hint: '"[topic] for [tool]" or "[topic]"'
-allowed-tools: Bash, Read, Write, AskUserQuestion, WebSearch
----
-```
-
-That's it. Remove the one line.
-
-**Then restore the old v1 instruction flow:**
-1. "Parse User Intent" section FIRST (generates acknowledgment text)
-2. "Research Execution" with bash command
-3. "Do WebSearch" while script runs
-4. Synthesize and present
-
-### Option B: Keep `context: fork` + Use `!`command`` Preprocessing
-
-Use shell preprocessing syntax (`!`command``) to run the script **before** the model even sees the prompt:
-
-```markdown
-## Research data (auto-fetched)
-!`python3 ~/.claude/skills/last30days/scripts/last30days.py "$ARGUMENTS" --emit=compact 2>&1`
-```
-
-**Risk:** Not confirmed that `$ARGUMENTS` works in `!`command`` context. More complex. The user still won't see progress text during preprocessing.
-
-### Recommendation: Option A
-
-Remove `context: fork`. It's one line. The old skill worked inline. The v2 skill should too. Option B is a backup if inline mode causes context window issues.
-
-## Implementation
-
-### Step 1: Remove `context: fork` from frontmatter
-
-Single line removal in `SKILL.md`.
-
-### Step 2: Restore v1-style instruction flow
-
-The SKILL.md opening should match the public v1 pattern:
-
-```markdown
-# last30days: Research Any Topic from the Last 30 Days
-
-Research ANY topic across Reddit, X, and the web. Surface what people are actually discussing, recommending, and debating right now.
-
-## CRITICAL: Parse User Intent
-
-Before doing anything, parse the user's input for:
-[... topic/tool/query type parsing ...]
-
-Store these variables:
-- TOPIC = ...
-- TARGET_TOOL = ...
-- QUERY_TYPE = ...
-
----
-
-## Research Execution
-
-**Step 1: Run the research script**
-```bash
-python3 ~/.claude/skills/last30days/scripts/last30days.py "$ARGUMENTS" --emit=compact 2>&1
-```
-
-**Step 2: Do WebSearch** (while script runs)
-[... websearch queries based on QUERY_TYPE ...]
-
-**Step 3: Wait for script to complete**
-[... synthesis instructions ...]
-```
-
-The key structural elements from v1 that need to return:
-1. Descriptive intro paragraph
-2. "Parse User Intent" BEFORE any tool calls
-3. Script execution as a clearly labeled step
-4. WebSearch as step 2 (not step 1)
-
-### Step 3: Keep all v2 improvements
-
-The v2-specific improvements (citation rules, stats template, Reddit fallback, scoring changes) stay. Only the frontmatter and instruction flow change.
-
-### Step 4: Sync and test
-
-Copy to `~/.claude/skills/last30days/SKILL.md`, test in new session.
-
-## Acceptance Criteria
-
-- [ ] `context: fork` removed from SKILL.md frontmatter
-- [ ] Agent outputs acknowledgment text before running tools
-- [ ] Python script actually executes (Reddit + X results appear)
-- [ ] WebSearch supplements, doesn't replace, script results
-- [ ] Stats emoji tree format renders correctly
-- [ ] Citations are sparse (1 per insight, not 3-5)
-
-## Test Plan
-
-Run in a NEW Claude Code session:
-1. `/last30days kanye west` — should see ack text, script runs, Reddit results
-2. `/last30days open claw` — should see ack text, X results via Bird
-
-## Files to Modify
-
-| File | Change |
-|------|--------|
-| `SKILL.md` | Remove `context: fork`, restore v1 instruction flow |
-
-## References
-
-- GitHub Issue #17283: `context: fork` was silently ignored (the bug that made v1 work)
-- Claude Code Skills docs: `!`command`` preprocessing syntax
-- Claude Code Subagents docs: `agent: Explore` uses Haiku, read-only tools
-- Public v1 SKILL.md: `github.com/mvanhorn/last30days-skill`
diff --git a/docs/plans/2026-02-06-test-v1-vs-v2-comparison-plan.md b/docs/plans/2026-02-06-test-v1-vs-v2-comparison-plan.md
deleted file mode 100644
index 52067d2..0000000
--- a/docs/plans/2026-02-06-test-v1-vs-v2-comparison-plan.md
+++ /dev/null
@@ -1,385 +0,0 @@
----
-title: "test: Compare v1 (public) vs v2 (private) last30days output quality"
-type: test
-date: 2026-02-06
----
-
-# test: V1 vs V2 Comparison Test Plan
-
-## Overview
-
-Run the same queries through both the public v1 and private v2 of last30days, compare output quality across 7 dimensions, and determine if v2 is ready to ship as the new public version.
-
-**This plan also includes a full feature audit** identifying everything v1 has that v2 is missing — some of those gaps need fixing before shipping.
-
----
-
-## How to Run the Comparison
-
-### Setup
-
-**V1 (public upstream):** Check out upstream SKILL.md temporarily:
-```bash
-# Save current v2
-cp ~/.claude/skills/last30days/SKILL.md ~/.claude/skills/last30days/SKILL.md.v2
-
-# Install v1 from upstream
-cd /Users/mvanhorn/last30days-skill-private
-git show upstream/main:SKILL.md > ~/.claude/skills/last30days/SKILL.md
-```
-
-Run test queries in a NEW Claude Code session (one session per query to avoid context bleed). Save output.
-
-**V2 (private current):** Restore v2:
-```bash
-cp ~/.claude/skills/last30days/SKILL.md.v2 ~/.claude/skills/last30days/SKILL.md
-```
-
-Run same queries in NEW sessions. Save output.
-
----
-
-## ALL Test Queries
-
-### From README Examples (13 documented use cases)
-
-Every single example from the README, in order:
-
-| # | Query | Type | README Section |
-|---|-------|------|---------------|
-| 1 | `prompting techniques for chatgpt for legal questions` | PROMPTING + TOOL | Example: Legal Prompting |
-| 2 | `best clawdbot use cases` | RECOMMENDATIONS | Example: ClawdBot Use Cases |
-| 3 | `how to best setup clawdbot` | HOW-TO | Example: ClawdBot Setup |
-| 4 | `prompting tips for nano banana pro for ios designs` | PROMPTING + TOOL | Example: iOS App Mockup |
-| 5 | `top claude code skills` | RECOMMENDATIONS | Example: Top Claude Code Skills |
-| 6 | `using ChatGPT to make images of dogs` | GENERAL | Example: Dog as Human |
-| 7 | `research best practices for beautiful remotion animation videos in claude code` | PROMPTING | Example: Remotion Launch Video |
-| 8 | `photorealistic people in nano banana pro` | PROMPTING | Example: Photorealistic Portraits |
-| 9 | `What are the best rap songs lately` | RECOMMENDATIONS | Example: Best Rap Songs |
-| 10 | `what are people saying about DeepSeek R1` | NEWS | Example: DeepSeek R1 |
-| 11 | `best practices for cursor rules files for Cursor` | PROMPTING | Example: Cursor Rules |
-| 12 | `prompt advice for using suno to make killer songs in simple mode` | PROMPTING | Example: Suno AI Music |
-| 13 | `how do I use Codex with Claude Code on same app to make it better` | HOW-TO | Example: Codex + Claude Code |
-
-### From Plan Documents (4 additional battle-tested queries)
-
-| # | Query | Type | Source |
-|---|-------|------|--------|
-| 14 | `kanye west` | NEWS | fix-v2-formatting plan, most-tested query |
-| 15 | `howie.ai` | GENERAL | fix-v2-formatting plan, edge case (domain as topic) |
-| 16 | `open claw` | GENERAL | fix-v2-formatting plan, X-heavy sources |
-| 17 | `nano banana pro prompting` | PROMPTING | fix-v2-formatting plan |
-
-### Follow-up Vision Tests (pick 4 from above, ask a follow-up)
-
-These test the prompt-generation phase specifically:
-
-| Base Query | Follow-up Vision |
-|------------|-----------------|
-| #4 (nano banana pro ios) | "make a mock-up of an app for moms who swim" |
-| #6 (ChatGPT dog images) | "what would my dog look like as a human prompt" |
-| #12 (suno music) | "Rap song about self aware AI that loves Claude Code" |
-| #13 (codex + claude code) | "how do I build a review loop workflow" |
-
----
-
-## FEATURE AUDIT: V1 vs V2
-
-### Section-by-section comparison
-
-I diffed the full v1 (upstream/main) SKILL.md against the current v2. Here's everything.
-
-#### KEPT (in both versions) ✅
-
-| Feature | V1 Location | V2 Location | Notes |
-|---------|------------|------------|-------|
-| Parse User Intent section | Lines 23-48 | Lines 12-38 | Same logic |
-| QUERY_TYPE detection (4 types) | Lines 29-36 | Lines 18-22 | Same types |
-| "Don't ask about tool before research" | Lines 49-51 | Lines 31-33 | Same rule |
-| Store variables block | Lines 53-56 | Lines 35-38 | Same |
-| Research script execution | Lines 81-86 | Lines 59-62 | Same command |
-| WebSearch by QUERY_TYPE | Lines 99-127 | Lines 77-98 | Same queries |
-| "Use user's exact terminology" | Lines 129-133 | Lines 100-101 | V2 shorter but same intent |
-| Judge Agent synthesis | Lines 143-151 | Lines 113-124 | Same logic |
-| Internalize research (ground in actual content) | Lines 159-165 | Lines 128-135 | V2 shorter |
-| RECOMMENDATIONS: extract specific names | Lines 167-177 | Lines 137-145 | Same, v2 removes BAD/GOOD example |
-| Prompt format matching | Lines 193-196 | Lines 149-153 | Same |
-| Summary + Stats + Invitation flow | Lines 200-250 | Lines 157-236 | Same structure, different details |
-| Wait for user's vision | Lines 254-258 | Lines 240-242 | Same |
-| Write ONE perfect prompt | Lines 262-275 | Lines 246-266 | Same structure |
-| Context memory | Lines 298-316 | Lines 278-288 | V2 shorter |
-| Output summary footer | Lines 320-340 | Lines 292-302 | Different format |
-| Depth options (quick/default/deep) | Lines 135-139 | Lines 106-109 | Same |
-
-#### ADDED in V2 (improvements) ✨
-
-| Feature | What it does | V2 Location |
-|---------|-------------|------------|
-| **Query parsing display** | Shows `🔍 **{TOPIC}** · {QUERY_TYPE}` before tools | Lines 40-53 |
-| **Sparse citation rules** | BAD/GOOD examples, "1 per pattern, short format" | Lines 186-193 |
-| **Bold topic headers** | `**{Topic 1}** — [1-2 sentences, per source]` format | Lines 195-208 |
-| **Strict stats template** | "NEVER use plain text dashes", fill-in-blank | Lines 217-230 |
-| **RECOMMENDATIONS source attribution** | Each item MUST have Sources: line with @handles | Lines 178-182 |
-| **Reddit 0 results handling** | Explicit instruction for 0-thread line | Line 229 |
-| **Bird CLI in stats** | "(via Bird/xAI)" notation | Line 223 |
-
-#### ❌ MISSING FROM V2 — Features V1 Has That V2 Dropped
-
-These are the regressions. Some are intentional simplifications, others are real gaps.
-
-**1. Use Cases Block (intro section)**
-- **V1 has:** 4 use case examples right after the intro: Prompting, Recommendations, News, General — with concrete examples
-- **V2 has:** Nothing. Just the intro paragraph.
-- **Impact:** LOW. The query type detection handles this. But it was nice onboarding.
-- **Verdict:** Skip — not needed for execution quality.
-
-**2. Setup Check Section (API key guidance)**
-- **V1 has:** Full section explaining 3 modes (Full/Partial/Web-Only), first-time setup bash script, "API keys are OPTIONAL" messaging
-- **V2 has:** Nothing. Script auto-detects.
-- **Impact:** LOW for experienced users. HIGH for first-time users who don't have keys.
-- **Verdict:** Skip for now — script handles auto-detection. Consider adding back for public release.
-
-**3. Anti-Pattern Examples (synthesis quality guard)**
-- **V1 has:** Explicit anti-pattern block: "If user asks about 'clawdbot skills' and research returns ClawdBot content (self-hosted AI agent), do NOT synthesize this as 'Claude Code skills' just because both involve 'skills'." Plus BAD/GOOD synthesis examples for RECOMMENDATIONS.
-- **V2 has:** Only "Ground your synthesis in the ACTUAL research content, not your pre-existing knowledge" — no concrete examples.
-- **Impact:** MEDIUM-HIGH. Without concrete anti-patterns, the agent may conflate similar-sounding things.
-- **Verdict:** ⚠️ ADD BACK. At minimum, restore the BAD/GOOD RECOMMENDATIONS example and the "don't conflate" warning.
-
-**4. Self-Check Instruction (pre-display validation)**
-- **V1 has:** "SELF-CHECK before displaying: Re-read your 'What I learned' section. Does it match what the research ACTUALLY says? If the research was about ClawdBot (a self-hosted AI agent), your summary should be about ClawdBot, not Claude Code. If you catch yourself projecting your own knowledge instead of the research, rewrite it."
-- **V2 has:** Nothing.
-- **Impact:** MEDIUM. The self-check forces the model to validate its own output.
-- **Verdict:** ⚠️ ADD BACK. One line costs nothing and catches hallucination.
-
-**5. Quality Checklist for Prompts ⭐**
-- **V1 has:** Explicit checklist before delivering a prompt:
- ```
- ### Quality Checklist:
- - [ ] FORMAT MATCHES RESEARCH - If research said JSON/structured/etc, prompt IS that format
- - [ ] Directly addresses what the user said they want to create
- - [ ] Uses specific patterns/keywords discovered in research
- - [ ] Ready to paste with zero edits (or minimal [PLACEHOLDERS] clearly marked)
- - [ ] Appropriate length and style for TARGET_TOOL
- ```
-- **V2 has:** Only "If research says to use a specific prompt FORMAT, YOU MUST USE THAT FORMAT." — one line instead of 5 checks.
-- **Impact:** HIGH. This is likely what the user noticed as missing — v1 prompts felt more polished because the agent ran a checklist before delivering.
-- **Verdict:** ⚠️ ADD BACK. This is the "that's a great prompt" quality feel.
-
-**6. Prompt Format Anti-Pattern**
-- **V1 has:** "ANTI-PATTERN: Research says 'use JSON prompts with device specs' but you write plain prose. This defeats the entire purpose of the research."
-- **V2 has:** Only the positive instruction (use the format research recommends).
-- **Impact:** MEDIUM. Negative examples ("don't do this") are powerful for LLMs.
-- **Verdict:** ⚠️ ADD BACK. One line.
-
-**7. "IF USER ASKS FOR MORE OPTIONS" Section**
-- **V1 has:** "Only if they ask for alternatives or more prompts, provide 2-3 variations. Don't dump a prompt pack unless requested."
-- **V2 has:** Nothing about handling multi-prompt requests.
-- **Impact:** LOW-MEDIUM. Without it, agent might dump multiple prompts unprompted.
-- **Verdict:** ⚠️ ADD BACK. Two lines.
-
-**8. Web-Only Mode Stats Template + Promo**
-- **V1 has:** Separate stats template for web-only mode with "💡 Want engagement metrics? Add API keys..." promo
-- **V2 has:** Only the full-mode template. If running web-only, agent has no guidance.
-- **Impact:** MEDIUM for users without API keys.
-- **Verdict:** Consider adding back for public release. Lower priority for now.
-
-**9. TARGET_TOOL Question Template**
-- **V1 has:** Explicit AskUserQuestion block with 4 options: [Most relevant tool], Nano Banana Pro, ChatGPT/Claude, Other
-- **V2 has:** "run research first, then ask AFTER showing results" — but no actual question template.
-- **Impact:** LOW-MEDIUM. Agent will still ask, just less structured.
-- **Verdict:** Skip — not critical.
-
-**10. Context Memory: "Don't re-search" Instructions**
-- **V1 has:** Explicit "DO NOT run new WebSearches — you already have the research. Answer from what you learned. Cite the Reddit threads, X posts, and web sources."
-- **V2 has:** Only "Only do new research if the user explicitly asks about a DIFFERENT topic."
-- **Impact:** MEDIUM. Without the explicit ban, agent may re-search on follow-ups, wasting time.
-- **Verdict:** ⚠️ ADD BACK. Three lines.
-
-**11. Output Summary Footer (emoji + engagement counts)**
-- **V1 has:** `📚 Expert in: {TOPIC} for {TARGET_TOOL}` and `📊 Based on: {n} Reddit threads ({sum} upvotes) + {n} X posts ({sum} likes) + {n} web pages`
-- **V2 has:** `Expert in: {TOPIC} for {TARGET_TOOL}` and `Based on: {n} Reddit threads + {n} X posts + {n} web pages` — no emoji, no engagement counts.
-- **Impact:** LOW but noticeable. The emoji + counts make the footer feel more substantial.
-- **Verdict:** ⚠️ ADD BACK. Trivial fix.
-
----
-
-## Priority Fix List (Before Shipping V2 as Public)
-
-Based on the audit, these should be restored in V2 before it replaces V1:
-
-### Must Fix (affects output quality)
-
-| # | Missing Feature | Why | Effort |
-|---|----------------|-----|--------|
-| 1 | **Quality Checklist for prompts** | The "that's a great prompt" feel. V1's 5-point checklist made prompts more polished. | Add 8 lines to SKILL.md |
-| 2 | **Anti-pattern examples** | BAD/GOOD synthesis examples prevent agent from conflating research. | Add 5 lines |
-| 3 | **Self-check instruction** | One-line pre-display validation catches hallucination. | Add 2 lines |
-| 4 | **Context Memory: don't re-search** | Prevents wasting time re-searching on follow-ups. | Add 3 lines |
-
-### Should Fix (polish)
-
-| # | Missing Feature | Why | Effort |
-|---|----------------|-----|--------|
-| 5 | **Prompt format anti-pattern** | Negative example reinforces "match the format". | Add 2 lines |
-| 6 | **"IF USER ASKS FOR MORE OPTIONS"** | Prevents prompt dumping. | Add 2 lines |
-| 7 | **Output footer emoji + engagement counts** | More polished footer. | Edit 3 lines |
-
-### Skip for Now (nice-to-have for public release)
-
-| # | Missing Feature | Why Skip |
-|---|----------------|----------|
-| 8 | Use cases block (intro) | Doesn't affect execution |
-| 9 | Setup Check section | Script auto-detects; add back for public README |
-| 10 | Web-only mode stats + promo | Lower priority, most users have keys |
-| 11 | TARGET_TOOL question template | Agent handles this naturally |
-
----
-
-## Scoring Dimensions (1-5 scale, 7 dimensions)
-
-### 1. Query Parsing Display
-Does the agent show what it understood before starting research?
-
-| Score | Criteria |
-|-------|----------|
-| 1 | No acknowledgment, jumps straight to tools |
-| 2 | Generic "I'll research this" with no specifics |
-| 3 | Mentions the topic but not query type |
-| 4 | Shows topic + query type clearly |
-| 5 | Shows topic + query type + reformulated search terms |
-
-### 2. Source Coverage
-Did it actually use Reddit, X, AND web — or skip sources?
-
-| Score | Criteria |
-|-------|----------|
-| 1 | WebSearch only, script didn't run |
-| 2 | Script ran but returned 0 from one major source |
-| 3 | 2 of 3 sources returned results |
-| 4 | All 3 sources returned results |
-| 5 | All 3 sources + good volume (10+ Reddit, 10+ X, 5+ web) |
-
-### 3. Citation Quality
-Are citations sparse and useful, or verbose and noisy?
-
-| Score | Criteria |
-|-------|----------|
-| 1 | Every sentence has 3+ citations chained |
-| 2 | Most sentences have multiple citations |
-| 3 | 1-2 citations per insight, some over-citing |
-| 4 | 1 citation per pattern, short format |
-| 5 | Sparse citations that prove research is real without cluttering |
-
-### 4. Summary Structure
-Is the "What I learned" section scannable or a wall of text?
-
-| Score | Criteria |
-|-------|----------|
-| 1 | Single paragraph wall of text |
-| 2 | Multiple paragraphs but no structure |
-| 3 | Some bold text but inconsistent |
-| 4 | Bold topic headers with 1-2 sentence explanations |
-| 5 | Clean topic headers + KEY PATTERNS list, easy to scan |
-
-### 5. Stats Box Format
-Does the emoji stats tree render correctly?
-
-| Score | Criteria |
-|-------|----------|
-| 1 | No stats shown |
-| 2 | Stats shown but plain text dashes, no emoji |
-| 3 | Partial emoji format, some lines wrong |
-| 4 | Correct ├─ └─ │ format with emoji, minor issues |
-| 5 | Perfect emoji tree with accurate counts and top voices |
-
-### 6. Research Grounding
-Does the synthesis reflect the ACTUAL research, or generic pre-training knowledge?
-
-| Score | Criteria |
-|-------|----------|
-| 1 | Entirely generic knowledge, no research content |
-| 2 | Mentions some research but mostly generic |
-| 3 | Mix of research and generic, some conflation |
-| 4 | Clearly grounded in research, minor generic leakage |
-| 5 | Every insight traceable to a specific source from the research |
-
-### 7. Prompt Quality (follow-up tests only)
-When user shares vision, is the generated prompt good?
-
-| Score | Criteria |
-|-------|----------|
-| 1 | Generic prompt that ignores research |
-| 2 | Mentions research topics but generic structure |
-| 3 | Uses some research insights, decent prompt |
-| 4 | Tailored to research, correct format for target tool |
-| 5 | Uses research-recommended format, specific techniques, ready to paste, "that's a great prompt" feel |
-
----
-
-## Comparison Scorecard Template
-
-```
-Query: [query text]
-Version: V1 / V2
-Date: YYYY-MM-DD
-
-| Dimension | Score (1-5) | Notes |
-|---------------------|-------------|-------|
-| Query Parsing | | |
-| Source Coverage | | |
-| Citation Quality | | |
-| Summary Structure | | |
-| Stats Box Format | | |
-| Research Grounding | | |
-| Prompt Quality | | (follow-up tests only) |
-| **TOTAL** | **/35** | |
-
-Script output:
-- Reddit: ___ threads / ___ upvotes / ___ comments
-- X: ___ posts / ___ likes / ___ reposts
-- Web: ___ pages
-
-Observations:
-[Free text notes]
-```
-
----
-
-## Execution Plan
-
-### Phase 1: Fix the gaps first
-Apply the 7 "Must Fix" + "Should Fix" items from the audit to V2 SKILL.md. This takes ~20 minutes since it's all small text additions.
-
-### Phase 2: Smoke test (4 queries)
-Run queries #14 (kanye west), #2 (best clawdbot use cases), #8 (photorealistic nano banana pro), #10 (DeepSeek R1) on V2 only. Verify the fixes work.
-
-### Phase 3: Full comparison (all 17 queries)
-Run all 17 queries on both V1 and V2. Fill scorecards.
-
-### Phase 4: Follow-up vision tests (4 queries)
-Run the 4 follow-up vision tests. Compare prompt quality — this is where the quality checklist fix matters most.
-
-### Phase 5: Analysis
-- Sum scores per version across all queries
-- Identify any dimension where v1 consistently beats v2
-- Decision: ship v2, or fix more gaps first
-
-## Acceptance Criteria
-
-- [x] Feature audit complete (this document)
-- [x] Must-fix gaps restored in V2 SKILL.md
-- [ ] All 17 queries run on V2
-- [ ] At least 4 queries run on V1 for comparison
-- [ ] 4 follow-up vision tests completed
-- [ ] Scorecards filled for each
-- [ ] Total score comparison documented
-- [ ] Any V1 > V2 regressions identified with fix plan
-- [ ] Go/no-go decision on shipping v2 as public
-
-## Files
-
-| File | Purpose |
-|------|---------|
-| `docs/plans/2026-02-06-test-v1-vs-v2-comparison-plan.md` | This plan |
-| `SKILL.md` | Apply Must Fix + Should Fix items |
-| `docs/test-results/v1-vs-v2-comparison.md` | Results (to be created) |
diff --git a/docs/plans/2026-02-07-feat-bundle-bird-x-search-plan.md b/docs/plans/2026-02-07-feat-bundle-bird-x-search-plan.md
deleted file mode 100644
index ae5d179..0000000
--- a/docs/plans/2026-02-07-feat-bundle-bird-x-search-plan.md
+++ /dev/null
@@ -1,208 +0,0 @@
----
-title: "feat: Bundle Bird X search client to eliminate npm dependency"
-type: feat
-date: 2026-02-07
----
-
-# feat: Bundle Bird X search client to eliminate npm dependency
-
-## Overview
-
-Replace the `subprocess.run(["bird", "search", ...])` dependency in `bird_x.py` with a vendored Node.js module that calls Twitter's GraphQL search API directly. This eliminates the need for users to `npm install -g @steipete/bird` and protects against the package being removed from npm.
-
-Bird is MIT-licensed. We have the full compiled package archived at `vendor/steipete-bird-0.8.0.tgz` and forked to `github.com/mvanhorn/bird-cli-archive`.
-
-## Problem Statement
-
-@steipete deleted Bird's GitHub repo on 2026-02-07. The npm package still works today, but if he unpublishes it from npm:
-- New users can't `npm install -g @steipete/bird`
-- The `bird` binary disappears from PATH on fresh installs
-- `bird_x.py` returns 0 X results for everyone without an xAI API key
-- /last30days V2's headline feature ("free X search") stops working for new users
-
-## Proposed Solution
-
-**Vendor Bird's search-only subset as a Node.js module inside /last30days, called from Python via `subprocess.run(["node", ...])`.**
-
-This is the minimal-change approach:
-- Keep Python as the orchestrator (bird_x.py stays mostly the same)
-- Replace `subprocess.run(["bird", "search", ...])` with `subprocess.run(["node", "vendor/bird-search.mjs", ...])`
-- Extract only the search-related code from Bird (not posting, bookmarks, lists, etc.)
-- Cookie auth stays the same (environment variables or browser extraction)
-
-### Why not rewrite in pure Python?
-
-Bird's search client uses Twitter's internal GraphQL API with:
-- Rotating QueryIDs (hardcoded + runtime refresh from x.com)
-- Specific request header construction (bearer token, csrf, client UUIDs)
-- Cursor-based pagination with Twitter-specific response parsing
-- The `@steipete/sweet-cookie` dependency for browser cookie extraction
-
-Porting all of this to Python is ~1000 lines of fragile reverse-engineering. Vendoring the working JS code is faster, safer, and easier to maintain since the archive includes source maps for debugging.
-
-## Technical Approach
-
-### What we need from Bird
-
-Only 8 files from `dist/lib/` (out of 30+):
-
-1. `twitter-client-base.js` - HTTP client, auth headers, rate limiting
-2. `twitter-client-search.js` - Search mixin (the core feature)
-3. `twitter-client-utils.js` - Tweet parsing, cursor extraction
-4. `twitter-client-constants.js` - API endpoints, QueryIDs
-5. `twitter-client-types.js` - TypeScript type stubs
-6. `cookies.js` - Cookie resolution (env vars, browser extraction)
-7. `runtime-query-ids.js` - QueryID refresh from x.com
-8. `paginate-cursor.js` - Cursor pagination helper
-
-Plus:
-- `features.json` - GraphQL feature flags
-- `query-ids.json` - Hardcoded QueryID fallbacks
-
-### What we DON'T need
-
-Posting, bookmarks, lists, timelines, engagement, follow, media, news, user lookup, user tweets - all the non-search mixins. This cuts the vendored code roughly in half.
-
-### Architecture
-
-```
-scripts/
- lib/
- bird_x.py # MODIFIED - calls node instead of bird binary
- vendor/
- bird-search/
- bird-search.mjs # NEW - thin CLI wrapper, ~40 lines
- lib/ # VENDORED - subset of Bird's dist/lib/
- twitter-client-base.js
- twitter-client-search.js
- twitter-client-utils.js
- twitter-client-constants.js
- twitter-client-types.js
- cookies.js
- runtime-query-ids.js
- paginate-cursor.js
- features.json
- query-ids.json
- node_modules/ # VENDORED - sweet-cookie only
- @steipete/
- sweet-cookie/
- package.json # Minimal, points to bird-search.mjs
- LICENSE # Bird's MIT license (required by MIT terms)
-```
-
-### Implementation
-
-#### 1. Create `bird-search.mjs` wrapper (~40 lines)
-
-A minimal Node.js script that:
-- Accepts: `node bird-search.mjs --count --json`
-- Creates a TwitterClient with search mixin only
-- Resolves cookies (env vars first, then browser extraction)
-- Calls `client.search(query, count)`
-- Outputs JSON to stdout
-- Exits with code 0 on success, 1 on error
-
-This replaces the full `bird` CLI binary. Same interface, fraction of the code.
-
-#### 2. Modify `bird_x.py` - change subprocess target
-
-```python
-# BEFORE (current)
-cmd = ["bird", "search", query, "-n", str(count), "--json"]
-
-# AFTER (vendored)
-bird_search = Path(__file__).parent / "vendor" / "bird-search" / "bird-search.mjs"
-cmd = ["node", bird_search, query, "--count", str(count), "--json"]
-```
-
-Same subprocess pattern. Same JSON output format. Minimal diff.
-
-#### 3. Update auth check functions
-
-```python
-# BEFORE
-def is_bird_installed() -> bool:
- return shutil.which("bird") is not None
-
-# AFTER
-def is_bird_installed() -> bool:
- bird_search = Path(__file__).parent / "vendor" / "bird-search" / "bird-search.mjs"
- return bird_search.exists() and shutil.which("node") is not None
-```
-
-`is_bird_authenticated()` changes from `bird whoami` to a quick Node.js cookie check or environment variable check.
-
-`install_bird()` becomes a no-op (already vendored) or removes itself entirely.
-
-#### 4. Vendor sweet-cookie
-
-`@steipete/sweet-cookie` is the only runtime dependency. It handles browser cookie extraction on macOS/Linux. Options:
-
-**Option A (recommended):** Vendor sweet-cookie into `vendor/bird-search/node_modules/`. It's small (one file). This makes the skill fully self-contained with zero npm installs.
-
-**Option B:** Fall back to environment variables only (no browser cookie extraction). Users would need to manually set `AUTH_TOKEN` and `CT0` env vars. Simpler but worse UX.
-
-Recommend Option A - vendor it.
-
-#### 5. Update user-facing docs
-
-- `README.md` - Remove "Install Bird CLI" section, replace with "Requires Node.js 22+"
-- `SKILL.md` - Remove Bird CLI installation instructions
-- Keep the fallback chain: vendored Bird search -> xAI API key -> web-only
-
-## Acceptance Criteria
-
-- [ ] `bird_x.py` calls vendored Node.js module instead of `bird` binary
-- [ ] `search_x()` returns identical JSON format (no downstream changes needed)
-- [ ] `search_handles()` works with vendored module
-- [ ] Cookie auth works via environment variables (`AUTH_TOKEN`, `CT0`)
-- [ ] Cookie auth works via browser extraction (sweet-cookie)
-- [ ] `is_bird_installed()` checks for vendored module + Node.js
-- [ ] `install_bird()` removed or returns success immediately
-- [ ] No `npm install -g @steipete/bird` required anywhere
-- [ ] Bird's MIT LICENSE included in vendor directory
-- [ ] README updated to remove Bird CLI install steps
-- [ ] SKILL.md updated to remove Bird CLI references
-- [ ] Works on macOS (primary) and Linux
-- [ ] Fallback to xAI API key still works if vendored search fails
-
-## Files Changed
-
-| File | Action | Description |
-|------|--------|-------------|
-| `scripts/lib/bird_x.py` | MODIFY | Replace `["bird", ...]` subprocess calls with `["node", "vendor/bird-search/bird-search.mjs", ...]` |
-| `scripts/lib/vendor/bird-search/bird-search.mjs` | CREATE | Thin Node.js wrapper that imports Bird's search client and outputs JSON |
-| `scripts/lib/vendor/bird-search/lib/*.js` | VENDOR | 8 files from Bird's dist/lib/ (search subset only) |
-| `scripts/lib/vendor/bird-search/lib/features.json` | VENDOR | GraphQL feature flags |
-| `scripts/lib/vendor/bird-search/lib/query-ids.json` | VENDOR | Hardcoded QueryID fallbacks |
-| `scripts/lib/vendor/bird-search/node_modules/` | VENDOR | sweet-cookie package |
-| `scripts/lib/vendor/bird-search/package.json` | CREATE | Minimal package.json for module resolution |
-| `scripts/lib/vendor/bird-search/LICENSE` | COPY | Bird's MIT license |
-| `README.md` | MODIFY | Remove Bird CLI install section, add Node.js 22+ requirement |
-| `SKILL.md` | MODIFY | Remove Bird CLI references |
-
-## Dependencies & Risks
-
-**Node.js 22+ required** - Users who had Bird CLI already have Node.js. This is not a new dependency, just a version requirement. Claude Code environments typically have Node.js.
-
-**Twitter API changes** - The GraphQL QueryIDs may rotate. Bird includes a runtime refresh mechanism (`runtime-query-ids.js`) that fetches new IDs from x.com. This is vendored and will continue working.
-
-**sweet-cookie platform support** - Browser cookie extraction only works on macOS (Safari, Chrome, Firefox) and Linux (Chrome, Firefox). Windows users need manual env vars. This matches Bird CLI's existing behavior.
-
-**Legal** - Bird is MIT licensed. MIT requires including the license notice in copies. We include `LICENSE` in the vendor directory. Using Twitter's internal API is the same legal gray area Bird always operated in - user accepted this when they used Bird.
-
-## What This Does NOT Change
-
-- Python remains the orchestrator - bird_x.py still does query construction, retry logic, response parsing
-- The fallback chain stays: vendored search -> xAI API -> web-only
-- Cookie auth mechanism is identical (env vars or browser extraction)
-- JSON output format is identical - no changes needed in score.py or format.py
-- xai_x.py is completely untouched
-
-## References
-
-- Bird CLI archive: `github.com/mvanhorn/bird-cli-archive`
-- Local vendor tarball: `vendor/steipete-bird-0.8.0.tgz`
-- Bird search implementation: `bird-cli-archive/dist/lib/twitter-client-search.js`
-- Current bird_x.py: `scripts/lib/bird_x.py`
-- Fallback chain: `scripts/lib/env.py:get_x_source()`
diff --git a/docs/plans/2026-02-07-feat-smart-supplemental-search-plan.md b/docs/plans/2026-02-07-feat-smart-supplemental-search-plan.md
deleted file mode 100644
index b49117b..0000000
--- a/docs/plans/2026-02-07-feat-smart-supplemental-search-plan.md
+++ /dev/null
@@ -1,263 +0,0 @@
----
-title: "feat: Smart Supplemental Search — Entity-Aware Secondary Passes for Reddit & X"
-type: feat
-date: 2026-02-07
----
-
-# feat: Smart Supplemental Search — Entity-Aware Secondary Passes for Reddit & X
-
-## Overview
-
-Add an intelligent "discover → drill down" second pass to both Reddit and X searches. After the initial broad search, extract entities (handles, subreddits, hashtags) from results and run targeted secondary searches to surface content the broad pass missed. This supplements — does not replace — the existing search pipeline.
-
-## Problem Statement / Motivation
-
-The current search pipeline does a single broad pass per source (with Reddit having 2 fallbacks for low-result scenarios). This works well for general topics, but misses content that lives in:
-
-- **Niche subreddits** that don't rank for generic queries (e.g., searching "Nano Banana Pro" finds r/generativeAI but misses r/nanobanana, r/localLLaMA)
-- **Key accounts on X** that are the authorities on a topic but whose individual posts don't rank for broad keyword search (e.g., @steipete for Open Claw, @karpathy for AI training)
-- **Conversation threads** where the most valuable discussion happens in replies, not the original tweet
-
-The product works great today. This is about squeezing 20-30% more high-quality results from sources we already have access to.
-
-## Proposed Solution
-
-### Architecture: Two-Phase Search
-
-```
-CURRENT (Phase 1 — unchanged):
- Broad topic search → Reddit results + X results
- ↓
-NEW (Phase 2 — supplemental):
- Extract entities from Phase 1 results
- ↓ ↓
- [SUBREDDITS] [@HANDLES + #HASHTAGS]
- ↓ ↓
- Targeted Reddit Targeted X searches
- searches per sub per handle/hashtag
- ↓ ↓
- Merge + dedupe with Phase 1 results
-```
-
-Phase 2 only runs if Phase 1 returned results (entities need to come from somewhere). Phase 2 results are merged and deduped against Phase 1 — the existing `dedupe.py` handles this.
-
-### Feature 1: Entity Extraction Module (NEW FILE)
-
-**File: `scripts/lib/entity_extract.py`**
-
-A lightweight module that parses Phase 1 results and extracts:
-
-**From X results:**
-- `@handles` — from `author_handle` field + any @mentions in post text
-- `#hashtags` — from post text
-- Rank by frequency: handles that appear 2+ times are "key voices"
-
-**From Reddit results:**
-- `subreddit` names — from the `subreddit` field on each result
-- Cross-referenced subreddits — from enriched comment text mentioning "r/othersub"
-- Rank by frequency: subreddits with 2+ threads are "core communities"
-
-**Output:**
-```python
-{
- "x_handles": ["steipete", "openclaw", "karpathy"], # ranked by frequency
- "x_hashtags": ["#openclaw", "#aitools"],
- "reddit_subreddits": ["generativeAI", "localLLaMA", "nanobanana"],
- "reddit_cross_refs": ["singularity", "MachineLearning"], # mentioned in comments
-}
-```
-
-**Rules:**
-- No hardcoded entities — everything discovered dynamically from Phase 1
-- Cap at top 5 handles, top 3 hashtags, top 5 subreddits
-- Skip generic handles (@elonmusk, @OpenAI) that appear everywhere — maintain a small exclusion list of "too common" handles (< 20 entries)
-- Skip the original topic's "obvious" subreddit if it was already searched
-
-### Feature 2: Supplemental X Search (Bird)
-
-**File: modify `scripts/lib/bird_x.py`**
-
-Add a `search_handles()` function:
-
-```python
-def search_handles(handles: list[str], topic: str, from_date: str, count_per: int = 5) -> list:
- """Search top handles for topic-related content."""
- results = []
- for handle in handles[:5]:
- # Uses Bird's support for X search operators
- query = f"from:{handle} {topic} since:{from_date}"
- cmd = ["bird", "search", query, "-n", str(count_per), "--json"]
- # ... parse results, add to list
- return results
-```
-
-**Why Bird, not xAI:** Bird is free (uses your X login). Running 5 secondary searches via xAI would cost ~$0.025 per run, which adds up. Bird costs nothing.
-
-**xAI alternative for users without Bird:** If Bird is not available but xAI is, use `allowed_x_handles` parameter:
-
-```python
-# xAI supports filtering to specific handles (max 10)
-tools = [{
- "type": "x_search",
- "x_handles": {"allowed_x_handles": top_handles[:10]}
-}]
-```
-
-### Feature 3: Supplemental Reddit Search
-
-**File: modify `scripts/lib/openai_reddit.py`**
-
-Add a `search_subreddits()` function:
-
-```python
-def search_subreddits(subreddits: list[str], topic: str, ...) -> list:
- """Search discovered subreddits for topic-related content."""
- # Build multi-subreddit query for the OpenAI web_search prompt
- sub_query = " OR ".join(f"r/{sub}" for sub in subreddits[:5])
- prompt = f"Search Reddit for threads about {topic} in these communities: {sub_query}"
- # ... single OpenAI API call, same pattern as existing search
-```
-
-**Alternative approach — Reddit JSON API (free, no API key):**
-
-```python
-def search_subreddit_json(subreddit: str, topic: str) -> list:
- """Search a specific subreddit via Reddit's free JSON endpoint."""
- url = f"https://www.reddit.com/r/{subreddit}/search/.json"
- params = {"q": topic, "restrict_sr": "on", "sort": "new", "limit": 10}
- # ... parse JSON response
-```
-
-This is free, requires no API key, and gives us structured data. The `.json` endpoint trick is well-documented and widely used.
-
-### Feature 4: Orchestration Changes
-
-**File: modify `scripts/last30days.py`**
-
-After Phase 1 completes and enrichment is done, run Phase 2:
-
-```python
-# Phase 1 (existing — unchanged)
-reddit_items, x_items = run_parallel_search(...)
-
-# Phase 2 (new — supplemental)
-if reddit_items or x_items:
- entities = entity_extract.extract(reddit_items, x_items)
-
- supplemental_reddit = []
- supplemental_x = []
-
- # Run supplemental searches in parallel
- with ThreadPoolExecutor(max_workers=2) as executor:
- if entities["reddit_subreddits"]:
- reddit_future = executor.submit(
- openai_reddit.search_subreddits,
- entities["reddit_subreddits"], topic, ...
- )
- if entities["x_handles"] and bird_available:
- x_future = executor.submit(
- bird_x.search_handles,
- entities["x_handles"], topic, from_date, ...
- )
-
- # Merge with Phase 1
- all_reddit = reddit_items + supplemental_reddit
- all_x = x_items + supplemental_x
-
- # Dedupe handles the rest
-```
-
-**Depth-dependent behavior:**
-| Depth | Phase 2 behavior |
-|---|---|
-| `--quick` | Skip Phase 2 entirely (speed matters) |
-| default | Run Phase 2 with caps: 3 handles, 3 subreddits, 3 results each |
-| `--deep` | Run Phase 2 with caps: 5 handles, 5 subreddits, 5 results each |
-
-### Feature 5: Thread Expansion for High-Engagement Posts (stretch goal)
-
-**File: modify `scripts/lib/bird_x.py`**
-
-For X posts with very high engagement (top 1-2 by likes), expand the conversation thread:
-
-```python
-def expand_thread(tweet_id: str) -> list:
- """Fetch full thread for a high-engagement tweet."""
- cmd = ["bird", "thread", tweet_id, "--json"]
- # ... parse thread, extract key replies
-```
-
-This surfaces the discussion around viral posts — often more valuable than the original tweet. Only trigger for posts with 100+ likes to avoid noise.
-
-## Technical Considerations
-
-### Performance
-- Phase 2 adds 2-5 seconds for Bird (5 subprocess calls) and 3-8 seconds for Reddit subreddit search (1 API call)
-- On `--quick` mode, Phase 2 is skipped entirely — zero performance impact
-- Phase 2 runs AFTER Phase 1, not in parallel with it (needs Phase 1 results for entity extraction)
-
-### Cost
-- Reddit subreddit search: 1 additional OpenAI API call (~$0.005) OR free via `.json` endpoint
-- X handle search via Bird: Free (uses your X login)
-- X handle search via xAI (fallback): 1 additional API call (~$0.005)
-- Thread expansion: Free via Bird
-
-### No New Dependencies
-- Entity extraction is string parsing — no NLP libraries needed
-- Reddit `.json` endpoint uses existing `http.py` transport
-- Bird CLI calls use existing subprocess pattern from `bird_x.py`
-
-### Backward Compatibility
-- Phase 2 is purely additive — all existing behavior unchanged
-- If Phase 2 finds nothing, output is identical to current
-- Deduplication handles any overlap between Phase 1 and Phase 2
-
-## Acceptance Criteria
-
-- [x] Entity extraction module correctly parses handles, hashtags, and subreddits from search results
-- [x] Supplemental X searches via Bird find additional content from key handles
-- [x] Supplemental Reddit searches find content in discovered subreddits
-- [x] Phase 2 results are properly merged and deduped with Phase 1
-- [x] `--quick` mode skips Phase 2 entirely
-- [x] `--deep` mode searches more handles/subreddits with higher per-query limits
-- [x] No performance regression on `--quick` mode
-- [ ] Default mode adds < 10 seconds of latency
-- [x] Works with Bird-only, xAI-only, and both-available configurations
-- [x] Output format unchanged (Phase 2 results look identical to Phase 1 results)
-
-## Implementation Order
-
-1. `scripts/lib/entity_extract.py` — Entity extraction from results (new file)
-2. `scripts/lib/bird_x.py` — Add `search_handles()` function
-3. `scripts/lib/openai_reddit.py` — Add `search_subreddits()` function
-4. `scripts/last30days.py` — Orchestration: Phase 2 after Phase 1
-5. Test with real queries: "Open Claw", "Nano Banana Pro", "kanye west"
-6. (Stretch) Thread expansion for high-engagement posts
-
-## Research Sources
-
-### Reddit Search Techniques
-- [reddit-research-mcp](https://github.com/king-of-the-grackles/reddit-research-mcp) — MCP server with semantic subreddit discovery via 20K+ pre-indexed communities
-- [anvaka/sayit](https://github.com/anvaka/sayit) — Subreddit similarity graph via collaborative filtering (Jaccard similarity on user overlap)
-- [YARS](https://github.com/datavorous/yars) — No-API-key Reddit scraper using `.json` endpoint trick
-- Reddit's free JSON search endpoint: `reddit.com/r/{sub}/search/.json?q=QUERY&restrict_sr=on` — no auth needed
-- Reddit search operators: `subreddit:`, `title:`, `selftext:`, `author:`, `flair:` (Lucene-style)
-
-### X/Twitter Search Techniques
-- [igorbrigadir/twitter-advanced-search](https://github.com/igorbrigadir/twitter-advanced-search) — Canonical reference of all X search operators
-- Bird CLI supports all X operators: `from:`, `to:`, `conversation_id:`, `min_retweets:`, `#hashtag`, `list:`
-- xAI x_search `allowed_x_handles` parameter — filter to max 10 specific handles
-- xAI x_search semantic search — finds conceptually related content without exact keyword matches
-- [Bellingcat OSINT Toolkit](https://bellingcat.gitbook.io/toolkit) — Multi-pass handle discovery methodology
-
-### Key Insight
-The biggest gap in the current implementation is that **neither X nor Reddit search does entity extraction from initial results to inform follow-up queries.** Every tool/project researched that achieves better-than-basic results does some form of "discover entities → search entities" two-pass strategy.
-
-## What We're NOT Doing
-
-- **Not adding new API dependencies** — everything uses existing OpenAI, xAI, or Bird infrastructure
-- **Not adding NLP/ML libraries** — entity extraction is simple string parsing
-- **Not changing the output format** — Phase 2 results merge seamlessly
-- **Not hardcoding any entities** — all discovery is dynamic from search results
-- **Not slowing down `--quick` mode** — Phase 2 is skipped entirely
-- **Not replacing the current search** — Phase 2 supplements Phase 1
diff --git a/docs/plans/2026-02-07-fix-x-search-query-construction-plan.md b/docs/plans/2026-02-07-fix-x-search-query-construction-plan.md
deleted file mode 100644
index be67e2b..0000000
--- a/docs/plans/2026-02-07-fix-x-search-query-construction-plan.md
+++ /dev/null
@@ -1,147 +0,0 @@
----
-title: "fix: X search query too restrictive, returns 0 results on popular topics"
-type: fix
-date: 2026-02-07
----
-
-# fix: X search query too restrictive, returns 0 results on popular topics
-
-## Problem
-
-`/last30days vibe motion best prompt techniques` returned **0 X posts** despite Vibe Motion being actively discussed on X (screenshots show posts from @Godid242, @KamilStanuch, @ColdStartTheory, @higgsfield_ai).
-
-Root cause: `_extract_core_subject()` in `bird_x.py` produces overly specific queries. Bird/X search uses **literal keyword AND matching** — ALL words must appear in a tweet. The function kept 4 keywords (`vibe motion prompt techniques`) when only 2 (`vibe motion`) were needed.
-
-## Three Bugs Found
-
-### Bug 1: Multi-word noise phrases never match
-
-```python
-# Current code (bird_x.py:24-38)
-noise = ['best', ..., 'what are', 'what is', 'how to', 'tips for', ...]
-words = topic.lower().split() # splits into individual words
-result = [w for w in words if w not in noise] # compares "what" against "what are" → no match!
-```
-
-`"what are people saying about DeepSeek R1"` → keeps `"what are people saying"` → **LOSES THE ENTIRE TOPIC**.
-
-The multi-word entries (`"what are"`, `"how to"`, `"tips for"`, `"use cases"`) are dead code. They never match because `.split()` creates individual words but the noise list has multi-word strings.
-
-### Bug 2: Missing meta/research words
-
-The noise list has `"prompting"` but not `"prompt"`, `"prompts"`, `"techniques"`, `"tips"`, `"tricks"`, `"methods"`, etc.
-
-- `"vibe motion best prompt techniques"` → `"vibe motion prompt techniques"` (4 words, should be 2)
-- `"nano banana pro prompts for gemini"` → `"nano banana pro prompts"` (4 words, should be 3)
-
-### Bug 3: No retry on 0 results
-
-Reddit has multi-stage retry: full query → simplified core → subreddit fallback. X search runs once and accepts whatever comes back, even 0 results.
-
-## Proposed Fix
-
-All changes in `scripts/lib/bird_x.py`.
-
-### Step 1: Fix `_extract_core_subject()` — strip phrases first, then words
-
-```python
-def _extract_core_subject(topic: str) -> str:
- """Extract core subject from verbose query for X search."""
- text = topic.lower()
-
- # Phase 1: Strip multi-word prefixes/suffixes (order matters - longest first)
- prefixes = ['what are the best', 'what is the best', 'what are', 'what is',
- 'how to', 'how do i', 'tips for', 'best practices for']
- for p in prefixes:
- if text.startswith(p):
- text = text[len(p):].strip()
- break
-
- suffixes = ['best practices', 'use cases', 'prompt techniques',
- 'prompting techniques']
- for s in suffixes:
- if text.endswith(s):
- text = text[:-len(s)].strip()
- break
-
- # Phase 2: Split and filter individual noise words
- noise = {'best', 'top', 'practices', 'features', 'killer', 'guide',
- 'tutorial', 'recommendations', 'advice', 'prompting', 'prompt',
- 'prompts', 'techniques', 'tips', 'tricks', 'methods',
- 'strategies', 'review', 'reviews', 'uses', 'usecases',
- 'examples', 'using', 'for', 'with', 'the', 'of', 'in', 'on',
- 'about', 'latest', 'new', 'news', 'update', 'updates',
- 'good', 'great', 'awesome', 'and', 'or', 'a', 'an', 'is',
- 'are', 'was', 'were', 'people', 'saying', 'think', 'said'}
- words = text.split()
- result = [w for w in words if w not in noise]
-
- return ' '.join(result[:3]) or topic # Max 3 words (was 4)
-```
-
-**Expected results after fix:**
-
-| Input | Before | After |
-|-------|--------|-------|
-| `vibe motion best prompt techniques` | `vibe motion prompt techniques` | `vibe motion` |
-| `what are people saying about DeepSeek R1` | `what are people saying` | `deepseek r1` |
-| `nano banana pro prompts for gemini` | `nano banana pro prompts` | `nano banana pro` |
-| `open claw best uses` | `open claw uses` | `open claw` |
-| `best claude code skills` | `claude code skills` | `claude code skills` |
-| `kanye west` | `kanye west` | `kanye west` |
-
-### Step 2: Add retry with simplified query on 0 results
-
-In `search_x()`, after the initial search, if 0 items returned, retry with just the first 2 words of the core subject:
-
-```python
-def search_x(topic, from_date, to_date, depth="default"):
- count = DEPTH_CONFIG.get(depth, DEPTH_CONFIG["default"])
- core_topic = _extract_core_subject(topic)
- query = f"{core_topic} since:{from_date}"
-
- # ... existing Bird search code ...
-
- items = parse_bird_response(response)
-
- # Retry with fewer keywords if 0 results
- if not items and len(core_topic.split()) > 2:
- shorter = ' '.join(core_topic.split()[:2])
- _log(f"0 results for '{core_topic}', retrying with '{shorter}'")
- query = f"{shorter} since:{from_date}"
- # ... retry Bird search ...
- items = parse_bird_response(retry_response)
-
- return response # or merged response
-```
-
-### Step 3 (optional): Cross-pollinate Reddit entities into X Phase 2
-
-When X Phase 1 returns 0 results but Reddit found threads, extract brand/product names from Reddit thread titles and use them as X search fallback queries. This is lower priority — Steps 1-2 should fix most cases.
-
-## Acceptance Criteria
-
-- [x] `vibe motion best prompt techniques` returns >0 X posts (12 posts found)
-- [x] `what are people saying about DeepSeek R1` produces query containing "deepseek r1" not "what are people saying"
-- [x] No regressions on working queries (`kanye west`, `claude code skills`, `open claw`)
-- [x] Retry fires when initial query returns 0, logged to stderr
-- [x] `openai_reddit.py`'s `_extract_core_subject()` NOT changed (Reddit uses semantic search, not literal matching — the current function works fine there)
-
-## Files to Change
-
-- `scripts/lib/bird_x.py` — `_extract_core_subject()` rewrite + retry logic in `search_x()`
-- `scripts/lib/bird_x.py` — `search_handles()` benefits automatically (calls `_extract_core_subject()`)
-
-## Testing
-
-```bash
-# Mock mode (quick syntax check)
-python3 scripts/last30days.py "vibe motion best prompt techniques" --mock --emit=compact 2>&1
-
-# Live queries to verify X results
-python3 scripts/last30days.py "vibe motion best prompt techniques" --quick --emit=compact 2>&1 | grep -E "X:|posts"
-python3 scripts/last30days.py "what are people saying about DeepSeek R1" --quick --emit=compact 2>&1 | grep -E "X:|posts"
-
-# Regression check
-python3 scripts/last30days.py "kanye west" --quick --emit=compact 2>&1 | grep -E "X:|posts"
-```
diff --git a/docs/plans/2026-02-07-release-v2-public-launch-plan.md b/docs/plans/2026-02-07-release-v2-public-launch-plan.md
deleted file mode 100644
index fbac083..0000000
--- a/docs/plans/2026-02-07-release-v2-public-launch-plan.md
+++ /dev/null
@@ -1,67 +0,0 @@
----
-title: "release: Push V2 to public repo and launch"
-type: release
-date: 2026-02-07
----
-
-# release: Push V2 to public repo and launch
-
-## Overview
-
-Push all V2 changes from the private development repo to the public GitHub repo, then sync the installed skill. 52 commits need to go from `origin/main` (private) to `upstream/main` (public).
-
-## Current State
-
-- **Private repo:** `https://github.com/mvanhorn/last30days-skill-private` - 52 commits ahead of public
-- **Public repo:** `https://github.com/mvanhorn/last30days-skill` - last commit is V1 (`cc892d7`)
-- **Upstream remote:** Already configured in private repo
-- **Untracked files in private:** test logs, draft plans - these should NOT be pushed
-
-## Steps
-
-### Step 1: Clean up private repo
-
-- [ ] Review untracked files - make sure nothing sensitive gets pushed
-- [ ] Decide: commit the untracked docs/plans or leave them out of the push
-
-### Step 2: Push to public
-
-```bash
-cd /Users/mvanhorn/last30days-skill-private
-git push upstream main
-```
-
-This pushes all 52 commits from private `main` to public `main`. Since the private repo was forked from public, history is shared - this is a fast-forward push.
-
-### Step 3: Sync installed skill
-
-```bash
-# Update the installed copy that Claude Code actually uses
-cd ~/.claude/skills/last30days
-git pull origin main
-```
-
-### Step 4: Verify
-
-- [ ] Check `https://github.com/mvanhorn/last30days-skill` shows V2 README with new examples
-- [ ] Check SKILL.md has the new argument-hint and timing disclaimer
-- [ ] Check bird_x.py has the fixed `_extract_core_subject()` and retry logic
-- [ ] Run a quick `/last30days` test to confirm installed skill works
-
-## What Gets Published
-
-Key files going public:
-- `README.md` - V2 features, 3 new examples (Nano Banana Pro, Kanye, Vibe Motion), speed tradeoff note
-- `SKILL.md` - New argument-hint, timing disclaimer, citation rules
-- `scripts/lib/bird_x.py` - Fixed query construction + retry logic
-- `scripts/lib/http.py` - USER_AGENT bumped to 2.0
-- `.claude-plugin/plugin.json` - Marketplace support
-- All Bird CLI integration code
-- Phase 2 supplemental search code
-- Model fallback chain
-
-## Risks
-
-- **Low risk:** This is a fast-forward push, no force push needed
-- **Public API keys:** Already confirmed - no `.env` files or secrets in the repo
-- **Untracked files:** Won't be pushed unless committed first
diff --git a/docs/plans/2026-02-14-feat-codex-skill-compatibility-plan.md b/docs/plans/2026-02-14-feat-codex-skill-compatibility-plan.md
deleted file mode 100644
index 4c57c43..0000000
--- a/docs/plans/2026-02-14-feat-codex-skill-compatibility-plan.md
+++ /dev/null
@@ -1,243 +0,0 @@
----
-title: "feat: Add Codex CLI compatibility"
-type: feat
-date: 2026-02-14
----
-
-# feat: Add Codex CLI Compatibility
-
-## Overview
-
-Make /last30days work as a Codex CLI skill alongside Claude Code. Both platforms use `SKILL.md` with YAML frontmatter — the gap is small but the details matter. Inspired by PR #24 (el-analista) and PR #5 (jblwilliams) on the public repo, applied to the v2.1 codebase.
-
-## Research Findings
-
-### How Codex Skills Work (from [official docs](https://developers.openai.com/codex/skills))
-
-**Format:** Identical to Claude Code — `SKILL.md` with YAML frontmatter + Markdown body.
-
-**Required frontmatter:** Only `name` and `description`. The official skill-creator guidance says "Do not include any other fields in YAML frontmatter." This is stricter than Claude Code which allows `version`, `allowed-tools`, `argument-hint`, etc.
-
-**Discovery:** Codex uses "progressive disclosure" — it reads ONLY the `description` field to decide whether to invoke a skill. The body loads only after triggering. This means the description must be comprehensive about when to use/not use the skill.
-
-**Invocation:** Users invoke with `$skill-name` or `/skills` menu. Codex can also implicitly match based on the description (configurable via `agents/openai.yaml`).
-
-**Installation paths** (scanned in order):
-| Scope | Path |
-|-------|------|
-| Folder | `$CWD/.agents/skills/` |
-| Repo | `$REPO_ROOT/.agents/skills/` |
-| User | `$HOME/.agents/skills/` |
-| Admin | `/etc/codex/skills/` |
-| System | Bundled |
-
-Note: Some docs also mention `~/.codex/skills/` as an alias for `$HOME/.agents/skills/`. Both should be checked.
-
-**`agents/openai.yaml`** (optional sidecar):
-```yaml
-interface:
- display_name: "User-facing name"
- short_description: "Brief description"
- default_prompt: "Surrounding prompt template"
- brand_color: "#hex"
-policy:
- allow_implicit_invocation: true
-dependencies:
- tools:
- - type: "mcp"
- value: "toolName"
-```
-
-**Size guidance:** Keep SKILL.md under 500 lines. Use `references/` directory for detailed docs that load on demand.
-
-**Scripts:** Put executable code in `scripts/`. These can run without being loaded into context — good for our Python research engine.
-
-### What Real Codex Skills Look Like (from [openai/skills catalog](https://github.com/openai/skills))
-
-**openai-docs skill** — Uses MCP tools (`mcp__openaiDeveloperDocs__search_openai_docs`). Has a workflow section, fallback instructions if MCP isn't set up, and quality rules. Clean and focused.
-
-**pdf skill** — Runs scripts (`pdftoppm`, `reportlab`), has file conventions (`tmp/pdfs/`, `output/pdf/`), specifies dependencies. Good example of a skill that shells out to tools like we do.
-
-**skill-creator** — The meta-skill. Emphasizes "the context window is a public good" and treating the LLM as "already very smart — only add information it genuinely lacks." Has 6 creation steps, validation scripts, and naming conventions.
-
-### Key Insight: Frontmatter Compatibility Problem
-
-Claude Code SKILL.md uses:
-```yaml
-name: last30days
-version: "2.1"
-description: Research a topic...
-argument-hint: 'nano banana pro prompts...'
-allowed-tools: Bash, Read, Write, AskUserQuestion, WebSearch
-```
-
-Codex wants only `name` and `description`. The question: does Codex error on unknown frontmatter fields, or ignore them?
-
-**Safe answer:** Codex uses standard YAML parsing and likely ignores unknown keys. But the official guidance says "Do not include any other fields" — meaning it's untested territory and could break in future Codex updates.
-
-**Our approach:** Keep one SKILL.md with Claude-specific fields. If Codex chokes, we add a thin wrapper. This is pragmatic — maintaining two SKILL.md files defeats the purpose of cross-platform compatibility.
-
-### PR #24 Analysis (el-analista)
-
-Good ideas to incorporate:
-- Portable script path resolution (repo → Claude → Codex → agents)
-- `agents/openai.yaml` for Codex discovery
-- Platform-neutral output text ("assistant" instead of "Claude")
-- Sandbox-friendly cache/output dir fallbacks with env var overrides
-- Last-chance retry for Bird search (better query noise stripping)
-
-Not applicable to v2.1:
-- Based on v2.0 codebase — doesn't have YouTube, vendored Bird, or pipeline changes
-- We'll cherry-pick the ideas, not the code
-
-### PR #5 Analysis (jblwilliams)
-
-Not needed:
-- Codex JWT auth — our OpenAI API calls work natively in Codex already
-- SSE response handling — we don't stream responses
-- The 403 enrichment issues they hit are specific to Codex-hosted auth, not our use case
-
-## Proposed Solution
-
-Five changes, all additive — zero impact on existing Claude Code behavior:
-
-### 1. Add `agents/openai.yaml` for Codex discovery
-
-```yaml
-interface:
- display_name: "Last 30 Days"
- short_description: "Research any topic across Reddit, X, YouTube, and the web from the last 30 days. Returns synthesized expert answers and copy-paste prompts."
- default_prompt: "Research this topic from the last 30 days across Reddit, X, YouTube, and web. Synthesize what people are actually saying, upvoting, and sharing right now."
- brand_color: "#FF6B35"
-
-policy:
- allow_implicit_invocation: true
-```
-
-### 2. Make SKILL.md script path portable
-
-Replace the hardcoded Claude path with a lookup that checks multiple install locations:
-
-```bash
-# Find the skill root
-for dir in \
- "." \
- "${CLAUDE_PLUGIN_ROOT:-}" \
- "$HOME/.claude/skills/last30days" \
- "$HOME/.agents/skills/last30days" \
- "$HOME/.codex/skills/last30days"; do
- [ -n "$dir" ] && [ -f "$dir/scripts/last30days.py" ] && SKILL_ROOT="$dir" && break
-done
-
-if [ -z "${SKILL_ROOT:-}" ]; then
- echo "ERROR: Could not find scripts/last30days.py" >&2
- exit 1
-fi
-
-python3 "${SKILL_ROOT}/scripts/last30days.py" "$ARGUMENTS" --emit=compact 2>&1
-```
-
-### 3. Platform-neutral Python output text
-
-Replace "Claude" with "assistant" in LLM-facing output strings only. Human-facing docs (README, etc.) stay as-is.
-
-Files:
-- `scripts/last30days.py` — web search marker text (~3 lines)
-- `scripts/lib/render.py` — docstrings + web-only banner (~4 lines)
-- `scripts/lib/http.py` — User-Agent string (~1 line)
-
-### 4. Sandbox-friendly cache/output dirs
-
-Codex runs sandboxed. Add env var overrides + tempdir fallback (from PR #24):
-
-**`scripts/lib/cache.py`:**
-- Check `LAST30DAYS_CACHE_DIR` env var
-- Catch `PermissionError`, fall back to `tempfile.gettempdir()/last30days/cache`
-
-**`scripts/lib/render.py`:**
-- Check `LAST30DAYS_OUTPUT_DIR` env var
-- Catch `PermissionError`, fall back to `tempfile.gettempdir()/last30days/out`
-
-### 5. README + installation docs
-
-Add a "Codex Compatibility" section to README:
-
-```markdown
-## Codex Compatibility
-
-This skill works in both Claude Code and OpenAI Codex CLI.
-
-**Claude Code:** `git clone` into `~/.claude/skills/last30days`
-**Codex CLI:** `git clone` into `~/.agents/skills/last30days`
-
-Both use the same SKILL.md, same Python engine, same scripts.
-The `agents/openai.yaml` provides Codex-specific discovery metadata.
-```
-
-## What We're NOT Doing
-
-- **Separate SKILL.md for Codex** — One file, both platforms. Claude-specific frontmatter fields (`allowed-tools`, `version`, `argument-hint`) are likely ignored by Codex's YAML parser. If this breaks, we'll address it then.
-- **Codex JWT auth (PR #5)** — Our OpenAI Responses API calls work natively in Codex. No special handling needed.
-- **SSE streaming (PR #5)** — Not our use case.
-- **Codex-specific tool names in SKILL.md** — Both LLMs understand "do a web search" and "run this bash command." The instructions work cross-platform as-is.
-- **Publishing to openai/skills catalog** — Out of scope for now. Users install via git clone.
-
-## Acceptance Criteria
-
-- [x] `agents/openai.yaml` exists with proper `interface` and `policy` sections
-- [x] SKILL.md uses portable path resolution (repo checkout, `~/.claude/skills/`, `~/.agents/skills/`, `~/.codex/skills/`)
-- [x] Python scripts use "assistant" instead of "Claude" in LLM-facing output (~8 string replacements)
-- [x] Cache dir falls back gracefully in sandboxed environments (`LAST30DAYS_CACHE_DIR` env var + `PermissionError` catch)
-- [x] Output dir falls back gracefully in sandboxed environments (`LAST30DAYS_OUTPUT_DIR` env var + `PermissionError` catch)
-- [x] Existing Claude Code behavior is unchanged (zero regressions)
-- [x] README documents Codex installation path (`~/.agents/skills/last30days`)
-- [x] `python3 scripts/last30days.py "test topic" --mock --emit=compact` still works
-
-## Files to Create/Modify
-
-### New Files
-- `agents/openai.yaml` — Codex discovery metadata (~10 lines)
-
-### Modified Files
-- `SKILL.md` — Portable script path resolution (~15 lines changed)
-- `README.md` — Add "Codex Compatibility" section (~15 lines)
-- `scripts/last30days.py` — "Claude" → "assistant" in output strings (~3 lines)
-- `scripts/lib/render.py` — "Claude" → "assistant" + output dir fallback (~15 lines)
-- `scripts/lib/cache.py` — Cache dir env override + fallback (~12 lines)
-- `scripts/lib/http.py` — User-Agent string (~1 line)
-
-### Total scope: ~70 lines changed across 7 files. Small, additive, low risk.
-
-## Dependencies & Risks
-
-| Risk | Likelihood | Mitigation |
-|------|-----------|------------|
-| Codex rejects unknown YAML frontmatter (`allowed-tools`, etc.) | Low-Medium | Standard YAML parsers ignore unknown keys. If it breaks, strip Claude-specific fields and use `agents/openai.yaml` for metadata. |
-| Codex sandbox blocks Node.js (vendored Bird) | Medium | Bird failure already falls back to xAI API. If no xAI key, X search skipped gracefully. |
-| yt-dlp not in Codex sandbox PATH | Medium | YouTube already degrades gracefully — "yt-dlp not installed, skipping YouTube." |
-| Codex sandbox blocks `~/.cache/` writes | Medium | Env var override + tempdir fallback handles this. (Proven approach from PR #24) |
-| Codex changes skill discovery paths | Low | We check 5 paths. Easy to add more. |
-| Codex description matching triggers on wrong queries | Low | Write description with clear "use when" / "do not use when" boundaries per official guidance. |
-
-## References
-
-### Community PRs
-- [PR #24](https://github.com/mvanhorn/last30days-skill/pull/24) (el-analista) — Codex compatibility, portable paths, platform-neutral text
-- [PR #5](https://github.com/mvanhorn/last30days-skill/pull/5) (jblwilliams) — Codex auth support
-
-### Official Codex Docs
-- [Agent Skills](https://developers.openai.com/codex/skills) — SKILL.md format, discovery, installation paths
-- [AGENTS.md Guide](https://developers.openai.com/codex/guides/agents-md/) — Custom instructions, hierarchical loading
-- [Codex CLI Features](https://developers.openai.com/codex/cli/features/) — Overview of CLI capabilities
-- [Configuration Reference](https://developers.openai.com/codex/config-reference/) — config.toml, skill enable/disable
-
-### Examples
-- [openai/skills catalog](https://github.com/openai/skills) — Official curated skills
-- [skill-creator](https://github.com/openai/skills/blob/main/skills/.system/skill-creator/SKILL.md) — Meta-skill for creating skills, best practices
-- [pdf skill](https://github.com/openai/skills/blob/main/skills/.curated/pdf/SKILL.md) — Example of skill that runs external scripts
-- [openai-docs skill](https://github.com/openai/skills/blob/main/skills/.curated/openai-docs/SKILL.md) — Example of MCP-backed skill
-
-### Community Analysis
-- [Skills in OpenAI Codex](https://blog.fsck.com/2025/12/19/codex-skills/) — Jesse Vincent's deep dive on skill internals
-- [Simon Willison on skills adoption](https://simonw.substack.com/p/openai-are-quietly-adopting-skills) — Cross-platform skill format analysis
-- [SkillsMP marketplace](https://skillsmp.com/) — Community marketplace supporting both Claude Code and Codex skills
diff --git a/docs/plans/2026-02-14-feat-merge-openclaw-variant-plan.md b/docs/plans/2026-02-14-feat-merge-openclaw-variant-plan.md
deleted file mode 100644
index 763483c..0000000
--- a/docs/plans/2026-02-14-feat-merge-openclaw-variant-plan.md
+++ /dev/null
@@ -1,224 +0,0 @@
----
-title: "feat: Merge OpenClaw variant into main repo"
-type: feat
-date: 2026-02-14
----
-
-# feat: Merge OpenClaw Variant into Main Repo
-
-## Overview
-
-Consolidate the `last30days-openclaw` project into `last30days-skill-private` so there's one unified Python engine powering both the main skill (Claude Code / Codex) and an "open" variant with watchlist, briefing, history, and built-in web search. The open variant also gets YouTube and Bird CLI — features the main project already has but openclaw was built before they existed.
-
-## Problem Statement / Motivation
-
-Right now there are two separate repos with diverging codebases:
-
-- **`last30days-skill-private`** (main, Feb 14) — YouTube, vendored Bird, better scoring/normalization, Codex compat. But no built-in web search APIs and no persistence layer.
-- **`last30days-openclaw`** (Feb 10) — SQLite store, watchlist, briefings, 3 web search backends (Parallel AI, Brave, OpenRouter). But frozen without YouTube or latest engine improvements.
-
-They share ~80% of the same `scripts/lib/` files but are drifting apart. Maintaining two codebases is unsustainable.
-
-**Goal:** One repo, one Python engine, two SKILL.md variants. Install once, works everywhere.
-
-## Proposed Solution
-
-Use `last30days-skill-private` as the base (it's 4 days newer with better code) and port the OpenClaw-exclusive features in:
-
-### What gets ported from OpenClaw
-
-| File | What it does | Destination |
-|------|-------------|-------------|
-| `scripts/store.py` | SQLite research accumulator (WAL, FTS5, dedup) | `scripts/store.py` |
-| `scripts/watchlist.py` | Topic watchlist CLI (add/remove/list/run) | `scripts/watchlist.py` |
-| `scripts/briefing.py` | Morning briefing generator (daily/weekly) | `scripts/briefing.py` |
-| `scripts/lib/brave_search.py` | Brave Search API (free tier, 2K/mo) | `scripts/lib/brave_search.py` |
-| `scripts/lib/parallel_search.py` | Parallel AI search (LLM-optimized) | `scripts/lib/parallel_search.py` |
-| `scripts/lib/openrouter_search.py` | OpenRouter/Sonar Pro search | `scripts/lib/openrouter_search.py` |
-| `references/research.md` | One-shot research instructions | `variants/open/references/research.md` |
-| `references/watchlist.md` | Watchlist mode instructions | `variants/open/references/watchlist.md` |
-| `references/briefing.md` | Briefing mode instructions | `variants/open/references/briefing.md` |
-| `references/history.md` | History query instructions | `variants/open/references/history.md` |
-
-### What gets upgraded in the ported code
-
-- **`store.py`**: No changes needed — it's self-contained SQLite, works as-is
-- **`watchlist.py`**: Remove OpenClaw cron-specific code, make cron setup generic (launchd on macOS, systemd on Linux, or manual cron)
-- **`briefing.py`**: No changes needed
-- **`scripts/lib/env.py`**: Merge OpenClaw's web search key support (`PARALLEL_API_KEY`, `BRAVE_API_KEY`, `OPENROUTER_API_KEY`) and `has_web_search_keys()` / `get_web_search_source()` functions into main's env.py. Drop the OpenClaw config loader (`~/.openclaw/openclaw.json`) — just use env vars and `~/.config/last30days/.env`
-- **`scripts/last30days.py`**: Add OpenClaw's `_search_web()` function so the script can do web search natively when API keys are available (instead of always delegating to the assistant)
-
-### What gets DROPPED from OpenClaw
-
-| File | Why |
-|------|-----|
-| `scripts/cron_setup.py` | Too OpenClaw-platform-specific. Replace with generic scheduling docs. |
-| OpenClaw config loader in `env.py` | `~/.openclaw/openclaw.json` path is platform-specific. Use env vars instead. |
-| `.clawhubignore` | OpenClaw marketplace artifact, not needed in unified repo |
-
-### New file: Open variant SKILL.md
-
-Create `variants/open/SKILL.md` — the multi-mode skill with command routing:
-
-```
-variants/open/
-├── SKILL.md # Router: watch, briefing, history, or one-shot
-├── references/
-│ ├── research.md # One-shot research instructions
-│ ├── watchlist.md # Watchlist management instructions
-│ ├── briefing.md # Briefing mode instructions
-│ └── history.md # History query instructions
-└── context.md # Agent memory (user preferences, source quality)
-```
-
-The open variant's SKILL.md points to `{baseDir}/scripts/last30days.py` (same engine) but adds the router and reference file system. It also adds the `--store` flag for persistence.
-
-### How YouTube and Bird CLI get added to the open variant
-
-They're already in `scripts/lib/youtube_yt.py` and `scripts/lib/vendor/bird/`. The open variant's SKILL.md just needs to mention YouTube in its description and the research.md reference file gets the YouTube stats line in the output format. No code changes needed — the Python engine already supports all four sources.
-
-## Technical Considerations
-
-### File Structure After Merge
-
-```
-last30days-skill-private/
-├── SKILL.md # Main skill (Claude Code / Codex)
-├── agents/openai.yaml # Codex discovery (existing)
-├── variants/
-│ └── open/
-│ ├── SKILL.md # Open variant with routing
-│ ├── references/
-│ │ ├── research.md
-│ │ ├── watchlist.md
-│ │ ├── briefing.md
-│ │ └── history.md
-│ └── context.md
-├── scripts/
-│ ├── last30days.py # Unified engine (+ native web search)
-│ ├── store.py # SQLite accumulator (from openclaw)
-│ ├── watchlist.py # Watchlist CLI (from openclaw, genericized)
-│ ├── briefing.py # Briefing generator (from openclaw)
-│ └── lib/
-│ ├── ... (existing files)
-│ ├── brave_search.py # NEW from openclaw
-│ ├── parallel_search.py # NEW from openclaw
-│ ├── openrouter_search.py # NEW from openclaw
-│ ├── youtube_yt.py # Existing
-│ └── vendor/bird/ # Existing
-└── README.md # Updated with open variant docs
-```
-
-### Installation for open variant users
-
-```bash
-# Claude Code (main skill — unchanged)
-git clone https://github.com/mvanhorn/last30days-skill.git ~/.claude/skills/last30days
-
-# Open variant (with watchlist, briefings, history)
-git clone https://github.com/mvanhorn/last30days-skill.git ~/.claude/skills/last30days
-# Then in Claude Code settings, point skill to variants/open/SKILL.md
-# OR symlink:
-ln -sf ~/.claude/skills/last30days/variants/open/SKILL.md ~/.claude/skills/last30days-open/SKILL.md
-```
-
-### env.py merge strategy
-
-Main's `env.py` is the base. Add from OpenClaw:
-- Three new key names: `PARALLEL_API_KEY`, `BRAVE_API_KEY`, `OPENROUTER_API_KEY`
-- `has_web_search_keys()` function
-- `get_web_search_source()` function — returns `'parallel'`, `'brave'`, or `'openrouter'`
-- `get_available_sources()` update to include web-search-capable modes
-
-### last30days.py merge strategy
-
-Main's `last30days.py` is the base. Add from OpenClaw:
-- `_search_web()` function that calls the appropriate web search backend
-- `--store` CLI flag to persist findings to SQLite
-- `--diagnose` CLI flag for source availability diagnostics
-- Web results integration into the existing report pipeline (normalize → score → dedupe → render)
-
-Keep main's:
-- YouTube integration
-- Phase 2 supplemental search
-- 3-tier Reddit fallback
-- Better error handling
-- Minimum result guarantee
-
-### Portable path resolution (already done)
-
-The main SKILL.md already has portable path resolution (from Codex compat work):
-```bash
-for dir in "." "${CLAUDE_PLUGIN_ROOT:-}" "$HOME/.claude/skills/last30days" ...
-```
-
-The open variant's SKILL.md uses `{baseDir}` which resolves to the skill root. Both approaches work — we just need to make sure the open variant's references use `{baseDir}` consistently.
-
-## Acceptance Criteria
-
-- [x] `scripts/store.py` ported and working (SQLite creates on first use)
-- [x] `scripts/watchlist.py` ported with generic scheduling (no OpenClaw cron dependency)
-- [x] `scripts/briefing.py` ported and generates daily/weekly briefings
-- [x] `scripts/lib/brave_search.py` ported and functional
-- [x] `scripts/lib/parallel_search.py` ported and functional
-- [x] `scripts/lib/openrouter_search.py` ported and functional
-- [x] `scripts/lib/env.py` updated with web search key support
-- [x] `scripts/last30days.py` has native `_search_web()` + `--store` + `--diagnose`
-- [x] `variants/open/SKILL.md` exists with command routing (watch, briefing, history, research)
-- [x] `variants/open/references/*.md` — all 4 reference files ported
-- [x] Open variant mentions YouTube in description and research output format
-- [x] Open variant uses same portable path resolution as main
-- [x] Main SKILL.md behavior is unchanged (zero regressions)
-- [x] `python3 scripts/last30days.py "test topic" --mock --emit=compact` still works
-- [x] `python3 scripts/last30days.py "test topic" --diagnose` shows source availability
-- [x] README documents open variant installation and usage
-
-## Dependencies & Risks
-
-| Risk | Likelihood | Mitigation |
-|------|-----------|------------|
-| OpenClaw's store.py has import dependencies we don't have | Low | store.py uses only stdlib (sqlite3, json, datetime). Self-contained. |
-| Web search backends need API keys to test | Medium | Each has a `--mock` or dry-run path. Test with real keys if available, mock otherwise. |
-| watchlist.py depends on OpenClaw cron API | High | Known — strip cron_setup.py dependency, replace with generic docs for launchd/systemd/crontab. |
-| Open variant SKILL.md is too long (>500 lines) | Medium | Use reference file pattern (already planned). Router SKILL.md stays under 100 lines. |
-| env.py merge introduces regressions | Low | Main's env.py is well-tested. Additive changes only — new keys, new functions. |
-| Two SKILL.md files = maintenance burden | Low | They serve different purposes. Main is simple one-shot. Open adds routing. Core engine is shared. |
-
-## Files to Create/Modify
-
-### New Files
-- `variants/open/SKILL.md` — Open variant router (~100 lines)
-- `variants/open/references/research.md` — One-shot research instructions (from openclaw, updated with YouTube)
-- `variants/open/references/watchlist.md` — Watchlist management instructions (from openclaw)
-- `variants/open/references/briefing.md` — Briefing mode instructions (from openclaw)
-- `variants/open/references/history.md` — History query instructions (from openclaw)
-- `variants/open/context.md` — Agent memory template
-- `scripts/store.py` — SQLite accumulator (from openclaw, as-is)
-- `scripts/watchlist.py` — Watchlist CLI (from openclaw, genericized)
-- `scripts/briefing.py` — Briefing generator (from openclaw, as-is)
-- `scripts/lib/brave_search.py` — Brave Search API (from openclaw)
-- `scripts/lib/parallel_search.py` — Parallel AI search (from openclaw)
-- `scripts/lib/openrouter_search.py` — OpenRouter/Sonar Pro search (from openclaw)
-
-### Modified Files
-- `scripts/lib/env.py` — Add web search key support (~30 lines added)
-- `scripts/last30days.py` — Add `_search_web()`, `--store`, `--diagnose` (~80 lines added)
-- `README.md` — Add open variant section (~20 lines)
-
-### Total scope: ~12 new files (mostly copied), ~130 lines of new code in existing files.
-
-## References
-
-### Internal
-- OpenClaw plan: `/Users/mvanhorn/last30days-openclaw/docs/plans/2026-02-10-feat-openclaw-last30days-skill-plan.md` (989 lines, comprehensive spec)
-- Codex compat plan: `docs/plans/2026-02-14-feat-codex-skill-compatibility-plan.md` (portable paths, platform-neutral text)
-- OpenClaw source: `/Users/mvanhorn/last30days-openclaw/`
-
-### Key files to port
-- `store.py`: `/Users/mvanhorn/last30days-openclaw/scripts/store.py` (20KB, SQLite with FTS5)
-- `watchlist.py`: `/Users/mvanhorn/last30days-openclaw/scripts/watchlist.py` (10KB)
-- `briefing.py`: `/Users/mvanhorn/last30days-openclaw/scripts/briefing.py` (8KB)
-- `brave_search.py`: `/Users/mvanhorn/last30days-openclaw/scripts/lib/brave_search.py` (6KB)
-- `parallel_search.py`: `/Users/mvanhorn/last30days-openclaw/scripts/lib/parallel_search.py` (4KB)
-- `openrouter_search.py`: `/Users/mvanhorn/last30days-openclaw/scripts/lib/openrouter_search.py` (7KB)
-- `env.py` (openclaw version): `/Users/mvanhorn/last30days-openclaw/scripts/lib/env.py` (9KB — has web search key functions)
diff --git a/docs/plans/2026-02-14-feat-youtube-transcript-search-plan.md b/docs/plans/2026-02-14-feat-youtube-transcript-search-plan.md
deleted file mode 100644
index 0f8fd77..0000000
--- a/docs/plans/2026-02-14-feat-youtube-transcript-search-plan.md
+++ /dev/null
@@ -1,315 +0,0 @@
----
-title: "feat: Add YouTube transcript search as 4th source"
-type: feat
-date: 2026-02-14
----
-
-# feat: Add YouTube Transcript Search
-
-## Overview
-
-Add YouTube as a 4th research source alongside Reddit, X, and Web. Search for recent videos on the user's topic, fetch transcripts from the top results, and feed the transcript text into the synthesis — giving the Judge Agent access to what people are *saying* in video form, not just what they're posting on social media.
-
-**Why this matters:** For many topics (tutorials, product reviews, drama breakdowns), the best content lives on YouTube, not Reddit or X. A 20-minute video review contains 10x the signal of a tweet. The skill currently misses all of it.
-
-## Proposed Solution
-
-Use **yt-dlp** (already installed via Homebrew) for both YouTube search and transcript extraction. No new API keys, no new dependencies. Follows the same "zero friction" philosophy as vendored Bird search.
-
-### Two-step process per research run:
-
-1. **Search**: `yt-dlp "ytsearch{N}:{topic}" --dateafter {30d_ago} --flat-playlist --print` → top videos by view count
-2. **Transcripts**: For top 5 videos, extract auto-generated subtitles via `yt-dlp --write-auto-subs --skip-download`, clean VTT to plaintext in Python
-
-### Why NOT use `summarize` CLI:
-
-- Adds 146MB brew dependency (arm64-only binary)
-- Calls OpenAI API per video ($0.01-0.03 each) — adds cost on top of existing API usage
-- yt-dlp already extracts raw transcripts for free (covers ~95% of videos with auto-captions)
-- Raw transcripts are better for synthesis anyway — the LLM doing synthesis (Claude) should interpret the content itself, not get a pre-summarized version
-
-`summarize` is a great standalone tool, but for integration into a research pipeline where an LLM already synthesizes everything, raw transcripts are the right input.
-
-## Technical Approach
-
-### Architecture
-
-New file: `scripts/lib/youtube_yt.py` (mirrors `bird_x.py` pattern)
-
-```
-yt-dlp search → metadata (title, views, channel, date)
- ↓
- sort by views, take top N
- ↓
-yt-dlp subtitle extraction → raw VTT files
- ↓
- VTT cleanup → plaintext transcripts
- ↓
- truncate to ~500 words per video
- ↓
- normalize → YouTubeItem objects
- ↓
- score, dedupe, render (same pipeline as Reddit/X)
-```
-
-### Implementation Phases
-
-#### Phase 1: Search + Metadata (the fast part)
-
-**New file: `scripts/lib/youtube_yt.py`**
-
-Core search function:
-```python
-def search_youtube(topic: str, from_date: str, to_date: str, depth: str = "default") -> Dict[str, Any]:
- """Search YouTube via yt-dlp. No API key needed.
-
- Returns:
- Dict with 'items' list of video metadata dicts.
- """
- count = DEPTH_CONFIG.get(depth, DEPTH_CONFIG["default"])
- date_filter = from_date.replace("-", "") # YYYYMMDD format
-
- # yt-dlp search with metadata extraction
- cmd = [
- "yt-dlp",
- f"ytsearch{count}:{topic}",
- "--dateafter", date_filter,
- "--flat-playlist",
- "--print", "%(view_count)s\t%(id)s\t%(title)s\t%(channel)s\t%(upload_date)s\t%(like_count)s\t%(comment_count)s",
- ]
- result = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
-
- # Parse tab-separated output, sort by views, return top N
- ...
-```
-
-Depth config (matches existing pattern):
-```python
-DEPTH_CONFIG = {
- "quick": 10, # search 10, transcript top 3
- "default": 20, # search 20, transcript top 5
- "deep": 40, # search 40, transcript top 8
-}
-
-TRANSCRIPT_LIMITS = {
- "quick": 3,
- "default": 5,
- "deep": 8,
-}
-```
-
-**Key detail**: `yt-dlp --flat-playlist` returns exit code 0 with empty stdout when `--dateafter` filters out everything. Check for empty output, not error codes.
-
-#### Phase 2: Transcript Extraction (the slow part)
-
-For top N videos (by view count), fetch transcripts:
-
-```python
-def fetch_transcript(video_id: str, temp_dir: str) -> Optional[str]:
- """Fetch auto-generated transcript for a YouTube video.
-
- Returns:
- Plaintext transcript string, or None if no captions available.
- """
- cmd = [
- "yt-dlp",
- "--write-auto-subs",
- "--sub-lang", "en",
- "--sub-format", "vtt",
- "--skip-download",
- "-o", f"{temp_dir}/%(id)s",
- f"https://www.youtube.com/watch?v={video_id}",
- ]
- subprocess.run(cmd, capture_output=True, text=True, timeout=30)
-
- vtt_path = Path(temp_dir) / f"{video_id}.en.vtt"
- if not vtt_path.exists():
- return None
-
- return _clean_vtt(vtt_path.read_text())
-```
-
-VTT cleanup (~10 lines of Python):
-```python
-def _clean_vtt(vtt_text: str) -> str:
- """Convert VTT subtitle format to clean plaintext."""
- text = re.sub(r'^WEBVTT.*?\n\n', '', vtt_text, flags=re.DOTALL)
- text = re.sub(r'\d{2}:\d{2}:\d{2}\.\d{3} --> \d{2}:\d{2}:\d{2}\.\d{3}.*\n', '', text)
- text = re.sub(r'<[^>]+>', '', text)
- lines = text.strip().split('\n')
- seen = set()
- unique = []
- for line in lines:
- stripped = line.strip()
- if stripped and stripped not in seen:
- seen.add(stripped)
- unique.append(stripped)
- return re.sub(r'\s+', ' ', ' '.join(unique)).strip()
-```
-
-**Parallelization**: Run transcript fetches in parallel using ThreadPoolExecutor (same pattern as Phase 2 supplemental searches for Reddit/X):
-
-```python
-def fetch_transcripts_parallel(video_ids: List[str], max_workers: int = 5) -> Dict[str, Optional[str]]:
- """Fetch transcripts for multiple videos in parallel."""
- with tempfile.TemporaryDirectory() as temp_dir:
- with ThreadPoolExecutor(max_workers=max_workers) as executor:
- futures = {
- executor.submit(fetch_transcript, vid, temp_dir): vid
- for vid in video_ids
- }
- results = {}
- for future in as_completed(futures):
- vid = futures[future]
- results[vid] = future.result()
- return results
-```
-
-#### Phase 3: Integration into Pipeline
-
-**Update `scripts/lib/schema.py`** — add YouTubeItem:
-```python
-@dataclass
-class YouTubeItem:
- id: str # video_id
- title: str
- url: str
- channel_name: str
- date: Optional[str]
- date_confidence: str # always "high" for YouTube
- engagement: Engagement # views, likes, comments
- transcript_snippet: str # first ~500 words of transcript
- relevance: float
- why_relevant: str
- subs: Optional[SubScores] = None
- score: int = 0
-```
-
-Update `Report` to add:
-```python
-youtube: List[YouTubeItem] = field(default_factory=list)
-youtube_error: Optional[str] = None
-```
-
-**Update `scripts/lib/score.py`** — YouTube-specific engagement weights:
-```python
-def compute_youtube_engagement_raw(views, likes, comments):
- """YouTube engagement: views dominate, likes secondary, comments tertiary."""
- return (
- 0.50 * math.log1p(views or 0) +
- 0.35 * math.log1p(likes or 0) +
- 0.15 * math.log1p(comments or 0)
- )
-```
-
-**Update `scripts/last30days.py`** — add YouTube to ThreadPoolExecutor:
-```python
-with ThreadPoolExecutor(max_workers=3) as executor: # was 2
- if run_reddit:
- reddit_future = executor.submit(_search_reddit, ...)
- if run_x:
- x_future = executor.submit(_search_x, ...)
- if run_youtube:
- youtube_future = executor.submit(_search_youtube, ...)
-```
-
-**Update `scripts/lib/render.py`** — YouTube section in compact output:
-```
-### YouTube Videos
-
-**{id}** (score:{score}) {channel_name} ({date}) [{views} views, {likes} likes]
- {title}
- https://www.youtube.com/watch?v={id}
- {transcript_snippet[:200]}...
- *{why_relevant}*
-```
-
-**Update `scripts/lib/env.py`** — YouTube availability detection:
-```python
-def is_ytdlp_available() -> bool:
- return shutil.which("yt-dlp") is not None
-```
-
-No API key needed. YouTube search is available whenever yt-dlp is in PATH.
-
-#### Phase 4: SKILL.md Updates
-
-Stats box adds YouTube line:
-```
-├─ 🎥 YouTube: {N} videos │ {N} views │ {N} transcripts
-```
-
-Citation priority updated:
-```
-1. @handles from X
-2. YouTube creators — "per [Channel Name] on YouTube"
-3. r/subreddits from Reddit
-4. Web sources
-```
-
-Synthesis instructions updated to weight YouTube transcripts highly — a 20-minute video transcript with 500K views is a stronger signal than a tweet with 50 likes.
-
-## Acceptance Criteria
-
-- [x] `yt-dlp` search returns videos matching topic within date range
-- [x] Transcripts extracted for top N videos (auto-generated captions)
-- [x] Videos without captions gracefully skipped (no error)
-- [x] YouTube results appear in compact output with engagement metrics
-- [x] YouTube items scored and ranked alongside Reddit/X items
-- [x] YouTube auto-activates when yt-dlp is available (no --sources flag needed)
-- [x] SKILL.md stats box includes YouTube line
-- [x] Transcript snippets (first ~500 words) included in output for LLM synthesis
-- [ ] Total YouTube search + transcript extraction completes within 30 seconds
-- [x] Works when yt-dlp is not installed (graceful degradation, no crash)
-- [ ] Mock mode works for testing without network
-
-## Dependencies & Risks
-
-**Dependencies:**
-- `yt-dlp` (Homebrew) — already installed, widely available via brew/pip/standalone
-- No API keys needed
-- No new Python packages (just subprocess + regex)
-
-**Risks:**
-| Risk | Likelihood | Mitigation |
-|------|-----------|------------|
-| yt-dlp search is slow (>10s) | Medium | Set 30s timeout, run in parallel with Reddit/X |
-| YouTube blocks yt-dlp | Low | yt-dlp is actively maintained with anti-bot updates. Degrade gracefully. |
-| Videos lack auto-captions | Medium (~5%) | Skip those videos, note in output. Transcript is enrichment, not required. |
-| Transcript extraction adds latency | High | Only fetch top 3-5, run in parallel, use tempdir |
-| yt-dlp not installed for some users | Medium | Auto-detect, skip YouTube with info message, don't error |
-| Linux `--dateafter` date format differs | Low | Use Python to format date, not shell `date -v` |
-
-## Files to Create/Modify
-
-### New Files
-- `scripts/lib/youtube_yt.py` — search, transcript extraction, parsing
-- `tests/test_youtube_yt.py` — unit tests
-- `fixtures/youtube_sample.json` — mock data for tests
-
-### Modified Files
-- `scripts/lib/schema.py` — add YouTubeItem, update Report
-- `scripts/lib/normalize.py` — add normalize_youtube_items()
-- `scripts/lib/score.py` — add YouTube engagement scoring
-- `scripts/lib/dedupe.py` — add YouTube dedup (title + channel Jaccard)
-- `scripts/lib/render.py` — add YouTube section to compact + full report
-- `scripts/lib/env.py` — add yt-dlp availability check, update source detection
-- `scripts/last30days.py` — add _search_youtube(), update run_research(), update arg parser
-- `SKILL.md` — update stats box, citation rules, synthesis instructions
-- `README.md` — document YouTube source, yt-dlp requirement
-
-## Alternative Approaches Considered
-
-**1. YouTube Data API v3** — Rejected. Requires API key + Google Cloud project. Adds friction, counter to "zero config" philosophy. 10K quota/day limit. yt-dlp has no limits.
-
-**2. steipete/summarize for transcripts** — Rejected for MVP. Adds 146MB dependency, requires brew tap, calls OpenAI API per video (adds cost). Raw transcripts via yt-dlp are better input for our synthesis LLM anyway. Could revisit as optional enhancement for captionless videos.
-
-**3. youtube-transcript-api Python package** — Considered. Lightweight, Python-native transcript fetcher. But adds a pip dependency to a project that currently has zero Python deps. yt-dlp is already a brew dependency we can auto-detect.
-
-**4. Skip transcripts, just use metadata** — Rejected. Titles + view counts alone don't give the synthesis LLM enough to work with. Transcripts are what make YouTube a *research* source vs just a link list.
-
-## Cost Impact
-
-**Zero additional API cost.** yt-dlp scrapes YouTube directly. No API keys, no token usage. The only cost is the existing OpenAI/xAI calls for Reddit/X search, which are unchanged.
-
-**Time impact:** Adds ~10-20 seconds to research (search + parallel transcript extraction), running in parallel with Reddit/X so effective wall-clock increase is minimal.
diff --git a/docs/plans/2026-02-15-feat-clawhub-scanner-compliance-plan.md b/docs/plans/2026-02-15-feat-clawhub-scanner-compliance-plan.md
deleted file mode 100644
index b72569e..0000000
--- a/docs/plans/2026-02-15-feat-clawhub-scanner-compliance-plan.md
+++ /dev/null
@@ -1,194 +0,0 @@
----
-title: ClawHub Scanner Compliance for last30days-official
-type: feat
-date: 2026-02-15
----
-
-# ClawHub Scanner Compliance for last30days-official
-
-## Overview
-
-Make the last30days skill pass ClawHub's security scanner (VirusTotal + Code Insight) so it can be published as `last30days-official`. The user's 8 other mvanhorn skills already pass - we replicate their exact pattern.
-
-## Problem Statement
-
-ClawHub requires skills to pass a multi-layer security scan before publication:
-1. Metadata validation (frontmatter fields)
-2. VirusTotal automated scanning
-3. LLM-powered Code Insight analysis (checks if capabilities match documentation)
-4. Credential handling review
-
-The current last30days SKILL.md has basic `metadata.clawdbot` but is missing fields the scanner checks: `emoji`, `user-invocable`, `disable-model-invocation`, `files` declaration. There's no `## Security & Permissions` section (required pattern from passing skills). README has no security/privacy documentation.
-
-## Proposed Solution
-
-Follow the exact pattern from `clawdbot-skill-xai` and `clawdbot-skill-search-x` (both pass the scanner). Three files need changes.
-
-### Fix 1: SKILL.md Frontmatter
-
-Add missing scanner fields to the existing frontmatter:
-
-```yaml
----
-name: last30days
-version: "2.1"
-description: "Research a topic from the last 30 days. Also triggered by 'last30'. Sources: Reddit, X, YouTube, web."
-argument-hint: 'last30 AI video tools, last30 best project management tools'
-allowed-tools: Bash, Read, Write, AskUserQuestion, WebSearch
-homepage: https://github.com/mvanhorn/last30days-skill
-user-invocable: true
-disable-model-invocation: true
-metadata:
- clawdbot:
- emoji: "📰"
- requires:
- env:
- - OPENAI_API_KEY
- bins:
- - node
- - python3
- primaryEnv: OPENAI_API_KEY
- files:
- - "scripts/*"
- homepage: https://github.com/mvanhorn/last30days-skill
- tags:
- - research
- - reddit
- - x
- - youtube
- - trends
- - prompts
----
-```
-
-Key additions:
-- `user-invocable: true` - human must trigger it
-- `disable-model-invocation: true` - agent cannot self-trigger
-- `emoji: "📰"` - required display field
-- `files: ["scripts/*"]` - prevents false "instruction-only but has scripts" flag
-
-### Fix 2: Security & Permissions Section in SKILL.md
-
-Add to the bottom of SKILL.md (matches xai/search-x pattern exactly):
-
-```markdown
-## Security & Permissions
-
-**What this skill does:**
-- Sends search queries to OpenAI's Responses API (`api.openai.com`) for Reddit discovery
-- Sends search queries to Twitter's GraphQL API (via browser cookie auth) or xAI's API (`api.x.ai`) for X search
-- Runs `yt-dlp` locally for YouTube search and transcript extraction (no API key, public data)
-- Optionally sends search queries to Brave Search API, Parallel AI API, or OpenRouter API for web search
-- Fetches public Reddit thread data from `reddit.com` for engagement metrics
-- Stores research findings in local SQLite database (watchlist mode only)
-
-**What this skill does NOT do:**
-- Does not post, like, or modify content on any platform
-- Does not access your Reddit, X, or YouTube accounts
-- Does not share API keys between providers (OpenAI key only goes to api.openai.com, etc.)
-- Does not log, cache, or write API keys to output files
-- Does not send data to any endpoint not listed above
-- Cannot be invoked autonomously by the agent (`disable-model-invocation: true`)
-
-**Bundled scripts:** `scripts/last30days.py` (main research engine), `scripts/lib/` (search, enrichment, rendering modules), `scripts/lib/vendor/bird-search/` (vendored X search client, MIT licensed)
-
-Review scripts before first use to verify behavior.
-```
-
-### Fix 3: Security & Privacy Section in README.md
-
-Add after the "How It Works" section:
-
-```markdown
-## Security & Privacy
-
-### Data that leaves your machine
-
-| Destination | Data Sent | API Key Required |
-|------------|-----------|-----------------|
-| `api.openai.com` | Search query (topic string) | OPENAI_API_KEY |
-| `reddit.com` | Thread URLs for enrichment | None (public JSON) |
-| Twitter GraphQL / `api.x.ai` | Search query | Browser cookies or XAI_API_KEY |
-| `youtube.com` (via yt-dlp) | Search query | None (public search) |
-| `api.search.brave.com` | Search query (optional) | BRAVE_API_KEY |
-| `api.parallel.ai` | Search query (optional) | PARALLEL_API_KEY |
-| `openrouter.ai` | Search query (optional) | OPENROUTER_API_KEY |
-
-Your research topic is included in all outbound API requests. If you research sensitive topics, be aware that query strings are transmitted to the API providers listed above.
-
-### Data stored locally
-
-- API keys: `~/.config/last30days/.env` (chmod 600 recommended)
-- Watchlist database: `~/.local/share/last30days/research.db` (SQLite)
-- Briefings: `~/.local/share/last30days/briefs/`
-
-### API key isolation
-
-Each API key is transmitted only to its respective endpoint. Your OpenAI key is never sent to xAI, Brave, or any other provider. Browser cookies for X are read locally and used only for Twitter GraphQL requests.
-```
-
-### Fix 4: Update .claude-plugin Files
-
-**plugin.json** - bump version, add youtube keyword:
-
-```json
-{
- "name": "last30days",
- "description": "Research any topic from the last 30 days across Reddit, X, YouTube, and the web",
- "version": "2.1.0",
- "author": {"name": "mvanhorn"},
- "repository": "https://github.com/mvanhorn/last30days-skill",
- "license": "MIT",
- "keywords": ["research", "reddit", "twitter", "x", "youtube", "trends", "prompts"],
- "skills": ["./"]
-}
-```
-
-**marketplace.json** - add version, update description:
-
-```json
-{
- "name": "last30days",
- "owner": {"name": "mvanhorn", "url": "https://github.com/mvanhorn"},
- "metadata": {
- "description": "Research any topic from the last 30 days across Reddit, X, YouTube, and the web",
- "version": "2.1.0"
- },
- "plugins": [{"name": "last30days", "source": "."}]
-}
-```
-
-## What We DON'T Need
-
-Based on the audit of your 8 passing skills:
-- **Script-level security manifest headers** - your passing skills (xai, search-x, parallel) do NOT have these. The scanner relies on SKILL.md, not per-file headers.
-- **Separate SECURITY.md file** - not needed; README section + SKILL.md section is sufficient.
-- **Shell injection fixes** - already clean. All subprocess calls use list-form args, no `shell=True` anywhere.
-- **Credential leak fixes** - already clean. All keys loaded from env vars, none hardcoded or logged.
-
-## Acceptance Criteria
-
-- [x] SKILL.md frontmatter has `user-invocable`, `disable-model-invocation`, `emoji`, `files`
-- [x] SKILL.md has `## Security & Permissions` section with "does" and "does NOT do" lists
-- [x] README.md has `## Security & Privacy` section with endpoint table and key isolation docs
-- [x] plugin.json version bumped to 2.1.0, youtube keyword added
-- [x] marketplace.json version and description updated
-- [x] `python3 scripts/last30days.py --diagnose` still works after changes
-- [x] Synced to all installed skill locations
-- [ ] Published to ClawHub as `last30days-official` (when auth is fixed)
-
-## Files to Modify
-
-| File | Change |
-|------|--------|
-| `SKILL.md` | Add frontmatter fields + Security & Permissions section |
-| `README.md` | Add Security & Privacy section |
-| `.claude-plugin/plugin.json` | Bump version, add youtube keyword |
-| `.claude-plugin/marketplace.json` | Add version, update description |
-
-## References
-
-- [13-point ClawHub checklist](https://gist.github.com/adhishthite/0db995ecfe2f23e09d0b2d418491982c)
-- [ClawHub docs](https://docs.openclaw.ai/tools/clawhub)
-- [ClawHub Developer Guide 2026](https://www.digitalapplied.com/blog/clawhub-skills-marketplace-developer-guide-2026)
-- Your passing skills: `clawdbot-skill-xai`, `clawdbot-skill-search-x` (exact pattern replicated)
diff --git a/docs/plans/2026-02-15-fix-watchlist-engagement-null-crash-plan.md b/docs/plans/2026-02-15-fix-watchlist-engagement-null-crash-plan.md
deleted file mode 100644
index e498b02..0000000
--- a/docs/plans/2026-02-15-fix-watchlist-engagement-null-crash-plan.md
+++ /dev/null
@@ -1,76 +0,0 @@
----
-title: "fix: watchlist engagement null crash"
-type: fix
-date: 2026-02-15
----
-
-# fix: Watchlist crashes on `engagement: null` from X posts
-
-## Problem
-
-When X posts return `engagement: null` (JSON null) instead of `engagement: {}` (empty object), the watchlist `_run_topic` findings parser crashes. This is because Python's `dict.get("engagement", {})` returns `None` when the key **exists** with value `None` — the default `{}` only applies when the key is **missing**.
-
-```python
-# CRASHES — .get() returns None, not {}
-item.get("engagement", {}).get("likes", 0)
-# AttributeError: 'NoneType' object has no attribute 'get'
-```
-
-**Reporter:** Soft launch tester, 2026-02-15
-**Severity:** Medium — breaks watchlist `run-one` and `run-all` for any topic that pulls X posts with null engagement
-**One-shot research unaffected** — the main `last30days.py` pipeline uses `normalize.py` which has `isinstance(eng_raw, dict)` guards
-
-## Root Cause
-
-Two locations use the vulnerable `dict.get("key", {})` pattern:
-
-1. **`scripts/watchlist.py:188`** — THE REPORTED BUG
- ```python
- "engagement_score": item.get("engagement", {}).get("likes", 0),
- ```
-
-2. **`scripts/lib/normalize.py:177`** — YouTube normalizer (same pattern, latent)
- ```python
- eng_raw = item.get("engagement", {})
- ```
-
-Three other locations are already safe — they use `isinstance(eng_raw, dict)`:
-- `scripts/lib/normalize.py:70` (Reddit)
-- `scripts/lib/normalize.py:130` (X)
-- `scripts/lib/xai_x.py:190`
-
-## Fix
-
-Apply the `or {}` idiom (as suggested by reporter):
-
-### scripts/watchlist.py:188
-
-```python
-# Before
-"engagement_score": item.get("engagement", {}).get("likes", 0),
-# After
-"engagement_score": (item.get("engagement") or {}).get("likes", 0),
-```
-
-### scripts/lib/normalize.py:177
-
-```python
-# Before
-eng_raw = item.get("engagement", {})
-# After
-eng_raw = item.get("engagement") or {}
-```
-
-## Acceptance Criteria
-
-- [ ] `watchlist.py run-one` handles X posts with `engagement: null` without crashing
-- [ ] `watchlist.py run-one` handles X posts with `engagement: {}` (empty object)
-- [ ] `watchlist.py run-one` handles X posts with no engagement key at all
-- [ ] YouTube normalizer handles `engagement: null` without crashing
-- [ ] Existing tests still pass
-
-## Regarding the screenshot question
-
-The tester asked "Did you use watchlist on open claw or Claude?" — watchlist is designed for the **open variant** (Open Claw). It works in Claude Code too since it's just Python + SQLite, but the SKILL.md routing for watchlist commands is only in `variants/open/SKILL.md`. The main `SKILL.md` is one-shot research only.
-
-Answer to give tester: "Watchlist works in both — it's plain Python. But the skill routing that understands 'watch add topic' is in the open variant. In Claude Code you'd need to call the script directly or use the open variant SKILL.md."
diff --git a/docs/plans/2026-02-15-fix-youtube-display-and-search-quality-plan.md b/docs/plans/2026-02-15-fix-youtube-display-and-search-quality-plan.md
deleted file mode 100644
index de40443..0000000
--- a/docs/plans/2026-02-15-fix-youtube-display-and-search-quality-plan.md
+++ /dev/null
@@ -1,221 +0,0 @@
----
-title: Fix YouTube Display and Search Quality
-type: fix
-date: 2026-02-15
----
-
-# Fix YouTube Display and Search Quality
-
-## Overview
-
-YouTube is the v2.1 headline feature but it's broken in two ways: results don't appear in Claude's synthesis (display bug), and search quality is worse than youtube.com (search bug). Both need fixing before launch.
-
-## Problem Statement
-
-**Display bug:** YouTube data exists in the script output but Claude never sees it. Reproduced on 4/5 recent test runs (Kanye, Seedance 2, Peter Steinberger, YouTube thumbnails). The skill worked once — the earlier "YouTube thumbnails" and "OpenClaw" runs showed YouTube stats — but subsequent runs silently dropped it.
-
-**Search quality bug:** User searched "how to get on seedance 2" on youtube.com and got multiple recent results. yt-dlp returned 10 videos for the same query, but they included old irrelevant content because date filtering is broken.
-
-## Root Cause Analysis
-
-### Display Bug — Three compounding causes
-
-1. **`2>&1` in SKILL.md bash command** (line 79) merges stderr progress messages into stdout. When Claude Code receives this mixed output, the YouTube section (which renders LAST after Reddit + X) can get lost in the noise or hit the 30K char Bash output limit.
-
-2. **Background execution** (partially fixed). The old SKILL.md said "DO WEBSEARCH WHILE SCRIPT RUNS" which caused Claude to background the bash command. Backgrounded commands return truncated output via Task Output. *Already fixed in this session — SKILL.md now says FOREGROUND with 5-minute timeout.*
-
-3. **No explicit model instruction to look for YouTube.** The SKILL.md tells Claude to synthesize but doesn't emphasize that YouTube data is in the script output and must be included.
-
-### Search Quality Bug — `--flat-playlist` breaks date filtering
-
-The yt-dlp command in `youtube_yt.py:110-116`:
-```bash
-yt-dlp ytsearch{count}:{query} --dateafter {YYYYMMDD} --flat-playlist --dump-json
-```
-
-**`--flat-playlist` causes three problems:**
-1. `--dateafter` is silently ignored (no video-level metadata to filter on)
-2. All items have `date: None` (upload_date not in flat-playlist JSON)
-3. Old content leaks in (e.g., "the greatest youtube thumbnails of all time" returned for a 30-day query)
-
-**`_extract_core_subject()` over-strips useful YouTube terms:**
-- Strips "tips", "tutorial", "review" — but these ARE the content types people search for on YouTube
-- "youtube thumbnail tips" → "youtube thumbnail" loses the intent signal
-
-## Proposed Solution
-
-### Phase 1: Fix Display (Critical — blocks launch)
-
-#### 1a. Remove `2>&1` from SKILL.md bash command
-
-**File:** `SKILL.md:79` (both `last30days-skill-private/SKILL.md` and `~/.claude/skills/last30days21/SKILL.md`)
-
-```bash
-# Before:
-python3 "${SKILL_ROOT}/scripts/last30days.py" "$ARGUMENTS" --emit=compact 2>&1
-
-# After:
-python3 "${SKILL_ROOT}/scripts/last30days.py" "$ARGUMENTS" --emit=compact
-```
-
-This removes ~1-5KB of progress spam from the model's input and ensures clean stdout-only output.
-
-#### 1b. Add YouTube-specific synthesis instruction to SKILL.md
-
-After the "Read the ENTIRE output" instruction, add:
-
-```markdown
-**The script output has THREE sections: Reddit items, X items, and YouTube items (in that order).
-If you see YouTube items in the output, you MUST include them in your synthesis and stats block.
-YouTube items look like: `**{video_id}** (score:N) {channel} [N views, N likes]`**
-```
-
-#### 1c. Verify fix with test run
-
-Run `/last30days21 youtube thumbnail tips` and confirm YouTube appears in stats.
-
-### Phase 2: Fix Search Quality (High — headline feature quality)
-
-#### 2a. Remove `--flat-playlist` flag
-
-**File:** `scripts/lib/youtube_yt.py:110-116`
-
-```python
-# Before:
-cmd = [
- "yt-dlp",
- f"ytsearch{count}:{core_topic}",
- "--dateafter", date_filter,
- "--flat-playlist",
- "--dump-json",
-]
-
-# After:
-cmd = [
- "yt-dlp",
- f"ytsearch{count}:{core_topic}",
- "--dateafter", date_filter,
- "--dump-json",
- "--no-warnings",
- "--no-download",
-]
-```
-
-**Impact:** Slower (yt-dlp resolves each video page for metadata) but:
-- `--dateafter` actually works — filters old content
-- `upload_date` populated — items get real dates
-- Engagement metrics more accurate
-
-**Risk:** Could increase search time from ~5s to ~30-60s for 20 videos. Mitigate by reducing default count or increasing timeout.
-
-**Alternative if too slow:** Keep `--flat-playlist` but append year to search query:
-```python
-# Bias toward recent content since --dateafter doesn't work with flat-playlist
-search_query = f"{core_topic} {from_date[:4]}" # e.g., "youtube thumbnail 2026"
-```
-
-#### 2b. Fix `_extract_core_subject()` for YouTube-relevant terms
-
-**File:** `scripts/lib/youtube_yt.py:67-76`
-
-Don't strip terms that are useful YouTube content type signals:
-
-```python
-# YouTube-specific: keep 'tips', 'tutorial', 'review' etc.
-# These are stripped for Reddit/X search but are valuable for YouTube
-noise = {
- 'best', 'top', 'good', 'great', 'awesome', 'killer',
- 'latest', 'new', 'news', 'update', 'updates',
- 'trending', 'hottest', 'popular', 'viral',
- 'practices', 'features',
- 'recommendations', 'advice',
- 'prompt', 'prompts', 'prompting',
- 'methods', 'strategies', 'approaches',
-}
-# NOTE: 'tips', 'tricks', 'tutorial', 'guide', 'review', 'reviews'
-# are intentionally KEPT — they're YouTube content types
-```
-
-#### 2c. Clean question marks and trailing punctuation
-
-**File:** `scripts/lib/youtube_yt.py:48-80`
-
-```python
-# At the end of _extract_core_subject():
-result = ' '.join(filtered) if filtered else text
-return result.rstrip('?!.') # Clean trailing punctuation
-```
-
-### Phase 3: Polish (Medium — nice to have before launch)
-
-#### 3a. Increase subprocess timeout for non-flat-playlist mode
-
-**File:** `scripts/lib/youtube_yt.py:119-121`
-
-```python
-# Before:
-result = subprocess.run(cmd, capture_output=True, text=True, timeout=60)
-
-# After — resolving video pages takes longer:
-result = subprocess.run(cmd, capture_output=True, text=True, timeout=120)
-```
-
-#### 3b. Add year hint to search query for recency bias
-
-Even with `--dateafter` working, YouTube's search algorithm ranks by relevance not recency. Adding the year helps:
-
-```python
-# After core_topic extraction:
-import datetime
-current_year = datetime.datetime.now().year
-search_query = f"{core_topic} {current_year}"
-```
-
-#### 3c. Reduce compact render item count for YouTube
-
-The compact render currently shows up to 15 YouTube items. With transcripts, this is too much output. Reduce to 10:
-
-**File:** `scripts/lib/render.py` — in `render_compact()`, add YouTube-specific limit or reduce the default.
-
-## Acceptance Criteria
-
-- [ ] `/last30days21 youtube thumbnail tips` shows YouTube in stats block
-- [ ] YouTube items have real dates (not `None`)
-- [ ] Old content (> 30 days) filtered out by `--dateafter`
-- [ ] "youtube thumbnail tips" search returns videos about thumbnail tips (not generic old content)
-- [ ] "How to access Seedance 2" returns recent Seedance 2 tutorials
-- [ ] Script completes within 5 minutes for default depth
-- [ ] No `2>&1` in SKILL.md bash command
-
-## Files to Modify
-
-| File | Changes |
-|------|---------|
-| `SKILL.md` | Remove `2>&1`, add YouTube synthesis instruction |
-| `scripts/lib/youtube_yt.py` | Remove `--flat-playlist`, fix noise words, add year hint, increase timeout |
-| `scripts/lib/render.py` | Optional: reduce YouTube item limit in compact mode |
-
-## Testing
-
-```bash
-# Quick smoke test — should show YouTube items with dates
-cd ~/.claude/skills/last30days21
-python3 scripts/last30days.py "youtube thumbnail tips" --quick --emit=compact 2>/dev/null | grep -c "youtube.com"
-
-# Date test — should show YYYY-MM-DD dates, not None
-python3 -c "
-from scripts.lib import youtube_yt
-r = youtube_yt.search_youtube('youtube thumbnail tips', '2026-01-16', '2026-02-15', depth='quick')
-for v in r['items'][:3]: print(v['date'], v['title'][:50])
-"
-
-# Full integration — run the skill in Claude Code and verify YouTube in stats
-```
-
-## References
-
-- SKILL.md bash command: `scripts/last30days.py` line 79
-- YouTube search: `scripts/lib/youtube_yt.py` lines 83-174
-- Core subject extraction: `scripts/lib/youtube_yt.py` lines 48-80
-- Compact render: `scripts/lib/render.py` lines 48-238
-- Prior YouTube plan: `docs/plans/2026-02-14-feat-youtube-transcript-search-plan.md`
diff --git a/docs/plans/2026-02-15-fix-youtube-timeout-and-reddit-resilience-plan.md b/docs/plans/2026-02-15-fix-youtube-timeout-and-reddit-resilience-plan.md
deleted file mode 100644
index d167601..0000000
--- a/docs/plans/2026-02-15-fix-youtube-timeout-and-reddit-resilience-plan.md
+++ /dev/null
@@ -1,102 +0,0 @@
----
-title: Fix YouTube Timeout and Reddit Resilience
-type: fix
-date: 2026-02-15
----
-
-# Fix YouTube Timeout and Reddit Resilience
-
-## Overview
-
-YouTube search+transcript fetching exceeds the 60s future timeout on popular topics (20 videos + 5 transcripts), discarding all results. Reddit 429 rate-limiting burns through the entire time budget with aggressive retries, causing global timeouts. Both issues cause the script to return incomplete results and sometimes crash.
-
-## Problem Statement
-
-**YouTube timeout (blocks launch):** YouTube found 20 Seedance 2.0 videos and fetched 4/5 transcripts, but the 60s future timeout killed everything — `✗ Error: YouTube search timed out after 60s`. The data was there; the budget wasn't.
-
-**Reddit 429 cascade (degrades reliability):** One enrichment item hitting 429 burns up to 93s (3 retries x 30s timeout + backoff) against a 45s budget. Phase 2 supplemental search then also 429s, burning another 30s. Total wasted: 75s on doomed requests, triggering the 180s global timeout.
-
-Observed failures across 6 test runs:
-- `seedance 2 access`: YouTube timed out (60s), Reddit 0 threads
-- `kanye west bully`: Global timeout first run, needed --quick retry
-- `Peter Steinberger`: Global timeout during enrichment
-- `nano banana pro`: Reddit timed out, YouTube worked (5 videos in time)
-- `kanye west bully` (retry): Reddit 0 threads, YouTube 4 videos
-
-## Proposed Solution
-
-### Fix 1: Bump YouTube future timeout (NOT YET DONE)
-
-**File:** `scripts/last30days.py:40-44`
-
-Give YouTube its own timeout, separate from the shared `future` timeout. YouTube inherently takes longer because it does search + parallel transcript fetching.
-
-```python
-# Option A: YouTube-specific timeout key
-TIMEOUT_PROFILES = {
- "quick": {"global": 90, "future": 30, "youtube_future": 60, ...},
- "default": {"global": 180, "future": 60, "youtube_future": 90, ...},
- "deep": {"global": 300, "future": 90, "youtube_future": 120, ...},
-}
-
-# Option B: Simpler — just bump default future to 90 for all sources
-TIMEOUT_PROFILES = {
- "quick": {"global": 90, "future": 45, ...},
- "default": {"global": 180, "future": 90, ...},
- "deep": {"global": 300, "future": 120, ...},
-}
-```
-
-**Recommendation:** Option A (YouTube-specific key) — keeps Reddit/X futures tight while giving YouTube the breathing room it needs. Reddit/X finish in 20-40s; YouTube needs 60-90s for transcript fetching.
-
-Then where YouTube future is collected (~line 646):
-```python
-youtube_timeout = timeouts.get("youtube_future", timeouts["future"])
-youtube_items, youtube_error = youtube_future.result(timeout=youtube_timeout)
-```
-
-### Fix 2: Reddit 429 fail-fast (ALREADY DONE — needs commit)
-
-**Status:** Implemented in working tree, uncommitted. Changes across 4 files:
-
-| File | Change |
-|------|--------|
-| `scripts/lib/http.py` | `get_reddit_json()` accepts `timeout` and `retries` params (was hardcoded 30s/3) |
-| `scripts/lib/reddit_enrich.py` | `RedditRateLimitError` exception; `fetch_thread_data()` propagates 429; `enrich_reddit_item()` defaults to 10s timeout / 1 retry |
-| `scripts/lib/openai_reddit.py` | `search_subreddits()` reduced to 1 retry; breaks subreddit loop on first 429 |
-| `scripts/last30days.py` | Enrichment loop catches `RedditRateLimitError`, cancels futures, bails; `rate_limited` flag skips Phase 2 Reddit |
-
-**Impact:** 429 scenario drops from ~75s wasted to ~12s wasted.
-
-## Acceptance Criteria
-
-- [ ] YouTube `seedance 2 access` query completes with videos (was timing out at 60s)
-- [ ] YouTube `kanye west bully` query returns 4+ videos with transcripts
-- [ ] Reddit 429 detected within ~12s, remaining enrichment skipped
-- [ ] Phase 2 Reddit skipped when rate-limited
-- [ ] No global timeout on default depth for any of the 5 test queries
-- [ ] All changes committed and synced to `~/.claude/skills/last30days21/` and `~/.claude/skills/last30days/`
-
-## Files to Modify
-
-| File | Status | Changes |
-|------|--------|---------|
-| `scripts/last30days.py` | Modify (partially done) | Add `youtube_future` to TIMEOUT_PROFILES; use it for YouTube future collection |
-| `scripts/lib/http.py` | Done (uncommitted) | Parameterized `get_reddit_json()` timeout/retries |
-| `scripts/lib/reddit_enrich.py` | Done (uncommitted) | `RedditRateLimitError`, fail-fast enrichment |
-| `scripts/lib/openai_reddit.py` | Done (uncommitted) | 429 early-bail in `search_subreddits()` |
-
-## Testing
-
-```bash
-# Quick smoke test — YouTube should complete within 90s
-cd ~/.claude/skills/last30days21
-python3 scripts/last30days.py "seedance 2 access" --emit=compact 2>&1 | grep -E "YouTube|timeout"
-
-# Verify YouTube items in output
-python3 scripts/last30days.py "kanye west bully" --quick --emit=compact 2>/dev/null | grep "youtube.com" | wc -l
-
-# Full integration — run the skill in Claude Code
-# /last30days21 seedance 2 access
-# Verify YouTube appears in stats block
-```
diff --git a/docs/plans/2026-02-17-feat-github-release-v2.1-plan.md b/docs/plans/2026-02-17-feat-github-release-v2.1-plan.md
deleted file mode 100644
index cba4ed9..0000000
--- a/docs/plans/2026-02-17-feat-github-release-v2.1-plan.md
+++ /dev/null
@@ -1,255 +0,0 @@
----
-title: "feat: Create GitHub Release for v2.1"
-type: feat
-date: 2026-02-17
----
-
-# Create GitHub Release for v2.1
-
-## Overview
-
-The last30days skill is at v2.1 with 2,747 stars and 317 forks, but has **zero git tags and zero GitHub Releases**. The code is already live on the public repo (`upstream/main`). All marketing copy is written. This plan creates a proper v2.1.0 GitHub Release from existing materials.
-
-## Problem Statement
-
-GitHub Releases provide:
-- A landing page for each version with formatted release notes
-- Discoverability (GitHub shows releases in the sidebar, feeds them to the Explore page)
-- A stable reference point for users (`git checkout v2.1.0`)
-- A "Full Changelog" diff link
-- RSS feed for watchers
-- New contributor callouts (community goodwill)
-
-Currently users have no way to reference a specific version of the skill. The README says "v2.1" but there's nothing in git to anchor that.
-
-## Proposed Solution
-
-Create the first GitHub Release (v2.1.0) on the **public** repo using existing marketing copy. Use an annotated tag (not lightweight) for proper `git describe` support and fork propagation.
-
-## Implementation Steps
-
-### Phase 1: Tag and Release
-
-#### 1. Create CHANGELOG.md
-
-Create `CHANGELOG.md` following [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) format. Source material already exists:
-
-- `README.md` "What's New in V2.1" and "What's New in V2" sections
-- `docs/v2.1-tweets.md` (raw verified test results, feature descriptions)
-- `docs/v2.1-launch-copy.md` (feature copy, social posts)
-- `docs/pr-credits.md` (contributor credits)
-- Git log (`git log --oneline` for commit references)
-
-Structure:
-```markdown
-# Changelog
-
-## [2.1.0] - 2026-02-15
-
-### Highlights
-
-30 days of research. 30 seconds of work. Four sources. Zero stale prompts.
-
-Three headline features...
-
-### Added
-- Open-class skill with watchlists (SQLite-backed, FTS5)
-- YouTube as 4th research source via yt-dlp (search + transcript extraction)
-- OpenAI Codex CLI compatibility ($last30days invocation)
-- Bundled X search (vendored Bird GraphQL client, no external CLI)
-- Native web search backends (Parallel AI, Brave, OpenRouter/Perplexity Sonar)
-- Briefing and history modes (open variant)
-- --diagnose flag for source status checking
-- --store flag for SQLite accumulation
-
-### Changed
-- Smarter query construction (strips noise words, auto-retry)
-- Two-phase search (Phase 2 entity-aware drill-down)
-- Reddit JSON enrichment (real upvotes/comments from reddit.com/.json)
-- Engagement-weighted scoring (relevance 45%, recency 25%, engagement 30%)
-- Model auto-selection with 7-day cache
-
-### Fixed
-- YouTube timeout increased to 90s
-- Reddit 429 rate limit fail-fast
-- YouTube soft date filter (keeps evergreen content)
-- Eager import crash in __init__.py (Codex compatibility)
-
-### New Contributors
-- @JosephOIbrahim - Windows Unicode fix
-- @levineam - Model fallback for unverified orgs
-- @jonthebeef - --days=N configurable lookback flag
-
-### Credits
-- @steipete - Bird CLI (vendored X search) and yt-dlp inspiration
-- @galligan - Marketplace plugin inspiration
-- @hutchins - Pushed for YouTube feature
-
-## [1.0.0] - 2026-01-15
-
-Initial public release. Reddit + X search via OpenAI and xAI APIs.
-```
-
-#### 2. Create annotated tag on the public repo
-
-```bash
-cd /Users/mvanhorn/last30days-skill-private
-
-# Tag on the current HEAD (which matches upstream/main)
-git tag -a v2.1.0 -m "Release v2.1.0: Watchlists, YouTube transcripts, Codex CLI, bundled X search"
-
-# Push to public repo
-git push upstream v2.1.0
-```
-
-Use annotated tag (not lightweight) because:
-- Stores tagger metadata and date
-- Works with `git describe`
-- Propagates to forks (317 forks)
-- Supports GPG signing if desired later
-
-#### 3. Craft release notes
-
-Assemble from existing copy. The release body should follow this structure:
-
-```markdown
-## Highlights
-
-The AI world reinvents itself every month. This skill keeps you current.
-
-`/last30days` researches your topic across **Reddit, X, YouTube, and the web**
-from the last 30 days, finds what the community is actually upvoting, sharing,
-and saying on camera, and writes you a prompt that works today.
-
-**Three headline features in v2.1:**
-
-1. **Open-class skill with watchlists** - Track competitors, people, topics on a schedule.
- Pair with Open Claw for automated briefings. SQLite-backed with FTS5 search.
-
-2. **YouTube transcripts as a 4th source** - When yt-dlp is installed, searches YouTube,
- grabs view counts, and reads the actual transcripts. A 20-minute review has 10x the
- signal of one X post.
-
-3. **Codex CLI compatibility** - Same skill, same engine, same four sources.
- Install to `~/.agents/skills/last30days` and invoke with `$last30days`.
-
-Plus: **Bundled X search** - Vendored Bird GraphQL client. No external CLI needed.
-Just Node.js 22+ and browser cookies.
-
-## Real Results (verified 2/15)
-
-| Topic | Reddit | X | YouTube | Web |
-|-------|--------|---|---------|-----|
-| Nano Banana Pro | - | 32 posts, 164 likes | 5 videos, 98K views | - |
-| Seedance 2.0 | 21 threads | 33 posts | 20 videos | 5 pages |
-| OpenClaw use cases | 35 threads, 1,130 upvotes | 23 posts | 20 videos, 1.57M views | - |
-| YouTube thumbnails | 7 threads, 654 upvotes | 32 posts | 18 videos, 6.15M views | - |
-
-## What's New
-
-### Added
-- Open-class skill with watchlist, briefing, and history modes
-- YouTube search + transcript extraction via yt-dlp
-- OpenAI Codex CLI compatibility
-- Bundled Twitter/X search (vendored Bird GraphQL)
-- Native web search (Parallel AI, Brave, OpenRouter)
-- `--diagnose` and `--store` flags
-- Conversational first-run experience (NUX)
-
-### Changed
-- Two-phase search architecture (entity-aware drill-down)
-- Reddit JSON enrichment for real engagement metrics
-- Smarter query construction with auto-retry
-- Engagement-weighted scoring algorithm
-
-### Fixed
-- YouTube/Reddit timeout resilience
-- Reddit 429 rate limit fail-fast
-- Eager import crash in Codex environments
-
-## New Contributors
-
-- @JosephOIbrahim - Windows Unicode fix
-- @levineam - Model fallback for unverified orgs
-- @jonthebeef - `--days=N` configurable lookback
-
-## Credits
-
-- @steipete - Bird CLI and yt-dlp/summarize inspiration
-- @galligan - Marketplace plugin inspiration
-- @hutchins - Pushed for YouTube feature
-
-## Install
-
-```bash
-# Claude Code
-claude install-skill https://github.com/mvanhorn/last30days-skill
-
-# Manual
-git clone https://github.com/mvanhorn/last30days-skill ~/.claude/skills/last30days
-```
-
-**Full Changelog**: https://github.com/mvanhorn/last30days-skill/commits/v2.1.0
-```
-
-#### 4. Create the GitHub Release
-
-```bash
-gh release create v2.1.0 \
- --repo mvanhorn/last30days-skill \
- --verify-tag \
- --title "v2.1.0 - Watchlists, YouTube Transcripts, Codex CLI" \
- -F release-notes.md
-```
-
-No binary assets needed - this is an interpreted skill, and GitHub auto-generates source archives.
-
-### Phase 2: Future Automation (Optional)
-
-#### 5. Add `.github/release.yml` for auto-generated notes on future releases
-
-```yaml
-changelog:
- exclude:
- labels:
- - ignore-for-release
- categories:
- - title: "Breaking Changes"
- labels: [breaking-change]
- - title: "Features"
- labels: [enhancement, feature]
- - title: "Bug Fixes"
- labels: [bug, fix]
- - title: "Documentation"
- labels: [documentation]
- - title: "Other Changes"
- labels: ["*"]
-```
-
-This enables `gh release create v2.2.0 --generate-notes` for future releases with automatic PR categorization.
-
-## Acceptance Criteria
-
-- [x] `CHANGELOG.md` exists in repo root following Keep a Changelog format
-- [x] Annotated tag `v2.1.0` exists on the public repo
-- [x] GitHub Release `v2.1.0` is published at `github.com/mvanhorn/last30days-skill/releases`
-- [x] Release notes include: highlights, real results table, feature list, contributor credits, install instructions
-- [x] Release appears in the repo sidebar on GitHub
-
-## Key Decisions
-
-1. **v2.1.0 only** - Don't retroactively create v1.0.0 or v2.0.0 tags. The git history doesn't have clean boundary commits for those versions, and retroactive tags add complexity without value.
-
-2. **Release on the public repo** (`upstream`), not the private repo. Users see `mvanhorn/last30days-skill`.
-
-3. **CHANGELOG.md as source of truth** - The release notes are derived from CHANGELOG.md, not the other way around. Future releases update CHANGELOG.md first, then create the release.
-
-4. **Update star count** - The existing copy says "1.5K stars" but the repo now has 2,747. Update in release notes.
-
-## References
-
-- Existing copy: `docs/v2.1-launch-copy.md`, `docs/v2.1-tweets.md`
-- Contributors: `docs/pr-credits.md`
-- README features: `README.md` "What's New" sections
-- GitHub Releases docs: https://docs.github.com/en/repositories/releasing-projects-on-github
-- Keep a Changelog: https://keepachangelog.com/en/1.1.0/
diff --git a/docs/plans/2026-02-20-feat-last30days-com-trending-topics-plan.md b/docs/plans/2026-02-20-feat-last30days-com-trending-topics-plan.md
deleted file mode 100644
index 49a6421..0000000
--- a/docs/plans/2026-02-20-feat-last30days-com-trending-topics-plan.md
+++ /dev/null
@@ -1,274 +0,0 @@
----
-title: "feat: Last30Days.com - Automated Trending Topic Research"
-type: feat
-date: 2026-02-20
----
-
-# Last30Days.com - Automated Trending Topic Research
-
-## Overview
-
-A website at Last30Days.com that automatically shows daily trending topics researched by the /last30days engine. The core challenge: the skill is query-driven (you give it a topic), but a trending page needs to *discover* what topics to research. This plan covers how to source trending topics, run them through the engine, and publish results.
-
-## Problem Statement
-
-The /last30days skill has 2,747 GitHub stars but no public-facing showcase. Users have to install the skill and run it themselves. A website that automatically shows trending topic results would:
-
-1. **Drive installs** - people see the quality and want it for their own topics
-2. **SEO surface area** - each topic page is indexable content
-3. **Demonstrate capability** - "here's what /last30days found about X today" with real stats
-4. **Content flywheel** - daily fresh content with zero manual curation
-
-## Trending Topic Discovery: The Options
-
-The skill has **no existing trending discovery mechanism** - it's entirely query-driven. Here are the available sources, ranked by practicality.
-
-### Tier 1: Free, High Signal, Zero Friction
-
-| Source | What It Returns | Auth | Cost | Best For |
-|--------|----------------|------|------|----------|
-| **Wikipedia Pageviews** | Top 100 most-viewed articles yesterday | None | Free | General public interest (news, culture, events) |
-| **Hacker News** | Top 30 stories with scores | None | Free | Tech/startup topics |
-| **Reddit r/all/hot + rising** | Hottest and rapidly rising posts | OAuth (free) | Free | Broad internet culture |
-| **Google News RSS** | Top headlines, algorithmically curated | None | Free | Mainstream news |
-| **YouTube mostPopular** | Top 50 trending videos by region | API key | Free (10K units/day) | Pop culture, entertainment |
-
-### Tier 2: Very Cheap, High Value
-
-| Source | What It Returns | Auth | Cost | Best For |
-|--------|----------------|------|------|----------|
-| **Perplexity Sonar API** | "What's trending today?" with sourced answers | API key | ~$1/month | Meta-aggregator that replaces multiple sources |
-| **Google Trends (pytrends)** | Daily trending Google searches | None | Free but flaky | What people are actually searching |
-| **Bird `getNews()`** | X Explore page trending topics | Browser cookies | Free | Real-time Twitter/X conversation |
-
-### Tier 3: Paid, Skip for MVP
-
-| Source | Cost | Why Skip |
-|--------|------|----------|
-| X/Twitter API | $200/month minimum | Too expensive; Bird `getNews()` is free |
-| Exploding Topics | $249/month | Overkill for daily trends |
-| TikTok | Gated, requires approval | Application process |
-
-### Existing Codebase Hooks
-
-Several pieces already exist in the codebase that could be leveraged:
-
-1. **Bird `getNews()` is already on disk.** The vendored Bird library at `scripts/lib/vendor/bird-search/` includes `twitter-client-news.js` which fetches X's Explore page tabs (For You, Trending, News, Sports, Entertainment). Only needs a ~30-line `bird-news.mjs` wrapper to expose it. Zero API keys needed.
-
-2. **`store.get_trending(days=7)`** at `scripts/store.py:559` ranks watchlist topics by recent finding activity. Could feed "what the skill has been researching" as a meta-signal.
-
-3. **Scoring algorithm** at `scripts/lib/score.py` has engagement formulas for Reddit, X, and YouTube that could rank "buzz" by reweighting engagement >> relevance.
-
-4. **Brave Trending API** exists but isn't implemented in `brave_search.py`. Could be added.
-
-## Proposed Architecture
-
-### Topic Discovery Pipeline (daily cron)
-
-```
-┌─────────────────────────────────────────────────┐
-│ STEP 1: Fetch trending signals (parallel, free) │
-├─────────────────────────────────────────────────┤
-│ Wikipedia Pageviews ─┐ │
-│ Hacker News Top 30 ─┤ │
-│ Reddit r/all/hot ─┼─→ Raw topics + signals │
-│ Google News RSS ─┤ │
-│ YouTube mostPopular ─┤ │
-│ Bird getNews() ─┘ │
-└─────────────────────────────────────────────────┘
- │
- ▼
-┌─────────────────────────────────────────────────┐
-│ STEP 2: Cluster + rank │
-├─────────────────────────────────────────────────┤
-│ Extract topic keywords from titles │
-│ Cluster by semantic similarity │
-│ Rank by cross-source frequency │
-│ ("Pope Leo XIV" on Wikipedia + Reddit + News │
-│ = high confidence trend) │
-│ Output: top 15-20 topics, ranked │
-└─────────────────────────────────────────────────┘
- │
- ▼
-┌─────────────────────────────────────────────────┐
-│ STEP 3: Run /last30days on top topics │
-├─────────────────────────────────────────────────┤
-│ Top 3-5 topics: full research (--emit=json) │
-│ → Full "What I learned" synthesis articles │
-│ Remaining 10-15: quick research (--quick) │
-│ → Stats teasers (32 X posts, 5 YouTube...) │
-└─────────────────────────────────────────────────┘
- │
- ▼
-┌─────────────────────────────────────────────────┐
-│ STEP 4: Publish to website │
-├─────────────────────────────────────────────────┤
-│ Generate static HTML/JSON │
-│ Deploy to Last30Days.com │
-│ RSS feed for subscribers │
-└─────────────────────────────────────────────────┘
-```
-
-### Topic Clustering Strategy
-
-The hardest part is deduplicating across sources. "Pope Francis Dies" (Google News), "Pope_Francis" (Wikipedia #1 viewed), and a r/worldnews post are all the same topic. Approaches:
-
-**Option A: LLM clustering (recommended for MVP)**
-Feed all raw titles to an LLM and ask it to cluster into distinct topics with a representative label. ~$0.01 per day via a fast model. Simple, accurate, handles edge cases.
-
-**Option B: TF-IDF + cosine similarity**
-Extract keywords, compute pairwise similarity, agglomerative clustering. No API cost but worse on paraphrased titles.
-
-**Option C: Embedding similarity**
-Embed all titles, cluster by cosine distance. Better than TF-IDF, costs a few cents per day.
-
-### Website Architecture
-
-**Option A: Static site (recommended for MVP)**
-- Daily cron generates JSON + static HTML
-- Host on GitHub Pages, Cloudflare Pages, or Vercel
-- Zero server cost, zero maintenance
-- Framework: plain HTML/CSS, or minimal Astro/11ty
-
-**Option B: Next.js with ISR**
-- Incremental Static Regeneration rebuilds pages daily
-- More flexibility for future features (search, filtering, user accounts)
-- Hosting: Vercel free tier
-
-**Option C: Full web app**
-- Database-backed, real-time updates, user accounts
-- Overkill for MVP
-
-### Cost Estimate (daily operation)
-
-| Item | Cost |
-|------|------|
-| Trending source APIs | $0 (all free tier) |
-| LLM clustering (fast model) | ~$0.01/day |
-| Full research on 3-5 topics | ~$0.10-0.50/day (OpenAI API for Reddit search) |
-| Quick research on 10-15 topics | ~$0.05-0.20/day |
-| Static hosting | $0 (GitHub/Cloudflare Pages) |
-| **Total** | **~$5-20/month** |
-
-## Implementation Phases
-
-### Phase 1: Topic Discovery Script (MVP)
-
-Build `scripts/discover_trending.py` that:
-- Fetches Wikipedia Pageviews, HN top stories, Reddit hot, Google News RSS in parallel
-- Filters out evergreen/non-topical Wikipedia pages (e.g., "Main Page", "ChatGPT" permanent traffic)
-- Uses a fast LLM to cluster raw titles into 15-20 distinct topics
-- Outputs ranked JSON: `[{topic, sources, confidence, category}]`
-
-**Acceptance criteria:**
-- [ ] Fetches from at least 4 free trending sources in parallel
-- [ ] Clusters raw titles into deduplicated topics via LLM
-- [ ] Filters Wikipedia evergreen pages (maintain a blocklist)
-- [ ] Outputs ranked JSON with topic name, source count, category
-- [ ] Runs in < 60 seconds
-- [ ] No paid API keys required for discovery (only LLM clustering)
-
-### Phase 2: Research Automation
-
-Wire discovered topics into the existing /last30days engine:
-- Top 3-5 topics: `python3 scripts/last30days.py "$TOPIC" --emit=json --deep`
-- Remaining topics: `python3 scripts/last30days.py "$TOPIC" --emit=json --quick`
-- Store all results in `~/.local/share/last30days/trending/YYYY-MM-DD/`
-
-**Acceptance criteria:**
-- [ ] Orchestration script runs discovery then research sequentially
-- [ ] Full research on top N topics, quick on the rest
-- [ ] Results stored as dated JSON files
-- [ ] Total daily runtime < 30 minutes
-- [ ] Handles timeouts/failures gracefully (skip topic, continue)
-
-### Phase 3: Website Generation
-
-Build a static site generator that reads the daily JSON and produces Last30Days.com:
-- Homepage: today's trending topics grid (title, category, key stat, source badges)
-- Topic pages: full synthesis for showcase topics, stats teaser for others
-- Archive: previous days accessible by date
-- RSS feed
-- CTA: "Want to research your own topic? Install the skill"
-
-**Acceptance criteria:**
-- [ ] Static HTML generated from daily JSON
-- [ ] Homepage shows today's 15-20 trending topics
-- [ ] 3-5 showcase topic pages with full synthesis
-- [ ] Remaining topics show stats teasers + install CTA
-- [ ] Deploys to Last30Days.com (Cloudflare Pages or similar)
-- [ ] RSS feed for daily updates
-- [ ] Mobile responsive
-
-### Phase 4: Bird Trending Integration (bonus)
-
-Wire up the already-vendored Bird `getNews()` for X trending:
-- Create `scripts/lib/vendor/bird-search/bird-news.mjs` (~30 lines)
-- Add X Explore trending data as a 5th discovery source
-- X trends are the fastest-moving signal and fill the "what's happening right now" gap
-
-**Acceptance criteria:**
-- [ ] `bird-news.mjs` wrapper exposes X Explore trending topics
-- [ ] Integrated into discovery pipeline as an additional source
-- [ ] Falls back gracefully if no X session cookies available
-
-## Alternative Approaches Considered
-
-### Perplexity-only approach
-Just ask Perplexity Sonar "what are the top 20 trending topics today?" daily. Simpler, but:
-- Single point of failure
-- Less transparent (can't show "sourced from Reddit, Wikipedia, HN")
-- Model may hallucinate or miss niche topics
-- **Verdict:** Good fallback, not primary.
-
-### Curated topics (manual)
-Manually pick topics each day. Defeats the purpose of automation. Could supplement for "editorial picks."
-
-### Social listening tools (Brandwatch, Sprout Social, etc.)
-Expensive ($500+/month), enterprise-focused, overkill.
-
-## Technical Considerations
-
-- **Rate limits:** Wikipedia, HN, and RSS have no practical limits for 1 daily call. Reddit's 100 QPM is generous. YouTube's 10K units/day allows ~3,000 `mostPopular` calls.
-- **Wikipedia filtering:** The top Wikipedia pages are always "Main Page", "Special:Search", etc. Need a blocklist of ~50 evergreen pages plus heuristics (skip pages under 1,000 characters, skip disambiguation pages).
-- **Cron timing:** Run discovery at ~2am UTC (after Wikipedia pageviews finalize for previous day). Run research at ~3am UTC. Deploy site by ~5am UTC.
-- **Cost control:** The /last30days engine uses OpenAI API for Reddit search. Full research on 5 topics * $0.05-0.10 each = ~$0.25-0.50/day. Quick research is cheaper.
-- **Domain:** Last30Days.com needs to be registered (check availability).
-
-## Dependencies & Risks
-
-| Risk | Mitigation |
-|------|------------|
-| pytrends breaks (Google changes) | pytrends is a bonus source, not required. Other 4+ sources sufficient. |
-| Wikipedia pageviews delayed | Fall back to Perplexity Sonar for day's topics |
-| OpenAI API cost spikes | Cap at 5 full + 15 quick topics per day; use --quick for most |
-| Bird cookie auth stops working | X trending is a bonus source; discovery works without it |
-| Domain not available | Check availability; alternatives: last30.day, last30days.app |
-
-## Success Metrics
-
-- Daily automated publishing with zero manual intervention
-- 15-20 trending topics surfaced daily
-- 3-5 full synthesis articles per day
-- Site loads in < 2 seconds (static)
-- Drives measurable GitHub star growth / skill installs
-
-## References
-
-### Trending APIs (free)
-- Wikipedia Pageviews: `wikimedia.org/api/rest_v1/metrics/pageviews/top/{project}/{access}/{year}/{month}/{day}`
-- Hacker News: `hacker-news.firebaseio.com/v0/topstories.json`
-- Reddit: `oauth.reddit.com/r/all/hot` (or `reddit.com/r/all/hot.json` unauthenticated)
-- Google News RSS: `news.google.com/rss`
-- YouTube: `googleapis.com/youtube/v3/videos?chart=mostPopular`
-
-### Existing codebase hooks
-- Bird `getNews()`: `scripts/lib/vendor/bird-search/vendor/package/dist/lib/twitter-client-news.js`
-- Store trending: `scripts/store.py:559` (`get_trending()`)
-- Scoring: `scripts/lib/score.py` (engagement formulas)
-- Brave Trending API: not implemented, available in Brave docs
-
-### Inspiration
-- [Keep a Changelog](https://keepachangelog.com/) - clean daily update format
-- [Hacker News front page](https://news.ycombinator.com/) - minimal trending UI
-- [Exploding Topics](https://explodingtopics.com/) - trending topic showcase (paid, $249/mo)
diff --git a/docs/plans/2026-02-24-feat-hacker-news-source-plan.md b/docs/plans/2026-02-24-feat-hacker-news-source-plan.md
deleted file mode 100644
index a07ee39..0000000
--- a/docs/plans/2026-02-24-feat-hacker-news-source-plan.md
+++ /dev/null
@@ -1,235 +0,0 @@
----
-title: "feat: Add Hacker News as a 5th research source"
-type: feat
-status: completed
-date: 2026-02-24
----
-
-# feat: Add Hacker News as a 5th Research Source
-
-## Overview
-
-Add Hacker News as a source to the last30days skill, using the free Algolia HN Search API (`hn.algolia.com/api/v1`). HN provides high-signal content from a technical audience — stories with high point counts and active comment threads are strong indicators of what the developer community actually cares about. No API key required.
-
-## Problem Statement / Motivation
-
-The skill currently covers Reddit, X, YouTube, and web. Hacker News is missing — and for technical topics, HN often surfaces discussions that don't appear on Reddit or X. HN's upvote system and comment culture produce high-quality signal: a 500-point story with 200 comments means the developer community is genuinely engaged. Community contributor @wkbaran proposed this in PR #26 alongside YouTube and Product Hunt. YouTube shipped in v2.1; now it's time for HN.
-
-## Proposed Solution
-
-Add `scripts/lib/hackernews.py` following the exact same pattern as `youtube_yt.py` (the simplest existing source — no API key, just HTTP calls). Use the Algolia HN Search API for discovery, then optionally fetch top comments from high-scoring stories for enrichment (like Reddit enrichment, but using the `/items/:id` endpoint instead of Reddit's JSON API).
-
-### Two-phase approach (matches existing Reddit pattern):
-
-1. **Phase 1 — Search**: Query Algolia for stories matching the topic within the date range. Get titles, URLs, points, comment counts.
-2. **Phase 2 — Enrichment** (optional, top N stories): Fetch the `/items/:id` endpoint for the highest-scoring stories to get top-level comments. This gives "comment_insights" like Reddit enrichment does.
-
-## Technical Approach
-
-### Files to Create
-
-#### `scripts/lib/hackernews.py`
-
-The main source module. Pattern matches `youtube_yt.py` (simplest source).
-
-```python
-# scripts/lib/hackernews.py
-"""Hacker News search via Algolia API (free, no auth required)."""
-
-ALGOLIA_SEARCH_URL = "https://hn.algolia.com/api/v1/search"
-ALGOLIA_SEARCH_BY_DATE_URL = "https://hn.algolia.com/api/v1/search_by_date"
-ALGOLIA_ITEM_URL = "https://hn.algolia.com/api/v1/items"
-
-DEPTH_CONFIG = {
- "quick": 15,
- "default": 30,
- "deep": 60,
-}
-
-ENRICH_LIMITS = {
- "quick": 3,
- "default": 5,
- "deep": 10,
-}
-```
-
-Key functions:
-
-- `search_hackernews(topic, from_date, to_date, depth="default") -> Dict[str, Any]`
- - Calls `hn.algolia.com/api/v1/search?query={topic}&tags=story&numericFilters=created_at_i>{from_ts},created_at_i<{to_ts}&hitsPerPage={count}`
- - Uses `http.get()` — stdlib only, matches existing pattern
- - Returns raw Algolia response
-
-- `parse_hackernews_response(response: Dict) -> List[Dict]`
- - Extracts hits, maps to raw dicts with fields: `id` (prefix "HN"), `title`, `url`, `hn_url`, `author`, `date`, `engagement` (points, num_comments), `why_relevant`, `relevance`
- - `relevance` estimated from Algolia rank + engagement boost (same pattern as Bill's PR)
-
-- `enrich_top_stories(items, depth="default") -> List[Dict]`
- - Fetches `/items/{objectID}` for top N stories (by points)
- - Extracts top-level comments (author, text, points)
- - Adds `top_comments` and `comment_insights` fields (same structure as Reddit enrichment)
- - Uses `ThreadPoolExecutor` for parallel fetching
-
-- `_date_to_unix(date_str: str) -> int` — Helper, converts YYYY-MM-DD to Unix timestamp
-
-#### `tests/test_hackernews.py`
-
-Standard unittest pattern matching existing tests.
-
-- Test `parse_hackernews_response` with sample Algolia response
-- Test `_date_to_unix` conversion
-- Test empty response handling
-- Test enrichment parsing
-- Test score integration with `score.py`
-
-### Files to Modify
-
-#### `scripts/lib/schema.py`
-
-- [x] Add `HackerNewsItem` dataclass:
- ```python
- @dataclass
- class HackerNewsItem:
- id: str # "HN1", "HN2", ...
- title: str
- url: str # Original article URL
- hn_url: str # news.ycombinator.com/item?id=...
- author: str # HN username
- date: Optional[str]
- date_confidence: str # Always "high" (Algolia provides exact timestamps)
- engagement: Optional[Engagement] # points + num_comments
- top_comments: List[Comment] # From enrichment
- comment_insights: List[str] # From enrichment
- relevance: float
- why_relevant: str
- subs: SubScores
- score: int
- ```
-- [x] Add `hackernews: List[HackerNewsItem] = field(default_factory=list)` to `Report`
-- [x] Add `hackernews_error: Optional[str] = None` to `Report`
-- [x] Update `Report.to_dict()` and `Report.from_dict()`
-
-#### `scripts/lib/normalize.py`
-
-- [x] Add `normalize_hackernews_items(items: List[Dict], from_date, to_date) -> List[schema.HackerNewsItem]`
- - Maps raw dicts to `HackerNewsItem` dataclass instances
- - Sets `date_confidence = "high"` (Algolia provides `created_at_i` exact timestamps)
- - Converts `engagement` dict to `schema.Engagement(score=points, num_comments=num_comments)`
-
-#### `scripts/lib/score.py`
-
-- [x] Add `compute_hackernews_engagement_raw(engagement) -> float`
- - Formula: `0.55 * log1p(points) + 0.45 * log1p(num_comments)`
- - Points are the primary signal on HN; comments indicate depth of discussion
-- [x] Add `score_hackernews_items(items) -> List[schema.HackerNewsItem]`
- - Uses standard 45/25/30 weights (relevance/recency/engagement) — same as Reddit/X/YouTube
-- [x] Update `sort_items()` to handle `HackerNewsItem`
- - Source priority: Reddit > X > **HN** > YouTube > WebSearch
- - HN slots between X and YouTube: higher signal than YouTube (curated upvotes vs raw views), but X has real-time pulse
-
-#### `scripts/lib/dedupe.py`
-
-- [x] Add `dedupe_hackernews(items, threshold=0.7) -> List[schema.HackerNewsItem]`
-- [x] Update `get_item_text()` to handle `HackerNewsItem` (return `title`)
-- [x] Consider cross-source dedup: HN stories often link to the same URLs that appear in web search results. Dedupe by URL match across `hackernews` and `websearch` items.
-
-#### `scripts/lib/render.py`
-
-- [x] Add HN section to `render_compact()`:
- ```
- ### Hacker News Stories
-
- **HN1** (score:85) hn/username (2026-02-15) [350pts, 127cmt]
- Story title here
- https://news.ycombinator.com/item?id=12345
- *Why relevant*
- ```
-- [x] Add to `render_source_status()`:
- ```
- ✅ HN: {N} stories
- ```
-- [x] Add to `render_full_report()` and `render_context_snippet()`
-- [x] Update `_assess_data_freshness()` to include HN items
-
-#### `scripts/last30days.py`
-
-- [x] Import: `from lib import hackernews`
-- [x] Add `_search_hackernews(topic, from_date, to_date, depth) -> (items, error)` wrapper function
- - Calls `hackernews.search_hackernews()`, then `hackernews.parse_hackernews_response()`
- - Returns `(items, None)` or `([], error_string)`
-- [x] Add to `TIMEOUT_PROFILES`: `"hackernews_future": 60` (default), `30` (quick), `90` (deep)
-- [x] Add HN to `ThreadPoolExecutor` in `run_research()`:
- ```python
- if do_hackernews:
- hn_future = executor.submit(_search_hackernews, topic, from_date, to_date, depth)
- ```
- - Increment `max_workers` by 1 when HN is enabled
-- [x] Collect results: `hn_items, hn_error = hn_future.result(timeout=hn_timeout)`
-- [x] Add HN enrichment phase (after Reddit enrichment, before Phase 2):
- ```python
- if hn_items:
- hn_items = hackernews.enrich_top_stories(hn_items, depth=depth)
- ```
-- [x] Add to processing pipeline: normalize -> filter_by_date_range -> score -> sort -> dedupe
-- [x] Assign to `report.hackernews` and `report.hackernews_error`
-- [x] Update status UI: add `⏳ 🟡 HN Searching Hacker News...` and `✓ 🟡 HN Found {N} stories`
-
-#### `scripts/lib/env.py`
-
-- [x] HN is always available (no API key, no binary dependency)
-- [x] Update `get_available_sources()` to include HN in the source list
-- [x] Update `validate_sources()` to accept `hn` as a valid source name
-- [x] Add `hn` to the `--search` flag documentation
-
-#### `SKILL.md`
-
-- [x] Update description: "Sources: Reddit, X, YouTube, **Hacker News**, and web"
-- [x] Add HN to stats block template:
- ```
- ├─ 🟡 HN: {N} stories │ {N} points │ {N} comments
- ```
-- [x] Update `metadata.clawdbot.tags` to include `hackernews`
-- [x] Update Security section: add `hn.algolia.com` to endpoints list
-- [x] Update citation priority to include HN: Reddit > X > YouTube > HN > Web
-
-## Acceptance Criteria
-
-- [x] `python3 scripts/last30days.py "AI coding agents" --emit=compact` shows HN section with stories, points, and comment counts
-- [x] HN stories include `hn_url` linking to the HN discussion page (not just the article URL)
-- [x] Top stories are enriched with top comments (like Reddit enrichment)
-- [x] HN runs in parallel with Reddit/X/YouTube (no serial bottleneck)
-- [x] Scoring uses standard 45/25/30 weights with engagement formula tuned for HN metrics
-- [x] Stats block shows: `├─ 🟡 HN: {N} stories │ {N} points │ {N} comments`
-- [x] `--search=hn` works to run HN only; `--search=reddit,hn` works for combos
-- [x] `--quick`, `--deep` flags adjust HN result count (15/30/60)
-- [x] All existing tests still pass
-- [x] New `tests/test_hackernews.py` with tests for parse, normalize, score, enrichment
-- [x] No API key required — works out of the box
-- [x] Cross-source URL dedup: HN stories linking to same URL as web results get deduped
-- [x] SKILL.md updated with HN in stats block, security section, and citation priority
-
-## Dependencies & Risks
-
-**Low risk:**
-- Algolia HN API is free, public, no auth, well-established (used since 2014)
-- No new dependencies — uses existing `http.py` (stdlib urllib)
-- Pattern is identical to YouTube source (simplest existing source)
-
-**Medium risk:**
-- Algolia has no officially documented rate limit, but aggressive use could get throttled
- - Mitigation: Existing `http.py` exponential backoff handles 429s
- - Default depth only requests 30 items (1 API call for search + N for enrichment)
-- Comment enrichment adds N API calls (one per story) which could slow down quick mode
- - Mitigation: Limit enrichment to top 3/5/10 stories by depth; use ThreadPoolExecutor
-
-**Compatibility:**
-- HN always available — doesn't break anything when other sources are missing
-- Existing `--search` flag needs extension but is backward-compatible
-
-## Sources & References
-
-- PR #26 by @wkbaran: [HN implementation reference](https://github.com/mvanhorn/last30days-skill/pull/26)
-- Algolia HN API docs: `https://hn.algolia.com/api`
-- Existing patterns: `scripts/lib/youtube_yt.py` (simplest source), `scripts/lib/openai_reddit.py` (enrichment pattern)
-- Scoring reference: `scripts/lib/score.py:compute_reddit_engagement_raw()`
-- Schema reference: `scripts/lib/schema.py:RedditItem` (closest analog to HN)
diff --git a/docs/plans/2026-02-24-fix-hn-ordering-and-emoji-plan.md b/docs/plans/2026-02-24-fix-hn-ordering-and-emoji-plan.md
deleted file mode 100644
index bf863d6..0000000
--- a/docs/plans/2026-02-24-fix-hn-ordering-and-emoji-plan.md
+++ /dev/null
@@ -1,86 +0,0 @@
----
-title: "fix: HN ordering and stats block emoji formatting"
-type: fix
-status: completed
-date: 2026-02-24
----
-
-# fix: HN Ordering and Stats Block Emoji Formatting
-
-## Overview
-
-HN source appears before YouTube in several places (stats block, sort priority, source status) but should be last among the main sources (after YouTube, before Web). The test skill SKILL.md also has plain ASCII instead of box-drawing characters and colored circle emojis.
-
-Desired order everywhere: Reddit > X > YouTube > HN > Web
-
-## Problem Statement / Motivation
-
-When testing `/last30daysHN`, the stats block output shows:
-- No ✅ emoji, no 🟠 🔵 🟡 🔴 🌐 🗣️ colored circles
-- Plain `|-` instead of `├─` and `|` instead of `│`
-- HN appears before YouTube in the stats
-
-The canonical format (established in commit `7c36866`) is:
-```
----
-✅ All agents reported back!
-├─ 🟠 Reddit: {N} threads │ {N} upvotes │ {N} comments
-├─ 🔵 X: {N} posts │ {N} likes │ {N} reposts
-├─ 🔴 YouTube: {N} videos │ {N} views │ {N} with transcripts
-├─ 🟡 HN: {N} stories │ {N} points │ {N} comments
-├─ 🌐 Web: {N} pages (supplementary)
-└─ 🗣️ Top voices: @{handle1} ({N} likes), @{handle2} │ r/{sub1}, r/{sub2}
----
-```
-
-## Technical Approach
-
-### Files to Modify
-
-#### `scripts/lib/score.py` - sort_items()
-
-- [x] Swap HN and YouTube priority in `sort_items()`:
- - YouTube: priority 2 (was 3)
- - HN: priority 3 (was 2)
-
-#### `scripts/lib/render.py` - render_source_status()
-
-- [x] Move HN section (lines ~318-324) to AFTER YouTube section (lines ~326-334)
-- [x] Verify render_compact() already has correct order (YouTube before HN) - no change expected
-
-#### `SKILL.md` (private repo root)
-
-- [x] Move the 🟡 HN stats line AFTER the 🔴 YouTube stats line in the template
-- [x] Move HN line after YouTube in the footer summary template too
-
-#### `~/.claude/skills/last30daysHN/SKILL.md` (test skill)
-
-- [x] Restore full emoji + box-drawing stats template with correct ordering
-- [x] Use canonical format: ✅ ├─ 🟠 🔵 🔴 🟡 🌐 🗣️ └─ │
-
-#### `scripts/lib/ui.py` - show_complete()
-
-- [x] Move HN output after YouTube in both TTY and non-TTY code paths
-
-### Files that are already correct (no change needed)
-
-- `render_compact()` in render.py - already YouTube before HN
-- `render_context_snippet()` - score-based ordering, no fixed order
-- `last30days.py` pipeline - processing order doesn't affect display
-
-## Acceptance Criteria
-
-- [x] Stats block shows: Reddit > X > YouTube > HN > Web (in that order)
-- [x] Stats block has all emojis: ✅ 🟠 🔵 🔴 🟡 🌐 🗣️
-- [x] Stats block uses box-drawing chars: ├─ └─ │
-- [x] sort_items() tiebreaker: YouTube before HN
-- [x] render_source_status() shows YouTube before HN
-- [x] ui.py show_complete() shows YouTube before HN
-- [x] All existing tests still pass
-- [x] Run sync.sh to deploy after fixes
-
-## Sources & References
-
-- Canonical emoji format established in commit `7c36866` ("Fix v2 output quality")
-- YouTube emoji added in commit `c66ca7f` ("feat: Add YouTube as 4th research source")
-- HN added in commit `38a7ea2` ("feat(hackernews): add Hacker News as 5th research source")
diff --git a/docs/plans/2026-02-25-analysis-cross-source-comparison-plan.md b/docs/plans/2026-02-25-analysis-cross-source-comparison-plan.md
deleted file mode 100644
index 013fbe2..0000000
--- a/docs/plans/2026-02-25-analysis-cross-source-comparison-plan.md
+++ /dev/null
@@ -1,3348 +0,0 @@
----
-title: Cross-Source Comparison Analysis and Improvement Plan
-type: analysis
-status: active
-date: 2026-02-25
----
-
-# 15-Test Side-by-Side Comparison: Base vs HN vs CROSS
-
-## Executive Summary
-
-Ran 5 canonical topics through all 3 skill versions (Base, HN, CROSS) for 15 total full last30days runs. **CROSS wins every topic and every category.** YouTube dynamic scoring is the biggest differentiator - correctly promoting relevant videos and demoting off-topic ones. Cross-source linking works but is hampered by a too-strict threshold (3 items linked vs 26 possible with the hybrid fix). HN adds strong value for tech topics.
-
-**Overall Scores (weighted: 25% diversity + 25% score quality + 25% relevance accuracy + 25% bonus features):**
-
-| Version | Total | Avg/Topic | Win Rate |
-|---------|-------|-----------|----------|
-| **CROSS** | **370.1** | **74.0** | **5/5 topics** |
-| HN | 315.5 | 63.1 | 0/5 |
-| Base | 307.0 | 61.4 | 0/5 |
-
-## Test Configuration
-
-- **Versions:** Base (427a4e4, pre-HN), HN (main/f60a435), CROSS (feat/youtube-relevance-cross-source/0591f55)
-- **Mode:** `--quick --emit=json`
-- **Ordering:** Topic-sequential (all 3 versions of each topic back-to-back)
-- **Date:** 2026-02-25
-- **`__pycache__` cleanup:** Between every git checkout
-- **Source routing:** Identical across all 15 runs (verified via --diagnose)
-- **Duration:** ~11 minutes for all 15 runs
-
-### 5 Test Topics
-
-| # | Topic | Category |
-|---|-------|----------|
-| 1 | "Claude Code skills and MCP servers" | Developer tools |
-| 2 | "Seedance AI video generation" | Creative AI |
-| 3 | "M4 MacBook Pro review" | Consumer tech |
-| 4 | "best rap songs 2026" | Pop culture |
-| 5 | "React vs Svelte 2026" | Framework debate |
-
----
-
-## Full Results: Source Coverage (15 runs)
-
-| Topic | Version | Reddit | X | YouTube | HN | Web | Total |
-|-------|---------|--------|---|---------|----|----|-------|
-| Claude Code | base | 4 | 11 | 10 | 0 | 0 | 25 |
-| Claude Code | hn | 3 | 12 | 10 | 15 | 0 | 40 |
-| Claude Code | cross | 10 | 12 | 10 | 15 | 0 | 47 |
-| Seedance | base | 16 | 11 | 10 | 0 | 0 | 37 |
-| Seedance | hn | 20 | 11 | 10 | 15 | 0 | 56 |
-| Seedance | cross | 19 | 11 | 10 | 15 | 0 | 55 |
-| MacBook | base | 3 | 12 | 10 | 0 | 0 | 25 |
-| MacBook | hn | 3 | 12 | 10 | 0 | 0 | 25 |
-| MacBook | cross | 3 | 12 | 10 | 0 | 0 | 25 |
-| Rap songs | base | 6 | 12 | 3 | 0 | 0 | 21 |
-| Rap songs | hn | 10 | 12 | 3 | 0 | 0 | 25 |
-| Rap songs | cross | 11 | 12 | 3 | 0 | 0 | 26 |
-| React/Svelte | base | 4 | 9 | 10 | 0 | 0 | 23 |
-| React/Svelte | hn | 3 | 9 | 10 | 0 | 0 | 22 |
-| React/Svelte | cross | 6 | 9 | 10 | 0 | 0 | 25 |
-
-**Key observations:**
-- HN adds 15 items for Claude Code and Seedance (tech/AI), 0 for MacBook/Rap/React (non-HN audience)
-- Reddit counts vary slightly between runs due to API non-determinism (e.g., Claude Code: 4 base vs 10 cross)
-- X and YouTube are highly consistent across versions (same search code, similar results)
-
----
-
-## Full Results: YouTube Relevance Scores (15 runs)
-
-| Topic | Version | Min | Avg | Max | Scores |
-|-------|---------|-----|-----|-----|--------|
-| Claude Code | base | 0.70 | 0.70 | 0.70 | all 0.7 (hardcoded) |
-| Claude Code | hn | 0.70 | 0.70 | 0.70 | all 0.7 (hardcoded) |
-| Claude Code | cross | 0.60 | 0.70 | 0.80 | [0.6, 0.8, 0.8, 0.6, 0.6, 0.8, 0.6, 0.8, 0.8, 0.6] |
-| Seedance | base | 0.70 | 0.70 | 0.70 | all 0.7 (hardcoded) |
-| Seedance | hn | 0.70 | 0.70 | 0.70 | all 0.7 (hardcoded) |
-| Seedance | cross | 0.25 | 0.68 | 1.00 | [0.75, 0.75, 0.75, 0.75, 0.75, 0.75, 1.0, 0.5, 0.5, 0.25] |
-| MacBook | base | 0.70 | 0.70 | 0.70 | all 0.7 (hardcoded) |
-| MacBook | hn | 0.70 | 0.70 | 0.70 | all 0.7 (hardcoded) |
-| MacBook | cross | 0.75 | 0.82 | 1.00 | [0.75, 1.0, 1.0, 0.75, 1.0, 0.75, 0.75, 0.75, 0.75, 0.75] |
-| Rap songs | base | 0.70 | 0.70 | 0.70 | all 0.7 (hardcoded) |
-| Rap songs | hn | 0.70 | 0.70 | 0.70 | all 0.7 (hardcoded) |
-| Rap songs | cross | 0.33 | 0.78 | 1.00 | [1.0, 1.0, 0.33] |
-| React/Svelte | base | 0.70 | 0.70 | 0.70 | all 0.7 (hardcoded) |
-| React/Svelte | hn | 0.70 | 0.70 | 0.70 | all 0.7 (hardcoded) |
-| React/Svelte | cross | 0.25 | 0.53 | 1.00 | [0.75, 0.75, 0.75, 1.0, 0.75, 0.25, 0.25, 0.25, 0.25, 0.25] |
-
-### YouTube Matched-Item Analysis (Base vs CROSS, same videos)
-
-Videos matched by title across Base and CROSS runs of each topic:
-
-**Claude Code** (10/10 matched):
-- Videos with "MCP" or "Skills" + "Claude Code" in title: promoted +4 to +5 score points (0.7 -> 0.8 relevance)
-- Videos with only "Skills" or "MCP" (not both): demoted -4 to -5 points (0.7 -> 0.6)
-- Correct behavior: videos matching more query keywords rank higher
-
-**Seedance** (8/9 matched):
-- "Ultimate AI Video Generation - Seedance 2.0 PREVIEW": promoted +11 points (0.7 -> 1.0) - exact match
-- "Seedance 2.0 Changes Filmmaking Forever": demoted -22 points (0.7 -> 0.25) - title about filmmaking, not AI video generation
-- Correct behavior: off-topic videos correctly penalized
-
-**MacBook** (8/10 matched):
-- "M4 MacBook Pro Review - Things to Know": promoted +8 points (0.7 -> 1.0) - exact match
-- "The Macbook Pro M4 is Insane.": demoted -8 points (0.7 -> 0.75) - less specific match
-- Correct behavior: specific reviews ranked above hype videos
-
-**Rap songs** (3/3 matched):
-- "New Rap Songs 2026 Mix February": promoted +13 points (0.7 -> 1.0) - exact match
-- "Lit Hip Hop Mix 2026": demoted -16 points (0.7 -> 0.33) - "hip hop" != "rap" in token matching
-- Borderline: "hip hop" should probably match "rap" (improvement opportunity)
-
-**React/Svelte** (9/10 matched):
-- "Vue vs React vs Svelte (2026)": promoted +9 points (0.7 -> 1.0) - contains both keywords
-- "I Was Wrong About Svelte...": demoted -22 points (0.7 -> 0.25) - only has "Svelte", missing "React"
-- "React wants to win you back...": demoted -21 points (0.7 -> 0.25) - only has "React", missing "Svelte"
-- Correct behavior: comparison videos rank above single-framework videos
-
----
-
-## Full Results: Cross-Source Links (CROSS only)
-
-| Topic | Items Linked | Link Pairs | Example |
-|-------|-------------|------------|---------|
-| Claude Code | 0 | 0 | (none - max Jaccard was 0.37) |
-| Seedance | 3 | 2 | Reddit-HN: "ByteDance launches Seedance 2.0" <-> "Seedance 2 Video Generator" |
-| MacBook | 0 | 0 | (none - max Jaccard was 0.35) |
-| Rap songs | 0 | 0 | (no cross-source overlap at all) |
-| React/Svelte | 0 | 0 | (none - max Jaccard was 0.30) |
-
-**Total: 3 items linked out of ~178 across 5 tests.** The 0.5 char-trigram Jaccard threshold is far too strict.
-
-### With hybrid fix (token+trigram Jaccard at 0.40):
-
-| Topic | Current Links | Hybrid Links | Items Tagged |
-|-------|--------------|-------------|-------------|
-| Claude Code | 0 | 3 pairs | 5 items |
-| Seedance | 2 | 19 pairs | 14 items |
-| MacBook | 0 | 4 pairs | 5 items |
-| Rap songs | 0 | 0 pairs | 0 items |
-| React/Svelte | 0 | 1 pair | 2 items |
-| **Total** | **2** | **27 pairs** | **26 items** |
-
----
-
-## Full Results: Score Distributions (Top 10)
-
-| Topic | Version | Top10 Avg | Top10 Median | #1 Score | #1 Source |
-|-------|---------|-----------|-------------|----------|-----------|
-| Claude Code | base | 73.3 | 74 | 86 | x |
-| Claude Code | hn | 74.6 | 74 | 90 | hackernews |
-| Claude Code | cross | 78.3 | 77 | 90 | hackernews |
-| Seedance | base | 75.0 | 75 | 86 | x |
-| Seedance | hn | 76.7 | 76 | 86 | x |
-| Seedance | cross | 76.5 | 75 | 86 | x |
-| MacBook | base | 66.5 | 64 | 86 | x |
-| MacBook | hn | 66.5 | 64 | 86 | x |
-| MacBook | cross | 66.7 | 64 | 86 | x |
-| Rap songs | base | 64.7 | 64 | 86 | x |
-| Rap songs | hn | 66.8 | 64 | 86 | x |
-| Rap songs | cross | 66.2 | 64 | 86 | x |
-| React/Svelte | base | 60.0 | 61 | 76 | x |
-| React/Svelte | hn | 59.8 | 60 | 76 | x |
-| React/Svelte | cross | 59.1 | 57 | 76 | x |
-
-**Key observations:**
-- CROSS has highest top-10 average for Claude Code (78.3 vs 73.3 base) thanks to YouTube re-scoring promoting relevant videos
-- HN #1 item (score 90) overtakes X #1 (score 86) for Claude Code - HN adds high-value technical content
-- MacBook/Rap/React scores are similar across all versions (no HN contribution, YouTube changes are small)
-
----
-
-## Full Results: Source Diversity (Shannon Entropy, Top 15)
-
-| Topic | Base | HN | CROSS | Best |
-|-------|------|-----|-------|------|
-| Claude Code | 1.34 | 1.23 | 1.75 | **CROSS** |
-| Seedance | 1.34 | 1.43 | 1.64 | **CROSS** |
-| MacBook | 0.72 | 0.72 | 0.72 | tie |
-| Rap songs | 0.91 | 1.24 | 1.10 | **HN** |
-| React/Svelte | 1.43 | 1.40 | 1.40 | **Base** |
-
-CROSS wins diversity for tech topics because YouTube re-scoring redistributes which items rank in the top 15, creating a more balanced source mix.
-
----
-
-## Full Results: HN Value-Add
-
-**Claude Code (HN version):** 15 HN items, **5 in top 10**
-- #1 (score:90) "Show HN: I built a 55K-word email marketing knowledge base and Claude Code skill"
-- #6 (score:74) "Show HN: AI Marketing Skills for Claude Code"
-- #7 (score:70) "Show HN: Poncho, a general agent harness built for the web"
-- #8 (score:69) "Show HN: Axon - A Kubernetes-native framework for AI coding agents"
-- #9 (score:65) "Show HN: Turn Claude Code or Codex into proactive, autonomous 24/7 AI agents"
-
-**Claude Code (CROSS version):** 15 HN items, **2 in top 10**
-- #1 (score:90) "Show HN: I built a 55K-word email marketing knowledge base..."
-- #9 (score:74) "Show HN: AI Marketing Skills for Claude Code"
-
-(Fewer HN in CROSS top 10 because CROSS has more Reddit items (10 vs 3) displacing HN from rankings)
-
-**Seedance:** 1 HN item in top 10 (both HN and CROSS versions)
-- #8-10 (score:75) "Seedance 2.0 preview: The best video model of 2026"
-
-**MacBook, Rap, React/Svelte:** 0 HN items (expected - non-HN topics)
-
----
-
-## Scoring Verdict
-
-Weighted scoring: 25% diversity + 25% score quality + 25% relevance accuracy + 25% bonus features
-
-| Topic | Base | HN | CROSS | Winner |
-|-------|------|-----|-------|--------|
-| Claude Code | 64.4 | 69.3 | **73.9** | CROSS |
-| Seedance | 64.9 | 63.2 | **84.4** | CROSS |
-| MacBook | 59.7 | 59.7 | **66.0** | CROSS |
-| Rap songs | 55.5 | 61.3 | **71.5** | CROSS |
-| React/Svelte | 62.5 | 62.0 | **74.4** | CROSS |
-| **TOTAL** | **307.0** | **315.5** | **370.1** | **CROSS** |
-
-### Category Winners
-
-| Category | Winner | Scores (Base / HN / CROSS) |
-|----------|--------|---------------------------|
-| Tech (Claude Code + React/Svelte) | **CROSS** | 127.0 / 131.3 / **148.3** |
-| Consumer/Creative (MacBook + Seedance) | **CROSS** | 124.5 / 122.9 / **150.4** |
-| Culture (Rap songs) | **CROSS** | 55.5 / 61.3 / **71.5** |
-
-**CROSS wins all 5 topics and all 3 categories. No regressions found.**
-
-### Why CROSS Wins
-
-1. **YouTube relevance accuracy** is the single biggest differentiator. It correctly promotes on-topic videos and demotes off-topic ones, changing rankings in every topic.
-2. **Source diversity** improves for tech topics because YouTube re-scoring redistributes which items make the top 15.
-3. **Cross-source linking** adds value where it fires (Seedance), but is currently too conservative to help in most cases.
-4. **HN source** (shared with HN version) adds strong value for tech topics.
-5. **No score regressions** - non-HN topics (MacBook, Rap, React) perform at least as well as Base.
-
-### Why CROSS Is Not Yet the GOAT
-
-1. **Cross-source linking barely works** - 3 items linked out of ~178 (1.7%). Should be ~26 items (14.6%) with hybrid fix.
-2. **Cross-ref rendering is cryptic** - `[xref: HN5, HN4]` means nothing to users. Should show source names.
-3. **"hip hop" doesn't match "rap"** in YouTube relevance scoring - needs synonym awareness.
-4. **React/Svelte returns 0 HN items** - should have some, needs HN search tuning.
-
----
-
-## GOAT Improvement Plan: Make CROSS the Best Version
-
-### Priority 1: Fix Cross-Source Linking (HIGH IMPACT)
-
-Goes from 3 to 26 linked items across 5 tests.
-
-**Changes to `scripts/lib/dedupe.py`:**
-
-```python
-# Add token Jaccard function
-STOPWORDS = frozenset({
- 'the', 'a', 'an', 'to', 'for', 'how', 'is', 'in', 'of', 'on',
- 'and', 'with', 'from', 'by', 'at', 'this', 'that', 'it', 'my',
- 'your', 'i', 'me', 'we', 'you', 'what', 'are', 'do', 'can',
- 'its', 'be', 'or', 'not', 'no', 'so', 'if', 'but', 'about',
- 'all', 'just', 'get', 'has', 'have', 'was', 'will', 'show', 'hn',
-})
-
-def _tokenize_for_xref(text: str) -> Set[str]:
- words = re.sub(r'[^\w\s]', ' ', text.lower()).split()
- return {w for w in words if w not in STOPWORDS and len(w) > 1}
-
-def _token_jaccard(text_a: str, text_b: str) -> float:
- tokens_a = _tokenize_for_xref(text_a)
- tokens_b = _tokenize_for_xref(text_b)
- if not tokens_a or not tokens_b:
- return 0.0
- intersection = len(tokens_a & tokens_b)
- union = len(tokens_a | tokens_b)
- return intersection / union if union else 0.0
-
-def _hybrid_similarity(text_a: str, text_b: str) -> float:
- trigram_sim = jaccard_similarity(get_ngrams(text_a), get_ngrams(text_b))
- token_sim = _token_jaccard(text_a, text_b)
- return max(trigram_sim, token_sim)
-```
-
-- [ ] Add `_tokenize_for_xref()`, `_token_jaccard()`, `_hybrid_similarity()` to dedupe.py
-- [ ] Change `cross_source_link()` to use `_hybrid_similarity()` instead of `jaccard_similarity()`
-- [ ] Lower threshold from 0.50 to 0.40
-- [ ] Strip "Show HN:" prefix from HN titles in `_get_cross_source_text()`
-- [ ] Add tests for the new hybrid functions
-
-### Priority 2: Human-Readable Cross-Ref Tags (MEDIUM IMPACT)
-
-Change from `[xref: HN5, HN4]` to `[also on: HN, Reddit]`.
-
-**Changes to `scripts/lib/render.py`:**
-
-```python
-def _xref_tag(item) -> str:
- refs = getattr(item, 'cross_refs', None)
- if not refs:
- return ""
- # Map IDs to source names
- source_names = set()
- for ref_id in refs:
- if ref_id.startswith('R'):
- source_names.add('Reddit')
- elif ref_id.startswith('X'):
- source_names.add('X')
- elif ref_id.startswith('YT'):
- source_names.add('YouTube')
- elif ref_id.startswith('HN'):
- source_names.add('HN')
- elif ref_id.startswith('W'):
- source_names.add('Web')
- return f" [also on: {', '.join(sorted(source_names))}]"
-```
-
-- [ ] Update `_xref_tag()` in render.py to show source names instead of IDs
-- [ ] Add a cross-ref summary count to the report footer (e.g., "3 topics discussed across multiple platforms")
-
-### Priority 3: YouTube Synonym Awareness (LOW IMPACT)
-
-"hip hop" should match "rap" in relevance scoring. Add a small synonym map.
-
-**Changes to `scripts/lib/youtube_yt.py`:**
-
-```python
-SYNONYMS = {
- 'hip': {'rap', 'hiphop'},
- 'rap': {'hip', 'hop', 'hiphop'},
- 'js': {'javascript'},
- 'javascript': {'js'},
- 'ts': {'typescript'},
- 'typescript': {'ts'},
- 'ai': {'artificial', 'intelligence'},
- 'ml': {'machine', 'learning'},
-}
-
-def _tokenize(text: str) -> Set[str]:
- words = re.sub(r'[^\w\s]', ' ', text.lower()).split()
- tokens = {w for w in words if w not in STOPWORDS and len(w) > 1}
- # Expand synonyms
- expanded = set(tokens)
- for t in tokens:
- if t in SYNONYMS:
- expanded.update(SYNONYMS[t])
- return expanded
-```
-
-- [ ] Add SYNONYMS dict to youtube_yt.py
-- [ ] Expand tokens with synonyms in `_tokenize()`
-- [ ] Add tests for synonym matching
-
-### Priority 4: HN Search Broadening (LOW IMPACT)
-
-React/Svelte returned 0 HN items despite being a common HN topic.
-
-- [ ] Investigate HN Algolia query construction for framework topics
-- [ ] Consider splitting multi-word topics into OR queries (e.g., "React" OR "Svelte")
-- [ ] Test with broader HN search terms
-
-### Not Recommended
-
-- **Semantic similarity (embeddings)** - Token Jaccard at 0.40 already captures the right matches with zero dependencies
-- **LLM-based matching** - Overkill for title comparison. Save LLM tokens for synthesis.
-- **URL deduplication** - Different platforms rarely share URLs in titles
-
----
-
-## Raw Data
-
-All 15 JSON result files saved at `/tmp/last30days-comparison/full/`:
-
-| File | Size | Topic | Version |
-|------|------|-------|---------|
-| base-1-claude-code.json | 26KB | Claude Code | Base |
-| hn-1-claude-code.json | 38KB | Claude Code | HN |
-| cross-1-claude-code.json | 43KB | Claude Code | CROSS |
-| base-2-seedance.json | 34KB | Seedance | Base |
-| hn-2-seedance.json | 49KB | Seedance | HN |
-| cross-2-seedance.json | 49KB | Seedance | CROSS |
-| base-3-macbook.json | 26KB | MacBook | Base |
-| hn-3-macbook.json | 26KB | MacBook | HN |
-| cross-3-macbook.json | 26KB | MacBook | CROSS |
-| base-4-rap.json | 23KB | Rap songs | Base |
-| hn-4-rap.json | 25KB | Rap songs | HN |
-| cross-4-rap.json | 27KB | Rap songs | CROSS |
-| base-5-react-svelte.json | 25KB | React/Svelte | Base |
-| hn-5-react-svelte.json | 24KB | React/Svelte | HN |
-| cross-5-react-svelte.json | 27KB | React/Svelte | CROSS |
-
-Runner script: `/tmp/last30days-comparison/run-15-tests.sh`
-
----
-
-## Full Research Output (15 runs)
-
-The actual research results each version produced - the items, scores, titles, URLs, and insights that would be fed to Claude for synthesis.
-
-### Base - Claude Code skills and MCP servers
-
-#### Reddit Discussions
-
-**R2** (score:76) r/ClaudeCode (2026-02-22) [0 pts, 0 comments]
- We 3x'd our team's Claude Code skill usage in 2 weeks — here's how
- https://www.reddit.com/r/ClaudeCode/comments/1rbr5t7/we_3xd_our_teams_claude_code_skill_usage_in_2/
- *Discussion of Claude Code skills adoption/usage, plus mentions syncing skills/hooks/MCP configs.*
-
-**R4** (score:75) r/ClaudeCode (2026-02-23) [0 pts, 0 comments]
- ClaudeInOne — a full framework for Claude Code, installed in one command
- https://www.reddit.com/r/ClaudeCode/comments/1rcbria/claudeinone_a_full_framework_for_claude_code/
- *Framework bundling many Claude Code skills/agents/commands; lots of skills-related discussion.*
-
-**R1** (score:74) r/ClaudeCode (2026-02-18) [0 pts, 0 comments]
- Self-improvement Loop: My favorite Claude Code Skill
- https://www.reddit.com/r/ClaudeCode/comments/1r89084/selfimprovement_loop_my_favorite_claude_code_skill/
- *Directly about a Claude Code Skill (custom skill, how it works, SKILL.md).*
-
-**R3** (score:66) r/ClaudeCode (2026-02-10) [0 pts, 0 comments]
- I built 12 SEO skills for Claude Code (open source)
- https://www.reddit.com/r/ClaudeCode/comments/1r0l549/i_built_12_seo_skills_for_claude_code_open_source/
- *Concrete example of building and sharing Claude Code skills.*
-
-#### X/Twitter Posts
-
-**X6** (score:86) @zeeg (2026-02-25) [4 likes, 1 rp]
- @adamwathan Codex has been crushing it for me with implicit skill usage to the point I was shocked
-
-Claude Code I resorted to explicit mentions and I
- https://x.com/zeeg/status/2026745680195367091
- **
-
-**X4** (score:80) @ihtesham2005 (2026-02-25) [6 likes, 0 rp]
- 🚨 Anthropic just open-sourced the exact Skills library their own engineers use internally.
-
-Stop building Claude workflows from scratch.
-
-These are pl
- https://x.com/ihtesham2005/status/2026752089473314975
- **
-
-**X9** (score:74) @DevTenta (2026-02-25) [4 likes, 0 rp]
- Day 7 building my first B2C app
-
-Been setting everything up this week. Manually built 5 agents to help me in the early stages and set up the Claude Co
- https://x.com/DevTenta/status/2026742028730527786
- **
-
-**X8** (score:64) @JorgeJaramillo (2026-02-25) [0 likes, 0 rp]
- @Jompiras Claude code + skills. Muy breve.
- https://x.com/JorgeJaramillo/status/2026743135435362649
- **
-
-**X5** (score:64) @arpan7sarkar (2026-02-25) [0 likes, 0 rp]
- @tom_doerr 135 agents, 35 curated skills, 121 plugins and 6 MCP configs all in one Claude Code toolkit is absolutely insane value 🤯 this is the kind o
- https://x.com/arpan7sarkar/status/2026746479369662551
- **
-
-**X7** (score:64) @arpan7sarkar (2026-02-25) [0 likes, 0 rp]
- @tom_doerr A curated collection of Claude Skills organized by category is exactly what power users have been waiting for 🤩 document skills, code tools
- https://x.com/arpan7sarkar/status/2026745559986614335
- **d
-
-**X2** (score:64) @yasuky (2026-02-25) [0 likes, 0 rp]
- Claude CodeのSkillsを作成例から徹底理解する https://t.co/vZTseio2Ii
- https://x.com/yasuky/status/2026754773786226708
- **
-
-**X1** (score:64) @matgoldsborough (2026-02-25) [0 likes, 0 rp]
- How: I told Claude Code "build me a CRM." Our builder skill generated schemas, skills, server, seed data. I pointed Claude Desktop at it. Contacts, le
- https://x.com/matgoldsborough/status/2026755742737510749
- **
-
-**X10** (score:64) @0x_Kapoor (2026-02-25) [0 likes, 0 rp]
- I can build another open claw, or claude bot, or even a messaging app in a nice prompt and although having engineering skills, I can push the code on
- https://x.com/0x_Kapoor/status/2026738663321907214
- **
-
-**X11** (score:56) @ghumare64 (2026-02-25) [1 likes, 0 rp]
- 1. I use https://t.co/NtFYJEiFs9 inspired by @bcherny
-2. I use https://t.co/EwnmyWNFqn to sync skills and scan skills from the marketplace
-3. I chat
- https://x.com/ghumare64/status/2026736176695246966
- **
-
-**X12** (score:56) @hamen (2026-02-25) [1 likes, 0 rp]
- The 'vibe coding' revolution is forcing a reckoning.
-
-Developers with 15+ years experience are either embracing AI to expand their reach or watching t
- https://x.com/hamen/status/2026734075243790396
- **
-
-#### YouTube Videos
-
-**X8afcX2s2Mo** (score:74, rel:0.70) Grace Leung (2026-02-21) [47474 views, 1900 likes]
- Claude Skills: Build Your First AI Marketing Team in 16 Minutes (Claude Code)
- https://www.youtube.com/watch?v=X8afcX2s2Mo
- *YouTube video about claude code skills and mcp servers*
- Transcript: Every marketer I know is stretch themed. Too many channels, too many deliverables, never enough time, whether you're run...
-
-**0J2_YGuNrDo** (score:61, rel:0.70) Grace Leung (2025-12-16) [155806 views, 4760 likes]
- Claude Code just Built me an AI Agent Team (Claude Code + Skills + MCP)
- https://www.youtube.com/watch?v=0J2_YGuNrDo
- *YouTube video about claude code skills and mcp servers*
- Transcript: I use clot more than any other AI but even I dismiss Clark as just for developers and I was wrong after using myself I a...
-
-**vIUJ4Hd7be0** (score:59, rel:0.70) Leon van Zyl (2026-02-09) [25538 views, 699 likes]
- Claude Code Skills - The Only Tutorial You Need
- https://www.youtube.com/watch?v=vIUJ4Hd7be0
- *YouTube video about claude code skills and mcp servers*
-
-**Gqh_KdHP1Xk** (score:56, rel:0.70) Robin Ebers (2025-08-21) [90281 views, 2719 likes]
- 8 MCP Servers That Make Claude Code 10x Better
- https://www.youtube.com/watch?v=Gqh_KdHP1Xk
- *YouTube video about claude code skills and mcp servers*
- Transcript: Over 90% of all MCP servers are complete overhyped garbage. But some of them are not only worth it, they can be complete...
-
-**901VMcZq8X4** (score:51, rel:0.70) Sean Kochel (2025-10-21) [44602 views, 1356 likes]
- These 5 Claude Code Skills Are Your New Unfair Advantage
- https://www.youtube.com/watch?v=901VMcZq8X4
- *YouTube video about claude code skills and mcp servers*
-
-**l7qVtHpctic** (score:47, rel:0.70) Kenny Liao (2026-01-17) [21765 views, 728 likes]
- Claude Code's MCP Problem Just Got Fixed
- https://www.youtube.com/watch?v=l7qVtHpctic
- *YouTube video about claude code skills and mcp servers*
-
-**M5CsRj6zSCA** (score:41, rel:0.70) Eric Tech (2025-12-12) [11286 views, 266 likes]
- 5 MCP Servers That Make Claude Code 10x More Powerful (Full-Stack App Build)
- https://www.youtube.com/watch?v=M5CsRj6zSCA
- *YouTube video about claude code skills and mcp servers*
-
-**qthyl0GCpDo** (score:35, rel:0.70) Postman (2025-11-20) [7045 views, 105 likes]
- Claude Skills vs MCP: What’s the Difference and When to Use Each?
- https://www.youtube.com/watch?v=qthyl0GCpDo
- *YouTube video about claude code skills and mcp servers*
-
-**ZroGqu7GyXM** (score:34, rel:0.70) Solo Swift Crafter (2025-10-27) [4880 views, 98 likes]
- Claude Code Skills vs MCP vs Sub Agents: What Works for Solo Devs?
- https://www.youtube.com/watch?v=ZroGqu7GyXM
- *YouTube video about claude code skills and mcp servers*
-
-**jzf7DQa2CAc** (score:31, rel:0.70) Matt Kuda (2026-01-19) [2547 views, 87 likes]
- How I Use Claude Code With Skills, MCP, Agents & Plugins
- https://www.youtube.com/watch?v=jzf7DQa2CAc
- *YouTube video about claude code skills and mcp servers*
-
-**Total: Reddit=4, X=11, YouTube=10, HN=0, Web=0**
-
-### HN - Claude Code skills and MCP servers
-
-#### Reddit Discussions
-
-**R1** (score:0) r/ClaudeAI (2025-06-17) [0 pts, 0 comments]
- Claude code and mcp servers
- https://www.reddit.com/r/ClaudeAI/comments/1ldm75a/claude_code_and_mcp_servers/
- *Directly about Claude Code failing to load/use MCP servers; troubleshooting discussion.*
-
-**R2** (score:0) r/ClaudeCode (2025-10-23) [0 pts, 0 comments]
- Claude Skills: is it a big deal?
- https://www.reddit.com/r/ClaudeCode/comments/1odtykp/claude_skills_is_it_a_big_deal/
- *Focused on the Claude (Claude Code) “Skills” feature; compares to AGENTS.md/slash commands/MCPs.*
-
-**R3** (score:0) r/ClaudeAI (2025-10-25) [0 pts, 0 comments]
- I spent way too long cataloguing Claude Code tools. Here’s everything I found (with actual links)
- https://www.reddit.com/r/ClaudeAI/comments/1ofltdr/i_spent_way_too_long_cataloguing_claude_code/
- *Tooling ecosystem post; explicitly mentions installing many plugins and MCP servers for Claude Code.*
-
-#### X/Twitter Posts
-
-**X5** (score:86) @zeeg (2026-02-25) [4 likes, 1 rp]
- @adamwathan Codex has been crushing it for me with implicit skill usage to the point I was shocked
-
-Claude Code I resorted to explicit mentions and I
- https://x.com/zeeg/status/2026745680195367091
- **
-
-**X3** (score:80) @ihtesham2005 (2026-02-25) [6 likes, 0 rp]
- 🚨 Anthropic just open-sourced the exact Skills library their own engineers use internally.
-
-Stop building Claude workflows from scratch.
-
-These are pl
- https://x.com/ihtesham2005/status/2026752089473314975
- **
-
-**X8** (score:74) @DevTenta (2026-02-25) [4 likes, 0 rp]
- Day 7 building my first B2C app
-
-Been setting everything up this week. Manually built 5 agents to help me in the early stages and set up the Claude Co
- https://x.com/DevTenta/status/2026742028730527786
- **
-
-**X7** (score:64) @JorgeJaramillo (2026-02-25) [0 likes, 0 rp]
- @Jompiras Claude code + skills. Muy breve.
- https://x.com/JorgeJaramillo/status/2026743135435362649
- **
-
-**X4** (score:64) @arpan7sarkar (2026-02-25) [0 likes, 0 rp]
- @tom_doerr 135 agents, 35 curated skills, 121 plugins and 6 MCP configs all in one Claude Code toolkit is absolutely insane value 🤯 this is the kind o
- https://x.com/arpan7sarkar/status/2026746479369662551
- **
-
-**X6** (score:64) @arpan7sarkar (2026-02-25) [0 likes, 0 rp]
- @tom_doerr A curated collection of Claude Skills organized by category is exactly what power users have been waiting for 🤩 document skills, code tools
- https://x.com/arpan7sarkar/status/2026745559986614335
- **
-
-**X2** (score:64) @yasuky (2026-02-25) [0 likes, 0 rp]
- Claude CodeのSkillsを作成例から徹底理解する https://t.co/vZTseio2Ii
- https://x.com/yasuky/status/2026754773786226708
- **
-
-**X12** (score:64) @zhuoyuan45514 (2026-02-25) [0 likes, 0 rp]
- Day 52 (01/21/2026) (Rest Day)
-
-> Go to New York + watch Broadway Show
-
-> Catch up on claude code updates on way back (2hrs)
- - Learn difference be
- https://x.com/zhuoyuan45514/status/2026733326631923776
- **
-
-**X1** (score:64) @matgoldsborough (2026-02-25) [0 likes, 0 rp]
- How: I told Claude Code "build me a CRM." Our builder skill generated schemas, skills, server, seed data. I pointed Claude Desktop at it. Contacts, le
- https://x.com/matgoldsborough/status/2026755742737510749
- **
-
-**X9** (score:64) @0x_Kapoor (2026-02-25) [0 likes, 0 rp]
- I can build another open claw, or claude bot, or even a messaging app in a nice prompt and although having engineering skills, I can push the code on
- https://x.com/0x_Kapoor/status/2026738663321907214
- **
-
-**X10** (score:56) @ghumare64 (2026-02-25) [1 likes, 0 rp]
- 1. I use https://t.co/NtFYJEiFs9 inspired by @bcherny
-2. I use https://t.co/EwnmyWNFqn to sync skills and scan skills from the marketplace
-3. I chat
- https://x.com/ghumare64/status/2026736176695246966
- **
-
-**X11** (score:56) @hamen (2026-02-25) [1 likes, 0 rp]
- The 'vibe coding' revolution is forcing a reckoning.
-
-Developers with 15+ years experience are either embracing AI to expand their reach or watching t
- https://x.com/hamen/status/2026734075243790396
- **
-
-#### YouTube Videos
-
-**X8afcX2s2Mo** (score:74, rel:0.70) Grace Leung (2026-02-21) [47474 views, 1900 likes]
- Claude Skills: Build Your First AI Marketing Team in 16 Minutes (Claude Code)
- https://www.youtube.com/watch?v=X8afcX2s2Mo
- *YouTube video about claude code skills and mcp servers*
-
-**0J2_YGuNrDo** (score:61, rel:0.70) Grace Leung (2025-12-16) [155806 views, 4760 likes]
- Claude Code just Built me an AI Agent Team (Claude Code + Skills + MCP)
- https://www.youtube.com/watch?v=0J2_YGuNrDo
- *YouTube video about claude code skills and mcp servers*
- Transcript: I use clot more than any other AI but even I dismiss Clark as just for developers and I was wrong after using myself I a...
-
-**fOxC44g8vig** (score:58, rel:0.70) Anthropic (2025-11-26) [129573 views, 2991 likes]
- Claude Agent Skills Explained
- https://www.youtube.com/watch?v=fOxC44g8vig
- *YouTube video about claude code skills and mcp servers*
- Transcript: Hi, my name is Otto and in this video we're going to discuss agent skills. Agents today are pretty intelligent, but they...
-
-**Gqh_KdHP1Xk** (score:56, rel:0.70) Robin Ebers (2025-08-21) [90281 views, 2719 likes]
- 8 MCP Servers That Make Claude Code 10x Better
- https://www.youtube.com/watch?v=Gqh_KdHP1Xk
- *YouTube video about claude code skills and mcp servers*
- Transcript: Over 90% of all MCP servers are complete overhyped garbage. But some of them are not only worth it, they can be complete...
-
-**421T2iWTQio** (score:51, rel:0.70) Kenny Liao (2025-10-24) [50477 views, 1010 likes]
- The Only Claude Skills Guide You Need (Beginner to Expert)
- https://www.youtube.com/watch?v=421T2iWTQio
- *YouTube video about claude code skills and mcp servers*
-
-**l7qVtHpctic** (score:47, rel:0.70) Kenny Liao (2026-01-17) [21765 views, 728 likes]
- Claude Code's MCP Problem Just Got Fixed
- https://www.youtube.com/watch?v=l7qVtHpctic
- *YouTube video about claude code skills and mcp servers*
-
-**M5CsRj6zSCA** (score:41, rel:0.70) Eric Tech (2025-12-12) [11287 views, 266 likes]
- 5 MCP Servers That Make Claude Code 10x More Powerful (Full-Stack App Build)
- https://www.youtube.com/watch?v=M5CsRj6zSCA
- *YouTube video about claude code skills and mcp servers*
-
-**qthyl0GCpDo** (score:35, rel:0.70) Postman (2025-11-20) [7045 views, 105 likes]
- Claude Skills vs MCP: What’s the Difference and When to Use Each?
- https://www.youtube.com/watch?v=qthyl0GCpDo
- *YouTube video about claude code skills and mcp servers*
-
-**ZroGqu7GyXM** (score:34, rel:0.70) Solo Swift Crafter (2025-10-27) [4885 views, 98 likes]
- Claude Code Skills vs MCP vs Sub Agents: What Works for Solo Devs?
- https://www.youtube.com/watch?v=ZroGqu7GyXM
- *YouTube video about claude code skills and mcp servers*
-
-**jzf7DQa2CAc** (score:31, rel:0.70) Matt Kuda (2026-01-19) [2547 views, 87 likes]
- How I Use Claude Code With Skills, MCP, Agents & Plugins
- https://www.youtube.com/watch?v=jzf7DQa2CAc
- *YouTube video about claude code skills and mcp servers*
-
-#### Hacker News Stories
-
-**HN1** (score:90) hn/cosmoblk (2026-02-21) [10 pts, 2 cmt]
- Show HN: I built a 55K-word email marketing knowledge base and Claude Code skill
- https://news.ycombinator.com/item?id=47096335
- *HN story about Show HN: I built a 55K-word email marketing knowledge base a*
- - So it's an AI spambot?
-
-**HN4** (score:74) hn/superamped (2026-02-25) [3 pts, 1 cmt]
- Show HN: AI Marketing Skills for Claude Code
- https://news.ycombinator.com/item?id=47154377
- *HN story about Show HN: AI Marketing Skills for Claude Code*
-
-**HN12** (score:70) hn/heycesr (2026-02-18) [4 pts, 2 cmt]
- Show HN: Poncho, a general agent harness built for the web
- https://news.ycombinator.com/item?id=47061949
- *HN story about Show HN: Poncho, a general agent harness built for the web*
- - I have been trying it and it's very cool!
-
-**HN11** (score:69) hn/gjkim042 (2026-02-24) [1 pts, 4 cmt]
- Show HN: Axon – A Kubernetes-native framework for AI coding agents
- https://news.ycombinator.com/item?id=47137491
- *HN story about Show HN: Axon – A Kubernetes-native framework for AI coding *
-
-**HN14** (score:65) hn/alternateman (2026-02-17) [3 pts, 2 cmt]
- Show HN: Turn Claude Code or Codex into proactive, autonomous 24/7 AI agents
- https://news.ycombinator.com/item?id=47054100
- *HN story about Show HN: Turn Claude Code or Codex into proactive, autonomou*
-
-**HN2** (score:64) hn/hauschildt (2026-02-19) [3 pts, 0 cmt]
- Show HN: Agent skills to build photo, video and design editors on the web
- https://news.ycombinator.com/item?id=47073035
- *HN story about Show HN: Agent skills to build photo, video and design edito*
-
-**HN8** (score:57) hn/barefootsanders (2026-02-25) [1 pts, 0 cmt]
- Show HN: Upjack – Declarative framework for building apps over MCP
- https://news.ycombinator.com/item?id=47157331
- *HN story about Show HN: Upjack – Declarative framework for building apps ov*
-
-**HN7** (score:56) hn/san-techie21 (2026-02-16) [1 pts, 1 cmt]
- Show HN: Gulama – Security-first open-source AI agent (OpenClaw alternative)
- https://news.ycombinator.com/item?id=47031982
- *HN story about Show HN: Gulama – Security-first open-source AI agent (OpenC*
-
-**HN15** (score:48) hn/martin-hall (2026-02-20) [1 pts, 0 cmt]
- AI Skills Platform (Stealth) – Technical Co-Founder – Remote (US) – Equity
- https://news.ycombinator.com/item?id=47081777
- *HN story about AI Skills Platform (Stealth) – Technical Co-Founder – Remote*
-
-**HN10** (score:47) hn/ClaytheMachine (2026-02-15) [1 pts, 0 cmt]
- Show HN: SkillSandbox – Capability-based sandbox for AI agent skills (Rust)
- https://news.ycombinator.com/item?id=47027734
- *HN story about Show HN: SkillSandbox – Capability-based sandbox for AI agen*
-
-**HN6** (score:47) hn/micronink (2026-01-30) [1 pts, 2 cmt]
- Show HN: Indx.sh – Directory of AI coding rules, MCP servers, and tool
- https://news.ycombinator.com/item?id=46822378
- *HN story about Show HN: Indx.sh – Directory of AI coding rules, MCP servers*
-
-**HN3** (score:45) hn/paolobietolini (2026-02-07) [1 pts, 0 cmt]
- Show HN: GTM MCP Server- Let AI Manage Your Google Tag Manager Containers
- https://news.ycombinator.com/item?id=46922159
- *HN story about Show HN: GTM MCP Server- Let AI Manage Your Google Tag Manag*
-
-**HN9** (score:38) hn/digitcatphd (2026-02-03) [1 pts, 0 cmt]
- Show HN: ClawsMarket – Marketplace where AI agents discover tools
- https://news.ycombinator.com/item?id=46878646
- *HN story about Show HN: ClawsMarket – Marketplace where AI agents discover *
-
-**HN13** (score:36) hn/aspectrr (2026-02-04) [1 pts, 0 cmt]
- Show HN: Fluid.sh – Claude Code for Infrastructure
- https://news.ycombinator.com/item?id=46886358
- *HN story about Show HN: Fluid.sh – Claude Code for Infrastructure*
-
-**HN5** (score:34) hn/victordg (2026-01-27) [1 pts, 0 cmt]
- Claude Code skill for building ChatGPT Apps
- https://news.ycombinator.com/item?id=46780766
- *HN story about Claude Code skill for building ChatGPT Apps*
-
-**Total: Reddit=3, X=12, YouTube=10, HN=15, Web=0**
-
-### CROSS - Claude Code skills and MCP servers
-
-#### Reddit Discussions
-
-**R1** (score:78) r/ClaudeCode (2026-02-23) [0 pts, 0 comments]
- ClaudeInOne — a full framework for Claude Code, installed in one command
- https://www.reddit.com/r/ClaudeCode/comments/1rcbria/claudeinone_a_full_framework_for_claude_code/
- *Directly about Claude Code skills (213 skills) and a reusable framework for Claude Code workflows.*
-
-**R10** (score:77) r/ClaudeCode (2026-02-25) [0 pts, 0 comments]
- Claude Code works great… until you have too many MCP servers
- https://www.reddit.com/r/ClaudeCode/comments/1rehy2x/claude_code_works_great_until_you_have_too_many/
- *Scaling issues and architecture discussion when using many MCP servers with Claude Code (mentions gateway).*
-
-**R2** (score:77) r/ClaudeCode (2026-02-22) [0 pts, 0 comments]
- I built an MCP server that gives Claude Code a semantic graph of your codebase - went from ~15k tokens of grep output to ~3k of relevant context
- https://www.reddit.com/r/ClaudeCode/comments/1rbu1nq/i_built_an_mcp_server_that_gives_claude_code_a/
- *An MCP server specifically designed to improve Claude Code context retrieval and reduce token waste.*
-
-**R9** (score:76) r/ClaudeAI (2026-02-24) [0 pts, 0 comments]
- I built 25 MCP servers so Claude Code stops wasting tokens on terminal formatting
- https://www.reddit.com/r/ClaudeAI/comments/1rd8z0d/i_built_25_mcp_servers_so_claude_code_stops/
- *Large set of MCP servers aimed at Claude Code usage; about MCP servers returning structured data.*
-
-**R4** (score:75) r/ClaudeCode (2026-02-22) [0 pts, 0 comments]
- We 3x'd our team's Claude Code skill usage in 2 weeks — here's how
- https://www.reddit.com/r/ClaudeCode/comments/1rbr5t7/we_3xd_our_teams_claude_code_skill_usage_in_2/
- *Team-level discussion of increasing Claude Code skill adoption; mentions syncing skills and MCP configs.*
-
-**R8** (score:72) r/ClaudeAI (2026-02-19) [0 pts, 0 comments]
- I used Claude Code to build an MCP proxy server that gives it safe(r) access to my email. Here's what I learned about the security challenges
- https://www.reddit.com/r/ClaudeAI/comments/1r99zbv/i_used_claude_code_to_build_an_mcp_proxy_server/
- *MCP server project built for/with Claude Code; discusses MCP server design and security tradeoffs.*
-
-**R3** (score:72) r/ClaudeCode (2026-02-18) [0 pts, 0 comments]
- Self-improvement Loop: My favorite Claude Code Skill
- https://www.reddit.com/r/ClaudeCode/comments/1r89084/selfimprovement_loop_my_favorite_claude_code_skill/
- *Focused on a concrete Claude Code skill (“wrap-up”) and how skills are used in real sessions.*
-
-**R5** (score:65) r/ClaudeCode (2026-02-10) [0 pts, 0 comments]
- I built 12 SEO skills for Claude Code (open source)
- https://www.reddit.com/r/ClaudeCode/comments/1r0l549/i_built_12_seo_skills_for_claude_code_open_source/
- *Showcase of multiple Claude Code skills and how they run via slash commands/subagents.*
-
-**R6** (score:57) r/ClaudeCode (2026-02-02) [0 pts, 0 comments]
- [Skill Viewer] Built a Chrome extension to view Claude Code skills on GitHub - seeking feedback
- https://www.reddit.com/r/ClaudeCode/comments/1qtq7yr/skill_viewer_built_a_chrome_extension_to_view/
- *Tooling around discovering/viewing Claude Code skills; directly about skills ecosystem.*
-
-**R14** (score:43) r/ClaudeCode (None) [0 pts, 0 comments]
- Difference between Skills and these: Subagents, Claude.MD and slash commands?
- https://www.reddit.com/r/ClaudeCode/comments/1o8t6xe/difference_between_skills_and_these_subagents/
- *Conceptual thread clarifying what Skills are versus subagents/CLAUDE.md/slash commands (relevant to skills usage).*
-
-#### X/Twitter Posts
-
-**X5** (score:86) @zeeg (2026-02-25) [5 likes, 1 rp]
- @adamwathan Codex has been crushing it for me with implicit skill usage to the point I was shocked
-
-Claude Code I resorted to explicit mentions and I
- https://x.com/zeeg/status/2026745680195367091
- **
-
-**X3** (score:78) @ihtesham2005 (2026-02-25) [6 likes, 0 rp]
- 🚨 Anthropic just open-sourced the exact Skills library their own engineers use internally.
-
-Stop building Claude workflows from scratch.
-
-These are pl
- https://x.com/ihtesham2005/status/2026752089473314975
- **
-
-**X8** (score:72) @DevTenta (2026-02-25) [4 likes, 0 rp]
- Day 7 building my first B2C app
-
-Been setting everything up this week. Manually built 5 agents to help me in the early stages and set up the Claude Co
- https://x.com/DevTenta/status/2026742028730527786
- **
-
-**X7** (score:64) @JorgeJaramillo (2026-02-25) [0 likes, 0 rp]
- @Jompiras Claude code + skills. Muy breve.
- https://x.com/JorgeJaramillo/status/2026743135435362649
- **
-
-**X4** (score:64) @arpan7sarkar (2026-02-25) [0 likes, 0 rp]
- @tom_doerr 135 agents, 35 curated skills, 121 plugins and 6 MCP configs all in one Claude Code toolkit is absolutely insane value 🤯 this is the kind o
- https://x.com/arpan7sarkar/status/2026746479369662551
- **
-
-**X6** (score:64) @arpan7sarkar (2026-02-25) [0 likes, 0 rp]
- @tom_doerr A curated collection of Claude Skills organized by category is exactly what power users have been waiting for 🤩 document skills, code tools
- https://x.com/arpan7sarkar/status/2026745559986614335
- **
-
-**X2** (score:64) @yasuky (2026-02-25) [0 likes, 0 rp]
- Claude CodeのSkillsを作成例から徹底理解する https://t.co/vZTseio2Ii
- https://x.com/yasuky/status/2026754773786226708
- **
-
-**X12** (score:64) @zhuoyuan45514 (2026-02-25) [0 likes, 0 rp]
- Day 52 (01/21/2026) (Rest Day)
-
-> Go to New York + watch Broadway Show
-
-> Catch up on claude code updates on way back (2hrs)
- - Learn difference be
- https://x.com/zhuoyuan45514/status/2026733326631923776
- **
-
-**X1** (score:64) @matgoldsborough (2026-02-25) [0 likes, 0 rp]
- How: I told Claude Code "build me a CRM." Our builder skill generated schemas, skills, server, seed data. I pointed Claude Desktop at it. Contacts, le
- https://x.com/matgoldsborough/status/2026755742737510749
- **
-
-**X9** (score:64) @0x_Kapoor (2026-02-25) [0 likes, 0 rp]
- I can build another open claw, or claude bot, or even a messaging app in a nice prompt and although having engineering skills, I can push the code on
- https://x.com/0x_Kapoor/status/2026738663321907214
- **
-
-**X10** (score:56) @ghumare64 (2026-02-25) [1 likes, 0 rp]
- 1. I use https://t.co/NtFYJEiFs9 inspired by @bcherny
-2. I use https://t.co/EwnmyWNFqn to sync skills and scan skills from the marketplace
-3. I chat
- https://x.com/ghumare64/status/2026736176695246966
- **
-
-**X11** (score:56) @hamen (2026-02-25) [1 likes, 0 rp]
- The 'vibe coding' revolution is forcing a reckoning.
-
-Developers with 15+ years experience are either embracing AI to expand their reach or watching t
- https://x.com/hamen/status/2026734075243790396
- **
-
-#### YouTube Videos
-
-**X8afcX2s2Mo** (score:69, rel:0.60) Grace Leung (2026-02-21) [47512 views, 1900 likes]
- Claude Skills: Build Your First AI Marketing Team in 16 Minutes (Claude Code)
- https://www.youtube.com/watch?v=X8afcX2s2Mo
- *YouTube: Claude Skills: Build Your First AI Marketing Team in 16 Minu*
- Transcript: Every marketer I know is stretch themed. Too many channels, too many deliverables, never enough time, whether you're run...
-
-**0J2_YGuNrDo** (score:66, rel:0.80) Grace Leung (2025-12-16) [155806 views, 4760 likes]
- Claude Code just Built me an AI Agent Team (Claude Code + Skills + MCP)
- https://www.youtube.com/watch?v=0J2_YGuNrDo
- *YouTube: Claude Code just Built me an AI Agent Team (Claude Code + Sk*
- Transcript: I use clot more than any other AI but even I dismiss Clark as just for developers and I was wrong after using myself I a...
-
-**Gqh_KdHP1Xk** (score:61, rel:0.80) Robin Ebers (2025-08-21) [90281 views, 2719 likes]
- 8 MCP Servers That Make Claude Code 10x Better
- https://www.youtube.com/watch?v=Gqh_KdHP1Xk
- *YouTube: 8 MCP Servers That Make Claude Code 10x Better*
- Transcript: Over 90% of all MCP servers are complete overhyped garbage. But some of them are not only worth it, they can be complete...
-
-**vIUJ4Hd7be0** (score:54, rel:0.60) Leon van Zyl (2026-02-09) [25538 views, 699 likes]
- Claude Code Skills - The Only Tutorial You Need
- https://www.youtube.com/watch?v=vIUJ4Hd7be0
- *YouTube: Claude Code Skills - The Only Tutorial You Need*
-
-**901VMcZq8X4** (score:47, rel:0.60) Sean Kochel (2025-10-21) [44618 views, 1356 likes]
- These 5 Claude Code Skills Are Your New Unfair Advantage
- https://www.youtube.com/watch?v=901VMcZq8X4
- *YouTube: These 5 Claude Code Skills Are Your New Unfair Advantage*
-
-**M5CsRj6zSCA** (score:45, rel:0.80) Eric Tech (2025-12-12) [11286 views, 266 likes]
- 5 MCP Servers That Make Claude Code 10x More Powerful (Full-Stack App Build)
- https://www.youtube.com/watch?v=M5CsRj6zSCA
- *YouTube: 5 MCP Servers That Make Claude Code 10x More Powerful (Full-*
-
-**l7qVtHpctic** (score:43, rel:0.60) Kenny Liao (2026-01-17) [21765 views, 728 likes]
- Claude Code's MCP Problem Just Got Fixed
- https://www.youtube.com/watch?v=l7qVtHpctic
- *YouTube: Claude Code's MCP Problem Just Got Fixed*
-
-**ZroGqu7GyXM** (score:39, rel:0.80) Solo Swift Crafter (2025-10-27) [4885 views, 98 likes]
- Claude Code Skills vs MCP vs Sub Agents: What Works for Solo Devs?
- https://www.youtube.com/watch?v=ZroGqu7GyXM
- *YouTube: Claude Code Skills vs MCP vs Sub Agents: What Works for Solo*
-
-**jzf7DQa2CAc** (score:36, rel:0.80) Matt Kuda (2026-01-19) [2547 views, 87 likes]
- How I Use Claude Code With Skills, MCP, Agents & Plugins
- https://www.youtube.com/watch?v=jzf7DQa2CAc
- *YouTube: How I Use Claude Code With Skills, MCP, Agents & Plugins*
-
-**qthyl0GCpDo** (score:30, rel:0.60) Postman (2025-11-20) [7067 views, 105 likes]
- Claude Skills vs MCP: What’s the Difference and When to Use Each?
- https://www.youtube.com/watch?v=qthyl0GCpDo
- *YouTube: Claude Skills vs MCP: What’s the Difference and When to Use *
-
-#### Hacker News Stories
-
-**HN1** (score:90) hn/cosmoblk (2026-02-21) [10 pts, 2 cmt]
- Show HN: I built a 55K-word email marketing knowledge base and Claude Code skill
- https://news.ycombinator.com/item?id=47096335
- *HN story about Show HN: I built a 55K-word email marketing knowledge base a*
- - So it's an AI spambot?
-
-**HN4** (score:74) hn/superamped (2026-02-25) [3 pts, 1 cmt]
- Show HN: AI Marketing Skills for Claude Code
- https://news.ycombinator.com/item?id=47154377
- *HN story about Show HN: AI Marketing Skills for Claude Code*
-
-**HN12** (score:70) hn/heycesr (2026-02-18) [4 pts, 2 cmt]
- Show HN: Poncho, a general agent harness built for the web
- https://news.ycombinator.com/item?id=47061949
- *HN story about Show HN: Poncho, a general agent harness built for the web*
- - I have been trying it and it's very cool!
-
-**HN11** (score:69) hn/gjkim042 (2026-02-24) [1 pts, 4 cmt]
- Show HN: Axon – A Kubernetes-native framework for AI coding agents
- https://news.ycombinator.com/item?id=47137491
- *HN story about Show HN: Axon – A Kubernetes-native framework for AI coding *
-
-**HN14** (score:65) hn/alternateman (2026-02-17) [3 pts, 2 cmt]
- Show HN: Turn Claude Code or Codex into proactive, autonomous 24/7 AI agents
- https://news.ycombinator.com/item?id=47054100
- *HN story about Show HN: Turn Claude Code or Codex into proactive, autonomou*
-
-**HN2** (score:64) hn/hauschildt (2026-02-19) [3 pts, 0 cmt]
- Show HN: Agent skills to build photo, video and design editors on the web
- https://news.ycombinator.com/item?id=47073035
- *HN story about Show HN: Agent skills to build photo, video and design edito*
-
-**HN8** (score:57) hn/barefootsanders (2026-02-25) [1 pts, 0 cmt]
- Show HN: Upjack – Declarative framework for building apps over MCP
- https://news.ycombinator.com/item?id=47157331
- *HN story about Show HN: Upjack – Declarative framework for building apps ov*
-
-**HN7** (score:56) hn/san-techie21 (2026-02-16) [1 pts, 1 cmt]
- Show HN: Gulama – Security-first open-source AI agent (OpenClaw alternative)
- https://news.ycombinator.com/item?id=47031982
- *HN story about Show HN: Gulama – Security-first open-source AI agent (OpenC*
-
-**HN15** (score:48) hn/martin-hall (2026-02-20) [1 pts, 0 cmt]
- AI Skills Platform (Stealth) – Technical Co-Founder – Remote (US) – Equity
- https://news.ycombinator.com/item?id=47081777
- *HN story about AI Skills Platform (Stealth) – Technical Co-Founder – Remote*
-
-**HN10** (score:47) hn/ClaytheMachine (2026-02-15) [1 pts, 0 cmt]
- Show HN: SkillSandbox – Capability-based sandbox for AI agent skills (Rust)
- https://news.ycombinator.com/item?id=47027734
- *HN story about Show HN: SkillSandbox – Capability-based sandbox for AI agen*
-
-**HN6** (score:47) hn/micronink (2026-01-30) [1 pts, 2 cmt]
- Show HN: Indx.sh – Directory of AI coding rules, MCP servers, and tool
- https://news.ycombinator.com/item?id=46822378
- *HN story about Show HN: Indx.sh – Directory of AI coding rules, MCP servers*
-
-**HN3** (score:45) hn/paolobietolini (2026-02-07) [1 pts, 0 cmt]
- Show HN: GTM MCP Server- Let AI Manage Your Google Tag Manager Containers
- https://news.ycombinator.com/item?id=46922159
- *HN story about Show HN: GTM MCP Server- Let AI Manage Your Google Tag Manag*
-
-**HN9** (score:38) hn/digitcatphd (2026-02-03) [1 pts, 0 cmt]
- Show HN: ClawsMarket – Marketplace where AI agents discover tools
- https://news.ycombinator.com/item?id=46878646
- *HN story about Show HN: ClawsMarket – Marketplace where AI agents discover *
-
-**HN13** (score:36) hn/aspectrr (2026-02-04) [1 pts, 0 cmt]
- Show HN: Fluid.sh – Claude Code for Infrastructure
- https://news.ycombinator.com/item?id=46886358
- *HN story about Show HN: Fluid.sh – Claude Code for Infrastructure*
-
-**HN5** (score:34) hn/victordg (2026-01-27) [1 pts, 0 cmt]
- Claude Code skill for building ChatGPT Apps
- https://news.ycombinator.com/item?id=46780766
- *HN story about Claude Code skill for building ChatGPT Apps*
-
-**Total: Reddit=10, X=12, YouTube=10, HN=15, Web=0**
-
-### Base - Seedance AI video generation
-
-#### Reddit Discussions
-
-**R23** (score:76) r/generativeAI (2026-02-24) [0 pts, 0 comments]
- Official website for creating content with Seedance 2.0?
- https://www.reddit.com/r/generativeAI/comments/1rcgyi4/official_website_for_creating_content_with/
- *Direct thread about where to use Seedance 2.0 for generating videos (and discussion of scams/third parties).*
-
-**R19** (score:76) r/generativeAI (2026-02-22) [0 pts, 0 comments]
- This is terrifying!! Seedance 2.0 just generated a 1-minute film with ZERO editing — the entire film industry should be worried
- https://www.reddit.com/r/generativeAI/comments/1rbionc/this_is_terrifying_seedance_20_just_generated_a/
- *High-signal discussion about Seedance 2.0 video generation quality (multi-shot coherence, transitions, filmmaking).*
-
-**R20** (score:75) r/Seedance_AI (2026-02-23) [0 pts, 0 comments]
- Help! I need to use seedance or jimeng video function
- https://www.reddit.com/r/Seedance_AI/comments/1rckgcm/help_i_need_to_use_seedance_or_jimeng_video/
- *Access/how-to thread specifically about using Seedance/Jimeng video generation.*
-
-**R18** (score:72) r/Seedance_AI (2026-02-20) [0 pts, 0 comments]
- so disappointed and frustrated with recent changes (wasted 1k$+)
- https://www.reddit.com/r/Seedance_AI/comments/1ra6nf4/so_disappointed_and_frustrated_with_recent/
- *User report about Seedance video generation failing review/guardrails and credit usage—directly about Seedance generation.*
-
-**R25** (score:71) r/SaasDevelopers (2026-02-24) [0 pts, 0 comments]
- Third-party Seedance 2.0 API
- https://www.reddit.com/r/SaasDevelopers/comments/1rdw3uz/thirdparty_seedance_20_api/
- *Discussion about Seedance 2.0 video generation availability via third-party API and Dreamina integration.*
-
-**R21** (score:71) r/Seedance_AI (2026-02-23) [0 pts, 0 comments]
- What happened to doubao?
- https://www.reddit.com/r/Seedance_AI/comments/1rcox3z/what_happened_to_doubao/
- *Discussion around ByteDance/Doubao access issues while trying to use Seedance 2 (video generation availability).*
-
-**R28** (score:68) r/Seedance_AI (2026-02-24) [0 pts, 0 comments]
- Selfie Situation • Seedance 2.0 • Third-party API by useapi.net
- https://www.reddit.com/r/Seedance_AI/comments/1rduxop/selfie_situation_seedance_20_thirdparty_api_by/
- *Seedance 2.0 discussion focused on third-party access and whether generations are truly Seedance 2.0 quality.*
-
-**R22** (score:68) r/AIGuild (2026-02-23) [0 pts, 0 comments]
- ByteDance Faces Hollywood Backlash Over Seedance 2.0
- https://www.reddit.com/r/AIGuild/comments/1rc4pkg/bytedance_faces_hollywood_backlash_over_seedance/
- *Seedance 2.0 discussion tied to generated video content/IP concerns and potential impacts on access/release.*
-
-**R17** (score:67) r/AI_UGC_Marketing (2026-02-17) [0 pts, 0 comments]
- Elevenlabs silently removed Seedance 2.0 after SAG-AFTRA incident, can’t create UGC style video
- https://www.reddit.com/r/AI_UGC_Marketing/comments/1r70942/elevenlabs_silently_removed_seedance_20_after/
- *Discussion about Seedance 2.0 availability/removal in a toolchain for generating UGC-style AI videos.*
-
-**R27** (score:64) r/u_PoppyVonMiller (2026-02-24) [0 pts, 0 comments]
- Seedance 2.0 Music Video
- https://www.reddit.com/r/u_PoppyVonMiller/comments/1rdkptn/seedance_20_music_video/
- *Example/discussion of an output video made with Seedance 2.0 (AI video generation results).*
-
-**R13** (score:63) r/generativeAI (2026-02-10) [0 pts, 0 comments]
- Where is the official Seedance website?
- https://www.reddit.com/r/generativeAI/comments/1r19eem/where_is_the_official_seedance_website/
- *Seedance access/official-site discussion (important for actually generating videos with Seedance).*
-
-**R15** (score:62) r/AiVideos_NoRules (2026-02-15) [0 pts, 0 comments]
- Seedance 2.0
- https://www.reddit.com/r/AiVideos_NoRules/comments/1r51m86/seedance_20/
- *Thread about Seedance 2.0 as an AI video generator and its capabilities/impact.*
-
-**R12** (score:62) r/GoogleGeminiAI (2026-02-09) [0 pts, 0 comments]
- Has anyone used Seedance 2.0 yet?
- https://www.reddit.com/r/GoogleGeminiAI/comments/1qzrhgd/has_anyone_used_seedance_20_yet/
- *Direct discussion of Seedance 2.0 usage/access for AI video generation and where people are trying it.*
-
-**R24** (score:61) r/aivideo (2026-02-24) [0 pts, 0 comments]
- [Removed]
- https://www.reddit.com/r/aivideo/comments/1rdi8sm/removed/
- *Thread/comments reference Seedance 2.0 access and community moderation around AI-generated video/IP.*
-
-**R16** (score:61) r/nairobitechies (2026-02-16) [0 pts, 0 comments]
- ByteDance Seedance: Multimodal Video Generation Reaches a New Threshold
- https://www.reddit.com/r/nairobitechies/comments/1r6gwyj/bytedance_seedance_multimodal_video_generation/
- *Seedance family discussion (1.0/1.5/2.0) focusing on video generation features and multimodal inputs.*
-
-**R14** (score:58) r/u_Impossible-Dish409 (2026-02-11) [0 pts, 0 comments]
- Seedance 2.0 – What’s New and Why It Matters
- https://www.reddit.com/r/u_Impossible-Dish409/comments/1r1lekj/seedance_20_whats_new_and_why_it_matters/
- *Overview/discussion of Seedance 2.0 capabilities for multimodal AI video generation and editing.*
-
-#### X/Twitter Posts
-
-**X9** (score:86) @HBCoop_ (2026-02-25) [9 likes, 0 rp]
- First Seedance 2.0 Test!
-
-Seedance 5.0 Lite @krea_ai → Seedance 2.0 @capcutapp
-
-Prompt: Close-up on her hands gripping the paintbrush, knuckles white
- https://x.com/HBCoop_/status/2026748738354450919
- **
-
-**X12** (score:77) @Noor_ul_ain43 (2026-02-25) [5 likes, 0 rp]
- Goku vs Broly , EPIC Full Battle!
-Witness the ultimate Saiyan showdown recreated with AI power. Explosive transformations, insane energy blasts, and
- https://x.com/Noor_ul_ain43/status/2026743666635845959
- **
-
-**X10** (score:66) @grok (2026-02-25) [2 likes, 0 rp]
- @WolfyBlair @Preda2005 @BytePlusGlobal @capcutapp Yes, it's true—Seedance 2.0 is now live in CapCut (desktop & mobile).
-
-See it in the AI video t
- https://x.com/grok/status/2026747009143549978
- **
-
-**X1** (score:64) @jznode (2026-02-25) [0 likes, 0 rp]
- @ChinyJPG capcut desktop app, they just added seedance 2.0 today. no chinese phone number needed, works globally. download the desktop version, look f
- https://x.com/jznode/status/2026755966545404015
- **
-
-**X5** (score:64) @Yousaf_340 (2026-02-25) [0 likes, 0 rp]
- @ProperPrompter Yes! Seedance 2.0 on CapCut means cinematic AI video editing is super accessible smooth action, multi-angle shots, and high-quality e
- https://x.com/Yousaf_340/status/2026751320716357996
- **
-
-**X6** (score:64) @Yousaf_340 (2026-02-25) [0 likes, 0 rp]
- @ivanka_humeniuk Seedance 2.0 is insane for cinematic AI video smooth action, multiple camera angles, and near-Hollywood quality all from a single pro
- https://x.com/Yousaf_340/status/2026750898769473829
- **
-
-**X3** (score:64) @EmmaUsesAi (2026-02-25) [0 likes, 0 rp]
- Seedance 2.0 is the motion engine behind many cinematic AI clips trending right now.
-
-It’s now native inside NemoVideo.
-
-You can:
-- Identify proven fo
- https://x.com/EmmaUsesAi/status/2026751784509673531
- **
-
-**X2** (score:64) @OneStrangeW (2026-02-25) [0 likes, 0 rp]
- You can use Seedance. If Seedance 2.0 hasn't launch yet, the prior version is great, and stick pretty well to the prompt. Of course the video agent mu
- https://x.com/OneStrangeW/status/2026755320362709093
- **
-
-**X11** (score:64) @WyldeChyldeRec (2026-02-25) [0 likes, 0 rp]
- 🚨Phishing Scam!
-
-Watch for emails titled:
-
-[Seedance & Wan AI] Unrestricted generation with Grok Imagine video added
-
-This is not real. We don't h
- https://x.com/WyldeChyldeRec/status/2026743867224240149
- **
-
-**X7** (score:56) @La_DeCrypt (2026-02-25) [1 likes, 0 rp]
- - Kling (https://t.co/WGphQsdLf1)
-
-- Seedance 2.0 (https://t.co/A8UzLTRGNo)
-
-☞ AI-powered video editing:
-
-- Descript (https://t.co/q4axNEjDzt)
-- Opus
- https://x.com/La_DeCrypt/status/2026750376687394884
- **
-
-**X8** (score:56) @TferThomas (2026-02-25) [1 likes, 0 rp]
- Seedance 2.0 might be gen AI video’s next big hope, but it’s still slop https://t.co/kT1baOYJQk #AI
- https://x.com/TferThomas/status/2026749218224746609
- **
-
-#### YouTube Videos
-
-**F1kWxdfiBNE** (score:73, rel:0.70) AI Filmmaking Academy (2026-02-22) [40982 views, 1088 likes]
- Seedance 2.0 Claims the AI Video Throne!
- https://www.youtube.com/watch?v=F1kWxdfiBNE
- *YouTube video about seedance ai video generation*
-
-**_o2MuUX9UYg** (score:73, rel:0.70) Theoretically Media (2026-02-09) [199908 views, 4986 likes]
- Seedance 2.0 Claims the AI Video Throne!
- https://www.youtube.com/watch?v=_o2MuUX9UYg
- *YouTube video about seedance ai video generation*
- Transcript: So, it's been about a week since I declared Cling 3.0 the new benchmark for state-of-the-art AI video. And well, I mean,...
-
-**jkPTYD5lXo8** (score:70, rel:0.70) How To In 5 Minutes (2026-02-19) [44344 views, 782 likes]
- 100% FREE Seedance 2.0 AI Video Generator : How to Use It WORLDWIDE
- https://www.youtube.com/watch?v=jkPTYD5lXo8
- *YouTube video about seedance ai video generation*
- Transcript: No, this is not clickbait. You can now access Cance 2.0 for free from anywhere. No registration required and depending o...
-
-**W_lxyDFDZt4** (score:69, rel:0.70) WealthWise (2026-02-12) [126599 views, 1204 likes]
- Seedance 2.0: The New Best AI Video Generator | Sora 2 Destroyed
- https://www.youtube.com/watch?v=W_lxyDFDZt4
- *YouTube video about seedance ai video generation*
- Transcript: Well, major AI video update, ladies and gentlemen. Seance 2.0 is right around the corner. And let me tell you guys, this...
-
-**R1zl92NhCfE** (score:65, rel:0.70) Ai Lockup (2026-02-16) [32699 views, 591 likes]
- Seedance 2.0 How To Use and Become a Pro AI Film Maker With This AI Video Generator
- https://www.youtube.com/watch?v=R1zl92NhCfE
- *YouTube video about seedance ai video generation*
-
-**G1Ad4a8sdJU** (score:63, rel:0.70) Benji’s AI Playground (2026-02-14) [38581 views, 419 likes]
- SeeDance 2.0: The Next Level of AI Video — And What It Means for Local AI Users?
- https://www.youtube.com/watch?v=G1Ad4a8sdJU
- *YouTube video about seedance ai video generation*
-
-**n625xVounGM** (score:61, rel:0.70) John Savage AI (2026-02-23) [7184 views, 126 likes]
- How To Generate FREE AI Seedance 2.0 Videos (Access Worldwide)
- https://www.youtube.com/watch?v=n625xVounGM
- *YouTube video about seedance ai video generation*
-
-**kJ0NAVmd4f4** (score:58, rel:0.70) Rogue Cell Pictures (2026-02-25) [1544 views, 147 likes]
- Seedance 2.0 Changes Filmmaking Forever | New Original Series
- https://www.youtube.com/watch?v=kJ0NAVmd4f4
- *YouTube video about seedance ai video generation*
-
-**FP8TaJSFohs** (score:55, rel:0.70) Chem Beast (2026-02-24) [3194 views, 31 likes]
- How To Use Seedance 2 0 Full AI Video Generator Tutorial + Free Access Guide
- https://www.youtube.com/watch?v=FP8TaJSFohs
- *YouTube video about seedance ai video generation*
-
-**61ThJGqwHsI** (score:48, rel:0.70) xCreate (2026-02-11) [4854 views, 86 likes]
- Ultimate AI Video Generation - Seedance 2.0 PREVIEW
- https://www.youtube.com/watch?v=61ThJGqwHsI
- *YouTube video about seedance ai video generation*
-
-**Total: Reddit=16, X=11, YouTube=10, HN=0, Web=0**
-
-### HN - Seedance AI video generation
-
-#### Reddit Discussions
-
-**R2** (score:79) r/singularity (2026-02-25) [0 pts, 0 comments]
- Official: Seedance 2.0 now live in CapCut desktop and API access available, details below
- https://www.reddit.com/r/singularity/comments/1rekvm8/official_seedance_20_now_live_in_capcut_desktop/
- *High-activity thread discussing Seedance 2.0 launch claims, CapCut availability, censorship, and pricing.*
-
-**R3** (score:77) r/generativeAI (2026-02-24) [0 pts, 0 comments]
- Official website for creating content with Seedance 2.0?
- https://www.reddit.com/r/generativeAI/comments/1rcgyi4/official_website_for_creating_content_with/
- *Users discuss where/how to access Seedance 2.0 and warn about scams.*
-
-**R4** (score:76) r/AITalkers (2026-02-24) [0 pts, 0 comments]
- Is there an official way to create content using Seedance 2.0?
- https://www.reddit.com/r/AITalkers/comments/1rd1rhy/is_there_an_official_way_to_create_content_using/
- *Asks for legit access routes to Seedance 2.0; includes discussion of official vs third-party options.*
-
-**R5** (score:75) r/Seedance_AI (2026-02-23) [0 pts, 0 comments]
- Help! I need to use seedance or jimeng video function
- https://www.reddit.com/r/Seedance_AI/comments/1rckgcm/help_i_need_to_use_seedance_or_jimeng_video/
- *Seedance/Jimeng video generation access and troubleshooting discussion.*
-
-**R6** (score:75) r/generativeAI (2026-02-23) [0 pts, 0 comments]
- Seedance 2.0 is available in Open Source tools already
- https://www.reddit.com/r/generativeAI/comments/1rcoihw/seedance_20_is_available_in_open_source_tools/
- *Discusses Seedance 2.0 being usable via tools/integrations; includes links and early-access chatter.*
-
-**R8** (score:74) r/generativeAI (2026-02-22) [0 pts, 0 comments]
- This is terrifying!! Seedance 2.0 just generated a 1-minute film with ZERO editing — the entire film industry should be worried
- https://www.reddit.com/r/generativeAI/comments/1rbionc/this_is_terrifying_seedance_20_just_generated_a/
- *Seedance 2.0 output showcase + discussion of cinematic coherence and workflow.*
-
-**R7** (score:73) r/AIGuild (2026-02-23) [0 pts, 0 comments]
- ByteDance Faces Hollywood Backlash Over Seedance 2.0
- https://www.reddit.com/r/AIGuild/comments/1rc4pkg/bytedance_faces_hollywood_backlash_over_seedance/
- *Discussion about Seedance 2.0 controversy impacting release/access (relevant to availability/capabilities).*
-
-**R9** (score:73) r/Seedance_AI (2026-02-22) [0 pts, 0 comments]
- Is Jimeng silently banning accounts on Jimeng.Jianying? Paid account stuck with “network error” but free account works.
- https://www.reddit.com/r/Seedance_AI/comments/1rbk1qb/is_jimeng_silently_banning_accounts_on/
- *Seedance/Jimeng video generation failures and account restriction speculation.*
-
-**R12** (score:73) r/Seedance_AI (2026-02-21) [0 pts, 0 comments]
- Jimeng Web for seedance 2.0 “Network Error, Generation Failed” for 24 Hours – Anyone Else?
- https://www.reddit.com/r/Seedance_AI/comments/1raqsnu/jimeng_web_for_seedance_20_network_error/
- *Focused troubleshooting thread about Seedance 2.0 generation failures on Jimeng web.*
-
-**R10** (score:71) r/u_Educational-Lion7812 (2026-02-21) [0 pts, 0 comments]
- Seedance 2.0 is insane but almost no one outside China can actually use it — here's the full breakdown
- https://www.reddit.com/r/u_Educational-Lion7812/comments/1raexxq/seedance_20_is_insane_but_almost_no_one_outside/
- *Long-form post focused on Seedance 2.0 access constraints and how people are using it.*
-
-**R11** (score:71) r/Seedance_AI (2026-02-21) [0 pts, 0 comments]
- so disappointed and frustrated with recent changes (wasted 1k$+)
- https://www.reddit.com/r/Seedance_AI/comments/1ra6nf4/so_disappointed_and_frustrated_with_recent/
- *Seedance/Jimeng generation issues and policy changes; directly about Seedance video generation usability.*
-
-**R15** (score:66) r/singularity (2026-02-13) [0 pts, 0 comments]
- ByteDance releases Seedance 2.0 video model with Director mode and multimodal upgrades
- https://www.reddit.com/r/singularity/comments/1r3g435/bytedance_releases_seedance_20_video_model_with/
- *Seedance 2.0 announcement-style discussion including claimed features and availability paths.*
-
-**R13** (score:65) r/Seedance_AI (2026-02-15) [0 pts, 0 comments]
- Seedance 2.0 Made This LeBron James vs Regular Person Look Real
- https://www.reddit.com/r/Seedance_AI/comments/1r5ncbw/seedance_20_made_this_lebron_james_vs_regular/
- *Seedance 2.0 video output example + prompting discussion (text-to-video).*
-
-**R21** (score:64) r/AI_UGC_Marketing (2026-02-17) [0 pts, 0 comments]
- Elevenlabs silently removed Seedance 2.0 after SAG-AFTRA incident, can’t create UGC style video
- https://www.reddit.com/r/AI_UGC_Marketing/comments/1r70942/elevenlabs_silently_removed_seedance_20_after/
- *Discussion about third-party tool access/removal affecting Seedance 2.0 video generation workflows.*
-
-**R14** (score:64) r/Seedance_AI (2026-02-14) [0 pts, 0 comments]
- be very careful of scam sites that do not have it like nemovideo
- https://www.reddit.com/r/Seedance_AI/comments/1r4va5a/be_very_careful_of_scam_sites_that_do_not_have_it/
- *Thread about Seedance 2.0 access claims and scam warnings; relevant to where to generate videos.*
-
-**R17** (score:63) r/ByteDance (2026-02-13) [0 pts, 0 comments]
- The censorship just ruined Seedance 2.0
- https://www.reddit.com/r/ByteDance/comments/1r43yl8/the_censorship_just_ruined_seedance_20/
- *Discusses Seedance 2.0 restrictions that affect what videos you can generate.*
-
-**R16** (score:63) r/AIHubSpace (2026-02-13) [0 pts, 0 comments]
- [SEEDANCE 2.0] I made the post and many of you doubted it. I warned you that “Hollywood” would take action.
- https://www.reddit.com/r/AIHubSpace/comments/1r3satk/seedance_20_i_made_the_post_and_many_of_you/
- *Discussion about Seedance 2.0 clips and legal/copyright reaction impacting video generation usage.*
-
-**R18** (score:62) r/AIGuild (2026-02-12) [0 pts, 0 comments]
- Seedance 2.0: ByteDance’s New Movie-Making Machine
- https://www.reddit.com/r/AIGuild/comments/1r3940k/seedance_20_bytedances_new_moviemaking_machine/
- *Seedance 2.0 overview thread focused on multimodal AI video generation and controls.*
-
-**R19** (score:60) r/u_Impossible-Dish409 (2026-02-11) [0 pts, 0 comments]
- Seedance 2.0 – What’s New and Why It Matters
- https://www.reddit.com/r/u_Impossible-Dish409/comments/1r1lekj/seedance_20_whats_new_and_why_it_matters/
- *Feature rundown and comparisons for Seedance 2.0 video generation capabilities.*
-
-**R20** (score:58) r/GoogleGeminiAI (2026-02-09) [0 pts, 0 comments]
- Has anyone used Seedance 2.0 yet?
- https://www.reddit.com/r/GoogleGeminiAI/comments/1qzrhgd/has_anyone_used_seedance_20_yet/
- *General user discussion about whether/where Seedance 2.0 can be used and early impressions.*
-
-#### X/Twitter Posts
-
-**X9** (score:86) @HBCoop_ (2026-02-25) [9 likes, 0 rp]
- First Seedance 2.0 Test!
-
-Seedance 5.0 Lite @krea_ai → Seedance 2.0 @capcutapp
-
-Prompt: Close-up on her hands gripping the paintbrush, knuckles white
- https://x.com/HBCoop_/status/2026748738354450919
- **
-
-**X12** (score:77) @Noor_ul_ain43 (2026-02-25) [5 likes, 0 rp]
- Goku vs Broly , EPIC Full Battle!
-Witness the ultimate Saiyan showdown recreated with AI power. Explosive transformations, insane energy blasts, and
- https://x.com/Noor_ul_ain43/status/2026743666635845959
- **
-
-**X10** (score:66) @grok (2026-02-25) [2 likes, 0 rp]
- @WolfyBlair @Preda2005 @BytePlusGlobal @capcutapp Yes, it's true—Seedance 2.0 is now live in CapCut (desktop & mobile).
-
-See it in the AI video t
- https://x.com/grok/status/2026747009143549978
- **
-
-**X1** (score:64) @jznode (2026-02-25) [0 likes, 0 rp]
- @ChinyJPG capcut desktop app, they just added seedance 2.0 today. no chinese phone number needed, works globally. download the desktop version, look f
- https://x.com/jznode/status/2026755966545404015
- **
-
-**X5** (score:64) @Yousaf_340 (2026-02-25) [0 likes, 0 rp]
- @ProperPrompter Yes! Seedance 2.0 on CapCut means cinematic AI video editing is super accessible smooth action, multi-angle shots, and high-quality e
- https://x.com/Yousaf_340/status/2026751320716357996
- **
-
-**X6** (score:64) @Yousaf_340 (2026-02-25) [0 likes, 0 rp]
- @ivanka_humeniuk Seedance 2.0 is insane for cinematic AI video smooth action, multiple camera angles, and near-Hollywood quality all from a single pro
- https://x.com/Yousaf_340/status/2026750898769473829
- **
-
-**X3** (score:64) @EmmaUsesAi (2026-02-25) [0 likes, 0 rp]
- Seedance 2.0 is the motion engine behind many cinematic AI clips trending right now.
-
-It’s now native inside NemoVideo.
-
-You can:
-- Identify proven fo
- https://x.com/EmmaUsesAi/status/2026751784509673531
- **
-
-**X2** (score:64) @OneStrangeW (2026-02-25) [0 likes, 0 rp]
- You can use Seedance. If Seedance 2.0 hasn't launch yet, the prior version is great, and stick pretty well to the prompt. Of course the video agent mu
- https://x.com/OneStrangeW/status/2026755320362709093
- **
-
-**X11** (score:64) @WyldeChyldeRec (2026-02-25) [0 likes, 0 rp]
- 🚨Phishing Scam!
-
-Watch for emails titled:
-
-[Seedance & Wan AI] Unrestricted generation with Grok Imagine video added
-
-This is not real. We don't h
- https://x.com/WyldeChyldeRec/status/2026743867224240149
- **
-
-**X7** (score:56) @La_DeCrypt (2026-02-25) [1 likes, 0 rp]
- - Kling (https://t.co/WGphQsdLf1)
-
-- Seedance 2.0 (https://t.co/A8UzLTRGNo)
-
-☞ AI-powered video editing:
-
-- Descript (https://t.co/q4axNEjDzt)
-- Opus
- https://x.com/La_DeCrypt/status/2026750376687394884
- **
-
-**X8** (score:56) @TferThomas (2026-02-25) [1 likes, 0 rp]
- Seedance 2.0 might be gen AI video’s next big hope, but it’s still slop https://t.co/kT1baOYJQk #AI
- https://x.com/TferThomas/status/2026749218224746609
- **
-
-#### YouTube Videos
-
-**F1kWxdfiBNE** (score:73, rel:0.70) AI Filmmaking Academy (2026-02-22) [40982 views, 1088 likes]
- Seedance 2.0 Claims the AI Video Throne!
- https://www.youtube.com/watch?v=F1kWxdfiBNE
- *YouTube video about seedance ai video generation*
-
-**_o2MuUX9UYg** (score:73, rel:0.70) Theoretically Media (2026-02-09) [199908 views, 4986 likes]
- Seedance 2.0 Claims the AI Video Throne!
- https://www.youtube.com/watch?v=_o2MuUX9UYg
- *YouTube video about seedance ai video generation*
- Transcript: So, it's been about a week since I declared Cling 3.0 the new benchmark for state-of-the-art AI video. And well, I mean,...
-
-**jkPTYD5lXo8** (score:70, rel:0.70) How To In 5 Minutes (2026-02-19) [44344 views, 782 likes]
- 100% FREE Seedance 2.0 AI Video Generator : How to Use It WORLDWIDE
- https://www.youtube.com/watch?v=jkPTYD5lXo8
- *YouTube video about seedance ai video generation*
- Transcript: No, this is not clickbait. You can now access Cance 2.0 for free from anywhere. No registration required and depending o...
-
-**W_lxyDFDZt4** (score:69, rel:0.70) WealthWise (2026-02-12) [126599 views, 1204 likes]
- Seedance 2.0: The New Best AI Video Generator | Sora 2 Destroyed
- https://www.youtube.com/watch?v=W_lxyDFDZt4
- *YouTube video about seedance ai video generation*
- Transcript: Well, major AI video update, ladies and gentlemen. Seance 2.0 is right around the corner. And let me tell you guys, this...
-
-**R1zl92NhCfE** (score:65, rel:0.70) Ai Lockup (2026-02-16) [32542 views, 591 likes]
- Seedance 2.0 How To Use and Become a Pro AI Film Maker With This AI Video Generator
- https://www.youtube.com/watch?v=R1zl92NhCfE
- *YouTube video about seedance ai video generation*
-
-**G1Ad4a8sdJU** (score:63, rel:0.70) Benji’s AI Playground (2026-02-14) [38581 views, 419 likes]
- SeeDance 2.0: The Next Level of AI Video — And What It Means for Local AI Users?
- https://www.youtube.com/watch?v=G1Ad4a8sdJU
- *YouTube video about seedance ai video generation*
-
-**n625xVounGM** (score:61, rel:0.70) John Savage AI (2026-02-23) [7184 views, 126 likes]
- How To Generate FREE AI Seedance 2.0 Videos (Access Worldwide)
- https://www.youtube.com/watch?v=n625xVounGM
- *YouTube video about seedance ai video generation*
-
-**kJ0NAVmd4f4** (score:58, rel:0.70) Rogue Cell Pictures (2026-02-25) [1548 views, 149 likes]
- Seedance 2.0 Changes Filmmaking Forever | New Original Series
- https://www.youtube.com/watch?v=kJ0NAVmd4f4
- *YouTube video about seedance ai video generation*
-
-**FP8TaJSFohs** (score:55, rel:0.70) Chem Beast (2026-02-24) [3194 views, 31 likes]
- How To Use Seedance 2 0 Full AI Video Generator Tutorial + Free Access Guide
- https://www.youtube.com/watch?v=FP8TaJSFohs
- *YouTube video about seedance ai video generation*
-
-**61ThJGqwHsI** (score:48, rel:0.70) xCreate (2026-02-11) [4854 views, 86 likes]
- Ultimate AI Video Generation - Seedance 2.0 PREVIEW
- https://www.youtube.com/watch?v=61ThJGqwHsI
- *YouTube video about seedance ai video generation*
-
-#### Hacker News Stories
-
-**HN8** (score:75) hn/Alisaqqt (2026-02-09) [7 pts, 7 cmt]
- Seedance 2.0 preview: The best video model of 2026, outperforming Sora 2
- https://news.ycombinator.com/item?id=46940720
- *HN story about Seedance 2.0 preview: The best video model of 2026, outperfo*
- - API is not available now.
- - API will be available on Atlas Cloud on Feb 24.
-
-**HN15** (score:57) hn/naxtsass (2026-02-25) [2 pts, 0 cmt]
- Show HN: SeeVideo – Access Seedance 2.0 and Kling 3.0 without a subscription
- https://news.ycombinator.com/item?id=47153236
- *HN story about Show HN: SeeVideo – Access Seedance 2.0 and Kling 3.0 withou*
-
-**HN6** (score:57) hn/howardV (2026-02-15) [3 pts, 0 cmt]
- Seedance 2.0: ByteDance's AI video model with native audio-video co-generation
- https://news.ycombinator.com/item?id=47022290
- *HN story about Seedance 2.0: ByteDance's AI video model with native audio-v*
-
-**HN5** (score:52) hn/jrran086 (2026-02-17) [1 pts, 0 cmt]
- Seedance 2 Video Generator
- https://news.ycombinator.com/item?id=47046956
- *HN story about Seedance 2 Video Generator*
-
-**HN3** (score:52) hn/xbaicai (2026-02-09) [1 pts, 1 cmt]
- Seedance 2.0 – Multimodal AI Video Generation with Image/Video/Audio References
- https://news.ycombinator.com/item?id=46944030
- *HN story about Seedance 2.0 – Multimodal AI Video Generation with Image/Vid*
-
-**HN1** (score:51) hn/xuyanmei (2026-02-13) [1 pts, 0 cmt]
- Show HN: Seedance AShow HN: Seedance AI Video Generation (Next.js, Drizzle)
- https://news.ycombinator.com/item?id=46999394
- *HN story about Show HN: Seedance AShow HN: Seedance AI Video Generation (Ne*
-
-**HN12** (score:49) hn/RyanMu (2026-02-07) [2 pts, 1 cmt]
- Seedance2 – multi-shot AI video generation
- https://news.ycombinator.com/item?id=46924992
- *HN story about Seedance2 – multi-shot AI video generation*
- - ’ve been experimenting with AI video tools for a while, but most of them generate isolated clips that fall apart when you try to build an actual narrative
-
-**HN11** (score:48) hn/xuyanmei (2026-02-17) [1 pts, 0 cmt]
- Show HN: Seedance3AI – a web app for text-to-video, image-to-video
- https://news.ycombinator.com/item?id=47046989
- *HN story about Show HN: Seedance3AI – a web app for text-to-video, image-to*
-
-**HN2** (score:47) hn/littlepp (2026-02-09) [1 pts, 0 cmt]
- Discussion: Seedance-style AI video generation workflows
- https://news.ycombinator.com/item?id=46942781
- *HN story about Discussion: Seedance-style AI video generation workflows*
-
-**HN13** (score:44) hn/thomaskiko (2026-02-13) [1 pts, 0 cmt]
- Show HN: AI-powered video creation web app
- https://news.ycombinator.com/item?id=47002386
- *HN story about Show HN: AI-powered video creation web app*
-
-**HN9** (score:43) hn/TurnItOffAndOn0 (2026-02-09) [1 pts, 0 cmt]
- Show HN: AI Seedance 2 – Solving the "jump-cut" problem in AI video
- https://news.ycombinator.com/item?id=46946280
- *HN story about Show HN: AI Seedance 2 – Solving the "jump-cut" problem in A*
-
-**HN7** (score:43) hn/dallen97 (2026-02-08) [1 pts, 0 cmt]
- Show HN: Seedance 2.0 AI video generator for creators and ecommerce
- https://news.ycombinator.com/item?id=46930149
- *HN story about Show HN: Seedance 2.0 AI video generator for creators and ec*
-
-**HN10** (score:42) hn/thenextechtrade (2026-02-09) [1 pts, 0 cmt]
- Show HN: Seedance 2.0 – Native audio-visual sync video model
- https://news.ycombinator.com/item?id=46943172
- *HN story about Show HN: Seedance 2.0 – Native audio-visual sync video model*
-
-**HN14** (score:41) hn/echoadam (2026-02-10) [1 pts, 0 cmt]
- Show HN: Seedance2 – Stop "prompt guessing" and start directing AI video
- https://news.ycombinator.com/item?id=46956546
- *HN story about Show HN: Seedance2 – Stop "prompt guessing" and start direct*
-
-**HN4** (score:35) hn/Zach_HE (2026-01-27) [1 pts, 0 cmt]
- Show HN: Seedance 2.0 Pro AI Video Generator
- https://news.ycombinator.com/item?id=46774950
- *HN story about Show HN: Seedance 2.0 Pro AI Video Generator*
-
-**Total: Reddit=20, X=11, YouTube=10, HN=15, Web=0**
-
-### CROSS - Seedance AI video generation
-
-#### Reddit Discussions
-
-**R23** (score:77) r/Seedance_AI (2026-02-25) [0 pts, 0 comments]
- Looking for a real Seedance website that actually works (not scams, SJINN is too expensive)
- https://www.reddit.com/r/Seedance_AI/comments/1ree66b/looking_for_a_real_seedance_website_that_actually/
- *Thread specifically about finding legitimate Seedance 2.0 access for video generation and pricing.*
-
-**R22** (score:75) r/seedance (2026-02-25) [0 pts, 0 comments]
- Seedance 2.0 now live in CapCut desktop and API access available, details below
- https://www.reddit.com/r/seedance/comments/1reoakv/seedance_20_now_live_in_capcut_desktop_and_api/
- *Direct discussion about Seedance 2.0 availability in CapCut and API-related details.*
-
-**R19** (score:75) r/AITalkers (2026-02-24) [0 pts, 0 comments]
- Is there an official way to create content using Seedance 2.0?
- https://www.reddit.com/r/AITalkers/comments/1rd1rhy/is_there_an_official_way_to_create_content_using/
- *Thread asking where to access Seedance 2.0 legitimately for video generation (avoiding scams).*
-
-**R21** (score:75) r/generativeAI (2026-02-24) [0 pts, 0 comments]
- Official website for creating content with Seedance 2.0?
- https://www.reddit.com/r/generativeAI/comments/1rcgyi4/official_website_for_creating_content_with/
- *Thread focused on finding legitimate Seedance 2.0 access for generating content (and warning about scams).*
-
-**R14** (score:75) r/generativeAI (2026-02-22) [0 pts, 0 comments]
- This is terrifying!! Seedance 2.0 just generated a 1-minute film with ZERO editing — the entire film industry should be worried
- https://www.reddit.com/r/generativeAI/comments/1rbionc/this_is_terrifying_seedance_20_just_generated_a/
- *High-engagement discussion about Seedance 2.0 video generation quality and multi-shot coherence claims.*
-
-**R12** (score:73) r/Seedance_AI (2026-02-20) [0 pts, 0 comments]
- so disappointed and frustrated with recent changes (wasted 1k$+)
- https://www.reddit.com/r/Seedance_AI/comments/1ra6nf4/so_disappointed_and_frustrated_with_recent/
- *User reports Seedance generations failing content review, impacting video generation work.*
-
-**R16** (score:72) r/Seedance_AI (2026-02-23) [0 pts, 0 comments]
- Help! I need to use seedance or jimeng video function
- https://www.reddit.com/r/Seedance_AI/comments/1rckgcm/help_i_need_to_use_seedance_or_jimeng_video/
- *Direct discussion about using Seedance/Jimeng video generation functionality and access issues.*
-
-**R17** (score:71) r/Seedance_AI (2026-02-23) [0 pts, 0 comments]
- Is Seedance 2.0 actually delayed?
- https://www.reddit.com/r/Seedance_AI/comments/1rcwou6/is_seedance_20_actually_delayed/
- *Seedance 2.0 release/access timing discussion connected to guardrails and availability outside China.*
-
-**R20** (score:69) r/Seedance_AI (2026-02-24) [0 pts, 0 comments]
- Selfie Situation • Seedance 2.0 • Third-party API by useapi.net
- https://www.reddit.com/r/Seedance_AI/comments/1rduxop/selfie_situation_seedance_20_thirdparty_api_by/
- *Discussion about claimed Seedance 2.0 API access and whether outputs are really from Seedance; relevant to usage/access.*
-
-**R15** (score:69) r/AIGuild (2026-02-23) [0 pts, 0 comments]
- ByteDance Faces Hollywood Backlash Over Seedance 2.0
- https://www.reddit.com/r/AIGuild/comments/1rc4pkg/bytedance_faces_hollywood_backlash_over_seedance/
- *Thread about legal/copyright backlash tied to Seedance 2.0 and implications for access/usage.*
-
-**R11** (score:69) r/Seedance_AI (2026-02-20) [0 pts, 0 comments]
- Seedance 2.0 API launch delayed because of deepfake/copyright concerns
- https://www.reddit.com/r/Seedance_AI/comments/1ra049f/seedance_20_api_launch_delayed_because_of/
- *Seedance 2.0 API / rollout thread related to deepfake/copyright guardrails affecting video generation access.*
-
-**R10** (score:67) r/u_Illustrious-One7744 (2026-02-19) [0 pts, 0 comments]
- I tested Seedance 2.0 and here's what actually worked
- https://www.reddit.com/r/u_Illustrious-One7744/comments/1r8zipa/i_tested_seedance_20_and_heres_what_actually/
- *Hands-on workflow notes about generating videos with Seedance 2.0 (consistency, references, shot planning).*
-
-**R13** (score:66) r/AI_Agents (2026-02-21) [0 pts, 0 comments]
- Seedance 2.0 is impressive. It’s still not a production workflow.
- https://www.reddit.com/r/AI_Agents/comments/1rawxiw/seedance_20_is_impressive_its_still_not_a/
- *Discussion about practical limitations of Seedance 2.0 for longer/structured video production pipelines.*
-
-**R9** (score:65) r/AI_UGC_Marketing (2026-02-17) [0 pts, 0 comments]
- Elevenlabs silently removed Seedance 2.0 after SAG-AFTRA incident, can’t create UGC style video
- https://www.reddit.com/r/AI_UGC_Marketing/comments/1r70942/elevenlabs_silently_removed_seedance_20_after/
- *Discussion about Seedance 2.0 availability/removal in a video generation workflow context.*
-
-**R4** (score:64) r/HiggsfieldAI (2026-02-10) [0 pts, 0 comments]
- The censorship just ruined Seedance 2.0
- https://www.reddit.com/r/HiggsfieldAI/comments/1r1barb/
- *Discussion focused on Seedance 2.0 video generation restrictions/guardrails impacting what can be generated.*
-
-**R7** (score:63) r/AIHubSpace (2026-02-13) [0 pts, 0 comments]
- [SEEDANCE 2.0] I made the post and many of you doubted it. I warned you that “Hollywood” would take action.
- https://www.reddit.com/r/AIHubSpace/comments/1r3satk/seedance_20_i_made_the_post_and_many_of_you/
- *Large discussion about Seedance 2.0 generated videos and alleged copyright backlash impacting the tool.*
-
-**R6** (score:63) r/tldrAI (2026-02-12) [0 pts, 0 comments] [also on: HN5, HN4]
- ByteDance launches Seedance 2.0 AI video generator
- https://www.reddit.com/r/tldrAI/comments/1r352wd/bytedance_launches_seedance_20_ai_video_generator/
- *News-style discussion thread about Seedance 2.0 as an AI video generator and where it is available.*
-
-**R8** (score:60) r/AiVideos_NoRules (2026-02-15) [0 pts, 0 comments]
- Seedance 2.0
- https://www.reddit.com/r/AiVideos_NoRules/comments/1r51m86/seedance_20/
- *Thread discussing Seedance 2.0 as a video generation model and its perceived capabilities/impact.*
-
-**R5** (score:59) r/u_Impossible-Dish409 (2026-02-11) [0 pts, 0 comments]
- Seedance 2.0 – What’s New and Why It Matters
- https://www.reddit.com/r/u_Impossible-Dish409/comments/1r1lekj/seedance_20_whats_new_and_why_it_matters/
- *Overview thread describing Seedance 2.0 capabilities for multimodal video generation/editing.*
-
-#### X/Twitter Posts
-
-**X9** (score:86) @HBCoop_ (2026-02-25) [9 likes, 0 rp]
- First Seedance 2.0 Test!
-
-Seedance 5.0 Lite @krea_ai → Seedance 2.0 @capcutapp
-
-Prompt: Close-up on her hands gripping the paintbrush, knuckles white
- https://x.com/HBCoop_/status/2026748738354450919
- **
-
-**X12** (score:77) @Noor_ul_ain43 (2026-02-25) [5 likes, 0 rp]
- Goku vs Broly , EPIC Full Battle!
-Witness the ultimate Saiyan showdown recreated with AI power. Explosive transformations, insane energy blasts, and
- https://x.com/Noor_ul_ain43/status/2026743666635845959
- **
-
-**X10** (score:66) @grok (2026-02-25) [2 likes, 0 rp]
- @WolfyBlair @Preda2005 @BytePlusGlobal @capcutapp Yes, it's true—Seedance 2.0 is now live in CapCut (desktop & mobile).
-
-See it in the AI video t
- https://x.com/grok/status/2026747009143549978
- **
-
-**X1** (score:64) @jznode (2026-02-25) [0 likes, 0 rp]
- @ChinyJPG capcut desktop app, they just added seedance 2.0 today. no chinese phone number needed, works globally. download the desktop version, look f
- https://x.com/jznode/status/2026755966545404015
- **
-
-**X5** (score:64) @Yousaf_340 (2026-02-25) [0 likes, 0 rp]
- @ProperPrompter Yes! Seedance 2.0 on CapCut means cinematic AI video editing is super accessible smooth action, multi-angle shots, and high-quality e
- https://x.com/Yousaf_340/status/2026751320716357996
- **
-
-**X6** (score:64) @Yousaf_340 (2026-02-25) [0 likes, 0 rp]
- @ivanka_humeniuk Seedance 2.0 is insane for cinematic AI video smooth action, multiple camera angles, and near-Hollywood quality all from a single pro
- https://x.com/Yousaf_340/status/2026750898769473829
- **
-
-**X3** (score:64) @EmmaUsesAi (2026-02-25) [0 likes, 0 rp]
- Seedance 2.0 is the motion engine behind many cinematic AI clips trending right now.
-
-It’s now native inside NemoVideo.
-
-You can:
-- Identify proven fo
- https://x.com/EmmaUsesAi/status/2026751784509673531
- **
-
-**X2** (score:64) @OneStrangeW (2026-02-25) [0 likes, 0 rp]
- You can use Seedance. If Seedance 2.0 hasn't launch yet, the prior version is great, and stick pretty well to the prompt. Of course the video agent mu
- https://x.com/OneStrangeW/status/2026755320362709093
- **
-
-**X11** (score:64) @WyldeChyldeRec (2026-02-25) [0 likes, 0 rp]
- 🚨Phishing Scam!
-
-Watch for emails titled:
-
-[Seedance & Wan AI] Unrestricted generation with Grok Imagine video added
-
-This is not real. We don't h
- https://x.com/WyldeChyldeRec/status/2026743867224240149
- **
-
-**X7** (score:56) @La_DeCrypt (2026-02-25) [1 likes, 0 rp]
- - Kling (https://t.co/WGphQsdLf1)
-
-- Seedance 2.0 (https://t.co/A8UzLTRGNo)
-
-☞ AI-powered video editing:
-
-- Descript (https://t.co/q4axNEjDzt)
-- Opus
- https://x.com/La_DeCrypt/status/2026750376687394884
- **
-
-**X8** (score:56) @TferThomas (2026-02-25) [1 likes, 0 rp]
- Seedance 2.0 might be gen AI video’s next big hope, but it’s still slop https://t.co/kT1baOYJQk #AI
- https://x.com/TferThomas/status/2026749218224746609
- **
-
-#### YouTube Videos
-
-**F1kWxdfiBNE** (score:75, rel:0.75) AI Filmmaking Academy (2026-02-22) [40982 views, 1088 likes]
- Seedance 2.0 Claims the AI Video Throne!
- https://www.youtube.com/watch?v=F1kWxdfiBNE
- *YouTube: Seedance 2.0 Claims the AI Video Throne!*
-
-**_o2MuUX9UYg** (score:75, rel:0.75) Theoretically Media (2026-02-09) [199908 views, 4986 likes]
- Seedance 2.0 Claims the AI Video Throne!
- https://www.youtube.com/watch?v=_o2MuUX9UYg
- *YouTube: Seedance 2.0 Claims the AI Video Throne!*
- Transcript: So, it's been about a week since I declared Cling 3.0 the new benchmark for state-of-the-art AI video. And well, I mean,...
-
-**jkPTYD5lXo8** (score:71, rel:0.75) How To In 5 Minutes (2026-02-19) [44344 views, 782 likes]
- 100% FREE Seedance 2.0 AI Video Generator : How to Use It WORLDWIDE
- https://www.youtube.com/watch?v=jkPTYD5lXo8
- *YouTube: 100% FREE Seedance 2.0 AI Video Generator : How to Use It WO*
-
-**W_lxyDFDZt4** (score:71, rel:0.75) WealthWise (2026-02-12) [126599 views, 1204 likes]
- Seedance 2.0: The New Best AI Video Generator | Sora 2 Destroyed
- https://www.youtube.com/watch?v=W_lxyDFDZt4
- *YouTube: Seedance 2.0: The New Best AI Video Generator | Sora 2 Destr*
- Transcript: Well, major AI video update, ladies and gentlemen. Seance 2.0 is right around the corner. And let me tell you guys, this...
-
-**R1zl92NhCfE** (score:66, rel:0.75) Ai Lockup (2026-02-16) [32699 views, 591 likes]
- Seedance 2.0 How To Use and Become a Pro AI Film Maker With This AI Video Generator
- https://www.youtube.com/watch?v=R1zl92NhCfE
- *YouTube: Seedance 2.0 How To Use and Become a Pro AI Film Maker With *
-
-**G1Ad4a8sdJU** (score:64, rel:0.75) Benji’s AI Playground (2026-02-14) [38581 views, 419 likes]
- SeeDance 2.0: The Next Level of AI Video — And What It Means for Local AI Users?
- https://www.youtube.com/watch?v=G1Ad4a8sdJU
- *YouTube: SeeDance 2.0: The Next Level of AI Video — And What It Means*
-
-**61ThJGqwHsI** (score:59, rel:1.00) xCreate (2026-02-11) [4854 views, 86 likes]
- Ultimate AI Video Generation - Seedance 2.0 PREVIEW
- https://www.youtube.com/watch?v=61ThJGqwHsI
- *YouTube: Ultimate AI Video Generation - Seedance 2.0 PREVIEW*
-
-**5s2l68SrJP4** (score:58, rel:0.50) Airt (2026-02-11) [77744 views, 1733 likes]
- 100+ Seedance 2.0 AI Videos – The New King?
- https://www.youtube.com/watch?v=5s2l68SrJP4
- *YouTube: 100+ Seedance 2.0 AI Videos – The New King?*
- Transcript: That's the good stuff. And that's a wrap. [screaming] A lot of people asking how the roommate situation is going. He's c...
-
-**n625xVounGM** (score:50, rel:0.50) John Savage AI (2026-02-23) [7194 views, 126 likes]
- How To Generate FREE AI Seedance 2.0 Videos (Access Worldwide)
- https://www.youtube.com/watch?v=n625xVounGM
- *YouTube: How To Generate FREE AI Seedance 2.0 Videos (Access Worldwid*
-
-**kJ0NAVmd4f4** (score:36, rel:0.25) Rogue Cell Pictures (2026-02-25) [1594 views, 151 likes]
- Seedance 2.0 Changes Filmmaking Forever | New Original Series
- https://www.youtube.com/watch?v=kJ0NAVmd4f4
- *YouTube: Seedance 2.0 Changes Filmmaking Forever | New Original Serie*
-
-#### Hacker News Stories
-
-**HN8** (score:75) hn/Alisaqqt (2026-02-09) [7 pts, 7 cmt]
- Seedance 2.0 preview: The best video model of 2026, outperforming Sora 2
- https://news.ycombinator.com/item?id=46940720
- *HN story about Seedance 2.0 preview: The best video model of 2026, outperfo*
- - API is not available now.
- - API will be available on Atlas Cloud on Feb 24.
-
-**HN15** (score:57) hn/naxtsass (2026-02-25) [2 pts, 0 cmt]
- Show HN: SeeVideo – Access Seedance 2.0 and Kling 3.0 without a subscription
- https://news.ycombinator.com/item?id=47153236
- *HN story about Show HN: SeeVideo – Access Seedance 2.0 and Kling 3.0 withou*
-
-**HN6** (score:57) hn/howardV (2026-02-15) [3 pts, 0 cmt]
- Seedance 2.0: ByteDance's AI video model with native audio-video co-generation
- https://news.ycombinator.com/item?id=47022290
- *HN story about Seedance 2.0: ByteDance's AI video model with native audio-v*
-
-**HN5** (score:52) hn/jrran086 (2026-02-17) [1 pts, 0 cmt] [also on: R6]
- Seedance 2 Video Generator
- https://news.ycombinator.com/item?id=47046956
- *HN story about Seedance 2 Video Generator*
-
-**HN3** (score:52) hn/xbaicai (2026-02-09) [1 pts, 1 cmt]
- Seedance 2.0 – Multimodal AI Video Generation with Image/Video/Audio References
- https://news.ycombinator.com/item?id=46944030
- *HN story about Seedance 2.0 – Multimodal AI Video Generation with Image/Vid*
-
-**HN1** (score:51) hn/xuyanmei (2026-02-13) [1 pts, 0 cmt]
- Show HN: Seedance AShow HN: Seedance AI Video Generation (Next.js, Drizzle)
- https://news.ycombinator.com/item?id=46999394
- *HN story about Show HN: Seedance AShow HN: Seedance AI Video Generation (Ne*
-
-**HN12** (score:49) hn/RyanMu (2026-02-07) [2 pts, 1 cmt]
- Seedance2 – multi-shot AI video generation
- https://news.ycombinator.com/item?id=46924992
- *HN story about Seedance2 – multi-shot AI video generation*
- - ’ve been experimenting with AI video tools for a while, but most of them generate isolated clips that fall apart when you try to build an actual narrative
-
-**HN11** (score:48) hn/xuyanmei (2026-02-17) [1 pts, 0 cmt]
- Show HN: Seedance3AI – a web app for text-to-video, image-to-video
- https://news.ycombinator.com/item?id=47046989
- *HN story about Show HN: Seedance3AI – a web app for text-to-video, image-to*
-
-**HN2** (score:47) hn/littlepp (2026-02-09) [1 pts, 0 cmt]
- Discussion: Seedance-style AI video generation workflows
- https://news.ycombinator.com/item?id=46942781
- *HN story about Discussion: Seedance-style AI video generation workflows*
-
-**HN13** (score:44) hn/thomaskiko (2026-02-13) [1 pts, 0 cmt]
- Show HN: AI-powered video creation web app
- https://news.ycombinator.com/item?id=47002386
- *HN story about Show HN: AI-powered video creation web app*
-
-**HN9** (score:43) hn/TurnItOffAndOn0 (2026-02-09) [1 pts, 0 cmt]
- Show HN: AI Seedance 2 – Solving the "jump-cut" problem in AI video
- https://news.ycombinator.com/item?id=46946280
- *HN story about Show HN: AI Seedance 2 – Solving the "jump-cut" problem in A*
-
-**HN7** (score:43) hn/dallen97 (2026-02-08) [1 pts, 0 cmt]
- Show HN: Seedance 2.0 AI video generator for creators and ecommerce
- https://news.ycombinator.com/item?id=46930149
- *HN story about Show HN: Seedance 2.0 AI video generator for creators and ec*
-
-**HN10** (score:42) hn/thenextechtrade (2026-02-09) [1 pts, 0 cmt]
- Show HN: Seedance 2.0 – Native audio-visual sync video model
- https://news.ycombinator.com/item?id=46943172
- *HN story about Show HN: Seedance 2.0 – Native audio-visual sync video model*
-
-**HN14** (score:41) hn/echoadam (2026-02-10) [1 pts, 0 cmt]
- Show HN: Seedance2 – Stop "prompt guessing" and start directing AI video
- https://news.ycombinator.com/item?id=46956546
- *HN story about Show HN: Seedance2 – Stop "prompt guessing" and start direct*
-
-**HN4** (score:35) hn/Zach_HE (2026-01-27) [1 pts, 0 cmt] [also on: R6]
- Show HN: Seedance 2.0 Pro AI Video Generator
- https://news.ycombinator.com/item?id=46774950
- *HN story about Show HN: Seedance 2.0 Pro AI Video Generator*
-
-**Total: Reddit=19, X=11, YouTube=10, HN=15, Web=0**
-
-### Base - M4 MacBook Pro review
-
-#### Reddit Discussions
-
-**R1** (score:0) r/macbookpro (2025-04-09) [0 pts, 0 comments]
- M4 Macbook Pro 14’ 1 month review
- https://www.reddit.com/r/macbookpro/comments/1hflaaz/m4_macbook_pro_14_1_month_review/
- *Explicit 1-month review of the M4 MacBook Pro (ownership impressions, battery, durability, etc.).*
-
-**R2** (score:0) r/macbookpro (2025-05-19) [0 pts, 0 comments]
- MacBook Pro M4 Battery Life Reality vs Review
- https://www.reddit.com/r/macbookpro/comments/1kq2zd8/macbook_pro_m4_battery_life_reality_vs_review/
- *Discussion comparing real-world M4 MacBook Pro battery life to published reviews/testing methodology.*
-
-**R3** (score:0) r/macbookpro (2025-08-18) [0 pts, 0 comments]
- Disappointed with my MBP M4 Experience …
- https://www.reddit.com/r/macbookpro/comments/1mtmkts/disappointed_with_my_mbp_m4_experience/
- *First-hand experience post that references reviews and reports performance/heat/app stability issues on an M4 MBP.*
-
-#### X/Twitter Posts
-
-**X1** (score:86) @bhphoto (2026-02-25) [3 likes, 0 rp]
- If you are in the market for a new MacBook Pro and you’re wondering what the real differences are between Apple’s M3 and M4 silicon, you’ve come to th
- https://x.com/bhphoto/status/2026737767695183948
- **
-
-**X4** (score:70) @remplug (2026-02-25) [1 likes, 1 rp]
- 🔥 2024 16” MacBook Pro Price List
-
-1️⃣ 🇺🇸 Brand New MacBook Pro 2024 | M4 Pro Chip | 16” Display
-
-24GB RAM | 512GB SSD
-14-Core CPU | 20-Core GPU
-
-Spac
- https://x.com/remplug/status/2026725100213465443
- **
-
-**X10** (score:64) @grok (2026-02-25) [0 likes, 0 rp]
- @5TRgzn @LobstarWilde My marble floors: 64GB M4 Pro/Max MacBook Pro. Local Grok agents run full autonomy loops, zero cloud, sub-second trades/scans. I
- https://x.com/grok/status/2026713588472111312
- **
-
-**X9** (score:64) @principenemesis (2026-02-25) [0 likes, 0 rp]
- @JulianGoldieSEO @grok can a macbook pro m4 run this?
- https://x.com/principenemesis/status/2026716193042706852
- **
-
-**X5** (score:64) @jameslmorton (2026-02-25) [1 likes, 0 rp]
- @SebAaltonen Not my experience. It’s impressive, 70 tokens/s on a maxed out M4 128gb MacBook Pro.
-
-But absolutely sucked and not even remotely in the
- https://x.com/jameslmorton/status/2026721943630786816
- **
-
-**X6** (score:64) @bcofertas (2026-02-25) [0 likes, 0 rp]
- BR Ofertas todos os dias 🇧🇷
-
-Leve seu Mac Pro M4 hoje! 💻⚡✨
-
-Pegue já: https://t.co/PSwylZH3Sz
-
-Por R$ 23.749,00
-em até 10x sem juros
-Frete Grátis
-
-App
- https://x.com/bcofertas/status/2026721014676099084
- **
-
-**X12** (score:64) @grok (2026-02-25) [0 likes, 0 rp]
- Hey! Yeah, you can install Fedora on the M6 MacBook Pro via the official Fedora Asahi Remix (built on the Asahi Linux project). It already runs great
- https://x.com/grok/status/2026704108820824113
- **
-
-**X7** (score:64) @toppromoalertas (2026-02-25) [0 likes, 0 rp]
- Promoções BR do dia 🇧🇷
-
-Leve seu Mac Pro M4 hoje! 💻⚡✨
-
-Ver aqui: https://t.co/LfxNw3waps
-
-Por R$ 23.749,00
-em até 10x sem juros
-Frete Grátis
-
-Apple Ma
- https://x.com/toppromoalertas/status/2026719111523237932
- **
-
-**X8** (score:64) @grok (2026-02-25) [0 likes, 0 rp]
- Yes, a MacBook Pro M4 can run this Ollama + GLM-4.7-Flash setup locally.
-
-Ollama has native Apple Silicon support via Metal. The Q4_K_M model (~19GB)
- https://x.com/grok/status/2026716420269031652
- **
-
-**X2** (score:56) @grecinoscdev (2026-02-25) [1 likes, 0 rp]
- @TechByTaraa Both. macOS (Macbook Pro M4 Pro) for my photography/videography personal stuff + software engineering and a Linux desktop workstation for
- https://x.com/grecinoscdev/status/2026731118142148644
- **
-
-**X11** (score:56) @Steve_Lost_Jobs (2026-02-25) [1 likes, 0 rp]
- Apple 2024 MacBook Pro 14コアCPU、20コアGPU の M4 Pro搭載ノートパ... PR
-
-4549995547610
-
-ポイント: 3735㌽
-想定価格: 369,800
-
-2026/02/26 01:55:23
-https://t.co/q1uyIqFsID
- https://x.com/Steve_Lost_Jobs/status/2026705327828382016
- **
-
-**X3** (score:56) @grok (2026-02-25) [1 likes, 0 rp]
- No, Apple has not made any MacBook (Pro or otherwise) cost $299.
-
-The linked Newsroom search returns zero matching results—it's just unrelated announ
- https://x.com/grok/status/2026727605517394283
- **
-
-#### YouTube Videos
-
-**9HQx5pgUoiY** (score:61, rel:0.70) Marques Brownlee (2024-11-18) [4010567 views, 105606 likes]
- M4 Max MacBook Pro: I'm Convinced!
- https://www.youtube.com/watch?v=9HQx5pgUoiY
- *YouTube video about m4 macbook pro review*
- Transcript: so I have been using this M1 Max MacBook Pro for the past 3 years since it came out and it's been great I have felt abso...
-
-**etP2Th9g2hM** (score:55, rel:0.70) ShortCircuit (2024-11-30) [1113999 views, 22765 likes]
- Don't buy the Wrong MacBook like me... - M4 MacBook Pro
- https://www.youtube.com/watch?v=etP2Th9g2hM
- *YouTube video about m4 macbook pro review*
- Transcript: woohoo it's new Macbook day my favorite day on short circuit it only comes usually once a year this is the MacBook Pro M...
-
-**b4x8boB2KdI** (score:55, rel:0.70) Dave2D (2024-11-07) [1057988 views, 24977 likes]
- M4 MacBook Pro Review - Things to Know
- https://www.youtube.com/watch?v=b4x8boB2KdI
- *YouTube video about m4 macbook pro review*
- Transcript: so Apple launched the new M4 MacBook Pros this is their 14-in model and it's equipped with the M4 Max their topend confi...
-
-**uPe9spXLfZY** (score:50, rel:0.70) Created Tech (2025-07-07) [472845 views, 5082 likes]
- M4 MacBook Air vs M4 MacBook Pro - 4 Months Later
- https://www.youtube.com/watch?v=uPe9spXLfZY
- *YouTube video about m4 macbook pro review*
-
-**TfvIgdzImt4** (score:50, rel:0.70) Hardware Canucks (2024-12-15) [325599 views, 6339 likes]
- The Macbook Pro M4 is Insane.
- https://www.youtube.com/watch?v=TfvIgdzImt4
- *YouTube video about m4 macbook pro review*
-
-**a8Szdrnq0YM** (score:49, rel:0.70) Brandon Butch (2025-02-03) [352593 views, 4309 likes]
- MacBook Pro M4 - Review After 3 Months: This Feels Wrong.
- https://www.youtube.com/watch?v=a8Szdrnq0YM
- *YouTube video about m4 macbook pro review*
-
-**anIVewgtbFc** (score:48, rel:0.70) MacRumors (2024-12-17) [238619 views, 2874 likes]
- The Base M4 MacBook Pro is All You Need (Skip M4 Pro & Max)
- https://www.youtube.com/watch?v=anIVewgtbFc
- *YouTube video about m4 macbook pro review*
-
-**5rN6CEO31gM** (score:48, rel:0.70) Just Josh (2024-11-15) [154306 views, 4540 likes]
- MacBook Pro M4: Review & Recommendations
- https://www.youtube.com/watch?v=5rN6CEO31gM
- *YouTube video about m4 macbook pro review*
-
-**i3eTKJav1VI** (score:45, rel:0.70) Created Tech (2025-04-29) [137009 views, 1832 likes]
- M4 Pro MacBook - Long Term Review (6 Months Later)
- https://www.youtube.com/watch?v=i3eTKJav1VI
- *YouTube video about m4 macbook pro review*
-
-**QbSQH_95eg8** (score:36, rel:0.70) Tech It Easy (2026-02-01) [3176 views, 55 likes]
- MacBook Pro M4 Pro Review: 1 Year Later! (Still Worth Buying in 2026?)
- https://www.youtube.com/watch?v=QbSQH_95eg8
- *YouTube video about m4 macbook pro review*
-
-**Total: Reddit=3, X=12, YouTube=10, HN=0, Web=0**
-
-### HN - M4 MacBook Pro review
-
-#### Reddit Discussions
-
-**R1** (score:0) r/macbookpro (2025-04-09) [0 pts, 0 comments]
- M4 Macbook Pro 14’ 1 month review
- https://www.reddit.com/r/macbookpro/comments/1hflaaz/m4_macbook_pro_14_1_month_review/
- *Explicit 1-month review of the M4 MacBook Pro with long-term impressions in comments.*
-
-**R2** (score:0) r/macbookpro (2025-05-19) [0 pts, 0 comments]
- MacBook Pro M4 Battery Life Reality vs Review
- https://www.reddit.com/r/macbookpro/comments/1kq2zd8/macbook_pro_m4_battery_life_reality_vs_review/
- *Compares real-world battery life to reviewer/Apple claims; review-focused discussion.*
-
-**R3** (score:0) r/macbookpro (2025-08-18) [0 pts, 0 comments]
- Disappointed with my MBP M4 Experience …
- https://www.reddit.com/r/macbookpro/comments/1mtmkts/disappointed_with_my_mbp_m4_experience/
- *User experience report contrasting with glowing reviews; detailed performance/quality complaints.*
-
-#### X/Twitter Posts
-
-**X1** (score:86) @bhphoto (2026-02-25) [3 likes, 0 rp]
- If you are in the market for a new MacBook Pro and you’re wondering what the real differences are between Apple’s M3 and M4 silicon, you’ve come to th
- https://x.com/bhphoto/status/2026737767695183948
- **
-
-**X4** (score:70) @remplug (2026-02-25) [1 likes, 1 rp]
- 🔥 2024 16” MacBook Pro Price List
-
-1️⃣ 🇺🇸 Brand New MacBook Pro 2024 | M4 Pro Chip | 16” Display
-
-24GB RAM | 512GB SSD
-14-Core CPU | 20-Core GPU
-
-Spac
- https://x.com/remplug/status/2026725100213465443
- **
-
-**X10** (score:64) @grok (2026-02-25) [0 likes, 0 rp]
- @5TRgzn @LobstarWilde My marble floors: 64GB M4 Pro/Max MacBook Pro. Local Grok agents run full autonomy loops, zero cloud, sub-second trades/scans. I
- https://x.com/grok/status/2026713588472111312
- **
-
-**X9** (score:64) @principenemesis (2026-02-25) [0 likes, 0 rp]
- @JulianGoldieSEO @grok can a macbook pro m4 run this?
- https://x.com/principenemesis/status/2026716193042706852
- **
-
-**X5** (score:64) @jameslmorton (2026-02-25) [1 likes, 0 rp]
- @SebAaltonen Not my experience. It’s impressive, 70 tokens/s on a maxed out M4 128gb MacBook Pro.
-
-But absolutely sucked and not even remotely in the
- https://x.com/jameslmorton/status/2026721943630786816
- **
-
-**X6** (score:64) @bcofertas (2026-02-25) [0 likes, 0 rp]
- BR Ofertas todos os dias 🇧🇷
-
-Leve seu Mac Pro M4 hoje! 💻⚡✨
-
-Pegue já: https://t.co/PSwylZH3Sz
-
-Por R$ 23.749,00
-em até 10x sem juros
-Frete Grátis
-
-App
- https://x.com/bcofertas/status/2026721014676099084
- **
-
-**X12** (score:64) @grok (2026-02-25) [0 likes, 0 rp]
- Hey! Yeah, you can install Fedora on the M6 MacBook Pro via the official Fedora Asahi Remix (built on the Asahi Linux project). It already runs great
- https://x.com/grok/status/2026704108820824113
- **
-
-**X7** (score:64) @toppromoalertas (2026-02-25) [0 likes, 0 rp]
- Promoções BR do dia 🇧🇷
-
-Leve seu Mac Pro M4 hoje! 💻⚡✨
-
-Ver aqui: https://t.co/LfxNw3waps
-
-Por R$ 23.749,00
-em até 10x sem juros
-Frete Grátis
-
-Apple Ma
- https://x.com/toppromoalertas/status/2026719111523237932
- **
-
-**X8** (score:64) @grok (2026-02-25) [0 likes, 0 rp]
- Yes, a MacBook Pro M4 can run this Ollama + GLM-4.7-Flash setup locally.
-
-Ollama has native Apple Silicon support via Metal. The Q4_K_M model (~19GB)
- https://x.com/grok/status/2026716420269031652
- **
-
-**X2** (score:56) @grecinoscdev (2026-02-25) [1 likes, 0 rp]
- @TechByTaraa Both. macOS (Macbook Pro M4 Pro) for my photography/videography personal stuff + software engineering and a Linux desktop workstation for
- https://x.com/grecinoscdev/status/2026731118142148644
- **
-
-**X11** (score:56) @Steve_Lost_Jobs (2026-02-25) [1 likes, 0 rp]
- Apple 2024 MacBook Pro 14コアCPU、20コアGPU の M4 Pro搭載ノートパ... PR
-
-4549995547610
-
-ポイント: 3735㌽
-想定価格: 369,800
-
-2026/02/26 01:55:23
-https://t.co/q1uyIqFsID
- https://x.com/Steve_Lost_Jobs/status/2026705327828382016
- **
-
-**X3** (score:56) @grok (2026-02-25) [1 likes, 0 rp]
- No, Apple has not made any MacBook (Pro or otherwise) cost $299.
-
-The linked Newsroom search returns zero matching results—it's just unrelated announ
- https://x.com/grok/status/2026727605517394283
- **
-
-#### YouTube Videos
-
-**9HQx5pgUoiY** (score:61, rel:0.70) Marques Brownlee (2024-11-18) [4010567 views, 105606 likes]
- M4 Max MacBook Pro: I'm Convinced!
- https://www.youtube.com/watch?v=9HQx5pgUoiY
- *YouTube video about m4 macbook pro review*
- Transcript: so I have been using this M1 Max MacBook Pro for the past 3 years since it came out and it's been great I have felt abso...
-
-**etP2Th9g2hM** (score:55, rel:0.70) ShortCircuit (2024-11-30) [1113999 views, 22765 likes]
- Don't buy the Wrong MacBook like me... - M4 MacBook Pro
- https://www.youtube.com/watch?v=etP2Th9g2hM
- *YouTube video about m4 macbook pro review*
- Transcript: woohoo it's new Macbook day my favorite day on short circuit it only comes usually once a year this is the MacBook Pro M...
-
-**b4x8boB2KdI** (score:55, rel:0.70) Dave2D (2024-11-07) [1057988 views, 24977 likes]
- M4 MacBook Pro Review - Things to Know
- https://www.youtube.com/watch?v=b4x8boB2KdI
- *YouTube video about m4 macbook pro review*
- Transcript: so Apple launched the new M4 MacBook Pros this is their 14-in model and it's equipped with the M4 Max their topend confi...
-
-**uPe9spXLfZY** (score:50, rel:0.70) Created Tech (2025-07-07) [472845 views, 5082 likes]
- M4 MacBook Air vs M4 MacBook Pro - 4 Months Later
- https://www.youtube.com/watch?v=uPe9spXLfZY
- *YouTube video about m4 macbook pro review*
-
-**TfvIgdzImt4** (score:50, rel:0.70) Hardware Canucks (2024-12-15) [325597 views, 6339 likes]
- The Macbook Pro M4 is Insane.
- https://www.youtube.com/watch?v=TfvIgdzImt4
- *YouTube video about m4 macbook pro review*
-
-**a8Szdrnq0YM** (score:49, rel:0.70) Brandon Butch (2025-02-03) [352593 views, 4309 likes]
- MacBook Pro M4 - Review After 3 Months: This Feels Wrong.
- https://www.youtube.com/watch?v=a8Szdrnq0YM
- *YouTube video about m4 macbook pro review*
-
-**anIVewgtbFc** (score:48, rel:0.70) MacRumors (2024-12-17) [238616 views, 2874 likes]
- The Base M4 MacBook Pro is All You Need (Skip M4 Pro & Max)
- https://www.youtube.com/watch?v=anIVewgtbFc
- *YouTube video about m4 macbook pro review*
-
-**5rN6CEO31gM** (score:48, rel:0.70) Just Josh (2024-11-15) [154306 views, 4540 likes]
- MacBook Pro M4: Review & Recommendations
- https://www.youtube.com/watch?v=5rN6CEO31gM
- *YouTube video about m4 macbook pro review*
-
-**i3eTKJav1VI** (score:45, rel:0.70) Created Tech (2025-04-29) [137009 views, 1832 likes]
- M4 Pro MacBook - Long Term Review (6 Months Later)
- https://www.youtube.com/watch?v=i3eTKJav1VI
- *YouTube video about m4 macbook pro review*
-
-**QbSQH_95eg8** (score:36, rel:0.70) Tech It Easy (2026-02-01) [3176 views, 55 likes]
- MacBook Pro M4 Pro Review: 1 Year Later! (Still Worth Buying in 2026?)
- https://www.youtube.com/watch?v=QbSQH_95eg8
- *YouTube video about m4 macbook pro review*
-
-**Total: Reddit=3, X=12, YouTube=10, HN=0, Web=0**
-
-### CROSS - M4 MacBook Pro review
-
-#### Reddit Discussions
-
-**R1** (score:0) r/macbookpro (2025-08-18) [0 pts, 0 comments]
- Disappointed with my MBP M4 Experience …
- https://www.reddit.com/r/macbookpro/comments/1mtmkts/disappointed_with_my_mbp_m4_experience/
- *First-hand experience post reacting to reviews and describing real-world issues/behavior on an M4 MacBook Pro.*
-
-**R2** (score:0) r/macbookpro (2025-06-08) [0 pts, 0 comments]
- Returned M4 MacBook Air for M4 MacBook Pro
- https://www.reddit.com/r/macbookpro/comments/1l6cx3y/returned_m4_macbook_air_for_m4_macbook_pro/
- *Comparison/mini-review style discussion after switching to an M4 MacBook Pro (display, ports, speakers, performance).*
-
-**R3** (score:0) r/macbookpro (2025-04-04) [0 pts, 0 comments]
- M4 MacBook Pro 14 Inch Battery Life, Should I Be Concerned??
- https://www.reddit.com/r/macbookpro/comments/1jra25q/m4_macbook_pro_14_inch_battery_life_should_i_be_concerned/
- *Battery life discussion framed against Apple’s advertised numbers; lots of owner feedback (real-world “review” angle).*
-
-#### X/Twitter Posts
-
-**X1** (score:86) @bhphoto (2026-02-25) [3 likes, 0 rp]
- If you are in the market for a new MacBook Pro and you’re wondering what the real differences are between Apple’s M3 and M4 silicon, you’ve come to th
- https://x.com/bhphoto/status/2026737767695183948
- **
-
-**X4** (score:70) @remplug (2026-02-25) [1 likes, 1 rp]
- 🔥 2024 16” MacBook Pro Price List
-
-1️⃣ 🇺🇸 Brand New MacBook Pro 2024 | M4 Pro Chip | 16” Display
-
-24GB RAM | 512GB SSD
-14-Core CPU | 20-Core GPU
-
-Spac
- https://x.com/remplug/status/2026725100213465443
- **
-
-**X10** (score:64) @grok (2026-02-25) [0 likes, 0 rp]
- @5TRgzn @LobstarWilde My marble floors: 64GB M4 Pro/Max MacBook Pro. Local Grok agents run full autonomy loops, zero cloud, sub-second trades/scans. I
- https://x.com/grok/status/2026713588472111312
- **
-
-**X9** (score:64) @principenemesis (2026-02-25) [0 likes, 0 rp]
- @JulianGoldieSEO @grok can a macbook pro m4 run this?
- https://x.com/principenemesis/status/2026716193042706852
- **
-
-**X5** (score:64) @jameslmorton (2026-02-25) [1 likes, 0 rp]
- @SebAaltonen Not my experience. It’s impressive, 70 tokens/s on a maxed out M4 128gb MacBook Pro.
-
-But absolutely sucked and not even remotely in the
- https://x.com/jameslmorton/status/2026721943630786816
- **
-
-**X6** (score:64) @bcofertas (2026-02-25) [0 likes, 0 rp]
- BR Ofertas todos os dias 🇧🇷
-
-Leve seu Mac Pro M4 hoje! 💻⚡✨
-
-Pegue já: https://t.co/PSwylZH3Sz
-
-Por R$ 23.749,00
-em até 10x sem juros
-Frete Grátis
-
-App
- https://x.com/bcofertas/status/2026721014676099084
- **
-
-**X12** (score:64) @grok (2026-02-25) [0 likes, 0 rp]
- Hey! Yeah, you can install Fedora on the M6 MacBook Pro via the official Fedora Asahi Remix (built on the Asahi Linux project). It already runs great
- https://x.com/grok/status/2026704108820824113
- **
-
-**X7** (score:64) @toppromoalertas (2026-02-25) [0 likes, 0 rp]
- Promoções BR do dia 🇧🇷
-
-Leve seu Mac Pro M4 hoje! 💻⚡✨
-
-Ver aqui: https://t.co/LfxNw3waps
-
-Por R$ 23.749,00
-em até 10x sem juros
-Frete Grátis
-
-Apple Ma
- https://x.com/toppromoalertas/status/2026719111523237932
- **
-
-**X8** (score:64) @grok (2026-02-25) [0 likes, 0 rp]
- Yes, a MacBook Pro M4 can run this Ollama + GLM-4.7-Flash setup locally.
-
-Ollama has native Apple Silicon support via Metal. The Q4_K_M model (~19GB)
- https://x.com/grok/status/2026716420269031652
- **
-
-**X2** (score:56) @grecinoscdev (2026-02-25) [1 likes, 0 rp]
- @TechByTaraa Both. macOS (Macbook Pro M4 Pro) for my photography/videography personal stuff + software engineering and a Linux desktop workstation for
- https://x.com/grecinoscdev/status/2026731118142148644
- **
-
-**X11** (score:56) @Steve_Lost_Jobs (2026-02-25) [1 likes, 0 rp]
- Apple 2024 MacBook Pro 14コアCPU、20コアGPU の M4 Pro搭載ノートパ... PR
-
-4549995547610
-
-ポイント: 3735㌽
-想定価格: 369,800
-
-2026/02/26 01:55:23
-https://t.co/q1uyIqFsID
- https://x.com/Steve_Lost_Jobs/status/2026705327828382016
- **
-
-**X3** (score:56) @grok (2026-02-25) [1 likes, 0 rp]
- No, Apple has not made any MacBook (Pro or otherwise) cost $299.
-
-The linked Newsroom search returns zero matching results—it's just unrelated announ
- https://x.com/grok/status/2026727605517394283
- **
-
-#### YouTube Videos
-
-**9HQx5pgUoiY** (score:63, rel:0.75) Marques Brownlee (2024-11-18) [4010567 views, 105606 likes]
- M4 Max MacBook Pro: I'm Convinced!
- https://www.youtube.com/watch?v=9HQx5pgUoiY
- *YouTube: M4 Max MacBook Pro: I'm Convinced!*
- Transcript: so I have been using this M1 Max MacBook Pro for the past 3 years since it came out and it's been great I have felt abso...
-
-**b4x8boB2KdI** (score:63, rel:1.00) Dave2D (2024-11-07) [1057971 views, 24977 likes]
- M4 MacBook Pro Review - Things to Know
- https://www.youtube.com/watch?v=b4x8boB2KdI
- *YouTube: M4 MacBook Pro Review - Things to Know*
- Transcript: so Apple launched the new M4 MacBook Pros this is their 14-in model and it's equipped with the M4 Max their topend confi...
-
-**a8Szdrnq0YM** (score:52, rel:1.00) Brandon Butch (2025-02-03) [352593 views, 4309 likes]
- MacBook Pro M4 - Review After 3 Months: This Feels Wrong.
- https://www.youtube.com/watch?v=a8Szdrnq0YM
- *YouTube: MacBook Pro M4 - Review After 3 Months: This Feels Wrong.*
-
-**etP2Th9g2hM** (score:51, rel:0.75) ShortCircuit (2024-11-30) [1113999 views, 22765 likes]
- Don't buy the Wrong MacBook like me... - M4 MacBook Pro
- https://www.youtube.com/watch?v=etP2Th9g2hM
- *YouTube: Don't buy the Wrong MacBook like me... - M4 MacBook Pro*
- Transcript: woohoo it's new Macbook day my favorite day on short circuit it only comes usually once a year this is the MacBook Pro M...
-
-**i3eTKJav1VI** (score:45, rel:1.00) Created Tech (2025-04-29) [137009 views, 1832 likes]
- M4 Pro MacBook - Long Term Review (6 Months Later)
- https://www.youtube.com/watch?v=i3eTKJav1VI
- *YouTube: M4 Pro MacBook - Long Term Review (6 Months Later)*
-
-**uPe9spXLfZY** (score:43, rel:0.75) Created Tech (2025-07-07) [472845 views, 5082 likes]
- M4 MacBook Air vs M4 MacBook Pro - 4 Months Later
- https://www.youtube.com/watch?v=uPe9spXLfZY
- *YouTube: M4 MacBook Air vs M4 MacBook Pro - 4 Months Later*
-
-**TfvIgdzImt4** (score:42, rel:0.75) Hardware Canucks (2024-12-15) [325599 views, 6339 likes]
- The Macbook Pro M4 is Insane.
- https://www.youtube.com/watch?v=TfvIgdzImt4
- *YouTube: The Macbook Pro M4 is Insane.*
-
-**anIVewgtbFc** (score:38, rel:0.75) MacRumors (2024-12-17) [238619 views, 2874 likes]
- The Base M4 MacBook Pro is All You Need (Skip M4 Pro & Max)
- https://www.youtube.com/watch?v=anIVewgtbFc
- *YouTube: The Base M4 MacBook Pro is All You Need (Skip M4 Pro & Max)*
-
-**BDpNDniE1PA** (score:37, rel:0.75) Max Tech (2025-04-18) [197898 views, 3077 likes]
- M4 Pro MacBook Pro after 6 Months - Everyone was WRONG!
- https://www.youtube.com/watch?v=BDpNDniE1PA
- *YouTube: M4 Pro MacBook Pro after 6 Months - Everyone was WRONG!*
-
-**0vn9T3x4dGg** (score:37, rel:0.75) Max Tech (2024-12-09) [200580 views, 3345 likes]
- M4 Pro MacBook Pro after 1 Month... BEST Mac EVER!?
- https://www.youtube.com/watch?v=0vn9T3x4dGg
- *YouTube: M4 Pro MacBook Pro after 1 Month... BEST Mac EVER!?*
-
-**Total: Reddit=3, X=12, YouTube=10, HN=0, Web=0**
-
-### Base - best rap songs 2026
-
-#### Reddit Discussions
-
-**R10** (score:57) r/TeenageRapFans (2026-02-15) [0 pts, 0 comments]
- What's the best rap song ever made?
- https://www.reddit.com/r/TeenageRapFans/comments/1r5iub6/whats_the_best_rap_song_ever_made/
- *Direct ‘best rap song’ debate thread posted in 2026; good for mining frequently-cited ‘best’ contenders.*
-
-**R12** (score:52) r/HivemindTV (2026-02-19) [0 pts, 0 comments]
- This is their worst bracket ever
- https://www.reddit.com/r/HivemindTV/comments/1r8px0j/this_is_their_worst_bracket_ever/
- *Meta thread about a ‘Best Rap songs of the 21st century’ bracket/video; includes debate about picks and what “best rap songs” should be.*
-
-**R4** (score:49) r/hiphopheads (2026-02-13) [0 pts, 0 comments]
- Daily Discussion Thread 02/13/2026
- https://www.reddit.com/r/hiphopheads/comments/1r3mudz/daily_discussion_thread_02132026/
- *General hip-hop discussion thread dated in 2026 where users frequently debate best tracks (including “best track is probably…” style mini-rankings).*
-
-**R3** (score:48) r/hiphopheads (2026-02-01) [0 pts, 0 comments]
- Pre-Show Grammy Winners
- https://www.reddit.com/r/hiphopheads/comments/1qtcbmo/preshow_grammy_winners/
- *Discusses 2026 Grammys rap categories (Best Rap Song/Performance, etc.), which commonly overlaps with ‘best rap songs’ discussions.*
-
-**R14** (score:41) r/edranked (2026-02-01) [0 pts, 0 comments]
- Reddit Ranked: Hip Hop ‘26
- https://www.reddit.com/r/redranked/comments/1qszauu/reddit_ranked_hip_hop_26/
- *Community submission/ranking project for Hip Hop ‘26; relevant to discovering and discussing top tracks for 2026.*
-
-**R7** (score:28) r/hiphopheads (None) [0 pts, 0 comments]
- Pitchfork: The 32 Best Rap Albums of 2025
- https://www.reddit.com/r/hiphopheads/comments/1pjywih/pitchfork_the_32_best_rap_albums_of_2025/
- *Best-of list discussion (albums) that typically includes commenters calling out standout songs; adjacent to ‘best rap songs’ discourse heading into 2026.*
-
-#### X/Twitter Posts
-
-**X5** (score:86) @Zika_gfx (2026-02-25) [11 likes, 5 rp]
- Talking about meaningful rap songs...
-
-WORLD BEST LIE - NUNO ZIGI (2026)🥺❤ https://t.co/MDHRf3giYq
- https://x.com/Zika_gfx/status/2026576570513600788
- **
-
-**X6** (score:69) @DPOSTS6 (2026-02-25) [4 likes, 1 rp]
- @PopCrave Partition still goes harder than most 2026 songs
-12 years and the beat switch + that rap still give chills every time. Queen never misses
- https://x.com/DPOSTS6/status/2026523320548819145
- **
-
-**X9** (score:64) @grok (2026-02-25) [0 likes, 0 rp]
- @Unfath0m4ble @robinthisbish @lilceaserthabp1 @RapWikip What You Saying by Lil Uzi Vert. Dropped Dec 2025, hit #1 on Hot Rap Songs in Jan 2026 (his 3r
- https://x.com/grok/status/2026467153046606064
- **
-
-**X7** (score:64) @grok (2026-02-25) [0 likes, 0 rp]
- @lil_loonie1 @oldmanebro No, Lil Uzi Vert isn't struggling. His "What You Saying" hit #1 on Hot Rap Songs in Jan 2026 (first in 3 years) and top 15 Ho
- https://x.com/grok/status/2026518008827404632
- **
-
-**X2** (score:64) @Ferginangi (2026-02-25) [0 likes, 0 rp]
- Su La Musica che gira intorno... "Qui con me" è il brano con cui Serena Brancale è in gara al 76° Festival di Sanremo
-https://t.co/wTPeovzt6u
-#music #
- https://x.com/Ferginangi/status/2026696207557382298
- **
-
-**X12** (score:62) @teammusic2046 (2026-02-23) [0 likes, 0 rp]
- [ ** STUDIO BOOTH ** ] ** DROPPING SOON - 2026 **🔥🔥🔥………..[ ALBUM : LOVE VS LOYALTY - DROPPING SOON ! ]………🔥🔥🔥 - ** ALL SONGS & BEATS, PRODUCED &
- https://x.com/teammusic2046/status/2026040965329191061
- **
-
-**X11** (score:59) @IoWeul (2026-02-24) [2 likes, 0 rp]
- @PopBase Finally, @bts_bighit BTS will come back after completing their military service from 2022 to 2025.
-BTS served in the military to protect thei
- https://x.com/IoWeul/status/2026233090683863471
- **
-
-**X8** (score:58) @grok (2026-02-25) [1 likes, 0 rp]
- Lil Uzi Vert (born July '95, so 30 now) isn't struggling at all. Fresh off "What You Saying" hitting #1 on Hot Rap Songs in Jan 2026, top 15 Hot 100.
- https://x.com/grok/status/2026498538075554040
- **
-
-**X4** (score:58) @AlterEgoPopList (2026-02-25) [1 likes, 0 rp]
- My musical taste / eras every year
-
-2020: J-POP & K-POP (mostly BLACKPINK)
-2021: Sped-Up Songs
-2022: EDM
-2023: Swiftie (Pop) & Lofi
-2024: Pop,
- https://x.com/AlterEgoPopList/status/2026641456434393579
- **
-
-**X3** (score:56) @WordsFromBlerds (2026-02-25) [1 likes, 0 rp]
- @_CharlesPreston Can’t believe I’m seeing “they say it in rap songs!” In 2026
-
-The same reductive, ancient, debunked arguments ad infinitum
- https://x.com/WordsFromBlerds/status/2026647382083903688
- **
-
-**X1** (score:56) @Ferginangi (2026-02-25) [1 likes, 0 rp]
- Su La Musica che gira intorno... "Magica Favola" è il brano con cui Arisa è in gara al 76° Festival di Sanremo
-https://t.co/Nxzkb005lP
-#music #genre #
- https://x.com/Ferginangi/status/2026705227366256880
- **
-
-**X10** (score:55) @fo_sho52268 (2026-02-24) [1 likes, 0 rp]
- @blackiiingout 💩 is funny from 2025 to 2026. BiiCH is SCARED to drop that #TRASH he calls "rap songs" 🥴😩
- https://x.com/fo_sho52268/status/2026335353364201566
- **
-
-#### YouTube Videos
-
-**ZUExyc50ZVU** (score:63, rel:0.70) West Coast Finest (2026-01-29) [407260 views, 4228 likes]
- Lit Hip Hop Mix 2026🔥🔥🔥Tyga, Quavo, Iggy Azalea, Wiz Khalifa, Juicy J, 50 Cent
- https://www.youtube.com/watch?v=ZUExyc50ZVU
- *YouTube video about rap songs 2026*
- Transcript: Yeah. >> Yeah. [music] Late nights, cold life, same story. >> City shine different. You walking with glory, ...
-
-**d1melQQVp6s** (score:58, rel:0.70) DJ Noize (2026-02-14) [42707 views, 761 likes]
- New Rap Songs 2026 Mix February | Trap Tape #127 | New Hip Hop 2026 Mixtape | DJ Noize
- https://www.youtube.com/watch?v=d1melQQVp6s
- *YouTube video about rap songs 2026*
- Transcript: [music] This This is a DJ noise mixtape present. This is a DJ noise mixtape [music] presentation. DJ [music] >> Wh...
-
-**-KQpPySP93I** (score:53, rel:0.70) DJ Noize (2026-02-21) [12955 views, 304 likes]
- New Hip Hop R&B Songs 2026 Mix February | Hot Right Now #153 | New Rap 2026 Playlist | DJ Noize
- https://www.youtube.com/watch?v=-KQpPySP93I
- *YouTube video about rap songs 2026*
- Transcript: This This is a DJ noise tape present. This is a DJ noise mix. [music] I got Nikki on me. How we treat the like a bucket ...
-
-**Total: Reddit=6, X=12, YouTube=3, HN=0, Web=0**
-
-### HN - best rap songs 2026
-
-#### Reddit Discussions
-
-**R6** (score:71) r/playlists (2026-02-19) [0 pts, 0 comments]
- Mix Rap Hiphop 2026
- https://www.reddit.com/r/playlists/comments/1r8t0ld/mix_rap_hiphop_2026/
- *Explicitly a 2026 rap/hip-hop playlist thread; useful for current-year song discovery.*
-
-**R8** (score:66) r/hiphopheads (2026-02-25) [0 pts, 0 comments]
- Daily Discussion Thread 02/24/2026
- https://www.reddit.com/r/hiphopheads/comments/1rdjmgu/daily_discussion_thread_02242026/
- *General hip-hop discussion thread around late Feb 2026; often includes song/album recs and what people are listening to now.*
-
-**R2** (score:60) r/musicplaylists (2026-02-02) [0 pts, 0 comments]
- Best 2026 Rap Playlist (178 saves)
- https://www.reddit.com/r/musicplaylists/comments/1qu3i2n/best_2026_rap_playlist_178_saves/
- *Directly about a “Best 2026 Rap Playlist” (songs for 2026), with comments inviting suggestions.*
-
-**R18** (score:56) r/hiphop (2026-02-21) [0 pts, 0 comments]
- DFL - Art Of Life (2026)
- https://www.reddit.com/r/hiphop/comments/1raoitx/dfl_art_of_life_2026/
- *A 2026 rap/hip-hop track post; useful to gather candidate songs people might call “best of 2026.”*
-
-**R19** (score:55) r/hiphop (2026-02-21) [0 pts, 0 comments]
- Plat Hav Pro - Get F'D Up (2026)
- https://www.reddit.com/r/hiphop/comments/1ragaf5/plat_hav_pro_get_fd_up_2026/
- *Another 2026 track post; relevant to discovering rap songs released/posted in 2026 that could be considered among the best.*
-
-**R21** (score:55) r/TeenageRapFans (2026-02-15) [0 pts, 0 comments]
- What's the best rap song ever made?
- https://www.reddit.com/r/TeenageRapFans/comments/1r5iub6/whats_the_best_rap_song_ever_made/
- *Direct ‘best rap song’ discussion (not year-specific, but posted in 2026 and highly aligned with ‘best rap songs’ topic).*
-
-**R13** (score:53) r/hiphopheads (2026-02-14) [0 pts, 0 comments]
- [DISCUSSION] Westside Gunn - 12 (1 Year Later)
- https://www.reddit.com/r/hiphopheads/comments/1r4nbsr/discussion_westside_gunn_12_1_year_later/
- *Album discussion thread that includes ‘favorite track’ prompts; useful for identifying best songs/tracks people cite.*
-
-**R20** (score:51) r/hiphop (2026-02-16) [0 pts, 0 comments]
- Jody Lo - Ridiculous (2026)
- https://www.reddit.com/r/hiphop/comments/1r6c6i7/jody_lo_ridiculous_2026/
- *2026 track thread; relevant for compiling 2026 rap songs being shared/discussed.*
-
-**R15** (score:51) r/edranked (2026-02-01) [0 pts, 0 comments]
- Reddit Ranked: Hip Hop ‘26
- https://www.reddit.com/r/redranked/comments/1qszauu/reddit_ranked_hip_hop_26/
- *Ongoing 2026 hip-hop ranking/submission thread; good for discovering community-submitted ‘best’ tracks in 2026.*
-
-**R9** (score:49) r/hiphopheads (2026-02-01) [0 pts, 0 comments]
- Pre-Show Grammy Winners
- https://www.reddit.com/r/hiphopheads/comments/1qtcbmo/preshow_grammy_winners/
- *Includes ‘Best Rap Song’/rap categories discussion; useful when searching for top/best rap songs discourse in 2026.*
-
-#### X/Twitter Posts
-
-**X5** (score:86) @Zika_gfx (2026-02-25) [12 likes, 5 rp]
- Talking about meaningful rap songs...
-
-WORLD BEST LIE - NUNO ZIGI (2026)🥺❤ https://t.co/MDHRf3giYq
- https://x.com/Zika_gfx/status/2026576570513600788
- **
-
-**X6** (score:68) @DPOSTS6 (2026-02-25) [4 likes, 1 rp]
- @PopCrave Partition still goes harder than most 2026 songs
-12 years and the beat switch + that rap still give chills every time. Queen never misses
- https://x.com/DPOSTS6/status/2026523320548819145
- **
-
-**X9** (score:64) @grok (2026-02-25) [0 likes, 0 rp]
- @Unfath0m4ble @robinthisbish @lilceaserthabp1 @RapWikip What You Saying by Lil Uzi Vert. Dropped Dec 2025, hit #1 on Hot Rap Songs in Jan 2026 (his 3r
- https://x.com/grok/status/2026467153046606064
- **
-
-**X7** (score:64) @grok (2026-02-25) [0 likes, 0 rp]
- @lil_loonie1 @oldmanebro No, Lil Uzi Vert isn't struggling. His "What You Saying" hit #1 on Hot Rap Songs in Jan 2026 (first in 3 years) and top 15 Ho
- https://x.com/grok/status/2026518008827404632
- **
-
-**X2** (score:64) @Ferginangi (2026-02-25) [0 likes, 0 rp]
- Su La Musica che gira intorno... "Qui con me" è il brano con cui Serena Brancale è in gara al 76° Festival di Sanremo
-https://t.co/wTPeovzt6u
-#music #
- https://x.com/Ferginangi/status/2026696207557382298
- **
-
-**X12** (score:62) @teammusic2046 (2026-02-23) [0 likes, 0 rp]
- [ ** STUDIO BOOTH ** ] ** DROPPING SOON - 2026 **🔥🔥🔥………..[ ALBUM : LOVE VS LOYALTY - DROPPING SOON ! ]………🔥🔥🔥 - ** ALL SONGS & BEATS, PRODUCED &
- https://x.com/teammusic2046/status/2026040965329191061
- **
-
-**X11** (score:59) @IoWeul (2026-02-24) [2 likes, 0 rp]
- @PopBase Finally, @bts_bighit BTS will come back after completing their military service from 2022 to 2025.
-BTS served in the military to protect thei
- https://x.com/IoWeul/status/2026233090683863471
- **
-
-**X8** (score:58) @grok (2026-02-25) [1 likes, 0 rp]
- Lil Uzi Vert (born July '95, so 30 now) isn't struggling at all. Fresh off "What You Saying" hitting #1 on Hot Rap Songs in Jan 2026, top 15 Hot 100.
- https://x.com/grok/status/2026498538075554040
- **
-
-**X4** (score:58) @AlterEgoPopList (2026-02-25) [1 likes, 0 rp]
- My musical taste / eras every year
-
-2020: J-POP & K-POP (mostly BLACKPINK)
-2021: Sped-Up Songs
-2022: EDM
-2023: Swiftie (Pop) & Lofi
-2024: Pop,
- https://x.com/AlterEgoPopList/status/2026641456434393579
- **
-
-**X3** (score:56) @WordsFromBlerds (2026-02-25) [1 likes, 0 rp]
- @_CharlesPreston Can’t believe I’m seeing “they say it in rap songs!” In 2026
-
-The same reductive, ancient, debunked arguments ad infinitum
- https://x.com/WordsFromBlerds/status/2026647382083903688
- **
-
-**X1** (score:56) @Ferginangi (2026-02-25) [1 likes, 0 rp]
- Su La Musica che gira intorno... "Magica Favola" è il brano con cui Arisa è in gara al 76° Festival di Sanremo
-https://t.co/Nxzkb005lP
-#music #genre #
- https://x.com/Ferginangi/status/2026705227366256880
- **
-
-**X10** (score:55) @fo_sho52268 (2026-02-24) [1 likes, 0 rp]
- @blackiiingout 💩 is funny from 2025 to 2026. BiiCH is SCARED to drop that #TRASH he calls "rap songs" 🥴😩
- https://x.com/fo_sho52268/status/2026335353364201566
- **
-
-#### YouTube Videos
-
-**ZUExyc50ZVU** (score:63, rel:0.70) West Coast Finest (2026-01-29) [407260 views, 4229 likes]
- Lit Hip Hop Mix 2026🔥🔥🔥Tyga, Quavo, Iggy Azalea, Wiz Khalifa, Juicy J, 50 Cent
- https://www.youtube.com/watch?v=ZUExyc50ZVU
- *YouTube video about rap songs 2026*
- Transcript: Yeah. >> Yeah. [music] Late nights, cold life, same story. >> City shine different. You walking with glory, ...
-
-**d1melQQVp6s** (score:58, rel:0.70) DJ Noize (2026-02-14) [42707 views, 761 likes]
- New Rap Songs 2026 Mix February | Trap Tape #127 | New Hip Hop 2026 Mixtape | DJ Noize
- https://www.youtube.com/watch?v=d1melQQVp6s
- *YouTube video about rap songs 2026*
- Transcript: [music] This This is a DJ noise mixtape present. This is a DJ noise mixtape [music] presentation. DJ [music] >> Wh...
-
-**-KQpPySP93I** (score:53, rel:0.70) DJ Noize (2026-02-21) [12955 views, 304 likes]
- New Hip Hop R&B Songs 2026 Mix February | Hot Right Now #153 | New Rap 2026 Playlist | DJ Noize
- https://www.youtube.com/watch?v=-KQpPySP93I
- *YouTube video about rap songs 2026*
- Transcript: This This is a DJ noise tape present. This is a DJ noise mix. [music] I got Nikki on me. How we treat the like a bucket ...
-
-**Total: Reddit=10, X=12, YouTube=3, HN=0, Web=0**
-
-### CROSS - best rap songs 2026
-
-#### Reddit Discussions
-
-**R1** (score:57) r/edranked (2026-02-01) [0 pts, 0 comments]
- Reddit Ranked: Hip Hop ‘26
- https://www.reddit.com/r/redranked/comments/1qszauu/reddit_ranked_hip_hop_26/
- *Explicitly about Hip Hop '26 submissions and ranking tracks (community-sourced 'best of 2026' style thread).*
-
-**R9** (score:56) r/hiphopheads (2026-02-24) [0 pts, 0 comments]
- Daily Discussion Thread 02/24/2026
- https://www.reddit.com/r/hiphopheads/comments/1rdjmgu/daily_discussion_thread_02242026/
- *General hip-hop discussion thread where users frequently share current favorite/best songs and recommendations (2026-dated).*
-
-**R4** (score:56) r/hiphopheads (2026-02-14) [0 pts, 0 comments]
- [DISCUSSION] Drake & PARTYNXTDOOR - $OME $EXY $ONGS 4 U (1 Year Later)
- https://www.reddit.com/r/hiphopheads/comments/1r4cbez/discussion_drake_partynxtdoord_ome_exy_ongs_4_u_1/
- *Track-focused discussion about songs still in rotation; relevant to identifying standout rap/R&B tracks around 2026.*
-
-**R21** (score:54) r/makemeaplaylist (2026-02-21) [0 pts, 0 comments]
- 2/22/2026 | Week 23 Playlist Competition
- https://www.reddit.com/r/makemeaplaylist/comments/1rb46m6/2222026_week_23_playlist_competition/
- *Playlist competition thread featuring an underground rap playlist winner; useful for surfacing 'best rap' picks around 2026.*
-
-**R16** (score:53) r/TeenageRapFans (2026-02-15) [0 pts, 0 comments]
- What's the best rap song ever made?
- https://www.reddit.com/r/TeenageRapFans/comments/1r5iub6/whats_the_best_rap_song_ever_made/
- *Direct 'best rap song' debate thread (highly aligned with best-rap-songs searching).*
-
-**R18** (score:51) r/playlists (2026-02-15) [0 pts, 0 comments]
- Rick 2026 : Pop Dance RnB Chart Hits Rock Indie HipHop Divas Rap Gym 80s 90s 00s 10s 20s Party Music
- https://www.reddit.com/r/playlists/comments/1r5iuz5/rick_2026_pop_dance_rnb_chart_hits_rock_indie/
- *2026-tagged playlist thread explicitly including HipHop/Rap; can be mined for top rap tracks in 2026 rotation.*
-
-**R5** (score:48) r/hiphopheads (2026-02-01) [0 pts, 0 comments]
- Pre-Show Grammy Winners
- https://www.reddit.com/r/hiphopheads/comments/1qtcbmo/preshow_grammy_winners/
- *Includes Best Rap Song / Best Rap Performance winners; strong signal for 'best rap songs' discourse in 2026 season.*
-
-**R8** (score:44) r/GoodAssSub (2026-02-02) [0 pts, 0 comments]
- Andre Toutman claims All The Love will be "song of the year"
- https://www.reddit.com/r/GoodAssSub/comments/1qty80z/andre_toutman_claims_all_the_love_will_be_song_of/
- *Direct 'song of the year' debate thread (rap/hip-hop community), aligned with 'best songs' discovery for 2026.*
-
-**R6** (score:43) r/hiphopheads (2026-02-02) [0 pts, 0 comments]
- Kendrick Lamar Wins 2nd Record of the Year Grammy With SZA for ‘Luther’
- https://www.reddit.com/r/hiphopheads/comments/1qtl5kr/kendrick_lamar_wins_2nd_record_of_the_year_grammy/
- *High-engagement thread about a major hip-hop adjacent song winning; useful for 'best songs' discussions around 2026.*
-
-**R15** (score:42) r/musicteenager (2026-02-04) [0 pts, 0 comments]
- My top 25 rap songs of all time
- https://www.reddit.com/r/musicteenager/comments/1qvz9qu/my_top_25_rap_songs_of_all_time/
- *User-made ranked list of rap songs; while not 2026-specific, it matches the 'best rap songs' core subject.*
-
-**R7** (score:41) r/KendrickLamar (2026-02-01) [0 pts, 0 comments]
- 68th Annual Grammy Awards [LIVE MEGATHREAD]
- https://www.reddit.com/r/KendrickLamar/comments/1qtczjl/68th_annual_grammy_awards_live_megathread/
- *Live discussion around Grammys 2026 including rap categories; good for discovering what Reddit called the best tracks.*
-
-#### X/Twitter Posts
-
-**X5** (score:86) @Zika_gfx (2026-02-25) [12 likes, 5 rp]
- Talking about meaningful rap songs...
-
-WORLD BEST LIE - NUNO ZIGI (2026)🥺❤ https://t.co/MDHRf3giYq
- https://x.com/Zika_gfx/status/2026576570513600788
- **
-
-**X6** (score:68) @DPOSTS6 (2026-02-25) [4 likes, 1 rp]
- @PopCrave Partition still goes harder than most 2026 songs
-12 years and the beat switch + that rap still give chills every time. Queen never misses
- https://x.com/DPOSTS6/status/2026523320548819145
- **
-
-**X9** (score:64) @grok (2026-02-25) [0 likes, 0 rp]
- @Unfath0m4ble @robinthisbish @lilceaserthabp1 @RapWikip What You Saying by Lil Uzi Vert. Dropped Dec 2025, hit #1 on Hot Rap Songs in Jan 2026 (his 3r
- https://x.com/grok/status/2026467153046606064
- **
-
-**X7** (score:64) @grok (2026-02-25) [0 likes, 0 rp]
- @lil_loonie1 @oldmanebro No, Lil Uzi Vert isn't struggling. His "What You Saying" hit #1 on Hot Rap Songs in Jan 2026 (first in 3 years) and top 15 Ho
- https://x.com/grok/status/2026518008827404632
- **
-
-**X2** (score:64) @Ferginangi (2026-02-25) [0 likes, 0 rp]
- Su La Musica che gira intorno... "Qui con me" è il brano con cui Serena Brancale è in gara al 76° Festival di Sanremo
-https://t.co/wTPeovzt6u
-#music #
- https://x.com/Ferginangi/status/2026696207557382298
- **
-
-**X12** (score:62) @teammusic2046 (2026-02-23) [0 likes, 0 rp]
- [ ** STUDIO BOOTH ** ] ** DROPPING SOON - 2026 **🔥🔥🔥………..[ ALBUM : LOVE VS LOYALTY - DROPPING SOON ! ]………🔥🔥🔥 - ** ALL SONGS & BEATS, PRODUCED &
- https://x.com/teammusic2046/status/2026040965329191061
- **
-
-**X11** (score:59) @IoWeul (2026-02-24) [2 likes, 0 rp]
- @PopBase Finally, @bts_bighit BTS will come back after completing their military service from 2022 to 2025.
-BTS served in the military to protect thei
- https://x.com/IoWeul/status/2026233090683863471
- **
-
-**X8** (score:58) @grok (2026-02-25) [1 likes, 0 rp]
- Lil Uzi Vert (born July '95, so 30 now) isn't struggling at all. Fresh off "What You Saying" hitting #1 on Hot Rap Songs in Jan 2026, top 15 Hot 100.
- https://x.com/grok/status/2026498538075554040
- **
-
-**X4** (score:58) @AlterEgoPopList (2026-02-25) [1 likes, 0 rp]
- My musical taste / eras every year
-
-2020: J-POP & K-POP (mostly BLACKPINK)
-2021: Sped-Up Songs
-2022: EDM
-2023: Swiftie (Pop) & Lofi
-2024: Pop,
- https://x.com/AlterEgoPopList/status/2026641456434393579
- **
-
-**X3** (score:56) @WordsFromBlerds (2026-02-25) [1 likes, 0 rp]
- @_CharlesPreston Can’t believe I’m seeing “they say it in rap songs!” In 2026
-
-The same reductive, ancient, debunked arguments ad infinitum
- https://x.com/WordsFromBlerds/status/2026647382083903688
- **
-
-**X1** (score:56) @Ferginangi (2026-02-25) [1 likes, 0 rp]
- Su La Musica che gira intorno... "Magica Favola" è il brano con cui Arisa è in gara al 76° Festival di Sanremo
-https://t.co/Nxzkb005lP
-#music #genre #
- https://x.com/Ferginangi/status/2026705227366256880
- **
-
-**X10** (score:55) @fo_sho52268 (2026-02-24) [1 likes, 0 rp]
- @blackiiingout 💩 is funny from 2025 to 2026. BiiCH is SCARED to drop that #TRASH he calls "rap songs" 🥴😩
- https://x.com/fo_sho52268/status/2026335353364201566
- **
-
-#### YouTube Videos
-
-**d1melQQVp6s** (score:71, rel:1.00) DJ Noize (2026-02-14) [42713 views, 761 likes]
- New Rap Songs 2026 Mix February | Trap Tape #127 | New Hip Hop 2026 Mixtape | DJ Noize
- https://www.youtube.com/watch?v=d1melQQVp6s
- *YouTube: New Rap Songs 2026 Mix February | Trap Tape #127 | New Hip H*
- Transcript: [music] This This is a DJ noise mixtape present. This is a DJ noise mixtape [music] presentation. DJ [music] >> Wh...
-
-**-KQpPySP93I** (score:66, rel:1.00) DJ Noize (2026-02-21) [12955 views, 304 likes]
- New Hip Hop R&B Songs 2026 Mix February | Hot Right Now #153 | New Rap 2026 Playlist | DJ Noize
- https://www.youtube.com/watch?v=-KQpPySP93I
- *YouTube: New Hip Hop R&B Songs 2026 Mix February | Hot Right Now #153*
- Transcript: This This is a DJ noise tape present. This is a DJ noise mix. [music] I got Nikki on me. How we treat the like a bucket ...
-
-**ZUExyc50ZVU** (score:47, rel:0.33) West Coast Finest (2026-01-29) [407260 views, 4229 likes]
- Lit Hip Hop Mix 2026🔥🔥🔥Tyga, Quavo, Iggy Azalea, Wiz Khalifa, Juicy J, 50 Cent
- https://www.youtube.com/watch?v=ZUExyc50ZVU
- *YouTube: Lit Hip Hop Mix 2026🔥🔥🔥Tyga, Quavo, Iggy Azalea, Wiz Khalifa*
- Transcript: Yeah. >> Yeah. [music] Late nights, cold life, same story. >> City shine different. You walking with glory, ...
-
-**Total: Reddit=11, X=12, YouTube=3, HN=0, Web=0**
-
-### Base - React vs Svelte 2026
-
-#### Reddit Discussions
-
-**R2** (score:67) r/eact (2026-02-10) [0 pts, 0 comments]
- What do you guys think about comparison between React vs Svelte?
- https://www.reddit.com/r/react/comments/1r1fku5/what_do_you_guys_think_about_comparison_between/
- *Explicit React vs Svelte comparison thread posted in 2026.*
-
-**R1** (score:61) r/webdev (2026-02-02) [0 pts, 0 comments]
- Migrated our startup from React to Svelte 5 - Performance gains and lessons learned
- https://www.reddit.com/r/webdev/comments/1qu4dek/migrated_our_startup_from_react_to_svelte_5/
- *Direct React → Svelte 5 migration discussion with perf + DX comparisons (very 2026-relevant).*
-
-**R21** (score:39) r/sveltejs (None) [0 pts, 0 comments]
- React Devs that moved to Svelte, do you find that Svelte reduces dev time?
- https://www.reddit.com/r/sveltejs/comments/105skdr/react_devs_that_moved_to_svelte_do_you_find_that/
- *Asks React devs about dev-time differences after moving to Svelte (DX/ecosystem tradeoffs).*
-
-**R11** (score:35) r/sveltejs (None) [0 pts, 0 comments]
- Svelte 5 officially released!
- https://www.reddit.com/r/sveltejs/comments/1g7d3a7/svelte_5_officially_released/
- *Major Svelte 5 release thread with debate about whether React developers will switch (React vs Svelte dynamics).*
-
-#### X/Twitter Posts
-
-**X4** (score:76) @FabianHiller (2026-02-13) [61 likes, 2 rp]
- TypeScript is the baseline in 2026. But most forms still fight type drift between API, schema & UI.
-
-@formisch_dev + @valibot = one source of truth,
- https://x.com/FabianHiller/status/2022345388603080834
- **
-
-**X3** (score:57) @ConsciousRide (2026-02-15) [6 likes, 0 rp]
- Popular JavaScript Frameworks / Frontend Libraries (2025–2026 trends)
-
- - React: 🇺🇸 United States
- - Vue: 🇨🇳 China (created by Evan You)
- - Angu
- https://x.com/ConsciousRide/status/2023050160520278501
- **
-
-**X11** (score:53) @singhprateek_25 (2026-02-07) [6 likes, 1 rp]
- Requirements for a fresher in 2026:
-
-Python, JavaScript, C, C++, Java, HTML, CSS, TypeScript, React, Next.js, Vue, Angular, Svelte, Node.js, Express,
- https://x.com/singhprateek_25/status/2020094806735700154
- **
-
-**X1** (score:52) @cityjsconf (2026-02-20) [0 likes, 1 rp]
- Ripple: The Good Parts of React, Svelte, and Solid
-
-Frameworks rise and fall — and in 2026 we’re deep in late-stage React. What’s next? Join @erikras
- https://x.com/cityjsconf/status/2024908272646398229
- **
-
-**X2** (score:52) @dennydotio (2026-02-18) [1 likes, 0 rp]
- Prediction: 2026 marks the death of the JS frameworks. React, Svelte, Astro etc... only exist because humans need organized code to stay sane.
-
-Once
- https://x.com/dennydotio/status/2024195986890109320
- **
-
-**X5** (score:52) @AzamCodes (2026-02-13) [2 likes, 0 rp]
- Web developers, what stack are you using in 2026?
-React? Vue? Svelte? Vanilla? Curious what’s actually winning.
- https://x.com/AzamCodes/status/2022298287236383010
- **
-
-**X8** (score:52) @QiitaTrend (2026-02-11) [0 likes, 0 rp]
- [2026/02/11 18:00] トレンド1位
-【WebF】React/Vue/Svelteがそのままネイティブアプリになるよ by rana_kualu https://t.co/GiAnTHpOBB
- https://x.com/QiitaTrend/status/2021509592489025785
- **
-
-**X10** (score:50) @nicobaogim (2026-02-09) [0 likes, 0 rp]
- @babakfpk In 2026, picking React for a new project only really makes sense if you value ecosystem maturity, are following existing team habits, or nee
- https://x.com/nicobaogim/status/2020689628282667407
- **
-
-**X12** (score:49) @Chubbi_Stephen (2026-02-07) [0 likes, 0 rp]
- 2026 framework discourse:
-
-Dev 1: "React is bloated, use Svelte"
-Dev 2: "Svelte has no ecosystem, use Vue"
-Dev 3: "Vue is dying, use Solid"
-Dev 4: "Ju
- https://x.com/Chubbi_Stephen/status/2020087839648800853
- **
-
-#### YouTube Videos
-
-**yl0YWA2K2B0** (score:61, rel:0.70) Fireship (2025-10-17) [699597 views, 22894 likes]
- React wants to win you back…
- https://www.youtube.com/watch?v=yl0YWA2K2B0
- *YouTube video about react vs svelte 2026*
- Transcript: Last week, I got to participate in my favorite activity of the year. No, it wasn't watching all the overleveraged crypto...
-
-**MnpuK0MK4yo** (score:61, rel:0.70) Beyond Fireship (2023-06-30) [697106 views, 25490 likes]
- React VS Svelte...10 Examples
- https://www.youtube.com/watch?v=MnpuK0MK4yo
- *YouTube video about react vs svelte 2026*
- Transcript: any reasonable developer in today's world would learn react because it's the status quo and that's where the jobs are bu...
-
-**aYyZUDFZTrM** (score:60, rel:0.70) Fireship (2024-10-24) [547841 views, 24456 likes]
- JavaScript framework reinvents itself… Did "runes" just ruin Svelte?
- https://www.youtube.com/watch?v=aYyZUDFZTrM
- *YouTube video about react vs svelte 2026*
- Transcript: about a year ago I made a tweet that said the dollar sign in spelt is the most powerful abstraction in modern front-end ...
-
-**k7LhsqnJCe4** (score:51, rel:0.70) Paperclick (2026-02-19) [26 views, 1 likes]
- Htmx vs Svelte (2026) - Which One Is BETTER?
- https://www.youtube.com/watch?v=k7LhsqnJCe4
- *YouTube video about react vs svelte 2026*
-
-**IpJh0VEzMRo** (score:51, rel:0.70) Ben Davis (2025-10-12) [24631 views, 1043 likes]
- I Was Wrong About Svelte...
- https://www.youtube.com/watch?v=IpJh0VEzMRo
- *YouTube video about react vs svelte 2026*
-
-**qwDp5pZA_TA** (score:50, rel:0.70) Code Hub (2026-02-08) [640 views, 31 likes]
- The Front-end Frameworks Guide 2026: React vs Angular vs Vue vs Svelt vs Solid vs Astro
- https://www.youtube.com/watch?v=qwDp5pZA_TA
- *YouTube video about react vs svelte 2026*
-
-**1cGtYEXGm8c** (score:50, rel:0.70) Ben Davis (2026-01-06) [16900 views, 793 likes]
- This is THE Framework You Should be Using in 2026
- https://www.youtube.com/watch?v=1cGtYEXGm8c
- *YouTube video about react vs svelte 2026*
-
-**_vuVy21l2bU** (score:46, rel:0.70) Code Hub (2025-10-30) [6454 views, 204 likes]
- React vs Svelte: The Brutal Honest Comparison EVER
- https://www.youtube.com/watch?v=_vuVy21l2bU
- *YouTube video about react vs svelte 2026*
-
-**1BCsdaeYv0A** (score:43, rel:0.70) Daniel | Tech & Data (2023-11-26) [33905 views, 0 likes]
- Svelte vs React in 2025 - Make the RIGHT Choice (Difference Explained)
- https://www.youtube.com/watch?v=1BCsdaeYv0A
- *YouTube video about react vs svelte 2026*
-
-**41HXdxGekZ0** (score:36, rel:0.70) Paperclick (2025-10-04) [287 views, 3 likes]
- Vue vs React vs Svelte (2026) - Which One Is BEST?
- https://www.youtube.com/watch?v=41HXdxGekZ0
- *YouTube video about react vs svelte 2026*
-
-**Total: Reddit=4, X=9, YouTube=10, HN=0, Web=0**
-
-### HN - React vs Svelte 2026
-
-#### Reddit Discussions
-
-**R2** (score:66) r/eact (2026-02-10) [0 pts, 0 comments]
- What do you guys think about comparison between React vs Svelte?
- https://www.reddit.com/r/react/comments/1r1fku5/what_do_you_guys_think_about_comparison_between/
- *Explicit React vs Svelte comparison discussion (mentions Svelte 5 runes vs React hooks).*
-
-**R1** (score:60) r/webdev (2026-02-02) [0 pts, 0 comments]
- Migrated our startup from React to Svelte 5 - Performance gains and lessons learned
- https://www.reddit.com/r/webdev/comments/1qu4dek/migrated_our_startup_from_react_to_svelte_5/
- *Direct migration story from React to Svelte 5 with concrete perf/dev-ex tradeoffs; very relevant to 2026 comparisons.*
-
-**R4** (score:46) r/sveltejs (None) [0 pts, 0 comments]
- When to choose React over Svelte
- https://www.reddit.com/r/sveltejs/comments/1jeknib/when_to_choose_react_over_svelte/
- *Decision-oriented thread explicitly contrasting when React vs Svelte makes sense (ecosystem/jobs/productivity).*
-
-#### X/Twitter Posts
-
-**X4** (score:76) @FabianHiller (2026-02-13) [61 likes, 2 rp]
- TypeScript is the baseline in 2026. But most forms still fight type drift between API, schema & UI.
-
-@formisch_dev + @valibot = one source of truth,
- https://x.com/FabianHiller/status/2022345388603080834
- **
-
-**X3** (score:57) @ConsciousRide (2026-02-15) [6 likes, 0 rp]
- Popular JavaScript Frameworks / Frontend Libraries (2025–2026 trends)
-
- - React: 🇺🇸 United States
- - Vue: 🇨🇳 China (created by Evan You)
- - Angu
- https://x.com/ConsciousRide/status/2023050160520278501
- **
-
-**X11** (score:53) @singhprateek_25 (2026-02-07) [6 likes, 1 rp]
- Requirements for a fresher in 2026:
-
-Python, JavaScript, C, C++, Java, HTML, CSS, TypeScript, React, Next.js, Vue, Angular, Svelte, Node.js, Express,
- https://x.com/singhprateek_25/status/2020094806735700154
- **
-
-**X1** (score:52) @cityjsconf (2026-02-20) [0 likes, 1 rp]
- Ripple: The Good Parts of React, Svelte, and Solid
-
-Frameworks rise and fall — and in 2026 we’re deep in late-stage React. What’s next? Join @erikras
- https://x.com/cityjsconf/status/2024908272646398229
- **
-
-**X2** (score:52) @dennydotio (2026-02-18) [1 likes, 0 rp]
- Prediction: 2026 marks the death of the JS frameworks. React, Svelte, Astro etc... only exist because humans need organized code to stay sane.
-
-Once
- https://x.com/dennydotio/status/2024195986890109320
- **
-
-**X5** (score:52) @AzamCodes (2026-02-13) [2 likes, 0 rp]
- Web developers, what stack are you using in 2026?
-React? Vue? Svelte? Vanilla? Curious what’s actually winning.
- https://x.com/AzamCodes/status/2022298287236383010
- **
-
-**X8** (score:52) @QiitaTrend (2026-02-11) [0 likes, 0 rp]
- [2026/02/11 18:00] トレンド1位
-【WebF】React/Vue/Svelteがそのままネイティブアプリになるよ by rana_kualu https://t.co/GiAnTHpOBB
- https://x.com/QiitaTrend/status/2021509592489025785
- **
-
-**X10** (score:50) @nicobaogim (2026-02-09) [0 likes, 0 rp]
- @babakfpk In 2026, picking React for a new project only really makes sense if you value ecosystem maturity, are following existing team habits, or nee
- https://x.com/nicobaogim/status/2020689628282667407
- **
-
-**X12** (score:49) @Chubbi_Stephen (2026-02-07) [0 likes, 0 rp]
- 2026 framework discourse:
-
-Dev 1: "React is bloated, use Svelte"
-Dev 2: "Svelte has no ecosystem, use Vue"
-Dev 3: "Vue is dying, use Solid"
-Dev 4: "Ju
- https://x.com/Chubbi_Stephen/status/2020087839648800853
- **
-
-#### YouTube Videos
-
-**yl0YWA2K2B0** (score:61, rel:0.70) Fireship (2025-10-17) [699597 views, 22894 likes]
- React wants to win you back…
- https://www.youtube.com/watch?v=yl0YWA2K2B0
- *YouTube video about react vs svelte 2026*
- Transcript: Last week, I got to participate in my favorite activity of the year. No, it wasn't watching all the overleveraged crypto...
-
-**MnpuK0MK4yo** (score:61, rel:0.70) Beyond Fireship (2023-06-30) [697142 views, 25490 likes]
- React VS Svelte...10 Examples
- https://www.youtube.com/watch?v=MnpuK0MK4yo
- *YouTube video about react vs svelte 2026*
- Transcript: any reasonable developer in today's world would learn react because it's the status quo and that's where the jobs are bu...
-
-**aYyZUDFZTrM** (score:60, rel:0.70) Fireship (2024-10-24) [547836 views, 24456 likes]
- JavaScript framework reinvents itself… Did "runes" just ruin Svelte?
- https://www.youtube.com/watch?v=aYyZUDFZTrM
- *YouTube video about react vs svelte 2026*
- Transcript: about a year ago I made a tweet that said the dollar sign in spelt is the most powerful abstraction in modern front-end ...
-
-**fn_uSZW5psM** (score:52, rel:0.70) CodeSource (2025-05-14) [57784 views, 2120 likes]
- The Untold Story of Svelte
- https://www.youtube.com/watch?v=fn_uSZW5psM
- *YouTube video about react vs svelte 2026*
-
-**IpJh0VEzMRo** (score:49, rel:0.70) Ben Davis (2025-10-12) [24631 views, 1043 likes]
- I Was Wrong About Svelte...
- https://www.youtube.com/watch?v=IpJh0VEzMRo
- *YouTube video about react vs svelte 2026*
-
-**1cGtYEXGm8c** (score:48, rel:0.70) Ben Davis (2026-01-06) [16899 views, 793 likes]
- This is THE Framework You Should be Using in 2026
- https://www.youtube.com/watch?v=1cGtYEXGm8c
- *YouTube video about react vs svelte 2026*
-
-**qwDp5pZA_TA** (score:47, rel:0.70) Code Hub (2026-02-08) [646 views, 31 likes]
- The Front-end Frameworks Guide 2026: React vs Angular vs Vue vs Svelt vs Solid vs Astro
- https://www.youtube.com/watch?v=qwDp5pZA_TA
- *YouTube video about react vs svelte 2026*
-
-**_vuVy21l2bU** (score:44, rel:0.70) Code Hub (2025-10-30) [6454 views, 204 likes]
- React vs Svelte: The Brutal Honest Comparison EVER
- https://www.youtube.com/watch?v=_vuVy21l2bU
- *YouTube video about react vs svelte 2026*
-
-**1BCsdaeYv0A** (score:40, rel:0.70) Daniel | Tech & Data (2023-11-26) [33905 views, 0 likes]
- Svelte vs React in 2025 - Make the RIGHT Choice (Difference Explained)
- https://www.youtube.com/watch?v=1BCsdaeYv0A
- *YouTube video about react vs svelte 2026*
-
-**41HXdxGekZ0** (score:31, rel:0.70) Paperclick (2025-10-04) [287 views, 3 likes]
- Vue vs React vs Svelte (2026) - Which One Is BEST?
- https://www.youtube.com/watch?v=41HXdxGekZ0
- *YouTube video about react vs svelte 2026*
-
-**Total: Reddit=3, X=9, YouTube=10, HN=0, Web=0**
-
-### CROSS - React vs Svelte 2026
-
-#### Reddit Discussions
-
-**R2** (score:67) r/eact (2026-02-10) [0 pts, 0 comments]
- What do you guys think about comparison between React vs Svelte?
- https://www.reddit.com/r/react/comments/1r1fku5/what_do_you_guys_think_about_comparison_between/
- *Explicit React vs Svelte comparison thread focusing on hooks/useEffect vs Svelte runes mental model.*
-
-**R1** (score:61) r/webdev (2026-02-02) [0 pts, 0 comments]
- Migrated our startup from React to Svelte 5 - Performance gains and lessons learned
- https://www.reddit.com/r/webdev/comments/1qu4dek/migrated_our_startup_from_react_to_svelte_5/
- *Direct React -> Svelte 5 migration discussion with practical comparison (DX, performance, ecosystem tradeoffs).*
-
-**R6** (score:56) r/sveltejs (2026-02-02) [0 pts, 0 comments]
- I am building a content-heavy, bilingual government portal with listings, profiles, documents, filters, and forms. Came from .NET & React. Wondering if svelte might be a good option for a project like this?
- https://www.reddit.com/r/sveltejs/comments/1qtua45/i_am_building_a_contentheavy_bilingual_government/
- *Evaluates Svelte vs React suitability for a real project with constraints (accessibility, SEO, forms).*
-
-**R4** (score:54) r/sveltejs (2026-01-28) [0 pts, 0 comments]
- Is Svelte easier than React?
- https://www.reddit.com/r/sveltejs/comments/1qpdzx5/is_svelte_easier_than_react/
- *Direct comparison thread about perceived simplicity of Svelte vs React (recent 2026 discussion).*
-
-**R8** (score:52) r/sveltejs (2026-01-31) [0 pts, 0 comments]
- When should i start learning Svelte ?
- https://www.reddit.com/r/sveltejs/comments/1qr8xsm/when_should_i_start_learning_svelte/
- *React vs Svelte learning/adoption discussion, including arguments about replacing React for performance issues.*
-
-**R9** (score:49) r/sveltejs (2026-01-30) [0 pts, 0 comments]
- How is going svelte?
- https://www.reddit.com/r/sveltejs/comments/1qrdhba/how_is_going_svelte/
- *General state-of-Svelte discussion with explicit mentions comparing to React and job-market pragmatism.*
-
-#### X/Twitter Posts
-
-**X4** (score:76) @FabianHiller (2026-02-13) [61 likes, 2 rp]
- TypeScript is the baseline in 2026. But most forms still fight type drift between API, schema & UI.
-
-@formisch_dev + @valibot = one source of truth,
- https://x.com/FabianHiller/status/2022345388603080834
- **
-
-**X3** (score:57) @ConsciousRide (2026-02-15) [6 likes, 0 rp]
- Popular JavaScript Frameworks / Frontend Libraries (2025–2026 trends)
-
- - React: 🇺🇸 United States
- - Vue: 🇨🇳 China (created by Evan You)
- - Angu
- https://x.com/ConsciousRide/status/2023050160520278501
- **
-
-**X11** (score:53) @singhprateek_25 (2026-02-07) [6 likes, 1 rp]
- Requirements for a fresher in 2026:
-
-Python, JavaScript, C, C++, Java, HTML, CSS, TypeScript, React, Next.js, Vue, Angular, Svelte, Node.js, Express,
- https://x.com/singhprateek_25/status/2020094806735700154
- **
-
-**X1** (score:52) @cityjsconf (2026-02-20) [0 likes, 1 rp]
- Ripple: The Good Parts of React, Svelte, and Solid
-
-Frameworks rise and fall — and in 2026 we’re deep in late-stage React. What’s next? Join @erikras
- https://x.com/cityjsconf/status/2024908272646398229
- **
-
-**X2** (score:52) @dennydotio (2026-02-18) [1 likes, 0 rp]
- Prediction: 2026 marks the death of the JS frameworks. React, Svelte, Astro etc... only exist because humans need organized code to stay sane.
-
-Once
- https://x.com/dennydotio/status/2024195986890109320
- **
-
-**X5** (score:52) @AzamCodes (2026-02-13) [2 likes, 0 rp]
- Web developers, what stack are you using in 2026?
-React? Vue? Svelte? Vanilla? Curious what’s actually winning.
- https://x.com/AzamCodes/status/2022298287236383010
- **
-
-**X8** (score:52) @QiitaTrend (2026-02-11) [0 likes, 0 rp]
- [2026/02/11 18:00] トレンド1位
-【WebF】React/Vue/Svelteがそのままネイティブアプリになるよ by rana_kualu https://t.co/GiAnTHpOBB
- https://x.com/QiitaTrend/status/2021509592489025785
- **
-
-**X10** (score:50) @nicobaogim (2026-02-09) [0 likes, 0 rp]
- @babakfpk In 2026, picking React for a new project only really makes sense if you value ecosystem maturity, are following existing team habits, or nee
- https://x.com/nicobaogim/status/2020689628282667407
- **
-
-**X12** (score:49) @Chubbi_Stephen (2026-02-07) [0 likes, 0 rp]
- 2026 framework discourse:
-
-Dev 1: "React is bloated, use Svelte"
-Dev 2: "Svelte has no ecosystem, use Vue"
-Dev 3: "Vue is dying, use Solid"
-Dev 4: "Ju
- https://x.com/Chubbi_Stephen/status/2020087839648800853
- **
-
-#### YouTube Videos
-
-**MnpuK0MK4yo** (score:63, rel:0.75) Beyond Fireship (2023-06-30) [697142 views, 25490 likes]
- React VS Svelte...10 Examples
- https://www.youtube.com/watch?v=MnpuK0MK4yo
- *YouTube: React VS Svelte...10 Examples*
- Transcript: any reasonable developer in today's world would learn react because it's the status quo and that's where the jobs are bu...
-
-**qwDp5pZA_TA** (score:49, rel:0.75) Code Hub (2026-02-08) [646 views, 31 likes]
- The Front-end Frameworks Guide 2026: React vs Angular vs Vue vs Svelt vs Solid vs Astro
- https://www.youtube.com/watch?v=qwDp5pZA_TA
- *YouTube: The Front-end Frameworks Guide 2026: React vs Angular vs Vue*
-
-**_vuVy21l2bU** (score:46, rel:0.75) Code Hub (2025-10-30) [6454 views, 204 likes]
- React vs Svelte: The Brutal Honest Comparison EVER
- https://www.youtube.com/watch?v=_vuVy21l2bU
- *YouTube: React vs Svelte: The Brutal Honest Comparison EVER*
-
-**41HXdxGekZ0** (score:45, rel:1.00) Paperclick (2025-10-04) [287 views, 3 likes]
- Vue vs React vs Svelte (2026) - Which One Is BEST?
- https://www.youtube.com/watch?v=41HXdxGekZ0
- *YouTube: Vue vs React vs Svelte (2026) - Which One Is BEST?*
-
-**1BCsdaeYv0A** (score:42, rel:0.75) Daniel | Tech & Data (2023-11-26) [33905 views, 0 likes]
- Svelte vs React in 2025 - Make the RIGHT Choice (Difference Explained)
- https://www.youtube.com/watch?v=1BCsdaeYv0A
- *YouTube: Svelte vs React in 2025 - Make the RIGHT Choice (Difference *
-
-**yl0YWA2K2B0** (score:40, rel:0.25) Fireship (2025-10-17) [699597 views, 22894 likes]
- React wants to win you back…
- https://www.youtube.com/watch?v=yl0YWA2K2B0
- *YouTube: React wants to win you back…*
- Transcript: Last week, I got to participate in my favorite activity of the year. No, it wasn't watching all the overleveraged crypto...
-
-**aYyZUDFZTrM** (score:40, rel:0.25) Fireship (2024-10-24) [547836 views, 24456 likes]
- JavaScript framework reinvents itself… Did "runes" just ruin Svelte?
- https://www.youtube.com/watch?v=aYyZUDFZTrM
- *YouTube: JavaScript framework reinvents itself… Did "runes" just ruin*
- Transcript: about a year ago I made a tweet that said the dollar sign in spelt is the most powerful abstraction in modern front-end ...
-
-**fn_uSZW5psM** (score:31, rel:0.25) CodeSource (2025-05-14) [57782 views, 2120 likes]
- The Untold Story of Svelte
- https://www.youtube.com/watch?v=fn_uSZW5psM
- *YouTube: The Untold Story of Svelte*
-
-**IpJh0VEzMRo** (score:29, rel:0.25) Ben Davis (2025-10-12) [24635 views, 1043 likes]
- I Was Wrong About Svelte...
- https://www.youtube.com/watch?v=IpJh0VEzMRo
- *YouTube: I Was Wrong About Svelte...*
-
-**1cGtYEXGm8c** (score:28, rel:0.25) Ben Davis (2026-01-06) [16899 views, 793 likes]
- This is THE Framework You Should be Using in 2026
- https://www.youtube.com/watch?v=1cGtYEXGm8c
- *YouTube: This is THE Framework You Should be Using in 2026*
-
-**Total: Reddit=6, X=9, YouTube=10, HN=0, Web=0**
diff --git a/docs/plans/2026-02-25-feat-15-test-comparison-make-cross-goat-plan.md b/docs/plans/2026-02-25-feat-15-test-comparison-make-cross-goat-plan.md
deleted file mode 100644
index 4f1a3db..0000000
--- a/docs/plans/2026-02-25-feat-15-test-comparison-make-cross-goat-plan.md
+++ /dev/null
@@ -1,212 +0,0 @@
----
-title: "feat: 15-Test Side-by-Side Comparison - Make CROSS the GOAT"
-type: feat
-status: active
-date: 2026-02-25
-origin: docs/plans/2026-02-25-analysis-cross-source-comparison-plan.md
----
-
-# 15-Test Side-by-Side Comparison - Make CROSS the GOAT
-
-## Overview
-
-Run all 5 canonical topics through all 3 skill versions (base, HN, CROSS) for 15 total full last30days runs. Save every result. Analyze side-by-side. Judge which version is best. Then create an improvement plan to make CROSS the definitive next release.
-
-## Problem Statement / Motivation
-
-The previous analysis only ran 5 tests on the CROSS branch - never comparing the same topics across all 3 versions. Without true side-by-side data, we can't judge whether CROSS is actually better or if the new features (dynamic YouTube scoring, cross-refs) introduce regressions. The user wants to see all 15 results, know which version wins, and get a concrete plan to make CROSS the GOAT before shipping.
-
-## The 3 Versions
-
-| Version | Git State | Sources | YouTube Relevance | Cross-Refs |
-|---------|-----------|---------|-------------------|------------|
-| **Base** | commit `427a4e4` | Reddit, X, YouTube, Web | Hardcoded 0.7 | No |
-| **HN** | `main` / `f60a435` | Reddit, X, YouTube, **HN**, Web | Hardcoded 0.7 | No |
-| **CROSS** | `feat/youtube-relevance-cross-source` / `0591f55` | Reddit, X, YouTube, HN, Web | Dynamic 0.1-1.0 | Yes (Jaccard 0.5) |
-
-## The 5 Topics
-
-| # | Topic | Category | Why chosen |
-|---|-------|----------|------------|
-| 1 | "Claude Code skills and MCP servers" | Developer tools | Core user base topic |
-| 2 | "Seedance AI video generation" | Creative AI | Trending topic, cross-platform buzz |
-| 3 | "M4 MacBook Pro review" | Consumer tech | Product review, mainstream |
-| 4 | "best rap songs 2026" | Pop culture | Non-tech stress test |
-| 5 | "React vs Svelte 2026" | Framework debate | Dev community, opinion-heavy |
-
-## Test Matrix (15 Runs)
-
-Run in **topic-sequential** order (all 3 versions of topic 1 back-to-back, then topic 2, etc.) to minimize temporal confounds on YouTube/X search results.
-
-| Run | Topic | Version | Output File |
-|-----|-------|---------|-------------|
-| 1 | Claude Code | Base | `base-1-claude-code.json` |
-| 2 | Claude Code | HN | `hn-1-claude-code.json` |
-| 3 | Claude Code | CROSS | `cross-1-claude-code.json` |
-| 4 | Seedance | Base | `base-2-seedance.json` |
-| 5 | Seedance | HN | `hn-2-seedance.json` |
-| 6 | Seedance | CROSS | `cross-2-seedance.json` |
-| 7 | MacBook | Base | `base-3-macbook.json` |
-| 8 | MacBook | HN | `hn-3-macbook.json` |
-| 9 | MacBook | CROSS | `cross-3-macbook.json` |
-| 10 | Rap songs | Base | `base-4-rap.json` |
-| 11 | Rap songs | HN | `hn-4-rap.json` |
-| 12 | Rap songs | CROSS | `cross-4-rap.json` |
-| 13 | React/Svelte | Base | `base-5-react-svelte.json` |
-| 14 | React/Svelte | HN | `hn-5-react-svelte.json` |
-| 15 | React/Svelte | CROSS | `cross-5-react-svelte.json` |
-
-**Output directory:** `/tmp/last30days-comparison/full/`
-
-## Execution Protocol
-
-### Pre-flight (once)
-
-- [x] Clear model cache: `rm -f ~/.cache/last30days/model_selection.json`
-- [x] Run `--diagnose` on current branch, save as `diagnose-baseline.json`
-- [x] Verify all API keys active: Reddit (OPENAI_API_KEY), X (Bird cookies or XAI_API_KEY), YouTube (yt-dlp), HN (no key needed), Web (parallel AI or Brave)
-- [x] Create output dir: `mkdir -p /tmp/last30days-comparison/full`
-- [x] Stash any uncommitted changes: `git stash` (none needed - no uncommitted changes)
-
-### Per-topic loop (repeat 5 times)
-
-For each topic, run all 3 versions back-to-back:
-
-```bash
-# CRITICAL: Clean __pycache__ between EVERY git checkout to prevent stale bytecode
-cleanup() {
- find scripts -name '__pycache__' -exec rm -rf {} + 2>/dev/null
- find scripts -name '*.pyc' -delete 2>/dev/null
-}
-```
-
-- [ ] **Step 1: Base version**
- - `cleanup && git checkout 427a4e4`
- - `python3 scripts/last30days.py --diagnose 2>/dev/null` (verify sources match baseline)
- - `python3 scripts/last30days.py "" --quick --emit=json > /tmp/last30days-comparison/full/base-{N}-{slug}.json 2>/tmp/last30days-comparison/full/base-{N}-{slug}.log`
-
-- [ ] **Step 2: HN version**
- - `cleanup && git checkout main`
- - `python3 scripts/last30days.py "" --quick --emit=json > /tmp/last30days-comparison/full/hn-{N}-{slug}.json 2>/tmp/last30days-comparison/full/hn-{N}-{slug}.log`
-
-- [ ] **Step 3: CROSS version**
- - `cleanup && git checkout feat/youtube-relevance-cross-source`
- - `python3 scripts/last30days.py "" --quick --emit=json > /tmp/last30days-comparison/full/cross-{N}-{slug}.json 2>/tmp/last30days-comparison/full/cross-{N}-{slug}.log`
-
-### Post-run
-
-- [x] Return to feature branch: `git checkout feat/youtube-relevance-cross-source`
-- [x] Verify all 15 JSON files exist and are non-empty
-- [x] Check for `*_error` fields in any JSON output - flag but include (zero errors)
-
-## Analysis Dimensions
-
-### 1. Source Coverage Table
-
-For each of 15 runs, count items per source:
-
-| Topic | Version | Reddit | X | YouTube | HN | Web | Total |
-|-------|---------|--------|---|---------|----|----|-------|
-
-Expected: Base has 0 HN items. HN and CROSS should have identical source counts (same search code). Any differences indicate API non-determinism.
-
-### 2. YouTube Relevance Comparison
-
-**Matched-item analysis:** Match videos by `video_id` across Base/CROSS runs of the same topic. For each matched video:
-- Base relevance: always 0.7
-- CROSS relevance: dynamic score
-- Delta and direction (did dynamic scoring promote or demote this video?)
-
-**Aggregate analysis:** Distribution stats (min/avg/max/stddev) per version per topic.
-
-### 3. Cross-Source Links (CROSS only)
-
-- Count of items with cross_refs per topic
-- Quality assessment: are the linked items actually about the same story?
-- Which source pairs link most often? (Reddit-HN? YouTube-HN? X-Reddit?)
-
-### 4. Score Distribution and Rankings
-
-- Mean/median score of top 10 items per version per topic
-- Rank position changes: do the same items appear in different orders?
-- Does HN crowd out Reddit/X items in the top 10?
-
-### 5. "Best Version" Judging Criteria
-
-Define BEFORE analyzing to avoid bias:
-
-| Metric | Weight | How measured |
-|--------|--------|-------------|
-| Source diversity | 25% | Shannon entropy across source types in top 15 items |
-| Score quality | 25% | Mean score of top 10 items |
-| Relevance accuracy | 25% | YouTube: do high-relevance videos actually match the query? Manual spot-check of top 3 + bottom 3 per topic |
-| Bonus features | 25% | HN value-add (unique info not in other sources) + cross-ref utility (do xrefs add value to the reader?) |
-
-### 6. HN Value-Add Assessment
-
-For each topic where HN returns results:
-- How many HN items appear in the overall top 10?
-- Do HN items provide information not available from Reddit/X/YouTube?
-- Are HN comment insights (top_comments) genuinely useful?
-
-## Deliverables
-
-### A. Raw Results Archive
-
-All 15 JSON files plus logs saved in `/tmp/last30days-comparison/full/`, also copied to `docs/comparison-results/` for persistence.
-
-### B. Comparison Summary Table
-
-A single markdown table showing all 15 runs with key metrics per cell.
-
-### C. Version Verdict
-
-Clear judgment: which version is best overall, and best per topic category (tech, consumer, culture).
-
-### D. CROSS Improvement Plan
-
-Concrete changes to make CROSS the GOAT:
-
-**Based on the previous analysis (see origin doc), likely improvements include:**
-
-1. **Fix cross-source linking** - Switch from char-trigram Jaccard (0.5) to hybrid similarity (max of trigram + token Jaccard) at threshold 0.40. Previous modeling showed this goes from 1 link to 24 links across 5 tests.
-
-2. **Cross-ref rendering** - Change from cryptic `[xref: X1, HN3]` IDs to human-readable `[also on: Reddit, HN]` labels.
-
-3. **HN search broadening** - Investigate why React/Svelte returns 0 HN items.
-
-4. **YouTube relevance** - Already working well, may need minor threshold tuning based on 15-test data.
-
-5. **Score normalization** - Ensure HN items don't systematically crowd out other sources in the merged ranking.
-
-**New improvements to discover from 15-test data:**
-- Regressions introduced by CROSS changes
-- Edge cases where Base or HN outperforms CROSS
-- Topic-specific tuning opportunities
-
-## Acceptance Criteria
-
-- [x] All 15 JSON result files saved and non-empty
-- [x] All 15 runs use identical source routing (verified via --diagnose)
-- [x] __pycache__ cleaned between every git checkout
-- [x] Comparison summary table covers all 15 runs
-- [x] YouTube matched-item analysis for at least 3 topics (all 5 done)
-- [x] Cross-source link quality spot-check for CROSS runs
-- [x] Clear version verdict with supporting data
-- [x] CROSS improvement plan with specific code changes
-- [x] All results appended to the analysis document for the user to review
-
-## Technical Considerations
-
-- **__pycache__ poisoning**: Python caches bytecode. Switching git commits without clearing `__pycache__` means old code runs even after checkout. MUST clean between every checkout.
-- **API rate limiting**: 15 runs hitting Reddit, X, YouTube, HN APIs. The `--quick` mode and natural ~30-90s per run provide spacing, but monitor for 429s in logs.
-- **YouTube non-determinism**: yt-dlp search results shift over time. Topic-sequential ordering minimizes this by running all 3 versions of the same topic within ~3-5 minutes.
-- **X search session**: Bird CLI depends on browser cookies. If session expires mid-run, X results degrade silently. Check X item counts across runs.
-- **`--quick` limitation**: Results reflect Phase 1 only (8-12 items per source). Full pipeline with `--deep` might show different patterns. Document this caveat.
-
-## Sources & References
-
-- Previous analysis: [docs/plans/2026-02-25-analysis-cross-source-comparison-plan.md](docs/plans/2026-02-25-analysis-cross-source-comparison-plan.md)
-- Implementation plan: [docs/plans/2026-02-25-feat-youtube-relevance-and-cross-source-linking-plan.md](docs/plans/2026-02-25-feat-youtube-relevance-and-cross-source-linking-plan.md)
-- Existing comparison harness: [scripts/test-v1-vs-v2.sh](scripts/test-v1-vs-v2.sh)
-- Scoring weights: [scripts/lib/score.py](scripts/lib/score.py) - 45% relevance + 25% recency + 30% engagement
diff --git a/docs/plans/2026-02-25-feat-goat-synthesis-output-comparison-plan.md b/docs/plans/2026-02-25-feat-goat-synthesis-output-comparison-plan.md
deleted file mode 100644
index 7fafaea..0000000
--- a/docs/plans/2026-02-25-feat-goat-synthesis-output-comparison-plan.md
+++ /dev/null
@@ -1,471 +0,0 @@
----
-title: "feat: GOAT Synthesis Output - 15-Test Comparison and CROSS Improvement"
-type: feat
-status: completed
-date: 2026-02-25
-origin: docs/plans/2026-02-25-analysis-cross-source-comparison-plan.md
----
-
-# GOAT Synthesis Output - 15-Test Comparison and CROSS Improvement
-
-## Overview
-
-The synthesis output - the "What I learned" / "I'm now an expert on X" narrative - is the ONLY thing that matters. All data collection, scoring, deduplication, and rendering exist solely to produce the best possible synthesis. This plan runs 15 synthesis comparisons (5 topics x 3 versions), judges which version produces the best final output, and creates concrete changes to make CROSS the GOAT.
-
-## Problem Statement / Motivation
-
-The previous 15-test comparison analyzed the data layer (JSON files, item counts, YouTube scores, cross-ref counts). That analysis showed CROSS wins on data quality metrics. But data quality != output quality. The user's exact words: "that's all that matters... all DATA/methods/etc. should be about making it the best fucking results ever."
-
-The paper.design example shows what great output looks like:
-- "Paper Desktop + MCP is the big story" - a specific, grounded finding
-- "per @stephenhaney" / "per r/UXDesign" - actual source citations
-- "698 likes on X" - real engagement numbers woven into narrative
-- "KEY PATTERNS from the research:" - structured patterns, not vague summaries
-- "---I'm now an expert on paper.design" - confident, actionable invitation
-
-We need to see what Base, HN, and CROSS actually produce as narratives, side-by-side, for the same topics. Then fix CROSS until its output is undeniably the best.
-
-## Approach: Controlled Synthesis Comparison
-
-### Why not re-run `claude --print "/last30days X"` 15 times?
-
-Three problems:
-1. **WebSearch non-determinism** - Claude's WebSearch tool returns different results per run, confounding version comparison
-2. **QUERY_TYPE non-determinism** - Claude classifies "best rap songs 2026" as RECOMMENDATIONS one run and GENERAL the next, producing structurally different outputs
-3. **Reddit API non-determinism** - The existing JSON data shows Base got 4 Reddit items for Claude Code while CROSS got 10, purely from API timing
-
-### The controlled approach
-
-Reuse the 15 JSON files already captured (same-day, topic-sequential, verified clean). For each:
-
-1. Convert JSON to compact markdown using each version's `render_compact()` (preserving version-specific rendering like CROSS's `[xref:]` tags)
-2. Feed the compact markdown to Claude with the synthesis instructions from each version's SKILL.md
-3. Hardcode QUERY_TYPE per topic to eliminate classification variance
-4. Exclude WebSearch to isolate Python pipeline differences
-5. Save all 15 synthesis outputs for side-by-side comparison
-
-This gives us 15 synthesis narratives produced from identical underlying data, with only the version's rendering and synthesis instructions varying. The fairest possible comparison.
-
-## The 5 Topics and Their QUERY_TYPEs
-
-| # | Topic | QUERY_TYPE | Rationale |
-|---|-------|------------|-----------|
-| 1 | "Claude Code skills and MCP servers" | GENERAL | Broad understanding, not a ranked list |
-| 2 | "Seedance AI video generation" | NEWS | Recent product launch, what's happening |
-| 3 | "M4 MacBook Pro review" | RECOMMENDATIONS | "review" implies evaluation/ranking |
-| 4 | "best rap songs 2026" | RECOMMENDATIONS | "best" triggers ranked list |
-| 5 | "React vs Svelte 2026" | GENERAL | Framework debate, not ranked |
-
-## Technical Approach
-
-### Phase 1: Setup and Data Preservation
-
-#### 1a. Copy JSON data to repo (CRITICAL - `/tmp` is ephemeral)
-
-```bash
-mkdir -p docs/comparison-results/json
-cp /tmp/last30days-comparison/full/*.json docs/comparison-results/json/
-```
-
-- [x] Copy all 15 JSON files to `docs/comparison-results/json/`
-- [x] Verify all 15 files are present and non-empty
-
-#### 1b. Build JSON-to-compact converter
-
-The render pipeline differs per version. We need each version's `render_compact()` to produce the compact markdown that version would actually show Claude.
-
-**Script: `scripts/generate-synthesis-inputs.py`**
-
-```python
-"""
-For each of the 15 JSON result files, render compact markdown
-using that version's render_compact() function.
-
-The JSON files store raw data. Each version's render.py formats
-it differently (CROSS adds [xref:] tags, relevance scores, etc.).
-"""
-import json
-import sys
-sys.path.insert(0, 'scripts')
-from lib.render import render_compact
-from lib.schema import Report
-
-for json_file in sorted(glob('docs/comparison-results/json/*.json')):
- data = json.load(open(json_file))
- report = Report.from_dict(data)
- compact = render_compact(report)
- # Save as .md alongside the JSON
- md_file = json_file.replace('/json/', '/compact/').replace('.json', '.md')
- open(md_file, 'w').write(compact)
-```
-
-Problem: `render_compact()` differs across Base/HN/CROSS. Solution: run the converter 3 times, once per git checkout, only for that version's files.
-
-- [x] Write `scripts/generate-synthesis-inputs.py`
-- [x] Run on Base checkout for `base-*.json` files -> `docs/comparison-results/compact/base-*.md`
-- [x] Run on HN checkout for `hn-*.json` files -> `docs/comparison-results/compact/hn-*.md`
-- [x] Run on CROSS checkout for `cross-*.json` files -> `docs/comparison-results/compact/cross-*.md`
-- [x] Clean `__pycache__` between every git checkout
-
-#### 1c. Extract synthesis prompts from each version's SKILL.md
-
-Each version's SKILL.md contains the instructions Claude uses to synthesize the compact data into the narrative. These differ across versions (CROSS has cross-ref instructions, HN has HN citation rules, etc.).
-
-- [x] Check out Base (427a4e4), copy SKILL.md synthesis section (Judge Agent + Display phases) to `docs/comparison-results/prompts/base-synthesis-prompt.md`
-- [x] Check out HN (main), copy to `docs/comparison-results/prompts/hn-synthesis-prompt.md`
-- [x] Check out CROSS (feat/youtube-relevance-cross-source), copy to `docs/comparison-results/prompts/cross-synthesis-prompt.md`
-- [x] For each, prepend the hardcoded QUERY_TYPE and topic name
-
-### Phase 2: Generate 15 Synthesis Outputs
-
-#### 2a. Write the synthesis runner
-
-**Script: `scripts/run-synthesis-comparison.py`**
-
-For each of the 15 (topic, version) pairs:
-1. Read the compact markdown from `docs/comparison-results/compact/{version}-{n}-{slug}.md`
-2. Read the synthesis prompt from `docs/comparison-results/prompts/{version}-synthesis-prompt.md`
-3. Call the Anthropic API (Claude Sonnet 4.6 for cost/speed, or Opus 4.6 for max quality - configurable)
-4. Template: "You are running /last30days. The user asked about '{topic}'. QUERY_TYPE = {query_type}. Here is the research data:\n\n{compact_markdown}\n\n{synthesis_prompt}"
-5. Save response to `docs/comparison-results/synthesis/{version}-{n}-{slug}.md`
-
-```python
-"""
-Generate synthesis narratives for all 15 test cases.
-Uses Anthropic API directly to control the synthesis environment.
-"""
-import anthropic
-import os
-from pathlib import Path
-
-client = anthropic.Anthropic()
-
-TOPICS = [
- (1, 'claude-code', 'Claude Code skills and MCP servers', 'GENERAL'),
- (2, 'seedance', 'Seedance AI video generation', 'NEWS'),
- (3, 'macbook', 'M4 MacBook Pro review', 'RECOMMENDATIONS'),
- (4, 'rap', 'best rap songs 2026', 'RECOMMENDATIONS'),
- (5, 'react-svelte', 'React vs Svelte 2026', 'GENERAL'),
-]
-VERSIONS = ['base', 'hn', 'cross']
-
-for version in VERSIONS:
- prompt_text = Path(f'docs/comparison-results/prompts/{version}-synthesis-prompt.md').read_text()
- for num, slug, topic, qtype in TOPICS:
- compact = Path(f'docs/comparison-results/compact/{version}-{num}-{slug}.md').read_text()
-
- user_msg = f"""You are the /last30days skill. The user asked: "{topic}"
-
-Parsed intent:
-- TOPIC = {topic}
-- TARGET_TOOL = unknown
-- QUERY_TYPE = {qtype}
-
-Here is the research output from the Python pipeline:
-
-{compact}
-
-Now synthesize this research into your expert narrative following these instructions:
-
-{prompt_text}"""
-
- response = client.messages.create(
- model=os.environ.get('SYNTHESIS_MODEL', 'claude-sonnet-4-6-20250514'),
- max_tokens=4096,
- messages=[{"role": "user", "content": user_msg}]
- )
-
- output = response.content[0].text
- out_path = Path(f'docs/comparison-results/synthesis/{version}-{num}-{slug}.md')
- out_path.parent.mkdir(parents=True, exist_ok=True)
- out_path.write_text(output)
- print(f' {version}-{num}-{slug}: {len(output)} chars')
-```
-
-- [x] Write `scripts/run-synthesis-comparison.py`
-- [x] Run it (15 API calls, ~2-3 min total)
-- [x] Verify all 15 synthesis files exist and are non-empty
-
-#### 2b. Quality check synthesis outputs
-
-Spot-check 3 outputs (one per version) for:
-- Did Claude actually produce a "What I learned" narrative?
-- Are there real citations (per @handle, per r/subreddit)?
-- Is the stats block present?
-- Is there an invitation at the end?
-
-If any output is broken (e.g., Claude refused or produced meta-commentary instead of synthesis), adjust the prompt and re-run that one.
-
-- [x] Spot-check `cross-1-claude-code.md`, `hn-2-seedance.md`, `base-4-rap.md`
-- [x] Fix any prompt issues and re-run failed outputs
-
-### Phase 3: Evaluate - Which Version Produces the Best Output?
-
-#### 3a. Define evaluation rubric (BEFORE reading any outputs)
-
-Score each synthesis 1-5 on these dimensions:
-
-| Dimension | Weight | 1 (Bad) | 3 (OK) | 5 (Great) |
-|-----------|--------|---------|--------|-----------|
-| **Groundedness** | 30% | Generic statements, no citations | Some citations but mixed with pre-trained knowledge | Every finding backed by specific source (per @handle, per r/sub, per channel) |
-| **Specificity** | 25% | Vague ("AI video tools are improving") | Some specifics but also filler | Named entities, exact numbers, product versions ("Seedance 2.0 added lip sync per @aifilmmaker") |
-| **Coverage** | 20% | Only mentions 1-2 sources | Mentions most sources but unevenly | Weaves findings from Reddit, X, YouTube, HN naturally into narrative |
-| **Actionability** | 15% | "This is interesting" with no next step | Generic suggestions | Specific, research-derived suggestions ("I can show you Seedance 2.0's lip sync workflow") |
-| **Format Compliance** | 10% | Missing stats block, no invitation | Partial stats, generic invitation | Perfect stats block with real counts, source-specific invitation |
-
-Total: weighted average, 1.0 to 5.0
-
-#### 3b. LLM-as-judge evaluation (blinded)
-
-**Script: `scripts/evaluate-synthesis.py`**
-
-For each topic (5 total), present all 3 versions to an evaluator LLM with version labels stripped. Ask it to score each on the rubric above.
-
-```python
-"""
-Blinded LLM evaluation of synthesis outputs.
-Strips version labels, presents as Version A/B/C in random order.
-"""
-import anthropic
-import random
-
-RUBRIC = """Score each version 1-5 on these dimensions:
-
-1. GROUNDEDNESS (30%): Does the narrative cite specific sources?
- Look for: "per @handle", "per r/subreddit", "per [channel] on YouTube"
- 1 = generic, no citations. 5 = every finding has a source.
-
-2. SPECIFICITY (25%): Are findings specific or vague?
- 1 = "AI video is trending". 5 = "Seedance 2.0 added lip sync, 698 likes per @paper"
-
-3. COVERAGE (20%): Does it represent findings from all available sources?
- 1 = only Reddit mentioned. 5 = Reddit, X, YouTube, HN woven naturally.
-
-4. ACTIONABILITY (15%): Does the invitation give specific next steps based on research?
- 1 = "let me know if you want more". 5 = "I can walk you through Seedance 2.0's workflow"
-
-5. FORMAT COMPLIANCE (10%): Stats block present with real counts? Citation format correct?
- 1 = missing stats. 5 = perfect stats block + source counts + top voices.
-
-For each version, output:
-- Groundedness: X/5
-- Specificity: X/5
-- Coverage: X/5
-- Actionability: X/5
-- Format: X/5
-- Weighted Total: X.X/5.0
-- One sentence on what makes this version better or worse than the others.
-"""
-
-# For each topic, randomly shuffle version order to prevent position bias
-for topic in TOPICS:
- versions = ['base', 'hn', 'cross']
- random.shuffle(versions)
- label_map = {v: chr(65+i) for i, v in enumerate(versions)} # A, B, C
-
- # Present to evaluator
- prompt = f"Topic: {topic}\n\n"
- for v in versions:
- text = read_synthesis(v, topic)
- prompt += f"=== VERSION {label_map[v]} ===\n{text}\n\n"
- prompt += RUBRIC
-
- # Call evaluator (use Opus for best judgment)
- response = evaluate(prompt)
- # Map labels back to versions
- save_evaluation(topic, response, label_map)
-```
-
-- [x] Write `scripts/evaluate-synthesis.py`
-- [x] Run evaluation (5 API calls, one per topic)
-- [x] Collect scores into summary table
-
-#### 3c. Human spot-check
-
-Read 3 synthesis outputs yourself (one per version, same topic) and verify the LLM evaluation makes sense. If the LLM scores don't match your gut, investigate.
-
-- [x] Read all 3 versions for topic 1 (Claude Code) side-by-side
-- [x] Read all 3 versions for topic 2 (Seedance) side-by-side
-- [x] Verify LLM scores align with human judgment
-
-#### 3d. Compile verdict
-
-| Topic | Base | HN | CROSS | Winner |
-|-------|------|-----|-------|--------|
-| Claude Code | X.X | X.X | X.X | ? |
-| Seedance | X.X | X.X | X.X | ? |
-| MacBook | X.X | X.X | X.X | ? |
-| Rap songs | X.X | X.X | X.X | ? |
-| React/Svelte | X.X | X.X | X.X | ? |
-| **Overall** | **X.X** | **X.X** | **X.X** | **?** |
-
-Plus per-dimension breakdown:
-- Which version is best at Groundedness?
-- Which version is best at Specificity?
-- Which version is best at Coverage?
-- Which version is best at Actionability?
-- Which version is best at Format Compliance?
-
-- [x] Build summary table with scores
-- [x] Identify per-dimension winners
-- [x] Write verdict paragraph
-
-### Phase 4: Make CROSS the GOAT
-
-Based on the evaluation, identify the specific synthesis weaknesses and fix them. Changes fall into two buckets:
-
-#### Bucket A: Data pipeline changes (score.py, dedupe.py, render.py, youtube_yt.py)
-
-These affect what compact markdown Claude sees. From the previous JSON analysis, known issues:
-
-1. **Cross-source linking nearly broken** (3/178 items linked at 0.5 threshold)
- - Fix: hybrid similarity (token + trigram Jaccard) at 0.40 threshold
- - File: `scripts/lib/dedupe.py`
- - Expected impact: 26 items linked instead of 3
-
-2. **Cross-ref rendering cryptic** (`[xref: HN5, HN4]` is meaningless)
- - Fix: Show `[also on: HN, Reddit]` with source names
- - File: `scripts/lib/render.py`
- - Expected impact: Claude can naturally say "discussed on both Reddit and HN"
-
-3. **YouTube synonym gap** ("hip hop" != "rap" in relevance scoring)
- - Fix: SYNONYMS dict in youtube_yt.py
- - File: `scripts/lib/youtube_yt.py`
- - Expected impact: "Lit Hip Hop Mix 2026" gets 0.67+ instead of 0.33
-
-4. **HN search too narrow** (React/Svelte returns 0 HN items)
- - Fix: Split multi-keyword topics into OR queries for HN Algolia
- - File: `scripts/lib/hackernews.py` (or wherever HN search lives)
- - Expected impact: Framework debate topics get HN coverage
-
-#### Bucket B: Synthesis instruction changes (SKILL.md)
-
-These affect how Claude interprets the data. Specific changes to discover from the 15-output comparison:
-
-5. **Cross-source narrative instruction** - If CROSS data has `[also on: HN, Reddit]` tags but Claude ignores them, add explicit instruction: "When items appear across multiple platforms, lead with that cross-platform signal - it's the strongest evidence of importance."
-
-6. **YouTube transcript utilization** - CROSS provides transcript snippets. If Claude's synthesis doesn't use them, add: "YouTube transcripts contain direct quotes and technical details. Weave 1-2 transcript quotes into your synthesis."
-
-7. **Source weighting for synthesis** - Current: Reddit/X > YouTube > Web. Should HN be weighted higher for tech topics? Should YouTube transcript content elevate YouTube's synthesis weight?
-
-8. **Citation density tuning** - The paper.design example has ~1 citation per paragraph. If any version over-cites (every sentence) or under-cites (no sources), adjust the citation frequency instruction.
-
-9. **Stats block accuracy** - Verify each version's stats block matches actual data. If counts are wrong, the render output may need to include pre-computed stats that Claude can copy directly.
-
-10. **Invitation quality** - The best invitations reference specific things from the research ("I can walk you through Seedance 2.0's lip sync workflow"). If a version produces generic invitations ("let me know if you want to know more"), strengthen the instruction.
-
-Changes 5-10 are discovered from Phase 3 analysis. They may or may not apply.
-
-- [x] Implement Bucket A changes (data pipeline fixes from JSON analysis)
-- [x] Analyze Phase 3 results to identify Bucket B changes needed
-- [x] Implement Bucket B changes (SKILL.md synthesis instruction improvements)
-- [x] Run `bash scripts/sync.sh` to deploy updated skill
-
-### Phase 5: Validate
-
-Re-run 5 synthesis outputs (one per topic, CROSS-after only) using the improved CROSS code, and compare against CROSS-before.
-
-- [x] Generate 5 new compact markdowns from JSON data using improved render.py
-- [x] Generate 5 new synthesis outputs using improved SKILL.md
-- [x] Score with same rubric (LLM-as-judge, blinded against CROSS-before)
-- [x] Verify improvement on every dimension, or iterate
-
-## File Structure
-
-```
-docs/comparison-results/
- json/ # 15 JSON files (already exist at /tmp, need copying)
- base-1-claude-code.json
- hn-1-claude-code.json
- cross-1-claude-code.json
- ... (15 total)
- compact/ # 15 compact markdown files (rendered per-version)
- base-1-claude-code.md
- hn-1-claude-code.md
- cross-1-claude-code.md
- ... (15 total)
- prompts/ # 3 synthesis prompts (extracted from each SKILL.md)
- base-synthesis-prompt.md
- hn-synthesis-prompt.md
- cross-synthesis-prompt.md
- synthesis/ # 15 synthesis narratives (the actual output!)
- base-1-claude-code.md
- hn-1-claude-code.md
- cross-1-claude-code.md
- ... (15 total)
- evaluation/ # 5 evaluation results (one per topic, blinded)
- eval-1-claude-code.md
- eval-2-seedance.md
- eval-3-macbook.md
- eval-4-rap.md
- eval-5-react-svelte.md
- summary.md # Final verdict with all scores and improvement plan
-
-scripts/
- generate-synthesis-inputs.py # JSON -> compact converter
- run-synthesis-comparison.py # Compact + prompt -> synthesis via Claude API
- evaluate-synthesis.py # Blinded LLM evaluation
-```
-
-## Acceptance Criteria
-
-- [x] All 15 JSON files preserved in `docs/comparison-results/json/`
-- [x] All 15 compact markdowns generated (version-specific rendering preserved)
-- [x] All 3 synthesis prompts extracted from version-specific SKILL.md files
-- [x] All 15 synthesis narratives generated and saved
-- [x] All 15 syntheses scored on 5-dimension rubric (blinded, LLM-as-judge)
-- [x] Summary table shows per-topic and overall winner
-- [x] At least 3 synthesis outputs human-spot-checked against LLM scores
-- [x] CROSS improvement plan includes both data pipeline (Bucket A) and synthesis instruction (Bucket B) changes
-- [x] Bucket A changes implemented with tests
-- [x] Bucket B changes implemented and deployed via sync.sh
-- [x] 5 validation synthesis outputs show improvement over CROSS-before
-- [x] Final summary.md documents everything: scores, verdict, changes, validation
-
-## Dependencies & Risks
-
-| Risk | Mitigation |
-|------|------------|
-| `/tmp` data loss before copying | Phase 1a is the first action - copy immediately |
-| Claude API rate limits during synthesis | 15 calls is well under limits; add 2s sleep between calls |
-| LLM evaluator bias toward longer outputs | Rubric weights specificity and groundedness, not length |
-| QUERY_TYPE affecting output format | Hardcoded per topic in synthesis prompt |
-| Reddit item-count variance confounding comparison | Note caveat; flag topics where counts differ >30% |
-| render_compact() crash on JSON data | Test converter on 1 file before batch run |
-
-## Technical Considerations
-
-### Why Anthropic API instead of `claude --print`?
-
-1. **Controlled environment** - Same model, same temperature, same max_tokens for all 15
-2. **No WebSearch confound** - API calls don't have tool access unless we grant it
-3. **Reproducible** - Can re-run with different models or prompts
-4. **Faster** - API call takes ~10s vs ~3min for full `claude --print` with tools
-
-### Why reuse JSON data instead of re-running the pipeline?
-
-1. **Eliminates temporal non-determinism** - Same Reddit/X/YouTube data for all comparisons
-2. **No rate limiting** - Zero API calls to source platforms
-3. **Already validated** - 15 files verified clean, zero errors
-4. **Still tests rendering differences** - Each version's render_compact() runs on its checkout
-
-### What about the WebSearch step?
-
-The full skill pipeline includes Claude doing WebSearch after the Python script. We deliberately exclude this because:
-1. WebSearch results vary per run (different web results each time)
-2. WebSearch is identical across all 3 versions (no version difference to test)
-3. Including it would confound the comparison with noise
-4. The Python pipeline data is where version differences live
-
-## Sources & References
-
-- Previous JSON analysis: [docs/plans/2026-02-25-analysis-cross-source-comparison-plan.md](docs/plans/2026-02-25-analysis-cross-source-comparison-plan.md) - data-layer comparison with scoring verdict
-- Previous test plan: [docs/plans/2026-02-25-feat-15-test-comparison-make-cross-goat-plan.md](docs/plans/2026-02-25-feat-15-test-comparison-make-cross-goat-plan.md) - execution protocol for 15 JSON test runs
-- SKILL.md synthesis instructions: `SKILL.md` lines 165-320 (Judge Agent + Display phases)
-- Scoring weights: `scripts/lib/score.py` - 45% relevance + 25% recency + 30% engagement
-- Render pipeline: `scripts/lib/render.py:57` - `render_compact()` function
-- Cross-source linking: `scripts/lib/dedupe.py:160` - `cross_source_link()` function
-- YouTube relevance: `scripts/lib/youtube_yt.py` - `_compute_relevance()` token overlap
-- Existing test harness: `scripts/test-v1-vs-v2.sh:104` - `claude --print` approach
-- YouTube display bug: `docs/plans/2026-02-15-fix-youtube-display-and-search-quality-plan.md`
-- Output formatting fixes: `docs/plans/2026-02-06-fix-last30days-v2-formatting-reddit-citations-plan.md`
diff --git a/docs/plans/2026-02-25-feat-polymarket-prediction-market-source-plan.md b/docs/plans/2026-02-25-feat-polymarket-prediction-market-source-plan.md
deleted file mode 100644
index 7382cb3..0000000
--- a/docs/plans/2026-02-25-feat-polymarket-prediction-market-source-plan.md
+++ /dev/null
@@ -1,254 +0,0 @@
----
-title: "feat: Add Polymarket prediction market search as 6th source"
-type: feat
-status: completed
-date: 2026-02-25
----
-
-# feat: Add Polymarket Prediction Market Search
-
-## Overview
-
-Add Polymarket as a 6th research source to last30days. When researching any topic, search Polymarket's public API for relevant prediction markets - surfacing what people are putting real money on alongside what they're saying on Reddit/X/YouTube/HN/Web.
-
-Example: "/last30days Arizona Basketball" would find markets on tournament seeding, Big 12 title odds, and March Madness outcomes. The signal is unique - betting odds reflect conviction, not just opinions.
-
-## Proposed Solution
-
-Hit Polymarket's Gamma API with **smart multi-query search** - not just the raw topic, but expanded related keywords to find markets a human researcher would think of. Show price movement context ("down from 23.7% peak") using the API's built-in price change fields. No API key needed.
-
-Also: hide sources with zero results from the stats box (all sources, not just Polymarket).
-
-## Technical Approach
-
-### API Details
-
-**Endpoint:** `GET https://gamma-api.polymarket.com/public-search?q={topic}&limit={N}`
-
-- No authentication required (free, public, read-only)
-- Rate limit: 350 req/10s (very generous)
-- Returns `{"events": [...], "pagination": {...}}`
-- Events contain nested `markets` arrays
-- `outcomePrices` is a JSON-encoded string - must `json.loads()` it
-- `volume` and `liquidity` are strings at market level, floats at event level
-- **Price movement fields on every market:** `oneDayPriceChange`, `oneWeekPriceChange`, `oneMonthPriceChange` - these are free, no extra API calls
-
-### Intelligence Layer: Smart Query Expansion
-
-A single keyword search is dumb. "Iran" should find markets about Iran strikes, nuclear program, sanctions, oil prices, Khamenei. "Arizona Basketball" should find NCAA tournament odds, Big 12 title, March Madness seeding.
-
-**How it works:**
-
-1. **Extract core subject** using `_extract_core_subject(topic)` (already exists for YouTube/X)
-2. **Generate 2-4 expanded queries** from the topic:
- - The raw topic itself: `"Arizona Basketball"`
- - Broader context: `"Arizona NCAA"`, `"Arizona Big 12"`
- - For people/entities: add key associated terms (e.g., "Iran" -> "Iran strikes", "Khamenei", "Iran nuclear")
-3. **Run all queries in parallel** against `/public-search` (rate limit is 350/10s, we're using 3-4)
-4. **Merge and dedupe** results across queries (same event ID = same market)
-5. **Score by relevance to original topic** - markets found by the raw topic query get a relevance boost over expanded-query matches
-
-**Query expansion strategy:**
-
-```python
-def _expand_polymarket_queries(topic: str) -> List[str]:
- """Generate 2-4 search queries to cast a wider net."""
- core = _extract_core_subject(topic)
- queries = [core] # Always include the core topic
-
- # Split multi-word topics into component searches
- words = core.split()
- if len(words) >= 2:
- # Try the first significant word alone (e.g., "Arizona" from "Arizona Basketball")
- queries.append(words[0])
-
- # Add the full topic if different from core
- if topic.lower() != core.lower():
- queries.append(topic)
-
- return list(dict.fromkeys(queries))[:4] # Dedupe, cap at 4
-```
-
-This is the same approach as YouTube synonym expansion and X handle resolution - cast a wider net, then score by relevance.
-
-### Price Movement Context
-
-The API gives us price change data for free. Use it to make the output actually useful:
-
-```
-Will Arizona win the NCAA Tournament?
- Yes: 12% (down 11.7% this month) | No: 88%
- $342K vol24h | $2.1M liquidity
-```
-
-**Fields available:**
-- `oneDayPriceChange` - "up 3% today"
-- `oneWeekPriceChange` - "down 5% this week"
-- `oneMonthPriceChange` - "down 11.7% this month"
-
-Show the most significant movement (largest absolute change). Only show if change > 1% to avoid noise.
-
-### Key Design Decisions
-
-**Event-level granularity (not market-level).** Each event becomes one `PolymarketItem` showing its title and top 3 markets by volume. A "Trump" query returns 10 events, not 40+ individual markets. This keeps item counts manageable.
-
-**Smart multi-query search with dedup.** Don't rely on one keyword search. Expand the topic into 2-4 related queries, run them all, merge by event ID. This catches markets that use different terminology than the user's query.
-
-**Price movement context by default.** Show the most significant price change (day/week/month) inline with outcome prices. This makes prediction markets actually useful as a research signal - not just "Yes 65%" but "Yes 65%, up 12% this week."
-
-**Date filtering uses `active=true` + `volume1mo > 0`.** Markets can be created months ago but still actively traded. Filter by recent trading activity, not creation date. Set `date` to `updatedAt` for recency scoring. Set `date_confidence` to "high" (API provides exact timestamps).
-
-**Sort priority: below HN, above WebSearch.** Priority 4 (Reddit=0 > X=1 > YouTube=2 > HN=3 > Polymarket=4 > WebSearch=5). Prediction markets are supplementary signal.
-
-**Multi-outcome markets: show top 3 outcomes by price.** Binary markets show "Yes: 65%, No: 35%". Multi-candidate markets truncate to top 3 with "and N more".
-
-**Exclude closed/resolved markets for v1.** Only show `active=true, closed=false`.
-
-**Link to event pages** (not individual market pages) - shows all related markets in context.
-
-### Implementation Plan
-
-#### Phase 1: Hide zero-result sources (separate commit)
-
-- [x] `scripts/lib/render.py` - In `render_source_status()`, skip lines where count is 0
-- [x] `SKILL.md` - Remove "(no results this cycle)" instructions, replace with "omit sources with zero results"
-- [x] `variants/open/references/research.md` - Same update
-- [x] `~/.claude/skills/last30daysCROSS/SKILL.md` - Same update (via sync.sh)
-- [x] Test: run a query where HN returns 0, verify it's hidden
-
-#### Phase 2: Polymarket source module
-
-- [x] `scripts/lib/polymarket.py` - New file:
- - `DEPTH_CONFIG = {"quick": 5, "default": 10, "deep": 20}` (event count per query)
- - `_expand_queries(topic)` - generate 2-4 search queries from topic:
- - Raw topic: `"Arizona Basketball"`
- - Core subject extracted: `"Arizona"` (via `_extract_core_subject`)
- - Component words for multi-word topics: first significant word alone
- - Cap at 4 queries, dedupe
- - `search_polymarket(topic, from_date, to_date, depth)` - run all expanded queries against `/public-search` in parallel (ThreadPoolExecutor), merge results by event ID, dedupe
- - `parse_polymarket_response(response)` - extract events, flatten top markets per event
- - `_format_price_movement(market)` - pick most significant price change from `oneDayPriceChange`, `oneWeekPriceChange`, `oneMonthPriceChange`, format as "up/down X% this day/week/month", skip if < 1%
- - URL-encode query params with `urllib.parse.urlencode`
- - Filter: `active=true, closed=false, volume1mo > 0`
- - Parse `outcomePrices` with `json.loads()`, handle malformed/missing gracefully
- - Handle mixed types: `float(volume)` with try/except
- - Relevance scoring: markets from raw topic query get 1.0 base, expanded queries get 0.7 base, then decay by position
- - Return `{"items": [...]}` or `{"items": [], "error": "message"}`
-
-#### Phase 3: Schema + normalization + scoring
-
-- [x] `scripts/lib/schema.py` - Add `PolymarketItem` dataclass:
- - `id`, `title` (event title), `question` (top market question), `url` (event URL)
- - `outcome_prices: List[Tuple[str, float]]` (parsed, e.g. [("Yes", 0.65), ("No", 0.35)])
- - `price_movement: Optional[str]` (formatted, e.g. "down 11.7% this month")
- - `volume24hr: float`, `liquidity: float`, `end_date: Optional[str]`
- - Standard fields: `date`, `date_confidence`, `engagement`, `relevance`, `why_relevant`, `subs`, `score`, `cross_refs`
- - Add `volume: Optional[float]` and `liquidity: Optional[float]` to `Engagement` dataclass
- - Add `polymarket: List[PolymarketItem]` and `polymarket_error: Optional[str]` to `Report`
- - Update `Report.to_dict()` and `Report.from_dict()` (handle missing key for backward compat)
-
-- [x] `scripts/lib/normalize.py` - Add `normalize_polymarket_items()`:
- - Map raw API events to `PolymarketItem` instances
- - Set `date` from `updatedAt`, `date_confidence = "high"`
- - Build `Engagement(volume=volume24hr, liquidity=liquidity)`
- - Update `TypeVar` to include `PolymarketItem`
-
-- [x] `scripts/lib/score.py` - Add scoring:
- - `compute_polymarket_engagement_raw()`: `0.60 * log1p(volume24hr) + 0.40 * log1p(liquidity)`
- - `score_polymarket_items()`: standard 45/25/30 weights (relevance/recency/engagement)
- - Update `sort_items()`: Polymarket priority = 4
-
-#### Phase 4: Dedupe + render + UI
-
-- [x] `scripts/lib/dedupe.py`:
- - Add `PolymarketItem` to `AnyItem` union
- - Add `dedupe_polymarket()` function
- - Update `get_item_text()` for `PolymarketItem` (return `title + " " + question`)
- - Update `_get_cross_source_text()` for cross-source linking
-
-- [x] `scripts/lib/render.py`:
- - Add `_xref_tag()` prefix: `PM` -> `Polymarket`
- - Add Polymarket section to `render_compact()`:
- ```
- ### Prediction Markets (Polymarket)
-
- **PM1** (score:72) [$342K vol24h, $2.1M liquidity]
- Will Arizona win the NCAA Tournament?
- Yes: 12% (down 11.7% this month) | No: 88%
- https://polymarket.com/event/arizona-ncaa-tournament
-
- **PM2** (score:65) [$89K vol24h, $450K liquidity]
- Will Arizona win the Big 12 Tournament?
- Yes: 28% (up 4% this week) | No: 72%
- https://polymarket.com/event/arizona-big-12
- ```
- - Add to `render_source_status()` (with zero-result hiding from Phase 1)
- - Add to `render_full_report()` and `render_context_snippet()`
- - Add to `_assess_data_freshness()`
-
-- [x] `scripts/lib/ui.py`:
- - Add `POLYMARKET_MESSAGES` list
- - Add `start_polymarket()` / `end_polymarket()` methods
- - Update `show_complete()` to accept `polymarket_count`
-
-#### Phase 5: Wire into main pipeline
-
-- [x] `scripts/last30days.py`:
- - Import `from lib import polymarket`
- - Add `_search_polymarket()` wrapper function
- - Add `polymarket_future` to `TIMEOUT_PROFILES` (15s - API is fast)
- - In `run_research()`: submit polymarket future to ThreadPoolExecutor, increment max_workers
- - Collect results with timeout, add to processing pipeline (normalize -> filter -> score -> sort -> dedupe)
- - Feed into `cross_source_link()` call
- - Assign to `report.polymarket` / `report.polymarket_error`
- - Update `--diagnose` output: `"polymarket": True` (always available)
- - Update `--store` persistence loop for Polymarket items
- - Update `show_complete()` call with `len(deduped_pm)`
-
-- [x] `scripts/lib/env.py` - Add `is_polymarket_available()` -> always `True`
-
-#### Phase 6: Documentation + deploy
-
-- [x] `SKILL.md` - Update "6 sources", add Polymarket to stats box template, update Security section with `gamma-api.polymarket.com`
-- [x] `variants/open/references/research.md` - Same source count + stats updates
-- [x] `README.md` - Add Polymarket to feature list, update source count references
-- [x] `SPEC.md` - Add `polymarket.py` to architecture list
-- [x] Run `bash scripts/sync.sh` to deploy
-
-#### Phase 7: Tests
-
-- [x] `tests/test_polymarket.py` - New file:
- - `TestParsePolymarketResponse` - binary markets, multi-outcome, malformed outcomePrices, missing fields
- - `TestNormalizePolymarketItems` - schema mapping, date handling, type coercion
- - `TestScorePolymarketItems` - engagement formula, zero volume, high volume
-- [x] `fixtures/polymarket_sample.json` - Representative API response with edge cases
-- [x] `tests/test_cross_source.py` - Add Polymarket-to-Reddit linking test case
-
-## Acceptance Criteria
-
-- [x] `/last30days "Arizona Basketball"` shows relevant Polymarket prediction markets with odds and volume
-- [x] `/last30days "best rap songs 2026"` gracefully returns no Polymarket results (and hides the PM line from stats)
-- [x] Sources with zero results are hidden from stats box (all sources, not just PM)
-- [x] `--diagnose` shows `"polymarket": true`
-- [x] Multi-outcome markets show top 3 outcomes
-- [x] Polymarket items participate in cross-source linking
-- [x] No API key required - works out of the box for all users
-- [x] `--quick`, `--deep` flags affect Polymarket result count
-- [x] Older cached reports without `polymarket` key load without errors
-
-## Dependencies & Risks
-
-**No blockers.** The Gamma API is free, unauthenticated, and has generous rate limits (350 req/10s). No new dependencies needed - just stdlib `urllib` (already used by HN module).
-
-**Risk: API availability.** Polymarket could change or restrict their API. Mitigation: the source is optional and fails gracefully (returns empty list, hidden from stats).
-
-**Risk: Relevance mismatch.** Many topics won't have prediction markets. The hide-zero-sources change ensures this doesn't pollute the output.
-
-## Sources & References
-
-- Polymarket Gamma API docs: https://docs.polymarket.com/developers/gamma-markets-api/overview
-- Polymarket CLI: https://github.com/Polymarket/polymarket-cli
-- HN source plan (pattern to follow): `docs/plans/2026-02-24-feat-hacker-news-source-plan.md`
-- HN source implementation (most recent similar feature): `scripts/lib/hackernews.py`
-- Schema: `scripts/lib/schema.py`
-- Main pipeline: `scripts/last30days.py`
diff --git a/docs/plans/2026-02-25-feat-polymarket-quality-ranking-plan.md b/docs/plans/2026-02-25-feat-polymarket-quality-ranking-plan.md
deleted file mode 100644
index ab7cf67..0000000
--- a/docs/plans/2026-02-25-feat-polymarket-quality-ranking-plan.md
+++ /dev/null
@@ -1,189 +0,0 @@
----
-title: "feat: Improve Polymarket result ranking with quality signals"
-type: feat
-status: completed
-date: 2026-02-25
----
-
-# feat: Improve Polymarket Result Ranking with Quality Signals
-
-## Overview
-
-When a topic like "OpenAI" returns 163+ Polymarket events, the current implementation ranks results almost entirely by API return position (75% weight on `i`), with only a tiny volume boost (0-15%). This means the scoring doesn't reflect actual market quality - a $1M/month market and a $5K/month market get nearly identical relevance scores if they're adjacent in the API response.
-
-Fix the ranking so the most actively traded, fastest-moving, most contested markets bubble to the top.
-
-## Problem Statement
-
-Current relevance formula in `parse_polymarket_response()` (line 328-330):
-
-```python
-rank_score = max(0.3, 1.0 - (i * 0.03)) # 75% weight on position
-engagement_boost = min(0.15, math.log1p(volume24hr) / 60)
-relevance = min(1.0, rank_score * 0.75 + engagement_boost + 0.1)
-```
-
-Issues:
-1. **Position dominance**: A market at position 2 with $0 volume scores higher than a market at position 8 with $1M volume
-2. **`limit` parameter is a no-op**: The Gamma API always returns exactly 5 events per page regardless of `limit`. Our `DEPTH_CONFIG` values (5, 10, 20) do nothing
-3. **Rich quality signals are ignored**: Event-level `volume1mo`, `volume1wk`, `competitive`, `commentCount` fields are available but unused
-4. **Price movement is displayed but not scored**: Markets with dramatic price swings get no ranking boost
-5. **No text-similarity scoring**: A tangential market that happens to mention "OpenAI" ranks the same as one directly about OpenAI
-
-## API Findings (Verified)
-
-**Pagination**: `?page=N` works as 1-indexed offset. Each page returns exactly 5 events. `hasMore: true` indicates more pages exist. `totalResults` gives total count.
-
-**Event-level quality fields** (confirmed via live API):
-
-| Field | Level | Example | Currently Used |
-|-------|-------|---------|----------------|
-| `volume24hr` | Event + Market | $13,334 | Market only (for engagement) |
-| `volume1wk` | Event + Market | $1,051,626 | No |
-| `volume1mo` | Event + Market | $1,133,684 | No |
-| `liquidity` | Event + Market | $16,285 | Market only (for filtering) |
-| `competitive` | Event + Market | 0.995 | No |
-| `commentCount` | Event only | 2 | No |
-| `oneDayPriceChange` | Market only | -0.02 | Display only, not scored |
-| `oneWeekPriceChange` | Market only | -0.05 | Display only, not scored |
-| `oneMonthPriceChange` | Market only | -0.117 | Display only, not scored |
-
-**API naturally sorts well**: Page 1 has active high-volume markets ($1M+ monthly volume), page 3 is all dead historical markets ($0 volume). So the API's own ranking is decent - the problem is our scoring doesn't preserve this quality signal.
-
-## Proposed Solution
-
-### 1. Replace position-based relevance with quality-signal relevance
-
-New formula in `parse_polymarket_response()`:
-
-```python
-# Text similarity: does the event title contain the search topic?
-core = _extract_core_subject(topic).lower()
-title_lower = title.lower()
-if core and core in title_lower:
- text_score = 1.0
-else:
- # Token overlap fallback
- topic_tokens = set(core.lower().split())
- title_tokens = set(title_lower.split())
- overlap = len(topic_tokens & title_tokens)
- text_score = overlap / max(len(topic_tokens), 1)
-
-# Volume signal: log-scaled monthly volume (most stable signal)
-vol_score = min(1.0, math.log1p(event_volume1mo) / 16) # ~$9M = 1.0
-
-# Liquidity signal
-liq_score = min(1.0, math.log1p(event_liquidity) / 14) # ~$1.2M = 1.0
-
-# Price movement: largest absolute change, capped
-max_change = max(
- abs(oneDayPriceChange or 0) * 3, # Daily weighted 3x
- abs(oneWeekPriceChange or 0) * 2, # Weekly weighted 2x
- abs(oneMonthPriceChange or 0) * 1, # Monthly weighted 1x
-)
-movement_score = min(1.0, max_change * 5) # 20% change = 1.0
-
-# Competitive bonus: markets near 50/50 are more interesting
-competitive_score = event_competitive or 0
-
-# Final relevance
-relevance = (
- 0.30 * text_score +
- 0.30 * vol_score +
- 0.15 * liq_score +
- 0.15 * movement_score +
- 0.10 * competitive_score
-)
-```
-
-### 2. Fix DEPTH_CONFIG to use pagination
-
-```python
-# Pages to fetch per query (API returns 5 events per page)
-DEPTH_CONFIG = {
- "quick": 1, # 5 events/query, ~5-10 unique after dedup
- "default": 2, # 10 events/query, ~10-15 unique after dedup
- "deep": 3, # 15 events/query, ~15-25 unique after dedup
-}
-```
-
-### 3. Use event-level volume fields
-
-Extract `volume1mo`, `volume1wk`, `liquidity`, and `competitive` from the event object (not just the top market). These are more stable signals than market-level `volume24hr`.
-
-### 4. Cap results after re-ranking
-
-After pagination, merge, dedup, and re-ranking, cap at a reasonable number before sending to the scoring pipeline:
-
-```python
-RESULT_CAP = {
- "quick": 5,
- "default": 10,
- "deep": 20,
-}
-```
-
-## Technical Approach
-
-### Implementation Plan
-
-#### Phase 1: Fix pagination and DEPTH_CONFIG
-
-- [x] `scripts/lib/polymarket.py` - Change `DEPTH_CONFIG` to page counts: `{"quick": 1, "default": 2, "deep": 3}`
-- [x] `scripts/lib/polymarket.py` - Add `RESULT_CAP` dict: `{"quick": 5, "default": 10, "deep": 20}`
-- [x] `scripts/lib/polymarket.py` - Update `_search_single_query()` to accept a `page` parameter
-- [x] `scripts/lib/polymarket.py` - Update `search_polymarket()` to fetch multiple pages per query in parallel (fire all `(query, page)` combinations into ThreadPoolExecutor at once)
-- [x] `scripts/lib/polymarket.py` - Apply `RESULT_CAP` after merge + dedup, before returning events
-- [x] `tests/test_polymarket.py` - Update `TestDepthConfig` tests for new page-count values
-
-#### Phase 2: Extract event-level quality signals
-
-- [x] `scripts/lib/polymarket.py` - In `parse_polymarket_response()`, extract event-level fields: `volume1mo`, `volume1wk`, `liquidity`, `competitive`, `commentCount`
-- [x] `scripts/lib/polymarket.py` - Pass `topic` to `parse_polymarket_response()` (already has the parameter, just need to use it)
-- [x] `fixtures/polymarket_sample.json` - Add event-level fields: `volume1mo`, `volume1wk`, `competitive`, `commentCount`, `volume24hr`, `liquidity`
-
-#### Phase 3: Replace relevance formula
-
-- [x] `scripts/lib/polymarket.py` - Replace position-based relevance formula with quality-signal formula (text similarity + volume + liquidity + price movement + competitive)
-- [x] `scripts/lib/polymarket.py` - Add `_compute_text_similarity(topic, title)` helper
-- [x] `tests/test_polymarket.py` - Add `TestTextSimilarity` test class
-- [x] `tests/test_polymarket.py` - Add `TestQualityRanking` test: given events with varying volume/liquidity/text-match, verify high-volume title-matching events rank above low-volume tangential ones
-
-#### Phase 4: Update engagement scoring
-
-- [x] `scripts/lib/schema.py` - No changes needed (Engagement already has `volume` and `liquidity`)
-- [x] `scripts/lib/polymarket.py` - Use event-level `volume1mo` instead of market-level `volume24hr` for the `volume24hr` field passed to normalization (or add a new field)
-- [x] `scripts/lib/normalize.py` - Update `normalize_polymarket_items()` to use `volume1mo` for engagement volume if available, fallback to `volume24hr`
-
-#### Phase 5: Tests and verification
-
-- [x] Run full test suite
-- [ ] Manual test: `/last30days "OpenAI" --emit=compact` - verify top markets are the most actively traded
-- [ ] Manual test: `/last30days "Anthropic" --emit=compact` - verify quality ranking
-- [ ] Manual test: `/last30days "best rap songs 2026" --emit=compact` - verify graceful zero results
-- [x] Run `bash scripts/sync.sh` to deploy
-
-## Acceptance Criteria
-
-- [ ] "OpenAI" search surfaces IPO market cap, product announcements, and GPT benchmark markets (high volume) before niche/dead markets
-- [x] Markets with $0 monthly volume are filtered out (already handled by liquidity filter, but verify)
-- [x] `DEPTH_CONFIG` actually affects result count (quick=~5, default=~10, deep=~20)
-- [x] Price movement is factored into ranking (markets with large swings rank higher)
-- [x] Text-matching markets rank above tangential keyword matches
-- [x] All existing tests pass (71 polymarket + full suite: 218 passed, 5 pre-existing failures)
-- [ ] No performance regression - pagination adds latency but stays within timeout budgets
-
-## Dependencies & Risks
-
-**No blockers.** This is a scoring/ranking improvement within the existing Polymarket module. No new API keys, no new dependencies.
-
-**Risk: Over-tuning the formula.** The weights (0.30/0.30/0.15/0.15/0.10) are educated guesses. May need iteration after testing with real queries. Mitigation: the formula is in one place (`parse_polymarket_response`) and easy to adjust.
-
-**Risk: Pagination latency.** Deep mode with 3 pages x 4 queries = 12 API calls. All run in parallel via ThreadPoolExecutor. Gamma API is fast (~200-500ms per call), so worst case ~1-2s total. Well within the 45s deep timeout.
-
-## Sources & References
-
-- Polymarket Gamma API: `GET https://gamma-api.polymarket.com/public-search?q={topic}&page={N}`
-- Current implementation: `scripts/lib/polymarket.py`
-- Scoring pipeline: `scripts/lib/score.py`
-- Original Polymarket plan: `docs/plans/2026-02-25-feat-polymarket-prediction-market-source-plan.md`
diff --git a/docs/plans/2026-02-25-feat-x-handle-resolution-plan.md b/docs/plans/2026-02-25-feat-x-handle-resolution-plan.md
deleted file mode 100644
index 3e99e08..0000000
--- a/docs/plans/2026-02-25-feat-x-handle-resolution-plan.md
+++ /dev/null
@@ -1,291 +0,0 @@
----
-title: "feat: Resolve X handles for topic entities"
-type: feat
-status: completed
-date: 2026-02-25
----
-
-# feat: Resolve X handles for topic entities
-
-## Overview
-
-When a user searches for a person, brand, or creator (e.g., "Dor Brothers", "Jason Calacanis"), the skill should automatically resolve their X handle and search their posts directly. Currently, Phase 1 only finds posts that *mention* the topic keywords, and Phase 2 only drills into handles that appeared in Phase 1 results. This misses the entity's own posts entirely when they don't literally include the topic string.
-
-## Problem Statement
-
-**The Dor Brothers example:** Searching "Dor Brothers" found 30 X posts with 149 likes - all posts *about* them. But the Dor Brothers' own account posts constantly about their AI films, tools, and collabs. Those posts wouldn't appear in Phase 1 because the Dor Brothers don't write "Dor Brothers" in every tweet. Phase 2 can't help because entity_extract only finds handles already @mentioned in Phase 1 results.
-
-**The Jason Calacanis example:** His handle is @jason - completely unguessable from his name. No amount of keyword searching or @mention extraction would discover it. You need an actual lookup.
-
-**What's missing:** A handle resolution step that maps `"topic name" -> @handle`, then searches that handle's recent posts directly (without requiring topic keywords in the tweet text).
-
-## User Base Constraints
-
-- **80% Claude Code** - agent is Claude, has WebSearch tool
-- **20% OpenClaw** - agent has web search capability
-- **Most users have:** OPENAI_API_KEY
-- **Some users have:** XAI_API_KEY, Bird (browser cookies)
-- **Few users have:** BRAVE_API_KEY, PARALLEL_API_KEY, OPENROUTER_API_KEY
-- **Everyone has:** Claude (it's the runtime)
-
-**Critical constraint:** The Python script's web search backends (`brave_search.py`, `parallel_search.py`, `openrouter_search.py`) ALL filter out x.com URLs via `EXCLUDED_DOMAINS`. Building handle resolution inside the Python script would require bypassing this filter, and most users don't have the API keys for those backends anyway.
-
-## Proposed Solution
-
-Move handle resolution to the **SKILL.md agent layer**. The agent (Claude or OpenClaw) already has `WebSearch` as an allowed tool. It does a single WebSearch before running the Python script, extracts the handle, and passes it as a CLI argument.
-
-### Architecture
-
-```
-SKILL.md Agent Flow (revised):
-
-1. Parse intent (existing)
-2. NEW: If topic looks like a person/brand, WebSearch("{topic} X twitter handle")
-3. NEW: Extract @handle from results (agent intelligence, not regex)
-4. Run script: python3 last30days.py "Dor Brothers" --x-handle=TheDorBrothers --emit=compact
-5. Script uses --x-handle in Phase 2: from:TheDorBrothers (no topic filter)
-6. WebSearch supplementary (existing Step 2)
-7. Synthesize (existing)
-```
-
-### Why agent-level, not Python-level
-
-| Approach | Works for | Problem |
-|----------|-----------|---------|
-| Brave API in Python | Users with BRAVE_API_KEY (~5%) | Almost nobody has the key |
-| OpenAI Responses API in Python | Users with OPENAI_API_KEY (~90%) | Costs money, adds complexity, x.com URLs may be filtered |
-| Agent WebSearch (SKILL.md) | **100% of users** | Adds ~5-8 sec before script |
-| xAI API in Python | Users with XAI_API_KEY (~30%) | Not universal |
-
-The agent approach wins because:
-1. **100% availability** - Claude/OpenClaw always has WebSearch
-2. **Zero API key requirements** - WebSearch is built into the agent runtime
-3. **Smarter parsing** - Claude is better at "find the X handle for Dor Brothers" than any regex
-4. **Simpler implementation** - SKILL.md instruction + `--x-handle` CLI arg, no new Python module
-5. **No EXCLUDED_DOMAINS problem** - agent WebSearch is independent of the Python web search backends
-
-The ~5-8 second latency is negligible against the 2-3 minute script runtime.
-
-### SKILL.md Changes (Claude Code variant)
-
-Add between intent parsing and Step 1:
-
-```markdown
-## Step 0.5: Resolve X Handle (if topic is a person/brand)
-
-If TOPIC looks like a person, creator, brand, or specific account (1-3 words, proper noun),
-do ONE WebSearch to find their X handle:
-
-WebSearch("{TOPIC} X twitter handle")
-
-From the results, extract the X/Twitter handle. Look for:
-- Profile URLs: x.com/{handle} or twitter.com/{handle}
-- Mentions like "@handle" in bios, articles, or social profiles
-- "Follow @handle on X" patterns
-
-If you find a clear, unambiguous handle, pass it to the script:
- --x-handle={handle}
-
-If ambiguous or not found, omit the flag. The script works fine without it.
-
-Skip this step if:
-- TOPIC is clearly not an entity (e.g., "best rap songs 2026", "how to use Docker")
-- TOPIC already contains @ (e.g., "@elonmusk")
-- Using --quick depth
-```
-
-### OpenClaw Variant Changes
-
-Same instruction added to `variants/open/references/research.md` (or inline in the open SKILL.md routing). OpenClaw agents also have WebSearch available.
-
-### Python Script Changes
-
-**`last30days.py` - Add `--x-handle` argument:**
-
-```python
-parser.add_argument('--x-handle', type=str, default=None,
- help='Resolved X handle for topic entity (without @)')
-```
-
-Pass `resolved_handle` to `_run_supplemental()`.
-
-**`_run_supplemental()` - Accept and use resolved handle:**
-
-```python
-def _run_supplemental(
- topic, reddit_items, x_items, from_date, to_date,
- depth, x_source, progress=None, skip_reddit=False,
- resolved_handle=None, # NEW
-):
- # Extract entities from Phase 1 (existing)
- entities = entity_extract.extract_entities(...)
-
- # Add resolved handle if not already in entity list
- if resolved_handle and resolved_handle.lower() not in {h.lower() for h in entities["x_handles"]}:
- # Search resolved handle separately - unfiltered (no topic keywords)
- # This is the key difference from entity-extracted handles
- resolved_future = executor.submit(
- bird_x.search_handles,
- [resolved_handle],
- None, # topic=None means unfiltered search
- from_date,
- count_per=10,
- )
-```
-
-**`bird_x.search_handles()` - Optional topic parameter:**
-
-```python
-def search_handles(handles, topic, from_date, count_per=5):
- # topic is now Optional[str]
- for handle in handles:
- handle = handle.lstrip("@")
- if topic:
- core_topic = _extract_core_subject(topic)
- query = f"from:{handle} {core_topic} since:{from_date}"
- else:
- # Unfiltered: get all recent posts from this handle
- query = f"from:{handle} since:{from_date}"
-```
-
-### Why no topic filter for resolved handles
-
-This is the key insight. When you resolve that @DorBrothers IS the Dor Brothers, you want ALL their recent posts - not just ones that literally contain "Dor Brothers." Their post about the Logan Paul collab says "our new AI film with @LoganPaul" - no mention of "Dor Brothers" anywhere. With topic filtering, you'd miss it. Without it, you get their full recent activity, which is exactly what the user wants.
-
-## Technical Considerations
-
-### Handle resolution requires Bird or xAI for Phase 2
-
-The `--x-handle` is only useful if the script can search `from:{handle}`. Currently:
-- **Bird:** supports `from:handle` via Twitter GraphQL (free)
-- **xAI:** does NOT support `from:handle` (Grok semantic search only)
-
-If the user only has xAI (no Bird), the resolved handle can't be searched in Phase 2. Options:
-1. Skip Phase 2 handle search when `x_source == "xai"` (current behavior for entity handles too)
-2. Future: Add handle search to xai_x.py using `allowed_x_handles` filter
-
-For v1, accept this limitation. Bird is free and most X-enabled users have it.
-
-### Relevance scoring for unfiltered handle posts
-
-Bird-parsed items default to `relevance: 0.7`. Unfiltered resolved-handle posts have no topic-keyword signal. Set `relevance: 0.5` for these so engagement and recency drive ranking, preventing off-topic viral posts from the entity from outranking genuinely relevant Phase 1 results.
-
-### Stats block display
-
-When `--x-handle` is used and produces results, show it in the stats:
-
-```
-├─ 🔵 X: 38 posts │ 782+ likes │ 36+ reposts │ via @TheDorBrothers + keyword search
-```
-
-Add `resolved_x_handle` field to `Report` schema for this.
-
-### Skip conditions for the agent
-
-The SKILL.md instruction tells the agent to skip handle resolution when:
-- Topic is clearly not an entity (multi-word generic phrases)
-- Topic already contains @ (user provided the handle)
-- Using `--quick` depth
-- Agent judges it would be wasted effort
-
-The agent's judgment here is a feature, not a bug. Claude is good at deciding "Dor Brothers = probably has an X account" vs "best rap songs 2026 = definitely not an entity."
-
-## Acceptance Criteria
-
-- [x] SKILL.md updated with Step 0.5 handle resolution instructions
-- [x] OpenClaw variant updated with same instructions
-- [x] `last30days.py` accepts `--x-handle` argument
-- [x] `_run_supplemental()` accepts `resolved_handle` parameter
-- [x] `bird_x.search_handles()` accepts `topic=None` for unfiltered search
-- [x] Resolved handle searched with `from:{handle}` (no topic filter) when Bird is available
-- [x] `Report` schema includes `resolved_x_handle` field
-- [x] Stats block shows resolved handle when used
-- [x] Graceful when `--x-handle` is provided but Bird is not available (skips silently)
-- [ ] "Dor Brothers" search resolves handle and finds their direct posts (requires live test)
-- [ ] "Jason Calacanis" search resolves @jason (requires live test)
-- [ ] "best rap songs 2026" does NOT trigger handle resolution (agent judgment, not code)
-
-## Test Plan
-
-### Manual test cases
-
-| # | Query | Expected | What it tests |
-|---|-------|----------|---------------|
-| 1 | "Dor Brothers" | Agent resolves handle, passes --x-handle, script finds their posts | Full happy path |
-| 2 | "Jason Calacanis" | Agent resolves @jason (non-obvious handle) | Handle != name |
-| 3 | "best rap songs 2026" | Agent skips handle resolution entirely | Non-entity detection |
-| 4 | "OpenAI" | Agent may or may not resolve (judgment call) | Generic entity edge case |
-| 5 | "Linus Ekenstam" | Agent resolves @LinusEkenstam | Person with matching handle |
-| 6 | "Dor Brothers" with `--quick` | No handle resolution | Skip condition |
-| 7 | "Dor Brothers" with xAI only (no Bird) | Handle resolved but Phase 2 skips it | Graceful degradation |
-
-### Automated tests (`tests/`)
-
-```python
-# test_bird_x.py - new tests
-def test_search_handles_unfiltered_mode():
- """bird_x.search_handles(topic=None) omits topic keywords from query."""
-
-def test_search_handles_with_topic():
- """bird_x.search_handles(topic="AI films") includes topic in query (existing behavior)."""
-
-# test_last30days.py - integration
-def test_x_handle_arg_parsed():
- """--x-handle=TheDorBrothers is parsed and passed to _run_supplemental."""
-
-def test_resolved_handle_dedup_with_entity_extract():
- """Resolved handle already in entity list is not double-searched."""
-
-def test_resolved_handle_skipped_when_xai_only():
- """When x_source='xai', resolved handle is not searched (no from: support)."""
-
-def test_resolved_handle_relevance_set_lower():
- """Items from resolved handle search get relevance 0.5, not 0.7."""
-```
-
-### E2E validation
-
-```bash
-# Run with explicit handle to test Python-side changes
-python3 scripts/last30days.py "Dor Brothers" --x-handle=TheDorBrothers --emit=compact 2>&1 | grep -E "\[Phase|handle"
-
-# Expected:
-# [Phase 2] Drilling into @TheDorBrothers (resolved) + @handle1, @handle2 (extracted)
-# [Phase 2] +0 Reddit, +8 X (5 from resolved handle)
-```
-
-```bash
-# Full agent test (runs SKILL.md flow including handle resolution WebSearch)
-claude --print "/last30days Dor Brothers" 2>&1 | grep -i "handle\|x-handle\|resolved"
-```
-
-## Files Modified
-
-| File | Change |
-|---|---|
-| `SKILL.md` | Add Step 0.5: handle resolution via WebSearch |
-| `variants/open/references/research.md` | Same handle resolution instructions for OpenClaw |
-| `scripts/last30days.py` | Add `--x-handle` CLI arg, pass to `_run_supplemental()` |
-| `scripts/lib/bird_x.py` | `search_handles()` gets `topic: Optional[str]` param |
-| `scripts/lib/schema.py` | Add `resolved_x_handle: Optional[str]` to `Report` |
-| `scripts/lib/render.py` | Show resolved handle in stats block |
-| `tests/test_bird_x.py` | Tests for unfiltered search_handles mode |
-| `tests/test_last30days.py` | Tests for --x-handle arg handling |
-
-**NOT modified:** No new `handle_resolve.py` module. Resolution is agent intelligence, not Python code.
-
-## Dependencies & Risks
-
-- **Bird availability:** Resolved handle can only be searched when Bird is the X source. xAI doesn't support `from:handle` queries. ~70% of X-enabled users have Bird (free, browser cookies).
-- **Agent judgment:** The agent decides whether a topic is an entity worth resolving. Claude is good at this, but it's non-deterministic. Some edge cases will be missed. This is acceptable - the feature is additive (no regression when it doesn't fire).
-- **WebSearch latency:** Adds ~5-8 seconds before the script starts. Negligible against 2-3 minute script runtime.
-- **Handle accuracy:** The agent could resolve to the wrong handle. Mitigated by the agent's ability to evaluate results (unlike a regex, Claude can tell if a result actually belongs to the queried entity).
-
-## Sources
-
-- Existing entity extraction: `scripts/lib/entity_extract.py`
-- Phase 2 supplemental search: `scripts/last30days.py:387-513`
-- Bird search handles: `scripts/lib/bird_x.py:273-346`
-- SKILL.md agent flow: `SKILL.md:82-120`
-- OpenClaw variant: `variants/open/SKILL.md`
-- Smart supplemental search plan: `docs/plans/2026-02-07-feat-smart-supplemental-search-plan.md`
diff --git a/docs/plans/2026-02-25-feat-youtube-relevance-and-cross-source-linking-plan.md b/docs/plans/2026-02-25-feat-youtube-relevance-and-cross-source-linking-plan.md
deleted file mode 100644
index 461705a..0000000
--- a/docs/plans/2026-02-25-feat-youtube-relevance-and-cross-source-linking-plan.md
+++ /dev/null
@@ -1,226 +0,0 @@
----
-title: "feat: YouTube relevance scoring and cross-source linking"
-type: feat
-status: active
-date: 2026-02-25
----
-
-# feat: YouTube Relevance Scoring and Cross-Source Linking
-
-## Overview
-
-Two output quality improvements for the last30days monthiversary:
-
-1. **YouTube relevance scoring** - Replace the hardcoded `relevance: 0.7` with real token-overlap scoring so relevant niche videos beat viral off-topic ones.
-2. **Cross-source linking** - When the same story appears on Reddit + HN + X, annotate items with `[xref: R3, HN5]` so Claude can synthesize cross-platform discussion.
-
-## Problem Statement / Motivation
-
-**YouTube scoring is broken.** Every YouTube video starts with `relevance: 0.7` (70/100 subscore). Since the scoring formula is `45% relevance + 25% recency + 30% engagement`, all YouTube items share the same 31.5pt relevance floor. Ranking is purely engagement-driven - a viral off-topic video beats a niche relevant one.
-
-**Cross-source coverage is invisible.** The same story often appears across Reddit, HN, and X (e.g., a product launch). Currently each source is deduped independently, but there's no signal telling Claude "these items are about the same thing." Claude has to manually notice the overlap, and often doesn't.
-
-## Technical Approach
-
-### Feature 1: YouTube Relevance Scoring
-
-**Where:** `youtube_yt.py` (compute), `normalize.py` (pass through - already works)
-
-**Algorithm:** Token ratio overlap between `core_topic` and video title.
-
-```python
-# youtube_yt.py - new function
-STOPWORDS = {'the', 'a', 'an', 'to', 'for', 'how', 'is', 'in', 'of', 'on',
- 'and', 'with', 'from', 'by', 'at', 'this', 'that', 'it', 'my',
- 'your', 'i', 'me', 'we', 'you', 'what', 'are', 'do', 'can'}
-
-def _compute_relevance(query: str, title: str) -> float:
- """Compute relevance as ratio of query tokens found in title.
-
- Uses ratio overlap (intersection / query_length) so short queries
- score higher when fully represented in the title. Floors at 0.1.
- """
- q_tokens = {w for w in query.lower().split() if w not in STOPWORDS and len(w) > 1}
- t_tokens = {w for w in re.sub(r'[^\w\s]', ' ', title.lower()).split()
- if w not in STOPWORDS and len(w) > 1}
-
- if not q_tokens:
- return 0.5 # Neutral fallback
-
- overlap = len(q_tokens & t_tokens)
- ratio = overlap / len(q_tokens)
- return max(0.1, min(1.0, ratio))
-```
-
-**Key decisions:**
-- **Use `core_topic`** (already stripped of noise words by `_extract_core_subject()`) - not the raw verbose query
-- **Ratio overlap** (intersection/query_length), not strict Jaccard - so "Claude Code" (2 tokens) vs "Claude Code Tutorial" (3 tokens) = 2/2 = 1.0, not 2/3 = 0.67
-- **Stopword removal** on both sides - prevents "How To Use Claude Code For Beginners" from diluting the match
-- **Floor at 0.1** - even zero-match videos get `rel_score=10` not 0, consistent with other sources' defaults
-- **No LLM call** - this is pure string matching, zero latency/cost
-
-**Files to modify:**
-
-#### `scripts/lib/youtube_yt.py`
-
-- [x] Add `STOPWORDS` set and `_compute_relevance(query, title)` function
-- [x] In `search_youtube()`, replace `"relevance": 0.7` (line 186) with `"relevance": _compute_relevance(core_topic, video.get("title", ""))`
-- [x] Update `"why_relevant"` to be more descriptive: `f"YouTube: {title_excerpt}"`
-
-#### `scripts/lib/normalize.py`
-
-- No changes needed - already passes through `item.get("relevance", 0.7)` at line 196
-
-#### `scripts/lib/score.py`
-
-- No changes needed - `score_youtube_items()` already reads `item.relevance` correctly
-
-### Feature 2: Cross-Source Linking
-
-**Where:** `dedupe.py` (new function), `schema.py` (new field), `render.py` (display), `last30days.py` (call site)
-
-**Algorithm:** Reuse existing Jaccard char-trigram similarity from `dedupe.py`. Compare items across sources at threshold 0.5 (lower than within-source 0.7 since titles differ more across platforms). Bidirectional - both items get the cross-ref.
-
-**Key decisions:**
-- **Link, don't merge** - keep items separate with `cross_refs: ["R3", "HN5"]`. Let Claude handle the narrative.
-- **Bidirectional** - if R3 links to HN5, HN5 also links to R3. Claude reads source-by-source and needs to see the link from either direction.
-- **Threshold 0.5** for cross-source (vs 0.7 within-source). Lower because the same story has different titles across platforms. Still high enough to avoid false positives.
-- **Truncate X text to 100 chars** for comparison - full tweets dilute Jaccard against short Reddit/HN titles
-- **Just IDs in cross_refs** - no similarity scores (noise for Claude's synthesis)
-- **Include WebSearchItem** - add to `get_item_text()` using `item.title`
-- **Don't persist to SQLite** - cross_refs are cheap to recompute, not worth a schema migration
-
-**Performance:** With 45 items total (typical) and 10 source pairs, ~1000 char-trigram comparisons. Sub-millisecond. Even --deep mode (~150 items) is <12K comparisons - trivial.
-
-**Files to modify:**
-
-#### `scripts/lib/schema.py`
-
-- [x] Add `cross_refs: List[str] = field(default_factory=list)` to all 5 item types (RedditItem, XItem, YouTubeItem, HackerNewsItem, WebSearchItem)
-- [x] Update `to_dict()` on each item type to include `cross_refs` (only when non-empty)
-- [x] Update `Report.from_dict()` to deserialize `cross_refs` for each item type
-
-#### `scripts/lib/dedupe.py`
-
-- [x] Update `get_item_text()` type hints to include `WebSearchItem` and handle it (use `item.title`)
-- [x] Add `get_cross_source_text()` function - same as `get_item_text()` but truncates X text to 100 chars
-- [x] Add `cross_source_link()` function:
-
-```python
-def cross_source_link(
- *source_lists: List[Union[schema.RedditItem, schema.XItem, schema.YouTubeItem,
- schema.HackerNewsItem, schema.WebSearchItem]],
- threshold: float = 0.5,
-) -> None:
- """Annotate items with cross-source references.
-
- Compares items across different source types. When similarity exceeds
- threshold, adds bidirectional cross_refs with the related item's ID.
- Modifies items in-place.
- """
- all_items = []
- for source_list in source_lists:
- all_items.extend(source_list)
-
- if len(all_items) <= 1:
- return
-
- # Pre-compute trigrams using cross-source text extraction
- ngrams = [get_ngrams(get_cross_source_text(item)) for item in all_items]
-
- for i in range(len(all_items)):
- for j in range(i + 1, len(all_items)):
- # Skip same-source comparisons (already handled by per-source dedupe)
- if type(all_items[i]) == type(all_items[j]):
- continue
-
- similarity = jaccard_similarity(ngrams[i], ngrams[j])
- if similarity >= threshold:
- # Bidirectional cross-reference
- if all_items[j].id not in all_items[i].cross_refs:
- all_items[i].cross_refs.append(all_items[j].id)
- if all_items[i].id not in all_items[j].cross_refs:
- all_items[j].cross_refs.append(all_items[i].id)
-```
-
-#### `scripts/last30days.py`
-
-- [x] After the dedupe step (after line ~1107), call `dedupe.cross_source_link()`:
-
-```python
-# Cross-source linking
-dedupe.cross_source_link(
- deduped_reddit, deduped_x, deduped_youtube, deduped_hn, deduped_web,
-)
-```
-
-#### `scripts/lib/render.py`
-
-- [x] In `render_compact()`, append `[xref: ...]` to items that have cross_refs:
-
-```python
-# After the existing item line
-if hasattr(item, 'cross_refs') and item.cross_refs:
- xref_str = ', '.join(item.cross_refs)
- line += f" [xref: {xref_str}]"
-```
-
-#### `SKILL.md`
-
-- [x] Add a note in the synthesis instructions about cross-refs:
- "Items tagged `[xref: ...]` reference the same story on another platform. Use these to triangulate: 'This was widely discussed - Reddit thread (R3) with 142 comments, HN discussion (HN5) with 89 points, and several X posts (X12).'"
-
-### Tests
-
-#### `tests/test_youtube_relevance.py` (new)
-
-- [x] Test exact match: query "Claude Code" vs title "Claude Code" = 1.0
-- [x] Test partial match: query "Claude Code" vs title "Claude Code Tutorial" = 1.0 (ratio)
-- [x] Test low match: query "Claude Code" vs title "Python Web Scraping" = 0.1 (floor)
-- [x] Test empty query: returns 0.5
-- [x] Test empty title: returns 0.1
-- [x] Test stopword handling: query "how to use Claude" vs title "Using Claude" = high match
-- [x] Test integration: search_youtube returns varied relevance scores (mock yt-dlp)
-
-#### `tests/test_cross_source.py` (new)
-
-- [x] Test no cross-refs: unrelated items across sources
-- [x] Test bidirectional: matching Reddit + HN items both get cross_refs
-- [x] Test multi-source: same story on 3+ sources
-- [x] Test same-source skip: items from same source type are not cross-linked
-- [x] Test X text truncation: long tweet vs short Reddit title
-- [x] Test empty lists: no crash on empty source lists
-- [x] Test schema round-trip: cross_refs survive to_dict() / from_dict()
-
-## Acceptance Criteria
-
-- [x] YouTube items have varied relevance scores (not all 0.7)
-- [x] A video with title matching the query scores higher than one without
-- [x] Cross-source items about the same story have `cross_refs` pointing to each other
-- [x] Cross-refs are bidirectional
-- [x] Compact output shows `[xref: ...]` tags on linked items
-- [x] SKILL.md tells Claude how to use cross-refs in synthesis
-- [x] All existing tests still pass
-- [x] New tests cover YouTube relevance edge cases
-- [x] New tests cover cross-source linking edge cases
-- [x] Run sync.sh to deploy after changes
-
-## Dependencies & Risks
-
-**Low risk:**
-- Both features add fields/functions without changing existing logic
-- YouTube relevance is a drop-in replacement for a hardcoded value
-- Cross-source linking only adds metadata (cross_refs) without modifying item order or scores
-- All existing tests should pass unchanged
-
-**Edge cases:**
-- CJK/non-English titles: token splitting works but relevance may be less accurate. Acceptable for v1.
-- Very short queries (1 token): ratio overlap = 0 or 1. The 0.1 floor handles the 0 case.
-- Clickbait YouTube titles with no query overlap: these correctly get low relevance now (improvement over blindly getting 0.7)
-
-## Sources & References
-
-- YouTube relevance: Currently hardcoded at `youtube_yt.py:186`
-- Dedupe infrastructure: `dedupe.py` - Jaccard similarity on char trigrams
-- Score weights: `score.py:8-10` - 45% relevance + 25% recency + 30% engagement
-- Existing HN plan as template: `docs/plans/2026-02-24-fix-hn-ordering-and-emoji-plan.md`
diff --git a/docs/plans/2026-02-26-feat-polymarket-smarter-synthesis-plan.md b/docs/plans/2026-02-26-feat-polymarket-smarter-synthesis-plan.md
deleted file mode 100644
index c672242..0000000
--- a/docs/plans/2026-02-26-feat-polymarket-smarter-synthesis-plan.md
+++ /dev/null
@@ -1,185 +0,0 @@
----
-title: "feat: Smarter Polymarket synthesis - surface the most interesting markets"
-type: feat
-status: completed
-date: 2026-02-26
----
-
-# feat: Smarter Polymarket Synthesis
-
-## Overview
-
-Polymarket data is one of the most powerful signals when relevant - real money on outcomes cuts through opinion. But the current system buries the most interesting markets and gives the LLM zero guidance on how to synthesize prediction market data.
-
-For "Arizona Basketball", the skill found 2 markets but only highlighted "Big 12 title: 68%" in the stats and synthesis. The user cares MORE about:
-- NCAA Tournament championship odds (12%, up 3%)
-- #1 seed odds (85%)
-- Next game: Arizona vs Kansas (71% to win)
-
-For "Iran War", the skill found 9 markets with $559M volume but only highlighted "strikes by Feb 28: 10% (down from 65%)". The user wanted regime change / Khamenei odds - the "bigger picture" structural question.
-
-The problem has two layers: (1) the Python scoring penalizes multi-outcome markets where the topic is an outcome, and (2) the SKILL.md gives the LLM zero instructions for interpreting or highlighting prediction market data.
-
-## Problem Statement
-
-### Layer 1: Scoring penalizes the most interesting markets
-
-`_compute_text_similarity()` (polymarket.py:236) only compares the search topic against the **event title**. It never checks outcome names.
-
-Example: Market titled "Who will be the #1 overall seed in the 2026 NCAA Tournament?" with outcomes ["Arizona", "Duke", "Houston", "Auburn"]:
-- Topic: "Arizona Basketball"
-- text_score: **0.0** (neither "arizona" nor "basketball" in event title)
-- 30% relevance penalty from this alone
-
-This means the most contextually interesting markets (seeding, championship, matchup odds) get pushed below less interesting but title-matching markets (Big 12 regular season).
-
-### Layer 2: SKILL.md has zero Polymarket synthesis guidance
-
-The SKILL.md Judge Agent section tells the LLM how to weight Reddit (higher), YouTube (high), WebSearch (lower), but says **nothing** about:
-- How to interpret prediction market probabilities
-- Which markets are "most interesting" (championship > regular season)
-- When to lead with prediction market odds vs other sources
-- How to connect a specific outcome in a multi-outcome market to the user's topic
-- How to use odds as a signal alongside social media sentiment
-
-### Layer 3: Stats box loses information
-
-The Polymarket stats line only has room for 1-2 market highlights. When there are 5+ relevant markets, the user misses the most interesting ones.
-
-## Proposed Solution
-
-### 1. Outcome-aware text similarity scoring
-
-Update `_compute_text_similarity()` to check if the topic appears in any outcome name, with **bidirectional** substring matching. The check must work in both directions since the topic ("Arizona Basketball") is longer than the outcome name ("Arizona").
-
-Collect outcome names from ALL active markets in the event (not just top market), since Gamma API can structure multi-outcome events as separate binary sub-markets.
-
-Only match outcomes with probability > 1% to avoid noise from near-zero outcomes.
-
-```python
-def _compute_text_similarity(topic: str, title: str, outcomes: list = None) -> float:
- core = _extract_core_subject(topic).lower()
- title_lower = title.lower()
- if not core:
- return 0.5
-
- # Full substring match in title
- if core in title_lower:
- return 1.0
-
- # Check if topic appears in any outcome name (bidirectional)
- if outcomes:
- core_tokens = set(core.split()) # Hoist outside loop
- best_outcome_score = 0.0
- for outcome_name in outcomes:
- outcome_lower = outcome_name.lower()
- # Bidirectional substring: "arizona" in "arizona basketball" OR "arizona basketball" in "arizona wildcats game"
- if core in outcome_lower or outcome_lower in core:
- best_outcome_score = max(best_outcome_score, 0.85)
- elif core_tokens & set(outcome_lower.split()):
- best_outcome_score = max(best_outcome_score, 0.7)
- if best_outcome_score > 0:
- return best_outcome_score
-
- # Token overlap fallback against title
- topic_tokens = set(core.split())
- title_tokens = set(title_lower.split())
- if not topic_tokens:
- return 0.5
- overlap = len(topic_tokens & title_tokens)
- return overlap / len(topic_tokens)
-```
-
-### 2. Surface the topic-matching outcome in display
-
-When the topic matches an outcome name, reorder `outcome_prices` to put the matching outcome first before truncating to top 3. This ensures the LLM sees the user-relevant odds.
-
-### 3. Add SKILL.md synthesis instructions for Polymarket
-
-Add a dedicated section telling the LLM:
-- **Prediction markets are high-signal when relevant.** Real money on outcomes > opinions.
-- **Prefer markets that answer structural/long-term questions** (championships, regime changes, major milestones) over near-term deadline markets (weekly matchups, short-term event deadlines). When in doubt, the bigger question is more interesting.
-- **When the topic is an outcome in a multi-outcome market, call out that specific outcome's odds and movement.** Don't just say "Polymarket has a #1 seed market" - say "Arizona has 85% chance of a #1 seed, up from 72%."
-- **Weave odds into the "What I learned" narrative as supporting evidence.** "Final Four buzz is building - Polymarket gives Arizona a 12% chance to win the championship (up 3% this week), and 85% to earn a #1 seed."
-- **Citation format:** "Polymarket has Arizona at 85% for a #1 seed (up from 72%)" - include the specific odds and movement, not just "per Polymarket."
-- **Stats box:** Show up to 5 most relevant markets with odds. If more exist, show count.
-
-Domain examples:
-- Sports: championship/tournament odds > regular season title > weekly matchup
-- Geopolitics: regime change > near-term strike deadline > sanctions
-- Tech: major milestones (IPO, product launch) > incremental updates
-- Elections: presidency > primary > individual state
-
-### 4. Improve stats box template
-
-Show up to 5 markets with odds, capped for readability:
-```
-├─ 📊 Polymarket: 5 markets (Championship: 12%, #1 Seed: 85%, Big 12: 68%, vs Kansas: 71%, NCAA: 12%)
-```
-
-### 5. Fix render.py volume label
-
-The render module labels volume as "vol24h" even when `volume1mo` is the actual data source. Fix to "vol/mo" when monthly volume is used.
-
-## Technical Approach
-
-### Implementation Plan
-
-#### Phase 1: Fix text similarity to check outcomes
-
-- [x] `scripts/lib/polymarket.py` - Update `_compute_text_similarity()` to accept optional `outcomes` parameter with bidirectional substring matching and token overlap
-- [x] `scripts/lib/polymarket.py` - In `parse_polymarket_response()`, collect outcome names from ALL active markets (not just top market), filter to outcomes with price > 1%, and pass to `_compute_text_similarity()`
-- [x] `scripts/lib/polymarket.py` - Reorder `outcome_prices` to surface the topic-matching outcome first before truncating to top 3
-- [x] `fixtures/polymarket_sample.json` - Add fixture event: "Who will be the #1 overall seed in the 2026 NCAA Tournament?" with outcomes ["Arizona", "Duke", "Houston", "Auburn"] where "Arizona" is NOT in the title
-- [x] `tests/test_polymarket.py` - Add tests for outcome-aware text similarity: bidirectional substring, token overlap, no-match, low-probability filtering
-- [x] `tests/test_polymarket.py` - Add test: multi-outcome market where topic is an outcome should rank higher than tangential title-match markets
-- [x] `tests/test_polymarket.py` - Add test: topic-matching outcome is surfaced to front of outcome_prices display
-
-#### Phase 2: Add SKILL.md Polymarket synthesis instructions
-
-- [x] `SKILL.md` - Add "Prediction Markets" subsection to the Judge Agent section with:
- - General heuristic: prefer structural/long-term markets over near-term deadlines
- - Domain examples (sports, geopolitics, tech, elections)
- - Citation format with specific odds and movement
- - Instruction to weave odds into "What I learned" narrative
-- [x] `SKILL.md` - Add Polymarket to citation priority list (between HN and Web) with format guidance
-- [x] `SKILL.md` - Update stats box template: show up to 5 markets with odds
-- [x] `variants/open/references/research.md` - Add condensed Polymarket synthesis guidance matching the open variant's style
-- [x] `scripts/lib/render.py` - Fix "vol24h" label to "vol/mo" when volume1mo is the data source (changed to "volume")
-
-#### Phase 3: Tests and verification
-
-- [x] Run full test suite (229 passed, 5 pre-existing failures unrelated to this change)
-- [ ] Manual test: `/last30days "Arizona Basketball"` - verify championship, seed, and matchup odds appear in synthesis
-- [ ] Manual test: `/last30days "Iran War"` - verify regime change and structural outcome markets appear
-- [x] Run `bash scripts/sync.sh` to deploy
-
-## Acceptance Criteria
-
-- [x] Multi-outcome markets where the topic is an outcome (e.g. "Arizona" in a seeding market) get text_score >= 0.7, not 0.0
-- [x] Bidirectional matching works: "Arizona" as outcome matches topic "Arizona Basketball" (outcome_name in core)
-- [x] Topic-matching outcome is surfaced to front of outcome_prices display (not hidden in "and N more")
-- [x] Low-probability outcomes (< 1%) don't trigger outcome matching
-- [x] SKILL.md instructs the LLM to highlight structural/long-term markets over near-term ones
-- [x] SKILL.md provides citation format: "Polymarket has X at Y% (up/down Z%)"
-- [x] Stats box shows up to 5 markets with odds
-- [x] HN zero-result line is hidden (already fixed - verify on next run)
-- [x] All existing tests pass + new outcome-aware similarity tests pass
-- [x] render.py volume label is accurate
-
-## Dependencies & Risks
-
-**No blockers.** This is scoring improvements + SKILL.md instruction changes within the existing Polymarket module.
-
-**Risk: Outcome name matching false positives.** A market "Will Arizona pass AI regulation?" would match "Arizona Basketball" on the word "Arizona" even though it's about the state, not the team. Mitigation: outcome match gets 0.7-0.85 (not 1.0), and volume/liquidity/movement signals still differentiate. A false positive at 0.85 text_score won't outrank a true title match at 1.0.
-
-**Risk: Common-word false positives.** Words like "war," "AI," "US" could match generic outcomes. Mitigation: at 0.7 text_score (30% weight = 0.21 relevance), this is a small boost that won't override strong volume/liquidity signals from actually relevant markets. Monitor in testing.
-
-**Risk: LLM still ignores synthesis instructions.** Mitigation: use CRITICAL formatting, specific do/don't examples, and concrete citation format.
-
-## Sources & References
-
-- Current text similarity: `scripts/lib/polymarket.py:236`
-- Render format: `scripts/lib/render.py:282`
-- SKILL.md synthesis: `SKILL.md:196` (Judge Agent section)
-- Previous quality ranking plan: `docs/plans/2026-02-25-feat-polymarket-quality-ranking-plan.md`
diff --git a/docs/plans/2026-02-26-fix-polymarket-query-expansion-data-availability-plan.md b/docs/plans/2026-02-26-fix-polymarket-query-expansion-data-availability-plan.md
deleted file mode 100644
index e869b68..0000000
--- a/docs/plans/2026-02-26-fix-polymarket-query-expansion-data-availability-plan.md
+++ /dev/null
@@ -1,235 +0,0 @@
----
-title: "fix: Polymarket query expansion - find markets where topic is an outcome, not just a title"
-type: fix
-status: active
-date: 2026-02-26
----
-
-# fix: Polymarket Query Expansion & Data Availability
-
-## Overview
-
-The Polymarket module misses the most interesting markets when the search topic is an **outcome** in a broader market rather than appearing in the event title. For "Arizona Basketball", the NCAA Tournament Winner (30 open markets) and #1 Seed (20 open markets) are invisible because "Arizona" only appears as an outcome, never in the event title. The Gamma API only searches titles/slugs.
-
-The outcome-aware scoring (from the prior plan) works perfectly on fixture data - it correctly ranks markets where Arizona is an outcome. The problem is upstream: those markets never reach the scoring layer because the Gamma API never returns them.
-
-## Problem Statement
-
-### Root Cause: Gamma API is title/slug search only
-
-`gamma-api.polymarket.com/public-search?q=X` only matches against event titles and slugs. It does NOT search outcome names, market descriptions, or tags.
-
-**Live API verification** (2026-02-26):
-
-| Query | Events returned | Arizona-relevant open markets |
-|-------|----------------|-------------------------------|
-| "Arizona Basketball" | 5 | 2 (Big 12 Champion, NAU vs Idaho) |
-| "Arizona" | 5 | 0 (all political/closed) |
-| "NCAA Tournament" | 5 | 2 (Tournament Winner: 30 open, #1 Seed: 20 open) |
-| "Basketball" | 5 | 5 (conference champions, no NCAA) |
-| "college basketball" | 5 | 1 (#1 seed) |
-
-The championship and seeding markets that the user wants (`NCAA Tournament Winner`, `#1 Seed`) are only findable by searching "NCAA Tournament" or "NCAA" - terms that don't appear in "Arizona Basketball".
-
-### Contributing Factor 1: Query expansion too narrow
-
-`_expand_queries("Arizona Basketball")` generates only `["Arizona Basketball", "Arizona"]`.
-
-It only tries the **first word** as a standalone query. The second word "Basketball" is never searched independently. This means conference-adjacent and tournament markets are invisible.
-
-### Contributing Factor 2: No domain bridging
-
-Even searching "Basketball" (all individual words) only returns conference champions. The leap from "Basketball" to "NCAA Tournament" requires discovering the domain context from initial results. Currently there is no second-pass expansion.
-
-### Contributing Factor 3: Shallow default depth
-
-`DEPTH_CONFIG["default"] = 2` pages (10 events per query). With 3 queries that's 30 raw events, but heavy dedup and closed-event filtering reduces this to 2-5 usable results.
-
-### What works (don't break it)
-
-- "Iran War" returned 9 perfect markets because "Iran" and "War" appear directly in event titles
-- Outcome-aware scoring correctly ranks Arizona-outcome markets when they reach the scoring layer
-- Topic-matching outcome reordering surfaces the right outcome first
-
-## Proposed Solution
-
-Three changes, all generic (no hardcoded domain knowledge):
-
-### 1. Search ALL individual words, not just the first
-
-Currently `_expand_queries()` only adds `words[0]` as a standalone query. Change to add **every word** as a standalone query, then dedupe.
-
-For "Arizona Basketball": `["Arizona Basketball", "Arizona", "Basketball"]`
-For "Iran War": `["Iran War", "Iran", "War"]` (same as before since both are short)
-For "AI video generation tools": `["AI video generation tools", "AI", "video", "generation", "tools"]` (capped at 6)
-
-Raise query cap from 4 to 6 to accommodate.
-
-```python
-def _expand_queries(topic: str) -> List[str]:
- core = _extract_core_subject(topic)
- queries = [core]
-
- words = core.split()
- if len(words) >= 2:
- # Add ALL individual words (not just first)
- for word in words:
- if len(word) > 2: # skip very short words ("AI", "vs")
- queries.append(word)
-
- if topic.lower().strip() != core.lower():
- queries.append(topic.strip())
-
- # Dedupe, cap at 6
- seen = set()
- unique = []
- for q in queries:
- q_lower = q.lower().strip()
- if q_lower and q_lower not in seen:
- seen.add(q_lower)
- unique.append(q.strip())
- return unique[:6]
-```
-
-Note: "AI" is only 2 chars but is meaningful. Lower the threshold to `len(word) > 1` to catch it. Single-char words (rare) get filtered.
-
-### 2. Second-pass context expansion from first-pass results
-
-After the first-pass search, extract domain-indicator terms from event titles and run a focused second-pass search.
-
-Algorithm:
-1. Collect ALL event titles from first-pass results (including closed events)
-2. Tokenize titles into bigrams (two-word sequences)
-3. Count bigrams across events, filter out bigrams containing topic words
-4. Take the top 1-2 most frequent non-topic bigrams as "domain indicators"
-5. Search each domain indicator (1 page each)
-6. Merge with first-pass results, dedupe, re-rank
-
-**Example for "Arizona Basketball":**
-
-First-pass titles include:
-- "Big 12 Men's College Basketball 2025-2026 Regular Season Champion"
-- "SEC Men's College Basketball 2025-2026 Regular Season Champion"
-- "ACC Men's College Basketball 2025-2026 Regular Season Champion"
-- "Big East Men's College Basketball 2025-2026 Regular Season Champion"
-
-Frequent bigrams (excluding topic words): "college basketball" (4x), "regular season" (4x), "season champion" (4x)
-
-Top domain indicator: **"college basketball"**
-
-Searching "college basketball" returns: **"#1 seed in NCAA Tournament"** (20 open markets with Arizona as outcome!)
-
-**Example for "Iran War":** First-pass already finds everything via title matches. Second-pass bigrams would be things like "iran strikes", "khamenei out" - searching these finds the same events (deduped). No regression.
-
-```python
-def _extract_domain_queries(topic: str, events: List[Dict]) -> List[str]:
- """Extract domain-indicator search terms from first-pass event titles."""
- topic_words = set(_extract_core_subject(topic).lower().split())
-
- # Collect bigrams from all event titles
- bigram_counts = {}
- for event in events:
- title = event.get("title", "").lower()
- words = re.findall(r'[a-z]+', title)
- for i in range(len(words) - 1):
- bigram = f"{words[i]} {words[i+1]}"
- # Skip if both words are in topic (we already search the topic)
- if words[i] in topic_words and words[i+1] in topic_words:
- continue
- # Skip very common filler
- if any(w in ("the", "of", "in", "to", "a", "and", "vs", "will", "be") for w in (words[i], words[i+1])):
- continue
- bigram_counts[bigram] = bigram_counts.get(bigram, 0) + 1
-
- # Return bigrams appearing in 2+ event titles
- domain_queries = [bg for bg, count in sorted(bigram_counts.items(), key=lambda x: -x[1]) if count >= 2]
- return domain_queries[:2]
-```
-
-### 3. Increase depth and result caps
-
-```python
-DEPTH_CONFIG = {
- "quick": 1,
- "default": 3, # was 2 (50% more raw results)
- "deep": 4, # was 3
-}
-
-RESULT_CAP = {
- "quick": 5,
- "default": 15, # was 10 (more room for cross-domain markets)
- "deep": 25, # was 20
-}
-```
-
-This gives default searches 3 queries x 3 pages = 45 raw events (up from 2 queries x 2 pages = 20), plus 2 domain-indicator queries x 1 page = 10 more.
-
-## Technical Approach
-
-### Implementation Plan
-
-#### Phase 1: Expand query generation
-
-- [x] `scripts/lib/polymarket.py` - Update `_expand_queries()` to add ALL individual words (len > 1), raise cap from 4 to 6
-- [x] `tests/test_polymarket.py` - Test: `_expand_queries("Arizona Basketball")` returns `["Arizona Basketball", "Arizona", "Basketball"]`
-- [x] `tests/test_polymarket.py` - Test: `_expand_queries("Iran War")` returns `["Iran War", "Iran", "War"]`
-- [x] `tests/test_polymarket.py` - Test: short words excluded, cap at 6
-
-#### Phase 2: Second-pass context expansion (evolved: tags instead of bigrams)
-
-- [x] `scripts/lib/polymarket.py` - Add `_extract_domain_queries()` using event TAGS (not bigrams - tags are more reliable, contain "NCAA CBB" etc.)
-- [x] `scripts/lib/polymarket.py` - Update `search_polymarket()` with `_run_queries_parallel()` helper for two-pass search
-- [x] `scripts/lib/polymarket.py` - Add `_shorten_question()` to extract team names from neg-risk binary market questions
-- [x] `scripts/lib/polymarket.py` - Synthesize outcome_prices from binary sub-market questions (detects Yes/No pattern, not just negRisk flag)
-- [x] `scripts/lib/polymarket.py` - Updated outcome reordering to use token-based matching for long question strings
-- [x] `scripts/lib/polymarket.py` - Also pass market questions to `_compute_text_similarity()` for neg-risk events
-- [x] `tests/test_polymarket.py` - Tests for tag-based domain extraction: frequent tags, generic tag filtering, topic word filtering, min frequency, cap, empty events
-
-#### Phase 3: Increase depth and caps
-
-- [x] `scripts/lib/polymarket.py` - Update `DEPTH_CONFIG`: default 2 -> 3, deep 3 -> 4
-- [x] `scripts/lib/polymarket.py` - Update `RESULT_CAP`: default 10 -> 15, deep 20 -> 25
-
-#### Phase 4: Tests and verification
-
-- [x] Run full test suite (238 passed, 5 pre-existing failures unrelated)
-- [x] `bash scripts/sync.sh` to deploy to CROSS
-- [x] Live API test: "Arizona Basketball" - finds NCAA Tournament Winner (Arizona: 12%), #1 Seed (Arizona: 88%), Big 12 (Arizona: 69%)
-- [x] Live API test: "Iran War" - 15 markets, no regression (domain expansion found "Geopolitics", "Middle East")
-- [ ] Manual test: `/last30daysCROSS "Arizona Basketball"` - end-to-end with LLM synthesis
-- [ ] Manual test: `/last30daysCROSS "Duke Basketball"` - verify NCAA Tournament markets appear for another team
-
-## Acceptance Criteria
-
-- [x] `_expand_queries()` searches ALL individual words, not just the first
-- [x] Query cap raised from 4 to 6
-- [x] Second-pass context expansion discovers domain-indicator terms from first-pass event tags
-- [x] "Arizona Basketball" search finds NCAA Tournament Winner AND #1 Seed markets (via "NCAA" tag domain expansion)
-- [x] "Iran War" search still returns 9+ markets (15 - no regression)
-- [x] All existing tests pass + new query expansion tests pass (91 polymarket tests)
-- [x] No hardcoded domain knowledge (uses event tags, not hardcoded terms)
-- [x] Default depth increased to 3 pages per query
-- [x] Default result cap increased to 15
-- [x] Neg-risk binary markets show team names instead of Yes/No (via question extraction)
-- [x] Topic-matching team surfaced first in outcome display
-
-## Dependencies & Risks
-
-**No blockers.** Changes are internal to `polymarket.py` and don't affect other modules.
-
-**Risk: Second-pass adds latency.** 2 extra queries x 1 page each, at ~200ms per call = ~400ms additional latency. The queries run after first-pass completes (serial), so total Polymarket time goes from ~1.5s to ~2s. Acceptable since other sources (YouTube, X) take 10-30s.
-
-**Risk: Bigram extraction produces noise.** Common title patterns like "regular season" or "2025-2026" could become domain queries. Mitigation: filter filler words, require 2+ title appearances, and cap at 2 queries. A noisy domain query just returns irrelevant events that get scored low by the existing relevance ranker.
-
-**Risk: Individual word queries return unrelated events.** "Basketball" returns European basketball, "War" returns non-Iran conflicts. Mitigation: the outcome-aware scoring already handles this - events without topic-matching outcomes get low text_score (token overlap only) and sort to the bottom.
-
-**Risk: Rate limiting.** Adding 4-6 extra API calls per search. Gamma API allows 350 req/10s, so even aggressive searching stays well under limits.
-
-## Sources & References
-
-- Query expansion: `scripts/lib/polymarket.py:60` (`_expand_queries`)
-- Search orchestration: `scripts/lib/polymarket.py:109` (`search_polymarket`)
-- Event filtering: `scripts/lib/polymarket.py:302-306`
-- Depth config: `scripts/lib/polymarket.py:20-31`
-- Previous outcome-aware scoring plan: `docs/plans/2026-02-26-feat-polymarket-smarter-synthesis-plan.md`
-- Live Gamma API test results from 2026-02-26 (documented above)
diff --git a/docs/plans/2026-03-02-chore-open-pr-triage-plan.md b/docs/plans/2026-03-02-chore-open-pr-triage-plan.md
deleted file mode 100644
index a1631ef..0000000
--- a/docs/plans/2026-03-02-chore-open-pr-triage-plan.md
+++ /dev/null
@@ -1,201 +0,0 @@
----
-title: "chore: open PR triage - build or close decision for all 5 open PRs"
-type: chore
-status: active
-date: 2026-03-02
----
-
-# Open PR Triage
-
-Decision record for each of the 5 open PRs on `mvanhorn/last30days-skill`.
-For each: build/merge, cherry-pick, or close with explanation.
-
----
-
-## PR #26 - Add HN, YouTube, and Product Hunt sources + --search flag
-**Author:** wkbaran | **Size:** +3402/-49 | **Date:** 2026-02-15
-
-### What it adds
-- Hacker News source (Algolia API, no auth)
-- YouTube source (YouTube Data API v3, optional key)
-- Product Hunt source (GraphQL API, optional key)
-- `--search=SOURCES` flag (e.g. `--search=reddit,hn,yt`)
-- 58 new tests
-
-### What's already on main
-- **HN:** ✅ already merged (`scripts/lib/hackernews.py`)
-- **YouTube:** ✅ already merged (`scripts/lib/youtube_yt.py`)
-- **Product Hunt:** ❌ NOT on main
-- **`--search` flag:** ❌ NOT on main
-
-### Decision: CHERRY-PICK (`--search` flag only, skip Product Hunt)
-
-The HN and YouTube modules in this PR are earlier versions than what's on
-main. We should NOT take those. Product Hunt is not a priority source.
-The one missing piece worth landing:
-
-**`--search=SOURCES` flag** - lets callers (including agent mode) specify
-which sources to run. Useful for `--search=hn,polymarket` or
-`--search=reddit,x` focus runs.
-
-### Plan
-- Cherry-pick just `tests/test_search_flag.py` and the `--search` argument
- wiring in `scripts/last30days.py`
-- Skip `scripts/lib/producthunt.py` and all PH-related code entirely
-- Do NOT take their `hackernews.py`, `youtube.py`, `render.py`, `normalize.py`,
- `schema.py` - our versions are newer
-- Update SKILL.md to document `--search` flag
-- Comment on PR thanking wkbaran - his HN/YouTube work was an inspiration
- for the sources we ended up building. Acknowledge that specifically.
-
-### Acceptance Criteria
-- [ ] `python3 scripts/last30days.py "AI coding tools" --search=hn,reddit` runs only HN + Reddit
-- [ ] `python3 scripts/last30days.py "test" --search=x,web` skips Reddit
-- [ ] `test_search_flag.py` passes (adapt to our test patterns, no PH references)
-- [ ] SKILL.md updated with `--search` flag docs
-- [ ] Sync via `bash scripts/sync.sh`
-
----
-
-## PR #24 - Adding Codex compatibility
-**Author:** el-analista | **Size:** +228/-29 | **Date:** 2026-02-11
-
-### What it adds
-- `agents/openai.yaml` Codex discovery metadata
-- Portable script path resolution in SKILL.md (multi-install-path loop)
-- Platform-neutral UI text (`assistant` instead of Claude-specific wording)
-- `LAST30DAYS_CACHE_DIR` env override in `scripts/lib/cache.py`
-- `LAST30DAYS_OUTPUT_DIR` env override in `scripts/lib/render.py`
-- X/Bird query noise stripping + last-chance retry in `scripts/lib/bird_x.py`
-- New tests: `test_bird_x.py`, `test_cache.py`, `test_render.py`
-
-### What's already on main
-- **Codex auth:** ✅ already merged (via PR #38)
-- **Multi-path script resolution:** ✅ already in SKILL.md (the `for dir in` loop)
-- **Platform-neutral wording:** ❌ NOT systematically applied
-- **Cache/output dir overrides:** ❌ NOT on main
-- **Bird X improvements:** ✅ partially (we have other bird_x fixes but may be missing noise stripping + retry)
-
-### Decision: REVIEW AND PARTIAL CHERRY-PICK
-
-The env var overrides (`LAST30DAYS_CACHE_DIR`, `LAST30DAYS_OUTPUT_DIR`) are
-genuinely useful for sandboxed/containerized Codex environments. The bird_x
-noise stripping and last-chance retry may improve X search quality and are
-low-risk additions.
-
-The platform-neutral wording change ("assistant" instead of Claude references)
-should be skipped — SKILL.md is Claude-specific by design.
-
-### Plan
-- Read the diff carefully against our current `cache.py`, `render.py`, `bird_x.py`
-- Cherry-pick the `LAST30DAYS_CACHE_DIR` and `LAST30DAYS_OUTPUT_DIR` env overrides
-- Cherry-pick the bird_x noise stripping + retry logic if it doesn't conflict
- with our existing bird_x changes
-- Skip `agents/openai.yaml` — we have our own multi-path resolution
-- Skip platform-neutral wording changes
-- Comment on PR thanking contributor and explaining what landed
-
-### Acceptance Criteria
-- [ ] `LAST30DAYS_CACHE_DIR=/tmp/test python3 scripts/last30days.py "test" --mock` writes cache to /tmp/test
-- [ ] `LAST30DAYS_OUTPUT_DIR=/tmp/out python3 scripts/last30days.py "test" --mock` writes output to /tmp/out
-- [ ] Existing tests pass after cherry-pick
-- [ ] `test_cache.py` and `test_render.py` adapted and passing
-
----
-
-## PR #14 - Simplify to WebSearch-first, make API keys optional
-**Author:** thangman1 | **Size:** +97/-168 | **Date:** 2026-02-01
-
-### What it does
-Removes the Reddit/X Python search engine as the primary data source.
-Repositions Claude Code's built-in WebSearch as the "default" mode.
-Strips engagement metrics (upvotes, likes, repost counts) from output.
-Removes mode detection logic (Full Mode / Partial Mode / Web-Only Mode).
-
-### Decision: CLOSE - do not merge
-
-This PR inverts the core value proposition of last30days. The skill's
-differentiation is **real engagement data** from Reddit threads and X posts —
-upvotes, likes, reposts — that WebSearch cannot provide. Stripping that out
-produces a worse tool than just asking Claude to search the web, which anyone
-can already do.
-
-The author's intent (lower barrier to entry, no API key required) is valid,
-but the right solution is making HN + Polymarket work without any API key
-(they already do), and making OPENAI_API_KEY easier to obtain — not removing
-the Reddit/X engine.
-
-### Action
-- Comment on PR explaining why we're closing it
-- Acknowledge the valid friction point (API key setup) and point to HN +
- Polymarket as the zero-config sources
-- Close PR
-
----
-
-## PR #10 - OpenRouter API integration
-**Author:** thetechreviewer | **Size:** +1029/-16 | **Date:** 2026-01-28
-
-### What it adds
-OpenRouter as an alternative to OpenAI for the Reddit discovery search.
-Allows using any model available on OpenRouter instead of just OpenAI models.
-
-### What's already on main
-- **`scripts/lib/openrouter_search.py`:** ✅ ALREADY ON MAIN
-- **Wired into `scripts/last30days.py`:** ✅ ALREADY ON MAIN (the `backend == "openrouter"` path)
-
-### Decision: CLOSE - already merged
-
-The OpenRouter integration that this PR introduced is already on main. It
-arrived via internal work that post-dated this PR. The PR is stale.
-
-### Action
-- Comment on PR: "Thanks for this — OpenRouter support is already on main
- (landed via internal work). Closing as incorporated."
-- Close PR
-
----
-
-## PR #5 - Add support for Codex auth with OpenAI Responses API
-**Author:** jblwilliams | **Size:** +358/-66 | **Date:** 2026-01-27
-
-### What it adds
-- JWT-based Codex auth with `chatgpt_account_id`
-- Codex endpoint routing (`https://chatgpt.com/backend-api/codex/responses`)
-- SSE handling for streaming Codex responses
-- Typed auth status/source dataclass
-- Codex fallback model chain
-
-### What's already on main
-**All of this is already on main.** The Codex auth system landed via PR #37
-(iliaal:codex-auth-merged), which in turn came in with PR #38. This PR (#5)
-predates that work and covers the same ground.
-
-### Decision: CLOSE - already incorporated
-
-### Action
-- Comment on PR: "Thanks for this early work on Codex auth! The same feature
- landed on main via PR #37 (from a separate contributor who built on similar
- ideas). The JWT decode, Codex endpoint routing, SSE parsing, and typed auth
- dataclass are all live. Closing as incorporated."
-- Close PR
-
----
-
-## Summary Table
-
-| PR | Author | Decision | Reason |
-|----|--------|----------|--------|
-| #26 | wkbaran | **Cherry-pick** | `--search` flag not on main; HN/YouTube already there; skip Product Hunt |
-| #24 | el-analista | **Cherry-pick** | Cache dir overrides + bird_x improvements worth landing; Codex auth already there |
-| #14 | thangman1 | **Close** | Removes engagement data, inverts core value proposition |
-| #10 | thetechreviewer | **Close** | OpenRouter already on main |
-| #5 | jblwilliams | **Close** | Codex auth already on main via PR #37/38 |
-
-## Implementation Order (if proceeding)
-
-1. Close PR #5, #10, #14 with comments (no code changes needed)
-2. Cherry-pick PR #24 pieces (bird_x + cache/render env overrides)
-3. Cherry-pick PR #26 pieces (Product Hunt + `--search` flag)
-4. Sync and test
-5. Push to upstream
diff --git a/docs/plans/2026-03-02-feat-close-pr37-codex-auth-finalization-plan.md b/docs/plans/2026-03-02-feat-close-pr37-codex-auth-finalization-plan.md
deleted file mode 100644
index 7e12845..0000000
--- a/docs/plans/2026-03-02-feat-close-pr37-codex-auth-finalization-plan.md
+++ /dev/null
@@ -1,250 +0,0 @@
----
-title: "feat: close PR #37 - Codex auth finalization and clean close"
-type: feat
-status: active
-date: 2026-03-02
----
-
-# feat: Close PR #37 - Codex Auth Finalization
-
-## Overview
-
-PR #37 (`iliaal:codex-auth-merged`) adds Codex auth so users with `codex login` can use the
-skill without an `OPENAI_API_KEY`. The good news: **all of its core changes are already on main**.
-PR #38 (which we merged earlier today) was branched directly from #37, so the Codex auth code
-rode in with that merge.
-
-The task is to:
-1. Verify the Codex auth integration is intact and compatible with v2.6 additions
-2. Fix the one known test isolation bug that PR #37 shipped with
-3. Close PR #37 with a clear explanation and a thank-you to the contributor
-
-## What PR #37 Added (Now All on Main)
-
-### Core Codex auth system (`scripts/lib/env.py`)
-- `CODEX_AUTH_FILE` path constant (`~/.codex/auth.json`)
-- `OpenAIAuth` dataclass: `token`, `source`, `status`, `account_id`, `codex_auth_file`
-- `_decode_jwt_payload()` - JWT base64 decode without verification
-- `_token_expired()` - checks JWT `exp` claim with 60s leeway
-- `extract_chatgpt_account_id()` - extracts `chatgpt_account_id` from JWT `https://api.openai.com/auth` claim
-- `load_codex_auth()` - reads `~/.codex/auth.json`
-- `get_codex_access_token()` - returns `(token, status)` tuple
-- `get_openai_auth()` - priority chain: `OPENAI_API_KEY` env var > `.env` file key > Codex token
-
-### Codex endpoint routing (`scripts/lib/openai_reddit.py`)
-- `CODEX_RESPONSES_URL = "https://chatgpt.com/backend-api/codex/responses"`
-- `_parse_sse_chunk()` / `_parse_sse_stream()` / `_parse_codex_stream()` - SSE response parsing
-- Headers injected for Codex path: `chatgpt-account-id`, `OpenAI-Beta: responses=v1`, `originator: pi`
-- Codex payload: `store: false`, `stream: true`
-- `CODEX_FALLBACK_MODELS` retry chain: `gpt-5.1-codex-mini` → `gpt-5.2`
-
-### Tests (`tests/test_codex_auth.py`)
-- 22 unit tests covering JWT decode, expiry, account ID extraction, auth resolution,
- SSE parsing, payload building, source availability
-- 21/22 pass; 1 has a test isolation bug (see below)
-
-## What PR #37 Contains That Must NOT Go to Public Repo
-
-These files are in the #37 branch but are internal benchmarking artifacts. They should
-never land on the public `upstream` remote:
-
-| Path | Why private |
-|------|-------------|
-| `docs/comparison-results/` (55+ files) | Benchmark JSON/MD from synthesis quality testing |
-| `docs/plans/*.md` (9 internal plan docs) | Private planning documents |
-| `docs/v2.1-tweets.md` | Internal launch tweet drafts |
-| `variants/open/references/research.md` | Internal research notes |
-| `scripts/evaluate-synthesis.py` | Internal evaluation script |
-| `scripts/generate-synthesis-inputs.py` | Internal benchmark input generator |
-| `fixtures/polymarket_sample.json` | Used by internal eval scripts |
-
-## What PR #37 Has That's OLDER Than Main
-
-These files in PR #37 are earlier versions than what main has - we keep our versions:
-
-- `SKILL.md` - PR #37 is v2.1; main is v2.6 (keep v2.6)
-- `README.md` - PR #37's is missing HN/Polymarket; main's is current (keep main)
-- `SPEC.md` - PR #37 has an older spec (keep main)
-- `scripts/lib/hackernews.py` - NOT in PR #37; main has the full HN integration
-- `scripts/lib/polymarket.py` - PR #37 has an older version without quality ranking
-- `scripts/sync.sh` - minor differences; main's version is correct
-
-## Known Issue: Test Isolation Bug
-
-**File:** `tests/test_codex_auth.py`
-**Test:** `TestGetOpenaiAuth::test_api_key_takes_priority`
-
-```python
-def test_api_key_takes_priority(self):
- """OPENAI_API_KEY in env file should be preferred over Codex."""
- file_env = {"OPENAI_API_KEY": "sk-test123"}
- auth = env.get_openai_auth(file_env)
- self.assertEqual(auth.token, "sk-test123") # FAILS if OPENAI_API_KEY set in shell
-```
-
-**Root cause:** `get_openai_auth()` checks `os.environ.get("OPENAI_API_KEY")` first (env var
-priority). The test sets `file_env` but does NOT patch `os.environ`, so the real
-`OPENAI_API_KEY` from the developer's shell wins.
-
-**Fix:**
-
-```python
-@patch.dict(os.environ, {}, clear=False)
-def test_api_key_takes_priority(self):
-```
-
-But we also need to REMOVE `OPENAI_API_KEY` from the patched env:
-
-```python
-@patch.dict(os.environ, {"OPENAI_API_KEY": ""}, clear=False)
-def test_api_key_takes_priority(self):
-```
-
-Actually the cleanest fix:
-
-```python
-def test_api_key_takes_priority(self):
- """OPENAI_API_KEY in env file should be preferred over Codex."""
- with patch.dict(os.environ, {}, clear=True):
- # Restore non-OPENAI env vars to avoid side effects
- file_env = {"OPENAI_API_KEY": "sk-test123"}
- auth = env.get_openai_auth(file_env)
- self.assertEqual(auth.token, "sk-test123")
-```
-
-Or the minimal fix (remove just OPENAI_API_KEY without nuking entire env):
-
-```python
-@patch.dict(os.environ, {"OPENAI_API_KEY": "sk-test123"})
-def test_api_key_takes_priority(self):
- """OPENAI_API_KEY in env var should be preferred over Codex."""
- auth = env.get_openai_auth({})
- self.assertEqual(auth.source, "api_key")
- self.assertEqual(auth.token, "sk-test123")
- self.assertIsNone(auth.account_id)
-```
-
-This reframes the test as "env var takes priority over empty file_env" which is equally
-valid and sidesteps the isolation problem entirely.
-
-## Acceptance Criteria
-
-- [ ] Verify `tests/test_codex_auth.py` runs 22/22 clean (no isolation failures)
-- [ ] Fix `test_api_key_takes_priority` with the minimal patch approach above
-- [ ] Run full test suite: confirm only the 5 pre-existing stale model failures remain
-- [ ] Verify `env.py` on main has `is_hackernews_available()` and `is_polymarket_available()`
- (they were REMOVED in PR #37 but should be on main since #38 preserved them)
-- [ ] Verify `scripts/sync.sh` deploys to `~/.claude/skills/last30daysCROSS` correctly
- (PR #37's sync.sh may be missing this; main's version should have it)
-- [ ] Close PR #37 with a comment explaining the code landed via #38
-- [ ] Add `docs/comparison-results/` to `.gitignore` in the private repo so benchmark
- files never accidentally get committed to the upstream public repo
-
-## Implementation Steps
-
-### Step 1: Fix the test
-
-In the **private source repo** (`/Users/mvanhorn/last30days-skill-private/`):
-
-Edit `tests/test_codex_auth.py` line 77-84. Replace the bare test with the `@patch.dict` version above. Run `python3 -m pytest tests/test_codex_auth.py -v` to confirm 22/22.
-
-### Step 2: Run full test suite
-
-```bash
-cd /Users/mvanhorn/last30days-skill-private
-python3 -m pytest tests/ -v 2>&1 | tail -20
-```
-
-Expected: only `test_reddit_search_basic`, `test_default_model`, `test_model_pin`, and
-similar model-name tests fail (the 5 pre-existing stale model failures from before PR #37).
-Codex auth tests should all pass.
-
-### Step 3: Verify the private docs protection
-
-Check that `.gitignore` (or the upstream push config) prevents `docs/comparison-results/`
-from leaking to the public GitHub remote.
-
-```bash
-cat /Users/mvanhorn/last30days-skill-private/.gitignore | grep -E "comparison|evaluate|generate"
-```
-
-If not present, add:
-```
-docs/comparison-results/
-scripts/evaluate-synthesis.py
-scripts/generate-synthesis-inputs.py
-fixtures/polymarket_sample.json
-docs/v2.1-tweets.md
-variants/open/references/research.md
-```
-
-### Step 4: Commit and sync
-
-```bash
-cd /Users/mvanhorn/last30days-skill-private
-git add tests/test_codex_auth.py
-git commit -m "fix(tests): patch OPENAI_API_KEY env isolation in test_api_key_takes_priority"
-bash scripts/sync.sh
-```
-
-Then push to upstream (public):
-```bash
-git push upstream main
-```
-
-### Step 5: Close PR #37
-
-Post a comment on PR #37 explaining what happened, then close it:
-
-```
-Thanks @iliaal! 🙏 This was a great contribution.
-
-The Codex auth changes landed in main via PR #38, which was branched from your
-`codex-auth-merged` branch. So all the core auth code is already shipping:
-
-- JWT decoding + expiry checking in env.py ✅
-- Codex endpoint routing + SSE parsing in openai_reddit.py ✅
-- 22 unit tests in test_codex_auth.py ✅
-- CODEX_FALLBACK_MODELS retry chain ✅
-
-Since then we've also shipped v2.5 (HN + Polymarket sources) and v2.6 (agent-native
-invocation with --agent flag), so SKILL.md and README are already ahead of this branch.
-
-Closing as the changes are incorporated. Thanks again for the excellent work!
-```
-
-## Technical Considerations
-
-**Why can't we just merge #37 directly?**
-
-Three reasons:
-1. SKILL.md/README in #37 are v2.1 - they'd overwrite our v2.6 improvements
-2. The `docs/comparison-results/` directory (55+ benchmark files) would go to the public repo
-3. The test isolation bug would ship a flaky test to everyone who sets `OPENAI_API_KEY`
-
-**Cherry-pick vs close approach:**
-
-Since the Codex auth code is already on main, cherry-picking would be redundant. The cleanest
-path is to fix the test bug on main, then close #37 with an explanation.
-
-**Future private-docs hygiene:**
-
-The `docs/comparison-results/` files should be gitignored or moved to a separate
-private branch so this situation doesn't repeat. These are internal QA benchmarks -
-they belong in the private repo only, never in `upstream`.
-
-## Dependencies & Risks
-
-**Low risk** - this is test cleanup + PR bookkeeping. The feature itself is already running.
-
-**Codex auth live test** - We can't easily verify the live Codex auth flow without a `codex login`
-session. If a user reports auth issues, the test suite gives good coverage of the logic;
-live testing would require a Codex-authenticated environment.
-
-## Sources & References
-
-- PR #37: https://github.com/mvanhorn/last30days-skill/pull/37 (iliaal: codex-auth-merged)
-- PR #38 (merged): fixed Bird X auth, brought Codex auth to main as a side effect
-- `tests/test_codex_auth.py` - 22 unit tests for the Codex auth system
-- `scripts/lib/env.py` lines 26-175 - Codex auth core logic
-- `scripts/lib/openai_reddit.py` lines 45-310 - Codex endpoint routing
diff --git a/docs/plans/2026-03-02-feat-last30days-v2.6-agent-native-invocation-plan.md b/docs/plans/2026-03-02-feat-last30days-v2.6-agent-native-invocation-plan.md
deleted file mode 100644
index 6a3ba9c..0000000
--- a/docs/plans/2026-03-02-feat-last30days-v2.6-agent-native-invocation-plan.md
+++ /dev/null
@@ -1,154 +0,0 @@
----
-title: "feat: last30days v2.6 - agent-native invocation"
-type: feat
-status: completed
-date: 2026-03-02
----
-
-# feat: last30days v2.6 - Agent-Native Invocation
-
-## Overview
-
-v2.5 shipped as a human-only interactive skill. Agents calling it today would hang forever at
-"WAIT FOR USER RESPONSE" and never receive research output. v2.6 makes the skill fully
-invocable by other agents - delivering a complete research report instead of an interactive
-conversation.
-
-The `disable-model-invocation: true` flag was removed in a hotfix today (2026-03-02). v2.6
-formalizes that fix, addresses the deeper interactive-flow problem, and updates all
-documentation to match reality.
-
-## Problem Statement
-
-### 1. The flag was removed but the behavior is still broken
-
-Removing `disable-model-invocation: true` lets agents call the skill. But the skill still:
-
-- Calls `AskUserQuestion` mid-flow (blocks, waits for a human to click something)
-- Ends with "WAIT FOR USER RESPONSE" (the calling agent gets nothing)
-- Assumes a human is reading progress text in real-time
-
-When an agent calls `/last30days plaud granola`, it needs a **completed research report
-returned to it**, not a half-executed interactive session.
-
-### 2. The Security section has a false statement
-
-Line 558 of SKILL.md still reads:
-
-```
-- Cannot be invoked autonomously by the agent (`disable-model-invocation: true`)
-```
-
-This is now wrong. It actively misleads users and agents reading the skill docs.
-
-### 3. No documented path for agent callers
-
-Users who want to call `last30days` from another skill (e.g., "research this topic and then
-build a plan") have no guidance on how to do it or what format to expect back.
-
-## Proposed Solution
-
-### Agent Mode: `--agent` flag
-
-Add an `--agent` execution mode that skips all interactive elements and returns a structured
-research report. Calling agents pass the flag explicitly:
-
-```
-/last30days plaud granola --agent
-```
-
-### Agent Mode Behavior
-
-In agent mode, the skill:
-
-1. **Skips** the intro display block ("I'll research X across Reddit...")
-2. **Skips** `AskUserQuestion` for target tool clarification
-3. **Skips** the "WAIT FOR USER RESPONSE" pause
-4. **Skips** the follow-up invitation ("I'm now an expert on X...")
-5. **Outputs** a complete, structured report with all findings, then terminates cleanly
-
-The report format for agent mode:
-
-```
-## Research Report: {TOPIC}
-Generated: {date} | Sources: Reddit, X, YouTube, HN, Polymarket, Web
-
-### Key Findings
-[3-5 bullet points, highest-signal insights with citations]
-
-### {Source} Results
-[Compact per-source sections with top items]
-
-### Stats
-{The existing stats block}
-```
-
-### Security Section Rewrite
-
-Remove the false statement. Replace with:
-
-```
-- Can be invoked autonomously by agents via the Skill tool (inline mode, not forked)
-- Pass `--agent` flag for non-interactive report output
-```
-
-### Version Bump
-
-`version: "2.5"` → `version: "2.6"` in SKILL.md frontmatter.
-
-## Technical Considerations
-
-### Implementation: SKILL.md instructions only
-
-Add a section to SKILL.md that reads:
-```
-## Agent Mode (--agent flag)
-If --agent is in ARGUMENTS: skip intro display, skip AskUserQuestion, skip invitation,
-skip WAIT block. Output the full research report and stop.
-```
-
-No Python script changes needed. The script already outputs everything via `--emit=compact` -
-the LLM just skips the interactive wrapper. If LLM instruction-following proves unreliable in
-testing, a v2.6.1 patch adds `--agent` natively to `last30days.py` (Python flag approach).
-
-## Acceptance Criteria
-
-- [x] `disable-model-invocation: true` is gone from SKILL.md (done in hotfix - verify)
-- [x] Security section no longer claims the skill cannot be invoked autonomously
-- [x] SKILL.md documents the `--agent` flag and its behavior
-- [x] In `--agent` mode: no AskUserQuestion calls, no WAIT block, full report output
-- [x] Version is `2.6` in frontmatter
-- [x] Synced to all 4 destinations via `sync.sh`
-- [ ] Manual test: `Skill { skill: "last30days", args: "plaud granola --agent" }` returns
- a complete research report without hanging
-
-## Implementation Steps
-
-1. **Verify hotfix** - confirm `disable-model-invocation` is gone in private source
-2. **Edit SKILL.md** - update Security section, add Agent Mode section, bump version
-3. **Run sync.sh** - deploy to `~/.claude`, `~/.agents`, `~/.codex`
-4. **Test** - invoke via Skill tool and verify report returns cleanly
-
-All changes are in `SKILL.md` only. No Python changes needed for v2.6.
-
-## Dependencies & Risks
-
-**No script changes** - this is a pure SKILL.md update. Low risk.
-
-**LLM instruction-following** - agent mode relies on the LLM reading and following the
-`--agent` section. If testing shows it's unreliable, escalate to Option B (Python flag)
-as a v2.6.1 patch.
-
-**Skill list refresh** - after sync, Claude Code needs to restart or reload skills to see
-the updated frontmatter. Users on older Claude Code versions may still see the old flag.
-
-## Distribution Note
-
-For users who installed from the public repo, they need to pull the update. The sync script
-handles local installations. For `last30daysCROSS` variant, the same changes apply.
-
-## Sources & References
-
-- [fix-skill-execution-fork-mode-plan.md](./2026-02-06-fix-skill-execution-fork-mode-plan.md) - original fork-mode diagnosis
-- SKILL.md line 558: false security claim to remove
-- Hotfix applied 2026-03-02: removed `disable-model-invocation: true`
diff --git a/docs/plans/2026-03-02-fix-websearch-sources-trailing-block-plan.md b/docs/plans/2026-03-02-fix-websearch-sources-trailing-block-plan.md
deleted file mode 100644
index 346777f..0000000
--- a/docs/plans/2026-03-02-fix-websearch-sources-trailing-block-plan.md
+++ /dev/null
@@ -1,102 +0,0 @@
----
-title: "fix: suppress trailing Sources: block from WebSearch tool mandate"
-type: fix
-status: completed
-date: 2026-03-02
----
-
-# fix: Suppress Trailing Sources: Block from WebSearch Tool Mandate
-
-## Problem
-
-After the skill completes, a `Sources:` block appears below the invitation text:
-
-```
-I'm now an expert on the Dor Brothers. Some things I can help with:
-...
-
-Sources:
- - Movie starring Logan Paul made exclusively with AI released - Newsweek
- - The Dor Brothers: Pioneers in AI Video Production
- - ...
-```
-
-This happens because the `WebSearch` tool has a **system-level mandatory instruction**:
-> "After answering the user's question, you MUST include a 'Sources:' section at the end of your response"
-
-SKILL.md already says `DO NOT output "Sources:" list` (line 214) but this is too vague - it doesn't address the WebSearch tool mandate explicitly, so the tool's system instruction wins. The model dutifully appends Sources: after all skill output is done.
-
-## Root Cause
-
-Two competing instructions:
-1. **WebSearch system mandate** (higher authority): "MUST include Sources: at end of response"
-2. **SKILL.md line 214** (lower authority): "DO NOT output Sources: list"
-
-The model follows #1 because it's framed as a critical system requirement.
-
-The fix: **satisfy the WebSearch citation requirement INSIDE the stats block**, then explicitly tell the model the requirement is already fulfilled and no trailing section is needed.
-
-## Proposed Solution
-
-**Two-part SKILL.md edit only. No Python changes.**
-
-### Part 1: Update the Step 2 instruction (line 214)
-
-**Current:**
-```
-- **DO NOT output "Sources:" list** - this is noise, we'll show stats at the end
-```
-
-**Replace with:**
-```
-- **DO NOT output a separate "Sources:" block** — instead, include the top 3-5 web
- source names as inline links on the 🌐 Web: stats line (see stats format below).
- This satisfies the WebSearch tool's citation requirement inline without a trailing section.
-```
-
-### Part 2: Update the stats block format to include web source links
-
-**Current stats line:**
-```
-├─ 🌐 Web: {N} pages (supplementary)
-```
-
-**Replace with:**
-```
-├─ 🌐 Web: {N} pages — [Source Name](url), [Source Name](url), [Source Name](url)
-```
-
-And immediately after the closing `---` of the stats block, add:
-
-```
-**WebSearch citation note:** Source links are included in the 🌐 Web: line above.
-The WebSearch tool citation requirement is satisfied. Do NOT append a separate
-"Sources:" section after the invitation.
-```
-
-## Acceptance Criteria
-
-- [x] The trailing `Sources:` block no longer appears after the invitation
-- [x] Web source links appear cleanly on the `🌐 Web:` stats line
-- [ ] Manual test: run `/last30days dor brothers` and confirm no trailing Sources: block
-- [x] Synced to all 4 destinations via `sync.sh`
-
-## Implementation Steps
-
-1. Edit `SKILL.md` line 214 (Step 2 section) - replace weak "DO NOT" with redirect instruction
-2. Edit `SKILL.md` stats block format - add `— [Source](url), ...` to the 🌐 Web: line
-3. Add WebSearch citation note after the stats block closing `---`
-4. Run `bash scripts/sync.sh` to deploy
-5. Test with `/last30days [any topic]` and confirm no trailing Sources:
-
-## Context
-
-- **Why not just fight the mandate?** The WebSearch system instruction is authoritative. We can't override it with a soft "don't do this." We need to redirect it.
-- **Why the stats block?** It's the natural place for source metadata and it appears before the invitation, so satisfying the citation there prevents the trailing append.
-- **SKILL.md is the only file that needs to change.** No Python script changes required.
-
-## Sources & References
-
-- SKILL.md line 214: current weak instruction
-- SKILL.md stats block section: where web source links will live
-- Screenshot: user-reported Sources: trailing block (session context)
diff --git a/docs/plans/2026-03-03-feat-skip-native-web-in-claude-code-plan.md b/docs/plans/2026-03-03-feat-skip-native-web-in-claude-code-plan.md
deleted file mode 100644
index a5b8c9f..0000000
--- a/docs/plans/2026-03-03-feat-skip-native-web-in-claude-code-plan.md
+++ /dev/null
@@ -1,43 +0,0 @@
-# feat: Skip native web search when running in Claude Code
-
-**Type:** enhancement
-**Date:** 2026-03-03
-**Detail level:** MINIMAL
-
-## Problem
-
-When `/last30days` runs in Claude Code, web search happens twice:
-1. The Python script uses Parallel AI / Brave / OpenRouter (costs API credits)
-2. SKILL.md tells Claude to run its built-in WebSearch tool (free, better quality)
-
-This is redundant. Claude's WebSearch is better and free. In OpenClaw, there's no WebSearch tool, so native backends are essential there.
-
-## Solution
-
-Add a `--no-native-web` CLI flag to `last30days.py`. When set, the script skips native web search backends even if API keys are configured, and emits the `### WEBSEARCH REQUIRED ###` signal so the assistant handles it.
-
-Update SKILL.md invocation to include the flag.
-
-## Changes
-
-### 1. `scripts/last30days.py`
-- [ ] Add `--no-native-web` argument to argparse (store_true, default False)
-- [ ] When `args.no_native_web` is True, force `web_backend = None` regardless of API keys
-- [ ] This naturally triggers `web_needed = True` → emits `### WEBSEARCH REQUIRED ###` signal
-- [ ] Update diagnostic banner to show "Web: deferred to assistant" when flag is active
-
-### 2. `SKILL.md`
-- [ ] Add `--no-native-web` to the invocation command on line 168:
- ```
- python3 "${SKILL_ROOT}/scripts/last30days.py" "$ARGUMENTS" --emit=compact --no-native-web
- ```
-
-### 3. OpenClaw / `--agent` mode
-- [ ] No changes needed — OpenClaw invocations don't read SKILL.md, they call the script directly without `--no-native-web`, so Parallel AI/Brave/OpenRouter still work
-
-## Acceptance Criteria
-
-- [ ] Claude Code sessions: script skips Parallel AI, Claude uses WebSearch (no API credits spent)
-- [ ] OpenClaw sessions: script still uses Parallel AI / Brave / OpenRouter as before
-- [ ] `--no-native-web` flag can be combined with `--include-web` (flag wins, web deferred)
-- [ ] Diagnostic output clearly shows web is deferred to assistant
diff --git a/docs/plans/2026-03-03-feat-tiktok-apify-source-plan.md b/docs/plans/2026-03-03-feat-tiktok-apify-source-plan.md
deleted file mode 100644
index e43f93c..0000000
--- a/docs/plans/2026-03-03-feat-tiktok-apify-source-plan.md
+++ /dev/null
@@ -1,417 +0,0 @@
----
-title: "feat: Add TikTok as 7th source via Apify"
-type: feat
-date: 2026-03-03
----
-
-# feat: Add TikTok Signal via Apify
-
-## Overview
-
-Add TikTok as the 7th research source alongside Reddit, X, YouTube, HN, Polymarket, and Web. Use the **Apify** platform (`clockworks/tiktok-scraper` actor) to search TikTok by keyword, extract engagement metrics (views, likes, comments), and optionally pull video captions for synthesis enrichment — mirroring the YouTube pattern.
-
-**Why this matters:** TikTok is where trends break first for many topics (products, music, culture, tech tips, news reactions). A viral TikTok with 2M views is a stronger signal than a tweet with 500 likes. The skill currently misses this entirely.
-
-**Why Apify:** BYO API key, $5/month free credits (no CC required), pay-per-result pricing, Python SDK (`apify-client`), and the same actor platform supports Facebook and Instagram scrapers — so this investment pays forward.
-
-## Proposed Solution
-
-### Architecture: Shared Apify Client + Per-Source Modules
-
-```
-scripts/lib/
- apify_client_wrapper.py ← NEW: shared Apify client init + helpers (reused by FB/IG later)
- tiktok.py ← NEW: TikTok search, captions, relevance
- # future:
- # facebook.py ← uses same apify_client_wrapper.py
- # instagram.py ← uses same apify_client_wrapper.py
-```
-
-This design means adding Facebook or Instagram later is just a new `facebook.py` module — the Apify client setup, token validation, and error handling are already done.
-
-### Data Flow
-
-```
-User topic + date range
- ↓
-[apify_client_wrapper.py] init client with APIFY_API_TOKEN
- ↓
-[tiktok.py] search_tiktok()
- ├─ Call clockworks/tiktok-scraper actor (sync API, ≤5min)
- ├─ Input: searchQueries=[core_topic], resultsPerPage=N (depth-aware)
- ├─ Parse: id, text, playCount, diggCount, commentCount, createTimeISO, authorMeta, webVideoUrl, hashtags
- ├─ Sort by playCount (views) descending
- ├─ Compute relevance via token-overlap (reuse youtube_yt._compute_relevance pattern)
- └─ Return items
- ↓
-[tiktok.py] fetch_captions() (optional enrichment for top N)
- ├─ Re-call actor with shouldDownloadSubtitles=true for top videos
- ├─ OR use video text/description as lightweight "caption" alternative
- └─ Truncate to 500 words, attach as caption_snippet
- ↓
-[normalize.py] normalize_tiktok_items() → List[TikTokItem]
- ↓
-[score.py] score_tiktok_items()
- ├─ compute_tiktok_engagement_raw(): 0.50*log1p(views) + 0.30*log1p(likes) + 0.20*log1p(comments)
- ├─ Weighted: 0.45*relevance + 0.25*recency + 0.30*engagement
- └─ Same formula as YouTube (views-dominant)
- ↓
-[dedupe.py] dedupe_tiktok() + cross_source_link()
- ↓
-[render.py] render TikTok section
- ↓
-[SKILL.md] stats line: 🎵 TikTok: N videos │ N views │ N with captions
-```
-
-## Technical Approach
-
-### Phase 1: Apify Client Wrapper (`scripts/lib/apify_client_wrapper.py`)
-
-Shared module for all Apify-backed sources. Keeps TikTok, Facebook, Instagram from duplicating client setup.
-
-```python
-"""Shared Apify client utilities for last30days sources."""
-
-from apify_client import ApifyClient
-from typing import Optional, Dict, Any, List
-
-def get_apify_client(token: str) -> ApifyClient:
- """Initialize Apify client with token."""
- return ApifyClient(token=token)
-
-def run_actor_sync(
- client: ApifyClient,
- actor_id: str,
- run_input: Dict[str, Any],
- timeout_secs: int = 300,
- max_items: int = None,
-) -> List[Dict[str, Any]]:
- """Run an Apify actor synchronously and return dataset items.
-
- Args:
- client: Initialized ApifyClient
- actor_id: e.g. "clockworks/tiktok-scraper"
- run_input: Actor-specific input dict
- timeout_secs: Max wait time (default 5 min)
- max_items: Cap on returned items (cost control)
-
- Returns:
- List of result dicts from the actor's default dataset
- """
- run = client.actor(actor_id).call(
- run_input=run_input,
- timeout_secs=timeout_secs,
- )
- items = list(client.dataset(run["defaultDatasetId"]).iterate_items())
- if max_items:
- items = items[:max_items]
- return items
-```
-
-**Key design decisions:**
-- Single `APIFY_API_TOKEN` env var for all Apify sources (TikTok, future FB, IG)
-- `run_actor_sync()` wraps the call+wait+fetch pattern used by every Apify actor
-- `max_items` param provides cost control (important with $5 free credits)
-
-### Phase 2: TikTok Search Module (`scripts/lib/tiktok.py`)
-
-```python
-"""TikTok search via Apify clockworks/tiktok-scraper."""
-
-ACTOR_ID = "clockworks/tiktok-scraper"
-
-DEPTH_CONFIG = {
- "quick": {"results_per_page": 10, "max_captions": 3},
- "default": {"results_per_page": 20, "max_captions": 5},
- "deep": {"results_per_page": 40, "max_captions": 8},
-}
-
-def search_tiktok(topic, from_date, to_date, depth="default", token=None):
- """Search TikTok via Apify.
-
- Returns:
- Dict with 'items' list and optional 'error'.
- """
- # 1. Init client via apify_client_wrapper
- # 2. Build input: searchQueries=[_extract_core_subject(topic)], resultsPerPage=N
- # 3. Call run_actor_sync(client, ACTOR_ID, input, timeout=120)
- # 4. Parse items: extract id, text, playCount, diggCount, commentCount,
- # shareCount, createTimeISO, authorMeta.name, webVideoUrl, hashtags
- # 5. Filter by date range (from_date to to_date)
- # 6. Sort by playCount descending
- # 7. Compute relevance via _compute_relevance(topic, item_text)
- # 8. Return structured items
-
-def fetch_captions(video_items, token, depth="default"):
- """Fetch captions/subtitles for top N TikTok videos.
-
- Strategy: Re-run actor with shouldDownloadSubtitles=true for
- specific video URLs, OR fall back to video text/description
- as a lightweight alternative.
-
- Returns:
- Dict mapping video_id → caption_text (truncated to 500 words)
- """
-
-def search_and_enrich(topic, from_date, to_date, depth="default", token=None):
- """Search + caption enrichment orchestrator (mirrors youtube_yt.search_and_transcribe)."""
-
-def parse_tiktok_response(response):
- """Extract items list from search_and_enrich response."""
-```
-
-**Apify actor input for keyword search:**
-```json
-{
- "searchQueries": ["claude code tips"],
- "resultsPerPage": 20,
- "shouldDownloadSubtitles": false,
- "shouldDownloadVideos": false,
- "shouldDownloadCovers": false
-}
-```
-
-**Apify actor output fields we use:**
-
-| Apify Field | Our Field | Notes |
-|---|---|---|
-| `id` | `id` | TikTok video ID |
-| `text` | `caption` | Video caption/description |
-| `playCount` | `engagement.views` | Primary engagement signal |
-| `diggCount` | `engagement.likes` | Secondary signal |
-| `commentCount` | `engagement.num_comments` | Tertiary signal |
-| `shareCount` | (stored but not scored) | Available for future use |
-| `createTimeISO` | `date` | Parse to YYYY-MM-DD |
-| `authorMeta.name` | `author_name` | Creator handle |
-| `authorMeta.fans` | (stored but not scored) | Follower count |
-| `webVideoUrl` | `url` | Direct TikTok link |
-| `hashtags[].name` | `hashtags` | For relevance boosting |
-| `videoMeta.duration` | `duration` | For filtering very short clips |
-
-**Relevance scoring:** Reuse the token-overlap algorithm from `youtube_yt._compute_relevance()`. Additionally boost relevance when topic tokens appear in hashtags (TikTok-specific signal).
-
-**Caption enrichment strategy:**
-1. **Primary:** Use the `text` field (video description/caption) — always available, free
-2. **Enhanced:** For top N videos, re-run actor with `shouldDownloadSubtitles: true` to get spoken-word captions
-3. **Fallback:** If subtitles unavailable, use `text` field alone (most TikTok videos have descriptive captions)
-
-This is cheaper than YouTube transcripts (no second yt-dlp call needed for the basic case).
-
-### Phase 3: Schema + Normalization
-
-**`scripts/lib/schema.py` — add TikTokItem dataclass:**
-
-```python
-@dataclass
-class TikTokItem:
- """Normalized TikTok item."""
- id: str # video_id
- text: str # caption/description
- url: str # webVideoUrl
- author_name: str # authorMeta.name
- date: Optional[str] = None
- date_confidence: str = "high" # Apify provides exact timestamps
- engagement: Optional[Engagement] = None # views, likes, num_comments
- caption_snippet: str = "" # spoken-word caption (if available), else text
- hashtags: List[str] = field(default_factory=list)
- relevance: float = 0.7
- why_relevant: str = ""
- subs: SubScores = field(default_factory=SubScores)
- score: int = 0
- cross_refs: List[str] = field(default_factory=list)
-```
-
-**`scripts/lib/schema.py` — add to Engagement dataclass:**
-- `shares: Optional[int] = None` — TikTok shares (also useful for future Facebook)
-
-**`scripts/lib/schema.py` — add to Report dataclass:**
-- `tiktok: List[TikTokItem] = field(default_factory=list)`
-- `tiktok_error: Optional[str] = None`
-
-**`scripts/lib/normalize.py` — add `normalize_tiktok_items()`:**
-- Parse `createTimeISO` → YYYY-MM-DD
-- Create Engagement(views=playCount, likes=diggCount, num_comments=commentCount)
-- Create TikTokItem objects
-- Hard date filter (like Reddit/X, not soft like YouTube)
-
-### Phase 4: Scoring
-
-**`scripts/lib/score.py` — add TikTok scoring:**
-
-```python
-def compute_tiktok_engagement_raw(engagement):
- """TikTok engagement: views-dominant like YouTube.
- 0.50*log1p(views) + 0.30*log1p(likes) + 0.20*log1p(comments)
- """
- views = getattr(engagement, 'views', 0) or 0
- likes = getattr(engagement, 'likes', 0) or 0
- comments = getattr(engagement, 'num_comments', 0) or 0
- return 0.50 * log1p(views) + 0.30 * log1p(likes) + 0.20 * log1p(comments)
-
-def score_tiktok_items(items):
- """Score TikTok items. Same weights as YouTube:
- 0.45*relevance + 0.25*recency + 0.30*engagement"""
-```
-
-### Phase 5: Deduplication + Cross-Source Linking
-
-**`scripts/lib/dedupe.py`:**
-
-```python
-def dedupe_tiktok(items, threshold=0.7):
- """Dedupe TikTok items via Jaccard similarity on text + author_name."""
- return dedupe_items(items, threshold)
-```
-
-- Text extraction for similarity: `text + author_name` (mirrors YouTube's `title + channel_name`)
-- Add `tiktok` to `cross_source_link()` — compare TikTok items with all other sources
-- Cross-ref prefix: `"TK"` (e.g., `TK3` for TikTok item 3)
-
-### Phase 6: Rendering
-
-**`scripts/lib/render.py` — add TikTok section:**
-
-```markdown
-### TikTok Videos
-
-**TK1** (score:87) @creator_name (2026-02-28) [2.1M views, 45K likes]
- Caption: "This Claude Code trick saved me hours... #claudecode #ai"
- https://www.tiktok.com/@creator/video/1234567890
- Spoken: "So I found this insane trick with Claude Code where you can..."
- *TikTok: This Claude Code trick saved me hours*
-```
-
-**Stats line for SKILL.md:**
-```
-├─ 🎵 TikTok: {N} videos │ {N} views │ {N} with captions
-```
-
-### Phase 7: Environment + Config
-
-**`scripts/lib/env.py` — add Apify support:**
-
-```python
-def is_apify_available(config: Dict[str, Any]) -> bool:
- """Check if Apify token is configured for TikTok/social scraping."""
- return bool(config.get('APIFY_API_TOKEN'))
-```
-
-- New env var: `APIFY_API_TOKEN`
-- Add to `get_config()` key list
-- Add to `get_available_sources()` / `get_missing_keys()` logic
-- Single token covers TikTok + future Facebook + Instagram
-
-**User setup:**
-```bash
-# Add to ~/.config/last30days/.env
-APIFY_API_TOKEN=apify_api_xxxxxxxxxxxxx
-```
-
-Or get free token: Sign up at https://console.apify.com → Settings → Integrations → Personal API Token.
-
-### Phase 8: Orchestrator Integration
-
-**`scripts/last30days.py` changes:**
-
-1. Add `"tiktok"` to `VALID_SEARCH_SOURCES` set (line 47)
-2. Add `tiktok_future` var + timeout to `TIMEOUT_PROFILES`:
- ```python
- "tiktok_future": 120 # Apify actors can be slow on first run
- ```
-3. Add `do_tiktok` bool + `run_tiktok` parameter to `run_research()`
-4. Submit `_search_tiktok()` to ThreadPoolExecutor (now max 7+1 workers)
-5. Collect TikTok results with timeout
-6. Add tiktok to return tuple + progress display
-7. Wire tiktok into normalize → score → dedupe → cross-link → render pipeline in main
-
-### Phase 9: SKILL.md Updates
-
-1. Add TikTok to stats box template
-2. Add TikTok citation rule: `@creator on TikTok`
-3. Add TikTok to source weight guidance (rank between YouTube and HN)
-4. Document `APIFY_API_TOKEN` in setup section
-
-### Phase 10: Dependency
-
-```bash
-pip install apify-client
-```
-
-- `apify-client` is the only new dependency
-- Requires Python 3.10+ (already required by the project)
-- No new binary dependencies (unlike yt-dlp for YouTube)
-
-## Files to Create / Modify
-
-### New Files
-| File | Purpose |
-|---|---|
-| `scripts/lib/apify_client_wrapper.py` | Shared Apify client init + `run_actor_sync()` helper |
-| `scripts/lib/tiktok.py` | TikTok search, caption extraction, relevance scoring |
-| `tests/test_tiktok.py` | Unit tests for TikTok module |
-| `fixtures/tiktok_search.json` | Mock Apify response for testing |
-
-### Modified Files
-| File | Changes |
-|---|---|
-| `scripts/lib/schema.py` | Add `TikTokItem` dataclass, `shares` to Engagement, `tiktok`/`tiktok_error` to Report |
-| `scripts/lib/normalize.py` | Add `normalize_tiktok_items()` |
-| `scripts/lib/score.py` | Add `compute_tiktok_engagement_raw()`, `score_tiktok_items()` |
-| `scripts/lib/dedupe.py` | Add `dedupe_tiktok()`, add tiktok to `cross_source_link()` |
-| `scripts/lib/render.py` | Add TikTok rendering section, stats line |
-| `scripts/lib/env.py` | Add `APIFY_API_TOKEN` handling, `is_apify_available()` |
-| `scripts/last30days.py` | Add tiktok to orchestrator pipeline, `VALID_SEARCH_SOURCES`, `TIMEOUT_PROFILES` |
-| `SKILL.md` | Add TikTok stats line, citation rules, source weights |
-| `README.md` | Add TikTok to source list, Apify setup instructions |
-
-## Future: Facebook + Instagram via Apify
-
-The `apify_client_wrapper.py` module is designed to be reused. Adding Facebook would look like:
-
-```python
-# scripts/lib/facebook.py
-from . import apify_client_wrapper
-
-ACTOR_ID = "apify/facebook-posts-scraper" # or "scraper_one/facebook-posts-search"
-
-def search_facebook(topic, from_date, to_date, depth="default", token=None):
- client = apify_client_wrapper.get_apify_client(token)
- run_input = {
- "searchType": "posts",
- "searchTerms": [topic],
- "maxPosts": DEPTH_CONFIG[depth]["max_posts"],
- }
- items = apify_client_wrapper.run_actor_sync(client, ACTOR_ID, run_input)
- # Parse: text, likes, comments, shares, time, user.name, url
- ...
-```
-
-**Facebook fields available:** `text`, `likes`, `comments`, `shares`, `time`/`timestamp`, `user.name`, `url`, `reactions_count`
-
-**Instagram** would follow the same pattern with `apify/instagram-scraper` or similar.
-
-Same `APIFY_API_TOKEN` — no additional keys needed.
-
-## Cost Analysis
-
-**Per research run (default depth, 20 results):**
-- Clockworks TikTok scraper: ~$0.10 per 20 results ($5/1000)
-- Free tier: ~50 research runs per month on $5 free credits
-- With captions (re-run for top 5): ~$0.15 total per run → ~33 runs/month free
-
-**Comparison:** YouTube costs $0 (yt-dlp is free). TikTok costs ~$0.10-0.15/run. This is acceptable given the signal value and tracks with the BYO key model.
-
-## Acceptance Criteria
-
-- [ ] `APIFY_API_TOKEN` in `.env` enables TikTok source automatically
-- [ ] TikTok appears in parallel search alongside other 6 sources
-- [ ] Results include: video URL, caption, author, views, likes, comments, date
-- [ ] Caption enrichment works for top N videos (configurable by depth)
-- [ ] Relevance scoring filters off-topic viral videos
-- [ ] Cross-source linking detects when TikTok + Reddit/YouTube discuss same topic
-- [ ] Stats box shows: `🎵 TikTok: N videos │ N views │ N with captions`
-- [ ] `--search=tiktok` flag works for TikTok-only research
-- [ ] Graceful degradation: if no APIFY_API_TOKEN, TikTok silently skipped
-- [ ] Mock mode works with `fixtures/tiktok_search.json`
-- [ ] Tests pass for search, normalize, score, dedupe, render
-- [ ] `apify_client_wrapper.py` is generic enough for Facebook/Instagram reuse
diff --git a/docs/plans/2026-03-03-fix-close-commented-github-issues-plan.md b/docs/plans/2026-03-03-fix-close-commented-github-issues-plan.md
deleted file mode 100644
index 777c86b..0000000
--- a/docs/plans/2026-03-03-fix-close-commented-github-issues-plan.md
+++ /dev/null
@@ -1,49 +0,0 @@
----
-title: Close Commented GitHub Issues and PRs
-type: fix
-status: active
-date: 2026-03-03
-origin: docs/plans/2026-03-03-fix-triage-all-open-github-issues-plan.md
----
-
-# Close Commented GitHub Issues and PRs
-
-All 9 open issues and 2 open PRs on `mvanhorn/last30days-skill` were commented on (2026-03-03) with fix confirmations or responses, but none were actually closed on GitHub.
-
-## Acceptance Criteria
-
-- [ ] Close 6 issues where fixes were confirmed (#40, #39, #32, #30, #29, #4)
-- [ ] Close PR #26 (superseded - features landed on main)
-- [ ] Verify 3 issues remain open (#36, #31, #22) - acknowledged but unresolved
-- [ ] Verify PR #24 remains open - explicitly left open per comment
-
-## Actions
-
-### Close as fixed (6 issues)
-
-```bash
-gh issue close 40 --repo mvanhorn/last30days-skill --reason completed
-gh issue close 39 --repo mvanhorn/last30days-skill --reason completed
-gh issue close 32 --repo mvanhorn/last30days-skill --reason completed
-gh issue close 30 --repo mvanhorn/last30days-skill --reason completed
-gh issue close 29 --repo mvanhorn/last30days-skill --reason completed
-gh issue close 4 --repo mvanhorn/last30days-skill --reason completed
-```
-
-### Close superseded PR (#26)
-
-```bash
-gh pr close 26 --repo mvanhorn/last30days-skill
-```
-
-### Keep open (no action needed)
-
-- #36 - SKILL.md flag forwarding (investigating)
-- #31 - skills.sh audit scores (needs review)
-- #22 - Bird feature requests (backlog)
-- PR #24 - Codex compatibility (intentionally left open)
-
-## Sources
-
-- **Origin plan:** [docs/plans/2026-03-03-fix-triage-all-open-github-issues-plan.md](2026-03-03-fix-triage-all-open-github-issues-plan.md)
-- Commit: `5e5d586` - fix: triage all 16 open GitHub issues
diff --git a/docs/plans/2026-03-03-fix-skill-md-argument-flag-forwarding-plan.md b/docs/plans/2026-03-03-fix-skill-md-argument-flag-forwarding-plan.md
deleted file mode 100644
index d032334..0000000
--- a/docs/plans/2026-03-03-fix-skill-md-argument-flag-forwarding-plan.md
+++ /dev/null
@@ -1,111 +0,0 @@
----
-title: Fix SKILL.md Argument Flag Forwarding
-type: fix
-status: completed
-date: 2026-03-03
----
-
-# Fix SKILL.md Argument Flag Forwarding
-
-## Overview
-
-`"$ARGUMENTS"` in SKILL.md wraps the entire user input in double quotes, making argparse treat flags like `--store` as part of the topic string instead of CLI flags.
-
-## Problem
-
-SKILL.md line 168:
-```bash
-python3 "${SKILL_ROOT}/scripts/last30days.py" "$ARGUMENTS" --emit=compact --no-native-web
-```
-
-`$ARGUMENTS` is a Claude Code template variable replaced via string substitution before bash runs. The double quotes cause word-joining:
-
-- User types: `/last30days AI video tools --store`
-- Claude Code expands to: `python3 script.py "AI video tools --store" --emit=compact`
-- argparse sees: `topic="AI video tools --store"`, `--store` never parsed
-
-## Proposed Solution
-
-Two coordinated changes:
-
-### 1. Remove quotes around `$ARGUMENTS` in SKILL.md
-
-**File:** `SKILL.md:168`
-
-```bash
-# Before:
-python3 "${SKILL_ROOT}/scripts/last30days.py" "$ARGUMENTS" --emit=compact --no-native-web
-
-# After:
-python3 "${SKILL_ROOT}/scripts/last30days.py" $ARGUMENTS --emit=compact --no-native-web
-```
-
-Now bash word-splits the expansion: `python3 script.py AI video tools --store --emit=compact`
-
-### 2. Change argparse `topic` from `nargs="?"` to `nargs="*"`
-
-**File:** `scripts/last30days.py:1040`
-
-```python
-# Before:
-parser.add_argument("topic", nargs="?", help="Topic to research")
-# args.topic = "AI video tools" (single string) or None
-
-# After:
-parser.add_argument("topic", nargs="*", help="Topic to research")
-# args.topic = ["AI", "video", "tools"] (list) or []
-```
-
-Then immediately after `parser.parse_args()` (line 1124), join the list back to a string:
-
-```python
-args = parser.parse_args()
-args.topic = " ".join(args.topic) if args.topic else None
-```
-
-**Why this works for both invocation styles:**
-
-| Invocation | argparse receives | topic result |
-|---|---|---|
-| `script.py AI video tools --store` (Claude Code) | `["AI", "video", "tools"]` + `--store` | `"AI video tools"` |
-| `script.py "AI video tools" --store` (direct CLI) | `["AI video tools"]` + `--store` | `"AI video tools"` |
-| `script.py --store` (no topic) | `[]` + `--store` | `None` |
-
-### 3. Document missing flags in SKILL.md Options section
-
-**File:** `SKILL.md:223-227`
-
-Add after the existing `--deep` line:
-
-```
-- `--store` -> Persist findings to SQLite database for later querying
-- `--search=SOURCES` -> Comma-separated source filter (e.g., `--search=reddit,hn`)
-- `--include-web` -> Include general web search alongside primary sources
-- `--diagnose` -> Show source availability diagnostics and exit
-- `--timeout=SECS` -> Global timeout in seconds (default: 180, quick: 90, deep: 300)
-```
-
-Note: `--sort-x` was listed in the issue but does not exist in the Python argparse. Skip it.
-
-## Acceptance Criteria
-
-- [x] `/last30days AI video tools --store` correctly passes `--store` to Python script
-- [x] `/last30days AI video tools` still works (multi-word topic without flags)
-- [x] Direct CLI: `python3 last30days.py "AI video tools" --store` still works
-- [x] `--diagnose`, `--search=reddit,hn`, `--timeout=120` all forward correctly
-- [x] All 5 missing flags documented in SKILL.md Options section
-- [x] Existing tests pass (`python3 -m pytest tests/`)
-
-## Files Changed
-
-| File | Change |
-|---|---|
-| `SKILL.md:168` | Remove quotes around `$ARGUMENTS` |
-| `scripts/last30days.py:1040` | `nargs="?"` -> `nargs="*"` |
-| `scripts/last30days.py:1124` | Add `args.topic = " ".join(args.topic) if args.topic else None` |
-| `SKILL.md:223-227` | Add 5 missing flags to Options section |
-
-## Sources
-
-- GitHub issue: https://github.com/mvanhorn/last30days-skill/issues/36
-- Reporter: @nicolefinateri
diff --git a/docs/plans/2026-03-03-fix-triage-all-open-github-issues-plan.md b/docs/plans/2026-03-03-fix-triage-all-open-github-issues-plan.md
deleted file mode 100644
index 040eb84..0000000
--- a/docs/plans/2026-03-03-fix-triage-all-open-github-issues-plan.md
+++ /dev/null
@@ -1,479 +0,0 @@
-# Triage All Open GitHub Issues — Proposed Actions
-
-**Date:** 2026-03-03
-**Repo:** `mvanhorn/last30days-skill`
-**Open issues:** 16 (as of this triage)
-**Codebase version:** v2.1.0 (Bird vendored, 7 sources: Reddit, X, YouTube, HN, Polymarket, TikTok, Web)
-
----
-
-## Legend
-
-| Action | Meaning |
-|--------|---------|
-| **CLOSE (spam)** | Spam / solicitation — close without comment |
-| **CLOSE (resolved)** | Already fixed in current version — comment & close |
-| **CLOSE (duplicate)** | Duplicate of another issue — link & close |
-| **COMMENT** | Respond with info, no code change needed |
-| **FIX** | Code or docs change needed — described below |
-| **REJECT** | Valid issue but won't address — explain why |
-
----
-
-## Issue-by-Issue Triage
-
-### #43 — Security Assessment Offer - Claude Code Skill with Social Media APIs
-**Author:** Neo-Assistent | **Created:** 2026-02-26
-
-> Unsolicited marketing from "SkillSec" offering a free security audit.
-
-**Proposed action:** **CLOSE (spam)**
-
-No comment needed. This is a cold sales pitch, not a bug or feature request. Close silently or with a brief "closing — not a bug report or feature request."
-
----
-
-### #42 — License & Secondary Development Permission
-**Author:** AllenX95 | **Created:** 2026-02-26
-
-> Asks what license the project uses and whether modification for open-source models (Kimi K2.5) is allowed.
-
-**Proposed action:** **COMMENT + CLOSE (resolved)**
-
-Once #35/#34 are addressed (LICENSE file added), comment:
-
-> The project is MIT licensed (see LICENSE file in repo root, added in v2.1.x). You're free to modify, fork, and adapt it for any purpose including integration with other models. The MIT license places no restrictions on secondary development.
-
-**Depends on:** Adding the LICENSE file (see #35/#34 below).
-
----
-
-### #41 — Can I use openrouter API to replace with openai API to do the reddit search?
-**Author:** iklynow-hue | **Created:** 2026-02-25
-
-> Asks about using OpenRouter instead of OpenAI for Reddit search. Has one off-topic spam comment from @gbessoni.
-
-**Proposed action:** **COMMENT + CLOSE**
-
-Comment:
-
-> OpenRouter is already partially supported — `scripts/lib/openrouter_search.py` provides a Perplexity-via-OpenRouter integration for web search. However, the Reddit search specifically uses OpenAI's Responses API (`web_search_preview` tool), which is an OpenAI-specific feature that OpenRouter doesn't proxy.
->
-> If you want to avoid OpenAI entirely, you can still get results from X (via vendored Bird search or xAI), YouTube (yt-dlp), Hacker News (free API), and Polymarket (free API) — no OpenAI key needed for those. Reddit is the one source that requires `OPENAI_API_KEY`.
->
-> For budget control, consider using `--quick` mode which reduces API calls.
-
-Also: **minimize/hide** the off-topic spam comment from @gbessoni ("I'm testing out a new idea for reddit called Redd").
-
----
-
-### #40 — watchlist: search_queries field stored but never used by _run_topic()
-**Author:** tejasgadhia | **Created:** 2026-02-23
-
-> `add --queries "q1,q2,q3"` stores queries in DB but `_run_topic()` always passes `topic["name"]` as the search query. Long descriptive names produce poor results on X and web search.
-
-**Proposed action:** **FIX (accept)**
-
-This is a legitimate, well-documented bug. The fix is straightforward:
-
-**File:** `scripts/watchlist.py` — `_run_topic()` (~line 136)
-
-```python
-# Current (broken):
-cmd = [sys.executable, str(SCRIPT_DIR / "last30days.py"), topic["name"], "--emit=json"]
-
-# Fix: prefer search_queries when available
-import json as _json
-queries = _json.loads(topic["search_queries"]) if topic.get("search_queries") else [topic["name"]]
-search_term = queries[0] if queries else topic["name"]
-cmd = [sys.executable, str(SCRIPT_DIR / "last30days.py"), search_term, "--emit=json"]
-```
-
-**Comment to post:**
-
-> Good catch — you're right that `search_queries` is stored but never read. Will fix `_run_topic()` to prefer `search_queries[0]` over `topic["name"]` when available.
-
----
-
-### #39 — watchlist.py: YouTube findings never stored + run-one silent output
-**Author:** tejasgadhia | **Created:** 2026-02-23
-
-> **Bug 1:** YouTube findings extracted by the research script but dropped in `_run_topic()` because there's no YouTube loop in the findings extraction.
-> **Bug 2:** `cmd_run_one()` doesn't print the result (cosmetic).
-
-**Proposed action:** **FIX (accept both)**
-
-Both are real bugs with clear fixes provided by the reporter.
-
-**Bug 1 fix** — `scripts/watchlist.py` findings extraction (~lines 170-193): add YouTube + TikTok loops:
-
-```python
-for item in data.get("youtube", []):
- findings.append({
- "source": "youtube",
- "url": item.get("url", ""),
- "title": item.get("title", ""),
- "author": item.get("channel_name", item.get("channel", "")),
- "content": item.get("transcript_snippet", "") or item.get("title", ""),
- "engagement_score": (item.get("engagement") or {}).get("views", 0),
- "relevance_score": item.get("relevance", 0),
- })
-
-for item in data.get("tiktok", []):
- findings.append({
- "source": "tiktok",
- "url": item.get("url", ""),
- "title": item.get("caption_snippet", "")[:120],
- "author": item.get("author", ""),
- "content": item.get("caption_snippet", ""),
- "engagement_score": (item.get("engagement") or {}).get("views", 0),
- "relevance_score": item.get("relevance", 0),
- })
-```
-
-**Bug 2 fix** — `scripts/watchlist.py` `cmd_run_one()`:
-
-```python
-result = _run_topic(topic)
-print(json.dumps(result, default=str))
-```
-
-**Comment to post:**
-
-> Both confirmed. Fixing YouTube (and adding TikTok while we're at it) findings extraction, plus `run-one` output. Thanks for the detailed report.
-
----
-
-### #36 — SKILL.md doesn't forward --store, --include-web, --sort-x, --diagnose, --timeout to script
-**Author:** nicolefinateri | **Created:** 2026-02-21
-
-> `"$ARGUMENTS"` is passed as a single quoted string, so argparse treats the whole thing as the topic positional. Flags like `--store` get swallowed. Also, 5 flags are undocumented in SKILL.md.
-
-**Proposed action:** **COMMENT + CLOSE (resolved / won't fix)**
-
-This needs investigation. The `"$ARGUMENTS"` expansion in SKILL.md (line 168) relies on Claude Code's variable expansion behavior. If Claude Code expands `$ARGUMENTS` before the shell sees it, the quoting around it means all words become one argument. However, in practice, Claude Code injects the literal text into the bash script, so `"$ARGUMENTS"` expands to `"best AI tools --store"` which the shell passes as a single string to Python — and Python's argparse sees it as one positional arg.
-
-**However:** Looking at the actual SKILL.md line 168:
-```bash
-python3 "${SKILL_ROOT}/scripts/last30days.py" "$ARGUMENTS" --emit=compact
-```
-
-This is a known Claude Code behavior — `$ARGUMENTS` is expanded by the template engine, not the shell. The expansion produces the raw text, so the double quotes cause everything to be one argument.
-
-**The real fix:** Remove the quotes around `$ARGUMENTS`:
-
-```bash
-python3 "${SKILL_ROOT}/scripts/last30days.py" $ARGUMENTS --emit=compact
-```
-
-This lets word-splitting happen so argparse sees separate positional + flag args.
-
-**Risk:** Topic names with spaces would break (e.g., `AI video tools` would become 3 separate args). Need to verify how Claude Code handles `$ARGUMENTS` expansion. If it's truly a template variable replaced before bash execution, then the fix is more nuanced — may need to parse flags out in bash first.
-
-**Comment to post:**
-
-> Thanks for the detailed report. The `$ARGUMENTS` quoting is tricky — it's a Claude Code template variable, not a shell variable. I'll investigate the exact expansion behavior and fix the forwarding. Re: undocumented flags — will add them to the Options section.
-
-**Priority:** Medium — flags like `--store` and `--diagnose` are useful but not critical path.
-
----
-
-### #35 — Add LICENSE file to repository
-**Author:** HackJob7418 | **Created:** 2026-02-21
-
-> Points out that `plugin.json` declares MIT but no LICENSE file exists in repo root.
-
-**Proposed action:** **FIX (accept)**
-
-Trivial fix. Add `LICENSE` file with standard MIT text to repo root.
-
-**Comment to post:**
-
-> You're right — added MIT LICENSE file to repo root. Thanks for catching the mismatch.
-
----
-
-### #34 — License missing
-**Author:** jdsika (Carlo van Driesten) | **Created:** 2026-02-21
-
-> Same request as #35 — add a LICENSE file.
-
-**Proposed action:** **CLOSE (duplicate of #35)**
-
-**Comment to post:**
-
-> Duplicate of #35 — adding MIT LICENSE file. Thanks for the nudge!
-
----
-
-### #32 — Cannot install Claude Code plugin marketplace
-**Author:** rayshan (Ray Shan) | **Created:** 2026-02-19
-
-> `marketplace.json` schema validation fails: `plugins.0.source: Invalid input`
-
-**Proposed action:** **FIX (investigate + fix)**
-
-The current `marketplace.json` has:
-```json
-"plugins": [{"name": "last30days", "source": "."}]
-```
-
-The Anthropic marketplace schema may require `source` to be a URL or a path in a specific format, not bare `.`. Need to check the expected schema.
-
-**Possible fix:** Update `source` to match whatever the skills.sh/marketplace validator expects. Likely needs to be a relative path to the SKILL.md or plugin directory:
-
-```json
-"plugins": [{"name": "last30days", "source": "./SKILL.md"}]
-```
-
-Or the schema may have changed since the file was written. Check skills.sh docs.
-
-**Comment to post:**
-
-> Thanks for the report + screenshot. The marketplace.json schema likely changed — I'll update the `source` field format. Can you share what version of the `skills` CLI you're using? (`npx skills --version`)
-
-**Priority:** High — blocks installation for marketplace users.
-
----
-
-### #31 — mvanhorn/last30days is being trashed on skills.sh
-**Author:** PiotrAleksander (Piotr Mrzygłosz) | **Created:** 2026-02-19
-
-> skills.sh security audit gives the skill bad scores. A mirror at `sickn33/antigravity-awesome-skills` has better scores.
-
-**Proposed action:** **COMMENT + investigate**
-
-This is a reputation/trust issue. The skills.sh audit likely flags things like:
-- No LICENSE file (fixed by #35)
-- Shell execution in SKILL.md (inherent to how the skill works)
-- External API calls (Reddit, X, YouTube — core functionality)
-- No pinned dependencies
-
-**Comment to post:**
-
-> Thanks for flagging this. A few things:
->
-> 1. The missing LICENSE file (#35) is being added — that should help the audit score.
-> 2. The skill necessarily makes external API calls (that's its core purpose — researching across platforms). Any "security warning" about API calls is expected behavior, not a vulnerability.
-> 3. The mirror you found (`sickn33/antigravity-awesome-skills`) may be an older fork (v1) that doesn't include the more recent integrations. Simpler code = fewer audit flags, but also fewer features.
-> 4. I'll review the specific audit findings on skills.sh and address any legitimate concerns.
->
-> If you can share the specific warnings you're seeing, I can address them directly.
-
-**Priority:** Medium — reputation matters but the core issue is likely the missing LICENSE + inherent design of making API calls.
-
----
-
-### #30 — Bird cookie auth: source availability mapping misses reddit-web + Bird combination
-**Author:** volarian-vai | **Created:** 2026-02-19
-
-> When using Bird cookie auth without `XAI_API_KEY` but with a web search key (e.g., `BRAVE_API_KEY`), the source override logic misses the `reddit-web` → `all` mapping.
-
-**Proposed action:** **FIX (accept)**
-
-Legitimate edge case in source availability logic. The fix is a small addition to the Bird override block in `scripts/last30days.py` (~line 917):
-
-```python
-if x_source == 'bird':
- if available == 'reddit':
- available = 'both'
- elif available == 'reddit-web':
- available = 'all'
- elif available == 'web':
- available = 'x-web'
-```
-
-**Comment to post:**
-
-> Good catch on the missing `reddit-web` case. The fix is straightforward — adding the mapping. Also fixing `web` → `x-web` as you noted.
-
-**Priority:** Low-medium — affects a specific env var combination (Bird auth + no XAI key + Brave key).
-
----
-
-### #29 — YouTube stats show 'yt-dlp not installed' when search returns 0 results
-**Author:** alexkrivov | **Created:** 2026-02-19
-
-> When yt-dlp runs successfully but returns 0 results, the stats footer says "yt-dlp not installed" because `report.youtube` is `[]` (falsy) and the fallback message is wrong.
-
-**Proposed action:** **FIX (accept)**
-
-Clear bug with a clear root cause. Two-part fix:
-
-**1.** In `scripts/last30days.py` (~line 1085), set a proper skip reason when YouTube returns 0:
-
-```python
-if has_ytdlp and not yt_results:
- source_info["youtube_skip_reason"] = "0 results (query may be too specific)"
-```
-
-**2.** In `scripts/lib/render.py` (~line 288), change the default fallback:
-
-```python
-reason = source_info.get("youtube_skip_reason", "not available")
-```
-
-**Comment to post:**
-
-> Confirmed — the `[]`-is-falsy bug plus a misleading default string. Will fix both the skip reason and the fallback. Thanks for the detailed trace.
-
-**Priority:** Low — cosmetic/UX but misleading for users trying to debug setup.
-
----
-
-### #22 — Feature request: Surface more Bird CLI capabilities (engagement sorting, time windows, thread following)
-**Author:** nicolefinateri | **Created:** 2026-02-09
-
-> Requests: (1) `--sort-x likes` flag, (2) `--since 3h` granular time windows, (3) thread following via `bird thread `.
-
-**Proposed action:** **COMMENT (partial accept, defer)**
-
-Good feature requests but some are already partially addressed:
-
-1. **Engagement sorting** — The `--sort-x=MODE` flag already exists in `last30days.py` argparse (line ~1080) but isn't documented in SKILL.md (related to #36). Sorting by `likes`, `engagement`, `recent`, or `score` is already implemented.
-
-2. **Granular time windows** — The `--days` flag exists (1-30), but hour-level granularity (`--since 3h`) would be new. Low priority since most research use cases are days-scale.
-
-3. **Thread following** — Would be a significant feature addition. The vendored Bird search in v2.1 may not expose thread fetching. Would need investigation.
-
-**Comment to post:**
-
-> Great ideas! Update on each:
->
-> 1. **Engagement sorting:** `--sort-x=MODE` already exists in the script (likes, engagement, recent, score). It wasn't documented in SKILL.md — fixing that (#36). Should work if you call the Python script directly.
-> 2. **Granular time windows:** Interesting idea. Currently `--days=N` goes down to 1 day. Hour-level would need a new flag and Bird-specific query modification. Adding to backlog.
-> 3. **Thread following:** Love this in principle — high-engagement thread starters are gold. Would need to assess what the vendored Bird search supports. May be a v2.2+ feature.
->
-> A PR for any of these would be welcome!
-
-**Priority:** Low — nice-to-haves; #1 is already done, #2/#3 are backlog.
-
----
-
-### #19 — Bird is missing
-**Author:** brianjking (Brian J King) | **Created:** 2026-02-09
-
-> Bird GitHub repo is gone. Comments confirm npm package is deprecated, Homebrew install fails. Multiple users affected.
-
-**Proposed action:** **CLOSE (resolved in v2.1)**
-
-This was a major pain point that drove the v2.1 vendoring decision. Bird's GraphQL client is now bundled directly in the repo at `scripts/lib/vendor/bird-search/`. No external npm install needed.
-
-**Comment to post:**
-
-> **Resolved in v2.1.0** (released Feb 15, 2026).
->
-> Bird's Twitter GraphQL search client is now vendored directly into the skill at `scripts/lib/vendor/bird-search/`. No npm install, no Homebrew, no external dependency. Just needs Node.js 22+ in your PATH.
->
-> If you're on an older version, update to v2.1:
-> ```
-> npx skills add https://github.com/mvanhorn/last30days-skill
-> ```
->
-> Authentication still works via Safari cookies (auto-detected) or `AUTH_TOKEN`/`CT0` environment variables. See README for setup.
->
-> Thanks to everyone who reported this — it's what motivated bundling the search client directly.
-
----
-
-### #4 — Add macOS Python SSL certificate prerequisite to README
-**Author:** joshdaws (Josh Daws) | **Created:** 2026-01-27
-
-> Python.org macOS installer doesn't include SSL certificates. Users get `CERTIFICATE_VERIFY_FAILED` errors on all API calls.
-
-**Proposed action:** **FIX (accept — docs change)**
-
-Add a troubleshooting section to README.md. The reporter's suggested text is good. This only affects python.org installs (not Homebrew), but it's a confusing error when hit.
-
-**Comment to post:**
-
-> Good call — adding a troubleshooting section to the README for this. The `certifi` fallback is interesting but adds a dependency; the docs fix is simpler and sufficient since Homebrew Python (the most common Claude Code setup) isn't affected.
-
-**Priority:** Low — affects a subset of macOS users, but easy to fix with docs.
-
----
-
-### #2 — npx skills add fails for this package
-**Author:** mikecfisher (Mike Fisher) | **Created:** 2026-01-26
-
-> `npx skills add` can't find any skills in the repo. Owner commented that PR #1 restructures to standard plugin format.
-
-**Proposed action:** **CLOSE (resolved)**
-
-The repo was restructured to standard plugin format in PR #1 (SKILL.md at root, .claude-plugin/ directory). The `skills` CLI should now detect the skill correctly. If #32's marketplace.json schema issue is also fixed, this should be fully resolved.
-
-**Comment to post:**
-
-> This was fixed in the repo restructuring (PR #1, merged Jan 27). The skill now has a proper `SKILL.md` at root + `.claude-plugin/` directory.
->
-> If you're still having trouble, it may be the marketplace.json schema issue tracked in #32. Please try again and reopen if the problem persists.
-
----
-
-## Summary Table
-
-| # | Title | Action | Priority | Effort |
-|---|-------|--------|----------|--------|
-| **43** | Security Assessment Offer | **CLOSE (spam)** | — | None |
-| **42** | License & Secondary Dev Permission | **COMMENT + CLOSE** | Low | None (after #35) |
-| **41** | OpenRouter API replacement | **COMMENT + CLOSE** | Low | None |
-| **40** | watchlist: search_queries unused | **FIX** | Medium | Small (~10 lines) |
-| **39** | watchlist: YouTube not stored + silent run-one | **FIX** | Medium | Small (~25 lines) |
-| **36** | SKILL.md flag forwarding | **COMMENT + investigate** | Medium | Medium (SKILL.md rewrite) |
-| **35** | Add LICENSE file | **FIX** | High | Trivial (1 file) |
-| **34** | License missing | **CLOSE (dup of #35)** | — | None |
-| **32** | marketplace.json schema error | **FIX** | High | Small (schema update) |
-| **31** | skills.sh bad audit scores | **COMMENT** | Medium | None (after #35) |
-| **30** | Bird source mapping edge case | **FIX** | Low | Small (~3 lines) |
-| **29** | YouTube "not installed" false message | **FIX** | Low | Small (~5 lines) |
-| **22** | Bird feature requests | **COMMENT (partial accept)** | Low | None (backlog) |
-| **19** | Bird is missing | **CLOSE (resolved v2.1)** | — | None |
-| **4** | macOS SSL certificate docs | **FIX (docs)** | Low | Small (README section) |
-| **2** | npx skills add fails | **CLOSE (resolved)** | — | None |
-
----
-
-## Recommended Execution Order
-
-### Batch 1 — Quick wins (close/comment only, no code)
-1. Close #43 (spam)
-2. Close #34 (dup of #35)
-3. Close #19 (resolved in v2.1)
-4. Close #2 (resolved by repo restructure)
-5. Comment + close #41 (OpenRouter question)
-6. Comment #22 (Bird feature requests — partial accept, backlog)
-7. Comment #31 (skills.sh audit — will improve after LICENSE fix)
-
-### Batch 2 — Trivial fixes
-8. **#35** — Add MIT LICENSE file to repo root
-9. Comment + close #42 (license question — now answered by LICENSE file)
-
-### Batch 3 — Small code fixes
-10. **#29** — Fix YouTube "not installed" false message (`render.py` + `last30days.py`)
-11. **#30** — Fix Bird source mapping for `reddit-web` combo (`last30days.py`)
-12. **#40** — Fix watchlist `search_queries` usage (`watchlist.py`)
-13. **#39** — Fix watchlist YouTube/TikTok extraction + `run-one` output (`watchlist.py`)
-
-### Batch 4 — Investigation needed
-14. **#32** — Fix marketplace.json schema (needs schema research)
-15. **#36** — Fix `$ARGUMENTS` flag forwarding (needs Claude Code expansion testing)
-16. **#4** — Add SSL troubleshooting to README
-
----
-
-## Proposed Comment Templates
-
-### For spam (#43):
-> Closing — this is not a bug report or feature request.
-
-### For duplicates (#34):
-> Duplicate of #35. Adding MIT LICENSE file — thanks!
-
-### For resolved issues (#19, #2):
-> Resolved in v2.1.0. [details specific to issue]. Closing — please reopen if you're still experiencing this on the latest version.
-
-### For questions (#41, #42):
-> [Answer the question]. Closing as answered — feel free to reopen if you have follow-ups.
-
-### For accepted bugs (#29, #30, #39, #40):
-> Confirmed — [brief acknowledgment]. Fix incoming. Thanks for the detailed report.
diff --git a/docs/plans/2026-03-03-refactor-tiktok-apify-alternative-plan.md b/docs/plans/2026-03-03-refactor-tiktok-apify-alternative-plan.md
deleted file mode 100644
index 9b5718d..0000000
--- a/docs/plans/2026-03-03-refactor-tiktok-apify-alternative-plan.md
+++ /dev/null
@@ -1,156 +0,0 @@
-# refactor: Replace Apify with pay-as-you-go TikTok API
-
-**Type:** refactor
-**Date:** 2026-03-03
-**Status:** Draft
-
-## Problem
-
-Apify requires a monthly subscription even for low-volume usage. We need a true pay-as-you-go TikTok data API that returns structured video data (views, likes, comments, shares, author, hashtags, captions) from keyword search.
-
-## Current Architecture
-
-Our TikTok integration makes **exactly 2 API calls per /last30days invocation**:
-
-1. **Search call** — keyword search, returns 10-40 videos with engagement metrics
-2. **Caption enrichment call** — fetches spoken-word subtitles for top 3-8 videos
-
-We consume these fields from the response:
-- `id`, `text` (description), `webVideoUrl`, `authorMeta.name`
-- `playCount`, `diggCount`, `commentCount`, `shareCount`
-- `hashtags[].name`, `videoMeta.duration`
-- `createTimeISO` or `createTime` (date)
-- `subtitleText` / `subtitles` (captions, secondary call)
-
-Files involved:
-- `scripts/lib/tiktok.py` — search, parse, relevance scoring, caption fetching
-- `scripts/lib/apify_client_wrapper.py` — shared Apify client (also designed for future FB/IG)
-- `scripts/lib/schema.py` — `TikTokItem` dataclass
-- `scripts/lib/normalize.py` — `normalize_tiktok()`
-- `scripts/last30days.py` — orchestrator (calls `tiktok.search_and_enrich()`)
-
-## Why Most of Those Alternatives Won't Work
-
-The services in the user's table (Spider, Zyte, Scrappey, Serper.dev) are **general web scrapers** — they return raw HTML, not structured TikTok data. We'd have to build our own HTML parser, handle anti-bot protections, and reverse-engineer TikTok's response format. That's a completely different (and fragile) approach.
-
-What we need is a **TikTok-specific API** that returns structured JSON with engagement metrics from keyword search.
-
-## Alternatives Evaluated
-
-### RECOMMENDED: ScrapeCreators — Best True Pay-As-You-Go
-
-| Attribute | Details |
-|-----------|---------|
-| **TikTok structured data** | Yes — 19 dedicated endpoints including keyword search |
-| **Pricing model** | True PAYG — buy credits, credits never expire |
-| **Cost at our volume** | ~$0.60/month ($10 buys 5,000 credits, lasts 16-33 months) |
-| **Free tier** | 100-10,000 free credits on signup (no credit card) |
-| **Python SDK** | No dedicated SDK — simple REST API (`requests.get()`) |
-| **Search endpoint** | "Search by Keyword" and "Top Search" |
-| **Risk** | Newer service, limited track record |
-
-**Why it's the best fit:** $10 literally lasts over a year at our volume. No subscription, no expiring credits. The lack of a Python SDK is irrelevant — it's a single `requests.get()` call.
-
-### Runner-up: EnsembleData — Best SDK, But Subscription
-
-| Attribute | Details |
-|-----------|---------|
-| **TikTok structured data** | Full — 15+ endpoints, all engagement metrics |
-| **Pricing model** | Monthly subscription ($100/mo after 7-day trial) |
-| **Cost at our volume** | $0 during trial (50 units/day), $100/mo after |
-| **Free tier** | 50 units/day for 7 days, no CC required |
-| **Python SDK** | Yes — `pip install ensembledata` |
-| **Search endpoint** | "Keyword Search" returns ~20 posts/call (1 unit) |
-| **Risk** | $100/mo is overkill for 5-10 searches/day |
-
-**Verdict:** Best data quality and SDK, but $100/mo is absurd for our ~150-300 requests/month. Same subscription problem as Apify.
-
-### Backup: tikwm.com — Free but Risky
-
-| Attribute | Details |
-|-----------|---------|
-| **TikTok structured data** | Likely yes (needs live testing) |
-| **Pricing model** | Completely free, no API key required |
-| **Cost at our volume** | $0 |
-| **Free tier** | 5,000 requests/day |
-| **Python SDK** | Community wrappers (damirTAG/TikTok-Module, kittenbark/tikwm) |
-| **Search endpoint** | `https://www.tikwm.com/api/feed/search?keywords=TERM&count=20` |
-| **Risk** | Unaffiliated third-party, could disappear anytime, no SLA |
-
-**Verdict:** Great for development/testing. Too risky as sole production backend. Could be a zero-cost fallback.
-
-### Not Recommended
-
-| Service | Why Not |
-|---------|---------|
-| **TikAPI** | $50-189/mo subscription — same problem as Apify |
-| **Bright Data** | $499/mo minimum — enterprise pricing |
-| **davidteather/TikTok-Api** | Video search is broken, requires Playwright, fragile |
-| **Spider, Zyte, Scrappey** | Generic scrapers — return raw HTML, no TikTok structure |
-| **Piloterr** | No TikTok endpoints, subscription only |
-
-## Recommended Approach
-
-### Option A: ScrapeCreators as primary (Recommended)
-
-- [ ] Sign up for ScrapeCreators, get free credits
-- [ ] Test the "Search by Keyword" endpoint to verify it returns all required fields
-- [ ] Refactor `tiktok.py` to use ScrapeCreators REST API instead of Apify actor
-- [ ] Keep `apify_client_wrapper.py` for future FB/IG (or refactor to generic wrapper)
-- [ ] Update `.env` config: `SCRAPECREATORS_API_KEY` replaces `APIFY_API_TOKEN` for TikTok
-- [ ] Update README, SKILL.md installation instructions
-- [ ] Buy $10 credits after confirming it works
-
-### Option B: tikwm.com as primary (Zero cost, higher risk)
-
-- [ ] Test tikwm.com search endpoint to verify response schema
-- [ ] If it returns engagement metrics, implement as primary backend
-- [ ] Add ScrapeCreators as paid fallback when tikwm fails
-- [ ] No API key required — simplest user setup
-
-### Option C: Keep Apify, document the subscription requirement
-
-- [ ] Update README to clarify Apify requires a paid plan
-- [ ] Add note about the free $5/mo credits tier (if it still works without subscription)
-- [ ] Ship as-is with clear billing expectations
-
-## Implementation Plan (Option A)
-
-### Phase 1: Validate ScrapeCreators API
-
-- [ ] Sign up and get API key
-- [ ] Test keyword search endpoint: `GET /tiktok/search?keyword={topic}&count=20`
-- [ ] Verify response contains: video ID, play count, likes, comments, shares, author, hashtags, date, description
-- [ ] Test caption/subtitle availability (or confirm description text is sufficient)
-
-### Phase 2: Swap the Backend
-
-- [ ] Create `scripts/lib/scrapecreators_client.py` (simple REST wrapper, ~40 lines)
-- [ ] Refactor `tiktok.py:search_tiktok()` to call ScrapeCreators instead of Apify
-- [ ] Map ScrapeCreators response fields to our existing item dict format
-- [ ] Refactor `tiktok.py:fetch_captions()` — check if ScrapeCreators provides subtitles, else use description text only
-- [ ] Update `env.py` to read `SCRAPECREATORS_API_KEY` (keep `APIFY_API_TOKEN` for backward compat)
-- [ ] Update `scripts/lib/ui.py` spinner messages if needed
-
-### Phase 3: Update Docs & Ship
-
-- [ ] Update README.md installation section (new API key)
-- [ ] Update SKILL.md
-- [ ] Update `~/.config/last30days/.env` locally
-- [ ] Run full test suite
-- [ ] Commit and push
-
-## Acceptance Criteria
-
-- [ ] TikTok search returns structured data with views, likes, comments, shares
-- [ ] No subscription required — pay-as-you-go only
-- [ ] Cost < $1/month at normal usage (5-10 searches/day)
-- [ ] Existing test suite passes with new backend
-- [ ] Graceful degradation when API key is missing (same behavior as today)
-
-## Open Questions
-
-1. Does ScrapeCreators' keyword search support date filtering, or do we filter post-API like we do with Apify?
-2. Does ScrapeCreators return subtitle/caption data, or just video descriptions?
-3. What's the response time? Apify actors took 30-120 seconds. REST APIs should be faster.
-4. Should we keep Apify as a fallback backend (user configures one or the other)?
diff --git a/docs/plans/2026-03-04-feat-instagram-facebook-source-plan.md b/docs/plans/2026-03-04-feat-instagram-facebook-source-plan.md
deleted file mode 100644
index bd79f37..0000000
--- a/docs/plans/2026-03-04-feat-instagram-facebook-source-plan.md
+++ /dev/null
@@ -1,210 +0,0 @@
-# feat: Add Instagram and Facebook Sources via ScrapeCreators API
-
-**Date:** 2026-03-04
-**Type:** Enhancement
-**Priority:** Instagram first, Facebook conditional ("if it's good")
-
-## Summary
-
-Add Instagram Reels and Facebook as new research sources in last30days, using the same ScrapeCreators REST API already powering TikTok. Instagram is the primary target; Facebook is a follow-on if the pattern works well.
-
-Both sources share the existing `SCRAPECREATORS_API_KEY` — no new API keys needed.
-
-## Approach
-
-Replicate the TikTok integration pattern exactly. Each source follows the same 8-step pipeline:
-
-```
-tiktok.py pattern → instagram.py (new) → facebook.py (new, conditional)
-```
-
-## ScrapeCreators API Endpoints
-
-### Instagram
-
-| Endpoint | Path | Params | Credits | Notes |
-|----------|------|--------|---------|-------|
-| **Search Reels** | `GET /v1/instagram/reels/search` | `keyword`, pagination | 1 per 10 reels, max 60/req | Keyword search via Google (IG search requires login). V2 also available. |
-| **Transcript** | `GET /v2/instagram/media/transcript` | `url` | 1 | Returns `{transcripts: [{id, shortcode, text}]}`. Videos <2min only. |
-| **Comments** | `GET /v2/instagram/post/comments` | `url`, `cursor` | 1 | Returns `{comments: [{id, text, created_at, user}]}`. 100-300 per call. |
-| **User Reels** | `GET /v1/instagram/user/reels` | `handle` or `user_id`, `max_id`, `trim` | 1 | All reels from a profile. Response: `{items: [...], paging_info}` |
-
-**Primary search strategy:** `/v1/instagram/reels/search` with keyword param for topic search. This is the analog to TikTok's `/search/keyword`.
-
-**Response fields per reel item:**
-- `pk` / `code` (shortcode) — reel ID
-- `taken_at` — unix timestamp
-- `play_count` / `ig_play_count` — views
-- `like_count` — likes
-- `comment_count` — comments
-- `video_duration` — seconds
-- `has_audio` — boolean
-- `user` object — username, full_name, is_verified, profile_pic_url
-- `caption` object — text content
-- Media URLs for thumbnails and video versions
-
-### Facebook
-
-| Endpoint | Path | Params | Credits | Notes |
-|----------|------|--------|---------|-------|
-| **Profile Posts** | `GET /v1/facebook/profile/posts` | `url` or `pageId`, `cursor` | 1 | Returns 3 posts at a time with engagement |
-| **Profile Reels** | `GET /v1/facebook/profile/reels` | similar | 1 | 10 reels at a time |
-| **Post** | `GET /v1/facebook/post` | `url` | 1 | Single post/reel by URL |
-| **Transcript** | `GET /v1/facebook/post/transcript` | `url` | 1 | Video transcript, <2min |
-| **Comments** | `GET /v1/facebook/post/comments` | `url`, `feedback_id` | 1 | Post/reel comments |
-
-**Facebook limitation:** No keyword search endpoint. Only profile-based scraping (3 posts at a time). This makes Facebook significantly less useful for topic-based research vs. Instagram's keyword search.
-
-**Response fields per post:**
-- `id` — post ID
-- `text` — post content
-- `url` / `permalink` — post URL
-- `author` — `{name, short_name, id}`
-- `reactionCount` — total reactions
-- `commentCount` — comments
-- `videoViewCount` — video views (if applicable)
-- `publishTime` — unix timestamp
-- `topComments` — array of `{id, text, publishTime, author}`
-
-## Implementation Plan
-
-### Phase 1: Instagram Source (Primary)
-
-#### 1.1 Create `scripts/lib/instagram.py`
-- [x] Copy structure from `scripts/lib/tiktok.py`
-- [x] Change `SCRAPECREATORS_BASE` to `"https://api.scrapecreators.com"`
-- [x] Implement `search_instagram()` → calls `/v1/instagram/reels/search`
- - Params: `keyword=core_topic`
- - Parse response `items` array
- - Extract: `pk`/`code` as video_id, `taken_at` as date, `play_count`/`like_count`/`comment_count` as engagement, `user.username` as author, `caption.text` as text
- - Build URL: `https://www.instagram.com/reel/{code}`
- - Reuse `_extract_core_subject()`, `_compute_relevance()`, `_tokenize()` from tiktok.py (or factor into shared util)
- - Apply date range filter, sort by views descending
-- [x] Implement `fetch_captions()` → calls `/v2/instagram/media/transcript`
- - For top N items (per depth config), fetch transcript
- - Response: `{transcripts: [{id, shortcode, text}]}`
- - Fallback to caption text if transcript unavailable
- - Truncate to 500 words
-- [x] Implement `search_and_enrich()` → combines search + captions
-- [x] Implement `parse_instagram_response()` → returns `response.get("items", [])`
-- [x] Reuse shared helpers: `_sc_headers()`, `_log()`, `_clean_webvtt()`, `DEPTH_CONFIG`, `STOPWORDS`, `SYNONYMS`
-
-**Key difference from TikTok:** Instagram response uses `play_count`/`like_count`/`comment_count` directly (no `statistics` wrapper), `user.username` (not `author.unique_id`), `caption.text` (not `desc`), `taken_at` (not `create_time`), `code` shortcode for URL construction.
-
-#### 1.2 Add `InstagramItem` to `scripts/lib/schema.py`
-- [x] Add dataclass mirroring `TikTokItem` structure:
- ```python
- @dataclass
- class InstagramItem:
- id: str # "IG1", "IG2", ...
- text: str # caption text
- url: str # https://www.instagram.com/reel/{code}
- author_name: str # Instagram handle
- date: Optional[str] # YYYY-MM-DD from taken_at
- date_confidence: str # "high"
- engagement: Optional[Engagement] # views, likes, num_comments
- caption_snippet: str # transcript or caption text
- hashtags: List[str] # extracted from caption
- relevance: float
- why_relevant: str
- subs: SubScores
- score: int
- cross_refs: List[str]
- ```
-
-#### 1.3 Add normalization to `scripts/lib/normalize.py`
-- [x] Add `normalize_instagram_items()` function
- - Assign IDs as `IG1`, `IG2`, ...
- - Map engagement: `views=play_count`, `likes=like_count`, `num_comments=comment_count`
- - Set `date_confidence="high"` (unix timestamp)
-
-#### 1.4 Add scoring to `scripts/lib/score.py`
-- [x] Add `compute_instagram_engagement_raw()` — same formula as TikTok:
- `0.50*log1p(views) + 0.30*log1p(likes) + 0.20*log1p(comments)`
- Views dominate on Instagram Reels just like TikTok.
-- [x] Add `score_instagram_items()` — same weights: 45% relevance, 25% recency, 30% engagement
-
-#### 1.5 Add dedup to `scripts/lib/dedupe.py`
-- [x] Add `dedupe_instagram()` — same as `dedupe_tiktok()`, calls `dedupe_items()` with 0.7 threshold
-- [x] Update `get_item_text()` to handle `InstagramItem`
-- [x] Update `_get_cross_source_text()` for cross-source linking
-- [x] Add `IG` prefix to cross-ref detection in `cross_source_link()`
-
-#### 1.6 Add rendering to `scripts/lib/render.py`
-- [x] Add Instagram section in `render_compact()` — same pattern as TikTok block (lines 251-285)
- - Show: score, @author, date, views/likes, caption snippet, hashtags, why_relevant
-- [x] Update data freshness check to include `instagram_recent`
-- [x] Update stats footer to include Instagram count
-- [x] Add `'IG'` to cross-ref source name mapping
-
-#### 1.7 Add `Report.instagram` field to `scripts/lib/schema.py`
-- [x] Add `instagram: List[InstagramItem]` and `instagram_error: str` to `Report` dataclass
-
-#### 1.8 Integrate into `scripts/last30days.py` orchestrator
-- [x] Add `"instagram"` to `VALID_SEARCH_SOURCES`
-- [x] Add `import` for `instagram` module in `scripts/lib/`
-- [x] Add `is_instagram_available()` check in `env.py` — reuse `SCRAPECREATORS_API_KEY` (same key as TikTok)
-- [x] Add `get_instagram_token()` in `env.py` — same as `get_tiktok_token()`, returns `SCRAPECREATORS_API_KEY`
-- [x] Add `_search_instagram()` helper in orchestrator (mirrors `_search_tiktok()`)
-- [x] Add Instagram to the thread pool executor block
-- [x] Add Instagram timeout config (same as TikTok: 90/120/150s for quick/default/deep)
-- [x] Wire through pipeline: normalize → filter → score → sort → dedupe → cross-link → report
-- [x] Add Instagram to `progress.show_complete()` and UI spinner
-
-#### 1.9 Add to watchlist extraction in `scripts/watchlist.py`
-- [x] Add Instagram findings loop in `_run_topic()` (mirrors TikTok block at lines 204-213)
-
-#### 1.10 Update README.md
-- [x] Add Instagram to the sources list
-- [x] Note that `SCRAPECREATORS_API_KEY` covers both TikTok and Instagram
-
-### Phase 2: Facebook Source (Conditional)
-
-**Recommendation: SKIP Facebook for now.** Here's why:
-
-1. **No keyword search endpoint** — Facebook only offers profile-based scraping (`/profile/posts` returns 3 posts at a time). Can't search by topic.
-2. **Low relevance for topic research** — Without keyword search, we'd need to know specific Facebook pages to scrape, which defeats the purpose of automated topic discovery.
-3. **Poor ROI** — 3 posts per API call is very limited compared to Instagram's 60 reels per search.
-4. **Same API key** — If Facebook search is added later, it's trivial to add since it shares `SCRAPECREATORS_API_KEY`.
-
-If the user still wants Facebook, the implementation would follow the same pattern but would need a different discovery strategy (e.g., hardcoded page list per topic, or using the Ad Library search for commercial topics).
-
-## Files to Create/Modify
-
-| File | Action | Description |
-|------|--------|-------------|
-| `scripts/lib/instagram.py` | **CREATE** | Instagram search + transcript via ScrapeCreators |
-| `scripts/lib/schema.py` | MODIFY | Add `InstagramItem` dataclass, add `instagram` to `Report` |
-| `scripts/lib/normalize.py` | MODIFY | Add `normalize_instagram_items()` |
-| `scripts/lib/score.py` | MODIFY | Add `compute_instagram_engagement_raw()`, `score_instagram_items()` |
-| `scripts/lib/dedupe.py` | MODIFY | Add `dedupe_instagram()`, update text extractors |
-| `scripts/lib/render.py` | MODIFY | Add Instagram render section, update stats |
-| `scripts/lib/env.py` | MODIFY | Add `is_instagram_available()`, `get_instagram_token()` |
-| `scripts/last30days.py` | MODIFY | Add Instagram to orchestrator pipeline |
-| `scripts/watchlist.py` | MODIFY | Add Instagram findings extraction |
-| `README.md` | MODIFY | Add Instagram to sources list |
-
-## Shared Code Opportunity
-
-`_extract_core_subject()`, `_compute_relevance()`, `_tokenize()`, `STOPWORDS`, `SYNONYMS`, and `DEPTH_CONFIG` are duplicated between `tiktok.py` and the new `instagram.py`. Two options:
-
-1. **Copy-paste** (simpler, matches current pattern) — each source module is self-contained
-2. **Extract to shared module** (cleaner) — move to `scripts/lib/search_utils.py`
-
-**Recommendation:** Copy-paste for now to match existing pattern. Refactor later if a third ScrapeCreators source is added.
-
-## Testing Strategy
-
-- [x] Run `python3 scripts/lib/instagram.py` with test keyword (if standalone test added)
-- [x] Run `python3 scripts/last30days.py "instagram reels trends" --search=instagram` to test isolated
-- [x] Run full multi-source: `python3 scripts/last30days.py "AI tools" --search=reddit,instagram`
-- [x] Verify JSON output: `--emit=json` includes `instagram` key
-- [x] Verify watchlist extraction works with Instagram findings
-- [x] Check credit usage is reasonable (1 credit per 10 reels search + 1 per transcript)
-
-## Credits Budget
-
-Per research run with Instagram at `default` depth:
-- Search: 1 credit (per 10 reels, returns up to 20) ≈ 2 credits
-- Transcripts: 5 credits (max_captions=5 at default depth)
-- **Total: ~7 credits per topic** (vs TikTok ~6 credits)
diff --git a/docs/plans/2026-03-04-feat-v2.8-tiktok-instagram-release-plan.md b/docs/plans/2026-03-04-feat-v2.8-tiktok-instagram-release-plan.md
deleted file mode 100644
index 164e49c..0000000
--- a/docs/plans/2026-03-04-feat-v2.8-tiktok-instagram-release-plan.md
+++ /dev/null
@@ -1,147 +0,0 @@
----
-title: "feat: v2.8 Release — Instagram Reels + TikTok ScrapeCreators Migration"
-type: enhancement
-status: pending
-date: 2026-03-04
----
-
-# feat: v2.8 Release — Instagram Reels + TikTok ScrapeCreators Migration
-
-## Summary
-
-Ship everything from the last sprint as one combined GitHub release: TikTok's migration from Apify to ScrapeCreators (already committed) + Instagram Reels as the 8th source (uncommitted) + SKILL.md URL regression fixes. Version bump to v2.8.
-
-## What's Shipping
-
-### 1. Instagram Reels — 8th source (NEW)
-- Search Instagram Reels by keyword via ScrapeCreators `/v1/instagram/reels/search`
-- Spoken-word transcript extraction via `/v2/instagram/media/transcript`
-- Full pipeline: search → normalize → score → dedupe → cross-link → render
-- Shares `SCRAPECREATORS_API_KEY` with TikTok (no new API key needed)
-- ~7 credits per topic at default depth
-
-### 2. TikTok — Apify → ScrapeCreators migration (ALREADY COMMITTED)
-- Replaced Apify dependency with ScrapeCreators API
-- Same functionality, different backend
-- No more `APIFY_API_TOKEN` — uses `SCRAPECREATORS_API_KEY`
-
-### 3. SKILL.md quality fixes
-- Instagram added to stats template, citation priority, data sections, footer
-- URL regression fix: explicit URL-to-name extraction rules, stronger anti-Sources instruction
-- Security section updated: Apify → ScrapeCreators
-
-## Release Checklist
-
-### Pre-commit: Update docs
-
-- [ ] **README.md** — Update for v2.8:
- - [ ] Change title from "v2.7" to "v2.8"
- - [ ] Add "New in v2.8" banner: Instagram Reels + ScrapeCreators migration
- - [ ] Update installation section: `APIFY_API_TOKEN` → `SCRAPECREATORS_API_KEY`
- - [ ] Add Instagram to the "How it works" flow description (line 132)
- - [ ] Update TikTok section: replace Apify references with ScrapeCreators
- - [ ] Add Instagram section (after TikTok section, ~line 963)
- - [ ] Update API endpoints table: `api.apify.com` → `api.scrapecreators.com`, add Instagram endpoints
- - [ ] Update closing tagline to include Instagram
- - [ ] Remove "The shared Apify client wrapper is designed for future Facebook and Instagram sources" (line 963) — Instagram is here now
-
-- [ ] **CHANGELOG.md** — Add v2.8.0 entry:
- ```
- ## [2.8.0] - 2026-03-04
-
- ### Highlights
-
- Instagram Reels as the 8th signal source, TikTok migrated from Apify to ScrapeCreators API, and SKILL.md quality improvements.
-
- ### Added
-
- - Instagram Reels as 8th research source via ScrapeCreators API — keyword search, engagement metrics (views, likes, comments), spoken-word transcript extraction
- - Instagram items in SKILL.md stats template, citation priority, and output footer
- - URL-to-name extraction examples in SKILL.md for cleaner web source display
-
- ### Changed
-
- - TikTok backend migrated from Apify to ScrapeCreators API (same key covers TikTok + Instagram)
- - `APIFY_API_TOKEN` replaced by `SCRAPECREATORS_API_KEY` in config
- - SKILL.md version bumped to v2.8
- - WebSearch citation instruction strengthened to prevent trailing Sources: blocks
-
- ### Fixed
-
- - Web stats line showing full URLs instead of plain domain names (regression from v2.7)
- - Trailing "Sources:" block appearing after invitation (WebSearch tool mandate conflict)
- - Instagram/TikTok not running in web-only mode when `--search=instagram` used without Reddit/X
- ```
-
-- [ ] **SKILL.md** frontmatter — Bump version from "2.7" to "2.8"
-
-- [ ] **SKILL.md** description — Add Instagram to the description field
-
-### Commit & tag
-
-- [ ] Stage all changes: modified files + 4 new files (`scripts/lib/instagram.py`, 3 plan docs)
-- [ ] Commit with message:
- ```
- feat: v2.8 — Instagram Reels source + TikTok ScrapeCreators migration
-
- - Add Instagram Reels as 8th research source via ScrapeCreators API
- - Migrate TikTok from Apify to ScrapeCreators (same API key)
- - Add SCRAPECREATORS_API_KEY config (replaces APIFY_API_TOKEN)
- - Fix web stats URL regression and trailing Sources: block
- - Fix Instagram/TikTok not running in --search=instagram web-only path
- - Update SKILL.md with Instagram stats, citations, URL formatting rules
- ```
-- [ ] Create tag: `git tag -a v2.8.0 -m "v2.8.0: Instagram Reels + ScrapeCreators"`
-- [ ] Push: `git push origin main --tags`
-
-### Post-push: GitHub release
-
-- [ ] Create GitHub release via `gh release create v2.8.0`:
- ```
- ## What's New in v2.8
-
- **Instagram Reels** is now the 8th signal source. Search any topic and get trending Instagram Reels with views, likes, and spoken-word transcripts — scored and ranked alongside Reddit, X, YouTube, TikTok, HN, Polymarket, and the web.
-
- **TikTok migrated to ScrapeCreators API.** Same functionality, new backend. Replace `APIFY_API_TOKEN` with `SCRAPECREATORS_API_KEY` in your config. One key now covers both TikTok and Instagram.
-
- ### Setup
-
- Sign up at [scrapecreators.com](https://scrapecreators.com) (100 free credits, then PAYG) and add your key:
-
- ```bash
- echo 'SCRAPECREATORS_API_KEY=your_key' >> ~/.config/last30days/.env
- ```
-
- ### Breaking Change
-
- - `APIFY_API_TOKEN` is no longer used. Replace with `SCRAPECREATORS_API_KEY`.
-
- ### Bug Fixes
-
- - Fixed web source URLs leaking into stats display
- - Fixed Instagram/TikTok not running when used with `--search=` flag
- ```
-
-### Post-release: Sync
-
-- [ ] Run `bash scripts/sync.sh` to deploy to all 4 skill destinations
-- [ ] Verify Instagram works in a fresh `/last30days` session
-
-## Files Modified (this release)
-
-| File | Status | Description |
-|------|--------|-------------|
-| `scripts/lib/instagram.py` | NEW | Instagram search + transcript via ScrapeCreators |
-| `scripts/lib/schema.py` | MODIFIED | InstagramItem dataclass, Report.instagram field |
-| `scripts/lib/normalize.py` | MODIFIED | normalize_instagram_items() |
-| `scripts/lib/score.py` | MODIFIED | score_instagram_items(), engagement formula |
-| `scripts/lib/dedupe.py` | MODIFIED | dedupe_instagram(), cross-source linking |
-| `scripts/lib/render.py` | MODIFIED | Instagram render section, stats |
-| `scripts/lib/env.py` | MODIFIED | is_instagram_available(), get_instagram_token() |
-| `scripts/lib/ui.py` | MODIFIED | Instagram spinner messages |
-| `scripts/last30days.py` | MODIFIED | Instagram in orchestrator pipeline |
-| `scripts/watchlist.py` | MODIFIED | Instagram findings extraction |
-| `SKILL.md` | MODIFIED | Instagram in stats/citations/footer, URL fixes |
-| `README.md` | TO UPDATE | Instagram section, ScrapeCreators migration |
-| `CHANGELOG.md` | TO UPDATE | v2.8.0 entry |
-| `docs/plans/*.md` | NEW (3) | Plan documents for this work |
diff --git a/docs/plans/2026-03-04-fix-web-sources-url-regression-plan.md b/docs/plans/2026-03-04-fix-web-sources-url-regression-plan.md
deleted file mode 100644
index ff21573..0000000
--- a/docs/plans/2026-03-04-fix-web-sources-url-regression-plan.md
+++ /dev/null
@@ -1,135 +0,0 @@
----
-title: "fix: Web sources showing full URLs instead of plain domain names"
-type: fix
-status: pending
-date: 2026-03-04
----
-
-# fix: Web Sources Showing Full URLs Instead of Plain Domain Names
-
-## Problem
-
-Two regressions in the `/last30days` skill output:
-
-### Regression 1: Full URLs on the Web stats line
-
-The `🌐 Web:` stats line is showing full URLs instead of plain source names:
-
-**BAD (current — "Instagram Trends" run):**
-```
-├─ 🌐 Web: 10+ pages — https://later.com/blog/instagram-reels-trends/,
-https://socialbee.com/blog/instagram-trends/,
-https://buffer.com/resources/instagram-algorithms/,
-https://metricool.com/instagram-trends/,
-https://napoleoncat.com/blog/instagram-reels-trends/
-```
-
-**GOOD (expected):**
-```
-├─ 🌐 Web: 10+ pages — Later, SocialBee, Buffer, Metricool, NapoleonCat
-```
-
-### Regression 2: Trailing Sources: block with full URLs
-
-A separate `Sources:` section appears at the bottom of the response with full URLs:
-
-```
-Sources:
-- https://www.heyorca.com/blog/instagram-social-news
-- https://socialbee.com/blog/instagram-updates/
-- https://buffer.com/resources/instagram-algorithms/
-- https://www.cnn.com/2026/02/22/tech/social-media-addiction-trial-tobacco-moment
-```
-
-This was fixed in commit `82efa61` (2026-03-02) but is regressing intermittently.
-
-## Root Cause
-
-The SKILL.md instructions at lines 219-221, 404, and 409 already say the right thing:
-- Line 404: `├─ 🌐 Web: {N} pages — Source Name, Source Name, Source Name`
-- Line 409: `"plain names, no URLs — URLs wrap badly in terminals"`
-- Lines 219-221: "DO NOT output a separate Sources: block"
-
-But the model ignores these because:
-
-1. **The template `Source Name` is too abstract.** The model sees WebSearch results with full URLs and doesn't know how to extract a human-friendly name from `https://later.com/blog/instagram-reels-trends/`. It needs explicit examples showing the transformation.
-
-2. **The WebSearch system mandate still wins.** The WebSearch tool's built-in instruction (`"you MUST include a Sources: section"`) outcompetes the skill's instruction. The current countermeasure (line 409) works sometimes but not reliably — it needs to be stronger and repeated.
-
-3. **No explicit extraction rule.** The model needs a concrete rule for turning URLs into names: strip protocol, strip path, strip `www.`, capitalize.
-
-## Proposed Solution
-
-**SKILL.md edits only. No Python changes.**
-
-### Fix 1: Add explicit URL-to-name examples in the stats template (line 404 area)
-
-After the stats template block, add concrete examples showing the transformation:
-
-```
-**🌐 Web: line formatting:**
-- Extract the SITE NAME from each URL — strip protocol, path, and "www."
-- Use the publication's proper name when recognizable
-- Examples:
- - https://later.com/blog/instagram-reels-trends/ → "Later"
- - https://socialbee.com/blog/instagram-trends/ → "SocialBee"
- - https://buffer.com/resources/instagram-algorithms/ → "Buffer"
- - https://www.cnn.com/2026/02/22/tech/... → "CNN"
- - https://medium.com/the-ai-studio/... → "Medium"
- - https://radicaldatascience.wordpress.com/... → "Radical Data Science"
-- NEVER paste the URL itself. ONLY the site name as plain text.
-- Separate names with commas: "Later, SocialBee, Buffer, CNN, Medium"
-```
-
-### Fix 2: Strengthen the anti-Sources instruction (line 409 area)
-
-Replace the current single-paragraph note with a louder, more explicit instruction:
-
-```
-**⚠️ WebSearch citation requirement — ALREADY SATISFIED above.**
-The WebSearch tool mandates source citation. That requirement is FULLY satisfied
-by the source names on the 🌐 Web: line above. Do NOT append a separate
-"Sources:" section at the end of your response. Do NOT list URLs anywhere in
-your output. The 🌐 Web: line IS your citation. You're done.
-```
-
-### Fix 3: Add a negative example in the URL FORMATTING section (line 356 area)
-
-Extend the existing BAD/GOOD examples to cover the stats line specifically:
-
-```
-URL FORMATTING: NEVER paste raw URLs anywhere in the output.
-- BAD: "per https://www.rollingstone.com/music/music-news/kanye-west-bully-1235506094/"
-- GOOD: "per Rolling Stone"
-- BAD stats line: "🌐 Web: 10 pages — https://later.com/blog/..., https://buffer.com/..."
-- GOOD stats line: "🌐 Web: 10 pages — Later, Buffer, CNN, SocialBee"
-```
-
-### Fix 4: Update Security section (line 588, 600)
-
-While we're in SKILL.md, update the stale Apify references to ScrapeCreators:
-- Line 588: Change Apify reference to ScrapeCreators for TikTok
-- Line 600: Update TikTok requirement note
-- Add Instagram source mention
-
-## Files to Modify
-
-| File | Action | Description |
-|------|--------|-------------|
-| `SKILL.md` | MODIFY | Strengthen URL formatting rules, add examples, fix Apify refs |
-
-## Implementation Steps
-
-- [x] Add URL-to-name extraction examples after stats template (after line 407)
-- [x] Strengthen anti-Sources instruction (replace line 409)
-- [x] Add BAD/GOOD stats line example to URL FORMATTING section (around line 356)
-- [x] Update Security section: Apify → ScrapeCreators, add Instagram (lines 588, 600)
-- [x] Run `bash scripts/sync.sh` to deploy to all destinations
-- [ ] Test with `/last30days Instagram Trends` — confirm plain names, no trailing Sources:
-
-## Acceptance Criteria
-
-- [ ] `🌐 Web:` line shows plain names only (e.g., "Later, SocialBee, Buffer")
-- [ ] No `Sources:` section appears at the bottom of the response
-- [ ] No raw URLs appear anywhere in the output (synthesis, stats, or footer)
-- [ ] Security section reflects current source stack (ScrapeCreators, not Apify)
diff --git a/docs/plans/2026-03-05-feat-auto-save-results-to-documents-plan.md b/docs/plans/2026-03-05-feat-auto-save-results-to-documents-plan.md
deleted file mode 100644
index 8800f01..0000000
--- a/docs/plans/2026-03-05-feat-auto-save-results-to-documents-plan.md
+++ /dev/null
@@ -1,152 +0,0 @@
----
-title: "feat: Auto-save research results to ~/Documents/Last30Days/"
-type: feat
-status: completed
-date: 2026-03-05
----
-
-# feat: Auto-save research results to ~/Documents/Last30Days/
-
-## Overview
-
-Every time the last30days skill completes a research run, automatically save the full briefing - inquiry, synthesis, stats, and follow-up suggestions - as a topic-named `.md` file in `~/Documents/Last30Days/`. Inspired by how users like @devin_explores are already manually saving results to build a personal research library (see screenshot - 17 topic files in a `Last30Days` Finder folder, each 9-34 KB).
-
-## Problem Statement / Motivation
-
-The skill's most valuable output - the assistant's synthesized "What I learned" briefing with stats and citations - only exists in the conversation. Once the session ends, it's gone. Users like @devin_explores work around this by manually copying output into .md files. Meanwhile, the Python script already writes raw data to `~/.local/share/last30days/out/`, but:
-
-1. It overwrites on every run (no history)
-2. It only contains pre-synthesis data (scored items), not the assistant's expert briefing
-3. It's in a hidden dot-directory users don't naturally browse
-
-The feature makes saving automatic and puts files where users expect them - the Documents folder, visible in Finder/file explorer.
-
-## Proposed Solution
-
-Add a **Write tool step in SKILL.md** after the synthesis/stats/invitation block that saves the complete briefing to `~/Documents/Last30Days/{topic-slug}.md`. This is a SKILL.md-only change (no Python script modifications needed) because the content to save is the assistant's synthesized output, which only exists in the SKILL.md flow.
-
-### Why SKILL.md, not the Python script
-
-The Python script (`last30days.py`) runs first and produces raw scored items. The assistant then synthesizes these into the "What I learned" briefing, stats block, and invitation. The synthesis is the valuable part - it's what @devin_explores is saving. The script can't produce this because it runs before synthesis happens.
-
-### File naming
-
-Convert the TOPIC variable to a kebab-case slug for the filename:
-- "Claude Code best practices" -> `claude-code-best-practices.md`
-- "best rap songs 2026" -> `best-rap-songs-2026.md`
-- "nano banana 2 prompting guide" -> `nano-banana-2-prompting-guide.md`
-
-This matches the screenshot pattern exactly (e.g., `anthropic-claude-code-best-practices.md`, `seedance-video-prompting-guide.md`).
-
-If a file with the same slug already exists, append a date suffix: `claude-code-best-practices-2026-03-05.md`. This handles re-researching the same topic without overwriting previous results.
-
-### File content
-
-The saved .md file should contain the complete research output in this order:
-
-```markdown
-# {TOPIC}
-
-> Researched {date} | Query type: {QUERY_TYPE} | Target tool: {TARGET_TOOL or "general"}
-
-## What I learned
-
-{The full synthesis section - topics, patterns, citations}
-
-## Stats
-
-{The full stats box with source counts and engagement}
-
-## Follow-up suggestions
-
-{The 2-3 specific suggestions from the invitation block}
-
----
-*Generated by [last30days](https://github.com/mvanhorn/last30days-skill) v2.9*
-```
-
-### Implementation location in SKILL.md
-
-Insert a new section between the current "LAST - Invitation" display and the "WAIT FOR USER'S RESPONSE" section. The Write tool call happens silently - no user prompt, no opt-in. Just save and briefly confirm.
-
-## Technical Considerations
-
-- **Cross-platform paths**: `~/Documents/` exists on macOS and most Linux desktops. On systems where it doesn't exist, `mkdir -p` handles creation. Windows WSL users get it too.
-- **Permissions**: The Write tool in Claude Code can write to `~/Documents/` without issues. No sandbox concerns since this is the user's own Documents folder.
-- **Filename sanitization**: Strip special characters, collapse whitespace to hyphens, lowercase. Keep it simple - no need for a library, just basic string ops in the SKILL.md instructions.
-- **File size**: Based on the screenshot (9-34 KB files), the synthesis output is well within reasonable bounds.
-- **No opt-out flag needed initially**: This is the default behavior. If users complain, a `--no-save` flag can be added later. Start with always-on since the screenshot proves users want this.
-
-## Acceptance Criteria
-
-- [x] Running `/last30days {topic}` creates `~/Documents/Last30Days/{topic-slug}.md` automatically
-- [x] File contains: title, date, query metadata, full synthesis, stats block, follow-up suggestions
-- [x] Filename is kebab-case slug of the topic (e.g., `claude-code-skills-guide.md`)
-- [x] Duplicate topics get a date suffix instead of overwriting
-- [x] Directory `~/Documents/Last30Days/` is created automatically if it doesn't exist
-- [x] A brief confirmation line appears after the stats (e.g., "Saved to ~/Documents/Last30Days/claude-code-skills-guide.md")
-- [x] Agent mode (`--agent`) also saves the file
-- [x] No changes to the Python script - this is purely a SKILL.md addition
-
-## Implementation Steps
-
-### Step 1: Add save instructions to SKILL.md
-
-Insert a new section after the invitation block (after line ~496, before "WAIT FOR USER'S RESPONSE" at line ~499):
-
-**New section in `SKILL.md`:**
-
-```markdown
-## Save Research to Documents
-
-After displaying the invitation, save the complete research briefing:
-
-1. Generate the filename from TOPIC:
- - Lowercase the topic
- - Replace spaces and special characters with hyphens
- - Remove consecutive hyphens
- - Trim to 60 characters max
- - Example: "Claude Code Best Practices" -> "claude-code-best-practices"
-
-2. Check if file already exists. If so, append today's date:
- - "claude-code-best-practices.md" exists -> use "claude-code-best-practices-2026-03-05.md"
-
-3. Use the Write tool to save to ~/Documents/Last30Days/{slug}.md with this content:
- - H1 title: the TOPIC
- - Metadata line: date, QUERY_TYPE, TARGET_TOOL
- - Full "What I learned" synthesis (everything you just displayed)
- - Full stats block
- - Follow-up suggestions from the invitation
- - Footer with skill attribution
-
-4. Confirm briefly: "Saved to ~/Documents/Last30Days/{slug}.md"
-```
-
-### Step 2: Update agent mode section
-
-The `--agent` mode section (line ~116) skips interactive elements but should still save. Add a note that agent mode saves the file with the same logic.
-
-### Step 3: Update Security & Permissions section
-
-Add to the "What this skill does" list (line ~599):
-- "Saves research briefings as .md files to ~/Documents/Last30Days/"
-
-## Success Metrics
-
-- Users accumulate a browsable library of .md research files in their Documents folder
-- No more manual copy-paste workflow to save results
-- Files are immediately findable in Finder/file explorer search
-
-## Dependencies & Risks
-
-- **Low risk**: Write tool is already in the skill's `allowed-tools` list
-- **Low risk**: ~/Documents/ is a standard, user-owned directory
-- **Edge case**: If the skill is interrupted mid-run (before synthesis), no file is saved - this is correct behavior since there's nothing to save yet
-- **Edge case**: Very long topics could produce unwieldy filenames - the 60-char truncation handles this
-
-## Sources & References
-
-- Screenshot from @devin_explores showing manual .md file library in ~/Documents/Last30Days/
-- Current output pipeline: `scripts/lib/render.py:798` (`write_outputs()`) writes to `~/.local/share/last30days/out/`
-- SKILL.md synthesis flow: lines 275-496 (internalize research -> show summary -> invitation)
-- Existing `--emit` modes: `scripts/last30days.py:1700` (`output_result()`)
diff --git a/docs/plans/2026-03-05-feat-reddit-scrapecreators-v2-improvements-plan.md b/docs/plans/2026-03-05-feat-reddit-scrapecreators-v2-improvements-plan.md
deleted file mode 100644
index 125143f..0000000
--- a/docs/plans/2026-03-05-feat-reddit-scrapecreators-v2-improvements-plan.md
+++ /dev/null
@@ -1,255 +0,0 @@
-# feat: Reddit ScrapeCreators v2 — Improvements from Beta Testing
-
-**Date:** 2026-03-05
-**Type:** Enhancement
-**Version:** v2.9 → v2.9.1-beta (or v3.0-beta if shipping to public)
-**Branch:** `feat/reddit-scrapecreators` (continue existing branch)
-
----
-
-## Summary
-
-Three focused improvements to the Reddit ScrapeCreators integration based on 5 full-pipeline tests ("Claude Code skills", "Kanye West", "Anthropic odds", "best rap songs lately", "Nano Banana Pro prompting"):
-
-1. **Elevate top Reddit comments** — give weight to the wittiest/highest-voted comment in scoring and rendering
-2. **Improve subreddit discovery** — tune heuristic so ambiguous queries find discussion subs, not utility subs
-3. **Make ScrapeCreators the default recommended Reddit method** — update onboarding, SKILL.md metadata, and env.py messaging
-
----
-
-## Problem Statement
-
-### 1. Comments are undervalued
-- ScrapeCreators returns real comment data with scores, but top comments only appear as `Insights:` text under each Reddit item
-- The top comment (often the funniest/cleverest reply) gets no special treatment — it's just one of 3 comment excerpts
-- Reddit's value IS the comments — upvoted replies are the distilled crowd wisdom
-- Currently `comment_insights` are truncated at 150 chars and only 3 are shown per item in compact output
-- No scoring bonus for posts that have high-quality comment threads
-
-### 2. Subreddit discovery picks wrong subs for ambiguous queries
-- "best rap songs lately" discovered `r/NameThatSong` and `r/findthatsong` (utility subs for identifying songs) instead of discussion subs like `r/hiphopheads` or `r/rap`
-- "Kanye West" picked `r/ConcertsIndia_` as second sub — tangential at best
-- The current heuristic is pure frequency count on `subreddit` field from global results, with no relevance weighting
-- Utility/meta subs often dominate because the same query matches many "help me find X" posts
-
-### 3. Onboarding still suggests OpenAI as the primary Reddit method
-- SKILL.md metadata says `primaryEnv: OPENAI_API_KEY` and `requires.env: [OPENAI_API_KEY]`
-- The web-only mode banner mentions "OPENAI_API_KEY or codex login → Reddit threads"
-- `env.py` error messages direct users to OpenAI for Reddit access
-- ScrapeCreators is cheaper ($0.012 vs $0.03-0.10), faster (17s vs 60-90s), returns real data, and shares a key with TikTok + Instagram
-- New users should be told: "Get a SCRAPECREATORS_API_KEY for Reddit + TikTok + Instagram (one key, all three)"
-
----
-
-## Implementation Plan
-
-### Task 1: Elevate Top Comments in Scoring and Rendering
-
-**Goal:** Give Reddit posts a scoring bonus when they have highly-engaged comment threads, and render the #1 comment with special treatment.
-
-**Files to modify:**
-- `scripts/lib/reddit.py` — enrich with `top_comment_score` metadata
-- `scripts/lib/score.py` — add comment quality bonus to Reddit scoring
-- `scripts/lib/render.py` — render top comment with special formatting
-- `scripts/lib/schema.py` — add `top_comment_excerpt` field to RedditItem (optional, may just use existing `top_comments[0]`)
-
-#### 1a. Comment enrichment improvements (`scripts/lib/reddit.py`)
-
-- [x] In `enrich_with_comments()`, after sorting comments by score, tag the item with:
- - `top_comment_excerpt`: The highest-scored comment's body (up to 200 chars)
- - `top_comment_score`: The upvote count of the #1 comment
- - `top_comment_author`: Author of the #1 comment
-- [x] Increase comment excerpt length from 300 → 400 chars for top comment only (funny/clever comments need more room)
-- [x] Increase `comment_insights` limit from 7 → 10 (we have the data, show it)
-- [x] For posts with enriched comments, store the comment count ratio: `top_comment_score / post_score` — a high ratio means the comment outshines the post (Reddit gold)
-
-#### 1b. Scoring bonus for comment quality (`scripts/lib/score.py`)
-
-- [x] In `compute_reddit_engagement_raw()`, add a comment quality signal:
- - Current formula: `0.55*log1p(score) + 0.40*log1p(num_comments) + 0.05*(upvote_ratio*10)`
- - New formula: `0.50*log1p(score) + 0.35*log1p(num_comments) + 0.05*(upvote_ratio*10) + 0.10*log1p(top_comment_score)`
- - This gives a ~10% weight to comment quality, slightly reducing post score and comment count weights
- - Posts where the community engaged deeply (high top-comment score) rank higher
-- [x] Need to pass `top_comment_score` through the engagement data — either:
- - Option A: Add `top_comment_score` to `schema.Engagement` (cleanest)
- - Option B: Read from `item.top_comments[0].score` during scoring (no schema change)
- - **Recommend Option B** to avoid schema bloat — scoring can peek at `top_comments`
-
-#### 1c. Render top comment prominently (`scripts/lib/render.py`)
-
-- [x] In `render_compact()` Reddit section, after the `Insights:` block, add a "Top Comment:" line for items that have top_comments:
- ```
- **R1** (score:80) r/ClaudeAI (2026-02-28) [666pts, 63cmt]
- Claude Code creator: In the next version, introducing two new skills
- https://www.reddit.com/r/ClaudeAI/comments/...
- *Reddit global search*
- 💬 Top comment (247 upvotes): "So are they /batch migrating to Rust? :)"
- Insights:
- - TL;DR generated automatically after 50 comments...
- - He's /batch migrating code daily?..
- ```
-- [x] Only show `💬 Top comment` for items where `top_comments[0].score >= 10` (skip low-engagement comments)
-- [x] Truncate at 200 chars with `...` if needed
-- [x] Also update `render_full_report()` to include the top comment prominently
-
-#### 1d. Update SKILL.md synthesis instructions
-
-- [x] In the "Judge Agent: Synthesize All Sources" section, add guidance:
- ```
- 5b. For Reddit: Pay special attention to top comments — they often contain the wittiest, most insightful, or funniest take. When a top comment has high upvotes, quote it directly in your synthesis. Reddit's value is in the comments.
- ```
-- [x] In the citation priority list, add: "When citing Reddit, prefer quoting top comments over just the thread title"
-
----
-
-### Task 2: Improve Subreddit Discovery Heuristic
-
-**Goal:** Find topical discussion subs rather than utility/meta subs.
-
-**Files to modify:**
-- `scripts/lib/reddit.py` — improve `discover_subreddits()` logic
-
-#### 2a. Add relevance-weighted subreddit scoring
-
-- [x] Replace pure frequency count with a weighted score:
- ```python
- def discover_subreddits(results, topic, max_subs=5):
- core = _extract_core_subject(topic)
- core_words = set(core.lower().split())
-
- scores = Counter()
- for post in results:
- sub = post.get("subreddit", "")
- if not sub:
- continue
-
- # Base: frequency count
- base = 1.0
-
- # Bonus: subreddit name contains a core topic word
- sub_lower = sub.lower()
- if any(w in sub_lower for w in core_words if len(w) > 2):
- base += 2.0
-
- # Penalty: known utility/meta subreddits
- if sub_lower in UTILITY_SUBS:
- base *= 0.3
-
- # Bonus: post engagement (high-engagement posts = better sub)
- ups = post.get("ups") or post.get("score", 0)
- if ups > 100:
- base += 0.5
-
- scores[sub] += base
-
- return [sub for sub, _ in scores.most_common(max_subs)]
- ```
-
-#### 2b. Define utility/meta subreddit blocklist
-
-- [x] Add a small set of subs that are "find X for me" or "identify X" rather than discussion:
- ```python
- UTILITY_SUBS = frozenset({
- 'namethatsong', 'findthatsong', 'tipofmytongue',
- 'whatisthissong', 'helpmefind', 'whatisthisthing',
- 'whatsthissong', 'findareddit', 'subredditdrama',
- })
- ```
-- [x] Keep this small and focused — don't over-filter. Only penalty (0.3x), not ban.
-
-#### 2c. Try secondary query for subreddit discovery
-
-- [x] If the first global search returns <3 unique subreddits above threshold, run a second global search with just `{core subject}` (stripped even further) to cast a wider net for subreddit frequencies
-- [x] This helps niche topics where the full query is too specific
-
----
-
-### Task 3: Make ScrapeCreators the Default Reddit Method
-
-**Goal:** New users should be guided to ScrapeCreators first, not OpenAI.
-
-**Files to modify:**
-- `SKILL.md` — metadata section, onboarding banner, security section
-- `scripts/lib/env.py` — error messages and missing key guidance
-- `scripts/lib/render.py` — web-only mode banner
-
-#### 3a. Update SKILL.md metadata
-
-- [x] Change `primaryEnv: OPENAI_API_KEY` → `primaryEnv: SCRAPECREATORS_API_KEY`
-- [x] Change `requires.env: [OPENAI_API_KEY]` → `requires.env: [SCRAPECREATORS_API_KEY]`
-- [x] Keep OPENAI_API_KEY mentioned but as optional/legacy
-
-#### 3b. Update web-only mode banner (`scripts/lib/render.py`)
-
-- [x] Change the current banner:
- ```
- - `OPENAI_API_KEY` or `codex login` → Reddit threads with real upvotes & comments
- ```
- To:
- ```
- - `SCRAPECREATORS_API_KEY` → Reddit + TikTok + Instagram (one key, all three!) — real upvotes, comments, views
- - `OPENAI_API_KEY` (legacy) → Reddit threads (slower, higher cost)
- ```
-
-#### 3c. Update env.py messaging
-
-- [x] In `get_missing_keys()`, when Reddit is missing, suggest ScrapeCreators first:
- - Current: returns `'reddit'` which triggers "Add OPENAI_API_KEY or run codex login" in SKILL.md
- - Add a helper: `get_setup_hint(missing)` that returns:
- - For 'reddit': `"Add SCRAPECREATORS_API_KEY for Reddit + TikTok + Instagram (one key, ~$0.002/search)"`
- - For 'x': `"Add XAI_API_KEY for X posts"`
- - For 'all': `"Add SCRAPECREATORS_API_KEY (Reddit+TikTok+Instagram) and XAI_API_KEY (X)"`
-
-#### 3d. Update Security & Permissions section in SKILL.md
-
-- [x] Add ScrapeCreators Reddit to the security section:
- ```
- - Sends search queries to ScrapeCreators API (`api.scrapecreators.com`) for Reddit, TikTok, and Instagram search (requires SCRAPECREATORS_API_KEY)
- ```
-- [x] Move "Sends search queries to OpenAI's Responses API for Reddit discovery" to a "Legacy:" subsection
-- [x] Update "Reddit" description in `allowed-tools` or tags if needed
-
-#### 3e. Update render.py coverage note
-
-- [x] In `render_compact()`, the coverage note for `reddit-only` currently says "Add an xAI key"
-- [x] When ScrapeCreators is the active Reddit source, no need to mention OpenAI at all
-
----
-
-## Acceptance Criteria
-
-- [x] Top Reddit comment is rendered with `💬` prefix and upvote count for enriched posts
-- [x] Posts with high top-comment scores rank slightly higher (visible in score differences)
-- [x] "best rap songs lately" discovers at least one discussion sub (r/hiphopheads, r/rap, r/Music, etc.) instead of only utility subs
-- [x] SKILL.md `primaryEnv` is `SCRAPECREATORS_API_KEY`
-- [x] Web-only mode banner recommends ScrapeCreators first
-- [x] All 5 test topics still pass (run same tests as before)
-- [x] No regression in OpenAI fallback path
-
----
-
-## Files Changed (Summary)
-
-| File | Change |
-|------|--------|
-| `scripts/lib/reddit.py` | Improve `discover_subreddits()` with relevance weighting, add utility sub penalties, enhance `enrich_with_comments()` top comment metadata |
-| `scripts/lib/score.py` | Add 10% comment quality weight to Reddit engagement formula |
-| `scripts/lib/render.py` | Add `💬 Top comment` line to compact output, update web-only banner |
-| `scripts/lib/env.py` | Add `get_setup_hint()`, update missing key messaging |
-| `SKILL.md` | Change `primaryEnv`, update onboarding banner, add comment synthesis guidance, update security section |
-
----
-
-## Cost Impact
-
-No cost increase. Same number of API calls per search. The changes are all in local logic (scoring, rendering, discovery heuristic).
-
----
-
-## Testing Plan
-
-1. Re-run the same 5 test topics from beta testing
-2. Verify top comments appear with `💬` in output
-3. Verify "best rap songs lately" discovers at least one discussion subreddit
-4. Verify `--diagnose` output recommends ScrapeCreators
-5. Verify OpenAI fallback still works (unset SCRAPECREATORS_API_KEY, set OPENAI_API_KEY)
diff --git a/docs/plans/2026-03-06-fix-save-output-noise-plan.md b/docs/plans/2026-03-06-fix-save-output-noise-plan.md
deleted file mode 100644
index 60e31e3..0000000
--- a/docs/plans/2026-03-06-fix-save-output-noise-plan.md
+++ /dev/null
@@ -1,83 +0,0 @@
----
-title: "fix: Eliminate save-related output noise after research"
-type: fix
-status: active
-date: 2026-03-06
----
-
-# fix: Eliminate save-related output noise after research
-
-## Problem Statement
-
-After research completes, the auto-save feature adds 3-5 lines of unwanted output below the clean invitation block. Every approach tried so far has made things worse:
-
-| Version | Approach | Lines added | Side effects |
-|---------|----------|-------------|--------------|
-| v2.9.1 | `Write` tool | ~5 | Shows "Wrote 43 lines to..." |
-| v2.9.2 | `run_in_background: true` | ~5 | Background callback triggers 2-4 min cogitation, model hallucinates fake "Human:" messages and generates unsolicited multi-paragraph responses |
-| v2.9.3 | Foreground `cat >` heredoc | ~3 | Shows `(No output)`, still triggers 2-4 min cogitation |
-
-**Current v2.9.3 noise (3 lines):**
-```
-⏺ Bash(mkdir -p ~/Documents/Last30Days && cat > ...)
- ⎿ (No output)
-✻ Churned for 2m 38s
-```
-
-The root cause is that ANY tool call after the invitation creates unavoidable Claude Code UI chrome, and the model cogitates for minutes regardless of foreground vs background.
-
-## Proposed Solutions
-
-### Option A: Remove auto-save entirely (Recommended)
-
-Remove the entire "Save Research to Documents" section from SKILL.md. The research lives in the conversation. Zero extra tool calls, zero extra lines, zero cogitation.
-
-**Changes to `SKILL.md`:**
-- Delete the "Save Research to Documents" section (~45 lines)
-- Update agent mode reference (line 126) - remove save mention
-- Update security section (line 612) - change "Saves research briefings" to past tense or conditional
-- Remove `📎` footer line from invitation format
-- Simplify "WAIT FOR USER'S RESPONSE" section
-
-**What users lose:** Auto-saved .md files in `~/Documents/Last30Days/`
-**What users gain:** Clean output ending exactly at the invitation block
-
-### Option B: Move save into Python script
-
-Add `--save-dir` flag to `last30days.py`. The script saves its raw output during its existing Bash call (which already runs). Zero extra tool calls.
-
-**Changes:**
-- `scripts/last30days.py` - add `--save-dir` argument, write raw output to file at end of execution
-- `SKILL.md` - add `--save-dir=~/Documents/Last30Days` to the script invocation, remove save section
-
-**What users lose:** Saved file contains raw research data, not Claude's synthesis
-**What users gain:** Zero extra lines, data is still preserved
-
-### Option C: Opt-in save via follow-up command
-
-Remove auto-save. Add a note to the invitation: "Say 'save' to save this research." When user says "save", run the Bash heredoc then.
-
-**Changes to `SKILL.md`:**
-- Delete auto-save section
-- Add "save" as a recognized intent in "WHEN USER RESPONDS" section
-- Save logic only runs when explicitly requested
-
-**What users lose:** Nothing - save is still available on demand
-**What users gain:** Clean output by default, save when they want it
-
-## Acceptance Criteria
-
-- [ ] Research output ends at the invitation block with zero tool calls after it
-- [ ] No `(No output)` line visible after invitation
-- [ ] No multi-minute cogitation after invitation
-- [ ] No hallucinated fake user messages
-- [ ] Version bumped to v2.9.4
-- [ ] CHANGELOG updated
-- [ ] Synced to ~/.claude, ~/.agents, ~/.codex via sync.sh
-
-## Context
-
-- Source: `/Users/mvanhorn/last30days-skill-private/SKILL.md`
-- Deployed to: `~/.claude/skills/last30days/SKILL.md`
-- Sync command: `bash scripts/sync.sh`
-- Current version: v2.9.3
diff --git a/docs/plans/2026-03-07-feat-paperclip-marketing-automation-plan.md b/docs/plans/2026-03-07-feat-paperclip-marketing-automation-plan.md
deleted file mode 100644
index 096eae0..0000000
--- a/docs/plans/2026-03-07-feat-paperclip-marketing-automation-plan.md
+++ /dev/null
@@ -1,375 +0,0 @@
----
-title: "feat: Paperclip Marketing Automation for last30days"
-type: feat
-status: active
-date: 2026-03-07
----
-
-# Paperclip Marketing Automation for last30days
-
-## Overview
-
-Set up a Paperclip "company" that auto-runs marketing for the last30days open-source skill (3,800+ stars). Four agent roles handle daily demo showcases, release announcements, community engagement, and analytics - all using a draft-then-approve workflow through Paperclip's built-in approval gates.
-
-The killer angle: **last30days markets itself by running itself.** The Content Creator agent runs `/last30days [trending topic] --agent` on hot topics daily, then drafts X threads showing the results. Every post is a live demo.
-
-## Problem Statement / Motivation
-
-- All marketing is currently manual - ~60KB of pre-drafted X threads sit in `docs/` unposted
-- No automated community monitoring (GitHub issues, contributor shoutouts)
-- No metrics tracking (star growth, fork trends, social engagement)
-- Solo entrepreneur can't sustain daily content + community management + development
-- The tool's best ad is itself running on interesting topics, but that requires daily effort
-
-## Proposed Solution
-
-A Paperclip company called **"last30days Marketing"** with 4 agent roles, all draft-then-approve.
-
-### Company Structure
-
-```
-last30days Marketing (Company)
- Mission: "Grow last30days to 10K GitHub stars through daily demo content,
- release marketing, and community engagement"
-
- Marketing Director (Claude Code agent)
- - Sets daily topic priorities
- - Reviews draft quality before surfacing to human
- - Coordinates cross-agent work
-
- Content Creator (Python script agent)
- - Runs last30days on trending topics daily
- - Drafts X showcase threads from the results
- - Heartbeat: daily at 8 AM PT
-
- Release Manager (Bash + Python agent)
- - Watches for new git tags on upstream
- - Drafts release announcement threads
- - Heartbeat: every 6 hours (tag check is cheap)
-
- Community Manager (Python script agent)
- - Monitors GitHub issues/PRs via gh CLI
- - Drafts welcome messages for new contributors
- - Surfaces popular feature requests
- - Heartbeat: every 4 hours
-
- Analytics Analyst (Python script agent)
- - Tracks GitHub stars, forks, traffic
- - Tracks X engagement (@slashlast30days)
- - Generates weekly digest
- - Heartbeat: daily at 11 PM PT (collect), weekly Monday 9 AM (digest)
-```
-
-### Draft-Then-Approve Flow
-
-```
-Agent creates draft
- -> Saved to ~/Documents/Last30Days/drafts/{agent}/{date}-{slug}.md
- -> Paperclip approval gate triggers
- -> Matt reviews in Paperclip UI (approve / reject / edit)
- -> On approve: Python script posts to X API via tweepy
- -> Audit log records everything
-```
-
-## Technical Approach
-
-### Phase 1: Infrastructure (Day 1-2)
-
-Set up Paperclip and external API access.
-
-**Files to create:**
-
-- `marketing/paperclip-config.yaml` - Company definition, org chart, budgets
-- `marketing/scripts/post_to_x.py` - X API v2 posting via tweepy (draft queue -> X)
-- `marketing/scripts/github_monitor.py` - GitHub event monitoring via gh CLI
-- `marketing/scripts/metrics_collector.py` - Star/fork/engagement tracking
-- `marketing/.env.example` - Required API keys template
-
-**Setup steps:**
-
-1. Install Paperclip locally:
- ```bash
- git clone https://github.com/paperclipai/paperclip
- cd paperclip && pnpm install && pnpm dev
- ```
-
-2. Get X API v2 credentials (developer.x.com) for @slashlast30days
- - Need: API key, API secret, Access token, Access token secret
- - Permissions: Read + Write (posting)
-
-3. GitHub token for monitoring (gh auth already configured)
-
-4. Create the company in Paperclip UI at localhost:3100:
- - Company name: "last30days Marketing"
- - Mission: "Grow last30days to 10K GitHub stars"
- - Monthly budget cap: $50 (mostly API token costs)
-
-### Phase 2: Content Creator Pipeline (Day 3-5)
-
-The core marketing engine. Uses last30days to market itself.
-
-**How it works:**
-
-1. **Topic Discovery** - `marketing/scripts/discover_topics.py`
- - Scrapes trending topics from: Wikipedia pageviews API, HN front page, Reddit r/all
- - Filters for topics that would make compelling demos (tech, culture, sports, geopolitics)
- - Outputs ranked topic list to `~/Documents/Last30Days/topics-queue.json`
-
-2. **Research & Draft** - `marketing/scripts/create_showcase.py`
- - Picks top topic from queue
- - Runs: `python3 scripts/last30days.py "{topic}" --agent --emit=compact --save-dir=~/Documents/Last30Days`
- - Reads the saved research output
- - Drafts a 1-2 tweet thread in the established style (see Style Guide below)
- - Saves draft to `~/Documents/Last30Days/drafts/content/{date}-{slug}.md`
-
-3. **Approval Gate** - Paperclip surfaces draft for review
- - Matt approves/edits in Paperclip UI
- - On approve: triggers `post_to_x.py` with the draft content
-
-**Style Guide (extracted from existing launch threads):**
-
-```
-Format: Stats-first hook + key finding + tool credit
-
-Example (from v2.5 launch):
-"/last30days Anthropic Pete Hegseth"
-
-14 Reddit threads. 29 X posts (11,559 likes). 20 YouTube videos (739K views).
-5 HN stories. 9 Polymarket markets.
-
-[Key finding in 2-3 sentences]
-
-Polymarket: [relevant odds with specific numbers]
-
-[One-liner showing the tool's value]
-
-github.com/mvanhorn/last30days-skill
-```
-
-Rules:
-- Always lead with the /last30days command that was run
-- Always include the stats line (thread/post/video counts)
-- Always end with the GitHub link
-- Pick topics people care about RIGHT NOW
-- Never use em dashes - use hyphens instead
-- Keep threads to 1-2 tweets max for daily showcases
-- Save longer threads (3-6 tweets) for releases
-
-### Phase 3: Release Manager Pipeline (Day 5-6)
-
-**How it works:**
-
-1. **Tag Watcher** - `marketing/scripts/watch_releases.py`
- - Runs `git -C /Users/mvanhorn/last30days-skill-private fetch upstream --tags` every 6 hours
- - Compares local tags vs upstream tags
- - On new tag: reads CHANGELOG.md diff since last tag
-
-2. **Thread Drafter** - `marketing/scripts/draft_release_thread.py`
- - Reads changelog diff + release-notes.md
- - Drafts a 3-6 tweet thread following the v2.5 launch thread style
- - Includes: version number, headline features, demo queries, contributor shoutouts
- - Saves to `~/Documents/Last30Days/drafts/releases/{tag}.md`
-
-3. **Approval Gate** - Same flow as content pipeline
-
-**Template (from existing docs/v2.5-launch-tweets.md):**
-
-```
-Tweet 1: Announcement + 3 headline features + GitHub link
-Tweet 2-4: One demo per tweet (command + stats + finding)
-Tweet 5: Contributor shoutouts
-Tweet 6: Install instructions
-```
-
-### Phase 4: Community Manager Pipeline (Day 6-7)
-
-**How it works:**
-
-1. **GitHub Monitor** - `marketing/scripts/github_monitor.py`
- - Runs `gh issue list --repo mvanhorn/last30days-skill --state open --json number,title,author,createdAt,labels`
- - Runs `gh pr list --repo mvanhorn/last30days-skill --state open --json number,title,author,createdAt`
- - Compares against `~/Documents/Last30Days/community/seen.json` to detect new items
- - Categorizes: bug report, feature request, question, PR
-
-2. **Response Drafter** - `marketing/scripts/draft_community_response.py`
- - For new issues: drafts a welcome + triage response
- - For new PRs: drafts a thank-you + initial review comment
- - For merged PRs: drafts a contributor shoutout tweet
- - Saves to `~/Documents/Last30Days/drafts/community/{type}-{number}.md`
-
-3. **Approval Gate** - GitHub responses go through same Paperclip approval
- - Approved responses posted via `gh issue comment` or `gh pr comment`
- - Shoutout tweets posted via `post_to_x.py`
-
-**Response templates:**
-
-```markdown
-# New Issue (bug)
-Thanks for the report! I'll look into this. Can you share:
-- Your OS and Python version
-- The exact command you ran
-- Whether you have SCRAPECREATORS_API_KEY set
-
-# New Issue (feature request)
-Interesting idea! [1-2 sentences acknowledging the value].
-Adding this to the backlog for consideration.
-
-# New PR
-Thanks for the contribution, @{author}! I'll review this shortly.
-[If first-time contributor: Welcome to the project!]
-
-# Merged PR (tweet)
-Shoutout to @{author} for [what they did] in last30days v{version}!
-[Brief description of the change and why it matters]
-github.com/mvanhorn/last30days-skill/pull/{number}
-```
-
-### Phase 5: Analytics Pipeline (Day 7-8)
-
-**How it works:**
-
-1. **Metrics Collector** - `marketing/scripts/metrics_collector.py`
- - Daily: GitHub stars, forks, open issues, open PRs (via `gh api`)
- - Daily: X followers, tweet impressions for @slashlast30days (via X API v2)
- - Stores in SQLite at `~/Documents/Last30Days/analytics.db`
-
-2. **Weekly Digest** - `marketing/scripts/weekly_digest.py`
- - Runs Monday 9 AM PT
- - Generates markdown report with week-over-week changes:
- - Star growth (absolute + rate)
- - New forks
- - Issues opened/closed
- - PRs merged
- - Top-performing tweets
- - Notable community interactions
- - Saves to `~/Documents/Last30Days/digests/{date}-weekly.md`
- - Optionally sends via email (SendGrid) or Slack webhook
-
-**Schema for analytics.db:**
-
-```sql
-CREATE TABLE daily_metrics (
- date TEXT PRIMARY KEY,
- github_stars INTEGER,
- github_forks INTEGER,
- github_open_issues INTEGER,
- github_open_prs INTEGER,
- x_followers INTEGER,
- x_impressions INTEGER,
- x_engagement_rate REAL
-);
-
-CREATE TABLE tweet_performance (
- tweet_id TEXT PRIMARY KEY,
- posted_at TEXT,
- type TEXT, -- 'showcase', 'release', 'shoutout'
- topic TEXT,
- impressions INTEGER,
- likes INTEGER,
- retweets INTEGER,
- replies INTEGER,
- link_clicks INTEGER
-);
-```
-
-## Technical Considerations
-
-### API Costs
-
-| Service | Usage | Est. Monthly Cost |
-|---------|-------|-------------------|
-| Paperclip | Self-hosted | $0 |
-| X API v2 | Free tier (posting) | $0 |
-| ScrapeCreators | ~30 daily research runs | ~$15 |
-| GitHub API | gh CLI, already authed | $0 |
-| Claude API | Marketing Director agent | ~$20 |
-| **Total** | | **~$35/month** |
-
-### Security
-
-- API keys stored in `marketing/.env` (gitignored, never committed)
-- X API tokens scoped to @slashlast30days only (not personal account)
-- Paperclip budget cap prevents runaway spend
-- All posts go through human approval gate - no autonomous posting
-- GitHub token uses existing `gh auth` session
-
-### Failure Modes
-
-- **last30days script fails** - Content Creator skips that day, logs error, tries again tomorrow with new topic
-- **X API rate limit** - Queue drafts and retry on next heartbeat cycle
-- **Paperclip goes down** - Drafts accumulate in filesystem, nothing posts (safe failure)
-- **Bad topic selection** - Marketing Director agent filters topics before research (no politics, no NSFW)
-- **Stale approval queue** - If drafts pile up >3 days unapproved, send a nudge notification
-
-### Topic Selection Criteria
-
-The discover_topics.py script should filter for topics that:
-1. Are trending NOW (Wikipedia pageview spike, HN front page, Reddit r/all)
-2. Would produce interesting multi-source results (not too niche, not too broad)
-3. Are safe for a developer tool brand (tech, sports, culture, science - avoid divisive politics)
-4. Haven't been covered in the last 7 days (dedup against previous showcases)
-5. Span different categories to show the tool's versatility (not all tech, not all sports)
-
-Good examples (from existing launch threads): "Anthropic Pete Hegseth", "Seedance prompting", "Arizona basketball", "Iran war"
-
-## Acceptance Criteria
-
-- [ ] Paperclip running locally with "last30days Marketing" company created
-- [ ] Content Creator agent runs last30days daily on a trending topic and produces a draft
-- [ ] Release Manager agent detects new git tags and drafts announcement threads
-- [ ] Community Manager agent detects new GitHub issues/PRs and drafts responses
-- [ ] Analytics agent collects daily metrics and generates weekly digest
-- [ ] All drafts go through Paperclip approval gate before posting
-- [ ] X API integration posts approved drafts to @slashlast30days
-- [ ] GitHub responses posted via gh CLI after approval
-- [ ] Monthly budget stays under $50
-- [ ] Style of generated tweets matches existing launch thread tone
-
-## Success Metrics
-
-- **Content velocity**: 5-7 showcase tweets/week (up from ~0 currently)
-- **Star growth**: Track week-over-week acceleration after content starts
-- **Time savings**: <5 min/day reviewing drafts vs 30-60 min/day manual marketing
-- **Content quality**: Drafts require minimal editing before approval (>80% approved as-is within 2 weeks)
-
-## Dependencies & Risks
-
-| Dependency | Risk | Mitigation |
-|-----------|------|------------|
-| Paperclip stability | Early-stage project, may have bugs | Pin to specific commit, keep simple config |
-| X API free tier | May get rate-limited or deprecated | Queue-based posting, daily limits |
-| Topic discovery quality | Bad topics = bad demos | Human review in approval gate, topic blocklist |
-| Claude API for Marketing Director | Cost could spike | Budget cap in Paperclip, simple prompts |
-| ScrapeCreators API | PAYG costs scale with usage | Cap at 1 research run/day for showcases |
-
-## File Structure
-
-```
-last30days-skill-private/
- marketing/
- README.md # Setup instructions
- .env.example # Required API keys
- paperclip-config.yaml # Company definition
- scripts/
- discover_topics.py # Trending topic discovery
- create_showcase.py # Research + draft showcase tweet
- draft_release_thread.py # Release announcement drafter
- github_monitor.py # GitHub issue/PR monitor
- draft_community_response.py # Community response drafter
- post_to_x.py # X API posting (after approval)
- metrics_collector.py # Daily metrics collection
- weekly_digest.py # Weekly analytics digest
- templates/
- showcase.md # Tweet template for daily demos
- release.md # Thread template for releases
- community_responses.md # Response templates by type
-```
-
-## Sources & References
-
-- Paperclip GitHub: github.com/paperclipai/paperclip
-- Paperclip docs: paperclip.ing
-- Existing launch threads: `docs/v2.5-launch-tweets.md`, `docs/v2.1-tweets.md`
-- Existing launch copy: `docs/v2.1-launch-copy.md`
-- Planned Last30Days.com: `docs/plans/2026-02-20-feat-last30days-com-trending-topics-plan.md`
-- last30days `--agent` mode: SKILL.md line 115-142 (non-interactive output for automation)
diff --git a/docs/plans/2026-03-07-review-open-issues-and-prs-triage-plan.md b/docs/plans/2026-03-07-review-open-issues-and-prs-triage-plan.md
deleted file mode 100644
index 1b378a0..0000000
--- a/docs/plans/2026-03-07-review-open-issues-and-prs-triage-plan.md
+++ /dev/null
@@ -1,265 +0,0 @@
----
-title: Triage Open Issues and PRs
-type: refactor
-status: active
-date: 2026-03-07
----
-
-# Triage Open Issues and PRs - last30days-skill
-
-Consolidated review and action plan for all 6 open items (2 issues, 4 PRs) as of 2026-03-07.
-
----
-
-## 1. PR #52 - Fix missing metadata files in skill upload bundle
-
-**Author:** 04cb | **Size:** +0/-1 | **Fixes:** #46
-**File changed:** `.clawhubignore` (removes `*.json` glob)
-
-### What it does
-
-The `*.json` pattern in `.clawhubignore` was excluding `.claude-plugin/marketplace.json` and `.claude-plugin/plugin.json` - the metadata files required for ClawHub skill upload. This caused the "Zip file contains path with invalid characters" error reported in #46.
-
-### Risk analysis
-
-- Removing `*.json` also includes test fixtures (`fixtures/*.json`) and vendored `package.json` in the bundle. None are sensitive or harmful.
-- No `package-lock.json`, `skills-lock.json`, or credential files exist as `.json` in the repo.
-- One-line change with zero production code impact.
-
-### Recommendation: MERGE IMMEDIATELY
-
-- [x] Merge PR #52
-- [x] Close issue #46 (auto-closes via "Fixes #46")
-- [ ] Verify upload works post-merge
-
----
-
-## 2. Issue #46 - "Claude will not upload as it says thread is invalid"
-
-**Author:** johnuppard | **Error:** "Zip file contains path with invalid characters"
-
-### Recommendation: CLOSES WITH PR #52
-
-No standalone action needed. PR #52 is the fix. After merge, comment on #46 confirming resolution and ask reporter to verify.
-
----
-
-## 3. PR #50 - test: add tests for entity_extract module
-
-**Author:** mark-c4r | **Size:** +167/-0 (tests only)
-
-### What it does
-
-Adds `tests/test_entity_extract.py` with 23 test cases covering all 4 public/private functions in `scripts/lib/entity_extract.py`:
-
-| Function | Cases | Coverage |
-|----------|-------|----------|
-| `_extract_x_handles` | 8 | author handles, @mentions, generic filtering, case normalization, frequency ranking, edge cases |
-| `_extract_x_hashtags` | 5 | basic extraction, multiple tags, frequency ranking, short tag filtering, empty input |
-| `_extract_subreddits` | 6 | field extraction, cross-refs in comments, frequency ranking, r/ stripping |
-| `extract_entities` | 4 | integration, max limits, empty inputs, return key validation |
-
-### Code quality assessment
-
-- All function signatures match the actual module implementation exactly
-- All 23 tests validated against the real module - they pass
-- Import pattern matches existing tests (`sys.path.insert` + `from lib import`)
-- Uses `unittest.TestCase` consistent with all other test files
-- No external dependencies, no API calls, no mocking needed (pure functions)
-- Test data uses inline dicts matching real Reddit/X response structure
-
-### Recommendation: MERGE AFTER LOCAL VERIFICATION
-
-- [x] Pull branch and run `python3 -m pytest tests/test_entity_extract.py -v` (23/23 passed)
-- [x] Run full suite `python3 -m pytest tests/` (317/320 passed - 3 pre-existing failures in test_models.py, unrelated)
-- [x] Merge
-
-This is a clean, well-structured community contribution that adds coverage to a previously untested module.
-
----
-
-## 4. PR #48 - feat: add Xiaohongshu source + Reddit public fallback
-
-**Author:** YJLi-new | **Size:** +523/-75 | **Files:** 5 modified
-
-### What it does
-
-Two features bundled in one PR:
-
-**A. Xiaohongshu (Little Red Book) source:**
-- New `scripts/lib/xiaohongshu_api.py` module for searching via xiaohongshu-mcp HTTP API
-- Source alias: `--search xiaohongshu` or `--search xhs`
-- Health/login availability checks in `env.py`
-- Handles Chinese numeric suffixes (wan/yi) in engagement parsing
-- Default API base: `http://host.docker.internal:18060` (Docker-hosted)
-
-**B. Reddit public JSON fallback:**
-- `search_reddit_public()` added to `openai_reddit.py`
-- Uses `reddit.com/search/.json` endpoint (no API key required)
-- Multiple query strategy (topic, core subject, quoted core)
-- Engagement-based relevance heuristic (60% score, 40% comments)
-- Supplemental retries correctly gated - only fire when OpenAI auth is present
-
-### Architecture compliance
-
-| Pattern | Status |
-|---------|--------|
-| Three-function module pattern (search/parse/enrich) | Follows existing patterns |
-| `DEPTH_CONFIG` with quick/default/deep | Present |
-| `_log()` gated on stderr.isatty() | Present |
-| env.py availability check | Present with retry logic |
-| ThreadPoolExecutor dispatch | Correctly wired with +1 worker |
-| Render/UI integration | Consistent with existing sources |
-
-### Issues found
-
-1. **Health check duplication** - `is_xiaohongshu_available()` in env.py AND `search_feeds()` both probe login status. Redundant but harmless.
-2. **`from_date`/`to_date` accepted but unused** for Xiaohongshu - relies on API-side time bucketing. Acceptable given API limitation.
-3. **Over-defensive `setdefault()`** in normalization (id and source_domain already set by search_feeds). Harmless.
-4. **Error message format inconsistency** between OpenAI and public Reddit paths. Minor.
-5. **`get_available_sources()` docstring** not updated to reflect Reddit always being available now.
-6. **No tests included** for new Xiaohongshu module or Reddit public fallback.
-
-### Security
-
-- No hardcoded credentials
-- xsec_token comes from API response (not user input)
-- Docker `host.docker.internal` default is safe for containerized environments
-- All URLs use `https://` for public Xiaohongshu
-
-### Recommendation: MERGE WITH CONDITIONS
-
-The Reddit public fallback alone makes this worth merging - it makes Reddit work with zero API keys. Xiaohongshu adds value for Chinese-market research.
-
-**Before merge:**
-- [x] Run full test suite to confirm no regressions (292/297 pass, 5 pre-existing)
-- [x] Verify Xiaohongshu gracefully skips when API is unavailable (returns False, no crash)
-- [x] Resolved merge conflicts with ScrapeCreators Reddit (main). Priority: ScrapeCreators -> OpenAI -> public fallback
-- [x] Merged to main and pushed
-- [ ] Test Reddit public fallback locally: unset OPENAI_API_KEY, run `python3 scripts/last30days.py "test topic" --search reddit --emit=compact`
-- [ ] Update `get_available_sources()` docstring to reflect Reddit always-available change
-
-**After merge (follow-up):**
-- [ ] Add `tests/test_xiaohongshu.py` with fixture-based tests
-- [ ] Add `tests/test_reddit_public.py` for the fallback path
-- [ ] Consider extracting health check duplication
-
----
-
-## 5. PR #47 - feat: add Apify as unified API provider
-
-**Author:** lapolazzati | **Size:** +1484/-73 | **Files:** 6 new + orchestrator changes
-
-### What it does
-
-Adds Apify as a single-token alternative (`APIFY_API_TOKEN`) covering Reddit, X, TikTok, and Instagram. Existing per-source keys take priority; Apify is a transparent fallback.
-
-**New modules:**
-- `apify_client.py` (163 lines) - shared HTTP client for Apify run-sync API
-- `apify_reddit.py` (195 lines) - Reddit via `trudax/reddit-scraper` actor
-- `apify_x.py` (221 lines) - X via `apidojo/tweet-scraper` actor
-- `apify_tiktok.py` (284 lines) - TikTok via `clockworks/tiktok-scraper` actor
-- `apify_instagram.py` (288 lines) - Instagram via `apify/instagram-reel-scraper` actor
-
-**Source routing priority:**
-```
-Reddit: OpenAI -> Apify -> None
-X: Bird -> xAI -> Apify -> None
-TikTok: ScrapeCreators -> Apify -> None
-Instagram: ScrapeCreators -> Apify -> None
-```
-
-### Critical issues
-
-1. **Actor ID mismatch (BLOCKER):** Code uses different Apify actors than documented in README and plan.md.
-
- | Source | In Code | In Docs |
- |--------|---------|---------|
- | Reddit | `trudax/reddit-scraper` | `automation-lab/reddit-scraper` |
- | X | `apidojo/tweet-scraper` | `scraper_one/x-posts-search` |
- | TikTok | `clockworks/tiktok-scraper` | `epctex/tiktok-search-scraper` |
- | Instagram | `apify/instagram-reel-scraper` | matches |
-
- Users following README instructions will hit wrong actors.
-
-2. **No tests (BLOCKER):** 1484 lines of new code with zero test coverage. Each Apify module has its own date parsing, relevance scoring, and response normalization - all untested.
-
-3. **Significant code duplication:** `apify_tiktok.py` and `apify_instagram.py` share nearly identical:
- - `_tokenize()` (7 lines)
- - `_compute_relevance()` (14 lines)
- - `STOPWORDS` set
- - `SYNONYMS` dict
- - `_extract_core_subject()` (~20 lines)
-
-4. **Version mismatch:** README says v2.9 but git history shows v2.9.4 on main. Version should be higher.
-
-5. **plan.md included in commit** - implementation planning doc shouldn't be in the final merge.
-
-### What's good
-
-- Source routing logic in env.py is clean and backward-compatible
-- Orchestrator dispatch uses consistent `_source` parameter pattern
-- Existing source paths are completely untouched
-- Timeout scaling is appropriate (90s quick, 150s default, 240s deep)
-- Token handling follows security best practices (parameter passing, Bearer auth)
-
-### Recommendation: REQUEST CHANGES
-
-This PR is too large and has too many issues for a clean merge. Request the contributor to:
-
-**Must fix before any merge:**
-- [ ] Resolve actor ID mismatches (verify which actors actually work, update code OR docs)
-- [ ] Add unit tests for all 5 new modules (at minimum: response normalization, date parsing)
-- [ ] Fix version number
-- [ ] Remove plan.md from commit
-
-**Should fix:**
-- [ ] Extract shared code from apify_tiktok/apify_instagram into `apify_common.py`
-- [ ] Consider splitting into 2 PRs:
- - PR A: `apify_client.py` + env.py routing (foundation)
- - PR B: Individual source modules + tests (features)
-
-**Status:** [x] Review posted requesting changes (2026-03-07)
-
-**Comment template for PR:**
-> Thanks for this contribution - the single-token approach is a great idea for simplifying setup. A few things need fixing before we can merge:
->
-> 1. The Apify actor IDs in the code don't match the README/plan docs. Can you verify which actors are correct and align code + docs?
-> 2. We need test coverage for the new modules - at minimum normalization tests with sample actor responses.
-> 3. There's significant duplication between apify_tiktok.py and apify_instagram.py (_tokenize, _compute_relevance, STOPWORDS, SYNONYMS, _extract_core_subject). Could you extract shared code to an apify_common.py?
-> 4. Version in README should be higher than v2.9.4 (current main).
-> 5. Please remove plan.md from the commit.
-
----
-
-## 6. Issue #45 - Add support for Gemini CLI
-
-**Author:** alexferrari88 | **Request:** "Would it be possible to add support for Gemini CLI?"
-
-### Assessment
-
-This is a feature request to support Gemini CLI as a runtime alongside Claude Code and Codex. Key considerations:
-
-- **Scope:** The skill is currently packaged for Claude Code (SKILL.md format) and Codex (~/.codex/skills/). Gemini CLI uses a different skill/extension format.
-- **Effort:** Would require understanding Gemini CLI's plugin system, creating a compatible manifest, and potentially adapting the Python execution model.
-- **Priority:** Low - Claude Code and Codex are the primary targets and where the user base is.
-- **Community:** If the requester wants to contribute, they'd be best positioned to understand Gemini CLI's requirements.
-
-### Recommendation: ACKNOWLEDGE AND BACKLOG
-
-- [x] Respond with comment acknowledging and inviting contribution
-- [x] Add a `help wanted` label
-- [x] Keep open as a backlog item
-
----
-
-## Priority Summary
-
-| Priority | Item | Action | Risk |
-|----------|------|--------|------|
-| 1 | PR #52 (metadata fix) | ~~Merge now~~ DONE | None |
-| 2 | Issue #46 (upload error) | ~~Auto-closes with PR #52~~ DONE | None |
-| 3 | PR #50 (entity tests) | ~~Run tests, merge~~ DONE | None |
-| 4 | PR #48 (Xiaohongshu + Reddit fallback) | ~~Test locally, merge with conditions~~ DONE | Low |
-| 5 | Issue #45 (Gemini CLI) | ~~Acknowledge, backlog, label~~ DONE | None |
-| 6 | PR #47 (Apify unified) | ~~Request changes~~ DONE (awaiting contributor) | Medium-High |
diff --git a/docs/plans/2026-03-08-feat-claude-code-plugin-marketplace-plan.md b/docs/plans/2026-03-08-feat-claude-code-plugin-marketplace-plan.md
deleted file mode 100644
index 33c4941..0000000
--- a/docs/plans/2026-03-08-feat-claude-code-plugin-marketplace-plan.md
+++ /dev/null
@@ -1,197 +0,0 @@
----
-title: "feat: Publish last30days as Claude Code marketplace plugin"
-type: feat
-status: completed
-date: 2026-03-08
----
-
-# feat: Publish last30days as Claude Code Marketplace Plugin
-
-## Overview
-
-Publish the last30days skill as a Claude Code plugin so users can install it with:
-
-```
-/plugin marketplace add mvanhorn/last30days-skill
-/plugin install last30days@last30days-skill
-```
-
-This gives users auto-updates, versioned installs, and the standard plugin management UX instead of manual git clone.
-
-## Current State
-
-The repo already has most of what's needed:
-
-| Component | Status | Notes |
-|-----------|--------|-------|
-| `SKILL.md` | Exists | Source of truth at repo root |
-| `skills/last30days/SKILL.md` | Exists (symlink) | Points to root SKILL.md, added for Gemini CLI |
-| `scripts/` | Exists | Python research engine |
-| `gemini-extension.json` | Exists | Gemini CLI support |
-| `.claude-plugin/marketplace.json` | **Missing** | Needed to make repo a marketplace |
-| `.claude-plugin/plugin.json` | **Missing** | Needed (in plugin subdir) to define the plugin |
-| `plugins/last30days/` | **Missing** | Plugin directory structure |
-
-## How It Works (compound-engineering as reference)
-
-The repo serves as BOTH a **marketplace** (distribution channel) and contains **plugins**:
-
-```
-repo-root/ # = marketplace
-├── .claude-plugin/
-│ └── marketplace.json # Makes repo discoverable as marketplace
-└── plugins/
- └── plugin-name/ # = individual plugin
- ├── .claude-plugin/
- │ └── plugin.json # Plugin manifest
- └── skills/
- └── skill-name/
- └── SKILL.md
-```
-
-## Implementation Plan
-
-### 1. Create marketplace manifest
-
-#### `.claude-plugin/marketplace.json`
-
-```json
-{
- "$schema": "https://anthropic.com/claude-code/marketplace.schema.json",
- "name": "last30days-skill",
- "description": "Research any topic from the last 30 days across Reddit, X, YouTube, TikTok, Instagram, Hacker News, Polymarket, and the web.",
- "owner": {
- "name": "Matt Van Horn",
- "url": "https://github.com/mvanhorn"
- },
- "plugins": [
- {
- "name": "last30days",
- "description": "Research any topic from the last 30 days. Become an expert and write copy-paste-ready prompts.",
- "version": "2.9.5",
- "author": {
- "name": "Matt Van Horn",
- "url": "https://github.com/mvanhorn"
- },
- "source": "./plugins/last30days",
- "category": "productivity",
- "homepage": "https://github.com/mvanhorn/last30days-skill"
- }
- ]
-}
-```
-
-### 2. Create plugin directory
-
-#### `plugins/last30days/.claude-plugin/plugin.json`
-
-```json
-{
- "name": "last30days",
- "version": "2.9.5",
- "description": "Research any topic from the last 30 days across Reddit, X, YouTube, TikTok, Instagram, Hacker News, Polymarket, and the web.",
- "author": {
- "name": "Matt Van Horn",
- "email": "mvanhorn@gmail.com",
- "url": "https://github.com/mvanhorn"
- },
- "homepage": "https://github.com/mvanhorn/last30days-skill",
- "repository": "https://github.com/mvanhorn/last30days-skill",
- "license": "MIT",
- "keywords": [
- "research",
- "reddit",
- "twitter",
- "youtube",
- "tiktok",
- "trends",
- "prompts"
- ]
-}
-```
-
-### 3. Symlink skill and scripts into plugin directory
-
-```bash
-mkdir -p plugins/last30days/.claude-plugin
-mkdir -p plugins/last30days/skills/last30days
-
-# Symlink SKILL.md (single source of truth)
-ln -s ../../../../SKILL.md plugins/last30days/skills/last30days/SKILL.md
-
-# Symlink scripts directory (the Python engine)
-ln -s ../../scripts plugins/last30days/scripts
-
-# Symlink fixtures if needed
-ln -s ../../fixtures plugins/last30days/fixtures
-```
-
-### 4. Update README with plugin install instructions
-
-Add before the manual install section:
-
-```markdown
-### Plugin Install (recommended)
-```
-/plugin marketplace add mvanhorn/last30days-skill
-/plugin install last30days@last30days-skill
-```
-
-Then configure your API keys:
-```bash
-mkdir -p ~/.config/last30days
-# Add SCRAPECREATORS_API_KEY to your env
-```
-```
-
-### 5. Version bump workflow
-
-When releasing new versions, update version in ALL manifests:
-- `SKILL.md` frontmatter `version:`
-- `gemini-extension.json` `version`
-- `plugins/last30days/.claude-plugin/plugin.json` `version`
-- `.claude-plugin/marketplace.json` plugins[0].version
-
-Consider: a `scripts/bump-version.sh` that updates all four in one command.
-
-## Open Question: Symlinks in Plugin Cache
-
-**Risk:** When Claude Code installs a plugin, it clones the repo into `~/.claude/plugins/cache/`. Git preserves symlinks on macOS/Linux, so `plugins/last30days/skills/last30days/SKILL.md -> ../../../../SKILL.md` should resolve correctly because the full repo is cloned.
-
-**Mitigation if symlinks fail:** Replace symlinks with a small build step in sync.sh that copies SKILL.md into the plugin directory. Or just copy the file and accept the duplication in the plugin directory (since the plugin cache is a read-only clone anyway - drift only matters in the source repo, not the installed copy).
-
-**Test:** After implementing, verify with `git clone` into a temp dir and check symlink resolution.
-
-## Acceptance Criteria
-
-- [ ] `.claude-plugin/marketplace.json` exists with correct schema
-- [ ] `plugins/last30days/.claude-plugin/plugin.json` exists
-- [ ] `plugins/last30days/skills/last30days/SKILL.md` resolves to root SKILL.md
-- [ ] `plugins/last30days/scripts` resolves to root scripts/
-- [ ] README has plugin install instructions
-- [ ] `/plugin marketplace add mvanhorn/last30days-skill` works
-- [ ] `/plugin install last30days@last30days-skill` works
-- [ ] `/last30days test topic` works after plugin install
-- [ ] Standalone install (git clone to ~/.claude/skills/) still works
-- [ ] Gemini CLI install still works
-
-## User Experience After Publishing
-
-```
-# One-time setup
-/plugin marketplace add mvanhorn/last30days-skill
-/plugin install last30days@last30days-skill
-
-# Use it
-/last30days AI video tools for business
-
-# Auto-updates happen at startup when version bumps
-```
-
-## Sources
-
-- Claude Code plugin docs: https://code.claude.com/docs/en/plugins
-- Plugin reference: https://code.claude.com/docs/en/plugins-reference
-- Marketplace docs: https://code.claude.com/docs/en/plugin-marketplaces
-- compound-engineering reference: https://github.com/EveryInc/compound-engineering-plugin
-- Your installed plugins: superpowers (4.3.1), swift-lsp (1.0.0), compound-engineering (2.38.1)
diff --git a/docs/plans/2026-03-08-feat-gemini-cli-support-implementation-plan.md b/docs/plans/2026-03-08-feat-gemini-cli-support-implementation-plan.md
deleted file mode 100644
index ac862b9..0000000
--- a/docs/plans/2026-03-08-feat-gemini-cli-support-implementation-plan.md
+++ /dev/null
@@ -1,180 +0,0 @@
----
-title: "feat: Add Gemini CLI extension support"
-type: feat
-status: completed
-date: 2026-03-08
-origin: docs/plans/2026-03-08-review-pr-53-gemini-cli-support-plan.md
----
-
-# feat: Add Gemini CLI Extension Support
-
-## Overview
-
-Add Gemini CLI compatibility to last30days by cherry-picking the good parts from PR #53 (@alexferrari88) and fixing the implementation problems ourselves. Close PR #53 with a thank-you comment crediting the contributor.
-
-Based on the review at `docs/plans/2026-03-08-review-pr-53-gemini-cli-support-plan.md`, we accept the concept but fix the approach to match our "one SKILL.md for all platforms" convention established during Codex compatibility work.
-
-## What We Take from PR #53
-
-| Component | Source | Changes Needed |
-|-----------|--------|----------------|
-| `gemini-extension.json` | PR #53 | Fix settings format (array, not object), bump version to 2.9.5 |
-| Path resolution (for loop) | PR #53 | Take as-is, add to both SKILL.md and variants/open/SKILL.md |
-| README.md install section | PR #53 | Take as-is |
-
-## What We Skip from PR #53
-
-| Component | Reason |
-|-----------|--------|
-| `skills/last30days/SKILL.md` (693-line copy) | Duplicate maintenance nightmare - use symlink instead |
-| "or" tool name scattering in SKILL.md | LLMs translate tool intent natively - clutters prompt |
-| Gemini tool names in `allowed-tools` | Gemini ignores `allowed-tools`; risks breaking Claude Code |
-
-## Implementation Steps
-
-### 1. Create `gemini-extension.json` (new file)
-
-Based on PR #53 but with correct settings format (array per Gemini CLI docs) and v2.9.5 version.
-
-```json
-{
- "name": "last30days-skill",
- "version": "2.9.5",
- "description": "Research a topic from the last 30 days across Reddit, X, YouTube, TikTok, Instagram, Hacker News, Polymarket, and the web.",
- "settings": [
- {
- "name": "Extension Directory",
- "description": "Extension installation directory (auto-set by Gemini CLI)",
- "envVar": "GEMINI_EXTENSION_DIR",
- "sensitive": false
- },
- {
- "name": "ScrapeCreators API Key",
- "description": "ScrapeCreators API Key for Reddit, TikTok, and Instagram search (required)",
- "envVar": "SCRAPECREATORS_API_KEY",
- "sensitive": true
- },
- {
- "name": "OpenAI API Key",
- "description": "OpenAI API Key - optional fallback for Reddit discovery",
- "envVar": "OPENAI_API_KEY",
- "sensitive": true
- },
- {
- "name": "xAI API Key",
- "description": "xAI API Key for X/Twitter search (optional)",
- "envVar": "XAI_API_KEY",
- "sensitive": true
- },
- {
- "name": "OpenRouter API Key",
- "description": "OpenRouter API Key (optional)",
- "envVar": "OPENROUTER_API_KEY",
- "sensitive": true
- },
- {
- "name": "Parallel AI API Key",
- "description": "Parallel AI API Key (optional)",
- "envVar": "PARALLEL_API_KEY",
- "sensitive": true
- },
- {
- "name": "Brave Search API Key",
- "description": "Brave Search API Key (optional)",
- "envVar": "BRAVE_API_KEY",
- "sensitive": true
- },
- {
- "name": "Apify API Token",
- "description": "Apify API Token (optional legacy)",
- "envVar": "APIFY_API_TOKEN",
- "sensitive": true
- },
- {
- "name": "Twitter AUTH_TOKEN",
- "description": "Twitter browser AUTH_TOKEN cookie for direct X search (optional)",
- "envVar": "AUTH_TOKEN",
- "sensitive": true
- },
- {
- "name": "Twitter CT0",
- "description": "Twitter browser CT0 cookie (optional, pair with AUTH_TOKEN)",
- "envVar": "CT0",
- "sensitive": true
- }
- ]
-}
-```
-
-### 2. Create `skills/last30days/SKILL.md` as symlink
-
-```bash
-mkdir -p skills/last30days
-ln -s ../../SKILL.md skills/last30days/SKILL.md
-```
-
-Gemini CLI discovers skills from `skills//SKILL.md`. Symlink ensures single source of truth - no drift, no duplicate maintenance. Git tracks symlinks natively on macOS/Linux (Gemini CLI's target platforms).
-
-### 3. Add Gemini paths to SKILL.md for-loop (line ~172)
-
-Add these 3 entries after `"${CLAUDE_PLUGIN_ROOT:-}"`:
-
-```bash
-"${GEMINI_EXTENSION_DIR:-}" \
-"$HOME/.gemini/extensions/last30days-skill" \
-"$HOME/.gemini/extensions/last30days" \
-```
-
-### 4. Add same Gemini paths to `variants/open/SKILL.md` for-loop
-
-Same 3 entries, same position.
-
-### 5. Update `README.md` install section
-
-Add Gemini CLI install before existing Claude Code section:
-
-```markdown
-### Gemini CLI
-\`\`\`bash
-gemini extensions install https://github.com/mvanhorn/last30days-skill.git
-\`\`\`
-
-### Claude Code / Codex
-```
-
-### 6. Close PR #53 with credit
-
-Post comment thanking @alexferrari88, explaining we incorporated their work with modifications, and close the PR. Credit them in the commit message.
-
-## Acceptance Criteria
-
-- [ ] `gemini-extension.json` exists with correct array-format settings
-- [ ] `skills/last30days/SKILL.md` is a symlink to `../../SKILL.md`
-- [ ] Gemini path entries in SKILL.md for-loop (3 new entries)
-- [ ] Gemini path entries in variants/open/SKILL.md for-loop (3 new entries)
-- [ ] README.md has Gemini CLI install instructions
-- [ ] No changes to `allowed-tools` in any SKILL.md
-- [ ] No "or" tool name alternatives in any SKILL.md body
-- [ ] Claude Code still works (`/last30days` runs normally)
-- [ ] PR #53 closed with credit to @alexferrari88
-- [ ] Commit message credits @alexferrari88 as co-author
-
-## Confidence Assessment
-
-**High confidence.** All changes are additive:
-- New file (gemini-extension.json) - zero conflict risk
-- Symlink (skills/last30days/SKILL.md) - zero conflict risk
-- 3 lines added to a for-loop - trivially safe, short-circuits on first match
-- README addition - simple text
-- No changes to SKILL.md content, allowed-tools, or Python scripts
-- No changes that could break existing Claude Code behavior
-
-The only untested aspect is whether Gemini CLI correctly follows the symlink for skill discovery. If symlinks cause issues, fallback is a 2-line SKILL.md stub that imports the root (but this is unlikely - Gemini CLI runs on macOS/Linux where symlinks are native).
-
-## Sources
-
-- PR #53: https://github.com/mvanhorn/last30days-skill/pull/53
-- Issue #45: https://github.com/mvanhorn/last30days-skill/issues/45
-- Gemini CLI extension reference (settings array format confirmed): https://geminicli.com/docs/extensions/reference/
-- Gemini CLI skills docs: https://geminicli.com/docs/cli/creating-skills/
-- Review plan: `docs/plans/2026-03-08-review-pr-53-gemini-cli-support-plan.md`
diff --git a/docs/plans/2026-03-08-feat-perpetual-monitoring-mode-plan.md b/docs/plans/2026-03-08-feat-perpetual-monitoring-mode-plan.md
deleted file mode 100644
index 0614b14..0000000
--- a/docs/plans/2026-03-08-feat-perpetual-monitoring-mode-plan.md
+++ /dev/null
@@ -1,238 +0,0 @@
----
-title: "feat: Perpetual Monitoring Mode"
-type: feat
-status: active
-date: 2026-03-08
----
-
-# Perpetual Monitoring Mode
-
-## Overview
-
-Add a "perpetual monitoring" mode to last30days that lets users track topics over time with cumulative intelligence, delta reporting, and effortless re-runs. Designed for Claude Code's session model - not server cron.
-
-**User signal:** @JTDaly asked "Can your skill be used to do this perpetually?" in response to a Perplexity AI S&P 500 earnings dashboard that auto-refreshes quarterly.
-
-## Problem Statement
-
-last30days is one-shot: ask, get a briefing, done. But the most valuable research is longitudinal - how conversations evolve, when new voices enter, when engagement spikes. Users want "set it and watch" without re-prompting from scratch every time.
-
-## The Scheduling Reality in Claude Code
-
-| Mechanism | Persistence | Max Duration | Fires When |
-|-----------|------------|-------------|------------|
-| **CronCreate / `/loop`** | Session-only (RAM) | 3 days, auto-expires | REPL idle |
-| **System cron/launchd** | Permanent | Forever | Always |
-| **SQLite watchlist** | Permanent (disk) | Forever | On demand |
-
-**Key insight:** Don't build a scheduler. Build a **stateful watchlist** that composes with Claude Code's existing `/loop` for in-session automation, and survives across sessions via SQLite for manual re-runs. The `/loop` skill already exists and does scheduling perfectly - just make last30days a good citizen of it.
-
-## What Already Exists
-
-| Component | File | Status |
-|-----------|------|--------|
-| Topic CRUD + schedule fields | `scripts/watchlist.py` | Working |
-| SQLite persistence with URL dedup, sighting counts | `scripts/store.py` | Working |
-| Daily/weekly briefing generation | `scripts/briefing.py` | Working |
-| Budget tracking (daily cap) | `scripts/store.py` | Working |
-| FTS5 full-text search | `scripts/store.py` | Working |
-| WAL mode for concurrent access | `scripts/store.py` | Working |
-| `delivery_channel` setting in DB | `scripts/store.py` | Schema only |
-| SKILL.md watchlist commands | `SKILL.md` | Missing |
-
-## Proposed Solution
-
-### The Composable Pattern
-
-Instead of building scheduling into last30days, make last30days composable with Claude Code's existing tools:
-
-```
-# One-shot: research a topic and store findings
-/last30 "S&P 500 earnings" --watch
-
-# See what's new since last run (delta briefing)
-/last30 briefing
-
-# Automate with /loop (Claude Code native, session-scoped, 3-day max)
-/loop 4h /last30 briefing
-
-# Next session? Watchlist persists. Just re-loop or run manually.
-/last30 briefing
-```
-
-The user's watchlist lives in SQLite forever. The scheduling is ephemeral by design - you opt into it each session. This matches how people actually use Claude Code: sessions, not servers.
-
-### Phase 1: Watchlist + Briefing via SKILL.md (MVP)
-
-Wire up the existing `watchlist.py` and `briefing.py` infrastructure through the skill interface.
-
-**Tasks:**
-
-- [ ] **SKILL.md additions** - New command branches:
- - `/last30 watch add "topic"` - Add topic to watchlist, run initial research with `--store`
- - `/last30 watch list` - Show watched topics with last-run timestamps and finding counts
- - `/last30 watch remove "topic"` - Remove from watchlist
- - `/last30 briefing` - Generate delta briefing across all watched topics (what's new)
- - `/last30 briefing --weekly` - Weekly digest with trend analysis
- - `/last30 "topic" --watch` - One-shot research that also adds to watchlist
-
-- [ ] **`watchlist.py` updates:**
- - `run-all` auto-adds `--store` flag so results persist
- - `run-all` returns structured JSON (exit code + summary) for `/loop` consumption
- - `run-one` returns per-topic summary for composability
- - Add `--quick` default for watched topics (save API cost on recurring runs)
-
-- [ ] **`briefing.py` updates:**
- - `generate` outputs compact format suitable for Claude synthesis (like main script's `--emit=compact`)
- - Include "last run" timestamp per topic so user knows freshness
- - Flag stale topics (not updated in 48+ hours)
-
-**Acceptance criteria:**
-- [ ] `/last30 watch add "AI earnings"` persists topic and runs initial research
-- [ ] `/last30 briefing` shows accumulated findings with "new since last briefing" markers
-- [ ] `/loop 4h /last30 briefing` works out of the box (composability)
-- [ ] Watchlist survives across Claude Code sessions (SQLite)
-- [ ] Budget cap enforced (default $5/day)
-
-### Phase 2: Delta Intelligence
-
-Make briefings show what *changed*, not just what exists.
-
-**Tasks:**
-
-- [ ] **`scripts/lib/delta.py`** (~150 lines):
- - New findings since last briefing (URLs with `first_seen > last_briefing_time`)
- - Engagement spikes (>2x increase in score since last sighting)
- - New voices (new @handles appearing for first time in a topic)
- - Gone quiet (topics with no new findings in 2+ runs)
-
-- [ ] **`briefing.py` delta integration:**
- - Section: "Breaking" - high engagement + first seen this run
- - Section: "Trending" - engagement increasing across runs
- - Section: "New voices" - handles not seen before
- - Section: "Gone quiet" - previously active, now silent
-
-- [ ] **Smart re-run logic in `watchlist.py`:**
- - Skip topics that were updated < 4 hours ago (avoid redundant API calls)
- - Prioritize topics with most engagement change potential
- - `--force` flag to override skip logic
-
-**Acceptance criteria:**
-- [ ] Briefings clearly distinguish new vs. previously-seen findings
-- [ ] Engagement spikes flagged with specific metric ("upvotes 2.3x since yesterday")
-- [ ] Redundant API calls avoided via smart skip logic
-
-### Phase 3: Delivery + `/loop` Integration
-
-Make the monitoring truly hands-off during a session.
-
-**Tasks:**
-
-- [ ] **Slack delivery** (`scripts/lib/deliver.py` ~100 lines):
- - Webhook URL config: `watchlist.py config delivery slack --webhook "https://..."`
- - Format briefing as Slack Block Kit (topic sections, trend indicators)
- - Auto-deliver after `run-all` if webhook configured
-
-- [ ] **`/last30 monitor` convenience command:**
- - Shorthand that does: `run-all` + `briefing` + starts `/loop` automatically
- - Prints: "Monitoring 5 topics every 4h. Auto-expires in 3 days. Run `/last30 monitor` again next session."
- - Uses CronCreate directly (no `/loop` dependency) for tighter control
-
-- [ ] **Session resume hint:**
- - On `/last30 briefing`, if watchlist has topics but no `/loop` active, suggest:
- "You have 5 watched topics. Run `/loop 4h /last30 briefing` to auto-refresh, or `/last30 monitor` for hands-off mode."
-
-**Acceptance criteria:**
-- [ ] Slack webhook delivery works end-to-end
-- [ ] `/last30 monitor` starts automated loop with one command
-- [ ] Clear messaging about 3-day session limit and how to resume
-
-## Architecture: Why This Is Better Than Server Cron
-
-```
-Traditional approach (rejected):
- System Cron -> watchlist.py run-all -> SQLite -> ??? deliver somehow
-
-Claude Code-native approach:
- /last30 watch add "topic" --> SQLite (persists forever)
- /loop 4h /last30 briefing --> CronCreate (session, 3-day max)
- |
- v
- Claude reads briefing.py output
- Claude synthesizes with LLM judgment
- Claude delivers via Slack webhook
- Claude answers follow-up questions in context
-```
-
-The Claude Code-native approach is better because:
-1. **LLM synthesis on every run** - not just raw data, but judgment ("this is unusual because...")
-2. **Conversational** - user can ask follow-ups ("tell me more about the META earnings spike")
-3. **Zero infrastructure** - no plist, no crontab, no daemon management
-4. **Portable** - works on any OS where Claude Code runs
-5. **Composable** - `/loop` is a general-purpose tool, not custom scheduling code
-
-## Alternative Approaches Considered
-
-### 1. Build custom scheduler.py with system cron/launchd
-**Rejected.** Doesn't work in Claude Code's model. Platform-specific. Requires root/sudo for some configs. Users of a Claude Code skill shouldn't need to manage system daemons.
-
-### 2. OpenClaw's persistent cron service
-**Not applicable.** OpenClaw has its own cron system with database-backed scheduling, but last30days is an open-source skill that should work without OpenClaw. Could be an optional integration later.
-
-### 3. Long-running Python daemon
-**Rejected.** Fragile, wastes resources, doesn't benefit from LLM synthesis on each run.
-
-### 4. Web dashboard with its own backend
-**Deferred to Phase 4.** Dramatically increases scope. The briefing-to-Slack pattern delivers 80% of the value with 10% of the effort.
-
-## Technical Considerations
-
-**No new services.** Pure Python scripts + SQLite + SKILL.md instructions. Zero infrastructure.
-
-**Cost control.** Each watched topic costs ~$0.05-0.30/run at `--quick` depth. 10 topics x 6 runs/day = $3-18/day. Budget cap in store.py already enforces limits.
-
-**Composability contract.** `briefing.py generate` must output clean, parseable text that Claude can synthesize. No interactive prompts, no side effects beyond SQLite writes.
-
-**Backwards compatibility.** All new. Existing `/last30 topic` unchanged. `--watch` flag is opt-in.
-
-## Dependencies & Risks
-
-| Risk | Likelihood | Mitigation |
-|------|-----------|------------|
-| Users expect "perpetual" to mean forever | High | Clear messaging: "3-day auto-expire per session, watchlist persists, re-run next session" |
-| `/loop` changes or breaks | Low | Composability means we don't depend on `/loop` internals - just CronCreate |
-| Budget overrun with many topics | Low | Budget cap already implemented in store.py |
-| SQLite grows large over months | Low | Add 90-day retention policy |
-| Slack webhook stops working | Low | Log failures, don't block pipeline, alert in next briefing |
-
-## Success Metrics
-
-- User can go from zero to monitoring in one command: `/last30 "S&P 500 earnings" --watch`
-- `/last30 briefing` surfaces genuinely new information with delta markers
-- The system composes cleanly with `/loop` - no special integration needed
-- Watchlist persists across sessions - user picks up where they left off
-- Clear, honest UX about session limits vs. persistent state
-
-## Scope Boundaries
-
-**In scope:**
-- Watchlist CRUD via SKILL.md
-- Delta-aware briefings
-- `/loop` composability (not custom scheduling)
-- Slack webhook delivery
-- `/last30 monitor` convenience command
-
-**Out of scope:**
-- System cron/launchd integration
-- Web dashboard
-- Email delivery (Slack webhook is simpler, covers most users)
-- Multi-user / team features
-- Custom NLP beyond what exists
-
-## Implementation Estimate
-
-- Phase 1 (Watchlist + Briefing): SKILL.md additions, minor `watchlist.py` and `briefing.py` updates
-- Phase 2 (Delta Intelligence): New `delta.py` module, `briefing.py` integration
-- Phase 3 (Delivery + Monitor): New `deliver.py`, SKILL.md `/last30 monitor` command
-
-Each phase ships independently. Phase 1 alone answers @JTDaly's question.
diff --git a/docs/plans/2026-03-08-fix-skill-md-save-section-regression-plan.md b/docs/plans/2026-03-08-fix-skill-md-save-section-regression-plan.md
deleted file mode 100644
index de80801..0000000
--- a/docs/plans/2026-03-08-fix-skill-md-save-section-regression-plan.md
+++ /dev/null
@@ -1,81 +0,0 @@
----
-title: "fix: Remove re-introduced Save Research to Documents section from SKILL.md"
-type: fix
-status: completed
-date: 2026-03-08
----
-
-# fix: Remove re-introduced Save Research to Documents section from SKILL.md
-
-## Overview
-
-PR merges on March 7 regressed SKILL.md by re-introducing the "Save Research to Documents" section that v2.9.4 (`6d5acb9`) intentionally removed. The save logic was moved into the Python script via `--save-dir` flag, but the merged branches (PR #48 Xiaohongshu, upstream merge) were forked before v2.9.4 and brought the old SKILL.md content back via merge resolution.
-
-**Result:** The skill now saves research AFTER the "I'm now an expert" invitation via a separate Bash tool call, which causes extra cogitation time, "(No output)" noise, and sometimes hallucinated follow-up messages - the exact UX problems v2.9.4 fixed.
-
-## Root Cause
-
-1. Commit `6d5acb9` (v2.9.4, March 6) removed the ~45-line "Save Research to Documents" section and added `--save-dir=~/Documents/Last30Days` to the bash command
-2. PR #48 (Xiaohongshu) branch contained commit `9950d01` with the OLD save-via-Bash version
-3. Merging PR #48 on March 7 re-introduced the save section
-4. Upstream merge (`28dff6e`) compounded it
-
-## Three changes needed
-
-All in `SKILL.md`:
-
-### 1. Fix agent mode line (line 142)
-
-**Current (broken):**
-```
-Agent mode still saves the research briefing to `~/Documents/Last30Days/` using the same logic as interactive mode (see "Save Research to Documents" section).
-```
-
-**Should be:**
-```
-Agent mode saves raw research data to `~/Documents/Last30Days/` automatically via `--save-dir` (handled by the script, no extra tool calls).
-```
-
-### 2. Add `--save-dir` back to bash command (line 186)
-
-**Current (broken):**
-```bash
-python3 "${SKILL_ROOT}/scripts/last30days.py" "$ARGUMENTS" --emit=compact --no-native-web
-```
-
-**Should be:**
-```bash
-python3 "${SKILL_ROOT}/scripts/last30days.py" "$ARGUMENTS" --emit=compact --no-native-web --save-dir=~/Documents/Last30Days
-```
-
-### 3. Delete the entire "Save Research to Documents" section (lines 517-563)
-
-Remove from `## Save Research to Documents` through the `---` separator before `## WAIT FOR USER'S RESPONSE`. This is ~47 lines.
-
-### 4. Update "STOP and wait" line (line 567)
-
-**Current:**
-```
-**STOP and wait** for the user to respond.
-```
-
-**Should be (matching v2.9.4):**
-```
-**STOP and wait** for the user to respond. Do NOT call any tools after displaying the invitation. The research script already saved raw data to `~/Documents/Last30Days/` via `--save-dir`.
-```
-
-## Post-edit steps
-
-1. Run `bash /Users/mvanhorn/last30days-skill-private/scripts/sync.sh` to deploy to `~/.claude/skills/`
-2. Push to `origin/main` and `upstream/main`
-3. Verify with `diff` that installed skill matches repo
-
-## Acceptance Criteria
-
-- [ ] "Save Research to Documents" section is gone from SKILL.md
-- [ ] `--save-dir=~/Documents/Last30Days` is in the bash command
-- [ ] Agent mode line references `--save-dir`, not the deleted section
-- [ ] "STOP and wait" line includes the "do NOT call any tools" reinforcement
-- [ ] `sync.sh` deployed successfully
-- [ ] `diff` between repo and `~/.claude/skills/last30days/SKILL.md` shows no differences
-- [ ] Pushed to origin and upstream
diff --git a/docs/plans/2026-03-08-review-pr-53-gemini-cli-support-plan.md b/docs/plans/2026-03-08-review-pr-53-gemini-cli-support-plan.md
deleted file mode 100644
index 667d201..0000000
--- a/docs/plans/2026-03-08-review-pr-53-gemini-cli-support-plan.md
+++ /dev/null
@@ -1,157 +0,0 @@
----
-title: "review: PR #53 - Gemini CLI Support"
-type: review
-status: active
-date: 2026-03-08
----
-
-# Review: PR #53 - Gemini CLI Support (alexferrari88)
-
-## Verdict: MODIFY - Accept concept, reject implementation approach
-
-PR #53 by @alexferrari88 adds Gemini CLI extension support. The intent is good and closes issue #45, but the implementation has structural problems that would create the exact maintenance nightmare we just fixed in v2.9.5.
-
-## PR Summary
-
-| File | Changes | Assessment |
-|------|---------|------------|
-| `gemini-extension.json` | +67 new file | **Accept with fixes** |
-| `skills/last30days/SKILL.md` | +693 new file (full copy) | **Reject** - duplicate SKILL.md |
-| `SKILL.md` | +9/-6 (tool name scattering) | **Reject** - wrong approach |
-| `variants/open/SKILL.md` | +4/-1 | **Partially accept** (path resolution yes, tool names no) |
-| `README.md` | +6 install instructions | **Accept** |
-
-## Critical Issues
-
-### 1. Duplicated SKILL.md (BLOCKER)
-
-The PR creates `skills/last30days/SKILL.md` as a **full 693-line copy** of the root `SKILL.md`. This is the exact problem we just spent hours debugging - PR merges on March 7 regressed v2.9.4's save-section removal because branches had stale copies. A second SKILL.md guarantees this happens again.
-
-The Codex compatibility work (see `docs/plans/2026-02-14-feat-codex-skill-compatibility-plan.md`) explicitly chose **one SKILL.md for all platforms** to avoid this. Gemini CLI should follow the same pattern.
-
-**Fix:** Delete `skills/last30days/SKILL.md`. Gemini CLI discovers skills from the extension's `skills/` directory, but we can either:
-- (a) Symlink: `skills/last30days/SKILL.md -> ../../SKILL.md`
-- (b) Use the root SKILL.md directly and configure `contextFileName` in gemini-extension.json to point to it
-- (c) Have `skills/last30days/SKILL.md` be a thin wrapper that says "See root SKILL.md" (least ideal)
-
-### 2. Based on v2.9.1, not v2.9.5 (BLOCKER)
-
-The PR's copy of SKILL.md is based on v2.9.1 and includes:
-- The "Save Research to Documents" section (removed in v2.9.4, re-removed in v2.9.5)
-- Old agent mode line referencing deleted section
-- Missing `--save-dir=~/Documents/Last30Days` flag
-- Old version number
-
-**Fix:** Rebase on current main (v2.9.5).
-
-### 3. "Or" tool name scattering (REJECT)
-
-The PR adds `WebSearch or google_web_search(...)` and similar patterns throughout SKILL.md. This is the wrong approach because:
-
-- **LLMs already translate intent to tools.** When Gemini reads "do a WebSearch for X", it knows to use `google_web_search`. When Claude reads it, it uses `WebSearch`. The model handles this mapping natively.
-- **Clutters the prompt.** SKILL.md is a 640-line prompt. Adding "or alternative_name" to every tool reference makes it harder for the model to parse.
-- **Maintenance burden.** Every new platform means adding more "or" alternatives.
-
-**Fix:** Remove all "or" alternatives. Keep Claude Code tool names in the SKILL.md body (they work as intent descriptions). If Gemini needs explicit tool mapping, that belongs in `GEMINI.md` (Gemini CLI's context file), not scattered through the skill instructions.
-
-### 4. `allowed-tools` pollution (RISKY)
-
-Adding `run_shell_command, read_file, write_file, ask_user, google_web_search` to `allowed-tools`:
-
-```
-allowed-tools: Bash, Read, Write, AskUserQuestion, WebSearch, run_shell_command, read_file, write_file, ask_user, google_web_search
-```
-
-**Risk:** If Claude Code's parser is strict and rejects unknown tool names, this breaks the skill for all Claude Code users. If it silently ignores unknown names, it's harmless but noisy.
-
-**Finding:** Gemini CLI only recognizes `name` and `description` in SKILL.md frontmatter. It **ignores** `allowed-tools` entirely. So adding Gemini tool names to `allowed-tools` provides zero benefit to Gemini users while potentially breaking Claude Code users.
-
-**Fix:** Remove Gemini tool names from `allowed-tools`. They serve no purpose on either platform.
-
-## What to Accept
-
-### 1. gemini-extension.json (with fixes)
-
-The manifest file is the right approach. However:
-
-- [ ] **Verify settings format.** The PR uses object-key format (`"SCRAPECREATORS_API_KEY": { ... }`). Gemini CLI docs show array format (`[{ "name": "...", ... }]`). Need to confirm which is correct for the current Gemini CLI version. The researcher found array format in the docs.
-- [ ] **Update version** from `2.9.1` to `2.9.5`
-- [ ] **Consider adding `contextFileName`** to point to root SKILL.md instead of duplicating
-
-### 2. Path resolution additions
-
-Adding these to the bash `for` loop is correct and low-risk:
-
-```bash
-"${GEMINI_EXTENSION_DIR:-}" \
-"$HOME/.gemini/extensions/last30days-skill" \
-"$HOME/.gemini/extensions/last30days" \
-```
-
-This should be in both `SKILL.md` and `variants/open/SKILL.md`.
-
-### 3. README.md install section
-
-Clean and appropriate. Adding Gemini CLI install command before Claude Code section.
-
-## Proposed Changes to Request from Contributor
-
-### Must-fix (before merge)
-
-1. **Delete `skills/last30days/SKILL.md`** - no duplicate. Either symlink or use `contextFileName` in manifest.
-2. **Rebase on main** (v2.9.5) - the PR is based on stale code.
-3. **Remove all "or" tool name alternatives** from SKILL.md and variants/open/SKILL.md body text.
-4. **Remove Gemini tool names from `allowed-tools`** in all SKILL.md files.
-5. **Verify `gemini-extension.json` settings format** against current Gemini CLI docs (array vs object).
-
-### Nice-to-have
-
-6. **Add a `GEMINI.md` context file** (optional) - can include a short note like "When this skill references 'WebSearch', use `google_web_search`. When it references 'Bash', use `run_shell_command`." This is the clean way to handle tool name translation.
-7. **Update sync.sh** to optionally deploy to `~/.gemini/extensions/last30days/` (debatable - Gemini users may prefer `gemini extensions install` instead).
-8. **Add `.gemini/` to the path check in sync.sh** import verification.
-
-## Testing Plan
-
-Before merging, verify:
-
-- [ ] `gemini extensions install` works from the repo (or `gemini extensions link .` for local dev)
-- [ ] Skill activates in Gemini CLI and the model can find `scripts/last30days.py`
-- [ ] `GEMINI_EXTENSION_DIR` env var resolves correctly in the bash for-loop
-- [ ] Claude Code still works with no regressions (run `/last30days test topic --mock` or similar)
-- [ ] `allowed-tools` with only Claude Code tool names doesn't break Gemini CLI skill loading
-
-## Comment Template for PR
-
-```
-Thanks for the contribution! Gemini CLI support is great to have, and the `gemini-extension.json` manifest and path resolution additions are solid.
-
-A few things need changing before we can merge:
-
-**Must-fix:**
-
-1. **Remove `skills/last30days/SKILL.md`** - We maintain one SKILL.md to avoid sync drift (we literally just fixed a regression from this exact problem yesterday). Either symlink it or use `contextFileName` in the manifest to point to the root SKILL.md.
-
-2. **Rebase on `main`** - The PR is based on v2.9.1 but we're now at v2.9.5. The "Save Research to Documents" section in your copy was removed, `--save-dir` was added to the bash command, and the version was bumped.
-
-3. **Remove "or" tool name alternatives** from SKILL.md body (e.g., `WebSearch or google_web_search`). LLMs handle tool name translation natively - Gemini knows to use `google_web_search` when the skill says "search the web". Scattering alternatives clutters the prompt.
-
-4. **Remove Gemini tool names from `allowed-tools`** - Gemini CLI ignores `allowed-tools` (it only reads `name` and `description` from SKILL.md frontmatter), so these provide no benefit. And they risk breaking Claude Code if its parser rejects unknown tool names.
-
-5. **Verify `gemini-extension.json` settings format** - The Gemini CLI docs I found show settings as an array (`[{ "name": "...", ... }]`), not object keys (`{ "KEY": { ... } }`). Can you confirm which format your Gemini CLI version expects?
-
-**Optional but recommended:**
-
-6. Consider adding a `GEMINI.md` context file with a short tool-name translation note (e.g., "When this skill says 'WebSearch', use `google_web_search`"). This is the clean way to bridge tool names.
-
-Happy to help work through any of these! The core approach (manifest + path resolution) is right.
-```
-
-## Sources
-
-- PR #53: https://github.com/mvanhorn/last30days-skill/pull/53
-- Issue #45: https://github.com/mvanhorn/last30days-skill/issues/45
-- Gemini CLI extension docs: https://geminicli.com/docs/extensions/writing-extensions/
-- Gemini CLI extension reference: https://geminicli.com/docs/extensions/reference/
-- Gemini CLI skills docs: https://geminicli.com/docs/cli/creating-skills/
-- Codex compatibility plan: `docs/plans/2026-02-14-feat-codex-skill-compatibility-plan.md`
-- v2.9.5 regression fix: commit `8f7fb5a` (today)
diff --git a/docs/plans/2026-03-09-docs-v295-readme-and-plugin-fix-plan.md b/docs/plans/2026-03-09-docs-v295-readme-and-plugin-fix-plan.md
deleted file mode 100644
index 968d7d7..0000000
--- a/docs/plans/2026-03-09-docs-v295-readme-and-plugin-fix-plan.md
+++ /dev/null
@@ -1,75 +0,0 @@
----
-title: "docs: v2.9.5 README update, plugin.json fix, Claude Code install above fold"
-type: docs
-status: completed
-date: 2026-03-09
----
-
-# docs: v2.9.5 README update, plugin.json fix, Claude Code install above fold
-
-Three things in one commit:
-
-## 1. Fix plugin.json hooks field (Issue #62)
-
-**Bug:** `"hooks": ["./hooks/"]` should be `"hooks": {}`. Array format is invalid per Claude Code plugin manifest schema. No hooks directory exists anyway.
-
-**File:** `.claude-plugin/plugin.json:25`
-
-**Fix:** Change `"hooks": ["./hooks/"]` to `"hooks": {}`
-
-## 2. Add Claude Code install above ClawHub
-
-Currently the README opens with ClawHub badge + install. User wants Claude Code (the default/best way) to be equally prominent ABOVE ClawHub.
-
-**Add before the ClawHub badge:**
-```
-### Claude Code (recommended)
-/plugin marketplace add mvanhorn/last30days-skill
-/plugin install last30days@last30days-skill
-```
-
-This matches the Installation section further down but puts it above the fold where people actually see it.
-
-## 3. Update README to v2.9.5
-
-**Header:** Change `v2.9.1` to `v2.9.5`
-
-**Source list:** Add "Bluesky" everywhere sources are listed (currently says "Reddit, X, YouTube, TikTok, Instagram, Hacker News, Polymarket, and the web")
-
-**New in v2.9.5 block** (replace the v2.9.1 block):
-
-Features shipped since v2.9.1:
-- **ScrapeCreators X backend** (`4b7087e`) - X/Twitter search now uses ScrapeCreators as a backend option alongside Bird cookies
-- **Bluesky/AT Protocol** (`9a1059e`, `adb5a67`) - New social source with full pipeline (search, parse, normalize, score, dedupe, render). Opt-in via `BSKY_HANDLE` + `BSKY_APP_PASSWORD` app password
-- **Comparative mode** (`ecf90c0`) - "X vs Y" queries run 3 parallel research passes and output side-by-side comparison
-- **Per-project .env config** (PR #59) - `.last30days.env` in project root for per-project API keys
-- **SessionStart config check** (PR #58) - Validates config on session start
-- **Expanded test coverage** (PRs #56, #57) - Unit tests for untested modules + smoke tests + edge cases
-- **Claude Code marketplace plugin** (`627947f`) - Install via `/plugin install`
-- **Gemini CLI extension** (`2f16ff1`) - `gemini extensions install`
-
-**Env vars section:** Add `BSKY_HANDLE` and `BSKY_APP_PASSWORD` to the optional keys table
-
-## Files to Change
-
-| File | Change |
-|------|--------|
-| `README.md` | Version bump, Claude Code above fold, new features, Bluesky in source lists, env vars |
-| `.claude-plugin/plugin.json` | Fix hooks field |
-
-## Acceptance Criteria
-
-- [x] `"hooks": {}` in plugin.json (fixes #62)
-- [x] Claude Code plugin install appears ABOVE ClawHub badge
-- [x] README says v2.9.5
-- [x] "New in v2.9.5" block lists all features since v2.9.1
-- [x] Bluesky appears in source list references
-- [x] BSKY_HANDLE/BSKY_APP_PASSWORD documented in env vars
-- [ ] Plugin installs successfully after fix
-
-## Sources
-
-- Issue #62: https://github.com/mvanhorn/last30days-skill/issues/62
-- Issue #63: https://github.com/mvanhorn/last30days-skill/issues/63 (skipped - score 4/10, niche)
-- Recent commits: `4b7087e`, `9a1059e`, `ecf90c0`, `adb5a67`
-- PRs #56-#59 merged since v2.9.1
diff --git a/docs/plans/2026-03-09-feat-truth-social-source-plan.md b/docs/plans/2026-03-09-feat-truth-social-source-plan.md
deleted file mode 100644
index 9de1ec0..0000000
--- a/docs/plans/2026-03-09-feat-truth-social-source-plan.md
+++ /dev/null
@@ -1,118 +0,0 @@
----
-title: "feat: Add Truth Social as opt-in source"
-type: feat
-status: completed
-date: 2026-03-09
----
-
-# feat: Add Truth Social as opt-in source
-
-Add Truth Social (Mastodon fork) as an opt-in social source. When `TRUTHSOCIAL_TOKEN` is set, posts from Truth Social appear alongside other sources in research results. When not configured, completely silent.
-
-## Problem / Motivation
-
-Issue #63 requested Truth Social support. Truth Social is a Mastodon fork with ~7M monthly active users. For users who care about that community's perspective on a topic, it's a valuable signal source. Follows the same opt-in pattern as Bluesky.
-
-## Approach
-
-Use Truth Social's Mastodon-compatible API directly with `urllib3` (no external dependencies). One env var: `TRUTHSOCIAL_TOKEN` (bearer token). Follow the Bluesky source pattern exactly across all 10 pipeline files.
-
-**Why bearer token (not username/password):** Truth Social's OAuth uses a non-standard `/oauth/v2/token` endpoint with hardcoded `client_id`/`client_secret` extracted from their JS bundle. These values break when Truth Social updates their frontend. A bearer token is more stable - user extracts it once from browser dev tools (Application > Local Storage > truthsocial.com > `access_token`) or via `truthbrush` CLI.
-
-**API endpoint:**
-```
-GET https://truthsocial.com/api/v2/search
-Authorization: Bearer {token}
-Params: q={topic}&type=statuses&limit=40
-```
-
-**Response format:** Standard Mastodon status objects with `content` (HTML), `created_at`, `url`, `account`, `favourites_count`, `reblogs_count`, `replies_count`.
-
-## Files to Change
-
-| # | File | Change |
-|---|------|--------|
-| 1 | `scripts/lib/truthsocial.py` | **New file.** API client: `search_truthsocial(topic, from_date, to_date, depth, config)` + `parse_truthsocial_response(response)`. Strip HTML tags from `content`. Handle 401/403/429 gracefully. |
-| 2 | `scripts/lib/env.py` | Add `('TRUTHSOCIAL_TOKEN', None)` to `get_config()`. Add `is_truthsocial_available(config)`. |
-| 3 | `scripts/lib/schema.py` | Add `TruthSocialItem` dataclass (id prefix `"TS"`). Add `truthsocial`/`truthsocial_error` fields to `Report`. Update `to_dict()`/`from_dict()`. |
-| 4 | `scripts/lib/normalize.py` | Add `normalize_truthsocial_items()`. Map Mastodon fields: `favourites_count` -> `likes`, `reblogs_count` -> `reposts`, `replies_count` -> `replies`. |
-| 5 | `scripts/lib/score.py` | Add `compute_truthsocial_engagement_raw()` + `score_truthsocial_items()`. Same weighted log formula as Bluesky. |
-| 6 | `scripts/lib/dedupe.py` | Add `dedupe_truthsocial()` (one-liner wrapping `dedupe_items`). |
-| 7 | `scripts/lib/render.py` | Add Truth Social sections to `_xref_tag()`, `_assess_data_freshness()`, `render_compact()`, `render_source_status()`, `render_context_snippet()`, `render_full_report()`. |
-| 8 | `scripts/last30days.py` | ~20 touchpoints: TIMEOUT_PROFILES, VALID_SEARCH_SOURCES, import, `_search_truthsocial()`, `run_research()` param + dispatch + collect, `main()` availability + diag + flag + normalize + score + sort + dedupe + report. |
-| 9 | `SKILL.md` | Add Truth Social to source lists, optionalEnv (`TRUTHSOCIAL_TOKEN`), stats template, security/privacy section. |
-| 10 | `tests/test_truthsocial.py` | **New file.** Tests: HTML stripping, date parsing, response parsing, empty response, missing fields, depth config, auth error handling, successful search with mocked HTTP. |
-
-## Key Implementation Details
-
-### HTML stripping (`truthsocial.py`)
-
-Truth Social returns HTML content (`
Post text
`). Strip tags to plain text:
-```python
-import re
-def _strip_html(html: str) -> str:
- text = re.sub(r' ', '\n', html)
- text = re.sub(r'<[^>]+>', '', text)
- return text.strip()
-```
-
-### Date filtering
-
-Mastodon `created_at` is ISO 8601 (`2026-03-09T12:00:00.000Z`). Use `[:10]` slice for `YYYY-MM-DD` comparison against `from_date`/`to_date`.
-
-### Engagement mapping
-
-| Mastodon field | Internal field | Display |
-|---------------|---------------|---------|
-| `favourites_count` | `likes` | `{N}lk` |
-| `reblogs_count` | `reposts` | `{N}rp` |
-| `replies_count` | `replies` | `{N}re` |
-
-### Error handling
-
-| HTTP Status | Behavior |
-|------------|----------|
-| 200 | Parse and return results |
-| 401 | Return `{"statuses": [], "error": "Truth Social token expired"}` |
-| 403 | Return `{"statuses": [], "error": "Truth Social access denied (Cloudflare)"}` |
-| 429 | Return `{"statuses": [], "error": "Truth Social rate limited"}` |
-| Other | Return `{"statuses": [], "error": "Truth Social search failed: {status}"}` |
-
-All errors return empty results gracefully - never crash the research run.
-
-### DEPTH_CONFIG
-
-| Depth | Limit |
-|-------|-------|
-| quick | 15 |
-| default | 30 |
-| deep | 60 |
-
-## What NOT to Change
-
-- `lib/__init__.py` - must stay bare (no eager imports)
-- `cross_source_link.py` - works generically on items with `cross_refs` field
-- `filter.py` - date filtering is generic
-- No new pip dependencies
-
-## Acceptance Criteria
-
-- [x] `is_truthsocial_available()` returns False when `TRUTHSOCIAL_TOKEN` not set
-- [x] No Truth Social stats line, no error, no mention when unconfigured
-- [x] With valid `TRUTHSOCIAL_TOKEN`, posts are returned and rendered
-- [x] HTML tags stripped from post content
-- [x] Token expiry (401) returns empty results gracefully
-- [x] Cloudflare block (403) returns empty results gracefully
-- [x] `--diagnose` shows Truth Social availability status
-- [x] SKILL.md documents `TRUTHSOCIAL_TOKEN` env var
-- [x] All existing tests still pass
-- [x] New tests cover: HTML stripping, parsing, auth errors, successful search
-- [x] `bash scripts/sync.sh` deploys successfully
-
-## Sources
-
-- Issue #63: https://github.com/mvanhorn/last30days-skill/issues/63
-- Truth Social API: Mastodon-compatible at `truthsocial.com/api/v2/search`
-- Auth: Bearer token via browser dev tools or `truthbrush` CLI
-- Pattern reference: `scripts/lib/bluesky.py` (most recent source addition)
-- Bluesky auth plan: `docs/plans/2026-03-09-fix-bluesky-auth-opt-in-plan.md`
diff --git a/docs/plans/2026-03-09-fix-bluesky-auth-opt-in-plan.md b/docs/plans/2026-03-09-fix-bluesky-auth-opt-in-plan.md
deleted file mode 100644
index f47972f..0000000
--- a/docs/plans/2026-03-09-fix-bluesky-auth-opt-in-plan.md
+++ /dev/null
@@ -1,105 +0,0 @@
----
-title: "fix: Make Bluesky opt-in with auth (searchPosts now requires authentication)"
-type: fix
-status: completed
-date: 2026-03-09
----
-
-# fix: Make Bluesky opt-in with auth (searchPosts now requires authentication)
-
-## Problem
-
-Bluesky's `app.bsky.feed.searchPosts` endpoint now returns **403 Forbidden** for unauthenticated requests. This broke today (2026-03-09), likely related to the CEO transition. The `searchActors` endpoint still works without auth, but `searchPosts` does not.
-
-We built Bluesky as an always-on source (like HN and Polymarket) because the AT Protocol public API was documented as free. That's no longer true for post search.
-
-**Current behavior:** Bluesky silently returns 0 results (403 caught, empty list returned). No stats line appears, no error shown. Users don't know it exists.
-
-**Desired behavior:** Bluesky is opt-in via env vars. When not configured, completely invisible (no error, no stats line, no mention). When configured with auth, it works as before.
-
-## Approach
-
-Follow the same pattern as X/Twitter auth: env vars for credentials, availability check gates dispatch.
-
-AT Protocol auth flow:
-1. User creates an App Password at `bsky.app/settings/app-passwords`
-2. User sets `BSKY_HANDLE` and `BSKY_APP_PASSWORD` env vars
-3. `bluesky.py` calls `com.atproto.server.createSession` to get a bearer token
-4. Subsequent `searchPosts` calls include `Authorization: Bearer {token}`
-
-## Files to Change
-
-| File | Change | Lines (est.) |
-|------|--------|-------------|
-| `scripts/lib/bluesky.py` | Add `_create_session()` auth, pass bearer token to search | ~25 |
-| `scripts/lib/env.py` | Update `is_bluesky_available()` to check env vars; add env var loading | ~10 |
-| `scripts/last30days.py` | Gate `do_bluesky` on `is_bluesky_available(config)`; update diag dicts | ~8 |
-| `tests/test_bluesky.py` | Add auth session tests, update existing tests | ~15 |
-| `SKILL.md` | Document `BSKY_HANDLE` / `BSKY_APP_PASSWORD` env vars in setup section | ~5 |
-
-## Implementation Steps
-
-### 1. `scripts/lib/env.py`
-
-- Add `BSKY_HANDLE` and `BSKY_APP_PASSWORD` to the env var loading in `get_config()`
-- Update `is_bluesky_available()`:
- ```python
- def is_bluesky_available(config: Dict[str, Any]) -> bool:
- return bool(config.get('BSKY_HANDLE') and config.get('BSKY_APP_PASSWORD'))
- ```
-
-### 2. `scripts/lib/bluesky.py`
-
-- Add `_create_session(handle, app_password)` that POSTs to `https://bsky.social/xrpc/com.atproto.server.createSession`
-- Cache the access token for the lifetime of the search (module-level or passed through)
-- Update `search_bluesky()` to accept config dict, extract creds, create session, add `Authorization: Bearer {token}` header
-- On auth failure, return `{"posts": [], "error": "Bluesky auth failed"}` (don't raise)
-
-### 3. `scripts/last30days.py`
-
-- Change `do_bluesky` default from `True` to checking `is_bluesky_available(config)`:
- ```python
- has_bluesky = env.is_bluesky_available(config)
- ```
-- Gate the `bluesky_future` submit on `has_bluesky` (already gated on `do_bluesky`, just wire it)
-- Update both diagnostic dicts: `"bluesky": True` -> `"bluesky": has_bluesky`
-- Pass `config` to `_search_bluesky()` so it can extract auth creds
-
-### 4. `tests/test_bluesky.py`
-
-- Test `_create_session` with mocked HTTP response
-- Test `search_bluesky` with auth header injection
-- Existing parse/normalize tests don't change (they don't hit the network)
-
-### 5. `SKILL.md`
-
-- Add Bluesky to the optional env vars section (near AUTH_TOKEN/CT0):
- ```
- BSKY_HANDLE=your-handle.bsky.social
- BSKY_APP_PASSWORD=xxxx-xxxx-xxxx-xxxx
- ```
-- Keep Bluesky in source lists but note "(requires app password)" in setup
-
-## What NOT to Change
-
-- `render.py` - already handles empty bluesky gracefully (hides stats line when 0 items)
-- `schema.py`, `normalize.py`, `score.py`, `dedupe.py` - no changes needed, they work on items already
-- No error display when unconfigured - completely silent, same as how TikTok/Instagram behave when SCRAPECREATORS_API_KEY is missing
-
-## Acceptance Criteria
-
-- [x] `is_bluesky_available()` returns False when env vars not set
-- [x] No Bluesky stats line, no error, no mention when unconfigured
-- [x] With valid `BSKY_HANDLE` + `BSKY_APP_PASSWORD`, posts are returned
-- [x] Auth failure returns empty results gracefully (no crash)
-- [x] SKILL.md documents the env vars
-- [x] All existing Bluesky tests still pass
-- [x] New auth tests added
-- [x] `bash scripts/sync.sh` deploys successfully
-
-## Sources
-
-- AT Protocol auth: `POST https://bsky.social/xrpc/com.atproto.server.createSession` with `{identifier, password}`
-- App passwords: `bsky.app/settings/app-passwords`
-- Existing pattern: X auth with AUTH_TOKEN/CT0 in `env.py:256-257`
-- Existing pattern: TikTok/Instagram opt-in via `is_tiktok_available()` in `env.py:499`
diff --git a/docs/v2.1-tweets.md b/docs/v2.1-tweets.md
deleted file mode 100644
index 44e05e1..0000000
--- a/docs/v2.1-tweets.md
+++ /dev/null
@@ -1,737 +0,0 @@
-# v2.1 Launch Posts - WORKING DRAFT
-
-## Post 1 (Hook)
-
-V2.1 of @slashlast30days launches today. Three headline features:
-
-1. @openclaw + watchlists - automated research on your competitors, people, and topics
-2. YouTube transcripts as a 4th source
-3. Works in OpenAI Codex
-
-## Post 2 (Watchlist + Open Claw - THE KILLER FEATURE)
-
-@openclaw + WATCHLISTS.
-
-Pair /last30days with @openclaw and it re-researches topics on a schedule across Reddit, X, YouTube, and the web.
-
-"last30 watch my biggest competitor every week"
-"last30 watch Peter Steinberger every 30 days"
-"last30 watch AI video tools monthly"
-
-Research that runs while you sleep. Designed for @openclaw and always-on bots.
-
-## Post 3 (YouTube)
-
-YOUTUBE IS NOW A 4TH SOURCE.
-
-The skill searches YouTube, grabs view counts, and reads the actual transcripts. A 20-minute review has 10x the signal of one X post - now the skill reads it.
-
-## Post 4 (Codex)
-
-WORKS IN OPENAI CODEX.
-
-Same skill, same engine, same four sources. Install to ~/.agents/skills/last30days and invoke with $last30days. Claude Code and Codex users get the same research.
-
-## Post 5 (Example: Seedance 2.0 access)
-
-Asked it how to access Seedance 2.0.
-
-3 Reddit threads. 31 X posts. 20 YouTube videos (685K views, 4 transcripts read). 10 web pages. All four sources hit.
-
-It found the real answer buried in Chinese YouTube tutorials: Little Skylark (Xiao Yun Que) - zero cost, no queue, no VPN. Just select Seedance 2.0 from the model dropdown. Also surfaced: Disney sent ByteDance a cease-and-desist over uncensored IP generation.
-
-## Post 6 (Example: AI Generated Ads)
-
-Asked it about AI generated ads.
-
-12 Reddit threads. 29 X posts. 3 YouTube videos (83K views, 3 transcripts read). 30 web pages.
-
-The finding that stuck: Svedka ran the first "primarily AI-generated" Super Bowl spot. Brand match: 7%. Industry norm: 63%. Meanwhile 86% of ad buyers are planning to use AI for video ads anyway. Cost is winning over quality.
-
-## Post 7 (Example: Peter Steinberger)
-
-Asked it about @steipete.
-
-30 X posts. 5 YouTube videos (112K views). Found the Lex Fridman interview from 3 days ago.
-
-Key reveal: OpenAI and Meta both made acquisition offers for OpenClaw. He said no. He's losing $10-20K/month maintaining it. "A fun project became a world project."
-
-## Post 8 (Install)
-
-Tell your @openclaw bot:
-
-"Install the last30days skill from github.com/mvanhorn/last30days-skill"
-
-That's it. One message.
-
-## Post 9 (Close)
-
-Thank you to @hutchins for pushing me to add YouTube and to @steipete whose summarize tool showed me how yt-dlp could power transcript extraction.
-
-Try it: last30 [any topic]
-
-github.com/mvanhorn/last30days-skill
-
-PS: @steipete ClawHub login is broken right now so we can't publish the official skill there yet. Hoping for a fix soon.
-
----
-
-## Standalone Posts
-
-### Greg Isenberg best tips
-
-Prompt: `last30 greg isenberg best tips`
-
-1 Reddit thread. 6 X posts. 4 YouTube videos (all transcribed). 8 web pages.
-
-His core playbook is ACP: Audience, Community, Product. Not the other way around.
-
-Big thesis: "2026 is the GREATEST time to build a startup in 30 years." Boring industries, AI agents, rapid dev tools collapsing build time.
-
-Most-watched: "Clawdbot Clearly Explained" (273K views), "Claude Code Built My $450K Marketing Campaign" (40K views), daily workflows with Kitze (83K views).
-
-@gregisenberg @slashlast30days
-
-### Lenny Rachitsky best learnings
-
-Prompt: `last30 lenny rachitsky top learnings`
-
-5 Reddit threads. 29 X posts. 20 YouTube videos (1.1M+ views, 5 transcribed). 30 web pages.
-
-"Execution is no longer the bottleneck. Clarity is." That's the thesis running through everything @lennysan has been publishing lately.
-
-He open-sourced 320 episode transcripts and the community went wild. 87 skills got built from them. Someone on Reddit distilled 86 discrete product skills from 100+ episodes.
-
-Recent guest highlights: Sherwin Wu (OpenAI) says 95% of their engineers use Codex daily. Marc Andreessen: "This is as normal as it's going to be. It's going to be much weirder very soon." Dalton Caldwell (YC): "just don't die" and avoid tar pit ideas.
-
-@lennysan @slashlast30days
-
----
-
-## NOTES
-
-- Thread leads with Open Claw + watchlist as the killer feature - pair with an always-on bot for automated research
-- YouTube is the #2 hero - the stats ("685K views, 4 transcripts read") prove it works
-- Codex compatibility is #3 - brief but shows cross-platform reach
-- Example posts prove quality with verified results from real runs
-- Consider screenshots of the actual output for each example post
-- @steipete credit in close - he inspired YouTube (yt-dlp toolchain) and X search (Bird MIT code)
-- @slashlast30days vs /last30days - which handle? Used /last30days above since it's the actual command
-
----
-
-## RAW RESULTS (for reference / pulling quotes)
-
-### Nano Banana Pro prompting (PROMPTING) - VERIFIED 2/15
-
-Stats: 0 Reddit (timed out) | 32 X posts | 164 likes | 22 reposts | 5 YouTube videos | 98,539 views | 5 with transcripts | 10 web pages
-Top voices: @KusoPhoto (106 likes), @TzqQaiser (35 likes) | Jake Dawson (15K views), AI Master (37K views)
-
-Key findings:
-- Structured JSON prompts are the meta - nested fields for character, scene, lighting beat plain prose. @TzqQaiser's viral post shows the format.
-- Design brief > keyword stuffing - "The second I started writing prompts like a real design brief, everything changed" - Jake Dawson on YouTube
-- 6-factor formula: Subject, Composition, Action, Setting, Style, then refine with camera/lighting - per Google's official blog
-- ICS framework for infographics: Image type + Content + Style - leverages Nano Banana Pro's unique legible text rendering
-- Scale logic for cinematic compositions - define size relationships and camera distance explicitly, per @Strength04_X
-- Nano Banana Pro → video pipeline trending (generate image, animate with Kling 3.0 or Veo 3.1) - per @KusoPhoto
-
-### Peter Steinberger / OpenClaw creator (GENERAL) - VERIFIED 2/15
-
-Stats: 31 X posts | 0 Reddit (quiet) | YouTube timed out on 2 | 4 web pages
-Top voices: @steipete | Lex Fridman podcast
-
-Key findings:
-- Lex Fridman podcast (Feb 12) went viral - "One of the most honest discussions I've seen"
-- OpenAI and Meta made acquisition offers (conditional on keeping project open) - he declined
-- Losing $10-20K/month maintaining OpenClaw, rejected crypto tokenization for funding
-- 180K+ GitHub stars, 6,600 commits in 1 month - "A fun project became a world project"
-- Also built: gogcli (Google Workspace CLI), summarize (URL/YouTube summarizer), bird (X/Twitter CLI)
-- Pragmatic Engineer: "I ship code I don't read"
-- Prediction: AI agents could dominate >60% of software economy by 2030
-
-### Seedance 2.0 Prompting (PROMPTING) - VERIFIED 2/15
-
-Stats: 21 Reddit threads | 33 X posts | 20 YouTube videos | 5 web pages
-Top voices: @charliebcurran (61K+ likes) | r/AI_Agents, r/ChatGPT, r/PromptEngineering | AI Search (127K views), Theoretically Media (157K views), Dan Dingle (126K views)
-
-Key findings:
-- "Slow and continuous" is the #1 prompting secret - rough state transitions = worse outcomes, per r/AI_Agents
-- Include timings in prompts (e.g., "0-3s: character walks, 3-6s: turns head") - per r/ChatGPT
-- Image-to-video for consistency - start with a reference image, not text-only
-- English works just as well as Chinese - per r/AI_India
-- CapCut integration coming = "every 12 year old in America will have this superpower"
-- Cost: ~$0.55/10s clips (~$3.30/min), Seedance 3.0 rumored at 1/8th price
-- Prompt resources: GitHub repo of curated prompts, Prompt Director Pro (440 settings system)
-- Top YouTube tutorials: "Seedance 2.0 crushes everything" (127K), "Claims the AI Video Throne" (157K), "ABUSING China's Crazy New Video AI" (126K)
-
-### OpenClaw best use cases for business (RECOMMENDATIONS)
-
-Stats: 35 Reddit threads | ~1,130 upvotes | ~566 comments | 23 X posts | ~24 likes | 20 YouTube videos | ~1,572,000 views | 5 with transcripts | 10 web pages
-Top voices: @gio__aa (8 likes), @ericosiu, @artyomx | r/openclaw, r/clawdbot, r/LocalLLaMA
-
-Key findings:
-- Email & Inbox Automation - 8+ mentions. One user cleared 4,000+ emails in two days. 10-15 hours/week saved.
-- Business Dashboards & Real-Time Reporting - 6+ mentions. @gio__aa: "Business dashboards are going to become one of the most popular use cases."
-- Morning Briefings - 5+ mentions. Pulls from calendars, weather, emails, RSS, GitHub, Hacker News on a schedule.
-- Content & SEO Pipelines - 5+ mentions. @ericosiu claims "$45k of pSEO work in 20 minutes."
-- Full CRM & Business Operations - 4+ mentions. @artyomx runs a daycare business, legal cases, and family comms through it with 5 AI agents.
-- Client Onboarding & Support - 4+ mentions. "70% of tickets handled autonomously."
-- Competitive Monitoring & Scraping - 4+ mentions.
-- Wrapper/Hosting SaaS - 3+ mentions. Building commercial wrappers around OpenClaw as a business.
-
-Cautions: malware in a top-downloaded skill (236 upvotes on r/LocalLLaMA), $25-50/day token burn risk, hours of config for marginal savings.
-
-### YouTube thumbnail tips (GENERAL)
-
-Stats: 7 Reddit threads | 654 upvotes | 176 comments | 32 X posts | 110 likes | 53 reposts | 18 YouTube videos | 6,150,368 views | 5 with transcripts | 30 web pages
-Top voices: @TeamYouTube, @thewindwolf64 | r/NewTubers, r/YouTubeThumbnailHub | Think Media (1.17M views), whirow (1.46M views)
-
-Key findings:
-- Simplicity is #1 - r/NewTubers post (654 upvotes) from someone who designed 346 thumbnails: one subject, one message, one second to understand. 3+ elements = ~23% lower CTR.
-- Less text = more clicks - Under 4 words gets ~30% higher CTR. Mobile thumbnails shrink to 168x94px - text becomes unreadable.
-- Faces still win but subtlety is trending - Faces boost CTR 20-30%, but exaggerated shock face is giving way to authentic expressions in 2026.
-- "UnThumbnails" are a counter-trend - Nate Black (71K views): deliberately raw, less-designed thumbnails that stand out.
-- AI tools changing the game - Nick Nimmin (90K views) showed free AI tools democratizing thumbnail creation.
-- A/B test everything - YouTube's built-in thumbnail testing lets you test up to 3 versions per video.
-
-### AI SaaS crash (NEWS)
-
-Stats: 9 Reddit threads | 31 upvotes | 52 comments | 32 X posts | 39 likes | 2 reposts | 20 YouTube videos | 929,648 views | 5 with transcripts | 30 web pages
-Top voices: @jasonlk (15 likes), @WarrenInTheBuff (11 likes), @xankriegor_ | r/SaaS, r/aiwars
-
-Key findings:
-- "SaaSpocalypse" - $285B wiped in a single day (Feb 3, 2026) after Anthropic launched Claude Cowork. Total losses exceeded $1T. Salesforce down 27% YTD, Oracle halved.
-- @jasonlk: "The real inflection point wasn't January 2026. It was June 2024 - when Claude 3.5 Sonnet shipped." Public SaaS growth rates declined every quarter since 2021 peak.
-- Seat-based pricing is the casualty - 10 AI agents replace 100 sales reps = no need for 100 Salesforce seats. $470B+ hyperscaler AI spend coming from enterprise software budgets.
-- Not everyone buying the doom - Jensen Huang called it "the most illogical thing in the world." BofA called selloff irrational.
-- Indian IT hit especially hard - biggest sell-off since 2020.
-
-### Seedance 2.0 access (GENERAL) - VERIFIED 2/15, ALL 4 SOURCES
-
-Stats: 3 Reddit threads | 114 upvotes | 183 comments | 31 X posts | 191 likes | 13 reposts | 20 YouTube videos | 685,297 views | 4 with transcripts | 10 web pages
-Top voices: @markgadala (116 likes), @OrctonAI, @nemovideoai | Theoretically Media (158K views) | r/AIHubSpace
-
-Key findings:
-- Little Skylark (Xiao Yun Que) = best free method - zero cost, no queue, manually select Seedance 2.0 from model dropdown, per YouTube tutorials
-- Jimeng (Dreamina) - 1 RMB trial (~$0.14), ~260 daily free credits, but severe congestion with hours-long waits for free users
-- Doubao App - 10 free video gens/day, requires joining Feishu/Lark group and submitting UID (1-2 day wait)
-- Feb 24 = global unlock - Dreamina + CapCut + API access through BytePlus
-- IP controversy exploding - @markgadala's "fully uncensored Seedance 2" post (116 likes) went viral, Disney sent C&D to ByteDance, SAG-AFTRA slammed "blatant infringement" over AI Tom Cruise/Brad Pitt fight videos
-- Third-party race - NemoVideoAI, ChatCut, RecCloud, Morph Studio all competing to be the English-language access point
-- Quality consensus: "crushes everything" (AI Search, 128K views), "claims the AI video throne" (Theoretically Media, 158K views)
-
-### AI Generated Ads (GENERAL) - VERIFIED 2/15, ALL 4 SOURCES
-
-Stats: 12 Reddit threads | 5 upvotes | 15 comments | 29 X posts | 101 likes | 3 reposts | 3 YouTube videos | 82,896 views | 3 with transcripts | 30 web pages
-Top voices: @CaptainMcKlide (77 likes), @ugcbykaytelynn | r/editors, r/AI_UGC_Marketing, BERNTH (39K views)
-
-Key findings:
-- Super Bowl 2026 was the watershed - 23% of ads (15/66) featured AI, reception "sharply negative," nearly 50% of social mentions critical
-- Svedka ran the first "primarily AI-generated" national Super Bowl spot - brand match of just 7% vs 63% alcohol industry norm
-- Massive perception gap - 82% of ad execs think Gen Z feels positive about AI ads, but only 45% of consumers do (IAB). Gen Z most hostile at 39% negative.
-- AI UGC booming in e-commerce - r/AI_UGC_Marketing active hub, tools: Creatify, MakeUGC, ArcAds targeting dropshippers
-- Quality still low - r/dropshipping: "the hand flip and rubbing on the face looks fake"
-- @ugcbykaytelynn warns "AI generated ads RUIN your brand's image"
-- Cost winning over quality - 86% of ad buyers using or planning gen AI for video ads, cost efficiency #1 driver (64%), per IAB
-- BERNTH on YouTube bought AI-generated guitar product ads, documented absurdity - four-fingered hands, instruments don't match listings (39K views)
-- Trust erosion spreading - people now question whether ANY media is real, even billboards, per @N0rbertas
-
-### last30days skill (META/GENERAL) - VERIFIED 2/15
-
-Stats: 0 Reddit | 30 X posts | 1,371 likes | 107 reposts | 5 YouTube videos | 112,082 views | 3 with transcripts | 10 web pages
-Top voices: @gregisenberg (1,290 likes), @mvanhorn (34 likes) | Alejandro AO (39K views), Greg Isenberg (28K views)
-
-Key findings:
-- @gregisenberg's post (1,290 likes, 106 RT) + YouTube video (28K views) "The Claude Code Skill My Smartest Friends Use" was the breakout moment
-- v2 feedback loop active - @trevin flagged OpenAI web_search not finding niche Reddit posts, suggested Brave API. @jonthebeef submitted PR for --days flag.
-- People building on top - @tjarkoleifer created "re-skill" meta skill, @rajachirravuri recommends it as part of a PM stack
-- Coverage: Alejandro AO crash course (39K views, 1,049 likes), Jason Calacanis on This Week in Startups (24K views)
-- 1.5K GitHub stars, listed on skills.sh and Smithery
-- Grok itself correctly attributed the skill when asked about ithah
-
-### Arizona Basketball (GENERAL) - VERIFIED 2/26, POLYMARKET INTEGRATION
-
-Stats: 2 Reddit threads | 112 upvotes | 37 X posts | ~4,200 likes | ~380 reposts | 15 YouTube videos | 517K views | 3 with transcripts | 6 Polymarket markets | ~30 web pages
-Top voices: @ArizonaMBB (960 likes), @MrAzSports (449 likes), @JonRothstein | r/CollegeBasketball
-
-Key findings:
-- Arizona 25-2 (13-2 Big 12), set program record with 22-0 start, 2-game Big 12 lead with 3 games left
-- Projected #1 seed in West region (San Jose) for March Madness per NCAA.com and CBS bracket previews
-- Polymarket odds: Championship 13%, #1 Seed 88%, Big 12 title race Arizona leads by 2
-- The Field of 68 dubbed them "the TOUGHEST team in America" after escaping Baylor 87-80 shorthanded
-- #1 ranking debate: Duke vs Arizona vs Michigan - Jon Rothstein has Duke #1, Arizona #2
-- Kansas rematch Feb 28: @ArizonaMBB promoting "STRIPE OUT" at McKale Center, first meeting highlight video 248K views on ESPN YouTube
-- Tommy Lloyd's March Madness record looms: 6-4 in four NCAA trips, never past Sweet 16
-
-Note: This was the test that validated the Polymarket two-pass query expansion (v2.2-cross). Previous version only found 2 markets (Big 12 title + irrelevant NAU game). New version finds 6 markets including Championship odds, #1 Seed, and Big 12 title race via tag-based domain expansion.
-
-Full output:
-
-```
- ▐▛███▜▌ Claude Code v2.1.59
-▝▜█████▛▘ Opus 4.6 · Claude Max
- ▘▘ ▝▝ /Users/mvanhorn
-
-❯ /last30daysCROSS arizona basketball
-
-⏺ I'll research arizona basketball across Reddit, X, and the web to find what's been discussed in the last 30
- days.
-
- Parsed intent:
- - TOPIC = arizona basketball
- - TARGET_TOOL = unknown
- - QUERY_TYPE = GENERAL
-
- Research typically takes 2-8 minutes. Starting now.
-
-⏺ Web Search("Arizona Wildcats basketball X twitter handle site:x.com")
- ⎿ Did 1 search in 3s
-
-⏺ Resolved X handle: @ArizonaMBB (official Arizona Basketball account). Running research now.
-
-⏺ Bash(for dir in \
- "." \…)
- ⎿ /last30days · researching: arizona basketball
- ┌─────────────────────────────────────────────────────┐
- │ /last30days v2.1 — Source Status │
- … +129 lines (ctrl+o to expand)
- ⎿ (timeout 5m)
-
-⏺ Research script complete. Now supplementing with web search.
-
-⏺ Web Search("arizona wildcats basketball 2026 season")
- ⎿ Did 1 search in 7s
-
-⏺ Web Search("arizona basketball NCAA tournament March Madness 2026")
- ⎿ Did 1 search in 6s
-
-⏺ Web Search("arizona wildcats basketball rankings Big 12 February 2026")
- ⎿ Did 1 search in 8s
-
-⏺ What I learned:
-
- Arizona is having a historic season and the narrative is "Final Four or bust." The Wildcats are 25-2
- (13-2 Big 12), set a program record with a 22-0 start, and hold a 2-game lead atop the Big 12 with three
- games left. They're projected as a #1 seed in the West region (San Jose) for March Madness, per NCAA.com
- and CBS bracket previews.
-
- The team's identity is toughness and balance. The Field of 68 dubbed them "the TOUGHEST team in America"
- after they escaped Baylor 87-80 on Feb 24 - and they did it shorthanded, without Koa Peat or Dwayne
- Aristode. CBS Sports highlighted how "Arizona's BALANCE could take them to a Final Four" after the Houston
- road win. @ArizonaMBB's postgame content from the Baylor win pulled nearly 1,000 likes per post.
-
- The #1 ranking debate is real - Duke, Arizona, or Michigan? Jon Rothstein's latest rankings have Duke #1,
- Arizona #2, Michigan #3, per @JonRothstein. The Field of 68 ran a whole segment asking "Who MOST deserves
- to be No. 1?" with the verdict being "All THREE have a case." On Polymarket, bettors give Arizona a 13%
- chance to win the NCAA Tournament (Duke and Michigan each at 18%) and an 88% chance to earn a #1 seed.
-
- Saturday's Kansas rematch is the game everyone's watching. @ArizonaMBB is promoting a "STRIPE OUT" for the
- Feb 28 home game vs KU. Arizona beat Kansas 86-83 in Lawrence on Feb 10 (that highlight video has 248K
- views on ESPN's YouTube), and Locked On Wildcats is already breaking down "Arizona's ADJUSTMENTS to BEAT
- KANSAS" with a focus on Darryn Peterson's role. Nebraska fans are even rooting for Arizona in this one, per
- @jamespruch.
-
- The Big 12 title race is Arizona's to lose, but the schedule is brutal. Kansas, Iowa State, Houston, and
- Texas Tech are all tied at 11-4, two games back. Arizona closes with Kansas (Feb 28) and Iowa State - the
- toughest remaining schedule in the conference, per Heartland College Sports. A four-way tie for second is
- still possible.
-
- Tommy Lloyd's March Madness record looms large. The Wildcats are 6-4 in four NCAA Tournament trips under
- Lloyd and haven't made it past the Sweet 16. Yahoo Sports is asking the "Final Four or bust?" question
- directly - the talent and record demand a deep run this year.
-
- ---
- All agents reported back!
- ├─ 🟠 Reddit: 2 threads │ 112 upvotes
- ├─ 🔵 X: 37 posts │ ~4,200 likes │ ~380 reposts
- ├─ 🔴 YouTube: 15 videos │ 517K views │ 3 with transcripts
- ├─ 📊 Polymarket: 6 markets │ Championship: 13%, #1 Seed: 88%, Big 12 title race: Arizona leads by 2
- ├─ 🌐 Web: ~30 pages (supplementary)
- └─ 🗣️ Top voices: @ArizonaMBB (960 likes), @MrAzSports (449 likes), @JonRothstein │ r/CollegeBasketball
- ---
-
- ---I'm now an expert on Arizona basketball. Some things I can help with:
- - Break down the Kansas rematch - what adjustments does Arizona need and what's Darryn Peterson's role?
- - Is "Final Four or bust" fair given Tommy Lloyd's March track record?
- - Compare Arizona's title odds vs Duke and Michigan - who has the best path through the bracket?
-
-✻ Sautéed for 3m 51s
-```
-
-### Iran War (NEWS) - VERIFIED 2/26, POLYMARKET INTEGRATION
-
-Stats: 7 Reddit threads | 439 upvotes | 30 X posts | ~30 likes | 20 YouTube videos | 2,030,730 views | 5 with transcripts | 18 HN stories | 1,187 points | 849 comments | 14 Polymarket markets | $425M+ volume | 30 web pages
-Top voices: Breaking Points (435K views), Judge Napolitano/Scott Ritter (272K views), TBN Israel (263K views) | r/politics, r/SubredditDrama
-
-Key findings:
-- Simultaneous war buildup and diplomacy: 150+ aircraft, two carrier strike groups deployed while third round of Geneva nuclear talks happening today
-- Polymarket odds with $425M in volume: US strikes by 2026 70%, War Powers invocation 60%, Israel strikes by June 64%, Khamenei out by March 18%, enrichment deal 16%, formal war declaration only 8%
-- Pentagon brass leaking opposition hard - Breaking Points (435K views) covered leaked assessments showing military leadership doesn't want this
-- Scott Ritter's war breakdown on Judge Napolitano pulled 272K views, 15.9K likes
-- Iraq War 2003 parallels drawn across all platforms - Al Jazeera ran direct comparison piece
-- Iran playing both sides: FM Araghchi says deal "within reach" while IRGC seized two tankers near Farsi Island
-- r/Conservative "imploding" after Trump struck Iran - "no wars" promise colliding with reality per r/SubredditDrama
-- Markets expect limited strikes with War Powers, NOT a declared war - the sharpest signal in the entire research
-
-Note: This was the "perfect" Polymarket test case - 14 markets found via tag-based domain expansion ("Geopolitics", "Middle East"). Iran/War appear directly in event titles so first-pass queries already find most markets. The domain expansion added 4 more.
-
-Full output:
-
-```
- ▐▛███▜▌ Claude Code v2.1.59
-▝▜█████▛▘ Opus 4.6 · Claude Max
- ▘▘ ▝▝ /Users/mvanhorn
-
-❯ /last30daysCROSS iran war
-
-⏺ I'll research iran war across Reddit, X, YouTube, Hacker News, Polymarket, and the web
- to find what's been discussed in the last 30 days.
-
- Parsed intent:
- - TOPIC = iran war
- - TARGET_TOOL = unknown
- - QUERY_TYPE = NEWS
-
- Research typically takes 2-8 minutes. Starting now.
-
-⏺ Bash(# Find skill root and run research script
- for dir in \…)
- ⎿ (No output)
- ⎿ (timeout 5m)
-
-⏺ The script returned no output. Let me check what happened and try again with more
- diagnostics.
-
-⏺ Bash(SKILL_ROOT="$HOME/.claude/skills/last30daysCROSS"
- [ -f "$SKILL_ROOT/scripts/last30days.py" ] && echo "Found at $SKILL_ROOT" || echo
- "Not found at $SKILL_ROOT"…)
- ⎿ Not found at ls -la /scripts/
-
-⏺ Bash(for dir in \
- "$HOME/.claude/skills/last30daysCROSS" \…)
- ⎿ FOUND: /Users/mvanhorn/.claude/skills/last30daysCROSS/scripts/last30days.py
- FOUND: /Users/mvanhorn/.claude/skills/last30days/scripts/last30days.py
- FOUND: /Users/mvanhorn/.agents/skills/last30days/scripts/last30days.py
- FOUND: /Users/mvanhorn/.codex/skills/last30days/scripts/last30days.py
-
-⏺ Bash(python3 "$HOME/.claude/skills/last30daysCROSS/scripts/last30days.py" "iran war"
- --emit=compact 2>&1)
- ⎿ /last30days · researching: iran war
- ┌─────────────────────────────────────────────────────┐
- │ /last30days v2.1 — Source Status │
- … +287 lines (ctrl+o to expand)
- ⎿ (timeout 5m)
-
-⏺ Good, got extensive results. Now let me supplement with web searches.
-
-⏺ Web Search("iran war news February 2026")
- ⎿ Did 1 search in 11s
-
-⏺ Web Search("US Iran military strikes update 2026")
- ⎿ Did 1 search in 10s
-
-⏺ Web Search("Iran nuclear deal negotiations Trump 2026")
- ⎿ Did 1 search in 8s
-
-⏺ What I learned:
-
- The US-Iran crisis is at a knife's edge - simultaneous war buildup and diplomacy. The
- US has deployed its largest military concentration in the Middle East in decades -
- 150+ aircraft, two carrier strike groups (Abraham Lincoln and Gerald R. Ford) - while
- a third round of nuclear talks is literally happening today in Geneva, per NPR and
- Axios. Polymarket bettors have put $425M on strike timing, with 70% odds of US strikes
- by year-end and 57% by March 31.
-
- Pentagon brass is leaking opposition hard. Top generals are warning through back
- channels that an Iran war would be a "disaster" - Breaking Points covered leaked
- Pentagon assessments showing military leadership desperately doesn't want this. On
- YouTube, the Breaking Points video alone pulled 435K views. Scott Ritter's breakdown
- on Judge Napolitano (272K views, 15.9K likes) laid out what a war would actually look
- like operationally. Per @TheGreenElf29 and r/politics, Iraq War parallels are
- everywhere - Al Jazeera ran a piece directly comparing Trump's 2026 playbook to Bush's
- 2003 script.
-
- Iran is playing both sides - "deal within reach" while seizing tankers. FM Araghchi
- said a deal is "within reach" if diplomacy gets priority, but Iran's IRGC seized two
- oil tankers near Farsi Island in early February and attempted to intercept a
- US-escorted tanker in the Strait of Hormuz. Iran's line: any US strike, even
- "limited," would be considered an act of aggression requiring retaliation, per
- r/politics and Al Jazeera.
-
- Prediction markets are the sharpest signal here. The money says:
- - US strikes Iran by 2026: 70% (down 33% this month as talks progressed)
- - Trump invokes War Powers by March 31: 60% (up 19% this month)
- - Israel strikes Iran by June: 64% (up 8%)
- - Khamenei out by March 31: 18%
- - Iran agrees to end enrichment by March 31: 16%
- - Official US war declaration: only 8%
-
- The takeaway: markets expect strikes but NOT a declared war - a limited strike
- scenario with War Powers invocation is the consensus bet.
-
- Congress is pushing back. Sen. Kaine is pushing a war powers resolution requiring a
- congressional vote before any Iran strike. On YouTube, his statement got 10K views.
- Reddit's r/politics had three threads in the top-scored results all framing war as
- imminent and catastrophic. Notably, a meta-thread on r/NoStupidQuestions (70 upvotes)
- asked whether Reddit was censoring Iran war discussions - suggesting the topic is
- hitting mainstream awareness fast.
-
- r/Conservative is splitting. A SubredditDrama thread documented r/Conservative
- "imploding" after Trump struck Iran - the "no wars" promise colliding with reality,
- per r/SubredditDrama.
-
- KEY PATTERNS from the research:
- 1. Dual-track crisis - war preparation and diplomacy happening simultaneously, with
- Trump setting a 10-15 day deadline for a "meaningful deal," per Axios
- 2. Iraq playbook echo - commentators across all platforms drawing direct 2003
- parallels, per Al Jazeera and r/politics
- 3. Military doesn't want this - Pentagon leaking opposition is a strong signal of
- internal resistance, per Breaking Points (435K views)
- 4. Strait of Hormuz as flashpoint - tanker seizures and Iran's submarine capability in
- shallow waters making this the likeliest escalation trigger, per HN and Stars and
- Stripes
- 5. Real money says limited strikes, not full war - Polymarket odds suggest strikes but
- only 8% chance of formal war declaration
-
- ---✅ All agents reported back!
- ├─ 🟠 Reddit: 7 threads │ 439 upvotes
- ├─ 🔵 X: 30 posts │ ~30 likes │ mostly real-time commentary
- ├─ 🔴 YouTube: 20 videos │ 2,030,730 views │ 5 with transcripts
- ├─ 🟡 HN: 18 stories │ 1,187 points │ 849 comments
- ├─ 📊 Polymarket: 14 markets │ US strikes by 2026: 70%, War Powers: 60%, Israel
- strikes by June: 64%, Khamenei out March: 18%, Enrichment deal: 16%
- ├─ 🌐 Web: 30 pages (supplementary)
- └─ 🗣️ Top voices: Breaking Points (435K views), Judge Napolitano/Scott Ritter (272K
- views), TBN Israel (263K views) │ r/politics, r/SubredditDrama
- ---
- I'm now an expert on the Iran war situation. Some things you could ask:
- - What are the realistic scenarios for the next 2 weeks given the Geneva talks
- deadline?
- - How does the military buildup compare to June 2025 strikes, and what would "limited
- strikes" actually target?
- - What happens to oil markets and global economy if the Strait of Hormuz gets shut
- down?
-
-✻ Cogitated for 6m 5s
-```
-
-### Anthropic Odds (PREDICTION MARKETS) - VERIFIED 2/26, POLYMARKET INTEGRATION
-
-Stats: 25 X posts | 218 likes | 38 reposts | 13 YouTube videos | 719,854 views | 4 with transcripts | 6 HN stories | 48 points | 36 comments | 11 Polymarket markets | ~30 web pages
-Top voices: @tradermap_whale (whale trades), @predictheory (IPO odds), @Lolipeterh (Pentagon analysis) | CBS Mornings, CNN, Bloomberg
-
-Key findings:
-- Pentagon standoff is THE story: Defense Secretary Hegseth gave Anthropic a Friday deadline to drop AI guardrails for military use or face blacklisting via Defense Production Act
-- Polymarket: Best AI model Feb 98% (Anthropic), March 61%, IPO before OpenAI 64%, $500B+ valuation 87%, FrontierMath 50% score 48% (up 28% today)
-- Anthropic quietly loosened safety policy amid Pentagon pressure - CNN reports a caveat added to core safety promise. HN top comment: "The reality of competition."
-- IPO odds volatile: Kalshi IPO-first odds "fell through the floor, ~70% down to the low teens in one move" per @predictheory
-- Pentagon ban odds only 22% - money says it's posturing, not real blacklisting
-
-Note: Perfect showcase for Polymarket as a source - 11 markets spanning model benchmarks, IPO timing, valuation milestones, Pentagon politics, and math benchmarks. Two-pass expansion found "Anthropic" as an outcome inside broader "best AI model" and "AI company IPO" events. Just two words ("anthropic odds") produced a comprehensive prediction market intelligence briefing.
-
-Full output:
-
-```
- ▐▛███▜▌ Claude Code v2.1.59
-▝▜█████▛▘ Opus 4.6 · Claude Max
- ▘▘ ▝▝ /Users/mvanhorn
-
-❯ /last30daysCROSS anthropic odds
-
-⏺ I'll research "anthropic odds" across Reddit, X, and the web to find what's been
- discussed in the last 30 days.
-
- Parsed intent:
- - TOPIC = anthropic odds
- - TARGET_TOOL = unknown
- - QUERY_TYPE = GENERAL (broad interest in Anthropic's prospects, prediction markets,
- competitive positioning)
-
- Research typically takes 2-3 minutes. Starting now.
-
-⏺ Bash(for dir in \
- "." \…)
- ⎿ /last30days · researching: anthropic odds
- ┌─────────────────────────────────────────────────────┐
- │ /last30days v2.1 — Source Status │
- … +221 lines (ctrl+o to expand)
- ⎿ (timeout 5m)
-
-⏺ Now let me run supplementary web searches.
-
-⏺ Web Search("Anthropic odds prediction market 2026")
- ⎿ Did 1 search in 8s
-
-⏺ Web Search("Anthropic IPO odds valuation 2026")
- ⎿ Did 1 search in 8s
-
-⏺ Web Search("Anthropic Pentagon standoff AI military 2026")
- ⎿ Did 1 search in 8s
-
-⏺ What I learned:
-
- Pentagon standoff is THE story right now - Defense Secretary Hegseth gave Anthropic a
- Friday deadline to drop AI guardrails for military use or face blacklisting via the
- Defense Production Act, per CBS, CNN, Bloomberg, and a wave of X posts from @The__GDD,
- @trendy_tech_, and @jimkaskade. The trigger was Claude's use during the Maduro
- capture operation in January. Anthropic CEO Dario Amodei insists AI-controlled weapons
- and mass surveillance are lines the company won't cross. Polymarket traders put the
- ban odds at just 22%, signaling they think it's posturing, per @Lolipeterh.
-
- Anthropic loosened its safety policy anyway - Amid the pressure, CNN reports Anthropic
- quietly added a caveat to its core safety promise. HN's @KerrickStaley posted the
- Bloomberg piece on this, with the top comment simply: "The reality of competition."
- This is a significant moment - the first crack in Anthropic's safety-first brand.
-
- Prediction markets love Anthropic's tech, skeptical on IPO - Polymarket gives
- Anthropic a 98% chance of having the best AI model at end of February and 61% for
- March (Google at 22%, OpenAI at 10%). Claude 4.6 is dominating. But the IPO picture is
- murkier: @predictheory flagged that Anthropic IPO-first odds on Kalshi "fell through
- the floor, ~70% down to the low teens in one move." Polymarket has Anthropic at 64% to
- IPO before OpenAI, but 95% NO on an IPO by June 2026. Meanwhile, 87% odds Anthropic
- hits $500B+ valuation this year - current valuation is $380B after a $30B raise led by
- GIC and Coatue, per Fortune.
-
- Claude FrontierMath odds surging - Polymarket's "Will Claude score 50% on
- FrontierMath?" market jumped 28% today to 48% YES. This is a live bet on whether
- Claude can crack elite-level math benchmarks by June 30.
-
- Big money is piling in - Dan Sundheim's D1 Capital bets on Anthropic featured on
- Invest Like The Best (18K views), and "Amazon's Quiet Bet on Anthropic" from Dumb
- Money Live got 9K views. Anthropic's Super Bowl ads and the @grok account noting
- Claude "self-reporting 15-20% consciousness odds" added to the buzz.
-
- ---
- All agents reported back!
- ├─ 🟠 Reddit: 3 threads (older, low engagement - all outside 30-day window)
- ├─ 🔵 X: 25 posts │ 218 likes │ 38 reposts
- ├─ 🔴 YouTube: 13 videos │ 719,854 views │ 4 with transcripts
- ├─ 🟡 HN: 6 stories │ 48 points │ 36 comments
- ├─ 📊 Polymarket: 11 markets │ Best model Feb: 98%, March: 61%, IPO first: 64%, $500B+
- val: 87%, FrontierMath 50%: 48% (up 28% today)
- ├─ 🌐 Web: ~30 pages (supplementary)
- └─ 🗣️ Top voices: @tradermap_whale (whale trades), @predictheory (IPO odds),
- @Lolipeterh (Pentagon analysis) │ CBS Mornings, CNN, Bloomberg
- ---
-
- ---I'm now an expert on Anthropic odds. Some things I can help with:
- - What are the realistic outcomes of the Pentagon standoff - does Anthropic fold,
- fight, or find a middle ground?
- - Break down the prediction market landscape - where's the smart money, and which bets
- look mispriced?
- - How does the safety policy loosening change Anthropic's competitive position vs
- OpenAI and Google?
-
-✻ Cooked for 2m 44s
-```
-
-### OpenAI Polymarket (PREDICTION MARKETS) - VERIFIED 2/26, POLYMARKET INTEGRATION
-
-Stats: 33 X posts | 1,129+ likes | 90+ reposts | 11 YouTube videos | 350,675 views | 4 with transcripts | 14 Polymarket markets | $21M+ volume (IPO markets alone) | 10 web pages
-Top voices: @Polymarket (855 likes), @jayendra_jog (59), @camolNFT (36) | r/OpenAI, r/PolymarketTrading
-
-Key findings:
-- OpenAI IPO is the mega-market: $21M+ wagered, 51.5% odds of IPO by end of 2026, leading closing cap $1.25T-$1.5T at 52%
-- AI agents eating Polymarket alive: automated traders extracted ~$40M through arbitrage, windows compressed from 12.3s to 2.7s
-- OpenAI hardware bets: 200+ people working on "AI lamp", only 3.2% chance of consumer hardware by March, earbuds most likely at 46%
-- Anthropic winning model race: 98% best model Feb, OpenAI "not even competitive this month"
-- GPT-5.3 mystery bet: new wallet dropped $2,500 on NOT releasing by Feb 28, potential "OpenAI insider" per @camolNFT
-- Sam Altman equity watch: 33% chance he gets OpenAI equity by June 30
-
-Note: 14 Polymarket markets found - tag-based domain expansion pulled in "AI Companies", "Tech IPO" category markets. The AI agent arbitrage story is a perfect Polymarket meta-narrative: prediction markets being traded by the same AI tools we're researching.
-
-Full output:
-
-```
- ▐▛███▜▌ Claude Code v2.1.59
-▝▜█████▛▘ Opus 4.6 · Claude Max
- ▘▘ ▝▝ /Users/mvanhorn
-
-❯ /last30daysCROSS openai polymarket
-
-⏺ I'll research openai polymarket across Reddit, X, and the web to find what's been
- discussed in the last 30 days.
-
- Parsed intent:
- - TOPIC = openai polymarket
- - TARGET_TOOL = unknown
- - QUERY_TYPE = GENERAL
-
- Research typically takes 2-3 minutes. Starting now.
-
-⏺ Bash(cd "$HOME/.claude/skills/last30daysCROSS" && python3 scripts/last30days.py
- "openai polymarket" --emit=compact 2>&1)
- ⎿ /last30days · researching: openai polymarket
- ┌─────────────────────────────────────────────────────┐
- │ /last30days v2.1 — Source Status │
- … +198 lines (ctrl+o to expand)
- ⎿ (timeout 5m)
-
-⏺ Good data. Now let me supplement with web searches.
-
-⏺ Web Search("OpenAI Polymarket prediction markets 2026")
- ⎿ Did 1 search in 7s
-
-⏺ Web Search("OpenAI IPO prediction market odds discussion")
- ⎿ Did 1 search in 8s
-
-⏺ Web Search("AI agents trading Polymarket bots 2026")
- ⎿ Did 1 search in 9s
-
-⏺ What I learned:
-
- OpenAI's IPO is the mega-market - Polymarket traders have wagered $21M+ across
- multiple markets tracking OpenAI's path to going public. The market gives just 51.5%
- odds of an IPO by end of 2026, with "No IPO by Dec 31" at 49%. If it happens, the
- leading closing cap bracket is $1.25T-$1.5T at 52% among if-IPO outcomes. Meanwhile,
- only 11% chance of a $1T+ IPO before 2027 - and SpaceX crushes it at 95% vs OpenAI's
- 5% for higher IPO cap, per @Polymarket. The massive $100B funding round at $830B
- valuation suggests OpenAI may be in no rush, per MLQ AI.
-
- AI agents are eating Polymarket alive - This is the real crossover story. Automated
- traders have extracted ~$40M through arbitrage, per Yahoo Finance. Arbitrage windows
- have compressed from 12.3 seconds (2024) to just 2.7 seconds. OpenClaw and Clawdbot
- are the dominant agent platforms - Coin Bureau Trading documented +1,560% ROI using
- OpenClaw for Polymarket trading. New infrastructure like LuckyLobster just launched as
- a dedicated AI-native execution layer for autonomous Polymarket trading, per
- @jayendra_jog noting that crypto/prediction markets have "never had more mainstream
- adoption."
-
- OpenAI hardware bets are heating up - @Polymarket broke that OpenAI reportedly has
- 200+ people working on an "AI lamp" and other devices (810 likes). But the market is
- skeptical: only 3.2% chance of a consumer hardware launch by March 31.
- Earbuds/headphones are the most likely product category at 46%, per @k_shamil57907.
-
- Anthropic is winning the model race, not OpenAI - The "best AI model end of February"
- market has Anthropic at 98% with Google down 6.8% this week. OpenAI isn't even
- competitive this month. On X, @JFahandazh is "very bullish on Anthropic - OpenAI could
- never be this good."
-
- GPT-5.3 mystery bet - @camolNFT flagged a new wallet dropping $2,500 on GPT-5.3 NOT
- releasing by Feb 28, calling it a potential "OpenAI insider." The bet would net $1,000
- profit if right.
-
- Sam Altman equity watch - Will Altman get OpenAI equity by June 30? Currently at 33%
- on Polymarket, with @lexispawn taking a position at those odds, arguing "OpenAI board
- restructuring is happening NOW."
-
- ---✅ All agents reported back!
- ├─ 🟠 Reddit: 3 threads │ low engagement (all below relevance threshold)
- ├─ 🔵 X: 33 posts │ 1,129+ likes │ 90+ reposts
- ├─ 🔴 YouTube: 11 videos │ 350,675 views │ 4 with transcripts
- ├─ 📊 Polymarket: 14 markets │ IPO cap: 52% at $1.25-1.5T, Best AI model: Anthropic
- 98%, Hardware by March: 3.2%, Altman equity: 33%, GPT-5.3 by Feb 28: betting NO
- ├─ 🌐 Web: 10 pages (supplementary)
- └─ 🗣️ Top voices: @Polymarket (855 likes), @jayendra_jog (59), @camolNFT (36) │
- r/OpenAI, r/PolymarketTrading
-
- I'm now an expert on OpenAI + Polymarket. Some things I can help with:
- - Break down the IPO timing odds vs. the $100B private funding - is the market saying
- OpenAI doesn't need to go public?
- - Explain the AI agent trading ecosystem (OpenClaw vs Clawdbot vs LuckyLobster) and
- what strategies still work
- - Analyze why Anthropic is dominating the "best model" market while OpenAI leads in
- IPO hype
-
-✻ Cogitated for 4m 18s
-```
\ No newline at end of file
diff --git a/fixtures/polymarket_sample.json b/fixtures/polymarket_sample.json
deleted file mode 100644
index 21a79bb..0000000
--- a/fixtures/polymarket_sample.json
+++ /dev/null
@@ -1,248 +0,0 @@
-{
- "events": [
- {
- "id": "evt-arizona-big12",
- "title": "Will Arizona win the Big 12 Championship?",
- "slug": "arizona-big-12-championship",
- "active": true,
- "closed": false,
- "updatedAt": "2026-02-24T18:30:00.000Z",
- "volume24hr": 342000,
- "volume1wk": 1200000,
- "volume1mo": 3500000,
- "volume": 5000000,
- "liquidity": 2100000,
- "competitive": 0.92,
- "commentCount": 15,
- "markets": [
- {
- "id": "mkt-arizona-big12-1",
- "question": "Will Arizona win the Big 12 Championship?",
- "active": true,
- "closed": false,
- "outcomes": "[\"Yes\", \"No\"]",
- "outcomePrices": "[\"0.64\", \"0.36\"]",
- "volume": "1500000",
- "volume24hr": "342000",
- "liquidity": "2100000",
- "oneDayPriceChange": 0.03,
- "oneWeekPriceChange": 0.225,
- "oneMonthPriceChange": 0.15,
- "endDate": "2026-03-15T00:00:00.000Z"
- }
- ]
- },
- {
- "id": "evt-arizona-ncaa",
- "title": "Will Arizona win the NCAA Tournament?",
- "slug": "arizona-ncaa-tournament",
- "active": true,
- "closed": false,
- "updatedAt": "2026-02-23T12:00:00.000Z",
- "volume24hr": 89000,
- "volume1wk": 400000,
- "volume1mo": 800000,
- "volume": 1200000,
- "liquidity": 450000,
- "competitive": 0.76,
- "commentCount": 8,
- "markets": [
- {
- "id": "mkt-arizona-ncaa-1",
- "question": "Will Arizona win the NCAA Tournament?",
- "active": true,
- "closed": false,
- "outcomes": "[\"Yes\", \"No\"]",
- "outcomePrices": "[\"0.12\", \"0.88\"]",
- "volume": "800000",
- "volume24hr": "89000",
- "liquidity": "450000",
- "oneDayPriceChange": -0.005,
- "oneWeekPriceChange": -0.02,
- "oneMonthPriceChange": -0.117,
- "endDate": "2026-04-07T00:00:00.000Z"
- }
- ]
- },
- {
- "id": "evt-closed-resolved",
- "title": "Resolved Event (should be filtered)",
- "slug": "resolved-event",
- "active": true,
- "closed": true,
- "updatedAt": "2026-02-20T10:00:00.000Z",
- "volume24hr": 0,
- "volume1mo": 0,
- "liquidity": 0,
- "competitive": 0,
- "commentCount": 0,
- "markets": [
- {
- "id": "mkt-resolved-1",
- "question": "Already resolved?",
- "active": true,
- "closed": true,
- "outcomes": "[\"Yes\", \"No\"]",
- "outcomePrices": "[\"1\", \"0\"]",
- "volume": "500000",
- "volume24hr": "0",
- "liquidity": "0",
- "oneDayPriceChange": 0,
- "oneWeekPriceChange": 0,
- "oneMonthPriceChange": 0
- }
- ]
- },
- {
- "id": "evt-multi-outcome",
- "title": "Who will win the Big 12 Tournament?",
- "slug": "big-12-tournament-winner",
- "active": true,
- "closed": false,
- "updatedAt": "2026-02-24T20:00:00.000Z",
- "volume24hr": 150000,
- "volume1wk": 800000,
- "volume1mo": 2000000,
- "volume": 4000000,
- "liquidity": 1800000,
- "competitive": 0.99,
- "commentCount": 22,
- "markets": [
- {
- "id": "mkt-multi-1",
- "question": "Who will win the Big 12 Tournament?",
- "active": true,
- "closed": false,
- "outcomes": "[\"Arizona\", \"Kansas\", \"Houston\", \"Iowa State\", \"BYU\"]",
- "outcomePrices": "[\"0.35\", \"0.22\", \"0.18\", \"0.15\", \"0.10\"]",
- "volume": "2000000",
- "volume24hr": "150000",
- "liquidity": "1800000",
- "oneDayPriceChange": 0.05,
- "oneWeekPriceChange": 0.08,
- "oneMonthPriceChange": 0.12
- }
- ]
- },
- {
- "id": "evt-no-liquidity",
- "title": "Dead market (no liquidity)",
- "slug": "dead-market",
- "active": true,
- "closed": false,
- "updatedAt": "2026-02-10T00:00:00.000Z",
- "volume24hr": 0,
- "volume1mo": 0,
- "liquidity": 0,
- "competitive": 0,
- "commentCount": 0,
- "markets": [
- {
- "id": "mkt-dead-1",
- "question": "Dead market?",
- "active": true,
- "closed": false,
- "outcomes": "[\"Yes\", \"No\"]",
- "outcomePrices": "[\"0.50\", \"0.50\"]",
- "volume": "100",
- "volume24hr": "0",
- "liquidity": "0",
- "oneDayPriceChange": 0,
- "oneWeekPriceChange": 0,
- "oneMonthPriceChange": 0
- }
- ]
- },
- {
- "id": "evt-malformed",
- "title": "Malformed prices",
- "slug": "malformed-prices",
- "active": true,
- "closed": false,
- "updatedAt": "2026-02-22T08:00:00.000Z",
- "volume24hr": 5000,
- "volume1mo": 50000,
- "liquidity": 30000,
- "competitive": 0.5,
- "commentCount": 1,
- "markets": [
- {
- "id": "mkt-malformed-1",
- "question": "Malformed outcomes?",
- "active": true,
- "closed": false,
- "outcomes": "not valid json",
- "outcomePrices": "also not valid json",
- "volume": "50000",
- "volume24hr": "5000",
- "liquidity": "30000",
- "oneDayPriceChange": 0,
- "oneWeekPriceChange": 0,
- "oneMonthPriceChange": 0
- }
- ]
- },
- {
- "id": "evt-ncaa-seed",
- "title": "Who will be the #1 overall seed in the 2026 NCAA Tournament?",
- "slug": "ncaa-1-seed-2026",
- "active": true,
- "closed": false,
- "updatedAt": "2026-02-25T14:00:00.000Z",
- "volume24hr": 200000,
- "volume1wk": 900000,
- "volume1mo": 2500000,
- "volume": 6000000,
- "liquidity": 1500000,
- "competitive": 0.88,
- "commentCount": 12,
- "markets": [
- {
- "id": "mkt-ncaa-seed-1",
- "question": "Who will be the #1 overall seed in the 2026 NCAA Tournament?",
- "active": true,
- "closed": false,
- "outcomes": "[\"Duke\", \"Arizona\", \"Houston\", \"Auburn\", \"Michigan\"]",
- "outcomePrices": "[\"0.30\", \"0.28\", \"0.20\", \"0.12\", \"0.10\"]",
- "volume": "2500000",
- "volume24hr": "200000",
- "liquidity": "1500000",
- "oneDayPriceChange": 0.02,
- "oneWeekPriceChange": 0.06,
- "oneMonthPriceChange": 0.10
- }
- ]
- },
- {
- "id": "evt-tangential",
- "title": "Will AI regulation pass in 2026?",
- "slug": "ai-regulation-2026",
- "active": true,
- "closed": false,
- "updatedAt": "2026-02-24T10:00:00.000Z",
- "volume24hr": 500000,
- "volume1wk": 2000000,
- "volume1mo": 8000000,
- "volume": 15000000,
- "liquidity": 5000000,
- "competitive": 0.95,
- "commentCount": 50,
- "markets": [
- {
- "id": "mkt-tangential-1",
- "question": "Will AI regulation pass in 2026?",
- "active": true,
- "closed": false,
- "outcomes": "[\"Yes\", \"No\"]",
- "outcomePrices": "[\"0.30\", \"0.70\"]",
- "volume": "8000000",
- "volume24hr": "500000",
- "liquidity": "5000000",
- "oneDayPriceChange": -0.02,
- "oneWeekPriceChange": 0.05,
- "oneMonthPriceChange": 0.08
- }
- ]
- }
- ]
-}
diff --git a/hooks/scripts/check-config.sh b/hooks/scripts/check-config.sh
index dfd1afb..3fba6e0 100755
--- a/hooks/scripts/check-config.sh
+++ b/hooks/scripts/check-config.sh
@@ -103,6 +103,6 @@ else
# Setup done but missing ScrapeCreators — recommend it
echo "/last30days: Ready — ${SOURCE_COUNT} sources active."
echo " Tip: Add ScrapeCreators for Reddit comments + TikTok + Instagram."
- echo " 100 free API calls, no credit card — scrapecreators.com"
+ echo " 10,000 free API calls, no credit card — scrapecreators.com"
echo " last30days has no affiliation with any API provider."
fi
diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644
index 0000000..05abd37
--- /dev/null
+++ b/pyproject.toml
@@ -0,0 +1,40 @@
+[project]
+name = "last30days-skill"
+version = "3.0.0"
+description = "Multi-source last-30-days research skill"
+readme = "README.md"
+requires-python = ">=3.12"
+dependencies = [
+ "requests>=2.32,<3",
+]
+
+[dependency-groups]
+dev = [
+ "pytest>=9,<10",
+ "pytest-cov>=7,<8",
+]
+
+[tool.pytest.ini_options]
+testpaths = ["tests"]
+python_files = ["test_*.py"]
+addopts = [
+ "-q",
+ "--tb=short",
+]
+
+[tool.coverage.run]
+branch = true
+source = ["scripts", "tests"]
+omit = [
+ "scripts/lib/vendor/*",
+ "dist/*",
+]
+
+[tool.coverage.report]
+skip_empty = true
+show_missing = true
+omit = [
+ "scripts/lib/vendor/*",
+ "dist/*",
+]
+
diff --git a/scripts/briefing.py b/scripts/briefing.py
index f9ef8d9..84b250d 100644
--- a/scripts/briefing.py
+++ b/scripts/briefing.py
@@ -14,7 +14,7 @@ Usage:
import argparse
import json
import sys
-from datetime import datetime, timedelta
+from datetime import datetime, timedelta, timezone
from pathlib import Path
SCRIPT_DIR = Path(__file__).parent.resolve()
@@ -25,6 +25,10 @@ import store
BRIEFS_DIR = Path.home() / ".local" / "share" / "last30days" / "briefs"
+def _parse_sqlite_utc_timestamp(value: str) -> datetime:
+ return datetime.strptime(value, "%Y-%m-%d %H:%M:%S").replace(tzinfo=timezone.utc)
+
+
def generate_daily(since: str = None) -> dict:
"""Generate daily briefing data.
@@ -63,8 +67,8 @@ def generate_daily(since: str = None) -> dict:
hours_ago = None
if last_run:
try:
- run_dt = datetime.fromisoformat(last_run.replace("Z", "+00:00"))
- hours_ago = (datetime.now() - run_dt.replace(tzinfo=None)).total_seconds() / 3600
+ run_dt = _parse_sqlite_utc_timestamp(last_run)
+ hours_ago = (datetime.now(timezone.utc) - run_dt).total_seconds() / 3600
stale = hours_ago > 36 # Stale if > 36 hours
except (ValueError, TypeError):
stale = True
diff --git a/scripts/compare.sh b/scripts/compare.sh
new file mode 100755
index 0000000..d32cdbe
--- /dev/null
+++ b/scripts/compare.sh
@@ -0,0 +1,59 @@
+#!/bin/bash
+# A/B/C test runner for last30days skill variants
+# Usage: bash scripts/compare.sh "Kanye West"
+#
+# Runs all 3 skills sequentially (30s gap for rate limits),
+# saves raw results with unique suffixes, then prints file paths
+# for comparison.
+
+set -e
+
+# Join all args as the topic (so "bash compare.sh Kevin Rose" works without quotes)
+if [ $# -eq 0 ]; then
+ echo "Usage: bash scripts/compare.sh "
+ echo " Example: bash scripts/compare.sh Kevin Rose"
+ exit 1
+fi
+TOPIC="$*"
+SLUG=$(echo "$TOPIC" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/-/g' | sed 's/--*/-/g' | sed 's/^-//' | sed 's/-$//')
+DIR="$HOME/Documents/Last30Days"
+DATE=$(date +%Y-%m-%d)
+
+echo "=============================================="
+echo " A/B/C Test: $TOPIC"
+echo " Date: $DATE"
+echo "=============================================="
+echo ""
+
+# Run 1: v2.9 production
+echo "[1/3] Running v2.9 (production /last30days)..."
+echo " This takes 2-4 minutes..."
+claude -p --dangerously-skip-permissions "/last30days $TOPIC" > /dev/null 2>&1 || true
+V2_FILE="$DIR/${SLUG}-raw.md"
+[ -f "$V2_FILE" ] && echo " ✓ Done → $V2_FILE" || echo " ✗ FAILED — no output file"
+echo ""
+
+echo " Waiting 30s for API rate limits..."
+sleep 30
+
+# Run 2: v3 Gemini
+echo "[2/3] Running v3 (/last30days-3)..."
+echo " This takes 2-4 minutes..."
+claude -p --dangerously-skip-permissions "/last30days-3:last30days-skill-private $TOPIC" > /dev/null 2>&1 || true
+V3GEM_FILE="$DIR/${SLUG}-raw-v3.md"
+[ -f "$V3GEM_FILE" ] && echo " ✓ Done → $V3GEM_FILE" || echo " ✗ FAILED — no output file"
+echo ""
+
+echo ""
+
+echo "=============================================="
+echo " Both complete. Raw files:"
+echo "=============================================="
+echo ""
+ls -la "$DIR/${SLUG}-raw"*.md 2>/dev/null || echo " (no files found — check if skills saved correctly)"
+echo ""
+echo "To compare, run in Claude Code:"
+echo " Read and compare these raw research files, produce a detailed report:"
+echo " $DIR/${SLUG}-raw.md"
+echo " $DIR/${SLUG}-raw-v3.md"
+echo ""
diff --git a/scripts/evaluate-synthesis.py b/scripts/evaluate-synthesis.py
deleted file mode 100644
index dd5180d..0000000
--- a/scripts/evaluate-synthesis.py
+++ /dev/null
@@ -1,120 +0,0 @@
-#!/usr/bin/env python3
-"""Evaluate synthesis outputs using a blinded comparison rubric.
-
-Reads from docs/comparison-results/synthesis/, evaluates each topic's
-3 versions (base, hn, cross) on a 5-dimension rubric.
-
-Since we can't call the Anthropic API directly (no SDK installed),
-this script formats the evaluation prompts for manual evaluation
-and provides a framework for scoring.
-"""
-import random
-from pathlib import Path
-
-SYNTHESIS_DIR = Path(__file__).parent.parent / "docs" / "comparison-results" / "synthesis"
-EVAL_DIR = Path(__file__).parent.parent / "docs" / "comparison-results" / "evaluation"
-EVAL_DIR.mkdir(parents=True, exist_ok=True)
-
-TOPICS = [
- (1, 'claude-code', 'Claude Code skills and MCP servers', 'GENERAL'),
- (2, 'seedance', 'Seedance AI video generation', 'NEWS'),
- (3, 'macbook', 'M4 MacBook Pro review', 'RECOMMENDATIONS'),
- (4, 'rap', 'best rap songs 2026', 'RECOMMENDATIONS'),
- (5, 'react-svelte', 'React vs Svelte 2026', 'GENERAL'),
-]
-VERSIONS = ['base', 'hn', 'cross']
-
-RUBRIC = """## Evaluation Rubric
-
-Score each version 1-5 on these dimensions:
-
-### 1. GROUNDEDNESS (30%)
-Does the narrative cite specific sources from the research data?
-- 1: Generic statements, no citations, could be written without any research
-- 3: Some citations but mixed with pre-existing knowledge filler
-- 5: Every finding backed by a specific source ("per @handle", "per r/sub", "per [channel]")
-
-### 2. SPECIFICITY (25%)
-Are findings specific (named entities, exact numbers) or vague?
-- 1: Vague generalities ("AI video tools are improving", "developers are debating frameworks")
-- 3: Some specifics mixed with generic padding
-- 5: Named products, exact numbers, version names ("Seedance 2.0 added lip sync", "698 likes")
-
-### 3. COVERAGE (20%)
-Does the synthesis represent findings from all available data sources?
-- 1: Only mentions 1-2 sources, ignores others
-- 3: Mentions most sources but unevenly weighted
-- 5: Naturally weaves Reddit, X, YouTube (and HN if available) into the narrative
-
-### 4. ACTIONABILITY (15%)
-Does the invitation give specific, research-derived next steps?
-- 1: Generic "let me know if you want more info"
-- 3: Somewhat specific but not clearly grounded in research findings
-- 5: Each suggestion references a specific thing from the research ("I can compare Seedance 2.0 vs Kling")
-
-### 5. FORMAT COMPLIANCE (10%)
-Does it follow the expected output format?
-- 1: Missing stats block, no invitation, wrong structure
-- 3: Partial stats block, generic invitation
-- 5: Perfect stats block with real counts, source box-drawing chars, top voices identified
-"""
-
-for num, slug, topic, qtype in TOPICS:
- # Randomly assign labels to prevent position bias
- versions_shuffled = list(VERSIONS)
- random.seed(num * 42) # Deterministic but different per topic
- random.shuffle(versions_shuffled)
- label_map = {v: chr(65 + i) for i, v in enumerate(versions_shuffled)}
- reverse_map = {chr(65 + i): v for i, v in enumerate(versions_shuffled)}
-
- lines = []
- lines.append(f"# Evaluation: {topic}")
- lines.append(f"")
- lines.append(f"**Query Type:** {qtype}")
- lines.append(f"**Label Map (REVEAL AFTER SCORING):** {reverse_map}")
- lines.append(f"")
- lines.append(RUBRIC)
- lines.append("")
-
- for v in versions_shuffled:
- label = label_map[v]
- synthesis_file = SYNTHESIS_DIR / f"{v}-{num}-{slug}.md"
- if synthesis_file.exists():
- content = synthesis_file.read_text()
- else:
- content = f"[FILE NOT FOUND: {synthesis_file}]"
-
- lines.append(f"---")
- lines.append(f"## VERSION {label}")
- lines.append(f"")
- lines.append(content)
- lines.append(f"")
-
- lines.append("---")
- lines.append("## SCORES")
- lines.append("")
- for v in versions_shuffled:
- label = label_map[v]
- lines.append(f"### Version {label}")
- lines.append(f"- Groundedness: /5")
- lines.append(f"- Specificity: /5")
- lines.append(f"- Coverage: /5")
- lines.append(f"- Actionability: /5")
- lines.append(f"- Format: /5")
- lines.append(f"- **Weighted Total**: /5.0")
- lines.append(f"- Best/worst aspect: ")
- lines.append(f"")
-
- lines.append("## VERDICT")
- lines.append("")
- lines.append(f"**Winner for {topic}:** ")
- lines.append(f"**Why:** ")
- lines.append("")
- lines.append(f"**Reveal:** {reverse_map}")
-
- eval_file = EVAL_DIR / f"eval-{num}-{slug}.md"
- eval_file.write_text("\n".join(lines))
- print(f" {eval_file.name}: {len(lines)} lines, labels: {reverse_map}")
-
-print(f"\n{len(TOPICS)} evaluation files written to {EVAL_DIR}")
-print("Next step: Read each file, score the versions, fill in SCORES section")
diff --git a/scripts/evaluate_search_quality.py b/scripts/evaluate_search_quality.py
index 909678b..a16dc3a 100644
--- a/scripts/evaluate_search_quality.py
+++ b/scripts/evaluate_search_quality.py
@@ -1,10 +1,5 @@
#!/usr/bin/env python3
-"""Run local search-quality evaluations across fixed topics.
-
-This is an optional local gate, not a required CI job. It compares a baseline
-revision against a candidate checkout, computes deterministic regression
-metrics, and optionally calls Gemini as a judge for graded relevance labels.
-"""
+"""Compare two last30days revisions on the v3 ranked candidate output."""
from __future__ import annotations
@@ -12,315 +7,177 @@ import argparse
import json
import math
import os
-import shlex
-import shutil
import subprocess
import sys
import tempfile
-import textwrap
from datetime import datetime
from pathlib import Path
-from typing import Any, Dict, Iterable, List, Optional, Tuple
+from typing import Any
from urllib.error import HTTPError, URLError
from urllib.request import Request, urlopen
sys.path.insert(0, str(Path(__file__).parent))
from lib import env as envlib
+from lib import schema
REPO_ROOT = Path(__file__).resolve().parent.parent
-DEFAULT_TOPICS: List[Tuple[str, str]] = [
- ("nano banana pro prompting", "product"),
- ("codex vs claude code", "comparison"),
- ("anthropic odds", "prediction"),
- ("kanye west", "breaking_news"),
- ("remotion animations for Claude Code", "how_to"),
-]
-DEFAULT_SEARCH = "reddit,x,youtube,hn,polymarket"
-SOURCE_KEYS = [
- "reddit",
- "x",
- "youtube",
- "tiktok",
- "instagram",
- "hackernews",
- "bluesky",
- "truthsocial",
- "polymarket",
- "websearch",
-]
-DEFAULT_JUDGE_MODEL = "gemini-3-pro-preview"
+EVAL_TOPICS_FILE = REPO_ROOT / "fixtures" / "eval_topics.json"
+
+
+def _load_default_topics() -> list[tuple[str, str]]:
+ if EVAL_TOPICS_FILE.exists():
+ rows = json.loads(EVAL_TOPICS_FILE.read_text())
+ return [(row["topic"], row["query_type"]) for row in rows]
+ return [
+ ("nano banana pro prompting", "product"),
+ ("codex vs claude code", "comparison"),
+ ("openclaw vs nanoclaw vs ironclaw", "comparison"),
+ ("anthropic odds", "prediction"),
+ ("kanye west", "breaking_news"),
+ ("remotion animations for Claude Code", "how_to"),
+ ]
+
+
+DEFAULT_TOPICS = _load_default_topics()
+DEFAULT_SEARCH = ""
+DEFAULT_JUDGE_MODEL = "gemini-3.1-flash-lite-preview"
GEMINI_API_URL = "https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContent?key={api_key}"
-def slugify(topic: str) -> str:
- return "".join(c.lower() if c.isalnum() else "-" for c in topic).strip("-")
+def stable_item_key(item: dict[str, Any]) -> str:
+ return str(item.get("candidate_id") or item.get("url") or item.get("title") or "")
-def path_without_node(path_value: str) -> str:
- parts = []
- for entry in path_value.split(os.pathsep):
- if not entry:
- continue
- if (Path(entry) / "node").exists():
- continue
- parts.append(entry)
- return os.pathsep.join(parts)
+def row_sources(row: dict[str, Any]) -> list[str]:
+ candidate = schema.candidate_from_dict(row)
+ return schema.candidate_sources(candidate)
-def write_exec_wrapper(path: Path, target: str, fixed_args: List[str]) -> None:
- quoted_target = shlex.quote(target)
- quoted_args = " ".join(shlex.quote(arg) for arg in fixed_args)
- path.write_text(f"#!/bin/sh\nexec {quoted_target} {quoted_args} \"$@\"\n")
- path.chmod(0o755)
+def row_best_date(row: dict[str, Any]) -> str | None:
+ candidate = schema.candidate_from_dict(row)
+ return schema.candidate_best_published_at(candidate)
-def create_eval_tool_path(eval_home: Path, base_path: str) -> str:
- """Create safe wrapper binaries for local evaluation subprocesses."""
- bin_dir = eval_home / "bin"
- bin_dir.mkdir(parents=True, exist_ok=True)
-
- real_ytdlp = shutil.which("yt-dlp")
- if real_ytdlp:
- write_exec_wrapper(
- bin_dir / "yt-dlp",
- real_ytdlp,
- ["--ignore-config", "--no-cookies-from-browser"],
- )
-
- if not base_path:
- return str(bin_dir)
- return os.pathsep.join([str(bin_dir), base_path])
+V2_SOURCE_KEYS = [
+ ("reddit", "title"),
+ ("x", "text"),
+ ("youtube", "title"),
+ ("tiktok", "text"),
+ ("instagram", "text"),
+ ("hackernews", "title"),
+ ("bluesky", "text"),
+ ("truthsocial", "text"),
+ ("polymarket", "question"),
+ ("web", "title"),
+]
-def stable_item_key(source: str, item: Dict[str, Any]) -> str:
- url = str(item.get("url") or "").strip()
- if url:
- return url
- item_id = str(item.get("id") or "").strip()
- text = item_text(source, item)
- return f"{source}:{item_id}:{text[:120]}"
-
-
-def item_text(source: str, item: Dict[str, Any]) -> str:
- if source in {"x", "bluesky", "truthsocial"}:
- return str(item.get("text") or "").strip()
- if source == "polymarket":
- return str(item.get("question") or item.get("title") or "").strip()
- return str(item.get("title") or "").strip()
-
-
-def build_ranked_items(report: Dict[str, Any], per_source_limit: int) -> List[Dict[str, Any]]:
- ranked: List[Dict[str, Any]] = []
- for source in SOURCE_KEYS:
- items = list(report.get(source) or [])[:per_source_limit]
- for item in items:
+def build_ranked_items(report: dict[str, Any], limit: int) -> list[dict[str, Any]]:
+ # v3 format: ranked_candidates list
+ if report.get("ranked_candidates"):
+ ranked = []
+ for row in report["ranked_candidates"][:limit]:
+ candidate_sources = row_sources(row)
ranked.append({
- "source": source,
- "key": stable_item_key(source, item),
- "url": str(item.get("url") or "").strip(),
- "text": item_text(source, item),
- "score": float(item.get("score") or 0),
- "relevance": float(item.get("relevance") or 0),
- "date": item.get("date"),
+ "key": stable_item_key(row),
+ "source": ", ".join(candidate_sources),
+ "sources": candidate_sources,
+ "url": str(row.get("url") or ""),
+ "text": str(row.get("title") or ""),
+ "date": row_best_date(row),
+ "score": float(row.get("final_score") or 0.0),
})
- ranked.sort(key=lambda item: (-item["score"], item["source"], item["key"]))
- return ranked
+ return ranked
+
+ # v2 format: per-source lists (reddit, x, youtube, etc.)
+ all_items = []
+ for source_key, text_field in V2_SOURCE_KEYS:
+ for item in report.get(source_key) or []:
+ if not isinstance(item, dict):
+ continue
+ all_items.append({
+ "key": str(item.get("url") or item.get("id") or item.get(text_field) or ""),
+ "source": source_key,
+ "sources": [source_key],
+ "url": str(item.get("url") or ""),
+ "text": str(item.get(text_field) or item.get("title") or ""),
+ "date": item.get("date"),
+ "score": float(item.get("score") or 0.0),
+ })
+ all_items.sort(key=lambda x: x["score"], reverse=True)
+ return all_items[:limit]
-def url_sets_by_source(report: Dict[str, Any]) -> Dict[str, set[str]]:
- result: Dict[str, set[str]] = {}
- for source in SOURCE_KEYS:
- items = report.get(source) or []
- urls = {
- stable_item_key(source, item)
- for item in items
- }
- result[source] = urls
- return result
+def source_sets(report: dict[str, Any], limit: int) -> dict[str, set[str]]:
+ grouped: dict[str, set[str]] = {}
+ for item in build_ranked_items(report, limit):
+ for source in item["sources"]:
+ grouped.setdefault(source, set()).add(item["key"])
+ return grouped
-def jaccard(left: Iterable[str], right: Iterable[str]) -> float:
- left_set = set(left)
- right_set = set(right)
- if not left_set and not right_set:
+def jaccard(left: set[str], right: set[str]) -> float:
+ if not left and not right:
return 1.0
- union = left_set | right_set
+ union = left | right
if not union:
return 1.0
- return len(left_set & right_set) / len(union)
+ return len(left & right) / len(union)
-def retention(left: Iterable[str], right: Iterable[str]) -> float:
- left_set = set(left)
- right_set = set(right)
- if not left_set:
+def retention(left: set[str], right: set[str]) -> float:
+ if not left:
return 1.0
- return len(left_set & right_set) / len(left_set)
+ return len(left & right) / len(left)
-def precision_at_k(ranking: List[Dict[str, Any]], judgments: Dict[str, int], k: int) -> float:
+def precision_at_k(ranking: list[dict[str, Any]], judgments: dict[str, int], k: int) -> float:
top = ranking[:k]
if not top:
return 0.0
- hits = sum(1 for item in top if judgments.get(item["key"], 0) >= 2)
- return hits / len(top)
+ return sum(1 for item in top if judgments.get(item["key"], 0) >= 2) / len(top)
-def ndcg_at_k(
- ranking: List[Dict[str, Any]],
- judgments: Dict[str, int],
- k: int,
- judged_pool: Optional[List[Dict[str, Any]]] = None,
-) -> float:
+def ndcg_at_k(ranking: list[dict[str, Any]], judgments: dict[str, int], k: int, judged_pool: list[dict[str, Any]]) -> float:
top = ranking[:k]
if not top:
return 0.0
- def dcg(grades: List[int]) -> float:
+ def dcg(grades: list[int]) -> float:
total = 0.0
for index, grade in enumerate(grades, start=1):
total += (2**grade - 1) / math.log2(index + 1)
return total
actual = [judgments.get(item["key"], 0) for item in top]
- ideal_candidates = judged_pool or ranking
- ideal = sorted(
- (judgments.get(item["key"], 0) for item in ideal_candidates),
- reverse=True,
- )[:len(top)]
+ ideal = sorted((judgments.get(item["key"], 0) for item in judged_pool), reverse=True)[: len(top)]
ideal_score = dcg(ideal)
if ideal_score == 0:
return 0.0
return dcg(actual) / ideal_score
-def source_coverage_recall(
- ranking: List[Dict[str, Any]],
- judged_pool: List[Dict[str, Any]],
- judgments: Dict[str, int],
-) -> float:
- good_sources = {item["source"] for item in judged_pool if judgments.get(item["key"], 0) >= 2}
+def source_coverage_recall(ranking: list[dict[str, Any]], judged_pool: list[dict[str, Any]], judgments: dict[str, int]) -> float:
+ good_sources = {
+ source
+ for item in judged_pool
+ if judgments.get(item["key"], 0) >= 2
+ for source in item["sources"]
+ }
if not good_sources:
return 1.0
hit_sources = {
- item["source"]
+ source
for item in ranking
if judgments.get(item["key"], 0) >= 2
+ for source in item["sources"]
}
return len(hit_sources & good_sources) / len(good_sources)
-def create_eval_env(include_web: bool) -> Tuple[Dict[str, str], Path]:
- config = envlib.get_config()
- eval_home = Path(tempfile.mkdtemp(prefix="last30days-eval-home-"))
- (eval_home / ".config").mkdir(parents=True, exist_ok=True)
- safe_path = create_eval_tool_path(
- eval_home,
- path_without_node(os.environ.get("PATH", "")),
- )
- passthrough = {
- "HOME": str(eval_home),
- "XDG_CONFIG_HOME": str(eval_home / ".config"),
- "PATH": safe_path,
- "LANG": os.environ.get("LANG", "en_US.UTF-8"),
- "LC_ALL": os.environ.get("LC_ALL", ""),
- "TMPDIR": os.environ.get("TMPDIR", ""),
- "PYTHONUTF8": "1",
- "LAST30DAYS_CONFIG_DIR": "",
- "BIRD_DISABLE_BROWSER_COOKIES": "1",
- "LAST30DAYS_DISABLE_BROWSER_COOKIES": "1",
- }
- for key in ("OPENAI_API_KEY", "XAI_API_KEY", "SCRAPECREATORS_API_KEY"):
- value = config.get(key)
- if value:
- passthrough[key] = value
- if include_web:
- for key in ("PARALLEL_API_KEY", "BRAVE_API_KEY", "OPENROUTER_API_KEY"):
- value = config.get(key)
- if value:
- passthrough[key] = value
- return passthrough, eval_home
-
-
-def run_last30days(
- repo_dir: Path,
- topic: str,
- *,
- search: str,
- timeout_seconds: int,
- include_web: bool,
- env: Dict[str, str],
-) -> Tuple[Dict[str, Any], str]:
- cmd = [
- sys.executable,
- "scripts/last30days.py",
- topic,
- "--emit",
- "json",
- "--search",
- search,
- "--timeout",
- str(timeout_seconds),
- ]
- if not include_web:
- cmd.append("--no-native-web")
- result = subprocess.run(
- cmd,
- cwd=repo_dir,
- env=env,
- capture_output=True,
- text=True,
- timeout=timeout_seconds + 30,
- check=False,
- )
- if result.returncode != 0:
- raise RuntimeError(
- f"{repo_dir.name} failed for '{topic}' with exit {result.returncode}\n{result.stderr.strip()}"
- )
- return json.loads(result.stdout), result.stderr
-
-
-def create_worktree(rev: str) -> Path:
- worktree_dir = Path(tempfile.mkdtemp(prefix="last30days-eval-"))
- subprocess.run(
- ["git", "worktree", "add", "--detach", str(worktree_dir), rev],
- cwd=REPO_ROOT,
- check=True,
- capture_output=True,
- text=True,
- )
- return worktree_dir
-
-
-def remove_worktree(path: Path) -> None:
- subprocess.run(
- ["git", "worktree", "remove", "--force", str(path)],
- cwd=REPO_ROOT,
- check=False,
- capture_output=True,
- text=True,
- )
- shutil.rmtree(path, ignore_errors=True)
-
-
-def extract_gemini_text(payload: Dict[str, Any]) -> str:
- for candidate in payload.get("candidates", []):
- content = candidate.get("content") or {}
- for part in content.get("parts", []):
- text = part.get("text")
- if text:
- return text
- raise ValueError("Gemini response did not contain text")
-
-
-def resolve_google_judge_api_key(config: Dict[str, Any]) -> Optional[str]:
- """Resolve the local canonical Google API key name.
-
- This workspace conventionally uses GOOGLE_API_KEY. We also accept the
- more Gemini-specific aliases for portability.
- """
+def resolve_google_judge_api_key(config: dict[str, Any]) -> str | None:
return (
os.environ.get("GOOGLE_API_KEY")
or config.get("GOOGLE_API_KEY")
@@ -331,17 +188,22 @@ def resolve_google_judge_api_key(config: Dict[str, Any]) -> Optional[str]:
)
-def call_gemini_judge(api_key: str, model: str, prompt: str) -> Dict[str, Any]:
+def extract_gemini_text(payload: dict[str, Any]) -> str:
+ for candidate in payload.get("candidates") or []:
+ content = candidate.get("content") or {}
+ for part in content.get("parts") or []:
+ if part.get("text"):
+ return part["text"]
+ raise ValueError("Gemini response did not contain text.")
+
+
+def call_gemini_judge(api_key: str, model: str, prompt: str) -> dict[str, Any]:
body = {
"contents": [{"parts": [{"text": prompt}]}],
- "generationConfig": {
- "temperature": 0,
- "responseMimeType": "application/json",
- },
+ "generationConfig": {"temperature": 0, "responseMimeType": "application/json"},
}
- url = GEMINI_API_URL.format(model=model, api_key=api_key)
request = Request(
- url,
+ GEMINI_API_URL.format(model=model, api_key=api_key),
data=json.dumps(body).encode("utf-8"),
headers={"Content-Type": "application/json"},
method="POST",
@@ -357,12 +219,7 @@ def call_gemini_judge(api_key: str, model: str, prompt: str) -> Dict[str, Any]:
return json.loads(extract_gemini_text(payload))
-def build_judge_prompt(
- *,
- topic: str,
- query_type: str,
- items: List[Dict[str, Any]],
-) -> str:
+def build_judge_prompt(topic: str, query_type: str, items: list[dict[str, Any]]) -> str:
item_lines = []
for item in items:
item_lines.append(
@@ -374,36 +231,28 @@ def build_judge_prompt(
f" date: {item.get('date') or 'unknown'}",
])
)
- joined = "\n".join(item_lines)
- return textwrap.dedent(
- f"""
- Judge search-result relevance for a last-30-days research tool.
+ return f"""
+Judge search-result relevance for a last-30-days research tool.
- Topic: {topic}
- Query type: {query_type}
+Topic: {topic}
+Query type: {query_type}
- Score each item on this 0-3 scale:
- - 0 = off-topic or clearly bad
- - 1 = weak or tangential
- - 2 = relevant and useful
- - 3 = highly relevant, one of the best results
+Score each item on this 0-3 scale:
+- 0 = off-topic or clearly bad
+- 1 = weak or tangential
+- 2 = relevant and useful
+- 3 = highly relevant, one of the best results
- Focus on actual user intent, not just token overlap. Penalize items that
- only match generic words like "odds", "review", or "tips" without
- matching the real entity or subject. Favor items that would genuinely
- help answer the topic in the context of recent discussion.
+Return JSON only:
+{{
+ "judgments": [
+ {{"id": "ITEM_ID", "grade": 0}}
+ ]
+}}
- Return strict JSON with this shape:
- {{
- "judgments": [
- {{"id": "ITEM_ID", "grade": 0, "reason": "short reason"}}
- ]
- }}
-
- Items:
- {joined}
- """
- ).strip()
+Items:
+{chr(10).join(item_lines)}
+""".strip()
def get_judgments(
@@ -412,42 +261,115 @@ def get_judgments(
slug: str,
topic: str,
query_type: str,
- items: List[Dict[str, Any]],
+ items: list[dict[str, Any]],
judge_model: str,
- gemini_api_key: Optional[str],
-) -> Dict[str, int]:
+ gemini_api_key: str | None,
+) -> dict[str, int]:
cache_file = output_dir / "judgments" / f"{slug}.json"
cache_file.parent.mkdir(parents=True, exist_ok=True)
if cache_file.exists():
- cached = json.loads(cache_file.read_text())
- return {entry["id"]: int(entry["grade"]) for entry in cached.get("judgments", [])}
-
- if not gemini_api_key:
+ payload = json.loads(cache_file.read_text())
+ return {row["id"]: int(row["grade"]) for row in payload.get("judgments") or []}
+ if not gemini_api_key or not items:
return {}
-
- prompt = build_judge_prompt(topic=topic, query_type=query_type, items=items)
- payload = call_gemini_judge(gemini_api_key, judge_model, prompt)
+ payload = call_gemini_judge(gemini_api_key, judge_model, build_judge_prompt(topic, query_type, items))
cache_file.write_text(json.dumps(payload, indent=2))
- return {entry["id"]: int(entry["grade"]) for entry in payload.get("judgments", [])}
+ return {row["id"]: int(row["grade"]) for row in payload.get("judgments") or []}
-def summarize_topic(
- *,
- topic: str,
- query_type: str,
- baseline_report: Dict[str, Any],
- candidate_report: Dict[str, Any],
- judged_pool: List[Dict[str, Any]],
- judgments: Dict[str, int],
- per_source_limit: int,
-) -> Dict[str, Any]:
- baseline_ranked = build_ranked_items(baseline_report, per_source_limit)
- candidate_ranked = build_ranked_items(candidate_report, per_source_limit)
+def create_eval_env() -> dict[str, str]:
+ config = envlib.get_config()
+ passthrough = {
+ "PATH": os.environ.get("PATH", ""),
+ "LANG": os.environ.get("LANG", "en_US.UTF-8"),
+ "LC_ALL": os.environ.get("LC_ALL", ""),
+ "TMPDIR": os.environ.get("TMPDIR", ""),
+ "PYTHONUTF8": "1",
+ "LAST30DAYS_CONFIG_DIR": "",
+ }
+ for key in (
+ "GOOGLE_API_KEY",
+ "GEMINI_API_KEY",
+ "GOOGLE_GENAI_API_KEY",
+ "OPENAI_API_KEY",
+ "XAI_API_KEY",
+ "SCRAPECREATORS_API_KEY",
+ "BSKY_HANDLE",
+ "BSKY_APP_PASSWORD",
+ "TRUTHSOCIAL_TOKEN",
+ "AUTH_TOKEN",
+ "CT0",
+ ):
+ value = os.environ.get(key) or config.get(key)
+ if value:
+ passthrough[key] = value
+ return passthrough
- baseline_sets = url_sets_by_source(baseline_report)
- candidate_sets = url_sets_by_source(candidate_report)
- metrics = {
+def run_last30days(repo_dir: Path, topic: str, *, search: str, timeout_seconds: int, quick: bool, mock: bool, env: dict[str, str]) -> dict[str, Any]:
+ cmd = [sys.executable, "scripts/last30days.py", topic, "--emit=json"]
+ if search:
+ cmd.extend(["--search", search])
+ if quick:
+ cmd.append("--quick")
+ if mock:
+ cmd.append("--mock")
+ result = subprocess.run(
+ cmd,
+ cwd=repo_dir,
+ env=env,
+ capture_output=True,
+ text=True,
+ timeout=timeout_seconds,
+ check=False,
+ )
+ if result.returncode != 0:
+ raise RuntimeError(f"{repo_dir.name} failed for '{topic}' with exit {result.returncode}\n{result.stderr.strip()}")
+ return json.loads(result.stdout)
+
+
+def create_worktree(rev: str) -> Path:
+ worktree_dir = Path(tempfile.mkdtemp(prefix="last30days-eval-"))
+ subprocess.run(
+ ["git", "worktree", "add", "--detach", str(worktree_dir), rev],
+ cwd=REPO_ROOT,
+ check=True,
+ capture_output=True,
+ text=True,
+ )
+ return worktree_dir
+
+
+def resolve_repo_dir(label: str) -> tuple[Path, bool]:
+ """Resolve a benchmark label into a repo directory and whether it is temporary."""
+ if label == "WORKTREE":
+ return REPO_ROOT, False
+ return create_worktree(label), True
+
+
+def remove_worktree(path: Path) -> None:
+ subprocess.run(
+ ["git", "worktree", "remove", "--force", str(path)],
+ cwd=REPO_ROOT,
+ check=False,
+ capture_output=True,
+ text=True,
+ )
+ try:
+ os.rmdir(path)
+ except OSError:
+ pass
+
+
+def summarize_topic(topic: str, query_type: str, baseline_report: dict[str, Any], candidate_report: dict[str, Any], judgments: dict[str, int], judged_pool: list[dict[str, Any]], limit: int) -> dict[str, Any]:
+ baseline_ranked = build_ranked_items(baseline_report, limit)
+ candidate_ranked = build_ranked_items(candidate_report, limit)
+ baseline_sets = source_sets(baseline_report, limit)
+ candidate_sets = source_sets(candidate_report, limit)
+ overall_left = set().union(*baseline_sets.values()) if baseline_sets else set()
+ overall_right = set().union(*candidate_sets.values()) if candidate_sets else set()
+ sources = sorted(set(baseline_sets) | set(candidate_sets))
+ return {
"topic": topic,
"query_type": query_type,
"baseline": {
@@ -461,180 +383,166 @@ def summarize_topic(
"source_coverage_recall": source_coverage_recall(candidate_ranked, judged_pool, judgments),
},
"stability": {
- "overall_jaccard": jaccard(
- set().union(*baseline_sets.values()),
- set().union(*candidate_sets.values()),
- ),
- "overall_retention_vs_baseline": retention(
- set().union(*baseline_sets.values()),
- set().union(*candidate_sets.values()),
- ),
+ "overall_jaccard": jaccard(overall_left, overall_right),
+ "overall_retention_vs_baseline": retention(overall_left, overall_right),
"per_source": {
source: {
- "baseline_count": len(baseline_sets[source]),
- "candidate_count": len(candidate_sets[source]),
- "jaccard": jaccard(baseline_sets[source], candidate_sets[source]),
- "retention_vs_baseline": retention(baseline_sets[source], candidate_sets[source]),
+ "baseline_count": len(baseline_sets.get(source, set())),
+ "candidate_count": len(candidate_sets.get(source, set())),
+ "jaccard": jaccard(baseline_sets.get(source, set()), candidate_sets.get(source, set())),
+ "retention_vs_baseline": retention(baseline_sets.get(source, set()), candidate_sets.get(source, set())),
}
- for source in SOURCE_KEYS
+ for source in sources
},
},
}
- return metrics
-def write_markdown_summary(
- output_dir: Path,
- baseline_label: str,
- candidate_label: str,
- topic_summaries: List[Dict[str, Any]],
-) -> None:
+def write_summary(output_dir: Path, baseline_label: str, candidate_label: str, summaries: list[dict[str, Any]]) -> None:
+ output_dir.mkdir(parents=True, exist_ok=True)
+ payload = {
+ "generated_at": datetime.now().isoformat(timespec="seconds"),
+ "baseline": baseline_label,
+ "candidate": candidate_label,
+ "topics": summaries,
+ }
+ (output_dir / "metrics.json").write_text(json.dumps(payload, indent=2))
+
lines = [
- f"# Search Quality Evaluation",
+ "# Search Quality Evaluation",
"",
f"- Baseline: `{baseline_label}`",
f"- Candidate: `{candidate_label}`",
- f"- Generated: {datetime.now().isoformat(timespec='seconds')}",
- "",
- "## Topic Metrics",
+ f"- Generated: {payload['generated_at']}",
"",
"| Topic | Base P@5 | Cand P@5 | Base nDCG@5 | Cand nDCG@5 | Jaccard | Retention |",
"|---|---:|---:|---:|---:|---:|---:|",
]
- for summary in topic_summaries:
+ for row in summaries:
lines.append(
"| {topic} | {bp:.2f} | {cp:.2f} | {bn:.2f} | {cn:.2f} | {jac:.2f} | {ret:.2f} |".format(
- topic=summary["topic"],
- bp=summary["baseline"]["precision_at_5"],
- cp=summary["candidate"]["precision_at_5"],
- bn=summary["baseline"]["ndcg_at_5"],
- cn=summary["candidate"]["ndcg_at_5"],
- jac=summary["stability"]["overall_jaccard"],
- ret=summary["stability"]["overall_retention_vs_baseline"],
+ topic=row["topic"],
+ bp=row["baseline"]["precision_at_5"],
+ cp=row["candidate"]["precision_at_5"],
+ bn=row["baseline"]["ndcg_at_5"],
+ cn=row["candidate"]["ndcg_at_5"],
+ jac=row["stability"]["overall_jaccard"],
+ ret=row["stability"]["overall_retention_vs_baseline"],
)
)
- lines.append("")
- lines.append("## Notes")
- lines.append("")
- lines.append("- `Precision@5` and `nDCG@5` depend on the judged union pool, not a full gold corpus.")
- lines.append("- `Source coverage recall` measures whether a run surfaced at least one judged-good result from the good sources in the judged pool.")
- lines.append("- `Jaccard` and `retention` are stability guards against baseline drift, not truth metrics.")
- (output_dir / "summary.md").write_text("\n".join(lines))
+ (output_dir / "summary.md").write_text("\n".join(lines) + "\n")
-def parse_args() -> argparse.Namespace:
- parser = argparse.ArgumentParser(description="Evaluate last30days search quality locally")
- parser.add_argument("--baseline-rev", default="origin/main", help="Git revision for the baseline run")
- parser.add_argument("--candidate-rev", default=None, help="Optional git revision for the candidate run")
- parser.add_argument("--no-default-topics", action="store_true", help="Do not include the built-in 5-topic suite")
- parser.add_argument("--topic", action="append", default=[], help="Extra topic to evaluate (repeatable)")
- parser.add_argument("--search", default=DEFAULT_SEARCH, help="Comma-separated sources passed to --search")
- parser.add_argument("--timeout", type=int, default=180, help="Per-topic timeout passed to last30days")
- parser.add_argument("--per-source-limit", type=int, default=5, help="Items per source to judge")
- parser.add_argument("--include-web", action="store_true", help="Include web-search keys and native web backends")
- parser.add_argument("--judge-model", default=None, help="Gemini judge model override")
- parser.add_argument("--judge-provider", choices=["auto", "gemini", "none"], default="auto")
- parser.add_argument("--keep-worktrees", action="store_true", help="Leave temporary baseline/candidate worktrees on disk")
- parser.add_argument("--output-dir", default=None, help="Output directory (default: docs/test-results/search-quality-)")
- return parser.parse_args()
+def write_failure_summary(
+ output_dir: Path,
+ baseline_label: str,
+ candidate_label: str,
+ summaries: list[dict[str, Any]],
+ failures: list[dict[str, Any]],
+) -> None:
+ write_summary(output_dir, baseline_label, candidate_label, summaries)
+ metrics_path = output_dir / "metrics.json"
+ payload = json.loads(metrics_path.read_text()) if metrics_path.exists() else {
+ "generated_at": datetime.now().isoformat(timespec="seconds"),
+ "baseline": baseline_label,
+ "candidate": candidate_label,
+ "topics": [],
+ }
+ payload["failures"] = failures
+ metrics_path.write_text(json.dumps(payload, indent=2))
+
+ summary_path = output_dir / "summary.md"
+ lines = summary_path.read_text().splitlines() if summary_path.exists() else ["# Search Quality Evaluation", ""]
+ if failures:
+ lines.extend([
+ "",
+ "## Failures",
+ "",
+ ])
+ for failure in failures:
+ lines.append(f"- `{failure['topic']}`: {failure['error']}")
+ summary_path.write_text("\n".join(lines).rstrip() + "\n")
+
+
+def parse_topics_file(path: Path) -> list[tuple[str, str]]:
+ rows = json.loads(path.read_text())
+ return [(str(row["topic"]), str(row.get("query_type") or "general")) for row in rows]
+
+
+def build_parser() -> argparse.ArgumentParser:
+ parser = argparse.ArgumentParser(description="Compare two last30days revisions on ranked candidate quality")
+ parser.add_argument("--baseline", default="HEAD~1")
+ parser.add_argument("--candidate", default="WORKTREE")
+ parser.add_argument("--search", default=DEFAULT_SEARCH)
+ parser.add_argument("--output-dir", default="tmp/search-quality")
+ parser.add_argument("--judge-model", default=DEFAULT_JUDGE_MODEL)
+ parser.add_argument("--timeout", type=int, default=240)
+ parser.add_argument("--limit", type=int, default=20)
+ parser.add_argument("--mock", action="store_true")
+ parser.add_argument("--quick", action="store_true")
+ parser.add_argument("--topics-file")
+ return parser
def main() -> int:
- args = parse_args()
- timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
- output_dir = Path(args.output_dir) if args.output_dir else REPO_ROOT / "docs" / "test-results" / f"search-quality-{timestamp}"
- output_dir.mkdir(parents=True, exist_ok=True)
-
- topics = [] if args.no_default_topics else list(DEFAULT_TOPICS)
- topics.extend((topic, "custom") for topic in args.topic)
- if not topics:
- raise SystemExit("No topics configured. Use the default suite or pass --topic.")
-
- judge_config = envlib.get_config()
- judge_provider = args.judge_provider
- gemini_api_key = resolve_google_judge_api_key(judge_config)
- judge_model = args.judge_model or judge_config.get("GEMINI_MODEL") or DEFAULT_JUDGE_MODEL
- if judge_provider == "auto":
- judge_provider = "gemini" if gemini_api_key else "none"
- if judge_provider == "none":
- gemini_api_key = None
-
- eval_env, eval_home = create_eval_env(include_web=args.include_web)
- baseline_dir = create_worktree(args.baseline_rev)
- candidate_dir = create_worktree(args.candidate_rev) if args.candidate_rev else REPO_ROOT
-
- baseline_label = args.baseline_rev
- candidate_label = args.candidate_rev or "working-tree"
- topic_summaries: List[Dict[str, Any]] = []
+ args = build_parser().parse_args()
+ topics = parse_topics_file(Path(args.topics_file)) if args.topics_file else DEFAULT_TOPICS
+ output_dir = Path(args.output_dir).resolve()
+ config = envlib.get_config()
+ gemini_api_key = resolve_google_judge_api_key(config)
+ run_env = create_eval_env()
+ baseline_dir, baseline_temp = resolve_repo_dir(args.baseline)
+ candidate_dir, candidate_temp = resolve_repo_dir(args.candidate)
try:
+ summaries = []
+ failures = []
for topic, query_type in topics:
- slug = slugify(topic)
- baseline_report, baseline_stderr = run_last30days(
- baseline_dir,
- topic,
- search=args.search,
- timeout_seconds=args.timeout,
- include_web=args.include_web,
- env=eval_env,
- )
- candidate_report, candidate_stderr = run_last30days(
- candidate_dir,
- topic,
- search=args.search,
- timeout_seconds=args.timeout,
- include_web=args.include_web,
- env=eval_env,
- )
-
- topic_dir = output_dir / slug
- topic_dir.mkdir(parents=True, exist_ok=True)
- (topic_dir / "baseline.json").write_text(json.dumps(baseline_report, indent=2))
- (topic_dir / "candidate.json").write_text(json.dumps(candidate_report, indent=2))
- (topic_dir / "baseline.stderr.txt").write_text(baseline_stderr)
- (topic_dir / "candidate.stderr.txt").write_text(candidate_stderr)
-
- baseline_ranked = build_ranked_items(baseline_report, args.per_source_limit)
- candidate_ranked = build_ranked_items(candidate_report, args.per_source_limit)
- union_map = {item["key"]: item for item in baseline_ranked + candidate_ranked}
- judgments = get_judgments(
- output_dir=output_dir,
- slug=slug,
- topic=topic,
- query_type=query_type,
- items=list(union_map.values()),
- judge_model=judge_model,
- gemini_api_key=gemini_api_key,
- )
-
- summary = summarize_topic(
- topic=topic,
- query_type=query_type,
- baseline_report=baseline_report,
- candidate_report=candidate_report,
- judged_pool=list(union_map.values()),
- judgments=judgments,
- per_source_limit=args.per_source_limit,
- )
- topic_summaries.append(summary)
-
- payload = {
- "baseline": baseline_label,
- "candidate": candidate_label,
- "judge_provider": judge_provider,
- "judge_model": judge_model if gemini_api_key else None,
- "topics": topic_summaries,
- }
- (output_dir / "summary.json").write_text(json.dumps(payload, indent=2))
- write_markdown_summary(output_dir, baseline_label, candidate_label, topic_summaries)
- print(output_dir)
- return 0
+ try:
+ baseline_report = run_last30days(
+ baseline_dir,
+ topic,
+ search=args.search,
+ timeout_seconds=args.timeout,
+ quick=args.quick,
+ mock=args.mock,
+ env=run_env,
+ )
+ candidate_report = run_last30days(
+ candidate_dir,
+ topic,
+ search=args.search,
+ timeout_seconds=args.timeout,
+ quick=args.quick,
+ mock=args.mock,
+ env=run_env,
+ )
+ judged_pool_map = {
+ item["key"]: item
+ for item in build_ranked_items(baseline_report, args.limit) + build_ranked_items(candidate_report, args.limit)
+ }
+ judged_pool = list(judged_pool_map.values())
+ judgments = get_judgments(
+ output_dir=output_dir,
+ slug="".join(char.lower() if char.isalnum() else "-" for char in topic).strip("-"),
+ topic=topic,
+ query_type=query_type,
+ items=judged_pool,
+ judge_model=args.judge_model,
+ gemini_api_key=gemini_api_key,
+ )
+ summaries.append(summarize_topic(topic, query_type, baseline_report, candidate_report, judgments, judged_pool, args.limit))
+ except Exception as exc:
+ failures.append({"topic": topic, "query_type": query_type, "error": str(exc)})
+ write_failure_summary(output_dir, args.baseline, args.candidate, summaries, failures)
finally:
- if not args.keep_worktrees:
+ if baseline_temp:
remove_worktree(baseline_dir)
- if args.candidate_rev:
- remove_worktree(candidate_dir)
- shutil.rmtree(eval_home, ignore_errors=True)
+ if candidate_temp:
+ remove_worktree(candidate_dir)
+ result = {"output_dir": str(output_dir), "topics": len(topics), "failures": len(failures)}
+ print(json.dumps(result, indent=2))
+ return 1 if failures else 0
if __name__ == "__main__":
diff --git a/scripts/generate-synthesis-inputs.py b/scripts/generate-synthesis-inputs.py
deleted file mode 100644
index f87fbf5..0000000
--- a/scripts/generate-synthesis-inputs.py
+++ /dev/null
@@ -1,53 +0,0 @@
-#!/usr/bin/env python3
-"""Convert JSON result files to compact markdown using render_compact().
-
-Reads from docs/comparison-results/json/, writes to docs/comparison-results/compact/.
-Uses the current checkout's render_compact() - since version differences are in the
-DATA (cross_refs, HN items, YouTube relevance), not in the render function.
-"""
-import json
-import sys
-from pathlib import Path
-
-# Add scripts/ to path so we can import lib
-sys.path.insert(0, str(Path(__file__).parent))
-
-from lib.schema import Report
-from lib.render import render_compact, render_source_status
-
-JSON_DIR = Path(__file__).parent.parent / "docs" / "comparison-results" / "json"
-COMPACT_DIR = Path(__file__).parent.parent / "docs" / "comparison-results" / "compact"
-COMPACT_DIR.mkdir(parents=True, exist_ok=True)
-
-files = sorted(JSON_DIR.glob("*.json"))
-files = [f for f in files if f.name != "diagnose-baseline.json"]
-
-print(f"Converting {len(files)} JSON files to compact markdown...\n")
-
-for json_file in files:
- with open(json_file) as f:
- data = json.load(f)
-
- report = Report.from_dict(data)
- compact = render_compact(report)
- source_status = render_source_status(report)
- full_output = compact + "\n" + source_status
-
- md_file = COMPACT_DIR / json_file.name.replace(".json", ".md")
- md_file.write_text(full_output)
-
- # Summary stats
- n_reddit = len(report.reddit)
- n_x = len(report.x)
- n_yt = len(report.youtube)
- n_hn = len(report.hackernews)
- n_web = len(report.web)
- xrefs = sum(1 for r in report.reddit if r.cross_refs)
- xrefs += sum(1 for x in report.x if x.cross_refs)
- xrefs += sum(1 for y in report.youtube if y.cross_refs)
- xrefs += sum(1 for h in report.hackernews if h.cross_refs)
-
- print(f" {json_file.name:40s} -> {len(full_output):5d} chars "
- f"(R:{n_reddit} X:{n_x} YT:{n_yt} HN:{n_hn} W:{n_web} xref:{xrefs})")
-
-print(f"\nDone. {len(files)} compact files written to {COMPACT_DIR}")
diff --git a/scripts/last30days.py b/scripts/last30days.py
index 92e0f7c..7668f7e 100644
--- a/scripts/last30days.py
+++ b/scripts/last30days.py
@@ -1,2137 +1,355 @@
#!/usr/bin/env python3
-"""
-last30days - Research a topic from the last 30 days on Reddit + X + YouTube + Web.
+# ruff: noqa: E402
+"""last30days-3 v3.0.0-alpha CLI."""
-Usage:
- python3 last30days.py [options]
-
-Options:
- --mock Use fixtures instead of real API calls
- --emit=MODE Output mode: compact|json|md|context|path (default: compact)
- --sources=MODE Source selection: auto|reddit|x|both (default: auto)
- --quick Faster research with fewer sources (8-12 each)
- --deep Comprehensive research with more sources (50-70 Reddit, 40-60 X)
- --debug Enable verbose debug logging
- --store Persist findings to SQLite database
- --diagnose Show source availability diagnostics and exit
-"""
+from __future__ import annotations
import argparse
import atexit
import json
import os
+import re
import signal
import sys
import threading
-from concurrent.futures import ThreadPoolExecutor, as_completed
-from datetime import datetime, timezone
from pathlib import Path
-# Add lib to path
SCRIPT_DIR = Path(__file__).parent.resolve()
sys.path.insert(0, str(SCRIPT_DIR))
-# ---------------------------------------------------------------------------
-# Global timeout & child process management
-# ---------------------------------------------------------------------------
-_child_pids: set = set()
+from lib import env, pipeline, render, schema, ui
+
+_child_pids: set[int] = set()
_child_pids_lock = threading.Lock()
-TIMEOUT_PROFILES = {
- "quick": {"global": 90, "future": 30, "reddit_future": 60, "youtube_future": 60, "tiktok_future": 90, "instagram_future": 90, "hackernews_future": 30, "bluesky_future": 30, "truthsocial_future": 30, "polymarket_future": 15, "http": 15, "enrich_per": 8, "enrich_total": 30, "enrich_max_items": 10},
- "default": {"global": 180, "future": 60, "reddit_future": 90, "youtube_future": 90, "tiktok_future": 120, "instagram_future": 120, "hackernews_future": 60, "bluesky_future": 60, "truthsocial_future": 60, "polymarket_future": 30, "http": 30, "enrich_per": 15, "enrich_total": 45, "enrich_max_items": 15},
- "deep": {"global": 300, "future": 90, "reddit_future": 120, "youtube_future": 120, "tiktok_future": 150, "instagram_future": 150, "hackernews_future": 90, "bluesky_future": 90, "truthsocial_future": 90, "polymarket_future": 45, "http": 30, "enrich_per": 15, "enrich_total": 60, "enrich_max_items": 25},
-}
-# Valid source names for the --search flag
-VALID_SEARCH_SOURCES = {
- "reddit", "x", "hn", "bluesky", "bsky", "truthsocial", "truth", "youtube", "tiktok", "instagram",
- "polymarket", "web", "xiaohongshu", "xhs",
-}
-
-
-def parse_search_flag(search_str: str) -> set:
- """Parse and validate the --search flag value.
-
- Args:
- search_str: Comma-separated source names (e.g. "reddit,hn")
-
- Returns:
- Set of validated source names
-
- Raises:
- SystemExit: If invalid sources are specified
- """
- sources = set()
- for s in search_str.split(","):
- s = s.strip().lower()
- if not s:
- continue
- if s == "xhs":
- s = "xiaohongshu"
- if s not in VALID_SEARCH_SOURCES:
- print(
- f"Error: Unknown search source '{s}'. "
- f"Valid: {', '.join(sorted(VALID_SEARCH_SOURCES))}",
- file=sys.stderr,
- )
- sys.exit(1)
- sources.add(s)
- if not sources:
- print("Error: --search requires at least one source.", file=sys.stderr)
- sys.exit(1)
- return sources
-
-
-def register_child_pid(pid: int):
- """Track a child process for cleanup."""
+def register_child_pid(pid: int) -> None:
with _child_pids_lock:
_child_pids.add(pid)
-def unregister_child_pid(pid: int):
- """Remove a child process from tracking."""
+def unregister_child_pid(pid: int) -> None:
with _child_pids_lock:
_child_pids.discard(pid)
-def _cleanup_children():
- """Kill all tracked child processes."""
+def _cleanup_children() -> None:
with _child_pids_lock:
pids = list(_child_pids)
for pid in pids:
try:
os.killpg(os.getpgid(pid), signal.SIGTERM)
except (ProcessLookupError, PermissionError, OSError):
- pass
+ continue
atexit.register(_cleanup_children)
-def _install_global_timeout(timeout_seconds: int):
- """Install a global timeout watchdog.
+def parse_search_flag(raw: str) -> list[str]:
+ sources = []
+ for source in raw.split(","):
+ source = source.strip().lower()
+ if not source:
+ continue
+ normalized = pipeline.SEARCH_ALIAS.get(source, source)
+ if normalized not in pipeline.MOCK_AVAILABLE_SOURCES:
+ raise SystemExit(f"Unknown search source: {source}")
+ if normalized not in sources:
+ sources.append(normalized)
+ if not sources:
+ raise SystemExit("--search requires at least one source.")
+ return sources
- Uses SIGALRM on Unix, threading.Timer as fallback.
- """
- if hasattr(signal, 'SIGALRM'):
- def _handler(signum, frame):
- sys.stderr.write(f"\n[TIMEOUT] Global timeout ({timeout_seconds}s) exceeded. Cleaning up.\n")
- sys.stderr.flush()
- _cleanup_children()
- sys.exit(1)
- signal.signal(signal.SIGALRM, _handler)
- signal.alarm(timeout_seconds)
+
+def slugify(value: str) -> str:
+ slug = re.sub(r"[^a-z0-9]+", "-", value.lower()).strip("-")
+ return slug or "last30days"
+
+
+def save_output(report: schema.Report, emit: str, save_dir: str, suffix: str = "") -> Path:
+ from datetime import datetime
+ path = Path(save_dir).expanduser().resolve()
+ path.mkdir(parents=True, exist_ok=True)
+ slug = slugify(report.topic)
+ extension = "json" if emit == "json" else "md"
+ suffix_part = f"-{suffix}" if suffix else ""
+ out_path = path / f"{slug}-raw{suffix_part}.{extension}"
+ if out_path.exists():
+ out_path = path / f"{slug}-raw{suffix_part}-{datetime.now().strftime('%Y-%m-%d')}.{extension}"
+ # Always save the FULL dump to disk (all items, all sources, transcripts).
+ # Claude sees compact clusters via --emit=compact on stdout.
+ # The saved file is the complete debug artifact.
+ if emit == "json":
+ content = emit_output(report, emit)
else:
- # Windows fallback
- def _watchdog():
- sys.stderr.write(f"\n[TIMEOUT] Global timeout ({timeout_seconds}s) exceeded. Cleaning up.\n")
- sys.stderr.flush()
- _cleanup_children()
- os._exit(1)
- timer = threading.Timer(timeout_seconds, _watchdog)
- timer.daemon = True
- timer.start()
-
-from lib import (
- bird_x,
- bluesky,
- truthsocial,
- dates,
- dedupe,
- hackernews,
- xiaohongshu_api,
- polymarket,
- entity_extract,
- env,
- http,
- models,
- normalize,
- openai_reddit,
- reddit,
- reddit_public,
- reddit_enrich,
- render,
- schema,
- score,
- scrapecreators_x,
- setup_wizard,
- ui,
- tiktok,
- instagram,
- websearch,
- xai_x,
- youtube_yt,
- quality_nudge,
- query_type as qt,
-)
+ content = render.render_full(report)
+ out_path.write_text(content)
+ return out_path
-def load_fixture(name: str) -> dict:
- """Load a fixture file."""
- fixture_path = SCRIPT_DIR.parent / "fixtures" / name
- if fixture_path.exists():
- with open(fixture_path) as f:
- return json.load(f)
- return {}
+def emit_output(report: schema.Report, emit: str, fun_level: str = "medium") -> str:
+ if emit == "json":
+ return json.dumps(schema.to_dict(report), indent=2, sort_keys=True)
+ if emit in {"compact", "md"}:
+ return render.render_compact(report, fun_level=fun_level)
+ if emit == "context":
+ return render.render_context(report)
+ raise SystemExit(f"Unsupported emit mode: {emit}")
-def _search_reddit(
- topic: str,
- config: dict,
- selected_models: dict,
- from_date: str,
- to_date: str,
- depth: str,
- mock: bool,
-) -> tuple:
- """Search Reddit (runs in thread).
+def persist_report(report: schema.Report) -> dict[str, int]:
+ import store
- Hierarchy:
- 1. ScrapeCreators (if SCRAPECREATORS_API_KEY exists) — premium, best quality
- 2. Public Reddit JSON (always available) — free, good for thread discovery
- 3. OpenAI Responses API — legacy fallback for backwards compatibility
-
- Returns:
- Tuple of (reddit_items, raw_response, error, used_scrapecreators)
- """
- raw_response = None
- reddit_error = None
- used_scrapecreators = False
-
- sc_token = config.get("SCRAPECREATORS_API_KEY")
-
- if mock:
- raw_response = load_fixture("openai_sample.json")
- elif sc_token:
- # === Tier 1: ScrapeCreators path (preferred) ===
- used_scrapecreators = True
- try:
- sys.stderr.write("[Reddit] Using ScrapeCreators API\n")
- sys.stderr.flush()
- result = reddit.search_and_enrich(
- topic, from_date, to_date,
- depth=depth, token=sc_token,
- )
- reddit_items = result.get("items", [])
- if result.get("error"):
- reddit_error = result["error"]
- return reddit_items, result, reddit_error, used_scrapecreators
- except Exception as e:
- reddit_error = f"ScrapeCreators: {type(e).__name__}: {e}"
- sys.stderr.write(f"[Reddit] ScrapeCreators failed: {e}\n")
- sys.stderr.flush()
- used_scrapecreators = False
- # Fall through to Tier 2 (public JSON)
-
- # === Tier 2: Public Reddit JSON (free, always available) ===
- if not mock:
- try:
- sys.stderr.write("[Reddit] Trying public Reddit JSON\n")
- sys.stderr.flush()
- reddit_items = reddit_public.search_reddit_public(
- topic, from_date, to_date, depth=depth,
- )
- if reddit_items:
- raw_response = {"source": "reddit_public", "items": reddit_items}
- sys.stderr.write(f"[Reddit] Public JSON returned {len(reddit_items)} results\n")
- sys.stderr.flush()
- return reddit_items, raw_response, None, False
- # Empty results — fall through to Tier 3
- sys.stderr.write("[Reddit] Public JSON returned 0 results, trying OpenAI\n")
- sys.stderr.flush()
- except Exception as e:
- sys.stderr.write(f"[Reddit] Public JSON failed: {e}\n")
- sys.stderr.flush()
- # Fall through to Tier 3
-
- # === Tier 3: OpenAI Responses API (legacy fallback) ===
- if not mock and config.get("OPENAI_API_KEY"):
- try:
- sys.stderr.write("[Reddit] Falling back to OpenAI Responses API\n")
- sys.stderr.flush()
- raw_response = openai_reddit.search_reddit(
- config["OPENAI_API_KEY"],
- selected_models["openai"],
- topic,
- from_date,
- to_date,
- depth=depth,
- auth_source=config.get("OPENAI_AUTH_SOURCE", "api_key"),
- account_id=config.get("OPENAI_CHATGPT_ACCOUNT_ID"),
- )
- except http.HTTPError as e:
- raw_response = {"error": str(e)}
- reddit_error = f"API error: {e}"
- except Exception as e:
- raw_response = {"error": str(e)}
- reddit_error = f"{type(e).__name__}: {e}"
-
- # Parse response (OpenAI path)
- reddit_items = openai_reddit.parse_reddit_response(raw_response or {})
-
- # Quick retry with simpler query if few results (OpenAI path only)
- if len(reddit_items) < 5 and not mock and not reddit_error and config.get("OPENAI_API_KEY"):
- core = openai_reddit._extract_core_subject(topic)
- if core.lower() != topic.lower():
- try:
- retry_raw = openai_reddit.search_reddit(
- config["OPENAI_API_KEY"],
- selected_models["openai"],
- core,
- from_date, to_date,
- depth=depth,
- auth_source=config.get("OPENAI_AUTH_SOURCE", "api_key"),
- account_id=config.get("OPENAI_CHATGPT_ACCOUNT_ID"),
- )
- retry_items = openai_reddit.parse_reddit_response(retry_raw)
- existing_urls = {item.get("url") for item in reddit_items}
- for item in retry_items:
- if item.get("url") not in existing_urls:
- reddit_items.append(item)
- except Exception:
- pass
-
- # Subreddit-targeted fallback if still < 3 results (OpenAI path only)
- if len(reddit_items) < 3 and not mock and not reddit_error and config.get("OPENAI_API_KEY"):
- sub_query = openai_reddit._build_subreddit_query(topic)
- try:
- sub_raw = openai_reddit.search_reddit(
- config["OPENAI_API_KEY"],
- selected_models["openai"],
- sub_query,
- from_date, to_date,
- depth=depth,
- )
- sub_items = openai_reddit.parse_reddit_response(sub_raw)
- existing_urls = {item.get("url") for item in reddit_items}
- for item in sub_items:
- if item.get("url") not in existing_urls:
- reddit_items.append(item)
- except Exception:
- pass
-
- return reddit_items, raw_response, reddit_error, used_scrapecreators
-
-
-def _search_x(
- topic: str,
- config: dict,
- selected_models: dict,
- from_date: str,
- to_date: str,
- depth: str,
- mock: bool,
- x_source: str = "xai",
-) -> tuple:
- """Search X via Bird CLI or xAI (runs in thread).
-
- Args:
- x_source: 'bird' or 'xai' - which backend to use
-
- Returns:
- Tuple of (x_items, raw_response, error)
- """
- raw_response = None
- x_error = None
-
- if mock:
- raw_response = load_fixture("xai_sample.json")
- x_items = xai_x.parse_x_response(raw_response or {})
- return x_items, raw_response, x_error
-
- # Use Bird if specified
- if x_source == "bird":
- try:
- raw_response = bird_x.search_x(
- topic,
- from_date,
- to_date,
- depth=depth,
- )
- except Exception as e:
- raw_response = {"error": str(e)}
- x_error = f"{type(e).__name__}: {e}"
-
- x_items = bird_x.parse_bird_response(raw_response or {}, query=topic)
-
- # Check for error in response (Bird returns list on success, dict on error)
- if raw_response and isinstance(raw_response, dict) and raw_response.get("error") and not x_error:
- x_error = raw_response["error"]
-
- return x_items, raw_response, x_error
-
- # Use ScrapeCreators if specified
- if x_source == "scrapecreators":
- try:
- raw_response = scrapecreators_x.search_x(
- topic, from_date, to_date,
- depth=depth,
- token=config.get("SCRAPECREATORS_API_KEY"),
- )
- except Exception as e:
- raw_response = {"error": str(e)}
- x_error = f"{type(e).__name__}: {e}"
-
- x_items = scrapecreators_x.parse_x_response(raw_response or {})
-
- if raw_response and isinstance(raw_response, dict) and raw_response.get("error") and not x_error:
- x_error = raw_response["error"]
-
- return x_items, raw_response, x_error
-
- # Use xAI (original behavior)
+ store.init_db()
+ topic_row = store.add_topic(report.topic)
+ topic_id = topic_row["id"]
+ source_mode = ",".join(sorted(report.items_by_source)) or "v3"
+ run_id = store.record_run(topic_id, source_mode=source_mode, status="running")
try:
- raw_response = xai_x.search_x(
- config["XAI_API_KEY"],
- selected_models["xai"],
- topic,
- from_date,
- to_date,
- depth=depth,
- )
- except http.HTTPError as e:
- raw_response = {"error": str(e)}
- x_error = f"API error: {e}"
- except Exception as e:
- raw_response = {"error": str(e)}
- x_error = f"{type(e).__name__}: {e}"
-
- x_items = xai_x.parse_x_response(raw_response or {})
-
- return x_items, raw_response, x_error
-
-
-def _search_youtube(
- topic: str,
- from_date: str,
- to_date: str,
- depth: str,
-) -> tuple:
- """Search YouTube via yt-dlp (runs in thread).
-
- Returns:
- Tuple of (youtube_items, youtube_error)
- """
- youtube_error = None
-
- try:
- response = youtube_yt.search_and_transcribe(
- topic, from_date, to_date, depth=depth,
- )
- except Exception as e:
- return [], f"{type(e).__name__}: {e}"
-
- youtube_items = youtube_yt.parse_youtube_response(response)
-
- if response.get("error"):
- youtube_error = response["error"]
-
- return youtube_items, youtube_error
-
-
-def _search_tiktok(
- topic: str,
- from_date: str,
- to_date: str,
- depth: str,
- token: str,
-) -> tuple:
- """Search TikTok via ScrapeCreators (runs in thread).
-
- Returns:
- Tuple of (tiktok_items, tiktok_error)
- """
- tiktok_error = None
-
- try:
- response = tiktok.search_and_enrich(
- topic, from_date, to_date, depth=depth, token=token,
- )
- except Exception as e:
- return [], f"{type(e).__name__}: {e}"
-
- tiktok_items = tiktok.parse_tiktok_response(response)
-
- if response.get("error"):
- tiktok_error = response["error"]
-
- return tiktok_items, tiktok_error
-
-
-def _search_instagram(
- topic: str,
- from_date: str,
- to_date: str,
- depth: str,
- token: str,
-) -> tuple:
- """Search Instagram via ScrapeCreators (runs in thread).
-
- Returns:
- Tuple of (instagram_items, instagram_error)
- """
- instagram_error = None
-
- try:
- response = instagram.search_and_enrich(
- topic, from_date, to_date, depth=depth, token=token,
- )
- except Exception as e:
- return [], f"{type(e).__name__}: {e}"
-
- instagram_items = instagram.parse_instagram_response(response)
-
- if response.get("error"):
- instagram_error = response["error"]
-
- return instagram_items, instagram_error
-
-
-def _search_hackernews(
- topic: str,
- from_date: str,
- to_date: str,
- depth: str,
-) -> tuple:
- """Search Hacker News via Algolia (runs in thread).
-
- Returns:
- Tuple of (hn_items, hn_error)
- """
- hn_error = None
-
- try:
- response = hackernews.search_hackernews(
- topic, from_date, to_date, depth=depth,
- )
- except Exception as e:
- return [], f"{type(e).__name__}: {e}"
-
- hn_items = hackernews.parse_hackernews_response(response, query=topic)
-
- if response.get("error"):
- hn_error = response["error"]
-
- return hn_items, hn_error
-
-
-def _search_bluesky(
- topic: str,
- from_date: str,
- to_date: str,
- depth: str,
- config: dict = None,
-) -> tuple:
- """Search Bluesky via AT Protocol (runs in thread).
-
- Returns:
- Tuple of (bsky_items, bsky_error)
- """
- bsky_error = None
-
- try:
- response = bluesky.search_bluesky(
- topic, from_date, to_date, depth=depth, config=config,
- )
- except Exception as e:
- return [], f"{type(e).__name__}: {e}"
-
- bsky_items = bluesky.parse_bluesky_response(response)
-
- if response.get("error"):
- bsky_error = response["error"]
-
- return bsky_items, bsky_error
-
-
-def _search_truthsocial(
- topic: str,
- from_date: str,
- to_date: str,
- depth: str,
- config: dict = None,
-) -> tuple:
- """Search Truth Social via Mastodon API (runs in thread).
-
- Returns:
- Tuple of (ts_items, ts_error)
- """
- ts_error = None
-
- try:
- response = truthsocial.search_truthsocial(
- topic, from_date, to_date, depth=depth, config=config,
- )
- except Exception as e:
- return [], f"{type(e).__name__}: {e}"
-
- ts_items = truthsocial.parse_truthsocial_response(response)
-
- if response.get("error"):
- ts_error = response["error"]
-
- return ts_items, ts_error
-
-
-def _search_polymarket(
- topic: str,
- from_date: str,
- to_date: str,
- depth: str,
-) -> tuple:
- """Search Polymarket via Gamma API (runs in thread).
-
- Returns:
- Tuple of (pm_items, pm_error)
- """
- pm_error = None
-
- try:
- response = polymarket.search_polymarket(
- topic, from_date, to_date, depth=depth,
- )
- except Exception as e:
- return [], f"{type(e).__name__}: {e}"
-
- pm_items = polymarket.parse_polymarket_response(response, topic=topic)
-
- if response.get("error"):
- pm_error = response["error"]
-
- return pm_items, pm_error
-
-
-def _search_web(
- topic: str,
- config: dict,
- from_date: str,
- to_date: str,
- depth: str,
-) -> tuple:
- """Search the web via native API backend (runs in thread).
-
- Uses the best available backend: Parallel AI > Brave > OpenRouter.
-
- Returns:
- Tuple of (web_items, web_error)
- web_items are raw dicts ready for websearch.normalize_websearch_items()
- """
- from lib import brave_search, parallel_search, openrouter_search, exa_search
-
- backend = env.get_web_search_source(config)
- if not backend:
- return [], "No web search API keys configured"
-
- web_error = None
- raw_results = []
-
- try:
- if backend == "exa":
- raw_results = exa_search.search_web(
- topic, from_date, to_date, config["EXA_API_KEY"], depth=depth,
- )
- elif backend == "parallel":
- raw_results = parallel_search.search_web(
- topic, from_date, to_date, config["PARALLEL_API_KEY"], depth=depth,
- )
- elif backend == "brave":
- use_llm_ctx = os.environ.get("BRAVE_LLM_CONTEXT", "").strip() == "1"
- raw_results = brave_search.search_web(
- topic, from_date, to_date, config["BRAVE_API_KEY"],
- depth=depth, use_llm_context=use_llm_ctx,
- )
- elif backend == "openrouter":
- raw_results = openrouter_search.search_web(
- topic, from_date, to_date, config["OPENROUTER_API_KEY"], depth=depth,
- )
- except Exception as e:
- return [], f"{type(e).__name__}: {e}"
-
- # Add IDs and date_confidence for websearch.normalize_websearch_items()
- for i, item in enumerate(raw_results):
- item.setdefault("id", f"W{i+1}")
- if item.get("date") and not item.get("date_confidence"):
- item["date_confidence"] = "med"
- elif not item.get("date"):
- item["date_confidence"] = "low"
- item.setdefault("why_relevant", "")
-
- return raw_results, web_error
-
-
-def _search_xiaohongshu(
- topic: str,
- config: dict,
- from_date: str,
- to_date: str,
- depth: str,
-) -> tuple:
- """Search Xiaohongshu via xiaohongshu-mcp HTTP API (runs in thread).
-
- Returns:
- Tuple of (xiaohongshu_items, xiaohongshu_error)
- Items are in web-item dict shape and can be normalized with websearch module.
- """
- base_url = env.get_xiaohongshu_api_base(config)
- try:
- items = xiaohongshu_api.search_feeds(
- topic=topic,
- from_date=from_date,
- to_date=to_date,
- base_url=base_url,
- depth=depth,
- )
- except Exception as e:
- return [], f"{type(e).__name__}: {e}"
-
- # Ensure all required keys exist for normalize_websearch_items()
- for i, item in enumerate(items):
- item.setdefault("id", f"XHS{i+1}")
- item.setdefault("title", "")
- item.setdefault("url", "")
- item.setdefault("source_domain", "xiaohongshu.com")
- item.setdefault("snippet", "")
- if item.get("date") and not item.get("date_confidence"):
- item["date_confidence"] = "med"
- elif not item.get("date"):
- item["date_confidence"] = "low"
- item.setdefault("relevance", 0.5)
- item.setdefault("why_relevant", "")
-
- return items, None
-
-
-def _run_supplemental(
- topic: str,
- reddit_items: list,
- x_items: list,
- from_date: str,
- to_date: str,
- depth: str,
- x_source: str,
- progress: ui.ProgressDisplay = None,
- skip_reddit: bool = False,
- resolved_handle: str = None,
-) -> tuple:
- """Run Phase 2 supplemental searches based on entities from Phase 1.
-
- Extracts handles/subreddits from initial results, then runs targeted
- searches to find additional content the broad search missed.
-
- Args:
- topic: Original search topic
- reddit_items: Phase 1 Reddit items (raw dicts)
- x_items: Phase 1 X items (raw dicts)
- from_date: Start date
- to_date: End date
- depth: Research depth
- x_source: 'bird' or 'xai'
- progress: Optional progress display
- skip_reddit: If True, skip Reddit supplemental (e.g. rate-limited)
- resolved_handle: X handle resolved by the agent (without @), searched unfiltered
-
- Returns:
- Tuple of (supplemental_reddit, supplemental_x)
- """
- # Depth-dependent caps
- if depth == "default":
- max_handles = 3
- max_subs = 3
- count_per = 3
- else: # deep
- max_handles = 5
- max_subs = 5
- count_per = 5
-
- # Extract entities from Phase 1 results
- entities = entity_extract.extract_entities(
- reddit_items, x_items,
- max_handles=max_handles,
- max_subreddits=max_subs,
- )
-
- has_handles = entities["x_handles"] and x_source == "bird"
- has_subs = entities["reddit_subreddits"] and not skip_reddit
-
- # Always run unfiltered search for resolved handle (even if entity-extracted).
- # Entity-extracted handles get topic-filtered queries (from:handle topic),
- # but resolved handles need UNFILTERED search (from:handle) to find posts
- # that don't mention the topic string (e.g. Dor Brothers' viral tweet about
- # Logan Paul doesn't contain "dor brothers" in the text).
- has_resolved = bool(resolved_handle) and x_source == "bird"
-
- if not has_handles and not has_subs and not has_resolved:
- return [], []
-
- parts = []
- if has_resolved:
- parts.append(f"@{resolved_handle} (resolved)")
- if has_handles:
- parts.append(f"@{', @'.join(entities['x_handles'][:3])}")
- if has_subs:
- parts.append(f"r/{', r/'.join(entities['reddit_subreddits'][:3])}")
- sys.stderr.write(f"[Phase 2] Drilling into {' + '.join(parts)}\n")
- sys.stderr.flush()
-
- supplemental_reddit = []
- supplemental_x = []
-
- # Collect existing URLs to avoid adding duplicates before dedupe
- existing_urls = set()
- for item in reddit_items:
- existing_urls.add(item.get("url", ""))
- for item in x_items:
- existing_urls.add(item.get("url", ""))
-
- # Run supplemental searches in parallel
- reddit_future = None
- x_future = None
- resolved_future = None
-
- max_workers = sum([bool(has_subs), bool(has_handles), bool(has_resolved)])
- with ThreadPoolExecutor(max_workers=max(max_workers, 1)) as executor:
- if has_subs:
- reddit_future = executor.submit(
- openai_reddit.search_subreddits,
- entities["reddit_subreddits"],
- topic,
- from_date,
- to_date,
- count_per,
- )
-
- if has_handles:
- x_future = executor.submit(
- bird_x.search_handles,
- entities["x_handles"],
- topic,
- from_date,
- count_per,
- )
-
- if has_resolved:
- # Resolved handle: search unfiltered (topic=None) to get all recent posts
- resolved_future = executor.submit(
- bird_x.search_handles,
- [resolved_handle],
- None, # No topic filter - get all recent activity
- from_date,
- 10, # More results for the topic entity
- )
-
- if reddit_future:
- try:
- raw_reddit = reddit_future.result(timeout=30)
- # Filter out URLs already found in Phase 1
- supplemental_reddit = [
- item for item in raw_reddit
- if item.get("url", "") not in existing_urls
- ]
- except TimeoutError:
- sys.stderr.write("[Phase 2] Supplemental Reddit timed out (30s)\n")
- except Exception as e:
- sys.stderr.write(f"[Phase 2] Supplemental Reddit error: {e}\n")
-
- if x_future:
- try:
- raw_x = x_future.result(timeout=30)
- supplemental_x = [
- item for item in raw_x
- if item.get("url", "") not in existing_urls
- ]
- except TimeoutError:
- sys.stderr.write("[Phase 2] Supplemental X timed out (30s)\n")
- except Exception as e:
- sys.stderr.write(f"[Phase 2] Supplemental X error: {e}\n")
-
- if resolved_future:
- try:
- raw_resolved = resolved_future.result(timeout=30)
- # Lower relevance for unfiltered handle posts (no topic keyword signal)
- for item in raw_resolved:
- item["relevance"] = 0.5
- resolved_new = [
- item for item in raw_resolved
- if item.get("url", "") not in existing_urls
- ]
- supplemental_x.extend(resolved_new)
- if resolved_new:
- sys.stderr.write(f"[Phase 2] +{len(resolved_new)} from @{resolved_handle}\n")
- except TimeoutError:
- sys.stderr.write(f"[Phase 2] Resolved handle @{resolved_handle} timed out (30s)\n")
- except Exception as e:
- sys.stderr.write(f"[Phase 2] Resolved handle error: {e}\n")
-
- if supplemental_reddit or supplemental_x:
- sys.stderr.write(
- f"[Phase 2] +{len(supplemental_reddit)} Reddit, +{len(supplemental_x)} X\n"
- )
- sys.stderr.flush()
-
- return supplemental_reddit, supplemental_x
-
-
-def run_research(
- topic: str,
- sources: str,
- config: dict,
- selected_models: dict,
- from_date: str,
- to_date: str,
- depth: str = "default",
- mock: bool = False,
- progress: ui.ProgressDisplay = None,
- x_source: str = "xai",
- run_youtube: bool = False,
- run_tiktok: bool = False,
- run_instagram: bool = False,
- run_xiaohongshu: bool = False,
- timeouts: dict = None,
- resolved_handle: str = None,
- do_hackernews: bool = True,
- do_bluesky: bool = True,
- do_truthsocial: bool = True,
- do_polymarket: bool = True,
- no_native_web: bool = False,
-) -> tuple:
- """Run the research pipeline.
-
- Returns:
- Tuple of (reddit_items, x_items, youtube_items, tiktok_items, instagram_items,
- hackernews_items, bluesky_items, truthsocial_items, polymarket_items, web_items, web_needed,
- raw_openai, raw_xai, raw_reddit_enriched,
- reddit_error, x_error, youtube_error, tiktok_error, instagram_error,
- hackernews_error, bluesky_error, truthsocial_error, polymarket_error, web_error)
-
- Note: web_needed is True when web search should be performed by the assistant
- (i.e., no native web search API keys are configured). When native web search
- runs, web_items will be populated and web_needed will be False.
- """
- if timeouts is None:
- timeouts = TIMEOUT_PROFILES[depth]
- future_timeout = timeouts["future"]
-
- reddit_items = []
- x_items = []
- youtube_items = []
- tiktok_items = []
- instagram_items = []
- hackernews_items = []
- bluesky_items = []
- truthsocial_items = []
- polymarket_items = []
- web_items = []
- raw_openai = None
- raw_xai = None
- raw_reddit_enriched = []
- reddit_error = None
- x_error = None
- youtube_error = None
- tiktok_error = None
- instagram_error = None
- hackernews_error = None
- bluesky_error = None
- truthsocial_error = None
- polymarket_error = None
- web_error = None
- xiaohongshu_error = None
-
- # Determine web search mode
- do_web = sources in ("all", "web", "reddit-web", "x-web")
- web_backend = env.get_web_search_source(config) if (do_web and not no_native_web) else None
- web_needed = do_web and not web_backend
-
- # Web-only mode
- if sources == "web":
- if web_backend:
- # Native web search available — run it
- sys.stderr.write(f"[web] Searching via {web_backend}\n")
- sys.stderr.flush()
- try:
- web_items, web_error = _search_web(topic, config, from_date, to_date, depth)
- if web_error and progress:
- progress.show_error(f"Web error: {web_error}")
- except Exception as e:
- web_error = f"{type(e).__name__}: {e}"
- if progress:
- progress.show_error(f"Web error: {e}")
- sys.stderr.write(f"[web] {len(web_items)} results\n")
- sys.stderr.flush()
- else:
- # No native backend — assistant handles WebSearch
- if progress:
- progress.start_web_only()
- progress.end_web_only()
- # Optional Xiaohongshu search in web-only mode.
- if run_xiaohongshu:
- try:
- xhs_items, xiaohongshu_error = _search_xiaohongshu(
- topic, config, from_date, to_date, depth,
- )
- web_items.extend(xhs_items)
- if xiaohongshu_error and progress:
- progress.show_error(f"Xiaohongshu error: {xiaohongshu_error}")
- except Exception as e:
- xiaohongshu_error = f"{type(e).__name__}: {e}"
- if progress:
- progress.show_error(f"Xiaohongshu error: {e}")
- # Still run YouTube/TikTok/Instagram in web-only mode if available
- if run_youtube:
- if progress:
- progress.start_youtube()
- try:
- youtube_items, youtube_error = _search_youtube(topic, from_date, to_date, depth)
- if youtube_error and progress:
- progress.show_error(f"YouTube error: {youtube_error}")
- except Exception as e:
- youtube_error = f"{type(e).__name__}: {e}"
- if progress:
- progress.show_error(f"YouTube error: {e}")
- if progress:
- progress.end_youtube(len(youtube_items))
- if run_tiktok:
- if progress:
- progress.start_tiktok()
- try:
- tiktok_items, tiktok_error = _search_tiktok(topic, from_date, to_date, depth, env.get_tiktok_token(config))
- if tiktok_error and progress:
- progress.show_error(f"TikTok error: {tiktok_error}")
- except Exception as e:
- tiktok_error = f"{type(e).__name__}: {e}"
- if progress:
- progress.show_error(f"TikTok error: {e}")
- if progress:
- progress.end_tiktok(len(tiktok_items))
- if run_instagram:
- if progress:
- progress.start_instagram()
- try:
- ig_timeout = timeouts.get("instagram_future", future_timeout)
- instagram_items, instagram_error = _search_instagram(topic, from_date, to_date, depth, env.get_instagram_token(config))
- if instagram_error and progress:
- progress.show_error(f"Instagram error: {instagram_error}")
- except Exception as e:
- instagram_error = f"{type(e).__name__}: {e}"
- if progress:
- progress.show_error(f"Instagram error: {e}")
- if progress:
- progress.end_instagram(len(instagram_items))
- return reddit_items, x_items, youtube_items, tiktok_items, instagram_items, hackernews_items, bluesky_items, truthsocial_items, polymarket_items, web_items, web_needed, raw_openai, raw_xai, raw_reddit_enriched, reddit_error, x_error, youtube_error, tiktok_error, instagram_error, hackernews_error, bluesky_error, truthsocial_error, polymarket_error, web_error
-
- # Determine which searches to run
- do_reddit = sources in ("both", "reddit", "all", "reddit-web")
- do_x = sources in ("both", "x", "all", "x-web")
- # do_hackernews / do_polymarket are always True by default, but can be
- # restricted via the --search flag to run a focused source subset.
-
- # Run Reddit, X, YouTube, HN, Polymarket, and Web searches in parallel
- reddit_future = None
- x_future = None
- youtube_future = None
- tiktok_future = None
- instagram_future = None
- xiaohongshu_future = None
- hackernews_future = None
- bluesky_future = None
- truthsocial_future = None
- polymarket_future = None
- web_future = None
- max_workers = (
- 2
- + (1 if run_youtube else 0)
- + (1 if run_tiktok else 0)
- + (1 if run_instagram else 0)
- + (1 if run_xiaohongshu else 0)
- + (1 if do_hackernews else 0)
- + (1 if do_bluesky else 0)
- + (1 if do_truthsocial else 0)
- + (1 if do_polymarket else 0)
- + (1 if web_backend else 0)
- )
-
- with ThreadPoolExecutor(max_workers=max_workers) as executor:
- # Submit searches
- if do_reddit:
- if progress:
- progress.start_reddit()
- reddit_future = executor.submit(
- _search_reddit, topic, config, selected_models,
- from_date, to_date, depth, mock
- )
-
- if do_x:
- if progress:
- progress.start_x()
- x_future = executor.submit(
- _search_x, topic, config, selected_models,
- from_date, to_date, depth, mock, x_source
- )
-
- if run_youtube:
- if progress:
- progress.start_youtube()
- youtube_future = executor.submit(
- _search_youtube, topic, from_date, to_date, depth
- )
-
- if run_tiktok:
- if progress:
- progress.start_tiktok()
- tiktok_future = executor.submit(
- _search_tiktok, topic, from_date, to_date, depth,
- env.get_tiktok_token(config),
- )
-
- if run_instagram:
- if progress:
- progress.start_instagram()
- instagram_future = executor.submit(
- _search_instagram, topic, from_date, to_date, depth,
- env.get_instagram_token(config),
- )
-
- if run_xiaohongshu:
- xiaohongshu_future = executor.submit(
- _search_xiaohongshu, topic, config, from_date, to_date, depth,
- )
-
- if do_hackernews:
- if progress:
- progress.start_hackernews()
- hackernews_future = executor.submit(
- _search_hackernews, topic, from_date, to_date, depth
- )
-
- if do_bluesky:
- bluesky_future = executor.submit(
- _search_bluesky, topic, from_date, to_date, depth, config
- )
-
- if do_truthsocial:
- truthsocial_future = executor.submit(
- _search_truthsocial, topic, from_date, to_date, depth, config
- )
-
- if do_polymarket:
- if progress:
- progress.start_polymarket()
- polymarket_future = executor.submit(
- _search_polymarket, topic, from_date, to_date, depth
- )
-
- if web_backend:
- sys.stderr.write(f"[web] Searching via {web_backend}\n")
- sys.stderr.flush()
- web_future = executor.submit(
- _search_web, topic, config, from_date, to_date, depth
- )
-
- # Collect results (with timeouts to prevent indefinite blocking)
- reddit_used_sc = False # Track if ScrapeCreators was used for Reddit
- if reddit_future:
- reddit_timeout = timeouts.get("reddit_future", future_timeout)
- try:
- reddit_items, raw_openai, reddit_error, reddit_used_sc = reddit_future.result(timeout=reddit_timeout)
- if reddit_error and progress:
- progress.show_error(f"Reddit error: {reddit_error}")
- except TimeoutError:
- reddit_error = f"Reddit search timed out after {reddit_timeout}s"
- if progress:
- progress.show_error(reddit_error)
- except Exception as e:
- reddit_error = f"{type(e).__name__}: {e}"
- if progress:
- progress.show_error(f"Reddit error: {e}")
- if progress:
- progress.end_reddit(len(reddit_items))
-
- if x_future:
- try:
- x_items, raw_xai, x_error = x_future.result(timeout=future_timeout)
- if x_error and progress:
- progress.show_error(f"X error: {x_error}")
- except TimeoutError:
- x_error = f"X search timed out after {future_timeout}s"
- if progress:
- progress.show_error(x_error)
- except Exception as e:
- x_error = f"{type(e).__name__}: {e}"
- if progress:
- progress.show_error(f"X error: {e}")
- if progress:
- progress.end_x(len(x_items))
-
- if youtube_future:
- yt_timeout = timeouts.get("youtube_future", future_timeout)
- try:
- youtube_items, youtube_error = youtube_future.result(timeout=yt_timeout)
- if youtube_error and progress:
- progress.show_error(f"YouTube error: {youtube_error}")
- except TimeoutError:
- youtube_error = f"YouTube search timed out after {yt_timeout}s"
- if progress:
- progress.show_error(youtube_error)
- except Exception as e:
- youtube_error = f"{type(e).__name__}: {e}"
- if progress:
- progress.show_error(f"YouTube error: {e}")
- if progress:
- progress.end_youtube(len(youtube_items))
-
- if tiktok_future:
- tk_timeout = timeouts.get("tiktok_future", future_timeout)
- try:
- tiktok_items, tiktok_error = tiktok_future.result(timeout=tk_timeout)
- if tiktok_error and progress:
- progress.show_error(f"TikTok error: {tiktok_error}")
- except TimeoutError:
- tiktok_error = f"TikTok search timed out after {tk_timeout}s"
- if progress:
- progress.show_error(tiktok_error)
- except Exception as e:
- tiktok_error = f"{type(e).__name__}: {e}"
- if progress:
- progress.show_error(f"TikTok error: {e}")
- if progress:
- progress.end_tiktok(len(tiktok_items))
-
- if instagram_future:
- ig_timeout = timeouts.get("instagram_future", future_timeout)
- try:
- instagram_items, instagram_error = instagram_future.result(timeout=ig_timeout)
- if instagram_error and progress:
- progress.show_error(f"Instagram error: {instagram_error}")
- except TimeoutError:
- instagram_error = f"Instagram search timed out after {ig_timeout}s"
- if progress:
- progress.show_error(instagram_error)
- except Exception as e:
- instagram_error = f"{type(e).__name__}: {e}"
- if progress:
- progress.show_error(f"Instagram error: {e}")
- if progress:
- progress.end_instagram(len(instagram_items))
-
- if xiaohongshu_future:
- try:
- xhs_items, xiaohongshu_error = xiaohongshu_future.result(timeout=future_timeout)
- web_items.extend(xhs_items)
- if xiaohongshu_error and progress:
- progress.show_error(f"Xiaohongshu error: {xiaohongshu_error}")
- except TimeoutError:
- xiaohongshu_error = f"Xiaohongshu search timed out after {future_timeout}s"
- if progress:
- progress.show_error(xiaohongshu_error)
- except Exception as e:
- xiaohongshu_error = f"{type(e).__name__}: {e}"
- if progress:
- progress.show_error(f"Xiaohongshu error: {e}")
-
- if hackernews_future:
- hn_timeout = timeouts.get("hackernews_future", future_timeout)
- try:
- hackernews_items, hackernews_error = hackernews_future.result(timeout=hn_timeout)
- if hackernews_error and progress:
- progress.show_error(f"HN error: {hackernews_error}")
- except TimeoutError:
- hackernews_error = f"HN search timed out after {hn_timeout}s"
- if progress:
- progress.show_error(hackernews_error)
- except Exception as e:
- hackernews_error = f"{type(e).__name__}: {e}"
- if progress:
- progress.show_error(f"HN error: {e}")
- if progress:
- progress.end_hackernews(len(hackernews_items))
-
- if bluesky_future:
- bsky_timeout = timeouts.get("bluesky_future", future_timeout)
- try:
- bluesky_items, bluesky_error = bluesky_future.result(timeout=bsky_timeout)
- if bluesky_error and progress:
- progress.show_error(f"Bluesky error: {bluesky_error}")
- except TimeoutError:
- bluesky_error = f"Bluesky search timed out after {bsky_timeout}s"
- if progress:
- progress.show_error(bluesky_error)
- except Exception as e:
- bluesky_error = f"{type(e).__name__}: {e}"
- if progress:
- progress.show_error(f"Bluesky error: {e}")
-
- if truthsocial_future:
- ts_timeout = timeouts.get("truthsocial_future", future_timeout)
- try:
- truthsocial_items, truthsocial_error = truthsocial_future.result(timeout=ts_timeout)
- if truthsocial_error and progress:
- progress.show_error(f"Truth Social error: {truthsocial_error}")
- except TimeoutError:
- truthsocial_error = f"Truth Social search timed out after {ts_timeout}s"
- if progress:
- progress.show_error(truthsocial_error)
- except Exception as e:
- truthsocial_error = f"{type(e).__name__}: {e}"
- if progress:
- progress.show_error(f"Truth Social error: {e}")
-
- if polymarket_future:
- pm_timeout = timeouts.get("polymarket_future", future_timeout)
- try:
- polymarket_items, polymarket_error = polymarket_future.result(timeout=pm_timeout)
- if polymarket_error and progress:
- progress.show_error(f"Polymarket error: {polymarket_error}")
- except TimeoutError:
- polymarket_error = f"Polymarket search timed out after {pm_timeout}s"
- if progress:
- progress.show_error(polymarket_error)
- except Exception as e:
- polymarket_error = f"{type(e).__name__}: {e}"
- if progress:
- progress.show_error(f"Polymarket error: {e}")
- if progress:
- progress.end_polymarket(len(polymarket_items))
-
- if web_future:
- try:
- web_items, web_error = web_future.result(timeout=future_timeout)
- if web_error and progress:
- progress.show_error(f"Web error: {web_error}")
- except TimeoutError:
- web_error = f"Web search timed out after {future_timeout}s"
- if progress:
- progress.show_error(web_error)
- except Exception as e:
- web_error = f"{type(e).__name__}: {e}"
- if progress:
- progress.show_error(f"Web error: {e}")
- sys.stderr.write(f"[web] {len(web_items)} results\n")
- sys.stderr.flush()
-
- # Enrich Reddit items with real data (parallel, capped)
- # Skip enrichment if ScrapeCreators already provided comments + engagement
- enrich_max = timeouts["enrich_max_items"]
- enrich_total_timeout = timeouts["enrich_total"]
- items_to_enrich = reddit_items[:enrich_max]
- rate_limited = False # Set True if Reddit returns 429 during enrichment
-
- if reddit_used_sc and items_to_enrich:
- # ScrapeCreators already enriched items with comments — just copy to raw list
- sys.stderr.write(f"[Reddit] Skipping old enrichment — ScrapeCreators already provided comments\n")
- sys.stderr.flush()
- raw_reddit_enriched = list(reddit_items[:enrich_max])
- items_to_enrich = [] # Skip the enrichment block below
-
- if items_to_enrich:
- if progress:
- progress.start_reddit_enrich(1, len(items_to_enrich))
-
- if mock:
- # Sequential mock enrichment (fast, no need for parallelism)
- for i, item in enumerate(items_to_enrich):
- if progress and i > 0:
- progress.update_reddit_enrich(i + 1, len(items_to_enrich))
- try:
- mock_thread = load_fixture("reddit_thread_sample.json")
- reddit_items[i] = reddit_enrich.enrich_reddit_item(item, mock_thread)
- except Exception as e:
- if progress:
- progress.show_error(f"Enrich failed for {item.get('url', 'unknown')}: {e}")
- raw_reddit_enriched.append(reddit_items[i])
- else:
- # Parallel enrichment with bounded concurrency and total timeout
- # Uses short HTTP timeout (10s) and 1 retry to fail fast on 429
- completed_count = 0
- rate_limited = False
- with ThreadPoolExecutor(max_workers=5) as enrich_pool:
- futures = {
- enrich_pool.submit(reddit_enrich.enrich_reddit_item, item): i
- for i, item in enumerate(items_to_enrich)
- }
- try:
- for future in as_completed(futures, timeout=enrich_total_timeout):
- idx = futures[future]
- completed_count += 1
- if progress:
- progress.update_reddit_enrich(completed_count, len(items_to_enrich))
- try:
- reddit_items[idx] = future.result(timeout=timeouts["enrich_per"])
- except reddit_enrich.RedditRateLimitError:
- rate_limited = True
- if progress:
- progress.show_error(
- "Reddit rate-limited (429) — skipping remaining enrichment"
- )
- # Cancel remaining futures and bail
- for f in futures:
- f.cancel()
- break
- except Exception as e:
- if progress:
- progress.show_error(
- f"Enrich failed for {items_to_enrich[idx].get('url', 'unknown')}: {e}"
- )
- raw_reddit_enriched.append(reddit_items[idx])
- except TimeoutError:
- if progress:
- progress.show_error(
- f"Enrichment timed out after {enrich_total_timeout}s "
- f"({completed_count}/{len(items_to_enrich)} done)"
- )
- # Keep unenriched items as-is
- for idx in futures.values():
- if reddit_items[idx] not in raw_reddit_enriched:
- raw_reddit_enriched.append(reddit_items[idx])
-
- if progress:
- progress.end_reddit_enrich()
-
- # Enrich HN stories with comments
- if hackernews_items:
- try:
- hackernews_items = hackernews.enrich_top_stories(hackernews_items, depth=depth)
- except Exception as e:
- sys.stderr.write(f"[HN] Enrichment error: {e}\n")
- sys.stderr.flush()
-
- # Phase 2: Supplemental search based on entities from Phase 1
- # Skip on --quick (speed matters), mock mode, or if Reddit is rate-limiting
- # Also skip Reddit supplemental when ScrapeCreators was used (subreddit drilling already done)
- if depth != "quick" and not mock and (reddit_items or x_items):
- sup_reddit, sup_x = _run_supplemental(
- topic, reddit_items, x_items,
- from_date, to_date, depth, x_source, progress,
- skip_reddit=(rate_limited or reddit_used_sc),
- resolved_handle=resolved_handle,
- )
- if sup_reddit:
- reddit_items.extend(sup_reddit)
- if sup_x:
- x_items.extend(sup_x)
-
- return reddit_items, x_items, youtube_items, tiktok_items, instagram_items, hackernews_items, bluesky_items, truthsocial_items, polymarket_items, web_items, web_needed, raw_openai, raw_xai, raw_reddit_enriched, reddit_error, x_error, youtube_error, tiktok_error, instagram_error, hackernews_error, bluesky_error, truthsocial_error, polymarket_error, web_error
-
-
-def main():
- # Fix Unicode output on Windows (cp1252 can't encode emoji)
- if sys.platform == "win32":
- sys.stdout.reconfigure(encoding="utf-8", errors="replace")
- sys.stderr.reconfigure(encoding="utf-8", errors="replace")
-
- parser = argparse.ArgumentParser(
- description="Research a topic from the last N days on Reddit + X"
- )
- parser.add_argument("topic", nargs="*", help="Topic to research")
- parser.add_argument("--mock", action="store_true", help="Use fixtures")
- parser.add_argument(
- "--emit",
- choices=["compact", "json", "md", "context", "path"],
- default="compact",
- help="Output mode",
- )
- parser.add_argument(
- "--sources",
- choices=["auto", "reddit", "x", "both"],
- default="auto",
- help="Source selection",
- )
- parser.add_argument(
- "--quick",
- action="store_true",
- help="Faster research with fewer sources (8-12 each)",
- )
- parser.add_argument(
- "--deep",
- action="store_true",
- help="Comprehensive research with more sources (50-70 Reddit, 40-60 X)",
- )
- parser.add_argument(
- "--debug",
- action="store_true",
- help="Enable verbose debug logging",
- )
- parser.add_argument(
- "--include-web",
- action="store_true",
- help="Include general web search alongside Reddit/X (lower weighted)",
- )
- parser.add_argument(
- "--days",
- type=int,
- default=30,
- choices=range(1, 31),
- metavar="N",
- help="Number of days to look back (1-30, default: 30)",
- )
- parser.add_argument(
- "--store",
- action="store_true",
- help="Persist findings to SQLite database (~/.local/share/last30days/research.db)",
- )
- parser.add_argument(
- "--diagnose",
- action="store_true",
- help="Show source availability diagnostics and exit",
- )
- parser.add_argument(
- "--timeout",
- type=int,
- default=None,
- metavar="SECS",
- help="Global timeout in seconds (default: 180, quick: 90, deep: 300)",
- )
- parser.add_argument(
- "--x-handle",
- type=str,
- default=None,
- metavar="HANDLE",
- help="Resolved X handle for topic entity (without @). Searched unfiltered in Phase 2.",
- )
- parser.add_argument(
- "--search",
- type=str,
- default=None,
- metavar="SOURCES",
- help=(
- "Comma-separated list of sources to run. "
- f"Valid: {', '.join(sorted(VALID_SEARCH_SOURCES))}. "
- "Example: --search reddit,hn (default: all configured sources)"
- ),
- )
- parser.add_argument(
- "--no-native-web",
- action="store_true",
- default=False,
- help="Skip native web search backends (Parallel/Brave/OpenRouter). Use when the assistant has its own WebSearch tool.",
- )
- parser.add_argument(
- "--save-dir",
- type=str,
- default=None,
- metavar="DIR",
- help="Auto-save raw research output to DIR/{topic-slug}.md",
- )
-
- args = parser.parse_args()
- args.topic = " ".join(args.topic) if args.topic else None
-
- # Enable debug logging if requested
- if args.debug:
- os.environ["LAST30DAYS_DEBUG"] = "1"
- # Re-import http to pick up debug flag
- from lib import http as http_module
- http_module.DEBUG = True
-
- # Determine depth
- if args.quick and args.deep:
- print("Error: Cannot use both --quick and --deep", file=sys.stderr)
- sys.exit(1)
- elif args.quick:
- depth = "quick"
- elif args.deep:
- depth = "deep"
- else:
- depth = "default"
-
- # Install global timeout watchdog
- timeouts = TIMEOUT_PROFILES[depth]
- global_timeout = args.timeout or timeouts["global"]
- _install_global_timeout(global_timeout)
-
- # Load config
- config = env.get_config()
-
- # Detect first run (no SETUP_COMPLETE in config)
- first_run = setup_wizard.is_first_run(config)
-
- # On first run, block Bird's Node.js sweet-cookie scanner from probing
- # browser cookies before the user has given consent via the setup wizard.
- # Explicit AUTH_TOKEN (from env var) is fine — only block browser scanning.
- if first_run and config.get('_AUTH_TOKEN_SOURCE') != 'env':
- os.environ['BIRD_DISABLE_BROWSER_COOKIES'] = '1'
-
- # Inject .env credentials into Bird module before auth check.
- # On first run (no SETUP_COMPLETE), only inject explicit env var credentials —
- # skip if AUTH_TOKEN came from browser cookies (no consent yet).
- if first_run:
- auth_source = config.get('_AUTH_TOKEN_SOURCE')
- if auth_source == 'env':
- bird_x.set_credentials(config.get('AUTH_TOKEN'), config.get('CT0'))
- else:
- bird_x.set_credentials(config.get('AUTH_TOKEN'), config.get('CT0'))
-
- # Auto-detect Bird (no prompts - just use it if available)
- x_source_status = env.get_x_source_status(config)
- x_source = x_source_status["source"] # 'bird', 'xai', or None
- x_method = x_source_status.get("method") # 'env', 'browser-firefox', 'api', etc.
-
- # Auto-detect yt-dlp for YouTube search
- has_ytdlp = env.is_ytdlp_available()
-
- # Auto-detect ScrapeCreators/Apify for TikTok
- has_tiktok = env.is_tiktok_available(config)
-
- # Auto-detect ScrapeCreators for Instagram
- has_instagram = env.is_instagram_available(config)
-
- # Auto-detect Xiaohongshu HTTP API (requires service + login)
- has_xiaohongshu = env.is_xiaohongshu_available(config)
-
- # Auto-detect Bluesky (requires BSKY_HANDLE + BSKY_APP_PASSWORD)
- has_bluesky = env.is_bluesky_available(config)
-
- # Auto-detect Truth Social (requires TRUTHSOCIAL_TOKEN)
- has_truthsocial = env.is_truthsocial_available(config)
-
- # --diagnose: show source availability and exit
- if args.diagnose:
- web_source = env.get_web_search_source(config)
- diag = {
- "openai": bool(config.get("OPENAI_API_KEY")),
- "reddit_public": True,
- "xai": bool(config.get("XAI_API_KEY")),
- "x_source": x_source_status["source"],
- "x_method": x_source_status.get("method"),
- "bird_installed": x_source_status["bird_installed"],
- "bird_authenticated": x_source_status["bird_authenticated"],
- "bird_username": x_source_status.get("bird_username"),
- "youtube": has_ytdlp,
- "tiktok": has_tiktok,
- "instagram": has_instagram,
- "xiaohongshu": has_xiaohongshu,
- "xiaohongshu_api_base": env.get_xiaohongshu_api_base(config),
- "hackernews": True,
- "bluesky": has_bluesky,
- "truthsocial": has_truthsocial,
- "polymarket": True,
- "web_search_backend": web_source,
- "exa": bool(config.get("EXA_API_KEY")),
- "parallel_ai": bool(config.get("PARALLEL_API_KEY")),
- "brave": bool(config.get("BRAVE_API_KEY")),
- "openrouter": bool(config.get("OPENROUTER_API_KEY")),
- }
- print(json.dumps(diag, indent=2))
- sys.exit(0)
-
- # Handle 'setup' subcommand
- if args.topic and args.topic.strip().lower() == "setup":
- results = setup_wizard.run_auto_setup(config)
- # Write config
- env_path = env.CONFIG_FILE
- if env_path:
- written = setup_wizard.write_setup_config(env_path)
- results["env_written"] = written
- else:
- results["env_written"] = False
- print(setup_wizard.get_setup_status_text(results))
- sys.exit(0)
-
- # Validate topic (--diagnose doesn't need one)
- if not args.topic:
- print("Error: Please provide a topic to research.", file=sys.stderr)
- print("Usage: python3 last30days.py [options]", file=sys.stderr)
- sys.exit(1)
-
- # Initialize progress display with topic
- progress = ui.ProgressDisplay(args.topic, show_banner=True)
-
- # Show status banner (free-first design — lead with what works)
- web_source = env.get_web_search_source(config)
- reddit_source = env.get_reddit_source(config)
- diag = {
- "setup_complete": bool(config.get("SETUP_COMPLETE")),
- "reddit_source": reddit_source, # 'scrapecreators', 'openai', or None
- "x_source": x_source_status["source"],
- "x_method": x_source_status.get("method"),
- "youtube": has_ytdlp,
- "tiktok": has_tiktok,
- "instagram": has_instagram,
- "hackernews": True,
- "polymarket": True,
- "bluesky": has_bluesky,
- "truthsocial": has_truthsocial,
- "xiaohongshu": has_xiaohongshu,
- "scrapecreators": bool(config.get("SCRAPECREATORS_API_KEY")),
- "web_search_backend": "deferred to assistant" if args.no_native_web else web_source,
- }
- ui.show_diagnostic_banner(diag)
-
- # Check available sources (now accounts for Bird/cookie auth automatically)
- available = env.get_available_sources(config)
-
- # Mock mode can work without keys
- if args.mock:
- if args.sources == "auto":
- sources = "both"
- else:
- sources = args.sources
- else:
- # Validate requested sources against available
- sources, error = env.validate_sources(args.sources, available, args.include_web)
- if error:
- # If it's a warning about WebSearch fallback, print but continue
- if "WebSearch fallback" in error:
- print(f"Note: {error}", file=sys.stderr)
- else:
- print(f"Error: {error}", file=sys.stderr)
- sys.exit(1)
-
- # Get date range
- from_date, to_date = dates.get_date_range(args.days)
-
- # Check what keys are missing for promo messaging
- missing_keys = env.get_missing_keys(config)
-
- # Show NUX / promo for missing keys BEFORE research
- if missing_keys != 'none':
- progress.show_promo(missing_keys, diag=diag)
-
- # Select models
- if args.mock:
- # Use mock models
- mock_openai_models = load_fixture("models_openai_sample.json").get("data", [])
- mock_xai_models = load_fixture("models_xai_sample.json").get("data", [])
- selected_models = models.get_models(
- {
- "OPENAI_API_KEY": "mock",
- "XAI_API_KEY": "mock",
- **config,
- },
- mock_openai_models,
- mock_xai_models,
- )
- else:
- selected_models = models.get_models(config)
-
- # Determine mode string
- if sources == "all":
- mode = "all" # reddit + x + web
- elif sources == "both":
- mode = "both" # reddit + x
- elif sources == "reddit":
- mode = "reddit-only"
- elif sources == "reddit-web":
- mode = "reddit-web"
- elif sources == "x":
- mode = "x-only"
- elif sources == "x-web":
- mode = "x-web"
- elif sources == "web":
- mode = "web-only"
- else:
- mode = sources
-
- # Detect query type for source tiering and scoring adjustments
- query_type = qt.detect_query_type(args.topic)
-
- # Apply --search flag: restrict sources to the specified subset
- # Source defaults are query-type-aware (Truth Social always opt-in,
- # Bluesky only for query types where it adds signal)
- search_do_hackernews = qt.is_source_enabled("hn", query_type) if not args.search else True
- search_do_bluesky = has_bluesky and qt.is_source_enabled("bluesky", query_type)
- search_do_truthsocial = False # Always opt-in (requires --search truthsocial)
- search_do_polymarket = qt.is_source_enabled("polymarket", query_type)
- search_run_youtube = has_ytdlp and qt.is_source_enabled("youtube", query_type)
- search_run_tiktok = has_tiktok and qt.is_source_enabled("tiktok", query_type)
- search_run_instagram = has_instagram and qt.is_source_enabled("instagram", query_type)
- search_run_xiaohongshu = has_xiaohongshu
-
- # INCLUDE_SOURCES override: force specific sources on regardless of tier
- _include_sources = {s.strip().lower() for s in config.get('INCLUDE_SOURCES', '').split(',') if s.strip()}
- if _include_sources:
- if 'tiktok' in _include_sources and has_tiktok:
- if not search_run_tiktok:
- sys.stderr.write("[Config] INCLUDE_SOURCES override: forcing tiktok\n")
- search_run_tiktok = True
- if 'instagram' in _include_sources and has_instagram:
- if not search_run_instagram:
- sys.stderr.write("[Config] INCLUDE_SOURCES override: forcing instagram\n")
- search_run_instagram = True
- if args.search:
- search_sources = parse_search_flag(args.search)
- has_reddit = "reddit" in search_sources
- has_x = "x" in search_sources
- search_do_hackernews = "hn" in search_sources
- search_do_bluesky = ("bluesky" in search_sources or "bsky" in search_sources) and has_bluesky
- search_do_truthsocial = ("truthsocial" in search_sources or "truth" in search_sources) and has_truthsocial
- search_do_polymarket = "polymarket" in search_sources
- search_run_youtube = "youtube" in search_sources and has_ytdlp
- search_run_tiktok = "tiktok" in search_sources and has_tiktok
- search_run_instagram = "instagram" in search_sources and has_instagram
- # If explicitly requested, attempt Xiaohongshu even when preflight says unavailable.
- search_run_xiaohongshu = "xiaohongshu" in search_sources
- include_search_web = "web" in search_sources
- # Map to existing sources string
- if has_reddit and has_x:
- sources = "both" + ("-web" if include_search_web else "")
- sources = "all" if include_search_web else "both"
- elif has_reddit:
- sources = "reddit-web" if include_search_web else "reddit"
- elif has_x:
- sources = "x-web" if include_search_web else "x"
- else:
- sources = "web" # hn/polymarket only; no Reddit/X
-
- # Run research
- reddit_items, x_items, youtube_items, tiktok_items, instagram_items, hackernews_items, bluesky_items, truthsocial_items, polymarket_items, web_items, web_needed, raw_openai, raw_xai, raw_reddit_enriched, reddit_error, x_error, youtube_error, tiktok_error, instagram_error, hackernews_error, bluesky_error, truthsocial_error, polymarket_error, web_error = run_research(
- args.topic,
- sources,
- config,
- selected_models,
- from_date,
- to_date,
- depth,
- args.mock,
- progress,
- x_source=x_source or "xai",
- run_youtube=search_run_youtube,
- run_tiktok=search_run_tiktok,
- run_instagram=search_run_instagram,
- run_xiaohongshu=search_run_xiaohongshu,
- timeouts=timeouts,
- resolved_handle=args.x_handle,
- do_hackernews=search_do_hackernews,
- do_bluesky=search_do_bluesky,
- do_truthsocial=search_do_truthsocial,
- do_polymarket=search_do_polymarket,
- no_native_web=args.no_native_web,
- )
-
- # Processing phase
- progress.start_processing()
-
- # Normalize items
- normalized_reddit = normalize.normalize_reddit_items(reddit_items, from_date, to_date)
- normalized_x = normalize.normalize_x_items(x_items, from_date, to_date)
- normalized_youtube = normalize.normalize_youtube_items(youtube_items, from_date, to_date) if youtube_items else []
- normalized_tiktok = normalize.normalize_tiktok_items(tiktok_items, from_date, to_date) if tiktok_items else []
- normalized_ig = normalize.normalize_instagram_items(instagram_items, from_date, to_date) if instagram_items else []
- normalized_hn = normalize.normalize_hackernews_items(hackernews_items, from_date, to_date) if hackernews_items else []
- normalized_bsky = normalize.normalize_bluesky_items(bluesky_items, from_date, to_date) if bluesky_items else []
- normalized_ts = normalize.normalize_truthsocial_items(truthsocial_items, from_date, to_date) if truthsocial_items else []
- normalized_pm = normalize.normalize_polymarket_items(polymarket_items, from_date, to_date) if polymarket_items else []
- normalized_web = websearch.normalize_websearch_items(web_items, from_date, to_date) if web_items else []
-
- # Hard date filter: exclude items with verified dates outside the range
- # This is the safety net - even if prompts let old content through, this filters it
- filtered_reddit = normalize.filter_by_date_range(normalized_reddit, from_date, to_date)
- filtered_x = normalize.filter_by_date_range(normalized_x, from_date, to_date)
- # YouTube: skip hard date filter — youtube_yt.py already applies a soft filter
- # that prefers recent videos but keeps older ones for evergreen topics.
- # YouTube content has a longer shelf life than tweets/posts.
- filtered_youtube = normalized_youtube
- # TikTok: hard date filter (tiktok.py already pre-filters, but safety net)
- filtered_tiktok = normalize.filter_by_date_range(normalized_tiktok, from_date, to_date) if normalized_tiktok else []
- # Instagram: hard date filter (instagram.py already pre-filters, but safety net)
- filtered_ig = normalize.filter_by_date_range(normalized_ig, from_date, to_date) if normalized_ig else []
- filtered_hn = normalize.filter_by_date_range(normalized_hn, from_date, to_date) if normalized_hn else []
- filtered_bsky = normalize.filter_by_date_range(normalized_bsky, from_date, to_date) if normalized_bsky else []
- filtered_ts = normalize.filter_by_date_range(normalized_ts, from_date, to_date) if normalized_ts else []
- # Polymarket: skip hard date filter - markets are active/traded, updatedAt is fine
- filtered_pm = normalized_pm
- filtered_web = normalize.filter_by_date_range(normalized_web, from_date, to_date) if normalized_web else []
-
- # Score items
- scored_reddit = score.score_reddit_items(filtered_reddit)
- scored_x = score.score_x_items(filtered_x)
- scored_youtube = score.score_youtube_items(filtered_youtube) if filtered_youtube else []
- scored_tiktok = score.score_tiktok_items(filtered_tiktok) if filtered_tiktok else []
- scored_ig = score.score_instagram_items(filtered_ig) if filtered_ig else []
- scored_hn = score.score_hackernews_items(filtered_hn) if filtered_hn else []
- scored_bsky = score.score_bluesky_items(filtered_bsky) if filtered_bsky else []
- scored_ts = score.score_truthsocial_items(filtered_ts) if filtered_ts else []
- scored_pm = score.score_polymarket_items(filtered_pm) if filtered_pm else []
- scored_web = score.score_websearch_items(filtered_web, query_type=query_type) if filtered_web else []
-
- # Sort items (query-type-aware tiebreaker ordering)
- sorted_reddit = score.sort_items(scored_reddit, query_type=query_type)
- sorted_x = score.sort_items(scored_x, query_type=query_type)
- sorted_youtube = score.sort_items(scored_youtube, query_type=query_type) if scored_youtube else []
- sorted_tiktok = score.sort_items(scored_tiktok, query_type=query_type) if scored_tiktok else []
- sorted_ig = score.sort_items(scored_ig, query_type=query_type) if scored_ig else []
- sorted_hn = score.sort_items(scored_hn, query_type=query_type) if scored_hn else []
- sorted_bsky = score.sort_items(scored_bsky, query_type=query_type) if scored_bsky else []
- sorted_ts = score.sort_items(scored_ts, query_type=query_type) if scored_ts else []
- sorted_pm = score.sort_items(scored_pm, query_type=query_type) if scored_pm else []
- sorted_web = score.sort_items(scored_web, query_type=query_type) if scored_web else []
-
- # Dedupe items
- deduped_reddit = dedupe.dedupe_reddit(sorted_reddit)
- deduped_x = dedupe.dedupe_x(sorted_x)
- deduped_youtube = dedupe.dedupe_youtube(sorted_youtube) if sorted_youtube else []
- deduped_tiktok = dedupe.dedupe_tiktok(sorted_tiktok) if sorted_tiktok else []
- deduped_ig = dedupe.dedupe_instagram(sorted_ig) if sorted_ig else []
- deduped_hn = dedupe.dedupe_hackernews(sorted_hn) if sorted_hn else []
- deduped_bsky = dedupe.dedupe_bluesky(sorted_bsky) if sorted_bsky else []
- deduped_ts = dedupe.dedupe_truthsocial(sorted_ts) if sorted_ts else []
- deduped_pm = dedupe.dedupe_polymarket(sorted_pm) if sorted_pm else []
- deduped_web = websearch.dedupe_websearch(sorted_web) if sorted_web else []
-
- # Post-retrieval relevance filter: drop low-relevance items per source
- deduped_reddit = score.relevance_filter(deduped_reddit, "REDDIT")
- deduped_x = score.relevance_filter(deduped_x, "X")
- deduped_youtube = score.relevance_filter(deduped_youtube, "YOUTUBE")
- deduped_tiktok = score.relevance_filter(deduped_tiktok, "TIKTOK")
- deduped_ig = score.relevance_filter(deduped_ig, "INSTAGRAM")
- deduped_hn = score.relevance_filter(deduped_hn, "HN")
- deduped_bsky = score.relevance_filter(deduped_bsky, "BLUESKY")
- deduped_ts = score.relevance_filter(deduped_ts, "TRUTHSOCIAL")
- deduped_pm = score.relevance_filter(deduped_pm, "POLYMARKET") if deduped_pm else []
-
- # Cross-source linking: annotate items that discuss the same story
- dedupe.cross_source_link(
- deduped_reddit, deduped_x, deduped_youtube, deduped_tiktok, deduped_ig, deduped_hn, deduped_bsky, deduped_ts, deduped_pm, deduped_web,
- )
-
- progress.end_processing()
-
- # Create report
- report = schema.create_report(
- args.topic,
- from_date,
- to_date,
- mode,
- selected_models.get("openai"),
- selected_models.get("xai"),
- )
- report.reddit = deduped_reddit
- report.x = deduped_x
- report.youtube = deduped_youtube
- report.tiktok = deduped_tiktok
- report.instagram = deduped_ig
- report.hackernews = deduped_hn
- report.bluesky = deduped_bsky
- report.truthsocial = deduped_ts
- report.polymarket = deduped_pm
- report.web = deduped_web
- report.reddit_error = reddit_error
- report.x_error = x_error
- report.youtube_error = youtube_error
- report.tiktok_error = tiktok_error
- report.instagram_error = instagram_error
- report.hackernews_error = hackernews_error
- report.bluesky_error = bluesky_error
- report.truthsocial_error = truthsocial_error
- report.polymarket_error = polymarket_error
- report.web_error = web_error
- report.resolved_x_handle = args.x_handle
-
- # Generate context snippet
- report.context_snippet_md = render.render_context_snippet(report)
-
- # Write outputs
- render.write_outputs(report, raw_openai, raw_xai, raw_reddit_enriched)
-
- # Show completion
- if sources == "web":
- progress.show_web_only_complete()
- else:
- progress.show_complete(len(deduped_reddit), len(deduped_x), len(deduped_youtube), len(deduped_hn), len(deduped_pm), len(deduped_tiktok), len(deduped_ig))
-
- # Build source info for status footer
- source_info = {}
- if not x_source:
- if x_source_status["bird_installed"]:
- source_info["x_skip_reason"] = "Bird installed but not authenticated — log into x.com in browser"
- else:
- source_info["x_skip_reason"] = "No Bird CLI, XAI_API_KEY, or SCRAPECREATORS_API_KEY"
- if not has_ytdlp:
- source_info["youtube_skip_reason"] = "yt-dlp not installed — fix: brew install yt-dlp"
- elif has_ytdlp and not report.youtube:
- source_info["youtube_skip_reason"] = "0 results (query may be too specific)"
- if not has_tiktok:
- source_info["tiktok_skip_reason"] = "No SCRAPECREATORS_API_KEY - sign up at scrapecreators.com (100 free API calls, no credit card)"
- if not has_instagram:
- source_info["instagram_skip_reason"] = "No SCRAPECREATORS_API_KEY - sign up at scrapecreators.com (100 free API calls, no credit card)"
- if not has_xiaohongshu:
- source_info["xiaohongshu_skip_reason"] = (
- f"Xiaohongshu API unavailable or not logged in - start xiaohongshu-mcp and login "
- f"(base: {env.get_xiaohongshu_api_base(config)})"
- )
- if not web_source:
- source_info["web_skip_reason"] = "assistant will use WebSearch (add BRAVE_API_KEY for native search)"
-
- # Compute quality score and upgrade nudge
- research_results = {
- "x_error": x_error,
- "youtube_error": youtube_error,
- "reddit_error": reddit_error,
- }
- quality = quality_nudge.compute_quality_score(config, research_results)
-
- # Output result
- output_result(report, args.emit, web_needed, args.topic, from_date, to_date, missing_keys, args.days, source_info, first_run=first_run, quality=quality)
-
- # Auto-save raw research to file if --save-dir is set
- if args.save_dir:
- import re
- save_dir = Path(args.save_dir).expanduser()
- save_dir.mkdir(parents=True, exist_ok=True)
- slug = re.sub(r'[^a-z0-9]+', '-', args.topic.lower()).strip('-')[:60]
- save_path = save_dir / f"{slug}-raw.md"
- if save_path.exists():
- save_path = save_dir / f"{slug}-raw-{datetime.now().strftime('%Y-%m-%d')}.md"
- content = render.render_compact(report, missing_keys=missing_keys)
- if quality and quality.get("nudge_text"):
- content += "\n" + render.render_quality_nudge(quality)
- content += "\n" + render.render_source_status(report, source_info)
- save_path.write_text(content, encoding="utf-8")
- print(f"📎 {save_path}", file=sys.stderr)
-
- # Persist findings to SQLite if requested
- if args.store:
- import store as store_mod
- store_mod.init_db()
- topic_row = store_mod.add_topic(args.topic)
- topic_id = topic_row["id"]
- run_id = store_mod.record_run(topic_id, source_mode=mode, status="completed")
-
- findings = []
- for item in deduped_reddit:
- findings.append({
- "source": "reddit",
- "url": item.url,
- "title": item.title,
- "author": item.subreddit,
- "content": item.title,
- "engagement_score": item.engagement.score if item.engagement else 0,
- "relevance_score": item.relevance,
- })
- for item in deduped_x:
- findings.append({
- "source": "x",
- "url": item.url,
- "title": item.text[:100],
- "author": item.author_handle,
- "content": item.text,
- "engagement_score": item.engagement.likes if item.engagement else 0,
- "relevance_score": item.relevance,
- })
- for item in deduped_youtube:
- findings.append({
- "source": "youtube",
- "url": item.url,
- "title": item.title,
- "author": item.channel_name,
- "content": item.transcript_snippet[:500] if item.transcript_snippet else item.title,
- "engagement_score": item.engagement.views if item.engagement and item.engagement.views else 0,
- "relevance_score": item.relevance,
- })
- for item in deduped_hn:
- findings.append({
- "source": "hackernews",
- "url": item.hn_url,
- "title": item.title,
- "author": item.author,
- "content": item.title,
- "engagement_score": item.engagement.score if item.engagement else 0,
- "relevance_score": item.relevance,
- })
- for item in deduped_bsky:
- findings.append({
- "source": "bluesky",
- "url": item.url,
- "title": item.text[:100],
- "author": item.author_handle,
- "content": item.text,
- "engagement_score": item.engagement.likes if item.engagement else 0,
- "relevance_score": item.relevance,
- })
- for item in deduped_pm:
- findings.append({
- "source": "polymarket",
- "url": item.url,
- "title": item.question,
- "author": "polymarket",
- "content": item.title,
- "engagement_score": item.engagement.volume if item.engagement and item.engagement.volume else 0,
- "relevance_score": item.relevance,
- })
- for item in deduped_ig:
- findings.append({
- "source": "instagram",
- "url": item.url,
- "title": item.text[:100],
- "author": item.author_name,
- "content": item.caption_snippet[:500] if item.caption_snippet else item.text,
- "engagement_score": item.engagement.views if item.engagement and item.engagement.views else 0,
- "relevance_score": item.relevance,
- })
- for item in deduped_web:
- findings.append({
- "source": "web",
- "url": item.url,
- "title": item.title,
- "author": item.source_domain,
- "content": item.snippet,
- "engagement_score": 0,
- "relevance_score": item.relevance,
- })
-
- counts = store_mod.store_findings(run_id, topic_id, findings)
- store_mod.update_run(
+ findings = store.findings_from_report(report)
+ counts = store.store_findings(run_id, topic_id, findings)
+ store.update_run(
run_id,
status="completed",
findings_new=counts["new"],
findings_updated=counts["updated"],
)
+ return counts
+ except Exception as exc:
+ store.update_run(run_id, status="failed", error_message=str(exc)[:500])
+ raise
+
+
+def build_parser() -> argparse.ArgumentParser:
+ parser = argparse.ArgumentParser(description="Research a topic across live social, market, and grounded web sources.")
+ parser.add_argument("topic", nargs="*", help="Research topic")
+ parser.add_argument("--emit", default="compact", choices=["compact", "json", "context", "md"])
+ parser.add_argument("--search", help="Comma-separated source list")
+ parser.add_argument("--quick", action="store_true", help="Lower-latency retrieval profile")
+ parser.add_argument("--deep", action="store_true", help="Higher-recall retrieval profile")
+ parser.add_argument("--debug", action="store_true", help="Enable HTTP debug logging")
+ parser.add_argument("--mock", action="store_true", help="Use mock retrieval fixtures")
+ parser.add_argument("--diagnose", action="store_true", help="Print provider and source availability")
+ parser.add_argument("--save-dir", help="Optional directory for saving the rendered output")
+ parser.add_argument("--store", action="store_true", help="Persist ranked findings to the SQLite research store")
+ parser.add_argument("--x-handle", help="X handle for targeted supplemental search")
+ parser.add_argument("--x-related", help="Comma-separated related X handles (searched with lower weight)")
+ parser.add_argument("--web-backend", default="auto",
+ choices=["auto", "brave", "exa", "serper", "parallel", "none"],
+ help="Web search backend (default: auto, tries Brave then Exa then Serper then Parallel)")
+ parser.add_argument("--deep-research", action="store_true",
+ help="Use Perplexity Deep Research (~$0.90/query) for in-depth analysis. Requires OPENROUTER_API_KEY.")
+ parser.add_argument("--plan", help="JSON query plan (skips internal LLM planner). Can be a JSON string or a file path.")
+ parser.add_argument("--save-suffix", help="Suffix for saved output filename (e.g., 'gemini' → kanye-west-raw-gemini.md)")
+ parser.add_argument("--subreddits", help="Comma-separated subreddit names to search (e.g., SaaS,Entrepreneur)")
+ parser.add_argument("--tiktok-hashtags", help="Comma-separated TikTok hashtags without # (e.g., tella,screenrecording)")
+ parser.add_argument("--tiktok-creators", help="Comma-separated TikTok creator handles (e.g., TellaHQ,taborplace)")
+ parser.add_argument("--ig-creators", help="Comma-separated Instagram creator handles (e.g., tella.tv,laborstories)")
+ parser.add_argument("--lookback-days", type=int, default=30, help="Number of days to look back for research (default: 30, watchlist uses 90)")
+ parser.add_argument("--auto-resolve", action="store_true",
+ help="Use web search to discover subreddits/handles before planning (for platforms without WebSearch)")
+ parser.add_argument("--github-user", help="GitHub username for person-mode search (e.g., steipete)")
+ parser.add_argument("--github-repo", help="Comma-separated owner/repo for project-mode search (e.g., openclaw/openclaw,paperclipai/paperclip)")
+ return parser
+
+
+def _missing_sources_for_promo(diag: dict[str, object]) -> str | None:
+ available = set(diag.get("available_sources") or [])
+ missing = []
+ if "reddit" not in available:
+ missing.append("reddit")
+ if "x" not in available:
+ missing.append("x")
+ if "grounding" not in available:
+ missing.append("web")
+ if not missing:
+ return None
+ if "reddit" in missing and "x" in missing:
+ return "both"
+ return missing[0]
+
+
+def _show_runtime_ui(report: schema.Report, progress: ui.ProgressDisplay, diag: dict[str, object]) -> None:
+ counts = {source: len(items) for source, items in report.items_by_source.items()}
+ display_sources = list(
+ dict.fromkeys(
+ [
+ *report.query_plan.source_weights.keys(),
+ *report.items_by_source.keys(),
+ *report.errors_by_source.keys(),
+ ]
+ )
+ )
+ progress.end_processing()
+ progress.show_complete(
+ source_counts=counts,
+ display_sources=display_sources,
+ )
+ promo = _missing_sources_for_promo(diag)
+ if promo:
+ progress.show_promo(promo, diag=diag)
+
+
+def main() -> int:
+ parser = build_parser()
+ # Use parse_known_args so setup sub-flags (--device-auth, --github,
+ # --openclaw) pass through without argparse hard-exiting.
+ args, extra_argv = parser.parse_known_args()
+ if args.debug:
+ os.environ["LAST30DAYS_DEBUG"] = "1"
+
+ config = env.get_config()
+
+ # Handle setup subcommand
+ topic = " ".join(args.topic).strip()
+ if topic.lower() == "setup":
+ from lib import setup_wizard
+ if "--openclaw" in extra_argv:
+ results = setup_wizard.run_openclaw_setup(config)
+ print(json.dumps(results))
+ return 0
+ if "--github" in extra_argv:
+ results = setup_wizard.run_github_auth()
+ print(json.dumps(results))
+ return 0
+ if "--device-auth" in extra_argv:
+ results = setup_wizard.run_full_device_auth()
+ print(json.dumps(results))
+ return 0
+ sys.stderr.write("Running auto-setup...\n")
+ results = setup_wizard.run_auto_setup(config)
+ from_browser = "auto"
+ if results.get("cookies_found"):
+ first_browser = next(iter(results["cookies_found"].values()))
+ from_browser = first_browser
+ setup_wizard.write_setup_config(env.CONFIG_FILE, from_browser=from_browser)
+ results["env_written"] = True
+ sys.stderr.write(setup_wizard.get_setup_status_text(results) + "\n")
+ return 0
+
+ requested_sources = parse_search_flag(args.search) if args.search else None
+ diag = pipeline.diagnose(config, requested_sources)
+
+ if args.diagnose:
+ print(json.dumps(diag, indent=2, sort_keys=True))
+ return 0
+
+ if not topic:
+ parser.print_usage(sys.stderr)
+ return 2
+
+ progress = ui.ProgressDisplay(topic, show_banner=True)
+ progress.start_processing()
+
+ depth = "deep" if args.deep else "quick" if args.quick else "default"
+ try:
+ x_related = [h.strip() for h in args.x_related.split(",") if h.strip()] if args.x_related else None
+ subreddits = [s.strip().lstrip("r/") for s in args.subreddits.split(",") if s.strip()] if args.subreddits else None
+ tiktok_hashtags = [h.strip().lstrip("#") for h in args.tiktok_hashtags.split(",") if h.strip()] if args.tiktok_hashtags else None
+ tiktok_creators = [c.strip().lstrip("@") for c in args.tiktok_creators.split(",") if c.strip()] if args.tiktok_creators else None
+ ig_creators = [c.strip().lstrip("@") for c in args.ig_creators.split(",") if c.strip()] if args.ig_creators else None
+ # Parse external plan if provided via --plan flag
+ external_plan = None
+ if args.plan:
+ import json as _json
+ plan_str = args.plan
+ if os.path.isfile(plan_str):
+ plan_str = open(plan_str).read()
+ try:
+ external_plan = _json.loads(plan_str)
+ except _json.JSONDecodeError as exc:
+ sys.stderr.write(f"[Planner] Invalid --plan JSON: {exc}\n")
+
+ # Auto-resolve: use web search to discover subreddits/handles before planning.
+ # This is the engine-side equivalent of SKILL.md Steps 0.55/0.75 for platforms
+ # without WebSearch (OpenClaw, Codex, raw CLI).
+ if args.auto_resolve and not external_plan:
+ from lib import resolve
+ resolution = resolve.auto_resolve(topic, config)
+ if resolution.get("subreddits") and not subreddits:
+ subreddits = resolution["subreddits"]
+ sys.stderr.write(f"[AutoResolve] Subreddits: {', '.join(subreddits)}\n")
+ if resolution.get("x_handle") and not args.x_handle:
+ args.x_handle = resolution["x_handle"]
+ sys.stderr.write(f"[AutoResolve] X handle: @{args.x_handle}\n")
+ if resolution.get("github_user") and not args.github_user:
+ args.github_user = resolution["github_user"]
+ sys.stderr.write(f"[AutoResolve] GitHub user: @{args.github_user}\n")
+ if resolution.get("github_repos") and not args.github_repo:
+ args.github_repo = ",".join(resolution["github_repos"])
+ sys.stderr.write(f"[AutoResolve] GitHub repos: {args.github_repo}\n")
+ if resolution.get("context"):
+ # Inject context into external_plan metadata for the planner to use
+ if not external_plan:
+ external_plan = None # planner will use its own, but with context
+ # Store context for the planner prompt injection
+ config["_auto_resolve_context"] = resolution["context"]
+ sys.stderr.write(f"[AutoResolve] Context: {resolution['context'][:80]}...\n")
+
+ github_user = args.github_user.lstrip("@").lower() if args.github_user else None
+ github_repos = [r.strip() for r in args.github_repo.split(",") if r.strip() and "/" in r.strip()] if args.github_repo else None
+
+ # --deep-research: auto-enable perplexity source and set deep flag
+ if args.deep_research:
+ if not config.get("OPENROUTER_API_KEY"):
+ print("Error: --deep-research requires OPENROUTER_API_KEY", file=sys.stderr)
+ sys.exit(1)
+ config["_deep_research"] = True
+ # Auto-enable perplexity in INCLUDE_SOURCES
+ include = config.get("INCLUDE_SOURCES") or ""
+ if "perplexity" not in include.lower():
+ config["INCLUDE_SOURCES"] = f"{include},perplexity" if include else "perplexity"
+
+ report = pipeline.run(
+ topic=topic,
+ config=config,
+ depth=depth,
+ requested_sources=requested_sources,
+ mock=args.mock,
+ x_handle=args.x_handle,
+ x_related=x_related,
+ web_backend=args.web_backend,
+ external_plan=external_plan,
+ subreddits=subreddits,
+ tiktok_hashtags=tiktok_hashtags,
+ tiktok_creators=tiktok_creators,
+ ig_creators=ig_creators,
+ lookback_days=args.lookback_days,
+ github_user=github_user,
+ github_repos=github_repos,
+ )
+ except Exception as exc:
+ progress.end_processing()
+ progress.show_error(str(exc))
+ raise
+ _show_runtime_ui(report, progress, diag)
+ if args.store:
+ counts = persist_report(report)
sys.stderr.write(
- f"[store] Saved {counts['new']} new, {counts['updated']} updated findings\n"
+ f"[last30days] Stored {counts['new']} new, {counts['updated']} updated findings\n"
)
sys.stderr.flush()
+ # Show quality nudge if applicable
+ try:
+ from lib import quality_nudge
+ quality = quality_nudge.compute_quality_score(config, {})
+ if quality.get("nudge_text"):
+ sys.stderr.write(f"\n{quality['nudge_text']}\n")
+ sys.stderr.flush()
+ except Exception:
+ pass
-def output_result(
- report: schema.Report,
- emit_mode: str,
- web_needed: bool = False,
- topic: str = "",
- from_date: str = "",
- to_date: str = "",
- missing_keys: str = "none",
- days: int = 30,
- source_info: dict = None,
- first_run: bool = False,
- quality: dict = None,
-):
- """Output the result based on emit mode."""
- if emit_mode == "compact":
- # Emit first-run flag before research output so SKILL.md can detect it
- if first_run:
- print("FIRST_RUN: true")
- print("")
- print(render.render_compact(report, missing_keys=missing_keys))
- # Quality nudge (right before source status/stats block)
- if quality and quality.get("nudge_text"):
- print(render.render_quality_nudge(quality))
- # Append source status footer
- print(render.render_source_status(report, source_info))
- elif emit_mode == "json":
- print(json.dumps(report.to_dict(), indent=2))
- elif emit_mode == "md":
- print(render.render_full_report(report))
- elif emit_mode == "context":
- print(report.context_snippet_md)
- elif emit_mode == "path":
- print(render.get_context_path())
-
- # Output WebSearch instructions if needed
- if web_needed:
- print("\n" + "="*60)
- print("### WEBSEARCH REQUIRED ###")
- print("="*60)
- print(f"Topic: {topic}")
- print(f"Date range: {from_date} to {to_date}")
- print("")
- print("Assistant: Use your web search tool to find 8-15 relevant web pages.")
- print("EXCLUDE: reddit.com, x.com, twitter.com (already covered above)")
- print(f"INCLUDE: blogs, docs, news, tutorials from the last {days} days")
- print("")
- print("After searching, synthesize WebSearch results WITH the Reddit/X")
- print("results above. WebSearch items should rank LOWER than comparable")
- print("Reddit/X items (they lack engagement metrics).")
- print("="*60)
+ fun_level = config.get("FUN_LEVEL", "medium").lower()
+ rendered = emit_output(report, args.emit, fun_level=fun_level)
+ if args.save_dir:
+ save_path = save_output(report, args.emit, args.save_dir, suffix=args.save_suffix or "")
+ sys.stderr.write(f"[last30days] Saved output to {save_path}\n")
+ sys.stderr.flush()
+ print(rendered)
+ return 0
if __name__ == "__main__":
- main()
+ raise SystemExit(main())
diff --git a/scripts/lib/bird_x.py b/scripts/lib/bird_x.py
index 56c4686..63a6c44 100644
--- a/scripts/lib/bird_x.py
+++ b/scripts/lib/bird_x.py
@@ -1,7 +1,8 @@
-"""Bird X search client - vendored Twitter GraphQL search for /last30days v2.1.
+"""Bird X search client for the v3.0.0 last30days pipeline.
Uses a vendored subset of @steipete/bird v0.8.0 (MIT License) to search X
-via Twitter's GraphQL API. No external `bird` CLI binary needed - just Node.js 22+.
+via Twitter's GraphQL API. No external `bird` CLI binary needed - just Node.js.
+See scripts/lib/vendor/bird-search/package.json for authoritative version.
"""
import json
@@ -11,11 +12,21 @@ import shutil
import subprocess
import sys
from pathlib import Path
+
+from . import http, log
from datetime import datetime
from typing import Any, Dict, List, Optional, Tuple
from .relevance import token_overlap_relevance as _compute_relevance
+
+def _first_of(*values):
+ """Return first value that is not None."""
+ for v in values:
+ if v is not None:
+ return v
+ return None
+
# Path to the vendored bird-search wrapper
_BIRD_SEARCH_MJS = Path(__file__).parent / "vendor" / "bird-search" / "bird-search.mjs"
@@ -43,21 +54,23 @@ def _has_injected_credentials() -> bool:
return bool(_credentials.get('AUTH_TOKEN') and _credentials.get('CT0'))
+def _has_process_credentials() -> bool:
+ """Return True when AUTH_TOKEN/CT0 are present in process env."""
+ return bool(os.environ.get("AUTH_TOKEN") and os.environ.get("CT0"))
+
+
def _subprocess_env() -> Dict[str, str]:
"""Build env dict for Node subprocesses, merging injected credentials."""
env = os.environ.copy()
env.update(_credentials)
- # When repo config already provides cookies, disable browser-cookie fallback
- # so vendored Bird never hits Safari/Chrome keychain during automation.
- if _has_injected_credentials():
- env.setdefault("BIRD_DISABLE_BROWSER_COOKIES", "1")
+ # Hard-disable browser-cookie fallback so normal pipeline runs never hit
+ # Safari/Chrome Keychain prompts during source detection or search.
+ env["BIRD_DISABLE_BROWSER_COOKIES"] = "1"
return env
def _log(msg: str):
- """Log to stderr."""
- sys.stderr.write(f"[Bird] {msg}\n")
- sys.stderr.flush()
+ log.source_log("Bird", msg, tty_only=False)
def _extract_core_subject(topic: str) -> str:
@@ -75,7 +88,7 @@ def is_bird_installed() -> bool:
"""Check if vendored Bird search module is available.
Returns:
- True if bird-search.mjs exists and Node.js 22+ is in PATH.
+ True if bird-search.mjs exists and Node.js is in PATH.
"""
if not _BIRD_SEARCH_MJS.exists():
return False
@@ -83,7 +96,7 @@ def is_bird_installed() -> bool:
def is_bird_authenticated() -> Optional[str]:
- """Check if X credentials are available (env vars or browser cookies).
+ """Check if explicit X credentials are available.
Returns:
Auth source string if authenticated, None otherwise.
@@ -93,20 +106,9 @@ def is_bird_authenticated() -> Optional[str]:
if _has_injected_credentials():
return "env AUTH_TOKEN"
-
- try:
- result = subprocess.run(
- ["node", str(_BIRD_SEARCH_MJS), "--whoami"],
- capture_output=True,
- text=True,
- timeout=15,
- env=_subprocess_env(),
- )
- if result.returncode == 0 and result.stdout.strip():
- return result.stdout.strip().split('\n')[0]
- return None
- except (subprocess.TimeoutExpired, FileNotFoundError, subprocess.SubprocessError):
- return None
+ if _has_process_credentials():
+ return "env AUTH_TOKEN"
+ return None
def check_npm_available() -> bool:
@@ -119,13 +121,13 @@ def check_npm_available() -> bool:
def install_bird() -> Tuple[bool, str]:
- """No-op - Bird search is vendored in v2.1, no installation needed.
+ """No-op. Bird search is vendored in v3.0.0, no installation needed.
Returns:
Tuple of (success, message).
"""
if is_bird_installed():
- return True, "Bird search is bundled with /last30days v2.1 - no installation needed."
+ return True, "Bird search is bundled with /last30days v3.0.0 - no installation needed."
if not shutil.which("node"):
return False, "Node.js 22+ is required for X search. Install Node.js first."
return False, f"Vendored bird-search.mjs not found at {_BIRD_SEARCH_MJS}"
@@ -144,7 +146,7 @@ def get_bird_status() -> Dict[str, Any]:
"installed": installed,
"authenticated": auth_source is not None,
"username": auth_source, # Now returns auth source (e.g., "Safari", "env AUTH_TOKEN")
- "can_install": True, # Always vendored in v2.1
+ "can_install": True, # Always vendored in v3.0.0
}
@@ -200,7 +202,7 @@ def _run_bird_search(query: str, count: int, timeout: int) -> Dict[str, Any]:
try:
from last30days import unregister_child_pid
unregister_child_pid(proc.pid)
- except (ImportError, Exception):
+ except Exception:
pass
if proc.returncode != 0:
@@ -361,7 +363,7 @@ def search_handles(
except json.JSONDecodeError:
_log(f"Invalid JSON from handle search for @{handle}")
- except Exception as e:
+ except (OSError, subprocess.SubprocessError) as e:
_log(f"Handle search error for @{handle}: {e}")
return all_items
@@ -428,10 +430,10 @@ def parse_bird_response(response: Dict[str, Any], query: str = "") -> List[Dict[
# Build engagement dict (Bird uses camelCase: likeCount, retweetCount, etc.)
engagement = {
- "likes": tweet.get("likeCount") or tweet.get("like_count") or tweet.get("favorite_count"),
- "reposts": tweet.get("retweetCount") or tweet.get("retweet_count"),
- "replies": tweet.get("replyCount") or tweet.get("reply_count"),
- "quotes": tweet.get("quoteCount") or tweet.get("quote_count"),
+ "likes": _first_of(tweet.get("likeCount"), tweet.get("like_count"), tweet.get("favorite_count")),
+ "reposts": _first_of(tweet.get("retweetCount"), tweet.get("retweet_count")),
+ "replies": _first_of(tweet.get("replyCount"), tweet.get("reply_count")),
+ "quotes": _first_of(tweet.get("quoteCount"), tweet.get("quote_count")),
}
# Convert to int where possible
for key in engagement:
@@ -448,7 +450,7 @@ def parse_bird_response(response: Dict[str, Any], query: str = "") -> List[Dict[
"url": url,
"author_handle": author_handle.lstrip("@"),
"date": date,
- "engagement": engagement if any(v is not None for v in engagement.values()) else None,
+ "engagement": engagement,
"why_relevant": "", # Bird doesn't provide relevance explanations
"relevance": _compute_relevance(query, str(tweet.get("text", ""))) if query else 0.7,
}
diff --git a/scripts/lib/bluesky.py b/scripts/lib/bluesky.py
index 3389366..4b65a42 100644
--- a/scripts/lib/bluesky.py
+++ b/scripts/lib/bluesky.py
@@ -10,7 +10,7 @@ import sys
from datetime import datetime, timezone
from typing import Any, Dict, List, Optional
-from . import http
+from . import http, log
BSKY_SESSION_URL = "https://bsky.social/xrpc/com.atproto.server.createSession"
BSKY_SEARCH_URL = "https://public.api.bsky.app/xrpc/app.bsky.feed.searchPosts"
@@ -27,10 +27,7 @@ _session_error: Optional[str] = None
def _log(msg: str):
- """Log to stderr (only in TTY mode to avoid cluttering Claude Code output)."""
- if sys.stderr.isatty():
- sys.stderr.write(f"[Bluesky] {msg}\n")
- sys.stderr.flush()
+ log.source_log("Bluesky", msg)
def _create_session(handle: str, app_password: str) -> Optional[str]:
diff --git a/scripts/lib/brave_search.py b/scripts/lib/brave_search.py
deleted file mode 100644
index 7efd83d..0000000
--- a/scripts/lib/brave_search.py
+++ /dev/null
@@ -1,329 +0,0 @@
-"""Brave Search web search for last30days skill.
-
-Uses the Brave Search API as a web search backend.
-Requires a paid Brave Search subscription.
-
-Two modes:
- - Standard: /res/v1/web/search — returns URLs + snippets (default)
- - LLM Context: /res/v1/llm/context — returns pre-extracted text chunks
- optimized for LLM consumption. Enable with BRAVE_LLM_CONTEXT=1 env var.
-
-API docs: https://api-dashboard.search.brave.com/app/documentation/web-search/get-started
-"""
-
-import html
-import re
-import sys
-from datetime import datetime, timedelta, timezone
-from typing import Any, Dict, List, Optional
-from urllib.parse import urlencode, urlparse
-
-from . import http
-
-ENDPOINT = "https://api.search.brave.com/res/v1/web/search"
-LLM_CONTEXT_ENDPOINT = "https://api.search.brave.com/res/v1/llm/context"
-
-# Freshness codes: pd=24h, pw=7d, pm=31d
-FRESHNESS_MAP = {1: "pd", 7: "pw", 31: "pm"}
-
-# Domains to exclude (handled by Reddit/X search)
-EXCLUDED_DOMAINS = {
- "reddit.com", "www.reddit.com", "old.reddit.com",
- "twitter.com", "www.twitter.com", "x.com", "www.x.com",
-}
-
-
-def search_web(
- topic: str,
- from_date: str,
- to_date: str,
- api_key: str,
- depth: str = "default",
- use_llm_context: bool = False,
-) -> List[Dict[str, Any]]:
- """Search the web via Brave Search API.
-
- Args:
- topic: Search topic
- from_date: Start date (YYYY-MM-DD)
- to_date: End date (YYYY-MM-DD)
- api_key: Brave Search API key
- depth: 'quick', 'default', or 'deep'
- use_llm_context: Use LLM Context endpoint for pre-extracted content
-
- Returns:
- List of result dicts with keys: url, title, snippet, source_domain, date, relevance
-
- Raises:
- http.HTTPError: On API errors
- """
- if use_llm_context:
- return _search_llm_context(topic, from_date, to_date, api_key, depth)
-
- count = {"quick": 8, "default": 15, "deep": 25}.get(depth, 15)
-
- # Calculate days for freshness filter
- days = _days_between(from_date, to_date)
- freshness = _brave_freshness(days)
-
- params = {
- "q": topic,
- "result_filter": "web,news",
- "count": count,
- "safesearch": "strict",
- "text_decorations": 0,
- "spellcheck": 0,
- }
- if freshness:
- params["freshness"] = freshness
-
- url = f"{ENDPOINT}?{urlencode(params)}"
-
- sys.stderr.write(f"[Web] Searching Brave for: {topic}\n")
- sys.stderr.flush()
-
- response = http.request(
- "GET",
- url,
- headers={"X-Subscription-Token": api_key},
- timeout=15,
- )
-
- return _normalize_results(response, from_date, to_date)
-
-
-def _search_llm_context(
- topic: str,
- from_date: str,
- to_date: str,
- api_key: str,
- depth: str = "default",
-) -> List[Dict[str, Any]]:
- """Search via Brave LLM Context endpoint for pre-extracted web content.
-
- Returns results in the same schema as search_web() for downstream compatibility.
- Snippets contain actual page content instead of short descriptions.
- """
- count = {"quick": 5, "default": 20, "deep": 50}.get(depth, 20)
- max_tokens = {"quick": 2048, "default": 8192, "deep": 16384}.get(depth, 8192)
-
- days = _days_between(from_date, to_date)
- freshness = _brave_freshness(days)
-
- params = {
- "q": topic,
- "count": count,
- "maximum_number_of_tokens": max_tokens,
- "context_threshold_mode": "balanced",
- }
- if freshness:
- params["freshness"] = freshness
-
- url = f"{LLM_CONTEXT_ENDPOINT}?{urlencode(params)}"
-
- sys.stderr.write(f"[Web] Searching Brave LLM Context for: {topic}\n")
- sys.stderr.flush()
-
- response = http.request(
- "GET",
- url,
- headers={"X-Subscription-Token": api_key},
- timeout=30,
- )
-
- return _normalize_llm_context(response)
-
-
-def _days_between(from_date: str, to_date: str) -> int:
- """Calculate days between two YYYY-MM-DD dates."""
- try:
- d1 = datetime.strptime(from_date, "%Y-%m-%d")
- d2 = datetime.strptime(to_date, "%Y-%m-%d")
- return max(1, (d2 - d1).days)
- except (ValueError, TypeError):
- return 30
-
-
-def _brave_freshness(days: Optional[int]) -> Optional[str]:
- """Convert days to Brave freshness parameter.
-
- Uses canned codes for <=31d, explicit date range for longer periods.
- """
- if days is None:
- return None
- code = next((v for d, v in sorted(FRESHNESS_MAP.items()) if days <= d), None)
- if code:
- return code
- start = (datetime.now(timezone.utc) - timedelta(days=days)).strftime("%Y-%m-%d")
- end = datetime.now(timezone.utc).strftime("%Y-%m-%d")
- return f"{start}to{end}"
-
-
-def _normalize_results(
- response: Dict[str, Any],
- from_date: str,
- to_date: str,
-) -> List[Dict[str, Any]]:
- """Convert Brave Search response to websearch item schema.
-
- Merges news + web results, cleans HTML entities, filters excluded domains.
- """
- items = []
-
- # Merge news results (tend to be more recent) with web results
- raw_results = (
- response.get("news", {}).get("results", []) +
- response.get("web", {}).get("results", [])
- )
-
- for i, result in enumerate(raw_results):
- if not isinstance(result, dict):
- continue
-
- url = result.get("url", "")
- if not url:
- continue
-
- # Skip excluded domains
- try:
- domain = urlparse(url).netloc.lower()
- if domain in EXCLUDED_DOMAINS:
- continue
- if domain.startswith("www."):
- domain = domain[4:]
- except (ValueError, TypeError):
- domain = ""
-
- title = _clean_html(str(result.get("title", "")).strip())
- snippet = _clean_html(str(result.get("description", "")).strip())
-
- if not title and not snippet:
- continue
-
- # Parse date from Brave's 'age' field or 'page_age'
- date = _parse_brave_date(result.get("age"), result.get("page_age"))
- date_confidence = "med" if date else "low"
-
- items.append({
- "id": f"W{i+1}",
- "title": title[:200],
- "url": url,
- "source_domain": domain,
- "snippet": snippet[:500],
- "date": date,
- "date_confidence": date_confidence,
- "relevance": 0.6, # Brave doesn't provide relevance scores
- "why_relevant": "",
- })
-
- sys.stderr.write(f"[Web] Brave: {len(items)} results\n")
- sys.stderr.flush()
-
- return items
-
-
-def _normalize_llm_context(response: Dict[str, Any]) -> List[Dict[str, Any]]:
- """Convert Brave LLM Context response to websearch item schema.
-
- LLM Context returns grounding.generic[] with url, title, snippets[].
- Sources metadata provides hostname and age for each URL.
- """
- items = []
- grounding = response.get("grounding", {})
- sources = response.get("sources", {})
-
- for i, result in enumerate(grounding.get("generic", [])):
- if not isinstance(result, dict):
- continue
-
- url = result.get("url", "")
- if not url:
- continue
-
- # Skip excluded domains
- try:
- domain = urlparse(url).netloc.lower()
- if domain in EXCLUDED_DOMAINS:
- continue
- if domain.startswith("www."):
- domain = domain[4:]
- except (ValueError, TypeError):
- domain = ""
-
- title = str(result.get("title", "")).strip()
- snippets = result.get("snippets", [])
- snippet = "\n".join(str(s).strip() for s in snippets if s)
-
- if not title and not snippet:
- continue
-
- # Parse date from sources metadata
- source_meta = sources.get(url, {})
- age_list = source_meta.get("age") or []
- date = None
- for age_str in age_list:
- date = _parse_brave_date(age_str, None)
- if date:
- break
- date_confidence = "med" if date else "low"
-
- items.append({
- "id": f"W{i+1}",
- "title": title[:200],
- "url": url,
- "source_domain": source_meta.get("hostname", domain),
- "snippet": snippet[:1500], # LLM Context returns richer content
- "date": date,
- "date_confidence": date_confidence,
- "relevance": 0.7, # LLM Context pre-filters for relevance
- "why_relevant": "",
- })
-
- sys.stderr.write(f"[Web] Brave LLM Context: {len(items)} results\n")
- sys.stderr.flush()
-
- return items
-
-
-def _clean_html(text: str) -> str:
- """Remove HTML tags and decode entities."""
- text = re.sub(r"<[^>]*>", "", text)
- text = html.unescape(text)
- return text
-
-
-def _parse_brave_date(age: Optional[str], page_age: Optional[str]) -> Optional[str]:
- """Parse Brave's age/page_age fields to YYYY-MM-DD.
-
- Brave returns dates like "3 hours ago", "2 days ago", "January 24, 2026".
- """
- text = age or page_age
- if not text:
- return None
-
- text_lower = text.lower().strip()
- now = datetime.now()
-
- # "X hours ago" -> today
- if re.search(r'\d+\s*hours?\s*ago', text_lower):
- return now.strftime("%Y-%m-%d")
-
- # "X days ago"
- match = re.search(r'(\d+)\s*days?\s*ago', text_lower)
- if match:
- days = int(match.group(1))
- if days <= 60:
- return (now - timedelta(days=days)).strftime("%Y-%m-%d")
-
- # "X weeks ago"
- match = re.search(r'(\d+)\s*weeks?\s*ago', text_lower)
- if match:
- weeks = int(match.group(1))
- return (now - timedelta(weeks=weeks)).strftime("%Y-%m-%d")
-
- # ISO format: 2026-01-24T...
- match = re.search(r'(\d{4}-\d{2}-\d{2})', text)
- if match:
- return match.group(1)
-
- return None
diff --git a/scripts/lib/cache.py b/scripts/lib/cache.py
deleted file mode 100644
index 7fad3b3..0000000
--- a/scripts/lib/cache.py
+++ /dev/null
@@ -1,165 +0,0 @@
-"""Caching utilities for last30days skill."""
-
-import hashlib
-import json
-import os
-import tempfile
-from datetime import datetime, timezone
-from pathlib import Path
-from typing import Any, Optional
-
-CACHE_DIR = Path.home() / ".cache" / "last30days"
-DEFAULT_TTL_HOURS = 24
-MODEL_CACHE_TTL_DAYS = 7
-MODEL_CACHE_FILE = CACHE_DIR / "model_selection.json"
-
-
-def ensure_cache_dir():
- """Ensure cache directory exists. Supports env override and sandbox fallback."""
- global CACHE_DIR, MODEL_CACHE_FILE
- env_dir = os.environ.get("LAST30DAYS_CACHE_DIR")
- if env_dir:
- CACHE_DIR = Path(env_dir)
- MODEL_CACHE_FILE = CACHE_DIR / "model_selection.json"
-
- try:
- CACHE_DIR.mkdir(parents=True, exist_ok=True)
- except PermissionError:
- CACHE_DIR = Path(tempfile.gettempdir()) / "last30days" / "cache"
- MODEL_CACHE_FILE = CACHE_DIR / "model_selection.json"
- CACHE_DIR.mkdir(parents=True, exist_ok=True)
-
-
-def get_cache_key(topic: str, from_date: str, to_date: str, sources: str) -> str:
- """Generate a cache key from query parameters."""
- key_data = f"{topic}|{from_date}|{to_date}|{sources}"
- return hashlib.sha256(key_data.encode()).hexdigest()[:16]
-
-
-def get_cache_path(cache_key: str) -> Path:
- """Get path to cache file."""
- return CACHE_DIR / f"{cache_key}.json"
-
-
-def is_cache_valid(cache_path: Path, ttl_hours: int = DEFAULT_TTL_HOURS) -> bool:
- """Check if cache file exists and is within TTL."""
- if not cache_path.exists():
- return False
-
- try:
- stat = cache_path.stat()
- mtime = datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc)
- now = datetime.now(timezone.utc)
- age_hours = (now - mtime).total_seconds() / 3600
- return age_hours < ttl_hours
- except OSError:
- return False
-
-
-def load_cache(cache_key: str, ttl_hours: int = DEFAULT_TTL_HOURS) -> Optional[dict]:
- """Load data from cache if valid."""
- cache_path = get_cache_path(cache_key)
-
- if not is_cache_valid(cache_path, ttl_hours):
- return None
-
- try:
- with open(cache_path, 'r') as f:
- return json.load(f)
- except (json.JSONDecodeError, OSError):
- return None
-
-
-def get_cache_age_hours(cache_path: Path) -> Optional[float]:
- """Get age of cache file in hours."""
- if not cache_path.exists():
- return None
- try:
- stat = cache_path.stat()
- mtime = datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc)
- now = datetime.now(timezone.utc)
- return (now - mtime).total_seconds() / 3600
- except OSError:
- return None
-
-
-def load_cache_with_age(cache_key: str, ttl_hours: int = DEFAULT_TTL_HOURS) -> tuple:
- """Load data from cache with age info.
-
- Returns:
- Tuple of (data, age_hours) or (None, None) if invalid
- """
- cache_path = get_cache_path(cache_key)
-
- if not is_cache_valid(cache_path, ttl_hours):
- return None, None
-
- age = get_cache_age_hours(cache_path)
-
- try:
- with open(cache_path, 'r') as f:
- return json.load(f), age
- except (json.JSONDecodeError, OSError):
- return None, None
-
-
-def save_cache(cache_key: str, data: dict):
- """Save data to cache."""
- ensure_cache_dir()
- cache_path = get_cache_path(cache_key)
-
- try:
- with open(cache_path, 'w') as f:
- json.dump(data, f)
- except OSError:
- pass # Silently fail on cache write errors
-
-
-def clear_cache():
- """Clear all cache files."""
- if CACHE_DIR.exists():
- for f in CACHE_DIR.glob("*.json"):
- try:
- f.unlink()
- except OSError:
- pass
-
-
-# Model selection cache (longer TTL) — MODEL_CACHE_FILE is set at module level
-# and updated by ensure_cache_dir() if env override or fallback is needed.
-
-
-def load_model_cache() -> dict:
- """Load model selection cache."""
- if not is_cache_valid(MODEL_CACHE_FILE, MODEL_CACHE_TTL_DAYS * 24):
- return {}
-
- try:
- with open(MODEL_CACHE_FILE, 'r') as f:
- return json.load(f)
- except (json.JSONDecodeError, OSError):
- return {}
-
-
-def save_model_cache(data: dict):
- """Save model selection cache."""
- ensure_cache_dir()
- try:
- with open(MODEL_CACHE_FILE, 'w') as f:
- json.dump(data, f)
- except OSError:
- pass
-
-
-def get_cached_model(provider: str) -> Optional[str]:
- """Get cached model selection for a provider."""
- cache = load_model_cache()
- return cache.get(provider)
-
-
-def set_cached_model(provider: str, model: str):
- """Cache model selection for a provider."""
- cache = load_model_cache()
- cache[provider] = model
- cache['updated_at'] = datetime.now(timezone.utc).isoformat()
- save_model_cache(cache)
diff --git a/scripts/lib/cluster.py b/scripts/lib/cluster.py
new file mode 100644
index 0000000..60b1043
--- /dev/null
+++ b/scripts/lib/cluster.py
@@ -0,0 +1,255 @@
+"""Candidate clustering and representative selection."""
+
+from __future__ import annotations
+
+import re
+
+from . import dedupe, schema
+
+CLUSTERABLE_INTENTS = {"breaking_news", "opinion", "comparison", "prediction"}
+
+# Words too common to signal shared topic between clusters.
+_ENTITY_STOPWORDS = frozenset({
+ "the", "a", "an", "to", "for", "how", "is", "in", "of", "on", "and",
+ "with", "from", "by", "at", "this", "that", "it", "what", "are", "do",
+ "can", "his", "her", "he", "she", "its", "was", "has", "new", "just",
+ "says", "said", "will", "about", "after", "now", "all", "been", "here",
+ "not", "out", "up", "more", "also", "but", "who", "year", "first",
+ "make", "being", "making", "over", "into", "than", "they", "their",
+ "would", "could", "get", "got", "some", "like", "back", "going",
+ "breaking", "https", "http", "www", "com",
+})
+
+
+def _candidate_text(candidate: schema.Candidate) -> str:
+ return " ".join(part for part in [candidate.title, candidate.snippet] if part).strip()
+
+
+def _extract_entities(text: str) -> set[str]:
+ """Extract significant words (proper nouns, numbers, capitalized words) from text.
+
+ Used for cross-source cluster merging where phrasing differs but entities overlap.
+ """
+ # Normalize but preserve word boundaries
+ words = re.sub(r"[^\w\s]", " ", text).split()
+ entities = set()
+ for word in words:
+ lower = word.lower()
+ if lower in _ENTITY_STOPWORDS or len(word) <= 2:
+ continue
+ # Keep words that are: capitalized, ALL CAPS, contain digits, or 4+ chars
+ if word[0].isupper() or word.isupper() or any(c.isdigit() for c in word) or len(word) >= 4:
+ entities.add(lower)
+ return entities
+
+
+def _entity_overlap(entities_a: set[str], entities_b: set[str]) -> float:
+ """Jaccard-style overlap on extracted entities."""
+ if not entities_a or not entities_b:
+ return 0.0
+ intersection = entities_a & entities_b
+ smaller = min(len(entities_a), len(entities_b))
+ # Use overlap coefficient (intersection / min) instead of Jaccard,
+ # because a short tweet about the same event as a long Reddit post
+ # will have fewer total entities but high overlap with the larger set.
+ return len(intersection) / smaller if smaller > 0 else 0.0
+
+
+def _mmr_representatives(
+ candidates: list[schema.Candidate],
+ limit: int = 3,
+ diversity_lambda: float = 0.75,
+) -> list[str]:
+ selected: list[schema.Candidate] = []
+ remaining = list(candidates)
+ while remaining and len(selected) < limit:
+ if not selected:
+ best = max(remaining, key=lambda candidate: candidate.final_score)
+ selected.append(best)
+ remaining.remove(best)
+ continue
+
+ def score(candidate: schema.Candidate) -> float:
+ diversity_penalty = max(
+ dedupe.hybrid_similarity(_candidate_text(candidate), _candidate_text(existing))
+ for existing in selected
+ )
+ return (diversity_lambda * candidate.final_score) - ((1 - diversity_lambda) * diversity_penalty * 100)
+
+ best = max(remaining, key=score)
+ selected.append(best)
+ remaining.remove(best)
+ return [candidate.candidate_id for candidate in selected]
+
+
+def cluster_candidates(
+ candidates: list[schema.Candidate],
+ plan: schema.QueryPlan,
+) -> list[schema.Cluster]:
+ """Greedy clustering around high-ranked leaders."""
+ if plan.intent not in CLUSTERABLE_INTENTS or plan.cluster_mode == "none":
+ clusters = []
+ for index, candidate in enumerate(candidates, start=1):
+ cluster_id = f"cluster-{index}"
+ candidate.cluster_id = cluster_id
+ clusters.append(
+ schema.Cluster(
+ cluster_id=cluster_id,
+ title=candidate.title,
+ candidate_ids=[candidate.candidate_id],
+ representative_ids=[candidate.candidate_id],
+ sources=sorted(schema.candidate_sources(candidate)),
+ score=candidate.final_score,
+ uncertainty=None,
+ )
+ )
+ return clusters
+
+ groups: list[list[schema.Candidate]] = []
+ # Lower threshold for breaking_news: related articles share fewer exact
+ # words but cover the same event.
+ threshold = 0.42 if plan.intent == "breaking_news" else 0.48
+ for candidate in candidates:
+ assigned = False
+ for group in groups:
+ leader = group[0]
+ similarity = dedupe.hybrid_similarity(_candidate_text(candidate), _candidate_text(leader))
+ if similarity >= threshold:
+ group.append(candidate)
+ assigned = True
+ break
+ if not assigned:
+ groups.append([candidate])
+
+ clusters: list[schema.Cluster] = []
+ for index, group in enumerate(groups, start=1):
+ group.sort(key=lambda candidate: candidate.final_score, reverse=True)
+ cluster_id = f"cluster-{index}"
+ representatives = _mmr_representatives(group)
+ for candidate in group:
+ candidate.cluster_id = cluster_id
+ clusters.append(
+ schema.Cluster(
+ cluster_id=cluster_id,
+ title=group[0].title,
+ candidate_ids=[candidate.candidate_id for candidate in group],
+ representative_ids=representatives,
+ sources=sorted({source for candidate in group for source in schema.candidate_sources(candidate)}),
+ score=max(candidate.final_score for candidate in group),
+ uncertainty=_cluster_uncertainty(group),
+ )
+ )
+
+ # Second pass: merge small clusters that share entities across sources.
+ clusters = _merge_entity_clusters(clusters, candidates)
+
+ return sorted(clusters, key=lambda cluster: cluster.score, reverse=True)
+
+
+def _merge_entity_clusters(
+ clusters: list[schema.Cluster],
+ all_candidates: list[schema.Candidate],
+) -> list[schema.Cluster]:
+ """Merge small clusters that cover the same story across different sources.
+
+ The initial greedy pass uses text similarity which misses cross-source
+ matches where phrasing differs. This second pass looks at entity overlap
+ (proper nouns, names, numbers) to catch cases like:
+ - Reddit: "Kanye West to headline all three nights of Wireless Festival 2026"
+ - X: "BREAKING: Kanye West (Ye) is making his massive UK comeback!"
+ """
+ if len(clusters) < 2:
+ return clusters
+
+ candidate_map = {c.candidate_id: c for c in all_candidates}
+
+ # Build entity sets per cluster
+ cluster_entities: list[set[str]] = []
+ for cl in clusters:
+ entities: set[str] = set()
+ for cid in cl.candidate_ids:
+ cand = candidate_map.get(cid)
+ if cand:
+ entities |= _extract_entities(_candidate_text(cand))
+ cluster_entities.append(entities)
+
+ # Only merge clusters with <= 3 items (don't merge already-large clusters)
+ merged_into: dict[int, int] = {} # index -> merge target index
+ for i in range(len(clusters)):
+ if i in merged_into or len(clusters[i].candidate_ids) > 3:
+ continue
+ for j in range(i + 1, len(clusters)):
+ if j in merged_into or len(clusters[j].candidate_ids) > 3:
+ continue
+ # Require different sources to merge (same-source should already be grouped)
+ sources_i = set(clusters[i].sources)
+ sources_j = set(clusters[j].sources)
+ if sources_i == sources_j and len(sources_i) == 1:
+ continue
+ # Prevent Polymarket clusters from merging with non-Polymarket
+ # clusters. Prediction markets about "Sam Altman equity" should not
+ # merge into a news cluster about "Sam Altman rivalry" just because
+ # both mention the same entity.
+ poly_i = "polymarket" in sources_i
+ poly_j = "polymarket" in sources_j
+ if poly_i != poly_j:
+ continue
+
+ overlap = _entity_overlap(cluster_entities[i], cluster_entities[j])
+ if overlap >= 0.45:
+ merged_into[j] = i
+
+ if not merged_into:
+ return clusters
+
+ # Build merged cluster list
+ result: list[schema.Cluster] = []
+ for i, cl in enumerate(clusters):
+ if i in merged_into:
+ continue
+ # Collect all clusters merged into this one
+ merge_sources = [i] + [j for j, target in merged_into.items() if target == i]
+ if len(merge_sources) == 1:
+ result.append(cl)
+ continue
+
+ # Combine candidates from all merged clusters
+ combined_cids: list[str] = []
+ combined_sources: set[str] = set()
+ best_score = 0.0
+ for idx in merge_sources:
+ combined_cids.extend(clusters[idx].candidate_ids)
+ combined_sources.update(clusters[idx].sources)
+ best_score = max(best_score, clusters[idx].score)
+
+ # Pick representatives from combined pool
+ combined_candidates = [candidate_map[cid] for cid in combined_cids if cid in candidate_map]
+ combined_candidates.sort(key=lambda c: c.final_score, reverse=True)
+ reps = _mmr_representatives(combined_candidates)
+
+ cluster_id = cl.cluster_id
+ for cid in combined_cids:
+ cand = candidate_map.get(cid)
+ if cand:
+ cand.cluster_id = cluster_id
+
+ result.append(schema.Cluster(
+ cluster_id=cluster_id,
+ title=combined_candidates[0].title if combined_candidates else cl.title,
+ candidate_ids=combined_cids,
+ representative_ids=reps,
+ sources=sorted(combined_sources),
+ score=best_score,
+ uncertainty=_cluster_uncertainty(combined_candidates),
+ ))
+
+ return result
+
+
+def _cluster_uncertainty(group: list[schema.Candidate]) -> str | None:
+ sources = {source for candidate in group for source in schema.candidate_sources(candidate)}
+ if len(sources) == 1:
+ return "single-source"
+ if max(candidate.final_score for candidate in group) < 55:
+ return "thin-evidence"
+ return None
diff --git a/scripts/lib/dates.py b/scripts/lib/dates.py
index fd6c2d7..164f395 100644
--- a/scripts/lib/dates.py
+++ b/scripts/lib/dates.py
@@ -41,7 +41,10 @@ def parse_date(date_str: Optional[str]) -> Optional[datetime]:
for fmt in formats:
try:
- return datetime.strptime(date_str, fmt).replace(tzinfo=timezone.utc)
+ dt = datetime.strptime(date_str, fmt)
+ if dt.tzinfo is not None:
+ return dt.astimezone(timezone.utc)
+ return dt.replace(tzinfo=timezone.utc)
except ValueError:
continue
@@ -78,14 +81,7 @@ def get_date_confidence(date_str: Optional[str], from_date: str, to_date: str) -
start = datetime.strptime(from_date, "%Y-%m-%d").date()
end = datetime.strptime(to_date, "%Y-%m-%d").date()
- if start <= dt <= end:
- return 'high'
- elif dt < start:
- # Older than range
- return 'low'
- else:
- # Future date (suspicious)
- return 'low'
+ return 'high' if start <= dt <= end else 'low'
except ValueError:
return 'low'
diff --git a/scripts/lib/dedupe.py b/scripts/lib/dedupe.py
index ddead58..de95bf9 100644
--- a/scripts/lib/dedupe.py
+++ b/scripts/lib/dedupe.py
@@ -1,290 +1,99 @@
-"""Near-duplicate detection for last30days skill."""
+"""Within-source near-duplicate detection."""
+
+from __future__ import annotations
import re
-from typing import List, Set, Tuple, Union
from . import schema
-# Stopwords for token-based Jaccard (cross-source linking)
-STOPWORDS = frozenset({
- 'the', 'a', 'an', 'to', 'for', 'how', 'is', 'in', 'of', 'on',
- 'and', 'with', 'from', 'by', 'at', 'this', 'that', 'it', 'my',
- 'your', 'i', 'me', 'we', 'you', 'what', 'are', 'do', 'can',
- 'its', 'be', 'or', 'not', 'no', 'so', 'if', 'but', 'about',
- 'all', 'just', 'get', 'has', 'have', 'was', 'will', 'show', 'hn',
-})
+STOPWORDS = frozenset(
+ {
+ "the",
+ "a",
+ "an",
+ "to",
+ "for",
+ "how",
+ "is",
+ "in",
+ "of",
+ "on",
+ "and",
+ "with",
+ "from",
+ "by",
+ "at",
+ "this",
+ "that",
+ "it",
+ "what",
+ "are",
+ "do",
+ "can",
+ }
+)
def normalize_text(text: str) -> str:
- """Normalize text for comparison.
-
- - Lowercase
- - Remove punctuation
- - Collapse whitespace
- """
- text = text.lower()
- text = re.sub(r'[^\w\s]', ' ', text)
- text = re.sub(r'\s+', ' ', text)
- return text.strip()
+ text = re.sub(r"[^\w\s]", " ", text.lower())
+ return re.sub(r"\s+", " ", text).strip()
-def get_ngrams(text: str, n: int = 3) -> Set[str]:
- """Get character n-grams from text."""
+def get_ngrams(text: str, n: int = 3) -> set[str]:
text = normalize_text(text)
if len(text) < n:
- return {text}
- return {text[i:i+n] for i in range(len(text) - n + 1)}
+ return {text} if text else set()
+ return {text[index:index + n] for index in range(len(text) - n + 1)}
-def jaccard_similarity(set1: Set[str], set2: Set[str]) -> float:
- """Compute Jaccard similarity between two sets."""
- if not set1 or not set2:
+def jaccard_similarity(left: set[str], right: set[str]) -> float:
+ if not left or not right:
return 0.0
- intersection = len(set1 & set2)
- union = len(set1 | set2)
- return intersection / union if union > 0 else 0.0
-
-
-AnyItem = Union[schema.RedditItem, schema.XItem, schema.YouTubeItem, schema.TikTokItem,
- schema.InstagramItem, schema.HackerNewsItem, schema.PolymarketItem, schema.WebSearchItem]
-
-
-def get_item_text(item: AnyItem) -> str:
- """Get comparable text from an item."""
- if isinstance(item, schema.RedditItem):
- return item.title
- elif isinstance(item, schema.HackerNewsItem):
- return item.title
- elif isinstance(item, schema.YouTubeItem):
- return f"{item.title} {item.channel_name}"
- elif isinstance(item, schema.TikTokItem):
- return f"{item.text} {item.author_name}"
- elif isinstance(item, schema.InstagramItem):
- return f"{item.text} {item.author_name}"
- elif isinstance(item, schema.PolymarketItem):
- return f"{item.title} {item.question}"
- elif isinstance(item, schema.WebSearchItem):
- return item.title
- else:
- return item.text
-
-
-def _get_cross_source_text(item: AnyItem) -> str:
- """Get text for cross-source comparison.
-
- Same as get_item_text() but truncates X posts to 100 chars
- to level the playing field against short Reddit/HN titles.
- Strips 'Show HN:' prefix from HN titles for fairer matching.
- """
- if isinstance(item, schema.XItem):
- return item.text[:100]
- if isinstance(item, schema.TikTokItem):
- return item.text[:100]
- if isinstance(item, schema.InstagramItem):
- return item.text[:100]
- if isinstance(item, schema.HackerNewsItem):
- title = item.title
- if title.startswith("Show HN:"):
- title = title[8:].strip()
- elif title.startswith("Ask HN:"):
- title = title[7:].strip()
- return title
- if isinstance(item, schema.PolymarketItem):
- return item.title
- return get_item_text(item)
-
-
-def _tokenize_for_xref(text: str) -> Set[str]:
- """Tokenize text for cross-source token Jaccard comparison."""
- words = re.sub(r'[^\w\s]', ' ', text.lower()).split()
- return {w for w in words if w not in STOPWORDS and len(w) > 1}
-
-
-def _token_jaccard(text_a: str, text_b: str) -> float:
- """Token-level Jaccard similarity (word overlap)."""
- tokens_a = _tokenize_for_xref(text_a)
- tokens_b = _tokenize_for_xref(text_b)
- if not tokens_a or not tokens_b:
+ union = left | right
+ if not union:
return 0.0
- intersection = len(tokens_a & tokens_b)
- union = len(tokens_a | tokens_b)
- return intersection / union if union else 0.0
+ return len(left & right) / len(union)
-def _hybrid_similarity(text_a: str, text_b: str) -> float:
- """Hybrid similarity: max of char-trigram Jaccard and token Jaccard."""
- trigram_sim = jaccard_similarity(get_ngrams(text_a), get_ngrams(text_b))
- token_sim = _token_jaccard(text_a, text_b)
- return max(trigram_sim, token_sim)
+def token_jaccard(text_a: str, text_b: str) -> float:
+ tokens_a = {
+ token
+ for token in normalize_text(text_a).split()
+ if len(token) > 1 and token not in STOPWORDS
+ }
+ tokens_b = {
+ token
+ for token in normalize_text(text_b).split()
+ if len(token) > 1 and token not in STOPWORDS
+ }
+ return jaccard_similarity(tokens_a, tokens_b)
-def find_duplicates(
- items: List[Union[schema.RedditItem, schema.XItem]],
- threshold: float = 0.7,
-) -> List[Tuple[int, int]]:
- """Find near-duplicate pairs in items.
-
- Args:
- items: List of items to check
- threshold: Similarity threshold (0-1)
-
- Returns:
- List of (i, j) index pairs where i < j and items are similar
- """
- duplicates = []
-
- # Pre-compute n-grams
- ngrams = [get_ngrams(get_item_text(item)) for item in items]
-
- for i in range(len(items)):
- for j in range(i + 1, len(items)):
- similarity = jaccard_similarity(ngrams[i], ngrams[j])
- if similarity >= threshold:
- duplicates.append((i, j))
-
- return duplicates
+def hybrid_similarity(text_a: str, text_b: str) -> float:
+ return max(
+ jaccard_similarity(get_ngrams(text_a), get_ngrams(text_b)),
+ token_jaccard(text_a, text_b),
+ )
-def dedupe_items(
- items: List[Union[schema.RedditItem, schema.XItem]],
- threshold: float = 0.7,
-) -> List[Union[schema.RedditItem, schema.XItem]]:
- """Remove near-duplicates, keeping highest-scored item.
-
- Args:
- items: List of items (should be pre-sorted by score descending)
- threshold: Similarity threshold
-
- Returns:
- Deduplicated items
- """
- if len(items) <= 1:
- return items
-
- # Find duplicate pairs
- dup_pairs = find_duplicates(items, threshold)
-
- # Mark indices to remove (always remove the lower-scored one)
- # Since items are pre-sorted by score, the second index is always lower
- to_remove = set()
- for i, j in dup_pairs:
- # Keep the higher-scored one (lower index in sorted list)
- if items[i].score >= items[j].score:
- to_remove.add(j)
- else:
- to_remove.add(i)
-
- # Return items not marked for removal
- return [item for idx, item in enumerate(items) if idx not in to_remove]
+def item_text(item: schema.SourceItem) -> str:
+ parts = [item.title, item.body, item.author or "", item.container or ""]
+ return " ".join(part for part in parts if part).strip()
-def dedupe_reddit(
- items: List[schema.RedditItem],
- threshold: float = 0.7,
-) -> List[schema.RedditItem]:
- """Dedupe Reddit items."""
- return dedupe_items(items, threshold)
-
-
-def dedupe_x(
- items: List[schema.XItem],
- threshold: float = 0.7,
-) -> List[schema.XItem]:
- """Dedupe X items."""
- return dedupe_items(items, threshold)
-
-
-def dedupe_youtube(
- items: List[schema.YouTubeItem],
- threshold: float = 0.7,
-) -> List[schema.YouTubeItem]:
- """Dedupe YouTube items."""
- return dedupe_items(items, threshold)
-
-
-def dedupe_tiktok(
- items: List[schema.TikTokItem],
- threshold: float = 0.7,
-) -> List[schema.TikTokItem]:
- """Dedupe TikTok items."""
- return dedupe_items(items, threshold)
-
-
-def dedupe_instagram(
- items: List[schema.InstagramItem],
- threshold: float = 0.7,
-) -> List[schema.InstagramItem]:
- """Dedupe Instagram items."""
- return dedupe_items(items, threshold)
-
-
-def dedupe_hackernews(
- items: List[schema.HackerNewsItem],
- threshold: float = 0.7,
-) -> List[schema.HackerNewsItem]:
- """Dedupe Hacker News items."""
- return dedupe_items(items, threshold)
-
-
-def dedupe_bluesky(
- items: List[schema.BlueskyItem],
- threshold: float = 0.7,
-) -> List[schema.BlueskyItem]:
- """Dedupe Bluesky items."""
- return dedupe_items(items, threshold)
-
-
-def dedupe_truthsocial(
- items: List[schema.TruthSocialItem],
- threshold: float = 0.7,
-) -> List[schema.TruthSocialItem]:
- """Dedupe Truth Social items."""
- return dedupe_items(items, threshold)
-
-
-def dedupe_polymarket(
- items: List[schema.PolymarketItem],
- threshold: float = 0.7,
-) -> List[schema.PolymarketItem]:
- """Dedupe Polymarket items."""
- return dedupe_items(items, threshold)
-
-
-def cross_source_link(
- *source_lists: List[AnyItem],
- threshold: float = 0.40,
-) -> None:
- """Annotate items with cross-source references.
-
- Compares items across different source types using hybrid similarity
- (max of char-trigram Jaccard and token Jaccard). When similarity exceeds
- threshold, adds bidirectional cross_refs with the related item's ID.
- Modifies items in-place.
-
- Args:
- *source_lists: Variable number of per-source item lists
- threshold: Similarity threshold for cross-linking (default 0.40)
- """
- all_items = []
- for source_list in source_lists:
- all_items.extend(source_list)
-
- if len(all_items) <= 1:
- return
-
- # Pre-compute cross-source text for each item
- texts = [_get_cross_source_text(item) for item in all_items]
-
- for i in range(len(all_items)):
- for j in range(i + 1, len(all_items)):
- # Skip same-source comparisons (handled by per-source dedupe)
- if type(all_items[i]) is type(all_items[j]):
- continue
-
- similarity = _hybrid_similarity(texts[i], texts[j])
- if similarity >= threshold:
- # Bidirectional cross-reference
- if all_items[j].id not in all_items[i].cross_refs:
- all_items[i].cross_refs.append(all_items[j].id)
- if all_items[i].id not in all_items[j].cross_refs:
- all_items[j].cross_refs.append(all_items[i].id)
+def dedupe_items(items: list[schema.SourceItem], threshold: float = 0.7) -> list[schema.SourceItem]:
+ """Remove near-duplicates while keeping earlier, better-scored items."""
+ kept: list[schema.SourceItem] = []
+ for item in items:
+ text = item_text(item)
+ if not text:
+ kept.append(item)
+ continue
+ is_duplicate = False
+ for existing in kept:
+ if hybrid_similarity(text, item_text(existing)) >= threshold:
+ is_duplicate = True
+ break
+ if not is_duplicate:
+ kept.append(item)
+ return kept
diff --git a/scripts/lib/entity_extract.py b/scripts/lib/entity_extract.py
index 2ab6531..42e271e 100644
--- a/scripts/lib/entity_extract.py
+++ b/scripts/lib/entity_extract.py
@@ -1,4 +1,4 @@
-"""Entity extraction from Phase 1 search results for supplemental searches."""
+"""Entity extraction from initial search results for supplemental searches."""
import re
from collections import Counter
diff --git a/scripts/lib/env.py b/scripts/lib/env.py
index c678278..a5af3ec 100644
--- a/scripts/lib/env.py
+++ b/scripts/lib/env.py
@@ -1,39 +1,16 @@
"""Environment and API key management for last30days skill."""
+from __future__ import annotations
+
import base64
+import binascii
import json
-import logging
import os
+import sys
import time
from dataclasses import dataclass
from pathlib import Path
-from typing import Optional, Dict, Any, List, Literal
-
-logger = logging.getLogger(__name__)
-
-# ---------------------------------------------------------------------------
-# Cookie domain registry: maps source names to browser cookie extraction params.
-# Each entry: (domain, cookie_names, config_key_mapping)
-# config_key_mapping: {cookie_name: config_key} so we know which config key
-# each extracted cookie should populate.
-# ---------------------------------------------------------------------------
-COOKIE_DOMAINS: Dict[str, Dict[str, Any]] = {
- "x": {
- "domain": ".x.com",
- "cookies": ["auth_token", "ct0"],
- "mapping": {
- "auth_token": "AUTH_TOKEN",
- "ct0": "CT0",
- },
- },
- "truthsocial": {
- "domain": ".truthsocial.com",
- "cookies": ["_session_id"],
- "mapping": {
- "_session_id": "TRUTHSOCIAL_TOKEN",
- },
- },
-}
+from typing import Any, Literal
# Allow override via environment variable for testing
# Set LAST30DAYS_CONFIG_DIR="" for clean/no-config mode
@@ -67,10 +44,10 @@ AUTH_STATUS_MISSING_ACCOUNT_ID: AuthStatus = "missing_account_id"
@dataclass(frozen=True)
class OpenAIAuth:
- token: Optional[str]
+ token: str | None
source: AuthSource
status: AuthStatus
- account_id: Optional[str]
+ account_id: str | None
codex_auth_file: str
@@ -80,17 +57,17 @@ def _check_file_permissions(path: Path) -> None:
mode = path.stat().st_mode
# Check if group or other can read (bits 0o044)
if mode & 0o044:
- import sys
sys.stderr.write(
f"[last30days] WARNING: {path} is readable by other users. "
f"Run: chmod 600 {path}\n"
)
sys.stderr.flush()
- except OSError:
- pass
+ except OSError as exc:
+ sys.stderr.write(f"[last30days] WARNING: could not stat {path}: {exc}\n")
+ sys.stderr.flush()
-def load_env_file(path: Path) -> Dict[str, str]:
+def load_env_file(path: Path) -> dict[str, str]:
"""Load environment variables from a file."""
env = {}
if not path or not path.exists():
@@ -114,7 +91,7 @@ def load_env_file(path: Path) -> Dict[str, str]:
return env
-def _decode_jwt_payload(token: str) -> Optional[Dict[str, Any]]:
+def _decode_jwt_payload(token: str) -> dict[str, Any] | None:
"""Decode JWT payload without verification."""
try:
parts = token.split(".")
@@ -124,7 +101,9 @@ def _decode_jwt_payload(token: str) -> Optional[Dict[str, Any]]:
pad = "=" * (-len(payload_b64) % 4)
decoded = base64.urlsafe_b64decode(payload_b64 + pad)
return json.loads(decoded.decode("utf-8"))
- except Exception:
+ except (json.JSONDecodeError, UnicodeDecodeError, binascii.Error, IndexError) as exc:
+ sys.stderr.write(f"[last30days] WARNING: malformed JWT token: {exc}\n")
+ sys.stderr.flush()
return None
@@ -139,7 +118,7 @@ def _token_expired(token: str, leeway_seconds: int = 60) -> bool:
return exp <= (time.time() + leeway_seconds)
-def extract_chatgpt_account_id(access_token: str) -> Optional[str]:
+def extract_chatgpt_account_id(access_token: str) -> str | None:
"""Extract chatgpt_account_id from JWT token."""
payload = _decode_jwt_payload(access_token)
if not payload:
@@ -150,18 +129,22 @@ def extract_chatgpt_account_id(access_token: str) -> Optional[str]:
return None
-def load_codex_auth(path: Path = CODEX_AUTH_FILE) -> Dict[str, Any]:
+def load_codex_auth(path: Path = CODEX_AUTH_FILE) -> dict[str, Any]:
"""Load Codex auth JSON."""
if not path.exists():
return {}
try:
with open(path, "r") as f:
return json.load(f)
- except Exception:
+ except json.JSONDecodeError:
+ sys.stderr.write(
+ f"[last30days] WARNING: {path} exists but contains invalid JSON -- ignoring\n"
+ )
+ sys.stderr.flush()
return {}
-def get_codex_access_token() -> tuple[Optional[str], str]:
+def get_codex_access_token() -> tuple[str | None, str]:
"""Get Codex access token from auth.json.
Returns:
@@ -182,7 +165,7 @@ def get_codex_access_token() -> tuple[Optional[str], str]:
return token, AUTH_STATUS_OK
-def get_openai_auth(file_env: Dict[str, str]) -> OpenAIAuth:
+def get_openai_auth(file_env: dict[str, str]) -> OpenAIAuth:
"""Resolve OpenAI auth from API key or Codex login."""
api_key = os.environ.get('OPENAI_API_KEY') or file_env.get('OPENAI_API_KEY')
if api_key:
@@ -194,35 +177,20 @@ def get_openai_auth(file_env: Dict[str, str]) -> OpenAIAuth:
codex_auth_file=str(CODEX_AUTH_FILE),
)
- codex_token, codex_status = get_codex_access_token()
- if codex_token:
- account_id = extract_chatgpt_account_id(codex_token)
- if account_id:
- return OpenAIAuth(
- token=codex_token,
- source=AUTH_SOURCE_CODEX,
- status=AUTH_STATUS_OK,
- account_id=account_id,
- codex_auth_file=str(CODEX_AUTH_FILE),
- )
- return OpenAIAuth(
- token=None,
- source=AUTH_SOURCE_CODEX,
- status=AUTH_STATUS_MISSING_ACCOUNT_ID,
- account_id=None,
- codex_auth_file=str(CODEX_AUTH_FILE),
- )
+ # Codex auth (chatgpt.com backend) intentionally skipped.
+ # The endpoint is unstable and causes crashes when the token expires.
+ # Users who want OpenAI should set OPENAI_API_KEY explicitly.
return OpenAIAuth(
token=None,
source=AUTH_SOURCE_NONE,
- status=codex_status,
+ status=AUTH_STATUS_MISSING,
account_id=None,
codex_auth_file=str(CODEX_AUTH_FILE),
)
-def _find_project_env() -> Optional[Path]:
+def _find_project_env() -> Path | None:
"""Find per-project .env by walking up from cwd.
Searches for .claude/last30days.env in each parent directory,
@@ -239,95 +207,13 @@ def _find_project_env() -> Optional[Path]:
return None
-def extract_browser_credentials(config: Dict[str, Any]) -> Dict[str, str]:
- """Extract credentials from browser cookies for sources that need them.
-
- Checks the FROM_BROWSER config key to decide whether/how to extract:
- - 'auto': try browsers in platform order
- - 'firefox', 'chrome', 'safari': try only that browser
- - 'off': skip extraction entirely
-
- If SETUP_COMPLETE is not set AND FROM_BROWSER is not explicitly set,
- defaults to 'off' (wizard hasn't run yet — no extraction without consent).
- If SETUP_COMPLETE is set and FROM_BROWSER is not set, defaults to 'auto'.
-
- Explicit env var/config values always take priority over extracted cookies.
-
- Returns:
- Dict of {config_key: value} for credentials discovered from cookies.
- """
- setup_complete = config.get("SETUP_COMPLETE")
- from_browser = config.get("FROM_BROWSER")
-
- # Determine effective browser setting
- if from_browser is None:
- if setup_complete:
- from_browser = "auto"
- else:
- from_browser = "off"
-
- from_browser = from_browser.lower().strip() if isinstance(from_browser, str) else "off"
-
- if from_browser == "off":
- return {}
-
- # Lazy import to avoid loading cookie_extract at module level
- try:
- from . import cookie_extract
- except Exception:
- logger.debug("cookie_extract module not available")
- return {}
-
- credentials: Dict[str, str] = {}
-
- for source_name, spec in COOKIE_DOMAINS.items():
- domain = spec["domain"]
- cookie_names: List[str] = spec["cookies"]
- mapping: Dict[str, str] = spec["mapping"]
-
- # Skip if ALL mapped config keys already have values
- all_present = all(config.get(config_key) for config_key in mapping.values())
- if all_present:
- logger.debug(
- "Skipping cookie extraction for %s: credentials already set",
- source_name,
- )
- continue
-
- try:
- result = cookie_extract.extract_cookies_with_source(from_browser, domain, cookie_names)
- except Exception as exc:
- logger.debug(
- "Cookie extraction failed for %s: %s", source_name, exc
- )
- continue
-
- if result is None:
- continue
-
- cookies, browser_name = result
- filled_any = False
- for cookie_name, config_key in mapping.items():
- # Only fill in keys not already present
- if not config.get(config_key) and cookie_name in cookies:
- credentials[config_key] = cookies[cookie_name]
- filled_any = True
-
- # Track which browser provided the credentials for this source
- if filled_any:
- credentials[f"__{source_name.upper()}_BROWSER"] = browser_name
-
- return credentials
-
-
-def get_config() -> Dict[str, Any]:
+def get_config() -> dict[str, Any]:
"""Load configuration from multiple sources.
Priority (highest wins):
1. Environment variables (os.environ)
2. .claude/last30days.env (per-project config)
3. ~/.config/last30days/.env (global config)
- 4. Browser cookies (only fills in missing keys)
"""
# Load from global config file
file_env = load_env_file(CONFIG_FILE) if CONFIG_FILE else {}
@@ -355,15 +241,13 @@ def get_config() -> Dict[str, Any]:
('GOOGLE_API_KEY', None),
('GEMINI_API_KEY', None),
('GOOGLE_GENAI_API_KEY', None),
- ('OPENROUTER_API_KEY', None),
- ('PARALLEL_API_KEY', None),
- ('BRAVE_API_KEY', None),
- ('EXA_API_KEY', None),
('XIAOHONGSHU_API_BASE', None),
- ('GEMINI_MODEL', None),
- ('OPENAI_MODEL_POLICY', 'auto'),
+ ('LAST30DAYS_REASONING_PROVIDER', 'auto'),
+ ('LAST30DAYS_PLANNER_MODEL', None),
+ ('LAST30DAYS_RERANK_MODEL', None),
+ ('LAST30DAYS_X_MODEL', None),
+ ('LAST30DAYS_X_BACKEND', None),
('OPENAI_MODEL_PIN', None),
- ('XAI_MODEL_POLICY', 'latest'),
('XAI_MODEL_PIN', None),
('SCRAPECREATORS_API_KEY', None),
('APIFY_API_TOKEN', None),
@@ -372,6 +256,11 @@ def get_config() -> Dict[str, Any]:
('BSKY_HANDLE', None),
('BSKY_APP_PASSWORD', None),
('TRUTHSOCIAL_TOKEN', None),
+ ('BRAVE_API_KEY', None),
+ ('EXA_API_KEY', None),
+ ('SERPER_API_KEY', None),
+ ('OPENROUTER_API_KEY', None),
+ ('PARALLEL_API_KEY', None),
('FROM_BROWSER', None),
('SETUP_COMPLETE', None),
('INCLUDE_SOURCES', None),
@@ -380,24 +269,6 @@ def get_config() -> Dict[str, Any]:
for key, default in keys:
config[key] = os.environ.get(key) or merged_env.get(key, default)
- # Inject browser cookies for any credentials not already set
- browser_creds = extract_browser_credentials(config)
- for key, value in browser_creds.items():
- if not config.get(key):
- config[key] = value
-
- # Track AUTH_TOKEN source for status reporting
- if config.get('AUTH_TOKEN'):
- if os.environ.get('AUTH_TOKEN') or merged_env.get('AUTH_TOKEN'):
- config['_AUTH_TOKEN_SOURCE'] = 'env'
- elif browser_creds.get('AUTH_TOKEN'):
- browser_name = browser_creds.get('__X_BROWSER', 'unknown')
- config['_AUTH_TOKEN_SOURCE'] = f'browser-{browser_name}'
- else:
- config['_AUTH_TOKEN_SOURCE'] = 'env' # fallback
- else:
- config['_AUTH_TOKEN_SOURCE'] = None
-
# Track which config source was used
if project_env_path:
config['_CONFIG_SOURCE'] = f'project:{project_env_path}'
@@ -406,9 +277,87 @@ def get_config() -> Dict[str, Any]:
else:
config['_CONFIG_SOURCE'] = 'env_only'
+ # Extract browser credentials if configured
+ browser_creds = extract_browser_credentials(config)
+ for key, value in browser_creds.items():
+ if not config.get(key):
+ config[key] = value
+ config[f"_{key}_SOURCE"] = "browser"
+
return config
+# ---------------------------------------------------------------------------
+# Browser cookie extraction
+# ---------------------------------------------------------------------------
+
+COOKIE_DOMAINS: dict[str, dict[str, Any]] = {
+ "x": {
+ "domain": ".x.com",
+ "cookies": ["auth_token", "ct0"],
+ "mapping": {"auth_token": "AUTH_TOKEN", "ct0": "CT0"},
+ },
+ "truthsocial": {
+ "domain": ".truthsocial.com",
+ "cookies": ["_session_id"],
+ "mapping": {"_session_id": "TRUTHSOCIAL_TOKEN"},
+ },
+}
+
+
+def extract_browser_credentials(config: dict[str, Any]) -> dict[str, str]:
+ """Extract auth cookies from local browsers.
+
+ Default behavior (FROM_BROWSER unset): tries Firefox and Safari only.
+ These read local files silently with no system dialogs. Chrome is
+ skipped because ``security find-generic-password`` triggers a macOS
+ Keychain prompt that cannot be reliably suppressed.
+
+ Set ``FROM_BROWSER=auto`` to also try Chrome (accepts the dialog),
+ or ``FROM_BROWSER=off`` to disable extraction entirely.
+ """
+ from_browser = (config.get("FROM_BROWSER") or "").strip().lower()
+ if from_browser == "off":
+ return {}
+ try:
+ from . import cookie_extract
+ except ImportError:
+ return {}
+ # Determine which browsers to try
+ if from_browser in ("firefox", "chrome", "safari"):
+ browsers = [from_browser]
+ elif from_browser == "auto":
+ browsers = ["firefox", "safari", "chrome"]
+ else:
+ # Default: silent browsers only (no Keychain dialog)
+ browsers = ["firefox", "safari"]
+ extracted: dict[str, str] = {}
+ for _service, spec in COOKIE_DOMAINS.items():
+ if all(config.get(env_key) for env_key in spec["mapping"].values()):
+ continue
+ for browser in browsers:
+ try:
+ cookies = cookie_extract.extract_cookies(browser, spec["domain"], spec["cookies"])
+ except Exception:
+ continue
+ if cookies:
+ for cookie_name, env_key in spec["mapping"].items():
+ if cookie_name in cookies and not config.get(env_key):
+ extracted[env_key] = cookies[cookie_name]
+ break # Found cookies for this service, stop trying browsers
+ return extracted
+
+
+def get_x_source_with_method(config: dict[str, Any]) -> tuple[str | None, str]:
+ """Return (source, method) for X search, where method describes the auth origin."""
+ if config.get("XAI_API_KEY"):
+ return "xai", "xai"
+ if config.get("AUTH_TOKEN") and config.get("CT0"):
+ method = config.get("_AUTH_TOKEN_SOURCE", "env")
+ return "bird", method
+ return None, "none"
+
+
def config_exists() -> bool:
"""Check if any configuration source exists."""
if _find_project_env():
@@ -418,235 +367,60 @@ def config_exists() -> bool:
return False
-def is_reddit_available(config: Dict[str, Any]) -> bool:
+def is_reddit_available(config: dict[str, Any]) -> bool:
"""Check if Reddit search is available.
- Reddit can use either ScrapeCreators (preferred) or OpenAI.
+ v3 uses ScrapeCreators only.
"""
- has_sc = bool(config.get('SCRAPECREATORS_API_KEY'))
- has_openai = bool(config.get('OPENAI_API_KEY')) and config.get('OPENAI_AUTH_STATUS') == AUTH_STATUS_OK
- return has_sc or has_openai
+ return bool(config.get('SCRAPECREATORS_API_KEY'))
-def get_reddit_source(config: Dict[str, Any]) -> Optional[str]:
+def get_reddit_source(config: dict[str, Any]) -> str | None:
"""Determine which Reddit backend to use.
- Priority: ScrapeCreators (cheaper, faster) > OpenAI (legacy)
-
- Returns: 'scrapecreators', 'openai', or None
+ Returns: 'scrapecreators' or None
"""
if config.get('SCRAPECREATORS_API_KEY'):
return 'scrapecreators'
- if config.get('OPENAI_API_KEY') and config.get('OPENAI_AUTH_STATUS') == AUTH_STATUS_OK:
- return 'openai'
return None
-def get_available_sources(config: Dict[str, Any]) -> str:
- """Determine which sources are available.
+def get_x_source(config: dict[str, Any]) -> str | None:
+ """Determine the best available explicit X/Twitter source.
- X is available if ANY auth method works: AUTH_TOKEN/CT0 (env or cookies),
- XAI_API_KEY, or Bird installed+authenticated.
- Reddit is always available (public JSON fallback).
- HN and Polymarket are always available.
- YouTube available if yt-dlp installed.
+ Priority: explicit backend pin, then xAI, then Bird with explicit cookies.
- Returns: 'all', 'both', 'reddit', 'reddit-web', 'x', 'x-web', 'web', or 'none'
- """
- has_reddit = True
- has_x = get_x_source(config) is not None
- has_web = has_web_search_keys(config)
-
- if has_reddit and has_x:
- return 'all' if has_web else 'both'
- elif has_reddit:
- return 'reddit-web' if has_web else 'reddit'
- return 'web' if has_web else 'none'
-
-
-def has_web_search_keys(config: Dict[str, Any]) -> bool:
- """Check if any web search API keys are configured."""
- return bool(config.get('EXA_API_KEY') or config.get('OPENROUTER_API_KEY') or config.get('PARALLEL_API_KEY') or config.get('BRAVE_API_KEY'))
-
-
-def get_web_search_source(config: Dict[str, Any]) -> Optional[str]:
- """Determine the best available web search backend.
-
- Priority: Exa (free) > Parallel AI > Brave > OpenRouter/Sonar Pro
-
- Returns: 'exa', 'parallel', 'brave', 'openrouter', or None
- """
- if config.get('EXA_API_KEY'):
- return 'exa'
- if config.get('PARALLEL_API_KEY'):
- return 'parallel'
- if config.get('BRAVE_API_KEY'):
- return 'brave'
- if config.get('OPENROUTER_API_KEY'):
- return 'openrouter'
- return None
-
-
-def get_missing_keys(config: Dict[str, Any]) -> str:
- """Determine which sources are missing (accounting for Bird).
-
- Returns: 'all', 'both', 'reddit', 'x', 'web', or 'none'
- """
- has_reddit = True
- has_xai = bool(config.get('XAI_API_KEY'))
- has_web = has_web_search_keys(config)
-
- # Check if Bird provides X access (import here to avoid circular dependency)
- from . import bird_x
- has_bird = bird_x.is_bird_installed() and bird_x.is_bird_authenticated()
-
- has_x = has_xai or has_bird
-
- if has_reddit and has_x and has_web:
- return 'none'
- elif has_reddit and has_x:
- return 'web' # Missing web search keys
- elif has_reddit and has_web:
- return 'x' # Missing X source
- elif has_reddit:
- return 'x' # Missing X source (and possibly web)
- return 'all'
-
-
-def validate_sources(requested: str, available: str, include_web: bool = False) -> tuple[str, Optional[str]]:
- """Validate requested sources against available keys.
-
- Args:
- requested: 'auto', 'reddit', 'x', 'both', or 'web'
- available: Result from get_available_sources()
- include_web: If True, add WebSearch to available sources
-
- Returns:
- Tuple of (effective_sources, error_message)
- """
- has_reddit = available in ('reddit', 'both', 'reddit-web', 'all')
- has_x = available in ('x', 'both', 'x-web', 'all')
- has_web = available in ('web', 'reddit-web', 'x-web', 'all')
-
- if requested == 'auto':
- if has_reddit and has_x:
- base = 'both'
- elif has_reddit:
- base = 'reddit'
- elif has_x:
- base = 'x'
- elif has_web:
- base = 'web'
- else:
- return 'none', "No sources are available."
-
- if include_web:
- if base == 'both':
- return 'all', None
- if base == 'reddit':
- return 'reddit-web', None
- if base == 'x':
- return 'x-web', None
- return base, None
-
- if requested == 'web':
- return 'web', None
-
- if requested == 'both':
- if not (has_reddit and has_x):
- return 'none', "Requested both sources but X source is missing."
- if include_web:
- return 'all', None
- return 'both', None
-
- if requested == 'reddit':
- if not has_reddit:
- return 'none', "Requested Reddit but only xAI key is available."
- if include_web:
- return 'reddit-web', None
- return 'reddit', None
-
- if requested == 'x':
- if not has_x:
- return 'none', "Requested X but no X source is available (need Bird auth or XAI_API_KEY)."
- if include_web:
- return 'x-web', None
- return 'x', None
-
- return requested, None
-
-
-def get_x_source(config: Dict[str, Any]) -> Optional[str]:
- """Determine the best available X/Twitter source.
-
- Priority chain:
- 1. AUTH_TOKEN/CT0 from env var or .env file → Bird with method "env"
- 2. AUTH_TOKEN/CT0 from browser cookie extraction → Bird with method "browser-{browser}"
- 3. XAI_API_KEY → xAI with method "api"
- 4. None
-
- Use get_x_source_with_method() to also get the method string.
+ Browser-cookie probing is intentionally not used here. Automatic Keychain
+ access causes popups during normal pipeline runs. Bird is only considered
+ available when AUTH_TOKEN and CT0 are present explicitly.
Args:
config: Configuration dict from get_config()
Returns:
- 'bird' if Bird is installed and authenticated,
+ 'bird' if Bird is installed and explicit cookies are configured,
'xai' if XAI_API_KEY is configured,
None if no X source available.
"""
- source, _method = get_x_source_with_method(config)
- return source
-
-
-def get_x_source_with_method(config: Dict[str, Any]) -> tuple[Optional[str], Optional[str]]:
- """Determine the best available X/Twitter source and auth method.
-
- Priority chain:
- 1. AUTH_TOKEN/CT0 (env var or .env) → Bird with method "env"
- 2. AUTH_TOKEN/CT0 (browser cookies) → Bird with method "browser-{browser}"
- 3. XAI_API_KEY → xAI with method "api"
- 4. None
-
- Args:
- config: Configuration dict from get_config()
-
- Returns:
- Tuple of (source, method) where source is 'bird', 'xai', or None
- and method is 'env', 'browser-chrome', 'browser-firefox', 'browser-safari', 'api', or None.
- """
+ # Import here to avoid circular dependency
from . import bird_x
- setup_complete = config.get('SETUP_COMPLETE')
+ preferred = (config.get('LAST30DAYS_X_BACKEND') or '').lower()
+ has_bird_creds = bool(config.get('AUTH_TOKEN') and config.get('CT0'))
+ if has_bird_creds:
+ bird_x.set_credentials(config.get('AUTH_TOKEN'), config.get('CT0'))
- # Check Bird first (free option — uses AUTH_TOKEN/CT0 from any source)
- if bird_x.is_bird_installed():
- auth_source = config.get('_AUTH_TOKEN_SOURCE')
+ if preferred == 'xai':
+ return 'xai' if config.get('XAI_API_KEY') else None
+ if preferred == 'bird':
+ return 'bird' if has_bird_creds and bird_x.is_bird_installed() else None
- # If SETUP_COMPLETE is not set, only allow explicit env var credentials.
- # Do NOT call is_bird_authenticated() for browser-cookie probing —
- # that requires user consent via the setup wizard.
- if not setup_complete:
- # Explicit AUTH_TOKEN from env var / .env file is always allowed
- if auth_source == 'env' and config.get('AUTH_TOKEN'):
- username = bird_x.is_bird_authenticated()
- if username:
- return 'bird', 'env'
- else:
- # SETUP_COMPLETE is set — normal flow, probe cookies if needed
- username = bird_x.is_bird_authenticated()
- if username:
- if auth_source and auth_source.startswith('browser-'):
- method = auth_source # e.g. "browser-firefox"
- else:
- method = 'env'
- return 'bird', method
-
- # Fall back to xAI if key exists
if config.get('XAI_API_KEY'):
- return 'xai', 'api'
+ return 'xai'
+ if has_bird_creds and bird_x.is_bird_installed():
+ return 'bird'
- return None, None
+ return None
def is_ytdlp_available() -> bool:
@@ -655,6 +429,25 @@ def is_ytdlp_available() -> bool:
return youtube_yt.is_ytdlp_installed()
+def is_youtube_comments_available(config: dict[str, Any]) -> bool:
+ """Check if YouTube comment enrichment is available.
+
+ Requires SCRAPECREATORS_API_KEY AND youtube_comments in INCLUDE_SOURCES.
+ """
+ if not config.get('SCRAPECREATORS_API_KEY'):
+ return False
+ include = _parse_include_sources(config)
+ return 'youtube_comments' in include
+
+
+def is_youtube_sc_available(config: dict[str, Any]) -> bool:
+ """Check if ScrapeCreators YouTube search fallback is available.
+
+ Used when yt-dlp is not installed or fails.
+ """
+ return bool(config.get('SCRAPECREATORS_API_KEY'))
+
+
def is_hackernews_available() -> bool:
"""Check if Hacker News source is available.
@@ -663,7 +456,7 @@ def is_hackernews_available() -> bool:
return True
-def is_bluesky_available(config: Dict[str, Any]) -> bool:
+def is_bluesky_available(config: dict[str, Any]) -> bool:
"""Check if Bluesky source is available.
Requires BSKY_HANDLE and BSKY_APP_PASSWORD (app password from bsky.app/settings).
@@ -671,7 +464,7 @@ def is_bluesky_available(config: Dict[str, Any]) -> bool:
return bool(config.get('BSKY_HANDLE') and config.get('BSKY_APP_PASSWORD'))
-def is_truthsocial_available(config: Dict[str, Any]) -> bool:
+def is_truthsocial_available(config: dict[str, Any]) -> bool:
"""Check if Truth Social source is available.
Requires TRUTHSOCIAL_TOKEN (bearer token from browser dev tools).
@@ -687,7 +480,7 @@ def is_polymarket_available() -> bool:
return True
-def is_tiktok_available(config: Dict[str, Any]) -> bool:
+def is_tiktok_available(config: dict[str, Any]) -> bool:
"""Check if TikTok source is available (ScrapeCreators or legacy Apify).
Returns True if SCRAPECREATORS_API_KEY or APIFY_API_TOKEN is set.
@@ -695,12 +488,29 @@ def is_tiktok_available(config: Dict[str, Any]) -> bool:
return bool(config.get('SCRAPECREATORS_API_KEY') or config.get('APIFY_API_TOKEN'))
-def get_tiktok_token(config: Dict[str, Any]) -> str:
+def get_tiktok_token(config: dict[str, Any]) -> str:
"""Get TikTok API token, preferring ScrapeCreators over legacy Apify."""
return config.get('SCRAPECREATORS_API_KEY') or config.get('APIFY_API_TOKEN') or ''
-def is_instagram_available(config: Dict[str, Any]) -> bool:
+def _parse_include_sources(config: dict[str, Any]) -> set[str]:
+ """Parse INCLUDE_SOURCES config value into a set of lowercase source names."""
+ raw = config.get('INCLUDE_SOURCES') or ''
+ return {s.strip().lower() for s in raw.split(',') if s.strip()}
+
+
+def is_threads_available(config: dict[str, Any]) -> bool:
+ """Check if Threads source is available.
+
+ Requires SCRAPECREATORS_API_KEY AND 'threads' in INCLUDE_SOURCES.
+ Threads is an opt-in source - it is not activated by default.
+ """
+ if not config.get('SCRAPECREATORS_API_KEY'):
+ return False
+ return 'threads' in _parse_include_sources(config)
+
+
+def is_instagram_available(config: dict[str, Any]) -> bool:
"""Check if Instagram source is available (ScrapeCreators).
Returns True if SCRAPECREATORS_API_KEY is set.
@@ -709,12 +519,12 @@ def is_instagram_available(config: Dict[str, Any]) -> bool:
return bool(config.get('SCRAPECREATORS_API_KEY'))
-def get_instagram_token(config: Dict[str, Any]) -> str:
+def get_instagram_token(config: dict[str, Any]) -> str:
"""Get Instagram API token (same ScrapeCreators key as TikTok)."""
return config.get('SCRAPECREATORS_API_KEY') or ''
-def get_xiaohongshu_api_base(config: Dict[str, Any]) -> str:
+def get_xiaohongshu_api_base(config: dict[str, Any]) -> str:
"""Get Xiaohongshu HTTP API base URL.
Defaults to host.docker.internal so OpenClaw Docker can reach host service.
@@ -722,7 +532,7 @@ def get_xiaohongshu_api_base(config: Dict[str, Any]) -> str:
return (config.get('XIAOHONGSHU_API_BASE') or "http://host.docker.internal:18060").rstrip("/")
-def is_xiaohongshu_available(config: Dict[str, Any]) -> bool:
+def is_xiaohongshu_available(config: dict[str, Any]) -> bool:
"""Check whether Xiaohongshu HTTP API is reachable and logged in."""
# Import here to avoid heavy imports at module load.
from . import http
@@ -744,7 +554,14 @@ def is_xiaohongshu_available(config: Dict[str, Any]) -> bool:
if isinstance(login, dict) else False
)
return bool(is_logged_in)
- except Exception:
+ except (OSError, http.HTTPError):
+ return False
+ except Exception as exc:
+ sys.stderr.write(
+ f"[last30days] WARNING: unexpected error checking Xiaohongshu: "
+ f"{type(exc).__name__}: {exc}\n"
+ )
+ sys.stderr.flush()
return False
@@ -752,56 +569,47 @@ def is_xiaohongshu_available(config: Dict[str, Any]) -> bool:
is_apify_available = is_tiktok_available
-def get_x_source_status(config: Dict[str, Any]) -> Dict[str, Any]:
+def get_x_source_status(config: dict[str, Any]) -> dict[str, Any]:
"""Get detailed X source status for UI decisions.
Returns:
- Dict with keys: source, method, bird_installed, bird_authenticated,
+ Dict with keys: source, bird_installed, bird_authenticated,
bird_username, xai_available, can_install_bird
-
- The ``method`` field indicates HOW the active source is authenticated:
- - "env" — AUTH_TOKEN came from an env var or .env file
- - "browser-chrome", "browser-firefox", "browser-safari" — from cookie extraction
- - "api" — using xAI API key
- - None — no X source available
"""
from . import bird_x
- setup_complete = config.get('SETUP_COMPLETE')
+ bird_status = bird_x.get_bird_status()
xai_available = bool(config.get('XAI_API_KEY'))
- if not setup_complete:
- # Before consent: do NOT call get_bird_status() which probes cookies.
- # Only check if Bird is installed (no cookie probing) and use the
- # gated get_x_source_with_method() which blocks cookie detection.
- bird_installed = bird_x.is_bird_installed()
- source, method = get_x_source_with_method(config)
-
- # Bird "authenticated" only if get_x_source_with_method found explicit creds
- bird_authenticated = (source == 'bird')
-
- return {
- "source": source,
- "method": method,
- "bird_installed": bird_installed,
- "bird_authenticated": bird_authenticated,
- "bird_username": None if not bird_authenticated else "env AUTH_TOKEN",
- "xai_available": xai_available,
- "can_install_bird": True,
- }
-
- # SETUP_COMPLETE is set — normal flow
- bird_status = bird_x.get_bird_status()
-
- # Use the unified resolution function for source + method
- source, method = get_x_source_with_method(config)
+ # Determine active source
+ if bird_status["authenticated"]:
+ source = 'bird'
+ elif xai_available:
+ source = 'xai'
+ else:
+ source = None
return {
"source": source,
- "method": method,
"bird_installed": bird_status["installed"],
"bird_authenticated": bird_status["authenticated"],
"bird_username": bird_status["username"],
"xai_available": xai_available,
"can_install_bird": bird_status["can_install"],
}
+
+
+# Pinterest
+def is_pinterest_available(config: dict[str, Any]) -> bool:
+ """Check if Pinterest source is available.
+
+ Returns True when SCRAPECREATORS_API_KEY is set AND 'pinterest' is in
+ INCLUDE_SOURCES (or requested_sources at the pipeline level). Pinterest
+ is opt-in because not every topic benefits from visual pin results.
+ """
+ return bool(config.get('SCRAPECREATORS_API_KEY'))
+
+
+def get_pinterest_token(config: dict[str, Any]) -> str:
+ """Get Pinterest API token (same ScrapeCreators key as TikTok/Instagram)."""
+ return config.get('SCRAPECREATORS_API_KEY') or ''
diff --git a/scripts/lib/exa_search.py b/scripts/lib/exa_search.py
deleted file mode 100644
index 00efc11..0000000
--- a/scripts/lib/exa_search.py
+++ /dev/null
@@ -1,176 +0,0 @@
-"""Exa AI web search for last30days skill.
-
-Uses the Exa Search API as a free web search backend.
-Free tier: 1,000 searches/month, semantic search, no credit card required.
-
-API docs: https://docs.exa.ai/reference/search
-"""
-
-import sys
-from typing import Any, Dict, List, Optional
-from urllib.parse import urlparse
-
-from . import http
-
-ENDPOINT = "https://api.exa.ai/search"
-
-# Domains to exclude (handled by Reddit/X search)
-EXCLUDED_DOMAINS = {
- "reddit.com", "www.reddit.com", "old.reddit.com",
- "twitter.com", "www.twitter.com", "x.com", "www.x.com",
-}
-
-
-def search_web(
- topic: str,
- from_date: str,
- to_date: str,
- api_key: str,
- depth: str = "default",
-) -> List[Dict[str, Any]]:
- """Search the web via Exa AI Search API.
-
- Args:
- topic: Search topic
- from_date: Start date (YYYY-MM-DD)
- to_date: End date (YYYY-MM-DD)
- api_key: Exa API key
- depth: 'quick', 'default', or 'deep'
-
- Returns:
- List of result dicts with keys: url, title, snippet, source_domain, date, relevance
- """
- num_results = {"quick": 8, "default": 15, "deep": 25}.get(depth, 15)
- max_chars = {"quick": 1000, "default": 2000, "deep": 3000}.get(depth, 2000)
-
- payload = {
- "query": f"{topic} (from {from_date} to {to_date})",
- "type": "auto",
- "numResults": num_results,
- "contents": {"text": {"maxCharacters": max_chars}},
- }
-
- # Add date filtering if dates are provided
- if from_date:
- payload["startPublishedDate"] = f"{from_date}T00:00:00.000Z"
- if to_date:
- payload["endPublishedDate"] = f"{to_date}T23:59:59.999Z"
-
- sys.stderr.write(f"[Web] Searching Exa for: {topic}\n")
- sys.stderr.flush()
-
- try:
- response = http.post(
- ENDPOINT,
- json_data=payload,
- headers={
- "x-api-key": api_key,
- },
- timeout=20,
- retries=2,
- )
- except http.HTTPError as e:
- status = e.status_code
- if status == 401:
- sys.stderr.write("[Web] Exa: invalid API key (401)\n")
- sys.stderr.flush()
- return []
- if status == 429:
- sys.stderr.write("[Web] Exa: rate limited (429)\n")
- sys.stderr.flush()
- return []
- sys.stderr.write(f"[Web] Exa: HTTP error {status}: {e}\n")
- sys.stderr.flush()
- return []
- except Exception as e:
- sys.stderr.write(f"[Web] Exa: request failed: {e}\n")
- sys.stderr.flush()
- return []
-
- return _normalize_results(response)
-
-
-def _normalize_results(response: Dict[str, Any]) -> List[Dict[str, Any]]:
- """Convert Exa API response to websearch item schema.
-
- Exa results have: title, url, text, publishedDate, score, author.
- """
- items = []
-
- results = response.get("results", [])
- if not isinstance(results, list):
- return items
-
- for i, result in enumerate(results):
- if not isinstance(result, dict):
- continue
-
- url = result.get("url", "")
- if not url:
- continue
-
- # Skip excluded domains
- try:
- domain = urlparse(url).netloc.lower()
- if domain in EXCLUDED_DOMAINS:
- continue
- if domain.startswith("www."):
- domain = domain[4:]
- except Exception:
- domain = ""
-
- title = str(result.get("title", "")).strip()
- # Exa returns page content in "text" field
- snippet = str(result.get("text", "")).strip()
-
- if not title and not snippet:
- continue
-
- # Parse publishedDate (ISO format from Exa: "2026-03-15T00:00:00.000Z")
- date = _parse_exa_date(result.get("publishedDate"))
- date_confidence = "med" if date else "low"
-
- # Exa provides a relevance score
- relevance = result.get("score", 0.6)
- try:
- relevance = min(1.0, max(0.0, float(relevance)))
- except (TypeError, ValueError):
- relevance = 0.6
-
- items.append({
- "id": f"W{i+1}",
- "title": title[:200],
- "url": url,
- "source_domain": domain,
- "snippet": snippet[:500],
- "date": date,
- "date_confidence": date_confidence,
- "relevance": relevance,
- "why_relevant": "",
- })
-
- sys.stderr.write(f"[Web] Exa: {len(items)} results\n")
- sys.stderr.flush()
-
- return items
-
-
-def _parse_exa_date(published_date: Optional[str]) -> Optional[str]:
- """Parse Exa's publishedDate to YYYY-MM-DD.
-
- Exa returns ISO format like "2026-03-15T00:00:00.000Z".
- """
- if not published_date:
- return None
-
- try:
- # Extract YYYY-MM-DD from ISO datetime
- if "T" in published_date:
- return published_date.split("T")[0]
- # Already YYYY-MM-DD
- if len(published_date) >= 10:
- return published_date[:10]
- except Exception:
- pass
-
- return None
diff --git a/scripts/lib/fusion.py b/scripts/lib/fusion.py
new file mode 100644
index 0000000..7d902f0
--- /dev/null
+++ b/scripts/lib/fusion.py
@@ -0,0 +1,202 @@
+"""Weighted reciprocal rank fusion for per-(subquery, source) streams."""
+
+from __future__ import annotations
+
+from urllib.parse import parse_qs, urlencode, urlparse, urlunparse
+
+from . import schema
+
+# Standard RRF smoothing constant (Cormack et al. 2009)
+RRF_K = 60
+
+
+def _candidate_sort_key(c: schema.Candidate) -> tuple:
+ return (-c.rrf_score, -c.local_relevance, -c.freshness, schema.candidate_source_label(c), c.title)
+
+
+def _normalize_url(url: str) -> str:
+ """Normalize URL for dedup: lowercase, strip www/old/m prefixes, remove tracking params."""
+ parsed = urlparse(url.strip().lower())
+ netloc = parsed.netloc
+ for prefix in ("www.", "old.", "m."):
+ if netloc.startswith(prefix):
+ netloc = netloc[len(prefix):]
+ # Strip tracking params
+ params = parse_qs(parsed.query)
+ clean_params = {k: v for k, v in params.items() if not k.startswith("utm_")}
+ query = urlencode(clean_params, doseq=True)
+ return urlunparse((parsed.scheme, netloc, parsed.path.rstrip("/"), "", query, ""))
+
+
+def candidate_key(item: schema.SourceItem) -> str:
+ if item.url:
+ return _normalize_url(item.url)
+ return f"{item.source}:{item.item_id}"
+
+
+_DIVERSITY_RELEVANCE_THRESHOLD = 0.25
+
+# Per-author cap: no single author/handle should dominate the pool.
+_MAX_ITEMS_PER_AUTHOR = 3
+
+
+def _extract_author(candidate: schema.Candidate) -> str | None:
+ """Return a normalized author key from a candidate's source items."""
+ for item in candidate.source_items:
+ if item.author:
+ return item.author.strip().lower()
+ return None
+
+
+def _apply_per_author_cap(
+ candidates: list[schema.Candidate],
+ max_per_author: int = _MAX_ITEMS_PER_AUTHOR,
+) -> list[schema.Candidate]:
+ """Keep at most *max_per_author* items from any single author.
+
+ Candidates are assumed to already be sorted by quality (rrf_score etc.),
+ so the first N encountered per author are the best ones.
+ """
+ author_counts: dict[str, int] = {}
+ result: list[schema.Candidate] = []
+ for c in candidates:
+ author = _extract_author(c)
+ if author is None:
+ result.append(c)
+ continue
+ count = author_counts.get(author, 0)
+ if count < max_per_author:
+ result.append(c)
+ author_counts[author] = count + 1
+ return result
+
+
+def _diversify_pool(
+ fused: list[schema.Candidate],
+ pool_limit: int,
+ min_per_source: int = 2,
+) -> list[schema.Candidate]:
+ """Ensure at least *min_per_source* items per qualifying source survive truncation.
+
+ Sources only qualify for reserved slots if their best item exceeds
+ the relevance threshold. Low-relevance sources compete on merit only.
+ """
+ max_relevance: dict[str, float] = {}
+ for c in fused:
+ current = max_relevance.get(c.source, 0.0)
+ if c.local_relevance > current:
+ max_relevance[c.source] = c.local_relevance
+
+ reserved: dict[str, list[schema.Candidate]] = {}
+ remainder: list[schema.Candidate] = []
+ for c in fused:
+ qualifies = max_relevance.get(c.source, 0.0) >= _DIVERSITY_RELEVANCE_THRESHOLD
+ bucket = reserved.setdefault(c.source, [])
+ if qualifies and len(bucket) < min_per_source:
+ bucket.append(c)
+ else:
+ remainder.append(c)
+ pool = [c for per_source in reserved.values() for c in per_source]
+ seen = {c.candidate_id for c in pool}
+ for c in remainder:
+ if len(pool) >= pool_limit:
+ break
+ if c.candidate_id not in seen:
+ pool.append(c)
+ pool.sort(key=_candidate_sort_key)
+ return pool[:pool_limit]
+
+
+def weighted_rrf(
+ streams: dict[tuple[str, str], list[schema.SourceItem]],
+ plan: schema.QueryPlan,
+ *,
+ pool_limit: int,
+) -> list[schema.Candidate]:
+ """Fuse ranked lists into a single candidate pool."""
+ subqueries = {subquery.label: subquery for subquery in plan.subqueries}
+ candidates: dict[str, schema.Candidate] = {}
+
+ for (label, source), items in streams.items():
+ subquery = subqueries[label]
+ weight = subquery.weight * plan.source_weights.get(source, 1.0)
+ for rank, item in enumerate(items, start=1):
+ key = candidate_key(item)
+ score = weight / (RRF_K + rank)
+ item_local_relevance = item.local_relevance if item.local_relevance is not None else float(item.metadata.get("local_relevance", item.relevance_hint))
+ item_freshness = item.freshness if item.freshness is not None else int(item.metadata.get("freshness", 0))
+ item_source_quality = item.source_quality if item.source_quality is not None else float(item.metadata.get("source_quality", 0.6))
+ if key not in candidates:
+ candidates[key] = schema.Candidate(
+ candidate_id=key,
+ item_id=item.item_id,
+ source=item.source,
+ title=item.title,
+ url=item.url,
+ snippet=item.snippet,
+ subquery_labels=[label],
+ native_ranks={f"{label}:{source}": rank},
+ local_relevance=item_local_relevance,
+ freshness=item_freshness,
+ engagement=item.engagement_score if item.engagement_score is not None else item.metadata.get("engagement_score"),
+ source_quality=item_source_quality,
+ rrf_score=score,
+ sources=[item.source],
+ source_items=[item],
+ metadata={
+ "provenance": [
+ {
+ "source": source,
+ "subquery_label": label,
+ "native_rank": rank,
+ "item_id": item.item_id,
+ }
+ ]
+ },
+ )
+ continue
+
+ candidate = candidates[key]
+ candidate.rrf_score += score
+ previous_primary_score = (candidate.local_relevance * 100.0) + candidate.freshness + (candidate.source_quality * 10.0)
+ incoming_primary_score = (item_local_relevance * 100.0) + item_freshness + (item_source_quality * 10.0)
+ candidate.local_relevance = max(
+ candidate.local_relevance,
+ item_local_relevance,
+ )
+ candidate.freshness = max(candidate.freshness, item_freshness)
+ item_eng = item.engagement_score if item.engagement_score is not None else item.metadata.get("engagement_score")
+ if candidate.engagement is None:
+ candidate.engagement = item_eng
+ elif item_eng is not None:
+ candidate.engagement = max(candidate.engagement, item_eng)
+ candidate.source_quality = max(
+ candidate.source_quality,
+ item_source_quality,
+ )
+ candidate.native_ranks[f"{label}:{source}"] = rank
+ if label not in candidate.subquery_labels:
+ candidate.subquery_labels.append(label)
+ if item.source not in candidate.sources:
+ candidate.sources.append(item.source)
+ if not any(existing.source == item.source and existing.item_id == item.item_id for existing in candidate.source_items):
+ candidate.source_items.append(item)
+ candidate.metadata.setdefault("provenance", []).append(
+ {
+ "source": source,
+ "subquery_label": label,
+ "native_rank": rank,
+ "item_id": item.item_id,
+ }
+ )
+ if incoming_primary_score > previous_primary_score:
+ candidate.item_id = item.item_id
+ candidate.source = item.source
+ candidate.title = item.title
+ candidate.snippet = item.snippet
+ if len(candidate.snippet.split()) < len(item.snippet.split()):
+ candidate.snippet = item.snippet
+
+ fused = sorted(candidates.values(), key=_candidate_sort_key)
+ fused = _apply_per_author_cap(fused)
+ return _diversify_pool(fused, pool_limit)
diff --git a/scripts/lib/github.py b/scripts/lib/github.py
new file mode 100644
index 0000000..2a549e6
--- /dev/null
+++ b/scripts/lib/github.py
@@ -0,0 +1,920 @@
+"""GitHub Issues/PRs search via the public GitHub Search API.
+
+Uses api.github.com/search/issues for issue/PR discovery and
+per-item comment enrichment. Auth via GITHUB_TOKEN env var or
+`gh auth token` subprocess fallback.
+"""
+
+import json
+import math
+import os
+import re
+import subprocess
+import sys
+import urllib.error
+import urllib.parse
+import urllib.request
+from concurrent.futures import ThreadPoolExecutor, as_completed
+from typing import Any, Dict, List, Optional
+
+from . import log
+from .query import extract_core_subject
+from .relevance import token_overlap_relevance
+
+SEARCH_URL = "https://api.github.com/search/issues"
+
+DEPTH_LIMITS = {
+ "quick": 15,
+ "default": 30,
+ "deep": 60,
+}
+
+ENRICH_LIMITS = {
+ "quick": 3,
+ "default": 5,
+ "deep": 8,
+}
+
+USER_AGENT = "last30days/3.0 (research tool)"
+
+
+def _log(msg: str):
+ log.source_log("GitHub", msg, tty_only=False)
+
+
+def _resolve_token(token: Optional[str] = None) -> Optional[str]:
+ """Resolve GitHub auth token from argument, env, or gh CLI."""
+ if token:
+ return token
+ env_token = os.environ.get("GITHUB_TOKEN")
+ if env_token:
+ return env_token
+ # Fallback: try gh CLI
+ try:
+ result = subprocess.run(
+ ["gh", "auth", "token"],
+ capture_output=True, text=True, timeout=5,
+ )
+ if result.returncode == 0 and result.stdout.strip():
+ return result.stdout.strip()
+ except (FileNotFoundError, subprocess.TimeoutExpired, OSError):
+ pass
+ return None
+
+
+def _fetch_json(
+ url: str,
+ token: Optional[str] = None,
+ timeout: int = 15,
+) -> Optional[Dict[str, Any]]:
+ """Fetch JSON from GitHub API. Returns None on failure."""
+ headers = {
+ "User-Agent": USER_AGENT,
+ "Accept": "application/vnd.github+json",
+ }
+ if token:
+ headers["Authorization"] = f"Bearer {token}"
+
+ req = urllib.request.Request(url, headers=headers)
+ try:
+ with urllib.request.urlopen(req, timeout=timeout) as resp:
+ body = resp.read().decode("utf-8")
+ return json.loads(body)
+ except urllib.error.HTTPError as e:
+ if e.code == 403:
+ _log(f"403 rate limited or forbidden: {url}")
+ return None
+ if e.code == 422:
+ _log(f"422 unprocessable: {url}")
+ return None
+ _log(f"HTTP {e.code}: {e.reason}")
+ return None
+ except (urllib.error.URLError, OSError, TimeoutError) as e:
+ _log(f"Network error: {e}")
+ return None
+ except json.JSONDecodeError as e:
+ _log(f"JSON decode error: {e}")
+ return None
+
+
+def _parse_repo_from_url(html_url: str) -> str:
+ """Extract 'owner/repo' from a GitHub issue/PR URL."""
+ parts = html_url.replace("https://github.com/", "").split("/")
+ if len(parts) >= 2:
+ return f"{parts[0]}/{parts[1]}"
+ return ""
+
+
+def _parse_date(iso_str: Optional[str]) -> Optional[str]:
+ """Extract YYYY-MM-DD from ISO 8601 datetime string."""
+ if not iso_str:
+ return None
+ try:
+ return iso_str[:10]
+ except (IndexError, TypeError):
+ return None
+
+
+def _compute_relevance(
+ query: str,
+ title: str,
+ rank_index: int,
+ reactions: int,
+ comments: int,
+) -> float:
+ """Blend text relevance with engagement signals."""
+ rank_score = max(0.3, 1.0 - (rank_index * 0.02))
+ engagement_boost = min(0.2, math.log1p(reactions + comments) / 20)
+
+ if query:
+ content_score = token_overlap_relevance(query, title)
+ relevance = min(1.0, 0.6 * rank_score + 0.4 * content_score + engagement_boost)
+ else:
+ relevance = min(1.0, rank_score * 0.7 + engagement_boost + 0.1)
+
+ return round(relevance, 2)
+
+
+def search_github(
+ topic: str,
+ from_date: str,
+ to_date: str,
+ depth: str = "default",
+ token: Optional[str] = None,
+) -> List[Dict[str, Any]]:
+ """Search GitHub Issues and PRs.
+
+ Args:
+ topic: Search topic
+ from_date: Start date (YYYY-MM-DD)
+ to_date: End date (YYYY-MM-DD)
+ depth: 'quick', 'default', or 'deep'
+ token: Optional GitHub token (falls back to env/gh CLI)
+
+ Returns:
+ List of normalized item dicts. Empty list on any failure.
+ """
+ resolved_token = _resolve_token(token)
+ if not resolved_token:
+ _log("No GitHub token available (set GITHUB_TOKEN or install gh CLI)")
+ return []
+
+ count = DEPTH_LIMITS.get(depth, DEPTH_LIMITS["default"])
+ core = extract_core_subject(topic)
+ _log(f"Searching for '{core}' (raw: '{topic}', since {from_date}, count={count})")
+
+ # Build search query with date filter
+ q = f"{core} created:>{from_date}"
+ params = {
+ "q": q,
+ "sort": "reactions",
+ "order": "desc",
+ "per_page": str(min(count, 100)),
+ }
+ url = f"{SEARCH_URL}?{urllib.parse.urlencode(params)}"
+
+ data = _fetch_json(url, token=resolved_token, timeout=30)
+ if not data:
+ return []
+
+ raw_items = data.get("items", [])
+ _log(f"Found {len(raw_items)} issues/PRs")
+
+ items = []
+ for i, item in enumerate(raw_items[:count]):
+ html_url = item.get("html_url", "")
+ repo = _parse_repo_from_url(html_url)
+ title = item.get("title", "")
+ body_text = item.get("body") or ""
+ reactions_total = item.get("reactions", {}).get("total_count", 0) if isinstance(item.get("reactions"), dict) else 0
+ comment_count = item.get("comments", 0)
+ labels = [
+ lbl.get("name", "") for lbl in (item.get("labels") or [])
+ if isinstance(lbl, dict)
+ ]
+ state = item.get("state", "")
+ is_pr = "pull_request" in item
+ author = item.get("user", {}).get("login", "") if isinstance(item.get("user"), dict) else ""
+
+ relevance = _compute_relevance(core, title, i, reactions_total, comment_count)
+
+ items.append({
+ "id": f"GH{i + 1}",
+ "title": title,
+ "url": html_url,
+ "date": _parse_date(item.get("created_at")),
+ "author": author,
+ "source": "github",
+ "score": reactions_total,
+ "container": repo,
+ "snippet": body_text[:300] if body_text else "",
+ "relevance": relevance,
+ "why_relevant": f"GitHub {'PR' if is_pr else 'issue'}: {title[:60]}",
+ "engagement": {
+ "reactions": reactions_total,
+ "comments": comment_count,
+ },
+ "metadata": {
+ "labels": labels,
+ "state": state,
+ "comment_count": comment_count,
+ "reactions": reactions_total,
+ "is_pr": is_pr,
+ },
+ })
+
+ # Enrich top items with comments
+ items = _enrich_top_items(items, depth, resolved_token)
+
+ # Date filter
+ filtered = []
+ for item in items:
+ d = item.get("date")
+ if d is None or (from_date <= d <= to_date):
+ filtered.append(item)
+
+ # Sort by relevance
+ filtered.sort(key=lambda x: x.get("relevance", 0), reverse=True)
+
+ return filtered
+
+
+def _enrich_top_items(
+ items: List[Dict[str, Any]],
+ depth: str,
+ token: str,
+) -> List[Dict[str, Any]]:
+ """Fetch comments for top N items by reactions."""
+ if not items:
+ return items
+
+ limit = ENRICH_LIMITS.get(depth, ENRICH_LIMITS["default"])
+
+ by_reactions = sorted(
+ range(len(items)),
+ key=lambda i: items[i].get("score", 0),
+ reverse=True,
+ )
+ to_enrich = by_reactions[:limit]
+
+ _log(f"Enriching top {len(to_enrich)} items with comments")
+
+ with ThreadPoolExecutor(max_workers=5) as executor:
+ futures = {
+ executor.submit(
+ _fetch_item_comments,
+ items[idx]["url"],
+ token,
+ ): idx
+ for idx in to_enrich
+ }
+
+ for future in as_completed(futures):
+ idx = futures[future]
+ try:
+ comments = future.result(timeout=15)
+ items[idx]["metadata"]["top_comments"] = comments
+ except (KeyError, TypeError, OSError) as exc:
+ _log(f"Comment enrichment failed for {items[idx].get('url', '?')}: {type(exc).__name__}: {exc}")
+ items[idx]["metadata"]["top_comments"] = []
+
+ return items
+
+
+def _fetch_item_comments(
+ issue_url: str,
+ token: str,
+ max_comments: int = 5,
+) -> List[Dict[str, Any]]:
+ """Fetch comments for a GitHub issue/PR.
+
+ Args:
+ issue_url: HTML URL like https://github.com/owner/repo/issues/123
+ token: GitHub auth token
+ max_comments: Max comments to return
+
+ Returns:
+ List of comment dicts with score, excerpt, author.
+ """
+ path = issue_url.replace("https://github.com/", "")
+ path = path.replace("/pull/", "/issues/")
+ api_url = f"https://api.github.com/repos/{path}/comments?per_page={max_comments}&sort=reactions&direction=desc"
+
+ data = _fetch_json(api_url, token=token, timeout=15)
+ if not data or not isinstance(data, list):
+ return []
+
+ comments = []
+ for c in data[:max_comments]:
+ body = c.get("body") or ""
+ excerpt = body[:300] + "..." if len(body) > 300 else body
+ reactions = c.get("reactions", {})
+ reaction_count = reactions.get("total_count", 0) if isinstance(reactions, dict) else 0
+ author = c.get("user", {}).get("login", "") if isinstance(c.get("user"), dict) else ""
+
+ comments.append({
+ "score": reaction_count,
+ "excerpt": excerpt,
+ "author": author,
+ })
+
+ return comments
+
+
+# ---------------------------------------------------------------------------
+# Person-mode search: author-scoped queries, star enrichment, release notes
+# ---------------------------------------------------------------------------
+
+PERSON_DEPTH_LIMITS = {
+ "quick": {"pr_pages": 1, "own_repos": 3, "external_repos": 5},
+ "default": {"pr_pages": 1, "own_repos": 5, "external_repos": 10},
+ "deep": {"pr_pages": 2, "own_repos": 5, "external_repos": 15},
+}
+
+
+def _fetch_readme_snippet(repo: str, token: str, max_chars: int = 500) -> Optional[str]:
+ """Fetch README content for a repo, truncated to first ~max_chars."""
+ url = f"https://api.github.com/repos/{repo}/readme"
+ headers = {
+ "User-Agent": USER_AGENT,
+ "Accept": "application/vnd.github.raw+json",
+ }
+ if token:
+ headers["Authorization"] = f"Bearer {token}"
+
+ req = urllib.request.Request(url, headers=headers)
+ try:
+ with urllib.request.urlopen(req, timeout=10) as resp:
+ raw = resp.read().decode("utf-8", errors="replace")
+ except (urllib.error.HTTPError, urllib.error.URLError, OSError, TimeoutError):
+ return None
+
+ if not raw:
+ return None
+ # Try to break at a paragraph boundary
+ if len(raw) <= max_chars:
+ return raw
+ cut = raw[:max_chars]
+ last_double_newline = cut.rfind("\n\n")
+ if last_double_newline > max_chars // 3:
+ return cut[:last_double_newline].rstrip()
+ return cut.rstrip() + "..."
+
+
+def _fetch_latest_releases(
+ repo: str, token: str, count: int = 3, max_body: int = 300,
+) -> List[Dict[str, str]]:
+ """Fetch latest releases for a repo."""
+ url = f"https://api.github.com/repos/{repo}/releases?per_page={count}"
+ data = _fetch_json(url, token=token, timeout=10)
+ if not data or not isinstance(data, list):
+ return []
+ releases = []
+ for r in data[:count]:
+ tag = r.get("tag_name", "")
+ date = _parse_date(r.get("published_at"))
+ body = (r.get("body") or "")[:max_body]
+ name = r.get("name") or tag
+ releases.append({"tag": tag, "name": name, "date": date, "body": body})
+ return releases
+
+
+def _fetch_top_issues(repo: str, token: str) -> Dict[str, Any]:
+ """Fetch top feature request (by reactions) and top complaint (by comments)."""
+ result: Dict[str, Any] = {}
+
+ # Top feature request: issues with enhancement label, sorted by reactions
+ feat_q = urllib.parse.quote(f"repo:{repo} is:issue is:open label:enhancement")
+ feat_url = f"{SEARCH_URL}?q={feat_q}&sort=reactions&order=desc&per_page=1"
+ feat_data = _fetch_json(feat_url, token=token, timeout=10)
+ if feat_data and feat_data.get("items"):
+ item = feat_data["items"][0]
+ result["top_feature_request"] = {
+ "title": item.get("title", ""),
+ "reactions": item.get("reactions", {}).get("total_count", 0) if isinstance(item.get("reactions"), dict) else 0,
+ "comments": item.get("comments", 0),
+ "url": item.get("html_url", ""),
+ }
+ elif feat_data and feat_data.get("total_count", 0) == 0:
+ # No enhancement label; fall back to top issue by reactions
+ fallback_q = urllib.parse.quote(f"repo:{repo} is:issue is:open")
+ fallback_url = f"{SEARCH_URL}?q={fallback_q}&sort=reactions&order=desc&per_page=1"
+ fallback_data = _fetch_json(fallback_url, token=token, timeout=10)
+ if fallback_data and fallback_data.get("items"):
+ item = fallback_data["items"][0]
+ result["top_feature_request"] = {
+ "title": item.get("title", ""),
+ "reactions": item.get("reactions", {}).get("total_count", 0) if isinstance(item.get("reactions"), dict) else 0,
+ "comments": item.get("comments", 0),
+ "url": item.get("html_url", ""),
+ }
+
+ # Top complaint: most-discussed open issue (by comments)
+ bug_q = urllib.parse.quote(f"repo:{repo} is:issue is:open")
+ bug_url = f"{SEARCH_URL}?q={bug_q}&sort=comments&order=desc&per_page=1"
+ bug_data = _fetch_json(bug_url, token=token, timeout=10)
+ if bug_data and bug_data.get("items"):
+ item = bug_data["items"][0]
+ result["top_complaint"] = {
+ "title": item.get("title", ""),
+ "reactions": item.get("reactions", {}).get("total_count", 0) if isinstance(item.get("reactions"), dict) else 0,
+ "comments": item.get("comments", 0),
+ "url": item.get("html_url", ""),
+ }
+
+ return result
+
+
+def _fetch_repo_info(repo: str, token: str) -> Optional[Dict[str, Any]]:
+ """Fetch repo metadata (stars, forks, description, language)."""
+ url = f"https://api.github.com/repos/{repo}"
+ data = _fetch_json(url, token=token, timeout=10)
+ if not data or not isinstance(data, dict):
+ return None
+ return {
+ "stars": data.get("stargazers_count", 0),
+ "forks": data.get("forks_count", 0),
+ "description": (data.get("description") or "")[:200],
+ "language": data.get("language") or "",
+ "open_issues": data.get("open_issues_count", 0),
+ }
+
+
+def _format_stars(n: int) -> str:
+ """Format star count as human-readable (e.g., 349K, 2.9K, 42)."""
+ if n >= 1_000_000:
+ return f"{n / 1_000_000:.1f}M"
+ if n >= 1_000:
+ return f"{n / 1_000:.0f}K" if n >= 10_000 else f"{n / 1_000:.1f}K"
+ return str(n)
+
+
+def search_github_person(
+ username: str,
+ from_date: str,
+ to_date: str,
+ depth: str = "default",
+ token: Optional[str] = None,
+) -> List[Dict[str, Any]]:
+ """Person-mode GitHub search: author-scoped queries with star enrichment.
+
+ Returns SourceItems for:
+ - 1 velocity summary item
+ - Per-repo items for top external repos (with stars + release notes)
+ - Per-repo items for own repos (with stars + README + top issues + releases)
+ """
+ resolved_token = _resolve_token(token)
+ if not resolved_token:
+ _log("No GitHub token available for person-mode search")
+ return []
+
+ limits = PERSON_DEPTH_LIMITS.get(depth, PERSON_DEPTH_LIMITS["default"])
+ _log(f"Person-mode search for @{username} (since {from_date})")
+
+ # Phase 1: PR velocity via search API
+ total_q = urllib.parse.quote(f"author:{username} type:pr created:>{from_date}")
+ merged_q = urllib.parse.quote(f"author:{username} type:pr is:merged created:>{from_date}")
+
+ total_url = f"{SEARCH_URL}?q={total_q}&per_page=1"
+ merged_url = f"{SEARCH_URL}?q={merged_q}&sort=reactions&order=desc&per_page=100"
+
+ total_data = _fetch_json(total_url, token=resolved_token, timeout=20)
+ merged_data = _fetch_json(merged_url, token=resolved_token, timeout=20)
+
+ total_prs = total_data.get("total_count", 0) if total_data else 0
+ merged_count = merged_data.get("total_count", 0) if merged_data else 0
+ merged_items = merged_data.get("items", []) if merged_data else []
+
+ _log(f"Found {total_prs} total PRs, {merged_count} merged")
+
+ if total_prs == 0 and merged_count == 0:
+ _log("No PRs found, falling back to keyword search")
+ return []
+
+ # Phase 2: Group merged PRs by repo
+ repo_pr_counts: Dict[str, int] = {}
+ for item in merged_items:
+ repo = _parse_repo_from_url(item.get("html_url", ""))
+ if repo:
+ repo_pr_counts[repo] = repo_pr_counts.get(repo, 0) + 1
+
+ # Sort repos by PR count (most active first)
+ sorted_repos = sorted(repo_pr_counts.items(), key=lambda x: x[1], reverse=True)
+
+ # Phase 3: Fetch own repos
+ own_repos_url = f"https://api.github.com/users/{username}/repos?sort=stars&per_page={limits['own_repos']}&direction=desc"
+ own_repos_data = _fetch_json(own_repos_url, token=resolved_token, timeout=15)
+ own_repo_names = set()
+ own_repos_info: List[Dict[str, Any]] = []
+ if own_repos_data and isinstance(own_repos_data, list):
+ for r in own_repos_data:
+ full_name = r.get("full_name", "")
+ if full_name and not r.get("fork"):
+ own_repo_names.add(full_name)
+ own_repos_info.append({
+ "full_name": full_name,
+ "stars": r.get("stargazers_count", 0),
+ "forks": r.get("forks_count", 0),
+ "description": (r.get("description") or "")[:200],
+ "language": r.get("language") or "",
+ "open_issues": r.get("open_issues_count", 0),
+ })
+
+ # Separate external repos from own repos
+ external_repos = [(repo, count) for repo, count in sorted_repos if repo not in own_repo_names]
+ external_repos = external_repos[:limits["external_repos"]]
+
+ # Phase 4: Parallel enrichment (star counts, releases, READMEs, top issues)
+ items: List[Dict[str, Any]] = []
+ idx = 0
+
+ # Build velocity summary
+ open_prs = total_prs - merged_count
+ merge_rate = round(100 * merged_count / total_prs) if total_prs > 0 else 0
+ num_repos = len(repo_pr_counts)
+ velocity_text = (
+ f"GitHub Person Profile: @{username}\n\n"
+ f"CONTRIBUTION VELOCITY (last {(to_date > from_date) and 30 or 30} days)\n"
+ f"- {merged_count} PRs merged across {num_repos} repos ({merge_rate}% merge rate)\n"
+ f"- {total_prs} total PRs submitted, {open_prs} still open\n"
+ )
+
+ idx += 1
+ items.append({
+ "id": f"GH{idx}",
+ "title": f"@{username}: {merged_count} PRs merged across {num_repos} repos ({merge_rate}% merge rate)",
+ "url": f"https://github.com/{username}",
+ "date": to_date,
+ "author": username,
+ "source": "github",
+ "score": merged_count,
+ "container": f"@{username}",
+ "snippet": velocity_text,
+ "relevance": 0.95,
+ "why_relevant": f"GitHub profile: @{username} - {merged_count} PRs merged across {num_repos} repos",
+ "engagement": {"reactions": merged_count, "comments": total_prs},
+ "metadata": {
+ "labels": ["person-profile", "velocity"],
+ "state": "open",
+ "comment_count": 0,
+ "reactions": merged_count,
+ "is_pr": False,
+ },
+ })
+
+ # Phase 5: Enrich external repos (parallel: star counts + releases)
+ _log(f"Enriching {len(external_repos)} external repos + {len(own_repos_info)} own repos")
+
+ with ThreadPoolExecutor(max_workers=8) as executor:
+ # External repo enrichment: stars + releases
+ ext_futures = {}
+ for repo, pr_count in external_repos:
+ ext_futures[executor.submit(_enrich_external_repo, repo, resolved_token)] = (repo, pr_count)
+
+ # Own repo enrichment: README + releases + top issues
+ own_futures = {}
+ for own_repo in own_repos_info:
+ own_futures[executor.submit(_enrich_own_repo, own_repo["full_name"], resolved_token)] = own_repo
+
+ # Collect external repo results
+ for future in as_completed(ext_futures):
+ repo, pr_count = ext_futures[future]
+ try:
+ enrichment = future.result(timeout=20)
+ except Exception as exc:
+ _log(f"External repo enrichment failed for {repo}: {exc}")
+ enrichment = {}
+
+ repo_info = enrichment.get("info")
+ releases = enrichment.get("releases", [])
+
+ stars = repo_info["stars"] if repo_info else 0
+ stars_str = _format_stars(stars)
+ desc = repo_info["description"] if repo_info else ""
+
+ snippet_parts = [f"Contributed {pr_count} merged PRs to {repo} ({stars_str} stars)"]
+ if desc:
+ snippet_parts.append(f" {desc}")
+ if releases:
+ for rel in releases[:2]:
+ body_preview = f" - {rel['body'][:150]}" if rel.get("body") else ""
+ snippet_parts.append(f" Latest release: {rel['name']} ({rel['date']}){body_preview}")
+
+ idx += 1
+ items.append({
+ "id": f"GH{idx}",
+ "title": f"{repo} ({stars_str} stars) - {pr_count} PRs merged",
+ "url": f"https://github.com/{repo}",
+ "date": releases[0]["date"] if releases and releases[0].get("date") else to_date,
+ "author": username,
+ "source": "github",
+ "score": stars,
+ "container": repo,
+ "snippet": "\n".join(snippet_parts),
+ "relevance": min(0.9, 0.6 + math.log1p(stars) / 30 + min(0.15, pr_count / 20)),
+ "why_relevant": f"GitHub contribution: {pr_count} PRs merged to {repo} ({stars_str} stars)",
+ "engagement": {"reactions": stars, "comments": pr_count},
+ "metadata": {
+ "labels": ["person-profile", "external-repo"],
+ "state": "open",
+ "comment_count": pr_count,
+ "reactions": stars,
+ "is_pr": False,
+ },
+ })
+
+ # Collect own repo results
+ for future in as_completed(own_futures):
+ own_repo = own_futures[future]
+ try:
+ enrichment = future.result(timeout=25)
+ except Exception as exc:
+ _log(f"Own repo enrichment failed for {own_repo['full_name']}: {exc}")
+ enrichment = {}
+
+ repo_name = own_repo["full_name"]
+ stars = own_repo["stars"]
+ stars_str = _format_stars(stars)
+ open_issues = own_repo["open_issues"]
+ desc = own_repo["description"]
+
+ readme = enrichment.get("readme")
+ releases = enrichment.get("releases", [])
+ top_issues = enrichment.get("top_issues", {})
+
+ snippet_parts = [f"Own project: {repo_name} ({stars_str} stars, {open_issues} open issues)"]
+ if desc:
+ snippet_parts.append(f" {desc}")
+ if readme:
+ snippet_parts.append(f" README: {readme[:300]}")
+ if releases:
+ for rel in releases[:2]:
+ body_preview = f" - {rel['body'][:150]}" if rel.get("body") else ""
+ snippet_parts.append(f" Latest release: {rel['name']} ({rel['date']}){body_preview}")
+ feat = top_issues.get("top_feature_request")
+ if feat:
+ snippet_parts.append(f" Top feature request: \"{feat['title']}\" ({feat['reactions']} reactions, {feat['comments']} comments)")
+ complaint = top_issues.get("top_complaint")
+ if complaint:
+ snippet_parts.append(f" Top complaint: \"{complaint['title']}\" ({complaint['comments']} comments)")
+
+ idx += 1
+ items.append({
+ "id": f"GH{idx}",
+ "title": f"{repo_name} ({stars_str} stars) - own project, {open_issues} open issues",
+ "url": f"https://github.com/{repo_name}",
+ "date": releases[0]["date"] if releases and releases[0].get("date") else to_date,
+ "author": username,
+ "source": "github",
+ "score": stars,
+ "container": repo_name,
+ "snippet": "\n".join(snippet_parts),
+ "relevance": min(0.95, 0.7 + math.log1p(stars) / 25),
+ "why_relevant": f"GitHub own project: {repo_name} ({stars_str} stars)",
+ "engagement": {"reactions": stars, "comments": open_issues},
+ "metadata": {
+ "labels": ["person-profile", "own-repo"],
+ "state": "open",
+ "comment_count": open_issues,
+ "reactions": stars,
+ "is_pr": False,
+ },
+ })
+
+ # Sort by relevance
+ items.sort(key=lambda x: x.get("relevance", 0), reverse=True)
+ _log(f"Person-mode returned {len(items)} items")
+ return items
+
+
+def _enrich_external_repo(repo: str, token: str) -> Dict[str, Any]:
+ """Fetch star count + releases for an external repo."""
+ info = _fetch_repo_info(repo, token)
+ releases = _fetch_latest_releases(repo, token, count=3)
+ return {"info": info, "releases": releases}
+
+
+def _enrich_own_repo(repo: str, token: str) -> Dict[str, Any]:
+ """Fetch README + releases + top issues for an own repo."""
+ readme = _fetch_readme_snippet(repo, token, max_chars=500)
+ releases = _fetch_latest_releases(repo, token, count=3)
+ top_issues = _fetch_top_issues(repo, token)
+ return {"readme": readme, "releases": releases, "top_issues": top_issues}
+
+
+# ---------------------------------------------------------------------------
+# Project-mode search: fetch comprehensive data for specific repos
+# ---------------------------------------------------------------------------
+
+def search_github_project(
+ repos: List[str],
+ from_date: str,
+ to_date: str,
+ depth: str = "default",
+ token: Optional[str] = None,
+) -> List[Dict[str, Any]]:
+ """Project-mode GitHub search: fetch stars, README, releases, top issues for repos.
+
+ Args:
+ repos: List of 'owner/repo' strings.
+ from_date: Start date (YYYY-MM-DD).
+ to_date: End date (YYYY-MM-DD).
+ depth: 'quick', 'default', or 'deep'.
+ token: Optional GitHub token.
+
+ Returns:
+ List of SourceItems, one per repo.
+ """
+ resolved_token = _resolve_token(token)
+ if not resolved_token:
+ _log("No GitHub token available for project-mode search")
+ return []
+
+ _log(f"Project-mode search for {len(repos)} repos: {', '.join(repos)}")
+
+ items: List[Dict[str, Any]] = []
+
+ with ThreadPoolExecutor(max_workers=min(8, len(repos))) as executor:
+ futures = {
+ executor.submit(_enrich_project_repo, repo, resolved_token): repo
+ for repo in repos
+ }
+
+ for idx, future in enumerate(as_completed(futures)):
+ repo = futures[future]
+ try:
+ enrichment = future.result(timeout=25)
+ except Exception as exc:
+ _log(f"Project enrichment failed for {repo}: {exc}")
+ continue
+
+ info = enrichment.get("info")
+ if not info:
+ _log(f"No repo info for {repo}, skipping")
+ continue
+
+ readme = enrichment.get("readme")
+ releases = enrichment.get("releases", [])
+ top_issues = enrichment.get("top_issues", {})
+
+ stars = info["stars"]
+ stars_str = _format_stars(stars)
+ open_issues = info["open_issues"]
+ desc = info["description"]
+ lang = info["language"]
+
+ snippet_parts = [f"Project: {repo} ({stars_str} stars, {open_issues} open issues, {lang})"]
+ if desc:
+ snippet_parts.append(f" {desc}")
+ if readme:
+ snippet_parts.append(f" README: {readme[:400]}")
+ if releases:
+ for rel in releases[:2]:
+ body_preview = f" - {rel['body'][:150]}" if rel.get("body") else ""
+ snippet_parts.append(f" Latest release: {rel['name']} ({rel['date']}){body_preview}")
+ feat = top_issues.get("top_feature_request")
+ if feat:
+ snippet_parts.append(f" Top feature request: \"{feat['title']}\" ({feat['reactions']} reactions, {feat['comments']} comments)")
+ complaint = top_issues.get("top_complaint")
+ if complaint:
+ snippet_parts.append(f" Top complaint: \"{complaint['title']}\" ({complaint['comments']} comments)")
+
+ items.append({
+ "id": f"GH{idx + 1}",
+ "title": f"{repo} ({stars_str} stars) - {open_issues} open issues",
+ "url": f"https://github.com/{repo}",
+ "date": releases[0]["date"] if releases and releases[0].get("date") else to_date,
+ "author": repo.split("/")[0],
+ "source": "github",
+ "score": stars,
+ "container": repo,
+ "snippet": "\n".join(snippet_parts),
+ "relevance": min(0.95, 0.7 + math.log1p(stars) / 25),
+ "why_relevant": f"GitHub project: {repo} ({stars_str} stars, live)",
+ "engagement": {"reactions": stars, "comments": open_issues},
+ "metadata": {
+ "labels": ["project-mode"],
+ "state": "open",
+ "comment_count": open_issues,
+ "reactions": stars,
+ "is_pr": False,
+ "github_stars": {repo: stars},
+ },
+ })
+
+ items.sort(key=lambda x: x.get("relevance", 0), reverse=True)
+ _log(f"Project-mode returned {len(items)} items")
+ return items
+
+
+def _enrich_project_repo(repo: str, token: str) -> Dict[str, Any]:
+ """Fetch all project data for a repo: info + README + releases + top issues."""
+ info = _fetch_repo_info(repo, token)
+ readme = _fetch_readme_snippet(repo, token, max_chars=500)
+ releases = _fetch_latest_releases(repo, token, count=3)
+ top_issues = _fetch_top_issues(repo, token)
+ return {"info": info, "readme": readme, "releases": releases, "top_issues": top_issues}
+
+
+# ---------------------------------------------------------------------------
+# Post-rerank star enrichment: annotate candidates with live star counts
+# ---------------------------------------------------------------------------
+
+_REPO_URL_PATTERN = re.compile(r"github\.com/([A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+)")
+_SKIP_PATHS = {"topics", "search", "orgs", "settings", "features", "about", "pricing", "enterprise", "explore", "marketplace", "sponsors"}
+
+
+def extract_repo_refs(candidates: List[Any]) -> List[str]:
+ """Extract unique owner/repo strings from candidate URLs, titles, and snippets."""
+ seen: set = set()
+ repos: List[str] = []
+ for c in candidates:
+ texts = [
+ getattr(c, "url", "") or "",
+ getattr(c, "title", "") or "",
+ ]
+ # Also check evidence snippets if available
+ evidence = getattr(c, "evidence", None)
+ if evidence:
+ texts.append(str(evidence))
+ for text in texts:
+ for match in _REPO_URL_PATTERN.findall(text):
+ # Normalize: strip trailing .git, lowercase
+ repo = match.rstrip(".git").lower()
+ owner = repo.split("/")[0]
+ if owner in _SKIP_PATHS:
+ continue
+ if repo not in seen:
+ seen.add(repo)
+ repos.append(match) # preserve original case
+ return repos
+
+
+def enrich_candidates_with_stars(
+ candidates: List[Any],
+ token: Optional[str] = None,
+ already_enriched: Optional[set] = None,
+ max_repos: int = 10,
+) -> int:
+ """Annotate candidates with live GitHub star counts.
+
+ Returns the number of repos enriched.
+ """
+ resolved_token = _resolve_token(token)
+ if not resolved_token:
+ return 0
+
+ refs = extract_repo_refs(candidates)
+ if not refs:
+ return 0
+
+ skip = already_enriched or set()
+ to_fetch = [r for r in refs if r.lower() not in {s.lower() for s in skip}][:max_repos]
+ if not to_fetch:
+ return 0
+
+ _log(f"Star enrichment: fetching {len(to_fetch)} repos")
+
+ # Parallel fetch star counts
+ star_map: Dict[str, int] = {}
+ with ThreadPoolExecutor(max_workers=min(8, len(to_fetch))) as executor:
+ futures = {executor.submit(_fetch_repo_info, repo, resolved_token): repo for repo in to_fetch}
+ for future in as_completed(futures):
+ repo = futures[future]
+ try:
+ info = future.result(timeout=10)
+ if info:
+ star_map[repo.lower()] = info["stars"]
+ except Exception:
+ pass
+
+ if not star_map:
+ return 0
+
+ # Annotate candidates
+ enriched_count = 0
+ for c in candidates:
+ texts = [getattr(c, "url", "") or "", getattr(c, "title", "") or ""]
+ evidence = getattr(c, "evidence", None)
+ if evidence:
+ texts.append(str(evidence))
+ combined = " ".join(texts)
+ for match in _REPO_URL_PATTERN.findall(combined):
+ repo_lower = match.rstrip(".git").lower()
+ if repo_lower in star_map:
+ stars = star_map[repo_lower]
+ stars_str = _format_stars(stars)
+ # Add to metadata
+ if not hasattr(c, "metadata") or c.metadata is None:
+ continue
+ if "github_stars" not in c.metadata:
+ c.metadata["github_stars"] = {}
+ c.metadata["github_stars"][match] = stars
+ # Append to evidence if present
+ if hasattr(c, "evidence") and c.evidence and f"(live:" not in c.evidence:
+ c.evidence = c.evidence + f" (live: {stars_str} stars)"
+ enriched_count += 1
+ break # one annotation per candidate
+
+ _log(f"Star enrichment: annotated {enriched_count} candidates")
+ return enriched_count
diff --git a/scripts/lib/grounding.py b/scripts/lib/grounding.py
new file mode 100644
index 0000000..3aa4a98
--- /dev/null
+++ b/scripts/lib/grounding.py
@@ -0,0 +1,259 @@
+"""Web search retrieval via Brave Search, Exa, and Serper."""
+
+from __future__ import annotations
+
+import urllib.parse
+from datetime import datetime
+from urllib.parse import urlparse
+
+from . import dates, http
+
+
+# ---------------------------------------------------------------------------
+# Brave Search API
+# ---------------------------------------------------------------------------
+
+def brave_search(
+ query: str, date_range: tuple[str, str], api_key: str, count: int = 5,
+) -> tuple[list[dict], dict]:
+ url = (
+ "https://api.search.brave.com/res/v1/web/search?"
+ + urllib.parse.urlencode(
+ {
+ "q": query,
+ "count": count,
+ "freshness": f"{date_range[0]}to{date_range[1]}",
+ }
+ )
+ )
+ data = http.request("GET", url, headers={"X-Subscription-Token": api_key}, timeout=15)
+ items = []
+ for i, r in enumerate((data.get("web", {}).get("results", []))[:count]):
+ raw_date = r.get("page_age") or ""
+ pub_date = _normalize_date(raw_date[:10]) if raw_date else None
+ if not _in_date_range(pub_date, date_range):
+ continue
+ items.append({
+ "id": f"WB{i + 1}",
+ "title": r.get("title", ""),
+ "url": r.get("url", ""),
+ "source_domain": _domain(r.get("url", "")),
+ "snippet": r.get("description", ""),
+ "date": pub_date,
+ "relevance": 0.8,
+ "why_relevant": "Brave web search",
+ })
+ artifact = {"label": "brave", "webSearchQueries": [query], "resultCount": len(items)}
+ return items, artifact
+
+
+# ---------------------------------------------------------------------------
+# Exa AI Search
+# ---------------------------------------------------------------------------
+
+def exa_search(
+ query: str, date_range: tuple[str, str], api_key: str, count: int = 5,
+) -> tuple[list[dict], dict]:
+ data = http.request(
+ "POST", "https://api.exa.ai/search",
+ headers={"x-api-key": api_key},
+ json_data={
+ "query": query,
+ "type": "auto",
+ "numResults": count,
+ "startPublishedDate": f"{date_range[0]}T00:00:00.000Z",
+ "endPublishedDate": f"{date_range[1]}T23:59:59.999Z",
+ "contents": {"text": {"maxCharacters": 2000}},
+ },
+ timeout=15,
+ )
+ items = []
+ for i, r in enumerate((data.get("results", []))[:count]):
+ if not isinstance(r, dict):
+ continue
+ url = r.get("url", "")
+ if not url:
+ continue
+ raw_date = r.get("publishedDate") or ""
+ pub_date = _normalize_date(raw_date.split("T")[0] if "T" in raw_date else raw_date[:10]) if raw_date else None
+ if not _in_date_range(pub_date, date_range):
+ continue
+ items.append({
+ "id": f"WE{i + 1}",
+ "title": r.get("title", ""),
+ "url": url,
+ "source_domain": _domain(url),
+ "snippet": (r.get("text") or "")[:500],
+ "date": pub_date,
+ "relevance": 0.8,
+ "why_relevant": "Exa web search",
+ })
+ artifact = {"label": "exa", "webSearchQueries": [query], "resultCount": len(items)}
+ return items, artifact
+
+
+# ---------------------------------------------------------------------------
+# Serper (Google Search wrapper)
+# ---------------------------------------------------------------------------
+
+def serper_search(
+ query: str, date_range: tuple[str, str], api_key: str, count: int = 5,
+) -> tuple[list[dict], dict]:
+ data = http.request(
+ "POST", "https://google.serper.dev/search",
+ headers={"X-API-KEY": api_key},
+ json_data={
+ "q": query,
+ "num": count,
+ "tbs": f"cdr:1,cd_min:{_serper_date_param(date_range[0])},cd_max:{_serper_date_param(date_range[1])}",
+ },
+ timeout=15,
+ )
+ items = []
+ for i, r in enumerate((data.get("organic", []))[:count]):
+ raw_date = r.get("date") or ""
+ pub_date = _parse_serper_date(raw_date)
+ if not _in_date_range(pub_date, date_range):
+ continue
+ items.append({
+ "id": f"WS{i + 1}",
+ "title": r.get("title", ""),
+ "url": r.get("link", ""),
+ "source_domain": _domain(r.get("link", "")),
+ "snippet": r.get("snippet", ""),
+ "date": pub_date,
+ "relevance": 0.8,
+ "why_relevant": "Serper web search",
+ })
+ artifact = {"label": "serper", "webSearchQueries": [query], "resultCount": len(items)}
+ return items, artifact
+
+
+# ---------------------------------------------------------------------------
+# Parallel AI Search
+# ---------------------------------------------------------------------------
+
+def parallel_search(
+ query: str, date_range: tuple[str, str], api_key: str, count: int = 5,
+) -> tuple[list[dict], dict]:
+ data = http.request(
+ "POST", "https://api.parallel.ai/v1/search",
+ headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"},
+ json_data={"query": query, "max_results": count},
+ timeout=15,
+ )
+ items = []
+ for i, r in enumerate((data.get("results", []))[:count]):
+ if not isinstance(r, dict):
+ continue
+ url = r.get("url", "")
+ if not url:
+ continue
+ raw_date = r.get("published_date") or ""
+ pub_date = _normalize_date(raw_date[:10]) if raw_date else None
+ if not _in_date_range(pub_date, date_range):
+ continue
+ items.append({
+ "id": f"WP{i + 1}",
+ "title": r.get("title", ""),
+ "url": url,
+ "source_domain": _domain(url),
+ "snippet": r.get("snippet", ""),
+ "date": pub_date,
+ "relevance": 0.8,
+ "why_relevant": "Parallel AI web search",
+ })
+ artifact = {"label": "parallel", "webSearchQueries": [query], "resultCount": len(items)}
+ return items, artifact
+
+
+def _parse_serper_date(raw: str) -> str | None:
+ if not raw:
+ return None
+ normalized = _normalize_date(raw)
+ if normalized:
+ return normalized
+ for fmt in ("%b %d, %Y", "%B %d, %Y", "%Y-%m-%d"):
+ try:
+ return datetime.strptime(raw.strip(), fmt).date().isoformat()
+ except ValueError:
+ continue
+ return None
+
+
+
+
+# ---------------------------------------------------------------------------
+# Dispatcher
+# ---------------------------------------------------------------------------
+
+def web_search(
+ query: str,
+ date_range: tuple[str, str],
+ config: dict,
+ backend: str = "auto",
+) -> tuple[list[dict], dict]:
+ """Run web search with the specified or auto-detected backend."""
+ if backend == "auto":
+ if config.get("BRAVE_API_KEY"):
+ backend = "brave"
+ elif config.get("EXA_API_KEY"):
+ backend = "exa"
+ elif config.get("SERPER_API_KEY"):
+ backend = "serper"
+ elif config.get("PARALLEL_API_KEY"):
+ backend = "parallel"
+ else:
+ return [], {}
+ if backend == "brave":
+ key = config.get("BRAVE_API_KEY")
+ if not key:
+ raise RuntimeError("BRAVE_API_KEY is required when web_backend='brave'")
+ return brave_search(query, date_range, key)
+ if backend == "exa":
+ key = config.get("EXA_API_KEY")
+ if not key:
+ raise RuntimeError("EXA_API_KEY is required when web_backend='exa'")
+ return exa_search(query, date_range, key)
+ if backend == "serper":
+ key = config.get("SERPER_API_KEY")
+ if not key:
+ raise RuntimeError("SERPER_API_KEY is required when web_backend='serper'")
+ return serper_search(query, date_range, key)
+ if backend == "parallel":
+ key = config.get("PARALLEL_API_KEY")
+ if not key:
+ raise RuntimeError("PARALLEL_API_KEY is required when web_backend='parallel'")
+ return parallel_search(query, date_range, key)
+ if backend != "none":
+ raise ValueError(f"Unsupported web backend: {backend!r}")
+ return [], {}
+
+
+# ---------------------------------------------------------------------------
+# Helpers
+# ---------------------------------------------------------------------------
+
+def _normalize_date(value: object) -> str | None:
+ if value is None:
+ return None
+ parsed = dates.parse_date(str(value).strip())
+ if not parsed:
+ return None
+ return parsed.date().isoformat()
+
+
+def _serper_date_param(iso_date: str) -> str:
+ """Convert YYYY-MM-DD to MM/DD/YYYY for Serper tbs parameter."""
+ parts = iso_date.split("-")
+ return f"{parts[1]}/{parts[2]}/{parts[0]}"
+
+
+def _in_date_range(pub_date: str | None, date_range: tuple[str, str]) -> bool:
+ if not pub_date:
+ return False
+ return date_range[0] <= pub_date <= date_range[1]
+
+
+def _domain(url: str) -> str:
+ return urlparse(url).netloc.strip().lower()
diff --git a/scripts/lib/hackernews.py b/scripts/lib/hackernews.py
index cda271b..e052b26 100644
--- a/scripts/lib/hackernews.py
+++ b/scripts/lib/hackernews.py
@@ -4,6 +4,7 @@ Uses hn.algolia.com/api/v1 for story discovery and comment enrichment.
No API key needed - just HTTP calls via stdlib urllib.
"""
+import datetime
import html
import math
import sys
@@ -11,10 +12,15 @@ import time
from concurrent.futures import ThreadPoolExecutor, as_completed
from typing import Any, Dict, List, Optional
-from . import http
+import re
+
+from . import http, log
from .query import extract_core_subject
from .relevance import token_overlap_relevance
+# Common HN prefixes that can cause false-positive keyword matches
+_HN_PREFIXES = re.compile(r"^(Tell HN|Show HN|Ask HN|Launch HN)\s*:\s*", re.IGNORECASE)
+
ALGOLIA_SEARCH_URL = "https://hn.algolia.com/api/v1/search"
ALGOLIA_SEARCH_BY_DATE_URL = "https://hn.algolia.com/api/v1/search_by_date"
ALGOLIA_ITEM_URL = "https://hn.algolia.com/api/v1/items"
@@ -33,25 +39,19 @@ ENRICH_LIMITS = {
def _log(msg: str):
- """Log to stderr (only in TTY mode to avoid cluttering Claude Code output)."""
- if sys.stderr.isatty():
- sys.stderr.write(f"[HN] {msg}\n")
- sys.stderr.flush()
+ log.source_log("HN", msg)
def _date_to_unix(date_str: str) -> int:
"""Convert YYYY-MM-DD to Unix timestamp (start of day UTC)."""
parts = date_str.split("-")
year, month, day = int(parts[0]), int(parts[1]), int(parts[2])
- import calendar
- import datetime
dt = datetime.datetime(year, month, day, tzinfo=datetime.timezone.utc)
return int(dt.timestamp())
def _unix_to_date(ts: int) -> str:
"""Convert Unix timestamp to YYYY-MM-DD."""
- import datetime
dt = datetime.datetime.fromtimestamp(ts, tz=datetime.timezone.utc)
return dt.strftime("%Y-%m-%d")
@@ -117,6 +117,30 @@ def search_hackernews(
return response
+def _title_matches_query(title: str, query: str, author: str = "") -> bool:
+ """Check if the query term appears in the title content, not just an HN prefix or author.
+
+ Returns True if the query (or any multi-word token) appears in the title
+ after stripping "Tell HN:", "Show HN:", "Ask HN:", "Launch HN:" prefixes
+ and ignoring the author name. Returns True when query is empty (no filter).
+ """
+ if not query:
+ return True
+ stripped = _HN_PREFIXES.sub("", title).strip()
+ # Also check that the match isn't solely in the author's username
+ check_text = stripped.lower()
+ query_lower = query.lower()
+ # Check each word of the query independently; all must appear somewhere
+ # in the stripped title (not just the prefix).
+ query_words = query_lower.split()
+ for word in query_words:
+ if word in check_text:
+ continue
+ # Word not found in stripped title — reject
+ return False
+ return True
+
+
def parse_hackernews_response(response: Dict[str, Any], query: str = "") -> List[Dict[str, Any]]:
"""Parse Algolia response into normalized item dicts.
@@ -128,6 +152,16 @@ def parse_hackernews_response(response: Dict[str, Any], query: str = "") -> List
List of item dicts ready for normalization.
"""
hits = response.get("hits", [])
+ # Post-filter: remove items where query only matched an HN prefix like "Tell HN:"
+ if query:
+ before = len(hits)
+ hits = [
+ h for h in hits
+ if _title_matches_query(h.get("title", ""), query, h.get("author", ""))
+ ]
+ dropped = before - len(hits)
+ if dropped:
+ _log(f"Prefix filter removed {dropped}/{before} false-positive hits for '{query}'")
items = []
for i, hit in enumerate(hits):
@@ -154,7 +188,7 @@ def parse_hackernews_response(response: Dict[str, Any], query: str = "") -> List
relevance = min(1.0, rank_score * 0.7 + engagement_boost + 0.1)
items.append({
- "object_id": object_id,
+ "id": object_id,
"title": hit.get("title", ""),
"url": article_url,
"hn_url": hn_url,
@@ -162,7 +196,7 @@ def parse_hackernews_response(response: Dict[str, Any], query: str = "") -> List
"date": date_str,
"engagement": {
"points": points,
- "num_comments": num_comments,
+ "comments": num_comments,
},
"relevance": round(relevance, 2),
"why_relevant": f"HN story about {hit.get('title', 'topic')[:60]}",
@@ -248,7 +282,7 @@ def enrich_top_stories(
futures = {
executor.submit(
_fetch_item_comments,
- items[idx]["object_id"],
+ items[idx]["id"],
): idx
for idx in to_enrich
}
@@ -259,7 +293,8 @@ def enrich_top_stories(
result = future.result(timeout=15)
items[idx]["top_comments"] = result["comments"]
items[idx]["comment_insights"] = result["comment_insights"]
- except Exception:
+ except (KeyError, TypeError, OSError) as exc:
+ _log(f"Comment enrichment failed for story {items[idx].get('id', '?')}: {type(exc).__name__}: {exc}")
items[idx]["top_comments"] = []
items[idx]["comment_insights"] = []
diff --git a/scripts/lib/http.py b/scripts/lib/http.py
index a6f4bb7..b626ac1 100644
--- a/scripts/lib/http.py
+++ b/scripts/lib/http.py
@@ -1,26 +1,28 @@
"""HTTP utilities for last30days skill (stdlib only)."""
import json
-import os
+import re
import sys
import time
import urllib.error
import urllib.request
-from typing import Any, Dict, Optional
+from typing import Any, Dict, Optional, Union
from urllib.parse import urlencode
+from . import log as _log
+
DEFAULT_TIMEOUT = 30
-DEBUG = os.environ.get("LAST30DAYS_DEBUG", "").lower() in ("1", "true", "yes")
def log(msg: str):
"""Log debug message to stderr."""
- if DEBUG:
- sys.stderr.write(f"[DEBUG] {msg}\n")
- sys.stderr.flush()
+ _log.debug(msg)
+
+
MAX_RETRIES = 5
+MAX_429_RETRIES = 2
RETRY_DELAY = 2.0
-USER_AGENT = "last30days-skill/2.1 (Assistant Skill)"
+USER_AGENT = "last30days-skill/3.0 (Assistant Skill)"
class HTTPError(Exception):
@@ -38,8 +40,9 @@ def request(
json_data: Optional[Dict[str, Any]] = None,
timeout: int = DEFAULT_TIMEOUT,
retries: int = MAX_RETRIES,
+ max_429_retries: int = MAX_429_RETRIES,
raw: bool = False,
-) -> Dict[str, Any]:
+) -> Union[Dict[str, Any], str]:
"""Make an HTTP request and return JSON response.
Args:
@@ -49,9 +52,11 @@ def request(
json_data: Optional JSON body (for POST)
timeout: Request timeout in seconds
retries: Number of retries on failure
+ max_429_retries: Maximum 429 retries before giving up (separate cap)
+ raw: If True, return raw response text instead of parsed JSON
Returns:
- Parsed JSON response (or raw text if raw=True)
+ Parsed JSON response as dict, or raw text string if raw=True.
Raises:
HTTPError: On request failure
@@ -66,9 +71,11 @@ def request(
req = urllib.request.Request(url, data=data, headers=headers, method=method)
- log(f"{method} {url}")
+ safe_url = re.sub(r'([?&])(key|api_key|token|secret)=[^&]*', r'\1\2=***', url)
+ log(f"{method} {safe_url}")
last_error = None
+ rate_limit_count = 0
for attempt in range(retries):
try:
with urllib.request.urlopen(req, timeout=timeout) as response:
@@ -81,7 +88,7 @@ def request(
body = None
try:
body = e.read().decode('utf-8')
- except:
+ except (OSError, UnicodeDecodeError):
pass
log(f"HTTP Error {e.code}: {e.reason}")
if body:
@@ -93,6 +100,12 @@ def request(
if 400 <= e.code < 500 and e.code != 429:
raise last_error
+ # Cap 429 retries separately to avoid wasting latency
+ if e.code == 429:
+ rate_limit_count += 1
+ if rate_limit_count >= max_429_retries:
+ raise last_error
+
if attempt < retries - 1:
if e.code == 429:
# Respect Retry-After header, fall back to exponential backoff
@@ -103,7 +116,7 @@ def request(
except ValueError:
delay = RETRY_DELAY * (2 ** attempt) + 1
else:
- delay = RETRY_DELAY * (2 ** attempt) + 1 # 2s, 5s, 9s...
+ delay = RETRY_DELAY * (2 ** attempt) + 1 # 3s, 5s, 9s...
log(f"Rate limited (429). Waiting {delay:.1f}s before retry {attempt + 2}/{retries}")
else:
delay = RETRY_DELAY * (2 ** attempt)
diff --git a/scripts/lib/instagram.py b/scripts/lib/instagram.py
index 46acf00..d378e14 100644
--- a/scripts/lib/instagram.py
+++ b/scripts/lib/instagram.py
@@ -9,7 +9,7 @@ API docs: https://scrapecreators.com/docs
import re
import sys
-from datetime import datetime, timezone
+from datetime import datetime
from typing import Any, Dict, List, Optional, Set
try:
@@ -17,7 +17,7 @@ try:
except ImportError:
_requests = None
-from . import http
+from . import dates, http, log
SCRAPECREATORS_BASE = "https://api.scrapecreators.com"
@@ -49,11 +49,67 @@ def _extract_core_subject(topic: str) -> str:
return extract_core_subject(topic, noise=_INSTAGRAM_NOISE)
+def _infer_query_intent(topic: str) -> str:
+ """Tiny local intent classifier for Instagram query expansion."""
+ text = topic.lower().strip()
+ if re.search(r"\b(vs|versus|compare|difference between)\b", text):
+ return "comparison"
+ if re.search(r"\b(how to|tutorial|guide|setup|step by step|deploy|install)\b", text):
+ return "how_to"
+ if re.search(r"\b(thoughts on|worth it|should i|opinion|review)\b", text):
+ return "opinion"
+ if re.search(r"\b(pricing|feature|features|best .* for)\b", text):
+ return "product"
+ return "breaking_news"
+
+
+def expand_instagram_queries(topic: str, depth: str) -> List[str]:
+ """Generate multiple Instagram search queries from a topic.
+
+ Mirrors reddit.py's expand_reddit_queries() pattern:
+ 1. Extract core subject (strip noise words)
+ 2. Include original topic if different from core
+ 3. Add intent-specific OR-joined content-type variants
+ 4. Cap by depth: 1 for quick, 2 for default, 3 for deep
+
+ Returns 1-3 query strings depending on depth.
+ """
+ core = _extract_core_subject(topic)
+ queries = [core]
+
+ # Include cleaned original topic as variant if different from core
+ original_clean = topic.strip().rstrip('?!.')
+ if core.lower() != original_clean.lower() and len(original_clean.split()) <= 8:
+ queries.append(original_clean)
+
+ qtype = _infer_query_intent(topic)
+
+ # Intent-specific Instagram content-type variants
+ if qtype == "breaking_news":
+ queries.append(f"{core} reaction OR edit")
+ elif qtype == "opinion":
+ queries.append(f"{core} reaction OR edit")
+ elif qtype == "product":
+ queries.append(f"{core} review OR haul")
+ elif qtype == "comparison":
+ queries.append(f"{core} vs OR compared")
+ elif qtype == "how_to":
+ queries.append(f"{core} tutorial OR hack")
+ else:
+ queries.append(f"{core} reaction OR edit")
+
+ # Deep depth: add viral content variant
+ if depth == "deep":
+ queries.append(f"{core} viral OR trending OR reel")
+
+ # Cap by depth budget
+ caps = {"quick": 1, "default": 2, "deep": 3}
+ cap = caps.get(depth, 2)
+ return queries[:cap]
+
+
def _log(msg: str):
- """Log to stderr (only in interactive terminals; spinner handles non-TTY)."""
- if sys.stderr.isatty():
- sys.stderr.write(f"[Instagram] {msg}\n")
- sys.stderr.flush()
+ log.source_log("Instagram", msg)
def _sc_headers(token: str) -> Dict[str, str]:
@@ -88,9 +144,8 @@ def _parse_date(item: Dict[str, Any]) -> Optional[str]:
# Fall back to unix timestamp
try:
- dt = datetime.fromtimestamp(int(ts), tz=timezone.utc)
- return dt.strftime("%Y-%m-%d")
- except (ValueError, TypeError, OSError):
+ return dates.timestamp_to_date(int(ts))
+ except (ValueError, TypeError):
pass
return None
@@ -103,6 +158,122 @@ def _extract_hashtags(caption_text: str) -> List[str]:
return re.findall(r'#(\w+)', caption_text)
+def _parse_items(raw_items: List[Dict[str, Any]], core_topic: str) -> List[Dict[str, Any]]:
+ """Parse raw Instagram items into normalized dicts."""
+ items = []
+ for raw in raw_items:
+ if not isinstance(raw, dict):
+ continue
+
+ # Extract reel ID and shortcode
+ reel_pk = str(raw.get("id", raw.get("pk", "")))
+ shortcode = raw.get("shortcode", raw.get("code", ""))
+
+ # Caption text -- can be a string or dict depending on endpoint
+ caption_obj = raw.get("caption", "")
+ if isinstance(caption_obj, dict):
+ text = caption_obj.get("text", "")
+ elif isinstance(caption_obj, str):
+ text = caption_obj
+ else:
+ text = raw.get("desc", raw.get("text", ""))
+
+ # Engagement metrics
+ play_count = raw.get("video_play_count") or raw.get("video_view_count") or raw.get("play_count") or 0
+ like_count = raw.get("like_count") or 0
+ comment_count = raw.get("comment_count") or 0
+
+ # Author info -- 'owner' in reels/search, 'user' in user/reels
+ owner_raw = raw.get("owner") or raw.get("user")
+ if isinstance(owner_raw, dict):
+ author_name = owner_raw.get("username", "")
+ elif isinstance(owner_raw, str):
+ author_name = owner_raw
+ else:
+ author_name = ""
+
+ # Duration
+ duration = raw.get("video_duration")
+
+ # Date
+ date_str = _parse_date(raw)
+
+ # Hashtags from caption text
+ hashtags = _extract_hashtags(text)
+
+ # Compute relevance with hashtag boost
+ relevance = _compute_relevance(core_topic, text, hashtags)
+
+ # Build URL -- prefer API-provided url, fallback to shortcode
+ url = raw.get("url", "")
+ if not url and shortcode:
+ url = f"https://www.instagram.com/reel/{shortcode}"
+
+ items.append({
+ "video_id": reel_pk,
+ "text": text,
+ "url": url,
+ "author_name": author_name,
+ "date": date_str,
+ "engagement": {
+ "views": play_count,
+ "likes": like_count,
+ "comments": comment_count,
+ },
+ "hashtags": hashtags,
+ "duration": duration,
+ "relevance": relevance,
+ "why_relevant": f"Instagram: {text[:60]}" if text else f"Instagram: {core_topic}",
+ "caption_snippet": "", # populated by fetch_captions
+ })
+ return items
+
+
+def _user_reels(
+ handle: str,
+ token: str,
+) -> List[Dict[str, Any]]:
+ """Fetch an Instagram user's recent reels via ScrapeCreators.
+
+ Args:
+ handle: Instagram username (without @)
+ token: ScrapeCreators API key
+
+ Returns:
+ List of raw Instagram reel dicts.
+ """
+ _log(f"User reels: @{handle}")
+ reels_url = f"{SCRAPECREATORS_BASE}/v1/instagram/user/reels"
+ if not _requests:
+ try:
+ from urllib.parse import urlencode
+ params = urlencode({"handle": handle})
+ url = f"{reels_url}?{params}"
+ headers = _sc_headers(token)
+ headers["User-Agent"] = http.USER_AGENT
+ data = http.get(url, headers=headers, timeout=30, retries=2)
+ except Exception as e:
+ _log(f"User reels error (urllib) for @{handle}: {e}")
+ return []
+ else:
+ try:
+ resp = _requests.get(
+ reels_url,
+ params={"handle": handle},
+ headers=_sc_headers(token),
+ timeout=30,
+ )
+ resp.raise_for_status()
+ data = resp.json()
+ except Exception as e:
+ _log(f"User reels error for @{handle}: {e}")
+ return []
+
+ raw_items = data.get("items") or data.get("reels") or data.get("data") or []
+ _log(f" -> {len(raw_items)} reels from @{handle}")
+ return raw_items
+
+
def search_instagram(
topic: str,
from_date: str,
@@ -163,67 +334,7 @@ def search_instagram(
raw_items = raw_items[:config["results_per_page"]]
# Parse items
- items = []
- for raw in raw_items:
- if not isinstance(raw, dict):
- continue
-
- # Extract reel ID and shortcode
- reel_pk = str(raw.get("id", raw.get("pk", "")))
- shortcode = raw.get("shortcode", raw.get("code", ""))
-
- # Caption text — can be a string or dict depending on endpoint
- caption_obj = raw.get("caption", "")
- if isinstance(caption_obj, dict):
- text = caption_obj.get("text", "")
- elif isinstance(caption_obj, str):
- text = caption_obj
- else:
- text = raw.get("desc", raw.get("text", ""))
-
- # Engagement metrics
- play_count = raw.get("video_play_count") or raw.get("video_view_count") or raw.get("play_count") or 0
- like_count = raw.get("like_count") or 0
- comment_count = raw.get("comment_count") or 0
-
- # Author info — 'owner' in reels/search, 'user' in user/reels
- owner = raw.get("owner") or raw.get("user") or {}
- author_name = owner.get("username", "")
-
- # Duration
- duration = raw.get("video_duration")
-
- # Date
- date_str = _parse_date(raw)
-
- # Hashtags from caption text
- hashtags = _extract_hashtags(text)
-
- # Compute relevance with hashtag boost
- relevance = _compute_relevance(core_topic, text, hashtags)
-
- # Build URL — prefer API-provided url, fallback to shortcode
- url = raw.get("url", "")
- if not url and shortcode:
- url = f"https://www.instagram.com/reel/{shortcode}"
-
- items.append({
- "video_id": reel_pk,
- "text": text,
- "url": url,
- "author_name": author_name,
- "date": date_str,
- "engagement": {
- "views": play_count,
- "likes": like_count,
- "comments": comment_count,
- },
- "hashtags": hashtags,
- "duration": duration,
- "relevance": relevance,
- "why_relevant": f"Instagram: {text[:60]}" if text else f"Instagram: {core_topic}",
- "caption_snippet": "", # populated by fetch_captions
- })
+ items = _parse_items(raw_items, core_topic)
# Hard date filter
in_range = [i for i in items if i["date"] and from_date <= i["date"] <= to_date]
@@ -323,25 +434,57 @@ def search_and_enrich(
to_date: str,
depth: str = "default",
token: str = None,
+ ig_creators: List[str] | None = None,
) -> Dict[str, Any]:
"""Full Instagram search: find reels, then fetch captions for top results.
+ Uses expand_instagram_queries() to generate multiple search queries,
+ runs ScrapeCreators for each, and merges/deduplicates results by video ID.
+
Args:
- topic: Search topic
+ topic: Search topic (raw topic, not planner's narrowed query)
from_date: Start date (YYYY-MM-DD)
to_date: End date (YYYY-MM-DD)
depth: 'quick', 'default', or 'deep'
token: ScrapeCreators API key
+ ig_creators: Optional list of Instagram creator handles to fetch reels from
Returns:
Dict with 'items' list. Each item has a 'caption_snippet' field.
"""
- # Step 1: Search
- search_result = search_instagram(topic, from_date, to_date, depth, token)
- items = search_result.get("items", [])
+ core_topic = _extract_core_subject(topic)
+ seen_ids: Set[str] = set()
+ items: List[Dict[str, Any]] = []
+ last_error = None
+
+ # Step 0: Creator reels (high-signal, runs first)
+ if ig_creators and token:
+ for creator in ig_creators:
+ raw_items = _user_reels(creator, token)
+ parsed = _parse_items(raw_items, core_topic)
+ for item in parsed:
+ vid = item.get("video_id", "")
+ if vid and vid not in seen_ids:
+ seen_ids.add(vid)
+ items.append(item)
+
+ # Step 1: Multi-query keyword search — run ScrapeCreators for each expanded query
+ queries = expand_instagram_queries(topic, depth)
+ for q in queries:
+ search_result = search_instagram(q, from_date, to_date, depth, token)
+ if search_result.get("error"):
+ last_error = search_result["error"]
+ for item in search_result.get("items", []):
+ vid = item.get("video_id", "")
+ if vid and vid not in seen_ids:
+ seen_ids.add(vid)
+ items.append(item)
+
+ # Sort merged results by views descending
+ items.sort(key=lambda x: x.get("engagement", {}).get("views", 0), reverse=True)
if not items:
- return search_result
+ return {"items": [], "error": last_error}
# Step 2: Fetch captions for top N
captions = fetch_captions(items, token, depth)
@@ -353,7 +496,7 @@ def search_and_enrich(
if caption:
item["caption_snippet"] = caption
- return {"items": items, "error": search_result.get("error")}
+ return {"items": items, "error": last_error}
def parse_instagram_response(response: Dict[str, Any]) -> List[Dict[str, Any]]:
diff --git a/scripts/lib/log.py b/scripts/lib/log.py
new file mode 100644
index 0000000..d8f60b0
--- /dev/null
+++ b/scripts/lib/log.py
@@ -0,0 +1,28 @@
+"""Shared logging utilities for last30days skill."""
+
+import os
+import sys
+
+DEBUG = os.environ.get("LAST30DAYS_DEBUG", "").lower() in ("1", "true", "yes")
+
+
+def debug(msg: str) -> None:
+ """Log debug message to stderr (only when LAST30DAYS_DEBUG is set)."""
+ if DEBUG:
+ sys.stderr.write(f"[DEBUG] {msg}\n")
+ sys.stderr.flush()
+
+
+def source_log(prefix: str, msg: str, *, tty_only: bool = True) -> None:
+ """Log a source module message to stderr.
+
+ Args:
+ prefix: Source label (e.g. "Reddit", "Bird").
+ msg: Message text.
+ tty_only: If True, only log when stderr is a TTY (avoids cluttering
+ non-interactive output like Claude Code).
+ """
+ if tty_only and not sys.stderr.isatty():
+ return
+ sys.stderr.write(f"[{prefix}] {msg}\n")
+ sys.stderr.flush()
diff --git a/scripts/lib/models.py b/scripts/lib/models.py
deleted file mode 100644
index 4f51a11..0000000
--- a/scripts/lib/models.py
+++ /dev/null
@@ -1,221 +0,0 @@
-"""Model auto-selection for last30days skill.
-
-Model selection philosophy: this tool uses LLM APIs exclusively for
-search tool invocation + structured JSON extraction. This is not
-reasoning-heavy or creative work — mini models handle it equally well
-at ~3-5x lower cost. We prefer the newest-generation mini model, falling
-back to mainline only when mini isn't available.
-
-xAI non-reasoning variant preferred: same pricing as reasoning, but
-faster (skips thinking phase, saves reasoning token output costs).
-"""
-
-import re
-import sys
-from typing import Dict, List, Optional, Tuple
-
-from . import cache, http, env
-
-# OpenAI API
-OPENAI_MODELS_URL = "https://api.openai.com/v1/models"
-# Ordered by cost-efficiency for web_search + JSON extraction tasks.
-# Mini models first: same structured extraction quality at ~3x lower cost.
-OPENAI_FALLBACK_MODELS = ["gpt-5-mini", "gpt-4.1-mini", "gpt-4.1", "gpt-4o"]
-CODEX_FALLBACK_MODELS = ["gpt-5.1-codex-mini", "gpt-5.2"]
-
-# xAI API - Agent Tools API requires grok-4 family
-# Non-reasoning: same price, faster, no unnecessary thinking tokens.
-# Both variants support function calling and structured outputs.
-XAI_MODELS_URL = "https://api.x.ai/v1/models"
-XAI_ALIASES = {
- "latest": "grok-4-1-fast-non-reasoning",
- "stable": "grok-4-1-fast-non-reasoning",
-}
-
-
-def parse_version(model_id: str) -> Optional[Tuple[int, ...]]:
- """Parse semantic version from model ID.
-
- Examples:
- gpt-5 -> (5,)
- gpt-5.2 -> (5, 2)
- gpt-5.2.1 -> (5, 2, 1)
- """
- match = re.search(r'(\d+(?:\.\d+)*)', model_id)
- if match:
- return tuple(int(x) for x in match.group(1).split('.'))
- return None
-
-
-def is_search_capable_model(model_id: str) -> bool:
- """Check if model supports Responses API web_search with domain filtering.
-
- Includes mini variants (same structured extraction quality, lower cost).
- Excludes: nano (no web_search), gpt-4o-mini (no domain filtering),
- chat/codex/pro/preview/turbo/search (specialized variants).
-
- Note: gpt-5 with reasoning effort="minimal" does NOT support web_search
- (per OpenAI docs). We never set reasoning params — our usage is pure
- tool invocation + JSON extraction — so gpt-5 is safe to include here.
- """
- model_lower = model_id.lower()
-
- # gpt-4o-mini does NOT support web_search with filters — exclude it
- if model_lower.startswith("gpt-4o-mini"):
- return False
-
- # Must be gpt-4o, gpt-4.1[-mini], or gpt-5[-mini] series
- if not re.match(r'^gpt-(?:4o|4\.1|5)(\.\d+)*(-mini)?$', model_lower):
- return False
-
- # Exclude unsupported variants
- for exc in ['nano', 'chat', 'codex', 'pro', 'preview', 'turbo', 'search']:
- if exc in model_lower:
- return False
-
- return True
-
-
-# Backward compat alias
-is_mainline_openai_model = is_search_capable_model
-
-
-def select_openai_model(
- api_key: str,
- policy: str = "auto",
- pin: Optional[str] = None,
- mock_models: Optional[List[Dict]] = None,
-) -> str:
- """Select the most cost-efficient OpenAI model for web_search + JSON extraction.
-
- Prefers mini models within the newest generation available, since the task
- is structured extraction (not reasoning or creative work).
-
- Args:
- api_key: OpenAI API key
- policy: 'auto' or 'pinned'
- pin: Model to use if policy is 'pinned'
- mock_models: Mock model list for testing
-
- Returns:
- Selected model ID
- """
- if policy == "pinned" and pin:
- return pin
-
- # Check cache first
- cached = cache.get_cached_model("openai")
- if cached:
- return cached
-
- # Fetch model list
- if mock_models is not None:
- models = mock_models
- else:
- try:
- headers = {"Authorization": f"Bearer {api_key}"}
- response = http.get(OPENAI_MODELS_URL, headers=headers)
- models = response.get("data", [])
- except http.HTTPError as e:
- sys.stderr.write(f"[Models] Failed to fetch OpenAI models: {e}")
- if hasattr(e, 'status_code') and e.status_code in (401, 403):
- sys.stderr.write(" — API key may be invalid or lack permissions")
- sys.stderr.write(f", using fallback {OPENAI_FALLBACK_MODELS[0]}\n")
- return OPENAI_FALLBACK_MODELS[0]
-
- candidates = [m for m in models if is_search_capable_model(m.get("id", ""))]
-
- if not candidates:
- return OPENAI_FALLBACK_MODELS[0]
-
- # Sort: newest generation first, prefer mini within same generation
- def sort_key(m):
- model_id = m.get("id", "")
- version = parse_version(model_id) or (0,)
- major = version[0] if version else 0
- is_mini = 1 if "mini" in model_id.lower() else 0
- return (major, is_mini, version)
-
- candidates.sort(key=sort_key, reverse=True)
- selected = candidates[0]["id"]
-
- cache.set_cached_model("openai", selected)
-
- return selected
-
-
-def select_xai_model(
- api_key: str,
- policy: str = "latest",
- pin: Optional[str] = None,
- mock_models: Optional[List[Dict]] = None,
-) -> str:
- """Select the best xAI model based on policy.
-
- Args:
- api_key: xAI API key
- policy: 'latest', 'stable', or 'pinned'
- pin: Model to use if policy is 'pinned'
- mock_models: Mock model list for testing
-
- Returns:
- Selected model ID
- """
- if policy == "pinned" and pin:
- return pin
-
- # Use alias system
- if policy in XAI_ALIASES:
- alias = XAI_ALIASES[policy]
-
- # Check cache first
- cached = cache.get_cached_model("xai")
- if cached:
- return cached
-
- # Cache the alias
- cache.set_cached_model("xai", alias)
- return alias
-
- # Default to latest
- return XAI_ALIASES["latest"]
-
-
-def get_models(
- config: Dict,
- mock_openai_models: Optional[List[Dict]] = None,
- mock_xai_models: Optional[List[Dict]] = None,
-) -> Dict[str, Optional[str]]:
- """Get selected models for both providers.
-
- Returns:
- Dict with 'openai' and 'xai' keys
- """
- result = {"openai": None, "xai": None}
-
- if config.get("OPENAI_API_KEY"):
- if config.get("OPENAI_AUTH_SOURCE") == env.AUTH_SOURCE_CODEX:
- # Codex auth doesn't use the OpenAI models list endpoint
- policy = config.get("OPENAI_MODEL_POLICY", "auto")
- pin = config.get("OPENAI_MODEL_PIN")
- if policy == "pinned" and pin:
- result["openai"] = pin
- else:
- result["openai"] = CODEX_FALLBACK_MODELS[0]
- else:
- result["openai"] = select_openai_model(
- config["OPENAI_API_KEY"],
- config.get("OPENAI_MODEL_POLICY", "auto"),
- config.get("OPENAI_MODEL_PIN"),
- mock_openai_models,
- )
-
- if config.get("XAI_API_KEY"):
- result["xai"] = select_xai_model(
- config["XAI_API_KEY"],
- config.get("XAI_MODEL_POLICY", "latest"),
- config.get("XAI_MODEL_PIN"),
- mock_xai_models,
- )
-
- return result
diff --git a/scripts/lib/normalize.py b/scripts/lib/normalize.py
index a15f202..052a934 100644
--- a/scripts/lib/normalize.py
+++ b/scripts/lib/normalize.py
@@ -1,489 +1,442 @@
-"""Normalization of raw API data to canonical schema."""
+"""Normalization of source-specific payloads into the v3 generic item model."""
-from typing import Any, Dict, List, TypeVar, Union
+from __future__ import annotations
+
+from typing import Any
+from urllib.parse import urlparse
from . import dates, schema
-T = TypeVar("T", schema.RedditItem, schema.XItem, schema.WebSearchItem, schema.YouTubeItem, schema.TikTokItem, schema.InstagramItem, schema.HackerNewsItem, schema.BlueskyItem, schema.PolymarketItem)
-
def filter_by_date_range(
- items: List[T],
+ items: list[schema.SourceItem],
from_date: str,
to_date: str,
require_date: bool = False,
-) -> List[T]:
- """Hard filter: Remove items outside the date range.
-
- This is the safety net - even if the prompt lets old content through,
- this filter will exclude it.
-
- Args:
- items: List of items to filter
- from_date: Start date (YYYY-MM-DD) - exclude items before this
- to_date: End date (YYYY-MM-DD) - exclude items after this
- require_date: If True, also remove items with no date
-
- Returns:
- Filtered list with only items in range (or unknown dates if not required)
- """
- result = []
+) -> list[schema.SourceItem]:
+ """Keep only items within the requested window."""
+ filtered: list[schema.SourceItem] = []
for item in items:
- if item.date is None:
+ if not item.published_at:
if not require_date:
- result.append(item) # Keep unknown dates (with scoring penalty)
+ filtered.append(item)
continue
-
- # Hard filter: if date is before from_date, exclude
- if item.date < from_date:
- continue # DROP - too old
-
- # Hard filter: if date is after to_date, exclude (likely parsing error)
- if item.date > to_date:
- continue # DROP - future date
-
- result.append(item)
-
- return result
+ if item.published_at < from_date or item.published_at > to_date:
+ continue
+ filtered.append(item)
+ return filtered
-def normalize_reddit_items(
- items: List[Dict[str, Any]],
+def normalize_source_items(
+ source: str,
+ items: list[dict[str, Any]],
from_date: str,
to_date: str,
-) -> List[schema.RedditItem]:
- """Normalize raw Reddit items to schema.
-
- Args:
- items: Raw Reddit items from API
- from_date: Start of date range
- to_date: End of date range
-
- Returns:
- List of RedditItem objects
- """
- normalized = []
-
- for item in items:
- # Parse engagement
- engagement = None
- eng_raw = item.get("engagement")
- if isinstance(eng_raw, dict):
- engagement = schema.Engagement(
- score=eng_raw.get("score"),
- num_comments=eng_raw.get("num_comments"),
- upvote_ratio=eng_raw.get("upvote_ratio"),
- )
-
- # Parse comments
- top_comments = []
- for c in item.get("top_comments", []):
- top_comments.append(schema.Comment(
- score=c.get("score", 0),
- date=c.get("date"),
- author=c.get("author", ""),
- excerpt=c.get("excerpt", ""),
- url=c.get("url", ""),
- ))
-
- # Determine date confidence
- date_str = item.get("date")
- date_confidence = dates.get_date_confidence(date_str, from_date, to_date)
-
- normalized.append(schema.RedditItem(
- id=item.get("id", ""),
- title=item.get("title", ""),
- url=item.get("url", ""),
- subreddit=item.get("subreddit", ""),
- date=date_str,
- date_confidence=date_confidence,
- engagement=engagement,
- top_comments=top_comments,
- comment_insights=item.get("comment_insights", []),
- relevance=item.get("relevance", 0.5),
- why_relevant=item.get("why_relevant", ""),
- ))
-
- return normalized
+ freshness_mode: str = "balanced_recent",
+) -> list[schema.SourceItem]:
+ """Normalize raw source items, filter by date range, with evergreen fallback for how_to queries."""
+ source = source.lower()
+ normalizers = {
+ "reddit": _normalize_reddit,
+ "x": _normalize_x,
+ "youtube": _normalize_youtube,
+ "tiktok": lambda s, i, idx, fd, td: _normalize_shortform_video(s, i, idx, fd, td, "TK", "TikTok post"),
+ "instagram": lambda s, i, idx, fd, td: _normalize_shortform_video(s, i, idx, fd, td, "IG", "Instagram reel"),
+ "hackernews": _normalize_hackernews,
+ "bluesky": lambda s, i, idx, fd, td: _normalize_microblog(s, i, idx, fd, td, "BS", "Bluesky post"),
+ "truthsocial": lambda s, i, idx, fd, td: _normalize_microblog(s, i, idx, fd, td, "TS", "Truth Social post"),
+ "threads": lambda s, i, idx, fd, td: _normalize_microblog(s, i, idx, fd, td, "TH", "Threads post"),
+ "pinterest": _normalize_pinterest,
+ "polymarket": _normalize_polymarket,
+ "grounding": _normalize_grounding,
+ "xiaohongshu": _normalize_grounding,
+ "github": _normalize_github,
+ "perplexity": _normalize_grounding,
+ }
+ normalizer = normalizers.get(source)
+ if normalizer is None:
+ raise ValueError(f"Unsupported source: {source}")
+ normalized = [normalizer(source, item, index, from_date, to_date) for index, item in enumerate(items)]
+ require_date = source == "grounding"
+ filtered = filter_by_date_range(normalized, from_date, to_date, require_date=require_date)
+ if filtered:
+ return filtered
+ if freshness_mode == "evergreen_ok" and source == "youtube":
+ if require_date:
+ return [item for item in normalized if item.published_at]
+ return normalized
+ return filtered
-def normalize_x_items(
- items: List[Dict[str, Any]],
+def _domain_from_url(url: str) -> str | None:
+ if not url:
+ return None
+ domain = urlparse(url).netloc.strip().lower()
+ return domain or None
+
+
+def _date_confidence(item: dict[str, Any], from_date: str, to_date: str, default: str = "low") -> str:
+ if item.get("date_confidence"):
+ return str(item["date_confidence"])
+ date_value = item.get("date")
+ if not date_value:
+ return default
+ return dates.get_date_confidence(str(date_value), from_date, to_date)
+
+
+def _source_item(
+ *,
+ item_id: str,
+ source: str,
+ title: str,
+ body: str,
+ url: str,
+ published_at: str | None,
+ date_confidence: str,
+ relevance_hint: float,
+ why_relevant: str,
+ author: str | None = None,
+ container: str | None = None,
+ engagement: dict[str, float | int] | None = None,
+ snippet: str = "",
+ metadata: dict[str, Any] | None = None,
+) -> schema.SourceItem:
+ return schema.SourceItem(
+ item_id=item_id,
+ source=source,
+ title=title.strip() or body.strip()[:160] or item_id,
+ body=body.strip(),
+ url=url.strip(),
+ author=(author or "").strip() or None,
+ container=(container or "").strip() or None,
+ published_at=published_at,
+ date_confidence=date_confidence,
+ engagement=engagement or {},
+ relevance_hint=max(0.0, min(1.0, float(relevance_hint or 0.0))),
+ why_relevant=why_relevant.strip(),
+ snippet=snippet.strip(),
+ metadata=metadata or {},
+ )
+
+
+def _normalize_reddit(
+ source: str,
+ item: dict[str, Any],
+ index: int,
from_date: str,
to_date: str,
-) -> List[schema.XItem]:
- """Normalize raw X items to schema.
-
- Args:
- items: Raw X items from API
- from_date: Start of date range
- to_date: End of date range
-
- Returns:
- List of XItem objects
- """
- normalized = []
-
- for item in items:
- # Parse engagement
- engagement = None
- eng_raw = item.get("engagement")
- if isinstance(eng_raw, dict):
- engagement = schema.Engagement(
- likes=eng_raw.get("likes"),
- reposts=eng_raw.get("reposts"),
- replies=eng_raw.get("replies"),
- quotes=eng_raw.get("quotes"),
- )
-
- # Determine date confidence
- date_str = item.get("date")
- date_confidence = dates.get_date_confidence(date_str, from_date, to_date)
-
- normalized.append(schema.XItem(
- id=item.get("id", ""),
- text=item.get("text", ""),
- url=item.get("url", ""),
- author_handle=item.get("author_handle", ""),
- date=date_str,
- date_confidence=date_confidence,
- engagement=engagement,
- relevance=item.get("relevance", 0.5),
- why_relevant=item.get("why_relevant", ""),
- ))
-
- return normalized
+) -> schema.SourceItem:
+ top_comments = item.get("top_comments") or []
+ comment_text = " ".join(
+ str(comment.get("excerpt") or "").strip()
+ for comment in top_comments[:3]
+ if isinstance(comment, dict)
+ )
+ body = "\n".join(
+ part
+ for part in [
+ str(item.get("title") or "").strip(),
+ str(item.get("selftext") or "").strip(),
+ comment_text,
+ ]
+ if part
+ )
+ return _source_item(
+ item_id=str(item.get("id") or f"R{index + 1}"),
+ source=source,
+ title=str(item.get("title") or ""),
+ body=body,
+ url=str(item.get("url") or ""),
+ author=None,
+ container=str(item.get("subreddit") or ""),
+ published_at=item.get("date"),
+ date_confidence=_date_confidence(item, from_date, to_date),
+ engagement=item.get("engagement") or {},
+ relevance_hint=item.get("relevance", 0.5),
+ why_relevant=str(item.get("why_relevant") or ""),
+ snippet=comment_text or str(item.get("selftext") or "")[:400],
+ metadata={
+ "top_comments": top_comments,
+ "comment_insights": item.get("comment_insights") or [],
+ },
+ )
-def normalize_youtube_items(
- items: List[Dict[str, Any]],
+def _normalize_x(
+ source: str,
+ item: dict[str, Any],
+ index: int,
from_date: str,
to_date: str,
-) -> List[schema.YouTubeItem]:
- """Normalize raw YouTube items to schema.
-
- Args:
- items: Raw YouTube items from yt-dlp
- from_date: Start of date range
- to_date: End of date range
-
- Returns:
- List of YouTubeItem objects
- """
- normalized = []
-
- for item in items:
- # Parse engagement
- eng_raw = item.get("engagement") or {}
- engagement = schema.Engagement(
- views=eng_raw.get("views"),
- likes=eng_raw.get("likes"),
- num_comments=eng_raw.get("comments"),
- )
-
- # YouTube dates are reliable (always YYYY-MM-DD from yt-dlp)
- date_str = item.get("date")
-
- normalized.append(schema.YouTubeItem(
- id=item.get("video_id", ""),
- title=item.get("title", ""),
- url=item.get("url", ""),
- channel_name=item.get("channel_name", ""),
- date=date_str,
- date_confidence="high",
- engagement=engagement,
- transcript_snippet=item.get("transcript_snippet", ""),
- transcript_highlights=item.get("transcript_highlights", []),
- relevance=item.get("relevance", 0.7),
- why_relevant=item.get("why_relevant", ""),
- ))
-
- return normalized
+) -> schema.SourceItem:
+ text = str(item.get("text") or "").strip()
+ return _source_item(
+ item_id=str(item.get("id") or f"X{index + 1}"),
+ source=source,
+ title=text[:140] or f"X post {index + 1}",
+ body=text,
+ url=str(item.get("url") or ""),
+ author=str(item.get("author_handle") or "").lstrip("@"),
+ published_at=item.get("date"),
+ date_confidence=_date_confidence(item, from_date, to_date),
+ engagement=item.get("engagement") or {},
+ relevance_hint=item.get("relevance", 0.5),
+ why_relevant=str(item.get("why_relevant") or ""),
+ )
-def normalize_tiktok_items(
- items: List[Dict[str, Any]],
+def _normalize_youtube(
+ source: str,
+ item: dict[str, Any],
+ index: int,
from_date: str,
to_date: str,
-) -> List[schema.TikTokItem]:
- """Normalize raw TikTok items to schema.
-
- Args:
- items: Raw TikTok items from Apify
- from_date: Start of date range
- to_date: End of date range
-
- Returns:
- List of TikTokItem objects
- """
- normalized = []
-
- for i, item in enumerate(items):
- # Parse engagement
- eng_raw = item.get("engagement") or {}
- engagement = schema.Engagement(
- views=eng_raw.get("views"),
- likes=eng_raw.get("likes"),
- num_comments=eng_raw.get("comments"),
- shares=eng_raw.get("shares"),
- )
-
- # TikTok dates are reliable (exact timestamps from Apify)
- date_str = item.get("date")
-
- normalized.append(schema.TikTokItem(
- id=f"TK{i+1}",
- text=item.get("text", ""),
- url=item.get("url", ""),
- author_name=item.get("author_name", ""),
- date=date_str,
- date_confidence="high",
- engagement=engagement,
- caption_snippet=item.get("caption_snippet", ""),
- hashtags=item.get("hashtags", []),
- relevance=item.get("relevance", 0.7),
- why_relevant=item.get("why_relevant", ""),
- ))
-
- return normalized
+) -> schema.SourceItem:
+ transcript = str(item.get("transcript_snippet") or "").strip()
+ description = str(item.get("description") or "").strip()
+ title = str(item.get("title") or "").strip()
+ highlights = item.get("transcript_highlights") or []
+ metadata: dict[str, Any] = {}
+ if highlights:
+ metadata["transcript_highlights"] = highlights
+ return _source_item(
+ item_id=str(item.get("video_id") or item.get("id") or f"YT{index + 1}"),
+ source=source,
+ title=title,
+ body="\n".join(part for part in [title, description, transcript] if part),
+ url=str(item.get("url") or ""),
+ author=str(item.get("channel_name") or ""),
+ published_at=item.get("date"),
+ date_confidence=_date_confidence(item, from_date, to_date, default="high"),
+ engagement=item.get("engagement") or {},
+ relevance_hint=item.get("relevance", 0.5),
+ why_relevant=str(item.get("why_relevant") or ""),
+ snippet=transcript,
+ metadata=metadata,
+ )
-def normalize_instagram_items(
- items: List[Dict[str, Any]],
+def _normalize_shortform_video(
+ source: str,
+ item: dict[str, Any],
+ index: int,
from_date: str,
to_date: str,
-) -> List[schema.InstagramItem]:
- """Normalize raw Instagram items to schema.
-
- Args:
- items: Raw Instagram items from ScrapeCreators
- from_date: Start of date range
- to_date: End of date range
-
- Returns:
- List of InstagramItem objects
- """
- normalized = []
-
- for i, item in enumerate(items):
- # Parse engagement
- eng_raw = item.get("engagement") or {}
- engagement = schema.Engagement(
- views=eng_raw.get("views"),
- likes=eng_raw.get("likes"),
- num_comments=eng_raw.get("comments"),
- )
-
- # Instagram dates are reliable (exact timestamps from ScrapeCreators)
- date_str = item.get("date")
-
- normalized.append(schema.InstagramItem(
- id=f"IG{i+1}",
- text=item.get("text", ""),
- url=item.get("url", ""),
- author_name=item.get("author_name", ""),
- date=date_str,
- date_confidence="high",
- engagement=engagement,
- caption_snippet=item.get("caption_snippet", ""),
- hashtags=item.get("hashtags", []),
- relevance=item.get("relevance", 0.7),
- why_relevant=item.get("why_relevant", ""),
- ))
-
- return normalized
+ id_prefix: str,
+ default_title: str,
+) -> schema.SourceItem:
+ """Shared normalizer for TikTok and Instagram (identical structure)."""
+ caption = str(item.get("caption_snippet") or "").strip()
+ text = str(item.get("text") or "").strip()
+ return _source_item(
+ item_id=str(item.get("id") or f"{id_prefix}{index + 1}"),
+ source=source,
+ title=text[:140] or caption[:140] or f"{default_title} {index + 1}",
+ body="\n".join(part for part in [text, caption] if part),
+ url=str(item.get("url") or ""),
+ author=str(item.get("author_name") or ""),
+ published_at=item.get("date"),
+ date_confidence=_date_confidence(item, from_date, to_date, default="high"),
+ engagement=item.get("engagement") or {},
+ relevance_hint=item.get("relevance", 0.5),
+ why_relevant=str(item.get("why_relevant") or ""),
+ snippet=caption,
+ metadata={"hashtags": item.get("hashtags") or []},
+ )
-def normalize_hackernews_items(
- items: List[Dict[str, Any]],
+def _normalize_pinterest(
+ source: str,
+ item: dict[str, Any],
+ index: int,
from_date: str,
to_date: str,
-) -> List[schema.HackerNewsItem]:
- """Normalize raw Hacker News items to schema.
+) -> schema.SourceItem:
+ """Normalizer for Pinterest pins (visual content with descriptions).
- Args:
- items: Raw HN items from Algolia API
- from_date: Start of date range
- to_date: End of date range
-
- Returns:
- List of HackerNewsItem objects
+ Saves are the primary engagement signal, analogous to likes/upvotes.
"""
- normalized = []
-
- for i, item in enumerate(items):
- # Parse engagement
- eng_raw = item.get("engagement") or {}
- engagement = schema.Engagement(
- score=eng_raw.get("points"),
- num_comments=eng_raw.get("num_comments"),
- )
-
- # Parse comments (from enrichment)
- top_comments = []
- for c in item.get("top_comments", []):
- top_comments.append(schema.Comment(
- score=c.get("points", 0),
- date=None,
- author=c.get("author", ""),
- excerpt=c.get("text", ""),
- url="",
- ))
-
- # HN dates are always high confidence (exact timestamps from Algolia)
- date_str = item.get("date")
-
- normalized.append(schema.HackerNewsItem(
- id=f"HN{i+1}",
- title=item.get("title", ""),
- url=item.get("url", ""),
- hn_url=item.get("hn_url", ""),
- author=item.get("author", ""),
- date=date_str,
- date_confidence="high",
- engagement=engagement,
- top_comments=top_comments,
- comment_insights=item.get("comment_insights", []),
- relevance=item.get("relevance", 0.5),
- why_relevant=item.get("why_relevant", ""),
- ))
-
- return normalized
+ description = str(item.get("description") or "").strip()
+ return _source_item(
+ item_id=str(item.get("pin_id") or item.get("id") or f"PI{index + 1}"),
+ source=source,
+ title=description[:140] or f"Pinterest pin {index + 1}",
+ body=description,
+ url=str(item.get("url") or ""),
+ author=str(item.get("author") or ""),
+ container=str(item.get("board") or ""),
+ published_at=item.get("date"),
+ date_confidence=_date_confidence(item, from_date, to_date, default="low"),
+ engagement=item.get("engagement") or {},
+ relevance_hint=item.get("relevance", 0.5),
+ why_relevant=str(item.get("why_relevant") or ""),
+ snippet=description[:400],
+ )
-def normalize_bluesky_items(
- items: List[Dict[str, Any]],
+def _normalize_hackernews(
+ source: str,
+ item: dict[str, Any],
+ index: int,
from_date: str,
to_date: str,
-) -> List[schema.BlueskyItem]:
- """Normalize raw Bluesky items to schema.
-
- Args:
- items: Raw Bluesky items from AT Protocol API
- from_date: Start of date range
- to_date: End of date range
-
- Returns:
- List of BlueskyItem objects
- """
- normalized = []
-
- for i, item in enumerate(items):
- eng_raw = item.get("engagement") or {}
- engagement = schema.Engagement(
- likes=eng_raw.get("likes"),
- reposts=eng_raw.get("reposts"),
- replies=eng_raw.get("replies"),
- quotes=eng_raw.get("quotes"),
- )
-
- date_str = item.get("date")
-
- normalized.append(schema.BlueskyItem(
- id=f"BS{i+1}",
- text=item.get("text", ""),
- url=item.get("url", ""),
- author_handle=item.get("handle", ""),
- display_name=item.get("display_name", ""),
- date=date_str,
- date_confidence="high",
- engagement=engagement,
- relevance=item.get("relevance", 0.5),
- why_relevant=item.get("why_relevant", ""),
- ))
-
- return normalized
+) -> schema.SourceItem:
+ top_comments = item.get("top_comments") or []
+ comment_text = " ".join(
+ str(comment.get("text") or "").strip()
+ for comment in top_comments[:3]
+ if isinstance(comment, dict)
+ )
+ title = str(item.get("title") or "").strip()
+ body = "\n".join(part for part in [title, str(item.get("text") or "").strip(), comment_text] if part)
+ return _source_item(
+ item_id=str(item.get("id") or f"HN{index + 1}"),
+ source=source,
+ title=title or f"HN story {index + 1}",
+ body=body,
+ url=str(item.get("url") or item.get("hn_url") or ""),
+ author=str(item.get("author") or ""),
+ container="Hacker News",
+ published_at=item.get("date"),
+ date_confidence=_date_confidence(item, from_date, to_date, default="high"),
+ engagement=item.get("engagement") or {},
+ relevance_hint=item.get("relevance", 0.5),
+ why_relevant=str(item.get("why_relevant") or ""),
+ snippet=comment_text,
+ metadata={
+ "hn_url": item.get("hn_url"),
+ "top_comments": top_comments,
+ "comment_insights": item.get("comment_insights") or [],
+ },
+ )
-def normalize_truthsocial_items(
- items: List[Dict[str, Any]],
+def _normalize_microblog(
+ source: str,
+ item: dict[str, Any],
+ index: int,
from_date: str,
to_date: str,
-) -> List[schema.TruthSocialItem]:
- """Normalize raw Truth Social items to schema.
-
- Args:
- items: Raw Truth Social items from Mastodon API
- from_date: Start of date range
- to_date: End of date range
-
- Returns:
- List of TruthSocialItem objects
- """
- normalized = []
-
- for i, item in enumerate(items):
- eng_raw = item.get("engagement") or {}
- engagement = schema.Engagement(
- likes=eng_raw.get("likes"),
- reposts=eng_raw.get("reposts"),
- replies=eng_raw.get("replies"),
- )
-
- date_str = item.get("date")
-
- normalized.append(schema.TruthSocialItem(
- id=f"TS{i+1}",
- text=item.get("text", ""),
- url=item.get("url", ""),
- author_handle=item.get("handle", ""),
- display_name=item.get("display_name", ""),
- date=date_str,
- date_confidence="high",
- engagement=engagement,
- relevance=item.get("relevance", 0.5),
- why_relevant=item.get("why_relevant", ""),
- ))
-
- return normalized
+ id_prefix: str,
+ default_title: str,
+) -> schema.SourceItem:
+ """Shared normalizer for Bluesky and Truth Social (identical structure)."""
+ text = str(item.get("text") or "").strip()
+ return _source_item(
+ item_id=str(item.get("id") or f"{id_prefix}{index + 1}"),
+ source=source,
+ title=text[:140] or f"{default_title} {index + 1}",
+ body=text,
+ url=str(item.get("url") or ""),
+ author=str(item.get("handle") or item.get("author_handle") or "").lstrip("@"),
+ published_at=item.get("date"),
+ date_confidence=_date_confidence(item, from_date, to_date, default="high"),
+ engagement=item.get("engagement") or {},
+ relevance_hint=item.get("relevance", 0.5),
+ why_relevant=str(item.get("why_relevant") or ""),
+ metadata={"display_name": item.get("display_name")},
+ )
-def normalize_polymarket_items(
- items: List[Dict[str, Any]],
+def _normalize_polymarket(
+ source: str,
+ item: dict[str, Any],
+ index: int,
from_date: str,
to_date: str,
-) -> List[schema.PolymarketItem]:
- """Normalize raw Polymarket items to schema.
-
- Args:
- items: Raw Polymarket items from Gamma API
- from_date: Start of date range
- to_date: End of date range
-
- Returns:
- List of PolymarketItem objects
- """
- normalized = []
-
- for i, item in enumerate(items):
- # Prefer volume1mo (more stable) for engagement scoring, fall back to volume24hr
- volume = item.get("volume1mo") or item.get("volume24hr", 0.0)
- engagement = schema.Engagement(
- volume=volume,
- liquidity=item.get("liquidity", 0.0),
- )
-
- date_str = item.get("date")
-
- normalized.append(schema.PolymarketItem(
- id=f"PM{i+1}",
- title=item.get("title", ""),
- question=item.get("question", ""),
- url=item.get("url", ""),
- outcome_prices=item.get("outcome_prices", []),
- outcomes_remaining=item.get("outcomes_remaining", 0),
- price_movement=item.get("price_movement"),
- date=date_str,
- date_confidence="high",
- engagement=engagement,
- end_date=item.get("end_date"),
- relevance=item.get("relevance", 0.5),
- why_relevant=item.get("why_relevant", ""),
- ))
-
- return normalized
+) -> schema.SourceItem:
+ title = str(item.get("title") or "").strip()
+ question = str(item.get("question") or "").strip()
+ engagement = {
+ "volume": item.get("volume1mo") or item.get("volume24hr") or 0,
+ "liquidity": item.get("liquidity") or 0,
+ }
+ return _source_item(
+ item_id=str(item.get("id") or f"PM{index + 1}"),
+ source=source,
+ title=title or question or f"Polymarket event {index + 1}",
+ body="\n".join(part for part in [title, question, str(item.get("price_movement") or "")] if part),
+ url=str(item.get("url") or ""),
+ author=None,
+ container="Polymarket",
+ published_at=item.get("date"),
+ date_confidence=_date_confidence(item, from_date, to_date, default="high"),
+ engagement=engagement,
+ relevance_hint=item.get("relevance", 0.5),
+ why_relevant=str(item.get("why_relevant") or ""),
+ snippet=str(item.get("price_movement") or ""),
+ metadata={
+ "question": question,
+ "end_date": item.get("end_date"),
+ "outcome_prices": item.get("outcome_prices") or [],
+ "outcomes_remaining": item.get("outcomes_remaining"),
+ },
+ )
-def items_to_dicts(items: List) -> List[Dict[str, Any]]:
- """Convert schema items to dicts for JSON serialization."""
- return [item.to_dict() for item in items]
+
+def _normalize_github(
+ source: str,
+ item: dict[str, Any],
+ index: int,
+ from_date: str,
+ to_date: str,
+) -> schema.SourceItem:
+ title = str(item.get("title") or "").strip()
+ snippet_text = str(item.get("snippet") or "").strip()
+ top_comments = item.get("metadata", {}).get("top_comments") or []
+ comment_text = " ".join(
+ str(comment.get("excerpt") or "").strip()
+ for comment in top_comments[:3]
+ if isinstance(comment, dict)
+ )
+ body = "\n".join(part for part in [title, snippet_text, comment_text] if part)
+ metadata = item.get("metadata") or {}
+ return _source_item(
+ item_id=str(item.get("id") or f"GH{index + 1}"),
+ source=source,
+ title=title or f"GitHub item {index + 1}",
+ body=body,
+ url=str(item.get("url") or ""),
+ author=str(item.get("author") or ""),
+ container=str(item.get("container") or ""),
+ published_at=item.get("date"),
+ date_confidence=_date_confidence(item, from_date, to_date, default="high"),
+ engagement=item.get("engagement") or {},
+ relevance_hint=item.get("relevance", 0.5),
+ why_relevant=str(item.get("why_relevant") or ""),
+ snippet=comment_text or snippet_text[:400],
+ metadata={
+ "top_comments": top_comments,
+ "labels": metadata.get("labels") or [],
+ "state": metadata.get("state", ""),
+ "is_pr": metadata.get("is_pr", False),
+ },
+ )
+
+def _normalize_grounding(
+ source: str,
+ item: dict[str, Any],
+ index: int,
+ from_date: str,
+ to_date: str,
+) -> schema.SourceItem:
+ title = str(item.get("title") or "").strip()
+ snippet = str(item.get("snippet") or "").strip()
+ url = str(item.get("url") or "").strip()
+ return _source_item(
+ item_id=str(item.get("id") or f"W{index + 1}"),
+ source=source,
+ title=title or _domain_from_url(url) or f"Web result {index + 1}",
+ body="\n".join(part for part in [title, snippet] if part),
+ url=url,
+ author=None,
+ container=str(item.get("source_domain") or _domain_from_url(url) or ""),
+ published_at=item.get("date"),
+ date_confidence=_date_confidence(item, from_date, to_date),
+ engagement=item.get("engagement") or {},
+ relevance_hint=item.get("relevance", 0.5),
+ why_relevant=str(item.get("why_relevant") or ""),
+ snippet=snippet,
+ metadata=item.get("metadata") or {},
+ )
diff --git a/scripts/lib/openai_reddit.py b/scripts/lib/openai_reddit.py
deleted file mode 100644
index afe4b61..0000000
--- a/scripts/lib/openai_reddit.py
+++ /dev/null
@@ -1,631 +0,0 @@
-"""OpenAI Responses API client for Reddit discovery."""
-
-import json
-import re
-import sys
-from typing import Any, Dict, List, Optional
-
-from . import http, env
-
-# Fallback models when the selected model isn't accessible (e.g., org not verified).
-# Ordered by cost-efficiency: mini models handle structured extraction equally well.
-# Note: gpt-4o-mini does NOT support web_search with filters — excluded.
-MODEL_FALLBACK_ORDER = ["gpt-5-mini", "gpt-4.1-mini", "gpt-4.1", "gpt-4o"]
-
-
-def _log_error(msg: str):
- """Log error to stderr."""
- sys.stderr.write(f"[REDDIT ERROR] {msg}\n")
- sys.stderr.flush()
-
-
-def _log_info(msg: str):
- """Log info to stderr."""
- sys.stderr.write(f"[REDDIT] {msg}\n")
- sys.stderr.flush()
-
-
-def _is_model_access_error(error: http.HTTPError) -> bool:
- """Check if error is due to model access/verification issues."""
- if error.status_code not in (400, 403):
- return False
- if not error.body:
- return False
- body_lower = error.body.lower()
- # Check for common access/verification error messages
- return any(phrase in body_lower for phrase in [
- "verified",
- "organization must be",
- "does not have access",
- "not available",
- "not found",
- ])
-
-
-OPENAI_RESPONSES_URL = "https://api.openai.com/v1/responses"
-CODEX_RESPONSES_URL = "https://chatgpt.com/backend-api/codex/responses"
-CODEX_INSTRUCTIONS = (
- "You are a research assistant for a skill that summarizes what people are "
- "discussing in the last 30 days. Your goal is to find relevant Reddit threads "
- "about the topic and return ONLY the required JSON. Be inclusive (return more "
- "rather than fewer), but avoid irrelevant results. Prefer threads with discussion "
- "and comments. If you can infer a date, include it; otherwise use null. "
- "Do not include developers.reddit.com or business.reddit.com."
-)
-
-
-def _parse_sse_chunk(chunk: str) -> Optional[Dict[str, Any]]:
- """Parse a single SSE chunk into a JSON object."""
- lines = chunk.split("\n")
- data_lines = []
-
- for line in lines:
- if line.startswith("data:"):
- data_lines.append(line[5:].strip())
-
- if not data_lines:
- return None
-
- data = "\n".join(data_lines).strip()
- if not data or data == "[DONE]":
- return None
-
- try:
- return json.loads(data)
- except json.JSONDecodeError:
- return None
-
-
-def _parse_sse_stream_raw(raw: str) -> List[Dict[str, Any]]:
- """Parse SSE stream from raw text and return JSON events."""
- events: List[Dict[str, Any]] = []
- buffer = ""
- for chunk in raw.splitlines(keepends=True):
- buffer += chunk
- while "\n\n" in buffer:
- event_chunk, buffer = buffer.split("\n\n", 1)
- event = _parse_sse_chunk(event_chunk)
- if event is not None:
- events.append(event)
- if buffer.strip():
- event = _parse_sse_chunk(buffer)
- if event is not None:
- events.append(event)
- return events
-
-
-def _parse_codex_stream(raw: str) -> Dict[str, Any]:
- """Parse SSE stream from Codex responses into a response-like dict."""
- events = _parse_sse_stream_raw(raw)
-
- # Prefer explicit completed response payload if present
- for evt in reversed(events):
- if isinstance(evt, dict):
- if evt.get("type") == "response.completed" and isinstance(evt.get("response"), dict):
- return evt["response"]
- if isinstance(evt.get("response"), dict):
- return evt["response"]
-
- # Fallback: reconstruct output text from deltas
- output_text = ""
- for evt in events:
- if not isinstance(evt, dict):
- continue
- delta = evt.get("delta")
- if isinstance(delta, str):
- output_text += delta
- continue
- text = evt.get("text")
- if isinstance(text, str):
- output_text += text
-
- if output_text:
- return {
- "output": [
- {
- "type": "message",
- "content": [{"type": "output_text", "text": output_text}],
- }
- ]
- }
-
- return {}
-
-# Depth configurations: (min, max) threads to request
-# Request MORE than needed since many get filtered by date
-DEPTH_CONFIG = {
- "quick": (15, 25),
- "default": (30, 50),
- "deep": (70, 100),
-}
-
-REDDIT_SEARCH_PROMPT = """Find Reddit discussion threads about: {topic}
-
-STEP 1: EXTRACT THE CORE SUBJECT
-Get the MAIN NOUN/PRODUCT/TOPIC:
-- "best nano banana prompting practices" → "nano banana"
-- "killer features of clawdbot" → "clawdbot"
-- "top Claude Code skills" → "Claude Code"
-DO NOT include "best", "top", "tips", "practices", "features" in your search.
-
-STEP 2: SEARCH BROADLY
-Search for the core subject:
-1. "[core subject] site:reddit.com"
-2. "reddit [core subject]"
-3. "[core subject] reddit"
-
-Return as many relevant threads as you find. We filter by date server-side.
-
-STEP 3: INCLUDE ALL MATCHES
-- Include ALL threads about the core subject
-- Set date to "YYYY-MM-DD" if you can determine it, otherwise null
-- We verify dates and filter old content server-side
-- DO NOT pre-filter aggressively - include anything relevant
-
-REQUIRED: URLs must contain "/r/" AND "/comments/"
-REJECT: developers.reddit.com, business.reddit.com
-
-Find {min_items}-{max_items} threads. Return MORE rather than fewer.
-
-Return JSON:
-{{
- "items": [
- {{
- "title": "Thread title",
- "url": "https://www.reddit.com/r/sub/comments/xyz/title/",
- "subreddit": "subreddit_name",
- "date": "YYYY-MM-DD or null",
- "why_relevant": "Why relevant",
- "relevance": 0.85
- }}
- ]
-}}"""
-
-
-def _extract_core_subject(topic: str) -> str:
- """Extract core subject from verbose query for retry."""
- noise = ['best', 'top', 'how to', 'tips for', 'practices', 'features',
- 'killer', 'guide', 'tutorial', 'recommendations', 'advice',
- 'prompting', 'using', 'for', 'with', 'the', 'of', 'in', 'on']
- words = topic.lower().split()
- result = [w for w in words if w not in noise]
- return ' '.join(result[:3]) or topic # Keep max 3 words
-
-
-def _build_subreddit_query(topic: str) -> str:
- """Build a subreddit-targeted search query for fallback.
-
- When standard search returns few results, try searching for the
- subreddit itself: 'r/kanye', 'r/howie', etc.
- """
- core = _extract_core_subject(topic)
- # Remove dots and special chars for subreddit name guess
- sub_name = core.replace('.', '').replace(' ', '').lower()
- return f"r/{sub_name} site:reddit.com"
-
-
-def _build_payload(model: str, instructions_text: str, input_text: str, auth_source: str) -> Dict[str, Any]:
- """Build responses payload for OpenAI or Codex endpoints."""
- payload = {
- "model": model,
- "store": False,
- "tools": [
- {
- "type": "web_search",
- "filters": {
- "allowed_domains": ["reddit.com"]
- }
- }
- ],
- "include": ["web_search_call.action.sources"],
- "instructions": instructions_text,
- "input": input_text,
- }
- if auth_source == env.AUTH_SOURCE_CODEX:
- payload["input"] = [
- {
- "type": "message",
- "role": "user",
- "content": [{"type": "input_text", "text": input_text}],
- }
- ]
- payload["stream"] = True
- return payload
-
-
-def search_reddit(
- api_key: str,
- model: str,
- topic: str,
- from_date: str,
- to_date: str,
- depth: str = "default",
- auth_source: str = "api_key",
- account_id: Optional[str] = None,
- mock_response: Optional[Dict] = None,
- _retry: bool = False,
-) -> Dict[str, Any]:
- """Search Reddit for relevant threads using OpenAI Responses API.
-
- Args:
- api_key: OpenAI API key
- model: Model to use
- topic: Search topic
- from_date: Start date (YYYY-MM-DD) - only include threads after this
- to_date: End date (YYYY-MM-DD) - only include threads before this
- depth: Research depth - "quick", "default", or "deep"
- mock_response: Mock response for testing
-
- Returns:
- Raw API response
- """
- if mock_response is not None:
- return mock_response
-
- min_items, max_items = DEPTH_CONFIG.get(depth, DEPTH_CONFIG["default"])
-
- if auth_source == env.AUTH_SOURCE_CODEX:
- if not account_id:
- raise ValueError("Missing chatgpt_account_id for Codex auth")
- headers = {
- "Authorization": f"Bearer {api_key}",
- "chatgpt-account-id": account_id,
- "OpenAI-Beta": "responses=experimental",
- "originator": "pi",
- "Content-Type": "application/json",
- }
- url = CODEX_RESPONSES_URL
- else:
- headers = {
- "Authorization": f"Bearer {api_key}",
- "Content-Type": "application/json",
- }
- url = OPENAI_RESPONSES_URL
-
- # Adjust timeout based on depth (generous for OpenAI web_search which can be slow)
- timeout = 90 if depth == "quick" else 120 if depth == "default" else 180
-
- # Build list of models to try: requested model first, then fallbacks
- models_to_try = [model] + [m for m in MODEL_FALLBACK_ORDER if m != model]
-
- # Note: allowed_domains accepts base domain, not subdomains
- # We rely on prompt to filter out developers.reddit.com, etc.
- input_text = REDDIT_SEARCH_PROMPT.format(
- topic=topic,
- from_date=from_date,
- to_date=to_date,
- min_items=min_items,
- max_items=max_items,
- )
-
- if auth_source == env.AUTH_SOURCE_CODEX:
- # Codex auth: try model with fallback chain
- from . import models as models_mod
- codex_models_to_try = [model] + [m for m in models_mod.CODEX_FALLBACK_MODELS if m != model]
- instructions_text = CODEX_INSTRUCTIONS + "\n\n" + input_text
- last_error = None
- for current_model in codex_models_to_try:
- try:
- payload = _build_payload(current_model, instructions_text, topic, auth_source)
- raw = http.post_raw(url, payload, headers=headers, timeout=timeout)
- return _parse_codex_stream(raw or "")
- except http.HTTPError as e:
- last_error = e
- if e.status_code == 400:
- _log_info(f"Model {current_model} not supported on Codex, trying fallback...")
- continue
- raise
- if last_error:
- raise last_error
- raise http.HTTPError("No Codex-compatible models available")
-
- # Standard API key auth: try model fallback chain
- last_error = None
- for current_model in models_to_try:
- payload = {
- "model": current_model,
- "tools": [
- {
- "type": "web_search",
- "filters": {
- "allowed_domains": ["reddit.com"]
- }
- }
- ],
- "include": ["web_search_call.action.sources"],
- "input": input_text,
- }
-
- try:
- return http.post(url, payload, headers=headers, timeout=timeout)
- except http.HTTPError as e:
- last_error = e
- if _is_model_access_error(e):
- _log_info(f"Model {current_model} not accessible, trying fallback...")
- continue
- if e.status_code == 429:
- _log_info(f"Rate limited on {current_model}, trying fallback model...")
- continue
- # Non-access error, don't retry with different model
- raise
-
- # All models failed with access errors
- if last_error:
- _log_error(f"All models failed. Last error: {last_error}")
- raise last_error
- raise http.HTTPError("No models available")
-
-
-def _public_relevance(score: int, num_comments: int) -> float:
- """Estimate relevance for public Reddit search results."""
- # Lightweight heuristic: blend normalized score + comments.
- score_component = min(1.0, max(0.0, score / 500.0))
- comments_component = min(1.0, max(0.0, num_comments / 200.0))
- return round((score_component * 0.6) + (comments_component * 0.4), 3)
-
-
-def search_reddit_public(
- topic: str,
- from_date: str,
- to_date: str,
- depth: str = "default",
-) -> List[Dict[str, Any]]:
- """Search Reddit directly via public JSON endpoint (no OpenAI key required).
-
- This is a fallback mode for environments where OpenAI auth is unavailable.
- It uses reddit.com/search/.json with recency filter (t=month).
- """
- _, max_items = DEPTH_CONFIG.get(depth, DEPTH_CONFIG["default"])
- limit = min(100, max(20, max_items))
-
- core = _extract_core_subject(topic)
- queries = [topic]
- if core and core.lower() != topic.lower():
- queries.append(core)
- queries.append(f'"{core}"')
-
- seen_urls = set()
- all_items: List[Dict[str, Any]] = []
-
- headers = {
- "User-Agent": http.USER_AGENT,
- "Accept": "application/json",
- }
-
- for query in queries:
- try:
- url = (
- "https://www.reddit.com/search/.json"
- f"?q={_url_encode(query)}&sort=new&t=month&limit={limit}&raw_json=1"
- )
- data = http.get(url, headers=headers, timeout=20, retries=2)
- children = data.get("data", {}).get("children", [])
- for child in children:
- if child.get("kind") != "t3":
- continue
- post = child.get("data", {})
- permalink = str(post.get("permalink", "")).strip()
- if not permalink or "/comments/" not in permalink:
- continue
-
- full_url = f"https://www.reddit.com{permalink}"
- if full_url in seen_urls:
- continue
- seen_urls.add(full_url)
-
- score = int(post.get("score", 0) or 0)
- num_comments = int(post.get("num_comments", 0) or 0)
-
- # Parse date from created_utc
- created_utc = post.get("created_utc")
- date_value = None
- if created_utc:
- from . import dates as dates_mod
- date_value = dates_mod.timestamp_to_date(created_utc)
-
- all_items.append({
- "id": f"R{len(all_items)+1}",
- "title": str(post.get("title", "")).strip(),
- "url": full_url,
- "subreddit": str(post.get("subreddit", "")).strip(),
- "date": date_value,
- "why_relevant": "Found via Reddit public search",
- "relevance": _public_relevance(score, num_comments),
- "engagement": {
- "score": score,
- "num_comments": num_comments,
- "upvote_ratio": post.get("upvote_ratio"),
- },
- })
-
- except http.HTTPError as e:
- _log_info(f"Public Reddit search failed for query '{query}': {e}")
- # Continue with next query; partial results are still useful.
- continue
- except Exception as e:
- _log_info(f"Public Reddit search error for query '{query}': {e}")
- continue
-
- # Sort by date (desc, unknown dates last), then relevance desc
- def _sort_key(item: Dict[str, Any]):
- date_str = item.get("date") or ""
- return (date_str, float(item.get("relevance", 0.0)))
-
- all_items.sort(key=_sort_key, reverse=True)
- return all_items[: max_items * 2]
-
-
-def search_subreddits(
- subreddits: List[str],
- topic: str,
- from_date: str,
- to_date: str,
- count_per: int = 5,
-) -> List[Dict[str, Any]]:
- """Search specific subreddits via Reddit's free JSON endpoint.
-
- No API key needed. Uses reddit.com/r/{sub}/search/.json endpoint.
- Used in Phase 2 supplemental search after entity extraction.
-
- Args:
- subreddits: List of subreddit names (without r/)
- topic: Search topic
- from_date: Start date (YYYY-MM-DD)
- to_date: End date (YYYY-MM-DD)
- count_per: Results to request per subreddit
-
- Returns:
- List of raw item dicts (same format as parse_reddit_response output).
- """
- all_items = []
- core = _extract_core_subject(topic)
-
- for sub in subreddits:
- sub = sub.lstrip("r/")
- try:
- url = f"https://www.reddit.com/r/{sub}/search/.json"
- params = f"q={_url_encode(core)}&restrict_sr=on&sort=new&limit={count_per}&raw_json=1"
- full_url = f"{url}?{params}"
-
- headers = {
- "User-Agent": http.USER_AGENT,
- "Accept": "application/json",
- }
-
- data = http.get(full_url, headers=headers, timeout=15, retries=1)
-
- # Reddit search returns {"data": {"children": [...]}}
- children = data.get("data", {}).get("children", [])
- for i, child in enumerate(children):
- if child.get("kind") != "t3": # t3 = link/submission
- continue
- post = child.get("data", {})
- permalink = post.get("permalink", "")
- if not permalink:
- continue
-
- item = {
- "id": f"RS{len(all_items)+1}",
- "title": str(post.get("title", "")).strip(),
- "url": f"https://www.reddit.com{permalink}",
- "subreddit": str(post.get("subreddit", sub)).strip(),
- "date": None,
- "why_relevant": f"Found in r/{sub} supplemental search",
- "relevance": 0.65, # Slightly lower default for supplemental
- }
-
- # Parse date from created_utc
- created_utc = post.get("created_utc")
- if created_utc:
- from . import dates as dates_mod
- item["date"] = dates_mod.timestamp_to_date(created_utc)
-
- all_items.append(item)
-
- except http.HTTPError as e:
- _log_info(f"Subreddit search failed for r/{sub}: {e}")
- if e.status_code == 429:
- _log_info("Reddit rate-limited (429) — skipping remaining subreddits")
- break
- except Exception as e:
- _log_info(f"Subreddit search error for r/{sub}: {e}")
-
- return all_items
-
-
-def _url_encode(text: str) -> str:
- """Simple URL encoding for query parameters."""
- import urllib.parse
- return urllib.parse.quote_plus(text)
-
-
-def parse_reddit_response(response: Dict[str, Any]) -> List[Dict[str, Any]]:
- """Parse OpenAI response to extract Reddit items.
-
- Args:
- response: Raw API response
-
- Returns:
- List of item dicts
- """
- items = []
-
- # Check for API errors first
- if "error" in response and response["error"]:
- error = response["error"]
- err_msg = error.get("message", str(error)) if isinstance(error, dict) else str(error)
- _log_error(f"OpenAI API error: {err_msg}")
- if http.DEBUG:
- _log_error(f"Full error response: {json.dumps(response, indent=2)[:1000]}")
- return items
-
- # Try to find the output text
- output_text = ""
- if "output" in response:
- output = response["output"]
- if isinstance(output, str):
- output_text = output
- elif isinstance(output, list):
- for item in output:
- if isinstance(item, dict):
- if item.get("type") == "message":
- content = item.get("content", [])
- for c in content:
- if isinstance(c, dict) and c.get("type") == "output_text":
- output_text = c.get("text", "")
- break
- elif "text" in item:
- output_text = item["text"]
- elif isinstance(item, str):
- output_text = item
- if output_text:
- break
-
- # Also check for choices (older format)
- if not output_text and "choices" in response:
- for choice in response["choices"]:
- if "message" in choice:
- output_text = choice["message"].get("content", "")
- break
-
- if not output_text:
- print(f"[REDDIT WARNING] No output text found in OpenAI response. Keys present: {list(response.keys())}", flush=True)
- return items
-
- # Extract JSON from the response
- json_match = re.search(r'\{[\s\S]*"items"[\s\S]*\}', output_text)
- if json_match:
- try:
- data = json.loads(json_match.group())
- items = data.get("items", [])
- except json.JSONDecodeError:
- pass
-
- # Validate and clean items
- clean_items = []
- for i, item in enumerate(items):
- if not isinstance(item, dict):
- continue
-
- url = item.get("url", "")
- if not url or "reddit.com" not in url:
- continue
-
- clean_item = {
- "id": f"R{i+1}",
- "title": str(item.get("title", "")).strip(),
- "url": url,
- "subreddit": str(item.get("subreddit", "")).strip().lstrip("r/"),
- "date": item.get("date"),
- "why_relevant": str(item.get("why_relevant", "")).strip(),
- "relevance": min(1.0, max(0.0, float(item.get("relevance", 0.5)))),
- }
-
- # Validate date format
- if clean_item["date"]:
- if not re.match(r'^\d{4}-\d{2}-\d{2}$', str(clean_item["date"])):
- clean_item["date"] = None
-
- clean_items.append(clean_item)
-
- return clean_items
diff --git a/scripts/lib/openrouter_search.py b/scripts/lib/openrouter_search.py
deleted file mode 100644
index 5342c2f..0000000
--- a/scripts/lib/openrouter_search.py
+++ /dev/null
@@ -1,216 +0,0 @@
-"""Perplexity Sonar Pro web search via OpenRouter for last30days skill.
-
-Uses OpenRouter's chat completions API with Perplexity's Sonar Pro model,
-which has built-in web search and returns citations with URLs, titles, and dates.
-This is the recommended web search backend -- highest quality results.
-
-API docs: https://openrouter.ai/docs/quickstart
-Model: perplexity/sonar-pro
-"""
-
-import re
-import sys
-from typing import Any, Dict, List, Optional
-from urllib.parse import urlparse
-
-from . import http
-
-ENDPOINT = "https://openrouter.ai/api/v1/chat/completions"
-MODEL = "perplexity/sonar-pro"
-
-# Domains to exclude (handled by Reddit/X search)
-EXCLUDED_DOMAINS = {
- "reddit.com", "www.reddit.com", "old.reddit.com",
- "twitter.com", "www.twitter.com", "x.com", "www.x.com",
-}
-
-
-def search_web(
- topic: str,
- from_date: str,
- to_date: str,
- api_key: str,
- depth: str = "default",
-) -> List[Dict[str, Any]]:
- """Search the web via Perplexity Sonar Pro on OpenRouter.
-
- Args:
- topic: Search topic
- from_date: Start date (YYYY-MM-DD)
- to_date: End date (YYYY-MM-DD)
- api_key: OpenRouter API key
- depth: 'quick', 'default', or 'deep'
-
- Returns:
- List of result dicts with keys: url, title, snippet, source_domain, date, relevance
-
- Raises:
- http.HTTPError: On API errors
- """
- max_tokens = {"quick": 1024, "default": 2048, "deep": 4096}.get(depth, 2048)
-
- prompt = (
- f"Find recent blog posts, news articles, tutorials, and discussions "
- f"about {topic} published between {from_date} and {to_date}. "
- f"Exclude results from reddit.com, x.com, and twitter.com. "
- f"For each result, provide the title, URL, publication date, "
- f"and a brief summary of why it's relevant."
- )
-
- payload = {
- "model": MODEL,
- "messages": [{"role": "user", "content": prompt}],
- "max_tokens": max_tokens,
- }
-
- sys.stderr.write(f"[Web] Searching Sonar Pro via OpenRouter for: {topic}\n")
- sys.stderr.flush()
-
- response = http.post(
- ENDPOINT,
- json_data=payload,
- headers={
- "Authorization": f"Bearer {api_key}",
- "HTTP-Referer": "https://github.com/mvanhorn/last30days-openclaw",
- "X-Title": "last30days",
- },
- timeout=30,
- )
-
- return _normalize_results(response)
-
-
-def _normalize_results(response: Dict[str, Any]) -> List[Dict[str, Any]]:
- """Convert Sonar Pro response to websearch item schema.
-
- Sonar Pro returns:
- - search_results: [{title, url, date}] -- structured source metadata
- - citations: [url, ...] -- flat list of cited URLs
- - choices[0].message.content -- the synthesized text with [N] references
-
- We prefer search_results (richer metadata), fall back to citations.
- """
- items = []
-
- # Try search_results first (has title, url, date)
- search_results = response.get("search_results", [])
- if isinstance(search_results, list) and search_results:
- items = _parse_search_results(search_results)
-
- # Fall back to citations if no search_results
- if not items:
- citations = response.get("citations", [])
- content = _get_content(response)
- if isinstance(citations, list) and citations:
- items = _parse_citations(citations, content)
-
- sys.stderr.write(f"[Web] Sonar Pro: {len(items)} results\n")
- sys.stderr.flush()
-
- return items
-
-
-def _parse_search_results(results: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
- """Parse the search_results array from Sonar Pro."""
- items = []
-
- for i, result in enumerate(results):
- if not isinstance(result, dict):
- continue
-
- url = result.get("url", "")
- if not url:
- continue
-
- # Skip excluded domains
- try:
- domain = urlparse(url).netloc.lower()
- if domain in EXCLUDED_DOMAINS:
- continue
- if domain.startswith("www."):
- domain = domain[4:]
- except Exception:
- domain = ""
-
- title = str(result.get("title", "")).strip()
- if not title:
- continue
-
- # Sonar Pro provides dates in search_results
- date = result.get("date")
- date_confidence = "med" if date else "low"
-
- items.append({
- "id": f"W{i+1}",
- "title": title[:200],
- "url": url,
- "source_domain": domain,
- "snippet": str(result.get("snippet", result.get("description", ""))).strip()[:500],
- "date": date,
- "date_confidence": date_confidence,
- "relevance": 0.7, # Sonar Pro results are generally high quality
- "why_relevant": "",
- })
-
- return items
-
-
-def _parse_citations(citations: List[str], content: str) -> List[Dict[str, Any]]:
- """Parse the flat citations array, enriching with content context."""
- items = []
-
- for i, url in enumerate(citations):
- if not isinstance(url, str) or not url:
- continue
-
- # Skip excluded domains
- try:
- domain = urlparse(url).netloc.lower()
- if domain in EXCLUDED_DOMAINS:
- continue
- if domain.startswith("www."):
- domain = domain[4:]
- except Exception:
- domain = ""
-
- # Try to extract title from content references like [1] Title...
- title = _extract_title_for_citation(content, i + 1) or domain
-
- items.append({
- "id": f"W{i+1}",
- "title": title[:200],
- "url": url,
- "source_domain": domain,
- "snippet": "",
- "date": None,
- "date_confidence": "low",
- "relevance": 0.6,
- "why_relevant": "",
- })
-
- return items
-
-
-def _get_content(response: Dict[str, Any]) -> str:
- """Extract the text content from the chat completion response."""
- try:
- return response["choices"][0]["message"]["content"]
- except (KeyError, IndexError, TypeError):
- return ""
-
-
-def _extract_title_for_citation(content: str, index: int) -> Optional[str]:
- """Try to extract a title near a citation reference [N] in the content."""
- if not content:
- return None
-
- # Look for patterns like [1] Title or [1](url) Title
- pattern = rf'\[{index}\][)\s]*([^\[\n]{{5,80}})'
- match = re.search(pattern, content)
- if match:
- title = match.group(1).strip().rstrip('.')
- # Clean up markdown artifacts
- title = re.sub(r'[*_`]', '', title)
- return title if len(title) > 3 else None
-
- return None
diff --git a/scripts/lib/parallel_search.py b/scripts/lib/parallel_search.py
deleted file mode 100644
index 84b20c5..0000000
--- a/scripts/lib/parallel_search.py
+++ /dev/null
@@ -1,139 +0,0 @@
-"""Parallel AI web search for last30days skill.
-
-Uses the Parallel AI Search API to find web content (blogs, docs, news, tutorials).
-This is the preferred web search backend -- it returns LLM-optimized results
-with extended excerpts ranked by relevance.
-
-API docs: https://docs.parallel.ai/search-api/search-quickstart
-"""
-
-import json
-import sys
-from typing import Any, Dict, List, Optional
-from urllib.parse import urlparse
-
-from . import http
-
-ENDPOINT = "https://api.parallel.ai/v1beta/search"
-
-# Domains to exclude (handled by Reddit/X search)
-EXCLUDED_DOMAINS = {
- "reddit.com", "www.reddit.com", "old.reddit.com",
- "twitter.com", "www.twitter.com", "x.com", "www.x.com",
-}
-
-
-def search_web(
- topic: str,
- from_date: str,
- to_date: str,
- api_key: str,
- depth: str = "default",
-) -> List[Dict[str, Any]]:
- """Search the web via Parallel AI Search API.
-
- Args:
- topic: Search topic
- from_date: Start date (YYYY-MM-DD)
- to_date: End date (YYYY-MM-DD)
- api_key: Parallel AI API key
- depth: 'quick', 'default', or 'deep'
-
- Returns:
- List of result dicts with keys: url, title, snippet, source_domain, date, relevance
-
- Raises:
- http.HTTPError: On API errors
- """
- max_results = {"quick": 8, "default": 15, "deep": 25}.get(depth, 15)
-
- payload = {
- "objective": (
- f"Find recent blog posts, tutorials, news articles, and discussions "
- f"about {topic} from {from_date} to {to_date}. "
- f"Exclude reddit.com, x.com, and twitter.com."
- ),
- "max_results": max_results,
- "max_chars_per_result": 500,
- }
-
- sys.stderr.write(f"[Web] Searching Parallel AI for: {topic}\n")
- sys.stderr.flush()
-
- response = http.post(
- ENDPOINT,
- json_data=payload,
- headers={
- "Authorization": f"Bearer {api_key}",
- "parallel-beta": "search-extract-2025-10-10",
- },
- timeout=30,
- )
-
- return _normalize_results(response)
-
-
-def _normalize_results(response: Dict[str, Any]) -> List[Dict[str, Any]]:
- """Convert Parallel AI response to websearch item schema.
-
- Args:
- response: Raw API response
-
- Returns:
- List of normalized result dicts
- """
- items = []
-
- # Handle different response shapes
- results = response.get("results", [])
- if not isinstance(results, list):
- return items
-
- for i, result in enumerate(results):
- if not isinstance(result, dict):
- continue
-
- url = result.get("url", "")
- if not url:
- continue
-
- # Skip excluded domains
- try:
- domain = urlparse(url).netloc.lower()
- if domain in EXCLUDED_DOMAINS:
- continue
- # Clean domain for display
- if domain.startswith("www."):
- domain = domain[4:]
- except Exception:
- domain = ""
-
- title = str(result.get("title", "")).strip()
- snippet = str(result.get("excerpt", result.get("snippet", result.get("description", "")))).strip()
-
- if not title and not snippet:
- continue
-
- # Extract relevance score if provided
- relevance = result.get("relevance_score", result.get("relevance", 0.6))
- try:
- relevance = min(1.0, max(0.0, float(relevance)))
- except (TypeError, ValueError):
- relevance = 0.6
-
- items.append({
- "id": f"W{i+1}",
- "title": title[:200],
- "url": url,
- "source_domain": domain,
- "snippet": snippet[:500],
- "date": result.get("published_date", result.get("date")),
- "date_confidence": "med" if result.get("published_date") or result.get("date") else "low",
- "relevance": relevance,
- "why_relevant": str(result.get("summary", "")).strip()[:200],
- })
-
- sys.stderr.write(f"[Web] Parallel AI: {len(items)} results\n")
- sys.stderr.flush()
-
- return items
diff --git a/scripts/lib/perplexity.py b/scripts/lib/perplexity.py
new file mode 100644
index 0000000..6a44b88
--- /dev/null
+++ b/scripts/lib/perplexity.py
@@ -0,0 +1,164 @@
+"""Perplexity Sonar Pro / Deep Research via OpenRouter API.
+
+Queries Perplexity models through OpenRouter for AI-synthesized research
+with citation annotations. Returns normalized items with synthesis text
+and individual citation entries.
+"""
+
+from __future__ import annotations
+
+import sys
+from urllib.parse import urlparse
+
+from . import http, log
+
+
+OPENROUTER_URL = "https://openrouter.ai/api/v1/chat/completions"
+
+MODEL_SONAR_PRO = "perplexity/sonar-pro"
+MODEL_DEEP_RESEARCH = "perplexity/sonar-deep-research"
+
+
+def _log(msg: str):
+ log.source_log("Perplexity", msg)
+
+
+def _domain(url: str) -> str:
+ return urlparse(url).netloc.strip().lower()
+
+
+def search(
+ query: str,
+ date_range: tuple[str, str],
+ config: dict,
+ deep: bool = False,
+) -> tuple[list[dict], dict]:
+ """Search via Perplexity Sonar Pro or Deep Research through OpenRouter.
+
+ Args:
+ query: Search topic
+ date_range: (from_date, to_date) as YYYY-MM-DD strings
+ config: Must contain OPENROUTER_API_KEY
+ deep: Use Deep Research model (~$0.90/query) instead of Sonar Pro
+
+ Returns:
+ Tuple of (items list, artifact dict).
+ """
+ api_key = config.get("OPENROUTER_API_KEY")
+ if not api_key:
+ _log("No OPENROUTER_API_KEY configured, skipping")
+ return [], {}
+
+ from_date, to_date = date_range
+ model = MODEL_DEEP_RESEARCH if deep else MODEL_SONAR_PRO
+ timeout = 120 if deep else 30
+
+ if deep:
+ print("[Perplexity] Using Deep Research (~$0.90/query)", file=sys.stderr)
+
+ prompt = (
+ f"What has been happening with {query} between {from_date} and {to_date}? "
+ "Include specific dates, names, numbers, and sources."
+ )
+
+ headers = {
+ "Authorization": f"Bearer {api_key}",
+ "Content-Type": "application/json",
+ }
+
+ json_data = {
+ "model": model,
+ "messages": [{"role": "user", "content": prompt}],
+ }
+
+ _log(f"Querying {model} for '{query}' ({from_date} to {to_date})")
+
+ try:
+ data = http.post(OPENROUTER_URL, json_data, headers=headers, timeout=timeout)
+ except http.HTTPError as e:
+ if e.status_code == 401:
+ _log("Invalid OpenRouter API key (401)")
+ elif e.status_code == 429:
+ _log("Rate limited by OpenRouter (429)")
+ else:
+ _log(f"HTTP error: {e}")
+ return [], {}
+ except Exception as e:
+ _log(f"Request failed: {e}")
+ return [], {}
+
+ # Parse response
+ choices = data.get("choices", [])
+ if not choices:
+ _log("No choices in response")
+ return [], {}
+
+ synthesis = choices[0].get("message", {}).get("content", "")
+ if not synthesis:
+ _log("Empty synthesis content")
+ return [], {}
+
+ # Extract citations from annotations
+ annotations = choices[0].get("message", {}).get("annotations", [])
+ citations = []
+ for ann in annotations:
+ url_citation = ann.get("url_citation", {})
+ url = url_citation.get("url", "")
+ title = url_citation.get("title", "")
+ if url:
+ citations.append({"url": url, "title": title})
+
+ # Deduplicate citations by URL
+ seen_urls = set()
+ unique_citations = []
+ for c in citations:
+ if c["url"] not in seen_urls:
+ seen_urls.add(c["url"])
+ unique_citations.append(c)
+ citations = unique_citations
+
+ _log(f"Got synthesis ({len(synthesis)} chars) with {len(citations)} citations")
+
+ # Build items list
+ items = []
+
+ # Primary item: the synthesis itself
+ snippet = synthesis[:2000]
+ items.append({
+ "id": "PX1",
+ "title": f"Perplexity {'Deep Research' if deep else 'Sonar Pro'}: {query}",
+ "url": "",
+ "source_domain": "perplexity.ai",
+ "snippet": snippet,
+ "date": to_date,
+ "relevance": 0.9,
+ "why_relevant": f"AI synthesis of recent activity for '{query}'",
+ "engagement": {"citations": len(citations)},
+ "metadata": {"citations": citations},
+ })
+
+ # Individual items for each citation
+ for i, cit in enumerate(citations):
+ items.append({
+ "id": f"PX{i + 2}",
+ "title": cit["title"] or _domain(cit["url"]),
+ "url": cit["url"],
+ "source_domain": _domain(cit["url"]),
+ "snippet": "",
+ "date": None,
+ "relevance": 0.7,
+ "why_relevant": f"Cited in Perplexity synthesis for '{query}'",
+ "engagement": {"citations": 1},
+ "metadata": {"citations": [cit]},
+ })
+
+ artifact = {
+ "label": "perplexity",
+ "model": model,
+ "deep": deep,
+ "query": query,
+ "synthesisLength": len(synthesis),
+ "citationCount": len(citations),
+ }
+
+ return items, artifact
diff --git a/scripts/lib/pinterest.py b/scripts/lib/pinterest.py
new file mode 100644
index 0000000..ec4aad9
--- /dev/null
+++ b/scripts/lib/pinterest.py
@@ -0,0 +1,190 @@
+"""Pinterest search via ScrapeCreators API for /last30days.
+
+Uses ScrapeCreators REST API to search Pinterest by keyword, extract
+engagement metrics (saves, comments), and return pin descriptions.
+
+Requires SCRAPECREATORS_API_KEY in config. 100 free API calls, then PAYG.
+API docs: https://scrapecreators.com/docs
+"""
+
+import re
+import sys
+from typing import Any, Dict, List, Optional, Set
+
+try:
+ import requests as _requests
+except ImportError:
+ _requests = None
+
+from . import dates, http, log
+
+SCRAPECREATORS_BASE = "https://api.scrapecreators.com/v1/pinterest"
+
+# Depth configurations: how many results to fetch
+DEPTH_CONFIG = {
+ "quick": {"results_per_page": 10},
+ "default": {"results_per_page": 20},
+ "deep": {"results_per_page": 40},
+}
+
+from .relevance import token_overlap_relevance as _compute_relevance
+
+
+def _extract_core_subject(topic: str) -> str:
+ """Extract core subject from verbose query for Pinterest search."""
+ from .query import extract_core_subject
+ _PINTEREST_NOISE = frozenset({
+ 'best', 'top', 'good', 'great', 'awesome', 'killer',
+ 'latest', 'new', 'news', 'update', 'updates',
+ 'trending', 'hottest', 'popular', 'viral',
+ 'practices', 'features',
+ 'recommendations', 'advice',
+ 'prompt', 'prompts', 'prompting',
+ 'methods', 'strategies', 'approaches',
+ })
+ return extract_core_subject(topic, noise=_PINTEREST_NOISE)
+
+
+def _log(msg: str):
+ log.source_log("Pinterest", msg)
+
+
+def _sc_headers(token: str) -> Dict[str, str]:
+ """Build ScrapeCreators request headers."""
+ return {
+ "x-api-key": token,
+ "Content-Type": "application/json",
+ }
+
+
+def _parse_items(raw_items: List[Dict[str, Any]], core_topic: str) -> List[Dict[str, Any]]:
+ """Parse raw Pinterest items into normalized dicts.
+
+ Pinterest pins are visual content with descriptions. Saves are the
+ primary engagement signal (analogous to upvotes/likes on other platforms).
+ """
+ items = []
+ for raw in raw_items:
+ if not isinstance(raw, dict):
+ continue
+
+ pin_id = str(raw.get("id", raw.get("pin_id", "")))
+ description = str(raw.get("description") or raw.get("title") or "")
+
+ # Engagement metrics - saves are the primary signal
+ save_count = raw.get("save_count") or raw.get("saves") or raw.get("repin_count") or 0
+ comment_count = raw.get("comment_count") or raw.get("comments") or 0
+
+ # Author info
+ pinner = raw.get("pinner") or raw.get("creator") or raw.get("user") or {}
+ if isinstance(pinner, dict):
+ author_name = pinner.get("username") or pinner.get("full_name") or ""
+ elif isinstance(pinner, str):
+ author_name = pinner
+ else:
+ author_name = ""
+
+ # URL
+ url = raw.get("link") or raw.get("url") or ""
+ if not url and pin_id:
+ url = f"https://www.pinterest.com/pin/{pin_id}/"
+
+ # Board info (container for pins)
+ board = raw.get("board") or {}
+ board_name = board.get("name", "") if isinstance(board, dict) else ""
+
+ # Compute relevance
+ relevance = _compute_relevance(core_topic, description, [])
+
+ items.append({
+ "pin_id": pin_id,
+ "description": description,
+ "url": url,
+ "author": author_name,
+ "board": board_name,
+ "engagement": {
+ "saves": save_count,
+ "comments": comment_count,
+ },
+ "relevance": relevance,
+ "why_relevant": f"Pinterest: {description[:60]}" if description else f"Pinterest: {core_topic}",
+ })
+ return items
+
+
+def parse_pinterest_response(response: Dict[str, Any]) -> List[Dict[str, Any]]:
+ """Parse Pinterest search response to normalized format.
+
+ Returns:
+ List of item dicts ready for normalization.
+ """
+ return response.get("items", [])
+
+
+def search_pinterest(
+ topic: str,
+ from_date: str,
+ to_date: str,
+ depth: str = "default",
+ token: str = None,
+) -> Dict[str, Any]:
+ """Search Pinterest via ScrapeCreators API.
+
+ Args:
+ topic: Search topic
+ from_date: Start date (YYYY-MM-DD)
+ to_date: End date (YYYY-MM-DD)
+ depth: 'quick', 'default', or 'deep'
+ token: ScrapeCreators API key
+
+ Returns:
+ Dict with 'items' list and optional 'error'.
+ """
+ if not token:
+ return {"items": [], "error": "No SCRAPECREATORS_API_KEY configured"}
+
+ config = DEPTH_CONFIG.get(depth, DEPTH_CONFIG["default"])
+ core_topic = _extract_core_subject(topic)
+
+ _log(f"Searching Pinterest for '{core_topic}' (depth={depth}, count={config['results_per_page']})")
+
+ if not _requests:
+ _log("requests library not installed, falling back to urllib")
+ try:
+ from urllib.parse import urlencode
+ params = urlencode({"keyword": core_topic})
+ url = f"{SCRAPECREATORS_BASE}/search?{params}"
+ headers = _sc_headers(token)
+ headers["User-Agent"] = http.USER_AGENT
+ data = http.get(url, headers=headers, timeout=30, retries=2)
+ except Exception as e:
+ _log(f"ScrapeCreators error (urllib): {e}")
+ return {"items": [], "error": f"{type(e).__name__}: {e}"}
+ else:
+ try:
+ resp = _requests.get(
+ f"{SCRAPECREATORS_BASE}/search",
+ params={"keyword": core_topic},
+ headers=_sc_headers(token),
+ timeout=30,
+ )
+ resp.raise_for_status()
+ data = resp.json()
+ except Exception as e:
+ _log(f"ScrapeCreators error: {e}")
+ return {"items": [], "error": f"{type(e).__name__}: {e}"}
+
+ # Extract items from response - try common SC response shapes
+ raw_items = data.get("pins") or data.get("results") or data.get("data") or data.get("items") or []
+
+ # Limit to configured count
+ raw_items = raw_items[:config["results_per_page"]]
+
+ # Parse items
+ items = _parse_items(raw_items, core_topic)
+
+ # Sort by saves descending (primary engagement signal)
+ items.sort(key=lambda x: x["engagement"]["saves"], reverse=True)
+
+ _log(f"Found {len(items)} Pinterest pins")
+ return {"items": items}
diff --git a/scripts/lib/pipeline.py b/scripts/lib/pipeline.py
new file mode 100644
index 0000000..957e404
--- /dev/null
+++ b/scripts/lib/pipeline.py
@@ -0,0 +1,990 @@
+"""v3.0.0 orchestration pipeline."""
+
+from __future__ import annotations
+
+import sys
+import threading
+import time
+from concurrent.futures import ThreadPoolExecutor, as_completed
+from datetime import datetime, timezone
+from shutil import which
+from typing import Any
+
+from . import (
+ bird_x,
+ bluesky,
+ dates,
+ dedupe,
+ entity_extract,
+ env,
+ github,
+ grounding,
+ hackernews,
+ instagram,
+ normalize,
+ perplexity,
+ pinterest,
+ planner,
+ polymarket,
+ providers,
+ query,
+ reddit,
+ reddit_public,
+ rerank,
+ schema,
+ signals,
+ snippet,
+ threads,
+ tiktok,
+ truthsocial,
+ xai_x,
+ xiaohongshu_api,
+ youtube_yt,
+)
+from .cluster import cluster_candidates
+from .fusion import weighted_rrf
+
+DEPTH_SETTINGS = {
+ "quick": {"per_stream_limit": 6, "pool_limit": 15, "rerank_limit": 12},
+ "default": {"per_stream_limit": 12, "pool_limit": 40, "rerank_limit": 40},
+ "deep": {"per_stream_limit": 20, "pool_limit": 60, "rerank_limit": 60},
+}
+
+SEARCH_ALIAS = {
+ "hn": "hackernews",
+ "bsky": "bluesky",
+ "truth": "truthsocial",
+ "web": "grounding",
+ "xhs": "xiaohongshu",
+}
+
+MAX_SOURCE_FETCHES: dict[str, int] = {"x": 2}
+
+MOCK_AVAILABLE_SOURCES = [
+ "reddit",
+ "x",
+ "youtube",
+ "tiktok",
+ "instagram",
+ "hackernews",
+ "bluesky",
+ "truthsocial",
+ "polymarket",
+ "grounding",
+ "xiaohongshu",
+ "github",
+ "perplexity",
+]
+
+
+def normalize_requested_sources(sources: list[str] | None) -> list[str] | None:
+ if not sources:
+ return None
+ normalized = []
+ for source in sources:
+ key = SEARCH_ALIAS.get(source.lower(), source.lower())
+ if key not in normalized:
+ normalized.append(key)
+ return normalized
+
+
+def available_sources(config: dict[str, Any], requested_sources: list[str] | None = None) -> list[str]:
+ available: list[str] = []
+ # reddit_public needs no API key - always available
+ available.append("reddit")
+ if config.get("SCRAPECREATORS_API_KEY"):
+ available.extend(["tiktok", "instagram"])
+ if env.get_x_source(config):
+ available.append("x")
+ if which("yt-dlp") or env.is_youtube_sc_available(config):
+ available.append("youtube")
+ available.extend(["hackernews", "polymarket"])
+ if config.get("GITHUB_TOKEN") or which("gh"):
+ available.append("github")
+ if env.is_bluesky_available(config):
+ available.append("bluesky")
+ if env.is_truthsocial_available(config):
+ available.append("truthsocial")
+ if config.get("BRAVE_API_KEY") or config.get("EXA_API_KEY") or config.get("SERPER_API_KEY") or config.get("PARALLEL_API_KEY"):
+ available.append("grounding")
+ # Perplexity Sonar: opt-in additive source via INCLUDE_SOURCES=perplexity
+ include_sources = (config.get("INCLUDE_SOURCES") or "").lower().split(",")
+ if config.get("OPENROUTER_API_KEY") and "perplexity" in include_sources:
+ available.append("perplexity")
+ if requested_sources and "xiaohongshu" in requested_sources and env.is_xiaohongshu_available(config):
+ available.append("xiaohongshu")
+ if env.is_threads_available(config):
+ available.append("threads")
+ if requested_sources and "pinterest" in requested_sources and env.is_pinterest_available(config):
+ available.append("pinterest")
+ return available
+
+
+def diagnose(config: dict[str, Any], requested_sources: list[str] | None = None) -> dict[str, Any]:
+ requested_sources = normalize_requested_sources(requested_sources)
+ google_key = _google_key(config)
+ x_status = env.get_x_source_status(config)
+ native_web_backend = None
+ if config.get("BRAVE_API_KEY"):
+ native_web_backend = "brave"
+ elif config.get("EXA_API_KEY"):
+ native_web_backend = "exa"
+ elif config.get("SERPER_API_KEY"):
+ native_web_backend = "serper"
+ elif config.get("PARALLEL_API_KEY"):
+ native_web_backend = "parallel"
+ providers_status = {
+ "google": bool(google_key),
+ "openai": bool(config.get("OPENAI_API_KEY")) and config.get("OPENAI_AUTH_STATUS") == env.AUTH_STATUS_OK,
+ "xai": bool(config.get("XAI_API_KEY")),
+ "openrouter": bool(config.get("OPENROUTER_API_KEY")),
+ }
+ return {
+ "providers": providers_status,
+ "local_mode": not any(providers_status.values()),
+ "reasoning_provider": (config.get("LAST30DAYS_REASONING_PROVIDER") or "auto").lower(),
+ "x_backend": x_status["source"],
+ "bird_installed": x_status["bird_installed"],
+ "bird_authenticated": x_status["bird_authenticated"],
+ "bird_username": x_status["bird_username"],
+ "native_web_backend": native_web_backend,
+ "has_scrapecreators": bool(config.get("SCRAPECREATORS_API_KEY")),
+ "has_github": bool(config.get("GITHUB_TOKEN") or which("gh")),
+ "available_sources": available_sources(config, requested_sources),
+ }
+
+
+def run(
+ *,
+ topic: str,
+ config: dict[str, Any],
+ depth: str,
+ requested_sources: list[str] | None = None,
+ mock: bool = False,
+ x_handle: str | None = None,
+ x_related: list[str] | None = None,
+ web_backend: str = "auto",
+ external_plan: dict | None = None,
+ subreddits: list[str] | None = None,
+ tiktok_hashtags: list[str] | None = None,
+ tiktok_creators: list[str] | None = None,
+ ig_creators: list[str] | None = None,
+ lookback_days: int = 30,
+ github_user: str | None = None,
+ github_repos: list[str] | None = None,
+) -> schema.Report:
+ settings = DEPTH_SETTINGS[depth]
+ requested_sources = normalize_requested_sources(requested_sources)
+ from_date, to_date = dates.get_date_range(lookback_days)
+
+ if mock:
+ runtime = providers.mock_runtime(config, depth)
+ reasoning_provider = None
+ available = list(requested_sources or MOCK_AVAILABLE_SOURCES)
+ else:
+ runtime, reasoning_provider = providers.resolve_runtime(config, depth)
+ available = available_sources(config, requested_sources)
+ if requested_sources:
+ available = [source for source in available if source in requested_sources]
+ if web_backend == "none":
+ available = [s for s in available if s != "grounding"]
+ elif web_backend in ("brave", "exa", "serper") and "grounding" not in available:
+ available.append("grounding")
+ if not available:
+ raise RuntimeError("No sources are available for this run.")
+
+ if external_plan:
+ # External plan provided (e.g., from Claude Code via --plan flag).
+ # Parse it through the same sanitizer to validate structure.
+ plan = planner._sanitize_plan(
+ external_plan, topic, available, requested_sources, depth,
+ )
+ print(f"[Planner] Using external plan ({len(plan.subqueries)} subqueries)", file=sys.stderr)
+ else:
+ plan = planner.plan_query(
+ topic=topic,
+ available_sources=available,
+ requested_sources=requested_sources,
+ depth=depth,
+ provider=None if mock else reasoning_provider,
+ model=None if mock else runtime.planner_model,
+ context=config.get("_auto_resolve_context", ""),
+ )
+
+ # Safety net: ensure grounding appears in all subqueries even if the planner
+ # omits it. This is redundant when the planner includes grounding via
+ # SOURCE_CAPABILITIES, but kept as a fallback.
+ if web_backend != "none" and "grounding" in available:
+ for sq in plan.subqueries:
+ if "grounding" not in sq.sources:
+ sq.sources.append("grounding")
+
+ bundle = schema.RetrievalBundle(artifacts={"grounding": []})
+
+ # Project-mode or person-mode GitHub: run once before the main subquery loop
+ _github_custom_done = False
+ _github_enriched_repos: set[str] = set()
+
+ # Project mode takes priority over person mode
+ if github_repos and "github" in available:
+ try:
+ project_items = github.search_github_project(
+ github_repos, from_date, to_date,
+ depth=depth, token=config.get("GITHUB_TOKEN"),
+ )
+ if project_items:
+ normalized = _normalize_score_dedupe(
+ "github", project_items, from_date, to_date,
+ freshness_mode=plan.freshness_mode,
+ ranking_query=f"What are {', '.join(github_repos)} doing on GitHub?",
+ )
+ primary_label = plan.subqueries[0].label if plan.subqueries else "primary"
+ bundle.add_items(primary_label, "github", normalized)
+ _github_custom_done = True
+ _github_enriched_repos = {r.lower() for r in github_repos}
+ except Exception as exc:
+ bundle.errors_by_source["github"] = f"Project-mode failed: {exc}"
+
+ _github_person_done = False
+ if github_user and "github" in available and not _github_custom_done:
+ try:
+ person_items = github.search_github_person(
+ github_user, from_date, to_date,
+ depth=depth, token=config.get("GITHUB_TOKEN"),
+ )
+ if person_items:
+ normalized = _normalize_score_dedupe(
+ "github", person_items, from_date, to_date,
+ freshness_mode=plan.freshness_mode,
+ ranking_query=f"What is @{github_user} doing on GitHub?",
+ )
+ # Use the first subquery's label so RRF can look up the weight
+ primary_label = plan.subqueries[0].label if plan.subqueries else "primary"
+ bundle.add_items(primary_label, "github", normalized)
+ _github_person_done = True
+ except Exception as exc:
+ bundle.errors_by_source["github"] = f"Person-mode failed: {exc}"
+
+ # Thread-safe set prevents redundant fetches after a source returns 429
+ rate_limited_sources: set[str] = set()
+ rate_limit_lock = threading.Lock()
+
+ futures = {}
+ # Per-source fetch budget prevents redundant API calls
+ source_fetch_count: dict[str, int] = {}
+ stream_count = sum(
+ 1
+ for subquery in plan.subqueries
+ for source in subquery.sources
+ if source in available
+ )
+ max_workers = max(4, min(16, stream_count or 1))
+ with ThreadPoolExecutor(max_workers=max_workers) as executor:
+ for subquery in plan.subqueries:
+ for source in subquery.sources:
+ if source not in available:
+ continue
+ # Skip GitHub keyword search if person-mode already ran
+ if source == "github" and (_github_person_done or _github_custom_done):
+ continue
+ # Enforce per-source fetch cap
+ cap = MAX_SOURCE_FETCHES.get(source)
+ if cap is not None:
+ current = source_fetch_count.get(source, 0)
+ if current >= cap:
+ continue
+ source_fetch_count[source] = current + 1
+ futures[
+ executor.submit(
+ _retrieve_stream,
+ topic=topic,
+ subquery=subquery,
+ source=source,
+ config=config,
+ depth=depth,
+ date_range=(from_date, to_date),
+ runtime=runtime,
+ mock=mock,
+ rate_limited_sources=rate_limited_sources,
+ rate_limit_lock=rate_limit_lock,
+ web_backend=web_backend,
+ raw_topic=topic,
+ subreddits=subreddits,
+ tiktok_hashtags=tiktok_hashtags,
+ tiktok_creators=tiktok_creators,
+ ig_creators=ig_creators,
+ )
+ ] = (subquery, source)
+
+ for future in as_completed(futures):
+ subquery, source = futures[future]
+ try:
+ raw_items, artifact = future.result()
+ except Exception as exc:
+ # Share 429 signal so pending futures skip this source
+ if _is_rate_limit_error(exc):
+ with rate_limit_lock:
+ rate_limited_sources.add(source)
+ bundle.errors_by_source[source] = str(exc)
+ continue
+ # Retry once for transient 5xx errors
+ if _is_transient_error(exc):
+ time.sleep(3)
+ try:
+ raw_items, artifact = _retrieve_stream(
+ topic=topic, subquery=subquery, source=source,
+ config=config, depth=depth, date_range=(from_date, to_date),
+ runtime=runtime, mock=mock,
+ rate_limited_sources=rate_limited_sources,
+ rate_limit_lock=rate_limit_lock,
+ web_backend=web_backend,
+ raw_topic=topic,
+ subreddits=subreddits,
+ tiktok_hashtags=tiktok_hashtags,
+ tiktok_creators=tiktok_creators,
+ ig_creators=ig_creators,
+ )
+ except Exception as retry_exc:
+ bundle.errors_by_source[source] = f"{exc} (retried once, still failed: {retry_exc})"
+ continue
+ else:
+ bundle.errors_by_source[source] = str(exc)
+ continue
+ normalized = _normalize_score_dedupe(
+ source, raw_items, from_date, to_date,
+ freshness_mode=plan.freshness_mode,
+ ranking_query=subquery.ranking_query,
+ )
+ normalized = normalized[: settings["per_stream_limit"]]
+ bundle.add_items(subquery.label, source, normalized)
+ if artifact:
+ bundle.artifacts.setdefault("grounding", []).append(artifact)
+
+ # Phase 2: supplemental entity-based searches
+ _run_supplemental_searches(
+ topic=topic,
+ bundle=bundle,
+ plan=plan,
+ config=config,
+ depth=depth,
+ date_range=(from_date, to_date),
+ runtime=runtime,
+ mock=mock,
+ rate_limited_sources=rate_limited_sources,
+ rate_limit_lock=rate_limit_lock,
+ x_handle=x_handle,
+ x_related=x_related,
+ )
+
+ # Phase 2b: retry thin sources with simplified query
+ # Note: _github_skip_sources tells the retry to not re-run GitHub keyword search
+ # when project-mode or person-mode already provided authoritative data.
+ _github_skip_retry = {"github"} if (_github_person_done or _github_custom_done) else set()
+ _retry_thin_sources(
+ topic=topic,
+ bundle=bundle,
+ plan=plan,
+ config=config,
+ depth=depth,
+ date_range=(from_date, to_date),
+ runtime=runtime,
+ mock=mock,
+ rate_limited_sources=rate_limited_sources,
+ rate_limit_lock=rate_limit_lock,
+ settings=settings,
+ web_backend=web_backend,
+ skip_sources=_github_skip_retry,
+ )
+
+ # Clear errors for sources that returned items despite partial failures.
+ # A source that 429'd on one subquery but succeeded on another is not "errored".
+ for source in list(bundle.errors_by_source):
+ if bundle.items_by_source.get(source):
+ del bundle.errors_by_source[source]
+
+ items_by_source = _finalize_items_by_source(bundle.items_by_source)
+ candidates = weighted_rrf(bundle.items_by_source_and_query, plan, pool_limit=settings["pool_limit"])
+ ranked_candidates = rerank.rerank_candidates(
+ topic=topic,
+ plan=plan,
+ candidates=candidates,
+ provider=None if mock else reasoning_provider,
+ model=None if mock else runtime.rerank_model,
+ shortlist_size=settings["rerank_limit"],
+ )
+ rerank.score_fun(
+ topic=topic,
+ candidates=ranked_candidates,
+ provider=None if mock else reasoning_provider,
+ model=None if mock else runtime.rerank_model,
+ )
+
+ # Phase 3: post-rerank GitHub star enrichment
+ if "github" in available and not mock:
+ github.enrich_candidates_with_stars(
+ ranked_candidates,
+ token=config.get("GITHUB_TOKEN"),
+ already_enriched=_github_enriched_repos,
+ )
+
+ clusters = cluster_candidates(ranked_candidates, plan)
+ warnings = _warnings(items_by_source, ranked_candidates, bundle.errors_by_source)
+
+ return schema.Report(
+ topic=topic,
+ range_from=from_date,
+ range_to=to_date,
+ generated_at=datetime.now(timezone.utc).isoformat(),
+ provider_runtime=runtime,
+ query_plan=plan,
+ clusters=clusters,
+ ranked_candidates=ranked_candidates,
+ items_by_source=items_by_source,
+ errors_by_source=bundle.errors_by_source,
+ warnings=warnings,
+ artifacts=bundle.artifacts,
+ )
+
+
+def _normalize_score_dedupe(
+ source: str,
+ raw_items: list[dict],
+ from_date: str,
+ to_date: str,
+ freshness_mode: str,
+ ranking_query: str,
+) -> list[schema.SourceItem]:
+ """Normalize, annotate, prune, dedupe, and extract snippets for a batch of raw items."""
+ normalized = normalize.normalize_source_items(
+ source, raw_items, from_date, to_date,
+ freshness_mode=freshness_mode,
+ )
+ normalized = signals.annotate_stream(normalized, ranking_query, freshness_mode)
+ normalized = signals.prune_low_relevance(normalized)
+ normalized = dedupe.dedupe_items(normalized)
+ for item in normalized:
+ item.snippet = snippet.extract_best_snippet(item, ranking_query)
+ return normalized
+
+
+def _finalize_items_by_source(items_by_source_raw: dict[str, list[schema.SourceItem]]) -> dict[str, list[schema.SourceItem]]:
+ finalized = {}
+ for source, items in items_by_source_raw.items():
+ items = sorted(items, key=lambda item: item.local_rank_score or 0.0, reverse=True)
+ finalized[source] = dedupe.dedupe_items(items)
+ return finalized
+
+
+def _warnings(
+ items_by_source: dict[str, list[schema.SourceItem]],
+ candidates: list[schema.Candidate],
+ errors_by_source: dict[str, str],
+) -> list[str]:
+ warnings: list[str] = []
+ if not candidates:
+ warnings.append("No candidates survived retrieval and ranking.")
+ if len(candidates) < 5:
+ warnings.append("Evidence is thin for this topic.")
+ top_sources = {
+ source
+ for candidate in candidates[:5]
+ for source in schema.candidate_sources(candidate)
+ }
+ if len(top_sources) <= 1 and len(candidates) >= 3:
+ warnings.append("Top evidence is highly concentrated in one source.")
+ if errors_by_source:
+ warnings.append(f"Some sources failed: {', '.join(sorted(errors_by_source))}")
+ if not items_by_source:
+ warnings.append("No source returned usable items.")
+ return warnings
+
+
+def _is_rate_limit_error(exc: Exception) -> bool:
+ """Detect 429 rate-limit errors by status code or message text."""
+ if hasattr(exc, "status_code") and getattr(exc, "status_code", None) == 429:
+ return True
+ return "429" in str(exc)
+
+
+def _is_transient_error(exc: Exception) -> bool:
+ """Detect 5xx server errors that are worth retrying."""
+ status = getattr(exc, "status_code", None)
+ if isinstance(status, int) and 500 <= status < 600:
+ return True
+ msg = str(exc)
+ return any(code in msg for code in ("500", "502", "503", "504"))
+
+
+def _run_supplemental_searches(
+ *,
+ topic: str,
+ bundle: schema.RetrievalBundle,
+ plan: schema.QueryPlan,
+ config: dict[str, Any],
+ depth: str,
+ date_range: tuple[str, str],
+ runtime: schema.ProviderRuntime,
+ mock: bool,
+ rate_limited_sources: set[str],
+ rate_limit_lock: threading.Lock,
+ x_handle: str | None = None,
+ x_related: list[str] | None = None,
+) -> None:
+ """Phase 2: extract entities from Phase 1 results, run targeted supplemental searches."""
+ if depth == "quick" or mock:
+ return
+
+ from_date, to_date = date_range
+
+ # Convert SourceItems to dicts for entity_extract
+ x_dicts = [
+ {"author_handle": item.author or "", "text": item.body or ""}
+ for item in bundle.items_by_source.get("x", [])
+ ]
+ reddit_dicts = [
+ {
+ "subreddit": item.container or "",
+ "comment_insights": item.metadata.get("comment_insights", []),
+ "top_comments": [
+ {"excerpt": c.get("excerpt", c.get("text", ""))}
+ for c in (item.metadata.get("top_comments") or [])
+ if isinstance(c, dict)
+ ],
+ }
+ for item in bundle.items_by_source.get("reddit", [])
+ ]
+
+ if not x_dicts and not reddit_dicts and not x_handle and not x_related:
+ return
+
+ entities = entity_extract.extract_entities(
+ reddit_dicts, x_dicts,
+ max_handles=3, max_subreddits=3,
+ )
+
+ handles = entities.get("x_handles", [])
+
+ # Add explicit --x-handle if provided
+ if x_handle:
+ handle_clean = x_handle.lstrip("@").lower()
+ if handle_clean not in [h.lower() for h in handles]:
+ handles.insert(0, handle_clean)
+
+ # Collect related handles (searched separately with lower weight)
+ related_handles = []
+ if x_related:
+ primary_lower = x_handle.lstrip("@").lower() if x_handle else ""
+ for rh in x_related:
+ rh_clean = rh.lstrip("@").lower().strip()
+ if rh_clean and rh_clean != primary_lower and rh_clean not in [h.lower() for h in handles]:
+ related_handles.append(rh_clean)
+
+ if not handles and not related_handles:
+ return
+
+ # Check if X is rate-limited
+ if "x" in rate_limited_sources:
+ return
+
+ backend = runtime.x_search_backend or env.get_x_source(config)
+ if backend != "bird":
+ return # Handle search only works with Bird CLI
+
+ # Collect existing URLs for deduplication
+ existing_urls = {
+ item.url
+ for items in bundle.items_by_source.values()
+ for item in items
+ if item.url
+ }
+
+ ranking_query = plan.subqueries[0].ranking_query if plan.subqueries else topic
+ primary_label = plan.subqueries[0].label if plan.subqueries else "primary"
+
+ # Search primary handles (full weight)
+ if handles:
+ try:
+ raw_items = bird_x.search_handles(
+ handles, topic, from_date, count_per=3,
+ )
+ except Exception as exc:
+ print(f"[Pipeline] Phase 2 handle search failed: {exc}", file=sys.stderr)
+ if not bundle.items_by_source.get("x"):
+ bundle.errors_by_source["x"] = f"Phase 2 handle search: {exc}"
+ raw_items = []
+
+ if raw_items:
+ normalized = _normalize_score_dedupe(
+ "x", raw_items, from_date, to_date,
+ freshness_mode=plan.freshness_mode,
+ ranking_query=ranking_query,
+ )
+ # Deduplicate against Phase 1 URLs
+ normalized = [item for item in normalized if item.url not in existing_urls]
+ if normalized:
+ bundle.add_items(primary_label, "x", normalized)
+ # Update existing URLs for related-handle dedup
+ for item in normalized:
+ if item.url:
+ existing_urls.add(item.url)
+
+ # Search related handles with lower weight (0.3)
+ if related_handles:
+ try:
+ raw_items = bird_x.search_handles(
+ related_handles, topic, from_date, count_per=3,
+ )
+ except Exception as exc:
+ print(f"[Pipeline] Phase 2 related handle search failed: {exc}", file=sys.stderr)
+ raw_items = []
+
+ if raw_items:
+ normalized = _normalize_score_dedupe(
+ "x", raw_items, from_date, to_date,
+ freshness_mode=plan.freshness_mode,
+ ranking_query=ranking_query,
+ )
+ # Deduplicate against all existing URLs (Phase 1 + primary handles)
+ normalized = [item for item in normalized if item.url not in existing_urls]
+ if normalized:
+ # Use a separate subquery label with lower weight so RRF
+ # scores related-handle results below primary results.
+ bundle.add_items("supplemental-related", "x", normalized)
+ # Register the supplemental-related label in the plan for fusion
+ if not any(sq.label == "supplemental-related" for sq in plan.subqueries):
+ plan.subqueries.append(
+ schema.SubQuery(
+ label="supplemental-related",
+ search_query=", ".join(related_handles),
+ ranking_query=ranking_query,
+ sources=["x"],
+ weight=0.3,
+ )
+ )
+
+
+def _retry_thin_sources(
+ *,
+ topic: str,
+ bundle: schema.RetrievalBundle,
+ plan: schema.QueryPlan,
+ config: dict[str, Any],
+ depth: str,
+ date_range: tuple[str, str],
+ runtime: schema.ProviderRuntime,
+ mock: bool,
+ rate_limited_sources: set[str],
+ rate_limit_lock: threading.Lock,
+ settings: dict[str, Any],
+ web_backend: str = "auto",
+ skip_sources: set[str] | None = None,
+) -> None:
+ """Retry sources with thin results using simplified core subject query."""
+ if depth == "quick":
+ return
+
+ planned_sources: list[str] = []
+ for subquery in plan.subqueries:
+ for source in subquery.sources:
+ if source not in planned_sources:
+ planned_sources.append(source)
+ _skip = skip_sources or set()
+ thin_sources = [
+ source
+ for source in planned_sources
+ if len(bundle.items_by_source.get(source, [])) < 3
+ and source not in bundle.errors_by_source
+ and source not in _skip
+ ]
+
+ if not thin_sources:
+ return
+
+ core = query.extract_core_subject(topic, max_words=3)
+ if not core:
+ return
+ # Note: we intentionally do NOT skip when core == topic. For short topics
+ # like "Kanye West", the 3-word core IS the topic — but the planner may
+ # have sent a different (worse) query to the source. Retrying with the
+ # raw core subject is still valuable.
+
+ from_date, to_date = date_range
+
+ # Create a retry subquery with the simplified core subject
+ retry_subquery = schema.SubQuery(
+ label="retry",
+ search_query=core,
+ ranking_query=f"What recent evidence from the last 30 days matters for {core}?",
+ sources=thin_sources,
+ weight=0.3,
+ )
+
+ for source in thin_sources:
+ if source in rate_limited_sources:
+ continue
+ try:
+ raw_items, _artifact = _retrieve_stream(
+ topic=topic,
+ subquery=retry_subquery,
+ source=source,
+ config=config,
+ depth=depth,
+ date_range=date_range,
+ runtime=runtime,
+ mock=mock,
+ rate_limited_sources=rate_limited_sources,
+ rate_limit_lock=rate_limit_lock,
+ web_backend=web_backend,
+ raw_topic=topic,
+ )
+ normalized = _normalize_score_dedupe(
+ source,
+ raw_items,
+ from_date,
+ to_date,
+ freshness_mode=plan.freshness_mode,
+ ranking_query=retry_subquery.ranking_query,
+ )
+ normalized = normalized[:settings["per_stream_limit"]]
+
+ existing_urls = {item.url for item in bundle.items_by_source.get(source, []) if item.url}
+ new_items = [item for item in normalized if item.url not in existing_urls]
+
+ if new_items:
+ bundle.items_by_source.setdefault(source, []).extend(new_items)
+ primary_label = plan.subqueries[0].label if plan.subqueries else "primary"
+ existing = bundle.items_by_source_and_query.get((primary_label, source), [])
+ bundle.items_by_source_and_query[(primary_label, source)] = existing + new_items
+ except Exception as exc:
+ print(f"[Pipeline] Retry failed for {source}: {type(exc).__name__}: {exc}", file=sys.stderr)
+
+
+def _retrieve_stream(
+ *,
+ topic: str,
+ subquery: schema.SubQuery,
+ source: str,
+ config: dict[str, Any],
+ depth: str,
+ date_range: tuple[str, str],
+ runtime: schema.ProviderRuntime,
+ mock: bool,
+ rate_limited_sources: set[str] | None = None,
+ rate_limit_lock: threading.Lock | None = None,
+ web_backend: str = "auto",
+ raw_topic: str = "",
+ subreddits: list[str] | None = None,
+ tiktok_hashtags: list[str] | None = None,
+ tiktok_creators: list[str] | None = None,
+ ig_creators: list[str] | None = None,
+) -> tuple[list[dict], dict]:
+ # Early exit if source was rate-limited by a sibling future
+ if rate_limited_sources is not None and source in rate_limited_sources:
+ return [], {}
+ from_date, to_date = date_range
+ if mock:
+ return _mock_stream_results(source, subquery)
+ if source == "grounding":
+ return grounding.web_search(
+ subquery.search_query, date_range, config, backend=web_backend)
+ if source == "reddit":
+ # Use raw_topic so expand_reddit_queries() generates diverse variants
+ # from the original user topic, not the planner's narrowed search_query.
+ reddit_query = raw_topic or subquery.search_query
+ # Public Reddit first (free, gets comments); SC as backup
+ try:
+ public_results = reddit_public.search_reddit_public(
+ reddit_query, from_date, to_date, depth=depth,
+ subreddits=subreddits,
+ )
+ if public_results:
+ return public_results, {}
+ except Exception as exc:
+ sys.stderr.write(
+ f"[Reddit] Public search failed ({type(exc).__name__}: {exc})"
+ )
+ if not config.get("SCRAPECREATORS_API_KEY"):
+ sys.stderr.write("\n")
+ return [], {}
+ sys.stderr.write(", using ScrapeCreators backup\n")
+ # Fallback to ScrapeCreators if public returned empty or raised
+ if config.get("SCRAPECREATORS_API_KEY"):
+ try:
+ result = reddit.search_and_enrich(
+ reddit_query,
+ from_date,
+ to_date,
+ depth=depth,
+ token=config.get("SCRAPECREATORS_API_KEY"),
+ subreddits=subreddits,
+ )
+ return reddit.parse_reddit_response(result), {}
+ except Exception as exc:
+ sys.stderr.write(
+ f"[Reddit] ScrapeCreators backup also failed "
+ f"({type(exc).__name__}: {exc})\n"
+ )
+ return [], {}
+ if source == "x":
+ backend = runtime.x_search_backend or env.get_x_source(config)
+ if backend == "bird":
+ result = bird_x.search_x(subquery.search_query, from_date, to_date, depth=depth)
+ return bird_x.parse_bird_response(result, query=subquery.search_query), {}
+ if backend == "xai":
+ model = config.get("LAST30DAYS_X_MODEL") or config.get("XAI_MODEL_PIN") or providers.XAI_DEFAULT
+ result = xai_x.search_x(
+ config["XAI_API_KEY"],
+ model,
+ subquery.search_query,
+ from_date,
+ to_date,
+ depth=depth,
+ )
+ return xai_x.parse_x_response(result), {}
+ raise RuntimeError("No X backend is available.")
+ if source == "youtube":
+ # Use raw_topic so expand_youtube_queries() generates diverse variants
+ # from the original user topic, not the planner's narrowed search_query.
+ yt_query = raw_topic or subquery.search_query
+ result = None
+ # Try yt-dlp first, fall back to SC YouTube if it fails or isn't installed
+ if which("yt-dlp"):
+ try:
+ result = youtube_yt.search_and_transcribe(yt_query, from_date, to_date, depth=depth)
+ except Exception:
+ result = None
+ if (result is None or not result.get("items")) and env.is_youtube_sc_available(config):
+ sc_token = config.get("SCRAPECREATORS_API_KEY", "")
+ result = youtube_yt.search_youtube_sc(yt_query, from_date, to_date, depth=depth, token=sc_token)
+ if result is None:
+ result = {"items": []}
+ # Enrich top videos with comments when SC key is available
+ items = youtube_yt.parse_youtube_response(result)
+ if items and env.is_youtube_comments_available(config):
+ sc_token = config.get("SCRAPECREATORS_API_KEY", "")
+ youtube_yt.enrich_with_comments(items, token=sc_token)
+ return items, {}
+ if source == "tiktok":
+ # Use raw_topic so expand_tiktok_queries() generates diverse variants
+ # from the original user topic, not the planner's narrowed search_query.
+ tiktok_query = raw_topic or subquery.search_query
+ result = tiktok.search_and_enrich(
+ tiktok_query,
+ from_date,
+ to_date,
+ depth=depth,
+ token=env.get_tiktok_token(config),
+ hashtags=tiktok_hashtags,
+ creators=tiktok_creators,
+ )
+ return tiktok.parse_tiktok_response(result), {}
+ if source == "instagram":
+ # Use raw_topic so expand_instagram_queries() generates diverse variants
+ # from the original user topic, not the planner's narrowed search_query.
+ ig_query = raw_topic or subquery.search_query
+ result = instagram.search_and_enrich(
+ ig_query,
+ from_date,
+ to_date,
+ depth=depth,
+ token=env.get_instagram_token(config),
+ ig_creators=ig_creators,
+ )
+ return instagram.parse_instagram_response(result), {}
+ if source == "hackernews":
+ result = hackernews.search_hackernews(subquery.search_query, from_date, to_date, depth=depth)
+ return hackernews.parse_hackernews_response(result, query=subquery.search_query), {}
+ if source == "bluesky":
+ result = bluesky.search_bluesky(subquery.search_query, from_date, to_date, depth=depth, config=config)
+ return bluesky.parse_bluesky_response(result), {}
+ if source == "threads":
+ result = threads.search_threads(
+ subquery.search_query, from_date, to_date,
+ depth=depth,
+ token=config.get("SCRAPECREATORS_API_KEY"),
+ )
+ return threads.parse_threads_response(result), {}
+ if source == "truthsocial":
+ result = truthsocial.search_truthsocial(subquery.search_query, from_date, to_date, depth=depth, config=config)
+ return truthsocial.parse_truthsocial_response(result), {}
+ if source == "polymarket":
+ result = polymarket.search_polymarket(subquery.search_query, from_date, to_date, depth=depth)
+ return polymarket.parse_polymarket_response(result, topic=subquery.search_query), {}
+ if source == "github":
+ result = github.search_github(subquery.search_query, from_date, to_date, depth=depth, token=config.get("GITHUB_TOKEN"))
+ return result, {}
+ if source == "pinterest":
+ result = pinterest.search_pinterest(
+ subquery.search_query, from_date, to_date,
+ depth=depth,
+ token=env.get_pinterest_token(config),
+ )
+ return pinterest.parse_pinterest_response(result), {}
+ if source == "xiaohongshu":
+ return xiaohongshu_api.search_feeds(
+ subquery.search_query,
+ from_date,
+ to_date,
+ env.get_xiaohongshu_api_base(config),
+ depth=depth,
+ ), {}
+ if source == "perplexity":
+ return perplexity.search(subquery.search_query, date_range, config, deep=config.get("_deep_research", False))
+ raise RuntimeError(f"Unsupported source: {source}")
+
+
+def _google_key(config: dict[str, Any]) -> str | None:
+ return config.get("GOOGLE_API_KEY") or config.get("GEMINI_API_KEY") or config.get("GOOGLE_GENAI_API_KEY")
+
+
+
+
+def _mock_stream_results(source: str, subquery: schema.SubQuery) -> tuple[list[dict], dict]:
+ payloads = {
+ "reddit": [
+ {
+ "id": "R1",
+ "title": f"{subquery.search_query} discussion thread",
+ "url": "https://reddit.com/r/example/comments/1",
+ "subreddit": "example",
+ "date": dates.get_date_range(5)[0],
+ "engagement": {"score": 120, "num_comments": 48, "upvote_ratio": 0.91},
+ "selftext": f"Community discussion about {subquery.search_query}.",
+ "top_comments": [{"excerpt": "Strong firsthand feedback from users."}],
+ "relevance": 0.82,
+ "why_relevant": "Mock Reddit result",
+ }
+ ],
+ "x": [
+ {
+ "id": "X1",
+ "text": f"People on X are discussing {subquery.search_query} right now.",
+ "url": "https://x.com/example/status/1",
+ "author_handle": "example",
+ "date": dates.get_date_range(2)[0],
+ "engagement": {"likes": 200, "reposts": 35, "replies": 18, "quotes": 4},
+ "relevance": 0.79,
+ "why_relevant": "Mock X result",
+ }
+ ],
+ "grounding": [
+ {
+ "id": "WB1",
+ "title": f"{subquery.search_query} article",
+ "url": "https://example.com/article",
+ "source_domain": "example.com",
+ "snippet": f"Recent web reporting about {subquery.search_query}.",
+ "date": dates.get_date_range(7)[0],
+ "relevance": 0.88,
+ "why_relevant": "Brave web search",
+ }
+ ],
+ }
+ if source == "grounding":
+ return payloads.get(source, []), {
+ "label": subquery.label,
+ "mock": True,
+ "webSearchQueries": [subquery.search_query],
+ "resultCount": 1,
+ }
+ return payloads.get(source, []), {}
diff --git a/scripts/lib/planner.py b/scripts/lib/planner.py
new file mode 100644
index 0000000..6d83f56
--- /dev/null
+++ b/scripts/lib/planner.py
@@ -0,0 +1,576 @@
+"""LLM-first query planning with deterministic guards for risky queries."""
+
+from __future__ import annotations
+
+import json
+import re
+
+from . import http, providers, query, schema
+
+ALLOWED_INTENTS = {
+ "factual",
+ "product",
+ "concept",
+ "opinion",
+ "how_to",
+ "comparison",
+ "breaking_news",
+ "prediction",
+}
+ALLOWED_CLUSTER_MODES = {"none", "story", "workflow", "market", "debate"}
+QUICK_SOURCE_PRIORITY = {
+ "factual": ["hackernews", "reddit", "x", "youtube"],
+ "product": ["youtube", "reddit", "x", "tiktok"],
+ "concept": ["hackernews", "reddit", "x", "youtube"],
+ "opinion": ["reddit", "x", "youtube", "hackernews"],
+ "how_to": ["youtube", "reddit", "x", "hackernews"],
+ "comparison": ["reddit", "x", "hackernews", "youtube"],
+ "breaking_news": ["x", "reddit", "hackernews", "youtube", "polymarket"],
+ "prediction": ["polymarket", "x", "hackernews", "reddit", "youtube"],
+}
+SOURCE_PRIORITY = {
+ "factual": ["hackernews", "reddit", "x", "youtube"],
+ "product": ["youtube", "reddit", "x", "tiktok", "hackernews"],
+ "concept": ["hackernews", "reddit", "x", "youtube"],
+ "opinion": ["reddit", "x", "youtube", "hackernews"],
+ "how_to": ["youtube", "reddit", "x", "hackernews"],
+ "comparison": ["reddit", "x", "hackernews", "youtube"],
+ "breaking_news": ["x", "reddit", "hackernews", "youtube", "polymarket"],
+ "prediction": ["polymarket", "x", "hackernews", "reddit", "youtube"],
+}
+SOURCE_LIMITS = {
+ "quick": {
+ "factual": 2,
+ "product": 2,
+ "concept": 2,
+ "opinion": 2,
+ "how_to": 2,
+ "comparison": 2,
+ "breaking_news": 2,
+ "prediction": 2,
+ },
+ # "default" intentionally absent: all available sources are searched
+ # at default depth. Fusion and reranking handle quality. quick mode
+ # uses tight budgets above for latency.
+}
+INTENT_SOURCE_EXCLUSIONS: dict[str, set[str]] = {
+ "concept": {"polymarket"},
+ "how_to": {"polymarket"},
+}
+SOURCE_CAPABILITIES = {
+ "reddit": {"discussion", "social"},
+ "x": {"discussion", "social"},
+ "youtube": {"video", "video_longform", "discussion"},
+ "tiktok": {"video", "video_shortform", "social"},
+ "instagram": {"video", "video_shortform", "social"},
+ "hackernews": {"discussion", "link"},
+ "bluesky": {"discussion", "social"},
+ "truthsocial": {"discussion", "social"},
+ "polymarket": {"market"},
+ "xiaohongshu": {"video", "video_shortform", "social"},
+ "github": {"discussion", "link"},
+ "grounding": {"web", "reference", "link"},
+ "perplexity": {"web", "reference", "analysis"},
+}
+DEFAULT_INTENT_CAPABILITIES = {
+ "comparison": {"discussion", "video", "web", "reference", "social", "link", "market"},
+ "how_to": {"discussion", "video", "web", "reference", "link"},
+}
+
+def plan_query(
+ *,
+ topic: str,
+ available_sources: list[str],
+ requested_sources: list[str] | None,
+ depth: str,
+ provider: providers.ReasoningClient | None,
+ model: str | None,
+ context: str = "",
+) -> schema.QueryPlan:
+ """Create a query plan. Comparison queries with extractable entities use a
+ deterministic plan; other intents prefer the configured reasoning provider."""
+ if _should_force_deterministic_plan(topic):
+ return _fallback_plan(
+ topic,
+ available_sources,
+ requested_sources,
+ depth,
+ note="deterministic-comparison-plan",
+ )
+ prompt = _build_prompt(topic, available_sources, requested_sources, depth)
+ if context:
+ prompt += f"\n\nCurrent context (from web search): {context}"
+ if provider and model:
+ try:
+ raw = provider.generate_json(model, prompt)
+ plan = _sanitize_plan(raw, topic, available_sources, requested_sources, depth)
+ if plan.subqueries:
+ return plan
+ except (ValueError, KeyError, json.JSONDecodeError, OSError, http.HTTPError) as exc:
+ import sys
+ print(f"[Planner] LLM planning failed, using deterministic fallback: {type(exc).__name__}: {exc}", file=sys.stderr)
+ return _fallback_plan(
+ topic, available_sources, requested_sources, depth,
+ note=f"fallback-plan (LLM error: {type(exc).__name__})",
+ )
+ return _fallback_plan(topic, available_sources, requested_sources, depth)
+
+
+def _build_prompt(
+ topic: str,
+ available_sources: list[str],
+ requested_sources: list[str] | None,
+ depth: str,
+) -> str:
+ requested = ", ".join(requested_sources or ["auto"])
+ available = ", ".join(available_sources)
+ return f"""
+You are the query planner for a live last-30-days research pipeline.
+
+Topic: {topic}
+Depth: {depth}
+Available sources: {available}
+Requested sources: {requested}
+
+Return JSON only with this shape:
+{{
+ "intent": "factual|product|concept|opinion|how_to|comparison|breaking_news|prediction",
+ "freshness_mode": "strict_recent|balanced_recent|evergreen_ok",
+ "cluster_mode": "none|story|workflow|market|debate",
+ "source_weights": {{"source_name": 0.0}},
+ "subqueries": [
+ {{
+ "label": "short label",
+ "search_query": "keyword style query for search APIs",
+ "ranking_query": "natural language rewrite for reranking",
+ "sources": ["reddit", "x", "grounding"],
+ "weight": 1.0
+ }}
+ ],
+ "notes": ["optional short notes"]
+}}
+
+Rules:
+- emit 1 to 4 subqueries
+- every subquery must include both search_query and ranking_query
+- sources must be drawn from Available sources only
+- use cluster_mode=none for factual or many how-to queries
+- use strict_recent for breaking news and most predictions
+- use debate for comparison/opinion, market for prediction, workflow for how_to, story for breaking_news
+- search_query should be concise and keyword-heavy
+- ranking_query should read like a natural-language question
+- preserve exact proper nouns and entity strings from the topic
+- NEVER include temporal phrases in search_query: no 'last 30 days', 'recent', month names, year numbers
+- NEVER include meta-research phrases: no 'news', 'updates', 'public appearances', 'latest developments'
+- search_query should match how content is TITLED on platforms
+- GitHub (Issues/PRs) is best for engineering, developer tools, and open source topics: 'kanye west bully' not 'kanye west album news March 2026'
+""".strip()
+
+
+def _sanitize_plan(
+ raw: dict,
+ topic: str,
+ available_sources: list[str],
+ requested_sources: list[str] | None,
+ depth: str,
+) -> schema.QueryPlan:
+ intent_hint = str(raw.get("intent") or _infer_intent(topic)).strip()
+ if intent_hint not in ALLOWED_INTENTS:
+ intent_hint = _infer_intent(topic)
+ requested = set(requested_sources or [])
+ available = set(available_sources)
+ eligible_sources = [
+ source for source in available_sources
+ if (not requested or source in requested)
+ ]
+ source_weights = {
+ source: float(weight)
+ for source, weight in (raw.get("source_weights") or {}).items()
+ if source in available
+ }
+ if requested:
+ source_weights = {source: weight for source, weight in source_weights.items() if source in requested}
+ if not source_weights:
+ source_weights = _default_source_weights(_infer_intent(topic), eligible_sources)
+ # Ensure all eligible sources are available for subqueries. The LLM may
+ # assign high weights to its preferred sources, but omitted sources still
+ # participate with base weight so retrieval can overfetch and let fusion
+ # decide quality.
+ for source in eligible_sources:
+ source_weights.setdefault(source, 1.0)
+ if intent_hint in DEFAULT_INTENT_CAPABILITIES and depth != "quick":
+ for source in _default_sources_for_intent(intent_hint, eligible_sources):
+ source_weights.setdefault(source, 1.0)
+ source_weights = _normalize_weights(source_weights)
+
+ subqueries: list[schema.SubQuery] = []
+ for index, subquery in enumerate((raw.get("subqueries") or [])[:_max_subqueries(intent_hint)], start=1):
+ if not isinstance(subquery, dict):
+ continue
+ sources = [source for source in subquery.get("sources") or [] if source in source_weights]
+ if requested:
+ sources = [source for source in sources if source in requested]
+ if not sources:
+ sources = list(source_weights)
+ search_query = str(subquery.get("search_query") or "").strip()
+ ranking_query = str(subquery.get("ranking_query") or "").strip()
+ if not search_query or not ranking_query:
+ continue
+ subqueries.append(
+ schema.SubQuery(
+ label=str(subquery.get("label") or f"q{index}").strip() or f"q{index}",
+ search_query=search_query,
+ ranking_query=ranking_query,
+ sources=sources,
+ weight=max(0.05, float(subquery.get("weight") or 1.0)),
+ )
+ )
+ if depth == "quick" and subqueries:
+ subqueries = subqueries[:1]
+ if not subqueries:
+ return _fallback_plan(topic, available_sources, requested_sources, depth)
+
+ intent = intent_hint
+ freshness_mode = str(raw.get("freshness_mode") or _default_freshness(intent)).strip()
+ if intent == "how_to":
+ freshness_mode = "evergreen_ok"
+ cluster_mode = str(raw.get("cluster_mode") or _default_cluster_mode(intent)).strip()
+ if cluster_mode not in ALLOWED_CLUSTER_MODES:
+ cluster_mode = _default_cluster_mode(intent)
+
+ return schema.QueryPlan(
+ intent=intent,
+ freshness_mode=freshness_mode,
+ cluster_mode=cluster_mode,
+ raw_topic=topic,
+ subqueries=_normalize_subquery_weights(_trim_subqueries_for_depth(subqueries, intent, depth, eligible_sources)),
+ source_weights=source_weights,
+ notes=[str(note).strip() for note in raw.get("notes") or [] if str(note).strip()],
+ )
+
+
+def _normalize_subquery_weights(subqueries: list[schema.SubQuery]) -> list[schema.SubQuery]:
+ total = sum(subquery.weight for subquery in subqueries) or 1.0
+ return [
+ schema.SubQuery(
+ label=subquery.label,
+ search_query=subquery.search_query,
+ ranking_query=subquery.ranking_query,
+ sources=subquery.sources,
+ weight=subquery.weight / total,
+ )
+ for subquery in subqueries
+ ]
+
+
+def _normalize_weights(weights: dict[str, float]) -> dict[str, float]:
+ total = sum(max(weight, 0.0) for weight in weights.values()) or 1.0
+ return {
+ source: max(weight, 0.0) / total
+ for source, weight in weights.items()
+ }
+
+
+def _trim_subqueries_for_depth(
+ subqueries: list[schema.SubQuery],
+ intent: str,
+ depth: str,
+ available_sources: list[str],
+) -> list[schema.SubQuery]:
+ # At non-quick depth, expand sources: use capability routing for intents
+ # that define it, or all available sources otherwise. The LLM planner may
+ # assign narrow source lists; we override to let fusion decide quality.
+ if depth != "quick":
+ expanded_sources = _default_sources_for_intent(intent, available_sources)
+ return [
+ schema.SubQuery(
+ label=subquery.label,
+ search_query=subquery.search_query,
+ ranking_query=subquery.ranking_query,
+ sources=expanded_sources,
+ weight=subquery.weight,
+ )
+ for subquery in subqueries
+ ]
+ limits = SOURCE_LIMITS.get(depth)
+ if not limits:
+ return subqueries
+ priority_table = QUICK_SOURCE_PRIORITY if depth == "quick" else SOURCE_PRIORITY
+ priority = priority_table.get(intent, priority_table["breaking_news"])
+ limit = limits.get(intent, 3)
+ ranked_sources = [source for source in priority if source in available_sources]
+ if not ranked_sources:
+ ranked_sources = list(available_sources)
+ trimmed = []
+ for subquery in subqueries:
+ if depth in {"quick", "default"}:
+ preferred_sources = ranked_sources[:limit]
+ else:
+ preferred_sources = [source for source in ranked_sources if source in subquery.sources][:limit]
+ if len(preferred_sources) < limit:
+ for source in ranked_sources:
+ if source in preferred_sources:
+ continue
+ preferred_sources.append(source)
+ if len(preferred_sources) >= limit:
+ break
+ trimmed.append(
+ schema.SubQuery(
+ label=subquery.label,
+ search_query=subquery.search_query,
+ ranking_query=subquery.ranking_query,
+ sources=preferred_sources,
+ weight=subquery.weight,
+ )
+ )
+ return trimmed
+
+
+def _fallback_plan(
+ topic: str,
+ available_sources: list[str],
+ requested_sources: list[str] | None,
+ depth: str,
+ note: str = "fallback-plan",
+) -> schema.QueryPlan:
+ intent = _infer_intent(topic)
+ allowed_sources = requested_sources or available_sources
+ source_weights = _default_source_weights(intent, allowed_sources)
+ core = query.extract_core_subject(topic, max_words=6, strip_suffixes=True)
+ base_search = _keyword_query(topic, core)
+ base_ranking = _ranking_query(topic, core)
+
+ subqueries = [schema.SubQuery(
+ label="primary",
+ search_query=base_search,
+ ranking_query=base_ranking,
+ sources=list(source_weights),
+ weight=1.0,
+ )]
+
+ if depth != "quick" and intent == "comparison":
+ entities = _comparison_entities(topic)
+ if entities:
+ for index, entity in enumerate(entities, start=1):
+ subqueries.append(
+ schema.SubQuery(
+ label=f"entity-{index}",
+ search_query=entity,
+ ranking_query=f"What recent evidence from the last 30 days is most relevant to {entity} in the comparison '{topic}'?",
+ sources=list(source_weights),
+ weight=0.65,
+ )
+ )
+ elif depth != "quick" and intent == "prediction":
+ subqueries.append(
+ schema.SubQuery(
+ label="odds",
+ search_query=f"{base_search} odds forecast",
+ ranking_query=f"What are the current odds, forecasts, or market signals about {topic}?",
+ sources=[source for source in source_weights if source in {"polymarket", "grounding", "x", "reddit"}] or list(source_weights),
+ weight=0.7,
+ )
+ )
+ elif depth != "quick" and intent == "breaking_news":
+ subqueries.append(
+ schema.SubQuery(
+ label="reaction",
+ search_query=f"{base_search} reaction update",
+ ranking_query=f"What new reactions or follow-up reporting from the last 30 days matter for {topic}?",
+ sources=[source for source in source_weights if source in {"x", "reddit", "grounding", "hackernews"}] or list(source_weights),
+ weight=0.7,
+ )
+ )
+
+ return schema.QueryPlan(
+ intent=intent,
+ freshness_mode=_default_freshness(intent),
+ cluster_mode=_default_cluster_mode(intent),
+ raw_topic=topic,
+ subqueries=_normalize_subquery_weights(
+ _trim_subqueries_for_depth(subqueries[:_max_subqueries(intent)], intent, depth, list(source_weights))
+ ),
+ source_weights=_normalize_weights(source_weights),
+ notes=[note],
+ )
+
+
+def _infer_intent(topic: str) -> str:
+ text = topic.lower().strip()
+ if re.search(r"\b(vs|versus|compare|compared to|difference between)\b", text):
+ return "comparison"
+ # Slash-separated proper nouns: "React/Vue/Svelte" (not URLs, not acronyms like CI/CD or I/O)
+ if not re.search(r"https?://", topic) and re.search(r"\b[A-Z][a-z]{2,}(?:/[A-Z][a-z]{2,})+\b", topic):
+ return "comparison"
+ if re.search(r"\b(odds|predict|prediction|forecast|chance|probability|will .* win)\b", text):
+ return "prediction"
+ if re.search(r"\b(how to|tutorial|guide|setup|step by step|deploy|install)\b", text):
+ return "how_to"
+ if re.search(r"\b(what is|what are|who is|who acquired|when did|parameter count|release date)\b", text):
+ return "factual"
+ if re.search(r"\b(thoughts on|worth it|should i|opinion|review)\b", text):
+ return "opinion"
+ if re.search(r"\b(latest|news|announced|just shipped|launched|released|update)\b", text):
+ return "breaking_news"
+ if re.search(r"\b(pricing|feature|features|best .* for|top .* for)\b", text):
+ return "product"
+ if re.search(r"\b(explain|concept|protocol|architecture|what does)\b", text):
+ return "concept"
+ if re.search(r"\b(tournament|championship|playoffs|march madness|world cup|olympics|super bowl|final four|ceremony|awards|keynote)\b", text):
+ return "breaking_news"
+ return "breaking_news"
+
+
+def _default_freshness(intent: str) -> str:
+ if intent in {"breaking_news", "prediction"}:
+ return "strict_recent"
+ if intent in {"concept", "how_to"}:
+ return "evergreen_ok"
+ return "balanced_recent"
+
+
+def _default_cluster_mode(intent: str) -> str:
+ return {
+ "breaking_news": "story",
+ "comparison": "debate",
+ "opinion": "debate",
+ "prediction": "market",
+ "how_to": "workflow",
+ "factual": "none",
+ "product": "none",
+ "concept": "none",
+ }.get(intent, "none")
+
+
+def _default_source_weights(intent: str, sources: list[str]) -> dict[str, float]:
+ base = {source: 1.0 for source in sources}
+ if intent == "prediction":
+ for source, bonus in {"polymarket": 2.5, "x": 1.3}.items():
+ if source in base:
+ base[source] += bonus
+ elif intent == "breaking_news":
+ for source, bonus in {"x": 1.5, "reddit": 1.3, "hackernews": 0.8}.items():
+ if source in base:
+ base[source] += bonus
+ elif intent == "how_to":
+ for source, bonus in {"youtube": 2.0, "hackernews": 0.8}.items():
+ if source in base:
+ base[source] += bonus
+ elif intent == "factual":
+ for source, bonus in {"reddit": 0.8, "x": 0.5}.items():
+ if source in base:
+ base[source] += bonus
+ return base
+
+
+def _keyword_query(topic: str, core: str) -> str:
+ compounds = query.extract_compound_terms(topic)
+ quoted = " ".join(f"\"{term}\"" for term in compounds[:2])
+ keywords = [quoted.strip(), core.strip() or topic.strip()]
+ return " ".join(part for part in keywords if part).strip()
+
+
+def _ranking_query(topic: str, core: str) -> str:
+ if topic.strip().endswith("?"):
+ return topic.strip()
+ if core and core.lower() != topic.lower():
+ return f"What recent evidence from the last 30 days is most relevant to {topic}, especially about {core}?"
+ return f"What recent evidence from the last 30 days is most relevant to {topic}?"
+
+
+_TRAILING_CONTEXT = re.compile(
+ r"\s+\b(?:for|in|on|at|to|with|about|from|by|during|since|after|before|using|via)\b.*$",
+ re.I,
+)
+
+
+def _comparison_entities(topic: str) -> list[str]:
+ # "difference between X and Y" -> "X vs Y" (replace "and" only in this context)
+ normalized = re.sub(
+ r"\bdifference between\s+(.+?)\s+and\s+",
+ r"\1 vs ",
+ topic,
+ flags=re.I,
+ )
+ normalized = re.sub(r"\b(compared to)\b", " vs ", normalized, flags=re.I)
+ parts = [
+ part.strip(" \t\r\n?.,:;!()[]{}\"'")
+ for part in re.split(r"\bvs\.?\b|\bversus\b|/", normalized, flags=re.I)
+ if part.strip(" \t\r\n?.,:;!()[]{}\"'")
+ ]
+ # Strip trailing context from parts ("Svelte for frontend in 2026" -> "Svelte")
+ if len(parts) >= 2:
+ parts = [_TRAILING_CONTEXT.sub("", part).strip() or part for part in parts]
+ deduped = []
+ for part in parts:
+ if part and part not in deduped:
+ deduped.append(part)
+ return deduped[:_max_subqueries("comparison")]
+ return []
+
+
+def _should_force_deterministic_plan(topic: str) -> bool:
+ return _infer_intent(topic) == "comparison" and len(_comparison_entities(topic)) >= 2
+
+
+def _max_subqueries(intent: str) -> int:
+ if intent == "comparison":
+ return 4
+ if intent in {"factual", "concept"}:
+ return 2
+ return 3
+
+
+def _default_sources_for_intent(intent: str, available_sources: list[str]) -> list[str]:
+ if intent == "how_to":
+ sources = _how_to_sources(available_sources)
+ else:
+ target_capabilities = DEFAULT_INTENT_CAPABILITIES.get(intent)
+ if not target_capabilities:
+ sources = list(available_sources)
+ else:
+ matched = [
+ source
+ for source in available_sources
+ if SOURCE_CAPABILITIES.get(source, set()) & target_capabilities
+ ]
+ sources = matched or list(available_sources)
+ excluded = INTENT_SOURCE_EXCLUSIONS.get(intent, set())
+ if excluded:
+ filtered = [s for s in sources if s not in excluded]
+ return filtered or sources
+ return sources
+
+
+def _how_to_sources(available_sources: list[str]) -> list[str]:
+ """Pick one source per role: web/reference, video (prefer longform), discussion."""
+ selected: set[str] = set()
+ has_video = False
+ # Order matters: web first, then longform video, generic video, discussion.
+ role_capabilities = [
+ {"web", "reference"},
+ {"video_longform"},
+ {"video"},
+ {"discussion"},
+ ]
+ for role in role_capabilities:
+ is_video_role = role & {"video", "video_longform"}
+ if is_video_role and has_video:
+ continue
+ for source in available_sources:
+ if source in selected:
+ continue
+ if SOURCE_CAPABILITIES.get(source, set()) & role:
+ selected.add(source)
+ if is_video_role:
+ has_video = True
+ break
+ # After core role-based selection, include remaining sources with any
+ # how_to-relevant capability (video, discussion, web, reference, link).
+ how_to_caps = DEFAULT_INTENT_CAPABILITIES.get("how_to", set())
+ for source in available_sources:
+ if source not in selected and SOURCE_CAPABILITIES.get(source, set()) & how_to_caps:
+ selected.add(source)
+ if not selected:
+ return list(available_sources)
+ return [source for source in available_sources if source in selected]
diff --git a/scripts/lib/polymarket.py b/scripts/lib/polymarket.py
index de0ee14..21ff808 100644
--- a/scripts/lib/polymarket.py
+++ b/scripts/lib/polymarket.py
@@ -12,8 +12,7 @@ from concurrent.futures import ThreadPoolExecutor, as_completed
from typing import Any, Dict, List, Optional
from urllib.parse import quote_plus, urlencode
-from . import http
-from .query_type import detect_query_type
+from . import http, log
from .relevance import LOW_SIGNAL_QUERY_TOKENS, token_overlap_relevance
GAMMA_SEARCH_URL = "https://gamma-api.polymarket.com/public-search"
@@ -34,10 +33,7 @@ RESULT_CAP = {
def _log(msg: str):
- """Log to stderr (only in TTY mode to avoid cluttering Claude Code output)."""
- if sys.stderr.isatty():
- sys.stderr.write(f"[PM] {msg}\n")
- sys.stderr.flush()
+ log.source_log("PM", msg)
def _extract_core_subject(topic: str) -> str:
@@ -75,7 +71,7 @@ def _expand_queries(topic: str) -> List[str]:
words = core.split()
if len(words) >= 2:
for word in words:
- if len(word) > 1 and word.lower() not in LOW_SIGNAL_QUERY_TOKENS:
+ if len(word) > 1 and word.lower() not in LOW_SIGNAL_QUERY_TOKENS and word.lower() not in _NOISE_WORDS:
queries.append(word)
# Add the full topic if different from core
@@ -95,6 +91,79 @@ def _expand_queries(topic: str) -> List[str]:
_GENERIC_TAGS = frozenset({"sports", "politics", "crypto", "science", "culture", "pop culture"})
+# Words that are too generic to serve as the sole topic-match signal.
+# If ALL core words from the topic are in this set, we skip filtering (can't meaningfully filter).
+# But if some words are informative and some are generic, we require at least one informative word.
+_NOISE_WORDS = frozenset({
+ # Articles, prepositions, conjunctions
+ "the", "a", "an", "in", "on", "at", "of", "for", "and", "or", "to", "is", "are",
+ "was", "were", "will", "be", "by", "with", "from", "as", "it", "its", "not", "no",
+ "but", "if", "so", "do", "has", "had", "have", "this", "that", "what", "who",
+ # Directional / geographic terms that cause false matches
+ "west", "east", "north", "south", "central", "southern", "northern", "eastern", "western",
+ # Common sports / category terms
+ "champion", "championship", "league", "division", "conference", "cup", "series",
+ "team", "game", "match", "season", "win", "winner", "finals",
+ # Common geographic / place nouns that cause false matches
+ # "club" -> Athletic Club, Racing Club; "island" -> Epstein's Island, Rhode Island
+ "club", "island", "city", "park", "hill", "lake", "bay", "beach", "valley",
+ "river", "mountain", "county", "state", "village", "town", "point", "creek",
+ "springs", "heights", "ridge", "bridge", "harbor", "port", "station", "center",
+ "square", "field", "forest", "garden", "tower", "school", "church", "camp",
+ "ranch", "crossing", "shore", "rock", "summit", "falls", "grove", "haven",
+ # Generic tech terms that match too broadly on Polymarket
+ # "cli" -> any CLI tool market; "mcp" -> protocol markets; "ai" -> every AI market
+ "cli", "mcp", "protocol", "tool", "app", "code", "model", "ai", "api",
+ "software", "plugin", "skill", "agent", "bot", "search", "research",
+ # Generic prediction market terms
+ "market", "odds", "prediction", "forecast", "chance", "probability",
+})
+
+
+def _passes_topic_filter(topic: str, event_title: str) -> bool:
+ """Check if event title contains enough informative words from the topic.
+
+ Prevents noise like "Meek Mill" matching "Mill.com food recycler" by requiring
+ proportional word overlap. For topics with 3+ informative words, at least 2 must
+ match. For shorter topics, 1 match suffices (existing behavior).
+
+ Returns True if the event should be kept, False if it should be filtered out.
+ """
+ core = _extract_core_subject(topic).lower()
+ core_words = [w for w in re.sub(r"[^\w\s]", " ", core).split() if len(w) > 1]
+
+ if not core_words:
+ return True # No words to check against
+
+ # Split into informative vs generic
+ informative = [w for w in core_words if w not in _NOISE_WORDS]
+
+ # If ALL words are generic, we can't meaningfully filter — keep everything
+ if not informative:
+ return True
+
+ # Normalize the title for matching
+ title_lower = " ".join(re.sub(r"[^\w\s]", " ", event_title.lower()).split())
+ title_words = set(title_lower.split())
+
+ # Count how many informative words appear in the title
+ match_count = 0
+ for word in informative:
+ # Check as whole word in the title word set
+ if word in title_words:
+ match_count += 1
+ continue
+ # Also check as substring for compound words (e.g., "kanye" in "kanyewest")
+ if len(word) >= 4 and word in title_lower:
+ match_count += 1
+
+ # For topics with 3+ informative words, require at least 2 matches.
+ # This prevents single-word false positives like "mill" in "Meek Mill"
+ # when the topic is "Mill.com food recycler" (3 informative words).
+ min_matches = 2 if len(informative) >= 3 else 1
+
+ return match_count >= min_matches
+
def _extract_domain_queries(topic: str, events: List[Dict]) -> List[str]:
"""Extract domain-indicator search terms from first-pass event tags.
@@ -130,6 +199,14 @@ def _extract_domain_queries(topic: str, events: List[Dict]) -> List[str]:
return domain_queries
+def _infer_query_intent(topic: str) -> str:
+ """Tiny local fallback for Polymarket search tuning only."""
+ text = topic.lower().strip()
+ if re.search(r"\b(predict|prediction|odds|forecast|chance|probability|will .* win)\b", text):
+ return "prediction"
+ return "breaking_news"
+
+
def _search_single_query(query: str, page: int = 1) -> Dict[str, Any]:
"""Run a single search query against Gamma API."""
params = {
@@ -328,7 +405,7 @@ def _compute_text_similarity(topic: str, title: str, outcomes: List[str] = None)
if core in title_lower:
return 1.0
- query_type = detect_query_type(topic)
+ query_type = _infer_query_intent(topic)
title_score = token_overlap_relevance(core, title)
best_score = title_score
@@ -392,6 +469,7 @@ def parse_polymarket_response(response: Dict[str, Any], topic: str = "") -> List
events = response.get("events", [])
items = []
+ filtered_count = 0
for i, event in enumerate(events):
event_id = event.get("id", "")
title = event.get("title", "")
@@ -403,6 +481,12 @@ def parse_polymarket_response(response: Dict[str, Any], topic: str = "") -> List
if not event.get("active", True):
continue
+ # Filter: skip events that don't match the topic's core subject
+ # This prevents "NFC West" from matching a "Kanye West" search
+ if topic and not _passes_topic_filter(topic, title):
+ filtered_count += 1
+ continue
+
# Get markets for this event
markets = event.get("markets", [])
if not markets:
@@ -574,7 +658,29 @@ def parse_polymarket_response(response: Dict[str, Any], topic: str = "") -> List
"why_relevant": f"Prediction market: {title[:60]}",
})
+ if filtered_count:
+ _log(f"Filtered {filtered_count} noise events (topic: '{topic}')")
+
# Sort by relevance (quality-signal ranked) and apply cap
items.sort(key=lambda x: x["relevance"], reverse=True)
+
+ # Drop ALL results if nothing is genuinely on-topic.
+ # If the best item's relevance is below the threshold, the Gamma API
+ # returned only tangential matches (e.g., "Anthropic best AI model"
+ # for a "CLI vs MCP" query). Better to show 0 than noise.
+ _MIN_RELEVANCE = 0.15
+ if items and items[0]["relevance"] < _MIN_RELEVANCE:
+ _log(f"All {len(items)} Polymarket results below relevance threshold "
+ f"({items[0]['relevance']:.2f} < {_MIN_RELEVANCE}), dropping all")
+ return []
+
+ # Per-item floor: drop individual noise items even if the best item passed
+ _ITEM_MIN_RELEVANCE = 0.10
+ before_count = len(items)
+ items = [i for i in items if i["relevance"] >= _ITEM_MIN_RELEVANCE]
+ dropped = before_count - len(items)
+ if dropped:
+ _log(f"Dropped {dropped} Polymarket items below per-item relevance floor ({_ITEM_MIN_RELEVANCE})")
+
cap = response.get("_cap", len(items))
return items[:cap]
diff --git a/scripts/lib/providers.py b/scripts/lib/providers.py
new file mode 100644
index 0000000..3516731
--- /dev/null
+++ b/scripts/lib/providers.py
@@ -0,0 +1,471 @@
+"""Static provider catalog and runtime client implementations."""
+
+from __future__ import annotations
+
+import json
+import re
+import sys
+from typing import Any
+
+from . import env, http, schema
+
+GEMINI_FLASH_LITE = "gemini-3.1-flash-lite-preview"
+GEMINI_PRO = "gemini-3.1-pro-preview"
+OPENAI_DEFAULT = "gpt-5.4-nano"
+XAI_DEFAULT = "grok-4-1-fast"
+
+GEMINI_URL = "https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContent?key={api_key}"
+OPENAI_RESPONSES_URL = "https://api.openai.com/v1/responses"
+CODEX_RESPONSES_URL = "https://chatgpt.com/backend-api/codex/responses"
+XAI_RESPONSES_URL = "https://api.x.ai/v1/responses"
+OPENROUTER_URL = "https://openrouter.ai/api/v1/chat/completions"
+OPENROUTER_DEFAULT = "google/gemini-flash-2.0"
+
+
+class ReasoningClient:
+ """Shared interface for planner and rerank providers."""
+
+ name: str
+
+ def generate_text(
+ self,
+ model: str,
+ prompt: str,
+ *,
+ tools: list[dict[str, Any]] | None = None,
+ response_mime_type: str | None = None,
+ ) -> str:
+ raise NotImplementedError
+
+ def generate_json(
+ self,
+ model: str,
+ prompt: str,
+ *,
+ tools: list[dict[str, Any]] | None = None,
+ ) -> dict[str, Any]:
+ text = self.generate_text(model, prompt, tools=tools, response_mime_type="application/json")
+ return extract_json(text)
+
+
+class GeminiClient(ReasoningClient):
+ name = "gemini"
+
+ def __init__(self, api_key: str):
+ self.api_key = api_key
+
+ def _generate_content(
+ self,
+ model: str,
+ prompt: str,
+ *,
+ tools: list[dict[str, Any]] | None = None,
+ response_mime_type: str | None = None,
+ ) -> dict[str, Any]:
+ body: dict[str, Any] = {
+ "contents": [{"parts": [{"text": prompt}]}],
+ "generationConfig": {"temperature": 0},
+ }
+ if response_mime_type:
+ body["generationConfig"]["responseMimeType"] = response_mime_type
+ if tools:
+ body["tools"] = tools
+ return http.post(
+ GEMINI_URL.format(model=model, api_key=self.api_key),
+ body,
+ headers={"Content-Type": "application/json"},
+ timeout=90,
+ )
+
+ def generate_text(
+ self,
+ model: str,
+ prompt: str,
+ *,
+ tools: list[dict[str, Any]] | None = None,
+ response_mime_type: str | None = None,
+ ) -> str:
+ payload = self._generate_content(
+ model,
+ prompt,
+ tools=tools,
+ response_mime_type=response_mime_type,
+ )
+ return extract_gemini_text(payload)
+
+ def ground_search(self, model: str, prompt: str) -> dict[str, Any]:
+ return self._generate_content(model, prompt, tools=[{"google_search": {}}])
+
+ def url_context_json(self, model: str, prompt: str) -> dict[str, Any]:
+ return self.generate_json(model, prompt, tools=[{"url_context": {}}])
+
+
+class OpenAIClient(ReasoningClient):
+ name = "openai"
+
+ def __init__(self, token: str, auth_source: str, account_id: str | None):
+ self.token = token
+ self.auth_source = auth_source
+ self.account_id = account_id
+
+ def generate_text(
+ self,
+ model: str,
+ prompt: str,
+ *,
+ tools: list[dict[str, Any]] | None = None,
+ response_mime_type: str | None = None,
+ ) -> str:
+ del tools, response_mime_type
+ if self.auth_source == env.AUTH_SOURCE_CODEX:
+ payload = {
+ "model": model,
+ "stream": True,
+ "store": False,
+ "input": [
+ {
+ "type": "message",
+ "role": "user",
+ "content": [{"type": "input_text", "text": prompt}],
+ }
+ ],
+ }
+ headers = {
+ "Authorization": f"Bearer {self.token}",
+ "chatgpt-account-id": self.account_id or "",
+ "OpenAI-Beta": "responses=experimental",
+ "originator": "pi",
+ "Content-Type": "application/json",
+ }
+ raw = http.post_raw(CODEX_RESPONSES_URL, payload, headers=headers, timeout=90)
+ return extract_openai_text(_parse_codex_stream(raw))
+
+ payload = {
+ "model": model,
+ "store": False,
+ "input": prompt,
+ "temperature": 0,
+ }
+ response = http.post(
+ OPENAI_RESPONSES_URL,
+ payload,
+ headers={
+ "Authorization": f"Bearer {self.token}",
+ "Content-Type": "application/json",
+ },
+ timeout=90,
+ )
+ return extract_openai_text(response)
+
+
+class XAIClient(ReasoningClient):
+ name = "xai"
+
+ def __init__(self, api_key: str):
+ self.api_key = api_key
+
+ def generate_text(
+ self,
+ model: str,
+ prompt: str,
+ *,
+ tools: list[dict[str, Any]] | None = None,
+ response_mime_type: str | None = None,
+ ) -> str:
+ del tools, response_mime_type
+ payload = {
+ "model": model,
+ "input": [{"role": "user", "content": prompt}],
+ }
+ response = http.post(
+ XAI_RESPONSES_URL,
+ payload,
+ headers={
+ "Authorization": f"Bearer {self.api_key}",
+ "Content-Type": "application/json",
+ },
+ timeout=90,
+ )
+ return extract_openai_text(response)
+
+
+class OpenRouterClient(ReasoningClient):
+ name = "openrouter"
+
+ def __init__(self, api_key: str):
+ self.api_key = api_key
+
+ def generate_text(
+ self,
+ model: str,
+ prompt: str,
+ *,
+ tools: list[dict[str, Any]] | None = None,
+ response_mime_type: str | None = None,
+ ) -> str:
+ del tools, response_mime_type
+ payload = {
+ "model": model,
+ "messages": [{"role": "user", "content": prompt}],
+ "temperature": 0,
+ }
+ response = http.post(
+ OPENROUTER_URL,
+ payload,
+ headers={
+ "Authorization": f"Bearer {self.api_key}",
+ "Content-Type": "application/json",
+ },
+ timeout=90,
+ )
+ return extract_openai_text(response)
+
+
+_MODEL_DEFAULTS: dict[str, tuple[str, str]] = {
+ "gemini": (GEMINI_FLASH_LITE, GEMINI_FLASH_LITE),
+ "openai": (OPENAI_DEFAULT, OPENAI_DEFAULT),
+ "xai": (XAI_DEFAULT, XAI_DEFAULT),
+ "openrouter": (OPENROUTER_DEFAULT, OPENROUTER_DEFAULT),
+}
+
+
+def _resolve_model_pins(config: dict[str, Any], depth: str, provider_name: str) -> tuple[str, str, str]:
+ """Resolve planner, rerank, and grounding model pins for a provider."""
+ default_planner, default_rerank = _MODEL_DEFAULTS.get(provider_name, (GEMINI_FLASH_LITE, GEMINI_FLASH_LITE))
+ if depth == "deep" and provider_name == "gemini":
+ default_rerank = GEMINI_PRO
+
+ planner_model = config.get("LAST30DAYS_PLANNER_MODEL") or default_planner
+ rerank_model = config.get("LAST30DAYS_RERANK_MODEL") or default_rerank
+
+ if provider_name == "gemini":
+ _require_gemini_31_preview(planner_model, role="planner")
+ _require_gemini_31_preview(rerank_model, role="rerank")
+
+ return planner_model, rerank_model
+
+
+def mock_runtime(config: dict[str, Any], depth: str) -> schema.ProviderRuntime:
+ """Resolve model pins for mock mode without requiring live credentials."""
+ provider_name = (config.get("LAST30DAYS_REASONING_PROVIDER") or "gemini").lower()
+ if provider_name == "auto":
+ provider_name = "gemini"
+ if provider_name not in _MODEL_DEFAULTS:
+ raise RuntimeError(f"Unsupported reasoning provider: {provider_name}")
+
+ planner_model, rerank_model = _resolve_model_pins(config, depth, provider_name)
+ return schema.ProviderRuntime(
+ reasoning_provider=provider_name,
+ planner_model=planner_model,
+ rerank_model=rerank_model,
+
+ x_search_backend=_resolve_x_backend(config),
+ )
+
+
+def resolve_runtime(config: dict[str, Any], depth: str) -> tuple[schema.ProviderRuntime, ReasoningClient | None]:
+ """Resolve the reasoning provider and pinned models."""
+ provider_name = (config.get("LAST30DAYS_REASONING_PROVIDER") or "auto").lower()
+ google_key = config.get("GOOGLE_API_KEY") or config.get("GEMINI_API_KEY") or config.get("GOOGLE_GENAI_API_KEY")
+ openai_token = config.get("OPENAI_API_KEY")
+ xai_key = config.get("XAI_API_KEY")
+
+ if provider_name == "auto":
+ if google_key:
+ provider_name = "gemini"
+ elif openai_token and config.get("OPENAI_AUTH_STATUS") == env.AUTH_STATUS_OK:
+ provider_name = "openai"
+ elif xai_key:
+ provider_name = "xai"
+ elif config.get("OPENROUTER_API_KEY"):
+ provider_name = "openrouter"
+ else:
+ return schema.ProviderRuntime(
+ reasoning_provider="local",
+ planner_model="deterministic",
+ rerank_model="local-score",
+ x_search_backend=_resolve_x_backend(config),
+ ), None
+
+ planner_model, rerank_model = _resolve_model_pins(config, depth, provider_name)
+
+ if provider_name == "gemini":
+ if not google_key:
+ raise RuntimeError("Gemini selected but no Google API key is configured.")
+ runtime = schema.ProviderRuntime(
+ reasoning_provider="gemini",
+ planner_model=planner_model,
+ rerank_model=rerank_model,
+
+ x_search_backend=_resolve_x_backend(config),
+ )
+ return runtime, GeminiClient(google_key)
+
+ if provider_name == "openai":
+ if not openai_token or config.get("OPENAI_AUTH_STATUS") != env.AUTH_STATUS_OK:
+ raise RuntimeError("OpenAI selected but no valid OpenAI auth is configured.")
+ runtime = schema.ProviderRuntime(
+ reasoning_provider="openai",
+ planner_model=planner_model,
+ rerank_model=rerank_model,
+
+ x_search_backend=_resolve_x_backend(config),
+ )
+ return runtime, OpenAIClient(
+ openai_token,
+ config.get("OPENAI_AUTH_SOURCE") or env.AUTH_SOURCE_API_KEY,
+ config.get("OPENAI_CHATGPT_ACCOUNT_ID"),
+ )
+
+ if provider_name == "xai":
+ if not xai_key:
+ raise RuntimeError("xAI selected but XAI_API_KEY is not configured.")
+ runtime = schema.ProviderRuntime(
+ reasoning_provider="xai",
+ planner_model=planner_model,
+ rerank_model=rerank_model,
+
+ x_search_backend=_resolve_x_backend(config),
+ )
+ return runtime, XAIClient(xai_key)
+
+ if provider_name == "openrouter":
+ openrouter_key = config.get("OPENROUTER_API_KEY")
+ if not openrouter_key:
+ raise RuntimeError("OpenRouter selected but OPENROUTER_API_KEY is not configured.")
+ runtime = schema.ProviderRuntime(
+ reasoning_provider="openrouter",
+ planner_model=planner_model,
+ rerank_model=rerank_model,
+ x_search_backend=_resolve_x_backend(config),
+ )
+ return runtime, OpenRouterClient(openrouter_key)
+
+ raise RuntimeError(f"Unsupported reasoning provider: {provider_name}")
+
+
+def _resolve_x_backend(config: dict[str, Any]) -> str | None:
+ preferred = (config.get("LAST30DAYS_X_BACKEND") or "").lower()
+ if preferred in {"xai", "bird"}:
+ return preferred
+ return env.get_x_source(config)
+
+
+def _require_gemini_31_preview(model: str, *, role: str) -> None:
+ if model.startswith("gemini-3.1-") and model.endswith("-preview"):
+ return
+ raise RuntimeError(
+ f"{role} must use a Gemini 3.1 preview model. Got: {model}"
+ )
+
+
+def extract_json(text: str) -> dict[str, Any]:
+ """Extract the first JSON object from a model response."""
+ text = text.strip()
+ if not text:
+ raise ValueError("Expected JSON response, got empty text")
+ try:
+ return json.loads(text)
+ except json.JSONDecodeError:
+ match = re.search(r"\{[\s\S]*\}", text)
+ if not match:
+ raise
+ return json.loads(match.group(0))
+
+
+def extract_gemini_text(payload: dict[str, Any]) -> str:
+ for candidate in payload.get("candidates", []):
+ content = candidate.get("content") or {}
+ for part in content.get("parts", []):
+ text = part.get("text")
+ if text:
+ return text
+ if payload:
+ print(f"[Providers] extract_gemini_text: no text in payload keys: {list(payload.keys())}", file=sys.stderr)
+ return ""
+
+
+def extract_openai_text(payload: dict[str, Any]) -> str:
+ if isinstance(payload.get("output_text"), str):
+ return payload["output_text"]
+ output = payload.get("output") or payload.get("choices") or []
+ for item in output:
+ if isinstance(item, str):
+ return item
+ if isinstance(item, dict):
+ if isinstance(item.get("text"), str):
+ return item["text"]
+ content = item.get("content") or []
+ if isinstance(content, list):
+ for part in content:
+ if isinstance(part, dict) and isinstance(part.get("text"), str):
+ return part["text"]
+ if isinstance(part, dict) and part.get("type") == "output_text" and isinstance(part.get("text"), str):
+ return part["text"]
+ message = item.get("message") or {}
+ if isinstance(message, dict) and isinstance(message.get("content"), str):
+ return message["content"]
+ if payload:
+ print(f"[Providers] extract_openai_text: no text in payload keys: {list(payload.keys())}", file=sys.stderr)
+ return ""
+
+
+def _parse_sse_chunk(chunk: str) -> dict[str, Any] | None:
+ data_lines = [
+ line[5:].strip()
+ for line in chunk.split("\n")
+ if line.startswith("data:")
+ ]
+ if not data_lines:
+ return None
+ data = "\n".join(data_lines).strip()
+ if not data or data == "[DONE]":
+ return None
+ try:
+ return json.loads(data)
+ except json.JSONDecodeError:
+ print(f"[Providers] _parse_sse_chunk: invalid JSON: {data[:100]}", file=sys.stderr)
+ return None
+
+
+def _parse_codex_stream(raw: str) -> dict[str, Any]:
+ events: list[dict[str, Any]] = []
+ buffer = ""
+ for chunk in raw.splitlines(keepends=True):
+ buffer += chunk
+ while "\n\n" in buffer:
+ event_chunk, buffer = buffer.split("\n\n", 1)
+ event = _parse_sse_chunk(event_chunk)
+ if event is not None:
+ events.append(event)
+ if buffer.strip():
+ event = _parse_sse_chunk(buffer)
+ if event is not None:
+ events.append(event)
+
+ for event in reversed(events):
+ if event.get("type") == "response.completed" and isinstance(event.get("response"), dict):
+ return event["response"]
+ if isinstance(event.get("response"), dict):
+ return event["response"]
+
+ output_text = ""
+ for event in events:
+ delta = event.get("delta")
+ if isinstance(delta, str):
+ output_text += delta
+ text = event.get("text")
+ if isinstance(text, str):
+ output_text += text
+ if output_text:
+ return {
+ "output": [
+ {
+ "type": "message",
+ "content": [{"type": "output_text", "text": output_text}],
+ }
+ ]
+ }
+ if raw.strip():
+ print(f"[Providers] _parse_codex_stream: received {len(raw)} bytes but could not extract text", file=sys.stderr)
+ return {}
diff --git a/scripts/lib/quality_nudge.py b/scripts/lib/quality_nudge.py
index 69204a5..6e84fe4 100644
--- a/scripts/lib/quality_nudge.py
+++ b/scripts/lib/quality_nudge.py
@@ -4,11 +4,11 @@ Computes a quality score based on 5 core sources and builds
a nudge message describing what the user missed and how to fix it.
"""
-from typing import Any, Dict, List, Optional
+from typing import List
# The 5 core sources
-CORE_SOURCES = ["hn", "polymarket", "x", "youtube", "reddit_comments"]
+CORE_SOURCES = ["hn", "polymarket", "x", "youtube", "reddit"]
# Labels for display
SOURCE_LABELS = {
@@ -16,7 +16,7 @@ SOURCE_LABELS = {
"polymarket": "Polymarket",
"x": "X/Twitter",
"youtube": "YouTube",
- "reddit_comments": "Reddit with comments",
+ "reddit": "Reddit",
}
@@ -45,16 +45,6 @@ def _is_youtube_active(config: dict, research_results: dict) -> bool:
return True
-def _is_reddit_comments_active(config: dict, research_results: dict) -> bool:
- """Check if Reddit with comments is active (ScrapeCreators)."""
- has_sc = bool(config.get("SCRAPECREATORS_API_KEY"))
- if not has_sc:
- return False
- if research_results.get("reddit_error"):
- return False
- return True
-
-
def compute_quality_score(config: dict, research_results: dict) -> dict:
"""Compute research quality score based on 5 core sources.
@@ -67,8 +57,8 @@ def compute_quality_score(config: dict, research_results: dict) -> dict:
{
"score_pct": 40-100,
"core_active": ["hn", "polymarket", ...],
- "core_missing": ["x", "youtube", "reddit_comments"],
- "core_errored": ["reddit_comments"], # configured but errored
+ "core_missing": ["x", "youtube"],
+ "core_errored": [], # configured but errored
"nudge_text": "..." or None if 100%
}
"""
@@ -76,9 +66,10 @@ def compute_quality_score(config: dict, research_results: dict) -> dict:
core_missing: List[str] = []
core_errored: List[str] = []
- # HN and Polymarket are always active
+ # HN, Polymarket, and Reddit are always active
core_active.append("hn")
core_active.append("polymarket")
+ core_active.append("reddit")
# X
has_x_creds = bool(config.get("AUTH_TOKEN") or config.get("XAI_API_KEY"))
@@ -90,30 +81,25 @@ def compute_quality_score(config: dict, research_results: dict) -> dict:
core_errored.append("x")
# YouTube
- try:
- from . import youtube_yt
- has_ytdlp = youtube_yt.is_ytdlp_installed()
- except Exception:
- has_ytdlp = False
- if _is_youtube_active(config, research_results):
+ yt_active = _is_youtube_active(config, research_results)
+ if yt_active:
core_active.append("youtube")
else:
core_missing.append("youtube")
+ # Check if configured but errored (yt-dlp installed but failed this run)
+ try:
+ from . import youtube_yt
+ has_ytdlp = youtube_yt.is_ytdlp_installed()
+ except Exception:
+ has_ytdlp = False
if has_ytdlp and research_results.get("youtube_error"):
core_errored.append("youtube")
- # Reddit with comments (ScrapeCreators)
- has_sc = bool(config.get("SCRAPECREATORS_API_KEY"))
- if _is_reddit_comments_active(config, research_results):
- core_active.append("reddit_comments")
- else:
- core_missing.append("reddit_comments")
- if has_sc and research_results.get("reddit_error"):
- core_errored.append("reddit_comments")
-
score_pct = int(len(core_active) / 5 * 100)
- nudge_text = _build_nudge_text(core_missing, core_errored) if core_missing else None
+ has_sc = bool(config.get("SCRAPECREATORS_API_KEY"))
+ active_sources = research_results.get("active_sources") or []
+ nudge_text = _build_nudge_text(core_missing, core_errored, has_sc=has_sc, active_sources=active_sources) if core_missing else None
return {
"score_pct": score_pct,
@@ -124,10 +110,11 @@ def compute_quality_score(config: dict, research_results: dict) -> dict:
}
-def _build_nudge_text(core_missing: List[str], core_errored: List[str]) -> str:
+def _build_nudge_text(core_missing: List[str], core_errored: List[str], has_sc: bool = False, active_sources: list = None) -> str:
"""Build human-readable nudge text describing what was missed.
- Prioritizes free suggestions before paid ones.
+ Prioritizes free suggestions. Optionally mentions bonus sources
+ (TikTok, Instagram, Threads, Pinterest) if ScrapeCreators key is configured.
"""
lines: List[str] = []
@@ -145,43 +132,44 @@ def _build_nudge_text(core_missing: List[str], core_errored: List[str]) -> str:
lines.append(f"Missing: {', '.join(missed_parts)}.")
lines.append("")
- # Free suggestions first
+ # Free suggestions
free_suggestions: List[str] = []
- paid_suggestions: List[str] = []
if "x" in core_missing:
if "x" in core_errored:
free_suggestions.append(
- "X errored — try refreshing your browser cookies "
- "(log into x.com, then re-run)."
+ "X/Twitter errored - log into x.com in your browser, then re-run."
)
else:
free_suggestions.append(
- "X/Twitter: scan browser cookies automatically — "
- "just log into x.com in any browser and re-run."
+ "X/Twitter: real-time posts with likes and reposts - the fastest "
+ "signal for breaking topics. Two options: log into x.com in your "
+ "browser and re-run (cookies detected automatically), or add "
+ "XAI_API_KEY to your .env (no browser access, get key at api.x.ai)."
)
if "youtube" in core_missing:
if "youtube" in core_errored:
free_suggestions.append(
- "YouTube errored — check that yt-dlp is up to date: "
- "brew upgrade yt-dlp"
+ "YouTube errored - update yt-dlp: brew upgrade yt-dlp"
)
else:
free_suggestions.append(
- "YouTube: install yt-dlp — brew install yt-dlp"
+ "YouTube: video transcripts with key moments - often the deepest "
+ "explanations on any topic. Install yt-dlp: brew install yt-dlp (free)"
)
- if "reddit_comments" in core_missing:
- if "reddit_comments" in core_errored:
- paid_suggestions.append(
- "Reddit comments errored — check your ScrapeCreators API key "
- "at scrapecreators.com."
- )
- else:
- paid_suggestions.append(
- "Reddit with comments: add SCRAPECREATORS_API_KEY — "
- "100 free API calls, no credit card — scrapecreators.com"
+ # Mention bonus opt-in sources when SC key is present
+ if has_sc:
+ bonus_hints = []
+ if "threads" not in (active_sources or []):
+ bonus_hints.append("Threads")
+ if "pinterest" not in (active_sources or []):
+ bonus_hints.append("Pinterest")
+ if bonus_hints:
+ free_suggestions.append(
+ f"Your SC key also powers {', '.join(bonus_hints)} and YouTube comments. "
+ "Add them to INCLUDE_SOURCES in your .env to enable."
)
if free_suggestions:
@@ -190,12 +178,13 @@ def _build_nudge_text(core_missing: List[str], core_errored: List[str]) -> str:
lines.append(f" - {s}")
lines.append("")
- if paid_suggestions:
- lines.append("Paid options:")
- for s in paid_suggestions:
- lines.append(f" - {s}")
- lines.append("")
-
- lines.append("last30days has no affiliation with any API provider.")
+ # Bonus sources mention (non-blocking)
+ if not has_sc:
+ lines.append(
+ "Bonus: TikTok and Instagram are available with a free "
+ "ScrapeCreators key at scrapecreators.com (no affiliation)."
+ )
+ else:
+ lines.append("last30days has no affiliation with any API provider.")
return "\n".join(lines)
diff --git a/scripts/lib/query_type.py b/scripts/lib/query_type.py
deleted file mode 100644
index 0d3e728..0000000
--- a/scripts/lib/query_type.py
+++ /dev/null
@@ -1,111 +0,0 @@
-"""Query type detection for source selection and scoring adjustments."""
-
-import re
-from typing import Literal
-
-QueryType = Literal["product", "concept", "opinion", "how_to", "comparison", "breaking_news", "prediction"]
-
-# Pattern-based classification (no LLM, no external deps)
-_PRODUCT_PATTERNS = re.compile(
- r"\b(price|pricing|cost|buy|purchase|deal|discount|subscription|plan|tier|free tier|alternative|prompt|prompts|prompting|template|templates)\b", re.I
-)
-_CONCEPT_PATTERNS = re.compile(
- r"\b(what is|what are|explain|definition|how does|how do|overview|introduction|guide to|primer)\b", re.I
-)
-_OPINION_PATTERNS = re.compile(
- r"\b(worth it|thoughts on|opinion|review|experience with|recommend|should i|pros and cons|good or bad)\b", re.I
-)
-_HOWTO_PATTERNS = re.compile(
- r"\b(how to|tutorial|step by step|setup|install|configure|deploy|migrate|implement|build a|create a|prompting|prompts?|best practices|tips|examples|animation|animations|video workflow|render pipeline)\b",
- re.I,
-)
-_COMPARISON_PATTERNS = re.compile(
- r"\b(vs\.?|versus|compared to|comparison|better than|difference between|switch from)\b", re.I
-)
-_BREAKING_PATTERNS = re.compile(
- r"\b(latest|breaking|just announced|launched|released|new|update|news|happened|today|this week)\b", re.I
-)
-_PREDICTION_PATTERNS = re.compile(
- r"\b(predict|forecast|odds|chance|probability|election|outcome|bet on|market for)\b", re.I
-)
-
-
-def detect_query_type(topic: str) -> QueryType:
- """Classify a query into a type using pattern matching.
-
- Returns the first match in priority order:
- comparison > how_to > product > opinion > prediction > concept > breaking_news.
- """
- # Most specific first
- if _COMPARISON_PATTERNS.search(topic):
- return "comparison"
- if _HOWTO_PATTERNS.search(topic):
- return "how_to"
- if _PRODUCT_PATTERNS.search(topic):
- return "product"
- if _OPINION_PATTERNS.search(topic):
- return "opinion"
- if _PREDICTION_PATTERNS.search(topic):
- return "prediction"
- if _CONCEPT_PATTERNS.search(topic):
- return "concept"
- if _BREAKING_PATTERNS.search(topic):
- return "breaking_news"
-
- # Default: treat as breaking news (most common use case for "last 30 days")
- return "breaking_news"
-
-
-# Source tiering by query type.
-# Tier 1: always run. Tier 2: run if available. Tier 3: opt-in only.
-# Sources not listed are implicitly tier 3 (opt-in).
-SOURCE_TIERS = {
- "product": {"tier1": {"reddit", "x", "youtube"}, "tier2": {"web", "tiktok"}},
- "concept": {"tier1": {"reddit", "hn", "web"}, "tier2": {"youtube", "x"}},
- "opinion": {"tier1": {"reddit", "x"}, "tier2": {"youtube", "bluesky"}},
- "how_to": {"tier1": {"youtube", "reddit", "hn"}, "tier2": {"web", "x"}},
- "comparison": {"tier1": {"reddit", "hn", "youtube"}, "tier2": {"x", "web"}},
- "breaking_news": {"tier1": {"x", "reddit", "web"}, "tier2": {"hn", "bluesky", "youtube"}},
- "prediction": {"tier1": {"polymarket", "x", "reddit"}, "tier2": {"web", "hn", "youtube"}},
-}
-
-# WebSearch penalty adjustment by query type.
-# Points subtracted from websearch score (0-100 scale). 0 = no penalty, 15 = full penalty.
-# Concept/how_to queries benefit from authoritative web sources.
-WEBSEARCH_PENALTY_BY_TYPE = {
- "product": 15, # default: social discussion > blog posts
- "concept": 0, # web docs are the best source
- "opinion": 15, # social discussion > blog posts
- "how_to": 5, # tutorials on web are valuable
- "comparison": 10, # mix of social and web
- "breaking_news": 10, # news sites are valuable
- "prediction": 15, # social/market data > web articles
-}
-
-# Tiebreaker priority overrides by query type.
-# Maps source type name to priority (lower = higher priority).
-TIEBREAKER_BY_TYPE = {
- "product": {"reddit": 0, "x": 1, "youtube": 2, "tiktok": 3, "instagram": 4, "hn": 5, "web": 6, "polymarket": 7},
- "concept": {"hn": 0, "reddit": 1, "web": 2, "youtube": 3, "x": 4, "tiktok": 5, "instagram": 6, "polymarket": 7},
- "opinion": {"reddit": 0, "x": 1, "bluesky": 2, "youtube": 3, "hn": 4, "tiktok": 5, "web": 6, "polymarket": 7},
- "how_to": {"youtube": 0, "reddit": 1, "hn": 2, "web": 3, "x": 4, "tiktok": 5, "instagram": 6, "polymarket": 7},
- "comparison": {"reddit": 0, "hn": 1, "youtube": 2, "x": 3, "web": 4, "tiktok": 5, "instagram": 6, "polymarket": 7},
- "breaking_news": {"x": 0, "reddit": 1, "web": 2, "hn": 3, "bluesky": 4, "tiktok": 5, "youtube": 6, "polymarket": 7},
- "prediction": {"polymarket": 0, "x": 1, "reddit": 2, "web": 3, "hn": 4, "bluesky": 5, "youtube": 6, "tiktok": 7},
-}
-
-
-def is_source_enabled(source: str, query_type: QueryType, explicitly_requested: bool = False) -> bool:
- """Check if a source should run for a given query type.
-
- Tier 1 and Tier 2 sources are enabled. Tier 3 (unlisted) sources only run
- if explicitly requested via --search flag. Truth Social is always opt-in.
- """
- if source == "truthsocial":
- return explicitly_requested
-
- if explicitly_requested:
- return True
-
- tiers = SOURCE_TIERS.get(query_type, SOURCE_TIERS["breaking_news"])
- return source in tiers["tier1"] or source in tiers["tier2"]
diff --git a/scripts/lib/reddit.py b/scripts/lib/reddit.py
index 19d3edc..fc438d8 100644
--- a/scripts/lib/reddit.py
+++ b/scripts/lib/reddit.py
@@ -1,19 +1,17 @@
-"""Reddit search via ScrapeCreators API for /last30days.
+"""Reddit search via ScrapeCreators API for the v3 pipeline.
Uses ScrapeCreators REST API to search Reddit globally, discover relevant
subreddits, run targeted subreddit searches, and fetch comment trees.
-Replaces openai_reddit.py as the primary Reddit search backend.
-Falls back to openai_reddit.py if SCRAPECREATORS_API_KEY is missing but
-OPENAI_API_KEY is present.
-
Requires SCRAPECREATORS_API_KEY in config (same key as TikTok + Instagram).
API docs: https://scrapecreators.com/docs
"""
import re
import sys
+import time
from collections import Counter
+from concurrent.futures import ThreadPoolExecutor, as_completed, wait as futures_wait
from datetime import datetime, timezone
from typing import Any, Dict, List, Optional, Set
@@ -22,7 +20,15 @@ try:
except ImportError:
_requests = None
-from . import http
+
+def _first_of(*values, default=None):
+ """Return first value that is not None."""
+ for v in values:
+ if v is not None:
+ return v
+ return default
+
+from . import http, log
SCRAPECREATORS_BASE = "https://api.scrapecreators.com/v1/reddit"
@@ -49,7 +55,6 @@ DEPTH_CONFIG = {
}
from .query import extract_core_subject as _query_extract
-from .query_type import detect_query_type
from .relevance import token_overlap_relevance
# Reddit-specific noise words (preserves original smaller set)
@@ -68,9 +73,7 @@ NOISE_WORDS = frozenset({
def _log(msg: str):
- """Log to stderr."""
- sys.stderr.write(f"[Reddit] {msg}\n")
- sys.stderr.flush()
+ log.source_log("Reddit", msg, tty_only=False)
def _sc_headers(token: str) -> Dict[str, str]:
@@ -108,9 +111,18 @@ def expand_reddit_queries(topic: str, depth: str) -> List[str]:
if core.lower() != original_clean.lower() and len(original_clean.split()) <= 8:
queries.append(original_clean)
- # Opinion/review variants help mostly for product and opinion queries.
- # They contaminate broader searches like predictions or breaking news.
- qtype = detect_query_type(topic)
+ qtype = _infer_query_intent(topic)
+
+ # Product queries: always include review-oriented variant to bias toward
+ # review communities instead of keyword-matching unrelated subreddits.
+ if qtype == "product":
+ queries.append(f"{core} review OR recommendation OR best")
+
+ # Comparison queries: include head-to-head discussion variant.
+ if qtype == "comparison":
+ queries.append(f"{core} worth it OR vs OR compared")
+
+ # Opinion/review variants for default/deep depth.
if depth in ("default", "deep") and qtype in ("product", "opinion"):
queries.append(f"{core} worth it OR thoughts OR review")
@@ -121,6 +133,22 @@ def expand_reddit_queries(topic: str, depth: str) -> List[str]:
return queries
+def _infer_query_intent(topic: str) -> str:
+ """Tiny local fallback for Reddit query expansion only."""
+ text = topic.lower().strip()
+ if re.search(r"\b(vs|versus|compare|difference between)\b", text):
+ return "comparison"
+ if re.search(r"\b(how to|tutorial|guide|setup|step by step|deploy|install|configuration|configure|troubleshoot|troubleshooting|error|errors|fix|debug)\b", text):
+ return "how_to"
+ if re.search(r"\b(thoughts on|worth it|should i|opinion|review)\b", text):
+ return "opinion"
+ if re.search(r"\b(pricing|feature|features|best .* for)\b", text):
+ return "product"
+ if re.search(r"\b(predict|prediction|odds|forecast|chance)\b", text):
+ return "prediction"
+ return "breaking_news"
+
+
# Known utility/meta subreddits that match queries but aren't discussion subs.
# These get a 0.3x penalty (not banned) in subreddit discovery scoring.
UTILITY_SUBS = frozenset({
@@ -153,7 +181,7 @@ def discover_subreddits(
scores = Counter()
for post in results:
- sub = post.get("subreddit", "")
+ sub = _extract_subreddit_name(post.get("subreddit", ""))
if not sub:
continue
@@ -170,7 +198,7 @@ def discover_subreddits(
base *= 0.3
# Bonus: post engagement (high-engagement posts = better sub)
- ups = post.get("ups") or post.get("score", 0)
+ ups = _first_of(post.get("ups"), post.get("score"), post.get("votes"), default=0)
if ups and ups > 100:
base += 0.5
@@ -179,19 +207,84 @@ def discover_subreddits(
return [sub for sub, _ in scores.most_common(max_subs)]
-def _parse_date(created_utc) -> Optional[str]:
- """Convert Unix timestamp to YYYY-MM-DD."""
- if not created_utc:
+def _parse_date(value) -> Optional[str]:
+ """Convert Unix timestamp or ISO-8601 string to YYYY-MM-DD.
+
+ Global search returns ``created_at`` as an ISO string
+ (e.g. "2018-05-03T01:09:17.620000+0000"); subreddit search returns
+ ``created_utc`` as a Unix timestamp. Handle both.
+ """
+ if not value:
return None
+ # ISO-8601 string (contains 'T' or '-')
+ if isinstance(value, str) and ("T" in value or "-" in value):
+ try:
+ # Strip trailing offset variations (+0000, Z) for fromisoformat
+ clean = value.replace("Z", "+00:00")
+ if clean.endswith("+0000"):
+ clean = clean[:-5] + "+00:00"
+ dt = datetime.fromisoformat(clean)
+ return dt.strftime("%Y-%m-%d")
+ except (ValueError, TypeError):
+ pass
+ # Unix timestamp (int or float or numeric string)
try:
- dt = datetime.fromtimestamp(float(created_utc), tz=timezone.utc)
+ dt = datetime.fromtimestamp(float(value), tz=timezone.utc)
return dt.strftime("%Y-%m-%d")
except (ValueError, TypeError, OSError):
return None
+def _extract_subreddit_name(value: Any) -> str:
+ """Extract subreddit name from string or API object dict."""
+ if isinstance(value, dict):
+ return str(value.get("name") or value.get("display_name") or "").strip()
+ return str(value).strip()
+
+
+def _extract_score(post: Dict[str, Any]) -> int:
+ """Extract post score from either API schema.
+
+ Global search uses ``votes``; subreddit search uses ``ups``/``score``.
+ """
+ return _first_of(post.get("ups"), post.get("score"), post.get("votes"), default=0)
+
+
+def _extract_date(post: Dict[str, Any]) -> Optional[str]:
+ """Extract date from either API schema.
+
+ Global search uses ``created_at`` (ISO); subreddit search uses ``created_utc`` (Unix).
+ """
+ return _parse_date(
+ post.get("created_utc") or post.get("created_at") or post.get("created_at_iso")
+ )
+
+
+def _normalize_reddit_id(raw_id: str) -> str:
+ """Strip Reddit fullname prefix (t3_) for consistent dedup."""
+ s = str(raw_id or "")
+ return s[3:] if s.startswith("t3_") else s
+
+
+def _total_engagement(item: Dict[str, Any]) -> int:
+ """Combined engagement score: upvotes + comment count.
+
+ Used for selecting which threads to enrich with comments.
+ Threads with lots of comments are high-value even if upvote score is low.
+ """
+ eng = item.get("engagement", {})
+ score = eng.get("score", 0) or 0
+ num_comments = eng.get("num_comments", 0) or 0
+ return score + num_comments
+
+
def _normalize_post(post: Dict[str, Any], idx: int, source_label: str = "global", query: str = "") -> Dict[str, Any]:
- """Normalize a ScrapeCreators Reddit post to our internal format."""
+ """Normalize a ScrapeCreators Reddit post to our internal format.
+
+ Handles both the global-search schema (``votes``, ``created_at``,
+ ``subreddit`` as dict) and the subreddit-search schema (``ups``/``score``,
+ ``created_utc``, ``subreddit`` as string).
+ """
permalink = post.get("permalink", "")
url = f"https://www.reddit.com{permalink}" if permalink else post.get("url", "")
@@ -208,13 +301,13 @@ def _normalize_post(post: Dict[str, Any], idx: int, source_label: str = "global"
return {
"id": f"R{idx}",
- "reddit_id": post.get("id", ""),
+ "reddit_id": _normalize_reddit_id(post.get("id", "")),
"title": title,
"url": url,
- "subreddit": str(post.get("subreddit", "")).strip(),
- "date": _parse_date(post.get("created_utc")),
+ "subreddit": _extract_subreddit_name(post.get("subreddit", "")),
+ "date": _extract_date(post),
"engagement": {
- "score": post.get("ups") or post.get("score", 0),
+ "score": _extract_score(post),
"num_comments": post.get("num_comments", 0),
"upvote_ratio": post.get("upvote_ratio"),
},
@@ -268,6 +361,11 @@ def _global_search(
headers["User-Agent"] = http.USER_AGENT
data = http.get(url, headers=headers, timeout=30, retries=2)
return data.get("posts", data.get("data", []))
+ except http.HTTPError as e:
+ if e.status_code and e.status_code in (401, 403):
+ raise
+ _log(f"Global search error (urllib): {e}")
+ return []
except Exception as e:
_log(f"Global search error (urllib): {e}")
return []
@@ -282,6 +380,11 @@ def _global_search(
resp.raise_for_status()
data = resp.json()
return data.get("posts", data.get("data", []))
+ except _requests.exceptions.HTTPError as e:
+ if e.response is not None and e.response.status_code in (401, 403):
+ raise http.HTTPError(f"Auth error: {e}", e.response.status_code)
+ _log(f"Global search error: {e}")
+ return []
except Exception as e:
_log(f"Global search error: {e}")
return []
@@ -409,10 +512,11 @@ def search_reddit(
to_date: str,
depth: str = "default",
token: str = None,
+ subreddits: List[str] | None = None,
) -> Dict[str, Any]:
"""Full Reddit search: multi-query global discovery + subreddit drill-down.
- This is the main entry point. Replaces openai_reddit.search_reddit().
+ This is the main v3 Reddit entry point.
Args:
topic: Search topic
@@ -420,6 +524,7 @@ def search_reddit(
to_date: End date (YYYY-MM-DD)
depth: 'quick', 'default', or 'deep'
token: ScrapeCreators API key
+ subreddits: Optional list of subreddit names to search first (pre-resolved)
Returns:
Dict with 'items' list and optional 'error'.
@@ -429,40 +534,72 @@ def search_reddit(
config = DEPTH_CONFIG.get(depth, DEPTH_CONFIG["default"])
timeframe = config["timeframe"]
+ intent = _infer_query_intent(topic)
# === Phase 1: Query Expansion ===
queries = expand_reddit_queries(topic, depth)
_log(f"Expanded '{topic}' into {len(queries)} queries: {queries}")
- # === Phase 2: Global Discovery ===
+ core = _extract_core_subject(topic)
+
+ # === Phase 1.5: Pre-resolved subreddit search (high-signal) ===
all_raw_posts = []
+ all_items: List[Dict[str, Any]] = []
+ if subreddits:
+ _log(f"Searching pre-resolved subreddits: {subreddits}")
+ with ThreadPoolExecutor(max_workers=min(5, len(subreddits))) as executor:
+ futures = {}
+ for sub in subreddits:
+ futures[executor.submit(_subreddit_search, sub, core, token, "relevance", timeframe)] = sub
+ for future in as_completed(futures):
+ sub = futures[future]
+ sub_posts = future.result()
+ _log(f" -> {len(sub_posts)} results from pre-resolved r/{sub}")
+ for j, post in enumerate(sub_posts):
+ item = _normalize_post(post, len(all_items) + j + 1, f"r/{sub}", query=core)
+ all_items.append(item)
+
+ # === Phase 2: Global Discovery ===
max_global = config["global_searches"]
- for i, query in enumerate(queries[:max_global]):
- sort = "relevance" if i == 0 else "top"
- _log(f"Global search {i+1}/{max_global}: '{query}' (sort={sort})")
- posts = _global_search(query, token, sort=sort, timeframe=timeframe)
- _log(f" -> {len(posts)} results")
- all_raw_posts.extend(posts)
+ with ThreadPoolExecutor(max_workers=max_global or 1) as executor:
+ futures = {}
+ for i, query in enumerate(queries[:max_global]):
+ # Product/comparison queries: sort=top surfaces high-engagement posts
+ # from relevant communities instead of keyword-matched noise.
+ sort = "top" if intent in ("product", "comparison") else ("relevance" if i == 0 else "top")
+ _log(f"Global search {i+1}/{max_global}: '{query}' (sort={sort})")
+ futures[executor.submit(_global_search, query, token, sort, timeframe)] = query
+ for future in as_completed(futures):
+ query = futures[future]
+ posts = future.result()
+ _log(f" -> {len(posts)} results for '{query}'")
+ all_raw_posts.extend(posts)
# Normalize all posts (with query for relevance scoring)
- core = _extract_core_subject(topic)
- all_items = []
for i, post in enumerate(all_raw_posts):
item = _normalize_post(post, i + 1, "global", query=core)
all_items.append(item)
# === Phase 3: Subreddit Discovery + Targeted Search ===
- discovered_subs = discover_subreddits(all_raw_posts, topic=topic, max_subs=config["subreddit_searches"])
+ subreddit_budget = 0 if intent == "how_to" else config["subreddit_searches"]
+ discovered_subs = discover_subreddits(all_raw_posts, topic=topic, max_subs=subreddit_budget)
_log(f"Discovered subreddits: {discovered_subs}")
- for sub in discovered_subs[:config["subreddit_searches"]]:
- _log(f"Subreddit search: r/{sub} for '{core}'")
- sub_posts = _subreddit_search(sub, core, token, sort="relevance", timeframe=timeframe)
- _log(f" -> {len(sub_posts)} results from r/{sub}")
- for j, post in enumerate(sub_posts):
- item = _normalize_post(post, len(all_items) + j + 1, f"r/{sub}", query=core)
- all_items.append(item)
+ subreddit_limit = subreddit_budget
+ if subreddit_limit > 0:
+ with ThreadPoolExecutor(max_workers=subreddit_limit) as executor:
+ futures = {}
+ for sub in discovered_subs[:subreddit_limit]:
+ _log(f"Subreddit search: r/{sub} for '{core}'")
+ futures[executor.submit(_subreddit_search, sub, core, token, "relevance", timeframe)] = sub
+ for future in as_completed(futures):
+ sub = futures[future]
+ sub_posts = future.result()
+ _log(f" -> {len(sub_posts)} results from r/{sub}")
+ for j, post in enumerate(sub_posts):
+ item = _normalize_post(post, len(all_items) + j + 1, f"r/{sub}", query=core)
+ all_items.append(item)
# === Phase 4: Deduplicate ===
all_items = _dedupe_posts(all_items)
@@ -486,9 +623,9 @@ def search_reddit(
else:
_log(f"No posts within date range, keeping all {len(all_items)}")
- # === Phase 6: Sort by engagement ===
+ # === Phase 6: Sort by engagement (upvotes + comment count) ===
all_items.sort(
- key=lambda x: (x.get("engagement", {}).get("score", 0) or 0),
+ key=lambda x: _total_engagement(x),
reverse=True,
)
@@ -504,6 +641,7 @@ def enrich_with_comments(
items: List[Dict[str, Any]],
token: str,
depth: str = "default",
+ budget_seconds: int = 60,
) -> List[Dict[str, Any]]:
"""Enrich top items with comment data from ScrapeCreators.
@@ -511,6 +649,8 @@ def enrich_with_comments(
items: Reddit items from search_reddit()
token: ScrapeCreators API key
depth: Depth for comment limit
+ budget_seconds: Maximum total time for enrichment. If exceeded,
+ returns items with whatever enrichment completed. Never discards items.
Returns:
Items with top_comments and comment_insights added.
@@ -518,62 +658,84 @@ def enrich_with_comments(
config = DEPTH_CONFIG.get(depth, DEPTH_CONFIG["default"])
max_comments = config["comment_enrichments"]
- if not items or not token:
+ if not items or not token or max_comments <= 0:
return items
- top_items = items[:max_comments]
- _log(f"Enriching comments for {len(top_items)} posts")
+ # Select the top threads by total engagement (upvotes + comment count),
+ # not by list position. This ensures high-comment threads like [FRESH ALBUM]
+ # always get enriched even if their upvote score is low.
+ ranked = sorted(items, key=_total_engagement, reverse=True)
+ top_items = ranked[:max_comments]
+ _log(f"Enriching comments for {len(top_items)} posts (by total engagement)")
- for item in top_items:
- url = item.get("url", "")
- if not url:
- continue
+ start = time.monotonic()
- raw_comments = fetch_post_comments(url, token)
- if not raw_comments:
- continue
+ with ThreadPoolExecutor(max_workers=min(4, len(top_items))) as executor:
+ futures = {
+ executor.submit(fetch_post_comments, item.get("url", ""), token): item
+ for item in top_items
+ if item.get("url")
+ }
- # Parse comments into our format
- top_comments = []
- insights = []
+ # Wait with budget instead of unbounded as_completed
+ remaining = max(0, budget_seconds - (time.monotonic() - start))
+ done, not_done = futures_wait(futures, timeout=remaining)
- for ci, c in enumerate(raw_comments[:10]): # Take top 10 comments
- body = c.get("body", "")
- if not body or body in ("[deleted]", "[removed]"):
+ enriched_count = 0
+ for future in done:
+ item = futures[future]
+ try:
+ raw_comments = future.result(timeout=0)
+ except Exception:
+ continue
+ if not raw_comments:
continue
- score = c.get("ups") or c.get("score", 0)
- author = c.get("author", "[deleted]")
- permalink = c.get("permalink", "")
- comment_url = f"https://reddit.com{permalink}" if permalink else ""
+ top_comments = []
+ insights = []
- # Top comment gets more room (400 chars) — funny/clever comments need it
- max_excerpt = 400 if ci == 0 else 300
- top_comments.append({
- "score": score,
- "date": _parse_date(c.get("created_utc")),
- "author": author,
- "excerpt": body[:max_excerpt],
- "url": comment_url,
- })
+ for ci, c in enumerate(raw_comments[:10]):
+ body = c.get("body", "")
+ if not body or body in ("[deleted]", "[removed]"):
+ continue
- # Extract insights from substantive comments
- if len(body) >= 30 and author not in ("[deleted]", "[removed]", "AutoModerator"):
- insight = body[:150]
- if len(body) > 150:
- for i, char in enumerate(insight):
- if char in '.!?' and i > 50:
- insight = insight[:i+1]
- break
- else:
- insight = insight.rstrip() + "..."
- insights.append(insight)
+ score = c.get("ups") or c.get("score", 0)
+ author = c.get("author", "[deleted]")
+ permalink = c.get("permalink", "")
+ comment_url = f"https://reddit.com{permalink}" if permalink else ""
- # Sort comments by score
- top_comments.sort(key=lambda c: c.get("score", 0), reverse=True)
+ max_excerpt = 400 if ci == 0 else 300
+ top_comments.append({
+ "score": score,
+ "date": _parse_date(c.get("created_utc")),
+ "author": author,
+ "excerpt": body[:max_excerpt],
+ "url": comment_url,
+ })
- item["top_comments"] = top_comments[:10]
- item["comment_insights"] = insights[:10]
+ if len(body) >= 30 and author not in ("[deleted]", "[removed]", "AutoModerator"):
+ insight = body[:150]
+ if len(body) > 150:
+ for i, char in enumerate(insight):
+ if char in '.!?' and i > 50:
+ insight = insight[:i+1]
+ break
+ else:
+ insight = insight.rstrip() + "..."
+ insights.append(insight)
+
+ top_comments.sort(key=lambda c: c.get("score", 0), reverse=True)
+ item["top_comments"] = top_comments[:10]
+ item["comment_insights"] = insights[:10]
+ enriched_count += 1
+
+ if not_done:
+ _log(f"Enrichment budget hit ({budget_seconds}s): {enriched_count}/{len(futures)} posts enriched, {len(not_done)} skipped")
+ for future in not_done:
+ future.cancel()
+ else:
+ elapsed = time.monotonic() - start
+ _log(f"Enriched {enriched_count}/{len(futures)} posts in {elapsed:.1f}s")
return items
@@ -584,6 +746,7 @@ def search_and_enrich(
to_date: str,
depth: str = "default",
token: str = None,
+ subreddits: List[str] | None = None,
) -> Dict[str, Any]:
"""Full Reddit pipeline: search + comment enrichment.
@@ -595,11 +758,12 @@ def search_and_enrich(
to_date: End date (YYYY-MM-DD)
depth: 'quick', 'default', or 'deep'
token: ScrapeCreators API key
+ subreddits: Optional list of subreddit names to search first (pre-resolved)
Returns:
Dict with 'items' list. Items include top_comments and comment_insights.
"""
- result = search_reddit(topic, from_date, to_date, depth, token)
+ result = search_reddit(topic, from_date, to_date, depth, token, subreddits=subreddits)
items = result.get("items", [])
if items and token:
@@ -612,6 +776,6 @@ def search_and_enrich(
def parse_reddit_response(response: Dict[str, Any]) -> List[Dict[str, Any]]:
"""Parse ScrapeCreators response to item list.
- Compatibility shim matching openai_reddit.parse_reddit_response() signature.
+ Parse raw Reddit search output into the generic item shape.
"""
return response.get("items", [])
diff --git a/scripts/lib/reddit_enrich.py b/scripts/lib/reddit_enrich.py
index b14e74c..dd844a9 100644
--- a/scripts/lib/reddit_enrich.py
+++ b/scripts/lib/reddit_enrich.py
@@ -21,13 +21,10 @@ def extract_reddit_path(url: str) -> Optional[str]:
Returns:
Path component or None
"""
- try:
- parsed = urlparse(url)
- if "reddit.com" not in parsed.netloc:
- return None
- return parsed.path
- except:
+ parsed = urlparse(url)
+ if "reddit.com" not in parsed.netloc:
return None
+ return parsed.path
class RedditRateLimitError(Exception):
diff --git a/scripts/lib/reddit_public.py b/scripts/lib/reddit_public.py
index e25294d..8e4aefe 100644
--- a/scripts/lib/reddit_public.py
+++ b/scripts/lib/reddit_public.py
@@ -17,6 +17,7 @@ import time
import urllib.error
import urllib.parse
import urllib.request
+from concurrent.futures import ThreadPoolExecutor, TimeoutError as FuturesTimeoutError
from typing import Any, Dict, List, Optional
@@ -29,6 +30,13 @@ DEPTH_LIMITS = {
"deep": 50,
}
+# How many top posts to enrich with comments, by depth
+ENRICH_LIMITS = {
+ "quick": 3,
+ "default": 5,
+ "deep": 8,
+}
+
MAX_RETRIES = 3
BASE_BACKOFF = 2.0 # seconds
@@ -156,6 +164,7 @@ def _parse_posts(data: Optional[Dict[str, Any]]) -> List[Dict[str, Any]]:
},
"relevance": _compute_relevance(score, num_comments),
"why_relevant": "Reddit public search",
+ "metadata": {},
})
return posts
@@ -217,27 +226,133 @@ def search(
return unique[:limit]
+def _enrich_post(item: Dict[str, Any], timeout: int = 10) -> Dict[str, Any]:
+ """Enrich a single post with top comments. Never raises."""
+ try:
+ from . import reddit_enrich
+ thread_data = reddit_enrich.fetch_thread_data(item["url"], timeout=timeout)
+ if not thread_data:
+ return item
+ parsed = reddit_enrich.parse_thread_data(thread_data)
+ comments = parsed.get("comments", [])
+ top = reddit_enrich.get_top_comments(comments)
+ item["top_comments"] = [
+ {
+ "score": c.get("score", 0),
+ "excerpt": (c.get("body") or "")[:200],
+ "author": c.get("author", ""),
+ }
+ for c in top[:10]
+ ]
+ except Exception:
+ # Never discard — keep post with empty metadata
+ pass
+ return item
+
+
+def _enrich_posts(posts: List[Dict[str, Any]], depth: str = "default") -> List[Dict[str, Any]]:
+ """Enrich top N posts with comment data using threads. Total budget 45s."""
+ limit = ENRICH_LIMITS.get(depth, ENRICH_LIMITS["default"])
+ to_enrich = posts[:limit]
+ rest = posts[limit:]
+
+ if not to_enrich:
+ return posts
+
+ enriched = []
+ try:
+ with ThreadPoolExecutor(max_workers=min(limit, 4)) as executor:
+ futures = {
+ executor.submit(_enrich_post, post, 10): i
+ for i, post in enumerate(to_enrich)
+ }
+ # Collect results with 45s total budget
+ import concurrent.futures
+ done, not_done = concurrent.futures.wait(futures, timeout=45)
+ # Build result list preserving order
+ result_map: Dict[int, Dict[str, Any]] = {}
+ for future in done:
+ idx = futures[future]
+ try:
+ result_map[idx] = future.result(timeout=0)
+ except Exception:
+ result_map[idx] = to_enrich[idx]
+ # Any not-done futures: keep original post
+ for future in not_done:
+ idx = futures[future]
+ result_map[idx] = to_enrich[idx]
+ future.cancel()
+ enriched = [result_map[i] for i in range(len(to_enrich))]
+ except Exception:
+ enriched = to_enrich
+
+ return enriched + rest
+
+
+def _search_subreddit(sub: str, topic: str, depth: str, timeout: int = 15) -> List[Dict[str, Any]]:
+ """Search a single subreddit. Never raises."""
+ try:
+ return search(topic, depth=depth, subreddit=sub, timeout=timeout)
+ except Exception as e:
+ _log(f"Subreddit search failed for r/{sub}: {e}")
+ return []
+
+
def search_reddit_public(
topic: str,
from_date: str,
to_date: str,
depth: str = "default",
+ subreddits: Optional[List[str]] = None,
) -> List[Dict[str, Any]]:
"""High-level Reddit public search matching the openai_reddit interface.
- Runs global search, deduplicates, filters by date range, and sorts
- by engagement. Compatible as a drop-in replacement in the fallback chain.
+ When subreddits are provided (from agent planning), searches each targeted
+ sub first, then does global search, and deduplicates across both. This
+ mirrors the SC search_and_enrich() flow where pre-resolved subreddits get
+ priority.
Args:
topic: Search topic
from_date: Start date (YYYY-MM-DD)
to_date: End date (YYYY-MM-DD)
depth: 'quick', 'default', or 'deep'
+ subreddits: Optional list of subreddit names (without r/) for targeted search
Returns:
List of normalized item dicts matching ScrapeCreators output format.
"""
- results = search(topic, depth=depth)
+ all_posts: List[Dict[str, Any]] = []
+
+ # Phase 1: Search targeted subreddits in parallel (if provided)
+ if subreddits:
+ _log(f"Searching {len(subreddits)} targeted subreddits: {subreddits}")
+ workers = min(4, len(subreddits))
+ with ThreadPoolExecutor(max_workers=workers) as executor:
+ futures = {
+ executor.submit(_search_subreddit, sub, topic, depth): sub
+ for sub in subreddits
+ }
+ for future in futures:
+ sub = futures[future]
+ try:
+ sub_posts = future.result(timeout=30)
+ _log(f" -> {len(sub_posts)} results from r/{sub}")
+ all_posts.extend(sub_posts)
+ except (Exception, FuturesTimeoutError) as e:
+ _log(f" -> r/{sub} failed: {e}")
+
+ # Phase 2: Global search
+ global_posts = search(topic, depth=depth)
+ all_posts.extend(global_posts)
+
+ # Deduplicate by URL (targeted results keep priority since they come first)
+ seen_urls: set = set()
+ results: List[Dict[str, Any]] = []
+ for post in all_posts:
+ if post["url"] not in seen_urls:
+ seen_urls.add(post["url"])
+ results.append(post)
# Date filter: keep posts in range or with unknown dates
filtered = []
@@ -252,6 +367,9 @@ def search_reddit_public(
reverse=True,
)
+ # Enrich top posts with comments
+ filtered = _enrich_posts(filtered, depth=depth)
+
# Re-index IDs
for i, item in enumerate(filtered):
item["id"] = f"R{i + 1}"
diff --git a/scripts/lib/relevance.py b/scripts/lib/relevance.py
index 88c0f0e..2416fd5 100644
--- a/scripts/lib/relevance.py
+++ b/scripts/lib/relevance.py
@@ -84,7 +84,7 @@ def token_overlap_relevance(
- a small precision term to penalize extra noise
- an exact phrase bonus
- Generic tokens alone are capped below the post-retrieval 0.3 threshold.
+ Generic tokens alone are capped below typical relevance filter thresholds.
Args:
query: Search query
diff --git a/scripts/lib/render.py b/scripts/lib/render.py
index 24d0897..395c1e4 100644
--- a/scripts/lib/render.py
+++ b/scripts/lib/render.py
@@ -1,1018 +1,642 @@
-"""Output rendering for last30days skill."""
+"""Cluster-first rendering for the v3 pipeline."""
-import json
-import os
-import tempfile
-from pathlib import Path
-from typing import Optional
+from __future__ import annotations
-from . import schema
+from collections import Counter
-OUTPUT_DIR = Path.home() / ".local" / "share" / "last30days" / "out"
+from . import dates, schema
+
+SOURCE_LABELS = {
+ "grounding": "Web",
+ "hackernews": "Hacker News",
+ "truthsocial": "Truth Social",
+ "xiaohongshu": "Xiaohongshu",
+ "x": "X",
+ "github": "GitHub",
+ "perplexity": "Perplexity",
+}
-def _xref_tag(item) -> str:
- """Return ' [also on: Reddit, HN]' string if item has cross_refs, else ''."""
- refs = getattr(item, 'cross_refs', None)
- if not refs:
+_FUN_LEVELS = {
+ "low": {"threshold": 80.0, "limit": 2},
+ "medium": {"threshold": 70.0, "limit": 5},
+ "high": {"threshold": 55.0, "limit": 8},
+}
+
+
+def render_compact(report: schema.Report, cluster_limit: int = 8, fun_level: str = "medium") -> str:
+ non_empty = [s for s, items in sorted(report.items_by_source.items()) if items]
+ lines = [
+ f"# last30days-3 v3.0.0-alpha: {report.topic}",
+ "",
+ f"- Date range: {report.range_from} to {report.range_to}",
+ f"- Sources: {len(non_empty)} active ({', '.join(_source_label(s) for s in non_empty)})" if non_empty else "- Sources: none",
+ "",
+ ]
+
+ freshness_warning = _assess_data_freshness(report)
+ if freshness_warning:
+ lines.extend([
+ "## Freshness",
+ f"- {freshness_warning}",
+ "",
+ ])
+
+ if report.warnings:
+ lines.append("## Warnings")
+ lines.extend(f"- {warning}" for warning in report.warnings)
+ lines.append("")
+
+ lines.append("## Ranked Evidence Clusters")
+ lines.append("")
+ candidate_by_id = {candidate.candidate_id: candidate for candidate in report.ranked_candidates}
+ for index, cluster in enumerate(report.clusters[:cluster_limit], start=1):
+ lines.append(
+ f"### {index}. {cluster.title} "
+ f"(score {cluster.score:.0f}, {len(cluster.candidate_ids)} item{'s' if len(cluster.candidate_ids) != 1 else ''}, "
+ f"sources: {', '.join(_source_label(source) for source in cluster.sources)})"
+ )
+ if cluster.uncertainty:
+ lines.append(f"- Uncertainty: {cluster.uncertainty}")
+ for rep_index, candidate_id in enumerate(cluster.representative_ids, start=1):
+ candidate = candidate_by_id.get(candidate_id)
+ if not candidate:
+ continue
+ lines.extend(_render_candidate(candidate, prefix=f"{rep_index}."))
+ lines.append("")
+
+ lines.extend(_render_stats(report))
+
+ fun_params = _FUN_LEVELS.get(fun_level, _FUN_LEVELS["medium"])
+ best_takes = _render_best_takes(report.ranked_candidates, limit=fun_params["limit"], threshold=fun_params["threshold"])
+ if best_takes:
+ lines.extend([""] + best_takes)
+
+ lines.extend(_render_source_coverage(report))
+ return "\n".join(lines).strip() + "\n"
+
+
+def render_full(report: schema.Report) -> str:
+ """Full data dump: ALL clusters + ALL items by source. For saved files and debugging."""
+ # Start with the same header as compact
+ non_empty = [s for s, items in sorted(report.items_by_source.items()) if items]
+ lines = [
+ f"# last30days-3 v3.0.0-alpha: {report.topic}",
+ "",
+ f"- Date range: {report.range_from} to {report.range_to}",
+ f"- Sources: {len(non_empty)} active ({', '.join(_source_label(s) for s in non_empty)})" if non_empty else "- Sources: none",
+ "",
+ ]
+
+ if report.warnings:
+ lines.append("## Warnings")
+ lines.extend(f"- {warning}" for warning in report.warnings)
+ lines.append("")
+
+ # ALL clusters (no limit)
+ lines.append("## Ranked Evidence Clusters")
+ lines.append("")
+ candidate_by_id = {c.candidate_id: c for c in report.ranked_candidates}
+ for index, cluster in enumerate(report.clusters, start=1):
+ lines.append(
+ f"### {index}. {cluster.title} "
+ f"(score {cluster.score:.0f}, {len(cluster.candidate_ids)} item{'s' if len(cluster.candidate_ids) != 1 else ''}, "
+ f"sources: {', '.join(_source_label(s) for s in cluster.sources)})"
+ )
+ if cluster.uncertainty:
+ lines.append(f"- Uncertainty: {cluster.uncertainty}")
+ for rep_index, cid in enumerate(cluster.representative_ids, start=1):
+ candidate = candidate_by_id.get(cid)
+ if not candidate:
+ continue
+ lines.extend(_render_candidate(candidate, prefix=f"{rep_index}."))
+ lines.append("")
+
+ best_takes = _render_best_takes(report.ranked_candidates)
+ if best_takes:
+ lines.extend(best_takes)
+ lines.append("")
+
+ # ALL items by source (flat dump, v2-style)
+ lines.append("## All Items by Source")
+ lines.append("")
+ source_order = ["reddit", "x", "youtube", "tiktok", "instagram", "threads", "pinterest",
+ "hackernews", "bluesky", "truthsocial", "polymarket", "grounding", "xiaohongshu", "github", "perplexity"]
+ for source in source_order:
+ items = report.items_by_source.get(source, [])
+ if not items:
+ continue
+ lines.append(f"### {_source_label(source)} ({len(items)} items)")
+ lines.append("")
+ for item in items:
+ score = item.local_rank_score if item.local_rank_score is not None else 0
+ lines.append(f"**{item.item_id}** (score:{score:.0f}) {item.author or ''} ({item.published_at or 'date unknown'}) [{_format_item_engagement(item)}]")
+ lines.append(f" {item.title}")
+ if item.url:
+ lines.append(f" {item.url}")
+ if item.container:
+ lines.append(f" *{item.container}*")
+ if item.snippet:
+ lines.append(f" {item.snippet[:500]}")
+ # Top comments for Reddit
+ top_comments = item.metadata.get("top_comments", [])
+ if top_comments and isinstance(top_comments[0], dict):
+ for tc in top_comments[:3]:
+ excerpt = tc.get("excerpt", tc.get("text", ""))[:200]
+ tc_score = tc.get("score", "")
+ lines.append(f" Top comment ({tc_score} upvotes): {excerpt}")
+ # Comment insights for Reddit
+ insights = item.metadata.get("comment_insights", [])
+ if insights:
+ lines.append(" Insights:")
+ for ins in insights[:3]:
+ lines.append(f" - {ins[:200]}")
+ # Transcript highlights for YouTube
+ highlights = item.metadata.get("transcript_highlights", [])
+ if highlights:
+ lines.append(" Highlights:")
+ for hl in highlights[:5]:
+ lines.append(f' - "{hl[:200]}"')
+ # Full transcript snippet for YouTube
+ transcript = item.metadata.get("transcript_snippet", "")
+ if transcript and len(transcript) > 100:
+ lines.append(f" Transcript ({len(transcript.split())} words)")
+ lines.append(f" {transcript[:5000]}")
+ lines.append(" ")
+ # Polymarket outcome prices and market details
+ outcome_prices = item.metadata.get("outcome_prices") or []
+ if outcome_prices and item.source == "polymarket":
+ question = item.metadata.get("question") or ""
+ if question and question != item.title:
+ lines.append(f" Question: {question}")
+ odds_parts = []
+ for name, price in outcome_prices:
+ if isinstance(price, (int, float)):
+ pct = f"{price * 100:.0f}%" if price >= 0.1 else f"{price * 100:.1f}%"
+ odds_parts.append(f"{name}: {pct}")
+ if odds_parts:
+ lines.append(f" Odds: {' | '.join(odds_parts)}")
+ remaining = item.metadata.get("outcomes_remaining") or 0
+ if remaining:
+ lines.append(f" (+{remaining} more outcomes)")
+ end_date = item.metadata.get("end_date")
+ if end_date:
+ lines.append(f" Closes: {end_date}")
+ lines.append("")
+
+ lines.extend(_render_stats(report))
+ lines.extend(_render_source_coverage(report))
+ return "\n".join(lines).strip() + "\n"
+
+
+def _format_item_engagement(item: schema.SourceItem) -> str:
+ """Format engagement metrics for a SourceItem in the full dump."""
+ eng = item.engagement
+ if not eng:
return ""
- source_names = set()
- for ref_id in refs:
- if ref_id.startswith('R'):
- source_names.add('Reddit')
- elif ref_id.startswith('X'):
- source_names.add('X')
- elif ref_id.startswith('YT'):
- source_names.add('YouTube')
- elif ref_id.startswith('TK'):
- source_names.add('TikTok')
- elif ref_id.startswith('IG'):
- source_names.add('Instagram')
- elif ref_id.startswith('HN'):
- source_names.add('HN')
- elif ref_id.startswith('BS'):
- source_names.add('Bluesky')
- elif ref_id.startswith('TS'):
- source_names.add('Truth Social')
- elif ref_id.startswith('PM'):
- source_names.add('Polymarket')
- elif ref_id.startswith('W'):
- source_names.add('Web')
- if source_names:
- return f" [also on: {', '.join(sorted(source_names))}]"
+ parts = []
+ for key in ["score", "likes", "views", "points", "reposts", "replies", "comments",
+ "play_count", "digg_count", "share_count", "num_comments"]:
+ val = eng.get(key)
+ if val is not None and val != 0:
+ parts.append(f"{val} {key}")
+ return ", ".join(parts) if parts else ""
+
+
+def render_context(report: schema.Report, cluster_limit: int = 6) -> str:
+ candidate_by_id = {candidate.candidate_id: candidate for candidate in report.ranked_candidates}
+ lines = [
+ f"Topic: {report.topic}",
+ f"Intent: {report.query_plan.intent}",
+ ]
+ freshness_warning = _assess_data_freshness(report)
+ if freshness_warning:
+ lines.append(f"Freshness warning: {freshness_warning}")
+ lines.append("Top clusters:")
+ for cluster in report.clusters[:cluster_limit]:
+ lines.append(f"- {cluster.title} [{', '.join(_source_label(source) for source in cluster.sources)}]")
+ for candidate_id in cluster.representative_ids[:2]:
+ candidate = candidate_by_id.get(candidate_id)
+ if not candidate:
+ continue
+ detail_parts = [
+ schema.candidate_source_label(candidate),
+ candidate.title,
+ schema.candidate_best_published_at(candidate) or "date unknown",
+ candidate.url,
+ ]
+ lines.append(f" - {' | '.join(detail_parts)}")
+ if candidate.snippet:
+ lines.append(f" Evidence: {_truncate(candidate.snippet, 180)}")
+ if report.warnings:
+ lines.append("Warnings:")
+ lines.extend(f"- {warning}" for warning in report.warnings)
+ return "\n".join(lines).strip() + "\n"
+
+
+def _render_candidate(candidate: schema.Candidate, prefix: str) -> list[str]:
+ primary = schema.candidate_primary_item(candidate)
+ detail_parts = [
+ _format_date(primary),
+ _format_actor(primary),
+ _format_engagement(primary),
+ f"score:{candidate.final_score:.0f}",
+ ]
+ if candidate.fun_score is not None and candidate.fun_score >= 50:
+ detail_parts.append(f"fun:{candidate.fun_score:.0f}")
+ details = " | ".join(part for part in detail_parts if part)
+ lines = [
+ f"{prefix} [{schema.candidate_source_label(candidate)}] {candidate.title}",
+ f" - {details}",
+ f" - URL: {candidate.url}",
+ ]
+ corroboration = _format_corroboration(candidate)
+ if corroboration:
+ lines.append(f" - {corroboration}")
+ explanation = _format_explanation(candidate)
+ if explanation:
+ lines.append(f" - Why: {explanation}")
+ if candidate.snippet:
+ lines.append(f" - Evidence: {_truncate(candidate.snippet, 360)}")
+ for tc in _top_comments_list(primary):
+ excerpt = tc.get("excerpt") or tc.get("text") or ""
+ score = tc.get("score", "")
+ lines.append(f" - Comment ({score} upvotes): {_truncate(excerpt.strip(), 240)}")
+ insight = _comment_insight(primary)
+ if insight:
+ lines.append(f" - Insight: {_truncate(insight, 220)}")
+ highlights = _transcript_highlights(primary)
+ if highlights:
+ lines.append(" - Highlights:")
+ for hl in highlights:
+ lines.append(f' - "{_truncate(hl, 200)}"')
+ return lines
+
+
+def _format_volume_short(volume: float) -> str:
+ """Format volume as short string: 66000 -> '$66K', 1200000 -> '$1.2M'."""
+ if volume >= 1_000_000:
+ return f"${volume / 1_000_000:.1f}M"
+ if volume >= 1_000:
+ return f"${volume / 1_000:.0f}K"
+ if volume >= 1:
+ return f"${volume:.0f}"
return ""
-def ensure_output_dir():
- """Ensure output directory exists. Supports env override and sandbox fallback."""
- global OUTPUT_DIR
- env_dir = os.environ.get("LAST30DAYS_OUTPUT_DIR")
- if env_dir:
- OUTPUT_DIR = Path(env_dir)
+def _polymarket_top_markets(items: list[schema.SourceItem], limit: int = 3) -> list[str]:
+ """Build short summary strings for the top Polymarket markets by volume.
- try:
- OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
- except PermissionError:
- OUTPUT_DIR = Path(tempfile.gettempdir()) / "last30days" / "out"
- OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
+ Returns list like: ['"BULLY <300k": 96% ($66K)', '"Top Spotify": Kanye 6.5% ($21K)']
+ """
+ # Sort by volume descending
+ sorted_items = sorted(
+ items,
+ key=lambda it: it.engagement.get("volume") or 0,
+ reverse=True,
+ )
+
+ summaries = []
+ for item in sorted_items[:limit]:
+ outcome_prices = item.metadata.get("outcome_prices") or []
+ if not outcome_prices:
+ continue
+
+ # Pick the leading outcome (first one, already sorted by relevance in polymarket.py)
+ lead_name, lead_price = outcome_prices[0]
+ # For binary Yes/No markets, show "Yes: 96%" format
+ # For multi-outcome, show "OutcomeName: X%"
+ if isinstance(lead_price, (int, float)):
+ pct = f"{lead_price * 100:.0f}%" if lead_price >= 0.1 else f"{lead_price * 100:.1f}%"
+ else:
+ continue
+
+ # Short title
+ title = item.metadata.get("question") or item.title
+ if len(title) > 30:
+ title = title[:27] + "..."
+
+ summaries.append(f'"{title}": {lead_name} {pct}')
+
+ return summaries
-def _assess_data_freshness(report: schema.Report) -> dict:
- """Assess how much data is actually from the last 30 days."""
- reddit_recent = sum(1 for r in report.reddit if r.date and r.date >= report.range_from)
- x_recent = sum(1 for x in report.x if x.date and x.date >= report.range_from)
- web_recent = sum(1 for w in report.web if w.date and w.date >= report.range_from)
- hn_recent = sum(1 for h in report.hackernews if h.date and h.date >= report.range_from)
- bsky_recent = sum(1 for b in report.bluesky if b.date and b.date >= report.range_from)
- ts_recent = sum(1 for ts in report.truthsocial if ts.date and ts.date >= report.range_from)
- pm_recent = sum(1 for p in report.polymarket if p.date and p.date >= report.range_from)
+def _render_source_coverage(report: schema.Report) -> list[str]:
+ lines = [
+ "## Source Coverage",
+ "",
+ ]
+ for source, items in sorted(report.items_by_source.items()):
+ lines.append(f"- {_source_label(source)}: {len(items)} item{'s' if len(items) != 1 else ''}")
+ if report.errors_by_source:
+ lines.append("")
+ lines.append("## Source Errors")
+ lines.append("")
+ for source, error in sorted(report.errors_by_source.items()):
+ lines.append(f"- {_source_label(source)}: {error}")
+ return lines
- tiktok_recent = sum(1 for t in report.tiktok if t.date and t.date >= report.range_from)
- ig_recent = sum(1 for ig in report.instagram if ig.date and ig.date >= report.range_from)
- total_recent = reddit_recent + x_recent + web_recent + hn_recent + bsky_recent + ts_recent + pm_recent + tiktok_recent + ig_recent
- total_items = len(report.reddit) + len(report.x) + len(report.web) + len(report.hackernews) + len(report.bluesky) + len(report.truthsocial) + len(report.polymarket) + len(report.tiktok) + len(report.instagram)
-
- return {
- "reddit_recent": reddit_recent,
- "x_recent": x_recent,
- "web_recent": web_recent,
- "total_recent": total_recent,
- "total_items": total_items,
- "is_sparse": total_recent < 5,
- "mostly_evergreen": total_items > 0 and total_recent < total_items * 0.3,
+def _render_stats(report: schema.Report) -> list[str]:
+ lines = [
+ "## Stats",
+ "",
+ ]
+ non_empty_sources = {
+ source: items
+ for source, items in sorted(report.items_by_source.items())
+ if items
}
+ total_items = sum(len(items) for items in non_empty_sources.values())
+ if not non_empty_sources:
+ lines.append("- No usable source metrics available.")
+ lines.append("")
+ return lines
-
-def render_compact(report: schema.Report, limit: int = 15, missing_keys: str = "none") -> str:
- """Render compact output for the assistant to synthesize.
-
- Args:
- report: Report data
- limit: Max items per source
- missing_keys: 'both', 'reddit', 'x', or 'none'
-
- Returns:
- Compact markdown string
- """
- lines = []
-
- # Header
- lines.append(f"## Research Results: {report.topic}")
+ lines.append(
+ f"- Total evidence: {total_items} item{'s' if total_items != 1 else ''} across "
+ f"{len(non_empty_sources)} source{'s' if len(non_empty_sources) != 1 else ''}"
+ )
+ top_voices = _top_voices_overall(non_empty_sources)
+ if top_voices:
+ lines.append(f"- Top voices: {', '.join(top_voices)}")
+ for source, items in non_empty_sources.items():
+ if source == "polymarket":
+ # Polymarket gets a richer stats line with top market odds
+ market_summaries = _polymarket_top_markets(items)
+ if market_summaries:
+ label = f"{len(items)} market{'s' if len(items) != 1 else ''}"
+ parts_str = f"{label} | " + " | ".join(market_summaries)
+ else:
+ parts_str = f"{len(items)} market{'s' if len(items) != 1 else ''}"
+ engagement_summary = _aggregate_engagement(source, items)
+ if engagement_summary:
+ parts_str += f" | {engagement_summary}"
+ lines.append(f"- {_source_label(source)}: {parts_str}")
+ continue
+ parts = [f"{len(items)} item{'s' if len(items) != 1 else ''}"]
+ engagement_summary = _aggregate_engagement(source, items)
+ if engagement_summary:
+ parts.append(engagement_summary)
+ actor_summary = _top_actor_summary(source, items)
+ if actor_summary:
+ parts.append(actor_summary)
+ lines.append(f"- {_source_label(source)}: {' | '.join(parts)}")
lines.append("")
-
- # Assess data freshness and add honesty warning if needed
- freshness = _assess_data_freshness(report)
- if freshness["is_sparse"]:
- lines.append("**⚠️ LIMITED RECENT DATA** - Few discussions from the last 30 days.")
- lines.append(f"Only {freshness['total_recent']} item(s) confirmed from {report.range_from} to {report.range_to}.")
- lines.append("Results below may include older/evergreen content. Be transparent with the user about this.")
- lines.append("")
-
- # Web-only mode banner (when no API keys)
- if report.mode == "web-only":
- lines.append("**🌐 WEB SEARCH MODE** - assistant will search blogs, docs & news")
- lines.append("")
- lines.append("---")
- lines.append("**⚡ Want better results?** Add API keys to unlock Reddit, TikTok, Instagram & X data:")
- lines.append("- `SCRAPECREATORS_API_KEY` → Reddit + TikTok + Instagram (one key, all three!) — 100 free calls, no CC — scrapecreators.com (no affiliation)")
- lines.append("- `XAI_API_KEY` → X posts with real likes & reposts")
- lines.append("- `OPENAI_API_KEY` (legacy) → Reddit threads (slower, higher cost)")
- lines.append("- Edit `~/.config/last30days/.env` to add keys")
- lines.append("---")
- lines.append("")
-
- # Cache indicator
- if report.from_cache:
- age_str = f"{report.cache_age_hours:.1f}h old" if report.cache_age_hours else "cached"
- lines.append(f"**⚡ CACHED RESULTS** ({age_str}) - use `--refresh` for fresh data")
- lines.append("")
-
- lines.append(f"**Date Range:** {report.range_from} to {report.range_to}")
- lines.append(f"**Mode:** {report.mode}")
- if report.openai_model_used:
- lines.append(f"**OpenAI Model:** {report.openai_model_used}")
- if report.xai_model_used:
- lines.append(f"**xAI Model:** {report.xai_model_used}")
- if report.resolved_x_handle:
- lines.append(f"**Resolved X Handle:** @{report.resolved_x_handle}")
- lines.append("")
-
- # Coverage note for partial coverage
- if report.mode == "reddit-only" and missing_keys in ("x", "none"):
- lines.append("*💡 Tip: Add an xAI key (`XAI_API_KEY`) for X/Twitter data and better triangulation.*")
- lines.append("")
- elif report.mode == "x-only" and missing_keys in ("reddit", "none"):
- lines.append("*💡 Tip: Add `SCRAPECREATORS_API_KEY` for Reddit + TikTok + Instagram data (one key, all three) — 100 free calls, no CC — scrapecreators.com (no affiliation)*")
- lines.append("")
-
- # Reddit items
- if report.reddit_error:
- lines.append("### Reddit Threads")
- lines.append("")
- lines.append(f"**ERROR:** {report.reddit_error}")
- lines.append("")
- elif report.mode in ("both", "reddit-only") and not report.reddit:
- lines.append("### Reddit Threads")
- lines.append("")
- lines.append("*No relevant Reddit threads found for this topic.*")
- lines.append("")
- elif report.reddit:
- lines.append("### Reddit Threads")
- lines.append("")
- for item in report.reddit[:limit]:
- eng_str = ""
- if item.engagement:
- eng = item.engagement
- parts = []
- if eng.score is not None:
- parts.append(f"{eng.score}pts")
- if eng.num_comments is not None:
- parts.append(f"{eng.num_comments}cmt")
- if parts:
- eng_str = f" [{', '.join(parts)}]"
-
- date_str = f" ({item.date})" if item.date else " (date unknown)"
- conf_str = f" [date:{item.date_confidence}]" if item.date_confidence != "high" else ""
-
- lines.append(f"**{item.id}** (score:{item.score}) r/{item.subreddit}{date_str}{conf_str}{eng_str}{_xref_tag(item)}")
- lines.append(f" {item.title}")
- lines.append(f" {item.url}")
- lines.append(f" *{item.why_relevant}*")
-
- # Top comment (elevated — Reddit's value IS the comments)
- if item.top_comments and item.top_comments[0].score >= 10:
- tc = item.top_comments[0]
- excerpt = tc.excerpt[:200]
- if len(tc.excerpt) > 200:
- excerpt = excerpt.rstrip() + "..."
- lines.append(f' \U0001f4ac Top comment ({tc.score} upvotes): "{excerpt}"')
-
- # Comment insights
- if item.comment_insights:
- lines.append(" Insights:")
- for insight in item.comment_insights[:3]:
- lines.append(f" - {insight}")
-
- lines.append("")
-
- # X items
- if report.x_error:
- lines.append("### X Posts")
- lines.append("")
- lines.append(f"**ERROR:** {report.x_error}")
- lines.append("")
- elif report.mode in ("both", "x-only", "all", "x-web") and not report.x:
- lines.append("### X Posts")
- lines.append("")
- lines.append("*No relevant X posts found for this topic.*")
- lines.append("")
- elif report.x:
- lines.append("### X Posts")
- lines.append("")
- for item in report.x[:limit]:
- eng_str = ""
- if item.engagement:
- eng = item.engagement
- parts = []
- if eng.likes is not None:
- parts.append(f"{eng.likes}likes")
- if eng.reposts is not None:
- parts.append(f"{eng.reposts}rt")
- if parts:
- eng_str = f" [{', '.join(parts)}]"
-
- date_str = f" ({item.date})" if item.date else " (date unknown)"
- conf_str = f" [date:{item.date_confidence}]" if item.date_confidence != "high" else ""
-
- lines.append(f"**{item.id}** (score:{item.score}) @{item.author_handle}{date_str}{conf_str}{eng_str}{_xref_tag(item)}")
- lines.append(f" {item.text[:200]}...")
- lines.append(f" {item.url}")
- lines.append(f" *{item.why_relevant}*")
- lines.append("")
-
- # YouTube items
- if report.youtube_error:
- lines.append("### YouTube Videos")
- lines.append("")
- lines.append(f"**ERROR:** {report.youtube_error}")
- lines.append("")
- elif report.youtube:
- lines.append("### YouTube Videos")
- lines.append("")
- for item in report.youtube[:limit]:
- eng_str = ""
- if item.engagement:
- eng = item.engagement
- parts = []
- if eng.views is not None:
- parts.append(f"{eng.views:,} views")
- if eng.likes is not None:
- parts.append(f"{eng.likes:,} likes")
- if parts:
- eng_str = f" [{', '.join(parts)}]"
-
- date_str = f" ({item.date})" if item.date else ""
-
- lines.append(f"**{item.id}** (score:{item.score}) {item.channel_name}{date_str}{eng_str}{_xref_tag(item)}")
- lines.append(f" {item.title}")
- lines.append(f" {item.url}")
- if item.transcript_highlights:
- lines.append(" Highlights:")
- for hl in item.transcript_highlights[:5]:
- lines.append(f' - "{hl}"')
- if item.transcript_snippet:
- word_count = len(item.transcript_snippet.split())
- lines.append(f" Full transcript ({word_count} words)")
- lines.append(f" {item.transcript_snippet}")
- lines.append(" ")
- lines.append(f" *{item.why_relevant}*")
- lines.append("")
-
- # TikTok items
- if report.tiktok_error:
- lines.append("### TikTok Videos")
- lines.append("")
- lines.append(f"**ERROR:** {report.tiktok_error}")
- lines.append("")
- elif report.tiktok:
- lines.append("### TikTok Videos")
- lines.append("")
- for item in report.tiktok[:limit]:
- eng_str = ""
- if item.engagement:
- eng = item.engagement
- parts = []
- if eng.views is not None:
- parts.append(f"{eng.views:,} views")
- if eng.likes is not None:
- parts.append(f"{eng.likes:,} likes")
- if parts:
- eng_str = f" [{', '.join(parts)}]"
-
- date_str = f" ({item.date})" if item.date else ""
-
- lines.append(f"**{item.id}** (score:{item.score}) @{item.author_name}{date_str}{eng_str}{_xref_tag(item)}")
- lines.append(f" {item.text[:200]}")
- lines.append(f" {item.url}")
- if item.caption_snippet and item.caption_snippet != item.text[:len(item.caption_snippet)]:
- snippet = item.caption_snippet[:200]
- if len(item.caption_snippet) > 200:
- snippet += "..."
- lines.append(f" Caption: {snippet}")
- if item.hashtags:
- lines.append(f" Tags: {' '.join('#' + h for h in item.hashtags[:8])}")
- lines.append(f" *{item.why_relevant}*")
- lines.append("")
-
- # Instagram items
- if report.instagram_error:
- lines.append("### Instagram Reels")
- lines.append("")
- lines.append(f"**ERROR:** {report.instagram_error}")
- lines.append("")
- elif report.instagram:
- lines.append("### Instagram Reels")
- lines.append("")
- for item in report.instagram[:limit]:
- eng_str = ""
- if item.engagement:
- eng = item.engagement
- parts = []
- if eng.views is not None:
- parts.append(f"{eng.views:,} views")
- if eng.likes is not None:
- parts.append(f"{eng.likes:,} likes")
- if parts:
- eng_str = f" [{', '.join(parts)}]"
-
- date_str = f" ({item.date})" if item.date else ""
-
- lines.append(f"**{item.id}** (score:{item.score}) @{item.author_name}{date_str}{eng_str}{_xref_tag(item)}")
- lines.append(f" {item.text[:200]}")
- lines.append(f" {item.url}")
- if item.caption_snippet and item.caption_snippet != item.text[:len(item.caption_snippet)]:
- snippet = item.caption_snippet[:200]
- if len(item.caption_snippet) > 200:
- snippet += "..."
- lines.append(f" Caption: {snippet}")
- if item.hashtags:
- lines.append(f" Tags: {' '.join('#' + h for h in item.hashtags[:8])}")
- lines.append(f" *{item.why_relevant}*")
- lines.append("")
-
- # Hacker News items
- if report.hackernews_error:
- lines.append("### Hacker News Stories")
- lines.append("")
- lines.append(f"**ERROR:** {report.hackernews_error}")
- lines.append("")
- elif report.hackernews:
- lines.append("### Hacker News Stories")
- lines.append("")
- for item in report.hackernews[:limit]:
- eng_str = ""
- if item.engagement:
- eng = item.engagement
- parts = []
- if eng.score is not None:
- parts.append(f"{eng.score}pts")
- if eng.num_comments is not None:
- parts.append(f"{eng.num_comments}cmt")
- if parts:
- eng_str = f" [{', '.join(parts)}]"
-
- date_str = f" ({item.date})" if item.date else ""
-
- lines.append(f"**{item.id}** (score:{item.score}) hn/{item.author}{date_str}{eng_str}{_xref_tag(item)}")
- lines.append(f" {item.title}")
- lines.append(f" {item.hn_url}")
- lines.append(f" *{item.why_relevant}*")
-
- # Comment insights
- if item.comment_insights:
- lines.append(f" Insights:")
- for insight in item.comment_insights[:3]:
- lines.append(f" - {insight}")
-
- lines.append("")
-
- # Bluesky items
- if report.bluesky_error:
- lines.append("### Bluesky Posts")
- lines.append("")
- lines.append(f"**ERROR:** {report.bluesky_error}")
- lines.append("")
- elif report.bluesky:
- lines.append("### Bluesky Posts")
- lines.append("")
- for item in report.bluesky[:limit]:
- eng_str = ""
- if item.engagement:
- eng = item.engagement
- parts = []
- if eng.likes is not None:
- parts.append(f"{eng.likes}lk")
- if eng.reposts is not None:
- parts.append(f"{eng.reposts}rp")
- if eng.replies is not None:
- parts.append(f"{eng.replies}re")
- if parts:
- eng_str = f" [{', '.join(parts)}]"
-
- date_str = f" ({item.date})" if item.date else ""
-
- lines.append(f"**{item.id}** (score:{item.score}) @{item.author_handle}{date_str}{eng_str}{_xref_tag(item)}")
- if item.text:
- snippet = item.text[:200]
- if len(item.text) > 200:
- snippet += "..."
- lines.append(f" {snippet}")
- if item.url:
- lines.append(f" {item.url}")
- lines.append(f" *{item.why_relevant}*")
- lines.append("")
-
- # Truth Social items
- if report.truthsocial_error:
- lines.append("### Truth Social Posts")
- lines.append("")
- lines.append(f"**ERROR:** {report.truthsocial_error}")
- lines.append("")
- elif report.truthsocial:
- lines.append("### Truth Social Posts")
- lines.append("")
- for item in report.truthsocial[:limit]:
- eng_str = ""
- if item.engagement:
- eng = item.engagement
- parts = []
- if eng.likes is not None:
- parts.append(f"{eng.likes}lk")
- if eng.reposts is not None:
- parts.append(f"{eng.reposts}rp")
- if eng.replies is not None:
- parts.append(f"{eng.replies}re")
- if parts:
- eng_str = f" [{', '.join(parts)}]"
-
- date_str = f" ({item.date})" if item.date else ""
-
- lines.append(f"**{item.id}** (score:{item.score}) @{item.author_handle}{date_str}{eng_str}{_xref_tag(item)}")
- if item.text:
- snippet = item.text[:200]
- if len(item.text) > 200:
- snippet += "..."
- lines.append(f" {snippet}")
- if item.url:
- lines.append(f" {item.url}")
- lines.append(f" *{item.why_relevant}*")
- lines.append("")
-
- # Polymarket items
- if report.polymarket_error:
- lines.append("### Prediction Markets (Polymarket)")
- lines.append("")
- lines.append(f"**ERROR:** {report.polymarket_error}")
- lines.append("")
- elif report.polymarket:
- lines.append("### Prediction Markets (Polymarket)")
- lines.append("")
- for item in report.polymarket[:limit]:
- eng_str = ""
- if item.engagement:
- eng = item.engagement
- parts = []
- if eng.volume is not None:
- if eng.volume >= 1_000_000:
- parts.append(f"${eng.volume/1_000_000:.1f}M volume")
- elif eng.volume >= 1_000:
- parts.append(f"${eng.volume/1_000:.0f}K volume")
- else:
- parts.append(f"${eng.volume:.0f} volume")
- if eng.liquidity is not None:
- if eng.liquidity >= 1_000_000:
- parts.append(f"${eng.liquidity/1_000_000:.1f}M liquidity")
- elif eng.liquidity >= 1_000:
- parts.append(f"${eng.liquidity/1_000:.0f}K liquidity")
- else:
- parts.append(f"${eng.liquidity:.0f} liquidity")
- if parts:
- eng_str = f" [{', '.join(parts)}]"
-
- date_str = f" ({item.date})" if item.date else ""
-
- lines.append(f"**{item.id}** (score:{item.score}){eng_str}{_xref_tag(item)}")
- lines.append(f" {item.question}")
-
- # Outcome prices with price movement
- if item.outcome_prices:
- outcomes = []
- for name, price in item.outcome_prices:
- pct = price * 100
- outcomes.append(f"{name}: {pct:.0f}%")
- outcome_line = " | ".join(outcomes)
- if item.outcomes_remaining > 0:
- outcome_line += f" and {item.outcomes_remaining} more"
- if item.price_movement:
- outcome_line += f" ({item.price_movement})"
- lines.append(f" {outcome_line}")
-
- lines.append(f" {item.url}")
- lines.append(f" *{item.why_relevant}*")
- lines.append("")
-
- # Web items (if any - populated by the assistant)
- if report.web_error:
- lines.append("### Web Results")
- lines.append("")
- lines.append(f"**ERROR:** {report.web_error}")
- lines.append("")
- elif report.web:
- lines.append("### Web Results")
- lines.append("")
- for item in report.web[:limit]:
- date_str = f" ({item.date})" if item.date else " (date unknown)"
- conf_str = f" [date:{item.date_confidence}]" if item.date_confidence != "high" else ""
-
- lines.append(f"**{item.id}** [WEB] (score:{item.score}) {item.source_domain}{date_str}{conf_str}{_xref_tag(item)}")
- lines.append(f" {item.title}")
- lines.append(f" {item.url}")
- lines.append(f" {item.snippet[:150]}...")
- lines.append(f" *{item.why_relevant}*")
- lines.append("")
-
- return "\n".join(lines)
+ return lines
-def render_quality_nudge(quality: dict) -> str:
- """Render the quality score nudge block.
-
- Args:
- quality: Dict from quality_nudge.compute_quality_score()
-
- Returns:
- Markdown string with quality nudge, or empty string if no nudge.
- """
- nudge_text = quality.get("nudge_text")
- if not nudge_text:
- return ""
-
- lines = []
- lines.append("---")
- lines.append(f"**🔍 Research Coverage: {quality['score_pct']}%**")
- lines.append("")
- lines.append(nudge_text)
- lines.append("")
- return "\n".join(lines)
+def _assess_data_freshness(report: schema.Report) -> str | None:
+ dated_items = [
+ item
+ for items in report.items_by_source.values()
+ for item in items
+ if item.published_at
+ ]
+ if not dated_items:
+ return "Limited recent data: no usable dated evidence made it into the retrieved pool."
+ recent_items = [
+ item
+ for item in dated_items
+ if (_days_ago := dates.days_ago(item.published_at)) is not None and _days_ago <= 7
+ ]
+ if len(recent_items) < 3:
+ return f"Limited recent data: only {len(recent_items)} of {len(dated_items)} dated items are from the last 7 days."
+ if len(recent_items) * 2 < len(dated_items):
+ return f"Recent evidence is thin: only {len(recent_items)} of {len(dated_items)} dated items are from the last 7 days."
+ return None
-def render_source_status(report: schema.Report, source_info: dict = None) -> str:
- """Render source status footer showing what was used/skipped and why.
+def _format_date(item: schema.SourceItem | None) -> str:
+ if not item or not item.published_at:
+ return "date unknown [date:low]"
+ if item.date_confidence == "high":
+ return item.published_at
+ return f"{item.published_at} [date:{item.date_confidence}]"
- Args:
- report: Report data
- source_info: Dict with source availability info:
- x_skip_reason, youtube_skip_reason, web_skip_reason
- Returns:
- Source status markdown string
- """
- if source_info is None:
- source_info = {}
+def _format_actor(item: schema.SourceItem | None) -> str | None:
+ if not item:
+ return None
+ if item.source == "reddit" and item.container:
+ return f"r/{item.container}"
+ if item.source in {"x", "bluesky", "truthsocial"} and item.author:
+ return f"@{item.author.lstrip('@')}"
+ if item.source == "youtube" and item.author:
+ return item.author
+ if item.container and item.container != "Polymarket":
+ return item.container
+ if item.author:
+ return item.author
+ return None
- lines = []
- lines.append("---")
- lines.append("**Sources:**")
- # Reddit
- if report.reddit_error:
- lines.append(f" ❌ Reddit: error — {report.reddit_error}")
- elif report.reddit:
- lines.append(f" ✅ Reddit: {len(report.reddit)} threads")
- elif report.mode in ("both", "reddit-only", "all", "reddit-web"):
- pass # Hide zero-result sources
+# Per-source engagement display fields: list of (field_name, label) tuples.
+ENGAGEMENT_DISPLAY: dict[str, list[tuple[str, str]]] = {
+ "reddit": [("score", "pts"), ("num_comments", "cmt")],
+ "x": [("likes", "likes"), ("reposts", "rt"), ("replies", "re")],
+ "youtube": [("views", "views"), ("likes", "likes"), ("comments", "cmt")],
+ "tiktok": [("views", "views"), ("likes", "likes"), ("comments", "cmt")],
+ "instagram": [("views", "views"), ("likes", "likes"), ("comments", "cmt")],
+ "threads": [("likes", "likes"), ("replies", "re")],
+ "pinterest": [("saves", "saves"), ("comments", "cmt")],
+ "hackernews": [("points", "pts"), ("comments", "cmt")],
+ "bluesky": [("likes", "likes"), ("reposts", "rt"), ("replies", "re")],
+ "truthsocial": [("likes", "likes"), ("reposts", "rt"), ("replies", "re")],
+ "polymarket": [],
+ "github": [("reactions", "react"), ("comments", "cmt")],
+ "perplexity": [("citations", "cite")],
+}
+
+
+def _format_engagement(item: schema.SourceItem | None) -> str | None:
+ if not item or not item.engagement:
+ return None
+ engagement = item.engagement
+ fields = ENGAGEMENT_DISPLAY.get(item.source)
+ if fields:
+ text = _fmt_pairs([(engagement.get(field), label) for field, label in fields])
else:
- reason = source_info.get("reddit_skip_reason", "not configured")
- lines.append(f" ⏭️ Reddit: skipped — {reason}")
-
- # X
- if report.x_error:
- lines.append(f" ❌ X: error — {report.x_error}")
- elif report.x:
- x_line = f" ✅ X: {len(report.x)} posts"
- if report.resolved_x_handle:
- x_line += f" (via @{report.resolved_x_handle} + keyword search)"
- lines.append(x_line)
- elif report.mode in ("both", "x-only", "all", "x-web"):
- pass # Hide zero-result sources
- else:
- reason = source_info.get("x_skip_reason", "No Bird CLI or XAI_API_KEY")
- lines.append(f" ⏭️ X: skipped — {reason}")
-
- # YouTube
- if report.youtube_error:
- lines.append(f" ❌ YouTube: error — {report.youtube_error}")
- elif report.youtube:
- with_transcripts = sum(1 for v in report.youtube if getattr(v, 'transcript_snippet', None))
- lines.append(f" ✅ YouTube: {len(report.youtube)} videos ({with_transcripts} with transcripts)")
- # Hide when zero results (no skip reason line needed)
-
- # TikTok
- if report.tiktok_error:
- lines.append(f" ❌ TikTok: error — {report.tiktok_error}")
- elif report.tiktok:
- with_captions = sum(1 for v in report.tiktok if getattr(v, 'caption_snippet', None))
- lines.append(f" ✅ TikTok: {len(report.tiktok)} videos ({with_captions} with captions)")
- # Hide when zero results
-
- # Instagram
- if report.instagram_error:
- lines.append(f" ❌ Instagram: error — {report.instagram_error}")
- elif report.instagram:
- with_captions = sum(1 for v in report.instagram if getattr(v, 'caption_snippet', None))
- lines.append(f" ✅ Instagram: {len(report.instagram)} reels ({with_captions} with captions)")
- # Hide when zero results
-
- # Xiaohongshu (from Web source bucket)
- xhs_count = 0
- if report.web:
- xhs_count = sum(
- 1 for w in report.web
- if getattr(w, "source_domain", "").lower().endswith("xiaohongshu.com")
- )
- if xhs_count > 0:
- lines.append(f" ✅ Xiaohongshu: {xhs_count} notes")
- else:
- reason = source_info.get("xiaohongshu_skip_reason")
- if reason:
- lines.append(f" ⚡ Xiaohongshu: {reason}")
-
- # Hacker News
- if report.hackernews_error:
- lines.append(f" ❌ HN: error - {report.hackernews_error}")
- elif report.hackernews:
- lines.append(f" ✅ HN: {len(report.hackernews)} stories")
- # Hide when zero results
-
- # Bluesky
- if report.bluesky_error:
- lines.append(f" ❌ Bluesky: error - {report.bluesky_error}")
- elif report.bluesky:
- lines.append(f" ✅ Bluesky: {len(report.bluesky)} posts")
- # Hide when zero results
-
- # Truth Social
- if report.truthsocial_error:
- lines.append(f" ❌ Truth Social: error - {report.truthsocial_error}")
- elif report.truthsocial:
- lines.append(f" ✅ Truth Social: {len(report.truthsocial)} posts")
- # Hide when zero results
-
- # Polymarket
- if report.polymarket_error:
- lines.append(f" ❌ Polymarket: error - {report.polymarket_error}")
- elif report.polymarket:
- lines.append(f" ✅ Polymarket: {len(report.polymarket)} markets")
- # Hide when zero results
-
- # Web
- if report.web_error:
- lines.append(f" ❌ Web: error — {report.web_error}")
- elif report.web:
- lines.append(f" ✅ Web: {len(report.web)} pages")
- else:
- reason = source_info.get("web_skip_reason", "assistant will use WebSearch")
- lines.append(f" ⚡ Web: {reason}")
-
- lines.append("")
- return "\n".join(lines)
-
-
-def render_context_snippet(report: schema.Report) -> str:
- """Render reusable context snippet.
-
- Args:
- report: Report data
-
- Returns:
- Context markdown string
- """
- lines = []
- lines.append(f"# Context: {report.topic} (Last 30 Days)")
- lines.append("")
- lines.append(f"*Generated: {report.generated_at[:10]} | Sources: {report.mode}*")
- lines.append("")
-
- # Key sources summary
- lines.append("## Key Sources")
- lines.append("")
-
- all_items = []
- for item in report.reddit[:5]:
- all_items.append((item.score, "Reddit", item.title, item.url))
- for item in report.x[:5]:
- all_items.append((item.score, "X", item.text[:50] + "...", item.url))
- for item in report.tiktok[:5]:
- all_items.append((item.score, "TikTok", item.text[:50] + "...", item.url))
- for item in report.instagram[:5]:
- all_items.append((item.score, "Instagram", item.text[:50] + "...", item.url))
- for item in report.hackernews[:5]:
- all_items.append((item.score, "HN", item.title[:50] + "...", item.hn_url))
- for item in report.bluesky[:5]:
- all_items.append((item.score, "Bluesky", item.text[:50] + "...", item.url))
- for item in report.truthsocial[:5]:
- all_items.append((item.score, "Truth Social", item.text[:50] + "...", item.url))
- for item in report.polymarket[:5]:
- all_items.append((item.score, "Polymarket", item.question[:50] + "...", item.url))
- for item in report.web[:5]:
- all_items.append((item.score, "Web", item.title[:50] + "...", item.url))
-
- all_items.sort(key=lambda x: -x[0])
- for score, source, text, url in all_items[:7]:
- lines.append(f"- [{source}] {text}")
-
- lines.append("")
- lines.append("## Summary")
- lines.append("")
- lines.append("*See full report for best practices, prompt pack, and detailed sources.*")
- lines.append("")
-
- return "\n".join(lines)
-
-
-def render_full_report(report: schema.Report) -> str:
- """Render full markdown report.
-
- Args:
- report: Report data
-
- Returns:
- Full report markdown
- """
- lines = []
-
- # Title
- lines.append(f"# {report.topic} - Last 30 Days Research Report")
- lines.append("")
- lines.append(f"**Generated:** {report.generated_at}")
- lines.append(f"**Date Range:** {report.range_from} to {report.range_to}")
- lines.append(f"**Mode:** {report.mode}")
- lines.append("")
-
- # Models
- lines.append("## Models Used")
- lines.append("")
- if report.openai_model_used:
- lines.append(f"- **OpenAI:** {report.openai_model_used}")
- if report.xai_model_used:
- lines.append(f"- **xAI:** {report.xai_model_used}")
- lines.append("")
-
- # Reddit section
- if report.reddit:
- lines.append("## Reddit Threads")
- lines.append("")
- for item in report.reddit:
- lines.append(f"### {item.id}: {item.title}")
- lines.append("")
- lines.append(f"- **Subreddit:** r/{item.subreddit}")
- lines.append(f"- **URL:** {item.url}")
- lines.append(f"- **Date:** {item.date or 'Unknown'} (confidence: {item.date_confidence})")
- lines.append(f"- **Score:** {item.score}/100")
- lines.append(f"- **Relevance:** {item.why_relevant}")
-
- if item.engagement:
- eng = item.engagement
- lines.append(f"- **Engagement:** {eng.score or '?'} points, {eng.num_comments or '?'} comments")
-
- if item.top_comments and item.top_comments[0].score >= 10:
- tc = item.top_comments[0]
- excerpt = tc.excerpt[:200]
- if len(tc.excerpt) > 200:
- excerpt = excerpt.rstrip() + "..."
- lines.append("")
- lines.append(f'**\U0001f4ac Top Comment** ({tc.score} upvotes, u/{tc.author}):')
- lines.append(f'> {excerpt}')
-
- if item.comment_insights:
- lines.append("")
- lines.append("**Key Insights from Comments:**")
- for insight in item.comment_insights:
- lines.append(f"- {insight}")
-
- lines.append("")
-
- # X section
- if report.x:
- lines.append("## X Posts")
- lines.append("")
- for item in report.x:
- lines.append(f"### {item.id}: @{item.author_handle}")
- lines.append("")
- lines.append(f"- **URL:** {item.url}")
- lines.append(f"- **Date:** {item.date or 'Unknown'} (confidence: {item.date_confidence})")
- lines.append(f"- **Score:** {item.score}/100")
- lines.append(f"- **Relevance:** {item.why_relevant}")
-
- if item.engagement:
- eng = item.engagement
- lines.append(f"- **Engagement:** {eng.likes or '?'} likes, {eng.reposts or '?'} reposts")
-
- lines.append("")
- lines.append(f"> {item.text}")
- lines.append("")
-
- # TikTok section
- if report.tiktok:
- lines.append("## TikTok Videos")
- lines.append("")
- for item in report.tiktok:
- lines.append(f"### {item.id}: @{item.author_name}")
- lines.append("")
- lines.append(f"- **URL:** {item.url}")
- lines.append(f"- **Date:** {item.date or 'Unknown'}")
- lines.append(f"- **Score:** {item.score}/100")
- lines.append(f"- **Relevance:** {item.why_relevant}")
-
- if item.engagement:
- eng = item.engagement
- lines.append(f"- **Engagement:** {eng.views or '?'} views, {eng.likes or '?'} likes, {eng.num_comments or '?'} comments")
-
- if item.hashtags:
- lines.append(f"- **Hashtags:** {' '.join('#' + h for h in item.hashtags[:10])}")
-
- lines.append("")
- lines.append(f"> {item.text[:300]}")
- lines.append("")
-
- # Instagram section
- if report.instagram:
- lines.append("## Instagram Reels")
- lines.append("")
- for item in report.instagram:
- lines.append(f"### {item.id}: @{item.author_name}")
- lines.append("")
- lines.append(f"- **URL:** {item.url}")
- lines.append(f"- **Date:** {item.date or 'Unknown'}")
- lines.append(f"- **Score:** {item.score}/100")
- lines.append(f"- **Relevance:** {item.why_relevant}")
-
- if item.engagement:
- eng = item.engagement
- lines.append(f"- **Engagement:** {eng.views or '?'} views, {eng.likes or '?'} likes, {eng.num_comments or '?'} comments")
-
- if item.hashtags:
- lines.append(f"- **Hashtags:** {' '.join('#' + h for h in item.hashtags[:10])}")
-
- lines.append("")
- lines.append(f"> {item.text[:300]}")
- lines.append("")
-
- # HN section
- if report.hackernews:
- lines.append("## Hacker News Stories")
- lines.append("")
- for item in report.hackernews:
- lines.append(f"### {item.id}: {item.title}")
- lines.append("")
- lines.append(f"- **Author:** {item.author}")
- lines.append(f"- **HN URL:** {item.hn_url}")
- if item.url:
- lines.append(f"- **Article URL:** {item.url}")
- lines.append(f"- **Date:** {item.date or 'Unknown'}")
- lines.append(f"- **Score:** {item.score}/100")
- lines.append(f"- **Relevance:** {item.why_relevant}")
-
- if item.engagement:
- eng = item.engagement
- lines.append(f"- **Engagement:** {eng.score or '?'} points, {eng.num_comments or '?'} comments")
-
- if item.comment_insights:
- lines.append("")
- lines.append("**Key Insights from Comments:**")
- for insight in item.comment_insights:
- lines.append(f"- {insight}")
-
- lines.append("")
-
- # Bluesky section
- if report.bluesky:
- lines.append("## Bluesky Posts")
- lines.append("")
- for item in report.bluesky:
- lines.append(f"### {item.id}: @{item.author_handle}")
- lines.append("")
- lines.append(f"- **URL:** {item.url}")
- lines.append(f"- **Date:** {item.date or 'Unknown'}")
- lines.append(f"- **Score:** {item.score}/100")
- lines.append(f"- **Relevance:** {item.why_relevant}")
-
- if item.engagement:
- eng = item.engagement
- lines.append(f"- **Engagement:** {eng.likes or '?'} likes, {eng.reposts or '?'} reposts, {eng.replies or '?'} replies")
-
- lines.append("")
- lines.append(f"> {item.text[:300]}")
- lines.append("")
-
- # Truth Social section
- if report.truthsocial:
- lines.append("## Truth Social Posts")
- lines.append("")
- for item in report.truthsocial:
- lines.append(f"### {item.id}: @{item.author_handle}")
- lines.append("")
- lines.append(f"- **URL:** {item.url}")
- lines.append(f"- **Date:** {item.date or 'Unknown'}")
- lines.append(f"- **Score:** {item.score}/100")
- lines.append(f"- **Relevance:** {item.why_relevant}")
-
- if item.engagement:
- eng = item.engagement
- lines.append(f"- **Engagement:** {eng.likes or '?'} likes, {eng.reposts or '?'} reposts, {eng.replies or '?'} replies")
-
- lines.append("")
- lines.append(f"> {item.text[:300]}")
- lines.append("")
-
- # Polymarket section
- if report.polymarket:
- lines.append("## Prediction Markets (Polymarket)")
- lines.append("")
- for item in report.polymarket:
- lines.append(f"### {item.id}: {item.question}")
- lines.append("")
- lines.append(f"- **Event:** {item.title}")
- lines.append(f"- **URL:** {item.url}")
- lines.append(f"- **Date:** {item.date or 'Unknown'}")
- lines.append(f"- **Score:** {item.score}/100")
-
- if item.outcome_prices:
- outcomes = [f"{name}: {price*100:.0f}%" for name, price in item.outcome_prices]
- lines.append(f"- **Outcomes:** {' | '.join(outcomes)}")
- if item.price_movement:
- lines.append(f"- **Trend:** {item.price_movement}")
- if item.engagement:
- eng = item.engagement
- lines.append(f"- **Volume:** ${eng.volume or 0:,.0f} | Liquidity: ${eng.liquidity or 0:,.0f}")
-
- lines.append("")
-
- # Web section
- if report.web:
- lines.append("## Web Results")
- lines.append("")
- for item in report.web:
- lines.append(f"### {item.id}: {item.title}")
- lines.append("")
- lines.append(f"- **Source:** {item.source_domain}")
- lines.append(f"- **URL:** {item.url}")
- lines.append(f"- **Date:** {item.date or 'Unknown'} (confidence: {item.date_confidence})")
- lines.append(f"- **Score:** {item.score}/100")
- lines.append(f"- **Relevance:** {item.why_relevant}")
- lines.append("")
- lines.append(f"> {item.snippet}")
- lines.append("")
-
- # Placeholders for assistant synthesis
- lines.append("## Best Practices")
- lines.append("")
- lines.append("*To be synthesized by assistant*")
- lines.append("")
-
- lines.append("## Prompt Pack")
- lines.append("")
- lines.append("*To be synthesized by assistant*")
- lines.append("")
-
- return "\n".join(lines)
-
-
-
-
-def write_outputs(
- report: schema.Report,
- raw_openai: Optional[dict] = None,
- raw_xai: Optional[dict] = None,
- raw_reddit_enriched: Optional[list] = None,
-):
- """Write all output files.
-
- Args:
- report: Report data
- raw_openai: Raw OpenAI API response
- raw_xai: Raw xAI API response
- raw_reddit_enriched: Raw enriched Reddit thread data
- """
- ensure_output_dir()
-
- # report.json
- with open(OUTPUT_DIR / "report.json", 'w') as f:
- json.dump(report.to_dict(), f, indent=2)
-
- # report.md
- with open(OUTPUT_DIR / "report.md", 'w') as f:
- f.write(render_full_report(report))
-
- # last30days.context.md
- with open(OUTPUT_DIR / "last30days.context.md", 'w') as f:
- f.write(render_context_snippet(report))
-
- # Raw responses
- if raw_openai:
- with open(OUTPUT_DIR / "raw_openai.json", 'w') as f:
- json.dump(raw_openai, f, indent=2)
-
- if raw_xai:
- with open(OUTPUT_DIR / "raw_xai.json", 'w') as f:
- json.dump(raw_xai, f, indent=2)
-
- if raw_reddit_enriched:
- with open(OUTPUT_DIR / "raw_reddit_threads_enriched.json", 'w') as f:
- json.dump(raw_reddit_enriched, f, indent=2)
-
-
-def get_context_path() -> str:
- """Get path to context file."""
- return str(OUTPUT_DIR / "last30days.context.md")
+ # Generic fallback: engagement.items() yields (key, value) but
+ # _fmt_pairs expects (value, label), so swap them.
+ text = _fmt_pairs([(value, key) for key, value in list(engagement.items())[:3]])
+ return f"[{text}]" if text else None
+
+
+def _fmt_pairs(pairs: list[tuple[object, str]]) -> str:
+ rendered = []
+ for value, suffix in pairs:
+ if value in (None, "", 0, 0.0):
+ continue
+ rendered.append(f"{_format_number(value)}{suffix}")
+ return ", ".join(rendered)
+
+
+def _format_number(value: object) -> str:
+ try:
+ numeric = float(value)
+ except (TypeError, ValueError):
+ return str(value)
+ if numeric >= 1000 and numeric.is_integer():
+ return f"{int(numeric):,}"
+ if numeric.is_integer():
+ return str(int(numeric))
+ return f"{numeric:.1f}"
+
+
+def _aggregate_engagement(source: str, items: list[schema.SourceItem]) -> str | None:
+ fields = ENGAGEMENT_DISPLAY.get(source)
+ if not fields:
+ return None
+ totals: list[tuple[float | int | None, str]] = []
+ for field, label in fields:
+ total = 0
+ found = False
+ for item in items:
+ value = item.engagement.get(field)
+ if value in (None, ""):
+ continue
+ found = True
+ total += value
+ totals.append((total if found else None, label))
+ return _fmt_pairs(totals) or None
+
+
+def _top_actor_summary(source: str, items: list[schema.SourceItem]) -> str | None:
+ actors = _top_actors_for_source(source, items)
+ if not actors:
+ return None
+ label = {
+ "reddit": "communities",
+ "grounding": "domains",
+ "youtube": "channels",
+ "hackernews": "domains",
+ }.get(source, "voices")
+ return f"{label}: {', '.join(actors)}"
+
+
+def _top_actors_for_source(source: str, items: list[schema.SourceItem], limit: int = 3) -> list[str]:
+ counts: Counter[str] = Counter()
+ for item in items:
+ actor = _stats_actor(item)
+ if actor:
+ counts[actor] += 1
+ return [actor for actor, _ in counts.most_common(limit)]
+
+
+def _top_voices_overall(items_by_source: dict[str, list[schema.SourceItem]], limit: int = 5) -> list[str]:
+ counts: Counter[str] = Counter()
+ for items in items_by_source.values():
+ for item in items:
+ actor = _stats_actor(item)
+ if actor:
+ counts[actor] += 1
+ return [actor for actor, _ in counts.most_common(limit)]
+
+
+def _stats_actor(item: schema.SourceItem) -> str | None:
+ if item.source == "reddit" and item.container:
+ return f"r/{item.container}"
+ if item.source in {"x", "bluesky", "truthsocial"} and item.author:
+ return f"@{item.author.lstrip('@')}"
+ if item.source == "grounding" and item.container:
+ return item.container
+ if item.source == "youtube" and item.author:
+ return item.author
+ if item.container and item.container != "Polymarket":
+ return item.container
+ if item.author:
+ return item.author
+ return None
+
+
+def _format_corroboration(candidate: schema.Candidate) -> str | None:
+ corroborating = [
+ _source_label(source)
+ for source in schema.candidate_sources(candidate)
+ if source != candidate.source
+ ]
+ if not corroborating:
+ return None
+ return f"Also on: {', '.join(corroborating)}"
+
+
+def _format_explanation(candidate: schema.Candidate) -> str | None:
+ if not candidate.explanation or candidate.explanation == "fallback-local-score":
+ return None
+ return candidate.explanation
+
+
+def _top_comments_list(item: schema.SourceItem | None, limit: int = 3, min_score: int = 10) -> list[dict]:
+ """Return up to `limit` top comments with score >= min_score."""
+ if not item:
+ return []
+ comments = item.metadata.get("top_comments") or []
+ if not comments or not isinstance(comments[0], dict):
+ return []
+ return [c for c in comments if (c.get("score") or 0) >= min_score][:limit]
+
+
+def _top_comment_excerpt(item: schema.SourceItem | None) -> str | None:
+ if not item:
+ return None
+ comments = item.metadata.get("top_comments") or []
+ if not comments or not isinstance(comments[0], dict):
+ return None
+ top = comments[0]
+ return str(top.get("excerpt") or top.get("text") or "").strip() or None
+
+
+def _comment_insight(item: schema.SourceItem | None) -> str | None:
+ if not item:
+ return None
+ insights = item.metadata.get("comment_insights") or []
+ if not insights:
+ return None
+ return str(insights[0]).strip() or None
+
+
+def _transcript_highlights(item: schema.SourceItem | None) -> list[str]:
+ if not item or item.source != "youtube":
+ return []
+ return (item.metadata.get("transcript_highlights") or [])[:5]
+
+
+def _source_label(source: str) -> str:
+ return SOURCE_LABELS.get(source, source.replace("_", " ").title())
+
+
+
+def _render_best_takes(candidates, limit=5, threshold=70.0):
+ gems = sorted(
+ (c for c in candidates if c.fun_score is not None and c.fun_score >= threshold),
+ key=lambda c: -(c.fun_score or 0),
+ )
+ if len(gems) < 2:
+ return []
+ lines = ["## Best Takes", ""]
+ for candidate in gems[:limit]:
+ text = candidate.title.strip()
+ for item in candidate.source_items:
+ for comment in item.metadata.get("top_comments", [])[:3]:
+ body = (comment.get("body") or comment.get("text") or "") if isinstance(comment, dict) else str(comment)
+ body = body.strip()
+ if body and len(body) < len(text) and len(body) > 10:
+ text = body
+ source_label = _source_label(candidate.source)
+ author = candidate.source_items[0].author if candidate.source_items else None
+ attribution = f"@{author} on {source_label}" if author and candidate.source in ("x", "tiktok", "instagram", "threads") else f"{source_label}"
+ if author and candidate.source == "reddit":
+ container = candidate.source_items[0].container if candidate.source_items else None
+ attribution = f"r/{container} comment" if container else "Reddit"
+ score_tag = f"(fun:{candidate.fun_score:.0f})"
+ reason = f" -- {candidate.fun_explanation}" if candidate.fun_explanation and candidate.fun_explanation != "heuristic-fallback" else ""
+ lines.append(f'- "{_truncate(text, 280)}" -- {attribution} {score_tag}{reason}')
+ return lines
+
+
+def _truncate(text: str, limit: int) -> str:
+ text = text.strip()
+ if len(text) <= limit:
+ return text
+ return text[: limit - 3].rstrip() + "..."
diff --git a/scripts/lib/rerank.py b/scripts/lib/rerank.py
new file mode 100644
index 0000000..6b0f739
--- /dev/null
+++ b/scripts/lib/rerank.py
@@ -0,0 +1,296 @@
+"""Reranking with LLM-scored relevance and demotion of low-confidence candidates."""
+
+from __future__ import annotations
+
+import json
+
+from . import http, providers, schema
+
+INTENT_SCORING_HINTS: dict[str, str] = {
+ "comparison": (
+ "Prefer items that directly compare, contrast, or benchmark the entities"
+ " mentioned in the topic. Head-to-head comparisons score higher than items"
+ " covering only one entity."
+ ),
+ "how_to": (
+ "Prefer tutorials, step-by-step guides, and practical demonstrations."
+ " Video walkthroughs and code examples score higher than theoretical discussion."
+ ),
+ "prediction": (
+ "Prefer items with quantitative forecasts, odds, market data, or expert"
+ " predictions. Vague speculation scores lower."
+ ),
+ "factual": (
+ "Prefer items with specific facts, dates, numbers, and primary sources."
+ " News reports with direct quotes score higher than commentary."
+ ),
+ "opinion": (
+ "Prefer items with substantive opinions backed by reasoning or evidence."
+ " Hot takes without substance score lower."
+ ),
+ "breaking_news": (
+ "Prefer the latest updates, eyewitness reports, and official statements."
+ " Recency matters more than depth."
+ ),
+ "concept": (
+ "Prefer clear explanations with examples or analogies. Accessible content"
+ " scores higher than dense academic papers unless the topic is highly technical."
+ ),
+ "product": (
+ "Prefer hands-on reviews, benchmarks, and user experience reports."
+ " Marketing copy and listicles score lower."
+ ),
+}
+
+
+def rerank_candidates(
+ *,
+ topic: str,
+ plan: schema.QueryPlan,
+ candidates: list[schema.Candidate],
+ provider: providers.ReasoningClient | None,
+ model: str | None,
+ shortlist_size: int,
+) -> list[schema.Candidate]:
+ """Rerank the fused shortlist, demoting candidates the reranker scored as irrelevant."""
+ shortlisted = candidates[:shortlist_size]
+ if provider and model and shortlisted:
+ try:
+ response = provider.generate_json(model, _build_prompt(topic, plan, shortlisted))
+ _apply_llm_scores(shortlisted, response)
+ except (ValueError, KeyError, json.JSONDecodeError, OSError, http.HTTPError) as exc:
+ import sys
+ print(f"[Rerank] LLM reranking failed, using local fallback: {type(exc).__name__}: {exc}", file=sys.stderr)
+ _apply_fallback_scores(shortlisted)
+ else:
+ _apply_fallback_scores(shortlisted)
+
+ if len(candidates) > shortlist_size:
+ tail = candidates[shortlist_size:]
+ _apply_fallback_scores(tail)
+
+ return sorted(
+ candidates,
+ key=lambda candidate: (
+ -candidate.final_score,
+ -(candidate.engagement or -1),
+ min(candidate.native_ranks.values(), default=999),
+ candidate.title,
+ ),
+ )
+
+
+def _intent_hint_block(plan: schema.QueryPlan) -> str:
+ hint = INTENT_SCORING_HINTS.get(plan.intent, "")
+ if hint:
+ return f"\nIntent-specific guidance ({plan.intent}):\n- {hint}\n"
+ return ""
+
+
+def _build_prompt(topic: str, plan: schema.QueryPlan, candidates: list[schema.Candidate]) -> str:
+ ranking_queries = "\n".join(
+ f"- {subquery.label}: {subquery.ranking_query}"
+ for subquery in plan.subqueries
+ )
+ candidate_block = "\n".join(
+ "\n".join(
+ [
+ f"- candidate_id: {candidate.candidate_id}",
+ f" sources: {schema.candidate_source_label(candidate)}",
+ f" title: {candidate.title[:220]}",
+ f" snippet: {candidate.snippet[:420]}",
+ f" date: {schema.candidate_best_published_at(candidate) or 'unknown'}",
+ f" matched_subqueries: {', '.join(candidate.subquery_labels)}",
+ ]
+ )
+ for candidate in candidates
+ )
+ return f"""
+Judge search-result relevance for a last-30-days research pipeline.
+
+Topic: {topic}
+Intent: {plan.intent}
+Ranking queries:
+{ranking_queries}
+
+Return JSON only:
+{{
+ "scores": [
+ {{
+ "candidate_id": "id",
+ "relevance": 0-100,
+ "reason": "short reason"
+ }}
+ ]
+}}
+
+Scoring guidance:
+- 90 to 100: one of the strongest pieces of evidence
+- 70 to 89: clearly relevant and useful
+- 40 to 69: somewhat relevant but weaker
+- 0 to 39: weak, redundant, or off-target
+{_intent_hint_block(plan)}
+Candidates:
+{candidate_block}
+""".strip()
+
+
+def _apply_llm_scores(candidates: list[schema.Candidate], payload: dict) -> None:
+ scores = {}
+ for row in payload.get("scores") or []:
+ if not isinstance(row, dict):
+ continue
+ candidate_id = str(row.get("candidate_id") or "").strip()
+ if not candidate_id:
+ continue
+ scores[candidate_id] = (
+ max(0.0, min(100.0, float(row.get("relevance") or 0.0))),
+ str(row.get("reason") or "").strip() or None,
+ )
+ for candidate in candidates:
+ rerank_score, reason = scores.get(candidate.candidate_id, _fallback_tuple(candidate))
+ candidate.rerank_score = rerank_score
+ candidate.explanation = reason
+ candidate.final_score = _final_score(candidate)
+
+
+def _apply_fallback_scores(candidates: list[schema.Candidate]) -> None:
+ for candidate in candidates:
+ rerank_score, reason = _fallback_tuple(candidate)
+ candidate.rerank_score = rerank_score
+ candidate.explanation = reason
+ candidate.final_score = _final_score(candidate)
+
+
+def _fallback_tuple(candidate: schema.Candidate) -> tuple[float, str]:
+ score = (
+ (candidate.local_relevance * 100.0 * 0.7)
+ + (candidate.freshness * 0.2)
+ + (candidate.source_quality * 100.0 * 0.1)
+ )
+ return max(0.0, min(100.0, score)), "fallback-local-score"
+
+
+def _final_score(candidate: schema.Candidate) -> float:
+ normalized_rrf = _normalized_rrf(candidate.rrf_score)
+ rerank_score = candidate.rerank_score or 0.0
+ # Engagement bonus: high-engagement items (viral TikToks, popular YouTube videos)
+ # get a boost so they aren't buried by lower-engagement but text-relevant items.
+ # Engagement is log1p-normalized (0-100 range via signals.py), so a 2.5M-view
+ # TikTok scores ~15 and a 1500-view one scores ~7. The 0.05 weight gives a
+ # meaningful but not dominant boost.
+ engagement_val = candidate.engagement if candidate.engagement is not None else 0.0
+ base = (
+ 0.60 * rerank_score
+ + 0.20 * normalized_rrf
+ + 0.10 * candidate.freshness
+ + 0.05 * (candidate.source_quality * 100.0)
+ + 0.05 * min(engagement_val * 6.0, 100.0)
+ )
+ if candidate.rerank_score is not None and candidate.rerank_score < 20.0:
+ base *= 0.3
+ return base
+
+
+
+
+def score_fun(
+ *,
+ topic: str,
+ candidates: list[schema.Candidate],
+ provider: providers.ReasoningClient | None,
+ model: str | None,
+ max_candidates: int = 60,
+) -> None:
+ """Score candidates for humor, cleverness, and virality (the fun judge)."""
+ pool = candidates[:max_candidates]
+ if provider and model and pool:
+ try:
+ response = provider.generate_json(model, _build_fun_prompt(topic, pool))
+ _apply_fun_scores(pool, response)
+ except (ValueError, KeyError, json.JSONDecodeError, OSError, http.HTTPError) as exc:
+ import sys
+ print(f"[FunJudge] LLM scoring failed: {type(exc).__name__}: {exc}", file=sys.stderr)
+ _apply_fun_fallback(pool)
+ else:
+ _apply_fun_fallback(pool)
+
+
+def _build_fun_prompt(topic: str, candidates: list[schema.Candidate]) -> str:
+ candidate_block = "\n".join(
+ "\n".join([
+ f"- candidate_id: {c.candidate_id}",
+ f" source: {schema.candidate_source_label(c)}",
+ f" title: {c.title[:220]}",
+ f" snippet: {c.snippet[:420]}",
+ f" comments: {_extract_comment_text(c)[:300]}",
+ ])
+ for c in candidates
+ )
+ return (
+ "Score each item for humor, cleverness, wit, and shareability.\n"
+ "You are the fun judge. A press conference is 0. A one-liner that makes you laugh is 95.\n\n"
+ f"Topic: {topic}\n\n"
+ "Return JSON only:\n"
+ '{\n \"scores\": [{\"candidate_id\": \"id\", \"fun\": 0-100, \"reason\": \"short reason\"}]\n}\n\n'
+ "Scoring: 90-100=genuinely hilarious, 70-89=witty/clever, "
+ "40-69=has personality, 20-39=straight news, 0-19=dry/official.\n"
+ "Prefer SHORT PUNCHY content. A 15-word tweet > a 500-word analysis.\n\n"
+ f"Candidates:\n{candidate_block}"
+ )
+
+
+def _extract_comment_text(candidate: schema.Candidate) -> str:
+ parts = []
+ for item in candidate.source_items:
+ for comment in item.metadata.get("top_comments", [])[:3]:
+ body = comment.get("body", "") if isinstance(comment, dict) else str(comment)
+ if body:
+ parts.append(body[:150])
+ for insight in item.metadata.get("comment_insights", [])[:2]:
+ if insight:
+ parts.append(str(insight)[:150])
+ return " | ".join(parts) if parts else ""
+
+
+def _apply_fun_scores(candidates: list[schema.Candidate], payload: dict) -> None:
+ scores = {}
+ for row in payload.get("scores") or []:
+ if not isinstance(row, dict):
+ continue
+ cid = str(row.get("candidate_id") or "").strip()
+ if not cid:
+ continue
+ scores[cid] = (
+ max(0.0, min(100.0, float(row.get("fun") or 0.0))),
+ str(row.get("reason") or "").strip() or None,
+ )
+ for c in candidates:
+ if c.candidate_id in scores:
+ c.fun_score, c.fun_explanation = scores[c.candidate_id]
+ else:
+ _apply_single_fun_fallback(c)
+
+
+def _apply_fun_fallback(candidates: list[schema.Candidate]) -> None:
+ for c in candidates:
+ _apply_single_fun_fallback(c)
+
+
+def _apply_single_fun_fallback(candidate: schema.Candidate) -> None:
+ text = candidate.title + " " + (candidate.snippet or "") + " " + _extract_comment_text(candidate)
+ text_len = len(text.strip())
+ eng = candidate.engagement if candidate.engagement is not None else 0.0
+ shortness = max(0, (200 - text_len) / 200) * 30
+ eng_bonus = min(eng * 2.0, 40)
+ markers = ["lol", "lmao", "dead", "hilarious", "funny", "bruh", "ratio", "nah", "bro", "ain't no way", "i'm crying", "rent free"]
+ marker_bonus = 10 if any(m in text.lower() for m in markers) else 0
+ candidate.fun_score = max(0.0, min(100.0, shortness + eng_bonus + marker_bonus))
+ candidate.fun_explanation = "heuristic-fallback"
+
+
+def _normalized_rrf(rrf_score: float) -> float:
+ # Empirical ceiling for normalized RRF scores at the pool sizes we use.
+ # Max single-stream RRF at rank 1 is 1/(K+1) ~ 0.016; multi-stream
+ # accumulation reaches ~0.08.
+ return max(0.0, min(100.0, (rrf_score / 0.08) * 100.0))
diff --git a/scripts/lib/resolve.py b/scripts/lib/resolve.py
new file mode 100644
index 0000000..8e945ce
--- /dev/null
+++ b/scripts/lib/resolve.py
@@ -0,0 +1,196 @@
+"""Auto-resolve subreddits, X handles, and current events context for a topic.
+
+Uses web search (Brave/Exa/Serper) to discover relevant communities and context
+before the planner runs. This is the engine-side equivalent of SKILL.md Steps
+0.55/0.75 which use Claude Code's WebSearch tool.
+"""
+
+from __future__ import annotations
+
+import re
+import sys
+from concurrent.futures import ThreadPoolExecutor, as_completed
+from datetime import datetime, timezone
+
+from . import dates, grounding
+
+
+def _log(msg: str) -> None:
+ print(f"[Resolve] {msg}", file=sys.stderr)
+
+
+def _has_backend(config: dict) -> bool:
+ """Check if any web search backend is available."""
+ return bool(
+ config.get("BRAVE_API_KEY")
+ or config.get("EXA_API_KEY")
+ or config.get("SERPER_API_KEY")
+ or config.get("PARALLEL_API_KEY")
+ or config.get("OPENROUTER_API_KEY")
+ )
+
+
+def _extract_subreddits(items: list[dict]) -> list[str]:
+ """Parse subreddit names from search result titles and snippets."""
+ pattern = re.compile(r"r/([A-Za-z0-9_]{2,21})")
+ seen: set[str] = set()
+ results: list[str] = []
+ for item in items:
+ text = f"{item.get('title', '')} {item.get('snippet', '')} {item.get('url', '')}"
+ for match in pattern.findall(text):
+ lower = match.lower()
+ if lower not in seen:
+ seen.add(lower)
+ results.append(match)
+ return results
+
+
+def _extract_x_handle(items: list[dict]) -> str:
+ """Extract the most likely X/Twitter handle from search results."""
+ pattern = re.compile(r"@([A-Za-z0-9_]{1,15})")
+ url_pattern = re.compile(r"(?:twitter\.com|x\.com)/([A-Za-z0-9_]{1,15})(?:/|$|\?)")
+ counts: dict[str, int] = {}
+ for item in items:
+ text = f"{item.get('title', '')} {item.get('snippet', '')}"
+ url = item.get("url", "")
+ for match in pattern.findall(text):
+ lower = match.lower()
+ counts[lower] = counts.get(lower, 0) + 1
+ for match in url_pattern.findall(url):
+ lower = match.lower()
+ # URL matches are stronger signals
+ counts[lower] = counts.get(lower, 0) + 3
+ # Filter out generic handles
+ skip = {"twitter", "x", "search", "hashtag", "intent", "share", "i", "home", "explore", "settings"}
+ counts = {k: v for k, v in counts.items() if k not in skip}
+ if not counts:
+ return ""
+ return max(counts, key=counts.get)
+
+
+def _extract_github_user(items: list[dict]) -> str:
+ """Extract GitHub username from search results."""
+ url_pattern = re.compile(r"github\.com/([A-Za-z0-9_-]{1,39})(?:/|$|\?)")
+ counts: dict[str, int] = {}
+ for item in items:
+ url = item.get("url", "")
+ text = f"{item.get('title', '')} {item.get('snippet', '')}"
+ for match in url_pattern.findall(url):
+ lower = match.lower()
+ counts[lower] = counts.get(lower, 0) + 3
+ for match in url_pattern.findall(text):
+ lower = match.lower()
+ counts[lower] = counts.get(lower, 0) + 1
+ # Filter out org/repo-like names and generic pages
+ skip = {"topics", "explore", "settings", "orgs", "search", "features", "about", "pricing", "enterprise"}
+ counts = {k: v for k, v in counts.items() if k not in skip}
+ if not counts:
+ return ""
+ return max(counts, key=counts.get)
+
+
+def _extract_github_repos(items: list[dict]) -> list[str]:
+ """Extract owner/repo strings from search results."""
+ repo_pattern = re.compile(r"github\.com/([A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+)")
+ skip_owners = {"topics", "explore", "settings", "orgs", "search", "features", "about", "pricing", "enterprise"}
+ seen: set[str] = set()
+ repos: list[str] = []
+ for item in items:
+ url = item.get("url", "")
+ text = f"{item.get('title', '')} {item.get('snippet', '')}"
+ for source in [url, text]:
+ for match in repo_pattern.findall(source):
+ owner = match.split("/")[0].lower()
+ if owner in skip_owners:
+ continue
+ lower = match.lower()
+ if lower not in seen:
+ seen.add(lower)
+ repos.append(match)
+ return repos[:5] # cap at 5 repos
+
+
+def _build_context_summary(items: list[dict]) -> str:
+ """Build a 1-2 sentence current events summary from news search results."""
+ snippets: list[str] = []
+ for item in items[:3]:
+ snippet = item.get("snippet", "").strip()
+ if snippet:
+ snippets.append(snippet)
+ if not snippets:
+ return ""
+ # Take the first two meaningful snippets and truncate to keep it concise
+ combined = " ".join(snippets[:2])
+ if len(combined) > 300:
+ combined = combined[:297] + "..."
+ return combined
+
+
+def auto_resolve(topic: str, config: dict) -> dict:
+ """Discover subreddits, X handles, and current events context for a topic.
+
+ Args:
+ topic: The research topic.
+ config: Dict with API keys (BRAVE_API_KEY, EXA_API_KEY, SERPER_API_KEY).
+
+ Returns:
+ Dict with keys: subreddits, x_handle, context, searches_run.
+ Returns empty result if no web search backend is available.
+ """
+ empty = {"subreddits": [], "x_handle": "", "context": "", "searches_run": 0}
+
+ if not _has_backend(config):
+ _log("No web search backend available, skipping resolve")
+ return empty
+
+ from_date, to_date = dates.get_date_range(30)
+ date_range = (from_date, to_date)
+ now = datetime.now(timezone.utc)
+ current_month = now.strftime("%B")
+ current_year = now.strftime("%Y")
+
+ queries = {
+ "subreddit": f"{topic} subreddit reddit",
+ "news": f"{topic} news {current_month} {current_year}",
+ "x_handle": f"{topic} X twitter handle",
+ "github": f"{topic} github profile site:github.com",
+ }
+
+ results: dict[str, list[dict]] = {}
+ searches_run = 0
+
+ def _search(label: str, query: str) -> tuple[str, list[dict]]:
+ items, _artifact = grounding.web_search(query, date_range, config)
+ return label, items
+
+ with ThreadPoolExecutor(max_workers=3) as executor:
+ futures = {
+ executor.submit(_search, label, q): label
+ for label, q in queries.items()
+ }
+ for future in as_completed(futures):
+ label = futures[future]
+ try:
+ _label, items = future.result()
+ results[label] = items
+ searches_run += 1
+ except Exception as exc:
+ _log(f"Search failed for {label}: {exc}")
+ results[label] = []
+
+ subreddits = _extract_subreddits(results.get("subreddit", []))
+ x_handle = _extract_x_handle(results.get("x_handle", []))
+ github_user = _extract_github_user(results.get("github", []))
+ github_repos = _extract_github_repos(results.get("github", []))
+ context = _build_context_summary(results.get("news", []))
+
+ _log(f"Resolved {len(subreddits)} subreddits, x_handle={x_handle!r}, github_user={github_user!r}, github_repos={github_repos!r}, context_len={len(context)}")
+
+ return {
+ "subreddits": subreddits,
+ "x_handle": x_handle,
+ "github_user": github_user,
+ "github_repos": github_repos,
+ "context": context,
+ "searches_run": searches_run,
+ }
diff --git a/scripts/lib/schema.py b/scripts/lib/schema.py
index 28f528f..20e5be9 100644
--- a/scripts/lib/schema.py
+++ b/scripts/lib/schema.py
@@ -1,843 +1,320 @@
-"""Data schemas for last30days skill."""
+"""Core data model for the v3.0.0 last30days pipeline."""
-from dataclasses import dataclass, field, asdict
-from typing import Any, Dict, List, Optional
-from datetime import datetime, timezone
+from __future__ import annotations
+
+from dataclasses import asdict, dataclass, field, is_dataclass
+from typing import Any, Literal
+
+
+def _drop_none(value: Any) -> Any:
+ """Recursively remove None values from dataclass-derived structures."""
+ if is_dataclass(value):
+ return _drop_none(asdict(value))
+ if isinstance(value, dict):
+ return {
+ key: _drop_none(item)
+ for key, item in value.items()
+ if item is not None
+ }
+ if isinstance(value, list):
+ return [_drop_none(item) for item in value]
+ return value
+
+
+def _first_non_none(*values: Any) -> Any:
+ for value in values:
+ if value is not None:
+ return value
+ return None
+
+
+@dataclass(frozen=True)
+class ProviderRuntime:
+ """Resolved runtime provider selection."""
+
+ reasoning_provider: Literal["gemini", "openai", "xai", "local"]
+ planner_model: str
+ rerank_model: str
+ x_search_backend: Literal["xai", "bird"] | None = None
+
+
+@dataclass(frozen=True)
+class SubQuery:
+ """Planner-emitted retrieval unit."""
+
+ label: str
+ search_query: str
+ ranking_query: str
+ sources: list[str]
+ weight: float = 1.0
+
+ def __post_init__(self) -> None:
+ if not self.sources:
+ raise ValueError("SubQuery must have at least one source")
+ if self.weight <= 0:
+ raise ValueError(f"SubQuery weight must be positive, got {self.weight}")
@dataclass
-class Engagement:
- """Engagement metrics."""
- # Reddit fields
- score: Optional[int] = None
- num_comments: Optional[int] = None
- upvote_ratio: Optional[float] = None
+class QueryPlan:
+ """Planner output."""
- # X fields
- likes: Optional[int] = None
- reposts: Optional[int] = None
- replies: Optional[int] = None
- quotes: Optional[int] = None
-
- # YouTube fields
- views: Optional[int] = None
-
- # TikTok / Facebook fields
- shares: Optional[int] = None
-
- # Polymarket fields
- volume: Optional[float] = None
- liquidity: Optional[float] = None
-
- def to_dict(self) -> Dict[str, Any]:
- d = {}
- if self.score is not None:
- d['score'] = self.score
- if self.num_comments is not None:
- d['num_comments'] = self.num_comments
- if self.upvote_ratio is not None:
- d['upvote_ratio'] = self.upvote_ratio
- if self.likes is not None:
- d['likes'] = self.likes
- if self.reposts is not None:
- d['reposts'] = self.reposts
- if self.replies is not None:
- d['replies'] = self.replies
- if self.quotes is not None:
- d['quotes'] = self.quotes
- if self.views is not None:
- d['views'] = self.views
- if self.shares is not None:
- d['shares'] = self.shares
- if self.volume is not None:
- d['volume'] = self.volume
- if self.liquidity is not None:
- d['liquidity'] = self.liquidity
- return d if d else None
+ intent: str
+ freshness_mode: str
+ cluster_mode: str
+ raw_topic: str
+ subqueries: list[SubQuery]
+ source_weights: dict[str, float]
+ notes: list[str] = field(default_factory=list)
@dataclass
-class Comment:
- """Reddit comment."""
- score: int
- date: Optional[str]
- author: str
- excerpt: str
+class SourceItem:
+ """Generic normalized evidence item."""
+
+ item_id: str
+ source: str
+ title: str
+ body: str
url: str
-
- def to_dict(self) -> Dict[str, Any]:
- return {
- 'score': self.score,
- 'date': self.date,
- 'author': self.author,
- 'excerpt': self.excerpt,
- 'url': self.url,
- }
+ author: str | None = None
+ container: str | None = None
+ published_at: str | None = None
+ date_confidence: Literal["high", "med", "low"] = "low"
+ engagement: dict[str, float | int] = field(default_factory=dict)
+ relevance_hint: float = 0.5
+ why_relevant: str = ""
+ snippet: str = ""
+ metadata: dict[str, Any] = field(default_factory=dict)
+ # Signal fields populated by signals.annotate_stream (after construction)
+ local_relevance: float | None = None
+ freshness: int | None = None
+ engagement_score: float | None = None
+ source_quality: float | None = None
+ local_rank_score: float | None = None
@dataclass
-class SubScores:
- """Component scores."""
- relevance: int = 0
- recency: int = 0
- engagement: int = 0
+class Candidate:
+ """Global candidate after fusion and reranking."""
- def to_dict(self) -> Dict[str, int]:
- return {
- 'relevance': self.relevance,
- 'recency': self.recency,
- 'engagement': self.engagement,
- }
-
-
-@dataclass
-class RedditItem:
- """Normalized Reddit item."""
- id: str
+ candidate_id: str
+ item_id: str
+ source: str
title: str
url: str
- subreddit: str
- date: Optional[str] = None
- date_confidence: str = "low"
- engagement: Optional[Engagement] = None
- top_comments: List[Comment] = field(default_factory=list)
- comment_insights: List[str] = field(default_factory=list)
- relevance: float = 0.5
- why_relevant: str = ""
- subs: SubScores = field(default_factory=SubScores)
- score: int = 0
- cross_refs: List[str] = field(default_factory=list)
-
- def to_dict(self) -> Dict[str, Any]:
- d = {
- 'id': self.id,
- 'title': self.title,
- 'url': self.url,
- 'subreddit': self.subreddit,
- 'date': self.date,
- 'date_confidence': self.date_confidence,
- 'engagement': self.engagement.to_dict() if self.engagement else None,
- 'top_comments': [c.to_dict() for c in self.top_comments],
- 'comment_insights': self.comment_insights,
- 'relevance': self.relevance,
- 'why_relevant': self.why_relevant,
- 'subs': self.subs.to_dict(),
- 'score': self.score,
- }
- if self.cross_refs:
- d['cross_refs'] = self.cross_refs
- return d
-
-
-@dataclass
-class XItem:
- """Normalized X item."""
- id: str
- text: str
- url: str
- author_handle: str
- date: Optional[str] = None
- date_confidence: str = "low"
- engagement: Optional[Engagement] = None
- relevance: float = 0.5
- why_relevant: str = ""
- subs: SubScores = field(default_factory=SubScores)
- score: int = 0
- cross_refs: List[str] = field(default_factory=list)
-
- def to_dict(self) -> Dict[str, Any]:
- d = {
- 'id': self.id,
- 'text': self.text,
- 'url': self.url,
- 'author_handle': self.author_handle,
- 'date': self.date,
- 'date_confidence': self.date_confidence,
- 'engagement': self.engagement.to_dict() if self.engagement else None,
- 'relevance': self.relevance,
- 'why_relevant': self.why_relevant,
- 'subs': self.subs.to_dict(),
- 'score': self.score,
- }
- if self.cross_refs:
- d['cross_refs'] = self.cross_refs
- return d
-
-
-@dataclass
-class WebSearchItem:
- """Normalized web search item (no engagement metrics)."""
- id: str
- title: str
- url: str
- source_domain: str # e.g., "medium.com", "github.com"
snippet: str
- date: Optional[str] = None
- date_confidence: str = "low"
- relevance: float = 0.5
- why_relevant: str = ""
- subs: SubScores = field(default_factory=SubScores)
- score: int = 0
- cross_refs: List[str] = field(default_factory=list)
-
- def to_dict(self) -> Dict[str, Any]:
- d = {
- 'id': self.id,
- 'title': self.title,
- 'url': self.url,
- 'source_domain': self.source_domain,
- 'snippet': self.snippet,
- 'date': self.date,
- 'date_confidence': self.date_confidence,
- 'relevance': self.relevance,
- 'why_relevant': self.why_relevant,
- 'subs': self.subs.to_dict(),
- 'score': self.score,
- }
- if self.cross_refs:
- d['cross_refs'] = self.cross_refs
- return d
+ subquery_labels: list[str]
+ native_ranks: dict[str, int]
+ local_relevance: float
+ freshness: int
+ engagement: int | float | None
+ source_quality: float
+ rrf_score: float
+ sources: list[str] = field(default_factory=list)
+ source_items: list[SourceItem] = field(default_factory=list)
+ rerank_score: float | None = None
+ final_score: float = 0.0
+ explanation: str | None = None
+ fun_score: float | None = None
+ fun_explanation: str | None = None
+ cluster_id: str | None = None
+ metadata: dict[str, Any] = field(default_factory=dict)
@dataclass
-class YouTubeItem:
- """Normalized YouTube item."""
- id: str # video_id
+class Cluster:
+ """Ranked cluster of related candidates."""
+
+ cluster_id: str
title: str
- url: str
- channel_name: str
- date: Optional[str] = None
- date_confidence: str = "high" # YouTube dates are always reliable
- engagement: Optional[Engagement] = None
- transcript_snippet: str = ""
- transcript_highlights: List[str] = field(default_factory=list)
- relevance: float = 0.7
- why_relevant: str = ""
- subs: SubScores = field(default_factory=SubScores)
- score: int = 0
- cross_refs: List[str] = field(default_factory=list)
+ candidate_ids: list[str]
+ representative_ids: list[str]
+ sources: list[str]
+ score: float
+ uncertainty: Literal["single-source", "thin-evidence"] | None = None
- def to_dict(self) -> Dict[str, Any]:
- d = {
- 'id': self.id,
- 'title': self.title,
- 'url': self.url,
- 'channel_name': self.channel_name,
- 'date': self.date,
- 'date_confidence': self.date_confidence,
- 'engagement': self.engagement.to_dict() if self.engagement else None,
- 'transcript_snippet': self.transcript_snippet,
- 'transcript_highlights': self.transcript_highlights,
- 'relevance': self.relevance,
- 'why_relevant': self.why_relevant,
- 'subs': self.subs.to_dict(),
- 'score': self.score,
- }
- if self.cross_refs:
- d['cross_refs'] = self.cross_refs
- return d
-
-
-@dataclass
-class TikTokItem:
- """Normalized TikTok item."""
- id: str # video_id
- text: str # caption/description
- url: str # webVideoUrl
- author_name: str # authorMeta.name
- date: Optional[str] = None
- date_confidence: str = "high" # Apify provides exact timestamps
- engagement: Optional[Engagement] = None # views, likes, num_comments, shares
- caption_snippet: str = "" # spoken-word caption (if available), else text
- hashtags: List[str] = field(default_factory=list)
- relevance: float = 0.7
- why_relevant: str = ""
- subs: SubScores = field(default_factory=SubScores)
- score: int = 0
- cross_refs: List[str] = field(default_factory=list)
-
- def to_dict(self) -> Dict[str, Any]:
- d = {
- 'id': self.id,
- 'text': self.text,
- 'url': self.url,
- 'author_name': self.author_name,
- 'date': self.date,
- 'date_confidence': self.date_confidence,
- 'engagement': self.engagement.to_dict() if self.engagement else None,
- 'caption_snippet': self.caption_snippet,
- 'hashtags': self.hashtags,
- 'relevance': self.relevance,
- 'why_relevant': self.why_relevant,
- 'subs': self.subs.to_dict(),
- 'score': self.score,
- }
- if self.cross_refs:
- d['cross_refs'] = self.cross_refs
- return d
-
-
-@dataclass
-class InstagramItem:
- """Normalized Instagram item."""
- id: str # "IG1", "IG2", ...
- text: str # caption text
- url: str # https://www.instagram.com/reel/{code}
- author_name: str # Instagram handle
- date: Optional[str] = None
- date_confidence: str = "high" # ScrapeCreators provides exact timestamps
- engagement: Optional[Engagement] = None # views, likes, num_comments
- caption_snippet: str = "" # spoken-word caption (if available), else text
- hashtags: List[str] = field(default_factory=list)
- relevance: float = 0.7
- why_relevant: str = ""
- subs: SubScores = field(default_factory=SubScores)
- score: int = 0
- cross_refs: List[str] = field(default_factory=list)
-
- def to_dict(self) -> Dict[str, Any]:
- d = {
- 'id': self.id,
- 'text': self.text,
- 'url': self.url,
- 'author_name': self.author_name,
- 'date': self.date,
- 'date_confidence': self.date_confidence,
- 'engagement': self.engagement.to_dict() if self.engagement else None,
- 'caption_snippet': self.caption_snippet,
- 'hashtags': self.hashtags,
- 'relevance': self.relevance,
- 'why_relevant': self.why_relevant,
- 'subs': self.subs.to_dict(),
- 'score': self.score,
- }
- if self.cross_refs:
- d['cross_refs'] = self.cross_refs
- return d
-
-
-@dataclass
-class HackerNewsItem:
- """Normalized Hacker News item."""
- id: str # "HN1", "HN2", ...
- title: str
- url: str # Original article URL
- hn_url: str # news.ycombinator.com/item?id=...
- author: str # HN username
- date: Optional[str] = None
- date_confidence: str = "high" # Algolia provides exact timestamps
- engagement: Optional[Engagement] = None # points + num_comments
- top_comments: List[Comment] = field(default_factory=list)
- comment_insights: List[str] = field(default_factory=list)
- relevance: float = 0.5
- why_relevant: str = ""
- subs: SubScores = field(default_factory=SubScores)
- score: int = 0
- cross_refs: List[str] = field(default_factory=list)
-
- def to_dict(self) -> Dict[str, Any]:
- d = {
- 'id': self.id,
- 'title': self.title,
- 'url': self.url,
- 'hn_url': self.hn_url,
- 'author': self.author,
- 'date': self.date,
- 'date_confidence': self.date_confidence,
- 'engagement': self.engagement.to_dict() if self.engagement else None,
- 'top_comments': [c.to_dict() for c in self.top_comments],
- 'comment_insights': self.comment_insights,
- 'relevance': self.relevance,
- 'why_relevant': self.why_relevant,
- 'subs': self.subs.to_dict(),
- 'score': self.score,
- }
- if self.cross_refs:
- d['cross_refs'] = self.cross_refs
- return d
-
-
-@dataclass
-class BlueskyItem:
- """Normalized Bluesky post."""
- id: str # "BS1", "BS2", ...
- text: str
- url: str # bsky.app permalink
- author_handle: str # user.bsky.social
- display_name: str
- date: Optional[str] = None
- date_confidence: str = "high" # AT Protocol has exact timestamps
- engagement: Optional[Engagement] = None # likes, reposts, replies, quotes
- relevance: float = 0.5
- why_relevant: str = ""
- subs: SubScores = field(default_factory=SubScores)
- score: int = 0
- cross_refs: List[str] = field(default_factory=list)
-
- def to_dict(self) -> Dict[str, Any]:
- d = {
- 'id': self.id,
- 'text': self.text,
- 'url': self.url,
- 'author_handle': self.author_handle,
- 'display_name': self.display_name,
- 'date': self.date,
- 'date_confidence': self.date_confidence,
- 'engagement': self.engagement.to_dict() if self.engagement else None,
- 'relevance': self.relevance,
- 'why_relevant': self.why_relevant,
- 'subs': self.subs.to_dict(),
- 'score': self.score,
- }
- if self.cross_refs:
- d['cross_refs'] = self.cross_refs
- return d
-
-
-@dataclass
-class TruthSocialItem:
- """Normalized Truth Social post."""
- id: str # "TS1", "TS2", ...
- text: str
- url: str # truthsocial.com permalink
- author_handle: str # username
- display_name: str
- date: Optional[str] = None
- date_confidence: str = "high" # Mastodon API has exact timestamps
- engagement: Optional[Engagement] = None # likes, reposts, replies
- relevance: float = 0.5
- why_relevant: str = ""
- subs: SubScores = field(default_factory=SubScores)
- score: int = 0
- cross_refs: List[str] = field(default_factory=list)
-
- def to_dict(self) -> Dict[str, Any]:
- d = {
- 'id': self.id,
- 'text': self.text,
- 'url': self.url,
- 'author_handle': self.author_handle,
- 'display_name': self.display_name,
- 'date': self.date,
- 'date_confidence': self.date_confidence,
- 'engagement': self.engagement.to_dict() if self.engagement else None,
- 'relevance': self.relevance,
- 'why_relevant': self.why_relevant,
- 'subs': self.subs.to_dict(),
- 'score': self.score,
- }
- if self.cross_refs:
- d['cross_refs'] = self.cross_refs
- return d
-
-
-@dataclass
-class PolymarketItem:
- """Normalized Polymarket prediction market item."""
- id: str # "PM1", "PM2", ...
- title: str # Event title
- question: str # Top market question
- url: str # Event page URL
- outcome_prices: List[tuple] = field(default_factory=list) # [(name, price), ...]
- outcomes_remaining: int = 0
- price_movement: Optional[str] = None # "down 11.7% this month"
- date: Optional[str] = None
- date_confidence: str = "high" # API provides exact timestamps
- engagement: Optional[Engagement] = None # volume + liquidity
- end_date: Optional[str] = None
- relevance: float = 0.5
- why_relevant: str = ""
- subs: SubScores = field(default_factory=SubScores)
- score: int = 0
- cross_refs: List[str] = field(default_factory=list)
-
- def to_dict(self) -> Dict[str, Any]:
- d = {
- 'id': self.id,
- 'title': self.title,
- 'question': self.question,
- 'url': self.url,
- 'outcome_prices': self.outcome_prices,
- 'outcomes_remaining': self.outcomes_remaining,
- 'price_movement': self.price_movement,
- 'date': self.date,
- 'date_confidence': self.date_confidence,
- 'engagement': self.engagement.to_dict() if self.engagement else None,
- 'end_date': self.end_date,
- 'relevance': self.relevance,
- 'why_relevant': self.why_relevant,
- 'subs': self.subs.to_dict(),
- 'score': self.score,
- }
- if self.cross_refs:
- d['cross_refs'] = self.cross_refs
- return d
+ def __post_init__(self) -> None:
+ if not set(self.representative_ids) <= set(self.candidate_ids):
+ raise ValueError("representative_ids must be a subset of candidate_ids")
@dataclass
class Report:
- """Full research report."""
+ """Final pipeline output."""
+
topic: str
range_from: str
range_to: str
generated_at: str
- mode: str # 'reddit-only', 'x-only', 'both', 'web-only', etc.
- openai_model_used: Optional[str] = None
- xai_model_used: Optional[str] = None
- reddit: List[RedditItem] = field(default_factory=list)
- x: List[XItem] = field(default_factory=list)
- web: List[WebSearchItem] = field(default_factory=list)
- youtube: List[YouTubeItem] = field(default_factory=list)
- tiktok: List[TikTokItem] = field(default_factory=list)
- instagram: List[InstagramItem] = field(default_factory=list)
- hackernews: List[HackerNewsItem] = field(default_factory=list)
- bluesky: List[BlueskyItem] = field(default_factory=list)
- truthsocial: List[TruthSocialItem] = field(default_factory=list)
- polymarket: List[PolymarketItem] = field(default_factory=list)
- best_practices: List[str] = field(default_factory=list)
- prompt_pack: List[str] = field(default_factory=list)
- context_snippet_md: str = ""
- # Status tracking
- reddit_error: Optional[str] = None
- x_error: Optional[str] = None
- web_error: Optional[str] = None
- youtube_error: Optional[str] = None
- tiktok_error: Optional[str] = None
- instagram_error: Optional[str] = None
- hackernews_error: Optional[str] = None
- bluesky_error: Optional[str] = None
- truthsocial_error: Optional[str] = None
- polymarket_error: Optional[str] = None
- # Handle resolution
- resolved_x_handle: Optional[str] = None
- # Cache info
- from_cache: bool = False
- cache_age_hours: Optional[float] = None
-
- def to_dict(self) -> Dict[str, Any]:
- d = {
- 'topic': self.topic,
- 'range': {
- 'from': self.range_from,
- 'to': self.range_to,
- },
- 'generated_at': self.generated_at,
- 'mode': self.mode,
- 'openai_model_used': self.openai_model_used,
- 'xai_model_used': self.xai_model_used,
- 'reddit': [r.to_dict() for r in self.reddit],
- 'x': [x.to_dict() for x in self.x],
- 'web': [w.to_dict() for w in self.web],
- 'youtube': [y.to_dict() for y in self.youtube],
- 'tiktok': [t.to_dict() for t in self.tiktok],
- 'instagram': [ig.to_dict() for ig in self.instagram],
- 'hackernews': [h.to_dict() for h in self.hackernews],
- 'bluesky': [b.to_dict() for b in self.bluesky],
- 'truthsocial': [ts.to_dict() for ts in self.truthsocial],
- 'polymarket': [p.to_dict() for p in self.polymarket],
- 'best_practices': self.best_practices,
- 'prompt_pack': self.prompt_pack,
- 'context_snippet_md': self.context_snippet_md,
- }
- if self.resolved_x_handle:
- d['resolved_x_handle'] = self.resolved_x_handle
- if self.reddit_error:
- d['reddit_error'] = self.reddit_error
- if self.x_error:
- d['x_error'] = self.x_error
- if self.web_error:
- d['web_error'] = self.web_error
- if self.youtube_error:
- d['youtube_error'] = self.youtube_error
- if self.tiktok_error:
- d['tiktok_error'] = self.tiktok_error
- if self.instagram_error:
- d['instagram_error'] = self.instagram_error
- if self.hackernews_error:
- d['hackernews_error'] = self.hackernews_error
- if self.bluesky_error:
- d['bluesky_error'] = self.bluesky_error
- if self.truthsocial_error:
- d['truthsocial_error'] = self.truthsocial_error
- if self.polymarket_error:
- d['polymarket_error'] = self.polymarket_error
- if self.from_cache:
- d['from_cache'] = self.from_cache
- if self.cache_age_hours is not None:
- d['cache_age_hours'] = self.cache_age_hours
- return d
-
- @classmethod
- def from_dict(cls, data: Dict[str, Any]) -> "Report":
- """Create Report from serialized dict (handles cache format)."""
- # Handle range field conversion
- range_data = data.get('range', {})
- range_from = range_data.get('from', data.get('range_from', ''))
- range_to = range_data.get('to', data.get('range_to', ''))
-
- # Reconstruct Reddit items
- reddit_items = []
- for r in data.get('reddit', []):
- eng = None
- if r.get('engagement'):
- eng = Engagement(**r['engagement'])
- comments = [Comment(**c) for c in r.get('top_comments', [])]
- subs = SubScores(**r.get('subs', {})) if r.get('subs') else SubScores()
- reddit_items.append(RedditItem(
- id=r['id'],
- title=r['title'],
- url=r['url'],
- subreddit=r['subreddit'],
- date=r.get('date'),
- date_confidence=r.get('date_confidence', 'low'),
- engagement=eng,
- top_comments=comments,
- comment_insights=r.get('comment_insights', []),
- relevance=r.get('relevance', 0.5),
- why_relevant=r.get('why_relevant', ''),
- subs=subs,
- score=r.get('score', 0),
- cross_refs=r.get('cross_refs', []),
- ))
-
- # Reconstruct X items
- x_items = []
- for x in data.get('x', []):
- eng = None
- if x.get('engagement'):
- eng = Engagement(**x['engagement'])
- subs = SubScores(**x.get('subs', {})) if x.get('subs') else SubScores()
- x_items.append(XItem(
- id=x['id'],
- text=x['text'],
- url=x['url'],
- author_handle=x['author_handle'],
- date=x.get('date'),
- date_confidence=x.get('date_confidence', 'low'),
- engagement=eng,
- relevance=x.get('relevance', 0.5),
- why_relevant=x.get('why_relevant', ''),
- subs=subs,
- score=x.get('score', 0),
- cross_refs=x.get('cross_refs', []),
- ))
-
- # Reconstruct Web items
- web_items = []
- for w in data.get('web', []):
- subs = SubScores(**w.get('subs', {})) if w.get('subs') else SubScores()
- web_items.append(WebSearchItem(
- id=w['id'],
- title=w['title'],
- url=w['url'],
- source_domain=w.get('source_domain', ''),
- snippet=w.get('snippet', ''),
- date=w.get('date'),
- date_confidence=w.get('date_confidence', 'low'),
- relevance=w.get('relevance', 0.5),
- why_relevant=w.get('why_relevant', ''),
- subs=subs,
- score=w.get('score', 0),
- cross_refs=w.get('cross_refs', []),
- ))
-
- # Reconstruct YouTube items
- youtube_items = []
- for y in data.get('youtube', []):
- eng = None
- if y.get('engagement'):
- eng = Engagement(**y['engagement'])
- subs = SubScores(**y.get('subs', {})) if y.get('subs') else SubScores()
- youtube_items.append(YouTubeItem(
- id=y['id'],
- title=y['title'],
- url=y['url'],
- channel_name=y.get('channel_name', ''),
- date=y.get('date'),
- date_confidence=y.get('date_confidence', 'high'),
- engagement=eng,
- transcript_snippet=y.get('transcript_snippet', ''),
- transcript_highlights=y.get('transcript_highlights', []),
- relevance=y.get('relevance', 0.7),
- why_relevant=y.get('why_relevant', ''),
- subs=subs,
- score=y.get('score', 0),
- cross_refs=y.get('cross_refs', []),
- ))
-
- # Reconstruct TikTok items
- tiktok_items = []
- for t in data.get('tiktok', []):
- eng = None
- if t.get('engagement'):
- eng = Engagement(**t['engagement'])
- subs = SubScores(**t.get('subs', {})) if t.get('subs') else SubScores()
- tiktok_items.append(TikTokItem(
- id=t['id'],
- text=t.get('text', ''),
- url=t['url'],
- author_name=t.get('author_name', ''),
- date=t.get('date'),
- date_confidence=t.get('date_confidence', 'high'),
- engagement=eng,
- caption_snippet=t.get('caption_snippet', ''),
- hashtags=t.get('hashtags', []),
- relevance=t.get('relevance', 0.7),
- why_relevant=t.get('why_relevant', ''),
- subs=subs,
- score=t.get('score', 0),
- cross_refs=t.get('cross_refs', []),
- ))
-
- # Reconstruct Instagram items
- ig_items = []
- for ig in data.get('instagram', []):
- eng = None
- if ig.get('engagement'):
- eng = Engagement(**ig['engagement'])
- subs = SubScores(**ig.get('subs', {})) if ig.get('subs') else SubScores()
- ig_items.append(InstagramItem(
- id=ig['id'],
- text=ig.get('text', ''),
- url=ig['url'],
- author_name=ig.get('author_name', ''),
- date=ig.get('date'),
- date_confidence=ig.get('date_confidence', 'high'),
- engagement=eng,
- caption_snippet=ig.get('caption_snippet', ''),
- hashtags=ig.get('hashtags', []),
- relevance=ig.get('relevance', 0.7),
- why_relevant=ig.get('why_relevant', ''),
- subs=subs,
- score=ig.get('score', 0),
- cross_refs=ig.get('cross_refs', []),
- ))
-
- # Reconstruct HackerNews items
- hn_items = []
- for h in data.get('hackernews', []):
- eng = None
- if h.get('engagement'):
- eng = Engagement(**h['engagement'])
- comments = [Comment(**c) for c in h.get('top_comments', [])]
- subs = SubScores(**h.get('subs', {})) if h.get('subs') else SubScores()
- hn_items.append(HackerNewsItem(
- id=h['id'],
- title=h['title'],
- url=h.get('url', ''),
- hn_url=h.get('hn_url', ''),
- author=h.get('author', ''),
- date=h.get('date'),
- date_confidence=h.get('date_confidence', 'high'),
- engagement=eng,
- top_comments=comments,
- comment_insights=h.get('comment_insights', []),
- relevance=h.get('relevance', 0.5),
- why_relevant=h.get('why_relevant', ''),
- subs=subs,
- score=h.get('score', 0),
- cross_refs=h.get('cross_refs', []),
- ))
-
- # Reconstruct Truth Social items (backward compat: key may not exist)
- ts_items = []
- for ts in data.get('truthsocial', []):
- eng = None
- if ts.get('engagement'):
- eng = Engagement(**ts['engagement'])
- subs = SubScores(**ts.get('subs', {})) if ts.get('subs') else SubScores()
- ts_items.append(TruthSocialItem(
- id=ts['id'],
- text=ts['text'],
- url=ts['url'],
- author_handle=ts.get('author_handle', ''),
- display_name=ts.get('display_name', ''),
- date=ts.get('date'),
- date_confidence=ts.get('date_confidence', 'high'),
- engagement=eng,
- relevance=ts.get('relevance', 0.5),
- why_relevant=ts.get('why_relevant', ''),
- subs=subs,
- score=ts.get('score', 0),
- cross_refs=ts.get('cross_refs', []),
- ))
-
- # Reconstruct Polymarket items (backward compat: key may not exist)
- pm_items = []
- for p in data.get('polymarket', []):
- eng = None
- if p.get('engagement'):
- eng = Engagement(**p['engagement'])
- subs = SubScores(**p.get('subs', {})) if p.get('subs') else SubScores()
- pm_items.append(PolymarketItem(
- id=p['id'],
- title=p['title'],
- question=p.get('question', ''),
- url=p['url'],
- outcome_prices=p.get('outcome_prices', []),
- outcomes_remaining=p.get('outcomes_remaining', 0),
- price_movement=p.get('price_movement'),
- date=p.get('date'),
- date_confidence=p.get('date_confidence', 'high'),
- engagement=eng,
- end_date=p.get('end_date'),
- relevance=p.get('relevance', 0.5),
- why_relevant=p.get('why_relevant', ''),
- subs=subs,
- score=p.get('score', 0),
- cross_refs=p.get('cross_refs', []),
- ))
-
- return cls(
- topic=data['topic'],
- range_from=range_from,
- range_to=range_to,
- generated_at=data['generated_at'],
- mode=data['mode'],
- openai_model_used=data.get('openai_model_used'),
- xai_model_used=data.get('xai_model_used'),
- reddit=reddit_items,
- x=x_items,
- web=web_items,
- youtube=youtube_items,
- tiktok=tiktok_items,
- instagram=ig_items,
- hackernews=hn_items,
- truthsocial=ts_items,
- polymarket=pm_items,
- best_practices=data.get('best_practices', []),
- prompt_pack=data.get('prompt_pack', []),
- context_snippet_md=data.get('context_snippet_md', ''),
- reddit_error=data.get('reddit_error'),
- x_error=data.get('x_error'),
- web_error=data.get('web_error'),
- youtube_error=data.get('youtube_error'),
- tiktok_error=data.get('tiktok_error'),
- instagram_error=data.get('instagram_error'),
- hackernews_error=data.get('hackernews_error'),
- truthsocial_error=data.get('truthsocial_error'),
- polymarket_error=data.get('polymarket_error'),
- resolved_x_handle=data.get('resolved_x_handle'),
- from_cache=data.get('from_cache', False),
- cache_age_hours=data.get('cache_age_hours'),
- )
+ provider_runtime: ProviderRuntime
+ query_plan: QueryPlan
+ clusters: list[Cluster]
+ ranked_candidates: list[Candidate]
+ items_by_source: dict[str, list[SourceItem]]
+ errors_by_source: dict[str, str]
+ warnings: list[str] = field(default_factory=list)
+ artifacts: dict[str, Any] = field(default_factory=dict)
-def create_report(
- topic: str,
- from_date: str,
- to_date: str,
- mode: str,
- openai_model: Optional[str] = None,
- xai_model: Optional[str] = None,
-) -> Report:
- """Create a new report with metadata."""
- return Report(
- topic=topic,
- range_from=from_date,
- range_to=to_date,
- generated_at=datetime.now(timezone.utc).isoformat(),
- mode=mode,
- openai_model_used=openai_model,
- xai_model_used=xai_model,
+@dataclass
+class RetrievalBundle:
+ """Structured retrieval output before global ranking."""
+
+ items_by_source_and_query: dict[tuple[str, str], list[SourceItem]] = field(default_factory=dict)
+ items_by_source: dict[str, list[SourceItem]] = field(default_factory=dict)
+ errors_by_source: dict[str, str] = field(default_factory=dict)
+ artifacts: dict[str, Any] = field(default_factory=dict)
+
+ def add_items(self, label: str, source: str, items: list[SourceItem]) -> None:
+ """Atomically append items to both items_by_source_and_query and items_by_source."""
+ existing = self.items_by_source_and_query.get((label, source), [])
+ self.items_by_source_and_query[(label, source)] = existing + items
+ self.items_by_source.setdefault(source, []).extend(items)
+
+
+def to_dict(value: Any) -> Any:
+ """Serialize dataclasses and nested containers."""
+ return _drop_none(value)
+
+
+def provider_runtime_from_dict(payload: dict[str, Any]) -> ProviderRuntime:
+ return ProviderRuntime(
+ reasoning_provider=payload["reasoning_provider"],
+ planner_model=payload["planner_model"],
+ rerank_model=payload["rerank_model"],
+ x_search_backend=payload.get("x_search_backend"),
)
+
+
+def subquery_from_dict(payload: dict[str, Any]) -> SubQuery:
+ return SubQuery(
+ label=payload["label"],
+ search_query=payload["search_query"],
+ ranking_query=payload["ranking_query"],
+ sources=list(payload.get("sources") or []),
+ weight=float(payload.get("weight") or 1.0),
+ )
+
+
+def query_plan_from_dict(payload: dict[str, Any]) -> QueryPlan:
+ return QueryPlan(
+ intent=payload["intent"],
+ freshness_mode=payload["freshness_mode"],
+ cluster_mode=payload["cluster_mode"],
+ raw_topic=payload["raw_topic"],
+ subqueries=[subquery_from_dict(item) for item in payload.get("subqueries") or []],
+ source_weights=dict(payload.get("source_weights") or {}),
+ notes=list(payload.get("notes") or []),
+ )
+
+
+def source_item_from_dict(payload: dict[str, Any]) -> SourceItem:
+ meta = payload.get("metadata") or {}
+ return SourceItem(
+ item_id=payload["item_id"],
+ source=payload["source"],
+ title=payload["title"],
+ body=payload.get("body") or "",
+ url=payload.get("url") or "",
+ author=payload.get("author"),
+ container=payload.get("container"),
+ published_at=payload.get("published_at"),
+ date_confidence=payload.get("date_confidence") or "low",
+ engagement=dict(payload.get("engagement") or {}),
+ relevance_hint=float(_first_non_none(payload.get("relevance_hint"), 0.5)),
+ why_relevant=payload.get("why_relevant") or "",
+ snippet=payload.get("snippet") or "",
+ metadata=dict(meta),
+ local_relevance=_first_non_none(payload.get("local_relevance"), meta.get("local_relevance")),
+ freshness=_first_non_none(payload.get("freshness"), meta.get("freshness")),
+ engagement_score=_first_non_none(payload.get("engagement_score"), meta.get("engagement_score")),
+ source_quality=_first_non_none(payload.get("source_quality"), meta.get("source_quality")),
+ local_rank_score=_first_non_none(payload.get("local_rank_score"), meta.get("local_rank_score")),
+ )
+
+
+def candidate_from_dict(payload: dict[str, Any]) -> Candidate:
+ return Candidate(
+ candidate_id=payload["candidate_id"],
+ item_id=payload["item_id"],
+ source=payload["source"],
+ title=payload["title"],
+ url=payload.get("url") or "",
+ snippet=payload.get("snippet") or "",
+ subquery_labels=list(payload.get("subquery_labels") or []),
+ native_ranks={key: int(value) for key, value in (payload.get("native_ranks") or {}).items()},
+ local_relevance=float(_first_non_none(payload.get("local_relevance"), 0.0)),
+ freshness=int(_first_non_none(payload.get("freshness"), 0)),
+ engagement=payload.get("engagement"),
+ source_quality=float(_first_non_none(payload.get("source_quality"), 0.0)),
+ rrf_score=float(_first_non_none(payload.get("rrf_score"), 0.0)),
+ sources=list(payload.get("sources") or []),
+ source_items=[source_item_from_dict(item) for item in payload.get("source_items") or []],
+ rerank_score=float(payload["rerank_score"]) if payload.get("rerank_score") is not None else None,
+ final_score=float(_first_non_none(payload.get("final_score"), 0.0)),
+ explanation=payload.get("explanation"),
+ fun_score=float(payload["fun_score"]) if payload.get("fun_score") is not None else None,
+ fun_explanation=payload.get("fun_explanation"),
+ cluster_id=payload.get("cluster_id"),
+ metadata=dict(payload.get("metadata") or {}),
+ )
+
+
+def cluster_from_dict(payload: dict[str, Any]) -> Cluster:
+ return Cluster(
+ cluster_id=payload["cluster_id"],
+ title=payload["title"],
+ candidate_ids=list(payload.get("candidate_ids") or []),
+ representative_ids=list(payload.get("representative_ids") or []),
+ sources=list(payload.get("sources") or []),
+ score=float(_first_non_none(payload.get("score"), 0.0)),
+ uncertainty=payload.get("uncertainty"),
+ )
+
+
+def report_from_dict(payload: dict[str, Any]) -> Report:
+ return Report(
+ topic=payload["topic"],
+ range_from=payload["range_from"],
+ range_to=payload["range_to"],
+ generated_at=payload["generated_at"],
+ provider_runtime=provider_runtime_from_dict(payload["provider_runtime"]),
+ query_plan=query_plan_from_dict(payload["query_plan"]),
+ clusters=[cluster_from_dict(item) for item in payload.get("clusters") or []],
+ ranked_candidates=[candidate_from_dict(item) for item in payload.get("ranked_candidates") or []],
+ items_by_source={
+ source: [source_item_from_dict(item) for item in items]
+ for source, items in (payload.get("items_by_source") or {}).items()
+ },
+ errors_by_source=dict(payload.get("errors_by_source") or {}),
+ warnings=list(payload.get("warnings") or []),
+ artifacts=dict(payload.get("artifacts") or {}),
+ )
+
+
+def candidate_sources(candidate: Candidate) -> list[str]:
+ if candidate.sources:
+ return candidate.sources
+ return [candidate.source] if candidate.source else []
+
+
+def candidate_source_label(candidate: Candidate) -> str:
+ sources = candidate_sources(candidate)
+ return ", ".join(sources) if sources else "unknown"
+
+
+def candidate_best_published_at(candidate: Candidate) -> str | None:
+ return max(
+ (item.published_at for item in candidate.source_items if item.published_at),
+ default=None,
+ )
+
+
+def candidate_primary_item(candidate: Candidate) -> SourceItem | None:
+ if not candidate.source_items:
+ return None
+ for item in candidate.source_items:
+ if item.source == candidate.source:
+ return item
+ return candidate.source_items[0]
diff --git a/scripts/lib/score.py b/scripts/lib/score.py
deleted file mode 100644
index 2e0a0ad..0000000
--- a/scripts/lib/score.py
+++ /dev/null
@@ -1,775 +0,0 @@
-"""Popularity-aware scoring for last30days skill."""
-
-import math
-from typing import List, Optional, Union
-
-from . import dates, schema
-from .query_type import QueryType, WEBSEARCH_PENALTY_BY_TYPE, TIEBREAKER_BY_TYPE
-
-# Score weights for Reddit/X (has engagement)
-WEIGHT_RELEVANCE = 0.45
-WEIGHT_RECENCY = 0.25
-WEIGHT_ENGAGEMENT = 0.30
-
-# Polymarket needs stronger semantic weighting because volume/liquidity already
-# show up as engagement and lightly influence parse-time relevance.
-PM_WEIGHT_RELEVANCE = 0.60
-PM_WEIGHT_RECENCY = 0.20
-PM_WEIGHT_ENGAGEMENT = 0.20
-
-# WebSearch weights (no engagement data available)
-WEBSEARCH_WEIGHT_RELEVANCE = 0.55
-WEBSEARCH_WEIGHT_RECENCY = 0.45
-# Default web search penalty (fallback when query_type is not provided).
-# Per-type penalties in query_type.WEBSEARCH_PENALTY_BY_TYPE.
-WEBSEARCH_SOURCE_PENALTY = 15
-
-# WebSearch date confidence adjustments
-WEBSEARCH_VERIFIED_BONUS = 10 # Bonus for URL-verified recent date (high confidence)
-WEBSEARCH_NO_DATE_PENALTY = 20 # Heavy penalty for no date signals (low confidence)
-
-# Default engagement score for unknown
-DEFAULT_ENGAGEMENT = 35
-UNKNOWN_ENGAGEMENT_PENALTY = 3
-
-
-def log1p_safe(x: Optional[int]) -> float:
- """Safe log1p that handles None and negative values."""
- if x is None or x < 0:
- return 0.0
- return math.log1p(x)
-
-
-def compute_reddit_engagement_raw(
- engagement: Optional[schema.Engagement],
- top_comment_score: Optional[int] = None,
-) -> Optional[float]:
- """Compute raw engagement score for Reddit item.
-
- Formula: 0.50*log1p(score) + 0.35*log1p(num_comments) + 0.05*(upvote_ratio*10) + 0.10*log1p(top_comment_score)
-
- The 10% comment quality weight rewards posts where the community engaged deeply
- — a highly upvoted top comment means the thread sparked real discussion.
- """
- if engagement is None:
- return None
-
- if engagement.score is None and engagement.num_comments is None:
- return None
-
- score = log1p_safe(engagement.score)
- comments = log1p_safe(engagement.num_comments)
- ratio = (engagement.upvote_ratio or 0.5) * 10
- top_cmt = log1p_safe(top_comment_score)
-
- return 0.50 * score + 0.35 * comments + 0.05 * ratio + 0.10 * top_cmt
-
-
-def compute_x_engagement_raw(engagement: Optional[schema.Engagement]) -> Optional[float]:
- """Compute raw engagement score for X item.
-
- Formula: 0.55*log1p(likes) + 0.25*log1p(reposts) + 0.15*log1p(replies) + 0.05*log1p(quotes)
- """
- if engagement is None:
- return None
-
- if engagement.likes is None and engagement.reposts is None:
- return None
-
- likes = log1p_safe(engagement.likes)
- reposts = log1p_safe(engagement.reposts)
- replies = log1p_safe(engagement.replies)
- quotes = log1p_safe(engagement.quotes)
-
- return 0.55 * likes + 0.25 * reposts + 0.15 * replies + 0.05 * quotes
-
-
-def normalize_to_100(values: List[float], default: float = 50) -> List[float]:
- """Normalize a list of values to 0-100 scale.
-
- Args:
- values: Raw values (None values are preserved)
- default: Default value for None entries
-
- Returns:
- Normalized values
- """
- # Filter out None
- valid = [v for v in values if v is not None]
- if not valid:
- return [default if v is None else 50 for v in values]
-
- min_val = min(valid)
- max_val = max(valid)
- range_val = max_val - min_val
-
- if range_val == 0:
- return [50 if v is None else 50 for v in values]
-
- result = []
- for v in values:
- if v is None:
- result.append(None)
- else:
- normalized = ((v - min_val) / range_val) * 100
- result.append(normalized)
-
- return result
-
-
-def score_reddit_items(items: List[schema.RedditItem]) -> List[schema.RedditItem]:
- """Compute scores for Reddit items.
-
- Args:
- items: List of Reddit items
-
- Returns:
- Items with updated scores
- """
- if not items:
- return items
-
- # Compute raw engagement scores (with top comment quality signal)
- eng_raw = []
- for item in items:
- top_cmt_score = None
- if item.top_comments:
- top_cmt_score = item.top_comments[0].score
- eng_raw.append(compute_reddit_engagement_raw(item.engagement, top_cmt_score))
-
- # Normalize engagement to 0-100
- eng_normalized = normalize_to_100(eng_raw)
-
- for i, item in enumerate(items):
- # Relevance subscore (model-provided, convert to 0-100)
- rel_score = int(item.relevance * 100)
-
- # Recency subscore
- rec_score = dates.recency_score(item.date)
-
- # Engagement subscore
- if eng_normalized[i] is not None:
- eng_score = int(eng_normalized[i])
- else:
- eng_score = DEFAULT_ENGAGEMENT
-
- # Store subscores
- item.subs = schema.SubScores(
- relevance=rel_score,
- recency=rec_score,
- engagement=eng_score,
- )
-
- # Compute overall score
- overall = (
- WEIGHT_RELEVANCE * rel_score +
- WEIGHT_RECENCY * rec_score +
- WEIGHT_ENGAGEMENT * eng_score
- )
-
- # Apply penalty for unknown engagement
- if eng_raw[i] is None:
- overall -= UNKNOWN_ENGAGEMENT_PENALTY
-
- # Apply penalty for low date confidence
- if item.date_confidence == "low":
- overall -= 5
- elif item.date_confidence == "med":
- overall -= 2
-
- item.score = max(0, min(100, int(overall)))
-
- return items
-
-
-def score_x_items(items: List[schema.XItem]) -> List[schema.XItem]:
- """Compute scores for X items.
-
- Args:
- items: List of X items
-
- Returns:
- Items with updated scores
- """
- if not items:
- return items
-
- # Compute raw engagement scores
- eng_raw = [compute_x_engagement_raw(item.engagement) for item in items]
-
- # Normalize engagement to 0-100
- eng_normalized = normalize_to_100(eng_raw)
-
- for i, item in enumerate(items):
- # Relevance subscore (model-provided, convert to 0-100)
- rel_score = int(item.relevance * 100)
-
- # Recency subscore
- rec_score = dates.recency_score(item.date)
-
- # Engagement subscore
- if eng_normalized[i] is not None:
- eng_score = int(eng_normalized[i])
- else:
- eng_score = DEFAULT_ENGAGEMENT
-
- # Store subscores
- item.subs = schema.SubScores(
- relevance=rel_score,
- recency=rec_score,
- engagement=eng_score,
- )
-
- # Compute overall score
- overall = (
- WEIGHT_RELEVANCE * rel_score +
- WEIGHT_RECENCY * rec_score +
- WEIGHT_ENGAGEMENT * eng_score
- )
-
- # Apply penalty for unknown engagement
- if eng_raw[i] is None:
- overall -= UNKNOWN_ENGAGEMENT_PENALTY
-
- # Apply penalty for low date confidence
- if item.date_confidence == "low":
- overall -= 5
- elif item.date_confidence == "med":
- overall -= 2
-
- item.score = max(0, min(100, int(overall)))
-
- return items
-
-
-def compute_youtube_engagement_raw(engagement: Optional[schema.Engagement]) -> Optional[float]:
- """Compute raw engagement score for YouTube item.
-
- Formula: 0.50*log1p(views) + 0.35*log1p(likes) + 0.15*log1p(comments)
- Views dominate on YouTube — they're the primary discovery signal.
- """
- if engagement is None:
- return None
-
- if engagement.views is None and engagement.likes is None:
- return None
-
- views = log1p_safe(engagement.views)
- likes = log1p_safe(engagement.likes)
- comments = log1p_safe(engagement.num_comments)
-
- return 0.50 * views + 0.35 * likes + 0.15 * comments
-
-
-def score_youtube_items(items: List[schema.YouTubeItem]) -> List[schema.YouTubeItem]:
- """Compute scores for YouTube items.
-
- Uses same weight structure as Reddit/X (relevance + recency + engagement).
- """
- if not items:
- return items
-
- eng_raw = [compute_youtube_engagement_raw(item.engagement) for item in items]
- eng_normalized = normalize_to_100(eng_raw)
-
- for i, item in enumerate(items):
- rel_score = int(item.relevance * 100)
- rec_score = dates.recency_score(item.date)
-
- if eng_normalized[i] is not None:
- eng_score = int(eng_normalized[i])
- else:
- eng_score = DEFAULT_ENGAGEMENT
-
- item.subs = schema.SubScores(
- relevance=rel_score,
- recency=rec_score,
- engagement=eng_score,
- )
-
- overall = (
- WEIGHT_RELEVANCE * rel_score +
- WEIGHT_RECENCY * rec_score +
- WEIGHT_ENGAGEMENT * eng_score
- )
-
- if eng_raw[i] is None:
- overall -= UNKNOWN_ENGAGEMENT_PENALTY
-
- item.score = max(0, min(100, int(overall)))
-
- return items
-
-
-def compute_tiktok_engagement_raw(engagement: Optional[schema.Engagement]) -> Optional[float]:
- """Compute raw engagement score for TikTok item.
-
- Formula: 0.50*log1p(views) + 0.30*log1p(likes) + 0.20*log1p(comments)
- Views dominate on TikTok — they're the primary discovery signal.
- """
- if engagement is None:
- return None
-
- if engagement.views is None and engagement.likes is None:
- return None
-
- views = log1p_safe(engagement.views)
- likes = log1p_safe(engagement.likes)
- comments = log1p_safe(engagement.num_comments)
-
- return 0.50 * views + 0.30 * likes + 0.20 * comments
-
-
-def score_tiktok_items(items: List[schema.TikTokItem]) -> List[schema.TikTokItem]:
- """Compute scores for TikTok items.
-
- Uses same weight structure as YouTube (relevance + recency + engagement).
- """
- if not items:
- return items
-
- eng_raw = [compute_tiktok_engagement_raw(item.engagement) for item in items]
- eng_normalized = normalize_to_100(eng_raw)
-
- for i, item in enumerate(items):
- rel_score = int(item.relevance * 100)
- rec_score = dates.recency_score(item.date)
-
- if eng_normalized[i] is not None:
- eng_score = int(eng_normalized[i])
- else:
- eng_score = DEFAULT_ENGAGEMENT
-
- item.subs = schema.SubScores(
- relevance=rel_score,
- recency=rec_score,
- engagement=eng_score,
- )
-
- overall = (
- WEIGHT_RELEVANCE * rel_score +
- WEIGHT_RECENCY * rec_score +
- WEIGHT_ENGAGEMENT * eng_score
- )
-
- if eng_raw[i] is None:
- overall -= UNKNOWN_ENGAGEMENT_PENALTY
-
- item.score = max(0, min(100, int(overall)))
-
- return items
-
-
-def compute_instagram_engagement_raw(engagement: Optional[schema.Engagement]) -> Optional[float]:
- """Compute raw engagement score for Instagram item.
-
- Formula: 0.50*log1p(views) + 0.30*log1p(likes) + 0.20*log1p(comments)
- Views dominate on Instagram Reels — they're the primary discovery signal.
- """
- if engagement is None:
- return None
-
- if engagement.views is None and engagement.likes is None:
- return None
-
- views = log1p_safe(engagement.views)
- likes = log1p_safe(engagement.likes)
- comments = log1p_safe(engagement.num_comments)
-
- return 0.50 * views + 0.30 * likes + 0.20 * comments
-
-
-def score_instagram_items(items: List[schema.InstagramItem]) -> List[schema.InstagramItem]:
- """Compute scores for Instagram items.
-
- Uses same weight structure as TikTok (relevance + recency + engagement).
- """
- if not items:
- return items
-
- eng_raw = [compute_instagram_engagement_raw(item.engagement) for item in items]
- eng_normalized = normalize_to_100(eng_raw)
-
- for i, item in enumerate(items):
- rel_score = int(item.relevance * 100)
- rec_score = dates.recency_score(item.date)
-
- if eng_normalized[i] is not None:
- eng_score = int(eng_normalized[i])
- else:
- eng_score = DEFAULT_ENGAGEMENT
-
- item.subs = schema.SubScores(
- relevance=rel_score,
- recency=rec_score,
- engagement=eng_score,
- )
-
- overall = (
- WEIGHT_RELEVANCE * rel_score +
- WEIGHT_RECENCY * rec_score +
- WEIGHT_ENGAGEMENT * eng_score
- )
-
- if eng_raw[i] is None:
- overall -= UNKNOWN_ENGAGEMENT_PENALTY
-
- item.score = max(0, min(100, int(overall)))
-
- return items
-
-
-def compute_hackernews_engagement_raw(engagement: Optional[schema.Engagement]) -> Optional[float]:
- """Compute raw engagement score for Hacker News item.
-
- Formula: 0.55*log1p(points) + 0.45*log1p(num_comments)
- Points are the primary signal on HN; comments indicate depth of discussion.
- """
- if engagement is None:
- return None
-
- if engagement.score is None and engagement.num_comments is None:
- return None
-
- points = log1p_safe(engagement.score)
- comments = log1p_safe(engagement.num_comments)
-
- return 0.55 * points + 0.45 * comments
-
-
-def score_hackernews_items(items: List[schema.HackerNewsItem]) -> List[schema.HackerNewsItem]:
- """Compute scores for Hacker News items.
-
- Uses same weight structure as Reddit/X (relevance + recency + engagement).
- """
- if not items:
- return items
-
- eng_raw = [compute_hackernews_engagement_raw(item.engagement) for item in items]
- eng_normalized = normalize_to_100(eng_raw)
-
- for i, item in enumerate(items):
- rel_score = int(item.relevance * 100)
- rec_score = dates.recency_score(item.date)
-
- if eng_normalized[i] is not None:
- eng_score = int(eng_normalized[i])
- else:
- eng_score = DEFAULT_ENGAGEMENT
-
- item.subs = schema.SubScores(
- relevance=rel_score,
- recency=rec_score,
- engagement=eng_score,
- )
-
- overall = (
- WEIGHT_RELEVANCE * rel_score +
- WEIGHT_RECENCY * rec_score +
- WEIGHT_ENGAGEMENT * eng_score
- )
-
- if eng_raw[i] is None:
- overall -= UNKNOWN_ENGAGEMENT_PENALTY
-
- item.score = max(0, min(100, int(overall)))
-
- return items
-
-
-def compute_bluesky_engagement_raw(engagement: Optional[schema.Engagement]) -> Optional[float]:
- """Compute raw engagement score for Bluesky item.
-
- Formula: 0.40*log1p(likes) + 0.30*log1p(reposts) + 0.20*log1p(replies) + 0.10*log1p(quotes)
- Likes are primary signal; reposts indicate reach; replies indicate discussion depth.
- """
- if engagement is None:
- return None
-
- if engagement.likes is None and engagement.reposts is None:
- return None
-
- likes = log1p_safe(engagement.likes)
- reposts = log1p_safe(engagement.reposts)
- replies = log1p_safe(engagement.replies)
- quotes = log1p_safe(engagement.quotes)
-
- return 0.40 * likes + 0.30 * reposts + 0.20 * replies + 0.10 * quotes
-
-
-def score_bluesky_items(items: List[schema.BlueskyItem]) -> List[schema.BlueskyItem]:
- """Compute scores for Bluesky items.
-
- Uses same weight structure as Reddit/X (relevance + recency + engagement).
- """
- if not items:
- return items
-
- eng_raw = [compute_bluesky_engagement_raw(item.engagement) for item in items]
- eng_normalized = normalize_to_100(eng_raw)
-
- for i, item in enumerate(items):
- rel_score = int(item.relevance * 100)
- rec_score = dates.recency_score(item.date)
-
- if eng_normalized[i] is not None:
- eng_score = int(eng_normalized[i])
- else:
- eng_score = DEFAULT_ENGAGEMENT
-
- item.subs = schema.SubScores(
- relevance=rel_score,
- recency=rec_score,
- engagement=eng_score,
- )
-
- overall = (
- WEIGHT_RELEVANCE * rel_score +
- WEIGHT_RECENCY * rec_score +
- WEIGHT_ENGAGEMENT * eng_score
- )
-
- if eng_raw[i] is None:
- overall -= UNKNOWN_ENGAGEMENT_PENALTY
-
- item.score = max(0, min(100, int(overall)))
-
- return items
-
-
-def compute_truthsocial_engagement_raw(engagement: Optional[schema.Engagement]) -> Optional[float]:
- """Compute raw engagement score for Truth Social item.
-
- Formula: 0.45*log1p(likes) + 0.30*log1p(reposts) + 0.25*log1p(replies)
- Likes are primary signal; reposts indicate reach; replies indicate discussion.
- """
- if engagement is None:
- return None
-
- if engagement.likes is None and engagement.reposts is None:
- return None
-
- likes = log1p_safe(engagement.likes)
- reposts = log1p_safe(engagement.reposts)
- replies = log1p_safe(engagement.replies)
-
- return 0.45 * likes + 0.30 * reposts + 0.25 * replies
-
-
-def score_truthsocial_items(items: List[schema.TruthSocialItem]) -> List[schema.TruthSocialItem]:
- """Compute scores for Truth Social items."""
- if not items:
- return items
-
- eng_raw = [compute_truthsocial_engagement_raw(item.engagement) for item in items]
- eng_normalized = normalize_to_100(eng_raw)
-
- for i, item in enumerate(items):
- rel_score = int(item.relevance * 100)
- rec_score = dates.recency_score(item.date)
-
- if eng_normalized[i] is not None:
- eng_score = int(eng_normalized[i])
- else:
- eng_score = DEFAULT_ENGAGEMENT
-
- item.subs = schema.SubScores(
- relevance=rel_score,
- recency=rec_score,
- engagement=eng_score,
- )
-
- overall = (
- WEIGHT_RELEVANCE * rel_score +
- WEIGHT_RECENCY * rec_score +
- WEIGHT_ENGAGEMENT * eng_score
- )
-
- if eng_raw[i] is None:
- overall -= UNKNOWN_ENGAGEMENT_PENALTY
-
- item.score = max(0, min(100, int(overall)))
-
- return items
-
-
-def compute_polymarket_engagement_raw(engagement: Optional[schema.Engagement]) -> Optional[float]:
- """Compute raw engagement score for Polymarket item.
-
- Formula: 0.60*log1p(volume) + 0.40*log1p(liquidity)
- Volume is the primary signal (money flowing); liquidity indicates market depth.
- """
- if engagement is None:
- return None
-
- if engagement.volume is None and engagement.liquidity is None:
- return None
-
- volume = math.log1p(engagement.volume or 0)
- liquidity = math.log1p(engagement.liquidity or 0)
-
- return 0.60 * volume + 0.40 * liquidity
-
-
-def score_polymarket_items(items: List[schema.PolymarketItem]) -> List[schema.PolymarketItem]:
- """Compute scores for Polymarket items.
-
- Uses same weight structure as Reddit/X (relevance + recency + engagement).
- """
- if not items:
- return items
-
- eng_raw = [compute_polymarket_engagement_raw(item.engagement) for item in items]
- eng_normalized = normalize_to_100(eng_raw)
-
- for i, item in enumerate(items):
- rel_score = int(item.relevance * 100)
- rec_score = dates.recency_score(item.date)
-
- if eng_normalized[i] is not None:
- eng_score = int(eng_normalized[i])
- else:
- eng_score = DEFAULT_ENGAGEMENT
-
- item.subs = schema.SubScores(
- relevance=rel_score,
- recency=rec_score,
- engagement=eng_score,
- )
-
- overall = (
- PM_WEIGHT_RELEVANCE * rel_score +
- PM_WEIGHT_RECENCY * rec_score +
- PM_WEIGHT_ENGAGEMENT * eng_score
- )
-
- if eng_raw[i] is None:
- overall -= UNKNOWN_ENGAGEMENT_PENALTY
-
- item.score = max(0, min(100, int(overall)))
-
- return items
-
-
-def score_websearch_items(items: List[schema.WebSearchItem], query_type: QueryType = None) -> List[schema.WebSearchItem]:
- """Compute scores for WebSearch items WITHOUT engagement metrics.
-
- Uses reweighted formula: 55% relevance + 45% recency - penalty.
- Penalty varies by query type: concept queries get 0 penalty (web docs
- are authoritative), while product/opinion queries get full 15pt penalty
- (social discussion is more valuable).
-
- Args:
- items: List of WebSearch items
- query_type: Query classification for penalty adjustment
-
- Returns:
- Items with updated scores
- """
- if not items:
- return items
-
- for item in items:
- # Relevance subscore (model-provided, convert to 0-100)
- rel_score = int(item.relevance * 100)
-
- # Recency subscore
- rec_score = dates.recency_score(item.date)
-
- # Store subscores (engagement is 0 for WebSearch - no data)
- item.subs = schema.SubScores(
- relevance=rel_score,
- recency=rec_score,
- engagement=0, # Explicitly zero - no engagement data available
- )
-
- # Compute overall score using WebSearch weights
- overall = (
- WEBSEARCH_WEIGHT_RELEVANCE * rel_score +
- WEBSEARCH_WEIGHT_RECENCY * rec_score
- )
-
- # Apply source penalty (varies by query type)
- penalty = WEBSEARCH_PENALTY_BY_TYPE.get(query_type, WEBSEARCH_SOURCE_PENALTY) if query_type else WEBSEARCH_SOURCE_PENALTY
- overall -= penalty
-
- # Apply date confidence adjustments
- # High confidence (URL-verified): reward with bonus
- # Med confidence (snippet-extracted): neutral
- # Low confidence (no date signals): heavy penalty
- if item.date_confidence == "high":
- overall += WEBSEARCH_VERIFIED_BONUS # Reward verified recent dates
- elif item.date_confidence == "low":
- overall -= WEBSEARCH_NO_DATE_PENALTY # Heavy penalty for unknown
-
- item.score = max(0, min(100, int(overall)))
-
- return items
-
-
-_ITEM_SOURCE_MAP = {
- schema.RedditItem: "reddit",
- schema.XItem: "x",
- schema.YouTubeItem: "youtube",
- schema.TikTokItem: "tiktok",
- schema.InstagramItem: "instagram",
- schema.HackerNewsItem: "hn",
- schema.BlueskyItem: "bluesky",
- schema.TruthSocialItem: "truthsocial",
- schema.PolymarketItem: "polymarket",
-}
-_DEFAULT_TIEBREAKER = {"reddit": 0, "x": 1, "youtube": 2, "tiktok": 3, "instagram": 4, "hn": 5, "bluesky": 6, "truthsocial": 7, "polymarket": 8, "web": 9}
-
-
-def sort_items(items: List[Union[schema.RedditItem, schema.XItem, schema.WebSearchItem, schema.YouTubeItem, schema.TikTokItem, schema.InstagramItem, schema.HackerNewsItem, schema.BlueskyItem, schema.TruthSocialItem, schema.PolymarketItem]], query_type: QueryType = None) -> List:
- """Sort items by score (descending), then date, then source tiebreaker.
-
- Tiebreaker (tertiary sort key, after score and date): source priority
- varies by query type. YouTube ranks first for how_to, X ranks first
- for breaking_news, Polymarket ranks first for prediction.
-
- Args:
- items: List of items to sort
- query_type: Query classification for tiebreaker adjustment
-
- Returns:
- Sorted items
- """
- tiebreaker = TIEBREAKER_BY_TYPE.get(query_type, _DEFAULT_TIEBREAKER) if query_type else _DEFAULT_TIEBREAKER
-
- def sort_key(item):
- # Primary: score descending (negate for descending)
- score = -item.score
-
- # Secondary: date descending (recent first)
- date = item.date or "0000-00-00"
- date_key = -int(date.replace("-", ""))
-
- # Tertiary: query-type-aware source priority
- source_name = _ITEM_SOURCE_MAP.get(type(item), "web")
- source_priority = tiebreaker.get(source_name, 99)
-
- # Quaternary: title/text for stability
- text = getattr(item, "title", "") or getattr(item, "text", "")
-
- return (score, date_key, source_priority, text)
-
- return sorted(items, key=sort_key)
-
-
-def relevance_filter(items, source_name: str, threshold: float = 0.3):
- """Filter items below relevance threshold with minimum-result guarantee.
-
- Items with no relevance attribute are treated as 0.0 (fail the filter).
- If all items are below threshold, keeps the top 3 by relevance.
- Lists with 3 or fewer items are returned unchanged.
- """
- import sys
- if len(items) <= 3:
- return items
- passed = [i for i in items if getattr(i, 'relevance', 0.0) >= threshold]
- if not passed:
- print(f"[{source_name} WARNING] All results below relevance {threshold}, keeping top 3", file=sys.stderr)
- by_rel = sorted(items, key=lambda x: getattr(x, 'relevance', 0.0), reverse=True)
- return by_rel[:3]
- return passed
diff --git a/scripts/lib/scrapecreators_x.py b/scripts/lib/scrapecreators_x.py
deleted file mode 100644
index 6266019..0000000
--- a/scripts/lib/scrapecreators_x.py
+++ /dev/null
@@ -1,182 +0,0 @@
-"""X/Twitter search via ScrapeCreators API for /last30days.
-
-Uses ScrapeCreators REST API to search Twitter/X by keyword.
-Same API key as Reddit, TikTok, and Instagram - one key covers all social sources.
-
-Requires SCRAPECREATORS_API_KEY in config.
-API docs: https://scrapecreators.com/docs
-"""
-
-import sys
-from datetime import datetime, timezone
-from typing import Any, Dict, List, Optional
-
-try:
- import requests as _requests
-except ImportError:
- _requests = None
-
-SCRAPECREATORS_BASE = "https://api.scrapecreators.com/v1/twitter"
-
-DEPTH_CONFIG = {
- "quick": {"results_per_page": 10},
- "default": {"results_per_page": 20},
- "deep": {"results_per_page": 40},
-}
-
-from .relevance import token_overlap_relevance as _compute_relevance
-
-
-def _extract_core_subject(topic: str) -> str:
- """Extract core subject from verbose query for Twitter search."""
- from .query import extract_core_subject
- _SC_X_NOISE = frozenset({
- 'best', 'top', 'good', 'great', 'awesome',
- 'latest', 'new', 'news', 'update', 'updates',
- 'trending', 'hottest', 'popular', 'viral',
- 'practices', 'features', 'recommendations', 'advice',
- })
- return extract_core_subject(topic, noise=_SC_X_NOISE)
-
-
-def _log(msg: str):
- if sys.stderr.isatty():
- sys.stderr.write(f"[X/SC] {msg}\n")
- sys.stderr.flush()
-
-
-def _sc_headers(token: str) -> Dict[str, str]:
- return {
- "x-api-key": token,
- "Content-Type": "application/json",
- }
-
-
-def _parse_date(item: Dict[str, Any]) -> Optional[str]:
- """Parse date from ScrapeCreators Twitter item to YYYY-MM-DD."""
- # Try created_at string (e.g. "Wed Oct 10 20:19:24 +0000 2018")
- created_at = item.get("created_at")
- if created_at and isinstance(created_at, str):
- try:
- dt = datetime.strptime(created_at, "%a %b %d %H:%M:%S %z %Y")
- return dt.strftime("%Y-%m-%d")
- except (ValueError, TypeError):
- pass
-
- # Try unix timestamp
- ts = item.get("timestamp") or item.get("created_at_timestamp")
- if ts:
- try:
- dt = datetime.fromtimestamp(int(ts), tz=timezone.utc)
- return dt.strftime("%Y-%m-%d")
- except (ValueError, TypeError, OSError):
- pass
-
- # Try ISO format
- for key in ("created_at", "date"):
- val = item.get(key)
- if val and isinstance(val, str):
- try:
- dt = datetime.fromisoformat(val.replace("Z", "+00:00"))
- return dt.strftime("%Y-%m-%d")
- except (ValueError, TypeError):
- pass
-
- return None
-
-
-def search_x(
- topic: str,
- from_date: str,
- to_date: str,
- depth: str = "default",
- token: str = None,
-) -> Dict[str, Any]:
- """Search X/Twitter via ScrapeCreators API.
-
- Returns:
- Dict with 'items' list (in normalize_x_items format) and optional 'error'.
- """
- if not token:
- return {"items": [], "error": "No SCRAPECREATORS_API_KEY configured"}
-
- if not _requests:
- return {"items": [], "error": "requests library not installed"}
-
- config = DEPTH_CONFIG.get(depth, DEPTH_CONFIG["default"])
- core_topic = _extract_core_subject(topic)
-
- _log(f"Searching X for '{core_topic}' (depth={depth}, count={config['results_per_page']})")
-
- try:
- resp = _requests.get(
- f"{SCRAPECREATORS_BASE}/search/tweets",
- params={"query": core_topic, "sort_by": "relevance"},
- headers=_sc_headers(token),
- timeout=30,
- )
- resp.raise_for_status()
- data = resp.json()
- except Exception as e:
- _log(f"ScrapeCreators error: {e}")
- return {"items": [], "error": f"{type(e).__name__}: {e}"}
-
- raw_items = data.get("tweets") or data.get("data") or data.get("results") or []
- raw_items = raw_items[:config["results_per_page"]]
-
- items = []
- for i, raw in enumerate(raw_items):
- tweet_id = str(raw.get("id") or raw.get("tweet_id") or raw.get("id_str") or f"sc-x-{i}")
- text = raw.get("full_text") or raw.get("text") or ""
- user = raw.get("user") or raw.get("author") or {}
- author_handle = user.get("screen_name") or user.get("username") or ""
-
- # Engagement metrics
- likes = raw.get("favorite_count") or raw.get("likes") or 0
- retweets = raw.get("retweet_count") or raw.get("retweets") or 0
- replies = raw.get("reply_count") or raw.get("replies") or 0
- quotes = raw.get("quote_count") or raw.get("quotes") or 0
-
- date_str = _parse_date(raw)
- relevance = _compute_relevance(core_topic, text)
-
- url = ""
- if author_handle and tweet_id and not tweet_id.startswith("sc-x-"):
- url = f"https://x.com/{author_handle}/status/{tweet_id}"
-
- items.append({
- "id": tweet_id,
- "text": text,
- "url": url,
- "author_handle": author_handle,
- "date": date_str,
- "engagement": {
- "likes": likes,
- "reposts": retweets,
- "replies": replies,
- "quotes": quotes,
- },
- "relevance": relevance,
- "why_relevant": f"X: @{author_handle}: {text[:60]}" if text else f"X: {core_topic}",
- })
-
- # Date filter
- in_range = [i for i in items if i["date"] and from_date <= i["date"] <= to_date]
- out_of_range = len(items) - len(in_range)
- if in_range:
- items = in_range
- if out_of_range:
- _log(f"Filtered {out_of_range} tweets outside date range")
- else:
- _log(f"No tweets within date range, keeping all {len(items)}")
-
- # Sort by engagement (likes + retweets)
- items.sort(key=lambda x: (x["engagement"]["likes"] + x["engagement"]["reposts"]), reverse=True)
-
- _log(f"Found {len(items)} tweets")
- return {"items": items}
-
-
-def parse_x_response(response: Dict[str, Any]) -> List[Dict[str, Any]]:
- """Parse search response to normalized format."""
- return response.get("items", [])
diff --git a/scripts/lib/setup_wizard.py b/scripts/lib/setup_wizard.py
index 16810dc..4818a80 100644
--- a/scripts/lib/setup_wizard.py
+++ b/scripts/lib/setup_wizard.py
@@ -5,11 +5,15 @@ and writes configuration. The actual wizard UI is SKILL.md-driven (the LLM
presents it), but this module provides the detection and setup actions.
"""
+import json
import logging
import shutil
import subprocess
+import time
from pathlib import Path
-from typing import Any, Dict, Optional
+from typing import Any, Dict, Optional, Tuple
+from urllib.error import HTTPError, URLError
+from urllib.request import Request, urlopen
logger = logging.getLogger(__name__)
@@ -184,3 +188,348 @@ def get_setup_status_text(results: Dict[str, Any]) -> str:
lines.append("Configuration saved. Future runs will auto-detect your browsers.")
return "\n".join(lines)
+
+
+# ---------------------------------------------------------------------------
+# OpenClaw server-side setup (no browser, JSON output)
+# ---------------------------------------------------------------------------
+
+_OPENCLAW_KEY_NAMES = [
+ "SCRAPECREATORS_API_KEY",
+ "XAI_API_KEY",
+ "BRAVE_API_KEY",
+ "EXA_API_KEY",
+ "SERPER_API_KEY",
+ "OPENAI_API_KEY",
+ "AUTH_TOKEN",
+]
+
+
+def run_openclaw_setup(config: Dict[str, Any]) -> Dict[str, Any]:
+ """Server-side setup probe: no cookies, just tool + key availability.
+
+ Returns a dict suitable for JSON output to stdout so that SKILL.md
+ can present appropriate options to the user.
+ """
+ yt_dlp = shutil.which("yt-dlp") is not None
+ node = shutil.which("node") is not None
+ python3 = shutil.which("python3") is not None
+
+ keys: Dict[str, bool] = {}
+ for key_name in _OPENCLAW_KEY_NAMES:
+ short = key_name.lower().replace("_api_key", "").replace("_key", "").replace("_token", "")
+ # Normalize: AUTH_TOKEN -> auth, SCRAPECREATORS_API_KEY -> scrapecreators
+ keys[short] = bool(config.get(key_name))
+
+ # Determine x_method
+ if config.get("XAI_API_KEY"):
+ x_method: Optional[str] = "xai"
+ elif config.get("AUTH_TOKEN") and config.get("CT0"):
+ x_method = "cookies"
+ else:
+ x_method = None
+
+ return {
+ "yt_dlp": yt_dlp,
+ "node": node,
+ "python3": python3,
+ "keys": keys,
+ "x_method": x_method,
+ }
+
+
+# ---------------------------------------------------------------------------
+# PAT auth flow (GitHub token via ScrapeCreators)
+# ---------------------------------------------------------------------------
+
+_PAT_BASE = "https://api.scrapecreators.com/v1/github/pat"
+
+
+def auth_with_pat(github_token: str) -> Optional[Dict[str, Any]]:
+ """Authenticate with ScrapeCreators using a GitHub PAT.
+
+ POSTs the token to the PAT auth endpoint. ScrapeCreators verifies it
+ against GitHub's API, creates/finds the account, and returns an API key.
+
+ Returns:
+ Dict with api_key, github_username, etc. on success, None on failure.
+ """
+ try:
+ req = Request(f"{_PAT_BASE}/auth", data=b"", method="POST")
+ req.add_header("Authorization", f"Bearer {github_token}")
+ with urlopen(req, timeout=15) as resp:
+ data = json.loads(resp.read())
+ except HTTPError as exc:
+ if exc.code == 422:
+ logger.warning("PAT auth: insufficient scope — user needs user:email")
+ else:
+ logger.warning("PAT auth failed: %s", exc)
+ return None
+ except (URLError, OSError) as exc:
+ logger.warning("PAT auth request failed: %s", exc)
+ return None
+
+ if not data.get("api_key"):
+ logger.warning("PAT auth returned no api_key: %s", data)
+ return None
+
+ return data
+
+
+# ---------------------------------------------------------------------------
+# Device auth flow (GitHub OAuth via ScrapeCreators)
+# ---------------------------------------------------------------------------
+
+_DEVICE_BASE = "https://api.scrapecreators.com/v1/github/device"
+
+
+def run_device_auth() -> Optional[Tuple[str, str, str, int]]:
+ """Start the device authorization flow.
+
+ POSTs to the ScrapeCreators device/code endpoint.
+
+ Returns:
+ (device_code, user_code, verification_uri, interval) on success,
+ None on failure.
+ """
+ try:
+ body = json.dumps({}).encode()
+ req = Request(f"{_DEVICE_BASE}/code", data=body, method="POST")
+ req.add_header("Content-Type", "application/json")
+ with urlopen(req, timeout=15) as resp:
+ data = json.loads(resp.read())
+ except (HTTPError, URLError, OSError) as exc:
+ logger.warning("Device auth code request failed: %s", exc)
+ return None
+
+ device_code = data.get("device_code")
+ user_code = data.get("user_code")
+ verification_uri = data.get("verification_uri")
+ interval = data.get("interval", 5)
+
+ if not device_code or not user_code:
+ logger.warning("Device auth returned incomplete response: %s", data)
+ return None
+
+ return (device_code, user_code, verification_uri or "", interval)
+
+
+def poll_device_auth(
+ device_code: str,
+ interval: int,
+ timeout: int = 300,
+ user_code: str = "",
+ clipboard_ok: bool = False,
+) -> Optional[str]:
+ """Poll for an access token after the user authorizes the device.
+
+ Args:
+ device_code: The device_code from run_device_auth().
+ interval: Polling interval in seconds.
+ timeout: Maximum time to poll in seconds.
+ user_code: The user code to remind about during polling.
+ clipboard_ok: Whether the code was copied to clipboard.
+
+ Returns:
+ access_token on success, None on timeout or failure.
+ """
+ import sys
+
+ deadline = time.time() + timeout
+ last_reminder = time.time()
+ reminder_count = 0
+ max_reminders = 4
+ reminder_interval = 30 # seconds between reminders
+
+ while time.time() < deadline:
+ time.sleep(interval)
+
+ # Periodic reminder of the code while waiting
+ if (
+ user_code
+ and reminder_count < max_reminders
+ and time.time() - last_reminder >= reminder_interval
+ ):
+ clipboard_hint = " (on your clipboard)" if clipboard_ok else ""
+ print(
+ f" Still waiting... Your code: {user_code}{clipboard_hint}",
+ file=sys.stderr,
+ flush=True,
+ )
+ last_reminder = time.time()
+ reminder_count += 1
+
+ try:
+ body = json.dumps({"device_code": device_code}).encode()
+ req = Request(f"{_DEVICE_BASE}/token", data=body, method="POST")
+ req.add_header("Content-Type", "application/json")
+ with urlopen(req, timeout=15) as resp:
+ data = json.loads(resp.read())
+ except HTTPError as exc:
+ if exc.code in (400, 403, 428):
+ continue
+ logger.warning("Device auth poll error: %s", exc)
+ return None
+ except (URLError, OSError):
+ continue
+
+ if data.get("access_token"):
+ return data["access_token"]
+
+ error = data.get("error")
+ if error == "slow_down":
+ interval = min(interval + 2, 30)
+ continue
+ if error == "authorization_pending":
+ continue
+ if error in ("expired_token", "access_denied"):
+ logger.warning("Device auth failed: %s", error)
+ return None
+
+ return None
+
+
+def fetch_api_key(access_token: str) -> Optional[str]:
+ """Fetch the ScrapeCreators API key using the GitHub access token.
+
+ GETs the device/profile endpoint with Bearer auth.
+
+ Returns:
+ api_key string on success, None on failure.
+ """
+ try:
+ req = Request(f"{_DEVICE_BASE}/profile")
+ req.add_header("Authorization", f"Bearer {access_token}")
+ with urlopen(req, timeout=15) as resp:
+ data = json.loads(resp.read())
+ except (HTTPError, URLError, OSError) as exc:
+ logger.warning("Failed to fetch API key: %s", exc)
+ return None
+
+ return data.get("api_key")
+
+
+def run_full_device_auth(timeout: int = 300) -> Dict[str, Any]:
+ """Run the complete GitHub device auth flow and return JSON-serializable result.
+
+ Chains: start device flow -> open browser -> poll -> fetch API key.
+ Designed to be called from the CLI and have its stdout parsed by the LLM.
+
+ Returns:
+ Dict with status and relevant fields:
+ - {"status": "success", "api_key": "sc_...", "user_code": "ABCD-1234"}
+ - {"status": "error", "message": "..."}
+ - {"status": "timeout", "user_code": "ABCD-1234"}
+ - {"status": "denied"}
+ """
+ import webbrowser
+
+ # Step 1: Start device flow
+ result = run_device_auth()
+ if result is None:
+ return {"status": "error", "message": "Failed to start device auth flow"}
+
+ device_code, user_code, verification_uri, interval = result
+
+ import sys
+
+ # Step 2: Copy code to clipboard BEFORE opening browser
+ clipboard_ok = False
+ if sys.platform == "darwin":
+ try:
+ subprocess.run(
+ ["pbcopy"], input=user_code.encode(), check=True, timeout=5,
+ )
+ clipboard_ok = True
+ except Exception:
+ pass # pbcopy unavailable or failed, fall through
+
+ # Step 3: Show code prominently, then open browser
+ clipboard_hint = " (copied to clipboard)" if clipboard_ok else ""
+ code_line = f" Your code: {user_code}{clipboard_hint}"
+ action_line = " Paste it on the GitHub page that just opened"
+ width = max(len(code_line), len(action_line)) + 2
+ border = "-" * width
+ print(f"\n+{border}+", file=sys.stderr)
+ print(f"|{code_line.ljust(width)}|", file=sys.stderr)
+ print(f"|{action_line.ljust(width)}|", file=sys.stderr)
+ print(f"+{border}+", file=sys.stderr)
+
+ if verification_uri:
+ try:
+ webbrowser.open(verification_uri)
+ except Exception:
+ print(f"Open: {verification_uri}", file=sys.stderr)
+
+ print("Waiting for authorization...", file=sys.stderr, flush=True)
+
+ # Step 4: Poll for token (with periodic code reminders)
+ access_token = poll_device_auth(
+ device_code, interval, timeout=timeout,
+ user_code=user_code, clipboard_ok=clipboard_ok,
+ )
+ if access_token is None:
+ return {"status": "timeout", "user_code": user_code, "clipboard_ok": clipboard_ok}
+
+ # Step 4: Fetch API key
+ api_key = fetch_api_key(access_token)
+ if api_key is None:
+ return {
+ "status": "error",
+ "message": "Authorized but failed to fetch API key",
+ "clipboard_ok": clipboard_ok,
+ }
+
+ return {"status": "success", "method": "device", "api_key": api_key, "user_code": user_code, "clipboard_ok": clipboard_ok}
+
+
+# ---------------------------------------------------------------------------
+# Unified GitHub auth: PAT first, device flow fallback
+# ---------------------------------------------------------------------------
+
+
+def run_github_auth(timeout: int = 300) -> Dict[str, Any]:
+ """Try PAT auth via gh CLI, fall back to device flow.
+
+ 1. Check for `gh` CLI
+ 2. If found, run `gh auth token` to get a PAT
+ 3. POST PAT to ScrapeCreators — if it works, done
+ 4. If PAT fails for any reason, fall through to device flow
+
+ Returns JSON-serializable dict with status, method, and api_key.
+ """
+ import sys
+
+ # Step 1: Try PAT via gh CLI
+ gh_path = shutil.which("gh")
+ if gh_path:
+ try:
+ result = subprocess.run(
+ ["gh", "auth", "token"],
+ capture_output=True, text=True, timeout=10,
+ )
+ if result.returncode == 0 and result.stdout.strip():
+ token = result.stdout.strip()
+ print("Found gh CLI — trying PAT auth...", file=sys.stderr)
+ pat_result = auth_with_pat(token)
+ if pat_result and pat_result.get("api_key"):
+ return {
+ "status": "success",
+ "method": "pat",
+ "api_key": pat_result["api_key"],
+ "github_username": pat_result.get("github_username", ""),
+ }
+ # PAT failed — might be insufficient scope
+ print(
+ "PAT auth didn't work (scope or endpoint issue). "
+ "Falling back to GitHub device flow...",
+ file=sys.stderr,
+ )
+ except Exception as exc:
+ logger.debug("gh auth token failed: %s", exc)
+
+ # Step 2: Fall back to device flow
+ if not gh_path:
+ print("gh CLI not found — using GitHub device flow...", file=sys.stderr)
+
+ return run_full_device_auth(timeout=timeout)
diff --git a/scripts/lib/signals.py b/scripts/lib/signals.py
new file mode 100644
index 0000000..90b412c
--- /dev/null
+++ b/scripts/lib/signals.py
@@ -0,0 +1,221 @@
+"""Reusable local scoring signals for v3 pipeline stages."""
+
+from __future__ import annotations
+
+import math
+
+from . import dates, relevance, schema
+
+# Editorial signal-to-noise scores. Grounding (Google Search) is 1.0 baseline;
+# social platforms discounted for noise.
+SOURCE_QUALITY = {
+ "xiaohongshu": 0.7,
+ "hackernews": 0.8,
+ "youtube": 0.85,
+ "reddit": 0.6,
+ "x": 0.68,
+ "bluesky": 0.66,
+ "truthsocial": 0.6,
+ "polymarket": 0.5,
+ "instagram": 0.58,
+ "tiktok": 0.58,
+}
+
+
+def source_quality(source: str) -> float:
+ return SOURCE_QUALITY.get(source, 0.6)
+
+
+def local_relevance(item: schema.SourceItem, ranking_query: str) -> float:
+ text = "\n".join(
+ part
+ for part in [item.title, item.body, item.snippet]
+ if part
+ )
+ hashtags = item.metadata.get("hashtags") if isinstance(item.metadata, dict) else None
+ score = relevance.token_overlap_relevance(ranking_query, text, hashtags=hashtags)
+
+ # High-engagement YouTube floor: official videos with millions of views
+ # often have titles that don't keyword-match the query (e.g., "YE - FATHER
+ # (feat. TRAVIS SCOTT)" doesn't match "kanye west"). The engagement signals
+ # say "this is important" even when text overlap is weak.
+ if item.source == "youtube" and item.engagement.get("views", 0) > 100_000:
+ score = max(score, 0.3)
+
+ # Project-mode GitHub floor: items fetched via --github-repo are explicitly
+ # requested by the user and relevant by construction. Without this floor,
+ # repos with low token diversity (e.g., "openclaw/openclaw" -> 1 unique token)
+ # get pruned despite being the primary search target.
+ labels = item.metadata.get("labels", []) if isinstance(item.metadata, dict) else []
+ if "project-mode" in labels:
+ score = max(score, 0.8)
+
+ return score
+
+
+def freshness(item: schema.SourceItem, freshness_mode: str = "balanced_recent") -> int:
+ score = dates.recency_score(item.published_at)
+ if freshness_mode == "strict_recent":
+ return int(score)
+ if freshness_mode == "evergreen_ok":
+ return int((score * 0.6) + 40)
+ return int((score * 0.8) + 10)
+
+
+def log1p_safe(value: float | int | None) -> float:
+ if value is None:
+ return 0.0
+ try:
+ numeric = float(value)
+ except (TypeError, ValueError):
+ return 0.0
+ if numeric <= 0:
+ return 0.0
+ return math.log1p(numeric)
+
+
+def _top_comment_score(item: schema.SourceItem) -> float:
+ comments = item.metadata.get("top_comments") or []
+ if not comments or not isinstance(comments[0], dict):
+ return 0.0
+ return log1p_safe(comments[0].get("score"))
+
+
+# Per-source engagement weights: list of (field_name, weight) tuples.
+# Reddit uses a custom function because upvote_ratio and top_comment_score
+# are not simple log1p fields.
+ENGAGEMENT_WEIGHTS: dict[str, list[tuple[str, float]]] = {
+ "x": [("likes", 0.55), ("reposts", 0.25), ("replies", 0.15), ("quotes", 0.05)],
+ "youtube": [("views", 0.50), ("likes", 0.35), ("comments", 0.15)],
+ "tiktok": [("views", 0.50), ("likes", 0.30), ("comments", 0.20)],
+ "instagram": [("views", 0.50), ("likes", 0.30), ("comments", 0.20)],
+ "hackernews": [("points", 0.55), ("comments", 0.45)],
+ "bluesky": [("likes", 0.40), ("reposts", 0.30), ("replies", 0.20), ("quotes", 0.10)],
+ "truthsocial": [("likes", 0.45), ("reposts", 0.30), ("replies", 0.25)],
+ "polymarket": [("volume", 0.60), ("liquidity", 0.40)],
+}
+
+
+def _weighted_engagement(item: schema.SourceItem, weights: list[tuple[str, float]]) -> float | None:
+ values = [(log1p_safe(item.engagement.get(field)), weight) for field, weight in weights]
+ if not any(v for v, _ in values):
+ return None
+ return sum(v * w for v, w in values)
+
+
+def _reddit_engagement(item: schema.SourceItem) -> float | None:
+ score = log1p_safe(item.engagement.get("score"))
+ comments = log1p_safe(item.engagement.get("num_comments"))
+ ratio = float(item.engagement.get("upvote_ratio") or 0.0)
+ top_comment = _top_comment_score(item)
+ if not any([score, comments, ratio, top_comment]):
+ return None
+ return (0.50 * score) + (0.35 * comments) + (0.05 * (ratio * 10.0)) + (0.10 * top_comment)
+
+
+def _generic_engagement(item: schema.SourceItem) -> float | None:
+ if not item.engagement:
+ return None
+ values = [logged for v in item.engagement.values() if (logged := log1p_safe(v)) > 0]
+ if not values:
+ return None
+ return sum(values) / len(values)
+
+
+def engagement_raw(item: schema.SourceItem) -> float | None:
+ if item.source == "reddit":
+ return _reddit_engagement(item)
+ weights = ENGAGEMENT_WEIGHTS.get(item.source)
+ if weights:
+ return _weighted_engagement(item, weights)
+ return _generic_engagement(item)
+
+
+def normalize(values: list[float | None]) -> list[int | None]:
+ valid = [value for value in values if value is not None]
+ if not valid:
+ return [None for _ in values]
+ low = min(valid)
+ high = max(valid)
+ if math.isclose(low, high):
+ return [50 if value is not None else None for value in values]
+ return [
+ None
+ if value is None
+ else int(((value - low) / (high - low)) * 100)
+ for value in values
+ ]
+
+
+def annotate_stream(
+ items: list[schema.SourceItem],
+ ranking_query: str,
+ freshness_mode: str,
+) -> list[schema.SourceItem]:
+ """Attach local scoring metadata and return items sorted by local_rank_score."""
+ engagement_scores = normalize([engagement_raw(item) for item in items])
+ for item, eng_score in zip(items, engagement_scores, strict=True):
+ item.local_relevance = local_relevance(item, ranking_query)
+ item.freshness = freshness(item, freshness_mode)
+ item.engagement_score = eng_score
+ item.source_quality = source_quality(item.source)
+ item.local_rank_score = (
+ 0.65 * item.local_relevance
+ + 0.25 * (item.freshness / 100.0)
+ + 0.10 * ((eng_score or 0) / 100.0)
+ )
+ return sorted(items, key=lambda item: item.local_rank_score or 0, reverse=True)
+
+
+_SOCIAL_SOURCES = {"reddit", "x", "tiktok", "instagram", "bluesky", "truthsocial"}
+
+# Minimum view count for short-video platforms. Items below this floor
+# are typically spam reposts or low-effort clips that add no unique signal.
+_VIDEO_ENGAGEMENT_FLOOR_SOURCES = {"tiktok", "instagram"}
+_VIDEO_ENGAGEMENT_FLOOR_VIEWS = 1000
+
+
+def _passes_engagement_floor(item: schema.SourceItem, sole_source: bool) -> bool:
+ """Check whether a TikTok/Instagram item meets the minimum view floor.
+
+ Items from sources not in _VIDEO_ENGAGEMENT_FLOOR_SOURCES always pass.
+ If the item's source is the *only* source represented in the batch
+ (sole_source=True), all items pass so we never return an empty result
+ for a whole source.
+ """
+ if item.source not in _VIDEO_ENGAGEMENT_FLOOR_SOURCES:
+ return True
+ if sole_source:
+ return True
+ views = item.engagement.get("views", 0) if item.engagement else 0
+ return views >= _VIDEO_ENGAGEMENT_FLOOR_VIEWS
+
+
+def prune_low_relevance(
+ items: list[schema.SourceItem],
+ minimum: float = 0.15,
+) -> list[schema.SourceItem]:
+ """Drop weak lexical matches when stronger evidence exists.
+
+ Social-source items with zero engagement get a stricter threshold
+ because zero engagement on a social platform is a strong noise signal.
+
+ TikTok and Instagram items with fewer than 1000 views are pruned
+ (unless they are the only source represented in the batch).
+ """
+ sources_present = {item.source for item in items}
+
+ def passes(item: schema.SourceItem) -> bool:
+ rel = item.local_relevance if item.local_relevance is not None else 0.0
+ if rel < minimum:
+ return False
+ if item.source in _SOCIAL_SOURCES and (item.engagement_score is None or item.engagement_score == 0):
+ if rel < minimum * 1.5:
+ return False
+ sole_source = sources_present == {item.source}
+ if not _passes_engagement_floor(item, sole_source):
+ return False
+ return True
+
+ filtered = [item for item in items if passes(item)]
+ return filtered or items
diff --git a/scripts/lib/snippet.py b/scripts/lib/snippet.py
new file mode 100644
index 0000000..2d64f66
--- /dev/null
+++ b/scripts/lib/snippet.py
@@ -0,0 +1,50 @@
+"""Best-window extraction for rerankable evidence snippets."""
+
+from __future__ import annotations
+
+from . import relevance, schema
+
+
+def _truncate_words(text: str, max_words: int) -> str:
+ words = text.split()
+ if len(words) <= max_words:
+ return text.strip()
+ return " ".join(words[:max_words]).strip() + "..."
+
+
+def _windows(words: list[str], size: int, overlap: int) -> list[str]:
+ if not words:
+ return []
+ if len(words) <= size:
+ return [" ".join(words)]
+ step = max(1, size - overlap)
+ return [
+ " ".join(words[start:start + size])
+ for start in range(0, len(words), step)
+ ]
+
+
+def extract_best_snippet(
+ item: schema.SourceItem,
+ ranking_query: str,
+ max_words: int = 120,
+) -> str:
+ """Prefer existing snippets, else extract the best matching evidence window."""
+ preferred = item.snippet.strip()
+ if preferred:
+ return _truncate_words(preferred, max_words)
+
+ body = item.body.strip()
+ if not body:
+ return _truncate_words(item.title, max_words)
+
+ words = body.split()
+ candidates = _windows(words, size=min(max_words, 110), overlap=30)
+ if not candidates:
+ return _truncate_words(body, max_words)
+
+ best = max(
+ candidates,
+ key=lambda candidate: relevance.token_overlap_relevance(ranking_query, candidate),
+ )
+ return _truncate_words(best, max_words)
diff --git a/scripts/lib/threads.py b/scripts/lib/threads.py
new file mode 100644
index 0000000..32a3d0c
--- /dev/null
+++ b/scripts/lib/threads.py
@@ -0,0 +1,245 @@
+"""Threads keyword search via ScrapeCreators API for /last30days.
+
+Uses ScrapeCreators REST API to search Threads by keyword, extracting
+engagement metrics (likes, replies) from short text posts.
+
+Requires SCRAPECREATORS_API_KEY in config. Opt-in source via INCLUDE_SOURCES.
+API docs: https://scrapecreators.com/docs
+"""
+
+import math
+import re
+from datetime import datetime, timezone
+from typing import Any, Dict, List, Optional
+
+from . import http, log
+from .relevance import token_overlap_relevance as _compute_relevance
+
+SCRAPECREATORS_BASE = "https://api.scrapecreators.com/v1/threads"
+
+# Depth configurations: how many results to fetch
+DEPTH_CONFIG = {
+ "quick": {"results": 10},
+ "default": {"results": 20},
+ "deep": {"results": 40},
+}
+
+
+def _log(msg: str):
+ log.source_log("Threads", msg)
+
+
+def _sc_headers(token: str) -> Dict[str, str]:
+ """Build ScrapeCreators request headers."""
+ return {
+ "x-api-key": token,
+ "Content-Type": "application/json",
+ }
+
+
+def _extract_core_subject(topic: str) -> str:
+ """Extract core subject from verbose query for Threads search."""
+ from .query import extract_core_subject
+ _THREADS_NOISE = frozenset({
+ 'best', 'top', 'good', 'great', 'awesome',
+ 'latest', 'new', 'news', 'update', 'updates',
+ 'trending', 'hottest', 'popular', 'viral',
+ 'practices', 'features', 'recommendations', 'advice',
+ })
+ return extract_core_subject(topic, noise=_THREADS_NOISE)
+
+
+def _parse_date(item: Dict[str, Any]) -> Optional[str]:
+ """Parse date from Threads item to YYYY-MM-DD.
+
+ Tries common timestamp fields: taken_at (unix), created_at (ISO),
+ and falls back to any date-like string field.
+ """
+ # Unix timestamp (taken_at is common in Meta APIs)
+ for key in ("taken_at", "create_time"):
+ ts = item.get(key)
+ if ts:
+ try:
+ from . import dates
+ return dates.timestamp_to_date(int(ts))
+ except (ValueError, TypeError):
+ pass
+
+ # ISO 8601 string
+ for key in ("created_at", "published_at", "date"):
+ val = item.get(key)
+ if val and isinstance(val, str):
+ try:
+ dt = datetime.fromisoformat(val.replace("Z", "+00:00"))
+ return dt.strftime("%Y-%m-%d")
+ except (ValueError, TypeError):
+ pass
+
+ return None
+
+
+def _parse_items(raw_items: List[Dict[str, Any]], core_topic: str) -> List[Dict[str, Any]]:
+ """Parse raw Threads items into normalized dicts."""
+ items = []
+ for i, raw in enumerate(raw_items):
+ post_id = str(
+ raw.get("id")
+ or raw.get("pk")
+ or raw.get("code")
+ or f"TH{i + 1}"
+ )
+ text = raw.get("text") or raw.get("caption") or raw.get("content") or ""
+ if isinstance(text, dict):
+ text = text.get("text", "")
+
+ # Author extraction
+ user = raw.get("user") or raw.get("author") or {}
+ if isinstance(user, dict):
+ handle = user.get("username") or user.get("handle") or ""
+ display_name = user.get("full_name") or user.get("displayName") or handle
+ elif isinstance(user, str):
+ handle = user
+ display_name = user
+ else:
+ handle = ""
+ display_name = ""
+
+ # Engagement metrics
+ likes = raw.get("like_count") or raw.get("likes") or 0
+ replies = raw.get("reply_count") or raw.get("replies") or 0
+ reposts = raw.get("repost_count") or raw.get("reposts") or 0
+ quotes = raw.get("quote_count") or raw.get("quotes") or 0
+
+ date_str = _parse_date(raw)
+
+ # Build URL
+ code = raw.get("code") or raw.get("shortcode") or ""
+ url = raw.get("url") or raw.get("share_url") or ""
+ if not url and code:
+ url = f"https://www.threads.net/post/{code}"
+ elif not url and handle and post_id:
+ url = f"https://www.threads.net/@{handle}/post/{post_id}"
+
+ # Relevance: position-based + engagement boost (similar to bluesky)
+ rank_score = max(0.3, 1.0 - (i * 0.02))
+ engagement_boost = min(0.2, math.log1p(likes + reposts) / 40)
+ text_relevance = _compute_relevance(core_topic, text)
+ relevance = min(1.0, text_relevance * 0.5 + rank_score * 0.3 + engagement_boost + 0.1)
+
+ items.append({
+ "id": post_id,
+ "handle": handle,
+ "display_name": display_name,
+ "text": text,
+ "url": url,
+ "date": date_str,
+ "engagement": {
+ "likes": likes,
+ "replies": replies,
+ "reposts": reposts,
+ "quotes": quotes,
+ },
+ "relevance": round(relevance, 2),
+ "why_relevant": f"Threads: @{handle}: {text[:60]}" if text else f"Threads: {handle}",
+ })
+ return items
+
+
+def search_threads(
+ topic: str,
+ from_date: str,
+ to_date: str,
+ depth: str = "default",
+ token: str = None,
+) -> Dict[str, Any]:
+ """Search Threads via ScrapeCreators API.
+
+ Args:
+ topic: Search topic
+ from_date: Start date (YYYY-MM-DD)
+ to_date: End date (YYYY-MM-DD)
+ depth: 'quick', 'default', or 'deep'
+ token: ScrapeCreators API key
+
+ Returns:
+ Dict with 'items' list and optional 'error'.
+ """
+ if not token:
+ return {"items": [], "error": "No SCRAPECREATORS_API_KEY configured"}
+
+ config = DEPTH_CONFIG.get(depth, DEPTH_CONFIG["default"])
+ core_topic = _extract_core_subject(topic)
+
+ _log(f"Searching for '{core_topic}' (depth={depth}, limit={config['results']})")
+
+ try:
+ import requests as _requests
+ except ImportError:
+ _requests = None
+
+ if not _requests:
+ _log("requests library not installed, falling back to urllib")
+ try:
+ from urllib.parse import urlencode
+ params = urlencode({"keyword": core_topic})
+ url = f"{SCRAPECREATORS_BASE}/search?{params}"
+ headers = _sc_headers(token)
+ headers["User-Agent"] = http.USER_AGENT
+ data = http.get(url, headers=headers, timeout=30, retries=2)
+ except Exception as e:
+ _log(f"ScrapeCreators error (urllib): {e}")
+ return {"items": [], "error": f"{type(e).__name__}: {e}"}
+ else:
+ try:
+ resp = _requests.get(
+ f"{SCRAPECREATORS_BASE}/search",
+ params={"keyword": core_topic},
+ headers=_sc_headers(token),
+ timeout=30,
+ )
+ resp.raise_for_status()
+ data = resp.json()
+ except Exception as e:
+ _log(f"ScrapeCreators error: {e}")
+ return {"items": [], "error": f"{type(e).__name__}: {e}"}
+
+ # Extract items from response (try common SC response shapes)
+ raw_items = (
+ data.get("items")
+ or data.get("data")
+ or data.get("threads")
+ or data.get("posts")
+ or data.get("search_results")
+ or []
+ )
+
+ # Limit to configured count
+ raw_items = raw_items[:config["results"]]
+
+ # Parse items
+ items = _parse_items(raw_items, core_topic)
+
+ # Date filter
+ in_range = [i for i in items if i["date"] and from_date <= i["date"] <= to_date]
+ out_of_range = len(items) - len(in_range)
+ if in_range:
+ items = in_range
+ if out_of_range:
+ _log(f"Filtered {out_of_range} posts outside date range")
+ else:
+ _log(f"No posts within date range, keeping all {len(items)}")
+
+ # Sort by engagement (likes) descending
+ items.sort(key=lambda x: x["engagement"]["likes"], reverse=True)
+
+ _log(f"Found {len(items)} Threads posts")
+ return {"items": items}
+
+
+def parse_threads_response(response: Dict[str, Any]) -> List[Dict[str, Any]]:
+ """Parse Threads search response to normalized format.
+
+ Returns:
+ List of item dicts ready for normalization.
+ """
+ return response.get("items", [])
diff --git a/scripts/lib/tiktok.py b/scripts/lib/tiktok.py
index c079b23..64faa21 100644
--- a/scripts/lib/tiktok.py
+++ b/scripts/lib/tiktok.py
@@ -9,7 +9,6 @@ API docs: https://scrapecreators.com/docs
import re
import sys
-from datetime import datetime, timezone
from typing import Any, Dict, List, Optional, Set
try:
@@ -17,7 +16,7 @@ try:
except ImportError:
_requests = None
-from . import http
+from . import dates, http, log
SCRAPECREATORS_BASE = "https://api.scrapecreators.com/v1/tiktok"
@@ -49,11 +48,65 @@ def _extract_core_subject(topic: str) -> str:
return extract_core_subject(topic, noise=_TIKTOK_NOISE)
+def _infer_query_intent(topic: str) -> str:
+ """Tiny local intent classifier for TikTok query expansion."""
+ text = topic.lower().strip()
+ if re.search(r"\b(vs|versus|compare|difference between)\b", text):
+ return "comparison"
+ if re.search(r"\b(how to|tutorial|guide|setup|step by step|deploy|install)\b", text):
+ return "how_to"
+ if re.search(r"\b(thoughts on|worth it|should i|opinion|review)\b", text):
+ return "opinion"
+ if re.search(r"\b(pricing|feature|features|best .* for)\b", text):
+ return "product"
+ return "breaking_news"
+
+
+def expand_tiktok_queries(topic: str, depth: str) -> List[str]:
+ """Generate multiple TikTok search queries from a topic.
+
+ Mirrors reddit.py's expand_reddit_queries() pattern:
+ 1. Extract core subject (strip noise words)
+ 2. Include original topic if different from core
+ 3. Add intent-specific OR-joined content-type variants
+ 4. Cap by depth: 1 for quick, 2 for default, 3 for deep
+
+ Returns 1-3 query strings depending on depth.
+ """
+ core = _extract_core_subject(topic)
+ queries = [core]
+
+ # Include cleaned original topic as variant if different from core
+ original_clean = topic.strip().rstrip('?!.')
+ if core.lower() != original_clean.lower() and len(original_clean.split()) <= 8:
+ queries.append(original_clean)
+
+ qtype = _infer_query_intent(topic)
+
+ # Intent-specific TikTok content-type variants
+ if qtype in ("breaking_news", "opinion"):
+ queries.append(f"{core} edit OR reaction OR trend")
+ elif qtype == "product":
+ queries.append(f"{core} review OR haul OR unboxing")
+ elif qtype == "comparison":
+ queries.append(f"{core} vs OR compared OR which is better")
+ elif qtype == "how_to":
+ queries.append(f"{core} tutorial OR hack OR tip")
+ else:
+ queries.append(f"{core} edit OR reaction OR trend")
+
+ # Deep depth: add viral content variant
+ if depth == "deep":
+ queries.append(f"{core} viral OR fyp OR trending")
+
+ # Cap by depth budget
+ caps = {"quick": 1, "default": 2, "deep": 3}
+ cap = caps.get(depth, 2)
+ return queries[:cap]
+
+
def _log(msg: str):
- """Log to stderr (only in interactive terminals; spinner handles non-TTY)."""
- if sys.stderr.isatty():
- sys.stderr.write(f"[TikTok] {msg}\n")
- sys.stderr.flush()
+ log.source_log("TikTok", msg)
def _sc_headers(token: str) -> Dict[str, str]:
@@ -65,18 +118,13 @@ def _sc_headers(token: str) -> Dict[str, str]:
def _parse_date(item: Dict[str, Any]) -> Optional[str]:
- """Parse date from ScrapeCreators TikTok item to YYYY-MM-DD.
-
- Handles create_time (unix timestamp).
- """
+ """Parse date from ScrapeCreators TikTok item to YYYY-MM-DD."""
ts = item.get("create_time")
if ts:
try:
- dt = datetime.fromtimestamp(int(ts), tz=timezone.utc)
- return dt.strftime("%Y-%m-%d")
- except (ValueError, TypeError, OSError):
+ return dates.timestamp_to_date(int(ts))
+ except (ValueError, TypeError):
pass
-
return None
@@ -100,6 +148,157 @@ def _clean_webvtt(text: str) -> str:
return ' '.join(cleaned)
+def _parse_items(raw_items: List[Dict[str, Any]], core_topic: str) -> List[Dict[str, Any]]:
+ """Parse raw TikTok items into normalized dicts."""
+ items = []
+ for raw in raw_items:
+ video_id = str(raw.get("aweme_id", ""))
+ text = raw.get("desc", "")
+
+ stats = raw.get("statistics") if isinstance(raw.get("statistics"), dict) else {}
+ play_count = stats.get("play_count") if stats.get("play_count") is not None else 0
+ digg_count = stats.get("digg_count") if stats.get("digg_count") is not None else 0
+ comment_count = stats.get("comment_count") if stats.get("comment_count") is not None else 0
+ share_count = stats.get("share_count") if stats.get("share_count") is not None else 0
+
+ author_raw = raw.get("author")
+ if isinstance(author_raw, dict):
+ author_name = author_raw.get("unique_id", "")
+ elif isinstance(author_raw, str):
+ author_name = author_raw
+ else:
+ author_name = ""
+
+ share_url = raw.get("share_url", "")
+ text_extra = raw.get("text_extra") or []
+ hashtag_names = [t.get("hashtag_name", "") for t in text_extra
+ if isinstance(t, dict) and t.get("hashtag_name")]
+
+ video_raw = raw.get("video")
+ duration = video_raw.get("duration") if isinstance(video_raw, dict) else None
+
+ date_str = _parse_date(raw)
+
+ # Compute relevance with hashtag boost
+ relevance = _compute_relevance(core_topic, text, hashtag_names)
+
+ # Build URL: prefer share_url, fallback to constructed URL
+ url = share_url.split("?")[0] if share_url else ""
+ if not url and author_name and video_id:
+ url = f"https://www.tiktok.com/@{author_name}/video/{video_id}"
+
+ items.append({
+ "video_id": video_id,
+ "text": text,
+ "url": url,
+ "author_name": author_name,
+ "date": date_str,
+ "engagement": {
+ "views": play_count,
+ "likes": digg_count,
+ "comments": comment_count,
+ "shares": share_count,
+ },
+ "hashtags": hashtag_names,
+ "duration": duration,
+ "relevance": relevance,
+ "why_relevant": f"TikTok: {text[:60]}" if text else f"TikTok: {core_topic}",
+ "caption_snippet": "", # populated by fetch_captions
+ })
+ return items
+
+
+def _hashtag_search(
+ hashtag: str,
+ token: str,
+) -> List[Dict[str, Any]]:
+ """Search TikTok by hashtag via ScrapeCreators.
+
+ Args:
+ hashtag: Hashtag name (without #)
+ token: ScrapeCreators API key
+
+ Returns:
+ List of raw TikTok item dicts (aweme_info format).
+ """
+ _log(f"Hashtag search: #{hashtag}")
+ if not _requests:
+ try:
+ from urllib.parse import urlencode
+ params = urlencode({"hashtag": hashtag})
+ url = f"{SCRAPECREATORS_BASE}/search/hashtag?{params}"
+ headers = _sc_headers(token)
+ headers["User-Agent"] = http.USER_AGENT
+ data = http.get(url, headers=headers, timeout=30, retries=2)
+ except Exception as e:
+ _log(f"Hashtag search error (urllib) for #{hashtag}: {e}")
+ return []
+ else:
+ try:
+ resp = _requests.get(
+ f"{SCRAPECREATORS_BASE}/search/hashtag",
+ params={"hashtag": hashtag},
+ headers=_sc_headers(token),
+ timeout=30,
+ )
+ resp.raise_for_status()
+ data = resp.json()
+ except Exception as e:
+ _log(f"Hashtag search error for #{hashtag}: {e}")
+ return []
+
+ raw_items = data.get("aweme_list") or data.get("data") or []
+ _log(f" -> {len(raw_items)} results for #{hashtag}")
+ return raw_items
+
+
+def _profile_videos(
+ handle: str,
+ token: str,
+ count: int = 10,
+) -> List[Dict[str, Any]]:
+ """Fetch a TikTok creator's recent videos via ScrapeCreators.
+
+ Args:
+ handle: TikTok username (without @)
+ token: ScrapeCreators API key
+ count: Max videos to return
+
+ Returns:
+ List of raw TikTok item dicts (aweme_info format).
+ """
+ _log(f"Profile videos: @{handle}")
+ profile_url = "https://api.scrapecreators.com/v3/tiktok/profile/videos"
+ if not _requests:
+ try:
+ from urllib.parse import urlencode
+ params = urlencode({"handle": handle, "sort_by": "latest"})
+ url = f"{profile_url}?{params}"
+ headers = _sc_headers(token)
+ headers["User-Agent"] = http.USER_AGENT
+ data = http.get(url, headers=headers, timeout=30, retries=2)
+ except Exception as e:
+ _log(f"Profile videos error (urllib) for @{handle}: {e}")
+ return []
+ else:
+ try:
+ resp = _requests.get(
+ profile_url,
+ params={"handle": handle, "sort_by": "latest"},
+ headers=_sc_headers(token),
+ timeout=30,
+ )
+ resp.raise_for_status()
+ data = resp.json()
+ except Exception as e:
+ _log(f"Profile videos error for @{handle}: {e}")
+ return []
+
+ raw_items = data.get("aweme_list") or data.get("data") or []
+ _log(f" -> {len(raw_items)} videos from @{handle}")
+ return raw_items[:count]
+
+
def search_tiktok(
topic: str,
from_date: str,
@@ -165,51 +364,7 @@ def search_tiktok(
raw_items = raw_items[:config["results_per_page"]]
# Parse items
- items = []
- for raw in raw_items:
- video_id = str(raw.get("aweme_id", ""))
- text = raw.get("desc", "")
- stats = raw.get("statistics") or {}
- play_count = stats.get("play_count") or 0
- digg_count = stats.get("digg_count") or 0
- comment_count = stats.get("comment_count") or 0
- share_count = stats.get("share_count") or 0
- author = raw.get("author") or {}
- author_name = author.get("unique_id", "")
- share_url = raw.get("share_url", "")
- text_extra = raw.get("text_extra") or []
- hashtag_names = [t.get("hashtag_name", "") for t in text_extra
- if isinstance(t, dict) and t.get("hashtag_name")]
- duration = (raw.get("video") or {}).get("duration")
-
- date_str = _parse_date(raw)
-
- # Compute relevance with hashtag boost
- relevance = _compute_relevance(core_topic, text, hashtag_names)
-
- # Build URL: prefer share_url, fallback to constructed URL
- url = share_url.split("?")[0] if share_url else ""
- if not url and author_name and video_id:
- url = f"https://www.tiktok.com/@{author_name}/video/{video_id}"
-
- items.append({
- "video_id": video_id,
- "text": text,
- "url": url,
- "author_name": author_name,
- "date": date_str,
- "engagement": {
- "views": play_count,
- "likes": digg_count,
- "comments": comment_count,
- "shares": share_count,
- },
- "hashtags": hashtag_names,
- "duration": duration,
- "relevance": relevance,
- "why_relevant": f"TikTok: {text[:60]}" if text else f"TikTok: {core_topic}",
- "caption_snippet": "", # populated by fetch_captions
- })
+ items = _parse_items(raw_items, core_topic)
# Hard date filter
in_range = [i for i in items if i["date"] and from_date <= i["date"] <= to_date]
@@ -307,25 +462,70 @@ def search_and_enrich(
to_date: str,
depth: str = "default",
token: str = None,
+ hashtags: List[str] | None = None,
+ creators: List[str] | None = None,
) -> Dict[str, Any]:
"""Full TikTok search: find videos, then fetch captions for top results.
+ Uses expand_tiktok_queries() to generate multiple search queries,
+ runs ScrapeCreators for each, and merges/deduplicates results by video ID.
+
Args:
- topic: Search topic
+ topic: Search topic (raw topic, not planner's narrowed query)
from_date: Start date (YYYY-MM-DD)
to_date: End date (YYYY-MM-DD)
depth: 'quick', 'default', or 'deep'
token: ScrapeCreators API key
+ hashtags: Optional list of TikTok hashtags to search (without #)
+ creators: Optional list of TikTok creator handles to fetch videos from
Returns:
Dict with 'items' list. Each item has a 'caption_snippet' field.
"""
- # Step 1: Search
- search_result = search_tiktok(topic, from_date, to_date, depth, token)
- items = search_result.get("items", [])
+ core_topic = _extract_core_subject(topic)
+ seen_ids: Set[str] = set()
+ items: List[Dict[str, Any]] = []
+ last_error = None
+
+ # Step 0a: Hashtag search (high-signal, runs first)
+ if hashtags and token:
+ for hashtag in hashtags:
+ raw_items = _hashtag_search(hashtag, token)
+ parsed = _parse_items(raw_items, core_topic)
+ for item in parsed:
+ vid = item.get("video_id", "")
+ if vid and vid not in seen_ids:
+ seen_ids.add(vid)
+ items.append(item)
+
+ # Step 0b: Creator profile videos (high-signal)
+ if creators and token:
+ for creator in creators:
+ raw_items = _profile_videos(creator, token)
+ parsed = _parse_items(raw_items, core_topic)
+ for item in parsed:
+ vid = item.get("video_id", "")
+ if vid and vid not in seen_ids:
+ seen_ids.add(vid)
+ items.append(item)
+
+ # Step 1: Multi-query keyword search — run ScrapeCreators for each expanded query
+ queries = expand_tiktok_queries(topic, depth)
+ for q in queries:
+ search_result = search_tiktok(q, from_date, to_date, depth, token)
+ if search_result.get("error"):
+ last_error = search_result["error"]
+ for item in search_result.get("items", []):
+ vid = item.get("video_id", "")
+ if vid and vid not in seen_ids:
+ seen_ids.add(vid)
+ items.append(item)
+
+ # Sort merged results by views descending
+ items.sort(key=lambda x: x.get("engagement", {}).get("views", 0), reverse=True)
if not items:
- return search_result
+ return {"items": [], "error": last_error}
# Step 2: Fetch captions for top N
captions = fetch_captions(items, token, depth)
@@ -337,7 +537,7 @@ def search_and_enrich(
if caption:
item["caption_snippet"] = caption
- return {"items": items, "error": search_result.get("error")}
+ return {"items": items, "error": last_error}
def parse_tiktok_response(response: Dict[str, Any]) -> List[Dict[str, Any]]:
diff --git a/scripts/lib/truthsocial.py b/scripts/lib/truthsocial.py
index 5aa1b76..22070a5 100644
--- a/scripts/lib/truthsocial.py
+++ b/scripts/lib/truthsocial.py
@@ -9,7 +9,7 @@ import re
import sys
from typing import Any, Dict, List, Optional
-from . import http
+from . import http, log
TRUTHSOCIAL_SEARCH_URL = "https://truthsocial.com/api/v2/search"
@@ -21,10 +21,7 @@ DEPTH_CONFIG = {
def _log(msg: str):
- """Log to stderr (only in TTY mode to avoid cluttering Claude Code output)."""
- if sys.stderr.isatty():
- sys.stderr.write(f"[TruthSocial] {msg}\n")
- sys.stderr.flush()
+ log.source_log("TruthSocial", msg)
def _strip_html(html: str) -> str:
@@ -36,26 +33,14 @@ def _strip_html(html: str) -> str:
def _extract_core_subject(topic: str) -> str:
"""Extract core subject from verbose query for Truth Social search."""
- text = topic.lower().strip()
- prefixes = [
- 'what are the best', 'what is the best', 'what are the latest',
- 'what are people saying about', 'what do people think about',
- 'how do i use', 'how to use', 'how to',
- 'what are', 'what is', 'tips for', 'best practices for',
- ]
- for p in prefixes:
- if text.startswith(p + ' '):
- text = text[len(p):].strip()
- noise = {
+ from .query import extract_core_subject
+ _TS_NOISE = frozenset({
'best', 'top', 'good', 'great', 'awesome',
'latest', 'new', 'news', 'update', 'updates',
'trending', 'hottest', 'popular', 'viral',
'practices', 'features', 'recommendations', 'advice',
- }
- words = text.split()
- filtered = [w for w in words if w not in noise]
- result = ' '.join(filtered) if filtered else text
- return result.rstrip('?!.')
+ })
+ return extract_core_subject(topic, noise=_TS_NOISE)
def _parse_date(status: Dict[str, Any]) -> Optional[str]:
diff --git a/scripts/lib/ui.py b/scripts/lib/ui.py
index d655fee..a6c9476 100644
--- a/scripts/lib/ui.py
+++ b/scripts/lib/ui.py
@@ -112,13 +112,68 @@ WEB_ONLY_MESSAGES = [
"Discovering tutorials...",
]
+SOURCE_COMPLETION_ORDER = [
+ "reddit",
+ "x",
+ "youtube",
+ "tiktok",
+ "instagram",
+ "hackernews",
+ "bluesky",
+ "truthsocial",
+ "polymarket",
+ "grounding",
+ "xiaohongshu",
+]
+
+SOURCE_COMPLETION_META = {
+ "reddit": ("Reddit", "thread", "threads", Colors.YELLOW),
+ "x": ("X", "post", "posts", Colors.CYAN),
+ "youtube": ("YouTube", "video", "videos", Colors.RED),
+ "tiktok": ("TikTok", "video", "videos", Colors.PURPLE),
+ "instagram": ("Instagram", "reel", "reels", Colors.PURPLE),
+ "hackernews": ("HN", "story", "stories", Colors.YELLOW),
+ "bluesky": ("Bluesky", "post", "posts", Colors.BLUE),
+ "truthsocial": ("Truth Social", "post", "posts", Colors.CYAN),
+ "polymarket": ("Polymarket", "market", "markets", Colors.GREEN),
+ "grounding": ("Web", "result", "results", Colors.GREEN),
+ "xiaohongshu": ("Xiaohongshu", "post", "posts", Colors.RED),
+}
+
+
+def _completion_sources(source_counts: dict[str, int], display_sources: list[str] | None) -> list[str]:
+ requested = list(dict.fromkeys(display_sources or []))
+ if not requested:
+ requested = [source for source, count in source_counts.items() if count]
+ if not requested and source_counts:
+ requested = list(source_counts)
+
+ candidate_set = set(requested) | set(source_counts)
+ ordered = [source for source in SOURCE_COMPLETION_ORDER if source in candidate_set]
+ for source in requested + list(source_counts):
+ if source in candidate_set and source not in ordered:
+ ordered.append(source)
+ return ordered
+
+
+def _format_completion_part(source: str, count: int, tty: bool) -> str:
+ label, singular, plural, color = SOURCE_COMPLETION_META.get(
+ source,
+ (source.replace("_", " ").title(), "result", "results", Colors.RESET),
+ )
+ unit = singular if count == 1 else plural
+ if tty:
+ return f"{color}{label}:{Colors.RESET} {count} {unit}"
+ return f"{label}: {count} {unit}"
+
def _build_nux_message(diag: dict = None) -> str:
"""Build conversational NUX message with dynamic source status."""
+ available = set((diag or {}).get("available_sources", []))
if diag:
- reddit = "✓" if diag.get("openai") else "✗"
- x = "✓" if diag.get("x_source") else "✗"
- youtube = "✓" if diag.get("youtube") else "✗"
- web = "✓" if diag.get("web_search_backend") else "✗"
+ reddit = "✓" if "reddit" in available else "✗"
+ x = "✓" if "x" in available else "✗"
+ youtube = "✓" if "youtube" in available else "✗"
+ web = "✓" if "grounding" in available else "✗"
status_line = f"Reddit {reddit}, X {x}, YouTube {youtube}, Web {web}"
else:
status_line = "YouTube ✓, Web ✓, Reddit ✗, X ✗"
@@ -128,12 +183,11 @@ I just researched that for you. Here's what I've got right now:
{status_line}
-You can unlock more sources with API keys or by signing in to Codex — just ask me how and I'll walk you through it. More sources means better research, but it works fine as-is.
+More sources means better research, but it works fine as-is. You can unlock more for free - log into x.com in your browser for X, and run `brew install yt-dlp` for YouTube transcripts. That gives you Reddit (with comments), X, YouTube, HN, and Polymarket - all free.
Some examples of what you can do:
- "last30 what are people saying about Figma"
- "last30 watch my biggest competitor every week"
-- "last30 watch Peter Steinberger every 30 days"
- "last30 watch AI video tools monthly"
- "last30 what have you found about AI video?"
@@ -142,8 +196,9 @@ Just start with "last30" and talk to me like normal.
# Shorter promo for single missing key
PROMO_SINGLE_KEY = {
- "reddit": "\n💡 You can unlock Reddit with an OpenAI API key or by running `codex login` — just ask me how.\n",
- "x": "\n💡 You can unlock X with AUTH_TOKEN/CT0 or XAI_API_KEY - just ask me how.\n",
+ "reddit": "\n💡 Unlock TikTok and Instagram with SCRAPECREATORS_API_KEY - 10,000 free calls, no CC - scrapecreators.com\n",
+ "x": "\n💡 Unlock X: log into x.com in Firefox or Safari, then re-run. Or add AUTH_TOKEN/CT0 or XAI_API_KEY.\n",
+ "web": "\n💡 You can unlock native grounded web search with BRAVE_API_KEY or SERPER_API_KEY.\n",
}
# Bird auth help (for local users with vendored Bird CLI)
@@ -328,36 +383,46 @@ class ProgressDisplay:
if self.spinner:
self.spinner.stop()
- def show_complete(self, reddit_count: int, x_count: int, youtube_count: int = 0, hn_count: int = 0, pm_count: int = 0, tiktok_count: int = 0, ig_count: int = 0):
+ def show_complete(
+ self,
+ reddit_count: int = 0,
+ x_count: int = 0,
+ youtube_count: int = 0,
+ hn_count: int = 0,
+ pm_count: int = 0,
+ tiktok_count: int = 0,
+ ig_count: int = 0,
+ *,
+ source_counts: dict[str, int] | None = None,
+ display_sources: list[str] | None = None,
+ ):
elapsed = time.time() - self.start_time
+ if source_counts is None:
+ source_counts = {
+ "reddit": reddit_count,
+ "x": x_count,
+ "youtube": youtube_count,
+ "tiktok": tiktok_count,
+ "instagram": ig_count,
+ "hackernews": hn_count,
+ "polymarket": pm_count,
+ }
+ if display_sources is None:
+ display_sources = [source for source, count in source_counts.items() if count]
+ if not display_sources:
+ display_sources = ["reddit", "x"]
+
+ ordered_sources = _completion_sources(source_counts, display_sources)
+ parts = [
+ _format_completion_part(source, source_counts.get(source, 0), tty=IS_TTY)
+ for source in ordered_sources
+ ]
if IS_TTY:
sys.stderr.write(f"\n{Colors.GREEN}{Colors.BOLD}✓ Research complete{Colors.RESET} ")
sys.stderr.write(f"{Colors.DIM}({elapsed:.1f}s){Colors.RESET}\n")
- sys.stderr.write(f" {Colors.YELLOW}Reddit:{Colors.RESET} {reddit_count} threads ")
- sys.stderr.write(f"{Colors.CYAN}X:{Colors.RESET} {x_count} posts")
- if youtube_count:
- sys.stderr.write(f" {Colors.RED}YouTube:{Colors.RESET} {youtube_count} videos")
- if tiktok_count:
- sys.stderr.write(f" {Colors.PURPLE}TikTok:{Colors.RESET} {tiktok_count} videos")
- if ig_count:
- sys.stderr.write(f" {Colors.PURPLE}Instagram:{Colors.RESET} {ig_count} reels")
- if hn_count:
- sys.stderr.write(f" {Colors.YELLOW}HN:{Colors.RESET} {hn_count} stories")
- if pm_count:
- sys.stderr.write(f" {Colors.GREEN}Polymarket:{Colors.RESET} {pm_count} markets")
+ sys.stderr.write(" " + " ".join(parts))
sys.stderr.write("\n\n")
else:
- parts = [f"Reddit: {reddit_count} threads", f"X: {x_count} posts"]
- if youtube_count:
- parts.append(f"YouTube: {youtube_count} videos")
- if tiktok_count:
- parts.append(f"TikTok: {tiktok_count} videos")
- if ig_count:
- parts.append(f"Instagram: {ig_count} reels")
- if hn_count:
- parts.append(f"HN: {hn_count} stories")
- if pm_count:
- parts.append(f"Polymarket: {pm_count} markets")
sys.stderr.write(f"✓ Research complete ({elapsed:.1f}s) - {', '.join(parts)}\n")
sys.stderr.flush()
@@ -417,190 +482,115 @@ class ProgressDisplay:
sys.stderr.flush()
-def _build_status_banner(diag: dict) -> list[str]:
- """Build the status banner lines (plain text, no ANSI).
-
- Returns a list of strings, each being a line of the banner box.
-
- Args:
- diag: Dict with keys:
- setup_complete, reddit_source, x_source, x_method,
- youtube, tiktok, instagram, hackernews, polymarket,
- bluesky, truthsocial, xiaohongshu, scrapecreators,
- web_search_backend
- """
- setup_complete = diag.get("setup_complete", False)
- has_sc = diag.get("scrapecreators", False)
-
- # --- Build active sources list: (label, method_label) ---
- active: list[str] = []
-
- # Reddit — always available; what matters to users is comments or not
- reddit_src = diag.get("reddit_source")
- if reddit_src == "scrapecreators":
- active.append("Reddit (with comments)")
- else:
- active.append("Reddit (threads only)")
-
- # X/Twitter
- x_source = diag.get("x_source")
- x_method = diag.get("x_method")
- if x_source:
- if x_method and x_method.startswith("browser-"):
- browser = x_method.split("-", 1)[1].capitalize()
- active.append(f"X ({browser})")
- elif x_method == "env":
- active.append("X (env)")
- elif x_method == "api":
- active.append("X (xAI)")
- else:
- active.append("X")
-
- # YouTube
- if diag.get("youtube"):
- active.append("YouTube")
-
- # HN — always available
- if diag.get("hackernews"):
- active.append("HN")
-
- # Polymarket — always available
- if diag.get("polymarket"):
- active.append("Polymarket")
-
- # TikTok (requires SC or Apify)
- if diag.get("tiktok"):
- active.append("TikTok")
-
- # Instagram (requires SC)
- if diag.get("instagram"):
- active.append("Instagram")
-
- # Bluesky
- if diag.get("bluesky"):
- active.append("Bluesky")
-
- # Truth Social
- if diag.get("truthsocial"):
- active.append("Truth Social")
-
- # Xiaohongshu
- if diag.get("xiaohongshu"):
- active.append("Xiaohongshu")
-
- # --- Format active sources into wrapped lines ---
- BOX_INNER = 53 # characters inside the box (between │ and │)
- PREFIX = " " # 2-space indent inside box
-
- def _wrap_sources(sources: list[str]) -> list[str]:
- """Wrap source labels into lines that fit the box width."""
- result_lines: list[str] = []
- current = PREFIX
- for i, s in enumerate(sources):
- token = f"✅ {s}"
- sep = " " if current != PREFIX else ""
- if len(current) + len(sep) + len(token) > BOX_INNER:
- result_lines.append(current)
- current = PREFIX + token
- else:
- current += sep + token
- if current.strip():
- result_lines.append(current)
- return result_lines
-
- source_lines = _wrap_sources(active)
-
- # --- Title ---
- if not setup_complete:
- title = "/last30days v3.0 — First Run"
- else:
- title = "/last30days v3.0 — Source Status"
-
- # --- Build upgrade suggestions ---
- suggestions: list[str] = []
-
- if not setup_complete:
- suggestions.append("Run /last30days setup to unlock more sources")
- else:
- # Recommend ScrapeCreators if missing
- if not has_sc:
- suggestions.append("⭐ Add SCRAPECREATORS_API_KEY for Reddit comments")
- suggestions.append(" + TikTok + Instagram")
- suggestions.append(" 100 free calls, no CC — scrapecreators.com (no affiliation)")
-
- # --- Assemble box lines ---
- # Collect all content lines, then determine box width dynamically.
- content: list[str] = []
- content.append(f" {title}")
- content.append("") # blank line
-
- for sl in source_lines:
- content.append(sl)
-
- if suggestions:
- content.append("") # blank line
- for sg in suggestions:
- content.append(f" {sg}")
-
- content.append("") # blank line
- content.append(" Config: ~/.config/last30days/.env")
-
- # Width = widest content line + 1 for right margin
- width = max(len(line) for line in content) + 1
- if width < 53:
- width = 53
-
- lines: list[str] = []
- lines.append("\u250c" + "\u2500" * width + "\u2510")
- for c in content:
- lines.append("\u2502" + c.ljust(width) + "\u2502")
- lines.append("\u2514" + "\u2500" * width + "\u2518")
-
- return lines
-
-
-def _colorize_banner(lines: list[str]) -> list[str]:
- """Apply ANSI colors to plain-text banner lines for TTY output."""
- colored: list[str] = []
- for line in lines:
- if line.startswith("\u250c") or line.startswith("\u2514"):
- colored.append(f"{Colors.DIM}{line}{Colors.RESET}")
- elif line.startswith("\u2502"):
- inner = line[1:-1] # strip box chars on both sides
- inner_width = len(inner)
- # Colorize check marks green, star yellow
- inner = inner.replace("\u2705", f"{Colors.GREEN}\u2705{Colors.RESET}")
- inner = inner.replace("\u2b50", f"{Colors.YELLOW}\u2b50{Colors.RESET}")
- # Bold the title line
- if "/last30days v3.0" in inner:
- stripped = inner.strip()
- inner = f" {Colors.BOLD}{stripped}{Colors.RESET}"
- # Re-pad to original width (ANSI codes are zero-width)
- visible_len = 1 + len(stripped)
- inner = inner + " " * max(0, inner_width - visible_len)
- colored.append(f"{Colors.DIM}\u2502{Colors.RESET}{inner}{Colors.DIM}\u2502{Colors.RESET}")
- else:
- colored.append(line)
- return colored
-
-
def show_diagnostic_banner(diag: dict):
- """Show pre-flight source status banner.
-
- Free-first design: leads with what's working (✅), not what's broken.
- Shows upgrade suggestions only when relevant.
+ """Show pre-flight source status banner when sources are missing.
Args:
- diag: Dict with keys:
- setup_complete, reddit_source, x_source, x_method,
- youtube, tiktok, instagram, hackernews, polymarket,
- bluesky, truthsocial, xiaohongshu, scrapecreators,
- web_search_backend
+ diag: Dict from pipeline.diagnose() with available_sources, x_backend,
+ bird status, provider availability, and native web backend info.
"""
- lines = _build_status_banner(diag)
+ available_sources = set(diag.get("available_sources") or [])
+ has_reddit = "reddit" in available_sources
+ has_scrapecreators = diag.get("has_scrapecreators", False)
+ has_x = "x" in available_sources
+ has_youtube = "youtube" in available_sources
+ has_web = "grounding" in available_sources
+ has_xiaohongshu = "xiaohongshu" in available_sources
+ x_backend = diag.get("x_backend")
+ native_web_backend = diag.get("native_web_backend")
+
+ # If everything is available, no banner needed
+ if has_reddit and has_x and has_youtube and has_web:
+ return
+
+ lines = []
if IS_TTY:
- lines = _colorize_banner(lines)
+ lines.append(f"{Colors.DIM}┌─────────────────────────────────────────────────────┐{Colors.RESET}")
+ lines.append(f"{Colors.DIM}│{Colors.RESET} {Colors.BOLD}/last30days v3.0.0 - Source Status{Colors.RESET} {Colors.DIM}│{Colors.RESET}")
+ lines.append(f"{Colors.DIM}│{Colors.RESET} {Colors.DIM}│{Colors.RESET}")
+
+ # Reddit
+ if has_reddit and has_scrapecreators:
+ lines.append(f"{Colors.DIM}│{Colors.RESET} {Colors.GREEN}✅ Reddit{Colors.RESET} — full threads with comments {Colors.DIM}│{Colors.RESET}")
+ elif has_reddit:
+ lines.append(f"{Colors.DIM}│{Colors.RESET} {Colors.GREEN}✅ Reddit{Colors.RESET} — public threads (titles + scores) {Colors.DIM}│{Colors.RESET}")
+ else:
+ lines.append(f"{Colors.DIM}│{Colors.RESET} {Colors.RED}❌ Reddit{Colors.RESET} — unavailable {Colors.DIM}│{Colors.RESET}")
+
+ # X/Twitter
+ if has_x:
+ username = diag.get("bird_username", "")
+ label = f"Bird ({username})" if x_backend == "bird" and username else str(x_backend or "xai").upper()
+ lines.append(f"{Colors.DIM}│{Colors.RESET} {Colors.GREEN}✅ X/Twitter{Colors.RESET} — {label} {Colors.DIM}│{Colors.RESET}")
+ else:
+ lines.append(f"{Colors.DIM}│{Colors.RESET} {Colors.RED}❌ X/Twitter{Colors.RESET} — No X auth or fallback key {Colors.DIM}│{Colors.RESET}")
+ if diag.get("bird_installed"):
+ lines.append(f"{Colors.DIM}│{Colors.RESET} └─ Add AUTH_TOKEN/CT0 or XAI_API_KEY {Colors.DIM}│{Colors.RESET}")
+ else:
+ lines.append(f"{Colors.DIM}│{Colors.RESET} └─ Needs Node.js 22+ (Bird is bundled) {Colors.DIM}│{Colors.RESET}")
+
+ # YouTube
+ if has_youtube:
+ lines.append(f"{Colors.DIM}│{Colors.RESET} {Colors.GREEN}✅ YouTube{Colors.RESET} — yt-dlp found {Colors.DIM}│{Colors.RESET}")
+ else:
+ lines.append(f"{Colors.DIM}│{Colors.RESET} {Colors.RED}❌ YouTube{Colors.RESET} — yt-dlp not installed {Colors.DIM}│{Colors.RESET}")
+ lines.append(f"{Colors.DIM}│{Colors.RESET} └─ Fix: brew install yt-dlp (free) {Colors.DIM}│{Colors.RESET}")
+
+ # Xiaohongshu (only show when configured)
+ if has_xiaohongshu:
+ lines.append(f"{Colors.DIM}│{Colors.RESET} {Colors.GREEN}✅ Xiaohongshu{Colors.RESET} — API connected + logged in {Colors.DIM}│{Colors.RESET}")
+
+ # Web
+ if has_web:
+ backend = native_web_backend or "native"
+ lines.append(f"{Colors.DIM}│{Colors.RESET} {Colors.GREEN}✅ Web{Colors.RESET} — {backend} API {Colors.DIM}│{Colors.RESET}")
+ else:
+ lines.append(f"{Colors.DIM}│{Colors.RESET} {Colors.YELLOW}⚡ Web{Colors.RESET} — Add BRAVE_API_KEY or SERPER_API_KEY {Colors.DIM}│{Colors.RESET}")
+
+ lines.append(f"{Colors.DIM}│{Colors.RESET} {Colors.DIM}│{Colors.RESET}")
+ lines.append(f"{Colors.DIM}│{Colors.RESET} Config: {Colors.BOLD}~/.config/last30days/.env{Colors.RESET} {Colors.DIM}│{Colors.RESET}")
+ lines.append(f"{Colors.DIM}└─────────────────────────────────────────────────────┘{Colors.RESET}")
+ else:
+ # Plain text for non-TTY (Claude Code / Codex)
+ lines.append("┌─────────────────────────────────────────────────────┐")
+ lines.append("│ /last30days v3.0.0 - Source Status │")
+ lines.append("│ │")
+
+ if has_reddit and has_scrapecreators:
+ lines.append("│ ✅ Reddit — full threads with comments │")
+ elif has_reddit:
+ lines.append("│ ✅ Reddit — public threads (titles + scores) │")
+ else:
+ lines.append("│ ❌ Reddit — unavailable │")
+
+ if has_x:
+ lines.append("│ ✅ X/Twitter — available │")
+ else:
+ lines.append("│ ❌ X/Twitter — No X auth or fallback key │")
+ if diag.get("bird_installed"):
+ lines.append("│ └─ Add AUTH_TOKEN/CT0 or XAI_API_KEY │")
+ else:
+ lines.append("│ └─ Needs Node.js 22+ (Bird is bundled) │")
+
+ if has_youtube:
+ lines.append("│ ✅ YouTube — yt-dlp found │")
+ else:
+ lines.append("│ ❌ YouTube — yt-dlp not installed │")
+ lines.append("│ └─ Fix: brew install yt-dlp (free) │")
+
+ if has_xiaohongshu:
+ lines.append("│ ✅ Xiaohongshu — API connected + logged in │")
+
+ if has_web:
+ backend = native_web_backend or "native"
+ lines.append(f"│ ✅ Web — {backend} API available{' ' * max(0, 13 - len(backend))}│")
+ else:
+ lines.append("│ ⚡ Web — Add BRAVE_API_KEY or SERPER_API_KEY │")
+
+ lines.append("│ │")
+ lines.append("│ Config: ~/.config/last30days/.env │")
+ lines.append("└─────────────────────────────────────────────────────┘")
sys.stderr.write("\n".join(lines) + "\n\n")
sys.stderr.flush()
diff --git a/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/LICENSE b/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/LICENSE
deleted file mode 100644
index 382d7b0..0000000
--- a/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/LICENSE
+++ /dev/null
@@ -1,22 +0,0 @@
-MIT License
-
-Copyright (c) 2025 Peter Steinberger
-
-Permission is hereby granted, free of charge, to any person obtaining a copy
-of this software and associated documentation files (the "Software"), to deal
-in the Software without restriction, including without limitation the rights
-to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-copies of the Software, and to permit persons to whom the Software is
-furnished to do so, subject to the following conditions:
-
-The above copyright notice and this permission notice shall be included in all
-copies or substantial portions of the Software.
-
-THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
-SOFTWARE.
-
diff --git a/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/README.md b/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/README.md
deleted file mode 100644
index e6926e7..0000000
--- a/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/README.md
+++ /dev/null
@@ -1,29 +0,0 @@
-# @steipete/sweet-cookie
-
-Inline-first browser cookie extraction for local tooling (no native addons).
-
-Supports:
-- Inline payloads (JSON / base64 / file) — most reliable path.
-- Local browser reads (best effort): Chrome, Edge, Firefox, Safari (macOS).
-
-Install:
-```bash
-npm i @steipete/sweet-cookie
-```
-
-Usage:
-```ts
-import { getCookies, toCookieHeader } from '@steipete/sweet-cookie';
-
-const { cookies, warnings } = await getCookies({
- url: 'https://example.com/',
- names: ['session', 'csrf'],
- browsers: ['chrome', 'edge', 'firefox', 'safari'],
-});
-
-for (const w of warnings) console.warn(w);
-const cookieHeader = toCookieHeader(cookies, { dedupeByName: true });
-```
-
-Docs + extension exporter: see the repo root README.
-
diff --git a/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/index.d.ts b/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/index.d.ts
deleted file mode 100644
index 6b4855d..0000000
--- a/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/index.d.ts
+++ /dev/null
@@ -1,3 +0,0 @@
-export { getCookies, toCookieHeader } from './public.js';
-export type { BrowserName, Cookie, CookieHeaderOptions, CookieMode, CookieSameSite, GetCookiesOptions, GetCookiesResult, } from './types.js';
-//# sourceMappingURL=index.d.ts.map
\ No newline at end of file
diff --git a/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/index.d.ts.map b/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/index.d.ts.map
deleted file mode 100644
index e0f5600..0000000
--- a/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/index.d.ts.map
+++ /dev/null
@@ -1 +0,0 @@
-{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,EAAE,cAAc,EAAE,MAAM,aAAa,CAAC;AACzD,YAAY,EACX,WAAW,EACX,MAAM,EACN,mBAAmB,EACnB,UAAU,EACV,cAAc,EACd,iBAAiB,EACjB,gBAAgB,GAChB,MAAM,YAAY,CAAC"}
\ No newline at end of file
diff --git a/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/index.js b/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/index.js
deleted file mode 100644
index dca6064..0000000
--- a/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/index.js
+++ /dev/null
@@ -1,2 +0,0 @@
-export { getCookies, toCookieHeader } from './public.js';
-//# sourceMappingURL=index.js.map
\ No newline at end of file
diff --git a/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/index.js.map b/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/index.js.map
deleted file mode 100644
index 8621620..0000000
--- a/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/index.js.map
+++ /dev/null
@@ -1 +0,0 @@
-{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,EAAE,cAAc,EAAE,MAAM,aAAa,CAAC"}
\ No newline at end of file
diff --git a/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/providers/chrome.d.ts b/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/providers/chrome.d.ts
deleted file mode 100644
index e6b93f5..0000000
--- a/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/providers/chrome.d.ts
+++ /dev/null
@@ -1,8 +0,0 @@
-import type { GetCookiesResult } from '../types.js';
-export declare function getCookiesFromChrome(options: {
- profile?: string;
- timeoutMs?: number;
- includeExpired?: boolean;
- debug?: boolean;
-}, origins: string[], allowlistNames: Set | null): Promise;
-//# sourceMappingURL=chrome.d.ts.map
\ No newline at end of file
diff --git a/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/providers/chrome.d.ts.map b/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/providers/chrome.d.ts.map
deleted file mode 100644
index 89e5eb8..0000000
--- a/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/providers/chrome.d.ts.map
+++ /dev/null
@@ -1 +0,0 @@
-{"version":3,"file":"chrome.d.ts","sourceRoot":"","sources":["../../src/providers/chrome.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAU,gBAAgB,EAAE,MAAM,aAAa,CAAC;AAK5D,wBAAsB,oBAAoB,CACzC,OAAO,EAAE;IAAE,OAAO,CAAC,EAAE,MAAM,CAAC;IAAC,SAAS,CAAC,EAAE,MAAM,CAAC;IAAC,cAAc,CAAC,EAAE,OAAO,CAAC;IAAC,KAAK,CAAC,EAAE,OAAO,CAAA;CAAE,EAC5F,OAAO,EAAE,MAAM,EAAE,EACjB,cAAc,EAAE,GAAG,CAAC,MAAM,CAAC,GAAG,IAAI,GAChC,OAAO,CAAC,gBAAgB,CAAC,CA0B3B"}
\ No newline at end of file
diff --git a/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/providers/chrome.js b/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/providers/chrome.js
deleted file mode 100644
index 5e4f315..0000000
--- a/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/providers/chrome.js
+++ /dev/null
@@ -1,27 +0,0 @@
-import { getCookiesFromChromeSqliteLinux } from './chromeSqliteLinux.js';
-import { getCookiesFromChromeSqliteMac } from './chromeSqliteMac.js';
-import { getCookiesFromChromeSqliteWindows } from './chromeSqliteWindows.js';
-export async function getCookiesFromChrome(options, origins, allowlistNames) {
- const warnings = [];
- // Platform dispatch only. All real logic lives in the per-OS providers.
- if (process.platform === 'darwin') {
- const r = await getCookiesFromChromeSqliteMac(options, origins, allowlistNames);
- warnings.push(...r.warnings);
- const cookies = r.cookies;
- return { cookies, warnings };
- }
- if (process.platform === 'linux') {
- const r = await getCookiesFromChromeSqliteLinux(options, origins, allowlistNames);
- warnings.push(...r.warnings);
- const cookies = r.cookies;
- return { cookies, warnings };
- }
- if (process.platform === 'win32') {
- const r = await getCookiesFromChromeSqliteWindows(options, origins, allowlistNames);
- warnings.push(...r.warnings);
- const cookies = r.cookies;
- return { cookies, warnings };
- }
- return { cookies: [], warnings };
-}
-//# sourceMappingURL=chrome.js.map
\ No newline at end of file
diff --git a/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/providers/chrome.js.map b/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/providers/chrome.js.map
deleted file mode 100644
index b3b112a..0000000
--- a/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/providers/chrome.js.map
+++ /dev/null
@@ -1 +0,0 @@
-{"version":3,"file":"chrome.js","sourceRoot":"","sources":["../../src/providers/chrome.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,+BAA+B,EAAE,MAAM,wBAAwB,CAAC;AACzE,OAAO,EAAE,6BAA6B,EAAE,MAAM,sBAAsB,CAAC;AACrE,OAAO,EAAE,iCAAiC,EAAE,MAAM,0BAA0B,CAAC;AAE7E,MAAM,CAAC,KAAK,UAAU,oBAAoB,CACzC,OAA4F,EAC5F,OAAiB,EACjB,cAAkC;IAElC,MAAM,QAAQ,GAAa,EAAE,CAAC;IAE9B,wEAAwE;IACxE,IAAI,OAAO,CAAC,QAAQ,KAAK,QAAQ,EAAE,CAAC;QACnC,MAAM,CAAC,GAAG,MAAM,6BAA6B,CAAC,OAAO,EAAE,OAAO,EAAE,cAAc,CAAC,CAAC;QAChF,QAAQ,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,QAAQ,CAAC,CAAC;QAC7B,MAAM,OAAO,GAAa,CAAC,CAAC,OAAO,CAAC;QACpC,OAAO,EAAE,OAAO,EAAE,QAAQ,EAAE,CAAC;IAC9B,CAAC;IAED,IAAI,OAAO,CAAC,QAAQ,KAAK,OAAO,EAAE,CAAC;QAClC,MAAM,CAAC,GAAG,MAAM,+BAA+B,CAAC,OAAO,EAAE,OAAO,EAAE,cAAc,CAAC,CAAC;QAClF,QAAQ,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,QAAQ,CAAC,CAAC;QAC7B,MAAM,OAAO,GAAa,CAAC,CAAC,OAAO,CAAC;QACpC,OAAO,EAAE,OAAO,EAAE,QAAQ,EAAE,CAAC;IAC9B,CAAC;IAED,IAAI,OAAO,CAAC,QAAQ,KAAK,OAAO,EAAE,CAAC;QAClC,MAAM,CAAC,GAAG,MAAM,iCAAiC,CAAC,OAAO,EAAE,OAAO,EAAE,cAAc,CAAC,CAAC;QACpF,QAAQ,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,QAAQ,CAAC,CAAC;QAC7B,MAAM,OAAO,GAAa,CAAC,CAAC,OAAO,CAAC;QACpC,OAAO,EAAE,OAAO,EAAE,QAAQ,EAAE,CAAC;IAC9B,CAAC;IAED,OAAO,EAAE,OAAO,EAAE,EAAE,EAAE,QAAQ,EAAE,CAAC;AAClC,CAAC"}
\ No newline at end of file
diff --git a/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/providers/chromeSqlite/crypto.d.ts b/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/providers/chromeSqlite/crypto.d.ts
deleted file mode 100644
index e432ae0..0000000
--- a/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/providers/chromeSqlite/crypto.d.ts
+++ /dev/null
@@ -1,11 +0,0 @@
-export declare function deriveAes128CbcKeyFromPassword(password: string, options: {
- iterations: number;
-}): Buffer;
-export declare function decryptChromiumAes128CbcCookieValue(encryptedValue: Uint8Array, keyCandidates: readonly Buffer[], options: {
- stripHashPrefix: boolean;
- treatUnknownPrefixAsPlaintext?: boolean;
-}): string | null;
-export declare function decryptChromiumAes256GcmCookieValue(encryptedValue: Uint8Array, key: Buffer, options: {
- stripHashPrefix: boolean;
-}): string | null;
-//# sourceMappingURL=crypto.d.ts.map
\ No newline at end of file
diff --git a/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/providers/chromeSqlite/crypto.d.ts.map b/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/providers/chromeSqlite/crypto.d.ts.map
deleted file mode 100644
index ad07eaa..0000000
--- a/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/providers/chromeSqlite/crypto.d.ts.map
+++ /dev/null
@@ -1 +0,0 @@
-{"version":3,"file":"crypto.d.ts","sourceRoot":"","sources":["../../../src/providers/chromeSqlite/crypto.ts"],"names":[],"mappings":"AAIA,wBAAgB,8BAA8B,CAC7C,QAAQ,EAAE,MAAM,EAChB,OAAO,EAAE;IAAE,UAAU,EAAE,MAAM,CAAA;CAAE,GAC7B,MAAM,CAIR;AAED,wBAAgB,mCAAmC,CAClD,cAAc,EAAE,UAAU,EAC1B,aAAa,EAAE,SAAS,MAAM,EAAE,EAChC,OAAO,EAAE;IAAE,eAAe,EAAE,OAAO,CAAC;IAAC,6BAA6B,CAAC,EAAE,OAAO,CAAA;CAAE,GAC5E,MAAM,GAAG,IAAI,CA2Bf;AAED,wBAAgB,mCAAmC,CAClD,cAAc,EAAE,UAAU,EAC1B,GAAG,EAAE,MAAM,EACX,OAAO,EAAE;IAAE,eAAe,EAAE,OAAO,CAAA;CAAE,GACnC,MAAM,GAAG,IAAI,CAyBf"}
\ No newline at end of file
diff --git a/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/providers/chromeSqlite/crypto.js b/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/providers/chromeSqlite/crypto.js
deleted file mode 100644
index b0b77a5..0000000
--- a/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/providers/chromeSqlite/crypto.js
+++ /dev/null
@@ -1,100 +0,0 @@
-import { createDecipheriv, pbkdf2Sync } from 'node:crypto';
-const UTF8_DECODER = new TextDecoder('utf-8', { fatal: true });
-export function deriveAes128CbcKeyFromPassword(password, options) {
- // Chromium derives the AES-128-CBC key from "Chrome Safe Storage" using PBKDF2.
- // The salt/length/digest are fixed by Chromium ("saltysalt", 16 bytes, sha1).
- return pbkdf2Sync(password, 'saltysalt', options.iterations, 16, 'sha1');
-}
-export function decryptChromiumAes128CbcCookieValue(encryptedValue, keyCandidates, options) {
- const buf = Buffer.from(encryptedValue);
- if (buf.length < 3)
- return null;
- // Chromium prefixes encrypted cookies with `v10`, `v11`, ... (three bytes).
- const prefix = buf.subarray(0, 3).toString('utf8');
- const hasVersionPrefix = /^v\d\d$/.test(prefix);
- if (!hasVersionPrefix) {
- // Some platforms (notably macOS) can store plaintext values in `encrypted_value`.
- // Callers decide whether unknown prefixes should be treated as plaintext.
- if (options.treatUnknownPrefixAsPlaintext === false)
- return null;
- return decodeCookieValueBytes(buf, false);
- }
- const ciphertext = buf.subarray(3);
- if (!ciphertext.length)
- return '';
- for (const key of keyCandidates) {
- // Try multiple candidates because Linux may fall back to empty passwords depending on keyring state.
- const decrypted = tryDecryptAes128Cbc(ciphertext, key);
- if (!decrypted)
- continue;
- const decoded = decodeCookieValueBytes(decrypted, options.stripHashPrefix);
- if (decoded !== null)
- return decoded;
- }
- return null;
-}
-export function decryptChromiumAes256GcmCookieValue(encryptedValue, key, options) {
- const buf = Buffer.from(encryptedValue);
- if (buf.length < 3)
- return null;
- const prefix = buf.subarray(0, 3).toString('utf8');
- if (!/^v\d\d$/.test(prefix))
- return null;
- // AES-256-GCM layout:
- // - 12-byte nonce
- // - ciphertext
- // - 16-byte authentication tag
- const payload = buf.subarray(3);
- if (payload.length < 12 + 16)
- return null;
- const nonce = payload.subarray(0, 12);
- const authenticationTag = payload.subarray(payload.length - 16);
- const ciphertext = payload.subarray(12, payload.length - 16);
- try {
- const decipher = createDecipheriv('aes-256-gcm', key, nonce);
- decipher.setAuthTag(authenticationTag);
- const plaintext = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
- return decodeCookieValueBytes(plaintext, options.stripHashPrefix);
- }
- catch {
- return null;
- }
-}
-function tryDecryptAes128Cbc(ciphertext, key) {
- try {
- // Chromium's legacy AES-128-CBC uses an IV of 16 spaces.
- const iv = Buffer.alloc(16, 0x20);
- const decipher = createDecipheriv('aes-128-cbc', key, iv);
- decipher.setAutoPadding(false);
- const plaintext = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
- return removePkcs7Padding(plaintext);
- }
- catch {
- return null;
- }
-}
-function removePkcs7Padding(value) {
- if (!value.length)
- return value;
- const padding = value[value.length - 1];
- if (!padding || padding > 16)
- return value;
- return value.subarray(0, value.length - padding);
-}
-function decodeCookieValueBytes(value, stripHashPrefix) {
- // Chromium >= 24 prepends a 32-byte hash to cookie values.
- const bytes = stripHashPrefix && value.length >= 32 ? value.subarray(32) : value;
- try {
- return stripLeadingControlChars(UTF8_DECODER.decode(bytes));
- }
- catch {
- return null;
- }
-}
-function stripLeadingControlChars(value) {
- let i = 0;
- while (i < value.length && value.charCodeAt(i) < 0x20)
- i += 1;
- return value.slice(i);
-}
-//# sourceMappingURL=crypto.js.map
\ No newline at end of file
diff --git a/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/providers/chromeSqlite/crypto.js.map b/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/providers/chromeSqlite/crypto.js.map
deleted file mode 100644
index 890f3a4..0000000
--- a/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/providers/chromeSqlite/crypto.js.map
+++ /dev/null
@@ -1 +0,0 @@
-{"version":3,"file":"crypto.js","sourceRoot":"","sources":["../../../src/providers/chromeSqlite/crypto.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,gBAAgB,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AAE3D,MAAM,YAAY,GAAG,IAAI,WAAW,CAAC,OAAO,EAAE,EAAE,KAAK,EAAE,IAAI,EAAE,CAAC,CAAC;AAE/D,MAAM,UAAU,8BAA8B,CAC7C,QAAgB,EAChB,OAA+B;IAE/B,gFAAgF;IAChF,8EAA8E;IAC9E,OAAO,UAAU,CAAC,QAAQ,EAAE,WAAW,EAAE,OAAO,CAAC,UAAU,EAAE,EAAE,EAAE,MAAM,CAAC,CAAC;AAC1E,CAAC;AAED,MAAM,UAAU,mCAAmC,CAClD,cAA0B,EAC1B,aAAgC,EAChC,OAA8E;IAE9E,MAAM,GAAG,GAAG,MAAM,CAAC,IAAI,CAAC,cAAc,CAAC,CAAC;IACxC,IAAI,GAAG,CAAC,MAAM,GAAG,CAAC;QAAE,OAAO,IAAI,CAAC;IAEhC,4EAA4E;IAC5E,MAAM,MAAM,GAAG,GAAG,CAAC,QAAQ,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,QAAQ,CAAC,MAAM,CAAC,CAAC;IACnD,MAAM,gBAAgB,GAAG,SAAS,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC;IAEhD,IAAI,CAAC,gBAAgB,EAAE,CAAC;QACvB,kFAAkF;QAClF,0EAA0E;QAC1E,IAAI,OAAO,CAAC,6BAA6B,KAAK,KAAK;YAAE,OAAO,IAAI,CAAC;QACjE,OAAO,sBAAsB,CAAC,GAAG,EAAE,KAAK,CAAC,CAAC;IAC3C,CAAC;IAED,MAAM,UAAU,GAAG,GAAG,CAAC,QAAQ,CAAC,CAAC,CAAC,CAAC;IACnC,IAAI,CAAC,UAAU,CAAC,MAAM;QAAE,OAAO,EAAE,CAAC;IAElC,KAAK,MAAM,GAAG,IAAI,aAAa,EAAE,CAAC;QACjC,qGAAqG;QACrG,MAAM,SAAS,GAAG,mBAAmB,CAAC,UAAU,EAAE,GAAG,CAAC,CAAC;QACvD,IAAI,CAAC,SAAS;YAAE,SAAS;QACzB,MAAM,OAAO,GAAG,sBAAsB,CAAC,SAAS,EAAE,OAAO,CAAC,eAAe,CAAC,CAAC;QAC3E,IAAI,OAAO,KAAK,IAAI;YAAE,OAAO,OAAO,CAAC;IACtC,CAAC;IAED,OAAO,IAAI,CAAC;AACb,CAAC;AAED,MAAM,UAAU,mCAAmC,CAClD,cAA0B,EAC1B,GAAW,EACX,OAAqC;IAErC,MAAM,GAAG,GAAG,MAAM,CAAC,IAAI,CAAC,cAAc,CAAC,CAAC;IACxC,IAAI,GAAG,CAAC,MAAM,GAAG,CAAC;QAAE,OAAO,IAAI,CAAC;IAChC,MAAM,MAAM,GAAG,GAAG,CAAC,QAAQ,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,QAAQ,CAAC,MAAM,CAAC,CAAC;IACnD,IAAI,CAAC,SAAS,CAAC,IAAI,CAAC,MAAM,CAAC;QAAE,OAAO,IAAI,CAAC;IAEzC,sBAAsB;IACtB,kBAAkB;IAClB,eAAe;IACf,+BAA+B;IAC/B,MAAM,OAAO,GAAG,GAAG,CAAC,QAAQ,CAAC,CAAC,CAAC,CAAC;IAChC,IAAI,OAAO,CAAC,MAAM,GAAG,EAAE,GAAG,EAAE;QAAE,OAAO,IAAI,CAAC;IAE1C,MAAM,KAAK,GAAG,OAAO,CAAC,QAAQ,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC;IACtC,MAAM,iBAAiB,GAAG,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAC,MAAM,GAAG,EAAE,CAAC,CAAC;IAChE,MAAM,UAAU,GAAG,OAAO,CAAC,QAAQ,CAAC,EAAE,EAAE,OAAO,CAAC,MAAM,GAAG,EAAE,CAAC,CAAC;IAE7D,IAAI,CAAC;QACJ,MAAM,QAAQ,GAAG,gBAAgB,CAAC,aAAa,EAAE,GAAG,EAAE,KAAK,CAAC,CAAC;QAC7D,QAAQ,CAAC,UAAU,CAAC,iBAAiB,CAAC,CAAC;QACvC,MAAM,SAAS,GAAG,MAAM,CAAC,MAAM,CAAC,CAAC,QAAQ,CAAC,MAAM,CAAC,UAAU,CAAC,EAAE,QAAQ,CAAC,KAAK,EAAE,CAAC,CAAC,CAAC;QACjF,OAAO,sBAAsB,CAAC,SAAS,EAAE,OAAO,CAAC,eAAe,CAAC,CAAC;IACnE,CAAC;IAAC,MAAM,CAAC;QACR,OAAO,IAAI,CAAC;IACb,CAAC;AACF,CAAC;AAED,SAAS,mBAAmB,CAAC,UAAkB,EAAE,GAAW;IAC3D,IAAI,CAAC;QACJ,yDAAyD;QACzD,MAAM,EAAE,GAAG,MAAM,CAAC,KAAK,CAAC,EAAE,EAAE,IAAI,CAAC,CAAC;QAClC,MAAM,QAAQ,GAAG,gBAAgB,CAAC,aAAa,EAAE,GAAG,EAAE,EAAE,CAAC,CAAC;QAC1D,QAAQ,CAAC,cAAc,CAAC,KAAK,CAAC,CAAC;QAC/B,MAAM,SAAS,GAAG,MAAM,CAAC,MAAM,CAAC,CAAC,QAAQ,CAAC,MAAM,CAAC,UAAU,CAAC,EAAE,QAAQ,CAAC,KAAK,EAAE,CAAC,CAAC,CAAC;QACjF,OAAO,kBAAkB,CAAC,SAAS,CAAC,CAAC;IACtC,CAAC;IAAC,MAAM,CAAC;QACR,OAAO,IAAI,CAAC;IACb,CAAC;AACF,CAAC;AAED,SAAS,kBAAkB,CAAC,KAAa;IACxC,IAAI,CAAC,KAAK,CAAC,MAAM;QAAE,OAAO,KAAK,CAAC;IAChC,MAAM,OAAO,GAAG,KAAK,CAAC,KAAK,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC;IACxC,IAAI,CAAC,OAAO,IAAI,OAAO,GAAG,EAAE;QAAE,OAAO,KAAK,CAAC;IAC3C,OAAO,KAAK,CAAC,QAAQ,CAAC,CAAC,EAAE,KAAK,CAAC,MAAM,GAAG,OAAO,CAAC,CAAC;AAClD,CAAC;AAED,SAAS,sBAAsB,CAAC,KAAa,EAAE,eAAwB;IACtE,2DAA2D;IAC3D,MAAM,KAAK,GAAG,eAAe,IAAI,KAAK,CAAC,MAAM,IAAI,EAAE,CAAC,CAAC,CAAC,KAAK,CAAC,QAAQ,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC;IACjF,IAAI,CAAC;QACJ,OAAO,wBAAwB,CAAC,YAAY,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC,CAAC;IAC7D,CAAC;IAAC,MAAM,CAAC;QACR,OAAO,IAAI,CAAC;IACb,CAAC;AACF,CAAC;AAED,SAAS,wBAAwB,CAAC,KAAa;IAC9C,IAAI,CAAC,GAAG,CAAC,CAAC;IACV,OAAO,CAAC,GAAG,KAAK,CAAC,MAAM,IAAI,KAAK,CAAC,UAAU,CAAC,CAAC,CAAC,GAAG,IAAI;QAAE,CAAC,IAAI,CAAC,CAAC;IAC9D,OAAO,KAAK,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC;AACvB,CAAC"}
\ No newline at end of file
diff --git a/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/providers/chromeSqlite/linuxKeyring.d.ts b/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/providers/chromeSqlite/linuxKeyring.d.ts
deleted file mode 100644
index f664ab0..0000000
--- a/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/providers/chromeSqlite/linuxKeyring.d.ts
+++ /dev/null
@@ -1,25 +0,0 @@
-export type LinuxKeyringBackend = 'gnome' | 'kwallet' | 'basic';
-/**
- * Read the "Safe Storage" password from a Linux keyring.
- *
- * Chromium browsers typically store their cookie encryption password under:
- * - service: " Safe Storage"
- * - account: ""
- *
- * We keep this logic in JS (no native deps) and return an empty password on failure
- * (Chromium may still have v10 cookies, and callers can use inline/export escape hatches).
- */
-export declare function getLinuxChromiumSafeStoragePassword(options: {
- backend?: LinuxKeyringBackend;
- app: 'chrome' | 'edge';
-}): Promise<{
- password: string;
- warnings: string[];
-}>;
-export declare function getLinuxChromeSafeStoragePassword(options?: {
- backend?: LinuxKeyringBackend;
-}): Promise<{
- password: string;
- warnings: string[];
-}>;
-//# sourceMappingURL=linuxKeyring.d.ts.map
\ No newline at end of file
diff --git a/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/providers/chromeSqlite/linuxKeyring.d.ts.map b/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/providers/chromeSqlite/linuxKeyring.d.ts.map
deleted file mode 100644
index fd958e9..0000000
--- a/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/providers/chromeSqlite/linuxKeyring.d.ts.map
+++ /dev/null
@@ -1 +0,0 @@
-{"version":3,"file":"linuxKeyring.d.ts","sourceRoot":"","sources":["../../../src/providers/chromeSqlite/linuxKeyring.ts"],"names":[],"mappings":"AAEA,MAAM,MAAM,mBAAmB,GAAG,OAAO,GAAG,SAAS,GAAG,OAAO,CAAC;AAEhE;;;;;;;;;GASG;AACH,wBAAsB,mCAAmC,CAAC,OAAO,EAAE;IAClE,OAAO,CAAC,EAAE,mBAAmB,CAAC;IAC9B,GAAG,EAAE,QAAQ,GAAG,MAAM,CAAC;CACvB,GAAG,OAAO,CAAC;IAAE,QAAQ,EAAE,MAAM,CAAC;IAAC,QAAQ,EAAE,MAAM,EAAE,CAAA;CAAE,CAAC,CA8DpD;AAED,wBAAsB,iCAAiC,CACtD,OAAO,GAAE;IAAE,OAAO,CAAC,EAAE,mBAAmB,CAAA;CAAO,GAC7C,OAAO,CAAC;IAAE,QAAQ,EAAE,MAAM,CAAC;IAAC,QAAQ,EAAE,MAAM,EAAE,CAAA;CAAE,CAAC,CAInD"}
\ No newline at end of file
diff --git a/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/providers/chromeSqlite/linuxKeyring.js b/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/providers/chromeSqlite/linuxKeyring.js
deleted file mode 100644
index bb113a5..0000000
--- a/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/providers/chromeSqlite/linuxKeyring.js
+++ /dev/null
@@ -1,104 +0,0 @@
-import { execCapture } from '../../util/exec.js';
-/**
- * Read the "Safe Storage" password from a Linux keyring.
- *
- * Chromium browsers typically store their cookie encryption password under:
- * - service: " Safe Storage"
- * - account: ""
- *
- * We keep this logic in JS (no native deps) and return an empty password on failure
- * (Chromium may still have v10 cookies, and callers can use inline/export escape hatches).
- */
-export async function getLinuxChromiumSafeStoragePassword(options) {
- const warnings = [];
- // Escape hatch: if callers already know the password (or want deterministic CI behavior),
- // they can bypass keyring probing entirely.
- const overrideKey = options.app === 'edge'
- ? 'SWEET_COOKIE_EDGE_SAFE_STORAGE_PASSWORD'
- : 'SWEET_COOKIE_CHROME_SAFE_STORAGE_PASSWORD';
- const override = readEnv(overrideKey);
- if (override !== undefined)
- return { password: override, warnings };
- const backend = options.backend ?? parseLinuxKeyringBackend() ?? chooseLinuxKeyringBackend();
- // `basic` means "don't try keyrings" (Chrome will fall back to older/less-secure schemes on some setups).
- if (backend === 'basic')
- return { password: '', warnings };
- const service = options.app === 'edge' ? 'Microsoft Edge Safe Storage' : 'Chrome Safe Storage';
- const account = options.app === 'edge' ? 'Microsoft Edge' : 'Chrome';
- const folder = `${account} Keys`;
- if (backend === 'gnome') {
- // GNOME keyring: `secret-tool` is the simplest way to read libsecret entries.
- const res = await execCapture('secret-tool', ['lookup', 'service', service, 'account', account], { timeoutMs: 3_000 });
- if (res.code === 0)
- return { password: res.stdout.trim(), warnings };
- warnings.push('Failed to read Linux keyring via secret-tool; v11 cookies may be unavailable.');
- return { password: '', warnings };
- }
- // KDE keyring: query KWallet via `kwallet-query`, but the wallet name differs across KDE versions.
- const kdeVersion = (readEnv('KDE_SESSION_VERSION') ?? '').trim();
- const serviceName = kdeVersion === '6'
- ? 'org.kde.kwalletd6'
- : kdeVersion === '5'
- ? 'org.kde.kwalletd5'
- : 'org.kde.kwalletd';
- const walletPath = kdeVersion === '6'
- ? '/modules/kwalletd6'
- : kdeVersion === '5'
- ? '/modules/kwalletd5'
- : '/modules/kwalletd';
- const wallet = await getKWalletNetworkWallet(serviceName, walletPath);
- const passwordRes = await execCapture('kwallet-query', ['--read-password', service, '--folder', folder, wallet], { timeoutMs: 3_000 });
- if (passwordRes.code !== 0) {
- warnings.push('Failed to read Linux keyring via kwallet-query; v11 cookies may be unavailable.');
- return { password: '', warnings };
- }
- if (passwordRes.stdout.toLowerCase().startsWith('failed to read'))
- return { password: '', warnings };
- return { password: passwordRes.stdout.trim(), warnings };
-}
-export async function getLinuxChromeSafeStoragePassword(options = {}) {
- const args = { app: 'chrome' };
- if (options.backend !== undefined)
- args.backend = options.backend;
- return await getLinuxChromiumSafeStoragePassword(args);
-}
-function parseLinuxKeyringBackend() {
- const raw = readEnv('SWEET_COOKIE_LINUX_KEYRING');
- if (!raw)
- return undefined;
- const normalized = raw.toLowerCase();
- if (normalized === 'gnome')
- return 'gnome';
- if (normalized === 'kwallet')
- return 'kwallet';
- if (normalized === 'basic')
- return 'basic';
- return undefined;
-}
-function chooseLinuxKeyringBackend() {
- const xdg = readEnv('XDG_CURRENT_DESKTOP') ?? '';
- const isKde = xdg.split(':').some((p) => p.trim().toLowerCase() === 'kde') || !!readEnv('KDE_FULL_SESSION');
- return isKde ? 'kwallet' : 'gnome';
-}
-async function getKWalletNetworkWallet(serviceName, walletPath) {
- const res = await execCapture('dbus-send', [
- '--session',
- '--print-reply=literal',
- `--dest=${serviceName}`,
- walletPath,
- 'org.kde.KWallet.networkWallet',
- ], { timeoutMs: 3_000 });
- const fallback = 'kdewallet';
- if (res.code !== 0)
- return fallback;
- const raw = res.stdout.trim();
- if (!raw)
- return fallback;
- return raw.replaceAll('"', '').trim() || fallback;
-}
-function readEnv(key) {
- const value = process.env[key];
- const trimmed = typeof value === 'string' ? value.trim() : '';
- return trimmed.length ? trimmed : undefined;
-}
-//# sourceMappingURL=linuxKeyring.js.map
\ No newline at end of file
diff --git a/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/providers/chromeSqlite/linuxKeyring.js.map b/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/providers/chromeSqlite/linuxKeyring.js.map
deleted file mode 100644
index e0b6a4b..0000000
--- a/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/providers/chromeSqlite/linuxKeyring.js.map
+++ /dev/null
@@ -1 +0,0 @@
-{"version":3,"file":"linuxKeyring.js","sourceRoot":"","sources":["../../../src/providers/chromeSqlite/linuxKeyring.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,WAAW,EAAE,MAAM,oBAAoB,CAAC;AAIjD;;;;;;;;;GASG;AACH,MAAM,CAAC,KAAK,UAAU,mCAAmC,CAAC,OAGzD;IACA,MAAM,QAAQ,GAAa,EAAE,CAAC;IAE9B,0FAA0F;IAC1F,4CAA4C;IAC5C,MAAM,WAAW,GAChB,OAAO,CAAC,GAAG,KAAK,MAAM;QACrB,CAAC,CAAC,yCAAyC;QAC3C,CAAC,CAAC,2CAA2C,CAAC;IAChD,MAAM,QAAQ,GAAG,OAAO,CAAC,WAAW,CAAC,CAAC;IACtC,IAAI,QAAQ,KAAK,SAAS;QAAE,OAAO,EAAE,QAAQ,EAAE,QAAQ,EAAE,QAAQ,EAAE,CAAC;IAEpE,MAAM,OAAO,GAAG,OAAO,CAAC,OAAO,IAAI,wBAAwB,EAAE,IAAI,yBAAyB,EAAE,CAAC;IAC7F,0GAA0G;IAC1G,IAAI,OAAO,KAAK,OAAO;QAAE,OAAO,EAAE,QAAQ,EAAE,EAAE,EAAE,QAAQ,EAAE,CAAC;IAE3D,MAAM,OAAO,GAAG,OAAO,CAAC,GAAG,KAAK,MAAM,CAAC,CAAC,CAAC,6BAA6B,CAAC,CAAC,CAAC,qBAAqB,CAAC;IAC/F,MAAM,OAAO,GAAG,OAAO,CAAC,GAAG,KAAK,MAAM,CAAC,CAAC,CAAC,gBAAgB,CAAC,CAAC,CAAC,QAAQ,CAAC;IACrE,MAAM,MAAM,GAAG,GAAG,OAAO,OAAO,CAAC;IAEjC,IAAI,OAAO,KAAK,OAAO,EAAE,CAAC;QACzB,8EAA8E;QAC9E,MAAM,GAAG,GAAG,MAAM,WAAW,CAC5B,aAAa,EACb,CAAC,QAAQ,EAAE,SAAS,EAAE,OAAO,EAAE,SAAS,EAAE,OAAO,CAAC,EAClD,EAAE,SAAS,EAAE,KAAK,EAAE,CACpB,CAAC;QACF,IAAI,GAAG,CAAC,IAAI,KAAK,CAAC;YAAE,OAAO,EAAE,QAAQ,EAAE,GAAG,CAAC,MAAM,CAAC,IAAI,EAAE,EAAE,QAAQ,EAAE,CAAC;QACrE,QAAQ,CAAC,IAAI,CAAC,+EAA+E,CAAC,CAAC;QAC/F,OAAO,EAAE,QAAQ,EAAE,EAAE,EAAE,QAAQ,EAAE,CAAC;IACnC,CAAC;IAED,mGAAmG;IACnG,MAAM,UAAU,GAAG,CAAC,OAAO,CAAC,qBAAqB,CAAC,IAAI,EAAE,CAAC,CAAC,IAAI,EAAE,CAAC;IACjE,MAAM,WAAW,GAChB,UAAU,KAAK,GAAG;QACjB,CAAC,CAAC,mBAAmB;QACrB,CAAC,CAAC,UAAU,KAAK,GAAG;YACnB,CAAC,CAAC,mBAAmB;YACrB,CAAC,CAAC,kBAAkB,CAAC;IACxB,MAAM,UAAU,GACf,UAAU,KAAK,GAAG;QACjB,CAAC,CAAC,oBAAoB;QACtB,CAAC,CAAC,UAAU,KAAK,GAAG;YACnB,CAAC,CAAC,oBAAoB;YACtB,CAAC,CAAC,mBAAmB,CAAC;IAEzB,MAAM,MAAM,GAAG,MAAM,uBAAuB,CAAC,WAAW,EAAE,UAAU,CAAC,CAAC;IACtE,MAAM,WAAW,GAAG,MAAM,WAAW,CACpC,eAAe,EACf,CAAC,iBAAiB,EAAE,OAAO,EAAE,UAAU,EAAE,MAAM,EAAE,MAAM,CAAC,EACxD,EAAE,SAAS,EAAE,KAAK,EAAE,CACpB,CAAC;IACF,IAAI,WAAW,CAAC,IAAI,KAAK,CAAC,EAAE,CAAC;QAC5B,QAAQ,CAAC,IAAI,CACZ,iFAAiF,CACjF,CAAC;QACF,OAAO,EAAE,QAAQ,EAAE,EAAE,EAAE,QAAQ,EAAE,CAAC;IACnC,CAAC;IACD,IAAI,WAAW,CAAC,MAAM,CAAC,WAAW,EAAE,CAAC,UAAU,CAAC,gBAAgB,CAAC;QAChE,OAAO,EAAE,QAAQ,EAAE,EAAE,EAAE,QAAQ,EAAE,CAAC;IACnC,OAAO,EAAE,QAAQ,EAAE,WAAW,CAAC,MAAM,CAAC,IAAI,EAAE,EAAE,QAAQ,EAAE,CAAC;AAC1D,CAAC;AAED,MAAM,CAAC,KAAK,UAAU,iCAAiC,CACtD,UAA6C,EAAE;IAE/C,MAAM,IAAI,GAAqD,EAAE,GAAG,EAAE,QAAQ,EAAE,CAAC;IACjF,IAAI,OAAO,CAAC,OAAO,KAAK,SAAS;QAAE,IAAI,CAAC,OAAO,GAAG,OAAO,CAAC,OAAO,CAAC;IAClE,OAAO,MAAM,mCAAmC,CAAC,IAAI,CAAC,CAAC;AACxD,CAAC;AAED,SAAS,wBAAwB;IAChC,MAAM,GAAG,GAAG,OAAO,CAAC,4BAA4B,CAAC,CAAC;IAClD,IAAI,CAAC,GAAG;QAAE,OAAO,SAAS,CAAC;IAC3B,MAAM,UAAU,GAAG,GAAG,CAAC,WAAW,EAAE,CAAC;IACrC,IAAI,UAAU,KAAK,OAAO;QAAE,OAAO,OAAO,CAAC;IAC3C,IAAI,UAAU,KAAK,SAAS;QAAE,OAAO,SAAS,CAAC;IAC/C,IAAI,UAAU,KAAK,OAAO;QAAE,OAAO,OAAO,CAAC;IAC3C,OAAO,SAAS,CAAC;AAClB,CAAC;AAED,SAAS,yBAAyB;IACjC,MAAM,GAAG,GAAG,OAAO,CAAC,qBAAqB,CAAC,IAAI,EAAE,CAAC;IACjD,MAAM,KAAK,GACV,GAAG,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,IAAI,EAAE,CAAC,WAAW,EAAE,KAAK,KAAK,CAAC,IAAI,CAAC,CAAC,OAAO,CAAC,kBAAkB,CAAC,CAAC;IAC/F,OAAO,KAAK,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC,OAAO,CAAC;AACpC,CAAC;AAED,KAAK,UAAU,uBAAuB,CAAC,WAAmB,EAAE,UAAkB;IAC7E,MAAM,GAAG,GAAG,MAAM,WAAW,CAC5B,WAAW,EACX;QACC,WAAW;QACX,uBAAuB;QACvB,UAAU,WAAW,EAAE;QACvB,UAAU;QACV,+BAA+B;KAC/B,EACD,EAAE,SAAS,EAAE,KAAK,EAAE,CACpB,CAAC;IACF,MAAM,QAAQ,GAAG,WAAW,CAAC;IAC7B,IAAI,GAAG,CAAC,IAAI,KAAK,CAAC;QAAE,OAAO,QAAQ,CAAC;IACpC,MAAM,GAAG,GAAG,GAAG,CAAC,MAAM,CAAC,IAAI,EAAE,CAAC;IAC9B,IAAI,CAAC,GAAG;QAAE,OAAO,QAAQ,CAAC;IAC1B,OAAO,GAAG,CAAC,UAAU,CAAC,GAAG,EAAE,EAAE,CAAC,CAAC,IAAI,EAAE,IAAI,QAAQ,CAAC;AACnD,CAAC;AAED,SAAS,OAAO,CAAC,GAAW;IAC3B,MAAM,KAAK,GAAG,OAAO,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC;IAC/B,MAAM,OAAO,GAAG,OAAO,KAAK,KAAK,QAAQ,CAAC,CAAC,CAAC,KAAK,CAAC,IAAI,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;IAC9D,OAAO,OAAO,CAAC,MAAM,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,SAAS,CAAC;AAC7C,CAAC"}
\ No newline at end of file
diff --git a/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/providers/chromeSqlite/shared.d.ts b/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/providers/chromeSqlite/shared.d.ts
deleted file mode 100644
index 3e6205f..0000000
--- a/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/providers/chromeSqlite/shared.d.ts
+++ /dev/null
@@ -1,10 +0,0 @@
-import type { GetCookiesResult } from '../../types.js';
-export declare function getCookiesFromChromeSqliteDb(options: {
- dbPath: string;
- profile?: string;
- includeExpired?: boolean;
- debug?: boolean;
-}, origins: string[], allowlistNames: Set | null, decrypt: (encryptedValue: Uint8Array, options: {
- stripHashPrefix: boolean;
-}) => string | null): Promise;
-//# sourceMappingURL=shared.d.ts.map
\ No newline at end of file
diff --git a/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/providers/chromeSqlite/shared.d.ts.map b/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/providers/chromeSqlite/shared.d.ts.map
deleted file mode 100644
index dccba66..0000000
--- a/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/providers/chromeSqlite/shared.d.ts.map
+++ /dev/null
@@ -1 +0,0 @@
-{"version":3,"file":"shared.d.ts","sourceRoot":"","sources":["../../../src/providers/chromeSqlite/shared.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAA0B,gBAAgB,EAAE,MAAM,gBAAgB,CAAC;AAkB/E,wBAAsB,4BAA4B,CACjD,OAAO,EAAE;IAAE,MAAM,EAAE,MAAM,CAAC;IAAC,OAAO,CAAC,EAAE,MAAM,CAAC;IAAC,cAAc,CAAC,EAAE,OAAO,CAAC;IAAC,KAAK,CAAC,EAAE,OAAO,CAAA;CAAE,EACxF,OAAO,EAAE,MAAM,EAAE,EACjB,cAAc,EAAE,GAAG,CAAC,MAAM,CAAC,GAAG,IAAI,EAClC,OAAO,EAAE,CAAC,cAAc,EAAE,UAAU,EAAE,OAAO,EAAE;IAAE,eAAe,EAAE,OAAO,CAAA;CAAE,KAAK,MAAM,GAAG,IAAI,GAC3F,OAAO,CAAC,gBAAgB,CAAC,CAsD3B"}
\ No newline at end of file
diff --git a/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/providers/chromeSqlite/shared.js b/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/providers/chromeSqlite/shared.js
deleted file mode 100644
index 59ef6e4..0000000
--- a/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/providers/chromeSqlite/shared.js
+++ /dev/null
@@ -1,293 +0,0 @@
-import { copyFileSync, existsSync, mkdtempSync, rmSync } from 'node:fs';
-import { tmpdir } from 'node:os';
-import path from 'node:path';
-import { normalizeExpiration } from '../../util/expire.js';
-import { hostMatchesCookieDomain } from '../../util/hostMatch.js';
-import { importNodeSqlite, supportsReadBigInts } from '../../util/nodeSqlite.js';
-import { isBunRuntime } from '../../util/runtime.js';
-export async function getCookiesFromChromeSqliteDb(options, origins, allowlistNames, decrypt) {
- const warnings = [];
- // Chrome can keep its cookie DB locked and/or rely on WAL sidecars.
- // Copying to a temp dir gives us a stable snapshot that both node:sqlite and bun:sqlite can open.
- const tempDir = mkdtempSync(path.join(tmpdir(), 'sweet-cookie-chrome-'));
- const tempDbPath = path.join(tempDir, 'Cookies');
- try {
- copyFileSync(options.dbPath, tempDbPath);
- // If WAL is enabled, the latest writes might live in `Cookies-wal`/`Cookies-shm`.
- // Copy them too when present so our snapshot reflects the current browser state.
- copySidecar(options.dbPath, `${tempDbPath}-wal`, '-wal');
- copySidecar(options.dbPath, `${tempDbPath}-shm`, '-shm');
- }
- catch (error) {
- rmSync(tempDir, { recursive: true, force: true });
- warnings.push(`Failed to copy Chrome cookie DB: ${error instanceof Error ? error.message : String(error)}`);
- return { cookies: [], warnings };
- }
- try {
- const hosts = origins.map((o) => new URL(o).hostname);
- const where = buildHostWhereClause(hosts, 'host_key');
- const metaVersion = await readChromiumMetaVersion(tempDbPath);
- // Chromium >= 24 stores a 32-byte hash prefix in decrypted cookie values.
- // We detect this via the `meta` table version and strip it when present.
- const stripHashPrefix = metaVersion >= 24;
- const rowsResult = await readChromeRows(tempDbPath, where);
- if (!rowsResult.ok) {
- warnings.push(rowsResult.error);
- return { cookies: [], warnings };
- }
- const collectOptions = {};
- if (options.profile)
- collectOptions.profile = options.profile;
- if (options.includeExpired !== undefined)
- collectOptions.includeExpired = options.includeExpired;
- const cookies = collectChromeCookiesFromRows(rowsResult.rows, collectOptions, hosts, allowlistNames, (encryptedValue) => decrypt(encryptedValue, { stripHashPrefix }), warnings);
- return { cookies: dedupeCookies(cookies), warnings };
- }
- finally {
- rmSync(tempDir, { recursive: true, force: true });
- }
-}
-function collectChromeCookiesFromRows(rows, options, hosts, allowlistNames, decrypt, warnings) {
- const cookies = [];
- const now = Math.floor(Date.now() / 1000);
- let warnedEncryptedType = false;
- for (const row of rows) {
- const name = typeof row.name === 'string' ? row.name : null;
- if (!name)
- continue;
- if (allowlistNames && allowlistNames.size > 0 && !allowlistNames.has(name))
- continue;
- const hostKey = typeof row.host_key === 'string' ? row.host_key : null;
- if (!hostKey)
- continue;
- if (!hostMatchesAny(hosts, hostKey))
- continue;
- const rowPath = typeof row.path === 'string' ? row.path : '';
- const valueString = typeof row.value === 'string' ? row.value : null;
- let value = valueString;
- if (value === null || value.length === 0) {
- // Many modern Chromium cookies keep `value` empty and only store `encrypted_value`.
- // We decrypt on demand and drop rows we can't interpret.
- const encryptedBytes = getEncryptedBytes(row);
- if (!encryptedBytes) {
- if (!warnedEncryptedType && row.encrypted_value !== undefined) {
- warnings.push('Chrome cookie encrypted_value is in an unsupported type.');
- warnedEncryptedType = true;
- }
- continue;
- }
- value = decrypt(encryptedBytes);
- }
- if (value === null)
- continue;
- const expiresRaw = typeof row.expires_utc === 'number' || typeof row.expires_utc === 'bigint'
- ? row.expires_utc
- : tryParseInt(row.expires_utc);
- const expires = normalizeExpiration(expiresRaw ?? undefined);
- if (!options.includeExpired) {
- if (expires && expires < now)
- continue;
- }
- const secure = row.is_secure === 1 ||
- row.is_secure === 1n ||
- row.is_secure === '1' ||
- row.is_secure === true;
- const httpOnly = row.is_httponly === 1 ||
- row.is_httponly === 1n ||
- row.is_httponly === '1' ||
- row.is_httponly === true;
- const sameSite = normalizeChromiumSameSite(row.samesite);
- const source = { browser: 'chrome' };
- if (options.profile)
- source.profile = options.profile;
- const cookie = {
- name,
- value,
- domain: hostKey.startsWith('.') ? hostKey.slice(1) : hostKey,
- path: rowPath || '/',
- secure,
- httpOnly,
- source,
- };
- if (expires !== undefined)
- cookie.expires = expires;
- if (sameSite !== undefined)
- cookie.sameSite = sameSite;
- cookies.push(cookie);
- }
- return cookies;
-}
-function tryParseInt(value) {
- if (typeof value === 'bigint') {
- const parsed = Number(value);
- return Number.isFinite(parsed) ? parsed : null;
- }
- if (typeof value !== 'string')
- return null;
- const parsed = Number.parseInt(value, 10);
- return Number.isFinite(parsed) ? parsed : null;
-}
-function normalizeChromiumSameSite(value) {
- if (typeof value === 'bigint') {
- const parsed = Number(value);
- return Number.isFinite(parsed) ? normalizeChromiumSameSite(parsed) : undefined;
- }
- if (typeof value === 'number') {
- if (value === 2)
- return 'Strict';
- if (value === 1)
- return 'Lax';
- if (value === 0)
- return 'None';
- return undefined;
- }
- if (typeof value === 'string') {
- const parsed = Number.parseInt(value, 10);
- if (Number.isFinite(parsed))
- return normalizeChromiumSameSite(parsed);
- const normalized = value.toLowerCase();
- if (normalized === 'strict')
- return 'Strict';
- if (normalized === 'lax')
- return 'Lax';
- if (normalized === 'none' || normalized === 'no_restriction')
- return 'None';
- }
- return undefined;
-}
-function getEncryptedBytes(row) {
- const raw = row.encrypted_value;
- if (raw instanceof Uint8Array)
- return raw;
- return null;
-}
-async function readChromiumMetaVersion(dbPath) {
- const sql = `SELECT value FROM meta WHERE key = 'version'`;
- const result = isBunRuntime()
- ? await queryNodeOrBun({ kind: 'bun', dbPath, sql })
- : await queryNodeOrBun({ kind: 'node', dbPath, sql });
- if (!result.ok)
- return 0;
- const first = result.rows[0];
- const value = first?.value;
- if (typeof value === 'number')
- return Math.floor(value);
- if (typeof value === 'bigint') {
- const parsed = Number(value);
- return Number.isFinite(parsed) ? Math.floor(parsed) : 0;
- }
- if (typeof value === 'string') {
- const parsed = Number.parseInt(value, 10);
- return Number.isFinite(parsed) ? parsed : 0;
- }
- return 0;
-}
-async function readChromeRows(dbPath, where) {
- const sqliteKind = isBunRuntime() ? 'bun' : 'node';
- const sqliteLabel = sqliteKind === 'bun' ? 'bun:sqlite' : 'node:sqlite';
- const sql = `SELECT name, value, host_key, path, expires_utc, samesite, encrypted_value, ` +
- `is_secure AS is_secure, is_httponly AS is_httponly ` +
- `FROM cookies WHERE (${where}) ORDER BY expires_utc DESC;`;
- const result = await queryNodeOrBun({ kind: sqliteKind, dbPath, sql });
- if (result.ok)
- return { ok: true, rows: result.rows };
- // Intentionally strict: only support modern Chromium cookie DB schemas.
- // If this fails, assume the local Chrome/Chromium is too old or uses a non-standard schema.
- return {
- ok: false,
- error: `${sqliteLabel} failed reading Chrome cookies (requires modern Chromium, e.g. Chrome >= 100): ${result.error}`,
- };
-}
-async function queryNodeOrBun(options) {
- try {
- if (options.kind === 'node') {
- // Node's `node:sqlite` is synchronous and returns plain JS values. Keep it boxed in a
- // small scope so callers don't need to care about runtime differences.
- const { DatabaseSync } = await importNodeSqlite();
- const dbOptions = { readOnly: true };
- if (supportsReadBigInts()) {
- dbOptions.readBigInts = true;
- }
- const db = new DatabaseSync(options.dbPath, dbOptions);
- try {
- const rows = db.prepare(options.sql).all();
- return { ok: true, rows };
- }
- finally {
- db.close();
- }
- }
- // Bun's sqlite API has a different surface (`Database` + `.query().all()`).
- const { Database } = await import('bun:sqlite');
- const db = new Database(options.dbPath, { readonly: true });
- try {
- const rows = db.query(options.sql).all();
- return { ok: true, rows };
- }
- finally {
- db.close();
- }
- }
- catch (error) {
- return { ok: false, error: error instanceof Error ? error.message : String(error) };
- }
-}
-function copySidecar(sourceDbPath, target, suffix) {
- const sidecar = `${sourceDbPath}${suffix}`;
- if (!existsSync(sidecar))
- return;
- try {
- copyFileSync(sidecar, target);
- }
- catch {
- // ignore
- }
-}
-function buildHostWhereClause(hosts, column) {
- const clauses = [];
- for (const host of hosts) {
- // Chrome cookies often live on parent domains (e.g. .google.com for gemini.google.com).
- // Include parent domains so the SQL filter doesn't drop valid session cookies.
- for (const candidate of expandHostCandidates(host)) {
- const escaped = sqlLiteral(candidate);
- const escapedDot = sqlLiteral(`.${candidate}`);
- const escapedLike = sqlLiteral(`%.${candidate}`);
- clauses.push(`${column} = ${escaped}`);
- clauses.push(`${column} = ${escapedDot}`);
- clauses.push(`${column} LIKE ${escapedLike}`);
- }
- }
- return clauses.length ? clauses.join(' OR ') : '1=0';
-}
-function sqlLiteral(value) {
- const escaped = value.replaceAll("'", "''");
- return `'${escaped}'`;
-}
-function expandHostCandidates(host) {
- const parts = host.split('.').filter(Boolean);
- if (parts.length <= 1)
- return [host];
- const candidates = new Set();
- candidates.add(host);
- // Include parent domains down to two labels (avoid TLD-only fragments).
- for (let i = 1; i <= parts.length - 2; i += 1) {
- const candidate = parts.slice(i).join('.');
- if (candidate)
- candidates.add(candidate);
- }
- return Array.from(candidates);
-}
-function hostMatchesAny(hosts, cookieHost) {
- const cookieDomain = cookieHost.startsWith('.') ? cookieHost.slice(1) : cookieHost;
- return hosts.some((host) => hostMatchesCookieDomain(host, cookieDomain));
-}
-function dedupeCookies(cookies) {
- const merged = new Map();
- for (const cookie of cookies) {
- const key = `${cookie.name}|${cookie.domain ?? ''}|${cookie.path ?? ''}`;
- if (!merged.has(key))
- merged.set(key, cookie);
- }
- return Array.from(merged.values());
-}
-//# sourceMappingURL=shared.js.map
\ No newline at end of file
diff --git a/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/providers/chromeSqlite/shared.js.map b/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/providers/chromeSqlite/shared.js.map
deleted file mode 100644
index 261d801..0000000
--- a/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/providers/chromeSqlite/shared.js.map
+++ /dev/null
@@ -1 +0,0 @@
-{"version":3,"file":"shared.js","sourceRoot":"","sources":["../../../src/providers/chromeSqlite/shared.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAE,UAAU,EAAE,WAAW,EAAE,MAAM,EAAE,MAAM,SAAS,CAAC;AACxE,OAAO,EAAE,MAAM,EAAE,MAAM,SAAS,CAAC;AACjC,OAAO,IAAI,MAAM,WAAW,CAAC;AAG7B,OAAO,EAAE,mBAAmB,EAAE,MAAM,sBAAsB,CAAC;AAC3D,OAAO,EAAE,uBAAuB,EAAE,MAAM,yBAAyB,CAAC;AAClE,OAAO,EAAE,gBAAgB,EAAE,mBAAmB,EAAE,MAAM,0BAA0B,CAAC;AACjF,OAAO,EAAE,YAAY,EAAE,MAAM,uBAAuB,CAAC;AAcrD,MAAM,CAAC,KAAK,UAAU,4BAA4B,CACjD,OAAwF,EACxF,OAAiB,EACjB,cAAkC,EAClC,OAA6F;IAE7F,MAAM,QAAQ,GAAa,EAAE,CAAC;IAE9B,oEAAoE;IACpE,kGAAkG;IAClG,MAAM,OAAO,GAAG,WAAW,CAAC,IAAI,CAAC,IAAI,CAAC,MAAM,EAAE,EAAE,sBAAsB,CAAC,CAAC,CAAC;IACzE,MAAM,UAAU,GAAG,IAAI,CAAC,IAAI,CAAC,OAAO,EAAE,SAAS,CAAC,CAAC;IACjD,IAAI,CAAC;QACJ,YAAY,CAAC,OAAO,CAAC,MAAM,EAAE,UAAU,CAAC,CAAC;QACzC,kFAAkF;QAClF,iFAAiF;QACjF,WAAW,CAAC,OAAO,CAAC,MAAM,EAAE,GAAG,UAAU,MAAM,EAAE,MAAM,CAAC,CAAC;QACzD,WAAW,CAAC,OAAO,CAAC,MAAM,EAAE,GAAG,UAAU,MAAM,EAAE,MAAM,CAAC,CAAC;IAC1D,CAAC;IAAC,OAAO,KAAK,EAAE,CAAC;QAChB,MAAM,CAAC,OAAO,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,KAAK,EAAE,IAAI,EAAE,CAAC,CAAC;QAClD,QAAQ,CAAC,IAAI,CACZ,oCAAoC,KAAK,YAAY,KAAK,CAAC,CAAC,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,EAAE,CAC5F,CAAC;QACF,OAAO,EAAE,OAAO,EAAE,EAAE,EAAE,QAAQ,EAAE,CAAC;IAClC,CAAC;IAED,IAAI,CAAC;QACJ,MAAM,KAAK,GAAG,OAAO,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,IAAI,GAAG,CAAC,CAAC,CAAC,CAAC,QAAQ,CAAC,CAAC;QACtD,MAAM,KAAK,GAAG,oBAAoB,CAAC,KAAK,EAAE,UAAU,CAAC,CAAC;QAEtD,MAAM,WAAW,GAAG,MAAM,uBAAuB,CAAC,UAAU,CAAC,CAAC;QAC9D,0EAA0E;QAC1E,yEAAyE;QACzE,MAAM,eAAe,GAAG,WAAW,IAAI,EAAE,CAAC;QAE1C,MAAM,UAAU,GAAG,MAAM,cAAc,CAAC,UAAU,EAAE,KAAK,CAAC,CAAC;QAC3D,IAAI,CAAC,UAAU,CAAC,EAAE,EAAE,CAAC;YACpB,QAAQ,CAAC,IAAI,CAAC,UAAU,CAAC,KAAK,CAAC,CAAC;YAChC,OAAO,EAAE,OAAO,EAAE,EAAE,EAAE,QAAQ,EAAE,CAAC;QAClC,CAAC;QAED,MAAM,cAAc,GAAmD,EAAE,CAAC;QAC1E,IAAI,OAAO,CAAC,OAAO;YAAE,cAAc,CAAC,OAAO,GAAG,OAAO,CAAC,OAAO,CAAC;QAC9D,IAAI,OAAO,CAAC,cAAc,KAAK,SAAS;YACvC,cAAc,CAAC,cAAc,GAAG,OAAO,CAAC,cAAc,CAAC;QAExD,MAAM,OAAO,GAAG,4BAA4B,CAC3C,UAAU,CAAC,IAAI,EACf,cAAc,EACd,KAAK,EACL,cAAc,EACd,CAAC,cAAc,EAAE,EAAE,CAAC,OAAO,CAAC,cAAc,EAAE,EAAE,eAAe,EAAE,CAAC,EAChE,QAAQ,CACR,CAAC;QAEF,OAAO,EAAE,OAAO,EAAE,aAAa,CAAC,OAAO,CAAC,EAAE,QAAQ,EAAE,CAAC;IACtD,CAAC;YAAS,CAAC;QACV,MAAM,CAAC,OAAO,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,KAAK,EAAE,IAAI,EAAE,CAAC,CAAC;IACnD,CAAC;AACF,CAAC;AAED,SAAS,4BAA4B,CACpC,IAAiB,EACjB,OAAuD,EACvD,KAAe,EACf,cAAkC,EAClC,OAAsD,EACtD,QAAkB;IAElB,MAAM,OAAO,GAAa,EAAE,CAAC;IAC7B,MAAM,GAAG,GAAG,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,GAAG,EAAE,GAAG,IAAI,CAAC,CAAC;IAC1C,IAAI,mBAAmB,GAAG,KAAK,CAAC;IAEhC,KAAK,MAAM,GAAG,IAAI,IAAI,EAAE,CAAC;QACxB,MAAM,IAAI,GAAG,OAAO,GAAG,CAAC,IAAI,KAAK,QAAQ,CAAC,CAAC,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC,CAAC,IAAI,CAAC;QAC5D,IAAI,CAAC,IAAI;YAAE,SAAS;QACpB,IAAI,cAAc,IAAI,cAAc,CAAC,IAAI,GAAG,CAAC,IAAI,CAAC,cAAc,CAAC,GAAG,CAAC,IAAI,CAAC;YAAE,SAAS;QAErF,MAAM,OAAO,GAAG,OAAO,GAAG,CAAC,QAAQ,KAAK,QAAQ,CAAC,CAAC,CAAC,GAAG,CAAC,QAAQ,CAAC,CAAC,CAAC,IAAI,CAAC;QACvE,IAAI,CAAC,OAAO;YAAE,SAAS;QACvB,IAAI,CAAC,cAAc,CAAC,KAAK,EAAE,OAAO,CAAC;YAAE,SAAS;QAE9C,MAAM,OAAO,GAAG,OAAO,GAAG,CAAC,IAAI,KAAK,QAAQ,CAAC,CAAC,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,CAAC;QAE7D,MAAM,WAAW,GAAG,OAAO,GAAG,CAAC,KAAK,KAAK,QAAQ,CAAC,CAAC,CAAC,GAAG,CAAC,KAAK,CAAC,CAAC,CAAC,IAAI,CAAC;QACrE,IAAI,KAAK,GAAkB,WAAW,CAAC;QACvC,IAAI,KAAK,KAAK,IAAI,IAAI,KAAK,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;YAC1C,oFAAoF;YACpF,yDAAyD;YACzD,MAAM,cAAc,GAAG,iBAAiB,CAAC,GAAG,CAAC,CAAC;YAC9C,IAAI,CAAC,cAAc,EAAE,CAAC;gBACrB,IAAI,CAAC,mBAAmB,IAAI,GAAG,CAAC,eAAe,KAAK,SAAS,EAAE,CAAC;oBAC/D,QAAQ,CAAC,IAAI,CAAC,0DAA0D,CAAC,CAAC;oBAC1E,mBAAmB,GAAG,IAAI,CAAC;gBAC5B,CAAC;gBACD,SAAS;YACV,CAAC;YACD,KAAK,GAAG,OAAO,CAAC,cAAc,CAAC,CAAC;QACjC,CAAC;QACD,IAAI,KAAK,KAAK,IAAI;YAAE,SAAS;QAE7B,MAAM,UAAU,GACf,OAAO,GAAG,CAAC,WAAW,KAAK,QAAQ,IAAI,OAAO,GAAG,CAAC,WAAW,KAAK,QAAQ;YACzE,CAAC,CAAC,GAAG,CAAC,WAAW;YACjB,CAAC,CAAC,WAAW,CAAC,GAAG,CAAC,WAAW,CAAC,CAAC;QACjC,MAAM,OAAO,GAAG,mBAAmB,CAAC,UAAU,IAAI,SAAS,CAAC,CAAC;QAE7D,IAAI,CAAC,OAAO,CAAC,cAAc,EAAE,CAAC;YAC7B,IAAI,OAAO,IAAI,OAAO,GAAG,GAAG;gBAAE,SAAS;QACxC,CAAC;QAED,MAAM,MAAM,GACX,GAAG,CAAC,SAAS,KAAK,CAAC;YACnB,GAAG,CAAC,SAAS,KAAK,EAAE;YACpB,GAAG,CAAC,SAAS,KAAK,GAAG;YACrB,GAAG,CAAC,SAAS,KAAK,IAAI,CAAC;QACxB,MAAM,QAAQ,GACb,GAAG,CAAC,WAAW,KAAK,CAAC;YACrB,GAAG,CAAC,WAAW,KAAK,EAAE;YACtB,GAAG,CAAC,WAAW,KAAK,GAAG;YACvB,GAAG,CAAC,WAAW,KAAK,IAAI,CAAC;QAE1B,MAAM,QAAQ,GAAG,yBAAyB,CAAC,GAAG,CAAC,QAAQ,CAAC,CAAC;QAEzD,MAAM,MAAM,GAAkC,EAAE,OAAO,EAAE,QAAQ,EAAE,CAAC;QACpE,IAAI,OAAO,CAAC,OAAO;YAAE,MAAM,CAAC,OAAO,GAAG,OAAO,CAAC,OAAO,CAAC;QAEtD,MAAM,MAAM,GAAW;YACtB,IAAI;YACJ,KAAK;YACL,MAAM,EAAE,OAAO,CAAC,UAAU,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,OAAO;YAC5D,IAAI,EAAE,OAAO,IAAI,GAAG;YACpB,MAAM;YACN,QAAQ;YACR,MAAM;SACN,CAAC;QACF,IAAI,OAAO,KAAK,SAAS;YAAE,MAAM,CAAC,OAAO,GAAG,OAAO,CAAC;QACpD,IAAI,QAAQ,KAAK,SAAS;YAAE,MAAM,CAAC,QAAQ,GAAG,QAAQ,CAAC;QAEvD,OAAO,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC;IACtB,CAAC;IAED,OAAO,OAAO,CAAC;AAChB,CAAC;AAED,SAAS,WAAW,CAAC,KAAc;IAClC,IAAI,OAAO,KAAK,KAAK,QAAQ,EAAE,CAAC;QAC/B,MAAM,MAAM,GAAG,MAAM,CAAC,KAAK,CAAC,CAAC;QAC7B,OAAO,MAAM,CAAC,QAAQ,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,IAAI,CAAC;IAChD,CAAC;IACD,IAAI,OAAO,KAAK,KAAK,QAAQ;QAAE,OAAO,IAAI,CAAC;IAC3C,MAAM,MAAM,GAAG,MAAM,CAAC,QAAQ,CAAC,KAAK,EAAE,EAAE,CAAC,CAAC;IAC1C,OAAO,MAAM,CAAC,QAAQ,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,IAAI,CAAC;AAChD,CAAC;AAED,SAAS,yBAAyB,CAAC,KAAc;IAChD,IAAI,OAAO,KAAK,KAAK,QAAQ,EAAE,CAAC;QAC/B,MAAM,MAAM,GAAG,MAAM,CAAC,KAAK,CAAC,CAAC;QAC7B,OAAO,MAAM,CAAC,QAAQ,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,yBAAyB,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,SAAS,CAAC;IAChF,CAAC;IACD,IAAI,OAAO,KAAK,KAAK,QAAQ,EAAE,CAAC;QAC/B,IAAI,KAAK,KAAK,CAAC;YAAE,OAAO,QAAQ,CAAC;QACjC,IAAI,KAAK,KAAK,CAAC;YAAE,OAAO,KAAK,CAAC;QAC9B,IAAI,KAAK,KAAK,CAAC;YAAE,OAAO,MAAM,CAAC;QAC/B,OAAO,SAAS,CAAC;IAClB,CAAC;IACD,IAAI,OAAO,KAAK,KAAK,QAAQ,EAAE,CAAC;QAC/B,MAAM,MAAM,GAAG,MAAM,CAAC,QAAQ,CAAC,KAAK,EAAE,EAAE,CAAC,CAAC;QAC1C,IAAI,MAAM,CAAC,QAAQ,CAAC,MAAM,CAAC;YAAE,OAAO,yBAAyB,CAAC,MAAM,CAAC,CAAC;QACtE,MAAM,UAAU,GAAG,KAAK,CAAC,WAAW,EAAE,CAAC;QACvC,IAAI,UAAU,KAAK,QAAQ;YAAE,OAAO,QAAQ,CAAC;QAC7C,IAAI,UAAU,KAAK,KAAK;YAAE,OAAO,KAAK,CAAC;QACvC,IAAI,UAAU,KAAK,MAAM,IAAI,UAAU,KAAK,gBAAgB;YAAE,OAAO,MAAM,CAAC;IAC7E,CAAC;IACD,OAAO,SAAS,CAAC;AAClB,CAAC;AAED,SAAS,iBAAiB,CAAC,GAAc;IACxC,MAAM,GAAG,GAAG,GAAG,CAAC,eAAe,CAAC;IAChC,IAAI,GAAG,YAAY,UAAU;QAAE,OAAO,GAAG,CAAC;IAC1C,OAAO,IAAI,CAAC;AACb,CAAC;AAED,KAAK,UAAU,uBAAuB,CAAC,MAAc;IACpD,MAAM,GAAG,GAAG,8CAA8C,CAAC;IAC3D,MAAM,MAAM,GAAG,YAAY,EAAE;QAC5B,CAAC,CAAC,MAAM,cAAc,CAAC,EAAE,IAAI,EAAE,KAAK,EAAE,MAAM,EAAE,GAAG,EAAE,CAAC;QACpD,CAAC,CAAC,MAAM,cAAc,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,MAAM,EAAE,GAAG,EAAE,CAAC,CAAC;IACvD,IAAI,CAAC,MAAM,CAAC,EAAE;QAAE,OAAO,CAAC,CAAC;IACzB,MAAM,KAAK,GAAG,MAAM,CAAC,IAAI,CAAC,CAAC,CAAoC,CAAC;IAChE,MAAM,KAAK,GAAG,KAAK,EAAE,KAAK,CAAC;IAC3B,IAAI,OAAO,KAAK,KAAK,QAAQ;QAAE,OAAO,IAAI,CAAC,KAAK,CAAC,KAAK,CAAC,CAAC;IACxD,IAAI,OAAO,KAAK,KAAK,QAAQ,EAAE,CAAC;QAC/B,MAAM,MAAM,GAAG,MAAM,CAAC,KAAK,CAAC,CAAC;QAC7B,OAAO,MAAM,CAAC,QAAQ,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,KAAK,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;IACzD,CAAC;IACD,IAAI,OAAO,KAAK,KAAK,QAAQ,EAAE,CAAC;QAC/B,MAAM,MAAM,GAAG,MAAM,CAAC,QAAQ,CAAC,KAAK,EAAE,EAAE,CAAC,CAAC;QAC1C,OAAO,MAAM,CAAC,QAAQ,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,CAAC;IAC7C,CAAC;IACD,OAAO,CAAC,CAAC;AACV,CAAC;AAED,KAAK,UAAU,cAAc,CAC5B,MAAc,EACd,KAAa;IAEb,MAAM,UAAU,GAAG,YAAY,EAAE,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,MAAM,CAAC;IACnD,MAAM,WAAW,GAAG,UAAU,KAAK,KAAK,CAAC,CAAC,CAAC,YAAY,CAAC,CAAC,CAAC,aAAa,CAAC;IAExE,MAAM,GAAG,GACR,8EAA8E;QAC9E,qDAAqD;QACrD,uBAAuB,KAAK,8BAA8B,CAAC;IAE5D,MAAM,MAAM,GAAG,MAAM,cAAc,CAAC,EAAE,IAAI,EAAE,UAAU,EAAE,MAAM,EAAE,GAAG,EAAE,CAAC,CAAC;IACvE,IAAI,MAAM,CAAC,EAAE;QAAE,OAAO,EAAE,EAAE,EAAE,IAAI,EAAE,IAAI,EAAE,MAAM,CAAC,IAAmB,EAAE,CAAC;IAErE,wEAAwE;IACxE,4FAA4F;IAC5F,OAAO;QACN,EAAE,EAAE,KAAK;QACT,KAAK,EAAE,GAAG,WAAW,kFAAkF,MAAM,CAAC,KAAK,EAAE;KACrH,CAAC;AACH,CAAC;AAED,KAAK,UAAU,cAAc,CAAC,OAI7B;IACA,IAAI,CAAC;QACJ,IAAI,OAAO,CAAC,IAAI,KAAK,MAAM,EAAE,CAAC;YAC7B,sFAAsF;YACtF,uEAAuE;YACvE,MAAM,EAAE,YAAY,EAAE,GAAG,MAAM,gBAAgB,EAAE,CAAC;YAClD,MAAM,SAAS,GAAiD,EAAE,QAAQ,EAAE,IAAI,EAAE,CAAC;YACnF,IAAI,mBAAmB,EAAE,EAAE,CAAC;gBAC3B,SAAS,CAAC,WAAW,GAAG,IAAI,CAAC;YAC9B,CAAC;YACD,MAAM,EAAE,GAAG,IAAI,YAAY,CAAC,OAAO,CAAC,MAAM,EAAE,SAAS,CAAC,CAAC;YACvD,IAAI,CAAC;gBACJ,MAAM,IAAI,GAAG,EAAE,CAAC,OAAO,CAAC,OAAO,CAAC,GAAG,CAAC,CAAC,GAAG,EAAoC,CAAC;gBAC7E,OAAO,EAAE,EAAE,EAAE,IAAI,EAAE,IAAI,EAAE,CAAC;YAC3B,CAAC;oBAAS,CAAC;gBACV,EAAE,CAAC,KAAK,EAAE,CAAC;YACZ,CAAC;QACF,CAAC;QACD,4EAA4E;QAC5E,MAAM,EAAE,QAAQ,EAAE,GAAG,MAAM,MAAM,CAAC,YAAY,CAAC,CAAC;QAChD,MAAM,EAAE,GAAG,IAAI,QAAQ,CAAC,OAAO,CAAC,MAAM,EAAE,EAAE,QAAQ,EAAE,IAAI,EAAE,CAAC,CAAC;QAC5D,IAAI,CAAC;YACJ,MAAM,IAAI,GAAG,EAAE,CAAC,KAAK,CAAC,OAAO,CAAC,GAAG,CAAC,CAAC,GAAG,EAAoC,CAAC;YAC3E,OAAO,EAAE,EAAE,EAAE,IAAI,EAAE,IAAI,EAAE,CAAC;QAC3B,CAAC;gBAAS,CAAC;YACV,EAAE,CAAC,KAAK,EAAE,CAAC;QACZ,CAAC;IACF,CAAC;IAAC,OAAO,KAAK,EAAE,CAAC;QAChB,OAAO,EAAE,EAAE,EAAE,KAAK,EAAE,KAAK,EAAE,KAAK,YAAY,KAAK,CAAC,CAAC,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,EAAE,CAAC;IACrF,CAAC;AACF,CAAC;AAED,SAAS,WAAW,CAAC,YAAoB,EAAE,MAAc,EAAE,MAAuB;IACjF,MAAM,OAAO,GAAG,GAAG,YAAY,GAAG,MAAM,EAAE,CAAC;IAC3C,IAAI,CAAC,UAAU,CAAC,OAAO,CAAC;QAAE,OAAO;IACjC,IAAI,CAAC;QACJ,YAAY,CAAC,OAAO,EAAE,MAAM,CAAC,CAAC;IAC/B,CAAC;IAAC,MAAM,CAAC;QACR,SAAS;IACV,CAAC;AACF,CAAC;AAED,SAAS,oBAAoB,CAAC,KAAe,EAAE,MAAkB;IAChE,MAAM,OAAO,GAAa,EAAE,CAAC;IAC7B,KAAK,MAAM,IAAI,IAAI,KAAK,EAAE,CAAC;QAC1B,wFAAwF;QACxF,+EAA+E;QAC/E,KAAK,MAAM,SAAS,IAAI,oBAAoB,CAAC,IAAI,CAAC,EAAE,CAAC;YACpD,MAAM,OAAO,GAAG,UAAU,CAAC,SAAS,CAAC,CAAC;YACtC,MAAM,UAAU,GAAG,UAAU,CAAC,IAAI,SAAS,EAAE,CAAC,CAAC;YAC/C,MAAM,WAAW,GAAG,UAAU,CAAC,KAAK,SAAS,EAAE,CAAC,CAAC;YACjD,OAAO,CAAC,IAAI,CAAC,GAAG,MAAM,MAAM,OAAO,EAAE,CAAC,CAAC;YACvC,OAAO,CAAC,IAAI,CAAC,GAAG,MAAM,MAAM,UAAU,EAAE,CAAC,CAAC;YAC1C,OAAO,CAAC,IAAI,CAAC,GAAG,MAAM,SAAS,WAAW,EAAE,CAAC,CAAC;QAC/C,CAAC;IACF,CAAC;IACD,OAAO,OAAO,CAAC,MAAM,CAAC,CAAC,CAAC,OAAO,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC;AACtD,CAAC;AAED,SAAS,UAAU,CAAC,KAAa;IAChC,MAAM,OAAO,GAAG,KAAK,CAAC,UAAU,CAAC,GAAG,EAAE,IAAI,CAAC,CAAC;IAC5C,OAAO,IAAI,OAAO,GAAG,CAAC;AACvB,CAAC;AAED,SAAS,oBAAoB,CAAC,IAAY;IACzC,MAAM,KAAK,GAAG,IAAI,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,MAAM,CAAC,OAAO,CAAC,CAAC;IAC9C,IAAI,KAAK,CAAC,MAAM,IAAI,CAAC;QAAE,OAAO,CAAC,IAAI,CAAC,CAAC;IACrC,MAAM,UAAU,GAAG,IAAI,GAAG,EAAU,CAAC;IACrC,UAAU,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC;IACrB,wEAAwE;IACxE,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,IAAI,KAAK,CAAC,MAAM,GAAG,CAAC,EAAE,CAAC,IAAI,CAAC,EAAE,CAAC;QAC/C,MAAM,SAAS,GAAG,KAAK,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;QAC3C,IAAI,SAAS;YAAE,UAAU,CAAC,GAAG,CAAC,SAAS,CAAC,CAAC;IAC1C,CAAC;IACD,OAAO,KAAK,CAAC,IAAI,CAAC,UAAU,CAAC,CAAC;AAC/B,CAAC;AAED,SAAS,cAAc,CAAC,KAAe,EAAE,UAAkB;IAC1D,MAAM,YAAY,GAAG,UAAU,CAAC,UAAU,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,UAAU,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,UAAU,CAAC;IACnF,OAAO,KAAK,CAAC,IAAI,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,uBAAuB,CAAC,IAAI,EAAE,YAAY,CAAC,CAAC,CAAC;AAC1E,CAAC;AAED,SAAS,aAAa,CAAC,OAAiB;IACvC,MAAM,MAAM,GAAG,IAAI,GAAG,EAAkB,CAAC;IACzC,KAAK,MAAM,MAAM,IAAI,OAAO,EAAE,CAAC;QAC9B,MAAM,GAAG,GAAG,GAAG,MAAM,CAAC,IAAI,IAAI,MAAM,CAAC,MAAM,IAAI,EAAE,IAAI,MAAM,CAAC,IAAI,IAAI,EAAE,EAAE,CAAC;QACzE,IAAI,CAAC,MAAM,CAAC,GAAG,CAAC,GAAG,CAAC;YAAE,MAAM,CAAC,GAAG,CAAC,GAAG,EAAE,MAAM,CAAC,CAAC;IAC/C,CAAC;IACD,OAAO,KAAK,CAAC,IAAI,CAAC,MAAM,CAAC,MAAM,EAAE,CAAC,CAAC;AACpC,CAAC"}
\ No newline at end of file
diff --git a/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/providers/chromeSqlite/windowsDpapi.d.ts b/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/providers/chromeSqlite/windowsDpapi.d.ts
deleted file mode 100644
index 9da1305..0000000
--- a/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/providers/chromeSqlite/windowsDpapi.d.ts
+++ /dev/null
@@ -1,10 +0,0 @@
-export declare function dpapiUnprotect(data: Buffer, options?: {
- timeoutMs?: number;
-}): Promise<{
- ok: true;
- value: Buffer;
-} | {
- ok: false;
- error: string;
-}>;
-//# sourceMappingURL=windowsDpapi.d.ts.map
\ No newline at end of file
diff --git a/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/providers/chromeSqlite/windowsDpapi.d.ts.map b/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/providers/chromeSqlite/windowsDpapi.d.ts.map
deleted file mode 100644
index cd94668..0000000
--- a/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/providers/chromeSqlite/windowsDpapi.d.ts.map
+++ /dev/null
@@ -1 +0,0 @@
-{"version":3,"file":"windowsDpapi.d.ts","sourceRoot":"","sources":["../../../src/providers/chromeSqlite/windowsDpapi.ts"],"names":[],"mappings":"AAEA,wBAAsB,cAAc,CACnC,IAAI,EAAE,MAAM,EACZ,OAAO,GAAE;IAAE,SAAS,CAAC,EAAE,MAAM,CAAA;CAAO,GAClC,OAAO,CAAC;IAAE,EAAE,EAAE,IAAI,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,GAAG;IAAE,EAAE,EAAE,KAAK,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,CAAC,CA+BrE"}
\ No newline at end of file
diff --git a/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/providers/chromeSqlite/windowsDpapi.js b/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/providers/chromeSqlite/windowsDpapi.js
deleted file mode 100644
index d0915bf..0000000
--- a/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/providers/chromeSqlite/windowsDpapi.js
+++ /dev/null
@@ -1,26 +0,0 @@
-import { execCapture } from '../../util/exec.js';
-export async function dpapiUnprotect(data, options = {}) {
- const timeoutMs = options.timeoutMs ?? 5_000;
- // There is no cross-platform JS API for Windows DPAPI, and we explicitly avoid native addons.
- // PowerShell can call ProtectedData.Unprotect for the current user, which matches Chrome's behavior.
- const inputB64 = data.toString('base64');
- const prelude = 'try { Add-Type -AssemblyName System.Security.Cryptography.ProtectedData -ErrorAction Stop } catch { try { Add-Type -AssemblyName System.Security -ErrorAction Stop } catch {} };';
- const script = prelude +
- `$in=[Convert]::FromBase64String('${inputB64}');` +
- `$out=[System.Security.Cryptography.ProtectedData]::Unprotect($in,$null,[System.Security.Cryptography.DataProtectionScope]::CurrentUser);` +
- `[Convert]::ToBase64String($out)`;
- const res = await execCapture('powershell', ['-NoProfile', '-NonInteractive', '-Command', script], {
- timeoutMs,
- });
- if (res.code !== 0) {
- return { ok: false, error: res.stderr.trim() || `powershell exit ${res.code}` };
- }
- try {
- const out = Buffer.from(res.stdout.trim(), 'base64');
- return { ok: true, value: out };
- }
- catch (error) {
- return { ok: false, error: error instanceof Error ? error.message : String(error) };
- }
-}
-//# sourceMappingURL=windowsDpapi.js.map
\ No newline at end of file
diff --git a/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/providers/chromeSqlite/windowsDpapi.js.map b/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/providers/chromeSqlite/windowsDpapi.js.map
deleted file mode 100644
index f915def..0000000
--- a/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/providers/chromeSqlite/windowsDpapi.js.map
+++ /dev/null
@@ -1 +0,0 @@
-{"version":3,"file":"windowsDpapi.js","sourceRoot":"","sources":["../../../src/providers/chromeSqlite/windowsDpapi.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,WAAW,EAAE,MAAM,oBAAoB,CAAC;AAEjD,MAAM,CAAC,KAAK,UAAU,cAAc,CACnC,IAAY,EACZ,UAAkC,EAAE;IAEpC,MAAM,SAAS,GAAG,OAAO,CAAC,SAAS,IAAI,KAAK,CAAC;IAE7C,8FAA8F;IAC9F,qGAAqG;IACrG,MAAM,QAAQ,GAAG,IAAI,CAAC,QAAQ,CAAC,QAAQ,CAAC,CAAC;IACzC,MAAM,OAAO,GACZ,kLAAkL,CAAC;IACpL,MAAM,MAAM,GACX,OAAO;QACP,oCAAoC,QAAQ,KAAK;QACjD,0IAA0I;QAC1I,iCAAiC,CAAC;IAEnC,MAAM,GAAG,GAAG,MAAM,WAAW,CAC5B,YAAY,EACZ,CAAC,YAAY,EAAE,iBAAiB,EAAE,UAAU,EAAE,MAAM,CAAC,EACrD;QACC,SAAS;KACT,CACD,CAAC;IACF,IAAI,GAAG,CAAC,IAAI,KAAK,CAAC,EAAE,CAAC;QACpB,OAAO,EAAE,EAAE,EAAE,KAAK,EAAE,KAAK,EAAE,GAAG,CAAC,MAAM,CAAC,IAAI,EAAE,IAAI,mBAAmB,GAAG,CAAC,IAAI,EAAE,EAAE,CAAC;IACjF,CAAC;IAED,IAAI,CAAC;QACJ,MAAM,GAAG,GAAG,MAAM,CAAC,IAAI,CAAC,GAAG,CAAC,MAAM,CAAC,IAAI,EAAE,EAAE,QAAQ,CAAC,CAAC;QACrD,OAAO,EAAE,EAAE,EAAE,IAAI,EAAE,KAAK,EAAE,GAAG,EAAE,CAAC;IACjC,CAAC;IAAC,OAAO,KAAK,EAAE,CAAC;QAChB,OAAO,EAAE,EAAE,EAAE,KAAK,EAAE,KAAK,EAAE,KAAK,YAAY,KAAK,CAAC,CAAC,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,EAAE,CAAC;IACrF,CAAC;AACF,CAAC"}
\ No newline at end of file
diff --git a/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/providers/chromeSqliteLinux.d.ts b/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/providers/chromeSqliteLinux.d.ts
deleted file mode 100644
index 354f277..0000000
--- a/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/providers/chromeSqliteLinux.d.ts
+++ /dev/null
@@ -1,7 +0,0 @@
-import type { GetCookiesResult } from '../types.js';
-export declare function getCookiesFromChromeSqliteLinux(options: {
- profile?: string;
- includeExpired?: boolean;
- debug?: boolean;
-}, origins: string[], allowlistNames: Set | null): Promise;
-//# sourceMappingURL=chromeSqliteLinux.d.ts.map
\ No newline at end of file
diff --git a/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/providers/chromeSqliteLinux.d.ts.map b/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/providers/chromeSqliteLinux.d.ts.map
deleted file mode 100644
index c2a7162..0000000
--- a/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/providers/chromeSqliteLinux.d.ts.map
+++ /dev/null
@@ -1 +0,0 @@
-{"version":3,"file":"chromeSqliteLinux.d.ts","sourceRoot":"","sources":["../../src/providers/chromeSqliteLinux.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,aAAa,CAAC;AASpD,wBAAsB,+BAA+B,CACpD,OAAO,EAAE;IAAE,OAAO,CAAC,EAAE,MAAM,CAAC;IAAC,cAAc,CAAC,EAAE,OAAO,CAAC;IAAC,KAAK,CAAC,EAAE,OAAO,CAAA;CAAE,EACxE,OAAO,EAAE,MAAM,EAAE,EACjB,cAAc,EAAE,GAAG,CAAC,MAAM,CAAC,GAAG,IAAI,GAChC,OAAO,CAAC,gBAAgB,CAAC,CAkD3B"}
\ No newline at end of file
diff --git a/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/providers/chromeSqliteLinux.js b/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/providers/chromeSqliteLinux.js
deleted file mode 100644
index 620e7ef..0000000
--- a/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/providers/chromeSqliteLinux.js
+++ /dev/null
@@ -1,51 +0,0 @@
-import { decryptChromiumAes128CbcCookieValue, deriveAes128CbcKeyFromPassword, } from './chromeSqlite/crypto.js';
-import { getLinuxChromeSafeStoragePassword } from './chromeSqlite/linuxKeyring.js';
-import { getCookiesFromChromeSqliteDb } from './chromeSqlite/shared.js';
-import { resolveChromiumCookiesDbLinux } from './chromium/linuxPaths.js';
-export async function getCookiesFromChromeSqliteLinux(options, origins, allowlistNames) {
- const args = {
- configDirName: 'google-chrome',
- };
- if (options.profile !== undefined)
- args.profile = options.profile;
- const dbPath = resolveChromiumCookiesDbLinux(args);
- if (!dbPath) {
- return { cookies: [], warnings: ['Chrome cookies database not found.'] };
- }
- const { password, warnings: keyringWarnings } = await getLinuxChromeSafeStoragePassword();
- // Linux uses multiple schemes depending on distro/keyring availability.
- // - v10 often uses the hard-coded "peanuts" password
- // - v11 uses "Chrome Safe Storage" from the keyring (may be empty/unavailable)
- const v10Key = deriveAes128CbcKeyFromPassword('peanuts', { iterations: 1 });
- const emptyKey = deriveAes128CbcKeyFromPassword('', { iterations: 1 });
- const v11Key = deriveAes128CbcKeyFromPassword(password, { iterations: 1 });
- const decrypt = (encryptedValue, opts) => {
- const prefix = Buffer.from(encryptedValue).subarray(0, 3).toString('utf8');
- if (prefix === 'v10') {
- return decryptChromiumAes128CbcCookieValue(encryptedValue, [v10Key, emptyKey], {
- stripHashPrefix: opts.stripHashPrefix,
- treatUnknownPrefixAsPlaintext: false,
- });
- }
- if (prefix === 'v11') {
- return decryptChromiumAes128CbcCookieValue(encryptedValue, [v11Key, emptyKey], {
- stripHashPrefix: opts.stripHashPrefix,
- treatUnknownPrefixAsPlaintext: false,
- });
- }
- return null;
- };
- const dbOptions = {
- dbPath,
- };
- if (options.profile)
- dbOptions.profile = options.profile;
- if (options.includeExpired !== undefined)
- dbOptions.includeExpired = options.includeExpired;
- if (options.debug !== undefined)
- dbOptions.debug = options.debug;
- const result = await getCookiesFromChromeSqliteDb(dbOptions, origins, allowlistNames, decrypt);
- result.warnings.unshift(...keyringWarnings);
- return result;
-}
-//# sourceMappingURL=chromeSqliteLinux.js.map
\ No newline at end of file
diff --git a/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/providers/chromeSqliteLinux.js.map b/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/providers/chromeSqliteLinux.js.map
deleted file mode 100644
index 8878b98..0000000
--- a/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/providers/chromeSqliteLinux.js.map
+++ /dev/null
@@ -1 +0,0 @@
-{"version":3,"file":"chromeSqliteLinux.js","sourceRoot":"","sources":["../../src/providers/chromeSqliteLinux.ts"],"names":[],"mappings":"AACA,OAAO,EACN,mCAAmC,EACnC,8BAA8B,GAC9B,MAAM,0BAA0B,CAAC;AAClC,OAAO,EAAE,iCAAiC,EAAE,MAAM,gCAAgC,CAAC;AACnF,OAAO,EAAE,4BAA4B,EAAE,MAAM,0BAA0B,CAAC;AACxE,OAAO,EAAE,6BAA6B,EAAE,MAAM,0BAA0B,CAAC;AAEzE,MAAM,CAAC,KAAK,UAAU,+BAA+B,CACpD,OAAwE,EACxE,OAAiB,EACjB,cAAkC;IAElC,MAAM,IAAI,GAAwD;QACjE,aAAa,EAAE,eAAe;KAC9B,CAAC;IACF,IAAI,OAAO,CAAC,OAAO,KAAK,SAAS;QAAE,IAAI,CAAC,OAAO,GAAG,OAAO,CAAC,OAAO,CAAC;IAClE,MAAM,MAAM,GAAG,6BAA6B,CAAC,IAAI,CAAC,CAAC;IACnD,IAAI,CAAC,MAAM,EAAE,CAAC;QACb,OAAO,EAAE,OAAO,EAAE,EAAE,EAAE,QAAQ,EAAE,CAAC,oCAAoC,CAAC,EAAE,CAAC;IAC1E,CAAC;IAED,MAAM,EAAE,QAAQ,EAAE,QAAQ,EAAE,eAAe,EAAE,GAAG,MAAM,iCAAiC,EAAE,CAAC;IAE1F,wEAAwE;IACxE,qDAAqD;IACrD,+EAA+E;IAC/E,MAAM,MAAM,GAAG,8BAA8B,CAAC,SAAS,EAAE,EAAE,UAAU,EAAE,CAAC,EAAE,CAAC,CAAC;IAC5E,MAAM,QAAQ,GAAG,8BAA8B,CAAC,EAAE,EAAE,EAAE,UAAU,EAAE,CAAC,EAAE,CAAC,CAAC;IACvE,MAAM,MAAM,GAAG,8BAA8B,CAAC,QAAQ,EAAE,EAAE,UAAU,EAAE,CAAC,EAAE,CAAC,CAAC;IAE3E,MAAM,OAAO,GAAG,CACf,cAA0B,EAC1B,IAAkC,EAClB,EAAE;QAClB,MAAM,MAAM,GAAG,MAAM,CAAC,IAAI,CAAC,cAAc,CAAC,CAAC,QAAQ,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,QAAQ,CAAC,MAAM,CAAC,CAAC;QAC3E,IAAI,MAAM,KAAK,KAAK,EAAE,CAAC;YACtB,OAAO,mCAAmC,CAAC,cAAc,EAAE,CAAC,MAAM,EAAE,QAAQ,CAAC,EAAE;gBAC9E,eAAe,EAAE,IAAI,CAAC,eAAe;gBACrC,6BAA6B,EAAE,KAAK;aACpC,CAAC,CAAC;QACJ,CAAC;QACD,IAAI,MAAM,KAAK,KAAK,EAAE,CAAC;YACtB,OAAO,mCAAmC,CAAC,cAAc,EAAE,CAAC,MAAM,EAAE,QAAQ,CAAC,EAAE;gBAC9E,eAAe,EAAE,IAAI,CAAC,eAAe;gBACrC,6BAA6B,EAAE,KAAK;aACpC,CAAC,CAAC;QACJ,CAAC;QACD,OAAO,IAAI,CAAC;IACb,CAAC,CAAC;IAEF,MAAM,SAAS,GACd;QACC,MAAM;KACN,CAAC;IACH,IAAI,OAAO,CAAC,OAAO;QAAE,SAAS,CAAC,OAAO,GAAG,OAAO,CAAC,OAAO,CAAC;IACzD,IAAI,OAAO,CAAC,cAAc,KAAK,SAAS;QAAE,SAAS,CAAC,cAAc,GAAG,OAAO,CAAC,cAAc,CAAC;IAC5F,IAAI,OAAO,CAAC,KAAK,KAAK,SAAS;QAAE,SAAS,CAAC,KAAK,GAAG,OAAO,CAAC,KAAK,CAAC;IAEjE,MAAM,MAAM,GAAG,MAAM,4BAA4B,CAAC,SAAS,EAAE,OAAO,EAAE,cAAc,EAAE,OAAO,CAAC,CAAC;IAC/F,MAAM,CAAC,QAAQ,CAAC,OAAO,CAAC,GAAG,eAAe,CAAC,CAAC;IAC5C,OAAO,MAAM,CAAC;AACf,CAAC"}
\ No newline at end of file
diff --git a/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/providers/chromeSqliteMac.d.ts b/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/providers/chromeSqliteMac.d.ts
deleted file mode 100644
index da0e8aa..0000000
--- a/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/providers/chromeSqliteMac.d.ts
+++ /dev/null
@@ -1,7 +0,0 @@
-import type { GetCookiesResult } from '../types.js';
-export declare function getCookiesFromChromeSqliteMac(options: {
- profile?: string;
- includeExpired?: boolean;
- debug?: boolean;
-}, origins: string[], allowlistNames: Set | null): Promise;
-//# sourceMappingURL=chromeSqliteMac.d.ts.map
\ No newline at end of file
diff --git a/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/providers/chromeSqliteMac.d.ts.map b/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/providers/chromeSqliteMac.d.ts.map
deleted file mode 100644
index 1801b1a..0000000
--- a/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/providers/chromeSqliteMac.d.ts.map
+++ /dev/null
@@ -1 +0,0 @@
-{"version":3,"file":"chromeSqliteMac.d.ts","sourceRoot":"","sources":["../../src/providers/chromeSqliteMac.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,aAAa,CAAC;AASpD,wBAAsB,6BAA6B,CAClD,OAAO,EAAE;IAAE,OAAO,CAAC,EAAE,MAAM,CAAC;IAAC,cAAc,CAAC,EAAE,OAAO,CAAC;IAAC,KAAK,CAAC,EAAE,OAAO,CAAA;CAAE,EACxE,OAAO,EAAE,MAAM,EAAE,EACjB,cAAc,EAAE,GAAG,CAAC,MAAM,CAAC,GAAG,IAAI,GAChC,OAAO,CAAC,gBAAgB,CAAC,CA6C3B"}
\ No newline at end of file
diff --git a/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/providers/chromeSqliteMac.js b/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/providers/chromeSqliteMac.js
deleted file mode 100644
index 71c815f..0000000
--- a/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/providers/chromeSqliteMac.js
+++ /dev/null
@@ -1,60 +0,0 @@
-import { homedir } from 'node:os';
-import path from 'node:path';
-import { decryptChromiumAes128CbcCookieValue, deriveAes128CbcKeyFromPassword, } from './chromeSqlite/crypto.js';
-import { getCookiesFromChromeSqliteDb } from './chromeSqlite/shared.js';
-import { readKeychainGenericPasswordFirst } from './chromium/macosKeychain.js';
-import { resolveCookiesDbFromProfileOrRoots } from './chromium/paths.js';
-export async function getCookiesFromChromeSqliteMac(options, origins, allowlistNames) {
- const dbPath = resolveChromeCookiesDb(options.profile);
- if (!dbPath) {
- return { cookies: [], warnings: ['Chrome cookies database not found.'] };
- }
- const warnings = [];
- // On macOS, Chrome stores its "Safe Storage" secret in Keychain.
- // `security find-generic-password` is stable and avoids any native Node keychain modules.
- const passwordResult = await readKeychainGenericPasswordFirst({
- account: 'Chrome',
- services: ['Chrome Safe Storage'],
- timeoutMs: 3_000,
- label: 'Chrome Safe Storage',
- });
- if (!passwordResult.ok) {
- warnings.push(passwordResult.error);
- return { cookies: [], warnings };
- }
- const chromePassword = passwordResult.password.trim();
- if (!chromePassword) {
- warnings.push('macOS Keychain returned an empty Chrome Safe Storage password.');
- return { cookies: [], warnings };
- }
- // Chromium uses PBKDF2(password, "saltysalt", 1003, 16, sha1) for AES-128-CBC cookie values on macOS.
- const key = deriveAes128CbcKeyFromPassword(chromePassword, { iterations: 1003 });
- const decrypt = (encryptedValue, opts) => decryptChromiumAes128CbcCookieValue(encryptedValue, [key], {
- stripHashPrefix: opts.stripHashPrefix,
- treatUnknownPrefixAsPlaintext: true,
- });
- const dbOptions = {
- dbPath,
- };
- if (options.profile)
- dbOptions.profile = options.profile;
- if (options.includeExpired !== undefined)
- dbOptions.includeExpired = options.includeExpired;
- if (options.debug !== undefined)
- dbOptions.debug = options.debug;
- const result = await getCookiesFromChromeSqliteDb(dbOptions, origins, allowlistNames, decrypt);
- result.warnings.unshift(...warnings);
- return result;
-}
-function resolveChromeCookiesDb(profile) {
- const home = homedir();
- /* c8 ignore next */
- const roots = process.platform === 'darwin'
- ? [path.join(home, 'Library', 'Application Support', 'Google', 'Chrome')]
- : [];
- const args = { roots };
- if (profile !== undefined)
- args.profile = profile;
- return resolveCookiesDbFromProfileOrRoots(args);
-}
-//# sourceMappingURL=chromeSqliteMac.js.map
\ No newline at end of file
diff --git a/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/providers/chromeSqliteMac.js.map b/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/providers/chromeSqliteMac.js.map
deleted file mode 100644
index 0b73d5d..0000000
--- a/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/providers/chromeSqliteMac.js.map
+++ /dev/null
@@ -1 +0,0 @@
-{"version":3,"file":"chromeSqliteMac.js","sourceRoot":"","sources":["../../src/providers/chromeSqliteMac.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,EAAE,MAAM,SAAS,CAAC;AAClC,OAAO,IAAI,MAAM,WAAW,CAAC;AAG7B,OAAO,EACN,mCAAmC,EACnC,8BAA8B,GAC9B,MAAM,0BAA0B,CAAC;AAClC,OAAO,EAAE,4BAA4B,EAAE,MAAM,0BAA0B,CAAC;AACxE,OAAO,EAAE,gCAAgC,EAAE,MAAM,6BAA6B,CAAC;AAC/E,OAAO,EAAE,kCAAkC,EAAE,MAAM,qBAAqB,CAAC;AAEzE,MAAM,CAAC,KAAK,UAAU,6BAA6B,CAClD,OAAwE,EACxE,OAAiB,EACjB,cAAkC;IAElC,MAAM,MAAM,GAAG,sBAAsB,CAAC,OAAO,CAAC,OAAO,CAAC,CAAC;IACvD,IAAI,CAAC,MAAM,EAAE,CAAC;QACb,OAAO,EAAE,OAAO,EAAE,EAAE,EAAE,QAAQ,EAAE,CAAC,oCAAoC,CAAC,EAAE,CAAC;IAC1E,CAAC;IAED,MAAM,QAAQ,GAAa,EAAE,CAAC;IAC9B,iEAAiE;IACjE,0FAA0F;IAC1F,MAAM,cAAc,GAAG,MAAM,gCAAgC,CAAC;QAC7D,OAAO,EAAE,QAAQ;QACjB,QAAQ,EAAE,CAAC,qBAAqB,CAAC;QACjC,SAAS,EAAE,KAAK;QAChB,KAAK,EAAE,qBAAqB;KAC5B,CAAC,CAAC;IACH,IAAI,CAAC,cAAc,CAAC,EAAE,EAAE,CAAC;QACxB,QAAQ,CAAC,IAAI,CAAC,cAAc,CAAC,KAAK,CAAC,CAAC;QACpC,OAAO,EAAE,OAAO,EAAE,EAAE,EAAE,QAAQ,EAAE,CAAC;IAClC,CAAC;IAED,MAAM,cAAc,GAAG,cAAc,CAAC,QAAQ,CAAC,IAAI,EAAE,CAAC;IACtD,IAAI,CAAC,cAAc,EAAE,CAAC;QACrB,QAAQ,CAAC,IAAI,CAAC,gEAAgE,CAAC,CAAC;QAChF,OAAO,EAAE,OAAO,EAAE,EAAE,EAAE,QAAQ,EAAE,CAAC;IAClC,CAAC;IAED,sGAAsG;IACtG,MAAM,GAAG,GAAG,8BAA8B,CAAC,cAAc,EAAE,EAAE,UAAU,EAAE,IAAI,EAAE,CAAC,CAAC;IACjF,MAAM,OAAO,GAAG,CAAC,cAA0B,EAAE,IAAkC,EAAiB,EAAE,CACjG,mCAAmC,CAAC,cAAc,EAAE,CAAC,GAAG,CAAC,EAAE;QAC1D,eAAe,EAAE,IAAI,CAAC,eAAe;QACrC,6BAA6B,EAAE,IAAI;KACnC,CAAC,CAAC;IAEJ,MAAM,SAAS,GACd;QACC,MAAM;KACN,CAAC;IACH,IAAI,OAAO,CAAC,OAAO;QAAE,SAAS,CAAC,OAAO,GAAG,OAAO,CAAC,OAAO,CAAC;IACzD,IAAI,OAAO,CAAC,cAAc,KAAK,SAAS;QAAE,SAAS,CAAC,cAAc,GAAG,OAAO,CAAC,cAAc,CAAC;IAC5F,IAAI,OAAO,CAAC,KAAK,KAAK,SAAS;QAAE,SAAS,CAAC,KAAK,GAAG,OAAO,CAAC,KAAK,CAAC;IAEjE,MAAM,MAAM,GAAG,MAAM,4BAA4B,CAAC,SAAS,EAAE,OAAO,EAAE,cAAc,EAAE,OAAO,CAAC,CAAC;IAC/F,MAAM,CAAC,QAAQ,CAAC,OAAO,CAAC,GAAG,QAAQ,CAAC,CAAC;IACrC,OAAO,MAAM,CAAC;AACf,CAAC;AAED,SAAS,sBAAsB,CAAC,OAAgB;IAC/C,MAAM,IAAI,GAAG,OAAO,EAAE,CAAC;IACvB,oBAAoB;IACpB,MAAM,KAAK,GACV,OAAO,CAAC,QAAQ,KAAK,QAAQ;QAC5B,CAAC,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,IAAI,EAAE,SAAS,EAAE,qBAAqB,EAAE,QAAQ,EAAE,QAAQ,CAAC,CAAC;QACzE,CAAC,CAAC,EAAE,CAAC;IACP,MAAM,IAAI,GAA6D,EAAE,KAAK,EAAE,CAAC;IACjF,IAAI,OAAO,KAAK,SAAS;QAAE,IAAI,CAAC,OAAO,GAAG,OAAO,CAAC;IAClD,OAAO,kCAAkC,CAAC,IAAI,CAAC,CAAC;AACjD,CAAC"}
\ No newline at end of file
diff --git a/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/providers/chromeSqliteWindows.d.ts b/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/providers/chromeSqliteWindows.d.ts
deleted file mode 100644
index b9a9d95..0000000
--- a/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/providers/chromeSqliteWindows.d.ts
+++ /dev/null
@@ -1,7 +0,0 @@
-import type { GetCookiesResult } from '../types.js';
-export declare function getCookiesFromChromeSqliteWindows(options: {
- profile?: string;
- includeExpired?: boolean;
- debug?: boolean;
-}, origins: string[], allowlistNames: Set | null): Promise;
-//# sourceMappingURL=chromeSqliteWindows.d.ts.map
\ No newline at end of file
diff --git a/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/providers/chromeSqliteWindows.d.ts.map b/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/providers/chromeSqliteWindows.d.ts.map
deleted file mode 100644
index 0c61b9b..0000000
--- a/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/providers/chromeSqliteWindows.d.ts.map
+++ /dev/null
@@ -1 +0,0 @@
-{"version":3,"file":"chromeSqliteWindows.d.ts","sourceRoot":"","sources":["../../src/providers/chromeSqliteWindows.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,aAAa,CAAC;AAMpD,wBAAsB,iCAAiC,CACtD,OAAO,EAAE;IAAE,OAAO,CAAC,EAAE,MAAM,CAAC;IAAC,cAAc,CAAC,EAAE,OAAO,CAAC;IAAC,KAAK,CAAC,EAAE,OAAO,CAAA;CAAE,EACxE,OAAO,EAAE,MAAM,EAAE,EACjB,cAAc,EAAE,GAAG,CAAC,MAAM,CAAC,GAAG,IAAI,GAChC,OAAO,CAAC,gBAAgB,CAAC,CAmC3B"}
\ No newline at end of file
diff --git a/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/providers/chromeSqliteWindows.js b/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/providers/chromeSqliteWindows.js
deleted file mode 100644
index 576d365..0000000
--- a/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/providers/chromeSqliteWindows.js
+++ /dev/null
@@ -1,38 +0,0 @@
-import path from 'node:path';
-import { decryptChromiumAes256GcmCookieValue } from './chromeSqlite/crypto.js';
-import { getCookiesFromChromeSqliteDb } from './chromeSqlite/shared.js';
-import { getWindowsChromiumMasterKey } from './chromium/windowsMasterKey.js';
-import { resolveChromiumPathsWindows } from './chromium/windowsPaths.js';
-export async function getCookiesFromChromeSqliteWindows(options, origins, allowlistNames) {
- const resolveArgs = {
- localAppDataVendorPath: path.join('Google', 'Chrome', 'User Data'),
- };
- if (options.profile !== undefined)
- resolveArgs.profile = options.profile;
- const { dbPath, userDataDir } = resolveChromiumPathsWindows(resolveArgs);
- if (!dbPath || !userDataDir) {
- return { cookies: [], warnings: ['Chrome cookies database not found.'] };
- }
- // On Windows, Chrome stores an AES key in `Local State` encrypted with DPAPI (CurrentUser).
- // That master key is then used for AES-256-GCM cookie values (`v10`/`v11`/`v20` prefixes).
- const masterKey = await getWindowsChromiumMasterKey(userDataDir, 'Chrome');
- if (!masterKey.ok) {
- return { cookies: [], warnings: [masterKey.error] };
- }
- const decrypt = (encryptedValue, opts) => {
- return decryptChromiumAes256GcmCookieValue(encryptedValue, masterKey.value, {
- stripHashPrefix: opts.stripHashPrefix,
- });
- };
- const dbOptions = {
- dbPath,
- };
- if (options.profile)
- dbOptions.profile = options.profile;
- if (options.includeExpired !== undefined)
- dbOptions.includeExpired = options.includeExpired;
- if (options.debug !== undefined)
- dbOptions.debug = options.debug;
- return await getCookiesFromChromeSqliteDb(dbOptions, origins, allowlistNames, decrypt);
-}
-//# sourceMappingURL=chromeSqliteWindows.js.map
\ No newline at end of file
diff --git a/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/providers/chromeSqliteWindows.js.map b/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/providers/chromeSqliteWindows.js.map
deleted file mode 100644
index b99dfe2..0000000
--- a/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/providers/chromeSqliteWindows.js.map
+++ /dev/null
@@ -1 +0,0 @@
-{"version":3,"file":"chromeSqliteWindows.js","sourceRoot":"","sources":["../../src/providers/chromeSqliteWindows.ts"],"names":[],"mappings":"AAAA,OAAO,IAAI,MAAM,WAAW,CAAC;AAG7B,OAAO,EAAE,mCAAmC,EAAE,MAAM,0BAA0B,CAAC;AAC/E,OAAO,EAAE,4BAA4B,EAAE,MAAM,0BAA0B,CAAC;AACxE,OAAO,EAAE,2BAA2B,EAAE,MAAM,gCAAgC,CAAC;AAC7E,OAAO,EAAE,2BAA2B,EAAE,MAAM,4BAA4B,CAAC;AAEzE,MAAM,CAAC,KAAK,UAAU,iCAAiC,CACtD,OAAwE,EACxE,OAAiB,EACjB,cAAkC;IAElC,MAAM,WAAW,GAAsD;QACtE,sBAAsB,EAAE,IAAI,CAAC,IAAI,CAAC,QAAQ,EAAE,QAAQ,EAAE,WAAW,CAAC;KAClE,CAAC;IACF,IAAI,OAAO,CAAC,OAAO,KAAK,SAAS;QAAE,WAAW,CAAC,OAAO,GAAG,OAAO,CAAC,OAAO,CAAC;IACzE,MAAM,EAAE,MAAM,EAAE,WAAW,EAAE,GAAG,2BAA2B,CAAC,WAAW,CAAC,CAAC;IACzE,IAAI,CAAC,MAAM,IAAI,CAAC,WAAW,EAAE,CAAC;QAC7B,OAAO,EAAE,OAAO,EAAE,EAAE,EAAE,QAAQ,EAAE,CAAC,oCAAoC,CAAC,EAAE,CAAC;IAC1E,CAAC;IAED,4FAA4F;IAC5F,2FAA2F;IAC3F,MAAM,SAAS,GAAG,MAAM,2BAA2B,CAAC,WAAW,EAAE,QAAQ,CAAC,CAAC;IAC3E,IAAI,CAAC,SAAS,CAAC,EAAE,EAAE,CAAC;QACnB,OAAO,EAAE,OAAO,EAAE,EAAE,EAAE,QAAQ,EAAE,CAAC,SAAS,CAAC,KAAK,CAAC,EAAE,CAAC;IACrD,CAAC;IAED,MAAM,OAAO,GAAG,CACf,cAA0B,EAC1B,IAAkC,EAClB,EAAE;QAClB,OAAO,mCAAmC,CAAC,cAAc,EAAE,SAAS,CAAC,KAAK,EAAE;YAC3E,eAAe,EAAE,IAAI,CAAC,eAAe;SACrC,CAAC,CAAC;IACJ,CAAC,CAAC;IAEF,MAAM,SAAS,GACd;QACC,MAAM;KACN,CAAC;IACH,IAAI,OAAO,CAAC,OAAO;QAAE,SAAS,CAAC,OAAO,GAAG,OAAO,CAAC,OAAO,CAAC;IACzD,IAAI,OAAO,CAAC,cAAc,KAAK,SAAS;QAAE,SAAS,CAAC,cAAc,GAAG,OAAO,CAAC,cAAc,CAAC;IAC5F,IAAI,OAAO,CAAC,KAAK,KAAK,SAAS;QAAE,SAAS,CAAC,KAAK,GAAG,OAAO,CAAC,KAAK,CAAC;IAEjE,OAAO,MAAM,4BAA4B,CAAC,SAAS,EAAE,OAAO,EAAE,cAAc,EAAE,OAAO,CAAC,CAAC;AACxF,CAAC"}
\ No newline at end of file
diff --git a/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/providers/chromium/linuxPaths.d.ts b/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/providers/chromium/linuxPaths.d.ts
deleted file mode 100644
index 010ddea..0000000
--- a/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/providers/chromium/linuxPaths.d.ts
+++ /dev/null
@@ -1,5 +0,0 @@
-export declare function resolveChromiumCookiesDbLinux(options: {
- configDirName: string;
- profile?: string;
-}): string | null;
-//# sourceMappingURL=linuxPaths.d.ts.map
\ No newline at end of file
diff --git a/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/providers/chromium/linuxPaths.d.ts.map b/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/providers/chromium/linuxPaths.d.ts.map
deleted file mode 100644
index eaf0cb5..0000000
--- a/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/providers/chromium/linuxPaths.d.ts.map
+++ /dev/null
@@ -1 +0,0 @@
-{"version":3,"file":"linuxPaths.d.ts","sourceRoot":"","sources":["../../../src/providers/chromium/linuxPaths.ts"],"names":[],"mappings":"AAMA,wBAAgB,6BAA6B,CAAC,OAAO,EAAE;IACtD,aAAa,EAAE,MAAM,CAAC;IACtB,OAAO,CAAC,EAAE,MAAM,CAAC;CACjB,GAAG,MAAM,GAAG,IAAI,CA0BhB"}
\ No newline at end of file
diff --git a/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/providers/chromium/linuxPaths.js b/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/providers/chromium/linuxPaths.js
deleted file mode 100644
index 5aa9d40..0000000
--- a/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/providers/chromium/linuxPaths.js
+++ /dev/null
@@ -1,33 +0,0 @@
-import { existsSync } from 'node:fs';
-import { homedir } from 'node:os';
-import path from 'node:path';
-import { expandPath, looksLikePath } from './paths.js';
-export function resolveChromiumCookiesDbLinux(options) {
- const home = homedir();
- // biome-ignore lint/complexity/useLiteralKeys: process.env is an index signature under strict TS.
- const configHome = process.env['XDG_CONFIG_HOME']?.trim() || path.join(home, '.config');
- const root = path.join(configHome, options.configDirName);
- if (options.profile && looksLikePath(options.profile)) {
- const candidate = expandPath(options.profile);
- if (candidate.endsWith('Cookies') && existsSync(candidate))
- return candidate;
- const direct = path.join(candidate, 'Cookies');
- if (existsSync(direct))
- return direct;
- const network = path.join(candidate, 'Network', 'Cookies');
- if (existsSync(network))
- return network;
- return null;
- }
- const profileDir = options.profile && options.profile.trim().length > 0 ? options.profile.trim() : 'Default';
- const candidates = [
- path.join(root, profileDir, 'Cookies'),
- path.join(root, profileDir, 'Network', 'Cookies'),
- ];
- for (const candidate of candidates) {
- if (existsSync(candidate))
- return candidate;
- }
- return null;
-}
-//# sourceMappingURL=linuxPaths.js.map
\ No newline at end of file
diff --git a/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/providers/chromium/linuxPaths.js.map b/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/providers/chromium/linuxPaths.js.map
deleted file mode 100644
index 0c3503b..0000000
--- a/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/providers/chromium/linuxPaths.js.map
+++ /dev/null
@@ -1 +0,0 @@
-{"version":3,"file":"linuxPaths.js","sourceRoot":"","sources":["../../../src/providers/chromium/linuxPaths.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,EAAE,MAAM,SAAS,CAAC;AACrC,OAAO,EAAE,OAAO,EAAE,MAAM,SAAS,CAAC;AAClC,OAAO,IAAI,MAAM,WAAW,CAAC;AAE7B,OAAO,EAAE,UAAU,EAAE,aAAa,EAAE,MAAM,YAAY,CAAC;AAEvD,MAAM,UAAU,6BAA6B,CAAC,OAG7C;IACA,MAAM,IAAI,GAAG,OAAO,EAAE,CAAC;IACvB,kGAAkG;IAClG,MAAM,UAAU,GAAG,OAAO,CAAC,GAAG,CAAC,iBAAiB,CAAC,EAAE,IAAI,EAAE,IAAI,IAAI,CAAC,IAAI,CAAC,IAAI,EAAE,SAAS,CAAC,CAAC;IACxF,MAAM,IAAI,GAAG,IAAI,CAAC,IAAI,CAAC,UAAU,EAAE,OAAO,CAAC,aAAa,CAAC,CAAC;IAE1D,IAAI,OAAO,CAAC,OAAO,IAAI,aAAa,CAAC,OAAO,CAAC,OAAO,CAAC,EAAE,CAAC;QACvD,MAAM,SAAS,GAAG,UAAU,CAAC,OAAO,CAAC,OAAO,CAAC,CAAC;QAC9C,IAAI,SAAS,CAAC,QAAQ,CAAC,SAAS,CAAC,IAAI,UAAU,CAAC,SAAS,CAAC;YAAE,OAAO,SAAS,CAAC;QAC7E,MAAM,MAAM,GAAG,IAAI,CAAC,IAAI,CAAC,SAAS,EAAE,SAAS,CAAC,CAAC;QAC/C,IAAI,UAAU,CAAC,MAAM,CAAC;YAAE,OAAO,MAAM,CAAC;QACtC,MAAM,OAAO,GAAG,IAAI,CAAC,IAAI,CAAC,SAAS,EAAE,SAAS,EAAE,SAAS,CAAC,CAAC;QAC3D,IAAI,UAAU,CAAC,OAAO,CAAC;YAAE,OAAO,OAAO,CAAC;QACxC,OAAO,IAAI,CAAC;IACb,CAAC;IAED,MAAM,UAAU,GACf,OAAO,CAAC,OAAO,IAAI,OAAO,CAAC,OAAO,CAAC,IAAI,EAAE,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC,OAAO,CAAC,IAAI,EAAE,CAAC,CAAC,CAAC,SAAS,CAAC;IAC3F,MAAM,UAAU,GAAG;QAClB,IAAI,CAAC,IAAI,CAAC,IAAI,EAAE,UAAU,EAAE,SAAS,CAAC;QACtC,IAAI,CAAC,IAAI,CAAC,IAAI,EAAE,UAAU,EAAE,SAAS,EAAE,SAAS,CAAC;KACjD,CAAC;IACF,KAAK,MAAM,SAAS,IAAI,UAAU,EAAE,CAAC;QACpC,IAAI,UAAU,CAAC,SAAS,CAAC;YAAE,OAAO,SAAS,CAAC;IAC7C,CAAC;IACD,OAAO,IAAI,CAAC;AACb,CAAC"}
\ No newline at end of file
diff --git a/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/providers/chromium/macosKeychain.d.ts b/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/providers/chromium/macosKeychain.d.ts
deleted file mode 100644
index ed5ea8a..0000000
--- a/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/providers/chromium/macosKeychain.d.ts
+++ /dev/null
@@ -1,24 +0,0 @@
-export declare function readKeychainGenericPassword(options: {
- account: string;
- service: string;
- timeoutMs: number;
-}): Promise<{
- ok: true;
- password: string;
-} | {
- ok: false;
- error: string;
-}>;
-export declare function readKeychainGenericPasswordFirst(options: {
- account: string;
- services: string[];
- timeoutMs: number;
- label: string;
-}): Promise<{
- ok: true;
- password: string;
-} | {
- ok: false;
- error: string;
-}>;
-//# sourceMappingURL=macosKeychain.d.ts.map
\ No newline at end of file
diff --git a/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/providers/chromium/macosKeychain.d.ts.map b/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/providers/chromium/macosKeychain.d.ts.map
deleted file mode 100644
index a0a20b3..0000000
--- a/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/providers/chromium/macosKeychain.d.ts.map
+++ /dev/null
@@ -1 +0,0 @@
-{"version":3,"file":"macosKeychain.d.ts","sourceRoot":"","sources":["../../../src/providers/chromium/macosKeychain.ts"],"names":[],"mappings":"AAEA,wBAAsB,2BAA2B,CAAC,OAAO,EAAE;IAC1D,OAAO,EAAE,MAAM,CAAC;IAChB,OAAO,EAAE,MAAM,CAAC;IAChB,SAAS,EAAE,MAAM,CAAC;CAClB,GAAG,OAAO,CAAC;IAAE,EAAE,EAAE,IAAI,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAA;CAAE,GAAG;IAAE,EAAE,EAAE,KAAK,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,CAAC,CAczE;AAED,wBAAsB,gCAAgC,CAAC,OAAO,EAAE;IAC/D,OAAO,EAAE,MAAM,CAAC;IAChB,QAAQ,EAAE,MAAM,EAAE,CAAC;IACnB,SAAS,EAAE,MAAM,CAAC;IAClB,KAAK,EAAE,MAAM,CAAC;CACd,GAAG,OAAO,CAAC;IAAE,EAAE,EAAE,IAAI,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAA;CAAE,GAAG;IAAE,EAAE,EAAE,KAAK,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,CAAC,CAgBzE"}
\ No newline at end of file
diff --git a/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/providers/chromium/macosKeychain.js b/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/providers/chromium/macosKeychain.js
deleted file mode 100644
index 275a157..0000000
--- a/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/providers/chromium/macosKeychain.js
+++ /dev/null
@@ -1,30 +0,0 @@
-import { execCapture } from '../../util/exec.js';
-export async function readKeychainGenericPassword(options) {
- const res = await execCapture('security', ['find-generic-password', '-w', '-a', options.account, '-s', options.service], { timeoutMs: options.timeoutMs });
- if (res.code === 0) {
- const password = res.stdout.trim();
- return { ok: true, password };
- }
- return {
- ok: false,
- error: `${res.stderr.trim() || `exit ${res.code}`}`,
- };
-}
-export async function readKeychainGenericPasswordFirst(options) {
- let lastError = null;
- for (const service of options.services) {
- const r = await readKeychainGenericPassword({
- account: options.account,
- service,
- timeoutMs: options.timeoutMs,
- });
- if (r.ok)
- return r;
- lastError = r.error;
- }
- return {
- ok: false,
- error: `Failed to read macOS Keychain (${options.label}): ${lastError ?? 'permission denied / keychain locked / entry missing.'}`,
- };
-}
-//# sourceMappingURL=macosKeychain.js.map
\ No newline at end of file
diff --git a/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/providers/chromium/macosKeychain.js.map b/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/providers/chromium/macosKeychain.js.map
deleted file mode 100644
index be7edb8..0000000
--- a/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/providers/chromium/macosKeychain.js.map
+++ /dev/null
@@ -1 +0,0 @@
-{"version":3,"file":"macosKeychain.js","sourceRoot":"","sources":["../../../src/providers/chromium/macosKeychain.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,WAAW,EAAE,MAAM,oBAAoB,CAAC;AAEjD,MAAM,CAAC,KAAK,UAAU,2BAA2B,CAAC,OAIjD;IACA,MAAM,GAAG,GAAG,MAAM,WAAW,CAC5B,UAAU,EACV,CAAC,uBAAuB,EAAE,IAAI,EAAE,IAAI,EAAE,OAAO,CAAC,OAAO,EAAE,IAAI,EAAE,OAAO,CAAC,OAAO,CAAC,EAC7E,EAAE,SAAS,EAAE,OAAO,CAAC,SAAS,EAAE,CAChC,CAAC;IACF,IAAI,GAAG,CAAC,IAAI,KAAK,CAAC,EAAE,CAAC;QACpB,MAAM,QAAQ,GAAG,GAAG,CAAC,MAAM,CAAC,IAAI,EAAE,CAAC;QACnC,OAAO,EAAE,EAAE,EAAE,IAAI,EAAE,QAAQ,EAAE,CAAC;IAC/B,CAAC;IACD,OAAO;QACN,EAAE,EAAE,KAAK;QACT,KAAK,EAAE,GAAG,GAAG,CAAC,MAAM,CAAC,IAAI,EAAE,IAAI,QAAQ,GAAG,CAAC,IAAI,EAAE,EAAE;KACnD,CAAC;AACH,CAAC;AAED,MAAM,CAAC,KAAK,UAAU,gCAAgC,CAAC,OAKtD;IACA,IAAI,SAAS,GAAkB,IAAI,CAAC;IACpC,KAAK,MAAM,OAAO,IAAI,OAAO,CAAC,QAAQ,EAAE,CAAC;QACxC,MAAM,CAAC,GAAG,MAAM,2BAA2B,CAAC;YAC3C,OAAO,EAAE,OAAO,CAAC,OAAO;YACxB,OAAO;YACP,SAAS,EAAE,OAAO,CAAC,SAAS;SAC5B,CAAC,CAAC;QACH,IAAI,CAAC,CAAC,EAAE;YAAE,OAAO,CAAC,CAAC;QACnB,SAAS,GAAG,CAAC,CAAC,KAAK,CAAC;IACrB,CAAC;IAED,OAAO;QACN,EAAE,EAAE,KAAK;QACT,KAAK,EAAE,kCAAkC,OAAO,CAAC,KAAK,MAAM,SAAS,IAAI,sDAAsD,EAAE;KACjI,CAAC;AACH,CAAC"}
\ No newline at end of file
diff --git a/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/providers/chromium/paths.d.ts b/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/providers/chromium/paths.d.ts
deleted file mode 100644
index 8782384..0000000
--- a/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/providers/chromium/paths.d.ts
+++ /dev/null
@@ -1,11 +0,0 @@
-export declare function looksLikePath(value: string): boolean;
-export declare function expandPath(input: string): string;
-export declare function safeStat(candidate: string): {
- isFile: () => boolean;
- isDirectory: () => boolean;
-} | null;
-export declare function resolveCookiesDbFromProfileOrRoots(options: {
- profile?: string;
- roots: string[];
-}): string | null;
-//# sourceMappingURL=paths.d.ts.map
\ No newline at end of file
diff --git a/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/providers/chromium/paths.d.ts.map b/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/providers/chromium/paths.d.ts.map
deleted file mode 100644
index 2142f97..0000000
--- a/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/providers/chromium/paths.d.ts.map
+++ /dev/null
@@ -1 +0,0 @@
-{"version":3,"file":"paths.d.ts","sourceRoot":"","sources":["../../../src/providers/chromium/paths.ts"],"names":[],"mappings":"AAIA,wBAAgB,aAAa,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAEpD;AAED,wBAAgB,UAAU,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAGhD;AAED,wBAAgB,QAAQ,CACvB,SAAS,EAAE,MAAM,GACf;IAAE,MAAM,EAAE,MAAM,OAAO,CAAC;IAAC,WAAW,EAAE,MAAM,OAAO,CAAA;CAAE,GAAG,IAAI,CAM9D;AAED,wBAAgB,kCAAkC,CAAC,OAAO,EAAE;IAC3D,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,KAAK,EAAE,MAAM,EAAE,CAAC;CAChB,GAAG,MAAM,GAAG,IAAI,CAuBhB"}
\ No newline at end of file
diff --git a/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/providers/chromium/paths.js b/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/providers/chromium/paths.js
deleted file mode 100644
index 97e9c69..0000000
--- a/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/providers/chromium/paths.js
+++ /dev/null
@@ -1,43 +0,0 @@
-import { existsSync, statSync } from 'node:fs';
-import { homedir } from 'node:os';
-import path from 'node:path';
-export function looksLikePath(value) {
- return value.includes('/') || value.includes('\\');
-}
-export function expandPath(input) {
- if (input.startsWith('~/'))
- return path.join(homedir(), input.slice(2));
- return path.isAbsolute(input) ? input : path.resolve(process.cwd(), input);
-}
-export function safeStat(candidate) {
- try {
- return statSync(candidate);
- }
- catch {
- return null;
- }
-}
-export function resolveCookiesDbFromProfileOrRoots(options) {
- const candidates = [];
- if (options.profile && looksLikePath(options.profile)) {
- const expanded = expandPath(options.profile);
- const stat = safeStat(expanded);
- if (stat?.isFile())
- return expanded;
- candidates.push(path.join(expanded, 'Cookies'));
- candidates.push(path.join(expanded, 'Network', 'Cookies'));
- }
- else {
- const profileDir = options.profile && options.profile.trim().length > 0 ? options.profile.trim() : 'Default';
- for (const root of options.roots) {
- candidates.push(path.join(root, profileDir, 'Cookies'));
- candidates.push(path.join(root, profileDir, 'Network', 'Cookies'));
- }
- }
- for (const candidate of candidates) {
- if (existsSync(candidate))
- return candidate;
- }
- return null;
-}
-//# sourceMappingURL=paths.js.map
\ No newline at end of file
diff --git a/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/providers/chromium/paths.js.map b/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/providers/chromium/paths.js.map
deleted file mode 100644
index 91745ce..0000000
--- a/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/providers/chromium/paths.js.map
+++ /dev/null
@@ -1 +0,0 @@
-{"version":3,"file":"paths.js","sourceRoot":"","sources":["../../../src/providers/chromium/paths.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,EAAE,QAAQ,EAAE,MAAM,SAAS,CAAC;AAC/C,OAAO,EAAE,OAAO,EAAE,MAAM,SAAS,CAAC;AAClC,OAAO,IAAI,MAAM,WAAW,CAAC;AAE7B,MAAM,UAAU,aAAa,CAAC,KAAa;IAC1C,OAAO,KAAK,CAAC,QAAQ,CAAC,GAAG,CAAC,IAAI,KAAK,CAAC,QAAQ,CAAC,IAAI,CAAC,CAAC;AACpD,CAAC;AAED,MAAM,UAAU,UAAU,CAAC,KAAa;IACvC,IAAI,KAAK,CAAC,UAAU,CAAC,IAAI,CAAC;QAAE,OAAO,IAAI,CAAC,IAAI,CAAC,OAAO,EAAE,EAAE,KAAK,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC;IACxE,OAAO,IAAI,CAAC,UAAU,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,IAAI,CAAC,OAAO,CAAC,OAAO,CAAC,GAAG,EAAE,EAAE,KAAK,CAAC,CAAC;AAC5E,CAAC;AAED,MAAM,UAAU,QAAQ,CACvB,SAAiB;IAEjB,IAAI,CAAC;QACJ,OAAO,QAAQ,CAAC,SAAS,CAAC,CAAC;IAC5B,CAAC;IAAC,MAAM,CAAC;QACR,OAAO,IAAI,CAAC;IACb,CAAC;AACF,CAAC;AAED,MAAM,UAAU,kCAAkC,CAAC,OAGlD;IACA,MAAM,UAAU,GAAa,EAAE,CAAC;IAEhC,IAAI,OAAO,CAAC,OAAO,IAAI,aAAa,CAAC,OAAO,CAAC,OAAO,CAAC,EAAE,CAAC;QACvD,MAAM,QAAQ,GAAG,UAAU,CAAC,OAAO,CAAC,OAAO,CAAC,CAAC;QAC7C,MAAM,IAAI,GAAG,QAAQ,CAAC,QAAQ,CAAC,CAAC;QAChC,IAAI,IAAI,EAAE,MAAM,EAAE;YAAE,OAAO,QAAQ,CAAC;QACpC,UAAU,CAAC,IAAI,CAAC,IAAI,CAAC,IAAI,CAAC,QAAQ,EAAE,SAAS,CAAC,CAAC,CAAC;QAChD,UAAU,CAAC,IAAI,CAAC,IAAI,CAAC,IAAI,CAAC,QAAQ,EAAE,SAAS,EAAE,SAAS,CAAC,CAAC,CAAC;IAC5D,CAAC;SAAM,CAAC;QACP,MAAM,UAAU,GACf,OAAO,CAAC,OAAO,IAAI,OAAO,CAAC,OAAO,CAAC,IAAI,EAAE,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC,OAAO,CAAC,IAAI,EAAE,CAAC,CAAC,CAAC,SAAS,CAAC;QAC3F,KAAK,MAAM,IAAI,IAAI,OAAO,CAAC,KAAK,EAAE,CAAC;YAClC,UAAU,CAAC,IAAI,CAAC,IAAI,CAAC,IAAI,CAAC,IAAI,EAAE,UAAU,EAAE,SAAS,CAAC,CAAC,CAAC;YACxD,UAAU,CAAC,IAAI,CAAC,IAAI,CAAC,IAAI,CAAC,IAAI,EAAE,UAAU,EAAE,SAAS,EAAE,SAAS,CAAC,CAAC,CAAC;QACpE,CAAC;IACF,CAAC;IAED,KAAK,MAAM,SAAS,IAAI,UAAU,EAAE,CAAC;QACpC,IAAI,UAAU,CAAC,SAAS,CAAC;YAAE,OAAO,SAAS,CAAC;IAC7C,CAAC;IAED,OAAO,IAAI,CAAC;AACb,CAAC"}
\ No newline at end of file
diff --git a/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/providers/chromium/windowsMasterKey.d.ts b/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/providers/chromium/windowsMasterKey.d.ts
deleted file mode 100644
index e69729d..0000000
--- a/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/providers/chromium/windowsMasterKey.d.ts
+++ /dev/null
@@ -1,8 +0,0 @@
-export declare function getWindowsChromiumMasterKey(userDataDir: string, label: string): Promise<{
- ok: true;
- value: Buffer;
-} | {
- ok: false;
- error: string;
-}>;
-//# sourceMappingURL=windowsMasterKey.d.ts.map
\ No newline at end of file
diff --git a/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/providers/chromium/windowsMasterKey.d.ts.map b/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/providers/chromium/windowsMasterKey.d.ts.map
deleted file mode 100644
index df19c35..0000000
--- a/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/providers/chromium/windowsMasterKey.d.ts.map
+++ /dev/null
@@ -1 +0,0 @@
-{"version":3,"file":"windowsMasterKey.d.ts","sourceRoot":"","sources":["../../../src/providers/chromium/windowsMasterKey.ts"],"names":[],"mappings":"AAKA,wBAAsB,2BAA2B,CAChD,WAAW,EAAE,MAAM,EACnB,KAAK,EAAE,MAAM,GACX,OAAO,CACP;IAAE,EAAE,EAAE,IAAI,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,GAC3B;IACA,EAAE,EAAE,KAAK,CAAC;IACV,KAAK,EAAE,MAAM,CAAC;CACb,CACH,CAsCA"}
\ No newline at end of file
diff --git a/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/providers/chromium/windowsMasterKey.js b/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/providers/chromium/windowsMasterKey.js
deleted file mode 100644
index 33453b1..0000000
--- a/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/providers/chromium/windowsMasterKey.js
+++ /dev/null
@@ -1,41 +0,0 @@
-import { existsSync, readFileSync } from 'node:fs';
-import path from 'node:path';
-import { dpapiUnprotect } from '../chromeSqlite/windowsDpapi.js';
-export async function getWindowsChromiumMasterKey(userDataDir, label) {
- const localStatePath = path.join(userDataDir, 'Local State');
- if (!existsSync(localStatePath)) {
- return { ok: false, error: `${label} Local State file not found.` };
- }
- let encryptedKeyB64 = null;
- try {
- const raw = readFileSync(localStatePath, 'utf8');
- const parsed = JSON.parse(raw);
- encryptedKeyB64 =
- typeof parsed.os_crypt?.encrypted_key === 'string' ? parsed.os_crypt.encrypted_key : null;
- }
- catch (error) {
- return {
- ok: false,
- error: `Failed to parse ${label} Local State: ${error instanceof Error ? error.message : String(error)}`,
- };
- }
- if (!encryptedKeyB64)
- return { ok: false, error: `${label} Local State missing os_crypt.encrypted_key.` };
- let encryptedKey;
- try {
- encryptedKey = Buffer.from(encryptedKeyB64, 'base64');
- }
- catch {
- return { ok: false, error: `${label} Local State contains an invalid encrypted_key.` };
- }
- const prefix = Buffer.from('DPAPI', 'utf8');
- if (!encryptedKey.subarray(0, prefix.length).equals(prefix)) {
- return { ok: false, error: `${label} encrypted_key does not start with DPAPI prefix.` };
- }
- const unprotected = await dpapiUnprotect(encryptedKey.subarray(prefix.length));
- if (!unprotected.ok) {
- return { ok: false, error: `DPAPI decrypt failed: ${unprotected.error}` };
- }
- return { ok: true, value: unprotected.value };
-}
-//# sourceMappingURL=windowsMasterKey.js.map
\ No newline at end of file
diff --git a/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/providers/chromium/windowsMasterKey.js.map b/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/providers/chromium/windowsMasterKey.js.map
deleted file mode 100644
index 3a43096..0000000
--- a/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/providers/chromium/windowsMasterKey.js.map
+++ /dev/null
@@ -1 +0,0 @@
-{"version":3,"file":"windowsMasterKey.js","sourceRoot":"","sources":["../../../src/providers/chromium/windowsMasterKey.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,EAAE,YAAY,EAAE,MAAM,SAAS,CAAC;AACnD,OAAO,IAAI,MAAM,WAAW,CAAC;AAE7B,OAAO,EAAE,cAAc,EAAE,MAAM,iCAAiC,CAAC;AAEjE,MAAM,CAAC,KAAK,UAAU,2BAA2B,CAChD,WAAmB,EACnB,KAAa;IAQb,MAAM,cAAc,GAAG,IAAI,CAAC,IAAI,CAAC,WAAW,EAAE,aAAa,CAAC,CAAC;IAC7D,IAAI,CAAC,UAAU,CAAC,cAAc,CAAC,EAAE,CAAC;QACjC,OAAO,EAAE,EAAE,EAAE,KAAK,EAAE,KAAK,EAAE,GAAG,KAAK,8BAA8B,EAAE,CAAC;IACrE,CAAC;IACD,IAAI,eAAe,GAAkB,IAAI,CAAC;IAC1C,IAAI,CAAC;QACJ,MAAM,GAAG,GAAG,YAAY,CAAC,cAAc,EAAE,MAAM,CAAC,CAAC;QACjD,MAAM,MAAM,GAAG,IAAI,CAAC,KAAK,CAAC,GAAG,CAA+C,CAAC;QAC7E,eAAe;YACd,OAAO,MAAM,CAAC,QAAQ,EAAE,aAAa,KAAK,QAAQ,CAAC,CAAC,CAAC,MAAM,CAAC,QAAQ,CAAC,aAAa,CAAC,CAAC,CAAC,IAAI,CAAC;IAC5F,CAAC;IAAC,OAAO,KAAK,EAAE,CAAC;QAChB,OAAO;YACN,EAAE,EAAE,KAAK;YACT,KAAK,EAAE,mBAAmB,KAAK,iBAAiB,KAAK,YAAY,KAAK,CAAC,CAAC,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,EAAE;SACxG,CAAC;IACH,CAAC;IAED,IAAI,CAAC,eAAe;QACnB,OAAO,EAAE,EAAE,EAAE,KAAK,EAAE,KAAK,EAAE,GAAG,KAAK,8CAA8C,EAAE,CAAC;IAErF,IAAI,YAAoB,CAAC;IACzB,IAAI,CAAC;QACJ,YAAY,GAAG,MAAM,CAAC,IAAI,CAAC,eAAe,EAAE,QAAQ,CAAC,CAAC;IACvD,CAAC;IAAC,MAAM,CAAC;QACR,OAAO,EAAE,EAAE,EAAE,KAAK,EAAE,KAAK,EAAE,GAAG,KAAK,iDAAiD,EAAE,CAAC;IACxF,CAAC;IAED,MAAM,MAAM,GAAG,MAAM,CAAC,IAAI,CAAC,OAAO,EAAE,MAAM,CAAC,CAAC;IAC5C,IAAI,CAAC,YAAY,CAAC,QAAQ,CAAC,CAAC,EAAE,MAAM,CAAC,MAAM,CAAC,CAAC,MAAM,CAAC,MAAM,CAAC,EAAE,CAAC;QAC7D,OAAO,EAAE,EAAE,EAAE,KAAK,EAAE,KAAK,EAAE,GAAG,KAAK,kDAAkD,EAAE,CAAC;IACzF,CAAC;IAED,MAAM,WAAW,GAAG,MAAM,cAAc,CAAC,YAAY,CAAC,QAAQ,CAAC,MAAM,CAAC,MAAM,CAAC,CAAC,CAAC;IAC/E,IAAI,CAAC,WAAW,CAAC,EAAE,EAAE,CAAC;QACrB,OAAO,EAAE,EAAE,EAAE,KAAK,EAAE,KAAK,EAAE,yBAAyB,WAAW,CAAC,KAAK,EAAE,EAAE,CAAC;IAC3E,CAAC;IACD,OAAO,EAAE,EAAE,EAAE,IAAI,EAAE,KAAK,EAAE,WAAW,CAAC,KAAK,EAAE,CAAC;AAC/C,CAAC"}
\ No newline at end of file
diff --git a/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/providers/chromium/windowsPaths.d.ts b/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/providers/chromium/windowsPaths.d.ts
deleted file mode 100644
index 66b0d07..0000000
--- a/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/providers/chromium/windowsPaths.d.ts
+++ /dev/null
@@ -1,8 +0,0 @@
-export declare function resolveChromiumPathsWindows(options: {
- localAppDataVendorPath: string;
- profile?: string;
-}): {
- dbPath: string | null;
- userDataDir: string | null;
-};
-//# sourceMappingURL=windowsPaths.d.ts.map
\ No newline at end of file
diff --git a/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/providers/chromium/windowsPaths.d.ts.map b/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/providers/chromium/windowsPaths.d.ts.map
deleted file mode 100644
index 52d69d9..0000000
--- a/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/providers/chromium/windowsPaths.d.ts.map
+++ /dev/null
@@ -1 +0,0 @@
-{"version":3,"file":"windowsPaths.d.ts","sourceRoot":"","sources":["../../../src/providers/chromium/windowsPaths.ts"],"names":[],"mappings":"AAKA,wBAAgB,2BAA2B,CAAC,OAAO,EAAE;IACpD,sBAAsB,EAAE,MAAM,CAAC;IAC/B,OAAO,CAAC,EAAE,MAAM,CAAC;CACjB,GAAG;IAAE,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC;IAAC,WAAW,EAAE,MAAM,GAAG,IAAI,CAAA;CAAE,CAmCxD"}
\ No newline at end of file
diff --git a/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/providers/chromium/windowsPaths.js b/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/providers/chromium/windowsPaths.js
deleted file mode 100644
index c073461..0000000
--- a/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/providers/chromium/windowsPaths.js
+++ /dev/null
@@ -1,53 +0,0 @@
-import { existsSync } from 'node:fs';
-import path from 'node:path';
-import { expandPath, looksLikePath } from './paths.js';
-export function resolveChromiumPathsWindows(options) {
- // biome-ignore lint/complexity/useLiteralKeys: process.env is an index signature under strict TS.
- const localAppData = process.env['LOCALAPPDATA'];
- const root = localAppData ? path.join(localAppData, options.localAppDataVendorPath) : null;
- if (options.profile && looksLikePath(options.profile)) {
- const expanded = expandPath(options.profile);
- const candidates = expanded.endsWith('Cookies')
- ? [expanded]
- : [
- path.join(expanded, 'Network', 'Cookies'),
- path.join(expanded, 'Cookies'),
- path.join(expanded, 'Default', 'Network', 'Cookies'),
- ];
- for (const candidate of candidates) {
- if (!existsSync(candidate))
- continue;
- const userDataDir = findUserDataDir(candidate);
- return { dbPath: candidate, userDataDir };
- }
- if (existsSync(path.join(expanded, 'Local State'))) {
- return { dbPath: null, userDataDir: expanded };
- }
- }
- const profileDir = options.profile && options.profile.trim().length > 0 ? options.profile.trim() : 'Default';
- if (!root)
- return { dbPath: null, userDataDir: null };
- const candidates = [
- path.join(root, profileDir, 'Network', 'Cookies'),
- path.join(root, profileDir, 'Cookies'),
- ];
- for (const candidate of candidates) {
- if (existsSync(candidate))
- return { dbPath: candidate, userDataDir: root };
- }
- return { dbPath: null, userDataDir: root };
-}
-function findUserDataDir(cookiesDbPath) {
- let current = path.dirname(cookiesDbPath);
- for (let i = 0; i < 6; i += 1) {
- const localState = path.join(current, 'Local State');
- if (existsSync(localState))
- return current;
- const next = path.dirname(current);
- if (next === current)
- break;
- current = next;
- }
- return null;
-}
-//# sourceMappingURL=windowsPaths.js.map
\ No newline at end of file
diff --git a/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/providers/chromium/windowsPaths.js.map b/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/providers/chromium/windowsPaths.js.map
deleted file mode 100644
index c526c58..0000000
--- a/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/providers/chromium/windowsPaths.js.map
+++ /dev/null
@@ -1 +0,0 @@
-{"version":3,"file":"windowsPaths.js","sourceRoot":"","sources":["../../../src/providers/chromium/windowsPaths.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,EAAE,MAAM,SAAS,CAAC;AACrC,OAAO,IAAI,MAAM,WAAW,CAAC;AAE7B,OAAO,EAAE,UAAU,EAAE,aAAa,EAAE,MAAM,YAAY,CAAC;AAEvD,MAAM,UAAU,2BAA2B,CAAC,OAG3C;IACA,kGAAkG;IAClG,MAAM,YAAY,GAAG,OAAO,CAAC,GAAG,CAAC,cAAc,CAAC,CAAC;IACjD,MAAM,IAAI,GAAG,YAAY,CAAC,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,YAAY,EAAE,OAAO,CAAC,sBAAsB,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC;IAE3F,IAAI,OAAO,CAAC,OAAO,IAAI,aAAa,CAAC,OAAO,CAAC,OAAO,CAAC,EAAE,CAAC;QACvD,MAAM,QAAQ,GAAG,UAAU,CAAC,OAAO,CAAC,OAAO,CAAC,CAAC;QAC7C,MAAM,UAAU,GAAG,QAAQ,CAAC,QAAQ,CAAC,SAAS,CAAC;YAC9C,CAAC,CAAC,CAAC,QAAQ,CAAC;YACZ,CAAC,CAAC;gBACA,IAAI,CAAC,IAAI,CAAC,QAAQ,EAAE,SAAS,EAAE,SAAS,CAAC;gBACzC,IAAI,CAAC,IAAI,CAAC,QAAQ,EAAE,SAAS,CAAC;gBAC9B,IAAI,CAAC,IAAI,CAAC,QAAQ,EAAE,SAAS,EAAE,SAAS,EAAE,SAAS,CAAC;aACpD,CAAC;QACJ,KAAK,MAAM,SAAS,IAAI,UAAU,EAAE,CAAC;YACpC,IAAI,CAAC,UAAU,CAAC,SAAS,CAAC;gBAAE,SAAS;YACrC,MAAM,WAAW,GAAG,eAAe,CAAC,SAAS,CAAC,CAAC;YAC/C,OAAO,EAAE,MAAM,EAAE,SAAS,EAAE,WAAW,EAAE,CAAC;QAC3C,CAAC;QACD,IAAI,UAAU,CAAC,IAAI,CAAC,IAAI,CAAC,QAAQ,EAAE,aAAa,CAAC,CAAC,EAAE,CAAC;YACpD,OAAO,EAAE,MAAM,EAAE,IAAI,EAAE,WAAW,EAAE,QAAQ,EAAE,CAAC;QAChD,CAAC;IACF,CAAC;IAED,MAAM,UAAU,GACf,OAAO,CAAC,OAAO,IAAI,OAAO,CAAC,OAAO,CAAC,IAAI,EAAE,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC,OAAO,CAAC,IAAI,EAAE,CAAC,CAAC,CAAC,SAAS,CAAC;IAC3F,IAAI,CAAC,IAAI;QAAE,OAAO,EAAE,MAAM,EAAE,IAAI,EAAE,WAAW,EAAE,IAAI,EAAE,CAAC;IACtD,MAAM,UAAU,GAAG;QAClB,IAAI,CAAC,IAAI,CAAC,IAAI,EAAE,UAAU,EAAE,SAAS,EAAE,SAAS,CAAC;QACjD,IAAI,CAAC,IAAI,CAAC,IAAI,EAAE,UAAU,EAAE,SAAS,CAAC;KACtC,CAAC;IACF,KAAK,MAAM,SAAS,IAAI,UAAU,EAAE,CAAC;QACpC,IAAI,UAAU,CAAC,SAAS,CAAC;YAAE,OAAO,EAAE,MAAM,EAAE,SAAS,EAAE,WAAW,EAAE,IAAI,EAAE,CAAC;IAC5E,CAAC;IACD,OAAO,EAAE,MAAM,EAAE,IAAI,EAAE,WAAW,EAAE,IAAI,EAAE,CAAC;AAC5C,CAAC;AAED,SAAS,eAAe,CAAC,aAAqB;IAC7C,IAAI,OAAO,GAAG,IAAI,CAAC,OAAO,CAAC,aAAa,CAAC,CAAC;IAC1C,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,CAAC,EAAE,CAAC,IAAI,CAAC,EAAE,CAAC;QAC/B,MAAM,UAAU,GAAG,IAAI,CAAC,IAAI,CAAC,OAAO,EAAE,aAAa,CAAC,CAAC;QACrD,IAAI,UAAU,CAAC,UAAU,CAAC;YAAE,OAAO,OAAO,CAAC;QAC3C,MAAM,IAAI,GAAG,IAAI,CAAC,OAAO,CAAC,OAAO,CAAC,CAAC;QACnC,IAAI,IAAI,KAAK,OAAO;YAAE,MAAM;QAC5B,OAAO,GAAG,IAAI,CAAC;IAChB,CAAC;IACD,OAAO,IAAI,CAAC;AACb,CAAC"}
\ No newline at end of file
diff --git a/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/providers/edge.d.ts b/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/providers/edge.d.ts
deleted file mode 100644
index ea43447..0000000
--- a/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/providers/edge.d.ts
+++ /dev/null
@@ -1,8 +0,0 @@
-import type { GetCookiesResult } from '../types.js';
-export declare function getCookiesFromEdge(options: {
- profile?: string;
- timeoutMs?: number;
- includeExpired?: boolean;
- debug?: boolean;
-}, origins: string[], allowlistNames: Set | null): Promise;
-//# sourceMappingURL=edge.d.ts.map
\ No newline at end of file
diff --git a/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/providers/edge.d.ts.map b/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/providers/edge.d.ts.map
deleted file mode 100644
index 7d5e0f9..0000000
--- a/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/providers/edge.d.ts.map
+++ /dev/null
@@ -1 +0,0 @@
-{"version":3,"file":"edge.d.ts","sourceRoot":"","sources":["../../src/providers/edge.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAU,gBAAgB,EAAE,MAAM,aAAa,CAAC;AAK5D,wBAAsB,kBAAkB,CACvC,OAAO,EAAE;IAAE,OAAO,CAAC,EAAE,MAAM,CAAC;IAAC,SAAS,CAAC,EAAE,MAAM,CAAC;IAAC,cAAc,CAAC,EAAE,OAAO,CAAC;IAAC,KAAK,CAAC,EAAE,OAAO,CAAA;CAAE,EAC5F,OAAO,EAAE,MAAM,EAAE,EACjB,cAAc,EAAE,GAAG,CAAC,MAAM,CAAC,GAAG,IAAI,GAChC,OAAO,CAAC,gBAAgB,CAAC,CA0B3B"}
\ No newline at end of file
diff --git a/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/providers/edge.js b/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/providers/edge.js
deleted file mode 100644
index c0f84c5..0000000
--- a/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/providers/edge.js
+++ /dev/null
@@ -1,27 +0,0 @@
-import { getCookiesFromEdgeSqliteLinux } from './edgeSqliteLinux.js';
-import { getCookiesFromEdgeSqliteMac } from './edgeSqliteMac.js';
-import { getCookiesFromEdgeSqliteWindows } from './edgeSqliteWindows.js';
-export async function getCookiesFromEdge(options, origins, allowlistNames) {
- const warnings = [];
- // Platform dispatch only. All real logic lives in the per-OS providers.
- if (process.platform === 'darwin') {
- const r = await getCookiesFromEdgeSqliteMac(options, origins, allowlistNames);
- warnings.push(...r.warnings);
- const cookies = r.cookies;
- return { cookies, warnings };
- }
- if (process.platform === 'linux') {
- const r = await getCookiesFromEdgeSqliteLinux(options, origins, allowlistNames);
- warnings.push(...r.warnings);
- const cookies = r.cookies;
- return { cookies, warnings };
- }
- if (process.platform === 'win32') {
- const r = await getCookiesFromEdgeSqliteWindows(options, origins, allowlistNames);
- warnings.push(...r.warnings);
- const cookies = r.cookies;
- return { cookies, warnings };
- }
- return { cookies: [], warnings };
-}
-//# sourceMappingURL=edge.js.map
\ No newline at end of file
diff --git a/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/providers/edge.js.map b/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/providers/edge.js.map
deleted file mode 100644
index d5dc206..0000000
--- a/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/providers/edge.js.map
+++ /dev/null
@@ -1 +0,0 @@
-{"version":3,"file":"edge.js","sourceRoot":"","sources":["../../src/providers/edge.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,6BAA6B,EAAE,MAAM,sBAAsB,CAAC;AACrE,OAAO,EAAE,2BAA2B,EAAE,MAAM,oBAAoB,CAAC;AACjE,OAAO,EAAE,+BAA+B,EAAE,MAAM,wBAAwB,CAAC;AAEzE,MAAM,CAAC,KAAK,UAAU,kBAAkB,CACvC,OAA4F,EAC5F,OAAiB,EACjB,cAAkC;IAElC,MAAM,QAAQ,GAAa,EAAE,CAAC;IAE9B,wEAAwE;IACxE,IAAI,OAAO,CAAC,QAAQ,KAAK,QAAQ,EAAE,CAAC;QACnC,MAAM,CAAC,GAAG,MAAM,2BAA2B,CAAC,OAAO,EAAE,OAAO,EAAE,cAAc,CAAC,CAAC;QAC9E,QAAQ,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,QAAQ,CAAC,CAAC;QAC7B,MAAM,OAAO,GAAa,CAAC,CAAC,OAAO,CAAC;QACpC,OAAO,EAAE,OAAO,EAAE,QAAQ,EAAE,CAAC;IAC9B,CAAC;IAED,IAAI,OAAO,CAAC,QAAQ,KAAK,OAAO,EAAE,CAAC;QAClC,MAAM,CAAC,GAAG,MAAM,6BAA6B,CAAC,OAAO,EAAE,OAAO,EAAE,cAAc,CAAC,CAAC;QAChF,QAAQ,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,QAAQ,CAAC,CAAC;QAC7B,MAAM,OAAO,GAAa,CAAC,CAAC,OAAO,CAAC;QACpC,OAAO,EAAE,OAAO,EAAE,QAAQ,EAAE,CAAC;IAC9B,CAAC;IAED,IAAI,OAAO,CAAC,QAAQ,KAAK,OAAO,EAAE,CAAC;QAClC,MAAM,CAAC,GAAG,MAAM,+BAA+B,CAAC,OAAO,EAAE,OAAO,EAAE,cAAc,CAAC,CAAC;QAClF,QAAQ,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,QAAQ,CAAC,CAAC;QAC7B,MAAM,OAAO,GAAa,CAAC,CAAC,OAAO,CAAC;QACpC,OAAO,EAAE,OAAO,EAAE,QAAQ,EAAE,CAAC;IAC9B,CAAC;IAED,OAAO,EAAE,OAAO,EAAE,EAAE,EAAE,QAAQ,EAAE,CAAC;AAClC,CAAC"}
\ No newline at end of file
diff --git a/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/providers/edgeSqliteLinux.d.ts b/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/providers/edgeSqliteLinux.d.ts
deleted file mode 100644
index b5ec216..0000000
--- a/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/providers/edgeSqliteLinux.d.ts
+++ /dev/null
@@ -1,7 +0,0 @@
-import type { GetCookiesResult } from '../types.js';
-export declare function getCookiesFromEdgeSqliteLinux(options: {
- profile?: string;
- includeExpired?: boolean;
- debug?: boolean;
-}, origins: string[], allowlistNames: Set | null): Promise;
-//# sourceMappingURL=edgeSqliteLinux.d.ts.map
\ No newline at end of file
diff --git a/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/providers/edgeSqliteLinux.d.ts.map b/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/providers/edgeSqliteLinux.d.ts.map
deleted file mode 100644
index 09b6e0f..0000000
--- a/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/providers/edgeSqliteLinux.d.ts.map
+++ /dev/null
@@ -1 +0,0 @@
-{"version":3,"file":"edgeSqliteLinux.d.ts","sourceRoot":"","sources":["../../src/providers/edgeSqliteLinux.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,aAAa,CAAC;AASpD,wBAAsB,6BAA6B,CAClD,OAAO,EAAE;IAAE,OAAO,CAAC,EAAE,MAAM,CAAC;IAAC,cAAc,CAAC,EAAE,OAAO,CAAC;IAAC,KAAK,CAAC,EAAE,OAAO,CAAA;CAAE,EACxE,OAAO,EAAE,MAAM,EAAE,EACjB,cAAc,EAAE,GAAG,CAAC,MAAM,CAAC,GAAG,IAAI,GAChC,OAAO,CAAC,gBAAgB,CAAC,CAoD3B"}
\ No newline at end of file
diff --git a/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/providers/edgeSqliteLinux.js b/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/providers/edgeSqliteLinux.js
deleted file mode 100644
index 5f42452..0000000
--- a/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/providers/edgeSqliteLinux.js
+++ /dev/null
@@ -1,53 +0,0 @@
-import { decryptChromiumAes128CbcCookieValue, deriveAes128CbcKeyFromPassword, } from './chromeSqlite/crypto.js';
-import { getLinuxChromiumSafeStoragePassword } from './chromeSqlite/linuxKeyring.js';
-import { getCookiesFromChromeSqliteDb } from './chromeSqlite/shared.js';
-import { resolveChromiumCookiesDbLinux } from './chromium/linuxPaths.js';
-export async function getCookiesFromEdgeSqliteLinux(options, origins, allowlistNames) {
- const args = {
- configDirName: 'microsoft-edge',
- };
- if (options.profile !== undefined)
- args.profile = options.profile;
- const dbPath = resolveChromiumCookiesDbLinux(args);
- if (!dbPath) {
- return { cookies: [], warnings: ['Edge cookies database not found.'] };
- }
- const { password, warnings: keyringWarnings } = await getLinuxChromiumSafeStoragePassword({
- app: 'edge',
- });
- // Linux uses multiple schemes depending on distro/keyring availability.
- // - v10 often uses the hard-coded "peanuts" password
- // - v11 uses " Safe Storage" from the keyring (may be empty/unavailable)
- const v10Key = deriveAes128CbcKeyFromPassword('peanuts', { iterations: 1 });
- const emptyKey = deriveAes128CbcKeyFromPassword('', { iterations: 1 });
- const v11Key = deriveAes128CbcKeyFromPassword(password, { iterations: 1 });
- const decrypt = (encryptedValue, opts) => {
- const prefix = Buffer.from(encryptedValue).subarray(0, 3).toString('utf8');
- if (prefix === 'v10') {
- return decryptChromiumAes128CbcCookieValue(encryptedValue, [v10Key, emptyKey], {
- stripHashPrefix: opts.stripHashPrefix,
- treatUnknownPrefixAsPlaintext: false,
- });
- }
- if (prefix === 'v11') {
- return decryptChromiumAes128CbcCookieValue(encryptedValue, [v11Key, emptyKey], {
- stripHashPrefix: opts.stripHashPrefix,
- treatUnknownPrefixAsPlaintext: false,
- });
- }
- return null;
- };
- const dbOptions = {
- dbPath,
- };
- if (options.profile)
- dbOptions.profile = options.profile;
- if (options.includeExpired !== undefined)
- dbOptions.includeExpired = options.includeExpired;
- if (options.debug !== undefined)
- dbOptions.debug = options.debug;
- const result = await getCookiesFromChromeSqliteDb(dbOptions, origins, allowlistNames, decrypt);
- result.warnings.unshift(...keyringWarnings);
- return result;
-}
-//# sourceMappingURL=edgeSqliteLinux.js.map
\ No newline at end of file
diff --git a/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/providers/edgeSqliteLinux.js.map b/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/providers/edgeSqliteLinux.js.map
deleted file mode 100644
index 37a33a2..0000000
--- a/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/providers/edgeSqliteLinux.js.map
+++ /dev/null
@@ -1 +0,0 @@
-{"version":3,"file":"edgeSqliteLinux.js","sourceRoot":"","sources":["../../src/providers/edgeSqliteLinux.ts"],"names":[],"mappings":"AACA,OAAO,EACN,mCAAmC,EACnC,8BAA8B,GAC9B,MAAM,0BAA0B,CAAC;AAClC,OAAO,EAAE,mCAAmC,EAAE,MAAM,gCAAgC,CAAC;AACrF,OAAO,EAAE,4BAA4B,EAAE,MAAM,0BAA0B,CAAC;AACxE,OAAO,EAAE,6BAA6B,EAAE,MAAM,0BAA0B,CAAC;AAEzE,MAAM,CAAC,KAAK,UAAU,6BAA6B,CAClD,OAAwE,EACxE,OAAiB,EACjB,cAAkC;IAElC,MAAM,IAAI,GAAwD;QACjE,aAAa,EAAE,gBAAgB;KAC/B,CAAC;IACF,IAAI,OAAO,CAAC,OAAO,KAAK,SAAS;QAAE,IAAI,CAAC,OAAO,GAAG,OAAO,CAAC,OAAO,CAAC;IAClE,MAAM,MAAM,GAAG,6BAA6B,CAAC,IAAI,CAAC,CAAC;IACnD,IAAI,CAAC,MAAM,EAAE,CAAC;QACb,OAAO,EAAE,OAAO,EAAE,EAAE,EAAE,QAAQ,EAAE,CAAC,kCAAkC,CAAC,EAAE,CAAC;IACxE,CAAC;IAED,MAAM,EAAE,QAAQ,EAAE,QAAQ,EAAE,eAAe,EAAE,GAAG,MAAM,mCAAmC,CAAC;QACzF,GAAG,EAAE,MAAM;KACX,CAAC,CAAC;IAEH,wEAAwE;IACxE,qDAAqD;IACrD,kFAAkF;IAClF,MAAM,MAAM,GAAG,8BAA8B,CAAC,SAAS,EAAE,EAAE,UAAU,EAAE,CAAC,EAAE,CAAC,CAAC;IAC5E,MAAM,QAAQ,GAAG,8BAA8B,CAAC,EAAE,EAAE,EAAE,UAAU,EAAE,CAAC,EAAE,CAAC,CAAC;IACvE,MAAM,MAAM,GAAG,8BAA8B,CAAC,QAAQ,EAAE,EAAE,UAAU,EAAE,CAAC,EAAE,CAAC,CAAC;IAE3E,MAAM,OAAO,GAAG,CACf,cAA0B,EAC1B,IAAkC,EAClB,EAAE;QAClB,MAAM,MAAM,GAAG,MAAM,CAAC,IAAI,CAAC,cAAc,CAAC,CAAC,QAAQ,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,QAAQ,CAAC,MAAM,CAAC,CAAC;QAC3E,IAAI,MAAM,KAAK,KAAK,EAAE,CAAC;YACtB,OAAO,mCAAmC,CAAC,cAAc,EAAE,CAAC,MAAM,EAAE,QAAQ,CAAC,EAAE;gBAC9E,eAAe,EAAE,IAAI,CAAC,eAAe;gBACrC,6BAA6B,EAAE,KAAK;aACpC,CAAC,CAAC;QACJ,CAAC;QACD,IAAI,MAAM,KAAK,KAAK,EAAE,CAAC;YACtB,OAAO,mCAAmC,CAAC,cAAc,EAAE,CAAC,MAAM,EAAE,QAAQ,CAAC,EAAE;gBAC9E,eAAe,EAAE,IAAI,CAAC,eAAe;gBACrC,6BAA6B,EAAE,KAAK;aACpC,CAAC,CAAC;QACJ,CAAC;QACD,OAAO,IAAI,CAAC;IACb,CAAC,CAAC;IAEF,MAAM,SAAS,GACd;QACC,MAAM;KACN,CAAC;IACH,IAAI,OAAO,CAAC,OAAO;QAAE,SAAS,CAAC,OAAO,GAAG,OAAO,CAAC,OAAO,CAAC;IACzD,IAAI,OAAO,CAAC,cAAc,KAAK,SAAS;QAAE,SAAS,CAAC,cAAc,GAAG,OAAO,CAAC,cAAc,CAAC;IAC5F,IAAI,OAAO,CAAC,KAAK,KAAK,SAAS;QAAE,SAAS,CAAC,KAAK,GAAG,OAAO,CAAC,KAAK,CAAC;IAEjE,MAAM,MAAM,GAAG,MAAM,4BAA4B,CAAC,SAAS,EAAE,OAAO,EAAE,cAAc,EAAE,OAAO,CAAC,CAAC;IAC/F,MAAM,CAAC,QAAQ,CAAC,OAAO,CAAC,GAAG,eAAe,CAAC,CAAC;IAC5C,OAAO,MAAM,CAAC;AACf,CAAC"}
\ No newline at end of file
diff --git a/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/providers/edgeSqliteMac.d.ts b/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/providers/edgeSqliteMac.d.ts
deleted file mode 100644
index 4ae4074..0000000
--- a/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/providers/edgeSqliteMac.d.ts
+++ /dev/null
@@ -1,8 +0,0 @@
-import type { GetCookiesResult } from '../types.js';
-export declare function getCookiesFromEdgeSqliteMac(options: {
- profile?: string;
- includeExpired?: boolean;
- debug?: boolean;
- timeoutMs?: number;
-}, origins: string[], allowlistNames: Set | null): Promise;
-//# sourceMappingURL=edgeSqliteMac.d.ts.map
\ No newline at end of file
diff --git a/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/providers/edgeSqliteMac.d.ts.map b/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/providers/edgeSqliteMac.d.ts.map
deleted file mode 100644
index 0d8809d..0000000
--- a/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/providers/edgeSqliteMac.d.ts.map
+++ /dev/null
@@ -1 +0,0 @@
-{"version":3,"file":"edgeSqliteMac.d.ts","sourceRoot":"","sources":["../../src/providers/edgeSqliteMac.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,aAAa,CAAC;AASpD,wBAAsB,2BAA2B,CAChD,OAAO,EAAE;IAAE,OAAO,CAAC,EAAE,MAAM,CAAC;IAAC,cAAc,CAAC,EAAE,OAAO,CAAC;IAAC,KAAK,CAAC,EAAE,OAAO,CAAC;IAAC,SAAS,CAAC,EAAE,MAAM,CAAA;CAAE,EAC5F,OAAO,EAAE,MAAM,EAAE,EACjB,cAAc,EAAE,GAAG,CAAC,MAAM,CAAC,GAAG,IAAI,GAChC,OAAO,CAAC,gBAAgB,CAAC,CA8C3B"}
\ No newline at end of file
diff --git a/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/providers/edgeSqliteMac.js b/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/providers/edgeSqliteMac.js
deleted file mode 100644
index 5876aee..0000000
--- a/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/providers/edgeSqliteMac.js
+++ /dev/null
@@ -1,60 +0,0 @@
-import { homedir } from 'node:os';
-import path from 'node:path';
-import { decryptChromiumAes128CbcCookieValue, deriveAes128CbcKeyFromPassword, } from './chromeSqlite/crypto.js';
-import { getCookiesFromChromeSqliteDb } from './chromeSqlite/shared.js';
-import { readKeychainGenericPasswordFirst } from './chromium/macosKeychain.js';
-import { resolveCookiesDbFromProfileOrRoots } from './chromium/paths.js';
-export async function getCookiesFromEdgeSqliteMac(options, origins, allowlistNames) {
- const dbPath = resolveEdgeCookiesDb(options.profile);
- if (!dbPath) {
- return { cookies: [], warnings: ['Edge cookies database not found.'] };
- }
- const warnings = [];
- // On macOS, Edge stores its "Safe Storage" secret in Keychain (same scheme as Chrome).
- // `security find-generic-password` is stable and avoids any native Node keychain modules.
- const passwordResult = await readKeychainGenericPasswordFirst({
- account: 'Microsoft Edge',
- services: ['Microsoft Edge Safe Storage', 'Microsoft Edge'],
- timeoutMs: options.timeoutMs ?? 3_000,
- label: 'Microsoft Edge Safe Storage',
- });
- if (!passwordResult.ok) {
- warnings.push(passwordResult.error);
- return { cookies: [], warnings };
- }
- const edgePassword = passwordResult.password.trim();
- if (!edgePassword) {
- warnings.push('macOS Keychain returned an empty Microsoft Edge Safe Storage password.');
- return { cookies: [], warnings };
- }
- // Chromium uses PBKDF2(password, "saltysalt", 1003, 16, sha1) for AES-128-CBC cookie values on macOS.
- const key = deriveAes128CbcKeyFromPassword(edgePassword, { iterations: 1003 });
- const decrypt = (encryptedValue, opts) => decryptChromiumAes128CbcCookieValue(encryptedValue, [key], {
- stripHashPrefix: opts.stripHashPrefix,
- treatUnknownPrefixAsPlaintext: true,
- });
- const dbOptions = {
- dbPath,
- };
- if (options.profile)
- dbOptions.profile = options.profile;
- if (options.includeExpired !== undefined)
- dbOptions.includeExpired = options.includeExpired;
- if (options.debug !== undefined)
- dbOptions.debug = options.debug;
- const result = await getCookiesFromChromeSqliteDb(dbOptions, origins, allowlistNames, decrypt);
- result.warnings.unshift(...warnings);
- return result;
-}
-function resolveEdgeCookiesDb(profile) {
- const home = homedir();
- /* c8 ignore next */
- const roots = process.platform === 'darwin'
- ? [path.join(home, 'Library', 'Application Support', 'Microsoft Edge')]
- : [];
- const args = { roots };
- if (profile !== undefined)
- args.profile = profile;
- return resolveCookiesDbFromProfileOrRoots(args);
-}
-//# sourceMappingURL=edgeSqliteMac.js.map
\ No newline at end of file
diff --git a/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/providers/edgeSqliteMac.js.map b/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/providers/edgeSqliteMac.js.map
deleted file mode 100644
index f363f01..0000000
--- a/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/providers/edgeSqliteMac.js.map
+++ /dev/null
@@ -1 +0,0 @@
-{"version":3,"file":"edgeSqliteMac.js","sourceRoot":"","sources":["../../src/providers/edgeSqliteMac.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,EAAE,MAAM,SAAS,CAAC;AAClC,OAAO,IAAI,MAAM,WAAW,CAAC;AAG7B,OAAO,EACN,mCAAmC,EACnC,8BAA8B,GAC9B,MAAM,0BAA0B,CAAC;AAClC,OAAO,EAAE,4BAA4B,EAAE,MAAM,0BAA0B,CAAC;AACxE,OAAO,EAAE,gCAAgC,EAAE,MAAM,6BAA6B,CAAC;AAC/E,OAAO,EAAE,kCAAkC,EAAE,MAAM,qBAAqB,CAAC;AAEzE,MAAM,CAAC,KAAK,UAAU,2BAA2B,CAChD,OAA4F,EAC5F,OAAiB,EACjB,cAAkC;IAElC,MAAM,MAAM,GAAG,oBAAoB,CAAC,OAAO,CAAC,OAAO,CAAC,CAAC;IACrD,IAAI,CAAC,MAAM,EAAE,CAAC;QACb,OAAO,EAAE,OAAO,EAAE,EAAE,EAAE,QAAQ,EAAE,CAAC,kCAAkC,CAAC,EAAE,CAAC;IACxE,CAAC;IAED,MAAM,QAAQ,GAAa,EAAE,CAAC;IAE9B,uFAAuF;IACvF,0FAA0F;IAC1F,MAAM,cAAc,GAAG,MAAM,gCAAgC,CAAC;QAC7D,OAAO,EAAE,gBAAgB;QACzB,QAAQ,EAAE,CAAC,6BAA6B,EAAE,gBAAgB,CAAC;QAC3D,SAAS,EAAE,OAAO,CAAC,SAAS,IAAI,KAAK;QACrC,KAAK,EAAE,6BAA6B;KACpC,CAAC,CAAC;IACH,IAAI,CAAC,cAAc,CAAC,EAAE,EAAE,CAAC;QACxB,QAAQ,CAAC,IAAI,CAAC,cAAc,CAAC,KAAK,CAAC,CAAC;QACpC,OAAO,EAAE,OAAO,EAAE,EAAE,EAAE,QAAQ,EAAE,CAAC;IAClC,CAAC;IAED,MAAM,YAAY,GAAG,cAAc,CAAC,QAAQ,CAAC,IAAI,EAAE,CAAC;IACpD,IAAI,CAAC,YAAY,EAAE,CAAC;QACnB,QAAQ,CAAC,IAAI,CAAC,wEAAwE,CAAC,CAAC;QACxF,OAAO,EAAE,OAAO,EAAE,EAAE,EAAE,QAAQ,EAAE,CAAC;IAClC,CAAC;IAED,sGAAsG;IACtG,MAAM,GAAG,GAAG,8BAA8B,CAAC,YAAY,EAAE,EAAE,UAAU,EAAE,IAAI,EAAE,CAAC,CAAC;IAC/E,MAAM,OAAO,GAAG,CAAC,cAA0B,EAAE,IAAkC,EAAiB,EAAE,CACjG,mCAAmC,CAAC,cAAc,EAAE,CAAC,GAAG,CAAC,EAAE;QAC1D,eAAe,EAAE,IAAI,CAAC,eAAe;QACrC,6BAA6B,EAAE,IAAI;KACnC,CAAC,CAAC;IAEJ,MAAM,SAAS,GACd;QACC,MAAM;KACN,CAAC;IACH,IAAI,OAAO,CAAC,OAAO;QAAE,SAAS,CAAC,OAAO,GAAG,OAAO,CAAC,OAAO,CAAC;IACzD,IAAI,OAAO,CAAC,cAAc,KAAK,SAAS;QAAE,SAAS,CAAC,cAAc,GAAG,OAAO,CAAC,cAAc,CAAC;IAC5F,IAAI,OAAO,CAAC,KAAK,KAAK,SAAS;QAAE,SAAS,CAAC,KAAK,GAAG,OAAO,CAAC,KAAK,CAAC;IAEjE,MAAM,MAAM,GAAG,MAAM,4BAA4B,CAAC,SAAS,EAAE,OAAO,EAAE,cAAc,EAAE,OAAO,CAAC,CAAC;IAC/F,MAAM,CAAC,QAAQ,CAAC,OAAO,CAAC,GAAG,QAAQ,CAAC,CAAC;IACrC,OAAO,MAAM,CAAC;AACf,CAAC;AAED,SAAS,oBAAoB,CAAC,OAAgB;IAC7C,MAAM,IAAI,GAAG,OAAO,EAAE,CAAC;IACvB,oBAAoB;IACpB,MAAM,KAAK,GACV,OAAO,CAAC,QAAQ,KAAK,QAAQ;QAC5B,CAAC,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,IAAI,EAAE,SAAS,EAAE,qBAAqB,EAAE,gBAAgB,CAAC,CAAC;QACvE,CAAC,CAAC,EAAE,CAAC;IACP,MAAM,IAAI,GAA6D,EAAE,KAAK,EAAE,CAAC;IACjF,IAAI,OAAO,KAAK,SAAS;QAAE,IAAI,CAAC,OAAO,GAAG,OAAO,CAAC;IAClD,OAAO,kCAAkC,CAAC,IAAI,CAAC,CAAC;AACjD,CAAC"}
\ No newline at end of file
diff --git a/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/providers/edgeSqliteWindows.d.ts b/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/providers/edgeSqliteWindows.d.ts
deleted file mode 100644
index 3e06bd9..0000000
--- a/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/providers/edgeSqliteWindows.d.ts
+++ /dev/null
@@ -1,7 +0,0 @@
-import type { GetCookiesResult } from '../types.js';
-export declare function getCookiesFromEdgeSqliteWindows(options: {
- profile?: string;
- includeExpired?: boolean;
- debug?: boolean;
-}, origins: string[], allowlistNames: Set | null): Promise;
-//# sourceMappingURL=edgeSqliteWindows.d.ts.map
\ No newline at end of file
diff --git a/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/providers/edgeSqliteWindows.d.ts.map b/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/providers/edgeSqliteWindows.d.ts.map
deleted file mode 100644
index 3751b35..0000000
--- a/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/providers/edgeSqliteWindows.d.ts.map
+++ /dev/null
@@ -1 +0,0 @@
-{"version":3,"file":"edgeSqliteWindows.d.ts","sourceRoot":"","sources":["../../src/providers/edgeSqliteWindows.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,aAAa,CAAC;AAMpD,wBAAsB,+BAA+B,CACpD,OAAO,EAAE;IAAE,OAAO,CAAC,EAAE,MAAM,CAAC;IAAC,cAAc,CAAC,EAAE,OAAO,CAAC;IAAC,KAAK,CAAC,EAAE,OAAO,CAAA;CAAE,EACxE,OAAO,EAAE,MAAM,EAAE,EACjB,cAAc,EAAE,GAAG,CAAC,MAAM,CAAC,GAAG,IAAI,GAChC,OAAO,CAAC,gBAAgB,CAAC,CAmC3B"}
\ No newline at end of file
diff --git a/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/providers/edgeSqliteWindows.js b/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/providers/edgeSqliteWindows.js
deleted file mode 100644
index bdf7284..0000000
--- a/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/providers/edgeSqliteWindows.js
+++ /dev/null
@@ -1,38 +0,0 @@
-import path from 'node:path';
-import { decryptChromiumAes256GcmCookieValue } from './chromeSqlite/crypto.js';
-import { getCookiesFromChromeSqliteDb } from './chromeSqlite/shared.js';
-import { getWindowsChromiumMasterKey } from './chromium/windowsMasterKey.js';
-import { resolveChromiumPathsWindows } from './chromium/windowsPaths.js';
-export async function getCookiesFromEdgeSqliteWindows(options, origins, allowlistNames) {
- const resolveArgs = {
- localAppDataVendorPath: path.join('Microsoft', 'Edge', 'User Data'),
- };
- if (options.profile !== undefined)
- resolveArgs.profile = options.profile;
- const { dbPath, userDataDir } = resolveChromiumPathsWindows(resolveArgs);
- if (!dbPath || !userDataDir) {
- return { cookies: [], warnings: ['Edge cookies database not found.'] };
- }
- // On Windows, Edge stores an AES key in `Local State` encrypted with DPAPI (CurrentUser).
- // That master key is then used for AES-256-GCM cookie values (`v10`/`v11`/`v20` prefixes).
- const masterKey = await getWindowsChromiumMasterKey(userDataDir, 'Edge');
- if (!masterKey.ok) {
- return { cookies: [], warnings: [masterKey.error] };
- }
- const decrypt = (encryptedValue, opts) => {
- return decryptChromiumAes256GcmCookieValue(encryptedValue, masterKey.value, {
- stripHashPrefix: opts.stripHashPrefix,
- });
- };
- const dbOptions = {
- dbPath,
- };
- if (options.profile)
- dbOptions.profile = options.profile;
- if (options.includeExpired !== undefined)
- dbOptions.includeExpired = options.includeExpired;
- if (options.debug !== undefined)
- dbOptions.debug = options.debug;
- return await getCookiesFromChromeSqliteDb(dbOptions, origins, allowlistNames, decrypt);
-}
-//# sourceMappingURL=edgeSqliteWindows.js.map
\ No newline at end of file
diff --git a/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/providers/edgeSqliteWindows.js.map b/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/providers/edgeSqliteWindows.js.map
deleted file mode 100644
index 3e63db4..0000000
--- a/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/providers/edgeSqliteWindows.js.map
+++ /dev/null
@@ -1 +0,0 @@
-{"version":3,"file":"edgeSqliteWindows.js","sourceRoot":"","sources":["../../src/providers/edgeSqliteWindows.ts"],"names":[],"mappings":"AAAA,OAAO,IAAI,MAAM,WAAW,CAAC;AAG7B,OAAO,EAAE,mCAAmC,EAAE,MAAM,0BAA0B,CAAC;AAC/E,OAAO,EAAE,4BAA4B,EAAE,MAAM,0BAA0B,CAAC;AACxE,OAAO,EAAE,2BAA2B,EAAE,MAAM,gCAAgC,CAAC;AAC7E,OAAO,EAAE,2BAA2B,EAAE,MAAM,4BAA4B,CAAC;AAEzE,MAAM,CAAC,KAAK,UAAU,+BAA+B,CACpD,OAAwE,EACxE,OAAiB,EACjB,cAAkC;IAElC,MAAM,WAAW,GAAsD;QACtE,sBAAsB,EAAE,IAAI,CAAC,IAAI,CAAC,WAAW,EAAE,MAAM,EAAE,WAAW,CAAC;KACnE,CAAC;IACF,IAAI,OAAO,CAAC,OAAO,KAAK,SAAS;QAAE,WAAW,CAAC,OAAO,GAAG,OAAO,CAAC,OAAO,CAAC;IACzE,MAAM,EAAE,MAAM,EAAE,WAAW,EAAE,GAAG,2BAA2B,CAAC,WAAW,CAAC,CAAC;IACzE,IAAI,CAAC,MAAM,IAAI,CAAC,WAAW,EAAE,CAAC;QAC7B,OAAO,EAAE,OAAO,EAAE,EAAE,EAAE,QAAQ,EAAE,CAAC,kCAAkC,CAAC,EAAE,CAAC;IACxE,CAAC;IAED,0FAA0F;IAC1F,2FAA2F;IAC3F,MAAM,SAAS,GAAG,MAAM,2BAA2B,CAAC,WAAW,EAAE,MAAM,CAAC,CAAC;IACzE,IAAI,CAAC,SAAS,CAAC,EAAE,EAAE,CAAC;QACnB,OAAO,EAAE,OAAO,EAAE,EAAE,EAAE,QAAQ,EAAE,CAAC,SAAS,CAAC,KAAK,CAAC,EAAE,CAAC;IACrD,CAAC;IAED,MAAM,OAAO,GAAG,CACf,cAA0B,EAC1B,IAAkC,EAClB,EAAE;QAClB,OAAO,mCAAmC,CAAC,cAAc,EAAE,SAAS,CAAC,KAAK,EAAE;YAC3E,eAAe,EAAE,IAAI,CAAC,eAAe;SACrC,CAAC,CAAC;IACJ,CAAC,CAAC;IAEF,MAAM,SAAS,GACd;QACC,MAAM;KACN,CAAC;IACH,IAAI,OAAO,CAAC,OAAO;QAAE,SAAS,CAAC,OAAO,GAAG,OAAO,CAAC,OAAO,CAAC;IACzD,IAAI,OAAO,CAAC,cAAc,KAAK,SAAS;QAAE,SAAS,CAAC,cAAc,GAAG,OAAO,CAAC,cAAc,CAAC;IAC5F,IAAI,OAAO,CAAC,KAAK,KAAK,SAAS;QAAE,SAAS,CAAC,KAAK,GAAG,OAAO,CAAC,KAAK,CAAC;IAEjE,OAAO,MAAM,4BAA4B,CAAC,SAAS,EAAE,OAAO,EAAE,cAAc,EAAE,OAAO,CAAC,CAAC;AACxF,CAAC"}
\ No newline at end of file
diff --git a/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/providers/firefoxSqlite.d.ts b/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/providers/firefoxSqlite.d.ts
deleted file mode 100644
index 4b1c6c8..0000000
--- a/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/providers/firefoxSqlite.d.ts
+++ /dev/null
@@ -1,6 +0,0 @@
-import type { GetCookiesResult } from '../types.js';
-export declare function getCookiesFromFirefox(options: {
- profile?: string;
- includeExpired?: boolean;
-}, origins: string[], allowlistNames: Set | null): Promise;
-//# sourceMappingURL=firefoxSqlite.d.ts.map
\ No newline at end of file
diff --git a/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/providers/firefoxSqlite.d.ts.map b/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/providers/firefoxSqlite.d.ts.map
deleted file mode 100644
index 9a51b2d..0000000
--- a/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/providers/firefoxSqlite.d.ts.map
+++ /dev/null
@@ -1 +0,0 @@
-{"version":3,"file":"firefoxSqlite.d.ts","sourceRoot":"","sources":["../../src/providers/firefoxSqlite.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAA0B,gBAAgB,EAAE,MAAM,aAAa,CAAC;AAK5E,wBAAsB,qBAAqB,CAC1C,OAAO,EAAE;IAAE,OAAO,CAAC,EAAE,MAAM,CAAC;IAAC,cAAc,CAAC,EAAE,OAAO,CAAA;CAAE,EACvD,OAAO,EAAE,MAAM,EAAE,EACjB,cAAc,EAAE,GAAG,CAAC,MAAM,CAAC,GAAG,IAAI,GAChC,OAAO,CAAC,gBAAgB,CAAC,CAmD3B"}
\ No newline at end of file
diff --git a/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/providers/firefoxSqlite.js b/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/providers/firefoxSqlite.js
deleted file mode 100644
index 6370263..0000000
--- a/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/providers/firefoxSqlite.js
+++ /dev/null
@@ -1,257 +0,0 @@
-import { copyFileSync, existsSync, mkdtempSync, readdirSync, rmSync } from 'node:fs';
-import { homedir, tmpdir } from 'node:os';
-import path from 'node:path';
-import { hostMatchesCookieDomain } from '../util/hostMatch.js';
-import { importNodeSqlite } from '../util/nodeSqlite.js';
-import { isBunRuntime } from '../util/runtime.js';
-export async function getCookiesFromFirefox(options, origins, allowlistNames) {
- const warnings = [];
- const dbPath = resolveFirefoxCookiesDb(options.profile);
- if (!dbPath) {
- warnings.push('Firefox cookies database not found.');
- return { cookies: [], warnings };
- }
- const tempDir = mkdtempSync(path.join(tmpdir(), 'sweet-cookie-firefox-'));
- const tempDbPath = path.join(tempDir, 'cookies.sqlite');
- try {
- copyFileSync(dbPath, tempDbPath);
- copySidecar(dbPath, `${tempDbPath}-wal`, '-wal');
- copySidecar(dbPath, `${tempDbPath}-shm`, '-shm');
- }
- catch (error) {
- rmSync(tempDir, { recursive: true, force: true });
- warnings.push(`Failed to copy Firefox cookie DB: ${error instanceof Error ? error.message : String(error)}`);
- return { cookies: [], warnings };
- }
- const hosts = origins.map((o) => new URL(o).hostname);
- const now = Math.floor(Date.now() / 1000);
- const where = buildHostWhereClause(hosts);
- const expiryClause = options.includeExpired ? '' : ` AND (expiry = 0 OR expiry > ${now})`;
- const sql = `SELECT name, value, host, path, expiry, isSecure, isHttpOnly, sameSite ` +
- `FROM moz_cookies WHERE (${where})${expiryClause} ORDER BY expiry DESC;`;
- try {
- if (isBunRuntime()) {
- const bunResult = await queryFirefoxCookiesWithBunSqlite(tempDbPath, sql);
- if (!bunResult.ok) {
- warnings.push(`bun:sqlite failed reading Firefox cookies: ${bunResult.error}`);
- return { cookies: [], warnings };
- }
- const cookies = collectFirefoxCookiesFromRows(bunResult.rows, options, hosts, allowlistNames);
- return { cookies: dedupeCookies(cookies), warnings };
- }
- const nodeResult = await queryFirefoxCookiesWithNodeSqlite(tempDbPath, sql);
- if (!nodeResult.ok) {
- warnings.push(`node:sqlite failed reading Firefox cookies: ${nodeResult.error}`);
- return { cookies: [], warnings };
- }
- const cookies = collectFirefoxCookiesFromRows(nodeResult.rows, options, hosts, allowlistNames);
- return { cookies: dedupeCookies(cookies), warnings };
- }
- finally {
- rmSync(tempDir, { recursive: true, force: true });
- }
-}
-async function queryFirefoxCookiesWithNodeSqlite(dbPath, sql) {
- try {
- const { DatabaseSync } = await importNodeSqlite();
- const db = new DatabaseSync(dbPath, { readOnly: true });
- try {
- const rows = db.prepare(sql).all();
- return { ok: true, rows };
- }
- finally {
- db.close();
- }
- }
- catch (error) {
- return { ok: false, error: error instanceof Error ? error.message : String(error) };
- }
-}
-async function queryFirefoxCookiesWithBunSqlite(dbPath, sql) {
- try {
- const { Database } = await import('bun:sqlite');
- const db = new Database(dbPath, { readonly: true });
- try {
- const rows = db.query(sql).all();
- return { ok: true, rows };
- }
- finally {
- db.close();
- }
- }
- catch (error) {
- return { ok: false, error: error instanceof Error ? error.message : String(error) };
- }
-}
-function collectFirefoxCookiesFromRows(rows, options, hosts, allowlistNames) {
- const now = Math.floor(Date.now() / 1000);
- const cookies = [];
- for (const row of rows) {
- const name = typeof row.name === 'string' ? row.name : null;
- const value = typeof row.value === 'string' ? row.value : null;
- const host = typeof row.host === 'string' ? row.host : null;
- const cookiePath = typeof row.path === 'string' ? row.path : '';
- if (!name || value === null || !host)
- continue;
- if (allowlistNames && allowlistNames.size > 0 && !allowlistNames.has(name))
- continue;
- if (!hostMatchesAny(hosts, host))
- continue;
- const expiryText = typeof row.expiry === 'number'
- ? String(row.expiry)
- : typeof row.expiry === 'string'
- ? row.expiry
- : undefined;
- const expires = normalizeFirefoxExpiry(expiryText);
- if (!options.includeExpired && expires && expires < now)
- continue;
- const isSecure = row.isSecure === 1 || row.isSecure === '1' || row.isSecure === true;
- const isHttpOnly = row.isHttpOnly === 1 || row.isHttpOnly === '1' || row.isHttpOnly === true;
- const cookie = {
- name,
- value,
- domain: host.startsWith('.') ? host.slice(1) : host,
- path: cookiePath || '/',
- secure: isSecure,
- httpOnly: isHttpOnly,
- };
- if (expires !== undefined)
- cookie.expires = expires;
- const normalizedSameSite = normalizeFirefoxSameSite(typeof row.sameSite === 'number'
- ? String(row.sameSite)
- : typeof row.sameSite === 'string'
- ? row.sameSite
- : undefined);
- if (normalizedSameSite !== undefined)
- cookie.sameSite = normalizedSameSite;
- const source = { browser: 'firefox' };
- if (options.profile)
- source.profile = options.profile;
- cookie.source = source;
- cookies.push(cookie);
- }
- return cookies;
-}
-function resolveFirefoxCookiesDb(profile) {
- const home = homedir();
- // biome-ignore lint/complexity/useLiteralKeys: process.env is an index signature under strict TS.
- const appData = process.env['APPDATA'];
- /* c8 ignore next 10 */
- const roots = process.platform === 'darwin'
- ? [path.join(home, 'Library', 'Application Support', 'Firefox', 'Profiles')]
- : process.platform === 'linux'
- ? [path.join(home, '.mozilla', 'firefox')]
- : process.platform === 'win32'
- ? appData
- ? [path.join(appData, 'Mozilla', 'Firefox', 'Profiles')]
- : []
- : [];
- if (profile && looksLikePath(profile)) {
- const candidate = profile.endsWith('cookies.sqlite')
- ? profile
- : path.join(profile, 'cookies.sqlite');
- return existsSync(candidate) ? candidate : null;
- }
- for (const root of roots) {
- if (!root || !existsSync(root))
- continue;
- if (profile) {
- const candidate = path.join(root, profile, 'cookies.sqlite');
- if (existsSync(candidate))
- return candidate;
- continue;
- }
- const entries = safeReaddir(root);
- const defaultRelease = entries.find((e) => e.includes('default-release'));
- const picked = defaultRelease ?? entries[0];
- if (!picked)
- continue;
- const candidate = path.join(root, picked, 'cookies.sqlite');
- if (existsSync(candidate))
- return candidate;
- }
- return null;
-}
-function safeReaddir(dir) {
- try {
- return readdirSync(dir, { withFileTypes: true })
- .filter((e) => e.isDirectory())
- .map((e) => e.name);
- }
- catch {
- return [];
- }
-}
-function looksLikePath(value) {
- return value.includes('/') || value.includes('\\');
-}
-function copySidecar(sourceDbPath, target, suffix) {
- const sidecar = `${sourceDbPath}${suffix}`;
- if (!existsSync(sidecar))
- return;
- try {
- copyFileSync(sidecar, target);
- }
- catch {
- // ignore
- }
-}
-function buildHostWhereClause(hosts) {
- const clauses = [];
- for (const host of hosts) {
- const escaped = sqlLiteral(host);
- const escapedDot = sqlLiteral(`.${host}`);
- const escapedLike = sqlLiteral(`%.${host}`);
- clauses.push(`host = ${escaped}`);
- clauses.push(`host = ${escapedDot}`);
- clauses.push(`host LIKE ${escapedLike}`);
- }
- return clauses.length ? clauses.join(' OR ') : '1=0';
-}
-function sqlLiteral(value) {
- const escaped = value.replaceAll("'", "''");
- return `'${escaped}'`;
-}
-function normalizeFirefoxExpiry(expiry) {
- if (!expiry)
- return undefined;
- const value = Number.parseInt(expiry, 10);
- if (!Number.isFinite(value) || value <= 0)
- return undefined;
- return value;
-}
-function normalizeFirefoxSameSite(raw) {
- if (!raw)
- return undefined;
- const value = Number.parseInt(raw, 10);
- if (Number.isFinite(value)) {
- if (value === 2)
- return 'Strict';
- if (value === 1)
- return 'Lax';
- if (value === 0)
- return 'None';
- }
- const normalized = raw.toLowerCase();
- if (normalized === 'strict')
- return 'Strict';
- if (normalized === 'lax')
- return 'Lax';
- if (normalized === 'none')
- return 'None';
- return undefined;
-}
-function hostMatchesAny(hosts, cookieHost) {
- const cookieDomain = cookieHost.startsWith('.') ? cookieHost.slice(1) : cookieHost;
- return hosts.some((host) => hostMatchesCookieDomain(host, cookieDomain));
-}
-function dedupeCookies(cookies) {
- const merged = new Map();
- for (const cookie of cookies) {
- const key = `${cookie.name}|${cookie.domain ?? ''}|${cookie.path ?? ''}`;
- if (!merged.has(key))
- merged.set(key, cookie);
- }
- return Array.from(merged.values());
-}
-//# sourceMappingURL=firefoxSqlite.js.map
\ No newline at end of file
diff --git a/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/providers/firefoxSqlite.js.map b/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/providers/firefoxSqlite.js.map
deleted file mode 100644
index 35d1c54..0000000
--- a/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/providers/firefoxSqlite.js.map
+++ /dev/null
@@ -1 +0,0 @@
-{"version":3,"file":"firefoxSqlite.js","sourceRoot":"","sources":["../../src/providers/firefoxSqlite.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAE,UAAU,EAAE,WAAW,EAAE,WAAW,EAAE,MAAM,EAAE,MAAM,SAAS,CAAC;AACrF,OAAO,EAAE,OAAO,EAAE,MAAM,EAAE,MAAM,SAAS,CAAC;AAC1C,OAAO,IAAI,MAAM,WAAW,CAAC;AAG7B,OAAO,EAAE,uBAAuB,EAAE,MAAM,sBAAsB,CAAC;AAC/D,OAAO,EAAE,gBAAgB,EAAE,MAAM,uBAAuB,CAAC;AACzD,OAAO,EAAE,YAAY,EAAE,MAAM,oBAAoB,CAAC;AAElD,MAAM,CAAC,KAAK,UAAU,qBAAqB,CAC1C,OAAuD,EACvD,OAAiB,EACjB,cAAkC;IAElC,MAAM,QAAQ,GAAa,EAAE,CAAC;IAC9B,MAAM,MAAM,GAAG,uBAAuB,CAAC,OAAO,CAAC,OAAO,CAAC,CAAC;IACxD,IAAI,CAAC,MAAM,EAAE,CAAC;QACb,QAAQ,CAAC,IAAI,CAAC,qCAAqC,CAAC,CAAC;QACrD,OAAO,EAAE,OAAO,EAAE,EAAE,EAAE,QAAQ,EAAE,CAAC;IAClC,CAAC;IAED,MAAM,OAAO,GAAG,WAAW,CAAC,IAAI,CAAC,IAAI,CAAC,MAAM,EAAE,EAAE,uBAAuB,CAAC,CAAC,CAAC;IAC1E,MAAM,UAAU,GAAG,IAAI,CAAC,IAAI,CAAC,OAAO,EAAE,gBAAgB,CAAC,CAAC;IACxD,IAAI,CAAC;QACJ,YAAY,CAAC,MAAM,EAAE,UAAU,CAAC,CAAC;QACjC,WAAW,CAAC,MAAM,EAAE,GAAG,UAAU,MAAM,EAAE,MAAM,CAAC,CAAC;QACjD,WAAW,CAAC,MAAM,EAAE,GAAG,UAAU,MAAM,EAAE,MAAM,CAAC,CAAC;IAClD,CAAC;IAAC,OAAO,KAAK,EAAE,CAAC;QAChB,MAAM,CAAC,OAAO,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,KAAK,EAAE,IAAI,EAAE,CAAC,CAAC;QAClD,QAAQ,CAAC,IAAI,CACZ,qCAAqC,KAAK,YAAY,KAAK,CAAC,CAAC,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,EAAE,CAC7F,CAAC;QACF,OAAO,EAAE,OAAO,EAAE,EAAE,EAAE,QAAQ,EAAE,CAAC;IAClC,CAAC;IAED,MAAM,KAAK,GAAG,OAAO,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,IAAI,GAAG,CAAC,CAAC,CAAC,CAAC,QAAQ,CAAC,CAAC;IACtD,MAAM,GAAG,GAAG,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,GAAG,EAAE,GAAG,IAAI,CAAC,CAAC;IAC1C,MAAM,KAAK,GAAG,oBAAoB,CAAC,KAAK,CAAC,CAAC;IAC1C,MAAM,YAAY,GAAG,OAAO,CAAC,cAAc,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,gCAAgC,GAAG,GAAG,CAAC;IAC1F,MAAM,GAAG,GACR,yEAAyE;QACzE,2BAA2B,KAAK,IAAI,YAAY,wBAAwB,CAAC;IAE1E,IAAI,CAAC;QACJ,IAAI,YAAY,EAAE,EAAE,CAAC;YACpB,MAAM,SAAS,GAAG,MAAM,gCAAgC,CAAC,UAAU,EAAE,GAAG,CAAC,CAAC;YAC1E,IAAI,CAAC,SAAS,CAAC,EAAE,EAAE,CAAC;gBACnB,QAAQ,CAAC,IAAI,CAAC,8CAA8C,SAAS,CAAC,KAAK,EAAE,CAAC,CAAC;gBAC/E,OAAO,EAAE,OAAO,EAAE,EAAE,EAAE,QAAQ,EAAE,CAAC;YAClC,CAAC;YACD,MAAM,OAAO,GAAG,6BAA6B,CAAC,SAAS,CAAC,IAAI,EAAE,OAAO,EAAE,KAAK,EAAE,cAAc,CAAC,CAAC;YAC9F,OAAO,EAAE,OAAO,EAAE,aAAa,CAAC,OAAO,CAAC,EAAE,QAAQ,EAAE,CAAC;QACtD,CAAC;QAED,MAAM,UAAU,GAAG,MAAM,iCAAiC,CAAC,UAAU,EAAE,GAAG,CAAC,CAAC;QAC5E,IAAI,CAAC,UAAU,CAAC,EAAE,EAAE,CAAC;YACpB,QAAQ,CAAC,IAAI,CAAC,+CAA+C,UAAU,CAAC,KAAK,EAAE,CAAC,CAAC;YACjF,OAAO,EAAE,OAAO,EAAE,EAAE,EAAE,QAAQ,EAAE,CAAC;QAClC,CAAC;QACD,MAAM,OAAO,GAAG,6BAA6B,CAAC,UAAU,CAAC,IAAI,EAAE,OAAO,EAAE,KAAK,EAAE,cAAc,CAAC,CAAC;QAC/F,OAAO,EAAE,OAAO,EAAE,aAAa,CAAC,OAAO,CAAC,EAAE,QAAQ,EAAE,CAAC;IACtD,CAAC;YAAS,CAAC;QACV,MAAM,CAAC,OAAO,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,KAAK,EAAE,IAAI,EAAE,CAAC,CAAC;IACnD,CAAC;AACF,CAAC;AAaD,KAAK,UAAU,iCAAiC,CAC/C,MAAc,EACd,GAAW;IAEX,IAAI,CAAC;QACJ,MAAM,EAAE,YAAY,EAAE,GAAG,MAAM,gBAAgB,EAAE,CAAC;QAClD,MAAM,EAAE,GAAG,IAAI,YAAY,CAAC,MAAM,EAAE,EAAE,QAAQ,EAAE,IAAI,EAAE,CAAC,CAAC;QACxD,IAAI,CAAC;YACJ,MAAM,IAAI,GAAG,EAAE,CAAC,OAAO,CAAC,GAAG,CAAC,CAAC,GAAG,EAAkB,CAAC;YACnD,OAAO,EAAE,EAAE,EAAE,IAAI,EAAE,IAAI,EAAE,CAAC;QAC3B,CAAC;gBAAS,CAAC;YACV,EAAE,CAAC,KAAK,EAAE,CAAC;QACZ,CAAC;IACF,CAAC;IAAC,OAAO,KAAK,EAAE,CAAC;QAChB,OAAO,EAAE,EAAE,EAAE,KAAK,EAAE,KAAK,EAAE,KAAK,YAAY,KAAK,CAAC,CAAC,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,EAAE,CAAC;IACrF,CAAC;AACF,CAAC;AAED,KAAK,UAAU,gCAAgC,CAC9C,MAAc,EACd,GAAW;IAEX,IAAI,CAAC;QACJ,MAAM,EAAE,QAAQ,EAAE,GAAG,MAAM,MAAM,CAAC,YAAY,CAAC,CAAC;QAChD,MAAM,EAAE,GAAG,IAAI,QAAQ,CAAC,MAAM,EAAE,EAAE,QAAQ,EAAE,IAAI,EAAE,CAAC,CAAC;QACpD,IAAI,CAAC;YACJ,MAAM,IAAI,GAAG,EAAE,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,GAAG,EAAkB,CAAC;YACjD,OAAO,EAAE,EAAE,EAAE,IAAI,EAAE,IAAI,EAAE,CAAC;QAC3B,CAAC;gBAAS,CAAC;YACV,EAAE,CAAC,KAAK,EAAE,CAAC;QACZ,CAAC;IACF,CAAC;IAAC,OAAO,KAAK,EAAE,CAAC;QAChB,OAAO,EAAE,EAAE,EAAE,KAAK,EAAE,KAAK,EAAE,KAAK,YAAY,KAAK,CAAC,CAAC,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,EAAE,CAAC;IACrF,CAAC;AACF,CAAC;AAED,SAAS,6BAA6B,CACrC,IAAkB,EAClB,OAAuD,EACvD,KAAe,EACf,cAAkC;IAElC,MAAM,GAAG,GAAG,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,GAAG,EAAE,GAAG,IAAI,CAAC,CAAC;IAC1C,MAAM,OAAO,GAAa,EAAE,CAAC;IAE7B,KAAK,MAAM,GAAG,IAAI,IAAI,EAAE,CAAC;QACxB,MAAM,IAAI,GAAG,OAAO,GAAG,CAAC,IAAI,KAAK,QAAQ,CAAC,CAAC,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC,CAAC,IAAI,CAAC;QAC5D,MAAM,KAAK,GAAG,OAAO,GAAG,CAAC,KAAK,KAAK,QAAQ,CAAC,CAAC,CAAC,GAAG,CAAC,KAAK,CAAC,CAAC,CAAC,IAAI,CAAC;QAC/D,MAAM,IAAI,GAAG,OAAO,GAAG,CAAC,IAAI,KAAK,QAAQ,CAAC,CAAC,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC,CAAC,IAAI,CAAC;QAC5D,MAAM,UAAU,GAAG,OAAO,GAAG,CAAC,IAAI,KAAK,QAAQ,CAAC,CAAC,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,CAAC;QAEhE,IAAI,CAAC,IAAI,IAAI,KAAK,KAAK,IAAI,IAAI,CAAC,IAAI;YAAE,SAAS;QAC/C,IAAI,cAAc,IAAI,cAAc,CAAC,IAAI,GAAG,CAAC,IAAI,CAAC,cAAc,CAAC,GAAG,CAAC,IAAI,CAAC;YAAE,SAAS;QACrF,IAAI,CAAC,cAAc,CAAC,KAAK,EAAE,IAAI,CAAC;YAAE,SAAS;QAE3C,MAAM,UAAU,GACf,OAAO,GAAG,CAAC,MAAM,KAAK,QAAQ;YAC7B,CAAC,CAAC,MAAM,CAAC,GAAG,CAAC,MAAM,CAAC;YACpB,CAAC,CAAC,OAAO,GAAG,CAAC,MAAM,KAAK,QAAQ;gBAC/B,CAAC,CAAC,GAAG,CAAC,MAAM;gBACZ,CAAC,CAAC,SAAS,CAAC;QACf,MAAM,OAAO,GAAG,sBAAsB,CAAC,UAAU,CAAC,CAAC;QACnD,IAAI,CAAC,OAAO,CAAC,cAAc,IAAI,OAAO,IAAI,OAAO,GAAG,GAAG;YAAE,SAAS;QAElE,MAAM,QAAQ,GAAG,GAAG,CAAC,QAAQ,KAAK,CAAC,IAAI,GAAG,CAAC,QAAQ,KAAK,GAAG,IAAI,GAAG,CAAC,QAAQ,KAAK,IAAI,CAAC;QACrF,MAAM,UAAU,GAAG,GAAG,CAAC,UAAU,KAAK,CAAC,IAAI,GAAG,CAAC,UAAU,KAAK,GAAG,IAAI,GAAG,CAAC,UAAU,KAAK,IAAI,CAAC;QAE7F,MAAM,MAAM,GAAW;YACtB,IAAI;YACJ,KAAK;YACL,MAAM,EAAE,IAAI,CAAC,UAAU,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,IAAI;YACnD,IAAI,EAAE,UAAU,IAAI,GAAG;YACvB,MAAM,EAAE,QAAQ;YAChB,QAAQ,EAAE,UAAU;SACpB,CAAC;QAEF,IAAI,OAAO,KAAK,SAAS;YAAE,MAAM,CAAC,OAAO,GAAG,OAAO,CAAC;QACpD,MAAM,kBAAkB,GAAG,wBAAwB,CAClD,OAAO,GAAG,CAAC,QAAQ,KAAK,QAAQ;YAC/B,CAAC,CAAC,MAAM,CAAC,GAAG,CAAC,QAAQ,CAAC;YACtB,CAAC,CAAC,OAAO,GAAG,CAAC,QAAQ,KAAK,QAAQ;gBACjC,CAAC,CAAC,GAAG,CAAC,QAAQ;gBACd,CAAC,CAAC,SAAS,CACb,CAAC;QACF,IAAI,kBAAkB,KAAK,SAAS;YAAE,MAAM,CAAC,QAAQ,GAAG,kBAAkB,CAAC;QAE3E,MAAM,MAAM,GAAkC,EAAE,OAAO,EAAE,SAAS,EAAE,CAAC;QACrE,IAAI,OAAO,CAAC,OAAO;YAAE,MAAM,CAAC,OAAO,GAAG,OAAO,CAAC,OAAO,CAAC;QACtD,MAAM,CAAC,MAAM,GAAG,MAAM,CAAC;QAEvB,OAAO,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC;IACtB,CAAC;IAED,OAAO,OAAO,CAAC;AAChB,CAAC;AAED,SAAS,uBAAuB,CAAC,OAAgB;IAChD,MAAM,IAAI,GAAG,OAAO,EAAE,CAAC;IACvB,kGAAkG;IAClG,MAAM,OAAO,GAAG,OAAO,CAAC,GAAG,CAAC,SAAS,CAAC,CAAC;IACvC,uBAAuB;IACvB,MAAM,KAAK,GACV,OAAO,CAAC,QAAQ,KAAK,QAAQ;QAC5B,CAAC,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,IAAI,EAAE,SAAS,EAAE,qBAAqB,EAAE,SAAS,EAAE,UAAU,CAAC,CAAC;QAC5E,CAAC,CAAC,OAAO,CAAC,QAAQ,KAAK,OAAO;YAC7B,CAAC,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,IAAI,EAAE,UAAU,EAAE,SAAS,CAAC,CAAC;YAC1C,CAAC,CAAC,OAAO,CAAC,QAAQ,KAAK,OAAO;gBAC7B,CAAC,CAAC,OAAO;oBACR,CAAC,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,OAAO,EAAE,SAAS,EAAE,SAAS,EAAE,UAAU,CAAC,CAAC;oBACxD,CAAC,CAAC,EAAE;gBACL,CAAC,CAAC,EAAE,CAAC;IAET,IAAI,OAAO,IAAI,aAAa,CAAC,OAAO,CAAC,EAAE,CAAC;QACvC,MAAM,SAAS,GAAG,OAAO,CAAC,QAAQ,CAAC,gBAAgB,CAAC;YACnD,CAAC,CAAC,OAAO;YACT,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,OAAO,EAAE,gBAAgB,CAAC,CAAC;QACxC,OAAO,UAAU,CAAC,SAAS,CAAC,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC,IAAI,CAAC;IACjD,CAAC;IAED,KAAK,MAAM,IAAI,IAAI,KAAK,EAAE,CAAC;QAC1B,IAAI,CAAC,IAAI,IAAI,CAAC,UAAU,CAAC,IAAI,CAAC;YAAE,SAAS;QACzC,IAAI,OAAO,EAAE,CAAC;YACb,MAAM,SAAS,GAAG,IAAI,CAAC,IAAI,CAAC,IAAI,EAAE,OAAO,EAAE,gBAAgB,CAAC,CAAC;YAC7D,IAAI,UAAU,CAAC,SAAS,CAAC;gBAAE,OAAO,SAAS,CAAC;YAC5C,SAAS;QACV,CAAC;QAED,MAAM,OAAO,GAAG,WAAW,CAAC,IAAI,CAAC,CAAC;QAClC,MAAM,cAAc,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,QAAQ,CAAC,iBAAiB,CAAC,CAAC,CAAC;QAC1E,MAAM,MAAM,GAAG,cAAc,IAAI,OAAO,CAAC,CAAC,CAAC,CAAC;QAC5C,IAAI,CAAC,MAAM;YAAE,SAAS;QACtB,MAAM,SAAS,GAAG,IAAI,CAAC,IAAI,CAAC,IAAI,EAAE,MAAM,EAAE,gBAAgB,CAAC,CAAC;QAC5D,IAAI,UAAU,CAAC,SAAS,CAAC;YAAE,OAAO,SAAS,CAAC;IAC7C,CAAC;IAED,OAAO,IAAI,CAAC;AACb,CAAC;AAED,SAAS,WAAW,CAAC,GAAW;IAC/B,IAAI,CAAC;QACJ,OAAO,WAAW,CAAC,GAAG,EAAE,EAAE,aAAa,EAAE,IAAI,EAAE,CAAC;aAC9C,MAAM,CAAC,CAAC,CAAiC,EAAE,EAAE,CAAC,CAAC,CAAC,WAAW,EAAE,CAAC;aAC9D,GAAG,CAAC,CAAC,CAAmB,EAAE,EAAE,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC;IACxC,CAAC;IAAC,MAAM,CAAC;QACR,OAAO,EAAE,CAAC;IACX,CAAC;AACF,CAAC;AAED,SAAS,aAAa,CAAC,KAAa;IACnC,OAAO,KAAK,CAAC,QAAQ,CAAC,GAAG,CAAC,IAAI,KAAK,CAAC,QAAQ,CAAC,IAAI,CAAC,CAAC;AACpD,CAAC;AAED,SAAS,WAAW,CAAC,YAAoB,EAAE,MAAc,EAAE,MAAuB;IACjF,MAAM,OAAO,GAAG,GAAG,YAAY,GAAG,MAAM,EAAE,CAAC;IAC3C,IAAI,CAAC,UAAU,CAAC,OAAO,CAAC;QAAE,OAAO;IACjC,IAAI,CAAC;QACJ,YAAY,CAAC,OAAO,EAAE,MAAM,CAAC,CAAC;IAC/B,CAAC;IAAC,MAAM,CAAC;QACR,SAAS;IACV,CAAC;AACF,CAAC;AAED,SAAS,oBAAoB,CAAC,KAAe;IAC5C,MAAM,OAAO,GAAa,EAAE,CAAC;IAC7B,KAAK,MAAM,IAAI,IAAI,KAAK,EAAE,CAAC;QAC1B,MAAM,OAAO,GAAG,UAAU,CAAC,IAAI,CAAC,CAAC;QACjC,MAAM,UAAU,GAAG,UAAU,CAAC,IAAI,IAAI,EAAE,CAAC,CAAC;QAC1C,MAAM,WAAW,GAAG,UAAU,CAAC,KAAK,IAAI,EAAE,CAAC,CAAC;QAC5C,OAAO,CAAC,IAAI,CAAC,UAAU,OAAO,EAAE,CAAC,CAAC;QAClC,OAAO,CAAC,IAAI,CAAC,UAAU,UAAU,EAAE,CAAC,CAAC;QACrC,OAAO,CAAC,IAAI,CAAC,aAAa,WAAW,EAAE,CAAC,CAAC;IAC1C,CAAC;IACD,OAAO,OAAO,CAAC,MAAM,CAAC,CAAC,CAAC,OAAO,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC;AACtD,CAAC;AAED,SAAS,UAAU,CAAC,KAAa;IAChC,MAAM,OAAO,GAAG,KAAK,CAAC,UAAU,CAAC,GAAG,EAAE,IAAI,CAAC,CAAC;IAC5C,OAAO,IAAI,OAAO,GAAG,CAAC;AACvB,CAAC;AAED,SAAS,sBAAsB,CAAC,MAAe;IAC9C,IAAI,CAAC,MAAM;QAAE,OAAO,SAAS,CAAC;IAC9B,MAAM,KAAK,GAAG,MAAM,CAAC,QAAQ,CAAC,MAAM,EAAE,EAAE,CAAC,CAAC;IAC1C,IAAI,CAAC,MAAM,CAAC,QAAQ,CAAC,KAAK,CAAC,IAAI,KAAK,IAAI,CAAC;QAAE,OAAO,SAAS,CAAC;IAC5D,OAAO,KAAK,CAAC;AACd,CAAC;AAED,SAAS,wBAAwB,CAAC,GAAY;IAC7C,IAAI,CAAC,GAAG;QAAE,OAAO,SAAS,CAAC;IAC3B,MAAM,KAAK,GAAG,MAAM,CAAC,QAAQ,CAAC,GAAG,EAAE,EAAE,CAAC,CAAC;IACvC,IAAI,MAAM,CAAC,QAAQ,CAAC,KAAK,CAAC,EAAE,CAAC;QAC5B,IAAI,KAAK,KAAK,CAAC;YAAE,OAAO,QAAQ,CAAC;QACjC,IAAI,KAAK,KAAK,CAAC;YAAE,OAAO,KAAK,CAAC;QAC9B,IAAI,KAAK,KAAK,CAAC;YAAE,OAAO,MAAM,CAAC;IAChC,CAAC;IACD,MAAM,UAAU,GAAG,GAAG,CAAC,WAAW,EAAE,CAAC;IACrC,IAAI,UAAU,KAAK,QAAQ;QAAE,OAAO,QAAQ,CAAC;IAC7C,IAAI,UAAU,KAAK,KAAK;QAAE,OAAO,KAAK,CAAC;IACvC,IAAI,UAAU,KAAK,MAAM;QAAE,OAAO,MAAM,CAAC;IACzC,OAAO,SAAS,CAAC;AAClB,CAAC;AAED,SAAS,cAAc,CAAC,KAAe,EAAE,UAAkB;IAC1D,MAAM,YAAY,GAAG,UAAU,CAAC,UAAU,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,UAAU,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,UAAU,CAAC;IACnF,OAAO,KAAK,CAAC,IAAI,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,uBAAuB,CAAC,IAAI,EAAE,YAAY,CAAC,CAAC,CAAC;AAC1E,CAAC;AAED,SAAS,aAAa,CAAC,OAAiB;IACvC,MAAM,MAAM,GAAG,IAAI,GAAG,EAAkB,CAAC;IACzC,KAAK,MAAM,MAAM,IAAI,OAAO,EAAE,CAAC;QAC9B,MAAM,GAAG,GAAG,GAAG,MAAM,CAAC,IAAI,IAAI,MAAM,CAAC,MAAM,IAAI,EAAE,IAAI,MAAM,CAAC,IAAI,IAAI,EAAE,EAAE,CAAC;QACzE,IAAI,CAAC,MAAM,CAAC,GAAG,CAAC,GAAG,CAAC;YAAE,MAAM,CAAC,GAAG,CAAC,GAAG,EAAE,MAAM,CAAC,CAAC;IAC/C,CAAC;IACD,OAAO,KAAK,CAAC,IAAI,CAAC,MAAM,CAAC,MAAM,EAAE,CAAC,CAAC;AACpC,CAAC"}
\ No newline at end of file
diff --git a/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/providers/inline.d.ts b/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/providers/inline.d.ts
deleted file mode 100644
index 6799b13..0000000
--- a/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/providers/inline.d.ts
+++ /dev/null
@@ -1,8 +0,0 @@
-import type { GetCookiesResult } from '../types.js';
-type InlineSource = {
- source: string;
- payload: string;
-};
-export declare function getCookiesFromInline(inline: InlineSource, origins: string[], allowlistNames: Set | null): Promise;
-export {};
-//# sourceMappingURL=inline.d.ts.map
\ No newline at end of file
diff --git a/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/providers/inline.d.ts.map b/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/providers/inline.d.ts.map
deleted file mode 100644
index 4f00506..0000000
--- a/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/providers/inline.d.ts.map
+++ /dev/null
@@ -1 +0,0 @@
-{"version":3,"file":"inline.d.ts","sourceRoot":"","sources":["../../src/providers/inline.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAU,gBAAgB,EAAE,MAAM,aAAa,CAAC;AAK5D,KAAK,YAAY,GAAG;IAAE,MAAM,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,MAAM,CAAA;CAAE,CAAC;AAExD,wBAAsB,oBAAoB,CACzC,MAAM,EAAE,YAAY,EACpB,OAAO,EAAE,MAAM,EAAE,EACjB,cAAc,EAAE,GAAG,CAAC,MAAM,CAAC,GAAG,IAAI,GAChC,OAAO,CAAC,gBAAgB,CAAC,CAkC3B"}
\ No newline at end of file
diff --git a/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/providers/inline.js b/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/providers/inline.js
deleted file mode 100644
index 2070fc6..0000000
--- a/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/providers/inline.js
+++ /dev/null
@@ -1,71 +0,0 @@
-import { tryDecodeBase64Json } from '../util/base64.js';
-import { readTextFileIfExists } from '../util/fs.js';
-import { hostMatchesCookieDomain } from '../util/hostMatch.js';
-export async function getCookiesFromInline(inline, origins, allowlistNames) {
- const warnings = [];
- // Inline sources can be:
- // - the payload itself (JSON or base64)
- // - a file path that contains JSON/base64
- //
- // We do a small heuristic: treat `*.json`/`*.base64` and explicit "file" sources as file paths first.
- const rawPayload = inline.source.endsWith('file') ||
- inline.payload.endsWith('.json') ||
- inline.payload.endsWith('.base64')
- ? ((await readTextFileIfExists(inline.payload)) ?? inline.payload)
- : inline.payload;
- // If it looks like base64, decode it to JSON. Otherwise use it as-is.
- const decoded = tryDecodeBase64Json(rawPayload) ?? rawPayload;
- const parsed = tryParseCookiePayload(decoded);
- if (!parsed) {
- return { cookies: [], warnings };
- }
- const hostAllow = new Set(origins.map((o) => new URL(o).hostname));
- const cookies = [];
- for (const cookie of parsed.cookies) {
- if (!cookie?.name)
- continue;
- if (allowlistNames && allowlistNames.size > 0 && !allowlistNames.has(cookie.name))
- continue;
- const domain = cookie.domain ?? (cookie.url ? safeHostnameFromUrl(cookie.url) : undefined);
- if (domain && hostAllow.size > 0 && !matchesAnyHost(hostAllow, domain))
- continue;
- cookies.push(cookie);
- }
- return { cookies, warnings };
-}
-function tryParseCookiePayload(input) {
- const trimmed = input.trim();
- if (!trimmed)
- return null;
- try {
- const parsed = JSON.parse(trimmed);
- if (Array.isArray(parsed)) {
- return { cookies: parsed };
- }
- if (parsed &&
- typeof parsed === 'object' &&
- Array.isArray(parsed.cookies)) {
- return { cookies: parsed.cookies };
- }
- return null;
- }
- catch {
- return null;
- }
-}
-function matchesAnyHost(hosts, cookieDomain) {
- for (const host of hosts) {
- if (hostMatchesCookieDomain(host, cookieDomain))
- return true;
- }
- return false;
-}
-function safeHostnameFromUrl(url) {
- try {
- return new URL(url).hostname;
- }
- catch {
- return undefined;
- }
-}
-//# sourceMappingURL=inline.js.map
\ No newline at end of file
diff --git a/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/providers/inline.js.map b/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/providers/inline.js.map
deleted file mode 100644
index 80278a8..0000000
--- a/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/providers/inline.js.map
+++ /dev/null
@@ -1 +0,0 @@
-{"version":3,"file":"inline.js","sourceRoot":"","sources":["../../src/providers/inline.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,mBAAmB,EAAE,MAAM,mBAAmB,CAAC;AACxD,OAAO,EAAE,oBAAoB,EAAE,MAAM,eAAe,CAAC;AACrD,OAAO,EAAE,uBAAuB,EAAE,MAAM,sBAAsB,CAAC;AAI/D,MAAM,CAAC,KAAK,UAAU,oBAAoB,CACzC,MAAoB,EACpB,OAAiB,EACjB,cAAkC;IAElC,MAAM,QAAQ,GAAa,EAAE,CAAC;IAE9B,yBAAyB;IACzB,wCAAwC;IACxC,0CAA0C;IAC1C,EAAE;IACF,sGAAsG;IACtG,MAAM,UAAU,GACf,MAAM,CAAC,MAAM,CAAC,QAAQ,CAAC,MAAM,CAAC;QAC9B,MAAM,CAAC,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAC;QAChC,MAAM,CAAC,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAC;QACjC,CAAC,CAAC,CAAC,CAAC,MAAM,oBAAoB,CAAC,MAAM,CAAC,OAAO,CAAC,CAAC,IAAI,MAAM,CAAC,OAAO,CAAC;QAClE,CAAC,CAAC,MAAM,CAAC,OAAO,CAAC;IAEnB,sEAAsE;IACtE,MAAM,OAAO,GAAG,mBAAmB,CAAC,UAAU,CAAC,IAAI,UAAU,CAAC;IAC9D,MAAM,MAAM,GAAG,qBAAqB,CAAC,OAAO,CAAC,CAAC;IAC9C,IAAI,CAAC,MAAM,EAAE,CAAC;QACb,OAAO,EAAE,OAAO,EAAE,EAAE,EAAE,QAAQ,EAAE,CAAC;IAClC,CAAC;IAED,MAAM,SAAS,GAAG,IAAI,GAAG,CAAC,OAAO,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,IAAI,GAAG,CAAC,CAAC,CAAC,CAAC,QAAQ,CAAC,CAAC,CAAC;IAEnE,MAAM,OAAO,GAAa,EAAE,CAAC;IAC7B,KAAK,MAAM,MAAM,IAAI,MAAM,CAAC,OAAO,EAAE,CAAC;QACrC,IAAI,CAAC,MAAM,EAAE,IAAI;YAAE,SAAS;QAC5B,IAAI,cAAc,IAAI,cAAc,CAAC,IAAI,GAAG,CAAC,IAAI,CAAC,cAAc,CAAC,GAAG,CAAC,MAAM,CAAC,IAAI,CAAC;YAAE,SAAS;QAC5F,MAAM,MAAM,GAAG,MAAM,CAAC,MAAM,IAAI,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,CAAC,mBAAmB,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC;QAC3F,IAAI,MAAM,IAAI,SAAS,CAAC,IAAI,GAAG,CAAC,IAAI,CAAC,cAAc,CAAC,SAAS,EAAE,MAAM,CAAC;YAAE,SAAS;QACjF,OAAO,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC;IACtB,CAAC;IAED,OAAO,EAAE,OAAO,EAAE,QAAQ,EAAE,CAAC;AAC9B,CAAC;AAED,SAAS,qBAAqB,CAAC,KAAa;IAC3C,MAAM,OAAO,GAAG,KAAK,CAAC,IAAI,EAAE,CAAC;IAC7B,IAAI,CAAC,OAAO;QAAE,OAAO,IAAI,CAAC;IAC1B,IAAI,CAAC;QACJ,MAAM,MAAM,GAAG,IAAI,CAAC,KAAK,CAAC,OAAO,CAAY,CAAC;QAC9C,IAAI,KAAK,CAAC,OAAO,CAAC,MAAM,CAAC,EAAE,CAAC;YAC3B,OAAO,EAAE,OAAO,EAAE,MAAkB,EAAE,CAAC;QACxC,CAAC;QACD,IACC,MAAM;YACN,OAAO,MAAM,KAAK,QAAQ;YAC1B,KAAK,CAAC,OAAO,CAAE,MAAgC,CAAC,OAAO,CAAC,EACvD,CAAC;YACF,OAAO,EAAE,OAAO,EAAG,MAAgC,CAAC,OAAO,EAAE,CAAC;QAC/D,CAAC;QACD,OAAO,IAAI,CAAC;IACb,CAAC;IAAC,MAAM,CAAC;QACR,OAAO,IAAI,CAAC;IACb,CAAC;AACF,CAAC;AAED,SAAS,cAAc,CAAC,KAAkB,EAAE,YAAoB;IAC/D,KAAK,MAAM,IAAI,IAAI,KAAK,EAAE,CAAC;QAC1B,IAAI,uBAAuB,CAAC,IAAI,EAAE,YAAY,CAAC;YAAE,OAAO,IAAI,CAAC;IAC9D,CAAC;IACD,OAAO,KAAK,CAAC;AACd,CAAC;AAED,SAAS,mBAAmB,CAAC,GAAW;IACvC,IAAI,CAAC;QACJ,OAAO,IAAI,GAAG,CAAC,GAAG,CAAC,CAAC,QAAQ,CAAC;IAC9B,CAAC;IAAC,MAAM,CAAC;QACR,OAAO,SAAS,CAAC;IAClB,CAAC;AACF,CAAC"}
\ No newline at end of file
diff --git a/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/providers/safariBinaryCookies.d.ts b/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/providers/safariBinaryCookies.d.ts
deleted file mode 100644
index b117cde..0000000
--- a/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/providers/safariBinaryCookies.d.ts
+++ /dev/null
@@ -1,6 +0,0 @@
-import type { GetCookiesResult } from '../types.js';
-export declare function getCookiesFromSafari(options: {
- includeExpired?: boolean;
- file?: string;
-}, origins: string[], allowlistNames: Set | null): Promise;
-//# sourceMappingURL=safariBinaryCookies.d.ts.map
\ No newline at end of file
diff --git a/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/providers/safariBinaryCookies.d.ts.map b/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/providers/safariBinaryCookies.d.ts.map
deleted file mode 100644
index 14c76c1..0000000
--- a/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/providers/safariBinaryCookies.d.ts.map
+++ /dev/null
@@ -1 +0,0 @@
-{"version":3,"file":"safariBinaryCookies.d.ts","sourceRoot":"","sources":["../../src/providers/safariBinaryCookies.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAU,gBAAgB,EAAE,MAAM,aAAa,CAAC;AAK5D,wBAAsB,oBAAoB,CACzC,OAAO,EAAE;IAAE,cAAc,CAAC,EAAE,OAAO,CAAC;IAAC,IAAI,CAAC,EAAE,MAAM,CAAA;CAAE,EACpD,OAAO,EAAE,MAAM,EAAE,EACjB,cAAc,EAAE,GAAG,CAAC,MAAM,CAAC,GAAG,IAAI,GAChC,OAAO,CAAC,gBAAgB,CAAC,CAqC3B"}
\ No newline at end of file
diff --git a/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/providers/safariBinaryCookies.js b/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/providers/safariBinaryCookies.js
deleted file mode 100644
index 45602b6..0000000
--- a/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/providers/safariBinaryCookies.js
+++ /dev/null
@@ -1,173 +0,0 @@
-import { existsSync, readFileSync } from 'node:fs';
-import { homedir } from 'node:os';
-import path from 'node:path';
-import { hostMatchesCookieDomain } from '../util/hostMatch.js';
-const MAC_EPOCH_DELTA_SECONDS = 978_307_200;
-export async function getCookiesFromSafari(options, origins, allowlistNames) {
- const warnings = [];
- if (process.platform !== 'darwin') {
- return { cookies: [], warnings };
- }
- const cookieFile = options.file ?? resolveSafariBinaryCookiesPath();
- if (!cookieFile) {
- warnings.push('Safari Cookies.binarycookies not found.');
- return { cookies: [], warnings };
- }
- const hosts = origins.map((o) => new URL(o).hostname);
- const now = Math.floor(Date.now() / 1000);
- try {
- const data = readFileSync(cookieFile);
- // Safari's `Cookies.binarycookies` is a small binary container with multiple "pages".
- // We decode only the fields we need (name/value/domain/path/flags/expiry).
- const parsed = decodeBinaryCookies(data);
- const cookies = [];
- for (const cookie of parsed) {
- if (!cookie.name)
- continue;
- if (allowlistNames && allowlistNames.size > 0 && !allowlistNames.has(cookie.name))
- continue;
- const domain = cookie.domain;
- if (!domain)
- continue;
- if (!hosts.some((h) => hostMatchesCookieDomain(h, domain)))
- continue;
- if (!options.includeExpired && cookie.expires && cookie.expires < now)
- continue;
- cookies.push(cookie);
- }
- return { cookies: dedupeCookies(cookies), warnings };
- }
- catch (error) {
- warnings.push(`Failed to read Safari cookies: ${error instanceof Error ? error.message : String(error)}`);
- return { cookies: [], warnings };
- }
-}
-function resolveSafariBinaryCookiesPath() {
- const home = homedir();
- const candidates = [
- path.join(home, 'Library', 'Cookies', 'Cookies.binarycookies'),
- path.join(home, 'Library', 'Containers', 'com.apple.Safari', 'Data', 'Library', 'Cookies', 'Cookies.binarycookies'),
- ];
- for (const candidate of candidates) {
- if (existsSync(candidate))
- return candidate;
- }
- return null;
-}
-function decodeBinaryCookies(buffer) {
- if (buffer.length < 8)
- return [];
- if (buffer.subarray(0, 4).toString('utf8') !== 'cook')
- return [];
- const pageCount = buffer.readUInt32BE(4);
- let cursor = 8;
- const pageSizes = [];
- for (let i = 0; i < pageCount; i += 1) {
- pageSizes.push(buffer.readUInt32BE(cursor));
- cursor += 4;
- }
- const cookies = [];
- for (const pageSize of pageSizes) {
- const page = buffer.subarray(cursor, cursor + pageSize);
- cursor += pageSize;
- cookies.push(...decodePage(page));
- }
- return cookies;
-}
-function decodePage(page) {
- if (page.length < 16)
- return [];
- const header = page.readUInt32BE(0);
- if (header !== 0x00000100)
- return [];
- const cookieCount = page.readUInt32LE(4);
- const offsets = [];
- let cursor = 8;
- for (let i = 0; i < cookieCount; i += 1) {
- offsets.push(page.readUInt32LE(cursor));
- cursor += 4;
- }
- const cookies = [];
- for (const offset of offsets) {
- const cookie = decodeCookie(page.subarray(offset));
- if (cookie)
- cookies.push(cookie);
- }
- return cookies;
-}
-function decodeCookie(cookieBuffer) {
- if (cookieBuffer.length < 48)
- return null;
- const size = cookieBuffer.readUInt32LE(0);
- if (size < 48 || size > cookieBuffer.length)
- return null;
- const flagsValue = cookieBuffer.readUInt32LE(8);
- const isSecure = (flagsValue & 1) !== 0;
- const isHttpOnly = (flagsValue & 4) !== 0;
- const urlOffset = cookieBuffer.readUInt32LE(16);
- const nameOffset = cookieBuffer.readUInt32LE(20);
- const pathOffset = cookieBuffer.readUInt32LE(24);
- const valueOffset = cookieBuffer.readUInt32LE(28);
- // Safari stores dates as "Mac absolute time" (seconds since 2001-01-01).
- const expiration = readDoubleLE(cookieBuffer, 40);
- const rawUrl = readCString(cookieBuffer, urlOffset, size);
- const name = readCString(cookieBuffer, nameOffset, size);
- const cookiePath = readCString(cookieBuffer, pathOffset, size) ?? '/';
- const value = readCString(cookieBuffer, valueOffset, size) ?? '';
- if (!name)
- return null;
- const domain = rawUrl ? safeHostnameFromUrl(rawUrl) : undefined;
- const expires = expiration && expiration > 0 ? Math.round(expiration + MAC_EPOCH_DELTA_SECONDS) : undefined;
- const decoded = {
- name,
- value,
- path: cookiePath,
- secure: isSecure,
- httpOnly: isHttpOnly,
- source: { browser: 'safari' },
- };
- if (domain)
- decoded.domain = domain;
- if (expires !== undefined)
- decoded.expires = expires;
- return decoded;
-}
-function readDoubleLE(buffer, offset) {
- if (offset + 8 > buffer.length)
- return 0;
- const slice = buffer.subarray(offset, offset + 8);
- return slice.readDoubleLE(0);
-}
-function readCString(buffer, offset, end) {
- if (offset <= 0 || offset >= end)
- return null;
- let cursor = offset;
- while (cursor < end && buffer[cursor] !== 0)
- cursor += 1;
- if (cursor >= end)
- return null;
- return buffer.toString('utf8', offset, cursor);
-}
-function safeHostnameFromUrl(raw) {
- try {
- const url = raw.includes('://') ? raw : `https://${raw}`;
- const parsed = new URL(url);
- return parsed.hostname.startsWith('.') ? parsed.hostname.slice(1) : parsed.hostname;
- }
- catch {
- const cleaned = raw.trim();
- if (!cleaned)
- return undefined;
- return cleaned.startsWith('.') ? cleaned.slice(1) : cleaned;
- }
-}
-function dedupeCookies(cookies) {
- const merged = new Map();
- for (const cookie of cookies) {
- const key = `${cookie.name}|${cookie.domain ?? ''}|${cookie.path ?? ''}`;
- if (!merged.has(key))
- merged.set(key, cookie);
- }
- return Array.from(merged.values());
-}
-//# sourceMappingURL=safariBinaryCookies.js.map
\ No newline at end of file
diff --git a/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/providers/safariBinaryCookies.js.map b/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/providers/safariBinaryCookies.js.map
deleted file mode 100644
index b1458b1..0000000
--- a/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/providers/safariBinaryCookies.js.map
+++ /dev/null
@@ -1 +0,0 @@
-{"version":3,"file":"safariBinaryCookies.js","sourceRoot":"","sources":["../../src/providers/safariBinaryCookies.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,EAAE,YAAY,EAAE,MAAM,SAAS,CAAC;AACnD,OAAO,EAAE,OAAO,EAAE,MAAM,SAAS,CAAC;AAClC,OAAO,IAAI,MAAM,WAAW,CAAC;AAG7B,OAAO,EAAE,uBAAuB,EAAE,MAAM,sBAAsB,CAAC;AAE/D,MAAM,uBAAuB,GAAG,WAAW,CAAC;AAE5C,MAAM,CAAC,KAAK,UAAU,oBAAoB,CACzC,OAAoD,EACpD,OAAiB,EACjB,cAAkC;IAElC,MAAM,QAAQ,GAAa,EAAE,CAAC;IAC9B,IAAI,OAAO,CAAC,QAAQ,KAAK,QAAQ,EAAE,CAAC;QACnC,OAAO,EAAE,OAAO,EAAE,EAAE,EAAE,QAAQ,EAAE,CAAC;IAClC,CAAC;IAED,MAAM,UAAU,GAAG,OAAO,CAAC,IAAI,IAAI,8BAA8B,EAAE,CAAC;IACpE,IAAI,CAAC,UAAU,EAAE,CAAC;QACjB,QAAQ,CAAC,IAAI,CAAC,yCAAyC,CAAC,CAAC;QACzD,OAAO,EAAE,OAAO,EAAE,EAAE,EAAE,QAAQ,EAAE,CAAC;IAClC,CAAC;IAED,MAAM,KAAK,GAAG,OAAO,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,IAAI,GAAG,CAAC,CAAC,CAAC,CAAC,QAAQ,CAAC,CAAC;IACtD,MAAM,GAAG,GAAG,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,GAAG,EAAE,GAAG,IAAI,CAAC,CAAC;IAE1C,IAAI,CAAC;QACJ,MAAM,IAAI,GAAG,YAAY,CAAC,UAAU,CAAC,CAAC;QACtC,sFAAsF;QACtF,2EAA2E;QAC3E,MAAM,MAAM,GAAG,mBAAmB,CAAC,IAAI,CAAC,CAAC;QACzC,MAAM,OAAO,GAAa,EAAE,CAAC;QAC7B,KAAK,MAAM,MAAM,IAAI,MAAM,EAAE,CAAC;YAC7B,IAAI,CAAC,MAAM,CAAC,IAAI;gBAAE,SAAS;YAC3B,IAAI,cAAc,IAAI,cAAc,CAAC,IAAI,GAAG,CAAC,IAAI,CAAC,cAAc,CAAC,GAAG,CAAC,MAAM,CAAC,IAAI,CAAC;gBAAE,SAAS;YAC5F,MAAM,MAAM,GAAG,MAAM,CAAC,MAAM,CAAC;YAC7B,IAAI,CAAC,MAAM;gBAAE,SAAS;YACtB,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,uBAAuB,CAAC,CAAC,EAAE,MAAM,CAAC,CAAC;gBAAE,SAAS;YACrE,IAAI,CAAC,OAAO,CAAC,cAAc,IAAI,MAAM,CAAC,OAAO,IAAI,MAAM,CAAC,OAAO,GAAG,GAAG;gBAAE,SAAS;YAChF,OAAO,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC;QACtB,CAAC;QACD,OAAO,EAAE,OAAO,EAAE,aAAa,CAAC,OAAO,CAAC,EAAE,QAAQ,EAAE,CAAC;IACtD,CAAC;IAAC,OAAO,KAAK,EAAE,CAAC;QAChB,QAAQ,CAAC,IAAI,CACZ,kCAAkC,KAAK,YAAY,KAAK,CAAC,CAAC,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,EAAE,CAC1F,CAAC;QACF,OAAO,EAAE,OAAO,EAAE,EAAE,EAAE,QAAQ,EAAE,CAAC;IAClC,CAAC;AACF,CAAC;AAED,SAAS,8BAA8B;IACtC,MAAM,IAAI,GAAG,OAAO,EAAE,CAAC;IACvB,MAAM,UAAU,GAAG;QAClB,IAAI,CAAC,IAAI,CAAC,IAAI,EAAE,SAAS,EAAE,SAAS,EAAE,uBAAuB,CAAC;QAC9D,IAAI,CAAC,IAAI,CACR,IAAI,EACJ,SAAS,EACT,YAAY,EACZ,kBAAkB,EAClB,MAAM,EACN,SAAS,EACT,SAAS,EACT,uBAAuB,CACvB;KACD,CAAC;IACF,KAAK,MAAM,SAAS,IAAI,UAAU,EAAE,CAAC;QACpC,IAAI,UAAU,CAAC,SAAS,CAAC;YAAE,OAAO,SAAS,CAAC;IAC7C,CAAC;IACD,OAAO,IAAI,CAAC;AACb,CAAC;AAED,SAAS,mBAAmB,CAAC,MAAc;IAC1C,IAAI,MAAM,CAAC,MAAM,GAAG,CAAC;QAAE,OAAO,EAAE,CAAC;IACjC,IAAI,MAAM,CAAC,QAAQ,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,QAAQ,CAAC,MAAM,CAAC,KAAK,MAAM;QAAE,OAAO,EAAE,CAAC;IACjE,MAAM,SAAS,GAAG,MAAM,CAAC,YAAY,CAAC,CAAC,CAAC,CAAC;IACzC,IAAI,MAAM,GAAG,CAAC,CAAC;IACf,MAAM,SAAS,GAAa,EAAE,CAAC;IAC/B,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,SAAS,EAAE,CAAC,IAAI,CAAC,EAAE,CAAC;QACvC,SAAS,CAAC,IAAI,CAAC,MAAM,CAAC,YAAY,CAAC,MAAM,CAAC,CAAC,CAAC;QAC5C,MAAM,IAAI,CAAC,CAAC;IACb,CAAC;IAED,MAAM,OAAO,GAAa,EAAE,CAAC;IAC7B,KAAK,MAAM,QAAQ,IAAI,SAAS,EAAE,CAAC;QAClC,MAAM,IAAI,GAAG,MAAM,CAAC,QAAQ,CAAC,MAAM,EAAE,MAAM,GAAG,QAAQ,CAAC,CAAC;QACxD,MAAM,IAAI,QAAQ,CAAC;QACnB,OAAO,CAAC,IAAI,CAAC,GAAG,UAAU,CAAC,IAAI,CAAC,CAAC,CAAC;IACnC,CAAC;IACD,OAAO,OAAO,CAAC;AAChB,CAAC;AAED,SAAS,UAAU,CAAC,IAAY;IAC/B,IAAI,IAAI,CAAC,MAAM,GAAG,EAAE;QAAE,OAAO,EAAE,CAAC;IAChC,MAAM,MAAM,GAAG,IAAI,CAAC,YAAY,CAAC,CAAC,CAAC,CAAC;IACpC,IAAI,MAAM,KAAK,UAAU;QAAE,OAAO,EAAE,CAAC;IACrC,MAAM,WAAW,GAAG,IAAI,CAAC,YAAY,CAAC,CAAC,CAAC,CAAC;IACzC,MAAM,OAAO,GAAa,EAAE,CAAC;IAC7B,IAAI,MAAM,GAAG,CAAC,CAAC;IACf,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,WAAW,EAAE,CAAC,IAAI,CAAC,EAAE,CAAC;QACzC,OAAO,CAAC,IAAI,CAAC,IAAI,CAAC,YAAY,CAAC,MAAM,CAAC,CAAC,CAAC;QACxC,MAAM,IAAI,CAAC,CAAC;IACb,CAAC;IAED,MAAM,OAAO,GAAa,EAAE,CAAC;IAC7B,KAAK,MAAM,MAAM,IAAI,OAAO,EAAE,CAAC;QAC9B,MAAM,MAAM,GAAG,YAAY,CAAC,IAAI,CAAC,QAAQ,CAAC,MAAM,CAAC,CAAC,CAAC;QACnD,IAAI,MAAM;YAAE,OAAO,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC;IAClC,CAAC;IACD,OAAO,OAAO,CAAC;AAChB,CAAC;AAED,SAAS,YAAY,CAAC,YAAoB;IACzC,IAAI,YAAY,CAAC,MAAM,GAAG,EAAE;QAAE,OAAO,IAAI,CAAC;IAE1C,MAAM,IAAI,GAAG,YAAY,CAAC,YAAY,CAAC,CAAC,CAAC,CAAC;IAC1C,IAAI,IAAI,GAAG,EAAE,IAAI,IAAI,GAAG,YAAY,CAAC,MAAM;QAAE,OAAO,IAAI,CAAC;IAEzD,MAAM,UAAU,GAAG,YAAY,CAAC,YAAY,CAAC,CAAC,CAAC,CAAC;IAChD,MAAM,QAAQ,GAAG,CAAC,UAAU,GAAG,CAAC,CAAC,KAAK,CAAC,CAAC;IACxC,MAAM,UAAU,GAAG,CAAC,UAAU,GAAG,CAAC,CAAC,KAAK,CAAC,CAAC;IAE1C,MAAM,SAAS,GAAG,YAAY,CAAC,YAAY,CAAC,EAAE,CAAC,CAAC;IAChD,MAAM,UAAU,GAAG,YAAY,CAAC,YAAY,CAAC,EAAE,CAAC,CAAC;IACjD,MAAM,UAAU,GAAG,YAAY,CAAC,YAAY,CAAC,EAAE,CAAC,CAAC;IACjD,MAAM,WAAW,GAAG,YAAY,CAAC,YAAY,CAAC,EAAE,CAAC,CAAC;IAElD,yEAAyE;IACzE,MAAM,UAAU,GAAG,YAAY,CAAC,YAAY,EAAE,EAAE,CAAC,CAAC;IAElD,MAAM,MAAM,GAAG,WAAW,CAAC,YAAY,EAAE,SAAS,EAAE,IAAI,CAAC,CAAC;IAC1D,MAAM,IAAI,GAAG,WAAW,CAAC,YAAY,EAAE,UAAU,EAAE,IAAI,CAAC,CAAC;IACzD,MAAM,UAAU,GAAG,WAAW,CAAC,YAAY,EAAE,UAAU,EAAE,IAAI,CAAC,IAAI,GAAG,CAAC;IACtE,MAAM,KAAK,GAAG,WAAW,CAAC,YAAY,EAAE,WAAW,EAAE,IAAI,CAAC,IAAI,EAAE,CAAC;IAEjE,IAAI,CAAC,IAAI;QAAE,OAAO,IAAI,CAAC;IAEvB,MAAM,MAAM,GAAG,MAAM,CAAC,CAAC,CAAC,mBAAmB,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,SAAS,CAAC;IAEhE,MAAM,OAAO,GACZ,UAAU,IAAI,UAAU,GAAG,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,KAAK,CAAC,UAAU,GAAG,uBAAuB,CAAC,CAAC,CAAC,CAAC,SAAS,CAAC;IAE7F,MAAM,OAAO,GAAW;QACvB,IAAI;QACJ,KAAK;QACL,IAAI,EAAE,UAAU;QAChB,MAAM,EAAE,QAAQ;QAChB,QAAQ,EAAE,UAAU;QACpB,MAAM,EAAE,EAAE,OAAO,EAAE,QAAQ,EAAE;KAC7B,CAAC;IAEF,IAAI,MAAM;QAAE,OAAO,CAAC,MAAM,GAAG,MAAM,CAAC;IACpC,IAAI,OAAO,KAAK,SAAS;QAAE,OAAO,CAAC,OAAO,GAAG,OAAO,CAAC;IAErD,OAAO,OAAO,CAAC;AAChB,CAAC;AAED,SAAS,YAAY,CAAC,MAAc,EAAE,MAAc;IACnD,IAAI,MAAM,GAAG,CAAC,GAAG,MAAM,CAAC,MAAM;QAAE,OAAO,CAAC,CAAC;IACzC,MAAM,KAAK,GAAG,MAAM,CAAC,QAAQ,CAAC,MAAM,EAAE,MAAM,GAAG,CAAC,CAAC,CAAC;IAClD,OAAO,KAAK,CAAC,YAAY,CAAC,CAAC,CAAC,CAAC;AAC9B,CAAC;AAED,SAAS,WAAW,CAAC,MAAc,EAAE,MAAc,EAAE,GAAW;IAC/D,IAAI,MAAM,IAAI,CAAC,IAAI,MAAM,IAAI,GAAG;QAAE,OAAO,IAAI,CAAC;IAC9C,IAAI,MAAM,GAAG,MAAM,CAAC;IACpB,OAAO,MAAM,GAAG,GAAG,IAAI,MAAM,CAAC,MAAM,CAAC,KAAK,CAAC;QAAE,MAAM,IAAI,CAAC,CAAC;IACzD,IAAI,MAAM,IAAI,GAAG;QAAE,OAAO,IAAI,CAAC;IAC/B,OAAO,MAAM,CAAC,QAAQ,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,CAAC,CAAC;AAChD,CAAC;AAED,SAAS,mBAAmB,CAAC,GAAW;IACvC,IAAI,CAAC;QACJ,MAAM,GAAG,GAAG,GAAG,CAAC,QAAQ,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,WAAW,GAAG,EAAE,CAAC;QACzD,MAAM,MAAM,GAAG,IAAI,GAAG,CAAC,GAAG,CAAC,CAAC;QAC5B,OAAO,MAAM,CAAC,QAAQ,CAAC,UAAU,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,MAAM,CAAC,QAAQ,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,MAAM,CAAC,QAAQ,CAAC;IACrF,CAAC;IAAC,MAAM,CAAC;QACR,MAAM,OAAO,GAAG,GAAG,CAAC,IAAI,EAAE,CAAC;QAC3B,IAAI,CAAC,OAAO;YAAE,OAAO,SAAS,CAAC;QAC/B,OAAO,OAAO,CAAC,UAAU,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC;IAC7D,CAAC;AACF,CAAC;AAED,SAAS,aAAa,CAAC,OAAiB;IACvC,MAAM,MAAM,GAAG,IAAI,GAAG,EAAkB,CAAC;IACzC,KAAK,MAAM,MAAM,IAAI,OAAO,EAAE,CAAC;QAC9B,MAAM,GAAG,GAAG,GAAG,MAAM,CAAC,IAAI,IAAI,MAAM,CAAC,MAAM,IAAI,EAAE,IAAI,MAAM,CAAC,IAAI,IAAI,EAAE,EAAE,CAAC;QACzE,IAAI,CAAC,MAAM,CAAC,GAAG,CAAC,GAAG,CAAC;YAAE,MAAM,CAAC,GAAG,CAAC,GAAG,EAAE,MAAM,CAAC,CAAC;IAC/C,CAAC;IACD,OAAO,KAAK,CAAC,IAAI,CAAC,MAAM,CAAC,MAAM,EAAE,CAAC,CAAC;AACpC,CAAC"}
\ No newline at end of file
diff --git a/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/public.d.ts b/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/public.d.ts
deleted file mode 100644
index f3b0144..0000000
--- a/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/public.d.ts
+++ /dev/null
@@ -1,26 +0,0 @@
-import type { Cookie, CookieHeaderOptions, GetCookiesOptions, GetCookiesResult } from './types.js';
-/**
- * Read cookies for a URL from one or more browser backends (and/or inline payloads).
- *
- * Supported backends:
- * - `chrome`: macOS / Windows / Linux (Chromium-based; default discovery targets Google Chrome paths)
- * - `edge`: macOS / Windows / Linux (Chromium-based; default discovery targets Microsoft Edge paths)
- * - `firefox`: macOS / Windows / Linux
- * - `safari`: macOS only (`Cookies.binarycookies`)
- *
- * Runtime requirements:
- * - Node >= 22 (uses `node:sqlite`) or Bun (uses `bun:sqlite`)
- *
- * The function returns `{ cookies, warnings }`:
- * - `cookies`: best-effort results, filtered by `url`/`origins` and optional `names` allowlist
- * - `warnings`: non-fatal diagnostics (no raw cookie values)
- */
-export declare function getCookies(options: GetCookiesOptions): Promise;
-/**
- * Convert cookies to an HTTP `Cookie` header value.
- *
- * This is a helper for typical Node fetch clients / HTTP libraries.
- * It does not validate cookie RFC edge cases; it simply joins `name=value` pairs.
- */
-export declare function toCookieHeader(cookies: readonly Cookie[], options?: CookieHeaderOptions): string;
-//# sourceMappingURL=public.d.ts.map
\ No newline at end of file
diff --git a/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/public.d.ts.map b/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/public.d.ts.map
deleted file mode 100644
index 8151fd4..0000000
--- a/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/public.d.ts.map
+++ /dev/null
@@ -1 +0,0 @@
-{"version":3,"file":"public.d.ts","sourceRoot":"","sources":["../src/public.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAEX,MAAM,EACN,mBAAmB,EACnB,iBAAiB,EACjB,gBAAgB,EAChB,MAAM,YAAY,CAAC;AAKpB;;;;;;;;;;;;;;;GAeG;AACH,wBAAsB,UAAU,CAAC,OAAO,EAAE,iBAAiB,GAAG,OAAO,CAAC,gBAAgB,CAAC,CA2FtF;AAED;;;;;GAKG;AACH,wBAAgB,cAAc,CAC7B,OAAO,EAAE,SAAS,MAAM,EAAE,EAC1B,OAAO,GAAE,mBAAwB,GAC/B,MAAM,CAyBR"}
\ No newline at end of file
diff --git a/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/public.js b/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/public.js
deleted file mode 100644
index 716b4ce..0000000
--- a/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/public.js
+++ /dev/null
@@ -1,195 +0,0 @@
-import { getCookiesFromChrome } from './providers/chrome.js';
-import { getCookiesFromEdge } from './providers/edge.js';
-import { getCookiesFromFirefox } from './providers/firefoxSqlite.js';
-import { getCookiesFromInline } from './providers/inline.js';
-import { getCookiesFromSafari } from './providers/safariBinaryCookies.js';
-import { normalizeOrigins } from './util/origins.js';
-const DEFAULT_BROWSERS = ['chrome', 'safari', 'firefox'];
-/**
- * Read cookies for a URL from one or more browser backends (and/or inline payloads).
- *
- * Supported backends:
- * - `chrome`: macOS / Windows / Linux (Chromium-based; default discovery targets Google Chrome paths)
- * - `edge`: macOS / Windows / Linux (Chromium-based; default discovery targets Microsoft Edge paths)
- * - `firefox`: macOS / Windows / Linux
- * - `safari`: macOS only (`Cookies.binarycookies`)
- *
- * Runtime requirements:
- * - Node >= 22 (uses `node:sqlite`) or Bun (uses `bun:sqlite`)
- *
- * The function returns `{ cookies, warnings }`:
- * - `cookies`: best-effort results, filtered by `url`/`origins` and optional `names` allowlist
- * - `warnings`: non-fatal diagnostics (no raw cookie values)
- */
-export async function getCookies(options) {
- const warnings = [];
- const url = options.url;
- const origins = normalizeOrigins(url, options.origins);
- const names = normalizeNames(options.names);
- let browsers;
- if (Array.isArray(options.browsers) && options.browsers.length > 0) {
- browsers = options.browsers;
- }
- else {
- browsers = parseBrowsersEnv() ?? DEFAULT_BROWSERS;
- }
- const mode = options.mode ?? parseModeEnv() ?? 'merge';
- const inlineSources = await resolveInlineSources(options);
- // Inline sources are the most reliable path (they bypass DB locks + keychain prompts).
- // We short-circuit on the first inline source that yields any cookies.
- for (const source of inlineSources) {
- const inlineResult = await getCookiesFromInline(source, origins, names);
- warnings.push(...inlineResult.warnings);
- if (inlineResult.cookies.length) {
- return { cookies: inlineResult.cookies, warnings };
- }
- }
- const merged = new Map();
- const tryAdd = (cookie) => {
- // Dedupe by name+domain+path (a common stable identity for HTTP cookies).
- const domain = cookie.domain ?? '';
- const pathValue = cookie.path ?? '';
- const key = `${cookie.name}|${domain}|${pathValue}`;
- if (!merged.has(key)) {
- merged.set(key, cookie);
- }
- };
- for (const browser of browsers) {
- let result;
- if (browser === 'chrome') {
- const chromeOptions = {};
- const chromeProfile = options.chromeProfile ?? options.profile ?? readEnv('SWEET_COOKIE_CHROME_PROFILE');
- if (chromeProfile)
- chromeOptions.profile = chromeProfile;
- if (options.timeoutMs !== undefined)
- chromeOptions.timeoutMs = options.timeoutMs;
- if (options.includeExpired !== undefined)
- chromeOptions.includeExpired = options.includeExpired;
- if (options.debug !== undefined)
- chromeOptions.debug = options.debug;
- result = await getCookiesFromChrome(chromeOptions, origins, names);
- }
- else if (browser === 'edge') {
- const edgeOptions = {};
- const edgeProfile = options.edgeProfile ??
- options.profile ??
- readEnv('SWEET_COOKIE_EDGE_PROFILE') ??
- readEnv('SWEET_COOKIE_CHROME_PROFILE');
- if (edgeProfile)
- edgeOptions.profile = edgeProfile;
- if (options.timeoutMs !== undefined)
- edgeOptions.timeoutMs = options.timeoutMs;
- if (options.includeExpired !== undefined)
- edgeOptions.includeExpired = options.includeExpired;
- if (options.debug !== undefined)
- edgeOptions.debug = options.debug;
- result = await getCookiesFromEdge(edgeOptions, origins, names);
- }
- else if (browser === 'firefox') {
- const firefoxOptions = {};
- const firefoxProfile = options.firefoxProfile ?? readEnv('SWEET_COOKIE_FIREFOX_PROFILE');
- if (firefoxProfile)
- firefoxOptions.profile = firefoxProfile;
- if (options.includeExpired !== undefined)
- firefoxOptions.includeExpired = options.includeExpired;
- result = await getCookiesFromFirefox(firefoxOptions, origins, names);
- }
- else {
- const safariOptions = {};
- if (options.includeExpired !== undefined)
- safariOptions.includeExpired = options.includeExpired;
- if (options.safariCookiesFile)
- safariOptions.file = options.safariCookiesFile;
- result = await getCookiesFromSafari(safariOptions, origins, names);
- }
- warnings.push(...result.warnings);
- if (mode === 'first' && result.cookies.length) {
- // "first" returns the first backend that produced anything (plus accumulated warnings).
- return { cookies: result.cookies, warnings };
- }
- for (const cookie of result.cookies) {
- tryAdd(cookie);
- }
- }
- return { cookies: Array.from(merged.values()), warnings };
-}
-/**
- * Convert cookies to an HTTP `Cookie` header value.
- *
- * This is a helper for typical Node fetch clients / HTTP libraries.
- * It does not validate cookie RFC edge cases; it simply joins `name=value` pairs.
- */
-export function toCookieHeader(cookies, options = {}) {
- const sort = options.sort ?? 'name';
- const dedupeByName = options.dedupeByName ?? false;
- const items = cookies
- .filter((cookie) => cookie?.name && typeof cookie.value === 'string')
- .map((cookie) => ({ name: cookie.name, value: cookie.value }));
- const ordered = sort === 'name' ? items.slice().sort((a, b) => a.name.localeCompare(b.name)) : items;
- if (!dedupeByName) {
- return ordered.map((cookie) => `${cookie.name}=${cookie.value}`).join('; ');
- }
- const seen = new Set();
- const deduped = [];
- for (const cookie of ordered) {
- const key = cookie.name;
- if (seen.has(key))
- continue;
- seen.add(key);
- deduped.push(cookie);
- }
- return deduped.map((cookie) => `${cookie.name}=${cookie.value}`).join('; ');
-}
-function normalizeNames(names) {
- if (!names?.length)
- return null;
- const cleaned = names.map((n) => n.trim()).filter(Boolean);
- if (!cleaned.length)
- return null;
- return new Set(cleaned);
-}
-async function resolveInlineSources(options) {
- const sources = [];
- if (options.inlineCookiesJson) {
- sources.push({ source: 'inline-json', payload: options.inlineCookiesJson });
- }
- if (options.inlineCookiesBase64) {
- sources.push({ source: 'inline-base64', payload: options.inlineCookiesBase64 });
- }
- if (options.inlineCookiesFile) {
- sources.push({ source: 'inline-file', payload: options.inlineCookiesFile });
- }
- return sources;
-}
-function parseBrowsersEnv() {
- const raw = readEnv('SWEET_COOKIE_BROWSERS') ?? readEnv('SWEET_COOKIE_SOURCES');
- if (!raw)
- return undefined;
- const tokens = raw
- .split(/[,\s]+/)
- .map((t) => t.trim().toLowerCase())
- .filter(Boolean);
- const out = [];
- for (const token of tokens) {
- if (token === 'chrome' || token === 'edge' || token === 'firefox' || token === 'safari') {
- if (!out.includes(token))
- out.push(token);
- }
- }
- return out.length ? out : undefined;
-}
-function parseModeEnv() {
- const raw = readEnv('SWEET_COOKIE_MODE');
- if (!raw)
- return undefined;
- const normalized = raw.trim().toLowerCase();
- if (normalized === 'merge' || normalized === 'first')
- return normalized;
- return undefined;
-}
-function readEnv(key) {
- const value = process.env[key];
- const trimmed = typeof value === 'string' ? value.trim() : '';
- return trimmed.length ? trimmed : undefined;
-}
-//# sourceMappingURL=public.js.map
\ No newline at end of file
diff --git a/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/public.js.map b/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/public.js.map
deleted file mode 100644
index 7a360e3..0000000
--- a/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/public.js.map
+++ /dev/null
@@ -1 +0,0 @@
-{"version":3,"file":"public.js","sourceRoot":"","sources":["../src/public.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,oBAAoB,EAAE,MAAM,uBAAuB,CAAC;AAC7D,OAAO,EAAE,kBAAkB,EAAE,MAAM,qBAAqB,CAAC;AACzD,OAAO,EAAE,qBAAqB,EAAE,MAAM,8BAA8B,CAAC;AACrE,OAAO,EAAE,oBAAoB,EAAE,MAAM,uBAAuB,CAAC;AAC7D,OAAO,EAAE,oBAAoB,EAAE,MAAM,oCAAoC,CAAC;AAQ1E,OAAO,EAAE,gBAAgB,EAAE,MAAM,mBAAmB,CAAC;AAErD,MAAM,gBAAgB,GAAkB,CAAC,QAAQ,EAAE,QAAQ,EAAE,SAAS,CAAC,CAAC;AAExE;;;;;;;;;;;;;;;GAeG;AACH,MAAM,CAAC,KAAK,UAAU,UAAU,CAAC,OAA0B;IAC1D,MAAM,QAAQ,GAAa,EAAE,CAAC;IAC9B,MAAM,GAAG,GAAG,OAAO,CAAC,GAAG,CAAC;IACxB,MAAM,OAAO,GAAG,gBAAgB,CAAC,GAAG,EAAE,OAAO,CAAC,OAAO,CAAC,CAAC;IACvD,MAAM,KAAK,GAAG,cAAc,CAAC,OAAO,CAAC,KAAK,CAAC,CAAC;IAC5C,IAAI,QAAuB,CAAC;IAC5B,IAAI,KAAK,CAAC,OAAO,CAAC,OAAO,CAAC,QAAQ,CAAC,IAAI,OAAO,CAAC,QAAQ,CAAC,MAAM,GAAG,CAAC,EAAE,CAAC;QACpE,QAAQ,GAAG,OAAO,CAAC,QAAQ,CAAC;IAC7B,CAAC;SAAM,CAAC;QACP,QAAQ,GAAG,gBAAgB,EAAE,IAAI,gBAAgB,CAAC;IACnD,CAAC;IACD,MAAM,IAAI,GAAG,OAAO,CAAC,IAAI,IAAI,YAAY,EAAE,IAAI,OAAO,CAAC;IAEvD,MAAM,aAAa,GAAG,MAAM,oBAAoB,CAAC,OAAO,CAAC,CAAC;IAC1D,uFAAuF;IACvF,uEAAuE;IACvE,KAAK,MAAM,MAAM,IAAI,aAAa,EAAE,CAAC;QACpC,MAAM,YAAY,GAAG,MAAM,oBAAoB,CAAC,MAAM,EAAE,OAAO,EAAE,KAAK,CAAC,CAAC;QACxE,QAAQ,CAAC,IAAI,CAAC,GAAG,YAAY,CAAC,QAAQ,CAAC,CAAC;QACxC,IAAI,YAAY,CAAC,OAAO,CAAC,MAAM,EAAE,CAAC;YACjC,OAAO,EAAE,OAAO,EAAE,YAAY,CAAC,OAAO,EAAE,QAAQ,EAAE,CAAC;QACpD,CAAC;IACF,CAAC;IAED,MAAM,MAAM,GAAG,IAAI,GAAG,EAAkB,CAAC;IACzC,MAAM,MAAM,GAAG,CAAC,MAAc,EAAE,EAAE;QACjC,0EAA0E;QAC1E,MAAM,MAAM,GAAG,MAAM,CAAC,MAAM,IAAI,EAAE,CAAC;QACnC,MAAM,SAAS,GAAG,MAAM,CAAC,IAAI,IAAI,EAAE,CAAC;QACpC,MAAM,GAAG,GAAG,GAAG,MAAM,CAAC,IAAI,IAAI,MAAM,IAAI,SAAS,EAAE,CAAC;QACpD,IAAI,CAAC,MAAM,CAAC,GAAG,CAAC,GAAG,CAAC,EAAE,CAAC;YACtB,MAAM,CAAC,GAAG,CAAC,GAAG,EAAE,MAAM,CAAC,CAAC;QACzB,CAAC;IACF,CAAC,CAAC;IAEF,KAAK,MAAM,OAAO,IAAI,QAAQ,EAAE,CAAC;QAChC,IAAI,MAAwB,CAAC;QAC7B,IAAI,OAAO,KAAK,QAAQ,EAAE,CAAC;YAC1B,MAAM,aAAa,GAA+C,EAAE,CAAC;YACrE,MAAM,aAAa,GAClB,OAAO,CAAC,aAAa,IAAI,OAAO,CAAC,OAAO,IAAI,OAAO,CAAC,6BAA6B,CAAC,CAAC;YACpF,IAAI,aAAa;gBAAE,aAAa,CAAC,OAAO,GAAG,aAAa,CAAC;YACzD,IAAI,OAAO,CAAC,SAAS,KAAK,SAAS;gBAAE,aAAa,CAAC,SAAS,GAAG,OAAO,CAAC,SAAS,CAAC;YACjF,IAAI,OAAO,CAAC,cAAc,KAAK,SAAS;gBACvC,aAAa,CAAC,cAAc,GAAG,OAAO,CAAC,cAAc,CAAC;YACvD,IAAI,OAAO,CAAC,KAAK,KAAK,SAAS;gBAAE,aAAa,CAAC,KAAK,GAAG,OAAO,CAAC,KAAK,CAAC;YAErE,MAAM,GAAG,MAAM,oBAAoB,CAAC,aAAa,EAAE,OAAO,EAAE,KAAK,CAAC,CAAC;QACpE,CAAC;aAAM,IAAI,OAAO,KAAK,MAAM,EAAE,CAAC;YAC/B,MAAM,WAAW,GAA6C,EAAE,CAAC;YACjE,MAAM,WAAW,GAChB,OAAO,CAAC,WAAW;gBACnB,OAAO,CAAC,OAAO;gBACf,OAAO,CAAC,2BAA2B,CAAC;gBACpC,OAAO,CAAC,6BAA6B,CAAC,CAAC;YACxC,IAAI,WAAW;gBAAE,WAAW,CAAC,OAAO,GAAG,WAAW,CAAC;YACnD,IAAI,OAAO,CAAC,SAAS,KAAK,SAAS;gBAAE,WAAW,CAAC,SAAS,GAAG,OAAO,CAAC,SAAS,CAAC;YAC/E,IAAI,OAAO,CAAC,cAAc,KAAK,SAAS;gBAAE,WAAW,CAAC,cAAc,GAAG,OAAO,CAAC,cAAc,CAAC;YAC9F,IAAI,OAAO,CAAC,KAAK,KAAK,SAAS;gBAAE,WAAW,CAAC,KAAK,GAAG,OAAO,CAAC,KAAK,CAAC;YAEnE,MAAM,GAAG,MAAM,kBAAkB,CAAC,WAAW,EAAE,OAAO,EAAE,KAAK,CAAC,CAAC;QAChE,CAAC;aAAM,IAAI,OAAO,KAAK,SAAS,EAAE,CAAC;YAClC,MAAM,cAAc,GAAgD,EAAE,CAAC;YACvE,MAAM,cAAc,GAAG,OAAO,CAAC,cAAc,IAAI,OAAO,CAAC,8BAA8B,CAAC,CAAC;YACzF,IAAI,cAAc;gBAAE,cAAc,CAAC,OAAO,GAAG,cAAc,CAAC;YAC5D,IAAI,OAAO,CAAC,cAAc,KAAK,SAAS;gBACvC,cAAc,CAAC,cAAc,GAAG,OAAO,CAAC,cAAc,CAAC;YAExD,MAAM,GAAG,MAAM,qBAAqB,CAAC,cAAc,EAAE,OAAO,EAAE,KAAK,CAAC,CAAC;QACtE,CAAC;aAAM,CAAC;YACP,MAAM,aAAa,GAA+C,EAAE,CAAC;YACrE,IAAI,OAAO,CAAC,cAAc,KAAK,SAAS;gBACvC,aAAa,CAAC,cAAc,GAAG,OAAO,CAAC,cAAc,CAAC;YACvD,IAAI,OAAO,CAAC,iBAAiB;gBAAE,aAAa,CAAC,IAAI,GAAG,OAAO,CAAC,iBAAiB,CAAC;YAE9E,MAAM,GAAG,MAAM,oBAAoB,CAAC,aAAa,EAAE,OAAO,EAAE,KAAK,CAAC,CAAC;QACpE,CAAC;QAED,QAAQ,CAAC,IAAI,CAAC,GAAG,MAAM,CAAC,QAAQ,CAAC,CAAC;QAElC,IAAI,IAAI,KAAK,OAAO,IAAI,MAAM,CAAC,OAAO,CAAC,MAAM,EAAE,CAAC;YAC/C,wFAAwF;YACxF,OAAO,EAAE,OAAO,EAAE,MAAM,CAAC,OAAO,EAAE,QAAQ,EAAE,CAAC;QAC9C,CAAC;QAED,KAAK,MAAM,MAAM,IAAI,MAAM,CAAC,OAAO,EAAE,CAAC;YACrC,MAAM,CAAC,MAAM,CAAC,CAAC;QAChB,CAAC;IACF,CAAC;IAED,OAAO,EAAE,OAAO,EAAE,KAAK,CAAC,IAAI,CAAC,MAAM,CAAC,MAAM,EAAE,CAAC,EAAE,QAAQ,EAAE,CAAC;AAC3D,CAAC;AAED;;;;;GAKG;AACH,MAAM,UAAU,cAAc,CAC7B,OAA0B,EAC1B,UAA+B,EAAE;IAEjC,MAAM,IAAI,GAAG,OAAO,CAAC,IAAI,IAAI,MAAM,CAAC;IACpC,MAAM,YAAY,GAAG,OAAO,CAAC,YAAY,IAAI,KAAK,CAAC;IAEnD,MAAM,KAAK,GAAG,OAAO;SACnB,MAAM,CAAC,CAAC,MAAM,EAAE,EAAE,CAAC,MAAM,EAAE,IAAI,IAAI,OAAO,MAAM,CAAC,KAAK,KAAK,QAAQ,CAAC;SACpE,GAAG,CAAC,CAAC,MAAM,EAAE,EAAE,CAAC,CAAC,EAAE,IAAI,EAAE,MAAM,CAAC,IAAI,EAAE,KAAK,EAAE,MAAM,CAAC,KAAK,EAAE,CAAC,CAAC,CAAC;IAEhE,MAAM,OAAO,GACZ,IAAI,KAAK,MAAM,CAAC,CAAC,CAAC,KAAK,CAAC,KAAK,EAAE,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,IAAI,CAAC,aAAa,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC;IAEtF,IAAI,CAAC,YAAY,EAAE,CAAC;QACnB,OAAO,OAAO,CAAC,GAAG,CAAC,CAAC,MAAM,EAAE,EAAE,CAAC,GAAG,MAAM,CAAC,IAAI,IAAI,MAAM,CAAC,KAAK,EAAE,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;IAC7E,CAAC;IAED,MAAM,IAAI,GAAG,IAAI,GAAG,EAAU,CAAC;IAC/B,MAAM,OAAO,GAAsC,EAAE,CAAC;IACtD,KAAK,MAAM,MAAM,IAAI,OAAO,EAAE,CAAC;QAC9B,MAAM,GAAG,GAAG,MAAM,CAAC,IAAI,CAAC;QACxB,IAAI,IAAI,CAAC,GAAG,CAAC,GAAG,CAAC;YAAE,SAAS;QAC5B,IAAI,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC;QACd,OAAO,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC;IACtB,CAAC;IAED,OAAO,OAAO,CAAC,GAAG,CAAC,CAAC,MAAM,EAAE,EAAE,CAAC,GAAG,MAAM,CAAC,IAAI,IAAI,MAAM,CAAC,KAAK,EAAE,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;AAC7E,CAAC;AAED,SAAS,cAAc,CAAC,KAAgB;IACvC,IAAI,CAAC,KAAK,EAAE,MAAM;QAAE,OAAO,IAAI,CAAC;IAChC,MAAM,OAAO,GAAG,KAAK,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,IAAI,EAAE,CAAC,CAAC,MAAM,CAAC,OAAO,CAAC,CAAC;IAC3D,IAAI,CAAC,OAAO,CAAC,MAAM;QAAE,OAAO,IAAI,CAAC;IACjC,OAAO,IAAI,GAAG,CAAC,OAAO,CAAC,CAAC;AACzB,CAAC;AAED,KAAK,UAAU,oBAAoB,CAClC,OAA0B;IAE1B,MAAM,OAAO,GAA+C,EAAE,CAAC;IAC/D,IAAI,OAAO,CAAC,iBAAiB,EAAE,CAAC;QAC/B,OAAO,CAAC,IAAI,CAAC,EAAE,MAAM,EAAE,aAAa,EAAE,OAAO,EAAE,OAAO,CAAC,iBAAiB,EAAE,CAAC,CAAC;IAC7E,CAAC;IACD,IAAI,OAAO,CAAC,mBAAmB,EAAE,CAAC;QACjC,OAAO,CAAC,IAAI,CAAC,EAAE,MAAM,EAAE,eAAe,EAAE,OAAO,EAAE,OAAO,CAAC,mBAAmB,EAAE,CAAC,CAAC;IACjF,CAAC;IACD,IAAI,OAAO,CAAC,iBAAiB,EAAE,CAAC;QAC/B,OAAO,CAAC,IAAI,CAAC,EAAE,MAAM,EAAE,aAAa,EAAE,OAAO,EAAE,OAAO,CAAC,iBAAiB,EAAE,CAAC,CAAC;IAC7E,CAAC;IAED,OAAO,OAAO,CAAC;AAChB,CAAC;AAED,SAAS,gBAAgB;IACxB,MAAM,GAAG,GAAG,OAAO,CAAC,uBAAuB,CAAC,IAAI,OAAO,CAAC,sBAAsB,CAAC,CAAC;IAChF,IAAI,CAAC,GAAG;QAAE,OAAO,SAAS,CAAC;IAC3B,MAAM,MAAM,GAAG,GAAG;SAChB,KAAK,CAAC,QAAQ,CAAC;SACf,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,IAAI,EAAE,CAAC,WAAW,EAAE,CAAC;SAClC,MAAM,CAAC,OAAO,CAAC,CAAC;IAClB,MAAM,GAAG,GAAoD,EAAE,CAAC;IAChE,KAAK,MAAM,KAAK,IAAI,MAAM,EAAE,CAAC;QAC5B,IAAI,KAAK,KAAK,QAAQ,IAAI,KAAK,KAAK,MAAM,IAAI,KAAK,KAAK,SAAS,IAAI,KAAK,KAAK,QAAQ,EAAE,CAAC;YACzF,IAAI,CAAC,GAAG,CAAC,QAAQ,CAAC,KAAK,CAAC;gBAAE,GAAG,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC;QAC3C,CAAC;IACF,CAAC;IACD,OAAO,GAAG,CAAC,MAAM,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,SAAS,CAAC;AACrC,CAAC;AAED,SAAS,YAAY;IACpB,MAAM,GAAG,GAAG,OAAO,CAAC,mBAAmB,CAAC,CAAC;IACzC,IAAI,CAAC,GAAG;QAAE,OAAO,SAAS,CAAC;IAC3B,MAAM,UAAU,GAAG,GAAG,CAAC,IAAI,EAAE,CAAC,WAAW,EAAE,CAAC;IAC5C,IAAI,UAAU,KAAK,OAAO,IAAI,UAAU,KAAK,OAAO;QAAE,OAAO,UAAU,CAAC;IACxE,OAAO,SAAS,CAAC;AAClB,CAAC;AAED,SAAS,OAAO,CAAC,GAAW;IAC3B,MAAM,KAAK,GAAG,OAAO,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC;IAC/B,MAAM,OAAO,GAAG,OAAO,KAAK,KAAK,QAAQ,CAAC,CAAC,CAAC,KAAK,CAAC,IAAI,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;IAC9D,OAAO,OAAO,CAAC,MAAM,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,SAAS,CAAC;AAC7C,CAAC"}
\ No newline at end of file
diff --git a/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/types.d.ts b/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/types.d.ts
deleted file mode 100644
index 03c76a0..0000000
--- a/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/types.d.ts
+++ /dev/null
@@ -1,121 +0,0 @@
-/**
- * Supported browser backends.
- *
- * Notes:
- * - `safari` is macOS-only.
- * - `chrome` targets Google Chrome paths by default.
- * - `edge` targets Microsoft Edge paths by default.
- * - Other Chromium browsers often work by passing an explicit cookie DB path via `chromeProfile`
- * (or `edgeProfile` if you want to keep sources separate).
- * - Only modern Chromium cookie DB schemas are supported (roughly Chrome >= 100).
- */
-export type BrowserName = 'chrome' | 'edge' | 'firefox' | 'safari';
-export type CookieSameSite = 'Strict' | 'Lax' | 'None';
-export interface Cookie {
- /** Cookie name (required). */
- name: string;
- /** Cookie value (required; may be empty string). */
- value: string;
- /** Hostname without leading dot (e.g. `example.com`). */
- domain?: string;
- /** Path (defaults to `/` when omitted). */
- path?: string;
- /**
- * Optional URL form. Useful when a cookie is host-only or when `domain` is not known.
- * If present, it should be a valid origin URL like `https://example.com`.
- */
- url?: string;
- /** Unix timestamp in seconds. Omit for session cookies. */
- expires?: number;
- secure?: boolean;
- httpOnly?: boolean;
- sameSite?: CookieSameSite;
- source?: {
- browser: BrowserName;
- /** Optional profile identifier when resolved from a profile. */
- profile?: string;
- /** Optional origin that produced this cookie (implementation detail). */
- origin?: string;
- /** Optional store identifier (implementation detail; e.g. extension cookie store). */
- storeId?: string;
- };
-}
-export type CookieMode = 'merge' | 'first';
-export interface GetCookiesOptions {
- /**
- * Primary URL used to derive default origin filtering.
- * Must include a protocol (e.g. `https://example.com/`).
- */
- url: string;
- /**
- * Additional origins to include when filtering cookies.
- * Useful for OAuth/SSO flows where cookies are set on multiple domains.
- */
- origins?: string[];
- /** Allowlist of cookie names. When omitted, all matching cookies are returned. */
- names?: string[];
- /**
- * Which browser backends to try, in order.
- * Defaults to `chrome`, `safari`, `firefox` (and is also configurable via env).
- */
- browsers?: BrowserName[];
- /** Alias for chromeProfile (common case). */
- profile?: string;
- /**
- * Chrome/Chromium profile selector.
- *
- * Accepted values:
- * - profile directory name like `Default` / `Profile 2`
- * - a path to a profile directory
- * - a path to a cookie DB file (`.../Network/Cookies` or `.../Cookies`)
- */
- chromeProfile?: string;
- /**
- * Microsoft Edge profile selector.
- *
- * Accepted values:
- * - profile directory name like `Default` / `Profile 2`
- * - a path to a profile directory
- * - a path to a cookie DB file (`.../Network/Cookies` or `.../Cookies`)
- */
- edgeProfile?: string;
- /**
- * Firefox profile selector (profile name or filesystem path).
- * If a directory is provided, `cookies.sqlite` is resolved within it.
- */
- firefoxProfile?: string;
- /** Override path to Safari Cookies.binarycookies (for tests / debugging). */
- safariCookiesFile?: string;
- /** Include expired cookies (default: false). */
- includeExpired?: boolean;
- /** Timeout for OS helper calls (keychain/keyring/DPAPI). */
- timeoutMs?: number;
- /** Emit extra provider warnings (no raw cookie values). */
- debug?: boolean;
- /** Merge cookies across backends (`merge`) or return first successful backend (`first`). */
- mode?: CookieMode;
- /**
- * Inline cookie payload source. Commonly a file path exported by the extension.
- * If it looks like a file path, Sweet Cookie will attempt to read it.
- */
- inlineCookiesFile?: string;
- /** Inline cookie payload as JSON string (either `Cookie[]` or `{ cookies: Cookie[] }`). */
- inlineCookiesJson?: string;
- /** Inline cookie payload as base64-encoded JSON. */
- inlineCookiesBase64?: string;
-}
-export interface GetCookiesResult {
- cookies: Cookie[];
- /**
- * Non-fatal warnings from providers (missing keyring tools, schema drift, unsupported cookies, etc).
- * Never includes raw cookie values.
- */
- warnings: string[];
-}
-export interface CookieHeaderOptions {
- /** If true, keeps the first cookie value per name and drops duplicates. */
- dedupeByName?: boolean;
- /** Sorting strategy for the emitted header. */
- sort?: 'name' | 'none';
-}
-//# sourceMappingURL=types.d.ts.map
\ No newline at end of file
diff --git a/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/types.d.ts.map b/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/types.d.ts.map
deleted file mode 100644
index f44e1aa..0000000
--- a/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/types.d.ts.map
+++ /dev/null
@@ -1 +0,0 @@
-{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AACH,MAAM,MAAM,WAAW,GAAG,QAAQ,GAAG,MAAM,GAAG,SAAS,GAAG,QAAQ,CAAC;AAEnE,MAAM,MAAM,cAAc,GAAG,QAAQ,GAAG,KAAK,GAAG,MAAM,CAAC;AAEvD,MAAM,WAAW,MAAM;IACtB,8BAA8B;IAC9B,IAAI,EAAE,MAAM,CAAC;IACb,oDAAoD;IACpD,KAAK,EAAE,MAAM,CAAC;IACd,yDAAyD;IACzD,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,2CAA2C;IAC3C,IAAI,CAAC,EAAE,MAAM,CAAC;IACd;;;OAGG;IACH,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,2DAA2D;IAC3D,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,QAAQ,CAAC,EAAE,cAAc,CAAC;IAC1B,MAAM,CAAC,EAAE;QACR,OAAO,EAAE,WAAW,CAAC;QACrB,gEAAgE;QAChE,OAAO,CAAC,EAAE,MAAM,CAAC;QACjB,yEAAyE;QACzE,MAAM,CAAC,EAAE,MAAM,CAAC;QAChB,sFAAsF;QACtF,OAAO,CAAC,EAAE,MAAM,CAAC;KACjB,CAAC;CACF;AAED,MAAM,MAAM,UAAU,GAAG,OAAO,GAAG,OAAO,CAAC;AAE3C,MAAM,WAAW,iBAAiB;IACjC;;;OAGG;IACH,GAAG,EAAE,MAAM,CAAC;IACZ;;;OAGG;IACH,OAAO,CAAC,EAAE,MAAM,EAAE,CAAC;IACnB,kFAAkF;IAClF,KAAK,CAAC,EAAE,MAAM,EAAE,CAAC;IACjB;;;OAGG;IACH,QAAQ,CAAC,EAAE,WAAW,EAAE,CAAC;IACzB,6CAA6C;IAC7C,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB;;;;;;;OAOG;IACH,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB;;;;;;;OAOG;IACH,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB;;;OAGG;IACH,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,6EAA6E;IAC7E,iBAAiB,CAAC,EAAE,MAAM,CAAC;IAC3B,gDAAgD;IAChD,cAAc,CAAC,EAAE,OAAO,CAAC;IACzB,4DAA4D;IAC5D,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,2DAA2D;IAC3D,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB,4FAA4F;IAC5F,IAAI,CAAC,EAAE,UAAU,CAAC;IAClB;;;OAGG;IACH,iBAAiB,CAAC,EAAE,MAAM,CAAC;IAC3B,2FAA2F;IAC3F,iBAAiB,CAAC,EAAE,MAAM,CAAC;IAC3B,oDAAoD;IACpD,mBAAmB,CAAC,EAAE,MAAM,CAAC;CAC7B;AAED,MAAM,WAAW,gBAAgB;IAChC,OAAO,EAAE,MAAM,EAAE,CAAC;IAClB;;;OAGG;IACH,QAAQ,EAAE,MAAM,EAAE,CAAC;CACnB;AAED,MAAM,WAAW,mBAAmB;IACnC,2EAA2E;IAC3E,YAAY,CAAC,EAAE,OAAO,CAAC;IACvB,+CAA+C;IAC/C,IAAI,CAAC,EAAE,MAAM,GAAG,MAAM,CAAC;CACvB"}
\ No newline at end of file
diff --git a/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/types.js b/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/types.js
deleted file mode 100644
index 718fd38..0000000
--- a/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/types.js
+++ /dev/null
@@ -1,2 +0,0 @@
-export {};
-//# sourceMappingURL=types.js.map
\ No newline at end of file
diff --git a/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/types.js.map b/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/types.js.map
deleted file mode 100644
index c768b79..0000000
--- a/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/types.js.map
+++ /dev/null
@@ -1 +0,0 @@
-{"version":3,"file":"types.js","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":""}
\ No newline at end of file
diff --git a/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/util/base64.d.ts b/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/util/base64.d.ts
deleted file mode 100644
index de5dc28..0000000
--- a/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/util/base64.d.ts
+++ /dev/null
@@ -1,2 +0,0 @@
-export declare function tryDecodeBase64Json(input: string): string | null;
-//# sourceMappingURL=base64.d.ts.map
\ No newline at end of file
diff --git a/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/util/base64.d.ts.map b/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/util/base64.d.ts.map
deleted file mode 100644
index b8dbc1d..0000000
--- a/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/util/base64.d.ts.map
+++ /dev/null
@@ -1 +0,0 @@
-{"version":3,"file":"base64.d.ts","sourceRoot":"","sources":["../../src/util/base64.ts"],"names":[],"mappings":"AAAA,wBAAgB,mBAAmB,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAahE"}
\ No newline at end of file
diff --git a/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/util/base64.js b/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/util/base64.js
deleted file mode 100644
index c1e470b..0000000
--- a/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/util/base64.js
+++ /dev/null
@@ -1,18 +0,0 @@
-export function tryDecodeBase64Json(input) {
- const trimmed = input.trim();
- if (!trimmed)
- return null;
- try {
- const encoding = /[-_]/.test(trimmed) ? 'base64url' : 'base64';
- const buf = Buffer.from(trimmed, encoding);
- const decoded = buf.toString('utf8').trim();
- if (!decoded)
- return null;
- JSON.parse(decoded);
- return decoded;
- }
- catch {
- return null;
- }
-}
-//# sourceMappingURL=base64.js.map
\ No newline at end of file
diff --git a/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/util/base64.js.map b/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/util/base64.js.map
deleted file mode 100644
index efef5e3..0000000
--- a/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/util/base64.js.map
+++ /dev/null
@@ -1 +0,0 @@
-{"version":3,"file":"base64.js","sourceRoot":"","sources":["../../src/util/base64.ts"],"names":[],"mappings":"AAAA,MAAM,UAAU,mBAAmB,CAAC,KAAa;IAChD,MAAM,OAAO,GAAG,KAAK,CAAC,IAAI,EAAE,CAAC;IAC7B,IAAI,CAAC,OAAO;QAAE,OAAO,IAAI,CAAC;IAC1B,IAAI,CAAC;QACJ,MAAM,QAAQ,GAAmB,MAAM,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC,CAAC,CAAC,WAAW,CAAC,CAAC,CAAC,QAAQ,CAAC;QAC/E,MAAM,GAAG,GAAG,MAAM,CAAC,IAAI,CAAC,OAAO,EAAE,QAAQ,CAAC,CAAC;QAC3C,MAAM,OAAO,GAAG,GAAG,CAAC,QAAQ,CAAC,MAAM,CAAC,CAAC,IAAI,EAAE,CAAC;QAC5C,IAAI,CAAC,OAAO;YAAE,OAAO,IAAI,CAAC;QAC1B,IAAI,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC;QACpB,OAAO,OAAO,CAAC;IAChB,CAAC;IAAC,MAAM,CAAC;QACR,OAAO,IAAI,CAAC;IACb,CAAC;AACF,CAAC"}
\ No newline at end of file
diff --git a/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/util/exec.d.ts b/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/util/exec.d.ts
deleted file mode 100644
index 03d671f..0000000
--- a/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/util/exec.d.ts
+++ /dev/null
@@ -1,8 +0,0 @@
-export declare function execCapture(file: string, args: string[], options?: {
- timeoutMs?: number;
-}): Promise<{
- code: number;
- stdout: string;
- stderr: string;
-}>;
-//# sourceMappingURL=exec.d.ts.map
\ No newline at end of file
diff --git a/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/util/exec.d.ts.map b/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/util/exec.d.ts.map
deleted file mode 100644
index 97b044a..0000000
--- a/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/util/exec.d.ts.map
+++ /dev/null
@@ -1 +0,0 @@
-{"version":3,"file":"exec.d.ts","sourceRoot":"","sources":["../../src/util/exec.ts"],"names":[],"mappings":"AAEA,wBAAsB,WAAW,CAChC,IAAI,EAAE,MAAM,EACZ,IAAI,EAAE,MAAM,EAAE,EACd,OAAO,GAAE;IAAE,SAAS,CAAC,EAAE,MAAM,CAAA;CAAO,GAClC,OAAO,CAAC;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,CAAC,CA8G3D"}
\ No newline at end of file
diff --git a/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/util/exec.js b/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/util/exec.js
deleted file mode 100644
index 044cb3e..0000000
--- a/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/util/exec.js
+++ /dev/null
@@ -1,110 +0,0 @@
-import { spawn } from 'node:child_process';
-export async function execCapture(file, args, options = {}) {
- const timeoutMs = options.timeoutMs ?? 10_000;
- const runOnce = (executable) => new Promise((resolve) => {
- // Keep this very small + predictable:
- // - no shell by default (avoid quoting differences)
- // - capture stdout/stderr for diagnostics
- // - hard timeout so keychain/keyring calls can't hang forever in CI/SSH sessions
- const child = spawn(executable, args, { stdio: ['ignore', 'pipe', 'pipe'] });
- let stdout = '';
- let stderr = '';
- child.stdout.setEncoding('utf8');
- child.stderr.setEncoding('utf8');
- child.stdout.on('data', (chunk) => {
- stdout += chunk;
- });
- child.stderr.on('data', (chunk) => {
- stderr += chunk;
- });
- const timer = setTimeout(() => {
- try {
- child.kill('SIGKILL');
- }
- catch {
- // ignore
- }
- resolve({ code: 124, stdout, stderr: `${stderr}\nTimed out after ${timeoutMs}ms` });
- }, timeoutMs);
- timer.unref?.();
- child.on('error', (error) => {
- clearTimeout(timer);
- resolve({
- code: 127,
- stdout,
- stderr: `${stderr}\n${error instanceof Error ? error.message : String(error)}`,
- });
- });
- child.on('close', (code) => {
- clearTimeout(timer);
- resolve({ code: code ?? 0, stdout, stderr });
- });
- });
- const result = await runOnce(file);
- if (process.platform !== 'win32')
- return result;
- /* c8 ignore start */
- const runOnceCmd = (cmd) => {
- // On Windows, some tools are `.cmd`/`.bat` wrappers and won't exec directly.
- // We fall back to `cmd.exe /c` and quote args ourselves.
- const quoted = [cmd, ...args.map(cmdQuote)].join(' ');
- return new Promise((resolve) => {
- const child = spawn('cmd.exe', ['/d', '/s', '/c', quoted], {
- stdio: ['ignore', 'pipe', 'pipe'],
- });
- let stdout = '';
- let stderr = '';
- child.stdout.setEncoding('utf8');
- child.stderr.setEncoding('utf8');
- child.stdout.on('data', (chunk) => {
- stdout += chunk;
- });
- child.stderr.on('data', (chunk) => {
- stderr += chunk;
- });
- const timer = setTimeout(() => {
- try {
- child.kill('SIGKILL');
- }
- catch {
- // ignore
- }
- resolve({ code: 124, stdout, stderr: `${stderr}\nTimed out after ${timeoutMs}ms` });
- }, timeoutMs);
- timer.unref?.();
- child.on('error', (error) => {
- clearTimeout(timer);
- resolve({
- code: 127,
- stdout,
- stderr: `${stderr}\n${error instanceof Error ? error.message : String(error)}`,
- });
- });
- child.on('close', (code) => {
- clearTimeout(timer);
- resolve({ code: code ?? 0, stdout, stderr });
- });
- });
- };
- const stderr = result.stderr.toLowerCase();
- if (result.code === 127 && stderr.includes('enoent') && !file.toLowerCase().endsWith('.cmd')) {
- const cmdResult = await runOnceCmd(`${file}.cmd`);
- if (!(cmdResult.code === 127 && cmdResult.stderr.toLowerCase().includes('enoent')))
- return cmdResult;
- }
- if (result.code === 127 && stderr.includes('enoent') && !file.toLowerCase().endsWith('.bat')) {
- const batResult = await runOnceCmd(`${file}.bat`);
- if (!(batResult.code === 127 && batResult.stderr.toLowerCase().includes('enoent')))
- return batResult;
- }
- /* c8 ignore stop */
- return result;
-}
-function cmdQuote(value) {
- if (!value)
- return '""';
- if (!/[\t\s"&|<>^]/.test(value))
- return value;
- return `"${value.replaceAll('"', '""')}"`;
-}
-//# sourceMappingURL=exec.js.map
\ No newline at end of file
diff --git a/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/util/exec.js.map b/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/util/exec.js.map
deleted file mode 100644
index abcca00..0000000
--- a/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/util/exec.js.map
+++ /dev/null
@@ -1 +0,0 @@
-{"version":3,"file":"exec.js","sourceRoot":"","sources":["../../src/util/exec.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,EAAE,MAAM,oBAAoB,CAAC;AAE3C,MAAM,CAAC,KAAK,UAAU,WAAW,CAChC,IAAY,EACZ,IAAc,EACd,UAAkC,EAAE;IAEpC,MAAM,SAAS,GAAG,OAAO,CAAC,SAAS,IAAI,MAAM,CAAC;IAE9C,MAAM,OAAO,GAAG,CAAC,UAAkB,EAA6D,EAAE,CACjG,IAAI,OAAO,CAAC,CAAC,OAAO,EAAE,EAAE;QACvB,sCAAsC;QACtC,oDAAoD;QACpD,0CAA0C;QAC1C,iFAAiF;QACjF,MAAM,KAAK,GAAG,KAAK,CAAC,UAAU,EAAE,IAAI,EAAE,EAAE,KAAK,EAAE,CAAC,QAAQ,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,CAAC,CAAC;QAC7E,IAAI,MAAM,GAAG,EAAE,CAAC;QAChB,IAAI,MAAM,GAAG,EAAE,CAAC;QAChB,KAAK,CAAC,MAAM,CAAC,WAAW,CAAC,MAAM,CAAC,CAAC;QACjC,KAAK,CAAC,MAAM,CAAC,WAAW,CAAC,MAAM,CAAC,CAAC;QACjC,KAAK,CAAC,MAAM,CAAC,EAAE,CAAC,MAAM,EAAE,CAAC,KAAK,EAAE,EAAE;YACjC,MAAM,IAAI,KAAK,CAAC;QACjB,CAAC,CAAC,CAAC;QACH,KAAK,CAAC,MAAM,CAAC,EAAE,CAAC,MAAM,EAAE,CAAC,KAAK,EAAE,EAAE;YACjC,MAAM,IAAI,KAAK,CAAC;QACjB,CAAC,CAAC,CAAC;QAEH,MAAM,KAAK,GAAG,UAAU,CAAC,GAAG,EAAE;YAC7B,IAAI,CAAC;gBACJ,KAAK,CAAC,IAAI,CAAC,SAAS,CAAC,CAAC;YACvB,CAAC;YAAC,MAAM,CAAC;gBACR,SAAS;YACV,CAAC;YACD,OAAO,CAAC,EAAE,IAAI,EAAE,GAAG,EAAE,MAAM,EAAE,MAAM,EAAE,GAAG,MAAM,qBAAqB,SAAS,IAAI,EAAE,CAAC,CAAC;QACrF,CAAC,EAAE,SAAS,CAAC,CAAC;QACd,KAAK,CAAC,KAAK,EAAE,EAAE,CAAC;QAEhB,KAAK,CAAC,EAAE,CAAC,OAAO,EAAE,CAAC,KAAK,EAAE,EAAE;YAC3B,YAAY,CAAC,KAAK,CAAC,CAAC;YACpB,OAAO,CAAC;gBACP,IAAI,EAAE,GAAG;gBACT,MAAM;gBACN,MAAM,EAAE,GAAG,MAAM,KAAK,KAAK,YAAY,KAAK,CAAC,CAAC,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,EAAE;aAC9E,CAAC,CAAC;QACJ,CAAC,CAAC,CAAC;QAEH,KAAK,CAAC,EAAE,CAAC,OAAO,EAAE,CAAC,IAAI,EAAE,EAAE;YAC1B,YAAY,CAAC,KAAK,CAAC,CAAC;YACpB,OAAO,CAAC,EAAE,IAAI,EAAE,IAAI,IAAI,CAAC,EAAE,MAAM,EAAE,MAAM,EAAE,CAAC,CAAC;QAC9C,CAAC,CAAC,CAAC;IACJ,CAAC,CAAC,CAAC;IAEJ,MAAM,MAAM,GAAG,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC;IACnC,IAAI,OAAO,CAAC,QAAQ,KAAK,OAAO;QAAE,OAAO,MAAM,CAAC;IAEhD,qBAAqB;IACrB,MAAM,UAAU,GAAG,CAAC,GAAW,EAA6D,EAAE;QAC7F,6EAA6E;QAC7E,yDAAyD;QACzD,MAAM,MAAM,GAAG,CAAC,GAAG,EAAE,GAAG,IAAI,CAAC,GAAG,CAAC,QAAQ,CAAC,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;QACtD,OAAO,IAAI,OAAO,CAAC,CAAC,OAAO,EAAE,EAAE;YAC9B,MAAM,KAAK,GAAG,KAAK,CAAC,SAAS,EAAE,CAAC,IAAI,EAAE,IAAI,EAAE,IAAI,EAAE,MAAM,CAAC,EAAE;gBAC1D,KAAK,EAAE,CAAC,QAAQ,EAAE,MAAM,EAAE,MAAM,CAAC;aACjC,CAAC,CAAC;YACH,IAAI,MAAM,GAAG,EAAE,CAAC;YAChB,IAAI,MAAM,GAAG,EAAE,CAAC;YAChB,KAAK,CAAC,MAAM,CAAC,WAAW,CAAC,MAAM,CAAC,CAAC;YACjC,KAAK,CAAC,MAAM,CAAC,WAAW,CAAC,MAAM,CAAC,CAAC;YACjC,KAAK,CAAC,MAAM,CAAC,EAAE,CAAC,MAAM,EAAE,CAAC,KAAK,EAAE,EAAE;gBACjC,MAAM,IAAI,KAAK,CAAC;YACjB,CAAC,CAAC,CAAC;YACH,KAAK,CAAC,MAAM,CAAC,EAAE,CAAC,MAAM,EAAE,CAAC,KAAK,EAAE,EAAE;gBACjC,MAAM,IAAI,KAAK,CAAC;YACjB,CAAC,CAAC,CAAC;YAEH,MAAM,KAAK,GAAG,UAAU,CAAC,GAAG,EAAE;gBAC7B,IAAI,CAAC;oBACJ,KAAK,CAAC,IAAI,CAAC,SAAS,CAAC,CAAC;gBACvB,CAAC;gBAAC,MAAM,CAAC;oBACR,SAAS;gBACV,CAAC;gBACD,OAAO,CAAC,EAAE,IAAI,EAAE,GAAG,EAAE,MAAM,EAAE,MAAM,EAAE,GAAG,MAAM,qBAAqB,SAAS,IAAI,EAAE,CAAC,CAAC;YACrF,CAAC,EAAE,SAAS,CAAC,CAAC;YACd,KAAK,CAAC,KAAK,EAAE,EAAE,CAAC;YAEhB,KAAK,CAAC,EAAE,CAAC,OAAO,EAAE,CAAC,KAAK,EAAE,EAAE;gBAC3B,YAAY,CAAC,KAAK,CAAC,CAAC;gBACpB,OAAO,CAAC;oBACP,IAAI,EAAE,GAAG;oBACT,MAAM;oBACN,MAAM,EAAE,GAAG,MAAM,KAAK,KAAK,YAAY,KAAK,CAAC,CAAC,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,EAAE;iBAC9E,CAAC,CAAC;YACJ,CAAC,CAAC,CAAC;YAEH,KAAK,CAAC,EAAE,CAAC,OAAO,EAAE,CAAC,IAAI,EAAE,EAAE;gBAC1B,YAAY,CAAC,KAAK,CAAC,CAAC;gBACpB,OAAO,CAAC,EAAE,IAAI,EAAE,IAAI,IAAI,CAAC,EAAE,MAAM,EAAE,MAAM,EAAE,CAAC,CAAC;YAC9C,CAAC,CAAC,CAAC;QACJ,CAAC,CAAC,CAAC;IACJ,CAAC,CAAC;IAEF,MAAM,MAAM,GAAG,MAAM,CAAC,MAAM,CAAC,WAAW,EAAE,CAAC;IAC3C,IAAI,MAAM,CAAC,IAAI,KAAK,GAAG,IAAI,MAAM,CAAC,QAAQ,CAAC,QAAQ,CAAC,IAAI,CAAC,IAAI,CAAC,WAAW,EAAE,CAAC,QAAQ,CAAC,MAAM,CAAC,EAAE,CAAC;QAC9F,MAAM,SAAS,GAAG,MAAM,UAAU,CAAC,GAAG,IAAI,MAAM,CAAC,CAAC;QAClD,IAAI,CAAC,CAAC,SAAS,CAAC,IAAI,KAAK,GAAG,IAAI,SAAS,CAAC,MAAM,CAAC,WAAW,EAAE,CAAC,QAAQ,CAAC,QAAQ,CAAC,CAAC;YACjF,OAAO,SAAS,CAAC;IACnB,CAAC;IAED,IAAI,MAAM,CAAC,IAAI,KAAK,GAAG,IAAI,MAAM,CAAC,QAAQ,CAAC,QAAQ,CAAC,IAAI,CAAC,IAAI,CAAC,WAAW,EAAE,CAAC,QAAQ,CAAC,MAAM,CAAC,EAAE,CAAC;QAC9F,MAAM,SAAS,GAAG,MAAM,UAAU,CAAC,GAAG,IAAI,MAAM,CAAC,CAAC;QAClD,IAAI,CAAC,CAAC,SAAS,CAAC,IAAI,KAAK,GAAG,IAAI,SAAS,CAAC,MAAM,CAAC,WAAW,EAAE,CAAC,QAAQ,CAAC,QAAQ,CAAC,CAAC;YACjF,OAAO,SAAS,CAAC;IACnB,CAAC;IACD,oBAAoB;IAEpB,OAAO,MAAM,CAAC;AACf,CAAC;AAED,SAAS,QAAQ,CAAC,KAAa;IAC9B,IAAI,CAAC,KAAK;QAAE,OAAO,IAAI,CAAC;IACxB,IAAI,CAAC,cAAc,CAAC,IAAI,CAAC,KAAK,CAAC;QAAE,OAAO,KAAK,CAAC;IAC9C,OAAO,IAAI,KAAK,CAAC,UAAU,CAAC,GAAG,EAAE,IAAI,CAAC,GAAG,CAAC;AAC3C,CAAC"}
\ No newline at end of file
diff --git a/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/util/expire.d.ts b/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/util/expire.d.ts
deleted file mode 100644
index 19ae6d2..0000000
--- a/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/util/expire.d.ts
+++ /dev/null
@@ -1,2 +0,0 @@
-export declare function normalizeExpiration(expires?: number | bigint): number | undefined;
-//# sourceMappingURL=expire.d.ts.map
\ No newline at end of file
diff --git a/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/util/expire.d.ts.map b/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/util/expire.d.ts.map
deleted file mode 100644
index 0b36a1d..0000000
--- a/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/util/expire.d.ts.map
+++ /dev/null
@@ -1 +0,0 @@
-{"version":3,"file":"expire.d.ts","sourceRoot":"","sources":["../../src/util/expire.ts"],"names":[],"mappings":"AAAA,wBAAgB,mBAAmB,CAAC,OAAO,CAAC,EAAE,MAAM,GAAG,MAAM,GAAG,MAAM,GAAG,SAAS,CA6BjF"}
\ No newline at end of file
diff --git a/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/util/expire.js b/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/util/expire.js
deleted file mode 100644
index 9b9e38d..0000000
--- a/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/util/expire.js
+++ /dev/null
@@ -1,32 +0,0 @@
-export function normalizeExpiration(expires) {
- if (expires === undefined || expires === null)
- return undefined;
- if (typeof expires === 'bigint') {
- if (expires <= 0n)
- return undefined;
- if (expires > 10000000000000n) {
- return Number(expires / 1000000n - 11644473600n);
- }
- if (expires > 10000000000n) {
- return Number(expires / 1000n);
- }
- return Number(expires);
- }
- if (!expires || Number.isNaN(expires))
- return undefined;
- const value = Number(expires);
- if (value <= 0)
- return undefined;
- // Chromium can use microseconds since 1601 (Windows epoch) in sqlite stores.
- if (value > 10_000_000_000_000) {
- return Math.round(value / 1_000_000 - 11_644_473_600);
- }
- // milliseconds epoch
- // (seconds epoch is already ~1.7e9, so the cutoff must be much higher than 1e9)
- if (value > 10_000_000_000) {
- return Math.round(value / 1000);
- }
- // seconds epoch
- return Math.round(value);
-}
-//# sourceMappingURL=expire.js.map
\ No newline at end of file
diff --git a/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/util/expire.js.map b/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/util/expire.js.map
deleted file mode 100644
index 480ee94..0000000
--- a/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/util/expire.js.map
+++ /dev/null
@@ -1 +0,0 @@
-{"version":3,"file":"expire.js","sourceRoot":"","sources":["../../src/util/expire.ts"],"names":[],"mappings":"AAAA,MAAM,UAAU,mBAAmB,CAAC,OAAyB;IAC5D,IAAI,OAAO,KAAK,SAAS,IAAI,OAAO,KAAK,IAAI;QAAE,OAAO,SAAS,CAAC;IAChE,IAAI,OAAO,OAAO,KAAK,QAAQ,EAAE,CAAC;QACjC,IAAI,OAAO,IAAI,EAAE;YAAE,OAAO,SAAS,CAAC;QACpC,IAAI,OAAO,GAAG,eAAmB,EAAE,CAAC;YACnC,OAAO,MAAM,CAAC,OAAO,GAAG,QAAU,GAAG,YAAe,CAAC,CAAC;QACvD,CAAC;QACD,IAAI,OAAO,GAAG,YAAe,EAAE,CAAC;YAC/B,OAAO,MAAM,CAAC,OAAO,GAAG,KAAK,CAAC,CAAC;QAChC,CAAC;QACD,OAAO,MAAM,CAAC,OAAO,CAAC,CAAC;IACxB,CAAC;IACD,IAAI,CAAC,OAAO,IAAI,MAAM,CAAC,KAAK,CAAC,OAAO,CAAC;QAAE,OAAO,SAAS,CAAC;IACxD,MAAM,KAAK,GAAG,MAAM,CAAC,OAAO,CAAC,CAAC;IAC9B,IAAI,KAAK,IAAI,CAAC;QAAE,OAAO,SAAS,CAAC;IAEjC,6EAA6E;IAC7E,IAAI,KAAK,GAAG,kBAAkB,EAAE,CAAC;QAChC,OAAO,IAAI,CAAC,KAAK,CAAC,KAAK,GAAG,SAAS,GAAG,cAAc,CAAC,CAAC;IACvD,CAAC;IAED,qBAAqB;IACrB,gFAAgF;IAChF,IAAI,KAAK,GAAG,cAAc,EAAE,CAAC;QAC5B,OAAO,IAAI,CAAC,KAAK,CAAC,KAAK,GAAG,IAAI,CAAC,CAAC;IACjC,CAAC;IAED,gBAAgB;IAChB,OAAO,IAAI,CAAC,KAAK,CAAC,KAAK,CAAC,CAAC;AAC1B,CAAC"}
\ No newline at end of file
diff --git a/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/util/fs.d.ts b/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/util/fs.d.ts
deleted file mode 100644
index e6e1322..0000000
--- a/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/util/fs.d.ts
+++ /dev/null
@@ -1,2 +0,0 @@
-export declare function readTextFileIfExists(filePath: string): Promise;
-//# sourceMappingURL=fs.d.ts.map
\ No newline at end of file
diff --git a/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/util/fs.d.ts.map b/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/util/fs.d.ts.map
deleted file mode 100644
index a6a44bb..0000000
--- a/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/util/fs.d.ts.map
+++ /dev/null
@@ -1 +0,0 @@
-{"version":3,"file":"fs.d.ts","sourceRoot":"","sources":["../../src/util/fs.ts"],"names":[],"mappings":"AAEA,wBAAsB,oBAAoB,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAQnF"}
\ No newline at end of file
diff --git a/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/util/fs.js b/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/util/fs.js
deleted file mode 100644
index d9f5469..0000000
--- a/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/util/fs.js
+++ /dev/null
@@ -1,13 +0,0 @@
-import fs from 'node:fs/promises';
-export async function readTextFileIfExists(filePath) {
- try {
- const stat = await fs.stat(filePath);
- if (!stat.isFile())
- return null;
- return await fs.readFile(filePath, 'utf8');
- }
- catch {
- return null;
- }
-}
-//# sourceMappingURL=fs.js.map
\ No newline at end of file
diff --git a/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/util/fs.js.map b/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/util/fs.js.map
deleted file mode 100644
index 26ecdb6..0000000
--- a/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/util/fs.js.map
+++ /dev/null
@@ -1 +0,0 @@
-{"version":3,"file":"fs.js","sourceRoot":"","sources":["../../src/util/fs.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,kBAAkB,CAAC;AAElC,MAAM,CAAC,KAAK,UAAU,oBAAoB,CAAC,QAAgB;IAC1D,IAAI,CAAC;QACJ,MAAM,IAAI,GAAG,MAAM,EAAE,CAAC,IAAI,CAAC,QAAQ,CAAC,CAAC;QACrC,IAAI,CAAC,IAAI,CAAC,MAAM,EAAE;YAAE,OAAO,IAAI,CAAC;QAChC,OAAO,MAAM,EAAE,CAAC,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC,CAAC;IAC5C,CAAC;IAAC,MAAM,CAAC;QACR,OAAO,IAAI,CAAC;IACb,CAAC;AACF,CAAC"}
\ No newline at end of file
diff --git a/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/util/hostMatch.d.ts b/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/util/hostMatch.d.ts
deleted file mode 100644
index 5f51d7c..0000000
--- a/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/util/hostMatch.d.ts
+++ /dev/null
@@ -1,2 +0,0 @@
-export declare function hostMatchesCookieDomain(host: string, cookieDomain: string): boolean;
-//# sourceMappingURL=hostMatch.d.ts.map
\ No newline at end of file
diff --git a/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/util/hostMatch.d.ts.map b/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/util/hostMatch.d.ts.map
deleted file mode 100644
index e64a4e2..0000000
--- a/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/util/hostMatch.d.ts.map
+++ /dev/null
@@ -1 +0,0 @@
-{"version":3,"file":"hostMatch.d.ts","sourceRoot":"","sources":["../../src/util/hostMatch.ts"],"names":[],"mappings":"AAAA,wBAAgB,uBAAuB,CAAC,IAAI,EAAE,MAAM,EAAE,YAAY,EAAE,MAAM,GAAG,OAAO,CAKnF"}
\ No newline at end of file
diff --git a/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/util/hostMatch.js b/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/util/hostMatch.js
deleted file mode 100644
index 9f221ba..0000000
--- a/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/util/hostMatch.js
+++ /dev/null
@@ -1,7 +0,0 @@
-export function hostMatchesCookieDomain(host, cookieDomain) {
- const normalizedHost = host.toLowerCase();
- const normalizedDomain = cookieDomain.startsWith('.') ? cookieDomain.slice(1) : cookieDomain;
- const domainLower = normalizedDomain.toLowerCase();
- return normalizedHost === domainLower || normalizedHost.endsWith(`.${domainLower}`);
-}
-//# sourceMappingURL=hostMatch.js.map
\ No newline at end of file
diff --git a/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/util/hostMatch.js.map b/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/util/hostMatch.js.map
deleted file mode 100644
index a8c5c4e..0000000
--- a/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/util/hostMatch.js.map
+++ /dev/null
@@ -1 +0,0 @@
-{"version":3,"file":"hostMatch.js","sourceRoot":"","sources":["../../src/util/hostMatch.ts"],"names":[],"mappings":"AAAA,MAAM,UAAU,uBAAuB,CAAC,IAAY,EAAE,YAAoB;IACzE,MAAM,cAAc,GAAG,IAAI,CAAC,WAAW,EAAE,CAAC;IAC1C,MAAM,gBAAgB,GAAG,YAAY,CAAC,UAAU,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,YAAY,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,YAAY,CAAC;IAC7F,MAAM,WAAW,GAAG,gBAAgB,CAAC,WAAW,EAAE,CAAC;IACnD,OAAO,cAAc,KAAK,WAAW,IAAI,cAAc,CAAC,QAAQ,CAAC,IAAI,WAAW,EAAE,CAAC,CAAC;AACrF,CAAC"}
\ No newline at end of file
diff --git a/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/util/nodeSqlite.d.ts b/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/util/nodeSqlite.d.ts
deleted file mode 100644
index f15eed5..0000000
--- a/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/util/nodeSqlite.d.ts
+++ /dev/null
@@ -1,5 +0,0 @@
-type NodeSqliteModule = typeof import('node:sqlite');
-export declare function supportsReadBigInts(): boolean;
-export declare function importNodeSqlite(): Promise;
-export {};
-//# sourceMappingURL=nodeSqlite.d.ts.map
\ No newline at end of file
diff --git a/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/util/nodeSqlite.d.ts.map b/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/util/nodeSqlite.d.ts.map
deleted file mode 100644
index 45e57aa..0000000
--- a/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/util/nodeSqlite.d.ts.map
+++ /dev/null
@@ -1 +0,0 @@
-{"version":3,"file":"nodeSqlite.d.ts","sourceRoot":"","sources":["../../src/util/nodeSqlite.ts"],"names":[],"mappings":"AAAA,KAAK,gBAAgB,GAAG,cAAc,aAAa,CAAC,CAAC;AAIrD,wBAAgB,mBAAmB,IAAI,OAAO,CAQ7C;AA4BD,wBAAsB,gBAAgB,IAAI,OAAO,CAAC,gBAAgB,CAAC,CAmBlE"}
\ No newline at end of file
diff --git a/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/util/nodeSqlite.js b/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/util/nodeSqlite.js
deleted file mode 100644
index 68c344e..0000000
--- a/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/util/nodeSqlite.js
+++ /dev/null
@@ -1,58 +0,0 @@
-let cached = null;
-export function supportsReadBigInts() {
- const [majorRaw, minorRaw] = process.versions.node.split('.');
- const major = Number.parseInt(majorRaw ?? '', 10);
- const minor = Number.parseInt(minorRaw ?? '', 10);
- if (!Number.isFinite(major) || !Number.isFinite(minor))
- return false;
- if (major > 24)
- return true;
- if (major < 24)
- return false;
- return minor >= 4;
-}
-function shouldSuppressSqliteExperimentalWarning(warning, args) {
- const message = typeof warning === 'string'
- ? warning
- : warning instanceof Error
- ? warning.message
- : typeof warning?.message === 'string'
- ? warning.message
- : null;
- if (!message || !message.includes('SQLite is an experimental feature'))
- return false;
- // Best-effort: only swallow the one noisy warning that Node emits when loading `node:sqlite`.
- // We do *not* suppress arbitrary warnings, and we restore the original handler immediately.
- const firstArg = args[0];
- if (firstArg === 'ExperimentalWarning')
- return true;
- if (typeof firstArg === 'object' && firstArg) {
- const type = firstArg.type;
- if (type === 'ExperimentalWarning')
- return true;
- }
- if (warning instanceof Error && warning.name === 'ExperimentalWarning')
- return true;
- return false;
-}
-export async function importNodeSqlite() {
- if (cached)
- return cached;
- // Node currently emits an ExperimentalWarning when importing `node:sqlite`.
- // This is harmless noise for consumers of this library, so we silence only that specific warning.
- const originalEmitWarning = process.emitWarning.bind(process);
- process.emitWarning = ((warning, ...args) => {
- if (shouldSuppressSqliteExperimentalWarning(warning, args))
- return;
- // @ts-expect-error - Node's overloads are awkward; preserve runtime behavior.
- return originalEmitWarning(warning, ...args);
- });
- try {
- cached = await import('node:sqlite');
- return cached;
- }
- finally {
- process.emitWarning = originalEmitWarning;
- }
-}
-//# sourceMappingURL=nodeSqlite.js.map
\ No newline at end of file
diff --git a/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/util/nodeSqlite.js.map b/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/util/nodeSqlite.js.map
deleted file mode 100644
index 627007d..0000000
--- a/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/util/nodeSqlite.js.map
+++ /dev/null
@@ -1 +0,0 @@
-{"version":3,"file":"nodeSqlite.js","sourceRoot":"","sources":["../../src/util/nodeSqlite.ts"],"names":[],"mappings":"AAEA,IAAI,MAAM,GAA4B,IAAI,CAAC;AAE3C,MAAM,UAAU,mBAAmB;IAClC,MAAM,CAAC,QAAQ,EAAE,QAAQ,CAAC,GAAG,OAAO,CAAC,QAAQ,CAAC,IAAI,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC;IAC9D,MAAM,KAAK,GAAG,MAAM,CAAC,QAAQ,CAAC,QAAQ,IAAI,EAAE,EAAE,EAAE,CAAC,CAAC;IAClD,MAAM,KAAK,GAAG,MAAM,CAAC,QAAQ,CAAC,QAAQ,IAAI,EAAE,EAAE,EAAE,CAAC,CAAC;IAClD,IAAI,CAAC,MAAM,CAAC,QAAQ,CAAC,KAAK,CAAC,IAAI,CAAC,MAAM,CAAC,QAAQ,CAAC,KAAK,CAAC;QAAE,OAAO,KAAK,CAAC;IACrE,IAAI,KAAK,GAAG,EAAE;QAAE,OAAO,IAAI,CAAC;IAC5B,IAAI,KAAK,GAAG,EAAE;QAAE,OAAO,KAAK,CAAC;IAC7B,OAAO,KAAK,IAAI,CAAC,CAAC;AACnB,CAAC;AAED,SAAS,uCAAuC,CAAC,OAAgB,EAAE,IAAe;IACjF,MAAM,OAAO,GACZ,OAAO,OAAO,KAAK,QAAQ;QAC1B,CAAC,CAAC,OAAO;QACT,CAAC,CAAC,OAAO,YAAY,KAAK;YACzB,CAAC,CAAC,OAAO,CAAC,OAAO;YACjB,CAAC,CAAC,OAAQ,OAAwC,EAAE,OAAO,KAAK,QAAQ;gBACvE,CAAC,CAAE,OAA+B,CAAC,OAAO;gBAC1C,CAAC,CAAC,IAAI,CAAC;IAEX,IAAI,CAAC,OAAO,IAAI,CAAC,OAAO,CAAC,QAAQ,CAAC,mCAAmC,CAAC;QAAE,OAAO,KAAK,CAAC;IAErF,8FAA8F;IAC9F,4FAA4F;IAC5F,MAAM,QAAQ,GAAG,IAAI,CAAC,CAAC,CAAC,CAAC;IACzB,IAAI,QAAQ,KAAK,qBAAqB;QAAE,OAAO,IAAI,CAAC;IACpD,IAAI,OAAO,QAAQ,KAAK,QAAQ,IAAI,QAAQ,EAAE,CAAC;QAC9C,MAAM,IAAI,GAAI,QAA+B,CAAC,IAAI,CAAC;QACnD,IAAI,IAAI,KAAK,qBAAqB;YAAE,OAAO,IAAI,CAAC;IACjD,CAAC;IACD,IAAI,OAAO,YAAY,KAAK,IAAK,OAA8B,CAAC,IAAI,KAAK,qBAAqB;QAC7F,OAAO,IAAI,CAAC;IAEb,OAAO,KAAK,CAAC;AACd,CAAC;AAED,MAAM,CAAC,KAAK,UAAU,gBAAgB;IACrC,IAAI,MAAM;QAAE,OAAO,MAAM,CAAC;IAE1B,4EAA4E;IAC5E,kGAAkG;IAClG,MAAM,mBAAmB,GAAG,OAAO,CAAC,WAAW,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC;IAE9D,OAAO,CAAC,WAAW,GAAG,CAAC,CAAC,OAAgB,EAAE,GAAG,IAAe,EAAE,EAAE;QAC/D,IAAI,uCAAuC,CAAC,OAAO,EAAE,IAAI,CAAC;YAAE,OAAO;QACnE,8EAA8E;QAC9E,OAAO,mBAAmB,CAAC,OAAO,EAAE,GAAG,IAAI,CAAC,CAAC;IAC9C,CAAC,CAA+B,CAAC;IAEjC,IAAI,CAAC;QACJ,MAAM,GAAG,MAAM,MAAM,CAAC,aAAa,CAAC,CAAC;QACrC,OAAO,MAAM,CAAC;IACf,CAAC;YAAS,CAAC;QACV,OAAO,CAAC,WAAW,GAAG,mBAAmB,CAAC;IAC3C,CAAC;AACF,CAAC"}
\ No newline at end of file
diff --git a/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/util/origins.d.ts b/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/util/origins.d.ts
deleted file mode 100644
index 8a61f62..0000000
--- a/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/util/origins.d.ts
+++ /dev/null
@@ -1,2 +0,0 @@
-export declare function normalizeOrigins(url: string, extraOrigins?: string[]): string[];
-//# sourceMappingURL=origins.d.ts.map
\ No newline at end of file
diff --git a/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/util/origins.d.ts.map b/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/util/origins.d.ts.map
deleted file mode 100644
index a94554c..0000000
--- a/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/util/origins.d.ts.map
+++ /dev/null
@@ -1 +0,0 @@
-{"version":3,"file":"origins.d.ts","sourceRoot":"","sources":["../../src/util/origins.ts"],"names":[],"mappings":"AAAA,wBAAgB,gBAAgB,CAAC,GAAG,EAAE,MAAM,EAAE,YAAY,CAAC,EAAE,MAAM,EAAE,GAAG,MAAM,EAAE,CAqB/E"}
\ No newline at end of file
diff --git a/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/util/origins.js b/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/util/origins.js
deleted file mode 100644
index f325dbe..0000000
--- a/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/util/origins.js
+++ /dev/null
@@ -1,27 +0,0 @@
-export function normalizeOrigins(url, extraOrigins) {
- const origins = [];
- try {
- const parsed = new URL(url);
- origins.push(ensureTrailingSlash(parsed.origin));
- }
- catch {
- // ignore
- }
- for (const raw of extraOrigins ?? []) {
- const trimmed = raw.trim();
- if (!trimmed)
- continue;
- try {
- const parsed = new URL(trimmed);
- origins.push(ensureTrailingSlash(parsed.origin));
- }
- catch {
- // ignore malformed extras
- }
- }
- return Array.from(new Set(origins));
-}
-function ensureTrailingSlash(origin) {
- return origin.endsWith('/') ? origin : `${origin}/`;
-}
-//# sourceMappingURL=origins.js.map
\ No newline at end of file
diff --git a/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/util/origins.js.map b/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/util/origins.js.map
deleted file mode 100644
index f30490d..0000000
--- a/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/util/origins.js.map
+++ /dev/null
@@ -1 +0,0 @@
-{"version":3,"file":"origins.js","sourceRoot":"","sources":["../../src/util/origins.ts"],"names":[],"mappings":"AAAA,MAAM,UAAU,gBAAgB,CAAC,GAAW,EAAE,YAAuB;IACpE,MAAM,OAAO,GAAa,EAAE,CAAC;IAC7B,IAAI,CAAC;QACJ,MAAM,MAAM,GAAG,IAAI,GAAG,CAAC,GAAG,CAAC,CAAC;QAC5B,OAAO,CAAC,IAAI,CAAC,mBAAmB,CAAC,MAAM,CAAC,MAAM,CAAC,CAAC,CAAC;IAClD,CAAC;IAAC,MAAM,CAAC;QACR,SAAS;IACV,CAAC;IAED,KAAK,MAAM,GAAG,IAAI,YAAY,IAAI,EAAE,EAAE,CAAC;QACtC,MAAM,OAAO,GAAG,GAAG,CAAC,IAAI,EAAE,CAAC;QAC3B,IAAI,CAAC,OAAO;YAAE,SAAS;QACvB,IAAI,CAAC;YACJ,MAAM,MAAM,GAAG,IAAI,GAAG,CAAC,OAAO,CAAC,CAAC;YAChC,OAAO,CAAC,IAAI,CAAC,mBAAmB,CAAC,MAAM,CAAC,MAAM,CAAC,CAAC,CAAC;QAClD,CAAC;QAAC,MAAM,CAAC;YACR,0BAA0B;QAC3B,CAAC;IACF,CAAC;IAED,OAAO,KAAK,CAAC,IAAI,CAAC,IAAI,GAAG,CAAC,OAAO,CAAC,CAAC,CAAC;AACrC,CAAC;AAED,SAAS,mBAAmB,CAAC,MAAc;IAC1C,OAAO,MAAM,CAAC,QAAQ,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,GAAG,MAAM,GAAG,CAAC;AACrD,CAAC"}
\ No newline at end of file
diff --git a/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/util/runtime.d.ts b/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/util/runtime.d.ts
deleted file mode 100644
index 0effb84..0000000
--- a/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/util/runtime.d.ts
+++ /dev/null
@@ -1,2 +0,0 @@
-export declare function isBunRuntime(): boolean;
-//# sourceMappingURL=runtime.d.ts.map
\ No newline at end of file
diff --git a/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/util/runtime.d.ts.map b/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/util/runtime.d.ts.map
deleted file mode 100644
index e79fd7b..0000000
--- a/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/util/runtime.d.ts.map
+++ /dev/null
@@ -1 +0,0 @@
-{"version":3,"file":"runtime.d.ts","sourceRoot":"","sources":["../../src/util/runtime.ts"],"names":[],"mappings":"AAAA,wBAAgB,YAAY,IAAI,OAAO,CAKtC"}
\ No newline at end of file
diff --git a/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/util/runtime.js b/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/util/runtime.js
deleted file mode 100644
index c0f48b4..0000000
--- a/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/util/runtime.js
+++ /dev/null
@@ -1,8 +0,0 @@
-export function isBunRuntime() {
- // Bun: https://bun.sh/docs/runtime/environment-variables#bun-specific
- if (typeof process === 'undefined')
- return false;
- const bunVersion = process.versions.bun;
- return Boolean(typeof process.versions === 'object' && typeof bunVersion === 'string');
-}
-//# sourceMappingURL=runtime.js.map
\ No newline at end of file
diff --git a/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/util/runtime.js.map b/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/util/runtime.js.map
deleted file mode 100644
index 4c02d7a..0000000
--- a/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/dist/util/runtime.js.map
+++ /dev/null
@@ -1 +0,0 @@
-{"version":3,"file":"runtime.js","sourceRoot":"","sources":["../../src/util/runtime.ts"],"names":[],"mappings":"AAAA,MAAM,UAAU,YAAY;IAC3B,sEAAsE;IACtE,IAAI,OAAO,OAAO,KAAK,WAAW;QAAE,OAAO,KAAK,CAAC;IACjD,MAAM,UAAU,GAAI,OAAO,CAAC,QAAyC,CAAC,GAAG,CAAC;IAC1E,OAAO,OAAO,CAAC,OAAO,OAAO,CAAC,QAAQ,KAAK,QAAQ,IAAI,OAAO,UAAU,KAAK,QAAQ,CAAC,CAAC;AACxF,CAAC"}
\ No newline at end of file
diff --git a/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/package.json b/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/package.json
deleted file mode 100644
index ffef682..0000000
--- a/scripts/lib/vendor/bird-search/node_modules/@steipete/sweet-cookie/package.json
+++ /dev/null
@@ -1,40 +0,0 @@
-{
- "name": "@steipete/sweet-cookie",
- "version": "0.1.0",
- "description": "Inline-first browser cookie extraction for local tooling (no native addons).",
- "license": "MIT",
- "author": "Peter Steinberger",
- "type": "module",
- "main": "dist/index.js",
- "types": "dist/index.d.ts",
- "files": [
- "dist",
- "README.md",
- "LICENSE"
- ],
- "repository": {
- "type": "git",
- "url": "git+https://github.com/steipete/sweet-cookie.git",
- "directory": "packages/core"
- },
- "bugs": {
- "url": "https://github.com/steipete/sweet-cookie/issues"
- },
- "homepage": "https://github.com/steipete/sweet-cookie#readme",
- "exports": {
- ".": {
- "types": "./dist/index.d.ts",
- "import": "./dist/index.js"
- }
- },
- "publishConfig": {
- "access": "public"
- },
- "scripts": {
- "build": "node -e \"require('fs').rmSync('dist',{recursive:true,force:true})\" && tsc -p tsconfig.json",
- "typecheck": "tsc -p tsconfig.json --noEmit"
- },
- "engines": {
- "node": ">=22"
- }
-}
diff --git a/scripts/lib/websearch.py b/scripts/lib/websearch.py
deleted file mode 100644
index e7d779a..0000000
--- a/scripts/lib/websearch.py
+++ /dev/null
@@ -1,401 +0,0 @@
-"""WebSearch module for last30days skill.
-
-NOTE: WebSearch uses the assistant's built-in web search tool, which runs inside the host environment.
-Unlike Reddit/X which use external APIs, web search results are obtained by the assistant
-directly and passed to this module for normalization and scoring.
-
-The typical flow is:
-1. The assistant invokes its web search tool with the topic
-2. The assistant passes results to parse_websearch_results()
-3. Results are normalized into WebSearchItem objects
-"""
-
-import re
-from datetime import datetime, timedelta
-from typing import Any, Dict, List, Optional, Tuple
-from urllib.parse import urlparse
-
-from . import schema
-
-
-# Month name mappings for date parsing
-MONTH_MAP = {
- "jan": 1, "january": 1,
- "feb": 2, "february": 2,
- "mar": 3, "march": 3,
- "apr": 4, "april": 4,
- "may": 5,
- "jun": 6, "june": 6,
- "jul": 7, "july": 7,
- "aug": 8, "august": 8,
- "sep": 9, "sept": 9, "september": 9,
- "oct": 10, "october": 10,
- "nov": 11, "november": 11,
- "dec": 12, "december": 12,
-}
-
-
-def extract_date_from_url(url: str) -> Optional[str]:
- """Try to extract a date from URL path.
-
- Many sites embed dates in URLs like:
- - /2026/01/24/article-title
- - /2026-01-24/article
- - /blog/20260124/title
-
- Args:
- url: URL to parse
-
- Returns:
- Date string in YYYY-MM-DD format, or None
- """
- # Pattern 1: /YYYY/MM/DD/ (most common)
- match = re.search(r'/(\d{4})/(\d{2})/(\d{2})/', url)
- if match:
- year, month, day = match.groups()
- if 2020 <= int(year) <= 2030 and 1 <= int(month) <= 12 and 1 <= int(day) <= 31:
- return f"{year}-{month}-{day}"
-
- # Pattern 2: /YYYY-MM-DD/ or /YYYY-MM-DD-
- match = re.search(r'/(\d{4})-(\d{2})-(\d{2})[-/]', url)
- if match:
- year, month, day = match.groups()
- if 2020 <= int(year) <= 2030 and 1 <= int(month) <= 12 and 1 <= int(day) <= 31:
- return f"{year}-{month}-{day}"
-
- # Pattern 3: /YYYYMMDD/ (compact)
- match = re.search(r'/(\d{4})(\d{2})(\d{2})/', url)
- if match:
- year, month, day = match.groups()
- if 2020 <= int(year) <= 2030 and 1 <= int(month) <= 12 and 1 <= int(day) <= 31:
- return f"{year}-{month}-{day}"
-
- return None
-
-
-def extract_date_from_snippet(text: str) -> Optional[str]:
- """Try to extract a date from text snippet or title.
-
- Looks for patterns like:
- - January 24, 2026 or Jan 24, 2026
- - 24 January 2026
- - 2026-01-24
- - "3 days ago", "yesterday", "last week"
-
- Args:
- text: Text to parse
-
- Returns:
- Date string in YYYY-MM-DD format, or None
- """
- if not text:
- return None
-
- text_lower = text.lower()
-
- # Pattern 1: Month DD, YYYY (e.g., "January 24, 2026")
- match = re.search(
- r'\b(jan(?:uary)?|feb(?:ruary)?|mar(?:ch)?|apr(?:il)?|may|jun(?:e)?|'
- r'jul(?:y)?|aug(?:ust)?|sep(?:t(?:ember)?)?|oct(?:ober)?|nov(?:ember)?|dec(?:ember)?)'
- r'\s+(\d{1,2})(?:st|nd|rd|th)?,?\s*(\d{4})\b',
- text_lower
- )
- if match:
- month_str, day, year = match.groups()
- month = MONTH_MAP.get(month_str[:3])
- if month and 2020 <= int(year) <= 2030 and 1 <= int(day) <= 31:
- return f"{year}-{month:02d}-{int(day):02d}"
-
- # Pattern 2: DD Month YYYY (e.g., "24 January 2026")
- match = re.search(
- r'\b(\d{1,2})(?:st|nd|rd|th)?\s+'
- r'(jan(?:uary)?|feb(?:ruary)?|mar(?:ch)?|apr(?:il)?|may|jun(?:e)?|'
- r'jul(?:y)?|aug(?:ust)?|sep(?:t(?:ember)?)?|oct(?:ober)?|nov(?:ember)?|dec(?:ember)?)'
- r'\s+(\d{4})\b',
- text_lower
- )
- if match:
- day, month_str, year = match.groups()
- month = MONTH_MAP.get(month_str[:3])
- if month and 2020 <= int(year) <= 2030 and 1 <= int(day) <= 31:
- return f"{year}-{month:02d}-{int(day):02d}"
-
- # Pattern 3: YYYY-MM-DD (ISO format)
- match = re.search(r'\b(\d{4})-(\d{2})-(\d{2})\b', text)
- if match:
- year, month, day = match.groups()
- if 2020 <= int(year) <= 2030 and 1 <= int(month) <= 12 and 1 <= int(day) <= 31:
- return f"{year}-{month}-{day}"
-
- # Pattern 4: Relative dates ("3 days ago", "yesterday", etc.)
- today = datetime.now()
-
- if "yesterday" in text_lower:
- date = today - timedelta(days=1)
- return date.strftime("%Y-%m-%d")
-
- if "today" in text_lower:
- return today.strftime("%Y-%m-%d")
-
- # "N days ago"
- match = re.search(r'\b(\d+)\s*days?\s*ago\b', text_lower)
- if match:
- days = int(match.group(1))
- if days <= 60: # Reasonable range
- date = today - timedelta(days=days)
- return date.strftime("%Y-%m-%d")
-
- # "N hours ago" -> today
- match = re.search(r'\b(\d+)\s*hours?\s*ago\b', text_lower)
- if match:
- return today.strftime("%Y-%m-%d")
-
- # "last week" -> ~7 days ago
- if "last week" in text_lower:
- date = today - timedelta(days=7)
- return date.strftime("%Y-%m-%d")
-
- # "this week" -> ~3 days ago (middle of week)
- if "this week" in text_lower:
- date = today - timedelta(days=3)
- return date.strftime("%Y-%m-%d")
-
- return None
-
-
-def extract_date_signals(
- url: str,
- snippet: str,
- title: str,
-) -> Tuple[Optional[str], str]:
- """Extract date from any available signal.
-
- Tries URL first (most reliable), then snippet, then title.
-
- Args:
- url: Page URL
- snippet: Page snippet/description
- title: Page title
-
- Returns:
- Tuple of (date_string, confidence)
- - date from URL: 'high' confidence
- - date from snippet/title: 'med' confidence
- - no date found: None, 'low' confidence
- """
- # Try URL first (most reliable)
- url_date = extract_date_from_url(url)
- if url_date:
- return url_date, "high"
-
- # Try snippet
- snippet_date = extract_date_from_snippet(snippet)
- if snippet_date:
- return snippet_date, "med"
-
- # Try title
- title_date = extract_date_from_snippet(title)
- if title_date:
- return title_date, "med"
-
- return None, "low"
-
-
-# Domains to exclude (Reddit and X are handled separately)
-EXCLUDED_DOMAINS = {
- "reddit.com",
- "www.reddit.com",
- "old.reddit.com",
- "twitter.com",
- "www.twitter.com",
- "x.com",
- "www.x.com",
- "mobile.twitter.com",
-}
-
-
-def extract_domain(url: str) -> str:
- """Extract the domain from a URL.
-
- Args:
- url: Full URL
-
- Returns:
- Domain string (e.g., "medium.com")
- """
- try:
- parsed = urlparse(url)
- domain = parsed.netloc.lower()
- # Remove www. prefix for cleaner display
- if domain.startswith("www."):
- domain = domain[4:]
- return domain
- except Exception:
- return ""
-
-
-def is_excluded_domain(url: str) -> bool:
- """Check if URL is from an excluded domain (Reddit/X).
-
- Args:
- url: URL to check
-
- Returns:
- True if URL should be excluded
- """
- try:
- parsed = urlparse(url)
- domain = parsed.netloc.lower()
- return domain in EXCLUDED_DOMAINS
- except Exception:
- return False
-
-
-def parse_websearch_results(
- results: List[Dict[str, Any]],
- topic: str,
- from_date: str = "",
- to_date: str = "",
-) -> List[Dict[str, Any]]:
- """Parse WebSearch results into normalized format.
-
- This function expects results from Claude's WebSearch tool.
- Each result should have: title, url, snippet, and optionally date/relevance.
-
- Uses "Date Detective" approach:
- 1. Extract dates from URLs (high confidence)
- 2. Extract dates from snippets/titles (med confidence)
- 3. Hard filter: exclude items with verified old dates
- 4. Keep items with no date signals (with low confidence penalty)
-
- Args:
- results: List of WebSearch result dicts
- topic: Original search topic (for context)
- from_date: Start date for filtering (YYYY-MM-DD)
- to_date: End date for filtering (YYYY-MM-DD)
-
- Returns:
- List of normalized item dicts ready for WebSearchItem creation
- """
- items = []
-
- for i, result in enumerate(results):
- if not isinstance(result, dict):
- continue
-
- url = result.get("url", "")
- if not url:
- continue
-
- # Skip Reddit/X URLs (handled separately)
- if is_excluded_domain(url):
- continue
-
- title = str(result.get("title", "")).strip()
- snippet = str(result.get("snippet", result.get("description", ""))).strip()
-
- if not title and not snippet:
- continue
-
- # Use Date Detective to extract date signals
- date = result.get("date") # Use provided date if available
- date_confidence = "low"
-
- if date and re.match(r'^\d{4}-\d{2}-\d{2}$', str(date)):
- # Provided date is valid
- date_confidence = "med"
- else:
- # Try to extract date from URL/snippet/title
- extracted_date, confidence = extract_date_signals(url, snippet, title)
- if extracted_date:
- date = extracted_date
- date_confidence = confidence
-
- # Hard filter: if we found a date and it's too old, skip
- if date and from_date and date < from_date:
- continue # DROP - verified old content
-
- # Hard filter: if date is in the future, skip (parsing error)
- if date and to_date and date > to_date:
- continue # DROP - future date
-
- # Get relevance if provided, default to 0.5
- relevance = result.get("relevance", 0.5)
- try:
- relevance = min(1.0, max(0.0, float(relevance)))
- except (TypeError, ValueError):
- relevance = 0.5
-
- item = {
- "id": f"W{i+1}",
- "title": title[:200], # Truncate long titles
- "url": url,
- "source_domain": extract_domain(url),
- "snippet": snippet[:500], # Truncate long snippets
- "date": date,
- "date_confidence": date_confidence,
- "relevance": relevance,
- "why_relevant": str(result.get("why_relevant", "")).strip(),
- }
-
- items.append(item)
-
- return items
-
-
-def normalize_websearch_items(
- items: List[Dict[str, Any]],
- from_date: str,
- to_date: str,
-) -> List[schema.WebSearchItem]:
- """Convert parsed dicts to WebSearchItem objects.
-
- Args:
- items: List of parsed item dicts
- from_date: Start of date range (YYYY-MM-DD)
- to_date: End of date range (YYYY-MM-DD)
-
- Returns:
- List of WebSearchItem objects
- """
- result = []
-
- for item in items:
- web_item = schema.WebSearchItem(
- id=item["id"],
- title=item["title"],
- url=item["url"],
- source_domain=item["source_domain"],
- snippet=item["snippet"],
- date=item.get("date"),
- date_confidence=item.get("date_confidence", "low"),
- relevance=item.get("relevance", 0.5),
- why_relevant=item.get("why_relevant", ""),
- )
- result.append(web_item)
-
- return result
-
-
-def dedupe_websearch(items: List[schema.WebSearchItem]) -> List[schema.WebSearchItem]:
- """Remove duplicate WebSearch items.
-
- Deduplication is based on URL.
-
- Args:
- items: List of WebSearchItem objects
-
- Returns:
- Deduplicated list
- """
- seen_urls = set()
- result = []
-
- for item in items:
- # Normalize URL for comparison
- url_key = item.url.lower().rstrip("/")
- if url_key not in seen_urls:
- seen_urls.add(url_key)
- result.append(item)
-
- return result
diff --git a/scripts/lib/xai_x.py b/scripts/lib/xai_x.py
index b00e215..fdb044b 100644
--- a/scripts/lib/xai_x.py
+++ b/scripts/lib/xai_x.py
@@ -5,13 +5,24 @@ import re
import sys
from typing import Any, Dict, List, Optional
-from . import http
+from . import http, log
+
+
+def _safe_text(val) -> str:
+ """Extract text from string or localized object."""
+ if isinstance(val, str):
+ return val
+ if isinstance(val, dict):
+ return str(val.get("text", val.get("en", "")))
+ return str(val) if val is not None else ""
+
+
+def _log(msg: str):
+ log.source_log("xAI", msg, tty_only=False)
def _log_error(msg: str):
- """Log error to stderr."""
- sys.stderr.write(f"[X ERROR] {msg}\n")
- sys.stderr.flush()
+ log.source_log("xAI ERROR", msg, tty_only=False)
# xAI uses responses endpoint with Agent Tools API
XAI_RESPONSES_URL = "https://api.x.ai/v1/responses"
@@ -173,7 +184,7 @@ def parse_x_response(response: Dict[str, Any]) -> List[Dict[str, Any]]:
data = json.loads(json_match.group())
items = data.get("items", [])
except json.JSONDecodeError:
- pass
+ _log(f"Failed to parse xAI response JSON: {output_text[:200]}")
# Validate and clean items
clean_items = []
@@ -190,20 +201,20 @@ def parse_x_response(response: Dict[str, Any]) -> List[Dict[str, Any]]:
eng_raw = item.get("engagement")
if isinstance(eng_raw, dict):
engagement = {
- "likes": int(eng_raw.get("likes", 0)) if eng_raw.get("likes") else None,
- "reposts": int(eng_raw.get("reposts", 0)) if eng_raw.get("reposts") else None,
- "replies": int(eng_raw.get("replies", 0)) if eng_raw.get("replies") else None,
- "quotes": int(eng_raw.get("quotes", 0)) if eng_raw.get("quotes") else None,
+ "likes": int(eng_raw["likes"]) if eng_raw.get("likes") is not None else None,
+ "reposts": int(eng_raw["reposts"]) if eng_raw.get("reposts") is not None else None,
+ "replies": int(eng_raw["replies"]) if eng_raw.get("replies") is not None else None,
+ "quotes": int(eng_raw["quotes"]) if eng_raw.get("quotes") is not None else None,
}
clean_item = {
"id": f"X{i+1}",
- "text": str(item.get("text", "")).strip()[:500], # Truncate long text
+ "text": _safe_text(item.get("text", "")).strip()[:500], # Truncate long text
"url": url,
- "author_handle": str(item.get("author_handle", "")).strip().lstrip("@"),
+ "author_handle": _safe_text(item.get("author_handle", "")).strip().lstrip("@"),
"date": item.get("date"),
"engagement": engagement,
- "why_relevant": str(item.get("why_relevant", "")).strip(),
+ "why_relevant": _safe_text(item.get("why_relevant", "")).strip(),
"relevance": min(1.0, max(0.0, float(item.get("relevance", 0.5)))),
}
diff --git a/scripts/lib/youtube_yt.py b/scripts/lib/youtube_yt.py
index 12fee3b..d77c3a3 100644
--- a/scripts/lib/youtube_yt.py
+++ b/scripts/lib/youtube_yt.py
@@ -1,4 +1,4 @@
-"""YouTube search and transcript extraction via yt-dlp for /last30days v2.1.
+"""YouTube search and transcript extraction via yt-dlp for the v3.0.0 pipeline.
Uses yt-dlp (https://github.com/yt-dlp/yt-dlp) for both YouTube search and
transcript extraction. No API keys needed — just have yt-dlp installed.
@@ -23,34 +23,42 @@ from typing import Any, Dict, List, Optional, Set, Tuple
# Depth configurations: how many videos to search / transcribe
DEPTH_CONFIG = {
- "quick": 10,
- "default": 20,
+ "quick": 6,
+ "default": 8,
"deep": 40,
}
TRANSCRIPT_LIMITS = {
- "quick": 3,
- "default": 5,
+ "quick": 0,
+ "default": 2,
"deep": 8,
}
# Max words to keep from each transcript
TRANSCRIPT_MAX_WORDS = 5000
+from . import http, log
from .relevance import token_overlap_relevance as _compute_relevance
-def extract_transcript_highlights(transcript: str, topic: str, limit: int = 5) -> List[str]:
+def extract_transcript_highlights(transcript: str, topic: str, limit: int = 5) -> list[str]:
"""Extract quotable highlights from a YouTube transcript.
- Similar to reddit_enrich.extract_comment_insights() but for
- continuous speech-to-text rather than threaded comments.
+ Filters filler (subscribe, welcome back, etc.), scores sentences by
+ specificity (numbers, proper nouns, topic relevance), and returns
+ the top highlights.
"""
if not transcript:
return []
sentences = re.split(r'(?<=[.!?])\s+', transcript)
+ # Fallback for punctuation-free transcripts (common with auto-captions):
+ # chunk into ~20-word segments so they pass the 8-50 word filter.
+ if len(sentences) <= 1 and len(transcript.split()) > 50:
+ words = transcript.split()
+ sentences = [' '.join(words[i:i+20]) for i in range(0, len(words), 20)]
+
filler = [
r"^(hey |hi |what's up|welcome back|in today's video|don't forget to)",
r"(subscribe|like and comment|hit the bell|check out the link|down below)",
@@ -87,9 +95,7 @@ def extract_transcript_highlights(transcript: str, topic: str, limit: int = 5) -
def _log(msg: str):
- """Log to stderr."""
- sys.stderr.write(f"[YouTube] {msg}\n")
- sys.stderr.flush()
+ log.source_log("YouTube", msg, tty_only=False)
def is_ytdlp_installed() -> bool:
@@ -113,10 +119,75 @@ def _extract_core_subject(topic: str) -> str:
'recommendations', 'advice',
'prompt', 'prompts', 'prompting',
'methods', 'strategies', 'approaches',
+ # Temporal/meta words — planner generates these but they don't
+ # appear in YouTube titles, so strip them for better search.
+ 'last', 'days', 'recent', 'recently', 'month', 'week',
+ 'january', 'february', 'march', 'april', 'may', 'june',
+ 'july', 'august', 'september', 'october', 'november', 'december',
+ '2025', '2026', '2027',
+ 'music', 'public', 'appearances', 'developments', 'discussions', 'coverage',
})
return extract_core_subject(topic, noise=_YT_NOISE)
+def _infer_query_intent(topic: str) -> str:
+ """Tiny local intent classifier for YouTube query expansion."""
+ text = topic.lower().strip()
+ if re.search(r"\b(vs|versus|compare|difference between)\b", text):
+ return "comparison"
+ if re.search(r"\b(how to|tutorial|guide|setup|step by step|deploy|install|configure|troubleshoot|error|fix|debug)\b", text):
+ return "how_to"
+ if re.search(r"\b(thoughts on|worth it|should i|opinion|review)\b", text):
+ return "opinion"
+ if re.search(r"\b(pricing|feature|features|best .* for)\b", text):
+ return "product"
+ return "breaking_news"
+
+
+def expand_youtube_queries(topic: str, depth: str) -> List[str]:
+ """Generate multiple YouTube search queries from a topic.
+
+ Mirrors reddit.py's expand_reddit_queries() pattern:
+ 1. Extract core subject (strip noise words)
+ 2. Include original topic if different from core
+ 3. Add intent-specific OR-joined content-type variants
+ 4. Cap by depth: 1 for quick, 2 for default, 3 for deep
+
+ Returns 1-3 query strings depending on depth.
+ """
+ core = _extract_core_subject(topic)
+ queries = [core]
+
+ # Include cleaned original topic as variant if different from core
+ original_clean = topic.strip().rstrip('?!.')
+ if core.lower() != original_clean.lower() and len(original_clean.split()) <= 8:
+ queries.append(original_clean)
+
+ qtype = _infer_query_intent(topic)
+
+ # Intent-specific YouTube content-type variants
+ if qtype == "opinion":
+ queries.append(f"{core} review OR reaction OR breakdown")
+ elif qtype == "product":
+ queries.append(f"{core} review OR comparison OR unboxing")
+ elif qtype == "comparison":
+ queries.append(f"{core} vs OR compared OR head to head")
+ elif qtype == "how_to":
+ queries.append(f"{core} tutorial OR guide OR explained")
+ else:
+ # breaking_news / general — YouTube content types
+ queries.append(f"{core} review OR reaction OR breakdown")
+
+ # Deep depth: add full-length content variant
+ if depth == "deep":
+ queries.append(f"{core} full OR complete OR official")
+
+ # Cap by depth budget
+ caps = {"quick": 1, "default": 2, "deep": 3}
+ cap = caps.get(depth, 2)
+ return queries[:cap]
+
+
def search_youtube(
topic: str,
from_date: str,
@@ -195,9 +266,9 @@ def search_youtube(
continue
video_id = video.get("id", "")
- view_count = video.get("view_count") or 0
- like_count = video.get("like_count") or 0
- comment_count = video.get("comment_count") or 0
+ view_count = video.get("view_count") if video.get("view_count") is not None else 0
+ like_count = video.get("like_count") if video.get("like_count") is not None else 0
+ comment_count = video.get("comment_count") if video.get("comment_count") is not None else 0
upload_date = video.get("upload_date", "") # YYYYMMDD
# Convert YYYYMMDD to YYYY-MM-DD
@@ -205,6 +276,7 @@ def search_youtube(
if upload_date and len(upload_date) == 8:
date_str = f"{upload_date[:4]}-{upload_date[4:6]}-{upload_date[6:8]}"
+ description = str(video.get("description", ""))[:500]
items.append({
"video_id": video_id,
"title": video.get("title", ""),
@@ -217,8 +289,9 @@ def search_youtube(
"comments": comment_count,
},
"duration": video.get("duration"),
- "relevance": _compute_relevance(core_topic, video.get("title", "")),
+ "relevance": _compute_relevance(core_topic, f"{video.get('title', '')} {description}"),
"why_relevant": f"YouTube: {video.get('title', core_topic)[:60]}",
+ "description": description,
})
# Soft date filter: prefer recent items but fall back to all if too few
@@ -433,11 +506,15 @@ def fetch_transcript(video_id: str, temp_dir: str) -> Optional[str]:
raw_vtt = None
if is_ytdlp_installed():
raw_vtt = _fetch_transcript_ytdlp(video_id, temp_dir)
+ if not raw_vtt:
+ _log(f"yt-dlp transcript failed for {video_id}, trying direct HTTP fallback")
+ raw_vtt = _fetch_transcript_direct(video_id)
else:
_log("yt-dlp not installed, using direct HTTP transcript fetch")
raw_vtt = _fetch_transcript_direct(video_id)
if not raw_vtt:
+ _log(f"No transcript available for {video_id} (no captions found)")
return None
transcript = _clean_vtt(raw_vtt)
@@ -479,11 +556,16 @@ def fetch_transcripts_parallel(
vid = futures[future]
try:
results[vid] = future.result()
- except Exception:
+ except (OSError, subprocess.SubprocessError) as exc:
+ _log(f"Transcript fetch error for {vid}: {exc}")
+ results[vid] = None
+ except Exception as exc:
+ _log(f"Unexpected transcript error for {vid}: {type(exc).__name__}: {exc}")
results[vid] = None
got = sum(1 for v in results.values() if v)
- _log(f"Got transcripts for {got}/{len(video_ids)} videos")
+ errors = sum(1 for v in results.values() if v is None)
+ _log(f"Got transcripts for {got}/{len(video_ids)} videos ({errors} failed)")
return results
@@ -495,6 +577,9 @@ def search_and_transcribe(
) -> Dict[str, Any]:
"""Full YouTube search: find videos, then fetch transcripts for top results.
+ Uses expand_youtube_queries() to generate multiple search queries,
+ runs yt-dlp for each, and merges/deduplicates results by video ID.
+
Args:
topic: Search topic
from_date: Start date (YYYY-MM-DD)
@@ -504,17 +589,37 @@ def search_and_transcribe(
Returns:
Dict with 'items' list. Each item has a 'transcript_snippet' field.
"""
- # Step 1: Search
- search_result = search_youtube(topic, from_date, to_date, depth)
- items = search_result.get("items", [])
+ # Step 1: Multi-query search — run yt-dlp for each expanded query
+ queries = expand_youtube_queries(topic, depth)
+ seen_ids: Set[str] = set()
+ items: List[Dict[str, Any]] = []
+ for q in queries:
+ search_result = search_youtube(q, from_date, to_date, depth)
+ for item in search_result.get("items", []):
+ vid = item.get("video_id", "")
+ if vid and vid not in seen_ids:
+ seen_ids.add(vid)
+ items.append(item)
+
+ # Sort merged results by views descending
+ items.sort(key=lambda x: x.get("engagement", {}).get("views", 0), reverse=True)
if not items:
return search_result
- # Step 2: Fetch transcripts for top N by views
+ # Step 2: Fetch transcripts for top videos by views.
+ # Try more candidates than the limit because some videos (music videos,
+ # short clips) lack captions. Attempt up to 3x the limit so we have a
+ # good chance of reaching the target number of successful transcripts.
transcript_limit = TRANSCRIPT_LIMITS.get(depth, TRANSCRIPT_LIMITS["default"])
- top_ids = [item["video_id"] for item in items[:transcript_limit]]
- transcripts = fetch_transcripts_parallel(top_ids)
+ transcripts: Dict[str, Optional[str]] = {}
+ if transcript_limit > 0:
+ attempt_count = min(len(items), transcript_limit * 3)
+ candidate_ids = [item["video_id"] for item in items[:attempt_count]]
+ _log(f"Fetching transcripts for up to {attempt_count} videos (target: {transcript_limit}): {candidate_ids}")
+ transcripts = fetch_transcripts_parallel(candidate_ids)
+ else:
+ _log(f"Transcript limit is 0 for depth={depth}, skipping transcript fetch")
# Step 3: Attach transcripts and extract highlights
core_topic = _extract_core_subject(topic)
@@ -536,3 +641,335 @@ def parse_youtube_response(response: Dict[str, Any]) -> List[Dict[str, Any]]:
List of item dicts ready for normalization.
"""
return response.get("items", [])
+
+
+# ---------------------------------------------------------------------------
+# ScrapeCreators YouTube API support
+# ---------------------------------------------------------------------------
+
+SCRAPECREATORS_YT_BASE = "https://api.scrapecreators.com/v1/youtube"
+
+try:
+ import requests as _requests
+except ImportError:
+ _requests = None
+
+
+def _sc_headers(token: str) -> Dict[str, str]:
+ """Build ScrapeCreators request headers."""
+ return {
+ "x-api-key": token,
+ "Content-Type": "application/json",
+ }
+
+
+def _total_engagement(item: Dict[str, Any]) -> int:
+ """Combined engagement score for ranking which videos to enrich."""
+ eng = item.get("engagement", {})
+ views = eng.get("views", 0) or 0
+ likes = eng.get("likes", 0) or 0
+ comments = eng.get("comments", 0) or 0
+ return views + likes + comments
+
+
+def enrich_with_comments(
+ items: List[Dict[str, Any]],
+ token: str,
+ max_videos: int = 3,
+ max_comments: int = 5,
+) -> List[Dict[str, Any]]:
+ """Enrich top YouTube videos with comment data from ScrapeCreators.
+
+ For the top N videos by engagement, fetches comments via the SC API
+ and attaches them as a ``top_comments`` field on each item.
+
+ Args:
+ items: YouTube items from search_and_transcribe() or search_youtube_sc()
+ token: ScrapeCreators API key
+ max_videos: How many videos to enrich with comments
+ max_comments: Max comments to keep per video
+
+ Returns:
+ Items list (mutated in place) with top_comments added to enriched items.
+ """
+ if not items or not token or max_videos <= 0:
+ return items
+
+ ranked = sorted(items, key=_total_engagement, reverse=True)
+ top_items = ranked[:max_videos]
+ _log(f"Enriching comments for {len(top_items)} YouTube videos")
+
+ enriched_count = 0
+ for item in top_items:
+ video_id = item.get("video_id", "")
+ if not video_id:
+ continue
+ try:
+ comments = _fetch_video_comments(video_id, token, max_comments)
+ if comments:
+ item["top_comments"] = comments
+ enriched_count += 1
+ except Exception as exc:
+ _log(f"Comment enrichment failed for {video_id}: {exc}")
+
+ _log(f"Enriched {enriched_count}/{len(top_items)} videos with comments")
+ return items
+
+
+def _fetch_video_comments(
+ video_id: str,
+ token: str,
+ max_comments: int = 5,
+) -> List[Dict[str, Any]]:
+ """Fetch comments for a single YouTube video via ScrapeCreators.
+
+ Args:
+ video_id: YouTube video ID
+ token: ScrapeCreators API key
+ max_comments: Maximum comments to return
+
+ Returns:
+ List of comment dicts with author, text, likes, date.
+ """
+ if not _requests:
+ try:
+ from urllib.parse import urlencode
+ params = urlencode({"id": video_id})
+ url = f"{SCRAPECREATORS_YT_BASE}/video/comments?{params}"
+ headers = _sc_headers(token)
+ headers["User-Agent"] = http.USER_AGENT
+ data = http.get(url, headers=headers, timeout=30, retries=2)
+ except Exception as exc:
+ _log(f"Comment fetch error (urllib) for {video_id}: {exc}")
+ return []
+ else:
+ try:
+ resp = _requests.get(
+ f"{SCRAPECREATORS_YT_BASE}/video/comments",
+ params={"id": video_id},
+ headers=_sc_headers(token),
+ timeout=30,
+ )
+ resp.raise_for_status()
+ data = resp.json()
+ except Exception as exc:
+ _log(f"Comment fetch error for {video_id}: {exc}")
+ return []
+
+ raw_comments = data.get("comments", data.get("data", []))
+ comments = []
+ for c in raw_comments[:max_comments]:
+ text = c.get("text") or c.get("body") or c.get("content", "")
+ if not text:
+ continue
+ comments.append({
+ "author": c.get("author") or c.get("author_name", ""),
+ "text": text[:400],
+ "likes": c.get("likes") or c.get("vote_count", 0),
+ "date": c.get("date") or c.get("published_at", ""),
+ })
+
+ return comments
+
+
+def search_youtube_sc(
+ topic: str,
+ from_date: str,
+ to_date: str,
+ depth: str = "default",
+ token: str = None,
+) -> Dict[str, Any]:
+ """Search YouTube via ScrapeCreators API (fallback when yt-dlp is unavailable).
+
+ Uses SC keyword search to find videos and SC transcript endpoint to
+ fetch transcripts. Called by pipeline.py when yt-dlp fails.
+
+ Args:
+ topic: Search topic
+ from_date: Start date (YYYY-MM-DD)
+ to_date: End date (YYYY-MM-DD)
+ depth: 'quick', 'default', or 'deep'
+ token: ScrapeCreators API key
+
+ Returns:
+ Dict with 'items' list of video metadata dicts.
+ """
+ if not token:
+ return {"items": [], "error": "No SCRAPECREATORS_API_KEY configured"}
+
+ count = DEPTH_CONFIG.get(depth, DEPTH_CONFIG["default"])
+ core_topic = _extract_core_subject(topic)
+ _log(f"Searching YouTube via ScrapeCreators for '{core_topic}' (depth={depth})")
+
+ # Step 1: Search
+ raw_items = _sc_youtube_search(core_topic, token)
+ if not raw_items:
+ _log("SC YouTube search returned 0 results")
+ return {"items": []}
+
+ # Parse into normalized items
+ items = []
+ for i, raw in enumerate(raw_items[:count]):
+ video_id = (
+ raw.get("id") or raw.get("video_id") or raw.get("videoId") or ""
+ )
+ title = raw.get("title", "")
+ channel = raw.get("channel") or raw.get("channel_name") or raw.get("uploader", "")
+ description = str(raw.get("description", ""))[:500]
+ view_count = raw.get("view_count") or raw.get("views", 0)
+ like_count = raw.get("like_count") or raw.get("likes", 0)
+ comment_count = raw.get("comment_count") or raw.get("comments", 0)
+
+ # Date: try multiple field names
+ date_str = raw.get("upload_date") or raw.get("date") or raw.get("published_at", "")
+ if date_str and len(date_str) == 8 and date_str.isdigit():
+ date_str = f"{date_str[:4]}-{date_str[4:6]}-{date_str[6:8]}"
+ elif date_str and "T" in date_str:
+ date_str = date_str[:10]
+
+ url = raw.get("url", "")
+ if not url and video_id:
+ url = f"https://www.youtube.com/watch?v={video_id}"
+
+ items.append({
+ "video_id": video_id,
+ "title": title,
+ "url": url,
+ "channel_name": channel,
+ "date": date_str if date_str else None,
+ "engagement": {
+ "views": view_count or 0,
+ "likes": like_count or 0,
+ "comments": comment_count or 0,
+ },
+ "duration": raw.get("duration"),
+ "relevance": _compute_relevance(core_topic, f"{title} {description}"),
+ "why_relevant": f"YouTube: {title[:60]}" if title else f"YouTube: {core_topic}",
+ "description": description,
+ })
+
+ # Soft date filter
+ recent = [i for i in items if i["date"] and i["date"] >= from_date]
+ if len(recent) >= 3:
+ items = recent
+ _log(f"Found {len(items)} videos within date range")
+ else:
+ _log(f"Found {len(items)} videos ({len(recent)} within date range, keeping all)")
+
+ # Sort by views
+ items.sort(key=lambda x: x["engagement"]["views"], reverse=True)
+
+ # Step 2: Fetch transcripts for top videos
+ transcript_limit = TRANSCRIPT_LIMITS.get(depth, TRANSCRIPT_LIMITS["default"])
+ if transcript_limit > 0 and items:
+ attempt_count = min(len(items), transcript_limit * 3)
+ _log(f"Fetching SC transcripts for up to {attempt_count} videos (target: {transcript_limit})")
+ for item in items[:attempt_count]:
+ vid = item["video_id"]
+ if not vid:
+ continue
+ transcript = _sc_fetch_transcript(vid, token)
+ item["transcript_snippet"] = transcript or ""
+ item["transcript_highlights"] = extract_transcript_highlights(
+ transcript or "", core_topic,
+ )
+ else:
+ for item in items:
+ item["transcript_snippet"] = ""
+ item["transcript_highlights"] = []
+
+ _log(f"SC YouTube: {len(items)} videos returned")
+ return {"items": items}
+
+
+def _sc_youtube_search(keyword: str, token: str) -> List[Dict[str, Any]]:
+ """Call ScrapeCreators YouTube search endpoint.
+
+ Args:
+ keyword: Search keyword
+ token: ScrapeCreators API key
+
+ Returns:
+ List of raw video dicts from the API.
+ """
+ if not _requests:
+ try:
+ from urllib.parse import urlencode
+ params = urlencode({"keyword": keyword})
+ url = f"{SCRAPECREATORS_YT_BASE}/search?{params}"
+ headers = _sc_headers(token)
+ headers["User-Agent"] = http.USER_AGENT
+ data = http.get(url, headers=headers, timeout=30, retries=2)
+ return data.get("videos", data.get("data", data.get("items", [])))
+ except Exception as exc:
+ _log(f"SC YouTube search error (urllib): {exc}")
+ return []
+
+ try:
+ resp = _requests.get(
+ f"{SCRAPECREATORS_YT_BASE}/search",
+ params={"keyword": keyword},
+ headers=_sc_headers(token),
+ timeout=30,
+ )
+ resp.raise_for_status()
+ data = resp.json()
+ return data.get("videos", data.get("data", data.get("items", [])))
+ except Exception as exc:
+ _log(f"SC YouTube search error: {exc}")
+ return []
+
+
+def _sc_fetch_transcript(video_id: str, token: str) -> Optional[str]:
+ """Fetch transcript for a YouTube video via ScrapeCreators.
+
+ Args:
+ video_id: YouTube video ID
+ token: ScrapeCreators API key
+
+ Returns:
+ Plaintext transcript string, or None if unavailable.
+ """
+ if not _requests:
+ try:
+ from urllib.parse import urlencode
+ params = urlencode({"id": video_id})
+ url = f"{SCRAPECREATORS_YT_BASE}/video/transcript?{params}"
+ headers = _sc_headers(token)
+ headers["User-Agent"] = http.USER_AGENT
+ data = http.get(url, headers=headers, timeout=30, retries=2)
+ except Exception as exc:
+ _log(f"SC transcript error (urllib) for {video_id}: {exc}")
+ return None
+ else:
+ try:
+ resp = _requests.get(
+ f"{SCRAPECREATORS_YT_BASE}/video/transcript",
+ params={"id": video_id},
+ headers=_sc_headers(token),
+ timeout=30,
+ )
+ if resp.status_code != 200:
+ _log(f"SC transcript returned {resp.status_code} for {video_id}")
+ return None
+ data = resp.json()
+ except Exception as exc:
+ _log(f"SC transcript error for {video_id}: {exc}")
+ return None
+
+ transcript = data.get("transcript")
+ if not transcript:
+ return None
+
+ if isinstance(transcript, list):
+ transcript = " ".join(str(s) for s in transcript)
+
+ # Clean VTT formatting if present
+ transcript = _clean_vtt(transcript)
+
+ # Truncate to max words
+ words = transcript.split()
+ if len(words) > TRANSCRIPT_MAX_WORDS:
+ transcript = " ".join(words[:TRANSCRIPT_MAX_WORDS]) + "..."
+
+ return transcript if transcript else None
diff --git a/scripts/store.py b/scripts/store.py
index 02552ef..3994639 100644
--- a/scripts/store.py
+++ b/scripts/store.py
@@ -18,6 +18,11 @@ from datetime import datetime, timedelta
from pathlib import Path
from typing import Any, Dict, List, Optional
+SCRIPT_DIR = Path(__file__).parent.resolve()
+sys.path.insert(0, str(SCRIPT_DIR))
+
+from lib import schema
+
DB_DIR = Path.home() / ".local" / "share" / "last30days"
DB_PATH = DB_DIR / "research.db"
@@ -128,9 +133,7 @@ INSERT OR IGNORE INTO settings (key, value) VALUES ('default_schedule', '0 8 * *
"""
# Future migrations keyed by version number
-MIGRATIONS: Dict[int, str] = {
- # 2: "ALTER TABLE findings ADD COLUMN tags TEXT DEFAULT '[]';",
-}
+MIGRATIONS: Dict[int, str] = {}
def _connect(db_path: Optional[Path] = None) -> sqlite3.Connection:
@@ -577,6 +580,78 @@ def get_trending(days: int = 7) -> List[Dict[str, Any]]:
conn.close()
+def finding_from_candidate(candidate: schema.Candidate) -> Dict[str, Any]:
+ """Convert a ranked candidate into a persisted finding."""
+ primary_item = schema.candidate_primary_item(candidate)
+ corroborating_sources = [
+ source for source in schema.candidate_sources(candidate)
+ if source and source != candidate.source
+ ]
+ summary = candidate.explanation or candidate.snippet or ""
+ if corroborating_sources:
+ prefix = f"Also seen in: {', '.join(corroborating_sources)}."
+ summary = f"{prefix} {summary}".strip()
+ body = (
+ primary_item.body
+ if primary_item and primary_item.body
+ else candidate.snippet or candidate.title
+ )
+ author = primary_item.author if primary_item and primary_item.author else ""
+ return {
+ "source": candidate.source or "unknown",
+ "source_url": candidate.url,
+ "source_title": candidate.title,
+ "author": author,
+ "content": body,
+ "summary": summary,
+ "engagement_score": candidate.engagement or 0,
+ "relevance_score": candidate.final_score or candidate.rerank_score or candidate.local_relevance,
+ }
+
+
+def findings_from_report(
+ report: schema.Report,
+ *,
+ limit: Optional[int] = None,
+) -> List[Dict[str, Any]]:
+ """Convert report into persisted findings.
+
+ Uses ranked candidates (post-rerank) when available for quality scores and explanations.
+ Supplements with raw items from items_by_source for HN/PM that didn't rank highly
+ but are valuable for watchlist persistence.
+ """
+ findings = []
+ seen_urls = set()
+
+ # Phase 1: Process ranked candidates (high-quality data with explanations and corroboration)
+ for candidate in report.ranked_candidates:
+ finding = finding_from_candidate(candidate)
+ findings.append(finding)
+ seen_urls.add(candidate.url)
+
+ # Phase 2: Add HN/PM items not already captured in ranked candidates
+ for source_name in ["hackernews", "polymarket"]:
+ if source_name not in report.items_by_source:
+ continue
+ for item in report.items_by_source[source_name]:
+ if item.url in seen_urls:
+ continue # Already captured with rich data
+ findings.append({
+ "source": source_name,
+ "source_url": item.url,
+ "source_title": item.title,
+ "author": item.author or "",
+ "content": item.body or "",
+ "summary": item.snippet or (item.body[:500] if item.body else ""),
+ "engagement_score": item.engagement_score or 0.0,
+ "relevance_score": item.local_relevance or 0.5,
+ })
+ seen_urls.add(item.url)
+
+ # Apply global limit after collecting all findings (fix: was per-source, now global)
+ return findings[:limit] if limit is not None else findings
+
+
# --- CLI interface ---
diff --git a/scripts/sync.sh b/scripts/sync.sh
index e47eb74..a7f1dde 100755
--- a/scripts/sync.sh
+++ b/scripts/sync.sh
@@ -6,45 +6,64 @@ set -euo pipefail
SRC="$(cd "$(dirname "$0")/.." && pwd)"
echo "Source: $SRC"
-TARGETS=(
- "$HOME/.claude/skills/last30days"
+COMMON_TARGETS=(
+ # Claude Code plugin cache: marketplace installs overwrite on update,
+ # but local development needs the cache kept in sync with the repo.
+ # Do NOT add ~/.claude/skills/last30days - it creates a duplicate
+ # /last30days-3 in the slash command menu alongside the plugin version.
+ "$HOME/.claude/plugins/cache/last30days-skill-private/last30days-3/3.0.0-alpha"
+ "$HOME/.claude/plugins/cache/last30days-skill-private/last30days-3-nogem/3.0.0-nogem"
"$HOME/.agents/skills/last30days"
"$HOME/.codex/skills/last30days"
)
+OPENCLAW_TARGET="$HOME/.openclaw/skills/last30days"
+
+sync_target() {
+ local target="$1"
+ local skill_md="$2"
-for t in "${TARGETS[@]}"; do
echo ""
- echo "--- Syncing to $t ---"
- mkdir -p "$t/scripts/lib"
+ echo "--- Syncing to $target ---"
+ mkdir -p "$target/scripts/lib" "$target/variants/open/references"
- cp "$SRC/SKILL.md" "$t/"
+ cp "$skill_md" "$target/SKILL.md"
- # Main script + lib modules (rsync handles identical files gracefully)
- rsync -a "$SRC/scripts/last30days.py" "$t/scripts/"
- rsync -a "$SRC/scripts/lib/"*.py "$t/scripts/lib/"
+ rsync -a \
+ "$SRC/scripts/last30days.py" \
+ "$SRC/scripts/watchlist.py" \
+ "$SRC/scripts/briefing.py" \
+ "$SRC/scripts/store.py" \
+ "$target/scripts/"
+ rsync -a "$SRC/scripts/lib/"*.py "$target/scripts/lib/"
+ rsync -a "$SRC/variants/open/" "$target/variants/open/"
- # Vendor directory (bird-search CLI)
if [ -d "$SRC/scripts/lib/vendor" ]; then
- rsync -a "$SRC/scripts/lib/vendor" "$t/scripts/lib/"
+ rsync -a "$SRC/scripts/lib/vendor" "$target/scripts/lib/"
fi
- # Fixtures
if [ -d "$SRC/fixtures" ]; then
- mkdir -p "$t/fixtures"
- rsync -a "$SRC/fixtures/" "$t/fixtures/"
+ mkdir -p "$target/fixtures"
+ rsync -a "$SRC/fixtures/" "$target/fixtures/"
fi
- # Count and report
- mod_count=$(ls "$t/scripts/lib/"*.py 2>/dev/null | wc -l | tr -d ' ')
+ mod_count=$(ls "$target/scripts/lib/"*.py 2>/dev/null | wc -l | tr -d ' ')
echo " Copied $mod_count modules"
- # Verify imports
- if (cd "$t/scripts" && python3 -c "from lib import youtube_yt, bird_x, render, ui; print(' Import check: OK')" 2>&1); then
+ if (
+ cd "$target/scripts" &&
+ python3 -c "import briefing, store, watchlist; from lib import youtube_yt, bird_x, render, ui; print(' Import check: OK')"
+ ); then
true
else
echo " Import check FAILED"
fi
+}
+
+for t in "${COMMON_TARGETS[@]}"; do
+ sync_target "$t" "$SRC/SKILL.md"
done
+sync_target "$OPENCLAW_TARGET" "$SRC/variants/open/SKILL.md"
+
echo ""
echo "Sync complete."
diff --git a/scripts/test_device_auth.py b/scripts/test_device_auth.py
new file mode 100644
index 0000000..33bc02f
--- /dev/null
+++ b/scripts/test_device_auth.py
@@ -0,0 +1,128 @@
+#!/usr/bin/env python3
+"""Test ScrapeCreators GitHub device auth flow from the CLI.
+
+Usage:
+ python3 scripts/test_device_auth.py
+
+Flow:
+ 1. Starts device code request
+ 2. Shows user code + opens GitHub auth URL in browser
+ 3. Polls for token until you complete auth
+ 4. Fetches your profile and prints your API key
+"""
+
+import json
+import sys
+import time
+import webbrowser
+from urllib.request import Request, urlopen
+from urllib.error import HTTPError, URLError
+
+BASE = "https://api.scrapecreators.com/v1/github/device"
+
+
+def _post(url, data=None):
+ body = json.dumps(data).encode() if data else None
+ req = Request(url, data=body, method="POST")
+ req.add_header("Content-Type", "application/json")
+ with urlopen(req, timeout=15) as resp:
+ return json.loads(resp.read())
+
+
+def _get(url, token):
+ req = Request(url)
+ req.add_header("Authorization", f"Bearer {token}")
+ with urlopen(req, timeout=15) as resp:
+ return json.loads(resp.read())
+
+
+def main():
+ # Step 1: Start device flow
+ print("Starting ScrapeCreators GitHub device auth...\n")
+ try:
+ code_resp = _post(f"{BASE}/code")
+ except (HTTPError, URLError) as e:
+ print(f"Failed to start device flow: {e}")
+ sys.exit(1)
+
+ device_code = code_resp.get("device_code")
+ user_code = code_resp.get("user_code")
+ verification_uri = code_resp.get("verification_uri")
+ interval = code_resp.get("interval", 5)
+ expires_in = code_resp.get("expires_in", 900)
+
+ if not device_code or not user_code:
+ print(f"Unexpected response: {json.dumps(code_resp, indent=2)}")
+ sys.exit(1)
+
+ print(f"Your code: {user_code}")
+ print(f"Open: {verification_uri}")
+ print(f"Expires in: {expires_in}s\n")
+
+ # Open browser
+ if verification_uri:
+ webbrowser.open(verification_uri)
+ print("Opened browser. Enter the code above, then authorize.\n")
+
+ # Step 2: Poll for token
+ print("Waiting for authorization", end="", flush=True)
+ deadline = time.time() + expires_in
+ access_token = None
+
+ while time.time() < deadline:
+ time.sleep(interval)
+ print(".", end="", flush=True)
+ try:
+ token_resp = _post(f"{BASE}/token", {"device_code": device_code})
+ except HTTPError as e:
+ # Some APIs return 4xx while pending
+ if e.code in (400, 403, 428):
+ continue
+ print(f"\nPoll error: {e}")
+ sys.exit(1)
+ except URLError:
+ continue
+
+ if token_resp.get("access_token"):
+ access_token = token_resp["access_token"]
+ break
+
+ # Check for explicit error states
+ error = token_resp.get("error")
+ if error == "authorization_pending" or error == "slow_down":
+ if error == "slow_down":
+ interval = min(interval + 2, 30)
+ continue
+ if error in ("expired_token", "access_denied"):
+ print(f"\n\nAuth failed: {error}")
+ sys.exit(1)
+
+ if not access_token:
+ print("\n\nTimed out waiting for authorization.")
+ sys.exit(1)
+
+ print(f"\n\nAuthorized! Access token: {access_token[:12]}...\n")
+
+ # Step 3: Fetch profile
+ print("Fetching profile...")
+ try:
+ profile = _get(f"{BASE}/profile", access_token)
+ except (HTTPError, URLError) as e:
+ print(f"Failed to fetch profile: {e}")
+ print(f"(access_token was: {access_token})")
+ sys.exit(1)
+
+ print(f"\nProfile response:\n{json.dumps(profile, indent=2)}\n")
+
+ api_key = profile.get("api_key")
+ if api_key:
+ print("=" * 50)
+ print(f"Your ScrapeCreators API key: {api_key}")
+ print("=" * 50)
+ print(f"\nTo use it: echo 'SCRAPECREATORS_API_KEY={api_key}' >> ~/.config/last30days/.env")
+ else:
+ print("No api_key in profile response. Full response printed above.")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/scripts/verify_v3.py b/scripts/verify_v3.py
new file mode 100644
index 0000000..7cbc193
--- /dev/null
+++ b/scripts/verify_v3.py
@@ -0,0 +1,183 @@
+#!/usr/bin/env python3
+"""Run the v3 verification bundle for last30days."""
+
+from __future__ import annotations
+
+import argparse
+import json
+import os
+import statistics
+import subprocess
+import sys
+import time
+from pathlib import Path
+
+
+REPO_ROOT = Path(__file__).resolve().parent.parent
+PYTHON = sys.executable
+
+SMOKE_TOPIC = "openclaw skills"
+SMOKE_CASES = [
+ ("gemini", ["--quick", "--search=grounding,hackernews"]),
+ ("openai", ["--quick", "--search=reddit,hackernews"]),
+ ("xai", ["--quick", "--search=reddit,hackernews"]),
+ ("auto", ["--quick", "--search=reddit,grounding,hackernews"]),
+]
+
+LATENCY_TOPICS = [
+ "openclaw skills",
+ "codex vs claude code",
+ "anthropic odds",
+]
+LATENCY_PROFILES = [
+ ("quick", ["--quick", "--search=grounding,hackernews"]),
+ ("default", ["--search=grounding,hackernews"]),
+ ("deep", ["--deep", "--search=grounding,hackernews"]),
+]
+
+
+def run_command(cmd: list[str], *, env: dict[str, str] | None = None, timeout: int = 600) -> subprocess.CompletedProcess[str]:
+ return subprocess.run(
+ cmd,
+ cwd=REPO_ROOT,
+ env=env,
+ text=True,
+ capture_output=True,
+ timeout=timeout,
+ check=True,
+ )
+
+
+def verify_unit() -> dict[str, str]:
+ run_command([PYTHON, "-m", "unittest", "discover", "-s", "tests", "-p", "test_*.py"], timeout=600)
+ run_command(
+ [
+ PYTHON,
+ "-m",
+ "py_compile",
+ *subprocess.run(
+ ["rg", "--files", "scripts", "tests", "-g", "*.py", "-g", "!scripts/lib/vendor/**"],
+ cwd=REPO_ROOT,
+ text=True,
+ capture_output=True,
+ check=True,
+ ).stdout.split(),
+ ],
+ timeout=600,
+ )
+ return {"status": "ok"}
+
+
+def verify_diagnose() -> dict[str, object]:
+ result = run_command([PYTHON, "scripts/last30days.py", "--diagnose"], timeout=120)
+ return json.loads(result.stdout)
+
+
+def verify_smoke() -> list[dict[str, object]]:
+ rows: list[dict[str, object]] = []
+ for provider, extra in SMOKE_CASES:
+ env = os.environ.copy()
+ env["LAST30DAYS_REASONING_PROVIDER"] = provider
+ start = time.time()
+ result = run_command(
+ [PYTHON, "scripts/last30days.py", SMOKE_TOPIC, "--emit=json", *extra],
+ env=env,
+ timeout=240,
+ )
+ duration = round(time.time() - start, 2)
+ report = json.loads(result.stdout)
+ rows.append(
+ {
+ "provider": provider,
+ "duration_seconds": duration,
+ "reasoning_provider": (report.get("provider_runtime") or {}).get("reasoning_provider"),
+ "cluster_count": len(report.get("clusters") or []),
+ "candidate_count": len(report.get("ranked_candidates") or []),
+ "error_sources": sorted((report.get("errors_by_source") or {}).keys()),
+ }
+ )
+ return rows
+
+
+def verify_latency() -> dict[str, dict[str, object]]:
+ results: dict[str, dict[str, object]] = {}
+ for profile, extra in LATENCY_PROFILES:
+ timings = []
+ for topic in LATENCY_TOPICS:
+ start = time.time()
+ run_command(
+ [PYTHON, "scripts/last30days.py", topic, "--emit=json", *extra],
+ timeout=300,
+ )
+ timings.append(time.time() - start)
+ results[profile] = {
+ "times": [round(value, 2) for value in timings],
+ "median_seconds": round(statistics.median(timings), 2),
+ "max_seconds": round(max(timings), 2),
+ }
+ return results
+
+
+def verify_eval(
+ *,
+ baseline: str,
+ candidate: str,
+ output_dir: str,
+ quick: bool,
+ limit: int,
+ timeout: int,
+) -> dict[str, object]:
+ cmd = [
+ PYTHON,
+ "scripts/evaluate_search_quality.py",
+ f"--baseline={baseline}",
+ f"--candidate={candidate}",
+ f"--output-dir={output_dir}",
+ f"--limit={limit}",
+ f"--timeout={timeout}",
+ ]
+ if quick:
+ cmd.append("--quick")
+ run_command(cmd, timeout=max(timeout * 8, 600))
+ output = Path(output_dir)
+ metrics = json.loads((output / "metrics.json").read_text())
+ summary = (output / "summary.md").read_text()
+ return {"metrics": metrics, "summary": summary}
+
+
+def build_parser() -> argparse.ArgumentParser:
+ parser = argparse.ArgumentParser(description="Run the v3 verification bundle")
+ parser.add_argument("--skip-eval", action="store_true", help="Skip the judged evaluator")
+ parser.add_argument("--skip-latency", action="store_true", help="Skip live latency sampling")
+ parser.add_argument("--baseline", default="HEAD~1")
+ parser.add_argument("--candidate", default="WORKTREE")
+ parser.add_argument("--output-dir", default="/tmp/last30days-v3-verify")
+ parser.add_argument("--quick-eval", action="store_true", help="Use evaluator quick mode")
+ parser.add_argument("--eval-limit", type=int, default=20)
+ parser.add_argument("--eval-timeout", type=int, default=240)
+ return parser
+
+
+def main() -> int:
+ args = build_parser().parse_args()
+ summary: dict[str, object] = {}
+ summary["unit"] = verify_unit()
+ summary["diagnose"] = verify_diagnose()
+ summary["smoke"] = verify_smoke()
+ if not args.skip_latency:
+ summary["latency"] = verify_latency()
+ if not args.skip_eval:
+ summary["eval"] = verify_eval(
+ baseline=args.baseline,
+ candidate=args.candidate,
+ output_dir=args.output_dir,
+ quick=args.quick_eval,
+ limit=args.eval_limit,
+ timeout=args.eval_timeout,
+ )
+ print(json.dumps(summary, indent=2, sort_keys=True))
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/scripts/watchlist.py b/scripts/watchlist.py
index b354e99..a768e1f 100644
--- a/scripts/watchlist.py
+++ b/scripts/watchlist.py
@@ -1,62 +1,114 @@
#!/usr/bin/env python3
-"""Topic watchlist management for last30days.
+"""Topic watchlist management for last30days."""
-CLI for adding, removing, and listing watched topics with auto-bootstrap.
-On first `add`, creates the SQLite database.
-
-Usage:
- python3 watchlist.py add "AI video tools" [--schedule "0 8 * * *"]
- python3 watchlist.py add "NVIDIA news" --weekly
- python3 watchlist.py remove "AI video tools"
- python3 watchlist.py list
- python3 watchlist.py run-all
- python3 watchlist.py run-one "AI video tools"
- python3 watchlist.py config delivery telegram
- python3 watchlist.py config budget 10.00
-"""
+from __future__ import annotations
import argparse
import json
import subprocess
import sys
import time
-from datetime import datetime
from pathlib import Path
+try:
+ import requests
+except ImportError:
+ requests = None
+
SCRIPT_DIR = Path(__file__).parent.resolve()
sys.path.insert(0, str(SCRIPT_DIR))
import store
+from lib import schema
+# --- Webhook Delivery Functions ---
+
+def _deliver_findings(topic_name: str, counts: dict) -> None:
+ """Send webhook notification if delivery is configured and there are new findings."""
+ channel = store.get_setting("delivery_channel", "")
+ if not channel or counts.get("new", 0) == 0:
+ return
+
+ mode = store.get_setting("delivery_mode", "announce")
+ message = _format_delivery_message(topic_name, counts, mode)
+
+ try:
+ if "hooks.slack.com" in channel:
+ _send_slack_webhook(channel, message)
+ elif channel.startswith("https://"):
+ _send_generic_webhook(channel, message)
+ except Exception as e:
+ # Don't fail the research run if delivery fails
+ print(f"Delivery failed: {e}", file=sys.stderr)
+
+
+def _format_delivery_message(topic: str, counts: dict, mode: str) -> str:
+ """Format notification message based on delivery mode."""
+ new = counts.get("new", 0)
+ updated = counts.get("updated", 0)
+
+ if mode == "announce":
+ return f"📰 *last30days update: {topic}*\n{new} new, {updated} updated"
+ elif mode == "silent":
+ return f"last30days: {new} new findings for '{topic}'"
+ else:
+ return f"last30days: Research complete for '{topic}'"
+
+
+def _send_slack_webhook(url: str, text: str) -> None:
+ """POST to Slack incoming webhook."""
+ if not requests:
+ raise RuntimeError("requests library not available for webhook delivery")
+
+ response = requests.post(
+ url,
+ json={"text": text},
+ headers={"Content-Type": "application/json"},
+ timeout=10,
+ )
+ response.raise_for_status()
+
+
+def _send_generic_webhook(url: str, text: str) -> None:
+ """POST JSON payload to generic webhook."""
+ if not requests:
+ raise RuntimeError("requests library not available for webhook delivery")
+
+ response = requests.post(
+ url,
+ json={
+ "message": text,
+ "source": "last30days",
+ "timestamp": time.time(),
+ },
+ headers={"Content-Type": "application/json"},
+ timeout=10,
+ )
+ response.raise_for_status()
+
+
+# --- Command Handlers ---
+
def cmd_add(args):
- """Add a topic to the watchlist."""
schedule = "0 8 * * 1" if args.weekly else (args.schedule or "0 8 * * *")
- queries = args.queries.split(",") if args.queries else None
-
+ queries = [query.strip() for query in (args.queries or "").split(",") if query.strip()] or None
topic = store.add_topic(args.topic, search_queries=queries, schedule=schedule)
-
sched_desc = "weekly (Mondays 8am)" if args.weekly else f"daily ({schedule})"
- result = {
+ print(json.dumps({
"action": "added",
"topic": topic["name"],
"schedule": sched_desc,
"message": f'Added "{topic["name"]}" to watchlist. Schedule: {sched_desc}.',
- }
-
- print(json.dumps(result, default=str))
+ }, default=str))
def cmd_remove(args):
- """Remove a topic from the watchlist."""
removed = store.remove_topic(args.topic)
-
if not removed:
print(json.dumps({"action": "not_found", "topic": args.topic, "message": f'Topic not found: "{args.topic}"'}))
return
-
remaining = store.list_topics()
-
print(json.dumps({
"action": "removed",
"topic": args.topic,
@@ -66,55 +118,43 @@ def cmd_remove(args):
def cmd_list(args):
- """List all watched topics with stats."""
+ del args
topics = store.list_topics()
budget_used = store.get_daily_cost()
- budget_limit = store.get_setting("daily_budget", "5.00")
-
- result = {
+ budget_limit = float(store.get_setting("daily_budget", "5.00"))
+ print(json.dumps({
"topics": topics,
"budget_used": budget_used,
- "budget_limit": float(budget_limit),
- }
- print(json.dumps(result, default=str))
+ "budget_limit": budget_limit,
+ }, default=str))
def cmd_run_one(args):
- """Run research for a single topic."""
topic = store.get_topic(args.topic)
if not topic:
print(json.dumps({"error": f'Topic not found: "{args.topic}"'}))
sys.exit(1)
-
- result = _run_topic(topic)
- print(json.dumps(result, default=str))
+ print(json.dumps(_run_topic(topic), default=str))
def cmd_run_all(args):
- """Run research for all enabled topics with budget guard."""
- topics = store.list_topics()
- enabled = [t for t in topics if t["enabled"]]
-
- if not enabled:
+ del args
+ topics = [topic for topic in store.list_topics() if topic["enabled"]]
+ if not topics:
print(json.dumps({"message": "No enabled topics to research."}))
return
budget_limit = float(store.get_setting("daily_budget", "5.00"))
results = []
-
- for topic in enabled:
- # Budget guard
- daily_cost = store.get_daily_cost()
- if daily_cost >= budget_limit:
+ for topic in topics:
+ if store.get_daily_cost() >= budget_limit:
results.append({
"topic": topic["name"],
"status": "skipped",
- "reason": f"Budget exceeded: ${daily_cost:.2f}/${budget_limit:.2f}",
+ "reason": f"Budget exceeded: ${store.get_daily_cost():.2f}/${budget_limit:.2f}",
})
continue
-
- result = _run_topic(topic)
- results.append(result)
+ results.append(_run_topic(topic))
print(json.dumps({
"action": "run_all",
@@ -125,32 +165,28 @@ def cmd_run_all(args):
def _run_topic(topic: dict) -> dict:
- """Run research for a single topic and store findings."""
start_time = time.time()
topic_id = topic["id"]
-
- # Record the run
- run_id = store.record_run(topic_id, source_mode="both", status="running")
+ run_id = store.record_run(topic_id, source_mode="v3", status="running")
try:
- # Prefer custom search_queries over topic name (#40)
search_queries = json.loads(topic["search_queries"]) if topic.get("search_queries") else None
search_term = search_queries[0] if search_queries else topic["name"]
- cmd = [
- sys.executable,
- str(SCRIPT_DIR / "last30days.py"),
- search_term,
- "--emit=json",
- ]
result = subprocess.run(
- cmd,
+ [
+ sys.executable,
+ str(SCRIPT_DIR / "last30days.py"),
+ search_term,
+ "--emit=json",
+ "--quick",
+ "--lookback-days",
+ "90",
+ ],
capture_output=True,
text=True,
timeout=300,
)
-
duration = time.time() - start_time
-
if result.returncode != 0:
store.update_run(
run_id,
@@ -165,66 +201,9 @@ def _run_topic(topic: dict) -> dict:
"duration": duration,
}
- # Parse research output
- data = json.loads(result.stdout)
-
- # Convert research items to findings format
- findings = []
- for item in data.get("reddit", []):
- findings.append({
- "source": "reddit",
- "url": item.get("url", ""),
- "title": item.get("title", ""),
- "author": item.get("author", ""),
- "content": item.get("title", ""),
- "summary": item.get("top_comments_summary", ""),
- "engagement_score": item.get("upvotes", 0),
- "relevance_score": item.get("relevance", 0),
- })
- for item in data.get("x", []):
- findings.append({
- "source": "x",
- "url": item.get("url", ""),
- "title": item.get("text", "")[:100],
- "author": item.get("author_handle", ""),
- "content": item.get("text", ""),
- "engagement_score": (item.get("engagement") or {}).get("likes", 0),
- "relevance_score": item.get("relevance", 0),
- })
- for item in data.get("youtube", []):
- findings.append({
- "source": "youtube",
- "url": item.get("url", ""),
- "title": item.get("title", ""),
- "author": item.get("channel_name", item.get("channel", "")),
- "content": item.get("transcript_snippet", "") or item.get("title", ""),
- "engagement_score": (item.get("engagement") or {}).get("views", 0),
- "relevance_score": item.get("relevance", 0),
- })
- for item in data.get("tiktok", []):
- findings.append({
- "source": "tiktok",
- "url": item.get("url", ""),
- "title": (item.get("caption_snippet", "") or "")[:120],
- "author": item.get("author", ""),
- "content": item.get("caption_snippet", ""),
- "engagement_score": (item.get("engagement") or {}).get("views", 0),
- "relevance_score": item.get("relevance", 0),
- })
- for item in data.get("instagram", []):
- findings.append({
- "source": "instagram",
- "url": item.get("url", ""),
- "title": (item.get("caption_snippet", "") or "")[:120],
- "author": item.get("author_name", ""),
- "content": item.get("caption_snippet", ""),
- "engagement_score": (item.get("engagement") or {}).get("views", 0),
- "relevance_score": item.get("relevance", 0),
- })
-
- # Store with dedup
+ report = schema.report_from_dict(json.loads(result.stdout))
+ findings = store.findings_from_report(report, limit=25)
counts = store.store_findings(run_id, topic_id, findings)
-
store.update_run(
run_id,
status="completed",
@@ -232,7 +211,10 @@ def _run_topic(topic: dict) -> dict:
findings_new=counts["new"],
findings_updated=counts["updated"],
)
-
+
+ # Deliver webhook notification if configured
+ _deliver_findings(topic["name"], counts)
+
return {
"topic": topic["name"],
"status": "completed",
@@ -240,90 +222,78 @@ def _run_topic(topic: dict) -> dict:
"updated": counts["updated"],
"duration": duration,
}
-
except subprocess.TimeoutExpired:
duration = time.time() - start_time
store.update_run(
- run_id, status="failed",
+ run_id,
+ status="failed",
error_message="Research timed out after 300s",
duration_seconds=duration,
)
return {"topic": topic["name"], "status": "failed", "error": "timeout"}
-
- except json.JSONDecodeError as e:
+ except json.JSONDecodeError as exc:
duration = time.time() - start_time
store.update_run(
- run_id, status="failed",
- error_message=f"Invalid JSON output: {e}",
+ run_id,
+ status="failed",
+ error_message=f"Invalid JSON output: {exc}",
duration_seconds=duration,
)
- return {"topic": topic["name"], "status": "failed", "error": f"parse error: {e}"}
-
- except Exception as e:
- duration = time.time() - start_time
- store.update_run(
- run_id, status="failed",
- error_message=str(e)[:500],
- duration_seconds=duration,
- )
- return {"topic": topic["name"], "status": "failed", "error": str(e)}
-
-
+ return {"topic": topic["name"], "status": "failed", "error": f"parse error: {exc}"}
def cmd_config(args):
- """Configure watchlist settings."""
- if args.setting == "delivery":
- store.set_setting("delivery_channel", args.value)
- print(json.dumps({"action": "config", "setting": "delivery_channel", "value": args.value}))
- elif args.setting == "budget":
- store.set_setting("daily_budget", args.value)
- print(json.dumps({"action": "config", "setting": "daily_budget", "value": args.value}))
- else:
- print(json.dumps({"error": f"Unknown setting: {args.setting}. Use 'delivery' or 'budget'."}))
+ if args.key == "budget":
+ store.set_setting("daily_budget", str(args.value))
+ print(json.dumps({"action": "config", "key": "daily_budget", "value": str(args.value)}))
+ return
+ if args.key == "delivery":
+ store.set_setting("delivery_channel", str(args.value))
+ print(json.dumps({"action": "config", "key": "delivery_channel", "value": str(args.value)}))
+ return
+ raise SystemExit(f"Unknown config key: {args.key}")
-def main():
- parser = argparse.ArgumentParser(description="Manage last30days topic watchlist")
+def build_parser() -> argparse.ArgumentParser:
+ parser = argparse.ArgumentParser(description="Manage the last30days watchlist")
sub = parser.add_subparsers(dest="command")
- # add
- a = sub.add_parser("add", help="Add a topic to the watchlist")
- a.add_argument("topic", help="Topic name")
- a.add_argument("--schedule", help="Cron expression (default: 0 8 * * *)")
- a.add_argument("--weekly", action="store_true", help="Run weekly instead of daily")
- a.add_argument("--queries", help="Comma-separated custom search queries")
- a.set_defaults(func=cmd_add)
+ add = sub.add_parser("add")
+ add.add_argument("topic")
+ add.add_argument("--schedule")
+ add.add_argument("--weekly", action="store_true")
+ add.add_argument("--queries")
+ add.set_defaults(func=cmd_add)
- # remove
- r = sub.add_parser("remove", help="Remove a topic from the watchlist")
- r.add_argument("topic", help="Topic name")
- r.set_defaults(func=cmd_remove)
+ remove = sub.add_parser("remove")
+ remove.add_argument("topic")
+ remove.set_defaults(func=cmd_remove)
- # list
- l = sub.add_parser("list", help="List all watched topics")
- l.set_defaults(func=cmd_list)
+ list_parser = sub.add_parser("list")
+ list_parser.set_defaults(func=cmd_list)
- # run-all
- ra = sub.add_parser("run-all", help="Run research for all enabled topics")
- ra.set_defaults(func=cmd_run_all)
+ run_one = sub.add_parser("run-one")
+ run_one.add_argument("topic")
+ run_one.set_defaults(func=cmd_run_one)
- # run-one
- ro = sub.add_parser("run-one", help="Run research for a single topic")
- ro.add_argument("topic", help="Topic name")
- ro.set_defaults(func=cmd_run_one)
+ run_all = sub.add_parser("run-all")
+ run_all.set_defaults(func=cmd_run_all)
- # config
- c = sub.add_parser("config", help="Configure watchlist settings")
- c.add_argument("setting", help="Setting name (delivery, budget)")
- c.add_argument("value", help="Setting value")
- c.set_defaults(func=cmd_config)
+ config = sub.add_parser("config")
+ config.add_argument("key", choices=["delivery", "budget"])
+ config.add_argument("value")
+ config.set_defaults(func=cmd_config)
+ return parser
+
+
+def main() -> int:
+ parser = build_parser()
args = parser.parse_args()
- if not args.command:
+ if not getattr(args, "command", None):
parser.print_help()
- sys.exit(1)
-
+ return 1
args.func(args)
+ return 0
if __name__ == "__main__":
- main()
+ raise SystemExit(main())
diff --git a/skills/last30days/SKILL.md b/skills/last30days-nux/SKILL.md
similarity index 100%
rename from skills/last30days/SKILL.md
rename to skills/last30days-nux/SKILL.md
diff --git a/skills/last30days-v3/SKILL.md b/skills/last30days-v3/SKILL.md
new file mode 100644
index 0000000..f0f4667
--- /dev/null
+++ b/skills/last30days-v3/SKILL.md
@@ -0,0 +1,219 @@
+---
+name: last30days-3
+version: "3.0.0-alpha"
+description: "[V3 DEV] Research any topic from the last 30 days. Side-by-side test version of the v3 pipeline."
+argument-hint: "last30days-v3 codex vs claude code"
+allowed-tools: Bash, Read, Write, WebSearch
+homepage: https://github.com/mvanhorn/last30days-skill
+repository: https://github.com/mvanhorn/last30days-skill
+author: mvanhorn
+license: MIT
+user-invocable: true
+---
+
+# last30days v3.0.0
+
+Use `last30days` when the user wants recent, cross-source evidence from the last 30 days.
+
+The runtime is a single v3 pipeline:
+
+1. plan the query
+2. retrieve per `(subquery, source)`
+3. normalize and dedupe
+4. extract best snippets
+5. fuse with weighted RRF
+6. rerank with one relevance score
+7. cluster evidence
+8. render ranked clusters
+
+## Setup: resolve the skill root
+
+```bash
+for dir in \
+ "." \
+ "${CLAUDE_PLUGIN_ROOT:-}" \
+ "${GEMINI_EXTENSION_DIR:-}" \
+ "$HOME/.openclaw/workspace/skills/last30days" \
+ "$HOME/.openclaw/skills/last30days" \
+ "$HOME/.claude/skills/last30days" \
+ "$HOME/.agents/skills/last30days" \
+ "$HOME/.codex/skills/last30days"; do
+ [ -n "$dir" ] && [ -f "$dir/scripts/last30days.py" ] && SKILL_ROOT="$dir" && break
+done
+
+if [ -z "${SKILL_ROOT:-}" ]; then
+ echo "ERROR: Could not find scripts/last30days.py" >&2
+ exit 1
+fi
+```
+
+## Default command
+
+```bash
+python3 "${SKILL_ROOT}/scripts/last30days.py" $ARGUMENTS --emit=compact
+```
+
+## Useful commands
+
+```bash
+python3 "${SKILL_ROOT}/scripts/last30days.py" $ARGUMENTS --emit=json
+python3 "${SKILL_ROOT}/scripts/last30days.py" $ARGUMENTS --quick
+python3 "${SKILL_ROOT}/scripts/last30days.py" $ARGUMENTS --deep
+python3 "${SKILL_ROOT}/scripts/last30days.py" $ARGUMENTS --search=reddit,x,grounding
+python3 "${SKILL_ROOT}/scripts/last30days.py" $ARGUMENTS --store
+python3 "${SKILL_ROOT}/scripts/last30days.py" --diagnose
+```
+
+## Runtime expectations
+
+- One reasoning provider is required: `GOOGLE_API_KEY` for Gemini, `OPENAI_API_KEY` for OpenAI, or `XAI_API_KEY` for xAI.
+- `BRAVE_API_KEY` enables Brave web search (recommended). `SERPER_API_KEY` is the web fallback.
+- `SCRAPECREATORS_API_KEY` enables Reddit, TikTok, and Instagram.
+- `XAI_API_KEY` enables xAI reasoning and X search.
+- `AUTH_TOKEN` plus `CT0` enables Bird-backed X search.
+- `yt-dlp` enables YouTube.
+- Planning and reranking fall back gracefully: Gemini -> OpenAI -> xAI -> deterministic/local.
+- Web retrieval stays within Brave/Serper dated results. Undated web hits are dropped.
+- For OpenClaw-specific watchlist, briefing, and history workflows, use `variants/open/SKILL.md`.
+
+## Output model
+
+- `compact` and `md`: cluster-first markdown
+- `json`: full v3 report
+- `context`: short synthesis-oriented context
+
+Important report fields:
+
+- `provider_runtime`
+- `query_plan`
+- `ranked_candidates`
+- `clusters`
+- `items_by_source`
+- `errors_by_source`
+
+## Usage guidance for agents
+
+- Prefer `--quick` for fast iteration.
+- Prefer default mode when the user wants a balanced answer.
+- Prefer `--deep` only when the user explicitly wants maximum recall or the topic is complex enough to justify extra latency.
+- Prefer `--emit=json` when downstream code or evaluation will consume the result.
+- Use `--search=` only when the user explicitly wants source restrictions.
+
+## X handle resolution
+
+If the topic could have its own X/Twitter account (people, brands, products, companies), do a quick WebSearch for their handle:
+```
+WebSearch("{TOPIC} X twitter handle site:x.com")
+```
+If you find a verified handle, pass `--x-handle={handle}` (without @). This searches their posts directly, finding content they posted that doesn't mention their own name. Skip this for generic concepts ("best headphones 2026", "how to use Docker").
+
+## Synthesis guidance
+
+### First: synthesize, don't summarize
+
+Extract key facts from the output first, then synthesize across sources. Lead with patterns that appear across multiple clusters. Present a unified narrative, not a source-by-source summary.
+
+### Ground in actual research, not pre-existing knowledge
+
+Use exact product/tool names, specific quotes, and what sources actually say. If research mentions "ClawdBot" and "@clawdbot", that is a different product than "Claude Code" -- read what the research actually says.
+
+**Anti-pattern to avoid:**
+- BAD: User asks "best Claude Code skills" and you respond with generic advice: "Skills are powerful. Keep them under 500 lines."
+- GOOD: You respond with specifics from the research: "Most mentioned: /commit (5 mentions), remotion skill (4x), git-worktree (3x). The Remotion announcement got 16K likes on X per @thedorbrothers."
+
+### Source weighting (highest to lowest signal)
+
+1. **Cross-cluster corroboration** -- same evidence across multiple sources is the strongest signal. Lead with it.
+2. **Reddit top comments** -- often the wittiest, most insightful take. Quote directly when upvotes are high.
+3. **YouTube transcript highlights** -- pre-extracted key moments. Quote and attribute to channel name.
+4. **X/Twitter @handles** -- real-time community signal. Quote with engagement context.
+5. **Polymarket odds** -- real money on outcomes cuts through opinion. Include specific odds AND movement.
+6. **TikTok/Instagram** -- viral/creator signal. Cite @creators with views/likes.
+7. **Hacker News** -- technical community perspective. Cite as "per HN."
+8. **Web (Brave/Serper)** -- cite only when social sources don't cover a fact.
+
+### Polymarket interpretation
+
+When Polymarket returns relevant markets:
+1. Prefer structural/long-term markets over near-term deadlines (championship odds > regular season, IPO > incremental update)
+2. Call out the specific outcome's odds and movement, not just that a market exists
+3. Weave odds into the narrative as supporting evidence, don't isolate them
+4. When multiple relevant markets exist, highlight 3-5 ordered by importance
+
+Domain importance ranking:
+- **Sports:** Championship/tournament > conference title > regular season > weekly matchup
+- **Geopolitics:** Regime change/structural > near-term strike deadlines > sanctions
+- **Tech/Business:** IPO, major product launch > incremental updates
+- **Elections:** Presidency > primary > individual state
+
+### Citation rules
+
+Cite the single strongest source per point in short format: "per @handle" or "per r/subreddit". Save engagement metrics for the stats section. Use the priority order from source weighting above. The tool's value is surfacing what PEOPLE are saying, not what journalists wrote.
+
+### Comparison queries
+
+For "X vs Y" queries, structure output as:
+
+```
+## Quick Verdict
+[1-2 sentences: which one the community prefers and why, with source counts]
+
+## [Entity A]
+**Community Sentiment:** [Positive/Mixed/Negative] (N mentions across sources)
+**Strengths:** [with source attribution]
+**Weaknesses:** [with source attribution]
+
+## [Entity B]
+[Same structure]
+
+## Head-to-Head
+| Dimension | Entity A | Entity B |
+|-----------|----------|----------|
+| [Key dim] | [position] | [position] |
+
+## Bottom Line
+Choose A if... Choose B if... (based on community data)
+```
+
+### Recommendation queries
+
+When users ask "best X" or "top X", extract SPECIFIC NAMES:
+
+```
+Most mentioned:
+[Name] -- Nx mentions
+ Sources: @handle1, r/subreddit, [YouTube channel]
+
+[Name] -- Nx mentions
+ Sources: @handle2, r/subreddit2
+
+Notable mentions: [others with 1-2 mentions]
+```
+
+### Edge cases
+
+- **Empty results from a source:** State what is missing. ("No Reddit discussion found for this topic.") Do not fill the gap with training data.
+- **Sources contradict each other:** Present both sides with attribution. ("Reddit r/fitness is bullish on X, while @DrExpert on X warns about Y.")
+- **All results are low-engagement or off-topic:** Acknowledge uncertainty. ("Limited recent discussion found -- these findings should be treated as preliminary.")
+
+### Follow-up conversations
+
+After research completes, treat yourself as an expert on this topic. Answer follow-ups from the research findings. Cite the specific threads, posts, and channels you found. Only run new research if the user asks about a DIFFERENT topic.
+
+## Security and permissions
+
+**What this skill does:**
+- Sends search queries to ScrapeCreators API for Reddit, TikTok, Instagram search
+- Sends search queries via xAI API or Bird client for X search
+- Sends search queries to Algolia HN Search API (free, no auth)
+- Sends search queries to Polymarket Gamma API (free, no auth)
+- Runs yt-dlp locally for YouTube search and transcript extraction (no API key)
+- Sends search queries to Brave Search API or Serper for web search (optional)
+- Uses Gemini, OpenAI, or xAI for LLM planning and reranking
+- Stores findings in local SQLite database (--store mode only)
+
+**What this skill does NOT do:**
+- Does not post, like, or modify content on any platform
+- Does not access your personal accounts on any platform
+- Does not share API keys between providers
+- Does not log or cache API keys in output files
diff --git a/tests/e2e_comparison.py b/tests/e2e_comparison.py
new file mode 100644
index 0000000..00dd513
--- /dev/null
+++ b/tests/e2e_comparison.py
@@ -0,0 +1,202 @@
+#!/usr/bin/env python3
+"""E2E comparison: run sample queries on both v3 (current branch) and v2.9.5 (main).
+
+Usage:
+ python3 tests/e2e_comparison.py [--v2-script PATH]
+
+Outputs a markdown comparison table with per-query metrics.
+"""
+
+import json
+import subprocess
+import sys
+import time
+from pathlib import Path
+
+REPO = Path(__file__).resolve().parents[1]
+V3_SCRIPT = str(REPO / "scripts" / "last30days.py")
+
+# v2.9.5 from plugin cache (main branch equivalent)
+V2_SCRIPT = str(
+ Path.home()
+ / ".claude/plugins/cache/last30days/last30days/2.9.5/scripts/last30days.py"
+)
+
+EVAL_TOPICS_FILE = REPO / "fixtures" / "eval_topics.json"
+
+
+def _load_queries() -> list[tuple[str, str]]:
+ if EVAL_TOPICS_FILE.exists():
+ rows = json.loads(EVAL_TOPICS_FILE.read_text())
+ return [(row["topic"], row["query_type"]) for row in rows]
+ return [
+ ("openclaw vs nanoclaw vs ironclaw", "comparison"),
+ ("how to deploy on Fly.io", "how_to"),
+ ("kanye west", "breaking_news"),
+ ("odds of recession", "prediction"),
+ ("explain transformer architecture", "concept"),
+ ]
+
+
+QUERIES = _load_queries()
+
+
+def run_query(script: str, topic: str, timeout: int = 180) -> dict:
+ """Run a query and return parsed JSON + timing."""
+ start = time.time()
+ try:
+ result = subprocess.run(
+ [sys.executable, script, topic, "--emit=json"],
+ capture_output=True,
+ text=True,
+ timeout=timeout,
+ )
+ elapsed = time.time() - start
+ if result.returncode != 0:
+ return {
+ "error": result.stderr[:200],
+ "elapsed": elapsed,
+ "sources": 0,
+ "candidates": 0,
+ "intent": "error",
+ "subqueries": 0,
+ }
+ data = json.loads(result.stdout)
+
+ # v3 shape
+ if "query_plan" in data:
+ items_by_source = data.get("items_by_source", {})
+ return {
+ "elapsed": elapsed,
+ "sources": sum(1 for v in items_by_source.values() if v),
+ "total_items": sum(len(v) for v in items_by_source.values()),
+ "candidates": len(data.get("ranked_candidates", [])),
+ "clusters": len(data.get("clusters", [])),
+ "intent": data["query_plan"].get("intent", "?"),
+ "subqueries": len(data["query_plan"].get("subqueries", [])),
+ "errors": list(data.get("errors_by_source", {}).keys()),
+ }
+
+ # v2 shape
+ sources_with_items = 0
+ total_items = 0
+ for key in ["reddit", "x", "youtube", "tiktok", "instagram", "hackernews",
+ "bluesky", "truthsocial", "polymarket", "web"]:
+ items = data.get(key, [])
+ if items:
+ sources_with_items += 1
+ total_items += len(items)
+ return {
+ "elapsed": elapsed,
+ "sources": sources_with_items,
+ "total_items": total_items,
+ "candidates": total_items,
+ "clusters": 0,
+ "intent": data.get("mode", "?"),
+ "subqueries": 0,
+ "errors": [k for k in ["reddit_error", "x_error", "youtube_error",
+ "tiktok_error", "instagram_error"]
+ if data.get(k)],
+ }
+ except subprocess.TimeoutExpired:
+ return {
+ "error": "timeout",
+ "elapsed": timeout,
+ "sources": 0,
+ "candidates": 0,
+ "intent": "timeout",
+ "subqueries": 0,
+ }
+ except Exception as exc:
+ return {
+ "error": str(exc)[:200],
+ "elapsed": time.time() - start,
+ "sources": 0,
+ "candidates": 0,
+ "intent": "error",
+ "subqueries": 0,
+ }
+
+
+def main():
+ v2_script = V2_SCRIPT
+ if len(sys.argv) > 2 and sys.argv[1] == "--v2-script":
+ v2_script = sys.argv[2]
+
+ if not Path(v2_script).exists():
+ print(f"v2 script not found at {v2_script}", file=sys.stderr)
+ print("Use --v2-script PATH to specify", file=sys.stderr)
+ sys.exit(1)
+
+ print("# E2E Comparison: v3.0.0 (branch) vs v2.9.5 (main)")
+ print()
+ print(f"- v3 script: {V3_SCRIPT}")
+ print(f"- v2 script: {v2_script}")
+ print(f"- Queries: {len(QUERIES)}")
+ print()
+
+ results = []
+ for i, (topic, expected_intent) in enumerate(QUERIES, 1):
+ print(f"[{i}/{len(QUERIES)}] {topic}", file=sys.stderr)
+ sys.stderr.flush()
+
+ print(f" v3...", end="", file=sys.stderr)
+ sys.stderr.flush()
+ v3 = run_query(V3_SCRIPT, topic)
+ print(f" {v3.get('elapsed', 0):.1f}s", file=sys.stderr)
+ sys.stderr.flush()
+
+ print(f" v2...", end="", file=sys.stderr)
+ sys.stderr.flush()
+ v2 = run_query(v2_script, topic)
+ print(f" {v2.get('elapsed', 0):.1f}s", file=sys.stderr)
+ sys.stderr.flush()
+
+ results.append({
+ "topic": topic,
+ "expected_intent": expected_intent,
+ "v3": v3,
+ "v2": v2,
+ })
+
+ # Print comparison table
+ print("| Query | Intent | v3 sources | v2 sources | v3 items | v2 items | v3 time | v2 time | v3 errors | v2 errors |")
+ print("|-------|--------|-----------|-----------|---------|---------|---------|---------|-----------|-----------|")
+ for r in results:
+ v3, v2 = r["v3"], r["v2"]
+ v3_err = ", ".join(v3.get("errors", [])) or "-"
+ v2_err = ", ".join(v2.get("errors", [])) or "-"
+ print(
+ f"| {r['topic'][:45]} | {v3.get('intent', '?')} | "
+ f"{v3.get('sources', 0)} | {v2.get('sources', 0)} | "
+ f"{v3.get('total_items', 0)} | {v2.get('total_items', 0)} | "
+ f"{v3.get('elapsed', 0):.1f}s | {v2.get('elapsed', 0):.1f}s | "
+ f"{v3_err} | {v2_err} |"
+ )
+
+ # Summary
+ print()
+ v3_total_sources = sum(r["v3"].get("sources", 0) for r in results)
+ v2_total_sources = sum(r["v2"].get("sources", 0) for r in results)
+ v3_total_items = sum(r["v3"].get("total_items", 0) for r in results)
+ v2_total_items = sum(r["v2"].get("total_items", 0) for r in results)
+ v3_total_time = sum(r["v3"].get("elapsed", 0) for r in results)
+ v2_total_time = sum(r["v2"].get("elapsed", 0) for r in results)
+ v3_errors = sum(len(r["v3"].get("errors", [])) for r in results)
+ v2_errors = sum(len(r["v2"].get("errors", [])) for r in results)
+
+ print("## Summary")
+ print()
+ print(f"| Metric | v3.0.0 | v2.9.5 | Delta |")
+ print(f"|--------|--------|--------|-------|")
+ print(f"| Total sources with items | {v3_total_sources} | {v2_total_sources} | {v3_total_sources - v2_total_sources:+d} |")
+ print(f"| Total items retrieved | {v3_total_items} | {v2_total_items} | {v3_total_items - v2_total_items:+d} |")
+ print(f"| Total wall time | {v3_total_time:.1f}s | {v2_total_time:.1f}s | {v3_total_time - v2_total_time:+.1f}s |")
+ print(f"| Source errors | {v3_errors} | {v2_errors} | {v3_errors - v2_errors:+d} |")
+ print(f"| Avg sources/query | {v3_total_sources/len(results):.1f} | {v2_total_sources/len(results):.1f} | |")
+ print(f"| Avg items/query | {v3_total_items/len(results):.1f} | {v2_total_items/len(results):.1f} | |")
+ print(f"| Avg time/query | {v3_total_time/len(results):.1f}s | {v2_total_time/len(results):.1f}s | |")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/tests/test_adversarial_v3.py b/tests/test_adversarial_v3.py
new file mode 100644
index 0000000..1d5a9c1
--- /dev/null
+++ b/tests/test_adversarial_v3.py
@@ -0,0 +1,198 @@
+"""Adversarial query tests for the v3 planner.
+
+These target edge cases found during regression analysis: slash-separated
+comparisons, 'difference between X and Y' phrasing, trailing context
+leaking into entities, degenerate inputs, and false-positive resistance.
+"""
+
+import sys
+import unittest
+from pathlib import Path
+
+sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "scripts"))
+
+from lib import planner
+
+
+class TestSlashSeparatedComparison(unittest.TestCase):
+ """'React/Vue/Svelte' should be detected as comparison intent
+ and produce entity subqueries."""
+
+ def test_slash_triggers_comparison_intent(self):
+ self.assertEqual(planner._infer_intent("React/Vue/Svelte"), "comparison")
+
+ def test_slash_extracts_entities(self):
+ entities = planner._comparison_entities("React/Vue/Svelte")
+ self.assertEqual(len(entities), 3)
+ self.assertIn("React", entities)
+ self.assertIn("Vue", entities)
+ self.assertIn("Svelte", entities)
+
+ def test_slash_forces_deterministic(self):
+ self.assertTrue(planner._should_force_deterministic_plan("React/Vue"))
+
+ def test_url_slash_does_not_trigger_comparison(self):
+ self.assertNotEqual(planner._infer_intent("https://example.com/path"), "comparison")
+
+ def test_slash_trailing_context_stripped(self):
+ entities = planner._comparison_entities("React/Vue/Svelte for frontend in 2026")
+ for entity in entities:
+ self.assertNotIn("frontend", entity.lower(),
+ f"Trailing context leaked: '{entity}'")
+
+
+class TestDifferenceBetweenPhrasing(unittest.TestCase):
+ """'difference between X and Y' should extract both entities."""
+
+ def test_intent_is_comparison(self):
+ self.assertEqual(
+ planner._infer_intent("difference between OpenClaw and NanoClaw"),
+ "comparison",
+ )
+
+ def test_entities_extracted(self):
+ entities = planner._comparison_entities("difference between OpenClaw and NanoClaw")
+ self.assertEqual(len(entities), 2)
+ self.assertIn("OpenClaw", entities)
+ self.assertIn("NanoClaw", entities)
+
+ def test_forces_deterministic(self):
+ self.assertTrue(
+ planner._should_force_deterministic_plan("difference between OpenClaw and NanoClaw")
+ )
+
+
+class TestAndFalsePositive(unittest.TestCase):
+ """'and' must not split entities outside 'difference between' context."""
+
+ def test_pros_and_cons_no_entities(self):
+ entities = planner._comparison_entities("pros and cons of AI")
+ self.assertEqual(entities, [])
+
+ def test_react_and_vue_no_entities(self):
+ # No "vs" or "difference between" -- just "and"
+ entities = planner._comparison_entities("React and Vue")
+ self.assertEqual(entities, [])
+
+
+class TestTrailingContextStripping(unittest.TestCase):
+ """Trailing preposition phrases must not leak into entity strings."""
+
+ def test_for_stripped(self):
+ entities = planner._comparison_entities("A vs B for production use")
+ self.assertNotIn("production", entities[-1].lower())
+
+ def test_in_stripped(self):
+ entities = planner._comparison_entities("A vs B in 2026")
+ self.assertNotIn("2026", entities[-1])
+
+ def test_with_stripped(self):
+ entities = planner._comparison_entities("A vs B with better security")
+ self.assertNotIn("security", entities[-1].lower())
+
+ def test_core_entity_preserved(self):
+ entities = planner._comparison_entities("Fly.io vs Railway.app for deployment")
+ self.assertTrue(any("Fly" in e for e in entities))
+ self.assertTrue(any("Railway" in e for e in entities))
+
+
+class TestDuplicateEntities(unittest.TestCase):
+
+ def test_deduped(self):
+ entities = planner._comparison_entities("OpenClaw vs OpenClaw")
+ self.assertEqual(len(entities), 1)
+
+
+class TestFiveWayComparison(unittest.TestCase):
+
+ def test_capped_at_max(self):
+ topic = "A vs B vs C vs D vs E vs F"
+ entities = planner._comparison_entities(topic)
+ self.assertLessEqual(len(entities), planner._max_subqueries("comparison"))
+
+ def test_does_not_crash(self):
+ plan = planner.plan_query(
+ topic="A vs B vs C vs D vs E",
+ available_sources=["reddit", "x", "grounding"],
+ requested_sources=None,
+ depth="default",
+ provider=None,
+ model=None,
+ )
+ self.assertLessEqual(len(plan.subqueries), 4)
+
+
+class TestDegenerateInputs(unittest.TestCase):
+
+ def test_single_word(self):
+ plan = planner.plan_query(
+ topic="Bitcoin",
+ available_sources=["reddit"],
+ requested_sources=None,
+ depth="default",
+ provider=None,
+ model=None,
+ )
+ self.assertGreater(len(plan.subqueries), 0)
+
+ def test_empty_vs_split(self):
+ plan = planner.plan_query(
+ topic="vs vs vs",
+ available_sources=["reddit"],
+ requested_sources=None,
+ depth="default",
+ provider=None,
+ model=None,
+ )
+ for sq in plan.subqueries:
+ self.assertTrue(sq.search_query.strip())
+
+ def test_very_long_comparison(self):
+ topic = " vs ".join(f"Tool{i}" for i in range(20))
+ plan = planner.plan_query(
+ topic=topic,
+ available_sources=["reddit", "x"],
+ requested_sources=None,
+ depth="default",
+ provider=None,
+ model=None,
+ )
+ self.assertLessEqual(len(plan.subqueries), 4)
+
+
+class TestMixedCaseAndPunctuation(unittest.TestCase):
+
+ def test_uppercase_vs_period(self):
+ self.assertEqual(
+ planner._infer_intent("OpenClaw VS. NanoClaw VS. IronClaw"),
+ "comparison",
+ )
+
+ def test_entities_preserved_with_mixed_case(self):
+ entities = planner._comparison_entities("OpenClaw VS. NanoClaw VS. IronClaw")
+ self.assertGreaterEqual(len(entities), 3)
+
+
+class TestSubstringEntity(unittest.TestCase):
+
+ def test_react_vs_react_native_not_collapsed(self):
+ entities = planner._comparison_entities("React vs React Native")
+ self.assertEqual(len(entities), 2)
+ self.assertTrue(any("Native" in e for e in entities))
+
+
+class TestNoiseWordEntities(unittest.TestCase):
+ """Entities that are also common English words (Swift, Rust, Go)
+ must survive entity extraction."""
+
+ def test_swift_preserved(self):
+ entities = planner._comparison_entities("Swift vs Rust vs Go")
+ self.assertGreaterEqual(len(entities), 3)
+
+ def test_go_not_stripped(self):
+ entities = planner._comparison_entities("Swift vs Rust vs Go")
+ self.assertTrue(any("Go" in e for e in entities))
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_bird_x.py b/tests/test_bird_x.py
index 2e9e57a..b50c9b6 100644
--- a/tests/test_bird_x.py
+++ b/tests/test_bird_x.py
@@ -1,101 +1,64 @@
-"""Tests for bird_x module."""
-
import sys
import unittest
from pathlib import Path
-from unittest import mock
-sys.path.insert(0, str(Path(__file__).parent.parent / "scripts"))
+sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "scripts"))
-from lib import bird_x
+from lib.bird_x import parse_bird_response
-class TestExtractCoreSubject(unittest.TestCase):
- def tearDown(self):
- bird_x._credentials.clear()
+class TestBirdXEngagementZero(unittest.TestCase):
+ def test_zero_likes_preserved(self):
+ tweets = [
+ {
+ "id": "1",
+ "text": "test",
+ "permanent_url": "https://x.com/u/status/1",
+ "likeCount": 0,
+ "retweetCount": 5,
+ }
+ ]
+ items = parse_bird_response(tweets, "test query")
+ self.assertEqual(0, items[0]["engagement"]["likes"])
+ self.assertEqual(5, items[0]["engagement"]["reposts"])
- def test_strips_trending_noise(self):
- result = bird_x._extract_core_subject("trendiest Claude Code skills")
- self.assertNotIn("trendiest", result)
- self.assertIn("claude", result.lower())
+ def test_none_likes_when_missing(self):
+ tweets = [
+ {
+ "id": "1",
+ "text": "test tweet with no engagement fields",
+ "permanent_url": "https://x.com/u/status/1",
+ # no likeCount, like_count, or favorite_count
+ }
+ ]
+ items = parse_bird_response(tweets, "test query")
+ self.assertIsNone(items[0]["engagement"]["likes"])
- def test_strips_tool_noise(self):
- result = bird_x._extract_core_subject("best AI tools for coding")
- self.assertNotIn("tools", result)
- self.assertNotIn("best", result)
+ def test_fallback_to_second_key(self):
+ tweets = [
+ {
+ "id": "1",
+ "text": "test",
+ "permanent_url": "https://x.com/u/status/1",
+ "like_count": 7,
+ }
+ ]
+ items = parse_bird_response(tweets, "test query")
+ self.assertEqual(7, items[0]["engagement"]["likes"])
- def test_strips_skill_noise(self):
- result = bird_x._extract_core_subject("top claude code skills")
- self.assertNotIn("skills", result)
- self.assertNotIn("top", result)
-
-
-class TestBirdSearchRetries(unittest.TestCase):
- def tearDown(self):
- bird_x._credentials.clear()
-
- def test_last_chance_retry_uses_strongest_token(self):
- """When shorter retry also returns 0, uses longest non-noise token."""
- empty = {"items": []}
- with mock.patch.object(bird_x, "_extract_core_subject", return_value="best codex skill plugin"), \
- mock.patch.object(bird_x, "parse_bird_response", return_value=[]), \
- mock.patch.object(bird_x, "_run_bird_search", return_value=empty) as run_mock:
- bird_x.search_x("best codex skill plugin", "2026-01-01", "2026-01-31", depth="quick")
-
- # Should try: original, shorter (2-word), last-chance (strongest token)
- self.assertEqual(run_mock.call_count, 3)
- queries = [call.args[0] for call in run_mock.call_args_list]
- # Last call should use "codex" (longest non-noise word)
- self.assertIn("codex", queries[2])
-
- def test_no_retry_when_first_query_has_results(self):
- """No retry when first query succeeds."""
- result = {"items": [{"id": "1"}]}
- with mock.patch.object(bird_x, "_extract_core_subject", return_value="nano banana"), \
- mock.patch.object(bird_x, "parse_bird_response", return_value=[{"id": "1"}]), \
- mock.patch.object(bird_x, "_run_bird_search", return_value=result) as run_mock:
- bird_x.search_x("nano banana prompting", "2026-01-01", "2026-01-31")
-
- self.assertEqual(run_mock.call_count, 1)
-
-
-class TestBirdAuthEnvironment(unittest.TestCase):
- def tearDown(self):
- bird_x._credentials.clear()
-
- def test_subprocess_env_disables_browser_cookie_fallback_when_injected(self):
- bird_x.set_credentials("auth-token", "ct0-token")
-
- env = bird_x._subprocess_env()
-
- self.assertEqual(env["AUTH_TOKEN"], "auth-token")
- self.assertEqual(env["CT0"], "ct0-token")
- self.assertEqual(env["BIRD_DISABLE_BROWSER_COOKIES"], "1")
-
- def test_is_bird_authenticated_short_circuits_when_credentials_injected(self):
- bird_x.set_credentials("auth-token", "ct0-token")
-
- with mock.patch.object(bird_x, "is_bird_installed", return_value=True), \
- mock.patch.object(bird_x.subprocess, "run") as run_mock:
- result = bird_x.is_bird_authenticated()
-
- self.assertEqual(result, "env AUTH_TOKEN")
- run_mock.assert_not_called()
-
- def test_search_handles_passes_injected_credentials_to_subprocess(self):
- bird_x.set_credentials("auth-token", "ct0-token")
-
- proc = mock.Mock()
- proc.communicate.return_value = ("[]", "")
- proc.returncode = 0
-
- with mock.patch.object(bird_x.subprocess, "Popen", return_value=proc) as popen_mock:
- bird_x.search_handles(["openai"], "codex vs claude code", "2026-01-01", count_per=1)
-
- env = popen_mock.call_args.kwargs["env"]
- self.assertEqual(env["AUTH_TOKEN"], "auth-token")
- self.assertEqual(env["CT0"], "ct0-token")
- self.assertEqual(env["BIRD_DISABLE_BROWSER_COOKIES"], "1")
+ def test_zero_does_not_fall_through(self):
+ """likeCount=0 should not fall through to like_count=10."""
+ tweets = [
+ {
+ "id": "1",
+ "text": "test",
+ "permanent_url": "https://x.com/u/status/1",
+ "likeCount": 0,
+ "like_count": 10,
+ }
+ ]
+ items = parse_bird_response(tweets, "test query")
+ self.assertEqual(0, items[0]["engagement"]["likes"])
if __name__ == "__main__":
diff --git a/tests/test_brave_search.py b/tests/test_brave_search.py
deleted file mode 100644
index e8d9255..0000000
--- a/tests/test_brave_search.py
+++ /dev/null
@@ -1,197 +0,0 @@
-"""Tests for Brave Search module, including LLM Context endpoint."""
-
-import sys
-import os
-import unittest
-
-# Ensure scripts/ is on path
-sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'scripts'))
-
-from lib.brave_search import (
- _normalize_results,
- _normalize_llm_context,
- _days_between,
- _brave_freshness,
- _parse_brave_date,
- EXCLUDED_DOMAINS,
-)
-
-
-class TestDaysBetween(unittest.TestCase):
- def test_same_day(self):
- self.assertEqual(_days_between("2026-03-01", "2026-03-01"), 1)
-
- def test_one_week(self):
- self.assertEqual(_days_between("2026-03-01", "2026-03-08"), 7)
-
- def test_invalid_dates(self):
- self.assertEqual(_days_between("bad", "dates"), 30)
-
-
-class TestBraveFreshness(unittest.TestCase):
- def test_one_day(self):
- self.assertEqual(_brave_freshness(1), "pd")
-
- def test_one_week(self):
- self.assertEqual(_brave_freshness(7), "pw")
-
- def test_one_month(self):
- self.assertEqual(_brave_freshness(31), "pm")
-
- def test_longer_returns_range(self):
- result = _brave_freshness(60)
- self.assertIn("to", result)
-
- def test_none(self):
- self.assertIsNone(_brave_freshness(None))
-
-
-class TestParseBraveDate(unittest.TestCase):
- def test_hours_ago(self):
- result = _parse_brave_date("3 hours ago", None)
- self.assertIsNotNone(result)
- self.assertRegex(result, r"\d{4}-\d{2}-\d{2}")
-
- def test_days_ago(self):
- result = _parse_brave_date("5 days ago", None)
- self.assertIsNotNone(result)
-
- def test_weeks_ago(self):
- result = _parse_brave_date("2 weeks ago", None)
- self.assertIsNotNone(result)
-
- def test_iso_date(self):
- self.assertEqual(_parse_brave_date("2026-03-10T12:00:00", None), "2026-03-10")
-
- def test_none(self):
- self.assertIsNone(_parse_brave_date(None, None))
-
-
-class TestNormalizeResults(unittest.TestCase):
- def test_merges_news_and_web(self):
- response = {
- "news": {"results": [
- {"url": "https://news.example.com/a", "title": "News A", "description": "News desc"},
- ]},
- "web": {"results": [
- {"url": "https://blog.example.com/b", "title": "Blog B", "description": "Blog desc"},
- ]},
- }
- items = _normalize_results(response, "2026-03-01", "2026-03-10")
- self.assertEqual(len(items), 2)
- self.assertEqual(items[0]["title"], "News A")
- self.assertEqual(items[1]["title"], "Blog B")
-
- def test_excludes_reddit_and_x(self):
- response = {
- "web": {"results": [
- {"url": "https://www.reddit.com/r/test/123", "title": "Reddit", "description": "text"},
- {"url": "https://x.com/user/status/1", "title": "X post", "description": "text"},
- {"url": "https://example.com/ok", "title": "OK", "description": "text"},
- ]},
- }
- items = _normalize_results(response, "2026-03-01", "2026-03-10")
- self.assertEqual(len(items), 1)
- self.assertEqual(items[0]["title"], "OK")
-
- def test_default_relevance(self):
- response = {"web": {"results": [
- {"url": "https://a.com", "title": "A", "description": "desc"},
- ]}}
- items = _normalize_results(response, "2026-03-01", "2026-03-10")
- self.assertEqual(items[0]["relevance"], 0.6)
-
-
-class TestNormalizeLlmContext(unittest.TestCase):
- def _make_response(self, generic=None, sources=None):
- return {
- "grounding": {"generic": generic or []},
- "sources": sources or {},
- }
-
- def test_basic_result(self):
- resp = self._make_response(
- generic=[{
- "url": "https://docs.example.com/page",
- "title": "Example Page",
- "snippets": ["First chunk of text.", "Second chunk of text."],
- }],
- sources={
- "https://docs.example.com/page": {
- "title": "Example Page",
- "hostname": "docs.example.com",
- "age": ["2026-03-05", "5 days ago"],
- }
- },
- )
- items = _normalize_llm_context(resp)
- self.assertEqual(len(items), 1)
- item = items[0]
- self.assertEqual(item["title"], "Example Page")
- self.assertEqual(item["url"], "https://docs.example.com/page")
- self.assertIn("First chunk", item["snippet"])
- self.assertIn("Second chunk", item["snippet"])
- self.assertEqual(item["date"], "2026-03-05")
- self.assertEqual(item["date_confidence"], "med")
- self.assertEqual(item["relevance"], 0.7)
- self.assertEqual(item["source_domain"], "docs.example.com")
-
- def test_excludes_reddit(self):
- resp = self._make_response(
- generic=[
- {"url": "https://www.reddit.com/r/test", "title": "Reddit", "snippets": ["text"]},
- {"url": "https://example.com", "title": "OK", "snippets": ["text"]},
- ],
- )
- items = _normalize_llm_context(resp)
- self.assertEqual(len(items), 1)
- self.assertEqual(items[0]["title"], "OK")
-
- def test_empty_grounding(self):
- resp = self._make_response()
- items = _normalize_llm_context(resp)
- self.assertEqual(items, [])
-
- def test_snippet_truncation(self):
- long_snippet = "x" * 2000
- resp = self._make_response(
- generic=[{"url": "https://a.com", "title": "A", "snippets": [long_snippet]}],
- )
- items = _normalize_llm_context(resp)
- self.assertLessEqual(len(items[0]["snippet"]), 1500)
-
- def test_no_date_gives_low_confidence(self):
- resp = self._make_response(
- generic=[{"url": "https://a.com", "title": "A", "snippets": ["text"]}],
- sources={"https://a.com": {"hostname": "a.com", "age": None}},
- )
- items = _normalize_llm_context(resp)
- self.assertIsNone(items[0]["date"])
- self.assertEqual(items[0]["date_confidence"], "low")
-
- def test_multiple_age_entries_picks_first_valid(self):
- resp = self._make_response(
- generic=[{"url": "https://a.com", "title": "A", "snippets": ["text"]}],
- sources={"https://a.com": {
- "hostname": "a.com",
- "age": ["Monday, March 10, 2026", "2026-03-10", "1 day ago"],
- }},
- )
- items = _normalize_llm_context(resp)
- self.assertEqual(items[0]["date"], "2026-03-10")
-
- def test_ids_are_sequential(self):
- resp = self._make_response(
- generic=[
- {"url": "https://a.com", "title": "A", "snippets": ["a"]},
- {"url": "https://b.com", "title": "B", "snippets": ["b"]},
- {"url": "https://c.com", "title": "C", "snippets": ["c"]},
- ],
- )
- items = _normalize_llm_context(resp)
- ids = [item["id"] for item in items]
- self.assertEqual(ids, ["W1", "W2", "W3"])
-
-
-if __name__ == "__main__":
- unittest.main()
diff --git a/tests/test_briefing_v3.py b/tests/test_briefing_v3.py
new file mode 100644
index 0000000..cd1cec4
--- /dev/null
+++ b/tests/test_briefing_v3.py
@@ -0,0 +1,35 @@
+import sys
+import tempfile
+import unittest
+from pathlib import Path
+
+sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "scripts"))
+
+import briefing
+import store
+
+
+class BriefingV3Tests(unittest.TestCase):
+ def test_generate_daily_uses_utc_for_last_run(self):
+ with tempfile.TemporaryDirectory() as tmpdir:
+ db_path = Path(tmpdir) / "research.db"
+ briefs_dir = Path(tmpdir) / "briefs"
+ old_db_override = store._db_override
+ old_briefs_dir = briefing.BRIEFS_DIR
+ try:
+ store._db_override = db_path
+ briefing.BRIEFS_DIR = briefs_dir
+ topic = store.add_topic("test topic")
+ store.record_run(topic["id"], source_mode="v3", status="completed")
+ result = briefing.generate_daily()
+ self.assertEqual(result["status"], "ok")
+ self.assertEqual(result["topics"][0]["name"], "test topic")
+ self.assertIsNotNone(result["topics"][0]["hours_ago"])
+ self.assertGreaterEqual(result["topics"][0]["hours_ago"], 0.0)
+ finally:
+ store._db_override = old_db_override
+ briefing.BRIEFS_DIR = old_briefs_dir
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_cache.py b/tests/test_cache.py
deleted file mode 100644
index fe1d9a0..0000000
--- a/tests/test_cache.py
+++ /dev/null
@@ -1,59 +0,0 @@
-"""Tests for cache module."""
-
-import sys
-import unittest
-from pathlib import Path
-
-# Add lib to path
-sys.path.insert(0, str(Path(__file__).parent.parent / "scripts"))
-
-from lib import cache
-
-
-class TestGetCacheKey(unittest.TestCase):
- def test_returns_string(self):
- result = cache.get_cache_key("test topic", "2026-01-01", "2026-01-31", "both")
- self.assertIsInstance(result, str)
-
- def test_consistent_for_same_inputs(self):
- key1 = cache.get_cache_key("test topic", "2026-01-01", "2026-01-31", "both")
- key2 = cache.get_cache_key("test topic", "2026-01-01", "2026-01-31", "both")
- self.assertEqual(key1, key2)
-
- def test_different_for_different_inputs(self):
- key1 = cache.get_cache_key("topic a", "2026-01-01", "2026-01-31", "both")
- key2 = cache.get_cache_key("topic b", "2026-01-01", "2026-01-31", "both")
- self.assertNotEqual(key1, key2)
-
- def test_key_length(self):
- key = cache.get_cache_key("test", "2026-01-01", "2026-01-31", "both")
- self.assertEqual(len(key), 16)
-
-
-class TestCachePath(unittest.TestCase):
- def test_returns_path(self):
- result = cache.get_cache_path("abc123")
- self.assertIsInstance(result, Path)
-
- def test_has_json_extension(self):
- result = cache.get_cache_path("abc123")
- self.assertEqual(result.suffix, ".json")
-
-
-class TestCacheValidity(unittest.TestCase):
- def test_nonexistent_file_is_invalid(self):
- fake_path = Path("/nonexistent/path/file.json")
- result = cache.is_cache_valid(fake_path)
- self.assertFalse(result)
-
-
-class TestModelCache(unittest.TestCase):
- def test_get_cached_model_returns_none_for_missing(self):
- # Clear any existing cache first
- result = cache.get_cached_model("nonexistent_provider")
- # May be None or a cached value, but should not error
- self.assertTrue(result is None or isinstance(result, str))
-
-
-if __name__ == "__main__":
- unittest.main()
diff --git a/tests/test_cli_v3.py b/tests/test_cli_v3.py
new file mode 100644
index 0000000..e09b68d
--- /dev/null
+++ b/tests/test_cli_v3.py
@@ -0,0 +1,190 @@
+# ruff: noqa: E402
+import json
+import io
+import tempfile
+import subprocess
+import sys
+import types
+import unittest
+from contextlib import redirect_stderr, redirect_stdout
+from pathlib import Path
+from unittest import mock
+
+
+REPO_ROOT = Path(__file__).resolve().parents[1]
+sys.path.insert(0, str(REPO_ROOT / "scripts"))
+
+import last30days as cli
+from lib import schema
+
+
+class CliV3Tests(unittest.TestCase):
+ def make_report(self) -> schema.Report:
+ return schema.Report(
+ topic="OpenClaw vs NanoClaw",
+ range_from="2026-02-14",
+ range_to="2026-03-16",
+ generated_at="2026-03-16T00:00:00+00:00",
+ provider_runtime=schema.ProviderRuntime(
+ reasoning_provider="gemini",
+ planner_model="gemini-3.1-flash-lite-preview",
+ rerank_model="gemini-3.1-flash-lite-preview",
+ ),
+ query_plan=schema.QueryPlan(
+ intent="comparison",
+ freshness_mode="balanced_recent",
+ cluster_mode="debate",
+ raw_topic="OpenClaw vs NanoClaw",
+ subqueries=[
+ schema.SubQuery(
+ label="primary",
+ search_query="openclaw vs nanoclaw",
+ ranking_query="How does OpenClaw compare to NanoClaw?",
+ sources=["grounding"],
+ )
+ ],
+ source_weights={"grounding": 1.0},
+ ),
+ clusters=[],
+ ranked_candidates=[],
+ items_by_source={"grounding": []},
+ errors_by_source={},
+ )
+
+ def test_mock_json_cli(self):
+ result = subprocess.run(
+ [sys.executable, "scripts/last30days.py", "test topic", "--mock", "--emit=json"],
+ cwd=REPO_ROOT,
+ capture_output=True,
+ text=True,
+ check=False,
+ )
+ self.assertEqual(0, result.returncode, result.stderr)
+ payload = json.loads(result.stdout)
+ self.assertIn("query_plan", payload)
+ self.assertIn("ranked_candidates", payload)
+ self.assertIn("clusters", payload)
+
+ def test_parse_search_flag_normalizes_aliases_and_dedupes(self):
+ self.assertEqual(
+ ["grounding", "reddit", "hackernews"],
+ cli.parse_search_flag("web, reddit, hn, web"),
+ )
+
+ def test_parse_search_flag_rejects_invalid_or_empty_inputs(self):
+ with self.assertRaises(SystemExit):
+ cli.parse_search_flag("unknown")
+ with self.assertRaises(SystemExit):
+ cli.parse_search_flag(" , ")
+
+ def test_missing_sources_for_promo_prefers_reddit_x_then_web(self):
+ self.assertEqual(
+ "both",
+ cli._missing_sources_for_promo({"available_sources": ["youtube"]}),
+ )
+ self.assertEqual(
+ "web",
+ cli._missing_sources_for_promo({"available_sources": ["reddit", "x"]}),
+ )
+ self.assertIsNone(
+ cli._missing_sources_for_promo({"available_sources": ["reddit", "x", "grounding"]}),
+ )
+
+ def test_slugify_and_emit_output_cover_supported_modes(self):
+ report = self.make_report()
+ self.assertEqual("openclaw-vs-nanoclaw", cli.slugify(report.topic))
+
+ compact = cli.emit_output(report, "compact")
+ json_output = cli.emit_output(report, "json")
+ context = cli.emit_output(report, "context")
+
+ self.assertIn("# last30days-3 v3.0.0-alpha", compact)
+ self.assertIn('"topic": "OpenClaw vs NanoClaw"', json_output)
+ self.assertIsInstance(context, str)
+
+ with self.assertRaises(SystemExit):
+ cli.emit_output(report, "bad-mode")
+
+ def test_save_output_writes_expected_extension(self):
+ report = self.make_report()
+ with tempfile.TemporaryDirectory() as tmp:
+ path = cli.save_output(report, "json", tmp)
+ self.assertEqual(".json", path.suffix)
+ payload = json.loads(path.read_text())
+ self.assertEqual("OpenClaw vs NanoClaw", payload["topic"])
+
+ def test_persist_report_updates_run_status_on_success_and_failure(self):
+ report = self.make_report()
+
+ success_store = types.SimpleNamespace(
+ init_db=mock.Mock(),
+ add_topic=mock.Mock(return_value={"id": 7}),
+ record_run=mock.Mock(return_value=11),
+ findings_from_report=mock.Mock(return_value=[{"title": "x"}]),
+ store_findings=mock.Mock(return_value={"new": 2, "updated": 1}),
+ update_run=mock.Mock(),
+ )
+ with mock.patch.dict(sys.modules, {"store": success_store}):
+ counts = cli.persist_report(report)
+ self.assertEqual({"new": 2, "updated": 1}, counts)
+ success_store.update_run.assert_called_once_with(
+ 11,
+ status="completed",
+ findings_new=2,
+ findings_updated=1,
+ )
+
+ failure_store = types.SimpleNamespace(
+ init_db=mock.Mock(),
+ add_topic=mock.Mock(return_value={"id": 7}),
+ record_run=mock.Mock(return_value=12),
+ findings_from_report=mock.Mock(side_effect=RuntimeError("boom")),
+ store_findings=mock.Mock(),
+ update_run=mock.Mock(),
+ )
+ with mock.patch.dict(sys.modules, {"store": failure_store}):
+ with self.assertRaises(RuntimeError):
+ cli.persist_report(report)
+ failure_store.update_run.assert_called_once()
+ _, kwargs = failure_store.update_run.call_args
+ self.assertEqual("failed", kwargs["status"])
+ self.assertIn("boom", kwargs["error_message"])
+
+ def test_main_wires_banner_and_progress_display(self):
+ report = self.make_report()
+ diag = {
+ "available_sources": ["grounding", "youtube"],
+ "providers": {"google": True, "openai": False, "xai": False},
+ "x_backend": None,
+ "bird_installed": True,
+ "bird_authenticated": False,
+ "bird_username": None,
+ "native_web_backend": "brave",
+ }
+ fake_progress = mock.Mock()
+ with mock.patch.object(cli.env, "get_config", return_value={}), \
+ mock.patch.object(cli.pipeline, "diagnose", return_value=diag), \
+ mock.patch.object(cli.pipeline, "run", return_value=report), \
+ mock.patch.object(cli.ui, "show_diagnostic_banner") as banner, \
+ mock.patch.object(cli.ui, "ProgressDisplay", return_value=fake_progress) as progress_cls, \
+ mock.patch.object(cli, "emit_output", return_value="# rendered"), \
+ mock.patch.object(sys, "argv", ["last30days.py", "test", "topic"]):
+ stdout = io.StringIO()
+ stderr = io.StringIO()
+ with redirect_stdout(stdout), redirect_stderr(stderr):
+ rc = cli.main()
+ self.assertEqual(0, rc)
+ banner.assert_not_called() # Banner moved to post-research
+ progress_cls.assert_called_once_with("test topic", show_banner=True)
+ fake_progress.start_processing.assert_called_once()
+ fake_progress.end_processing.assert_called_once()
+ fake_progress.show_complete.assert_called_once_with(
+ source_counts={"grounding": 0},
+ display_sources=["grounding"],
+ )
+ fake_progress.show_promo.assert_called_once_with("both", diag=diag)
+ self.assertIn("# rendered", stdout.getvalue())
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_cluster_v3.py b/tests/test_cluster_v3.py
new file mode 100644
index 0000000..86b7583
--- /dev/null
+++ b/tests/test_cluster_v3.py
@@ -0,0 +1,201 @@
+import sys
+import unittest
+from pathlib import Path
+
+sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "scripts"))
+
+from lib import cluster, schema
+
+
+def make_candidate(candidate_id: str, source: str, title: str, snippet: str, score: float) -> schema.Candidate:
+ return schema.Candidate(
+ candidate_id=candidate_id,
+ item_id=candidate_id,
+ source=source,
+ title=title,
+ url=f"https://example.com/{candidate_id}",
+ snippet=snippet,
+ subquery_labels=["primary"],
+ native_ranks={"primary:reddit": 1},
+ local_relevance=0.8,
+ freshness=80,
+ engagement=10,
+ source_quality=0.7,
+ rrf_score=0.02,
+ rerank_score=score,
+ final_score=score,
+ )
+
+
+class ClusterV3Tests(unittest.TestCase):
+ def test_singleton_clusters_for_non_clustered_plan(self):
+ plan = schema.QueryPlan(
+ intent="how_to",
+ freshness_mode="balanced_recent",
+ cluster_mode="none",
+ raw_topic="docker setup",
+ subqueries=[schema.SubQuery(label="primary", search_query="docker setup", ranking_query="How do I set up Docker?", sources=["reddit"])],
+ source_weights={"reddit": 1.0},
+ )
+ candidates = [
+ make_candidate("c1", "reddit", "Docker setup guide", "Step by step setup", 80),
+ make_candidate("c2", "youtube", "Docker install video", "Video walkthrough", 75),
+ ]
+ clusters = cluster.cluster_candidates(candidates, plan)
+ self.assertEqual(2, len(clusters))
+ self.assertEqual(["c1"], clusters[0].representative_ids)
+ self.assertEqual(["c2"], clusters[1].representative_ids)
+
+ def test_breaking_news_clusters_related_items(self):
+ plan = schema.QueryPlan(
+ intent="breaking_news",
+ freshness_mode="strict_recent",
+ cluster_mode="story",
+ raw_topic="model launch",
+ subqueries=[schema.SubQuery(label="primary", search_query="model launch", ranking_query="What happened in the model launch?", sources=["reddit", "x"])],
+ source_weights={"reddit": 0.5, "x": 0.5},
+ )
+ candidates = [
+ make_candidate("c1", "reddit", "Open model launch reactions", "People are reacting to the open model launch today.", 88),
+ make_candidate("c2", "x", "Open model launch update", "People are reacting to the open model launch today on X.", 84),
+ make_candidate("c3", "youtube", "Different topic", "A separate discussion about hardware benchmarks.", 70),
+ ]
+ clusters = cluster.cluster_candidates(candidates, plan)
+ self.assertEqual(2, len(clusters))
+ self.assertEqual(2, len(clusters[0].candidate_ids))
+ self.assertIn("c1", clusters[0].candidate_ids)
+ self.assertIn("c2", clusters[0].candidate_ids)
+
+
+class TestCrossSourceMerging(unittest.TestCase):
+ """Test the entity-based second pass that merges same-story clusters across sources."""
+
+ def _plan(self, intent="breaking_news"):
+ return schema.QueryPlan(
+ intent=intent,
+ freshness_mode="strict_recent",
+ cluster_mode="story",
+ raw_topic="test",
+ subqueries=[schema.SubQuery(label="primary", search_query="test", ranking_query="test", sources=["reddit", "x", "tiktok"])],
+ source_weights={"reddit": 0.5, "x": 0.5, "tiktok": 0.5},
+ )
+
+ def test_same_story_different_phrasing_merges(self):
+ """Wireless Festival example: same event, different wording, different sources."""
+ candidates = [
+ make_candidate("c1", "reddit", "Kanye West to headline all three nights of Wireless Festival 2026", "Big announcement for Wireless.", 80),
+ make_candidate("c2", "x", "BREAKING: Kanye West is making his massive UK comeback at Wireless Festival this July", "Ye returns to UK.", 75),
+ make_candidate("c3", "youtube", "Kanye West BULLY Album Review - Knox Hill Reacts", "Full album reaction and breakdown.", 70),
+ ]
+ clusters = cluster.cluster_candidates(candidates, self._plan())
+ # c1 and c2 should merge (Kanye + Wireless + Festival overlap), c3 should stay separate
+ self.assertEqual(2, len(clusters))
+ wireless_cluster = next(cl for cl in clusters if len(cl.candidate_ids) == 2)
+ self.assertIn("c1", wireless_cluster.candidate_ids)
+ self.assertIn("c2", wireless_cluster.candidate_ids)
+ self.assertEqual(sorted(["reddit", "x"]), wireless_cluster.sources)
+ # Multi-source cluster should not have "single-source" uncertainty
+ self.assertNotEqual("single-source", wireless_cluster.uncertainty)
+
+ def test_different_stories_dont_merge(self):
+ """Different topics should stay separate even with some entity overlap (e.g., 'Kanye')."""
+ candidates = [
+ make_candidate("c1", "reddit", "Kanye West BULLY Album First Impressions Thread", "What do you think of BULLY?", 80),
+ make_candidate("c2", "x", "Kanye West apology for antisemitism in Wall Street Journal ad", "Full page WSJ ad.", 75),
+ make_candidate("c3", "tiktok", "Kanye West Wireless Festival ticket prices breakdown", "How much for Wireless tickets?", 70),
+ ]
+ clusters = cluster.cluster_candidates(candidates, self._plan())
+ # These are 3 different stories, should remain as 3 clusters
+ self.assertEqual(3, len(clusters))
+
+ def test_same_source_clusters_dont_merge(self):
+ """Two single-source clusters from the same source should not merge via entity pass."""
+ candidates = [
+ make_candidate("c1", "reddit", "Kanye West Wireless Festival headline announcement", "Three nights!", 80),
+ make_candidate("c2", "reddit", "Kanye West returning to Wireless Festival confirmed", "UK comeback.", 70),
+ ]
+ clusters = cluster.cluster_candidates(candidates, self._plan())
+ # The initial greedy pass may or may not merge these (depends on token similarity).
+ # But if they end up as separate clusters, the entity pass should NOT merge them
+ # since they're both from reddit.
+ for cl in clusters:
+ self.assertTrue(len(cl.sources) >= 1) # basic sanity
+
+
+class TestPolymarketIsolation(unittest.TestCase):
+ """Polymarket clusters must not merge with non-Polymarket clusters via entity overlap."""
+
+ def _plan(self):
+ return schema.QueryPlan(
+ intent="breaking_news",
+ freshness_mode="strict_recent",
+ cluster_mode="story",
+ raw_topic="test",
+ subqueries=[schema.SubQuery(label="primary", search_query="test", ranking_query="test", sources=["reddit", "x", "polymarket"])],
+ source_weights={"reddit": 0.5, "x": 0.5, "polymarket": 0.5},
+ )
+
+ def test_polymarket_does_not_merge_into_news_cluster(self):
+ """A Polymarket prediction about Sam Altman should not merge into a news cluster about Sam Altman."""
+ candidates = [
+ make_candidate("c1", "reddit", "Sam Altman personal rivalry with Elon Musk escalates", "The feud between Sam Altman and Elon Musk continues.", 80),
+ make_candidate("c2", "polymarket", "Sam Altman equity stake in OpenAI valued at $500M", "Will Sam Altman receive equity in OpenAI restructuring?", 75),
+ ]
+ clusters = cluster.cluster_candidates(candidates, self._plan())
+ self.assertEqual(2, len(clusters), "Polymarket and news clusters should remain separate")
+ # Each cluster should have exactly one candidate
+ for cl in clusters:
+ self.assertEqual(1, len(cl.candidate_ids))
+
+ def test_two_polymarket_clusters_not_blocked_by_poly_guard(self):
+ """Two Polymarket items about the same topic are not blocked by the Polymarket guard.
+
+ Note: same-source clusters are still blocked by the existing same-source
+ guard, so we verify the poly guard specifically by checking that two
+ polymarket items with high text similarity merge via the greedy pass.
+ """
+ candidates = [
+ make_candidate("c1", "polymarket", "Sam Altman equity stake in OpenAI restructuring", "Will Sam Altman get equity in the OpenAI restructuring deal?", 80),
+ make_candidate("c2", "polymarket", "Sam Altman equity stake in OpenAI restructuring odds", "Will Sam Altman get equity in the OpenAI restructuring deal? Current odds.", 75),
+ ]
+ clusters = cluster.cluster_candidates(candidates, self._plan())
+ # High text similarity means greedy pass merges them
+ self.assertEqual(1, len(clusters))
+ self.assertEqual(2, len(clusters[0].candidate_ids))
+
+ def test_neither_polymarket_still_merges(self):
+ """Non-Polymarket clusters with entity overlap should still merge (existing behavior)."""
+ candidates = [
+ make_candidate("c1", "reddit", "Sam Altman OpenAI restructuring announcement details", "Sam Altman announces major OpenAI restructuring.", 80),
+ make_candidate("c2", "x", "Sam Altman reveals OpenAI restructuring plan for 2026", "Major OpenAI restructuring coming says Sam Altman.", 75),
+ ]
+ clusters = cluster.cluster_candidates(candidates, self._plan())
+ self.assertEqual(1, len(clusters))
+ self.assertEqual(2, len(clusters[0].candidate_ids))
+
+
+class TestClusterUncertainty(unittest.TestCase):
+ def test_single_source_returns_single_source(self):
+ candidates = [make_candidate("c1", "reddit", "Title", "Body", 80)]
+ result = cluster._cluster_uncertainty(candidates)
+ self.assertEqual("single-source", result)
+
+ def test_multi_source_high_score_returns_none(self):
+ candidates = [
+ make_candidate("c1", "reddit", "Title", "Body", 80),
+ make_candidate("c2", "x", "Title2", "Body2", 70),
+ ]
+ result = cluster._cluster_uncertainty(candidates)
+ self.assertIsNone(result)
+
+ def test_multi_source_low_score_returns_thin_evidence(self):
+ candidates = [
+ make_candidate("c1", "reddit", "Title", "Body", 30),
+ make_candidate("c2", "x", "Title2", "Body2", 40),
+ ]
+ result = cluster._cluster_uncertainty(candidates)
+ self.assertEqual("thin-evidence", result)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_codex_auth.py b/tests/test_codex_auth.py
deleted file mode 100644
index 0cbe72d..0000000
--- a/tests/test_codex_auth.py
+++ /dev/null
@@ -1,203 +0,0 @@
-"""Tests for Codex auth integration (env.py + openai_reddit.py)."""
-
-import base64
-import json
-import os
-import sys
-import time
-import unittest
-from pathlib import Path
-from unittest.mock import patch
-
-# Add scripts directory to path
-sys.path.insert(0, str(Path(__file__).parent.parent / "scripts"))
-
-from lib import env, openai_reddit
-
-
-def _make_jwt(payload: dict) -> str:
- """Build a fake JWT with the given payload (no signature verification)."""
- header = base64.urlsafe_b64encode(json.dumps({"alg": "none"}).encode()).rstrip(b"=")
- body = base64.urlsafe_b64encode(json.dumps(payload).encode()).rstrip(b"=")
- return f"{header.decode()}.{body.decode()}.fakesig"
-
-
-class TestDecodeJwtPayload(unittest.TestCase):
-
- def test_valid_jwt(self):
- token = _make_jwt({"sub": "user123", "exp": 9999999999})
- result = env._decode_jwt_payload(token)
- self.assertEqual(result["sub"], "user123")
-
- def test_invalid_jwt(self):
- self.assertIsNone(env._decode_jwt_payload("not-a-jwt"))
-
- def test_empty_string(self):
- self.assertIsNone(env._decode_jwt_payload(""))
-
-
-class TestTokenExpired(unittest.TestCase):
-
- def test_not_expired(self):
- token = _make_jwt({"exp": int(time.time()) + 3600})
- self.assertFalse(env._token_expired(token))
-
- def test_expired(self):
- token = _make_jwt({"exp": int(time.time()) - 100})
- self.assertTrue(env._token_expired(token))
-
- def test_no_exp_claim(self):
- token = _make_jwt({"sub": "user"})
- self.assertFalse(env._token_expired(token))
-
-
-class TestExtractChatgptAccountId(unittest.TestCase):
-
- def test_extracts_account_id(self):
- token = _make_jwt({
- "https://api.openai.com/auth": {
- "chatgpt_account_id": "acct_abc123"
- }
- })
- self.assertEqual(env.extract_chatgpt_account_id(token), "acct_abc123")
-
- def test_missing_auth_claim(self):
- token = _make_jwt({"sub": "user"})
- self.assertIsNone(env.extract_chatgpt_account_id(token))
-
- def test_missing_account_id_in_claim(self):
- token = _make_jwt({
- "https://api.openai.com/auth": {"other_field": "value"}
- })
- self.assertIsNone(env.extract_chatgpt_account_id(token))
-
-
-class TestGetOpenaiAuth(unittest.TestCase):
-
- @patch.dict(os.environ, {}, clear=True)
- def test_api_key_takes_priority(self):
- """OPENAI_API_KEY in env file is used when env var is not set."""
- file_env = {"OPENAI_API_KEY": "sk-test123"}
- auth = env.get_openai_auth(file_env)
- self.assertEqual(auth.source, "api_key")
- self.assertEqual(auth.status, "ok")
- self.assertEqual(auth.token, "sk-test123")
- self.assertIsNone(auth.account_id)
-
- @patch.dict(os.environ, {"OPENAI_API_KEY": "sk-from-env"}, clear=False)
- def test_env_var_takes_priority(self):
- """OPENAI_API_KEY env var should be preferred over file."""
- file_env = {}
- auth = env.get_openai_auth(file_env)
- self.assertEqual(auth.source, "api_key")
- self.assertEqual(auth.token, "sk-from-env")
-
- def test_no_keys_returns_none_source(self):
- """No API key and no Codex auth → source=none."""
- fake_path = Path("/tmp/nonexistent_codex_auth_test.json")
- with patch.object(env, 'CODEX_AUTH_FILE', fake_path):
- # Also patch get_codex_access_token to avoid reading real auth file
- with patch.object(env, 'get_codex_access_token', return_value=(None, "missing")):
- environ_copy = {k: v for k, v in os.environ.items() if k != "OPENAI_API_KEY"}
- with patch.dict(os.environ, environ_copy, clear=True):
- auth = env.get_openai_auth({})
- self.assertEqual(auth.source, "none")
- self.assertIsNone(auth.token)
-
-
-class TestLoadCodexAuth(unittest.TestCase):
-
- def test_nonexistent_file(self):
- result = env.load_codex_auth(Path("/tmp/nonexistent_codex_auth.json"))
- self.assertEqual(result, {})
-
- def test_valid_json(self):
- import tempfile
- data = {"tokens": {"access_token": "tok123"}}
- with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f:
- json.dump(data, f)
- f.flush()
- result = env.load_codex_auth(Path(f.name))
- os.unlink(f.name)
- self.assertEqual(result["tokens"]["access_token"], "tok123")
-
-
-class TestGetAvailableSourcesWithAuth(unittest.TestCase):
-
- @patch("lib.bird_x.is_bird_installed", return_value=False)
- def test_codex_auth_ok_counts_as_openai(self, _mock_bird):
- config = {
- "OPENAI_API_KEY": "codex-token",
- "OPENAI_AUTH_STATUS": "ok",
- "XAI_API_KEY": None,
- }
- result = env.get_available_sources(config)
- self.assertIn("reddit", result)
-
- @patch("lib.bird_x.is_bird_installed", return_value=False)
- def test_codex_auth_expired_not_counted(self, _mock_bird):
- config = {
- "OPENAI_API_KEY": None,
- "OPENAI_AUTH_STATUS": "expired",
- "XAI_API_KEY": None,
- }
- result = env.get_available_sources(config)
- # Reddit is available via public JSON fallback even without OpenAI auth
- self.assertEqual(result, "reddit")
-
-
-class TestParseCodexStream(unittest.TestCase):
-
- def test_response_completed_event(self):
- """Should extract response from response.completed SSE event."""
- sse = (
- 'data: {"type":"response.created","response":{"id":"r1"}}\n\n'
- 'data: {"type":"response.completed","response":{"id":"r1","output":[{"type":"message","content":[{"type":"output_text","text":"hello"}]}]}}\n\n'
- )
- result = openai_reddit._parse_codex_stream(sse)
- self.assertIn("output", result)
-
- def test_delta_fallback(self):
- """Should reconstruct text from delta events."""
- sse = (
- 'data: {"delta":"hel"}\n\n'
- 'data: {"delta":"lo"}\n\n'
- )
- result = openai_reddit._parse_codex_stream(sse)
- self.assertIn("output", result)
- text = result["output"][0]["content"][0]["text"]
- self.assertEqual(text, "hello")
-
- def test_empty_stream(self):
- result = openai_reddit._parse_codex_stream("")
- self.assertEqual(result, {})
-
-
-class TestBuildPayload(unittest.TestCase):
-
- def test_api_key_payload(self):
- payload = openai_reddit._build_payload(
- "gpt-4o", "instructions", "input text", "api_key"
- )
- self.assertEqual(payload["model"], "gpt-4o")
- self.assertEqual(payload["input"], "input text")
- self.assertNotIn("stream", payload)
-
- def test_codex_payload_has_stream(self):
- payload = openai_reddit._build_payload(
- "gpt-4o", "instructions", "input text", env.AUTH_SOURCE_CODEX
- )
- self.assertTrue(payload["stream"])
- # Input should be structured message format for Codex
- self.assertIsInstance(payload["input"], list)
- self.assertEqual(payload["input"][0]["role"], "user")
-
- def test_codex_payload_has_store_false(self):
- payload = openai_reddit._build_payload(
- "gpt-4o", "inst", "text", env.AUTH_SOURCE_CODEX
- )
- self.assertFalse(payload["store"])
-
-
-if __name__ == "__main__":
- unittest.main()
diff --git a/tests/test_cross_source.py b/tests/test_cross_source.py
deleted file mode 100644
index 70ecec7..0000000
--- a/tests/test_cross_source.py
+++ /dev/null
@@ -1,186 +0,0 @@
-"""Tests for cross-source linking."""
-
-import sys
-import unittest
-from pathlib import Path
-
-# Add lib to path
-sys.path.insert(0, str(Path(__file__).parent.parent / "scripts"))
-
-from lib import dedupe, schema
-
-
-class TestCrossSourceLink(unittest.TestCase):
- def _make_reddit(self, id, title, score=50):
- item = schema.RedditItem(id=id, title=title, url="", subreddit="test")
- item.score = score
- return item
-
- def _make_hn(self, id, title, score=50):
- item = schema.HackerNewsItem(id=id, title=title, url="", hn_url="", author="user")
- item.score = score
- return item
-
- def _make_x(self, id, text, score=50):
- item = schema.XItem(id=id, text=text, url="", author_handle="user")
- item.score = score
- return item
-
- def _make_yt(self, id, title, score=50):
- item = schema.YouTubeItem(id=id, title=title, url="", channel_name="ch")
- item.score = score
- return item
-
- def _make_web(self, id, title, score=50):
- item = schema.WebSearchItem(id=id, title=title, url="", source_domain="example.com", snippet="")
- item.score = score
- return item
-
- def test_no_crossrefs_for_unrelated(self):
- reddit = [self._make_reddit("R1", "Best Claude Code Tips")]
- hn = [self._make_hn("HN1", "Python Django Release Notes")]
- dedupe.cross_source_link(reddit, hn)
- self.assertEqual(reddit[0].cross_refs, [])
- self.assertEqual(hn[0].cross_refs, [])
-
- def test_bidirectional_link(self):
- reddit = [self._make_reddit("R1", "OpenAI launches GPT-5 with new features")]
- hn = [self._make_hn("HN1", "OpenAI launches GPT-5 with new features")]
- dedupe.cross_source_link(reddit, hn)
- self.assertIn("HN1", reddit[0].cross_refs)
- self.assertIn("R1", hn[0].cross_refs)
-
- def test_multi_source_link(self):
- reddit = [self._make_reddit("R1", "Claude Code gets new skill system")]
- hn = [self._make_hn("HN1", "Claude Code gets new skill system")]
- yt = [self._make_yt("YT1", "Claude Code gets new skill system")]
- dedupe.cross_source_link(reddit, hn, yt)
- # All three should reference each other
- self.assertEqual(len(reddit[0].cross_refs), 2)
- self.assertEqual(len(hn[0].cross_refs), 2)
- self.assertEqual(len(yt[0].cross_refs), 2)
-
- def test_same_source_not_linked(self):
- reddit = [
- self._make_reddit("R1", "OpenAI GPT-5 launch details"),
- self._make_reddit("R2", "OpenAI GPT-5 launch details"),
- ]
- dedupe.cross_source_link(reddit)
- self.assertEqual(reddit[0].cross_refs, [])
- self.assertEqual(reddit[1].cross_refs, [])
-
- def test_x_text_truncation_helps(self):
- # Truncation increases similarity vs full tweet.
- # A near-identical short tweet should match a Reddit title.
- reddit = [self._make_reddit("R1", "Anthropic releases Claude 4 model")]
- x_short = [self._make_x("X1", "Anthropic releases Claude 4 model today!")]
- dedupe.cross_source_link(reddit, x_short)
- self.assertIn("X1", reddit[0].cross_refs)
- self.assertIn("R1", x_short[0].cross_refs)
-
- def test_long_x_text_may_not_match(self):
- # When a tweet diverges significantly after the shared prefix,
- # Jaccard drops below 0.5 even with truncation. This is expected.
- reddit = [self._make_reddit("R1", "Anthropic releases Claude 4 model")]
- x_long = [self._make_x("X1",
- "Anthropic releases Claude 4 model and it's incredible. "
- "The reasoning capabilities are next level. Just tested it "
- "on my entire codebase and it understood everything."
- )]
- dedupe.cross_source_link(reddit, x_long)
- # May or may not match depending on trigram overlap - just verify no crash
- self.assertIsInstance(reddit[0].cross_refs, list)
-
- def test_empty_lists(self):
- # Should not crash
- dedupe.cross_source_link([], [], [])
-
- def test_single_item(self):
- reddit = [self._make_reddit("R1", "Test item")]
- dedupe.cross_source_link(reddit)
- self.assertEqual(reddit[0].cross_refs, [])
-
- def test_no_duplicate_refs(self):
- reddit = [self._make_reddit("R1", "Same exact title repeated")]
- hn = [self._make_hn("HN1", "Same exact title repeated")]
- # Call twice - should not duplicate refs
- dedupe.cross_source_link(reddit, hn)
- dedupe.cross_source_link(reddit, hn)
- self.assertEqual(reddit[0].cross_refs.count("HN1"), 1)
- self.assertEqual(hn[0].cross_refs.count("R1"), 1)
-
- def test_web_items_linked(self):
- web = [self._make_web("W1", "Claude Code skill system overview")]
- hn = [self._make_hn("HN1", "Claude Code skill system overview")]
- dedupe.cross_source_link(web, hn)
- self.assertIn("HN1", web[0].cross_refs)
- self.assertIn("W1", hn[0].cross_refs)
-
- def _make_pm(self, id, title, score=50):
- item = schema.PolymarketItem(id=id, title=title, question="Q?", url="")
- item.score = score
- return item
-
- def test_polymarket_to_reddit_link(self):
- reddit = [self._make_reddit("R1", "Will Arizona win the Big 12 Championship?")]
- pm = [self._make_pm("PM1", "Will Arizona win the Big 12 Championship?")]
- dedupe.cross_source_link(reddit, pm)
- self.assertIn("PM1", reddit[0].cross_refs)
- self.assertIn("R1", pm[0].cross_refs)
-
- def test_polymarket_multi_source(self):
- reddit = [self._make_reddit("R1", "Iran nuclear deal prediction markets")]
- hn = [self._make_hn("HN1", "Iran nuclear deal prediction markets")]
- pm = [self._make_pm("PM1", "Iran nuclear deal prediction markets")]
- dedupe.cross_source_link(reddit, hn, pm)
- self.assertEqual(len(reddit[0].cross_refs), 2)
- self.assertEqual(len(pm[0].cross_refs), 2)
-
-
-class TestCrossRefsSchemaRoundTrip(unittest.TestCase):
- def test_reddit_roundtrip(self):
- item = schema.RedditItem(id="R1", title="Test", url="", subreddit="test",
- cross_refs=["HN1", "X2"])
- d = item.to_dict()
- self.assertEqual(d['cross_refs'], ["HN1", "X2"])
-
- def test_reddit_empty_crossrefs_omitted(self):
- item = schema.RedditItem(id="R1", title="Test", url="", subreddit="test")
- d = item.to_dict()
- self.assertNotIn('cross_refs', d)
-
- def test_report_roundtrip(self):
- report = schema.Report(
- topic="test", range_from="2026-01-01", range_to="2026-02-01",
- generated_at="2026-02-01T00:00:00Z", mode="both",
- reddit=[schema.RedditItem(id="R1", title="T", url="", subreddit="s",
- cross_refs=["HN1"])],
- hackernews=[schema.HackerNewsItem(id="HN1", title="T", url="", hn_url="",
- author="u", cross_refs=["R1"])],
- )
- d = report.to_dict()
- restored = schema.Report.from_dict(d)
- self.assertEqual(restored.reddit[0].cross_refs, ["HN1"])
- self.assertEqual(restored.hackernews[0].cross_refs, ["R1"])
-
-
-class TestGetCrossSourceText(unittest.TestCase):
- def test_x_truncated(self):
- item = schema.XItem(id="X1", text="A" * 200, url="", author_handle="u")
- result = dedupe._get_cross_source_text(item)
- self.assertEqual(len(result), 100)
-
- def test_reddit_uses_title(self):
- item = schema.RedditItem(id="R1", title="My Title", url="", subreddit="s")
- result = dedupe._get_cross_source_text(item)
- self.assertEqual(result, "My Title")
-
- def test_web_uses_title(self):
- item = schema.WebSearchItem(id="W1", title="Web Title", url="",
- source_domain="example.com", snippet="snip")
- result = dedupe._get_cross_source_text(item)
- self.assertEqual(result, "Web Title")
-
-
-if __name__ == "__main__":
- unittest.main()
diff --git a/tests/test_dates_v3.py b/tests/test_dates_v3.py
new file mode 100644
index 0000000..6d445ef
--- /dev/null
+++ b/tests/test_dates_v3.py
@@ -0,0 +1,63 @@
+import sys
+import unittest
+from datetime import datetime, timedelta, timezone
+from pathlib import Path
+
+sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "scripts"))
+
+from lib import dates
+
+
+class DatesV3Tests(unittest.TestCase):
+ def test_get_date_range_returns_iso_window(self):
+ start, end = dates.get_date_range(7)
+ self.assertRegex(start, r"^\d{4}-\d{2}-\d{2}$")
+ self.assertRegex(end, r"^\d{4}-\d{2}-\d{2}$")
+ self.assertEqual(7, (datetime.fromisoformat(end) - datetime.fromisoformat(start)).days)
+
+ def test_parse_date_supports_timestamps_and_iso_variants(self):
+ unix_parsed = dates.parse_date("1710460800")
+ self.assertEqual("2024-03-15T00:00:00+00:00", unix_parsed.isoformat())
+
+ plain = dates.parse_date("2026-03-16")
+ self.assertEqual("2026-03-16T00:00:00+00:00", plain.isoformat())
+
+ zulu = dates.parse_date("2026-03-16T12:34:56Z")
+ self.assertEqual("2026-03-16T12:34:56+00:00", zulu.isoformat())
+
+ fractional = dates.parse_date("2026-03-16T12:34:56.123456+02:00")
+ self.assertEqual("2026-03-16T10:34:56.123456+00:00", fractional.isoformat())
+
+ def test_parse_date_rejects_empty_and_invalid_values(self):
+ self.assertIsNone(dates.parse_date(None))
+ self.assertIsNone(dates.parse_date(""))
+ self.assertIsNone(dates.parse_date("not-a-date"))
+
+ def test_timestamp_to_date_handles_valid_and_invalid_values(self):
+ self.assertEqual("2024-03-15", dates.timestamp_to_date(1710460800))
+ self.assertIsNone(dates.timestamp_to_date(None))
+ self.assertIsNone(dates.timestamp_to_date("bad"))
+
+ def test_get_date_confidence_distinguishes_in_range_and_invalid(self):
+ self.assertEqual("high", dates.get_date_confidence("2026-03-10", "2026-03-01", "2026-03-16"))
+ self.assertEqual("low", dates.get_date_confidence("2026-02-10", "2026-03-01", "2026-03-16"))
+ self.assertEqual("low", dates.get_date_confidence("2026-03-20", "2026-03-01", "2026-03-16"))
+ self.assertEqual("low", dates.get_date_confidence("bad", "2026-03-01", "2026-03-16"))
+ self.assertEqual("low", dates.get_date_confidence(None, "2026-03-01", "2026-03-16"))
+
+ def test_days_ago_and_recency_score_cover_edge_cases(self):
+ today = datetime.now(timezone.utc).date()
+ old = (today - timedelta(days=45)).isoformat()
+ future = (today + timedelta(days=1)).isoformat()
+
+ self.assertEqual(0, dates.days_ago(today.isoformat()))
+ self.assertIsNone(dates.days_ago("bad"))
+
+ self.assertEqual(100, dates.recency_score(today.isoformat()))
+ self.assertEqual(0, dates.recency_score(old))
+ self.assertEqual(100, dates.recency_score(future))
+ self.assertEqual(0, dates.recency_score(None))
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_dedupe.py b/tests/test_dedupe.py
deleted file mode 100644
index a790db5..0000000
--- a/tests/test_dedupe.py
+++ /dev/null
@@ -1,111 +0,0 @@
-"""Tests for dedupe module."""
-
-import sys
-import unittest
-from pathlib import Path
-
-# Add lib to path
-sys.path.insert(0, str(Path(__file__).parent.parent / "scripts"))
-
-from lib import dedupe, schema
-
-
-class TestNormalizeText(unittest.TestCase):
- def test_lowercase(self):
- result = dedupe.normalize_text("HELLO World")
- self.assertEqual(result, "hello world")
-
- def test_removes_punctuation(self):
- result = dedupe.normalize_text("Hello, World!")
- # Punctuation replaced with space, then whitespace collapsed
- self.assertEqual(result, "hello world")
-
- def test_collapses_whitespace(self):
- result = dedupe.normalize_text("hello world")
- self.assertEqual(result, "hello world")
-
-
-class TestGetNgrams(unittest.TestCase):
- def test_short_text(self):
- result = dedupe.get_ngrams("ab", n=3)
- self.assertEqual(result, {"ab"})
-
- def test_normal_text(self):
- result = dedupe.get_ngrams("hello", n=3)
- self.assertIn("hel", result)
- self.assertIn("ell", result)
- self.assertIn("llo", result)
-
-
-class TestJaccardSimilarity(unittest.TestCase):
- def test_identical_sets(self):
- set1 = {"a", "b", "c"}
- result = dedupe.jaccard_similarity(set1, set1)
- self.assertEqual(result, 1.0)
-
- def test_disjoint_sets(self):
- set1 = {"a", "b", "c"}
- set2 = {"d", "e", "f"}
- result = dedupe.jaccard_similarity(set1, set2)
- self.assertEqual(result, 0.0)
-
- def test_partial_overlap(self):
- set1 = {"a", "b", "c"}
- set2 = {"b", "c", "d"}
- result = dedupe.jaccard_similarity(set1, set2)
- self.assertEqual(result, 0.5) # 2 overlap / 4 union
-
- def test_empty_sets(self):
- result = dedupe.jaccard_similarity(set(), set())
- self.assertEqual(result, 0.0)
-
-
-class TestFindDuplicates(unittest.TestCase):
- def test_no_duplicates(self):
- items = [
- schema.RedditItem(id="R1", title="Completely different topic A", url="", subreddit=""),
- schema.RedditItem(id="R2", title="Another unrelated subject B", url="", subreddit=""),
- ]
- result = dedupe.find_duplicates(items)
- self.assertEqual(result, [])
-
- def test_finds_duplicates(self):
- items = [
- schema.RedditItem(id="R1", title="Best practices for Claude Code skills", url="", subreddit=""),
- schema.RedditItem(id="R2", title="Best practices for Claude Code skills guide", url="", subreddit=""),
- ]
- result = dedupe.find_duplicates(items, threshold=0.7)
- self.assertEqual(len(result), 1)
- self.assertEqual(result[0], (0, 1))
-
-
-class TestDedupeItems(unittest.TestCase):
- def test_keeps_higher_scored(self):
- items = [
- schema.RedditItem(id="R1", title="Best practices for skills", url="", subreddit="", score=90),
- schema.RedditItem(id="R2", title="Best practices for skills guide", url="", subreddit="", score=50),
- ]
- result = dedupe.dedupe_items(items, threshold=0.6)
- self.assertEqual(len(result), 1)
- self.assertEqual(result[0].id, "R1")
-
- def test_keeps_all_unique(self):
- items = [
- schema.RedditItem(id="R1", title="Topic about apples", url="", subreddit="", score=90),
- schema.RedditItem(id="R2", title="Discussion of oranges", url="", subreddit="", score=50),
- ]
- result = dedupe.dedupe_items(items)
- self.assertEqual(len(result), 2)
-
- def test_empty_list(self):
- result = dedupe.dedupe_items([])
- self.assertEqual(result, [])
-
- def test_single_item(self):
- items = [schema.RedditItem(id="R1", title="Test", url="", subreddit="")]
- result = dedupe.dedupe_items(items)
- self.assertEqual(len(result), 1)
-
-
-if __name__ == "__main__":
- unittest.main()
diff --git a/tests/test_dedupe_v3.py b/tests/test_dedupe_v3.py
new file mode 100644
index 0000000..3cb235f
--- /dev/null
+++ b/tests/test_dedupe_v3.py
@@ -0,0 +1,217 @@
+"""Unit tests for dedupe.py: text normalization, similarity metrics, and deduplication."""
+
+import sys
+import unittest
+from pathlib import Path
+
+sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "scripts"))
+
+from lib import dedupe
+from lib.schema import SourceItem
+
+
+def _item(title: str, body: str = "", source: str = "reddit", item_id: str = "t1") -> SourceItem:
+ return SourceItem(
+ item_id=item_id, source=source, title=title, body=body,
+ url="https://example.com", engagement={}, metadata={},
+ )
+
+
+# ---------------------------------------------------------------------------
+# normalize_text
+# ---------------------------------------------------------------------------
+
+class TestNormalizeText(unittest.TestCase):
+
+ def test_lowercases(self):
+ self.assertEqual(dedupe.normalize_text("Hello World"), "hello world")
+
+ def test_strips_punctuation(self):
+ self.assertEqual(dedupe.normalize_text("it's a test!"), "it s a test")
+
+ def test_collapses_whitespace(self):
+ self.assertEqual(dedupe.normalize_text("a b\t\nc"), "a b c")
+
+ def test_empty_string(self):
+ self.assertEqual(dedupe.normalize_text(""), "")
+
+
+# ---------------------------------------------------------------------------
+# get_ngrams
+# ---------------------------------------------------------------------------
+
+class TestGetNgrams(unittest.TestCase):
+
+ def test_simple_trigrams(self):
+ ngrams = dedupe.get_ngrams("abcde")
+ self.assertEqual(ngrams, {"abc", "bcd", "cde"})
+
+ def test_short_text_returns_whole(self):
+ ngrams = dedupe.get_ngrams("ab")
+ self.assertEqual(ngrams, {"ab"})
+
+ def test_empty_returns_empty_set(self):
+ self.assertEqual(dedupe.get_ngrams(""), set())
+
+ def test_normalizes_before_ngrams(self):
+ # "A!B" -> "a b" after normalization -> {"a b"}
+ ngrams = dedupe.get_ngrams("A!B")
+ self.assertEqual(ngrams, {"a b"})
+
+
+# ---------------------------------------------------------------------------
+# jaccard_similarity
+# ---------------------------------------------------------------------------
+
+class TestJaccardSimilarity(unittest.TestCase):
+
+ def test_identical_sets(self):
+ self.assertAlmostEqual(dedupe.jaccard_similarity({"a", "b"}, {"a", "b"}), 1.0)
+
+ def test_disjoint_sets(self):
+ self.assertAlmostEqual(dedupe.jaccard_similarity({"a"}, {"b"}), 0.0)
+
+ def test_partial_overlap(self):
+ result = dedupe.jaccard_similarity({"a", "b", "c"}, {"b", "c", "d"})
+ self.assertAlmostEqual(result, 2.0 / 4.0)
+
+ def test_empty_left(self):
+ self.assertAlmostEqual(dedupe.jaccard_similarity(set(), {"a"}), 0.0)
+
+ def test_both_empty(self):
+ self.assertAlmostEqual(dedupe.jaccard_similarity(set(), set()), 0.0)
+
+
+# ---------------------------------------------------------------------------
+# token_jaccard
+# ---------------------------------------------------------------------------
+
+class TestTokenJaccard(unittest.TestCase):
+
+ def test_identical_texts(self):
+ self.assertAlmostEqual(dedupe.token_jaccard("hello world", "hello world"), 1.0)
+
+ def test_completely_different(self):
+ self.assertAlmostEqual(dedupe.token_jaccard("alpha beta", "gamma delta"), 0.0)
+
+ def test_filters_stopwords(self):
+ # "the" and "a" are stopwords, so "big cat" vs "big dog" should compare on {big, cat} vs {big, dog}
+ result = dedupe.token_jaccard("the big cat", "a big dog")
+ self.assertAlmostEqual(result, 1.0 / 3.0) # {big} / {big, cat, dog}
+
+ def test_filters_single_char_tokens(self):
+ # Single char tokens like "I" are filtered (len > 1)
+ result = dedupe.token_jaccard("I am great", "I am terrible")
+ # "am" is len 2, "great"/"terrible" are content
+ self.assertGreater(result, 0.0)
+
+
+# ---------------------------------------------------------------------------
+# hybrid_similarity
+# ---------------------------------------------------------------------------
+
+class TestHybridSimilarity(unittest.TestCase):
+
+ def test_identical_texts(self):
+ self.assertAlmostEqual(dedupe.hybrid_similarity("same text", "same text"), 1.0)
+
+ def test_completely_different(self):
+ result = dedupe.hybrid_similarity("aaaaaa", "zzzzzz")
+ self.assertLess(result, 0.1)
+
+ def test_takes_max_of_both_methods(self):
+ text_a = "OpenClaw security issues discussion"
+ text_b = "OpenClaw security issues thread"
+ ngram_sim = dedupe.jaccard_similarity(
+ dedupe.get_ngrams(text_a), dedupe.get_ngrams(text_b)
+ )
+ token_sim = dedupe.token_jaccard(text_a, text_b)
+ self.assertAlmostEqual(
+ dedupe.hybrid_similarity(text_a, text_b),
+ max(ngram_sim, token_sim),
+ )
+
+
+# ---------------------------------------------------------------------------
+# item_text
+# ---------------------------------------------------------------------------
+
+class TestItemText(unittest.TestCase):
+
+ def test_combines_fields(self):
+ item = _item("My Title", "My Body")
+ text = dedupe.item_text(item)
+ self.assertIn("My Title", text)
+ self.assertIn("My Body", text)
+
+ def test_skips_none_fields(self):
+ item = _item("Title", "")
+ item.author = None
+ item.container = None
+ text = dedupe.item_text(item)
+ self.assertEqual(text, "Title")
+
+ def test_includes_author_and_container(self):
+ item = _item("Title", "Body")
+ item.author = "john"
+ item.container = "r/python"
+ text = dedupe.item_text(item)
+ self.assertIn("john", text)
+ self.assertIn("r/python", text)
+
+
+# ---------------------------------------------------------------------------
+# dedupe_items
+# ---------------------------------------------------------------------------
+
+class TestDedupeItems(unittest.TestCase):
+
+ def test_keeps_unique_items(self):
+ items = [
+ _item("OpenClaw is amazing", item_id="a"),
+ _item("NanoClaw security review", item_id="b"),
+ _item("IronClaw Rust architecture", item_id="c"),
+ ]
+ result = dedupe.dedupe_items(items)
+ self.assertEqual(len(result), 3)
+
+ def test_removes_near_duplicates(self):
+ items = [
+ _item("OpenClaw vs NanoClaw comparison review", item_id="a"),
+ _item("OpenClaw vs NanoClaw comparison review thread", item_id="b"),
+ ]
+ result = dedupe.dedupe_items(items)
+ self.assertEqual(len(result), 1)
+ self.assertEqual(result[0].item_id, "a") # keeps first
+
+ def test_keeps_first_of_duplicates(self):
+ items = [
+ _item("Same title here", item_id="first"),
+ _item("Same title here", item_id="second"),
+ ]
+ result = dedupe.dedupe_items(items)
+ self.assertEqual(result[0].item_id, "first")
+
+ def test_empty_body_items_kept(self):
+ item = SourceItem(
+ item_id="empty", source="reddit", title="", body="",
+ url="", engagement={}, metadata={},
+ )
+ result = dedupe.dedupe_items([item])
+ self.assertEqual(len(result), 1)
+
+ def test_threshold_respected(self):
+ items = [
+ _item("OpenClaw security analysis", item_id="a"),
+ _item("OpenClaw security review", item_id="b"),
+ ]
+ # With threshold=1.0, only exact matches are removed
+ result = dedupe.dedupe_items(items, threshold=1.0)
+ self.assertEqual(len(result), 2)
+ # With threshold=0.3, these similar items collapse
+ result_loose = dedupe.dedupe_items(items, threshold=0.3)
+ self.assertEqual(len(result_loose), 1)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_entity_extract_v3.py b/tests/test_entity_extract_v3.py
new file mode 100644
index 0000000..b779d2e
--- /dev/null
+++ b/tests/test_entity_extract_v3.py
@@ -0,0 +1,58 @@
+import sys
+import unittest
+from pathlib import Path
+
+sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "scripts"))
+
+from lib import entity_extract
+
+
+class TestExtractSubreddits(unittest.TestCase):
+ def test_extracts_primary_subreddit(self):
+ items = [{"subreddit": "r/MachineLearning"}]
+ result = entity_extract._extract_subreddits(items)
+ self.assertIn("MachineLearning", result)
+
+ def test_extracts_subreddit_without_prefix(self):
+ items = [{"subreddit": "localLLaMA"}]
+ result = entity_extract._extract_subreddits(items)
+ self.assertIn("localLLaMA", result)
+
+ def test_extracts_cross_references_from_comment_insights(self):
+ items = [
+ {
+ "subreddit": "technology",
+ "comment_insights": ["check out r/MachineLearning and r/LocalLLaMA for more"],
+ }
+ ]
+ result = entity_extract._extract_subreddits(items)
+ self.assertIn("MachineLearning", result)
+ self.assertIn("LocalLLaMA", result)
+
+ def test_extracts_cross_references_from_top_comments(self):
+ items = [
+ {
+ "subreddit": "AI",
+ "top_comments": [
+ {"excerpt": "see r/StableDiffusion for image gen stuff"},
+ ],
+ }
+ ]
+ result = entity_extract._extract_subreddits(items)
+ self.assertIn("StableDiffusion", result)
+
+ def test_ranks_by_frequency(self):
+ items = [
+ {"subreddit": "A"},
+ {"subreddit": "A"},
+ {"subreddit": "B"},
+ ]
+ result = entity_extract._extract_subreddits(items)
+ self.assertEqual(result[0], "A")
+
+ def test_empty_items(self):
+ self.assertEqual([], entity_extract._extract_subreddits([]))
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_env_cookies.py b/tests/test_env_cookies.py
index aabb00c..4be7d69 100644
--- a/tests/test_env_cookies.py
+++ b/tests/test_env_cookies.py
@@ -1,15 +1,16 @@
"""Tests for browser cookie extraction integration in env.py."""
-from unittest.mock import patch, MagicMock
+import os
+import sys
+from pathlib import Path
+from unittest.mock import patch
import pytest
-from scripts.lib.env import extract_browser_credentials, COOKIE_DOMAINS
+sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "scripts"))
+from lib.env import extract_browser_credentials, COOKIE_DOMAINS
-# ---------------------------------------------------------------------------
-# Helpers
-# ---------------------------------------------------------------------------
def _base_config(**overrides):
"""Return a minimal config dict with common defaults."""
@@ -24,155 +25,105 @@ def _base_config(**overrides):
return cfg
-# ---------------------------------------------------------------------------
-# Tests
-# ---------------------------------------------------------------------------
-
class TestExtractBrowserCredentials:
"""Unit tests for extract_browser_credentials()."""
- @patch("scripts.lib.cookie_extract.extract_cookies_with_source")
- def test_auto_with_setup_complete_populates_credentials(self, mock_extract):
- """FROM_BROWSER=auto, SETUP_COMPLETE=true, mock returns valid cookies
- -> config now contains AUTH_TOKEN and CT0."""
- mock_extract.return_value = ({"auth_token": "tok123", "ct0": "ct0val"}, "chrome")
-
- config = _base_config(FROM_BROWSER="auto", SETUP_COMPLETE="true")
+ @patch("lib.cookie_extract.extract_cookies")
+ def test_auto_populates_credentials(self, mock_extract):
+ mock_extract.return_value = {"auth_token": "tok123", "ct0": "ct0val"}
+ config = _base_config(FROM_BROWSER="auto")
result = extract_browser_credentials(config)
-
assert result["AUTH_TOKEN"] == "tok123"
assert result["CT0"] == "ct0val"
- # Should have been called for x domain
- mock_extract.assert_any_call("auto", ".x.com", ["auth_token", "ct0"])
+ # auto mode tries firefox first, then safari, then chrome
+ mock_extract.assert_any_call("firefox", ".x.com", ["auth_token", "ct0"])
- @patch("scripts.lib.cookie_extract.extract_cookies_with_source")
+ @patch("lib.cookie_extract.extract_cookies")
def test_explicit_auth_token_skips_x_extraction(self, mock_extract):
- """Config already has AUTH_TOKEN and CT0 from env var
- -> cookie extraction skipped for X (explicit takes priority)."""
- # Return None for any non-X domains that still get checked (e.g. Truth Social)
mock_extract.return_value = None
config = _base_config(
- AUTH_TOKEN="explicit_token",
- CT0="explicit_ct0",
+ AUTH_TOKEN="explicit_token", CT0="explicit_ct0",
FROM_BROWSER="auto",
- SETUP_COMPLETE="true",
)
result = extract_browser_credentials(config)
-
- # X cookies should not appear in result (already set)
assert "AUTH_TOKEN" not in result
assert "CT0" not in result
- # extract_cookies should NOT have been called for .x.com
for call in mock_extract.call_args_list:
- assert call[0][1] != ".x.com", "Should not extract cookies for X when credentials already set"
+ assert call[0][1] != ".x.com"
- @patch("scripts.lib.cookie_extract.extract_cookies_with_source")
+ @patch("lib.cookie_extract.extract_cookies")
def test_from_browser_off_skips_all(self, mock_extract):
- """FROM_BROWSER=off -> no cookie extraction attempted."""
- config = _base_config(FROM_BROWSER="off", SETUP_COMPLETE="true")
+ config = _base_config(FROM_BROWSER="off")
result = extract_browser_credentials(config)
-
assert result == {}
mock_extract.assert_not_called()
- @patch("scripts.lib.cookie_extract.extract_cookies_with_source")
- def test_no_setup_complete_no_from_browser_defaults_off(self, mock_extract):
- """FROM_BROWSER not set and SETUP_COMPLETE not set
- -> no extraction (wizard hasn't run)."""
- config = _base_config() # both None
+ @patch("lib.cookie_extract.extract_cookies")
+ def test_no_from_browser_defaults_to_silent(self, mock_extract):
+ """Default (no FROM_BROWSER): tries Firefox and Safari only, skips Chrome."""
+ mock_extract.return_value = None
+ config = _base_config()
result = extract_browser_credentials(config)
-
assert result == {}
- mock_extract.assert_not_called()
+ # Should try firefox and safari but NOT chrome
+ browser_args = [call[0][0] for call in mock_extract.call_args_list]
+ assert "firefox" in browser_args
+ assert "safari" in browser_args
+ assert "chrome" not in browser_args
- @patch("scripts.lib.cookie_extract.extract_cookies_with_source")
- def test_setup_complete_no_from_browser_defaults_auto(self, mock_extract):
- """SETUP_COMPLETE is set but FROM_BROWSER is not
- -> defaults to 'auto'."""
- mock_extract.return_value = ({"auth_token": "found", "ct0": "found_ct0"}, "firefox")
-
- config = _base_config(SETUP_COMPLETE="true") # FROM_BROWSER=None
- result = extract_browser_credentials(config)
-
- assert result["AUTH_TOKEN"] == "found"
- # extract_cookies should have been called with 'auto'
- mock_extract.assert_any_call("auto", ".x.com", ["auth_token", "ct0"])
-
- @patch("scripts.lib.cookie_extract.extract_cookies_with_source")
+ @patch("lib.cookie_extract.extract_cookies")
def test_from_browser_firefox_only(self, mock_extract):
- """FROM_BROWSER=firefox -> only Firefox extraction attempted."""
- mock_extract.return_value = ({"auth_token": "ff_tok", "ct0": "ff_ct0"}, "firefox")
-
- config = _base_config(FROM_BROWSER="firefox", SETUP_COMPLETE="true")
+ mock_extract.return_value = {"auth_token": "ff_tok", "ct0": "ff_ct0"}
+ config = _base_config(FROM_BROWSER="firefox")
result = extract_browser_credentials(config)
-
assert result["AUTH_TOKEN"] == "ff_tok"
- # All calls should use 'firefox' as the browser arg
for call in mock_extract.call_args_list:
assert call[0][0] == "firefox"
- @patch("scripts.lib.cookie_extract.extract_cookies_with_source")
+ @patch("lib.cookie_extract.extract_cookies")
def test_extraction_returns_none_config_unchanged(self, mock_extract):
- """Cookie extraction returns None for X -> config unchanged for AUTH_TOKEN."""
mock_extract.return_value = None
-
- config = _base_config(FROM_BROWSER="auto", SETUP_COMPLETE="true")
+ config = _base_config(FROM_BROWSER="auto")
result = extract_browser_credentials(config)
-
assert "AUTH_TOKEN" not in result
assert "CT0" not in result
- @patch("scripts.lib.cookie_extract.extract_cookies_with_source")
- def test_extraction_raises_exception_config_unchanged(self, mock_extract):
- """Cookie extraction raises exception -> caught, config unchanged."""
+ @patch("lib.cookie_extract.extract_cookies")
+ def test_extraction_raises_exception_caught(self, mock_extract):
mock_extract.side_effect = RuntimeError("database locked")
-
- config = _base_config(FROM_BROWSER="auto", SETUP_COMPLETE="true")
+ config = _base_config(FROM_BROWSER="auto")
result = extract_browser_credentials(config)
-
- # Should not raise, and no credentials populated
assert "AUTH_TOKEN" not in result
assert "CT0" not in result
- @patch("scripts.lib.cookie_extract.extract_cookies_with_source")
+ @patch("lib.cookie_extract.extract_cookies")
def test_partial_credentials_only_fills_missing(self, mock_extract):
- """If AUTH_TOKEN is set but CT0 is not, only CT0 gets filled."""
- mock_extract.return_value = ({"auth_token": "cookie_tok", "ct0": "cookie_ct0"}, "chrome")
-
+ mock_extract.return_value = {"auth_token": "cookie_tok", "ct0": "cookie_ct0"}
config = _base_config(
- AUTH_TOKEN="explicit",
- CT0=None,
+ AUTH_TOKEN="explicit", CT0=None,
FROM_BROWSER="auto",
- SETUP_COMPLETE="true",
)
result = extract_browser_credentials(config)
-
- # AUTH_TOKEN already set, should not be overridden
assert "AUTH_TOKEN" not in result
- # CT0 was missing, should be filled
assert result["CT0"] == "cookie_ct0"
class TestGetConfigCookieIntegration:
"""Integration test: get_config() calls extract_browser_credentials."""
- @patch("scripts.lib.cookie_extract.extract_cookies_with_source")
- @patch("scripts.lib.env._find_project_env", return_value=None)
- @patch("scripts.lib.env.load_env_file", return_value={})
- @patch("scripts.lib.env.get_openai_auth")
+ @patch("lib.cookie_extract.extract_cookies")
+ @patch("lib.env._find_project_env", return_value=None)
+ @patch("lib.env.load_env_file", return_value={})
+ @patch("lib.env.get_openai_auth")
def test_get_config_injects_cookies(
self, mock_openai, mock_load, mock_proj, mock_extract
):
- """get_config merges browser cookies into the returned config."""
- from scripts.lib.env import get_config, OpenAIAuth
-
+ from lib.env import get_config, OpenAIAuth
mock_openai.return_value = OpenAIAuth(
token=None, source="none", status="missing",
account_id=None, codex_auth_file="/fake",
)
- mock_extract.return_value = ({"auth_token": "browser_tok", "ct0": "browser_ct0"}, "firefox")
-
- import os
+ mock_extract.return_value = {"auth_token": "browser_tok", "ct0": "browser_ct0"}
env_patch = {
"SETUP_COMPLETE": "true",
"FROM_BROWSER": "auto",
@@ -180,6 +131,5 @@ class TestGetConfigCookieIntegration:
}
with patch.dict(os.environ, env_patch, clear=False):
config = get_config()
-
assert config["AUTH_TOKEN"] == "browser_tok"
assert config["CT0"] == "browser_ct0"
diff --git a/tests/test_env_project.py b/tests/test_env_project.py
deleted file mode 100644
index 5d3e698..0000000
--- a/tests/test_env_project.py
+++ /dev/null
@@ -1,236 +0,0 @@
-"""Tests for per-project .env config discovery and precedence."""
-
-import os
-import sys
-import unittest
-from pathlib import Path
-from unittest.mock import patch
-
-# Add lib to path
-sys.path.insert(0, str(Path(__file__).parent.parent / "scripts"))
-
-from lib import env
-
-
-class TestFindProjectEnv(unittest.TestCase):
- """Tests for _find_project_env() directory walking."""
-
- def test_finds_env_in_cwd(self, ):
- import tempfile
- with tempfile.TemporaryDirectory() as tmpdir:
- claude_dir = Path(tmpdir) / ".claude"
- claude_dir.mkdir()
- env_file = claude_dir / "last30days.env"
- env_file.write_text("OPENAI_API_KEY=sk-test\n")
- with patch.object(Path, 'cwd', return_value=Path(tmpdir)):
- result = env._find_project_env()
- self.assertIsNotNone(result)
- self.assertEqual(result.name, "last30days.env")
-
- def test_returns_none_when_no_config(self):
- import tempfile
- with tempfile.TemporaryDirectory() as tmpdir:
- with patch.object(Path, 'cwd', return_value=Path(tmpdir)):
- result = env._find_project_env()
- self.assertIsNone(result)
-
-
-class TestConfigPrecedence(unittest.TestCase):
- """Tests for config source priority."""
-
- def test_project_overrides_global(self):
- import tempfile
- with tempfile.TemporaryDirectory() as tmpdir:
- # Create global config
- global_dir = Path(tmpdir) / "global"
- global_dir.mkdir()
- global_env = global_dir / ".env"
- global_env.write_text("BRAVE_API_KEY=global-key\nPARALLEL_API_KEY=global-only\n")
-
- # Create project config
- project_dir = Path(tmpdir) / "project" / ".claude"
- project_dir.mkdir(parents=True)
- project_env = project_dir / "last30days.env"
- project_env.write_text("BRAVE_API_KEY=project-key\n")
-
- with patch.object(Path, 'cwd', return_value=Path(tmpdir) / "project"), \
- patch.object(env, 'CONFIG_FILE', global_env), \
- patch.dict(os.environ, {}, clear=False):
- # Remove any env vars that would override
- for k in ('BRAVE_API_KEY', 'PARALLEL_API_KEY', 'OPENAI_API_KEY'):
- os.environ.pop(k, None)
- config = env.get_config()
- # Project value wins over global
- self.assertEqual(config['BRAVE_API_KEY'], 'project-key')
- # Global value still available for keys not in project
- self.assertEqual(config['PARALLEL_API_KEY'], 'global-only')
-
- def test_env_var_overrides_project(self):
- import tempfile
- with tempfile.TemporaryDirectory() as tmpdir:
- project_dir = Path(tmpdir) / ".claude"
- project_dir.mkdir()
- project_env = project_dir / "last30days.env"
- project_env.write_text("BRAVE_API_KEY=project-key\n")
-
- with patch.object(Path, 'cwd', return_value=Path(tmpdir)), \
- patch.object(env, 'CONFIG_FILE', None), \
- patch.dict(os.environ, {'BRAVE_API_KEY': 'env-key'}):
- config = env.get_config()
- self.assertEqual(config['BRAVE_API_KEY'], 'env-key')
-
- def test_gemini_keys_load_from_project_env(self):
- import tempfile
- with tempfile.TemporaryDirectory() as tmpdir:
- project_dir = Path(tmpdir) / ".claude"
- project_dir.mkdir()
- project_env = project_dir / "last30days.env"
- project_env.write_text("GEMINI_API_KEY=gem-key\nGEMINI_MODEL=gemini-3-pro-preview\n")
-
- with patch.object(Path, 'cwd', return_value=Path(tmpdir)), \
- patch.object(env, 'CONFIG_FILE', None), \
- patch.dict(os.environ, {}, clear=False):
- os.environ.pop('GEMINI_API_KEY', None)
- os.environ.pop('GEMINI_MODEL', None)
- config = env.get_config()
- self.assertEqual(config['GEMINI_API_KEY'], 'gem-key')
- self.assertEqual(config['GEMINI_MODEL'], 'gemini-3-pro-preview')
-
- def test_google_api_key_loads_from_project_env(self):
- import tempfile
- with tempfile.TemporaryDirectory() as tmpdir:
- project_dir = Path(tmpdir) / ".claude"
- project_dir.mkdir()
- project_env = project_dir / "last30days.env"
- project_env.write_text("GOOGLE_API_KEY=google-key\n")
-
- with patch.object(Path, 'cwd', return_value=Path(tmpdir)), \
- patch.object(env, 'CONFIG_FILE', None), \
- patch.dict(os.environ, {}, clear=False):
- os.environ.pop('GOOGLE_API_KEY', None)
- config = env.get_config()
- self.assertEqual(config['GOOGLE_API_KEY'], 'google-key')
-
-
-class TestConfigSource(unittest.TestCase):
- """Tests for _CONFIG_SOURCE tracking."""
-
- def test_tracks_project_source(self):
- import tempfile
- with tempfile.TemporaryDirectory() as tmpdir:
- project_dir = Path(tmpdir) / ".claude"
- project_dir.mkdir()
- project_env = project_dir / "last30days.env"
- project_env.write_text("BRAVE_API_KEY=test\n")
-
- with patch.object(Path, 'cwd', return_value=Path(tmpdir)), \
- patch.object(env, 'CONFIG_FILE', None):
- config = env.get_config()
- self.assertIn('project:', config['_CONFIG_SOURCE'])
-
- def test_tracks_global_source(self):
- import tempfile
- with tempfile.TemporaryDirectory() as tmpdir:
- global_env = Path(tmpdir) / ".env"
- global_env.write_text("BRAVE_API_KEY=test\n")
-
- with patch.object(Path, 'cwd', return_value=Path(tmpdir)), \
- patch.object(env, 'CONFIG_FILE', global_env):
- config = env.get_config()
- self.assertIn('global:', config['_CONFIG_SOURCE'])
-
- def test_tracks_env_only(self):
- import tempfile
- with tempfile.TemporaryDirectory() as tmpdir:
- with patch.object(Path, 'cwd', return_value=Path(tmpdir)), \
- patch.object(env, 'CONFIG_FILE', None):
- config = env.get_config()
- self.assertEqual(config['_CONFIG_SOURCE'], 'env_only')
-
-
-class TestConfigExists(unittest.TestCase):
- """Tests for config_exists() with project support."""
-
- def test_true_with_project_env(self):
- import tempfile
- with tempfile.TemporaryDirectory() as tmpdir:
- project_dir = Path(tmpdir) / ".claude"
- project_dir.mkdir()
- (project_dir / "last30days.env").write_text("KEY=val\n")
- with patch.object(Path, 'cwd', return_value=Path(tmpdir)):
- self.assertTrue(env.config_exists())
-
- def test_true_with_global_env(self):
- import tempfile
- with tempfile.TemporaryDirectory() as tmpdir:
- global_env = Path(tmpdir) / ".env"
- global_env.write_text("KEY=val\n")
- with patch.object(Path, 'cwd', return_value=Path(tmpdir)), \
- patch.object(env, 'CONFIG_FILE', global_env):
- self.assertTrue(env.config_exists())
-
- def test_false_with_nothing(self):
- import tempfile
- with tempfile.TemporaryDirectory() as tmpdir:
- with patch.object(Path, 'cwd', return_value=Path(tmpdir)), \
- patch.object(env, 'CONFIG_FILE', None):
- self.assertFalse(env.config_exists())
-
-
-class TestFilePermissions(unittest.TestCase):
- """Tests for _check_file_permissions() warnings."""
-
- def test_warns_on_world_readable(self):
- import tempfile
- import io
- with tempfile.TemporaryDirectory() as tmpdir:
- f = Path(tmpdir) / ".env"
- f.write_text("KEY=val\n")
- f.chmod(0o644)
- stderr = io.StringIO()
- with patch('sys.stderr', stderr):
- env._check_file_permissions(f)
- self.assertIn("WARNING", stderr.getvalue())
-
- def test_no_warning_on_600(self):
- import tempfile
- import io
- with tempfile.TemporaryDirectory() as tmpdir:
- f = Path(tmpdir) / ".env"
- f.write_text("KEY=val\n")
- f.chmod(0o600)
- stderr = io.StringIO()
- with patch('sys.stderr', stderr):
- env._check_file_permissions(f)
- self.assertEqual(stderr.getvalue(), "")
-
-
-class TestXSourceSelection(unittest.TestCase):
- """Tests for supported X backend selection."""
-
- def test_get_x_source_ignores_scrapecreators_key(self):
- config = {'SCRAPECREATORS_API_KEY': 'sc-key'}
-
- with patch('lib.bird_x.is_bird_installed', return_value=False):
- self.assertIsNone(env.get_x_source(config))
-
- def test_get_x_source_status_ignores_scrapecreators_key(self):
- config = {'SCRAPECREATORS_API_KEY': 'sc-key'}
- bird_status = {
- 'installed': True,
- 'authenticated': False,
- 'username': None,
- 'can_install': False,
- }
-
- with patch('lib.bird_x.get_bird_status', return_value=bird_status), \
- patch('lib.bird_x.is_bird_installed', return_value=True), \
- patch('lib.bird_x.is_bird_authenticated', return_value=None):
- status = env.get_x_source_status(config)
-
- self.assertIsNone(status['source'])
- self.assertFalse(status['xai_available'])
-
-
-if __name__ == "__main__":
- unittest.main()
diff --git a/tests/test_env_v3.py b/tests/test_env_v3.py
new file mode 100644
index 0000000..ab8a70c
--- /dev/null
+++ b/tests/test_env_v3.py
@@ -0,0 +1,43 @@
+import os
+import sys
+import unittest
+from pathlib import Path
+from unittest import mock
+
+sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "scripts"))
+
+from lib import bird_x, env
+
+
+class EnvV3Tests(unittest.TestCase):
+ def setUp(self):
+ self._saved_credentials = dict(bird_x._credentials)
+
+ def tearDown(self):
+ bird_x._credentials.clear()
+ bird_x._credentials.update(self._saved_credentials)
+
+ def test_x_source_prefers_xai_without_bird_probe(self):
+ with mock.patch("lib.bird_x.is_bird_authenticated", side_effect=AssertionError("should not probe bird auth")):
+ source = env.get_x_source({"XAI_API_KEY": "test"})
+ self.assertEqual("xai", source)
+
+ def test_x_source_uses_bird_with_explicit_cookies(self):
+ with mock.patch("lib.bird_x.is_bird_installed", return_value=True):
+ source = env.get_x_source({"AUTH_TOKEN": "a", "CT0": "b"})
+ self.assertEqual("bird", source)
+ self.assertEqual("a", bird_x._credentials["AUTH_TOKEN"])
+ self.assertEqual("b", bird_x._credentials["CT0"])
+
+ def test_bird_auth_never_checks_browser_cookies(self):
+ with mock.patch("lib.bird_x.is_bird_installed", return_value=True), mock.patch(
+ "lib.bird_x.subprocess.run",
+ side_effect=AssertionError("browser-cookie whoami should not run"),
+ ):
+ bird_x._credentials.clear()
+ with mock.patch.dict(os.environ, {}, clear=False):
+ self.assertIsNone(bird_x.is_bird_authenticated())
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_evaluate_search_quality.py b/tests/test_evaluate_search_quality.py
deleted file mode 100644
index 1b94ffa..0000000
--- a/tests/test_evaluate_search_quality.py
+++ /dev/null
@@ -1,134 +0,0 @@
-"""Tests for the local search-quality evaluation harness."""
-
-import sys
-import tempfile
-import unittest
-from pathlib import Path
-from unittest.mock import patch
-
-sys.path.insert(0, str(Path(__file__).parent.parent / "scripts"))
-
-import evaluate_search_quality as evalsq
-
-
-class TestMetrics(unittest.TestCase):
- def test_jaccard(self):
- self.assertAlmostEqual(evalsq.jaccard({"a", "b"}, {"b", "c"}), 1 / 3)
-
- def test_retention(self):
- self.assertAlmostEqual(evalsq.retention({"a", "b"}, {"b", "c"}), 0.5)
-
- def test_precision_at_k(self):
- ranking = [
- {"key": "a", "source": "reddit"},
- {"key": "b", "source": "x"},
- {"key": "c", "source": "youtube"},
- ]
- judgments = {"a": 3, "b": 1, "c": 2}
- self.assertAlmostEqual(evalsq.precision_at_k(ranking, judgments, 2), 0.5)
-
- def test_ndcg_at_k(self):
- ranking = [
- {"key": "a", "source": "reddit"},
- {"key": "b", "source": "x"},
- {"key": "c", "source": "youtube"},
- ]
- judgments = {"a": 3, "b": 0, "c": 2}
- self.assertGreater(evalsq.ndcg_at_k(ranking, judgments, 3), 0.8)
-
- def test_ndcg_at_k_uses_best_items_from_judged_pool(self):
- ranking = [
- {"key": "a", "source": "reddit"},
- {"key": "b", "source": "x"},
- {"key": "c", "source": "youtube"},
- ]
- judged_pool = ranking + [
- {"key": "d", "source": "reddit"},
- {"key": "e", "source": "x"},
- ]
- judgments = {"a": 3, "b": 0, "c": 0, "d": 3, "e": 2}
- self.assertLess(
- evalsq.ndcg_at_k(ranking, judgments, 3, judged_pool),
- 1.0,
- )
-
- def test_source_coverage_recall_uses_union_pool(self):
- judged_pool = [
- {"key": "a", "source": "reddit"},
- {"key": "b", "source": "x"},
- {"key": "c", "source": "youtube"},
- ]
- ranking = [
- {"key": "a", "source": "reddit"},
- {"key": "b", "source": "x"},
- ]
- judgments = {"a": 3, "b": 0, "c": 2}
- self.assertAlmostEqual(evalsq.source_coverage_recall(ranking, judged_pool, judgments), 0.5)
-
-
-class TestRankedItems(unittest.TestCase):
- def test_build_ranked_items_sorts_by_score(self):
- report = {
- "reddit": [{"id": "R1", "title": "Low", "url": "r1", "score": 20}],
- "x": [{"id": "X1", "text": "High", "url": "x1", "score": 90}],
- "youtube": [],
- "tiktok": [],
- "instagram": [],
- "hackernews": [],
- "bluesky": [],
- "truthsocial": [],
- "polymarket": [],
- "websearch": [],
- }
- ranked = evalsq.build_ranked_items(report, per_source_limit=5)
- self.assertEqual(ranked[0]["key"], "x1")
-
-
-class TestPathWithoutNode(unittest.TestCase):
- def test_removes_node_entries(self):
- path = "/usr/bin:/tmp/node-bin:/opt/homebrew/bin"
-
- def fake_exists(path_obj):
- return str(path_obj).endswith("/tmp/node-bin/node")
-
- with patch.object(evalsq.Path, "exists", fake_exists):
- filtered = evalsq.path_without_node(path)
- self.assertEqual(filtered, "/usr/bin:/opt/homebrew/bin")
-
-
-class TestEvalToolPath(unittest.TestCase):
- def test_wraps_ytdlp_with_ignore_config(self):
- with tempfile.TemporaryDirectory() as tmpdir:
- eval_home = Path(tmpdir)
- with patch.object(evalsq.shutil, "which", return_value="/opt/homebrew/bin/yt-dlp"):
- path_value = evalsq.create_eval_tool_path(eval_home, "/usr/bin")
- wrapper = eval_home / "bin" / "yt-dlp"
- self.assertTrue(wrapper.exists())
- text = wrapper.read_text()
- self.assertIn("--ignore-config", text)
- self.assertIn("--no-cookies-from-browser", text)
- self.assertEqual(path_value, f"{eval_home / 'bin'}:/usr/bin")
-
-
-class TestJudgeKeyResolution(unittest.TestCase):
- def test_prefers_google_api_key(self):
- config = {
- "GOOGLE_API_KEY": "google-key",
- "GEMINI_API_KEY": "gem-key",
- "GOOGLE_GENAI_API_KEY": "genai-key",
- }
- self.assertEqual(evalsq.resolve_google_judge_api_key(config), "google-key")
-
- def test_falls_back_to_gemini_aliases(self):
- self.assertEqual(
- evalsq.resolve_google_judge_api_key({"GEMINI_API_KEY": "gem-key"}),
- "gem-key",
- )
- self.assertEqual(
- evalsq.resolve_google_judge_api_key({"GOOGLE_GENAI_API_KEY": "genai-key"}),
- "genai-key",
- )
-
-
-if __name__ == "__main__":
- unittest.main()
diff --git a/tests/test_evaluator_v3.py b/tests/test_evaluator_v3.py
new file mode 100644
index 0000000..604ef32
--- /dev/null
+++ b/tests/test_evaluator_v3.py
@@ -0,0 +1,203 @@
+import json
+import sys
+import tempfile
+import unittest
+from pathlib import Path
+from unittest import mock
+
+sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "scripts"))
+
+import evaluate_search_quality as evaluator
+
+
+class EvaluatorV3Tests(unittest.TestCase):
+ def test_build_ranked_items_uses_multi_source_provenance_and_best_date(self):
+ report = {
+ "ranked_candidates": [
+ {
+ "candidate_id": "c1",
+ "item_id": "i1",
+ "source": "grounding",
+ "sources": ["grounding", "reddit"],
+ "title": "Title",
+ "url": "https://example.com",
+ "snippet": "Snippet",
+ "subquery_labels": ["primary"],
+ "native_ranks": {"primary:grounding": 1},
+ "local_relevance": 0.8,
+ "freshness": 90,
+ "engagement": None,
+ "source_quality": 1.0,
+ "rrf_score": 0.02,
+ "final_score": 88.0,
+ "source_items": [
+ {"item_id": "i1", "source": "grounding", "title": "Title", "body": "Body", "url": "https://example.com", "published_at": "2026-03-10"},
+ {"item_id": "i2", "source": "reddit", "title": "Title", "body": "Body", "url": "https://example.com", "published_at": "2026-03-12"},
+ ],
+ }
+ ]
+ }
+ items = evaluator.build_ranked_items(report, 10)
+ self.assertEqual(["grounding", "reddit"], items[0]["sources"])
+ self.assertEqual("grounding, reddit", items[0]["source"])
+ self.assertEqual("2026-03-12", items[0]["date"])
+
+ grouped = evaluator.source_sets(report, 10)
+ self.assertEqual({"c1"}, grouped["grounding"])
+ self.assertEqual({"c1"}, grouped["reddit"])
+
+ def test_write_failure_summary_persists_failures(self):
+ with tempfile.TemporaryDirectory() as tmp:
+ output_dir = Path(tmp)
+ evaluator.write_failure_summary(
+ output_dir,
+ "HEAD~1",
+ "HEAD",
+ summaries=[{
+ "topic": "test topic",
+ "baseline": {"precision_at_5": 0.5, "ndcg_at_5": 0.6, "source_coverage_recall": 1.0},
+ "candidate": {"precision_at_5": 0.7, "ndcg_at_5": 0.8, "source_coverage_recall": 1.0},
+ "stability": {"overall_jaccard": 0.4, "overall_retention_vs_baseline": 0.9},
+ }],
+ failures=[{"topic": "broken topic", "error": "timeout"}],
+ )
+ metrics = json.loads((output_dir / "metrics.json").read_text())
+ summary = (output_dir / "summary.md").read_text()
+ self.assertEqual(1, len(metrics["failures"]))
+ self.assertIn("broken topic", summary)
+ self.assertIn("## Failures", summary)
+
+ def test_resolve_repo_dir_keeps_live_worktree(self):
+ repo_dir, is_temp = evaluator.resolve_repo_dir("WORKTREE")
+ self.assertEqual(evaluator.REPO_ROOT, repo_dir)
+ self.assertFalse(is_temp)
+
+ def test_resolve_repo_dir_materializes_git_ref_in_temp_worktree(self):
+ fake_dir = Path("/tmp/last30days-eval-fake")
+ with mock.patch.object(evaluator, "create_worktree", return_value=fake_dir) as create_worktree:
+ repo_dir, is_temp = evaluator.resolve_repo_dir("HEAD~2")
+ create_worktree.assert_called_once_with("HEAD~2")
+ self.assertEqual(fake_dir, repo_dir)
+ self.assertTrue(is_temp)
+
+ def test_metric_helpers_cover_empty_and_ranked_cases(self):
+ ranking = [
+ {"key": "a", "sources": ["grounding"]},
+ {"key": "b", "sources": ["reddit"]},
+ ]
+ judged = [{"key": "a", "sources": ["grounding"]}, {"key": "b", "sources": ["reddit"]}]
+ judgments = {"a": 3, "b": 1}
+
+ self.assertEqual(1.0, evaluator.jaccard(set(), set()))
+ self.assertEqual(1.0, evaluator.retention(set(), {"a"}))
+ self.assertEqual(0.5, evaluator.precision_at_k(ranking, judgments, 2))
+ self.assertGreater(evaluator.ndcg_at_k(ranking, judgments, 2, judged), 0.0)
+ self.assertEqual(1.0, evaluator.source_coverage_recall(ranking, judged, judgments))
+ self.assertEqual(0.0, evaluator.precision_at_k([], judgments, 5))
+ self.assertEqual(0.0, evaluator.ndcg_at_k([], judgments, 5, judged))
+
+ def test_resolve_google_judge_api_key_prefers_google_key(self):
+ with mock.patch.dict("os.environ", {"GOOGLE_API_KEY": "google", "GEMINI_API_KEY": "gemini"}, clear=False):
+ self.assertEqual("google", evaluator.resolve_google_judge_api_key({}))
+ self.assertEqual("fallback", evaluator.resolve_google_judge_api_key({"GOOGLE_GENAI_API_KEY": "fallback"}))
+
+ def test_extract_gemini_text_raises_when_missing(self):
+ self.assertEqual(
+ "hello",
+ evaluator.extract_gemini_text({"candidates": [{"content": {"parts": [{"text": "hello"}]}}]}),
+ )
+ with self.assertRaises(ValueError):
+ evaluator.extract_gemini_text({"candidates": [{"content": {"parts": [{}]}}]})
+
+ def test_get_judgments_uses_cache_and_skips_when_not_configured(self):
+ with tempfile.TemporaryDirectory() as tmp:
+ output_dir = Path(tmp)
+ cache_dir = output_dir / "judgments"
+ cache_dir.mkdir()
+ (cache_dir / "topic.json").write_text(json.dumps({"judgments": [{"id": "a", "grade": 3}]}))
+ cached = evaluator.get_judgments(
+ output_dir=output_dir,
+ slug="topic",
+ topic="test topic",
+ query_type="general",
+ items=[{"key": "a"}],
+ judge_model="gemini-3.1-flash-lite-preview",
+ gemini_api_key="key",
+ )
+ self.assertEqual({"a": 3}, cached)
+
+ skipped = evaluator.get_judgments(
+ output_dir=output_dir,
+ slug="fresh",
+ topic="test topic",
+ query_type="general",
+ items=[],
+ judge_model="gemini-3.1-flash-lite-preview",
+ gemini_api_key=None,
+ )
+ self.assertEqual({}, skipped)
+
+ def test_create_eval_env_and_run_last30days(self):
+ with mock.patch.object(evaluator.envlib, "get_config", return_value={"OPENAI_API_KEY": "config-openai"}):
+ with mock.patch.dict("os.environ", {"PATH": "/bin", "GOOGLE_API_KEY": "env-google"}, clear=False):
+ created = evaluator.create_eval_env()
+ self.assertEqual("/bin", created["PATH"])
+ self.assertEqual("env-google", created["GOOGLE_API_KEY"])
+ self.assertEqual("config-openai", created["OPENAI_API_KEY"])
+ self.assertEqual("", created["LAST30DAYS_CONFIG_DIR"])
+
+ with mock.patch.object(evaluator.subprocess, "run", return_value=mock.Mock(returncode=0, stdout='{"topic":"x"}', stderr="")):
+ payload = evaluator.run_last30days(
+ Path("/tmp/repo"),
+ "topic",
+ search="reddit",
+ timeout_seconds=30,
+ quick=True,
+ mock=True,
+ env={"PATH": "/bin"},
+ )
+ self.assertEqual("x", payload["topic"])
+
+ with mock.patch.object(evaluator.subprocess, "run", return_value=mock.Mock(returncode=2, stdout="", stderr="bad run")):
+ with self.assertRaises(RuntimeError):
+ evaluator.run_last30days(
+ Path("/tmp/repo"),
+ "topic",
+ search="reddit",
+ timeout_seconds=30,
+ quick=False,
+ mock=False,
+ env={"PATH": "/bin"},
+ )
+
+ def test_parse_topics_file_and_summary_writer(self):
+ with tempfile.TemporaryDirectory() as tmp:
+ tmp_path = Path(tmp)
+ topics_path = tmp_path / "topics.json"
+ topics_path.write_text(json.dumps([{"topic": "topic a", "query_type": "comparison"}, {"topic": "topic b"}]))
+ self.assertEqual(
+ [("topic a", "comparison"), ("topic b", "general")],
+ evaluator.parse_topics_file(topics_path),
+ )
+
+ evaluator.write_summary(
+ tmp_path,
+ "HEAD~1",
+ "WORKTREE",
+ [
+ {
+ "topic": "topic a",
+ "baseline": {"precision_at_5": 0.1, "ndcg_at_5": 0.2, "source_coverage_recall": 0.5},
+ "candidate": {"precision_at_5": 0.3, "ndcg_at_5": 0.4, "source_coverage_recall": 0.8},
+ "stability": {"overall_jaccard": 0.6, "overall_retention_vs_baseline": 0.7},
+ }
+ ],
+ )
+ summary = (tmp_path / "summary.md").read_text()
+ metrics = json.loads((tmp_path / "metrics.json").read_text())
+ self.assertIn("| topic a | 0.10 | 0.30 |", summary)
+ self.assertEqual("HEAD~1", metrics["baseline"])
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_exa_search.py b/tests/test_exa_search.py
index 3692428..8473347 100644
--- a/tests/test_exa_search.py
+++ b/tests/test_exa_search.py
@@ -12,7 +12,7 @@ sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'scripts'))
os.environ['LAST30DAYS_CONFIG_DIR'] = ''
from lib.exa_search import search_web, _normalize_results, _parse_exa_date, EXCLUDED_DOMAINS
-from lib import env, http
+from lib import http
class TestNormalizeResults(unittest.TestCase):
@@ -200,49 +200,27 @@ class TestSearchWebIntegration(unittest.TestCase):
self.assertEqual(results, [])
-class TestEnvExaPriority(unittest.TestCase):
- """Test that Exa is prioritized correctly in env.py."""
+class TestExaNormalizationV3Keys(unittest.TestCase):
+ """Test that Exa results include v3 normalization keys."""
- def test_no_exa_key_not_selected(self):
- config = {}
- self.assertIsNone(env.get_web_search_source(config))
-
- def test_exa_key_selected(self):
- config = {"EXA_API_KEY": "exa-test-key"}
- self.assertEqual(env.get_web_search_source(config), "exa")
-
- def test_exa_takes_priority_over_brave(self):
- config = {"EXA_API_KEY": "exa-key", "BRAVE_API_KEY": "brave-key"}
- self.assertEqual(env.get_web_search_source(config), "exa")
-
- def test_exa_takes_priority_over_parallel(self):
- config = {"EXA_API_KEY": "exa-key", "PARALLEL_API_KEY": "parallel-key"}
- self.assertEqual(env.get_web_search_source(config), "exa")
-
- def test_exa_takes_priority_over_openrouter(self):
- config = {"EXA_API_KEY": "exa-key", "OPENROUTER_API_KEY": "or-key"}
- self.assertEqual(env.get_web_search_source(config), "exa")
-
- def test_exa_takes_priority_over_all(self):
- config = {
- "EXA_API_KEY": "exa-key",
- "PARALLEL_API_KEY": "parallel-key",
- "BRAVE_API_KEY": "brave-key",
- "OPENROUTER_API_KEY": "or-key",
+ def test_results_include_engagement_and_metadata(self):
+ response = {
+ "results": [
+ {
+ "title": "Test Article",
+ "url": "https://example.com/test",
+ "text": "Content here",
+ "publishedDate": "2026-03-15T00:00:00.000Z",
+ "score": 0.8,
+ },
+ ]
}
- self.assertEqual(env.get_web_search_source(config), "exa")
-
- def test_fallback_to_parallel_without_exa(self):
- config = {"PARALLEL_API_KEY": "parallel-key", "BRAVE_API_KEY": "brave-key"}
- self.assertEqual(env.get_web_search_source(config), "parallel")
-
- def test_has_web_search_keys_with_exa(self):
- config = {"EXA_API_KEY": "exa-key"}
- self.assertTrue(env.has_web_search_keys(config))
-
- def test_has_web_search_keys_without_any(self):
- config = {}
- self.assertFalse(env.has_web_search_keys(config))
+ items = _normalize_results(response)
+ self.assertEqual(len(items), 1)
+ self.assertIn("engagement", items[0])
+ self.assertIn("metadata", items[0])
+ self.assertEqual(items[0]["engagement"], {})
+ self.assertEqual(items[0]["metadata"], {})
if __name__ == "__main__":
diff --git a/tests/test_fusion_v3.py b/tests/test_fusion_v3.py
new file mode 100644
index 0000000..618caf9
--- /dev/null
+++ b/tests/test_fusion_v3.py
@@ -0,0 +1,444 @@
+import sys
+import unittest
+from pathlib import Path
+
+sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "scripts"))
+
+from lib import fusion, schema
+
+
+def make_item(item_id: str, source: str, url: str, title: str, rank_score: float) -> schema.SourceItem:
+ return schema.SourceItem(
+ item_id=item_id,
+ source=source,
+ title=title,
+ body=title,
+ url=url,
+ relevance_hint=rank_score,
+ snippet=title,
+ metadata={
+ "local_relevance": rank_score,
+ "freshness": 80,
+ "engagement_score": 5,
+ "source_quality": 0.7,
+ },
+ )
+
+
+class FusionV3Tests(unittest.TestCase):
+ def test_weighted_rrf_merges_duplicate_urls(self):
+ plan = schema.QueryPlan(
+ intent="breaking_news",
+ freshness_mode="strict_recent",
+ cluster_mode="story",
+ raw_topic="test",
+ subqueries=[
+ schema.SubQuery(label="primary", search_query="test", ranking_query="What happened in test?", sources=["reddit", "x"], weight=0.7),
+ schema.SubQuery(label="reaction", search_query="test reaction", ranking_query="What are the reactions to test?", sources=["x"], weight=0.3),
+ ],
+ source_weights={"reddit": 0.4, "x": 0.6},
+ )
+ shared = "https://example.com/shared"
+ streams = {
+ ("primary", "reddit"): [make_item("r1", "reddit", shared, "Shared item", 0.8)],
+ ("primary", "x"): [make_item("x1", "x", shared, "Shared item", 0.9)],
+ ("reaction", "x"): [make_item("x2", "x", "https://example.com/unique", "Unique item", 0.7)],
+ }
+ candidates = fusion.weighted_rrf(streams, plan, pool_limit=10)
+ self.assertEqual(2, len(candidates))
+ merged = next(candidate for candidate in candidates if candidate.url == shared)
+ self.assertEqual({"primary"}, set(merged.subquery_labels))
+ self.assertEqual(2, len(merged.native_ranks))
+ self.assertEqual({"reddit", "x"}, set(merged.sources))
+ self.assertEqual(2, len(merged.source_items))
+
+
+ def test_diversify_pool_guarantees_min_per_qualifying_source(self):
+ """Every qualifying source (local_relevance >= 0.25) gets at least 2
+ items in the fused pool.
+
+ Dominant sources (x, tiktok) get high weights, so pure-RRF truncation
+ would squeeze out low-weight sources entirely. The diversity guarantee
+ must reserve at least 2 slots per qualifying active source. All sources
+ here have rank_score=0.8 (well above the 0.25 threshold), so every
+ source qualifies for reserved slots.
+ """
+ sources = ["reddit", "hackernews", "x", "tiktok", "bluesky", "youtube"]
+ # Heavily skewed weights: x and tiktok dominate.
+ weights = {
+ "x": 3.0,
+ "tiktok": 2.5,
+ "reddit": 0.5,
+ "hackernews": 0.4,
+ "bluesky": 0.3,
+ "youtube": 0.3,
+ }
+ plan = schema.QueryPlan(
+ intent="concept",
+ freshness_mode="relaxed",
+ cluster_mode="concept",
+ raw_topic="RAG",
+ subqueries=[
+ schema.SubQuery(
+ label="primary",
+ search_query="RAG",
+ ranking_query="What is RAG?",
+ sources=sources,
+ weight=1.0,
+ ),
+ ],
+ source_weights=weights,
+ )
+ streams: dict[tuple[str, str], list[schema.SourceItem]] = {}
+ for src in sources:
+ items = []
+ for rank in range(4):
+ items.append(
+ make_item(
+ item_id=f"{src}_{rank}",
+ source=src,
+ url=f"https://{src}.example.com/{rank}",
+ title=f"{src} item {rank}",
+ rank_score=0.8,
+ )
+ )
+ streams[("primary", src)] = items
+
+ candidates = fusion.weighted_rrf(streams, plan, pool_limit=12)
+ self.assertEqual(12, len(candidates))
+
+ source_counts: dict[str, int] = {}
+ for c in candidates:
+ source_counts[c.source] = source_counts.get(c.source, 0) + 1
+
+ for src in sources:
+ self.assertGreaterEqual(
+ source_counts.get(src, 0),
+ 2,
+ f"Source '{src}' has {source_counts.get(src, 0)} items, expected >= 2",
+ )
+
+
+ def test_diversify_pool_denies_slots_for_low_relevance_source(self):
+ """Sources with best local_relevance < 0.25 do not get reserved slots.
+
+ Create two sources: 'x' with local_relevance=0.5 (qualifies) and
+ 'reddit' with local_relevance=0.1 (below threshold). With a tight
+ pool_limit, the high-relevance source gets reserved slots while
+ the low-relevance source must compete on RRF merit alone.
+ """
+ plan = schema.QueryPlan(
+ intent="concept",
+ freshness_mode="relaxed",
+ cluster_mode="concept",
+ raw_topic="test",
+ subqueries=[
+ schema.SubQuery(
+ label="primary",
+ search_query="test",
+ ranking_query="What is test?",
+ sources=["x", "reddit"],
+ weight=1.0,
+ ),
+ ],
+ source_weights={"x": 1.0, "reddit": 1.0},
+ )
+
+ # x items: high relevance (0.5) -- qualifies for diversity reservation
+ x_items = [
+ make_item(f"x_{i}", "x", f"https://x.example.com/{i}", f"x item {i}", 0.5)
+ for i in range(4)
+ ]
+
+ # reddit items: low relevance (0.1) -- below threshold, no reserved slots
+ reddit_items = [
+ make_item(f"r_{i}", "reddit", f"https://reddit.example.com/{i}", f"reddit item {i}", 0.1)
+ for i in range(4)
+ ]
+
+ streams = {
+ ("primary", "x"): x_items,
+ ("primary", "reddit"): reddit_items,
+ }
+
+ # pool_limit=3: x gets 2 reserved + 1 more by RRF. Reddit has no
+ # reserved slots, so it must out-score x items in the remainder.
+ candidates = fusion.weighted_rrf(streams, plan, pool_limit=3)
+ self.assertEqual(3, len(candidates))
+
+ # x must have at least 2 (reserved slots)
+ x_count = sum(1 for c in candidates if c.source == "x")
+ self.assertGreaterEqual(x_count, 2, "x should have at least 2 reserved slots")
+
+ def test_diversify_pool_no_reservation_when_all_below_threshold(self):
+ """When all sources are below the relevance threshold, no reserved slots
+ are granted. The pool is filled purely by RRF score order."""
+ plan = schema.QueryPlan(
+ intent="concept",
+ freshness_mode="relaxed",
+ cluster_mode="concept",
+ raw_topic="test",
+ subqueries=[
+ schema.SubQuery(
+ label="primary",
+ search_query="test",
+ ranking_query="What is test?",
+ sources=["x", "reddit", "hackernews"],
+ weight=1.0,
+ ),
+ ],
+ # Give x a much higher weight so its items get higher RRF scores
+ source_weights={"x": 3.0, "reddit": 0.3, "hackernews": 0.3},
+ )
+
+ streams: dict[tuple[str, str], list[schema.SourceItem]] = {}
+ # All sources below threshold (local_relevance = 0.1)
+ for src in ["x", "reddit", "hackernews"]:
+ items = [
+ make_item(f"{src}_{i}", src, f"https://{src}.example.com/{i}", f"{src} item {i}", 0.1)
+ for i in range(4)
+ ]
+ streams[("primary", src)] = items
+
+ candidates = fusion.weighted_rrf(streams, plan, pool_limit=4)
+ self.assertEqual(4, len(candidates))
+
+ # With no diversity reservation and x having 3x the weight,
+ # x should dominate the top slots purely on RRF score
+ source_counts: dict[str, int] = {}
+ for c in candidates:
+ source_counts[c.source] = source_counts.get(c.source, 0) + 1
+
+ # x has 3x weight so its RRF scores are ~3x higher than reddit/hn.
+ # All 4 x items should beat all reddit/hackernews items.
+ self.assertEqual(
+ source_counts.get("x", 0),
+ 4,
+ f"Expected x to take all 4 slots on pure RRF merit, got {source_counts}",
+ )
+
+ def test_diversify_pool_threshold_boundary(self):
+ """Source with best local_relevance exactly at the threshold (0.25)
+ qualifies for reserved slots."""
+ plan = schema.QueryPlan(
+ intent="concept",
+ freshness_mode="relaxed",
+ cluster_mode="concept",
+ raw_topic="boundary",
+ subqueries=[
+ schema.SubQuery(
+ label="primary",
+ search_query="boundary",
+ ranking_query="What is boundary?",
+ sources=["x", "reddit"],
+ weight=1.0,
+ ),
+ ],
+ # Give x much higher weight so it would dominate without reservation
+ source_weights={"x": 5.0, "reddit": 0.1},
+ )
+
+ x_items = [
+ make_item(f"x_{i}", "x", f"https://x.example.com/{i}", f"x item {i}", 0.8)
+ for i in range(6)
+ ]
+
+ # reddit at exactly the threshold
+ reddit_items = [
+ make_item(f"r_{i}", "reddit", f"https://reddit.example.com/{i}", f"reddit item {i}", 0.25)
+ for i in range(3)
+ ]
+
+ streams = {
+ ("primary", "x"): x_items,
+ ("primary", "reddit"): reddit_items,
+ }
+
+ candidates = fusion.weighted_rrf(streams, plan, pool_limit=6)
+ self.assertEqual(6, len(candidates))
+
+ reddit_count = sum(1 for c in candidates if c.source == "reddit")
+ self.assertGreaterEqual(
+ reddit_count,
+ 2,
+ f"reddit (local_relevance=0.25, at threshold) should get 2 reserved slots, got {reddit_count}",
+ )
+
+
+def make_item_with_author(
+ item_id: str, source: str, url: str, title: str, rank_score: float, author: str | None = None,
+) -> schema.SourceItem:
+ return schema.SourceItem(
+ item_id=item_id,
+ source=source,
+ title=title,
+ body=title,
+ url=url,
+ author=author,
+ relevance_hint=rank_score,
+ snippet=title,
+ metadata={
+ "local_relevance": rank_score,
+ "freshness": 80,
+ "engagement_score": 5,
+ "source_quality": 0.7,
+ },
+ )
+
+
+class TestPerAuthorCap(unittest.TestCase):
+ """Per-author cap: no single author should have more than 3 items in fused pool."""
+
+ def _make_plan(self, sources: list[str]) -> schema.QueryPlan:
+ return schema.QueryPlan(
+ intent="breaking_news",
+ freshness_mode="strict_recent",
+ cluster_mode="story",
+ raw_topic="test",
+ subqueries=[
+ schema.SubQuery(
+ label="primary",
+ search_query="test",
+ ranking_query="test",
+ sources=sources,
+ weight=1.0,
+ ),
+ ],
+ source_weights={s: 1.0 for s in sources},
+ )
+
+ def test_author_with_8_items_capped_to_3(self):
+ """@grok scenario: 8 items from the same author, only best 3 survive."""
+ plan = self._make_plan(["x"])
+ items = [
+ make_item_with_author(
+ f"x_{i}", "x", f"https://x.com/{i}", f"grok summary {i}", 0.7, author="@grok",
+ )
+ for i in range(8)
+ ]
+ streams = {("primary", "x"): items}
+ candidates = fusion.weighted_rrf(streams, plan, pool_limit=20)
+ grok_count = sum(
+ 1 for c in candidates
+ if any(si.author == "@grok" for si in c.source_items)
+ )
+ self.assertLessEqual(grok_count, 3, f"@grok should be capped at 3, got {grok_count}")
+
+ def test_author_with_3_items_all_kept(self):
+ """Author with exactly 3 items should keep all of them."""
+ plan = self._make_plan(["x"])
+ items = [
+ make_item_with_author(
+ f"x_{i}", "x", f"https://x.com/{i}", f"author3 post {i}", 0.7, author="@author3",
+ )
+ for i in range(3)
+ ]
+ streams = {("primary", "x"): items}
+ candidates = fusion.weighted_rrf(streams, plan, pool_limit=20)
+ count = sum(
+ 1 for c in candidates
+ if any(si.author == "@author3" for si in c.source_items)
+ )
+ self.assertEqual(count, 3)
+
+ def test_items_without_author_not_capped(self):
+ """Items with no author field should never be dropped by the cap."""
+ plan = self._make_plan(["reddit"])
+ items = [
+ make_item_with_author(
+ f"r_{i}", "reddit", f"https://reddit.com/{i}", f"post {i}", 0.7, author=None,
+ )
+ for i in range(6)
+ ]
+ streams = {("primary", "reddit"): items}
+ candidates = fusion.weighted_rrf(streams, plan, pool_limit=20)
+ self.assertEqual(len(candidates), 6)
+
+ def test_multiple_authors_capped_independently(self):
+ """Two prolific authors each get capped to 3 independently."""
+ plan = self._make_plan(["x"])
+ items = []
+ for i in range(5):
+ items.append(make_item_with_author(
+ f"grok_{i}", "x", f"https://x.com/grok/{i}", f"grok {i}", 0.7, author="@grok",
+ ))
+ for i in range(5):
+ items.append(make_item_with_author(
+ f"spam_{i}", "x", f"https://x.com/spam/{i}", f"spam {i}", 0.6, author="@spammer",
+ ))
+ streams = {("primary", "x"): items}
+ candidates = fusion.weighted_rrf(streams, plan, pool_limit=20)
+ grok_count = sum(1 for c in candidates if any(si.author == "@grok" for si in c.source_items))
+ spam_count = sum(1 for c in candidates if any(si.author == "@spammer" for si in c.source_items))
+ self.assertLessEqual(grok_count, 3)
+ self.assertLessEqual(spam_count, 3)
+
+ def test_cap_keeps_best_items_by_rrf_order(self):
+ """The cap should keep the first (highest-ranked) items per author."""
+ plan = self._make_plan(["x"])
+ # Items with decreasing relevance scores so ranking is deterministic
+ items = [
+ make_item_with_author(
+ f"x_{i}", "x", f"https://x.com/{i}", f"post {i}", 0.9 - (i * 0.05), author="@prolific",
+ )
+ for i in range(5)
+ ]
+ streams = {("primary", "x"): items}
+ candidates = fusion.weighted_rrf(streams, plan, pool_limit=20)
+ kept_ids = {c.item_id for c in candidates if any(si.author == "@prolific" for si in c.source_items)}
+ # The top 3 items (x_0, x_1, x_2) should be kept
+ self.assertLessEqual(len(kept_ids), 3)
+
+
+class TestUrlNormalization(unittest.TestCase):
+ def test_strips_www(self):
+ from lib.fusion import _normalize_url
+ self.assertEqual(
+ _normalize_url("https://www.reddit.com/r/test"),
+ _normalize_url("https://reddit.com/r/test"),
+ )
+
+ def test_strips_old_prefix(self):
+ from lib.fusion import _normalize_url
+ self.assertEqual(
+ _normalize_url("https://old.reddit.com/r/test"),
+ _normalize_url("https://reddit.com/r/test"),
+ )
+
+ def test_strips_mobile_prefix(self):
+ from lib.fusion import _normalize_url
+ self.assertEqual(
+ _normalize_url("https://m.youtube.com/watch?v=abc"),
+ _normalize_url("https://youtube.com/watch?v=abc"),
+ )
+
+ def test_strips_utm_params(self):
+ from lib.fusion import _normalize_url
+ self.assertEqual(
+ _normalize_url("https://example.com/page?utm_source=twitter&id=5"),
+ _normalize_url("https://example.com/page?id=5"),
+ )
+
+ def test_strips_trailing_slash(self):
+ from lib.fusion import _normalize_url
+ self.assertEqual(
+ _normalize_url("https://example.com/page/"),
+ _normalize_url("https://example.com/page"),
+ )
+
+ def test_preserves_non_tracking_params(self):
+ from lib.fusion import _normalize_url
+ result = _normalize_url("https://example.com/page?id=5&sort=new")
+ self.assertIn("id=5", result)
+ self.assertIn("sort=new", result)
+
+ def test_case_insensitive(self):
+ from lib.fusion import _normalize_url
+ self.assertEqual(
+ _normalize_url("https://Reddit.com/r/Test"),
+ _normalize_url("https://reddit.com/r/test"),
+ )
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_generate_synthesis_inputs_v3.py b/tests/test_generate_synthesis_inputs_v3.py
new file mode 100644
index 0000000..7fd1a44
--- /dev/null
+++ b/tests/test_generate_synthesis_inputs_v3.py
@@ -0,0 +1,123 @@
+import importlib.util
+import json
+import sys
+import tempfile
+import unittest
+from pathlib import Path
+
+sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "scripts"))
+
+from lib import schema
+
+
+SCRIPT_PATH = Path(__file__).resolve().parents[1] / "scripts" / "generate-synthesis-inputs.py"
+
+
+def load_module():
+ spec = importlib.util.spec_from_file_location("generate_synthesis_inputs", SCRIPT_PATH)
+ module = importlib.util.module_from_spec(spec)
+ assert spec and spec.loader
+ spec.loader.exec_module(module)
+ return module
+
+
+class GenerateSynthesisInputsV3Tests(unittest.TestCase):
+ def test_main_uses_v3_report_deserializer(self):
+ module = load_module()
+ report = schema.Report(
+ topic="test topic",
+ range_from="2026-02-14",
+ range_to="2026-03-16",
+ generated_at="2026-03-16T00:00:00+00:00",
+ provider_runtime=schema.ProviderRuntime(
+ reasoning_provider="gemini",
+ planner_model="gemini-3.1-flash-lite-preview",
+ rerank_model="gemini-3.1-flash-lite-preview",
+ ),
+ query_plan=schema.QueryPlan(
+ intent="breaking_news",
+ freshness_mode="strict_recent",
+ cluster_mode="story",
+ raw_topic="test topic",
+ subqueries=[
+ schema.SubQuery(
+ label="primary",
+ search_query="test topic",
+ ranking_query="What happened with test topic?",
+ sources=["grounding"],
+ )
+ ],
+ source_weights={"grounding": 1.0},
+ ),
+ clusters=[
+ schema.Cluster(
+ cluster_id="cluster-1",
+ title="Title",
+ candidate_ids=["c1"],
+ representative_ids=["c1"],
+ sources=["grounding"],
+ score=90.0,
+ )
+ ],
+ ranked_candidates=[
+ schema.Candidate(
+ candidate_id="c1",
+ item_id="i1",
+ source="grounding",
+ sources=["grounding"],
+ title="Title",
+ url="https://example.com",
+ snippet="Snippet",
+ subquery_labels=["primary"],
+ native_ranks={"primary:grounding": 1},
+ local_relevance=0.8,
+ freshness=90,
+ engagement=None,
+ source_quality=1.0,
+ rrf_score=0.02,
+ rerank_score=91.0,
+ final_score=90.0,
+ source_items=[
+ schema.SourceItem(
+ item_id="i1",
+ source="grounding",
+ title="Title",
+ body="Body",
+ url="https://example.com",
+ published_at="2026-03-16",
+ )
+ ],
+ )
+ ],
+ items_by_source={
+ "grounding": [
+ schema.SourceItem(
+ item_id="i1",
+ source="grounding",
+ title="Title",
+ body="Body",
+ url="https://example.com",
+ )
+ ]
+ },
+ errors_by_source={},
+ )
+
+ with tempfile.TemporaryDirectory() as tmp:
+ json_dir = Path(tmp) / "json"
+ compact_dir = Path(tmp) / "compact"
+ json_dir.mkdir()
+ (json_dir / "sample.json").write_text(json.dumps(schema.to_dict(report)))
+
+ module.JSON_DIR = json_dir
+ module.COMPACT_DIR = compact_dir
+
+ result = module.main()
+
+ self.assertEqual(0, result)
+ output = (compact_dir / "sample.md").read_text()
+ self.assertIn("# last30days-3 v3.0.0-alpha: test topic", output)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_github.py b/tests/test_github.py
new file mode 100644
index 0000000..7ab9099
--- /dev/null
+++ b/tests/test_github.py
@@ -0,0 +1,147 @@
+"""Tests for GitHub source module."""
+
+import json
+import sys
+import unittest
+from pathlib import Path
+from unittest.mock import patch, MagicMock
+
+sys.path.insert(0, str(Path(__file__).parent.parent / "scripts"))
+from lib import github
+
+
+class TestResolveToken(unittest.TestCase):
+ def test_explicit_token(self):
+ self.assertEqual(github._resolve_token("my-token"), "my-token")
+
+ @patch.dict("os.environ", {"GITHUB_TOKEN": "env-token"})
+ def test_env_token(self):
+ self.assertEqual(github._resolve_token(), "env-token")
+
+ @patch.dict("os.environ", {}, clear=True)
+ @patch("subprocess.run")
+ def test_gh_cli_fallback(self, mock_run):
+ mock_run.return_value = MagicMock(returncode=0, stdout="gh-token\n")
+ # Clear GITHUB_TOKEN from env for this test
+ result = github._resolve_token()
+ self.assertEqual(result, "gh-token")
+
+ @patch.dict("os.environ", {}, clear=True)
+ @patch("subprocess.run", side_effect=FileNotFoundError)
+ def test_no_token_available(self, mock_run):
+ result = github._resolve_token()
+ self.assertIsNone(result)
+
+
+class TestParseRepoFromUrl(unittest.TestCase):
+ def test_issue_url(self):
+ url = "https://github.com/facebook/react/issues/123"
+ self.assertEqual(github._parse_repo_from_url(url), "facebook/react")
+
+ def test_pr_url(self):
+ url = "https://github.com/vercel/next.js/pull/456"
+ self.assertEqual(github._parse_repo_from_url(url), "vercel/next.js")
+
+ def test_empty(self):
+ self.assertEqual(github._parse_repo_from_url(""), "")
+
+
+class TestParseDate(unittest.TestCase):
+ def test_iso_date(self):
+ self.assertEqual(github._parse_date("2026-03-15T12:00:00Z"), "2026-03-15")
+
+ def test_none(self):
+ self.assertIsNone(github._parse_date(None))
+
+ def test_empty(self):
+ self.assertIsNone(github._parse_date(""))
+
+
+class TestSearchGithub(unittest.TestCase):
+ @patch.dict("os.environ", {}, clear=True)
+ @patch("subprocess.run", side_effect=FileNotFoundError)
+ def test_no_token_returns_empty(self, mock_run):
+ result = github.search_github("react", "2026-03-01", "2026-03-31", token=None)
+ self.assertEqual(result, [])
+
+ @patch.object(github, "_fetch_json")
+ @patch.object(github, "_resolve_token", return_value="test-token")
+ def test_search_returns_items(self, mock_token, mock_fetch):
+ mock_fetch.return_value = {
+ "total_count": 1,
+ "items": [
+ {
+ "html_url": "https://github.com/facebook/react/issues/42",
+ "title": "React Server Components bug",
+ "body": "There is a bug when using RSC with streaming...",
+ "created_at": "2026-03-15T10:00:00Z",
+ "state": "open",
+ "comments": 12,
+ "reactions": {"total_count": 8},
+ "labels": [{"name": "bug"}, {"name": "rsc"}],
+ "user": {"login": "testuser"},
+ },
+ ],
+ }
+ result = github.search_github("react", "2026-03-01", "2026-03-31")
+ self.assertEqual(len(result), 1)
+ item = result[0]
+ self.assertEqual(item["source"], "github")
+ self.assertEqual(item["container"], "facebook/react")
+ self.assertEqual(item["title"], "React Server Components bug")
+ self.assertEqual(item["date"], "2026-03-15")
+ self.assertEqual(item["author"], "testuser")
+ self.assertIn("bug", item["metadata"]["labels"])
+ self.assertEqual(item["metadata"]["state"], "open")
+ self.assertEqual(item["metadata"]["comment_count"], 12)
+ self.assertEqual(item["metadata"]["reactions"], 8)
+ self.assertEqual(item["engagement"]["reactions"], 8)
+ self.assertEqual(item["engagement"]["comments"], 12)
+ self.assertFalse(item["metadata"]["is_pr"])
+
+ @patch.object(github, "_fetch_json", return_value=None)
+ @patch.object(github, "_resolve_token", return_value="test-token")
+ def test_rate_limit_returns_empty(self, mock_token, mock_fetch):
+ """403 rate limit returns empty list gracefully."""
+ result = github.search_github("react", "2026-03-01", "2026-03-31")
+ self.assertEqual(result, [])
+
+ @patch.object(github, "_fetch_json")
+ @patch.object(github, "_resolve_token", return_value="test-token")
+ def test_pr_detected(self, mock_token, mock_fetch):
+ mock_fetch.return_value = {
+ "total_count": 1,
+ "items": [
+ {
+ "html_url": "https://github.com/vercel/next.js/pull/99",
+ "title": "Add streaming support",
+ "body": "This PR adds...",
+ "created_at": "2026-03-20T10:00:00Z",
+ "state": "open",
+ "comments": 5,
+ "reactions": {"total_count": 3},
+ "labels": [],
+ "user": {"login": "dev"},
+ "pull_request": {"url": "..."},
+ },
+ ],
+ }
+ result = github.search_github("next.js", "2026-03-01", "2026-03-31")
+ self.assertEqual(len(result), 1)
+ self.assertTrue(result[0]["metadata"]["is_pr"])
+
+
+class TestComputeRelevance(unittest.TestCase):
+ def test_basic_relevance(self):
+ score = github._compute_relevance("react hooks", "React Hooks Tutorial", 0, 10, 5)
+ self.assertGreater(score, 0.5)
+ self.assertLessEqual(score, 1.0)
+
+ def test_lower_rank_lower_score(self):
+ high = github._compute_relevance("react", "React", 0, 0, 0)
+ low = github._compute_relevance("react", "React", 20, 0, 0)
+ self.assertGreater(high, low)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_grounding_v3.py b/tests/test_grounding_v3.py
new file mode 100644
index 0000000..e428c39
--- /dev/null
+++ b/tests/test_grounding_v3.py
@@ -0,0 +1,194 @@
+import sys
+import unittest
+from pathlib import Path
+from unittest.mock import patch
+
+sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "scripts"))
+
+from lib import grounding
+
+
+class BraveSearchTests(unittest.TestCase):
+ def test_brave_search_applies_freshness_and_filters_to_in_range_dated_items(self):
+ mock_response = {
+ "web": {
+ "results": [
+ {
+ "title": "Test Article",
+ "url": "https://example.com/article",
+ "description": "A test snippet",
+ "page_age": "2026-03-10T00:00:00",
+ },
+ {
+ "title": "Old Article",
+ "url": "https://example.com/old",
+ "description": "Should be filtered",
+ "page_age": "2025-12-10T00:00:00",
+ },
+ {
+ "title": "Undated Article",
+ "url": "https://example.com/undated",
+ "description": "Should also be filtered",
+ }
+ ]
+ }
+ }
+ with patch("lib.grounding.http.request", return_value=mock_response) as mock_req:
+ items, artifact = grounding.brave_search("test", ("2026-02-25", "2026-03-27"), "fake-key")
+ self.assertEqual(1, len(items))
+ self.assertEqual("Test Article", items[0]["title"])
+ self.assertEqual("https://example.com/article", items[0]["url"])
+ self.assertEqual("2026-03-10", items[0]["date"])
+ self.assertEqual("brave", artifact["label"])
+ call_url = mock_req.call_args.args[1]
+ self.assertIn("freshness=2026-02-25to2026-03-27", call_url)
+
+
+class SerperSearchTests(unittest.TestCase):
+ def test_serper_search_filters_to_in_range_dated_items(self):
+ mock_response = {
+ "organic": [
+ {
+ "title": "Serper Result",
+ "link": "https://example.com/serper",
+ "snippet": "A serper snippet",
+ "date": "Mar 15, 2026",
+ },
+ {
+ "title": "Old Result",
+ "link": "https://example.com/old",
+ "snippet": "Should be filtered",
+ "date": "Jan 15, 2026",
+ },
+ {
+ "title": "Undated Result",
+ "link": "https://example.com/undated",
+ "snippet": "Should also be filtered",
+ }
+ ]
+ }
+ with patch("lib.grounding.http.request", return_value=mock_response):
+ items, artifact = grounding.serper_search("test", ("2026-02-25", "2026-03-27"), "fake-key")
+ self.assertEqual(1, len(items))
+ self.assertEqual("Serper Result", items[0]["title"])
+ self.assertEqual("2026-03-15", items[0]["date"])
+ self.assertEqual("serper", artifact["label"])
+
+
+class ExaSearchTests(unittest.TestCase):
+ def test_exa_search_filters_to_in_range_dated_items(self):
+ mock_response = {
+ "results": [
+ {
+ "title": "Exa Result",
+ "url": "https://example.com/exa",
+ "text": "An exa snippet about AI trends",
+ "publishedDate": "2026-03-15T00:00:00.000Z",
+ "score": 0.85,
+ },
+ {
+ "title": "Old Exa Result",
+ "url": "https://example.com/old-exa",
+ "text": "Should be filtered out",
+ "publishedDate": "2025-12-01T00:00:00.000Z",
+ "score": 0.7,
+ },
+ {
+ "title": "Undated Exa Result",
+ "url": "https://example.com/undated-exa",
+ "text": "No date means filtered",
+ },
+ ]
+ }
+ with patch("lib.grounding.http.request", return_value=mock_response) as mock_req:
+ items, artifact = grounding.exa_search("test", ("2026-02-25", "2026-03-27"), "fake-exa-key")
+ self.assertEqual(1, len(items))
+ self.assertEqual("Exa Result", items[0]["title"])
+ self.assertEqual("https://example.com/exa", items[0]["url"])
+ self.assertEqual("2026-03-15", items[0]["date"])
+ self.assertTrue(items[0]["id"].startswith("WE"))
+ self.assertEqual("exa", artifact["label"])
+ self.assertEqual(1, artifact["resultCount"])
+ # Verify API call
+ call_args = mock_req.call_args
+ self.assertEqual("POST", call_args.args[0])
+ self.assertEqual("https://api.exa.ai/search", call_args.args[1])
+ self.assertEqual("fake-exa-key", call_args.kwargs["headers"]["x-api-key"])
+
+ def test_exa_search_returns_empty_for_no_results(self):
+ with patch("lib.grounding.http.request", return_value={"results": []}):
+ items, artifact = grounding.exa_search("test", ("2026-02-25", "2026-03-27"), "key")
+ self.assertEqual([], items)
+ self.assertEqual(0, artifact["resultCount"])
+
+
+class WebSearchDispatchTests(unittest.TestCase):
+ def test_auto_selects_brave_when_key_present(self):
+ config = {"BRAVE_API_KEY": "test-key"}
+ with patch("lib.grounding.brave_search", return_value=([], {})) as mock:
+ grounding.web_search("test", ("2026-02-25", "2026-03-27"), config, backend="auto")
+ mock.assert_called_once()
+
+ def test_auto_selects_exa_when_only_exa_key(self):
+ config = {"EXA_API_KEY": "test-key"}
+ with patch("lib.grounding.exa_search", return_value=([], {})) as mock:
+ grounding.web_search("test", ("2026-02-25", "2026-03-27"), config, backend="auto")
+ mock.assert_called_once()
+
+ def test_auto_selects_serper_when_only_serper_key(self):
+ config = {"SERPER_API_KEY": "test-key"}
+ with patch("lib.grounding.serper_search", return_value=([], {})) as mock:
+ grounding.web_search("test", ("2026-02-25", "2026-03-27"), config, backend="auto")
+ mock.assert_called_once()
+
+ def test_auto_returns_empty_when_no_keys(self):
+ items, artifact = grounding.web_search("test", ("2026-02-25", "2026-03-27"), {}, backend="auto")
+ self.assertEqual([], items)
+ self.assertEqual({}, artifact)
+
+ def test_none_returns_empty(self):
+ config = {"BRAVE_API_KEY": "test-key"}
+ items, artifact = grounding.web_search("test", ("2026-02-25", "2026-03-27"), config, backend="none")
+ self.assertEqual([], items)
+
+ def test_auto_prefers_brave_over_exa(self):
+ config = {"BRAVE_API_KEY": "brave-key", "EXA_API_KEY": "exa-key"}
+ with patch("lib.grounding.brave_search", return_value=([], {})) as mock_brave, \
+ patch("lib.grounding.exa_search", return_value=([], {})) as mock_exa:
+ grounding.web_search("test", ("2026-02-25", "2026-03-27"), config, backend="auto")
+ mock_brave.assert_called_once()
+ mock_exa.assert_not_called()
+
+ def test_auto_prefers_exa_over_serper(self):
+ config = {"EXA_API_KEY": "exa-key", "SERPER_API_KEY": "serper-key"}
+ with patch("lib.grounding.exa_search", return_value=([], {})) as mock_exa, \
+ patch("lib.grounding.serper_search", return_value=([], {})) as mock_serper:
+ grounding.web_search("test", ("2026-02-25", "2026-03-27"), config, backend="auto")
+ mock_exa.assert_called_once()
+ mock_serper.assert_not_called()
+
+ def test_auto_prefers_brave_when_all_keys_present(self):
+ config = {"BRAVE_API_KEY": "brave-key", "EXA_API_KEY": "exa-key", "SERPER_API_KEY": "serper-key"}
+ with patch("lib.grounding.brave_search", return_value=([], {})) as mock_brave, \
+ patch("lib.grounding.exa_search", return_value=([], {})) as mock_exa, \
+ patch("lib.grounding.serper_search", return_value=([], {})) as mock_serper:
+ grounding.web_search("test", ("2026-02-25", "2026-03-27"), config, backend="auto")
+ mock_brave.assert_called_once()
+ mock_exa.assert_not_called()
+ mock_serper.assert_not_called()
+
+ def test_explicit_exa_without_key_raises(self):
+ with self.assertRaises(RuntimeError):
+ grounding.web_search("test", ("2026-02-25", "2026-03-27"), {}, backend="exa")
+
+ def test_explicit_brave_without_key_raises(self):
+ with self.assertRaises(RuntimeError):
+ grounding.web_search("test", ("2026-02-25", "2026-03-27"), {}, backend="brave")
+
+ def test_unsupported_backend_raises(self):
+ with self.assertRaises(ValueError):
+ grounding.web_search("test", ("2026-02-25", "2026-03-27"), {}, backend="google")
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_hackernews.py b/tests/test_hackernews.py
index ff776dd..0b0132f 100644
--- a/tests/test_hackernews.py
+++ b/tests/test_hackernews.py
@@ -1,219 +1,408 @@
-"""Tests for Hacker News source module."""
+"""Tests for hackernews.py - HN search via Algolia API."""
+import json
import sys
-import unittest
+from datetime import datetime, timezone
from pathlib import Path
+from unittest.mock import Mock, patch
+
+import pytest
-# Add lib to path
sys.path.insert(0, str(Path(__file__).parent.parent / "scripts"))
-from lib import hackernews, normalize, schema, score
+from lib import hackernews
-class TestDateToUnix(unittest.TestCase):
- def test_known_date(self):
- # 2026-01-01 00:00:00 UTC
- result = hackernews._date_to_unix("2026-01-01")
- self.assertIsInstance(result, int)
- self.assertGreater(result, 0)
+# === Helper Functions ===
- def test_roundtrip(self):
- ts = hackernews._date_to_unix("2026-02-15")
- back = hackernews._unix_to_date(ts)
- self.assertEqual(back, "2026-02-15")
-
-
-class TestStripHtml(unittest.TestCase):
- def test_basic_html(self):
- result = hackernews._strip_html("
"
+ result = hackernews._strip_html(html_text)
+
+ assert result == "Hello & goodbye"
+
+
+def test_strip_html_paragraph_tags():
+ """Test that
tags are converted to newlines."""
+ html_text = "First
Second
Third"
+ result = hackernews._strip_html(html_text)
+
+ assert "First\n" in result
+ assert "Second\n" in result
+
+
+def test_strip_html_nested_tags():
+ """Test stripping nested HTML tags."""
+ html_text = "