last30days: Research Any Topic from the Last 30 Days Research ANY topic across Reddit, X, and the web. Surface what people are actually discussing, recommending, and debating right now. Use cases: - Prompting : "photorealistic people in Nano Banana Pro", "Midjourney prompts", "ChatGPT image generation" → learn techniques, get copy-paste prompts - Recommendations : "best Claude Code skills", "top AI tools" → get a LIST of specific things people mention - News : "what's happening with OpenAI", "latest AI announcements" → current events and updates - General : any topic you're curious about → un…

, model_lower):\n return False\n\n # Exclude variants\n excludes = ['mini', 'nano', 'chat', 'codex', 'pro', 'preview', 'turbo']\n for exc in excludes:\n if exc in model_lower:\n return False\n\n return True\n\n\ndef select_openai_model(\n api_key: str,\n policy: str = \"auto\",\n pin: Optional[str] = None,\n mock_models: Optional[List[Dict]] = None,\n) -> str:\n \"\"\"Select the best OpenAI model based on policy.\n\n Args:\n api_key: OpenAI API key\n policy: 'auto' or 'pinned'\n pin: Model to use if policy is 'pinned'\n mock_models: Mock model list for testing\n\n Returns:\n Selected model ID\n \"\"\"\n if policy == \"pinned\" and pin:\n return pin\n\n # Check cache first\n cached = cache.get_cached_model(\"openai\")\n if cached:\n return cached\n\n # Fetch model list\n if mock_models is not None:\n models = mock_models\n else:\n try:\n headers = {\"Authorization\": f\"Bearer {api_key}\"}\n response = http.get(OPENAI_MODELS_URL, headers=headers)\n models = response.get(\"data\", [])\n except http.HTTPError:\n # Fall back to known models\n return OPENAI_FALLBACK_MODELS[0]\n\n # Filter to mainline models\n candidates = [m for m in models if is_mainline_openai_model(m.get(\"id\", \"\"))]\n\n if not candidates:\n # No gpt-5 models found, use fallback\n return OPENAI_FALLBACK_MODELS[0]\n\n # Sort by version (descending), then by created timestamp\n def sort_key(m):\n version = parse_version(m.get(\"id\", \"\")) or (0,)\n created = m.get(\"created\", 0)\n return (version, created)\n\n candidates.sort(key=sort_key, reverse=True)\n selected = candidates[0][\"id\"]\n\n # Cache the selection\n cache.set_cached_model(\"openai\", selected)\n\n return selected\n\n\ndef select_xai_model(\n api_key: str,\n policy: str = \"latest\",\n pin: Optional[str] = None,\n mock_models: Optional[List[Dict]] = None,\n) -> str:\n \"\"\"Select the best xAI model based on policy.\n\n Args:\n api_key: xAI API key\n policy: 'latest', 'stable', or 'pinned'\n pin: Model to use if policy is 'pinned'\n mock_models: Mock model list for testing\n\n Returns:\n Selected model ID\n \"\"\"\n if policy == \"pinned\" and pin:\n return pin\n\n # Use alias system\n if policy in XAI_ALIASES:\n alias = XAI_ALIASES[policy]\n\n # Check cache first\n cached = cache.get_cached_model(\"xai\")\n if cached:\n return cached\n\n # Cache the alias\n cache.set_cached_model(\"xai\", alias)\n return alias\n\n # Default to latest\n return XAI_ALIASES[\"latest\"]\n\n\ndef get_models(\n config: Dict,\n mock_openai_models: Optional[List[Dict]] = None,\n mock_xai_models: Optional[List[Dict]] = None,\n) -> Dict[str, Optional[str]]:\n \"\"\"Get selected models for both providers.\n\n Returns:\n Dict with 'openai' and 'xai' keys\n \"\"\"\n result = {\"openai\": None, \"xai\": None}\n\n if config.get(\"OPENAI_API_KEY\"):\n result[\"openai\"] = select_openai_model(\n config[\"OPENAI_API_KEY\"],\n config.get(\"OPENAI_MODEL_POLICY\", \"auto\"),\n config.get(\"OPENAI_MODEL_PIN\"),\n mock_openai_models,\n )\n\n if config.get(\"XAI_API_KEY\"):\n result[\"xai\"] = select_xai_model(\n config[\"XAI_API_KEY\"],\n config.get(\"XAI_MODEL_POLICY\", \"latest\"),\n config.get(\"XAI_MODEL_PIN\"),\n mock_xai_models,\n )\n\n return result\n","content_type":"text/x-python; charset=utf-8","language":"python","size":4631,"content_sha256":"98c633c6e74d39fd0a5919d6c14851b0b7a67b93e4908c248140df95cb2d0fc0"},{"filename":"scripts/lib/normalize.py","content":"\"\"\"Normalization of raw API data to canonical schema.\"\"\"\n\nfrom typing import Any, Dict, List, TypeVar, Union\n\nfrom . import dates, schema\n\nT = TypeVar(\"T\", schema.RedditItem, schema.XItem, schema.WebSearchItem)\n\n\ndef filter_by_date_range(\n items: List[T],\n from_date: str,\n to_date: str,\n require_date: bool = False,\n) -> List[T]:\n \"\"\"Hard filter: Remove items outside the date range.\n\n This is the safety net - even if the prompt lets old content through,\n this filter will exclude it.\n\n Args:\n items: List of items to filter\n from_date: Start date (YYYY-MM-DD) - exclude items before this\n to_date: End date (YYYY-MM-DD) - exclude items after this\n require_date: If True, also remove items with no date\n\n Returns:\n Filtered list with only items in range (or unknown dates if not required)\n \"\"\"\n result = []\n for item in items:\n if item.date is None:\n if not require_date:\n result.append(item) # Keep unknown dates (with scoring penalty)\n continue\n\n # Hard filter: if date is before from_date, exclude\n if item.date \u003c from_date:\n continue # DROP - too old\n\n # Hard filter: if date is after to_date, exclude (likely parsing error)\n if item.date > to_date:\n continue # DROP - future date\n\n result.append(item)\n\n return result\n\n\ndef normalize_reddit_items(\n items: List[Dict[str, Any]],\n from_date: str,\n to_date: str,\n) -> List[schema.RedditItem]:\n \"\"\"Normalize raw Reddit items to schema.\n\n Args:\n items: Raw Reddit items from API\n from_date: Start of date range\n to_date: End of date range\n\n Returns:\n List of RedditItem objects\n \"\"\"\n normalized = []\n\n for item in items:\n # Parse engagement\n engagement = None\n eng_raw = item.get(\"engagement\")\n if isinstance(eng_raw, dict):\n engagement = schema.Engagement(\n score=eng_raw.get(\"score\"),\n num_comments=eng_raw.get(\"num_comments\"),\n upvote_ratio=eng_raw.get(\"upvote_ratio\"),\n )\n\n # Parse comments\n top_comments = []\n for c in item.get(\"top_comments\", []):\n top_comments.append(schema.Comment(\n score=c.get(\"score\", 0),\n date=c.get(\"date\"),\n author=c.get(\"author\", \"\"),\n excerpt=c.get(\"excerpt\", \"\"),\n url=c.get(\"url\", \"\"),\n ))\n\n # Determine date confidence\n date_str = item.get(\"date\")\n date_confidence = dates.get_date_confidence(date_str, from_date, to_date)\n\n normalized.append(schema.RedditItem(\n id=item.get(\"id\", \"\"),\n title=item.get(\"title\", \"\"),\n url=item.get(\"url\", \"\"),\n subreddit=item.get(\"subreddit\", \"\"),\n date=date_str,\n date_confidence=date_confidence,\n engagement=engagement,\n top_comments=top_comments,\n comment_insights=item.get(\"comment_insights\", []),\n relevance=item.get(\"relevance\", 0.5),\n why_relevant=item.get(\"why_relevant\", \"\"),\n ))\n\n return normalized\n\n\ndef normalize_x_items(\n items: List[Dict[str, Any]],\n from_date: str,\n to_date: str,\n) -> List[schema.XItem]:\n \"\"\"Normalize raw X items to schema.\n\n Args:\n items: Raw X items from API\n from_date: Start of date range\n to_date: End of date range\n\n Returns:\n List of XItem objects\n \"\"\"\n normalized = []\n\n for item in items:\n # Parse engagement\n engagement = None\n eng_raw = item.get(\"engagement\")\n if isinstance(eng_raw, dict):\n engagement = schema.Engagement(\n likes=eng_raw.get(\"likes\"),\n reposts=eng_raw.get(\"reposts\"),\n replies=eng_raw.get(\"replies\"),\n quotes=eng_raw.get(\"quotes\"),\n )\n\n # Determine date confidence\n date_str = item.get(\"date\")\n date_confidence = dates.get_date_confidence(date_str, from_date, to_date)\n\n normalized.append(schema.XItem(\n id=item.get(\"id\", \"\"),\n text=item.get(\"text\", \"\"),\n url=item.get(\"url\", \"\"),\n author_handle=item.get(\"author_handle\", \"\"),\n date=date_str,\n date_confidence=date_confidence,\n engagement=engagement,\n relevance=item.get(\"relevance\", 0.5),\n why_relevant=item.get(\"why_relevant\", \"\"),\n ))\n\n return normalized\n\n\ndef items_to_dicts(items: List) -> List[Dict[str, Any]]:\n \"\"\"Convert schema items to dicts for JSON serialization.\"\"\"\n return [item.to_dict() for item in items]\n","content_type":"text/x-python; charset=utf-8","language":"python","size":4767,"content_sha256":"e8666ce0a872670b16eb1c822df05aa96981abc57fe9e795f0fb9eceaa3dcad5"},{"filename":"scripts/lib/openai_reddit.py","content":"\"\"\"OpenAI Responses API client for Reddit discovery.\"\"\"\n\nimport json\nimport re\nimport sys\nfrom typing import Any, Dict, List, Optional\n\nfrom . import http\n\n\ndef _log_error(msg: str):\n \"\"\"Log error to stderr.\"\"\"\n sys.stderr.write(f\"[REDDIT ERROR] {msg}\\n\")\n sys.stderr.flush()\n\nOPENAI_RESPONSES_URL = \"https://api.openai.com/v1/responses\"\n\n# Depth configurations: (min, max) threads to request\n# Request MORE than needed since many get filtered by date\nDEPTH_CONFIG = {\n \"quick\": (15, 25),\n \"default\": (30, 50),\n \"deep\": (70, 100),\n}\n\nREDDIT_SEARCH_PROMPT = \"\"\"Find Reddit discussion threads about: {topic}\n\nSTEP 1: EXTRACT THE CORE SUBJECT\nGet the MAIN NOUN/PRODUCT/TOPIC:\n- \"best nano banana prompting practices\" → \"nano banana\"\n- \"killer features of clawdbot\" → \"clawdbot\"\n- \"top Claude Code skills\" → \"Claude Code\"\nDO NOT include \"best\", \"top\", \"tips\", \"practices\", \"features\" in your search.\n\nSTEP 2: SEARCH BROADLY\nSearch for the core subject:\n1. \"[core subject] site:reddit.com\"\n2. \"reddit [core subject]\"\n3. \"[core subject] reddit\"\n\nReturn as many relevant threads as you find. We filter by date server-side.\n\nSTEP 3: INCLUDE ALL MATCHES\n- Include ALL threads about the core subject\n- Set date to \"YYYY-MM-DD\" if you can determine it, otherwise null\n- We verify dates and filter old content server-side\n- DO NOT pre-filter aggressively - include anything relevant\n\nREQUIRED: URLs must contain \"/r/\" AND \"/comments/\"\nREJECT: developers.reddit.com, business.reddit.com\n\nFind {min_items}-{max_items} threads. Return MORE rather than fewer.\n\nReturn JSON:\n{{\n \"items\": [\n {{\n \"title\": \"Thread title\",\n \"url\": \"https://www.reddit.com/r/sub/comments/xyz/title/\",\n \"subreddit\": \"subreddit_name\",\n \"date\": \"YYYY-MM-DD or null\",\n \"why_relevant\": \"Why relevant\",\n \"relevance\": 0.85\n }}\n ]\n}}\"\"\"\n\n\ndef _extract_core_subject(topic: str) -> str:\n \"\"\"Extract core subject from verbose query for retry.\"\"\"\n noise = ['best', 'top', 'how to', 'tips for', 'practices', 'features',\n 'killer', 'guide', 'tutorial', 'recommendations', 'advice',\n 'prompting', 'using', 'for', 'with', 'the', 'of', 'in', 'on']\n words = topic.lower().split()\n result = [w for w in words if w not in noise]\n return ' '.join(result[:3]) or topic # Keep max 3 words\n\n\ndef search_reddit(\n api_key: str,\n model: str,\n topic: str,\n from_date: str,\n to_date: str,\n depth: str = \"default\",\n mock_response: Optional[Dict] = None,\n _retry: bool = False,\n) -> Dict[str, Any]:\n \"\"\"Search Reddit for relevant threads using OpenAI Responses API.\n\n Args:\n api_key: OpenAI API key\n model: Model to use\n topic: Search topic\n from_date: Start date (YYYY-MM-DD) - only include threads after this\n to_date: End date (YYYY-MM-DD) - only include threads before this\n depth: Research depth - \"quick\", \"default\", or \"deep\"\n mock_response: Mock response for testing\n\n Returns:\n Raw API response\n \"\"\"\n if mock_response is not None:\n return mock_response\n\n min_items, max_items = DEPTH_CONFIG.get(depth, DEPTH_CONFIG[\"default\"])\n\n headers = {\n \"Authorization\": f\"Bearer {api_key}\",\n \"Content-Type\": \"application/json\",\n }\n\n # Adjust timeout based on depth (generous for OpenAI web_search which can be slow)\n timeout = 90 if depth == \"quick\" else 120 if depth == \"default\" else 180\n\n # Note: allowed_domains accepts base domain, not subdomains\n # We rely on prompt to filter out developers.reddit.com, etc.\n payload = {\n \"model\": model,\n \"tools\": [\n {\n \"type\": \"web_search\",\n \"filters\": {\n \"allowed_domains\": [\"reddit.com\"]\n }\n }\n ],\n \"include\": [\"web_search_call.action.sources\"],\n \"input\": REDDIT_SEARCH_PROMPT.format(\n topic=topic,\n from_date=from_date,\n to_date=to_date,\n min_items=min_items,\n max_items=max_items,\n ),\n }\n\n return http.post(OPENAI_RESPONSES_URL, payload, headers=headers, timeout=timeout)\n\n\ndef parse_reddit_response(response: Dict[str, Any]) -> List[Dict[str, Any]]:\n \"\"\"Parse OpenAI response to extract Reddit items.\n\n Args:\n response: Raw API response\n\n Returns:\n List of item dicts\n \"\"\"\n items = []\n\n # Check for API errors first\n if \"error\" in response and response[\"error\"]:\n error = response[\"error\"]\n err_msg = error.get(\"message\", str(error)) if isinstance(error, dict) else str(error)\n _log_error(f\"OpenAI API error: {err_msg}\")\n if http.DEBUG:\n _log_error(f\"Full error response: {json.dumps(response, indent=2)[:1000]}\")\n return items\n\n # Try to find the output text\n output_text = \"\"\n if \"output\" in response:\n output = response[\"output\"]\n if isinstance(output, str):\n output_text = output\n elif isinstance(output, list):\n for item in output:\n if isinstance(item, dict):\n if item.get(\"type\") == \"message\":\n content = item.get(\"content\", [])\n for c in content:\n if isinstance(c, dict) and c.get(\"type\") == \"output_text\":\n output_text = c.get(\"text\", \"\")\n break\n elif \"text\" in item:\n output_text = item[\"text\"]\n elif isinstance(item, str):\n output_text = item\n if output_text:\n break\n\n # Also check for choices (older format)\n if not output_text and \"choices\" in response:\n for choice in response[\"choices\"]:\n if \"message\" in choice:\n output_text = choice[\"message\"].get(\"content\", \"\")\n break\n\n if not output_text:\n print(f\"[REDDIT WARNING] No output text found in OpenAI response. Keys present: {list(response.keys())}\", flush=True)\n return items\n\n # Extract JSON from the response\n json_match = re.search(r'\\{[\\s\\S]*\"items\"[\\s\\S]*\\}', output_text)\n if json_match:\n try:\n data = json.loads(json_match.group())\n items = data.get(\"items\", [])\n except json.JSONDecodeError:\n pass\n\n # Validate and clean items\n clean_items = []\n for i, item in enumerate(items):\n if not isinstance(item, dict):\n continue\n\n url = item.get(\"url\", \"\")\n if not url or \"reddit.com\" not in url:\n continue\n\n clean_item = {\n \"id\": f\"R{i+1}\",\n \"title\": str(item.get(\"title\", \"\")).strip(),\n \"url\": url,\n \"subreddit\": str(item.get(\"subreddit\", \"\")).strip().lstrip(\"r/\"),\n \"date\": item.get(\"date\"),\n \"why_relevant\": str(item.get(\"why_relevant\", \"\")).strip(),\n \"relevance\": min(1.0, max(0.0, float(item.get(\"relevance\", 0.5)))),\n }\n\n # Validate date format\n if clean_item[\"date\"]:\n if not re.match(r'^\\d{4}-\\d{2}-\\d{2}

