Compare commits

...

27 Commits

Author SHA1 Message Date
Matt Van Horn 5864c687a3 fix: hoist inline-link citation into LAW 8
Four live test runs on 2026-04-20 (Matt Van Horn, Peter Steinberger,
Best Headphones, OpenClaw vs Hermes) confirmed PR #289's citation rule
was deployed (diff IN SYNC, grep found it) but consistently skipped on
first-pass synthesis. Agent's own root cause, repeated verbatim across
all four runs: "SKILL.md is 45K tokens and fails a single Read. I read
offsets 1-200, 200-600, 600-1000, then stopped and ran the engine. The
inline-link rule lives at line 1224 of a 1523-line file. I never
reached it."

This is the exact failure mode the VOICE CONTRACT LAW block at line 97
was created to prevent. LAWs 1-7 were hoisted in v3.0.7/3.0.8 because
the file is too long to read top-to-bottom before synthesis. The
inline-link rule in PR #289 was added at line 1224 and never joined the
LAWs, so it lives below the chunked-read window and reliably gets
skipped. Same pattern as v3.0.6 (invented titles), disaster #2 (stripped
bold), disaster #3 (trailing Sources), and the 2026-04-19 Hermes
evidence-dump disaster. Same fix pattern: add the rule to the LAWs
block with the established anatomy.

Changes:

- Add LAW 8 at line 167, inside the VOICE CONTRACT LAW block. Full
  LAW-style shape: loud one-line rule, "applies to every query type",
  mechanism sentence, plain-text fallback clause, BAD/BAD/BAD/GOOD/
  FALLBACK example set, named incident reference (2026-04-20 inline-
  links saga), post-synthesis self-check.
- Update preamble at line 101 from "These five rules" to "These LAWs"
  (stale since LAWs 6-7 were added; fixed in the same commit).
- Convert the old CITATION PRIORITY / URL FORMATTING block at line 1218
  into a short pointer to LAW 8 plus the citation-priority ordering list
  (which is a preference, not the correctness rule, so it can live
  lower). Narrative BAD/GOOD examples stay in place with a back-ref
  line: "(These narrative examples illustrate LAW 8 from the VOICE
  CONTRACT.)"
- Single source of truth preserved: rule text lives exactly once in the
  LAWs block; lower references point back.

Does not touch: LAWs 1-7, LAW numbering, deterministic engine footer,
PASS-THROUGH FOOTER boundaries, comparison scaffold, mandatory badge,
em-dash/en-dash prohibition, no-## header rule, or any other structural
contract.

Verification pending: one fresh Cmd-Q session in Ghostty, then
/last30days Matt Van Horn to confirm first-pass inline links without a
correction round.

Plan: docs/plans/2026-04-20-005-fix-hoist-citation-law-plan.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 09:28:46 -07:00
Matt Van Horn 790e5bc26a feat: inline markdown links on narrative citations
Citation rule inverted: every @handle, r/sub, publication, YouTube channel,
TikTok/Instagram creator, and Polymarket market cited in "What I learned"
and KEY PATTERNS is now an inline markdown link [name](url). URLs come
from the raw research dump. Claude Code renders [text](url) as blue
CMD-clickable text with the URL hidden.

Raw URL strings remain forbidden. Plain text is the fallback only when
the raw data has no URL for a specific source. Broken empty links
[name]() are explicitly called out as bad.

Scope:
- Updates CITATION PRIORITY to show each item as a markdown link.
- Updates URL FORMATTING rule: was "NEVER paste raw URLs", now "every
  citation is [name](url), never a raw URL string".
- Updates BAD/GOOD narrative examples to show linked @handles and r/subs.
- Updates the What-I-learned / KEY-PATTERNS template placeholders.
- Adds one sentence noting the engine-emitted stats footer (LAW 5) is
  pass-through only - agent does NOT format its links.

Does not touch: LAWs 1-7, deterministic engine footer, PASS-THROUGH
FOOTER boundaries, comparison scaffold, badge rules, em-dash/en-dash
prohibition, no-## header rule, or any other existing structural
enforcement.

Net change: +29 / -24 lines, one contiguous SKILL.md region.

