Compare commits

..

44 Commits

Author SHA1 Message Date
Matt Van Horn 61d46b54ee fix(mcp): dedup PYTHONPATH and drop unsupported win32 platform
Addresses two Greptile findings on #428.

P1 - buildEnv duplicated PYTHONPATH when the parent environment already
set one. POSIX getenv returns the first match, so the user's stale
PYTHONPATH would shadow the engine's cache dir and break
`from lib import ...` with ModuleNotFoundError. buildEnv now filters
any incoming PYTHONPATH= entry before appending the cache dir. Adds
TestRunDropsPreExistingPythonPath (end-to-end through the stub
interpreter) and TestBuildEnvDropsAllPreExistingPythonPath (direct
unit on the helper) to cover the missed case.

P2 - manifest.compatibility.platforms listed "win32" even though the
release matrix doesn't ship a Windows binary; Claude Desktop would
let Windows users start an install with no matching artifact.
Removed until the Windows packaging follow-up lands. Manifest test
renamed to TestPlatformsMatchShippingMatrix and tightened: now
forbids platforms the release CI doesn't build, with a message
pointing at .github/workflows/release.yml.

go test ./... 38 passed across 4 packages.
2026-05-17 21:18:05 -07:00
Matt Van Horn e7b7e61237 test(ci): allow mcp/scripts/ alongside skills/last30days/scripts/
The plugin-contract test guards against references to the removed
root-level scripts/ directory but matched any line containing
"scripts/", which caught the new mcp/scripts/sync-engine.sh
invocation in the release workflow. Extend the allowlist to cover
mcp/scripts/ and make the structure explicit so future legitimate
subdir scripts/ paths can be added without re-discovering this rule.
2026-05-17 20:57:48 -07:00
Matt Van Horn a547a0a948 docs(readme): install path for Claude Desktop via .mcpb bundle
U6 of the Claude Desktop .mcpb bundle plan.

Adds Claude Desktop as a fifth install surface in the Install table and
a dedicated subsection with the drag-drop flow, per-platform download
filenames, Python 3.12+ host requirement, the per-install credential
store caveat (Desktop and Code don't share keys), and the deferred-
Windows note.
2026-05-17 20:55:32 -07:00
Matt Van Horn 8ea048b988 feat(ci): release workflow builds .mcpb bundles for darwin + linux
U5 of the Claude Desktop .mcpb bundle plan. Splits the existing single-
artifact release into three jobs.

- build-skill keeps the prior bash skills/last30days/scripts/build-skill.sh
  flow, now uploaded via actions/upload-artifact instead of attaching
  directly so the final release step can pull from one place.
- build-mcpb runs a matrix across darwin/arm64, darwin/amd64, and
  linux/amd64. Each entry installs printing-press@v4.8.0 (pinned to
  the version this PR was verified against; bump deliberately),
  runs mcp/scripts/sync-engine.sh, cross-compiles the Go binary with
  CGO_ENABLED=0 and the tag stamped into main.Version, and packages
  via `printing-press bundle`. Output filenames follow PP's
  DefaultBundleOutputPath convention.
- release downloads every artifact (.skill + 3 .mcpb files) and
  attaches them to the GitHub release with generated notes.
- Windows packaging is deferred: the manifest's entry_point cannot
  vary per platform within a single bundle, and Windows binaries need
  .exe naming for the OS to honor execve. A follow-up plan can ship a
  Windows-only bundle variant when there is demand.

Verified locally: `printing-press bundle --skip-build --binary` against
a host build produces a valid .mcpb (manifest.json + bin/<entry>).
YAML parse-clean on both workflows.
2026-05-17 20:54:48 -07:00
Matt Van Horn 1b23a3e900 feat(mcp): MCPB v0.3 manifest with 13 user_config slots
U4 of the Claude Desktop .mcpb bundle plan.

- mcp/manifest.json hand-authored to match PP's emitted shape (see
  ~/printing-press/library/bugbounty-goat/manifest.json for the
  canonical reference). 13 user_config slots, all sensitive=true and
  required=false so the engine's graceful degradation to web-only
  mode keeps the install non-blocking on credential entry.
- Covered API keys: OpenAI, xAI, Brave, Exa, Serper, Google,
  Gemini (and the Google_genai alias), Apify, Bluesky app password,
  Parallel, ScrapeCreators, OpenRouter. Cookie / session flows
  (Truth Social, Xiaohongshu, ChatGPT account ID, Codex auth)
  deferred per plan Scope Boundaries - they need a richer UX than
  plain user_config strings.
- internal/manifest/manifest_test.go enforces the structural
  invariants Claude Desktop install correctness depends on:
  required MCPB fields, lowercased-env-name -> user_config-key
  cross-reference both directions, sensitive=true + required=false +
  description present on every slot, and platform list coverage.
- Local smoke: `printing-press bundle --skip-build --binary <built>`
  produces last30days-pp-mcp-darwin-arm64.mcpb (4.7MB compressed,
  manifest.json + bin/last30days-pp-mcp).
2026-05-17 20:52:41 -07:00
Matt Van Horn 35f12cb9ea feat(mcp): stdio MCP server with research tool
U3 of the Claude Desktop .mcpb bundle plan.

- internal/engine/run.go invokes python3 with the cached last30days.py,
  forwards os.Environ() so MCPB user_config env-injection reaches the
  engine, sets PYTHONPATH so the lib/ imports resolve, and surfaces
  three distinct error shapes (missing interpreter with install URL,
  non-zero exit with stderr, timeout). RunOptions.PythonPath lets tests
  inject a stub without manipulating PATH.
- internal/engine/run_test.go drives a shell-script stub interpreter
  through happy path, env forwarding, PYTHONPATH, non-zero exit with
  stderr surfacing, timeout, missing python3 (empty PATH), missing
  last30days.py, empty CacheDir, and timeout-env-override parsing.
- internal/tools/research.go registers a single research tool whose
  schema mirrors /last30days <topic> (required topic, optional emit
  enum, optional save bool). Validation failures surface as MCP
  tool errors so Claude sees structured failures instead of transport
  faults; engine extract or run errors fold engine stderr into the
  message so users can diagnose without leaving Desktop.
- internal/tools/research_test.go covers requireString, emitArgument,
  boolArgument, handler-level validation routing, and formatRunError.
- cmd/last30days-pp-mcp/main.go wires NewMCPServer + tools.Register +
  ServeStdio. main.Version is ldflags-stamped at build time and
  namespaces the per-user cache. mcp-go pinned at v0.54.0.

Smoke check: ./build/last30days-pp-mcp answers tools/list with the
research tool plus full schema and read-only/open-world annotations.
go test ./... passes across engine + tools (32 cases).
2026-05-17 20:51:00 -07:00
Matt Van Horn a1afbce84c feat(mcp): embed Python engine and extract to user cache
U2 of the Claude Desktop .mcpb bundle plan.

- internal/engine/embed.go embeds the vendored Python tree at build time
  via //go:embed all:vendored. The engine package owns the embed because
  Go's directive cannot reach outside its own package directory; sync and
  gitignore paths are updated to match (internal/engine/vendored/ in
  place of mcp/vendored/).
- internal/engine/extract.go materializes the embed into
  <cache>/last30days-pp-mcp/<version>/ with a .version sentinel that
  short-circuits re-extraction. Atomic rename from a .tmp sibling means
  a partial extraction can never be mistaken for complete. Concurrent
  first-call extractions serialize behind a per-cache-dir sync.Once.
- EnsureUserCache honors a LAST30DAYS_CACHE_DIR env override for
  locked-down filesystems; the override is named in extract errors.
- internal/engine/extract_test.go covers happy path, sentinel skip,
  version bump, 10-goroutine race, empty-version rejection, unwritable
  cache parent, and the env override (7 tests, all passing).
- A tracked vendored/.gitkeep anchors the embed path so the directive
  matches even before scripts/sync-engine.sh runs.
2026-05-17 20:46:29 -07:00
Matt Van Horn 15781bfcc0 feat(mcp): scaffold Go module under mcp/ for Claude Desktop bundle
Add a new top-level mcp/ Go module that will host the Claude Desktop MCPB
server. U1 of the Claude Desktop .mcpb bundle plan: scaffold only, no
behavior yet.

- mcp/go.mod targets Go 1.22+ with mark3labs/mcp-go as the planned
  dependency (added in U3 when the server wiring lands).
- mcp/scripts/sync-engine.sh mirrors skills/last30days/scripts/ into
  mcp/vendored/ before each build, keeping the Code skill and Desktop
  bundle on the same engine source.
- Root .gitignore excludes mcp/vendored/ and mcp/build/ so the engine
  mirror and cross-compiled binaries stay local.
2026-05-17 20:42:43 -07:00
Matt Van Horn d9f606ff75 chore: add gogcli #589 zoom demo gif (#408)
PR demo embed asset for openclaw/gogcli #589 (feat: --with-zoom).
Hosted here for stable raw URL.

Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
2026-05-16 11:18:41 -07:00
Trevin Chow 4a30923892 Merge pull request #405 from tmchow/docs/readme-multi-harness-install
docs+refactor: modernize install story everywhere, delete sync.sh
2026-05-15 23:46:57 -07:00
Trevin Chow 9fb19eae63 refactor: delete sync.sh, dev workflow moves to npx skills add . -g -y + native installers
Every job sync.sh did has a better replacement:

- Per-harness skill dirs (~/.claude/skills, ~/.codex/skills, ~/.agents/skills):
  `npx skills add . -g -y` writes to every detected harness's home dir and
  uses symlinks by default. Edits propagate live — no re-deploy step.
- Hermes (~/.hermes/skills/research/last30days):
  `hermes skills install mvanhorn/last30days-skill --force` pulls from
  GitHub and handles the deploy itself. The script wrapping was redundant.
- OpenClaw variant: `clawhub install last30days-official` is what users
  already run per the README; the maintainer doesn't need a separate
  variant-deploy step in the public repo's scripts.
- Claude marketplace cache (~/.claude/plugins/cache/...): this was a
  "test against the official install path" hack we shouldn't have been
  recommending. With PR #400's resolver collapse, STEP 0 no longer
  enforces the cache as the only valid SKILL.md location. Just install
  the skill normally via `npx skills` or the marketplace.

Cleanup:

- DELETE skills/last30days/scripts/sync.sh
- tests/test_version_consistency.py — drop test_sync_cache_path_uses_skill_version
- CLAUDE.md — replace the sync.sh command + rule with `npx skills add . -g -y`
- HERMES_SETUP.md — Installation now uses `hermes skills install --force`;
  developer-alternative section shows the symlink pattern for live editing
- render.py — _skill_version docstring no longer attributes the
  ".claude-plugin absent" case to sync.sh; explains it via per-harness
  install paths in general
- .github/PULL_REQUEST_TEMPLATE.md — drop the "Ran bash scripts/sync.sh"
  checklist item

CHANGELOG and historical docs (release notes, plan files) keep their
existing sync.sh mentions as accurate history.
2026-05-15 23:42:31 -07:00
Trevin Chow d1cc29d338 docs(readme): add -g (global) flag to every npx skills example
`npx skills add` defaults to project-local install (`./.skills/`,
committed with the repo). For a research-the-world skill like this one,
that's almost never what users want — they want it available across all
projects, not scoped to whichever directory they happened to run the
install from.

Adding `-g` (global) to every npx skills example in the README:
- Top-of-file install snippet
- Install table row
- Claude Code subsection's "alternative via npx skills" example
- Codex/Cursor/etc. subsection's default, per-harness, update, list,
  and remove commands

Brief one-liner explains what `-g` does and notes that dropping it
gives a project-local install for users who want team consistency on
a specific codebase.
2026-05-15 23:20:33 -07:00
Trevin Chow ded52062e6 docs(readme): drop Gemini CLI native-extension install path
The native `gemini extensions install` path was a workaround for the
v0.9.0 installer bug (still unresolved per upstream issue #11452).
Now that `npx skills add -a gemini-cli` covers Gemini cleanly with the
same install/update story as every other supported harness, the native
path is just one more confusing option to maintain. Users on Gemini get
the same recommendation everyone else does.

Removes the dedicated "Gemini CLI (native extension)" subsection and
the separate table row. Gemini CLI is now surfaced once, in the npx
skills section, alongside Codex, Cursor, Copilot, and the rest.
2026-05-15 23:03:51 -07:00
Trevin Chow 164d7ae6ed docs(readme): surface gemini-cli (and copilot, windsurf, 50+ others) in npx skills coverage
npx skills supports 50+ harnesses via the -a flag, including gemini-cli,
github-copilot, windsurf, cline, continue, roo, aider-desk, opencode,
goose, and more — not just the few I'd listed initially. Updating to
reflect that breadth.

- Top-of-file snippet now reads "Codex, Cursor, Copilot, Gemini CLI, or
  any of 50+ Agent Skills hosts" (was: "Codex, Cursor, Copilot, or any
  Agent Skills host" + Gemini listed separately in the table footer).
- Install table: same expansion; Gemini CLI native-extension row relabeled
  to clarify it's the native path (not the only Gemini option).
- npx skills subsection: lists the most common harness flags and links
  to the upstream vercel-labs/skills repo for the full list.
- Gemini CLI native subsection: now leads with "the npx skills path
  above is simpler" and frames the native install as the alternative
  for users with an existing Gemini extensions workflow or who hit
  the v0.9.0 installer bug.
2026-05-15 23:02:57 -07:00
Trevin Chow f1ce7533e6 docs(readme): recommend Claude Code plugin, add npx skills install for Codex/Cursor/Copilot
The skill is now installable across every major agent harness after the
SKILL.md path-resolver work landed in PR #400 + #404. README didn't yet
reflect that — the install table only listed Claude Code, OpenClaw, and
Gemini CLI, and the top-of-file install snippets featured Hermes (an
internal dev workflow, not a public install method).

Restructured the install section:

- Top-of-file snippets: just Claude Code (recommended, auto-updates) and
  the universal `npx skills add` one-liner. Dropped Hermes from the
  prominent spot (internal-only); pointed everything else to the Install
  section below.
- Install table: added a third column for update commands, since every
  harness now has a distinct update path worth surfacing. Added the
  `npx skills` row covering Codex/Cursor/Copilot/any Agent Skills host.
- Claude Code subsection: explains why it's recommended (marketplace
  handles versioned cache + auto-refresh) and notes that the agent-skills
  install also works on Claude Code if preferred (`-a claude-code`).
- New "Codex, Cursor, Copilot, and other Agent Skills hosts" subsection:
  shows the default install, per-harness `-a` targeting, and the update
  commands (`npx skills update last30days` for one skill, bare
  `npx skills update` for all).
- Manual (developer) subsection: switched from a clone-into-skills-dir
  recipe to a clone + symlink recipe. Symlink keeps the install in sync
  with the working tree as you edit, no re-copy on each change.