last30days: Research Any Topic from the Last 30 Days Research ANY topic across Reddit, X, and the web. Surface what people are actually discussing, recommending, and debating right now. Use cases: - Prompting : "photorealistic people in Nano Banana Pro", "Midjourney prompts", "ChatGPT image generation" → learn techniques, get copy-paste prompts - Recommendations : "best Claude Code skills", "top AI tools" → get a LIST of specific things people mention - News : "what's happening with OpenAI", "latest AI announcements" → current events and updates - General : any topic you're curious about → un…

, str(clean_item[\"date\"])):\n clean_item[\"date\"] = None\n\n clean_items.append(clean_item)\n\n return clean_items\n","content_type":"text/x-python; charset=utf-8","language":"python","size":7328,"content_sha256":"0a643152be88e070bd3db37a38703888491c4307e239ca1fc5a3c72b5315d80e"},{"filename":"scripts/lib/reddit_enrich.py","content":"\"\"\"Reddit thread enrichment with real engagement metrics.\"\"\"\n\nimport re\nfrom typing import Any, Dict, List, Optional\nfrom urllib.parse import urlparse\n\nfrom . import http, dates\n\n\ndef extract_reddit_path(url: str) -> Optional[str]:\n \"\"\"Extract the path from a Reddit URL.\n\n Args:\n url: Reddit URL\n\n Returns:\n Path component or None\n \"\"\"\n try:\n parsed = urlparse(url)\n if \"reddit.com\" not in parsed.netloc:\n return None\n return parsed.path\n except:\n return None\n\n\ndef fetch_thread_data(url: str, mock_data: Optional[Dict] = None) -> Optional[Dict[str, Any]]:\n \"\"\"Fetch Reddit thread JSON data.\n\n Args:\n url: Reddit thread URL\n mock_data: Mock data for testing\n\n Returns:\n Thread data dict or None on failure\n \"\"\"\n if mock_data is not None:\n return mock_data\n\n path = extract_reddit_path(url)\n if not path:\n return None\n\n try:\n data = http.get_reddit_json(path)\n return data\n except http.HTTPError:\n return None\n\n\ndef parse_thread_data(data: Any) -> Dict[str, Any]:\n \"\"\"Parse Reddit thread JSON into structured data.\n\n Args:\n data: Raw Reddit JSON response\n\n Returns:\n Dict with submission and comments data\n \"\"\"\n result = {\n \"submission\": None,\n \"comments\": [],\n }\n\n if not isinstance(data, list) or len(data) \u003c 1:\n return result\n\n # First element is submission listing\n submission_listing = data[0]\n if isinstance(submission_listing, dict):\n children = submission_listing.get(\"data\", {}).get(\"children\", [])\n if children:\n sub_data = children[0].get(\"data\", {})\n result[\"submission\"] = {\n \"score\": sub_data.get(\"score\"),\n \"num_comments\": sub_data.get(\"num_comments\"),\n \"upvote_ratio\": sub_data.get(\"upvote_ratio\"),\n \"created_utc\": sub_data.get(\"created_utc\"),\n \"permalink\": sub_data.get(\"permalink\"),\n \"title\": sub_data.get(\"title\"),\n \"selftext\": sub_data.get(\"selftext\", \"\")[:500], # Truncate\n }\n\n # Second element is comments listing\n if len(data) >= 2:\n comments_listing = data[1]\n if isinstance(comments_listing, dict):\n children = comments_listing.get(\"data\", {}).get(\"children\", [])\n for child in children:\n if child.get(\"kind\") != \"t1\": # t1 = comment\n continue\n c_data = child.get(\"data\", {})\n if not c_data.get(\"body\"):\n continue\n\n comment = {\n \"score\": c_data.get(\"score\", 0),\n \"created_utc\": c_data.get(\"created_utc\"),\n \"author\": c_data.get(\"author\", \"[deleted]\"),\n \"body\": c_data.get(\"body\", \"\")[:300], # Truncate\n \"permalink\": c_data.get(\"permalink\"),\n }\n result[\"comments\"].append(comment)\n\n return result\n\n\ndef get_top_comments(comments: List[Dict], limit: int = 10) -> List[Dict[str, Any]]:\n \"\"\"Get top comments sorted by score.\n\n Args:\n comments: List of comment dicts\n limit: Maximum number to return\n\n Returns:\n Top comments sorted by score\n \"\"\"\n # Filter out deleted/removed\n valid = [c for c in comments if c.get(\"author\") not in (\"[deleted]\", \"[removed]\")]\n\n # Sort by score descending\n sorted_comments = sorted(valid, key=lambda c: c.get(\"score\", 0), reverse=True)\n\n return sorted_comments[:limit]\n\n\ndef extract_comment_insights(comments: List[Dict], limit: int = 7) -> List[str]:\n \"\"\"Extract key insights from top comments.\n\n Uses simple heuristics to identify valuable comments:\n - Has substantive text\n - Contains actionable information\n - Not just agreement/disagreement\n\n Args:\n comments: Top comments\n limit: Max insights to extract\n\n Returns:\n List of insight strings\n \"\"\"\n insights = []\n\n for comment in comments[:limit * 2]: # Look at more comments than we need\n body = comment.get(\"body\", \"\").strip()\n if not body or len(body) \u003c 30:\n continue\n\n # Skip low-value patterns\n skip_patterns = [\n r'^(this|same|agreed|exactly|yep|nope|yes|no|thanks|thank you)\\.?

last30days: Research Any Topic from the Last 30 Days Research ANY topic across Reddit, X, and the web. Surface what people are actually discussing, recommending, and debating right now. Use cases: - Prompting : "photorealistic people in Nano Banana Pro", "Midjourney prompts", "ChatGPT image generation" → learn techniques, get copy-paste prompts - Recommendations : "best Claude Code skills", "top AI tools" → get a LIST of specific things people mention - News : "what's happening with OpenAI", "latest AI announcements" → current events and updates - General : any topic you're curious about → un…

,\n r'^lol|lmao|haha',\n r'^\\[deleted\\]',\n r'^\\[removed\\]',\n ]\n if any(re.match(p, body.lower()) for p in skip_patterns):\n continue\n\n # Truncate to first meaningful sentence or ~150 chars\n insight = body[:150]\n if len(body) > 150:\n # Try to find a sentence boundary\n for i, char in enumerate(insight):\n if char in '.!?' and i > 50:\n insight = insight[:i+1]\n break\n else:\n insight = insight.rstrip() + \"...\"\n\n insights.append(insight)\n if len(insights) >= limit:\n break\n\n return insights\n\n\ndef enrich_reddit_item(\n item: Dict[str, Any],\n mock_thread_data: Optional[Dict] = None,\n) -> Dict[str, Any]:\n \"\"\"Enrich a Reddit item with real engagement data.\n\n Args:\n item: Reddit item dict\n mock_thread_data: Mock data for testing\n\n Returns:\n Enriched item dict\n \"\"\"\n url = item.get(\"url\", \"\")\n\n # Fetch thread data\n thread_data = fetch_thread_data(url, mock_thread_data)\n if not thread_data:\n return item\n\n parsed = parse_thread_data(thread_data)\n submission = parsed.get(\"submission\")\n comments = parsed.get(\"comments\", [])\n\n # Update engagement metrics\n if submission:\n item[\"engagement\"] = {\n \"score\": submission.get(\"score\"),\n \"num_comments\": submission.get(\"num_comments\"),\n \"upvote_ratio\": submission.get(\"upvote_ratio\"),\n }\n\n # Update date from actual data\n created_utc = submission.get(\"created_utc\")\n if created_utc:\n item[\"date\"] = dates.timestamp_to_date(created_utc)\n\n # Get top comments\n top_comments = get_top_comments(comments)\n item[\"top_comments\"] = []\n for c in top_comments:\n permalink = c.get(\"permalink\", \"\")\n comment_url = f\"https://reddit.com{permalink}\" if permalink else \"\"\n item[\"top_comments\"].append({\n \"score\": c.get(\"score\", 0),\n \"date\": dates.timestamp_to_date(c.get(\"created_utc\")),\n \"author\": c.get(\"author\", \"\"),\n \"excerpt\": c.get(\"body\", \"\")[:200],\n \"url\": comment_url,\n })\n\n # Extract insights\n item[\"comment_insights\"] = extract_comment_insights(top_comments)\n\n return item\n","content_type":"text/x-python; charset=utf-8","language":"python","size":6750,"content_sha256":"f77e6666b3ed3fe701090b6f050a48d863a63348b678f18c2113a8bce9f40c4c"},{"filename":"scripts/lib/render.py","content":"\"\"\"Output rendering for last30days skill.\"\"\"\n\nimport json\nfrom pathlib import Path\nfrom typing import List, Optional\n\nfrom . import schema\n\nOUTPUT_DIR = Path.home() / \".local\" / \"share\" / \"last30days\" / \"out\"\n\n\ndef ensure_output_dir():\n \"\"\"Ensure output directory exists.\"\"\"\n OUTPUT_DIR.mkdir(parents=True, exist_ok=True)\n\n\ndef _assess_data_freshness(report: schema.Report) -> dict:\n \"\"\"Assess how much data is actually from the last 30 days.\"\"\"\n reddit_recent = sum(1 for r in report.reddit if r.date and r.date >= report.range_from)\n x_recent = sum(1 for x in report.x if x.date and x.date >= report.range_from)\n web_recent = sum(1 for w in report.web if w.date and w.date >= report.range_from)\n\n total_recent = reddit_recent + x_recent + web_recent\n total_items = len(report.reddit) + len(report.x) + len(report.web)\n\n return {\n \"reddit_recent\": reddit_recent,\n \"x_recent\": x_recent,\n \"web_recent\": web_recent,\n \"total_recent\": total_recent,\n \"total_items\": total_items,\n \"is_sparse\": total_recent \u003c 5,\n \"mostly_evergreen\": total_items > 0 and total_recent \u003c total_items * 0.3,\n }\n\n\ndef render_compact(report: schema.Report, limit: int = 15, missing_keys: str = \"none\") -> str:\n \"\"\"Render compact output for Claude to synthesize.\n\n Args:\n report: Report data\n limit: Max items per source\n missing_keys: 'both', 'reddit', 'x', or 'none'\n\n Returns:\n Compact markdown string\n \"\"\"\n lines = []\n\n # Header\n lines.append(f\"## Research Results: {report.topic}\")\n lines.append(\"\")\n\n # Assess data freshness and add honesty warning if needed\n freshness = _assess_data_freshness(report)\n if freshness[\"is_sparse\"]:\n lines.append(\"**⚠️ LIMITED RECENT DATA** - Few discussions from the last 30 days.\")\n lines.append(f\"Only {freshness['total_recent']} item(s) confirmed from {report.range_from} to {report.range_to}.\")\n lines.append(\"Results below may include older/evergreen content. Be transparent with the user about this.\")\n lines.append(\"\")\n\n # Web-only mode banner (when no API keys)\n if report.mode == \"web-only\":\n lines.append(\"**🌐 WEB SEARCH MODE** - Claude will search blogs, docs & news\")\n lines.append(\"\")\n lines.append(\"---\")\n lines.append(\"**⚡ Want better results?** Add API keys to unlock Reddit & X data:\")\n lines.append(\"- `OPENAI_API_KEY` → Reddit threads with real upvotes & comments\")\n lines.append(\"- `XAI_API_KEY` → X posts with real likes & reposts\")\n lines.append(\"- Edit `~/.config/last30days/.env` to add keys\")\n lines.append(\"---\")\n lines.append(\"\")\n\n # Cache indicator\n if report.from_cache:\n age_str = f\"{report.cache_age_hours:.1f}h old\" if report.cache_age_hours else \"cached\"\n lines.append(f\"**⚡ CACHED RESULTS** ({age_str}) - use `--refresh` for fresh data\")\n lines.append(\"\")\n\n lines.append(f\"**Date Range:** {report.range_from} to {report.range_to}\")\n lines.append(f\"**Mode:** {report.mode}\")\n if report.openai_model_used:\n lines.append(f\"**OpenAI Model:** {report.openai_model_used}\")\n if report.xai_model_used:\n lines.append(f\"**xAI Model:** {report.xai_model_used}\")\n lines.append(\"\")\n\n # Coverage note for partial coverage\n if report.mode == \"reddit-only\" and missing_keys == \"x\":\n lines.append(\"*💡 Tip: Add XAI_API_KEY for X/Twitter data and better triangulation.*\")\n lines.append(\"\")\n elif report.mode == \"x-only\" and missing_keys == \"reddit\":\n lines.append(\"*💡 Tip: Add OPENAI_API_KEY for Reddit data and better triangulation.*\")\n lines.append(\"\")\n\n # Reddit items\n if report.reddit_error:\n lines.append(\"### Reddit Threads\")\n lines.append(\"\")\n lines.append(f\"**ERROR:** {report.reddit_error}\")\n lines.append(\"\")\n elif report.mode in (\"both\", \"reddit-only\") and not report.reddit:\n lines.append(\"### Reddit Threads\")\n lines.append(\"\")\n lines.append(\"*No relevant Reddit threads found for this topic.*\")\n lines.append(\"\")\n elif report.reddit:\n lines.append(\"### Reddit Threads\")\n lines.append(\"\")\n for item in report.reddit[:limit]:\n eng_str = \"\"\n if item.engagement:\n eng = item.engagement\n parts = []\n if eng.score is not None:\n parts.append(f\"{eng.score}pts\")\n if eng.num_comments is not None:\n parts.append(f\"{eng.num_comments}cmt\")\n if parts:\n eng_str = f\" [{', '.join(parts)}]\"\n\n date_str = f\" ({item.date})\" if item.date else \" (date unknown)\"\n conf_str = f\" [date:{item.date_confidence}]\" if item.date_confidence != \"high\" else \"\"\n\n lines.append(f\"**{item.id}** (score:{item.score}) r/{item.subreddit}{date_str}{conf_str}{eng_str}\")\n lines.append(f\" {item.title}\")\n lines.append(f\" {item.url}\")\n lines.append(f\" *{item.why_relevant}*\")\n\n # Top comment insights\n if item.comment_insights:\n lines.append(f\" Insights:\")\n for insight in item.comment_insights[:3]:\n lines.append(f\" - {insight}\")\n\n lines.append(\"\")\n\n # X items\n if report.x_error:\n lines.append(\"### X Posts\")\n lines.append(\"\")\n lines.append(f\"**ERROR:** {report.x_error}\")\n lines.append(\"\")\n elif report.mode in (\"both\", \"x-only\", \"all\", \"x-web\") and not report.x:\n lines.append(\"### X Posts\")\n lines.append(\"\")\n lines.append(\"*No relevant X posts found for this topic.*\")\n lines.append(\"\")\n elif report.x:\n lines.append(\"### X Posts\")\n lines.append(\"\")\n for item in report.x[:limit]:\n eng_str = \"\"\n if item.engagement:\n eng = item.engagement\n parts = []\n if eng.likes is not None:\n parts.append(f\"{eng.likes}likes\")\n if eng.reposts is not None:\n parts.append(f\"{eng.reposts}rt\")\n if parts:\n eng_str = f\" [{', '.join(parts)}]\"\n\n date_str = f\" ({item.date})\" if item.date else \" (date unknown)\"\n conf_str = f\" [date:{item.date_confidence}]\" if item.date_confidence != \"high\" else \"\"\n\n lines.append(f\"**{item.id}** (score:{item.score}) @{item.author_handle}{date_str}{conf_str}{eng_str}\")\n lines.append(f\" {item.text[:200]}...\")\n lines.append(f\" {item.url}\")\n lines.append(f\" *{item.why_relevant}*\")\n lines.append(\"\")\n\n # Web items (if any - populated by Claude)\n if report.web_error:\n lines.append(\"### Web Results\")\n lines.append(\"\")\n lines.append(f\"**ERROR:** {report.web_error}\")\n lines.append(\"\")\n elif report.web:\n lines.append(\"### Web Results\")\n lines.append(\"\")\n for item in report.web[:limit]:\n date_str = f\" ({item.date})\" if item.date else \" (date unknown)\"\n conf_str = f\" [date:{item.date_confidence}]\" if item.date_confidence != \"high\" else \"\"\n\n lines.append(f\"**{item.id}** [WEB] (score:{item.score}) {item.source_domain}{date_str}{conf_str}\")\n lines.append(f\" {item.title}\")\n lines.append(f\" {item.url}\")\n lines.append(f\" {item.snippet[:150]}...\")\n lines.append(f\" *{item.why_relevant}*\")\n lines.append(\"\")\n\n return \"\\n\".join(lines)\n\n\ndef render_context_snippet(report: schema.Report) -> str:\n \"\"\"Render reusable context snippet.\n\n Args:\n report: Report data\n\n Returns:\n Context markdown string\n \"\"\"\n lines = []\n lines.append(f\"# Context: {report.topic} (Last 30 Days)\")\n lines.append(\"\")\n lines.append(f\"*Generated: {report.generated_at[:10]} | Sources: {report.mode}*\")\n lines.append(\"\")\n\n # Key sources summary\n lines.append(\"## Key Sources\")\n lines.append(\"\")\n\n all_items = []\n for item in report.reddit[:5]:\n all_items.append((item.score, \"Reddit\", item.title, item.url))\n for item in report.x[:5]:\n all_items.append((item.score, \"X\", item.text[:50] + \"...\", item.url))\n for item in report.web[:5]:\n all_items.append((item.score, \"Web\", item.title[:50] + \"...\", item.url))\n\n all_items.sort(key=lambda x: -x[0])\n for score, source, text, url in all_items[:7]:\n lines.append(f\"- [{source}] {text}\")\n\n lines.append(\"\")\n lines.append(\"## Summary\")\n lines.append(\"\")\n lines.append(\"*See full report for best practices, prompt pack, and detailed sources.*\")\n lines.append(\"\")\n\n return \"\\n\".join(lines)\n\n\ndef render_full_report(report: schema.Report) -> str:\n \"\"\"Render full markdown report.\n\n Args:\n report: Report data\n\n Returns:\n Full report markdown\n \"\"\"\n lines = []\n\n # Title\n lines.append(f\"# {report.topic} - Last 30 Days Research Report\")\n lines.append(\"\")\n lines.append(f\"**Generated:** {report.generated_at}\")\n lines.append(f\"**Date Range:** {report.range_from} to {report.range_to}\")\n lines.append(f\"**Mode:** {report.mode}\")\n lines.append(\"\")\n\n # Models\n lines.append(\"## Models Used\")\n lines.append(\"\")\n if report.openai_model_used:\n lines.append(f\"- **OpenAI:** {report.openai_model_used}\")\n if report.xai_model_used:\n lines.append(f\"- **xAI:** {report.xai_model_used}\")\n lines.append(\"\")\n\n # Reddit section\n if report.reddit:\n lines.append(\"## Reddit Threads\")\n lines.append(\"\")\n for item in report.reddit:\n lines.append(f\"### {item.id}: {item.title}\")\n lines.append(\"\")\n lines.append(f\"- **Subreddit:** r/{item.subreddit}\")\n lines.append(f\"- **URL:** {item.url}\")\n lines.append(f\"- **Date:** {item.date or 'Unknown'} (confidence: {item.date_confidence})\")\n lines.append(f\"- **Score:** {item.score}/100\")\n lines.append(f\"- **Relevance:** {item.why_relevant}\")\n\n if item.engagement:\n eng = item.engagement\n lines.append(f\"- **Engagement:** {eng.score or '?'} points, {eng.num_comments or '?'} comments\")\n\n if item.comment_insights:\n lines.append(\"\")\n lines.append(\"**Key Insights from Comments:**\")\n for insight in item.comment_insights:\n lines.append(f\"- {insight}\")\n\n lines.append(\"\")\n\n # X section\n if report.x:\n lines.append(\"## X Posts\")\n lines.append(\"\")\n for item in report.x:\n lines.append(f\"### {item.id}: @{item.author_handle}\")\n lines.append(\"\")\n lines.append(f\"- **URL:** {item.url}\")\n lines.append(f\"- **Date:** {item.date or 'Unknown'} (confidence: {item.date_confidence})\")\n lines.append(f\"- **Score:** {item.score}/100\")\n lines.append(f\"- **Relevance:** {item.why_relevant}\")\n\n if item.engagement:\n eng = item.engagement\n lines.append(f\"- **Engagement:** {eng.likes or '?'} likes, {eng.reposts or '?'} reposts\")\n\n lines.append(\"\")\n lines.append(f\"> {item.text}\")\n lines.append(\"\")\n\n # Web section\n if report.web:\n lines.append(\"## Web Results\")\n lines.append(\"\")\n for item in report.web:\n lines.append(f\"### {item.id}: {item.title}\")\n lines.append(\"\")\n lines.append(f\"- **Source:** {item.source_domain}\")\n lines.append(f\"- **URL:** {item.url}\")\n lines.append(f\"- **Date:** {item.date or 'Unknown'} (confidence: {item.date_confidence})\")\n lines.append(f\"- **Score:** {item.score}/100\")\n lines.append(f\"- **Relevance:** {item.why_relevant}\")\n lines.append(\"\")\n lines.append(f\"> {item.snippet}\")\n lines.append(\"\")\n\n # Placeholders for Claude synthesis\n lines.append(\"## Best Practices\")\n lines.append(\"\")\n lines.append(\"*To be synthesized by Claude*\")\n lines.append(\"\")\n\n lines.append(\"## Prompt Pack\")\n lines.append(\"\")\n lines.append(\"*To be synthesized by Claude*\")\n lines.append(\"\")\n\n return \"\\n\".join(lines)\n\n\ndef write_outputs(\n report: schema.Report,\n raw_openai: Optional[dict] = None,\n raw_xai: Optional[dict] = None,\n raw_reddit_enriched: Optional[list] = None,\n):\n \"\"\"Write all output files.\n\n Args:\n report: Report data\n raw_openai: Raw OpenAI API response\n raw_xai: Raw xAI API response\n raw_reddit_enriched: Raw enriched Reddit thread data\n \"\"\"\n ensure_output_dir()\n\n # report.json\n with open(OUTPUT_DIR / \"report.json\", 'w') as f:\n json.dump(report.to_dict(), f, indent=2)\n\n # report.md\n with open(OUTPUT_DIR / \"report.md\", 'w') as f:\n f.write(render_full_report(report))\n\n # last30days.context.md\n with open(OUTPUT_DIR / \"last30days.context.md\", 'w') as f:\n f.write(render_context_snippet(report))\n\n # Raw responses\n if raw_openai:\n with open(OUTPUT_DIR / \"raw_openai.json\", 'w') as f:\n json.dump(raw_openai, f, indent=2)\n\n if raw_xai:\n with open(OUTPUT_DIR / \"raw_xai.json\", 'w') as f:\n json.dump(raw_xai, f, indent=2)\n\n if raw_reddit_enriched:\n with open(OUTPUT_DIR / \"raw_reddit_threads_enriched.json\", 'w') as f:\n json.dump(raw_reddit_enriched, f, indent=2)\n\n\ndef get_context_path() -> str:\n \"\"\"Get path to context file.\"\"\"\n return str(OUTPUT_DIR / \"last30days.context.md\")\n","content_type":"text/x-python; charset=utf-8","language":"python","size":13781,"content_sha256":"166cbc714e64da7bb2c1932e9eacef51a90b2b90b9d0179cd22048674d4fd07c"},{"filename":"scripts/lib/schema.py","content":"\"\"\"Data schemas for last30days skill.\"\"\"\n\nfrom dataclasses import dataclass, field, asdict\nfrom typing import Any, Dict, List, Optional\nfrom datetime import datetime, timezone\n\n\n@dataclass\nclass Engagement:\n \"\"\"Engagement metrics.\"\"\"\n # Reddit fields\n score: Optional[int] = None\n num_comments: Optional[int] = None\n upvote_ratio: Optional[float] = None\n\n # X fields\n likes: Optional[int] = None\n reposts: Optional[int] = None\n replies: Optional[int] = None\n quotes: Optional[int] = None\n\n def to_dict(self) -> Dict[str, Any]:\n d = {}\n if self.score is not None:\n d['score'] = self.score\n if self.num_comments is not None:\n d['num_comments'] = self.num_comments\n if self.upvote_ratio is not None:\n d['upvote_ratio'] = self.upvote_ratio\n if self.likes is not None:\n d['likes'] = self.likes\n if self.reposts is not None:\n d['reposts'] = self.reposts\n if self.replies is not None:\n d['replies'] = self.replies\n if self.quotes is not None:\n d['quotes'] = self.quotes\n return d if d else None\n\n\n@dataclass\nclass Comment:\n \"\"\"Reddit comment.\"\"\"\n score: int\n date: Optional[str]\n author: str\n excerpt: str\n url: str\n\n def to_dict(self) -> Dict[str, Any]:\n return {\n 'score': self.score,\n 'date': self.date,\n 'author': self.author,\n 'excerpt': self.excerpt,\n 'url': self.url,\n }\n\n\n@dataclass\nclass SubScores:\n \"\"\"Component scores.\"\"\"\n relevance: int = 0\n recency: int = 0\n engagement: int = 0\n\n def to_dict(self) -> Dict[str, int]:\n return {\n 'relevance': self.relevance,\n 'recency': self.recency,\n 'engagement': self.engagement,\n }\n\n\n@dataclass\nclass RedditItem:\n \"\"\"Normalized Reddit item.\"\"\"\n id: str\n title: str\n url: str\n subreddit: str\n date: Optional[str] = None\n date_confidence: str = \"low\"\n engagement: Optional[Engagement] = None\n top_comments: List[Comment] = field(default_factory=list)\n comment_insights: List[str] = field(default_factory=list)\n relevance: float = 0.5\n why_relevant: str = \"\"\n subs: SubScores = field(default_factory=SubScores)\n score: int = 0\n\n def to_dict(self) -> Dict[str, Any]:\n return {\n 'id': self.id,\n 'title': self.title,\n 'url': self.url,\n 'subreddit': self.subreddit,\n 'date': self.date,\n 'date_confidence': self.date_confidence,\n 'engagement': self.engagement.to_dict() if self.engagement else None,\n 'top_comments': [c.to_dict() for c in self.top_comments],\n 'comment_insights': self.comment_insights,\n 'relevance': self.relevance,\n 'why_relevant': self.why_relevant,\n 'subs': self.subs.to_dict(),\n 'score': self.score,\n }\n\n\n@dataclass\nclass XItem:\n \"\"\"Normalized X item.\"\"\"\n id: str\n text: str\n url: str\n author_handle: str\n date: Optional[str] = None\n date_confidence: str = \"low\"\n engagement: Optional[Engagement] = None\n relevance: float = 0.5\n why_relevant: str = \"\"\n subs: SubScores = field(default_factory=SubScores)\n score: int = 0\n\n def to_dict(self) -> Dict[str, Any]:\n return {\n 'id': self.id,\n 'text': self.text,\n 'url': self.url,\n 'author_handle': self.author_handle,\n 'date': self.date,\n 'date_confidence': self.date_confidence,\n 'engagement': self.engagement.to_dict() if self.engagement else None,\n 'relevance': self.relevance,\n 'why_relevant': self.why_relevant,\n 'subs': self.subs.to_dict(),\n 'score': self.score,\n }\n\n\n@dataclass\nclass WebSearchItem:\n \"\"\"Normalized web search item (no engagement metrics).\"\"\"\n id: str\n title: str\n url: str\n source_domain: str # e.g., \"medium.com\", \"github.com\"\n snippet: str\n date: Optional[str] = None\n date_confidence: str = \"low\"\n relevance: float = 0.5\n why_relevant: str = \"\"\n subs: SubScores = field(default_factory=SubScores)\n score: int = 0\n\n def to_dict(self) -> Dict[str, Any]:\n return {\n 'id': self.id,\n 'title': self.title,\n 'url': self.url,\n 'source_domain': self.source_domain,\n 'snippet': self.snippet,\n 'date': self.date,\n 'date_confidence': self.date_confidence,\n 'relevance': self.relevance,\n 'why_relevant': self.why_relevant,\n 'subs': self.subs.to_dict(),\n 'score': self.score,\n }\n\n\n@dataclass\nclass Report:\n \"\"\"Full research report.\"\"\"\n topic: str\n range_from: str\n range_to: str\n generated_at: str\n mode: str # 'reddit-only', 'x-only', 'both', 'web-only', etc.\n openai_model_used: Optional[str] = None\n xai_model_used: Optional[str] = None\n reddit: List[RedditItem] = field(default_factory=list)\n x: List[XItem] = field(default_factory=list)\n web: List[WebSearchItem] = field(default_factory=list)\n best_practices: List[str] = field(default_factory=list)\n prompt_pack: List[str] = field(default_factory=list)\n context_snippet_md: str = \"\"\n # Status tracking\n reddit_error: Optional[str] = None\n x_error: Optional[str] = None\n web_error: Optional[str] = None\n # Cache info\n from_cache: bool = False\n cache_age_hours: Optional[float] = None\n\n def to_dict(self) -> Dict[str, Any]:\n d = {\n 'topic': self.topic,\n 'range': {\n 'from': self.range_from,\n 'to': self.range_to,\n },\n 'generated_at': self.generated_at,\n 'mode': self.mode,\n 'openai_model_used': self.openai_model_used,\n 'xai_model_used': self.xai_model_used,\n 'reddit': [r.to_dict() for r in self.reddit],\n 'x': [x.to_dict() for x in self.x],\n 'web': [w.to_dict() for w in self.web],\n 'best_practices': self.best_practices,\n 'prompt_pack': self.prompt_pack,\n 'context_snippet_md': self.context_snippet_md,\n }\n if self.reddit_error:\n d['reddit_error'] = self.reddit_error\n if self.x_error:\n d['x_error'] = self.x_error\n if self.web_error:\n d['web_error'] = self.web_error\n if self.from_cache:\n d['from_cache'] = self.from_cache\n if self.cache_age_hours is not None:\n d['cache_age_hours'] = self.cache_age_hours\n return d\n\n @classmethod\n def from_dict(cls, data: Dict[str, Any]) -> \"Report\":\n \"\"\"Create Report from serialized dict (handles cache format).\"\"\"\n # Handle range field conversion\n range_data = data.get('range', {})\n range_from = range_data.get('from', data.get('range_from', ''))\n range_to = range_data.get('to', data.get('range_to', ''))\n\n # Reconstruct Reddit items\n reddit_items = []\n for r in data.get('reddit', []):\n eng = None\n if r.get('engagement'):\n eng = Engagement(**r['engagement'])\n comments = [Comment(**c) for c in r.get('top_comments', [])]\n subs = SubScores(**r.get('subs', {})) if r.get('subs') else SubScores()\n reddit_items.append(RedditItem(\n id=r['id'],\n title=r['title'],\n url=r['url'],\n subreddit=r['subreddit'],\n date=r.get('date'),\n date_confidence=r.get('date_confidence', 'low'),\n engagement=eng,\n top_comments=comments,\n comment_insights=r.get('comment_insights', []),\n relevance=r.get('relevance', 0.5),\n why_relevant=r.get('why_relevant', ''),\n subs=subs,\n score=r.get('score', 0),\n ))\n\n # Reconstruct X items\n x_items = []\n for x in data.get('x', []):\n eng = None\n if x.get('engagement'):\n eng = Engagement(**x['engagement'])\n subs = SubScores(**x.get('subs', {})) if x.get('subs') else SubScores()\n x_items.append(XItem(\n id=x['id'],\n text=x['text'],\n url=x['url'],\n author_handle=x['author_handle'],\n date=x.get('date'),\n date_confidence=x.get('date_confidence', 'low'),\n engagement=eng,\n relevance=x.get('relevance', 0.5),\n why_relevant=x.get('why_relevant', ''),\n subs=subs,\n score=x.get('score', 0),\n ))\n\n # Reconstruct Web items\n web_items = []\n for w in data.get('web', []):\n subs = SubScores(**w.get('subs', {})) if w.get('subs') else SubScores()\n web_items.append(WebSearchItem(\n id=w['id'],\n title=w['title'],\n url=w['url'],\n source_domain=w.get('source_domain', ''),\n snippet=w.get('snippet', ''),\n date=w.get('date'),\n date_confidence=w.get('date_confidence', 'low'),\n relevance=w.get('relevance', 0.5),\n why_relevant=w.get('why_relevant', ''),\n subs=subs,\n score=w.get('score', 0),\n ))\n\n return cls(\n topic=data['topic'],\n range_from=range_from,\n range_to=range_to,\n generated_at=data['generated_at'],\n mode=data['mode'],\n openai_model_used=data.get('openai_model_used'),\n xai_model_used=data.get('xai_model_used'),\n reddit=reddit_items,\n x=x_items,\n web=web_items,\n best_practices=data.get('best_practices', []),\n prompt_pack=data.get('prompt_pack', []),\n context_snippet_md=data.get('context_snippet_md', ''),\n reddit_error=data.get('reddit_error'),\n x_error=data.get('x_error'),\n web_error=data.get('web_error'),\n from_cache=data.get('from_cache', False),\n cache_age_hours=data.get('cache_age_hours'),\n )\n\n\ndef create_report(\n topic: str,\n from_date: str,\n to_date: str,\n mode: str,\n openai_model: Optional[str] = None,\n xai_model: Optional[str] = None,\n) -> Report:\n \"\"\"Create a new report with metadata.\"\"\"\n return Report(\n topic=topic,\n range_from=from_date,\n range_to=to_date,\n generated_at=datetime.now(timezone.utc).isoformat(),\n mode=mode,\n openai_model_used=openai_model,\n xai_model_used=xai_model,\n )\n","content_type":"text/x-python; charset=utf-8","language":"python","size":10861,"content_sha256":"3532ba4ef6c7b1d80a85acecb691401687ef4e5d905e2ef5683eb756d8149f8e"},{"filename":"scripts/lib/score.py","content":"\"\"\"Popularity-aware scoring for last30days skill.\"\"\"\n\nimport math\nfrom typing import List, Optional, Union\n\nfrom . import dates, schema\n\n# Score weights for Reddit/X (has engagement)\nWEIGHT_RELEVANCE = 0.45\nWEIGHT_RECENCY = 0.25\nWEIGHT_ENGAGEMENT = 0.30\n\n# WebSearch weights (no engagement, reweighted to 100%)\nWEBSEARCH_WEIGHT_RELEVANCE = 0.55\nWEBSEARCH_WEIGHT_RECENCY = 0.45\nWEBSEARCH_SOURCE_PENALTY = 15 # Points deducted for lacking engagement\n\n# WebSearch date confidence adjustments\nWEBSEARCH_VERIFIED_BONUS = 10 # Bonus for URL-verified recent date (high confidence)\nWEBSEARCH_NO_DATE_PENALTY = 20 # Heavy penalty for no date signals (low confidence)\n\n# Default engagement score for unknown\nDEFAULT_ENGAGEMENT = 35\nUNKNOWN_ENGAGEMENT_PENALTY = 10\n\n\ndef log1p_safe(x: Optional[int]) -> float:\n \"\"\"Safe log1p that handles None and negative values.\"\"\"\n if x is None or x \u003c 0:\n return 0.0\n return math.log1p(x)\n\n\ndef compute_reddit_engagement_raw(engagement: Optional[schema.Engagement]) -> Optional[float]:\n \"\"\"Compute raw engagement score for Reddit item.\n\n Formula: 0.55*log1p(score) + 0.40*log1p(num_comments) + 0.05*(upvote_ratio*10)\n \"\"\"\n if engagement is None:\n return None\n\n if engagement.score is None and engagement.num_comments is None:\n return None\n\n score = log1p_safe(engagement.score)\n comments = log1p_safe(engagement.num_comments)\n ratio = (engagement.upvote_ratio or 0.5) * 10\n\n return 0.55 * score + 0.40 * comments + 0.05 * ratio\n\n\ndef compute_x_engagement_raw(engagement: Optional[schema.Engagement]) -> Optional[float]:\n \"\"\"Compute raw engagement score for X item.\n\n Formula: 0.55*log1p(likes) + 0.25*log1p(reposts) + 0.15*log1p(replies) + 0.05*log1p(quotes)\n \"\"\"\n if engagement is None:\n return None\n\n if engagement.likes is None and engagement.reposts is None:\n return None\n\n likes = log1p_safe(engagement.likes)\n reposts = log1p_safe(engagement.reposts)\n replies = log1p_safe(engagement.replies)\n quotes = log1p_safe(engagement.quotes)\n\n return 0.55 * likes + 0.25 * reposts + 0.15 * replies + 0.05 * quotes\n\n\ndef normalize_to_100(values: List[float], default: float = 50) -> List[float]:\n \"\"\"Normalize a list of values to 0-100 scale.\n\n Args:\n values: Raw values (None values are preserved)\n default: Default value for None entries\n\n Returns:\n Normalized values\n \"\"\"\n # Filter out None\n valid = [v for v in values if v is not None]\n if not valid:\n return [default if v is None else 50 for v in values]\n\n min_val = min(valid)\n max_val = max(valid)\n range_val = max_val - min_val\n\n if range_val == 0:\n return [50 if v is None else 50 for v in values]\n\n result = []\n for v in values:\n if v is None:\n result.append(None)\n else:\n normalized = ((v - min_val) / range_val) * 100\n result.append(normalized)\n\n return result\n\n\ndef score_reddit_items(items: List[schema.RedditItem]) -> List[schema.RedditItem]:\n \"\"\"Compute scores for Reddit items.\n\n Args:\n items: List of Reddit items\n\n Returns:\n Items with updated scores\n \"\"\"\n if not items:\n return items\n\n # Compute raw engagement scores\n eng_raw = [compute_reddit_engagement_raw(item.engagement) for item in items]\n\n # Normalize engagement to 0-100\n eng_normalized = normalize_to_100(eng_raw)\n\n for i, item in enumerate(items):\n # Relevance subscore (model-provided, convert to 0-100)\n rel_score = int(item.relevance * 100)\n\n # Recency subscore\n rec_score = dates.recency_score(item.date)\n\n # Engagement subscore\n if eng_normalized[i] is not None:\n eng_score = int(eng_normalized[i])\n else:\n eng_score = DEFAULT_ENGAGEMENT\n\n # Store subscores\n item.subs = schema.SubScores(\n relevance=rel_score,\n recency=rec_score,\n engagement=eng_score,\n )\n\n # Compute overall score\n overall = (\n WEIGHT_RELEVANCE * rel_score +\n WEIGHT_RECENCY * rec_score +\n WEIGHT_ENGAGEMENT * eng_score\n )\n\n # Apply penalty for unknown engagement\n if eng_raw[i] is None:\n overall -= UNKNOWN_ENGAGEMENT_PENALTY\n\n # Apply penalty for low date confidence\n if item.date_confidence == \"low\":\n overall -= 10\n elif item.date_confidence == \"med\":\n overall -= 5\n\n item.score = max(0, min(100, int(overall)))\n\n return items\n\n\ndef score_x_items(items: List[schema.XItem]) -> List[schema.XItem]:\n \"\"\"Compute scores for X items.\n\n Args:\n items: List of X items\n\n Returns:\n Items with updated scores\n \"\"\"\n if not items:\n return items\n\n # Compute raw engagement scores\n eng_raw = [compute_x_engagement_raw(item.engagement) for item in items]\n\n # Normalize engagement to 0-100\n eng_normalized = normalize_to_100(eng_raw)\n\n for i, item in enumerate(items):\n # Relevance subscore (model-provided, convert to 0-100)\n rel_score = int(item.relevance * 100)\n\n # Recency subscore\n rec_score = dates.recency_score(item.date)\n\n # Engagement subscore\n if eng_normalized[i] is not None:\n eng_score = int(eng_normalized[i])\n else:\n eng_score = DEFAULT_ENGAGEMENT\n\n # Store subscores\n item.subs = schema.SubScores(\n relevance=rel_score,\n recency=rec_score,\n engagement=eng_score,\n )\n\n # Compute overall score\n overall = (\n WEIGHT_RELEVANCE * rel_score +\n WEIGHT_RECENCY * rec_score +\n WEIGHT_ENGAGEMENT * eng_score\n )\n\n # Apply penalty for unknown engagement\n if eng_raw[i] is None:\n overall -= UNKNOWN_ENGAGEMENT_PENALTY\n\n # Apply penalty for low date confidence\n if item.date_confidence == \"low\":\n overall -= 10\n elif item.date_confidence == \"med\":\n overall -= 5\n\n item.score = max(0, min(100, int(overall)))\n\n return items\n\n\ndef score_websearch_items(items: List[schema.WebSearchItem]) -> List[schema.WebSearchItem]:\n \"\"\"Compute scores for WebSearch items WITHOUT engagement metrics.\n\n Uses reweighted formula: 55% relevance + 45% recency - 15pt source penalty.\n This ensures WebSearch items rank below comparable Reddit/X items.\n\n Date confidence adjustments:\n - High confidence (URL-verified date): +10 bonus\n - Med confidence (snippet-extracted date): no change\n - Low confidence (no date signals): -20 penalty\n\n Args:\n items: List of WebSearch items\n\n Returns:\n Items with updated scores\n \"\"\"\n if not items:\n return items\n\n for item in items:\n # Relevance subscore (model-provided, convert to 0-100)\n rel_score = int(item.relevance * 100)\n\n # Recency subscore\n rec_score = dates.recency_score(item.date)\n\n # Store subscores (engagement is 0 for WebSearch - no data)\n item.subs = schema.SubScores(\n relevance=rel_score,\n recency=rec_score,\n engagement=0, # Explicitly zero - no engagement data available\n )\n\n # Compute overall score using WebSearch weights\n overall = (\n WEBSEARCH_WEIGHT_RELEVANCE * rel_score +\n WEBSEARCH_WEIGHT_RECENCY * rec_score\n )\n\n # Apply source penalty (WebSearch \u003c Reddit/X for same relevance/recency)\n overall -= WEBSEARCH_SOURCE_PENALTY\n\n # Apply date confidence adjustments\n # High confidence (URL-verified): reward with bonus\n # Med confidence (snippet-extracted): neutral\n # Low confidence (no date signals): heavy penalty\n if item.date_confidence == \"high\":\n overall += WEBSEARCH_VERIFIED_BONUS # Reward verified recent dates\n elif item.date_confidence == \"low\":\n overall -= WEBSEARCH_NO_DATE_PENALTY # Heavy penalty for unknown\n\n item.score = max(0, min(100, int(overall)))\n\n return items\n\n\ndef sort_items(items: List[Union[schema.RedditItem, schema.XItem, schema.WebSearchItem]]) -> List:\n \"\"\"Sort items by score (descending), then date, then source priority.\n\n Args:\n items: List of items to sort\n\n Returns:\n Sorted items\n \"\"\"\n def sort_key(item):\n # Primary: score descending (negate for descending)\n score = -item.score\n\n # Secondary: date descending (recent first)\n date = item.date or \"0000-00-00\"\n date_key = -int(date.replace(\"-\", \"\"))\n\n # Tertiary: source priority (Reddit > X > WebSearch)\n if isinstance(item, schema.RedditItem):\n source_priority = 0\n elif isinstance(item, schema.XItem):\n source_priority = 1\n else: # WebSearchItem\n source_priority = 2\n\n # Quaternary: title/text for stability\n text = getattr(item, \"title\", \"\") or getattr(item, \"text\", \"\")\n\n return (score, date_key, source_priority, text)\n\n return sorted(items, key=sort_key)\n","content_type":"text/x-python; charset=utf-8","language":"python","size":9177,"content_sha256":"b969c7176bfec8e4723da57f7d51348ec7b2c8bbec65c7cf9e6e48c0b0495651"},{"filename":"scripts/lib/ui.py","content":"\"\"\"Terminal UI utilities for last30days skill.\"\"\"\n\nimport os\nimport sys\nimport time\nimport threading\nimport random\nfrom typing import Optional\n\n# Check if we're in a real terminal (not captured by Claude Code)\nIS_TTY = sys.stderr.isatty()\n\n# ANSI color codes\nclass Colors:\n PURPLE = '\\033[95m'\n BLUE = '\\033[94m'\n CYAN = '\\033[96m'\n GREEN = '\\033[92m'\n YELLOW = '\\033[93m'\n RED = '\\033[91m'\n BOLD = '\\033[1m'\n DIM = '\\033[2m'\n RESET = '\\033[0m'\n\n\nBANNER = f\"\"\"{Colors.PURPLE}{Colors.BOLD}\n ██╗ █████╗ ███████╗████████╗██████╗ ██████╗ ██████╗ █████╗ ██╗ ██╗███████╗\n ██║ ██╔══██╗██╔════╝╚══██╔══╝╚════██╗██╔═████╗██╔══██╗██╔══██╗╚██╗ ██╔╝██╔════╝\n ██║ ███████║███████╗ ██║ █████╔╝██║██╔██║██║ ██║███████║ ╚████╔╝ ███████╗\n ██║ ██╔══██║╚════██║ ██║ ╚═══██╗████╔╝██║██║ ██║██╔══██║ ╚██╔╝ ╚════██║\n ███████╗██║ ██║███████║ ██║ ██████╔╝╚██████╔╝██████╔╝██║ ██║ ██║ ███████║\n ╚══════╝╚═╝ ╚═╝╚══════╝ ╚═╝ ╚═════╝ ╚═════╝ ╚═════╝ ╚═╝ ╚═╝ ╚═╝ ╚══════╝\n{Colors.RESET}{Colors.DIM} 30 days of research. 30 seconds of work.{Colors.RESET}\n\"\"\"\n\nMINI_BANNER = f\"\"\"{Colors.PURPLE}{Colors.BOLD}/last30days{Colors.RESET} {Colors.DIM}· researching...{Colors.RESET}\"\"\"\n\n# Fun status messages for each phase\nREDDIT_MESSAGES = [\n \"Diving into Reddit threads...\",\n \"Scanning subreddits for gold...\",\n \"Reading what Redditors are saying...\",\n \"Exploring the front page of the internet...\",\n \"Finding the good discussions...\",\n \"Upvoting mentally...\",\n \"Scrolling through comments...\",\n]\n\nX_MESSAGES = [\n \"Checking what X is buzzing about...\",\n \"Reading the timeline...\",\n \"Finding the hot takes...\",\n \"Scanning tweets and threads...\",\n \"Discovering trending insights...\",\n \"Following the conversation...\",\n \"Reading between the posts...\",\n]\n\nENRICHING_MESSAGES = [\n \"Getting the juicy details...\",\n \"Fetching engagement metrics...\",\n \"Reading top comments...\",\n \"Extracting insights...\",\n \"Analyzing discussions...\",\n]\n\nPROCESSING_MESSAGES = [\n \"Crunching the data...\",\n \"Scoring and ranking...\",\n \"Finding patterns...\",\n \"Removing duplicates...\",\n \"Organizing findings...\",\n]\n\nWEB_ONLY_MESSAGES = [\n \"Searching the web...\",\n \"Finding blogs and docs...\",\n \"Crawling news sites...\",\n \"Discovering tutorials...\",\n]\n\n# Promo message for users without API keys\nPROMO_MESSAGE = f\"\"\"\n{Colors.YELLOW}{Colors.BOLD}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━{Colors.RESET}\n{Colors.YELLOW}⚡ UNLOCK THE FULL POWER OF /last30days{Colors.RESET}\n\n{Colors.DIM}Right now you're using web search only. Add API keys to unlock:{Colors.RESET}\n\n {Colors.YELLOW}🟠 Reddit{Colors.RESET} - Real upvotes, comments, and community insights\n └─ Add OPENAI_API_KEY (uses OpenAI's web_search for Reddit)\n\n {Colors.CYAN}🔵 X (Twitter){Colors.RESET} - Real-time posts, likes, reposts from creators\n └─ Add XAI_API_KEY (uses xAI's live X search)\n\n{Colors.DIM}Setup:{Colors.RESET} Edit {Colors.BOLD}~/.config/last30days/.env{Colors.RESET}\n{Colors.YELLOW}{Colors.BOLD}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━{Colors.RESET}\n\"\"\"\n\nPROMO_MESSAGE_PLAIN = \"\"\"\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n⚡ UNLOCK THE FULL POWER OF /last30days\n\nRight now you're using web search only. Add API keys to unlock:\n\n 🟠 Reddit - Real upvotes, comments, and community insights\n └─ Add OPENAI_API_KEY (uses OpenAI's web_search for Reddit)\n\n 🔵 X (Twitter) - Real-time posts, likes, reposts from creators\n └─ Add XAI_API_KEY (uses xAI's live X search)\n\nSetup: Edit ~/.config/last30days/.env\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n\"\"\"\n\n# Shorter promo for single missing key\nPROMO_SINGLE_KEY = {\n \"reddit\": f\"\"\"\n{Colors.DIM}💡 Tip: Add {Colors.YELLOW}OPENAI_API_KEY{Colors.RESET}{Colors.DIM} to ~/.config/last30days/.env for Reddit data with real engagement metrics!{Colors.RESET}\n\"\"\",\n \"x\": f\"\"\"\n{Colors.DIM}💡 Tip: Add {Colors.CYAN}XAI_API_KEY{Colors.RESET}{Colors.DIM} to ~/.config/last30days/.env for X/Twitter data with real likes & reposts!{Colors.RESET}\n\"\"\",\n}\n\nPROMO_SINGLE_KEY_PLAIN = {\n \"reddit\": \"\\n💡 Tip: Add OPENAI_API_KEY to ~/.config/last30days/.env for Reddit data with real engagement metrics!\\n\",\n \"x\": \"\\n💡 Tip: Add XAI_API_KEY to ~/.config/last30days/.env for X/Twitter data with real likes & reposts!\\n\",\n}\n\n# Spinner frames\nSPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']\nDOTS_FRAMES = [' ', '. ', '.. ', '...']\n\n\nclass Spinner:\n \"\"\"Animated spinner for long-running operations.\"\"\"\n\n def __init__(self, message: str = \"Working\", color: str = Colors.CYAN):\n self.message = message\n self.color = color\n self.running = False\n self.thread: Optional[threading.Thread] = None\n self.frame_idx = 0\n self.shown_static = False\n\n def _spin(self):\n while self.running:\n frame = SPINNER_FRAMES[self.frame_idx % len(SPINNER_FRAMES)]\n sys.stderr.write(f\"\\r{self.color}{frame}{Colors.RESET} {self.message} \")\n sys.stderr.flush()\n self.frame_idx += 1\n time.sleep(0.08)\n\n def start(self):\n self.running = True\n if IS_TTY:\n # Real terminal - animate\n self.thread = threading.Thread(target=self._spin, daemon=True)\n self.thread.start()\n else:\n # Not a TTY (Claude Code) - just print once\n if not self.shown_static:\n sys.stderr.write(f\"⏳ {self.message}\\n\")\n sys.stderr.flush()\n self.shown_static = True\n\n def update(self, message: str):\n self.message = message\n if not IS_TTY and not self.shown_static:\n # Print update in non-TTY mode\n sys.stderr.write(f\"⏳ {message}\\n\")\n sys.stderr.flush()\n\n def stop(self, final_message: str = \"\"):\n self.running = False\n if self.thread:\n self.thread.join(timeout=0.2)\n if IS_TTY:\n # Clear the line in real terminal\n sys.stderr.write(\"\\r\" + \" \" * 80 + \"\\r\")\n if final_message:\n sys.stderr.write(f\"✓ {final_message}\\n\")\n sys.stderr.flush()\n\n\nclass ProgressDisplay:\n \"\"\"Progress display for research phases.\"\"\"\n\n def __init__(self, topic: str, show_banner: bool = True):\n self.topic = topic\n self.spinner: Optional[Spinner] = None\n self.start_time = time.time()\n\n if show_banner:\n self._show_banner()\n\n def _show_banner(self):\n if IS_TTY:\n sys.stderr.write(MINI_BANNER + \"\\n\")\n sys.stderr.write(f\"{Colors.DIM}Topic: {Colors.RESET}{Colors.BOLD}{self.topic}{Colors.RESET}\\n\\n\")\n else:\n # Simple text for non-TTY\n sys.stderr.write(f\"/last30days · researching: {self.topic}\\n\")\n sys.stderr.flush()\n\n def start_reddit(self):\n msg = random.choice(REDDIT_MESSAGES)\n self.spinner = Spinner(f\"{Colors.YELLOW}Reddit{Colors.RESET} {msg}\", Colors.YELLOW)\n self.spinner.start()\n\n def end_reddit(self, count: int):\n if self.spinner:\n self.spinner.stop(f\"{Colors.YELLOW}Reddit{Colors.RESET} Found {count} threads\")\n\n def start_reddit_enrich(self, current: int, total: int):\n if self.spinner:\n self.spinner.stop()\n msg = random.choice(ENRICHING_MESSAGES)\n self.spinner = Spinner(f\"{Colors.YELLOW}Reddit{Colors.RESET} [{current}/{total}] {msg}\", Colors.YELLOW)\n self.spinner.start()\n\n def update_reddit_enrich(self, current: int, total: int):\n if self.spinner:\n msg = random.choice(ENRICHING_MESSAGES)\n self.spinner.update(f\"{Colors.YELLOW}Reddit{Colors.RESET} [{current}/{total}] {msg}\")\n\n def end_reddit_enrich(self):\n if self.spinner:\n self.spinner.stop(f\"{Colors.YELLOW}Reddit{Colors.RESET} Enriched with engagement data\")\n\n def start_x(self):\n msg = random.choice(X_MESSAGES)\n self.spinner = Spinner(f\"{Colors.CYAN}X{Colors.RESET} {msg}\", Colors.CYAN)\n self.spinner.start()\n\n def end_x(self, count: int):\n if self.spinner:\n self.spinner.stop(f\"{Colors.CYAN}X{Colors.RESET} Found {count} posts\")\n\n def start_processing(self):\n msg = random.choice(PROCESSING_MESSAGES)\n self.spinner = Spinner(f\"{Colors.PURPLE}Processing{Colors.RESET} {msg}\", Colors.PURPLE)\n self.spinner.start()\n\n def end_processing(self):\n if self.spinner:\n self.spinner.stop()\n\n def show_complete(self, reddit_count: int, x_count: int):\n elapsed = time.time() - self.start_time\n if IS_TTY:\n sys.stderr.write(f\"\\n{Colors.GREEN}{Colors.BOLD}✓ Research complete{Colors.RESET} \")\n sys.stderr.write(f\"{Colors.DIM}({elapsed:.1f}s){Colors.RESET}\\n\")\n sys.stderr.write(f\" {Colors.YELLOW}Reddit:{Colors.RESET} {reddit_count} threads \")\n sys.stderr.write(f\"{Colors.CYAN}X:{Colors.RESET} {x_count} posts\\n\\n\")\n else:\n sys.stderr.write(f\"✓ Research complete ({elapsed:.1f}s) - Reddit: {reddit_count} threads, X: {x_count} posts\\n\")\n sys.stderr.flush()\n\n def show_cached(self, age_hours: float = None):\n if age_hours is not None:\n age_str = f\" ({age_hours:.1f}h old)\"\n else:\n age_str = \"\"\n sys.stderr.write(f\"{Colors.GREEN}⚡{Colors.RESET} {Colors.DIM}Using cached results{age_str} - use --refresh for fresh data{Colors.RESET}\\n\\n\")\n sys.stderr.flush()\n\n def show_error(self, message: str):\n sys.stderr.write(f\"{Colors.RED}✗ Error:{Colors.RESET} {message}\\n\")\n sys.stderr.flush()\n\n def start_web_only(self):\n \"\"\"Show web-only mode indicator.\"\"\"\n msg = random.choice(WEB_ONLY_MESSAGES)\n self.spinner = Spinner(f\"{Colors.GREEN}Web{Colors.RESET} {msg}\", Colors.GREEN)\n self.spinner.start()\n\n def end_web_only(self):\n \"\"\"End web-only spinner.\"\"\"\n if self.spinner:\n self.spinner.stop(f\"{Colors.GREEN}Web{Colors.RESET} Claude will search the web\")\n\n def show_web_only_complete(self):\n \"\"\"Show completion for web-only mode.\"\"\"\n elapsed = time.time() - self.start_time\n if IS_TTY:\n sys.stderr.write(f\"\\n{Colors.GREEN}{Colors.BOLD}✓ Ready for web search{Colors.RESET} \")\n sys.stderr.write(f\"{Colors.DIM}({elapsed:.1f}s){Colors.RESET}\\n\")\n sys.stderr.write(f\" {Colors.GREEN}Web:{Colors.RESET} Claude will search blogs, docs & news\\n\\n\")\n else:\n sys.stderr.write(f\"✓ Ready for web search ({elapsed:.1f}s)\\n\")\n sys.stderr.flush()\n\n def show_promo(self, missing: str = \"both\"):\n \"\"\"Show promotional message for missing API keys.\n\n Args:\n missing: 'both', 'reddit', or 'x' - which keys are missing\n \"\"\"\n if missing == \"both\":\n if IS_TTY:\n sys.stderr.write(PROMO_MESSAGE)\n else:\n sys.stderr.write(PROMO_MESSAGE_PLAIN)\n elif missing in PROMO_SINGLE_KEY:\n if IS_TTY:\n sys.stderr.write(PROMO_SINGLE_KEY[missing])\n else:\n sys.stderr.write(PROMO_SINGLE_KEY_PLAIN[missing])\n sys.stderr.flush()\n\n\ndef print_phase(phase: str, message: str):\n \"\"\"Print a phase message.\"\"\"\n colors = {\n \"reddit\": Colors.YELLOW,\n \"x\": Colors.CYAN,\n \"process\": Colors.PURPLE,\n \"done\": Colors.GREEN,\n \"error\": Colors.RED,\n }\n color = colors.get(phase, Colors.RESET)\n sys.stderr.write(f\"{color}▸{Colors.RESET} {message}\\n\")\n sys.stderr.flush()\n","content_type":"text/x-python; charset=utf-8","language":"python","size":13268,"content_sha256":"275fc9d1c16e5fbf6f0a27915042c4a8d9b1af1cd7f07f39b64f460ea9987935"},{"filename":"scripts/lib/websearch.py","content":"\"\"\"WebSearch module for last30days skill.\n\nNOTE: WebSearch uses Claude's built-in WebSearch tool, which runs INSIDE Claude Code.\nUnlike Reddit/X which use external APIs, WebSearch results are obtained by Claude\ndirectly and passed to this module for normalization and scoring.\n\nThe typical flow is:\n1. Claude invokes WebSearch tool with the topic\n2. Claude passes results to parse_websearch_results()\n3. Results are normalized into WebSearchItem objects\n\"\"\"\n\nimport re\nfrom datetime import datetime, timedelta\nfrom typing import Any, Dict, List, Optional, Tuple\nfrom urllib.parse import urlparse\n\nfrom . import schema\n\n\n# Month name mappings for date parsing\nMONTH_MAP = {\n \"jan\": 1, \"january\": 1,\n \"feb\": 2, \"february\": 2,\n \"mar\": 3, \"march\": 3,\n \"apr\": 4, \"april\": 4,\n \"may\": 5,\n \"jun\": 6, \"june\": 6,\n \"jul\": 7, \"july\": 7,\n \"aug\": 8, \"august\": 8,\n \"sep\": 9, \"sept\": 9, \"september\": 9,\n \"oct\": 10, \"october\": 10,\n \"nov\": 11, \"november\": 11,\n \"dec\": 12, \"december\": 12,\n}\n\n\ndef extract_date_from_url(url: str) -> Optional[str]:\n \"\"\"Try to extract a date from URL path.\n\n Many sites embed dates in URLs like:\n - /2026/01/24/article-title\n - /2026-01-24/article\n - /blog/20260124/title\n\n Args:\n url: URL to parse\n\n Returns:\n Date string in YYYY-MM-DD format, or None\n \"\"\"\n # Pattern 1: /YYYY/MM/DD/ (most common)\n match = re.search(r'/(\\d{4})/(\\d{2})/(\\d{2})/', url)\n if match:\n year, month, day = match.groups()\n if 2020 \u003c= int(year) \u003c= 2030 and 1 \u003c= int(month) \u003c= 12 and 1 \u003c= int(day) \u003c= 31:\n return f\"{year}-{month}-{day}\"\n\n # Pattern 2: /YYYY-MM-DD/ or /YYYY-MM-DD-\n match = re.search(r'/(\\d{4})-(\\d{2})-(\\d{2})[-/]', url)\n if match:\n year, month, day = match.groups()\n if 2020 \u003c= int(year) \u003c= 2030 and 1 \u003c= int(month) \u003c= 12 and 1 \u003c= int(day) \u003c= 31:\n return f\"{year}-{month}-{day}\"\n\n # Pattern 3: /YYYYMMDD/ (compact)\n match = re.search(r'/(\\d{4})(\\d{2})(\\d{2})/', url)\n if match:\n year, month, day = match.groups()\n if 2020 \u003c= int(year) \u003c= 2030 and 1 \u003c= int(month) \u003c= 12 and 1 \u003c= int(day) \u003c= 31:\n return f\"{year}-{month}-{day}\"\n\n return None\n\n\ndef extract_date_from_snippet(text: str) -> Optional[str]:\n \"\"\"Try to extract a date from text snippet or title.\n\n Looks for patterns like:\n - January 24, 2026 or Jan 24, 2026\n - 24 January 2026\n - 2026-01-24\n - \"3 days ago\", \"yesterday\", \"last week\"\n\n Args:\n text: Text to parse\n\n Returns:\n Date string in YYYY-MM-DD format, or None\n \"\"\"\n if not text:\n return None\n\n text_lower = text.lower()\n\n # Pattern 1: Month DD, YYYY (e.g., \"January 24, 2026\")\n match = re.search(\n r'\\b(jan(?:uary)?|feb(?:ruary)?|mar(?:ch)?|apr(?:il)?|may|jun(?:e)?|'\n r'jul(?:y)?|aug(?:ust)?|sep(?:t(?:ember)?)?|oct(?:ober)?|nov(?:ember)?|dec(?:ember)?)'\n r'\\s+(\\d{1,2})(?:st|nd|rd|th)?,?\\s*(\\d{4})\\b',\n text_lower\n )\n if match:\n month_str, day, year = match.groups()\n month = MONTH_MAP.get(month_str[:3])\n if month and 2020 \u003c= int(year) \u003c= 2030 and 1 \u003c= int(day) \u003c= 31:\n return f\"{year}-{month:02d}-{int(day):02d}\"\n\n # Pattern 2: DD Month YYYY (e.g., \"24 January 2026\")\n match = re.search(\n r'\\b(\\d{1,2})(?:st|nd|rd|th)?\\s+'\n r'(jan(?:uary)?|feb(?:ruary)?|mar(?:ch)?|apr(?:il)?|may|jun(?:e)?|'\n r'jul(?:y)?|aug(?:ust)?|sep(?:t(?:ember)?)?|oct(?:ober)?|nov(?:ember)?|dec(?:ember)?)'\n r'\\s+(\\d{4})\\b',\n text_lower\n )\n if match:\n day, month_str, year = match.groups()\n month = MONTH_MAP.get(month_str[:3])\n if month and 2020 \u003c= int(year) \u003c= 2030 and 1 \u003c= int(day) \u003c= 31:\n return f\"{year}-{month:02d}-{int(day):02d}\"\n\n # Pattern 3: YYYY-MM-DD (ISO format)\n match = re.search(r'\\b(\\d{4})-(\\d{2})-(\\d{2})\\b', text)\n if match:\n year, month, day = match.groups()\n if 2020 \u003c= int(year) \u003c= 2030 and 1 \u003c= int(month) \u003c= 12 and 1 \u003c= int(day) \u003c= 31:\n return f\"{year}-{month}-{day}\"\n\n # Pattern 4: Relative dates (\"3 days ago\", \"yesterday\", etc.)\n today = datetime.now()\n\n if \"yesterday\" in text_lower:\n date = today - timedelta(days=1)\n return date.strftime(\"%Y-%m-%d\")\n\n if \"today\" in text_lower:\n return today.strftime(\"%Y-%m-%d\")\n\n # \"N days ago\"\n match = re.search(r'\\b(\\d+)\\s*days?\\s*ago\\b', text_lower)\n if match:\n days = int(match.group(1))\n if days \u003c= 60: # Reasonable range\n date = today - timedelta(days=days)\n return date.strftime(\"%Y-%m-%d\")\n\n # \"N hours ago\" -> today\n match = re.search(r'\\b(\\d+)\\s*hours?\\s*ago\\b', text_lower)\n if match:\n return today.strftime(\"%Y-%m-%d\")\n\n # \"last week\" -> ~7 days ago\n if \"last week\" in text_lower:\n date = today - timedelta(days=7)\n return date.strftime(\"%Y-%m-%d\")\n\n # \"this week\" -> ~3 days ago (middle of week)\n if \"this week\" in text_lower:\n date = today - timedelta(days=3)\n return date.strftime(\"%Y-%m-%d\")\n\n return None\n\n\ndef extract_date_signals(\n url: str,\n snippet: str,\n title: str,\n) -> Tuple[Optional[str], str]:\n \"\"\"Extract date from any available signal.\n\n Tries URL first (most reliable), then snippet, then title.\n\n Args:\n url: Page URL\n snippet: Page snippet/description\n title: Page title\n\n Returns:\n Tuple of (date_string, confidence)\n - date from URL: 'high' confidence\n - date from snippet/title: 'med' confidence\n - no date found: None, 'low' confidence\n \"\"\"\n # Try URL first (most reliable)\n url_date = extract_date_from_url(url)\n if url_date:\n return url_date, \"high\"\n\n # Try snippet\n snippet_date = extract_date_from_snippet(snippet)\n if snippet_date:\n return snippet_date, \"med\"\n\n # Try title\n title_date = extract_date_from_snippet(title)\n if title_date:\n return title_date, \"med\"\n\n return None, \"low\"\n\n\n# Domains to exclude (Reddit and X are handled separately)\nEXCLUDED_DOMAINS = {\n \"reddit.com\",\n \"www.reddit.com\",\n \"old.reddit.com\",\n \"twitter.com\",\n \"www.twitter.com\",\n \"x.com\",\n \"www.x.com\",\n \"mobile.twitter.com\",\n}\n\n\ndef extract_domain(url: str) -> str:\n \"\"\"Extract the domain from a URL.\n\n Args:\n url: Full URL\n\n Returns:\n Domain string (e.g., \"medium.com\")\n \"\"\"\n try:\n parsed = urlparse(url)\n domain = parsed.netloc.lower()\n # Remove www. prefix for cleaner display\n if domain.startswith(\"www.\"):\n domain = domain[4:]\n return domain\n except Exception:\n return \"\"\n\n\ndef is_excluded_domain(url: str) -> bool:\n \"\"\"Check if URL is from an excluded domain (Reddit/X).\n\n Args:\n url: URL to check\n\n Returns:\n True if URL should be excluded\n \"\"\"\n try:\n parsed = urlparse(url)\n domain = parsed.netloc.lower()\n return domain in EXCLUDED_DOMAINS\n except Exception:\n return False\n\n\ndef parse_websearch_results(\n results: List[Dict[str, Any]],\n topic: str,\n from_date: str = \"\",\n to_date: str = \"\",\n) -> List[Dict[str, Any]]:\n \"\"\"Parse WebSearch results into normalized format.\n\n This function expects results from Claude's WebSearch tool.\n Each result should have: title, url, snippet, and optionally date/relevance.\n\n Uses \"Date Detective\" approach:\n 1. Extract dates from URLs (high confidence)\n 2. Extract dates from snippets/titles (med confidence)\n 3. Hard filter: exclude items with verified old dates\n 4. Keep items with no date signals (with low confidence penalty)\n\n Args:\n results: List of WebSearch result dicts\n topic: Original search topic (for context)\n from_date: Start date for filtering (YYYY-MM-DD)\n to_date: End date for filtering (YYYY-MM-DD)\n\n Returns:\n List of normalized item dicts ready for WebSearchItem creation\n \"\"\"\n items = []\n\n for i, result in enumerate(results):\n if not isinstance(result, dict):\n continue\n\n url = result.get(\"url\", \"\")\n if not url:\n continue\n\n # Skip Reddit/X URLs (handled separately)\n if is_excluded_domain(url):\n continue\n\n title = str(result.get(\"title\", \"\")).strip()\n snippet = str(result.get(\"snippet\", result.get(\"description\", \"\"))).strip()\n\n if not title and not snippet:\n continue\n\n # Use Date Detective to extract date signals\n date = result.get(\"date\") # Use provided date if available\n date_confidence = \"low\"\n\n if date and re.match(r'^\\d{4}-\\d{2}-\\d{2}

last30days: Research Any Topic from the Last 30 Days Research ANY topic across Reddit, X, and the web. Surface what people are actually discussing, recommending, and debating right now. Use cases: - Prompting : "photorealistic people in Nano Banana Pro", "Midjourney prompts", "ChatGPT image generation" → learn techniques, get copy-paste prompts - Recommendations : "best Claude Code skills", "top AI tools" → get a LIST of specific things people mention - News : "what's happening with OpenAI", "latest AI announcements" → current events and updates - General : any topic you're curious about → un…

, str(date)):\n # Provided date is valid\n date_confidence = \"med\"\n else:\n # Try to extract date from URL/snippet/title\n extracted_date, confidence = extract_date_signals(url, snippet, title)\n if extracted_date:\n date = extracted_date\n date_confidence = confidence\n\n # Hard filter: if we found a date and it's too old, skip\n if date and from_date and date \u003c from_date:\n continue # DROP - verified old content\n\n # Hard filter: if date is in the future, skip (parsing error)\n if date and to_date and date > to_date:\n continue # DROP - future date\n\n # Get relevance if provided, default to 0.5\n relevance = result.get(\"relevance\", 0.5)\n try:\n relevance = min(1.0, max(0.0, float(relevance)))\n except (TypeError, ValueError):\n relevance = 0.5\n\n item = {\n \"id\": f\"W{i+1}\",\n \"title\": title[:200], # Truncate long titles\n \"url\": url,\n \"source_domain\": extract_domain(url),\n \"snippet\": snippet[:500], # Truncate long snippets\n \"date\": date,\n \"date_confidence\": date_confidence,\n \"relevance\": relevance,\n \"why_relevant\": str(result.get(\"why_relevant\", \"\")).strip(),\n }\n\n items.append(item)\n\n return items\n\n\ndef normalize_websearch_items(\n items: List[Dict[str, Any]],\n from_date: str,\n to_date: str,\n) -> List[schema.WebSearchItem]:\n \"\"\"Convert parsed dicts to WebSearchItem objects.\n\n Args:\n items: List of parsed item dicts\n from_date: Start of date range (YYYY-MM-DD)\n to_date: End of date range (YYYY-MM-DD)\n\n Returns:\n List of WebSearchItem objects\n \"\"\"\n result = []\n\n for item in items:\n web_item = schema.WebSearchItem(\n id=item[\"id\"],\n title=item[\"title\"],\n url=item[\"url\"],\n source_domain=item[\"source_domain\"],\n snippet=item[\"snippet\"],\n date=item.get(\"date\"),\n date_confidence=item.get(\"date_confidence\", \"low\"),\n relevance=item.get(\"relevance\", 0.5),\n why_relevant=item.get(\"why_relevant\", \"\"),\n )\n result.append(web_item)\n\n return result\n\n\ndef dedupe_websearch(items: List[schema.WebSearchItem]) -> List[schema.WebSearchItem]:\n \"\"\"Remove duplicate WebSearch items.\n\n Deduplication is based on URL.\n\n Args:\n items: List of WebSearchItem objects\n\n Returns:\n Deduplicated list\n \"\"\"\n seen_urls = set()\n result = []\n\n for item in items:\n # Normalize URL for comparison\n url_key = item.url.lower().rstrip(\"/\")\n if url_key not in seen_urls:\n seen_urls.add(url_key)\n result.append(item)\n\n return result\n","content_type":"text/x-python; charset=utf-8","language":"python","size":11644,"content_sha256":"752f4238c5052d19a405e15c347237f718f7bac52de7f2a84a728edf0905796c"},{"filename":"scripts/lib/xai_x.py","content":"\"\"\"xAI API client for X (Twitter) discovery.\"\"\"\n\nimport json\nimport re\nimport sys\nfrom typing import Any, Dict, List, Optional\n\nfrom . import http\n\n\ndef _log_error(msg: str):\n \"\"\"Log error to stderr.\"\"\"\n sys.stderr.write(f\"[X ERROR] {msg}\\n\")\n sys.stderr.flush()\n\n# xAI uses responses endpoint with Agent Tools API\nXAI_RESPONSES_URL = \"https://api.x.ai/v1/responses\"\n\n# Depth configurations: (min, max) posts to request\nDEPTH_CONFIG = {\n \"quick\": (8, 12),\n \"default\": (20, 30),\n \"deep\": (40, 60),\n}\n\nX_SEARCH_PROMPT = \"\"\"You have access to real-time X (Twitter) data. Search for posts about: {topic}\n\nFocus on posts from {from_date} to {to_date}. Find {min_items}-{max_items} high-quality, relevant posts.\n\nIMPORTANT: Return ONLY valid JSON in this exact format, no other text:\n{{\n \"items\": [\n {{\n \"text\": \"Post text content (truncated if long)\",\n \"url\": \"https://x.com/user/status/...\",\n \"author_handle\": \"username\",\n \"date\": \"YYYY-MM-DD or null if unknown\",\n \"engagement\": {{\n \"likes\": 100,\n \"reposts\": 25,\n \"replies\": 15,\n \"quotes\": 5\n }},\n \"why_relevant\": \"Brief explanation of relevance\",\n \"relevance\": 0.85\n }}\n ]\n}}\n\nRules:\n- relevance is 0.0 to 1.0 (1.0 = highly relevant)\n- date must be YYYY-MM-DD format or null\n- engagement can be null if unknown\n- Include diverse voices/accounts if applicable\n- Prefer posts with substantive content, not just links\"\"\"\n\n\ndef search_x(\n api_key: str,\n model: str,\n topic: str,\n from_date: str,\n to_date: str,\n depth: str = \"default\",\n mock_response: Optional[Dict] = None,\n) -> Dict[str, Any]:\n \"\"\"Search X for relevant posts using xAI API with live search.\n\n Args:\n api_key: xAI API key\n model: Model to use\n topic: Search topic\n from_date: Start date (YYYY-MM-DD)\n to_date: End date (YYYY-MM-DD)\n depth: Research depth - \"quick\", \"default\", or \"deep\"\n mock_response: Mock response for testing\n\n Returns:\n Raw API response\n \"\"\"\n if mock_response is not None:\n return mock_response\n\n min_items, max_items = DEPTH_CONFIG.get(depth, DEPTH_CONFIG[\"default\"])\n\n headers = {\n \"Authorization\": f\"Bearer {api_key}\",\n \"Content-Type\": \"application/json\",\n }\n\n # Adjust timeout based on depth (generous for API response time)\n timeout = 90 if depth == \"quick\" else 120 if depth == \"default\" else 180\n\n # Use Agent Tools API with x_search tool\n payload = {\n \"model\": model,\n \"tools\": [\n {\"type\": \"x_search\"}\n ],\n \"input\": [\n {\n \"role\": \"user\",\n \"content\": X_SEARCH_PROMPT.format(\n topic=topic,\n from_date=from_date,\n to_date=to_date,\n min_items=min_items,\n max_items=max_items,\n ),\n }\n ],\n }\n\n return http.post(XAI_RESPONSES_URL, payload, headers=headers, timeout=timeout)\n\n\ndef parse_x_response(response: Dict[str, Any]) -> List[Dict[str, Any]]:\n \"\"\"Parse xAI response to extract X items.\n\n Args:\n response: Raw API response\n\n Returns:\n List of item dicts\n \"\"\"\n items = []\n\n # Check for API errors first\n if \"error\" in response and response[\"error\"]:\n error = response[\"error\"]\n err_msg = error.get(\"message\", str(error)) if isinstance(error, dict) else str(error)\n _log_error(f\"xAI API error: {err_msg}\")\n if http.DEBUG:\n _log_error(f\"Full error response: {json.dumps(response, indent=2)[:1000]}\")\n return items\n\n # Try to find the output text\n output_text = \"\"\n if \"output\" in response:\n output = response[\"output\"]\n if isinstance(output, str):\n output_text = output\n elif isinstance(output, list):\n for item in output:\n if isinstance(item, dict):\n if item.get(\"type\") == \"message\":\n content = item.get(\"content\", [])\n for c in content:\n if isinstance(c, dict) and c.get(\"type\") == \"output_text\":\n output_text = c.get(\"text\", \"\")\n break\n elif \"text\" in item:\n output_text = item[\"text\"]\n elif isinstance(item, str):\n output_text = item\n if output_text:\n break\n\n # Also check for choices (older format)\n if not output_text and \"choices\" in response:\n for choice in response[\"choices\"]:\n if \"message\" in choice:\n output_text = choice[\"message\"].get(\"content\", \"\")\n break\n\n if not output_text:\n return items\n\n # Extract JSON from the response\n json_match = re.search(r'\\{[\\s\\S]*\"items\"[\\s\\S]*\\}', output_text)\n if json_match:\n try:\n data = json.loads(json_match.group())\n items = data.get(\"items\", [])\n except json.JSONDecodeError:\n pass\n\n # Validate and clean items\n clean_items = []\n for i, item in enumerate(items):\n if not isinstance(item, dict):\n continue\n\n url = item.get(\"url\", \"\")\n if not url:\n continue\n\n # Parse engagement\n engagement = None\n eng_raw = item.get(\"engagement\")\n if isinstance(eng_raw, dict):\n engagement = {\n \"likes\": int(eng_raw.get(\"likes\", 0)) if eng_raw.get(\"likes\") else None,\n \"reposts\": int(eng_raw.get(\"reposts\", 0)) if eng_raw.get(\"reposts\") else None,\n \"replies\": int(eng_raw.get(\"replies\", 0)) if eng_raw.get(\"replies\") else None,\n \"quotes\": int(eng_raw.get(\"quotes\", 0)) if eng_raw.get(\"quotes\") else None,\n }\n\n clean_item = {\n \"id\": f\"X{i+1}\",\n \"text\": str(item.get(\"text\", \"\")).strip()[:500], # Truncate long text\n \"url\": url,\n \"author_handle\": str(item.get(\"author_handle\", \"\")).strip().lstrip(\"@\"),\n \"date\": item.get(\"date\"),\n \"engagement\": engagement,\n \"why_relevant\": str(item.get(\"why_relevant\", \"\")).strip(),\n \"relevance\": min(1.0, max(0.0, float(item.get(\"relevance\", 0.5)))),\n }\n\n # Validate date format\n if clean_item[\"date\"]:\n if not re.match(r'^\\d{4}-\\d{2}-\\d{2}

