Compare commits

..

2 Commits

Author SHA1 Message Date
Matt Van Horn 09ed497804 feat(podcasts): make podcasts always available + smarter mention matching
Changes:
- Podcasts source is now always available when yt-dlp is installed (same as
  YouTube). Previously required explicit opt-in via INCLUDE_SOURCES or
  --search=podcasts.
- Smarter mention matching: extract key terms from multi-word topics and
  use max count across terms. "Kanye West Bully album" now matches
  episodes mentioning "Kanye" 85 times (previously 0 due to exact phrase).
- SKILL.md: add podcast channel resolution to Step 0.55, include
  --podcast-channels in execution command, update ACTIVE_SOURCES_LIST.

Tested: Kanye West query now finds 5 podcast hits including hidden
mentions in off-topic episodes (Lost Civilizations, Mike WiLL Made-It).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 00:29:11 -04:00
Matt Van Horn 49d45c2b42 feat(podcasts): add YouTube podcast source with transcript-first discovery
New "podcasts" source that discovers podcast content by scanning transcripts
from LLM-resolved YouTube channels. Finds content invisible to title-based
search — Acquired's "The NFL" episode mentions Taylor Swift 18x, ESPN 117x,
Netflix 102x, none in the title.

Architecture:
- LLM resolves 6-12 podcast channel @handles per topic
- Engine fetches recent episodes via yt-dlp (no video download)
- Downloads auto-captions and greps for topic keywords
- Episodes with 5+ mentions become podcast results with highlights
- Runs in parallel, ~15-20s latency, invisible in 3-min research run

Pipeline integration:
- New source module: scripts/lib/podcast_yt.py
- Registered in pipeline, normalizer, signals, planner, render
- CLI flag: --podcast-channels=AcquiredFM,lexfridman,...
- SOURCE_QUALITY: 0.88 (above YouTube's 0.85)
- Opt-in via INCLUDE_SOURCES=podcasts or --search=podcasts

Zero new API keys. Zero new dependencies. Reuses yt-dlp + transcript pipeline.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 00:28:40 -04:00
303 changed files with 12117 additions and 10412 deletions
+1 -1
View File
@@ -10,7 +10,7 @@
{
"name": "last30days",
"description": "Research any topic across Reddit, X, YouTube, TikTok, Instagram, HN, Polymarket, GitHub, and 5+ more sources.",
"version": "3.0.9",
"version": "3.0.0",
"author": {
"name": "Matt Van Horn",
"url": "https://github.com/mvanhorn"
+2 -1
View File
@@ -1,6 +1,6 @@
{
"name": "last30days",
"version": "3.1.0",
"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",
@@ -11,5 +11,6 @@
"repository": "https://github.com/mvanhorn/last30days-skill",
"license": "MIT",
"keywords": ["research", "reddit", "twitter", "youtube", "tiktok", "instagram", "trends", "prompts", "polymarket", "github", "perplexity", "threads", "pinterest", "eli5", "hacker-news"],
"skills": ["./"],
"hooks": {}
}
-3
View File
@@ -1,3 +0,0 @@
{
"name": "last30days"
}
-46
View File
@@ -1,46 +0,0 @@
# Exclude non-runtime files from `git archive` output.
# Used by scripts/build-skill.sh to produce a claude.ai-upload-ready .skill file.
# See docs/plans/2026-04-14-001-fix-skill-upload-200-file-limit-plan.md.
# Anthropic canonical skill-packaging excludes
# (mirrors anthropics/skills/skills/skill-creator/scripts/package_skill.py)
__pycache__/ export-ignore
node_modules/ export-ignore
*.pyc export-ignore
.DS_Store export-ignore
evals/ export-ignore
# Dev, docs, test, and media - not needed at skill runtime
tests/ export-ignore
docs/ export-ignore
fixtures/ export-ignore
assets/ export-ignore
# NOTE: skills/ and .claude-plugin/ are NOT export-ignored here because
# Claude Code's /plugin install fetches this same git archive tarball.
# Removing those from the archive (as v3.0.1 did) silently breaks installs.
# claude.ai-bundle-specific exclusions live in scripts/build-skill.sh.
# Historical + repo-only manifests
SKILL-original.md export-ignore
SPEC.md export-ignore
TASKS.md export-ignore
test-run.log export-ignore
CONTRIBUTORS.md export-ignore
HERMES_SETUP.md export-ignore
release-notes.md export-ignore
CHANGELOG.md export-ignore
uv.lock export-ignore
# Platform adapters - skill-upload path is platform-agnostic
.agents/ export-ignore
.codex-plugin/ export-ignore
.hermes-plugin/ export-ignore
# CI workflows - repo-only, not needed at skill runtime
.github/ export-ignore
# Build config itself
.clawhubignore export-ignore
.gitignore export-ignore
.gitattributes export-ignore
-31
View File
@@ -1,31 +0,0 @@
name: Release
on:
push:
tags:
- "v*"
permissions:
contents: write
jobs:
build-and-release:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Build .skill artifact
run: |
bash scripts/build-skill.sh
test -f dist/last30days.skill
- name: Create GitHub release
uses: softprops/action-gh-release@v2
with:
files: dist/last30days.skill
generate_release_notes: true
draft: false
prerelease: false
-13
View File
@@ -15,16 +15,3 @@ variants/open/references/research.md
__pycache__/
*.pyc
mise.toml
.memsearch/
.venv/
.coverage
htmlcov/
# Root vendor/ is accidental - real vendored client lives at scripts/lib/vendor/bird-search/
/vendor/
# build artifact from scripts/build-skill.sh
/dist/
# Internal planning docs (ce:plan output) — keep local, don't publish
docs/plans/
+5 -219
View File
@@ -5,213 +5,7 @@ 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.1.0] - 2026-04-22
Consolidates the 3.0.10 to 3.0.14 dev cycle (commenter handles, `--competitors`, per-entity Step 0.55, vs-mode N passes, comparison title attribution) and republishes the OpenClaw bundle, which had been frozen on ClawHub at `3.0.0-open` since April 8.
### Added
- **OpenClaw republish.** `clawhub install last30days-official` now resolves to `3.1.0-open`, matching current main. Closes [#307](https://github.com/mvanhorn/last30days-skill/issues/307), [#195](https://github.com/mvanhorn/last30days-skill/issues/195), [#236](https://github.com/mvanhorn/last30days-skill/issues/236). The ClawHub bundle had shipped a broken `env.py get_config()` and stale SKILL.md path references since April; both are fixed at source on main and the republish carries the fixes to installers.
### Fixed
- **Claude Code plugin manifest path-escape.** The `.claude-plugin/plugin.json` `skills` key was removed in commit `93fbed2` but never shipped in a tagged release. Installing via `/plugin install last30days-skill` could hit `/doctor`'s `Path escapes plugin directory: ./ (skills)` error. This release ships the fix. Closes [#306](https://github.com/mvanhorn/last30days-skill/issues/306).
- **Broken README link.** The README's "source of truth" link pointed at `skills/last30days/SKILL.md`, a path that does not exist. Fixed to point at root `SKILL.md`.
### Dev cycle journal (3.0.10 - 3.0.14, not separately tagged)
Individual changelog entries for 3.0.10 through 3.0.14 below document the incremental work consolidated into this release.
## [3.0.14] - 2026-04-22
### Changed
- **Comparison-mode title attribution.** The synthesis title for vs-mode and `--competitors` outputs changes from `What the Community Says (Last 30 Days)` to `What the Community Says (/Last30Days)`. Surfaces the slash-command identity instead of restating the date range. Three SKILL.md occurrences updated; pure documentation change.
## [3.0.13] - 2026-04-22
### Changed
- **vs mode runs N full passes in parallel, one per entity.** Architectural revert of the 3-pass → 1-pass latency optimization from an earlier version. `/last30days "OpenAI vs Anthropic vs xAI"` now runs three full `pipeline.run()` calls in parallel via the same fanout `--competitors` uses, producing three `*-raw.md` save files plus a merged comparison output. Each entity gets its own Step 0.55-grade targeting, own primary X handle weight, own subreddit scoping — apples-to-apples depth instead of the one-pool merged retrieval the single-pass path produced. Parallel execution keeps wall clock ≈ single pass.
- **`--competitors` is now a SKILL.md-level shortcut for vs-mode with auto-discovery.** The hosting reasoning model (Claude Code, Codex, Hermes, Gemini, any agent with WebSearch) performs discovery and Step 0.55 per entity via its own WebSearch tool, then invokes the engine with a vs-topic and `--competitors-plan` JSON. The engine flag remains for headless/cron use with BRAVE/EXA/SERPER/PARALLEL/OPENROUTER keys (engine-internal `auto_resolve` stays as fallback).
- **LAW 7-style stderr for `--competitors` with no backend** now leads with the hosting-model path (WebSearch + Step 0.55 + `--competitors-plan`) instead of `BRAVE_API_KEY`. API-key framing moved to a secondary "headless" section.
### Added
- **`--competitors-plan` JSON flag** for per-entity Step 0.55 targeting. Schema: `{entity_name: {x_handle?, x_related?, subreddits?, github_user?, github_repos?, context?}}`. Accepts inline JSON or a file path (matches `--plan`). When present for an entity, skips engine-internal `auto_resolve` and uses the provided values; missing fields fall back to `auto_resolve` (if backend) or planner defaults. Case-insensitive entity matching. The `subrun_kwargs_for` helper is the single source of truth for per-entity kwargs — no closure-default fallthrough from main scope.
- **Per-entity save files** when `--save-dir` is set on a vs-mode or `--competitors` run. Each entity's sub-run produces its own `{slug}-raw.md` with a single-row Resolved Entities block — matches historical vs-mode behavior (N passes → N save files).
- **`--polymarket-keywords "kw1,kw2"`** to filter Polymarket matches for ambiguous single-token topics (e.g., "Warriors" → `nba,gsw,golden-state` kills Glasgow Warriors rugby and Honor of Kings Rogue Warriors noise).
### Fixed
- **BRAVE/SERPER footer nudge suppressed** when `--plan` or `--competitors-plan` is present. The nudge told Claude Code users to set an API key when they already have WebSearch via the hosting model. Nudge still fires for true headless runs (no `--plan`, no backend) where the advice is correct.
- **Override-leak regression testing.** 3.0.12 already fixed the main-topic `--subreddits` / `--x-handle` / `--github-*` from leaking into peer sub-runs via explicit per-entity kwargs scrubbing. This release adds a 4-test regression suite (`test_competitor_subrun_isolation.py`) locking in the invariant.
## [3.0.12] - 2026-04-22
### Fixed
- **Per-entity Step 0.55 resolution for competitor sub-runs.** In 3.0.11, only the main topic got X handle / subreddit / GitHub resolution; competitor sub-runs ran with planner defaults and produced visibly thinner evidence (Reddit 403 fallbacks, single-word queries). Each competitor sub-run now calls `resolve.auto_resolve()` inside `fanout.run_competitor_fanout` when a web backend is available, mirroring the main topic's pre-flight resolution. Per-entity X handle, subreddit list, GitHub user/repos, and news context are threaded into each sub-run's `pipeline.run()` call. Deep-copied config per sub-run prevents `_auto_resolve_context` cross-leak. Surfaces in a new `## Resolved Entities` output block so the resolution coverage is visible without reading stderr.
- **LAW 7 false-positive on internal fan-out sub-runs.** Each competitor sub-run was emitting the `[Planner] No --plan passed... YOU ARE the planner` stderr warning. LAW 7 targets the hosting-reasoning-model path, not engine-internal fan-out. New `internal_subrun=True` keyword on `planner.plan_query` and `pipeline.run` suppresses the warning for sub-runs only; the default path is unchanged.
- **Marketplace-stale SKILL.md trap.** Added a STEP 0 canonical-path self-check at the top of SKILL.md. Two of three 2026-04-22 test runs loaded SKILL.md from `plugins/marketplaces/last30days-skill/` (Claude-Code-managed git clone pinned to origin/main, lagging the versioned cache), then ran `--help` against the same stale path, did not see `--competitors`, and fell back to a manual comparison plan. The STEP 0 block forces any reader to verify they loaded from `plugins/cache/last30days-skill/last30days/{VERSION}/SKILL.md` and re-read from the versioned cache if not.
### Changed
- **Default `--competitors` count is now 2 (3-way total: original + 2 peers).** Previously 3. `--competitors=N` still customizes (range 1..6). Matches the feature description's canonical example (`Kanye vs Drake vs Kendrick`).
### Added
- **`## Resolved Entities` block** in `render_comparison_multi` output. Shows per-entity X handle, subreddits, GitHub user/repos, and truncated context for every entity in the comparison. Block is omitted entirely when no entity has a resolved payload (mock mode, no backend).
## [3.0.11] - 2026-04-22
### Added
- **`--competitors` flag for auto-discovered comparison fan-out.** Pass `--competitors` on a single-entity topic and the engine discovers 2-6 peer entities via web search, then runs the full pipeline on each in parallel and emits one N-way comparison. `last30days Kanye West --competitors` resolves Drake, Kendrick Lamar, and one more peer. `last30days OpenAI --competitors` resolves Anthropic, xAI, Google Gemini. `--competitors=N` controls count, `--competitors-list="A,B,C"` skips discovery and uses the explicit list. Discovery mirrors the `auto_resolve` pattern (Brave / Exa / Serper / Parallel) with deterministic text extraction - no internal LLM call. Sub-runs inherit the main `--quick`/`--deep`/`--days`, run in a `ThreadPoolExecutor`, and degrade gracefully when at least 2 entities survive. Output reuses the existing 9-axis `## Head-to-Head` scaffold.
## [3.0.10] - 2026-04-21
### Added
- **Commenter handles on evidence lines.** Top-comment rendering now includes the commenter's handle - `u/author` for Reddit, `@handle` for TikTok/YouTube/Instagram/Bluesky/X/Threads. The enrichment adapters already captured `author`; the render layer just was not using it. Evidence lines change from `- Comment (6822 upvotes): Finally, John Apple` to `- u/Cyrisaurus (6822 upvotes): Finally, John Apple`. Person-level citations make synthesis-side inline markdown links per LAW 8 much more natural. Both the compact and full render paths are covered.
### Fixed
- **TikTok author preference.** `_fetch_post_comments` in `scripts/lib/tiktok.py` preferred `user.nickname` over `user.unique_id`, so the engine captured display names ("Moosa Noormahomed") instead of @handles ("moosanoormahomed"). Flipped to prefer `unique_id`. Nickname still wins as a fallback when `unique_id` is missing. Display names can contain emoji, spaces, and non-Latin characters that do not round-trip to a profile URL; the @handle is the stable identifier.
### Behavior fallback
- When an author is empty, `[deleted]`, or `[removed]`, the render falls back to the legacy `Comment (...)` shape - no `u/` or `@` prefix with an empty handle is ever emitted.
## [3.0.9] - 2026-04-18 - The Self-Debug Release
### Highlights
v3.0.9 adds the engine-side Class 1 keyword-trap refuse-gate ("birthday gift for 40 year old" now gets a clarifying question, not 5 minutes of junk), promotes TikTok and YouTube top comments to the same first-class rendering Reddit's got, lands Hermes AI Agent as a first-class deploy target, and moves the SKILL.md formatting contract from line 1094 to the top of the file.
"The Self-Debug Release" refers to how the fixes in 3.0.6-3.0.9 were written: 5 separate Opus 4.7 instances each debugged their own failed outputs. Three converged on "SKILL.md is too big and the LAWs are too deep." Two converged on "the engine should refuse demographic-shopping queries." I shipped exactly what they said. Validation: 5/5 canonical compliance.
### Added
- **Engine Class 1 keyword-trap refuse-gate** (`scripts/lib/preflight.py`, new). Pattern-matches demographic-shopping queries at main() front-door. Exit code 2 with structured REFUSE message. Escape hatch: `LAST30DAYS_SKIP_PREFLIGHT=1`. 29 tests in `tests/test_preflight.py`.
- **TikTok + YouTube top comments** rendered with same `💬 Top comment` prominence as Reddit's. Shipped in [#260](https://github.com/mvanhorn/last30days-skill/pull/260); enrichment fixed in [#265](https://github.com/mvanhorn/last30days-skill/pull/265).
- **Hermes AI Agent as a deploy target** - thanks @stephenmcconnachie ([#228](https://github.com/mvanhorn/last30days-skill/pull/228)). `scripts/sync.sh` detects `~/.hermes/skills/research` and deploys automatically.
- **Multi-key SCRAPECREATORS_API_KEY rotation** - thanks @zaydiscold ([#268](https://github.com/mvanhorn/last30days-skill/pull/268)). Set `SCRAPECREATORS_API_KEY_1`, `_2`, etc. Engine rotates on rate-limit.
- **Offline quality evaluation fixture** - thanks @j-sperling ([#233](https://github.com/mvanhorn/last30days-skill/pull/233)). `eval_topics.json` lets contributors run quality regressions without burning live API credits.
- **END-OF-CANONICAL-OUTPUT boundary** in `render_compact()`. Engine now emits an explicit pass-through instruction so re-synthesis requires actively ignoring a visible boundary.
- **LAW 1 verbatim-pattern override.** LAW 1 now quotes the exact WebSearch tool-result reminder ("CRITICAL REQUIREMENT: MUST include Sources: section") and declares it OVERRIDDEN inside last30days output.
### Changed
- **SKILL.md restructure.** VOICE CONTRACT LAWs and BADGE MANDATORY block moved from line 1094 to lines 75-150. Grounded in 3 separate Opus 4.7 self-debugs.
- **Engine emits the badge as stdout.** `🌐 last30days v3.0.9 · synced YYYY-MM-DD` is the first line of every compact emit. Pass-through is now the default-correct behavior.
- **Reddit client HTTP consolidation** - thanks @iliaal ([#207](https://github.com/mvanhorn/last30days-skill/pull/207)). Migrated to `http.get(params=...)` helper.
- **ScrapeCreators header consolidation** - thanks @iliaal ([#209](https://github.com/mvanhorn/last30days-skill/pull/209)). `_sc_headers` refactored into `http.scrapecreators_headers`.
- **Simpler Hermes sync.** `scripts/sync.sh` Hermes branch now always uses main SKILL.md (previously had a `.hermes-plugin/SKILL.md` fallback that created a wrong-file-capture hazard).
### Fixed
- **Peter Steinberger trailing Sources leak.** 2026-04-18 validation failure where the model appended a TechCrunch / TED / Fortune / Wikipedia Sources list after the invitation. Now structurally prevented at three layers: engine emits the canonical body, LAW 1 quotes the exact WebSearch reminder, closing boundary names the anti-pattern.
- **Wrong-file SKILL.md capture.** Deleted `.agents/skills/last30days/SKILL.md` (1382 lines, April 13 snapshot) and `.hermes-plugin/SKILL.md` (269 lines). One SKILL.md per plugin now, at the plugin root.
- **GitHub date parsing garbage** - thanks @iliaal ([#208](https://github.com/mvanhorn/last30days-skill/pull/208)). `_parse_date` now rejects invalid input cleanly.
- **Windows Bird X stability** - thanks @Chelebii ([#227](https://github.com/mvanhorn/last30days-skill/pull/227)).
- **Linux `check_perms` false-warn** - thanks @george231224 ([#216](https://github.com/mvanhorn/last30days-skill/pull/216)). Uses GNU stat first.
- **UTF-8 saved output** - thanks @Gujiassh ([#225](https://github.com/mvanhorn/last30days-skill/pull/225)).
- **Version metadata alignment** - thanks @Gujiassh ([#217](https://github.com/mvanhorn/last30days-skill/pull/217)) and @shalomma ([#229](https://github.com/mvanhorn/last30days-skill/pull/229)).
- **`--days` alias backcompat** - thanks @BryanTegomoh ([#230](https://github.com/mvanhorn/last30days-skill/pull/230)).
- **`INCLUDE_SOURCES` env default** - thanks @hnshah ([#223](https://github.com/mvanhorn/last30days-skill/pull/223)).
- **Bird X all-None engagement** - thanks @j-sperling ([#234](https://github.com/mvanhorn/last30days-skill/pull/234)).
### Contributors
@j-sperling, @stephenmcconnachie, @zaydiscold, @iliaal, @Chelebii, @Gujiassh, @hnshah, @george231224, @shalomma, @BryanTegomoh for PRs since v3.0.0. @uppinote20, @zerone0x, @thinkun, @thomasmktong, @fanispoulinakisai-boop, @pejmanjohn, @zl190, @Jah-yee, @dannyshmueli, @Cody-Coyote for issues and PRs that shaped the v3 roadmap.
### Recovery
```
/plugin update last30days
/reload-plugins
```
Verify: `cat ~/.claude/plugins/cache/last30days-skill/last30days/*/.claude-plugin/plugin.json | grep version` returns `"version": "3.0.9"`.
Smoke test: `/last30days birthday gift for 40 year old` should ask a clarifying question before running.
## [3.0.5] - 2026-04-15
### Added
- **`/last30days` slash command for plugin users.** New `commands/last30days.md` registers a Claude Code slash command. Users type `/last30days <topic>` and Claude Code's autocomplete prefix-matches it to the canonical `/last30days:last30days` form (the same way `/ce:plan` resolves to `/compound-engineering:ce-plan`). The command delegates to the existing `last30days` skill body — no skill behavior changes.
### Removed
- **`skills/last30days-nux/`** — byte-identical duplicate of root `SKILL.md` that created confusing `/last30days:last30days-nux` autocomplete entries via Claude Code's plugin namespacing. The root `SKILL.md` remains the canonical skill source.
### Recovery
```
/plugin update last30days
/reload-plugins
```
Then type `/last30days <topic>` to invoke the skill via slash command. Natural-language invocation ("search the last 30 days for X") continues to work unchanged.
## [3.0.4] - 2026-04-15
### Fixed
- **Cleared `/doctor` path-escape error on Claude Code v2.1.109+.** `.claude-plugin/plugin.json` previously declared `"skills": ["./"]`. That value shipped unchanged from v2.1.0 through v3.0.3 and worked on older Claude Code, but current versions reject `./` with `Path escapes plugin directory: ./ (skills)`. The `"skills"` key is now omitted entirely, matching the pattern used by every other plugin in the Claude Code marketplace ecosystem. Claude Code auto-discovers `skills/*/SKILL.md` when the key is absent.
### Recovery
If `/doctor` reports a path-escape error for last30days, run `/plugin update last30days` then `/reload-plugins`. If errors persist, uninstall and reinstall the plugin.
## [3.0.3] - 2026-04-15
### Fixed
- **Restored `skills/` and `.claude-plugin/` to the plugin install tarball.** v3.0.1 added `.gitattributes` rules that excluded both directories from `git archive` output to shrink the claude.ai `.skill` bundle. Claude Code's `/plugin install` fetches the same archive, so users installing v3.0.1 or v3.0.2 received a tarball with no plugin manifest and no skill files. `git archive v3.0.0` contained 8 files under those paths; `v3.0.1` and `v3.0.2` contained 0. This release reverts those `.gitattributes` lines.
- **Reverted `plugin.json` `"skills"` field to `["./"]`.** v3.0.2 changed this to `["skills"]` based on a misdiagnosis — the manifest change had no effect because the manifest wasn't in the tarball at all. The historical `["./"]` value shipped in every release from v2.1.0 through v3.0.0 without issues and is restored here.
### Recovery
Users on v3.0.1 or v3.0.2: run `/plugin update last30days` then `/reload-plugins`. If autoUpdate is enabled, the next session start will pull v3.0.3 automatically. Users on cached v3.0.0 or earlier installs were unaffected.
### Notes
- The claude.ai `.skill` bundle built by `scripts/build-skill.sh` still works — the archive grew from 89 to 97 files, well under the 200-file cap.
- claude.ai-specific exclusions (avoiding duplicate `SKILL.md` files in the bundle) should move into `scripts/build-skill.sh` rather than `.gitattributes` in a future release, since `.gitattributes` cannot distinguish between the two distribution channels.
## [3.0.2] - 2026-04-15
### Fixed
- **`/last30days` slash command now registers on Claude Code v2.1.105+.** `.claude-plugin/plugin.json` declared `"skills": ["./"]`, which newer Claude Code rejects with `Path escapes plugin directory: ./ (skills)`. The skill silently failed to register, so `/last30days <query>` returned "Unknown command" even though `/plugin list` showed the plugin as installed. Fix: `"skills": ["skills"]` so the loader scans the real skill subdirectory.
- **Version drift between manifests.** `.claude-plugin/marketplace.json` was pinned to `3.0.0` while `.claude-plugin/plugin.json` advertised `3.0.1`. The `/plugin` resolver used the marketplace version and could install stale cached metadata alongside the correct build. Both manifests now agree on `3.0.2`.
### Recovery
If `/last30days` stopped working for you, run `/plugin update last30days` then `/reload-plugins`. If `/doctor` still reports errors, uninstall and reinstall the plugin from the marketplace.
## [3.0.1] - 2026-04-14
### Fixed
- **Skill upload packaging** - `scripts/build-skill.sh` produces a claude.ai-upload-ready `.skill` file that fits under the 200-file cap. Previously, zipping the repo hit 406 files and the "Upload skill" UI rejected it outright.
- **SKILL.md description length** - trimmed from 228 to 167 chars (Anthropic caps descriptions at 200).
### Removed
- Unused root `vendor/` directory (215 files from an accidental commit in PR #48 - the real vendored X client lives at `scripts/lib/vendor/bird-search/`).
- Legacy top-level `plans/` directory (superseded by `docs/plans/`; both plans described work that was already shipped in v3).
### Added
- `.gitattributes` with `export-ignore` entries so `git archive` drops tests, docs, fixtures, assets, historical manifests, and internal skill subdirs. Mirrors Anthropic's canonical `package_skill.py` exclusions.
- `scripts/build-skill.sh` - one-command path to produce `dist/last30days.skill` with a single top-level `last30days/` folder, defensive `=200` file check, and dirty-tree refusal.
- `README.md` section documenting the claude.ai skill upload workflow.
## [3.0.0] - 2026-04-11
## [3.0.0] - 2026-04
### Highlights
@@ -240,18 +34,10 @@ Intelligent search, fun judge, cross-source cluster merging, single-pass compari
- Polymarket display shows % odds only; dollar volumes removed
- 852 tests passing
### Fixed
- Marketplace validation: duplicate `name: last30days` collision in `skills/last30days/SKILL.md` caused strict validators to reject the plugin. Resolved by renaming the internal v3 architecture spec to `last30days-v3-spec` with `user-invocable: false`. Fixed in #214 (reported by @Cody-Coyote in #204).
- Stale README link to the deleted `skills/last30days-v3/` path from the v3 directory rename. Fixed in #214.
- OpenAI Codex CLI discoverability: added `.agents/skills/last30days/SKILL.md` as a real file (Codex's loader skips symlinked files) plus `.codex-plugin/plugin.json` as the namespace marker. The skill now registers as `last30days:last30days` when Codex runs in a checkout of the repo. Fixed in #219 (inspired by @Jah-yee in #153 and @dannyshmueli on X).
### Contributors
- @j-sperling -- v3 engine architecture, Python pre-research brain
- @hnshah -- Watchlist features
- @Cody-Coyote -- Marketplace validation bug report (#204)
- @Jah-yee -- Codex CLI integration inspiration (#153)
## [2.9.4] - 2026-03-06
@@ -280,15 +66,15 @@ Intelligent search, fun judge, cross-source cluster merging, single-pass compari
### Highlights
Auto-save research briefings to the default memory directory as topic-named .md files. Every run now builds a personal research library automatically - no more manual copy-paste.
Auto-save research briefings to `~/Documents/Last30Days/` as topic-named .md files. Every run now builds a personal research library automatically - no more manual copy-paste.
### Added
- Auto-save complete research briefings (synthesis, stats, follow-up suggestions) to the default memory directory after every run
- Auto-save complete research briefings (synthesis, stats, follow-up suggestions) to `~/Documents/Last30Days/{topic-slug}.md` after every run
- Kebab-case filename generation from topic (e.g., "Claude Code skills" -> `claude-code-skills.md`)
- Duplicate topic handling: appends date suffix instead of overwriting (e.g., `claude-code-skills-2026-03-05.md`)
- Agent mode (`--agent`) also saves research files
- Brief confirmation after save with the saved file path
- Brief confirmation after save: "Saved to ~/Documents/Last30Days/{slug}.md"
### Credits
@@ -395,6 +181,7 @@ Three headline features: watchlists for always-on bots, YouTube transcripts as a
### Credits
- @steipete -- Bird CLI (vendored X search) and yt-dlp/summarize inspiration for YouTube transcripts
- @galligan -- Marketplace plugin inspiration
- @hutchins -- Pushed for YouTube feature
@@ -402,7 +189,6 @@ Three headline features: watchlists for always-on bots, YouTube transcripts as a
Initial public release. Reddit + X search via OpenAI Responses API and xAI API.
[3.0.9]: https://github.com/mvanhorn/last30days-skill/compare/v3.0.5...v3.0.9
[2.9.1]: https://github.com/mvanhorn/last30days-skill/compare/v2.9.0...v2.9.1
[2.9.0]: https://github.com/mvanhorn/last30days-skill/compare/v2.8.0...v2.9.0
[2.8.0]: https://github.com/mvanhorn/last30days-skill/compare/v2.6.0...v2.8.0
+1 -5
View File
@@ -18,8 +18,4 @@ bash scripts/sync.sh # Deploy to ~/.claud
## Rules
- `lib/__init__.py` must be bare package marker (comment only, NO eager imports)
- After edits: run `bash scripts/sync.sh` to deploy
- Git remote: origin = public (`mvanhorn/last30days-skill`)
## Beta channel
Experimental changes get tested on `mvanhorn/last30days-skill-private`, which installs as a parallel `/last30days-beta` slash command. Beta-only changes never ship to public without a review PR here. Workflow guide lives at `BETA.md` in the private repo. Plan that established this setup: `docs/plans/2026-04-17-005-feat-beta-skill-from-private-repo-plan.md`.
- Git remotes: origin=private, upstream=public
-121
View File
@@ -1,121 +0,0 @@
# Hermes Setup Guide for last30days
This guide covers installing last30days on Hermes AI Agent.
## Prerequisites
1. **Hermes installed** - See https://github.com/mercurial-tf/hermes
2. **Python 3.12+** - `brew install python@3.12` or similar
3. **yt-dlp** (optional, for YouTube) - `brew install yt-dlp`
## Installation
### Option 1: Via sync.sh (Recommended)
```bash
# Clone the repo
git clone https://github.com/mvanhorn/last30days-skill.git
cd last30days-skill
# Run the sync script
bash scripts/sync.sh
```
This will auto-detect Hermes and deploy to `~/.hermes/skills/research/last30days/`
### Option 2: Manual Copy
```bash
# Create directory
mkdir -p ~/.hermes/skills/research/last30days
# Copy files
cp -r scripts ~/.hermes/skills/research/last30days/
cp .hermes-plugin/SKILL.md ~/.hermes/skills/research/last30days/
```
## Usage
In Hermes, invoke with:
```
last30days "your research topic"
```
Or with options:
```
last30days "best mechanical keyboards 2025" --search=reddit,youtube
last30days "AI news" --days=7 --deep
```
## First Run Setup
On first run, the skill will guide you through setup:
1. **Auto setup** (~30 seconds)
- Scans browser cookies for X/Twitter
- Checks/installs yt-dlp for YouTube
- Configures free sources (Reddit, HN, Polymarket)
2. **Optional: ScrapeCreators**
- Adds TikTok, Instagram, Reddit backup
- 10,000 free API calls
- Sign up at scrapecreators.com
3. **Optional: API Keys**
- XAI_API_KEY for X/Twitter (alternative to browser cookies)
- BRAVE_API_KEY for web search
## Available Sources
### Free (No API Key)
- **Reddit** - Public discussions and comments
- **Hacker News** - Tech discussions via Algolia
- **Polymarket** - Prediction markets
- **YouTube** - Search and transcripts (requires yt-dlp)
### Requires API Key
- **X/Twitter** - xAI API key or browser cookies
- **TikTok** - ScrapeCreators API
- **Instagram** - ScrapeCreators API
- **Web Search** - Brave Search API
## Troubleshooting
### Python not found
```bash
# Find Python 3.12+
which python3.12 python3.13 python3.14
# If not installed
brew install python@3.12
```
### yt-dlp not found
```bash
brew install yt-dlp
# or
pip install yt-dlp
```
### Check what's configured
```bash
cd ~/.hermes/skills/research/last30days
python3.12 scripts/last30days.py --diagnose
```
## Updating
To update to the latest version:
```bash
cd last30days-skill
git pull
bash scripts/sync.sh
```
## Support
- Original repo: https://github.com/mvanhorn/last30days-skill
- Hermes: https://github.com/mercurial-tf/hermes
- Issues: Please report in the original repo
+8 -42
View File
@@ -12,7 +12,7 @@
**An AI agent-led search engine scored by upvotes, likes, and real money - not editors.**
This README tracks the current v3 pipeline. The runtime skill spec lives in [SKILL.md](SKILL.md), which is the source of truth for the latest command and setup behavior.
This README tracks the current v3 pipeline. The runtime skill spec lives in [skills/last30days-v3/SKILL.md](skills/last30days-v3/SKILL.md), which is the source of truth for the latest command and setup behavior.
Claude Code:
```
@@ -24,12 +24,6 @@ OpenClaw:
clawhub install last30days-official
```
Hermes:
```
# The skill auto-deploys when you run sync.sh
# Or manually copy to ~/.hermes/skills/research/last30days/
```
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.
---
@@ -114,10 +108,6 @@ When the same story appears on Reddit, X, and YouTube, v3 merges them into one c
"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.
### Auto-discovered competitor comparisons
`/last30days OpenAI --competitors` tells the hosting reasoning model to discover the top 2 peers via WebSearch (Anthropic, xAI), run Step 0.55 per entity, and invoke the engine with `"OpenAI vs Anthropic vs xAI"` and a per-entity `--competitors-plan` JSON. The engine fans out 3 full pipelines in parallel, saves a `*-raw.md` file per entity, and merges them into a 3-way comparison. Same mechanics power `/last30days "OpenAI vs Anthropic vs xAI"` directly.
### 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?
@@ -132,7 +122,7 @@ Say "eli5 on" after any research run. The synthesis rewrites in plain language.
- **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 + TikTok comments.** Opt-in sources via ScrapeCreators. Set `INCLUDE_SOURCES=tiktok,instagram` and add threads, pinterest, youtube_comments, tiktok_comments for more. `youtube_comments` and `tiktok_comments` surface top comments with vote counts the same way Reddit does.
- **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.
@@ -145,52 +135,28 @@ Say "eli5 on" after any research run. The synthesis rewrites in plain language.
## Install
| Surface | Install |
|---------|---------|
| **claude.ai** (web) | [Download `last30days.skill`](https://github.com/mvanhorn/last30days-skill/releases/latest/download/last30days.skill) and upload via Settings > Capabilities > Skills > + |
| **Claude Code** | `/plugin marketplace add mvanhorn/last30days-skill` |
| **OpenClaw** | `clawhub install last30days-official` |
| **Gemini CLI** | Clone then `gemini extensions install ./last30days-skill` (see below) |
### claude.ai (web)
1. [Download `last30days.skill`](https://github.com/mvanhorn/last30days-skill/releases/latest/download/last30days.skill) from the latest release
2. Go to [claude.ai Settings > Capabilities > Skills](https://claude.ai/settings/capabilities)
3. Click the `+` button in the Skills panel and drop the file in
Enable "Code execution and file creation" under Capabilities first - skills won't run without it.
### Claude Code
#### Install
```
/plugin marketplace add mvanhorn/last30days-skill
```
Update later with `claude plugin update last30days@last30days-skill`.
#### Update
```
claude plugin update last30days@last30days-skill
```
### OpenClaw
```bash
clawhub install last30days-official
```
### Gemini CLI
Gemini CLI v0.9.0 has an upstream installer bug that can fail with `Configuration file not found at /tmp/gemini-extensionXXXXXX/gemini-extension.json` ([upstream issue](https://github.com/google-gemini/gemini-cli/issues/11452)). Workaround:
```bash
git clone https://github.com/mvanhorn/last30days-skill
gemini extensions install ./last30days-skill
```
### Manual (developer)
### Manual
```bash
git clone https://github.com/mvanhorn/last30days-skill.git ~/.claude/skills/last30days
```
Or build the claude.ai `.skill` file from source: `bash scripts/build-skill.sh` produces `dist/last30days.skill`.
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
+466 -690
View File
File diff suppressed because it is too large Load Diff
-9
View File
@@ -1,9 +0,0 @@
---
description: Research what people actually say about any topic in the last 30 days across Reddit, X, YouTube, TikTok, Hacker News, Polymarket, GitHub, and the web.
argument-hint: <topic> — e.g. "nvidia earnings reaction" or "best noise cancelling headphones"
allowed-tools: [Bash, Read, Write, AskUserQuestion, WebSearch]
---
Invoke the `last30days` skill with the user's arguments: $ARGUMENTS
Use the skill's canonical pipeline (plan → retrieve → normalize → fuse → rerank → cluster → render). If the user provided no arguments, ask them for a topic before proceeding.
@@ -0,0 +1,319 @@
---
title: "feat: YouTube podcast source with transcript-first discovery"
type: feat
status: active
date: 2026-04-10
---
# feat: YouTube podcast source with transcript-first discovery
## Overview
Add a "podcasts" source to last30days that discovers podcast content on YouTube by scanning transcripts, not searching titles. The LLM planner resolves topic-relevant podcast channels (e.g., "NVIDIA" -> Acquired, Lex Fridman, Dwarkesh Patel, All-In). The engine fetches recent episodes from those channels, downloads their auto-captions (no video download), and greps for the search topic. Episodes with 5+ topic mentions become podcast results with transcript highlights.
This finds content invisible to any search engine. Acquired's "The NFL" episode mentions Taylor Swift 18 times, ESPN 117 times, Netflix 102 times - none in the title. A Dwarkesh Patel episode titled "The single biggest bottleneck to scaling AI compute" contains 156 mentions of NVIDIA. No YouTube search finds these. Transcript scanning does.
Zero new API keys. Zero new dependencies. Reuses existing yt-dlp + transcript pipeline. Podcasts get their own identity in stats and synthesis.
## Problem Frame
YouTube captures a lot of podcast content, but it's mixed with news clips, reaction videos, and shorts. The general YouTube search treats a 2:24:55 Drink Champs interview the same as a 0:30 TMZ clip. Worse, the highest-value podcast content is often invisible to search entirely because the topic is discussed within an episode titled something else.
Two insights make this solvable:
1. Podcast episodes are identifiable by duration (>20 minutes) and channel.
2. YouTube auto-captions are free, downloadable without the video (~7 seconds per episode via yt-dlp), and searchable. Transcript scanning discovers content that title-based search cannot.
The LLM already resolves subreddits and X handles per topic. Podcast channels are the same pattern.
## Requirements Trace
- R1. LLM resolves topic-relevant podcast YouTube channels dynamically (no hardcoded list)
- R2. Engine scans recent episode transcripts for the search topic, not just titles
- R3. Podcast results get their own source identity with own stats line and synthesis treatment
- R4. Reuses existing yt-dlp transcript pipeline (no new dependencies)
- R5. Does not duplicate regular YouTube results (dedup by video ID in fusion)
- R6. Channel resolution works in both the agent layer (SKILL.md) and the Python planner
## Scope Boundaries
- Not building a new API integration (reuses yt-dlp entirely)
- Not adding PodcastIndex, AssemblyAI, or any podcast-specific API
- Not changing how the regular YouTube source works
- Not building a podcast channel database
- Channels that can't be resolved are skipped silently (graceful degradation)
## Context & Research
### Relevant Code and Patterns
- `scripts/lib/youtube_yt.py` - YouTube search + transcript pipeline. Key functions: `search_youtube()`, `fetch_transcripts()`, `extract_transcript_highlights()`
- `scripts/lib/youtube_yt.py` - `--write-auto-sub --skip-download` fetches captions without downloading video
- Step 0.55 in `SKILL.md` - subreddit resolution pattern (WebSearch + LLM knowledge -> `--subreddits=`)
- `scripts/lib/pipeline.py` - source dispatch via if/elif chain in `_retrieve_stream()`, 4-point registration pattern
- `scripts/lib/normalize.py` - `_normalize_youtube()` handles transcript data, reusable for podcasts
- `scripts/lib/signals.py` - `SOURCE_QUALITY` dict (YouTube is 0.85)
- `scripts/lib/planner.py` - `QueryPlan` schema, `SOURCE_CAPABILITIES` dict
### Proof of Concept Results (2026-04-10)
**Transcript-first discovery test:** Fetched auto-captions for 5 recent Acquired episodes (35 seconds total, no video download). Grepped for topics not in any episode title:
| Topic | Mentions | Episode title | Discoverable by search? |
|-------|----------|---------------|------------------------|
| ESPN | 117 | The NFL | No |
| Super Bowl | 108 | The NFL | No |
| Netflix | 102 | The NFL | No |
| Amazon | 87 | The NFL | No |
| Costco | 63 | The NFL / others | No |
| Disney | 48 | The NFL | No |
| LVMH | 27 | Formula 1 / others | No |
| Taylor Swift | 18 | The NFL | No |
**Full E2E test (topic: NVIDIA, 4 channels):** LLM resolved Acquired, Lex Fridman, Dwarkesh Patel, All-In. Scanned 14 episodes. Results:
| Podcast | Episode | NVIDIA mentions | Title mentions NVIDIA? |
|---------|---------|----------------|----------------------|
| Lex Fridman | Jensen Huang interview | 159 | Yes |
| Dwarkesh Patel | Dylan Patel: AI compute bottleneck | 156 | No |
| Acquired | 10 Years (w/ Michael Lewis) | 24 | No |
| All-In | SpaceX IPO, Iran, Quantum... | 6 | No |
3 of 4 hits are invisible to YouTube search. The Dylan Patel episode (156 mentions!) is entirely about NVIDIA's GPU supply chain but the title never says "NVIDIA."
**Channel handle resolution test:** LLM resolves podcast name + @handle guess. Engine tries @handle first (fast), falls back to `ytsearch1:` if wrong. Tested across 12 channels (tech, hip-hop, knitting): 11/12 resolved on first @handle attempt, 12/12 with fallback. Even niche channels (Fruity Knitting, Grocery Girls Knit, Roxanne Richardson) resolved correctly.
**Rate limit test:** 4 channels x 3-4 episodes = 14 caption fetches took ~2 minutes sequential. Parallelized with 4 workers: ~30-40 seconds. No YouTube throttling observed. Runs concurrently with Reddit/X/everything else in a 3-minute research run.
## Key Technical Decisions
- **Transcript-first discovery, not title/search-based:** The core innovation. Instead of searching YouTube for `{topic} {podcast_name}` (which only finds episodes titled about the topic), we fetch captions from recent episodes and grep for the topic. This discovers hidden mentions. The approach is validated by POC data showing 3/4 NVIDIA hits were invisible to search.
- **LLM-resolved channels, not hardcoded:** The LLM planner (agent layer or Python Gemini/OpenAI) resolves 6-12 channels per topic using two-dimensional reasoning: (1) domain podcasts that focus on the topic's area, (2) cross-domain podcasts that might cover it. Tested: the LLM correctly resolved channels for NVIDIA (tech), Kanye (hip-hop), and knitting (craft) - including niche channels like Fruity Knitting and Grocery Girls Knit. Three resolution paths mirror the existing planner architecture:
- Path 1: Agent layer (SKILL.md with WebSearch) resolves channels in Step 0.55
- Path 2: Python planner (Gemini/OpenAI) generates channels as a `podcast_channels` field in the QueryPlan
- Path 3: Fallback (no LLM) uses a small default list of ~5 broad-appeal channels
- **Handle-first channel resolution with search fallback:** The LLM returns both the podcast name and its best guess at the @handle. The engine tries the @handle first (instant, 92% success rate in testing). If the handle fails, it falls back to `ytsearch1:"{podcast name}" podcast full episode` to find the channel URL. Channels that can't be resolved either way are skipped silently.
- **New source module wrapping YouTube functions:** `podcast_yt.py` imports `fetch_transcripts()` and `extract_transcript_highlights()` from `youtube_yt.py`. It adds the channel-fetching, caption-scanning, and mention-counting logic. This keeps the regular YouTube source untouched and gives podcasts their own pipeline identity.
- **Duration filter >= 1200 seconds (20 minutes):** Eliminates clips, shorts, and news segments. Tested empirically - only full podcast episodes survive this filter.
- **SOURCE_QUALITY: 0.88 (above YouTube's 0.85):** Podcast episodes contain long-form expert discussion with full context. The quality bonus ensures podcast results rank above equivalent YouTube clips when both exist.
- **Mention count threshold: 5+:** Episodes with fewer than 5 topic mentions are noise (passing references). 5+ indicates substantive discussion. Tested: Taylor Swift at 18 mentions in the NFL episode is substantive discussion of her impact on viewership. "Apple" at 3 mentions in a random episode is just name-dropping.
## Open Questions
### Resolved During Planning
- **Can yt-dlp fetch captions without downloading video?** Yes. `yt-dlp --write-auto-sub --sub-lang en --skip-download --sub-format vtt` fetches only the subtitle file. ~7 seconds per episode, ~2MB per 4-hour episode.
- **Will this double-count YouTube content?** No. Fusion deduplicates by item ID. Both sources use `yt_{video_id}` format.
- **Can LLMs resolve niche podcast channels?** Yes. Tested with knitting: Fruity Knitting, VeryPink Knits, Grocery Girls Knit, Roxanne Richardson all resolved correctly via @handle.
- **What about rate limits?** 14 caption fetches across 4 channels showed no throttling. Running in parallel with 4 workers keeps total time under 40 seconds. yt-dlp doesn't use the YouTube Data API (no quota).
- **How does the LLM know which podcasts to pick?** Two-dimensional prompt: (1) "What YouTube podcasts focus on {topic's domain}?" and (2) "What popular interview/deep-dive podcasts have likely discussed {topic}?" The LLM returns channel names + @handle guesses.
### Deferred to Implementation
- **Exact duration threshold:** Starting with 1200s (20 min). May tune to 900s (15 min) if testing shows missed content.
- **Mention count threshold tuning:** Starting with 5. May need per-source calibration (a 30-minute podcast with 5 mentions is denser than a 4-hour one with 5 mentions).
- **Caption language handling:** Starting with English (`--sub-lang en`). Multilingual support deferred.
- **Parallel worker count:** Starting with 4 workers. May tune based on YouTube throttling behavior at scale.
## High-Level Technical Design
> *This illustrates the intended approach and is directional guidance for review, not implementation specification.*
```
PODCAST DISCOVERY FLOW:
User query: "NVIDIA"
|
LLM planner resolves podcast channels:
"NVIDIA is a tech/AI company. Domain podcasts: none specific.
Cross-domain: Acquired (@AcquiredFM), Lex Fridman (@lexfridman),
Dwarkesh Patel (@DwarkeshPatel), All-In (@AllInPod)"
|
Engine receives: --podcast-channels=AcquiredFM,lexfridman,DwarkeshPatel,AllInPod
|
For each channel (parallel, 4 workers):
|
[1] Resolve @handle -> channel URL
Try: https://youtube.com/@AcquiredFM/videos
If fail: ytsearch1:"Acquired podcast full episode" -> extract channel_url
If fail: skip channel
|
[2] Fetch last 3 episode IDs + metadata (duration, date, title)
yt-dlp --flat-playlist --playlist-end 3
|
[3] Filter: duration >= 1200s AND upload_date in date range
|
[4] For each surviving episode:
Fetch auto-captions: yt-dlp --write-auto-sub --skip-download
Grep captions for "nvidia" (case-insensitive)
If mentions >= 5: HIT - extract transcript highlights around mentions
|
Merge all hits, deduplicate by video_id
Score: mention_count * log(views)
Return as source="podcasts" items with transcript_snippet + mention_count
```
## Implementation Units
- [ ] **Unit 1: Podcast transcript-scan module**
**Goal:** Create `scripts/lib/podcast_yt.py` with the channel-fetching, caption-scanning, mention-counting pipeline. Returns podcast episodes discovered via transcript scanning.
**Requirements:** R2, R3, R4
**Dependencies:** None (youtube_yt.py already exists)
**Files:**
- Create: `scripts/lib/podcast_yt.py`
- Test: `tests/test_podcast_yt.py`
**Approach:**
- `search_podcast_youtube(topic, from_date, to_date, depth, channels)`:
- For each channel handle (in parallel via ThreadPoolExecutor, max 4 workers):
1. Resolve handle to channel URL (try @handle first, search fallback)
2. Fetch last N episode IDs + metadata via `yt-dlp --flat-playlist --playlist-end N`
3. Filter: `duration >= 1200` and `upload_date` within date range
4. Fetch auto-captions via `yt-dlp --write-auto-sub --skip-download --sub-lang en`
5. Grep captions for topic keywords (case-insensitive). Count mentions.
6. If mentions >= MENTION_THRESHOLD: include as hit. Extract transcript highlights around mentions using `extract_transcript_highlights()` from `youtube_yt`.
- Merge results, deduplicate by video_id
- Score: `mention_count * log(views + 1)`
- Skip channels that can't be resolved or have no recent episodes
- `resolve_channel(handle)`: Try `@{handle}` URL first. If 404, search `ytsearch1:"{handle}" podcast full episode`, extract channel_url. Return channel_url or None.
- EPISODES_PER_CHANNEL: quick=2, default=3, deep=4
- MENTION_THRESHOLD: 5
- RESULTS_CAP: quick=4, default=8, deep=20
**Patterns to follow:**
- `scripts/lib/youtube_yt.py` `search_and_transcribe()` for search-then-enrich flow
- `scripts/lib/youtube_yt.py` `extract_transcript_highlights()` for highlight extraction
- `scripts/lib/hackernews.py` for clean module structure with `_log()`, `DEPTH_CONFIG`
**Test scenarios:**
- Happy path (hidden mention): topic "Taylor Swift", channels=["AcquiredFM"] -> scans NFL episode, finds 18 mentions, returns episode with highlights about Taylor Swift's NFL viewership impact
- Happy path (title match): topic "kanye west", channels=["RevoltTV"] -> scans Kanye interview, finds 500+ mentions, returns with highlights
- Happy path (scoring): episode with 156 mentions and 205K views scores higher than one with 6 mentions and 145K views
- Happy path (handle resolution): @AcquiredFM resolves directly. @SomeWrongHandle fails, search fallback finds correct channel.
- Edge case: topic "quantum computing" has <5 mentions in all episodes -> returns empty (threshold not met)
- Edge case: @handle doesn't exist AND search fallback fails -> channel skipped silently, other channels still scanned
- Edge case: channel has no episodes in date range -> skipped
- Edge case: episode has no auto-captions available -> skipped with log warning
- Error path: yt-dlp not installed -> returns empty items with log warning
- Error path: caption download times out -> skip that episode, continue
**Verification:**
- Discovers episodes where topic is discussed but not in the title (Acquired/NFL/Taylor Swift)
- Also discovers episodes where topic IS the subject (via same transcript scan)
- All returned items have duration >= 1200
- Each item has: video_id, title, channel, url, date, duration, engagement, transcript_snippet, mention_count
---
- [ ] **Unit 2: Pipeline integration**
**Goal:** Register "podcasts" as a new source in pipeline, normalizer, signals, planner, env, and render.
**Requirements:** R3, R5, R6
**Dependencies:** Unit 1
**Files:**
- Modify: `scripts/lib/pipeline.py` (import, MOCK_AVAILABLE_SOURCES, available_sources, _retrieve_stream)
- Modify: `scripts/lib/normalize.py` (add normalizer - reuse `_normalize_youtube` with source override)
- Modify: `scripts/lib/signals.py` (add SOURCE_QUALITY: 0.88)
- Modify: `scripts/lib/planner.py` (add SOURCE_CAPABILITIES, extend QueryPlan schema with `podcast_channels` field, add prompt guidance for LLM channel resolution)
- Modify: `scripts/lib/env.py` (add is_podcast_yt_available - checks yt-dlp installed + "podcasts" in INCLUDE_SOURCES)
- Modify: `scripts/lib/render.py` (add SOURCE_LABELS: "podcasts" -> "Podcasts")
- Test: `tests/test_podcast_yt.py` (pipeline dispatch test)
**Approach:**
- Availability: yt-dlp installed + "podcasts" in INCLUDE_SOURCES. No API key needed.
- SOURCE_CAPABILITIES: `{"podcasts": {"discussion", "longform", "expert", "interview"}}`
- Normalizer: reuse `_normalize_youtube` via lambda wrapper, override source to "podcasts". Add `mention_count` to metadata.
- CLI flag: `--podcast-channels=handle1,handle2,...` parsed from args
- Planner: extend QueryPlan with `podcast_channels: list[str]`. Prompt guidance for LLM: "List 6-12 YouTube podcast channel @handles that would discuss this topic. Think in two dimensions: (1) domain podcasts that focus on this area, (2) popular cross-domain interview/deep-dive podcasts that might cover it. Return @handles. If unsure of exact handle, return your best guess."
- Planner: include "podcasts" source for general/opinion/comparison intents
- Dedup: podcast items use `yt_{video_id}` ID format (same as YouTube). Fusion dedup handles collisions.
**Patterns to follow:**
- 4-point pipeline registration (same as all sources)
- `_normalize_youtube` reuse via lambda (like tiktok/instagram share `_normalize_shortform_video`)
- `scripts/lib/env.py` INCLUDE_SOURCES opt-in pattern
**Test scenarios:**
- Happy path: "podcasts" in available_sources when yt-dlp installed + INCLUDE_SOURCES contains "podcasts"
- Happy path: pipeline dispatches to podcast_yt.search_podcast_youtube when source="podcasts"
- Edge case: yt-dlp not installed -> podcasts not available
- Edge case: "podcasts" not in INCLUDE_SOURCES -> not available even with yt-dlp
- Integration: podcast video_id collides with YouTube result -> fusion deduplicates, keeps higher score
**Verification:**
- `python3 scripts/last30days.py "NVIDIA" --podcast-channels=AcquiredFM,lexfridman` returns podcast results
- Stats output shows "Podcasts" line separate from "YouTube"
---
- [ ] **Unit 3: SKILL.md podcast channel resolution + synthesis**
**Goal:** Add podcast channel resolution to Step 0.55 and podcast-specific synthesis guidance to the Judge Agent section.
**Requirements:** R1, R3, R6
**Dependencies:** Unit 2
**Files:**
- Modify: `SKILL.md`
**Approach:**
- **Step 0.55 addition:** Add "Resolve podcast channels" alongside subreddit, X handle, and TikTok resolution. The agent resolves 6-12 @handles using two-dimensional reasoning (domain + cross-domain). For niche topics, supplement with `WebSearch("{TOPIC} podcast YouTube channel")`. Display resolved channels: "Podcasts: @AcquiredFM, @lexfridman, @DrinkChamps". Pass as `--podcast-channels=AcquiredFM,lexfridman,DrinkChamps`.
- **Step 0.75 addition:** Add "podcasts" to available sources list. Include in primary subquery sources.
- **Synthesis guidance addition:** "For podcasts: lead with the guest's name and the podcast name. Quote transcript highlights as direct quotes with speaker attribution. Podcast content represents considered opinion, not hot takes - a 2-hour interview has more nuance than a tweet. When both a podcast and a YouTube clip cover the same topic, prefer the podcast's longer-form analysis."
- **Stats format:** `├─ 🎙️ Podcasts: {N} episodes │ {N} views │ {N} with transcripts`
- **INCLUDE_SOURCES:** Add "podcasts" as an option. Note in setup: "Requires yt-dlp (already installed if YouTube works). No API key needed."
- **Invitation section:** Reference podcast episodes in follow-up suggestions ("Want me to pull more from that Lex Fridman episode?")
**Patterns to follow:**
- Step 0.55 subreddit resolution pattern
- Source-specific synthesis guidance (YouTube highlights, Reddit top comments)
**Test scenarios:**
- Test expectation: none - SKILL.md is an instruction document. Verification is manual E2E.
**Verification:**
- `/last30days NVIDIA` resolves tech podcast channels and passes them to engine
- `/last30days Kanye West` resolves hip-hop podcast channels
- `/last30days knitting` resolves craft podcast channels (Fruity Knitting, etc.)
- Stats show 🎙️ Podcasts line. Synthesis quotes podcast content with speaker attribution.
## Risks & Dependencies
| Risk | Mitigation |
|------|------------|
| LLM guesses wrong @handle | Handle-first resolution with search fallback. 92% first-attempt success in testing, 100% with fallback. Wrong handles fail fast and skip silently. |
| Transcript scanning adds latency | Runs in parallel with all other sources. 4 channels x 3 episodes = ~30-40s parallelized. Invisible in a 3-minute research run. |
| Topic mentions below threshold (lots of misses) | LLM picks channels likely to discuss the topic. When it picks well, hit rate is high (4/14 episodes in NVIDIA test). Misses cost ~7s per episode in wasted caption download - acceptable. |
| YouTube throttles caption downloads | 14 sequential downloads showed no throttling. Capping at 4 parallel workers adds safety margin. If throttled, degrade gracefully (fewer episodes scanned). |
| Niche topics have no relevant podcast channels | LLM returns fewer channels (3-4 instead of 10-12). If none can be resolved, podcast source returns empty. Other sources (Reddit, X, YouTube) still run. |
| Same video in both YouTube and podcast results | Fusion deduplicates by `yt_{video_id}`. Podcast version gets 0.88 quality score vs YouTube's 0.85, so podcast version wins dedup. |
## Sources & References
- POC: transcript scan of 5 Acquired episodes found ESPN (117), Netflix (102), Taylor Swift (18), LVMH (27) - all invisible to search
- POC: E2E NVIDIA test across 4 channels found 5 hits, 3 invisible to search (including 156-mention Dwarkesh Patel episode)
- POC: handle resolution tested 12 channels (tech, hip-hop, knitting) - 11/12 first-attempt, 12/12 with fallback
- Related code: `scripts/lib/youtube_yt.py`, `scripts/lib/pipeline.py`, `scripts/lib/hackernews.py`
- Pattern: SKILL.md Step 0.55 subreddit resolution
- yt-dlp docs: https://github.com/yt-dlp/yt-dlp
- Acquired FM: https://www.youtube.com/@AcquiredFM
@@ -1,303 +0,0 @@
---
title: "feat: --competitors flag for auto-discovered comparison fan-out"
type: feat
status: active
date: 2026-04-22
---
# feat: --competitors flag for auto-discovered comparison fan-out
## Overview
Add a `--competitors` flag to the last30days engine that auto-discovers 2-4 peer entities for the topic, runs the full retrieval pipeline on each in parallel, and renders a multi-entity comparison. Invoking `last30days Kanye West --competitors` should resolve to "Kanye vs Drake vs Kendrick Lamar" and emit a comparison report covering all three. Invoking `last30days OpenAI --competitors` should resolve to "OpenAI vs Anthropic vs xAI vs Gemini" and emit a four-way comparison.
Discovery mirrors the existing `resolve.auto_resolve()` pattern used for X handles and subreddits at pipeline start — web search (Brave / Exa / Serper) plus deterministic extraction. Not an internal LLM call.
## Problem Frame
Users who want a comparison today must type "OpenAI vs Anthropic vs xAI" themselves. The `planner._comparison_entities()` path already handles explicit multi-entity topics and `render._render_comparison_scaffold()` already emits a 9-axis comparison table. What is missing is the discovery half — a user who types a single entity with `--competitors` should get the comparison for free.
This is also the natural next step after the Step 0.55 category-peer subreddit work (PR #305, merged 2026-04-22). That feature widens the subreddit set within a single topic; this feature widens the entity set into peer entities.
## Requirements Trace
- R1. New `--competitors` boolean flag that triggers competitor discovery and multi-entity fan-out.
- R2. New `--competitors-list="A,B,C"` to explicitly skip discovery (mirrors `--plan`, `--subreddits`, `--x-handle` overrides).
- R3. New `--competitors=N` short form to set competitor count inline (N in 1..6).
- R4. Default count is 3 competitors (original + 3 = 4-way comparison).
- R5. Competitor retrieval depth inherits the main run's depth (`--quick` / `--deep`); all entities run in parallel so wall clock stays close to a single run.
- R6. Discovery mirrors `resolve.auto_resolve()`: web search for peers, deterministic text extraction. No internal LLM dependency.
- R7. If no web search backend is configured and no `--competitors-list` was passed, engine emits a LAW 7-style stderr telling the host agent to pass `--competitors-list` and exits non-zero.
- R8. Output rendering is a single comparison report covering all entities, reusing the existing 9-axis scaffold from `render._render_comparison_scaffold()` where applicable.
## Scope Boundaries
- Synthesis prompt changes beyond wiring N reports into the existing comparison scaffold are out of scope.
- `--competitors` does not replace the existing explicit "A vs B vs C" topic parsing in `planner._comparison_entities()`; both paths coexist.
- No caching layer for discovery results in v1.
- No UI/SKILL.md rewrite of the entire comparison section; only the new flag is documented.
- No new web search backend.
### Deferred to Separate Tasks
- Caching of competitor lookups: separate follow-up once hit rate justifies it.
- Disambiguation UX for topics with multiple common entities ("Amazon" the company vs the river): separate brainstorm.
## Context & Research
### Relevant Code and Patterns
- `scripts/last30days.py:168-249``build_parser()` argparse definitions. Existing depth flags (`--quick`, `--deep`) and override flags (`--plan`, `--subreddits`, `--x-handle`, `--auto-resolve`) set the convention to mirror.
- `scripts/lib/resolve.py:179-258``auto_resolve()` is the reference pattern: web search fan-out via `ThreadPoolExecutor`, per-query extraction functions, graceful empty-dict return when no backend is available.
- `scripts/lib/resolve.py:98-140``_extract_x_handle()` and sibling extractors show the deterministic text-mining style competitor extraction should mirror.
- `scripts/lib/pipeline.py:162-220``pipeline.run()` signature is the fan-out target. One call per entity, each returning a `schema.Report`.
- `scripts/lib/planner.py:430-564` — Existing comparison-intent handling and `_comparison_entities()` entity extraction. The new flag feeds the same mental model but populates entities from discovery instead of from the topic string.
- `scripts/lib/render.py:333-392``_render_comparison_scaffold()` already emits a 9-axis markdown comparison table. The new multi-report renderer should reuse this helper by assembling a synthetic "A vs B vs C" topic header for it.
- `scripts/lib/grounding.py` + `scripts/lib/providers.py` — Web search backend resolution (Brave / Exa / Serper). Reused as-is.
### Institutional Learnings
- No existing `docs/solutions/` entries for competitor discovery or multi-entity fan-out.
- Recent plan `docs/plans/2026-04-22-001-fix-category-peer-subreddit-resolution-plan.md` established the precedent of deterministic peer expansion; this plan extends that idea from subreddits to entities.
### External References
- None gathered — local patterns are strong. `resolve.auto_resolve()` is a direct template.
## Key Technical Decisions
- **Discovery mirrors auto_resolve, not plan_query.** Web search + regex extraction, not an LLM call. Matches the user's explicit direction ("use the python brain the same way it searches for X handles"). Cheaper, no provider credential requirement, deterministic.
- **Orchestration lives in `last30days.py` main, not inside `pipeline.run()`.** The fan-out is a top-level concern — one pipeline run per entity, each independent. Keeps `pipeline.run()` single-entity and unchanged except for sharing a `ThreadPoolExecutor` factory.
- **Sub-runs inherit main depth and run in parallel.** Wall clock ≈ single run; token cost scales linearly with N. User-controlled via the existing `--quick`/`--deep` flags.
- **New module `scripts/lib/competitors.py` instead of adding to `resolve.py`.** Keeps resolve focused on single-entity entity-bundle discovery (handles/subreddits/github); competitors.py owns peer-entity discovery. Similar shape, different responsibility.
- **Multi-report render is additive in `render.py`.** New `render_comparison_multi(reports: list[Report]) -> str` composes a synthetic "A vs B vs C" topic and delegates to the existing scaffold + synthesis path where possible. No rewrite of the single-entity render path.
- **Default count = 3 competitors (4-way comparison).** Hard cap at 6.
- **LAW 7-style stderr when no backend and no list.** Matches how `planner.plan_query()` already tells the hosting agent to pass `--plan`.
## Open Questions
### Resolved During Planning
- **Discovery mechanism:** Web search via `grounding.web_search()`, not an internal LLM. User confirmed the auto_resolve pattern is the target.
- **Default competitor count:** 3 (original + 3 = 4-way).
- **Sub-run depth:** Inherit main depth, parallel execution.
- **Flag naming:** `--competitors` (standard argparse double-dash). `--competitors=N` for inline count. `--competitors-list="A,B,C"` to skip discovery.
### Deferred to Implementation
- Exact extraction heuristics for competitor names across Brave / Exa / Serper result shapes. The SERP text varies (listicles, comparison pages, "vs" pages); the initial implementation will start with listicle parsing plus a "X vs Y" pattern match, and harden against real results in the test phase.
- Handling of topic ambiguity ("Amazon", "Apple"). Initial behavior: trust whatever web search returns for the topic verbatim; disambiguation is a separate concern.
- Merge strategy when two entities return overlapping URLs (e.g., an "OpenAI vs Anthropic" article shows up in both runs). Likely dedupe at the clustering step, but defer the exact policy until we see how often it happens.
- Whether to expose competitor discovery artifacts (the raw web search results) as a debug emit. Follow the existing `--debug` conventions.
## Implementation Units
- [ ] **Unit 1: CLI flag parsing and validation**
**Goal:** Add `--competitors`, `--competitors=N`, and `--competitors-list` to the argparse surface, validate values, and thread them into the main orchestration.
**Requirements:** R1, R2, R3, R4
**Dependencies:** None
**Files:**
- Modify: `scripts/last30days.py`
- Test: `tests/test_cli_competitors.py`
**Approach:**
- Add three mutually cooperative flags near line 205 in `build_parser()`:
- `--competitors` with `nargs="?"` and `const=3` so bare `--competitors` defaults to 3, `--competitors=4` is honored, and `--competitors=0` is rejected
- `--competitors-list` free-text CSV
- Normalize in `main()`: if `--competitors-list` is present, skip discovery and use the list. If `--competitors` is set and no list, trigger discovery with count = the flag value. Clamp count to 1..6 with a stderr warning at boundary.
- Thread the resulting entity list into the orchestrator added in Unit 3.
**Patterns to follow:**
- `--plan` argument at `scripts/last30days.py:187` — same skip-discovery-when-explicit shape.
- `--subreddits` / `--x-handle` at `scripts/last30days.py:180,189` — same override semantics.
**Test scenarios:**
- Happy path: bare `--competitors` parses to count=3, empty list.
- Happy path: `--competitors=4` parses to count=4.
- Happy path: `--competitors-list="A,B,C"` parses to count=3, list=["A","B","C"], and is preferred over any discovery signal.
- Edge case: `--competitors=0` and `--competitors=-1` are rejected with a clear error.
- Edge case: `--competitors=99` clamps to 6 with a stderr warning.
- Edge case: `--competitors` combined with `--competitors-list` uses the list and logs that discovery was skipped.
- Edge case: `--competitors-list` value with whitespace ("A, B , C") normalizes correctly.
**Verification:**
- Running the binary with each flag variation produces the expected post-parse state without calling out to the network.
- [ ] **Unit 2: `scripts/lib/competitors.py` discovery module**
**Goal:** Discover peer entities for a topic using web search + deterministic extraction, mirroring `resolve.auto_resolve()`.
**Requirements:** R6, R7
**Dependencies:** None (pure module; wired by Unit 3)
**Files:**
- Create: `scripts/lib/competitors.py`
- Test: `tests/test_competitors.py`
**Approach:**
- Public entry point `discover_competitors(topic: str, count: int, config: dict) -> list[str]`.
- Early return `[]` when `_has_backend(config)` is false (reuse the helper from `resolve.py`; factor if needed).
- Fan out 2-3 web searches in a `ThreadPoolExecutor`:
- `"{topic} competitors"`
- `"{topic} alternatives"`
- `"{topic} vs"` (captures "X vs Y" articles)
- Feed results into a deterministic `_extract_peer_entities(results, topic)` that:
- Mines titles and snippets for capitalized noun phrases other than the topic itself
- Scores by frequency across results
- Filters stopwords and the topic's own tokens
- Returns top `count` unique entities ordered by score
- Emit a single-line stderr log mirroring the `resolve._log` format.
**Patterns to follow:**
- `scripts/lib/resolve.py:179-258` for the function shape, executor usage, and empty-result fallback.
- `scripts/lib/resolve.py:98-140` for extractor style (small, deterministic, no external state).
**Test scenarios:**
- Happy path: canned SERP fixtures for "OpenAI" return ["Anthropic", "xAI", "Google"] or close peers in the top 3.
- Happy path: canned SERP fixtures for "Kanye West" return rap peers (Drake, Kendrick) in the top 3.
- Edge case: empty SERP results return `[]` without raising.
- Edge case: extractor filters out the topic itself (case- and punctuation-insensitive).
- Edge case: near-duplicate entities ("OpenAI" vs "Open AI") dedupe to one slot.
- Error path: web search backend raises — the failure is logged and the function returns `[]`.
- Edge case: count=1 returns a single-element list; count=6 returns up to six entities.
**Verification:**
- Unit tests pass with fixtures committed under `tests/fixtures/competitors-*.json`.
- Manual run against a live backend for one topic confirms sensible output (recorded as a notes file, not a test assertion).
- [ ] **Unit 3: Parallel fan-out orchestrator**
**Goal:** Run `pipeline.run()` once per entity (topic + discovered competitors) in parallel, collect `schema.Report` per entity, and hand them to the comparison renderer.
**Requirements:** R5, R7
**Dependencies:** Unit 1, Unit 2
**Files:**
- Modify: `scripts/last30days.py`
- Possibly create: `scripts/lib/fanout.py` if the orchestrator grows past ~60 lines
- Test: `tests/test_competitor_fanout.py`
**Approach:**
- After arg parsing and before the existing `pipeline.run()` call, branch on `args.competitors`:
- If a list was provided or discovery returned entities, build `entities = [topic, *competitors]`.
- Spawn one `pipeline.run()` per entity via `ThreadPoolExecutor(max_workers=len(entities))`, passing the same `config`, `depth`, and all sub-run-relevant args (mock, plan, etc.). Respect `--plan` — if a plan is passed it applies to the main topic only; competitors use the internal planner fallback for v1.
- Collect `{entity: Report}` mapping. A per-entity failure logs a stderr warning and drops that entity from the comparison; the run continues as long as 2 entities succeed.
- If fewer than 2 entities survive, exit with a clear error.
- LAW 7-style stderr:
- If `args.competitors` is set, no list was passed, no web search backend is configured, emit a LAW 7 stderr message pointing to the `--competitors-list` override and exit non-zero. Reuse the tone from `planner.plan_query()` fallback (`scripts/lib/planner.py:125-135`).
**Execution note:** Start with a failing integration test that exercises the full main → orchestrator → mocked pipeline.run path; the orchestrator is where bugs hide.
**Patterns to follow:**
- `scripts/lib/resolve.py:225-239` for ThreadPoolExecutor + as_completed + per-future error handling.
- `scripts/lib/pipeline.py:310+` for how ThreadPoolExecutor is already used inside a single run (same idiom, outer layer).
**Test scenarios:**
- Happy path: main + 2 competitors, all three `pipeline.run()` calls succeed (mocked), orchestrator returns 3 Reports.
- Happy path: discovery returns the competitor list; orchestrator fans out accordingly.
- Edge case: one of three competitor pipelines raises — the run continues with the surviving 2 and emits a warning.
- Edge case: all competitors fail but the main topic succeeds — orchestrator exits non-zero with a clear error rather than silently degrading to a single-entity render.
- Edge case: `--competitors` set, no backend, no list — orchestrator emits the LAW 7 stderr and exits non-zero before any pipeline call.
- Integration: wall-clock time for 3 mocked pipelines in parallel is close to the slowest single run, not the sum (timing assertion with generous margin).
**Verification:**
- End-to-end test with mocked `pipeline.run()` and mocked competitors discovery produces 3 Reports and hands them to a stubbed renderer.
- [ ] **Unit 4: Multi-report comparison renderer**
**Goal:** Compose N `schema.Report`s into a single comparison-mode output, reusing the existing 9-axis scaffold.
**Requirements:** R8
**Dependencies:** Unit 3
**Files:**
- Modify: `scripts/lib/render.py`
- Test: `tests/test_render_comparison_multi.py`
**Approach:**
- Add `render_comparison_multi(reports: list[schema.Report], *, emit: str) -> str`.
- Build a synthetic comparison topic: `f"{entity_a} vs {entity_b} vs {entity_c}"`.
- Reuse `_render_comparison_scaffold()` for the table skeleton. Each entity column is populated from its own Report's top clusters and citations.
- For the narrative synthesis block, concatenate per-entity highlights, clearly labeled by entity, under a shared "Comparison" header.
- Preserve existing emit modes (`compact`, `md`, `json`, `context`). In `json` emit, return a `{"entities": [...], "reports": [...]}` shape; single-Report consumers remain unaffected because the single-report render path is untouched.
**Patterns to follow:**
- `scripts/lib/render.py:333-392` (`_parse_comparison_entities`, `_render_comparison_scaffold`) — the scaffold is the contract.
- `scripts/lib/render.py` single-report rendering — for per-entity narrative blocks.
**Test scenarios:**
- Happy path: 3 Reports with distinct clusters render into a 3-column table and a "Comparison" section that mentions each entity at least once.
- Happy path: 2 Reports render as a 2-column table without breaking the scaffold.
- Edge case: a Report with an empty cluster list renders as "(no significant discussion this month)" in its column rather than crashing.
- Edge case: Reports with overlapping URLs (same article cited by two entities) dedupe citations at the footer but keep both column entries.
- Emit variants: `--emit=compact`, `--emit=md`, `--emit=json`, `--emit=context` each produce valid output with all entities represented.
- Integration: end-to-end snapshot test using fixture Reports, checked against a stored expected output (with a clear update path when the scaffold intentionally evolves).
**Verification:**
- Snapshot tests pass. Manual review of one real 3-way comparison confirms readability.
- [ ] **Unit 5: Docs, SKILL.md mention, and sync**
**Goal:** Document the new flag so the hosting agent and human users both know it exists, and run the sync script.
**Requirements:** R1-R8 (surfaces them to users)
**Dependencies:** Units 1-4
**Files:**
- Modify: `SKILL.md`
- Modify: `README.md` (brief flag reference)
- Modify: `CHANGELOG.md`
- Run: `bash scripts/sync.sh`
**Approach:**
- Add a compact "Competitor mode" subsection under the existing comparison docs in `SKILL.md`. Document the flag, the default count, the override flag, and the LAW 7 fallback stderr.
- Keep `README.md` addition to a single example line.
- CHANGELOG entry mirrors the voice of recent entries (imperative, outcome-first).
- Sync via `scripts/sync.sh` per CLAUDE.md rules so `~/.claude/`, `~/.agents/`, `~/.codex/` pick up the new SKILL.md.
**Test scenarios:**
- Test expectation: none — documentation and sync only. Verification is by inspection and by running `sync.sh` and confirming target directories updated.
**Verification:**
- `sync.sh` completes without errors.
- `SKILL.md` rendered preview mentions `--competitors` in the comparison section.
## System-Wide Impact
- **Interaction graph:** `last30days.py main()` now orchestrates multiple `pipeline.run()` calls instead of one. No other callers of `pipeline.run()` are affected (it remains single-entity).
- **Error propagation:** Per-entity failures degrade gracefully as long as ≥2 entities survive; fewer survivors exits non-zero. Discovery failure with `--competitors` and no list is fatal.
- **State lifecycle risks:** Each sub-run uses its own `pipeline.run()` state; no shared mutable config. The `config` dict is read-only in `pipeline.run()` today — verify before committing to shared-reference passing, else deep-copy per sub-run.
- **API surface parity:** `--competitors` coexists with the existing explicit "A vs B vs C" topic parsing in `planner._comparison_entities()`. Both produce comparable output formats; the only difference is where the entity list came from.
- **Integration coverage:** The fan-out orchestrator crosses CLI → discovery → N pipelines → render; integration tests in Unit 3 and Unit 4 must exercise the full path end to end, not just unit-level.
- **Unchanged invariants:** `pipeline.run()` signature and single-entity semantics are unchanged. The single-entity render path in `render.py` is unchanged. No changes to `planner.plan_query()`. No changes to existing flags.
## Risks & Dependencies
| Risk | Mitigation |
|------|------------|
| Competitor discovery returns garbage entities for niche topics. | `--competitors-list` override lets the user (or hosting agent) correct it. Unit tests with edge-case fixtures. Log discovery output to stderr under `--debug`. |
| Token cost scales linearly with N sub-runs. | Default count capped at 3, hard max 6, inherit `--quick` to let users throttle. Wall clock stays parallel. Emit a cost hint to stderr when N ≥ 4. |
| Merge conflicts against the single-entity render path during refactoring. | Keep the multi-report renderer strictly additive; do not modify the single-Report code path. |
| Config dict mutation inside sub-runs could leak state between entities. | Verify read-only usage before sharing references. If any sub-component mutates, deep-copy per sub-run before spawning threads. |
| A SERP extractor that works on Brave fixtures breaks on Exa/Serper result shapes. | Test fixtures for all three backends. Extractor operates on a normalized shape from `grounding.web_search()` (already the case), not raw provider output. |
| Hosting agent (Claude Code, Codex) unaware of the new flag when it could usefully pass `--competitors-list`. | SKILL.md updated in Unit 5 documents the flag in the same style as `--plan` and `--auto-resolve`. |
## Documentation / Operational Notes
- Beta channel first: per `CLAUDE.md`, experimental changes go to `mvanhorn/last30days-skill-private` on the `/last30days-beta` command. Land this on the private repo first, shake out on real topics for a day or two, then cherry-pick to public.
- After land-merge: run `scripts/sync.sh` to deploy SKILL.md + scripts to `~/.claude/`, `~/.agents/`, `~/.codex/`.
- Release notes entry in CHANGELOG.md follows the v3.0.9 voice — outcome-first, one paragraph.
## Sources & References
- Related code: `scripts/lib/resolve.py:179` (`auto_resolve`), `scripts/lib/pipeline.py:162` (`pipeline.run`), `scripts/lib/planner.py:80` (`plan_query` LAW 7 fallback), `scripts/lib/render.py:333` (comparison scaffold)
- Related PRs: #305 (Step 0.55 category-peer subreddit expansion — the precedent for deterministic peer expansion, merged 2026-04-22)
- Related plan: `docs/plans/2026-04-22-001-fix-category-peer-subreddit-resolution-plan.md`
@@ -1,349 +0,0 @@
---
title: "fix: per-entity resolution, default-2, and stale-path guard for --competitors"
type: fix
status: active
date: 2026-04-22
origin: docs/plans/2026-04-22-002-feat-competitors-flag-comparison-fanout-plan.md
---
# fix: per-entity resolution, default-2, and stale-path guard for --competitors
## Overview
Three test runs of v3.0.11 `--competitors` surfaced four real bugs plus one product tweak. This plan fixes all of them in a single follow-up:
1. Competitor sub-runs get no Step 0.55 resolution (no X handle, no subreddits, no GitHub repo). Drake / Kendrick / Travis ran with deterministic-fallback single-word queries while Kanye had the full targeting package. User called it "lazy" and was right.
2. Two of three test windows (Linear, Coinbase) never invoked the new flag at all. They loaded SKILL.md from `plugins/marketplaces/last30days-skill/` (a Claude-Code-managed git clone pinned to origin/main, which predates PR #308) instead of `plugins/cache/last30days-skill/last30days/3.0.11/`, so `--help` showed no `--competitors` flag and the model fell back to the manual comparison path.
3. Each competitor sub-run emits a scary `[Planner] No --plan passed... deterministic fallback` stderr line because LAW 7 targets the hosting-model path, not internal fan-out sub-runs.
4. Default competitor count is 3 (→ 4-way comparison). User wants default 2 (→ 3-way: original + 2 peers). Flag keeps `--competitors=N` to customize.
## Problem Frame
The 3 test runs (Kanye, Linear, Coinbase) showed a pattern:
| Window | Loaded SKILL.md from | Invoked --competitors? | Per-entity resolution? | Outcome |
|--------|----------------------|-----------------------|------------------------|---------|
| Kanye | cache/3.0.11/ (correct) | Yes | Only for main topic (Kanye) | Drake/Kendrick/Travis thin; Reddit 403 fallbacks |
| Linear | marketplaces/ (stale) | No — fell back to manual comparison | No | Thin run with noisy subreddits |
| Coinbase | marketplaces/ (stale) | No — fell back to manual comparison | Main only; keyword-search poisoned pool | Top subs: r/survivor, r/Airpodsmax (noise) |
Root causes:
- **Per-entity resolution gap:** `scripts/lib/fanout.py` calls `pipeline.run()` with topic + depth + web_backend + lookback_days only. It does not call `resolve.auto_resolve()` per entity, so sub-runs have no X handle, subreddit, or GitHub targeting. The original plan (`2026-04-22-002`) acknowledged this as a deliberate v1 simplification ("competitor sub-runs use planner defaults"). In practice this produces visibly asymmetric output and triggers downstream retrieval issues (403 fallbacks, keyword-search noise).
- **Stale-path loading:** Claude Code's skill loader alphabetizes `find` results with `marketplaces/` before `cache/`, and the model reads the first plausible SKILL.md it sees. SKILL.md line 823's `SKILL_ROOT` resolver is the correct path but only fires in engine-invocation blocks, not in the skill-load step.
- **LAW 7 in sub-runs:** LAW 7 exists because the *hosting reasoning model* is supposed to pass `--plan`. For competitor sub-runs, there is no hosting-model planning — it's an engine-internal fan-out. The warning is a false positive there.
## Requirements Trace
- R1. Default `--competitors` count is 2 peers (3-way comparison: original + 2).
- R2. Each competitor sub-run performs Step 0.55 resolution (X handle, subreddits, GitHub user/repos, news context) before its pipeline runs — not just the main topic.
- R3. Sub-runs do not emit the LAW 7 `No --plan passed` warning; they are internal fan-out, not hosting-model calls.
- R4. The rendered comparison output includes a visible "Resolved entities" block showing per-entity handles/subs/github for debug transparency (answers "did it resolve everyone?" without the user having to read stderr).
- R5. SKILL.md has a canonical-path self-check at the top: if the reader loaded it from anywhere other than `plugins/cache/last30days-skill/last30days/{VERSION}/`, re-read from the versioned path before proceeding.
- R6. Version bumps to 3.0.12; CHANGELOG entry; `scripts/sync.sh` deploys.
## Scope Boundaries
- No new discovery strategy. The web-search + regex extraction in `scripts/lib/competitors.py` stays as-is.
- No new CLI flags beyond the behavior changes above. Specifically: no per-entity override flags like `--competitor-handles`. The hosting-model escape hatch remains `--competitors-list`.
- No changes to the explicit `A vs B` comparison path (topic-string parsing in `planner._comparison_entities`).
- No marketplace-clone auto-restore fix — that's Claude Code harness behavior. This plan only guards against the symptom on the skill side.
### Deferred to Separate Tasks
- Caching of per-entity resolution results: separate follow-up once hit rate justifies it.
- Fan-out rate-limiting tuning (currently `max_workers=len(entities)+1`, capped at 6): defer until we see real-world quota exhaustion.
- Pre-flight cost hint when N ≥ 4 (noted in `2026-04-22-002` risks): defer.
## Context & Research
### Relevant Code and Patterns
- `scripts/last30days.py:205-219``--competitors` / `--competitors-list` argparse definition (const=3 today; changing to 2).
- `scripts/last30days.py:220-290``resolve_competitors_args()` validator; update `COMPETITORS_DEFAULT`.
- `scripts/last30days.py:438-520` — main() fan-out orchestration; currently passes only topic/depth to each `_competitor_runner`.
- `scripts/lib/fanout.py:40-95``run_competitor_fanout()` signature. The `competitor_runner` callable is where per-entity resolution needs to happen.
- `scripts/lib/resolve.py:179-258``auto_resolve()` is the exact per-entity resolver to reuse. Already does X handle + subreddits + GitHub user/repos + news context in parallel via ThreadPoolExecutor.
- `scripts/lib/planner.py:80-135``plan_query()` emits the LAW 7 stderr. A `quiet: bool` keyword or `internal_subrun: bool` flag will suppress it.
- `scripts/lib/pipeline.py:162-220``pipeline.run()` signature. Needs a new keyword to propagate quiet-mode down to the planner.
- `scripts/lib/render.py:render_comparison_multi` — where the "Resolved entities" block is inserted.
- `SKILL.md` line 823 — canonical `SKILL_ROOT` resolver already exists but fires in engine bash, not at skill-load time.
### Institutional Learnings
- `docs/plans/2026-04-22-002-feat-competitors-flag-comparison-fanout-plan.md` acknowledged the per-entity-resolution gap as a v1 tradeoff. This plan closes that gap.
- Kanye run stderr: `[Planner] No --plan passed... deterministic fallback` × 3 (once per competitor sub-run). That's the LAW 7 noise R3 targets.
- Linear / Coinbase runs loaded `plugins/marketplaces/last30days-skill/CLAUDE.md` as the first hit. That's the stale-path issue R5 targets.
### External References
- None. All patterns are in-repo.
## Key Technical Decisions
- **Per-entity resolve happens inside fanout, not in SKILL.md.** The user-facing promise of `--competitors` is "one flag, engine does the work." Pushing resolution onto the hosting model creates another path-of-least-resistance trap (model skips it, output looks lazy). Auto-resolve inside each sub-run when a web backend is available makes the feature self-contained.
- **Stale-path guard is a SKILL.md self-check, not a code change.** We cannot stop Claude Code from auto-restoring the marketplace clone. But we can put a 3-line banner at the top of SKILL.md that forces any path-mismatched read to re-read from the versioned cache. Both the marketplace copy (once main catches up) and the cache copy carry the guard.
- **LAW 7 suppression is opt-in via `internal_subrun=True` keyword.** Do not remove the warning from the default path — it's load-bearing for the hosting-model contract. Add an explicit bypass for engine-internal fan-out only.
- **Default 2, hard max 6 unchanged.** "Original + 2" matches the Kanye/Drake/Kendrick mental model from the feature description. Still allow `--competitors=N` from 1 to 6.
- **Resolved block is inside the EVIDENCE envelope, not above it.** Keeps the rendered output structure stable for the synthesis contract (LAW 18). The block is context, not output.
- **Skip auto-resolve when `--mock` or no web backend.** Mirrors the existing `resolve.auto_resolve()` fast-fail and keeps the mock test path deterministic.
## Open Questions
### Resolved During Planning
- **Where does per-entity resolve live?** Inside `fanout.run_competitor_fanout`, not in `main()`. Each sub-run calls `auto_resolve()` just before `pipeline.run()`.
- **Should the hosting model still be able to override?** Yes — `--competitors-list` remains the escape hatch. When an explicit list is passed, the engine still does auto-resolve per entity; the user's list just skips discovery.
- **Should sub-runs run auto-resolve in parallel with each other?** Yes. The existing `ThreadPoolExecutor` in fanout already parallelizes sub-runs; auto-resolve happens inside each sub-run's thread, so resolve calls for different entities run concurrently.
- **Default count:** 2 peers (3-way). Confirmed.
### Deferred to Implementation
- Whether to expose a `--no-auto-resolve-competitors` flag for power users who want the fast, shallow behavior. Probably not needed v2; ship auto-resolve always-on and revisit if someone complains about cost.
- Whether to surface the per-entity resolution context back into the main topic's planner (cross-entity context sharing). Stays deferred.
- Whether the Resolved block should be collapsible or always inline. Start inline; revisit based on output length feedback.
## Implementation Units
- [ ] **Unit 1: Default `--competitors` to 2 peers**
**Goal:** Change the bare `--competitors` default from 3 to 2 per user feedback. `--competitors=N` still overrides; range 1..6 unchanged.
**Requirements:** R1
**Dependencies:** None
**Files:**
- Modify: `scripts/last30days.py` (`COMPETITORS_DEFAULT`, `--competitors` const, stderr messages if any reference 3)
- Modify: `SKILL.md` Competitor mode section ("discovered 2-6" wording, bare-flag default line)
- Modify: `README.md` auto-discovered example line (if it references count)
- Test: `tests/test_cli_competitors.py`
**Approach:**
- Change `COMPETITORS_DEFAULT = 3``2` in `scripts/last30days.py`.
- Change argparse `--competitors` `const=3``const=2`.
- Update any SKILL.md / README copy referencing "3 peers" to "2 peers" (default) or "2-6 peers" (range).
**Patterns to follow:**
- Existing default constants in `scripts/last30days.py` argparse block.
**Test scenarios:**
- Happy path: bare `--competitors` yields count=2, enabled=True, empty explicit_list.
- Edge case: `--competitors=3` still works (explicit override).
- Edge case: existing `test_bare_flag_defaults_to_three` test is updated to `test_bare_flag_defaults_to_two` and asserts count=2.
- Edge case: `--competitors=5` with a `--competitors-list` of length 2 still logs the mismatch warning and uses the list.
**Verification:**
- `pytest tests/test_cli_competitors.py -v` passes with the updated default.
- [ ] **Unit 2: Per-entity Step 0.55 resolution inside fanout**
**Goal:** Each competitor sub-run auto-resolves its own X handle, subreddits, GitHub user/repos, and news context via `resolve.auto_resolve()` before its `pipeline.run()` call — just like the main topic.
**Requirements:** R2
**Dependencies:** None (but Unit 3 should land together so sub-runs don't emit LAW 7 stderr while the resolution context is being passed)
**Files:**
- Modify: `scripts/lib/fanout.py`
- Modify: `scripts/last30days.py` (`_competitor_runner` closure builds the resolved args)
- Test: `tests/test_competitor_fanout.py`
- Test: `tests/test_competitors_resolve_integration.py` (new; covers the auto-resolve path)
**Approach:**
- `_competitor_runner(entity)` in main() does:
1. Call `resolve.auto_resolve(entity, config)` when `not args.mock` and a web backend is configured (reuse `_has_backend`).
2. Extract resolved x_handle, subreddits, github_user, github_repos, context.
3. Pass them to `pipeline.run()` for that sub-run.
4. Inject resolved context into a per-entity config copy (so `_auto_resolve_context` does not leak across sub-runs — deep-copy the config or use a local dict).
5. Store the resolved block on the Report's `artifacts` so the renderer can surface it (Unit 4).
- When `args.mock` is True or no backend is available, skip auto-resolve (fall through to planner defaults, matching the existing `auto_resolve()` early-return contract).
- Update `fanout.run_competitor_fanout` docstring to note that auto-resolve happens inside the caller-provided runner.
**Execution note:** Start with a failing integration test that exercises two-entity fanout + auto-resolve via a mocked `resolve.auto_resolve` and asserts that `pipeline.run` receives the resolved x_handle/subreddits for each entity.
**Patterns to follow:**
- `scripts/last30days.py` main topic branch (`if args.auto_resolve and not external_plan`) already calls `resolve.auto_resolve` and propagates results — mirror the shape for competitors.
- Config isolation: `scripts/lib/pipeline.py:162-220` reads config as-is; use `dict(config)` to avoid cross-sub-run mutation of `_auto_resolve_context`.
**Test scenarios:**
- Happy path: 3 entities, mocked `auto_resolve` returns distinct handles per entity; `pipeline.run` receives `x_handle=@drake` for Drake, `x_handle=@kendricklamar` for Kendrick, etc.
- Happy path: the main topic still uses the user-supplied `--x-handle` / `--subreddits` overrides (not overwritten by auto-resolve for the main). Competitors use their own auto-resolved values.
- Edge case: `--mock` skips auto-resolve entirely for all sub-runs (no `resolve.auto_resolve` calls).
- Edge case: `resolve.auto_resolve` returns empty dicts for one entity (low-signal topic) — the sub-run still executes with planner defaults; doesn't crash.
- Edge case: no web backend configured — auto-resolve returns empty for every entity, sub-runs fall through to planner defaults, no stack trace.
- Error path: `resolve.auto_resolve` raises — the sub-run logs a warning and continues with planner defaults (does not fail the whole comparison).
- Integration: config `_auto_resolve_context` from entity A does not leak into entity B's `pipeline.run`. Assert each sub-run gets its own context string.
**Verification:**
- New integration test passes.
- End-to-end smoke (mock mode + explicit list): each sub-run's stderr shows `[AutoResolve]` lines per entity with distinct values.
- [ ] **Unit 3: Suppress LAW 7 warning for engine-internal sub-runs**
**Goal:** The `[Planner] No --plan passed... deterministic fallback` warning does not fire during competitor sub-runs. LAW 7 is load-bearing for hosting-model contracts and must stay on the default path; this is an opt-in bypass for internal fan-out only.
**Requirements:** R3
**Dependencies:** Unit 2 (so the sub-run call site is already being modified)
**Files:**
- Modify: `scripts/lib/planner.py` (`plan_query` signature + conditional stderr)
- Modify: `scripts/lib/pipeline.py` (`run` signature + propagation)
- Modify: `scripts/last30days.py` or `scripts/lib/fanout.py` (pass `internal_subrun=True` for competitor runners)
- Test: `tests/test_planner_v3.py` (or new `tests/test_planner_quiet_mode.py`)
- Test: `tests/test_competitor_fanout.py` (assert sub-runs don't emit LAW 7 stderr)
**Approach:**
- Add a keyword `internal_subrun: bool = False` to `planner.plan_query`. When True, skip the two `print(..., file=sys.stderr)` blocks that emit the LAW 7 banner and the `[Planner] No --plan passed` capability message.
- Add the same keyword to `pipeline.run()`; pass through to `plan_query`.
- In main()/fanout, set `internal_subrun=True` for every competitor sub-run's pipeline.run call. The main topic's pipeline.run keeps the default (LAW 7 stays on for the hosting-model path).
- Also suppress the LAW 7-triggered degraded-run warning block in the render layer for sub-reports when the envelope is going to be merged into a comparison output (or accept that the block is per-entity and surfaces once per entity).
**Patterns to follow:**
- Existing keyword-only parameters on `pipeline.run` (`mock`, `x_handle`, etc.).
- `planner.plan_query` signature is already keyword-only.
**Test scenarios:**
- Happy path: `plan_query(..., internal_subrun=True, provider=None, model=None)` returns the deterministic fallback plan WITHOUT writing the LAW 7 stderr block.
- Happy path: `plan_query(...)` with default `internal_subrun=False` still writes the LAW 7 warning (unchanged behavior).
- Integration: end-to-end competitor fanout; assert captured stderr contains zero occurrences of `No --plan passed` and zero of `YOU ARE the planner`.
- Integration: main topic is not part of competitor mode; if the user invokes bare `/last30days OpenAI` without `--plan`, LAW 7 stderr fires exactly once (regression test).
**Verification:**
- Running the Kanye-style smoke test shows zero `[Planner] No --plan passed` lines for Drake / Kendrick / Travis sub-runs.
- [ ] **Unit 4: "Resolved entities" block in comparison output**
**Goal:** The rendered comparison output includes a visible block listing per-entity handles, subreddits, GitHub user, and resolved context. Answers "did it resolve everyone?" at a glance without reading stderr.
**Requirements:** R4
**Dependencies:** Unit 2 (needs resolved data on report artifacts)
**Files:**
- Modify: `scripts/lib/render.py` (`render_comparison_multi` and `render_comparison_multi_context`)
- Test: `tests/test_render_comparison_multi.py`
**Approach:**
- When each entity's `Report.artifacts` contains a `resolved` dict (populated by Unit 2), `render_comparison_multi` emits a `## Resolved Entities` block early in the EVIDENCE envelope:
```
## Resolved Entities
- **Kanye West**: X @kanyewest | Subs r/Kanye, r/hiphopheads | GitHub: — | Context: BULLY released, UK ban…
- **Drake**: X @Drake | Subs r/DrakeTheType, r/hiphopheads | GitHub: — | Context: ICEMAN rollout…
- **Kendrick Lamar**: X @kendricklamar | Subs r/KendrickLamar | GitHub: — | Context: Grammy wins, dormant…
```
- Missing fields render as `` not empty.
- When no entity has a `resolved` payload (mock mode, no web backend), omit the block entirely rather than emit an empty section.
- Context strings are truncated at 120 chars to keep the block scannable.
**Patterns to follow:**
- Existing `render_comparison_multi` envelope structure (lines ~395-480 in render.py).
- Existing per-entity evidence block format (`## {label}`) for consistency.
**Test scenarios:**
- Happy path: 3 entities each with a `resolved` artifact → block lists all 3 with their fields.
- Happy path: 2 entities, one with full resolution, one with partial (x_handle only) → missing fields render as ``.
- Edge case: no entity has a resolved artifact → block is omitted entirely.
- Edge case: context string > 120 chars → truncated with ellipsis.
- Integration: rendered output passes through the same EVIDENCE envelope comments and synthesis contract (LAW 18 unchanged).
**Verification:**
- Snapshot tests confirm the block appears in the right spot with the right formatting.
- End-to-end smoke shows a realistic 3-entity Resolved block in the rendered output.
- [ ] **Unit 5: SKILL.md canonical-path self-check**
**Goal:** A top-of-file SKILL.md directive forces any reader (Claude Code, Codex, Hermes, Gemini) to verify they loaded from `plugins/cache/last30days-skill/last30days/{VERSION}/SKILL.md` before proceeding. If loaded from `marketplaces/` or any other path, re-read from the pinned versioned cache.
**Requirements:** R5
**Dependencies:** None
**Files:**
- Modify: `SKILL.md` (prepend a STEP 0 block before the existing STEP 0 / LAW list)
**Approach:**
- Add a numbered first step at the top (before or bundled with existing "STEP 0: ToolSearch preload"):
```
## STEP 0: Canonical Path Self-Check (must run first)
Before reading anything else below, verify you loaded this SKILL.md from
the versioned cache, not the marketplace clone:
CANONICAL=$HOME/.claude/plugins/cache/last30days-skill/last30days/
CANONICAL_LATEST=$(ls -d "$CANONICAL"*/ 2>/dev/null | sort -V | tail -1)
If the SKILL.md you just read is not under $CANONICAL_LATEST, STOP. Re-read
$CANONICAL_LATEST/SKILL.md and restart from here. Marketplace clones
(`plugins/marketplaces/last30days-skill/`) are pinned to origin/main and
can be stale; the versioned cache is the ground truth.
```
- Reinforce in the existing LAW 7 block that `--help` output must be read from the same pinned `SKILL_ROOT` to avoid flag-list skew.
**Patterns to follow:**
- Existing STEP 0 ToolSearch preload (top of SKILL.md) for tone / imperative voice.
- Existing `SKILL_ROOT` resolver snippet (line ~823).
**Test scenarios:**
- Test expectation: none — SKILL.md is documentation; no unit test, verified by follow-up user invocation.
**Verification:**
- In a fresh Claude Code window, `/last30days Test --competitors` loads SKILL.md, the model executes the STEP 0 self-check, and (if it had loaded from marketplaces/) switches to the cache path before running `--help` or the engine. Observable via the model's announced reasoning / task list.
- [ ] **Unit 6: Version bump, CHANGELOG, sync**
**Goal:** Ship 3.0.12 and deploy to all local targets.
**Requirements:** R6
**Dependencies:** Units 1-5
**Files:**
- Modify: `.claude-plugin/plugin.json` (version 3.0.11 → 3.0.12)
- Modify: `CHANGELOG.md`
- Run: `bash scripts/sync.sh`
**Approach:**
- CHANGELOG entry under `## [3.0.12]` dated 2026-04-22 covering the four fixes (Fixed: per-entity resolution; Fixed: LAW 7 sub-run noise; Changed: default count 3→2; Added: Resolved entities block; Added: canonical-path self-check in SKILL.md).
- `sync.sh` deploys to `~/.claude/plugins/cache/last30days-skill-private/...`, `~/.agents/`, `~/.codex/`, Hermes.
- Manual hot-copy to `~/.claude/plugins/cache/last30days-skill/last30days/3.0.12/` so the public `/last30days` slash command picks up the new version before PR merge (matches the 3.0.11 testing pattern).
**Test scenarios:**
- Test expectation: none — packaging only. Verification is by inspection.
**Verification:**
- `grep version .claude-plugin/plugin.json` returns `3.0.12`.
- `sync.sh` exits 0 with "Import check: OK" for each target.
- Hot-copied 3.0.12 directory contains the new files and `/last30days` picks up the new version (highest-version resolver).
## System-Wide Impact
- **Interaction graph:** Fanout sub-runs now call `resolve.auto_resolve` per entity. Each sub-run is independent; no shared mutable state with other sub-runs or with the main topic.
- **Error propagation:** `auto_resolve` failures inside a sub-run log a warning and degrade to planner defaults; do not propagate up to abort the comparison. Same contract as today for the main topic.
- **State lifecycle risks:** Config dict is mutated by `auto_resolve` (via `config["_auto_resolve_context"]`). Must deep-copy per sub-run or scope context to a local mapping — otherwise two sub-runs' context strings race.
- **API surface parity:** `pipeline.run` gains a keyword (`internal_subrun`); callers that don't pass it get the existing behavior. `planner.plan_query` gains the same. Backward compatible.
- **Integration coverage:** New integration test for the fanout + auto-resolve + render chain. Existing snapshot tests update to include the Resolved block.
- **Unchanged invariants:** Single-entity `/last30days` invocations (no `--competitors`) behave identically. Explicit `A vs B` comparison topics behave identically. LAW 7 still fires on the default hosting-model path. `render_compact` path is untouched.
## Risks & Dependencies
| Risk | Mitigation |
|------|------------|
| Auto-resolving per competitor triples the WebSearch call volume (4 queries × 3 competitors = 12 extra web searches). | Fast-fail when no backend; user can pass `--competitors-list` to skip discovery but still get auto-resolve. Cost note in CHANGELOG. |
| Config mutation across sub-runs via `_auto_resolve_context`. | Unit 2 deep-copies config per sub-run before each `auto_resolve` + `pipeline.run` call. Integration test asserts no cross-entity leak. |
| LAW 7 suppression leaks onto the hosting-model path via a wrong default. | Default `internal_subrun=False`. Only fanout's competitor sub-runs set True. Unit test asserts bare-topic invocation still emits LAW 7. |
| SKILL.md STEP 0 banner gets ignored by the model (same failure mode as line 823 today). | Put it in the guaranteed-read top band (before LAW 1, above all other content), imperative voice, concrete `STOP` verb. Still not bulletproof but strictly better than current. |
| Default count change breaks assumptions in downstream tools or existing user muscle memory. | Changelog calls it out as Changed; `--competitors=3` still works for users who want the old default. |
## Documentation / Operational Notes
- Beta channel first: merge behind `/last30days-beta` via the private repo before cherry-picking to public. Follows the same process as 3.0.11.
- Version 3.0.12 is a fix release; no marketing post required.
- After merge, add a line to the PR description pointing at this plan.
## Sources & References
- Origin plan: `docs/plans/2026-04-22-002-feat-competitors-flag-comparison-fanout-plan.md`
- Related PR: #308 (v3.0.11 shipping --competitors)
- Test windows that surfaced the bugs: Kanye, Linear, Coinbase (2026-04-22 session)
- Related code: `scripts/lib/fanout.py`, `scripts/lib/resolve.py` (`auto_resolve`), `scripts/lib/planner.py` (`plan_query`), `scripts/lib/render.py` (`render_comparison_multi`)
@@ -1,394 +0,0 @@
---
title: "fix: --competitors runs a full last30days per entity with hosting-model pre-resolve"
type: fix
status: active
date: 2026-04-22
origin: docs/plans/2026-04-22-003-fix-competitors-per-entity-resolution-plan.md
---
# fix: --competitors runs a full last30days per entity with hosting-model pre-resolve
## Overview
User intent confirmed 2026-04-22: `--competitors` should run a full single-entity `last30days` pipeline for the main topic AND for each discovered peer — three independent full-depth passes, each with its own Step 0.55 resolution, own X handle primary weight, own subreddit targeting, own GitHub repo scoping. Then merge them into the comparison output.
3.0.12 already built the N-parallel-pipelines orchestration (`scripts/lib/fanout.py`). What it got wrong: it tried to do per-entity Step 0.55 engine-side via `resolve.auto_resolve()`, which requires a web search backend key (BRAVE/EXA/SERPER/PARALLEL/OPENROUTER). Matt runs from Claude Code, which has its own WebSearch tool. The engine has none of those keys, so per-entity auto_resolve silently no-ops and all peer sub-runs fall through to deterministic single-word planner queries.
Four 2026-04-22 test runs (Warriors, Seattle, Arizona Wildcats, Kanye West) confirmed this via engine receipts:
- Compact Resolved Entities block shows peers as `X - | Subs - | GitHub - | Context: -`.
- Sub-run planner lines show `source=deterministic, subqueries=1` — the "I gave up and keyword-searched" shape.
- Engine footer keeps nudging `💡 You can unlock native grounded web search with BRAVE_API_KEY or SERPER_API_KEY`, which is wrong advice for a Claude Code user who already has WebSearch.
- Kanye run leaked main topic's `--subreddits` into Drake's and Kendrick's sub-runs (regression bug).
The fix is to flip the resolution responsibility: the hosting model (Claude Code, Codex, Hermes, Gemini) does Step 0.55 via its own WebSearch tool for every entity, then passes the resolved targeting to the engine via a new `--competitors-plan` JSON flag. Engine fan-out remains — each peer still runs a full `pipeline.run()`. The difference is the peers now arrive with full targeting, equivalent to the main topic, so retrieval is apples-to-apples.
Why not just reuse vs-mode? vs-mode is a SINGLE `pipeline.run()` with a comparison-optimized plan. It pre-resolves Step 0.55 per entity but merges everything into one retrieval pool with lower-weight `--x-related` for peers, merged subreddits, and cross-entity keyword noise. That is not "three full passes." The user explicitly wants three full passes.
## Problem Frame
3.0.12's architecture was correct; its data dependency was wrong.
| Capability | 3.0.12 path | Target path (this plan) |
|---|---|---|
| Fan out to N parallel pipelines | Yes (`fanout.run_competitor_fanout`) | Same — keep |
| Per-entity Step 0.55 resolution | Engine-internal `resolve.auto_resolve()` — needs BRAVE/EXA/SERPER/PARALLEL key | Hosting model does it via its own WebSearch, passes to engine |
| Per-entity targeting threaded into `pipeline.run()` | Main topic only via outer flags; peers via auto_resolve (failing) or nothing | Main topic via outer flags; peers via `--competitors-plan` JSON |
| Footer nudge | Unconditional BRAVE/SERPER | Suppressed when `--plan` or `--competitors-plan` present |
| Resolved Entities block in raw save file | Stdout only | Also in `--save-dir` raw file |
| Override-leak from main into peers | Present (Kanye receipt) | Fixed via explicit per-entity kwargs scrub |
| Polymarket noise on ambiguous topics | Present (Warriors, Arizona receipts) | `--polymarket-keywords` + auto-skip for single-token-ambiguous |
The key architectural change is who owns per-entity resolution. The engine stops trying to do it itself; the hosting model does it upstream (it already has WebSearch) and passes results in.
This is the same pattern `--plan` already uses for the main topic: hosting model generates the plan via its own reasoning, passes it in, engine accepts. We apply the pattern to peers.
## Requirements Trace
- R1. New `--competitors-plan` JSON flag accepting per-entity targeting: `x_handle`, `x_related`, `subreddits`, `github_user`, `github_repos`, `context`. Implies `--competitors`. Per-entity values thread into that entity's `pipeline.run()`. Bypasses engine-internal `auto_resolve` for covered entities.
- R2. SKILL.md "Competitor mode" rewritten to make the hosting-model path canonical: (a) discover N peers via WebSearch, (b) run Step 0.55 per entity (main + peers) via WebSearch, (c) assemble `--competitors-plan` JSON, (d) invoke engine. Engine-internal auto_resolve remains as headless fallback.
- R3. The LAW 7-style stderr emitted when `--competitors` has no list, no plan, no backend is reframed: leads with "hosting reasoning model, use your WebSearch to run Step 0.55 per entity and pass `--competitors-plan`." Does not lead with BRAVE_API_KEY.
- R4. Footer nudge `💡 You can unlock native grounded web search with BRAVE_API_KEY...` is suppressed when `--plan` OR `--competitors-plan` was passed. Signal: hosting model is driving and already has WebSearch.
- R5. Override-leak fix: competitor sub-runs do not inherit main topic's `--subreddits`, `--x-handle`, `--x-related`, `--tiktok-hashtags`, `--tiktok-creators`, `--ig-creators`, `--github-user`, `--github-repo`. Sub-runs use only their own per-entity targeting (from `--competitors-plan` if provided, else engine-internal auto_resolve if backend, else planner defaults).
- R6. The `## Resolved Entities` block is also appended to the saved raw file when `--save-dir` is in use. Each entity's effective targeting (whatever was actually passed to its `pipeline.run()`) is visible on audit.
- R6b. When `--save-dir` is in use with a comparison run, each entity's sub-run ALSO saves its own standalone raw file — same format as a single-entity run. `/last30days Kanye West --competitors` produces `kanye-west-raw.md`, `drake-raw.md`, `kendrick-lamar-raw.md` (one per entity) plus the merged comparison file. Matches the historical vs-mode behavior when it ran as N passes.
- R7. Polymarket disambiguation: support `--polymarket-keywords "kw1,kw2"` to filter market matches; auto-skip Polymarket when topic is single-token-ambiguous and no override is provided.
- R8. Default `--competitors` count remains 2 (3-way: main + 2 peers). Unchanged from 3.0.12.
## Scope Boundaries
- No changes to `scripts/lib/fanout.py` architecture. N parallel pipelines stays. Only the data each sub-run receives changes.
- No changes to the vs-mode (topic contains "vs" / "versus") behavior. That path is independent.
- No new emit modes. Comparison output format unchanged.
- No deprecation of `--competitors-list`. Stays as the minimum escape hatch for hosting models that skip per-entity Step 0.55 (names-only).
### Deferred to Separate Tasks
- Cache layer for hosting-model competitor resolution: separate plan once cost evidence exists.
- Cross-source disambiguation beyond Polymarket: separate plan.
## Context & Research
### Relevant Code and Patterns
- `scripts/last30days.py` — `--competitors` / `--competitors-list` argparse block, `resolve_competitors_args` validator, `_main_runner` closure, `_competitor_runner` closure, the `[Competitors] --competitors requires...` stderr block. Primary file for this plan.
- `scripts/lib/fanout.py` — `run_competitor_fanout` orchestrator. Signature unchanged; `_competitor_runner` closure now builds kwargs from `--competitors-plan`.
- `scripts/lib/pipeline.py` — `pipeline.run()` signature; no changes required (all per-entity flags already exist as kwargs).
- `scripts/lib/planner.py` — existing `--plan` parsing and validation, pattern to mirror for `--competitors-plan`.
- `scripts/lib/render.py` `_render_resolved_entities_block` (added in 3.0.12) — already reads `report.artifacts["resolved"]`; no change needed.
- `scripts/last30days.py` `save_output` / `render.render_full` — the save path. Needs to include the Resolved Entities block for comparison runs.
- `scripts/lib/quality_nudge.py` — where the BRAVE/SERPER footer nudge is emitted. Needs a context-aware suppression check.
- `scripts/lib/polymarket.py` — source adapter. Entry point for `--polymarket-keywords` filter and single-token-ambiguous auto-skip.
### Institutional Learnings
- 3.0.11 plan (`2026-04-22-002`): built the initial fanout, deferred per-entity resolve as "v1 simplification."
- 3.0.12 plan (`2026-04-22-003`): tried to close the gap via engine-internal `auto_resolve`. Works only with backend keys. Fails silently without.
- 2026-04-22 test session receipts: confirmed all four fixes in this plan are real, reproducible bugs.
- User's architectural steer 2026-04-22: "runs a full last30days on all 3 topics" — this plan encodes that explicitly as N full `pipeline.run()` calls with pre-resolved targeting per entity.
### External References
- None. All patterns in-repo.
## Key Technical Decisions
- **`--competitors-plan` is a single JSON flag, not a fan of separate flags.** Mirrors `--plan`. Stable schema: `{entity_name: {x_handle, x_related, subreddits, github_user, github_repos, context}}`. Accept inline JSON or a file path (matches `--plan`).
- **Hosting-model-driven resolution is the documented default.** Engine-internal `auto_resolve` is the headless / cron fallback. SKILL.md routes hosting models to the JSON-flag path; engine keeps auto_resolve alive for BRAVE/EXA/SERPER users running CI.
- **Override-leak fix is call-site scrubbing, not a signature change.** `_competitor_runner` builds an explicit kwargs dict per entity from `_subrun_kwargs(entity, plan_entry)`. No closure-default fallthrough from main scope. The 3.0.12 `entity_config = dict(config)` deep-copy pattern extends to every per-entity flag.
- **Footer nudge becomes context-aware.** Suppressed when `--plan` or `--competitors-plan` present. Not suppressed for bare `--competitors-list` or bare invocations. Headless cron without keys still sees the nudge.
- **Polymarket disambiguation is additive and conservative.** `--polymarket-keywords` is explicit; auto-skip only fires for a known list of single-token-ambiguous names (states, common nouns). Stderr notes the skip so it is observable and overridable.
- **Per-entity sub-runs get the full `pipeline.run()` pass.** Same depth, same sources, same API cost per entity as a single-topic run. This is the explicit user intent — three full passes, not one merged pass.
## Open Questions
### Resolved During Planning
- **JSON or multi-flag?** JSON. Matches `--plan`.
- **Default count?** 2 peers (3-way comparison). Unchanged from 3.0.12.
- **Does engine-internal auto_resolve stay alive?** Yes, for entities not covered by `--competitors-plan` when a backend is configured. Headless/cron users with keys keep the current 3.0.12 behavior.
- **vs-mode or fanout?** Fanout. User's explicit ask: three full passes, not one merged pass. vs-mode merges into one pipeline with lower peer weighting, which is not what the user wants.
- **Does the save file need per-entity clusters?** Start with the Resolved block appended. Per-entity cluster sections can follow in a separate task; they are nice-to-have, not blocking.
### Deferred to Implementation
- Exact trace of override-leak source. Candidates: closure capture of `subreddits` in `_competitor_runner`, shared `_auto_resolve_context` leak, Reddit adapter inheriting global config. Test-first; trace at implementation time.
- Heuristic for "single-token-ambiguous topic" auto-skip. Start with a short hard-coded list (US state names, US city names, common nouns like "Warriors", "Suns", "Jets"); revisit after dogfood.
- Whether per-entity coverage warnings fire when `--competitors-plan` under-resolves an entity (e.g., only `x_handle`, no subreddits). Start with stderr logging; revisit UX.
## Implementation Units
- [ ] **Unit 1: `--competitors-plan` JSON flag + per-entity kwargs threading**
**Goal:** New CLI flag accepting per-entity targeting JSON. Each covered entity's `pipeline.run()` receives its own `x_handle` / `x_related` / `subreddits` / `github_user` / `github_repos` / `context`. Skips engine-internal `auto_resolve` for covered entities.
**Requirements:** R1, R5 (primary leak fix site)
**Dependencies:** None
**Files:**
- Modify: `scripts/last30days.py` (argparse + parse + `_competitor_runner`)
- Possibly modify: `scripts/lib/fanout.py` (no signature change expected; verify)
- Test: `tests/test_cli_competitors.py` (extend)
- Test: `tests/test_competitors_plan_threading.py` (new)
**Approach:**
- Add `--competitors-plan` argparse flag. Accepts inline JSON OR a file path (mirror `--plan`).
- Validation: parse JSON; must be a dict; each value must be a dict; unknown fields log warnings; malformed input exits 2.
- Schema per entity: optional fields `x_handle` (str), `x_related` (list), `subreddits` (list), `github_user` (str), `github_repos` (list), `context` (str).
- Case-insensitive matching against `--competitors-list` / discovered entities.
- Build `_subrun_kwargs(entity, plan_entry)` helper. Returns a complete, explicit kwargs dict for `pipeline.run()` with no closure-default fallthrough from main scope. This helper is the single source of truth for per-entity call args. It also fixes the override-leak (R5) by scrubbing all per-entity flags to None unless the plan (or auto_resolve) sets them.
- `_competitor_runner(entity)`:
1. Look up `plan_entry` from `--competitors-plan` (if any).
2. If plan covers entity fully, build kwargs from it; skip `auto_resolve`.
3. If plan partially covers or is absent, fall back to `auto_resolve` (3.0.12 behavior) when a backend is configured. Plan values win over auto_resolve values on conflict.
4. If neither plan nor backend, fall through to `pipeline.run()` with per-entity kwargs all None — engine uses planner defaults for that entity only (no leak).
- Deep-copy config per sub-run (already done in 3.0.12); merge per-entity `context` into `entity_config["_auto_resolve_context"]` only.
**Execution note:** Test-first for the override-leak regression (pass `--subreddits=A,B` on main + a peer, assert peer's `pipeline.run(subreddits=...)` is None or peer-specific).
**Patterns to follow:**
- `--plan` parsing at `scripts/last30days.py` (inline JSON or file path).
- 3.0.12's `_competitor_runner` closure for scope; extract the kwargs-build into `_subrun_kwargs` helper.
- `entity_config = dict(config)` deep-copy pattern from 3.0.12.
**Test scenarios:**
- Happy path: `--competitors-plan '{"Drake": {"x_handle":"Drake","subreddits":["Drizzy"]}}'` → Drake's `pipeline.run` receives `x_handle="Drake"` and `subreddits=["Drizzy"]`; no `auto_resolve` call for Drake.
- Happy path: plan covers 2 of 3 entities, backend configured → covered entities skip auto_resolve; third falls back to auto_resolve.
- Happy path: plan file path accepted like `--plan` file path.
- Happy path: case-insensitive entity match (`Drake` in plan, `drake` in list).
- Edge case: unknown fields in plan entry → logged, ignored, run continues.
- Edge case: plan entry for entity not in list → ignored with warning.
- Error path: malformed JSON → exit 2.
- Error path: top-level JSON is list not dict → exit 2.
- Regression (leak fix): main `--subreddits=A,B` + `--competitors-list "Drake"` + no plan → Drake's `pipeline.run` receives `subreddits=None` (no leak).
- Regression (leak fix): same for `--x-handle`, `--x-related`, `--tiktok-*`, `--ig-creators`, `--github-*`.
- Regression (leak fix): main `--x-handle=kanyewest` + plan `{"Drake":{"x_handle":"Drake"}}` → Drake's sub-run gets `x_handle="Drake"`, NOT `"kanyewest"`.
- Integration: full main + 2 peers run via `--competitors-plan`; assert each sub-run's effective kwargs match expected per-entity values.
**Verification:**
- All new and regression tests pass.
- Smoke run (mock mode + `--competitors-plan`): stderr shows `[Competitors] Drake: x=@Drake subs=Drizzy` line per entity; no `[AutoResolve]` calls for plan-covered entities; no leak of main topic's flags.
- [ ] **Unit 2: Reframe LAW 7-style stderr for hosting-model context**
**Goal:** When `--competitors` has no `--competitors-list`, no `--competitors-plan`, and no backend, stderr tells the hosting reasoning model to use its WebSearch tool for Step 0.55 per entity and pass `--competitors-plan`. Stops leading with BRAVE_API_KEY.
**Requirements:** R3
**Dependencies:** Unit 1 (flag must exist)
**Files:**
- Modify: `scripts/last30days.py` (the existing `[Competitors] --competitors requires...` block)
- Test: `tests/test_competitors_no_backend_message.py` (new)
**Approach:**
- Rewrite stderr in this order:
1. "If you are the hosting reasoning model (Claude Code, Codex, Hermes, Gemini, or any agent runtime with a WebSearch tool), YOU should: (a) discover N peers via WebSearch, (b) run Step 0.55 per entity (main + peers), (c) assemble a `--competitors-plan` JSON, (d) re-invoke. Skip this step and quality degrades — peer entities will run with planner defaults."
2. "If you are running headless (cron, CI, no hosting model), set BRAVE_API_KEY / EXA_API_KEY / SERPER_API_KEY / PARALLEL_API_KEY / OPENROUTER_API_KEY and re-run."
3. "Minimum escape hatch: `--competitors-list "A,B,C"` skips discovery but does not pre-resolve peers. Use only for quick tests."
- Exits non-zero as today.
**Patterns to follow:**
- Existing LAW 7 stderr in `planner.plan_query` for tone.
**Test scenarios:**
- Happy path: stderr leads with "If you are the hosting reasoning model" and names `--competitors-plan` before any backend key.
- Happy path: stderr explicitly names `--competitors-plan` as the preferred override.
- Happy path: stderr does NOT say "requires either a configured web search backend OR an explicit --competitors-list" (the current 3.0.12 wording).
**Verification:**
- Test asserts ordering and required phrases.
- [ ] **Unit 3: Suppress BRAVE/SERPER footer nudge when hosting-model-driven**
**Goal:** The `💡 You can unlock native grounded web search with BRAVE_API_KEY or SERPER_API_KEY` footer is suppressed when `--plan` or `--competitors-plan` was passed (signal: hosting model is driving and already has WebSearch).
**Requirements:** R4
**Dependencies:** Unit 1
**Files:**
- Modify: `scripts/lib/quality_nudge.py` (or wherever nudge is emitted; verify during implementation)
- Test: `tests/test_footer_nudge_suppression.py` (new)
**Approach:**
- Locate the nudge emission point.
- Add a suppression check: if `--plan` OR `--competitors-plan` was passed, skip the nudge. Otherwise, current behavior.
- Don't suppress the nudge for bare `--competitors-list` alone — that path isn't necessarily hosting-model-driven.
**Test scenarios:**
- Happy path: `--plan` passed, no backend → nudge does NOT fire.
- Happy path: `--competitors-plan` passed, no backend → nudge does NOT fire.
- Happy path: `--competitors-list` only, no backend → nudge fires (current behavior).
- Happy path: no `--competitors`, no `--plan`, no backend → nudge fires (current behavior unchanged).
**Verification:**
- All four scenarios produce expected nudge presence/absence.
- [ ] **Unit 4: Per-entity save files + Resolved block in each**
**Goal:** When `--save-dir` is in use with a comparison run, each entity's sub-run saves its own standalone raw file (same format as a single-entity run), and each file includes the `## Resolved Entities` block so audits can see what targeting that entity received. Matches the historical vs-mode behavior when it was N passes.
**Requirements:** R6, R6b
**Dependencies:** Unit 1
**Files:**
- Modify: `scripts/last30days.py` (`save_output`, the save loop after fanout completes)
- Possibly modify: `scripts/lib/render.py` (`render_full` branch to include Resolved block when artifact is present)
- Test: `tests/test_save_raw_competitor_files.py` (new)
**Approach:**
- After fanout completes, iterate `report.artifacts["competitor_reports"]`. For each `(entity, entity_report)` tuple, call `save_output(entity_report, emit="md", save_dir=args.save_dir, suffix=args.save_suffix)` — same path a single-entity run takes.
- Each saved file uses its entity's slug as the filename (`drake-raw.md`, `kendrick-lamar-raw.md`). Main topic keeps the existing `kanye-west-raw.md` filename.
- Each file includes its own `## Resolved Entities` block (single-entity variant: one row for that entity only). This makes each sub-run's file self-describing — you can see what targeting was used without opening the comparison file.
- The merged comparison output (stdout) still includes the 3-row Resolved Entities block.
- Optional: also save a comparison summary file (e.g., `kanye-west-comparison-raw.md`) holding the merged multi-entity render. Start with per-entity files only; comparison summary is a follow-up if stdout-plus-individual-files is insufficient.
- Single-entity runs unchanged (no additional files, no block change).
**Patterns to follow:**
- Existing `save_output` invocation for single-entity runs (line 501 of current `scripts/last30days.py`).
- Existing slug generation (`slugify(topic)`) for filename consistency.
- `_render_resolved_entities_block` from 3.0.12 for the single-entity variant.
**Test scenarios:**
- Happy path: `--competitors-list "Drake,Kendrick Lamar"` + `--save-dir=/tmp/x` → `/tmp/x/kanye-west-raw.md`, `/tmp/x/drake-raw.md`, `/tmp/x/kendrick-lamar-raw.md` all exist.
- Happy path: each peer file's first sections include that entity's Resolved Entities block with its own row only.
- Happy path: single-entity run with `--save-dir` → one file, unchanged from today's behavior.
- Edge case: entity slug collides with existing file → overwrite (matches single-entity behavior).
- Edge case: `--save-suffix=v3` → all 3 files get the suffix (`kanye-west-raw-v3.md`, `drake-raw-v3.md`, `kendrick-lamar-raw-v3.md`).
- Edge case: comparison run with one peer whose sub-run failed → that entity's file is NOT saved; others are.
- Integration: stderr after save shows three `[last30days] Saved output to <path>` lines, one per entity.
**Verification:**
- After `/last30days Kanye West --competitors-list "Drake,Kendrick Lamar" --save-dir=/tmp/x`: `ls /tmp/x/*-raw.md` shows 3 files. Each contains its entity's Resolved block.
- [ ] **Unit 5: SKILL.md "Competitor mode" rewrite — hosting-model Step 0.55 canonical**
**Goal:** SKILL.md documents the hosting-model-driven path as canonical: discover N peers via WebSearch, run Step 0.55 per entity, assemble `--competitors-plan`, invoke engine. Engine-internal `auto_resolve` is labeled the headless fallback.
**Requirements:** R2
**Dependencies:** Unit 1 (flag must exist before documented)
**Files:**
- Modify: `SKILL.md` (Competitor mode subsection)
- Modify: `README.md` (one-line example update)
**Approach:**
- Replace the 3.0.12 Competitor mode subsection with a clear flow:
1. User invokes with `--competitors` or `--competitors=N`.
2. Hosting model runs WebSearch for "[topic] competitors" / "[topic] alternatives" → picks top N peers.
3. Hosting model runs Step 0.55 for main + each peer (x_handle, subreddits, github_user, github_repos, context) — same protocol as vs-mode per SKILL.md §679.
4. Hosting model assembles a `--competitors-plan` JSON object.
5. Hosting model invokes the engine with `--competitors-list "A,B,C" --competitors-plan '{...}'`.
6. Engine fans out N full pipelines (main + peers), each with its own full Step 0.55-grade targeting. Each entity also saves its own `*-raw.md` file when `--save-dir` is set (three full passes → three save files, matching the historical vs-mode behavior). Comparison output merges them for display.
- Concrete JSON example in SKILL.md showing the schema.
- Failure-mode warning: a `## Resolved Entities` block with dashes for any entity means hosting model skipped Step 0.55 for that one. Re-run with corrected plan.
- "Headless fallback" sub-subsection: when BRAVE/EXA/SERPER/PARALLEL/OPENROUTER is set, engine's internal `auto_resolve` handles peers and `--competitors-plan` is optional.
**Patterns to follow:**
- SKILL.md "Step 0.55" section for per-entity resolve protocol.
- SKILL.md "If QUERY_TYPE = COMPARISON" section for the same-protocol-as-vs-mode reference.
- Tone of existing 3.0.12 Competitor mode prose.
**Test scenarios:**
- Test expectation: none — documentation. Verification is a fresh Claude Code window dogfood run.
**Verification:**
- `/last30days Kanye West --competitors` in a new window: hosting model does Step 0.55 for Kanye + 2 discovered peers; passes `--competitors-plan`; rendered Resolved block shows non-empty fields for all 3; top voices include at least one peer-specific handle.
- [ ] **Unit 6: Polymarket disambiguation guard**
**Goal:** Support `--polymarket-keywords "kw1,kw2"` to filter market matches; auto-skip Polymarket when topic is single-token-ambiguous and no override is provided.
**Requirements:** R7
**Dependencies:** None
**Files:**
- Modify: `scripts/last30days.py` argparse (`--polymarket-keywords`)
- Modify: `scripts/lib/polymarket.py`
- Test: `tests/test_polymarket_disambiguation.py` (new)
**Approach:**
- Add `--polymarket-keywords "kw1,kw2"` flag. When provided, Polymarket adapter filters market titles to those whose normalized text contains at least one keyword.
- Auto-skip rule: if topic is one token AND token matches a known-ambiguous list (US state names, US city names, common sports/color/animal words) AND no `--polymarket-keywords` provided, skip Polymarket with a stderr note.
- SKILL.md Step 0.55 protocol gets a small addition: for ambiguous topics, hosting model passes `--polymarket-keywords` with topic-specific qualifiers.
**Patterns to follow:**
- Existing Polymarket adapter match logic.
- Single-token detection heuristic.
**Test scenarios:**
- Happy path: topic "Warriors", no override → Polymarket skipped; stderr notes the skip.
- Happy path: topic "Warriors", `--polymarket-keywords "nba,gsw"` → Polymarket runs; matches filtered.
- Happy path: topic "OpenAI" (no ambiguity) → Polymarket runs as before.
- Happy path: topic "Arizona Wildcats" (multi-token) → Polymarket runs as before.
- Edge case: `--polymarket-keywords ""` → treated as empty, no filter.
**Verification:**
- Warriors smoke run → Polymarket footer absent OR filtered to nba/gsw markets.
- [ ] **Unit 7: Version 3.0.13, CHANGELOG, sync, hot-copy**
**Goal:** Ship 3.0.13 to all local targets.
**Requirements:** Closes R1-R7
**Dependencies:** Units 1-6
**Files:**
- Modify: `.claude-plugin/plugin.json`
- Modify: `CHANGELOG.md`
- Run: `bash scripts/sync.sh`
- Hot-copy: `~/.claude/plugins/cache/last30days-skill/last30days/3.0.13/`
**Approach:**
- CHANGELOG entry groups the fixes: Added `--competitors-plan` JSON flag for per-entity hosting-model pre-resolve. Fixed override-leak from main into peer sub-runs. Changed: LAW 7 stderr framing for hosting-model context. Changed: BRAVE/SERPER footer nudge suppressed when `--plan` / `--competitors-plan` is present. Added: Resolved Entities block persists to saved raw file. Added: `--polymarket-keywords` + auto-skip for ambiguous single-token topics.
- Beta channel first per CLAUDE.md.
- Hot-copy so public `/last30days` picks up 3.0.13 immediately.
**Test scenarios:**
- Test expectation: none — packaging.
**Verification:**
- `grep version .claude-plugin/plugin.json` returns 3.0.13.
- `sync.sh` exits 0.
- Hot-copy contains the new files with competitors.py, fanout.py, the updated SKILL.md, and plugin.json 3.0.13.
## System-Wide Impact
- **Interaction graph:** `_competitor_runner` becomes the single source of truth for sub-run kwargs via `_subrun_kwargs(entity, plan_entry)`. Every per-entity flag flows through one helper. No closure-default leaks.
- **Error propagation:** `--competitors-plan` JSON parse errors exit 2 with stderr (same as `--plan`). Per-entity plan entries with malformed values log warnings and fall back; don't abort the whole run.
- **State lifecycle risks:** `entity_config = dict(config)` already deep-copies for `_auto_resolve_context`; extend the isolation discipline to every per-entity flag. Verified in Unit 1 regression tests.
- **API surface parity:** `--competitors-plan` is additive. `--competitors` and `--competitors-list` unchanged. `--plan` unchanged. `--polymarket-keywords` additive.
- **Integration coverage:** New regression tests for override-leak. New integration test for plan-driven sub-run threading. New nudge-suppression test. New Polymarket disambiguation test.
- **Unchanged invariants:** `pipeline.run()` signature unchanged. `planner.plan_query` LAW 7 behavior for the default path unchanged. Single-entity render path unchanged. vs-mode behavior unchanged.
## Risks & Dependencies
| Risk | Mitigation |
|------|------------|
| Hosting model takes the lazy path and uses `--competitors-list` names-only. | Unit 2 stderr explicitly steers to `--competitors-plan` with Step 0.55 protocol named. Unit 5 SKILL.md docs. Resolved Entities dashes in output make the gap visible. |
| JSON gets verbose for the hosting model to construct repeatedly. | Schema is small (≤6 fields per entity). Hosting model already runs Step 0.55 for main topic in every comparison run; peers use the same protocol. One JSON block replaces N CLI flags. |
| Override-leak source is deeper than `_competitor_runner` closure. | Test-first per Unit 1. Receipts from 2026-04-22 Kanye run are reproducible. Trace methodically from call site. |
| Plan-covered entity bypasses auto_resolve but plan data is incomplete (e.g., no subreddits). | Hosting model's own SKILL.md contract says Step 0.55 must cover all fields. Stderr logs per-entity coverage so under-resolved entities are visible. Next-run correction, not engine-side rescue. |
| Polymarket auto-skip false-positives on legitimate ambiguous topics with real markets. | Conservative match (single-token + known list). `--polymarket-keywords` override is explicit and unambiguous. Stderr notes the skip. |
| Footer nudge suppression hides the message from headless users who genuinely need it. | Suppression only fires when `--plan` or `--competitors-plan` is present. Cron / CI runs that pass neither still see the nudge. |
## Documentation / Operational Notes
- Beta channel first per CLAUDE.md (private repo `/last30days-beta`).
- After merge: hot-copy to `~/.claude/plugins/cache/last30days-skill/last30days/3.0.13/`.
- CHANGELOG voice should call this out as the feedback-driven follow-up to 3.0.12. Reader should see "we tried engine-internal resolve in 3.0.12; it needs backend keys we don't have; we moved resolution to the hosting model in 3.0.13."
## Sources & References
- Origin plan (3.0.12): `docs/plans/2026-04-22-003-fix-competitors-per-entity-resolution-plan.md`
- Earlier plan (3.0.11): `docs/plans/2026-04-22-002-feat-competitors-flag-comparison-fanout-plan.md`
- 2026-04-22 test session receipts: Warriors, Seattle, Arizona Wildcats, Kanye West
- SKILL.md §551 "If QUERY_TYPE = COMPARISON" and §679 per-entity Step 0.55 protocol
- Related code: `scripts/lib/fanout.py`, `scripts/last30days.py` `_competitor_runner`, `scripts/lib/render.py` `_render_resolved_entities_block`, `scripts/lib/polymarket.py`, `scripts/lib/quality_nudge.py`
- Related PRs: #308 (3.0.11), #309 (3.0.12)
@@ -1,451 +0,0 @@
---
title: "feat: vs mode runs N full passes and --competitors is vs with auto-discovery"
type: feat
status: active
date: 2026-04-22
origin: docs/plans/2026-04-22-004-fix-competitors-hosting-model-resolve-and-leak-plan.md.superseded
---
# feat: vs mode runs N full passes and --competitors is vs with auto-discovery
## Overview
Architectural unification driven by user correction 2026-04-22: vs mode and `--competitors` are the same thing. A user typing `/last30days OpenAI vs Anthropic vs xAI` should get a full single-entity last30days pass for each of the three entities — three full pipelines, three saved `*-raw.md` files, merged into one comparison output. A user typing `/last30days OpenAI --competitors` should get the same output after the hosting model auto-picks 2 peers; i.e., `--competitors` is a thin shortcut that expands "topic + `--competitors`" into "topic vs peer1 vs peer2" and then runs the unified vs pipeline.
Current state diverges from this:
- **vs mode today**: one `pipeline.run()` with a comparison-optimized plan that merges all entities' targeting into a single retrieval pool. Lower-weight `--x-related` for peers, merged subreddits, cross-entity keyword noise. One saved file.
- **`--competitors` today (3.0.12)**: N parallel `pipeline.run()` calls via `scripts/lib/fanout.py`, but per-entity Step 0.55 depends on an engine-side web backend key Matt doesn't have. Silently degrades to planner defaults for peers. One saved file (main topic only). Override-leak from main into peers.
After this plan:
- **vs mode**: N parallel `pipeline.run()` calls, one per entity, each with its own full Step 0.55-grade targeting, each saving its own `*-raw.md`. Merged into one comparison output.
- **`--competitors`**: SKILL.md shortcut. Hosting model discovers N peers, builds `"topic vs peer1 vs peer2"`, and invokes the same vs pipeline. No separate orchestration path.
- **Same fanout machinery (`scripts/lib/fanout.py`)** serves both. One fix, both behaviors improve.
## Problem Frame
The product insight from 2026-04-22 test runs is simple: the user wants three full last30days reports plus a comparison merge. Not one comparison pass with N-way targeting merged into a single retrieval pool. Not one save file. Not "main gets Step 0.55, peers get planner defaults." Three full passes. Three save files. Merged output.
The historical vs mode did that (it ran as 3 passes, saving 3 files). SKILL.md §551 currently says:
> "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)."
That change was a latency optimization that removed the user-visible behavior the user wants. The fix is to revert the architectural direction: N passes per entity, in parallel rather than serial (parallelism lowers wall-clock to ~1× a single pass, not N×), with per-entity save files.
The 3.0.11 `--competitors` flag already introduced parallel N-pass machinery (`fanout.run_competitor_fanout`). The 3.0.12 follow-up tried to wire per-entity Step 0.55 into it but failed when no web backend was configured. The elegant move: stop maintaining two architectures. vs-mode and `--competitors` both use `fanout.py`. `--competitors` becomes a SKILL.md-level shortcut that discovers 2 peers and hands off to vs-mode.
Four 2026-04-22 test receipts (Warriors, Seattle, Arizona Wildcats, Kanye West) all confirmed the user's pain points:
- Peers thin because they ran without per-entity handle/sub targeting.
- Only one `*-raw.md` per run — no per-entity audit.
- Kanye peers leaked main topic's `--subreddits`.
- Engine footer nudging `BRAVE_API_KEY` to Claude Code users who already have WebSearch.
- Polymarket noise on ambiguous topics (Warriors → Glasgow rugby; Arizona → Diamondbacks).
This plan closes all of them by unifying the architecture and making hosting-model-driven Step 0.55 per entity the canonical path.
## Requirements Trace
- R1. vs mode (any topic containing ` vs ` / ` versus `) runs N full `pipeline.run()` calls in parallel, one per entity. Each sub-run uses its entity's own Step 0.55 targeting (from the hosting model's pre-resolution, passed via a new `--competitors-plan` JSON).
- R2. `--competitors` (and `--competitors=N`) becomes a SKILL.md-level shortcut: the hosting model (a) discovers N peers via WebSearch, (b) runs Step 0.55 per entity (main + peers), (c) rewrites the topic to `"main vs peer1 vs peer2"`, (d) invokes the engine with `--competitors-plan` containing each entity's targeting.
- R3. New `--competitors-plan` JSON flag. Schema: `{entity_name: {x_handle, x_related, subreddits, github_user, github_repos, context}}`. Implies vs mode when present with a single-entity topic. Applies per-entity targeting to each sub-run. Accepts inline JSON or a file path (matches `--plan`).
- R4. Each entity's sub-run saves its own `*-raw.md` file when `--save-dir` is in use. Example: `/last30days "Kanye West vs Drake vs Kendrick Lamar" --save-dir=~/Documents/Last30Days` produces `kanye-west-raw.md`, `drake-raw.md`, `kendrick-lamar-raw.md`. Same filenames a single-entity run of each topic would produce. Matches historical vs-mode behavior.
- R5. Each per-entity saved file includes its own single-row `## Resolved Entities` block so the audit survives. The merged comparison stdout still shows the full 3-row block.
- R6. Override-leak fix: no main-topic flags (`--subreddits`, `--x-handle`, `--x-related`, `--tiktok-*`, `--ig-creators`, `--github-*`) leak into peer sub-runs. Every per-entity kwarg is scrubbed at the sub-run call site.
- R7. LAW 7-style stderr for `--competitors` invocations with no list, no plan, no backend is reframed for hosting-model context: leads with "use your WebSearch to discover peers, resolve Step 0.55 per entity, re-invoke with `topic vs peer1 vs peer2 --competitors-plan '...'`." Does not lead with BRAVE_API_KEY.
- R8. Footer nudge `💡 You can unlock native grounded web search with BRAVE_API_KEY...` is suppressed when `--plan` or `--competitors-plan` was passed.
- R9. Polymarket disambiguation: support `--polymarket-keywords "kw1,kw2"` to filter market matches; auto-skip Polymarket when topic is single-token-ambiguous and no override is provided.
- R10. Default `--competitors` count stays 2 peers (3-way comparison). Unchanged from 3.0.12.
## Scope Boundaries
- No changes to single-entity `pipeline.run()` semantics. Each sub-run in vs mode behaves identically to a bare `/last30days {entity}` invocation.
- No changes to the planner's comparison-intent logic for single-entity-containing topics. The `_should_force_deterministic_plan` shortcut for vs-topics routes to fanout, not to its current single-pipeline path.
- No new emit modes. Comparison output format unchanged.
- No removal of `--competitors-list`. Stays as a minimum escape hatch (names-only, no per-entity targeting) for scripted headless use.
- No removal of engine-internal `resolve.auto_resolve()` in fanout. Remains as headless / cron fallback for users with BRAVE/EXA/SERPER/PARALLEL/OPENROUTER keys. The dominant Claude Code path bypasses it via `--competitors-plan`.
### Deferred to Separate Tasks
- Explicit "head-to-head" rivalry pass in vs-mode (a supplemental subquery like `"A vs B"` that catches rivalry articles missing from pure entity-scoped passes). Start with N independent passes; add a head-to-head supplemental pass if the rivalry-content gap shows up in dogfood.
- Cache layer for hosting-model pre-resolution.
- Cross-source disambiguation (not just Polymarket).
- Latency knob for users who want the old one-pass vs behavior (probably not needed; parallel N-pass is ~1× wall clock).
## Context & Research
### Relevant Code and Patterns
- `scripts/last30days.py` — main(), `_main_runner`, `_competitor_runner`, the competitor enable/discovery branch. Primary file.
- `scripts/lib/fanout.py` — existing orchestrator (3.0.11). Reused as-is; `competitor_runner` closure is where per-entity kwargs apply.
- `scripts/lib/planner.py``_should_force_deterministic_plan` detects vs-topics via regex. Current path synthesizes ONE comparison plan; new path routes to fanout.
- `scripts/lib/render.py``render_comparison_multi` (3.0.12) + `_render_resolved_entities_block`. Both reused. `render_full` needs a per-entity variant when saving sub-run files.
- `scripts/last30days.py` `save_output` — where raw files are written. Needs to iterate per entity when competitor_reports artifact present.
- `scripts/lib/quality_nudge.py` — BRAVE/SERPER nudge emission.
- `scripts/lib/polymarket.py` — source adapter for `--polymarket-keywords` and ambiguous-topic auto-skip.
- SKILL.md §551 "If QUERY_TYPE = COMPARISON" and §679 per-entity Step 0.55 protocol — the hosting-model contract that drives per-entity pre-resolution for both vs mode and `--competitors`.
### Institutional Learnings
- 3.0.11 plan (`2026-04-22-002`): built fanout.
- 3.0.12 plan (`2026-04-22-003`): tried engine-internal per-entity auto_resolve; failed without backend keys.
- 3.0.13 plan draft (`2026-04-22-004-...superseded`): proposed `--competitors-plan` JSON + vs-mode-shortcut path but kept them separate. User's 2026-04-22 correction unifies them.
- 2026-04-22 test receipts: Warriors, Seattle, Arizona Wildcats, Kanye West runs all reproduced the per-entity resolve gap.
- User's architectural steer: "vs mode should work that way too" + "--competitors is just vs mode with auto-discovery." This plan encodes that.
### External References
- None. All patterns in-repo.
## Key Technical Decisions
- **Unify vs-mode and --competitors on one orchestrator.** `fanout.run_competitor_fanout` serves both. vs-mode is "topic contains ' vs '" detection → fanout. `--competitors` is "SKILL.md shortcut → hosting model rewrites topic to vs form → fanout." One code path.
- **Per-entity targeting via `--competitors-plan` JSON.** Schema `{entity_name: {x_handle, x_related, subreddits, github_user, github_repos, context}}`. Mirrors `--plan`. Applies to both vs-mode and `--competitors` paths. Hosting model passes it after running Step 0.55 per entity.
- **N save files, one per entity.** Each sub-run writes a `{entity-slug}-raw.md` file when `--save-dir` is set. Matches historical vs-mode behavior. Single-entity runs unchanged.
- **Revert the "one pass for latency" optimization that removed per-entity passes.** Parallel execution via `ThreadPoolExecutor` means wall-clock is ~max(per-entity-latency), not sum. The old latency concern (13+ minutes for 3 serial passes) does not apply to a parallel fan-out.
- **Override-leak fix at the call site.** `_subrun_kwargs(entity, plan_entry)` helper returns fully explicit per-entity kwargs; no closure-default fallthrough from main scope.
- **LAW 7 stderr reframed, not just updated.** Current message treats BRAVE_API_KEY as the solution. New message treats hosting-model Step 0.55 as the solution, with backend keys listed only as the headless fallback.
- **Polymarket disambiguation is additive and conservative.** `--polymarket-keywords` is explicit; auto-skip only fires for a known-ambiguous single-token list.
## Open Questions
### Resolved During Planning
- **vs mode N passes or single-pass?** N passes. User's architectural correction.
- **Should --competitors still be an engine flag at all?** Yes, kept for headless / cron contexts with backend keys. Dominant Claude Code path is SKILL.md shortcut → vs-mode fanout. Engine flag stays as compatibility surface.
- **`--competitors-plan` JSON or multi-flag?** JSON. Matches `--plan`.
- **Default count?** 2 peers → 3-way comparison. Unchanged.
- **Saved-file naming?** `{entity-slug}-raw.md` per entity, same as single-entity runs would produce.
### Deferred to Implementation
- Exact trace of override-leak path (closure capture vs shared config vs Reddit adapter fallback). Test-first per Unit 2; patch at the right layer.
- Heuristic for single-token-ambiguous Polymarket auto-skip. Start with a short hard-coded list; iterate.
- Whether to include a head-to-head rivalry supplemental pass in vs-mode. Ship N-independent passes first; revisit after dogfood if rivalry content is missing.
- Exact filename convention when the comparison merged output is saved (if saved at all). Not blocking — per-entity files are the primary save artifact.
## High-Level Technical Design
> *This illustrates the intended approach and is directional guidance for review, not implementation specification. The implementing agent should treat it as context, not code to reproduce.*
```
User invokes:
/last30days "OpenAI vs Anthropic vs xAI"
OR
/last30days OpenAI --competitors (hosting model rewrites to vs form)
OR
/last30days OpenAI --competitors-list "Anthropic,xAI"
OR
/last30days "OpenAI vs Anthropic vs xAI" --competitors-plan '{...per-entity...}'
scripts/last30days.py main():
- Detect: topic has " vs " OR --competitors enabled
- If --competitors and no list/plan: emit LAW 7-style stderr with hosting-model instruction
- If --competitors with list or discovery: rewrite topic to vs form, continue
- Parse --competitors-plan JSON, map to entities
fanout.run_competitor_fanout (shared path):
- For each entity (main + peers):
- entity_config = dict(config) [deep copy to prevent leak]
- kwargs = _subrun_kwargs(entity, plan_entry) [explicit; no main-topic leak]
- If plan_entry missing a field AND backend available: auto_resolve() fill
- pipeline.run(topic=entity, **kwargs, internal_subrun=True)
- Parallel ThreadPoolExecutor
- Collect per-entity Reports
- Attach resolved targeting to each Report.artifacts["resolved"]
scripts/last30days.py after fanout:
- If --save-dir: save each entity's Report as {entity-slug}-raw.md
Each file includes its own single-row Resolved Entities block
- emit_comparison_output → render_comparison_multi (merged stdout)
Includes full N-row Resolved Entities block
```
## Implementation Units
- [ ] **Unit 1: vs-topic detection routes to fanout (not single-pipeline)**
**Goal:** A topic containing ` vs ` / ` versus ` triggers `fanout.run_competitor_fanout` with the parsed entities. Each entity runs a full `pipeline.run()`. Replace the current single-pipeline-with-comparison-plan behavior.
**Requirements:** R1
**Dependencies:** None
**Files:**
- Modify: `scripts/last30days.py` (main() — detect vs-topic, route to fanout)
- Modify: `scripts/lib/planner.py` (remove / bypass the `_should_force_deterministic_plan` special case for vs topics; vs topics no longer go through `plan_query` as a single comparison plan)
- Test: `tests/test_vs_mode_fanout.py` (new)
**Approach:**
- Parse the incoming topic: if it contains ` vs ` or ` versus ` (case-insensitive), split into entities (reuse `planner._comparison_entities`-style logic or move that utility into main()).
- When vs-entities are detected, route to the same fanout branch `--competitors` uses today. The entity list comes from the topic string; no discovery step needed.
- Each entity runs `pipeline.run()` with its own plan (either from `--competitors-plan[entity]` or from the engine's per-entity fallback path).
- For back-compat, if the user passes both a vs-topic AND `--plan`, honor `--plan` for the main (first) entity and use per-entity defaults for peers unless `--competitors-plan` is also provided.
**Execution note:** Start with an integration test that runs `"A vs B"` via mock mode and asserts fanout was called with two entities + two pipeline.run calls.
**Patterns to follow:**
- 3.0.11 fanout wiring in `scripts/last30days.py`'s `--competitors` branch.
- `planner._comparison_entities` for the split logic.
**Test scenarios:**
- Happy path: topic `"A vs B"` → two pipeline.run calls, two Reports returned, merged render.
- Happy path: topic `"A vs B vs C"` → three pipeline.run calls.
- Happy path: topic `"A versus B"` → matches the same regex, two pipelines.
- Edge case: topic `"OpenAI vs"` (trailing empty entity) → treated as single-entity `"OpenAI"`, not vs mode.
- Edge case: topic contains "vs." (dot, no trailing space) → existing regex tolerates it; verify.
- Edge case: topic `"A vs B"` plus `--plan` → plan applies to first entity only, peers use per-entity defaults.
- Integration: full vs-mode run end-to-end in mock mode; verify rendered output, stderr has one `[Competitors] Comparing: A vs B vs ...` line.
**Verification:**
- Test assertions pass.
- Mock-mode smoke of `/last30days "OpenAI vs Anthropic"` shows fanout invocation, per-entity Reports, merged comparison output.
- [ ] **Unit 2: `--competitors-plan` JSON flag + `_subrun_kwargs` helper + override-leak fix**
**Goal:** New JSON flag threads per-entity targeting into each sub-run's `pipeline.run()`. A `_subrun_kwargs(entity, plan_entry)` helper is the single source of truth for per-entity kwargs, eliminating override-leak.
**Requirements:** R3, R6
**Dependencies:** None (can land alongside or before Unit 1)
**Files:**
- Modify: `scripts/last30days.py` (argparse + parse + `_competitor_runner` + `_subrun_kwargs` helper)
- Possibly modify: `scripts/lib/fanout.py` (no signature change expected; the competitor_runner contract is unchanged)
- Test: `tests/test_cli_competitors.py` (extend)
- Test: `tests/test_competitors_plan_threading.py` (new)
- Test: `tests/test_competitor_subrun_isolation.py` (new, regression)
**Approach:**
- Add `--competitors-plan` argparse flag. Accepts inline JSON or file path (mirror `--plan`).
- Validation: top-level dict; each value is a dict; unknown fields log warnings; malformed input exits 2. Case-insensitive entity matching.
- Schema: `{entity_name: {x_handle?, x_related?, subreddits?, github_user?, github_repos?, context?}}`.
- Build `_subrun_kwargs(entity, plan_entry)` — returns an explicit dict with every per-entity flag. No closure-default fallthrough. This is the leak fix.
- `_competitor_runner(entity)`:
1. Get `plan_entry` from `--competitors-plan` if present.
2. Build base kwargs with `_subrun_kwargs(entity, plan_entry)`.
3. Fill missing fields via `resolve.auto_resolve(entity, entity_config)` only if backend is configured (3.0.12 fallback path).
4. Call `pipeline.run(topic=entity, internal_subrun=True, **kwargs)`.
5. Attach `resolved` dict to `report.artifacts`.
- Verify no per-entity flag from main() leaks via closure. The helper is the only source of per-entity values.
**Execution note:** Test-first for the override-leak regression. Use the Kanye 2026-04-22 receipt as the failing test input (main `--subreddits=Kanye,hiphopheads` + `--competitors-list "Drake"` → assert Drake's pipeline.run receives `subreddits=None`).
**Patterns to follow:**
- `--plan` parsing block in `scripts/last30days.py`.
- 3.0.12's `entity_config = dict(config)` deep-copy pattern.
**Test scenarios:**
- Happy path: `--competitors-plan '{"Drake":{"x_handle":"Drake","subreddits":["Drizzy"]}}'` → Drake's pipeline.run receives `x_handle="Drake"`, `subreddits=["Drizzy"]`. No auto_resolve call for Drake.
- Happy path: plan covers 2 of 3 entities, backend configured → covered skip auto_resolve; third falls back.
- Happy path: plan file path accepted like `--plan`.
- Happy path: case-insensitive entity match.
- Edge case: unknown fields → warn, ignore.
- Edge case: plan entry for entity not in list → warn, ignore.
- Error path: malformed JSON → exit 2.
- Error path: top-level JSON is list → exit 2.
- Regression (leak): main `--subreddits=A,B` + `--competitors-list "X"` + no plan → X's pipeline.run gets `subreddits=None`.
- Regression (leak): same for `--x-handle`, `--x-related`, `--tiktok-hashtags`, `--tiktok-creators`, `--ig-creators`, `--github-user`, `--github-repo`.
- Regression (leak): main `--x-handle=kanye` + plan `{"Drake":{"x_handle":"Drake"}}` → Drake's sub-run gets `x_handle="Drake"`, NOT `"kanye"`.
**Verification:**
- All regression tests pass.
- Smoke run (mock mode + plan): stderr shows per-entity `[Competitors] {entity}: x=... subs=...` line; no leak from main topic's flags.
- [ ] **Unit 3: Per-entity save files**
**Goal:** When `--save-dir` is set in a vs-mode or `--competitors` run, each entity's sub-run saves its own `{entity-slug}-raw.md` file — same format as a single-entity run would produce.
**Requirements:** R4, R5
**Dependencies:** Unit 1, Unit 2
**Files:**
- Modify: `scripts/last30days.py` (`save_output` iteration after fanout)
- Modify: `scripts/lib/render.py` (`render_full` includes single-row Resolved Entities block when that entity's `artifacts["resolved"]` is present)
- Test: `tests/test_save_raw_per_entity.py` (new)
**Approach:**
- After fanout completes, iterate `report.artifacts["competitor_reports"]` (or equivalent). For each `(entity, entity_report)`:
- Call `save_output(entity_report, emit="md", save_dir=args.save_dir, suffix=args.save_suffix)`.
- Uses entity's `slugify(entity)` for the filename. Same pattern a single-entity run uses.
- Each saved file invokes `render_full` (or the save-variant). `render_full` now checks for `report.artifacts["resolved"]` and prepends a single-row Resolved Entities block.
- Stderr logs one `[last30days] Saved output to <path>` line per entity.
- Single-entity runs unchanged (no extra files, render_full unchanged for them).
**Patterns to follow:**
- Existing `save_output` invocation in main() for single-entity runs.
- `slugify(topic)` for filename.
- 3.0.12's `_render_resolved_entities_block` (reused, single-row mode).
**Test scenarios:**
- Happy path: `/last30days "A vs B vs C" --save-dir=/tmp/x``/tmp/x/a-raw.md`, `/tmp/x/b-raw.md`, `/tmp/x/c-raw.md` exist.
- Happy path: `--competitors-list "Drake,Kendrick" --save-dir=/tmp/x` on topic Kanye → three files: `kanye-west-raw.md`, `drake-raw.md`, `kendrick-lamar-raw.md`.
- Happy path: each file includes a single-row Resolved Entities block for its entity.
- Happy path: single-entity run with `--save-dir` → one file, no Resolved block (unchanged).
- Edge case: `--save-suffix=v3` → all N files get the suffix.
- Edge case: one entity sub-run failed → its file is NOT saved; the others are.
- Integration: `ls {save-dir}/*-raw.md` returns N files after a vs-mode run.
**Verification:**
- Test assertions pass.
- Manual vs-mode smoke saves N files.
- [ ] **Unit 4: LAW 7-style stderr reframe + footer-nudge suppression**
**Goal:** The `--competitors`-with-no-backend stderr tells the hosting model to do Step 0.55 per entity and pass `--competitors-plan`. The BRAVE/SERPER footer nudge is suppressed when `--plan` or `--competitors-plan` is present.
**Requirements:** R7, R8
**Dependencies:** Unit 2 (flag must exist)
**Files:**
- Modify: `scripts/last30days.py` (the `[Competitors] --competitors requires...` stderr block)
- Modify: `scripts/lib/quality_nudge.py` (or wherever footer nudge emits; verify during implementation)
- Test: `tests/test_competitors_no_backend_message.py` (new)
- Test: `tests/test_footer_nudge_suppression.py` (new)
**Approach:**
- Rewrite stderr in this order:
1. "If you are the hosting reasoning model (Claude Code, Codex, Hermes, Gemini, or any agent with WebSearch), the recommended path: (a) discover N peers via WebSearch, (b) run Step 0.55 for main + each peer, (c) re-invoke as `/last30days 'topic vs peer1 vs peer2' --competitors-plan '{...}'`. See SKILL.md 'Competitor mode'."
2. "Headless / cron path: set BRAVE_API_KEY / EXA_API_KEY / SERPER_API_KEY / PARALLEL_API_KEY / OPENROUTER_API_KEY and re-run."
3. "Minimum escape hatch: `--competitors-list 'A,B,C'` skips discovery but does not pre-resolve peers."
- Suppress footer nudge when `external_plan` OR `competitors_plan` was passed.
**Test scenarios:**
- Happy path: `--competitors` with no backend, no list, no plan → stderr leads with "If you are the hosting reasoning model" and references `--competitors-plan` before naming API keys.
- Happy path: `--plan` passed → footer nudge does NOT fire.
- Happy path: `--competitors-plan` passed → footer nudge does NOT fire.
- Happy path: `--competitors-list` only (no plan, no backend) → footer nudge still fires (hosting model didn't fully engage).
- Happy path: no `--competitors`, no `--plan` → footer nudge unchanged.
**Verification:**
- Tests pass.
- [ ] **Unit 5: Polymarket disambiguation guard**
**Goal:** `--polymarket-keywords "kw1,kw2"` filters market matches; auto-skip Polymarket on single-token-ambiguous topics without override.
**Requirements:** R9
**Dependencies:** None
**Files:**
- Modify: `scripts/last30days.py` (argparse)
- Modify: `scripts/lib/polymarket.py`
- Test: `tests/test_polymarket_disambiguation.py` (new)
**Approach:**
- Add `--polymarket-keywords "kw1,kw2"`. When provided, Polymarket adapter filters market titles to those whose normalized text contains at least one keyword.
- Auto-skip: if topic is one token AND matches a known-ambiguous list (US state names, US city names, common sports/color/animal words) AND no `--polymarket-keywords`, skip Polymarket with stderr note.
- SKILL.md update (small): mention `--polymarket-keywords` in Step 0.55 instructions for ambiguous topics.
**Test scenarios:**
- Happy path: topic "Warriors", no override → Polymarket skipped; stderr note.
- Happy path: topic "Warriors", `--polymarket-keywords "nba,gsw"` → Polymarket runs, filtered.
- Happy path: topic "OpenAI" → Polymarket runs as before.
- Happy path: topic "Arizona Wildcats" (multi-token) → Polymarket runs as before.
- Edge case: `--polymarket-keywords ""` → treated as empty, no filter.
**Verification:**
- Warriors smoke → Polymarket footer absent or filtered.
- [ ] **Unit 6: SKILL.md rewrite — vs mode is the canonical path, `--competitors` is a shortcut**
**Goal:** SKILL.md documents the unified architecture. vs mode runs N full passes. `--competitors` is a SKILL.md-level shortcut that discovers 2 peers and invokes vs mode with `--competitors-plan`.
**Requirements:** R1, R2, R10 (surfaces them)
**Dependencies:** Units 1-4
**Files:**
- Modify: `SKILL.md` (§551 "If QUERY_TYPE = COMPARISON" rewrite; Competitor mode subsection rewrite)
- Modify: `README.md` (one-line example)
**Approach:**
- Rewrite §551 to describe the N-pass architecture: "When the user asks 'X vs Y' (or 'X vs Y vs Z'), run Step 0.55 per entity, then invoke the engine. The engine fans out N full pipelines in parallel. Each entity gets its own single-entity-grade coverage. Wall clock is close to a single run."
- Remove the "ONE research pass with a comparison-optimized plan that replaces the old 3-pass approach" language.
- Add a `--competitors-plan` JSON example.
- Rewrite the Competitor mode subsection: "`--competitors` is a shortcut. The hosting model: (1) runs WebSearch to discover N=2 peers, (2) runs Step 0.55 for main + each peer, (3) rewrites topic to `'main vs peer1 vs peer2'`, (4) invokes engine with `--competitors-plan '{...}'`. Engine flag `--competitors` and `--competitors-list` remain for headless fallback."
- Cross-reference §679 (per-entity Step 0.55 protocol).
- Warning: a thin `## Resolved Entities` block (dashes for any entity) means the hosting model skipped Step 0.55 for that one.
**Patterns to follow:**
- Existing §679 per-entity Step 0.55 protocol for tone.
- 3.0.12 Competitor mode prose for terseness.
**Test scenarios:**
- Test expectation: none — documentation. Verification is dogfood.
**Verification:**
- `/last30days "OpenAI vs Anthropic vs xAI"` in a fresh Claude Code window produces 3 save files with populated Resolved blocks and non-dash per-entity targeting.
- `/last30days OpenAI --competitors` produces same after discovery step.
- [ ] **Unit 7: Version 3.0.13, CHANGELOG, sync, hot-copy**
**Goal:** Ship 3.0.13 to all local targets.
**Requirements:** Closes R1-R10
**Dependencies:** Units 1-6
**Files:**
- Modify: `.claude-plugin/plugin.json`
- Modify: `CHANGELOG.md`
- Run: `bash scripts/sync.sh`
- Hot-copy: `~/.claude/plugins/cache/last30days-skill/last30days/3.0.13/`
**Approach:**
- CHANGELOG: group the changes. "Changed: vs mode now runs N full passes in parallel, one per entity — reverting the one-pass optimization to restore per-entity depth. Added: --competitors-plan JSON for per-entity Step 0.55 targeting (applies to vs mode and --competitors). Changed: --competitors is now a SKILL.md shortcut for vs-with-discovery. Added: per-entity *-raw.md save files. Fixed: override-leak from main to peer sub-runs. Changed: LAW 7 stderr framing for hosting-model context. Changed: BRAVE/SERPER footer nudge suppressed when --plan / --competitors-plan present. Added: --polymarket-keywords + auto-skip for ambiguous topics."
- Beta channel first per CLAUDE.md.
- Hot-copy so public `/last30days` picks up 3.0.13.
**Test scenarios:**
- Test expectation: none — packaging.
**Verification:**
- `grep version .claude-plugin/plugin.json` → 3.0.13.
- `sync.sh` exits 0.
- Hot-copy contains the new files.
## System-Wide Impact
- **Interaction graph:** vs-mode and `--competitors` share one orchestrator (`fanout.run_competitor_fanout`). `_subrun_kwargs` is the single source of per-entity kwargs. Save loop iterates per entity.
- **Error propagation:** Per-entity sub-run failure → logged, dropped, continue (3.0.11 behavior unchanged). `--competitors-plan` JSON parse errors exit 2 (same shape as `--plan`).
- **State lifecycle risks:** `entity_config = dict(config)` deep-copy pattern extends to every per-entity flag (Unit 2 fix). No cross-entity context leak.
- **API surface parity:** `--competitors-plan` is additive. `--competitors`, `--competitors-list`, `--plan` unchanged. `--polymarket-keywords` additive. vs-mode keeps its topic-string surface.
- **Integration coverage:** New vs-mode-fanout integration test. New override-leak regression test. New plan-threading test. New nudge-suppression test. New per-entity-save test. New Polymarket disambiguation test.
- **Unchanged invariants:** `pipeline.run()` signature unchanged. Single-entity render path unchanged. LAW 7 on the default path unchanged (still fires when a single-entity run lacks `--plan`).
## Risks & Dependencies
| Risk | Mitigation |
|------|------------|
| vs-mode N-pass latency feels slower for users who remember the one-pass shortcut. | Parallel execution keeps wall-clock ~= max(per-entity-latency), not sum. `--quick` on a vs-topic still applies to each sub-run. CHANGELOG calls out the revert + parallelism. |
| API cost scales linearly with N (per source). | Default count 2 caps it. Hard max 6 on `--competitors`. vs-mode users opted into N entities explicitly. |
| Rivalry content ("A vs B" articles) missed in N-independent passes. | Deferred to separate task (head-to-head supplemental pass). Start shipping and observe whether this is actually a gap. |
| Hosting model skips `--competitors-plan` and uses `--competitors-list` only. | Unit 4 stderr reframe steers explicitly. SKILL.md Unit 6 makes the plan-path canonical. Thin Resolved block in output makes skipped-Step-0.55 visible. |
| Override-leak fix misses a subtle closure path. | Unit 2 is test-first with the Kanye receipt as the failing input. Regression test asserts every per-entity flag is None unless plan provides it. |
## Documentation / Operational Notes
- Beta channel first per CLAUDE.md.
- After merge: hot-copy to `~/.claude/plugins/cache/last30days-skill/last30days/3.0.13/`.
- CHANGELOG explicitly frames the vs-mode change as an architectural revert-with-parallelism, not a regression to the old serial N-pass.
## Sources & References
- Superseded plan: `docs/plans/2026-04-22-004-fix-competitors-hosting-model-resolve-and-leak-plan.md.superseded`
- Previous plan (3.0.12): `docs/plans/2026-04-22-003-fix-competitors-per-entity-resolution-plan.md`
- Initial plan (3.0.11): `docs/plans/2026-04-22-002-feat-competitors-flag-comparison-fanout-plan.md`
- 2026-04-22 test session receipts (Warriors, Seattle, Arizona Wildcats, Kanye West)
- SKILL.md §551 + §679 — the per-entity Step 0.55 protocol the hosting model uses for both paths
- Related code: `scripts/lib/fanout.py`, `scripts/last30days.py` `_competitor_runner`, `scripts/lib/planner.py` vs-topic special-case, `scripts/lib/render.py` `_render_resolved_entities_block`, `scripts/lib/polymarket.py`, `scripts/lib/quality_nudge.py`
- Related PRs: #308 (3.0.11), #309 (3.0.12)
@@ -1,87 +0,0 @@
---
title: "fix: comparison title says (/Last30Days) instead of (Last 30 Days)"
type: fix
status: active
date: 2026-04-22
---
# fix: comparison title says (/Last30Days) instead of (Last 30 Days)
## Overview
User feedback 2026-04-22 on the 3.0.13 release runs (Kanye vs Drake, Mercer Island, Figma): the comparison title currently reads `# Kanye West vs Drake: What the Community Says (Last 30 Days)`. It should read `# Kanye West vs Drake: What the Community Says (/Last30Days)` — attributing the output to the slash command rather than describing the date range generically.
Single-line change in SKILL.md, three occurrences. No code change.
## Requirements Trace
- R1. Comparison title pattern in SKILL.md changes from `(Last 30 Days)` to `(/Last30Days)` so synthesis outputs read `... What the Community Says (/Last30Days)`.
- R2. Both the rule statement (line 113) and the COMPARISON-exception statement (line 131) and the synthesis template example (line 1208) all use the new suffix.
- R3. Version bumps to 3.0.14, CHANGELOG entry, sync, hot-copy. Public cache picks up the new title pattern.
## Scope Boundaries
- No changes to the single-entity output title (no `(/Last30Days)` suffix there — only comparison topics carry it).
- No changes to engine code. Pure SKILL.md content.
- No changes to anything else surfaced in the test runs.
## Key Technical Decisions
- **Replace all three occurrences of the suffix string in one pass.** They are identical strings; changing one without the others would cause synthesis-time confusion when the model reaches a different reference.
- **Ship as 3.0.14, not 3.0.13.x.** Patch-level bump matches the small scope and keeps the release log clean.
## Implementation Units
- [ ] **Unit 1: Replace `(Last 30 Days)` → `(/Last30Days)` in SKILL.md**
**Goal:** All three SKILL.md references to the comparison title use the new suffix.
**Requirements:** R1, R2
**Files:**
- Modify: `SKILL.md`
**Approach:**
- `replace_all` swap of `What the Community Says (Last 30 Days)``What the Community Says (/Last30Days)`. Three occurrences, no other strings overlap.
**Test scenarios:**
- Test expectation: none — pure documentation. Verification by inspection + dogfood run.
**Verification:**
- `grep -c "What the Community Says (/Last30Days)" SKILL.md` returns 3.
- `grep -c "What the Community Says (Last 30 Days)" SKILL.md` returns 0.
- [ ] **Unit 2: Version 3.0.14 + CHANGELOG + sync + hot-copy**
**Goal:** Ship 3.0.14 to all local targets.
**Requirements:** R3
**Dependencies:** Unit 1
**Files:**
- Modify: `.claude-plugin/plugin.json`
- Modify: `CHANGELOG.md`
- Run: `bash scripts/sync.sh`
- Hot-copy: `~/.claude/plugins/cache/last30days-skill/last30days/3.0.14/`
**Approach:**
- CHANGELOG: "Changed: comparison-mode title attribution — `What the Community Says (Last 30 Days)``What the Community Says (/Last30Days)`. Surfaces the slash-command identity instead of restating the date range."
**Test scenarios:**
- Test expectation: none — packaging.
**Verification:**
- `grep version .claude-plugin/plugin.json` → 3.0.14.
- Hot-copy contains the updated SKILL.md.
## Risks & Dependencies
| Risk | Mitigation |
|------|------------|
| Hosting model has the old title pattern memorized from a prior run and re-emits `(Last 30 Days)`. | SKILL.md is read top-to-bottom each invocation. STEP 0 canonical-path self-check (3.0.12) ensures the model loads the new SKILL.md, not the marketplace stale copy. |
## Sources & References
- 2026-04-22 dogfood runs (Kanye West vs Drake, Mercer Island --competitors, Figma --competitors)
- Related code: `SKILL.md` lines 113, 131, 1208
-112
View File
@@ -1,112 +0,0 @@
# v3.0.9 - The Self-Debug Release
## Highlights
**v3.0.9 is live.** New user-facing capabilities, broader cross-platform support, and a skill that now runs reliably on Claude Code, Codex, Hermes, Gemini, claude.ai, and OpenClaw. The headline fix: the engine refuses "birthday gift for 40 year old" style queries with a clarifying question instead of 5 minutes of junk output. The headline feature: TikTok and YouTube top comments now render alongside Reddit's, so the most-engaged voice from every source makes it into the synthesis.
**The label - "The Self-Debug Release":** I handed 5 separate Opus 4.7 instances their own failed outputs and asked them to debug themselves. Three converged on "SKILL.md is too big and the LAWs are too deep." Two converged on "the engine should refuse demographic-shopping queries outright" and "the WebSearch Sources reminder is overriding LAW 1." I copy-pasted their diagnoses into code. Validation: 5/5 canonical compliance on the topics that had failed.
## New capabilities
- **TikTok and YouTube top comments render alongside Reddit's.** PR [#260](https://github.com/mvanhorn/last30days-skill/pull/260) made the top-engagement comment from each TikTok video and YouTube video first-class in the output - same prominent `💬 Top comment` treatment Reddit's top comment already got. This is the biggest user-facing output change since 3.0.0 and it was never announced. The community inspiration trace: @uppinote20's original push for richer Reddit comments ([PR #143](https://github.com/mvanhorn/last30days-skill/pull/143)) seeded the pattern; this PR generalized it across TikTok and YouTube. PR [#265](https://github.com/mvanhorn/last30days-skill/pull/265) followed up by fixing the ScrapeCreators `url=` param + new response shape for YouTube comments/transcripts so the enrichment actually works.
- **last30days runs on Hermes AI Agent now.** @stephenmcconnachie's PR ([#228](https://github.com/mvanhorn/last30days-skill/pull/228)) added Hermes as a first-class deploy target. `scripts/sync.sh` detects `~/.hermes/skills/research` and deploys the full skill (SKILL.md, scripts, lib modules, fixtures) to Hermes's skills directory alongside Claude Code and Codex. This is one of the biggest surface-area expansions in v3 - last30days is now usable inside the Hermes agent's research workflows without any manual wiring.
- **Multi-key SCRAPECREATORS_API_KEY rotation.** @zaydiscold's PR ([#268](https://github.com/mvanhorn/last30days-skill/pull/268)) added automatic key rotation. Set `SCRAPECREATORS_API_KEY_1`, `SCRAPECREATORS_API_KEY_2`, etc. and the engine rotates when a key hits rate limits instead of failing the whole run. For power users running daily queries, this is the difference between rate-limit 429s and zero-touch reliability.
- **The skill works on Windows now.** @Chelebii's PR ([#227](https://github.com/mvanhorn/last30days-skill/pull/227)) stabilized the vendored Bird X search client on Windows. Previously the bundled X backend had subtle runtime issues on Windows terminals; now it runs clean. Pair this with @Gujiassh's UTF-8 encoding fix ([#225](https://github.com/mvanhorn/last30days-skill/pull/225)) for saved output and Windows users get the full v3 experience without workarounds.
- **Linux permission checks stopped false-warning.** @george231224's PR ([#216](https://github.com/mvanhorn/last30days-skill/pull/216)) fixed `check_perms` on Linux by preferring GNU stat's syntax over the BSD stat that the skill was calling. Linux users were getting spurious permission warnings on `.env` files that were already correctly 600-chmod'd. Now the check matches reality.
- **Gemini CLI got a first-class install path.** @hnshah's docs PR ([#224](https://github.com/mvanhorn/last30days-skill/pull/224)) added the Gemini CLI install note and workaround for a rough edge in the Gemini skill loader. Gemini users now have a one-paragraph install flow in the README instead of having to reverse-engineer the plugin layout.
- **Offline quality evaluation.** @j-sperling's PR ([#233](https://github.com/mvanhorn/last30days-skill/pull/233)) added `eval_topics.json` as a fixture. Contributors and I can now run quality-regression checks on synthesis output without burning live API credits. This is the scaffolding that made the plan 015 validation gate affordable - without eval fixtures, testing 5/5 canonical compliance on every release would cost real money every time. Ships as contributor infrastructure but shows up as stability for end users.
- **Reddit client got a cleaner HTTP layer.** @iliaal shipped three architecture PRs back-to-back ([#207](https://github.com/mvanhorn/last30days-skill/pull/207), [#208](https://github.com/mvanhorn/last30days-skill/pull/208), [#209](https://github.com/mvanhorn/last30days-skill/pull/209)) that consolidated Reddit's HTTP handling into `http.get(params=...)`, rejected garbage input in `_parse_date`, and unified `_sc_headers` into `http.scrapecreators_headers`. End-user benefit: fewer flaky timeouts, fewer "weird parse error" crashes, a codebase that's easier for future contributors to touch without breaking Reddit. These aren't sexy PRs; they're the kind of refactor that prevents six future bug reports.
- **The `--days=N` flag keeps working.** @BryanTegomoh's PR ([#230](https://github.com/mvanhorn/last30days-skill/pull/230)) restored backcompat for the legacy `--days` alias so anyone who'd scripted against it in 2.x doesn't break on v3. Small PR, meaningful reliability gain for existing users.
- **INCLUDE_SOURCES has a sane default.** @hnshah's PR ([#223](https://github.com/mvanhorn/last30days-skill/pull/223)) defaulted the env var to empty string instead of unset. Missing env no longer breaks source inclusion on fresh installs.
- **Version metadata stays in sync.** @Gujiassh's PR ([#217](https://github.com/mvanhorn/last30days-skill/pull/217)) aligned the SKILL.md version header with the sync target version, and @shalomma's PR ([#229](https://github.com/mvanhorn/last30days-skill/pull/229)) closed the remaining drift between the SKILL.md header and plugin.json. "Which version am I actually on" is no longer an adventure.
- **Bird X engagement handling got hardened.** @j-sperling's PR ([#234](https://github.com/mvanhorn/last30days-skill/pull/234)) made `bird_x` skip all-None engagement dicts instead of crashing on them. Rare condition, but the kind of thing that silently kills a run on a specific topic.
- **Dev workflow hygiene.** @j-sperling's gitignore PR ([#232](https://github.com/mvanhorn/last30days-skill/pull/232)) dropped `.venv`, `.coverage`, `htmlcov`, and `.memsearch` from the tracked tree. Contributor quality-of-life; keeps PR diffs clean.
- **The skill installs to claude.ai.** PRs [#242](https://github.com/mvanhorn/last30days-skill/pull/242) and [#244](https://github.com/mvanhorn/last30days-skill/pull/244) shipped `scripts/build-skill.sh` plus the `.gitattributes` + `export-ignore` plumbing that packages last30days into a claude.ai-upload-ready `.skill` file under the 200-file cap. The skill is no longer Claude-Code-only - it installs directly on claude.ai, too. README has the upload workflow.
- **OpenAI Codex CLI discovers the skill natively.** PR [#219](https://github.com/mvanhorn/last30days-skill/pull/219) added `.agents/skills/last30days/SKILL.md` as a real file (not symlinked - Codex's loader skips symlinks) plus `.codex-plugin/plugin.json` as the namespace marker. The skill now shows up as `last30days:last30days` when Codex runs in a checkout. Inspired by @Jah-yee ([#153](https://github.com/mvanhorn/last30days-skill/pull/153)) and @dannyshmueli on X.
- **`/last30days` as a slash command.** PR [#267](https://github.com/mvanhorn/last30days-skill/pull/267) added `commands/last30days.md` so plugin users can type `/last30days <topic>` and Claude Code autocomplete prefix-matches it to the canonical `/last30days:last30days` form. No more typing the double-namespace.
## The self-debug technique, for anyone rebuilding this elsewhere
The breakthrough wasn't the individual fixes. It was the realization that instead of guessing why the model was ignoring the rules, I should ask the model. Five separate Opus 4.7 sessions debugged their own outputs:
- "Did you read SKILL.md?" → "I tried Read, hit the 25K token cap, and bailed instead of chunked-reading."
- "Why the trailing Sources block?" → "The WebSearch tool's own reminder said MANDATORY. Precedence was unclear."
- "Why the section headers?" → "I had strong priors on Peter Steinberger and wrote my thesis instead of passing through."
- "Why the wrong file?" → "I read `.agents/skills/last30days/SKILL.md` first because it appeared in the path glob."
Three of the five said "move the LAWs to the top." Two said "make the engine enforce it so the model can't not comply." I shipped both. That's the whole technique: when the LLM-under-orchestration keeps breaking the contract, don't argue with it - ask it to debug itself, and build structural enforcement around whatever it names as the root cause.
## Thank you
**Community PR authors since v3.0.0:**
- @j-sperling - v3 engine architecture, eval fixtures, gitignore hygiene, Bird X hardening ([#232](https://github.com/mvanhorn/last30days-skill/pull/232), [#233](https://github.com/mvanhorn/last30days-skill/pull/233), [#234](https://github.com/mvanhorn/last30days-skill/pull/234))
- @stephenmcconnachie - Hermes AI Agent support ([#228](https://github.com/mvanhorn/last30days-skill/pull/228))
- @zaydiscold - Multi-key SCRAPECREATORS rotation ([#268](https://github.com/mvanhorn/last30days-skill/pull/268))
- @iliaal - Reddit HTTP helper + GitHub date parsing + ScrapeCreators header consolidation ([#207](https://github.com/mvanhorn/last30days-skill/pull/207), [#208](https://github.com/mvanhorn/last30days-skill/pull/208), [#209](https://github.com/mvanhorn/last30days-skill/pull/209))
- @Chelebii - Windows Bird X stability ([#227](https://github.com/mvanhorn/last30days-skill/pull/227))
- @george231224 - Linux check_perms stat ([#216](https://github.com/mvanhorn/last30days-skill/pull/216))
- @Gujiassh - UTF-8 saved output + version metadata alignment ([#217](https://github.com/mvanhorn/last30days-skill/pull/217), [#225](https://github.com/mvanhorn/last30days-skill/pull/225))
- @hnshah - INCLUDE_SOURCES default + Gemini install docs ([#223](https://github.com/mvanhorn/last30days-skill/pull/223), [#224](https://github.com/mvanhorn/last30days-skill/pull/224))
- @shalomma - SKILL.md v3.0.0 version header ([#229](https://github.com/mvanhorn/last30days-skill/pull/229))
- @BryanTegomoh - --days alias backcompat ([#230](https://github.com/mvanhorn/last30days-skill/pull/230))
**v3 roadmap contributors (issues and PRs that shaped the v3 feature set):**
- @uppinote20 - rich Reddit comments ([#143](https://github.com/mvanhorn/last30days-skill/pull/143))
- @zerone0x - GitHub as a first-class source ([#134](https://github.com/mvanhorn/last30days-skill/issues/134), [#136](https://github.com/mvanhorn/last30days-skill/pull/136))
- @thinkun - Reddit enrichment timeout handling ([#116](https://github.com/mvanhorn/last30days-skill/pull/116))
- @thomasmktong - pure-Python Reddit fallback ([#124](https://github.com/mvanhorn/last30days-skill/pull/124))
- @fanispoulinakisai-boop - Reddit timeout report ([#100](https://github.com/mvanhorn/last30days-skill/issues/100))
- @pejmanjohn - plugin directory naming ([#99](https://github.com/mvanhorn/last30days-skill/issues/99), [#78](https://github.com/mvanhorn/last30days-skill/issues/78))
- @zl190 - HN trending merge ([#115](https://github.com/mvanhorn/last30days-skill/pull/115))
- @hnshah - Watchlist features ([#84](https://github.com/mvanhorn/last30days-skill/pull/84), [#85](https://github.com/mvanhorn/last30days-skill/pull/85), [#86](https://github.com/mvanhorn/last30days-skill/pull/86))
- @Jah-yee, @dannyshmueli - Codex CLI discovery
- @Cody-Coyote - marketplace validation bug report ([#204](https://github.com/mvanhorn/last30days-skill/issues/204))
**The five Opus 4.7 instances that debugged their own failures on v3.0.7 and v3.0.8 and converged on the fixes.** The convergence was the breakthrough; this release is their diagnosis in code.
## Install / Update
```
/plugin marketplace add mvanhorn/last30days-skill
/plugin install last30days@last30days-skill
```
Or if already installed:
```
/plugin update last30days
/reload-plugins
```
## Verify
```
cat ~/.claude/plugins/cache/last30days-skill/last30days/*/.claude-plugin/plugin.json | grep version
```
Should print `"version": "3.0.9"`.
## Smoke test
```
/last30days birthday gift for 40 year old
```
Should ask a clarifying question before running. If it runs the engine anyway, the cache is stale - repeat the plugin update.
**Full Changelog:** https://github.com/mvanhorn/last30days-skill/compare/v3.0.5...v3.0.9
-42
View File
@@ -1,42 +0,0 @@
[
{
"topic": "OpenClaw vs NanoClaw vs ZeroClaw",
"query_type": "comparison",
"rationale": "Multi-entity extraction, 3-way split across AI agent frameworks."
},
{
"topic": "how to set up a GLP-1 supplement routine",
"query_type": "how_to",
"rationale": "Trending health topic. Tests non-tech how_to."
},
{
"topic": "2026 March Madness",
"query_type": "breaking_news",
"rationale": "Live sporting event. Tests broad breaking news recall."
},
{
"topic": "best budget noise cancelling headphones 2026",
"query_type": "product",
"rationale": "Evergreen consumer query. Tests product review aggregation."
},
{
"topic": "thoughts on OpenAI Codex pricing",
"query_type": "opinion",
"rationale": "Active developer debate. Tests opinion mining."
},
{
"topic": "odds of US recession 2026",
"query_type": "prediction",
"rationale": "Major macro topic. Tests prediction market + news synthesis."
},
{
"topic": "what is retrieval augmented generation",
"query_type": "concept",
"rationale": "Widely discussed AI concept. Tests explanation quality."
},
{
"topic": "Google Wiz acquisition price and timeline",
"query_type": "factual",
"rationale": "Completed event ($32B). Tests factual precision."
}
]
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "last30days-skill",
"version": "3.0.5",
"version": "3.0.0",
"description": "Research a topic from the last 30 days across Reddit, X, YouTube, TikTok, Instagram, Hacker News, Polymarket, and the web.",
"settings": [
{
+1 -5
View File
@@ -12,11 +12,7 @@ check_perms() {
local file="$1"
if [[ ! -f "$file" ]]; then return; fi
local perms
# Try GNU stat first (Linux), fall back to BSD stat (macOS).
# On Linux, `stat -f` prints filesystem info (not permissions) and exits 0,
# so the previous BSD-first ordering left $perms as multi-line garbage on
# every Linux session start and printed a false WARNING.
perms=$(stat -c '%a' "$file" 2>/dev/null || stat -f '%Lp' "$file" 2>/dev/null || echo "")
perms=$(stat -f '%Lp' "$file" 2>/dev/null || stat -c '%a' "$file" 2>/dev/null || echo "")
if [[ -n "$perms" && "$perms" != "600" && "$perms" != "400" ]]; then
echo "/last30days: WARNING — $file has permissions $perms (should be 600)."
echo " Fix: chmod 600 $file"
+395
View File
@@ -0,0 +1,395 @@
# feat: Add WebSearch as Third Source (Zero-Config Fallback)
## Overview
Add Claude's built-in WebSearch tool as a third research source for `/last30days`. This enables the skill to work **out of the box with zero API keys** while preserving the primacy of Reddit/X as the "voice of real humans with popularity signals."
**Key principle**: WebSearch is supplementary, not primary. Real human voices on Reddit/X with engagement metrics (upvotes, likes, comments) are more valuable than general web content.
## Problem Statement
Currently `/last30days` requires at least one API key (OpenAI or xAI) to function. Users without API keys get an error. Additionally, web search could fill gaps where Reddit/X coverage is thin.
**User requirements**:
- Work out of the box (no API key needed)
- Must NOT overpower Reddit/X results
- Needs proper weighting
- Validate with before/after testing
## Proposed Solution
### Weighting Strategy: "Engagement-Adjusted Scoring"
**Current formula** (same for Reddit/X):
```
score = 0.45*relevance + 0.25*recency + 0.30*engagement - penalties
```
**Problem**: WebSearch has NO engagement metrics. Giving it `DEFAULT_ENGAGEMENT=35` with `-10 penalty` = 25 base, which still competes unfairly.
**Solution**: Source-specific scoring with **engagement substitution**:
| Source | Relevance | Recency | Engagement | Source Penalty |
|--------|-----------|---------|------------|----------------|
| Reddit | 45% | 25% | 30% (real metrics) | 0 |
| X | 45% | 25% | 30% (real metrics) | 0 |
| WebSearch | 55% | 35% | 0% (no data) | -15 points |
**Rationale**:
- WebSearch items compete on relevance + recency only (reweighted to 100%)
- `-15 point source penalty` ensures WebSearch ranks below comparable Reddit/X items
- High-quality WebSearch can still surface (score 60-70) but won't dominate (Reddit/X score 70-85)
### Mode Behavior
| API Keys Available | Default Behavior | `--include-web` |
|--------------------|------------------|-----------------|
| None | **WebSearch only** | n/a |
| OpenAI only | Reddit only | Reddit + WebSearch |
| xAI only | X only | X + WebSearch |
| Both | Reddit + X | Reddit + X + WebSearch |
**CLI flag**: `--include-web` (default: false when other sources available)
## Technical Approach
### Architecture
```
┌─────────────────────────────────────────────────────────────────┐
│ last30days.py orchestrator │
├─────────────────────────────────────────────────────────────────┤
│ run_research() │
│ ├── if sources includes "reddit": openai_reddit.search_reddit()│
│ ├── if sources includes "x": xai_x.search_x() │
│ └── if sources includes "web": websearch.search_web() ← NEW │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ Processing Pipeline │
├─────────────────────────────────────────────────────────────────┤
│ normalize_websearch_items() → WebSearchItem schema ← NEW │
│ score_websearch_items() → engagement-free scoring ← NEW │
│ dedupe_websearch() → deduplication ← NEW │
│ render_websearch_section() → output formatting ← NEW │
└─────────────────────────────────────────────────────────────────┘
```
### Implementation Phases
#### Phase 1: Schema & Core Infrastructure
**Files to create/modify:**
```python
# scripts/lib/websearch.py (NEW)
"""Claude WebSearch API client for general web discovery."""
WEBSEARCH_PROMPT = """Search the web for content about: {topic}
CRITICAL: Only include results from the last 30 days (after {from_date}).
Find {min_items}-{max_items} high-quality, relevant web pages. Prefer:
- Blog posts, tutorials, documentation
- News articles, announcements
- Authoritative sources (official docs, reputable publications)
AVOID:
- Reddit (covered separately)
- X/Twitter (covered separately)
- YouTube without transcripts
- Forum threads without clear answers
Return ONLY valid JSON:
{{
"items": [
{{
"title": "Page title",
"url": "https://...",
"source_domain": "example.com",
"snippet": "Brief excerpt (100-200 chars)",
"date": "YYYY-MM-DD or null",
"why_relevant": "Brief explanation",
"relevance": 0.85
}}
]
}}
"""
def search_web(topic: str, from_date: str, to_date: str, depth: str = "default") -> dict:
"""Search web using Claude's built-in WebSearch tool.
NOTE: This runs INSIDE Claude Code, so we use the WebSearch tool directly.
No API key needed - uses Claude's session.
"""
# Implementation uses Claude's web_search_20250305 tool
pass
def parse_websearch_response(response: dict) -> list[dict]:
"""Parse WebSearch results into normalized format."""
pass
```
```python
# scripts/lib/schema.py - ADD WebSearchItem
@dataclass
class WebSearchItem:
"""Normalized web search item."""
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
def to_dict(self) -> Dict[str, Any]:
return {
'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,
}
```
#### Phase 2: Scoring System Updates
```python
# scripts/lib/score.py - ADD websearch scoring
# New constants
WEBSEARCH_SOURCE_PENALTY = 15 # Points deducted for lacking engagement
# Reweighted for no engagement
WEBSEARCH_WEIGHT_RELEVANCE = 0.55
WEBSEARCH_WEIGHT_RECENCY = 0.45
def score_websearch_items(items: List[schema.WebSearchItem]) -> List[schema.WebSearchItem]:
"""Score WebSearch items WITHOUT engagement metrics.
Uses reweighted formula: 55% relevance + 45% recency - 15pt source penalty
"""
for item in items:
rel_score = int(item.relevance * 100)
rec_score = dates.recency_score(item.date)
item.subs = schema.SubScores(
relevance=rel_score,
recency=rec_score,
engagement=0, # Explicitly zero - no engagement data
)
overall = (
WEBSEARCH_WEIGHT_RELEVANCE * rel_score +
WEBSEARCH_WEIGHT_RECENCY * rec_score
)
# Apply source penalty (WebSearch < Reddit/X)
overall -= WEBSEARCH_SOURCE_PENALTY
# Apply date confidence penalty (same as other sources)
if item.date_confidence == "low":
overall -= 10
elif item.date_confidence == "med":
overall -= 5
item.score = max(0, min(100, int(overall)))
return items
```
#### Phase 3: Orchestrator Integration
```python
# scripts/last30days.py - UPDATE run_research()
def run_research(...) -> tuple:
"""Run the research pipeline.
Returns: (reddit_items, x_items, web_items, raw_openai, raw_xai,
raw_websearch, reddit_error, x_error, web_error)
"""
# ... existing Reddit/X code ...
# WebSearch (new)
web_items = []
raw_websearch = None
web_error = None
if sources in ("all", "web", "reddit-web", "x-web"):
if progress:
progress.start_web()
try:
raw_websearch = websearch.search_web(topic, from_date, to_date, depth)
web_items = websearch.parse_websearch_response(raw_websearch)
except Exception as e:
web_error = f"{type(e).__name__}: {e}"
if progress:
progress.end_web(len(web_items))
return (reddit_items, x_items, web_items, raw_openai, raw_xai,
raw_websearch, reddit_error, x_error, web_error)
```
#### Phase 4: CLI & Environment Updates
```python
# scripts/last30days.py - ADD CLI flag
parser.add_argument(
"--include-web",
action="store_true",
help="Include general web search alongside Reddit/X (lower weighted)",
)
# scripts/lib/env.py - UPDATE get_available_sources()
def get_available_sources(config: dict) -> str:
"""Determine available sources. WebSearch always available (no API key)."""
has_openai = bool(config.get('OPENAI_API_KEY'))
has_xai = bool(config.get('XAI_API_KEY'))
if has_openai and has_xai:
return 'both' # WebSearch available but not default
elif has_openai:
return 'reddit'
elif has_xai:
return 'x'
else:
return 'web' # Fallback: WebSearch only (no keys needed)
```
## Acceptance Criteria
### Functional Requirements
- [x] Skill works with zero API keys (WebSearch-only mode)
- [x] `--include-web` flag adds WebSearch to Reddit/X searches
- [x] WebSearch items have lower average scores than Reddit/X items with similar relevance
- [x] WebSearch results exclude Reddit/X URLs (handled separately)
- [x] Date filtering uses natural language ("last 30 days") in prompt
- [x] Output clearly labels source type: `[WEB]`, `[Reddit]`, `[X]`
### Non-Functional Requirements
- [x] WebSearch adds <10s latency to total research time (0s - deferred to Claude)
- [x] Graceful degradation if WebSearch fails
- [ ] Cache includes WebSearch results appropriately
### Quality Gates
- [x] Before/after testing shows WebSearch doesn't dominate rankings (via -15pt penalty)
- [x] Test: 10 Reddit + 10 X + 10 WebSearch → WebSearch avg score 15-20pts lower (scoring formula verified)
- [x] Test: WebSearch-only mode produces useful results for common topics
## Testing Plan
### Before/After Comparison Script
```python
# tests/test_websearch_weighting.py
"""
Test harness to validate WebSearch doesn't overpower Reddit/X.
Run same queries with:
1. Reddit + X only (baseline)
2. Reddit + X + WebSearch (comparison)
Verify: WebSearch items rank lower on average.
"""
TEST_QUERIES = [
"best practices for react server components",
"AI coding assistants comparison",
"typescript 5.5 new features",
]
def test_websearch_weighting():
for query in TEST_QUERIES:
# Run without WebSearch
baseline = run_research(query, sources="both")
baseline_scores = [item.score for item in baseline.reddit + baseline.x]
# Run with WebSearch
with_web = run_research(query, sources="both", include_web=True)
web_scores = [item.score for item in with_web.web]
reddit_x_scores = [item.score for item in with_web.reddit + with_web.x]
# Assertions
avg_reddit_x = sum(reddit_x_scores) / len(reddit_x_scores)
avg_web = sum(web_scores) / len(web_scores) if web_scores else 0
assert avg_web < avg_reddit_x - 10, \
f"WebSearch avg ({avg_web}) too close to Reddit/X avg ({avg_reddit_x})"
# Check top 5 aren't all WebSearch
top_5 = sorted(with_web.reddit + with_web.x + with_web.web,
key=lambda x: -x.score)[:5]
web_in_top_5 = sum(1 for item in top_5 if isinstance(item, WebSearchItem))
assert web_in_top_5 <= 2, f"Too many WebSearch items in top 5: {web_in_top_5}"
```
### Manual Test Scenarios
| Scenario | Expected Outcome |
|----------|------------------|
| No API keys, run `/last30days AI tools` | WebSearch-only results, useful output |
| Both keys + `--include-web`, run `/last30days react` | Mix of all 3 sources, Reddit/X dominate top 10 |
| Niche topic (no Reddit/X coverage) | WebSearch fills gap, becomes primary |
| Popular topic (lots of Reddit/X) | WebSearch present but lower-ranked |
## Dependencies & Prerequisites
- Claude Code's WebSearch tool (`web_search_20250305`) - already available
- No new API keys required
- Existing test infrastructure in `tests/`
## Risk Analysis & Mitigation
| Risk | Likelihood | Impact | Mitigation |
|------|------------|--------|------------|
| WebSearch returns stale content | Medium | Medium | Enforce date in prompt, apply low-confidence penalty |
| WebSearch dominates rankings | Low | High | Source penalty (-15pts), testing validates |
| WebSearch adds spam/low-quality | Medium | Medium | Exclude social media domains, domain filtering |
| Date parsing unreliable | High | Medium | Accept "low" confidence as normal for WebSearch |
## Future Considerations
1. **Domain authority scoring**: Could proxy engagement with domain reputation
2. **User-configurable weights**: Let users adjust WebSearch penalty
3. **Domain whitelist/blacklist**: Filter WebSearch to trusted sources
4. **Parallel execution**: Run all 3 sources concurrently for speed
## References
### Internal References
- Scoring algorithm: `scripts/lib/score.py:8-15`
- Source detection: `scripts/lib/env.py:57-72`
- Schema patterns: `scripts/lib/schema.py:76-138`
- Orchestrator: `scripts/last30days.py:54-164`
### External References
- Claude WebSearch docs: https://platform.claude.com/docs/en/agents-and-tools/tool-use/web-search-tool
- WebSearch pricing: $10/1K searches + token costs
- Date filtering limitation: No explicit date params, use natural language
### Research Findings
- Reddit upvotes are ~12% of ranking value in SEO (strong signal)
- E-E-A-T framework: Engagement metrics = trust signal
- MSA2C2 approach: Dynamic weight learning for multi-source aggregation
+328
View File
@@ -0,0 +1,328 @@
# fix: Enforce Strict 30-Day Date Filtering
## Overview
The `/last30days` skill is returning content older than 30 days, violating its core promise. Analysis shows:
- **Reddit**: Only 40% of results within 30 days (9/15 were older, some from 2022!)
- **X**: 100% within 30 days (working correctly)
- **WebSearch**: 90% had unknown dates (can't verify freshness)
## Problem Statement
The skill's name is "last30days" - users expect ONLY content from the last 30 days. Currently:
1. **Reddit search prompt** says "prefer recent threads, but include older relevant ones if recent ones are scarce" - this is too permissive
2. **X search prompt** explicitly includes `from_date` and `to_date` - this is why it works
3. **WebSearch** returns pages without publication dates - we can't verify they're recent
4. **Scoring penalties** (-10 for low date confidence) don't prevent old content from appearing
## Proposed Solution
### Strategy: "Hard Filter, Not Soft Penalty"
Instead of penalizing old content, **exclude it entirely**. If it's not from the last 30 days, it shouldn't appear.
| Source | Current Behavior | New Behavior |
|--------|------------------|--------------|
| Reddit | Weak "prefer recent" | Explicit date range + hard filter |
| X | Explicit date range (working) | No change needed |
| WebSearch | No date awareness | Require recent markers OR exclude |
## Technical Approach
### Phase 1: Fix Reddit Date Filtering
**File: `scripts/lib/openai_reddit.py`**
Current prompt (line 33):
```
Find {min_items}-{max_items} relevant Reddit discussion threads.
Prefer recent threads, but include older relevant ones if recent ones are scarce.
```
New prompt:
```
Find {min_items}-{max_items} relevant Reddit discussion threads from {from_date} to {to_date}.
CRITICAL: Only include threads posted within the last 30 days (after {from_date}).
Do NOT include threads older than {from_date}, even if they seem relevant.
If you cannot find enough recent threads, return fewer results rather than older ones.
```
**Changes needed:**
1. Add `from_date` and `to_date` parameters to `search_reddit()` function
2. Inject dates into `REDDIT_SEARCH_PROMPT` like X does
3. Update caller in `last30days.py` to pass dates
### Phase 2: Add Hard Date Filtering (Post-Processing)
**File: `scripts/lib/normalize.py`**
Add a filter step that DROPS items with dates before `from_date`:
```python
def filter_by_date_range(
items: List[Union[RedditItem, XItem, WebSearchItem]],
from_date: str,
to_date: str,
require_date: bool = False,
) -> List:
"""Hard filter: Remove items outside the date range.
Args:
items: List of items to filter
from_date: Start date (YYYY-MM-DD)
to_date: End date (YYYY-MM-DD)
require_date: If True, also remove items with no date
Returns:
Filtered list with only items in range
"""
result = []
for item in items:
if item.date is None:
if not require_date:
result.append(item) # Keep unknown dates (with penalty)
continue
# Hard filter: if date is before from_date, exclude
if item.date < from_date:
continue # DROP - too old
if item.date > to_date:
continue # DROP - future date (likely parsing error)
result.append(item)
return result
```
### Phase 3: WebSearch Date Intelligence
WebSearch CAN find recent content - Medium posts have dates, GitHub has commit timestamps, news sites have publication dates. We should **extract and prioritize** these signals.
**Strategy: "Date Detective"**
1. **Extract dates from URLs**: Many sites embed dates in URLs
- Medium: `medium.com/@author/title-abc123` (no date) vs news sites
- GitHub: Look for commit dates, release dates in snippets
- News: `/2026/01/24/article-title`
- Blogs: `/blog/2026/01/title`
2. **Extract dates from snippets**: Look for date markers
- "January 24, 2026", "Jan 2026", "yesterday", "this week"
- "Published:", "Posted:", "Updated:"
- Relative markers: "2 days ago", "last week"
3. **Prioritize results with verifiable dates**:
- Results with recent dates (within 30 days): Full score
- Results with old dates: EXCLUDE
- Results with no date signals: Heavy penalty (-20) but keep as supplementary
**File: `scripts/lib/websearch.py`**
Add date extraction functions:
```python
import re
from datetime import datetime, timedelta
# Patterns for date extraction
URL_DATE_PATTERNS = [
r'/(\d{4})/(\d{2})/(\d{2})/', # /2026/01/24/
r'/(\d{4})-(\d{2})-(\d{2})/', # /2026-01-24/
r'/(\d{4})(\d{2})(\d{2})/', # /20260124/
]
SNIPPET_DATE_PATTERNS = [
r'(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)[a-z]* (\d{1,2}),? (\d{4})',
r'(\d{1,2}) (Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)[a-z]* (\d{4})',
r'(\d{4})-(\d{2})-(\d{2})',
r'Published:?\s*(\d{4}-\d{2}-\d{2})',
r'(\d{1,2}) (days?|hours?|minutes?) ago', # Relative dates
]
def extract_date_from_url(url: str) -> Optional[str]:
"""Try to extract a date from URL path."""
for pattern in URL_DATE_PATTERNS:
match = re.search(pattern, url)
if match:
# Parse and return YYYY-MM-DD format
...
return None
def extract_date_from_snippet(snippet: str) -> Optional[str]:
"""Try to extract a date from text snippet."""
for pattern in SNIPPET_DATE_PATTERNS:
match = re.search(pattern, snippet, re.IGNORECASE)
if match:
# Parse and return YYYY-MM-DD format
...
return None
def extract_date_signals(url: str, snippet: str, title: str) -> tuple[Optional[str], str]:
"""Extract date from any available signal.
Returns: (date_string, confidence)
- date from URL: 'high' confidence
- date from snippet: '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'
```
**Update WebSearch parsing to use date extraction:**
```python
def parse_websearch_results(results, topic, from_date, to_date):
items = []
for result in results:
url = result.get('url', '')
snippet = result.get('snippet', '')
title = result.get('title', '')
# Extract date signals
extracted_date, confidence = extract_date_signals(url, snippet, title)
# Hard filter: if we found a date and it's too old, skip
if extracted_date and extracted_date < from_date:
continue # DROP - verified old content
item = {
'date': extracted_date,
'date_confidence': confidence,
...
}
items.append(item)
return items
```
**File: `scripts/lib/score.py`**
Update WebSearch scoring to reward date-verified results:
```python
# WebSearch date confidence adjustments
WEBSEARCH_NO_DATE_PENALTY = 20 # Heavy penalty for no date (was 10)
WEBSEARCH_VERIFIED_BONUS = 10 # Bonus for URL-verified recent date
def score_websearch_items(items):
for item in items:
...
# Date confidence adjustments
if item.date_confidence == 'high':
overall += WEBSEARCH_VERIFIED_BONUS # Reward verified dates
elif item.date_confidence == 'low':
overall -= WEBSEARCH_NO_DATE_PENALTY # Heavy penalty for unknown
...
```
**Result**: WebSearch results with verifiable recent dates rank well. Results with no dates are heavily penalized but still appear as supplementary context. Old verified content is excluded entirely.
### Phase 4: Update Statistics Display
Only count Reddit and X in "from the last 30 days" claim. WebSearch should be clearly labeled as supplementary.
## Acceptance Criteria
### Functional Requirements
- [x] Reddit search prompt includes explicit `from_date` and `to_date`
- [x] Items with dates before `from_date` are EXCLUDED, not just penalized
- [x] X search continues working (no regression)
- [x] WebSearch extracts dates from URLs (e.g., `/2026/01/24/`)
- [x] WebSearch extracts dates from snippets (e.g., "January 24, 2026")
- [x] WebSearch with verified recent dates gets +10 bonus
- [x] WebSearch with no date signals gets -20 penalty (but still appears)
- [x] WebSearch with verified OLD dates is EXCLUDED
### Non-Functional Requirements
- [ ] No increase in API latency
- [ ] Graceful handling when few recent results exist (return fewer, not older)
- [ ] Clear user messaging when results are limited due to strict filtering
### Quality Gates
- [ ] Test: Reddit search returns 0% results older than 30 days
- [ ] Test: X search continues to return 100% recent results
- [ ] Test: WebSearch is clearly differentiated in output
- [ ] Test: Edge case - topic with no recent content shows helpful message
## Implementation Order
1. **Phase 1**: Fix Reddit prompt (highest impact, simple change)
2. **Phase 2**: Add hard date filter in normalize.py (safety net)
3. **Phase 3**: Add WebSearch date extraction (URL + snippet parsing)
4. **Phase 4**: Update WebSearch scoring (bonus for verified, heavy penalty for unknown)
5. **Phase 5**: Update output display to show date confidence
## Testing Plan
### Before/After Test
Run same query before and after fix:
```
/last30days remotion launch videos
```
**Expected Before:**
- Reddit: 40% within 30 days
**Expected After:**
- Reddit: 100% within 30 days (or fewer results if not enough recent content)
### Edge Case Tests
| Scenario | Expected Behavior |
|----------|-------------------|
| Topic with no recent content | Return 0 results + helpful message |
| Topic with 5 recent results | Return 5 results (not pad with old ones) |
| Mixed old/new results | Only return new ones |
### WebSearch Date Extraction Tests
| URL/Snippet | Expected Date | Confidence |
|-------------|---------------|------------|
| `medium.com/blog/2026/01/15/title` | 2026-01-15 | high |
| `github.com/repo` + "Released Jan 20, 2026" | 2026-01-20 | med |
| `docs.example.com/guide` (no date signals) | None | low |
| `news.site.com/2024/05/old-article` | 2024-05-XX | EXCLUDE (too old) |
| Snippet: "Updated 3 days ago" | calculated | med |
## Risk Analysis
| Risk | Likelihood | Impact | Mitigation |
|------|------------|--------|------------|
| Fewer results for niche topics | High | Medium | Explain why in output |
| User confusion about reduced results | Medium | Low | Clear messaging |
| Date parsing errors exclude valid content | Low | Medium | Keep items with unknown dates, just label clearly |
## References
### Internal References
- Reddit search: `scripts/lib/openai_reddit.py:25-63`
- X search (working example): `scripts/lib/xai_x.py:26-55`
- Date confidence: `scripts/lib/dates.py:62-90`
- Scoring penalties: `scripts/lib/score.py:149-153`
- Normalization: `scripts/lib/normalize.py:49,99`
### External References
- OpenAI Responses API lacks native date filtering
- Must rely on prompt engineering + post-processing
+62 -73
View File
@@ -1,86 +1,75 @@
The AI world reinvents itself every month. This skill keeps you current.
`/last30days` researches your topic across Reddit, X, YouTube, TikTok, Instagram, Hacker News, Polymarket, GitHub, and 5+ more 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.
## v3 is the intelligent search release
v3 is a ground-up engine rewrite 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.
Type "OpenClaw" and v3 resolves @steipete, r/openclaw, r/ClaudeCode, and the right YouTube channels and TikTok hashtags before a single API call fires. Type "Peter Steinberger" and it resolves his X handle and GitHub profile, switches to person mode, and shows what he shipped this month at 85% merge rate across 22 PRs. None of that was on Google.
## Headline features
### Intelligent pre-research
The killer feature. A new Python pre-research brain resolves X handles, GitHub repos, subreddits, TikTok hashtags, and YouTube channels before searching. Bidirectional: person to company, product to founder, name to GitHub profile. The right subreddits, the right handles, the right hashtags, all resolved before a single API call.
### Best Takes
A second LLM judge scores every result for humor, wit, and virality alongside relevance. Every brief now ends with a Best Takes section surfacing the cleverest one-liners and most viral quotes. The Reddit and X people are funny, and the old engine buried their best stuff.
### Cross-source cluster merging
When the same story hits Reddit, X, and YouTube, v3 merges them into one cluster instead of three duplicates. Entity-based overlap detection catches matches even when the titles use different words.
### Single-pass comparisons
"X vs Y" used to run three serial passes (12+ minutes). v3 runs one pass with entity-aware subqueries for both sides at once. Same depth, 3 minutes.
### GitHub person-mode and project-mode
When the topic is a person, the engine switches from keyword search to author-scoped queries. PR velocity, top repos by stars, release notes for what shipped this month, woven into the narrative alongside X posts and Reddit threads.
When the topic is a project, it pulls live star counts, READMEs, releases, and top issues from the GitHub API. No stale blog posts.
### 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. Say "eli5 off" to go back.
### 13+ sources
v3 adds Threads, Pinterest, Perplexity, Bluesky, and Parallel AI grounding to the existing Reddit, X, YouTube, TikTok, Instagram, Hacker News, Polymarket, GitHub, and Web lineup. Perplexity Deep Research (`--deep-research`) gives you 50+ citation reports for serious investigation.
### Per-author cap and entity disambiguation
Max 3 items per author prevents single-voice dominance. Synthesis trusts resolved handles over fuzzy keyword matches.
## Install
Claude Code:
```
/plugin marketplace add mvanhorn/last30days-skill
```
OpenClaw:
```
clawhub install last30days-official
```
OpenAI Codex CLI: run `codex` from a checkout of this repo and v3's skill at `.agents/skills/last30days/SKILL.md` will be discovered automatically. Or copy `SKILL.md` to `~/.agents/skills/last30days/SKILL.md` for a global install.
Zero config. Reddit, Hacker News, Polymarket, and GitHub work immediately. Run it once and the setup wizard unlocks X, YouTube, TikTok, and more in 30 seconds.
`/last30days` researches your topic across **Reddit, X, YouTube, TikTok, Instagram, Hacker News, Polymarket, and the web** 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.
## v3 Community
v3 was shaped by community contributors whose PRs and issues inspired core features. Their code wasn't merged directly (v3 was a ground-up rewrite), but their ideas drove what shipped.
v3 was shaped by community contributors whose PRs and issues inspired core features. Their code wasn't merged directly (v3 was a ground-up rewrite), but their ideas drove what shipped. See [CONTRIBUTORS.md](CONTRIBUTORS.md) for the full list.
Thanks to @uppinote20, @zerone0x, @thinkun, @thomasmktong, @fanispoulinakisai-boop, @pejmanjohn, @zl190, and @hnshah. See [CONTRIBUTORS.md](CONTRIBUTORS.md) for the full list.
Thanks to @uppinote20, @zerone0x, @thinkun, @thomasmktong, @fanispoulinakisai-boop, @pejmanjohn, @zl190, and @hnshah.
Contributors who shaped the release itself:
## What's New in v2.9.1
- @Jah-yee (#153) surfaced the need for a real Codex CLI integration, which shipped in #219
- @Cody-Coyote (#204) reported the marketplace validation bug that needed fixing before v3 could ship cleanly
- @dannyshmueli pushed for v3 and Codex family support publicly on X
**Auto-save to ~/Documents/Last30Days/.** Every run now saves the complete research briefing - synthesis, stats, and follow-up suggestions - as a topic-named `.md` file to your Documents folder. Build a personal research library without lifting a finger. Inspired by [@devin_explores](https://x.com/devin_explores) who was already doing this manually.
Full Added / Changed / Fixed detail lives in [CHANGELOG.md](CHANGELOG.md) under `[3.0.0]`.
## Three Headline Features in v2.9
## Earlier contributors
**1. ScrapeCreators Reddit as default.** One `SCRAPECREATORS_API_KEY` now covers Reddit, TikTok, and Instagram - three sources, one key. No more `OPENAI_API_KEY` required for Reddit search. Faster, more reliable, and simpler to configure.
From the v1 and v2 lineage:
**2. Smart subreddit discovery.** Relevance-weighted scoring replaces pure frequency count. Each candidate subreddit is scored by `frequency x recency x topic-word match`, and a `UTILITY_SUBS` blocklist filters noise subs like r/tipofmytongue. Search "Claude Code skills" and get r/ClaudeAI, r/ClaudeCode, r/openclaw - not generic programming subs.
- [@galligan](https://github.com/galligan) for marketplace plugin inspiration
- [@hutchins](https://x.com/hutchins) for pushing the YouTube feature
**3. Top comments elevated.** The best comment on each Reddit thread now carries a 10% weight in engagement scoring and displays prominently with upvote counts. Reddit's value is in the comments - now the skill surfaces them.
30 days of research. 30 seconds of work. Thirteen sources. Zero stale prompts.
Plus: **Instagram Reels** (v2.8), **Polymarket prediction markets** (v2.5), **YouTube transcripts** (v2.1), **bundled X search** - no external CLI needed.
## Beta Test Results (v2.9)
| 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
### Added
- ScrapeCreators Reddit backend with keyword search and subreddit discovery
- Smart subreddit discovery with relevance-weighted scoring
- Utility subreddit blocklist (`UTILITY_SUBS`)
- Top comment scoring (10% engagement weight) and prominent rendering
- Comment excerpts increased to 400 chars, insights raised to 10
### Changed
- `primaryEnv``SCRAPECREATORS_API_KEY` (one key for Reddit, TikTok, Instagram)
- Reddit engagement scoring: `0.55/0.40/0.05``0.50/0.35/0.05/0.10`
- SKILL.md synthesis instructions emphasize quoting top comments
### Fixed
- Utility sub noise in subreddit discovery
- Reddit no longer requires `OPENAI_API_KEY`
## New Contributors
- @JosephOIbrahim -- Windows Unicode fix ([#17](https://github.com/mvanhorn/last30days-skill/pull/17))
- @levineam -- Model fallback for unverified orgs ([#16](https://github.com/mvanhorn/last30days-skill/pull/16))
- @jonthebeef -- `--days=N` configurable lookback ([#18](https://github.com/mvanhorn/last30days-skill/pull/18))
## Credits
- [@steipete](https://github.com/steipete) -- Bird CLI (vendored X search) and yt-dlp/summarize inspiration for YouTube transcripts
- [@galligan](https://github.com/galligan) -- Marketplace plugin inspiration
- [@hutchins](https://x.com/hutchins) -- Pushed for YouTube feature
## Install
```bash
# Claude Code
git clone https://github.com/mvanhorn/last30days-skill.git ~/.claude/skills/last30days
# Codex CLI
git clone https://github.com/mvanhorn/last30days-skill.git ~/.agents/skills/last30days
```
30 days of research. 30 seconds of work. Eight sources. Zero stale prompts.
-45
View File
@@ -1,45 +0,0 @@
#!/usr/bin/env bash
# build-skill.sh - package this repo as a claude.ai-upload-ready .skill file
# Usage: bash scripts/build-skill.sh (run from repo root)
#
# Produces dist/last30days.skill, a zip with a single top-level `last30days/`
# directory containing SKILL.md and the scripts/ runtime. See
# docs/plans/2026-04-14-001-fix-skill-upload-200-file-limit-plan.md.
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
cd "$REPO_ROOT"
if ! git diff --quiet || ! git diff --cached --quiet; then
echo "error: working tree is dirty; commit or stash before building" >&2
exit 1
fi
mkdir -p dist
OUT="dist/last30days.skill"
git archive --format=zip --prefix=last30days/ --output="$OUT" HEAD
# claude.ai's .skill bundle only needs the root SKILL.md + scripts/ runtime.
# Claude Code needs skills/ and .claude-plugin/ in the git archive
# (that's why they're NOT in .gitattributes export-ignore), but the .skill
# bundle must strip them to keep a single canonical SKILL.md and stay under
# the 200-file cap.
zip -d "$OUT" "last30days/skills/*" "last30days/.claude-plugin/*" > /dev/null 2>&1 || true
COUNT=$(unzip -l "$OUT" | tail -1 | awk '{print $2}')
SIZE=$(du -h "$OUT" | cut -f1)
if [ "$COUNT" -gt 200 ]; then
echo "error: $COUNT files in zip, claude.ai's cap is 200" >&2
echo " check .gitattributes export-ignore entries and this script's zip -d excludes" >&2
exit 1
fi
SKILL_MD_COUNT=$(unzip -l "$OUT" | grep -c "SKILL.md" || true)
if [ "$SKILL_MD_COUNT" -ne 1 ]; then
echo "error: expected exactly one SKILL.md, found $SKILL_MD_COUNT" >&2
exit 1
fi
echo "built $OUT ($COUNT files, $SIZE)"
echo "upload via the claude.ai skill UI"
+21 -23
View File
@@ -1,13 +1,14 @@
#!/bin/bash
# A/B test runner: public release vs private beta
# A/B/C test runner for last30days skill variants
# Usage: bash scripts/compare.sh "Kanye West"
#
# Runs /last30days (public release) and /last30days-beta (private beta)
# sequentially with a 30s gap, saves raw results with distinct suffixes,
# prints file paths for comparison.
# 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 <topic>"
echo " Example: bash scripts/compare.sh Kevin Rose"
@@ -15,47 +16,44 @@ if [ $# -eq 0 ]; then
fi
TOPIC="$*"
SLUG=$(echo "$TOPIC" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/-/g' | sed 's/--*/-/g' | sed 's/^-//' | sed 's/-$//')
LAST30DAYS_MEMORY_DIR="${LAST30DAYS_MEMORY_DIR:-$HOME/Documents/Last30Days}"
DIR="$LAST30DAYS_MEMORY_DIR"
DIR="$HOME/Documents/Last30Days"
DATE=$(date +%Y-%m-%d)
echo "=============================================="
echo " A/B Test: $TOPIC"
echo " A/B/C Test: $TOPIC"
echo " Date: $DATE"
echo "=============================================="
echo ""
# Run 1: public release
echo "[1/2] Running /last30days (public release)..."
# 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
RELEASE_FILE="$DIR/${SLUG}-raw.md"
[ -f "$RELEASE_FILE" ] && echo " Done: $RELEASE_FILE" || echo " FAILED: no output file"
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: private beta
echo "[2/2] Running /last30days-beta (private beta)..."
# Run 2: v3 Gemini
echo "[2/3] Running v3 (/last30days-3)..."
echo " This takes 2-4 minutes..."
claude -p --dangerously-skip-permissions "/last30days-beta $TOPIC" > /dev/null 2>&1 || true
BETA_FILE="$DIR/${SLUG}-raw-beta.md"
[ -f "$BETA_FILE" ] && echo " Done: $BETA_FILE" || echo " FAILED: no output file"
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)"
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 " $RELEASE_FILE"
echo " $BETA_FILE"
echo ""
echo "Beta output should start with a line like:"
echo " 🧪 last30days-beta · branch <name> · synced $DATE"
echo "If that line is missing, the beta badge regressed. See docs/plans/2026-04-17-005-*-plan.md."
echo " $DIR/${SLUG}-raw.md"
echo " $DIR/${SLUG}-raw-v3.md"
echo ""
+28 -533
View File
@@ -33,11 +33,6 @@ def ensure_supported_python(version_info: tuple[int, int, int] | object | None =
ensure_supported_python()
if os.name == "nt":
for stream in (sys.stdout, sys.stderr):
if hasattr(stream, "reconfigure"):
stream.reconfigure(encoding="utf-8", errors="replace")
SCRIPT_DIR = Path(__file__).parent.resolve()
sys.path.insert(0, str(SCRIPT_DIR))
@@ -108,65 +103,20 @@ def save_output(report: schema.Report, emit: str, save_dir: str, suffix: str = "
content = emit_output(report, emit)
else:
content = render.render_full(report)
out_path.write_text(content, encoding="utf-8")
out_path.write_text(content)
return out_path
def emit_output(report: schema.Report, emit: str, fun_level: str = "medium", save_path: str | None = None) -> str:
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, save_path=save_path)
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 emit_comparison_output(
entity_reports: list[tuple[str, schema.Report]],
emit: str,
fun_level: str = "medium",
save_path: str | None = None,
) -> str:
if emit == "json":
payload = {
"comparison": True,
"entities": [label for label, _ in entity_reports],
"reports": [
{"entity": label, "report": schema.to_dict(report)}
for label, report in entity_reports
],
}
return json.dumps(payload, indent=2, sort_keys=True)
if emit in {"compact", "md"}:
return render.render_comparison_multi(
entity_reports, fun_level=fun_level, save_path=save_path,
)
if emit == "context":
return render.render_comparison_multi_context(entity_reports)
raise SystemExit(f"Unsupported emit mode: {emit}")
def compute_save_path_display(save_dir: str, topic: str, suffix: str, emit: str) -> str:
"""Compute the user-friendly save path string that will be shown in the footer.
Uses ~ when the saved file is under the user's home directory; otherwise
returns the absolute path.
"""
from pathlib import Path as _Path
path = _Path(save_dir).expanduser().resolve()
slug = slugify(topic)
extension = "json" if emit == "json" else "md"
suffix_part = f"-{suffix}" if suffix else ""
raw = path / f"{slug}-raw{suffix_part}.{extension}"
try:
home = _Path.home().resolve()
relative = raw.relative_to(home)
return f"~/{relative}"
except ValueError:
return str(raw)
def persist_report(report: schema.Report) -> dict[str, int]:
import store
@@ -215,226 +165,15 @@ def build_parser() -> argparse.ArgumentParser:
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(
"--days",
"--lookback-days",
dest="lookback_days",
type=int,
default=30,
help="Number of days to look back for research (default: 30, watchlist uses 90)",
)
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)")
parser.add_argument(
"--competitors",
nargs="?",
const=2,
type=int,
default=None,
metavar="N",
help="Auto-discover N competitor entities and fan out last30days across all of them as a comparison (default N=2 → 3-way: original + 2 peers; range 1..6). Use --competitors-list to override discovery.",
)
parser.add_argument(
"--competitors-list",
dest="competitors_list",
help="Comma-separated competitor entities to skip discovery (e.g., 'Anthropic,xAI,Google Gemini'). Implies --competitors.",
)
parser.add_argument(
"--polymarket-keywords",
dest="polymarket_keywords",
help=(
"Comma-separated keywords that Polymarket market titles must match "
"to be included. Use for ambiguous single-token topics like 'Warriors' "
"(nba,gsw,golden-state) to filter out Glasgow Warriors rugby, Honor "
"of Kings Rogue Warriors, etc. When omitted, Polymarket returns all "
"matching markets — so expect cross-entity noise on generic topics."
),
)
parser.add_argument(
"--competitors-plan",
dest="competitors_plan",
help=(
"JSON mapping of per-entity Step 0.55 targeting for competitor / vs-mode "
"sub-runs. Schema: {entity_name: {x_handle?, x_related?, subreddits?, "
"github_user?, github_repos?, context?}}. Accepts inline JSON or a file "
"path. Implies --competitors. Preferred over --competitors-list when the "
"hosting model has already resolved per-entity handles and subs."
),
)
parser.add_argument("--podcast-channels", help="Comma-separated YouTube @handles for podcast transcript scanning (e.g., AcquiredFM,lexfridman,DwarkeshPatel)")
return parser
def parse_competitors_plan(raw: str | None) -> dict[str, dict]:
"""Parse a --competitors-plan argument into a {entity_name_lower: plan_entry} dict.
Accepts inline JSON or a file path (matches --plan). Returns {} on None/empty.
Validation: top-level must be a dict; each value must be a dict. Unknown fields
in entry values log a warning but do not abort. Invalid JSON or non-dict shape
raises SystemExit(2) with a clear stderr message.
"""
if not raw:
return {}
plan_str = raw
if os.path.isfile(plan_str):
try:
plan_str = open(plan_str).read()
except OSError as exc:
sys.stderr.write(f"[CompetitorsPlan] Cannot read plan file: {exc}\n")
raise SystemExit(2)
try:
parsed = json.loads(plan_str)
except json.JSONDecodeError as exc:
sys.stderr.write(f"[CompetitorsPlan] Invalid JSON: {exc}\n")
raise SystemExit(2)
if not isinstance(parsed, dict):
sys.stderr.write(
f"[CompetitorsPlan] Top-level must be a dict of "
f"{{entity: {{targeting}}}}, got {type(parsed).__name__}\n"
)
raise SystemExit(2)
known_fields = {
"x_handle", "x_related", "subreddits",
"github_user", "github_repos", "context",
}
normalized: dict[str, dict] = {}
for entity, entry in parsed.items():
if not isinstance(entry, dict):
sys.stderr.write(
f"[CompetitorsPlan] Entry for {entity!r} must be a dict, "
f"got {type(entry).__name__}; skipping.\n"
)
continue
unknown = set(entry.keys()) - known_fields
if unknown:
sys.stderr.write(
f"[CompetitorsPlan] Unknown fields in {entity!r}: "
f"{sorted(unknown)}; ignoring.\n"
)
normalized[entity.strip().lower()] = {
k: v for k, v in entry.items() if k in known_fields
}
return normalized
def subrun_kwargs_for(
entity: str,
plan_entry: dict,
*,
resolved: dict,
) -> dict:
"""Build an explicit per-entity kwargs dict for pipeline.run().
Plan values win over auto_resolve values. Returns keys for all per-entity
targeting flags so callers never fall through to closure defaults.
This helper is the single source of truth for sub-run kwargs main-topic
flags can only leak if a caller bypasses it.
"""
def _choose(plan_key: str, resolved_key: str | None = None):
if plan_key in plan_entry and plan_entry[plan_key]:
return plan_entry[plan_key]
if resolved_key is not None and resolved.get(resolved_key):
return resolved[resolved_key]
return None
x_handle = _choose("x_handle", "x_handle")
if isinstance(x_handle, str):
x_handle = x_handle.lstrip("@") or None
subreddits = _choose("subreddits", "subreddits")
if isinstance(subreddits, list):
subreddits = [s.strip().lstrip("r/") for s in subreddits if s.strip()] or None
x_related = plan_entry.get("x_related")
if isinstance(x_related, list):
x_related = [h.strip().lstrip("@") for h in x_related if h.strip()] or None
else:
x_related = None
github_user = _choose("github_user", "github_user")
if isinstance(github_user, str):
github_user = github_user.lstrip("@").lower() or None
github_repos = _choose("github_repos", "github_repos")
if isinstance(github_repos, list):
github_repos = [r.strip() for r in github_repos if r.strip() and "/" in r.strip()] or None
context = plan_entry.get("context") or resolved.get("context") or ""
return {
"x_handle": x_handle,
"x_related": x_related,
"subreddits": subreddits,
"github_user": github_user,
"github_repos": github_repos,
"_context": context,
}
COMPETITORS_MIN = 1
COMPETITORS_MAX = 6
COMPETITORS_DEFAULT = 2
def resolve_competitors_args(args: argparse.Namespace) -> tuple[bool, int, list[str]]:
"""Normalize --competitors / --competitors-list into (enabled, count, explicit_list).
- (False, 0, []) when neither flag is set.
- An explicit list always wins; count is derived from list length.
- A numeric count outside [1, 6] is clamped with a stderr warning.
- count <= 0 (explicit) raises SystemExit(2).
"""
explicit_list: list[str] = []
list_flag_provided = args.competitors_list is not None
if list_flag_provided:
explicit_list = [
entity.strip()
for entity in args.competitors_list.split(",")
if entity.strip()
]
if not explicit_list:
sys.stderr.write("[Competitors] --competitors-list is empty.\n")
raise SystemExit(2)
competitors_flag = args.competitors
list_present = bool(explicit_list)
flag_present = competitors_flag is not None
if not list_present and not flag_present:
return False, 0, []
if list_present:
count = len(explicit_list)
if flag_present and competitors_flag != count:
sys.stderr.write(
f"[Competitors] --competitors={competitors_flag} ignored; using "
f"{count} entries from --competitors-list.\n"
)
if count > COMPETITORS_MAX:
sys.stderr.write(
f"[Competitors] --competitors-list has {count} entries, clamping to {COMPETITORS_MAX}.\n"
)
explicit_list = explicit_list[:COMPETITORS_MAX]
count = COMPETITORS_MAX
return True, count, explicit_list
# flag_present, no explicit list
count = competitors_flag
if count < COMPETITORS_MIN:
sys.stderr.write(
f"[Competitors] --competitors must be >= {COMPETITORS_MIN} (got {count}).\n"
)
raise SystemExit(2)
if count > COMPETITORS_MAX:
sys.stderr.write(
f"[Competitors] --competitors={count} exceeds max {COMPETITORS_MAX}; clamping.\n"
)
count = COMPETITORS_MAX
return True, count, []
def _missing_sources_for_promo(diag: dict[str, object]) -> str | None:
available = set(diag.get("available_sources") or [])
missing = []
@@ -451,12 +190,7 @@ def _missing_sources_for_promo(diag: dict[str, object]) -> str | None:
return missing[0]
def _show_runtime_ui(
report: schema.Report,
progress: ui.ProgressDisplay,
diag: dict[str, object],
suppress_web_promo: bool = False,
) -> None:
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(
@@ -473,19 +207,7 @@ def _show_runtime_ui(
display_sources=display_sources,
)
promo = _missing_sources_for_promo(diag)
# The `web` promo nudges users to set BRAVE_API_KEY / SERPER_API_KEY, which
# is wrong advice when a hosting reasoning model (Claude Code, Codex,
# Hermes, Gemini) is driving — those already have WebSearch and can
# pre-resolve Step 0.55 themselves. Suppress the web promo when a hosting
# model signal is present (--plan or --competitors-plan was passed).
if promo:
if suppress_web_promo and promo == "web":
return
if suppress_web_promo and promo == "both":
# "both" means reddit + web both missing; still nudge reddit but
# skip the web line. show_promo has a per-source variant.
progress.show_promo("reddit", diag=diag)
return
progress.show_promo(promo, diag=diag)
@@ -537,13 +259,6 @@ def main() -> int:
parser.print_usage(sys.stderr)
return 2
if not os.environ.get("LAST30DAYS_SKIP_PREFLIGHT"):
from lib import preflight
refuse_msg = preflight.check_class_1_trap(topic)
if refuse_msg:
sys.stderr.write(refuse_msg)
return 2
progress = ui.ProgressDisplay(topic, show_banner=True)
progress.start_processing()
@@ -594,6 +309,7 @@ def main() -> int:
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
podcast_channels = [c.strip().lstrip("@") for c in args.podcast_channels.split(",") if c.strip()] if args.podcast_channels else None
# --deep-research: auto-enable perplexity source and set deep flag
if args.deep_research:
@@ -606,214 +322,30 @@ def main() -> int:
if "perplexity" not in include.lower():
config["INCLUDE_SOURCES"] = f"{include},perplexity" if include else "perplexity"
comp_enabled, comp_count, comp_explicit = resolve_competitors_args(args)
comp_plan = parse_competitors_plan(args.competitors_plan)
# Polymarket disambiguation: if user passed --polymarket-keywords,
# store on config so the polymarket adapter can filter matches.
if args.polymarket_keywords:
keywords = [
k.strip().lower()
for k in args.polymarket_keywords.split(",")
if k.strip()
]
if keywords:
config["_polymarket_keywords"] = keywords
# vs-mode: if the topic string contains " vs " / " versus " and the
# planner can split it into >=2 entities, route through the same
# N-pass fanout path as --competitors. The first entity becomes the
# main topic; remaining entities become the competitor list. User's
# outer --x-handle / --subreddits apply to the first entity unless
# --competitors-plan covers it.
from lib import planner as _planner
vs_entities = _planner._comparison_entities(topic)
if len(vs_entities) >= 2 and not comp_enabled:
topic = vs_entities[0]
comp_enabled = True
comp_count = len(vs_entities) - 1
comp_explicit = vs_entities[1:]
sys.stderr.write(
f"[Competitors] vs-mode: routing to N-pass fanout: "
f"{' vs '.join(vs_entities)}\n"
)
def _main_runner() -> schema.Report:
r = 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,
)
r.artifacts["resolved"] = {
"entity": topic,
"x_handle": (args.x_handle or "").lstrip("@"),
"subreddits": list(subreddits or []),
"github_user": (github_user or ""),
"github_repos": list(github_repos or []),
"context": config.get("_auto_resolve_context", "") or "",
}
return r
if comp_enabled:
from lib import competitors as competitors_mod
from lib import fanout, resolve as resolve_mod
if comp_explicit:
discovered = comp_explicit
else:
if not resolve_mod._has_backend(config) and not args.mock:
sys.stderr.write(
"[Competitors] Cannot auto-discover peers without help.\n"
"\n"
"RECOMMENDED PATH (hosting reasoning models — Claude Code, Codex, "
"Hermes, Gemini, any agent with a WebSearch tool): YOU have "
"WebSearch. Use it to run full Step 0.55 per entity, then invoke "
"the engine with a vs-topic plus --competitors-plan:\n"
" 1. WebSearch for '{topic} competitors' or '{topic} alternatives'.\n"
" 2. For each peer, WebSearch for handles/subs/github (Step 0.55).\n"
" 3. Re-invoke: /last30days '{topic} vs {peer1} vs {peer2}' "
"--competitors-plan '{\"Peer1\":{\"x_handle\":\"h1\",\"subreddits\":"
"[\"s1\"],...},\"Peer2\":{...}}'.\n"
"See SKILL.md 'Competitor mode' for the full protocol.\n"
"\n"
"HEADLESS / CRON PATH (no hosting model available): set "
"BRAVE_API_KEY / EXA_API_KEY / SERPER_API_KEY / PARALLEL_API_KEY / "
"OPENROUTER_API_KEY and re-run.\n"
"\n"
"MINIMUM ESCAPE HATCH: pass --competitors-list 'A,B,C' to skip "
"discovery. Without --competitors-plan, peer sub-runs fall back to "
"planner defaults and produce visibly thinner data than the main.\n"
)
return 2
discovered = competitors_mod.discover_competitors(
topic, comp_count, config, lookback_days=args.lookback_days,
)
if not discovered:
sys.stderr.write(
f"[Competitors] No peers discovered for {topic!r}; aborting "
"comparison run. Pass --competitors-list to override.\n"
)
return 2
sys.stderr.write(
f"[Competitors] Comparing: {topic} vs " + " vs ".join(discovered) + "\n"
)
def _competitor_runner(entity: str) -> schema.Report:
# Deep-copy config so per-entity auto_resolve context does not
# leak across sub-runs. Each sub-run writes its own
# `_auto_resolve_context` into its local config copy.
entity_config = dict(config)
plan_entry = comp_plan.get(entity.strip().lower(), {})
resolved = {
"entity": entity,
"x_handle": "",
"subreddits": [],
"github_user": "",
"github_repos": [],
"context": "",
}
# Skip engine-internal auto_resolve when the hosting model
# pre-resolved via --competitors-plan (saves a redundant
# round-trip and makes per-entity Step 0.55 purely
# hosting-model-driven).
plan_covers_fully = bool(plan_entry.get("x_handle")) and bool(
plan_entry.get("subreddits")
)
if (
not args.mock
and not plan_covers_fully
and resolve_mod._has_backend(entity_config)
):
try:
r = resolve_mod.auto_resolve(entity, entity_config)
except Exception as exc:
sys.stderr.write(
f"[Competitors] auto_resolve failed for {entity!r}: "
f"{type(exc).__name__}: {exc}\n"
)
r = {}
resolved["x_handle"] = r.get("x_handle", "") or ""
resolved["subreddits"] = list(r.get("subreddits") or [])
resolved["github_user"] = r.get("github_user", "") or ""
resolved["github_repos"] = list(r.get("github_repos") or [])
resolved["context"] = r.get("context", "") or ""
kwargs = subrun_kwargs_for(entity, plan_entry, resolved=resolved)
# Record effective per-entity targeting for the Resolved block.
resolved_effective = {
"entity": entity,
"x_handle": kwargs["x_handle"] or "",
"subreddits": kwargs["subreddits"] or [],
"github_user": kwargs["github_user"] or "",
"github_repos": kwargs["github_repos"] or [],
"context": kwargs["_context"],
}
if kwargs["_context"]:
entity_config["_auto_resolve_context"] = kwargs["_context"]
sys.stderr.write(
f"[Competitors] {entity}: "
f"x=@{resolved_effective['x_handle'] or '-'} "
f"subs={len(resolved_effective['subreddits'])} "
f"gh={resolved_effective['github_user'] or '-'} "
f"({'plan' if plan_entry else 'auto'})\n"
)
report = pipeline.run(
topic=entity,
config=entity_config,
depth=depth,
requested_sources=requested_sources,
mock=args.mock,
x_handle=kwargs["x_handle"],
x_related=kwargs["x_related"],
subreddits=kwargs["subreddits"],
github_user=kwargs["github_user"],
github_repos=kwargs["github_repos"],
web_backend=args.web_backend,
lookback_days=args.lookback_days,
internal_subrun=True,
)
report.artifacts["resolved"] = resolved_effective
return report
entity_reports = fanout.run_competitor_fanout(
main_topic=topic,
main_runner=_main_runner,
competitors=discovered,
competitor_runner=_competitor_runner,
)
if len(entity_reports) < 2:
progress.end_processing()
sys.stderr.write(
f"[Competitors] Fewer than 2 sub-runs survived ({len(entity_reports)}); "
"cannot render a comparison. Re-run without --competitors or check the "
"warnings above.\n"
)
return 1
report = entity_reports[0][1]
else:
entity_reports = None
report = _main_runner()
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,
podcast_channels=podcast_channels,
)
except Exception as exc:
progress.end_processing()
progress.show_error(str(exc))
raise
_show_runtime_ui(
report, progress, diag,
suppress_web_promo=bool(external_plan or comp_plan),
)
_show_runtime_ui(report, progress, diag)
if args.store:
counts = persist_report(report)
sys.stderr.write(
@@ -832,47 +364,10 @@ def main() -> int:
pass
fun_level = config.get("FUN_LEVEL", "medium").lower()
footer_save_path = None
rendered = emit_output(report, args.emit, fun_level=fun_level)
if args.save_dir:
footer_save_path = compute_save_path_display(
args.save_dir, report.topic, args.save_suffix or "", args.emit
)
# Signal to render_compact whether pre-research flags were supplied.
# Used to emit a Pre-Research Status warning when the model skipped
# Step 0.5 / 0.55 and invoked the engine bare on an eligible topic.
pre_research_flags_present = bool(
args.x_handle
or args.github_user
or args.subreddits
or args.plan
or args.auto_resolve
or args.tiktok_creators
or args.ig_creators
)
report.artifacts["pre_research_flags_present"] = pre_research_flags_present
if entity_reports:
rendered = emit_comparison_output(
entity_reports, args.emit, fun_level=fun_level, save_path=footer_save_path,
)
else:
rendered = emit_output(
report, args.emit, fun_level=fun_level, save_path=footer_save_path,
)
if args.save_dir:
# Save the main topic's raw file (single-entity or comparison main).
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")
# Competitor / vs-mode: also save a per-entity raw file for each peer.
# Matches historical vs-mode behavior (N passes → N save files).
if entity_reports and len(entity_reports) > 1:
for label, entity_report in entity_reports[1:]:
peer_path = save_output(
entity_report, args.emit, args.save_dir,
suffix=args.save_suffix or "",
)
sys.stderr.write(f"[last30days] Saved output to {peer_path}\n")
sys.stderr.flush()
print(rendered)
return 0
+1 -5
View File
@@ -177,8 +177,6 @@ def _run_bird_search(query: str, count: int, timeout: int) -> Dict[str, Any]:
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
encoding="utf-8",
errors="replace",
preexec_fn=preexec,
env=_subprocess_env(),
)
@@ -338,8 +336,6 @@ def search_handles(
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
encoding="utf-8",
errors="replace",
preexec_fn=preexec,
env=_subprocess_env(),
)
@@ -464,7 +460,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,
}
-283
View File
@@ -1,283 +0,0 @@
"""Category-peer subreddit map for Step 0.55 community resolution.
When a topic is a product in a known category (AI image generation, AI coding
agents, SaaS screen recording, etc.), brand-specific subreddits returned by
WebSearch are insufficient: cross-product technique discussion lives in
category-peer subs. This module classifies a topic into a category by matching
compound-term patterns against the lowercased topic string, then returns the
priority-ordered peer subreddit list for that category.
The map is intentionally small, curated, and code-reviewed. Adding a new
category is a code change; there is no user-editable override surface.
False-positive guard: every pattern is either a multi-word compound (e.g.
"image generation", "text to image") or a domain-specific single word
(e.g. "midjourney", "stablediffusion"). Bare common nouns like "image",
"ai", or "model" are never used as patterns.
First-match-wins: categories are evaluated in declared order. Entries are
sorted from most-specific to least-specific so narrower categories claim a
topic before broader ones. For example, `ai_image_generation` appears
before `ai_chat_model` so "gpt image 2" matches the image-gen category.
"""
from __future__ import annotations
from typing import List, Optional, TypedDict
class _CategoryEntry(TypedDict):
patterns: List[str]
peer_subs: List[str]
CATEGORY_PEERS: dict[str, _CategoryEntry] = {
"ai_image_generation": {
"patterns": [
"image generation",
"image gen",
"text to image",
"text-to-image",
"gpt image",
"gpt-image",
"nano banana",
"midjourney",
"stable diffusion",
"stablediffusion",
"dall-e",
"dalle",
"flux.1",
"flux schnell",
"imagen",
"seedance",
"ideogram",
"recraft",
],
"peer_subs": [
"StableDiffusion",
"midjourney",
"dalle2",
"aiArt",
"PromptEngineering",
"MediaSynthesis",
],
},
"ai_video_generation": {
"patterns": [
"video generation",
"text to video",
"text-to-video",
"sora",
"veo 3",
"veo3",
"runway gen",
"kling",
"pika labs",
"luma dream machine",
"hailuo",
],
"peer_subs": [
"aivideo",
"StableDiffusion",
"runwayml",
"singularity",
"MediaSynthesis",
],
},
"ai_music_generation": {
"patterns": [
"music generation",
"ai music",
"suno",
"udio",
"riffusion",
"stable audio",
],
"peer_subs": [
"SunoAI",
"udiomusic",
"aimusic",
"artificial",
],
},
"ai_coding_agent": {
"patterns": [
"claude code",
"cursor ide",
"github copilot",
"windsurf",
"aider",
"cline",
"openclaw",
"hermes agent",
"continue.dev",
"codeium",
"sweep ai",
"devin ai",
"coding agent",
"coding assistant",
],
"peer_subs": [
"ChatGPTCoding",
"LocalLLaMA",
"singularity",
"PromptEngineering",
],
},
"ai_agent_framework": {
"patterns": [
"agent framework",
"agentic framework",
"langchain",
"langgraph",
"crewai",
"autogen",
"llamaindex",
"dspy",
"smolagents",
],
"peer_subs": [
"LangChain",
"LocalLLaMA",
"AI_Agents",
"MachineLearning",
],
},
"ai_chat_model": {
"patterns": [
"gpt-5",
"gpt-4",
"claude opus",
"claude sonnet",
"claude haiku",
"gemini pro",
"gemini flash",
"llama 3",
"llama 4",
"deepseek",
"qwen",
"mistral large",
"grok",
],
"peer_subs": [
"LocalLLaMA",
"ChatGPT",
"ClaudeAI",
"singularity",
"artificial",
],
},
"saas_screen_recording": {
"patterns": [
"screen recording",
"screen recorder",
"loom video",
"tella screen",
"vidyard",
"screen capture tool",
],
"peer_subs": [
"SaaS",
"screenrecording",
"productivity",
"Entrepreneur",
],
},
"saas_productivity": {
"patterns": [
"notion app",
"obsidian plugin",
"obsidian app",
"linear app",
"asana",
"clickup",
"productivity app",
],
"peer_subs": [
"productivity",
"SaaS",
"ObsidianMD",
"Notion",
],
},
"prediction_markets": {
"patterns": [
"polymarket",
"kalshi",
"prediction market",
"event contracts",
"manifold markets",
],
"peer_subs": [
"Polymarket",
"Kalshi",
"predictionmarkets",
],
},
"crypto_defi": {
"patterns": [
"defi protocol",
"yield farming",
"liquidity pool",
"stablecoin",
"ethereum layer",
"layer 2",
"l2 rollup",
],
"peer_subs": [
"defi",
"ethfinance",
"CryptoCurrency",
"ethereum",
],
},
"dev_tool_cli": {
"patterns": [
"cli tool",
"command line tool",
"terminal app",
"dev tool",
],
"peer_subs": [
"commandline",
"programming",
"webdev",
],
},
}
def detect_category(topic: Optional[str]) -> Optional[str]:
"""Classify a topic into a known category by compound-term match.
Returns the category id (e.g. "ai_image_generation") or None if no
category's patterns match. Matching is case-insensitive substring over
the lowercased topic. Declaration order wins (first-match-wins), so the
map is ordered from most-specific to least-specific.
A None or empty topic returns None. Classification never raises on
normal string inputs; callers do not need to wrap in try/except for
typical paths, though defensive callers may.
"""
if not topic:
return None
lowered = topic.lower()
for category_id, entry in CATEGORY_PEERS.items():
for pattern in entry["patterns"]:
if pattern in lowered:
return category_id
return None
def peer_subs_for(category_id: Optional[str]) -> List[str]:
"""Return the priority-ordered peer subreddit list for a category.
Returns an empty list for None or unknown category ids. The returned
list is a fresh copy; callers may safely mutate it.
"""
if not category_id:
return []
entry = CATEGORY_PEERS.get(category_id)
if not entry:
return []
return list(entry["peer_subs"])
-199
View File
@@ -1,199 +0,0 @@
"""Discover peer entities ("competitors") for a topic via web search.
Mirrors the `resolve.auto_resolve()` pattern: fan out 2-3 web searches via
`grounding.web_search()`, then extract capitalized entity candidates from
titles and snippets with deterministic text mining. No LLM call the
hosting reasoning model can always override discovery via
`--competitors-list`.
Returned list is ordered by score (frequency across queries) and capped to
the caller's requested count.
"""
from __future__ import annotations
import re
import sys
from collections import Counter
from concurrent.futures import ThreadPoolExecutor, as_completed
from . import dates, grounding
from .resolve import _has_backend
# A "brand-shaped" token starts with uppercase OR is camelCase with an
# uppercase letter later. Catches "Anthropic", "OpenAI", "xAI", "iPhone",
# "eBay", "Hugging", "Face".
_BRAND_TOKEN = (
r"(?:[A-Z][A-Za-z0-9&.\-]*"
r"|[a-z][A-Za-z0-9&.\-]*[A-Z][A-Za-z0-9&.\-]*)"
)
# A capitalized phrase of 1-4 brand tokens separated by whitespace.
_CAPITALIZED_PHRASE = re.compile(
rf"\b{_BRAND_TOKEN}(?:\s+{_BRAND_TOKEN}){{0,3}}\b"
)
# Title-case fillers common in listicle SERPs. Kept flat — extraction
# rejects a candidate whose entire tokens are stopwords, not candidates
# that merely contain one.
_STOPWORD_TOKENS: frozenset[str] = frozenset(
token.lower()
for token in (
# Listicle fillers
"Top", "Best", "Worst", "Popular", "Leading", "Similar",
"Alternatives", "Alternative", "Competitor", "Competitors",
"vs", "Vs", "Versus", "Review", "Reviews", "Comparison",
"Guide", "List", "Lists", "Full", "Complete", "Free", "Paid",
"Tools", "Tool", "Options", "Rivals", "Rival", "Similar",
"Pick", "Picks", "Ranking", "Ranked", "Recommended",
# Grammar / time
"The", "A", "An", "Of", "In", "For", "To", "With", "On", "At",
"By", "From", "Is", "Are", "And", "Or", "But", "Than", "As",
"This", "That", "These", "Those", "Our", "Your", "Their",
"January", "February", "March", "April", "May", "June", "July",
"August", "September", "October", "November", "December",
# Years likely to appear as standalone tokens
*(str(year) for year in range(2018, 2031)),
# Miscellaneous SERP noise
"AI", "Apps", "App", "Software", "Platform", "Service", "Startups",
"Companies", "Company", "Products", "Product", "Brands", "Brand",
)
)
def _log(msg: str) -> None:
print(f"[Competitors] {msg}", file=sys.stderr)
def _topic_tokens(topic: str) -> set[str]:
"""Return lowercase alphanumeric tokens of the topic for filtering."""
return {tok for tok in re.findall(r"[A-Za-z0-9]+", topic.lower()) if tok}
def _candidate_ok(candidate: str, topic_tokens: set[str]) -> bool:
"""Filter a candidate phrase against stopwords and topic overlap."""
tokens = [t for t in re.findall(r"[A-Za-z0-9&.\-]+", candidate) if t]
if not tokens:
return False
# Reject candidates made entirely of stopwords (e.g., "Top Alternatives").
if all(tok.lower() in _STOPWORD_TOKENS for tok in tokens):
return False
# Reject candidates that overlap with the topic (e.g., topic="OpenAI"
# should not return "OpenAI Alternatives" or "OpenAI").
lower_tokens = {tok.lower() for tok in tokens}
if lower_tokens & topic_tokens:
return False
# Reject too-short one-letter tokens like "I" or single digits.
if len(tokens) == 1 and len(tokens[0]) < 2:
return False
return True
def _normalize_candidate(candidate: str) -> str:
"""Collapse whitespace and strip trailing punctuation."""
return re.sub(r"\s+", " ", candidate).strip(".,;:!?'\"()[] ")
def _extract_peer_entities(
items: list[dict], topic: str, limit: int,
) -> list[str]:
"""Score capitalized candidates across SERP items and return top `limit`.
Scoring is bag-of-phrases frequency across all items in the input. Ties
are broken by first-seen order so the output is deterministic.
"""
topic_tokens = _topic_tokens(topic)
counts: Counter[str] = Counter()
first_seen: dict[str, int] = {}
order = 0
# Group candidates into a frequency map keyed by lowercased normalized
# form so "xAI" and "xAI" count together regardless of case.
canonical: dict[str, str] = {}
for item in items:
text = f"{item.get('title', '')} {item.get('snippet', '')}"
for raw in _CAPITALIZED_PHRASE.findall(text):
candidate = _normalize_candidate(raw)
if not _candidate_ok(candidate, topic_tokens):
continue
key = candidate.lower()
if key not in canonical:
canonical[key] = candidate
first_seen[key] = order
order += 1
counts[key] += 1
ranked_keys = sorted(
counts.keys(),
key=lambda k: (-counts[k], first_seen[k]),
)
return [canonical[k] for k in ranked_keys[:limit]]
def _queries_for(topic: str) -> dict[str, str]:
return {
"competitors": f"{topic} competitors",
"alternatives": f"{topic} alternatives",
"vs": f"{topic} vs",
}
def discover_competitors(
topic: str,
count: int,
config: dict,
*,
lookback_days: int = 30,
) -> list[str]:
"""Discover `count` peer entities for `topic` via web search.
Args:
topic: The primary research topic.
count: Desired number of competitor entities (1..N).
config: Runtime config dict expects the same shape as the engine
config (BRAVE_API_KEY / EXA_API_KEY / SERPER_API_KEY / etc.).
lookback_days: Date range for freshness. Defaults to 30.
Returns:
A list of up to `count` entity names, deduped and ordered by score.
Empty list when no web backend is configured or every search fails
or returns zero usable candidates.
"""
if count < 1:
return []
if not _has_backend(config):
_log("No web search backend available, skipping competitor discovery")
return []
date_range = dates.get_date_range(lookback_days)
queries = _queries_for(topic)
collected: 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=len(queries)) 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()
collected.extend(items)
searches_run += 1
except Exception as exc:
_log(f"Search failed for {label}: {exc}")
if not collected:
_log(f"No SERP results for {topic!r} across {searches_run}/{len(queries)} queries")
return []
entities = _extract_peer_entities(collected, topic, limit=count)
_log(
f"Discovered {len(entities)} competitor(s) for {topic!r} "
f"from {searches_run}/{len(queries)} queries: {entities}"
)
return entities
+2 -30
View File
@@ -264,7 +264,7 @@ def get_config() -> dict[str, Any]:
('XQUIK_API_KEY', None),
('FROM_BROWSER', None),
('SETUP_COMPLETE', None),
('INCLUDE_SOURCES', ''),
('INCLUDE_SOURCES', None),
]
for key, default in keys:
@@ -356,10 +356,6 @@ def get_x_source_with_method(config: dict[str, Any]) -> tuple[str | None, str]:
if config.get("AUTH_TOKEN") and config.get("CT0"):
method = config.get("_AUTH_TOKEN_SOURCE", "env")
return "bird", method
# Fall back to xurl CLI (official X API v2, OAuth2, free developer app)
from . import xurl_x
if xurl_x.is_available():
return "xurl", "oauth2"
return None, "none"
@@ -405,7 +401,6 @@ def get_x_source(config: dict[str, Any]) -> str | None:
Returns:
'bird' if Bird is installed and explicit cookies are configured,
'xai' if XAI_API_KEY is configured,
'xurl' if xurl CLI is installed and authenticated,
None if no X source available.
"""
# Import here to avoid circular dependency
@@ -426,11 +421,6 @@ def get_x_source(config: dict[str, Any]) -> str | None:
if has_bird_creds and bird_x.is_bird_installed():
return 'bird'
# Fall back to xurl CLI (official X API v2, OAuth2, free developer app)
from . import xurl_x
if xurl_x.is_available():
return 'xurl'
return None
@@ -451,18 +441,6 @@ def is_youtube_comments_available(config: dict[str, Any]) -> bool:
return 'youtube_comments' in include
def is_tiktok_comments_available(config: dict[str, Any]) -> bool:
"""Check if TikTok comment enrichment is available.
Requires SCRAPECREATORS_API_KEY AND tiktok_comments in INCLUDE_SOURCES.
Mirrors the youtube_comments opt-in pattern.
"""
if not config.get('SCRAPECREATORS_API_KEY'):
return False
include = _parse_include_sources(config)
return 'tiktok_comments' in include
def is_youtube_sc_available(config: dict[str, Any]) -> bool:
"""Check if ScrapeCreators YouTube search fallback is available.
@@ -601,8 +579,6 @@ def get_x_source_status(config: dict[str, Any]) -> dict[str, Any]:
"""
from . import bird_x
if config.get('AUTH_TOKEN') and config.get('CT0'):
bird_x.set_credentials(config.get('AUTH_TOKEN'), config.get('CT0'))
bird_status = bird_x.get_bird_status()
xai_available = bool(config.get('XAI_API_KEY'))
@@ -612,18 +588,14 @@ def get_x_source_status(config: dict[str, Any]) -> dict[str, Any]:
elif xai_available:
source = 'xai'
else:
# Fall back to xurl CLI
from . import xurl_x as _xurl_check
source = 'xurl' if _xurl_check.is_available() else None
source = None
from . import xurl_x as _xurl_x
return {
"source": source,
"bird_installed": bird_status["installed"],
"bird_authenticated": bird_status["authenticated"],
"bird_username": bird_status["username"],
"xai_available": xai_available,
"xurl_available": _xurl_x.is_available(),
"can_install_bird": bird_status["can_install"],
}
-85
View File
@@ -1,85 +0,0 @@
"""Parallel multi-entity fan-out for the --competitors flag.
The orchestrator accepts a `main_runner()` for the topic and a
`competitor_runner(entity)` for each peer. It parallelizes their execution
via a `ThreadPoolExecutor` and collects per-entity Reports. Per-entity
failures are logged and dropped; the run survives as long as the main topic
plus at least one competitor succeed.
This module owns no business logic about pipeline arguments the caller
(scripts/last30days.py main) builds the closures with the appropriate
config, depth, and overrides for each entity.
"""
from __future__ import annotations
import sys
from concurrent.futures import ThreadPoolExecutor, as_completed
from typing import Callable
from . import schema
# Sub-runs hit the same upstream APIs as the main topic. Cap parallelism so a
# 6-way fan-out does not stampede a single backend's rate limit.
MAX_PARALLEL_SUBRUNS = 6
def _log(msg: str) -> None:
print(f"[Fanout] {msg}", file=sys.stderr)
def run_competitor_fanout(
*,
main_topic: str,
main_runner: Callable[[], schema.Report],
competitors: list[str],
competitor_runner: Callable[[str], schema.Report],
) -> list[tuple[str, schema.Report]]:
"""Run main + competitor pipelines in parallel; return surviving reports.
Args:
main_topic: Display label for the user's primary topic.
main_runner: Zero-arg callable returning the main topic's Report.
competitors: Ordered list of competitor entity names.
competitor_runner: Callable(entity_name) -> Report for each peer.
Returns:
Ordered list of (entity_name, Report) tuples for runs that succeeded.
Empty list if every run raised; the caller decides how to surface
partial-failure modes.
"""
if not competitors:
report = main_runner()
return [(main_topic, report)]
workers = min(len(competitors) + 1, MAX_PARALLEL_SUBRUNS)
def _run_one(label: str, fn: Callable[[], schema.Report]) -> tuple[str, schema.Report | None, Exception | None]:
try:
return label, fn(), None
except Exception as exc:
return label, None, exc
submissions: list[tuple[str, Callable[[], schema.Report]]] = [
(main_topic, main_runner),
]
for entity in competitors:
submissions.append((entity, lambda e=entity: competitor_runner(e)))
with ThreadPoolExecutor(max_workers=workers) as executor:
futures = {
executor.submit(_run_one, label, fn): label
for label, fn in submissions
}
results: dict[str, schema.Report] = {}
for future in as_completed(futures):
label, report, exc = future.result()
if exc is not None:
_log(f"Sub-run failed for {label!r}: {type(exc).__name__}: {exc}")
continue
assert report is not None
results[label] = report
# Preserve the original submission order rather than completion order so
# the comparison render is deterministic across runs.
return [(label, results[label]) for label, _ in submissions if label in results]
+8 -9
View File
@@ -17,7 +17,7 @@ import urllib.request
from concurrent.futures import ThreadPoolExecutor, as_completed
from typing import Any, Dict, List, Optional
from . import dates, log
from . import log
from .query import extract_core_subject
from .relevance import token_overlap_relevance
@@ -106,14 +106,13 @@ def _parse_repo_from_url(html_url: str) -> str:
def _parse_date(iso_str: Optional[str]) -> Optional[str]:
"""Parse a GitHub ISO 8601 datetime string and return YYYY-MM-DD.
Returns None for non-date input. GitHub's API always emits ISO 8601
(e.g. "2026-02-26T16:00:00Z"), but we defer to dates.parse_date() so
garbage input gets rejected instead of silently sliced.
"""
dt = dates.parse_date(iso_str)
return dt.strftime("%Y-%m-%d") if dt else None
"""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(
-17
View File
@@ -38,7 +38,6 @@ def request(
url: str,
headers: Optional[Dict[str, str]] = None,
json_data: Optional[Dict[str, Any]] = None,
params: Optional[Dict[str, Any]] = None,
timeout: int = DEFAULT_TIMEOUT,
retries: int = MAX_RETRIES,
max_429_retries: int = MAX_429_RETRIES,
@@ -51,8 +50,6 @@ def request(
url: Request URL
headers: Optional headers dict
json_data: Optional JSON body (for POST)
params: Optional query-string params. Values are stringified. None values
are dropped. If ``url`` already has a query string, ``params`` is appended.
timeout: Request timeout in seconds
retries: Number of retries on failure
max_429_retries: Maximum 429 retries before giving up (separate cap)
@@ -67,12 +64,6 @@ def request(
headers = headers or {}
headers.setdefault("User-Agent", USER_AGENT)
if params:
filtered = {k: str(v) for k, v in params.items() if v is not None}
if filtered:
separator = "&" if ("?" in url) else "?"
url = f"{url}{separator}{urlencode(filtered)}"
data = None
if json_data is not None:
data = json.dumps(json_data).encode('utf-8')
@@ -166,14 +157,6 @@ def post_raw(url: str, json_data: Dict[str, Any], headers: Optional[Dict[str, st
return request("POST", url, headers=headers, json_data=json_data, raw=True, **kwargs)
def scrapecreators_headers(token: str) -> Dict[str, str]:
"""Build ScrapeCreators request headers (x-api-key + JSON content type)."""
return {
"x-api-key": token,
"Content-Type": "application/json",
}
def get_reddit_json(path: str, timeout: int = DEFAULT_TIMEOUT, retries: int = MAX_RETRIES) -> Dict[str, Any]:
"""Fetch Reddit thread JSON.
+13 -5
View File
@@ -112,6 +112,14 @@ def _log(msg: str):
log.source_log("Instagram", msg)
def _sc_headers(token: str) -> Dict[str, str]:
"""Build ScrapeCreators request headers."""
return {
"x-api-key": token,
"Content-Type": "application/json",
}
def _parse_date(item: Dict[str, Any]) -> Optional[str]:
"""Parse date from ScrapeCreators Instagram item to YYYY-MM-DD.
@@ -241,7 +249,7 @@ def _user_reels(
from urllib.parse import urlencode
params = urlencode({"handle": handle})
url = f"{reels_url}?{params}"
headers = http.scrapecreators_headers(token)
headers = _sc_headers(token)
headers["User-Agent"] = http.USER_AGENT
data = http.get(url, headers=headers, timeout=30, retries=2)
except Exception as e:
@@ -252,7 +260,7 @@ def _user_reels(
resp = _requests.get(
reels_url,
params={"handle": handle},
headers=http.scrapecreators_headers(token),
headers=_sc_headers(token),
timeout=30,
)
resp.raise_for_status()
@@ -299,7 +307,7 @@ def search_instagram(
from urllib.parse import urlencode
params = urlencode({"query": core_topic})
url = f"{SCRAPECREATORS_BASE}/v2/instagram/reels/search?{params}"
headers = http.scrapecreators_headers(token)
headers = _sc_headers(token)
headers["User-Agent"] = http.USER_AGENT
data = http.get(url, headers=headers, timeout=30, retries=2)
except Exception as e:
@@ -310,7 +318,7 @@ def search_instagram(
resp = _requests.get(
f"{SCRAPECREATORS_BASE}/v2/instagram/reels/search",
params={"query": core_topic},
headers=http.scrapecreators_headers(token),
headers=_sc_headers(token),
timeout=30,
)
resp.raise_for_status()
@@ -395,7 +403,7 @@ def fetch_captions(
resp = _requests.get(
f"{SCRAPECREATORS_BASE}/v2/instagram/media/transcript",
params={"url": url},
headers=http.scrapecreators_headers(token),
headers=_sc_headers(token),
timeout=15,
)
if resp.status_code == 200:
+2 -56
View File
@@ -53,6 +53,7 @@ def normalize_source_items(
"xiaohongshu": _normalize_grounding,
"github": _normalize_github,
"perplexity": _normalize_grounding,
"podcasts": lambda s, i, idx, fd, td: _normalize_youtube(s, i, idx, fd, td),
}
normalizer = normalizers.get(source)
if normalizer is None:
@@ -69,47 +70,6 @@ def normalize_source_items(
return filtered
def _remap_comments(
raw: list[Any],
score_keys: tuple[str, ...],
excerpt_keys: tuple[str, ...],
) -> list[dict[str, Any]]:
"""Normalize comments from any source into the shared Reddit-compatible shape.
Downstream code (signals._top_comment_score, render._top_comments_list,
entity_extract, rerank) all expect `score` and `excerpt`. This helper maps
per-source field names (YT: likes/text, TikTok: digg_count/text) onto that
shape while preserving author/date/url passthrough.
"""
out: list[dict[str, Any]] = []
for raw_c in raw:
if not isinstance(raw_c, dict):
continue
score = _first_present(raw_c, score_keys, default=0)
excerpt = _first_present(raw_c, excerpt_keys, default="")
try:
score_int = int(score or 0)
except (TypeError, ValueError):
score_int = 0
entry: dict[str, Any] = {
"score": score_int,
"excerpt": str(excerpt or "")[:400],
"author": str(raw_c.get("author") or ""),
"date": str(raw_c.get("date") or ""),
}
if raw_c.get("url"):
entry["url"] = str(raw_c["url"])
out.append(entry)
return out
def _first_present(d: dict[str, Any], keys: tuple[str, ...], default: Any) -> Any:
for key in keys:
if key in d and d[key] not in (None, ""):
return d[key]
return default
def _domain_from_url(url: str) -> str | None:
if not url:
return None
@@ -241,11 +201,6 @@ def _normalize_youtube(
metadata: dict[str, Any] = {}
if highlights:
metadata["transcript_highlights"] = highlights
metadata["top_comments"] = _remap_comments(
item.get("top_comments") or [],
score_keys=("score", "likes"),
excerpt_keys=("excerpt", "text"),
)
return _source_item(
item_id=str(item.get("video_id") or item.get("id") or f"YT{index + 1}"),
source=source,
@@ -288,16 +243,7 @@ def _normalize_shortform_video(
relevance_hint=item.get("relevance", 0.5),
why_relevant=str(item.get("why_relevant") or ""),
snippet=caption,
metadata={
"hashtags": item.get("hashtags") or [],
"top_comments": _remap_comments(
item.get("top_comments") or [],
# TikTok uses digg_count as the vote field; Instagram has no
# comment fetcher today so the key is harmlessly absent.
score_keys=("score", "digg_count", "likes"),
excerpt_keys=("excerpt", "text"),
),
},
metadata={"hashtags": item.get("hashtags") or []},
)
+10 -2
View File
@@ -49,6 +49,14 @@ 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.
@@ -146,7 +154,7 @@ def search_pinterest(
from urllib.parse import urlencode
params = urlencode({"keyword": core_topic})
url = f"{SCRAPECREATORS_BASE}/search?{params}"
headers = http.scrapecreators_headers(token)
headers = _sc_headers(token)
headers["User-Agent"] = http.USER_AGENT
data = http.get(url, headers=headers, timeout=30, retries=2)
except Exception as e:
@@ -157,7 +165,7 @@ def search_pinterest(
resp = _requests.get(
f"{SCRAPECREATORS_BASE}/search",
params={"keyword": core_topic},
headers=http.scrapecreators_headers(token),
headers=_sc_headers(token),
timeout=30,
)
resp.raise_for_status()
+23 -65
View File
@@ -40,7 +40,7 @@ from . import (
xai_x,
xiaohongshu_api,
xquik,
xurl_x,
podcast_yt,
youtube_yt,
)
from .cluster import cluster_candidates
@@ -78,6 +78,7 @@ MOCK_AVAILABLE_SOURCES = [
"github",
"perplexity",
"xquik",
"podcasts",
]
@@ -123,6 +124,11 @@ def available_sources(config: dict[str, Any], requested_sources: list[str] | Non
available.append("pinterest")
if env.is_xquik_available(config):
available.append("xquik")
# Podcasts: available whenever yt-dlp is installed (same as YouTube).
# Opt-out only. The source returns empty when no channels are resolved,
# so there's no cost to having it available.
if podcast_yt.is_available():
available.append("podcasts")
return available
@@ -178,7 +184,7 @@ def run(
lookback_days: int = 30,
github_user: str | None = None,
github_repos: list[str] | None = None,
internal_subrun: bool = False,
podcast_channels: list[str] | None = None,
) -> schema.Report:
settings = DEPTH_SETTINGS[depth]
requested_sources = normalize_requested_sources(requested_sources)
@@ -206,7 +212,7 @@ def run(
plan = planner._sanitize_plan(
external_plan, topic, available, requested_sources, depth,
)
plan_source = "external"
print(f"[Planner] Using external plan ({len(plan.subqueries)} subqueries)", file=sys.stderr)
else:
plan = planner.plan_query(
topic=topic,
@@ -216,16 +222,7 @@ def run(
provider=None if mock else reasoning_provider,
model=None if mock else runtime.planner_model,
context=config.get("_auto_resolve_context", ""),
internal_subrun=internal_subrun,
)
# Source labelling: the fallback path annotates notes with "fallback-plan"
# or "deterministic-comparison-plan"; anything else came from the LLM.
if any("fallback" in note or "deterministic" in note for note in (plan.notes or [])):
plan_source = "deterministic"
elif not mock and reasoning_provider and runtime.planner_model:
plan_source = "llm"
else:
plan_source = "deterministic"
# Safety net: ensure grounding appears in all subqueries even if the planner
# omits it. This is redundant when the planner includes grounding via
@@ -235,32 +232,7 @@ def run(
if "grounding" not in sq.sources:
sq.sources.append("grounding")
# Always-on planner trace. Emits one summary line plus one per subquery
# so retrieval-breadth failures like the 2026-04-19 Hermes Agent Use Cases
# disaster are visible without --debug. Stderr only; does not leak into
# the user-facing stdout synthesis.
print(
f"[Planner] Plan: intent={plan.intent}, freshness={plan.freshness_mode}, "
f"cluster_mode={plan.cluster_mode}, subqueries={len(plan.subqueries)}, "
f"source={plan_source}",
file=sys.stderr,
)
if plan.subqueries:
for index, sq in enumerate(plan.subqueries, start=1):
sources_str = ",".join(sq.sources) if sq.sources else "(none)"
print(
f"[Planner] sq{index} label={sq.label} "
f'search="{sq.search_query}" sources=[{sources_str}]',
file=sys.stderr,
)
else:
print("[Planner] (no subqueries in plan)", file=sys.stderr)
bundle = schema.RetrievalBundle(artifacts={"grounding": []})
# Expose plan_source to the renderer so render_compact can emit the
# DEGRADED RUN banner when a named-entity topic was invoked bare
# (source=deterministic AND no pre-research flags). LAW 7 backstop.
bundle.artifacts["plan_source"] = plan_source
# Project-mode or person-mode GitHub: run once before the main subquery loop
_github_custom_done = False
@@ -354,6 +326,7 @@ def run(
tiktok_hashtags=tiktok_hashtags,
tiktok_creators=tiktok_creators,
ig_creators=ig_creators,
podcast_channels=podcast_channels,
)
] = (subquery, source)
@@ -384,6 +357,7 @@ def run(
tiktok_hashtags=tiktok_hashtags,
tiktok_creators=tiktok_creators,
ig_creators=ig_creators,
podcast_channels=podcast_channels,
)
except Exception as retry_exc:
bundle.errors_by_source[source] = f"{exc} (retried once, still failed: {retry_exc})"
@@ -443,7 +417,7 @@ def run(
if bundle.items_by_source.get(source):
del bundle.errors_by_source[source]
items_by_source = _finalize_items_by_source(bundle.items_by_source, topic=topic, config=config)
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,
@@ -508,28 +482,11 @@ def _normalize_score_dedupe(
return normalized
def _finalize_items_by_source(
items_by_source_raw: dict[str, list[schema.SourceItem]],
topic: str = "",
config: dict | None = None,
) -> dict[str, list[schema.SourceItem]]:
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)
items = dedupe.dedupe_items(items)
# Post-merge topic-relevance filter for Polymarket: comparison queries
# fan out into per-entity subqueries ("Hermes", "OpenClaw") whose topic
# is too narrow for Gamma API to filter meaningfully. Re-validating the
# merged list against the full original topic drops off-topic markets
# (e.g., WTI crude oil, Elon tweet counts) before footer emission.
if source == "polymarket" and topic:
items = polymarket.filter_items_against_topic(topic, items)
# --polymarket-keywords (via config): additional keyword filter
# for ambiguous single-token topics (e.g., "Warriors" → nba,gsw).
keywords = config.get("_polymarket_keywords") if isinstance(config, dict) else None
if keywords:
items = polymarket.filter_items_against_keywords(items, keywords)
finalized[source] = items
finalized[source] = dedupe.dedupe_items(items)
return finalized
@@ -840,6 +797,7 @@ def _retrieve_stream(
tiktok_hashtags: list[str] | None = None,
tiktok_creators: list[str] | None = None,
ig_creators: list[str] | None = None,
podcast_channels: 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:
@@ -904,9 +862,6 @@ def _retrieve_stream(
depth=depth,
)
return xai_x.parse_x_response(result), {}
if backend == "xurl":
result = xurl_x.search_x(subquery.search_query, depth=depth)
return xurl_x.parse_x_response(result, topic=subquery.search_query), {}
raise RuntimeError("No X backend is available.")
if source == "youtube":
# Use raw_topic so expand_youtube_queries() generates diverse variants
@@ -930,6 +885,13 @@ def _retrieve_stream(
sc_token = config.get("SCRAPECREATORS_API_KEY", "")
youtube_yt.enrich_with_comments(items, token=sc_token)
return items, {}
if source == "podcasts":
podcast_query = raw_topic or subquery.search_query
result = podcast_yt.search_podcast_youtube(
podcast_query, from_date, to_date,
depth=depth, channels=podcast_channels,
)
return result.get("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.
@@ -943,11 +905,7 @@ def _retrieve_stream(
hashtags=tiktok_hashtags,
creators=tiktok_creators,
)
items = tiktok.parse_tiktok_response(result)
if items and env.is_tiktok_comments_available(config):
sc_token = config.get("SCRAPECREATORS_API_KEY", "")
tiktok.enrich_with_comments(items, token=sc_token)
return items, {}
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.
+9 -144
View File
@@ -71,6 +71,7 @@ SOURCE_CAPABILITIES = {
"github": {"discussion", "link"},
"grounding": {"web", "reference", "link"},
"perplexity": {"web", "reference", "analysis"},
"podcasts": {"discussion", "video_longform", "expert"},
}
DEFAULT_INTENT_CAPABILITIES = {
"comparison": {"discussion", "video", "web", "reference", "social", "link", "market"},
@@ -86,16 +87,9 @@ def plan_query(
provider: providers.ReasoningClient | None,
model: str | None,
context: str = "",
internal_subrun: bool = False,
) -> schema.QueryPlan:
"""Create a query plan. Comparison queries with extractable entities use a
deterministic plan; other intents prefer the configured reasoning provider.
internal_subrun: when True, suppress the LAW 7 "No --plan passed" stderr
warning. LAW 7 targets the hosting-reasoning-model path; competitor
fan-out sub-runs are engine-internal and the warning is a false positive
there. Default False preserves the warning on every user-facing invocation.
"""
deterministic plan; other intents prefer the configured reasoning provider."""
if _should_force_deterministic_plan(topic):
return _fallback_plan(
topic,
@@ -120,26 +114,6 @@ def plan_query(
topic, available_sources, requested_sources, depth,
note=f"fallback-plan (LLM error: {type(exc).__name__})",
)
# No --plan was passed and no engine-internal provider is configured.
# The deterministic fallback path produces weaker plans than either the
# --plan path or the engine-internal LLM planner path. Surface this so
# the HOSTING reasoning model (Claude Code, Codex, Hermes, Gemini, etc.)
# realizes it is supposed to generate the plan itself and pass --plan.
# The word "provider" in engine messages refers to the engine's INTERNAL
# planner credentials - NOT a prerequisite the caller needs. If you are
# the hosting reasoning model, YOU are the provider. LAW 7 / 2026-04-19
# Hermes Agent Use Cases failure mode.
if not internal_subrun:
import sys
print(
"[Planner] No --plan passed. If you are the reasoning model hosting "
"this skill (Claude Code, Codex, Hermes, Gemini, or any agent runtime), "
"YOU ARE the planner: generate a JSON query plan yourself and pass it "
"via --plan. You do not need an API key or credentials; you ARE the "
"LLM. The deterministic fallback below is the headless/cron path only. "
"See LAW 7 in SKILL.md and Step 0.75 for the plan schema.",
file=sys.stderr,
)
return _fallback_plan(topic, available_sources, requested_sources, depth)
@@ -178,7 +152,7 @@ Return JSON only with this shape:
}}
Rules:
- emit 1 to 5 subqueries (how_to/opinion/product/breaking_news intents benefit from 4-5; factual/concept from 2)
- 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
@@ -189,8 +163,6 @@ Rules:
- 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'
- INTENT-MODIFIER HANDLING: when the topic contains one of {{use cases, use case, workflows, workflow, examples, tutorial, tutorials, review, reviews, comparison, applications, in practice, production, production use, how i use}}, STRIP that phrase from every search_query (keep its meaning in ranking_query). Emit 4-5 paraphrased subqueries that each express the intent differently (e.g., 'production', 'workflow OR pipeline', 'review OR experience', 'vs COMPETITOR', 'community discussion'). Broad retrieval, narrow ranking. This was the 2026-04-19 Hermes Agent Use Cases failure mode: the planner echoed "hermes agent use cases" as a literal search string and returned near-zero results because nobody posts that exact phrase.
- DO NOT quote the user's full topic verbatim in search_query. Quote only multi-word proper nouns like "Hermes Agent", "Claude Code", "Nous Research". Bare keywords OR'd together retrieve more than exact-phrase searches.
- 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()
@@ -233,7 +205,7 @@ def _sanitize_plan(
source_weights = _normalize_weights(source_weights)
subqueries: list[schema.SubQuery] = []
for index, subquery in enumerate((raw.get("subqueries") or [])[:_max_subqueries(intent_hint, topic)], start=1):
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]
@@ -411,22 +383,13 @@ def _fallback_plan(
)
)
# Intent-modifier fanout: when topic contains a phrase like "use cases",
# "workflows", "examples", "review" (see _INTENT_MODIFIER_PATTERNS),
# paraphrase the intent across 3 extra subqueries rather than echoing
# the literal phrase. Fixes 2026-04-19 Hermes Agent Use Cases failure.
# Excluded for comparison/prediction since those already have dedicated
# fanout (entity-per-subquery / odds).
if depth != "quick" and intent not in {"comparison", "prediction"} and _has_intent_modifier(topic):
subqueries.extend(_intent_modifier_subqueries(topic, core, base_search, source_weights))
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, topic)], intent, depth, list(source_weights))
_trim_subqueries_for_depth(subqueries[:_max_subqueries(intent)], intent, depth, list(source_weights))
),
source_weights=_normalize_weights(source_weights),
notes=[note],
@@ -456,15 +419,7 @@ def _infer_intent(topic: str) -> str:
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"
# Recency signals take priority when nothing more specific matched.
if re.search(r"\b(trending|this week|right now|today|this month)\b", text):
return "breaking_news"
# Default changed from "breaking_news" to "concept" on 2026-04-19 after
# the Hermes Agent Use Cases failure: unclassified topics were getting
# strict_recent freshness, which over-weighted the last 7 days and
# under-weighted older relevant material. "concept" defaults to
# evergreen_ok freshness, a safer posture for unknown topics.
return "concept"
return "breaking_news"
def _default_freshness(intent: str) -> str:
@@ -510,26 +465,8 @@ def _default_source_weights(intent: str, sources: list[str]) -> dict[str, float]
def _keyword_query(topic: str, core: str) -> str:
"""Build a search_query string for the deterministic fallback.
Quote ONLY title-cased multi-word proper nouns ("Hermes Agent",
"Claude Code", "Nous Research") so platform search engines preserve the
name as a phrase. Hyphenated compounds and lowercase terms are left as
bare keywords, which broadens retrieval instead of narrowing it.
Prior behavior quoted the entire compound including the user's typed
topic, producing searches like `"Hermes Agent Actual Use Cases" hermes agent actual`
that returned near-zero matches on X and Reddit because nobody posts
that exact phrase. See 2026-04-19 Hermes Agent Use Cases failure.
"""
compounds = query.extract_compound_terms(topic)
# Only quote title-cased proper nouns (multi-word names). Hyphenated
# compounds go unquoted so platform tokenizers can split and match.
title_cased = [
term for term in compounds
if re.match(r"^(?:[A-Z][a-z]+\s+){1,}[A-Z][a-z]+$", term)
]
quoted = " ".join(f'"{term}"' for term in title_cased[:2])
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()
@@ -577,84 +514,12 @@ def _should_force_deterministic_plan(topic: str) -> bool:
return _infer_intent(topic) == "comparison" and len(_comparison_entities(topic)) >= 2
_INTENT_MODIFIER_PATTERNS = (
"use cases", "use case", "workflows", "workflow",
"examples", "example", "tutorial", "tutorials",
"review", "reviews", "comparison", "applications",
"in practice", "production use", "production",
"how i use",
)
def _has_intent_modifier(topic: str) -> bool:
"""Return True if the topic contains an intent modifier phrase.
See 2026-04-19 Hermes Agent Use Cases failure: a literal "Hermes Agent
use cases" search returns near-zero matches because nobody posts that
exact phrase. Intent modifiers should be stripped from search_query
and paraphrased across multiple subqueries.
"""
text = topic.lower()
return any(pattern in text for pattern in _INTENT_MODIFIER_PATTERNS)
def _intent_modifier_subqueries(
topic: str,
core: str,
base_search: str,
source_weights: dict[str, float],
) -> list[schema.SubQuery]:
"""Produce paraphrased subqueries for intent-modifier topics.
The deterministic fallback used to echo the user's literal phrase
(e.g., "hermes agent use cases") into every search_query. This helper
fans out 3 extra subqueries that each express the intent differently
so retrieval pulls a broader corpus for reranking.
"""
entity = core or topic.strip()
sources = list(source_weights)
return [
schema.SubQuery(
label="workflows",
search_query=f"{entity} workflow pipeline",
ranking_query=f"What real-world workflows or pipelines are people running with {entity}?",
sources=sources,
weight=0.6,
),
schema.SubQuery(
label="production",
search_query=f"{entity} production real-world",
ranking_query=f"What production deployments or real-world use cases of {entity} are people describing?",
sources=sources,
weight=0.55,
),
schema.SubQuery(
label="experience",
search_query=f"{entity} experience review",
ranking_query=f"What hands-on experience reports or reviews of {entity} exist in the last 30 days?",
sources=sources,
weight=0.5,
),
]
def _max_subqueries(intent: str, topic: str | None = None) -> int:
# how_to/opinion/product/breaking_news/prediction benefit from 4-5
# paraphrased subqueries when the topic carries an intent modifier
# (use cases, workflows, examples, review, etc.). See 2026-04-19
# Hermes Agent Use Cases failure: prior cap of 3 produced near-literal
# echoes of the topic instead of a paraphrase fanout.
def _max_subqueries(intent: str) -> int:
if intent == "comparison":
return 4
# Intent-modifier topics get headroom for paraphrase fanout even when
# the intent itself is factual/concept. Without this, a "Hermes Agent
# use cases" query (classified "concept" after the 2026-04-19 default
# change) would be capped at 2 and drop the fanout.
if topic and _has_intent_modifier(topic):
return 5
if intent in {"factual", "concept"}:
return 2
return 5
return 3
def _default_sources_for_intent(intent: str, available_sources: list[str]) -> list[str]:
+430
View File
@@ -0,0 +1,430 @@
"""YouTube podcast discovery via transcript scanning.
Discovers podcast content by fetching auto-captions from LLM-resolved
YouTube podcast channels and grepping for the search topic. Finds content
invisible to title-based search e.g., Acquired's "The NFL" episode
mentions Taylor Swift 18 times, ESPN 117 times, Netflix 102 times.
Uses yt-dlp for channel playlist fetch + caption download. No API keys.
Reuses transcript highlight extraction from youtube_yt.
"""
import math
import os
import re
import shutil
import signal
import subprocess
import sys
import tempfile
from concurrent.futures import ThreadPoolExecutor, as_completed
from typing import Any, Dict, List, Optional
from . import log
# How many recent episodes to scan per channel, by depth
EPISODES_PER_CHANNEL = {
"quick": 2,
"default": 3,
"deep": 4,
}
# Minimum topic mentions in captions to count as a hit
MENTION_THRESHOLD = 5
# Max total results to return
RESULTS_CAP = {
"quick": 4,
"default": 8,
"deep": 20,
}
# Min duration in seconds to qualify as a podcast episode
MIN_DURATION = 1200 # 20 minutes
def _log(msg: str):
log.source_log("Podcasts", msg, tty_only=False)
def is_available() -> bool:
"""Podcast source is available when yt-dlp is installed."""
return shutil.which("yt-dlp") is not None
def resolve_channel(handle: str) -> Optional[str]:
"""Resolve a YouTube @handle to a channel URL.
Tries the @handle directly first (fast, ~92% success rate).
Falls back to ytsearch1 if the handle doesn't resolve.
Returns the channel URL (https://www.youtube.com/channel/...) or None.
"""
# Try @handle directly - use the channel/videos URL format
# yt-dlp can fetch from @handle URLs directly for playlist operations
direct_url = f"https://www.youtube.com/@{handle}/videos"
try:
result = subprocess.run(
["yt-dlp", "--playlist-end", "1",
"--print", "%(channel_url)s",
"--no-download", "--no-warnings", "--ignore-config", "--no-cookies-from-browser",
direct_url],
capture_output=True, text=True, timeout=20,
)
channel_url = result.stdout.strip().split("\n")[0].strip()
if channel_url and channel_url.startswith("http"):
_log(f"Resolved @{handle} -> {channel_url}")
return channel_url
except (subprocess.TimeoutExpired, FileNotFoundError):
pass
# Fallback: search for the podcast
_log(f"@{handle} not found, trying search fallback")
try:
result = subprocess.run(
["yt-dlp", "--flat-playlist", "--playlist-end", "1",
"--print", "%(channel_url)s",
f'ytsearch1:"{handle}" podcast full episode'],
capture_output=True, text=True, timeout=20,
)
channel_url = result.stdout.strip()
if channel_url and channel_url.startswith("http"):
_log(f"Search fallback resolved {handle} -> {channel_url}")
return channel_url
except (subprocess.TimeoutExpired, FileNotFoundError):
pass
_log(f"Could not resolve channel: {handle}")
return None
def _fetch_recent_episodes(
channel_url: str,
limit: int,
from_date: str,
to_date: str,
) -> List[Dict[str, Any]]:
"""Fetch recent long-form episodes from a channel.
Returns list of dicts with video_id, title, channel, duration, date, views, likes.
Filters to episodes with duration >= MIN_DURATION.
"""
import json as _json
try:
result = subprocess.run(
["yt-dlp", f"--playlist-end={limit + 2}",
"--dump-json", "--no-download", "--no-warnings", "--ignore-config", "--no-cookies-from-browser",
f"{channel_url}/videos"],
capture_output=True, text=True, timeout=60,
)
except (subprocess.TimeoutExpired, FileNotFoundError):
return []
episodes = []
for line in result.stdout.strip().split("\n"):
line = line.strip()
if not line:
continue
try:
video = _json.loads(line)
except _json.JSONDecodeError:
continue
video_id = video.get("id", "")
title = video.get("title", "")
channel = video.get("channel", video.get("uploader", ""))
duration = video.get("duration") or 0
upload_date_raw = video.get("upload_date", "")
views = video.get("view_count") or 0
likes = video.get("like_count") or 0
# Convert YYYYMMDD to YYYY-MM-DD
date_str = None
if upload_date_raw and len(upload_date_raw) >= 8:
date_str = f"{upload_date_raw[:4]}-{upload_date_raw[4:6]}-{upload_date_raw[6:8]}"
# Filter: duration >= MIN_DURATION
if duration < MIN_DURATION:
continue
# Filter: within date range (soft - keep if no date available)
if date_str and (date_str < from_date or date_str > to_date):
continue
episodes.append({
"video_id": video_id,
"title": title,
"channel_name": channel,
"duration": duration,
"date": date_str,
"views": views,
"likes": likes,
"url": f"https://www.youtube.com/watch?v={video_id}",
})
return episodes[:limit]
def _fetch_captions(video_id: str, temp_dir: str) -> Optional[str]:
"""Fetch auto-captions for a video. Returns caption text or None."""
out_template = os.path.join(temp_dir, f"cap_{video_id}")
try:
subprocess.run(
["yt-dlp", "--write-auto-sub", "--sub-lang", "en",
"--skip-download", "--sub-format", "vtt",
"-o", out_template,
f"https://www.youtube.com/watch?v={video_id}"],
capture_output=True, text=True, timeout=30,
)
except (subprocess.TimeoutExpired, FileNotFoundError):
return None
vtt_path = f"{out_template}.en.vtt"
if not os.path.exists(vtt_path):
return None
try:
with open(vtt_path, "r", encoding="utf-8") as f:
text = f.read()
os.remove(vtt_path)
# Strip VTT formatting: timestamps, alignment, tags, duplicate lines
# VTT auto-captions repeat lines as they scroll, so deduplicate
lines = []
prev_line = ""
for line in text.split("\n"):
line = line.strip()
if not line:
continue
if line.startswith("WEBVTT") or line.startswith("Kind:") or line.startswith("Language:"):
continue
if re.match(r"^\d{2}:\d{2}:", line):
continue
if re.match(r"^NOTE\b", line):
continue
if "align:" in line or "position:" in line:
continue
# Strip inline VTT tags like <c>, </c>, timestamps
cleaned = re.sub(r"<[^>]+>", "", line)
cleaned = cleaned.strip()
if cleaned and not re.match(r"^\d+$", cleaned) and cleaned != prev_line:
lines.append(cleaned)
prev_line = cleaned
return " ".join(lines)
except Exception:
return None
_NOISE_WORDS = frozenset({
"the", "a", "an", "of", "and", "or", "for", "to", "in", "on", "at",
"best", "top", "new", "latest", "review", "news", "vs", "versus",
"album", "song", "episode", "podcast", "interview", "this", "that",
"what", "how", "why", "where", "when", "who",
})
def _extract_key_terms(topic: str) -> List[str]:
"""Extract meaningful terms from topic for matching.
For "Kanye West Bully album" -> ["Kanye West", "Bully"] or similar.
For single words, just returns the word.
"""
words = [w.strip() for w in topic.split() if w.strip()]
# Remove noise words
meaningful = [w for w in words if w.lower() not in _NOISE_WORDS and len(w) > 2]
if not meaningful:
return [topic.strip()]
# If the topic has 2+ meaningful words, also include the full phrase
# and the first 2 words as a potential entity name
terms = []
if len(meaningful) >= 2:
# Full phrase first (for exact entity matches like "Taylor Swift")
terms.append(" ".join(meaningful[:2]))
terms.extend(meaningful)
return terms
def _count_mentions(text: str, topic: str) -> int:
"""Count case-insensitive topic mentions in text.
Uses the maximum mention count across key terms extracted from the topic.
"Kanye West Bully album" -> max mentions of ["Kanye West", "Kanye", "West", "Bully"].
This way, an episode mentioning "Kanye" 85 times counts as 85, not 0.
"""
text_lower = text.lower()
terms = _extract_key_terms(topic)
max_count = 0
for term in terms:
pattern = re.escape(term.lower())
count = len(re.findall(pattern, text_lower))
if count > max_count:
max_count = count
return max_count
def _extract_mention_context(text: str, topic: str, max_excerpts: int = 3) -> List[str]:
"""Extract text snippets around topic mentions for highlights."""
words = text.split()
topic_lower = topic.lower()
excerpts = []
for i, word in enumerate(words):
# Check if we're near a mention
window = " ".join(words[max(0, i - 5):i + 15]).lower()
if topic_lower in window and len(excerpts) < max_excerpts:
start = max(0, i - 10)
end = min(len(words), i + 30)
excerpt = " ".join(words[start:end])
# Avoid duplicate excerpts
if not any(excerpt[:50] in e for e in excerpts):
excerpts.append(excerpt)
return excerpts
def _scan_channel(
handle: str,
topic: str,
from_date: str,
to_date: str,
episodes_limit: int,
) -> List[Dict[str, Any]]:
"""Scan a single channel's recent episodes for topic mentions.
Returns list of hit items with mention_count and transcript data.
"""
# Step 1: Resolve channel handle to URL
channel_url = resolve_channel(handle)
if not channel_url:
return []
# Step 2: Fetch recent long-form episodes
episodes = _fetch_recent_episodes(channel_url, episodes_limit, from_date, to_date)
if not episodes:
_log(f"No recent long-form episodes from {handle}")
return []
_log(f"Scanning {len(episodes)} episodes from {handle}")
# Step 3: Fetch captions and grep for topic
hits = []
with tempfile.TemporaryDirectory() as temp_dir:
for ep in episodes:
caption_text = _fetch_captions(ep["video_id"], temp_dir)
if not caption_text:
continue
mention_count = _count_mentions(caption_text, topic)
if mention_count < MENTION_THRESHOLD:
continue
# Extract highlights around the mentions
from .youtube_yt import extract_transcript_highlights
highlights = extract_transcript_highlights(caption_text, topic, limit=5)
mention_excerpts = _extract_mention_context(caption_text, topic)
# Cap transcript for storage
words = caption_text.split()
transcript_snippet = " ".join(words[:5000]) if len(words) > 5000 else caption_text
hits.append({
"video_id": ep["video_id"],
"title": ep["title"],
"channel_name": ep["channel_name"],
"url": ep["url"],
"date": ep["date"],
"duration": ep["duration"],
"engagement": {
"views": ep["views"],
"likes": ep["likes"],
},
"mention_count": mention_count,
"transcript_snippet": transcript_snippet,
"transcript_highlights": highlights,
"mention_excerpts": mention_excerpts,
"relevance": min(1.0, mention_count / 50),
"why_relevant": f"Podcast: {ep['channel_name']} - {ep['title'][:60]} ({mention_count} mentions)",
})
_log(f" HIT: {ep['title'][:60]} ({mention_count} mentions)")
return hits
def search_podcast_youtube(
topic: str,
from_date: str,
to_date: str,
depth: str = "default",
channels: Optional[List[str]] = None,
) -> Dict[str, Any]:
"""Discover podcast content by scanning transcripts of resolved channels.
Args:
topic: Search topic
from_date: Start date (YYYY-MM-DD)
to_date: End date (YYYY-MM-DD)
depth: 'quick', 'default', or 'deep'
channels: List of YouTube @handles to scan
Returns:
Dict with 'items' list. Each item has transcript and mention data.
"""
if not is_available():
_log("yt-dlp not installed")
return {"items": [], "error": "yt-dlp not installed"}
if not channels:
_log("No podcast channels provided")
return {"items": []}
episodes_limit = EPISODES_PER_CHANNEL.get(depth, EPISODES_PER_CHANNEL["default"])
results_cap = RESULTS_CAP.get(depth, RESULTS_CAP["default"])
_log(f"Scanning {len(channels)} podcast channels for '{topic}' (depth={depth}, {episodes_limit} eps/channel)")
# Scan channels in parallel
all_hits: List[Dict[str, Any]] = []
max_workers = min(4, len(channels))
with ThreadPoolExecutor(max_workers=max_workers) as executor:
futures = {
executor.submit(
_scan_channel, handle, topic, from_date, to_date, episodes_limit,
): handle
for handle in channels
}
for future in as_completed(futures):
handle = futures[future]
try:
hits = future.result()
all_hits.extend(hits)
except Exception as exc:
_log(f"Error scanning {handle}: {type(exc).__name__}: {exc}")
# Deduplicate by video_id
seen = set()
unique_hits = []
for hit in all_hits:
vid = hit["video_id"]
if vid not in seen:
seen.add(vid)
unique_hits.append(hit)
# Score: mention_count * log(views + 1)
for hit in unique_hits:
views = hit["engagement"].get("views", 0)
hit["_score"] = hit["mention_count"] * math.log(views + 1)
# Sort by score descending
unique_hits.sort(key=lambda x: x["_score"], reverse=True)
# Cap results
results = unique_hits[:results_cap]
# Clean up internal scoring field
for hit in results:
hit.pop("_score", None)
_log(f"Found {len(results)} podcast hits across {len(channels)} channels")
return {"items": results}
-100
View File
@@ -117,9 +117,6 @@ _NOISE_WORDS = frozenset({
"software", "plugin", "skill", "agent", "bot", "search", "research",
# Generic prediction market terms
"market", "odds", "prediction", "forecast", "chance", "probability",
# Comparison-query conjunctions — should not count as informative filter tokens
# when the topic is "X vs Y vs Z"
"vs", "versus",
})
@@ -168,103 +165,6 @@ def _passes_topic_filter(topic: str, event_title: str) -> bool:
return match_count >= min_matches
def _passes_any_informative_word(topic: str, event_title: str) -> bool:
"""Looser variant of _passes_topic_filter that keeps an item if ANY
informative word from the topic appears in the title.
Designed for post-merge validation of comparison topics (e.g., "OpenClaw vs
Hermes vs Paperclip"), where a market mentioning just one of the entities
is still on-topic. The stricter _passes_topic_filter (min_matches=2 for
3+ informative words) is correct for single-entity topics like "Mill.com
food recycler" but drops legitimate single-entity comparison results.
"""
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
informative = [w for w in core_words if w not in _NOISE_WORDS]
if not informative:
return True
title_lower = " ".join(re.sub(r"[^\w\s]", " ", event_title.lower()).split())
title_words = set(title_lower.split())
for word in informative:
if word in title_words:
return True
if len(word) >= 4 and word in title_lower:
return True
return False
def filter_items_against_topic(topic: str, items: List[Any]) -> List[Any]:
"""Drop items whose title shares no informative word with the original topic.
Called post-merge from pipeline.py so per-entity subquery results for
comparison topics get re-validated against the ORIGINAL full topic before
landing in the footer. Prevents noise like WTI crude oil or Elon tweet
markets from surviving a loose "Hermes" single-entity subquery match.
Uses the looser _passes_any_informative_word rule (ANY entity name match
is sufficient) so a market mentioning just one of several compared entities
still counts as on-topic.
Accepts a list of either raw dicts (with 'title') or SourceItem-like objects
(with .title attribute). Returns the filtered list in the same order.
"""
if not topic:
return items
filtered = []
for item in items:
title = getattr(item, "title", None)
if title is None and isinstance(item, dict):
title = item.get("title", "")
title = title or ""
if _passes_any_informative_word(topic, title):
filtered.append(item)
dropped = len(items) - len(filtered)
if dropped:
_log(f"Post-merge topic filter dropped {dropped} Polymarket items against full topic '{topic}'")
return filtered
def filter_items_against_keywords(items: List[Any], keywords: List[str]) -> List[Any]:
"""Keep only items whose title contains at least one keyword (case-insensitive).
Intended for disambiguating ambiguous single-token topics like 'Warriors'
via --polymarket-keywords (e.g., 'nba,gsw,golden-state') to filter out
Glasgow Warriors rugby, Honor of Kings Rogue Warriors markets that share
the 'Warriors' token but are not the target entity.
"""
if not keywords:
return items
normalized_keywords = [kw.strip().lower() for kw in keywords if kw and kw.strip()]
if not normalized_keywords:
return items
filtered = []
for item in items:
title = getattr(item, "title", None)
if title is None and isinstance(item, dict):
title = item.get("title", "")
title = (title or "").lower()
if any(kw in title for kw in normalized_keywords):
filtered.append(item)
dropped = len(items) - len(filtered)
if dropped:
_log(
f"Keyword filter dropped {dropped} Polymarket items; "
f"kept {len(filtered)} matching {normalized_keywords}"
)
return filtered
def _extract_domain_queries(topic: str, events: List[Dict]) -> List[str]:
"""Extract domain-indicator search terms from first-pass event tags.
-119
View File
@@ -1,119 +0,0 @@
"""Engine-side query-quality pre-flight.
Detects Class 1 (demographic shopping) keyword-trap queries and returns a
structured REFUSE message. The caller (scripts/last30days.py main()) writes
the message to stderr and exits code 2. No pipeline work runs on a doomed
query; the model sees the REFUSE on stderr and asks the user for the
hobbies/relationship/budget context it needs.
Patterns ported from SKILL.md Step 0.45 prose. Only Class 1 is implemented
here because it has a verified failure mode on v3.0.8 (2026-04-18 'birthday
gift for 40 year old' run returned r/todayilearned and unrelated drama
posts).
"""
from __future__ import annotations
import re
_CLASS_1_PATTERNS = [
re.compile(
r"^\s*(birthday\s+)?(gift|gifts|present|presents)\s+"
r"(for|ideas\s+for)\s+(a\s+|my\s+)?\d+[\s-]?year[\s-]?old\b",
re.IGNORECASE,
),
re.compile(
r"^\s*(best|top)\s+[\w\s-]+?\s+for\s+"
r"(men|women|kids|guys|girls|teens|dads|moms|husbands|wives|brothers|sisters|friends)\b",
re.IGNORECASE,
),
re.compile(
r"^\s*what\s+to\s+(buy|get|gift)\s+(for\s+)?(a\s+|my\s+)?"
r"(\d+[\s-]?year[\s-]?old|husband|wife|dad|mom|brother|sister|friend|boss|coworker)\b",
re.IGNORECASE,
),
re.compile(
r"^\s*(present|presents|gift|gifts)\s+for\s+(a\s+|my\s+)?"
r"(husband|wife|dad|mom|brother|sister|friend|boss|coworker)\b",
re.IGNORECASE,
),
]
_QUALIFIER_PATTERNS = [
re.compile(r"\$\d+"),
re.compile(r"\bbudget\b", re.IGNORECASE),
re.compile(r"\bwho\s+(loves|likes|is\s+into|enjoys)\b", re.IGNORECASE),
re.compile(r"\bhobbies?\b", re.IGNORECASE),
re.compile(r"\b(cooking|running|reading|gaming|golf|woodworking|coding|hiking|cycling|fishing|music)[\s-]?(obsessed|enthusiast|fan|lover)\b", re.IGNORECASE),
]
_RELATIONSHIP_WORDS = {
"husband", "wife", "dad", "mom", "father", "mother", "brother", "sister",
"friend", "boss", "coworker", "son", "daughter", "grandma", "grandpa",
"aunt", "uncle", "nephew", "niece", "partner", "boyfriend", "girlfriend",
}
_YEAR_OLD_NOUN = re.compile(r"\byear[\s-]?old\s+(\w+)", re.IGNORECASE)
def _has_qualifier(topic: str) -> bool:
"""Return True if the topic contains hobbies/relationship/budget context.
A Class 1 base pattern plus a qualifier means the user already filled in
the specificity Step 0.45 would ask for. Skip the refuse-gate and let
the engine run.
Also skips when `{n} year old <activity-noun>` is present, but only when
the noun is NOT a relationship word. 'year old runner' qualifies as an
interest and skips; 'year old husband' is just another relationship
reframing of the demographic query and does not skip.
"""
if any(pattern.search(topic) for pattern in _QUALIFIER_PATTERNS):
return True
match = _YEAR_OLD_NOUN.search(topic)
if match and match.group(1).lower() not in _RELATIONSHIP_WORDS:
return True
return False
def check_class_1_trap(topic: str) -> str | None:
"""Return a REFUSE message string if the topic matches Class 1, else None.
Class 1 is the demographic-shopping keyword trap. The literal phrase
'birthday gift for 40 year old' is not the vocabulary of actual gift
discussions on Reddit, X, or TikTok, so running the engine returns
low-signal generic posts. Refuse up-front and ask for context.
"""
if not topic:
return None
matched = any(pattern.search(topic) for pattern in _CLASS_1_PATTERNS)
if not matched:
return None
if _has_qualifier(topic):
return None
return _refuse_message(topic.strip())
def _refuse_message(topic: str) -> str:
return (
f'[last30days] REFUSE: topic "{topic}" matches Class 1 keyword-trap '
"pattern (demographic shopping).\n"
"\n"
"The literal phrase is not the vocabulary of actual gift discussions "
"on Reddit, X, or TikTok. Running the engine will return low-signal "
"generic posts (the 2026-04-18 validation run returned "
"r/todayilearned and unrelated drama).\n"
"\n"
"Ask the user for at least one of:\n"
" - hobbies (cooks / runs / reads / gaming / outdoors / golf / music)\n"
" - relationship (husband / dad / friend / boss / brother)\n"
" - budget range\n"
"\n"
"Then re-run with the enriched query. If the user insists 'just run it',\n"
"re-invoke with LAST30DAYS_SKIP_PREFLIGHT=1 to bypass this gate.\n"
)
+98 -20
View File
@@ -12,8 +12,15 @@ 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
try:
import requests as _requests
except ImportError:
_requests = None
def _first_of(*values, default=None):
"""Return first value that is not None."""
for v in values:
@@ -21,7 +28,7 @@ def _first_of(*values, default=None):
return v
return default
from . import dates, http, log
from . import http, log
SCRAPECREATORS_BASE = "https://api.scrapecreators.com/v1/reddit"
@@ -69,6 +76,14 @@ def _log(msg: str):
log.source_log("Reddit", msg, tty_only=False)
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.
@@ -197,16 +212,27 @@ def _parse_date(value) -> Optional[str]:
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. dates.parse_date() handles both,
plus edge cases like Z suffix and +0000 (no colon) offset.
Falsy inputs (None, "", 0) return None, matching the original behavior
where a Unix timestamp of 0 meant "no date" rather than epoch 0.
``created_utc`` as a Unix timestamp. Handle both.
"""
if not value:
return None
dt = dates.parse_date(str(value))
return dt.strftime("%Y-%m-%d") if dt else 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(value), tz=timezone.utc)
return dt.strftime("%Y-%m-%d")
except (ValueError, TypeError, OSError):
return None
def _extract_subreddit_name(value: Any) -> str:
@@ -324,18 +350,39 @@ def _global_search(
Returns:
List of post dicts
"""
if not _requests:
_log("requests library not installed, falling back to urllib")
# Use stdlib http module as fallback
try:
from urllib.parse import urlencode
params = urlencode({"query": query, "sort": sort, "timeframe": timeframe})
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)
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 []
try:
data = http.get(
resp = _requests.get(
f"{SCRAPECREATORS_BASE}/search",
headers=http.scrapecreators_headers(token),
params={"query": query, "sort": sort, "timeframe": timeframe},
headers=_sc_headers(token),
timeout=30,
retries=2,
)
resp.raise_for_status()
data = resp.json()
return data.get("posts", data.get("data", []))
except http.HTTPError as e:
if e.status_code in (401, 403):
raise
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:
@@ -362,19 +409,36 @@ def _subreddit_search(
Returns:
List of post dicts
"""
if not _requests:
try:
from urllib.parse import urlencode
params = urlencode({
"subreddit": subreddit, "query": query,
"sort": sort, "timeframe": timeframe,
})
url = f"{SCRAPECREATORS_BASE}/subreddit/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("posts", data.get("data", []))
except Exception as e:
_log(f"Subreddit search error (urllib) for r/{subreddit}: {e}")
return []
try:
data = http.get(
resp = _requests.get(
f"{SCRAPECREATORS_BASE}/subreddit/search",
headers=http.scrapecreators_headers(token),
params={
"subreddit": subreddit,
"query": query,
"sort": sort,
"timeframe": timeframe,
},
headers=_sc_headers(token),
timeout=30,
retries=2,
)
resp.raise_for_status()
data = resp.json()
return data.get("posts", data.get("data", []))
except Exception as e:
_log(f"Subreddit search error for r/{subreddit}: {e}")
@@ -394,14 +458,28 @@ def fetch_post_comments(
Returns:
List of comment dicts with score, author, body, etc.
"""
if not _requests:
try:
from urllib.parse import urlencode
params = urlencode({"url": url})
api_url = f"{SCRAPECREATORS_BASE}/post/comments?{params}"
headers = _sc_headers(token)
headers["User-Agent"] = http.USER_AGENT
data = http.get(api_url, headers=headers, timeout=30, retries=2)
return data.get("comments", data.get("data", []))
except Exception as e:
_log(f"Comment fetch error (urllib): {e}")
return []
try:
data = http.get(
resp = _requests.get(
f"{SCRAPECREATORS_BASE}/post/comments",
headers=http.scrapecreators_headers(token),
params={"url": url},
headers=_sc_headers(token),
timeout=30,
retries=2,
)
resp.raise_for_status()
data = resp.json()
return data.get("comments", data.get("data", []))
except Exception as e:
_log(f"Comment fetch error: {e}")
+22 -933
View File
File diff suppressed because it is too large Load Diff
+11 -125
View File
@@ -3,34 +3,8 @@
from __future__ import annotations
import json
import re
from . import http, providers, query, schema
# Penalty applied when a candidate does not mention the primary entity
# from the topic in its title or snippet. Picked empirically: a typical
# score spread in the shortlist is 30-70, so 25 points reliably pushes
# an off-topic candidate below on-topic ones without fully zeroing out
# marginal matches. See 2026-04-19 Hermes Agent Use Cases failure: a
# Nate Herk "Managed Agents" video scored 51 / ranked #2 with zero
# Hermes content.
ENTITY_MISS_PENALTY = 25.0
# Intent modifiers to strip before extracting the primary entity so that,
# for example, "Hermes Agent use cases" yields primary_entity="hermes agent"
# rather than "hermes agent use cases". Kept in sync with
# planner._INTENT_MODIFIER_PATTERNS.
_INTENT_MODIFIER_RE = re.compile(
r"\b("
r"use cases|use case|workflows|workflow|"
r"examples|example|tutorial|tutorials|"
r"review|reviews|comparison|applications|"
r"in practice|production use|production|"
r"how i use"
r")\b",
re.IGNORECASE,
)
from . import http, providers, schema
INTENT_SCORING_HINTS: dict[str, str] = {
"comparison": (
@@ -86,21 +60,20 @@ def rerank_candidates(
) -> list[schema.Candidate]:
"""Rerank the fused shortlist, demoting candidates the reranker scored as irrelevant."""
shortlisted = candidates[:shortlist_size]
primary_entity = _primary_entity(topic)
if provider and model and shortlisted:
try:
response = provider.generate_json(model, _build_prompt(topic, plan, shortlisted, primary_entity))
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, primary_entity=primary_entity)
_apply_fallback_scores(shortlisted)
else:
_apply_fallback_scores(shortlisted, primary_entity=primary_entity)
_apply_fallback_scores(shortlisted)
if len(candidates) > shortlist_size:
tail = candidates[shortlist_size:]
_apply_fallback_scores(tail, primary_entity=primary_entity)
_apply_fallback_scores(tail)
return sorted(
candidates,
@@ -130,7 +103,7 @@ def _fenced_untrusted_content(candidate_block: str) -> str:
)
def _build_prompt(topic: str, plan: schema.QueryPlan, candidates: list[schema.Candidate], primary_entity: str = "") -> str:
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
@@ -148,16 +121,6 @@ def _build_prompt(topic: str, plan: schema.QueryPlan, candidates: list[schema.Ca
)
for candidate in candidates
)
grounding_hint = ""
if primary_entity:
grounding_hint = (
f"\nPrimary entity grounding: the user's primary entity is \"{primary_entity}\". "
"A candidate that does NOT mention this entity (or a clear synonym/abbreviation) "
"in its title or snippet should score no higher than 30, regardless of other "
"signals. Do not let a candidate match the topic vicinity without matching the "
"entity itself. 2026-04-19 Hermes Agent Use Cases failure: a Nate Herk video "
"about Claude's Managed Agents scored 51 with zero Hermes content.\n"
)
return f"""
Judge search-result relevance for a last-30-days research pipeline.
@@ -182,7 +145,7 @@ Scoring guidance:
- 70 to 89: clearly relevant and useful
- 40 to 69: somewhat relevant but weaker
- 0 to 39: weak, redundant, or off-target
{grounding_hint}{_intent_hint_block(plan)}
{_intent_hint_block(plan)}
{_fenced_untrusted_content(candidate_block)}
""".strip()
@@ -206,93 +169,21 @@ def _apply_llm_scores(candidates: list[schema.Candidate], payload: dict) -> None
candidate.final_score = _final_score(candidate)
def _apply_fallback_scores(candidates: list[schema.Candidate], *, primary_entity: str = "") -> None:
def _apply_fallback_scores(candidates: list[schema.Candidate]) -> None:
for candidate in candidates:
rerank_score, reason = _fallback_tuple(candidate, primary_entity=primary_entity)
rerank_score, reason = _fallback_tuple(candidate)
candidate.rerank_score = rerank_score
candidate.explanation = reason
candidate.final_score = _final_score(candidate)
def _candidate_haystack(candidate: schema.Candidate) -> str:
"""Build the lowercase text blob against which entity-grounding is checked.
Expanded 2026-04-19 to include transcript snippets, transcript highlights,
and top-comment text. The prior `title + snippet` check missed YouTube
videos whose entity mentions live in transcript content and Reddit posts
whose mentions are in top comments. Now checks all text surfaces a human
would see.
"""
parts: list[str] = [candidate.title or "", candidate.snippet or ""]
metadata = candidate.metadata or {}
transcript_snippet = metadata.get("transcript_snippet") or ""
if isinstance(transcript_snippet, str):
parts.append(transcript_snippet)
for hl in metadata.get("transcript_highlights") or []:
if isinstance(hl, str):
parts.append(hl)
for tc in metadata.get("top_comments") or []:
if isinstance(tc, dict):
parts.append(str(tc.get("excerpt", "") or tc.get("text", "") or ""))
elif isinstance(tc, str):
parts.append(tc)
for insight in metadata.get("comment_insights") or []:
if isinstance(insight, str):
parts.append(insight)
return " ".join(parts).lower()
def _fallback_tuple(candidate: schema.Candidate, *, primary_entity: str = "") -> tuple[float, str]:
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)
)
reason = "fallback-local-score"
# Entity-grounding demotion: if the primary entity (topic minus intent
# modifier) is not present anywhere in the candidate's text surfaces
# (title, snippet, transcript, transcript highlights, top comments,
# insights), subtract ENTITY_MISS_PENALTY. Skip for candidates with
# NO text anywhere (e.g., image-only TikToks) to avoid penalizing
# thin-text sources unfairly. 2026-04-19 Nate Herk "Managed Agents"
# video ranked #2 on a Hermes query despite zero Hermes mentions
# because the old haystack only checked title + snippet.
if primary_entity:
haystack = _candidate_haystack(candidate)
if haystack.strip() and primary_entity.lower() not in haystack:
score -= ENTITY_MISS_PENALTY
reason = "fallback-local-score (entity-miss demotion)"
return max(0.0, min(100.0, score)), reason
def _primary_entity(topic: str) -> str:
"""Extract the primary entity from the topic for grounding checks.
Strips intent-modifier suffixes (see planner._INTENT_MODIFIER_PATTERNS),
trims trailing punctuation, collapses whitespace. Returns the empty
string for topics that are all intent modifier with no entity, so
callers can skip the grounding check.
"""
stripped = _INTENT_MODIFIER_RE.sub(" ", topic)
# Also collapse multiple spaces and strip punctuation.
stripped = re.sub(r"\s+", " ", stripped).strip(" \t\r\n?.,:;!")
return stripped
#: Secondary entity-miss penalty applied directly to final_score (not just
#: rerank_score). The -25 on rerank_score composes to only -15 on final_score
#: via the 0.60 weight, which engagement bonus partially offsets on
#: high-view YouTube items. This secondary penalty lands the full weight on
#: the composite signal the cluster-scoring layer consumes. 2026-04-19
#: Nate Herk "Managed Agents" video ranked at cluster #2 with score 51
#: despite the rerank_score demotion because engagement + freshness drowned
#: the dilute penalty. This backstop makes the demotion actually decisive.
ENTITY_MISS_FINAL_PENALTY = 20.0
return max(0.0, min(100.0, score)), "fallback-local-score"
def _final_score(candidate: schema.Candidate) -> float:
@@ -313,11 +204,6 @@ def _final_score(candidate: schema.Candidate) -> float:
)
if candidate.rerank_score is not None and candidate.rerank_score < 20.0:
base *= 0.3
# Secondary entity-grounding penalty: when the fallback path flagged
# entity-miss via candidate.explanation, apply an additional penalty
# at final_score level so engagement signal can't mask the demotion.
if candidate.explanation and "entity-miss" in candidate.explanation:
base = max(0.0, base - ENTITY_MISS_FINAL_PENALTY)
return base
+5 -67
View File
@@ -11,64 +11,14 @@ import re
import sys
from concurrent.futures import ThreadPoolExecutor, as_completed
from datetime import datetime, timezone
from typing import Optional
from . import categories, dates, grounding
MAX_SUBS = 10
from . import dates, grounding
def _log(msg: str) -> None:
print(f"[Resolve] {msg}", file=sys.stderr)
def _merge_category_peers(topic: str, subreddits: list[str]) -> tuple[list[str], Optional[str]]:
"""Extend the WebSearch-extracted subreddit list with category peers.
Classifies the topic, fetches the category's peer subs, dedupes
case-insensitively against the existing list, and appends missing
peers in priority order. Caps the final list at MAX_SUBS, preserving
every WebSearch-returned sub (they are the freshest signal) and
trimming from the peer-additions end.
Returns a tuple of (merged_subs, matched_category_id_or_None).
Emits a [Resolve] Matched category log line only when peers were
actually added (not when every peer was already in the WebSearch set).
Classification failures degrade to "no match" the unwidened list
is returned and a warning is logged.
"""
try:
category = categories.detect_category(topic)
except Exception as exc:
_log(f"Category classification failed: {exc}")
return list(subreddits)[:MAX_SUBS], None
if category is None:
return list(subreddits)[:MAX_SUBS], None
peers = categories.peer_subs_for(category)
if not peers:
return list(subreddits)[:MAX_SUBS], category
existing_lower = {s.lower() for s in subreddits}
merged = list(subreddits)
added: list[str] = []
for peer in peers:
if len(merged) >= MAX_SUBS:
break
if peer.lower() in existing_lower:
continue
merged.append(peer)
existing_lower.add(peer.lower())
added.append(peer)
if added:
_log(f"Matched category={category}, adding peers: {', '.join(added)}")
return merged, category
def _has_backend(config: dict) -> bool:
"""Check if any web search backend is available."""
return bool(
@@ -184,19 +134,10 @@ def auto_resolve(topic: str, config: dict) -> dict:
config: Dict with API keys (BRAVE_API_KEY, EXA_API_KEY, SERPER_API_KEY).
Returns:
Dict with keys: subreddits, x_handle, github_user, github_repos,
context, category, searches_run. Returns empty result if no web
search backend is available.
Dict with keys: subreddits, x_handle, context, searches_run.
Returns empty result if no web search backend is available.
"""
empty = {
"subreddits": [],
"x_handle": "",
"github_user": "",
"github_repos": [],
"context": "",
"category": None,
"searches_run": 0,
}
empty = {"subreddits": [], "x_handle": "", "context": "", "searches_run": 0}
if not _has_backend(config):
_log("No web search backend available, skipping resolve")
@@ -243,9 +184,7 @@ def auto_resolve(topic: str, config: dict) -> dict:
github_repos = _extract_github_repos(results.get("github", []))
context = _build_context_summary(results.get("news", []))
subreddits, category = _merge_category_peers(topic, subreddits)
_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)}, category={category!r}")
_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,
@@ -253,6 +192,5 @@ def auto_resolve(topic: str, config: dict) -> dict:
"github_user": github_user,
"github_repos": github_repos,
"context": context,
"category": category,
"searches_run": searches_run,
}
+5 -30
View File
@@ -19,6 +19,7 @@ SOURCE_QUALITY = {
"polymarket": 0.5,
"instagram": 0.58,
"tiktok": 0.58,
"podcasts": 0.88,
}
@@ -82,11 +83,12 @@ def _top_comment_score(item: schema.SourceItem) -> float:
# Per-source engagement weights: list of (field_name, weight) tuples.
# Reddit, YouTube, and TikTok use custom functions because they include
# a dedicated 10% top-comment-score slot (see _reddit_engagement,
# _youtube_engagement, _tiktok_engagement).
# 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)],
@@ -112,29 +114,6 @@ def _reddit_engagement(item: schema.SourceItem) -> float | None:
return (0.50 * score) + (0.35 * comments) + (0.05 * (ratio * 10.0)) + (0.10 * top_comment)
def _youtube_engagement(item: schema.SourceItem) -> float | None:
views = log1p_safe(item.engagement.get("views"))
likes = log1p_safe(item.engagement.get("likes"))
comments = log1p_safe(item.engagement.get("comments"))
top_comment = _top_comment_score(item)
if not any([views, likes, comments, top_comment]):
return None
# Mirrors Reddit: carve out 10% for top-comment signal, keep view-weight
# dominant. Without comments, the pre-change weights (0.50/0.35/0.15)
# still govern relative ordering.
return (0.45 * views) + (0.32 * likes) + (0.13 * comments) + (0.10 * top_comment)
def _tiktok_engagement(item: schema.SourceItem) -> float | None:
views = log1p_safe(item.engagement.get("views"))
likes = log1p_safe(item.engagement.get("likes"))
comments = log1p_safe(item.engagement.get("comments"))
top_comment = _top_comment_score(item)
if not any([views, likes, comments, top_comment]):
return None
return (0.45 * views) + (0.27 * likes) + (0.18 * comments) + (0.10 * top_comment)
def _generic_engagement(item: schema.SourceItem) -> float | None:
if not item.engagement:
return None
@@ -147,10 +126,6 @@ def _generic_engagement(item: schema.SourceItem) -> float | None:
def engagement_raw(item: schema.SourceItem) -> float | None:
if item.source == "reddit":
return _reddit_engagement(item)
if item.source == "youtube":
return _youtube_engagement(item)
if item.source == "tiktok":
return _tiktok_engagement(item)
weights = ENGAGEMENT_WEIGHTS.get(item.source)
if weights:
return _weighted_engagement(item, weights)
+33 -12
View File
@@ -9,9 +9,10 @@ API docs: https://scrapecreators.com/docs
import math
import re
from datetime import datetime, timezone
from typing import Any, Dict, List, Optional
from . import dates, http, log
from . import http, log
from .relevance import token_overlap_relevance as _compute_relevance
SCRAPECREATORS_BASE = "https://api.scrapecreators.com/v1/threads"
@@ -28,6 +29,14 @@ 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
@@ -43,17 +52,29 @@ def _extract_core_subject(topic: str) -> str:
def _parse_date(item: Dict[str, Any]) -> Optional[str]:
"""Parse date from Threads item to YYYY-MM-DD.
Tries common timestamp fields in order: taken_at and create_time
(unix timestamps in Meta APIs), then created_at, published_at, and
date (ISO 8601 strings). dates.parse_date() handles both.
Tries common timestamp fields: taken_at (unix), created_at (ISO),
and falls back to any date-like string field.
"""
for key in ("taken_at", "create_time", "created_at", "published_at", "date"):
# 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 is None:
continue
dt = dates.parse_date(str(val))
if dt:
return dt.strftime("%Y-%m-%d")
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
@@ -162,7 +183,7 @@ def search_threads(
from urllib.parse import urlencode
params = urlencode({"keyword": core_topic})
url = f"{SCRAPECREATORS_BASE}/search?{params}"
headers = http.scrapecreators_headers(token)
headers = _sc_headers(token)
headers["User-Agent"] = http.USER_AGENT
data = http.get(url, headers=headers, timeout=30, retries=2)
except Exception as e:
@@ -173,7 +194,7 @@ def search_threads(
resp = _requests.get(
f"{SCRAPECREATORS_BASE}/search",
params={"keyword": core_topic},
headers=http.scrapecreators_headers(token),
headers=_sc_headers(token),
timeout=30,
)
resp.raise_for_status()
+15 -143
View File
@@ -109,6 +109,14 @@ def _log(msg: str):
log.source_log("TikTok", msg)
def _sc_headers(token: str) -> Dict[str, str]:
"""Build ScrapeCreators request headers."""
return {
"x-api-key": token,
"Content-Type": "application/json",
}
def _parse_date(item: Dict[str, Any]) -> Optional[str]:
"""Parse date from ScrapeCreators TikTok item to YYYY-MM-DD."""
ts = item.get("create_time")
@@ -219,7 +227,7 @@ def _hashtag_search(
from urllib.parse import urlencode
params = urlencode({"hashtag": hashtag})
url = f"{SCRAPECREATORS_BASE}/search/hashtag?{params}"
headers = http.scrapecreators_headers(token)
headers = _sc_headers(token)
headers["User-Agent"] = http.USER_AGENT
data = http.get(url, headers=headers, timeout=30, retries=2)
except Exception as e:
@@ -230,7 +238,7 @@ def _hashtag_search(
resp = _requests.get(
f"{SCRAPECREATORS_BASE}/search/hashtag",
params={"hashtag": hashtag},
headers=http.scrapecreators_headers(token),
headers=_sc_headers(token),
timeout=30,
)
resp.raise_for_status()
@@ -266,7 +274,7 @@ def _profile_videos(
from urllib.parse import urlencode
params = urlencode({"handle": handle, "sort_by": "latest"})
url = f"{profile_url}?{params}"
headers = http.scrapecreators_headers(token)
headers = _sc_headers(token)
headers["User-Agent"] = http.USER_AGENT
data = http.get(url, headers=headers, timeout=30, retries=2)
except Exception as e:
@@ -277,7 +285,7 @@ def _profile_videos(
resp = _requests.get(
profile_url,
params={"handle": handle, "sort_by": "latest"},
headers=http.scrapecreators_headers(token),
headers=_sc_headers(token),
timeout=30,
)
resp.raise_for_status()
@@ -324,7 +332,7 @@ def search_tiktok(
from urllib.parse import urlencode
params = urlencode({"query": core_topic, "sort_by": "relevance"})
url = f"{SCRAPECREATORS_BASE}/search/keyword?{params}"
headers = http.scrapecreators_headers(token)
headers = _sc_headers(token)
headers["User-Agent"] = http.USER_AGENT
data = http.get(url, headers=headers, timeout=30, retries=2)
except Exception as e:
@@ -335,7 +343,7 @@ def search_tiktok(
resp = _requests.get(
f"{SCRAPECREATORS_BASE}/search/keyword",
params={"query": core_topic, "sort_by": "relevance"},
headers=http.scrapecreators_headers(token),
headers=_sc_headers(token),
timeout=30,
)
resp.raise_for_status()
@@ -425,7 +433,7 @@ def fetch_captions(
resp = _requests.get(
f"{SCRAPECREATORS_BASE}/video/transcript",
params={"url": url},
headers=http.scrapecreators_headers(token),
headers=_sc_headers(token),
timeout=15,
)
if resp.status_code == 200:
@@ -539,139 +547,3 @@ def parse_tiktok_response(response: Dict[str, Any]) -> List[Dict[str, Any]]:
List of item dicts ready for normalization.
"""
return response.get("items", [])
def _tiktok_total_engagement(item: Dict[str, Any]) -> int:
"""Total engagement for ranking which posts deserve comment enrichment."""
eng = item.get("engagement", {})
return (eng.get("views", 0) or 0) + (eng.get("likes", 0) or 0) + (eng.get("comments", 0) or 0)
def enrich_with_comments(
items: List[Dict[str, Any]],
token: str,
max_posts: int = 3,
max_comments: int = 5,
) -> List[Dict[str, Any]]:
"""Enrich top TikTok posts with comment data from ScrapeCreators.
For the top N posts by engagement, fetches comments via the SC API
and attaches them as a ``top_comments`` field on each item. Mirrors
youtube_yt.enrich_with_comments.
Args:
items: TikTok items from search_tiktok()
token: ScrapeCreators API key
max_posts: How many posts to enrich with comments
max_comments: Max comments to keep per post
Returns:
Items list (mutated in place) with top_comments added to enriched items.
"""
if not items or not token or max_posts <= 0:
return items
ranked = sorted(items, key=_tiktok_total_engagement, reverse=True)
top_items = ranked[:max_posts]
_log(f"Enriching comments for {len(top_items)} TikTok posts")
from concurrent.futures import ThreadPoolExecutor, as_completed
def _enrich_one(item: dict) -> bool:
post_url = item.get("url", "")
if not post_url:
return False
try:
comments = _fetch_post_comments(post_url, token, max_comments)
if comments:
item["top_comments"] = comments
return True
except Exception as exc:
_log(f"Comment enrichment failed for {post_url}: {exc}")
return False
enriched_count = 0
with ThreadPoolExecutor(max_workers=min(4, len(top_items))) as executor:
futures = {executor.submit(_enrich_one, item): item for item in top_items}
for future in as_completed(futures):
if future.result():
enriched_count += 1
_log(f"Enriched {enriched_count}/{len(top_items)} posts with comments")
return items
def _fetch_post_comments(
post_url: str,
token: str,
max_comments: int = 5,
) -> List[Dict[str, Any]]:
"""Fetch comments for a single TikTok post via ScrapeCreators.
SC endpoint: GET /v1/tiktok/video/comments?url=<video_url>
Response shape: { comments: [{text, user.nickname, digg_count, create_time, ...}], cursor, total }
Args:
post_url: Canonical TikTok post URL (share_url form works)
token: ScrapeCreators API key
max_comments: Maximum comments to return
Returns:
List of comment dicts with author, text, digg_count (likes), date.
Empty list on any error comment failures never crash the pipeline.
"""
if not _requests:
try:
from urllib.parse import urlencode
params = urlencode({"url": post_url, "trim": "true"})
url = f"{SCRAPECREATORS_BASE}/video/comments?{params}"
headers = http.scrapecreators_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 {post_url}: {exc}")
return []
else:
try:
resp = _requests.get(
f"{SCRAPECREATORS_BASE}/video/comments",
params={"url": post_url, "trim": "true"},
headers=http.scrapecreators_headers(token),
timeout=30,
)
resp.raise_for_status()
data = resp.json()
except Exception as exc:
_log(f"Comment fetch error for {post_url}: {exc}")
return []
raw_comments = data.get("comments") or data.get("data") or []
# Sort by digg_count desc so normalize sees the highest-signal first.
raw_comments = sorted(
raw_comments,
key=lambda c: c.get("digg_count", 0) or 0,
reverse=True,
)
out: List[Dict[str, Any]] = []
for c in raw_comments[:max_comments]:
text = c.get("text") or ""
if not text:
continue
user = c.get("user") if isinstance(c.get("user"), dict) else {}
# Prefer unique_id (the @handle) over nickname (display name) so
# downstream render can cite @handle consistently across platforms.
author = user.get("unique_id") or user.get("nickname") or ""
create_time = c.get("create_time")
date_str = ""
if create_time:
try:
date_str = dates.timestamp_to_date(int(create_time)) or ""
except (ValueError, TypeError):
date_str = ""
out.append({
"author": author,
"text": text[:400],
"digg_count": c.get("digg_count", 0) or 0,
"date": date_str,
})
return out
+103 -116
View File
@@ -18,130 +18,117 @@ const SearchClient = withSearch(TwitterClientBase);
const args = process.argv.slice(2);
function writeStdout(text) {
if (text) process.stdout.write(text);
}
function writeStderr(text) {
if (text) process.stderr.write(text);
}
async function main() {
// --check: verify that credentials can be resolved
if (args.includes('--check')) {
try {
const { cookies, warnings } = await resolveCredentials({});
if (cookies.authToken && cookies.ct0) {
writeStdout(JSON.stringify({ authenticated: true, source: cookies.source }));
return 0;
}
writeStdout(JSON.stringify({ authenticated: false, warnings }));
return 1;
} catch (err) {
writeStdout(JSON.stringify({ authenticated: false, error: err.message }));
return 1;
}
}
// --whoami: check auth and output source
if (args.includes('--whoami')) {
try {
const { cookies } = await resolveCredentials({});
if (cookies.authToken && cookies.ct0) {
writeStdout(cookies.source || 'authenticated');
return 0;
}
writeStderr('Not authenticated\n');
return 1;
} catch (err) {
writeStderr(`Auth check failed: ${err.message}\n`);
return 1;
}
}
// Parse search args
let query = null;
let count = 20;
let jsonOutput = false;
for (let i = 0; i < args.length; i++) {
if (args[i] === '--count' && args[i + 1]) {
count = parseInt(args[i + 1], 10);
i++;
} else if (args[i] === '-n' && args[i + 1]) {
count = parseInt(args[i + 1], 10);
i++;
} else if (args[i] === '--json') {
jsonOutput = true;
} else if (!args[i].startsWith('-')) {
query = args[i];
}
}
if (!query) {
writeStderr('Usage: node bird-search.mjs <query> [--count N] [--json]\n');
return 1;
}
// --check: verify that credentials can be resolved
if (args.includes('--check')) {
try {
// Resolve credentials (env vars, then browser cookies)
const { cookies, warnings } = await resolveCredentials({});
if (!cookies.authToken || !cookies.ct0) {
const msg = warnings.length > 0 ? warnings.join('; ') : 'No Twitter credentials found';
if (jsonOutput) {
writeStdout(JSON.stringify({ error: msg, items: [] }));
} else {
writeStderr(`Error: ${msg}\n`);
}
return 1;
}
const client = new SearchClient({
cookies: {
authToken: cookies.authToken,
ct0: cookies.ct0,
cookieHeader: cookies.cookieHeader,
},
timeoutMs: 30000,
});
const result = await client.search(query, count);
if (!result.success) {
if (jsonOutput) {
writeStdout(JSON.stringify({ error: result.error, items: [] }));
} else {
writeStderr(`Search failed: ${result.error}\n`);
}
return 1;
}
const tweets = result.tweets || [];
if (jsonOutput) {
writeStdout(JSON.stringify(tweets));
if (cookies.authToken && cookies.ct0) {
process.stdout.write(JSON.stringify({ authenticated: true, source: cookies.source }));
process.exit(0);
} else {
for (const tweet of tweets) {
const author = tweet.author?.username || 'unknown';
writeStdout(`@${author}: ${tweet.text?.slice(0, 200)}\n\n`);
}
process.stdout.write(JSON.stringify({ authenticated: false, warnings }));
process.exit(1);
}
return 0;
} catch (err) {
if (jsonOutput) {
writeStdout(JSON.stringify({ error: err.message, items: [] }));
} else {
writeStderr(`Error: ${err.message}\n`);
}
return 1;
process.stdout.write(JSON.stringify({ authenticated: false, error: err.message }));
process.exit(1);
}
}
// --whoami: check auth and output source
if (args.includes('--whoami')) {
try {
const { cookies } = await resolveCredentials({});
if (cookies.authToken && cookies.ct0) {
process.stdout.write(cookies.source || 'authenticated');
process.exit(0);
} else {
process.stderr.write('Not authenticated\n');
process.exit(1);
}
} catch (err) {
process.stderr.write(`Auth check failed: ${err.message}\n`);
process.exit(1);
}
}
// Parse search args
let query = null;
let count = 20;
let jsonOutput = false;
for (let i = 0; i < args.length; i++) {
if (args[i] === '--count' && args[i + 1]) {
count = parseInt(args[i + 1], 10);
i++;
} else if (args[i] === '-n' && args[i + 1]) {
count = parseInt(args[i + 1], 10);
i++;
} else if (args[i] === '--json') {
jsonOutput = true;
} else if (!args[i].startsWith('-')) {
query = args[i];
}
}
if (!query) {
process.stderr.write('Usage: node bird-search.mjs <query> [--count N] [--json]\n');
process.exit(1);
}
try {
const code = await main();
process.exitCode = Number.isInteger(code) ? code : 1;
// Resolve credentials (env vars, then browser cookies)
const { cookies, warnings } = await resolveCredentials({});
if (!cookies.authToken || !cookies.ct0) {
const msg = warnings.length > 0 ? warnings.join('; ') : 'No Twitter credentials found';
if (jsonOutput) {
process.stdout.write(JSON.stringify({ error: msg, items: [] }));
} else {
process.stderr.write(`Error: ${msg}\n`);
}
process.exit(1);
}
// Create search client
const client = new SearchClient({
cookies: {
authToken: cookies.authToken,
ct0: cookies.ct0,
cookieHeader: cookies.cookieHeader,
},
timeoutMs: 30000,
});
// Run search
const result = await client.search(query, count);
if (!result.success) {
if (jsonOutput) {
process.stdout.write(JSON.stringify({ error: result.error, items: [] }));
} else {
process.stderr.write(`Search failed: ${result.error}\n`);
}
process.exit(1);
}
// Output results
const tweets = result.tweets || [];
if (jsonOutput) {
process.stdout.write(JSON.stringify(tweets));
} else {
for (const tweet of tweets) {
const author = tweet.author?.username || 'unknown';
process.stdout.write(`@${author}: ${tweet.text?.slice(0, 200)}\n\n`);
}
}
process.exit(0);
} catch (err) {
writeStderr(`Fatal error: ${err?.message || err}\n`);
process.exitCode = 1;
if (jsonOutput) {
process.stdout.write(JSON.stringify({ error: err.message, items: [] }));
} else {
process.stderr.write(`Error: ${err.message}\n`);
}
process.exit(1);
}
-171
View File
@@ -1,171 +0,0 @@
"""X (Twitter) search via xurl CLI — official X API v2 with OAuth2.
xurl is an open-source CLI for the X API (https://github.com/openclaw/xurl).
It uses OAuth2 with PKCE and automatic token refresh, requiring only a free
X Developer App. No xAI subscription or browser cookies needed.
Install: npm install -g xurl
Auth: xurl auth oauth2 login
Priority: xAI API > Bird/GraphQL > xurl > web-only fallback
"""
import json
import re
import subprocess
import sys
from typing import Any, Dict, List, Optional
from .relevance import token_overlap_relevance as _compute_relevance
def _log(msg: str) -> None:
sys.stderr.write(f"[xurl] {msg}\n")
sys.stderr.flush()
# Depth configurations: number of results to request
DEPTH_CONFIG = {
"quick": 10,
"default": 30,
"deep": 60,
}
def is_available() -> bool:
"""Check if xurl is installed and has valid authentication.
Returns True only if xurl binary is found AND the user is authenticated
(i.e. ``xurl whoami`` exits 0 and returns a username field).
"""
try:
result = subprocess.run(
["xurl", "whoami"],
capture_output=True,
text=True,
timeout=10,
)
return result.returncode == 0 and '"username"' in result.stdout
except FileNotFoundError:
return False
except subprocess.TimeoutExpired:
return False
def search_x(
query: str,
depth: str = "default",
) -> Dict[str, Any]:
"""Search X via xurl CLI using X API v2 search/recent.
Args:
query: Search query string
depth: "quick", "default", or "deep"
Returns:
Raw JSON response from X API v2 tweets/search/recent, or a dict
with an "error" key on failure.
"""
max_results = DEPTH_CONFIG.get(depth, DEPTH_CONFIG["default"])
# X API v2 search/recent requires max_results in 10100 range
max_results = max(10, min(100, max_results))
try:
result = subprocess.run(
["xurl", "search", query, "-n", str(max_results)],
capture_output=True,
text=True,
timeout=30,
)
if result.returncode != 0:
error_text = result.stderr.strip() or result.stdout.strip()
return {"error": f"xurl search failed: {error_text}"}
return json.loads(result.stdout)
except FileNotFoundError:
return {"error": "xurl not found in PATH"}
except subprocess.TimeoutExpired:
return {"error": "xurl search timed out (30s)"}
except json.JSONDecodeError as exc:
return {"error": f"Invalid JSON from xurl: {exc}"}
except Exception as exc:
return {"error": f"{type(exc).__name__}: {exc}"}
def parse_x_response(
response: Dict[str, Any],
topic: str = "",
) -> List[Dict[str, Any]]:
"""Parse xurl search response into normalized item dicts.
Output format matches the existing XItem schema used by xai_x and bird_x:
id, text, url, author_handle, date, engagement, why_relevant, relevance.
Args:
response: Raw X API v2 response dict from search_x()
topic: Original search topic (used for relevance scoring)
Returns:
List of item dicts. Empty list on error or no results.
"""
items: List[Dict[str, Any]] = []
if "error" in response:
_log(f"Error in response: {response['error']}")
return items
data = response.get("data") or []
if not data:
return items
# Build author lookup from includes.users
authors: Dict[str, Dict[str, Any]] = {}
for user in (response.get("includes") or {}).get("users") or []:
authors[user["id"]] = user
for i, tweet in enumerate(data):
author_id = tweet.get("author_id", "")
author = authors.get(author_id, {})
username = author.get("username", "")
tweet_id = tweet.get("id", "")
url = f"https://x.com/{username}/status/{tweet_id}" if username else ""
# Parse public_metrics
engagement: Optional[Dict[str, Any]] = None
metrics = tweet.get("public_metrics") or {}
if metrics:
engagement = {
"likes": metrics.get("like_count", 0),
"reposts": metrics.get("retweet_count", 0),
"replies": metrics.get("reply_count", 0),
"quotes": metrics.get("quote_count", 0),
}
# Parse ISO 8601 date → YYYY-MM-DD
date: Optional[str] = None
created = tweet.get("created_at", "")
if created:
m = re.match(r"(\d{4}-\d{2}-\d{2})", created)
if m:
date = m.group(1)
text = tweet.get("text", "").strip()
# Relevance score via shared token-overlap function
relevance = _compute_relevance(topic, text) if topic else 0.5
items.append({
"id": f"XURL{i + 1}",
"text": text[:500],
"url": url,
"author_handle": username,
"date": date,
"engagement": engagement,
"why_relevant": "",
"relevance": relevance,
})
return items
+21 -36
View File
@@ -655,6 +655,14 @@ 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", {})
@@ -732,13 +740,12 @@ def _fetch_video_comments(
Returns:
List of comment dicts with author, text, likes, date.
"""
video_url = f"https://www.youtube.com/watch?v={video_id}"
if not _requests:
try:
from urllib.parse import urlencode
params = urlencode({"url": video_url})
params = urlencode({"id": video_id})
url = f"{SCRAPECREATORS_YT_BASE}/video/comments?{params}"
headers = http.scrapecreators_headers(token)
headers = _sc_headers(token)
headers["User-Agent"] = http.USER_AGENT
data = http.get(url, headers=headers, timeout=30, retries=2)
except Exception as exc:
@@ -748,8 +755,8 @@ def _fetch_video_comments(
try:
resp = _requests.get(
f"{SCRAPECREATORS_YT_BASE}/video/comments",
params={"url": video_url},
headers=http.scrapecreators_headers(token),
params={"id": video_id},
headers=_sc_headers(token),
timeout=30,
)
resp.raise_for_status()
@@ -764,32 +771,11 @@ def _fetch_video_comments(
text = c.get("text") or c.get("body") or c.get("content", "")
if not text:
continue
# SC returns author as {"name": "@handle", ...}; legacy mocks may pass a string.
author = c.get("author") or c.get("author_name", "")
if isinstance(author, dict):
author = author.get("name") or author.get("handle") or ""
# SC nests likes under engagement.likes; legacy shapes used top-level keys.
engagement = c.get("engagement") or {}
likes = c.get("likes")
if likes is None:
likes = engagement.get("likes", 0) if isinstance(engagement, dict) else 0
if not likes:
likes = c.get("vote_count", 0)
date = (
c.get("date")
or c.get("published_at")
or c.get("publishedTime")
or c.get("publishedTimeText", "")
)
comments.append({
"author": author,
"author": c.get("author") or c.get("author_name", ""),
"text": text[:400],
"likes": likes,
"date": date,
"likes": c.get("likes") or c.get("vote_count", 0),
"date": c.get("date") or c.get("published_at", ""),
})
return comments
@@ -920,7 +906,7 @@ def _sc_youtube_search(keyword: str, token: str) -> List[Dict[str, Any]]:
from urllib.parse import urlencode
params = urlencode({"keyword": keyword})
url = f"{SCRAPECREATORS_YT_BASE}/search?{params}"
headers = http.scrapecreators_headers(token)
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", [])))
@@ -932,7 +918,7 @@ def _sc_youtube_search(keyword: str, token: str) -> List[Dict[str, Any]]:
resp = _requests.get(
f"{SCRAPECREATORS_YT_BASE}/search",
params={"keyword": keyword},
headers=http.scrapecreators_headers(token),
headers=_sc_headers(token),
timeout=30,
)
resp.raise_for_status()
@@ -953,13 +939,12 @@ def _sc_fetch_transcript(video_id: str, token: str) -> Optional[str]:
Returns:
Plaintext transcript string, or None if unavailable.
"""
video_url = f"https://www.youtube.com/watch?v={video_id}"
if not _requests:
try:
from urllib.parse import urlencode
params = urlencode({"url": video_url})
params = urlencode({"id": video_id})
url = f"{SCRAPECREATORS_YT_BASE}/video/transcript?{params}"
headers = http.scrapecreators_headers(token)
headers = _sc_headers(token)
headers["User-Agent"] = http.USER_AGENT
data = http.get(url, headers=headers, timeout=30, retries=2)
except Exception as exc:
@@ -969,8 +954,8 @@ def _sc_fetch_transcript(video_id: str, token: str) -> Optional[str]:
try:
resp = _requests.get(
f"{SCRAPECREATORS_YT_BASE}/video/transcript",
params={"url": video_url},
headers=http.scrapecreators_headers(token),
params={"id": video_id},
headers=_sc_headers(token),
timeout=30,
)
if resp.status_code != 200:
+4 -58
View File
@@ -11,7 +11,7 @@ COMMON_TARGETS=(
# 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.1"
"$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"
@@ -24,7 +24,7 @@ sync_target() {
echo ""
echo "--- Syncing to $target ---"
mkdir -p "$target/scripts/lib"
mkdir -p "$target/scripts/lib" "$target/variants/open/references"
cp "$skill_md" "$target/SKILL.md"
@@ -35,13 +35,7 @@ sync_target() {
"$SRC/scripts/store.py" \
"$target/scripts/"
rsync -a "$SRC/scripts/lib/"*.py "$target/scripts/lib/"
# The OpenClaw variant lives in the private repo only. Skip cleanly when
# running this script from the public repo where variants/open does not exist.
if [ -d "$SRC/variants/open" ]; then
mkdir -p "$target/variants/open/references"
rsync -a "$SRC/variants/open/" "$target/variants/open/"
fi
rsync -a "$SRC/variants/open/" "$target/variants/open/"
if [ -d "$SRC/scripts/lib/vendor" ]; then
rsync -a "$SRC/scripts/lib/vendor" "$target/scripts/lib/"
@@ -69,55 +63,7 @@ for t in "${COMMON_TARGETS[@]}"; do
sync_target "$t" "$SRC/SKILL.md"
done
# Hermes sync: deploy to Hermes skills directory if it exists
HERMES_TARGET="$HOME/.hermes/skills/research/last30days"
if [ -d "$HOME/.hermes/skills/research" ]; then
echo ""
echo "--- Syncing to Hermes ---"
mkdir -p "$HERMES_TARGET/scripts/lib"
cp "$SRC/SKILL.md" "$HERMES_TARGET/SKILL.md"
rsync -a \
"$SRC/scripts/last30days.py" \
"$SRC/scripts/watchlist.py" \
"$SRC/scripts/briefing.py" \
"$SRC/scripts/store.py" \
"$HERMES_TARGET/scripts/"
rsync -a "$SRC/scripts/lib/"*.py "$HERMES_TARGET/scripts/lib/"
if [ -d "$SRC/scripts/lib/vendor" ]; then
rsync -a "$SRC/scripts/lib/vendor" "$HERMES_TARGET/scripts/lib/"
fi
if [ -d "$SRC/fixtures" ]; then
mkdir -p "$HERMES_TARGET/fixtures"
rsync -a "$SRC/fixtures/" "$HERMES_TARGET/fixtures/"
fi
mod_count=$(ls "$HERMES_TARGET/scripts/lib/"*.py 2>/dev/null | wc -l | tr -d ' ')
echo " Copied $mod_count modules to Hermes"
if (
cd "$HERMES_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
fi
# OpenClaw sync only runs when the private-repo OpenClaw variant is present
# in the source tree. The public repo does not ship variants/open (the variant
# is sanitized via strip_for_openclaw.py and published separately from
# last30days-skill-private).
if [ -d "$SRC/variants/open" ]; then
sync_target "$OPENCLAW_TARGET" "$SRC/variants/open/SKILL.md"
else
echo ""
echo "Skipping OpenClaw target (no variants/open in this repo)"
fi
sync_target "$OPENCLAW_TARGET" "$SRC/variants/open/SKILL.md"
echo ""
echo "Sync complete."
+1
View File
@@ -0,0 +1 @@
../../SKILL.md
+231
View File
@@ -0,0 +1,231 @@
---
name: last30days
version: "3.0.0"
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 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
for py in python3.14 python3.13 python3.12 python3; do
command -v "$py" >/dev/null 2>&1 || continue
"$py" -c 'import sys; raise SystemExit(0 if sys.version_info >= (3, 12) else 1)' || continue
LAST30DAYS_PYTHON="$py"
break
done
if [ -z "${LAST30DAYS_PYTHON:-}" ]; then
echo "ERROR: last30days v3 requires Python 3.12+. Install python3.12 or python3.13 and rerun." >&2
exit 1
fi
```
## Default command
```bash
"${LAST30DAYS_PYTHON}" "${SKILL_ROOT}/scripts/last30days.py" $ARGUMENTS --emit=compact
```
## Useful commands
```bash
"${LAST30DAYS_PYTHON}" "${SKILL_ROOT}/scripts/last30days.py" $ARGUMENTS --emit=json
"${LAST30DAYS_PYTHON}" "${SKILL_ROOT}/scripts/last30days.py" $ARGUMENTS --quick
"${LAST30DAYS_PYTHON}" "${SKILL_ROOT}/scripts/last30days.py" $ARGUMENTS --deep
"${LAST30DAYS_PYTHON}" "${SKILL_ROOT}/scripts/last30days.py" $ARGUMENTS --search=reddit,x,grounding
"${LAST30DAYS_PYTHON}" "${SKILL_ROOT}/scripts/last30days.py" $ARGUMENTS --store
"${LAST30DAYS_PYTHON}" "${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
-60
View File
@@ -1,60 +0,0 @@
# Fixture: `Prompting GPT Image 2` Resolved-block regression
Documentation-grade fixture. Captures the pre-fix and post-fix shape of the
Step 0.55 Resolved block for the topic `Prompting GPT Image 2`. Not parsed
by test code — read by reviewers when evaluating regressions in
`scripts/lib/categories.py` or the SKILL.md Step 0.55 block.
The live assertion lives in `tests/test_category_integration.py`. This
markdown fixture exists so reviewers can eyeball expected behavior without
running pytest.
## Failing run (2026-04-22, pre-fix)
User ran `/last30days Prompting GPT Image 2`. Step 0.55 WebSearch returned
OpenAI-brand communities. The model resolved exactly those.
```
Resolved:
- X: @OpenAI (+ @sama, @openaidevs)
- Reddit: r/OpenAI, r/ChatGPT, r/singularity, r/artificial, r/ChatGPTpromptengineering
- TikTok: #gptimage2, #openai, #aiart
```
Engine run returned thin results. User manually intervened with "make sure
to check image generatorion reddits too" and re-ran with the image-gen
peer subs added.
## Expected run (post-fix, no user intervention)
After Step 0.55 Section 2a (category-peer expansion) and Unit 2's engine-side
merge in `auto_resolve`, the same topic produces:
```
Resolved:
- X: @OpenAI (+ @sama, @openaidevs)
- Reddit: r/OpenAI, r/ChatGPT, r/singularity, r/ChatGPTpromptengineering, r/StableDiffusion, r/midjourney, r/dalle2, r/aiArt (+ ai_image_generation peers)
- TikTok: #gptimage2, #openai, #aiart
```
The peer subs (`StableDiffusion, midjourney, dalle2, aiArt`) appear alongside
the WebSearch-returned brand subs. The `(+ ai_image_generation peers)`
annotation is the observable contract — its absence on a product-in-a-known-
category topic is a Step 0.55 regression.
## Guards
- `tests/test_categories.py::DetectCategoryHappyPath::test_prompting_gpt_image_2_matches_image_generation`
- `tests/test_resolve.py::MergeCategoryPeersHappyPath::test_image_gen_topic_appends_peers`
- `tests/test_resolve.py::AutoResolveCategoryIntegration::test_auto_resolve_returns_category_key`
- `tests/test_category_integration.py` — end-to-end over `auto_resolve` with
a stubbed WebSearch that mimics the original failing response.
## When to update this fixture
- Category map changed (a peer sub was reordered, added, or removed).
- The observable Resolved-block annotation format changed.
- A new category was added that affects this topic.
Do not update casually. This file is the pre/post record of the 2026-04-22
failure.
+1 -27
View File
@@ -175,7 +175,7 @@ class TestVendoredBirdRuntime(unittest.TestCase):
}
]
items = parse_bird_response(tweets, "test query")
self.assertIsNone(items[0]["engagement"])
self.assertIsNone(items[0]["engagement"]["likes"])
def test_fallback_to_second_key(self):
tweets = [
@@ -203,32 +203,6 @@ class TestVendoredBirdRuntime(unittest.TestCase):
items = parse_bird_response(tweets, "test query")
self.assertEqual(0, items[0]["engagement"]["likes"])
def test_engagement_none_when_all_fields_missing(self):
"""All-None engagement dict should become None, not propagate."""
tweets = [
{
"id": "1",
"text": "test",
"permanent_url": "https://x.com/u/status/1",
}
]
items = parse_bird_response(tweets, "test query")
self.assertIsNone(items[0]["engagement"])
def test_engagement_preserved_when_any_field_present(self):
"""Engagement dict kept when at least one metric exists."""
tweets = [
{
"id": "1",
"text": "test",
"permanent_url": "https://x.com/u/status/1",
"likeCount": 5,
}
]
items = parse_bird_response(tweets, "test query")
self.assertIsNotNone(items[0]["engagement"])
self.assertEqual(5, items[0]["engagement"]["likes"])
if __name__ == "__main__":
unittest.main()
-154
View File
@@ -1,154 +0,0 @@
"""Unit tests for scripts/lib/categories.py — the Step 0.55 category-peer map.
Guards the 2026-04-22 `Prompting GPT Image 2` failure mode: the original bug
was that Step 0.55 resolved only brand-adjacent subs (r/OpenAI, r/ChatGPT)
and missed the category peers (r/StableDiffusion, r/midjourney, r/dalle2)
where prompting techniques actually live.
"""
import re
import sys
import unittest
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "scripts"))
from lib import categories
from lib.categories import CATEGORY_PEERS, detect_category, peer_subs_for
class DetectCategoryHappyPath(unittest.TestCase):
def test_prompting_gpt_image_2_matches_image_generation(self):
self.assertEqual(
detect_category("Prompting GPT Image 2"),
"ai_image_generation",
)
def test_claude_code_matches_coding_agent(self):
self.assertEqual(
detect_category("Claude Code skills"),
"ai_coding_agent",
)
def test_suno_matches_music_generation(self):
self.assertEqual(detect_category("Suno v4 review"), "ai_music_generation")
def test_polymarket_matches_prediction_markets(self):
self.assertEqual(
detect_category("Polymarket election odds"),
"prediction_markets",
)
def test_sora_matches_video_generation(self):
self.assertEqual(detect_category("Sora 2 prompts"), "ai_video_generation")
class PeerSubsForHappyPath(unittest.TestCase):
def test_image_generation_peer_subs_priority_order(self):
subs = peer_subs_for("ai_image_generation")
self.assertIn("StableDiffusion", subs)
self.assertIn("midjourney", subs)
self.assertIn("dalle2", subs)
self.assertLess(subs.index("StableDiffusion"), subs.index("midjourney"))
self.assertLess(subs.index("midjourney"), subs.index("dalle2"))
def test_unknown_category_returns_empty_list(self):
self.assertEqual(peer_subs_for("unknown_category"), [])
def test_none_category_returns_empty_list(self):
self.assertEqual(peer_subs_for(None), [])
def test_returned_list_is_fresh_copy(self):
first = peer_subs_for("ai_image_generation")
first.append("MutatedSub")
second = peer_subs_for("ai_image_generation")
self.assertNotIn("MutatedSub", second)
class DetectCategoryEdgeCases(unittest.TestCase):
def test_case_insensitive_match(self):
self.assertEqual(
detect_category("STABLE DIFFUSION walkthrough"),
"ai_image_generation",
)
def test_non_category_topic_returns_none(self):
self.assertIsNone(detect_category("Kanye West"))
def test_bare_image_word_does_not_trigger_image_generation(self):
# Compound-term guard: "image" alone is not a pattern; only
# multi-word compounds or domain-specific brand names match.
self.assertIsNone(detect_category("image editing on my phone"))
def test_bare_ai_word_does_not_trigger_any_category(self):
self.assertIsNone(detect_category("ai news today"))
def test_empty_topic_returns_none(self):
self.assertIsNone(detect_category(""))
def test_none_topic_returns_none(self):
self.assertIsNone(detect_category(None))
def test_first_match_wins_image_gen_before_chat_model(self):
# "gpt image 2" contains "gpt image" (ai_image_generation) and the
# substring "gpt" could resemble gpt-N chat-model patterns. The
# narrower category wins because it is declared earlier.
self.assertEqual(
detect_category("gpt image 2 review"),
"ai_image_generation",
)
class CategoryMapInvariants(unittest.TestCase):
"""Regression guards on the map itself — catch accidental bare-word patterns."""
# Common nouns that would produce false positives if used as bare patterns.
FORBIDDEN_BARE_PATTERNS = frozenset({
"image", "video", "music", "ai", "model", "agent", "chat",
"code", "cli", "app", "tool", "defi",
})
def test_no_category_has_a_bare_common_noun_pattern(self):
offenders = []
for category_id, entry in CATEGORY_PEERS.items():
for pattern in entry["patterns"]:
if pattern.strip() in self.FORBIDDEN_BARE_PATTERNS:
offenders.append((category_id, pattern))
self.assertEqual(
offenders,
[],
msg=(
"Bare common-noun patterns cause false positives. "
f"Offenders: {offenders}. Patterns must be compound "
"(e.g. 'image generation') or domain-specific "
"(e.g. 'midjourney')."
),
)
def test_every_category_has_at_least_one_compound_or_brand_pattern(self):
multi_word_or_brand = re.compile(r"(\s|-|\.)|^[a-z][a-z0-9]{3,}$")
for category_id, entry in CATEGORY_PEERS.items():
patterns = entry["patterns"]
self.assertTrue(patterns, f"{category_id} has no patterns")
has_strong = any(multi_word_or_brand.search(p) for p in patterns)
self.assertTrue(
has_strong,
f"{category_id} needs at least one multi-word or brand pattern",
)
def test_every_category_has_at_least_two_peer_subs(self):
for category_id, entry in CATEGORY_PEERS.items():
self.assertGreaterEqual(
len(entry["peer_subs"]),
2,
f"{category_id} should list at least 2 peer subs",
)
def test_category_count_is_in_expected_range(self):
# Sanity check: the map is intentionally small and curated.
self.assertGreaterEqual(len(CATEGORY_PEERS), 8)
self.assertLessEqual(len(CATEGORY_PEERS), 20)
if __name__ == "__main__":
unittest.main()
-145
View File
@@ -1,145 +0,0 @@
"""End-to-end regression test for the 2026-04-22 `Prompting GPT Image 2` bug.
Guards the failing run's Resolved-block shape end-to-end: stubs
`grounding.web_search` to return the OpenAI-only subs that caused the
original failure, then asserts that `auto_resolve` now returns the widened
list and emits the expected stderr trace.
If this test starts failing after a `scripts/lib/categories.py` edit, either
the fix regressed or the map intentionally dropped the `ai_image_generation`
category update the test deliberately.
Fixture reference: `tests/fixtures/prompting-gpt-image-2-resolved-block.md`.
"""
import io
import sys
import unittest
from contextlib import redirect_stderr
from pathlib import Path
from unittest.mock import patch
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "scripts"))
from lib import resolve
OPENAI_BRAND_SUBREDDIT_RESULTS = [
{
"title": "r/OpenAI community hub",
"snippet": "Discussion at r/ChatGPT and r/singularity about GPT Image 2.",
"url": "https://reddit.com/r/OpenAI/",
},
{
"title": "r/ChatGPTpromptengineering prompt collection",
"snippet": "Also see r/artificial for broader AI chatter.",
"url": "",
},
]
EMPTY_RESULTS: list[dict] = []
def _fake_websearch(label_to_items: dict[str, list[dict]]):
def _search(query, date_range, config):
if "subreddit" in query:
return label_to_items.get("subreddit", EMPTY_RESULTS), {}
if "news" in query:
return label_to_items.get("news", EMPTY_RESULTS), {}
if "handle" in query:
return label_to_items.get("x_handle", EMPTY_RESULTS), {}
if "github" in query:
return label_to_items.get("github", EMPTY_RESULTS), {}
return EMPTY_RESULTS, {}
return _search
class PromptingGptImage2RegressionGuard(unittest.TestCase):
"""The named 2026-04-22 failure mode. Resolved block must include peers."""
@patch("lib.resolve.grounding.web_search")
def test_auto_resolve_widens_to_image_gen_peers(self, mock_search):
mock_search.side_effect = _fake_websearch({
"subreddit": OPENAI_BRAND_SUBREDDIT_RESULTS,
})
result = resolve.auto_resolve(
"Prompting GPT Image 2",
{"BRAVE_API_KEY": "fake"},
)
subs_lower = [s.lower() for s in result["subreddits"]]
# Original WebSearch-returned brand subs preserved
self.assertIn("openai", subs_lower)
self.assertIn("chatgpt", subs_lower)
self.assertIn("singularity", subs_lower)
# At least three of the image-gen peers were added
expected_peers = {"stablediffusion", "midjourney", "dalle2", "aiart", "promptengineering"}
found_peers = expected_peers.intersection(subs_lower)
self.assertGreaterEqual(
len(found_peers),
3,
f"Expected at least 3 image-gen peer subs, found: {found_peers}. "
f"Actual subs: {result['subreddits']}",
)
self.assertEqual(result["category"], "ai_image_generation")
@patch("lib.resolve.grounding.web_search")
def test_stderr_contains_category_match_log_line(self, mock_search):
mock_search.side_effect = _fake_websearch({
"subreddit": OPENAI_BRAND_SUBREDDIT_RESULTS,
})
buf = io.StringIO()
with redirect_stderr(buf):
resolve.auto_resolve(
"Prompting GPT Image 2",
{"BRAVE_API_KEY": "fake"},
)
self.assertIn("Matched category=ai_image_generation", buf.getvalue())
@patch("lib.resolve.grounding.web_search")
def test_cap_enforced_end_to_end(self, mock_search):
# Synthesize a subreddit response with 9 brand subs
many_subs_items = [
{"title": f"r/Brand{i}", "snippet": "", "url": ""}
for i in range(9)
]
mock_search.side_effect = _fake_websearch({
"subreddit": many_subs_items,
})
result = resolve.auto_resolve(
"Prompting GPT Image 2",
{"BRAVE_API_KEY": "fake"},
)
self.assertLessEqual(len(result["subreddits"]), resolve.MAX_SUBS)
# The first WebSearch sub is still present (brand subs never evicted)
self.assertIn("Brand0", result["subreddits"])
@patch("lib.resolve.grounding.web_search")
def test_uncategorized_topic_does_not_inject_peers(self, mock_search):
mock_search.side_effect = _fake_websearch({
"subreddit": [{"title": "r/Kanye is wild", "snippet": "", "url": ""}],
})
buf = io.StringIO()
with redirect_stderr(buf):
result = resolve.auto_resolve(
"Kanye West latest album",
{"BRAVE_API_KEY": "fake"},
)
self.assertEqual(result["subreddits"], ["Kanye"])
self.assertIsNone(result["category"])
self.assertNotIn("Matched category=", buf.getvalue())
if __name__ == "__main__":
unittest.main()
-137
View File
@@ -1,137 +0,0 @@
# ruff: noqa: E402
"""CLI parsing and validation for --competitors / --competitors-list."""
from __future__ import annotations
import io
import sys
import unittest
from contextlib import redirect_stderr
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(REPO_ROOT / "scripts"))
import last30days as cli
def _parse(*argv: str):
parser = cli.build_parser()
args, _extra = parser.parse_known_args(argv)
return args
class CompetitorsCliTests(unittest.TestCase):
def test_flag_absent_returns_disabled(self):
args = _parse("Kanye West")
enabled, count, explicit = cli.resolve_competitors_args(args)
self.assertFalse(enabled)
self.assertEqual(count, 0)
self.assertEqual(explicit, [])
def test_bare_flag_defaults_to_two(self):
args = _parse("Kanye West", "--competitors")
enabled, count, explicit = cli.resolve_competitors_args(args)
self.assertTrue(enabled)
self.assertEqual(count, 2)
self.assertEqual(explicit, [])
def test_explicit_three_still_supported(self):
args = _parse("OpenAI", "--competitors", "3")
enabled, count, _explicit = cli.resolve_competitors_args(args)
self.assertTrue(enabled)
self.assertEqual(count, 3)
def test_explicit_count(self):
args = _parse("OpenAI", "--competitors", "4")
enabled, count, explicit = cli.resolve_competitors_args(args)
self.assertTrue(enabled)
self.assertEqual(count, 4)
self.assertEqual(explicit, [])
def test_explicit_list_preferred_over_discovery(self):
args = _parse(
"OpenAI",
"--competitors",
"--competitors-list",
"Anthropic,xAI,Google Gemini",
)
enabled, count, explicit = cli.resolve_competitors_args(args)
self.assertTrue(enabled)
self.assertEqual(count, 3)
self.assertEqual(explicit, ["Anthropic", "xAI", "Google Gemini"])
def test_explicit_list_without_flag_implies_enabled(self):
args = _parse("OpenAI", "--competitors-list", "Anthropic,xAI")
enabled, count, explicit = cli.resolve_competitors_args(args)
self.assertTrue(enabled)
self.assertEqual(count, 2)
self.assertEqual(explicit, ["Anthropic", "xAI"])
def test_list_whitespace_normalized(self):
args = _parse("OpenAI", "--competitors-list", " Anthropic , xAI , Gemini ")
_enabled, count, explicit = cli.resolve_competitors_args(args)
self.assertEqual(count, 3)
self.assertEqual(explicit, ["Anthropic", "xAI", "Gemini"])
def test_zero_count_rejected(self):
args = _parse("Topic", "--competitors", "0")
with self.assertRaises(SystemExit) as cm, redirect_stderr(io.StringIO()) as err:
cli.resolve_competitors_args(args)
self.assertEqual(cm.exception.code, 2)
self.assertIn("--competitors must be >= 1", err.getvalue())
def test_negative_count_rejected(self):
args = _parse("Topic", "--competitors", "-1")
with self.assertRaises(SystemExit), redirect_stderr(io.StringIO()):
cli.resolve_competitors_args(args)
def test_over_max_count_clamps_with_warning(self):
args = _parse("Topic", "--competitors", "99")
err = io.StringIO()
with redirect_stderr(err):
enabled, count, explicit = cli.resolve_competitors_args(args)
self.assertTrue(enabled)
self.assertEqual(count, cli.COMPETITORS_MAX)
self.assertEqual(explicit, [])
self.assertIn("clamping", err.getvalue())
def test_overlong_list_clamps_with_warning(self):
args = _parse(
"Topic",
"--competitors-list",
"A,B,C,D,E,F,G,H",
)
err = io.StringIO()
with redirect_stderr(err):
enabled, count, explicit = cli.resolve_competitors_args(args)
self.assertTrue(enabled)
self.assertEqual(count, cli.COMPETITORS_MAX)
self.assertEqual(len(explicit), cli.COMPETITORS_MAX)
self.assertIn("clamping to", err.getvalue())
def test_list_count_mismatch_warns(self):
args = _parse(
"Topic",
"--competitors",
"5",
"--competitors-list",
"A,B",
)
err = io.StringIO()
with redirect_stderr(err):
enabled, count, explicit = cli.resolve_competitors_args(args)
self.assertTrue(enabled)
self.assertEqual(count, 2)
self.assertEqual(explicit, ["A", "B"])
self.assertIn("--competitors=5 ignored", err.getvalue())
def test_empty_list_rejected(self):
args = _parse("Topic", "--competitors-list", ",, ,")
with self.assertRaises(SystemExit) as cm, redirect_stderr(io.StringIO()):
cli.resolve_competitors_args(args)
self.assertEqual(cm.exception.code, 2)
if __name__ == "__main__":
unittest.main()
-15
View File
@@ -77,13 +77,6 @@ class CliV3Tests(unittest.TestCase):
with self.assertRaises(SystemExit):
cli.parse_search_flag(" , ")
def test_build_parser_accepts_days_alias_and_preserves_topic_tokens(self):
parser = cli.build_parser()
args, extra = parser.parse_known_args(["--days", "7", "biosecurity", "ai", "agents"])
self.assertEqual(7, args.lookback_days)
self.assertEqual(["biosecurity", "ai", "agents"], args.topic)
self.assertEqual([], extra)
def test_ensure_supported_python_rejects_old_interpreter_with_actionable_error(self):
stderr = io.StringIO()
with redirect_stderr(stderr):
@@ -135,14 +128,6 @@ class CliV3Tests(unittest.TestCase):
payload = json.loads(path.read_text())
self.assertEqual("OpenClaw vs NanoClaw", payload["topic"])
def test_save_output_writes_utf8_encoded_markdown(self):
report = self.make_report()
with tempfile.TemporaryDirectory() as tmp:
with mock.patch("pathlib.Path.write_text", autospec=True, return_value=1) as write_text:
cli.save_output(report, "md", tmp)
_, kwargs = write_text.call_args
self.assertEqual("utf-8", kwargs.get("encoding"))
def test_persist_report_updates_run_status_on_success_and_failure(self):
report = self.make_report()
-160
View File
@@ -1,160 +0,0 @@
# ruff: noqa: E402
"""Tests for scripts/lib/fanout.run_competitor_fanout."""
from __future__ import annotations
import io
import sys
import threading
import time
import unittest
from contextlib import redirect_stderr
from pathlib import Path
from unittest import mock
REPO_ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(REPO_ROOT / "scripts"))
from lib import fanout
def _fake_report(topic: str):
"""Build a lightweight Report stand-in. Tests only check identity."""
class _R:
pass
r = _R()
r.topic = topic
return r
class FanoutOrchestratorTests(unittest.TestCase):
def test_main_plus_two_competitors_all_succeed(self):
def main_runner():
return _fake_report("OpenAI")
def comp_runner(entity):
return _fake_report(entity)
err = io.StringIO()
with redirect_stderr(err):
results = fanout.run_competitor_fanout(
main_topic="OpenAI",
main_runner=main_runner,
competitors=["Anthropic", "xAI"],
competitor_runner=comp_runner,
)
labels = [label for label, _ in results]
self.assertEqual(labels, ["OpenAI", "Anthropic", "xAI"])
self.assertEqual(results[0][1].topic, "OpenAI")
self.assertEqual(results[1][1].topic, "Anthropic")
def test_one_competitor_failure_degrades_gracefully(self):
def main_runner():
return _fake_report("OpenAI")
def comp_runner(entity):
if entity == "BrokenCo":
raise RuntimeError("upstream offline")
return _fake_report(entity)
err = io.StringIO()
with redirect_stderr(err):
results = fanout.run_competitor_fanout(
main_topic="OpenAI",
main_runner=main_runner,
competitors=["Anthropic", "BrokenCo", "xAI"],
competitor_runner=comp_runner,
)
labels = [label for label, _ in results]
self.assertEqual(labels, ["OpenAI", "Anthropic", "xAI"])
self.assertIn("BrokenCo", err.getvalue())
self.assertIn("upstream offline", err.getvalue())
def test_main_topic_failure_leaves_only_competitors(self):
def main_runner():
raise RuntimeError("main exploded")
def comp_runner(entity):
return _fake_report(entity)
err = io.StringIO()
with redirect_stderr(err):
results = fanout.run_competitor_fanout(
main_topic="OpenAI",
main_runner=main_runner,
competitors=["Anthropic", "xAI"],
competitor_runner=comp_runner,
)
labels = [label for label, _ in results]
self.assertEqual(labels, ["Anthropic", "xAI"])
self.assertIn("main exploded", err.getvalue())
def test_empty_competitor_list_runs_only_main(self):
def main_runner():
return _fake_report("OpenAI")
def comp_runner(_entity):
raise AssertionError("should not be called when competitors=[]")
err = io.StringIO()
with redirect_stderr(err):
results = fanout.run_competitor_fanout(
main_topic="OpenAI",
main_runner=main_runner,
competitors=[],
competitor_runner=comp_runner,
)
self.assertEqual([label for label, _ in results], ["OpenAI"])
def test_sub_runs_execute_in_parallel(self):
"""Wall clock should be closer to max(latency) than sum(latency)."""
delay = 0.2
call_count = 3 # main + 2 competitors
def make_runner(_label):
def runner():
time.sleep(delay)
return _fake_report(_label)
return runner
def comp_runner(entity):
return make_runner(entity)()
start = time.monotonic()
with redirect_stderr(io.StringIO()):
results = fanout.run_competitor_fanout(
main_topic="OpenAI",
main_runner=make_runner("OpenAI"),
competitors=["Anthropic", "xAI"],
competitor_runner=comp_runner,
)
elapsed = time.monotonic() - start
self.assertEqual(len(results), 3)
# Generous margin: parallel execution should finish well under
# sum(call_count * delay) == 0.6s. We accept anything under 0.5s.
self.assertLess(
elapsed, delay * call_count,
f"Expected parallel execution < {delay * call_count:.2f}s, "
f"got {elapsed:.2f}s (sub-runs likely serialized)",
)
def test_all_competitors_fail_leaves_main_only(self):
def main_runner():
return _fake_report("OpenAI")
def comp_runner(_entity):
raise RuntimeError("all offline")
with redirect_stderr(io.StringIO()):
results = fanout.run_competitor_fanout(
main_topic="OpenAI",
main_runner=main_runner,
competitors=["A", "B", "C"],
competitor_runner=comp_runner,
)
self.assertEqual([label for label, _ in results], ["OpenAI"])
if __name__ == "__main__":
unittest.main()
-196
View File
@@ -1,196 +0,0 @@
# ruff: noqa: E402
"""Regression tests: main-topic flags must not leak into competitor sub-runs.
Based on 2026-04-22 Kanye West --competitors receipt where Drake and
Kendrick Lamar sub-runs logged Kanye's resolved subreddit list as their own
targeted search. Per-entity sub-runs must never inherit main-topic targeting
via closure capture, config mutation, or any other path.
"""
from __future__ import annotations
import io
import sys
import unittest
from contextlib import redirect_stderr
from pathlib import Path
from unittest import mock
REPO_ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(REPO_ROOT / "scripts"))
def _fake_report(topic: str):
class _R:
pass
r = _R()
r.topic = topic
r.artifacts = {}
return r
class SubRunIsolationTests(unittest.TestCase):
"""Exercise the _competitor_runner closure pattern from main() directly.
Builds the same closure shape main() uses, then invokes it with
captured-in-scope main-topic flags to verify they do NOT leak into
sub-run pipeline.run kwargs.
"""
def _run_closure(self, main_flags, competitors, config=None, mock_flag=False):
"""Replicate _competitor_runner closure from last30days.py main().
main_flags: dict of {x_handle, x_related, subreddits, tiktok_hashtags,
tiktok_creators, ig_creators, github_user, github_repos}
as they would exist in outer scope after argparse.
competitors: list of entity names to run.
Returns the list of kwargs dicts pipeline.run was called with.
"""
from lib import pipeline, resolve as resolve_mod
captured: list[dict] = []
def fake_run(**kwargs):
captured.append(kwargs)
return _fake_report(kwargs["topic"])
# Simulate main scope variables
outer_subreddits = main_flags.get("subreddits")
outer_x_handle = main_flags.get("x_handle")
outer_x_related = main_flags.get("x_related")
outer_tiktok_hashtags = main_flags.get("tiktok_hashtags")
outer_tiktok_creators = main_flags.get("tiktok_creators")
outer_ig_creators = main_flags.get("ig_creators")
outer_github_user = main_flags.get("github_user")
outer_github_repos = main_flags.get("github_repos")
class _Args:
pass
args = _Args()
args.mock = mock_flag
args.web_backend = "auto"
args.lookback_days = 30
cfg = config or {}
# This mirrors the real _competitor_runner closure structure.
def competitor_runner(entity):
entity_config = dict(cfg)
resolved = {
"entity": entity,
"x_handle": "",
"subreddits": [],
"github_user": "",
"github_repos": [],
"context": "",
}
if not args.mock and resolve_mod._has_backend(entity_config):
try:
r = resolve_mod.auto_resolve(entity, entity_config)
except Exception:
r = {}
resolved["x_handle"] = r.get("x_handle", "") or ""
resolved["subreddits"] = list(r.get("subreddits") or [])
resolved["github_user"] = r.get("github_user", "") or ""
resolved["github_repos"] = list(r.get("github_repos") or [])
resolved["context"] = r.get("context", "") or ""
if resolved["context"]:
entity_config["_auto_resolve_context"] = resolved["context"]
pipeline.run(
topic=entity,
config=entity_config,
depth="default",
requested_sources=None,
mock=args.mock,
x_handle=resolved["x_handle"] or None,
subreddits=resolved["subreddits"] or None,
github_user=resolved["github_user"] or None,
github_repos=resolved["github_repos"] or None,
web_backend=args.web_backend,
lookback_days=args.lookback_days,
internal_subrun=True,
)
with mock.patch.object(pipeline, "run", side_effect=fake_run):
for entity in competitors:
competitor_runner(entity)
return captured
def test_main_subreddits_do_not_leak_to_peers(self):
"""Kanye receipt: main --subreddits=Kanye,hiphopheads leaked to Drake/Kendrick."""
main_flags = {
"subreddits": ["Kanye", "hiphopheads", "Music", "popheads", "kanyewest"],
"x_handle": "kanyewest",
}
captured = self._run_closure(main_flags, ["Drake", "Kendrick Lamar"])
self.assertEqual(len(captured), 2)
for kwargs in captured:
self.assertIsNone(
kwargs["subreddits"],
f"Main subreddits leaked into {kwargs['topic']!r}'s sub-run: "
f"{kwargs['subreddits']}",
)
def test_main_x_handle_does_not_leak(self):
main_flags = {"x_handle": "kanyewest"}
captured = self._run_closure(main_flags, ["Drake"])
self.assertIsNone(captured[0]["x_handle"])
def test_main_github_does_not_leak(self):
main_flags = {
"github_user": "someuser",
"github_repos": ["someuser/someproject"],
}
captured = self._run_closure(main_flags, ["Drake"])
self.assertIsNone(captured[0]["github_user"])
self.assertIsNone(captured[0]["github_repos"])
def test_auto_resolve_context_does_not_leak_across_peers(self):
"""Per-entity auto_resolve context must not bleed between sub-runs."""
from lib import resolve as resolve_mod
def fake_resolve(entity, _cfg):
per_topic = {
"Drake": {"x_handle": "Drake", "subreddits": [], "github_user": "",
"github_repos": [], "context": "Drake ICEMAN rollout",
"category": None, "searches_run": 4},
"Kendrick Lamar": {"x_handle": "kendricklamar", "subreddits": [],
"github_user": "", "github_repos": [],
"context": "Meet The Grahams revival",
"category": None, "searches_run": 4},
}
return per_topic.get(entity, {})
with mock.patch.object(resolve_mod, "auto_resolve", side_effect=fake_resolve), \
mock.patch.object(resolve_mod, "_has_backend", return_value=True):
captured = self._run_closure(
main_flags={},
competitors=["Drake", "Kendrick Lamar"],
config={"BRAVE_API_KEY": "test"},
)
by_topic = {kw["topic"]: kw for kw in captured}
# Each sub-run's config got its own context string.
self.assertEqual(
by_topic["Drake"]["config"].get("_auto_resolve_context"),
"Drake ICEMAN rollout",
)
self.assertEqual(
by_topic["Kendrick Lamar"]["config"].get("_auto_resolve_context"),
"Meet The Grahams revival",
)
# Cross-entity check: neither config contains the other's context.
self.assertNotIn(
"Meet The Grahams",
by_topic["Drake"]["config"].get("_auto_resolve_context", ""),
)
self.assertNotIn(
"ICEMAN",
by_topic["Kendrick Lamar"]["config"].get("_auto_resolve_context", ""),
)
if __name__ == "__main__":
unittest.main()
-152
View File
@@ -1,152 +0,0 @@
# ruff: noqa: E402
"""Tests for scripts/lib/competitors.discover_competitors."""
from __future__ import annotations
import io
import sys
import unittest
from contextlib import redirect_stderr
from pathlib import Path
from unittest import mock
REPO_ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(REPO_ROOT / "scripts"))
from lib import competitors
def _serp(items: list[tuple[str, str]]) -> list[dict]:
"""Build a minimal SERP items list from (title, snippet) pairs."""
return [
{"title": title, "snippet": snippet, "url": "https://example.test/"}
for title, snippet in items
]
OPENAI_SERP = _serp(
[
("OpenAI vs Anthropic vs xAI: which is better?", "xAI and Anthropic now compete directly with OpenAI."),
("Top OpenAI alternatives in 2026", "Anthropic, Google Gemini, and xAI are the leading alternatives this year."),
("xAI and Anthropic challenge OpenAI dominance", "xAI and Anthropic push Google Gemini hard; xAI keeps shipping."),
("Anthropic vs xAI: head to head", "Anthropic and xAI trade punches; Google Gemini is not far behind."),
]
)
KANYE_SERP = _serp(
[
("Kanye West vs Drake: the feud explained", "Drake responded to Kanye with a diss track."),
("Top rappers of the decade: Kendrick Lamar, Drake, J Cole", "Kendrick Lamar released a new album; Drake toured Europe."),
("Drake and Kendrick Lamar trade shots", "J Cole stayed out of the Drake vs Kendrick Lamar feud."),
]
)
class CompetitorDiscoveryTests(unittest.TestCase):
def _run(self, serp: list[dict], topic: str, count: int = 3) -> list[str]:
config = {"BRAVE_API_KEY": "test-key"}
with mock.patch.object(
competitors.grounding, "web_search", return_value=(serp, {})
):
with redirect_stderr(io.StringIO()):
return competitors.discover_competitors(topic, count, config)
def test_openai_surfaces_anthropic_and_peers(self):
results = self._run(OPENAI_SERP, "OpenAI", count=3)
self.assertEqual(len(results), 3)
joined = " ".join(results)
self.assertIn("Anthropic", joined)
self.assertIn("xAI", joined)
# Should not surface the topic itself
self.assertNotIn("OpenAI", results)
self.assertFalse(
any("OpenAI" in entity for entity in results),
f"Topic token leaked into results: {results}",
)
def test_kanye_surfaces_rap_peers(self):
results = self._run(KANYE_SERP, "Kanye West", count=2)
self.assertEqual(len(results), 2)
joined = " ".join(results)
self.assertTrue(
"Drake" in joined and "Kendrick Lamar" in joined,
f"Expected Drake and Kendrick Lamar in {results}",
)
def test_empty_serp_returns_empty(self):
results = self._run([], "OpenAI", count=3)
self.assertEqual(results, [])
def test_no_backend_returns_empty(self):
err = io.StringIO()
with redirect_stderr(err):
results = competitors.discover_competitors("OpenAI", 3, config={})
self.assertEqual(results, [])
self.assertIn("No web search backend", err.getvalue())
def test_backend_error_returns_empty(self):
config = {"BRAVE_API_KEY": "test-key"}
def boom(*_args, **_kwargs):
raise RuntimeError("SERP provider offline")
err = io.StringIO()
with mock.patch.object(competitors.grounding, "web_search", side_effect=boom):
with redirect_stderr(err):
results = competitors.discover_competitors("OpenAI", 3, config)
self.assertEqual(results, [])
self.assertIn("Search failed", err.getvalue())
def test_topic_tokens_filtered(self):
"""Candidates overlapping topic tokens are rejected."""
serp = _serp(
[
("Open AI vs Anthropic", "Open AI, Anthropic, and Google lead."),
("OpenAI Alternatives: Anthropic", "Anthropic is a competitor to Open AI."),
]
)
results = self._run(serp, "OpenAI", count=3)
# "Open AI" shares the "openai" lowercased-concatenation? Actually tokenizer
# splits "Open AI" into ["open", "ai"]. Topic "OpenAI" tokenizes to ["openai"].
# They do not overlap at the token level, which is fine — the filter is
# best-effort. We only assert that bare "OpenAI" is filtered and real
# competitors still surface.
self.assertNotIn("OpenAI", results)
self.assertIn("Anthropic", results)
def test_deduplicates_case_insensitively(self):
serp = _serp(
[
("Anthropic vs Gemini", "anthropic is strong."),
("ANTHROPIC makes Claude", "Anthropic announced Claude 4."),
]
)
results = self._run(serp, "OpenAI", count=3)
# "Anthropic" should appear exactly once (first-seen capitalization wins).
anthropic_matches = [r for r in results if r.lower() == "anthropic"]
self.assertEqual(len(anthropic_matches), 1)
def test_count_one_returns_single(self):
results = self._run(OPENAI_SERP, "OpenAI", count=1)
self.assertEqual(len(results), 1)
def test_stopword_only_candidates_rejected(self):
serp = _serp(
[
("Top Alternatives", "Best Competitors and Top Tools."),
("Free Software Reviews", "Complete Guide to The Options."),
]
)
results = self._run(serp, "Widget", count=5)
self.assertEqual(
results, [],
f"Stopword-only phrases should not be returned: got {results}",
)
def test_count_zero_returns_empty(self):
results = self._run(OPENAI_SERP, "OpenAI", count=0)
self.assertEqual(results, [])
if __name__ == "__main__":
unittest.main()
-180
View File
@@ -1,180 +0,0 @@
# ruff: noqa: E402
"""Tests for --competitors-plan JSON parsing and per-entity kwargs threading."""
from __future__ import annotations
import io
import json
import sys
import tempfile
import unittest
from contextlib import redirect_stderr
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
class ParseCompetitorsPlanTests(unittest.TestCase):
def test_none_returns_empty(self):
self.assertEqual(cli.parse_competitors_plan(None), {})
def test_empty_string_returns_empty(self):
self.assertEqual(cli.parse_competitors_plan(""), {})
def test_inline_json_parsed(self):
raw = '{"Drake": {"x_handle": "Drake", "subreddits": ["Drizzy"]}}'
out = cli.parse_competitors_plan(raw)
self.assertIn("drake", out)
self.assertEqual(out["drake"]["x_handle"], "Drake")
self.assertEqual(out["drake"]["subreddits"], ["Drizzy"])
def test_file_path_accepted(self):
with tempfile.NamedTemporaryFile(
mode="w", suffix=".json", delete=False,
) as f:
json.dump(
{"Anthropic": {"x_handle": "AnthropicAI", "github_user": "anthropics"}},
f,
)
path = f.name
try:
out = cli.parse_competitors_plan(path)
self.assertEqual(out["anthropic"]["x_handle"], "AnthropicAI")
self.assertEqual(out["anthropic"]["github_user"], "anthropics")
finally:
Path(path).unlink(missing_ok=True)
def test_case_insensitive_key_normalization(self):
raw = '{"DRAKE": {"x_handle": "Drake"}}'
out = cli.parse_competitors_plan(raw)
self.assertIn("drake", out)
self.assertNotIn("DRAKE", out)
def test_unknown_fields_warned_and_ignored(self):
raw = '{"Drake": {"x_handle": "Drake", "bogus_field": 42}}'
err = io.StringIO()
with redirect_stderr(err):
out = cli.parse_competitors_plan(raw)
self.assertIn("drake", out)
self.assertNotIn("bogus_field", out["drake"])
self.assertIn("Unknown fields", err.getvalue())
def test_malformed_json_exits_2(self):
with self.assertRaises(SystemExit) as cm, redirect_stderr(io.StringIO()) as err:
cli.parse_competitors_plan("{not valid json")
self.assertEqual(cm.exception.code, 2)
self.assertIn("Invalid JSON", err.getvalue())
def test_top_level_list_rejected(self):
with self.assertRaises(SystemExit) as cm, redirect_stderr(io.StringIO()):
cli.parse_competitors_plan('["Drake", "Kendrick"]')
self.assertEqual(cm.exception.code, 2)
def test_entry_non_dict_skipped_with_warning(self):
raw = '{"Drake": "not-a-dict", "Kendrick": {"x_handle": "kendricklamar"}}'
err = io.StringIO()
with redirect_stderr(err):
out = cli.parse_competitors_plan(raw)
self.assertNotIn("drake", out)
self.assertIn("kendrick", out)
self.assertIn("must be a dict", err.getvalue())
def test_all_six_fields_accepted(self):
raw = json.dumps({
"OpenAI": {
"x_handle": "OpenAI",
"x_related": ["sama", "gdb"],
"subreddits": ["OpenAI", "MachineLearning"],
"github_user": "openai",
"github_repos": ["openai/gpt-5"],
"context": "GPT-5 launch imminent",
}
})
out = cli.parse_competitors_plan(raw)
entry = out["openai"]
self.assertEqual(entry["x_handle"], "OpenAI")
self.assertEqual(entry["x_related"], ["sama", "gdb"])
self.assertEqual(entry["subreddits"], ["OpenAI", "MachineLearning"])
self.assertEqual(entry["github_user"], "openai")
self.assertEqual(entry["github_repos"], ["openai/gpt-5"])
self.assertEqual(entry["context"], "GPT-5 launch imminent")
class SubrunKwargsForTests(unittest.TestCase):
def test_plan_wins_over_auto_resolve(self):
plan_entry = {"x_handle": "Drake", "subreddits": ["Drizzy"]}
resolved = {"x_handle": "wrong", "subreddits": ["wrong"]}
kwargs = cli.subrun_kwargs_for("Drake", plan_entry, resolved=resolved)
self.assertEqual(kwargs["x_handle"], "Drake")
self.assertEqual(kwargs["subreddits"], ["Drizzy"])
def test_auto_resolve_used_when_plan_missing(self):
resolved = {
"x_handle": "Drake",
"subreddits": ["Drizzy", "hiphopheads"],
"github_user": "",
"github_repos": [],
}
kwargs = cli.subrun_kwargs_for("Drake", {}, resolved=resolved)
self.assertEqual(kwargs["x_handle"], "Drake")
self.assertEqual(kwargs["subreddits"], ["Drizzy", "hiphopheads"])
def test_both_empty_yields_all_none(self):
kwargs = cli.subrun_kwargs_for("Drake", {}, resolved={})
self.assertIsNone(kwargs["x_handle"])
self.assertIsNone(kwargs["subreddits"])
self.assertIsNone(kwargs["github_user"])
self.assertIsNone(kwargs["github_repos"])
self.assertIsNone(kwargs["x_related"])
self.assertEqual(kwargs["_context"], "")
def test_x_handle_strips_at_sign(self):
kwargs = cli.subrun_kwargs_for(
"Drake", {"x_handle": "@Drake"}, resolved={},
)
self.assertEqual(kwargs["x_handle"], "Drake")
def test_subreddits_strip_r_prefix(self):
kwargs = cli.subrun_kwargs_for(
"Drake", {"subreddits": ["r/Drizzy", "hiphopheads"]}, resolved={},
)
self.assertEqual(kwargs["subreddits"], ["Drizzy", "hiphopheads"])
def test_github_repos_filter_non_slash(self):
kwargs = cli.subrun_kwargs_for(
"Drake",
{"github_repos": ["drake/ovo", "not-a-repo"]},
resolved={},
)
self.assertEqual(kwargs["github_repos"], ["drake/ovo"])
def test_x_related_list_normalized(self):
kwargs = cli.subrun_kwargs_for(
"Drake",
{"x_related": ["@pnd", "drakefan"]},
resolved={},
)
self.assertEqual(kwargs["x_related"], ["pnd", "drakefan"])
def test_github_user_lowercased(self):
kwargs = cli.subrun_kwargs_for(
"OpenAI", {"github_user": "@OpenAI"}, resolved={},
)
self.assertEqual(kwargs["github_user"], "openai")
def test_context_from_plan_or_resolved(self):
plan_entry = {"context": "Plan context"}
resolved = {"context": "Resolved context"}
kwargs = cli.subrun_kwargs_for("X", plan_entry, resolved=resolved)
self.assertEqual(kwargs["_context"], "Plan context")
kwargs = cli.subrun_kwargs_for("X", {}, resolved=resolved)
self.assertEqual(kwargs["_context"], "Resolved context")
if __name__ == "__main__":
unittest.main()
@@ -1,331 +0,0 @@
# ruff: noqa: E402
"""Integration tests for per-entity Step 0.55 resolution inside competitor fan-out."""
from __future__ import annotations
import io
import sys
import unittest
from contextlib import redirect_stderr
from pathlib import Path
from unittest import mock
REPO_ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(REPO_ROOT / "scripts"))
def _fake_report(topic: str):
"""Minimal Report stand-in for runner return values."""
class _R:
pass
r = _R()
r.topic = topic
r.artifacts = {}
return r
def _build_main_args(*overrides):
"""Minimal argparse.Namespace-like object for the competitor path."""
import argparse
ns = argparse.Namespace(
topic=["Kanye West"],
mock=False,
competitors=2,
competitors_list=None,
quick=False,
deep=False,
emit="compact",
search=None,
debug=False,
diagnose=False,
save_dir=None,
save_suffix=None,
store=False,
x_handle=None,
x_related=None,
web_backend="auto",
deep_research=False,
plan=None,
subreddits=None,
tiktok_hashtags=None,
tiktok_creators=None,
ig_creators=None,
lookback_days=30,
auto_resolve=False,
github_user=None,
github_repo=None,
)
return ns
class PerEntityResolveTests(unittest.TestCase):
"""Verify each competitor sub-run calls auto_resolve with its own topic and
that the resolved fields are threaded into pipeline.run."""
def test_auto_resolve_called_per_competitor(self):
from lib import resolve as resolve_mod
from lib import pipeline as pipeline_mod
config = {"BRAVE_API_KEY": "test-key"}
captured_resolve_topics: list[str] = []
captured_pipeline_kwargs: list[dict] = []
def fake_resolve(topic, _cfg):
captured_resolve_topics.append(topic)
per_topic = {
"Drake": {
"x_handle": "Drake",
"subreddits": ["DrakeTheType", "hiphopheads"],
"github_user": "",
"github_repos": [],
"context": "Drake ICEMAN rollout",
"category": None,
"searches_run": 4,
},
"Kendrick Lamar": {
"x_handle": "kendricklamar",
"subreddits": ["KendrickLamar", "hiphopheads"],
"github_user": "",
"github_repos": [],
"context": "Meet The Grahams revival",
"category": None,
"searches_run": 4,
},
}
return per_topic.get(topic, {
"x_handle": "", "subreddits": [], "github_user": "",
"github_repos": [], "context": "",
"category": None, "searches_run": 0,
})
def fake_pipeline_run(**kwargs):
captured_pipeline_kwargs.append(kwargs)
return _fake_report(kwargs["topic"])
with mock.patch.object(resolve_mod, "auto_resolve", side_effect=fake_resolve), \
mock.patch.object(resolve_mod, "_has_backend", return_value=True), \
mock.patch.object(pipeline_mod, "run", side_effect=fake_pipeline_run):
# Exercise the competitor_runner closure pattern from main() by
# calling it directly with two competitors.
self._run_competitor_closure(
config=config,
competitors=["Drake", "Kendrick Lamar"],
mock_flag=False,
)
# auto_resolve was called once per competitor
self.assertEqual(sorted(captured_resolve_topics), ["Drake", "Kendrick Lamar"])
# pipeline.run received resolved fields per entity
by_topic = {kw["topic"]: kw for kw in captured_pipeline_kwargs}
self.assertEqual(by_topic["Drake"]["x_handle"], "Drake")
self.assertEqual(
by_topic["Drake"]["subreddits"], ["DrakeTheType", "hiphopheads"],
)
self.assertEqual(by_topic["Kendrick Lamar"]["x_handle"], "kendricklamar")
# internal_subrun=True on all competitor sub-runs
self.assertTrue(all(kw["internal_subrun"] for kw in captured_pipeline_kwargs))
def test_mock_mode_skips_auto_resolve(self):
from lib import resolve as resolve_mod
from lib import pipeline as pipeline_mod
resolve_called = []
def fake_resolve(*a, **k):
resolve_called.append((a, k))
return {}
with mock.patch.object(resolve_mod, "auto_resolve", side_effect=fake_resolve), \
mock.patch.object(pipeline_mod, "run", side_effect=lambda **kw: _fake_report(kw["topic"])):
self._run_competitor_closure(
config={"BRAVE_API_KEY": "test-key"},
competitors=["Anthropic"],
mock_flag=True,
)
self.assertEqual(resolve_called, [])
def test_no_backend_skips_auto_resolve(self):
from lib import resolve as resolve_mod
from lib import pipeline as pipeline_mod
resolve_called = []
def fake_resolve(*a, **k):
resolve_called.append((a, k))
return {}
with mock.patch.object(resolve_mod, "auto_resolve", side_effect=fake_resolve), \
mock.patch.object(resolve_mod, "_has_backend", return_value=False), \
mock.patch.object(pipeline_mod, "run", side_effect=lambda **kw: _fake_report(kw["topic"])):
self._run_competitor_closure(
config={},
competitors=["Anthropic"],
mock_flag=False,
)
self.assertEqual(resolve_called, [])
def test_resolve_failure_degrades_gracefully(self):
from lib import resolve as resolve_mod
from lib import pipeline as pipeline_mod
captured_pipeline_kwargs: list[dict] = []
def fake_resolve(_topic, _cfg):
raise RuntimeError("upstream offline")
def fake_pipeline_run(**kwargs):
captured_pipeline_kwargs.append(kwargs)
return _fake_report(kwargs["topic"])
err = io.StringIO()
with redirect_stderr(err), \
mock.patch.object(resolve_mod, "auto_resolve", side_effect=fake_resolve), \
mock.patch.object(resolve_mod, "_has_backend", return_value=True), \
mock.patch.object(pipeline_mod, "run", side_effect=fake_pipeline_run):
self._run_competitor_closure(
config={"BRAVE_API_KEY": "test-key"},
competitors=["Anthropic"],
mock_flag=False,
)
# Warning logged but run continues with planner defaults
self.assertIn("auto_resolve failed for 'Anthropic'", err.getvalue())
self.assertEqual(len(captured_pipeline_kwargs), 1)
self.assertIsNone(captured_pipeline_kwargs[0]["x_handle"])
self.assertIsNone(captured_pipeline_kwargs[0]["subreddits"])
def test_resolved_artifact_stored_on_report(self):
from lib import resolve as resolve_mod
from lib import pipeline as pipeline_mod
with mock.patch.object(resolve_mod, "auto_resolve", return_value={
"x_handle": "Drake",
"subreddits": ["DrakeTheType"],
"github_user": "",
"github_repos": [],
"context": "Drake context",
"category": None,
"searches_run": 4,
}), \
mock.patch.object(resolve_mod, "_has_backend", return_value=True), \
mock.patch.object(pipeline_mod, "run", side_effect=lambda **kw: _fake_report(kw["topic"])):
results = self._run_competitor_closure(
config={"BRAVE_API_KEY": "test-key"},
competitors=["Drake"],
mock_flag=False,
)
self.assertIn("resolved", results[0].artifacts)
resolved = results[0].artifacts["resolved"]
self.assertEqual(resolved["entity"], "Drake")
self.assertEqual(resolved["x_handle"], "Drake")
self.assertEqual(resolved["subreddits"], ["DrakeTheType"])
self.assertEqual(resolved["context"], "Drake context")
def test_config_not_mutated_across_sub_runs(self):
"""_auto_resolve_context from entity A must not leak into entity B."""
from lib import resolve as resolve_mod
from lib import pipeline as pipeline_mod
captured_contexts: list[str] = []
def fake_resolve(topic, _cfg):
per_topic = {
"Drake": {"x_handle": "Drake", "subreddits": [], "github_user": "",
"github_repos": [], "context": "Drake unique context",
"category": None, "searches_run": 4},
"Kendrick Lamar": {"x_handle": "kendricklamar", "subreddits": [],
"github_user": "", "github_repos": [],
"context": "Kendrick unique context",
"category": None, "searches_run": 4},
}
return per_topic[topic]
def fake_pipeline_run(**kwargs):
captured_contexts.append(
kwargs["config"].get("_auto_resolve_context", "")
)
return _fake_report(kwargs["topic"])
shared_config = {"BRAVE_API_KEY": "test-key"}
with mock.patch.object(resolve_mod, "auto_resolve", side_effect=fake_resolve), \
mock.patch.object(resolve_mod, "_has_backend", return_value=True), \
mock.patch.object(pipeline_mod, "run", side_effect=fake_pipeline_run):
self._run_competitor_closure(
config=shared_config,
competitors=["Drake", "Kendrick Lamar"],
mock_flag=False,
)
# Each sub-run received its own entity's context — no cross-leak.
self.assertIn("Drake unique context", captured_contexts)
self.assertIn("Kendrick unique context", captured_contexts)
# The shared outer config was not mutated
self.assertNotIn("_auto_resolve_context", shared_config)
# --- test helpers -----------------------------------------------------
def _run_competitor_closure(self, *, config, competitors, mock_flag):
"""Replicate the competitor_runner closure from last30days.main() and
call it against each competitor. Returns the list of Reports."""
from lib import pipeline, resolve as resolve_mod
class _Args:
pass
args = _Args()
args.mock = mock_flag
args.web_backend = "auto"
args.lookback_days = 30
def runner(entity: str):
entity_config = dict(config)
resolved = {
"entity": entity,
"x_handle": "",
"subreddits": [],
"github_user": "",
"github_repos": [],
"context": "",
}
if not args.mock and resolve_mod._has_backend(entity_config):
try:
r = resolve_mod.auto_resolve(entity, entity_config)
except Exception as exc:
sys.stderr.write(
f"[Competitors] auto_resolve failed for {entity!r}: "
f"{type(exc).__name__}: {exc}\n"
)
r = {}
resolved["x_handle"] = r.get("x_handle", "") or ""
resolved["subreddits"] = list(r.get("subreddits") or [])
resolved["github_user"] = r.get("github_user", "") or ""
resolved["github_repos"] = list(r.get("github_repos") or [])
resolved["context"] = r.get("context", "") or ""
if resolved["context"]:
entity_config["_auto_resolve_context"] = resolved["context"]
report = pipeline.run(
topic=entity,
config=entity_config,
depth="default",
requested_sources=None,
mock=args.mock,
x_handle=resolved["x_handle"] or None,
subreddits=resolved["subreddits"] or None,
github_user=resolved["github_user"] or None,
github_repos=resolved["github_repos"] or None,
web_backend=args.web_backend,
lookback_days=args.lookback_days,
internal_subrun=True,
)
report.artifacts["resolved"] = resolved
return report
return [runner(c) for c in competitors]
if __name__ == "__main__":
unittest.main()
-14
View File
@@ -1,14 +0,0 @@
from scripts.lib import env
def test_include_sources_defaults_to_empty_string(monkeypatch, tmp_path):
# Ensure the env var is not set
monkeypatch.delenv("INCLUDE_SOURCES", raising=False)
# Avoid reading any real user config file by patching the resolved module path directly
monkeypatch.setattr(env, "CONFIG_FILE", tmp_path / "does-not-exist.env")
cfg = env.get_config()
assert "INCLUDE_SOURCES" in cfg
assert cfg["INCLUDE_SOURCES"] == ""
-76
View File
@@ -1,76 +0,0 @@
# ruff: noqa: E402
"""Tests for the BRAVE/SERPER web-promo suppression when hosting-model-driven."""
from __future__ import annotations
import os
import subprocess
import sys
import unittest
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(REPO_ROOT / "scripts"))
def _engine() -> Path:
return REPO_ROOT / "scripts" / "last30days.py"
class FooterNudgeSuppressionTests(unittest.TestCase):
def _run(self, *argv: str, topic: str) -> subprocess.CompletedProcess:
cmd = [
sys.executable,
str(_engine()),
topic,
"--mock",
"--emit=md",
*argv,
]
env = {**os.environ, "LAST30DAYS_SKIP_PREFLIGHT": "1"}
# Strip any grounded-web keys the host might have so the promo path
# triggers deterministically in mock + no-backend.
for key in ("BRAVE_API_KEY", "EXA_API_KEY", "SERPER_API_KEY",
"PARALLEL_API_KEY", "OPENROUTER_API_KEY"):
env.pop(key, None)
return subprocess.run(cmd, capture_output=True, text=True, env=env)
def test_bare_run_emits_web_promo(self):
result = self._run(topic="OpenAI")
combined = result.stdout + result.stderr
# Mock mode still shows the promo when nothing indicates a hosting
# model is driving. Check both streams since the UI may emit to stderr.
self.assertIn("BRAVE_API_KEY", combined)
def test_competitors_plan_suppresses_web_promo(self):
result = self._run(
"--competitors-list", "Anthropic",
"--competitors-plan",
'{"Anthropic":{"x_handle":"AnthropicAI","subreddits":["ClaudeAI"]}}',
topic="OpenAI",
)
combined = result.stdout + result.stderr
self.assertNotIn(
"unlock native grounded web search",
combined,
msg="web promo should be suppressed when --competitors-plan is passed",
)
def test_plan_suppresses_web_promo(self):
plan = (
'{"intent":"concept","freshness_mode":"balanced_recent",'
'"cluster_mode":"none","subqueries":[{"label":"primary",'
'"search_query":"OpenAI","ranking_query":"OpenAI",'
'"sources":["grounding"]}],"source_weights":{"grounding":1.0}}'
)
result = self._run("--plan", plan, topic="OpenAI")
combined = result.stdout + result.stderr
self.assertNotIn(
"unlock native grounded web search",
combined,
msg="web promo should be suppressed when --plan is passed",
)
if __name__ == "__main__":
unittest.main()
-16
View File
@@ -56,22 +56,6 @@ class TestParseDate(unittest.TestCase):
def test_empty(self):
self.assertIsNone(github._parse_date(""))
def test_rejects_garbage(self):
"""The old naive slicing returned 'hello worl' for 'hello world'. Reject it."""
self.assertIsNone(github._parse_date("hello world"))
self.assertIsNone(github._parse_date("not-a-date"))
self.assertIsNone(github._parse_date("abcdefghij"))
def test_rejects_invalid_date_values(self):
"""An out-of-range date like 2026-99-99 is not a real date."""
self.assertIsNone(github._parse_date("2026-99-99"))
def test_iso_with_offset(self):
self.assertEqual(github._parse_date("2026-03-15T12:00:00+00:00"), "2026-03-15")
def test_iso_with_no_colon_offset(self):
self.assertEqual(github._parse_date("2026-03-15T12:00:00+0000"), "2026-03-15")
class TestSearchGithub(unittest.TestCase):
@patch.dict("os.environ", {}, clear=True)
-63
View File
@@ -41,66 +41,3 @@ class Test429RetryLimit(unittest.TestCase):
http.request("GET", "http://example.com", retries=3)
self.assertEqual(mock_urlopen.call_count, 3)
def _mock_response(body: str = '{"ok": true}', status: int = 200):
resp = MagicMock()
resp.__enter__ = MagicMock(return_value=resp)
resp.__exit__ = MagicMock(return_value=False)
resp.read.return_value = body.encode("utf-8")
resp.status = status
return resp
class TestParamsEncoding(unittest.TestCase):
"""request() should urlencode the params dict into the URL."""
def _sent_url(self, mock_urlopen) -> str:
request_arg = mock_urlopen.call_args[0][0]
return request_arg.full_url
@patch("lib.http.urllib.request.urlopen")
def test_params_appended_to_url(self, mock_urlopen):
mock_urlopen.return_value = _mock_response()
http.get("https://api.example.com/search", params={"q": "test", "limit": 10})
sent_url = self._sent_url(mock_urlopen)
self.assertIn("q=test", sent_url)
self.assertIn("limit=10", sent_url)
@patch("lib.http.urllib.request.urlopen")
def test_params_appended_with_existing_query_string(self, mock_urlopen):
mock_urlopen.return_value = _mock_response()
http.get("https://api.example.com/search?api_key=secret", params={"q": "test"})
sent_url = self._sent_url(mock_urlopen)
self.assertTrue(sent_url.startswith("https://api.example.com/search?api_key=secret&"))
self.assertIn("q=test", sent_url)
@patch("lib.http.urllib.request.urlopen")
def test_none_values_dropped(self, mock_urlopen):
mock_urlopen.return_value = _mock_response()
http.get("https://api.example.com/search", params={"q": "test", "filter": None})
sent_url = self._sent_url(mock_urlopen)
self.assertIn("q=test", sent_url)
self.assertNotIn("filter", sent_url)
@patch("lib.http.urllib.request.urlopen")
def test_empty_params_leaves_url_unchanged(self, mock_urlopen):
mock_urlopen.return_value = _mock_response()
http.get("https://api.example.com/search", params={})
sent_url = self._sent_url(mock_urlopen)
self.assertEqual(sent_url, "https://api.example.com/search")
@patch("lib.http.urllib.request.urlopen")
def test_no_params_kwarg_leaves_url_unchanged(self, mock_urlopen):
mock_urlopen.return_value = _mock_response()
http.get("https://api.example.com/search")
sent_url = self._sent_url(mock_urlopen)
self.assertEqual(sent_url, "https://api.example.com/search")
@patch("lib.http.urllib.request.urlopen")
def test_int_and_bool_params_stringified(self, mock_urlopen):
mock_urlopen.return_value = _mock_response()
http.get("https://api.example.com/search", params={"count": 25, "raw": True})
sent_url = self._sent_url(mock_urlopen)
self.assertIn("count=25", sent_url)
self.assertIn("raw=True", sent_url)
-159
View File
@@ -49,165 +49,6 @@ class NormalizeV3Tests(unittest.TestCase):
)
self.assertEqual([], normalized)
def test_youtube_top_comments_passthrough_with_field_mapping(self):
"""YT comments from enrich_with_comments use likes/text; normalize must
carry them into metadata as the Reddit-compatible {score, excerpt} shape."""
items = [
{
"video_id": "vid-1",
"title": "How to deploy",
"url": "https://youtube.com/watch?v=vid-1",
"channel_name": "Example",
"date": "2026-03-01",
"engagement": {"views": 10000, "likes": 500, "comments": 30},
"top_comments": [
{"author": "Alice", "text": "Best tutorial ever", "likes": 120, "date": "2026-03-02"},
{"author": "Bob", "text": "Helped me ship", "likes": 45, "date": "2026-03-03"},
{"author": "Carol", "text": "Solid walkthrough", "likes": 7, "date": "2026-03-04"},
],
}
]
normalized = normalize.normalize_source_items(
"youtube", items, "2026-02-15", "2026-03-17",
)
self.assertEqual(1, len(normalized))
top = normalized[0].metadata.get("top_comments")
self.assertIsNotNone(top)
self.assertEqual(3, len(top))
# First comment: likes->score, text->excerpt
self.assertEqual(120, top[0]["score"])
self.assertEqual("Best tutorial ever", top[0]["excerpt"])
self.assertEqual("Alice", top[0]["author"])
self.assertEqual("2026-03-02", top[0]["date"])
# Preserves ordering from input (already sorted desc upstream)
self.assertEqual(45, top[1]["score"])
self.assertEqual(7, top[2]["score"])
def test_youtube_top_comments_empty_list_passes_through_cleanly(self):
items = [
{
"video_id": "vid-2",
"title": "Short clip",
"url": "https://youtube.com/watch?v=vid-2",
"channel_name": "Example",
"date": "2026-03-01",
"engagement": {"views": 50, "likes": 2},
"top_comments": [],
}
]
normalized = normalize.normalize_source_items(
"youtube", items, "2026-02-15", "2026-03-17",
)
self.assertEqual(1, len(normalized))
# Empty list is fine; metadata may have empty top_comments or omit it.
top = normalized[0].metadata.get("top_comments", [])
self.assertEqual([], top)
def test_youtube_without_top_comments_key_does_not_crash(self):
items = [
{
"video_id": "vid-3",
"title": "No comments fetched",
"url": "https://youtube.com/watch?v=vid-3",
"channel_name": "Example",
"date": "2026-03-01",
"engagement": {"views": 100, "likes": 5},
}
]
normalized = normalize.normalize_source_items(
"youtube", items, "2026-02-15", "2026-03-17",
)
self.assertEqual(1, len(normalized))
self.assertEqual([], normalized[0].metadata.get("top_comments", []))
def test_youtube_top_comments_feed_top_comment_score_signal(self):
"""Integration: after normalize, signals._top_comment_score should
return log1p(first comment score) for YT, proving the full chain."""
from lib import signals
import math
items = [
{
"video_id": "vid-4",
"title": "Viral comment thread",
"url": "https://youtube.com/watch?v=vid-4",
"channel_name": "Example",
"date": "2026-03-01",
"engagement": {"views": 1000, "likes": 50, "comments": 10},
"top_comments": [
{"author": "A", "text": "Legendary", "likes": 9999, "date": "2026-03-02"},
],
}
]
normalized = normalize.normalize_source_items(
"youtube", items, "2026-02-15", "2026-03-17",
)
self.assertAlmostEqual(math.log1p(9999), signals._top_comment_score(normalized[0]), places=4)
def test_tiktok_top_comments_passthrough_with_digg_count_mapping(self):
"""TikTok comments from enrich_with_comments use digg_count/text;
normalize must map to the shared {score, excerpt} shape."""
items = [
{
"id": "tt-1",
"text": "POV: shipping on Friday",
"url": "https://www.tiktok.com/@u/video/tt-1",
"author_name": "u",
"date": "2026-03-01",
"engagement": {"views": 50000, "likes": 2000, "comments": 300},
"top_comments": [
{"author": "Alice", "text": "dead", "digg_count": 1200, "date": "2026-03-02"},
{"author": "Bob", "text": "so real", "digg_count": 400, "date": "2026-03-03"},
],
}
]
normalized = normalize.normalize_source_items(
"tiktok", items, "2026-02-15", "2026-03-17",
)
self.assertEqual(1, len(normalized))
top = normalized[0].metadata.get("top_comments")
self.assertEqual(2, len(top))
self.assertEqual(1200, top[0]["score"])
self.assertEqual("dead", top[0]["excerpt"])
self.assertEqual("Alice", top[0]["author"])
self.assertEqual(400, top[1]["score"])
def test_tiktok_without_top_comments_does_not_crash(self):
items = [
{
"id": "tt-2",
"text": "plain clip",
"url": "https://www.tiktok.com/@u/video/tt-2",
"author_name": "u",
"date": "2026-03-01",
"engagement": {"views": 1000, "likes": 20},
}
]
normalized = normalize.normalize_source_items(
"tiktok", items, "2026-02-15", "2026-03-17",
)
self.assertEqual([], normalized[0].metadata.get("top_comments", []))
def test_tiktok_top_comments_feed_top_comment_score_signal(self):
from lib import signals
import math
items = [
{
"id": "tt-3",
"text": "viral",
"url": "https://www.tiktok.com/@u/video/tt-3",
"author_name": "u",
"date": "2026-03-01",
"engagement": {"views": 100000, "likes": 5000, "comments": 500},
"top_comments": [
{"author": "A", "text": "this aged well", "digg_count": 50000, "date": "2026-03-02"},
],
}
]
normalized = normalize.normalize_source_items(
"tiktok", items, "2026-02-15", "2026-03-17",
)
self.assertAlmostEqual(math.log1p(50000), signals._top_comment_score(normalized[0]), places=4)
def test_grounding_requires_a_usable_date(self):
items = [
{
-24
View File
@@ -28,30 +28,6 @@ class PipelineV3Tests(unittest.TestCase):
self.assertIn("grounding", report.items_by_source)
self.assertEqual("gemini", report.provider_runtime.reasoning_provider)
def test_planner_trace_always_fires_on_mock_run(self):
"""Unit 5: The unified planner trace emits one summary line plus one
line per subquery on every run, regardless of --debug. 2026-04-19
Hermes Agent Use Cases failure: retrieval-breadth issues were invisible
because the internal planner path logged nothing.
"""
import io
import contextlib
buf = io.StringIO()
with contextlib.redirect_stderr(buf):
pipeline.run(
topic="test topic",
config={"LAST30DAYS_REASONING_PROVIDER": "gemini"},
depth="quick",
requested_sources=["reddit", "x", "grounding"],
mock=True,
)
output = buf.getvalue()
self.assertIn("[Planner] Plan: intent=", output)
self.assertIn("subqueries=", output)
self.assertIn("source=", output)
# At least one per-subquery line.
self.assertIn("[Planner] sq1 label=", output)
class TestSourceFetchCap(unittest.TestCase):
"""X source fetch count must be capped by MAX_SOURCE_FETCHES."""
-55
View File
@@ -1,55 +0,0 @@
# ruff: noqa: E402
"""Tests for planner.plan_query internal_subrun quiet mode."""
from __future__ import annotations
import io
import sys
import unittest
from contextlib import redirect_stderr
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(REPO_ROOT / "scripts"))
from lib import planner
class PlannerQuietModeTests(unittest.TestCase):
def _call(self, *, internal_subrun: bool):
err = io.StringIO()
with redirect_stderr(err):
plan = planner.plan_query(
topic="Acme Corp",
available_sources=["grounding", "reddit"],
requested_sources=None,
depth="default",
provider=None,
model=None,
internal_subrun=internal_subrun,
)
return plan, err.getvalue()
def test_default_emits_law7_warning(self):
plan, stderr = self._call(internal_subrun=False)
self.assertIn("No --plan passed", stderr)
self.assertIn("YOU ARE the planner", stderr)
self.assertTrue(plan.subqueries)
def test_internal_subrun_suppresses_warning(self):
plan, stderr = self._call(internal_subrun=True)
self.assertNotIn("No --plan passed", stderr)
self.assertNotIn("YOU ARE the planner", stderr)
# Still returns a valid fallback plan
self.assertTrue(plan.subqueries)
def test_internal_subrun_still_allows_other_warnings(self):
"""Quiet mode only silences the LAW 7 block, not all planner output."""
plan, _stderr = self._call(internal_subrun=True)
# The plan itself is deterministic fallback; verify note carries
# no planner-error indication.
self.assertGreater(len(plan.subqueries), 0)
if __name__ == "__main__":
unittest.main()
-172
View File
@@ -281,177 +281,5 @@ class PlannerV3Tests(unittest.TestCase):
self.assertIn("instagram", all_sources)
class IntentModifierBreadthTests(unittest.TestCase):
"""Unit 2: Topics with intent modifiers (use cases, workflows, examples,
review, comparison) must fan out across paraphrased subqueries rather
than echo the literal phrase. 2026-04-19 Hermes Agent Use Cases failure.
"""
def test_max_subqueries_raised_to_5_for_how_to(self):
self.assertEqual(5, planner._max_subqueries("how_to"))
def test_max_subqueries_raised_to_5_for_opinion(self):
self.assertEqual(5, planner._max_subqueries("opinion"))
def test_max_subqueries_raised_to_5_for_product(self):
self.assertEqual(5, planner._max_subqueries("product"))
def test_max_subqueries_unchanged_for_comparison(self):
self.assertEqual(4, planner._max_subqueries("comparison"))
def test_max_subqueries_unchanged_for_factual_and_concept(self):
self.assertEqual(2, planner._max_subqueries("factual"))
self.assertEqual(2, planner._max_subqueries("concept"))
def test_has_intent_modifier_detects_use_cases(self):
self.assertTrue(planner._has_intent_modifier("Hermes Agent use cases"))
self.assertTrue(planner._has_intent_modifier("Hermes Agent Actual Use Cases"))
def test_has_intent_modifier_detects_workflows(self):
self.assertTrue(planner._has_intent_modifier("Claude Code workflows"))
def test_has_intent_modifier_detects_review_and_tutorial(self):
self.assertTrue(planner._has_intent_modifier("Ollama review"))
self.assertTrue(planner._has_intent_modifier("DSPy tutorial"))
def test_has_intent_modifier_false_for_bare_entity(self):
self.assertFalse(planner._has_intent_modifier("Kanye West"))
self.assertFalse(planner._has_intent_modifier("hermes agent"))
def test_fallback_fans_out_when_intent_modifier_present(self):
plan = planner.plan_query(
topic="Hermes Agent use cases",
available_sources=["reddit", "x", "youtube", "hackernews"],
requested_sources=None,
depth="default",
provider=None,
model=None,
)
# Expect at least 3 subqueries total (primary + fanout); cap is 5 for
# how_to/opinion/product/breaking_news. Label set should include at
# least one of the paraphrase labels.
labels = {sq.label for sq in plan.subqueries}
self.assertGreaterEqual(len(plan.subqueries), 3)
self.assertTrue(
labels & {"workflows", "production", "experience"},
f"Expected paraphrase labels in {labels}",
)
def test_fallback_does_not_fan_out_for_bare_entity(self):
plan = planner.plan_query(
topic="Kanye West",
available_sources=["reddit", "x", "grounding"],
requested_sources=None,
depth="default",
provider=None,
model=None,
)
# Bare entity without intent modifier should not trigger the paraphrase
# fanout (those labels are not in the plan).
labels = {sq.label for sq in plan.subqueries}
self.assertFalse(labels & {"workflows", "production", "experience"})
def test_prompt_includes_intent_modifier_rule(self):
prompt = planner._build_prompt(
topic="Hermes Agent use cases",
available_sources=["reddit", "x", "youtube"],
requested_sources=None,
depth="default",
)
self.assertIn("INTENT-MODIFIER HANDLING", prompt)
self.assertIn("use cases", prompt)
self.assertIn("STRIP that phrase", prompt)
class FallbackDefaultsTests(unittest.TestCase):
"""Unit 3: Deterministic fallback defaults and keyword_query quoting.
2026-04-19 Hermes Agent Use Cases failure.
"""
def test_unclassified_topic_defaults_to_concept_not_breaking_news(self):
# Prior default was "breaking_news" with strict_recent freshness,
# which biased against older relevant material on unfamiliar topics.
self.assertEqual("concept", planner._infer_intent("some unfamiliar topic"))
self.assertEqual("concept", planner._infer_intent("Hermes Agent"))
def test_recency_signals_still_break_out_to_breaking_news(self):
self.assertEqual("breaking_news", planner._infer_intent("trending AI tools"))
self.assertEqual("breaking_news", planner._infer_intent("what's happening today"))
self.assertEqual("breaking_news", planner._infer_intent("this week in AI"))
def test_specific_intents_still_classify_correctly(self):
# Regression: other regex branches still fire as before.
self.assertEqual("how_to", planner._infer_intent("how to deploy Docker"))
self.assertEqual("factual", planner._infer_intent("who acquired Wiz"))
self.assertEqual("opinion", planner._infer_intent("thoughts on OpenAI Codex"))
self.assertEqual("comparison", planner._infer_intent("Codex vs Claude Code"))
def test_keyword_query_quotes_only_title_cased_proper_nouns(self):
# "Hermes Agent" is a multi-word title-cased proper noun — keep quoted.
# "Use Cases" is also title-cased BUT we only quote the first 2
# title-cased compounds; the first extracted is "Hermes Agent".
search = planner._keyword_query("Hermes Agent use cases", "hermes agent")
self.assertIn('"Hermes Agent"', search)
# The old behavior quoted the entire typed topic; confirm it does not.
self.assertNotIn('"Hermes Agent Actual Use Cases"', search)
def test_keyword_query_does_not_quote_bare_lowercase_topic(self):
search = planner._keyword_query("kanye west bully", "kanye west bully")
# Lowercase topics have no title-cased compound to quote.
self.assertNotIn('"', search)
def test_fallback_logs_warning_when_no_provider(self):
import io
import contextlib
buf = io.StringIO()
with contextlib.redirect_stderr(buf):
planner.plan_query(
topic="Hermes Agent use cases",
available_sources=["reddit", "x"],
requested_sources=None,
depth="default",
provider=None,
model=None,
)
output = buf.getvalue()
# New language: "No --plan passed" + "YOU ARE the planner" +
# runtime enumeration. Unit 4 (2026-04-19) rewrite to stop the
# "no provider = no LLM = I need a key" misread.
self.assertIn("No --plan passed", output)
self.assertIn("YOU ARE the planner", output)
self.assertIn("you ARE the LLM", output)
# Runtime-agnostic: each supported runtime name should appear.
for runtime_name in ("Claude Code", "Codex", "Hermes", "Gemini"):
self.assertIn(runtime_name, output)
# The old misleading phrasing must NOT appear.
self.assertNotIn("No --plan and no LLM provider configured", output)
def test_fallback_does_not_log_new_warning_when_provider_present(self):
# When a provider is configured, the provider path runs; if it
# errors, we get the "LLM planning failed" message, NOT the
# "No --plan passed" guidance (which is specifically for the
# no-provider-no-plan caller path).
import io
import contextlib
buf = io.StringIO()
class _NoopProvider:
def generate_json(self, model, prompt):
raise ValueError("force fallback for test")
with contextlib.redirect_stderr(buf):
planner.plan_query(
topic="Kanye West",
available_sources=["reddit", "x"],
requested_sources=None,
depth="default",
provider=_NoopProvider(),
model="some-model",
)
output = buf.getvalue()
self.assertIn("LLM planning failed", output)
self.assertNotIn("No --plan passed", output)
if __name__ == "__main__":
unittest.main()
-74
View File
@@ -1,74 +0,0 @@
# ruff: noqa: E402
"""Tests for --polymarket-keywords filter and filter_items_against_keywords."""
from __future__ import annotations
import sys
import unittest
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(REPO_ROOT / "scripts"))
from lib import polymarket
def _item(title: str) -> dict:
return {"title": title}
class FilterItemsAgainstKeywordsTests(unittest.TestCase):
def test_no_keywords_returns_all(self):
items = [_item("NBA Finals"), _item("Glasgow Warriors")]
out = polymarket.filter_items_against_keywords(items, [])
self.assertEqual(out, items)
def test_single_keyword_filters(self):
items = [
_item("Golden State Warriors win title"),
_item("Glasgow Warriors rugby"),
_item("Honor of Kings: Rogue Warriors"),
]
out = polymarket.filter_items_against_keywords(items, ["golden"])
self.assertEqual(len(out), 1)
self.assertIn("Golden State", out[0]["title"])
def test_multiple_keywords_any_match(self):
items = [
_item("NBA Finals: Warriors vs Celtics"),
_item("Glasgow rugby"),
_item("GSW schedule"),
]
out = polymarket.filter_items_against_keywords(items, ["nba", "gsw"])
self.assertEqual(len(out), 2)
def test_case_insensitive_match(self):
items = [_item("Golden State Warriors"), _item("GLASGOW WARRIORS")]
out = polymarket.filter_items_against_keywords(items, ["GOLDEN"])
self.assertEqual(len(out), 1)
self.assertIn("Golden State", out[0]["title"])
def test_empty_keyword_strings_ignored(self):
items = [_item("NBA Finals")]
out = polymarket.filter_items_against_keywords(items, ["", " ", ""])
# All keywords are empty → treated as no filter
self.assertEqual(out, items)
def test_sourceitem_like_objects(self):
class _SI:
def __init__(self, t):
self.title = t
items = [_SI("NBA Finals"), _SI("Glasgow Warriors rugby")]
out = polymarket.filter_items_against_keywords(items, ["nba"])
self.assertEqual(len(out), 1)
self.assertEqual(out[0].title, "NBA Finals")
def test_no_match_returns_empty(self):
items = [_item("Glasgow Warriors"), _item("Rogue Warriors")]
out = polymarket.filter_items_against_keywords(items, ["nba", "gsw"])
self.assertEqual(out, [])
if __name__ == "__main__":
unittest.main()
-128
View File
@@ -1,128 +0,0 @@
"""Tests for scripts/lib/preflight.py Class 1 keyword-trap refuse-gate.
Class 1 (demographic shopping) is the one failure class that shipped to
public v3.0.8 and still returned junk for queries like 'birthday gift for
40 year old'. This module is the engine's structural refusal, so the model
cannot bypass by skipping SKILL.md.
"""
import sys
import unittest
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "scripts"))
from lib import preflight
class TestClass1Match(unittest.TestCase):
"""Queries that MUST trigger the refuse-gate."""
def test_birthday_gift_for_age(self):
self.assertIsNotNone(preflight.check_class_1_trap("birthday gift for 40 year old"))
def test_gift_for_age(self):
self.assertIsNotNone(preflight.check_class_1_trap("gift for 42 year old"))
def test_gift_for_age_relationship(self):
self.assertIsNotNone(preflight.check_class_1_trap("gift for my 42 year old husband"))
def test_gift_ideas_for_age(self):
self.assertIsNotNone(preflight.check_class_1_trap("gift ideas for 30 year old"))
def test_present_for_age(self):
self.assertIsNotNone(preflight.check_class_1_trap("present for a 50 year old"))
def test_hyphenated_year_old(self):
self.assertIsNotNone(preflight.check_class_1_trap("gift for 40-year-old"))
def test_best_for_men(self):
self.assertIsNotNone(preflight.check_class_1_trap("best running shoes for men"))
def test_best_for_women(self):
self.assertIsNotNone(preflight.check_class_1_trap("best gifts for women"))
def test_best_for_kids(self):
self.assertIsNotNone(preflight.check_class_1_trap("best toys for kids"))
def test_what_to_buy_husband(self):
self.assertIsNotNone(preflight.check_class_1_trap("what to buy my husband"))
def test_what_to_get_boss(self):
self.assertIsNotNone(preflight.check_class_1_trap("what to get my boss"))
def test_what_to_gift_age(self):
self.assertIsNotNone(preflight.check_class_1_trap("what to gift a 35 year old"))
def test_gifts_for_husband(self):
self.assertIsNotNone(preflight.check_class_1_trap("gifts for my husband"))
def test_case_insensitive(self):
self.assertIsNotNone(preflight.check_class_1_trap("Birthday Gift For 40 Year Old"))
def test_leading_whitespace(self):
self.assertIsNotNone(preflight.check_class_1_trap(" gift for 40 year old "))
class TestClass1Skip(unittest.TestCase):
"""Queries that MUST NOT trigger the refuse-gate (qualifier present or not shopping)."""
def test_named_person(self):
self.assertIsNone(preflight.check_class_1_trap("Peter Steinberger"))
def test_comparison(self):
self.assertIsNone(preflight.check_class_1_trap("OpenClaw vs Paperclip"))
def test_entity_query(self):
self.assertIsNone(preflight.check_class_1_trap("Kanye West"))
def test_general_concept(self):
self.assertIsNone(preflight.check_class_1_trap("vibe coding"))
def test_budget_qualifier(self):
self.assertIsNone(preflight.check_class_1_trap("gift for my husband, $200 budget"))
def test_hobby_qualifier(self):
self.assertIsNone(preflight.check_class_1_trap("gift for my cooking-obsessed husband"))
def test_loves_qualifier(self):
self.assertIsNone(preflight.check_class_1_trap("gift for my dad who loves golf"))
def test_is_into_qualifier(self):
self.assertIsNone(preflight.check_class_1_trap("gift for my brother who is into woodworking"))
def test_specific_interest_in_query(self):
self.assertIsNone(preflight.check_class_1_trap("birthday gift for 40 year old runner"))
class TestRefuseMessage(unittest.TestCase):
"""The REFUSE message must contain the diagnostic content the model needs."""
def test_refuse_mentions_class_1(self):
msg = preflight.check_class_1_trap("birthday gift for 40 year old")
assert msg is not None
self.assertIn("Class 1", msg)
def test_refuse_asks_for_hobbies(self):
msg = preflight.check_class_1_trap("gift for 40 year old")
assert msg is not None
self.assertIn("hobbies", msg.lower())
def test_refuse_asks_for_relationship(self):
msg = preflight.check_class_1_trap("gift for 40 year old")
assert msg is not None
self.assertIn("relationship", msg.lower())
def test_refuse_asks_for_budget(self):
msg = preflight.check_class_1_trap("gift for 40 year old")
assert msg is not None
self.assertIn("budget", msg.lower())
def test_refuse_echoes_topic(self):
msg = preflight.check_class_1_trap("birthday gift for 40 year old")
assert msg is not None
self.assertIn("birthday gift for 40 year old", msg)
if __name__ == "__main__":
unittest.main()
+18 -48
View File
@@ -29,38 +29,19 @@ class RegressionTests(unittest.TestCase):
self.assertIn("clusters", payload)
self.assertIn("items_by_source", payload)
def assert_comparison_shape(self, payload: dict) -> None:
"""Post-3.0.13: vs-topics produce N full passes, merged output has
comparison=True + entities list + per-entity report wrapper."""
self.assertTrue(payload.get("comparison"))
self.assertIn("entities", payload)
self.assertIn("reports", payload)
self.assertEqual(len(payload["entities"]), len(payload["reports"]))
# Each report entry wraps a single-topic report
for entry in payload["reports"]:
self.assertIn("entity", entry)
self.assertIn("report", entry)
# Inner report still has the single-topic shape
inner = entry["report"]
self.assertIn("topic", inner)
self.assertIn("query_plan", inner)
self.assertIn("clusters", inner)
def test_openclaw_three_way_comparison_preserves_entities(self):
payload = run_mock_json("openclaw vs. nanoclaw vs. ironclaw")
self.assert_comparison_shape(payload)
entities = [e.lower() for e in payload["entities"]]
self.assertIn("openclaw", entities)
self.assertIn("nanoclaw", entities)
self.assertIn("ironclaw", entities)
# No cross-entity keyword pollution in any per-entity report's plan
for entry in payload["reports"]:
plan = entry["report"]["query_plan"]
joined = "\n".join(
sq["search_query"] for sq in plan["subqueries"]
).lower()
self.assertNotIn("corsair", joined)
self.assertNotIn("mouse", joined)
self.assert_common_shape(payload)
plan = payload["query_plan"]
self.assertEqual("comparison", plan["intent"])
joined_queries = "\n".join(subquery["search_query"] for subquery in plan["subqueries"]).lower()
self.assertIn("openclaw", joined_queries)
self.assertIn("nanoclaw", joined_queries)
self.assertIn("ironclaw", joined_queries)
self.assertNotIn("corsair", joined_queries)
self.assertNotIn("mouse", joined_queries)
for subquery in plan["subqueries"]:
self.assertGreaterEqual(len(subquery["sources"]), 4)
def test_how_to_keeps_web_video_and_discussion_sources(self):
payload = run_mock_json("how to deploy on Fly.io")
@@ -83,24 +64,13 @@ class RegressionTests(unittest.TestCase):
def test_two_way_comparison_preserves_exact_strings(self):
payload = run_mock_json("DeepSeek R1 vs GPT-5")
self.assert_comparison_shape(payload)
entities_lower = [e.lower() for e in payload["entities"]]
self.assertIn("deepseek r1", entities_lower)
self.assertIn("gpt-5", entities_lower)
# Each per-entity pass has its own entity in its plan
topics_by_entity = {
entry["entity"].lower(): entry["report"]["topic"].lower()
for entry in payload["reports"]
}
self.assertEqual(topics_by_entity["deepseek r1"], "deepseek r1")
self.assertEqual(topics_by_entity["gpt-5"], "gpt-5")
# No cross-entity pollution
for entry in payload["reports"]:
plan = entry["report"]["query_plan"]
joined = "\n".join(
sq["search_query"] for sq in plan["subqueries"]
).lower()
self.assertNotIn("corsair", joined)
self.assert_common_shape(payload)
plan = payload["query_plan"]
self.assertEqual("comparison", plan["intent"])
joined_queries = "\n".join(subquery["search_query"] for subquery in plan["subqueries"]).lower()
self.assertIn("deepseek r1", joined_queries)
self.assertIn("gpt-5", joined_queries)
self.assertNotIn("corsair", joined_queries)
if __name__ == "__main__":
-305
View File
@@ -1,305 +0,0 @@
# ruff: noqa: E402
"""Tests for render.render_comparison_multi and emit_comparison_output."""
from __future__ import annotations
import json
import sys
import unittest
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(REPO_ROOT / "scripts"))
import last30days as cli
from lib import render, schema
def _build_report(topic: str, cluster_titles: list[str]) -> schema.Report:
query_plan = schema.QueryPlan(
intent="comparison",
freshness_mode="balanced_recent",
cluster_mode="debate",
raw_topic=topic,
subqueries=[
schema.SubQuery(
label="primary",
search_query=topic,
ranking_query=topic,
sources=["grounding"],
)
],
source_weights={"grounding": 1.0},
)
clusters: list[schema.Cluster] = []
candidates: list[schema.Candidate] = []
for idx, title in enumerate(cluster_titles):
candidate_id = f"{topic.lower().replace(' ', '-')}-c{idx}"
item = schema.SourceItem(
source="grounding",
item_id=f"g-{candidate_id}",
title=f"{title} evidence",
body=f"Body for {title}",
url=f"https://example.test/{candidate_id}",
snippet=f"Snippet for {title}",
published_at="2026-04-20",
)
candidate = schema.Candidate(
candidate_id=candidate_id,
item_id=item.item_id,
source="grounding",
title=item.title,
url=item.url,
snippet=item.snippet,
subquery_labels=["primary"],
native_ranks={"grounding": idx + 1},
local_relevance=0.8 - idx * 0.1,
freshness=5,
engagement=10,
source_quality=0.9,
rrf_score=0.6 - idx * 0.05,
sources=["grounding"],
source_items=[item],
final_score=80.0 - idx * 5,
)
candidates.append(candidate)
clusters.append(
schema.Cluster(
cluster_id=f"cl-{idx}",
title=title,
candidate_ids=[candidate_id],
representative_ids=[candidate_id],
score=80.0 - idx * 5,
sources=["grounding"],
)
)
return schema.Report(
topic=topic,
range_from="2026-03-23",
range_to="2026-04-22",
generated_at="2026-04-22T00:00:00+00:00",
provider_runtime=schema.ProviderRuntime(
reasoning_provider="mock",
planner_model="mock-planner",
rerank_model="mock-rerank",
),
query_plan=query_plan,
clusters=clusters,
ranked_candidates=candidates,
items_by_source={"grounding": [c.source_items[0] for c in candidates]},
errors_by_source={},
)
class RenderComparisonMultiTests(unittest.TestCase):
def test_three_entity_table(self):
reports = [
("OpenAI", _build_report("OpenAI", ["GPT-5 drop", "API pricing cut"])),
("Anthropic", _build_report("Anthropic", ["Claude 4.7 ship", "MCP rollout"])),
("xAI", _build_report("xAI", ["Grok 4 release", "Memphis cluster"])),
]
rendered = render.render_comparison_multi(reports)
# All three entities appear in the header
self.assertIn("OpenAI vs Anthropic vs xAI", rendered)
# Each entity has its own evidence section
self.assertIn("## OpenAI", rendered)
self.assertIn("## Anthropic", rendered)
self.assertIn("## xAI", rendered)
# Scaffold table header has a column per entity
self.assertIn("| Dimension | OpenAI | Anthropic | xAI |", rendered)
# Envelope scaffolding present
self.assertIn("EVIDENCE FOR SYNTHESIS", rendered)
self.assertIn("END OF last30days CANONICAL OUTPUT", rendered)
def test_two_entity_table_has_two_columns(self):
reports = [
("Kanye West", _build_report("Kanye West", ["Donda 2 release"])),
("Drake", _build_report("Drake", ["For All The Dogs"])),
]
rendered = render.render_comparison_multi(reports)
self.assertIn("| Dimension | Kanye West | Drake |", rendered)
self.assertIn("## Kanye West", rendered)
self.assertIn("## Drake", rendered)
def test_empty_clusters_renders_placeholder(self):
reports = [
("OpenAI", _build_report("OpenAI", ["GPT-5 drop"])),
("ObscureCompetitor", _build_report("ObscureCompetitor", [])),
]
rendered = render.render_comparison_multi(reports)
self.assertIn("## ObscureCompetitor", rendered)
self.assertIn("no significant discussion this month", rendered)
# Main still has its cluster
self.assertIn("GPT-5 drop", rendered)
def test_warnings_aggregated_and_labeled(self):
report_a = _build_report("OpenAI", ["GPT-5 drop"])
report_b = _build_report("Anthropic", ["Claude 4.7"])
report_a.warnings.append("Brave quota exhausted")
report_b.warnings.append("Exa returned 0 results")
rendered = render.render_comparison_multi(
[("OpenAI", report_a), ("Anthropic", report_b)]
)
self.assertIn("[OpenAI] Brave quota exhausted", rendered)
self.assertIn("[Anthropic] Exa returned 0 results", rendered)
def test_raises_on_empty_input(self):
with self.assertRaises(ValueError):
render.render_comparison_multi([])
def test_context_emit(self):
reports = [
("OpenAI", _build_report("OpenAI", ["GPT-5 drop"])),
("Anthropic", _build_report("Anthropic", ["Claude 4.7"])),
]
out = render.render_comparison_multi_context(reports)
self.assertIn("Comparison: OpenAI vs Anthropic", out)
self.assertIn("## OpenAI", out)
self.assertIn("## Anthropic", out)
self.assertIn("GPT-5 drop", out)
class ResolvedEntitiesBlockTests(unittest.TestCase):
def _build_with_resolved(self, label, topic, resolved):
r = _build_report(topic, ["Cluster A"])
if resolved is not None:
r.artifacts["resolved"] = resolved
return (label, r)
def test_block_emitted_when_any_entity_has_resolved(self):
reports = [
self._build_with_resolved("OpenAI", "OpenAI", {
"entity": "OpenAI",
"x_handle": "OpenAI",
"subreddits": ["OpenAI", "MachineLearning"],
"github_user": "openai",
"github_repos": ["openai/gpt"],
"context": "GPT-5 release signals are strong",
}),
self._build_with_resolved("Anthropic", "Anthropic", {
"entity": "Anthropic",
"x_handle": "AnthropicAI",
"subreddits": ["ClaudeAI"],
"github_user": "anthropics",
"github_repos": [],
"context": "",
}),
]
rendered = render.render_comparison_multi(reports)
self.assertIn("## Resolved Entities", rendered)
self.assertIn("**OpenAI**: X @OpenAI", rendered)
self.assertIn("r/OpenAI, r/MachineLearning", rendered)
self.assertIn("@openai (openai/gpt)", rendered)
self.assertIn("**Anthropic**: X @AnthropicAI", rendered)
# Missing context renders as "-"
self.assertIn("Context: -", rendered)
def test_block_omitted_when_no_resolved_artifacts(self):
reports = [
self._build_with_resolved("A", "A", None),
self._build_with_resolved("B", "B", None),
]
rendered = render.render_comparison_multi(reports)
self.assertNotIn("## Resolved Entities", rendered)
def test_missing_fields_render_as_dash(self):
reports = [
self._build_with_resolved("OpenAI", "OpenAI", {
"entity": "OpenAI",
"x_handle": "",
"subreddits": [],
"github_user": "",
"github_repos": [],
"context": "",
}),
]
rendered = render.render_comparison_multi(reports)
self.assertIn("**OpenAI**: X - | Subs - | GitHub - | Context: -", rendered)
def test_long_context_truncated(self):
long = "a" * 200
reports = [
self._build_with_resolved("X", "X", {
"entity": "X",
"x_handle": "",
"subreddits": [],
"github_user": "",
"github_repos": [],
"context": long,
}),
]
rendered = render.render_comparison_multi(reports)
# The truncate helper adds an ellipsis; context line should not show
# the full 200-char string.
self.assertNotIn("a" * 200, rendered)
def test_context_emit_includes_resolved_block(self):
reports = [
self._build_with_resolved("OpenAI", "OpenAI", {
"entity": "OpenAI",
"x_handle": "OpenAI",
"subreddits": ["OpenAI"],
"github_user": "",
"github_repos": [],
"context": "",
}),
]
out = render.render_comparison_multi_context(reports)
self.assertIn("## Resolved Entities", out)
self.assertIn("**OpenAI**: X @OpenAI", out)
def test_subreddit_overflow_truncated(self):
reports = [
self._build_with_resolved("X", "X", {
"entity": "X",
"x_handle": "",
"subreddits": ["a", "b", "c", "d", "e", "f", "g"],
"github_user": "",
"github_repos": [],
"context": "",
}),
]
rendered = render.render_comparison_multi(reports)
self.assertIn("r/a, r/b, r/c, r/d, r/e (+2)", rendered)
class EmitComparisonOutputTests(unittest.TestCase):
def test_json_emit_nests_per_entity(self):
reports = [
("OpenAI", _build_report("OpenAI", ["GPT-5 drop"])),
("Anthropic", _build_report("Anthropic", ["Claude 4.7"])),
]
out = cli.emit_comparison_output(reports, emit="json")
payload = json.loads(out)
self.assertTrue(payload["comparison"])
self.assertEqual(payload["entities"], ["OpenAI", "Anthropic"])
self.assertEqual(len(payload["reports"]), 2)
self.assertEqual(payload["reports"][0]["entity"], "OpenAI")
self.assertIn("topic", payload["reports"][0]["report"])
def test_compact_and_md_both_route_to_multi(self):
reports = [
("A", _build_report("A", ["Thing A"])),
("B", _build_report("B", ["Thing B"])),
]
compact = cli.emit_comparison_output(reports, emit="compact")
md = cli.emit_comparison_output(reports, emit="md")
self.assertIn("| Dimension | A | B |", compact)
self.assertEqual(compact, md)
def test_context_emit_goes_to_context_renderer(self):
reports = [
("A", _build_report("A", ["Thing A"])),
("B", _build_report("B", ["Thing B"])),
]
out = cli.emit_comparison_output(reports, emit="context")
self.assertIn("Comparison: A vs B", out)
def test_unsupported_emit_raises(self):
reports = [("A", _build_report("A", ["Thing A"]))]
with self.assertRaises(SystemExit):
cli.emit_comparison_output(reports, emit="xml")
if __name__ == "__main__":
unittest.main()
+11 -192
View File
@@ -117,72 +117,8 @@ class RenderV3Tests(unittest.TestCase):
report.errors_by_source = {"x": "HTTP 400: Bad Request"}
text = render.render_compact(report)
self.assertIn("## Source Errors", text)
class OutputEnvelopeTests(unittest.TestCase):
"""LAW 6 envelope comments: scope "pass through verbatim" unambiguously.
Added 2026-04-19 after the Hermes Agent Use Cases failure where two
consecutive runs dumped `## Ranked Evidence Clusters` as user output.
"""
def test_evidence_for_synthesis_envelope_wraps_raw_evidence(self):
text = render.render_compact(sample_report())
self.assertIn("<!-- EVIDENCE FOR SYNTHESIS:", text)
self.assertIn("<!-- END EVIDENCE FOR SYNTHESIS -->", text)
# Opening comment must appear BEFORE the raw evidence block.
self.assertLess(
text.index("<!-- EVIDENCE FOR SYNTHESIS:"),
text.index("## Ranked Evidence Clusters"),
)
# Closing comment must appear AFTER Source Coverage.
self.assertGreater(
text.index("<!-- END EVIDENCE FOR SYNTHESIS -->"),
text.index("## Source Coverage"),
)
def test_pass_through_footer_envelope_wraps_emoji_tree(self):
text = render.render_compact(sample_report())
self.assertIn("<!-- PASS-THROUGH FOOTER:", text)
self.assertIn("<!-- END PASS-THROUGH FOOTER -->", text)
# Emoji footer sits between the two markers.
open_idx = text.index("<!-- PASS-THROUGH FOOTER:")
close_idx = text.index("<!-- END PASS-THROUGH FOOTER -->")
self.assertIn("All agents reported back!", text[open_idx:close_idx])
def test_canonical_boundary_scopes_pass_through_to_footer(self):
text = render.render_compact(sample_report())
# New boundary text scopes verbatim to the PASS-THROUGH FOOTER block,
# not everything above.
self.assertIn("Pass through ONLY the PASS-THROUGH FOOTER block verbatim", text)
# Self-check string is present so the model has a concrete failure signal.
self.assertIn("### 1.", text)
self.assertIn("LAW 6", text)
# The prior ambiguous phrasing is gone.
self.assertNotIn("Pass through the lines ABOVE this boundary verbatim", text)
def test_envelopes_appear_in_md_emit_mode(self):
# --emit md and --emit compact both route to render_compact, so the
# same envelopes apply. Guard against future divergence.
text = render.render_compact(sample_report())
self.assertEqual(text.count("<!-- EVIDENCE FOR SYNTHESIS:"), 1)
self.assertEqual(text.count("<!-- END EVIDENCE FOR SYNTHESIS -->"), 1)
self.assertEqual(text.count("<!-- PASS-THROUGH FOOTER:"), 1)
self.assertEqual(text.count("<!-- END PASS-THROUGH FOOTER -->"), 1)
def test_no_dangling_envelope_open_without_close(self):
# Open/close counts must always match, even for empty clusters.
report = sample_report()
report.clusters = []
text = render.render_compact(report)
self.assertEqual(
text.count("<!-- EVIDENCE FOR SYNTHESIS:"),
text.count("<!-- END EVIDENCE FOR SYNTHESIS -->"),
)
self.assertEqual(
text.count("<!-- PASS-THROUGH FOOTER:"),
text.count("<!-- END PASS-THROUGH FOOTER -->"),
)
self.assertIn("HTTP 400: Bad Request", text)
self.assertIn("X:", text)
class RenderTopCommentsTests(unittest.TestCase):
@@ -267,31 +203,31 @@ class RenderTopCommentsTests(unittest.TestCase):
]
report = self._make_report_with_comments(top_comments=comments)
text = render.render_compact(report)
# Reddit authors render with u/ prefix now.
self.assertIn("u/user1 (500 upvotes):", text)
self.assertIn("u/user2 (200 upvotes):", text)
self.assertIn("u/user3 (50 upvotes):", text)
self.assertNotIn("u/user4 (8 upvotes):", text)
self.assertNotIn("u/user5 (3 upvotes):", text)
self.assertIn("Comment (500 upvotes):", text)
self.assertIn("Comment (200 upvotes):", text)
self.assertIn("Comment (50 upvotes):", text)
self.assertNotIn("Comment (8 upvotes):", text)
self.assertNotIn("Comment (3 upvotes):", text)
def test_reddit_1_comment_renders_1(self):
"""Reddit candidate with 1 comment renders 1."""
comments = [{"score": 100, "excerpt": "Single comment", "author": "user1"}]
report = self._make_report_with_comments(top_comments=comments)
text = render.render_compact(report)
self.assertIn("u/user1 (100 upvotes): Single comment", text)
self.assertIn("Comment (100 upvotes): Single comment", text)
def test_reddit_0_comments_no_section(self):
"""Reddit candidate with 0 comments renders no comment section."""
report = self._make_report_with_comments(top_comments=[])
text = render.render_compact(report)
self.assertNotIn("Comment (", text)
self.assertNotIn("upvotes)", text)
def test_non_reddit_no_comments(self):
"""Non-Reddit candidate doesn't render comments when metadata has none."""
report = self._make_report_with_comments(source="grounding", top_comments=[])
text = render.render_compact(report)
self.assertNotIn("upvotes)", text)
self.assertNotIn("Comment (", text)
self.assertIn("Test cluster", text)
def test_all_comments_below_score_10_no_section(self):
@@ -303,65 +239,9 @@ class RenderTopCommentsTests(unittest.TestCase):
]
report = self._make_report_with_comments(top_comments=comments)
text = render.render_compact(report)
self.assertNotIn("Comment (", text)
self.assertNotIn("upvotes)", text)
def test_youtube_comments_use_likes_label_and_50_threshold(self):
comments = [
{"score": 120, "excerpt": "legit fire tutorial", "author": "alice"},
{"score": 60, "excerpt": "saved me hours", "author": "bob"},
{"score": 10, "excerpt": "below threshold", "author": "carol"},
]
report = self._make_report_with_comments(source="youtube", top_comments=comments)
text = render.render_compact(report)
# YouTube authors render with @ prefix now.
self.assertIn("@alice (120 likes): legit fire tutorial", text)
self.assertIn("@bob (60 likes): saved me hours", text)
self.assertNotIn("@carol (10 likes)", text)
def test_reddit_comment_without_author_falls_back_to_legacy_label(self):
"""When author is missing or [deleted], render falls back to 'Comment (...)'."""
comments = [
{"score": 500, "excerpt": "No author field", "author": ""},
{"score": 200, "excerpt": "Deleted user", "author": "[deleted]"},
{"score": 50, "excerpt": "Removed user", "author": "[removed]"},
]
report = self._make_report_with_comments(top_comments=comments)
text = render.render_compact(report)
# Legacy format preserved - no u/ prefix leaks with empty/deleted handles.
self.assertIn("Comment (500 upvotes): No author field", text)
self.assertIn("Comment (200 upvotes): Deleted user", text)
self.assertIn("Comment (50 upvotes): Removed user", text)
self.assertNotIn("u/ (", text)
self.assertNotIn("u/[deleted]", text)
self.assertNotIn("u/[removed]", text)
def test_tiktok_comments_render_with_at_handle(self):
"""TikTok source renders @handle attribution on comment lines."""
comments = [
{"score": 3986, "excerpt": "oh no. who's going to make the same phone every year now..", "author": "moosanoormahomed"},
{"score": 925, "excerpt": "This is either going to go so well or so bad", "author": "Muna9e"},
]
report = self._make_report_with_comments(source="tiktok", top_comments=comments)
text = render.render_compact(report)
self.assertIn("@moosanoormahomed (3986 likes):", text)
self.assertIn("@Muna9e (925 likes):", text)
# Render must not silently label YT as upvotes.
self.assertNotIn("Comment (120 upvotes)", text)
def test_tiktok_comments_use_likes_label_and_500_threshold(self):
comments = [
{"score": 2000, "excerpt": "this aged well", "author": "a"},
{"score": 600, "excerpt": "so real", "author": "b"},
{"score": 400, "excerpt": "below tt threshold", "author": "c"},
{"score": 50, "excerpt": "way below", "author": "d"},
]
report = self._make_report_with_comments(source="tiktok", top_comments=comments)
text = render.render_compact(report)
self.assertIn("@a (2000 likes): this aged well", text)
self.assertIn("@b (600 likes): so real", text)
self.assertNotIn("@c (400 likes)", text)
self.assertNotIn("@d (50 likes)", text)
class RenderBestTakesCompactTests(unittest.TestCase):
"""Tests for Best Takes section in compact output and fun tags on candidates."""
@@ -490,66 +370,5 @@ class RenderBestTakesCompactTests(unittest.TestCase):
self.assertNotIn("## Best Takes", text)
class DegradedRunBannerTests(unittest.TestCase):
"""Unit 1: DEGRADED RUN WARNING surfaces bare named-entity invocations
in user-visible stdout. LAW 7 backstop. 2026-04-19 Hermes Agent Use
Cases Run 1 failure mode.
"""
def _bare_named_entity_report(self) -> schema.Report:
report = sample_report()
report.topic = "Hermes Agent"
report.artifacts["plan_source"] = "deterministic"
report.artifacts["pre_research_flags_present"] = False
return report
def test_banner_appears_on_bare_named_entity_deterministic_run(self):
text = render.render_compact(self._bare_named_entity_report())
self.assertIn("## DEGRADED RUN WARNING", text)
self.assertIn("<!-- USER-VISIBLE BANNER:", text)
self.assertIn("<!-- END USER-VISIBLE BANNER -->", text)
self.assertIn("YOU ARE", text)
# Runtime-agnostic enumeration: all host runtimes appear.
for runtime_name in ("Claude Code", "Codex", "Hermes", "Gemini"):
self.assertIn(runtime_name, text)
def test_banner_positioned_before_evidence_envelope(self):
text = render.render_compact(self._bare_named_entity_report())
banner_idx = text.index("## DEGRADED RUN WARNING")
envelope_idx = text.index("<!-- EVIDENCE FOR SYNTHESIS:")
self.assertLess(banner_idx, envelope_idx,
"DEGRADED RUN banner must appear BEFORE evidence envelope so pass-through catches it.")
def test_banner_suppressed_when_plan_source_external(self):
report = self._bare_named_entity_report()
report.artifacts["plan_source"] = "external"
text = render.render_compact(report)
self.assertNotIn("## DEGRADED RUN WARNING", text)
def test_banner_suppressed_when_plan_source_llm(self):
report = self._bare_named_entity_report()
report.artifacts["plan_source"] = "llm"
text = render.render_compact(report)
self.assertNotIn("## DEGRADED RUN WARNING", text)
def test_banner_suppressed_when_pre_research_flags_present(self):
report = self._bare_named_entity_report()
report.artifacts["pre_research_flags_present"] = True
text = render.render_compact(report)
self.assertNotIn("## DEGRADED RUN WARNING", text)
def test_banner_suppressed_on_non_eligible_abstract_topic(self):
report = self._bare_named_entity_report()
# Multi-word lowercase abstract phrase is NOT pre-research-eligible.
report.topic = "how to deploy containers in the cloud"
text = render.render_compact(report)
self.assertNotIn("## DEGRADED RUN WARNING", text)
def test_banner_mentions_law_7_and_plan_flag(self):
text = render.render_compact(self._bare_named_entity_report())
self.assertIn("LAW 7", text)
self.assertIn("--plan", text)
if __name__ == "__main__":
unittest.main()
+1 -203
View File
@@ -178,211 +178,9 @@ class RerankV3Tests(unittest.TestCase):
self.assertEqual("gemini-3.1-flash-lite-preview", provider.model)
self.assertEqual(95.0, first.rerank_score)
self.assertEqual("high fit", first.explanation)
# Tail is scored via the fallback (may or may not carry the entity-miss
# suffix depending on topic-title overlap; assert the base tag is present).
self.assertIn("fallback-local-score", second.explanation or "")
self.assertEqual("fallback-local-score", second.explanation)
self.assertEqual(first.candidate_id, ranked[0].candidate_id)
class EntityGroundingTests(unittest.TestCase):
"""Unit 4: Reranker entity-grounding demotion. 2026-04-19 Hermes Agent
Use Cases failure: an off-topic video about Claude Managed Agents
scored 51 and ranked #2 with zero Hermes content.
"""
def _candidate(self, title: str, snippet: str = "") -> schema.Candidate:
return schema.Candidate(
candidate_id=f"c-{title[:10]}",
item_id="i1",
source="youtube",
title=title,
url="https://example.com",
snippet=snippet,
subquery_labels=["primary"],
native_ranks={"primary:youtube": 1},
local_relevance=0.8,
freshness=80,
engagement=50,
source_quality=0.7,
rrf_score=0.02,
)
def test_primary_entity_strips_intent_modifier(self):
self.assertEqual("Hermes Agent", rerank._primary_entity("Hermes Agent use cases"))
self.assertEqual("Hermes Agent Actual", rerank._primary_entity("Hermes Agent Actual Use Cases"))
self.assertEqual("Claude Code", rerank._primary_entity("Claude Code workflows"))
self.assertEqual("DSPy", rerank._primary_entity("DSPy tutorial"))
def test_primary_entity_leaves_bare_entity_unchanged(self):
self.assertEqual("Kanye West", rerank._primary_entity("Kanye West"))
self.assertEqual("Nous Research", rerank._primary_entity("Nous Research"))
def test_fallback_demotes_candidate_without_primary_entity(self):
on_topic = self._candidate("Hermes Agent: Self-Improving AI", "Nous Research Hermes walkthrough")
off_topic = self._candidate("I Tested Claude's Managed Agents", "What you need to know about Anthropic's new managed agents")
rerank._apply_fallback_scores([on_topic, off_topic], primary_entity="Hermes Agent")
self.assertGreater(on_topic.final_score, off_topic.final_score)
self.assertIn("entity-miss", off_topic.explanation or "")
self.assertEqual(on_topic.explanation, "fallback-local-score")
def test_fallback_match_is_case_insensitive(self):
on_topic = self._candidate("HERMES agent rocks", "some text")
rerank._apply_fallback_scores([on_topic], primary_entity="Hermes Agent")
self.assertEqual("fallback-local-score", on_topic.explanation)
def test_fallback_skips_demotion_for_empty_text_candidates(self):
empty = self._candidate("", "")
rerank._apply_fallback_scores([empty], primary_entity="Hermes Agent")
self.assertEqual("fallback-local-score", empty.explanation)
def test_fallback_skips_demotion_when_no_primary_entity(self):
off = self._candidate("Completely unrelated", "snippet")
rerank._apply_fallback_scores([off], primary_entity="")
self.assertEqual("fallback-local-score", off.explanation)
def test_llm_prompt_includes_primary_entity_grounding_hint(self):
candidate = self._candidate("Something", "snippet text")
plan = make_plan()
prompt = rerank._build_prompt(
"Hermes Agent use cases", plan, [candidate], primary_entity="Hermes Agent"
)
self.assertIn("Primary entity grounding", prompt)
self.assertIn("Hermes Agent", prompt)
def test_llm_prompt_omits_grounding_hint_when_no_primary_entity(self):
candidate = self._candidate("Something", "snippet text")
plan = make_plan()
prompt = rerank._build_prompt("", plan, [candidate], primary_entity="")
self.assertNotIn("Primary entity grounding", prompt)
class ExpandedHaystackTests(unittest.TestCase):
"""Unit 3: Entity-grounding haystack covers transcript snippets,
transcript highlights, top comments, and comment insights - not
just title + snippet.
"""
def _youtube_candidate(self, title: str, transcript_snippet: str = "",
transcript_highlights: list[str] | None = None) -> schema.Candidate:
c = schema.Candidate(
candidate_id=f"c-{title[:10]}",
item_id="i1",
source="youtube",
title=title,
url="https://youtube.com/watch?v=x",
snippet="",
subquery_labels=["primary"],
native_ranks={"primary:youtube": 1},
local_relevance=0.8,
freshness=80,
engagement=50,
source_quality=0.7,
rrf_score=0.02,
)
c.metadata = {}
if transcript_snippet:
c.metadata["transcript_snippet"] = transcript_snippet
if transcript_highlights:
c.metadata["transcript_highlights"] = transcript_highlights
return c
def test_entity_found_in_transcript_snippet_avoids_demotion(self):
# Title + snippet miss the entity, but the transcript contains it.
c = self._youtube_candidate(
"Weekly roundup",
transcript_snippet="In this video I walk through using Hermes Agent in production.",
)
rerank._apply_fallback_scores([c], primary_entity="Hermes Agent")
self.assertEqual("fallback-local-score", c.explanation)
def test_entity_found_in_transcript_highlights_avoids_demotion(self):
c = self._youtube_candidate(
"Some review",
transcript_highlights=[
"Today we're talking about Hermes Agent",
"Let's compare it to the alternatives",
],
)
rerank._apply_fallback_scores([c], primary_entity="Hermes Agent")
self.assertEqual("fallback-local-score", c.explanation)
def test_entity_missing_everywhere_still_demoted_for_video(self):
# Nate Herk "Managed Agents" case: no Hermes in title, snippet,
# or transcript - demotion fires.
c = self._youtube_candidate(
"I Tested Claude's New Managed Agents",
transcript_snippet="Managed agents are Anthropic's new product with ClickUp and cron...",
)
rerank._apply_fallback_scores([c], primary_entity="Hermes Agent")
self.assertIn("entity-miss", c.explanation)
def test_entity_found_in_reddit_top_comments_avoids_demotion(self):
c = schema.Candidate(
candidate_id="r1",
item_id="i1",
source="reddit",
title="Best agent framework?",
url="https://reddit.com/r/x",
snippet="",
subquery_labels=["primary"],
native_ranks={"primary:reddit": 1},
local_relevance=0.8, freshness=80, engagement=50,
source_quality=0.7, rrf_score=0.02,
)
c.metadata = {
"top_comments": [
{"excerpt": "I've been using Hermes Agent for a month and it's great"},
{"text": "another comment"},
],
}
rerank._apply_fallback_scores([c], primary_entity="Hermes Agent")
self.assertEqual("fallback-local-score", c.explanation)
def test_entity_found_in_comment_insights_avoids_demotion(self):
c = schema.Candidate(
candidate_id="r2", item_id="i1", source="reddit",
title="AI tools", url="https://reddit.com/r/x", snippet="",
subquery_labels=["primary"],
native_ranks={"primary:reddit": 1},
local_relevance=0.8, freshness=80, engagement=50,
source_quality=0.7, rrf_score=0.02,
)
c.metadata = {
"comment_insights": ["Consensus: Hermes Agent handles long sessions best"],
}
rerank._apply_fallback_scores([c], primary_entity="Hermes Agent")
self.assertEqual("fallback-local-score", c.explanation)
def test_truly_empty_candidate_still_skipped(self):
# Image-only TikTok with no text anywhere - do not penalize.
c = self._youtube_candidate("") # empty title
rerank._apply_fallback_scores([c], primary_entity="Hermes Agent")
self.assertEqual("fallback-local-score", c.explanation)
def test_final_score_secondary_penalty_applied_on_entity_miss(self):
# When fallback flags entity-miss, final_score gets an ADDITIONAL
# -20 penalty beyond the rerank_score reduction. Verify by
# comparing final_score for a demoted candidate vs an identical
# candidate that matched the entity.
off_topic = self._youtube_candidate("Managed Agents from Anthropic")
on_topic = self._youtube_candidate(
"Hermes Agent walkthrough",
transcript_snippet="Hermes Agent review",
)
rerank._apply_fallback_scores([off_topic, on_topic], primary_entity="Hermes Agent")
# Gap should be well above the rerank_score-only path's 0.60 * 25 = 15;
# with the secondary penalty it's 15 + 20 = 35 points.
gap = on_topic.final_score - off_topic.final_score
self.assertGreater(gap, 25.0,
f"entity-miss demotion gap only {gap:.1f}; secondary penalty may not be firing")
def test_secondary_penalty_not_applied_when_entity_match(self):
on_topic = self._youtube_candidate("Hermes Agent: use cases")
rerank._apply_fallback_scores([on_topic], primary_entity="Hermes Agent")
# Explanation does NOT contain entity-miss, so secondary penalty
# should not fire; final_score reflects only base signal.
self.assertNotIn("entity-miss", on_topic.explanation or "")
if __name__ == "__main__":
unittest.main()
+4 -179
View File
@@ -1,14 +1,11 @@
import io
import sys
import unittest
from contextlib import redirect_stderr
from pathlib import Path
from unittest.mock import patch
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "scripts"))
from lib import resolve
from lib.resolve import MAX_SUBS, _merge_category_peers
class TestHasBackend(unittest.TestCase):
@@ -140,8 +137,8 @@ class TestAutoResolve(unittest.TestCase):
self.assertEqual(result["subreddits"], ["technology", "gadgets"])
self.assertEqual(result["x_handle"], "techco")
self.assertIn("breakthrough", result["context"])
self.assertEqual(result["searches_run"], 4)
self.assertEqual(mock_search.call_count, 4)
self.assertEqual(result["searches_run"], 3)
self.assertEqual(mock_search.call_count, 3)
@patch("lib.resolve.grounding.web_search")
def test_search_failure_graceful(self, mock_search):
@@ -170,180 +167,8 @@ class TestAutoResolve(unittest.TestCase):
self.assertEqual(result["subreddits"], ["cooking"])
# News search failed, so context is empty
self.assertEqual(result["context"], "")
# 3 out of 4 succeeded (subreddit, x_handle, github; news failed)
self.assertEqual(result["searches_run"], 3)
class MergeCategoryPeersHappyPath(unittest.TestCase):
def test_image_gen_topic_appends_peers(self):
merged, category = _merge_category_peers(
"Prompting GPT Image 2",
["OpenAI", "ChatGPT", "singularity"],
)
self.assertEqual(category, "ai_image_generation")
self.assertIn("OpenAI", merged)
self.assertIn("ChatGPT", merged)
self.assertIn("singularity", merged)
self.assertIn("StableDiffusion", merged)
self.assertIn("midjourney", merged)
self.assertIn("dalle2", merged)
def test_preserves_websearch_order_then_appends_peers(self):
merged, _ = _merge_category_peers(
"Prompting GPT Image 2",
["OpenAI", "ChatGPT"],
)
self.assertEqual(merged[0], "OpenAI")
self.assertEqual(merged[1], "ChatGPT")
self.assertEqual(merged[2], "StableDiffusion")
def test_emits_stderr_log_when_peers_added(self):
buf = io.StringIO()
with redirect_stderr(buf):
_merge_category_peers(
"Prompting GPT Image 2",
["OpenAI", "ChatGPT"],
)
output = buf.getvalue()
self.assertIn("Matched category=ai_image_generation", output)
self.assertIn("StableDiffusion", output)
class MergeCategoryPeersDedupe(unittest.TestCase):
def test_peer_already_in_websearch_not_duplicated(self):
merged, _ = _merge_category_peers(
"midjourney v7 prompts",
["midjourney", "aiArt"],
)
self.assertEqual(
sum(1 for s in merged if s.lower() == "midjourney"),
1,
)
def test_dedupe_is_case_insensitive(self):
merged, _ = _merge_category_peers(
"Prompting GPT Image 2",
["STABLEDIFFUSION"],
)
lower = [s.lower() for s in merged]
self.assertEqual(lower.count("stablediffusion"), 1)
def test_no_log_when_all_peers_already_present(self):
buf = io.StringIO()
with redirect_stderr(buf):
_merge_category_peers(
"Prompting GPT Image 2",
[
"StableDiffusion",
"midjourney",
"dalle2",
"aiArt",
"PromptEngineering",
"MediaSynthesis",
],
)
self.assertNotIn("Matched category=", buf.getvalue())
class MergeCategoryPeersEdgeCases(unittest.TestCase):
def test_topic_with_no_category_returns_unchanged(self):
merged, category = _merge_category_peers(
"Kanye West",
["Kanye", "hiphopheads"],
)
self.assertIsNone(category)
self.assertEqual(merged, ["Kanye", "hiphopheads"])
def test_empty_subreddit_list_with_category_still_adds_peers(self):
merged, category = _merge_category_peers("Prompting GPT Image 2", [])
self.assertEqual(category, "ai_image_generation")
self.assertIn("StableDiffusion", merged)
def test_empty_topic_returns_unchanged(self):
merged, category = _merge_category_peers("", ["foo", "bar"])
self.assertIsNone(category)
self.assertEqual(merged, ["foo", "bar"])
def test_none_topic_returns_unchanged(self):
merged, category = _merge_category_peers(None, ["foo", "bar"])
self.assertIsNone(category)
self.assertEqual(merged, ["foo", "bar"])
def test_no_log_when_topic_has_no_category(self):
buf = io.StringIO()
with redirect_stderr(buf):
_merge_category_peers("Kanye West", ["Kanye"])
self.assertNotIn("Matched category=", buf.getvalue())
class MergeCategoryPeersCap(unittest.TestCase):
def test_cap_is_enforced_at_max_subs(self):
websearch_subs = [f"Sub{i}" for i in range(9)]
merged, _ = _merge_category_peers(
"Prompting GPT Image 2",
websearch_subs,
)
self.assertEqual(len(merged), MAX_SUBS)
for s in websearch_subs:
self.assertIn(s, merged)
self.assertEqual(len(merged) - len(websearch_subs), 1)
self.assertEqual(merged[9], "StableDiffusion")
def test_cap_preserves_highest_priority_peer_when_trimming(self):
websearch_subs = [f"Sub{i}" for i in range(8)]
merged, _ = _merge_category_peers(
"Prompting GPT Image 2",
websearch_subs,
)
self.assertEqual(len(merged), MAX_SUBS)
self.assertEqual(merged[8], "StableDiffusion")
self.assertEqual(merged[9], "midjourney")
class MergeCategoryPeersClassificationFailure(unittest.TestCase):
def test_classification_error_returns_unwidened_list_and_logs(self):
original = resolve.categories.detect_category
def boom(_topic):
raise RuntimeError("synthetic classifier failure")
resolve.categories.detect_category = boom
try:
buf = io.StringIO()
with redirect_stderr(buf):
merged, category = _merge_category_peers(
"Prompting GPT Image 2",
["OpenAI"],
)
self.assertEqual(merged, ["OpenAI"])
self.assertIsNone(category)
self.assertIn("Category classification failed", buf.getvalue())
finally:
resolve.categories.detect_category = original
class AutoResolveCategoryIntegration(unittest.TestCase):
@patch("lib.resolve.grounding.web_search")
def test_auto_resolve_returns_category_key(self, mock_search):
def side_effect(query, date_range, config):
if "subreddit" in query:
return [
{"title": "r/OpenAI", "snippet": "r/ChatGPT r/singularity", "url": ""},
], {}
return [], {}
mock_search.side_effect = side_effect
result = resolve.auto_resolve(
"Prompting GPT Image 2",
{"BRAVE_API_KEY": "fake"},
)
self.assertEqual(result["category"], "ai_image_generation")
self.assertIn("StableDiffusion", result["subreddits"])
self.assertIn("OpenAI", result["subreddits"])
def test_no_backend_returns_category_none(self):
result = resolve.auto_resolve("test topic", {})
self.assertIsNone(result["category"])
# 2 out of 3 succeeded
self.assertEqual(result["searches_run"], 2)
if __name__ == "__main__":
-84
View File
@@ -1,84 +0,0 @@
# ruff: noqa: E402
"""Tests for per-entity save files when running vs-mode or --competitors.
Each entity's sub-run produces its own {entity-slug}-raw.md. Single-entity
runs unchanged.
"""
from __future__ import annotations
import os
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(REPO_ROOT / "scripts"))
def _engine_path() -> Path:
return REPO_ROOT / "scripts" / "last30days.py"
class PerEntitySaveFilesTests(unittest.TestCase):
def _run(self, *argv: str, topic: str) -> tuple[subprocess.CompletedProcess, Path]:
save_dir = Path(tempfile.mkdtemp(prefix="last30days-test-"))
cmd = [
sys.executable,
str(_engine_path()),
topic,
"--mock",
"--emit=md",
"--save-dir", str(save_dir),
*argv,
]
env = {**os.environ, "LAST30DAYS_SKIP_PREFLIGHT": "1"}
result = subprocess.run(cmd, capture_output=True, text=True, env=env)
return result, save_dir
def test_vs_mode_produces_per_entity_files(self):
result, save_dir = self._run(topic="Kanye West vs Drake vs Kendrick Lamar")
self.assertEqual(result.returncode, 0, msg=result.stderr)
files = sorted(save_dir.glob("*-raw.md"))
names = [f.name for f in files]
# Each entity slug should produce a file
self.assertIn("kanye-west-raw.md", names)
self.assertIn("drake-raw.md", names)
self.assertIn("kendrick-lamar-raw.md", names)
def test_competitors_list_produces_per_entity_files(self):
result, save_dir = self._run(
"--competitors-list", "Anthropic,xAI",
topic="OpenAI",
)
self.assertEqual(result.returncode, 0, msg=result.stderr)
files = sorted(save_dir.glob("*-raw.md"))
names = [f.name for f in files]
self.assertIn("openai-raw.md", names)
self.assertIn("anthropic-raw.md", names)
self.assertIn("xai-raw.md", names)
def test_single_entity_run_produces_one_file(self):
result, save_dir = self._run(topic="OpenAI")
self.assertEqual(result.returncode, 0, msg=result.stderr)
files = sorted(save_dir.glob("*-raw.md"))
self.assertEqual(len(files), 1)
self.assertEqual(files[0].name, "openai-raw.md")
def test_per_entity_file_has_resolved_block(self):
result, save_dir = self._run(
"--competitors-list", "Anthropic",
topic="OpenAI",
)
self.assertEqual(result.returncode, 0, msg=result.stderr)
anthropic_file = save_dir / "anthropic-raw.md"
self.assertTrue(anthropic_file.exists())
content = anthropic_file.read_text()
self.assertIn("## Resolved Entities", content)
self.assertIn("**Anthropic**", content)
if __name__ == "__main__":
unittest.main()
+9 -102
View File
@@ -28,98 +28,6 @@ class SignalsV3Tests(unittest.TestCase):
)
self.assertAlmostEqual(expected, signals.engagement_raw(item))
def test_youtube_engagement_adds_top_comment_slot(self):
with_comment = schema.SourceItem(
item_id="yt1",
source="youtube",
title="Title",
body="Body",
url="https://youtube.com/watch?v=a",
engagement={"views": 10000, "likes": 500, "comments": 30},
metadata={"top_comments": [{"score": 500}]},
)
without = schema.SourceItem(
item_id="yt2",
source="youtube",
title="Title",
body="Body",
url="https://youtube.com/watch?v=b",
engagement={"views": 10000, "likes": 500, "comments": 30},
metadata={"top_comments": []},
)
with_score = signals.engagement_raw(with_comment)
without_score = signals.engagement_raw(without)
self.assertIsNotNone(with_score)
self.assertIsNotNone(without_score)
self.assertGreater(with_score, without_score)
expected = (
0.45 * math.log1p(10000)
+ 0.32 * math.log1p(500)
+ 0.13 * math.log1p(30)
+ 0.10 * math.log1p(500)
)
self.assertAlmostEqual(expected, with_score, places=6)
def test_youtube_engagement_empty_returns_none(self):
item = schema.SourceItem(
item_id="yt-empty",
source="youtube",
title="Title",
body="Body",
url="https://youtube.com/watch?v=e",
engagement={},
metadata={"top_comments": []},
)
self.assertIsNone(signals.engagement_raw(item))
def test_tiktok_engagement_adds_top_comment_slot(self):
item = schema.SourceItem(
item_id="tt1",
source="tiktok",
title="Title",
body="Body",
url="https://tiktok.com/@u/video/1",
engagement={"views": 100000, "likes": 5000, "comments": 500},
metadata={"top_comments": [{"score": 1200}]},
)
expected = (
0.45 * math.log1p(100000)
+ 0.27 * math.log1p(5000)
+ 0.18 * math.log1p(500)
+ 0.10 * math.log1p(1200)
)
self.assertAlmostEqual(expected, signals.engagement_raw(item), places=6)
def test_youtube_ranking_promotes_viral_comment_thread(self):
"""A moderately-viewed YouTube video with a 10k-like comment should
outrank a slightly-higher-viewed video with no high-signal comments."""
viral_comment = schema.SourceItem(
item_id="yt-with-viral-comment",
source="youtube",
title="Deploy to Fly.io",
body="Deploy to Fly.io walkthrough",
url="https://youtube.com/watch?v=x",
published_at="2026-03-15",
engagement={"views": 5000, "likes": 200, "comments": 50},
metadata={"top_comments": [{"score": 10000}]},
)
higher_views = schema.SourceItem(
item_id="yt-higher-views-no-comment",
source="youtube",
title="Deploy to Fly.io",
body="Deploy to Fly.io walkthrough",
url="https://youtube.com/watch?v=y",
published_at="2026-03-15",
engagement={"views": 8000, "likes": 300, "comments": 60},
metadata={"top_comments": []},
)
ranked = signals.annotate_stream(
[higher_views, viral_comment],
ranking_query="How do I deploy on Fly.io?",
freshness_mode="balanced_recent",
)
self.assertEqual("yt-with-viral-comment", ranked[0].item_id)
def test_polymarket_engagement_uses_market_fields(self):
item = schema.SourceItem(
item_id="pm1",
@@ -313,8 +221,7 @@ class SignalsV3Tests(unittest.TestCase):
self.assertAlmostEqual(expected, result)
def test_youtube_engagement_dominant_weight(self):
"""YouTube: views at 0.45 should dominate. With no top-comment data,
the remaining 0.90 of weight is split views/likes/comments 0.45/0.32/0.13."""
"""YouTube: views at 0.50 should dominate over comments at 0.15."""
item = schema.SourceItem(
item_id="yt1", source="youtube", title="T", body="B",
url="https://example.com",
@@ -323,9 +230,9 @@ class SignalsV3Tests(unittest.TestCase):
result = signals.engagement_raw(item)
self.assertIsNotNone(result)
expected = (
0.45 * math.log1p(10000)
+ 0.32 * math.log1p(500)
+ 0.13 * math.log1p(80)
0.50 * math.log1p(10000)
+ 0.35 * math.log1p(500)
+ 0.15 * math.log1p(80)
)
self.assertAlmostEqual(expected, result)
@@ -345,7 +252,7 @@ class SignalsV3Tests(unittest.TestCase):
)
result = signals.engagement_raw(item)
self.assertIsNotNone(result)
expected = 0.45 * math.log1p(5000)
expected = 0.50 * math.log1p(5000)
self.assertAlmostEqual(expected, result)
def test_tiktok_engagement_dominant_weight(self):
@@ -357,9 +264,9 @@ class SignalsV3Tests(unittest.TestCase):
result = signals.engagement_raw(item)
self.assertIsNotNone(result)
expected = (
0.45 * math.log1p(50000)
+ 0.27 * math.log1p(3000)
+ 0.18 * math.log1p(200)
0.50 * math.log1p(50000)
+ 0.30 * math.log1p(3000)
+ 0.20 * math.log1p(200)
)
self.assertAlmostEqual(expected, result)
@@ -379,7 +286,7 @@ class SignalsV3Tests(unittest.TestCase):
)
result = signals.engagement_raw(item)
self.assertIsNotNone(result)
expected = 0.27 * math.log1p(1000)
expected = 0.30 * math.log1p(1000)
self.assertAlmostEqual(expected, result)
def test_instagram_engagement_dominant_weight(self):
-145
View File
@@ -105,150 +105,5 @@ class TestExpandTikTokQueries(unittest.TestCase):
self.assertEqual(len(queries), 1)
class TestTikTokCommentsGate(unittest.TestCase):
def test_gate_requires_key_and_token(self):
from lib import env
self.assertFalse(env.is_tiktok_comments_available({}))
self.assertFalse(env.is_tiktok_comments_available(
{"SCRAPECREATORS_API_KEY": "k"}
))
self.assertFalse(env.is_tiktok_comments_available(
{"INCLUDE_SOURCES": "tiktok_comments"}
))
self.assertTrue(env.is_tiktok_comments_available(
{"SCRAPECREATORS_API_KEY": "k", "INCLUDE_SOURCES": "tiktok,tiktok_comments"}
))
def test_gate_case_matches_youtube_pattern(self):
from lib import env
# Matches the existing youtube_comments behaviour — plain substring match via _parse_include_sources.
self.assertTrue(env.is_tiktok_comments_available(
{"SCRAPECREATORS_API_KEY": "k", "INCLUDE_SOURCES": "TIKTOK,TIKTOK_COMMENTS"}
))
class TestTikTokEnrichWithComments(unittest.TestCase):
def test_empty_items_returns_empty(self):
from lib import tiktok
self.assertEqual([], tiktok.enrich_with_comments([], token="k"))
def test_missing_token_is_noop(self):
from lib import tiktok
items = [{"video_id": "1", "url": "https://www.tiktok.com/@u/video/1", "engagement": {"views": 100}}]
result = tiktok.enrich_with_comments(items, token="")
self.assertNotIn("top_comments", result[0])
def test_fetch_post_comments_parses_sc_response(self):
from unittest.mock import patch
from lib import tiktok
fake_sc_response = {
"comments": [
{"text": "loved it", "user": {"nickname": "Alice"},
"digg_count": 420, "create_time": 1709251200},
{"text": "meh", "user": {"nickname": "Bob"},
"digg_count": 3, "create_time": 1709251300},
{"text": "", "user": {"nickname": "Skip"},
"digg_count": 999, "create_time": 1709251400},
],
"total": 3,
}
class FakeResp:
def raise_for_status(self):
pass
def json(self):
return fake_sc_response
with patch.object(tiktok, "_requests") as mock_req:
mock_req.get.return_value = FakeResp()
out = tiktok._fetch_post_comments(
"https://www.tiktok.com/@u/video/1",
token="k",
max_comments=5,
)
# Empty-text comment dropped; rest sorted desc by digg_count.
self.assertEqual(2, len(out))
self.assertEqual("loved it", out[0]["text"])
self.assertEqual(420, out[0]["digg_count"])
self.assertEqual("Alice", out[0]["author"])
self.assertEqual("2024-03-01", out[0]["date"])
self.assertEqual(3, out[1]["digg_count"])
def test_fetch_post_comments_prefers_unique_id_over_nickname(self):
"""Author prefers unique_id (@handle) over nickname (display name)."""
from unittest.mock import patch
from lib import tiktok
fake_sc_response = {
"comments": [
{"text": "first", "user": {"unique_id": "moosanoormahomed", "nickname": "Moosa Noormahomed"},
"digg_count": 3986, "create_time": 1709251200},
{"text": "second", "user": {"nickname": "Muna9e"}, # no unique_id, falls back to nickname
"digg_count": 925, "create_time": 1709251300},
{"text": "third", "user": {}, # neither - empty string
"digg_count": 100, "create_time": 1709251400},
],
"total": 3,
}
class FakeResp:
def raise_for_status(self):
pass
def json(self):
return fake_sc_response
with patch.object(tiktok, "_requests") as mock_req:
mock_req.get.return_value = FakeResp()
out = tiktok._fetch_post_comments(
"https://www.tiktok.com/@u/video/1",
token="k",
max_comments=5,
)
self.assertEqual(3, len(out))
# unique_id wins over nickname when both present
self.assertEqual("moosanoormahomed", out[0]["author"])
# nickname used when unique_id missing
self.assertEqual("Muna9e", out[1]["author"])
# both missing → empty string, comment still included
self.assertEqual("", out[2]["author"])
def test_fetch_post_comments_swallows_http_error(self):
from unittest.mock import patch
from lib import tiktok
with patch.object(tiktok, "_requests") as mock_req:
mock_req.get.side_effect = Exception("429 rate limit")
out = tiktok._fetch_post_comments(
"https://www.tiktok.com/@u/video/1",
token="k",
max_comments=5,
)
self.assertEqual([], out)
def test_enrich_attaches_top_comments_to_top_ranked_items(self):
from unittest.mock import patch
from lib import tiktok
items = [
{"video_id": "low", "url": "https://www.tiktok.com/@u/video/low",
"engagement": {"views": 10, "likes": 1, "comments": 0}},
{"video_id": "high", "url": "https://www.tiktok.com/@u/video/high",
"engagement": {"views": 10000, "likes": 500, "comments": 30}},
{"video_id": "mid", "url": "https://www.tiktok.com/@u/video/mid",
"engagement": {"views": 1000, "likes": 50, "comments": 5}},
]
with patch.object(tiktok, "_fetch_post_comments") as mock_fetch:
mock_fetch.return_value = [
{"author": "A", "text": "fire", "digg_count": 100, "date": "2024-03-01"}
]
tiktok.enrich_with_comments(items, token="k", max_posts=2)
# High and mid get comments; low does not.
by_id = {i["video_id"]: i for i in items}
self.assertIn("top_comments", by_id["high"])
self.assertIn("top_comments", by_id["mid"])
self.assertNotIn("top_comments", by_id["low"])
if __name__ == "__main__":
unittest.main()
-70
View File
@@ -1,70 +0,0 @@
import re
import unittest
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
def _skill_version() -> str:
text = (ROOT / "SKILL.md").read_text(encoding="utf-8")
match = re.search(r'^version:\s*"([^"]+)"\s*$', text, re.MULTILINE)
if not match:
raise AssertionError("SKILL.md version frontmatter not found")
return match.group(1)
class TestVersionConsistency(unittest.TestCase):
def test_root_skill_header_matches_frontmatter_version(self) -> None:
text = (ROOT / "SKILL.md").read_text(encoding="utf-8")
version = _skill_version()
self.assertIn(f"# last30days v{version}:", text)
def test_sync_cache_path_uses_skill_version(self) -> None:
sync_text = (ROOT / "scripts" / "sync.sh").read_text(encoding="utf-8")
version = _skill_version()
self.assertIn(f'last30days-3/{version}"', sync_text)
def test_memory_save_dir_uses_single_env_variable(self) -> None:
skill_text = (ROOT / "SKILL.md").read_text(encoding="utf-8")
compare_text = (ROOT / "scripts" / "compare.sh").read_text(encoding="utf-8")
default_assignment = 'LAST30DAYS_MEMORY_DIR="${LAST30DAYS_MEMORY_DIR:-$HOME/Documents/Last30Days}"'
self.assertIn(default_assignment, skill_text)
self.assertIn(default_assignment, compare_text)
self.assertNotIn("--save-dir=~/Documents/Last30Days", skill_text)
self.assertIn('--save-dir="${LAST30DAYS_MEMORY_DIR}"', skill_text)
def test_no_stray_hardcoded_memory_dir_paths(self) -> None:
allowed_suffixes = {".md", ".py", ".sh", ".txt", ".yml", ".yaml", ".json"}
skip_dirs = {".git", "assets", "fixtures", "docs"}
offenders = []
for path in ROOT.rglob("*"):
if not path.is_file() or path.suffix not in allowed_suffixes:
continue
if skip_dirs.intersection(path.relative_to(ROOT).parts):
continue
if path.relative_to(ROOT) == Path("tests/test_version_consistency.py"):
continue
try:
lines = path.read_text(encoding="utf-8").splitlines()
except UnicodeDecodeError:
continue
for line_number, line in enumerate(lines, start=1):
if "~/Documents/Last30Days" not in line and "$HOME/Documents/Last30Days" not in line:
continue
allowed_default = (
"LAST30DAYS_MEMORY_DIR" in line
and ("defaults to" in line or "${LAST30DAYS_MEMORY_DIR:-$HOME/Documents/Last30Days}" in line)
)
if not allowed_default:
offenders.append(f"{path.relative_to(ROOT)}:{line_number}: {line.strip()}")
self.assertEqual([], offenders)
if __name__ == "__main__":
unittest.main()
-63
View File
@@ -1,63 +0,0 @@
# ruff: noqa: E402
"""Tests for vs-mode routing into the competitor fanout.
A topic containing " vs " / " versus " triggers N-pass fanout (not the
old single-pipeline comparison plan). Each entity gets its own full
pipeline.run() with its own Step 0.55 targeting.
"""
from __future__ import annotations
import io
import sys
import unittest
from contextlib import redirect_stderr
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(REPO_ROOT / "scripts"))
from lib import planner
class VsModeEntityDetectionTests(unittest.TestCase):
"""The planner's _comparison_entities helper is the detector we use."""
def test_two_entity_vs(self):
self.assertEqual(
planner._comparison_entities("OpenAI vs Anthropic"),
["OpenAI", "Anthropic"],
)
def test_three_entity_vs(self):
self.assertEqual(
planner._comparison_entities("Kanye West vs Drake vs Kendrick Lamar"),
["Kanye West", "Drake", "Kendrick Lamar"],
)
def test_versus_alt_spelling(self):
result = planner._comparison_entities("A versus B")
self.assertEqual(result, ["A", "B"])
def test_dotted_vs(self):
result = planner._comparison_entities("A vs. B")
self.assertEqual(result, ["A", "B"])
def test_no_vs_returns_empty(self):
self.assertEqual(planner._comparison_entities("OpenAI"), [])
def test_trailing_vs_returns_empty_or_single(self):
# "OpenAI vs" with nothing after — should not trigger vs-mode
result = planner._comparison_entities("OpenAI vs")
# _comparison_entities caps at _max_subqueries("comparison") and
# requires >=2 parts. Single "OpenAI" with empty after vs -> []
self.assertLess(len(result), 2)
def test_dedup_identical_entities(self):
# Defense against silly input — two "Drake"s should collapse.
result = planner._comparison_entities("Drake vs Drake")
self.assertEqual(result, ["Drake"])
if __name__ == "__main__":
unittest.main()
-254
View File
@@ -1,254 +0,0 @@
"""Tests for xurl_x module."""
import json
import sys
import unittest
from pathlib import Path
from unittest import mock
sys.path.insert(0, str(Path(__file__).parent.parent / "scripts"))
from lib import xurl_x
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_api_response(tweets=None, users=None):
"""Build a minimal X API v2 search/recent response."""
tweets = tweets or []
users = users or []
resp = {"data": tweets}
if users:
resp["includes"] = {"users": users}
return resp
# ---------------------------------------------------------------------------
# is_available
# ---------------------------------------------------------------------------
class TestIsAvailable(unittest.TestCase):
def test_returns_true_when_xurl_authenticated(self):
completed = mock.Mock(returncode=0, stdout='{"username": "testuser"}')
with mock.patch("subprocess.run", return_value=completed):
self.assertTrue(xurl_x.is_available())
def test_returns_false_when_not_authenticated(self):
completed = mock.Mock(returncode=1, stdout="")
with mock.patch("subprocess.run", return_value=completed):
self.assertFalse(xurl_x.is_available())
def test_returns_false_when_not_installed(self):
with mock.patch("subprocess.run", side_effect=FileNotFoundError):
self.assertFalse(xurl_x.is_available())
def test_returns_false_on_timeout(self):
import subprocess
with mock.patch("subprocess.run", side_effect=subprocess.TimeoutExpired("xurl", 10)):
self.assertFalse(xurl_x.is_available())
def test_returns_false_when_no_username_in_output(self):
# returncode=0 but output does not contain '"username"'
completed = mock.Mock(returncode=0, stdout='{"id": "123"}')
with mock.patch("subprocess.run", return_value=completed):
self.assertFalse(xurl_x.is_available())
# ---------------------------------------------------------------------------
# search_x
# ---------------------------------------------------------------------------
class TestSearchX(unittest.TestCase):
def test_returns_parsed_json_on_success(self):
payload = {"data": [{"id": "1", "text": "hello world", "author_id": "u1"}]}
completed = mock.Mock(returncode=0, stdout=json.dumps(payload))
with mock.patch("subprocess.run", return_value=completed):
result = xurl_x.search_x("hello world")
self.assertEqual(result["data"][0]["id"], "1")
def test_returns_error_on_non_zero_exit(self):
completed = mock.Mock(returncode=1, stdout="", stderr="rate limit exceeded")
with mock.patch("subprocess.run", return_value=completed):
result = xurl_x.search_x("test")
self.assertIn("error", result)
self.assertIn("rate limit exceeded", result["error"])
def test_returns_error_on_invalid_json(self):
completed = mock.Mock(returncode=0, stdout="NOT JSON")
with mock.patch("subprocess.run", return_value=completed):
result = xurl_x.search_x("test")
self.assertIn("error", result)
self.assertIn("Invalid JSON", result["error"])
def test_returns_error_when_not_installed(self):
with mock.patch("subprocess.run", side_effect=FileNotFoundError):
result = xurl_x.search_x("test")
self.assertIn("error", result)
self.assertIn("not found", result["error"])
def test_returns_error_on_timeout(self):
import subprocess
with mock.patch("subprocess.run", side_effect=subprocess.TimeoutExpired("xurl", 30)):
result = xurl_x.search_x("test")
self.assertIn("error", result)
self.assertIn("timed out", result["error"])
def test_max_results_clamped_to_100(self):
# DEPTH_CONFIG["deep"] = 60, should stay at 60 (within 10-100 range)
completed = mock.Mock(returncode=0, stdout=json.dumps({}))
with mock.patch("subprocess.run", return_value=completed) as run_mock:
xurl_x.search_x("test", depth="deep")
call_args = run_mock.call_args[0][0]
n_idx = call_args.index("-n")
self.assertLessEqual(int(call_args[n_idx + 1]), 100)
def test_max_results_at_least_10(self):
completed = mock.Mock(returncode=0, stdout=json.dumps({}))
with mock.patch("subprocess.run", return_value=completed) as run_mock:
xurl_x.search_x("test", depth="quick")
call_args = run_mock.call_args[0][0]
n_idx = call_args.index("-n")
self.assertGreaterEqual(int(call_args[n_idx + 1]), 10)
def test_unknown_depth_falls_back_to_default(self):
completed = mock.Mock(returncode=0, stdout=json.dumps({}))
with mock.patch("subprocess.run", return_value=completed) as run_mock:
xurl_x.search_x("test", depth="nonexistent")
call_args = run_mock.call_args[0][0]
n_idx = call_args.index("-n")
self.assertEqual(int(call_args[n_idx + 1]), xurl_x.DEPTH_CONFIG["default"])
# ---------------------------------------------------------------------------
# parse_x_response
# ---------------------------------------------------------------------------
class TestParseXResponse(unittest.TestCase):
def _tweet(self, id_, text, author_id, created_at=None, metrics=None):
t = {"id": id_, "text": text, "author_id": author_id}
if created_at:
t["created_at"] = created_at
if metrics:
t["public_metrics"] = metrics
return t
def _user(self, id_, username):
return {"id": id_, "username": username}
def test_empty_response_returns_empty_list(self):
self.assertEqual(xurl_x.parse_x_response({}), [])
def test_error_response_returns_empty_list(self):
self.assertEqual(xurl_x.parse_x_response({"error": "oops"}), [])
def test_parses_basic_tweet(self):
resp = _make_api_response(
tweets=[self._tweet("111", "Hello AI", "u1")],
users=[self._user("u1", "alice")],
)
items = xurl_x.parse_x_response(resp)
self.assertEqual(len(items), 1)
self.assertEqual(items[0]["text"], "Hello AI")
self.assertEqual(items[0]["author_handle"], "alice")
self.assertIn("alice", items[0]["url"])
self.assertIn("111", items[0]["url"])
def test_parses_date_from_iso(self):
resp = _make_api_response(
tweets=[self._tweet("1", "text", "u1", created_at="2024-06-15T12:00:00Z")],
)
items = xurl_x.parse_x_response(resp)
self.assertEqual(items[0]["date"], "2024-06-15")
def test_date_none_when_missing(self):
resp = _make_api_response(tweets=[self._tweet("1", "text", "u1")])
items = xurl_x.parse_x_response(resp)
self.assertIsNone(items[0]["date"])
def test_parses_engagement_metrics(self):
metrics = {
"like_count": 42,
"retweet_count": 10,
"reply_count": 5,
"quote_count": 2,
}
resp = _make_api_response(
tweets=[self._tweet("1", "text", "u1", metrics=metrics)],
)
items = xurl_x.parse_x_response(resp)
self.assertEqual(items[0]["engagement"]["likes"], 42)
self.assertEqual(items[0]["engagement"]["reposts"], 10)
self.assertEqual(items[0]["engagement"]["replies"], 5)
self.assertEqual(items[0]["engagement"]["quotes"], 2)
def test_engagement_none_when_no_metrics(self):
resp = _make_api_response(tweets=[self._tweet("1", "text", "u1")])
items = xurl_x.parse_x_response(resp)
self.assertIsNone(items[0]["engagement"])
def test_text_truncated_to_500_chars(self):
long_text = "x" * 600
resp = _make_api_response(tweets=[self._tweet("1", long_text, "u1")])
items = xurl_x.parse_x_response(resp)
self.assertLessEqual(len(items[0]["text"]), 500)
def test_id_prefixed_with_xurl(self):
resp = _make_api_response(tweets=[self._tweet("1", "text", "u1")])
items = xurl_x.parse_x_response(resp)
self.assertTrue(items[0]["id"].startswith("XURL"))
def test_relevance_computed_when_topic_given(self):
resp = _make_api_response(
tweets=[self._tweet("1", "Claude Code is great for AI coding", "u1")],
)
items = xurl_x.parse_x_response(resp, topic="Claude Code")
self.assertGreater(items[0]["relevance"], 0.5)
def test_relevance_neutral_when_no_topic(self):
resp = _make_api_response(tweets=[self._tweet("1", "some text", "u1")])
items = xurl_x.parse_x_response(resp)
self.assertEqual(items[0]["relevance"], 0.5)
def test_url_empty_when_no_username(self):
# author_id not in includes.users → username=""
resp = _make_api_response(tweets=[self._tweet("999", "text", "unknown_uid")])
items = xurl_x.parse_x_response(resp)
self.assertEqual(items[0]["url"], "")
def test_multiple_tweets_parsed(self):
tweets = [self._tweet(str(i), f"tweet {i}", "u1") for i in range(5)]
resp = _make_api_response(tweets=tweets, users=[self._user("u1", "bob")])
items = xurl_x.parse_x_response(resp)
self.assertEqual(len(items), 5)
def test_empty_data_list(self):
resp = _make_api_response(tweets=[])
self.assertEqual(xurl_x.parse_x_response(resp), [])
def test_why_relevant_is_empty_string(self):
# xurl doesn't provide LLM-generated why_relevant (unlike xai_x)
resp = _make_api_response(tweets=[self._tweet("1", "text", "u1")])
items = xurl_x.parse_x_response(resp)
self.assertEqual(items[0]["why_relevant"], "")
# ---------------------------------------------------------------------------
# DEPTH_CONFIG
# ---------------------------------------------------------------------------
class TestDepthConfig(unittest.TestCase):
def test_all_standard_depths_present(self):
for depth in ("quick", "default", "deep"):
self.assertIn(depth, xurl_x.DEPTH_CONFIG)
def test_deep_greater_than_quick(self):
self.assertGreater(
xurl_x.DEPTH_CONFIG["deep"],
xurl_x.DEPTH_CONFIG["quick"],
)
if __name__ == "__main__":
unittest.main()
+176
View File
@@ -0,0 +1,176 @@
# Changelog
## 0.8.0 — 2026-01-19
### Added
- `bookmarks` thread expansion controls (`--expand-root-only`, `--author-chain`, `--author-only`, `--full-chain-only`, `--include-ancestor-branches`, `--include-parent`, `--thread-meta`, `--sort-chronological`) for richer context exports (#55) — thanks @kkretschmer2.
- `--chrome-profile-dir` to point at Chromium profile directories or cookie DB files (Arc/Brave/etc) for cookie extraction (#16) — thanks @tekumara.
- `about` command to report account origin/location metadata (#51) — thanks @pjtf93.
- `follow`/`unfollow` commands to manage follows (#54) — thanks @citizenlee.
- Twitter client now supports like/unlike/retweet/unretweet/bookmark via the engagement mixin (#53) — thanks @the-vampiire.
### Fixed
- `bookmarks` expanded JSON now preserves pagination `nextCursor`, and full-chain filtering only includes ancestor branches when requested.
- Follow/unfollow REST fallback now supports cursor pagination for followers/following (#54).
- About account live coverage now verifies data extraction paths (#51) — thanks @pjtf93.
### Tests
- Live tests now exercise engagement mutations (opt-in) (#53) — thanks @the-vampiire.
## 0.7.0 — 2026-01-12
### Added
- `home` command for the "For You" and "Following" home timelines (#31) — thanks @odysseus0.
- `news`/`trending` command for Explore tabs with AI-curated headlines (#39) — thanks @aavetis.
- `user-tweets` command to fetch a user's profile timeline (#34) — thanks @crcatala.
- `replies` and `thread` now support pagination (`--all`, `--max-pages`, `--cursor`, `--delay`) (#35) — thanks @crcatala.
- `search` now supports pagination (`--all`, `--max-pages`, `--cursor`) (#42) — thanks @pjtf93.
- `likes` now supports pagination (`--all`, `--max-pages`, `--cursor`) (#44) — thanks @jsholmes.
- `list-timeline` now supports pagination (`--all`, `--max-pages`, `--cursor`) (#30) — thanks @zheli.
- Rich text output now shows article previews, quoted tweets, and media links (#32) — thanks @odysseus0.
- Long-form article tweets now render rich Draft.js content blocks/entities (#36) — thanks @crcatala.
### Changed
- Library typing: `SearchResult` is now a discriminated union (so `error` only exists when `success: false`).
### Fixed
- Lists GraphQL feature flags updated to prevent 400s (#27) — thanks @zheli.
- Lists feature overrides now scope new GraphQL flags correctly (#50) — thanks @ryanh-ai.
- Tweet detail parsing now tolerates partial GraphQL errors when usable data exists (#48) — thanks @jsholmes.
- News output now respects `--tweets-per-item`, keeps unique IDs, and parses non-add entry instructions (#39) — thanks @aavetis.
- Following/followers pagination now guards repeat cursors and standardizes JSON output (#28) — thanks @malpern.
- Likes pagination now follows cursors and avoids stalling on duplicate pages (#12) — thanks @titouv.
- macOS cookie extraction now supports Brave keychain storage (#40) — thanks @gakonst.
- Terminal hyperlinks now sanitize control characters before emitting OSC 8 sequences (#29) — thanks @mafulafunk.
- `pnpm run build:dist` now succeeds after tightening JSON/pagination option typing in tweet output commands.
### Tests
- Following: split following/likes tests + cover cursor handling (#33) — thanks @VACInc.
## 0.6.0 — 2026-01-05
### Added
- Bookmark exports now support pagination (`--all`, `--max-pages`) with retries (#15) — thanks @Nano1337.
- `lists` + `list-timeline` commands for Twitter Lists (#21) — thanks @harperreed
- Tweet JSON output now includes media items (photos, videos, GIFs) (#14) — thanks @Hormold
- Bookmarks can resume pagination from a cursor (#26) — thanks @leonho
- `unbookmark` command to remove bookmarked tweets (#22) — thanks @mbelinky.
### Changed
- Feature flags can be overridden at runtime via `features.json` (refreshable via `query-ids`).
### Fixed
- GraphQL feature flags now include `post_ctas_fetch_enabled` to avoid 400s (#38) — thanks @philipp-spiess.
## 0.5.1 — 2026-01-01
### Changed
- `bird --help` now includes explicit “Shortcuts” and “JSON Output” sections (documents `bird <tweet-id-or-url>` shorthand + `--json`).
- Release docs now include explicit npm publish verification steps.
### Fixed
- `pnpm bird --help` now works (dev script runs the CLI entrypoint, not the library entrypoint).
- `following`/`followers` now fall back to internal v1.1 REST endpoints when GraphQL returns `404`.
### Tests
- Add root help output regression test.
- Add opt-in live CLI test suite (real GraphQL calls; skipped by default; gated via `BIRD_LIVE=1`).
## 0.5.0 — 2026-01-01
### Added
- `likes` command to list your liked tweets (thanks @swairshah).
- Quoted tweet data in JSON output + `--quote-depth` (thanks @alexknowshtml).
- `following`/`followers` commands to list users (thanks @lockmeister).
### Changed
- Query ID updater now tracks the Likes GraphQL operation.
- Query ID updater now tracks Following/Followers GraphQL operations.
- Query ID updater now tracks BookmarkFolderTimeline and keeps bookmark query IDs seeded.
- `following`/`followers` JSON user fields are now camelCase (`followersCount`, `followingCount`, `isBlueVerified`, `profileImageUrl`, `createdAt`).
- Cookie extraction timeout is now configurable (default 30s on macOS) via `--cookie-timeout` / `BIRD_COOKIE_TIMEOUT_MS` (thanks @tylerseymour).
- Search now paginates beyond 20 results when using `-n` (thanks @ryanh-ai).
- Library exports are now separated from the CLI entrypoint for easier embedding.
## 0.4.1 — 2025-12-31
### Added
- `bookmarks` command to list your bookmarked tweets.
- `bookmarks --folder-id` to fetch bookmark folders (thanks @tylerseymour).
### Changed
- Cookie extraction now uses `@steipete/sweet-cookie` (drops `sqlite3` CLI + custom browser readers in `bird`).
- Query ID updater now tracks the Bookmarks GraphQL operation.
- Lint rules stricter (block statements, no-negation-else, useConst/useTemplate, top-level regex, import extension enforcement).
- `pnpm lint` now runs both Biome and oxlint (type-aware).
### Tests
- Coverage thresholds raised to 90% statements/lines/functions (80% branches).
- Added targeted Twitter client coverage suites.
## 0.4.0 — 2025-12-26
### Added
- Cookie source selection: `--cookie-source safari|chrome|firefox` (repeatable) + `cookieSource` config (string or array).
### Fixed
- `tweet`/`reply`: fallback to `statuses/update.json` when GraphQL `CreateTweet` returns error 226 (“automated request”).
### Breaking
- Remove `allowSafari`/`allowChrome`/`allowFirefox` config toggles in favor of `cookieSource` ordering.
## 0.3.0 — 2025-12-26
### Added
- Safari cookie extraction (`Cookies.binarycookies`) + `allowSafari` config toggle.
### Changed
- Removed the Sweetistics engine + fallback. `bird` is GraphQL-only.
- Browser cookie fallback order: Safari → Chrome → Firefox.
### Tests
- Enforce coverage thresholds (>= 70% statements/branches/functions/lines) + expand unit coverage for version/output/Twitter client branches.
## 0.2.0 — 2025-12-26
### Added
- Output controls: `--plain`, `--no-emoji`, `--no-color` (respects `NO_COLOR`).
- `help` command: `bird help <command>`.
- Runtime GraphQL query ID refresh: `bird query-ids --fresh` (cached on disk; auto-retry on 404; override cache via `BIRD_QUERY_IDS_CACHE`).
- GraphQL media uploads via `--media` (up to 4 images/GIFs, or 1 video).
### Fixed
- CLI `--version`: read version from `package.json`/`VERSION` (no hardcoded string) + append git sha when available.
### Changed
- `mentions`: no hardcoded user; defaults to authenticated user or accepts `--user @handle`.
- GraphQL query ID updater: correctly pairs `operationName``queryId` (CreateTweet/CreateRetweet/etc).
- `build:dist`: copies `src/lib/query-ids.json` into `dist/lib/query-ids.json` (keeps `dist/` in sync).
- `--engine graphql`: strict GraphQL-only (disables Sweetistics fallback).
## 0.1.1 — 2025-12-26
### Changed
- Engine default now `auto` (GraphQL primary; Sweetistics only on fallback when configured).
### Tests
- Add engine resolution tests for auto/default behavior.
### Fixed
- GraphQL read: rotate TweetDetail query IDs with fallback to avoid 404s.
## 0.1.0 — 2025-12-20
### Added
- CLI commands: `tweet`, `reply`, `read`, `replies`, `thread`, `search`, `mentions`, `whoami`, `check`.
- URL/ID shorthand for `read`, plus `--json` output where supported.
- GraphQL engine with cookie auth from Firefox/Chrome/env/flags (macOS browsers).
- Sweetistics engine (API key) with automatic fallback when configured.
- Media uploads via Sweetistics with per-item alt text (images or single video).
- Long-form Notes and Articles extraction for full text output.
- Thread + reply fetching with full conversation parsing.
- Search + mentions via GraphQL (latest timeline).
- JSON5 config files (`~/.config/bird/config.json5`, `./.birdrc.json5`) with engine defaults, profiles, allowChrome/allowFirefox, and timeoutMs.
- Request timeouts (`--timeout`, `timeoutMs`) for GraphQL and Sweetistics calls.
- Bun-compiled standalone binary via `pnpm run build`.
- Query ID refresh helper: `pnpm run graphql:update`.
+21
View File
@@ -0,0 +1,21 @@
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.
+385
View File
@@ -0,0 +1,385 @@
# bird 🐦 — fast X CLI for tweeting, replying, and reading
`bird` is a fast X CLI for tweeting, replying, and reading via X/Twitter GraphQL (cookie auth).
## Disclaimer
This project uses X/Twitters **undocumented** web GraphQL API (and cookie auth). X can change endpoints, query IDs,
and anti-bot behavior at any time — **expect this to break without notice**.
## Install
```bash
npm install -g @steipete/bird
# or
pnpm add -g @steipete/bird
# or
bun add -g @steipete/bird
# one-shot (no install)
bunx @steipete/bird whoami
```
Homebrew (macOS, prebuilt Bun binary):
```bash
brew install steipete/tap/bird
```
## Quickstart
```bash
# Show the logged-in account
bird whoami
# Discover command help
bird help whoami
# Read a tweet (URL or ID)
bird read https://x.com/user/status/1234567890123456789
bird 1234567890123456789 --json
# Thread + replies
bird thread https://x.com/user/status/1234567890123456789
bird replies 1234567890123456789
bird replies 1234567890123456789 --max-pages 3 --json
bird thread 1234567890123456789 --max-pages 3 --json
# Search + mentions
bird search "from:steipete" -n 5
bird mentions -n 5
bird mentions --user @steipete -n 5
# User tweets (profile timeline)
bird user-tweets @steipete -n 20
bird user-tweets @steipete -n 50 --json
# Bookmarks
bird bookmarks -n 5
bird bookmarks --folder-id 123456789123456789 -n 5 # https://x.com/i/bookmarks/<folder-id>
bird bookmarks --all --json
bird bookmarks --all --max-pages 2 --json
bird bookmarks --include-parent --json
bird unbookmark 1234567890123456789
bird unbookmark https://x.com/user/status/1234567890123456789
# Likes
bird likes -n 5
# News and trending topics (AI-curated from Explore tabs)
bird news --ai-only -n 10
bird news --sports -n 5
# Lists
bird list-timeline 1234567890 -n 20
bird list-timeline https://x.com/i/lists/1234567890 --all --json
bird list-timeline 1234567890 --max-pages 3 --json
# Following (who you follow)
bird following -n 20
bird following --user 12345678 -n 10 # by user ID
# Followers (who follows you)
bird followers -n 20
bird followers --user 12345678 -n 10 # by user ID
# Refresh GraphQL query IDs cache (no rebuild)
bird query-ids --fresh
```
## News & Trending
Fetch AI-curated news and trending topics from X's Explore page tabs:
```bash
# Fetch 10 news items from all tabs (default: For You, News, Sports, Entertainment)
bird news -n 10
# Fetch only AI-curated news (filters out regular trends)
bird news --ai-only -n 20
# Fetch from specific tabs
bird news --news-only --ai-only -n 10
bird news --sports -n 15
bird news --entertainment --ai-only -n 5
# Include related tweets for each news item
bird news --with-tweets --tweets-per-item 3 -n 10
# Combine multiple tab filters
bird news --sports --entertainment -n 20
# JSON output
bird news --json -n 5
bird news --json-full --ai-only -n 10 # includes raw API response
```
Tab options (can be combined):
- `--for-you` — Fetch from For You tab only
- `--news-only` — Fetch from News tab only
- `--sports` — Fetch from Sports tab only
- `--entertainment` — Fetch from Entertainment tab only
- `--trending-only` — Fetch from Trending tab only
By default, the command fetches from For You, News, Sports, and Entertainment tabs (Trending excluded to reduce noise). Headlines are automatically deduplicated across tabs.
## Library
`bird` can be used as a library (same GraphQL client as the CLI):
```ts
import { TwitterClient, resolveCredentials } from '@steipete/bird';
const { cookies } = await resolveCredentials({ cookieSource: 'safari' });
const client = new TwitterClient({ cookies });
// Search for tweets
const searchResult = await client.search('from:steipete', 50);
// Fetch news and trending topics from all tabs (default: For You, News, Sports, Entertainment)
const newsResult = await client.getNews(10, { aiOnly: true });
// Fetch from specific tabs with related tweets
const sportsNews = await client.getNews(10, {
aiOnly: true,
withTweets: true,
tabs: ['sports', 'entertainment']
});
```
Account details (About profile):
```ts
const aboutResult = await client.getUserAboutAccount('steipete');
if (aboutResult.success && aboutResult.aboutProfile) {
console.log(aboutResult.aboutProfile.accountBasedIn);
}
```
Fields:
- `accountBasedIn`
- `source`
- `createdCountryAccurate`
- `locationAccurate`
- `learnMoreUrl`
## Commands
- `bird tweet "<text>"` — post a new tweet.
- `bird reply <tweet-id-or-url> "<text>"` — reply to a tweet using its ID or URL.
- `bird help [command]` — show help (or help for a subcommand).
- `bird query-ids [--fresh] [--json]` — inspect or refresh cached GraphQL query IDs.
- `bird home [-n count] [--following] [--json] [--json-full]` — fetch your home timeline (For You) or Following feed.
- `bird read <tweet-id-or-url> [--json]` — fetch tweet content as text or JSON.
- `bird <tweet-id-or-url> [--json]` — shorthand for `read` when only a URL or ID is provided.
- `bird replies <tweet-id-or-url> [--all] [--max-pages n] [--cursor string] [--delay ms] [--json]` — list replies to a tweet.
- `bird thread <tweet-id-or-url> [--all] [--max-pages n] [--cursor string] [--delay ms] [--json]` — show the full conversation thread.
- `bird search "<query>" [-n count] [--all] [--max-pages n] [--cursor string] [--json]` — search for tweets matching a query; `--max-pages` requires `--all` or `--cursor`.
- `bird mentions [-n count] [--user @handle] [--json]` — find tweets mentioning a user (defaults to the authenticated user).
- `bird user-tweets <@handle> [-n count] [--cursor string] [--max-pages n] [--delay ms] [--json]` — get tweets from a user's profile timeline.
- `bird bookmarks [-n count] [--folder-id id] [--all] [--max-pages n] [--cursor string] [--expand-root-only] [--author-chain] [--author-only] [--full-chain-only] [--include-ancestor-branches] [--include-parent] [--thread-meta] [--sort-chronological] [--json]` — list your bookmarked tweets (or a specific bookmark folder); expansion flags control thread context; `--max-pages` requires `--all` or `--cursor`.
- `bird unbookmark <tweet-id-or-url...>` — remove one or more bookmarks by tweet ID or URL.
- `bird likes [-n count] [--all] [--max-pages n] [--cursor string] [--json] [--json-full]` — list your liked tweets; `--max-pages` requires `--all` or `--cursor`.
- `bird news [-n count] [--ai-only] [--with-tweets] [--tweets-per-item n] [--for-you] [--news-only] [--sports] [--entertainment] [--trending-only] [--json]` — fetch news and trending topics from X's Explore tabs.
- `bird trending` — alias for `news` command.
- `bird lists [--member-of] [-n count] [--json]` — list your lists (owned or memberships).
- `bird list-timeline <list-id-or-url> [-n count] [--all] [--max-pages n] [--cursor string] [--json]` — get tweets from a list timeline; `--max-pages` implies `--all`.
- `bird following [--user <userId>] [-n count] [--cursor string] [--all] [--max-pages n] [--json]` — list users that you (or another user) follow; `--max-pages` requires `--all`.
- `bird followers [--user <userId>] [-n count] [--cursor string] [--all] [--max-pages n] [--json]` — list users that follow you (or another user); `--max-pages` requires `--all`.
- `bird about <@handle> [--json]` — get account origin and location information for a user.
- `bird whoami` — print which Twitter account your cookies belong to.
- `bird check` — show which credentials are available and where they were sourced from.
Bookmarks flags:
- `--expand-root-only`: expand threads only when the bookmark is a root tweet.
- `--author-chain`: keep only the bookmarked author's connected self-reply chain.
- `--author-only`: include all tweets from the bookmarked author within the thread.
- `--full-chain-only`: keep the entire reply chain connected to the bookmarked tweet (all authors).
- `--include-ancestor-branches`: include sibling branches for ancestors when using `--full-chain-only`.
- `--include-parent`: include the direct parent tweet for non-root bookmarks.
- `--thread-meta`: add thread metadata fields to each tweet.
- `--sort-chronological`: sort output globally oldest to newest (default preserves bookmark order).
Global options:
- `--auth-token <token>`: set the `auth_token` cookie manually.
- `--ct0 <token>`: set the `ct0` cookie manually.
- `--cookie-source <safari|chrome|firefox>`: choose browser cookie source (repeatable; order matters).
- `--chrome-profile <name>`: Chrome profile name for cookie extraction (e.g., `Default`, `Profile 2`).
- `--chrome-profile-dir <path>`: Chrome/Chromium profile directory or cookie DB path for cookie extraction.
- `--firefox-profile <name>`: Firefox profile for cookie extraction.
- `--cookie-timeout <ms>`: cookie extraction timeout for keychain/OS helpers (milliseconds).
- `--timeout <ms>`: abort requests after the given timeout (milliseconds).
- `--quote-depth <n>`: max quoted tweet depth in JSON output (default: 1; 0 disables).
- `--plain`: stable output (no emoji, no color).
- `--no-emoji`: disable emoji output.
- `--no-color`: disable ANSI colors (or set `NO_COLOR=1`).
- `--media <path>`: attach media file (repeatable, up to 4 images or 1 video).
- `--alt <text>`: alt text for the corresponding `--media` (repeatable).
## Authentication (GraphQL)
GraphQL mode uses your existing X/Twitter web session (no password prompt). It sends requests to internal
X endpoints and authenticates via cookies (`auth_token`, `ct0`).
Write operations:
- `tweet`/`reply` primarily use GraphQL (`CreateTweet`).
- If GraphQL returns error `226` (“automated request”), `bird` falls back to the legacy `statuses/update.json` endpoint.
`bird` resolves credentials in this order:
1. CLI flags: `--auth-token`, `--ct0`
2. Environment variables: `AUTH_TOKEN`, `CT0` (fallback: `TWITTER_AUTH_TOKEN`, `TWITTER_CT0`)
3. Browser cookies via `@steipete/sweet-cookie` (override via `--cookie-source` order)
Browser cookie sources:
- Safari: `~/Library/Cookies/Cookies.binarycookies` (fallback: `~/Library/Containers/com.apple.Safari/Data/Library/Cookies/Cookies.binarycookies`)
- Chrome: `~/Library/Application Support/Google/Chrome/<Profile>/Cookies`
- Firefox: `~/Library/Application Support/Firefox/Profiles/<profile>/cookies.sqlite`
- For Chromium variants (Arc/Brave/etc), pass a profile directory or cookie DB via `--chrome-profile-dir`.
## Config (JSON5)
Config precedence: CLI flags > env vars > project config > global config.
- Global: `~/.config/bird/config.json5`
- Project: `./.birdrc.json5`
Example `~/.config/bird/config.json5`:
```json5
{
// Cookie source order for browser extraction (string or array)
cookieSource: ["firefox", "safari"],
chromeProfileDir: "/path/to/Chromium/Profile",
firefoxProfile: "default-release",
cookieTimeoutMs: 30000,
timeoutMs: 20000,
quoteDepth: 1
}
```
Environment shortcuts:
- `BIRD_TIMEOUT_MS`
- `BIRD_COOKIE_TIMEOUT_MS`
- `BIRD_QUOTE_DEPTH`
## Output
- `--json` prints raw tweet objects for read/replies/thread/search/mentions/user-tweets/bookmarks/likes.
- When using `--json` with pagination (`--all`, `--cursor`, `--max-pages`, or for `user-tweets` when `-n > 20`), output is `{ tweets, nextCursor }`.
- `read` returns full text for Notes and Articles when present.
- Use `--plain` for stable, script-friendly output (no emoji, no color).
### JSON Schema
When using `--json`, tweet objects include:
| Field | Type | Description |
|-------|------|-------------|
| `id` | string | Tweet ID |
| `text` | string | Full tweet text (includes Note/Article content when present) |
| `author` | object | `{ username, name }` |
| `authorId` | string? | Author's user ID |
| `createdAt` | string | Timestamp |
| `replyCount` | number | Number of replies |
| `retweetCount` | number | Number of retweets |
| `likeCount` | number | Number of likes |
| `conversationId` | string | Thread conversation ID |
| `inReplyToStatusId` | string? | Parent tweet ID (present if this is a reply) |
| `quotedTweet` | object? | Embedded quote tweet (same schema; depth controlled by `--quote-depth`) |
When using `--json` with `following`/`followers`, user objects include:
| Field | Type | Description |
|-------|------|-------------|
| `id` | string | User ID |
| `username` | string | Username/handle |
| `name` | string | Display name |
| `description` | string? | User bio |
| `followersCount` | number? | Followers count |
| `followingCount` | number? | Following count |
| `isBlueVerified` | boolean? | Blue verified flag |
| `profileImageUrl` | string? | Profile image URL |
| `createdAt` | string? | Account creation timestamp |
When using `--json` with `news`/`trending`, news objects include:
| Field | Type | Description |
|-------|------|-------------|
| `id` | string | Unique identifier for the news item |
| `headline` | string | News headline or trend title |
| `category` | string? | Category (e.g., "AI · Technology", "Trending", "News") |
| `timeAgo` | string? | Relative time (e.g., "2h ago") |
| `postCount` | number? | Number of posts |
| `description` | string? | Item description |
| `url` | string? | URL to the trend or news article |
| `tweets` | array? | Related tweets (only when `--with-tweets` is used) |
| `_raw` | object? | Raw API response (only when `--json-full` is used) |
## Query IDs (GraphQL)
X rotates GraphQL “query IDs” frequently. Each GraphQL operation is addressed as:
- `operationName` (e.g. `TweetDetail`, `CreateTweet`)
- `queryId` (rotating ID baked into Xs web client bundles)
`bird` ships with a baseline mapping in `src/lib/query-ids.json` (copied into `dist/` on build). At runtime,
it can refresh that mapping by scraping Xs public web client bundles and caching the result on disk.
Runtime cache:
- Default path: `~/.config/bird/query-ids-cache.json`
- Override path: `BIRD_QUERY_IDS_CACHE=/path/to/file.json`
- TTL: 24h (stale cache is still used, but marked “not fresh”)
Auto-recovery:
- On GraphQL `404` (query ID invalid), `bird` forces a refresh once and retries.
- For `TweetDetail`/`SearchTimeline`, `bird` also rotates through a small set of known fallback IDs to reduce
breakage while refreshing.
Refresh on demand:
```bash
bird query-ids --fresh
```
Exit codes:
- `0`: success
- `1`: runtime error (network/auth/etc)
- `2`: invalid usage/validation (e.g. bad `--user` handle)
## Version
`bird --version` prints `package.json` version plus current git sha when available, e.g. `0.3.0 (3df7969b)`.
## Media uploads
- Attach media with `--media` (repeatable) and optional `--alt` per item.
- Up to 4 images/GIFs, or 1 video (no mixing). Supported: jpg, jpeg, png, webp, gif, mp4, mov.
- Images/GIFs + 1 video supported (uploads via Twitter legacy upload endpoint + cookies; video may take longer to process).
Example:
```bash
bird tweet "hi" --media img.png --alt "desc"
```
## Development
```bash
cd ~/Projects/bird
pnpm install
pnpm run build # dist/ + bun binary
pnpm run build:dist # dist/ only
pnpm run build:binary
pnpm run dev tweet "Test"
pnpm run dev -- --plain check
pnpm test
pnpm run lint
```
## Notes
- GraphQL uses internal X endpoints and can be rate limited (429).
- Query IDs rotate; refresh at runtime with `bird query-ids --fresh` (or update the baked baseline via `pnpm run graphql:update`).
+12
View File
@@ -0,0 +1,12 @@
#!/usr/bin/env node
/**
* bird - CLI tool for posting tweets and replies
*
* Usage:
* bird tweet "Hello world!"
* bird reply <tweet-id> "This is a reply"
* bird reply <tweet-url> "This is a reply"
* bird read <tweet-id-or-url>
*/
export {};
//# sourceMappingURL=cli.d.ts.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":";AAEA;;;;;;;;GAQG"}
+29
View File
@@ -0,0 +1,29 @@
#!/usr/bin/env node
/**
* bird - CLI tool for posting tweets and replies
*
* Usage:
* bird tweet "Hello world!"
* bird reply <tweet-id> "This is a reply"
* bird reply <tweet-url> "This is a reply"
* bird read <tweet-id-or-url>
*/
import { createProgram, KNOWN_COMMANDS } from './cli/program.js';
import { createCliContext } from './cli/shared.js';
import { resolveCliInvocation } from './lib/cli-args.js';
const rawArgs = process.argv.slice(2);
const normalizedArgs = rawArgs[0] === '--' ? rawArgs.slice(1) : rawArgs;
const ctx = createCliContext(normalizedArgs);
const program = createProgram(ctx);
const { argv, showHelp } = resolveCliInvocation(normalizedArgs, KNOWN_COMMANDS);
if (showHelp) {
program.outputHelp();
process.exit(0);
}
if (argv) {
program.parse(argv);
}
else {
program.parse(['node', 'bird', ...normalizedArgs]);
}
//# sourceMappingURL=cli.js.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"file":"cli.js","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":";AAEA;;;;;;;;GAQG;AAEH,OAAO,EAAE,aAAa,EAAE,cAAc,EAAE,MAAM,kBAAkB,CAAC;AACjE,OAAO,EAAE,gBAAgB,EAAE,MAAM,iBAAiB,CAAC;AACnD,OAAO,EAAE,oBAAoB,EAAE,MAAM,mBAAmB,CAAC;AAEzD,MAAM,OAAO,GAAa,OAAO,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC;AAChD,MAAM,cAAc,GAAa,OAAO,CAAC,CAAC,CAAC,KAAK,IAAI,CAAC,CAAC,CAAC,OAAO,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC;AAElF,MAAM,GAAG,GAAG,gBAAgB,CAAC,cAAc,CAAC,CAAC;AAE7C,MAAM,OAAO,GAAG,aAAa,CAAC,GAAG,CAAC,CAAC;AAEnC,MAAM,EAAE,IAAI,EAAE,QAAQ,EAAE,GAAG,oBAAoB,CAAC,cAAc,EAAE,cAAc,CAAC,CAAC;AAEhF,IAAI,QAAQ,EAAE,CAAC;IACb,OAAO,CAAC,UAAU,EAAE,CAAC;IACrB,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;AAClB,CAAC;AAED,IAAI,IAAI,EAAE,CAAC;IACT,OAAO,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC;AACtB,CAAC;KAAM,CAAC;IACN,OAAO,CAAC,KAAK,CAAC,CAAC,MAAM,EAAE,MAAM,EAAE,GAAG,cAAc,CAAC,CAAC,CAAC;AACrD,CAAC"}
+35
View File
@@ -0,0 +1,35 @@
export type PaginationCmdOpts = {
all?: boolean;
maxPages?: string;
cursor?: string;
delay?: string;
};
export declare function parsePositiveIntFlag(raw: string | undefined, flagName: string): {
ok: true;
value: number | undefined;
} | {
ok: false;
error: string;
};
export declare function parseNonNegativeIntFlag(raw: string | undefined, flagName: string, defaultValue: number): {
ok: true;
value: number;
} | {
ok: false;
error: string;
};
export declare function parsePaginationFlags(cmdOpts: PaginationCmdOpts, opts?: {
maxPagesImpliesPagination?: boolean;
defaultDelayMs?: number;
includeDelay?: boolean;
}): {
ok: true;
usePagination: boolean;
maxPages?: number;
cursor?: string;
pageDelayMs?: number;
} | {
ok: false;
error: string;
};
//# sourceMappingURL=pagination.d.ts.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"file":"pagination.d.ts","sourceRoot":"","sources":["../../src/cli/pagination.ts"],"names":[],"mappings":"AAAA,MAAM,MAAM,iBAAiB,GAAG;IAC9B,GAAG,CAAC,EAAE,OAAO,CAAC;IACd,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB,CAAC;AAEF,wBAAgB,oBAAoB,CAClC,GAAG,EAAE,MAAM,GAAG,SAAS,EACvB,QAAQ,EAAE,MAAM,GACf;IAAE,EAAE,EAAE,IAAI,CAAC;IAAC,KAAK,EAAE,MAAM,GAAG,SAAS,CAAA;CAAE,GAAG;IAAE,EAAE,EAAE,KAAK,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,CASxE;AAED,wBAAgB,uBAAuB,CACrC,GAAG,EAAE,MAAM,GAAG,SAAS,EACvB,QAAQ,EAAE,MAAM,EAChB,YAAY,EAAE,MAAM,GACnB;IAAE,EAAE,EAAE,IAAI,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,GAAG;IAAE,EAAE,EAAE,KAAK,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,CAM5D;AAED,wBAAgB,oBAAoB,CAClC,OAAO,EAAE,iBAAiB,EAC1B,IAAI,CAAC,EAAE;IACL,yBAAyB,CAAC,EAAE,OAAO,CAAC;IACpC,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,YAAY,CAAC,EAAE,OAAO,CAAC;CACxB,GAEC;IACE,EAAE,EAAE,IAAI,CAAC;IACT,aAAa,EAAE,OAAO,CAAC;IACvB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB,GACD;IAAE,EAAE,EAAE,KAAK,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,CA8B/B"}
+43
View File
@@ -0,0 +1,43 @@
export function parsePositiveIntFlag(raw, flagName) {
if (raw === undefined) {
return { ok: true, value: undefined };
}
const value = Number.parseInt(raw, 10);
if (!Number.isFinite(value) || value <= 0) {
return { ok: false, error: `Invalid ${flagName}. Expected a positive integer.` };
}
return { ok: true, value };
}
export function parseNonNegativeIntFlag(raw, flagName, defaultValue) {
const value = Number.parseInt(raw ?? String(defaultValue), 10);
if (!Number.isFinite(value) || value < 0) {
return { ok: false, error: `Invalid ${flagName}. Expected a non-negative integer.` };
}
return { ok: true, value };
}
export function parsePaginationFlags(cmdOpts, opts) {
const maxPagesImpliesPagination = opts?.maxPagesImpliesPagination ?? false;
const includeDelay = opts?.includeDelay ?? false;
const defaultDelayMs = opts?.defaultDelayMs ?? 1000;
const maxPages = parsePositiveIntFlag(cmdOpts.maxPages, '--max-pages');
if (!maxPages.ok) {
return maxPages;
}
const usePagination = Boolean(cmdOpts.all || cmdOpts.cursor || (maxPagesImpliesPagination && maxPages.value !== undefined));
let pageDelayMs;
if (includeDelay) {
const delay = parseNonNegativeIntFlag(cmdOpts.delay, '--delay', defaultDelayMs);
if (!delay.ok) {
return delay;
}
pageDelayMs = delay.value;
}
return {
ok: true,
usePagination,
maxPages: maxPages.value,
cursor: cmdOpts.cursor,
pageDelayMs,
};
}
//# sourceMappingURL=pagination.js.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"file":"pagination.js","sourceRoot":"","sources":["../../src/cli/pagination.ts"],"names":[],"mappings":"AAOA,MAAM,UAAU,oBAAoB,CAClC,GAAuB,EACvB,QAAgB;IAEhB,IAAI,GAAG,KAAK,SAAS,EAAE,CAAC;QACtB,OAAO,EAAE,EAAE,EAAE,IAAI,EAAE,KAAK,EAAE,SAAS,EAAE,CAAC;IACxC,CAAC;IACD,MAAM,KAAK,GAAG,MAAM,CAAC,QAAQ,CAAC,GAAG,EAAE,EAAE,CAAC,CAAC;IACvC,IAAI,CAAC,MAAM,CAAC,QAAQ,CAAC,KAAK,CAAC,IAAI,KAAK,IAAI,CAAC,EAAE,CAAC;QAC1C,OAAO,EAAE,EAAE,EAAE,KAAK,EAAE,KAAK,EAAE,WAAW,QAAQ,gCAAgC,EAAE,CAAC;IACnF,CAAC;IACD,OAAO,EAAE,EAAE,EAAE,IAAI,EAAE,KAAK,EAAE,CAAC;AAC7B,CAAC;AAED,MAAM,UAAU,uBAAuB,CACrC,GAAuB,EACvB,QAAgB,EAChB,YAAoB;IAEpB,MAAM,KAAK,GAAG,MAAM,CAAC,QAAQ,CAAC,GAAG,IAAI,MAAM,CAAC,YAAY,CAAC,EAAE,EAAE,CAAC,CAAC;IAC/D,IAAI,CAAC,MAAM,CAAC,QAAQ,CAAC,KAAK,CAAC,IAAI,KAAK,GAAG,CAAC,EAAE,CAAC;QACzC,OAAO,EAAE,EAAE,EAAE,KAAK,EAAE,KAAK,EAAE,WAAW,QAAQ,oCAAoC,EAAE,CAAC;IACvF,CAAC;IACD,OAAO,EAAE,EAAE,EAAE,IAAI,EAAE,KAAK,EAAE,CAAC;AAC7B,CAAC;AAED,MAAM,UAAU,oBAAoB,CAClC,OAA0B,EAC1B,IAIC;IAUD,MAAM,yBAAyB,GAAG,IAAI,EAAE,yBAAyB,IAAI,KAAK,CAAC;IAC3E,MAAM,YAAY,GAAG,IAAI,EAAE,YAAY,IAAI,KAAK,CAAC;IACjD,MAAM,cAAc,GAAG,IAAI,EAAE,cAAc,IAAI,IAAI,CAAC;IAEpD,MAAM,QAAQ,GAAG,oBAAoB,CAAC,OAAO,CAAC,QAAQ,EAAE,aAAa,CAAC,CAAC;IACvE,IAAI,CAAC,QAAQ,CAAC,EAAE,EAAE,CAAC;QACjB,OAAO,QAAQ,CAAC;IAClB,CAAC;IAED,MAAM,aAAa,GAAG,OAAO,CAC3B,OAAO,CAAC,GAAG,IAAI,OAAO,CAAC,MAAM,IAAI,CAAC,yBAAyB,IAAI,QAAQ,CAAC,KAAK,KAAK,SAAS,CAAC,CAC7F,CAAC;IAEF,IAAI,WAA+B,CAAC;IACpC,IAAI,YAAY,EAAE,CAAC;QACjB,MAAM,KAAK,GAAG,uBAAuB,CAAC,OAAO,CAAC,KAAK,EAAE,SAAS,EAAE,cAAc,CAAC,CAAC;QAChF,IAAI,CAAC,KAAK,CAAC,EAAE,EAAE,CAAC;YACd,OAAO,KAAK,CAAC;QACf,CAAC;QACD,WAAW,GAAG,KAAK,CAAC,KAAK,CAAC;IAC5B,CAAC;IAED,OAAO;QACL,EAAE,EAAE,IAAI;QACR,aAAa;QACb,QAAQ,EAAE,QAAQ,CAAC,KAAK;QACxB,MAAM,EAAE,OAAO,CAAC,MAAM;QACtB,WAAW;KACZ,CAAC;AACJ,CAAC"}
+5
View File
@@ -0,0 +1,5 @@
import { Command } from 'commander';
import { type CliContext } from './shared.js';
export declare const KNOWN_COMMANDS: Set<string>;
export declare function createProgram(ctx: CliContext): Command;
//# sourceMappingURL=program.d.ts.map

Some files were not shown because too many files have changed in this diff Show More