Compare commits
45 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 61d46b54ee | |||
| e7b7e61237 | |||
| a547a0a948 | |||
| 8ea048b988 | |||
| 1b23a3e900 | |||
| 35f12cb9ea | |||
| a1afbce84c | |||
| 15781bfcc0 | |||
| d9f606ff75 | |||
| 4a30923892 | |||
| 9fb19eae63 | |||
| d1cc29d338 | |||
| ded52062e6 | |||
| 164d7ae6ed | |||
| f1ce7533e6 | |||
| 0b939bf703 | |||
| 9f95efb215 | |||
| ff54c07a3b | |||
| e276c30477 | |||
| 2f277dfc66 | |||
| 6c2c55733c | |||
| 997708ad48 | |||
| c913e1cf89 | |||
| 54db014c7c | |||
| 80a1a47eef | |||
| c845f483d6 | |||
| dc934ddb6a | |||
| 80392061d4 | |||
| c04bd67922 | |||
| b1773be8f3 | |||
| 5b87cca886 | |||
| bbf892aecc | |||
| 2acbf8a869 | |||
| e6b89f2644 | |||
| 2c2755b49c | |||
| 18b5658674 | |||
| 145adc9f56 | |||
| b100caf2df | |||
| dc0cb9850b | |||
| ceec99b24c | |||
| d1823a2d05 | |||
| 17caa0526d | |||
| f03cb866aa | |||
| 72495c1c14 | |||
| 1f7e85a03f |
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"name": "last30days-skill",
|
||||
"interface": {
|
||||
"displayName": "Last 30 Days"
|
||||
},
|
||||
"plugins": [
|
||||
{
|
||||
"name": "last30days",
|
||||
"source": {
|
||||
"source": "local",
|
||||
"path": "./"
|
||||
},
|
||||
"policy": {
|
||||
"installation": "AVAILABLE",
|
||||
"authentication": "ON_INSTALL"
|
||||
},
|
||||
"category": "Research"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,16 +1,17 @@
|
||||
{
|
||||
"$schema": "https://anthropic.com/claude-code/marketplace.schema.json",
|
||||
"name": "last30days-skill",
|
||||
"description": "Research any topic across Reddit, X, YouTube, TikTok, Instagram, HN, Polymarket, GitHub, and 5+ more sources.",
|
||||
"owner": {
|
||||
"name": "Matt Van Horn",
|
||||
"url": "https://github.com/mvanhorn"
|
||||
},
|
||||
"metadata": {
|
||||
"description": "Marketplace hosting the Last 30 Days research plugin."
|
||||
},
|
||||
"plugins": [
|
||||
{
|
||||
"name": "last30days",
|
||||
"description": "Research any topic across Reddit, X, YouTube, TikTok, Instagram, HN, Polymarket, GitHub, and 5+ more sources.",
|
||||
"version": "3.0.9",
|
||||
"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.",
|
||||
"version": "3.2.3",
|
||||
"author": {
|
||||
"name": "Matt Van Horn",
|
||||
"url": "https://github.com/mvanhorn"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "last30days",
|
||||
"version": "3.0.14",
|
||||
"version": "3.2.3",
|
||||
"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",
|
||||
@@ -10,6 +10,5 @@
|
||||
"homepage": "https://github.com/mvanhorn/last30days-skill",
|
||||
"repository": "https://github.com/mvanhorn/last30days-skill",
|
||||
"license": "MIT",
|
||||
"keywords": ["research", "reddit", "twitter", "youtube", "tiktok", "instagram", "trends", "prompts", "polymarket", "github", "perplexity", "threads", "pinterest", "eli5", "hacker-news"],
|
||||
"hooks": {}
|
||||
"keywords": ["research", "reddit", "twitter", "youtube", "tiktok", "instagram", "trends", "prompts", "polymarket", "github", "perplexity", "threads", "pinterest", "eli5", "hacker-news"]
|
||||
}
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
{
|
||||
"name": "last30days"
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
# Exclude non-runtime files from `git archive` output.
|
||||
# Used by scripts/build-skill.sh to produce a claude.ai-upload-ready .skill file.
|
||||
# Used by skills/last30days/scripts/build-skill.sh to produce a
|
||||
# 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.
|
||||
|
||||
# Anthropic canonical skill-packaging excludes
|
||||
@@ -32,9 +33,8 @@ release-notes.md export-ignore
|
||||
CHANGELOG.md export-ignore
|
||||
uv.lock export-ignore
|
||||
|
||||
# Platform adapters - skill-upload path is platform-agnostic
|
||||
.agents/ export-ignore
|
||||
.codex-plugin/ export-ignore
|
||||
# Platform adapters are kept in git archives because Claude Code and Codex
|
||||
# plugin installs use the same repository archive as their source payload.
|
||||
.hermes-plugin/ export-ignore
|
||||
|
||||
# CI workflows - repo-only, not needed at skill runtime
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
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.
|
||||
@@ -0,0 +1,24 @@
|
||||
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).
|
||||
@@ -0,0 +1,19 @@
|
||||
## 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`
|
||||
|
||||
## Related Issues
|
||||
|
||||
<!-- Link issues: Fixes #123 or Relates to #456 -->
|
||||
@@ -9,7 +9,10 @@ permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
build-and-release:
|
||||
# Build the existing .skill artifact (Claude Code / Codex / Cursor install
|
||||
# surface). Unchanged from prior versions; just isolated into its own job
|
||||
# so the .mcpb matrix can run in parallel.
|
||||
build-skill:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -19,13 +22,104 @@ jobs:
|
||||
|
||||
- name: Build .skill artifact
|
||||
run: |
|
||||
bash scripts/build-skill.sh
|
||||
bash skills/last30days/scripts/build-skill.sh
|
||||
test -f dist/last30days.skill
|
||||
|
||||
- name: Upload skill artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: last30days-skill
|
||||
path: dist/last30days.skill
|
||||
|
||||
# Cross-compile the Go MCP server for each Claude Desktop platform and
|
||||
# package each as a .mcpb. printing-press bundle handles the manifest +
|
||||
# zip layout; we only supply the pre-built binary via --skip-build.
|
||||
build-mcpb:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- goos: darwin
|
||||
goarch: arm64
|
||||
platform: darwin/arm64
|
||||
- goos: darwin
|
||||
goarch: amd64
|
||||
platform: darwin/amd64
|
||||
- goos: linux
|
||||
goarch: amd64
|
||||
platform: linux/amd64
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: stable
|
||||
|
||||
- name: Install printing-press
|
||||
# Pin to a known-good PP release so the bundle command's behavior
|
||||
# is deterministic across our tags. Bump deliberately when adopting
|
||||
# a newer PP version. GOSUMDB=off skips the sumdb 404 some
|
||||
# private-namespaced go install calls hit even when the repo is
|
||||
# public; harmless here because the module path is fully qualified.
|
||||
env:
|
||||
GOPRIVATE: github.com/mvanhorn/*
|
||||
GOSUMDB: "off"
|
||||
run: go install github.com/mvanhorn/cli-printing-press/v4/cmd/printing-press@v4.8.0
|
||||
|
||||
- name: Sync engine into vendored/
|
||||
run: bash mcp/scripts/sync-engine.sh
|
||||
|
||||
- name: Build MCP binary
|
||||
env:
|
||||
GOOS: ${{ matrix.goos }}
|
||||
GOARCH: ${{ matrix.goarch }}
|
||||
CGO_ENABLED: "0"
|
||||
run: |
|
||||
mkdir -p mcp/build
|
||||
go -C mcp build \
|
||||
-ldflags "-X main.Version=${{ github.ref_name }}" \
|
||||
-o build/last30days-pp-mcp \
|
||||
./cmd/last30days-pp-mcp
|
||||
|
||||
- name: Bundle .mcpb
|
||||
# printing-press bundle reads manifest.json from the cli dir and
|
||||
# rewrites the binary into bin/<entry_point> inside the zip. The
|
||||
# --platform tag drives the output filename suffix; the binary
|
||||
# itself is whatever we just cross-compiled.
|
||||
run: |
|
||||
printing-press bundle mcp \
|
||||
--skip-build \
|
||||
--binary mcp/build/last30days-pp-mcp \
|
||||
--platform ${{ matrix.platform }} \
|
||||
--output mcp/build/last30days-pp-mcp-${{ matrix.goos }}-${{ matrix.goarch }}.mcpb
|
||||
|
||||
- name: Upload .mcpb artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: mcpb-${{ matrix.goos }}-${{ matrix.goarch }}
|
||||
path: mcp/build/last30days-pp-mcp-${{ matrix.goos }}-${{ matrix.goarch }}.mcpb
|
||||
|
||||
# Gather every platform artifact and attach to one GitHub release.
|
||||
# release-notes generation reads commits since the prior tag.
|
||||
release:
|
||||
needs: [build-skill, build-mcpb]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Download all artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: dist
|
||||
merge-multiple: true
|
||||
|
||||
- name: Create GitHub release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
files: dist/last30days.skill
|
||||
files: |
|
||||
dist/last30days.skill
|
||||
dist/last30days-pp-mcp-*.mcpb
|
||||
generate_release_notes: true
|
||||
draft: false
|
||||
prerelease: false
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
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
|
||||
@@ -26,11 +26,14 @@ htmlcov/
|
||||
# build artifact from scripts/build-skill.sh
|
||||
/dist/
|
||||
|
||||
# Go MCP bundle build outputs - source of truth for vendored/ stays under
|
||||
# skills/last30days/scripts/; build/ holds cross-compiled binaries + .mcpb files.
|
||||
# vendored/ lives inside the engine package because //go:embed cannot reach
|
||||
# outside its own package directory; the .gitkeep anchor stays tracked so
|
||||
# the embed pattern always finds a match even before sync-engine runs.
|
||||
/mcp/internal/engine/vendored/*
|
||||
!/mcp/internal/engine/vendored/.gitkeep
|
||||
/mcp/build/
|
||||
|
||||
# Internal planning docs (ce:plan output) — keep local, don't publish
|
||||
docs/plans/
|
||||
|
||||
# Marketing video build artifacts
|
||||
marketing/v3.1-launch/node_modules/
|
||||
marketing/v3.1-launch/out/
|
||||
marketing/*/node_modules/
|
||||
marketing/*/out/
|
||||
|
||||
@@ -5,6 +5,52 @@ All notable changes to this project will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Changed
|
||||
|
||||
- Rename "Digg AI 1000" to just "Digg" in user-facing output (footer line, source label, inline-quote suffix, why_relevant, container attribution). Internal references to the upstream Digg AI 1000 product remain in code comments and docstrings.
|
||||
- Bump `POSTS_PER_CLUSTER` from 3 to 5 and the render-side display limit from 2 to 3 to match the per-source enrichment caps used by Reddit, HN, YouTube, TikTok, and GitHub. The previous 3/2 caps routinely truncated cluster context (e.g. dropped a Jason Calacanis quote tweet on a `cli-printing-press` run).
|
||||
- Rewrite SKILL.md path resolution. STEP 0 narrows from a global canonical-path enforcement to a Claude-Code-marketplaces-only stale-clone guard. Step 1 SKILL_ROOT resolver walks a single precedence list (Claude plugin cache, then `~/.codex/skills/`, `~/.agents/skills/`, repo checkout, `./.skills/last30days` for `npx skills add`, CWD, Gemini). Adds SKILL.md frontmatter fallback to `render.py::_skill_version` so the badge no longer prints `v?` on installs that don't include `.claude-plugin/plugin.json`.
|
||||
|
||||
- Switch SKILL.md's `--plan` and `--competitors-plan` invocation templates from inline single-quoted JSON to heredoc-written tmpfiles. Apostrophes in resolved context strings ("McDonald's", "people's choice", "developer's") previously closed the outer single-quote and broke shell parsing before the engine started — observed in a Codex run during PR #400 testing. The engine's `parse_plan()` / `parse_competitors_plan()` already supported file paths (via `os.path.isfile()` probe); only the template prose changed. Fixes [#403](https://github.com/mvanhorn/last30days-skill/issues/403).
|
||||
|
||||
### Removed
|
||||
|
||||
- **BREAKING for Codex native-plugin users:** `.codex-plugin/plugin.json` and the matching SKILL_ROOT resolver branch in SKILL.md Step 1. Codex users should install via `npx skills add mvanhorn/last30days-skill` or copy the skill to `~/.codex/skills/last30days/`.
|
||||
- **`skills/last30days/scripts/sync.sh`.** The maintainer dev-deploy script is gone. Every job it did has a better replacement: `npx skills add . -g -y` symlinks the working tree into every detected harness's skill dir (better than sync.sh's copy model — edits propagate live), `hermes skills install mvanhorn/last30days-skill --force` handles Hermes, `clawhub install last30days-official` handles OpenClaw, and the Claude marketplace cache target was a "test against the official install path" hack we shouldn't have been recommending in the first place. The `test_sync_cache_path_uses_skill_version` test was dropped along with it. CLAUDE.md, HERMES_SETUP.md, the PR template, and a render.py docstring were updated to drop references; CHANGELOG and historical docs (release notes, plan files) keep their existing mentions as accurate history.
|
||||
|
||||
## [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
|
||||
|
||||
### Changed
|
||||
@@ -61,6 +107,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
### 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.
|
||||
- **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
|
||||
|
||||
|
||||
@@ -4,20 +4,20 @@ Claude Code skill for researching any topic across Reddit, X, YouTube, and web.
|
||||
Python scripts with multi-source search aggregation.
|
||||
|
||||
## Structure
|
||||
- `scripts/last30days.py` — main research engine
|
||||
- `scripts/lib/` — search, enrichment, rendering modules
|
||||
- `scripts/lib/vendor/bird-search/` — vendored X search client
|
||||
- `SKILL.md` — skill definition (deployed to ~/.claude/skills/last30days/)
|
||||
- `skills/last30days/SKILL.md` — canonical skill definition
|
||||
- `skills/last30days/scripts/last30days.py` — main research engine
|
||||
- `skills/last30days/scripts/lib/` — search, enrichment, rendering modules
|
||||
- `skills/last30days/scripts/lib/vendor/bird-search/` — vendored X search client
|
||||
|
||||
## Commands
|
||||
```bash
|
||||
python3 scripts/last30days.py "test query" --emit=compact # Run research
|
||||
bash scripts/sync.sh # Deploy to ~/.claude, ~/.agents, ~/.codex
|
||||
python3 skills/last30days/scripts/last30days.py "test query" --emit=compact
|
||||
npx skills add . -g -y # one-time: symlink this repo into every detected harness's skill dir
|
||||
```
|
||||
|
||||
## Rules
|
||||
- `lib/__init__.py` must be bare package marker (comment only, NO eager imports)
|
||||
- After edits: run `bash scripts/sync.sh` to deploy
|
||||
- One-time setup: `npx skills add . -g -y` creates symlinks from each detected harness's skill dir to this repo. Edits in the working tree propagate live to every harness — no re-deploy step needed.
|
||||
- Git remote: origin = public (`mvanhorn/last30days-skill`)
|
||||
|
||||
## Beta channel
|
||||
|
||||
@@ -10,28 +10,20 @@ This guide covers installing last30days on Hermes AI Agent.
|
||||
|
||||
## Installation
|
||||
|
||||
### Option 1: Via sync.sh (Recommended)
|
||||
|
||||
```bash
|
||||
# Clone the repo
|
||||
git clone https://github.com/mvanhorn/last30days-skill.git
|
||||
cd last30days-skill
|
||||
|
||||
# Run the sync script
|
||||
bash scripts/sync.sh
|
||||
hermes skills install mvanhorn/last30days-skill --force
|
||||
```
|
||||
|
||||
This will auto-detect Hermes and deploy to `~/.hermes/skills/research/last30days/`
|
||||
This pulls the latest release from GitHub and deploys to `~/.hermes/skills/research/last30days/`. `--force` reinstalls over any existing copy.
|
||||
|
||||
### Option 2: Manual Copy
|
||||
### Developer / live-edit alternative
|
||||
|
||||
If you're hacking on the skill locally and want edits to propagate to Hermes without re-installing, symlink your working tree:
|
||||
|
||||
```bash
|
||||
# Create directory
|
||||
mkdir -p ~/.hermes/skills/research/last30days
|
||||
|
||||
# Copy files
|
||||
cp -r scripts ~/.hermes/skills/research/last30days/
|
||||
cp .hermes-plugin/SKILL.md ~/.hermes/skills/research/last30days/
|
||||
git clone https://github.com/mvanhorn/last30days-skill.git
|
||||
mkdir -p ~/.hermes/skills/research
|
||||
ln -s "$(pwd)/last30days-skill/skills/last30days" ~/.hermes/skills/research/last30days
|
||||
```
|
||||
|
||||
## Usage
|
||||
@@ -106,14 +98,12 @@ python3.12 scripts/last30days.py --diagnose
|
||||
|
||||
## Updating
|
||||
|
||||
To update to the latest version:
|
||||
|
||||
```bash
|
||||
cd last30days-skill
|
||||
git pull
|
||||
bash scripts/sync.sh
|
||||
hermes skills install mvanhorn/last30days-skill --force
|
||||
```
|
||||
|
||||
If you symlinked your working tree (developer alternative above), just `git pull` in the repo — edits propagate live, no re-install step.
|
||||
|
||||
## Support
|
||||
|
||||
- Original repo: https://github.com/mvanhorn/last30days-skill
|
||||
|
||||
@@ -12,23 +12,20 @@
|
||||
|
||||
**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 [skills/last30days/SKILL.md](skills/last30days/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 [SKILL.md](SKILL.md), which is the source of truth for the latest command and setup behavior.
|
||||
|
||||
Claude Code:
|
||||
**Claude Code (recommended — auto-updates via marketplace):**
|
||||
```
|
||||
/plugin marketplace add mvanhorn/last30days-skill
|
||||
```
|
||||
|
||||
OpenClaw:
|
||||
**Codex, Cursor, Copilot, Gemini CLI, or any of 50+ [Agent Skills](https://agentskills.io) hosts:**
|
||||
```
|
||||
clawhub install last30days-official
|
||||
npx skills add mvanhorn/last30days-skill -g
|
||||
```
|
||||
(`-g` installs globally for your user, available across all projects. Drop it to scope per-project.)
|
||||
|
||||
Hermes:
|
||||
```
|
||||
# The skill auto-deploys when you run sync.sh
|
||||
# Or manually copy to ~/.hermes/skills/research/last30days/
|
||||
```
|
||||
More install options (claude.ai web, OpenClaw, manual) in the [Install](#install) section below.
|
||||
|
||||
Zero config. Reddit, HN, Polymarket, and GitHub work immediately. Run it once and the setup wizard unlocks X, YouTube, TikTok, and more in 30 seconds.
|
||||
|
||||
@@ -68,6 +65,7 @@ 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. |
|
||||
| **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. |
|
||||
| **Digg** | Curated story clusters from Digg's AI 1000 leaderboard (~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. |
|
||||
| **Pinterest** | Visual discovery. Pins, saves, and comments on products and ideas. |
|
||||
| **Bluesky** | The decentralized social layer. AT Protocol posts from the post-Twitter migration. |
|
||||
@@ -96,6 +94,28 @@ The synthesis ranks by what real people actually engaged with. Social relevancy,
|
||||
|
||||
## 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
|
||||
|
||||
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.
|
||||
@@ -145,12 +165,62 @@ Say "eli5 on" after any research run. The synthesis rewrites in plain language.
|
||||
|
||||
## Install
|
||||
|
||||
| Surface | Install |
|
||||
|---------|---------|
|
||||
| **claude.ai** (web) | [Download `last30days.skill`](https://github.com/mvanhorn/last30days-skill/releases/latest/download/last30days.skill) and upload via Settings > Capabilities > Skills > + |
|
||||
| **Claude Code** | `/plugin marketplace add mvanhorn/last30days-skill` |
|
||||
| **OpenClaw** | `clawhub install last30days-official` |
|
||||
| **Gemini CLI** | Clone then `gemini extensions install ./last30days-skill` (see below) |
|
||||
| Surface | Install | Updates |
|
||||
|---------|---------|---------|
|
||||
| **Claude Code** (recommended) | `/plugin marketplace add mvanhorn/last30days-skill` | Auto via marketplace, or `claude plugin update last30days@last30days-skill` |
|
||||
| **Codex, Cursor, Copilot, Gemini CLI, GitHub Copilot, or any of 50+ [Agent Skills](https://agentskills.io) hosts** | `npx skills add mvanhorn/last30days-skill -g` | `npx skills update last30days -g` |
|
||||
| **claude.ai** (web) | [Download `last30days.skill`](https://github.com/mvanhorn/last30days-skill/releases/latest/download/last30days.skill) and upload via Settings > Capabilities > Skills > + | Re-download and re-upload |
|
||||
| **Claude Desktop** | [Download the `.mcpb` for your platform](https://github.com/mvanhorn/last30days-skill/releases/latest) and drag into Settings > Extensions | Re-download and drag the new bundle in |
|
||||
| **OpenClaw** | `clawhub install last30days-official` | `clawhub update last30days-official` |
|
||||
|
||||
### Claude Code (recommended)
|
||||
|
||||
```
|
||||
/plugin marketplace add mvanhorn/last30days-skill
|
||||
```
|
||||
|
||||
Recommended because the Claude Code marketplace handles updates for you — the plugin cache is versioned and auto-refreshes when a new release publishes. Run `claude plugin update last30days@last30days-skill` to force a check.
|
||||
|
||||
If you'd rather use the agent-skills install path on Claude Code, that's also supported:
|
||||
|
||||
```
|
||||
npx skills add mvanhorn/last30days-skill -g -a claude-code
|
||||
```
|
||||
|
||||
The native plugin and the `npx skills` install can coexist; Claude Code dedupes the slash command.
|
||||
|
||||
### Codex, Cursor, Copilot, Gemini CLI, and other Agent Skills hosts
|
||||
|
||||
Install via the open [Agent Skills](https://agentskills.io) CLI — supports 50+ harnesses including `codex`, `cursor`, `github-copilot`, `gemini-cli`, `claude-code`, `windsurf`, `cline`, `continue`, `roo`, `aider-desk`, `opencode`, `goose`, and more (full list on the [vercel-labs/skills repo](https://github.com/vercel-labs/skills)).
|
||||
|
||||
```bash
|
||||
npx skills add mvanhorn/last30days-skill -g
|
||||
```
|
||||
|
||||
The `-g` (global) flag installs to your user directory so the skill is available across all projects. Without `-g`, `npx skills` installs project-locally into `./.skills/` (committed with the repo). For a research-the-world tool, global is what you want.
|
||||
|
||||
By default this installs for whichever harness `npx skills` detects. To target a specific one (or multiple):
|
||||
|
||||
```bash
|
||||
npx skills add mvanhorn/last30days-skill -g -a codex
|
||||
npx skills add mvanhorn/last30days-skill -g -a cursor
|
||||
npx skills add mvanhorn/last30days-skill -g -a gemini-cli
|
||||
npx skills add mvanhorn/last30days-skill -g -a codex -a cursor
|
||||
```
|
||||
|
||||
Update later with:
|
||||
|
||||
```bash
|
||||
npx skills update last30days -g
|
||||
```
|
||||
|
||||
Or update everything you've installed globally via `npx skills`:
|
||||
|
||||
```bash
|
||||
npx skills update -g
|
||||
```
|
||||
|
||||
List and remove with `npx skills list -g` and `npx skills remove last30days -g`.
|
||||
|
||||
### claude.ai (web)
|
||||
|
||||
@@ -158,15 +228,25 @@ Say "eli5 on" after any research run. The synthesis rewrites in plain language.
|
||||
2. Go to [claude.ai Settings > Capabilities > Skills](https://claude.ai/settings/capabilities)
|
||||
3. Click the `+` button in the Skills panel and drop the file in
|
||||
|
||||
Enable "Code execution and file creation" under Capabilities first - skills won't run without it.
|
||||
Enable "Code execution and file creation" under Capabilities first — skills won't run without it.
|
||||
|
||||
### Claude Code
|
||||
### Claude Desktop
|
||||
|
||||
```
|
||||
/plugin marketplace add mvanhorn/last30days-skill
|
||||
```
|
||||
Claude Desktop installs `/last30days` as an MCP server via a `.mcpb` bundle (a one-click Model Context Protocol package).
|
||||
|
||||
Update later with `claude plugin update last30days@last30days-skill`.
|
||||
1. Go to the [latest release](https://github.com/mvanhorn/last30days-skill/releases/latest) and download the `.mcpb` for your platform:
|
||||
- macOS Apple Silicon: `last30days-pp-mcp-darwin-arm64.mcpb`
|
||||
- macOS Intel: `last30days-pp-mcp-darwin-amd64.mcpb`
|
||||
- Linux x86_64: `last30days-pp-mcp-linux-amd64.mcpb`
|
||||
2. Open Claude Desktop, go to Settings > Extensions, and drag the file in.
|
||||
3. When prompted, paste API keys for the sources you want to enable. Every field is optional — the engine degrades to web-only mode if you skip them all. Keys are stored in your OS keychain.
|
||||
4. Restart Claude Desktop. Ask Claude to "research Peter Steinberger" or any topic and it will call the `research` tool.
|
||||
|
||||
**Host requirement:** Python 3.12+ on PATH. The bundle ships the engine source but uses your local Python interpreter. Install from [python.org](https://www.python.org/downloads/) on Windows; macOS and most Linux distros ship a compatible version.
|
||||
|
||||
**Keys don't sync with the Code skill.** Claude Desktop and Claude Code maintain separate credential stores by design. If you already configured `~/.config/last30days/.env` for the Code skill, you'll re-enter the same keys here once.
|
||||
|
||||
Windows support is deferred until per-platform manifest entry points are sorted out; track in a follow-up issue.
|
||||
|
||||
### OpenClaw
|
||||
|
||||
@@ -174,22 +254,14 @@ Update later with `claude plugin update last30days@last30days-skill`.
|
||||
clawhub install last30days-official
|
||||
```
|
||||
|
||||
### Gemini CLI
|
||||
|
||||
Gemini CLI v0.9.0 has an upstream installer bug that can fail with `Configuration file not found at /tmp/gemini-extensionXXXXXX/gemini-extension.json` ([upstream issue](https://github.com/google-gemini/gemini-cli/issues/11452)). Workaround:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/mvanhorn/last30days-skill
|
||||
gemini extensions install ./last30days-skill
|
||||
```
|
||||
|
||||
### Manual (developer)
|
||||
|
||||
```bash
|
||||
git clone https://github.com/mvanhorn/last30days-skill.git ~/.claude/skills/last30days
|
||||
git clone https://github.com/mvanhorn/last30days-skill.git
|
||||
ln -s "$(pwd)/last30days-skill/skills/last30days" ~/.claude/skills/last30days
|
||||
```
|
||||
|
||||
Or build the claude.ai `.skill` file from source: `bash scripts/build-skill.sh` produces `dist/last30days.skill`.
|
||||
The symlink keeps the install in sync with your working tree as you edit — no re-copy needed. For `claude.ai`, build the `.skill` file from source: `bash skills/last30days/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.
|
||||
|
||||
|
||||
@@ -1,396 +0,0 @@
|
||||
---
|
||||
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.
|
||||
@@ -1,37 +0,0 @@
|
||||
# /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.
|
||||
@@ -1,23 +0,0 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
import { Config } from "@remotion/cli/config";
|
||||
|
||||
Config.setVideoImageFormat("jpeg");
|
||||
Config.setOverwriteOutput(true);
|
||||
Config.setConcurrency(null);
|
||||
Config.setCodec("h264");
|
||||
@@ -1,35 +0,0 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -1,19 +0,0 @@
|
||||
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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -1,68 +0,0 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -1,135 +0,0 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -1,95 +0,0 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -1,71 +0,0 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -1,4 +0,0 @@
|
||||
import { registerRoot } from "remotion";
|
||||
import { RemotionRoot } from "./Root";
|
||||
|
||||
registerRoot(RemotionRoot);
|
||||
@@ -1,20 +0,0 @@
|
||||
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';
|
||||
@@ -1,13 +0,0 @@
|
||||
// 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;
|
||||
@@ -1,61 +0,0 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -1,100 +0,0 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -1,221 +0,0 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -1,105 +0,0 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -1,54 +0,0 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -1,73 +0,0 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -1,19 +0,0 @@
|
||||
{
|
||||
"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/**/*"]
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
# Mirror of skills/last30days/scripts/, populated by scripts/sync-engine.sh.
|
||||
# Source of truth lives in the Python skill; never commit the mirror.
|
||||
# Lives inside internal/engine/ because //go:embed cannot reach outside
|
||||
# its own package directory.
|
||||
internal/engine/vendored/*
|
||||
!internal/engine/vendored/.gitkeep
|
||||
|
||||
# Local build output: cross-compiled binaries and packaged .mcpb files.
|
||||
build/
|
||||
# Anchor to the mcp/ root so the cmd/last30days-pp-mcp/ package directory
|
||||
# is not also excluded (subdirs with the same name would otherwise match).
|
||||
/last30days-pp-mcp
|
||||
@@ -0,0 +1,36 @@
|
||||
# last30days-pp-mcp
|
||||
|
||||
Go MCP server that wraps the last30days Python engine for Claude Desktop. Packaged as a `.mcpb` bundle (drag-drop install into Claude Desktop).
|
||||
|
||||
The MCP server exposes a single `research` tool that mirrors the `/last30days <topic>` slash command available in Claude Code. At runtime the binary extracts the vendored Python engine into a per-user cache and shells out to `python3` to produce the synthesis input Claude renders.
|
||||
|
||||
## Architecture
|
||||
|
||||
- `cmd/last30days-pp-mcp/` - server entry point
|
||||
- `internal/engine/` - `embed.FS` of the Python engine + cache extractor + subprocess wrapper
|
||||
- `internal/tools/` - MCP tool handlers (currently `research`)
|
||||
- `internal/engine/vendored/` - mirror of `skills/last30days/scripts/`, generated by `scripts/sync-engine.sh` (gitignored). Lives inside the engine package because `//go:embed` cannot reach files outside its own package directory.
|
||||
- `manifest.json` - MCPB v0.3 manifest consumed by Claude Desktop and `printing-press bundle`
|
||||
|
||||
## Local build
|
||||
|
||||
```bash
|
||||
# Mirror the Python engine into vendored/.
|
||||
bash scripts/sync-engine.sh
|
||||
|
||||
# Build for the current host.
|
||||
go build -ldflags "-X main.Version=dev" -o build/last30days-pp-mcp ./cmd/last30days-pp-mcp
|
||||
|
||||
# Package as a .mcpb (requires the printing-press binary on PATH).
|
||||
printing-press bundle . --skip-build --binary build/last30days-pp-mcp
|
||||
```
|
||||
|
||||
The output `.mcpb` lands at `build/last30days-pp-mcp-<os>-<arch>.mcpb`. Drag it into Claude Desktop's Extensions panel to install.
|
||||
|
||||
## Runtime requirements
|
||||
|
||||
End users need Python 3.12+ on PATH. The bundle ships the engine source but relies on the host interpreter.
|
||||
|
||||
## Versioning
|
||||
|
||||
The MCPB `manifest.json` version is hand-bumped in the same PR that ships engine changes worth releasing. Release CI stamps the Go binary's `main.Version` from the tag.
|
||||
@@ -0,0 +1,39 @@
|
||||
// Package main is the entry point for the last30days MCP server bundled
|
||||
// as a .mcpb for Claude Desktop. The server registers a single research
|
||||
// tool (see internal/tools) and serves it over stdio. See mcp/README.md
|
||||
// for build and packaging instructions.
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/mark3labs/mcp-go/server"
|
||||
|
||||
"github.com/mvanhorn/last30days-skill/mcp/internal/tools"
|
||||
)
|
||||
|
||||
// Version is stamped at build time via -ldflags "-X main.Version=<tag>".
|
||||
// It namespaces the per-user cache directory in internal/engine so multiple
|
||||
// installed versions can coexist without clobbering each other.
|
||||
var Version = "dev"
|
||||
|
||||
const (
|
||||
serverName = "last30days"
|
||||
serverVersion = "1"
|
||||
)
|
||||
|
||||
func main() {
|
||||
s := server.NewMCPServer(
|
||||
serverName,
|
||||
serverVersion,
|
||||
server.WithToolCapabilities(false),
|
||||
)
|
||||
|
||||
tools.Register(s, tools.Config{Version: Version})
|
||||
|
||||
if err := server.ServeStdio(s); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "last30days-pp-mcp: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
module github.com/mvanhorn/last30days-skill/mcp
|
||||
|
||||
go 1.25.5
|
||||
|
||||
require github.com/mark3labs/mcp-go v0.54.0
|
||||
|
||||
require (
|
||||
github.com/google/jsonschema-go v0.4.2 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect
|
||||
github.com/spf13/cast v1.7.1 // indirect
|
||||
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
|
||||
golang.org/x/text v0.14.0 // indirect
|
||||
)
|
||||
@@ -0,0 +1,34 @@
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI=
|
||||
github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
||||
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8=
|
||||
github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/mark3labs/mcp-go v0.54.0 h1:PZhQvd+5xrT43cUoiaKn/hDcvLUhcLc1twSEKYPTcTA=
|
||||
github.com/mark3labs/mcp-go v0.54.0/go.mod h1:+8WclSK1ZUweCP3hvktSji8n8ABG/95QaEkeVE/Uwas=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ=
|
||||
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU=
|
||||
github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y=
|
||||
github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
|
||||
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
|
||||
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
@@ -0,0 +1,26 @@
|
||||
// Package engine wraps the vendored Python last30days engine. The engine
|
||||
// is embedded at build time via //go:embed and extracted into a per-user
|
||||
// cache directory on first use, then invoked through python3 in a
|
||||
// subprocess. Consumers should call EnsureUserCache to materialize the
|
||||
// engine and Run to execute it.
|
||||
package engine
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"io/fs"
|
||||
)
|
||||
|
||||
// EngineSourceDir is the embed root inside the binary. scripts/sync-engine.sh
|
||||
// mirrors skills/last30days/scripts/ into this directory before each build.
|
||||
// The all: prefix preserves files starting with "." or "_" so the .gitkeep
|
||||
// anchor file survives - without it the embed would error before sync runs.
|
||||
//
|
||||
//go:embed all:vendored
|
||||
var vendored embed.FS
|
||||
|
||||
// EngineFS returns the embedded engine as a filesystem rooted at the
|
||||
// vendored/ directory contents (so callers see "last30days.py" at the
|
||||
// root, not "vendored/last30days.py").
|
||||
func EngineFS() (fs.FS, error) {
|
||||
return fs.Sub(vendored, "vendored")
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// SentinelFilename names the file Ensure writes inside the cache directory
|
||||
// after a successful extraction. Its contents are compared to the requested
|
||||
// version; a match short-circuits re-extraction on subsequent calls.
|
||||
const SentinelFilename = ".version"
|
||||
|
||||
// cacheSubdir namespaces our cache under the OS user cache directory so
|
||||
// multiple printing-press-style bundles can coexist.
|
||||
const cacheSubdir = "last30days-pp-mcp"
|
||||
|
||||
// CacheEnvOverride lets users redirect the cache directory when the default
|
||||
// OS cache location is read-only (locked-down corp images, ephemeral CI
|
||||
// containers). Pointed at by extract errors via the documented escape hatch.
|
||||
const CacheEnvOverride = "LAST30DAYS_CACHE_DIR"
|
||||
|
||||
// Ensure extracts src into baseDir/last30days-pp-mcp/<version> and returns
|
||||
// the cache path. If the sentinel file already records the same version the
|
||||
// directory is reused without rewriting. version must be non-empty so the
|
||||
// cache layout always namespaces by version.
|
||||
//
|
||||
// Extraction writes to a sibling .tmp directory and renames it on success
|
||||
// so a partial extraction can never be mistaken for a complete one. Concurrent
|
||||
// callers within the same process serialize behind a per-cache-dir sync.Once
|
||||
// so the rename happens exactly once.
|
||||
func Ensure(src fs.FS, baseDir, version string) (string, error) {
|
||||
if version == "" {
|
||||
return "", errors.New("engine: version is required")
|
||||
}
|
||||
cacheDir := filepath.Join(baseDir, cacheSubdir, version)
|
||||
|
||||
once := getOnce(cacheDir)
|
||||
var extractErr error
|
||||
once.Do(func() {
|
||||
extractErr = ensureLocked(src, cacheDir, version)
|
||||
})
|
||||
if extractErr != nil {
|
||||
// Reset the sync.Once so a follow-up call can retry rather than
|
||||
// permanently caching the error. Retry is the right default when
|
||||
// the failure is transient (e.g., disk full, parent dir restored).
|
||||
resetOnce(cacheDir)
|
||||
return "", extractErr
|
||||
}
|
||||
return cacheDir, nil
|
||||
}
|
||||
|
||||
// EnsureUserCache wraps Ensure with the OS user cache dir (or the
|
||||
// LAST30DAYS_CACHE_DIR override) as base. Production callers use this; tests
|
||||
// use Ensure with an explicit temp dir.
|
||||
func EnsureUserCache(src fs.FS, version string) (string, error) {
|
||||
if override := os.Getenv(CacheEnvOverride); override != "" {
|
||||
return Ensure(src, override, version)
|
||||
}
|
||||
base, err := os.UserCacheDir()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("engine: resolve user cache dir (set %s to override): %w", CacheEnvOverride, err)
|
||||
}
|
||||
return Ensure(src, base, version)
|
||||
}
|
||||
|
||||
func ensureLocked(src fs.FS, cacheDir, version string) error {
|
||||
if sentinelMatches(cacheDir, version) {
|
||||
return nil
|
||||
}
|
||||
tmpDir := cacheDir + ".tmp"
|
||||
if err := os.RemoveAll(tmpDir); err != nil {
|
||||
return fmt.Errorf("engine: clean tmp cache: %w", err)
|
||||
}
|
||||
if err := os.MkdirAll(tmpDir, 0o755); err != nil {
|
||||
return fmt.Errorf("engine: create tmp cache (%s, set %s to override): %w", tmpDir, CacheEnvOverride, err)
|
||||
}
|
||||
if err := extractAll(src, tmpDir); err != nil {
|
||||
_ = os.RemoveAll(tmpDir)
|
||||
return err
|
||||
}
|
||||
sentinel := filepath.Join(tmpDir, SentinelFilename)
|
||||
if err := os.WriteFile(sentinel, []byte(version), 0o644); err != nil {
|
||||
_ = os.RemoveAll(tmpDir)
|
||||
return fmt.Errorf("engine: write sentinel: %w", err)
|
||||
}
|
||||
if err := os.RemoveAll(cacheDir); err != nil {
|
||||
_ = os.RemoveAll(tmpDir)
|
||||
return fmt.Errorf("engine: clean old cache: %w", err)
|
||||
}
|
||||
if err := os.Rename(tmpDir, cacheDir); err != nil {
|
||||
_ = os.RemoveAll(tmpDir)
|
||||
return fmt.Errorf("engine: promote tmp cache: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func sentinelMatches(cacheDir, version string) bool {
|
||||
data, err := os.ReadFile(filepath.Join(cacheDir, SentinelFilename))
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return string(data) == version
|
||||
}
|
||||
|
||||
func extractAll(src fs.FS, dst string) error {
|
||||
return fs.WalkDir(src, ".", func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if path == "." {
|
||||
return nil
|
||||
}
|
||||
target := filepath.Join(dst, path)
|
||||
if d.IsDir() {
|
||||
return os.MkdirAll(target, 0o755)
|
||||
}
|
||||
return copyEmbeddedFile(src, path, target)
|
||||
})
|
||||
}
|
||||
|
||||
func copyEmbeddedFile(src fs.FS, srcPath, dst string) error {
|
||||
in, err := src.Open(srcPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("engine: open %s: %w", srcPath, err)
|
||||
}
|
||||
defer func() { _ = in.Close() }()
|
||||
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
|
||||
return fmt.Errorf("engine: ensure parent of %s: %w", dst, err)
|
||||
}
|
||||
out, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o644)
|
||||
if err != nil {
|
||||
return fmt.Errorf("engine: create %s: %w", dst, err)
|
||||
}
|
||||
defer func() { _ = out.Close() }()
|
||||
if _, err := io.Copy(out, in); err != nil {
|
||||
return fmt.Errorf("engine: write %s: %w", dst, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// onceRegistry serializes first-call extraction per cache directory so the
|
||||
// rename in ensureLocked happens exactly once across goroutines.
|
||||
var (
|
||||
onceMu sync.Mutex
|
||||
onceRegistry = map[string]*sync.Once{}
|
||||
)
|
||||
|
||||
func getOnce(cacheDir string) *sync.Once {
|
||||
onceMu.Lock()
|
||||
defer onceMu.Unlock()
|
||||
if o, ok := onceRegistry[cacheDir]; ok {
|
||||
return o
|
||||
}
|
||||
o := &sync.Once{}
|
||||
onceRegistry[cacheDir] = o
|
||||
return o
|
||||
}
|
||||
|
||||
func resetOnce(cacheDir string) {
|
||||
onceMu.Lock()
|
||||
defer onceMu.Unlock()
|
||||
delete(onceRegistry, cacheDir)
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
)
|
||||
|
||||
func newTestFS() fstest.MapFS {
|
||||
return fstest.MapFS{
|
||||
"last30days.py": &fstest.MapFile{Data: []byte("# last30days entry\n"), Mode: 0o644},
|
||||
"lib/__init__.py": &fstest.MapFile{Data: []byte(""), Mode: 0o644},
|
||||
"lib/env.py": &fstest.MapFile{Data: []byte("# env helpers\n"), Mode: 0o644},
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureExtractsEngine(t *testing.T) {
|
||||
src := newTestFS()
|
||||
base := t.TempDir()
|
||||
|
||||
cacheDir, err := Ensure(src, base, "v1")
|
||||
if err != nil {
|
||||
t.Fatalf("Ensure: %v", err)
|
||||
}
|
||||
if cacheDir != filepath.Join(base, cacheSubdir, "v1") {
|
||||
t.Fatalf("cacheDir = %q, want %q", cacheDir, filepath.Join(base, cacheSubdir, "v1"))
|
||||
}
|
||||
mustReadFile(t, filepath.Join(cacheDir, "last30days.py"), "# last30days entry\n")
|
||||
mustReadFile(t, filepath.Join(cacheDir, "lib/env.py"), "# env helpers\n")
|
||||
mustReadFile(t, filepath.Join(cacheDir, SentinelFilename), "v1")
|
||||
}
|
||||
|
||||
func TestEnsureSkipsWhenSentinelMatches(t *testing.T) {
|
||||
src := newTestFS()
|
||||
base := t.TempDir()
|
||||
|
||||
cacheDir, err := Ensure(src, base, "v1")
|
||||
if err != nil {
|
||||
t.Fatalf("first Ensure: %v", err)
|
||||
}
|
||||
target := filepath.Join(cacheDir, "last30days.py")
|
||||
info1, err := os.Stat(target)
|
||||
if err != nil {
|
||||
t.Fatalf("stat: %v", err)
|
||||
}
|
||||
|
||||
// Reset the sync.Once so a second call would re-extract if not for the
|
||||
// sentinel short-circuit. Without the reset, sync.Once would skip the
|
||||
// extraction regardless of sentinel state.
|
||||
resetOnce(cacheDir)
|
||||
|
||||
if _, err := Ensure(src, base, "v1"); err != nil {
|
||||
t.Fatalf("second Ensure: %v", err)
|
||||
}
|
||||
info2, err := os.Stat(target)
|
||||
if err != nil {
|
||||
t.Fatalf("stat second: %v", err)
|
||||
}
|
||||
if !info2.ModTime().Equal(info1.ModTime()) {
|
||||
t.Fatalf("expected file untouched on sentinel match; got mtime %v -> %v", info1.ModTime(), info2.ModTime())
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureReExtractsOnVersionChange(t *testing.T) {
|
||||
v1 := fstest.MapFS{
|
||||
"last30days.py": &fstest.MapFile{Data: []byte("v1\n"), Mode: 0o644},
|
||||
}
|
||||
v2 := fstest.MapFS{
|
||||
"last30days.py": &fstest.MapFile{Data: []byte("v2\n"), Mode: 0o644},
|
||||
}
|
||||
base := t.TempDir()
|
||||
|
||||
cache1, err := Ensure(v1, base, "v1")
|
||||
if err != nil {
|
||||
t.Fatalf("Ensure v1: %v", err)
|
||||
}
|
||||
cache2, err := Ensure(v2, base, "v2")
|
||||
if err != nil {
|
||||
t.Fatalf("Ensure v2: %v", err)
|
||||
}
|
||||
if cache1 == cache2 {
|
||||
t.Fatalf("expected distinct cache dirs per version, got %q == %q", cache1, cache2)
|
||||
}
|
||||
mustReadFile(t, filepath.Join(cache1, "last30days.py"), "v1\n")
|
||||
mustReadFile(t, filepath.Join(cache2, "last30days.py"), "v2\n")
|
||||
}
|
||||
|
||||
func TestEnsureConcurrentFirstCall(t *testing.T) {
|
||||
src := newTestFS()
|
||||
base := t.TempDir()
|
||||
|
||||
const goroutines = 10
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(goroutines)
|
||||
results := make([]string, goroutines)
|
||||
errs := make([]error, goroutines)
|
||||
for i := 0; i < goroutines; i++ {
|
||||
i := i
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
results[i], errs[i] = Ensure(src, base, "v1")
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
for i, err := range errs {
|
||||
if err != nil {
|
||||
t.Fatalf("goroutine %d: %v", i, err)
|
||||
}
|
||||
}
|
||||
for i := 1; i < goroutines; i++ {
|
||||
if results[i] != results[0] {
|
||||
t.Fatalf("goroutine 0 saw %q, goroutine %d saw %q", results[0], i, results[i])
|
||||
}
|
||||
}
|
||||
mustReadFile(t, filepath.Join(results[0], "last30days.py"), "# last30days entry\n")
|
||||
}
|
||||
|
||||
func TestEnsureRejectsEmptyVersion(t *testing.T) {
|
||||
if _, err := Ensure(newTestFS(), t.TempDir(), ""); err == nil {
|
||||
t.Fatal("expected error for empty version")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureReturnsErrorWhenCacheUnwritable(t *testing.T) {
|
||||
// Place the cache root at a path that cannot exist (a regular file).
|
||||
// MkdirAll will refuse and Ensure must surface a wrapped error.
|
||||
base := t.TempDir()
|
||||
blocker := filepath.Join(base, "blocker")
|
||||
if err := os.WriteFile(blocker, []byte("not a dir"), 0o644); err != nil {
|
||||
t.Fatalf("setup: %v", err)
|
||||
}
|
||||
|
||||
_, err := Ensure(newTestFS(), blocker, "v1")
|
||||
if err == nil {
|
||||
t.Fatal("expected error when cache parent is not a directory")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureUserCacheHonorsOverride(t *testing.T) {
|
||||
override := t.TempDir()
|
||||
t.Setenv(CacheEnvOverride, override)
|
||||
|
||||
src := newTestFS()
|
||||
cacheDir, err := EnsureUserCache(src, "v1")
|
||||
if err != nil {
|
||||
t.Fatalf("EnsureUserCache: %v", err)
|
||||
}
|
||||
want := filepath.Join(override, cacheSubdir, "v1")
|
||||
if cacheDir != want {
|
||||
t.Fatalf("cacheDir = %q, want %q", cacheDir, want)
|
||||
}
|
||||
mustReadFile(t, filepath.Join(cacheDir, "last30days.py"), "# last30days entry\n")
|
||||
}
|
||||
|
||||
func mustReadFile(t *testing.T, path, want string) {
|
||||
t.Helper()
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read %s: %v", path, err)
|
||||
}
|
||||
if string(data) != want {
|
||||
t.Fatalf("%s: got %q, want %q", path, string(data), want)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// DefaultPythonBinary is the interpreter we look up unless RunOptions
|
||||
// overrides it. Windows installs may expose only "python"; we surface a
|
||||
// clear error in that case rather than silently picking the wrong binary.
|
||||
const DefaultPythonBinary = "python3"
|
||||
|
||||
// MinPythonVersion mirrors the engine's MIN_PYTHON constant in
|
||||
// last30days.py. Surfaced in errors so users know what they're missing.
|
||||
const MinPythonVersion = "3.12"
|
||||
|
||||
// PythonInstallURL is included in the missing-interpreter error so users
|
||||
// have a direct route from the failure to a fix.
|
||||
const PythonInstallURL = "https://www.python.org/downloads/"
|
||||
|
||||
// DefaultTimeout caps a single research subprocess. The engine's deep mode
|
||||
// can run several minutes; five minutes is a safe upper bound that still
|
||||
// fails fast when something hangs.
|
||||
const DefaultTimeout = 5 * time.Minute
|
||||
|
||||
// TimeoutEnvOverride lets operators override DefaultTimeout per install
|
||||
// (seconds, integer). Honored by Run when RunOptions.Timeout is zero.
|
||||
const TimeoutEnvOverride = "LAST30DAYS_MCP_TIMEOUT"
|
||||
|
||||
// RunOptions configures one invocation of the embedded Python engine.
|
||||
// PythonPath is exposed so tests can substitute a stub interpreter without
|
||||
// manipulating the process PATH.
|
||||
type RunOptions struct {
|
||||
PythonPath string // resolved python3 binary; empty means look up DefaultPythonBinary on PATH
|
||||
CacheDir string // engine.Ensure result; lib/ here is added to PYTHONPATH
|
||||
Args []string // arguments after last30days.py (topic, --emit=..., etc.)
|
||||
ExtraEnv []string // appended to os.Environ() for the child process
|
||||
Timeout time.Duration // zero means DefaultTimeout or TimeoutEnvOverride
|
||||
}
|
||||
|
||||
// RunResult captures the engine's full output. Stdout is what we surface to
|
||||
// the agent; Stderr is included in error messages so users can diagnose
|
||||
// engine failures without leaving Claude Desktop.
|
||||
type RunResult struct {
|
||||
Stdout []byte
|
||||
Stderr []byte
|
||||
ExitCode int
|
||||
TimedOut bool
|
||||
}
|
||||
|
||||
// Run shells out to python3 with last30days.py inside cacheDir. The child
|
||||
// receives the parent environment (so MCPB user_config env-injection
|
||||
// reaches the engine) plus ExtraEnv and a PYTHONPATH that points at the
|
||||
// cache so the engine's `from lib import ...` statements resolve.
|
||||
//
|
||||
// A missing interpreter, a non-zero exit, and a timeout each surface as
|
||||
// distinct errors so the tool handler can map them to user-facing
|
||||
// messages without re-parsing stderr.
|
||||
func Run(ctx context.Context, opts RunOptions) (*RunResult, error) {
|
||||
if opts.CacheDir == "" {
|
||||
return nil, errors.New("engine: CacheDir is required")
|
||||
}
|
||||
pythonPath, err := resolvePython(opts.PythonPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
scriptPath := filepath.Join(opts.CacheDir, "last30days.py")
|
||||
if _, err := os.Stat(scriptPath); err != nil {
|
||||
return nil, fmt.Errorf("engine: last30days.py not found in cache %s: %w", opts.CacheDir, err)
|
||||
}
|
||||
|
||||
timeout := resolveTimeout(opts.Timeout)
|
||||
subCtx, cancel := context.WithTimeout(ctx, timeout)
|
||||
defer cancel()
|
||||
|
||||
args := append([]string{scriptPath}, opts.Args...)
|
||||
cmd := exec.CommandContext(subCtx, pythonPath, args...)
|
||||
cmd.Env = buildEnv(opts.CacheDir, opts.ExtraEnv)
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
cmd.Stdout = &stdout
|
||||
cmd.Stderr = &stderr
|
||||
|
||||
err = cmd.Run()
|
||||
res := &RunResult{
|
||||
Stdout: stdout.Bytes(),
|
||||
Stderr: stderr.Bytes(),
|
||||
ExitCode: 0,
|
||||
TimedOut: errors.Is(subCtx.Err(), context.DeadlineExceeded),
|
||||
}
|
||||
if err == nil {
|
||||
return res, nil
|
||||
}
|
||||
|
||||
var exitErr *exec.ExitError
|
||||
if errors.As(err, &exitErr) {
|
||||
res.ExitCode = exitErr.ExitCode()
|
||||
if res.TimedOut {
|
||||
return res, fmt.Errorf("engine: subprocess exceeded %s timeout", timeout)
|
||||
}
|
||||
return res, fmt.Errorf("engine: subprocess exited with code %d", res.ExitCode)
|
||||
}
|
||||
return res, fmt.Errorf("engine: subprocess failed to start: %w", err)
|
||||
}
|
||||
|
||||
// resolvePython returns an absolute path to the interpreter or an error
|
||||
// naming the install URL. If the caller supplied a path we trust it - tests
|
||||
// rely on this to inject a stub. Otherwise we look up python3 on PATH.
|
||||
func resolvePython(override string) (string, error) {
|
||||
if override != "" {
|
||||
return override, nil
|
||||
}
|
||||
path, err := exec.LookPath(DefaultPythonBinary)
|
||||
if err == nil {
|
||||
return path, nil
|
||||
}
|
||||
return "", fmt.Errorf(
|
||||
"engine: %s not found on PATH (need Python %s+, install from %s; current GOOS=%s)",
|
||||
DefaultPythonBinary, MinPythonVersion, PythonInstallURL, runtime.GOOS,
|
||||
)
|
||||
}
|
||||
|
||||
func resolveTimeout(explicit time.Duration) time.Duration {
|
||||
if explicit > 0 {
|
||||
return explicit
|
||||
}
|
||||
if raw := os.Getenv(TimeoutEnvOverride); raw != "" {
|
||||
if d, err := time.ParseDuration(raw); err == nil && d > 0 {
|
||||
return d
|
||||
}
|
||||
}
|
||||
return DefaultTimeout
|
||||
}
|
||||
|
||||
// buildEnv stitches PYTHONPATH onto os.Environ + ExtraEnv. Any pre-existing
|
||||
// PYTHONPATH in the parent environment is dropped before appending the
|
||||
// cache dir; otherwise the child sees two PYTHONPATH= entries and POSIX
|
||||
// getenv returns the first one, so the user's value wins and the engine's
|
||||
// `from lib import ...` fails with ModuleNotFoundError. The engine is
|
||||
// self-contained and does not need the user's Python module search path.
|
||||
func buildEnv(cacheDir string, extra []string) []string {
|
||||
const pyKey = "PYTHONPATH="
|
||||
parent := os.Environ()
|
||||
base := make([]string, 0, len(parent)+1+len(extra))
|
||||
for _, kv := range parent {
|
||||
if strings.HasPrefix(kv, pyKey) {
|
||||
continue
|
||||
}
|
||||
base = append(base, kv)
|
||||
}
|
||||
base = append(base, pyKey+cacheDir)
|
||||
base = append(base, extra...)
|
||||
return base
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// makeStubPython writes a shell script that simulates python3 and returns
|
||||
// its absolute path. The script honors a small env-driven protocol so each
|
||||
// test can shape its output:
|
||||
//
|
||||
// STUB_STDOUT - text printed to stdout
|
||||
// STUB_STDERR - text printed to stderr
|
||||
// STUB_EXIT_CODE - integer exit code (default 0)
|
||||
// STUB_SLEEP_SECS - sleep before exiting (for timeout tests)
|
||||
// STUB_ECHO_ENV - name of an env var; the stub prints "<NAME>=<VALUE>"
|
||||
// STUB_ECHO_ARG - integer index; the stub prints "ARG<i>=<args[i]>"
|
||||
//
|
||||
// The stub ignores its first argument (the script path), matching how a
|
||||
// real python3 invocation treats `python3 last30days.py ...`.
|
||||
func makeStubPython(t *testing.T) string {
|
||||
t.Helper()
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("stub-python tests rely on POSIX shell")
|
||||
}
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "python3-stub.sh")
|
||||
script := `#!/usr/bin/env bash
|
||||
if [ -n "${STUB_SLEEP_SECS:-}" ]; then sleep "$STUB_SLEEP_SECS"; fi
|
||||
if [ -n "${STUB_STDOUT:-}" ]; then printf "%s" "$STUB_STDOUT"; fi
|
||||
if [ -n "${STUB_STDERR:-}" ]; then printf "%s" "$STUB_STDERR" >&2; fi
|
||||
if [ -n "${STUB_ECHO_ENV:-}" ]; then echo "${STUB_ECHO_ENV}=${!STUB_ECHO_ENV:-<unset>}"; fi
|
||||
if [ -n "${STUB_ECHO_ARG:-}" ]; then echo "ARG${STUB_ECHO_ARG}=${!STUB_ECHO_ARG:-<unset>}"; fi
|
||||
exit "${STUB_EXIT_CODE:-0}"
|
||||
`
|
||||
if err := os.WriteFile(path, []byte(script), 0o755); err != nil {
|
||||
t.Fatalf("write stub: %v", err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
// stageCache materializes a fake CacheDir with a no-op last30days.py so
|
||||
// the existence check in Run passes. The stub python3 ignores the script
|
||||
// contents, so the file just has to exist.
|
||||
func stageCache(t *testing.T) string {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(dir, "last30days.py"), []byte("# stub\n"), 0o644); err != nil {
|
||||
t.Fatalf("stage cache: %v", err)
|
||||
}
|
||||
return dir
|
||||
}
|
||||
|
||||
func TestRunHappyPath(t *testing.T) {
|
||||
stub := makeStubPython(t)
|
||||
cache := stageCache(t)
|
||||
t.Setenv("STUB_STDOUT", "synthesis output\n")
|
||||
|
||||
res, err := Run(context.Background(), RunOptions{
|
||||
PythonPath: stub,
|
||||
CacheDir: cache,
|
||||
Args: []string{"my topic", "--emit=compact"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
if string(res.Stdout) != "synthesis output\n" {
|
||||
t.Fatalf("stdout = %q, want %q", res.Stdout, "synthesis output\n")
|
||||
}
|
||||
if res.ExitCode != 0 {
|
||||
t.Fatalf("ExitCode = %d, want 0", res.ExitCode)
|
||||
}
|
||||
if res.TimedOut {
|
||||
t.Fatal("TimedOut = true, want false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunForwardsEnv(t *testing.T) {
|
||||
stub := makeStubPython(t)
|
||||
cache := stageCache(t)
|
||||
t.Setenv("OPENAI_API_KEY", "sk-test-value")
|
||||
t.Setenv("STUB_ECHO_ENV", "OPENAI_API_KEY")
|
||||
|
||||
res, err := Run(context.Background(), RunOptions{
|
||||
PythonPath: stub,
|
||||
CacheDir: cache,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
if got := strings.TrimSpace(string(res.Stdout)); got != "OPENAI_API_KEY=sk-test-value" {
|
||||
t.Fatalf("stdout = %q, want OPENAI_API_KEY=sk-test-value", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunSetsPythonPath(t *testing.T) {
|
||||
stub := makeStubPython(t)
|
||||
cache := stageCache(t)
|
||||
t.Setenv("STUB_ECHO_ENV", "PYTHONPATH")
|
||||
|
||||
res, err := Run(context.Background(), RunOptions{
|
||||
PythonPath: stub,
|
||||
CacheDir: cache,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
want := "PYTHONPATH=" + cache
|
||||
if got := strings.TrimSpace(string(res.Stdout)); got != want {
|
||||
t.Fatalf("stdout = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunDropsPreExistingPythonPath guards the buildEnv dedup: when the
|
||||
// parent already sets PYTHONPATH (common on dev machines and CI runners
|
||||
// that touch Python), the child must NOT see two PYTHONPATH= entries.
|
||||
// POSIX getenv returns the first match, so a duplicate from os.Environ
|
||||
// would shadow our cache-dir entry and break `from lib import ...`.
|
||||
func TestRunDropsPreExistingPythonPath(t *testing.T) {
|
||||
stub := makeStubPython(t)
|
||||
cache := stageCache(t)
|
||||
t.Setenv("PYTHONPATH", "/users-stale-pythonpath")
|
||||
t.Setenv("STUB_ECHO_ENV", "PYTHONPATH")
|
||||
|
||||
res, err := Run(context.Background(), RunOptions{
|
||||
PythonPath: stub,
|
||||
CacheDir: cache,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
got := strings.TrimSpace(string(res.Stdout))
|
||||
want := "PYTHONPATH=" + cache
|
||||
if got != want {
|
||||
t.Fatalf("stdout = %q, want %q (stale parent value leaked through)", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildEnvDropsAllPreExistingPythonPath(t *testing.T) {
|
||||
// Direct unit test on buildEnv to catch the case where the parent has
|
||||
// PYTHONPATH set: the returned slice must contain exactly one
|
||||
// PYTHONPATH= entry, and it must be ours.
|
||||
t.Setenv("PYTHONPATH", "/parent/one")
|
||||
cache := "/cache/dir"
|
||||
out := buildEnv(cache, []string{"EXTRA=1"})
|
||||
|
||||
var pythonPaths []string
|
||||
for _, kv := range out {
|
||||
if strings.HasPrefix(kv, "PYTHONPATH=") {
|
||||
pythonPaths = append(pythonPaths, kv)
|
||||
}
|
||||
}
|
||||
if len(pythonPaths) != 1 {
|
||||
t.Fatalf("got %d PYTHONPATH entries, want 1: %v", len(pythonPaths), pythonPaths)
|
||||
}
|
||||
if pythonPaths[0] != "PYTHONPATH="+cache {
|
||||
t.Fatalf("PYTHONPATH = %q, want %q", pythonPaths[0], "PYTHONPATH="+cache)
|
||||
}
|
||||
// Confirm ExtraEnv still rides along.
|
||||
found := false
|
||||
for _, kv := range out {
|
||||
if kv == "EXTRA=1" {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("EXTRA=1 missing from buildEnv output")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunSurfacesExitCode(t *testing.T) {
|
||||
stub := makeStubPython(t)
|
||||
cache := stageCache(t)
|
||||
t.Setenv("STUB_STDERR", "engine boom\n")
|
||||
t.Setenv("STUB_EXIT_CODE", "2")
|
||||
|
||||
res, err := Run(context.Background(), RunOptions{
|
||||
PythonPath: stub,
|
||||
CacheDir: cache,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for non-zero exit")
|
||||
}
|
||||
if res == nil {
|
||||
t.Fatal("res is nil; want populated result alongside error")
|
||||
}
|
||||
if res.ExitCode != 2 {
|
||||
t.Fatalf("ExitCode = %d, want 2", res.ExitCode)
|
||||
}
|
||||
if !strings.Contains(string(res.Stderr), "engine boom") {
|
||||
t.Fatalf("stderr did not surface engine output: %q", res.Stderr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunTimesOut(t *testing.T) {
|
||||
stub := makeStubPython(t)
|
||||
cache := stageCache(t)
|
||||
t.Setenv("STUB_SLEEP_SECS", "3")
|
||||
|
||||
res, err := Run(context.Background(), RunOptions{
|
||||
PythonPath: stub,
|
||||
CacheDir: cache,
|
||||
Timeout: 200 * time.Millisecond,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected timeout error")
|
||||
}
|
||||
if !res.TimedOut {
|
||||
t.Fatal("TimedOut = false, want true")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "timeout") {
|
||||
t.Fatalf("error %q lacks 'timeout' marker", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunMissingPython(t *testing.T) {
|
||||
cache := stageCache(t)
|
||||
// Empty PATH guarantees the lookup fails. PythonPath stays unset so Run
|
||||
// falls through to exec.LookPath.
|
||||
t.Setenv("PATH", "")
|
||||
|
||||
_, err := Run(context.Background(), RunOptions{CacheDir: cache})
|
||||
if err == nil {
|
||||
t.Fatal("expected lookup failure with empty PATH")
|
||||
}
|
||||
if !strings.Contains(err.Error(), DefaultPythonBinary) {
|
||||
t.Fatalf("error %q does not mention %s", err, DefaultPythonBinary)
|
||||
}
|
||||
if !strings.Contains(err.Error(), PythonInstallURL) {
|
||||
t.Fatalf("error %q does not include install URL", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunMissingScript(t *testing.T) {
|
||||
stub := makeStubPython(t)
|
||||
// CacheDir exists but contains no last30days.py.
|
||||
cache := t.TempDir()
|
||||
|
||||
_, err := Run(context.Background(), RunOptions{
|
||||
PythonPath: stub,
|
||||
CacheDir: cache,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error when last30days.py missing")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "last30days.py") {
|
||||
t.Fatalf("error %q does not name missing script", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunRejectsEmptyCacheDir(t *testing.T) {
|
||||
stub := makeStubPython(t)
|
||||
_, err := Run(context.Background(), RunOptions{PythonPath: stub})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for empty CacheDir")
|
||||
}
|
||||
if !errors.Is(err, err) || !strings.Contains(err.Error(), "CacheDir") {
|
||||
t.Fatalf("error %q does not name CacheDir", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveTimeoutHonorsEnv(t *testing.T) {
|
||||
t.Setenv(TimeoutEnvOverride, "750ms")
|
||||
if got := resolveTimeout(0); got != 750*time.Millisecond {
|
||||
t.Fatalf("resolveTimeout = %v, want 750ms", got)
|
||||
}
|
||||
t.Setenv(TimeoutEnvOverride, "garbage")
|
||||
if got := resolveTimeout(0); got != DefaultTimeout {
|
||||
t.Fatalf("garbage value: got %v, want default %v", got, DefaultTimeout)
|
||||
}
|
||||
if got := resolveTimeout(time.Minute); got != time.Minute {
|
||||
t.Fatalf("explicit value not honored: got %v", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
Populated at build time by scripts/sync-engine.sh.
|
||||
Source of truth: skills/last30days/scripts/.
|
||||
@@ -0,0 +1,189 @@
|
||||
// Package manifest holds tests for mcp/manifest.json. It contains no
|
||||
// production code - the manifest itself is the artifact, and these tests
|
||||
// guard structural invariants the bundling pipeline depends on.
|
||||
package manifest
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// envBinding is a minimal subset of the MCPB v0.3 manifest just covering
|
||||
// the fields these tests assert on. We deliberately do not depend on the
|
||||
// printing-press internal/pipeline types (that's an internal/ package and
|
||||
// not importable across modules) - the structural invariants below are
|
||||
// what actually matter for Claude Desktop install correctness.
|
||||
type manifestShape struct {
|
||||
ManifestVersion string `json:"manifest_version"`
|
||||
Name string `json:"name"`
|
||||
Version string `json:"version"`
|
||||
Server struct {
|
||||
Type string `json:"type"`
|
||||
EntryPoint string `json:"entry_point"`
|
||||
MCPConfig struct {
|
||||
Command string `json:"command"`
|
||||
Env map[string]string `json:"env"`
|
||||
} `json:"mcp_config"`
|
||||
} `json:"server"`
|
||||
UserConfig map[string]struct {
|
||||
Type string `json:"type"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
Sensitive bool `json:"sensitive"`
|
||||
Required bool `json:"required"`
|
||||
} `json:"user_config"`
|
||||
Compatibility struct {
|
||||
ClaudeDesktop string `json:"claude_desktop"`
|
||||
Platforms []string `json:"platforms"`
|
||||
} `json:"compatibility"`
|
||||
}
|
||||
|
||||
// loadManifest reads mcp/manifest.json relative to this test file so the
|
||||
// test passes regardless of where `go test` is invoked from.
|
||||
func loadManifest(t *testing.T) manifestShape {
|
||||
t.Helper()
|
||||
_, thisFile, _, ok := runtime.Caller(0)
|
||||
if !ok {
|
||||
t.Fatal("runtime.Caller failed")
|
||||
}
|
||||
// manifest_test.go is at mcp/internal/manifest/; manifest.json at mcp/.
|
||||
manifestPath := filepath.Join(filepath.Dir(thisFile), "..", "..", "manifest.json")
|
||||
data, err := os.ReadFile(manifestPath)
|
||||
if err != nil {
|
||||
t.Fatalf("read manifest: %v", err)
|
||||
}
|
||||
var m manifestShape
|
||||
if err := json.Unmarshal(data, &m); err != nil {
|
||||
t.Fatalf("parse manifest: %v", err)
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func TestManifestRequiredFields(t *testing.T) {
|
||||
m := loadManifest(t)
|
||||
if m.ManifestVersion != "0.3" {
|
||||
t.Errorf("manifest_version = %q, want 0.3", m.ManifestVersion)
|
||||
}
|
||||
if m.Name != "last30days-pp-mcp" {
|
||||
t.Errorf("name = %q, want last30days-pp-mcp", m.Name)
|
||||
}
|
||||
if m.Version == "" {
|
||||
t.Error("version is empty")
|
||||
}
|
||||
if m.Server.Type != "binary" {
|
||||
t.Errorf("server.type = %q, want binary", m.Server.Type)
|
||||
}
|
||||
if m.Server.EntryPoint != "bin/last30days-pp-mcp" {
|
||||
t.Errorf("server.entry_point = %q, want bin/last30days-pp-mcp", m.Server.EntryPoint)
|
||||
}
|
||||
if m.Compatibility.ClaudeDesktop == "" {
|
||||
t.Error("compatibility.claude_desktop is empty")
|
||||
}
|
||||
}
|
||||
|
||||
// TestEnvAndUserConfigCrossReference is the key invariant: every
|
||||
// ${user_config.<key>} substitution in server.mcp_config.env must point
|
||||
// at a real user_config entry, and every declared user_config must be
|
||||
// wired to an env var. A typo on either side silently disables a credential
|
||||
// at install time without the binary or Claude Desktop noticing.
|
||||
func TestEnvAndUserConfigCrossReference(t *testing.T) {
|
||||
m := loadManifest(t)
|
||||
|
||||
if len(m.Server.MCPConfig.Env) == 0 {
|
||||
t.Fatal("server.mcp_config.env is empty; expected user_config substitutions")
|
||||
}
|
||||
if len(m.UserConfig) == 0 {
|
||||
t.Fatal("user_config is empty; expected per-key declarations")
|
||||
}
|
||||
|
||||
for envName, value := range m.Server.MCPConfig.Env {
|
||||
key, ok := parseUserConfigRef(value)
|
||||
if !ok {
|
||||
t.Errorf("env[%s] = %q is not a ${user_config.<key>} reference", envName, value)
|
||||
continue
|
||||
}
|
||||
if _, declared := m.UserConfig[key]; !declared {
|
||||
t.Errorf("env[%s] references user_config[%q], which is not declared", envName, key)
|
||||
}
|
||||
// The user_config key must be the lowercased env var so Claude
|
||||
// Desktop's substitution rule matches PP's emitted shape.
|
||||
if got := strings.ToLower(envName); key != got {
|
||||
t.Errorf("env[%s] -> user_config[%q]; convention requires user_config[%q]", envName, key, got)
|
||||
}
|
||||
}
|
||||
|
||||
envValues := make(map[string]bool, len(m.Server.MCPConfig.Env))
|
||||
for _, value := range m.Server.MCPConfig.Env {
|
||||
if key, ok := parseUserConfigRef(value); ok {
|
||||
envValues[key] = true
|
||||
}
|
||||
}
|
||||
for key := range m.UserConfig {
|
||||
if !envValues[key] {
|
||||
t.Errorf("user_config[%q] is declared but never substituted into env", key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserConfigShape(t *testing.T) {
|
||||
m := loadManifest(t)
|
||||
for key, slot := range m.UserConfig {
|
||||
if slot.Type != "string" {
|
||||
t.Errorf("user_config[%q].type = %q, want string", key, slot.Type)
|
||||
}
|
||||
if slot.Title == "" {
|
||||
t.Errorf("user_config[%q].title is empty", key)
|
||||
}
|
||||
if slot.Description == "" {
|
||||
t.Errorf("user_config[%q].description is empty", key)
|
||||
}
|
||||
if !slot.Sensitive {
|
||||
// API keys must be flagged sensitive so Claude Desktop masks
|
||||
// the input and prefers OS-keychain storage.
|
||||
t.Errorf("user_config[%q].sensitive = false; want true for API credentials", key)
|
||||
}
|
||||
if slot.Required {
|
||||
// The engine degrades to web-only mode without keys, so no
|
||||
// key is install-blocking.
|
||||
t.Errorf("user_config[%q].required = true; engine degrades without keys, so all keys are optional", key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlatformsMatchShippingMatrix(t *testing.T) {
|
||||
// compatibility.platforms must list exactly what the release CI
|
||||
// actually packages. Listing a platform we don't ship would let
|
||||
// Claude Desktop start an install that has no matching binary inside
|
||||
// the bundle, producing a silent failure. The CI matrix in
|
||||
// .github/workflows/release.yml currently covers darwin (arm64 +
|
||||
// amd64) and linux/amd64; Windows is deferred.
|
||||
m := loadManifest(t)
|
||||
required := map[string]bool{"darwin": false, "linux": false}
|
||||
forbidden := map[string]bool{"win32": true}
|
||||
for _, p := range m.Compatibility.Platforms {
|
||||
if _, ok := required[p]; ok {
|
||||
required[p] = true
|
||||
}
|
||||
if forbidden[p] {
|
||||
t.Errorf("compatibility.platforms contains %q but the release matrix does not ship that platform; add it to the matrix or remove from the manifest", p)
|
||||
}
|
||||
}
|
||||
for p, found := range required {
|
||||
if !found {
|
||||
t.Errorf("compatibility.platforms missing %q", p)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func parseUserConfigRef(value string) (string, bool) {
|
||||
const prefix = "${user_config."
|
||||
const suffix = "}"
|
||||
if !strings.HasPrefix(value, prefix) || !strings.HasSuffix(value, suffix) {
|
||||
return "", false
|
||||
}
|
||||
return value[len(prefix) : len(value)-len(suffix)], true
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
// Package tools owns the MCP tool surface for last30days. Today there is
|
||||
// exactly one tool, research, mirroring the /last30days <topic> slash
|
||||
// command available in Claude Code. Adding new tools means another file
|
||||
// here plus an additional s.AddTool call in Register.
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
mcplib "github.com/mark3labs/mcp-go/mcp"
|
||||
"github.com/mark3labs/mcp-go/server"
|
||||
|
||||
"github.com/mvanhorn/last30days-skill/mcp/internal/engine"
|
||||
)
|
||||
|
||||
// Config carries the version string used to namespace the per-user cache.
|
||||
// main passes its ldflags-stamped Version here.
|
||||
type Config struct {
|
||||
Version string
|
||||
}
|
||||
|
||||
// Register adds every tool this server exposes to s. The caller supplies a
|
||||
// Config so test harnesses can pin a version without touching globals.
|
||||
func Register(s *server.MCPServer, cfg Config) {
|
||||
s.AddTool(
|
||||
mcplib.NewTool("research",
|
||||
mcplib.WithDescription(
|
||||
"Research what people are actually saying about any topic in the last 30 days. "+
|
||||
"Aggregates Reddit, X, YouTube, Hacker News, Polymarket, GitHub, and the web, "+
|
||||
"scored by upvotes, likes, transcripts, and real-money prediction-market odds. "+
|
||||
"Returns the engine's compact output for the model to synthesize.",
|
||||
),
|
||||
mcplib.WithString("topic", mcplib.Required(), mcplib.Description("The subject to research (a person, company, product, event, or general topic).")),
|
||||
mcplib.WithString("emit", mcplib.Description("Output shape: 'compact' (default) for inline synthesis or 'html' to save a shareable brief alongside the response.")),
|
||||
mcplib.WithBoolean("save", mcplib.Description("Persist the synthesis as a markdown report under ~/Documents/Last30Days/ (or LAST30DAYS_MEMORY_DIR if set).")),
|
||||
mcplib.WithReadOnlyHintAnnotation(true),
|
||||
mcplib.WithDestructiveHintAnnotation(false),
|
||||
mcplib.WithOpenWorldHintAnnotation(true),
|
||||
),
|
||||
makeResearchHandler(cfg),
|
||||
)
|
||||
}
|
||||
|
||||
func makeResearchHandler(cfg Config) server.ToolHandlerFunc {
|
||||
return func(ctx context.Context, req mcplib.CallToolRequest) (*mcplib.CallToolResult, error) {
|
||||
args := req.GetArguments()
|
||||
topic, err := requireString(args, "topic")
|
||||
if err != nil {
|
||||
return mcplib.NewToolResultError(err.Error()), nil
|
||||
}
|
||||
|
||||
emit, err := emitArgument(args)
|
||||
if err != nil {
|
||||
return mcplib.NewToolResultError(err.Error()), nil
|
||||
}
|
||||
|
||||
save, err := boolArgument(args, "save")
|
||||
if err != nil {
|
||||
return mcplib.NewToolResultError(err.Error()), nil
|
||||
}
|
||||
|
||||
src, err := engine.EngineFS()
|
||||
if err != nil {
|
||||
return mcplib.NewToolResultError(fmt.Sprintf("engine source unavailable: %v", err)), nil
|
||||
}
|
||||
cacheDir, err := engine.EnsureUserCache(src, cfg.Version)
|
||||
if err != nil {
|
||||
return mcplib.NewToolResultError(fmt.Sprintf(
|
||||
"engine extract failed: %v\nhint: set %s to a writable directory if the default cache location is locked down",
|
||||
err, engine.CacheEnvOverride,
|
||||
)), nil
|
||||
}
|
||||
|
||||
runArgs := []string{topic, "--emit=" + emit}
|
||||
if save {
|
||||
runArgs = append(runArgs, "--save")
|
||||
}
|
||||
|
||||
res, runErr := engine.Run(ctx, engine.RunOptions{
|
||||
CacheDir: cacheDir,
|
||||
Args: runArgs,
|
||||
})
|
||||
if runErr != nil {
|
||||
return mcplib.NewToolResultError(formatRunError(runErr, res)), nil
|
||||
}
|
||||
return mcplib.NewToolResultText(string(res.Stdout)), nil
|
||||
}
|
||||
}
|
||||
|
||||
func requireString(args map[string]any, name string) (string, error) {
|
||||
raw, ok := args[name]
|
||||
if !ok {
|
||||
return "", fmt.Errorf("%s is required", name)
|
||||
}
|
||||
value, ok := raw.(string)
|
||||
if !ok || strings.TrimSpace(value) == "" {
|
||||
return "", fmt.Errorf("%s must be a non-empty string", name)
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func emitArgument(args map[string]any) (string, error) {
|
||||
raw, ok := args["emit"]
|
||||
if !ok {
|
||||
return "compact", nil
|
||||
}
|
||||
value, ok := raw.(string)
|
||||
if !ok {
|
||||
return "", errors.New("emit must be a string")
|
||||
}
|
||||
switch value {
|
||||
case "":
|
||||
return "compact", nil
|
||||
case "compact", "html":
|
||||
return value, nil
|
||||
default:
|
||||
return "", fmt.Errorf("emit must be 'compact' or 'html', got %q", value)
|
||||
}
|
||||
}
|
||||
|
||||
func boolArgument(args map[string]any, name string) (bool, error) {
|
||||
raw, ok := args[name]
|
||||
if !ok {
|
||||
return false, nil
|
||||
}
|
||||
value, ok := raw.(bool)
|
||||
if !ok {
|
||||
return false, fmt.Errorf("%s must be a boolean", name)
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
// formatRunError flattens engine.Run's distinct error shapes into a single
|
||||
// user-facing message that includes the relevant stderr context.
|
||||
func formatRunError(runErr error, res *engine.RunResult) string {
|
||||
var msg strings.Builder
|
||||
msg.WriteString(runErr.Error())
|
||||
if res != nil && len(res.Stderr) > 0 {
|
||||
msg.WriteString("\nengine stderr:\n")
|
||||
msg.Write(res.Stderr)
|
||||
}
|
||||
return msg.String()
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
mcplib "github.com/mark3labs/mcp-go/mcp"
|
||||
|
||||
"github.com/mvanhorn/last30days-skill/mcp/internal/engine"
|
||||
)
|
||||
|
||||
func newCallToolRequest(args map[string]any) mcplib.CallToolRequest {
|
||||
var req mcplib.CallToolRequest
|
||||
req.Params.Arguments = args
|
||||
return req
|
||||
}
|
||||
|
||||
// resultText pulls text content out of a tool result so tests can assert on
|
||||
// the body Claude will see. Returns empty string when the result is nil or
|
||||
// has no text content.
|
||||
func resultText(res *mcplib.CallToolResult) string {
|
||||
if res == nil {
|
||||
return ""
|
||||
}
|
||||
var out strings.Builder
|
||||
for _, item := range res.Content {
|
||||
if tc, ok := item.(mcplib.TextContent); ok {
|
||||
out.WriteString(tc.Text)
|
||||
}
|
||||
}
|
||||
return out.String()
|
||||
}
|
||||
|
||||
func TestRequireStringRejectsMissingAndBlank(t *testing.T) {
|
||||
if _, err := requireString(map[string]any{}, "topic"); err == nil {
|
||||
t.Fatal("expected error for missing topic")
|
||||
}
|
||||
if _, err := requireString(map[string]any{"topic": ""}, "topic"); err == nil {
|
||||
t.Fatal("expected error for empty topic")
|
||||
}
|
||||
if _, err := requireString(map[string]any{"topic": " "}, "topic"); err == nil {
|
||||
t.Fatal("expected error for whitespace-only topic")
|
||||
}
|
||||
if _, err := requireString(map[string]any{"topic": 42}, "topic"); err == nil {
|
||||
t.Fatal("expected error for non-string topic")
|
||||
}
|
||||
v, err := requireString(map[string]any{"topic": "OpenAI"}, "topic")
|
||||
if err != nil || v != "OpenAI" {
|
||||
t.Fatalf("requireString ok = %q, %v", v, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmitArgumentDefaultsAndValidates(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
args map[string]any
|
||||
want string
|
||||
wantErr bool
|
||||
}{
|
||||
{"missing defaults to compact", map[string]any{}, "compact", false},
|
||||
{"empty string defaults to compact", map[string]any{"emit": ""}, "compact", false},
|
||||
{"compact passes through", map[string]any{"emit": "compact"}, "compact", false},
|
||||
{"html passes through", map[string]any{"emit": "html"}, "html", false},
|
||||
{"invalid value rejected", map[string]any{"emit": "json"}, "", true},
|
||||
{"non-string rejected", map[string]any{"emit": 7}, "", true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got, err := emitArgument(tc.args)
|
||||
if (err != nil) != tc.wantErr {
|
||||
t.Fatalf("err = %v, wantErr = %v", err, tc.wantErr)
|
||||
}
|
||||
if got != tc.want {
|
||||
t.Fatalf("got %q, want %q", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBoolArgument(t *testing.T) {
|
||||
v, err := boolArgument(map[string]any{}, "save")
|
||||
if err != nil || v {
|
||||
t.Fatalf("missing: %v, %v", v, err)
|
||||
}
|
||||
v, err = boolArgument(map[string]any{"save": true}, "save")
|
||||
if err != nil || !v {
|
||||
t.Fatalf("true: %v, %v", v, err)
|
||||
}
|
||||
v, err = boolArgument(map[string]any{"save": false}, "save")
|
||||
if err != nil || v {
|
||||
t.Fatalf("false: %v, %v", v, err)
|
||||
}
|
||||
if _, err := boolArgument(map[string]any{"save": "true"}, "save"); err == nil {
|
||||
t.Fatal("expected error for string value")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResearchHandlerValidationErrorsAreToolErrors(t *testing.T) {
|
||||
// Validation failures are returned as MCP tool errors (not Go errors)
|
||||
// so Claude sees a structured failure with a readable message rather
|
||||
// than a transport-level fault.
|
||||
handler := makeResearchHandler(Config{Version: "test"})
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
args map[string]any
|
||||
wantSub string
|
||||
}{
|
||||
{"missing topic", map[string]any{}, "topic is required"},
|
||||
{"blank topic", map[string]any{"topic": " "}, "non-empty string"},
|
||||
{"invalid emit", map[string]any{"topic": "OpenAI", "emit": "json"}, "must be 'compact' or 'html'"},
|
||||
{"non-bool save", map[string]any{"topic": "OpenAI", "save": "yes"}, "save must be a boolean"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
res, err := handler(context.Background(), newCallToolRequest(tc.args))
|
||||
if err != nil {
|
||||
t.Fatalf("handler should not return Go error for validation; got %v", err)
|
||||
}
|
||||
if res == nil || !res.IsError {
|
||||
t.Fatalf("expected IsError result, got %+v", res)
|
||||
}
|
||||
if !strings.Contains(resultText(res), tc.wantSub) {
|
||||
t.Fatalf("result text %q missing substring %q", resultText(res), tc.wantSub)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatRunErrorIncludesStderr(t *testing.T) {
|
||||
res := &engine.RunResult{Stderr: []byte("engine exploded\n")}
|
||||
msg := formatRunError(errors.New("boom"), res)
|
||||
if !strings.Contains(msg, "boom") || !strings.Contains(msg, "engine exploded") {
|
||||
t.Fatalf("formatRunError missed pieces: %q", msg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatRunErrorHandlesNilResult(t *testing.T) {
|
||||
msg := formatRunError(errors.New("boom"), nil)
|
||||
if msg != "boom" {
|
||||
t.Fatalf("nil result: got %q, want %q", msg, "boom")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
{
|
||||
"manifest_version": "0.3",
|
||||
"name": "last30days-pp-mcp",
|
||||
"display_name": "Last30Days",
|
||||
"version": "3.0.0",
|
||||
"description": "Research any topic across Reddit, X, YouTube, Hacker News, Polymarket, GitHub, and the web - last 30 days, scored by upvotes, likes, and real-money prediction-market odds.",
|
||||
"author": {
|
||||
"name": "Matt Van Horn",
|
||||
"url": "https://github.com/mvanhorn/last30days-skill"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/mvanhorn/last30days-skill"
|
||||
},
|
||||
"license": "MIT",
|
||||
"keywords": [
|
||||
"research",
|
||||
"reddit",
|
||||
"twitter",
|
||||
"x",
|
||||
"youtube",
|
||||
"hacker-news",
|
||||
"polymarket",
|
||||
"github",
|
||||
"search",
|
||||
"synthesis"
|
||||
],
|
||||
"server": {
|
||||
"type": "binary",
|
||||
"entry_point": "bin/last30days-pp-mcp",
|
||||
"mcp_config": {
|
||||
"command": "${__dirname}/bin/last30days-pp-mcp",
|
||||
"args": [],
|
||||
"env": {
|
||||
"OPENAI_API_KEY": "${user_config.openai_api_key}",
|
||||
"XAI_API_KEY": "${user_config.xai_api_key}",
|
||||
"BRAVE_API_KEY": "${user_config.brave_api_key}",
|
||||
"EXA_API_KEY": "${user_config.exa_api_key}",
|
||||
"SERPER_API_KEY": "${user_config.serper_api_key}",
|
||||
"GOOGLE_API_KEY": "${user_config.google_api_key}",
|
||||
"GEMINI_API_KEY": "${user_config.gemini_api_key}",
|
||||
"GOOGLE_GENAI_API_KEY": "${user_config.google_genai_api_key}",
|
||||
"APIFY_API_TOKEN": "${user_config.apify_api_token}",
|
||||
"BSKY_APP_PASSWORD": "${user_config.bsky_app_password}",
|
||||
"PARALLEL_API_KEY": "${user_config.parallel_api_key}",
|
||||
"SCRAPECREATORS_API_KEY": "${user_config.scrapecreators_api_key}",
|
||||
"OPENROUTER_API_KEY": "${user_config.openrouter_api_key}"
|
||||
}
|
||||
}
|
||||
},
|
||||
"user_config": {
|
||||
"openai_api_key": {
|
||||
"type": "string",
|
||||
"title": "OPENAI_API_KEY",
|
||||
"description": "OpenAI API key. Powers Reddit research via OpenAI's web_search tool. Get one at https://platform.openai.com/api-keys.",
|
||||
"sensitive": true,
|
||||
"required": false
|
||||
},
|
||||
"xai_api_key": {
|
||||
"type": "string",
|
||||
"title": "XAI_API_KEY",
|
||||
"description": "xAI API key. Powers X / Twitter research via xAI's x_search tool. Get one at https://console.x.ai/.",
|
||||
"sensitive": true,
|
||||
"required": false
|
||||
},
|
||||
"brave_api_key": {
|
||||
"type": "string",
|
||||
"title": "BRAVE_API_KEY",
|
||||
"description": "Brave Search API key. Used for grounded web search results. Get one at https://brave.com/search/api/.",
|
||||
"sensitive": true,
|
||||
"required": false
|
||||
},
|
||||
"exa_api_key": {
|
||||
"type": "string",
|
||||
"title": "EXA_API_KEY",
|
||||
"description": "Exa search API key. Alternative web search backend with semantic ranking. Get one at https://exa.ai/.",
|
||||
"sensitive": true,
|
||||
"required": false
|
||||
},
|
||||
"serper_api_key": {
|
||||
"type": "string",
|
||||
"title": "SERPER_API_KEY",
|
||||
"description": "Serper API key. Google search via API. Get one at https://serper.dev/.",
|
||||
"sensitive": true,
|
||||
"required": false
|
||||
},
|
||||
"google_api_key": {
|
||||
"type": "string",
|
||||
"title": "GOOGLE_API_KEY",
|
||||
"description": "Google API key for YouTube transcript fetching and other Google services. Get one at https://console.cloud.google.com/apis/credentials.",
|
||||
"sensitive": true,
|
||||
"required": false
|
||||
},
|
||||
"gemini_api_key": {
|
||||
"type": "string",
|
||||
"title": "GEMINI_API_KEY",
|
||||
"description": "Gemini API key. Used for synthesis fallback when other LLM providers are unavailable. Get one at https://aistudio.google.com/apikey.",
|
||||
"sensitive": true,
|
||||
"required": false
|
||||
},
|
||||
"google_genai_api_key": {
|
||||
"type": "string",
|
||||
"title": "GOOGLE_GENAI_API_KEY",
|
||||
"description": "Alternative Google generative-AI API key. Same source as GEMINI_API_KEY; set whichever name your tooling expects.",
|
||||
"sensitive": true,
|
||||
"required": false
|
||||
},
|
||||
"apify_api_token": {
|
||||
"type": "string",
|
||||
"title": "APIFY_API_TOKEN",
|
||||
"description": "Apify API token. Powers TikTok and Instagram Reels search via Apify actors. Get one at https://console.apify.com/account/integrations.",
|
||||
"sensitive": true,
|
||||
"required": false
|
||||
},
|
||||
"bsky_app_password": {
|
||||
"type": "string",
|
||||
"title": "BSKY_APP_PASSWORD",
|
||||
"description": "Bluesky app password (not your main password). Powers AT Protocol post search. Create at https://bsky.app/settings/app-passwords.",
|
||||
"sensitive": true,
|
||||
"required": false
|
||||
},
|
||||
"parallel_api_key": {
|
||||
"type": "string",
|
||||
"title": "PARALLEL_API_KEY",
|
||||
"description": "Parallel AI key. Powers parallel research runs across sources. Get one at https://parallel.ai/.",
|
||||
"sensitive": true,
|
||||
"required": false
|
||||
},
|
||||
"scrapecreators_api_key": {
|
||||
"type": "string",
|
||||
"title": "SCRAPECREATORS_API_KEY",
|
||||
"description": "ScrapeCreators API key. Powers creator-focused social search across TikTok, Instagram, and YouTube. Get one at https://scrapecreators.com/.",
|
||||
"sensitive": true,
|
||||
"required": false
|
||||
},
|
||||
"openrouter_api_key": {
|
||||
"type": "string",
|
||||
"title": "OPENROUTER_API_KEY",
|
||||
"description": "OpenRouter API key. Alternative LLM provider gateway for synthesis. Get one at https://openrouter.ai/keys.",
|
||||
"sensitive": true,
|
||||
"required": false
|
||||
}
|
||||
},
|
||||
"compatibility": {
|
||||
"claude_desktop": ">=1.0.0",
|
||||
"platforms": [
|
||||
"darwin",
|
||||
"linux"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
#!/usr/bin/env bash
|
||||
# Mirrors skills/last30days/scripts/{last30days.py,lib/} into mcp/vendored/
|
||||
# so the Go binary's embed.FS captures the engine at build time.
|
||||
#
|
||||
# Source of truth: skills/last30days/scripts/. Never edit mcp/vendored/ directly.
|
||||
# Run before `go build` locally and in CI before `printing-press bundle`.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
MCP_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
|
||||
REPO_ROOT="$(cd "${MCP_DIR}/.." && pwd)"
|
||||
ENGINE_SRC="${REPO_ROOT}/skills/last30days/scripts"
|
||||
# Embed path must live inside the consuming package (Go //go:embed cannot
|
||||
# reach outside its own directory tree), so vendored/ sits under engine/.
|
||||
VENDORED="${MCP_DIR}/internal/engine/vendored"
|
||||
|
||||
if [ ! -f "${ENGINE_SRC}/last30days.py" ]; then
|
||||
echo "sync-engine: ${ENGINE_SRC}/last30days.py not found" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mkdir -p "${VENDORED}"
|
||||
# Clear stale content while keeping the .gitkeep that anchors the embed path.
|
||||
find "${VENDORED}" -mindepth 1 -not -name ".gitkeep" -delete
|
||||
|
||||
# Copy the entry script and the lib/ tree (modules + lib/vendor/).
|
||||
cp "${ENGINE_SRC}/last30days.py" "${VENDORED}/last30days.py"
|
||||
cp -R "${ENGINE_SRC}/lib" "${VENDORED}/lib"
|
||||
|
||||
# Strip caches so the embed.FS stays deterministic.
|
||||
find "${VENDORED}" -type d -name "__pycache__" -prune -exec rm -rf {} +
|
||||
find "${VENDORED}" -type f -name "*.pyc" -delete
|
||||
|
||||
echo "sync-engine: vendored engine at ${VENDORED}"
|
||||
|
After Width: | Height: | Size: 2.4 MiB |
@@ -1,12 +1,10 @@
|
||||
[project]
|
||||
name = "last30days-skill"
|
||||
version = "3.0.0"
|
||||
version = "3.2.3"
|
||||
description = "Multi-source last-30-days research skill"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = [
|
||||
"requests>=2.32,<3",
|
||||
]
|
||||
dependencies = []
|
||||
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
@@ -24,9 +22,9 @@ addopts = [
|
||||
|
||||
[tool.coverage.run]
|
||||
branch = true
|
||||
source = ["scripts", "tests"]
|
||||
source = ["skills/last30days/scripts", "tests"]
|
||||
omit = [
|
||||
"scripts/lib/vendor/*",
|
||||
"skills/last30days/scripts/lib/vendor/*",
|
||||
"dist/*",
|
||||
]
|
||||
|
||||
@@ -34,7 +32,6 @@ omit = [
|
||||
skip_empty = true
|
||||
show_missing = true
|
||||
omit = [
|
||||
"scripts/lib/vendor/*",
|
||||
"skills/last30days/scripts/lib/vendor/*",
|
||||
"dist/*",
|
||||
]
|
||||
|
||||
|
||||
@@ -58,7 +58,7 @@ OpenClaw:
|
||||
clawhub install last30days-official
|
||||
```
|
||||
|
||||
OpenAI Codex CLI: run `codex` from a checkout of this repo and v3's skill at `.agents/skills/last30days/SKILL.md` will be discovered automatically. Or copy `SKILL.md` to `~/.agents/skills/last30days/SKILL.md` for a global install.
|
||||
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`.
|
||||
|
||||
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,123 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# sync.sh - Deploy last30days skill to all host locations
|
||||
# Usage: bash scripts/sync.sh (run from repo root)
|
||||
set -euo pipefail
|
||||
|
||||
SRC="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
echo "Source: $SRC"
|
||||
|
||||
COMMON_TARGETS=(
|
||||
# Claude Code plugin cache: marketplace installs overwrite on update,
|
||||
# but local development needs the cache kept in sync with the repo.
|
||||
# Do NOT add ~/.claude/skills/last30days - it creates a duplicate
|
||||
# /last30days-3 in the slash command menu alongside the plugin version.
|
||||
"$HOME/.claude/plugins/cache/last30days-skill-private/last30days-3/3.0.1"
|
||||
"$HOME/.claude/plugins/cache/last30days-skill-private/last30days-3-nogem/3.0.0-nogem"
|
||||
"$HOME/.agents/skills/last30days"
|
||||
"$HOME/.codex/skills/last30days"
|
||||
)
|
||||
OPENCLAW_TARGET="$HOME/.openclaw/skills/last30days"
|
||||
|
||||
sync_target() {
|
||||
local target="$1"
|
||||
local skill_md="$2"
|
||||
|
||||
echo ""
|
||||
echo "--- Syncing to $target ---"
|
||||
mkdir -p "$target/scripts/lib"
|
||||
|
||||
cp "$skill_md" "$target/SKILL.md"
|
||||
|
||||
rsync -a \
|
||||
"$SRC/scripts/last30days.py" \
|
||||
"$SRC/scripts/watchlist.py" \
|
||||
"$SRC/scripts/briefing.py" \
|
||||
"$SRC/scripts/store.py" \
|
||||
"$target/scripts/"
|
||||
rsync -a "$SRC/scripts/lib/"*.py "$target/scripts/lib/"
|
||||
|
||||
# The OpenClaw variant lives in the private repo only. Skip cleanly when
|
||||
# running this script from the public repo where variants/open does not exist.
|
||||
if [ -d "$SRC/variants/open" ]; then
|
||||
mkdir -p "$target/variants/open/references"
|
||||
rsync -a "$SRC/variants/open/" "$target/variants/open/"
|
||||
fi
|
||||
|
||||
if [ -d "$SRC/scripts/lib/vendor" ]; then
|
||||
rsync -a "$SRC/scripts/lib/vendor" "$target/scripts/lib/"
|
||||
fi
|
||||
|
||||
if [ -d "$SRC/fixtures" ]; then
|
||||
mkdir -p "$target/fixtures"
|
||||
rsync -a "$SRC/fixtures/" "$target/fixtures/"
|
||||
fi
|
||||
|
||||
mod_count=$(ls "$target/scripts/lib/"*.py 2>/dev/null | wc -l | tr -d ' ')
|
||||
echo " Copied $mod_count modules"
|
||||
|
||||
if (
|
||||
cd "$target/scripts" &&
|
||||
python3 -c "import briefing, store, watchlist; from lib import youtube_yt, bird_x, render, ui; print(' Import check: OK')"
|
||||
); then
|
||||
true
|
||||
else
|
||||
echo " Import check FAILED"
|
||||
fi
|
||||
}
|
||||
|
||||
for t in "${COMMON_TARGETS[@]}"; do
|
||||
sync_target "$t" "$SRC/SKILL.md"
|
||||
done
|
||||
|
||||
# Hermes sync: deploy to Hermes skills directory if it exists
|
||||
HERMES_TARGET="$HOME/.hermes/skills/research/last30days"
|
||||
if [ -d "$HOME/.hermes/skills/research" ]; then
|
||||
echo ""
|
||||
echo "--- Syncing to Hermes ---"
|
||||
mkdir -p "$HERMES_TARGET/scripts/lib"
|
||||
|
||||
cp "$SRC/SKILL.md" "$HERMES_TARGET/SKILL.md"
|
||||
|
||||
rsync -a \
|
||||
"$SRC/scripts/last30days.py" \
|
||||
"$SRC/scripts/watchlist.py" \
|
||||
"$SRC/scripts/briefing.py" \
|
||||
"$SRC/scripts/store.py" \
|
||||
"$HERMES_TARGET/scripts/"
|
||||
rsync -a "$SRC/scripts/lib/"*.py "$HERMES_TARGET/scripts/lib/"
|
||||
|
||||
if [ -d "$SRC/scripts/lib/vendor" ]; then
|
||||
rsync -a "$SRC/scripts/lib/vendor" "$HERMES_TARGET/scripts/lib/"
|
||||
fi
|
||||
|
||||
if [ -d "$SRC/fixtures" ]; then
|
||||
mkdir -p "$HERMES_TARGET/fixtures"
|
||||
rsync -a "$SRC/fixtures/" "$HERMES_TARGET/fixtures/"
|
||||
fi
|
||||
|
||||
mod_count=$(ls "$HERMES_TARGET/scripts/lib/"*.py 2>/dev/null | wc -l | tr -d ' ')
|
||||
echo " Copied $mod_count modules to Hermes"
|
||||
|
||||
if (
|
||||
cd "$HERMES_TARGET/scripts" &&
|
||||
python3 -c "import briefing, store, watchlist; from lib import youtube_yt, bird_x, render, ui; print(' Import check: OK')"
|
||||
); then
|
||||
true
|
||||
else
|
||||
echo " Import check FAILED"
|
||||
fi
|
||||
fi
|
||||
|
||||
# OpenClaw sync only runs when the private-repo OpenClaw variant is present
|
||||
# in the source tree. The public repo does not ship variants/open (the variant
|
||||
# is sanitized via strip_for_openclaw.py and published separately from
|
||||
# last30days-skill-private).
|
||||
if [ -d "$SRC/variants/open" ]; then
|
||||
sync_target "$OPENCLAW_TARGET" "$SRC/variants/open/SKILL.md"
|
||||
else
|
||||
echo ""
|
||||
echo "Skipping OpenClaw target (no variants/open in this repo)"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "Sync complete."
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: last30days
|
||||
version: "3.0.1"
|
||||
version: "3.2.3"
|
||||
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'
|
||||
allowed-tools: Bash, Read, Write, AskUserQuestion, WebSearch
|
||||
@@ -45,6 +45,7 @@ metadata:
|
||||
- instagram
|
||||
- hackernews
|
||||
- polymarket
|
||||
- digg
|
||||
- bluesky
|
||||
- truthsocial
|
||||
- trends
|
||||
@@ -59,23 +60,32 @@ metadata:
|
||||
- clawhub
|
||||
---
|
||||
|
||||
# STEP 0: CANONICAL PATH SELF-CHECK — RUN BEFORE READING BELOW
|
||||
# STEP 0: STALE-CLONE SELF-CHECK — RUN BEFORE READING BELOW
|
||||
|
||||
Before reading anything else in this file, verify that the SKILL.md you just loaded is the versioned cache copy, not a stale marketplace clone:
|
||||
Before reading anything else in this file, check whether you loaded SKILL.md from the one known stale-clone location: Claude Code's marketplaces directory.
|
||||
|
||||
**Run this check:**
|
||||
|
||||
```bash
|
||||
CANONICAL_ROOT="$HOME/.claude/plugins/cache/last30days-skill/last30days"
|
||||
CANONICAL_LATEST=$(ls -d "$CANONICAL_ROOT"/*/ 2>/dev/null | sort -V | tail -1)
|
||||
CANONICAL_LATEST="${CANONICAL_LATEST%/}"
|
||||
CLAUDE_CACHE_LATEST=$(find "$HOME/.claude/plugins/cache/last30days-skill/last30days" -mindepth 1 -maxdepth 1 -type d 2>/dev/null | sort -V | tail -1)
|
||||
# Two cache layouts ship in the wild — nested ({version}/skills/last30days/SKILL.md)
|
||||
# and flat ({version}/SKILL.md). Resolve to whichever shape actually exists.
|
||||
CLAUDE_CACHE_SKILL_MD=""
|
||||
if [ -n "$CLAUDE_CACHE_LATEST" ]; then
|
||||
if [ -f "$CLAUDE_CACHE_LATEST/skills/last30days/SKILL.md" ]; then
|
||||
CLAUDE_CACHE_SKILL_MD="$CLAUDE_CACHE_LATEST/skills/last30days/SKILL.md"
|
||||
elif [ -f "$CLAUDE_CACHE_LATEST/SKILL.md" ]; then
|
||||
CLAUDE_CACHE_SKILL_MD="$CLAUDE_CACHE_LATEST/SKILL.md"
|
||||
fi
|
||||
fi
|
||||
echo "CLAUDE_CACHE_SKILL_MD=$CLAUDE_CACHE_SKILL_MD"
|
||||
```
|
||||
|
||||
If the SKILL.md file you just read is NOT under `$CANONICAL_LATEST/`, STOP and re-read `$CANONICAL_LATEST/SKILL.md` before proceeding. Do not run `--help`, do not plan, do not invoke the engine against any other path.
|
||||
If the SKILL.md path you just Read contains `/.claude/plugins/marketplaces/` AND `$CLAUDE_CACHE_SKILL_MD` is non-empty, STOP and re-read `$CLAUDE_CACHE_SKILL_MD` before proceeding. Otherwise the SKILL.md you have is fine — continue.
|
||||
|
||||
**Why:** `~/.claude/plugins/marketplaces/last30days-skill/` is a git clone Claude Code auto-restores to `origin/main` on session start. It can lag the versioned cache by one or more releases. Three 2026-04-22 test runs (Linear, Coinbase) loaded SKILL.md from `marketplaces/`, ran `--help` from the same stale path, did not see the `--competitors` flag that existed in the cache, and fell back to a manual comparison plan. Result: 2 of 3 windows never invoked the feature they were asked to test.
|
||||
**Why this specific check:** `~/.claude/plugins/marketplaces/last30days-skill/` is a git clone Claude Code auto-restores to `origin/main` on session start. It can lag the versioned cache by one or more releases. Three 2026-04-22 test runs (Linear, Coinbase) loaded SKILL.md from `marketplaces/`, ran `--help` from the same stale path, did not see the `--competitors` flag that existed in the cache, and fell back to a manual comparison plan. Result: 2 of 3 windows never invoked the feature they were asked to test. STEP 0 defends against that one Claude Code-specific bug.
|
||||
|
||||
**How to self-check:** the file path you used in your last Read tool call should match `$CANONICAL_LATEST/SKILL.md`. If it contains `marketplaces/` or any other prefix, that is the stale-path failure mode. Re-read from `$CANONICAL_LATEST/SKILL.md` and restart this contract from the top.
|
||||
|
||||
The same pinned resolver appears later in Step 1 for the engine Bash invocation. That guard is necessary but insufficient — by the time you reach Step 1, you may have already internalized an out-of-date flag list from the stale SKILL.md above it. This STEP 0 runs first so the CONTRACT itself is read from the right file.
|
||||
**Other install paths are fine:** `~/.codex/skills/`, `~/.agents/skills/`, an `npx skills add` install dir, or a repo checkout are all valid load points - the resolver in Step 1 picks them up. Do NOT abort or hop on those paths.
|
||||
|
||||
---
|
||||
|
||||
@@ -87,7 +97,7 @@ You are inside the `/last30days` SKILL. This is a specific research tool with a
|
||||
|
||||
**How v3.0.7 fixes it:** three structural anchors.
|
||||
1. **The MANDATORY first-line badge** (`🌐 last30days v{VERSION} · synced {YYYY-MM-DD}`) at the top of every response is the LAW 2 / LAW 4 enforcement anchor. See "BADGE (MANDATORY, FIRST LINE OF OUTPUT)" in the synthesis section.
|
||||
2. **The pinned SKILL_ROOT resolution** in the engine Bash calls always points to the public plugin cache, never `~/.openclaw/` or other stale copies.
|
||||
2. **The SKILL_ROOT resolver** in the engine Bash calls walks a precedence list of known install locations and picks the highest-versioned freshest copy, never `~/.openclaw/` or other stale copies.
|
||||
3. **This preface** tells you plainly: do NOT improvise. Follow SKILL.md top to bottom.
|
||||
|
||||
If you catch yourself about to write a `##` section header in a GENERAL-query body, a custom title line, a `Sources:` bullet list, a `for dir in ...` path-discovery loop, or a bare `python3 scripts/last30days.py "{TOPIC}"` engine call with no pre-flight flags — stop. Those are the exact failure modes the LAWs and this contract exist to prevent. The 10/10 beta validation from 2026-04-18 and the 0/8 public v3.0.6 regression from the same day had THE SAME MODEL and SIMILAR SKILL.md CONTENT; the delta is the three anchors this release restores. Read SKILL.md top to bottom before emitting your first response.
|
||||
@@ -104,7 +114,7 @@ These anchors used to live at line 1094 of this file. Three independent Opus 4.7
|
||||
🌐 last30days v{VERSION} · synced {YYYY-MM-DD}
|
||||
```
|
||||
|
||||
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.
|
||||
Replace `{VERSION}` with the installed plugin version (`jq -r '.version' "$SKILL_ROOT/../../.claude-plugin/plugin.json" 2>/dev/null || awk '/^version:/{gsub(/"/,"",$2); print $2; exit}' "$SKILL_ROOT/SKILL.md"`) 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.
|
||||
|
||||
@@ -176,13 +186,13 @@ The self-evolving loop is the sticky use case. Every 15 tool calls Hermes pauses
|
||||
Cron-scheduled autonomous briefings are the most-cited concrete workflow. r/TunisiaTech's "Use cases of OpenClaw, Hermes Agent" thread says it plainly: "Currently I have daily cron jobs for news briefing, but I know there's much more I can do."
|
||||
```
|
||||
|
||||
**LAW 7 - YOU ARE THE PLANNER. `--plan` IS MANDATORY ON NAMED-ENTITY TOPICS.** If you are the reasoning model hosting this skill (Claude Code, Codex, Hermes, Gemini, or any agent runtime that invoked `/last30days`), YOU generate the JSON query plan. You do not need an API key, "LLM provider" credentials, or an external planning service - you ARE the LLM. The `--plan` flag exists precisely so a reasoning model generates its own plan upstream and passes it to the engine. The engine's internal planner and deterministic fallback are headless/cron paths only; on any reasoning-model path, bypass them by passing `--plan '$JSON'`.
|
||||
**LAW 7 - YOU ARE THE PLANNER. `--plan` IS MANDATORY ON NAMED-ENTITY TOPICS.** If you are the reasoning model hosting this skill (Claude Code, Codex, Hermes, Gemini, or any agent runtime that invoked `/last30days`), YOU generate the JSON query plan. You do not need an API key, "LLM provider" credentials, or an external planning service - you ARE the LLM. The `--plan` flag exists precisely so a reasoning model generates its own plan upstream and passes it to the engine. The engine's internal planner and deterministic fallback are headless/cron paths only; on any reasoning-model path, bypass them by passing `--plan "$QUERY_PLAN_FILE"` (the path to a tmpfile you wrote via heredoc — see Step 1 for the pattern; never inline `--plan '$JSON'`, apostrophes in search/ranking strings break shell parsing).
|
||||
|
||||
Named-entity topics (capitalized proper nouns, product names, person names, project names, or any topic that would benefit from handle resolution in Step 0.55) REQUIRE `--plan`. Your invocation of `scripts/last30days.py` MUST contain `--plan '$JSON'`. A bare `python3 scripts/last30days.py "$TOPIC" --emit=compact` on a named-entity topic is a LAW 7 violation. Before you invoke Bash, self-check: does my command contain `--plan`? If no, STOP and generate a plan first (see Step 0.75 for the schema).
|
||||
Named-entity topics (capitalized proper nouns, product names, person names, project names, or any topic that would benefit from handle resolution in Step 0.55) REQUIRE `--plan`. Your invocation of `scripts/last30days.py` MUST contain `--plan "$QUERY_PLAN_FILE"` (or any path the engine can read). A bare `python3 scripts/last30days.py "$TOPIC" --emit=compact` on a named-entity topic is a LAW 7 violation. Before you invoke Bash, self-check: does my command contain `--plan`? If no, STOP and generate a plan first (see Step 0.75 for the schema).
|
||||
|
||||
**Observed LAW 7 violation (2026-04-19, Hermes Agent Use Cases Run 1):** the model called the engine bare with no `--plan`, no pre-flight handle resolution. The engine emitted a stderr warning ("No --plan and no LLM provider configured. Using deterministic fallback...") which the model read as a capability constraint ("I don't have a key, I can't do LLM stuff") instead of as what it actually was: a reminder that the reasoning model skipped its own planning step. The misread came from the word "provider" - the engine uses "provider" to mean "the key for the engine's INTERNAL planner," but the model parsed it as "I need a provider to plan at all." You do not. You ARE the provider. Run 2 of the same topic (2026-04-19, framed as "best workflows") with the same model and same cache generated the plan itself via `--plan` and produced clean results - the delta was this step.
|
||||
|
||||
**Self-check before Bash:** re-read your pending `scripts/last30days.py` command. Does it contain `--plan '$JSON'`? If no, and the topic is a named entity, STOP. Return to Step 0.75 and generate the plan. Do not interpret the word "provider" in any engine message as "you need credentials" - you are the provider.
|
||||
**Self-check before Bash:** re-read your pending `scripts/last30days.py` command. Does it contain `--plan "$QUERY_PLAN_FILE"` (or another path the engine can read)? If no, and the topic is a named entity, STOP. Return to Step 0.75 and generate the plan, then write it to a tmpfile per the Step 1 pattern. Do not interpret the word "provider" in any engine message as "you need credentials" - you are the provider.
|
||||
|
||||
**LAW 8 - EVERY CITATION IN THE NARRATIVE IS AN INLINE MARKDOWN LINK `[name](url)`. NEVER A RAW URL STRING. NEVER A PLAIN NAME WHEN A URL IS AVAILABLE.** Applies to every query type. In the "What I learned:" narrative, in KEY PATTERNS, and in the COMPARISON body sections, every cited @handle, r/subreddit, publication, YouTube channel, TikTok creator, Instagram creator, and Polymarket market is wrapped as `[name](url)` at first mention. The URL comes from the raw research dump — every engine item carries a URL; WebSearch supplements carry URLs in their own output. Claude Code renders `[text](url)` as blue CMD-clickable text; the URL is hidden in the rendering, only the link text shows. The stats footer (emoji-tree block) is engine-emitted per LAW 5 and passes through verbatim — do NOT reformat its links yourself.
|
||||
|
||||
@@ -233,7 +243,7 @@ If your Bash call to `last30days.py` does NOT include the FULL pre-flight checkl
|
||||
|
||||
---
|
||||
|
||||
# last30days v3.0.1: Research Any Topic from the Last 30 Days
|
||||
# last30days v3.2.3: 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.
|
||||
|
||||
@@ -317,6 +327,7 @@ Common patterns:
|
||||
|
||||
- Always active: Reddit, Hacker News, Polymarket
|
||||
- If gh CLI is installed (check `which gh`): add GitHub
|
||||
- If digg-pp-cli is installed (check `which digg-pp-cli`): add Digg
|
||||
- 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 SCRAPECREATORS_API_KEY is set and INCLUDE_SOURCES contains tiktok: add TikTok
|
||||
@@ -581,18 +592,56 @@ When the user asks "X vs Y" (or "X vs Y vs Z"), the engine fans out N full `pipe
|
||||
|
||||
**Invocation:**
|
||||
```bash
|
||||
# Comparison mode skips Step 1, so resolve SKILL_ROOT inline here (same precedence
|
||||
# walk as Step 1 — keep the two in sync if you edit either).
|
||||
SKILL_ROOT=""
|
||||
CLAUDE_PLUGIN_ROOT="$(find "$HOME/.claude/plugins/cache/last30days-skill/last30days" -mindepth 1 -maxdepth 1 -type d 2>/dev/null | sort -V | tail -1)"
|
||||
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
|
||||
if [ -z "$SKILL_ROOT" ] || [ ! -f "$SKILL_ROOT/scripts/last30days.py" ]; then
|
||||
for dir in \
|
||||
"$HOME/.codex/skills/last30days" \
|
||||
"$HOME/.agents/skills/last30days" \
|
||||
"./skills/last30days" \
|
||||
"./.skills/last30days" \
|
||||
"." \
|
||||
"${GEMINI_EXTENSION_DIR:-}"; do
|
||||
[ -n "$dir" ] && [ -f "$dir/scripts/last30days.py" ] && SKILL_ROOT="$dir" && break
|
||||
done
|
||||
fi
|
||||
|
||||
# Write the per-entity plan to a tmpfile and pass the path to the engine.
|
||||
# The engine's parse_competitors_plan() reads file paths transparently. This
|
||||
# avoids the inline-single-quoted-JSON apostrophe trap (resolved context
|
||||
# strings like "people's choice" or "McDonald's" otherwise close the outer
|
||||
# single-quote and break shell parsing before the engine is even invoked).
|
||||
# Trailing XXXXXX (no .json suffix) so BSD/macOS mktemp works the same as
|
||||
# GNU; BSD only substitutes X's at the end of the template.
|
||||
COMPETITORS_PLAN_FILE=$(mktemp "${TMPDIR:-/tmp}/last30days-competitors.XXXXXX")
|
||||
trap 'rm -f "$COMPETITORS_PLAN_FILE"' EXIT
|
||||
cat > "$COMPETITORS_PLAN_FILE" <<'PLAN_EOF'
|
||||
{
|
||||
"{TOPIC_B}": {"x_handle":"{TOPIC_B_HANDLE}","subreddits":["{TOPIC_B_SUB_1}","{TOPIC_B_SUB_2}"],"github_user":"{TOPIC_B_GH}","context":"{TOPIC_B_CONTEXT}"},
|
||||
"{TOPIC_C}": {"x_handle":"{TOPIC_C_HANDLE}","subreddits":["{TOPIC_C_SUB_1}"],"github_user":"{TOPIC_C_GH}","context":"{TOPIC_C_CONTEXT}"}
|
||||
}
|
||||
PLAN_EOF
|
||||
|
||||
"${LAST30DAYS_PYTHON}" "${SKILL_ROOT}/scripts/last30days.py" "{TOPIC_A} vs {TOPIC_B} vs {TOPIC_C}" \
|
||||
--emit=compact \
|
||||
--save-dir="${LAST30DAYS_MEMORY_DIR}" \
|
||||
--save-suffix=v3 \
|
||||
--x-handle={TOPIC_A_HANDLE} \
|
||||
--subreddits={TOPIC_A_SUBS} \
|
||||
--competitors-plan '{
|
||||
"{TOPIC_B}": {"x_handle":"{TOPIC_B_HANDLE}","subreddits":["{TOPIC_B_SUB_1}","{TOPIC_B_SUB_2}"],"github_user":"{TOPIC_B_GH}","context":"{TOPIC_B_CONTEXT}"},
|
||||
"{TOPIC_C}": {"x_handle":"{TOPIC_C_HANDLE}","subreddits":["{TOPIC_C_SUB_1}"],"github_user":"{TOPIC_C_GH}","context":"{TOPIC_C_CONTEXT}"}
|
||||
}'
|
||||
--competitors-plan "$COMPETITORS_PLAN_FILE"
|
||||
```
|
||||
|
||||
**The quoted heredoc marker `'PLAN_EOF'` is load-bearing** — quoting suppresses shell interpolation so apostrophes, `$`, backticks, etc. pass through verbatim. If you ever switch to an unquoted `<<PLAN_EOF`, every variable reference and apostrophe inside the JSON becomes a parse hazard.
|
||||
|
||||
Topic A (the main topic, first in the vs-string) uses outer `--x-handle`, `--x-related`, `--subreddits`, `--github-user`, `--github-repo`, `--tiktok-*`, `--ig-creators` as usual. Topics B and C get their targeting from `--competitors-plan` entries (keyed by entity name, case-insensitive).
|
||||
|
||||
**Step 0.55 for N entities.** The same pre-research protocol that applies to a single-entity topic applies to EACH entity in a vs-run. For N=3, that means 3 WebSearches for X handles, 3 for subreddits, 3 for GitHub, 3 for news context — or equivalent batched queries. A `## Resolved Entities` block with dashes for any entity means you skipped Step 0.55 for that one. Re-run with a corrected plan.
|
||||
@@ -824,7 +873,7 @@ Only show lines for platforms where something was resolved. Skip empty lines. On
|
||||
- For how_to: prioritize YouTube (tutorials) and Reddit (guides)
|
||||
- Primary subquery weight = 1.0, secondary = 0.6-0.8, peripheral = 0.3-0.5
|
||||
|
||||
**Available sources (include ALL in primary subquery):** reddit, x, youtube, tiktok, instagram, hackernews, polymarket. Optional: bluesky, truthsocial, threads, pinterest, grounding (web search - only if user has Brave/Exa/Serper key)
|
||||
**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 clusters - only if `digg-pp-cli` is on PATH)
|
||||
|
||||
**Intent → freshness_mode mapping:**
|
||||
- breaking_news, prediction → `strict_recent`
|
||||
@@ -867,34 +916,65 @@ 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).**
|
||||
|
||||
```bash
|
||||
# PIN SKILL_ROOT to the public plugin cache (highest-version dir wins on upgrade).
|
||||
# DO NOT write your own path-discovery loop. The 2026-04-18 Peter Steinberger run 1
|
||||
# regression was caused by a custom discovery loop landing on ~/.openclaw/skills/last30days/
|
||||
# (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%/}"
|
||||
# Resolve SKILL_ROOT by walking a precedence list of known install locations.
|
||||
# Claude Code plugin cache wins when present (highest version dir picked on upgrade),
|
||||
# then common per-harness skill dirs, then a repo checkout.
|
||||
SKILL_ROOT=""
|
||||
|
||||
# Fallback for repo checkout / Gemini / Codex hosts where the plugin cache does not exist.
|
||||
# Only runs if the public plugin cache is missing entirely.
|
||||
# 1. Claude Code plugin cache (versioned, sort -V picks freshest). Two cache layouts ship in the wild:
|
||||
# nested ({cache}/{version}/skills/last30days/scripts/...) and flat ({cache}/{version}/scripts/...).
|
||||
# `find` (not `ls + glob`) because zsh errors on globs that match nothing, leaking
|
||||
# noisy "no matches found" stderr in Codex/zsh sessions even with 2>/dev/null.
|
||||
CLAUDE_PLUGIN_ROOT="$(find "$HOME/.claude/plugins/cache/last30days-skill/last30days" -mindepth 1 -maxdepth 1 -type d 2>/dev/null | sort -V | tail -1)"
|
||||
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
|
||||
|
||||
# 2. Common per-harness skill dirs and repo checkout (npx skills, Codex, Agents, Gemini, etc).
|
||||
if [ -z "$SKILL_ROOT" ] || [ ! -f "$SKILL_ROOT/scripts/last30days.py" ]; then
|
||||
for dir in "." "${CLAUDE_PLUGIN_ROOT:-}" "${GEMINI_EXTENSION_DIR:-}"; do
|
||||
for dir in \
|
||||
"$HOME/.codex/skills/last30days" \
|
||||
"$HOME/.agents/skills/last30days" \
|
||||
"./skills/last30days" \
|
||||
"./.skills/last30days" \
|
||||
"." \
|
||||
"${GEMINI_EXTENSION_DIR:-}"; do
|
||||
[ -n "$dir" ] && [ -f "$dir/scripts/last30days.py" ] && SKILL_ROOT="$dir" && break
|
||||
done
|
||||
fi
|
||||
|
||||
if [ -z "${SKILL_ROOT:-}" ] || [ ! -f "$SKILL_ROOT/scripts/last30days.py" ]; then
|
||||
echo "ERROR: Could not find scripts/last30days.py in public plugin cache or repo checkout" >&2
|
||||
echo "Expected: $HOME/.claude/plugins/cache/last30days-skill/last30days/{VERSION}/scripts/last30days.py" >&2
|
||||
echo "ERROR: Could not find scripts/last30days.py in any known install location" >&2
|
||||
echo "Searched: ~/.claude/plugins/cache/, ~/.codex/skills/, ~/.agents/skills/, ./skills/last30days, ./.skills/last30days, ." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
"${LAST30DAYS_PYTHON}" "${SKILL_ROOT}/scripts/last30days.py" $ARGUMENTS --emit=compact --save-dir="${LAST30DAYS_MEMORY_DIR}" --save-suffix=v3
|
||||
```
|
||||
|
||||
**If you ran Steps 0.55 and 0.75 (agent planning), add these flags:**
|
||||
- `--plan 'QUERY_PLAN_JSON'` (replace with actual JSON from Step 0.75)
|
||||
**If you ran Steps 0.55 and 0.75 (agent planning), pass the plan via a tmpfile and add the targeting flags:**
|
||||
|
||||
```bash
|
||||
# Write QUERY_PLAN_JSON to a tmpfile before the engine invocation above.
|
||||
# parse_plan() reads file paths transparently; this avoids inline-JSON
|
||||
# shell-quoting hazards (apostrophes in search_query / ranking_query
|
||||
# strings break single-quoted command-line JSON). Trailing XXXXXX (no
|
||||
# .json suffix) for BSD/macOS portability — BSD mktemp only substitutes
|
||||
# X's at the end of the template.
|
||||
QUERY_PLAN_FILE=$(mktemp "${TMPDIR:-/tmp}/last30days-plan.XXXXXX")
|
||||
trap 'rm -f "$QUERY_PLAN_FILE"' EXIT
|
||||
cat > "$QUERY_PLAN_FILE" <<'PLAN_EOF'
|
||||
{QUERY_PLAN_JSON_FROM_STEP_0.75}
|
||||
PLAN_EOF
|
||||
```
|
||||
|
||||
Then add to the engine command:
|
||||
|
||||
- `--plan "$QUERY_PLAN_FILE"` (path to the file you just wrote)
|
||||
- `--x-handle={RESOLVED_HANDLE}` (from Step 0.5)
|
||||
- `--subreddits={RESOLVED_SUBREDDITS}` (from Step 0.55)
|
||||
- `--tiktok-hashtags={RESOLVED_HASHTAGS}` (from Step 0.55)
|
||||
@@ -1497,6 +1577,33 @@ 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
|
||||
|
||||
**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,90 @@
|
||||
# Save shareable HTML brief
|
||||
|
||||
This reference file is loaded by the main `SKILL.md` when the user asked for an HTML brief (either explicitly via `--emit=html` / `--emit:html` / `--html`, or in natural language - "give me a shareable HTML brief", "for Slack", "for Notion", "export as HTML", etc.). The detection happens in `SKILL.md` so that the common no-HTML path stays short; the implementation lives here.
|
||||
|
||||
The contract: the synthesis still appears in chat as the primary output. The HTML is an additional artifact saved to disk for sharing. Both happen in the same turn.
|
||||
|
||||
## When to fire this flow
|
||||
|
||||
- After you have already emitted the full chat response: badge, "What I learned:" (or comparison title), bold-lead-in paragraphs with citations, KEY PATTERNS list, engine footer pass-through, invitation block.
|
||||
- BEFORE the WAIT FOR USER'S RESPONSE pause.
|
||||
- ONLY if the user asked. Do NOT save HTML when the user didn't ask for it.
|
||||
|
||||
## How to fire it
|
||||
|
||||
```bash
|
||||
# 1. Write your synthesis prose VERBATIM to a temp file. The synthesis is the
|
||||
# "What I learned:" prose label, the bold-lead-in paragraphs with their
|
||||
# inline citations as you wrote them in chat, and the "KEY PATTERNS from
|
||||
# the research:" numbered list. Do NOT include the badge or the engine
|
||||
# footer in the temp file - the engine adds those when it renders the HTML.
|
||||
# Use the EXACT text you just wrote in chat. Do not paraphrase, do not
|
||||
# summarize, do not reorder. The HTML must read identically to the chat
|
||||
# response in voice and citations.
|
||||
SYNTHESIS_FILE="/tmp/last30days-synthesis-${CLAUDE_SESSION_ID}.md"
|
||||
cat > "$SYNTHESIS_FILE" <<'SYNTHESIS_EOF'
|
||||
What I learned:
|
||||
|
||||
**{First headline}** - {body with [name](url) inline citations}
|
||||
|
||||
**{Second headline}** - {body}
|
||||
|
||||
**{Third headline}** - {body}
|
||||
|
||||
KEY PATTERNS from the research:
|
||||
1. {pattern} - per [@handle](url)
|
||||
2. {pattern} - per [r/sub](url)
|
||||
3. {pattern} - per [@handle](url)
|
||||
SYNTHESIS_EOF
|
||||
|
||||
# 2. Convert the synthesis to a self-contained HTML file via the engine.
|
||||
# The engine reuses the cache from your earlier engine run (same topic
|
||||
# + plan), so this second invocation is typically <1s on cache hit.
|
||||
SLUG=$(echo "$TOPIC" | tr '[:upper:]' '[:lower:]' | tr -cs 'a-z0-9' '-' | sed 's/^-//;s/-$//')
|
||||
HTML_PATH="${LAST30DAYS_MEMORY_DIR}/${SLUG}-brief.html"
|
||||
"${LAST30DAYS_PYTHON}" "${SKILL_ROOT}/scripts/last30days.py" "${TOPIC}" \
|
||||
--emit=html \
|
||||
--synthesis-file "$SYNTHESIS_FILE" \
|
||||
> "$HTML_PATH"
|
||||
|
||||
# 3. Append ONE line to your already-emitted chat response, after the
|
||||
# invitation block. Use a paperclip emoji as a visible signal that an
|
||||
# artifact was produced:
|
||||
echo "📎 Shareable brief saved to $HTML_PATH"
|
||||
```
|
||||
|
||||
## What ends up in the HTML file
|
||||
|
||||
The engine's `--emit=html` renderer combines:
|
||||
|
||||
- The badge (`🌐 last30days vX.Y.Z · synced YYYY-MM-DD`) at the top
|
||||
- A single inline metadata line (`{date range} · {active sources}`) below the badge
|
||||
- Your synthesis verbatim, with prose labels promoted to `<h2>` and bold lead-ins preserved
|
||||
- All `[name](url)` citations rendered as `<a>` tags
|
||||
- The engine footer (`✅ All agents reported back!` tree) preserved verbatim in monospace
|
||||
- A colophon with the topic and a re-run hint
|
||||
|
||||
The renderer strips engine-internal noise that doesn't belong in a shareable artifact: the `# last30days vX.Y.Z: TOPIC` debug file header, the model-facing `> Safety note:` blockquote, and the `I'm now an expert on X` invitation block. Data quality warnings (degraded run, thin evidence, etc.) stay in the engine's stderr logs - they never leak into the share-ready file.
|
||||
|
||||
## Comparison mode
|
||||
|
||||
Same flow when the topic is `X vs Y` (or `X vs Y vs Z`). The engine routes through `render_for_html_comparison` internally; you don't need to do anything special. The synthesis temp file should still contain the comparison-shaped synthesis you wrote in chat (`## Quick Verdict`, `## {Entity}` per entity, `## Head-to-Head` table, `## The Bottom Line`, `## The emerging stack` per LAW 4 comparison exception).
|
||||
|
||||
## Follow-up turn
|
||||
|
||||
If the user runs `/last30days OpenClaw` normally, sees the synthesis in chat, and THEN says "save that as HTML" or "give me a shareable version" in a follow-up turn, do the same save flow on the synthesis you wrote in the previous turn. Do not re-research; the synthesis is already in the conversation history. Just write it to the temp file and call the engine with `--emit=html --synthesis-file`.
|
||||
|
||||
## What NOT to do
|
||||
|
||||
- Do NOT save HTML if the user didn't ask. The sparse mode (no synthesis) produces a thin file; not useful as a shareable.
|
||||
- Do NOT add content to the temp file beyond your synthesis prose. The badge / footer / colophon come from the engine.
|
||||
- Do NOT change the file path convention. `${LAST30DAYS_MEMORY_DIR}/${SLUG}-brief.html` is the canonical location.
|
||||
- Do NOT silently overwrite an existing file without telling the user. If `$HTML_PATH` already exists from a prior run, the engine will pick a date-suffixed name (`{slug}-brief-YYYY-MM-DD.html`) automatically; just print whichever path the redirect produced.
|
||||
- Do NOT include the data quality warning text in the temp file or in your final chat line. Warnings are an engine-stderr concern, not an artifact concern.
|
||||
|
||||
## Edge cases
|
||||
|
||||
- **Topic with shell-special characters** (quotes, ampersands): the temp filename uses a slugified version, but the engine receives the raw topic. The `cat <<'SYNTHESIS_EOF'` quoted heredoc form handles arbitrary content without expansion. Your synthesis text can include any character.
|
||||
- **Very long synthesis**: no upper bound. The engine handles long markdown bodies. Just paste verbatim.
|
||||
- **Synthesis with images or non-ASCII**: emoji and Unicode pass through. Image tags pass through as raw HTML; the renderer doesn't transform them. If you didn't include images in chat, don't add them here.
|
||||
- **No `${LAST30DAYS_MEMORY_DIR}` set**: defaults to `~/Documents/Last30Days/` per the SKILL.md `Configuration` section.
|
||||
@@ -1,13 +1,14 @@
|
||||
#!/usr/bin/env bash
|
||||
# build-skill.sh - package this repo as a claude.ai-upload-ready .skill file
|
||||
# Usage: bash scripts/build-skill.sh (run from repo root)
|
||||
# Usage: bash skills/last30days/scripts/build-skill.sh (run from repo root)
|
||||
#
|
||||
# Produces dist/last30days.skill, a zip with a single top-level `last30days/`
|
||||
# directory containing SKILL.md and the scripts/ runtime. See
|
||||
# directory containing SKILL.md and the scripts/ runtime from skills/last30days.
|
||||
# See
|
||||
# docs/plans/2026-04-14-001-fix-skill-upload-200-file-limit-plan.md.
|
||||
set -euo pipefail
|
||||
|
||||
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
REPO_ROOT="$(cd "$(dirname "$0")/../../.." && pwd)"
|
||||
cd "$REPO_ROOT"
|
||||
|
||||
if ! git diff --quiet || ! git diff --cached --quiet; then
|
||||
@@ -17,14 +18,7 @@ fi
|
||||
|
||||
mkdir -p dist
|
||||
OUT="dist/last30days.skill"
|
||||
git archive --format=zip --prefix=last30days/ --output="$OUT" HEAD
|
||||
|
||||
# claude.ai's .skill bundle only needs the root SKILL.md + scripts/ runtime.
|
||||
# Claude Code needs skills/ and .claude-plugin/ in the git archive
|
||||
# (that's why they're NOT in .gitattributes export-ignore), but the .skill
|
||||
# bundle must strip them to keep a single canonical SKILL.md and stay under
|
||||
# the 200-file cap.
|
||||
zip -d "$OUT" "last30days/skills/*" "last30days/.claude-plugin/*" > /dev/null 2>&1 || true
|
||||
git archive --format=zip --prefix=last30days/ --output="$OUT" HEAD:skills/last30days
|
||||
|
||||
COUNT=$(unzip -l "$OUT" | tail -1 | awk '{print $2}')
|
||||
SIZE=$(du -h "$OUT" | cut -f1)
|
||||
@@ -1,6 +1,6 @@
|
||||
#!/bin/bash
|
||||
# A/B test runner: public release vs private beta
|
||||
# Usage: bash scripts/compare.sh "Kanye West"
|
||||
# Usage: bash skills/last30days/scripts/compare.sh "Kanye West"
|
||||
#
|
||||
# Runs /last30days (public release) and /last30days-beta (private beta)
|
||||
# sequentially with a 30s gap, saves raw results with distinct suffixes,
|
||||
@@ -9,8 +9,8 @@
|
||||
set -e
|
||||
|
||||
if [ $# -eq 0 ]; then
|
||||
echo "Usage: bash scripts/compare.sh <topic>"
|
||||
echo " Example: bash scripts/compare.sh Kevin Rose"
|
||||
echo "Usage: bash skills/last30days/scripts/compare.sh <topic>"
|
||||
echo " Example: bash skills/last30days/scripts/compare.sh Kevin Rose"
|
||||
exit 1
|
||||
fi
|
||||
TOPIC="$*"
|
||||
@@ -22,7 +22,8 @@ from lib import env as envlib
|
||||
from lib import schema
|
||||
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
SKILL_ROOT = Path(__file__).resolve().parents[1]
|
||||
REPO_ROOT = Path(__file__).resolve().parents[3]
|
||||
EVAL_TOPICS_FILE = REPO_ROOT / "fixtures" / "eval_topics.json"
|
||||
|
||||
|
||||
@@ -307,7 +308,10 @@ 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]:
|
||||
cmd = [sys.executable, "scripts/last30days.py", topic, "--emit=json"]
|
||||
engine = repo_dir / "skills" / "last30days" / "scripts" / "last30days.py"
|
||||
if not engine.exists():
|
||||
engine = repo_dir / "scripts" / "last30days.py"
|
||||
cmd = [sys.executable, str(engine), topic, "--emit=json"]
|
||||
if search:
|
||||
cmd.extend(["--search", search])
|
||||
if quick:
|
||||
@@ -41,7 +41,7 @@ if os.name == "nt":
|
||||
SCRIPT_DIR = Path(__file__).parent.resolve()
|
||||
sys.path.insert(0, str(SCRIPT_DIR))
|
||||
|
||||
from lib import env, pipeline, render, schema, ui
|
||||
from lib import env, html_render, pipeline, render, schema, ui
|
||||
|
||||
_child_pids: set[int] = set()
|
||||
_child_pids_lock = threading.Lock()
|
||||
@@ -91,30 +91,46 @@ def slugify(value: str) -> str:
|
||||
return slug or "last30days"
|
||||
|
||||
|
||||
def save_output(report: schema.Report, emit: str, save_dir: str, suffix: str = "") -> Path:
|
||||
def save_output(
|
||||
report: schema.Report,
|
||||
emit: str,
|
||||
save_dir: str,
|
||||
suffix: str = "",
|
||||
synthesis_md: str | None = None,
|
||||
) -> Path:
|
||||
from datetime import datetime
|
||||
path = Path(save_dir).expanduser().resolve()
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
slug = slugify(report.topic)
|
||||
extension = "json" if emit == "json" else "md"
|
||||
extension = "json" if emit == "json" else "html" if emit == "html" else "md"
|
||||
raw_label = "raw-html" if emit == "html" else "raw"
|
||||
suffix_part = f"-{suffix}" if suffix else ""
|
||||
out_path = path / f"{slug}-raw{suffix_part}.{extension}"
|
||||
out_path = path / f"{slug}-{raw_label}{suffix_part}.{extension}"
|
||||
if out_path.exists():
|
||||
out_path = path / f"{slug}-raw{suffix_part}-{datetime.now().strftime('%Y-%m-%d')}.{extension}"
|
||||
# Always save the FULL dump to disk (all items, all sources, transcripts).
|
||||
# Claude sees compact clusters via --emit=compact on stdout.
|
||||
# The saved file is the complete debug artifact.
|
||||
if emit == "json":
|
||||
content = emit_output(report, emit)
|
||||
out_path = path / f"{slug}-{raw_label}{suffix_part}-{datetime.now().strftime('%Y-%m-%d')}.{extension}"
|
||||
# Markdown saves keep the complete debug artifact. JSON and HTML preserve
|
||||
# their requested wire format so file extensions match their content.
|
||||
if emit in {"json", "html"}:
|
||||
content = emit_output(report, emit, synthesis_md=synthesis_md)
|
||||
else:
|
||||
content = render.render_full(report)
|
||||
out_path.write_text(content, encoding="utf-8")
|
||||
return out_path
|
||||
|
||||
|
||||
def emit_output(report: schema.Report, emit: str, fun_level: str = "medium", save_path: str | None = None) -> str:
|
||||
def emit_output(
|
||||
report: schema.Report,
|
||||
emit: str,
|
||||
fun_level: str = "medium",
|
||||
save_path: str | None = None,
|
||||
synthesis_md: str | None = None,
|
||||
) -> str:
|
||||
if emit == "json":
|
||||
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"}:
|
||||
return render.render_compact(report, fun_level=fun_level, save_path=save_path)
|
||||
if emit == "context":
|
||||
@@ -127,6 +143,7 @@ def emit_comparison_output(
|
||||
emit: str,
|
||||
fun_level: str = "medium",
|
||||
save_path: str | None = None,
|
||||
synthesis_md: str | None = None,
|
||||
) -> str:
|
||||
if emit == "json":
|
||||
payload = {
|
||||
@@ -138,6 +155,13 @@ def emit_comparison_output(
|
||||
],
|
||||
}
|
||||
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"}:
|
||||
return render.render_comparison_multi(
|
||||
entity_reports, fun_level=fun_level, save_path=save_path,
|
||||
@@ -156,9 +180,10 @@ def compute_save_path_display(save_dir: str, topic: str, suffix: str, emit: str)
|
||||
from pathlib import Path as _Path
|
||||
path = _Path(save_dir).expanduser().resolve()
|
||||
slug = slugify(topic)
|
||||
extension = "json" if emit == "json" else "md"
|
||||
extension = "json" if emit == "json" else "html" if emit == "html" else "md"
|
||||
raw_label = "raw-html" if emit == "html" else "raw"
|
||||
suffix_part = f"-{suffix}" if suffix else ""
|
||||
raw = path / f"{slug}-raw{suffix_part}.{extension}"
|
||||
raw = path / f"{slug}-{raw_label}{suffix_part}.{extension}"
|
||||
try:
|
||||
home = _Path.home().resolve()
|
||||
relative = raw.relative_to(home)
|
||||
@@ -167,6 +192,14 @@ def compute_save_path_display(save_dir: str, topic: str, suffix: str, emit: str)
|
||||
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]:
|
||||
import store
|
||||
|
||||
@@ -193,7 +226,7 @@ def persist_report(report: schema.Report) -> dict[str, int]:
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(description="Research a topic across live social, market, and grounded web sources.")
|
||||
parser.add_argument("topic", nargs="*", help="Research topic")
|
||||
parser.add_argument("--emit", default="compact", choices=["compact", "json", "context", "md"])
|
||||
parser.add_argument("--emit", default="compact", choices=["compact", "json", "context", "md", "html"])
|
||||
parser.add_argument("--search", help="Comma-separated source list")
|
||||
parser.add_argument("--quick", action="store_true", help="Lower-latency retrieval profile")
|
||||
parser.add_argument("--deep", action="store_true", help="Higher-recall retrieval profile")
|
||||
@@ -201,6 +234,7 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
parser.add_argument("--mock", action="store_true", help="Use mock retrieval fixtures")
|
||||
parser.add_argument("--diagnose", action="store_true", help="Print provider and source availability")
|
||||
parser.add_argument("--save-dir", help="Optional directory for saving the rendered output")
|
||||
parser.add_argument("--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("--x-handle", help="X handle for targeted supplemental search")
|
||||
parser.add_argument("--x-related", help="Comma-separated related X handles (searched with lower weight)")
|
||||
@@ -537,6 +571,13 @@ def main() -> int:
|
||||
parser.print_usage(sys.stderr)
|
||||
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"):
|
||||
from lib import preflight
|
||||
refuse_msg = preflight.check_class_1_trap(topic)
|
||||
@@ -854,15 +895,29 @@ def main() -> int:
|
||||
|
||||
if entity_reports:
|
||||
rendered = emit_comparison_output(
|
||||
entity_reports, args.emit, fun_level=fun_level, save_path=footer_save_path,
|
||||
entity_reports,
|
||||
args.emit,
|
||||
fun_level=fun_level,
|
||||
save_path=footer_save_path,
|
||||
synthesis_md=synthesis_md,
|
||||
)
|
||||
else:
|
||||
rendered = emit_output(
|
||||
report, args.emit, fun_level=fun_level, save_path=footer_save_path,
|
||||
report,
|
||||
args.emit,
|
||||
fun_level=fun_level,
|
||||
save_path=footer_save_path,
|
||||
synthesis_md=synthesis_md,
|
||||
)
|
||||
if args.save_dir:
|
||||
# Save the main topic's raw file (single-entity or comparison main).
|
||||
save_path = save_output(report, args.emit, args.save_dir, suffix=args.save_suffix or "")
|
||||
save_path = save_output(
|
||||
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")
|
||||
# Competitor / vs-mode: also save a per-entity raw file for each peer.
|
||||
# Matches historical vs-mode behavior (N passes → N save files).
|
||||
@@ -871,6 +926,7 @@ def main() -> int:
|
||||
peer_path = save_output(
|
||||
entity_report, args.emit, args.save_dir,
|
||||
suffix=args.save_suffix or "",
|
||||
synthesis_md=synthesis_md,
|
||||
)
|
||||
sys.stderr.write(f"[last30days] Saved output to {peer_path}\n")
|
||||
sys.stderr.flush()
|
||||
@@ -7,13 +7,11 @@ See scripts/lib/vendor/bird-search/package.json for authoritative version.
|
||||
|
||||
import json
|
||||
import os
|
||||
import signal
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from . import http, log
|
||||
from . import http, log, subproc
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
@@ -168,62 +166,51 @@ def _run_bird_search(query: str, count: int, timeout: int) -> Dict[str, Any]:
|
||||
"--json",
|
||||
]
|
||||
|
||||
# Use process groups for clean cleanup on timeout/kill
|
||||
preexec = os.setsid if hasattr(os, 'setsid') else None
|
||||
pid_holder: list[int] = []
|
||||
|
||||
try:
|
||||
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)
|
||||
def _register(pid: int) -> None:
|
||||
pid_holder.append(pid)
|
||||
try:
|
||||
from last30days import register_child_pid, unregister_child_pid
|
||||
register_child_pid(proc.pid)
|
||||
from last30days import register_child_pid
|
||||
register_child_pid(pid)
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
try:
|
||||
stdout, stderr = proc.communicate(timeout=timeout)
|
||||
except subprocess.TimeoutExpired:
|
||||
# Kill the entire process group
|
||||
try:
|
||||
os.killpg(os.getpgid(proc.pid), signal.SIGTERM)
|
||||
except (ProcessLookupError, PermissionError, OSError):
|
||||
proc.kill()
|
||||
proc.wait(timeout=5)
|
||||
return {"error": f"Search timed out after {timeout}s", "items": []}
|
||||
finally:
|
||||
try:
|
||||
result = subproc.run_with_timeout(
|
||||
cmd,
|
||||
timeout=timeout,
|
||||
env=_subprocess_env(),
|
||||
on_pid=_register,
|
||||
)
|
||||
except subproc.SubprocTimeout:
|
||||
return {"error": f"Search timed out after {timeout}s", "items": []}
|
||||
except Exception as e:
|
||||
return {"error": str(e), "items": []}
|
||||
finally:
|
||||
if pid_holder:
|
||||
try:
|
||||
from last30days import unregister_child_pid
|
||||
unregister_child_pid(proc.pid)
|
||||
unregister_child_pid(pid_holder[0])
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if proc.returncode != 0:
|
||||
error = stderr.strip() if stderr else "Bird search failed"
|
||||
return {"error": error, "items": []}
|
||||
if result.returncode != 0:
|
||||
error = result.stderr.strip() or "Bird search failed"
|
||||
return {"error": error, "items": []}
|
||||
|
||||
output = stdout.strip() if stdout else ""
|
||||
if not output:
|
||||
return {"items": []}
|
||||
output = result.stdout.strip()
|
||||
if not output:
|
||||
return {"items": []}
|
||||
|
||||
try:
|
||||
parsed = json.loads(output)
|
||||
if isinstance(parsed, list):
|
||||
return {"items": parsed}
|
||||
return parsed
|
||||
|
||||
except json.JSONDecodeError as e:
|
||||
return {"error": f"Invalid JSON response: {e}", "items": []}
|
||||
except Exception as e:
|
||||
return {"error": str(e), "items": []}
|
||||
|
||||
if isinstance(parsed, list):
|
||||
return {"items": parsed}
|
||||
return parsed
|
||||
|
||||
|
||||
def search_x(
|
||||
@@ -330,47 +317,29 @@ def search_handles(
|
||||
"--json",
|
||||
]
|
||||
|
||||
preexec = os.setsid if hasattr(os, 'setsid') else None
|
||||
try:
|
||||
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:
|
||||
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)
|
||||
return parse_bird_response(response, query=core_topic)
|
||||
|
||||
except json.JSONDecodeError:
|
||||
_log(f"Invalid JSON from handle search for @{handle}")
|
||||
except (OSError, subprocess.SubprocessError) as e:
|
||||
_log(f"Handle search error for @{handle}: {e}")
|
||||
return []
|
||||
return []
|
||||
return parse_bird_response(response, query=core_topic)
|
||||
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
|
||||
@@ -39,11 +39,14 @@ def normalize_text(text: str) -> str:
|
||||
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]:
|
||||
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)}
|
||||
return _ngrams_of_normalized(normalize_text(text), n)
|
||||
|
||||
|
||||
def jaccard_similarity(left: set[str], right: set[str]) -> float:
|
||||
@@ -90,7 +93,7 @@ class _PreparedText:
|
||||
|
||||
def __init__(self, raw: str) -> None:
|
||||
norm = normalize_text(raw)
|
||||
self.ngrams = get_ngrams(norm) if norm else set()
|
||||
self.ngrams = _ngrams_of_normalized(norm)
|
||||
self.tokens = _tokenize(norm)
|
||||
|
||||
|
||||
@@ -0,0 +1,414 @@
|
||||
"""Digg AI 1000 source for last30days.
|
||||
|
||||
Shells out to ``digg-pp-cli`` (read-only, no auth required) to surface
|
||||
clustered stories curated from ~1000 high-signal AI accounts on X. Each
|
||||
cluster carries a published TLDR, a curatorial rank, and a list of X
|
||||
posts that can be fetched as inline quotes.
|
||||
|
||||
Activation gate: this source is only available when ``digg-pp-cli`` is
|
||||
on PATH. ``pipeline.available_sources`` checks ``shutil.which`` before
|
||||
including ``digg`` in the source list. The functions below also detect
|
||||
the missing-binary case as a defensive fallback.
|
||||
|
||||
Primary path: ``digg-pp-cli search <topic> --since 30d --agent --limit N``.
|
||||
Optional enrichment: ``digg-pp-cli posts <clusterUrlId> --agent --by rank
|
||||
--limit M`` for the top K clusters in default/deep depth, attaching the
|
||||
top-ranked X posts to each cluster's ``posts`` field.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import shutil
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from . import log, subproc
|
||||
from .relevance import token_overlap_relevance
|
||||
|
||||
|
||||
CLI_BIN = "digg-pp-cli"
|
||||
|
||||
# Per-depth knobs.
|
||||
DEPTH_CONFIG = {
|
||||
"quick": 8,
|
||||
"default": 20,
|
||||
"deep": 40,
|
||||
}
|
||||
|
||||
# How many top-ranked clusters get post enrichment, per depth. Quick mode
|
||||
# skips enrichment to keep latency low (clusters already carry a TLDR).
|
||||
ENRICH_CONFIG = {
|
||||
"quick": 0,
|
||||
"default": 3,
|
||||
"deep": 5,
|
||||
}
|
||||
|
||||
# X posts pulled per enriched cluster. Matches the 5-comment cap used by
|
||||
# Reddit/HN/YouTube/TikTok/GitHub enrichment.
|
||||
POSTS_PER_CLUSTER = 5
|
||||
|
||||
SEARCH_TIMEOUT = 30
|
||||
POSTS_TIMEOUT = 15
|
||||
|
||||
|
||||
def _log(msg: str) -> None:
|
||||
log.source_log("Digg", msg)
|
||||
|
||||
|
||||
def _is_available() -> bool:
|
||||
"""True when the digg-pp-cli binary is on PATH."""
|
||||
return shutil.which(CLI_BIN) is not None
|
||||
|
||||
|
||||
def _today() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
def _parse_first_post_age(age: Optional[str], today: Optional[datetime] = None) -> Optional[str]:
|
||||
"""Convert a digg firstPostAge token (e.g. '5d', '17d', '5h', '1w', '1m')
|
||||
into a YYYY-MM-DD string. Returns None when the value is outside the
|
||||
last-30-day window or cannot be parsed.
|
||||
|
||||
Digg uses minutes-symbol-collision for 'months' (per agent-context:
|
||||
'Nh, Nd, Nw, Nm (e.g. 30d, 1w, 12h, 1m)'), so 'Nm' is months ~30 days.
|
||||
"""
|
||||
if not age or not isinstance(age, str):
|
||||
return None
|
||||
age = age.strip().lower()
|
||||
if len(age) < 2:
|
||||
return None
|
||||
unit = age[-1]
|
||||
try:
|
||||
amount = int(age[:-1])
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
if amount < 0:
|
||||
return None
|
||||
|
||||
base = today or _today()
|
||||
|
||||
if unit == "h":
|
||||
delta = timedelta(hours=amount)
|
||||
elif unit == "d":
|
||||
delta = timedelta(days=amount)
|
||||
elif unit == "w":
|
||||
delta = timedelta(weeks=amount)
|
||||
elif unit == "m":
|
||||
delta = timedelta(days=amount * 30)
|
||||
else:
|
||||
return None
|
||||
|
||||
if delta > timedelta(days=30):
|
||||
return None
|
||||
|
||||
point = base - delta
|
||||
return point.date().isoformat()
|
||||
|
||||
|
||||
def _build_search_args(query: str, limit: int) -> List[str]:
|
||||
return [
|
||||
CLI_BIN,
|
||||
"search",
|
||||
query,
|
||||
"--since",
|
||||
"30d",
|
||||
"--agent",
|
||||
"--limit",
|
||||
str(limit),
|
||||
]
|
||||
|
||||
|
||||
def _build_posts_args(cluster_url_id: str, posts_per: int) -> List[str]:
|
||||
return [
|
||||
CLI_BIN,
|
||||
"posts",
|
||||
cluster_url_id,
|
||||
"--agent",
|
||||
"--by",
|
||||
"rank",
|
||||
"--limit",
|
||||
str(posts_per),
|
||||
]
|
||||
|
||||
|
||||
def _run_cli(cmd: List[str], timeout: int) -> Dict[str, Any]:
|
||||
"""Invoke digg-pp-cli and parse the JSON envelope.
|
||||
|
||||
Returns ``{"results": [...]}`` on success, ``{"results": [], "error": "..."}``
|
||||
on failure. Never raises; the pipeline relies on shape consistency.
|
||||
"""
|
||||
if not _is_available():
|
||||
return {"results": [], "error": f"{CLI_BIN} not on PATH"}
|
||||
try:
|
||||
result = subproc.run_with_timeout(cmd, timeout=timeout)
|
||||
except subproc.SubprocTimeout as exc:
|
||||
_log(f"Timeout: {exc}")
|
||||
return {"results": [], "error": str(exc)}
|
||||
except FileNotFoundError as exc:
|
||||
_log(f"Binary missing: {exc}")
|
||||
return {"results": [], "error": str(exc)}
|
||||
except OSError as exc:
|
||||
_log(f"Spawn failed: {exc}")
|
||||
return {"results": [], "error": str(exc)}
|
||||
|
||||
if result.returncode != 0:
|
||||
snippet = (result.stderr or "").strip().splitlines()[:1]
|
||||
first = snippet[0] if snippet else f"exit {result.returncode}"
|
||||
_log(f"CLI exit {result.returncode}: {first}")
|
||||
return {"results": [], "error": first}
|
||||
|
||||
stdout = result.stdout or ""
|
||||
if not stdout.strip():
|
||||
return {"results": []}
|
||||
try:
|
||||
data = json.loads(stdout)
|
||||
except json.JSONDecodeError as exc:
|
||||
_log(f"JSON decode failed: {exc}")
|
||||
return {"results": [], "error": f"json decode: {exc}"}
|
||||
|
||||
if not isinstance(data, dict):
|
||||
return {"results": []}
|
||||
results = data.get("results")
|
||||
if not isinstance(results, list):
|
||||
return {"results": []}
|
||||
return data
|
||||
|
||||
|
||||
def search_digg(
|
||||
topic: str,
|
||||
from_date: str,
|
||||
to_date: str,
|
||||
depth: str = "default",
|
||||
) -> Dict[str, Any]:
|
||||
"""Search Digg AI 1000 clusters via digg-pp-cli.
|
||||
|
||||
Args:
|
||||
topic: search query.
|
||||
from_date: YYYY-MM-DD start (advisory; --since 30d is the actual filter).
|
||||
to_date: YYYY-MM-DD end (advisory; same).
|
||||
depth: 'quick' | 'default' | 'deep'.
|
||||
|
||||
Returns:
|
||||
Dict with ``results`` list. On failure, ``results`` is empty and an
|
||||
``error`` key carries a one-line description.
|
||||
"""
|
||||
limit = DEPTH_CONFIG.get(depth, DEPTH_CONFIG["default"])
|
||||
if not topic or not topic.strip():
|
||||
return {"results": []}
|
||||
cmd = _build_search_args(topic, limit)
|
||||
_log(f"search '{topic}' (limit={limit}, since=30d)")
|
||||
response = _run_cli(cmd, timeout=SEARCH_TIMEOUT)
|
||||
n = len(response.get("results") or [])
|
||||
_log(f"found {n} clusters")
|
||||
return response
|
||||
|
||||
|
||||
def _build_url(cluster_url_id: str) -> str:
|
||||
return f"https://di.gg/ai/{cluster_url_id}"
|
||||
|
||||
|
||||
def _rank_score(rank: Optional[int]) -> float:
|
||||
"""Convert Digg rank (lower is better, top 50 are notable) into a
|
||||
positive engagement-style signal in [0, 50]. Anything off the top-50
|
||||
leaderboard contributes 0.
|
||||
"""
|
||||
if rank is None:
|
||||
return 0.0
|
||||
try:
|
||||
r = int(rank)
|
||||
except (TypeError, ValueError):
|
||||
return 0.0
|
||||
if r < 1 or r > 50:
|
||||
return 0.0
|
||||
return float(51 - r)
|
||||
|
||||
|
||||
def parse_digg_response(
|
||||
response: Dict[str, Any],
|
||||
query: str = "",
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Parse a digg search envelope into normalized item dicts.
|
||||
|
||||
Args:
|
||||
response: payload from ``search_digg``.
|
||||
query: original search query, used for token-overlap relevance.
|
||||
|
||||
Returns:
|
||||
List of dicts ready for ``normalize._normalize_digg``.
|
||||
"""
|
||||
raw = response.get("results") if isinstance(response, dict) else None
|
||||
if not isinstance(raw, list):
|
||||
return []
|
||||
|
||||
items: List[Dict[str, Any]] = []
|
||||
for i, cluster in enumerate(raw):
|
||||
if not isinstance(cluster, dict):
|
||||
continue
|
||||
cluster_url_id = cluster.get("clusterUrlId")
|
||||
if not cluster_url_id:
|
||||
continue
|
||||
|
||||
title = str(cluster.get("title") or "").strip()
|
||||
tldr = str(cluster.get("tldr") or "").strip()
|
||||
rank = cluster.get("rank")
|
||||
post_count = cluster.get("postCount") or 0
|
||||
unique_authors = cluster.get("uniqueAuthors") or 0
|
||||
first_post_age = cluster.get("firstPostAge")
|
||||
date_str = _parse_first_post_age(first_post_age)
|
||||
if date_str is None and first_post_age:
|
||||
# firstPostAge present but outside 30d -> drop; last30days contract.
|
||||
continue
|
||||
|
||||
rank_decay = max(0.3, 1.0 - (i * 0.02))
|
||||
if query:
|
||||
content_score = token_overlap_relevance(query, f"{title} {tldr}".strip())
|
||||
else:
|
||||
content_score = 0.5
|
||||
rank_boost = min(0.2, _rank_score(rank) / 250.0)
|
||||
relevance = min(1.0, 0.55 * rank_decay + 0.35 * content_score + rank_boost)
|
||||
|
||||
items.append(
|
||||
{
|
||||
"id": str(cluster_url_id),
|
||||
"title": title or f"Digg cluster {i + 1}",
|
||||
"url": _build_url(str(cluster_url_id)),
|
||||
"tldr": tldr,
|
||||
"author": "",
|
||||
"date": date_str,
|
||||
"engagement": {
|
||||
"postCount": int(post_count) if isinstance(post_count, (int, float)) else 0,
|
||||
"uniqueAuthors": int(unique_authors) if isinstance(unique_authors, (int, float)) else 0,
|
||||
"rank": int(rank) if isinstance(rank, (int, float)) else None,
|
||||
"rank_score": _rank_score(rank),
|
||||
},
|
||||
"first_post_age": first_post_age,
|
||||
"posts": [],
|
||||
"relevance": round(relevance, 2),
|
||||
"why_relevant": (
|
||||
f"Digg cluster (rank {rank}, {post_count} posts, {unique_authors} authors)"
|
||||
if rank is not None
|
||||
else f"Digg cluster ({post_count} posts, {unique_authors} authors)"
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
return items
|
||||
|
||||
|
||||
def _parse_post(raw_post: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
||||
"""Reduce a digg post payload into the small dict render uses.
|
||||
|
||||
We deliberately keep this minimal: an inline quote needs the author
|
||||
handle, the body, the post type, and the X URL.
|
||||
"""
|
||||
if not isinstance(raw_post, dict):
|
||||
return None
|
||||
body = str(raw_post.get("body") or "").strip()
|
||||
if not body:
|
||||
return None
|
||||
author = raw_post.get("author") or {}
|
||||
if not isinstance(author, dict):
|
||||
author = {}
|
||||
username = str(author.get("username") or "").strip()
|
||||
if not username:
|
||||
return None
|
||||
x_url = str(raw_post.get("xUrl") or "").strip()
|
||||
if not x_url:
|
||||
return None
|
||||
return {
|
||||
"username": username,
|
||||
"display_name": str(author.get("display_name") or "").strip() or username,
|
||||
"category": str(author.get("category") or "").strip(),
|
||||
"rank": author.get("rank"),
|
||||
"body": body,
|
||||
"post_type": str(raw_post.get("post_type") or "tweet").strip(),
|
||||
"x_url": x_url,
|
||||
"posted_at": raw_post.get("posted_at"),
|
||||
}
|
||||
|
||||
|
||||
def fetch_top_posts(cluster_url_id: str, posts_per: int = POSTS_PER_CLUSTER) -> List[Dict[str, Any]]:
|
||||
"""Fetch top-ranked X posts attached to a cluster.
|
||||
|
||||
Returns an empty list on any failure (timeout, missing cluster, JSON
|
||||
error). Never raises.
|
||||
"""
|
||||
if posts_per <= 0:
|
||||
return []
|
||||
cmd = _build_posts_args(cluster_url_id, posts_per)
|
||||
response = _run_cli(cmd, timeout=POSTS_TIMEOUT)
|
||||
raw = response.get("results") or []
|
||||
out: List[Dict[str, Any]] = []
|
||||
for entry in raw:
|
||||
post = _parse_post(entry)
|
||||
if post is not None:
|
||||
out.append(post)
|
||||
return out
|
||||
|
||||
|
||||
def enrich_with_top_posts(
|
||||
items: List[Dict[str, Any]],
|
||||
top_k: int = 3,
|
||||
posts_per: int = POSTS_PER_CLUSTER,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Attach top X posts to the first ``top_k`` clusters by Digg rank order.
|
||||
|
||||
Mutates and returns the same list. Items that already have posts, or
|
||||
whose ``postCount`` is 0, are skipped.
|
||||
"""
|
||||
if top_k <= 0 or posts_per <= 0:
|
||||
return items
|
||||
enriched = 0
|
||||
for item in items:
|
||||
if enriched >= top_k:
|
||||
break
|
||||
if item.get("posts"):
|
||||
continue
|
||||
engagement = item.get("engagement") or {}
|
||||
if not engagement.get("postCount"):
|
||||
continue
|
||||
cluster_url_id = item.get("id")
|
||||
if not cluster_url_id:
|
||||
continue
|
||||
posts = fetch_top_posts(str(cluster_url_id), posts_per=posts_per)
|
||||
item["posts"] = posts
|
||||
enriched += 1
|
||||
if enriched:
|
||||
_log(f"enriched {enriched} clusters with X posts")
|
||||
return items
|
||||
|
||||
|
||||
def enrich_source_items(items: list, top_k: int = 3, posts_per: int = POSTS_PER_CLUSTER) -> list:
|
||||
"""Attach top X posts to the first ``top_k`` SourceItems that survived dedupe.
|
||||
|
||||
Reads ``metadata['clusterUrlId']`` and writes ``metadata['posts']`` in
|
||||
place. Skips items that already carry a non-empty ``metadata['posts']``,
|
||||
items whose engagement ``postCount`` is 0, and items whose source is not
|
||||
'digg'. Designed to run from `_finalize_items_by_source` so enrichment
|
||||
is spent on the items the brief actually shows.
|
||||
"""
|
||||
if top_k <= 0 or posts_per <= 0:
|
||||
return items
|
||||
enriched = 0
|
||||
for item in items:
|
||||
if enriched >= top_k:
|
||||
break
|
||||
if getattr(item, "source", None) != "digg":
|
||||
continue
|
||||
metadata = getattr(item, "metadata", None) or {}
|
||||
if metadata.get("posts"):
|
||||
continue
|
||||
engagement = getattr(item, "engagement", None) or {}
|
||||
if not engagement.get("postCount"):
|
||||
continue
|
||||
cluster_url_id = metadata.get("clusterUrlId") or item.item_id
|
||||
if not cluster_url_id:
|
||||
continue
|
||||
posts = fetch_top_posts(str(cluster_url_id), posts_per=posts_per)
|
||||
if posts:
|
||||
metadata["posts"] = posts
|
||||
enriched += 1
|
||||
if enriched:
|
||||
_log(f"post-dedupe enriched {enriched} clusters with X posts")
|
||||
return items
|
||||
@@ -372,14 +372,6 @@ def config_exists() -> bool:
|
||||
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:
|
||||
"""Determine which Reddit backend to use.
|
||||
|
||||
@@ -116,6 +116,8 @@ def weighted_rrf(
|
||||
"""Fuse ranked lists into a single candidate pool."""
|
||||
subqueries = {subquery.label: subquery for subquery in plan.subqueries}
|
||||
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():
|
||||
subquery = subqueries[label]
|
||||
@@ -154,6 +156,7 @@ def weighted_rrf(
|
||||
]
|
||||
},
|
||||
)
|
||||
seen_source_items[key] = {(item.source, item.item_id)}
|
||||
continue
|
||||
|
||||
candidate = candidates[key]
|
||||
@@ -179,7 +182,9 @@ def weighted_rrf(
|
||||
candidate.subquery_labels.append(label)
|
||||
if item.source not in candidate.sources:
|
||||
candidate.sources.append(item.source)
|
||||
if not any(existing.source == item.source and existing.item_id == item.item_id for existing in candidate.source_items):
|
||||
source_item_key = (item.source, item.item_id)
|
||||
if source_item_key not in seen_source_items[key]:
|
||||
seen_source_items[key].add(source_item_key)
|
||||
candidate.source_items.append(item)
|
||||
candidate.metadata.setdefault("provenance", []).append(
|
||||
{
|
||||
@@ -0,0 +1,674 @@
|
||||
"""HTML rendering for shareable last30days reports."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import html
|
||||
import re
|
||||
from datetime import date
|
||||
|
||||
from . import render, schema
|
||||
|
||||
|
||||
PROSE_LABELS = [
|
||||
("What I learned:", "What I learned"),
|
||||
("KEY PATTERNS from the research:", "Key patterns from the research"),
|
||||
]
|
||||
|
||||
INVITATION_PATTERN = re.compile(r"^---\nI'm now an expert.*?Just ask\.$", re.MULTILINE | re.DOTALL)
|
||||
EVIDENCE_BLOCK_PATTERN = re.compile(r"<!-- EVIDENCE FOR SYNTHESIS.*?<!-- END EVIDENCE FOR SYNTHESIS -->", re.DOTALL)
|
||||
PASS_THROUGH_FOOTER_PATTERN = re.compile(r"<!-- PASS-THROUGH FOOTER.*?-->\n(.*?)<!-- END PASS-THROUGH FOOTER -->", re.DOTALL)
|
||||
CANONICAL_BOUNDARY_PATTERN = re.compile(r"\n?---\n# END OF last30days CANONICAL OUTPUT.*$", re.DOTALL)
|
||||
# render_for_html emits metadata as <!-- META: ... --> so it survives the
|
||||
# markdown converter (which escapes raw HTML inside paragraphs). Promoted to
|
||||
# a styled <div class="meta"> after conversion.
|
||||
META_MARKER_PATTERN = re.compile(r"<!--\s*META:\s*(.*?)\s*-->")
|
||||
|
||||
CSS = """
|
||||
:root {
|
||||
--bg: #0e0e10;
|
||||
--bg-elev: #18181b;
|
||||
--fg: #fafafa;
|
||||
--fg-muted: #a1a1aa;
|
||||
--fg-subtle: #71717a;
|
||||
--accent: #a855f7;
|
||||
--accent-soft: #c4b5fd;
|
||||
--border: #27272a;
|
||||
--code-bg: #1a1a1d;
|
||||
--max-w: 720px;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
:root {
|
||||
--bg: #ffffff;
|
||||
--bg-elev: #fafafa;
|
||||
--fg: #18181b;
|
||||
--fg-muted: #52525b;
|
||||
--fg-subtle: #71717a;
|
||||
--accent: #7c3aed;
|
||||
--accent-soft: #6d28d9;
|
||||
--border: #e4e4e7;
|
||||
--code-bg: #f4f4f5;
|
||||
}
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
html, body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: var(--bg);
|
||||
color: var(--fg);
|
||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, system-ui, sans-serif;
|
||||
font-size: 17px;
|
||||
line-height: 1.65;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
text-rendering: optimizeLegibility;
|
||||
}
|
||||
|
||||
body {
|
||||
max-width: var(--max-w);
|
||||
margin: 0 auto;
|
||||
padding: 4rem 1.5rem 6rem;
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 0.4rem 0.85rem;
|
||||
margin-bottom: 2.5rem;
|
||||
background: var(--bg-elev);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 999px;
|
||||
font-family: 'JetBrains Mono', ui-monospace, 'SF Mono', 'Cascadia Code', Menlo, Consolas, monospace;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--fg-muted);
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.badge .accent { color: var(--accent); }
|
||||
|
||||
.meta {
|
||||
margin: -1.5rem 0 2.5rem;
|
||||
color: var(--fg-subtle);
|
||||
font-family: 'JetBrains Mono', ui-monospace, 'SF Mono', 'Cascadia Code', Menlo, Consolas, monospace;
|
||||
font-size: 13px;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0 0 1.5rem;
|
||||
color: var(--fg);
|
||||
font-size: 30px;
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
h2,
|
||||
.prose-label {
|
||||
margin: 2.75rem 0 1.25rem;
|
||||
color: var(--fg);
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
line-height: 1.35;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.badge + h2,
|
||||
.badge + .prose-label { margin-top: 0.5rem; }
|
||||
|
||||
h3 {
|
||||
margin: 2rem 0 0.85rem;
|
||||
color: var(--fg);
|
||||
font-size: 17px;
|
||||
font-weight: 600;
|
||||
line-height: 1.4;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0 0 1.4rem;
|
||||
color: var(--fg-muted);
|
||||
}
|
||||
|
||||
p strong,
|
||||
li strong,
|
||||
td strong {
|
||||
color: var(--fg);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
border-bottom: 1px solid transparent;
|
||||
transition: border-color 0.15s ease;
|
||||
}
|
||||
|
||||
a:hover { border-bottom-color: var(--accent); }
|
||||
|
||||
ul,
|
||||
ol {
|
||||
margin: 0 0 1.6rem;
|
||||
padding-left: 1.5rem;
|
||||
color: var(--fg-muted);
|
||||
}
|
||||
|
||||
li {
|
||||
margin: 0.6rem 0;
|
||||
padding-left: 0.4rem;
|
||||
}
|
||||
|
||||
li::marker {
|
||||
color: var(--accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
blockquote {
|
||||
margin: 1.5rem 0;
|
||||
padding-left: 1rem;
|
||||
border-left: 3px solid var(--accent);
|
||||
color: var(--fg-muted);
|
||||
}
|
||||
|
||||
hr {
|
||||
margin: 2.5rem 0;
|
||||
border: 0;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: 'JetBrains Mono', ui-monospace, 'SF Mono', 'Cascadia Code', Menlo, Consolas, monospace;
|
||||
font-size: 0.92em;
|
||||
background: var(--code-bg);
|
||||
padding: 0.15rem 0.4rem;
|
||||
border-radius: 4px;
|
||||
color: var(--accent-soft);
|
||||
}
|
||||
|
||||
pre {
|
||||
margin: 1.4rem 0;
|
||||
background: var(--code-bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 1rem 1.25rem;
|
||||
overflow-x: auto;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
pre code {
|
||||
background: none;
|
||||
padding: 0;
|
||||
color: var(--fg);
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin: 1.5rem 0;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
th,
|
||||
td {
|
||||
text-align: left;
|
||||
padding: 0.75rem 1rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
th {
|
||||
color: var(--fg-muted);
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
letter-spacing: 0;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
td { color: var(--fg-muted); }
|
||||
td:first-child { color: var(--fg); font-weight: 500; }
|
||||
|
||||
.engine-footer {
|
||||
margin: 3rem 0 2.5rem;
|
||||
padding: 1.25rem 1.5rem;
|
||||
background: var(--bg-elev);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
color: var(--fg-muted);
|
||||
}
|
||||
|
||||
.engine-footer pre {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
font-family: 'JetBrains Mono', ui-monospace, 'SF Mono', 'Cascadia Code', Menlo, Consolas, monospace;
|
||||
font-size: 13.5px;
|
||||
font-weight: 400;
|
||||
line-height: 1.75;
|
||||
color: inherit;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.colophon {
|
||||
margin-top: 4rem;
|
||||
padding-top: 2rem;
|
||||
border-top: 1px solid var(--border);
|
||||
color: var(--fg-subtle);
|
||||
font-size: 13px;
|
||||
font-family: 'JetBrains Mono', ui-monospace, 'SF Mono', 'Cascadia Code', Menlo, Consolas, monospace;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.colophon .rerun {
|
||||
display: inline-block;
|
||||
padding: 0.15rem 0.5rem;
|
||||
margin-left: 0.25rem;
|
||||
background: var(--code-bg);
|
||||
border-radius: 4px;
|
||||
color: var(--accent-soft);
|
||||
font-size: 0.95em;
|
||||
}
|
||||
|
||||
@media print {
|
||||
:root {
|
||||
--bg: #ffffff;
|
||||
--bg-elev: #f5f5f5;
|
||||
--fg: #000000;
|
||||
--fg-muted: #1f2937;
|
||||
--fg-subtle: #4b5563;
|
||||
--accent: #6d28d9;
|
||||
--accent-soft: #6d28d9;
|
||||
--border: #d4d4d8;
|
||||
--code-bg: #f4f4f5;
|
||||
}
|
||||
|
||||
@page { size: A4; margin: 1.5cm 2cm; }
|
||||
|
||||
body {
|
||||
max-width: none;
|
||||
padding: 0;
|
||||
font-size: 11pt;
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
border-bottom: 0;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
a[href]::after {
|
||||
content: " (" attr(href) ")";
|
||||
font-size: 0.85em;
|
||||
color: var(--fg-subtle);
|
||||
}
|
||||
|
||||
.engine-footer { page-break-inside: avoid; }
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
body {
|
||||
padding: 2.5rem 1.25rem 4rem;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
h1 { font-size: 25px; }
|
||||
.badge { font-size: 12px; }
|
||||
th, td { padding: 0.65rem 0.5rem; }
|
||||
}
|
||||
""".strip()
|
||||
|
||||
HTML_TEMPLATE = """<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>last30days · __TITLE__</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
__CSS__
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
__BODY__
|
||||
__COLOPHON__
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
|
||||
def render_html(
|
||||
report: schema.Report,
|
||||
*,
|
||||
fun_level: str = "medium",
|
||||
save_path: str | None = None,
|
||||
synthesis_md: str | None = None,
|
||||
) -> str:
|
||||
_ = fun_level
|
||||
md = render.render_for_html(report, synthesis_md=synthesis_md, save_path=save_path)
|
||||
md = _strip_evidence_block(md)
|
||||
md = _strip_invitation(md)
|
||||
md = _strip_canonical_boundary(md)
|
||||
md = _promote_prose_labels(md)
|
||||
body = _markdown_to_html(md)
|
||||
body = _wrap_engine_footer(body)
|
||||
body = _promote_meta_marker(body)
|
||||
colophon = _build_colophon(report)
|
||||
return _wrap_in_template(body, colophon, report.topic)
|
||||
|
||||
|
||||
def render_html_comparison(
|
||||
entity_reports: list[tuple[str, schema.Report]],
|
||||
*,
|
||||
fun_level: str = "medium",
|
||||
save_path: str | None = None,
|
||||
synthesis_md: str | None = None,
|
||||
) -> str:
|
||||
_ = fun_level
|
||||
md = render.render_for_html_comparison(
|
||||
entity_reports, synthesis_md=synthesis_md, save_path=save_path,
|
||||
)
|
||||
md = _strip_evidence_block(md)
|
||||
md = _strip_invitation(md)
|
||||
md = _strip_canonical_boundary(md)
|
||||
md = _promote_prose_labels(md)
|
||||
body = _markdown_to_html(md)
|
||||
body = _wrap_engine_footer(body)
|
||||
body = _promote_meta_marker(body)
|
||||
topic = " vs ".join(label for label, _ in entity_reports)
|
||||
colophon = _build_colophon(entity_reports[0][1], topic=topic)
|
||||
return _wrap_in_template(body, colophon, topic)
|
||||
|
||||
|
||||
def _strip_evidence_block(md: str) -> str:
|
||||
return EVIDENCE_BLOCK_PATTERN.sub("", md)
|
||||
|
||||
|
||||
def _strip_invitation(md: str) -> str:
|
||||
return INVITATION_PATTERN.sub("", md)
|
||||
|
||||
|
||||
def _strip_canonical_boundary(md: str) -> str:
|
||||
return CANONICAL_BOUNDARY_PATTERN.sub("", md)
|
||||
|
||||
|
||||
def _promote_prose_labels(md: str) -> str:
|
||||
for source, normalized in PROSE_LABELS:
|
||||
md = re.sub(
|
||||
rf"^{re.escape(source)}$",
|
||||
f"## {normalized}",
|
||||
md,
|
||||
flags=re.MULTILINE,
|
||||
)
|
||||
return md
|
||||
|
||||
|
||||
def _markdown_to_html(md: str) -> str:
|
||||
md, footers = _protect_engine_footers(md)
|
||||
global _ENGINE_FOOTER_STORE
|
||||
_ENGINE_FOOTER_STORE = footers
|
||||
# Strip HTML comments EXCEPT preserved markers used for post-processing
|
||||
# (META is promoted to <div class="meta"> after markdown conversion).
|
||||
md = re.sub(r"<!--(?!\s*META:).*?-->", "", md, flags=re.DOTALL)
|
||||
lines = md.splitlines()
|
||||
out: list[str] = []
|
||||
paragraph: list[str] = []
|
||||
list_type: str | None = None
|
||||
in_code = False
|
||||
code_lines: list[str] = []
|
||||
index = 0
|
||||
|
||||
def flush_paragraph() -> None:
|
||||
nonlocal paragraph
|
||||
if paragraph:
|
||||
text = " ".join(part.strip() for part in paragraph).strip()
|
||||
if text:
|
||||
out.append(f"<p>{_inline_markdown(text)}</p>")
|
||||
paragraph = []
|
||||
|
||||
def close_list() -> None:
|
||||
nonlocal list_type
|
||||
if list_type:
|
||||
out.append(f"</{list_type}>")
|
||||
list_type = None
|
||||
|
||||
while index < len(lines):
|
||||
line = lines[index]
|
||||
stripped = line.strip()
|
||||
|
||||
if in_code:
|
||||
if stripped.startswith("```"):
|
||||
out.append(f"<pre><code>{html.escape(chr(10).join(code_lines))}</code></pre>")
|
||||
code_lines = []
|
||||
in_code = False
|
||||
else:
|
||||
code_lines.append(line)
|
||||
index += 1
|
||||
continue
|
||||
|
||||
if stripped.startswith("```"):
|
||||
flush_paragraph()
|
||||
close_list()
|
||||
in_code = True
|
||||
code_lines = []
|
||||
index += 1
|
||||
continue
|
||||
|
||||
if stripped in footers:
|
||||
flush_paragraph()
|
||||
close_list()
|
||||
out.append(stripped)
|
||||
index += 1
|
||||
continue
|
||||
|
||||
if not stripped:
|
||||
flush_paragraph()
|
||||
close_list()
|
||||
index += 1
|
||||
continue
|
||||
|
||||
if stripped == "---":
|
||||
flush_paragraph()
|
||||
close_list()
|
||||
out.append("<hr>")
|
||||
index += 1
|
||||
continue
|
||||
|
||||
if index + 1 < len(lines) and _is_table_row(stripped) and _is_table_separator(lines[index + 1].strip()):
|
||||
flush_paragraph()
|
||||
close_list()
|
||||
table_lines = [stripped]
|
||||
index += 2
|
||||
while index < len(lines) and _is_table_row(lines[index].strip()):
|
||||
table_lines.append(lines[index].strip())
|
||||
index += 1
|
||||
out.append(_render_table(table_lines))
|
||||
continue
|
||||
|
||||
heading = re.match(r"^(#{1,4})\s+(.+)$", stripped)
|
||||
if heading:
|
||||
flush_paragraph()
|
||||
close_list()
|
||||
level = min(len(heading.group(1)), 3)
|
||||
out.append(f"<h{level}>{_inline_markdown(heading.group(2))}</h{level}>")
|
||||
index += 1
|
||||
continue
|
||||
|
||||
if stripped.startswith(">"):
|
||||
flush_paragraph()
|
||||
close_list()
|
||||
quote_lines = []
|
||||
while index < len(lines) and lines[index].strip().startswith(">"):
|
||||
quote_lines.append(lines[index].strip().lstrip(">").strip())
|
||||
index += 1
|
||||
out.append(f"<blockquote>{_inline_markdown(' '.join(quote_lines))}</blockquote>")
|
||||
continue
|
||||
|
||||
unordered = re.match(r"^[-*]\s+(.+)$", stripped)
|
||||
ordered = re.match(r"^\d+[.)]\s+(.+)$", stripped)
|
||||
if unordered or ordered:
|
||||
flush_paragraph()
|
||||
next_type = "ul" if unordered else "ol"
|
||||
if list_type != next_type:
|
||||
close_list()
|
||||
out.append(f"<{next_type}>")
|
||||
list_type = next_type
|
||||
item = unordered.group(1) if unordered else ordered.group(1)
|
||||
out.append(f"<li>{_inline_markdown(item)}</li>")
|
||||
index += 1
|
||||
continue
|
||||
|
||||
if stripped.startswith("🌐 last30days"):
|
||||
flush_paragraph()
|
||||
close_list()
|
||||
badge_text = _inline_markdown(stripped.removeprefix("🌐").strip())
|
||||
out.append(f'<div class="badge"><span class="accent">🌐</span> {badge_text}</div>')
|
||||
index += 1
|
||||
continue
|
||||
|
||||
paragraph.append(line)
|
||||
index += 1
|
||||
|
||||
if in_code:
|
||||
out.append(f"<pre><code>{html.escape(chr(10).join(code_lines))}</code></pre>")
|
||||
flush_paragraph()
|
||||
close_list()
|
||||
return "\n".join(out).strip()
|
||||
|
||||
|
||||
def _protect_engine_footers(md: str) -> tuple[str, dict[str, str]]:
|
||||
footers: dict[str, str] = {}
|
||||
|
||||
def replace(match: re.Match[str]) -> str:
|
||||
token = f"__LAST30DAYS_ENGINE_FOOTER_{len(footers)}__"
|
||||
footers[token] = match.group(1).strip("\n")
|
||||
return f"\n{token}\n"
|
||||
|
||||
return PASS_THROUGH_FOOTER_PATTERN.sub(replace, md), footers
|
||||
|
||||
|
||||
def _wrap_engine_footer(body: str) -> str:
|
||||
def replace(match: re.Match[str]) -> str:
|
||||
footer = html.escape(_ENGINE_FOOTER_STORE.get(match.group(0), ""), quote=False)
|
||||
return f'<div class="engine-footer"><pre>{footer}</pre></div>'
|
||||
|
||||
return re.sub(
|
||||
r"__LAST30DAYS_ENGINE_FOOTER_\d+__",
|
||||
replace,
|
||||
body,
|
||||
)
|
||||
|
||||
|
||||
def _promote_meta_marker(body: str) -> str:
|
||||
"""Promote ``<!-- META: ... -->`` markers into a styled ``<div class="meta">``.
|
||||
|
||||
The marker is preserved through the comment-strip pass (see
|
||||
_markdown_to_html exemption) but the markdown converter wraps it in
|
||||
``<p>`` and HTML-escapes the angle brackets. After conversion the body
|
||||
contains shapes like:
|
||||
<p><!-- META: TEXT --></p>
|
||||
<p><!-- META: TEXT --></p> (when not escaped)
|
||||
Both collapse to ``<div class="meta">TEXT</div>``.
|
||||
"""
|
||||
def replace(match: re.Match[str]) -> str:
|
||||
text = match.group(1).strip()
|
||||
return f'<div class="meta">{text}</div>'
|
||||
|
||||
# Escaped form (most common after markdown conversion)
|
||||
body = re.sub(
|
||||
r"<p>\s*<!--\s*META:\s*(.*?)\s*-->\s*</p>",
|
||||
replace,
|
||||
body,
|
||||
)
|
||||
body = re.sub(r"<!--\s*META:\s*(.*?)\s*-->", replace, body)
|
||||
# Unescaped form (paranoid fallback)
|
||||
body = re.sub(r"<p>\s*<!--\s*META:\s*(.*?)\s*-->\s*</p>", replace, body)
|
||||
body = re.sub(r"<!--\s*META:\s*(.*?)\s*-->", replace, body)
|
||||
return body
|
||||
|
||||
|
||||
_ENGINE_FOOTER_STORE: dict[str, str] = {}
|
||||
|
||||
|
||||
def _inline_markdown(text: str) -> str:
|
||||
escaped = html.escape(text, quote=True)
|
||||
code_tokens: dict[str, str] = {}
|
||||
|
||||
def code_replace(match: re.Match[str]) -> str:
|
||||
token = f"__CODE_{len(code_tokens)}__"
|
||||
code_tokens[token] = f"<code>{match.group(1)}</code>"
|
||||
return token
|
||||
|
||||
escaped = re.sub(r"`([^`]+)`", code_replace, escaped)
|
||||
escaped = re.sub(r"\*\*([^*]+)\*\*", r"<strong>\1</strong>", escaped)
|
||||
escaped = re.sub(
|
||||
r"\[([^\]]+)\]\(([^)\s]+)\)",
|
||||
r'<a href="\2">\1</a>',
|
||||
escaped,
|
||||
)
|
||||
for token, value in code_tokens.items():
|
||||
escaped = escaped.replace(token, value)
|
||||
return escaped
|
||||
|
||||
|
||||
def _is_table_row(line: str) -> bool:
|
||||
return "|" in line and len(_split_table_cells(line)) >= 2
|
||||
|
||||
|
||||
def _is_table_separator(line: str) -> bool:
|
||||
cells = _split_table_cells(line)
|
||||
return bool(cells) and all(re.fullmatch(r":?-{3,}:?", cell.strip()) for cell in cells)
|
||||
|
||||
|
||||
def _split_table_cells(line: str) -> list[str]:
|
||||
return [cell.strip() for cell in line.strip().strip("|").split("|")]
|
||||
|
||||
|
||||
def _render_table(rows: list[str]) -> str:
|
||||
header = _split_table_cells(rows[0])
|
||||
body_rows = [_split_table_cells(row) for row in rows[1:]]
|
||||
out = ["<table>", "<thead>", "<tr>"]
|
||||
out.extend(f"<th>{_inline_markdown(cell)}</th>" for cell in header)
|
||||
out.extend(["</tr>", "</thead>", "<tbody>"])
|
||||
for row in body_rows:
|
||||
out.append("<tr>")
|
||||
out.extend(f"<td>{_inline_markdown(cell)}</td>" for cell in row)
|
||||
out.append("</tr>")
|
||||
out.extend(["</tbody>", "</table>"])
|
||||
return "\n".join(out)
|
||||
|
||||
|
||||
def _build_colophon(report: schema.Report, *, topic: str | None = None) -> str:
|
||||
display_topic = topic or report.topic
|
||||
generated = _generated_date(report)
|
||||
version = render._skill_version()
|
||||
escaped_topic = html.escape(display_topic)
|
||||
rerun = html.escape(f"/last30days {display_topic}")
|
||||
return (
|
||||
'<div class="colophon">\n'
|
||||
f" Generated {generated} by /last30days v{html.escape(version)} · topic: {escaped_topic}<br>\n"
|
||||
f' Re-run for fresh data: <span class="rerun">{rerun}</span>\n'
|
||||
"</div>"
|
||||
)
|
||||
|
||||
|
||||
def _generated_date(report: schema.Report) -> str:
|
||||
if report.generated_at:
|
||||
return report.generated_at[:10]
|
||||
return date.today().strftime("%Y-%m-%d")
|
||||
|
||||
|
||||
def _wrap_in_template(body: str, colophon: str, title: str) -> str:
|
||||
return (
|
||||
HTML_TEMPLATE
|
||||
.replace("__TITLE__", html.escape(title))
|
||||
.replace("__CSS__", CSS)
|
||||
.replace("__BODY__", body)
|
||||
.replace("__COLOPHON__", colophon)
|
||||
)
|
||||
@@ -12,11 +12,6 @@ import sys
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, List, Optional, Set
|
||||
|
||||
try:
|
||||
import requests as _requests
|
||||
except ImportError:
|
||||
_requests = None
|
||||
|
||||
from . import dates, http, log
|
||||
|
||||
SCRAPECREATORS_BASE = "https://api.scrapecreators.com"
|
||||
@@ -236,30 +231,17 @@ def _user_reels(
|
||||
"""
|
||||
_log(f"User reels: @{handle}")
|
||||
reels_url = f"{SCRAPECREATORS_BASE}/v1/instagram/user/reels"
|
||||
if not _requests:
|
||||
try:
|
||||
from urllib.parse import urlencode
|
||||
params = urlencode({"handle": handle})
|
||||
url = f"{reels_url}?{params}"
|
||||
headers = http.scrapecreators_headers(token)
|
||||
headers["User-Agent"] = http.USER_AGENT
|
||||
data = http.get(url, headers=headers, timeout=30, retries=2)
|
||||
except Exception as e:
|
||||
_log(f"User reels error (urllib) for @{handle}: {e}")
|
||||
return []
|
||||
else:
|
||||
try:
|
||||
resp = _requests.get(
|
||||
reels_url,
|
||||
params={"handle": handle},
|
||||
headers=http.scrapecreators_headers(token),
|
||||
timeout=30,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
except Exception as e:
|
||||
_log(f"User reels error for @{handle}: {e}")
|
||||
return []
|
||||
try:
|
||||
data = http.get(
|
||||
reels_url,
|
||||
params={"handle": handle},
|
||||
headers=http.scrapecreators_headers(token),
|
||||
timeout=30,
|
||||
retries=2,
|
||||
)
|
||||
except Exception as e:
|
||||
_log(f"User reels error for @{handle}: {e}")
|
||||
return []
|
||||
|
||||
raw_items = data.get("items") or data.get("reels") or data.get("data") or []
|
||||
_log(f" -> {len(raw_items)} reels from @{handle}")
|
||||
@@ -293,31 +275,17 @@ def search_instagram(
|
||||
|
||||
_log(f"Searching Instagram for '{core_topic}' (depth={depth}, count={config['results_per_page']})")
|
||||
|
||||
if not _requests:
|
||||
_log("requests library not installed, falling back to urllib")
|
||||
try:
|
||||
from urllib.parse import urlencode
|
||||
params = urlencode({"query": core_topic})
|
||||
url = f"{SCRAPECREATORS_BASE}/v2/instagram/reels/search?{params}"
|
||||
headers = http.scrapecreators_headers(token)
|
||||
headers["User-Agent"] = http.USER_AGENT
|
||||
data = http.get(url, headers=headers, timeout=30, retries=2)
|
||||
except Exception as e:
|
||||
_log(f"ScrapeCreators error (urllib): {e}")
|
||||
return {"items": [], "error": f"{type(e).__name__}: {e}"}
|
||||
else:
|
||||
try:
|
||||
resp = _requests.get(
|
||||
f"{SCRAPECREATORS_BASE}/v2/instagram/reels/search",
|
||||
params={"query": core_topic},
|
||||
headers=http.scrapecreators_headers(token),
|
||||
timeout=30,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
except Exception as e:
|
||||
_log(f"ScrapeCreators error: {e}")
|
||||
return {"items": [], "error": f"{type(e).__name__}: {e}"}
|
||||
try:
|
||||
data = http.get(
|
||||
f"{SCRAPECREATORS_BASE}/v2/instagram/reels/search",
|
||||
params={"query": core_topic},
|
||||
headers=http.scrapecreators_headers(token),
|
||||
timeout=30,
|
||||
retries=2,
|
||||
)
|
||||
except Exception as e:
|
||||
_log(f"ScrapeCreators error: {e}")
|
||||
return {"items": [], "error": f"{type(e).__name__}: {e}"}
|
||||
|
||||
# Items are in the 'reels' array (ScrapeCreators v2 response)
|
||||
raw_items = data.get("reels") or data.get("items") or data.get("data") or []
|
||||
@@ -367,7 +335,7 @@ def fetch_captions(
|
||||
config = DEPTH_CONFIG.get(depth, DEPTH_CONFIG["default"])
|
||||
max_captions = config["max_captions"]
|
||||
|
||||
if not video_items or not token or not _requests:
|
||||
if not video_items or not token:
|
||||
return {}
|
||||
|
||||
top_items = video_items[:max_captions]
|
||||
@@ -392,26 +360,24 @@ def fetch_captions(
|
||||
if not url:
|
||||
continue
|
||||
try:
|
||||
resp = _requests.get(
|
||||
data = http.get(
|
||||
f"{SCRAPECREATORS_BASE}/v2/instagram/media/transcript",
|
||||
params={"url": url},
|
||||
headers=http.scrapecreators_headers(token),
|
||||
timeout=15,
|
||||
retries=1,
|
||||
)
|
||||
if resp.status_code == 200:
|
||||
data = resp.json()
|
||||
transcripts = data.get("transcripts") or []
|
||||
if transcripts and isinstance(transcripts, list):
|
||||
# Combine all transcript segments
|
||||
transcript_text = " ".join(
|
||||
t.get("text", "") for t in transcripts
|
||||
if isinstance(t, dict) and t.get("text")
|
||||
)
|
||||
if transcript_text:
|
||||
words = transcript_text.split()
|
||||
if len(words) > CAPTION_MAX_WORDS:
|
||||
transcript_text = ' '.join(words[:CAPTION_MAX_WORDS]) + '...'
|
||||
captions[vid] = transcript_text
|
||||
transcripts = data.get("transcripts") or []
|
||||
if transcripts and isinstance(transcripts, list):
|
||||
transcript_text = " ".join(
|
||||
t.get("text", "") for t in transcripts
|
||||
if isinstance(t, dict) and t.get("text")
|
||||
)
|
||||
if transcript_text:
|
||||
words = transcript_text.split()
|
||||
if len(words) > CAPTION_MAX_WORDS:
|
||||
transcript_text = ' '.join(words[:CAPTION_MAX_WORDS]) + '...'
|
||||
captions[vid] = transcript_text
|
||||
except Exception as e:
|
||||
_log(f"Transcript fetch failed for {vid}: {e}")
|
||||
|
||||
@@ -49,6 +49,7 @@ def normalize_source_items(
|
||||
"xquik": _normalize_x,
|
||||
"pinterest": _normalize_pinterest,
|
||||
"polymarket": _normalize_polymarket,
|
||||
"digg": _normalize_digg,
|
||||
"grounding": _normalize_grounding,
|
||||
"xiaohongshu": _normalize_grounding,
|
||||
"github": _normalize_github,
|
||||
@@ -110,6 +111,19 @@ def _first_present(d: dict[str, Any], keys: tuple[str, ...], default: Any) -> An
|
||||
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:
|
||||
if not url:
|
||||
return None
|
||||
@@ -169,11 +183,7 @@ def _normalize_reddit(
|
||||
to_date: str,
|
||||
) -> schema.SourceItem:
|
||||
top_comments = item.get("top_comments") or []
|
||||
comment_text = " ".join(
|
||||
str(comment.get("excerpt") or "").strip()
|
||||
for comment in top_comments[:3]
|
||||
if isinstance(comment, dict)
|
||||
)
|
||||
comment_text = _join_comment_excerpts(top_comments, "excerpt")
|
||||
body = "\n".join(
|
||||
part
|
||||
for part in [
|
||||
@@ -338,11 +348,7 @@ def _normalize_hackernews(
|
||||
to_date: str,
|
||||
) -> schema.SourceItem:
|
||||
top_comments = item.get("top_comments") or []
|
||||
comment_text = " ".join(
|
||||
str(comment.get("text") or "").strip()
|
||||
for comment in top_comments[:3]
|
||||
if isinstance(comment, dict)
|
||||
)
|
||||
comment_text = _join_comment_excerpts(top_comments, "text")
|
||||
title = str(item.get("title") or "").strip()
|
||||
body = "\n".join(part for part in [title, str(item.get("text") or "").strip(), comment_text] if part)
|
||||
return _source_item(
|
||||
@@ -394,6 +400,53 @@ 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' 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",
|
||||
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(
|
||||
source: str,
|
||||
item: dict[str, Any],
|
||||
@@ -441,11 +494,7 @@ def _normalize_github(
|
||||
title = str(item.get("title") or "").strip()
|
||||
snippet_text = str(item.get("snippet") or "").strip()
|
||||
top_comments = item.get("metadata", {}).get("top_comments") or []
|
||||
comment_text = " ".join(
|
||||
str(comment.get("excerpt") or "").strip()
|
||||
for comment in top_comments[:3]
|
||||
if isinstance(comment, dict)
|
||||
)
|
||||
comment_text = _join_comment_excerpts(top_comments, "excerpt")
|
||||
body = "\n".join(part for part in [title, snippet_text, comment_text] if part)
|
||||
metadata = item.get("metadata") or {}
|
||||
return _source_item(
|
||||
@@ -11,11 +11,6 @@ import re
|
||||
import sys
|
||||
from typing import Any, Dict, List, Optional, Set
|
||||
|
||||
try:
|
||||
import requests as _requests
|
||||
except ImportError:
|
||||
_requests = None
|
||||
|
||||
from . import dates, http, log
|
||||
|
||||
SCRAPECREATORS_BASE = "https://api.scrapecreators.com/v1/pinterest"
|
||||
@@ -140,31 +135,17 @@ def search_pinterest(
|
||||
|
||||
_log(f"Searching Pinterest for '{core_topic}' (depth={depth}, count={config['results_per_page']})")
|
||||
|
||||
if not _requests:
|
||||
_log("requests library not installed, falling back to urllib")
|
||||
try:
|
||||
from urllib.parse import urlencode
|
||||
params = urlencode({"keyword": core_topic})
|
||||
url = f"{SCRAPECREATORS_BASE}/search?{params}"
|
||||
headers = http.scrapecreators_headers(token)
|
||||
headers["User-Agent"] = http.USER_AGENT
|
||||
data = http.get(url, headers=headers, timeout=30, retries=2)
|
||||
except Exception as e:
|
||||
_log(f"ScrapeCreators error (urllib): {e}")
|
||||
return {"items": [], "error": f"{type(e).__name__}: {e}"}
|
||||
else:
|
||||
try:
|
||||
resp = _requests.get(
|
||||
f"{SCRAPECREATORS_BASE}/search",
|
||||
params={"keyword": core_topic},
|
||||
headers=http.scrapecreators_headers(token),
|
||||
timeout=30,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
except Exception as e:
|
||||
_log(f"ScrapeCreators error: {e}")
|
||||
return {"items": [], "error": f"{type(e).__name__}: {e}"}
|
||||
try:
|
||||
data = http.get(
|
||||
f"{SCRAPECREATORS_BASE}/search",
|
||||
params={"keyword": core_topic},
|
||||
headers=http.scrapecreators_headers(token),
|
||||
timeout=30,
|
||||
retries=2,
|
||||
)
|
||||
except Exception as e:
|
||||
_log(f"ScrapeCreators error: {e}")
|
||||
return {"items": [], "error": f"{type(e).__name__}: {e}"}
|
||||
|
||||
# Extract items from response - try common SC response shapes
|
||||
raw_items = data.get("pins") or data.get("results") or data.get("data") or data.get("items") or []
|
||||
@@ -15,6 +15,7 @@ from . import (
|
||||
bluesky,
|
||||
dates,
|
||||
dedupe,
|
||||
digg,
|
||||
entity_extract,
|
||||
env,
|
||||
github,
|
||||
@@ -30,6 +31,7 @@ from . import (
|
||||
query,
|
||||
reddit,
|
||||
reddit_public,
|
||||
relevance,
|
||||
rerank,
|
||||
schema,
|
||||
signals,
|
||||
@@ -78,6 +80,7 @@ MOCK_AVAILABLE_SOURCES = [
|
||||
"github",
|
||||
"perplexity",
|
||||
"xquik",
|
||||
"digg",
|
||||
]
|
||||
|
||||
|
||||
@@ -105,6 +108,8 @@ def available_sources(config: dict[str, Any], requested_sources: list[str] | Non
|
||||
available.extend(["hackernews", "polymarket"])
|
||||
if config.get("GITHUB_TOKEN") or which("gh"):
|
||||
available.append("github")
|
||||
if which("digg-pp-cli"):
|
||||
available.append("digg")
|
||||
if env.is_bluesky_available(config):
|
||||
available.append("bluesky")
|
||||
if env.is_truthsocial_available(config):
|
||||
@@ -500,11 +505,12 @@ def _normalize_score_dedupe(
|
||||
source, raw_items, from_date, to_date,
|
||||
freshness_mode=freshness_mode,
|
||||
)
|
||||
normalized = signals.annotate_stream(normalized, ranking_query, freshness_mode)
|
||||
prepared_query = relevance.PreparedQuery(ranking_query)
|
||||
normalized = signals.annotate_stream(normalized, prepared_query, freshness_mode)
|
||||
normalized = signals.prune_low_relevance(normalized)
|
||||
normalized = dedupe.dedupe_items(normalized)
|
||||
for item in normalized:
|
||||
item.snippet = snippet.extract_best_snippet(item, ranking_query)
|
||||
item.snippet = snippet.extract_best_snippet(item, prepared_query)
|
||||
return normalized
|
||||
|
||||
|
||||
@@ -529,6 +535,12 @@ def _finalize_items_by_source(
|
||||
keywords = config.get("_polymarket_keywords") if isinstance(config, dict) else None
|
||||
if 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' quotes
|
||||
# paired with the clusters dedupe actually kept.
|
||||
digg.enrich_source_items(items, top_k=3)
|
||||
finalized[source] = items
|
||||
return finalized
|
||||
|
||||
@@ -964,6 +976,13 @@ def _retrieve_stream(
|
||||
if source == "hackernews":
|
||||
result = hackernews.search_hackernews(subquery.search_query, from_date, to_date, depth=depth)
|
||||
return hackernews.parse_hackernews_response(result, query=subquery.search_query), {}
|
||||
if source == "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":
|
||||
result = bluesky.search_bluesky(subquery.search_query, from_date, to_date, depth=depth, config=config)
|
||||
return bluesky.parse_bluesky_response(result), {}
|
||||
@@ -1056,6 +1075,45 @@ def _mock_stream_results(source: str, subquery: schema.SubQuery) -> tuple[list[d
|
||||
"why_relevant": "Brave web search",
|
||||
}
|
||||
],
|
||||
"digg": [
|
||||
{
|
||||
"id": "mock1abc",
|
||||
"title": f"Digg 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":
|
||||
return payloads.get(source, []), {
|
||||
@@ -67,6 +67,7 @@ SOURCE_CAPABILITIES = {
|
||||
"bluesky": {"discussion", "social"},
|
||||
"truthsocial": {"discussion", "social"},
|
||||
"polymarket": {"market"},
|
||||
"digg": {"discussion", "social", "link"},
|
||||
"xiaohongshu": {"video", "video_shortform", "social"},
|
||||
"github": {"discussion", "link"},
|
||||
"grounding": {"web", "reference", "link"},
|
||||
@@ -93,13 +93,6 @@ class GeminiClient(ReasoningClient):
|
||||
)
|
||||
return extract_gemini_text(payload)
|
||||
|
||||
def ground_search(self, model: str, prompt: str) -> dict[str, Any]:
|
||||
return self._generate_content(model, prompt, tools=[{"google_search": {}}])
|
||||
|
||||
def url_context_json(self, model: str, prompt: str) -> dict[str, Any]:
|
||||
return self.generate_json(model, prompt, tools=[{"url_context": {}}])
|
||||
|
||||
|
||||
class OpenAIClient(ReasoningClient):
|
||||
name = "openai"
|
||||
|
||||