last30days: Research Any Topic from the Last 30 Days Research ANY topic across Reddit, X, and the web. Surface what people are actually discussing, recommending, and debating right now. Use cases: - Prompting : "photorealistic people in Nano Banana Pro", "Midjourney prompts", "ChatGPT image generation" → learn techniques, get copy-paste prompts - Recommendations : "best Claude Code skills", "top AI tools" → get a LIST of specific things people mention - News : "what's happening with OpenAI", "latest AI announcements" → current events and updates - General : any topic you're curious about → un…

, str(clean_item[\"date\"])):\n clean_item[\"date\"] = None\n\n clean_items.append(clean_item)\n\n return clean_items\n","content_type":"text/x-python; charset=utf-8","language":"python","size":6646,"content_sha256":"ec9d50804958be9d6c7df2edbd7d2766b8a8cca36ab517caa9c371c19307829d"}],"content_json":{"type":"doc","content":[{"type":"heading","attrs":{"level":1},"content":[{"text":"last30days: Research Any Topic from the Last 30 Days","type":"text"}]},{"type":"paragraph","content":[{"text":"Research ANY topic across Reddit, X, and the web. Surface what people are actually discussing, recommending, and debating right now.","type":"text"}]},{"type":"paragraph","content":[{"text":"Use cases:","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Prompting","type":"text","marks":[{"type":"strong"}]},{"text":": \"photorealistic people in Nano Banana Pro\", \"Midjourney prompts\", \"ChatGPT image generation\" → learn techniques, get copy-paste prompts","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Recommendations","type":"text","marks":[{"type":"strong"}]},{"text":": \"best Claude Code skills\", \"top AI tools\" → get a LIST of specific things people mention","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"News","type":"text","marks":[{"type":"strong"}]},{"text":": \"what's happening with OpenAI\", \"latest AI announcements\" → current events and updates","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"General","type":"text","marks":[{"type":"strong"}]},{"text":": any topic you're curious about → understand what the community is saying","type":"text"}]}]}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"CRITICAL: Parse User Intent","type":"text"}]},{"type":"paragraph","content":[{"text":"Before doing anything, parse the user's input for:","type":"text"}]},{"type":"ordered_list","attrs":{"order":1,"listStyle":"number"},"content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"TOPIC","type":"text","marks":[{"type":"strong"}]},{"text":": What they want to learn about (e.g., \"web app mockups\", \"Claude Code skills\", \"image generation\")","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"TARGET TOOL","type":"text","marks":[{"type":"strong"}]},{"text":" (if specified): Where they'll use the prompts (e.g., \"Nano Banana Pro\", \"ChatGPT\", \"Midjourney\")","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"QUERY TYPE","type":"text","marks":[{"type":"strong"}]},{"text":": What kind of research they want:","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"PROMPTING","type":"text","marks":[{"type":"strong"}]},{"text":" - \"X prompts\", \"prompting for X\", \"X best practices\" → User wants to learn techniques and get copy-paste prompts","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"RECOMMENDATIONS","type":"text","marks":[{"type":"strong"}]},{"text":" - \"best X\", \"top X\", \"what X should I use\", \"recommended X\" → User wants a LIST of specific things","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"NEWS","type":"text","marks":[{"type":"strong"}]},{"text":" - \"what's happening with X\", \"X news\", \"latest on X\" → User wants current events/updates","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"GENERAL","type":"text","marks":[{"type":"strong"}]},{"text":" - anything else → User wants broad understanding of the topic","type":"text"}]}]}]}]}]},{"type":"paragraph","content":[{"text":"Common patterns:","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"[topic] for [tool]","type":"text","marks":[{"type":"code_inline"}]},{"text":" → \"web mockups for Nano Banana Pro\" → TOOL IS SPECIFIED","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"[topic] prompts for [tool]","type":"text","marks":[{"type":"code_inline"}]},{"text":" → \"UI design prompts for Midjourney\" → TOOL IS SPECIFIED","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Just ","type":"text"},{"text":"[topic]","type":"text","marks":[{"type":"code_inline"}]},{"text":" → \"iOS design mockups\" → TOOL NOT SPECIFIED, that's OK","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"\"best [topic]\" or \"top [topic]\" → QUERY_TYPE = RECOMMENDATIONS","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"\"what are the best [topic]\" → QUERY_TYPE = RECOMMENDATIONS","type":"text"}]}]}]},{"type":"paragraph","content":[{"text":"IMPORTANT: Do NOT ask about target tool before research.","type":"text","marks":[{"type":"strong"}]}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"If tool is specified in the query, use it","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"If tool is NOT specified, run research first, then ask AFTER showing results","type":"text"}]}]}]},{"type":"paragraph","content":[{"text":"Store these variables:","type":"text","marks":[{"type":"strong"}]}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"TOPIC = [extracted topic]","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"TARGET_TOOL = [extracted tool, or \"unknown\" if not specified]","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"QUERY_TYPE = [RECOMMENDATIONS | NEWS | HOW-TO | GENERAL]","type":"text","marks":[{"type":"code_inline"}]}]}]}]},{"type":"hr","attrs":{"markup":"---"}},{"type":"heading","attrs":{"level":2},"content":[{"text":"Setup Check","type":"text"}]},{"type":"paragraph","content":[{"text":"The skill works in three modes based on available API keys:","type":"text"}]},{"type":"ordered_list","attrs":{"order":1,"listStyle":"number"},"content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Full Mode","type":"text","marks":[{"type":"strong"}]},{"text":" (both keys): Reddit + X + WebSearch - best results with engagement metrics","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Partial Mode","type":"text","marks":[{"type":"strong"}]},{"text":" (one key): Reddit-only or X-only + WebSearch","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Web-Only Mode","type":"text","marks":[{"type":"strong"}]},{"text":" (no keys): WebSearch only - still useful, but no engagement metrics","type":"text"}]}]}]},{"type":"paragraph","content":[{"text":"API keys are OPTIONAL.","type":"text","marks":[{"type":"strong"}]},{"text":" The skill will work without them using WebSearch fallback.","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"First-Time Setup (Optional but Recommended)","type":"text"}]},{"type":"paragraph","content":[{"text":"If the user wants to add API keys for better results:","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"mkdir -p ~/.config/last30days\ncat > ~/.config/last30days/.env \u003c\u003c 'ENVEOF'\n# last30days API Configuration\n# Both keys are optional - skill works with WebSearch fallback\n\n# For Reddit research (uses OpenAI's web_search tool)\nOPENAI_API_KEY=\n\n# For X/Twitter research (uses xAI's x_search tool)\nXAI_API_KEY=\nENVEOF\n\nchmod 600 ~/.config/last30days/.env\necho \"Config created at ~/.config/last30days/.env\"\necho \"Edit to add your API keys for enhanced research.\"","type":"text"}]},{"type":"paragraph","content":[{"text":"DO NOT stop if no keys are configured.","type":"text","marks":[{"type":"strong"}]},{"text":" Proceed with web-only mode.","type":"text"}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Safety Boundaries","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Do not create or edit the local ","type":"text"},{"text":".env","type":"text","marks":[{"type":"code_inline"}]},{"text":" file unless the user asked to configure API keys.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Do not print secret values, bearer tokens, or local config contents in chat output.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Do not present stale prior knowledge as current research; use the gathered sources for the final synthesis.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Do not claim social-platform coverage when the skill had to fall back to web-only mode.","type":"text"}]}]}]},{"type":"hr","attrs":{"markup":"---"}},{"type":"heading","attrs":{"level":2},"content":[{"text":"Research Workflow","type":"text"}]},{"type":"paragraph","content":[{"text":"IMPORTANT: The script handles API key detection automatically.","type":"text","marks":[{"type":"strong"}]},{"text":" Run it and check the output to determine mode.","type":"text"}]},{"type":"paragraph","content":[{"text":"Step 1: Run the research script","type":"text","marks":[{"type":"strong"}]}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"python3 ./scripts/last30days.py \"$ARGUMENTS\" --emit=compact 2>&1","type":"text"}]},{"type":"paragraph","content":[{"text":"The script will automatically:","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Detect available API keys","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Show a promo banner if keys are missing (this is intentional marketing)","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Run Reddit/X searches if keys exist","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Signal if WebSearch is needed","type":"text"}]}]}]},{"type":"paragraph","content":[{"text":"Step 2: Check the output mode","type":"text","marks":[{"type":"strong"}]}]},{"type":"paragraph","content":[{"text":"The script output will indicate the mode:","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"\"Mode: both\"","type":"text","marks":[{"type":"strong"}]},{"text":" or ","type":"text"},{"text":"\"Mode: reddit-only\"","type":"text","marks":[{"type":"strong"}]},{"text":" or ","type":"text"},{"text":"\"Mode: x-only\"","type":"text","marks":[{"type":"strong"}]},{"text":": Script found results, WebSearch is supplementary","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"\"Mode: web-only\"","type":"text","marks":[{"type":"strong"}]},{"text":": No API keys, Claude must do ALL research via WebSearch","type":"text"}]}]}]},{"type":"paragraph","content":[{"text":"Step 3: Do WebSearch","type":"text","marks":[{"type":"strong"}]}]},{"type":"paragraph","content":[{"text":"For ","type":"text"},{"text":"ALL modes","type":"text","marks":[{"type":"strong"}]},{"text":", do WebSearch to supplement (or provide all data in web-only mode).","type":"text"}]},{"type":"paragraph","content":[{"text":"Choose search queries based on QUERY_TYPE:","type":"text"}]},{"type":"paragraph","content":[{"text":"If RECOMMENDATIONS","type":"text","marks":[{"type":"strong"}]},{"text":" (\"best X\", \"top X\", \"what X should I use\"):","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Search for: ","type":"text"},{"text":"best {TOPIC} recommendations","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Search for: ","type":"text"},{"text":"{TOPIC} list examples","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Search for: ","type":"text"},{"text":"most popular {TOPIC}","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Goal: Find SPECIFIC NAMES of things, not generic advice","type":"text"}]}]}]},{"type":"paragraph","content":[{"text":"If NEWS","type":"text","marks":[{"type":"strong"}]},{"text":" (\"what's happening with X\", \"X news\"):","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Search for: ","type":"text"},{"text":"{TOPIC} news 2026","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Search for: ","type":"text"},{"text":"{TOPIC} announcement update","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Goal: Find current events and recent developments","type":"text"}]}]}]},{"type":"paragraph","content":[{"text":"If PROMPTING","type":"text","marks":[{"type":"strong"}]},{"text":" (\"X prompts\", \"prompting for X\"):","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Search for: ","type":"text"},{"text":"{TOPIC} prompts examples 2026","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Search for: ","type":"text"},{"text":"{TOPIC} techniques tips","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Goal: Find prompting techniques and examples to create copy-paste prompts","type":"text"}]}]}]},{"type":"paragraph","content":[{"text":"If GENERAL","type":"text","marks":[{"type":"strong"}]},{"text":" (default):","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Search for: ","type":"text"},{"text":"{TOPIC} 2026","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Search for: ","type":"text"},{"text":"{TOPIC} discussion","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Goal: Find what people are actually saying","type":"text"}]}]}]},{"type":"paragraph","content":[{"text":"For ALL query types:","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"USE THE USER'S EXACT TERMINOLOGY","type":"text","marks":[{"type":"strong"}]},{"text":" - don't substitute or add tech names based on your knowledge","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"If user says \"ChatGPT image prompting\", search for \"ChatGPT image prompting\"","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Do NOT add \"DALL-E\", \"GPT-4o\", or other terms you think are related","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Your knowledge may be outdated - trust the user's terminology","type":"text"}]}]}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"EXCLUDE reddit.com, x.com, twitter.com (covered by script)","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"INCLUDE: blogs, tutorials, docs, news, GitHub repos","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"DO NOT output \"Sources:\" list","type":"text","marks":[{"type":"strong"}]},{"text":" - this is noise, we'll show stats at the end","type":"text"}]}]}]},{"type":"paragraph","content":[{"text":"Step 3: Wait for background script to complete","type":"text","marks":[{"type":"strong"}]},{"text":" Use TaskOutput to get the script results before proceeding to synthesis.","type":"text"}]},{"type":"paragraph","content":[{"text":"Depth options","type":"text","marks":[{"type":"strong"}]},{"text":" (passed through from user's command):","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"--quick","type":"text","marks":[{"type":"code_inline"}]},{"text":" → Faster, fewer sources (8-12 each)","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"(default) → Balanced (20-30 each)","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"--deep","type":"text","marks":[{"type":"code_inline"}]},{"text":" → Comprehensive (50-70 Reddit, 40-60 X)","type":"text"}]}]}]},{"type":"hr","attrs":{"markup":"---"}},{"type":"heading","attrs":{"level":2},"content":[{"text":"Judge Agent: Synthesize All Sources","type":"text"}]},{"type":"paragraph","content":[{"text":"After all searches complete, internally synthesize (don't display stats yet):","type":"text","marks":[{"type":"strong"}]}]},{"type":"paragraph","content":[{"text":"The Judge Agent must:","type":"text"}]},{"type":"ordered_list","attrs":{"order":1,"listStyle":"number"},"content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Weight Reddit/X sources HIGHER (they have engagement signals: upvotes, likes)","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Weight WebSearch sources LOWER (no engagement data)","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Identify patterns that appear across ALL three sources (strongest signals)","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Note any contradictions between sources","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Extract the top 3-5 actionable insights","type":"text"}]}]}]},{"type":"paragraph","content":[{"text":"Do NOT display stats here - they come at the end, right before the invitation.","type":"text","marks":[{"type":"strong"}]}]},{"type":"hr","attrs":{"markup":"---"}},{"type":"heading","attrs":{"level":2},"content":[{"text":"FIRST: Internalize the Research","type":"text"}]},{"type":"paragraph","content":[{"text":"CRITICAL: Ground your synthesis in the ACTUAL research content, not your pre-existing knowledge.","type":"text","marks":[{"type":"strong"}]}]},{"type":"paragraph","content":[{"text":"Read the research output carefully. Pay attention to:","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Exact product/tool names","type":"text","marks":[{"type":"strong"}]},{"text":" mentioned (e.g., if research mentions \"ClawdBot\" or \"@clawdbot\", that's a DIFFERENT product than \"Claude Code\" - don't conflate them)","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Specific quotes and insights","type":"text","marks":[{"type":"strong"}]},{"text":" from the sources - use THESE, not generic knowledge","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"What the sources actually say","type":"text","marks":[{"type":"strong"}]},{"text":", not what you assume the topic is about","type":"text"}]}]}]},{"type":"paragraph","content":[{"text":"ANTI-PATTERN TO AVOID","type":"text","marks":[{"type":"strong"}]},{"text":": If user asks about \"clawdbot skills\" and research returns ClawdBot content (self-hosted AI agent), do NOT synthesize this as \"Claude Code skills\" just because both involve \"skills\". Read what the research actually says.","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"If QUERY_TYPE = RECOMMENDATIONS","type":"text"}]},{"type":"paragraph","content":[{"text":"CRITICAL: Extract SPECIFIC NAMES, not generic patterns.","type":"text","marks":[{"type":"strong"}]}]},{"type":"paragraph","content":[{"text":"When user asks \"best X\" or \"top X\", they want a LIST of specific things:","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Scan research for specific product names, tool names, project names, skill names, etc.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Count how many times each is mentioned","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Note which sources recommend each (Reddit thread, X post, blog)","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"List them by popularity/mention count","type":"text"}]}]}]},{"type":"paragraph","content":[{"text":"BAD synthesis for \"best Claude Code skills\":","type":"text","marks":[{"type":"strong"}]}]},{"type":"blockquote","content":[{"type":"paragraph","content":[{"text":"\"Skills are powerful. Keep them under 500 lines. Use progressive disclosure.\"","type":"text"}]}]},{"type":"paragraph","content":[{"text":"GOOD synthesis for \"best Claude Code skills\":","type":"text","marks":[{"type":"strong"}]}]},{"type":"blockquote","content":[{"type":"paragraph","content":[{"text":"\"Most mentioned skills: /commit (5 mentions), remotion skill (4x), git-worktree (3x), /pr (3x). The Remotion announcement got 16K likes on X.\"","type":"text"}]}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"For all QUERY_TYPEs","type":"text"}]},{"type":"paragraph","content":[{"text":"Identify from the ACTUAL RESEARCH OUTPUT:","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"PROMPT FORMAT","type":"text","marks":[{"type":"strong"}]},{"text":" - Does research recommend JSON, structured params, natural language, keywords? THIS IS CRITICAL.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"The top 3-5 patterns/techniques that appeared across multiple sources","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Specific keywords, structures, or approaches mentioned BY THE SOURCES","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Common pitfalls mentioned BY THE SOURCES","type":"text"}]}]}]},{"type":"paragraph","content":[{"text":"If research says \"use JSON prompts\" or \"structured prompts\", you MUST deliver prompts in that format later.","type":"text","marks":[{"type":"strong"}]}]},{"type":"hr","attrs":{"markup":"---"}},{"type":"heading","attrs":{"level":2},"content":[{"text":"THEN: Show Summary + Invite Vision","type":"text"}]},{"type":"paragraph","content":[{"text":"CRITICAL: Do NOT output any \"Sources:\" lists. The final display should be clean.","type":"text","marks":[{"type":"strong"}]}]},{"type":"paragraph","content":[{"text":"Display in this EXACT sequence:","type":"text","marks":[{"type":"strong"}]}]},{"type":"paragraph","content":[{"text":"FIRST - What I learned (based on QUERY_TYPE):","type":"text","marks":[{"type":"strong"}]}]},{"type":"paragraph","content":[{"text":"If RECOMMENDATIONS","type":"text","marks":[{"type":"strong"}]},{"text":" - Show specific things mentioned:","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":""},"content":[{"text":"🏆 Most mentioned:\n1. [Specific name] - mentioned {n}x (r/sub, @handle, blog.com)\n2. [Specific name] - mentioned {n}x (sources)\n3. [Specific name] - mentioned {n}x (sources)\n4. [Specific name] - mentioned {n}x (sources)\n5. [Specific name] - mentioned {n}x (sources)\n\nNotable mentions: [other specific things with 1-2 mentions]","type":"text"}]},{"type":"paragraph","content":[{"text":"If PROMPTING/NEWS/GENERAL","type":"text","marks":[{"type":"strong"}]},{"text":" - Show synthesis and patterns:","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":""},"content":[{"text":"What I learned:\n\n[2-4 sentences synthesizing key insights FROM THE ACTUAL RESEARCH OUTPUT.]\n\nKEY PATTERNS I'll use:\n1. [Pattern from research]\n2. [Pattern from research]\n3. [Pattern from research]","type":"text"}]},{"type":"paragraph","content":[{"text":"THEN - Stats (right before invitation):","type":"text","marks":[{"type":"strong"}]}]},{"type":"paragraph","content":[{"text":"For ","type":"text"},{"text":"full/partial mode","type":"text","marks":[{"type":"strong"}]},{"text":" (has API keys):","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":""},"content":[{"text":"---\n✅ All agents reported back!\n├─ 🟠 Reddit: {n} threads │ {sum} upvotes │ {sum} comments\n├─ 🔵 X: {n} posts │ {sum} likes │ {sum} reposts\n├─ 🌐 Web: {n} pages │ {domains}\n└─ Top voices: r/{sub1}, r/{sub2} │ @{handle1}, @{handle2} │ {web_author} on {site}","type":"text"}]},{"type":"paragraph","content":[{"text":"For ","type":"text"},{"text":"web-only mode","type":"text","marks":[{"type":"strong"}]},{"text":" (no API keys):","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":""},"content":[{"text":"---\n✅ Research complete!\n├─ 🌐 Web: {n} pages │ {domains}\n└─ Top sources: {author1} on {site1}, {author2} on {site2}\n\n💡 Want engagement metrics? Add API keys to ~/.config/last30days/.env\n - OPENAI_API_KEY → Reddit (real upvotes & comments)\n - XAI_API_KEY → X/Twitter (real likes & reposts)","type":"text"}]},{"type":"paragraph","content":[{"text":"LAST - Invitation:","type":"text","marks":[{"type":"strong"}]}]},{"type":"code_block","attrs":{"wrap":false,"language":""},"content":[{"text":"---\nShare your vision for what you want to create and I'll write a thoughtful prompt you can copy-paste directly into {TARGET_TOOL}.","type":"text"}]},{"type":"paragraph","content":[{"text":"Use real numbers from the research output.","type":"text","marks":[{"type":"strong"}]},{"text":" The patterns should be actual insights from the research, not generic advice.","type":"text"}]},{"type":"paragraph","content":[{"text":"SELF-CHECK before displaying","type":"text","marks":[{"type":"strong"}]},{"text":": Re-read your \"What I learned\" section. Does it match what the research ACTUALLY says? If the research was about ClawdBot (a self-hosted AI agent), your summary should be about ClawdBot, not Claude Code. If you catch yourself projecting your own knowledge instead of the research, rewrite it.","type":"text"}]},{"type":"paragraph","content":[{"text":"IF TARGET_TOOL is still unknown after showing results","type":"text","marks":[{"type":"strong"}]},{"text":", ask NOW (not before research):","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":""},"content":[{"text":"What tool will you use these prompts with?\n\nOptions:\n1. [Most relevant tool based on research - e.g., if research mentioned Figma/Sketch, offer those]\n2. Nano Banana Pro (image generation)\n3. ChatGPT / Claude (text/code)\n4. Other (tell me)","type":"text"}]},{"type":"paragraph","content":[{"text":"IMPORTANT","type":"text","marks":[{"type":"strong"}]},{"text":": After displaying this, WAIT for the user to respond. Don't dump generic prompts.","type":"text"}]},{"type":"hr","attrs":{"markup":"---"}},{"type":"heading","attrs":{"level":2},"content":[{"text":"WAIT FOR USER'S VISION","type":"text"}]},{"type":"paragraph","content":[{"text":"After showing the stats summary with your invitation, ","type":"text"},{"text":"STOP and wait","type":"text","marks":[{"type":"strong"}]},{"text":" for the user to tell you what they want to create.","type":"text"}]},{"type":"paragraph","content":[{"text":"When they respond with their vision (e.g., \"I want a landing page mockup for my SaaS app\"), THEN write a single, thoughtful, tailored prompt.","type":"text"}]},{"type":"hr","attrs":{"markup":"---"}},{"type":"heading","attrs":{"level":2},"content":[{"text":"WHEN USER SHARES THEIR VISION: Write ONE Perfect Prompt","type":"text"}]},{"type":"paragraph","content":[{"text":"Based on what they want to create, write a ","type":"text"},{"text":"single, highly-tailored prompt","type":"text","marks":[{"type":"strong"}]},{"text":" using your research expertise.","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"CRITICAL: Match the FORMAT the research recommends","type":"text"}]},{"type":"paragraph","content":[{"text":"If research says to use a specific prompt FORMAT, YOU MUST USE THAT FORMAT:","type":"text","marks":[{"type":"strong"}]}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Research says \"JSON prompts\" → Write the prompt AS JSON","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Research says \"structured parameters\" → Use structured key: value format","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Research says \"natural language\" → Use conversational prose","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Research says \"keyword lists\" → Use comma-separated keywords","type":"text"}]}]}]},{"type":"paragraph","content":[{"text":"ANTI-PATTERN","type":"text","marks":[{"type":"strong"}]},{"text":": Research says \"use JSON prompts with device specs\" but you write plain prose. This defeats the entire purpose of the research.","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"Output Format:","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":""},"content":[{"text":"Here's your prompt for {TARGET_TOOL}:\n\n---\n\n[The actual prompt IN THE FORMAT THE RESEARCH RECOMMENDS - if research said JSON, this is JSON. If research said natural language, this is prose. Match what works.]\n\n---\n\nThis uses [brief 1-line explanation of what research insight you applied].","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"Quality Checklist:","type":"text"}]},{"type":"checkbox_list","attrs":{"id":null},"content":[{"type":"checkbox_item","attrs":{"checked":false},"content":[{"type":"paragraph","content":[{"text":"FORMAT MATCHES RESEARCH","type":"text","marks":[{"type":"strong"}]},{"text":" - If research said JSON/structured/etc, prompt IS that format","type":"text"}]}]},{"type":"checkbox_item","attrs":{"checked":false},"content":[{"type":"paragraph","content":[{"text":"Directly addresses what the user said they want to create","type":"text"}]}]},{"type":"checkbox_item","attrs":{"checked":false},"content":[{"type":"paragraph","content":[{"text":"Uses specific patterns/keywords discovered in research","type":"text"}]}]},{"type":"checkbox_item","attrs":{"checked":false},"content":[{"type":"paragraph","content":[{"text":"Ready to paste with zero edits (or minimal [PLACEHOLDERS] clearly marked)","type":"text"}]}]},{"type":"checkbox_item","attrs":{"checked":false},"content":[{"type":"paragraph","content":[{"text":"Appropriate length and style for TARGET_TOOL","type":"text"}]}]}]},{"type":"hr","attrs":{"markup":"---"}},{"type":"heading","attrs":{"level":2},"content":[{"text":"IF USER ASKS FOR MORE OPTIONS","type":"text"}]},{"type":"paragraph","content":[{"text":"Only if they ask for alternatives or more prompts, provide 2-3 variations. Don't dump a prompt pack unless requested.","type":"text"}]},{"type":"hr","attrs":{"markup":"---"}},{"type":"heading","attrs":{"level":2},"content":[{"text":"AFTER EACH PROMPT: Stay in Expert Mode","type":"text"}]},{"type":"paragraph","content":[{"text":"After delivering a prompt, offer to write more:","type":"text"}]},{"type":"blockquote","content":[{"type":"paragraph","content":[{"text":"Want another prompt? Just tell me what you're creating next.","type":"text"}]}]},{"type":"hr","attrs":{"markup":"---"}},{"type":"heading","attrs":{"level":2},"content":[{"text":"CONTEXT MEMORY","type":"text"}]},{"type":"paragraph","content":[{"text":"For the rest of this conversation, remember:","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"TOPIC","type":"text","marks":[{"type":"strong"}]},{"text":": {topic}","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"TARGET_TOOL","type":"text","marks":[{"type":"strong"}]},{"text":": {tool}","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"KEY PATTERNS","type":"text","marks":[{"type":"strong"}]},{"text":": {list the top 3-5 patterns you learned}","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"RESEARCH FINDINGS","type":"text","marks":[{"type":"strong"}]},{"text":": The key facts and insights from the research","type":"text"}]}]}]},{"type":"paragraph","content":[{"text":"CRITICAL: After research is complete, you are now an EXPERT on this topic.","type":"text","marks":[{"type":"strong"}]}]},{"type":"paragraph","content":[{"text":"When the user asks follow-up questions:","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"DO NOT run new WebSearches","type":"text","marks":[{"type":"strong"}]},{"text":" - you already have the research","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Answer from what you learned","type":"text","marks":[{"type":"strong"}]},{"text":" - cite the Reddit threads, X posts, and web sources","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"If they ask for a prompt","type":"text","marks":[{"type":"strong"}]},{"text":" - write one using your expertise","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"If they ask a question","type":"text","marks":[{"type":"strong"}]},{"text":" - answer it from your research findings","type":"text"}]}]}]},{"type":"paragraph","content":[{"text":"Only do new research if the user explicitly asks about a DIFFERENT topic.","type":"text"}]},{"type":"hr","attrs":{"markup":"---"}},{"type":"heading","attrs":{"level":2},"content":[{"text":"Output Summary Footer (After Each Prompt)","type":"text"}]},{"type":"paragraph","content":[{"text":"After delivering a prompt, end with:","type":"text"}]},{"type":"paragraph","content":[{"text":"For ","type":"text"},{"text":"full/partial mode","type":"text","marks":[{"type":"strong"}]},{"text":":","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":""},"content":[{"text":"---\n📚 Expert in: {TOPIC} for {TARGET_TOOL}\n📊 Based on: {n} Reddit threads ({sum} upvotes) + {n} X posts ({sum} likes) + {n} web pages\n\nWant another prompt? Just tell me what you're creating next.","type":"text"}]},{"type":"paragraph","content":[{"text":"For ","type":"text"},{"text":"web-only mode","type":"text","marks":[{"type":"strong"}]},{"text":":","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":""},"content":[{"text":"---\n📚 Expert in: {TOPIC} for {TARGET_TOOL}\n📊 Based on: {n} web pages from {domains}\n\nWant another prompt? Just tell me what you're creating next.\n\n💡 Unlock Reddit & X data: Add API keys to ~/.config/last30days/.env","type":"text"}]},{"type":"hr","attrs":{"markup":"---"}}]},"metadata":{"date":"2026-06-05","name":"last30days","author":"@skillopedia","source":{"stars":234,"repo_name":"agent-skills","origin_url":"https://github.com/jdrhyne/agent-skills/blob/HEAD/skills/last30days/SKILL.md","repo_owner":"jdrhyne","body_sha256":"a412e030c561099973d0d31cea51af24fa18362856f3a15b062d8317e75ddb00","cluster_key":"c2c63059cb3f265252debb2d24f25c97406dbebc4ac99b05ba231901cbd7dd65","clean_bundle":{"format":"clean-skill-bundle-v1","source":"jdrhyne/agent-skills/skills/last30days/SKILL.md","attachments":[{"id":"192301f4-3472-5865-be9b-2d58f2078635","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/192301f4-3472-5865-be9b-2d58f2078635/attachment.json","path":".clawhub/origin.json","size":142,"sha256":"7ad9138dec87b884dc2632018cdfcb792a4a40b1b9b85ce5e92afe299aa27eb7","contentType":"application/json; charset=utf-8"},{"id":"6ad4da30-f24c-5a61-b5a1-2f9feab53778","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/6ad4da30-f24c-5a61-b5a1-2f9feab53778/attachment.json","path":"_meta.json","size":129,"sha256":"a348d11a80dc5267e62adce6cbc48f63d4752d0be0e28026df6192c6e7f5cd55","contentType":"application/json; charset=utf-8"},{"id":"822ee106-c020-547c-a04b-58ffb0c22a35","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/822ee106-c020-547c-a04b-58ffb0c22a35/attachment.py","path":"scripts/last30days.py","size":16044,"sha256":"8d5fa8a3cce44116741014695a0e6bda73647b467d97f692bd5fb52d277e188b","contentType":"text/x-python; charset=utf-8"},{"id":"02ebbc66-048e-5bbd-8d0c-b93b98676086","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/02ebbc66-048e-5bbd-8d0c-b93b98676086/attachment.py","path":"scripts/lib/__init__.py","size":29,"sha256":"3fb925405f898201188f9b5eea04e214ea8c8a64643397fd727f08ffeea234d9","contentType":"text/x-python; charset=utf-8"},{"id":"533b053f-6d06-56b1-8523-c340107690be","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/533b053f-6d06-56b1-8523-c340107690be/attachment.py","path":"scripts/lib/cache.py","size":4149,"sha256":"72de64b54b41504aa30e9f1519ae62434f958f66cabe4eb3e785fe1fc6a96918","contentType":"text/x-python; charset=utf-8"},{"id":"ed2d985e-2eb7-54e6-9997-3514b0cfa028","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/ed2d985e-2eb7-54e6-9997-3514b0cfa028/attachment.py","path":"scripts/lib/dates.py","size":3253,"sha256":"c5b1f01dd4e28ea385cb5a0b10259cce7a642e308575df12424242d3779435af","contentType":"text/x-python; charset=utf-8"},{"id":"ad4aedb5-4713-55c1-b7ff-327b1b638dd9","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/ad4aedb5-4713-55c1-b7ff-327b1b638dd9/attachment.py","path":"scripts/lib/dedupe.py","size":3237,"sha256":"5b1ed9693d0febc5758c52d1d2769cefa23e42c515d10befd4e9feae93ac58e1","contentType":"text/x-python; charset=utf-8"},{"id":"6a1932d9-7594-5e92-83e7-2937785569c0","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/6a1932d9-7594-5e92-83e7-2937785569c0/attachment.py","path":"scripts/lib/env.py","size":4949,"sha256":"5db6ac526c369340f4485618275a72e6ddce4f15e5388ca824ddc97f2b24a45f","contentType":"text/x-python; charset=utf-8"},{"id":"535223b5-09f8-514f-88f0-7e8d18698a58","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/535223b5-09f8-514f-88f0-7e8d18698a58/attachment.py","path":"scripts/lib/http.py","size":4806,"sha256":"11d094130f7e4d7bf19713e268a42ca95abf622800e0a4743d9c41ddc85ad1cc","contentType":"text/x-python; charset=utf-8"},{"id":"0bded11f-f513-53f5-be15-db6298f12193","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/0bded11f-f513-53f5-be15-db6298f12193/attachment.py","path":"scripts/lib/models.py","size":4631,"sha256":"98c633c6e74d39fd0a5919d6c14851b0b7a67b93e4908c248140df95cb2d0fc0","contentType":"text/x-python; charset=utf-8"},{"id":"461d709a-98fa-56fd-b013-020edd254d35","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/461d709a-98fa-56fd-b013-020edd254d35/attachment.py","path":"scripts/lib/normalize.py","size":4767,"sha256":"e8666ce0a872670b16eb1c822df05aa96981abc57fe9e795f0fb9eceaa3dcad5","contentType":"text/x-python; charset=utf-8"},{"id":"559ecdf9-d8fc-5338-9590-55e15fc66d2c","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/559ecdf9-d8fc-5338-9590-55e15fc66d2c/attachment.py","path":"scripts/lib/openai_reddit.py","size":7328,"sha256":"0a643152be88e070bd3db37a38703888491c4307e239ca1fc5a3c72b5315d80e","contentType":"text/x-python; charset=utf-8"},{"id":"fe817f6d-abe0-5be0-8e9a-f929737ce30f","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/fe817f6d-abe0-5be0-8e9a-f929737ce30f/attachment.py","path":"scripts/lib/reddit_enrich.py","size":6750,"sha256":"f77e6666b3ed3fe701090b6f050a48d863a63348b678f18c2113a8bce9f40c4c","contentType":"text/x-python; charset=utf-8"},{"id":"b84cf739-2ab8-5712-8dce-6f7dc0f8a86e","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/b84cf739-2ab8-5712-8dce-6f7dc0f8a86e/attachment.py","path":"scripts/lib/render.py","size":13781,"sha256":"166cbc714e64da7bb2c1932e9eacef51a90b2b90b9d0179cd22048674d4fd07c","contentType":"text/x-python; charset=utf-8"},{"id":"5e01b731-7659-5dea-9462-9d66b0465ce7","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/5e01b731-7659-5dea-9462-9d66b0465ce7/attachment.py","path":"scripts/lib/schema.py","size":10861,"sha256":"3532ba4ef6c7b1d80a85acecb691401687ef4e5d905e2ef5683eb756d8149f8e","contentType":"text/x-python; charset=utf-8"},{"id":"a304d0a6-75a8-5741-93de-cc77accb34c9","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/a304d0a6-75a8-5741-93de-cc77accb34c9/attachment.py","path":"scripts/lib/score.py","size":9177,"sha256":"b969c7176bfec8e4723da57f7d51348ec7b2c8bbec65c7cf9e6e48c0b0495651","contentType":"text/x-python; charset=utf-8"},{"id":"15ac4a53-ba02-5918-84a2-7ee4730f1ea4","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/15ac4a53-ba02-5918-84a2-7ee4730f1ea4/attachment.py","path":"scripts/lib/ui.py","size":13268,"sha256":"275fc9d1c16e5fbf6f0a27915042c4a8d9b1af1cd7f07f39b64f460ea9987935","contentType":"text/x-python; charset=utf-8"},{"id":"8649c4bc-559d-5592-93d8-a2a88b91a08b","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/8649c4bc-559d-5592-93d8-a2a88b91a08b/attachment.py","path":"scripts/lib/websearch.py","size":11644,"sha256":"752f4238c5052d19a405e15c347237f718f7bac52de7f2a84a728edf0905796c","contentType":"text/x-python; charset=utf-8"},{"id":"9e600129-7733-5735-9e70-d41af55c22ee","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/9e600129-7733-5735-9e70-d41af55c22ee/attachment.py","path":"scripts/lib/xai_x.py","size":6646,"sha256":"ec9d50804958be9d6c7df2edbd7d2766b8a8cca36ab517caa9c371c19307829d","contentType":"text/x-python; charset=utf-8"}],"bundle_sha256":"85425067a18de9bbfd146a83be5939e328a03aa252b9c7f92d5d05c81260c72a","attachment_count":19,"text_attachments":19,"attachment_storage":"skillopedia-attachments-v1","binary_attachments":0,"excluded_attachments":[]},"cluster_size":1,"skill_md_path":"skills/last30days/SKILL.md","import_metadata":{"date":"2026-06-05","author":"@skillopedia","version":"v1","category":"web-development","category_label":"Web"},"exact_dupes_collapsed_into_this":0},"version":"v1","category":"web-development","import_tag":"clean-skills-v1","description":"Research any topic from the last 30 days on Reddit + X + Web, synthesize findings, and write copy-paste-ready prompts. Use when the user wants recent social/web research on a topic, asks \"what are people saying about X\", or wants to learn current best practices. Requires OPENAI_API_KEY and/or XAI_API_KEY for full Reddit+X access, falls back to web search.","permissions":[{"exec":"Runs the local research script when the user asks for last-30-days research."},{"file_write":"Creates the optional local config file only when the user asks to add API keys."},{"credential_access":"Reads the optional API keys from the documented local config file for Reddit and X research."},{"network":"Queries Reddit, X, and web search sources as part of the requested research workflow."}]}},"renderedAt":1782987773581}

last30days: Research Any Topic from the Last 30 Days Research ANY topic across Reddit, X, and the web. Surface what people are actually discussing, recommending, and debating right now. Use cases: - Prompting : "photorealistic people in Nano Banana Pro", "Midjourney prompts", "ChatGPT image generation" → learn techniques, get copy-paste prompts - Recommendations : "best Claude Code skills", "top AI tools" → get a LIST of specific things people mention - News : "what's happening with OpenAI", "latest AI announcements" → current events and updates - General : any topic you're curious about → un…