Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b31ec05c74 |
@@ -1,20 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "last30days-skill",
|
|
||||||
"interface": {
|
|
||||||
"displayName": "Last 30 Days"
|
|
||||||
},
|
|
||||||
"plugins": [
|
|
||||||
{
|
|
||||||
"name": "last30days",
|
|
||||||
"source": {
|
|
||||||
"source": "local",
|
|
||||||
"path": "./"
|
|
||||||
},
|
|
||||||
"policy": {
|
|
||||||
"installation": "AVAILABLE",
|
|
||||||
"authentication": "ON_INSTALL"
|
|
||||||
},
|
|
||||||
"category": "Research"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -1,17 +1,16 @@
|
|||||||
{
|
{
|
||||||
|
"$schema": "https://anthropic.com/claude-code/marketplace.schema.json",
|
||||||
"name": "last30days-skill",
|
"name": "last30days-skill",
|
||||||
|
"description": "Research any topic across Reddit, X, YouTube, TikTok, Instagram, HN, Polymarket, GitHub, and 5+ more sources.",
|
||||||
"owner": {
|
"owner": {
|
||||||
"name": "Matt Van Horn",
|
"name": "Matt Van Horn",
|
||||||
"url": "https://github.com/mvanhorn"
|
"url": "https://github.com/mvanhorn"
|
||||||
},
|
},
|
||||||
"metadata": {
|
|
||||||
"description": "Marketplace hosting the Last 30 Days research plugin."
|
|
||||||
},
|
|
||||||
"plugins": [
|
"plugins": [
|
||||||
{
|
{
|
||||||
"name": "last30days",
|
"name": "last30days",
|
||||||
"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.",
|
"description": "Research any topic across Reddit, X, YouTube, TikTok, Instagram, HN, Polymarket, GitHub, and 5+ more sources.",
|
||||||
"version": "3.2.0",
|
"version": "3.0.9",
|
||||||
"author": {
|
"author": {
|
||||||
"name": "Matt Van Horn",
|
"name": "Matt Van Horn",
|
||||||
"url": "https://github.com/mvanhorn"
|
"url": "https://github.com/mvanhorn"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "last30days",
|
"name": "last30days",
|
||||||
"version": "3.2.0",
|
"version": "3.0.14",
|
||||||
"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.",
|
"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": {
|
"author": {
|
||||||
"name": "Matt Van Horn",
|
"name": "Matt Van Horn",
|
||||||
@@ -10,5 +10,6 @@
|
|||||||
"homepage": "https://github.com/mvanhorn/last30days-skill",
|
"homepage": "https://github.com/mvanhorn/last30days-skill",
|
||||||
"repository": "https://github.com/mvanhorn/last30days-skill",
|
"repository": "https://github.com/mvanhorn/last30days-skill",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"keywords": ["research", "reddit", "twitter", "youtube", "tiktok", "instagram", "trends", "prompts", "polymarket", "github", "perplexity", "threads", "pinterest", "eli5", "hacker-news"]
|
"keywords": ["research", "reddit", "twitter", "youtube", "tiktok", "instagram", "trends", "prompts", "polymarket", "github", "perplexity", "threads", "pinterest", "eli5", "hacker-news"],
|
||||||
|
"hooks": {}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,43 +1,3 @@
|
|||||||
{
|
{
|
||||||
"name": "last30days",
|
"name": "last30days"
|
||||||
"version": "3.2.0",
|
|
||||||
"description": "Research any topic across Reddit, X, YouTube, TikTok, Instagram, Hacker News, Polymarket, GitHub, and 5+ more sources. AI agent scores by upvotes, likes, and real money - not editors.",
|
|
||||||
"author": {
|
|
||||||
"name": "Matt Van Horn",
|
|
||||||
"email": "mvanhorn@gmail.com",
|
|
||||||
"url": "https://github.com/mvanhorn"
|
|
||||||
},
|
|
||||||
"homepage": "https://github.com/mvanhorn/last30days-skill",
|
|
||||||
"repository": "https://github.com/mvanhorn/last30days-skill",
|
|
||||||
"license": "MIT",
|
|
||||||
"keywords": [
|
|
||||||
"research",
|
|
||||||
"reddit",
|
|
||||||
"twitter",
|
|
||||||
"youtube",
|
|
||||||
"tiktok",
|
|
||||||
"instagram",
|
|
||||||
"trends",
|
|
||||||
"polymarket",
|
|
||||||
"github",
|
|
||||||
"hacker-news"
|
|
||||||
],
|
|
||||||
"skills": "./skills/",
|
|
||||||
"interface": {
|
|
||||||
"displayName": "Last 30 Days",
|
|
||||||
"shortDescription": "Research recent discussion across social and web sources",
|
|
||||||
"longDescription": "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.",
|
|
||||||
"developerName": "Matt Van Horn",
|
|
||||||
"category": "Research",
|
|
||||||
"capabilities": [
|
|
||||||
"Interactive",
|
|
||||||
"Read",
|
|
||||||
"Write"
|
|
||||||
],
|
|
||||||
"websiteURL": "https://github.com/mvanhorn/last30days-skill",
|
|
||||||
"privacyPolicyURL": "https://docs.github.com/en/site-policy/privacy-policies/github-general-privacy-statement",
|
|
||||||
"termsOfServiceURL": "https://docs.github.com/en/site-policy/github-terms/github-terms-of-service",
|
|
||||||
"defaultPrompt": "Use Last 30 Days to research this topic from the last 30 days across Reddit, X, YouTube, and web.",
|
|
||||||
"brandColor": "#FF6B35"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
# Exclude non-runtime files from `git archive` output.
|
# Exclude non-runtime files from `git archive` output.
|
||||||
# Used by skills/last30days/scripts/build-skill.sh to produce a
|
# Used by scripts/build-skill.sh to produce a claude.ai-upload-ready .skill file.
|
||||||
# claude.ai-upload-ready .skill file from the canonical skills/last30days tree.
|
|
||||||
# See docs/plans/2026-04-14-001-fix-skill-upload-200-file-limit-plan.md.
|
# See docs/plans/2026-04-14-001-fix-skill-upload-200-file-limit-plan.md.
|
||||||
|
|
||||||
# Anthropic canonical skill-packaging excludes
|
# Anthropic canonical skill-packaging excludes
|
||||||
@@ -33,8 +32,9 @@ release-notes.md export-ignore
|
|||||||
CHANGELOG.md export-ignore
|
CHANGELOG.md export-ignore
|
||||||
uv.lock export-ignore
|
uv.lock export-ignore
|
||||||
|
|
||||||
# Platform adapters are kept in git archives because Claude Code and Codex
|
# Platform adapters - skill-upload path is platform-agnostic
|
||||||
# plugin installs use the same repository archive as their source payload.
|
.agents/ export-ignore
|
||||||
|
.codex-plugin/ export-ignore
|
||||||
.hermes-plugin/ export-ignore
|
.hermes-plugin/ export-ignore
|
||||||
|
|
||||||
# CI workflows - repo-only, not needed at skill runtime
|
# CI workflows - repo-only, not needed at skill runtime
|
||||||
|
|||||||
@@ -1,53 +0,0 @@
|
|||||||
name: Bug Report
|
|
||||||
description: Report a bug or unexpected behavior
|
|
||||||
labels: [bug]
|
|
||||||
body:
|
|
||||||
- type: textarea
|
|
||||||
id: summary
|
|
||||||
attributes:
|
|
||||||
label: Summary
|
|
||||||
description: What happened?
|
|
||||||
placeholder: Describe the bug in 1-2 sentences.
|
|
||||||
validations:
|
|
||||||
required: true
|
|
||||||
- type: textarea
|
|
||||||
id: repro
|
|
||||||
attributes:
|
|
||||||
label: Steps to Reproduce
|
|
||||||
description: How can we reproduce this?
|
|
||||||
placeholder: |
|
|
||||||
1. Run `python3 scripts/last30days.py "topic" --emit compact`
|
|
||||||
2. ...
|
|
||||||
validations:
|
|
||||||
required: true
|
|
||||||
- type: textarea
|
|
||||||
id: expected
|
|
||||||
attributes:
|
|
||||||
label: Expected Behavior
|
|
||||||
description: What should have happened?
|
|
||||||
validations:
|
|
||||||
required: true
|
|
||||||
- type: textarea
|
|
||||||
id: traceback
|
|
||||||
attributes:
|
|
||||||
label: Error / Traceback
|
|
||||||
description: Paste the full traceback or error output.
|
|
||||||
render: text
|
|
||||||
- type: dropdown
|
|
||||||
id: install
|
|
||||||
attributes:
|
|
||||||
label: Install Method
|
|
||||||
options:
|
|
||||||
- Claude Code plugin
|
|
||||||
- Gemini CLI extension
|
|
||||||
- Codex plugin
|
|
||||||
- Hermes skill
|
|
||||||
- Manual (git clone)
|
|
||||||
- Other
|
|
||||||
validations:
|
|
||||||
required: true
|
|
||||||
- type: input
|
|
||||||
id: os
|
|
||||||
attributes:
|
|
||||||
label: OS
|
|
||||||
placeholder: macOS 15.4, Ubuntu 24.04, Windows 11, etc.
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
name: Feature Request
|
|
||||||
description: Suggest a new feature or improvement
|
|
||||||
labels: [enhancement]
|
|
||||||
body:
|
|
||||||
- type: textarea
|
|
||||||
id: problem
|
|
||||||
attributes:
|
|
||||||
label: Problem
|
|
||||||
description: What problem does this solve?
|
|
||||||
placeholder: When I try to ..., I can't ...
|
|
||||||
validations:
|
|
||||||
required: true
|
|
||||||
- type: textarea
|
|
||||||
id: solution
|
|
||||||
attributes:
|
|
||||||
label: Proposed Solution
|
|
||||||
description: How should this work?
|
|
||||||
validations:
|
|
||||||
required: true
|
|
||||||
- type: textarea
|
|
||||||
id: alternatives
|
|
||||||
attributes:
|
|
||||||
label: Alternatives Considered
|
|
||||||
description: Other approaches you thought of (optional).
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
## Summary
|
|
||||||
|
|
||||||
<!-- What does this PR do? 1-3 sentences. -->
|
|
||||||
|
|
||||||
## Changes
|
|
||||||
|
|
||||||
<!-- Bullet list of what changed. Reference files if helpful. -->
|
|
||||||
|
|
||||||
-
|
|
||||||
|
|
||||||
## Testing
|
|
||||||
|
|
||||||
<!-- How did you verify this works? -->
|
|
||||||
|
|
||||||
- [ ] Ran `uv run python -m pytest -q --tb=short`
|
|
||||||
- [ ] Ran `bash scripts/sync.sh` (if scripts/ changed)
|
|
||||||
|
|
||||||
## Related Issues
|
|
||||||
|
|
||||||
<!-- Link issues: Fixes #123 or Relates to #456 -->
|
|
||||||
@@ -19,7 +19,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Build .skill artifact
|
- name: Build .skill artifact
|
||||||
run: |
|
run: |
|
||||||
bash skills/last30days/scripts/build-skill.sh
|
bash scripts/build-skill.sh
|
||||||
test -f dist/last30days.skill
|
test -f dist/last30days.skill
|
||||||
|
|
||||||
- name: Create GitHub release
|
- name: Create GitHub release
|
||||||
|
|||||||
@@ -1,26 +0,0 @@
|
|||||||
name: Validate
|
|
||||||
|
|
||||||
on:
|
|
||||||
pull_request:
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- main
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
plugin-contract:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Install uv
|
|
||||||
uses: astral-sh/setup-uv@v5
|
|
||||||
|
|
||||||
- name: Set up Python
|
|
||||||
run: uv python install 3.12
|
|
||||||
|
|
||||||
- name: Run plugin contract tests
|
|
||||||
run: uv run pytest tests/test_plugin_contract.py tests/test_version_consistency.py
|
|
||||||
@@ -28,3 +28,9 @@ htmlcov/
|
|||||||
|
|
||||||
# Internal planning docs (ce:plan output) — keep local, don't publish
|
# Internal planning docs (ce:plan output) — keep local, don't publish
|
||||||
docs/plans/
|
docs/plans/
|
||||||
|
|
||||||
|
# Marketing video build artifacts
|
||||||
|
marketing/v3.1-launch/node_modules/
|
||||||
|
marketing/v3.1-launch/out/
|
||||||
|
marketing/*/node_modules/
|
||||||
|
marketing/*/out/
|
||||||
|
|||||||
@@ -5,39 +5,6 @@ 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/),
|
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).
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
## [Unreleased]
|
|
||||||
|
|
||||||
## [3.2.0] - 2026-05-09
|
|
||||||
|
|
||||||
### Added
|
|
||||||
|
|
||||||
- Add `--emit=html` for shareable, print-friendly HTML research briefs.
|
|
||||||
- **Digg AI 1000 source** (auto-enabled when `digg-pp-cli` is on PATH). Surfaces curated story clusters from the AI 1000 leaderboard and pulls attributable X-post quotes into the brief as `[@handle](xUrl) via Digg AI 1000: ...` lines. Footer line: `⛏️ Digg AI 1000: N clusters │ K posts │ M authors`. No X auth required for the inline quotes since they flow through Digg's read-only endpoints.
|
|
||||||
|
|
||||||
## [3.1.1] - 2026-04-24
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
|
|
||||||
- **Codex plugin layout.** Move the canonical runtime payload under `skills/last30days/` and update Codex/Claude plugin metadata and tests for the relocated engine path.
|
|
||||||
- **Claude Code cache resolution.** Resolve Claude plugin installs to `skills/last30days/scripts/last30days.py` after the plugin-layout restructure.
|
|
||||||
|
|
||||||
## [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
|
## [3.0.14] - 2026-04-22
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
@@ -94,7 +61,6 @@ Individual changelog entries for 3.0.10 through 3.0.14 below document the increm
|
|||||||
### Fixed
|
### 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.
|
- **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.
|
||||||
- **Single plugin payload layout.** The canonical runtime moved to `skills/last30days/` for both Claude Code and Codex plugin loading. Root-level `SKILL.md`, `scripts/`, `agents/`, and `assets/` are no longer maintained as duplicate copies.
|
|
||||||
|
|
||||||
### Behavior fallback
|
### Behavior fallback
|
||||||
|
|
||||||
|
|||||||
@@ -4,20 +4,20 @@ Claude Code skill for researching any topic across Reddit, X, YouTube, and web.
|
|||||||
Python scripts with multi-source search aggregation.
|
Python scripts with multi-source search aggregation.
|
||||||
|
|
||||||
## Structure
|
## Structure
|
||||||
- `skills/last30days/SKILL.md` — canonical skill definition
|
- `scripts/last30days.py` — main research engine
|
||||||
- `skills/last30days/scripts/last30days.py` — main research engine
|
- `scripts/lib/` — search, enrichment, rendering modules
|
||||||
- `skills/last30days/scripts/lib/` — search, enrichment, rendering modules
|
- `scripts/lib/vendor/bird-search/` — vendored X search client
|
||||||
- `skills/last30days/scripts/lib/vendor/bird-search/` — vendored X search client
|
- `SKILL.md` — skill definition (deployed to ~/.claude/skills/last30days/)
|
||||||
|
|
||||||
## Commands
|
## Commands
|
||||||
```bash
|
```bash
|
||||||
python3 skills/last30days/scripts/last30days.py "test query" --emit=compact
|
python3 scripts/last30days.py "test query" --emit=compact # Run research
|
||||||
bash skills/last30days/scripts/sync.sh
|
bash scripts/sync.sh # Deploy to ~/.claude, ~/.agents, ~/.codex
|
||||||
```
|
```
|
||||||
|
|
||||||
## Rules
|
## Rules
|
||||||
- `lib/__init__.py` must be bare package marker (comment only, NO eager imports)
|
- `lib/__init__.py` must be bare package marker (comment only, NO eager imports)
|
||||||
- After edits: run `bash skills/last30days/scripts/sync.sh` to deploy
|
- After edits: run `bash scripts/sync.sh` to deploy
|
||||||
- Git remote: origin = public (`mvanhorn/last30days-skill`)
|
- Git remote: origin = public (`mvanhorn/last30days-skill`)
|
||||||
|
|
||||||
## Beta channel
|
## Beta channel
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ git clone https://github.com/mvanhorn/last30days-skill.git
|
|||||||
cd last30days-skill
|
cd last30days-skill
|
||||||
|
|
||||||
# Run the sync script
|
# Run the sync script
|
||||||
bash skills/last30days/scripts/sync.sh
|
bash scripts/sync.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
This will auto-detect Hermes and deploy to `~/.hermes/skills/research/last30days/`
|
This will auto-detect Hermes and deploy to `~/.hermes/skills/research/last30days/`
|
||||||
@@ -30,8 +30,8 @@ This will auto-detect Hermes and deploy to `~/.hermes/skills/research/last30days
|
|||||||
mkdir -p ~/.hermes/skills/research/last30days
|
mkdir -p ~/.hermes/skills/research/last30days
|
||||||
|
|
||||||
# Copy files
|
# Copy files
|
||||||
cp skills/last30days/SKILL.md ~/.hermes/skills/research/last30days/
|
cp -r scripts ~/.hermes/skills/research/last30days/
|
||||||
cp -r skills/last30days/scripts ~/.hermes/skills/research/last30days/
|
cp .hermes-plugin/SKILL.md ~/.hermes/skills/research/last30days/
|
||||||
```
|
```
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
@@ -111,7 +111,7 @@ To update to the latest version:
|
|||||||
```bash
|
```bash
|
||||||
cd last30days-skill
|
cd last30days-skill
|
||||||
git pull
|
git pull
|
||||||
bash skills/last30days/scripts/sync.sh
|
bash scripts/sync.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
## Support
|
## Support
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
|
|
||||||
**An AI agent-led search engine scored by upvotes, likes, and real money - not editors.**
|
**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/SKILL.md](skills/last30days/SKILL.md), which is the source of truth for the latest command and setup behavior.
|
||||||
|
|
||||||
Claude Code:
|
Claude Code:
|
||||||
```
|
```
|
||||||
@@ -68,7 +68,6 @@ If you're meeting with a CEO, have you read all their tweets and YouTube transcr
|
|||||||
| **Hacker News** | The developer consensus. 825 points, 899 comments. Where technical people actually argue. |
|
| **Hacker News** | The developer consensus. 825 points, 899 comments. Where technical people actually argue. |
|
||||||
| **Polymarket** | Not opinions. Odds. Backed by real money. 96% confidence on album sales. 4% on an acquisition. |
|
| **Polymarket** | Not opinions. Odds. Backed by real money. 96% confidence on album sales. 4% on an acquisition. |
|
||||||
| **GitHub** | For people: PR velocity, top repos by stars, release notes. For topics: issues and discussions. |
|
| **GitHub** | For people: PR velocity, top repos by stars, release notes. For topics: issues and discussions. |
|
||||||
| **Digg AI 1000** | Curated story clusters from ~1000 high-signal AI accounts on X, with attributable inline quotes (no X auth required). Auto-enabled when `digg-pp-cli` is on PATH. |
|
|
||||||
| **Threads** | The post-Twitter text layer. Conversations from creators and brands. |
|
| **Threads** | The post-Twitter text layer. Conversations from creators and brands. |
|
||||||
| **Pinterest** | Visual discovery. Pins, saves, and comments on products and ideas. |
|
| **Pinterest** | Visual discovery. Pins, saves, and comments on products and ideas. |
|
||||||
| **Bluesky** | The decentralized social layer. AT Protocol posts from the post-Twitter migration. |
|
| **Bluesky** | The decentralized social layer. AT Protocol posts from the post-Twitter migration. |
|
||||||
@@ -97,28 +96,6 @@ The synthesis ranks by what real people actually engaged with. Social relevancy,
|
|||||||
|
|
||||||
## What v3 Changed
|
## What v3 Changed
|
||||||
|
|
||||||
### Shareable HTML briefs
|
|
||||||
|
|
||||||
Ask for an HTML brief and the skill saves a self-contained, dark-mode, print-friendly file you can drop into Slack, email, or Notion. No raw markdown leaks. Inline CSS, system-font fallbacks behind Inter and JetBrains Mono. No JavaScript. Works offline.
|
|
||||||
|
|
||||||
```
|
|
||||||
/last30days OpenClaw --emit=html
|
|
||||||
```
|
|
||||||
|
|
||||||
or just ask in plain language:
|
|
||||||
|
|
||||||
```
|
|
||||||
/last30days OpenClaw, give me a shareable HTML brief
|
|
||||||
/last30days Cursor IDE for slack
|
|
||||||
/last30days Anthropic earnings export as html
|
|
||||||
```
|
|
||||||
|
|
||||||
The skill emits the synthesis in chat as usual AND saves a brief to `${LAST30DAYS_MEMORY_DIR}/{topic}-brief.html` (defaults to `~/Documents/Last30Days/`). The chat response ends with the file path so you can `open` it or drag it into a message.
|
|
||||||
|
|
||||||
What's in the file: badge, inline metadata line, the model's synthesis verbatim with all citations, the engine footer (✅ All agents reported back! tree), and a colophon noting the topic + how to re-run. Data quality warnings (degraded run, thin evidence, etc.) stay in the engine's stderr logs; they never leak into the shareable artifact.
|
|
||||||
|
|
||||||
For direct CLI use without the model in the loop, the engine also accepts `--synthesis-file PATH` to convert any markdown synthesis to HTML.
|
|
||||||
|
|
||||||
### Intelligent search: the killer feature
|
### Intelligent search: the killer feature
|
||||||
|
|
||||||
The v3 engine doesn't just search for your topic. It figures out *where* to search before the search begins. Type "OpenClaw" and the engine resolves @steipete (Peter Steinberger, the creator), r/openclaw, r/ClaudeCode, and the right YouTube channels and TikTok hashtags - all via a new Python pre-research brain built by [@j-sperling](https://github.com/j-sperling). The old engine searched keywords. The new engine understands your topic first, then searches the right people and communities.
|
The v3 engine doesn't just search for your topic. It figures out *where* to search before the search begins. Type "OpenClaw" and the engine resolves @steipete (Peter Steinberger, the creator), r/openclaw, r/ClaudeCode, and the right YouTube channels and TikTok hashtags - all via a new Python pre-research brain built by [@j-sperling](https://github.com/j-sperling). The old engine searched keywords. The new engine understands your topic first, then searches the right people and communities.
|
||||||
@@ -212,7 +189,7 @@ gemini extensions install ./last30days-skill
|
|||||||
git clone https://github.com/mvanhorn/last30days-skill.git ~/.claude/skills/last30days
|
git clone https://github.com/mvanhorn/last30days-skill.git ~/.claude/skills/last30days
|
||||||
```
|
```
|
||||||
|
|
||||||
Or build the claude.ai `.skill` file from source: `bash skills/last30days/scripts/build-skill.sh` produces `dist/last30days.skill`.
|
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.
|
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.
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
name: last30days
|
name: last30days
|
||||||
version: "3.2.0"
|
version: "3.0.1"
|
||||||
description: "Research what people actually say about any topic in the last 30 days. Pulls posts and engagement from Reddit, X, YouTube, TikTok, Hacker News, Polymarket, GitHub, and the web."
|
description: "Research what people actually say about any topic in the last 30 days. Pulls posts and engagement from Reddit, X, YouTube, TikTok, Hacker News, Polymarket, GitHub, and the web."
|
||||||
argument-hint: 'last30days nvidia earnings reaction | last30days AI video tools | last30days what users want in react'
|
argument-hint: 'last30days nvidia earnings reaction | last30days AI video tools | last30days what users want in react'
|
||||||
allowed-tools: Bash, Read, Write, AskUserQuestion, WebSearch
|
allowed-tools: Bash, Read, Write, AskUserQuestion, WebSearch
|
||||||
@@ -45,7 +45,6 @@ metadata:
|
|||||||
- instagram
|
- instagram
|
||||||
- hackernews
|
- hackernews
|
||||||
- polymarket
|
- polymarket
|
||||||
- digg
|
|
||||||
- bluesky
|
- bluesky
|
||||||
- truthsocial
|
- truthsocial
|
||||||
- trends
|
- trends
|
||||||
@@ -105,7 +104,7 @@ These anchors used to live at line 1094 of this file. Three independent Opus 4.7
|
|||||||
🌐 last30days v{VERSION} · synced {YYYY-MM-DD}
|
🌐 last30days v{VERSION} · synced {YYYY-MM-DD}
|
||||||
```
|
```
|
||||||
|
|
||||||
Replace `{VERSION}` with the installed plugin version (`jq -r '.version' "$SKILL_ROOT/../../.codex-plugin/plugin.json" 2>/dev/null || jq -r '.version' "$SKILL_ROOT/.claude-plugin/plugin.json"`) and `{YYYY-MM-DD}` with today's date. No other text on this line. One blank line after, then the synthesis begins.
|
Replace `{VERSION}` with the installed plugin version (`jq -r '.version' "$SKILL_ROOT/.claude-plugin/plugin.json"`) and `{YYYY-MM-DD}` with today's date. No other text on this line. One blank line after, then the synthesis begins.
|
||||||
|
|
||||||
**Why the badge is MANDATORY:** it is the structural anchor for the canonical output shape. Without it the model drifts into blog-post narrative format with `##` section headers and invented titles, violating LAW 2 and LAW 4. The 2026-04-18 public v3.0.6 0/8 regression produced outputs with section headers like "The headline", "Why he is everywhere", "1. gstack dominates", "The 'Homecoming' peak". Direct cause: this anchor was absent. Do NOT skip the badge. Do NOT describe it. Do NOT paraphrase it. Emit it verbatim as line 1.
|
**Why the badge is MANDATORY:** it is the structural anchor for the canonical output shape. Without it the model drifts into blog-post narrative format with `##` section headers and invented titles, violating LAW 2 and LAW 4. The 2026-04-18 public v3.0.6 0/8 regression produced outputs with section headers like "The headline", "Why he is everywhere", "1. gstack dominates", "The 'Homecoming' peak". Direct cause: this anchor was absent. Do NOT skip the badge. Do NOT describe it. Do NOT paraphrase it. Emit it verbatim as line 1.
|
||||||
|
|
||||||
@@ -234,7 +233,7 @@ If your Bash call to `last30days.py` does NOT include the FULL pre-flight checkl
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
# last30days v3.2.0: Research Any Topic from the Last 30 Days
|
# last30days v3.0.1: Research Any Topic from the Last 30 Days
|
||||||
|
|
||||||
> **Permissions overview:** Reads public web/platform data and optionally saves research briefings to `LAST30DAYS_MEMORY_DIR` (defaults to `~/Documents/Last30Days`). X/Twitter search uses optional user-provided tokens (AUTH_TOKEN/CT0 env vars). Bluesky search uses optional app password (BSKY_HANDLE/BSKY_APP_PASSWORD env vars - create at bsky.app/settings/app-passwords). All credential usage and data writes are documented in the [Security & Permissions](#security--permissions) section.
|
> **Permissions overview:** Reads public web/platform data and optionally saves research briefings to `LAST30DAYS_MEMORY_DIR` (defaults to `~/Documents/Last30Days`). X/Twitter search uses optional user-provided tokens (AUTH_TOKEN/CT0 env vars). Bluesky search uses optional app password (BSKY_HANDLE/BSKY_APP_PASSWORD env vars - create at bsky.app/settings/app-passwords). All credential usage and data writes are documented in the [Security & Permissions](#security--permissions) section.
|
||||||
|
|
||||||
@@ -318,7 +317,6 @@ Common patterns:
|
|||||||
|
|
||||||
- Always active: Reddit, Hacker News, Polymarket
|
- Always active: Reddit, Hacker News, Polymarket
|
||||||
- If gh CLI is installed (check `which gh`): add GitHub
|
- If gh CLI is installed (check `which gh`): add GitHub
|
||||||
- If digg-pp-cli is installed (check `which digg-pp-cli`): add Digg AI 1000
|
|
||||||
- If AUTH_TOKEN/CT0 or XAI_API_KEY or FROM_BROWSER is set, or xurl CLI is installed and authenticated: add X
|
- If AUTH_TOKEN/CT0 or XAI_API_KEY or FROM_BROWSER is set, or xurl CLI is installed and authenticated: add X
|
||||||
- If yt-dlp is installed (check `which yt-dlp`): add YouTube
|
- If yt-dlp is installed (check `which yt-dlp`): add YouTube
|
||||||
- If SCRAPECREATORS_API_KEY is set and INCLUDE_SOURCES contains tiktok: add TikTok
|
- If SCRAPECREATORS_API_KEY is set and INCLUDE_SOURCES contains tiktok: add TikTok
|
||||||
@@ -826,7 +824,7 @@ Only show lines for platforms where something was resolved. Skip empty lines. On
|
|||||||
- For how_to: prioritize YouTube (tutorials) and Reddit (guides)
|
- For how_to: prioritize YouTube (tutorials) and Reddit (guides)
|
||||||
- Primary subquery weight = 1.0, secondary = 0.6-0.8, peripheral = 0.3-0.5
|
- Primary subquery weight = 1.0, secondary = 0.6-0.8, peripheral = 0.3-0.5
|
||||||
|
|
||||||
**Available sources (include ALL in primary subquery):** reddit, x, youtube, tiktok, instagram, hackernews, polymarket. Optional: bluesky, truthsocial, threads, pinterest, grounding (web search - only if user has Brave/Exa/Serper key), digg (Digg AI 1000 clusters - only if `digg-pp-cli` is on PATH)
|
**Available sources (include ALL in primary subquery):** reddit, x, youtube, tiktok, instagram, hackernews, polymarket. Optional: bluesky, truthsocial, threads, pinterest, grounding (web search - only if user has Brave/Exa/Serper key)
|
||||||
|
|
||||||
**Intent → freshness_mode mapping:**
|
**Intent → freshness_mode mapping:**
|
||||||
- breaking_news, prediction → `strict_recent`
|
- breaking_news, prediction → `strict_recent`
|
||||||
@@ -869,36 +867,26 @@ Store your plan as `QUERY_PLAN_JSON` - you'll pass it to the script in the next
|
|||||||
**IMPORTANT: Include `--x-handle={RESOLVED_HANDLE}` in the command. For comparison mode: Pass `--x-handle={TOPIC_A_HANDLE}` to the first pass, `--x-handle={TOPIC_B_HANDLE}` to the second pass, and both to the head-to-head pass. Also include `--subreddits={RESOLVED_SUBREDDITS}`, `--tiktok-hashtags={RESOLVED_HASHTAGS}`, `--tiktok-creators={RESOLVED_TIKTOK_CREATORS}`, and `--ig-creators={RESOLVED_IG_CREATORS}` from Step 0.55. Omit any flag where the value was not resolved (empty).**
|
**IMPORTANT: Include `--x-handle={RESOLVED_HANDLE}` in the command. For comparison mode: Pass `--x-handle={TOPIC_A_HANDLE}` to the first pass, `--x-handle={TOPIC_B_HANDLE}` to the second pass, and both to the head-to-head pass. Also include `--subreddits={RESOLVED_SUBREDDITS}`, `--tiktok-hashtags={RESOLVED_HASHTAGS}`, `--tiktok-creators={RESOLVED_TIKTOK_CREATORS}`, and `--ig-creators={RESOLVED_IG_CREATORS}` from Step 0.55. Omit any flag where the value was not resolved (empty).**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# PIN SKILL_ROOT to an installed plugin cache first (highest-version dir wins on upgrade).
|
# PIN SKILL_ROOT to the public plugin cache (highest-version dir wins on upgrade).
|
||||||
# Prefer Codex's skill package path when installed as a Codex plugin. Keep the Claude
|
# DO NOT write your own path-discovery loop. The 2026-04-18 Peter Steinberger run 1
|
||||||
# plugin-root fallback for other hosts, then fall back to a repo checkout.
|
# regression was caused by a custom discovery loop landing on ~/.openclaw/skills/last30days/
|
||||||
SKILL_ROOT="$(ls -d "$HOME/.codex/plugins/cache/"*/last30days/*/skills/last30days/ 2>/dev/null | sort -V | tail -1)"
|
# (a stale copy from a private-repo sync pattern). That path contains a pre-plan-007
|
||||||
|
# engine and produces non-canonical output. This pinned resolution ignores every stale
|
||||||
|
# copy (~/.openclaw/, ~/.agents/, ~/.codex/) and picks the plugin cache exclusively.
|
||||||
|
SKILL_ROOT="$(ls -d "$HOME/.claude/plugins/cache/last30days-skill/last30days/"*/ 2>/dev/null | sort -V | tail -1)"
|
||||||
SKILL_ROOT="${SKILL_ROOT%/}"
|
SKILL_ROOT="${SKILL_ROOT%/}"
|
||||||
|
|
||||||
# Fallback for Claude plugin cache.
|
# Fallback for repo checkout / Gemini / Codex hosts where the plugin cache does not exist.
|
||||||
|
# Only runs if the public plugin cache is missing entirely.
|
||||||
if [ -z "$SKILL_ROOT" ] || [ ! -f "$SKILL_ROOT/scripts/last30days.py" ]; then
|
if [ -z "$SKILL_ROOT" ] || [ ! -f "$SKILL_ROOT/scripts/last30days.py" ]; then
|
||||||
CLAUDE_PLUGIN_ROOT="$(ls -d "$HOME/.claude/plugins/cache/last30days-skill/last30days/"*/ 2>/dev/null | sort -V | tail -1)"
|
for dir in "." "${CLAUDE_PLUGIN_ROOT:-}" "${GEMINI_EXTENSION_DIR:-}"; do
|
||||||
CLAUDE_PLUGIN_ROOT="${CLAUDE_PLUGIN_ROOT%/}"
|
|
||||||
if [ -n "$CLAUDE_PLUGIN_ROOT" ]; then
|
|
||||||
if [ -f "$CLAUDE_PLUGIN_ROOT/skills/last30days/scripts/last30days.py" ]; then
|
|
||||||
SKILL_ROOT="$CLAUDE_PLUGIN_ROOT/skills/last30days"
|
|
||||||
elif [ -f "$CLAUDE_PLUGIN_ROOT/scripts/last30days.py" ]; then
|
|
||||||
SKILL_ROOT="$CLAUDE_PLUGIN_ROOT"
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Fallback for repo checkout / Gemini / local development hosts where the plugin cache does not exist.
|
|
||||||
if [ -z "$SKILL_ROOT" ] || [ ! -f "$SKILL_ROOT/scripts/last30days.py" ]; then
|
|
||||||
for dir in "." "./skills/last30days" "${CLAUDE_PLUGIN_ROOT:-}" "${GEMINI_EXTENSION_DIR:-}"; do
|
|
||||||
[ -n "$dir" ] && [ -f "$dir/scripts/last30days.py" ] && SKILL_ROOT="$dir" && break
|
[ -n "$dir" ] && [ -f "$dir/scripts/last30days.py" ] && SKILL_ROOT="$dir" && break
|
||||||
done
|
done
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [ -z "${SKILL_ROOT:-}" ] || [ ! -f "$SKILL_ROOT/scripts/last30days.py" ]; then
|
if [ -z "${SKILL_ROOT:-}" ] || [ ! -f "$SKILL_ROOT/scripts/last30days.py" ]; then
|
||||||
echo "ERROR: Could not find scripts/last30days.py in Codex/Claude plugin cache or repo checkout" >&2
|
echo "ERROR: Could not find scripts/last30days.py in public plugin cache or repo checkout" >&2
|
||||||
echo "Expected Codex: $HOME/.codex/plugins/cache/{MARKETPLACE}/last30days/{VERSION}/skills/last30days/scripts/last30days.py" >&2
|
echo "Expected: $HOME/.claude/plugins/cache/last30days-skill/last30days/{VERSION}/scripts/last30days.py" >&2
|
||||||
echo "Expected Claude: $HOME/.claude/plugins/cache/last30days-skill/last30days/{VERSION}/skills/last30days/scripts/last30days.py" >&2
|
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -1509,33 +1497,6 @@ Close with `I have all the links to the {N} {source list} I pulled from. Just as
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## SHAREABLE HTML BRIEF (when the user asked for one)
|
|
||||||
|
|
||||||
**This section fires if EITHER trigger is true:**
|
|
||||||
|
|
||||||
- `$ARGUMENTS` contains `--emit=html`, `--emit:html`, or `--html` as a flag
|
|
||||||
- The user's natural-language request asks for an HTML brief, shareable doc, or file for sharing (Slack, email, Notion, "export as HTML", etc). Use your judgment for phrasing variants.
|
|
||||||
|
|
||||||
**If neither trigger fires, skip this entire section and proceed to WAIT FOR USER'S RESPONSE.** No HTML save flow, no reference read needed.
|
|
||||||
|
|
||||||
**When triggered, you MUST:**
|
|
||||||
|
|
||||||
- Read `references/save-html-brief.md` BEFORE proceeding to WAIT FOR USER'S RESPONSE
|
|
||||||
- Follow that file's instructions exactly - it is the canonical source for the save flow
|
|
||||||
- Append the confirmation line (`📎 Shareable brief saved to <path>`) to your already-emitted chat response
|
|
||||||
|
|
||||||
**You MUST NOT:**
|
|
||||||
|
|
||||||
- Improvise the HTML save flow from memory or from instructions you've seen before
|
|
||||||
- Skip the reference read because the steps "look familiar"
|
|
||||||
- Save to a different path than the reference specifies
|
|
||||||
- Add data quality warnings, debug headers, or safety notes to the saved HTML
|
|
||||||
- Re-research the topic for the HTML render - the engine cache covers the second invocation
|
|
||||||
|
|
||||||
**Why the directive is forceful:** the reference file is the only source of truth for the save flow. Skipping it produces broken artifacts - wrong path conventions, missing synthesis content, leaked engine debug output, or warnings that don't belong in shareable docs.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## WAIT FOR USER'S RESPONSE
|
## WAIT FOR USER'S RESPONSE
|
||||||
|
|
||||||
**STOP and wait** for the user to respond. Do NOT call any tools after displaying the invitation. Do NOT append a `Sources:` section (see override above - WebSearch's mandate does not apply here). The research script already saved raw data to `LAST30DAYS_MEMORY_DIR` (defaults to `~/Documents/Last30Days`) via `--save-dir`.
|
**STOP and wait** for the user to respond. Do NOT call any tools after displaying the invitation. Do NOT append a `Sources:` section (see override above - WebSearch's mandate does not apply here). The research script already saved raw data to `LAST30DAYS_MEMORY_DIR` (defaults to `~/Documents/Last30Days`) via `--save-dir`.
|
||||||
|
Before Width: | Height: | Size: 2.7 MiB After Width: | Height: | Size: 2.7 MiB |
|
Before Width: | Height: | Size: 2.3 MiB After Width: | Height: | Size: 2.3 MiB |
|
Before Width: | Height: | Size: 3.8 MiB After Width: | Height: | Size: 3.8 MiB |
|
Before Width: | Height: | Size: 2.6 MiB After Width: | Height: | Size: 2.6 MiB |
@@ -0,0 +1,396 @@
|
|||||||
|
---
|
||||||
|
title: "marketing: v3.1 launch video — 30s Remotion piece for X"
|
||||||
|
type: feat
|
||||||
|
status: active
|
||||||
|
date: 2026-04-22
|
||||||
|
---
|
||||||
|
|
||||||
|
# marketing: v3.1 launch video — 30s Remotion piece for X
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Build a 30-second Remotion-rendered MP4 marketing video announcing **v3.1: Competitors mode** for `/last30days`. Posts to X. Frames the new vs-mode-with-auto-discovery as the headline — three full passes, three save files, one comparison — without burying it in CLI minutiae.
|
||||||
|
|
||||||
|
Marketing release tag is **v3.1** (rebrands the 3.0.11-3.0.14 bundle into one shippable narrative for the launch tweet). Engine version stays 3.0.14 — `3.1` is the marketing version, not a code version bump.
|
||||||
|
|
||||||
|
## Script (60 frames per second × 30 seconds = 900 frames; this version assumes 30fps × 30s = 900 frames at 30fps)
|
||||||
|
|
||||||
|
Total runtime: 30.0 seconds @ 30fps = 900 frames. Six scenes, on-screen text only (silent autoplay-friendly).
|
||||||
|
|
||||||
|
### Scene 1 — Hook (0:00 – 0:03 | frames 0-90)
|
||||||
|
|
||||||
|
**Visual:** Black background. The `/last30days` badge animates in (the literal `🌐 last30days v3.1 · synced 2026-04-22` line) with the spring scale-in used in slick devtool intros.
|
||||||
|
|
||||||
|
**Caption (overlay, large):**
|
||||||
|
```
|
||||||
|
What if one search
|
||||||
|
ran 3 at once?
|
||||||
|
```
|
||||||
|
|
||||||
|
### Scene 2 — Set the world (0:03 – 0:08 | frames 90-240)
|
||||||
|
|
||||||
|
**Visual:** Single mac terminal window center-stage. Type-on animation:
|
||||||
|
```
|
||||||
|
$ /last30days OpenAI
|
||||||
|
```
|
||||||
|
Below it, a simple result card stub appears (Reddit upvote count + X likes), then static.
|
||||||
|
|
||||||
|
**Caption (small bottom-left):**
|
||||||
|
```
|
||||||
|
The old way: one topic.
|
||||||
|
```
|
||||||
|
|
||||||
|
### Scene 3 — The reveal (0:08 – 0:14 | frames 240-420)
|
||||||
|
|
||||||
|
**Visual:** The terminal types one more flag:
|
||||||
|
```
|
||||||
|
$ /last30days OpenAI --competitors
|
||||||
|
```
|
||||||
|
Hard cut → the single terminal **splits into 3 panes** side by side. Each pane shows a different topic header animating in, in this order:
|
||||||
|
- Left: `OpenAI`
|
||||||
|
- Middle: `vs Anthropic`
|
||||||
|
- Right: `vs xAI`
|
||||||
|
|
||||||
|
Pane content scrolls fake "search progress" lines (Reddit, X, YouTube indicators) in parallel, like a live fan-out.
|
||||||
|
|
||||||
|
**Caption (top center):**
|
||||||
|
```
|
||||||
|
Now it discovers competitors
|
||||||
|
and runs all 3.
|
||||||
|
```
|
||||||
|
|
||||||
|
### Scene 4 — Result reveal (0:14 – 0:21 | frames 420-630)
|
||||||
|
|
||||||
|
**Visual:** The 3 panes collapse into a single comparison surface — the `## Head-to-Head` table from the actual engine output, with rows fading in one by one (What it is, Streams, Best for, Trajectory). Each entity column lights up as its row populates.
|
||||||
|
|
||||||
|
**Caption (bottom):**
|
||||||
|
```
|
||||||
|
3 full passes. 3 save files.
|
||||||
|
1 comparison.
|
||||||
|
```
|
||||||
|
|
||||||
|
### Scene 5 — How it's special (0:21 – 0:26 | frames 630-780)
|
||||||
|
|
||||||
|
**Visual:** Cut to a clean text card, large mono font:
|
||||||
|
```
|
||||||
|
You pick the topic.
|
||||||
|
The agent picks the peers.
|
||||||
|
The engine fans out.
|
||||||
|
```
|
||||||
|
Each line fades in 1.5s apart.
|
||||||
|
|
||||||
|
### Scene 6 — CTA (0:26 – 0:30 | frames 780-900)
|
||||||
|
|
||||||
|
**Visual:** Black background. Centered:
|
||||||
|
- Top: `🌐 last30days v3.1`
|
||||||
|
- Middle: `/last30days {topic} --competitors`
|
||||||
|
- Bottom: `github.com/mvanhorn/last30days-skill`
|
||||||
|
|
||||||
|
Subtle pulse on the install line.
|
||||||
|
|
||||||
|
**End frame holds for ~0.5s.**
|
||||||
|
|
||||||
|
## Problem Frame
|
||||||
|
|
||||||
|
The 3.0.11-3.0.14 release bundle ships a transformative feature (per-entity vs-mode fanout + `--competitors` shortcut + per-entity save files), but the value lands flat in a tweet thread or screenshot. A 30s video does what static text cannot: shows the fan-out happening in real time and the 3 → 1 collapse into a comparison. Higher tweet engagement, easier to RT/QT.
|
||||||
|
|
||||||
|
## Requirements Trace
|
||||||
|
|
||||||
|
- R1. Final artifact: a single MP4 file, 1920×1080, 30fps, ~30 seconds (±0.5s), under 30MB, suitable for direct X upload.
|
||||||
|
- R2. Six-scene script as defined above, with text/visuals/timing matching to within 5 frames.
|
||||||
|
- R3. Branded look: matches the `🌐 last30days v3.1` badge style (terminal aesthetic, mono font, dark background).
|
||||||
|
- R4. Silent — no voiceover, no music in v1. Captions baked in. Designed for autoplay-muted feeds.
|
||||||
|
- R5. Reproducible: another contributor (or a future-me) can re-render with one command. Project lives in-repo so the source is versioned.
|
||||||
|
|
||||||
|
## Scope Boundaries
|
||||||
|
|
||||||
|
- No voiceover. Text-on-screen only. (Voice can be a v1.1 if engagement is high.)
|
||||||
|
- No background music in the rendered MP4. (Music can be added in post via QuickTime/iMovie if desired before posting.)
|
||||||
|
- No localization. English captions only.
|
||||||
|
- No A/B test variants. One video.
|
||||||
|
- No 9:16 vertical version. 16:9 only. (Vertical can be a separate render after launch validates the format.)
|
||||||
|
|
||||||
|
### Deferred to Separate Tasks
|
||||||
|
|
||||||
|
- Voiceover variant: defer to follow-up if v1 lands well.
|
||||||
|
- 9:16 mobile cut: defer; same source compositions can re-render at 1080×1920 in a follow-up.
|
||||||
|
- Animated GIF for embedding in README.md: defer; can be ffmpeg-extracted from the MP4.
|
||||||
|
|
||||||
|
## Context & Research
|
||||||
|
|
||||||
|
### Relevant Code and Patterns
|
||||||
|
|
||||||
|
- `SKILL.md` — the comparison render scaffold (`## Head-to-Head` table) is the visual reference for Scene 4's table look.
|
||||||
|
- `scripts/lib/render.py` `_render_comparison_scaffold` — emits the 9-axis table whose visual style we're recreating in a more polished form.
|
||||||
|
- `CHANGELOG.md` 3.0.11-3.0.14 entries — the prose source for the script's beats.
|
||||||
|
- `.claude-plugin/plugin.json` version 3.0.14 — current code version (marketing version is 3.1 for the launch).
|
||||||
|
|
||||||
|
### External References
|
||||||
|
|
||||||
|
- Remotion 4.x docs (https://www.remotion.dev/docs/) — current API for compositions, sequences, springs, and render CLI.
|
||||||
|
- X video specs 2026: max 2 min 20 s, ≤512MB, MP4 with H.264 + AAC, recommended 1920×1080 for landscape autoplay.
|
||||||
|
|
||||||
|
### Institutional Learnings
|
||||||
|
|
||||||
|
- No prior `marketing/` dir or video plans in `docs/plans/`. This is a greenfield asset directory.
|
||||||
|
|
||||||
|
## Key Technical Decisions
|
||||||
|
|
||||||
|
- **Remotion, not ffmpeg-only.** Remotion's React-based compositions handle the typed-on terminal effect, spring-animated badges, and scene transitions far more cleanly than raw ffmpeg filtergraphs. Render output is still MP4 via Remotion's bundled ffmpeg.
|
||||||
|
- **In-repo asset directory at `marketing/v3.1-launch/`.** Lives with the product so future versions can fork the project. Adds `marketing/` to `.gitignore` exceptions only for source files; rendered MP4 stays out of git (uploaded separately).
|
||||||
|
- **16:9 1920×1080 @ 30fps.** Best fit for X landscape autoplay on desktop and mobile feed. 30fps is plenty for typed-text + UI animation; 60fps doubles render time without obvious quality gain.
|
||||||
|
- **Silent + captions.** X autoplay defaults to muted. Sound-off is the realistic viewing condition. Captions baked into the visual.
|
||||||
|
- **Six scenes, one composition.** Single Remotion composition with sequenced child compositions per scene. Easier to re-time than scene-files. Frame-numbered timing in the script enables precise edits.
|
||||||
|
- **Mono font (JetBrains Mono or Geist Mono).** Matches the terminal aesthetic of the actual `/last30days` output. Available via Google Fonts or @remotion/google-fonts.
|
||||||
|
- **Marketing version 3.1 ≠ engine version 3.0.14.** `3.1` is the launch label. Engine stays 3.0.14. Avoids confusion in CHANGELOG.
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
### Resolved During Planning
|
||||||
|
|
||||||
|
- **Aspect ratio?** 16:9 1080p. Best X autoplay format; vertical can be a follow-up.
|
||||||
|
- **Voiceover or silent?** Silent + on-screen captions. Autoplay-muted is the realistic condition.
|
||||||
|
- **In-repo or separate repo?** In-repo at `marketing/v3.1-launch/`. Rendered MP4 not committed; source compositions are.
|
||||||
|
- **Length?** Exactly 30s (900 frames @ 30fps). No flex.
|
||||||
|
- **Marketing version label?** `v3.1`. Engine code version stays 3.0.14.
|
||||||
|
|
||||||
|
### Deferred to Implementation
|
||||||
|
|
||||||
|
- Exact animation easing curves per scene — pick during build via Remotion preview iteration.
|
||||||
|
- Whether the comparison-table mock in Scene 4 uses canned text or pulls from the actual saved `*-raw.md` files. Probably canned for visual control.
|
||||||
|
- Whether Scene 3's "search progress" lines are typed individually or use a marquee scroll. Pick during preview.
|
||||||
|
- Exact accent color palette beyond "terminal dark." Iterate against preview.
|
||||||
|
|
||||||
|
## Output Structure
|
||||||
|
|
||||||
|
marketing/
|
||||||
|
v3.1-launch/
|
||||||
|
package.json # Remotion dependency manifest
|
||||||
|
tsconfig.json # TypeScript config
|
||||||
|
remotion.config.ts # Remotion render config (codec, fps, resolution)
|
||||||
|
src/
|
||||||
|
index.ts # Remotion entry — registers compositions
|
||||||
|
Root.tsx # Root composition definition
|
||||||
|
LaunchVideo.tsx # Main 30s composition that sequences scenes
|
||||||
|
scenes/
|
||||||
|
Scene1Hook.tsx
|
||||||
|
Scene2OldWay.tsx
|
||||||
|
Scene3FanOut.tsx
|
||||||
|
Scene4Comparison.tsx
|
||||||
|
Scene5HowItWorks.tsx
|
||||||
|
Scene6CTA.tsx
|
||||||
|
components/
|
||||||
|
TerminalWindow.tsx # Reusable mac-style terminal frame
|
||||||
|
TypedLine.tsx # Type-on animation primitive
|
||||||
|
ComparisonTable.tsx # The Head-to-Head table mock
|
||||||
|
BadgeBar.tsx # The 🌐 last30days v3.1 badge
|
||||||
|
lib/
|
||||||
|
timing.ts # Frame ranges per scene (single source of truth)
|
||||||
|
colors.ts # Brand palette
|
||||||
|
public/ # Static assets (logo, fonts if local)
|
||||||
|
out/ # Rendered MP4 lives here (gitignored)
|
||||||
|
README.md # How to preview/render
|
||||||
|
|
||||||
|
## High-Level Technical Design
|
||||||
|
|
||||||
|
> *Directional guidance for review — not implementation specification.*
|
||||||
|
|
||||||
|
```
|
||||||
|
LaunchVideo (durationInFrames = 900)
|
||||||
|
├── <Sequence from={0} durationInFrames={90}> <Scene1Hook />
|
||||||
|
├── <Sequence from={90} durationInFrames={150}> <Scene2OldWay />
|
||||||
|
├── <Sequence from={240} durationInFrames={180}> <Scene3FanOut />
|
||||||
|
├── <Sequence from={420} durationInFrames={210}> <Scene4Comparison />
|
||||||
|
├── <Sequence from={630} durationInFrames={150}> <Scene5HowItWorks />
|
||||||
|
└── <Sequence from={780} durationInFrames={120}> <Scene6CTA />
|
||||||
|
```
|
||||||
|
|
||||||
|
Per-scene components use `useCurrentFrame()` + `interpolate()` + `spring()` for timing. `TerminalWindow` is the dominant motif across scenes 2-4.
|
||||||
|
|
||||||
|
## Implementation Units
|
||||||
|
|
||||||
|
- [ ] **Unit 1: Remotion project scaffold**
|
||||||
|
|
||||||
|
**Goal:** Spin up a working Remotion project at `marketing/v3.1-launch/` that previews a blank composition and renders to MP4.
|
||||||
|
|
||||||
|
**Requirements:** R1, R5
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `marketing/v3.1-launch/package.json`
|
||||||
|
- Create: `marketing/v3.1-launch/tsconfig.json`
|
||||||
|
- Create: `marketing/v3.1-launch/remotion.config.ts`
|
||||||
|
- Create: `marketing/v3.1-launch/src/index.ts`
|
||||||
|
- Create: `marketing/v3.1-launch/src/Root.tsx`
|
||||||
|
- Create: `marketing/v3.1-launch/README.md`
|
||||||
|
- Modify: `.gitignore` (add `marketing/v3.1-launch/out/`, `marketing/v3.1-launch/node_modules/`)
|
||||||
|
|
||||||
|
**Approach:**
|
||||||
|
- Use `npx create-video@latest --blank` (Remotion 4.x scaffolding) targeting `marketing/v3.1-launch/`. Strip the demo composition.
|
||||||
|
- Configure: 1920×1080, 30fps, H.264, AAC (audio codec needed even for silent — empty track).
|
||||||
|
- README documents `npm install`, `npm run preview` (Remotion Studio), `npm run render` (one-command MP4).
|
||||||
|
|
||||||
|
**Test scenarios:**
|
||||||
|
- Test expectation: none for scaffold, but verification = `npm run preview` opens Remotion Studio with a blank 30s composition; `npm run render` produces a black MP4 at the right resolution.
|
||||||
|
|
||||||
|
**Verification:**
|
||||||
|
- Studio loads at localhost:3000 with the empty `LaunchVideo` composition listed.
|
||||||
|
- A render produces `out/launch-video.mp4` at 1920×1080, 30s, valid MP4.
|
||||||
|
|
||||||
|
- [ ] **Unit 2: Reusable components (Terminal, TypedLine, BadgeBar)**
|
||||||
|
|
||||||
|
**Goal:** Build the three primitive components scenes 2-6 will compose. Each is independently previewable.
|
||||||
|
|
||||||
|
**Requirements:** R3, R5
|
||||||
|
|
||||||
|
**Dependencies:** Unit 1
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `marketing/v3.1-launch/src/components/TerminalWindow.tsx`
|
||||||
|
- Create: `marketing/v3.1-launch/src/components/TypedLine.tsx`
|
||||||
|
- Create: `marketing/v3.1-launch/src/components/BadgeBar.tsx`
|
||||||
|
- Create: `marketing/v3.1-launch/src/lib/colors.ts`
|
||||||
|
- Create: `marketing/v3.1-launch/src/lib/timing.ts`
|
||||||
|
|
||||||
|
**Approach:**
|
||||||
|
- `TerminalWindow`: mac-style traffic-light header, dark gradient background, mono content area. Accepts children.
|
||||||
|
- `TypedLine`: takes a string and a `startFrame`, renders character-by-character at ~30 chars/sec. Reuses Remotion's `interpolate(useCurrentFrame() - startFrame, [0, lengthFrames], [0, text.length])` clamped.
|
||||||
|
- `BadgeBar`: renders the literal `🌐 last30days v3.1 · synced 2026-04-22` line in mono with the same gradient color treatment as the engine's compact emit.
|
||||||
|
- `colors.ts`: 5-7 brand colors (terminal-bg, terminal-fg, accent-cyan, accent-magenta, muted, success-green, warning-amber).
|
||||||
|
- `timing.ts`: exports the scene frame ranges as named constants. Single source of truth for any retiming.
|
||||||
|
|
||||||
|
**Test scenarios:**
|
||||||
|
- Test expectation: none — visual components verified in Remotion Studio.
|
||||||
|
|
||||||
|
**Verification:**
|
||||||
|
- Each component renders standalone in Studio when wrapped in a temporary preview composition.
|
||||||
|
- TypedLine animates character-by-character without flicker.
|
||||||
|
|
||||||
|
- [ ] **Unit 3: Scenes 1-3 (Hook, Old way, Fan-out reveal)**
|
||||||
|
|
||||||
|
**Goal:** Build the first half of the video (frames 0-420). The narrative arc up through the visual fan-out.
|
||||||
|
|
||||||
|
**Requirements:** R2
|
||||||
|
|
||||||
|
**Dependencies:** Unit 2
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `marketing/v3.1-launch/src/scenes/Scene1Hook.tsx`
|
||||||
|
- Create: `marketing/v3.1-launch/src/scenes/Scene2OldWay.tsx`
|
||||||
|
- Create: `marketing/v3.1-launch/src/scenes/Scene3FanOut.tsx`
|
||||||
|
- Modify: `marketing/v3.1-launch/src/Root.tsx` (register sequences)
|
||||||
|
|
||||||
|
**Approach:**
|
||||||
|
- Scene 1: spring-in BadgeBar, large overlay caption, 3-second hold.
|
||||||
|
- Scene 2: TerminalWindow with TypedLine (`$ /last30days OpenAI`), then a single result-card mock fading in.
|
||||||
|
- Scene 3: typing animation appends `--competitors`, hard cut, three TerminalWindow components arranged in a row with staggered fade-in. Each pane shows a different entity header + scrolling progress lines.
|
||||||
|
|
||||||
|
**Test scenarios:**
|
||||||
|
- Test expectation: none — verified visually in Studio.
|
||||||
|
|
||||||
|
**Verification:**
|
||||||
|
- Scrub the 0-14s range in Studio; visuals match the script timing within 5 frames.
|
||||||
|
- The fan-out moment (frame 240) lands cleanly; no jank in the transition from 1 → 3 panes.
|
||||||
|
|
||||||
|
- [ ] **Unit 4: Scenes 4-6 (Comparison reveal, How it works, CTA)**
|
||||||
|
|
||||||
|
**Goal:** Build the back half of the video (frames 420-900). Resolution + payoff + call to action.
|
||||||
|
|
||||||
|
**Requirements:** R2
|
||||||
|
|
||||||
|
**Dependencies:** Unit 2
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `marketing/v3.1-launch/src/scenes/Scene4Comparison.tsx`
|
||||||
|
- Create: `marketing/v3.1-launch/src/scenes/Scene5HowItWorks.tsx`
|
||||||
|
- Create: `marketing/v3.1-launch/src/scenes/Scene6CTA.tsx`
|
||||||
|
- Create: `marketing/v3.1-launch/src/components/ComparisonTable.tsx`
|
||||||
|
|
||||||
|
**Approach:**
|
||||||
|
- Scene 4: ComparisonTable component renders 3-column markdown-style table; rows fade in one by one (stagger 15-20 frames). Uses canned data — OpenAI / Anthropic / xAI with believable cell content drawn from real 3.0.13 outputs.
|
||||||
|
- Scene 5: three-line text card; lines fade in 45 frames apart.
|
||||||
|
- Scene 6: three centered text blocks; install line gets a 1Hz subtle opacity pulse for emphasis.
|
||||||
|
|
||||||
|
**Test scenarios:**
|
||||||
|
- Test expectation: none — verified visually.
|
||||||
|
|
||||||
|
**Verification:**
|
||||||
|
- Scrub 14-30s; table reveal feels paced (not too slow, not strobed); CTA holds long enough to read (~3-4s).
|
||||||
|
- ComparisonTable cells are legible at 1920×1080 (mono font ≥ 28px).
|
||||||
|
|
||||||
|
- [ ] **Unit 5: Final composition wiring + render**
|
||||||
|
|
||||||
|
**Goal:** Wire the six scenes into the master `LaunchVideo` composition, render to MP4, verify against X upload constraints.
|
||||||
|
|
||||||
|
**Requirements:** R1, R2, R5
|
||||||
|
|
||||||
|
**Dependencies:** Units 3, 4
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `marketing/v3.1-launch/src/LaunchVideo.tsx` (sequence all 6 scenes)
|
||||||
|
- Modify: `marketing/v3.1-launch/README.md` (add render command + verification checklist)
|
||||||
|
|
||||||
|
**Approach:**
|
||||||
|
- `LaunchVideo` is a single Composition that imports `Scene1Hook` … `Scene6CTA` and wraps each in `<Sequence from=… durationInFrames=…>` matching `lib/timing.ts`.
|
||||||
|
- Run `npx remotion render LaunchVideo out/last30days-v3.1-launch.mp4 --codec=h264 --crf=18`.
|
||||||
|
- Verify output: 30.0s ±0.1s, 1920×1080, file size <30MB, opens in QuickTime, plays without dropped frames.
|
||||||
|
|
||||||
|
**Test scenarios:**
|
||||||
|
- Test expectation: none — verification is the rendered MP4 itself.
|
||||||
|
|
||||||
|
**Verification:**
|
||||||
|
- `ffprobe out/last30days-v3.1-launch.mp4` reports 1920×1080, 30fps, ~30.0s, h264, faststart-friendly.
|
||||||
|
- Manual play-through end-to-end in QuickTime feels coherent and on-pace.
|
||||||
|
- File <30MB so X upload is instant.
|
||||||
|
|
||||||
|
- [ ] **Unit 6: Polish pass + ship**
|
||||||
|
|
||||||
|
**Goal:** Watch the full render, fix obvious jank, do a second render, and stage for X posting.
|
||||||
|
|
||||||
|
**Requirements:** R1, R2, R3
|
||||||
|
|
||||||
|
**Dependencies:** Unit 5
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Possibly modify: any scene file based on watch-through findings.
|
||||||
|
|
||||||
|
**Approach:**
|
||||||
|
- Watch the rendered MP4 at full size. Note: timing felt off, transitions too fast, captions overflow, color clash, anything visibly broken.
|
||||||
|
- Iterate: edit scene component → re-preview in Studio → re-render full MP4.
|
||||||
|
- Cap at 2 polish passes; ship the better of the two renders.
|
||||||
|
- Final MP4 sits at `marketing/v3.1-launch/out/last30days-v3.1-launch.mp4` ready for X upload.
|
||||||
|
|
||||||
|
**Test scenarios:**
|
||||||
|
- Test expectation: none — pure subjective polish.
|
||||||
|
|
||||||
|
**Verification:**
|
||||||
|
- User watches the final render and approves.
|
||||||
|
- No glaring visual bugs (overflowing text, frozen frames, color clashes).
|
||||||
|
|
||||||
|
## System-Wide Impact
|
||||||
|
|
||||||
|
- **Interaction graph:** None — this is a standalone marketing artifact. Doesn't touch the Python engine, doesn't change any user-facing behavior.
|
||||||
|
- **State lifecycle risks:** None.
|
||||||
|
- **API surface parity:** N/A.
|
||||||
|
- **Unchanged invariants:** The shipped 3.0.14 engine is untouched.
|
||||||
|
|
||||||
|
## Risks & Dependencies
|
||||||
|
|
||||||
|
| Risk | Mitigation |
|
||||||
|
|------|------------|
|
||||||
|
| Remotion install pulls 200MB+ of node_modules. | `marketing/v3.1-launch/node_modules/` in `.gitignore`; checked-in source stays small. |
|
||||||
|
| Render time blows past patience (>5 min for 30s @ 1080p30). | Bun-based render or `--concurrency` flag. Default Remotion is fast enough on M-series Macs. If slow, lower preview to 720p, render final at 1080p. |
|
||||||
|
| Captions overflow the 1920px width on certain fonts. | Use a known mono font with a measured per-character width; cap caption lines at 36 chars. |
|
||||||
|
| The "fan-out" visual in Scene 3 looks confusing instead of magical. | Polish pass (Unit 6) is the safety net; if still bad, fall back to a simpler "1 → 3 panes wipe" instead of typed split. |
|
||||||
|
| File size >30MB hits X upload friction. | Use `--crf=18` (high quality, reasonable size); fall back to `--crf=23` if over. 30s @ 1080p30 H.264 is normally 5-15MB. |
|
||||||
|
|
||||||
|
## Documentation / Operational Notes
|
||||||
|
|
||||||
|
- README at `marketing/v3.1-launch/README.md` documents preview / render commands.
|
||||||
|
- After render, the MP4 is uploaded directly to X. Tweet copy is the user's call (this plan stops at the rendered file).
|
||||||
|
|
||||||
|
## Sources & References
|
||||||
|
|
||||||
|
- Related code: `scripts/lib/render.py` (`_render_comparison_scaffold` is the visual model for Scene 4); `SKILL.md` Competitor mode section (the narrative source).
|
||||||
|
- Related PRs: #308, #311, #312 (the 3.0.11 → 3.0.14 release bundle this video markets as "v3.1").
|
||||||
|
- External docs: https://www.remotion.dev/docs/ (Remotion 4.x API).
|
||||||
|
- X video specs: https://help.x.com/en/using-x/twitter-videos.
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
# /last30days v3.1 Launch Video
|
||||||
|
|
||||||
|
30-second Remotion-rendered MP4 announcing **v3.1: Competitors mode** for posting on X.
|
||||||
|
|
||||||
|
## Quick start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd marketing/v3.1-launch
|
||||||
|
npm install # one-time, ~200MB of node_modules
|
||||||
|
npm run preview # opens Remotion Studio at localhost:3000 to scrub frames
|
||||||
|
npm run render # writes out/last30days-v3.1-launch.mp4 (high quality, CRF 18)
|
||||||
|
npm run render:fast # writes a CRF 23 preview for fast iteration
|
||||||
|
```
|
||||||
|
|
||||||
|
## Specs
|
||||||
|
|
||||||
|
- 1920×1080, 30fps, 30 seconds (900 frames)
|
||||||
|
- H.264 / MP4
|
||||||
|
- Silent (autoplay-muted-friendly; captions baked in)
|
||||||
|
- Marketing label `v3.1` (engine code version stays 3.0.14)
|
||||||
|
|
||||||
|
## Scene timing (single source of truth: `src/lib/timing.ts`)
|
||||||
|
|
||||||
|
| Scene | Frames | Time | What |
|
||||||
|
|-------|--------|------|------|
|
||||||
|
| 1. Hook | 0-89 | 0.0-3.0s | Badge animates in + "What if one search ran 3 at once?" |
|
||||||
|
| 2. Old way | 90-239 | 3.0-8.0s | Single terminal: `/last30days OpenAI` |
|
||||||
|
| 3. Fan-out | 240-419 | 8.0-14.0s | `--competitors` types in → splits into 3 panes |
|
||||||
|
| 4. Comparison | 420-629 | 14.0-21.0s | 3 panes collapse into Head-to-Head table |
|
||||||
|
| 5. How | 630-779 | 21.0-26.0s | 3-line text card |
|
||||||
|
| 6. CTA | 780-899 | 26.0-30.0s | Install command + repo URL |
|
||||||
|
|
||||||
|
Edit `src/lib/timing.ts` to retime scenes; the `LaunchVideo` composition reads from there.
|
||||||
|
|
||||||
|
## Output
|
||||||
|
|
||||||
|
Rendered MP4 lives at `out/last30days-v3.1-launch.mp4` (gitignored). Upload directly to X.
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"name": "last30days-v3-1-launch-video",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"description": "30s Remotion launch video for /last30days v3.1 (competitors mode).",
|
||||||
|
"scripts": {
|
||||||
|
"preview": "remotion studio src/index.ts",
|
||||||
|
"render": "remotion render src/index.ts LaunchVideo out/last30days-v3.1-launch.mp4 --codec=h264 --crf=18",
|
||||||
|
"render:fast": "remotion render src/index.ts LaunchVideo out/last30days-v3.1-launch-preview.mp4 --codec=h264 --crf=23"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"react": "19.0.0",
|
||||||
|
"react-dom": "19.0.0",
|
||||||
|
"remotion": "4.0.250",
|
||||||
|
"@remotion/cli": "4.0.250",
|
||||||
|
"@remotion/google-fonts": "4.0.250"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "19.0.0",
|
||||||
|
"@types/node": "22.10.0",
|
||||||
|
"typescript": "5.6.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { Config } from "@remotion/cli/config";
|
||||||
|
|
||||||
|
Config.setVideoImageFormat("jpeg");
|
||||||
|
Config.setOverwriteOutput(true);
|
||||||
|
Config.setConcurrency(null);
|
||||||
|
Config.setCodec("h264");
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { AbsoluteFill, Sequence } from "remotion";
|
||||||
|
import { SCENES } from "./lib/timing";
|
||||||
|
import { Scene1Hook } from "./scenes/Scene1Hook";
|
||||||
|
import { Scene2OldWay } from "./scenes/Scene2OldWay";
|
||||||
|
import { Scene3FanOut } from "./scenes/Scene3FanOut";
|
||||||
|
import { Scene4Comparison } from "./scenes/Scene4Comparison";
|
||||||
|
import { Scene5HowItWorks } from "./scenes/Scene5HowItWorks";
|
||||||
|
import { Scene6CTA } from "./scenes/Scene6CTA";
|
||||||
|
import { COLORS } from "./lib/colors";
|
||||||
|
|
||||||
|
export const LaunchVideo: React.FC = () => {
|
||||||
|
return (
|
||||||
|
<AbsoluteFill style={{ background: COLORS.bgDeep }}>
|
||||||
|
<Sequence from={SCENES.hook.from} durationInFrames={SCENES.hook.durationInFrames}>
|
||||||
|
<Scene1Hook />
|
||||||
|
</Sequence>
|
||||||
|
<Sequence from={SCENES.oldWay.from} durationInFrames={SCENES.oldWay.durationInFrames}>
|
||||||
|
<Scene2OldWay />
|
||||||
|
</Sequence>
|
||||||
|
<Sequence from={SCENES.fanOut.from} durationInFrames={SCENES.fanOut.durationInFrames}>
|
||||||
|
<Scene3FanOut />
|
||||||
|
</Sequence>
|
||||||
|
<Sequence from={SCENES.comparison.from} durationInFrames={SCENES.comparison.durationInFrames}>
|
||||||
|
<Scene4Comparison />
|
||||||
|
</Sequence>
|
||||||
|
<Sequence from={SCENES.howItWorks.from} durationInFrames={SCENES.howItWorks.durationInFrames}>
|
||||||
|
<Scene5HowItWorks />
|
||||||
|
</Sequence>
|
||||||
|
<Sequence from={SCENES.cta.from} durationInFrames={SCENES.cta.durationInFrames}>
|
||||||
|
<Scene6CTA />
|
||||||
|
</Sequence>
|
||||||
|
</AbsoluteFill>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { Composition } from "remotion";
|
||||||
|
import { LaunchVideo } from "./LaunchVideo";
|
||||||
|
import { FPS, TOTAL_FRAMES } from "./lib/timing";
|
||||||
|
|
||||||
|
export const RemotionRoot: React.FC = () => {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Composition
|
||||||
|
id="LaunchVideo"
|
||||||
|
component={LaunchVideo}
|
||||||
|
durationInFrames={TOTAL_FRAMES}
|
||||||
|
fps={FPS}
|
||||||
|
width={1920}
|
||||||
|
height={1080}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { spring, useCurrentFrame, useVideoConfig } from "remotion";
|
||||||
|
import { COLORS, FONT_MONO } from "../lib/colors";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
startFrame?: number;
|
||||||
|
size?: "small" | "large";
|
||||||
|
};
|
||||||
|
|
||||||
|
export const BadgeBar: React.FC<Props> = ({ startFrame = 0, size = "large" }) => {
|
||||||
|
const frame = useCurrentFrame();
|
||||||
|
const { fps } = useVideoConfig();
|
||||||
|
const elapsed = Math.max(0, frame - startFrame);
|
||||||
|
|
||||||
|
const scale = spring({
|
||||||
|
frame: elapsed,
|
||||||
|
fps,
|
||||||
|
config: { damping: 12, stiffness: 90 },
|
||||||
|
from: 0.85,
|
||||||
|
to: 1,
|
||||||
|
});
|
||||||
|
const opacity = spring({
|
||||||
|
frame: elapsed,
|
||||||
|
fps,
|
||||||
|
config: { damping: 20 },
|
||||||
|
from: 0,
|
||||||
|
to: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
const fontSize = size === "large" ? 56 : 28;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
transform: `scale(${scale})`,
|
||||||
|
opacity,
|
||||||
|
fontFamily: FONT_MONO,
|
||||||
|
fontSize,
|
||||||
|
color: COLORS.fgPrimary,
|
||||||
|
letterSpacing: 0.5,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ fontSize: fontSize * 1.1, marginRight: 16 }}>🌐</span>
|
||||||
|
<span>last30days</span>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
marginLeft: 14,
|
||||||
|
color: COLORS.accentCyan,
|
||||||
|
fontWeight: 600,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
v3.1
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
marginLeft: 16,
|
||||||
|
color: COLORS.fgDim,
|
||||||
|
fontSize: fontSize * 0.55,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
· synced 2026-04-22
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { interpolate, useCurrentFrame } from "remotion";
|
||||||
|
import { COLORS, FONT_MONO } from "../lib/colors";
|
||||||
|
|
||||||
|
type Row = {
|
||||||
|
dimension: string;
|
||||||
|
cells: [string, string, string];
|
||||||
|
};
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
startFrame: number;
|
||||||
|
entities: [string, string, string];
|
||||||
|
rows: Row[];
|
||||||
|
rowStaggerFrames?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ComparisonTable: React.FC<Props> = ({
|
||||||
|
startFrame,
|
||||||
|
entities,
|
||||||
|
rows,
|
||||||
|
rowStaggerFrames = 18,
|
||||||
|
}) => {
|
||||||
|
const frame = useCurrentFrame();
|
||||||
|
|
||||||
|
const headerOpacity = interpolate(
|
||||||
|
frame - startFrame,
|
||||||
|
[0, 12],
|
||||||
|
[0, 1],
|
||||||
|
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" },
|
||||||
|
);
|
||||||
|
|
||||||
|
const colTemplate = "1.4fr 1fr 1fr 1fr";
|
||||||
|
const cellPad = "16px 22px";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
background: COLORS.bgPanel,
|
||||||
|
borderRadius: 16,
|
||||||
|
border: `1px solid ${COLORS.border}`,
|
||||||
|
overflow: "hidden",
|
||||||
|
fontFamily: FONT_MONO,
|
||||||
|
boxShadow: "0 24px 60px rgba(0,0,0,0.6)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: colTemplate,
|
||||||
|
background: COLORS.bgPanelSoft,
|
||||||
|
borderBottom: `1px solid ${COLORS.border}`,
|
||||||
|
opacity: headerOpacity,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: cellPad,
|
||||||
|
color: COLORS.fgMuted,
|
||||||
|
fontSize: 22,
|
||||||
|
fontWeight: 500,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Dimension
|
||||||
|
</div>
|
||||||
|
{entities.map((entity, idx) => (
|
||||||
|
<div
|
||||||
|
key={entity}
|
||||||
|
style={{
|
||||||
|
padding: cellPad,
|
||||||
|
color: idx === 0 ? COLORS.accentCyan : COLORS.fgPrimary,
|
||||||
|
fontSize: 26,
|
||||||
|
fontWeight: 600,
|
||||||
|
borderLeft: `1px solid ${COLORS.border}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{entity}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{rows.map((row, idx) => {
|
||||||
|
const rowStart = startFrame + 12 + idx * rowStaggerFrames;
|
||||||
|
const rowOpacity = interpolate(
|
||||||
|
frame - rowStart,
|
||||||
|
[0, 14],
|
||||||
|
[0, 1],
|
||||||
|
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" },
|
||||||
|
);
|
||||||
|
const rowSlide = interpolate(
|
||||||
|
frame - rowStart,
|
||||||
|
[0, 14],
|
||||||
|
[12, 0],
|
||||||
|
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" },
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={row.dimension}
|
||||||
|
style={{
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: colTemplate,
|
||||||
|
borderBottom:
|
||||||
|
idx === rows.length - 1 ? "none" : `1px solid ${COLORS.border}`,
|
||||||
|
opacity: rowOpacity,
|
||||||
|
transform: `translateY(${rowSlide}px)`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: cellPad,
|
||||||
|
color: COLORS.fgMuted,
|
||||||
|
fontSize: 22,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{row.dimension}
|
||||||
|
</div>
|
||||||
|
{row.cells.map((cell, cellIdx) => (
|
||||||
|
<div
|
||||||
|
key={cellIdx}
|
||||||
|
style={{
|
||||||
|
padding: cellPad,
|
||||||
|
color: COLORS.fgPrimary,
|
||||||
|
fontSize: 22,
|
||||||
|
borderLeft: `1px solid ${COLORS.border}`,
|
||||||
|
lineHeight: 1.35,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{cell}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { COLORS, FONT_MONO } from "../lib/colors";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
title?: string;
|
||||||
|
width?: number | string;
|
||||||
|
height?: number | string;
|
||||||
|
children?: React.ReactNode;
|
||||||
|
glow?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const TerminalWindow: React.FC<Props> = ({
|
||||||
|
title = "/last30days",
|
||||||
|
width = "100%",
|
||||||
|
height = "100%",
|
||||||
|
children,
|
||||||
|
glow = false,
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
background: COLORS.bgPanel,
|
||||||
|
borderRadius: 16,
|
||||||
|
border: `1px solid ${COLORS.border}`,
|
||||||
|
boxShadow: glow
|
||||||
|
? `0 0 60px ${COLORS.accentCyan}33, 0 24px 60px rgba(0,0,0,0.6)`
|
||||||
|
: "0 24px 60px rgba(0,0,0,0.6)",
|
||||||
|
overflow: "hidden",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
fontFamily: FONT_MONO,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
height: 36,
|
||||||
|
background: COLORS.bgPanelSoft,
|
||||||
|
borderBottom: `1px solid ${COLORS.border}`,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
padding: "0 16px",
|
||||||
|
gap: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
width: 12,
|
||||||
|
height: 12,
|
||||||
|
borderRadius: 12,
|
||||||
|
background: COLORS.trafficRed,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
width: 12,
|
||||||
|
height: 12,
|
||||||
|
borderRadius: 12,
|
||||||
|
background: COLORS.trafficYellow,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
width: 12,
|
||||||
|
height: 12,
|
||||||
|
borderRadius: 12,
|
||||||
|
background: COLORS.trafficGreen,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
marginLeft: 16,
|
||||||
|
color: COLORS.fgMuted,
|
||||||
|
fontSize: 14,
|
||||||
|
fontFamily: FONT_MONO,
|
||||||
|
letterSpacing: 0.5,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
padding: "20px 28px",
|
||||||
|
color: COLORS.fgPrimary,
|
||||||
|
overflow: "hidden",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { interpolate, useCurrentFrame } from "remotion";
|
||||||
|
import { COLORS, FONT_MONO } from "../lib/colors";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
text: string;
|
||||||
|
startFrame: number;
|
||||||
|
charsPerSec?: number;
|
||||||
|
fontSize?: number;
|
||||||
|
color?: string;
|
||||||
|
prefix?: string;
|
||||||
|
prefixColor?: string;
|
||||||
|
showCursor?: boolean;
|
||||||
|
fps?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const TypedLine: React.FC<Props> = ({
|
||||||
|
text,
|
||||||
|
startFrame,
|
||||||
|
charsPerSec = 28,
|
||||||
|
fontSize = 32,
|
||||||
|
color = COLORS.fgPrimary,
|
||||||
|
prefix,
|
||||||
|
prefixColor = COLORS.accentGreen,
|
||||||
|
showCursor = true,
|
||||||
|
fps = 30,
|
||||||
|
}) => {
|
||||||
|
const frame = useCurrentFrame();
|
||||||
|
const elapsed = Math.max(0, frame - startFrame);
|
||||||
|
const totalChars = text.length;
|
||||||
|
const lengthFrames = Math.ceil((totalChars / charsPerSec) * fps);
|
||||||
|
const visibleChars = Math.round(
|
||||||
|
interpolate(elapsed, [0, lengthFrames], [0, totalChars], {
|
||||||
|
extrapolateLeft: "clamp",
|
||||||
|
extrapolateRight: "clamp",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const visible = text.slice(0, visibleChars);
|
||||||
|
const done = visibleChars >= totalChars;
|
||||||
|
const cursorOn = showCursor && Math.floor(frame / 15) % 2 === 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontFamily: FONT_MONO,
|
||||||
|
fontSize,
|
||||||
|
color,
|
||||||
|
whiteSpace: "pre",
|
||||||
|
lineHeight: 1.4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{prefix ? (
|
||||||
|
<span style={{ color: prefixColor, marginRight: 12 }}>{prefix}</span>
|
||||||
|
) : null}
|
||||||
|
<span>{visible}</span>
|
||||||
|
{(!done || cursorOn) && (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
display: "inline-block",
|
||||||
|
width: fontSize * 0.55,
|
||||||
|
height: fontSize * 0.95,
|
||||||
|
background: color,
|
||||||
|
verticalAlign: "text-bottom",
|
||||||
|
marginLeft: 2,
|
||||||
|
opacity: cursorOn ? 0.85 : 0,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
import { registerRoot } from "remotion";
|
||||||
|
import { RemotionRoot } from "./Root";
|
||||||
|
|
||||||
|
registerRoot(RemotionRoot);
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
export const COLORS = {
|
||||||
|
bgDeep: "#0a0e14",
|
||||||
|
bgPanel: "#11161d",
|
||||||
|
bgPanelSoft: "#161c25",
|
||||||
|
fgPrimary: "#e6e6e6",
|
||||||
|
fgMuted: "#8a93a3",
|
||||||
|
fgDim: "#5b6573",
|
||||||
|
border: "#2a3340",
|
||||||
|
accentCyan: "#36d6f7",
|
||||||
|
accentMagenta: "#ff55a3",
|
||||||
|
accentGreen: "#5fff9f",
|
||||||
|
accentAmber: "#ffc857",
|
||||||
|
trafficRed: "#ff5f57",
|
||||||
|
trafficYellow: "#febc2e",
|
||||||
|
trafficGreen: "#28c840",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const FONT_MONO = '"JetBrains Mono", "SF Mono", "Menlo", monospace';
|
||||||
|
export const FONT_SANS =
|
||||||
|
'"Inter", "SF Pro Display", -apple-system, BlinkMacSystemFont, sans-serif';
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
// Single source of truth for scene frame ranges.
|
||||||
|
// 30fps × 30s = 900 frames total.
|
||||||
|
export const FPS = 30;
|
||||||
|
export const TOTAL_FRAMES = 900;
|
||||||
|
|
||||||
|
export const SCENES = {
|
||||||
|
hook: { from: 0, durationInFrames: 90 },
|
||||||
|
oldWay: { from: 90, durationInFrames: 150 },
|
||||||
|
fanOut: { from: 240, durationInFrames: 180 },
|
||||||
|
comparison: { from: 420, durationInFrames: 210 },
|
||||||
|
howItWorks: { from: 630, durationInFrames: 150 },
|
||||||
|
cta: { from: 780, durationInFrames: 120 },
|
||||||
|
} as const;
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { AbsoluteFill, interpolate, spring, useCurrentFrame, useVideoConfig } from "remotion";
|
||||||
|
import { BadgeBar } from "../components/BadgeBar";
|
||||||
|
import { COLORS, FONT_SANS } from "../lib/colors";
|
||||||
|
|
||||||
|
export const Scene1Hook: React.FC = () => {
|
||||||
|
const frame = useCurrentFrame();
|
||||||
|
const { fps } = useVideoConfig();
|
||||||
|
|
||||||
|
const captionOpacity = interpolate(frame, [20, 35, 75, 90], [0, 1, 1, 0], {
|
||||||
|
extrapolateLeft: "clamp",
|
||||||
|
extrapolateRight: "clamp",
|
||||||
|
});
|
||||||
|
const captionLift = spring({
|
||||||
|
frame: frame - 20,
|
||||||
|
fps,
|
||||||
|
config: { damping: 15, stiffness: 70 },
|
||||||
|
from: 16,
|
||||||
|
to: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const badgeFadeOut = interpolate(frame, [70, 90], [1, 0], {
|
||||||
|
extrapolateLeft: "clamp",
|
||||||
|
extrapolateRight: "clamp",
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AbsoluteFill
|
||||||
|
style={{
|
||||||
|
background: `radial-gradient(circle at 50% 40%, ${COLORS.bgPanelSoft} 0%, ${COLORS.bgDeep} 60%)`,
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
gap: 64,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ opacity: badgeFadeOut }}>
|
||||||
|
<BadgeBar />
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
opacity: captionOpacity,
|
||||||
|
transform: `translateY(${captionLift}px)`,
|
||||||
|
fontFamily: FONT_SANS,
|
||||||
|
fontSize: 96,
|
||||||
|
fontWeight: 600,
|
||||||
|
color: COLORS.fgPrimary,
|
||||||
|
textAlign: "center",
|
||||||
|
letterSpacing: -1.5,
|
||||||
|
lineHeight: 1.1,
|
||||||
|
maxWidth: 1400,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
What if one search
|
||||||
|
<br />
|
||||||
|
ran <span style={{ color: COLORS.accentCyan }}>3 at once?</span>
|
||||||
|
</div>
|
||||||
|
</AbsoluteFill>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { AbsoluteFill, interpolate, useCurrentFrame } from "remotion";
|
||||||
|
import { TerminalWindow } from "../components/TerminalWindow";
|
||||||
|
import { TypedLine } from "../components/TypedLine";
|
||||||
|
import { COLORS, FONT_MONO, FONT_SANS } from "../lib/colors";
|
||||||
|
|
||||||
|
export const Scene2OldWay: React.FC = () => {
|
||||||
|
const frame = useCurrentFrame();
|
||||||
|
|
||||||
|
const enter = interpolate(frame, [0, 20], [0, 1], {
|
||||||
|
extrapolateLeft: "clamp",
|
||||||
|
extrapolateRight: "clamp",
|
||||||
|
});
|
||||||
|
const slide = interpolate(frame, [0, 20], [40, 0], {
|
||||||
|
extrapolateLeft: "clamp",
|
||||||
|
extrapolateRight: "clamp",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Result card fades in after type completes (~70 frames)
|
||||||
|
const resultFade = interpolate(frame, [70, 95], [0, 1], {
|
||||||
|
extrapolateLeft: "clamp",
|
||||||
|
extrapolateRight: "clamp",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Caption appears late
|
||||||
|
const captionFade = interpolate(frame, [110, 130, 150], [0, 1, 1], {
|
||||||
|
extrapolateLeft: "clamp",
|
||||||
|
extrapolateRight: "clamp",
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AbsoluteFill
|
||||||
|
style={{
|
||||||
|
background: COLORS.bgDeep,
|
||||||
|
padding: 80,
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: 1400,
|
||||||
|
height: 520,
|
||||||
|
opacity: enter,
|
||||||
|
transform: `translateY(${slide}px)`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TerminalWindow title="bash">
|
||||||
|
<TypedLine
|
||||||
|
text="/last30days OpenAI"
|
||||||
|
startFrame={20}
|
||||||
|
prefix="$"
|
||||||
|
fontSize={42}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
opacity: resultFade,
|
||||||
|
marginTop: 36,
|
||||||
|
padding: "20px 24px",
|
||||||
|
background: COLORS.bgPanelSoft,
|
||||||
|
borderRadius: 12,
|
||||||
|
border: `1px solid ${COLORS.border}`,
|
||||||
|
fontFamily: FONT_MONO,
|
||||||
|
fontSize: 26,
|
||||||
|
color: COLORS.fgPrimary,
|
||||||
|
lineHeight: 1.6,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ color: COLORS.accentGreen }}>
|
||||||
|
✅ All agents reported back!
|
||||||
|
</div>
|
||||||
|
<div style={{ color: COLORS.fgMuted, marginTop: 6 }}>
|
||||||
|
├─ 🟠 Reddit: 14 threads
|
||||||
|
</div>
|
||||||
|
<div style={{ color: COLORS.fgMuted }}>
|
||||||
|
├─ 🔵 X: 22 posts
|
||||||
|
</div>
|
||||||
|
<div style={{ color: COLORS.fgMuted }}>
|
||||||
|
└─ 🟡 HN: 1 story
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TerminalWindow>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
opacity: captionFade,
|
||||||
|
marginTop: 60,
|
||||||
|
fontFamily: FONT_SANS,
|
||||||
|
fontSize: 38,
|
||||||
|
color: COLORS.fgMuted,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
The old way: <span style={{ color: COLORS.fgPrimary }}>one topic.</span>
|
||||||
|
</div>
|
||||||
|
</AbsoluteFill>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,221 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { AbsoluteFill, interpolate, spring, useCurrentFrame, useVideoConfig } from "remotion";
|
||||||
|
import { TerminalWindow } from "../components/TerminalWindow";
|
||||||
|
import { TypedLine } from "../components/TypedLine";
|
||||||
|
import { COLORS, FONT_MONO, FONT_SANS } from "../lib/colors";
|
||||||
|
|
||||||
|
const PROGRESS_LINES = [
|
||||||
|
{ source: "Reddit", color: "#ff6a3d" },
|
||||||
|
{ source: "X", color: "#36d6f7" },
|
||||||
|
{ source: "YouTube", color: "#ff5757" },
|
||||||
|
{ source: "TikTok", color: "#5fff9f" },
|
||||||
|
{ source: "Instagram", color: "#ff55a3" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const ENTITIES: { label: string; tag: string; accent: string }[] = [
|
||||||
|
{ label: "OpenAI", tag: "$ /last30days OpenAI", accent: COLORS.accentCyan },
|
||||||
|
{ label: "Anthropic", tag: "$ /last30days Anthropic", accent: COLORS.accentMagenta },
|
||||||
|
{ label: "xAI", tag: "$ /last30days xAI", accent: COLORS.accentAmber },
|
||||||
|
];
|
||||||
|
|
||||||
|
const FanPane: React.FC<{
|
||||||
|
label: string;
|
||||||
|
tag: string;
|
||||||
|
accent: string;
|
||||||
|
panelStart: number;
|
||||||
|
}> = ({ label, tag, accent, panelStart }) => {
|
||||||
|
const frame = useCurrentFrame();
|
||||||
|
const { fps } = useVideoConfig();
|
||||||
|
const localFrame = Math.max(0, frame - panelStart);
|
||||||
|
|
||||||
|
const enter = spring({
|
||||||
|
frame: localFrame,
|
||||||
|
fps,
|
||||||
|
config: { damping: 18, stiffness: 80 },
|
||||||
|
from: 0,
|
||||||
|
to: 1,
|
||||||
|
});
|
||||||
|
const slide = interpolate(localFrame, [0, 20], [40, 0], {
|
||||||
|
extrapolateLeft: "clamp",
|
||||||
|
extrapolateRight: "clamp",
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
opacity: enter,
|
||||||
|
transform: `translateY(${slide}px)`,
|
||||||
|
height: 480,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TerminalWindow title={label} glow>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
color: accent,
|
||||||
|
fontSize: 18,
|
||||||
|
fontFamily: FONT_MONO,
|
||||||
|
marginBottom: 14,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{tag}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 22,
|
||||||
|
color: COLORS.accentGreen,
|
||||||
|
fontFamily: FONT_MONO,
|
||||||
|
marginBottom: 12,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
[Competitors] running...
|
||||||
|
</div>
|
||||||
|
{PROGRESS_LINES.map((line, idx) => {
|
||||||
|
const lineStart = panelStart + 16 + idx * 6;
|
||||||
|
const lineFade = interpolate(
|
||||||
|
frame - lineStart,
|
||||||
|
[0, 8],
|
||||||
|
[0, 1],
|
||||||
|
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" },
|
||||||
|
);
|
||||||
|
// pulse the in-progress dot
|
||||||
|
const dotOn = Math.floor((frame - lineStart) / 6) % 2 === 0;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={line.source}
|
||||||
|
style={{
|
||||||
|
opacity: lineFade,
|
||||||
|
fontFamily: FONT_MONO,
|
||||||
|
fontSize: 20,
|
||||||
|
color: COLORS.fgMuted,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
marginBottom: 6,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
display: "inline-block",
|
||||||
|
width: 10,
|
||||||
|
height: 10,
|
||||||
|
borderRadius: 10,
|
||||||
|
background: dotOn ? line.color : COLORS.bgPanelSoft,
|
||||||
|
marginRight: 12,
|
||||||
|
boxShadow: dotOn ? `0 0 10px ${line.color}` : "none",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span style={{ color: line.color, marginRight: 8 }}>
|
||||||
|
►
|
||||||
|
</span>
|
||||||
|
<span>{line.source}</span>
|
||||||
|
<span style={{ marginLeft: "auto", color: COLORS.fgDim }}>
|
||||||
|
{dotOn ? "..." : "·"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</TerminalWindow>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Scene3FanOut: React.FC = () => {
|
||||||
|
const frame = useCurrentFrame();
|
||||||
|
|
||||||
|
// Phase 1 (0-30 frames): single terminal types --competitors flag
|
||||||
|
// Phase 2 (30+): split into 3 panes
|
||||||
|
const splitProgress = interpolate(frame, [30, 50], [0, 1], {
|
||||||
|
extrapolateLeft: "clamp",
|
||||||
|
extrapolateRight: "clamp",
|
||||||
|
});
|
||||||
|
|
||||||
|
const singleOpacity = interpolate(frame, [0, 8, 30, 45], [0, 1, 1, 0], {
|
||||||
|
extrapolateLeft: "clamp",
|
||||||
|
extrapolateRight: "clamp",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Caption fades in shortly after panes settle so it has time to read.
|
||||||
|
const captionFade = interpolate(frame, [60, 80], [0, 1], {
|
||||||
|
extrapolateLeft: "clamp",
|
||||||
|
extrapolateRight: "clamp",
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AbsoluteFill
|
||||||
|
style={{
|
||||||
|
background: COLORS.bgDeep,
|
||||||
|
padding: 60,
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
opacity: captionFade,
|
||||||
|
textAlign: "center",
|
||||||
|
fontFamily: FONT_SANS,
|
||||||
|
fontSize: 42,
|
||||||
|
color: COLORS.fgPrimary,
|
||||||
|
marginBottom: 32,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Now it discovers competitors
|
||||||
|
<br />
|
||||||
|
<span style={{ color: COLORS.accentCyan }}>and runs all 3.</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
position: "relative",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Single terminal during phase 1, fades out as panes appear */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
inset: 0,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
opacity: singleOpacity,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ width: 1200, height: 380 }}>
|
||||||
|
<TerminalWindow title="bash">
|
||||||
|
<TypedLine
|
||||||
|
text="/last30days OpenAI --competitors"
|
||||||
|
startFrame={0}
|
||||||
|
prefix="$"
|
||||||
|
fontSize={42}
|
||||||
|
/>
|
||||||
|
</TerminalWindow>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Three panes fade in starting frame ~30 */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
inset: 0,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
gap: 24,
|
||||||
|
opacity: splitProgress,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{ENTITIES.map((entity, idx) => (
|
||||||
|
<FanPane
|
||||||
|
key={entity.label}
|
||||||
|
label={entity.label}
|
||||||
|
tag={entity.tag}
|
||||||
|
accent={entity.accent}
|
||||||
|
panelStart={45 + idx * 8}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</AbsoluteFill>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { AbsoluteFill, interpolate, useCurrentFrame } from "remotion";
|
||||||
|
import { ComparisonTable } from "../components/ComparisonTable";
|
||||||
|
import { COLORS, FONT_SANS } from "../lib/colors";
|
||||||
|
|
||||||
|
const ROWS = [
|
||||||
|
{
|
||||||
|
dimension: "What it is",
|
||||||
|
cells: [
|
||||||
|
"GPT-5 leader, Plus + API",
|
||||||
|
"Claude 4, safety-first",
|
||||||
|
"Grok, X-native, fast",
|
||||||
|
] as [string, string, string],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
dimension: "30-day momentum",
|
||||||
|
cells: [
|
||||||
|
"GPT-5 launch wave",
|
||||||
|
"Claude 4.7 1M context",
|
||||||
|
"Grok 5 reveal",
|
||||||
|
] as [string, string, string],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
dimension: "Community vibe",
|
||||||
|
cells: [
|
||||||
|
"Defensive but deep",
|
||||||
|
"Quiet, devs-only",
|
||||||
|
"Loud, meme-rich",
|
||||||
|
] as [string, string, string],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
dimension: "Best for",
|
||||||
|
cells: [
|
||||||
|
"Mainstream + tools",
|
||||||
|
"Long-context coding",
|
||||||
|
"Live X intel",
|
||||||
|
] as [string, string, string],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const Scene4Comparison: React.FC = () => {
|
||||||
|
const frame = useCurrentFrame();
|
||||||
|
|
||||||
|
const captionFade = interpolate(frame, [0, 12, 180, 210], [0, 1, 1, 0], {
|
||||||
|
extrapolateLeft: "clamp",
|
||||||
|
extrapolateRight: "clamp",
|
||||||
|
});
|
||||||
|
const captionLift = interpolate(frame, [0, 14], [16, 0], {
|
||||||
|
extrapolateLeft: "clamp",
|
||||||
|
extrapolateRight: "clamp",
|
||||||
|
});
|
||||||
|
|
||||||
|
const tableFade = interpolate(frame, [16, 32], [0, 1], {
|
||||||
|
extrapolateLeft: "clamp",
|
||||||
|
extrapolateRight: "clamp",
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AbsoluteFill
|
||||||
|
style={{
|
||||||
|
background: COLORS.bgDeep,
|
||||||
|
padding: "48px 80px",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
alignItems: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
opacity: captionFade,
|
||||||
|
transform: `translateY(${captionLift}px)`,
|
||||||
|
fontFamily: FONT_SANS,
|
||||||
|
fontSize: 38,
|
||||||
|
color: COLORS.fgMuted,
|
||||||
|
textAlign: "center",
|
||||||
|
marginBottom: 36,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ color: COLORS.accentCyan, fontWeight: 600 }}>
|
||||||
|
3 full passes.
|
||||||
|
</span>
|
||||||
|
<span style={{ marginLeft: 18, color: COLORS.accentMagenta, fontWeight: 600 }}>
|
||||||
|
3 save files.
|
||||||
|
</span>
|
||||||
|
<span style={{ marginLeft: 18, color: COLORS.fgPrimary, fontWeight: 600 }}>
|
||||||
|
1 comparison.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
maxWidth: 1640,
|
||||||
|
opacity: tableFade,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ComparisonTable
|
||||||
|
startFrame={20}
|
||||||
|
entities={["OpenAI", "Anthropic", "xAI"]}
|
||||||
|
rows={ROWS}
|
||||||
|
rowStaggerFrames={22}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</AbsoluteFill>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { AbsoluteFill, interpolate, useCurrentFrame } from "remotion";
|
||||||
|
import { COLORS, FONT_SANS } from "../lib/colors";
|
||||||
|
|
||||||
|
const LINES = [
|
||||||
|
{ text: "You pick the topic.", color: COLORS.fgPrimary },
|
||||||
|
{ text: "The agent picks the peers.", color: COLORS.accentCyan },
|
||||||
|
{ text: "The engine fans out.", color: COLORS.accentMagenta },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const Scene5HowItWorks: React.FC = () => {
|
||||||
|
const frame = useCurrentFrame();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AbsoluteFill
|
||||||
|
style={{
|
||||||
|
background: `radial-gradient(circle at 50% 60%, ${COLORS.bgPanelSoft} 0%, ${COLORS.bgDeep} 70%)`,
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
gap: 48,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{LINES.map((line, idx) => {
|
||||||
|
const start = 10 + idx * 28;
|
||||||
|
const fade = interpolate(frame, [start, start + 14], [0, 1], {
|
||||||
|
extrapolateLeft: "clamp",
|
||||||
|
extrapolateRight: "clamp",
|
||||||
|
});
|
||||||
|
const slide = interpolate(frame, [start, start + 18], [24, 0], {
|
||||||
|
extrapolateLeft: "clamp",
|
||||||
|
extrapolateRight: "clamp",
|
||||||
|
});
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={line.text}
|
||||||
|
style={{
|
||||||
|
opacity: fade,
|
||||||
|
transform: `translateY(${slide}px)`,
|
||||||
|
fontFamily: FONT_SANS,
|
||||||
|
fontSize: 78,
|
||||||
|
fontWeight: 600,
|
||||||
|
color: line.color,
|
||||||
|
letterSpacing: -1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{line.text}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</AbsoluteFill>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { AbsoluteFill, interpolate, spring, useCurrentFrame, useVideoConfig } from "remotion";
|
||||||
|
import { BadgeBar } from "../components/BadgeBar";
|
||||||
|
import { COLORS, FONT_MONO, FONT_SANS } from "../lib/colors";
|
||||||
|
|
||||||
|
export const Scene6CTA: React.FC = () => {
|
||||||
|
const frame = useCurrentFrame();
|
||||||
|
const { fps } = useVideoConfig();
|
||||||
|
|
||||||
|
const installFade = interpolate(frame, [20, 40], [0, 1], {
|
||||||
|
extrapolateLeft: "clamp",
|
||||||
|
extrapolateRight: "clamp",
|
||||||
|
});
|
||||||
|
// Stronger 1Hz pulse on the install line for end-of-video emphasis
|
||||||
|
const pulse = 0.8 + 0.2 * Math.sin((frame / fps) * 2 * Math.PI);
|
||||||
|
const glowPulse = 0.4 + 0.4 * Math.sin((frame / fps) * 2 * Math.PI);
|
||||||
|
|
||||||
|
const repoFade = interpolate(frame, [50, 70], [0, 1], {
|
||||||
|
extrapolateLeft: "clamp",
|
||||||
|
extrapolateRight: "clamp",
|
||||||
|
});
|
||||||
|
|
||||||
|
const enterScale = spring({
|
||||||
|
frame,
|
||||||
|
fps,
|
||||||
|
config: { damping: 18, stiffness: 90 },
|
||||||
|
from: 0.95,
|
||||||
|
to: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AbsoluteFill
|
||||||
|
style={{
|
||||||
|
background: `radial-gradient(circle at 50% 50%, ${COLORS.bgPanelSoft} 0%, ${COLORS.bgDeep} 70%)`,
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
gap: 56,
|
||||||
|
transform: `scale(${enterScale})`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<BadgeBar />
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
opacity: installFade * pulse,
|
||||||
|
padding: "18px 36px",
|
||||||
|
background: COLORS.bgPanel,
|
||||||
|
border: `1px solid ${COLORS.accentCyan}`,
|
||||||
|
borderRadius: 14,
|
||||||
|
boxShadow: `0 0 ${40 + glowPulse * 60}px ${COLORS.accentCyan}${Math.round(40 + glowPulse * 80).toString(16)}`,
|
||||||
|
fontFamily: FONT_MONO,
|
||||||
|
fontSize: 44,
|
||||||
|
color: COLORS.fgPrimary,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ color: COLORS.accentGreen, marginRight: 18 }}>$</span>
|
||||||
|
/last30days <span style={{ color: COLORS.fgMuted }}>{"{topic}"}</span>{" "}
|
||||||
|
<span style={{ color: COLORS.accentCyan }}>--competitors</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
opacity: repoFade,
|
||||||
|
fontFamily: FONT_SANS,
|
||||||
|
fontSize: 28,
|
||||||
|
color: COLORS.fgMuted,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
github.com/mvanhorn/last30days-skill
|
||||||
|
</div>
|
||||||
|
</AbsoluteFill>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"strict": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
|
"types": ["node"]
|
||||||
|
},
|
||||||
|
"include": ["src/**/*"]
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "last30days-skill"
|
name = "last30days-skill"
|
||||||
version = "3.2.0"
|
version = "3.0.0"
|
||||||
description = "Multi-source last-30-days research skill"
|
description = "Multi-source last-30-days research skill"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
@@ -24,9 +24,9 @@ addopts = [
|
|||||||
|
|
||||||
[tool.coverage.run]
|
[tool.coverage.run]
|
||||||
branch = true
|
branch = true
|
||||||
source = ["skills/last30days/scripts", "tests"]
|
source = ["scripts", "tests"]
|
||||||
omit = [
|
omit = [
|
||||||
"skills/last30days/scripts/lib/vendor/*",
|
"scripts/lib/vendor/*",
|
||||||
"dist/*",
|
"dist/*",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -34,6 +34,7 @@ omit = [
|
|||||||
skip_empty = true
|
skip_empty = true
|
||||||
show_missing = true
|
show_missing = true
|
||||||
omit = [
|
omit = [
|
||||||
"skills/last30days/scripts/lib/vendor/*",
|
"scripts/lib/vendor/*",
|
||||||
"dist/*",
|
"dist/*",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ OpenClaw:
|
|||||||
clawhub install last30days-official
|
clawhub install last30days-official
|
||||||
```
|
```
|
||||||
|
|
||||||
OpenAI Codex CLI: install the repo as a local Codex marketplace/plugin. The plugin manifest lives at `.codex-plugin/plugin.json`, and the canonical skill payload is `skills/last30days/SKILL.md`.
|
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.
|
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.
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,13 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# build-skill.sh - package this repo as a claude.ai-upload-ready .skill file
|
# build-skill.sh - package this repo as a claude.ai-upload-ready .skill file
|
||||||
# Usage: bash skills/last30days/scripts/build-skill.sh (run from repo root)
|
# Usage: bash scripts/build-skill.sh (run from repo root)
|
||||||
#
|
#
|
||||||
# Produces dist/last30days.skill, a zip with a single top-level `last30days/`
|
# Produces dist/last30days.skill, a zip with a single top-level `last30days/`
|
||||||
# directory containing SKILL.md and the scripts/ runtime from skills/last30days.
|
# directory containing SKILL.md and the scripts/ runtime. See
|
||||||
# See
|
|
||||||
# docs/plans/2026-04-14-001-fix-skill-upload-200-file-limit-plan.md.
|
# docs/plans/2026-04-14-001-fix-skill-upload-200-file-limit-plan.md.
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
REPO_ROOT="$(cd "$(dirname "$0")/../../.." && pwd)"
|
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||||
cd "$REPO_ROOT"
|
cd "$REPO_ROOT"
|
||||||
|
|
||||||
if ! git diff --quiet || ! git diff --cached --quiet; then
|
if ! git diff --quiet || ! git diff --cached --quiet; then
|
||||||
@@ -18,7 +17,14 @@ fi
|
|||||||
|
|
||||||
mkdir -p dist
|
mkdir -p dist
|
||||||
OUT="dist/last30days.skill"
|
OUT="dist/last30days.skill"
|
||||||
git archive --format=zip --prefix=last30days/ --output="$OUT" HEAD:skills/last30days
|
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}')
|
COUNT=$(unzip -l "$OUT" | tail -1 | awk '{print $2}')
|
||||||
SIZE=$(du -h "$OUT" | cut -f1)
|
SIZE=$(du -h "$OUT" | cut -f1)
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
# A/B test runner: public release vs private beta
|
# A/B test runner: public release vs private beta
|
||||||
# Usage: bash skills/last30days/scripts/compare.sh "Kanye West"
|
# Usage: bash scripts/compare.sh "Kanye West"
|
||||||
#
|
#
|
||||||
# Runs /last30days (public release) and /last30days-beta (private beta)
|
# Runs /last30days (public release) and /last30days-beta (private beta)
|
||||||
# sequentially with a 30s gap, saves raw results with distinct suffixes,
|
# sequentially with a 30s gap, saves raw results with distinct suffixes,
|
||||||
@@ -9,8 +9,8 @@
|
|||||||
set -e
|
set -e
|
||||||
|
|
||||||
if [ $# -eq 0 ]; then
|
if [ $# -eq 0 ]; then
|
||||||
echo "Usage: bash skills/last30days/scripts/compare.sh <topic>"
|
echo "Usage: bash scripts/compare.sh <topic>"
|
||||||
echo " Example: bash skills/last30days/scripts/compare.sh Kevin Rose"
|
echo " Example: bash scripts/compare.sh Kevin Rose"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
TOPIC="$*"
|
TOPIC="$*"
|
||||||
@@ -22,8 +22,7 @@ from lib import env as envlib
|
|||||||
from lib import schema
|
from lib import schema
|
||||||
|
|
||||||
|
|
||||||
SKILL_ROOT = Path(__file__).resolve().parents[1]
|
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||||
REPO_ROOT = Path(__file__).resolve().parents[3]
|
|
||||||
EVAL_TOPICS_FILE = REPO_ROOT / "fixtures" / "eval_topics.json"
|
EVAL_TOPICS_FILE = REPO_ROOT / "fixtures" / "eval_topics.json"
|
||||||
|
|
||||||
|
|
||||||
@@ -308,10 +307,7 @@ def create_eval_env() -> dict[str, str]:
|
|||||||
|
|
||||||
|
|
||||||
def run_last30days(repo_dir: Path, topic: str, *, search: str, timeout_seconds: int, quick: bool, mock: bool, env: dict[str, str]) -> dict[str, Any]:
|
def run_last30days(repo_dir: Path, topic: str, *, search: str, timeout_seconds: int, quick: bool, mock: bool, env: dict[str, str]) -> dict[str, Any]:
|
||||||
engine = repo_dir / "skills" / "last30days" / "scripts" / "last30days.py"
|
cmd = [sys.executable, "scripts/last30days.py", topic, "--emit=json"]
|
||||||
if not engine.exists():
|
|
||||||
engine = repo_dir / "scripts" / "last30days.py"
|
|
||||||
cmd = [sys.executable, str(engine), topic, "--emit=json"]
|
|
||||||
if search:
|
if search:
|
||||||
cmd.extend(["--search", search])
|
cmd.extend(["--search", search])
|
||||||
if quick:
|
if quick:
|
||||||
@@ -41,7 +41,7 @@ if os.name == "nt":
|
|||||||
SCRIPT_DIR = Path(__file__).parent.resolve()
|
SCRIPT_DIR = Path(__file__).parent.resolve()
|
||||||
sys.path.insert(0, str(SCRIPT_DIR))
|
sys.path.insert(0, str(SCRIPT_DIR))
|
||||||
|
|
||||||
from lib import env, html_render, pipeline, render, schema, ui
|
from lib import env, pipeline, render, schema, ui
|
||||||
|
|
||||||
_child_pids: set[int] = set()
|
_child_pids: set[int] = set()
|
||||||
_child_pids_lock = threading.Lock()
|
_child_pids_lock = threading.Lock()
|
||||||
@@ -91,46 +91,30 @@ def slugify(value: str) -> str:
|
|||||||
return slug or "last30days"
|
return slug or "last30days"
|
||||||
|
|
||||||
|
|
||||||
def save_output(
|
def save_output(report: schema.Report, emit: str, save_dir: str, suffix: str = "") -> Path:
|
||||||
report: schema.Report,
|
|
||||||
emit: str,
|
|
||||||
save_dir: str,
|
|
||||||
suffix: str = "",
|
|
||||||
synthesis_md: str | None = None,
|
|
||||||
) -> Path:
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
path = Path(save_dir).expanduser().resolve()
|
path = Path(save_dir).expanduser().resolve()
|
||||||
path.mkdir(parents=True, exist_ok=True)
|
path.mkdir(parents=True, exist_ok=True)
|
||||||
slug = slugify(report.topic)
|
slug = slugify(report.topic)
|
||||||
extension = "json" if emit == "json" else "html" if emit == "html" else "md"
|
extension = "json" if emit == "json" else "md"
|
||||||
raw_label = "raw-html" if emit == "html" else "raw"
|
|
||||||
suffix_part = f"-{suffix}" if suffix else ""
|
suffix_part = f"-{suffix}" if suffix else ""
|
||||||
out_path = path / f"{slug}-{raw_label}{suffix_part}.{extension}"
|
out_path = path / f"{slug}-raw{suffix_part}.{extension}"
|
||||||
if out_path.exists():
|
if out_path.exists():
|
||||||
out_path = path / f"{slug}-{raw_label}{suffix_part}-{datetime.now().strftime('%Y-%m-%d')}.{extension}"
|
out_path = path / f"{slug}-raw{suffix_part}-{datetime.now().strftime('%Y-%m-%d')}.{extension}"
|
||||||
# Markdown saves keep the complete debug artifact. JSON and HTML preserve
|
# Always save the FULL dump to disk (all items, all sources, transcripts).
|
||||||
# their requested wire format so file extensions match their content.
|
# Claude sees compact clusters via --emit=compact on stdout.
|
||||||
if emit in {"json", "html"}:
|
# The saved file is the complete debug artifact.
|
||||||
content = emit_output(report, emit, synthesis_md=synthesis_md)
|
if emit == "json":
|
||||||
|
content = emit_output(report, emit)
|
||||||
else:
|
else:
|
||||||
content = render.render_full(report)
|
content = render.render_full(report)
|
||||||
out_path.write_text(content, encoding="utf-8")
|
out_path.write_text(content, encoding="utf-8")
|
||||||
return out_path
|
return out_path
|
||||||
|
|
||||||
|
|
||||||
def emit_output(
|
def emit_output(report: schema.Report, emit: str, fun_level: str = "medium", save_path: str | None = None) -> str:
|
||||||
report: schema.Report,
|
|
||||||
emit: str,
|
|
||||||
fun_level: str = "medium",
|
|
||||||
save_path: str | None = None,
|
|
||||||
synthesis_md: str | None = None,
|
|
||||||
) -> str:
|
|
||||||
if emit == "json":
|
if emit == "json":
|
||||||
return json.dumps(schema.to_dict(report), indent=2, sort_keys=True)
|
return json.dumps(schema.to_dict(report), indent=2, sort_keys=True)
|
||||||
if emit == "html":
|
|
||||||
return html_render.render_html(
|
|
||||||
report, fun_level=fun_level, save_path=save_path, synthesis_md=synthesis_md,
|
|
||||||
)
|
|
||||||
if emit in {"compact", "md"}:
|
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, save_path=save_path)
|
||||||
if emit == "context":
|
if emit == "context":
|
||||||
@@ -143,7 +127,6 @@ def emit_comparison_output(
|
|||||||
emit: str,
|
emit: str,
|
||||||
fun_level: str = "medium",
|
fun_level: str = "medium",
|
||||||
save_path: str | None = None,
|
save_path: str | None = None,
|
||||||
synthesis_md: str | None = None,
|
|
||||||
) -> str:
|
) -> str:
|
||||||
if emit == "json":
|
if emit == "json":
|
||||||
payload = {
|
payload = {
|
||||||
@@ -155,13 +138,6 @@ def emit_comparison_output(
|
|||||||
],
|
],
|
||||||
}
|
}
|
||||||
return json.dumps(payload, indent=2, sort_keys=True)
|
return json.dumps(payload, indent=2, sort_keys=True)
|
||||||
if emit == "html":
|
|
||||||
return html_render.render_html_comparison(
|
|
||||||
entity_reports,
|
|
||||||
fun_level=fun_level,
|
|
||||||
save_path=save_path,
|
|
||||||
synthesis_md=synthesis_md,
|
|
||||||
)
|
|
||||||
if emit in {"compact", "md"}:
|
if emit in {"compact", "md"}:
|
||||||
return render.render_comparison_multi(
|
return render.render_comparison_multi(
|
||||||
entity_reports, fun_level=fun_level, save_path=save_path,
|
entity_reports, fun_level=fun_level, save_path=save_path,
|
||||||
@@ -180,10 +156,9 @@ def compute_save_path_display(save_dir: str, topic: str, suffix: str, emit: str)
|
|||||||
from pathlib import Path as _Path
|
from pathlib import Path as _Path
|
||||||
path = _Path(save_dir).expanduser().resolve()
|
path = _Path(save_dir).expanduser().resolve()
|
||||||
slug = slugify(topic)
|
slug = slugify(topic)
|
||||||
extension = "json" if emit == "json" else "html" if emit == "html" else "md"
|
extension = "json" if emit == "json" else "md"
|
||||||
raw_label = "raw-html" if emit == "html" else "raw"
|
|
||||||
suffix_part = f"-{suffix}" if suffix else ""
|
suffix_part = f"-{suffix}" if suffix else ""
|
||||||
raw = path / f"{slug}-{raw_label}{suffix_part}.{extension}"
|
raw = path / f"{slug}-raw{suffix_part}.{extension}"
|
||||||
try:
|
try:
|
||||||
home = _Path.home().resolve()
|
home = _Path.home().resolve()
|
||||||
relative = raw.relative_to(home)
|
relative = raw.relative_to(home)
|
||||||
@@ -192,14 +167,6 @@ def compute_save_path_display(save_dir: str, topic: str, suffix: str, emit: str)
|
|||||||
return str(raw)
|
return str(raw)
|
||||||
|
|
||||||
|
|
||||||
def read_synthesis_file(path: str) -> str:
|
|
||||||
try:
|
|
||||||
return Path(path).expanduser().read_text(encoding="utf-8")
|
|
||||||
except OSError as exc:
|
|
||||||
sys.stderr.write(f"[last30days] Cannot read --synthesis-file: {exc}\n")
|
|
||||||
raise SystemExit(2)
|
|
||||||
|
|
||||||
|
|
||||||
def persist_report(report: schema.Report) -> dict[str, int]:
|
def persist_report(report: schema.Report) -> dict[str, int]:
|
||||||
import store
|
import store
|
||||||
|
|
||||||
@@ -226,7 +193,7 @@ def persist_report(report: schema.Report) -> dict[str, int]:
|
|||||||
def build_parser() -> argparse.ArgumentParser:
|
def build_parser() -> argparse.ArgumentParser:
|
||||||
parser = argparse.ArgumentParser(description="Research a topic across live social, market, and grounded web sources.")
|
parser = argparse.ArgumentParser(description="Research a topic across live social, market, and grounded web sources.")
|
||||||
parser.add_argument("topic", nargs="*", help="Research topic")
|
parser.add_argument("topic", nargs="*", help="Research topic")
|
||||||
parser.add_argument("--emit", default="compact", choices=["compact", "json", "context", "md", "html"])
|
parser.add_argument("--emit", default="compact", choices=["compact", "json", "context", "md"])
|
||||||
parser.add_argument("--search", help="Comma-separated source list")
|
parser.add_argument("--search", help="Comma-separated source list")
|
||||||
parser.add_argument("--quick", action="store_true", help="Lower-latency retrieval profile")
|
parser.add_argument("--quick", action="store_true", help="Lower-latency retrieval profile")
|
||||||
parser.add_argument("--deep", action="store_true", help="Higher-recall retrieval profile")
|
parser.add_argument("--deep", action="store_true", help="Higher-recall retrieval profile")
|
||||||
@@ -234,7 +201,6 @@ def build_parser() -> argparse.ArgumentParser:
|
|||||||
parser.add_argument("--mock", action="store_true", help="Use mock retrieval fixtures")
|
parser.add_argument("--mock", action="store_true", help="Use mock retrieval fixtures")
|
||||||
parser.add_argument("--diagnose", action="store_true", help="Print provider and source availability")
|
parser.add_argument("--diagnose", action="store_true", help="Print provider and source availability")
|
||||||
parser.add_argument("--save-dir", help="Optional directory for saving the rendered output")
|
parser.add_argument("--save-dir", help="Optional directory for saving the rendered output")
|
||||||
parser.add_argument("--synthesis-file", help="Markdown synthesis to embed in --emit=html output")
|
|
||||||
parser.add_argument("--store", action="store_true", help="Persist ranked findings to the SQLite research store")
|
parser.add_argument("--store", action="store_true", help="Persist ranked findings to the SQLite research store")
|
||||||
parser.add_argument("--x-handle", help="X handle for targeted supplemental search")
|
parser.add_argument("--x-handle", help="X handle for targeted supplemental search")
|
||||||
parser.add_argument("--x-related", help="Comma-separated related X handles (searched with lower weight)")
|
parser.add_argument("--x-related", help="Comma-separated related X handles (searched with lower weight)")
|
||||||
@@ -571,13 +537,6 @@ def main() -> int:
|
|||||||
parser.print_usage(sys.stderr)
|
parser.print_usage(sys.stderr)
|
||||||
return 2
|
return 2
|
||||||
|
|
||||||
synthesis_md = None
|
|
||||||
if args.synthesis_file:
|
|
||||||
if args.emit == "html":
|
|
||||||
synthesis_md = read_synthesis_file(args.synthesis_file)
|
|
||||||
else:
|
|
||||||
sys.stderr.write("[last30days] Warning: --synthesis-file is only used with --emit=html; ignoring.\n")
|
|
||||||
|
|
||||||
if not os.environ.get("LAST30DAYS_SKIP_PREFLIGHT"):
|
if not os.environ.get("LAST30DAYS_SKIP_PREFLIGHT"):
|
||||||
from lib import preflight
|
from lib import preflight
|
||||||
refuse_msg = preflight.check_class_1_trap(topic)
|
refuse_msg = preflight.check_class_1_trap(topic)
|
||||||
@@ -895,29 +854,15 @@ def main() -> int:
|
|||||||
|
|
||||||
if entity_reports:
|
if entity_reports:
|
||||||
rendered = emit_comparison_output(
|
rendered = emit_comparison_output(
|
||||||
entity_reports,
|
entity_reports, args.emit, fun_level=fun_level, save_path=footer_save_path,
|
||||||
args.emit,
|
|
||||||
fun_level=fun_level,
|
|
||||||
save_path=footer_save_path,
|
|
||||||
synthesis_md=synthesis_md,
|
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
rendered = emit_output(
|
rendered = emit_output(
|
||||||
report,
|
report, args.emit, fun_level=fun_level, save_path=footer_save_path,
|
||||||
args.emit,
|
|
||||||
fun_level=fun_level,
|
|
||||||
save_path=footer_save_path,
|
|
||||||
synthesis_md=synthesis_md,
|
|
||||||
)
|
)
|
||||||
if args.save_dir:
|
if args.save_dir:
|
||||||
# Save the main topic's raw file (single-entity or comparison main).
|
# Save the main topic's raw file (single-entity or comparison main).
|
||||||
save_path = save_output(
|
save_path = save_output(report, args.emit, args.save_dir, suffix=args.save_suffix or "")
|
||||||
report,
|
|
||||||
args.emit,
|
|
||||||
args.save_dir,
|
|
||||||
suffix=args.save_suffix or "",
|
|
||||||
synthesis_md=synthesis_md,
|
|
||||||
)
|
|
||||||
sys.stderr.write(f"[last30days] Saved output to {save_path}\n")
|
sys.stderr.write(f"[last30days] Saved output to {save_path}\n")
|
||||||
# Competitor / vs-mode: also save a per-entity raw file for each peer.
|
# Competitor / vs-mode: also save a per-entity raw file for each peer.
|
||||||
# Matches historical vs-mode behavior (N passes → N save files).
|
# Matches historical vs-mode behavior (N passes → N save files).
|
||||||
@@ -926,7 +871,6 @@ def main() -> int:
|
|||||||
peer_path = save_output(
|
peer_path = save_output(
|
||||||
entity_report, args.emit, args.save_dir,
|
entity_report, args.emit, args.save_dir,
|
||||||
suffix=args.save_suffix or "",
|
suffix=args.save_suffix or "",
|
||||||
synthesis_md=synthesis_md,
|
|
||||||
)
|
)
|
||||||
sys.stderr.write(f"[last30days] Saved output to {peer_path}\n")
|
sys.stderr.write(f"[last30days] Saved output to {peer_path}\n")
|
||||||
sys.stderr.flush()
|
sys.stderr.flush()
|
||||||
@@ -7,11 +7,13 @@ See scripts/lib/vendor/bird-search/package.json for authoritative version.
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import signal
|
||||||
import shutil
|
import shutil
|
||||||
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from . import http, log, subproc
|
from . import http, log
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Any, Dict, List, Optional, Tuple
|
from typing import Any, Dict, List, Optional, Tuple
|
||||||
|
|
||||||
@@ -166,51 +168,62 @@ def _run_bird_search(query: str, count: int, timeout: int) -> Dict[str, Any]:
|
|||||||
"--json",
|
"--json",
|
||||||
]
|
]
|
||||||
|
|
||||||
pid_holder: list[int] = []
|
# Use process groups for clean cleanup on timeout/kill
|
||||||
|
preexec = os.setsid if hasattr(os, 'setsid') else None
|
||||||
|
|
||||||
def _register(pid: int) -> None:
|
try:
|
||||||
pid_holder.append(pid)
|
proc = subprocess.Popen(
|
||||||
|
cmd,
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.PIPE,
|
||||||
|
text=True,
|
||||||
|
encoding="utf-8",
|
||||||
|
errors="replace",
|
||||||
|
preexec_fn=preexec,
|
||||||
|
env=_subprocess_env(),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Register for cleanup tracking (if available)
|
||||||
try:
|
try:
|
||||||
from last30days import register_child_pid
|
from last30days import register_child_pid, unregister_child_pid
|
||||||
register_child_pid(pid)
|
register_child_pid(proc.pid)
|
||||||
except ImportError:
|
except ImportError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
try:
|
try:
|
||||||
result = subproc.run_with_timeout(
|
stdout, stderr = proc.communicate(timeout=timeout)
|
||||||
cmd,
|
except subprocess.TimeoutExpired:
|
||||||
timeout=timeout,
|
# Kill the entire process group
|
||||||
env=_subprocess_env(),
|
try:
|
||||||
on_pid=_register,
|
os.killpg(os.getpgid(proc.pid), signal.SIGTERM)
|
||||||
)
|
except (ProcessLookupError, PermissionError, OSError):
|
||||||
except subproc.SubprocTimeout:
|
proc.kill()
|
||||||
return {"error": f"Search timed out after {timeout}s", "items": []}
|
proc.wait(timeout=5)
|
||||||
except Exception as e:
|
return {"error": f"Search timed out after {timeout}s", "items": []}
|
||||||
return {"error": str(e), "items": []}
|
finally:
|
||||||
finally:
|
|
||||||
if pid_holder:
|
|
||||||
try:
|
try:
|
||||||
from last30days import unregister_child_pid
|
from last30days import unregister_child_pid
|
||||||
unregister_child_pid(pid_holder[0])
|
unregister_child_pid(proc.pid)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
if result.returncode != 0:
|
if proc.returncode != 0:
|
||||||
error = result.stderr.strip() or "Bird search failed"
|
error = stderr.strip() if stderr else "Bird search failed"
|
||||||
return {"error": error, "items": []}
|
return {"error": error, "items": []}
|
||||||
|
|
||||||
output = result.stdout.strip()
|
output = stdout.strip() if stdout else ""
|
||||||
if not output:
|
if not output:
|
||||||
return {"items": []}
|
return {"items": []}
|
||||||
|
|
||||||
try:
|
|
||||||
parsed = json.loads(output)
|
parsed = json.loads(output)
|
||||||
|
if isinstance(parsed, list):
|
||||||
|
return {"items": parsed}
|
||||||
|
return parsed
|
||||||
|
|
||||||
except json.JSONDecodeError as e:
|
except json.JSONDecodeError as e:
|
||||||
return {"error": f"Invalid JSON response: {e}", "items": []}
|
return {"error": f"Invalid JSON response: {e}", "items": []}
|
||||||
|
except Exception as e:
|
||||||
if isinstance(parsed, list):
|
return {"error": str(e), "items": []}
|
||||||
return {"items": parsed}
|
|
||||||
return parsed
|
|
||||||
|
|
||||||
|
|
||||||
def search_x(
|
def search_x(
|
||||||
@@ -317,29 +330,47 @@ def search_handles(
|
|||||||
"--json",
|
"--json",
|
||||||
]
|
]
|
||||||
|
|
||||||
try:
|
preexec = os.setsid if hasattr(os, 'setsid') else None
|
||||||
result = subproc.run_with_timeout(cmd, timeout=15, env=_subprocess_env())
|
|
||||||
except subproc.SubprocTimeout:
|
|
||||||
_log(f"Handle search timed out for @{handle}")
|
|
||||||
return []
|
|
||||||
except OSError as e:
|
|
||||||
_log(f"Handle search error for @{handle}: {e}")
|
|
||||||
return []
|
|
||||||
|
|
||||||
if result.returncode != 0:
|
|
||||||
_log(f"Handle search failed for @{handle}: {result.stderr.strip()}")
|
|
||||||
return []
|
|
||||||
|
|
||||||
output = result.stdout.strip()
|
|
||||||
if not output:
|
|
||||||
return []
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
proc = subprocess.Popen(
|
||||||
|
cmd,
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.PIPE,
|
||||||
|
text=True,
|
||||||
|
encoding="utf-8",
|
||||||
|
errors="replace",
|
||||||
|
preexec_fn=preexec,
|
||||||
|
env=_subprocess_env(),
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
stdout, stderr = proc.communicate(timeout=15)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
try:
|
||||||
|
os.killpg(os.getpgid(proc.pid), signal.SIGTERM)
|
||||||
|
except (ProcessLookupError, PermissionError, OSError):
|
||||||
|
proc.kill()
|
||||||
|
proc.wait(timeout=5)
|
||||||
|
_log(f"Handle search timed out for @{handle}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
if proc.returncode != 0:
|
||||||
|
_log(f"Handle search failed for @{handle}: {(stderr or '').strip()}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
output = (stdout or "").strip()
|
||||||
|
if not output:
|
||||||
|
return []
|
||||||
|
|
||||||
response = json.loads(output)
|
response = json.loads(output)
|
||||||
|
return parse_bird_response(response, query=core_topic)
|
||||||
|
|
||||||
except json.JSONDecodeError:
|
except json.JSONDecodeError:
|
||||||
_log(f"Invalid JSON from handle search for @{handle}")
|
_log(f"Invalid JSON from handle search for @{handle}")
|
||||||
return []
|
except (OSError, subprocess.SubprocessError) as e:
|
||||||
return parse_bird_response(response, query=core_topic)
|
_log(f"Handle search error for @{handle}: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||||
|
|
||||||
@@ -39,14 +39,11 @@ def normalize_text(text: str) -> str:
|
|||||||
return re.sub(r"\s+", " ", text).strip()
|
return re.sub(r"\s+", " ", text).strip()
|
||||||
|
|
||||||
|
|
||||||
def _ngrams_of_normalized(norm: str, n: int = 3) -> set[str]:
|
|
||||||
if len(norm) < n:
|
|
||||||
return {norm} if norm else set()
|
|
||||||
return {norm[index:index + n] for index in range(len(norm) - n + 1)}
|
|
||||||
|
|
||||||
|
|
||||||
def get_ngrams(text: str, n: int = 3) -> set[str]:
|
def get_ngrams(text: str, n: int = 3) -> set[str]:
|
||||||
return _ngrams_of_normalized(normalize_text(text), n)
|
text = normalize_text(text)
|
||||||
|
if len(text) < n:
|
||||||
|
return {text} if text else set()
|
||||||
|
return {text[index:index + n] for index in range(len(text) - n + 1)}
|
||||||
|
|
||||||
|
|
||||||
def jaccard_similarity(left: set[str], right: set[str]) -> float:
|
def jaccard_similarity(left: set[str], right: set[str]) -> float:
|
||||||
@@ -93,7 +90,7 @@ class _PreparedText:
|
|||||||
|
|
||||||
def __init__(self, raw: str) -> None:
|
def __init__(self, raw: str) -> None:
|
||||||
norm = normalize_text(raw)
|
norm = normalize_text(raw)
|
||||||
self.ngrams = _ngrams_of_normalized(norm)
|
self.ngrams = get_ngrams(norm) if norm else set()
|
||||||
self.tokens = _tokenize(norm)
|
self.tokens = _tokenize(norm)
|
||||||
|
|
||||||
|
|
||||||
@@ -372,6 +372,14 @@ def config_exists() -> bool:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def is_reddit_available(config: dict[str, Any]) -> bool:
|
||||||
|
"""Check if Reddit search is available.
|
||||||
|
|
||||||
|
v3 uses ScrapeCreators only.
|
||||||
|
"""
|
||||||
|
return bool(config.get('SCRAPECREATORS_API_KEY'))
|
||||||
|
|
||||||
|
|
||||||
def get_reddit_source(config: dict[str, Any]) -> str | None:
|
def get_reddit_source(config: dict[str, Any]) -> str | None:
|
||||||
"""Determine which Reddit backend to use.
|
"""Determine which Reddit backend to use.
|
||||||
|
|
||||||
@@ -116,8 +116,6 @@ def weighted_rrf(
|
|||||||
"""Fuse ranked lists into a single candidate pool."""
|
"""Fuse ranked lists into a single candidate pool."""
|
||||||
subqueries = {subquery.label: subquery for subquery in plan.subqueries}
|
subqueries = {subquery.label: subquery for subquery in plan.subqueries}
|
||||||
candidates: dict[str, schema.Candidate] = {}
|
candidates: dict[str, schema.Candidate] = {}
|
||||||
# Track (source, item_id) pairs already attached to each candidate for O(1) dedup.
|
|
||||||
seen_source_items: dict[str, set[tuple[str, str]]] = {}
|
|
||||||
|
|
||||||
for (label, source), items in streams.items():
|
for (label, source), items in streams.items():
|
||||||
subquery = subqueries[label]
|
subquery = subqueries[label]
|
||||||
@@ -156,7 +154,6 @@ def weighted_rrf(
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
seen_source_items[key] = {(item.source, item.item_id)}
|
|
||||||
continue
|
continue
|
||||||
|
|
||||||
candidate = candidates[key]
|
candidate = candidates[key]
|
||||||
@@ -182,9 +179,7 @@ def weighted_rrf(
|
|||||||
candidate.subquery_labels.append(label)
|
candidate.subquery_labels.append(label)
|
||||||
if item.source not in candidate.sources:
|
if item.source not in candidate.sources:
|
||||||
candidate.sources.append(item.source)
|
candidate.sources.append(item.source)
|
||||||
source_item_key = (item.source, item.item_id)
|
if not any(existing.source == item.source and existing.item_id == item.item_id for existing in candidate.source_items):
|
||||||
if source_item_key not in seen_source_items[key]:
|
|
||||||
seen_source_items[key].add(source_item_key)
|
|
||||||
candidate.source_items.append(item)
|
candidate.source_items.append(item)
|
||||||
candidate.metadata.setdefault("provenance", []).append(
|
candidate.metadata.setdefault("provenance", []).append(
|
||||||
{
|
{
|
||||||
@@ -49,7 +49,6 @@ def normalize_source_items(
|
|||||||
"xquik": _normalize_x,
|
"xquik": _normalize_x,
|
||||||
"pinterest": _normalize_pinterest,
|
"pinterest": _normalize_pinterest,
|
||||||
"polymarket": _normalize_polymarket,
|
"polymarket": _normalize_polymarket,
|
||||||
"digg": _normalize_digg,
|
|
||||||
"grounding": _normalize_grounding,
|
"grounding": _normalize_grounding,
|
||||||
"xiaohongshu": _normalize_grounding,
|
"xiaohongshu": _normalize_grounding,
|
||||||
"github": _normalize_github,
|
"github": _normalize_github,
|
||||||
@@ -111,19 +110,6 @@ def _first_present(d: dict[str, Any], keys: tuple[str, ...], default: Any) -> An
|
|||||||
return default
|
return default
|
||||||
|
|
||||||
|
|
||||||
def _join_comment_excerpts(
|
|
||||||
top_comments: list[Any],
|
|
||||||
key: str,
|
|
||||||
limit: int = 3,
|
|
||||||
) -> str:
|
|
||||||
"""Space-join the `key` field from the first `limit` dict-shaped comments."""
|
|
||||||
return " ".join(
|
|
||||||
str(comment.get(key) or "").strip()
|
|
||||||
for comment in top_comments[:limit]
|
|
||||||
if isinstance(comment, dict)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _domain_from_url(url: str) -> str | None:
|
def _domain_from_url(url: str) -> str | None:
|
||||||
if not url:
|
if not url:
|
||||||
return None
|
return None
|
||||||
@@ -183,7 +169,11 @@ def _normalize_reddit(
|
|||||||
to_date: str,
|
to_date: str,
|
||||||
) -> schema.SourceItem:
|
) -> schema.SourceItem:
|
||||||
top_comments = item.get("top_comments") or []
|
top_comments = item.get("top_comments") or []
|
||||||
comment_text = _join_comment_excerpts(top_comments, "excerpt")
|
comment_text = " ".join(
|
||||||
|
str(comment.get("excerpt") or "").strip()
|
||||||
|
for comment in top_comments[:3]
|
||||||
|
if isinstance(comment, dict)
|
||||||
|
)
|
||||||
body = "\n".join(
|
body = "\n".join(
|
||||||
part
|
part
|
||||||
for part in [
|
for part in [
|
||||||
@@ -348,7 +338,11 @@ def _normalize_hackernews(
|
|||||||
to_date: str,
|
to_date: str,
|
||||||
) -> schema.SourceItem:
|
) -> schema.SourceItem:
|
||||||
top_comments = item.get("top_comments") or []
|
top_comments = item.get("top_comments") or []
|
||||||
comment_text = _join_comment_excerpts(top_comments, "text")
|
comment_text = " ".join(
|
||||||
|
str(comment.get("text") or "").strip()
|
||||||
|
for comment in top_comments[:3]
|
||||||
|
if isinstance(comment, dict)
|
||||||
|
)
|
||||||
title = str(item.get("title") or "").strip()
|
title = str(item.get("title") or "").strip()
|
||||||
body = "\n".join(part for part in [title, str(item.get("text") or "").strip(), comment_text] if part)
|
body = "\n".join(part for part in [title, str(item.get("text") or "").strip(), comment_text] if part)
|
||||||
return _source_item(
|
return _source_item(
|
||||||
@@ -400,53 +394,6 @@ def _normalize_microblog(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _normalize_digg(
|
|
||||||
source: str,
|
|
||||||
item: dict[str, Any],
|
|
||||||
index: int,
|
|
||||||
from_date: str,
|
|
||||||
to_date: str,
|
|
||||||
) -> schema.SourceItem:
|
|
||||||
"""Normalizer for Digg AI 1000 clusters.
|
|
||||||
|
|
||||||
Each cluster is one item. The TLDR carries the most useful body for
|
|
||||||
rerank and synthesis. Top-ranked X posts attached at search time are
|
|
||||||
passed through under metadata['posts'] so render can emit them as
|
|
||||||
inline 'via Digg AI 1000' quotes.
|
|
||||||
"""
|
|
||||||
title = str(item.get("title") or "").strip()
|
|
||||||
tldr = str(item.get("tldr") or "").strip()
|
|
||||||
body = "\n\n".join(part for part in [title, tldr] if part)
|
|
||||||
posts = item.get("posts") or []
|
|
||||||
if not isinstance(posts, list):
|
|
||||||
posts = []
|
|
||||||
cluster_url_id = str(item.get("id") or f"DG{index + 1}")
|
|
||||||
return _source_item(
|
|
||||||
item_id=cluster_url_id,
|
|
||||||
source=source,
|
|
||||||
title=title or f"Digg cluster {index + 1}",
|
|
||||||
body=body,
|
|
||||||
url=str(item.get("url") or f"https://di.gg/ai/{cluster_url_id}"),
|
|
||||||
author="",
|
|
||||||
container="Digg AI 1000",
|
|
||||||
published_at=item.get("date"),
|
|
||||||
date_confidence=_date_confidence(item, from_date, to_date, default="high"),
|
|
||||||
engagement=item.get("engagement") or {},
|
|
||||||
relevance_hint=item.get("relevance", 0.5),
|
|
||||||
why_relevant=str(item.get("why_relevant") or ""),
|
|
||||||
snippet=tldr[:400],
|
|
||||||
metadata={
|
|
||||||
"clusterUrlId": cluster_url_id,
|
|
||||||
"tldr": tldr,
|
|
||||||
"rank": (item.get("engagement") or {}).get("rank"),
|
|
||||||
"uniqueAuthors": (item.get("engagement") or {}).get("uniqueAuthors"),
|
|
||||||
"postCount": (item.get("engagement") or {}).get("postCount"),
|
|
||||||
"firstPostAge": item.get("first_post_age"),
|
|
||||||
"posts": posts,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _normalize_polymarket(
|
def _normalize_polymarket(
|
||||||
source: str,
|
source: str,
|
||||||
item: dict[str, Any],
|
item: dict[str, Any],
|
||||||
@@ -494,7 +441,11 @@ def _normalize_github(
|
|||||||
title = str(item.get("title") or "").strip()
|
title = str(item.get("title") or "").strip()
|
||||||
snippet_text = str(item.get("snippet") or "").strip()
|
snippet_text = str(item.get("snippet") or "").strip()
|
||||||
top_comments = item.get("metadata", {}).get("top_comments") or []
|
top_comments = item.get("metadata", {}).get("top_comments") or []
|
||||||
comment_text = _join_comment_excerpts(top_comments, "excerpt")
|
comment_text = " ".join(
|
||||||
|
str(comment.get("excerpt") or "").strip()
|
||||||
|
for comment in top_comments[:3]
|
||||||
|
if isinstance(comment, dict)
|
||||||
|
)
|
||||||
body = "\n".join(part for part in [title, snippet_text, comment_text] if part)
|
body = "\n".join(part for part in [title, snippet_text, comment_text] if part)
|
||||||
metadata = item.get("metadata") or {}
|
metadata = item.get("metadata") or {}
|
||||||
return _source_item(
|
return _source_item(
|
||||||
@@ -15,7 +15,6 @@ from . import (
|
|||||||
bluesky,
|
bluesky,
|
||||||
dates,
|
dates,
|
||||||
dedupe,
|
dedupe,
|
||||||
digg,
|
|
||||||
entity_extract,
|
entity_extract,
|
||||||
env,
|
env,
|
||||||
github,
|
github,
|
||||||
@@ -31,7 +30,6 @@ from . import (
|
|||||||
query,
|
query,
|
||||||
reddit,
|
reddit,
|
||||||
reddit_public,
|
reddit_public,
|
||||||
relevance,
|
|
||||||
rerank,
|
rerank,
|
||||||
schema,
|
schema,
|
||||||
signals,
|
signals,
|
||||||
@@ -80,7 +78,6 @@ MOCK_AVAILABLE_SOURCES = [
|
|||||||
"github",
|
"github",
|
||||||
"perplexity",
|
"perplexity",
|
||||||
"xquik",
|
"xquik",
|
||||||
"digg",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@@ -108,8 +105,6 @@ def available_sources(config: dict[str, Any], requested_sources: list[str] | Non
|
|||||||
available.extend(["hackernews", "polymarket"])
|
available.extend(["hackernews", "polymarket"])
|
||||||
if config.get("GITHUB_TOKEN") or which("gh"):
|
if config.get("GITHUB_TOKEN") or which("gh"):
|
||||||
available.append("github")
|
available.append("github")
|
||||||
if which("digg-pp-cli"):
|
|
||||||
available.append("digg")
|
|
||||||
if env.is_bluesky_available(config):
|
if env.is_bluesky_available(config):
|
||||||
available.append("bluesky")
|
available.append("bluesky")
|
||||||
if env.is_truthsocial_available(config):
|
if env.is_truthsocial_available(config):
|
||||||
@@ -505,12 +500,11 @@ def _normalize_score_dedupe(
|
|||||||
source, raw_items, from_date, to_date,
|
source, raw_items, from_date, to_date,
|
||||||
freshness_mode=freshness_mode,
|
freshness_mode=freshness_mode,
|
||||||
)
|
)
|
||||||
prepared_query = relevance.PreparedQuery(ranking_query)
|
normalized = signals.annotate_stream(normalized, ranking_query, freshness_mode)
|
||||||
normalized = signals.annotate_stream(normalized, prepared_query, freshness_mode)
|
|
||||||
normalized = signals.prune_low_relevance(normalized)
|
normalized = signals.prune_low_relevance(normalized)
|
||||||
normalized = dedupe.dedupe_items(normalized)
|
normalized = dedupe.dedupe_items(normalized)
|
||||||
for item in normalized:
|
for item in normalized:
|
||||||
item.snippet = snippet.extract_best_snippet(item, prepared_query)
|
item.snippet = snippet.extract_best_snippet(item, ranking_query)
|
||||||
return normalized
|
return normalized
|
||||||
|
|
||||||
|
|
||||||
@@ -535,12 +529,6 @@ def _finalize_items_by_source(
|
|||||||
keywords = config.get("_polymarket_keywords") if isinstance(config, dict) else None
|
keywords = config.get("_polymarket_keywords") if isinstance(config, dict) else None
|
||||||
if keywords:
|
if keywords:
|
||||||
items = polymarket.filter_items_against_keywords(items, keywords)
|
items = polymarket.filter_items_against_keywords(items, keywords)
|
||||||
if source == "digg" and items:
|
|
||||||
# Pull top-ranked X posts only for the survivors that will appear
|
|
||||||
# in the brief. Spending the enrichment budget here (rather than
|
|
||||||
# at retrieval time) keeps the inline 'via Digg AI 1000' quotes
|
|
||||||
# paired with the clusters dedupe actually kept.
|
|
||||||
digg.enrich_source_items(items, top_k=3)
|
|
||||||
finalized[source] = items
|
finalized[source] = items
|
||||||
return finalized
|
return finalized
|
||||||
|
|
||||||
@@ -976,13 +964,6 @@ def _retrieve_stream(
|
|||||||
if source == "hackernews":
|
if source == "hackernews":
|
||||||
result = hackernews.search_hackernews(subquery.search_query, from_date, to_date, depth=depth)
|
result = hackernews.search_hackernews(subquery.search_query, from_date, to_date, depth=depth)
|
||||||
return hackernews.parse_hackernews_response(result, query=subquery.search_query), {}
|
return hackernews.parse_hackernews_response(result, query=subquery.search_query), {}
|
||||||
if source == "digg":
|
|
||||||
result = digg.search_digg(subquery.search_query, from_date, to_date, depth=depth)
|
|
||||||
items = digg.parse_digg_response(result, query=subquery.search_query)
|
|
||||||
# Enrichment with attached X posts is deferred to
|
|
||||||
# _finalize_items_by_source so it runs on the items that actually
|
|
||||||
# survive dedupe rather than on top-K of the raw fanout.
|
|
||||||
return items, {}
|
|
||||||
if source == "bluesky":
|
if source == "bluesky":
|
||||||
result = bluesky.search_bluesky(subquery.search_query, from_date, to_date, depth=depth, config=config)
|
result = bluesky.search_bluesky(subquery.search_query, from_date, to_date, depth=depth, config=config)
|
||||||
return bluesky.parse_bluesky_response(result), {}
|
return bluesky.parse_bluesky_response(result), {}
|
||||||
@@ -1075,45 +1056,6 @@ def _mock_stream_results(source: str, subquery: schema.SubQuery) -> tuple[list[d
|
|||||||
"why_relevant": "Brave web search",
|
"why_relevant": "Brave web search",
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"digg": [
|
|
||||||
{
|
|
||||||
"id": "mock1abc",
|
|
||||||
"title": f"Digg AI 1000 cluster about {subquery.search_query}",
|
|
||||||
"url": "https://di.gg/ai/mock1abc",
|
|
||||||
"tldr": f"Curated cluster summarizing recent {subquery.search_query} discussion across the AI 1000.",
|
|
||||||
"author": "",
|
|
||||||
"date": dates.get_date_range(3)[0],
|
|
||||||
"engagement": {"postCount": 8, "uniqueAuthors": 5, "rank": 2, "rank_score": 49.0},
|
|
||||||
"first_post_age": "3d",
|
|
||||||
"posts": [
|
|
||||||
{
|
|
||||||
"username": "exampledev",
|
|
||||||
"display_name": "Example Dev",
|
|
||||||
"category": "Engineer",
|
|
||||||
"rank": 142,
|
|
||||||
"body": f"Quote from the AI 1000 about {subquery.search_query}.",
|
|
||||||
"post_type": "tweet",
|
|
||||||
"x_url": "https://x.com/exampledev/status/1",
|
|
||||||
"posted_at": dates.get_date_range(3)[0],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
"relevance": 0.84,
|
|
||||||
"why_relevant": "Mock Digg cluster",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "mock2def",
|
|
||||||
"title": f"Second Digg cluster on {subquery.search_query}",
|
|
||||||
"url": "https://di.gg/ai/mock2def",
|
|
||||||
"tldr": f"Another angle on {subquery.search_query}.",
|
|
||||||
"author": "",
|
|
||||||
"date": dates.get_date_range(8)[0],
|
|
||||||
"engagement": {"postCount": 3, "uniqueAuthors": 2, "rank": 18, "rank_score": 33.0},
|
|
||||||
"first_post_age": "8d",
|
|
||||||
"posts": [],
|
|
||||||
"relevance": 0.71,
|
|
||||||
"why_relevant": "Mock Digg cluster",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
}
|
||||||
if source == "grounding":
|
if source == "grounding":
|
||||||
return payloads.get(source, []), {
|
return payloads.get(source, []), {
|
||||||
@@ -67,7 +67,6 @@ SOURCE_CAPABILITIES = {
|
|||||||
"bluesky": {"discussion", "social"},
|
"bluesky": {"discussion", "social"},
|
||||||
"truthsocial": {"discussion", "social"},
|
"truthsocial": {"discussion", "social"},
|
||||||
"polymarket": {"market"},
|
"polymarket": {"market"},
|
||||||
"digg": {"discussion", "social", "link"},
|
|
||||||
"xiaohongshu": {"video", "video_shortform", "social"},
|
"xiaohongshu": {"video", "video_shortform", "social"},
|
||||||
"github": {"discussion", "link"},
|
"github": {"discussion", "link"},
|
||||||
"grounding": {"web", "reference", "link"},
|
"grounding": {"web", "reference", "link"},
|
||||||
@@ -93,6 +93,13 @@ class GeminiClient(ReasoningClient):
|
|||||||
)
|
)
|
||||||
return extract_gemini_text(payload)
|
return extract_gemini_text(payload)
|
||||||
|
|
||||||
|
def ground_search(self, model: str, prompt: str) -> dict[str, Any]:
|
||||||
|
return self._generate_content(model, prompt, tools=[{"google_search": {}}])
|
||||||
|
|
||||||
|
def url_context_json(self, model: str, prompt: str) -> dict[str, Any]:
|
||||||
|
return self.generate_json(model, prompt, tools=[{"url_context": {}}])
|
||||||
|
|
||||||
|
|
||||||
class OpenAIClient(ReasoningClient):
|
class OpenAIClient(ReasoningClient):
|
||||||
name = "openai"
|
name = "openai"
|
||||||
|
|
||||||
@@ -71,29 +71,8 @@ def _normalize_phrase(text: str) -> str:
|
|||||||
return ' '.join(re.sub(r'[^\w\s]', ' ', text.lower()).split())
|
return ' '.join(re.sub(r'[^\w\s]', ' ', text.lower()).split())
|
||||||
|
|
||||||
|
|
||||||
class PreparedQuery:
|
|
||||||
"""Precomputed query shape reused across items in a stream.
|
|
||||||
|
|
||||||
Built once per ranking_query; reused by token_overlap_relevance so the
|
|
||||||
per-item normalize/score loops don't re-tokenize the same query N times.
|
|
||||||
"""
|
|
||||||
|
|
||||||
__slots__ = ("raw", "q_tokens", "informative_q_tokens", "normalized_phrase")
|
|
||||||
|
|
||||||
def __init__(self, query: str) -> None:
|
|
||||||
self.raw = query
|
|
||||||
self.q_tokens = tokenize(query)
|
|
||||||
informative = {t for t in self.q_tokens if t not in LOW_SIGNAL_QUERY_TOKENS}
|
|
||||||
self.informative_q_tokens = informative or self.q_tokens
|
|
||||||
self.normalized_phrase = _normalize_phrase(query)
|
|
||||||
|
|
||||||
|
|
||||||
def _as_prepared(query: "str | PreparedQuery") -> PreparedQuery:
|
|
||||||
return query if isinstance(query, PreparedQuery) else PreparedQuery(query)
|
|
||||||
|
|
||||||
|
|
||||||
def token_overlap_relevance(
|
def token_overlap_relevance(
|
||||||
query: "str | PreparedQuery",
|
query: str,
|
||||||
text: str,
|
text: str,
|
||||||
hashtags: Optional[List[str]] = None,
|
hashtags: Optional[List[str]] = None,
|
||||||
) -> float:
|
) -> float:
|
||||||
@@ -116,8 +95,7 @@ def token_overlap_relevance(
|
|||||||
Returns:
|
Returns:
|
||||||
Float between 0.0 and 1.0 (0.5 for empty queries)
|
Float between 0.0 and 1.0 (0.5 for empty queries)
|
||||||
"""
|
"""
|
||||||
prepared = _as_prepared(query)
|
q_tokens = tokenize(query)
|
||||||
q_tokens = prepared.q_tokens
|
|
||||||
|
|
||||||
# Combine text and hashtags for matching
|
# Combine text and hashtags for matching
|
||||||
combined = text
|
combined = text
|
||||||
@@ -141,7 +119,9 @@ def token_overlap_relevance(
|
|||||||
if overlap == 0:
|
if overlap == 0:
|
||||||
return 0.0
|
return 0.0
|
||||||
|
|
||||||
informative_q_tokens = prepared.informative_q_tokens
|
informative_q_tokens = {t for t in q_tokens if t not in LOW_SIGNAL_QUERY_TOKENS}
|
||||||
|
if not informative_q_tokens:
|
||||||
|
informative_q_tokens = q_tokens
|
||||||
|
|
||||||
coverage = overlap / len(q_tokens)
|
coverage = overlap / len(q_tokens)
|
||||||
informative_overlap = len(informative_q_tokens & t_tokens) / len(informative_q_tokens)
|
informative_overlap = len(informative_q_tokens & t_tokens) / len(informative_q_tokens)
|
||||||
@@ -149,7 +129,7 @@ def token_overlap_relevance(
|
|||||||
precision = overlap / precision_denominator
|
precision = overlap / precision_denominator
|
||||||
|
|
||||||
phrase_bonus = 0.0
|
phrase_bonus = 0.0
|
||||||
normalized_query = prepared.normalized_phrase
|
normalized_query = _normalize_phrase(query)
|
||||||
normalized_text = _normalize_phrase(combined)
|
normalized_text = _normalize_phrase(combined)
|
||||||
if normalized_query and normalized_query in normalized_text:
|
if normalized_query and normalized_query in normalized_text:
|
||||||
phrase_bonus = 0.12 if len(normalized_query.split()) > 1 else 0.16
|
phrase_bonus = 0.12 if len(normalized_query.split()) > 1 else 0.16
|
||||||
@@ -12,7 +12,7 @@ from . import dates, schema
|
|||||||
|
|
||||||
|
|
||||||
def _skill_version() -> str:
|
def _skill_version() -> str:
|
||||||
"""Read plugin version from a plugin manifest if available.
|
"""Read plugin version from .claude-plugin/plugin.json if available.
|
||||||
|
|
||||||
Tries nearest plugin.json by walking up from render.py's own location.
|
Tries nearest plugin.json by walking up from render.py's own location.
|
||||||
Falls back to "?" if not found. This keeps the badge emission from
|
Falls back to "?" if not found. This keeps the badge emission from
|
||||||
@@ -20,13 +20,12 @@ def _skill_version() -> str:
|
|||||||
"""
|
"""
|
||||||
here = pathlib.Path(__file__).resolve()
|
here = pathlib.Path(__file__).resolve()
|
||||||
for parent in [here.parent, *here.parents]:
|
for parent in [here.parent, *here.parents]:
|
||||||
for manifest_dir in (".codex-plugin", ".claude-plugin"):
|
candidate = parent / ".claude-plugin" / "plugin.json"
|
||||||
candidate = parent / manifest_dir / "plugin.json"
|
if candidate.is_file():
|
||||||
if candidate.is_file():
|
try:
|
||||||
try:
|
return json.loads(candidate.read_text()).get("version", "?")
|
||||||
return json.loads(candidate.read_text()).get("version", "?")
|
except (json.JSONDecodeError, OSError):
|
||||||
except (json.JSONDecodeError, OSError):
|
return "?"
|
||||||
return "?"
|
|
||||||
return "?"
|
return "?"
|
||||||
|
|
||||||
|
|
||||||
@@ -53,7 +52,6 @@ SOURCE_LABELS = {
|
|||||||
"xiaohongshu": "Xiaohongshu",
|
"xiaohongshu": "Xiaohongshu",
|
||||||
"x": "X",
|
"x": "X",
|
||||||
"github": "GitHub",
|
"github": "GitHub",
|
||||||
"digg": "Digg AI 1000",
|
|
||||||
"perplexity": "Perplexity",
|
"perplexity": "Perplexity",
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -172,168 +170,6 @@ def render_compact(report: schema.Report, cluster_limit: int = 8, fun_level: str
|
|||||||
return "\n".join(lines).strip() + "\n"
|
return "\n".join(lines).strip() + "\n"
|
||||||
|
|
||||||
|
|
||||||
def render_for_html(
|
|
||||||
report: schema.Report,
|
|
||||||
synthesis_md: str | None = None,
|
|
||||||
*,
|
|
||||||
save_path: str | None = None,
|
|
||||||
) -> str:
|
|
||||||
"""Render markdown intended for shareable HTML conversion.
|
|
||||||
|
|
||||||
This output keeps the public badge, compact source/date metadata, an
|
|
||||||
optional one-line data quality note, optional synthesized brief markdown,
|
|
||||||
and the engine footer. It deliberately omits the debug file header,
|
|
||||||
model-facing safety note, and evidence scratchpad emitted by
|
|
||||||
render_compact().
|
|
||||||
|
|
||||||
When synthesis_md is None, the body is intentionally sparse: badge,
|
|
||||||
metadata, optional data quality note, and engine footer only.
|
|
||||||
"""
|
|
||||||
lines = [
|
|
||||||
*_render_badge(),
|
|
||||||
*_render_html_metadata(report),
|
|
||||||
]
|
|
||||||
if synthesis_md:
|
|
||||||
lines.extend(["", synthesis_md.strip()])
|
|
||||||
# Data quality warnings are NOT rendered into the HTML artifact. The HTML
|
|
||||||
# is meant to be shared (Slack, email, Notion); recipients haven't asked
|
|
||||||
# for technical commentary about how the run was produced. Generators see
|
|
||||||
# the same warnings via collect_html_warnings() routed to stderr by the
|
|
||||||
# CLI, so they can fix quality issues before sharing.
|
|
||||||
_append_html_footer(lines, report, save_path)
|
|
||||||
return "\n".join(lines).strip() + "\n"
|
|
||||||
|
|
||||||
|
|
||||||
def render_for_html_comparison(
|
|
||||||
entity_reports: list[tuple[str, schema.Report]],
|
|
||||||
synthesis_md: str | None = None,
|
|
||||||
*,
|
|
||||||
save_path: str | None = None,
|
|
||||||
) -> str:
|
|
||||||
"""Render comparison markdown intended for shareable HTML conversion.
|
|
||||||
|
|
||||||
Same semantics as render_for_html(), but metadata and data quality notes
|
|
||||||
are aggregated across the compared entities.
|
|
||||||
"""
|
|
||||||
if not entity_reports:
|
|
||||||
raise ValueError("render_for_html_comparison requires at least one report")
|
|
||||||
|
|
||||||
entities = [label for label, _ in entity_reports]
|
|
||||||
main_report = entity_reports[0][1]
|
|
||||||
meta = (
|
|
||||||
f"<!-- META: {main_report.range_from} to {main_report.range_to} "
|
|
||||||
f"· comparing {len(entities)}: {', '.join(entities)} -->"
|
|
||||||
)
|
|
||||||
lines = [
|
|
||||||
*_render_badge(),
|
|
||||||
meta,
|
|
||||||
]
|
|
||||||
if synthesis_md:
|
|
||||||
lines.extend(["", synthesis_md.strip()])
|
|
||||||
# Comparison data quality notes also go to stderr, not into the artifact.
|
|
||||||
_append_html_footer(lines, main_report, save_path)
|
|
||||||
return "\n".join(lines).strip() + "\n"
|
|
||||||
|
|
||||||
|
|
||||||
def collect_html_warnings(report: schema.Report) -> list[str]:
|
|
||||||
"""Collect data quality warnings for stderr output (NOT for the HTML artifact).
|
|
||||||
|
|
||||||
Returns a list of human-readable warning strings. Empty list if the run
|
|
||||||
was clean. Used by the CLI to emit diagnostics to stderr after writing
|
|
||||||
the HTML to stdout/file.
|
|
||||||
"""
|
|
||||||
notes: list[str] = []
|
|
||||||
if _render_degraded_run_warning(report):
|
|
||||||
notes.append("Run was missing pre-flight resolution. Re-run with `--plan` for richer results.")
|
|
||||||
elif _render_pre_research_warning(report):
|
|
||||||
notes.append("Pre-research was skipped, so results may be thinner than a resolved run.")
|
|
||||||
freshness_warning = _assess_data_freshness(report)
|
|
||||||
if freshness_warning:
|
|
||||||
notes.append(freshness_warning)
|
|
||||||
notes.extend(report.warnings)
|
|
||||||
return _dedupe_notes(notes)
|
|
||||||
|
|
||||||
|
|
||||||
def collect_html_warnings_comparison(
|
|
||||||
entity_reports: list[tuple[str, schema.Report]],
|
|
||||||
) -> list[str]:
|
|
||||||
"""Collect comparison-mode warnings, prefixed by entity label."""
|
|
||||||
notes: list[str] = []
|
|
||||||
for label, report in entity_reports:
|
|
||||||
for w in collect_html_warnings(report):
|
|
||||||
notes.append(f"{label}: {w}")
|
|
||||||
return notes
|
|
||||||
|
|
||||||
|
|
||||||
def _render_html_metadata(report: schema.Report) -> list[str]:
|
|
||||||
"""Inline metadata as an HTML comment marker.
|
|
||||||
|
|
||||||
html_render.py post-processes ``<!-- META: ... -->`` markers into a
|
|
||||||
``<div class="meta">`` after markdown conversion, so the metadata escapes
|
|
||||||
the markdown converter's HTML-escaping pass cleanly. Same pattern as the
|
|
||||||
PASS_THROUGH_FOOTER marker used for the engine tree.
|
|
||||||
"""
|
|
||||||
non_empty = [s for s, items in sorted(report.items_by_source.items()) if items]
|
|
||||||
if non_empty:
|
|
||||||
sources = ", ".join(_source_label(s) for s in non_empty)
|
|
||||||
else:
|
|
||||||
sources = "no active sources"
|
|
||||||
return [
|
|
||||||
f"<!-- META: {report.range_from} to {report.range_to} · {sources} -->",
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def _render_html_data_quality_note(report: schema.Report) -> str | None:
|
|
||||||
notes: list[str] = []
|
|
||||||
degraded_warning = _render_degraded_run_warning(report)
|
|
||||||
if degraded_warning:
|
|
||||||
notes.append("This run was missing pre-flight resolution. Re-run with `--plan` for richer results.")
|
|
||||||
pre_research_warning = _render_pre_research_warning(report)
|
|
||||||
if pre_research_warning and not degraded_warning:
|
|
||||||
notes.append("Pre-research was skipped, so results may be thinner than a resolved run.")
|
|
||||||
freshness_warning = _assess_data_freshness(report)
|
|
||||||
if freshness_warning:
|
|
||||||
notes.append(freshness_warning)
|
|
||||||
notes.extend(report.warnings)
|
|
||||||
if not notes:
|
|
||||||
return None
|
|
||||||
return f"> **Data quality note:** {' '.join(_dedupe_notes(notes))}"
|
|
||||||
|
|
||||||
|
|
||||||
def _render_html_comparison_data_quality_note(
|
|
||||||
entity_reports: list[tuple[str, schema.Report]],
|
|
||||||
) -> str | None:
|
|
||||||
notes: list[str] = []
|
|
||||||
for label, report in entity_reports:
|
|
||||||
note = _render_html_data_quality_note(report)
|
|
||||||
if note:
|
|
||||||
clean = note.removeprefix("> **Data quality note:** ").strip()
|
|
||||||
notes.append(f"{label}: {clean}")
|
|
||||||
if not notes:
|
|
||||||
return None
|
|
||||||
return f"> **Data quality note:** {' '.join(_dedupe_notes(notes))}"
|
|
||||||
|
|
||||||
|
|
||||||
def _dedupe_notes(notes: list[str]) -> list[str]:
|
|
||||||
out: list[str] = []
|
|
||||||
seen: set[str] = set()
|
|
||||||
for note in notes:
|
|
||||||
normalized = " ".join(str(note).split())
|
|
||||||
if not normalized or normalized in seen:
|
|
||||||
continue
|
|
||||||
seen.add(normalized)
|
|
||||||
out.append(normalized)
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
def _append_html_footer(lines: list[str], report: schema.Report, save_path: str | None) -> None:
|
|
||||||
footer = _render_emoji_footer(report, save_path)
|
|
||||||
lines.append("")
|
|
||||||
lines.append("<!-- PASS-THROUGH FOOTER: emit verbatim in the model response per LAW 5. -->")
|
|
||||||
lines.extend(footer)
|
|
||||||
lines.append("<!-- END PASS-THROUGH FOOTER -->")
|
|
||||||
|
|
||||||
|
|
||||||
def _render_canonical_boundary() -> list[str]:
|
def _render_canonical_boundary() -> list[str]:
|
||||||
"""Emit the explicit END-OF-CANONICAL-OUTPUT boundary.
|
"""Emit the explicit END-OF-CANONICAL-OUTPUT boundary.
|
||||||
|
|
||||||
@@ -827,7 +663,7 @@ def render_full(report: schema.Report) -> str:
|
|||||||
lines.append("## All Items by Source")
|
lines.append("## All Items by Source")
|
||||||
lines.append("")
|
lines.append("")
|
||||||
source_order = ["reddit", "x", "youtube", "tiktok", "instagram", "threads", "pinterest",
|
source_order = ["reddit", "x", "youtube", "tiktok", "instagram", "threads", "pinterest",
|
||||||
"hackernews", "bluesky", "truthsocial", "polymarket", "grounding", "xiaohongshu", "github", "digg", "perplexity"]
|
"hackernews", "bluesky", "truthsocial", "polymarket", "grounding", "xiaohongshu", "github", "perplexity"]
|
||||||
for source in source_order:
|
for source in source_order:
|
||||||
items = report.items_by_source.get(source, [])
|
items = report.items_by_source.get(source, [])
|
||||||
if not items:
|
if not items:
|
||||||
@@ -853,9 +689,6 @@ def render_full(report: schema.Report) -> str:
|
|||||||
tc_score = tc.get("score", "")
|
tc_score = tc.get("score", "")
|
||||||
attribution = _comment_attribution(item.source, tc.get("author"))
|
attribution = _comment_attribution(item.source, tc.get("author"))
|
||||||
lines.append(f" Top comment {attribution} ({tc_score} {vote_label}): {excerpt}")
|
lines.append(f" Top comment {attribution} ({tc_score} {vote_label}): {excerpt}")
|
||||||
# Digg AI 1000: inline X-post quotes attached to the cluster.
|
|
||||||
for post in _digg_posts_for(item, limit=3):
|
|
||||||
lines.append(f" > {_format_digg_quote(post)}")
|
|
||||||
# Comment insights for Reddit
|
# Comment insights for Reddit
|
||||||
insights = item.metadata.get("comment_insights", [])
|
insights = item.metadata.get("comment_insights", [])
|
||||||
if insights:
|
if insights:
|
||||||
@@ -977,8 +810,6 @@ def _render_candidate(candidate: schema.Candidate, prefix: str) -> list[str]:
|
|||||||
source = primary.source if primary else None
|
source = primary.source if primary else None
|
||||||
attribution = _comment_attribution(source, tc.get("author"))
|
attribution = _comment_attribution(source, tc.get("author"))
|
||||||
lines.append(f" - {attribution} ({score} {vote_label}): {_truncate(excerpt.strip(), 240)}")
|
lines.append(f" - {attribution} ({score} {vote_label}): {_truncate(excerpt.strip(), 240)}")
|
||||||
for post in _digg_posts_for(primary):
|
|
||||||
lines.append(f" - {_format_digg_quote(post)}")
|
|
||||||
insight = _comment_insight(primary)
|
insight = _comment_insight(primary)
|
||||||
if insight:
|
if insight:
|
||||||
lines.append(f" - Insight: {_truncate(insight, 220)}")
|
lines.append(f" - Insight: {_truncate(insight, 220)}")
|
||||||
@@ -1229,7 +1060,6 @@ _FOOTER_SOURCES: list[tuple[str, str, str, str, list[tuple[str, str]]]] = [
|
|||||||
("bluesky", "🦋", "Bluesky", "post", [("likes", "likes"), ("reposts", "reposts")]),
|
("bluesky", "🦋", "Bluesky", "post", [("likes", "likes"), ("reposts", "reposts")]),
|
||||||
("truthsocial", "🇺🇸", "Truth Social", "post", [("likes", "likes"), ("reposts", "reposts")]),
|
("truthsocial", "🇺🇸", "Truth Social", "post", [("likes", "likes"), ("reposts", "reposts")]),
|
||||||
("github", "🐙", "GitHub", "item", [("reactions", "reactions"), ("comments", "comments")]),
|
("github", "🐙", "GitHub", "item", [("reactions", "reactions"), ("comments", "comments")]),
|
||||||
("digg", "⛏️", "Digg AI 1000", "cluster", [("postCount", "posts"), ("uniqueAuthors", "authors")]),
|
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@@ -1487,7 +1317,6 @@ ENGAGEMENT_DISPLAY: dict[str, list[tuple[str, str]]] = {
|
|||||||
"polymarket": [],
|
"polymarket": [],
|
||||||
"github": [("reactions", "react"), ("comments", "cmt")],
|
"github": [("reactions", "react"), ("comments", "cmt")],
|
||||||
"perplexity": [("citations", "cite")],
|
"perplexity": [("citations", "cite")],
|
||||||
"digg": [("postCount", "posts"), ("uniqueAuthors", "auth")],
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -1675,6 +1504,16 @@ def _top_comments_list(item: schema.SourceItem | None, limit: int = 3, min_score
|
|||||||
return [c for c in comments if (c.get("score") or 0) >= min_score][:limit]
|
return [c for c in comments if (c.get("score") or 0) >= min_score][:limit]
|
||||||
|
|
||||||
|
|
||||||
|
def _top_comment_excerpt(item: schema.SourceItem | None) -> str | None:
|
||||||
|
if not item:
|
||||||
|
return None
|
||||||
|
comments = item.metadata.get("top_comments") or []
|
||||||
|
if not comments or not isinstance(comments[0], dict):
|
||||||
|
return None
|
||||||
|
top = comments[0]
|
||||||
|
return str(top.get("excerpt") or top.get("text") or "").strip() or None
|
||||||
|
|
||||||
|
|
||||||
def _comment_insight(item: schema.SourceItem | None) -> str | None:
|
def _comment_insight(item: schema.SourceItem | None) -> str | None:
|
||||||
if not item:
|
if not item:
|
||||||
return None
|
return None
|
||||||
@@ -1684,39 +1523,6 @@ def _comment_insight(item: schema.SourceItem | None) -> str | None:
|
|||||||
return str(insights[0]).strip() or None
|
return str(insights[0]).strip() or None
|
||||||
|
|
||||||
|
|
||||||
def _digg_posts_for(item: schema.SourceItem | None, limit: int = 2) -> list[dict]:
|
|
||||||
"""Return up to `limit` parsed Digg posts attached as enrichment to a cluster.
|
|
||||||
|
|
||||||
Returns an empty list for non-digg sources or clusters without enrichment.
|
|
||||||
"""
|
|
||||||
if not item or item.source != "digg":
|
|
||||||
return []
|
|
||||||
posts = item.metadata.get("posts") or []
|
|
||||||
if not isinstance(posts, list):
|
|
||||||
return []
|
|
||||||
out: list[dict] = []
|
|
||||||
for entry in posts:
|
|
||||||
if isinstance(entry, dict) and entry.get("body") and entry.get("username"):
|
|
||||||
out.append(entry)
|
|
||||||
if len(out) >= limit:
|
|
||||||
break
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
def _format_digg_quote(post: dict, body_limit: int = 200) -> str:
|
|
||||||
"""Format a Digg-attached X post as an inline 'via Digg AI 1000' quote line."""
|
|
||||||
handle = post.get("username") or ""
|
|
||||||
x_url = post.get("x_url") or ""
|
|
||||||
body = (post.get("body") or "").replace("\n", " ").strip()
|
|
||||||
if len(body) > body_limit:
|
|
||||||
body = body[: body_limit - 1].rstrip() + "…"
|
|
||||||
if x_url and handle:
|
|
||||||
return f"[@{handle}]({x_url}) via Digg AI 1000: {body}"
|
|
||||||
if handle:
|
|
||||||
return f"@{handle} via Digg AI 1000: {body}"
|
|
||||||
return f"via Digg AI 1000: {body}"
|
|
||||||
|
|
||||||
|
|
||||||
def _transcript_highlights(item: schema.SourceItem | None) -> list[str]:
|
def _transcript_highlights(item: schema.SourceItem | None) -> list[str]:
|
||||||
if not item or item.source != "youtube":
|
if not item or item.source != "youtube":
|
||||||
return []
|
return []
|
||||||
@@ -12,7 +12,6 @@ SOURCE_QUALITY = {
|
|||||||
"xiaohongshu": 0.7,
|
"xiaohongshu": 0.7,
|
||||||
"hackernews": 0.8,
|
"hackernews": 0.8,
|
||||||
"youtube": 0.85,
|
"youtube": 0.85,
|
||||||
"digg": 0.85,
|
|
||||||
"reddit": 0.6,
|
"reddit": 0.6,
|
||||||
"x": 0.68,
|
"x": 0.68,
|
||||||
"bluesky": 0.66,
|
"bluesky": 0.66,
|
||||||
@@ -27,10 +26,7 @@ def source_quality(source: str) -> float:
|
|||||||
return SOURCE_QUALITY.get(source, 0.6)
|
return SOURCE_QUALITY.get(source, 0.6)
|
||||||
|
|
||||||
|
|
||||||
def local_relevance(
|
def local_relevance(item: schema.SourceItem, ranking_query: str) -> float:
|
||||||
item: schema.SourceItem,
|
|
||||||
ranking_query: "str | relevance.PreparedQuery",
|
|
||||||
) -> float:
|
|
||||||
text = "\n".join(
|
text = "\n".join(
|
||||||
part
|
part
|
||||||
for part in [item.title, item.body, item.snippet]
|
for part in [item.title, item.body, item.snippet]
|
||||||
@@ -96,7 +92,6 @@ ENGAGEMENT_WEIGHTS: dict[str, list[tuple[str, float]]] = {
|
|||||||
"bluesky": [("likes", 0.40), ("reposts", 0.30), ("replies", 0.20), ("quotes", 0.10)],
|
"bluesky": [("likes", 0.40), ("reposts", 0.30), ("replies", 0.20), ("quotes", 0.10)],
|
||||||
"truthsocial": [("likes", 0.45), ("reposts", 0.30), ("replies", 0.25)],
|
"truthsocial": [("likes", 0.45), ("reposts", 0.30), ("replies", 0.25)],
|
||||||
"polymarket": [("volume", 0.60), ("liquidity", 0.40)],
|
"polymarket": [("volume", 0.60), ("liquidity", 0.40)],
|
||||||
"digg": [("postCount", 0.40), ("uniqueAuthors", 0.30), ("rank_score", 0.30)],
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -180,14 +175,13 @@ def normalize(values: list[float | None]) -> list[int | None]:
|
|||||||
|
|
||||||
def annotate_stream(
|
def annotate_stream(
|
||||||
items: list[schema.SourceItem],
|
items: list[schema.SourceItem],
|
||||||
ranking_query: "str | relevance.PreparedQuery",
|
ranking_query: str,
|
||||||
freshness_mode: str,
|
freshness_mode: str,
|
||||||
) -> list[schema.SourceItem]:
|
) -> list[schema.SourceItem]:
|
||||||
"""Attach local scoring metadata and return items sorted by local_rank_score."""
|
"""Attach local scoring metadata and return items sorted by local_rank_score."""
|
||||||
prepared_query = ranking_query if isinstance(ranking_query, relevance.PreparedQuery) else relevance.PreparedQuery(ranking_query)
|
|
||||||
engagement_scores = normalize([engagement_raw(item) for item in items])
|
engagement_scores = normalize([engagement_raw(item) for item in items])
|
||||||
for item, eng_score in zip(items, engagement_scores, strict=True):
|
for item, eng_score in zip(items, engagement_scores, strict=True):
|
||||||
item.local_relevance = local_relevance(item, prepared_query)
|
item.local_relevance = local_relevance(item, ranking_query)
|
||||||
item.freshness = freshness(item, freshness_mode)
|
item.freshness = freshness(item, freshness_mode)
|
||||||
item.engagement_score = eng_score
|
item.engagement_score = eng_score
|
||||||
item.source_quality = source_quality(item.source)
|
item.source_quality = source_quality(item.source)
|
||||||
@@ -26,7 +26,7 @@ def _windows(words: list[str], size: int, overlap: int) -> list[str]:
|
|||||||
|
|
||||||
def extract_best_snippet(
|
def extract_best_snippet(
|
||||||
item: schema.SourceItem,
|
item: schema.SourceItem,
|
||||||
ranking_query: "str | relevance.PreparedQuery",
|
ranking_query: str,
|
||||||
max_words: int = 120,
|
max_words: int = 120,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Prefer existing snippets, else extract the best matching evidence window."""
|
"""Prefer existing snippets, else extract the best matching evidence window."""
|
||||||
@@ -43,9 +43,8 @@ def extract_best_snippet(
|
|||||||
if not candidates:
|
if not candidates:
|
||||||
return _truncate_words(body, max_words)
|
return _truncate_words(body, max_words)
|
||||||
|
|
||||||
prepared_query = ranking_query if isinstance(ranking_query, relevance.PreparedQuery) else relevance.PreparedQuery(ranking_query)
|
|
||||||
best = max(
|
best = max(
|
||||||
candidates,
|
candidates,
|
||||||
key=lambda candidate: relevance.token_overlap_relevance(prepared_query, candidate),
|
key=lambda candidate: relevance.token_overlap_relevance(ranking_query, candidate),
|
||||||
)
|
)
|
||||||
return _truncate_words(best, max_words)
|
return _truncate_words(best, max_words)
|
||||||
@@ -124,7 +124,6 @@ SOURCE_COMPLETION_ORDER = [
|
|||||||
"polymarket",
|
"polymarket",
|
||||||
"grounding",
|
"grounding",
|
||||||
"xiaohongshu",
|
"xiaohongshu",
|
||||||
"digg",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
SOURCE_COMPLETION_META = {
|
SOURCE_COMPLETION_META = {
|
||||||
@@ -139,7 +138,6 @@ SOURCE_COMPLETION_META = {
|
|||||||
"polymarket": ("Polymarket", "market", "markets", Colors.GREEN),
|
"polymarket": ("Polymarket", "market", "markets", Colors.GREEN),
|
||||||
"grounding": ("Web", "result", "results", Colors.GREEN),
|
"grounding": ("Web", "result", "results", Colors.GREEN),
|
||||||
"xiaohongshu": ("Xiaohongshu", "post", "posts", Colors.RED),
|
"xiaohongshu": ("Xiaohongshu", "post", "posts", Colors.RED),
|
||||||
"digg": ("Digg", "cluster", "clusters", Colors.YELLOW),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||