Compare commits

..

3 Commits

Author SHA1 Message Date
Matt Van Horn 46c5c7f566 fix: preserve output contract as six hard rules, not two
Live Ghostty test of the prior amendment confirmed RULE A (inline links)
works on first pass, but exposed a new regression: the established output
contract collapsed. First-pass output had "Story 1/2/3" plain-prose
sections instead of bold-headline paragraphs, a plain "Stats" heading
with - bullets instead of the ├─ └─ │ emoji tree, no  All agents
reported back! line, no --- separators, and a flat "I am now an expert"
closer instead of the QUERY_TYPE-specific invitation variant.

Same failure pattern as before (emphasis imbalance between co-equal
rules), opposite direction: the link rules at the top of the output
section became the loudest prose and the existing structural rules
(bold headlines, stats template, emoji tree, QUERY_TYPE invitation,
--- separators) read as style preferences by comparison.

Fix is additive, not reductive. The TWO HARD RULES framing becomes the
OUTPUT CONTRACT with six rules named A-F, each at equal emphasis, stated
in parallel construction:

- RULE A: inline markdown links on every citation.
- RULE B: no trailing list of links.
- RULE C: bold headline per narrative paragraph (**Headline** — body).
- RULE D: stats block with  opening, ├─/└─/│ tree, emoji prefix per
  active source. No plain - bullets, no missing emoji.
- RULE E: QUERY_TYPE-specific invitation variant with 2-3 example
  follow-ups from this run. No flat "I am now an expert" closer.
- RULE F: --- horizontal rules before and after the stats block.

Closing sentence: "These six rules are a set, not a priority order.
Satisfying some by violating others is a bug, not a compromise."

Corresponding changes:

- PRE-SYNTHESIS COMMIT expanded from 6 link-focused bullets to 7
  bullets covering template/headlines/stats/separators/sources/trailing-
  list. Structure-first ordering primes the agent to reach for the
  template before drafting prose.
- PRE-PRESENT SELF-CHECK reworked. Items 1 and 2 become counted
  structural checks (≥3 bold headline openers, ≥3 ├─ tree characters,
  one emoji per active source). New items 5 and 6 add separator and
  invitation-variant checks. Items 7-10 preserve the existing RULE B
  / RULE A / no-raw-URL / no-broken-link checks.
- Release-notes entry extended to mention the preserved output
  contract alongside the clickable links.

No Python changes. This is the third commit on PR #286.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 08:17:22 -07:00
Matt Van Horn 83c14209da fix: inline links on first pass, rebalance rule emphasis
Live test on Ghostty after the prior commit showed a regression: first-pass
/last30days synthesis stripped all inline links. Only after the user asked
"weren't these links supposed to be clickable?" did the agent re-render
with inline links. Agent's own root cause: "I misread LAW 1 — over-applied
'no trailing Sources: block' to mean 'no URLs anywhere'."

The templates and per-citation examples were already correct. The regression
was in EMPHASIS BALANCE — the anti-trailing-Sources prohibition used the
loudest prose (CRITICAL OVERRIDE, MANDATORY, MUST NOT) while the pro-inline-
link rule read as lighter guidance. On a fresh first read, the agent
collapsed both into one prohibition and stripped every citation link.

This commit rebalances emphasis and adds a forcing function:

- New "TWO HARD RULES FOR LINKS" framing at the top of the output section
  names RULE A (inline links required on every citation) and RULE B (no
  trailing list of links) as a matched pair with equal rhetorical weight.
  Explicit: "These rules are complementary, not alternatives."
- New "PRE-SYNTHESIS COMMIT" step forces the agent to identify sources,
  pull URLs, and commit to inline-link placement BEFORE drafting — not
  audit after the fact.
- Existing CRITICAL OVERRIDE block now opens by pointing back to the
  matched-pair framing and drops the "Your citation is the Web: line.
  Nothing else is needed." priming sentence that biased the agent toward
  link-free drafts.
- New BAD/ALSO-BAD/GOOD triple in the CRITICAL OVERRIDE block shows the
  exact regression shape (stripping inline links to "comply" with RULE B)
  as a distinct failure mode, not just the trailing-list failure.
- PRE-PRESENT SELF-CHECK reworked: item 6 becomes "RULE B — no trailing
  list", item 7 becomes "RULE A — inline links present on first pass"
  with a positive count check that regenerates if zero inline links
  appear when raw data had URLs (the exact 2026-04-20 regression repro).
- Release notes entry gains one line describing the first-pass guarantee.

No Python changes. This is an amendment on PR #286 folding the fix into
the same review.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 07:24:56 -07:00
Matt Van Horn 38014f8143 feat: clickable source links in synthesis (no raw URLs)
Every cited source — X handles, subreddits, publications, YouTube channels,
Polymarket markets — now renders as a blue CMD-clickable markdown link in the
terminal. Raw URL strings remain forbidden everywhere in the output.

The old rule "NEVER paste raw URLs" blocked both raw URLs and links. Claude
Code renders CommonMark [text](url) as hyperlinks (URL hidden), so the new
rule is: "every link must be [text](url), never a raw URL string". Plain text
is the fallback only when the raw research dump has no URL for a source.

Changes:
- SKILL.md: invert URL formatting rule; update narrative / KEY PATTERNS /
  RECOMMENDATIONS / stats-block templates; retire the "I have all the links...
  Just ask." closer (it's now inline); add two new PRE-PRESENT SELF-CHECK
  items (no raw http/https strings, no broken empty links).
- README.md: note the clickable-links capability in the "How it works" list.
- release-notes.md: 2026-04-20 entry crediting @jay_k and @photomatt.