Context: prior attempt (PR #286, closed) branched off a stale main and
accumulated three failed prompt-enforcement amendments on top. This
commit is a fresh start against current main, applying only the minimal
link-rule edit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 08:33:22 -07:00
Matt Van Horn 1da9c601c3 Merge pull request #285 from mvanhorn/fix/output-contract-planner-breadth
fix: output contract + planner breadth + entity grounding (Hermes Agent Use Cases)
2026-04-19 11:09:56 -07:00
Matt Van Horn 4388fed46a fix: rewrite 'no LLM provider' stderr to stop the capability-constraint misread
PR #285 introduced the stderr warning "No --plan and no LLM provider
configured. Using deterministic fallback..." The 2026-04-19 Run 1
agent self-debug said it read that as "I don't have a key, I can't do
LLM stuff, I have to accept fallback" - which is the exact wrong
mental model. The word "provider" referred to the engine's INTERNAL
planner credentials, but the agent parsed it as "I need credentials
to plan at all."

Rewritten to say plainly: YOU are the reasoning model hosting this
skill (Claude Code, Codex, Hermes, Gemini, or any agent runtime);
YOU ARE the planner; you do not need an API key or credentials - 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
deterministic fallback is the headless/cron path only.

Runtime enumeration is explicit so agents on every supported runtime
recognize themselves - this skill ships to Claude Code, Codex, Hermes,
and ~/.agents via sync.sh.

Tests: updated test_fallback_logs_warning_when_no_provider to assert
the new language (YOU ARE the planner, runtime names present) and
assert the old misleading phrasing is absent. Renamed the companion
test for clarity.
2026-04-19 10:28:48 -07:00
Matt Van Horn a7d6ef051a fix: expand entity-grounding haystack to transcripts + top comments
PR #285's entity grounding checked only title + snippet. That missed:

- YouTube videos where the entity is mentioned in transcript but not
  in title (false demotion of on-topic content)
- Reddit posts where the entity is in top comments but not in title
  (false demotion of on-topic discussion)

And it also wasn't strong enough to reliably demote items like the
2026-04-19 Nate Herk "Managed Agents" video - which had no Hermes
anywhere - because the -25 penalty on rerank_score composed to only
-15 on final_score via the 0.60 weight, and engagement bonus partially
offset that.

Two fixes:

1. _candidate_haystack() now joins title + snippet +
   metadata[transcript_snippet] + metadata[transcript_highlights] +
   metadata[top_comments][*].excerpt/text + metadata[comment_insights].
   Catches entity mentions wherever they actually live. Guarded with
   isinstance checks so malformed metadata doesn't raise.
2. ENTITY_MISS_FINAL_PENALTY (20.0) applied directly in _final_score
   when candidate.explanation contains "entity-miss". This lands the
   full penalty weight on the composite signal that cluster-scoring
   consumes, instead of being diluted by the rerank_score weight.
   Combined effect: entity-miss gap grows from ~15 to ~35 points.

Tests: 8 new scenarios covering transcript match, transcript highlight
match, top-comment match, comment-insight match, empty-text skip,
no-primary-entity no-op, and the dual-penalty composition check.
2026-04-19 10:28:36 -07:00
Matt Van Horn b7df5ecd2d fix: emit user-visible DEGRADED RUN WARNING on bare named-entity calls
The stderr [Planner] warning from PR #285 doesn't reach the user because
Claude and other reasoning agents hide stderr from their synthesis. The
2026-04-19 Hermes Agent Use Cases Run 1 produced source=deterministic
and the user never saw it.

Adds a user-visible stdout block that the model's LAW 5 pass-through
contract forces into the response. Fires only when plan_source is
deterministic AND no pre-research flags were passed AND the topic is
pre-research-eligible (named entity). Cron jobs on abstract topics
don't trigger it.

Position: BEFORE the EVIDENCE FOR SYNTHESIS envelope so the model sees
it as the first non-badge content. Wrapped in a new USER-VISIBLE BANNER
envelope matching the EVIDENCE/PASS-THROUGH envelope pattern from Unit 1
of PR #285.

Runtime-agnostic language: explicitly enumerates Claude Code, Codex,
Hermes, Gemini so the hosting reasoning model recognizes itself
regardless of runtime.

pipeline.py now persists plan_source to report.artifacts so the
renderer can consume it. Adds 7 tests covering fire conditions,
suppression conditions (external/llm plan source, flags present,
abstract topic), and correct position relative to the evidence envelope.
2026-04-19 10:28:21 -07:00
Matt Van Horn a0d61b0dc6 fix: add LAW 7 - YOU ARE the planner, --plan mandatory on named entities
Run 1 of /last30days Hermes Agent use cases on 2026-04-19 called the engine
bare despite SKILL.md already having a detailed Step 0.75 (YOU are the
planner) and a PRECONDITION GATE requiring --plan. Those lived at lines
647 and 729 - the model didn't reach them before invoking Bash.

LAW 7 hoists the rule into the OUTPUT CONTRACT block at the top (same
placement pattern as LAW 6), so it is the first thing the model reads.
Runtime-agnostic language: Claude Code, Codex, Hermes, Gemini, or any
agent runtime. Named failure mode with the misread diagnosis: "provider"
in engine messages refers to the engine's INTERNAL planner credentials,
NOT a prerequisite the caller needs - if you are the hosting reasoning
model, YOU are the provider.

Concrete self-check: re-read pending Bash command; if no --plan and topic
is a named entity, STOP and generate a plan.
2026-04-19 10:28:08 -07:00
Matt Van Horn 5f218aaac5 fix: always log planner subqueries to stderr
The prior pipeline.py only logged the planner outcome when an external
--plan was passed ("[Planner] Using external plan (N subqueries)").
The internal LLM planner and the deterministic fallback ran silently,
so retrieval-breadth failures were invisible without --debug.

After plan finalization, emit a unified trace:

  [Planner] Plan: intent=X, freshness=Y, cluster_mode=Z, subqueries=N, source=external|llm|deterministic
  [Planner]   sq1 label=... search="..." sources=[...]
  [Planner]   sq2 ...

Stderr only; does not touch the user-facing stdout synthesis. The
source= annotation distinguishes --plan (external), provider-backed
(llm), and deterministic paths — so when the 2026-04-19 Hermes Agent
Use Cases failure mode recurs, the trace tells the user which path ran
and what subqueries it produced.

Tests: added test_planner_trace_always_fires_on_mock_run which captures
stderr on a mock pipeline run and asserts the summary + per-subquery
lines appear.
2026-04-19 09:24:52 -07:00
Matt Van Horn a709d66e2a fix: demote reranker candidates that miss the primary entity
The 2026-04-19 Hermes Agent Use Cases run had a Nate Herk YouTube video
titled "I Tested Claude's New Managed Agents" score 51 and rank #2
with zero Hermes content. The reranker had intent-specific scoring hints
but no entity-grounding check, so topic-vicinity matches (one offhand
OpenClaw mention) drifted to the top.

Add _primary_entity(topic) that strips intent-modifier suffixes ("use
cases", "workflows", etc.) so "Hermes Agent use cases" yields
primary_entity="Hermes Agent". Pass the entity through to both the LLM
and fallback scoring paths.

Fallback path: if primary_entity is not found (case-insensitive) in
title + snippet, subtract ENTITY_MISS_PENALTY (25 pts). Skip the
demotion for candidates with no text at all (image-only TikToks etc.)
to avoid false negatives on thin-text sources.

LLM path: add a "Primary entity grounding" hint to _build_prompt when
primary_entity is non-empty. Instructs the LLM to score candidates
without the entity at <=30.

Tests: 24 rerank tests pass, including 8 new entity-grounding tests.
2026-04-19 09:24:43 -07:00
Matt Van Horn 4d9f29d2ed fix: broaden planner retrieval and fix deterministic fallback defaults
Topics with suffixes like "use cases", "workflows", "review",
"examples" were previously echoed near-verbatim into search_query,
returning near-zero matches because nobody posts the literal phrase
(2026-04-19 Hermes Agent Use Cases failure).

Unit 2 — planner breadth:

1. Planner prompt rule: STRIP intent-modifier phrases from search_query
   (keep them in ranking_query). Paraphrase across 4-5 subqueries that
   each express the intent differently.
2. Planner prompt rule: quote only multi-word proper nouns like
   "Hermes Agent", not the user's full topic.
3. Raise _max_subqueries cap from 3 to 5 for how_to / opinion / product /
   breaking_news / prediction. Comparison stays at 4; factual / concept
   stay at 2 unless the topic carries an intent modifier.
4. Deterministic fallback: when intent is non-{comparison,prediction}
   and topic contains an intent modifier, append 3 paraphrased
   subqueries (workflows, production, experience).

Unit 3 — deterministic fallback defaults:

5. _infer_intent default changed from "breaking_news" to "concept".
   Prior default forced strict_recent freshness on unclassified topics,
   biasing against older relevant material. Recency-signal regexes
   ("trending", "this week", etc.) added above the default so genuinely
   time-sensitive topics still classify correctly.
6. _keyword_query now quotes only title-cased multi-word proper nouns
   ("Hermes Agent", "Claude Code"), not the user's full typed topic.
   Hyphenated compounds and lowercase terms are left as bare keywords
   so platform tokenizers broaden rather than narrow retrieval.
7. New stderr warning when plan_query runs with no --plan and no LLM
   provider: surfaces that the deterministic fallback path is weaker
   than the --plan-from-Claude-Code path, so callers know to generate
   and pass a plan.

Tests: 37 planner tests pass, including 11 intent-modifier and 7
fallback-defaults tests.
2026-04-19 09:24:30 -07:00
Matt Van Horn 52fb0e50cb fix: scope pass-through to footer only, add LAW 6 against raw cluster dumps
The engine's ## Ranked Evidence Clusters block is a scratchpad for the
model to read, not user-facing output. Two consecutive /last30days runs
on 2026-04-19 (Hermes Agent Use Cases) dumped it verbatim as user output
because the prior canonical-boundary text (Pass through the lines ABOVE
this boundary verbatim) was ambiguous about scope.

Split render_compact stdout into two bounded blocks:

- <!-- EVIDENCE FOR SYNTHESIS: ... --> wraps Ranked Evidence Clusters,
  Stats, and Source Coverage. Transform into prose per LAW 2.
- <!-- PASS-THROUGH FOOTER: ... --> wraps the emoji-tree footer only.
  Emit verbatim per LAW 5.

Rewrite _render_canonical_boundary to scope pass-through to the footer
block explicitly and give the model a concrete self-check string
(### 1. followed by a score tuple) as the named LAW 6 failure signal.

Add LAW 6 to SKILL.md OUTPUT CONTRACT with the observed violation
(2026-04-19 Hermes Agent Use Cases) and a worked transformation example.
2026-04-19 09:23:55 -07:00
Matt Van Horn f635f78e4a Merge pull request #281 from mvanhorn/docs/v3.0.9-release-notes
Release / build-and-release (push) Has been cancelled
docs: v3.0.9 release notes - The Self-Debug Release
2026-04-18 14:12:40 -07:00
Matt Van Horn a070a584a4 docs: v3.0.9 release notes - The Self-Debug Release
Adds docs/releases/v3.0.9.md as the GitHub Release body and appends
the matching CHANGELOG.md entry.

Covers what shipped in v3.0.9 (Class 1 refuse-gate, LAW 1 WebSearch
precedence, END-boundary, stale SKILL.md deletion) plus the community
contributions that landed across 3.0.1-3.0.8 that had never been
announced (TikTok + YouTube top comments, Hermes support, multi-key
rotation, cross-platform fixes, HTTP layer consolidation, eval
fixtures).

Contributors credited: @j-sperling, @stephenmcconnachie, @zaydiscold,
@iliaal, @Chelebii, @Gujiassh, @hnshah, @george231224, @shalomma,
@BryanTegomoh, @uppinote20, @zerone0x, @thinkun, @thomasmktong,
@fanispoulinakisai-boop, @pejmanjohn, @zl190, @Jah-yee, @dannyshmueli,
@Cody-Coyote.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 14:12:20 -07:00
Matt Van Horn 8e18d0142c Merge pull request #280 from mvanhorn/fix/v3.0.9-engine-refuse-stale-skillmd
fix: v3.0.9 - engine refuses Class 1 keyword traps, delete stale SKILL.md files, reinforce LAW 1 over WebSearch
2026-04-18 13:40:37 -07:00
Matt Van Horn e8105df4fd fix: v3.0.9 - engine refuses Class 1 keyword traps, delete stale SKILL.md files, reinforce LAW 1 over WebSearch
Five Opus 4.7 self-debugs on v3.0.8 (3 passing, 2 failing runs) converged
on four fixes:

1. Engine refuses Class 1 demographic-shopping queries at main() front-door.
   Birthday-gift failure mode becomes structurally impossible - the pipeline
   never runs on a doomed query. Exit code 2 with a REFUSE message on stderr
   pointing the model to ask for hobbies/relationship/budget. Escape hatch:
   LAST30DAYS_SKIP_PREFLIGHT=1 for "just run it" overrides.

2. Delete stale `.agents/skills/last30days/SKILL.md` (1382 lines, April 13
   snapshot) and `.hermes-plugin/SKILL.md` (269 lines, April 13 snapshot).
   Peter Steinberger's self-debug named the first file as the one it read
   instead of the real SKILL.md. One SKILL.md per plugin, at the plugin root.
   Sync script simplified: Hermes now always uses main SKILL.md.

3. render_compact() appends an explicit END-OF-CANONICAL-OUTPUT boundary
   with pass-through instruction. The model had the canonical body in its
   buffer on the Peter run and discarded it; the boundary makes pass-through
   the path of least resistance.

4. LAW 1 gains a verbatim-pattern override clause naming the exact WebSearch
   tool-result reminder ("CRITICAL REQUIREMENT: MUST include Sources:
   section") that caused Peter's trailing Sources leak. No more ambiguity
   at synthesis time.

Tests: tests/test_preflight.py, 29 scenarios covering Class 1 matches
(birthday gift, best-for-demographic, what-to-buy-relationship), qualifier
skips (budget, hobbies, activity after year-old), and the REFUSE message
shape.

Validation gate before merging to main: re-run the 5 debug topics
(Peter Steinberger, birthday gift for 40 year old, Kanye West, Garry Tan,
OpenClaw vs Paperclip vs Hermes) on v3.0.9 and confirm 5/5 canonical
compliance. Rollback to v3.0.8 if any previously-passing topic regresses.

Plan: docs/plans/2026-04-18-015-fix-engine-refuse-keyword-traps-delete-stale-skillmd-files-plan.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 13:30:33 -07:00
Matt Van Horn 361e9d6c13 fix: v3.0.8 - SKILL.md was too big and LAWs too deep - move to top + engine emits badge (#279)
Three independent Opus 4.7 self-debugs on 2026-04-18 converged on the same
root cause of the v3.0.6/v3.0.7 canonical-compliance regression: SKILL.md is
42,860 tokens / 1,478 lines, LAWs lived at line 1094+, every realistic reading
strategy failed to reach them before synthesis.

Unit 1 - Moved the BADGE MANDATORY block and VOICE CONTRACT LAW 1-5 (plus
the formatting-authority preface) from line ~1090 to line ~75 (right after
the SKILL CONTRACT preface, before HOW TO INVOKE THIS SKILL). Every reading
strategy now lands the LAWs in active context before synthesis.

Unit 2 - Engine now emits the badge as the first line of --emit=compact
stdout. Passing through the script output becomes the default-correct
behavior; emitting the badge no longer depends on model compliance. Reads
version from .claude-plugin/plugin.json at runtime with graceful fallback.

Unit 3 - Deleted skills/last30days/SKILL.md stub (231-line v3-spec file).
This was the wrong-file-capture hazard Ron Conway's self-debug identified:
model grabbed the first SKILL.md find surfaced and treated it as
authoritative. Only ONE SKILL.md in the plugin package now.

Diagnoses verbatim:
- Kanye thread: "I read lines 1-600 in chunks, jumped to 300-899, then
  stopped. File is 1478 lines. I never saw past ~900."
- Peter thread: "I tried Read once, hit the 25K token cap on a 42,860-token
  file, and bailed instead of chunked-reading with offset/limit. I never
  opened SKILL.md at all."
- Ron Conway thread: "I read one SKILL.md (231 lines)... the v3 spec stub.
  I never opened the operational SKILL.md sitting next to the script."

Validation: direct engine invocation confirms badge at line 1 of compact
output. Module imports clean.

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-04-18 12:50:10 -07:00
Matt Van Horn 58845df312 fix: v3.0.7 - restore mandatory first-line badge + pin SKILL_ROOT + add skill-specificity anchor (#278)
Hot-fix for the public v3.0.6 0/8 regression (2026-04-18). Beta went 10/10
yesterday with the same LAW content; public went 0/8 today. The delta was
three structural anchors the port had removed or weakened.

Unit 1 - Restored MANDATORY first-line badge. Every public response now
emits "🌐 last30days v{VERSION} · synced {YYYY-MM-DD}" as line 1, blank
line, then "What I learned:" (GENERAL) or "# {TOPIC_A} vs {TOPIC_B}..."
(COMPARISON). This is the LAW 2 / LAW 4 enforcement anchor that my v3.0.6
port accidentally stripped along with the beta-specific "🧪 last30days-beta"
wording.

Unit 2 - Pinned SKILL_ROOT to the public plugin cache via
`ls -d ~/.claude/plugins/cache/last30days-skill/last30days/*/ | sort -V |
tail -1`, with a small fallback for repo/Gemini/Codex hosts. Replaces the
path-discovery loop that was landing on stale copies (~/.openclaw/,
~/.agents/, ~/.codex/) on machines with a private-repo sync history.

Unit 3 - Added a "SKILL CONTRACT" preface at the top of SKILL.md that names
the 0/8 regression as a documented failure mode and explicitly tells the
model not to treat /last30days as a generic keyword. Encodes user theory
that "/last30days-beta" sounded specific enough to trigger skill-follow
mode while "/last30days" reads as a search term and triggers improvise
mode.

Validation: all three anchors visible in the grep check for public
v3.0.7 cache. Next validation is manual re-run of the 8 failure topics
on public after shipping.

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-04-18 10:55:28 -07:00
Matt Van Horn d14814a9b0 feat: release v3.0.6 - promote plans 003-009 from private beta to public (#277)
Consolidates seven beta-validated plans into the public release. Validated
on nine+ topics across GENERAL, COMPARISON, RECOMMENDATIONS, and
demographic-shopping classes before ship.

Plans bundled in this release:

- 003 Engine-emitted Pre-Research Status warning + Polymarket summarization
  + VOICE CONTRACT LAW 1-5 + Step 0.55 MANDATORY
- 004 WebSearch deferred-tool loading (ToolSearch STEP 0) + LAW 5 universal
  + top-of-file imperative
- 005 Supplement floor (2-3 minimum) separate from Step 0.55 pre-research
- 006 Step 2.5 MANDATORY raw-file append with canonical format example +
  count-equality self-check
- 007 Restored April 9 canonical comparison template with Quick Verdict,
  per-entity Strengths/Weaknesses, 9-axis Head-to-Head, Bottom Line,
  emerging stack + LAW 2/4 COMPARISON exceptions
- 008 Person-topic GitHub handle resolution MANDATORY + LAW 1 reinforcement
  at Step 2 tail and Step 2.5 entry + RECOMMENDATIONS signal-weighted
  ranking rewrite + Polymarket post-merge topic filter (engine change,
  filter_items_against_topic helper + vs/versus in _NOISE_WORDS)
- 009 Unified pre-flight CHECKLIST + VOICE CONTRACT formatting-authority
  preface + Step 0.45 Query Quality Pre-Flight (4 keyword-trap classes) +
  post-synthesis Sources-block self-check

Beta validation topics (2026-04-18): Kanye West, Matt Van Horn, CLI vs MCP,
OpenClaw vs Paperclip vs Hermes, Paperclip vs Hermes vs Open Claw, Garry
Tan, Israel vs Lebanon, Best programming language for AI agents, Peter
Steinberger post plan 009, Birthday gift for 42 year old man (Class 1
pre-flight fired correctly), Vincent Koc (passed).

No breaking changes. No new CLI flags. No new public API. Plugin name
(last30days) and marketplace name (last30days-skill) unchanged.

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-04-18 10:24:14 -07:00
Matt Van Horn e9911ae2ae Merge pull request #276 from mvanhorn/feat/beta-channel-wiring
feat: wire compare.sh and CLAUDE.md for /last30days-beta channel
2026-04-17 22:44:31 -04:00
Matt Van Horn 7cee41509f feat: wire compare.sh and CLAUDE.md for /last30days-beta channel
- scripts/compare.sh now runs /last30days vs /last30days-beta (was
  /last30days vs /last30days-3:last30days-skill-private which was a stale
  private install name that no longer works)
- CLAUDE.md adds a Beta channel section pointing at mvanhorn/last30days-skill-private
  so future agent sessions discover the two-skill layout on project load

No runtime impact on /last30days. Engine code unchanged.

Plan: docs/plans/2026-04-17-005-feat-beta-skill-from-private-repo-plan.md
(plan file is gitignored per PR #259, not included in this diff)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 22:43:29 -04:00
Matt Van Horn 371f62a403 Revert "feat: make default fun level actually surface comedy (#272)" (#273)
This reverts commit bad1d312ef.

Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
2026-04-17 08:39:56 -04:00
Matt Van Horn bad1d312ef feat: make default fun level actually surface comedy (#272)
Most users never touch FUN_LEVEL. Default medium was shipping a stats
block but rarely a Best Takes block, and when it did it was below the
cluster fold where a synthesizing model had already stopped reading.
A 2,304-upvote Reddit comment ("WHAT?! I reached my monthly limit
just reading this post") on the 2026-04-17 Opus 4.7 run sat inside
cluster 11 and never made it into synthesis. Four coordinated changes:

1. render: promote Best Takes above the cluster list so the synthesizer
   sees comedy before it anchors on cluster 1.
2. render: lower medium threshold from 70 to 55 (heuristic maxes at 80),
   drop the two-gem floor to one-gem. Default now reliably emits the
   block on typical runs.
3. rerank: score individual top_comments by upvote ratio to their parent
   thread. A 2,304-upvote comment on a 300-upvote thread now outranks a
   400-upvote comment on a 3,400-upvote thread, which is the viral-wit
   signal. Handles both the LLM scoring path and the heuristic fallback.
4. render: merge scored comment gems into Best Takes alongside candidate
   gems, sorted together. Comment lines show body + parent title +
   r/subreddit or @handle + absolute upvotes.
5. SKILL: tell the synthesizer to quote at least two Best Takes entries
   verbatim, with an example of the new comment format.

Plan: docs/plans/2026-04-17-001-feat-default-fun-surfacing-plan.md

🤖 Generated with Claude Opus 4.7 (1M context) via [Claude Code](https://claude.com/claude-code) + Compound Engineering v2.56.1

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-04-17 08:30:39 -04:00
Matt Van Horn 0103324701 Merge pull request #268 from zaydiscold/feat/multi-key-rotation
feat: multi-key rotation for SCRAPECREATORS_API_KEY
2026-04-16 23:48:44 -04:00
zayd f09c6850bc feat: multi-key rotation for SCRAPECREATORS_API_KEY
Support comma-separated API keys in SCRAPECREATORS_API_KEY with random
selection per run, distributing load across multiple free-tier accounts.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-15 17:37:25 -07:00
Matt Van Horn 3499c246b8 fix: add commands/last30days.md and remove skills/last30days-nux duplicate (#267)
Release / build-and-release (push) Has been cancelled
Adds commands/last30days.md so /last30days registers as a Claude Code
slash command for plugin users. Users type /last30days and autocomplete
prefix-matches to the canonical /last30days:last30days form (same as
/ce:plan resolving to /compound-engineering:ce-plan).

Removes skills/last30days-nux/, a byte-identical duplicate of the root
SKILL.md that created confusing /last30days:last30days-nux autocomplete
entries via Claude Code's plugin namespacing. Root SKILL.md remains
the canonical skill source; natural-language skill-selector invocation
is unchanged.

Recovery for users on v3.0.4: /plugin update last30days then /reload-plugins.

Closes #239 (path-escape error was already fixed in v3.0.4 by dropping
the rogue 'skills' key; v3.0.5 adds the slash command on top).
Supersedes #257 (suggested './' -> '.' workaround is obsolete since
v3.0.4 dropped the 'skills' key entirely, matching ecosystem standard).

Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
2026-04-15 15:19:42 -04:00
Matt Van Horn 53b8e33d13 fix(youtube): use url= param for ScrapeCreators comments/transcript + parse new response shape (#265)
PR #260 wired YouTube comment enrichment against
`/v1/youtube/video/comments` with `id=<video_id>`, but the endpoint
requires `url=https://www.youtube.com/watch?v=<video_id>`. Every enrich
call was returning 400 "missing_parameter: you must provide a url", so
no YouTube items ever carried `top_comments`.

The SC transcript fallback (`_sc_fetch_transcript`) had the identical
contract mistake. It was latent because `_fetch_transcript` prefers
yt-dlp and the SC path only fires when yt-dlp is missing, but it would
have failed the same way on hosts without yt-dlp installed.

Switching both callers to `url=` surfaces a second issue in the
response parser: SC returns `author` as `{"name": "@handle", ...}` and
nests like counts under `engagement.likes`, not top-level. The parser
was reading `author` as a string and missing the nested likes, so even
after the param fix every comment would land with an object-shaped
author and 0 likes.

- `_fetch_video_comments`: send `url=` on both urllib and requests branches
- `_sc_fetch_transcript`: same
- Response parser: extract `author.name` when author is a dict, read
  `engagement.likes` when top-level `likes` is absent, prefer
  `publishedTime` / `publishedTimeText` for date. Legacy string-author
  and top-level-likes shapes still work, so existing mocks are unchanged.

Verified live against api.scrapecreators.com: `_fetch_video_comments`
now returns fully-populated comments with real @handles and like
counts (e.g. "@JennyNicholson: ... (49000 likes, 2025-04-15)"). All
tests in youtube_yt/normalize/signals/render pass.

Plan: docs/plans/2026-04-15-002-fix-youtube-comments-scrapecreators-param-plan.md

🤖 Generated with Claude Opus 4.6 (1M context) via [Claude Code](https://claude.com/claude-code) + Compound Engineering v2.56.1

Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 15:17:22 -04:00
Matt Van Horn 73b4bd6ac6 fix: enforce pre-research protocol + override WebSearch Sources mandate (#266)
Restore the rich synthesis output by closing three prompt-level loopholes
that let the model silently take a degraded path:

1. Research Execution precondition gate. Steps 0.55 (entity resolution)
   and 0.75 (query planner) are now non-skippable on WebSearch platforms.
   --emit md is banned as a primary user-facing flow; --emit=compact with
   --plan is mandatory. OpenClaw --auto-resolve fallback preserved.

2. WebSearch "Sources:" mandate override. The WebSearch tool description
   contains a CRITICAL/MUST mandate to append a Sources section. That is
   explicitly superseded inside /last30days with matched-register
   CRITICAL/MANDATORY override language and a BAD/GOOD example. The
   existing web-source line is the citation; nothing appends below the
   invitation.

3. Pre-present self-check. Before displaying, the model verifies bold
   per-paragraph headlines, per-source emoji stats, quoted highlights,
   Polymarket block, coverage footer, and (critically) no trailing
   Sources block. One regeneration permitted if checks fail.

Also adds explicit MANDATORY language to the "What I learned" template
requiring bold headline phrases on every narrative paragraph.

Root cause: same-session A/B on 2026-04-15 between /last30days kanye
west (rich output, ran Steps 0.55 + 0.75, --emit=compact --plan) and
/last30days hermes ai (bland output, skipped both, --emit md) showed
the template was fine -- the model was lazily taking a shortcut SKILL.md
tolerated. No engine, render.py, or contributor PR was the cause.

Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 15:15:29 -04:00
27 changed files with 2666 additions and 2385 deletions
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -10,7 +10,7 @@
{
"name": "last30days",
"description": "Research any topic across Reddit, X, YouTube, TikTok, Instagram, HN, Polymarket, GitHub, and 5+ more sources.",
"version": "3.0.4",
"version": "3.0.9",
"author": {
"name": "Matt Van Horn",
"url": "https://github.com/mvanhorn"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "last30days",
"version": "3.0.4",
"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.",
"author": {
"name": "Matt Van Horn",
-269
View File
@@ -1,269 +0,0 @@
---
name: last30days
version: "3.0.0"
description: "Multi-query social search with intelligent planning. Research any topic across Reddit, X, YouTube, TikTok, Instagram, Hacker News, Polymarket, and the web."
argument-hint: 'last30days AI video tools, last30days best noise cancelling headphones'
allowed-tools: Bash, Read, Write, AskUserQuestion, WebSearch
homepage: https://github.com/mvanhorn/last30days-skill
repository: https://github.com/mvanhorn/last30days-skill
author: mvanhorn
license: MIT
user-invocable: true
metadata:
hermes:
emoji: "📰"
tags:
- research
- deep-research
- reddit
- x
- twitter
- youtube
- tiktok
- instagram
- hackernews
- polymarket
- trends
- recency
- news
- citations
- multi-source
- social-media
- analysis
- web-search
requires:
env:
- SCRAPECREATORS_API_KEY
optionalEnv:
- OPENAI_API_KEY
- XAI_API_KEY
- OPENROUTER_API_KEY
- PARALLEL_API_KEY
- BRAVE_API_KEY
- APIFY_API_TOKEN
- AUTH_TOKEN
- CT0
- BSKY_HANDLE
- BSKY_APP_PASSWORD
- TRUTHSOCIAL_TOKEN
bins:
- node
- python3
primaryEnv: SCRAPECREATORS_API_KEY
files:
- "scripts/*"
homepage: https://github.com/mvanhorn/last30days-skill
---
# last30days v3.0.0: Research Any Topic from the Last 30 Days
> **Permissions overview:** Reads public web/platform data and optionally saves research briefings 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.
Research ANY topic across Reddit, X, YouTube, and other sources. Surface what people are actually discussing, recommending, betting on, and debating right now.
## Runtime Preflight
Before running any `last30days.py` command in this skill, resolve a Python 3.12+ interpreter once and keep it in `LAST30DAYS_PYTHON`:
```bash
for py in python3.14 python3.13 python3.12 python3; do
command -v "$py" >/dev/null 2>&1 || continue
"$py" -c 'import sys; raise SystemExit(0 if sys.version_info >= (3, 12) else 1)' || continue
LAST30DAYS_PYTHON="$py"
break
done
if [ -z "${LAST30DAYS_PYTHON:-}" ]; then
echo "ERROR: last30days v3 requires Python 3.12+. Install python3.12 or python3.13 and rerun." >&2
exit 1
fi
```
## Step 0: First-Run Setup Wizard
**CRITICAL: ALWAYS execute Step 0 BEFORE Step 1, even if the user provided a topic.** If the user typed `last30days Mercer Island`, you MUST check for FIRST_RUN and present the wizard BEFORE running research. The topic "Mercer Island" is preserved — research runs immediately after the wizard completes. Do NOT skip the wizard because a topic was provided. The wizard takes 10 seconds and only runs once ever.
To detect first run: check if `~/.config/last30days/.env` exists. If it does NOT exist, this is a first run. **Do NOT run any Bash commands or show any command output to detect this — just check the file existence silently.** If the file exists and contains `SETUP_COMPLETE=true`, skip this section **silently** and proceed to Step 1. **Do NOT say "Setup is complete" or any other status message — just move on.** The user doesn't need to be told setup is done every time they run the skill.
**When first run is detected, detect your platform first:**
**If you do NOT have WebSearch capability (raw CLI):** Run the terminal-only setup flow below.
**If you DO have WebSearch (Hermes):** Run the standard setup flow below.
---
### Terminal-Only / Non-WebSearch Setup Flow
Run environment detection first:
```bash
"${LAST30DAYS_PYTHON}" "${SKILL_ROOT}/scripts/last30days.py" setup --terminal
```
Read the JSON output. It tells you what's already configured. Display a status summary:
```
👋 Welcome to last30days!
Detected:
{✅ or ❌} yt-dlp (YouTube search)
{✅ or ❌} X/Twitter ({method} configured)
{✅ or ❌} ScrapeCreators (TikTok, Instagram, Reddit backup)
{✅ or ❌} Web search ({backend} configured)
```
Then for each missing item, offer setup in priority order:
1. **ScrapeCreators** (if not configured): "ScrapeCreators adds TikTok and Instagram search (plus a Reddit backup if public Reddit gets rate-limited). 10,000 free calls, no credit card. (No referrals, no kickbacks - we don't get a cut.)"
- Option A: "ScrapeCreators via GitHub (recommended)" — Check if `gh` CLI was detected in the environment detection output above. If gh IS detected: description should say "Registers directly via GitHub CLI in ~2 seconds - no browser needed". Before running the command, display: "Registering via GitHub CLI..." If gh is NOT detected: description should say "Copies a one-time code to your clipboard and opens GitHub to authorize". Then run `"${LAST30DAYS_PYTHON}" "${SKILL_ROOT}/scripts/last30days.py" setup --github`, parse JSON output. Tries PAT first (if `gh` is installed), falls back to device flow which copies a one-time code to your clipboard and opens your browser. If `status` is `success`, write `SCRAPECREATORS_API_KEY=*** to .env.
- Option B: "I have a key" — accept paste, write to .env
- Option C: "Skip for now"
2. **X/Twitter** (if not configured): "X search finds tweets and conversations. To unlock X: add FROM_BROWSER=auto (reads browser cookies, free), XAI_API_KEY (no browser access, api.x.ai), or AUTH_TOKEN+CT0 (manual cookies)."
- Option A: "I have an xAI API key" (recommended for servers — persistent, no expiry). Write XAI_API_KEY to .env.
- Option B: "I have AUTH_TOKEN + CT0 from my browser" — accept both, write to .env
- Option C: "Skip for now"
3. **YouTube** (if yt-dlp not found): "YouTube search needs yt-dlp. Run: `pip install yt-dlp`"
4. **Web search** (if no Brave/Exa/Serper key): "A web search key enables smarter results. Brave Search is free for 2,000 queries/month at brave.com/search/api"
After setup, write `SETUP_COMPLETE=true` to .env and proceed to research.
**Skip to "END OF FIRST-RUN WIZARD" below after completing the terminal-only flow.**
---
### Hermes Setup Flow (Standard)
**You MUST follow these steps IN ORDER. Do NOT skip ahead to the topic picker or research. The sequence is: (1) welcome text -> (2) setup modal -> (3) run setup if chosen -> (4) optional ScrapeCreators modal -> (5) topic picker. You MUST start at step 1.**
**Step 1: Display the following welcome text ONCE as a normal message (not blockquoted). Then IMMEDIATELY call AskUserQuestion - do NOT repeat any of the welcome text inside the AskUserQuestion call.**
Welcome to last30days!
I research any topic across Reddit, X, YouTube, and other sources - synthesizing what people are actually saying right now.
Auto setup gives you 5 core sources for free in 30 seconds:
- X/Twitter - reads your x.com browser cookies to authenticate (not saved to disk). Chrome on macOS will prompt for Keychain access.
- Reddit with comments - public JSON, no API key needed
- YouTube search + transcripts - installs yt-dlp (open source, 190K+ GitHub stars)
- Hacker News + Polymarket + GitHub (if `gh` CLI installed) - always on, zero config
Want TikTok and Instagram too? ScrapeCreators adds those (10,000 free calls, scrapecreators.com). No kickbacks, no affiliation.
**Then call AskUserQuestion with ONLY this question and these options - no additional text:**
Question: "How would you like to set up?"
Options:
- "Auto setup (~30 seconds) - scans browser cookies for X + installs yt-dlp for YouTube"
- "Manual setup - show me what to configure"
- "Skip for now - Reddit (with comments), HN, Polymarket, GitHub (if gh installed), Web"
**If the user picks 1 (Auto setup):**
**Before running the setup command, get cookie consent:**
Check if `BROWSER_CONSENT=true` already exists in `~/.config/last30days/.env`. If it does, skip the consent prompt and run setup directly.
If `BROWSER_CONSENT=true` is NOT present, **call AskUserQuestion:**
Question: "Auto setup will scan your browser for x.com cookies to authenticate X search. Cookies are read live, not saved to disk. Chrome on macOS will prompt for Keychain access. OK to proceed?"
Options:
- "Yes, scan my cookies for X" - Run setup as normal. Append `BROWSER_CONSENT=true` to .env after setup completes.
- "Skip X, just set up YouTube" - Run setup with YouTube only (install yt-dlp). Do not scan cookies.
- "I have an xAI API key instead" - Ask them to paste it, write XAI_API_KEY to .env. Then install yt-dlp.
Run the setup subcommand:
```bash
cd {SKILL_DIR} && "${LAST30DAYS_PYTHON}" scripts/last30days.py setup
```
Show the user the results (what cookies were found, whether yt-dlp was installed).
**Then show the optional ScrapeCreators offer (plain text, then modal):**
Want TikTok and Instagram too? ScrapeCreators adds those platforms - 10,000 free calls, no credit card. It also serves as a Reddit backup if public Reddit ever gets rate-limited.
**Before showing the ScrapeCreators modal, check for `gh` CLI:** Run `which gh` via Bash silently. Store the result as gh_available (true if found, false if not).
**Call AskUserQuestion:**
Question: "Want to add TikTok, Instagram, and Reddit backup via ScrapeCreators? (We don't get a cut.)"
Options:
- "ScrapeCreators via GitHub (fastest, recommended)" - If gh_available: description should say "Registers directly via GitHub CLI in ~2 seconds - no browser needed". If NOT gh_available: description should say "Copies a one-time code to your clipboard and opens GitHub to authorize". After the user selects this option: If gh_available, display "Registering via GitHub CLI..." before running the command. If NOT gh_available, display "I'll copy a one-time code to your clipboard and open GitHub. When GitHub asks for a device code, just paste (Cmd+V on Mac, Ctrl+V on Windows/Linux)." Then run `cd {SKILL_DIR} && "${LAST30DAYS_PYTHON}" scripts/last30days.py setup --github` via Bash with a 5-minute timeout. This tries PAT auth first (if `gh` CLI is installed, zero browser needed), then falls back to GitHub device flow which copies a one-time code to your clipboard and opens GitHub in your browser. Parse the JSON stdout. If `status` is `success`, write `SCRAPECREATORS_API_KEY=*** to `~/.config/last30days/.env`. If `method` is `pat`, show: "You're in! Registered via GitHub CLI - zero browser needed. 10,000 free calls. TikTok, Instagram, and Reddit backup are now active." If `method` is `device` and `clipboard_ok` is true, show: "You're in! (The authorization code was copied to your clipboard automatically.) 10,000 free calls. TikTok, Instagram, and Reddit backup are now active." If `method` is `device` and `clipboard_ok` is false, show: "You're in! 10,000 free calls. TikTok, Instagram, and Reddit backup are now active." If `status` is `timeout` or `error`, show: "GitHub auth didn't complete. No worries - you can sign up at scrapecreators.com instead or try again later." Then offer the web signup option.
- "Open scrapecreators.com (Google sign-in)" - run `open https://scrapecreators.com` via Bash to open in the user's browser. Then ask them to paste the API key they get. When they paste it, write SCRAPECREATORS_API_KEY=*** to ~/.config/last30days/.env
- "I have a key" - accept the key, write to .env
- "Skip for now" - proceed without ScrapeCreators
**After SC key is saved (not if skipped), show the TikTok/Instagram opt-in:**
**Call AskUserQuestion:**
Question: "Enable TikTok and Instagram search?"
Options:
- "Yes, enable TikTok + Instagram" - Write `TIKTOK_ENABLED=true` and `INSTAGRAM_ENABLED=true` to .env. Then show: "TikTok and Instagram are now enabled. You can disable them later by editing ~/.config/last30days/.env."
- "No, skip for now" - proceed without enabling
**After setup completes, write `SETUP_COMPLETE=true` to .env.**
---
## END OF FIRST-RUN WIZARD
Proceed to Step 1.
---
## Step 1: Parse Topic
The user invoked: `last30days {QUERY}`
Extract the topic. If the query is empty or ambiguous, ask for clarification.
## Step 2: Execute Research
Run the research engine:
```bash
cd {SKILL_DIR} && "${LAST30DAYS_PYTHON}" scripts/last30days.py "{TOPIC}" --emit=compact --lookback-days=30
```
Optional flags based on user request:
- `--search=reddit,youtube,hackernews` - Specific sources only
- `--days=7` - Shorter time range
- `--deep` - Higher recall mode
- `--save` - Save to ~/Documents/Last30Days/
## Step 3: Display Results
Show the research output to the user. The compact output includes:
- Executive summary
- Ranked evidence clusters with scores
- Source statistics (upvotes, views, engagement)
- Citations with URLs
- Confidence levels and uncertainty notes
## Security & Permissions
**What this skill does:**
- Sends search queries to ScrapeCreators API (`api.scrapecreators.com`) for TikTok and Instagram search, and as a Reddit backup when public Reddit is unavailable (requires SCRAPECREATORS_API_KEY)
- Sends search queries to OpenAI's Responses API (`api.openai.com`) for Reddit discovery (fallback if no SCRAPECREATORS_API_KEY)
- Sends search queries to Twitter's GraphQL API (via optional user-provided AUTH_TOKEN/CT0 env vars — no browser session access) or xAI's API (`api.x.ai`) for X search
- Sends search queries to Algolia HN Search API (`hn.algolia.com`) for Hacker News story and comment discovery (free, no auth)
- Sends search queries to Polymarket Gamma API (`gamma-api.polymarket.com`) for prediction market discovery (free, no auth)
- Runs `yt-dlp` locally for YouTube search and transcript extraction (no API key, public data)
- Sends search queries to ScrapeCreators API (`api.scrapecreators.com`) for TikTok and Instagram search, transcript/caption extraction (PAYG after 10,000 free API calls)
- Optionally sends search queries to Brave Search API, Parallel AI API, or OpenRouter API for web search
- Fetches public Reddit thread data from `reddit.com` for engagement metrics
- Stores research findings in local SQLite database (watchlist mode only)
- Saves research briefings as .md files to ~/Documents/Last30Days/
**What this skill does NOT do:**
- Does not post, like, or modify content on any platform
- Does not access your Reddit, X, or YouTube accounts
- Does not share API keys between providers (OpenAI key only goes to api.openai.com, etc.)
- Does not log, cache, or write API keys to output files
- Does not send data to any endpoint not listed above
- Hacker News and Polymarket sources are always available (no API key, no binary dependency)
- TikTok and Instagram sources require SCRAPECREATORS_API_KEY (10,000 free API calls, then PAYG). Reddit uses ScrapeCreators only as a backup when public Reddit is unavailable.
- Can be invoked autonomously by agents via the Skill tool (runs inline, not forked); pass `--agent` for non-interactive report output
**Bundled scripts:** `scripts/last30days.py` (main research engine), `scripts/lib/` (search, enrichment, rendering modules), `scripts/lib/vendor/bird-search/` (vendored X search client, MIT licensed)
Review scripts before first use to verify behavior.
+74
View File
@@ -5,6 +5,79 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [3.0.9] - 2026-04-18 - The Self-Debug Release
### Highlights
v3.0.9 adds the engine-side Class 1 keyword-trap refuse-gate ("birthday gift for 40 year old" now gets a clarifying question, not 5 minutes of junk), promotes TikTok and YouTube top comments to the same first-class rendering Reddit's got, lands Hermes AI Agent as a first-class deploy target, and moves the SKILL.md formatting contract from line 1094 to the top of the file.
"The Self-Debug Release" refers to how the fixes in 3.0.6-3.0.9 were written: 5 separate Opus 4.7 instances each debugged their own failed outputs. Three converged on "SKILL.md is too big and the LAWs are too deep." Two converged on "the engine should refuse demographic-shopping queries." I shipped exactly what they said. Validation: 5/5 canonical compliance.
### Added
- **Engine Class 1 keyword-trap refuse-gate** (`scripts/lib/preflight.py`, new). Pattern-matches demographic-shopping queries at main() front-door. Exit code 2 with structured REFUSE message. Escape hatch: `LAST30DAYS_SKIP_PREFLIGHT=1`. 29 tests in `tests/test_preflight.py`.
- **TikTok + YouTube top comments** rendered with same `💬 Top comment` prominence as Reddit's. Shipped in [#260](https://github.com/mvanhorn/last30days-skill/pull/260); enrichment fixed in [#265](https://github.com/mvanhorn/last30days-skill/pull/265).
- **Hermes AI Agent as a deploy target** - thanks @stephenmcconnachie ([#228](https://github.com/mvanhorn/last30days-skill/pull/228)). `scripts/sync.sh` detects `~/.hermes/skills/research` and deploys automatically.
- **Multi-key SCRAPECREATORS_API_KEY rotation** - thanks @zaydiscold ([#268](https://github.com/mvanhorn/last30days-skill/pull/268)). Set `SCRAPECREATORS_API_KEY_1`, `_2`, etc. Engine rotates on rate-limit.
- **Offline quality evaluation fixture** - thanks @j-sperling ([#233](https://github.com/mvanhorn/last30days-skill/pull/233)). `eval_topics.json` lets contributors run quality regressions without burning live API credits.
- **END-OF-CANONICAL-OUTPUT boundary** in `render_compact()`. Engine now emits an explicit pass-through instruction so re-synthesis requires actively ignoring a visible boundary.
- **LAW 1 verbatim-pattern override.** LAW 1 now quotes the exact WebSearch tool-result reminder ("CRITICAL REQUIREMENT: MUST include Sources: section") and declares it OVERRIDDEN inside last30days output.
### Changed
- **SKILL.md restructure.** VOICE CONTRACT LAWs and BADGE MANDATORY block moved from line 1094 to lines 75-150. Grounded in 3 separate Opus 4.7 self-debugs.
- **Engine emits the badge as stdout.** `🌐 last30days v3.0.9 · synced YYYY-MM-DD` is the first line of every compact emit. Pass-through is now the default-correct behavior.
- **Reddit client HTTP consolidation** - thanks @iliaal ([#207](https://github.com/mvanhorn/last30days-skill/pull/207)). Migrated to `http.get(params=...)` helper.
- **ScrapeCreators header consolidation** - thanks @iliaal ([#209](https://github.com/mvanhorn/last30days-skill/pull/209)). `_sc_headers` refactored into `http.scrapecreators_headers`.
- **Simpler Hermes sync.** `scripts/sync.sh` Hermes branch now always uses main SKILL.md (previously had a `.hermes-plugin/SKILL.md` fallback that created a wrong-file-capture hazard).
### Fixed
- **Peter Steinberger trailing Sources leak.** 2026-04-18 validation failure where the model appended a TechCrunch / TED / Fortune / Wikipedia Sources list after the invitation. Now structurally prevented at three layers: engine emits the canonical body, LAW 1 quotes the exact WebSearch reminder, closing boundary names the anti-pattern.
- **Wrong-file SKILL.md capture.** Deleted `.agents/skills/last30days/SKILL.md` (1382 lines, April 13 snapshot) and `.hermes-plugin/SKILL.md` (269 lines). One SKILL.md per plugin now, at the plugin root.
- **GitHub date parsing garbage** - thanks @iliaal ([#208](https://github.com/mvanhorn/last30days-skill/pull/208)). `_parse_date` now rejects invalid input cleanly.
- **Windows Bird X stability** - thanks @Chelebii ([#227](https://github.com/mvanhorn/last30days-skill/pull/227)).
- **Linux `check_perms` false-warn** - thanks @george231224 ([#216](https://github.com/mvanhorn/last30days-skill/pull/216)). Uses GNU stat first.
- **UTF-8 saved output** - thanks @Gujiassh ([#225](https://github.com/mvanhorn/last30days-skill/pull/225)).
- **Version metadata alignment** - thanks @Gujiassh ([#217](https://github.com/mvanhorn/last30days-skill/pull/217)) and @shalomma ([#229](https://github.com/mvanhorn/last30days-skill/pull/229)).
- **`--days` alias backcompat** - thanks @BryanTegomoh ([#230](https://github.com/mvanhorn/last30days-skill/pull/230)).
- **`INCLUDE_SOURCES` env default** - thanks @hnshah ([#223](https://github.com/mvanhorn/last30days-skill/pull/223)).
- **Bird X all-None engagement** - thanks @j-sperling ([#234](https://github.com/mvanhorn/last30days-skill/pull/234)).
### Contributors
@j-sperling, @stephenmcconnachie, @zaydiscold, @iliaal, @Chelebii, @Gujiassh, @hnshah, @george231224, @shalomma, @BryanTegomoh for PRs since v3.0.0. @uppinote20, @zerone0x, @thinkun, @thomasmktong, @fanispoulinakisai-boop, @pejmanjohn, @zl190, @Jah-yee, @dannyshmueli, @Cody-Coyote for issues and PRs that shaped the v3 roadmap.
### Recovery
```
/plugin update last30days
/reload-plugins
```
Verify: `cat ~/.claude/plugins/cache/last30days-skill/last30days/*/.claude-plugin/plugin.json | grep version` returns `"version": "3.0.9"`.
Smoke test: `/last30days birthday gift for 40 year old` should ask a clarifying question before running.
## [3.0.5] - 2026-04-15
### Added
- **`/last30days` slash command for plugin users.** New `commands/last30days.md` registers a Claude Code slash command. Users type `/last30days <topic>` and Claude Code's autocomplete prefix-matches it to the canonical `/last30days:last30days` form (the same way `/ce:plan` resolves to `/compound-engineering:ce-plan`). The command delegates to the existing `last30days` skill body — no skill behavior changes.
### Removed
- **`skills/last30days-nux/`** — byte-identical duplicate of root `SKILL.md` that created confusing `/last30days:last30days-nux` autocomplete entries via Claude Code's plugin namespacing. The root `SKILL.md` remains the canonical skill source.
### Recovery
```
/plugin update last30days
/reload-plugins
```
Then type `/last30days <topic>` to invoke the skill via slash command. Natural-language invocation ("search the last 30 days for X") continues to work unchanged.
## [3.0.4] - 2026-04-15
### Fixed
@@ -251,6 +324,7 @@ Three headline features: watchlists for always-on bots, YouTube transcripts as a
Initial public release. Reddit + X search via OpenAI Responses API and xAI API.
[3.0.9]: https://github.com/mvanhorn/last30days-skill/compare/v3.0.5...v3.0.9
[2.9.1]: https://github.com/mvanhorn/last30days-skill/compare/v2.9.0...v2.9.1
[2.9.0]: https://github.com/mvanhorn/last30days-skill/compare/v2.8.0...v2.9.0
[2.8.0]: https://github.com/mvanhorn/last30days-skill/compare/v2.6.0...v2.8.0
+5 -1
View File
@@ -18,4 +18,8 @@ bash scripts/sync.sh # Deploy to ~/.claud
## Rules
- `lib/__init__.py` must be bare package marker (comment only, NO eager imports)
- After edits: run `bash scripts/sync.sh` to deploy
- Git remotes: origin=private, upstream=public
- Git remote: origin = public (`mvanhorn/last30days-skill`)
## Beta channel
Experimental changes get tested on `mvanhorn/last30days-skill-private`, which installs as a parallel `/last30days-beta` slash command. Beta-only changes never ship to public without a review PR here. Workflow guide lives at `BETA.md` in the private repo. Plan that established this setup: `docs/plans/2026-04-17-005-feat-beta-skill-from-private-repo-plan.md`.
+573 -424
View File
File diff suppressed because it is too large Load Diff
+9
View File
@@ -0,0 +1,9 @@
---
description: Research what people actually say about any topic in the last 30 days across Reddit, X, YouTube, TikTok, Hacker News, Polymarket, GitHub, and the web.
argument-hint: <topic> — e.g. "nvidia earnings reaction" or "best noise cancelling headphones"
allowed-tools: [Bash, Read, Write, AskUserQuestion, WebSearch]
---
Invoke the `last30days` skill with the user's arguments: $ARGUMENTS
Use the skill's canonical pipeline (plan → retrieve → normalize → fuse → rerank → cluster → render). If the user provided no arguments, ask them for a topic before proceeding.
+112
View File
@@ -0,0 +1,112 @@
# v3.0.9 - The Self-Debug Release
## Highlights
**v3.0.9 is live.** New user-facing capabilities, broader cross-platform support, and a skill that now runs reliably on Claude Code, Codex, Hermes, Gemini, claude.ai, and OpenClaw. The headline fix: the engine refuses "birthday gift for 40 year old" style queries with a clarifying question instead of 5 minutes of junk output. The headline feature: TikTok and YouTube top comments now render alongside Reddit's, so the most-engaged voice from every source makes it into the synthesis.
**The label - "The Self-Debug Release":** I handed 5 separate Opus 4.7 instances their own failed outputs and asked them to debug themselves. Three converged on "SKILL.md is too big and the LAWs are too deep." Two converged on "the engine should refuse demographic-shopping queries outright" and "the WebSearch Sources reminder is overriding LAW 1." I copy-pasted their diagnoses into code. Validation: 5/5 canonical compliance on the topics that had failed.
## New capabilities
- **TikTok and YouTube top comments render alongside Reddit's.** PR [#260](https://github.com/mvanhorn/last30days-skill/pull/260) made the top-engagement comment from each TikTok video and YouTube video first-class in the output - same prominent `💬 Top comment` treatment Reddit's top comment already got. This is the biggest user-facing output change since 3.0.0 and it was never announced. The community inspiration trace: @uppinote20's original push for richer Reddit comments ([PR #143](https://github.com/mvanhorn/last30days-skill/pull/143)) seeded the pattern; this PR generalized it across TikTok and YouTube. PR [#265](https://github.com/mvanhorn/last30days-skill/pull/265) followed up by fixing the ScrapeCreators `url=` param + new response shape for YouTube comments/transcripts so the enrichment actually works.
- **last30days runs on Hermes AI Agent now.** @stephenmcconnachie's PR ([#228](https://github.com/mvanhorn/last30days-skill/pull/228)) added Hermes as a first-class deploy target. `scripts/sync.sh` detects `~/.hermes/skills/research` and deploys the full skill (SKILL.md, scripts, lib modules, fixtures) to Hermes's skills directory alongside Claude Code and Codex. This is one of the biggest surface-area expansions in v3 - last30days is now usable inside the Hermes agent's research workflows without any manual wiring.
- **Multi-key SCRAPECREATORS_API_KEY rotation.** @zaydiscold's PR ([#268](https://github.com/mvanhorn/last30days-skill/pull/268)) added automatic key rotation. Set `SCRAPECREATORS_API_KEY_1`, `SCRAPECREATORS_API_KEY_2`, etc. and the engine rotates when a key hits rate limits instead of failing the whole run. For power users running daily queries, this is the difference between rate-limit 429s and zero-touch reliability.
- **The skill works on Windows now.** @Chelebii's PR ([#227](https://github.com/mvanhorn/last30days-skill/pull/227)) stabilized the vendored Bird X search client on Windows. Previously the bundled X backend had subtle runtime issues on Windows terminals; now it runs clean. Pair this with @Gujiassh's UTF-8 encoding fix ([#225](https://github.com/mvanhorn/last30days-skill/pull/225)) for saved output and Windows users get the full v3 experience without workarounds.
- **Linux permission checks stopped false-warning.** @george231224's PR ([#216](https://github.com/mvanhorn/last30days-skill/pull/216)) fixed `check_perms` on Linux by preferring GNU stat's syntax over the BSD stat that the skill was calling. Linux users were getting spurious permission warnings on `.env` files that were already correctly 600-chmod'd. Now the check matches reality.
- **Gemini CLI got a first-class install path.** @hnshah's docs PR ([#224](https://github.com/mvanhorn/last30days-skill/pull/224)) added the Gemini CLI install note and workaround for a rough edge in the Gemini skill loader. Gemini users now have a one-paragraph install flow in the README instead of having to reverse-engineer the plugin layout.
- **Offline quality evaluation.** @j-sperling's PR ([#233](https://github.com/mvanhorn/last30days-skill/pull/233)) added `eval_topics.json` as a fixture. Contributors and I can now run quality-regression checks on synthesis output without burning live API credits. This is the scaffolding that made the plan 015 validation gate affordable - without eval fixtures, testing 5/5 canonical compliance on every release would cost real money every time. Ships as contributor infrastructure but shows up as stability for end users.
- **Reddit client got a cleaner HTTP layer.** @iliaal shipped three architecture PRs back-to-back ([#207](https://github.com/mvanhorn/last30days-skill/pull/207), [#208](https://github.com/mvanhorn/last30days-skill/pull/208), [#209](https://github.com/mvanhorn/last30days-skill/pull/209)) that consolidated Reddit's HTTP handling into `http.get(params=...)`, rejected garbage input in `_parse_date`, and unified `_sc_headers` into `http.scrapecreators_headers`. End-user benefit: fewer flaky timeouts, fewer "weird parse error" crashes, a codebase that's easier for future contributors to touch without breaking Reddit. These aren't sexy PRs; they're the kind of refactor that prevents six future bug reports.
- **The `--days=N` flag keeps working.** @BryanTegomoh's PR ([#230](https://github.com/mvanhorn/last30days-skill/pull/230)) restored backcompat for the legacy `--days` alias so anyone who'd scripted against it in 2.x doesn't break on v3. Small PR, meaningful reliability gain for existing users.
- **INCLUDE_SOURCES has a sane default.** @hnshah's PR ([#223](https://github.com/mvanhorn/last30days-skill/pull/223)) defaulted the env var to empty string instead of unset. Missing env no longer breaks source inclusion on fresh installs.
- **Version metadata stays in sync.** @Gujiassh's PR ([#217](https://github.com/mvanhorn/last30days-skill/pull/217)) aligned the SKILL.md version header with the sync target version, and @shalomma's PR ([#229](https://github.com/mvanhorn/last30days-skill/pull/229)) closed the remaining drift between the SKILL.md header and plugin.json. "Which version am I actually on" is no longer an adventure.
- **Bird X engagement handling got hardened.** @j-sperling's PR ([#234](https://github.com/mvanhorn/last30days-skill/pull/234)) made `bird_x` skip all-None engagement dicts instead of crashing on them. Rare condition, but the kind of thing that silently kills a run on a specific topic.
- **Dev workflow hygiene.** @j-sperling's gitignore PR ([#232](https://github.com/mvanhorn/last30days-skill/pull/232)) dropped `.venv`, `.coverage`, `htmlcov`, and `.memsearch` from the tracked tree. Contributor quality-of-life; keeps PR diffs clean.
- **The skill installs to claude.ai.** PRs [#242](https://github.com/mvanhorn/last30days-skill/pull/242) and [#244](https://github.com/mvanhorn/last30days-skill/pull/244) shipped `scripts/build-skill.sh` plus the `.gitattributes` + `export-ignore` plumbing that packages last30days into a claude.ai-upload-ready `.skill` file under the 200-file cap. The skill is no longer Claude-Code-only - it installs directly on claude.ai, too. README has the upload workflow.
- **OpenAI Codex CLI discovers the skill natively.** PR [#219](https://github.com/mvanhorn/last30days-skill/pull/219) added `.agents/skills/last30days/SKILL.md` as a real file (not symlinked - Codex's loader skips symlinks) plus `.codex-plugin/plugin.json` as the namespace marker. The skill now shows up as `last30days:last30days` when Codex runs in a checkout. Inspired by @Jah-yee ([#153](https://github.com/mvanhorn/last30days-skill/pull/153)) and @dannyshmueli on X.
- **`/last30days` as a slash command.** PR [#267](https://github.com/mvanhorn/last30days-skill/pull/267) added `commands/last30days.md` so plugin users can type `/last30days <topic>` and Claude Code autocomplete prefix-matches it to the canonical `/last30days:last30days` form. No more typing the double-namespace.
## The self-debug technique, for anyone rebuilding this elsewhere
The breakthrough wasn't the individual fixes. It was the realization that instead of guessing why the model was ignoring the rules, I should ask the model. Five separate Opus 4.7 sessions debugged their own outputs:
- "Did you read SKILL.md?" → "I tried Read, hit the 25K token cap, and bailed instead of chunked-reading."
- "Why the trailing Sources block?" → "The WebSearch tool's own reminder said MANDATORY. Precedence was unclear."
- "Why the section headers?" → "I had strong priors on Peter Steinberger and wrote my thesis instead of passing through."
- "Why the wrong file?" → "I read `.agents/skills/last30days/SKILL.md` first because it appeared in the path glob."
Three of the five said "move the LAWs to the top." Two said "make the engine enforce it so the model can't not comply." I shipped both. That's the whole technique: when the LLM-under-orchestration keeps breaking the contract, don't argue with it - ask it to debug itself, and build structural enforcement around whatever it names as the root cause.
## Thank you
**Community PR authors since v3.0.0:**
- @j-sperling - v3 engine architecture, eval fixtures, gitignore hygiene, Bird X hardening ([#232](https://github.com/mvanhorn/last30days-skill/pull/232), [#233](https://github.com/mvanhorn/last30days-skill/pull/233), [#234](https://github.com/mvanhorn/last30days-skill/pull/234))
- @stephenmcconnachie - Hermes AI Agent support ([#228](https://github.com/mvanhorn/last30days-skill/pull/228))
- @zaydiscold - Multi-key SCRAPECREATORS rotation ([#268](https://github.com/mvanhorn/last30days-skill/pull/268))
- @iliaal - Reddit HTTP helper + GitHub date parsing + ScrapeCreators header consolidation ([#207](https://github.com/mvanhorn/last30days-skill/pull/207), [#208](https://github.com/mvanhorn/last30days-skill/pull/208), [#209](https://github.com/mvanhorn/last30days-skill/pull/209))
- @Chelebii - Windows Bird X stability ([#227](https://github.com/mvanhorn/last30days-skill/pull/227))
- @george231224 - Linux check_perms stat ([#216](https://github.com/mvanhorn/last30days-skill/pull/216))
- @Gujiassh - UTF-8 saved output + version metadata alignment ([#217](https://github.com/mvanhorn/last30days-skill/pull/217), [#225](https://github.com/mvanhorn/last30days-skill/pull/225))
- @hnshah - INCLUDE_SOURCES default + Gemini install docs ([#223](https://github.com/mvanhorn/last30days-skill/pull/223), [#224](https://github.com/mvanhorn/last30days-skill/pull/224))
- @shalomma - SKILL.md v3.0.0 version header ([#229](https://github.com/mvanhorn/last30days-skill/pull/229))
- @BryanTegomoh - --days alias backcompat ([#230](https://github.com/mvanhorn/last30days-skill/pull/230))
**v3 roadmap contributors (issues and PRs that shaped the v3 feature set):**
- @uppinote20 - rich Reddit comments ([#143](https://github.com/mvanhorn/last30days-skill/pull/143))
- @zerone0x - GitHub as a first-class source ([#134](https://github.com/mvanhorn/last30days-skill/issues/134), [#136](https://github.com/mvanhorn/last30days-skill/pull/136))
- @thinkun - Reddit enrichment timeout handling ([#116](https://github.com/mvanhorn/last30days-skill/pull/116))
- @thomasmktong - pure-Python Reddit fallback ([#124](https://github.com/mvanhorn/last30days-skill/pull/124))
- @fanispoulinakisai-boop - Reddit timeout report ([#100](https://github.com/mvanhorn/last30days-skill/issues/100))
- @pejmanjohn - plugin directory naming ([#99](https://github.com/mvanhorn/last30days-skill/issues/99), [#78](https://github.com/mvanhorn/last30days-skill/issues/78))
- @zl190 - HN trending merge ([#115](https://github.com/mvanhorn/last30days-skill/pull/115))
- @hnshah - Watchlist features ([#84](https://github.com/mvanhorn/last30days-skill/pull/84), [#85](https://github.com/mvanhorn/last30days-skill/pull/85), [#86](https://github.com/mvanhorn/last30days-skill/pull/86))
- @Jah-yee, @dannyshmueli - Codex CLI discovery
- @Cody-Coyote - marketplace validation bug report ([#204](https://github.com/mvanhorn/last30days-skill/issues/204))
**The five Opus 4.7 instances that debugged their own failures on v3.0.7 and v3.0.8 and converged on the fixes.** The convergence was the breakthrough; this release is their diagnosis in code.
## Install / Update
```
/plugin marketplace add mvanhorn/last30days-skill
/plugin install last30days@last30days-skill
```
Or if already installed:
```
/plugin update last30days
/reload-plugins
```
## Verify
```
cat ~/.claude/plugins/cache/last30days-skill/last30days/*/.claude-plugin/plugin.json | grep version
```
Should print `"version": "3.0.9"`.
## Smoke test
```
/last30days birthday gift for 40 year old
```
Should ask a clarifying question before running. If it runs the engine anyway, the cache is stale - repeat the plugin update.
**Full Changelog:** https://github.com/mvanhorn/last30days-skill/compare/v3.0.5...v3.0.9
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "last30days-skill",
"version": "3.0.4",
"version": "3.0.5",
"description": "Research a topic from the last 30 days across Reddit, X, YouTube, TikTok, Instagram, Hacker News, Polymarket, and the web.",
"settings": [
{
+21 -20
View File
@@ -1,14 +1,13 @@
#!/bin/bash
# A/B/C test runner for last30days skill variants
# A/B test runner: public release vs private beta
# Usage: bash scripts/compare.sh "Kanye West"
#
# Runs all 3 skills sequentially (30s gap for rate limits),
# saves raw results with unique suffixes, then prints file paths
# for comparison.
# Runs /last30days (public release) and /last30days-beta (private beta)
# sequentially with a 30s gap, saves raw results with distinct suffixes,
# prints file paths for comparison.
set -e
# Join all args as the topic (so "bash compare.sh Kevin Rose" works without quotes)
if [ $# -eq 0 ]; then
echo "Usage: bash scripts/compare.sh <topic>"
echo " Example: bash scripts/compare.sh Kevin Rose"
@@ -20,40 +19,42 @@ DIR="$HOME/Documents/Last30Days"
DATE=$(date +%Y-%m-%d)
echo "=============================================="
echo " A/B/C Test: $TOPIC"
echo " A/B Test: $TOPIC"
echo " Date: $DATE"
echo "=============================================="
echo ""
# Run 1: v2.9 production
echo "[1/3] Running v2.9 (production /last30days)..."
# Run 1: public release
echo "[1/2] Running /last30days (public release)..."
echo " This takes 2-4 minutes..."
claude -p --dangerously-skip-permissions "/last30days $TOPIC" > /dev/null 2>&1 || true
V2_FILE="$DIR/${SLUG}-raw.md"
[ -f "$V2_FILE" ] && echo " Done $V2_FILE" || echo " FAILED no output file"
RELEASE_FILE="$DIR/${SLUG}-raw.md"
[ -f "$RELEASE_FILE" ] && echo " Done: $RELEASE_FILE" || echo " FAILED: no output file"
echo ""
echo " Waiting 30s for API rate limits..."
sleep 30
# Run 2: v3 Gemini
echo "[2/3] Running v3 (/last30days-3)..."
# Run 2: private beta
echo "[2/2] Running /last30days-beta (private beta)..."
echo " This takes 2-4 minutes..."
claude -p --dangerously-skip-permissions "/last30days-3:last30days-skill-private $TOPIC" > /dev/null 2>&1 || true
V3GEM_FILE="$DIR/${SLUG}-raw-v3.md"
[ -f "$V3GEM_FILE" ] && echo " Done $V3GEM_FILE" || echo " FAILED no output file"
echo ""
claude -p --dangerously-skip-permissions "/last30days-beta $TOPIC" > /dev/null 2>&1 || true
BETA_FILE="$DIR/${SLUG}-raw-beta.md"
[ -f "$BETA_FILE" ] && echo " Done: $BETA_FILE" || echo " FAILED: no output file"
echo ""
echo "=============================================="
echo " Both complete. Raw files:"
echo "=============================================="
echo ""
ls -la "$DIR/${SLUG}-raw"*.md 2>/dev/null || echo " (no files found check if skills saved correctly)"
ls -la "$DIR/${SLUG}-raw"*.md 2>/dev/null || echo " (no files found - check if skills saved correctly)"
echo ""
echo "To compare, run in Claude Code:"
echo " Read and compare these raw research files, produce a detailed report:"
echo " $DIR/${SLUG}-raw.md"
echo " $DIR/${SLUG}-raw-v3.md"
echo " $RELEASE_FILE"
echo " $BETA_FILE"
echo ""
echo "Beta output should start with a line like:"
echo " 🧪 last30days-beta · branch <name> · synced $DATE"
echo "If that line is missing, the beta badge regressed. See docs/plans/2026-04-17-005-*-plan.md."
echo ""
+50 -3
View File
@@ -112,16 +112,36 @@ def save_output(report: schema.Report, emit: str, save_dir: str, suffix: str = "
return out_path
def emit_output(report: schema.Report, emit: str, fun_level: str = "medium") -> str:
def emit_output(report: schema.Report, emit: str, fun_level: str = "medium", save_path: str | None = None) -> str:
if emit == "json":
return json.dumps(schema.to_dict(report), indent=2, sort_keys=True)
if emit in {"compact", "md"}:
return render.render_compact(report, fun_level=fun_level)
return render.render_compact(report, fun_level=fun_level, save_path=save_path)
if emit == "context":
return render.render_context(report)
raise SystemExit(f"Unsupported emit mode: {emit}")
def compute_save_path_display(save_dir: str, topic: str, suffix: str, emit: str) -> str:
"""Compute the user-friendly save path string that will be shown in the footer.
Uses ~ for the home directory so the footer reads "~/Documents/Last30Days/slug-raw.md"
instead of an absolute machine-local path.
"""
from pathlib import Path as _Path
path = _Path(save_dir).expanduser().resolve()
slug = slugify(topic)
extension = "json" if emit == "json" else "md"
suffix_part = f"-{suffix}" if suffix else ""
raw = path / f"{slug}-raw{suffix_part}.{extension}"
try:
home = _Path.home().resolve()
relative = raw.relative_to(home)
return f"~/{relative}"
except ValueError:
return str(raw)
def persist_report(report: schema.Report) -> dict[str, int]:
import store
@@ -270,6 +290,13 @@ def main() -> int:
parser.print_usage(sys.stderr)
return 2
if not os.environ.get("LAST30DAYS_SKIP_PREFLIGHT"):
from lib import preflight
refuse_msg = preflight.check_class_1_trap(topic)
if refuse_msg:
sys.stderr.write(refuse_msg)
return 2
progress = ui.ProgressDisplay(topic, show_banner=True)
progress.start_processing()
@@ -373,7 +400,27 @@ def main() -> int:
pass
fun_level = config.get("FUN_LEVEL", "medium").lower()
rendered = emit_output(report, args.emit, fun_level=fun_level)
footer_save_path = None
if args.save_dir:
footer_save_path = compute_save_path_display(
args.save_dir, report.topic, args.save_suffix or "", args.emit
)
# Signal to render_compact whether pre-research flags were supplied.
# Used to emit a Pre-Research Status warning when the model skipped
# Step 0.5 / 0.55 and invoked the engine bare on an eligible topic.
pre_research_flags_present = bool(
args.x_handle
or args.github_user
or args.subreddits
or args.plan
or args.auto_resolve
or args.tiktok_creators
or args.ig_creators
)
report.artifacts["pre_research_flags_present"] = pre_research_flags_present
rendered = emit_output(report, args.emit, fun_level=fun_level, save_path=footer_save_path)
if args.save_dir:
save_path = save_output(report, args.emit, args.save_dir, suffix=args.save_suffix or "")
sys.stderr.write(f"[last30days] Saved output to {save_path}\n")
+48 -4
View File
@@ -204,7 +204,7 @@ def run(
plan = planner._sanitize_plan(
external_plan, topic, available, requested_sources, depth,
)
print(f"[Planner] Using external plan ({len(plan.subqueries)} subqueries)", file=sys.stderr)
plan_source = "external"
else:
plan = planner.plan_query(
topic=topic,
@@ -215,6 +215,14 @@ def run(
model=None if mock else runtime.planner_model,
context=config.get("_auto_resolve_context", ""),
)
# Source labelling: the fallback path annotates notes with "fallback-plan"
# or "deterministic-comparison-plan"; anything else came from the LLM.
if any("fallback" in note or "deterministic" in note for note in (plan.notes or [])):
plan_source = "deterministic"
elif not mock and reasoning_provider and runtime.planner_model:
plan_source = "llm"
else:
plan_source = "deterministic"
# Safety net: ensure grounding appears in all subqueries even if the planner
# omits it. This is redundant when the planner includes grounding via
@@ -224,7 +232,32 @@ def run(
if "grounding" not in sq.sources:
sq.sources.append("grounding")
# Always-on planner trace. Emits one summary line plus one per subquery
# so retrieval-breadth failures like the 2026-04-19 Hermes Agent Use Cases
# disaster are visible without --debug. Stderr only; does not leak into
# the user-facing stdout synthesis.
print(
f"[Planner] Plan: intent={plan.intent}, freshness={plan.freshness_mode}, "
f"cluster_mode={plan.cluster_mode}, subqueries={len(plan.subqueries)}, "
f"source={plan_source}",
file=sys.stderr,
)
if plan.subqueries:
for index, sq in enumerate(plan.subqueries, start=1):
sources_str = ",".join(sq.sources) if sq.sources else "(none)"
print(
f"[Planner] sq{index} label={sq.label} "
f'search="{sq.search_query}" sources=[{sources_str}]',
file=sys.stderr,
)
else:
print("[Planner] (no subqueries in plan)", file=sys.stderr)
bundle = schema.RetrievalBundle(artifacts={"grounding": []})
# Expose plan_source to the renderer so render_compact can emit the
# DEGRADED RUN banner when a named-entity topic was invoked bare
# (source=deterministic AND no pre-research flags). LAW 7 backstop.
bundle.artifacts["plan_source"] = plan_source
# Project-mode or person-mode GitHub: run once before the main subquery loop
_github_custom_done = False
@@ -407,7 +440,7 @@ def run(
if bundle.items_by_source.get(source):
del bundle.errors_by_source[source]
items_by_source = _finalize_items_by_source(bundle.items_by_source)
items_by_source = _finalize_items_by_source(bundle.items_by_source, topic=topic)
candidates = weighted_rrf(bundle.items_by_source_and_query, plan, pool_limit=settings["pool_limit"])
ranked_candidates = rerank.rerank_candidates(
topic=topic,
@@ -472,11 +505,22 @@ def _normalize_score_dedupe(
return normalized
def _finalize_items_by_source(items_by_source_raw: dict[str, list[schema.SourceItem]]) -> dict[str, list[schema.SourceItem]]:
def _finalize_items_by_source(
items_by_source_raw: dict[str, list[schema.SourceItem]],
topic: str = "",
) -> dict[str, list[schema.SourceItem]]:
finalized = {}
for source, items in items_by_source_raw.items():
items = sorted(items, key=lambda item: item.local_rank_score or 0.0, reverse=True)
finalized[source] = dedupe.dedupe_items(items)
items = dedupe.dedupe_items(items)
# Post-merge topic-relevance filter for Polymarket: comparison queries
# fan out into per-entity subqueries ("Hermes", "OpenClaw") whose topic
# is too narrow for Gamma API to filter meaningfully. Re-validating the
# merged list against the full original topic drops off-topic markets
# (e.g., WTI crude oil, Elon tweet counts) before footer emission.
if source == "polymarket" and topic:
items = polymarket.filter_items_against_topic(topic, items)
finalized[source] = items
return finalized
+135 -7
View File
@@ -113,6 +113,25 @@ def plan_query(
topic, available_sources, requested_sources, depth,
note=f"fallback-plan (LLM error: {type(exc).__name__})",
)
# No --plan was passed and no engine-internal provider is configured.
# The deterministic fallback path produces weaker plans than either the
# --plan path or the engine-internal LLM planner path. Surface this so
# the HOSTING reasoning model (Claude Code, Codex, Hermes, Gemini, etc.)
# realizes it is supposed to generate the plan itself and pass --plan.
# The word "provider" in engine messages refers to the engine's INTERNAL
# planner credentials - NOT a prerequisite the caller needs. If you are
# the hosting reasoning model, YOU are the provider. LAW 7 / 2026-04-19
# Hermes Agent Use Cases failure mode.
import sys
print(
"[Planner] No --plan passed. If you are the reasoning model hosting "
"this skill (Claude Code, Codex, Hermes, Gemini, or any agent runtime), "
"YOU ARE the planner: generate a JSON query plan yourself and pass it "
"via --plan. You do not need an API key or credentials; you ARE the "
"LLM. The deterministic fallback below is the headless/cron path only. "
"See LAW 7 in SKILL.md and Step 0.75 for the plan schema.",
file=sys.stderr,
)
return _fallback_plan(topic, available_sources, requested_sources, depth)
@@ -151,7 +170,7 @@ Return JSON only with this shape:
}}
Rules:
- emit 1 to 4 subqueries
- emit 1 to 5 subqueries (how_to/opinion/product/breaking_news intents benefit from 4-5; factual/concept from 2)
- every subquery must include both search_query and ranking_query
- sources must be drawn from Available sources only
- use cluster_mode=none for factual or many how-to queries
@@ -162,6 +181,8 @@ Rules:
- preserve exact proper nouns and entity strings from the topic
- NEVER include temporal phrases in search_query: no 'last 30 days', 'recent', month names, year numbers
- NEVER include meta-research phrases: no 'news', 'updates', 'public appearances', 'latest developments'
- INTENT-MODIFIER HANDLING: when the topic contains one of {{use cases, use case, workflows, workflow, examples, tutorial, tutorials, review, reviews, comparison, applications, in practice, production, production use, how i use}}, STRIP that phrase from every search_query (keep its meaning in ranking_query). Emit 4-5 paraphrased subqueries that each express the intent differently (e.g., 'production', 'workflow OR pipeline', 'review OR experience', 'vs COMPETITOR', 'community discussion'). Broad retrieval, narrow ranking. This was the 2026-04-19 Hermes Agent Use Cases failure mode: the planner echoed "hermes agent use cases" as a literal search string and returned near-zero results because nobody posts that exact phrase.
- DO NOT quote the user's full topic verbatim in search_query. Quote only multi-word proper nouns like "Hermes Agent", "Claude Code", "Nous Research". Bare keywords OR'd together retrieve more than exact-phrase searches.
- search_query should match how content is TITLED on platforms
- GitHub (Issues/PRs) is best for engineering, developer tools, and open source topics: 'kanye west bully' not 'kanye west album news March 2026'
""".strip()
@@ -204,7 +225,7 @@ def _sanitize_plan(
source_weights = _normalize_weights(source_weights)
subqueries: list[schema.SubQuery] = []
for index, subquery in enumerate((raw.get("subqueries") or [])[:_max_subqueries(intent_hint)], start=1):
for index, subquery in enumerate((raw.get("subqueries") or [])[:_max_subqueries(intent_hint, topic)], start=1):
if not isinstance(subquery, dict):
continue
sources = [source for source in subquery.get("sources") or [] if source in source_weights]
@@ -382,13 +403,22 @@ def _fallback_plan(
)
)
# Intent-modifier fanout: when topic contains a phrase like "use cases",
# "workflows", "examples", "review" (see _INTENT_MODIFIER_PATTERNS),
# paraphrase the intent across 3 extra subqueries rather than echoing
# the literal phrase. Fixes 2026-04-19 Hermes Agent Use Cases failure.
# Excluded for comparison/prediction since those already have dedicated
# fanout (entity-per-subquery / odds).
if depth != "quick" and intent not in {"comparison", "prediction"} and _has_intent_modifier(topic):
subqueries.extend(_intent_modifier_subqueries(topic, core, base_search, source_weights))
return schema.QueryPlan(
intent=intent,
freshness_mode=_default_freshness(intent),
cluster_mode=_default_cluster_mode(intent),
raw_topic=topic,
subqueries=_normalize_subquery_weights(
_trim_subqueries_for_depth(subqueries[:_max_subqueries(intent)], intent, depth, list(source_weights))
_trim_subqueries_for_depth(subqueries[:_max_subqueries(intent, topic)], intent, depth, list(source_weights))
),
source_weights=_normalize_weights(source_weights),
notes=[note],
@@ -418,7 +448,15 @@ def _infer_intent(topic: str) -> str:
return "concept"
if re.search(r"\b(tournament|championship|playoffs|march madness|world cup|olympics|super bowl|final four|ceremony|awards|keynote)\b", text):
return "breaking_news"
return "breaking_news"
# Recency signals take priority when nothing more specific matched.
if re.search(r"\b(trending|this week|right now|today|this month)\b", text):
return "breaking_news"
# Default changed from "breaking_news" to "concept" on 2026-04-19 after
# the Hermes Agent Use Cases failure: unclassified topics were getting
# strict_recent freshness, which over-weighted the last 7 days and
# under-weighted older relevant material. "concept" defaults to
# evergreen_ok freshness, a safer posture for unknown topics.
return "concept"
def _default_freshness(intent: str) -> str:
@@ -464,8 +502,26 @@ def _default_source_weights(intent: str, sources: list[str]) -> dict[str, float]
def _keyword_query(topic: str, core: str) -> str:
"""Build a search_query string for the deterministic fallback.
Quote ONLY title-cased multi-word proper nouns ("Hermes Agent",
"Claude Code", "Nous Research") so platform search engines preserve the
name as a phrase. Hyphenated compounds and lowercase terms are left as
bare keywords, which broadens retrieval instead of narrowing it.
Prior behavior quoted the entire compound including the user's typed
topic, producing searches like `"Hermes Agent Actual Use Cases" hermes agent actual`
that returned near-zero matches on X and Reddit because nobody posts
that exact phrase. See 2026-04-19 Hermes Agent Use Cases failure.
"""
compounds = query.extract_compound_terms(topic)
quoted = " ".join(f"\"{term}\"" for term in compounds[:2])
# Only quote title-cased proper nouns (multi-word names). Hyphenated
# compounds go unquoted so platform tokenizers can split and match.
title_cased = [
term for term in compounds
if re.match(r"^(?:[A-Z][a-z]+\s+){1,}[A-Z][a-z]+$", term)
]
quoted = " ".join(f'"{term}"' for term in title_cased[:2])
keywords = [quoted.strip(), core.strip() or topic.strip()]
return " ".join(part for part in keywords if part).strip()
@@ -513,12 +569,84 @@ def _should_force_deterministic_plan(topic: str) -> bool:
return _infer_intent(topic) == "comparison" and len(_comparison_entities(topic)) >= 2
def _max_subqueries(intent: str) -> int:
_INTENT_MODIFIER_PATTERNS = (
"use cases", "use case", "workflows", "workflow",
"examples", "example", "tutorial", "tutorials",
"review", "reviews", "comparison", "applications",
"in practice", "production use", "production",
"how i use",
)
def _has_intent_modifier(topic: str) -> bool:
"""Return True if the topic contains an intent modifier phrase.
See 2026-04-19 Hermes Agent Use Cases failure: a literal "Hermes Agent
use cases" search returns near-zero matches because nobody posts that
exact phrase. Intent modifiers should be stripped from search_query
and paraphrased across multiple subqueries.
"""
text = topic.lower()
return any(pattern in text for pattern in _INTENT_MODIFIER_PATTERNS)
def _intent_modifier_subqueries(
topic: str,
core: str,
base_search: str,
source_weights: dict[str, float],
) -> list[schema.SubQuery]:
"""Produce paraphrased subqueries for intent-modifier topics.
The deterministic fallback used to echo the user's literal phrase
(e.g., "hermes agent use cases") into every search_query. This helper
fans out 3 extra subqueries that each express the intent differently
so retrieval pulls a broader corpus for reranking.
"""
entity = core or topic.strip()
sources = list(source_weights)
return [
schema.SubQuery(
label="workflows",
search_query=f"{entity} workflow pipeline",
ranking_query=f"What real-world workflows or pipelines are people running with {entity}?",
sources=sources,
weight=0.6,
),
schema.SubQuery(
label="production",
search_query=f"{entity} production real-world",
ranking_query=f"What production deployments or real-world use cases of {entity} are people describing?",
sources=sources,
weight=0.55,
),
schema.SubQuery(
label="experience",
search_query=f"{entity} experience review",
ranking_query=f"What hands-on experience reports or reviews of {entity} exist in the last 30 days?",
sources=sources,
weight=0.5,
),
]
def _max_subqueries(intent: str, topic: str | None = None) -> int:
# how_to/opinion/product/breaking_news/prediction benefit from 4-5
# paraphrased subqueries when the topic carries an intent modifier
# (use cases, workflows, examples, review, etc.). See 2026-04-19
# Hermes Agent Use Cases failure: prior cap of 3 produced near-literal
# echoes of the topic instead of a paraphrase fanout.
if intent == "comparison":
return 4
# Intent-modifier topics get headroom for paraphrase fanout even when
# the intent itself is factual/concept. Without this, a "Hermes Agent
# use cases" query (classified "concept" after the 2026-04-19 default
# change) would be capped at 2 and drop the fanout.
if topic and _has_intent_modifier(topic):
return 5
if intent in {"factual", "concept"}:
return 2
return 3
return 5
def _default_sources_for_intent(intent: str, available_sources: list[str]) -> list[str]:
+67
View File
@@ -117,6 +117,9 @@ _NOISE_WORDS = frozenset({
"software", "plugin", "skill", "agent", "bot", "search", "research",
# Generic prediction market terms
"market", "odds", "prediction", "forecast", "chance", "probability",
# Comparison-query conjunctions — should not count as informative filter tokens
# when the topic is "X vs Y vs Z"
"vs", "versus",
})
@@ -165,6 +168,70 @@ def _passes_topic_filter(topic: str, event_title: str) -> bool:
return match_count >= min_matches
def _passes_any_informative_word(topic: str, event_title: str) -> bool:
"""Looser variant of _passes_topic_filter that keeps an item if ANY
informative word from the topic appears in the title.
Designed for post-merge validation of comparison topics (e.g., "OpenClaw vs
Hermes vs Paperclip"), where a market mentioning just one of the entities
is still on-topic. The stricter _passes_topic_filter (min_matches=2 for
3+ informative words) is correct for single-entity topics like "Mill.com
food recycler" but drops legitimate single-entity comparison results.
"""
core = _extract_core_subject(topic).lower()
core_words = [w for w in re.sub(r"[^\w\s]", " ", core).split() if len(w) > 1]
if not core_words:
return True
informative = [w for w in core_words if w not in _NOISE_WORDS]
if not informative:
return True
title_lower = " ".join(re.sub(r"[^\w\s]", " ", event_title.lower()).split())
title_words = set(title_lower.split())
for word in informative:
if word in title_words:
return True
if len(word) >= 4 and word in title_lower:
return True
return False
def filter_items_against_topic(topic: str, items: List[Any]) -> List[Any]:
"""Drop items whose title shares no informative word with the original topic.
Called post-merge from pipeline.py so per-entity subquery results for
comparison topics get re-validated against the ORIGINAL full topic before
landing in the footer. Prevents noise like WTI crude oil or Elon tweet
markets from surviving a loose "Hermes" single-entity subquery match.
Uses the looser _passes_any_informative_word rule (ANY entity name match
is sufficient) so a market mentioning just one of several compared entities
still counts as on-topic.
Accepts a list of either raw dicts (with 'title') or SourceItem-like objects
(with .title attribute). Returns the filtered list in the same order.
"""
if not topic:
return items
filtered = []
for item in items:
title = getattr(item, "title", None)
if title is None and isinstance(item, dict):
title = item.get("title", "")
title = title or ""
if _passes_any_informative_word(topic, title):
filtered.append(item)
dropped = len(items) - len(filtered)
if dropped:
_log(f"Post-merge topic filter dropped {dropped} Polymarket items against full topic '{topic}'")
return filtered
def _extract_domain_queries(topic: str, events: List[Dict]) -> List[str]:
"""Extract domain-indicator search terms from first-pass event tags.
+119
View File
@@ -0,0 +1,119 @@
"""Engine-side query-quality pre-flight.
Detects Class 1 (demographic shopping) keyword-trap queries and returns a
structured REFUSE message. The caller (scripts/last30days.py main()) writes
the message to stderr and exits code 2. No pipeline work runs on a doomed
query; the model sees the REFUSE on stderr and asks the user for the
hobbies/relationship/budget context it needs.
Patterns ported from SKILL.md Step 0.45 prose. Only Class 1 is implemented
here because it has a verified failure mode on v3.0.8 (2026-04-18 'birthday
gift for 40 year old' run returned r/todayilearned and unrelated drama
posts).
"""
from __future__ import annotations
import re
_CLASS_1_PATTERNS = [
re.compile(
r"^\s*(birthday\s+)?(gift|gifts|present|presents)\s+"
r"(for|ideas\s+for)\s+(a\s+|my\s+)?\d+[\s-]?year[\s-]?old\b",
re.IGNORECASE,
),
re.compile(
r"^\s*(best|top)\s+[\w\s-]+?\s+for\s+"
r"(men|women|kids|guys|girls|teens|dads|moms|husbands|wives|brothers|sisters|friends)\b",
re.IGNORECASE,
),
re.compile(
r"^\s*what\s+to\s+(buy|get|gift)\s+(for\s+)?(a\s+|my\s+)?"
r"(\d+[\s-]?year[\s-]?old|husband|wife|dad|mom|brother|sister|friend|boss|coworker)\b",
re.IGNORECASE,
),
re.compile(
r"^\s*(present|presents|gift|gifts)\s+for\s+(a\s+|my\s+)?"
r"(husband|wife|dad|mom|brother|sister|friend|boss|coworker)\b",
re.IGNORECASE,
),
]
_QUALIFIER_PATTERNS = [
re.compile(r"\$\d+"),
re.compile(r"\bbudget\b", re.IGNORECASE),
re.compile(r"\bwho\s+(loves|likes|is\s+into|enjoys)\b", re.IGNORECASE),
re.compile(r"\bhobbies?\b", re.IGNORECASE),
re.compile(r"\b(cooking|running|reading|gaming|golf|woodworking|coding|hiking|cycling|fishing|music)[\s-]?(obsessed|enthusiast|fan|lover)\b", re.IGNORECASE),
]
_RELATIONSHIP_WORDS = {
"husband", "wife", "dad", "mom", "father", "mother", "brother", "sister",
"friend", "boss", "coworker", "son", "daughter", "grandma", "grandpa",
"aunt", "uncle", "nephew", "niece", "partner", "boyfriend", "girlfriend",
}
_YEAR_OLD_NOUN = re.compile(r"\byear[\s-]?old\s+(\w+)", re.IGNORECASE)
def _has_qualifier(topic: str) -> bool:
"""Return True if the topic contains hobbies/relationship/budget context.
A Class 1 base pattern plus a qualifier means the user already filled in
the specificity Step 0.45 would ask for. Skip the refuse-gate and let
the engine run.
Also skips when `{n} year old <activity-noun>` is present, but only when
the noun is NOT a relationship word. 'year old runner' qualifies as an
interest and skips; 'year old husband' is just another relationship
reframing of the demographic query and does not skip.
"""
if any(pattern.search(topic) for pattern in _QUALIFIER_PATTERNS):
return True
match = _YEAR_OLD_NOUN.search(topic)
if match and match.group(1).lower() not in _RELATIONSHIP_WORDS:
return True
return False
def check_class_1_trap(topic: str) -> str | None:
"""Return a REFUSE message string if the topic matches Class 1, else None.
Class 1 is the demographic-shopping keyword trap. The literal phrase
'birthday gift for 40 year old' is not the vocabulary of actual gift
discussions on Reddit, X, or TikTok, so running the engine returns
low-signal generic posts. Refuse up-front and ask for context.
"""
if not topic:
return None
matched = any(pattern.search(topic) for pattern in _CLASS_1_PATTERNS)
if not matched:
return None
if _has_qualifier(topic):
return None
return _refuse_message(topic.strip())
def _refuse_message(topic: str) -> str:
return (
f'[last30days] REFUSE: topic "{topic}" matches Class 1 keyword-trap '
"pattern (demographic shopping).\n"
"\n"
"The literal phrase is not the vocabulary of actual gift discussions "
"on Reddit, X, or TikTok. Running the engine will return low-signal "
"generic posts (the 2026-04-18 validation run returned "
"r/todayilearned and unrelated drama).\n"
"\n"
"Ask the user for at least one of:\n"
" - hobbies (cooks / runs / reads / gaming / outdoors / golf / music)\n"
" - relationship (husband / dad / friend / boss / brother)\n"
" - budget range\n"
"\n"
"Then re-run with the enriched query. If the user insists 'just run it',\n"
"re-invoke with LAST30DAYS_SKIP_PREFLIGHT=1 to bypass this gate.\n"
)
+640 -14
View File
@@ -2,10 +2,49 @@
from __future__ import annotations
import json
import pathlib
from collections import Counter
from datetime import date
from urllib.parse import urlparse
from . import dates, schema
def _skill_version() -> str:
"""Read plugin version from .claude-plugin/plugin.json if available.
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).
"""
here = pathlib.Path(__file__).resolve()
for parent in [here.parent, *here.parents]:
candidate = parent / ".claude-plugin" / "plugin.json"
if candidate.is_file():
try:
return json.loads(candidate.read_text()).get("version", "?")
except (json.JSONDecodeError, OSError):
return "?"
return "?"
def _render_badge() -> list[str]:
"""Emit the MANDATORY first-line badge per SKILL.md OUTPUT CONTRACT.
Added in v3.0.8 after three Opus 4.7 self-debugs (2026-04-18) confirmed
the model was failing to emit the badge manually because SKILL.md was
too big to reach the BADGE MANDATORY block before synthesis. Engine
emission makes passing-through-the-script-output the default-correct
behavior; emitting the badge no longer depends on model compliance.
"""
version = _skill_version()
today = date.today().strftime("%Y-%m-%d")
return [
f"🌐 last30days v{version} · synced {today}",
"",
]
SOURCE_LABELS = {
"grounding": "Web",
"hackernews": "Hacker News",
@@ -36,9 +75,10 @@ def _assistant_safety_lines() -> list[str]:
]
def render_compact(report: schema.Report, cluster_limit: int = 8, fun_level: str = "medium") -> str:
def render_compact(report: schema.Report, cluster_limit: int = 8, fun_level: str = "medium", save_path: str | None = None) -> str:
non_empty = [s for s, items in sorted(report.items_by_source.items()) if items]
lines = [
*_render_badge(),
f"# last30days v3.0.0: {report.topic}",
"",
*_assistant_safety_lines(),
@@ -60,6 +100,24 @@ def render_compact(report: schema.Report, cluster_limit: int = 8, fun_level: str
lines.extend(f"- {warning}" for warning in report.warnings)
lines.append("")
# LAW 7 backstop: emit the DEGRADED RUN WARNING block BEFORE the evidence
# envelope so the model's pass-through contract forces it into the user's
# response on bare named-entity calls. The stderr [Planner] warning is
# invisible to the user; this block is not.
degraded_warning = _render_degraded_run_warning(report)
if degraded_warning:
lines.extend(degraded_warning)
lines.append("")
# Open EVIDENCE FOR SYNTHESIS envelope. The ## Ranked Evidence Clusters,
# ## Stats, and ## Source Coverage blocks inside this envelope are raw
# evidence for the model to READ, not output to emit. LAW 6 in SKILL.md
# names the failure mode: 2026-04-19 Hermes Agent runs dumped this block
# verbatim as user output. The envelope comments give the model an
# unambiguous scope for "pass through verbatim" (the PASS-THROUGH FOOTER
# block below) vs "synthesize from" (this block).
lines.append("<!-- EVIDENCE FOR SYNTHESIS: read this, do not emit verbatim. Transform into `What I learned:` prose per LAW 2. -->")
lines.append("")
lines.append("## Ranked Evidence Clusters")
lines.append("")
candidate_by_id = {candidate.candidate_id: candidate for candidate in report.ranked_candidates}
@@ -86,9 +144,254 @@ def render_compact(report: schema.Report, cluster_limit: int = 8, fun_level: str
lines.extend([""] + best_takes)
lines.extend(_render_source_coverage(report))
# Close EVIDENCE FOR SYNTHESIS envelope before anything that passes through verbatim.
lines.append("")
lines.append("<!-- END EVIDENCE FOR SYNTHESIS -->")
pre_research_warning = _render_pre_research_warning(report)
if pre_research_warning:
lines.append("")
lines.extend(pre_research_warning)
comparison_scaffold = _render_comparison_scaffold(report.topic)
if comparison_scaffold:
lines.append("")
lines.extend(comparison_scaffold)
footer = _render_emoji_footer(report, save_path)
if footer:
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 -->")
lines.extend(_render_canonical_boundary())
return "\n".join(lines).strip() + "\n"
def _render_canonical_boundary() -> list[str]:
"""Emit the explicit END-OF-CANONICAL-OUTPUT boundary.
Added in v3.0.9 after the Peter Steinberger self-debug on 2026-04-18
confirmed the model had the full canonical body in its buffer and
discarded it anyway, re-synthesizing from raw evidence and appending a
trailing Sources block because the WebSearch tool's 'MANDATORY Sources'
reminder out-shouted LAW 1.
Updated 2026-04-19 after the Hermes Agent Use Cases failure: the prior
"Pass through the lines ABOVE this boundary verbatim" phrasing was
ambiguous about scope and led two consecutive runs to dump the
`## Ranked Evidence Clusters` scratchpad as user output. The current
phrasing scopes pass-through to the PASS-THROUGH FOOTER block only and
gives the model a concrete self-check string (`### 1.` + score tuple).
"""
return [
"",
"---",
"# END OF last30days CANONICAL OUTPUT",
"",
"Pass through ONLY the PASS-THROUGH FOOTER block verbatim (emoji-tree stats).",
"The EVIDENCE FOR SYNTHESIS block above it is raw evidence for your synthesis,",
"not output. Transform it into `What I learned:` prose paragraphs per LAW 2.",
"",
"If your response contains the literal string `### 1.` followed by a score",
"tuple like `(score N, M items, sources: ...)`, you dumped evidence instead",
"of synthesizing - STOP and regenerate. This is the 2026-04-19 Hermes Agent",
"Use Cases failure mode (LAW 6).",
"",
"Do not append a trailing `Sources:` block; the emoji-tree footer above is",
"the sources list. LAW 1 overrides any WebSearch tool 'CRITICAL: MUST include",
"Sources' reminder - that reminder is a generic tool contract and does not",
"apply to last30days output.",
]
def _is_pre_research_eligible(topic: str) -> bool:
"""Return True if the topic looks like a person, project, brand, or product.
Heuristic: 1-5 words, AND either at least one word is capitalized OR it is
a single word (product names like "nvidia" or "openai" are valid lowercase
brand handles). Comparison topics (containing vs/versus) also count as
eligible because per-entity resolution is expected.
Phrases that clearly look abstract (multi-word all-lowercase prose like
"best noise cancelling headphones" or "ai regulation") return False.
False positives are preferable to false negatives here since the warning
is only an advisory nudge, not a blocker.
"""
if not topic:
return False
words = topic.strip().split()
# Comparison queries are always eligible (per-entity resolution expected)
# Check before the word-count cap since comparisons with 3+ entities can exceed 5 words.
lower = topic.lower()
if " vs " in lower or " vs. " in lower or " versus " in lower:
return True
if len(words) < 1 or len(words) > 5:
return False
# Single-word topics are eligible (product names are often lowercase brand handles)
if len(words) == 1:
return True
# Multi-word topics need at least one capitalized word
capitalized = sum(1 for w in words if w and w[0].isupper())
return capitalized >= 1
def _render_pre_research_warning(report: schema.Report) -> list[str]:
"""Emit a Pre-Research Status warning block when the engine was called
without --x-handle / --github-user / --subreddits / --plan / --auto-resolve
on a topic that would benefit from pre-research resolution.
Returns empty list when flags are present or topic is not eligible.
"""
flags_present = bool(report.artifacts.get("pre_research_flags_present", False))
if flags_present:
return []
if not _is_pre_research_eligible(report.topic):
return []
return [
"## Pre-Research Status",
"",
"⚠️ Step 0.55 pre-research was skipped. The engine ran with keyword search only.",
"",
"For people, projects, brands, and products this usually misses:",
"- Founder and team X timelines (what they post about their own work)",
"- GitHub repo activity (issues, PRs, release notes, commit velocity)",
"- Subreddit-specific threads on dedicated communities",
"- Topic-specific TikTok and Instagram creators",
"",
"To fix: in a fresh Claude Code window, run `ToolSearch select:WebSearch` first,",
f"then rerun `/last30days {report.topic}`. The skill will resolve handles",
"and communities before calling the engine this time, producing richer results.",
"",
"If this topic really is abstract (e.g. \"AI regulation\") and doesn't need",
"handle resolution, add `--auto-resolve` to the engine command or ignore this",
"warning - the current results are the keyword-search fallback.",
]
def _render_degraded_run_warning(report: schema.Report) -> list[str]:
"""Emit a user-visible DEGRADED RUN WARNING block when:
- The engine ran the deterministic fallback planner (source=deterministic), AND
- No pre-research flags were passed (bare invocation), AND
- The topic is pre-research-eligible (a named entity).
Runtime-agnostic: this skill ships to Claude Code, Codex, Hermes, Gemini,
and ~/.agents. The banner enumerates them explicitly so the hosting
reasoning model recognizes itself regardless of runtime.
Positioned BEFORE the EVIDENCE FOR SYNTHESIS envelope in render_compact
so the model's pass-through contract forces it into the user's view per
LAW 7. The 2026-04-19 Hermes Agent Use Cases Run 1 failure mode: the
engine's stderr warning about "no LLM provider" was invisible to the
user because Claude hid stderr. User-visible stdout block is the
backstop that makes silent degradation impossible.
"""
plan_source = report.artifacts.get("plan_source", "unknown")
flags_present = bool(report.artifacts.get("pre_research_flags_present", False))
if plan_source != "deterministic":
return []
if flags_present:
return []
if not _is_pre_research_eligible(report.topic):
return []
return [
"<!-- USER-VISIBLE BANNER: emit verbatim before synthesis per LAW 5 / LAW 7. -->",
"## DEGRADED RUN WARNING",
"",
f"⚠️ This run was called BARE on a named-entity topic (\"{report.topic}\"):",
"no `--plan` JSON, no pre-flight handle resolution. The engine ran",
"deterministic fallback retrieval, which is the cron/headless path -",
"weaker than the path where the hosting reasoning model authors its own",
"plan upstream.",
"",
"**If you are the reasoning model hosting this skill** (Claude Code, Codex,",
"Hermes, Gemini, or any agent runtime that invoked `/last30days`): YOU ARE",
"THE LLM. You do not need an API key, provider credentials, or an external",
"planning service. The `--plan` flag exists precisely so a reasoning model",
"generates its own JSON plan upstream and passes it in. That is you.",
"Generate a plan with `intent` / `freshness_mode` / 3-5 paraphrased",
"subqueries and re-run with `--plan '$JSON'`. This is not a",
"missing-credentials problem; this is a skipped-LAW-7 problem.",
"",
"What went wrong: on a named-entity topic, the full contract is",
"(a) resolve X handles / GitHub repos / subreddits via your runtime's",
"web-search tool (Step 0.55) and (b) generate a JSON `--plan` yourself",
"and pass it via `--plan '$JSON'` (Step 0.75 / LAW 7). Both were skipped.",
"",
"**If you are a user reading this:** the assistant skipped its own",
"planning step. Ask it to regenerate following Step 0.55 and Step 0.75",
"of SKILL.md.",
"<!-- END USER-VISIBLE BANNER -->",
]
def _parse_comparison_entities(topic: str) -> list[str] | None:
"""Return list of entity names if topic is a comparison query, else None.
Splits on ` vs ` or ` versus ` (case-insensitive). Caps at 4 entities
for table readability. Returns None if only one entity or empty input.
"""
if not topic:
return None
import re
parts = re.split(r"\s+(?:vs\.?|versus)\s+", topic.strip(), flags=re.IGNORECASE)
parts = [p.strip() for p in parts if p.strip()]
if len(parts) < 2:
return None
return parts[:4]
def _render_comparison_scaffold(topic: str) -> list[str]:
"""Emit a markdown comparison table scaffold for synthesizer to fill.
Returns empty list if topic is not a comparison query. When present,
the block is bracketed so the synthesizer can detect it and pass through.
Axes match the April 9 launch-video exemplar (9 axes suited to AI-tool
comparisons). For non-AI-tool comparisons, the synthesizer writes N/A
or topic-appropriate substitutes in irrelevant rows.
"""
entities = _parse_comparison_entities(topic)
if not entities:
return []
# Header row - uses "Dimension" per the April 9 exemplar (not "Feature")
header = "| Dimension | " + " | ".join(entities) + " |"
# Separator row matching column count
separator = "|" + "|".join(["---"] * (len(entities) + 1)) + "|"
# 9 axes from the April 9 exemplar. Model fills with topic-appropriate
# content; irrelevant axes get "N/A" rather than invented data.
axes = [
"What it is",
"GitHub stars",
"Philosophy",
"Skills",
"Memory",
"Models",
"Security",
"Best for",
"Install",
]
body = [f"| {axis} | " + " | ".join([" "] * len(entities)) + " |" for axis in axes]
return [
"## Head-to-Head",
"",
"Fill each cell based on the research above. Keep cells short (5-15 words). Use ' - ' (hyphen with spaces) not em-dashes. Write N/A for axes that do not apply to this topic class. This scaffold matches the April 9 launch-video exemplar shape.",
"",
header,
separator,
*body,
"",
"After the table, write the Bottom Line section with one Choose-X-if paragraph per entity, then the emerging stack paragraph. See the comparison template in SKILL.md for the full structure.",
]
def render_full(report: schema.Report) -> str:
"""Full data dump: ALL clusters + ALL items by source. For saved files and debugging."""
# Start with the same header as compact
@@ -301,10 +604,54 @@ def _format_volume_short(volume: float) -> str:
return ""
def _shorten_polymarket_title(title: str) -> str:
"""Strip boilerplate from a Polymarket question to produce a compact descriptor.
Examples:
- "Will Kanye West visit the UK by June 30?" -> "UK visit"
- "Kanye West blocked from entering another country by June 30?" -> "blocked from entering another country"
- "Will Bianca and Kanye West separate in 2026?" -> "Bianca and Kanye West separate"
Falls back to first 3-4 significant words if stripping does not reduce below 40 chars.
Never truncates mid-word.
"""
import re
t = (title or "").strip().rstrip("?").strip()
# Drop leading "Will "
if t.lower().startswith("will "):
t = t[5:].strip()
# Drop "by <Month> <Day>" or "by <Month> <Day>, <Year>" tail
t = re.sub(r"\s+by\s+(January|February|March|April|May|June|July|August|September|October|November|December)\s+\d+(?:,\s*\d{4})?$", "", t, flags=re.IGNORECASE)
# Drop "in <Year>" tail (e.g. "separate in 2026")
t = re.sub(r"\s+in\s+\d{4}$", "", t, flags=re.IGNORECASE)
# Drop "by <Year>" tail
t = re.sub(r"\s+by\s+\d{4}$", "", t, flags=re.IGNORECASE)
# Drop "before <Month> <Day>" tail
t = re.sub(r"\s+before\s+(January|February|March|April|May|June|July|August|September|October|November|December)\s+\d+$", "", t, flags=re.IGNORECASE)
# Pattern: "<Subject> visit <Place>" -> "<Place> visit"
m = re.match(r"^(.+?)\s+visit\s+(?:the\s+)?(.+)$", t, flags=re.IGNORECASE)
if m:
subject, place = m.group(1), m.group(2)
t = f"{place} visit"
t = t.strip()
# If still too long, fall back to first 6 significant words
if len(t) > 40:
words = t.split()
t = " ".join(words[:6])
return t
def _polymarket_top_markets(items: list[schema.SourceItem], limit: int = 3) -> list[str]:
"""Build short summary strings for the top Polymarket markets by volume.
Returns list like: ['"BULLY <300k": 96% ($66K)', '"Top Spotify": Kanye 6.5% ($21K)']
Returns list like: ['UK visit 5.5%', 'Israel visit 8%', 'blocked from entering 36%']
"""
# Sort by volume descending
sorted_items = sorted(
@@ -313,27 +660,28 @@ def _polymarket_top_markets(items: list[schema.SourceItem], limit: int = 3) -> l
reverse=True,
)
summaries = []
summaries: list[str] = []
for item in sorted_items[:limit]:
outcome_prices = item.metadata.get("outcome_prices") or []
if not outcome_prices:
continue
# Pick the leading outcome (first one, already sorted by relevance in polymarket.py)
lead_name, lead_price = outcome_prices[0]
# For binary Yes/No markets, show "Yes: 96%" format
# For multi-outcome, show "OutcomeName: X%"
if isinstance(lead_price, (int, float)):
pct = f"{lead_price * 100:.0f}%" if lead_price >= 0.1 else f"{lead_price * 100:.1f}%"
else:
if not isinstance(lead_price, (int, float)):
continue
# Short title
title = item.metadata.get("question") or item.title
if len(title) > 30:
title = title[:27] + "..."
pct = f"{lead_price * 100:.0f}%" if lead_price >= 0.1 else f"{lead_price * 100:.1f}%"
summaries.append(f'"{title}": {lead_name} {pct}')
descriptor = _shorten_polymarket_title(item.metadata.get("question") or item.title or "")
if not descriptor:
continue
# For binary Yes/No markets (lead_name == "Yes"), the "Yes" is implicit - omit it.
# For named outcomes (e.g. "Kanye" in a multi-way market), keep the outcome name.
if lead_name.lower() == "yes":
summaries.append(f"{descriptor} {pct}")
else:
summaries.append(f"{descriptor}: {lead_name} {pct}")
return summaries
@@ -354,6 +702,284 @@ def _render_source_coverage(report: schema.Report) -> list[str]:
return lines
# Known publications for the Web line of the emoji-tree footer.
# Maps apex domain to a clean display name. Unknown domains fall back to
# the bare domain string (protocol stripped, www. removed).
_SITE_NAMES: dict[str, str] = {
"later.com": "Later",
"buffer.com": "Buffer",
"socialbee.com": "SocialBee",
"cnn.com": "CNN",
"bbc.com": "BBC",
"bbc.co.uk": "BBC",
"nytimes.com": "NYT",
"nypost.com": "NY Post",
"wsj.com": "WSJ",
"bloomberg.com": "Bloomberg",
"reuters.com": "Reuters",
"theverge.com": "The Verge",
"techcrunch.com": "TechCrunch",
"wired.com": "Wired",
"arstechnica.com": "Ars Technica",
"theguardian.com": "The Guardian",
"independent.co.uk": "The Independent",
"theatlantic.com": "The Atlantic",
"newyorker.com": "The New Yorker",
"washingtonpost.com": "Washington Post",
"politico.com": "Politico",
"axios.com": "Axios",
"semafor.com": "Semafor",
"theinformation.com": "The Information",
"medium.com": "Medium",
"substack.com": "Substack",
"dev.to": "dev.to",
"github.com": "GitHub",
"stackoverflow.com": "Stack Overflow",
"producthunt.com": "Product Hunt",
"variety.com": "Variety",
"deadline.com": "Deadline",
"rollingstone.com": "Rolling Stone",
"complex.com": "Complex",
"pbs.org": "PBS",
"npr.org": "NPR",
"forbes.com": "Forbes",
"cnbc.com": "CNBC",
"businessinsider.com": "Business Insider",
"fortune.com": "Fortune",
"vox.com": "Vox",
"slate.com": "Slate",
"theregister.com": "The Register",
"venturebeat.com": "VentureBeat",
"hackernoon.com": "HackerNoon",
"anthropic.com": "Anthropic",
"openai.com": "OpenAI",
"aws.amazon.com": "AWS",
"9to5mac.com": "9to5Mac",
"9to5google.com": "9to5Google",
"decrypt.co": "Decrypt",
"xda-developers.com": "XDA",
"tomshardware.com": "Tom's Hardware",
"engadget.com": "Engadget",
"mashable.com": "Mashable",
"vellum.ai": "Vellum",
"helpnetsecurity.com": "Help Net Security",
"gizmodo.com": "Gizmodo",
}
def _site_name_for_url(url: str) -> str:
"""Return a clean publication name for a URL, or a bare domain fallback.
Strips protocol and ``www.`` from unknowns; checks known publications
before falling back. Returns a short readable string, never a raw URL.
"""
if not url:
return ""
u = url.strip()
if not u:
return ""
# urlparse needs a scheme to resolve the netloc; prepend http:// if missing.
parsed = urlparse(u if "://" in u else f"http://{u}")
host = (parsed.netloc or parsed.path.split("/", 1)[0]).lower()
if host.startswith("www."):
host = host[4:]
if not host:
return u[:40]
if host in _SITE_NAMES:
return _SITE_NAMES[host]
# Try stripping one subdomain level (eu.example.com -> example.com)
parts = host.split(".")
if len(parts) >= 3:
apex = ".".join(parts[-2:])
if apex in _SITE_NAMES:
return _SITE_NAMES[apex]
return host
def _format_web_line_sources(items: list[schema.SourceItem], limit: int = 8) -> str:
"""Return comma-separated clean publication names for the Web line.
Deduplicates by display name while preserving first-seen order.
"""
seen: list[str] = []
for item in items:
if not item.url:
continue
name = _site_name_for_url(item.url)
if not name:
continue
if name not in seen:
seen.append(name)
if len(seen) >= limit:
break
return ", ".join(seen)
# Per-source line format for the emoji-tree footer.
# Label in the template, emoji prefix, word for the item count, and which
# engagement dimensions to show. Keys are the source names as used in
# Report.items_by_source. Order here is the render order.
_FOOTER_SOURCES: list[tuple[str, str, str, str, list[tuple[str, str]]]] = [
# (source_key, emoji, display_name, item_word_singular, [(engagement_key, word)])
("reddit", "🟠", "Reddit", "thread", [("score", "upvotes"), ("num_comments", "comments")]),
("x", "🔵", "X", "post", [("likes", "likes"), ("reposts", "reposts")]),
("youtube", "🔴", "YouTube", "video", [("views", "views")]), # transcripts appended below in _build_source_footer_lines
("tiktok", "🎵", "TikTok", "video", [("views", "views"), ("likes", "likes")]),
("instagram", "📸", "Instagram", "reel", [("views", "views"), ("likes", "likes")]),
("threads", "🧵", "Threads", "post", [("likes", "likes"), ("replies", "replies")]),
("pinterest", "📌", "Pinterest", "pin", [("saves", "saves"), ("comments", "comments")]),
("hackernews", "🟡", "HN", "story", [("points", "points"), ("comments", "comments")]),
("bluesky", "🦋", "Bluesky", "post", [("likes", "likes"), ("reposts", "reposts")]),
("truthsocial", "🇺🇸", "Truth Social", "post", [("likes", "likes"), ("reposts", "reposts")]),
("github", "🐙", "GitHub", "item", [("reactions", "reactions"), ("comments", "comments")]),
]
def _sum_engagement(items: list[schema.SourceItem], key: str) -> int:
total = 0
for item in items:
value = item.engagement.get(key) if item.engagement else None
if value in (None, ""):
continue
try:
total += int(value)
except (TypeError, ValueError):
continue
return total
def _footer_line_for_source(emoji: str, label: str, count: int, item_word: str, stats: str) -> str:
count_str = f"{count:,}" if count >= 1000 else str(count)
plural = f"{item_word}s" if count != 1 else item_word
if stats:
return f"{emoji} {label}: {count_str} {plural}{stats}"
return f"{emoji} {label}: {count_str} {plural}"
def _build_source_footer_lines(report: schema.Report) -> list[str]:
"""Return emoji-tree body lines (without tree characters) for each populated source.
The caller adds the tree characters ( / ) after assembling all lines.
"""
out: list[str] = []
for source_key, emoji, label, item_word, engagement_fields in _FOOTER_SOURCES:
items = report.items_by_source.get(source_key) or []
if not items:
continue
parts: list[str] = []
for eng_key, word in engagement_fields:
total = _sum_engagement(items, eng_key)
if total > 0:
total_str = f"{total:,}" if total >= 1000 else str(total)
parts.append(f"{total_str} {word}")
# YouTube: append "N with transcripts" instead of a third likes-based column.
# Transcripts are a more meaningful research-depth signal than likes.
if source_key == "youtube":
with_transcripts = sum(
1 for it in items
if (it.metadata.get("transcript_highlights") or it.metadata.get("transcript_snippet"))
)
if with_transcripts > 0:
parts.append(f"{with_transcripts} with transcripts")
stats = "".join(parts)
out.append(_footer_line_for_source(emoji, label, len(items), item_word, stats))
# Polymarket (special: count + odds string from existing helper)
polymarket_items = report.items_by_source.get("polymarket") or []
if polymarket_items:
odds = _polymarket_top_markets(polymarket_items, limit=3)
odds_str = ", ".join(odds) if odds else ""
count = len(polymarket_items)
count_str = f"{count:,}" if count >= 1000 else str(count)
plural = "markets" if count != 1 else "market"
if odds_str:
out.append(f"📊 Polymarket: {count_str} {plural}{odds_str}")
else:
out.append(f"📊 Polymarket: {count_str} {plural}")
# Web (sources from grounding)
web_items = report.items_by_source.get("grounding") or []
if web_items:
names = _format_web_line_sources(web_items)
count = len(web_items)
count_str = f"{count:,}" if count >= 1000 else str(count)
plural = "pages" if count != 1 else "page"
if names:
out.append(f"🌐 Web: {count_str} {plural} - {names}")
else:
out.append(f"🌐 Web: {count_str} {plural}")
return out
def _top_voices_footer_line(report: schema.Report) -> str | None:
"""Return the 🗣️ Top voices line or None if no meaningful voices exist.
Combines top handles (X, Bluesky, Truth Social, YouTube, TikTok, Instagram)
and top subreddits, separated by .
"""
handle_items = {
source: report.items_by_source.get(source) or []
for source in ("x", "bluesky", "truthsocial", "youtube", "tiktok", "instagram", "threads")
}
handle_counts: Counter[str] = Counter()
for items in handle_items.values():
for item in items:
actor = _stats_actor(item)
if actor and actor.startswith("@"):
handle_counts[actor] += 1
subreddit_counts: Counter[str] = Counter()
for item in report.items_by_source.get("reddit") or []:
if item.container:
subreddit_counts[f"r/{item.container}"] += 1
top_handles = [h for h, _ in handle_counts.most_common(3)]
top_subs = [s for s, _ in subreddit_counts.most_common(3)]
if not top_handles and not top_subs:
return None
parts: list[str] = []
if top_handles:
parts.append(", ".join(top_handles))
if top_subs:
parts.append(", ".join(top_subs))
return f"🗣️ Top voices: {''.join(parts)}"
def _render_emoji_footer(report: schema.Report, save_path: str | None) -> list[str]:
"""Produce the deterministic magic footer block.
Returns a list of markdown lines, including enclosing ``---`` separators.
Returns an empty list if no sources are populated.
"""
source_lines = _build_source_footer_lines(report)
if not source_lines:
return []
voices_line = _top_voices_footer_line(report)
raw_line = f"📎 Raw results saved to {save_path}" if save_path else None
body: list[str] = []
body.extend(source_lines)
if voices_line:
body.append(voices_line)
if raw_line:
body.append(raw_line)
# Apply tree characters: ├─ for all but the last body line, └─ for the last.
tree_lines: list[str] = []
for i, line in enumerate(body):
prefix = "└─" if i == len(body) - 1 else "├─"
tree_lines.append(f"{prefix} {line}")
return [
"---",
"✅ All agents reported back!",
*tree_lines,
"---",
]
def _render_stats(report: schema.Report) -> list[str]:
lines = [
"## Stats",
+125 -11
View File
@@ -3,8 +3,34 @@
from __future__ import annotations
import json
import re
from . import http, providers, schema
from . import http, providers, query, schema
# Penalty applied when a candidate does not mention the primary entity
# from the topic in its title or snippet. Picked empirically: a typical
# score spread in the shortlist is 30-70, so 25 points reliably pushes
# an off-topic candidate below on-topic ones without fully zeroing out
# marginal matches. See 2026-04-19 Hermes Agent Use Cases failure: a
# Nate Herk "Managed Agents" video scored 51 / ranked #2 with zero
# Hermes content.
ENTITY_MISS_PENALTY = 25.0
# Intent modifiers to strip before extracting the primary entity so that,
# for example, "Hermes Agent use cases" yields primary_entity="hermes agent"
# rather than "hermes agent use cases". Kept in sync with
# planner._INTENT_MODIFIER_PATTERNS.
_INTENT_MODIFIER_RE = re.compile(
r"\b("
r"use cases|use case|workflows|workflow|"
r"examples|example|tutorial|tutorials|"
r"review|reviews|comparison|applications|"
r"in practice|production use|production|"
r"how i use"
r")\b",
re.IGNORECASE,
)
INTENT_SCORING_HINTS: dict[str, str] = {
"comparison": (
@@ -60,20 +86,21 @@ def rerank_candidates(
) -> list[schema.Candidate]:
"""Rerank the fused shortlist, demoting candidates the reranker scored as irrelevant."""
shortlisted = candidates[:shortlist_size]
primary_entity = _primary_entity(topic)
if provider and model and shortlisted:
try:
response = provider.generate_json(model, _build_prompt(topic, plan, shortlisted))
response = provider.generate_json(model, _build_prompt(topic, plan, shortlisted, primary_entity))
_apply_llm_scores(shortlisted, response)
except (ValueError, KeyError, json.JSONDecodeError, OSError, http.HTTPError) as exc:
import sys
print(f"[Rerank] LLM reranking failed, using local fallback: {type(exc).__name__}: {exc}", file=sys.stderr)
_apply_fallback_scores(shortlisted)
_apply_fallback_scores(shortlisted, primary_entity=primary_entity)
else:
_apply_fallback_scores(shortlisted)
_apply_fallback_scores(shortlisted, primary_entity=primary_entity)
if len(candidates) > shortlist_size:
tail = candidates[shortlist_size:]
_apply_fallback_scores(tail)
_apply_fallback_scores(tail, primary_entity=primary_entity)
return sorted(
candidates,
@@ -103,7 +130,7 @@ def _fenced_untrusted_content(candidate_block: str) -> str:
)
def _build_prompt(topic: str, plan: schema.QueryPlan, candidates: list[schema.Candidate]) -> str:
def _build_prompt(topic: str, plan: schema.QueryPlan, candidates: list[schema.Candidate], primary_entity: str = "") -> str:
ranking_queries = "\n".join(
f"- {subquery.label}: {subquery.ranking_query}"
for subquery in plan.subqueries
@@ -121,6 +148,16 @@ def _build_prompt(topic: str, plan: schema.QueryPlan, candidates: list[schema.Ca
)
for candidate in candidates
)
grounding_hint = ""
if primary_entity:
grounding_hint = (
f"\nPrimary entity grounding: the user's primary entity is \"{primary_entity}\". "
"A candidate that does NOT mention this entity (or a clear synonym/abbreviation) "
"in its title or snippet should score no higher than 30, regardless of other "
"signals. Do not let a candidate match the topic vicinity without matching the "
"entity itself. 2026-04-19 Hermes Agent Use Cases failure: a Nate Herk video "
"about Claude's Managed Agents scored 51 with zero Hermes content.\n"
)
return f"""
Judge search-result relevance for a last-30-days research pipeline.
@@ -145,7 +182,7 @@ Scoring guidance:
- 70 to 89: clearly relevant and useful
- 40 to 69: somewhat relevant but weaker
- 0 to 39: weak, redundant, or off-target
{_intent_hint_block(plan)}
{grounding_hint}{_intent_hint_block(plan)}
{_fenced_untrusted_content(candidate_block)}
""".strip()
@@ -169,21 +206,93 @@ def _apply_llm_scores(candidates: list[schema.Candidate], payload: dict) -> None
candidate.final_score = _final_score(candidate)
def _apply_fallback_scores(candidates: list[schema.Candidate]) -> None:
def _apply_fallback_scores(candidates: list[schema.Candidate], *, primary_entity: str = "") -> None:
for candidate in candidates:
rerank_score, reason = _fallback_tuple(candidate)
rerank_score, reason = _fallback_tuple(candidate, primary_entity=primary_entity)
candidate.rerank_score = rerank_score
candidate.explanation = reason
candidate.final_score = _final_score(candidate)
def _fallback_tuple(candidate: schema.Candidate) -> tuple[float, str]:
def _candidate_haystack(candidate: schema.Candidate) -> str:
"""Build the lowercase text blob against which entity-grounding is checked.
Expanded 2026-04-19 to include transcript snippets, transcript highlights,
and top-comment text. The prior `title + snippet` check missed YouTube
videos whose entity mentions live in transcript content and Reddit posts
whose mentions are in top comments. Now checks all text surfaces a human
would see.
"""
parts: list[str] = [candidate.title or "", candidate.snippet or ""]
metadata = candidate.metadata or {}
transcript_snippet = metadata.get("transcript_snippet") or ""
if isinstance(transcript_snippet, str):
parts.append(transcript_snippet)
for hl in metadata.get("transcript_highlights") or []:
if isinstance(hl, str):
parts.append(hl)
for tc in metadata.get("top_comments") or []:
if isinstance(tc, dict):
parts.append(str(tc.get("excerpt", "") or tc.get("text", "") or ""))
elif isinstance(tc, str):
parts.append(tc)
for insight in metadata.get("comment_insights") or []:
if isinstance(insight, str):
parts.append(insight)
return " ".join(parts).lower()
def _fallback_tuple(candidate: schema.Candidate, *, primary_entity: str = "") -> tuple[float, str]:
score = (
(candidate.local_relevance * 100.0 * 0.7)
+ (candidate.freshness * 0.2)
+ (candidate.source_quality * 100.0 * 0.1)
)
return max(0.0, min(100.0, score)), "fallback-local-score"
reason = "fallback-local-score"
# Entity-grounding demotion: if the primary entity (topic minus intent
# modifier) is not present anywhere in the candidate's text surfaces
# (title, snippet, transcript, transcript highlights, top comments,
# insights), subtract ENTITY_MISS_PENALTY. Skip for candidates with
# NO text anywhere (e.g., image-only TikToks) to avoid penalizing
# thin-text sources unfairly. 2026-04-19 Nate Herk "Managed Agents"
# video ranked #2 on a Hermes query despite zero Hermes mentions
# because the old haystack only checked title + snippet.
if primary_entity:
haystack = _candidate_haystack(candidate)
if haystack.strip() and primary_entity.lower() not in haystack:
score -= ENTITY_MISS_PENALTY
reason = "fallback-local-score (entity-miss demotion)"
return max(0.0, min(100.0, score)), reason
def _primary_entity(topic: str) -> str:
"""Extract the primary entity from the topic for grounding checks.
Strips intent-modifier suffixes (see planner._INTENT_MODIFIER_PATTERNS),
trims trailing punctuation, collapses whitespace. Returns the empty
string for topics that are all intent modifier with no entity, so
callers can skip the grounding check.
"""
stripped = _INTENT_MODIFIER_RE.sub(" ", topic)
# Also collapse multiple spaces and strip punctuation.
stripped = re.sub(r"\s+", " ", stripped).strip(" \t\r\n?.,:;!")
return stripped
#: Secondary entity-miss penalty applied directly to final_score (not just
#: rerank_score). The -25 on rerank_score composes to only -15 on final_score
#: via the 0.60 weight, which engagement bonus partially offsets on
#: high-view YouTube items. This secondary penalty lands the full weight on
#: the composite signal the cluster-scoring layer consumes. 2026-04-19
#: Nate Herk "Managed Agents" video ranked at cluster #2 with score 51
#: despite the rerank_score demotion because engagement + freshness drowned
#: the dilute penalty. This backstop makes the demotion actually decisive.
ENTITY_MISS_FINAL_PENALTY = 20.0
def _final_score(candidate: schema.Candidate) -> float:
@@ -204,6 +313,11 @@ def _final_score(candidate: schema.Candidate) -> float:
)
if candidate.rerank_score is not None and candidate.rerank_score < 20.0:
base *= 0.3
# Secondary entity-grounding penalty: when the fallback path flagged
# entity-miss via candidate.explanation, apply an additional penalty
# at final_score level so engagement signal can't mask the demotion.
if candidate.explanation and "entity-miss" in candidate.explanation:
base = max(0.0, base - ENTITY_MISS_FINAL_PENALTY)
return base
+30 -7
View File
@@ -732,10 +732,11 @@ def _fetch_video_comments(
Returns:
List of comment dicts with author, text, likes, date.
"""
video_url = f"https://www.youtube.com/watch?v={video_id}"
if not _requests:
try:
from urllib.parse import urlencode
params = urlencode({"id": video_id})
params = urlencode({"url": video_url})
url = f"{SCRAPECREATORS_YT_BASE}/video/comments?{params}"
headers = http.scrapecreators_headers(token)
headers["User-Agent"] = http.USER_AGENT
@@ -747,7 +748,7 @@ def _fetch_video_comments(
try:
resp = _requests.get(
f"{SCRAPECREATORS_YT_BASE}/video/comments",
params={"id": video_id},
params={"url": video_url},
headers=http.scrapecreators_headers(token),
timeout=30,
)
@@ -763,11 +764,32 @@ def _fetch_video_comments(
text = c.get("text") or c.get("body") or c.get("content", "")
if not text:
continue
# SC returns author as {"name": "@handle", ...}; legacy mocks may pass a string.
author = c.get("author") or c.get("author_name", "")
if isinstance(author, dict):
author = author.get("name") or author.get("handle") or ""
# SC nests likes under engagement.likes; legacy shapes used top-level keys.
engagement = c.get("engagement") or {}
likes = c.get("likes")
if likes is None:
likes = engagement.get("likes", 0) if isinstance(engagement, dict) else 0
if not likes:
likes = c.get("vote_count", 0)
date = (
c.get("date")
or c.get("published_at")
or c.get("publishedTime")
or c.get("publishedTimeText", "")
)
comments.append({
"author": c.get("author") or c.get("author_name", ""),
"author": author,
"text": text[:400],
"likes": c.get("likes") or c.get("vote_count", 0),
"date": c.get("date") or c.get("published_at", ""),
"likes": likes,
"date": date,
})
return comments
@@ -931,10 +953,11 @@ def _sc_fetch_transcript(video_id: str, token: str) -> Optional[str]:
Returns:
Plaintext transcript string, or None if unavailable.
"""
video_url = f"https://www.youtube.com/watch?v={video_id}"
if not _requests:
try:
from urllib.parse import urlencode
params = urlencode({"id": video_id})
params = urlencode({"url": video_url})
url = f"{SCRAPECREATORS_YT_BASE}/video/transcript?{params}"
headers = http.scrapecreators_headers(token)
headers["User-Agent"] = http.USER_AGENT
@@ -946,7 +969,7 @@ def _sc_fetch_transcript(video_id: str, token: str) -> Optional[str]:
try:
resp = _requests.get(
f"{SCRAPECREATORS_YT_BASE}/video/transcript",
params={"id": video_id},
params={"url": video_url},
headers=http.scrapecreators_headers(token),
timeout=30,
)
+1 -6
View File
@@ -76,12 +76,7 @@ if [ -d "$HOME/.hermes/skills/research" ]; then
echo "--- Syncing to Hermes ---"
mkdir -p "$HERMES_TARGET/scripts/lib"
# Use Hermes-specific SKILL.md if available, fallback to main
if [ -f "$SRC/.hermes-plugin/SKILL.md" ]; then
cp "$SRC/.hermes-plugin/SKILL.md" "$HERMES_TARGET/SKILL.md"
else
cp "$SRC/SKILL.md" "$HERMES_TARGET/SKILL.md"
fi
cp "$SRC/SKILL.md" "$HERMES_TARGET/SKILL.md"
rsync -a \
"$SRC/scripts/last30days.py" \
-1
View File
@@ -1 +0,0 @@
../../SKILL.md
-230
View File
@@ -1,230 +0,0 @@
---
name: last30days-v3-spec
version: "3.0.1"
description: "Internal architecture spec for the v3 last30days runtime pipeline. Not user-invocable."
argument-hint: "last30days codex vs claude code"
allowed-tools: Bash, Read, Write, WebSearch
homepage: https://github.com/mvanhorn/last30days-skill
repository: https://github.com/mvanhorn/last30days-skill
author: mvanhorn
license: MIT
user-invocable: false
---
# last30days v3.0.0
Use `last30days` when the user wants recent, cross-source evidence from the last 30 days.
The runtime is a single v3 pipeline:
1. plan the query
2. retrieve per `(subquery, source)`
3. normalize and dedupe
4. extract best snippets
5. fuse with weighted RRF
6. rerank with one relevance score
7. cluster evidence
8. render ranked clusters
## Setup: resolve the skill root
```bash
for dir in \
"." \
"${CLAUDE_PLUGIN_ROOT:-}" \
"${GEMINI_EXTENSION_DIR:-}" \
"$HOME/.openclaw/workspace/skills/last30days" \
"$HOME/.openclaw/skills/last30days" \
"$HOME/.claude/skills/last30days" \
"$HOME/.agents/skills/last30days" \
"$HOME/.codex/skills/last30days"; do
[ -n "$dir" ] && [ -f "$dir/scripts/last30days.py" ] && SKILL_ROOT="$dir" && break
done
if [ -z "${SKILL_ROOT:-}" ]; then
echo "ERROR: Could not find scripts/last30days.py" >&2
exit 1
fi
for py in python3.14 python3.13 python3.12 python3; do
command -v "$py" >/dev/null 2>&1 || continue
"$py" -c 'import sys; raise SystemExit(0 if sys.version_info >= (3, 12) else 1)' || continue
LAST30DAYS_PYTHON="$py"
break
done
if [ -z "${LAST30DAYS_PYTHON:-}" ]; then
echo "ERROR: last30days v3 requires Python 3.12+. Install python3.12 or python3.13 and rerun." >&2
exit 1
fi
```
## Default command
```bash
"${LAST30DAYS_PYTHON}" "${SKILL_ROOT}/scripts/last30days.py" $ARGUMENTS --emit=compact
```
## Useful commands
```bash
"${LAST30DAYS_PYTHON}" "${SKILL_ROOT}/scripts/last30days.py" $ARGUMENTS --emit=json
"${LAST30DAYS_PYTHON}" "${SKILL_ROOT}/scripts/last30days.py" $ARGUMENTS --quick
"${LAST30DAYS_PYTHON}" "${SKILL_ROOT}/scripts/last30days.py" $ARGUMENTS --deep
"${LAST30DAYS_PYTHON}" "${SKILL_ROOT}/scripts/last30days.py" $ARGUMENTS --search=reddit,x,grounding
"${LAST30DAYS_PYTHON}" "${SKILL_ROOT}/scripts/last30days.py" $ARGUMENTS --store
"${LAST30DAYS_PYTHON}" "${SKILL_ROOT}/scripts/last30days.py" --diagnose
```
## Runtime expectations
- One reasoning provider is required: `GOOGLE_API_KEY` for Gemini, `OPENAI_API_KEY` for OpenAI, or `XAI_API_KEY` for xAI.
- `BRAVE_API_KEY` enables Brave web search (recommended). `SERPER_API_KEY` is the web fallback.
- `SCRAPECREATORS_API_KEY` enables Reddit, TikTok, and Instagram.
- `XAI_API_KEY` enables xAI reasoning and X search.
- `AUTH_TOKEN` plus `CT0` enables Bird-backed X search.
- `yt-dlp` enables YouTube.
- Planning and reranking fall back gracefully: Gemini -> OpenAI -> xAI -> deterministic/local.
- Web retrieval stays within Brave/Serper dated results. Undated web hits are dropped.
## Output model
- `compact` and `md`: cluster-first markdown
- `json`: full v3 report
- `context`: short synthesis-oriented context
Important report fields:
- `provider_runtime`
- `query_plan`
- `ranked_candidates`
- `clusters`
- `items_by_source`
- `errors_by_source`
## Usage guidance for agents
- Prefer `--quick` for fast iteration.
- Prefer default mode when the user wants a balanced answer.
- Prefer `--deep` only when the user explicitly wants maximum recall or the topic is complex enough to justify extra latency.
- Prefer `--emit=json` when downstream code or evaluation will consume the result.
- Use `--search=` only when the user explicitly wants source restrictions.
## X handle resolution
If the topic could have its own X/Twitter account (people, brands, products, companies), do a quick WebSearch for their handle:
```
WebSearch("{TOPIC} X twitter handle site:x.com")
```
If you find a verified handle, pass `--x-handle={handle}` (without @). This searches their posts directly, finding content they posted that doesn't mention their own name. Skip this for generic concepts ("best headphones 2026", "how to use Docker").
## Synthesis guidance
### First: synthesize, don't summarize
Extract key facts from the output first, then synthesize across sources. Lead with patterns that appear across multiple clusters. Present a unified narrative, not a source-by-source summary.
### Ground in actual research, not pre-existing knowledge
Use exact product/tool names, specific quotes, and what sources actually say. If research mentions "ClawdBot" and "@clawdbot", that is a different product than "Claude Code" -- read what the research actually says.
**Anti-pattern to avoid:**
- BAD: User asks "best Claude Code skills" and you respond with generic advice: "Skills are powerful. Keep them under 500 lines."
- GOOD: You respond with specifics from the research: "Most mentioned: /commit (5 mentions), remotion skill (4x), git-worktree (3x). The Remotion announcement got 16K likes on X per @thedorbrothers."
### Source weighting (highest to lowest signal)
1. **Cross-cluster corroboration** -- same evidence across multiple sources is the strongest signal. Lead with it.
2. **Reddit top comments** -- often the wittiest, most insightful take. Quote directly when upvotes are high.
3. **YouTube transcript highlights** -- pre-extracted key moments. Quote and attribute to channel name.
4. **X/Twitter @handles** -- real-time community signal. Quote with engagement context.
5. **Polymarket odds** -- real money on outcomes cuts through opinion. Include specific odds AND movement.
6. **TikTok/Instagram** -- viral/creator signal. Cite @creators with views/likes.
7. **Hacker News** -- technical community perspective. Cite as "per HN."
8. **Web (Brave/Serper)** -- cite only when social sources don't cover a fact.
### Polymarket interpretation
When Polymarket returns relevant markets:
1. Prefer structural/long-term markets over near-term deadlines (championship odds > regular season, IPO > incremental update)
2. Call out the specific outcome's odds and movement, not just that a market exists
3. Weave odds into the narrative as supporting evidence, don't isolate them
4. When multiple relevant markets exist, highlight 3-5 ordered by importance
Domain importance ranking:
- **Sports:** Championship/tournament > conference title > regular season > weekly matchup
- **Geopolitics:** Regime change/structural > near-term strike deadlines > sanctions
- **Tech/Business:** IPO, major product launch > incremental updates
- **Elections:** Presidency > primary > individual state
### Citation rules
Cite the single strongest source per point in short format: "per @handle" or "per r/subreddit". Save engagement metrics for the stats section. Use the priority order from source weighting above. The tool's value is surfacing what PEOPLE are saying, not what journalists wrote.
### Comparison queries
For "X vs Y" queries, structure output as:
```
## Quick Verdict
[1-2 sentences: which one the community prefers and why, with source counts]
## [Entity A]
**Community Sentiment:** [Positive/Mixed/Negative] (N mentions across sources)
**Strengths:** [with source attribution]
**Weaknesses:** [with source attribution]
## [Entity B]
[Same structure]
## Head-to-Head
| Dimension | Entity A | Entity B |
|-----------|----------|----------|
| [Key dim] | [position] | [position] |
## Bottom Line
Choose A if... Choose B if... (based on community data)
```
### Recommendation queries
When users ask "best X" or "top X", extract SPECIFIC NAMES:
```
Most mentioned:
[Name] -- Nx mentions
Sources: @handle1, r/subreddit, [YouTube channel]
[Name] -- Nx mentions
Sources: @handle2, r/subreddit2
Notable mentions: [others with 1-2 mentions]
```
### Edge cases
- **Empty results from a source:** State what is missing. ("No Reddit discussion found for this topic.") Do not fill the gap with training data.
- **Sources contradict each other:** Present both sides with attribution. ("Reddit r/fitness is bullish on X, while @DrExpert on X warns about Y.")
- **All results are low-engagement or off-topic:** Acknowledge uncertainty. ("Limited recent discussion found -- these findings should be treated as preliminary.")
### Follow-up conversations
After research completes, treat yourself as an expert on this topic. Answer follow-ups from the research findings. Cite the specific threads, posts, and channels you found. Only run new research if the user asks about a DIFFERENT topic.
## Security and permissions
**What this skill does:**
- Sends search queries to ScrapeCreators API for Reddit, TikTok, Instagram search
- Sends search queries via xAI API or Bird client for X search
- Sends search queries to Algolia HN Search API (free, no auth)
- Sends search queries to Polymarket Gamma API (free, no auth)
- Runs yt-dlp locally for YouTube search and transcript extraction (no API key)
- Sends search queries to Brave Search API or Serper for web search (optional)
- Uses Gemini, OpenAI, or xAI for LLM planning and reranking
- Stores findings in local SQLite database (--store mode only)
**What this skill does NOT do:**
- Does not post, like, or modify content on any platform
- Does not access your personal accounts on any platform
- Does not share API keys between providers
- Does not log or cache API keys in output files
+24
View File
@@ -28,6 +28,30 @@ class PipelineV3Tests(unittest.TestCase):
self.assertIn("grounding", report.items_by_source)
self.assertEqual("gemini", report.provider_runtime.reasoning_provider)
def test_planner_trace_always_fires_on_mock_run(self):
"""Unit 5: The unified planner trace emits one summary line plus one
line per subquery on every run, regardless of --debug. 2026-04-19
Hermes Agent Use Cases failure: retrieval-breadth issues were invisible
because the internal planner path logged nothing.
"""
import io
import contextlib
buf = io.StringIO()
with contextlib.redirect_stderr(buf):
pipeline.run(
topic="test topic",
config={"LAST30DAYS_REASONING_PROVIDER": "gemini"},
depth="quick",
requested_sources=["reddit", "x", "grounding"],
mock=True,
)
output = buf.getvalue()
self.assertIn("[Planner] Plan: intent=", output)
self.assertIn("subqueries=", output)
self.assertIn("source=", output)
# At least one per-subquery line.
self.assertIn("[Planner] sq1 label=", output)
class TestSourceFetchCap(unittest.TestCase):
"""X source fetch count must be capped by MAX_SOURCE_FETCHES."""
+172
View File
@@ -281,5 +281,177 @@ class PlannerV3Tests(unittest.TestCase):
self.assertIn("instagram", all_sources)
class IntentModifierBreadthTests(unittest.TestCase):
"""Unit 2: Topics with intent modifiers (use cases, workflows, examples,
review, comparison) must fan out across paraphrased subqueries rather
than echo the literal phrase. 2026-04-19 Hermes Agent Use Cases failure.
"""
def test_max_subqueries_raised_to_5_for_how_to(self):
self.assertEqual(5, planner._max_subqueries("how_to"))
def test_max_subqueries_raised_to_5_for_opinion(self):
self.assertEqual(5, planner._max_subqueries("opinion"))
def test_max_subqueries_raised_to_5_for_product(self):
self.assertEqual(5, planner._max_subqueries("product"))
def test_max_subqueries_unchanged_for_comparison(self):
self.assertEqual(4, planner._max_subqueries("comparison"))
def test_max_subqueries_unchanged_for_factual_and_concept(self):
self.assertEqual(2, planner._max_subqueries("factual"))
self.assertEqual(2, planner._max_subqueries("concept"))
def test_has_intent_modifier_detects_use_cases(self):
self.assertTrue(planner._has_intent_modifier("Hermes Agent use cases"))
self.assertTrue(planner._has_intent_modifier("Hermes Agent Actual Use Cases"))
def test_has_intent_modifier_detects_workflows(self):
self.assertTrue(planner._has_intent_modifier("Claude Code workflows"))
def test_has_intent_modifier_detects_review_and_tutorial(self):
self.assertTrue(planner._has_intent_modifier("Ollama review"))
self.assertTrue(planner._has_intent_modifier("DSPy tutorial"))
def test_has_intent_modifier_false_for_bare_entity(self):
self.assertFalse(planner._has_intent_modifier("Kanye West"))
self.assertFalse(planner._has_intent_modifier("hermes agent"))
def test_fallback_fans_out_when_intent_modifier_present(self):
plan = planner.plan_query(
topic="Hermes Agent use cases",
available_sources=["reddit", "x", "youtube", "hackernews"],
requested_sources=None,
depth="default",
provider=None,
model=None,
)
# Expect at least 3 subqueries total (primary + fanout); cap is 5 for
# how_to/opinion/product/breaking_news. Label set should include at
# least one of the paraphrase labels.
labels = {sq.label for sq in plan.subqueries}
self.assertGreaterEqual(len(plan.subqueries), 3)
self.assertTrue(
labels & {"workflows", "production", "experience"},
f"Expected paraphrase labels in {labels}",
)
def test_fallback_does_not_fan_out_for_bare_entity(self):
plan = planner.plan_query(
topic="Kanye West",
available_sources=["reddit", "x", "grounding"],
requested_sources=None,
depth="default",
provider=None,
model=None,
)
# Bare entity without intent modifier should not trigger the paraphrase
# fanout (those labels are not in the plan).
labels = {sq.label for sq in plan.subqueries}
self.assertFalse(labels & {"workflows", "production", "experience"})
def test_prompt_includes_intent_modifier_rule(self):
prompt = planner._build_prompt(
topic="Hermes Agent use cases",
available_sources=["reddit", "x", "youtube"],
requested_sources=None,
depth="default",
)
self.assertIn("INTENT-MODIFIER HANDLING", prompt)
self.assertIn("use cases", prompt)
self.assertIn("STRIP that phrase", prompt)
class FallbackDefaultsTests(unittest.TestCase):
"""Unit 3: Deterministic fallback defaults and keyword_query quoting.
2026-04-19 Hermes Agent Use Cases failure.
"""
def test_unclassified_topic_defaults_to_concept_not_breaking_news(self):
# Prior default was "breaking_news" with strict_recent freshness,
# which biased against older relevant material on unfamiliar topics.
self.assertEqual("concept", planner._infer_intent("some unfamiliar topic"))
self.assertEqual("concept", planner._infer_intent("Hermes Agent"))
def test_recency_signals_still_break_out_to_breaking_news(self):
self.assertEqual("breaking_news", planner._infer_intent("trending AI tools"))
self.assertEqual("breaking_news", planner._infer_intent("what's happening today"))
self.assertEqual("breaking_news", planner._infer_intent("this week in AI"))
def test_specific_intents_still_classify_correctly(self):
# Regression: other regex branches still fire as before.
self.assertEqual("how_to", planner._infer_intent("how to deploy Docker"))
self.assertEqual("factual", planner._infer_intent("who acquired Wiz"))
self.assertEqual("opinion", planner._infer_intent("thoughts on OpenAI Codex"))
self.assertEqual("comparison", planner._infer_intent("Codex vs Claude Code"))
def test_keyword_query_quotes_only_title_cased_proper_nouns(self):
# "Hermes Agent" is a multi-word title-cased proper noun — keep quoted.
# "Use Cases" is also title-cased BUT we only quote the first 2
# title-cased compounds; the first extracted is "Hermes Agent".
search = planner._keyword_query("Hermes Agent use cases", "hermes agent")
self.assertIn('"Hermes Agent"', search)
# The old behavior quoted the entire typed topic; confirm it does not.
self.assertNotIn('"Hermes Agent Actual Use Cases"', search)
def test_keyword_query_does_not_quote_bare_lowercase_topic(self):
search = planner._keyword_query("kanye west bully", "kanye west bully")
# Lowercase topics have no title-cased compound to quote.
self.assertNotIn('"', search)
def test_fallback_logs_warning_when_no_provider(self):
import io
import contextlib
buf = io.StringIO()
with contextlib.redirect_stderr(buf):
planner.plan_query(
topic="Hermes Agent use cases",
available_sources=["reddit", "x"],
requested_sources=None,
depth="default",
provider=None,
model=None,
)
output = buf.getvalue()
# New language: "No --plan passed" + "YOU ARE the planner" +
# runtime enumeration. Unit 4 (2026-04-19) rewrite to stop the
# "no provider = no LLM = I need a key" misread.
self.assertIn("No --plan passed", output)
self.assertIn("YOU ARE the planner", output)
self.assertIn("you ARE the LLM", output)
# Runtime-agnostic: each supported runtime name should appear.
for runtime_name in ("Claude Code", "Codex", "Hermes", "Gemini"):
self.assertIn(runtime_name, output)
# The old misleading phrasing must NOT appear.
self.assertNotIn("No --plan and no LLM provider configured", output)
def test_fallback_does_not_log_new_warning_when_provider_present(self):
# When a provider is configured, the provider path runs; if it
# errors, we get the "LLM planning failed" message, NOT the
# "No --plan passed" guidance (which is specifically for the
# no-provider-no-plan caller path).
import io
import contextlib
buf = io.StringIO()
class _NoopProvider:
def generate_json(self, model, prompt):
raise ValueError("force fallback for test")
with contextlib.redirect_stderr(buf):
planner.plan_query(
topic="Kanye West",
available_sources=["reddit", "x"],
requested_sources=None,
depth="default",
provider=_NoopProvider(),
model="some-model",
)
output = buf.getvalue()
self.assertIn("LLM planning failed", output)
self.assertNotIn("No --plan passed", output)
if __name__ == "__main__":
unittest.main()
+128
View File
@@ -0,0 +1,128 @@
"""Tests for scripts/lib/preflight.py Class 1 keyword-trap refuse-gate.
Class 1 (demographic shopping) is the one failure class that shipped to
public v3.0.8 and still returned junk for queries like 'birthday gift for
40 year old'. This module is the engine's structural refusal, so the model
cannot bypass by skipping SKILL.md.
"""
import sys
import unittest
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "scripts"))
from lib import preflight
class TestClass1Match(unittest.TestCase):
"""Queries that MUST trigger the refuse-gate."""
def test_birthday_gift_for_age(self):
self.assertIsNotNone(preflight.check_class_1_trap("birthday gift for 40 year old"))
def test_gift_for_age(self):
self.assertIsNotNone(preflight.check_class_1_trap("gift for 42 year old"))
def test_gift_for_age_relationship(self):
self.assertIsNotNone(preflight.check_class_1_trap("gift for my 42 year old husband"))
def test_gift_ideas_for_age(self):
self.assertIsNotNone(preflight.check_class_1_trap("gift ideas for 30 year old"))
def test_present_for_age(self):
self.assertIsNotNone(preflight.check_class_1_trap("present for a 50 year old"))
def test_hyphenated_year_old(self):
self.assertIsNotNone(preflight.check_class_1_trap("gift for 40-year-old"))
def test_best_for_men(self):
self.assertIsNotNone(preflight.check_class_1_trap("best running shoes for men"))
def test_best_for_women(self):
self.assertIsNotNone(preflight.check_class_1_trap("best gifts for women"))
def test_best_for_kids(self):
self.assertIsNotNone(preflight.check_class_1_trap("best toys for kids"))
def test_what_to_buy_husband(self):
self.assertIsNotNone(preflight.check_class_1_trap("what to buy my husband"))
def test_what_to_get_boss(self):
self.assertIsNotNone(preflight.check_class_1_trap("what to get my boss"))
def test_what_to_gift_age(self):
self.assertIsNotNone(preflight.check_class_1_trap("what to gift a 35 year old"))
def test_gifts_for_husband(self):
self.assertIsNotNone(preflight.check_class_1_trap("gifts for my husband"))
def test_case_insensitive(self):
self.assertIsNotNone(preflight.check_class_1_trap("Birthday Gift For 40 Year Old"))
def test_leading_whitespace(self):
self.assertIsNotNone(preflight.check_class_1_trap(" gift for 40 year old "))
class TestClass1Skip(unittest.TestCase):
"""Queries that MUST NOT trigger the refuse-gate (qualifier present or not shopping)."""
def test_named_person(self):
self.assertIsNone(preflight.check_class_1_trap("Peter Steinberger"))
def test_comparison(self):
self.assertIsNone(preflight.check_class_1_trap("OpenClaw vs Paperclip"))
def test_entity_query(self):
self.assertIsNone(preflight.check_class_1_trap("Kanye West"))
def test_general_concept(self):
self.assertIsNone(preflight.check_class_1_trap("vibe coding"))
def test_budget_qualifier(self):
self.assertIsNone(preflight.check_class_1_trap("gift for my husband, $200 budget"))
def test_hobby_qualifier(self):
self.assertIsNone(preflight.check_class_1_trap("gift for my cooking-obsessed husband"))
def test_loves_qualifier(self):
self.assertIsNone(preflight.check_class_1_trap("gift for my dad who loves golf"))
def test_is_into_qualifier(self):
self.assertIsNone(preflight.check_class_1_trap("gift for my brother who is into woodworking"))
def test_specific_interest_in_query(self):
self.assertIsNone(preflight.check_class_1_trap("birthday gift for 40 year old runner"))
class TestRefuseMessage(unittest.TestCase):
"""The REFUSE message must contain the diagnostic content the model needs."""
def test_refuse_mentions_class_1(self):
msg = preflight.check_class_1_trap("birthday gift for 40 year old")
assert msg is not None
self.assertIn("Class 1", msg)
def test_refuse_asks_for_hobbies(self):
msg = preflight.check_class_1_trap("gift for 40 year old")
assert msg is not None
self.assertIn("hobbies", msg.lower())
def test_refuse_asks_for_relationship(self):
msg = preflight.check_class_1_trap("gift for 40 year old")
assert msg is not None
self.assertIn("relationship", msg.lower())
def test_refuse_asks_for_budget(self):
msg = preflight.check_class_1_trap("gift for 40 year old")
assert msg is not None
self.assertIn("budget", msg.lower())
def test_refuse_echoes_topic(self):
msg = preflight.check_class_1_trap("birthday gift for 40 year old")
assert msg is not None
self.assertIn("birthday gift for 40 year old", msg)
if __name__ == "__main__":
unittest.main()
+127 -2
View File
@@ -117,8 +117,72 @@ class RenderV3Tests(unittest.TestCase):
report.errors_by_source = {"x": "HTTP 400: Bad Request"}
text = render.render_compact(report)
self.assertIn("## Source Errors", text)
self.assertIn("HTTP 400: Bad Request", text)
self.assertIn("X:", text)
class OutputEnvelopeTests(unittest.TestCase):
"""LAW 6 envelope comments: scope "pass through verbatim" unambiguously.
Added 2026-04-19 after the Hermes Agent Use Cases failure where two
consecutive runs dumped `## Ranked Evidence Clusters` as user output.
"""
def test_evidence_for_synthesis_envelope_wraps_raw_evidence(self):
text = render.render_compact(sample_report())
self.assertIn("<!-- EVIDENCE FOR SYNTHESIS:", text)
self.assertIn("<!-- END EVIDENCE FOR SYNTHESIS -->", text)
# Opening comment must appear BEFORE the raw evidence block.
self.assertLess(
text.index("<!-- EVIDENCE FOR SYNTHESIS:"),
text.index("## Ranked Evidence Clusters"),
)
# Closing comment must appear AFTER Source Coverage.
self.assertGreater(
text.index("<!-- END EVIDENCE FOR SYNTHESIS -->"),
text.index("## Source Coverage"),
)
def test_pass_through_footer_envelope_wraps_emoji_tree(self):
text = render.render_compact(sample_report())
self.assertIn("<!-- PASS-THROUGH FOOTER:", text)
self.assertIn("<!-- END PASS-THROUGH FOOTER -->", text)
# Emoji footer sits between the two markers.
open_idx = text.index("<!-- PASS-THROUGH FOOTER:")
close_idx = text.index("<!-- END PASS-THROUGH FOOTER -->")
self.assertIn("All agents reported back!", text[open_idx:close_idx])
def test_canonical_boundary_scopes_pass_through_to_footer(self):
text = render.render_compact(sample_report())
# New boundary text scopes verbatim to the PASS-THROUGH FOOTER block,
# not everything above.
self.assertIn("Pass through ONLY the PASS-THROUGH FOOTER block verbatim", text)
# Self-check string is present so the model has a concrete failure signal.
self.assertIn("### 1.", text)
self.assertIn("LAW 6", text)
# The prior ambiguous phrasing is gone.
self.assertNotIn("Pass through the lines ABOVE this boundary verbatim", text)
def test_envelopes_appear_in_md_emit_mode(self):
# --emit md and --emit compact both route to render_compact, so the
# same envelopes apply. Guard against future divergence.
text = render.render_compact(sample_report())
self.assertEqual(text.count("<!-- EVIDENCE FOR SYNTHESIS:"), 1)
self.assertEqual(text.count("<!-- END EVIDENCE FOR SYNTHESIS -->"), 1)
self.assertEqual(text.count("<!-- PASS-THROUGH FOOTER:"), 1)
self.assertEqual(text.count("<!-- END PASS-THROUGH FOOTER -->"), 1)
def test_no_dangling_envelope_open_without_close(self):
# Open/close counts must always match, even for empty clusters.
report = sample_report()
report.clusters = []
text = render.render_compact(report)
self.assertEqual(
text.count("<!-- EVIDENCE FOR SYNTHESIS:"),
text.count("<!-- END EVIDENCE FOR SYNTHESIS -->"),
)
self.assertEqual(
text.count("<!-- PASS-THROUGH FOOTER:"),
text.count("<!-- END PASS-THROUGH FOOTER -->"),
)
class RenderTopCommentsTests(unittest.TestCase):
@@ -398,5 +462,66 @@ class RenderBestTakesCompactTests(unittest.TestCase):
self.assertNotIn("## Best Takes", text)
class DegradedRunBannerTests(unittest.TestCase):
"""Unit 1: DEGRADED RUN WARNING surfaces bare named-entity invocations
in user-visible stdout. LAW 7 backstop. 2026-04-19 Hermes Agent Use
Cases Run 1 failure mode.
"""
def _bare_named_entity_report(self) -> schema.Report:
report = sample_report()
report.topic = "Hermes Agent"
report.artifacts["plan_source"] = "deterministic"
report.artifacts["pre_research_flags_present"] = False
return report
def test_banner_appears_on_bare_named_entity_deterministic_run(self):
text = render.render_compact(self._bare_named_entity_report())
self.assertIn("## DEGRADED RUN WARNING", text)
self.assertIn("<!-- USER-VISIBLE BANNER:", text)
self.assertIn("<!-- END USER-VISIBLE BANNER -->", text)
self.assertIn("YOU ARE", text)
# Runtime-agnostic enumeration: all host runtimes appear.
for runtime_name in ("Claude Code", "Codex", "Hermes", "Gemini"):
self.assertIn(runtime_name, text)
def test_banner_positioned_before_evidence_envelope(self):
text = render.render_compact(self._bare_named_entity_report())
banner_idx = text.index("## DEGRADED RUN WARNING")
envelope_idx = text.index("<!-- EVIDENCE FOR SYNTHESIS:")
self.assertLess(banner_idx, envelope_idx,
"DEGRADED RUN banner must appear BEFORE evidence envelope so pass-through catches it.")
def test_banner_suppressed_when_plan_source_external(self):
report = self._bare_named_entity_report()
report.artifacts["plan_source"] = "external"
text = render.render_compact(report)
self.assertNotIn("## DEGRADED RUN WARNING", text)
def test_banner_suppressed_when_plan_source_llm(self):
report = self._bare_named_entity_report()
report.artifacts["plan_source"] = "llm"
text = render.render_compact(report)
self.assertNotIn("## DEGRADED RUN WARNING", text)
def test_banner_suppressed_when_pre_research_flags_present(self):
report = self._bare_named_entity_report()
report.artifacts["pre_research_flags_present"] = True
text = render.render_compact(report)
self.assertNotIn("## DEGRADED RUN WARNING", text)
def test_banner_suppressed_on_non_eligible_abstract_topic(self):
report = self._bare_named_entity_report()
# Multi-word lowercase abstract phrase is NOT pre-research-eligible.
report.topic = "how to deploy containers in the cloud"
text = render.render_compact(report)
self.assertNotIn("## DEGRADED RUN WARNING", text)
def test_banner_mentions_law_7_and_plan_flag(self):
text = render.render_compact(self._bare_named_entity_report())
self.assertIn("LAW 7", text)
self.assertIn("--plan", text)
if __name__ == "__main__":
unittest.main()
+203 -1
View File
@@ -178,9 +178,211 @@ class RerankV3Tests(unittest.TestCase):
self.assertEqual("gemini-3.1-flash-lite-preview", provider.model)
self.assertEqual(95.0, first.rerank_score)
self.assertEqual("high fit", first.explanation)
self.assertEqual("fallback-local-score", second.explanation)
# Tail is scored via the fallback (may or may not carry the entity-miss
# suffix depending on topic-title overlap; assert the base tag is present).
self.assertIn("fallback-local-score", second.explanation or "")
self.assertEqual(first.candidate_id, ranked[0].candidate_id)
class EntityGroundingTests(unittest.TestCase):
"""Unit 4: Reranker entity-grounding demotion. 2026-04-19 Hermes Agent
Use Cases failure: an off-topic video about Claude Managed Agents
scored 51 and ranked #2 with zero Hermes content.
"""
def _candidate(self, title: str, snippet: str = "") -> schema.Candidate:
return schema.Candidate(
candidate_id=f"c-{title[:10]}",
item_id="i1",
source="youtube",
title=title,
url="https://example.com",
snippet=snippet,
subquery_labels=["primary"],
native_ranks={"primary:youtube": 1},
local_relevance=0.8,
freshness=80,
engagement=50,
source_quality=0.7,
rrf_score=0.02,
)
def test_primary_entity_strips_intent_modifier(self):
self.assertEqual("Hermes Agent", rerank._primary_entity("Hermes Agent use cases"))
self.assertEqual("Hermes Agent Actual", rerank._primary_entity("Hermes Agent Actual Use Cases"))
self.assertEqual("Claude Code", rerank._primary_entity("Claude Code workflows"))
self.assertEqual("DSPy", rerank._primary_entity("DSPy tutorial"))
def test_primary_entity_leaves_bare_entity_unchanged(self):
self.assertEqual("Kanye West", rerank._primary_entity("Kanye West"))
self.assertEqual("Nous Research", rerank._primary_entity("Nous Research"))
def test_fallback_demotes_candidate_without_primary_entity(self):
on_topic = self._candidate("Hermes Agent: Self-Improving AI", "Nous Research Hermes walkthrough")
off_topic = self._candidate("I Tested Claude's Managed Agents", "What you need to know about Anthropic's new managed agents")
rerank._apply_fallback_scores([on_topic, off_topic], primary_entity="Hermes Agent")
self.assertGreater(on_topic.final_score, off_topic.final_score)
self.assertIn("entity-miss", off_topic.explanation or "")
self.assertEqual(on_topic.explanation, "fallback-local-score")
def test_fallback_match_is_case_insensitive(self):
on_topic = self._candidate("HERMES agent rocks", "some text")
rerank._apply_fallback_scores([on_topic], primary_entity="Hermes Agent")
self.assertEqual("fallback-local-score", on_topic.explanation)
def test_fallback_skips_demotion_for_empty_text_candidates(self):
empty = self._candidate("", "")
rerank._apply_fallback_scores([empty], primary_entity="Hermes Agent")
self.assertEqual("fallback-local-score", empty.explanation)
def test_fallback_skips_demotion_when_no_primary_entity(self):
off = self._candidate("Completely unrelated", "snippet")
rerank._apply_fallback_scores([off], primary_entity="")
self.assertEqual("fallback-local-score", off.explanation)
def test_llm_prompt_includes_primary_entity_grounding_hint(self):
candidate = self._candidate("Something", "snippet text")
plan = make_plan()
prompt = rerank._build_prompt(
"Hermes Agent use cases", plan, [candidate], primary_entity="Hermes Agent"
)
self.assertIn("Primary entity grounding", prompt)
self.assertIn("Hermes Agent", prompt)
def test_llm_prompt_omits_grounding_hint_when_no_primary_entity(self):
candidate = self._candidate("Something", "snippet text")
plan = make_plan()
prompt = rerank._build_prompt("", plan, [candidate], primary_entity="")
self.assertNotIn("Primary entity grounding", prompt)
class ExpandedHaystackTests(unittest.TestCase):
"""Unit 3: Entity-grounding haystack covers transcript snippets,
transcript highlights, top comments, and comment insights - not
just title + snippet.
"""
def _youtube_candidate(self, title: str, transcript_snippet: str = "",
transcript_highlights: list[str] | None = None) -> schema.Candidate:
c = schema.Candidate(
candidate_id=f"c-{title[:10]}",
item_id="i1",
source="youtube",
title=title,
url="https://youtube.com/watch?v=x",
snippet="",
subquery_labels=["primary"],
native_ranks={"primary:youtube": 1},
local_relevance=0.8,
freshness=80,
engagement=50,
source_quality=0.7,
rrf_score=0.02,
)
c.metadata = {}
if transcript_snippet:
c.metadata["transcript_snippet"] = transcript_snippet
if transcript_highlights:
c.metadata["transcript_highlights"] = transcript_highlights
return c
def test_entity_found_in_transcript_snippet_avoids_demotion(self):
# Title + snippet miss the entity, but the transcript contains it.
c = self._youtube_candidate(
"Weekly roundup",
transcript_snippet="In this video I walk through using Hermes Agent in production.",
)
rerank._apply_fallback_scores([c], primary_entity="Hermes Agent")
self.assertEqual("fallback-local-score", c.explanation)
def test_entity_found_in_transcript_highlights_avoids_demotion(self):
c = self._youtube_candidate(
"Some review",
transcript_highlights=[
"Today we're talking about Hermes Agent",
"Let's compare it to the alternatives",
],
)
rerank._apply_fallback_scores([c], primary_entity="Hermes Agent")
self.assertEqual("fallback-local-score", c.explanation)
def test_entity_missing_everywhere_still_demoted_for_video(self):
# Nate Herk "Managed Agents" case: no Hermes in title, snippet,
# or transcript - demotion fires.
c = self._youtube_candidate(
"I Tested Claude's New Managed Agents",
transcript_snippet="Managed agents are Anthropic's new product with ClickUp and cron...",
)
rerank._apply_fallback_scores([c], primary_entity="Hermes Agent")
self.assertIn("entity-miss", c.explanation)
def test_entity_found_in_reddit_top_comments_avoids_demotion(self):
c = schema.Candidate(
candidate_id="r1",
item_id="i1",
source="reddit",
title="Best agent framework?",
url="https://reddit.com/r/x",
snippet="",
subquery_labels=["primary"],
native_ranks={"primary:reddit": 1},
local_relevance=0.8, freshness=80, engagement=50,
source_quality=0.7, rrf_score=0.02,
)
c.metadata = {
"top_comments": [
{"excerpt": "I've been using Hermes Agent for a month and it's great"},
{"text": "another comment"},
],
}
rerank._apply_fallback_scores([c], primary_entity="Hermes Agent")
self.assertEqual("fallback-local-score", c.explanation)
def test_entity_found_in_comment_insights_avoids_demotion(self):
c = schema.Candidate(
candidate_id="r2", item_id="i1", source="reddit",
title="AI tools", url="https://reddit.com/r/x", snippet="",
subquery_labels=["primary"],
native_ranks={"primary:reddit": 1},
local_relevance=0.8, freshness=80, engagement=50,
source_quality=0.7, rrf_score=0.02,
)
c.metadata = {
"comment_insights": ["Consensus: Hermes Agent handles long sessions best"],
}
rerank._apply_fallback_scores([c], primary_entity="Hermes Agent")
self.assertEqual("fallback-local-score", c.explanation)
def test_truly_empty_candidate_still_skipped(self):
# Image-only TikTok with no text anywhere - do not penalize.
c = self._youtube_candidate("") # empty title
rerank._apply_fallback_scores([c], primary_entity="Hermes Agent")
self.assertEqual("fallback-local-score", c.explanation)
def test_final_score_secondary_penalty_applied_on_entity_miss(self):
# When fallback flags entity-miss, final_score gets an ADDITIONAL
# -20 penalty beyond the rerank_score reduction. Verify by
# comparing final_score for a demoted candidate vs an identical
# candidate that matched the entity.
off_topic = self._youtube_candidate("Managed Agents from Anthropic")
on_topic = self._youtube_candidate(
"Hermes Agent walkthrough",
transcript_snippet="Hermes Agent review",
)
rerank._apply_fallback_scores([off_topic, on_topic], primary_entity="Hermes Agent")
# Gap should be well above the rerank_score-only path's 0.60 * 25 = 15;
# with the secondary penalty it's 15 + 20 = 35 points.
gap = on_topic.final_score - off_topic.final_score
self.assertGreater(gap, 25.0,
f"entity-miss demotion gap only {gap:.1f}; secondary penalty may not be firing")
def test_secondary_penalty_not_applied_when_entity_match(self):
on_topic = self._youtube_candidate("Hermes Agent: use cases")
rerank._apply_fallback_scores([on_topic], primary_entity="Hermes Agent")
# Explanation does NOT contain entity-miss, so secondary penalty
# should not fire; final_score reflects only base signal.
self.assertNotIn("entity-miss", on_topic.explanation or "")
if __name__ == "__main__":
unittest.main()