No code changes. No version bump (docs-only).
2026-05-15 22:58:54 -07:00
Trevin Chow 0b939bf703 Merge pull request #404 from tmchow/fix/json-plan-shell-quoting
fix(skill): write --plan / --competitors-plan to tmpfile (closes #403)
2026-05-15 22:52:49 -07:00
Trevin Chow 9f95efb215 fix(skill): use portable trailing-XXXXXX mktemp form for plan tmpfiles
Greptile's review flagged mktemp -t as non-portable between BSD and GNU.
The suggested replacement (mktemp "$TMPDIR/...XXXXXX.json") is correct
about dropping -t but still puts X's in the middle of the template name
(XXXXXX.json), which BSD mktemp does not substitute — only X's at the
end of the basename are replaced on BSD. Verified on macOS:

  mktemp "$TMPDIR/last30days-test.XXXXXX.json"
  → /var/folders/.../last30days-test.XXXXXX.json  (X's left literal)

The fully portable form uses trailing X's and drops the .json suffix
(engine reads by path, not extension):

  mktemp "$TMPDIR/last30days-test.XXXXXX"
  → /var/folders/.../last30days-test.DXAHzR     (X's substituted)

Verified on bash and zsh, BSD/macOS. GNU/Linux is already fine since
GNU substitutes X's wherever they appear in the basename.

Applied to both --competitors-plan (comparison-mode block) and --plan
(Step 1 block) tmpfile writes.
2026-05-15 22:50:56 -07:00
Trevin Chow ff54c07a3b fix(skill): write --plan / --competitors-plan to tmpfile, bump 3.2.2 -> 3.2.3
Closes #403.

The SKILL.md templates instructed the model to invoke the engine with
inline single-quoted JSON: `--plan '$JSON'` and `--competitors-plan '{...}'`.
When any resolved field value contained an apostrophe (common in `context`
strings like "McDonald's", "people's choice", or contracted forms like
"don't", "won't"), the inner `'` closed the outer single-quote and broke
shell parsing before the engine was even invoked.

Observed during PR #400 testing: a Codex run hit the trap and self-healed
by re-encoding, wasting one engine invocation and ~30s of latency.

Fix: switch both templates to the heredoc + tmpfile pattern. The engine's
`parse_plan()` and `parse_competitors_plan()` already check
`os.path.isfile(plan_str)` and read from disk — only the SKILL.md prose
needed to change.

The quoted heredoc marker (<<'PLAN_EOF') is load-bearing: it suppresses
shell interpolation so apostrophes, $, backticks, etc. pass through verbatim.
A trap on EXIT cleans up the tmpfile after the engine call returns.

LAW 7's "MUST contain --plan" self-check guidance and Step 1's invocation
example both updated to reference the file form. Comparison-mode invocation
block updated the same way for --competitors-plan.

Version bump 3.2.2 -> 3.2.3 because this is a behavior change users
running comparison-mode queries will notice (no more "shell quoting error,
retrying" sequences on apostrophe-containing context strings).
2026-05-15 22:43:02 -07:00
Trevin Chow e276c30477 Merge pull request #400 from tmchow/refactor/skill-md-relative-path-resolver
refactor(skill): SKILL.md-relative path resolver, drop Codex native plugin
2026-05-15 22:36:53 -07:00
Trevin Chow 2f277dfc66 fix(skill): address greptile P1+P2 review feedback on PR #400
Two real bugs flagged in the automated review of PR #400; both small.

1. render.py::_skill_version manifest with no "version" key

   `json.loads(manifest.read_text()).get("version", "?")` returned "?"
   immediately on a valid JSON manifest that lacked the "version" key,
   never falling through to the SKILL.md frontmatter fallback. Contradicted
   the docstring's "Returns '?' only if both sources are missing" contract.
   Same shape if version is present but empty string ("" produces the
   broken badge `🌐 last30days v · synced ...`).

   Fix: pull the version out of the parsed dict, then `continue` to the
   next ancestor if it's None or empty. Falls through to the SKILL.md
   walk only after exhausting every ancestor.

2. SKILL.md STEP 0 re-read target hardcoded to nested cache layout

   STEP 0 told the model to re-read from
   `$CLAUDE_CACHE_LATEST/skills/last30days/SKILL.md` — the new nested
   layout. But Step 1's resolver explicitly handles both shapes
   (nested `{cache}/{version}/skills/last30days/` and flat
   `{cache}/{version}/`), noting "Both shapes ship in the wild." On an
   install where the highest-versioned cache happens to be the older flat
   shape, STEP 0's re-read target wouldn't exist; the model would silently
   stay on the stale marketplaces/ copy STEP 0 was supposed to move it
   away from — the exact failure mode this guard was added to prevent.

   Fix: extend the STEP 0 bash to resolve $CLAUDE_CACHE_SKILL_MD by
   probing both layouts, then have the model hop to that resolved path
   instead of constructing the path from a hardcoded suffix.

Two new tests in tests/test_skill_version.py cover the missing-key and
empty-string cases for fix 1. Fix 2 is exercised via the bash probe at
verify time (the STEP 0 prose-contract test isn't unit-testable from
Python, but the dual-layout bash is verified to resolve to the correct
SKILL.md on both shapes).

Stale finding skipped: greptile also flagged a missing try/except on the
SKILL.md read_text() call, but that was already addressed during the
ce-code-review safe_auto pass earlier in this PR — current code wraps it
in `try/except (OSError, UnicodeDecodeError)`, strictly more defensive
than the suggested fix.
2026-05-15 22:34:52 -07:00
Trevin Chow 6c2c55733c fix(skill): use find instead of ls+glob in cache resolvers (zsh compatibility)
zsh errors on globs that match nothing instead of returning the literal
pattern (bash's default), and `2>/dev/null` does not suppress the error
because it comes from the shell's glob expansion before `ls` even runs.
Under Codex (which executes the SKILL.md bash via zsh), STEP 0 and the
Step 1 / comparison-mode resolvers emitted noisy "no matches found"
errors on machines without a Claude plugin cache populated.

Replaces all three `ls -d $HOME/.claude/plugins/cache/last30days-skill/last30days/*/`
invocations with `find ... -mindepth 1 -maxdepth 1 -type d 2>/dev/null`.
find is POSIX-portable, errors silently when the base dir doesn't exist,
and never triggers shell glob errors. `sort -V | tail -1` precedence
preserved (verified: picks 3.10.0 over 3.2.1 over 3.1.0). Trailing-slash
strip removed because find doesn't append slashes.

Observed in Codex session running /last30days against PR #400 with the
Claude plugin cache deleted - bash output was:
  zsh:1: no matches found: /Users/.../last30days/*/

After fix: clean empty output, exit 0, STEP 0 correctly treats it as
"no cache present, do not hop", resolver falls through to per-harness
skill dirs as designed.
2026-05-15 22:14:37 -07:00
Trevin Chow 997708ad48 refactor(skill): apply ce-code-review fixes — bump to 3.2.2, fallback tests, comparison resolver
12 fixes from the multi-agent code review on PR #400:

Version 3.2.1 -> 3.2.2 across all manifests (SKILL.md frontmatter + body
header, pyproject.toml, .claude-plugin/{plugin,marketplace}.json, sync.sh
cache path). The PR ships observable behavior changes (STEP 0 logic flip,
resolver order change, badge fallback) that should not silently appear
under the same version number — the new fallback reads SKILL.md version
directly so the badge would otherwise be misleading.

render.py::_skill_version:
- `import re` moved to module top
- _VERSION_RE extracted as a module-level compiled pattern that accepts
  double-quoted, single-quoted, and unquoted YAML version scalars
- `break` -> `continue` on corrupt manifest, so a corrupt inner manifest
  no longer shadows a valid outer one
- Wrap SKILL.md read_text() in try/except for UnicodeDecodeError to keep
  badge emission from crashing on mis-encoded SKILL.md
- Docstring clarifies precedence; inline comment marks the fallback boundary
  between the manifest walk and the SKILL.md walk

tests/test_skill_version.py (new): 7 unit tests for the fallback paths
(manifest absent, manifest corrupt, corrupt-inner + valid-outer, both
absent, SKILL.md without version, single-quoted, unquoted).

tests/test_plugin_contract.py: tombstone test asserting .codex-plugin/
stays removed (was the only CI guard against accidental reintroduction).

SKILL.md:
- STEP 0 bash echoes CLAUDE_CACHE_LATEST so the model can see the
  resolved value when deciding whether to hop
- "Both shapes ship in the wild" comment now names the two cache layouts
  (nested {cache}/{version}/skills/last30days/ vs flat {cache}/{version}/)
- Comparison-mode bash invocation gets its own inline SKILL_ROOT resolver
  (latent gap: the contract tells the model to skip Step 1 on comparison
  queries, so SKILL_ROOT was previously unset there)

CHANGELOG.md: [Unreleased] entries for the resolver rewrite and the
breaking removal of Codex native-plugin support.

All 9 reviewer personas surfaced findings; 3 cross-reviewer corroboration
clusters were promoted (import re, "both shapes" comment, missing fallback
tests). Maintainability follow-up flagged: regex now duplicated across
render.py and 2 test files; could consolidate via shared lib/skill_meta.py
helper in a future PR.
2026-05-15 21:45:25 -07:00
Trevin Chow c913e1cf89 refactor(skill): SKILL.md-relative path resolver, drop Codex native plugin
STEP 0 (CANONICAL PATH SELF-CHECK) used to force any SKILL.md load that wasn't
under $HOME/.claude/plugins/cache/last30days-skill/last30days/{version}/ to
re-Read from there. That guard is Claude-Code-specific (defends against the
marketplaces/ stale-clone bug) and broke under non-Claude installers like
`npx skills add`, ~/.codex/skills/, and ~/.agents/skills/.

The new STEP 0 narrows the check to its actual target: fire only when the
loaded SKILL.md path contains /.claude/plugins/marketplaces/. Every other
install path is trusted. The 2026-04-22 incident workaround is preserved
without breaking other harnesses.

Step 1 SKILL_ROOT resolver collapses the Codex-first / Claude-fallback /
CWD-fallback chain into a single precedence walk: Claude plugin cache
(versioned) first, then ~/.codex/skills, ~/.agents/skills, repo checkout,
./.skills/last30days (npx skills install dir), CWD, and GEMINI_EXTENSION_DIR.

Also drops Codex native plugin support: .codex-plugin/plugin.json is deleted,
the badge VERSION jq fallback in line 108 stops looking at it, and render.py's
_skill_version no longer scans for it. Codex users install via `npx skills add`
or the per-harness skill dir going forward.

render.py::_skill_version gains a SKILL.md frontmatter fallback so the badge
no longer emits `v?` on install dirs that sync.sh populates (which don't
include .claude-plugin/plugin.json).
2026-05-15 21:44:55 -07:00
Trevin Chow 54db014c7c fix(sync): point sync.sh at this repo's plugin cache, not the private repo's (#402)
sync.sh was written against the layout of mvanhorn/last30days-skill-private
(`.../cache/last30days-skill-private/last30days-3/{version}`) and that path
was never updated when this public repo got its own copy. Running sync.sh
from here populated the BETA channel's cache (`/last30days-beta`) instead
of this repo's own `/last30days` cache, so devs working in this repo could
not test their changes via the public slash command without waiting for a
marketplace release.

Path now derives from this repo's own manifests:
- marketplace name `last30days-skill` (.claude-plugin/marketplace.json)
- plugin name      `last30days`       (.claude-plugin/plugin.json)

Drops the `last30days-3-nogem` target along with it - that's a private-repo
variant with no public equivalent.

Updates test_sync_cache_path_uses_skill_version to assert the new path
pattern and clarifies the COMMON_TARGETS comment so the next person editing
it understands which marketplace/plugin name segments come from where.
2026-05-15 21:43:23 -07:00
Trevin Chow 80a1a47eef refactor: drop requests dep, route all providers through lib/http urllib wrapper (#393)
Five provider modules (pinterest, threads, instagram, tiktok, youtube_yt)
and watchlist.py each carried a try/except `requests` import with parallel
urllib + requests branches. The urllib path already used the
stdlib-only wrapper at `lib/http.py` (retries, 429 handling, HTTPError).
This collapses every dual-branch into a single `http.get`/`http.post`
call and removes the `requests` dependency from `pyproject.toml`.

Also drops 4 transitive deps (urllib3, certifi, charset-normalizer, idna)
from the lockfile, leaving the skill stdlib-only at runtime.

Tests for tiktok comments and watchlist delivery were rewritten to mock
`lib.http` directly instead of the now-removed `requests` module.

Out of scope but flagged during review: the 13 surviving SC call sites
share a near-identical scaffold and would benefit from a
`http.scrapecreators_get(url, params, token, ...)` helper. Filed for a
follow-up PR rather than expanding scope here.
2026-05-15 08:07:43 -07:00
Matt Van Horn c845f483d6 fix(sync): bump cache target to 3.2.1 to match SKILL.md (#397)
test_sync_cache_path_uses_skill_version asserts that sync.sh's plugin
cache path includes the version from SKILL.md frontmatter. The frontmatter
moved to 3.2.1 in #371 but sync.sh still pointed at 3.2.0, leaving CI red
on every PR.

Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 08:06:33 -07:00
Matt Van Horn dc934ddb6a feat(digg): rename to 'Digg' and bump per-cluster post limits (#372)
* feat(digg): bump POSTS_PER_CLUSTER to 5 and render limit to 3

Match the per-item enrichment cap and inline-display cap used by the
other sources (Reddit, HN, YouTube, TikTok, GitHub all use 5 fetched /
3 displayed). At the previous 3/2 caps the engine routinely truncated
cluster context — a recent run on cli-printing-press lost the Jason
Calacanis quote tweet entirely because the display cut off after Garry
Tan's first two posts.

* feat(digg): rename 'Digg AI 1000' to 'Digg' in user-facing strings

Drop the 'AI 1000' suffix from the footer line, source label, inline
quote attribution ('via Digg'), why_relevant, container, mock title,
SKILL.md source list, and README sources table. Internal code comments
and docstrings still reference the upstream Digg AI 1000 product.

Bumps version to 3.2.1 and adds a CHANGELOG entry covering this rename
and the POSTS_PER_CLUSTER / render-limit bumps from the prior commit.

---------

Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
2026-05-09 21:04:23 -07:00
Matt Van Horn 80392061d4 chore(release): v3.2.0 (#371)
Release / build-and-release (push) Has been cancelled
* chore(release): v3.2.0

Bumps plugin/marketplace/codex/pyproject versions from 3.1.1 to 3.2.0.
Promotes the Unreleased CHANGELOG entries (--emit=html, Digg AI 1000
source) to the 3.2.0 release section.

* chore(release): bump SKILL.md header and sync.sh path to 3.2.0

---------

Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
2026-05-09 19:29:16 -07:00
Matt Van Horn c04bd67922 feat: add Digg AI 1000 as an opt-in source (#370)
* feat(digg): add Digg AI 1000 source module with cluster search and post enrichment

- search_digg shells out to digg-pp-cli with --since 30d --agent
- parse_digg_response normalizes clusters to last30days dict shape
- enrich_with_top_posts attaches top-ranked X posts to top-K clusters
- shutil.which gate plus subproc.run_with_timeout discipline matches
  bird_x.py / youtube_yt.py patterns

25 unit tests cover parse, age window, relevance, binary-missing
fallback, timeout recovery, and partial enrichment failures.

* feat(digg): wire Digg source into pipeline, normalize, signals, and render

pipeline.py:
- Import digg, add to MOCK_AVAILABLE_SOURCES, gate via shutil.which
- Dispatch case calls search_digg + parse_digg_response, runs
  enrich_with_top_posts at default/deep depth
- Mock fixture includes one enriched cluster + one bare cluster

normalize.py:
- _normalize_digg maps cluster dicts to SourceItem with
  container='Digg AI 1000' and metadata.posts pass-through

signals.py:
- SOURCE_QUALITY['digg'] = 0.85 (top tier alongside YouTube,
  reflecting Digg's curatorial layer)
- ENGAGEMENT_WEIGHTS['digg'] balances postCount, uniqueAuthors,
  and the rank_score derived from Digg's curatorial position

render.py:
- SOURCE_LABELS['digg'] = 'Digg AI 1000'
- _FOOTER_SOURCES adds '⛏️ Digg AI 1000' line after GitHub
- ENGAGEMENT_DISPLAY mirrors footer keys
- New _digg_posts_for + _format_digg_quote helpers emit inline
  '@handle via Digg AI 1000' quotes for clusters with attached X
  posts; both compact and full-dump renderers call them

* feat(digg): polish per-item engagement display and progress label

- ENGAGEMENT_DISPLAY for digg uses 'posts' / 'auth' to match the
  codebase abbreviation convention (HN: 'pts'/'cmt', X: 'rt'/'re')
- Footer item word changes from 'story' to 'cluster' to dodge the
  pre-existing naive plural in _footer_line_for_source ('storys')
  and to match Digg's actual data model
- ui.py SOURCE_COMPLETION_META adds digg with correct 'cluster'/
  'clusters' plural so 'Research complete' shows 'Digg: N clusters'

* feat(digg): document Digg AI 1000 source in skill, README, and changelog

- planner.py SOURCE_CAPABILITIES adds digg with discussion/social/link
  capabilities so the planner offers it through the standard fanout
- SKILL.md ACTIVE_SOURCES_LIST gate includes 'which digg-pp-cli' check
  and the source list / available-sources line names digg as opt-in
- README.md Sources table adds the Digg AI 1000 row with the activation
  gate so first-time readers see what they get
- CHANGELOG.md Unreleased section calls out the source addition

* fix(digg): enrich post-dedupe so brief survivors carry inline quotes

Pipeline dispatch was attaching X posts to the top-3 items returned by
search, but dedupe later picked different survivors when multiple
clusters compared similar (common for trending topics). The brief
ended up showing clusters with no posts attached even though
enrichment ran successfully on positions 0-2.

Move enrichment to _finalize_items_by_source. The new
digg.enrich_source_items helper reads metadata['clusterUrlId'] and
writes metadata['posts'] in place on the SourceItems that actually
survive dedupe.

Verified live on 'openclaw': 2 surviving clusters, both now carry
real X-post quotes from @sama and @jeremyphoward attributed
'via Digg AI 1000'.

Adds 3 unit tests covering survivor enrichment, non-digg skip, and
clusterUrlId fallback to item_id.

* test(digg): relax live off-topic test to check shape, not emptiness

Digg's live search uses fuzzy/popularity fallback, so an impossible
token can still return some loosely-related clusters. The contract
the pipeline depends on is shape (results is always a list);
token-overlap relevance handles the noise downstream.

---------

Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
2026-05-09 19:05:41 -07:00
Trevin Chow b1773be8f3 feat(emit): --emit=html for shareable self-contained briefs (#332)
Adds a one-command shareable HTML mode to /last30days. The skill detects
HTML intent (explicit --emit=html / --emit:html / --html flag in
$ARGUMENTS, or natural-language asks like "give me a shareable brief",
"for Slack", "export as HTML"), runs the normal research + chat synthesis
flow, then saves a self-contained HTML file to
~/Documents/Last30Days/{topic}-brief.html. The synthesis appears in chat
as usual; the HTML is an additional artifact for sharing.

User experience:

  /last30days OpenClaw --emit=html
  /last30days OpenClaw, give me an HTML brief for Slack

Synthesis prints to chat. Last line of the response: "📎 Shareable brief
saved to ~/Documents/Last30Days/openclaw-brief.html". Open it, drag it
into a message, browser-print to PDF, email it.

Architecture:

  - SKILL.md gets a small detection block (triggers + early exit +
    MUST/MUST NOT rules + rationale) that points to a reference file.
  - references/save-html-brief.md owns the implementation: capture the
    synthesis verbatim into a temp file via heredoc, invoke the engine
    with --emit=html --synthesis-file, save to disk, append the
    confirmation line to chat.
  - lib/render.py exposes render_for_html(report, synthesis_md=None) and
    render_for_html_comparison(...) -- clean markdown for HTML
    conversion. Omits debug file header, model-facing safety note, and
    data quality warnings (those stay in engine stderr; recipients can't
    act on them in a shared artifact).
  - lib/html_render.py is a new module: ~200-line CSS template (dark
    mode default, prefers-color-scheme switch, print stylesheet, mobile
    breakpoint), stdlib-regex markdown-to-HTML converter, marker-based
    META + engine-footer wrapping, PROSE_LABELS registry promoting plain
    -text labels to <h2>, colophon builder.
  - last30days.py adds --emit=html argparse choice and --synthesis-file
    PATH flag (engine still callable directly without the skill in the
    loop).

Design:

  - Voice-led research brief, not corporate report. Inter + JetBrains
    Mono via Google Fonts with full system fallbacks (no FOIT, works
    offline). Brand purple #a855f7 (#7c3aed in light mode). Type ramp:
    body 17px/400/muted, bold lead-in 17px/600/fg, h2 + .prose-label
    20px/600/fg, monospace badge/meta/footer/colophon at 13-13.5px.
  - 720px max-width, generous whitespace, no card layouts or shadows.
  - Print stylesheet: light theme, A4 margins, [href]::after URL
    footnotes, page-break-inside:avoid on the engine footer.

Templated (locked) shell:

  - HTML5 boilerplate, Google Fonts <link> with preconnect, all CSS
    inline.
  - .badge / .meta / .engine-footer / .colophon containers.

Flexible (role-based):

  - <h2> rendering covers BOTH plain ## headers (comparison mode per
    LAW 4 exception) AND promoted prose labels via PROSE_LABELS
    registry. Adding a new SKILL.md prose label is a one-line tuple
    addition; no CSS or template changes.
  - Marker-based engine boundaries (<!-- META: ... -->,
    <!-- PASS-THROUGH FOOTER -->) survive the markdown converter and
    get promoted post-conversion. Robust to engine output format
    changes.
  - Generic markdown-to-HTML for body content; future SKILL.md additions
    (new sections, tables, blockquotes) render correctly without code
    changes.

Tests: 30 new tests in tests/test_html_render.py covering snapshots
(rich/thin/comparison), CLI parsing, --synthesis-file end-to-end, prose
label promotion, warning exclusion from artifact, parseability via
html.parser, no-script self-containment.

No SKILL.md voice contract changes, no LAWs 1-8 changes, no new pip
dependencies, no JavaScript anywhere.
2026-05-02 11:30:22 -07:00
Ilia Alshanetsky 5b87cca886 fix(xurl): treat PermissionError from PATH lookup as unavailable (#322)
is_available() only caught FileNotFoundError and TimeoutExpired. On WSL,
a /mnt/c/.../WindowsApps entry on $PATH returns EACCES during exec, and
Python raises PermissionError. That escaped is_available() and crashed
pipeline.diagnose() before any source ran.

Catch OSError instead. It covers FileNotFoundError, PermissionError, and
any other spawn-time OS error, so a non-executable xurl on PATH falls
through to the next backend instead of aborting the run.
2026-04-26 14:16:14 -07:00
Ilia Alshanetsky bbf892aecc refactor: extract subprocess cleanup into shared subproc helper (#210)
bird_x.py and youtube_yt.py had four near-identical copies of the same
subprocess cleanup dance (Popen + os.setsid + communicate(timeout) +
SIGTERM via killpg + proc.kill() fallback + wait(5)). Extract to
lib.subproc.run_with_timeout(), which:

- runs the child in its own process group via os.setsid where available
- raises SubprocTimeout on timeout
- on timeout: SIGTERM the group, fall back to proc.kill(), wait up to 5s
- accepts an on_pid callback so bird_x can still register child PIDs
  with last30days.register_child_pid for whole-process cleanup
- captures stdout/stderr as strings in a SubprocResult dataclass

Migrated call sites: _run_bird_search, search_handles inner worker,
search_youtube, fetch_transcript. With the helper in place, the signal
and subprocess imports became dead in both files (plus os in
youtube_yt) and went with them.

Tests: 9 new subproc tests cover success, non-zero exit, stderr capture,
timeout-raises, timeout-kills-group, missing-command, env passthrough,
PID callback, and callback-exception suppression. test_env_v3 and
test_youtube_yt patch subproc.run_with_timeout instead of the removed
bird_x.subprocess and yt-dlp subprocess.
2026-04-25 14:17:47 -07:00
Ilia Alshanetsky 2acbf8a869 perf: batch store_findings, dedup source_items in O(1), remove dead code (#206)
1. N+1 queries in store.store_findings()
   The old loop ran one SELECT per finding to check existence, then one
   INSERT or UPDATE. 100 findings cost 200 serial SQLite roundtrips.
   Now: one batch SELECT with WHERE source_url IN (...) builds a lookup
   dict, then executemany() handles all inserts and updates. Query count
   stays constant regardless of batch size. Benchmark on 500 findings:
   ~30ms to ~20ms; gap widens on slower storage.

2. O(n^2) source_items dedup in fusion.weighted_rrf()
   Merging an item into an existing candidate ran any(existing.source ==
   ... for existing in candidate.source_items), linearly scanning a list
   that grew with each merge. At 40 candidates with 20 source_items each,
   fusion went quadratic. Now tracks (source, item_id) tuples in a
   per-candidate set for O(1) lookup. The source_items list itself is
   unchanged since other code iterates it.

3. Dead code removal
   - providers.GeminiClient.ground_search() and .url_context_json(): zero
     callers. Deleted.
   - render._top_comment_excerpt(): zero callers. Deleted.
   - env.is_reddit_available(): one-line wrapper around get_reddit_source.
     Callers can check get_reddit_source(config) is not None directly.
2026-04-25 14:17:17 -07:00
Ilia Alshanetsky e6b89f2644 perf: cache PreparedQuery per stream, skip double-normalize in dedupe (#282)
Scoring hot path (_normalize_score_dedupe) re-tokenized the same
ranking_query ~240x per stream: once per item for local_relevance,
plus ~5x per item across snippet windows. Query tokens are immutable
within a stream, so compute them once as relevance.PreparedQuery and
thread through signals.annotate_stream and snippet.extract_best_snippet.

dedupe._PreparedText called normalize_text twice: once in __init__ and
again via get_ngrams. Factor out _ngrams_of_normalized so the prepared
path skips the redundant pass while get_ngrams keeps its public contract.

Behavior unchanged.
2026-04-25 14:16:57 -07:00
Ilia Alshanetsky 2c2755b49c refactor(normalize): extract _join_comment_excerpts helper (#283)
_normalize_reddit, _normalize_hackernews, and _normalize_github inlined
the same 5-line comprehension to stringify and space-join the first 3
top_comments' excerpt field. Extract one helper, call it from all three.

The comment field name varies per source (Reddit/GitHub use 'excerpt',
HN uses 'text'), so it's passed as a parameter. Behavior unchanged.
2026-04-25 14:16:50 -07:00
Ilia Alshanetsky 18b5658674 chore: remove orphan test for deleted generate-synthesis-inputs script (#205)
tests/test_generate_synthesis_inputs_v3.py imported a script that no
longer exists in the repo. The test failed with FileNotFoundError on
every run.
2026-04-25 14:16:39 -07:00
Matt Van Horn 145adc9f56 Merge pull request #321 from tmchow/tmchow/review-plugin-json
chore: align plugin manifests, add Codex AGENTS.md
2026-04-25 12:34:12 -07:00
Trevin Chow b100caf2df fix(plugin): restore marketplace plugin version
`tests/test_plugin_contract.py::test_versions_match_across_manifests`
enforces that every version-bearing surface agrees: pyproject.toml,
SKILL.md, both plugin.json files, AND the marketplace plugin entry.
The Claude Code spec says plugin.json wins when both are set, but this
repo deliberately mirrors the version across all surfaces and tests it.
Restore the field at 3.1.1 to satisfy the contract.
2026-04-24 23:21:02 -07:00
Trevin Chow dc0cb9850b chore: add AGENTS.md pointing to CLAUDE.md
Codex CLI reads AGENTS.md for repo-level context the way Claude Code reads CLAUDE.md. Delegate to the existing CLAUDE.md so both harnesses share one source of project instructions.
2026-04-24 23:16:32 -07:00
Trevin Chow ceec99b24c chore(plugin): clean up plugin manifests
- Remove no-op `"hooks": {}` from .claude-plugin/plugin.json (auto-discovery from hooks/hooks.json picks up the SessionStart hook).
- Remove redundant `version` from marketplace.json plugin entry; plugin.json is the source of truth per the spec.
- Sync description / longDescription across .claude-plugin and .codex-plugin manifests so all surfaces show the same copy.
2026-04-24 23:16:29 -07:00
Dave Morin d1823a2d05 feat: add PR and issue templates for contributor workflow (#296)
Adds structured templates to help contributors submit higher-quality
PRs and issues. PR template includes testing checklist (pytest, sync.sh).
Issue templates use YAML forms for bug reports and feature requests.

Fixes #251
2026-04-24 10:49:06 -07:00
Claire Novotny 17caa0526d ci: validate plugin contract on pull requests 2026-04-24 12:05:39 -04:00
Claire Novotny f03cb866aa fix: address plugin layout review feedback 2026-04-24 11:52:48 -04:00
Claire Novotny 72495c1c14 Restructure as Codex plugin 2026-04-23 20:15:02 -04:00
207 changed files with 5483 additions and 1337 deletions
+20
View File
@@ -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"
}
]
}
+5 -4
View File
@@ -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"
+2 -3
View File
@@ -1,6 +1,6 @@
{
"name": "last30days",
"version": "3.1.0",
"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"]
}
-3
View File
@@ -1,3 +0,0 @@
{
"name": "last30days"
}
+4 -4
View File
@@ -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
+53
View File
@@ -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).
+19
View File
@@ -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 -->
+97 -3
View File
@@ -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
+26
View File
@@ -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
+9
View File
@@ -26,5 +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/
+1
View File
@@ -0,0 +1 @@
@CLAUDE.md
+30
View File
@@ -5,6 +5,35 @@ 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.
@@ -78,6 +107,7 @@ Individual changelog entries for 3.0.10 through 3.0.14 below document the increm
### 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
+7 -7
View File
@@ -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
+11 -21
View File
@@ -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
+103 -31
View File
@@ -14,21 +14,18 @@
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.
+12
View File
@@ -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
+36
View File
@@ -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.
+39
View File
@@ -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)
}
}
+14
View File
@@ -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
)
+34
View File
@@ -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=
+26
View File
@@ -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")
}
+168
View File
@@ -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)
}
+167
View File
@@ -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)
}
}
+163
View File
@@ -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
}
+281
View File
@@ -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)
}
}
+2
View File
@@ -0,0 +1,2 @@
Populated at build time by scripts/sync-engine.sh.
Source of truth: skills/last30days/scripts/.
+189
View File
@@ -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
}
+146
View File
@@ -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()
}
+145
View File
@@ -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")
}
}
+151
View File
@@ -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"
]
}
}
+35
View File
@@ -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}"
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 MiB

+5 -8
View File
@@ -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/*",
]
+1 -1
View File
@@ -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.
-123
View File
@@ -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."
+144 -37
View File
@@ -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)
+414
View File
@@ -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&amp;family=JetBrains+Mono:wght@400;500&amp;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>&lt;!-- META: TEXT --&gt;</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*&lt;!--\s*META:\s*(.*?)\s*--&gt;\s*</p>",
replace,
body,
)
body = re.sub(r"&lt;!--\s*META:\s*(.*?)\s*--&gt;", 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"
@@ -71,8 +71,29 @@ def _normalize_phrase(text: str) -> str:
return ' '.join(re.sub(r'[^\w\s]', ' ', text.lower()).split())
class PreparedQuery:
"""Precomputed query shape reused across items in a stream.
Built once per ranking_query; reused by token_overlap_relevance so the
per-item normalize/score loops don't re-tokenize the same query N times.
"""
__slots__ = ("raw", "q_tokens", "informative_q_tokens", "normalized_phrase")
def __init__(self, query: str) -> None:
self.raw = query
self.q_tokens = tokenize(query)
informative = {t for t in self.q_tokens if t not in LOW_SIGNAL_QUERY_TOKENS}
self.informative_q_tokens = informative or self.q_tokens
self.normalized_phrase = _normalize_phrase(query)
def _as_prepared(query: "str | PreparedQuery") -> PreparedQuery:
return query if isinstance(query, PreparedQuery) else PreparedQuery(query)
def token_overlap_relevance(
query: str,
query: "str | PreparedQuery",
text: str,
hashtags: Optional[List[str]] = None,
) -> float:
@@ -95,7 +116,8 @@ def token_overlap_relevance(
Returns:
Float between 0.0 and 1.0 (0.5 for empty queries)
"""
q_tokens = tokenize(query)
prepared = _as_prepared(query)
q_tokens = prepared.q_tokens
# Combine text and hashtags for matching
combined = text
@@ -119,9 +141,7 @@ def token_overlap_relevance(
if overlap == 0:
return 0.0
informative_q_tokens = {t for t in q_tokens if t not in LOW_SIGNAL_QUERY_TOKENS}
if not informative_q_tokens:
informative_q_tokens = q_tokens
informative_q_tokens = prepared.informative_q_tokens
coverage = overlap / len(q_tokens)
informative_overlap = len(informative_q_tokens & t_tokens) / len(informative_q_tokens)
@@ -129,7 +149,7 @@ def token_overlap_relevance(
precision = overlap / precision_denominator
phrase_bonus = 0.0
normalized_query = _normalize_phrase(query)
normalized_query = prepared.normalized_phrase
normalized_text = _normalize_phrase(combined)
if normalized_query and normalized_query in normalized_text:
phrase_bonus = 0.12 if len(normalized_query.split()) > 1 else 0.16
@@ -4,6 +4,7 @@ from __future__ import annotations
import json
import pathlib
import re
from collections import Counter
from datetime import date
from urllib.parse import urlparse
@@ -11,21 +12,47 @@ from urllib.parse import urlparse
from . import dates, schema
def _skill_version() -> str:
"""Read plugin version from .claude-plugin/plugin.json if available.
_VERSION_RE = re.compile(
r'''^version:\s*(?:"([^"]+)"|'([^']+)'|(\S+))\s*$''',
re.MULTILINE,
)
Tries nearest plugin.json by walking up from render.py's own location.
Falls back to "?" if not found. This keeps the badge emission from
crashing on non-plugin-cache installs (repo checkout, Gemini, Codex).
def _skill_version() -> str:
"""Read plugin version from .claude-plugin/plugin.json, falling back to SKILL.md frontmatter.
Per-harness skill install dirs (`~/.claude/skills`, `~/.codex/skills`, `~/.agents/skills`,
Hermes, etc.) do not always carry `.claude-plugin/plugin.json` that file ships with
plugin-cache installs but not with per-harness skill installs. SKILL.md frontmatter is
the fallback that keeps the badge from emitting v? on those installs. Returns "?" only
if both sources are missing.
A corrupt manifest at one ancestor does not shadow a valid manifest at a deeper one
(continue, not break). YAML frontmatter accepts double-quoted, single-quoted, or
unquoted version scalars.
"""
here = pathlib.Path(__file__).resolve()
for parent in [here.parent, *here.parents]:
candidate = parent / ".claude-plugin" / "plugin.json"
if candidate.is_file():
for parent in here.parents:
manifest = parent / ".claude-plugin" / "plugin.json"
if manifest.is_file():
try:
return json.loads(candidate.read_text()).get("version", "?")
version = json.loads(manifest.read_text()).get("version")
except (json.JSONDecodeError, OSError):
return "?"
continue
if version:
return version
# No usable manifest found at any ancestor — fall back to SKILL.md frontmatter.
for parent in here.parents:
skill_md = parent / "SKILL.md"
if skill_md.is_file():
try:
match = _VERSION_RE.search(skill_md.read_text())
except (OSError, UnicodeDecodeError):
break
if match:
return next(g for g in match.groups() if g is not None)
break
return "?"
@@ -52,6 +79,7 @@ SOURCE_LABELS = {
"xiaohongshu": "Xiaohongshu",
"x": "X",
"github": "GitHub",
"digg": "Digg",
"perplexity": "Perplexity",
}
@@ -170,6 +198,168 @@ def render_compact(report: schema.Report, cluster_limit: int = 8, fun_level: str
return "\n".join(lines).strip() + "\n"
def render_for_html(
report: schema.Report,
synthesis_md: str | None = None,
*,
save_path: str | None = None,
) -> str:
"""Render markdown intended for shareable HTML conversion.
This output keeps the public badge, compact source/date metadata, an
optional one-line data quality note, optional synthesized brief markdown,
and the engine footer. It deliberately omits the debug file header,
model-facing safety note, and evidence scratchpad emitted by
render_compact().
When synthesis_md is None, the body is intentionally sparse: badge,
metadata, optional data quality note, and engine footer only.
"""
lines = [
*_render_badge(),
*_render_html_metadata(report),
]
if synthesis_md:
lines.extend(["", synthesis_md.strip()])
# Data quality warnings are NOT rendered into the HTML artifact. The HTML
# is meant to be shared (Slack, email, Notion); recipients haven't asked
# for technical commentary about how the run was produced. Generators see
# the same warnings via collect_html_warnings() routed to stderr by the
# CLI, so they can fix quality issues before sharing.
_append_html_footer(lines, report, save_path)
return "\n".join(lines).strip() + "\n"
def render_for_html_comparison(
entity_reports: list[tuple[str, schema.Report]],
synthesis_md: str | None = None,
*,
save_path: str | None = None,
) -> str:
"""Render comparison markdown intended for shareable HTML conversion.
Same semantics as render_for_html(), but metadata and data quality notes
are aggregated across the compared entities.
"""
if not entity_reports:
raise ValueError("render_for_html_comparison requires at least one report")
entities = [label for label, _ in entity_reports]
main_report = entity_reports[0][1]
meta = (
f"<!-- META: {main_report.range_from} to {main_report.range_to} "
f"· comparing {len(entities)}: {', '.join(entities)} -->"
)
lines = [
*_render_badge(),
meta,
]
if synthesis_md:
lines.extend(["", synthesis_md.strip()])
# Comparison data quality notes also go to stderr, not into the artifact.
_append_html_footer(lines, main_report, save_path)
return "\n".join(lines).strip() + "\n"
def collect_html_warnings(report: schema.Report) -> list[str]:
"""Collect data quality warnings for stderr output (NOT for the HTML artifact).
Returns a list of human-readable warning strings. Empty list if the run
was clean. Used by the CLI to emit diagnostics to stderr after writing
the HTML to stdout/file.
"""
notes: list[str] = []
if _render_degraded_run_warning(report):
notes.append("Run was missing pre-flight resolution. Re-run with `--plan` for richer results.")
elif _render_pre_research_warning(report):
notes.append("Pre-research was skipped, so results may be thinner than a resolved run.")
freshness_warning = _assess_data_freshness(report)
if freshness_warning:
notes.append(freshness_warning)
notes.extend(report.warnings)
return _dedupe_notes(notes)
def collect_html_warnings_comparison(
entity_reports: list[tuple[str, schema.Report]],
) -> list[str]:
"""Collect comparison-mode warnings, prefixed by entity label."""
notes: list[str] = []
for label, report in entity_reports:
for w in collect_html_warnings(report):
notes.append(f"{label}: {w}")
return notes
def _render_html_metadata(report: schema.Report) -> list[str]:
"""Inline metadata as an HTML comment marker.
html_render.py post-processes ``<!-- META: ... -->`` markers into a
``<div class="meta">`` after markdown conversion, so the metadata escapes
the markdown converter's HTML-escaping pass cleanly. Same pattern as the
PASS_THROUGH_FOOTER marker used for the engine tree.
"""
non_empty = [s for s, items in sorted(report.items_by_source.items()) if items]
if non_empty:
sources = ", ".join(_source_label(s) for s in non_empty)
else:
sources = "no active sources"
return [
f"<!-- META: {report.range_from} to {report.range_to} · {sources} -->",
]
def _render_html_data_quality_note(report: schema.Report) -> str | None:
notes: list[str] = []
degraded_warning = _render_degraded_run_warning(report)
if degraded_warning:
notes.append("This run was missing pre-flight resolution. Re-run with `--plan` for richer results.")
pre_research_warning = _render_pre_research_warning(report)
if pre_research_warning and not degraded_warning:
notes.append("Pre-research was skipped, so results may be thinner than a resolved run.")
freshness_warning = _assess_data_freshness(report)
if freshness_warning:
notes.append(freshness_warning)
notes.extend(report.warnings)
if not notes:
return None
return f"> **Data quality note:** {' '.join(_dedupe_notes(notes))}"
def _render_html_comparison_data_quality_note(
entity_reports: list[tuple[str, schema.Report]],
) -> str | None:
notes: list[str] = []
for label, report in entity_reports:
note = _render_html_data_quality_note(report)
if note:
clean = note.removeprefix("> **Data quality note:** ").strip()
notes.append(f"{label}: {clean}")
if not notes:
return None
return f"> **Data quality note:** {' '.join(_dedupe_notes(notes))}"
def _dedupe_notes(notes: list[str]) -> list[str]:
out: list[str] = []
seen: set[str] = set()
for note in notes:
normalized = " ".join(str(note).split())
if not normalized or normalized in seen:
continue
seen.add(normalized)
out.append(normalized)
return out
def _append_html_footer(lines: list[str], report: schema.Report, save_path: str | None) -> None:
footer = _render_emoji_footer(report, save_path)
lines.append("")
lines.append("<!-- PASS-THROUGH FOOTER: emit verbatim in the model response per LAW 5. -->")
lines.extend(footer)
lines.append("<!-- END PASS-THROUGH FOOTER -->")
def _render_canonical_boundary() -> list[str]:
"""Emit the explicit END-OF-CANONICAL-OUTPUT boundary.
@@ -663,7 +853,7 @@ def render_full(report: schema.Report) -> str:
lines.append("## All Items by Source")
lines.append("")
source_order = ["reddit", "x", "youtube", "tiktok", "instagram", "threads", "pinterest",
"hackernews", "bluesky", "truthsocial", "polymarket", "grounding", "xiaohongshu", "github", "perplexity"]
"hackernews", "bluesky", "truthsocial", "polymarket", "grounding", "xiaohongshu", "github", "digg", "perplexity"]
for source in source_order:
items = report.items_by_source.get(source, [])
if not items:
@@ -689,6 +879,9 @@ def render_full(report: schema.Report) -> str:
tc_score = tc.get("score", "")
attribution = _comment_attribution(item.source, tc.get("author"))
lines.append(f" Top comment {attribution} ({tc_score} {vote_label}): {excerpt}")
# Digg: inline X-post quotes attached to the cluster.
for post in _digg_posts_for(item, limit=3):
lines.append(f" > {_format_digg_quote(post)}")
# Comment insights for Reddit
insights = item.metadata.get("comment_insights", [])
if insights:
@@ -810,6 +1003,8 @@ def _render_candidate(candidate: schema.Candidate, prefix: str) -> list[str]:
source = primary.source if primary else None
attribution = _comment_attribution(source, tc.get("author"))
lines.append(f" - {attribution} ({score} {vote_label}): {_truncate(excerpt.strip(), 240)}")
for post in _digg_posts_for(primary):
lines.append(f" - {_format_digg_quote(post)}")
insight = _comment_insight(primary)
if insight:
lines.append(f" - Insight: {_truncate(insight, 220)}")
@@ -1060,6 +1255,7 @@ _FOOTER_SOURCES: list[tuple[str, str, str, str, list[tuple[str, str]]]] = [
("bluesky", "🦋", "Bluesky", "post", [("likes", "likes"), ("reposts", "reposts")]),
("truthsocial", "🇺🇸", "Truth Social", "post", [("likes", "likes"), ("reposts", "reposts")]),
("github", "🐙", "GitHub", "item", [("reactions", "reactions"), ("comments", "comments")]),
("digg", "⛏️", "Digg", "cluster", [("postCount", "posts"), ("uniqueAuthors", "authors")]),
]
@@ -1317,6 +1513,7 @@ ENGAGEMENT_DISPLAY: dict[str, list[tuple[str, str]]] = {
"polymarket": [],
"github": [("reactions", "react"), ("comments", "cmt")],
"perplexity": [("citations", "cite")],
"digg": [("postCount", "posts"), ("uniqueAuthors", "auth")],
}
@@ -1504,16 +1701,6 @@ def _top_comments_list(item: schema.SourceItem | None, limit: int = 3, min_score
return [c for c in comments if (c.get("score") or 0) >= min_score][:limit]
def _top_comment_excerpt(item: schema.SourceItem | None) -> str | None:
if not item:
return None
comments = item.metadata.get("top_comments") or []
if not comments or not isinstance(comments[0], dict):
return None
top = comments[0]
return str(top.get("excerpt") or top.get("text") or "").strip() or None
def _comment_insight(item: schema.SourceItem | None) -> str | None:
if not item:
return None
@@ -1523,6 +1710,39 @@ def _comment_insight(item: schema.SourceItem | None) -> str | None:
return str(insights[0]).strip() or None
def _digg_posts_for(item: schema.SourceItem | None, limit: int = 3) -> list[dict]:
"""Return up to `limit` parsed Digg posts attached as enrichment to a cluster.
Returns an empty list for non-digg sources or clusters without enrichment.
"""
if not item or item.source != "digg":
return []
posts = item.metadata.get("posts") or []
if not isinstance(posts, list):
return []
out: list[dict] = []
for entry in posts:
if isinstance(entry, dict) and entry.get("body") and entry.get("username"):
out.append(entry)
if len(out) >= limit:
break
return out
def _format_digg_quote(post: dict, body_limit: int = 200) -> str:
"""Format a Digg-attached X post as an inline 'via Digg' quote line."""
handle = post.get("username") or ""
x_url = post.get("x_url") or ""
body = (post.get("body") or "").replace("\n", " ").strip()
if len(body) > body_limit:
body = body[: body_limit - 1].rstrip() + ""
if x_url and handle:
return f"[@{handle}]({x_url}) via Digg: {body}"
if handle:
return f"@{handle} via Digg: {body}"
return f"via Digg: {body}"
def _transcript_highlights(item: schema.SourceItem | None) -> list[str]:
if not item or item.source != "youtube":
return []
@@ -12,6 +12,7 @@ SOURCE_QUALITY = {
"xiaohongshu": 0.7,
"hackernews": 0.8,
"youtube": 0.85,
"digg": 0.85,
"reddit": 0.6,
"x": 0.68,
"bluesky": 0.66,
@@ -26,7 +27,10 @@ def source_quality(source: str) -> float:
return SOURCE_QUALITY.get(source, 0.6)
def local_relevance(item: schema.SourceItem, ranking_query: str) -> float:
def local_relevance(
item: schema.SourceItem,
ranking_query: "str | relevance.PreparedQuery",
) -> float:
text = "\n".join(
part
for part in [item.title, item.body, item.snippet]
@@ -92,6 +96,7 @@ ENGAGEMENT_WEIGHTS: dict[str, list[tuple[str, float]]] = {
"bluesky": [("likes", 0.40), ("reposts", 0.30), ("replies", 0.20), ("quotes", 0.10)],
"truthsocial": [("likes", 0.45), ("reposts", 0.30), ("replies", 0.25)],
"polymarket": [("volume", 0.60), ("liquidity", 0.40)],
"digg": [("postCount", 0.40), ("uniqueAuthors", 0.30), ("rank_score", 0.30)],
}
@@ -175,13 +180,14 @@ def normalize(values: list[float | None]) -> list[int | None]:
def annotate_stream(
items: list[schema.SourceItem],
ranking_query: str,
ranking_query: "str | relevance.PreparedQuery",
freshness_mode: str,
) -> list[schema.SourceItem]:
"""Attach local scoring metadata and return items sorted by local_rank_score."""
prepared_query = ranking_query if isinstance(ranking_query, relevance.PreparedQuery) else relevance.PreparedQuery(ranking_query)
engagement_scores = normalize([engagement_raw(item) for item in items])
for item, eng_score in zip(items, engagement_scores, strict=True):
item.local_relevance = local_relevance(item, ranking_query)
item.local_relevance = local_relevance(item, prepared_query)
item.freshness = freshness(item, freshness_mode)
item.engagement_score = eng_score
item.source_quality = source_quality(item.source)
@@ -26,7 +26,7 @@ def _windows(words: list[str], size: int, overlap: int) -> list[str]:
def extract_best_snippet(
item: schema.SourceItem,
ranking_query: str,
ranking_query: "str | relevance.PreparedQuery",
max_words: int = 120,
) -> str:
"""Prefer existing snippets, else extract the best matching evidence window."""
@@ -43,8 +43,9 @@ def extract_best_snippet(
if not candidates:
return _truncate_words(body, max_words)
prepared_query = ranking_query if isinstance(ranking_query, relevance.PreparedQuery) else relevance.PreparedQuery(ranking_query)
best = max(
candidates,
key=lambda candidate: relevance.token_overlap_relevance(ranking_query, candidate),
key=lambda candidate: relevance.token_overlap_relevance(prepared_query, candidate),
)
return _truncate_words(best, max_words)
+94
View File
@@ -0,0 +1,94 @@
"""Subprocess helpers: safe timeout + process-group cleanup.
Used by bird_x.py (Node.js Bird search) and youtube_yt.py (yt-dlp search
and transcript download). Both need the same os.setsid/killpg cleanup
dance on timeout to avoid orphaning child processes.
"""
from __future__ import annotations
import os
import signal
import subprocess
from dataclasses import dataclass
from typing import Optional, Sequence
class SubprocTimeout(Exception):
"""Raised when a subprocess exceeds its timeout and is killed."""
@dataclass
class SubprocResult:
"""Result of a subprocess run that captured stdout and stderr."""
returncode: int
stdout: str
stderr: str
def run_with_timeout(
cmd: Sequence[str],
*,
timeout: int,
env: Optional[dict] = None,
on_pid: Optional[callable] = None,
) -> SubprocResult:
"""Run a subprocess with process-group cleanup on timeout.
Spawns ``cmd`` inside its own process group via ``os.setsid`` where
available. If ``communicate(timeout=...)`` raises ``TimeoutExpired``,
signals ``SIGTERM`` to the entire group, falls back to ``proc.kill()``
if the signal fails, then waits up to 5 seconds for cleanup, and
raises ``SubprocTimeout``.
Args:
cmd: Command and arguments to spawn.
timeout: Timeout in seconds passed to ``communicate()``.
env: Optional environment dict. If None, inherits parent env.
on_pid: Optional callable invoked with the child PID right after
spawn. Used by bird_x.py to register child PIDs for cleanup
tracking. Exceptions raised by the callback are suppressed.
Returns:
SubprocResult with returncode, stdout, and stderr as strings.
Raises:
SubprocTimeout: If the process exceeded ``timeout``.
FileNotFoundError: If the executable is not found.
OSError: For other spawn failures.
"""
preexec = os.setsid if hasattr(os, "setsid") else None
proc = subprocess.Popen(
list(cmd),
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
encoding="utf-8",
errors="replace",
preexec_fn=preexec,
env=env,
)
if on_pid is not None:
try:
on_pid(proc.pid)
except Exception:
pass
try:
stdout, stderr = proc.communicate(timeout=timeout)
except subprocess.TimeoutExpired:
try:
os.killpg(os.getpgid(proc.pid), signal.SIGTERM)
except (ProcessLookupError, PermissionError, OSError):
proc.kill()
proc.wait(timeout=5)
raise SubprocTimeout(f"Command {cmd[0]} timed out after {timeout}s")
return SubprocResult(
returncode=proc.returncode,
stdout=stdout or "",
stderr=stderr or "",
)
@@ -152,35 +152,16 @@ def search_threads(
_log(f"Searching for '{core_topic}' (depth={depth}, limit={config['results']})")
try:
import requests as _requests
except ImportError:
_requests = None
if not _requests:
_log("requests library not installed, falling back to urllib")
try:
from urllib.parse import urlencode
params = urlencode({"keyword": core_topic})
url = f"{SCRAPECREATORS_BASE}/search?{params}"
headers = 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}"}
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 = (
@@ -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/tiktok"
@@ -214,30 +209,17 @@ def _hashtag_search(
List of raw TikTok item dicts (aweme_info format).
"""
_log(f"Hashtag search: #{hashtag}")
if not _requests:
try:
from urllib.parse import urlencode
params = urlencode({"hashtag": hashtag})
url = f"{SCRAPECREATORS_BASE}/search/hashtag?{params}"
headers = 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"Hashtag search error (urllib) for #{hashtag}: {e}")
return []
else:
try:
resp = _requests.get(
f"{SCRAPECREATORS_BASE}/search/hashtag",
params={"hashtag": hashtag},
headers=http.scrapecreators_headers(token),
timeout=30,
)
resp.raise_for_status()
data = resp.json()
except Exception as e:
_log(f"Hashtag search error for #{hashtag}: {e}")
return []
try:
data = http.get(
f"{SCRAPECREATORS_BASE}/search/hashtag",
params={"hashtag": hashtag},
headers=http.scrapecreators_headers(token),
timeout=30,
retries=2,
)
except Exception as e:
_log(f"Hashtag search error for #{hashtag}: {e}")
return []
raw_items = data.get("aweme_list") or data.get("data") or []
_log(f" -> {len(raw_items)} results for #{hashtag}")
@@ -261,30 +243,17 @@ def _profile_videos(
"""
_log(f"Profile videos: @{handle}")
profile_url = "https://api.scrapecreators.com/v3/tiktok/profile/videos"
if not _requests:
try:
from urllib.parse import urlencode
params = urlencode({"handle": handle, "sort_by": "latest"})
url = f"{profile_url}?{params}"
headers = 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"Profile videos error (urllib) for @{handle}: {e}")
return []
else:
try:
resp = _requests.get(
profile_url,
params={"handle": handle, "sort_by": "latest"},
headers=http.scrapecreators_headers(token),
timeout=30,
)
resp.raise_for_status()
data = resp.json()
except Exception as e:
_log(f"Profile videos error for @{handle}: {e}")
return []
try:
data = http.get(
profile_url,
params={"handle": handle, "sort_by": "latest"},
headers=http.scrapecreators_headers(token),
timeout=30,
retries=2,
)
except Exception as e:
_log(f"Profile videos error for @{handle}: {e}")
return []
raw_items = data.get("aweme_list") or data.get("data") or []
_log(f" -> {len(raw_items)} videos from @{handle}")
@@ -318,31 +287,17 @@ def search_tiktok(
_log(f"Searching TikTok 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, "sort_by": "relevance"})
url = f"{SCRAPECREATORS_BASE}/search/keyword?{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/keyword",
params={"query": core_topic, "sort_by": "relevance"},
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/keyword",
params={"query": core_topic, "sort_by": "relevance"},
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 nested under aweme_info
raw_entries = data.get("search_item_list") or data.get("data") or []
@@ -397,7 +352,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]
@@ -422,24 +377,23 @@ def fetch_captions(
if not url:
continue
try:
resp = _requests.get(
data = http.get(
f"{SCRAPECREATORS_BASE}/video/transcript",
params={"url": url},
headers=http.scrapecreators_headers(token),
timeout=15,
retries=1,
)
if resp.status_code == 200:
data = resp.json()
transcript = data.get("transcript")
transcript = data.get("transcript")
if transcript:
if isinstance(transcript, list):
transcript = " ".join(str(s) for s in transcript)
transcript = _clean_webvtt(transcript)
if transcript:
if isinstance(transcript, list):
transcript = " ".join(str(s) for s in transcript)
transcript = _clean_webvtt(transcript)
if transcript:
words = transcript.split()
if len(words) > CAPTION_MAX_WORDS:
transcript = ' '.join(words[:CAPTION_MAX_WORDS]) + '...'
captions[vid] = transcript
words = transcript.split()
if len(words) > CAPTION_MAX_WORDS:
transcript = ' '.join(words[:CAPTION_MAX_WORDS]) + '...'
captions[vid] = transcript
except Exception as e:
_log(f"Transcript fetch failed for {vid}: {e}")
@@ -620,30 +574,17 @@ def _fetch_post_comments(
List of comment dicts with author, text, digg_count (likes), date.
Empty list on any error comment failures never crash the pipeline.
"""
if not _requests:
try:
from urllib.parse import urlencode
params = urlencode({"url": post_url, "trim": "true"})
url = f"{SCRAPECREATORS_BASE}/video/comments?{params}"
headers = http.scrapecreators_headers(token)
headers["User-Agent"] = http.USER_AGENT
data = http.get(url, headers=headers, timeout=30, retries=2)
except Exception as exc:
_log(f"Comment fetch error (urllib) for {post_url}: {exc}")
return []
else:
try:
resp = _requests.get(
f"{SCRAPECREATORS_BASE}/video/comments",
params={"url": post_url, "trim": "true"},
headers=http.scrapecreators_headers(token),
timeout=30,
)
resp.raise_for_status()
data = resp.json()
except Exception as exc:
_log(f"Comment fetch error for {post_url}: {exc}")
return []
try:
data = http.get(
f"{SCRAPECREATORS_BASE}/video/comments",
params={"url": post_url, "trim": "true"},
headers=http.scrapecreators_headers(token),
timeout=30,
retries=2,
)
except Exception as exc:
_log(f"Comment fetch error for {post_url}: {exc}")
return []
raw_comments = data.get("comments") or data.get("data") or []
# Sort by digg_count desc so normalize sees the highest-signal first.
@@ -124,6 +124,7 @@ SOURCE_COMPLETION_ORDER = [
"polymarket",
"grounding",
"xiaohongshu",
"digg",
]
SOURCE_COMPLETION_META = {
@@ -138,6 +139,7 @@ SOURCE_COMPLETION_META = {
"polymarket": ("Polymarket", "market", "markets", Colors.GREEN),
"grounding": ("Web", "result", "results", Colors.GREEN),
"xiaohongshu": ("Xiaohongshu", "post", "posts", Colors.RED),
"digg": ("Digg", "cluster", "clusters", Colors.YELLOW),
}

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