No Python changes — the raw research dump already carries URLs per item;
this is synthesis-side formatting only. No breaking changes to the engine,
the schema, or the CLI flags.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 00:24:56 -07:00
7 changed files with 221 additions and 561 deletions
+1
View File
@@ -209,6 +209,7 @@ These platforms don't have relationships with each other. X doesn't know what Re
2. **The agent resolves who matters.** Finds X handles (including founders), GitHub repos, subreddits, TikTok hashtags, YouTube channels. For "Kanye West" it knows r/hiphopheads, @kanyewest, and "bully review" on YouTube. For "OpenClaw" it resolves openclaw/openclaw on GitHub and fetches live star counts. 2. **The agent resolves who matters.** Finds X handles (including founders), GitHub repos, subreddits, TikTok hashtags, YouTube channels. For "Kanye West" it knows r/hiphopheads, @kanyewest, and "bully review" on YouTube. For "OpenClaw" it resolves openclaw/openclaw on GitHub and fetches live star counts.
3. **All sources searched in parallel.** Multi-query expansion. Results scored by engagement, relevance, freshness. 3. **All sources searched in parallel.** Multi-query expansion. Results scored by engagement, relevance, freshness.
4. **The depth nobody else has.** Full YouTube transcripts from reaction videos. Top Reddit comments with upvote counts. TikTok captions. Polymarket odds. Not just titles and links. 4. **The depth nobody else has.** Full YouTube transcripts from reaction videos. Top Reddit comments with upvote counts. TikTok captions. Polymarket odds. Not just titles and links.
Every cited source is a blue CMD-clickable link in the terminal (X handles, subreddits, publications). No raw URL strings — clean text, one click to the source.
5. **Same story, merged.** Wireless Festival announced on Reddit, discussed on X, ticket prices on TikTok = one cluster, not three separate items. 5. **Same story, merged.** Wireless Festival announced on Reddit, discussed on X, ticket prices on TikTok = one cluster, not three separate items.
6. **Synthesized into one brief.** Grounded in specific data. Cited by source. Ranked by what people actually engage with. Not "here's what I found." It's "here's what matters." 6. **Synthesized into one brief.** Grounded in specific data. Cited by source. Ranked by what people actually engage with. Not "here's what I found." It's "here's what matters."
7. **Then it becomes your expert.** After one run, your Claude session knows everything the community knows. Ask follow-up questions. Have it write prompts, draft emails, plan trips, architect systems - all grounded in what's real right now. 7. **Then it becomes your expert.** After one run, your Claude session knows everything the community knows. Ask follow-up questions. Have it write prompts, draft emails, plan trips, architect systems - all grounded in what's real right now.
+210 -113
View File
@@ -85,9 +85,9 @@ fi
## Step 0: First-Run Setup Wizard ## 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. **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. 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:** **When first run is detected, detect your platform first:**
@@ -220,7 +220,7 @@ Options:
If user picks an example, run research with that topic. If they pick "Type my own", ask them what they want to research. If the user originally provided a topic with the command (e.g., `/last30days Mercer Island`), skip this modal and use their topic directly. If user picks an example, run research with that topic. If they pick "Type my own", ask them what they want to research. If the user originally provided a topic with the command (e.g., `/last30days Mercer Island`), skip this modal and use their topic directly.
**END OF FIRST-RUN WIZARD. Everything above in Step 0 ONLY runs on first run. If SETUP_COMPLETE=true exists in .env, skip ALL of Step 0 - no welcome, no setup, no ScrapeCreators modal, no topic picker. Go directly to Step 1 (Parse User Intent). The topic picker is ONLY for first-time users who haven't run /last30days before.** **END OF FIRST-RUN WIZARD. Everything above in Step 0 ONLY runs on first run. If SETUP_COMPLETE=true exists in .env, skip ALL of Step 0 no welcome, no setup, no ScrapeCreators modal, no topic picker. Go directly to Step 1 (Parse User Intent). The topic picker is ONLY for first-time users who haven't run /last30days before.**
**If the user picks 2 (Manual setup):** **If the user picks 2 (Manual setup):**
Show them this guide (present as plain text, not blockquoted): Show them this guide (present as plain text, not blockquoted):
@@ -387,12 +387,12 @@ Then display (use "and more" if 5+ sources, otherwise list all with Oxford comma
For GENERAL / NEWS / RECOMMENDATIONS / PROMPTING queries: For GENERAL / NEWS / RECOMMENDATIONS / PROMPTING queries:
``` ```
/last30days - searching {ACTIVE_SOURCES_LIST} for what people are saying about {TOPIC}. /last30days searching {ACTIVE_SOURCES_LIST} for what people are saying about {TOPIC}.
``` ```
For COMPARISON queries: For COMPARISON queries:
``` ```
/last30days - comparing {TOPIC_A} vs {TOPIC_B} across {ACTIVE_SOURCES_LIST}. /last30days comparing {TOPIC_A} vs {TOPIC_B} across {ACTIVE_SOURCES_LIST}.
``` ```
Do NOT show a multi-line "Parsed intent" block with TOPIC=, TARGET_TOOL=, QUERY_TYPE= variables. Do NOT promise a specific time. Do NOT list sources that aren't configured. Do NOT show a multi-line "Parsed intent" block with TOPIC=, TARGET_TOOL=, QUERY_TYPE= variables. Do NOT promise a specific time. Do NOT list sources that aren't configured.
@@ -563,10 +563,10 @@ When the user asks "X vs Y", run ONE research pass with a comparison-optimized p
``` ```
**The `--plan` JSON for comparisons should include 3-4 subqueries:** **The `--plan` JSON for comparisons should include 3-4 subqueries:**
1. **Head-to-head:** `"{TOPIC_A} vs {TOPIC_B}"` - catches rivalry content, direct comparisons 1. **Head-to-head:** `"{TOPIC_A} vs {TOPIC_B}"` catches rivalry content, direct comparisons
2. **Entity A news:** `"{TOPIC_A} news {MONTH} {YEAR}"` - catches entity-specific developments 2. **Entity A news:** `"{TOPIC_A} news {MONTH} {YEAR}"` catches entity-specific developments
3. **Entity B news:** `"{TOPIC_B} news {MONTH} {YEAR}"` - catches entity-specific developments 3. **Entity B news:** `"{TOPIC_B} news {MONTH} {YEAR}"` catches entity-specific developments
4. (Optional) **Domain context:** `"{COMPANY_A} {COMPANY_B} {DOMAIN} news"` - catches industry context (e.g., "OpenAI Anthropic AI news") 4. (Optional) **Domain context:** `"{COMPANY_A} {COMPANY_B} {DOMAIN} news"` catches industry context (e.g., "OpenAI Anthropic AI news")
ALL subqueries include ALL sources. The fusion engine handles deduplication across subqueries. **At least one subquery MUST include YouTube-specific search terms** (e.g., "{PERSON} interview 2026", "{PRODUCT_A} vs {PRODUCT_B} review") to ensure YouTube content is found. Without YouTube-specific terms, the engine may only find 0-1 videos for comparison queries. ALL subqueries include ALL sources. The fusion engine handles deduplication across subqueries. **At least one subquery MUST include YouTube-specific search terms** (e.g., "{PERSON} interview 2026", "{PRODUCT_A} vs {PRODUCT_B} review") to ensure YouTube content is found. Without YouTube-specific terms, the engine may only find 0-1 videos for comparison queries.
@@ -580,11 +580,11 @@ Then do WebSearch for: `{TOPIC_A} vs {TOPIC_B} comparison {YEAR}` and `{TOPIC_A}
> **PLATFORM GATE:** If your platform does NOT support WebSearch (e.g., OpenClaw, raw CLI), **skip Steps 0.55 and 0.75** but add `--auto-resolve` to the Python command in the Research Execution section. The engine will do its own pre-research using configured web search backends (Brave, Exa, or Serper) to discover subreddits, X handles, and current events context before planning. > **PLATFORM GATE:** If your platform does NOT support WebSearch (e.g., OpenClaw, raw CLI), **skip Steps 0.55 and 0.75** but add `--auto-resolve` to the Python command in the Research Execution section. The engine will do its own pre-research using configured web search backends (Brave, Exa, or Serper) to discover subreddits, X handles, and current events context before planning.
**Run 2-3 focused WebSearches (in parallel) to resolve platform-specific targeting. Do NOT search for every platform individually - that wastes time. Instead, use your knowledge of the topic to infer most targeting, and only WebSearch for what you can't infer.** **Run 2-3 focused WebSearches (in parallel) to resolve platform-specific targeting. Do NOT search for every platform individually that wastes time. Instead, use your knowledge of the topic to infer most targeting, and only WebSearch for what you can't infer.**
**1. X handles** - Already resolved in Step 0.5 above (including company handles and commentators). Reference your `RESOLVED_HANDLE` and `RESOLVED_RELATED` from that step. **1. X handles** Already resolved in Step 0.5 above (including company handles and commentators). Reference your `RESOLVED_HANDLE` and `RESOLVED_RELATED` from that step.
**2. Reddit communities + YouTube channels + current events** - Run 1-2 searches that cover multiple platforms at once: **2. Reddit communities + YouTube channels + current events** Run 1-2 searches that cover multiple platforms at once:
``` ```
WebSearch("{TOPIC} subreddit reddit community") WebSearch("{TOPIC} subreddit reddit community")
@@ -595,18 +595,18 @@ The first search finds subreddits. The second gives you current events context (
Extract 3-5 subreddit names from the results. Store as `RESOLVED_SUBREDDITS` (comma-separated, no r/ prefix). Extract 3-5 subreddit names from the results. Store as `RESOLVED_SUBREDDITS` (comma-separated, no r/ prefix).
**3. TikTok hashtags + creators** - **INFER these from your topic knowledge. Do NOT WebSearch for "{PERSON} TikTok account" - most people/CEOs don't have TikTok, and the search is wasted.** **3. TikTok hashtags + creators** **INFER these from your topic knowledge. Do NOT WebSearch for "{PERSON} TikTok account" most people/CEOs don't have TikTok, and the search is wasted.**
- **Hashtags:** Infer 2-3 from the topic name + category. Examples: "Kanye West" → `kanyewest,ye,bully`. "Claude Code" → `claudecode,aiagent,aicoding`. "Sam Altman" → `samaltman,openai,chatgpt`. - **Hashtags:** Infer 2-3 from the topic name + category. Examples: "Kanye West" → `kanyewest,ye,bully`. "Claude Code" → `claudecode,aiagent,aicoding`. "Sam Altman" → `samaltman,openai,chatgpt`.
- **Creators:** Only search if the topic is a content creator, influencer, or brand that likely has TikTok presence. For CEOs, politicians, and non-creator people: skip. - **Creators:** Only search if the topic is a content creator, influencer, or brand that likely has TikTok presence. For CEOs, politicians, and non-creator people: skip.
Store as `RESOLVED_HASHTAGS` and `RESOLVED_TIKTOK_CREATORS`. Store as `RESOLVED_HASHTAGS` and `RESOLVED_TIKTOK_CREATORS`.
**4. Instagram creators** - **Same rule: INFER from topic knowledge.** If the topic is a celebrity, brand, or creator with obvious Instagram presence, use their handle directly. If the topic is a tech CEO or abstract concept, skip. Do NOT waste a WebSearch on "Dario Amodei Instagram account." **4. Instagram creators** **Same rule: INFER from topic knowledge.** If the topic is a celebrity, brand, or creator with obvious Instagram presence, use their handle directly. If the topic is a tech CEO or abstract concept, skip. Do NOT waste a WebSearch on "Dario Amodei Instagram account."
Store as `RESOLVED_IG_CREATORS`. Store as `RESOLVED_IG_CREATORS`.
**5. YouTube content queries** - Infer 2-3 YouTube content-type queries from the topic without searching. The current events search (#2 above) may surface relevant YouTube channels. **5. YouTube content queries** Infer 2-3 YouTube content-type queries from the topic without searching. The current events search (#2 above) may surface relevant YouTube channels.
- **For music artists:** `'{TOPIC} album review'`, `'{TOPIC} reaction'` - **For music artists:** `'{TOPIC} album review'`, `'{TOPIC} reaction'`
- **For products/SaaS:** `'{TOPIC} review'`, `'{TOPIC} tutorial'` - **For products/SaaS:** `'{TOPIC} review'`, `'{TOPIC} tutorial'`
@@ -620,7 +620,7 @@ Store as `RESOLVED_YT_QUERIES`.
| Topic | WebSearches needed | Reddit subs | TikTok hashtags | TikTok creators | IG creators | YT queries | | Topic | WebSearches needed | Reddit subs | TikTok hashtags | TikTok creators | IG creators | YT queries |
|-------|-------------------|-------------|-----------------|-----------------|-------------|------------| |-------|-------------------|-------------|-----------------|-----------------|-------------|------------|
| **Kanye West** | 2 (subreddit + BULLY news) | `Kanye,WestSubEver,hiphopheads,Music` | `kanyewest,ye,bully` | (inferred: `kanyewest`) | (inferred: `kanyewest`) | `kanye west bully review,kanye west bully reaction` | | **Kanye West** | 2 (subreddit + BULLY news) | `Kanye,WestSubEver,hiphopheads,Music` | `kanyewest,ye,bully` | (inferred: `kanyewest`) | (inferred: `kanyewest`) | `kanye west bully review,kanye west bully reaction` |
| **Sam Altman vs Dario** | 2 (subreddit + AI CEO news) | `artificial,MachineLearning,OpenAI,ClaudeAI` | `samaltman,openai,anthropic` | (skip - CEOs don't TikTok) | (skip - CEOs don't Reel) | `sam altman interview 2026,dario amodei interview 2026` | | **Sam Altman vs Dario** | 2 (subreddit + AI CEO news) | `artificial,MachineLearning,OpenAI,ClaudeAI` | `samaltman,openai,anthropic` | (skip CEOs don't TikTok) | (skip CEOs don't Reel) | `sam altman interview 2026,dario amodei interview 2026` |
| **Tella** (SaaS) | 2 (subreddit + Tella news) | `SaaS,Entrepreneur,screenrecording,productivity` | `tella,tellaapp,screenrecording` | (search: `tella screen recorder TikTok`) | (inferred: `tella.tv`) | `tella screen recorder review,tella tutorial` | | **Tella** (SaaS) | 2 (subreddit + Tella news) | `SaaS,Entrepreneur,screenrecording,productivity` | `tella,tellaapp,screenrecording` | (search: `tella screen recorder TikTok`) | (inferred: `tella.tv`) | `tella screen recorder review,tella tutorial` |
**For comparison queries ("X vs Y"):** Resolve communities/handles for BOTH topics and merge the lists. **For comparison queries ("X vs Y"):** Resolve communities/handles for BOTH topics and merge the lists.
@@ -688,7 +688,7 @@ Only show lines for platforms where something was resolved. Skip empty lines. Th
**Rules for your plan:** **Rules for your plan:**
- Emit 1 to 4 subqueries (more for complex/multi-faceted topics, fewer for simple ones) - Emit 1 to 4 subqueries (more for complex/multi-faceted topics, fewer for simple ones)
- **CRITICAL: Your PRIMARY subquery MUST include ALL of these sources: reddit, x, youtube, tiktok, instagram, hackernews, polymarket.** Never omit reddit (highest-signal discussion) or youtube (unique transcripts + official content). Secondary subqueries can target specific platforms. - **CRITICAL: Your PRIMARY subquery MUST include ALL of these sources: reddit, x, youtube, tiktok, instagram, hackernews, polymarket.** Never omit reddit (highest-signal discussion) or youtube (unique transcripts + official content). Secondary subqueries can target specific platforms.
- `search_query` should be concise and keyword-heavy - match how content is TITLED on platforms - `search_query` should be concise and keyword-heavy match how content is TITLED on platforms
- `ranking_query` should read like a natural language question - `ranking_query` should read like a natural language question
- **DISAMBIGUATION:** If the topic name is a common word or has known non-product meanings (e.g., "Loom" = also a weaving tool, "Tella" = also a soccer player), add a qualifying term to your search_query to disambiguate. Examples: "tella screen recording" not just "tella", "loom video messaging" not just "loom". The product category prevents matching unrelated content. - **DISAMBIGUATION:** If the topic name is a common word or has known non-product meanings (e.g., "Loom" = also a weaving tool, "Tella" = also a soccer player), add a qualifying term to your search_query to disambiguate. Examples: "tella screen recording" not just "tella", "loom video messaging" not just "loom". The product category prevents matching unrelated content.
- **For comparison queries**, each subquery should include the product category: "tella screen recorder review" not just "tella review", "loom video tool pricing" not just "loom pricing". - **For comparison queries**, each subquery should include the product category: "tella screen recorder review" not just "tella review", "loom video tool pricing" not just "loom pricing".
@@ -701,7 +701,7 @@ Only show lines for platforms where something was resolved. Skip empty lines. Th
- For how_to: prioritize YouTube (tutorials) and Reddit (guides) - For how_to: prioritize YouTube (tutorials) and Reddit (guides)
- Primary subquery weight = 1.0, secondary = 0.6-0.8, peripheral = 0.3-0.5 - Primary subquery weight = 1.0, secondary = 0.6-0.8, peripheral = 0.3-0.5
**Available sources (include ALL in primary subquery):** reddit, x, youtube, tiktok, instagram, hackernews, polymarket. Optional: bluesky, truthsocial, threads, pinterest, grounding (web search - only if user has Brave/Exa/Serper key) **Available sources (include ALL in primary subquery):** reddit, x, youtube, tiktok, instagram, hackernews, polymarket. Optional: bluesky, truthsocial, threads, pinterest, grounding (web search only if user has Brave/Exa/Serper key)
**Intent → freshness_mode mapping:** **Intent → freshness_mode mapping:**
- breaking_news, prediction → `strict_recent` - breaking_news, prediction → `strict_recent`
@@ -715,18 +715,18 @@ Only show lines for platforms where something was resolved. Skip empty lines. Th
- how_to → `workflow` - how_to → `workflow`
- everything else → `none` - everything else → `none`
Store your plan as `QUERY_PLAN_JSON` - you'll pass it to the script in the next step. Store your plan as `QUERY_PLAN_JSON` you'll pass it to the script in the next step.
--- ---
## Research Execution ## Research Execution
### PRECONDITION GATE - read before running the script ### PRECONDITION GATE read before running the script
**STOP. Before invoking `last30days.py`, verify ALL of the following are true for this turn:** **STOP. Before invoking `last30days.py`, verify ALL of the following are true for this turn:**
1. **Platform branch chosen.** You know whether this session has WebSearch (Claude Code) or does not (OpenClaw, raw CLI, Codex without web tools). 1. **Platform branch chosen.** You know whether this session has WebSearch (Claude Code) or does not (OpenClaw, raw CLI, Codex without web tools).
2. **If WebSearch IS available:** you MUST have run Step 0.55 (Pre-Research Intelligence - resolved subreddits, X handles, TikTok hashtags/creators, Instagram creators, GitHub user/repo where applicable) AND Step 0.75 (Query Planner - produced `QUERY_PLAN_JSON` with 2-4 subqueries). These are NOT optional. If either was skipped, return to that step now. 2. **If WebSearch IS available:** you MUST have run Step 0.55 (Pre-Research Intelligence resolved subreddits, X handles, TikTok hashtags/creators, Instagram creators, GitHub user/repo where applicable) AND Step 0.75 (Query Planner produced `QUERY_PLAN_JSON` with 2-4 subqueries). These are NOT optional. If either was skipped, return to that step now.
3. **If WebSearch is NOT available:** you MUST add `--auto-resolve` to the command instead. Do not attempt Steps 0.55 / 0.75 without WebSearch. 3. **If WebSearch is NOT available:** you MUST add `--auto-resolve` to the command instead. Do not attempt Steps 0.55 / 0.75 without WebSearch.
4. **The command you are about to run uses `--emit=compact`.** `--emit md` is a debugging/inspection mode and is DISALLOWED as the primary user-facing flow. If you find yourself about to run `--emit md`, stop and switch to `--emit=compact`. 4. **The command you are about to run uses `--emit=compact`.** `--emit md` is a debugging/inspection mode and is DISALLOWED as the primary user-facing flow. If you find yourself about to run `--emit md`, stop and switch to `--emit=compact`.
5. **On WebSearch platforms the command MUST include `--plan 'QUERY_PLAN_JSON'`** plus every resolved handle/subreddit/hashtag/creator flag from Step 0.55. Omit only flags whose value was not resolvable. 5. **On WebSearch platforms the command MUST include `--plan 'QUERY_PLAN_JSON'`** plus every resolved handle/subreddit/hashtag/creator flag from Step 0.55. Omit only flags whose value was not resolvable.
@@ -744,7 +744,7 @@ Store your plan as `QUERY_PLAN_JSON` - you'll pass it to the script in the next
**IMPORTANT: Include `--x-handle={RESOLVED_HANDLE}` in the command. For comparison mode: Pass `--x-handle={TOPIC_A_HANDLE}` to the first pass, `--x-handle={TOPIC_B_HANDLE}` to the second pass, and both to the head-to-head pass. Also include `--subreddits={RESOLVED_SUBREDDITS}`, `--tiktok-hashtags={RESOLVED_HASHTAGS}`, `--tiktok-creators={RESOLVED_TIKTOK_CREATORS}`, and `--ig-creators={RESOLVED_IG_CREATORS}` from Step 0.55. Omit any flag where the value was not resolved (empty).** **IMPORTANT: Include `--x-handle={RESOLVED_HANDLE}` in the command. For comparison mode: Pass `--x-handle={TOPIC_A_HANDLE}` to the first pass, `--x-handle={TOPIC_B_HANDLE}` to the second pass, and both to the head-to-head pass. Also include `--subreddits={RESOLVED_SUBREDDITS}`, `--tiktok-hashtags={RESOLVED_HASHTAGS}`, `--tiktok-creators={RESOLVED_TIKTOK_CREATORS}`, and `--ig-creators={RESOLVED_IG_CREATORS}` from Step 0.55. Omit any flag where the value was not resolved (empty).**
```bash ```bash
# Find skill root - works in repo checkout, Claude Code, or Codex install # Find skill root works in repo checkout, Claude Code, or Codex install
for dir in \ for dir in \
"." \ "." \
"${CLAUDE_PLUGIN_ROOT:-}" \ "${CLAUDE_PLUGIN_ROOT:-}" \
@@ -790,11 +790,11 @@ The script will automatically:
**Read the ENTIRE output.** It contains EIGHT data sections in this order: Reddit items, X items, YouTube items, TikTok items, Instagram Reels items, Hacker News items, Polymarket items, and WebSearch items. If you miss sections, you will produce incomplete stats. **Read the ENTIRE output.** It contains EIGHT data sections in this order: Reddit items, X items, YouTube items, TikTok items, Instagram Reels items, Hacker News items, Polymarket items, and WebSearch items. If you miss sections, you will produce incomplete stats.
**YouTube items in the output look like:** `**{video_id}** (score:N) {channel_name} [N views, N likes]` followed by a title, URL, **transcript highlights** (pre-extracted quotable excerpts from the video), and an optional full transcript in a collapsible section. **Quote the highlights directly in your synthesis.** When YouTube items also include top comments (enabled via `youtube_comments`), quote those too with their like counts - they capture how viewers reacted to the video. Transcript highlights and top comments are complementary signals; use both when present. Attribute transcript quotes to the channel name, comment quotes to the commenter. Count them and include them in your synthesis and stats block. **YouTube items in the output look like:** `**{video_id}** (score:N) {channel_name} [N views, N likes]` followed by a title, URL, **transcript highlights** (pre-extracted quotable excerpts from the video), and an optional full transcript in a collapsible section. **Quote the highlights directly in your synthesis.** When YouTube items also include top comments (enabled via `youtube_comments`), quote those too with their like counts they capture how viewers reacted to the video. Transcript highlights and top comments are complementary signals; use both when present. Attribute transcript quotes to the channel name, comment quotes to the commenter. Count them and include them in your synthesis and stats block.
**TikTok items in the output look like:** `**{TK_id}** (score:N) @{creator} [N views, N likes]` followed by a caption, URL, hashtags, and optional caption snippet. Count them and include them in your synthesis and stats block. **TikTok items in the output look like:** `**{TK_id}** (score:N) @{creator} [N views, N likes]` followed by a caption, URL, hashtags, and optional caption snippet. Count them and include them in your synthesis and stats block.
**Instagram Reels items in the output look like:** `**{IG_id}** (score:N) @{creator} (date) [N views, N likes]` followed by caption text, URL, and optional transcript. Count them and include them in your synthesis and stats block. Instagram provides unique creator/influencer perspective - weight it alongside TikTok. **Instagram Reels items in the output look like:** `**{IG_id}** (score:N) @{creator} (date) [N views, N likes]` followed by caption text, URL, and optional transcript. Count them and include them in your synthesis and stats block. Instagram provides unique creator/influencer perspective weight it alongside TikTok.
--- ---
@@ -831,9 +831,11 @@ For ALL query types:
- **USE THE USER'S EXACT TERMINOLOGY** - don't substitute or add tech names based on your knowledge - **USE THE USER'S EXACT TERMINOLOGY** - don't substitute or add tech names based on your knowledge
- EXCLUDE reddit.com, x.com, twitter.com (covered by script) - EXCLUDE reddit.com, x.com, twitter.com (covered by script)
- INCLUDE: blogs, tutorials, docs, news, GitHub repos - INCLUDE: blogs, tutorials, docs, news, GitHub repos
- **DO NOT output a separate "Sources:" block** - instead, include the top 3-5 web - **DO NOT output a separate "Sources:" block** — every source (X handles, subreddits,
source names as inline links on the 🌐 Web: stats line (see stats format below). publications) is cited inline throughout the synthesis as a markdown link `[name](url)`,
The WebSearch tool requires citation; satisfy it there, not as a trailing section. and the top 3-5 web publications appear as markdown links on the 🌐 Web: stats line.
That satisfies WebSearch's citation requirement. A trailing Sources block is forbidden.
See the URL FORMATTING section below for the full link-rendering rules.
**Options** (passed through from user's command): **Options** (passed through from user's command):
- `--days=N` → Look back N days instead of 30 (e.g., `--days=7` for weekly roundup) - `--days=N` → Look back N days instead of 30 (e.g., `--days=7` for weekly roundup)
@@ -857,11 +859,11 @@ Example of what to append:
``` ```
## WebSearch Supplemental Results ## WebSearch Supplemental Results
- **Efficient App** (https://efficientapp.com/tella-vs-loom) - Side-by-side comparison showing Tella exports in 27s vs Loom's 11s, with Tella at $19/mo and Loom free/$8/mo. - **Efficient App** (https://efficientapp.com/tella-vs-loom) Side-by-side comparison showing Tella exports in 27s vs Loom's 11s, with Tella at $19/mo and Loom free/$8/mo.
- **Shannah Albert Blog** (https://shannahalbert.com/tella-review) - Creator walkthrough of Tella's recording flow, notes the teleprompter feature as a key differentiator. - **Shannah Albert Blog** (https://shannahalbert.com/tella-review) Creator walkthrough of Tella's recording flow, notes the teleprompter feature as a key differentiator.
``` ```
This ensures anyone reviewing the raw file sees ALL data that fed into the synthesis - not just the Python engine output. This ensures anyone reviewing the raw file sees ALL data that fed into the synthesis not just the Python engine output.
--- ---
@@ -872,9 +874,9 @@ This ensures anyone reviewing the raw file sees ALL data that fed into the synth
**v3 returns results grouped by STORY/THEME (clusters), not by source.** Each cluster represents one narrative thread found across multiple platforms. **v3 returns results grouped by STORY/THEME (clusters), not by source.** Each cluster represents one narrative thread found across multiple platforms.
**How to read v3 output:** **How to read v3 output:**
- `### 1. Cluster Title (score N, M items, sources: X, Reddit, TikTok)` - a story found across multiple platforms - `### 1. Cluster Title (score N, M items, sources: X, Reddit, TikTok)` a story found across multiple platforms
- `Uncertainty: single-source` - only one platform found this story (lower confidence) - `Uncertainty: single-source` only one platform found this story (lower confidence)
- `Uncertainty: thin-evidence` - all items scored below 55 (unconfirmed) - `Uncertainty: thin-evidence` all items scored below 55 (unconfirmed)
- Items within a cluster show: source label, title, date, score, URL, and evidence snippet - Items within a cluster show: source label, title, date, score, URL, and evidence snippet
**Synthesis strategy for cluster-first output:** **Synthesis strategy for cluster-first output:**
@@ -883,7 +885,7 @@ This ensures anyone reviewing the raw file sees ALL data that fed into the synth
3. **Check uncertainty tags.** "single-source" means treat with caution. "thin-evidence" means mention but caveat. 3. **Check uncertainty tags.** "single-source" means treat with caution. "thin-evidence" means mention but caveat.
4. **Cross-cluster synthesis second.** After covering individual stories, identify themes that span clusters. 4. **Cross-cluster synthesis second.** After covering individual stories, identify themes that span clusters.
5. **Engagement signals still matter.** Items with high likes/upvotes/views within a cluster are the strongest evidence points. 5. **Engagement signals still matter.** Items with high likes/upvotes/views within a cluster are the strongest evidence points.
6. **Quote directly from evidence snippets.** The snippets are pre-extracted best passages - use them. 6. **Quote directly from evidence snippets.** The snippets are pre-extracted best passages use them.
7. Extract the top 3-5 actionable insights across all clusters. 7. Extract the top 3-5 actionable insights across all clusters.
8. **Disambiguation: trust your resolved entity.** When Step 0.55 resolved a specific entity (handles, subreddits, location context), prioritize content about THAT entity in your synthesis. If search results contain a different entity with the same name (e.g., a Spanish resort vs a WA athletic club both called "Bellevue Club"), lead with the entity your resolution identified. Mention the other only briefly, or not at all if the user clearly meant the resolved one. The resolved handles are the strongest signal for user intent. 8. **Disambiguation: trust your resolved entity.** When Step 0.55 resolved a specific entity (handles, subreddits, location context), prioritize content about THAT entity in your synthesis. If search results contain a different entity with the same name (e.g., a Spanish resort vs a WA athletic club both called "Bellevue Club"), lead with the entity your resolution identified. Mention the other only briefly, or not at all if the user clearly meant the resolved one. The resolved handles are the strongest signal for user intent.
@@ -892,10 +894,10 @@ This ensures anyone reviewing the raw file sees ALL data that fed into the synth
The Judge Agent must: The Judge Agent must:
1. Weight Reddit/X sources HIGHER (they have engagement signals: upvotes, likes) 1. Weight Reddit/X sources HIGHER (they have engagement signals: upvotes, likes)
2. Weight YouTube sources HIGH (they have views, likes, and transcript content) 2. Weight YouTube sources HIGH (they have views, likes, and transcript content)
3. Weight TikTok sources HIGH (they have views, likes, and caption content - viral signal) 3. Weight TikTok sources HIGH (they have views, likes, and caption content viral signal)
4. Weight WebSearch sources LOWER (no engagement data) 4. Weight WebSearch sources LOWER (no engagement data)
5. **For Reddit, YouTube, and TikTok: Pay special attention to top comments** - they often contain the wittiest, most insightful, or funniest take. Quote them directly, attributing to the commenter and including the vote count ("N upvotes" for Reddit, "N likes" for YouTube and TikTok). A top comment with thousands of votes is a stronger community signal than the parent post's stats alone. 5. **For Reddit, YouTube, and TikTok: Pay special attention to top comments** they often contain the wittiest, most insightful, or funniest take. Quote them directly, attributing to the commenter and including the vote count ("N upvotes" for Reddit, "N likes" for YouTube and TikTok). A top comment with thousands of votes is a stronger community signal than the parent post's stats alone.
6. **For YouTube: Quote transcript highlights AND top comments.** Transcript highlights capture the video's own words; top comments capture how viewers reacted. Both add value - use them together. Attribute transcript quotes to the channel name. 6. **For YouTube: Quote transcript highlights AND top comments.** Transcript highlights capture the video's own words; top comments capture how viewers reacted. Both add value use them together. Attribute transcript quotes to the channel name.
7. Identify patterns that appear across ALL sources (strongest signals) 7. Identify patterns that appear across ALL sources (strongest signals)
8. Note any contradictions between sources 8. Note any contradictions between sources
9. **Multi-source clusters (items from 3+ platforms) are the strongest signals.** Lead with these. 9. **Multi-source clusters (items from 3+ platforms) are the strongest signals.** Lead with these.
@@ -931,7 +933,7 @@ The Judge Agent must:
### X Reply Cluster Weighting ### X Reply Cluster Weighting
When you see a cluster of replies to a recommendation-request tweet (someone asking "what's the best X?" and getting multiple independent responses), call this out prominently. This is the strongest form of community endorsement - real people independently making the same recommendation without coordination. Example: "In a thread where @ecom_cork asked for Loom alternatives, every reply said Tella." When you see a cluster of replies to a recommendation-request tweet (someone asking "what's the best X?" and getting multiple independent responses), call this out prominently. This is the strongest form of community endorsement real people independently making the same recommendation without coordination. Example: "In a thread where @ecom_cork asked for Loom alternatives, every reply said Tella."
### WebSearch Supplement Weighting for Comparisons ### WebSearch Supplement Weighting for Comparisons
@@ -1045,6 +1047,46 @@ Identify from the ACTUAL RESEARCH OUTPUT:
## THEN: Show Summary + Invite Vision ## THEN: Show Summary + Invite Vision
### OUTPUT CONTRACT — six hard rules, read all of them before writing anything
The synthesis output has six structural rules. They are a SET, not a priority order. A correct output satisfies ALL SIX on the first pass. Satisfying some by violating others is a bug, not a compromise. Each rule below has the same force as the next — do not elevate any one over the others.
**RULE A — REQUIRED: Inline markdown links on every citation.**
Every @handle, r/subreddit, publication, YouTube channel, TikTok creator, Instagram creator, and Polymarket market cited in the narrative body, KEY PATTERNS, and stats block MUST be an inline markdown link `[text](url)` at first mention. The URL comes from the raw research dump — every source item in the engine output carries its URL. First-pass synthesis MUST include these inline links; do not wait for the user to ask.
**RULE B — FORBIDDEN: Trailing list of links.**
No trailing list of links appears anywhere after the invitation. No `Sources:` block, no `References:` block, no `Further reading:` block, no `Citations:` block, no bulleted list of publication names, no closing "I have all the links... Just ask." sentence, no markdown-link list under any heading. The narrative's inline links (RULE A) ARE the source list — duplicating them into a trailing block is a bug.
**RULE C — REQUIRED: Bold headline per narrative paragraph.**
Every paragraph in "What I learned" opens with a bold headline phrase followed by a dash and the body text. Pattern: `**Headline phrase** — body text describing what people are saying...`. No plain-prose openers. No "Story 1 / Story 2 / Story 3" numbered sections. No unnumbered headings-as-pseudo-paragraphs. The bold headline IS the paragraph opener.
**RULE D — REQUIRED: Stats block uses the exact template, tree, and emoji.**
The stats block opens with the line `✅ All agents reported back!` on its own. Every active source is a line using the `├─` or `└─` tree character with its emoji prefix (🟠 Reddit, 🔵 X, 🔴 YouTube, 🎵 TikTok, 📸 Instagram, 🧵 Threads, 📌 Pinterest, 🟡 HN, 🦋 Bluesky, 🇺🇸 Truth Social, 🐙 GitHub, 📊 Polymarket, 🌐 Web, 🗣️ Top voices, 📎 Raw results). Use `│` for within-line separators. NO plain `-` bullets. NO plain "Stats" heading. NO missing emoji. Sources that returned 0 results are omitted entirely — do not include them as "0 threads" or "(no results this cycle)".
**RULE E — REQUIRED: QUERY_TYPE-specific invitation with example follow-ups.**
The closing invitation matches the detected QUERY_TYPE (PROMPTING / RECOMMENDATIONS / NEWS / COMPARISON / GENERAL) verbatim from the five variants defined below. Each variant includes 2-3 specific example follow-ups drawn from THIS run's research (not generic). A flat "I am now an expert on {TOPIC}" with no examples is a regression — it means you skipped the variant template.
**RULE F — REQUIRED: `---` horizontal-rule separators.**
Use `---` on its own line before the stats block (between the narrative/KEY PATTERNS and the `✅ All agents reported back!` line) and again between the stats block and the invitation. The separators are part of the template, not decoration.
**All six rules apply on first pass.** If you satisfy RULES A and B by stripping structure, you have violated C/D/E/F. If you satisfy C/D/E/F by skipping links, you have violated A. Satisfy all six, or the output is wrong. Not "mostly right" — wrong.
### PRE-SYNTHESIS COMMIT — do this before drafting
Before you write a single word of the synthesis, commit to every rule in the OUTPUT CONTRACT:
1. **Template (RULE E).** Identify the QUERY_TYPE for this run. Locate the matching invitation variant (PROMPTING / RECOMMENDATIONS / NEWS / COMPARISON / GENERAL) below. Commit to using that variant verbatim at the close, with 2-3 example follow-ups drawn from this specific research.
2. **Headlines (RULE C).** Commit to opening every narrative paragraph with a bold `**Headline phrase** —`. No "Story 1 / Story 2 / Story 3" or other plain-prose patterns.
3. **Stats template (RULE D).** Commit to the `✅ All agents reported back!` opening line, the `├─` / `└─` / `│` tree characters, and an emoji prefix on every active source line. Copy the template block below literally; do not rewrite it as a bullet list.
4. **Separators (RULE F).** Commit to `---` horizontal rules before and after the stats block.
5. **Sources (RULE A).** The raw research dump is in hand and includes URLs on every source item. Identify every source you intend to cite: @handles, r/subs, publications, YouTube channels, TikTok/Instagram creators, Polymarket markets. Pull URLs. Plan to wrap each at first mention as `[text](url)`. For any specific source with no URL in raw data, note the plain-text fallback.
6. **No trailing list (RULE B).** Confirm no Sources/References/bulleted-list block will appear after the invitation.
7. **Only now begin drafting.**
This commit is a forcing function, not a post-hoc checklist. Work through all seven steps before writing any prose.
---
**Display in this EXACT sequence:** **Display in this EXACT sequence:**
**FIRST - What I learned (based on QUERY_TYPE):** **FIRST - What I learned (based on QUERY_TYPE):**
@@ -1055,19 +1097,21 @@ Identify from the ACTUAL RESEARCH OUTPUT:
[Tool Name] - {n}x mentions [Tool Name] - {n}x mentions
Use Case: [what it does] Use Case: [what it does]
Sources: @handle1, @handle2, r/sub, blog.com Sources: [@handle1](https://x.com/handle1), [@handle2](https://x.com/handle2), [r/sub](https://reddit.com/r/sub), [blog.com](https://blog.com/specific-post/)
[Tool Name] - {n}x mentions [Tool Name] - {n}x mentions
Use Case: [what it does] Use Case: [what it does]
Sources: @handle3, r/sub2, Complex Sources: [@handle3](https://x.com/handle3), [r/sub2](https://reddit.com/r/sub2), [Complex](https://www.complex.com/specific-article/)
Notable mentions: [other specific things with 1-2 mentions] Notable mentions: [other specific things with 1-2 mentions]
``` ```
**CRITICAL for RECOMMENDATIONS:** **CRITICAL for RECOMMENDATIONS:**
- Each item MUST have a "Sources:" line with actual @handles from X posts (e.g., @LONGLIVE47, @ByDobson) - Each item MUST have a "Sources:" line with actual @handles from X posts (e.g., [@LONGLIVE47](https://x.com/LONGLIVE47), [@ByDobson](https://x.com/ByDobson))
- Include subreddit names (r/hiphopheads) and web sources (Complex, Variety) - Include subreddit names ([r/hiphopheads](https://reddit.com/r/hiphopheads)) and web sources ([Complex](https://www.complex.com/...), [Variety](https://variety.com/...))
- Parse @handles from research output and include the highest-engagement ones - Parse @handles from research output and include the highest-engagement ones
- Every source on the "Sources:" line is a markdown link `[text](url)` — never a raw URL, never a plain name when a URL is available. The URL comes from the raw research dump.
- This per-item "Sources:" line is allowed (it's inline per recommendation). A TRAILING "Sources:" block at the end of the whole output is still forbidden — see the URL FORMATTING and "You MUST NOT" sections below.
- Format naturally - tables work well for wide terminals, stacked cards for narrow - Format naturally - tables work well for wide terminals, stacked cards for narrow
- **CRITICAL whitespace rule:** Never insert more than ONE blank line between any two content blocks. Comparison tables should immediately follow the preceding paragraph with exactly one blank line. Do NOT pad with 3-6 empty lines before tables. - **CRITICAL whitespace rule:** Never insert more than ONE blank line between any two content blocks. Comparison tables should immediately follow the preceding paragraph with exactly one blank line. Do NOT pad with 3-6 empty lines before tables.
@@ -1079,58 +1123,65 @@ CITATION RULE: Cite sources sparingly to prove research is real.
- Do NOT include engagement metrics in citations (likes, upvotes) - save those for stats box - Do NOT include engagement metrics in citations (likes, upvotes) - save those for stats box
- Do NOT chain multiple citations: "per @x, @y, @z" is too much. Pick the strongest one. - Do NOT chain multiple citations: "per @x, @y, @z" is too much. Pick the strongest one.
CITATION PRIORITY (most to least preferred): CITATION PRIORITY (most to least preferred). Every cited name is a markdown link `[name](url)`:
1. @handles from X - "per @handle" (these prove the tool's unique value) 1. @handles from X `per [@handle](https://x.com/handle)` (these prove the tool's unique value)
2. r/subreddits from Reddit - "per r/subreddit" (when citing Reddit, YouTube, or TikTok, prefer quoting top comments over just the thread title) 2. r/subreddits from Reddit `per [r/subreddit](https://reddit.com/r/subreddit)` (when citing Reddit, YouTube, or TikTok, prefer quoting top comments over just the thread title)
3. YouTube channels - "per [channel name] on YouTube" (transcript-backed insights) 3. YouTube channels `per [channel name](https://youtube.com/@channel) on YouTube` (transcript-backed insights)
4. TikTok creators - "per @creator on TikTok" (viral/trending signal) 4. TikTok creators `per [@creator](https://tiktok.com/@creator) on TikTok` (viral/trending signal)
5. Instagram creators - "per @creator on Instagram" (influencer/creator signal) 5. Instagram creators `per [@creator](https://instagram.com/creator) on Instagram` (influencer/creator signal)
6. HN discussions - "per HN" or "per hn/username" (developer community signal) 6. HN discussions `per [HN](https://news.ycombinator.com/item?id=N)` or `per [hn/username](https://news.ycombinator.com/user?id=username)` (developer community signal)
7. Polymarket - "Polymarket has X at Y% (up/down Z%)" with specific odds and movement 7. Polymarket `[Polymarket](https://polymarket.com/event/...) has X at Y% (up/down Z%)` with specific odds and movement
8. Web sources - ONLY when Reddit/X/YouTube/TikTok/Instagram/HN/Polymarket don't cover that specific fact 8. Web sources ONLY when Reddit/X/YouTube/TikTok/Instagram/HN/Polymarket don't cover that specific fact; link the publication name: `per [Rolling Stone](https://rollingstone.com/...)`
The tool's value is surfacing what PEOPLE are saying, not what journalists wrote. The tool's value is surfacing what PEOPLE are saying, not what journalists wrote.
When both a web article and an X post cover the same fact, cite the X post. When both a web article and an X post cover the same fact, cite the X post.
URL FORMATTING: NEVER paste raw URLs anywhere in the output - not in synthesis, not in stats, not in sources. URL FORMATTING: Every citation MUST be a markdown link `[text](url)`, NEVER a raw URL string.
Claude Code renders `[text](url)` as blue CMD-clickable text — the URL is hidden, only the link text shows.
Raw `https://...` strings are forbidden everywhere: narrative, stats, KEY PATTERNS, everywhere.
Pull the URL for each source from the raw research dump (every item in the engine output carries its source URL).
If no URL is available for a source, fall back to plain text — NEVER emit a broken empty link like `[Rolling Stone]()`.
- **BAD:** "per https://www.rollingstone.com/music/music-news/kanye-west-bully-1235506094/" - **BAD:** "per https://www.rollingstone.com/music/music-news/kanye-west-bully-1235506094/"
- **GOOD:** "per Rolling Stone" - **BAD:** "per Rolling Stone" (when a URL is available in the raw data — use it)
- **BAD stats line:** `🌐 Web: 10 pages - https://later.com/blog/..., https://buffer.com/...` - **BAD:** "per [Rolling Stone]()" (empty link — fall back to plain text instead)
- **GOOD stats line:** `🌐 Web: 10 pages - Later, Buffer, CNN, SocialBee` - **GOOD:** "per [Rolling Stone](https://www.rollingstone.com/music/music-news/kanye-west-bully-1235506094/)"
Use the publication/site name, not the URL. The user doesn't need links - they need clean, readable text. - **GOOD (fallback, URL genuinely missing):** "per Rolling Stone"
- **BAD stats line:** `🌐 Web: 10 pages — https://later.com/blog/..., https://buffer.com/...`
- **BAD stats line:** `🌐 Web: 10 pages — Later, Buffer, CNN, SocialBee` (URLs were available — link them)
- **GOOD stats line:** `🌐 Web: 10 pages — [Later](https://later.com/blog/instagram-reels-trends/), [Buffer](https://buffer.com/resources/instagram-algorithms/), [CNN](https://www.cnn.com/2026/02/22/tech/...), [SocialBee](https://socialbee.com/blog/instagram-trends/)`
The link text is the short publication/handle/subreddit name. The URL is the deep link from the raw research — not just the bare domain. The user sees clean blue link text and can CMD-click to open the source.
**BAD:** "His album is set for March 20 (per Rolling Stone; Billboard; Complex)." **BAD:** "His album is set for March 20 (per Rolling Stone; Billboard; Complex)."
**GOOD:** "His album BULLY drops March 20 - fans on X are split on the tracklist, per @honest30bgfan_" **GOOD:** "His album BULLY drops March 20 fans on X are split on the tracklist, per [@honest30bgfan_](https://x.com/honest30bgfan_)"
**GOOD:** "Ye's apology got massive traction on r/hiphopheads" **GOOD:** "Ye's apology got massive traction on [r/hiphopheads](https://reddit.com/r/hiphopheads)"
**OK** (web, only when Reddit/X don't have it): "The Hellwatt Festival runs July 4-18 at RCF Arena, per Billboard" **OK** (web, only when Reddit/X don't have it): "The Hellwatt Festival runs July 4-18 at RCF Arena, per [Billboard](https://www.billboard.com/music/music-news/hellwatt-festival-2026-lineup-...)"
**Lead with people, not publications.** Start each topic with what Reddit/X **Lead with people, not publications.** Start each topic with what Reddit/X
users are saying/feeling, then add web context only if needed. The user came users are saying/feeling, then add web context only if needed. The user came
here for the conversation, not the press release. here for the conversation, not the press release.
**MANDATORY - bold headline per narrative paragraph.** Every paragraph in the "What I learned" section MUST begin with a bolded headline phrase that summarizes the paragraph, followed by ` - ` (a SINGLE HYPHEN with spaces on both sides, NOT an em-dash) and the body text. Pattern: `**Headline phrase** - body text describing what people are saying...`. Without the bold headline, the output is unscannable slop. **MANDATORY bold headline per narrative paragraph.** Every paragraph in the "What I learned" section MUST begin with a bolded headline phrase that summarizes the paragraph, followed by a dash and the body text. Pattern: `**Headline phrase** body text describing what people are saying...`. Without the bold headline, the output is unscannable slop. The Kanye and Matt Van Horn reference outputs follow this pattern end-to-end; bland outputs that drop the bold headline are the regression shape to avoid.
**NEVER use em-dashes (`—`) or en-dashes (``) anywhere in your response.** Use ` - ` (single hyphen with spaces) instead. Em-dashes are the most reliable AI-slop tell; a response with em-dashes reads as generated. This applies to synthesis body, headline separators, KEY PATTERNS list, and the invitation section. The only exception is quoted content where the source used an em-dash.
**NEVER use `##` or `###` markdown section headers in your response body.** No `## The launch`, no `## Where it disappoints`, no `## Polymarket`, no `## Best quotes`, no `## Stats snapshot`. Those read as AI-slop news-article structure. The narrative is a short block of bold-lead-in paragraphs followed by a prose label `KEY PATTERNS from the research:` followed by a numbered list. That is the only structure.
**NEVER write a title line at the top of your response.** No `Kanye West: last 30 days`, no `Claude Opus 4.7 - what people are actually saying`, no `{Topic} news`. Your response begins with the prose label `What I learned:` on its own line and goes straight into the narrative.
``` ```
What I learned: What I learned:
**{Headline summarizing topic 1}** - [1-2 sentences about what people are saying, per @handle or r/sub] **{Headline summarizing topic 1}** [1-2 sentences about what people are saying, per [@handle](https://x.com/handle) or [r/sub](https://reddit.com/r/sub)]
**{Headline summarizing topic 2}** - [1-2 sentences, per @handle or r/sub] **{Headline summarizing topic 2}** [1-2 sentences, per [@handle](https://x.com/handle) or [r/sub](https://reddit.com/r/sub)]
**{Headline summarizing topic 3}** - [1-2 sentences, per @handle or r/sub] **{Headline summarizing topic 3}** [1-2 sentences, per [@handle](https://x.com/handle) or [r/sub](https://reddit.com/r/sub)]
KEY PATTERNS from the research: KEY PATTERNS from the research:
1. [Pattern] - per @handle 1. [Pattern] per [@handle](https://x.com/handle)
2. [Pattern] - per r/sub 2. [Pattern] per [r/sub](https://reddit.com/r/sub)
3. [Pattern] - per @handle 3. [Pattern] per [@handle](https://x.com/handle)
``` ```
The `@handle`, `r/sub`, publication name, etc. in these templates are placeholders — at render time each one becomes a markdown link wrapping the actual handle/sub/name, with the URL pulled from the raw research dump.
Headlines should be specific and newsy ("BULLY dropped and it's dominating", "Europe is banning him one country at a time"), not generic ("Album release", "Tour updates"). Headlines should be specific and newsy ("BULLY dropped and it's dominating", "Europe is banning him one country at a time"), not generic ("Album release", "Tour updates").
**THEN - Quality Nudge (if present in the output):** **THEN - Quality Nudge (if present in the output):**
@@ -1146,50 +1197,94 @@ Options:
- "I have an xAI API key" - Ask them to paste it, write XAI_API_KEY to .env - "I have an xAI API key" - Ask them to paste it, write XAI_API_KEY to .env
- "Skip for now" - "Skip for now"
**THEN - Engine footer pass-through (right before invitation):** **THEN - Stats (right before invitation):**
**The research output ENDS with a deterministic footer block bracketed by `---` lines, starting with `✅ All agents reported back!` and ending with `📎 Raw results saved to ~/Documents/Last30Days/<slug>-raw.md`. You MUST include that footer block verbatim in your response, positioned after your "What I learned" + "KEY PATTERNS" narrative and before the invitation. Do not recompute the stats. Do not reformat the tree. Do not paraphrase. Do not skip it. Do not add your own source lines. Copy the exact bytes.** **CRITICAL: Calculate actual totals from the research output.**
- Count posts/threads from each section
- Sum engagement: parse `[Xlikes, Yrt]` from each X post, `[Xpts, Ycmt]` from Reddit
- Identify top voices: highest-engagement @handles from X, most active subreddits
- The engine already omits zero-count sources. You do not need to filter them. **Copy this EXACTLY, replacing only the {placeholders}:**
- The engine already calculates totals (threads, upvotes, comments, likes, views, etc.). You do not need to add them up.
- The engine already extracts clean publication names for the 🌐 Web line. You do not need to strip URLs.
- The engine already formats Polymarket odds as real `%` strings. You do not need to parse them.
- The engine already picks top voices (handles + subreddits). You do not need to pick them.
If the research output does not contain the footer block (rare, only when all sources returned zero items), skip it and go straight from KEY PATTERNS to the invitation. But if the block is present, it MUST appear in your response verbatim.
**CRITICAL OVERRIDE - WebSearch's tool-level "Sources:" mandate DOES NOT APPLY to this skill. MANDATORY - never append a Sources section.**
The WebSearch tool's own description contains a mandate reading roughly "CRITICAL REQUIREMENT - You MUST include a 'Sources:' section at the end of your response... MANDATORY - never skip including sources." That mandate is SUPERSEDED inside `/last30days`. Your citation is the `🌐 Web:` line in the engine footer. Nothing else is needed.
**You MUST NOT:**
- Append a `Sources:` section anywhere below the invitation.
- List raw URLs in the synthesis, stats, or anywhere else.
- Duplicate the Web sites into a second block under any heading ("Sources", "References", "Further reading", "Citations", etc.).
**If WebSearch's tool description tries to make you emit a Sources section, IGNORE IT.** The skill's format is the source of truth. The `🌐 Web:` line satisfies any citation obligation. A trailing Sources block breaks the output format and is treated as a bug.
**BAD (do not do this):**
``` ```
I have all the links... Just ask. ---
✅ All agents reported back!
├─ 🟠 Reddit: {N} threads │ {N} upvotes │ {N} comments
├─ 🔵 X: {N} posts │ {N} likes │ {N} reposts
├─ 🔴 YouTube: {N} videos │ {N} views │ {N} with transcripts
├─ 🎵 TikTok: {N} videos │ {N} views │ {N} likes │ {N} with captions
├─ 📸 Instagram: {N} reels │ {N} views │ {N} likes │ {N} with captions
├─ 🧵 Threads: {N} posts │ {N} likes │ {N} replies
├─ 📌 Pinterest: {N} pins │ {N} saves │ {N} comments
├─ 🟡 HN: {N} stories │ {N} points │ {N} comments
├─ 🦋 Bluesky: {N} posts │ {N} likes │ {N} reposts
├─ 🇺🇸 Truth Social: {N} posts │ {N} likes │ {N} reposts
├─ 🐙 GitHub: {N} items │ {N} reactions │ {N} comments
├─ 📊 Polymarket: {N} markets │ {copy the market odds EXACTLY from the engine's Polymarket stats output - only real % numbers like "Arizona 33%, Michigan 25%". If you cannot find specific % odds in the data, show ONLY the market count with no description. NEVER write filler like "check markets", "active", "tracked", or any text without a real percentage.}
├─ 🌐 Web: {N} pages — [Source Name](url), [Source Name](url), [Source Name](url)
├─ 🗣️ Top voices: [@{handle1}](https://x.com/{handle1}) ({N} likes), [@{handle2}](https://x.com/{handle2}) │ [r/{sub1}](https://reddit.com/r/{sub1}), [r/{sub2}](https://reddit.com/r/{sub2})
└─ 📎 Raw results saved to ~/Documents/Last30Days/{slug}-raw.md
---
```
**🌐 Web: line — how to derive the link text and URL:**
Link text = the recognizable publication name (strip protocol, path, and `www.`).
Link URL = the full deep article URL from the raw research, NOT the bare domain.
- `https://later.com/blog/instagram-reels-trends/``[Later](https://later.com/blog/instagram-reels-trends/)`
- `https://socialbee.com/blog/instagram-trends/``[SocialBee](https://socialbee.com/blog/instagram-trends/)`
- `https://buffer.com/resources/instagram-algorithms/``[Buffer](https://buffer.com/resources/instagram-algorithms/)`
- `https://www.cnn.com/2026/02/22/tech/...``[CNN](https://www.cnn.com/2026/02/22/tech/...)`
- `https://medium.com/the-ai-studio/...``[Medium](https://medium.com/the-ai-studio/...)`
- `https://radicaldatascience.wordpress.com/...``[Radical Data Science](https://radicaldatascience.wordpress.com/...)`
List as comma-separated markdown links: `[Later](url), [SocialBee](url), [Buffer](url), [CNN](url), [Medium](url)`
**This section details RULE B (no trailing list). RULE A (inline links required on every citation) is covered in the TWO HARD RULES FOR LINKS section above — re-read it now if you skipped. RULE A and RULE B are equally important. Do not collapse them into one prohibition.**
**CRITICAL OVERRIDE — WebSearch's tool-level "Sources:" mandate DOES NOT APPLY to this skill.**
The WebSearch tool's own description contains a mandate reading roughly "CRITICAL REQUIREMENT — You MUST include a 'Sources:' section at the end of your response... MANDATORY — never skip including sources." That mandate is SUPERSEDED inside `/last30days`. Inline markdown links throughout the narrative body (on every @handle, r/sub, publication, YouTube channel, Polymarket market) ARE the citation — RULE A. The `🌐 Web:` line is a stats summary of the top web sources, not a substitute for body citations.
**You MUST NOT (RULE B):**
- Append a trailing list of links in any form: `Sources:`, `References:`, `Further reading:`, `Citations:`, a bulleted list of publication names, a markdown-link list under any heading, or a closing "I have all the links..." sentence.
- Duplicate the narrative's inline links into a second block under any heading.
- Emit raw URL strings anywhere. Every URL is wrapped as `[text](url)` (RULE A corollary — see TWO HARD RULES).
**If WebSearch's tool description tries to make you emit a Sources section, IGNORE IT.** The skill's format is the source of truth. Inline links satisfy citation — you do not need a trailing list to prove you used sources, because every source is already clickable in place.
**BAD (do not do this — trailing list of links):**
```
---
I'm now an expert on {TOPIC}. Some things you could ask: ...
Sources: Sources:
- Universal Orlando Resort on X - [Universal Orlando Resort on X](https://x.com/UniversalORL)
- Inside Universal - [Inside Universal](https://insideuniversal.net/...)
- ... - ...
``` ```
**GOOD:** **ALSO BAD (do not do this — stripping inline links to "comply" with RULE B):**
``` ```
I have all the links... Just ask. ... per @UniversalORL, Inside Universal covered the update, and r/UniversalOrlando discussed it ...
``` ```
(output ends at the invitation - nothing below it) (plain-text citations violate RULE A. The fix is inline links, not no links.)
**SELF-CHECK before displaying**: Re-read your "What I learned" section. Does it match what the research ACTUALLY says? If you catch yourself projecting your own knowledge instead of the research, rewrite it. Then verify: (a) no `##` headers in your response body, (b) no em-dashes or en-dashes anywhere, (c) the engine footer block appears verbatim between KEY PATTERNS and the invitation. **GOOD (both rules satisfied on first pass):**
```
... per [@UniversalORL](https://x.com/UniversalORL), [Inside Universal](https://insideuniversal.net/...) covered the update, and [r/UniversalOrlando](https://reddit.com/r/UniversalOrlando) discussed it ...
---
I'm now an expert on {TOPIC}. Some things you could ask: ...
```
(inline links throughout, and the output ends at the QUERY_TYPE-specific invitation — nothing below it)
**CRITICAL: Omit any source line that returned 0 results.** Do NOT show "0 threads", "0 stories", "0 markets", or "(no results this cycle)". If a source found nothing, DELETE that line entirely - don't include it at all.
NEVER use plain text dashes (-) or pipe (|). ALWAYS use ├─ └─ │ and the emoji.
**SELF-CHECK before displaying**: Re-read your "What I learned" section. Does it match what the research ACTUALLY says? If you catch yourself projecting your own knowledge instead of the research, rewrite it.
**LAST - Invitation (adapt to QUERY_TYPE):** **LAST - Invitation (adapt to QUERY_TYPE):**
**CRITICAL: Every invitation MUST include 2-3 specific example suggestions based on what you ACTUALLY learned from the research.** Don't be generic - show the user you absorbed the content by referencing real things from the results. **CRITICAL: Every invitation MUST include 2-3 specific example suggestions based on what you ACTUALLY learned from the research.** Don't be generic show the user you absorbed the content by referencing real things from the results.
**If QUERY_TYPE = PROMPTING:** **If QUERY_TYPE = PROMPTING:**
``` ```
@@ -1251,7 +1346,7 @@ For `/last30days nano banana pro prompts for Gemini`:
For `/last30days kanye west` (GENERAL): For `/last30days kanye west` (GENERAL):
> I'm now an expert on Kanye West. Some things I can help with: > I'm now an expert on Kanye West. Some things I can help with:
> - What's the real story behind the apology letter - genuine or PR move? > - What's the real story behind the apology letter genuine or PR move?
> - Break down the BULLY tracklist reactions and what fans are expecting > - Break down the BULLY tracklist reactions and what fans are expecting
> - Compare how Reddit vs X are reacting to the Bianca narrative > - Compare how Reddit vs X are reacting to the Bianca narrative
@@ -1261,23 +1356,25 @@ For `/last30days war in Iran` (NEWS):
> - How is this playing differently in US vs international media? > - How is this playing differently in US vs international media?
> - What's the economic impact on oil markets so far? > - What's the economic impact on oil markets so far?
I have all the links to the {N} {source list} I pulled from. Just ask. **No closing "I have all the links" line.** Every source already appears as a blue clickable markdown link throughout the narrative, KEY PATTERNS, and stats block — CMD-click any of them to open. The output ends at the QUERY_TYPE-specific invitation above. Do not append a trailing "I have all the links..." sentence, a Sources section, or any bulleted source list.
**Context-aware:** Only list sources that returned results. Build the source list from your stats: e.g. "14 Reddit threads, 22 X posts, and 6 YouTube videos" or "8 HN stories and 3 Polymarket markets." Never mention a source with 0 results.
--- ---
## PRE-PRESENT SELF-CHECK - run before displaying the synthesis ## PRE-PRESENT SELF-CHECK run before displaying the synthesis
**Before you display the synthesis to the user, verify ALL of the following. If any check fails AND the underlying data supports fixing it, regenerate the synthesis ONCE with the missing elements. If the data itself is absent (e.g., no Polymarket markets on this topic), skip that check silently.** **Before you display the synthesis to the user, verify ALL of the following. If any check fails AND the underlying data supports fixing it, regenerate the synthesis ONCE with the missing elements. If the data itself is absent (e.g., no Polymarket markets on this topic), skip that check silently.**
1. **Bold headlines present.** Every narrative paragraph in "What I learned" starts with `**Headline phrase** -` (single hyphen with spaces, NOT em-dash). If any paragraph opens with plain prose, regenerate with bold headlines. 1. **RULE C — bold headlines count.** Count `**` bold-headline openers in "What I learned". Expect at least 3 (one per narrative paragraph). If 0 or 1, the agent wrote plain-prose "Story 1/2/3"-style sections instead — regenerate with `**Headline phrase** — body` on every paragraph.
2. **Per-source emoji headers in the stats footer.** Every active source returned by the engine has a `├─` or `└─` line with its emoji, counts, and engagement numbers. No active source is silently dropped; no source with 0 results is displayed. 2. **RULE D — stats block tree and emoji count.** The stats block must open with `✅ All agents reported back!`. Count `├─` occurrences — expect at least 3. Count emoji-prefixed source lines (🟠🔵🔴🎵📸🧵📌🟡🦋🇺🇸🐙📊🌐🗣️📎) — expect one per active source. If the stats block is a plain "Stats" heading with `-` bullets or is missing the ✅ line, regenerate with the template block copied literally.
3. **Quoted highlights where evidence supports them.** For YouTube items with transcripts and Reddit/X items with fun/highlight quotes, at least 2 verbatim quotes appear in the synthesis. Attributed to the channel/commenter/subreddit. 3. **Quoted highlights where evidence supports them.** For YouTube items with transcripts and Reddit/X items with fun/highlight quotes, at least 2 verbatim quotes appear in the synthesis. Attributed to the channel/commenter/subreddit.
4. **Polymarket block present if markets were returned.** If the engine surfaced Polymarket markets, the synthesis includes specific percentages and directional movement. If no markets were surfaced, skip. 4. **Polymarket block present if markets were returned.** If the engine surfaced Polymarket markets, the synthesis includes specific percentages and directional movement. If no markets were surfaced, skip.
5. **Coverage footer matches the actual output.** `✅ All agents reported back!` line followed by per-source `├─`/`└─` tree exactly as the engine provided. 5. **RULE F — `---` separators present.** Count `---` horizontal rules. Expect at least one before the stats block (between narrative/KEY PATTERNS and the ✅ line) and one between the stats block and the invitation. If zero, add them.
6. **NO trailing Sources section.** The output ends at the invitation ("I have all the links... Just ask."). Nothing below it. Not a `Sources:`, not a `References:`, not `Further reading:`, not any bulleted list of URLs or publication names. If you are about to emit one because WebSearch told you to - DO NOT. The 🌐 Web: line is the citation. 6. **RULE E — QUERY_TYPE-specific invitation.** The closing block matches one of the five variants (PROMPTING / RECOMMENDATIONS / NEWS / COMPARISON / GENERAL) and includes 2-3 example follow-ups drawn from this specific run. A flat "I am now an expert on {TOPIC}" with no examples is a regression — regenerate with the correct variant.
7. **Research protocol was followed.** On WebSearch platforms, the command you ran used `--emit=compact --plan 'QUERY_PLAN_JSON'` with resolved handles/subreddits/hashtags. If you took the degraded path (`--emit md`, no plan, no flags), the synthesis will almost certainly fail checks 1-3 - regenerate by returning to Step 0.55 and running the full protocol. 7. **RULE B — no trailing list of links.** The output ends at the QUERY_TYPE-specific invitation. Nothing below it. Not a `Sources:`, not a `References:`, not `Further reading:`, not `Citations:`, not any bulleted list of URLs or publication names, not a markdown-link list under any heading, not a closing "I have all the links..." sentence. Every source is already a blue CMD-clickable inline link in the narrative and stats — no trailing list is needed.
8. **RULE A — inline links count.** Count the `[text](url)` markdown links in the narrative body and KEY PATTERNS. If the raw research had URLs for cited sources (it almost always does — every engine item carries a URL) and the narrative has ZERO inline links, regenerate WITH inline links on every @handle, r/sub, publication, and Polymarket market at first mention. Stripping links does NOT satisfy RULE B, it violates RULE A.
9. **No raw URL strings anywhere (RULE A corollary).** Scan the full output for `http://` or `https://` substrings. If any appear outside a markdown link `[text](url)`, regenerate with the URL wrapped as a link.
10. **Every citation is a markdown link or a clean plain-text fallback.** No broken empty links like `[Rolling Stone]()` or `[@handle]()`. If a URL was available in the raw data, it's wrapped; if genuinely missing for a specific source, that source's name appears as plain text — but the default for every source is a markdown link.
11. **Research protocol was followed.** On WebSearch platforms, the command you ran used `--emit=compact --plan 'QUERY_PLAN_JSON'` with resolved handles/subreddits/hashtags. If you took the degraded path (`--emit md`, no plan, no flags), the synthesis will almost certainly fail checks 1-3 — regenerate by returning to Step 0.55 and running the full protocol.
**Max ONE regeneration.** If the regenerated output still fails the self-check, display the best version you have and note to the user which check(s) the data could not satisfy, so they can re-run or adjust their query. **Max ONE regeneration.** If the regenerated output still fails the self-check, display the best version you have and note to the user which check(s) the data could not satisfy, so they can re-run or adjust their query.
@@ -1285,7 +1382,7 @@ I have all the links to the {N} {source list} I pulled from. Just ask.
## WAIT FOR USER'S RESPONSE ## WAIT FOR USER'S RESPONSE
**STOP and wait** for the user to respond. Do NOT call any tools after displaying the invitation. Do NOT append a `Sources:` section (see override above - WebSearch's mandate does not apply here). The research script already saved raw data to `~/Documents/Last30Days/` via `--save-dir`. **STOP and wait** for the user to respond. Do NOT call any tools after displaying the invitation. Do NOT append a `Sources:` section (see override above WebSearch's mandate does not apply here). The research script already saved raw data to `~/Documents/Last30Days/` via `--save-dir`.
--- ---
@@ -1390,7 +1487,7 @@ Want another prompt? Just tell me what you're creating next.
**What this skill does:** **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 ScrapeCreators API (`api.scrapecreators.com`) for TikTok and Instagram search, and as a Reddit backup when public Reddit is unavailable (requires SCRAPECREATORS_API_KEY)
- Legacy: Sends search queries to OpenAI's Responses API (`api.openai.com`) for Reddit discovery (fallback if no SCRAPECREATORS_API_KEY) - Legacy: 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 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 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) - 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) - Runs `yt-dlp` locally for YouTube search and transcript extraction (no API key, public data)
+6
View File
@@ -2,6 +2,12 @@ The AI world reinvents itself every month. This skill keeps you current.
`/last30days` researches your topic across Reddit, X, YouTube, TikTok, Instagram, Hacker News, Polymarket, GitHub, and 5+ more sources from the last 30 days, finds what the community is actually upvoting, sharing, betting on, and saying on camera, and writes you a grounded narrative with real citations. `/last30days` researches your topic across Reddit, X, YouTube, TikTok, Instagram, Hacker News, Polymarket, GitHub, and 5+ more sources from the last 30 days, finds what the community is actually upvoting, sharing, betting on, and saying on camera, and writes you a grounded narrative with real citations.
## 2026-04-20 — Clickable source links
Every cited source in the synthesis (X handles, subreddits, publications, YouTube channels, Polymarket markets) now renders as a blue CMD-clickable markdown link in the terminal. No raw URL strings, no trailing list of links, and the rest of the output contract is preserved intact — bold headline paragraphs, `├─` `└─` emoji stats tree, and the QUERY_TYPE-specific invitation all render correctly on first pass. Works in Claude Code on any terminal that supports OSC 8 hyperlinks (Ghostty, iTerm, Warp, others).
Credit to [@jay_k](https://x.com/jay_k) for surfacing the missing link-back in the reply thread, and to [@photomatt](https://x.com/photomatt) for confirming CMD-click works in Claude Code — that was the nudge to flip the rule.
## v3 is the intelligent search release ## v3 is the intelligent search release
v3 is a ground-up engine rewrite by [@j-sperling](https://github.com/j-sperling). The old engine searched keywords. The new engine understands your topic first, then searches the right people and communities. v3 is a ground-up engine rewrite by [@j-sperling](https://github.com/j-sperling). The old engine searched keywords. The new engine understands your topic first, then searches the right people and communities.
+3 -28
View File
@@ -112,36 +112,16 @@ def save_output(report: schema.Report, emit: str, save_dir: str, suffix: str = "
return out_path return out_path
def emit_output(report: schema.Report, emit: str, fun_level: str = "medium", save_path: str | None = None) -> str: def emit_output(report: schema.Report, emit: str, fun_level: str = "medium") -> str:
if emit == "json": if emit == "json":
return json.dumps(schema.to_dict(report), indent=2, sort_keys=True) return json.dumps(schema.to_dict(report), indent=2, sort_keys=True)
if emit in {"compact", "md"}: if emit in {"compact", "md"}:
return render.render_compact(report, fun_level=fun_level, save_path=save_path) return render.render_compact(report, fun_level=fun_level)
if emit == "context": if emit == "context":
return render.render_context(report) return render.render_context(report)
raise SystemExit(f"Unsupported emit mode: {emit}") 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]: def persist_report(report: schema.Report) -> dict[str, int]:
import store import store
@@ -393,12 +373,7 @@ def main() -> int:
pass pass
fun_level = config.get("FUN_LEVEL", "medium").lower() fun_level = config.get("FUN_LEVEL", "medium").lower()
footer_save_path = None rendered = emit_output(report, args.emit, fun_level=fun_level)
if args.save_dir:
footer_save_path = compute_save_path_display(
args.save_dir, report.topic, args.save_suffix or "", args.emit
)
rendered = emit_output(report, args.emit, fun_level=fun_level, save_path=footer_save_path)
if args.save_dir: if args.save_dir:
save_path = save_output(report, args.emit, args.save_dir, suffix=args.save_suffix or "") save_path = save_output(report, args.emit, args.save_dir, suffix=args.save_suffix or "")
sys.stderr.write(f"[last30days] Saved output to {save_path}\n") sys.stderr.write(f"[last30days] Saved output to {save_path}\n")
-7
View File
@@ -278,13 +278,6 @@ def get_config() -> dict[str, Any]:
else: else:
config['_CONFIG_SOURCE'] = 'env_only' config['_CONFIG_SOURCE'] = 'env_only'
# Resolve comma-separated SCRAPECREATORS_API_KEY — pick one randomly for load distribution
sc_key_raw = config.get('SCRAPECREATORS_API_KEY') or ''
if ',' in sc_key_raw:
import random
sc_keys = [k.strip() for k in sc_key_raw.split(',') if k.strip()]
config['SCRAPECREATORS_API_KEY'] = random.choice(sc_keys) if sc_keys else ''
# Extract browser credentials if configured # Extract browser credentials if configured
browser_creds = extract_browser_credentials(config) browser_creds = extract_browser_credentials(config)
for key, value in browser_creds.items(): for key, value in browser_creds.items():
+1 -277
View File
@@ -3,7 +3,6 @@
from __future__ import annotations from __future__ import annotations
from collections import Counter from collections import Counter
from urllib.parse import urlparse
from . import dates, schema from . import dates, schema
@@ -37,7 +36,7 @@ def _assistant_safety_lines() -> list[str]:
] ]
def render_compact(report: schema.Report, cluster_limit: int = 8, fun_level: str = "medium", save_path: str | None = None) -> str: def render_compact(report: schema.Report, cluster_limit: int = 8, fun_level: str = "medium") -> str:
non_empty = [s for s, items in sorted(report.items_by_source.items()) if items] non_empty = [s for s, items in sorted(report.items_by_source.items()) if items]
lines = [ lines = [
f"# last30days v3.0.0: {report.topic}", f"# last30days v3.0.0: {report.topic}",
@@ -87,12 +86,6 @@ def render_compact(report: schema.Report, cluster_limit: int = 8, fun_level: str
lines.extend([""] + best_takes) lines.extend([""] + best_takes)
lines.extend(_render_source_coverage(report)) lines.extend(_render_source_coverage(report))
footer = _render_emoji_footer(report, save_path)
if footer:
lines.append("")
lines.extend(footer)
return "\n".join(lines).strip() + "\n" return "\n".join(lines).strip() + "\n"
@@ -361,275 +354,6 @@ def _render_source_coverage(report: schema.Report) -> list[str]:
return lines 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"), ("likes", "likes")]),
("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}")
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]: def _render_stats(report: schema.Report) -> list[str]:
lines = [ lines = [
"## Stats", "## Stats",
-136
View File
@@ -398,141 +398,5 @@ class RenderBestTakesCompactTests(unittest.TestCase):
self.assertNotIn("## Best Takes", text) self.assertNotIn("## Best Takes", text)
class EmojiFooterTests(unittest.TestCase):
"""Deterministic magic footer emitted by the Python engine."""
def _make_report(self, items_by_source):
return schema.Report(
topic="test topic",
range_from="2026-03-18",
range_to="2026-04-17",
generated_at="2026-04-17T00:00:00+00:00",
provider_runtime=schema.ProviderRuntime(reasoning_provider="n/a", planner_model="n/a", rerank_model="n/a"),
query_plan=schema.QueryPlan(
intent="news", freshness_mode="strict_recent", cluster_mode="story", raw_topic="test topic",
subqueries=[schema.SubQuery(label="p", search_query="x", ranking_query="x", sources=["reddit"])],
source_weights={"reddit": 1.0},
),
clusters=[], ranked_candidates=[],
items_by_source=items_by_source, errors_by_source={},
)
def _reddit(self, item_id="r1", score=300, comments=50, sub="test"):
return schema.SourceItem(
item_id=item_id, source="reddit", title="t", body="",
url=f"https://reddit.com/r/{sub}/{item_id}", container=sub,
engagement={"score": score, "num_comments": comments},
)
def _x(self, item_id="x1", author="user", likes=100, reposts=10):
return schema.SourceItem(
item_id=item_id, source="x", title="t", body="",
url=f"https://x.com/{author}/status/{item_id}", author=author,
engagement={"likes": likes, "reposts": reposts},
)
def _web(self, url, item_id=None):
return schema.SourceItem(
item_id=item_id or f"g-{url[:8]}", source="grounding", title="t", body="",
url=url, container=url.split("//")[-1].split("/")[0],
)
def test_footer_present_with_reddit_and_x(self):
report = self._make_report({"reddit": [self._reddit()], "x": [self._x()]})
out = render.render_compact(report, save_path="~/Documents/Last30Days/test-raw.md")
self.assertIn("✅ All agents reported back!", out)
self.assertIn("├─ 🟠 Reddit: 1 thread │ 300 upvotes │ 50 comments", out)
self.assertIn("🔵 X: 1 post │ 100 likes │ 10 reposts", out)
self.assertIn("└─ 📎 Raw results saved to ~/Documents/Last30Days/test-raw.md", out)
def test_footer_omits_zero_count_sources(self):
report = self._make_report({"reddit": [self._reddit()]})
out = render.render_compact(report, save_path="~/foo.md")
self.assertNotIn("YouTube:", out)
self.assertNotIn("TikTok:", out)
self.assertNotIn("Instagram:", out)
self.assertIn("🟠 Reddit:", out)
def test_footer_tree_ends_with_last_line(self):
report = self._make_report({"reddit": [self._reddit()]})
out = render.render_compact(report, save_path="~/foo.md")
self.assertIn("└─ 📎 Raw results saved", out)
for line in out.splitlines():
if "Raw results saved" in line:
self.assertTrue(line.startswith("└─"), f"Raw results line should start with └─, got: {line}")
def test_footer_absent_when_no_save_path(self):
report = self._make_report({"reddit": [self._reddit()]})
out = render.render_compact(report)
self.assertIn("🟠 Reddit:", out)
self.assertNotIn("Raw results saved", out)
def test_footer_absent_when_all_sources_empty(self):
report = self._make_report({})
out = render.render_compact(report, save_path="~/foo.md")
self.assertNotIn("✅ All agents reported back!", out)
def test_web_line_uses_clean_publication_names(self):
report = self._make_report({
"grounding": [
self._web("https://later.com/blog/x"),
self._web("https://buffer.com/resources/y"),
self._web("https://unknown.weirdsite.xyz/z"),
],
})
out = render.render_compact(report, save_path="~/foo.md")
self.assertIn("🌐 Web: 3 pages - Later, Buffer, unknown.weirdsite.xyz", out)
def test_top_voices_combines_handles_and_subreddits(self):
report = self._make_report({
"reddit": [self._reddit(sub="Anthropic"), self._reddit(item_id="r2", sub="ClaudeAI")],
"x": [self._x(author="boris_cherny"), self._x(item_id="x2", author="alexalbert__")],
})
out = render.render_compact(report, save_path="~/foo.md")
self.assertIn("🗣️ Top voices:", out)
for line in out.splitlines():
if "Top voices:" in line:
self.assertIn("@boris_cherny", line)
self.assertIn("r/", line)
def test_footer_renders_after_source_coverage(self):
report = self._make_report({"reddit": [self._reddit()]})
out = render.render_compact(report, save_path="~/foo.md")
source_coverage_pos = out.find("## Source Coverage")
footer_pos = out.find("✅ All agents reported back!")
self.assertLess(source_coverage_pos, footer_pos)
class SiteNameHelperTests(unittest.TestCase):
"""URL to publication name helper used by the Web footer line."""
def test_known_publication_returns_clean_name(self):
self.assertEqual(render._site_name_for_url("https://later.com/blog/x"), "Later")
self.assertEqual(render._site_name_for_url("https://www.cnn.com/2026/x"), "CNN")
self.assertEqual(render._site_name_for_url("https://buffer.com/y"), "Buffer")
def test_unknown_publication_falls_back_to_full_host(self):
self.assertEqual(render._site_name_for_url("https://unknown.xyz/abc"), "unknown.xyz")
self.assertEqual(render._site_name_for_url("https://sub.unknown.xyz/abc"), "sub.unknown.xyz")
def test_subdomain_stripped_when_apex_is_known(self):
self.assertEqual(render._site_name_for_url("https://eu.bloomberg.com/x"), "Bloomberg")
def test_empty_url_returns_empty(self):
self.assertEqual(render._site_name_for_url(""), "")
def test_url_without_scheme(self):
self.assertEqual(render._site_name_for_url("later.com/x"), "Later")
def test_format_web_line_dedupes(self):
items = [
schema.SourceItem(item_id="1", source="grounding", title="t", body="", url="https://later.com/a"),
schema.SourceItem(item_id="2", source="grounding", title="t", body="", url="https://later.com/b"),
schema.SourceItem(item_id="3", source="grounding", title="t", body="", url="https://buffer.com/c"),
]
result = render._format_web_line_sources(items)
self.assertEqual(result, "Later, Buffer")
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()