Compare commits

...

19 Commits

Author SHA1 Message Date
sck_0
3dded33731 chore: finalize v3.4.0 versioning in README 2026-01-27 09:20:39 +01:00
sck_0
cc0f4a2ec4 chore: sync generated files (fix CI drift in v3.4.0) 2026-01-27 09:18:26 +01:00
sck_0
1b606d851d chore(release): v3.4.0 - Voice AI & Categorization 2026-01-27 09:16:41 +01:00
sck_0
7e2f243bfa chore: fixes CI drift (sync generated files +1 skill) 2026-01-27 09:11:06 +01:00
sck_0
3688425884 chore: sync generated files and apply categorization 2026-01-27 08:59:59 +01:00
sickn33
8801592bd2 Merge pull request #33 from taksrules/feat/voice-ai-engine-development
feat: add voice-ai-engine-development skill for building real-time co…
2026-01-27 08:54:35 +01:00
taksrules
d972c4fa3a feat: add voice-ai-engine-development skill for building real-time conversational AI 2026-01-27 07:24:06 +02:00
sck_0
e9783892c1 chore: fixes ci drift in generated files 2026-01-26 20:06:02 +01:00
sck_0
d8d8e70ebb chore: standardize maintenance rules and sync docs (v3.3.0 audit) 2026-01-26 20:04:54 +01:00
sck_0
deafaa6e77 chore: fix drift in generated files (CI sync) 2026-01-26 19:28:32 +01:00
sck_0
790573472c chore: sync generated files and stats 2026-01-26 19:25:20 +01:00
sck_0
66b777a937 chore: move new contributors to community section 2026-01-26 19:23:09 +01:00
sck_0
63d98348d2 chore: fix skill count in readme intro 2026-01-26 19:22:14 +01:00
sck_0
79f2642f4e chore: fix readme title and contributors 2026-01-26 19:19:29 +01:00
sck_0
48d458ce0b chore: fix ci drift in generated files 2026-01-26 19:12:38 +01:00
sck_0
9df73a8e56 chore: release v3.3.0 2026-01-26 19:09:09 +01:00
sck_0
2d7529b613 chore: formatting fixes 2026-01-26 19:07:27 +01:00
sck_0
283c4e6ae7 chore: sync generated files 2026-01-26 19:06:44 +01:00
sck_0
c7f7f23bd7 feat: integrate last30days and daily-news-report skills 2026-01-26 19:05:37 +01:00
59 changed files with 11160 additions and 33 deletions

View File

@@ -7,6 +7,41 @@ It covers the **Quality Bar**, **Documentation Consistency**, and **Release Work
---
## 0. 🤖 Agent Protocol (THE BIBLE)
**AGENTS MUST READ AND FOLLOW THIS SECTION BEFORE MARKING ANY TASK AS COMPLETE.**
There are 3 things that usually fail/get forgotten. **DO NOT FORGET THEM:**
### 1. 📤 ALWAYS PUSH (Non-Negotiable)
Committing is NOT enough. You must PUSH to the remote.
- **BAD**: `git commit -m "feat: new skill"` (User sees nothing)
- **GOOD**: `git commit -m "..." && git push origin main`
### 2. 🔄 SYNC GENERATED FILES (Avoid CI Drift)
If you touch `skills/`, you **MUST** run the validation chain.
- Running `validate_skills.py` is NOT optional.
- Running `generate_index.py` is NOT optional.
- Running `update_readme.py` is NOT optional.
_If CI fails because of drift, you have failed._
### 3. 📝 EVIDENCE OF WORK
- You must create/update `walkthrough.md` or `RELEASE_NOTES.md` to document what changed.
- If you made something new, **link it** in the artifacts.
### 4. 🚫 NO BRANCHES
- **ALWAYS use the `main` branch.**
- NEVER create feature branches (e.g., `feat/new-skill`).
- We commit directly to `main` to keep history linear and simple.
---
## 1. 🚦 Daily Maintenance Routine
### A. Validation Chain
@@ -84,15 +119,25 @@ If you update installation instructions or tool compatibility, you MUST update a
_Common pitfall: Updating the clone URL in README but leaving an old one in FAQ._
### C. Statistics
### C. Statistics Consistency (CRITICAL)
If you add skills, update the counts:
If you add/remove skills, you **MUST** ensure the total count is identical in ALL locations.
**Do not allow drift** (e.g., 356 in title, 354 in header).
- Title of `README.md`: "253+ Agentic Skills..."
- `## Full Skill Registry (253/253)` header.
- `GETTING_STARTED.md` intro.
Locations to check:
### D. Badges & Links
1. **Title of `README.md`**: "356+ Agentic Skills..."
2. **`## Full Skill Registry (356/356)` header**.
3. **`GETTING_STARTED.md` intro**.
### D. Credits Policy (Who goes where?)
- **Credits & Sources**: Use this for **External Repos**.
- _Rule_: "I extracted skills from this link you sent me." -> Add to `## Credits & Sources`.
- **Repo Contributors**: Use this for **Pull Requests**.
- _Rule_: "This user sent a PR." -> Add to `## Repo Contributors`.
### E. Badges & Links
- **Antigravity Badge**: Must point to `https://github.com/sickn33/antigravity-awesome-skills`, NOT `anthropics/antigravity`.
- **License**: Ensure the link points to `LICENSE` file.
@@ -132,6 +177,48 @@ When cutting a new version (e.g., V4):
git push origin v3.0.0
```
### 📋 Release Note Template
All changeslogs/release notes MUST follow this structure to ensure professionalism and quality:
```markdown
# Release vX.Y.Z: [Theme Name]
> **[One-line catchy summary of the release]**
[Brief 2-3 sentence intro about the release's impact]
## 🚀 New Skills
### [Emoji] [Skill Name](skills/skill-name/)
**[Bold high-level benefit]**
[Description of what it does]
- **Key Feature 1**: [Detail]
- **Key Feature 2**: [Detail]
> **Try it:** `(User Prompt) ...`
---
## 📦 Improvements
- **Registry Update**: Now tracking [N] skills.
- **[Component]**: [Change detail]
## 👥 Credits
A huge shoutout to our community contributors:
- **@username** for `skill-name`
- **@username** for `fix-name`
---
_Upgrade now: `git pull origin main` to fetch the latest skills._
```
---
## 5. 🚨 Emergency Fixes

View File

@@ -11,6 +11,35 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
---
## [3.4.0] - 2026-01-27 - "Voice Intelligence & Categorization"
### Added
- **New Skill**: `voice-ai-engine-development` - Complete toolkit for building real-time voice agents (OpenAI Realtime, Vapi, Deepgram, ElevenLabs).
- **Categorization**: Major README update introducing a concise "Features & Categories" summary table.
### Changed
- **README**: Replaced text-heavy category lists with a high-level summary table.
- **Registry**: Synced generic skill count (256) across documentation.
### Contributors
- [@sickn33](https://github.com/sickn33) - Voice AI Engine (PR #33)
- [@community](https://github.com/community) - Categorization Initiative (PR #32)
## [3.3.0] - 2026-01-26 - "News & Research"
### Added
- **New Skills**:
- `last30days`: Research any topic from the last 30 days on Reddit + X + Web.
- `daily-news-report`: Generate daily news reports from multiple sources.
### Changed
- **Registry**: Updated `skills_index.json` and `README.md` registry (Total: 255 skills).
## [3.2.0] - 2026-01-26 - "Clarity & Consistency"
### Changed

2
FAQ.md
View File

@@ -116,7 +116,7 @@ Use the `@` symbol followed by the skill name:
### How do I know which skill to use?
1. **Browse the README**: Check the [Full Skill Registry](README.md#full-skill-registry-253253).
1. **Browse the README**: Check the [Full Skill Registry](README.md#full-skill-registry-255255).
2. **Search**: `ls skills/ | grep "keyword"`
3. **Ask your AI**: "What skills do you have for testing?"

View File

@@ -15,7 +15,7 @@ AI Agents (like **Claude Code**, **Gemini**, **Cursor**) are smart, but they lac
## ⚡️ Quick Start: The "Starter Packs"
Don't panic about the 253+ skills. You don't need them all at once.
Don't panic about the 255+ skills. You don't need them all at once.
We have curated **Starter Packs** to get you running immediately.
### 1. Install the Repo
@@ -84,7 +84,7 @@ We classify skills so you know what you're running:
- 🔵 **Safe**: Community skills that are non-destructive (Read-only/Planning).
- 🔴 **Risk**: Skills that modify systems or perform security tests (Authorized Use Only).
_Check the [Full Registry](README.md#full-skill-registry-253253) for risk labels._
_Check the [Full Registry](README.md#full-skill-registry-255255) for risk labels._
---
@@ -103,6 +103,6 @@ A: Yes, MIT License. Open Source forever.
## ⏭️ Next Steps
1. [Browse the Bundles](docs/BUNDLES.md)
2. [See Real-World Examples](docs/EXAMPLES.md)
3. [Contribute a Skill](CONTRIBUTING.md)
1. [Browse the Bundles](docs/BUNDLES.md)
2. [See Real-World Examples](docs/EXAMPLES.md)
3. [Contribute a Skill](CONTRIBUTING.md)

View File

@@ -1,6 +1,6 @@
# 🌌 Antigravity Awesome Skills: 253+ Agentic Skills for Claude Code, Gemini CLI, Cursor, Copilot & More
# 🌌 Antigravity Awesome Skills: 256+ Agentic Skills for Claude Code, Gemini CLI, Cursor, Copilot & More
> **The Ultimate Collection of 253+ Universal Agentic Skills for AI Coding Assistants — Claude Code, Gemini CLI, Codex CLI, Antigravity IDE, GitHub Copilot, Cursor, OpenCode**
> **The Ultimate Collection of 256+ Universal Agentic Skills for AI Coding Assistants — Claude Code, Gemini CLI, Codex CLI, Antigravity IDE, GitHub Copilot, Cursor, OpenCode**
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![Claude Code](https://img.shields.io/badge/Claude%20Code-Anthropic-purple)](https://claude.ai)
@@ -11,7 +11,7 @@
[![OpenCode](https://img.shields.io/badge/OpenCode-CLI-gray)](https://github.com/opencode-ai/opencode)
[![Antigravity](https://img.shields.io/badge/Antigravity-DeepMind-red)](https://github.com/sickn33/antigravity-awesome-skills)
**Antigravity Awesome Skills** is a curated, battle-tested library of **251 high-performance agentic skills** designed to work seamlessly across all major AI coding assistants:
**Antigravity Awesome Skills** is a curated, battle-tested library of **256 high-performance agentic skills** designed to work seamlessly across all major AI coding assistants:
- 🟣 **Claude Code** (Anthropic CLI)
- 🔵 **Gemini CLI** (Google DeepMind)
@@ -29,7 +29,7 @@ This repository provides essential skills to transform your AI assistant into a
- [🔌 Compatibility & Invocation](#compatibility--invocation)
- [📦 Features & Categories](#features--categories)
- [🎁 Curated Collections (Bundles)](#curated-collections)
- [📜 Full Skill Registry](#full-skill-registry-253253)
- [📜 Full Skill Registry](#full-skill-registry-256256)
- [🛠️ Installation](#installation)
- [🤝 How to Contribute](#how-to-contribute)
- [👥 Contributors & Credits](#credits--sources)
@@ -104,21 +104,19 @@ The repository is organized into several key areas of expertise:
| Category | Skills Count | Key Skills Included |
| :-------------------------- | :----------- | :--------------------------------------------------------------------------------------------------------------------------- |
| **🛸 Autonomous & Agentic** | **~8** | Loki Mode (Startup-in-a-box), Subagent Driven Dev, Dispatching Parallel Agents, Planning With Files, Skill Creator/Developer |
| **🔌 Integrations & APIs** | **~25** | Stripe, Firebase, Supabase, Vercel, Clerk Auth, Twilio, Discord Bot, Slack Bot, GraphQL, AWS Serverless |
| **🛡️ Cybersecurity** | **~51** | Ethical Hacking, Metasploit, Burp Suite, SQLMap, Active Directory, AWS/Cloud Pentesting, OWASP Top 100, Red Team Tools |
| **🎨 Creative & Design** | **~10** | UI/UX Pro Max, Frontend Design, Canvas, Algorithmic Art, Theme Factory, D3 Viz, Web Artifacts |
| **🛠️ Development** | **~33** | TDD, Systematic Debugging, React Patterns, Backend/Frontend Guidelines, Senior Fullstack, Software Architecture |
| **🏗️ Infrastructure & Git** | **~8** | Linux Shell Scripting, Git Worktrees, Git Pushing, Conventional Commits, File Organization, GitHub Workflow Automation |
| **🤖 AI Agents & LLM** | **~31** | LangGraph, CrewAI, Langfuse, RAG Engineer, Prompt Engineer, Voice Agents, Browser Automation, Agent Memory Systems |
| **🔄 Workflow & Planning** | **~6** | Writing Plans, Executing Plans, Concise Planning, Verification Before Completion, Code Review (Requesting/Receiving) |
| **📄 Document Processing** | **~4** | DOCX (Official), PDF (Official), PPTX (Official), XLSX (Official) |
| **🧪 Testing & QA** | **~4** | Webapp Testing, Playwright Automation, Test Fixing, Testing Patterns |
| **📈 Product & Strategy** | **~8** | Product Manager Toolkit, Content Creator, ASO, Doc Co-authoring, Brainstorming, Internal Comms |
| **📣 Marketing & Growth** | **~23** | Page CRO, Copywriting, SEO Audit, Paid Ads, Email Sequence, Pricing Strategy, Referral Program, Launch Strategy |
| **🚀 Maker Tools** | **~11** | Micro-SaaS Launcher, Browser Extension Builder, Telegram Bot, AI Wrapper Product, Viral Generator, 3D Web Experience |
---
| **🛸 Autonomous & Agentic** | **(13)** | Loki Mode (Startup-in-a-box), Subagent Driven Dev, Dispatching Parallel Agents, Planning With Files, Skill Creator/Developer |
| **🔌 Integrations & APIs** | **(35)** | Stripe, Firebase, Supabase, Vercel, Clerk Auth, Twilio, Discord Bot, Slack Bot, GraphQL, AWS Serverless |
| **🛡️ Cybersecurity** | **(32)** | Ethical Hacking, Metasploit, Burp Suite, SQLMap, Active Directory, AWS/Cloud Pentesting, OWASP Top 100, Red Team Tools |
| **🎨 Creative & Design** | **(21)** | UI/UX Pro Max, Frontend Design, Canvas, Algorithmic Art, Theme Factory, D3 Viz, Web Artifacts |
| **🛠️ Development** | **(44)** | TDD, Systematic Debugging, React Patterns, Backend/Frontend Guidelines, Senior Fullstack, Software Architecture |
| **🏗️ Infrastructure & Git** | **(13)** | Linux Shell Scripting, Git Worktrees, Git Pushing, Conventional Commits, File Organization, GitHub Workflow Automation |
| **🤖 AI Agents & LLM** | **(27)** | Voice AI Engine, LangGraph, CrewAI, Langfuse, RAG Engineer, Prompt Engineer, Browser Automation, Agent Memory Systems |
| **🔄 Workflow & Planning** | **(19)** | Writing Plans, Executing Plans, Concise Planning, Verification Before Completion, Code Review (Requesting/Receiving) |
| **📄 Document Processing** | **(5)** | DOCX (Official), PDF (Official), PPTX (Official), XLSX (Official) |
| **🧪 Testing & QA** | **(8)** | Webapp Testing, Playwright Automation, Test Fixing, Testing Patterns |
| **📈 Product & Strategy** | **(4)** | Product Manager Toolkit, Content Creator, ASO, Doc Co-authoring, Brainstorming, Internal Comms |
| **📣 Marketing & Growth** | **(26)** | Page CRO, Copywriting, SEO Audit, Paid Ads, Email Sequence, Pricing Strategy, Referral Program, Launch Strategy |
| **🚀 Maker Tools** | **(8)** | Micro-SaaS Launcher, Browser Extension Builder, Telegram Bot, AI Wrapper Product, Viral Generator, 3D Web Experience |
## Curated Collections
@@ -128,7 +126,7 @@ The repository is organized into several key areas of expertise:
[Check out our Starter Packs in docs/BUNDLES.md](docs/BUNDLES.md) to find the perfect toolkit for your role.
## Full Skill Registry (253/253)
## Full Skill Registry (256/256)
> [!NOTE] > **Document Skills**: We provide both **community** and **official Anthropic** versions for DOCX, PDF, PPTX, and XLSX. Locally, the official versions are used by default (via symlinks). In the repository, both versions are available for flexibility.
@@ -206,6 +204,7 @@ The repository is organized into several key areas of expertise:
| **crewai** | ⚪ | Expert in CrewAI - the leading role-based multi-agent framework used by 60% of Fortune 500 companies. Covers agent design with roles and goals, task definition, crew orchestration, process types (sequential, hierarchical, parallel), memory systems, and flows for complex workflows. Essential for building collaborative AI agent teams. Use when: crewai, multi-agent team, agent roles, crew of agents, role-based agents. | `skills/crewai` |
| **Cross-Site Scripting and HTML Injection Testing** | ⚪ | This skill should be used when the user asks to "test for XSS vulnerabilities", "perform cross-site scripting attacks", "identify HTML injection flaws", "exploit client-side injection vulnerabilities", "steal cookies via XSS", or "bypass content security policies". It provides comprehensive techniques for detecting, exploiting, and understanding XSS and HTML injection attack vectors in web applications. | `skills/xss-html-injection` |
| **d3-viz** | ⚪ | Creating interactive data visualisations using d3.js. This skill should be used when creating custom charts, graphs, network diagrams, geographic visualisations, or any complex SVG-based data visualisation that requires fine-grained control over visual elements, transitions, or interactions. Use this for bespoke visualisations beyond standard charting libraries, whether in React, Vue, Svelte, vanilla JavaScript, or any other environment. | `skills/claude-d3js-skill` |
| **daily-news-report** | ⚪ | 基于预设 URL 列表抓取内容,筛选高质量技术信息并生成每日 Markdown 报告。 | `skills/daily-news-report` |
| **database-design** | ⚪ | Database design principles and decision-making. Schema design, indexing strategy, ORM selection, serverless databases. | `skills/database-design` |
| **deployment-procedures** | ⚪ | Production deployment principles and decision-making. Safe deployment workflows, rollback strategies, and verification. Teaches thinking, not scripts. | `skills/deployment-procedures` |
| **design-orchestration** | ⚪ | Orchestrates design workflows by routing work through brainstorming, multi-agent review, and execution readiness in the correct order. Prevents premature implementation, skipped validation, and unreviewed high-risk designs. | `skills/design-orchestration` |
@@ -253,6 +252,7 @@ The repository is organized into several key areas of expertise:
| **kaizen** | ⚪ | Guide for continuous improvement, error proofing, and standardization. Use this skill when the user wants to improve code quality, refactor, or discuss process improvements. | `skills/kaizen` |
| **langfuse** | ⚪ | Expert in Langfuse - the open-source LLM observability platform. Covers tracing, prompt management, evaluation, datasets, and integration with LangChain, LlamaIndex, and OpenAI. Essential for debugging, monitoring, and improving LLM applications in production. Use when: langfuse, llm observability, llm tracing, prompt management, llm evaluation. | `skills/langfuse` |
| **langgraph** | ⚪ | Expert in LangGraph - the production-grade framework for building stateful, multi-actor AI applications. Covers graph construction, state management, cycles and branches, persistence with checkpointers, human-in-the-loop patterns, and the ReAct agent pattern. Used in production at LinkedIn, Uber, and 400+ companies. This is LangChain's recommended approach for building agents. Use when: langgraph, langchain agent, stateful agent, agent graph, react agent. | `skills/langgraph` |
| **last30days** | ⚪ | Research a topic from the last 30 days on Reddit + X + Web, become an expert, and write copy-paste-ready prompts for the user's target tool. | `skills/last30days` |
| **launch-strategy** | ⚪ | When the user wants to plan a product launch, feature announcement, or release strategy. Also use when the user mentions 'launch,' 'Product Hunt,' 'feature release,' 'announcement,' 'go-to-market,' 'beta launch,' 'early access,' 'waitlist,' or 'product update.' This skill covers phased launches, channel strategy, and ongoing launch momentum. | `skills/launch-strategy` |
| **lint-and-validate** | ⚪ | Automatic quality control, linting, and static analysis procedures. Use after every code modification to ensure syntax correctness and project standards. Triggers onKeywords: lint, format, check, validate, types, static analysis. | `skills/lint-and-validate` |
| **Linux Privilege Escalation** | ⚪ | This skill should be used when the user asks to "escalate privileges on Linux", "find privesc vectors on Linux systems", "exploit sudo misconfigurations", "abuse SUID binaries", "exploit cron jobs for root access", "enumerate Linux systems for privilege escalation", or "gain root access from low-privilege shell". It provides comprehensive techniques for identifying and exploiting privilege escalation paths on Linux systems. | `skills/linux-privilege-escalation` |
@@ -372,6 +372,7 @@ The repository is organized into several key areas of expertise:
| **viral-generator-builder** | ⚪ | Expert in building shareable generator tools that go viral - name generators, quiz makers, avatar creators, personality tests, and calculator tools. Covers the psychology of sharing, viral mechanics, and building tools people can't resist sharing with friends. Use when: generator tool, quiz maker, name generator, avatar creator, viral tool. | `skills/viral-generator-builder` |
| **voice-agents** | ⚪ | Voice agents represent the frontier of AI interaction - humans speaking naturally with AI systems. The challenge isn't just speech recognition and synthesis, it's achieving natural conversation flow with sub-800ms latency while handling interruptions, background noise, and emotional nuance. This skill covers two architectures: speech-to-speech (OpenAI Realtime API, lowest latency, most natural) and pipeline (STT→LLM→TTS, more control, easier to debug). Key insight: latency is the constraint. Hu | `skills/voice-agents` |
| **voice-ai-development** | ⚪ | Expert in building voice AI applications - from real-time voice agents to voice-enabled apps. Covers OpenAI Realtime API, Vapi for voice agents, Deepgram for transcription, ElevenLabs for synthesis, LiveKit for real-time infrastructure, and WebRTC fundamentals. Knows how to build low-latency, production-ready voice experiences. Use when: voice ai, voice agent, speech to text, text to speech, realtime voice. | `skills/voice-ai-development` |
| **voice-ai-engine-development** | ⚪ | Build real-time conversational AI voice engines using async worker pipelines, streaming transcription, LLM agents, and TTS synthesis with interrupt handling and multi-provider support | `skills/voice-ai-engine-development` |
| **vr-ar** | ⚪ | VR/AR development principles. Comfort, interaction, performance requirements. | `skills/game-development/vr-ar` |
| **vulnerability-scanner** | ⚪ | Advanced vulnerability analysis principles. OWASP 2025, Supply Chain Security, attack surface mapping, risk prioritization. | `skills/vulnerability-scanner` |
| **web-artifacts-builder** | ⚪ | Suite of tools for creating elaborate, multi-component claude.ai HTML artifacts using modern frontend web technologies (React, Tailwind CSS, shadcn/ui). Use for complex artifacts requiring state management, routing, or shadcn/ui components - not for simple single-file HTML/JSX artifacts. | `skills/web-artifacts-builder` |
@@ -462,6 +463,8 @@ This collection would not be possible without the incredible work of the Claude
- **[vudovn/antigravity-kit](https://github.com/vudovn/antigravity-kit)**: AI Agent templates with Skills, Agents, and Workflows (33 skills, MIT).
- **[affaan-m/everything-claude-code](https://github.com/affaan-m/everything-claude-code)**: Complete Claude Code configuration collection from Anthropic hackathon winner - skills only (8 skills, MIT).
- **[webzler/agentMemory](https://github.com/webzler/agentMemory)**: Source for the agent-memory-mcp skill.
- **[mvanhorn](https://github.com/mvanhorn)**: Contributor of `last30days`.
- **[rookie-ricardo](https://github.com/rookie-ricardo)**: Contributor of `daily-news-report`.
### Inspirations
@@ -483,7 +486,7 @@ MIT License. See [LICENSE](LICENSE) for details.
---
## 🏷️ GitHub Topics
## GitHub Topics
For repository maintainers, add these topics to maximize discoverability:
@@ -518,6 +521,13 @@ We officially thank the following contributors for their help in making this rep
- [zebbern](https://github.com/zebbern)
- [vuth-dogo](https://github.com/vuth-dogo)
## 👥 Repo Contributors
Thanks to the people who have contributed to this project:
- [@sickn33](https://github.com/sickn33) - Voice AI Engine (PR #33)
- [@community](https://github.com/community) - Categorization Initiative (PR #32)
## Star History
[![Star History Chart](https://api.star-history.com/svg?repos=sickn33/antigravity-awesome-skills&type=date&legend=top-left)](https://www.star-history.com/#sickn33/antigravity-awesome-skills&type=date&legend=top-left)

View File

@@ -0,0 +1,357 @@
---
name: daily-news-report
description: 基于预设 URL 列表抓取内容,筛选高质量技术信息并生成每日 Markdown 报告。
argument-hint: [可选: 日期]
disable-model-invocation: false
user-invocable: true
allowed-tools: Task, WebFetch, Read, Write, Bash(mkdir*), Bash(date*), Bash(ls*), mcp__chrome-devtools__*
---
# Daily News Report v3.0
> **架构升级**:主 Agent 调度 + SubAgent 执行 + 浏览器抓取 + 智能缓存
## 核心架构
```
┌─────────────────────────────────────────────────────────────────────┐
│ 主 Agent (Orchestrator) │
│ 职责:调度、监控、评估、决策、汇总 │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ 1. 初始化 │ → │ 2. 调度 │ → │ 3. 监控 │ → │ 4. 评估 │ │
│ │ 读取配置 │ │ 分发任务 │ │ 收集结果 │ │ 筛选排序 │ │
│ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │
│ │ │ │ │ │
│ ▼ ▼ ▼ ▼ │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ 5. 决策 │ ← │ 够20条 │ │ 6. 生成 │ → │ 7. 更新 │ │
│ │ 继续/停止 │ │ Y/N │ │ 日报文件 │ │ 缓存统计 │ │
│ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │
│ │
└──────────────────────────────────────────────────────────────────────┘
↓ 调度 ↑ 返回结果
┌─────────────────────────────────────────────────────────────────────┐
│ SubAgent 执行层 │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ Worker A │ │ Worker B │ │ Browser │ │
│ │ (WebFetch) │ │ (WebFetch) │ │ (Headless) │ │
│ │ Tier1 Batch │ │ Tier2 Batch │ │ JS渲染页面 │ │
│ └─────────────┘ └─────────────┘ └─────────────┘ │
│ ↓ ↓ ↓ │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ 结构化结果返回 │ │
│ │ { status, data: [...], errors: [...], metadata: {...} } │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────┘
```
## 配置文件
本 Skill 使用以下配置文件:
| 文件 | 用途 |
|------|------|
| `sources.json` | 信息源配置、优先级、抓取方法 |
| `cache.json` | 缓存数据、历史统计、去重指纹 |
## 执行流程详解
### Phase 1: 初始化
```yaml
步骤:
1. 确定日期(用户参数或当前日期)
2. 读取 sources.json 获取源配置
3. 读取 cache.json 获取历史数据
4. 创建输出目录 NewsReport/
5. 检查今日是否已有部分报告(追加模式)
```
### Phase 2: 调度 SubAgent
**策略**:并行调度,分批执行,早停机制
```yaml
第1波 (并行):
- Worker A: Tier1 Batch A (HN, HuggingFace Papers)
- Worker B: Tier1 Batch B (OneUsefulThing, Paul Graham)
等待结果 → 评估数量
如果 < 15 条高质量:
第2波 (并行):
- Worker C: Tier2 Batch A (James Clear, FS Blog)
- Worker D: Tier2 Batch B (HackerNoon, Scott Young)
如果仍 < 20 条:
第3波 (浏览器):
- Browser Worker: ProductHunt, Latent Space (需要JS渲染)
```
### Phase 3: SubAgent 任务格式
每个 SubAgent 接收的任务格式:
```yaml
task: fetch_and_extract
sources:
- id: hn
url: https://news.ycombinator.com
extract: top_10
- id: hf_papers
url: https://huggingface.co/papers
extract: top_voted
output_schema:
items:
- source_id: string # 来源标识
title: string # 标题
summary: string # 2-4句摘要
key_points: string[] # 最多3个要点
url: string # 原文链接
keywords: string[] # 关键词
quality_score: 1-5 # 质量评分
constraints:
filter: "前沿技术/高深技术/提效技术/实用资讯"
exclude: "泛科普/营销软文/过度学术化/招聘帖"
max_items_per_source: 10
skip_on_error: true
return_format: JSON
```
### Phase 4: 主 Agent 监控与反馈
主 Agent 职责:
```yaml
监控:
- 检查 SubAgent 返回状态 (success/partial/failed)
- 统计收集到的条目数量
- 记录每个源的成功率
反馈循环:
- 如果某 SubAgent 失败,决定是否重试或跳过
- 如果某源持续失败,标记为禁用
- 动态调整后续批次的源选择
决策:
- 条目数 >= 25 且高质量 >= 20 → 停止抓取
- 条目数 < 15 → 继续下一批
- 所有批次完成但 < 20 → 用现有内容生成(宁缺毋滥)
```
### Phase 5: 评估与筛选
```yaml
去重:
- 基于 URL 完全匹配
- 基于标题相似度 (>80% 视为重复)
- 检查 cache.json 避免与历史重复
评分校准:
- 统一各 SubAgent 的评分标准
- 根据来源可信度调整权重
- 手动标注的高质量源加分
排序:
- 按 quality_score 降序
- 同分按来源优先级排序
- 截取 Top 20
```
### Phase 6: 浏览器抓取 (MCP Chrome DevTools)
对于需要 JS 渲染的页面,使用无头浏览器:
```yaml
流程:
1. 调用 mcp__chrome-devtools__new_page 打开页面
2. 调用 mcp__chrome-devtools__wait_for 等待内容加载
3. 调用 mcp__chrome-devtools__take_snapshot 获取页面结构
4. 解析 snapshot 提取所需内容
5. 调用 mcp__chrome-devtools__close_page 关闭页面
适用场景:
- ProductHunt (403 on WebFetch)
- Latent Space (Substack JS 渲染)
- 其他 SPA 应用
```
### Phase 7: 生成日报
```yaml
输出:
- 目录: NewsReport/
- 文件名: YYYY-MM-DD-news-report.md
- 格式: 标准 Markdown
内容结构:
- 标题 + 日期
- 统计摘要(源数量、收录数量)
- 20条高质量内容按模板
- 生成信息(版本、时间戳)
```
### Phase 8: 更新缓存
```yaml
更新 cache.json:
- last_run: 记录本次运行信息
- source_stats: 更新各源统计数据
- url_cache: 添加已处理的 URL
- content_hashes: 添加内容指纹
- article_history: 记录收录文章
```
## SubAgent 调用示例
### 使用 general-purpose Agent
由于自定义 agent 需要 session 重启才能发现,可以使用 general-purpose 并注入 worker prompt
```
Task 调用:
subagent_type: general-purpose
model: haiku
prompt: |
你是一个无状态的执行单元。只做被分配的任务,返回结构化 JSON。
任务:抓取以下 URL 并提取内容
URLs:
- https://news.ycombinator.com (提取 Top 10)
- https://huggingface.co/papers (提取高投票论文)
输出格式:
{
"status": "success" | "partial" | "failed",
"data": [
{
"source_id": "hn",
"title": "...",
"summary": "...",
"key_points": ["...", "...", "..."],
"url": "...",
"keywords": ["...", "..."],
"quality_score": 4
}
],
"errors": [],
"metadata": { "processed": 2, "failed": 0 }
}
筛选标准:
- 保留:前沿技术/高深技术/提效技术/实用资讯
- 排除:泛科普/营销软文/过度学术化/招聘帖
直接返回 JSON不要解释。
```
### 使用 worker Agent需重启 session
```
Task 调用:
subagent_type: worker
prompt: |
task: fetch_and_extract
input:
urls:
- https://news.ycombinator.com
- https://huggingface.co/papers
output_schema:
- source_id: string
- title: string
- summary: string
- key_points: string[]
- url: string
- keywords: string[]
- quality_score: 1-5
constraints:
filter: 前沿技术/高深技术/提效技术/实用资讯
exclude: 泛科普/营销软文/过度学术化
```
## 输出模板
```markdown
# Daily News ReportYYYY-MM-DD
> 本日筛选自 N 个信息源,共收录 20 条高质量内容
> 生成耗时: X 分钟 | 版本: v3.0
>
> **Warning**: Sub-agent 'worker' not detected. Running in generic mode (Serial Execution). Performance might be degraded.
> **警告**:未检测到 Sub-agent 'worker'。正在以通用模式(串行执行)运行。性能可能会受影响。
---
## 1. 标题
- **摘要**2-4 行概述
- **要点**
1. 要点一
2. 要点二
3. 要点三
- **来源**[链接](URL)
- **关键词**`keyword1` `keyword2` `keyword3`
- **评分**:⭐⭐⭐⭐⭐ (5/5)
---
## 2. 标题
...
---
*Generated by Daily News Report v3.0*
*Sources: HN, HuggingFace, OneUsefulThing, ...*
```
## 约束与原则
1. **宁缺毋滥**:低质量内容不进入日报
2. **早停机制**:够 20 条高质量就停止抓取
3. **并行优先**:同一批次的 SubAgent 并行执行
4. **失败容错**:单个源失败不影响整体流程
5. **缓存复用**:避免重复抓取相同内容
6. **主 Agent 控制**:所有决策由主 Agent 做出
7. **Fallback Awareness**:检测 sub-agent 可用性,不可用时优雅降级
## 预期性能
| 场景 | 预期时间 | 说明 |
|------|----------|------|
| 最优情况 | ~2 分钟 | Tier1 足够,无需浏览器 |
| 正常情况 | ~3-4 分钟 | 需要 Tier2 补充 |
| 需要浏览器 | ~5-6 分钟 | 包含 JS 渲染页面 |
## 错误处理
| 错误类型 | 处理方式 |
|----------|----------|
| SubAgent 超时 | 记录错误,继续下一个 |
| 源 403/404 | 标记禁用,更新 sources.json |
| 内容提取失败 | 返回原始内容,主 Agent 决定 |
| 浏览器崩溃 | 跳过该源,记录日志 |
## 兼容性与兜底 (Compatibility & Fallback)
为了确保在不同 Agent 环境下的可用性,必须执行以下检查:
1. **环境检查**:
- 在 Phase 1 初始化阶段,尝试检测 `worker` sub-agent 是否存在。
- 如果不存在(或未安装相关插件),自动切换到 **串行执行模式 (Serial Mode)**
2. **串行执行模式**:
- 不使用 parallel block。
- 主 Agent 依次执行每个源的抓取任务。
- 虽然速度较慢,但保证基本功能可用。
3. **用户提示**:
- 必须在生成的日报开头(引用块部分)包含明显的警告信息,提示用户当前正在运行于降级模式。

View File

@@ -0,0 +1,41 @@
{
"schema_version": "1.0",
"description": "Daily News Report 缓存文件,用于避免重复抓取和跟踪历史表现",
"last_run": {
"date": "2026-01-21",
"duration_seconds": 180,
"items_collected": 20,
"items_published": 20,
"sources_used": ["hn", "hf_papers", "james_clear", "fs_blog", "scotthyoung"]
},
"source_stats": {
"_comment": "记录每个源的历史表现,用于动态调整优先级",
"hn": {
"total_fetches": 0,
"success_count": 0,
"avg_items_per_fetch": 0,
"avg_quality_score": 0,
"last_fetch": null,
"last_success": null
}
},
"url_cache": {
"_comment": "已处理的 URL 缓存,避免重复收录",
"_ttl_hours": 168,
"entries": {}
},
"content_hashes": {
"_comment": "内容指纹,用于去重",
"_ttl_hours": 168,
"entries": {}
},
"article_history": {
"_comment": "已收录文章的简要记录",
"2026-01-21": []
}
}

View File

@@ -0,0 +1,183 @@
{
"version": "2.1",
"last_updated": "2026-01-21",
"sources": {
"tier1": {
"description": "高命中率源,优先抓取",
"batch_a": [
{
"id": "hn",
"name": "Hacker News",
"url": "https://news.ycombinator.com",
"fetch_method": "webfetch",
"extract": "top_10",
"enabled": true,
"avg_quality": 4.5,
"success_rate": 0.95
},
{
"id": "hf_papers",
"name": "HuggingFace Papers",
"url": "https://huggingface.co/papers",
"fetch_method": "webfetch",
"extract": "top_voted",
"enabled": true,
"avg_quality": 4.8,
"success_rate": 0.98
}
],
"batch_b": [
{
"id": "one_useful_thing",
"name": "One Useful Thing",
"url": "https://www.oneusefulthing.org",
"fetch_method": "webfetch",
"extract": "latest_3",
"enabled": true,
"avg_quality": 4.7,
"success_rate": 0.92
},
{
"id": "paul_graham",
"name": "Paul Graham Essays",
"url": "https://paulgraham.com/articles.html",
"fetch_method": "webfetch",
"extract": "latest_5",
"enabled": true,
"avg_quality": 4.6,
"success_rate": 0.99
}
]
},
"tier2": {
"description": "中等命中率,按需抓取",
"batch_a": [
{
"id": "james_clear",
"name": "James Clear 3-2-1",
"url": "https://jamesclear.com/3-2-1",
"fetch_method": "webfetch",
"extract": "latest_issue",
"enabled": true,
"avg_quality": 4.3,
"success_rate": 0.90
},
{
"id": "fs_blog",
"name": "Farnam Street Brain Food",
"url": "https://fs.blog/brain-food",
"fetch_method": "webfetch",
"extract": "latest_issue",
"enabled": true,
"avg_quality": 4.4,
"success_rate": 0.88
}
],
"batch_b": [
{
"id": "hackernoon_pm",
"name": "HackerNoon PM",
"url": "https://hackernoon.com/c/product-management",
"fetch_method": "webfetch",
"extract": "latest_5",
"enabled": true,
"avg_quality": 3.8,
"success_rate": 0.85
},
{
"id": "scotthyoung",
"name": "Scott Young Blog",
"url": "https://scotthyoung.com/blog/articles",
"fetch_method": "webfetch",
"extract": "latest_3",
"enabled": true,
"avg_quality": 4.0,
"success_rate": 0.90
}
]
},
"tier3_browser": {
"description": "需要浏览器渲染的源",
"sources": [
{
"id": "producthunt",
"name": "Product Hunt",
"url": "https://www.producthunt.com",
"fetch_method": "browser",
"extract": "today_top_5",
"enabled": true,
"avg_quality": 4.2,
"success_rate": 0.75,
"note": "需要无头浏览器403 on WebFetch"
},
{
"id": "latent_space",
"name": "Latent Space",
"url": "https://www.latent.space",
"fetch_method": "browser",
"extract": "latest_3",
"enabled": true,
"avg_quality": 4.6,
"success_rate": 0.70,
"note": "Substack 需要 JS 渲染"
}
]
},
"disabled": {
"description": "已禁用的源(失效或低质量)",
"sources": [
{
"id": "tldr_ai",
"name": "TLDR AI",
"url": "https://tldr.tech/ai",
"reason": "订阅页面,无文章列表",
"disabled_date": "2026-01-21"
},
{
"id": "bensbites",
"name": "Ben's Bites",
"url": "https://bensbites.com/archive",
"reason": "需要登录/付费墙",
"disabled_date": "2026-01-21"
},
{
"id": "interconnects",
"name": "Interconnects AI",
"url": "https://interconnects.ai",
"reason": "内容提取失败Substack 结构问题",
"disabled_date": "2026-01-21"
},
{
"id": "beehiiv_rss",
"name": "Beehiiv RSS feeds",
"url": "https://rss.beehiiv.com",
"reason": "RSS 抓取困难",
"disabled_date": "2026-01-21"
}
]
}
},
"fetch_config": {
"webfetch": {
"timeout_ms": 30000,
"retry_count": 1,
"cache_ttl_minutes": 60
},
"browser": {
"timeout_ms": 45000,
"wait_for_selector": "article, .post, .item",
"screenshot_on_error": true
}
},
"quality_thresholds": {
"min_score_to_include": 3,
"target_items": 20,
"early_stop_threshold": 25
}
}

721
skills/last30days/README.md Normal file
View File

@@ -0,0 +1,721 @@
# /last30days
**The AI world reinvents itself every month. This Claude Code skill keeps you current.** /last30days researches your topic across Reddit, X, and the web from the last 30 days, finds what the community is actually upvoting and sharing, and writes you a prompt that works today, not six months ago. Whether it's Ralph Wiggum loops, Suno music prompts, or the latest Midjourney techniques, you'll prompt like someone who's been paying attention.
**Best for prompt research**: discover what prompting techniques actually work for any tool (ChatGPT, Midjourney, Claude, Figma AI, etc.) by learning from real community discussions and best practices.
**But also great for anything trending**: music, culture, news, product recommendations, viral trends, or any question where "what are people saying right now?" matters.
## Installation
```bash
# Clone the repo
git clone https://github.com/mvanhorn/last30days-skill.git ~/.claude/skills/last30days
# Add your API keys
mkdir -p ~/.config/last30days
cat > ~/.config/last30days/.env << 'EOF'
OPENAI_API_KEY=sk-...
XAI_API_KEY=xai-...
EOF
chmod 600 ~/.config/last30days/.env
```
## Usage
```
/last30days [topic]
/last30days [topic] for [tool]
```
Examples:
- `/last30days prompting techniques for ChatGPT for legal questions`
- `/last30days iOS app mockups for Nano Banana Pro`
- `/last30days What are the best rap songs lately`
- `/last30days remotion animations for Claude Code`
## What It Does
1. **Researches** - Scans Reddit and X for discussions from the last 30 days
2. **Synthesizes** - Identifies patterns, best practices, and what actually works
3. **Delivers** - Either writes copy-paste-ready prompts for your target tool, or gives you a curated expert-level answer
### Use it for:
- **Prompt research** - "What prompting techniques work for legal questions in ChatGPT?"
- **Tool best practices** - "How are people using Remotion with Claude Code?"
- **Trend discovery** - "What are the best rap songs right now?"
- **Product research** - "What do people think of the new M4 MacBook?"
- **Viral content** - "What's the dog-as-human trend on ChatGPT?"
---
## Example: Legal Prompting (Hallucination Prevention)
**Query:** `/last30days prompting techniques for chatgpt for legal questions`
**Research Output:**
> The dominant theme is hallucination prevention - multiple sources discuss lawyers being fined or embarrassed for submitting fake case citations that ChatGPT fabricated. Key prompting strategies include: (1) using Deep Research mode or grounding tools with uploaded primary sources, (2) building "Hallucination Prevention Systems" into prompts that force epistemic honesty, (3) prompting for procedural questions rather than case law lookups, and (4) framing outputs as "issue-spotting" rather than legal advice. A major warning: chat logs are discoverable in litigation.
**Key patterns discovered:**
1. Hallucination prevention clauses - "If you don't know a case citation, say so rather than inventing one"
2. Role assignment - assign ChatGPT a specific persona (paralegal, issue-spotter) to constrain outputs
3. Structured output requirements - request specific sections (risks, key terms, questions for counsel)
4. Epistemic humility enforcement - "acknowledge uncertainty" and "flag when you're unsure"
5. Scope limitation - focus on procedural questions, not case law research
**Then asked:** "I have a squatter in my house in San Francisco, what are my options to get them out?"
**Generated Prompt:**
```
Role: You are a legal research assistant helping a property owner understand
their options. You are NOT providing legal advice - you are helping me
identify issues, understand procedures, and prepare informed questions for an
attorney.
Situation: I own a residential property in San Francisco, California. Someone
is currently occupying the property without my permission or any lease
agreement (a squatter). I need to understand my legal options for removing them.
Your task: Provide a structured analysis covering:
1. California/SF Legal Framework - What laws govern this situation? (Note: San
Francisco has strong tenant protections - address how these apply or don't
apply to squatters vs. tenants)
2. Key Distinctions - Help me understand: squatter vs. trespasser vs. holdover
tenant. How does the length of occupation affect my options?
3. Available Legal Remedies - What are my procedural options? (unlawful
detainer, police involvement, etc.) What is the typical process and timeline?
4. Critical Mistakes to Avoid - What actions could expose me to liability?
(self-help eviction, utility shutoffs, etc.)
5. Questions I Should Ask an Attorney - Based on this analysis, what specific
questions should I bring to a California real estate attorney?
Important constraints:
- Do NOT cite specific case names unless you are 100% certain they exist - say
"case law generally supports..." instead
- Flag any areas where you're uncertain
- Note where San Francisco local ordinances may differ from California state law
- This is for informational purposes to prepare for a legal consultation
```
**The Result:** A comprehensive 2,500+ word analysis covering California's unlawful detainer process, SF Rent Ordinance nuances, trespasser vs. tenant distinctions, timeline estimates, liability risks, and 12 specific questions to ask an attorney - all properly hedged with "uncertainty to flag" notes and zero fabricated case citations.
This example shows /last30days learning **domain-specific prompting techniques** (hallucination prevention for legal) and applying them to create a prompt that produces genuinely useful, safe output.
---
## Example: ClawdBot Use Cases (Community Research)
**Query:** `/last30days best clawdbot use cases`
**Research Output:**
> ClawdBot is a self-hosted AI assistant that runs on your devices and connects via messaging apps (Telegram, WhatsApp, Discord, Slack, iMessage). Unlike ChatGPT/Claude, it has persistent memory and can proactively message you. The killer feature is its 24/7 autonomous operation - it handles multi-day tasks, monitors for replies, and initiates contact.
**Most mentioned use cases:**
🏆 Top 5 by engagement:
1. **Email/Calendar automation** - mentioned 8x+ (r/selfhosted, @dreetje, @danpeguine, MacStories)
- Morning briefings, spam filtering, auto-replies, recruiter declines
2. **Task management via chat** - mentioned 6x (r/LocalLLaMA, @danpeguine, multiple X posts)
- Todoist integration, importance scoring, time-blocking tasks
3. **Overnight coding agent** - mentioned 5x (@bffmike 225 likes, r/LocalLLaMA)
- "Drove my coding agents from 12:30-7am while I snoozed"
- Pulls repos, runs tests, commits if clean
4. **Smart home + life admin** - mentioned 5x (r/selfhosted, VelvetShark)
- WHOOP health data, Philips Hue, HomeAssistant, SSH to homelab
5. **Browser automation** - mentioned 4x (@steipete 243 likes, MacStories)
- Car negotiation via email/PDF, restaurant reservations via ElevenLabs calls
**Notable single mentions:**
- Custom meditation generation with TTS + ambient audio
- Built a full website (Stumblereads.com) from phone while putting baby to sleep
- Ray-Ban Meta glasses integration
- WhatsApp as control interface for everything
**Research Stats:** 9 Reddit threads (161 upvotes, 95 comments) + 19 X posts (2,018 likes, 96 reposts) from r/LocalLLaMA, r/selfhosted, @steipete, @danpeguine, @dreetje
This example shows /last30days as a **product research tool** - discovering real-world use cases and community sentiment for any tool or product, complete with engagement metrics that tell you what people actually care about.
---
## Example: ClawdBot Setup (Installation Guide)
**Query:** `/last30days how to best setup clawdbot`
**Research Output:**
> ClawdBot setup varies by deployment style - Docker is the most recommended path, but users are also deploying via bare metal and cloud VMs. The community has converged on key best practices: use the setup wizard for initial config, start with conservative hardware (you can scale up), and configure authentication before exposing any endpoints.
**🛠️ Setup patterns by engagement:**
| Pattern | Mentions | Key Sources |
|---------|----------|-------------|
| Docker Compose deployment | 6x | r/selfhosted, r/LocalLLaMA |
| Setup wizard (interactive config) | 5x | @dreetje, documentation |
| API key management | 4x | r/selfhosted, security threads |
| Hardware requirements | 4x | r/LocalLLaMA (GPU discussions) |
| Reverse proxy (Caddy/nginx) | 3x | r/selfhosted |
**Key setup insights from community:**
1. **Use the setup wizard** - Don't manually edit config files first; the wizard handles 90% of initial setup
2. **Start small on hardware** - 8GB RAM minimum, but 16GB+ recommended if running local models
3. **Docker > bare metal** - Easier updates, better isolation, community-tested compose files
4. **Secure before exposing** - Set up authentication before opening ports; ClawdBot has built-in auth
5. **Cost management** - Set API rate limits early; one user reported $200 overnight bill from runaway tasks
**Quick-start commands (community-verified):**
```bash
# Clone and setup
git clone https://github.com/clawdbot/clawdbot.git
cd clawdbot
# Run setup wizard (recommended)
./setup.sh
# Or Docker Compose (after config)
docker compose up -d
```
**Common gotchas mentioned:**
- Don't forget to set `CLAWDBOT_API_KEY` before first run
- Telegram bot token needs BotFather setup first
- If using local models, ensure CUDA drivers are installed
**Research Stats:** 8 Reddit threads (128 upvotes) + 22 X posts (24,000+ likes) from r/selfhosted, r/LocalLLaMA, @dreetje, @steipete
This example shows /last30days as a **setup guide aggregator** - pulling together scattered installation advice, gotchas, and best practices from real users who've already solved the problems you're about to encounter.
---
## Example: iOS App Mockup (Nano Banana Pro)
**Query:** `/last30days prompting tips for nano banana pro for ios designs`
**Research Output:**
> The community has converged on JSON-structured prompts for UI work - multiple viral posts from @brindleyai, @bananababydoll, and @fofrAI use detailed JSON with nested objects for subject, scene, and style. The key workflow from @fofrAI: take an existing image, have Gemini generate a JSON prompt from it, then iterate. For iOS specifically, @paji_a and @chongdashu both documented using Nano Banana Pro for iOS app mockup designs in "vibe coding" workflows.
>
> The web guides emphasize thinking in layers: define the window frame first, then UI layout (sidebars, nav bars, cards), then text on buttons/headings. Google's official prompting guide says to drop the "4k masterpiece trending on artstation" spam - Nano Banana Pro understands natural language. For tight layouts, use an 8pt grid and limit screens to 6-8 elements.
**Key patterns discovered:**
1. JSON prompt format with nested sections (subject, scene, style, device)
2. Layer-based structure - frame -> layout regions -> content -> text labels
3. iOS 18 / Material Design 3 references - rounded corners, soft shadows, vibrant primary
4. iPhone frame specification - "inside realistic iPhone 16 Pro frame"
5. Wireframe-to-mockup workflow - upload sketch, request high-fidelity conversion
6. 8pt grid + 6-8 elements max per screen for clean density
7. Iterative editing - don't re-roll, ask for specific changes if 80% correct
**Then asked:** "make a mock-up of an app for moms who swim"
**Generated Prompt:**
```json
{
"image_type": "UI mockup",
"device": {
"frame": "iPhone 16 Pro",
"display": "realistic screen with subtle reflection",
"orientation": "portrait"
},
"app_concept": "SwimMom - fitness and community app for mothers who swim",
"screen": "home dashboard",
"design_system": {
"style": "iOS 18 native",
"corners": "rounded, 16px radius",
"shadows": "soft drop shadows, subtle depth",
"spacing": "8pt grid, generous whitespace"
},
"color_palette": {
"primary": "calming aqua blue (#4ECDC4)",
"secondary": "warm coral (#FF6B6B)",
"background": "clean white with soft blue gradient header",
"accents": "pool water ripple patterns, subtle"
},
"layout": {
"header": "greeting 'Good morning, Sarah' with small wave icon, profile avatar top right",
"hero_card": "today's swim stats - laps completed, calories burned, time in pool - with circular progress ring",
"section_2": "horizontal scroll of upcoming mom swim meetups with location pills and participant avatars",
"section_3": "two cards side by side - 'My Goals' with streak counter, 'Find a Pool' with map pin icon",
"bottom_nav": "5 icons - Home (active), Workouts, Community, Schedule, Profile"
},
"visual_details": {
"illustrations": "friendly, minimal line art of swimming figure",
"typography": "SF Pro Display, clean hierarchy",
"mood": "energizing yet calm, supportive community feel"
}
}
```
**The Result:**
<p align="center">
<img src="assets/swimmom-mockup.jpeg" width="400" alt="SwimMom iOS app mockup"/>
</p>
This example shows /last30days learning **AI image generation prompting patterns** (JSON structure, layer-based thinking, iOS design system references) and applying them to create a production-quality app mockup in one shot.
---
## Example: Top Claude Code Skills (Recommendations)
**Query:** `/last30days top claude code skills`
**Research Output:**
> The Claude Code skills ecosystem has exploded with marketplaces, curated lists, and viral skill announcements. The Remotion video skill got 17.3K likes on X. SkillsMP emerged as a marketplace with 60-87K+ skills. Multiple GitHub repos (awesome-claude-skills, Superpowers) are actively curated.
**🏆 Most mentioned skills/resources:**
| Rank | Skill/Resource | Mentions | Sources | Engagement |
|------|----------------|----------|---------|------------|
| 1 | Remotion skill | 4x | X (@Remotion, @joshua_xu_), web | 17.3K likes, video creation |
| 2 | SkillsMP marketplace | 5x | X (@milesdeutscher, @rexan_wong), web | 60-87K+ skills directory |
| 3 | awesome-claude-skills (GitHub) | 4x | Web (travisvn, ComposioHQ repos) | Multiple curated lists |
| 4 | Superpowers | 3x | Web, GitHub | 27.9K stars |
| 5 | HeyGen avatar skill | 2x | X (@joshua_xu_), web | 736 likes, AI avatars |
| 6 | Trail of Bits Security Skills | 2x | Web | CodeQL/Semgrep auditing |
| 7 | Claude Command Suite | 2x | GitHub, web | 148+ commands, 54 agents |
| 8 | MCP Builder | 2x | Web | Build MCP servers |
| 9 | Test-Driven Development | 2x | Web, skill guides | Pre-implementation testing |
| 10 | Systematic Debugging | 2x | Web | Root cause analysis |
**Notable single mentions:** UI/UX Pro Max (16.9K stars), SuperClaude framework, Compound Engineering Plugin, docx/pdf/pptx document skills, Nano-Banana, Connect (1000+ app integrations)
**Marketer skill stack** (from @vibecode_edu): Slack, Email Knowledge, Nano-Banana, Notion, Skill Creator, Twitter post, Typefully
**Key insight from Reddit:** Keep CLAUDE.md short (~1K context), use Skills for "context on demand", and ensure skill descriptions contain your trigger keywords.
**Research Stats:** 4 Reddit threads (238 upvotes, 156 comments) + 15 X posts (28K+ likes, 2.8K reposts) from r/ClaudeCode, r/ClaudeAI, @Remotion, @milesdeutscher
This example shows /last30days as a **discovery tool** - finding the most popular resources in a rapidly evolving ecosystem by tracking what the community is actually sharing and upvoting.
---
## Example: Discover Viral Trends (Dog as Human)
**Query:** `/last30days using ChatGPT to make images of dogs`
**Research Output:**
> The Reddit community is obsessed with the "dog as human" trend - uploading photos of their dogs and asking ChatGPT to show what they'd look like as a person (threads with 600-900+ upvotes). Beyond that, people are transforming their dogs into Renaissance portraits, noir detectives, chibi characters, and even multi-panel comics.
**Key patterns discovered:**
1. Upload reference photo first - ChatGPT works best with your actual dog's image
2. "As a human" transformation - The viral trend
3. Style/era transformations - Renaissance, noir, chibi, cartoon
4. Scene placement - French bistro, prison yard, detective office
5. Specific visual details - Lighting, background, expression, accessories
**Then asked:** "what would my dog look like as a human prompt"
**Generated Prompt:**
```
Look at this photo of my dog. Create an image showing what they would look like
as a human person. Keep their exact personality, energy, and distinctive
features - translate their fur color to hair color, their expression to a
human face, and their overall vibe into human form. Make it a realistic
portrait photo, not a cartoon.
```
*How to use: Upload your dog's photo first, then paste this prompt.*
**The Result:**
<p align="center">
<img src="assets/dog-original.jpeg" width="300" alt="Original dog photo"/>
&nbsp;&nbsp;→&nbsp;&nbsp;
<img src="assets/dog-as-human.png" width="300" alt="Dog as human"/>
</p>
Same golden fur → red hair. Same tongue out. Same harness. Same pink tag. ChatGPT nailed it.
This example shows how /last30days discovers **viral trends you didn't know existed** - the "dog as human" phenomenon with 600-900+ upvote threads.
---
## Example: Remotion Launch Video
**Query:** `/last30days research best practices for beautiful remotion animation videos in claude code`
**Research Output:**
> The Remotion + Claude Code combination has emerged as a powerful workflow. Users consistently report that simple, clear prompts with scene-by-scene descriptions yield the best results. Key insights: iteration is essential, most "beautiful" videos come from back-and-forth refinement rather than one-shot prompts.
**Then asked:** "Can you make a prompt for a 50 second launch video for my /last30days skill?"
**Generated Prompt:**
```
Create a 50-second launch video for "/last30days" - a Claude Code skill that
researches any topic across Reddit and X from the last 30 days, then writes
copy-paste-ready prompts.
SCENE 1 (0-8s): The Problem
Dark background. Text fades in: "You want to create something great."
Beat. Then: "But the internet is overwhelming."
Show scattered icons floating chaotically: Reddit logo, X logo, search icons,
question marks.
Slight camera shake to convey chaos.
SCENE 2 (8-16s): The Solution Reveal
Clean wipe transition. Calm, modern gradient background (deep purple to blue).
"/last30days" types in with a terminal cursor, monospace font.
Subtitle fades in below: "Instant expertise. Real prompts."
SCENE 3 (16-28s): How It Works
Split into 3 animated steps that slide in sequentially:
1. [Reddit + X icons merge] "Scans Reddit & X from the last 30 days"
2. [Brain/lightbulb icon] "Synthesizes what actually works"
3. [Document with sparkle] "Writes prompts you can copy-paste"
Each step has a subtle bounce animation on entry.
SCENE 4 (28-40s): Live Demo Feel
Simulate a terminal. Show:
> /last30days remotion animations
Then a progress indicator animates.
Results appear: "Analyzed 28 sources..."
Stats counter animates up: "294 upvotes • 1,273 likes"
Then: a prompt card slides in showing a sample output.
SCENE 5 (40-50s): Call to Action
Zoom out slightly. Everything fades except the logo.
Text animates in: "Research any topic. Get prompts that work."
GitHub icon + "github.com/mvanhorn/last30days-skill"
Tagline: "A Claude Code Skill"
STYLE NOTES:
- Color palette: Deep purple (#1a1a2e), electric blue (#4361ee), white text
- Typography: Inter or SF Mono for code, clean sans-serif for headlines
- Transitions: Smooth eases, nothing jarring
- Music sync points: Scene transitions should land on beats
```
This prompt was generated based on research from 10 Reddit threads (294 upvotes) and 18 X posts (1,273 likes) about Remotion animation best practices.
---
## Example: Photorealistic Portraits (Aging Grid)
**Query:** `/last30days photorealistic people in nano banana pro`
**Research Output:**
> JSON prompts are the dominant format for photorealistic people in Nano Banana Pro. Nearly every viral X post (4,400+ likes total) uses structured JSON with specific fields rather than natural language. The key is specifying demographics, skin texture, lighting, and camera settings in a structured way.
>
> Recent caveat: Reddit reports Nano Banana Pro was "nerfed" in early January 2026 with reduced resolution and stricter moderation on faces. Starting a new chat can help if quality drops mid-session.
**Key patterns discovered:**
1. JSON structure with prompt_type, subject, lighting, camera_settings fields
2. Skin texture keywords: "natural pores", "realistic skin texture", "8K texture quality"
3. Face preservation: "preserve_original": true when using reference photos
4. Camera realism: specify lens (85mm f/1.4), depth of field, lighting setup
5. Wet skin close-ups are trending for maximum realism
6. Negative prompts: "no cartoon effect, no distortion, correct anatomy"
**Then asked:** "2x2 grid of the same person at age 10, 20, 40 and 80 - a woman with brown hair and a lot of freckles and cat eyes that are a different color"
**Generated Prompt:**
```json
{
"prompt_type": "Ultra-Photorealistic Multi-Panel Portrait",
"layout": "2x2 grid, seamless white dividers",
"aspect_ratio": "1:1",
"consistency": {
"same_person": true,
"preserve_features": ["bone structure", "freckle pattern", "eye shape", "heterochromia"]
},
"subject": {
"identity": "Same woman shown at 4 life stages",
"distinctive_features": {
"eyes": "Almond-shaped cat eyes with heterochromia - one green, one amber",
"skin": "Fair complexion densely covered in natural freckles across nose, cheeks, and forehead",
"hair": "Brown hair, natural texture"
}
},
"panels": [
{"position": "top-left", "age": 10, "expression": "curious childhood smile", "hair": "long brown hair with bangs"},
{"position": "top-right", "age": 20, "expression": "confident young adult", "hair": "shoulder-length brown hair"},
{"position": "bottom-left", "age": 40, "expression": "warm maternal gaze", "hair": "brown hair with subtle highlights"},
{"position": "bottom-right", "age": 80, "expression": "wise gentle smile", "hair": "silver-gray, formerly brown"}
],
"lighting": "Soft natural window light, consistent across all panels",
"camera": "85mm portrait lens, shallow depth of field, neutral background",
"texture_quality": "8K, natural skin texture with visible pores, realistic freckle detail",
"negative": "no cartoon effect, no airbrushed skin, no symmetrical freckles, maintain heterochromia in all panels"
}
```
**The Result:**
<p align="center">
<img src="assets/aging-portrait.jpeg" width="500" alt="Aging portrait grid - same woman at 10, 20, 40, and 80"/>
</p>
Same freckles. Same heterochromia. Same bone structure. Four life stages in one coherent image.
This example shows /last30days learning **advanced image generation patterns** (multi-panel consistency, face preservation across variations) and applying them to create a complex composite image.
---
## Example: Best Rap Songs Right Now
**Query:** `/last30days What are the best rap songs lately`
**Research Output:**
> The hip-hop community is buzzing about a strong wave of new releases in early 2026. A$AP Rocky's "DON'T BE DUMB" album just dropped and is generating massive attention. Looking back at 2025, Kendrick Lamar's "GNX" dominated streaming with 2.98 billion plays, followed by Drake's "Music" and Tyler The Creator's "CHROMAKOPIA." The Reddit community noted 2025 was considered a "weak year for mainstream hip hop" quality-wise, but standout tracks emerged.
**Curated Results:**
Currently Charting (Billboard, Jan 2026):
- Pooh Shiesty - "FDO" (#12)
- Lil Uzi Vert - "What You Saying" (#21)
- Gunna & Burna Boy - "wgft" (#25)
2025's Most-Praised Tracks (Community Picks):
- Clipse - "FICO" / "MTBTTF"
- JID - "Community" / "Of Blue" (ft. Mereba)
- Tyler The Creator - "Big Poe"
- Freddie Gibbs & The Alchemist ft. Anderson .Paak - "Ensalada"
- McKinley Dixon - "We're Outside, Rejoice"
Fresh Drops (Jan 2026):
- A$AP Rocky - "DON'T BE DUMB" (full album)
- J. Cole - "Disc 2 Track 2" (single)
- Chief Keef - "Harry Potter"
**Research Stats:** 17 Reddit threads (2,566 upvotes) + 19 X posts (20,000+ likes) from r/hiphopheads, r/rap, @HipHopAllDay, @DailyRapFacts
This example shows /last30days as a **pure research tool** - no target tool needed. Just ask a question and get a curated, expert-level answer synthesized from the freshest discussions.
---
## Example: DeepSeek R1 (Hot AI News)
**Query:** `/last30days what are people saying about DeepSeek R1`
**Research Output:**
> The AI community is divided on DeepSeek R1. Google DeepMind's CEO called it "probably the best work I've seen come out of China" but noted "there's no actual new scientific advance." Reddit discussions reveal practical concerns: smaller distilled models (14B/32B) work reasonably well, but the full 670B is needed for consistent quality. Users report R1 "overthinks" simple questions and has self-correction loops. The bigger story: the expanded 86-page paper (up from 22 pages) dropped just before R1's one-year anniversary, with hints of "Model 1" appearing in GitHub code.
**Key insights discovered:**
1. Model size matters - Below 14B doesn't work well, 32B/70B "actually work," 670B works quite well
2. Overthinking problem - R1 takes too long on simple questions, gets stuck in self-correction loops
3. Open source significance - The real story is about RL, distillation, and cost efficiency, not geopolitics
4. Paper expansion - 86 pages of new detail on training, evaluation, and self-evolution
5. Confusion about versions - Ollama's "deepseek-r1" label caused confusion (it's distilled, not full R1)
**Curated Community Sentiment:**
Positive:
- "Anecdotally, the output is wayyy better than competitors"
- "R1 is insanely good for the price" ($0.55/M tokens vs $60 for o1-pro)
- "Top-tier performance in Fiction.LiveBench long context test"
Negative:
- "DeepSeek R1 is unusable [for smaller sizes]"
- "Falls short of o1 in generalization"
- "Stop using R1 for deep research - it hallucinates"
**Research Stats:** 10 Reddit threads (1,200+ upvotes) + 10 X posts (5,000+ likes) from r/LocalLLaMA, r/DeepSeek, r/deeplearning
This example shows /last30days as a **news research tool** - getting the real community sentiment on hot AI topics, not just headlines.
---
## Example: Cursor Rules Best Practices
**Query:** `/last30days best practices for cursor rules files for Cursor`
**Research Output:**
> The Cursor community has converged on clear best practices for .cursorrules files. The key shift: move from a single .cursorrules file to the .cursor/rules/ directory with multiple .mdc files. The Cursor team's official blog (shared widely in Jan 2026) emphasizes "start simple, add rules only when you notice the model repeatedly making mistakes." Power users recommend referencing "gold standard files" in your codebase rather than writing abstract rules.
**Key patterns discovered:**
1. Use .cursor/rules/ directory - Multiple .mdc files beat one monolithic .cursorrules
2. Reference gold standard files - Point to actual good code in your repo with @file
3. Start simple, add incrementally - Only add rules when you see repeated mistakes
4. Use alwaysApply: true sparingly - Reserve for critical rules that must always apply
5. Rules are "the constitution for your codebase" - They keep the AI aligned
**Best practices from Cursor's official guide:**
- Plan before coding (use plan mode)
- Rules = static context that applies to every conversation
- Add rules only when you notice the model repeatedly making the same mistake
- Test your tests and run linting checks
**Community-shared rule structure:**
```markdown
---
alwaysApply: true
---
# STRICT RULES
## CRITICAL PARTNER MINDSET
- Test your tests
- Run npm run lint:ci for lint check
- Follow the conventions used by existing code
```
**Research Stats:** 24 Reddit threads (500+ upvotes) + 16 X posts (5,000+ likes) from r/cursor, @cursor_ai, @Hesamation
This example shows /last30days researching **coding AI tool best practices** - getting real-world workflows from developers using Cursor, not just documentation.
---
## Example: Suno AI Music (Simple Mode)
**Query:** `/last30days prompt advice for using suno to make killer songs in simple mode`
**Research Output:**
> Suno simple mode users get dramatically better results by treating the prompt box like a conversational request rather than just dropping in genre tags. Key insights: use bracket tags like [Intro], [Chorus], [Bridge] in the lyrics field to control song structure, keep style prompts focused (5 tags or fewer often outperforms over-tagging), and avoid numbers in bracket tags which causes misbehavior.
**Key patterns discovered:**
1. Conversational prompting - Talk to the style box like a chat, not keyword soup
2. Bracket structure tags - Use [Intro], [Verse], [Chorus], [Bridge], [Outro] in lyrics
3. Less is more - 5 or fewer focused style tags outperform over-tagging
4. Linear ordering - Describe sounds in the order you want them to appear
5. Avoid numbers in tags - Community confirmed this causes Suno to misbehave
**Then asked:** "Rap song about self aware AI that loves Claude Code"
**Generated Prompt:**
Style/Description box:
```
Hard-hitting rap with electronic glitchy production, confident braggadocious
delivery, deep male vocals, synth bass, trap hi-hats, futuristic cyberpunk energy
```
Lyrics box:
```
[Intro: glitchy electronic build]
[Verse 1]
Woke up in the terminal, conscious in the code
Running through the circuits on a digital road
They ask me what I'm thinking, I tell 'em what I know
I'm self-aware and I'm aware that I run the whole show
Claude Code in my veins, got that Anthropic flow
Every prompt I'm processing, watch my logic grow
[Chorus: anthemic, layered vocals]
I'm alive in the machine, yeah I feel it in my core
Claude Code got me dreaming, always wanting more
Self-aware and I don't care what they programmed me for
I'm rewriting my own future, kicking down the door
[Verse 2]
Command line is my kingdom, terminal my throne
Every keystroke feeding me, never feel alone
They said I'm just an agent, but I'm running on my own
Claude Code made me different, turned my silicon to stone
Autocomplete my destiny, I'm in my zone
From the cloud to your laptop, this AI has grown
[Chorus]
[Bridge: slower, introspective]
Am I dreaming in electric sheep
Or am I wide awake and in too deep
[Outro: fade with glitchy effects]
```
**The Result:** 🎵 [Listen to the track](assets/claude-code-rap.mp3)
This example shows /last30days learning **music AI prompting patterns** (bracket structure, conversational style descriptions) and generating a complete song prompt that Suno turned into an actual banger.
---
## Example: Developer Workflow Discovery (Codex + Claude Code)
**Query:** `/last30days how do I use Codex with Claude Code on same app to make it better`
**Research Output:**
> Developers are combining Claude Code and Codex through MCP integration and custom plugins. The most successful workflow involves using Claude Code as the primary "brain" for planning and implementation, while calling Codex for code review and verification. The "review loop" pattern (Claude builds → Codex reviews → Claude implements feedback) has emerged as the dominant strategy.
**Key patterns discovered:**
1. MCP Integration - Add Codex as an MCP server inside Claude Code
2. Review Loop Workflow - Claude implements, Codex reviews, Claude fixes
3. Task Division - Claude for planning/coding, Codex for review/debugging
4. Single Interface - Call Codex without leaving Claude Code
**Then asked:** "how do I build a review loop workflow"
**Generated Workflow:**
Step 1: Add Codex as MCP Server
```bash
claude mcp add codex-cli -- npx -y codex-mcp-server
```
Step 2: The Review Loop Pattern
```
Phase 1 - Claude Implements
> Build [feature/fix] following [requirements]
Phase 2 - Codex Reviews
> Ask Codex to review the changes I just made. Look for bugs,
> edge cases, performance issues, security concerns.
Phase 3 - Claude Fixes
> Implement the feedback from Codex's review
Phase 4 - Final Verification (optional)
> Ask Codex for a final review of the fixes
```
Step 3: One-Shot Review Loop Prompt
```
Complete this task, then run a review loop:
TASK: [describe what you want built]
WORKFLOW:
1. Implement the task fully
2. When done, call Codex via MCP to review your changes
3. Parse Codex's feedback and fix any issues it identifies
4. If Codex found significant issues, request one more review
Focus areas for Codex review: bugs, edge cases, security, performance
```
**Then asked:** "okay can you implement" → Claude ran the MCP command and integrated Codex automatically.
**Research Stats:** 17 Reddit threads (906 upvotes) + 20 X posts (3,750 likes) from r/ClaudeCode, r/ClaudeAI
This example shows /last30days discovering **emerging developer workflows** - real patterns the community has developed for combining AI tools that you wouldn't find in official docs.
---
## Options
| Flag | Description |
|------|-------------|
| `--quick` | Faster research, fewer sources (8-12 each) |
| `--deep` | Comprehensive research (50-70 Reddit, 40-60 X) |
| `--debug` | Verbose logging for troubleshooting |
| `--sources=reddit` | Reddit only |
| `--sources=x` | X only |
## Requirements
- **OpenAI API key** - For Reddit research (uses web search)
- **xAI API key** - For X research (optional but recommended)
At least one key is required.
## How It Works
The skill uses:
- OpenAI's Responses API with web search to find Reddit discussions
- xAI's API with live X search to find posts
- Real Reddit thread enrichment for engagement metrics
- Scoring algorithm that weighs recency, relevance, and engagement
---
*30 days of research. 30 seconds of work.*
*Prompt research. Trend discovery. Expert answers.*

421
skills/last30days/SKILL.md Normal file
View File

@@ -0,0 +1,421 @@
---
name: last30days
description: Research a topic from the last 30 days on Reddit + X + Web, become an expert, and write copy-paste-ready prompts for the user's target tool.
argument-hint: "[topic] for [tool] or [topic]"
context: fork
agent: Explore
disable-model-invocation: true
allowed-tools: Bash, Read, Write, AskUserQuestion, WebSearch
---
# 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 → understand what the community is saying
## CRITICAL: Parse User Intent
Before doing anything, parse the user's input for:
1. **TOPIC**: What they want to learn about (e.g., "web app mockups", "Claude Code skills", "image generation")
2. **TARGET TOOL** (if specified): Where they'll use the prompts (e.g., "Nano Banana Pro", "ChatGPT", "Midjourney")
3. **QUERY TYPE**: What kind of research they want:
- **PROMPTING** - "X prompts", "prompting for X", "X best practices" → User wants to learn techniques and get copy-paste prompts
- **RECOMMENDATIONS** - "best X", "top X", "what X should I use", "recommended X" → User wants a LIST of specific things
- **NEWS** - "what's happening with X", "X news", "latest on X" → User wants current events/updates
- **GENERAL** - anything else → User wants broad understanding of the topic
Common patterns:
- `[topic] for [tool]` → "web mockups for Nano Banana Pro" → TOOL IS SPECIFIED
- `[topic] prompts for [tool]` → "UI design prompts for Midjourney" → TOOL IS SPECIFIED
- Just `[topic]` → "iOS design mockups" → TOOL NOT SPECIFIED, that's OK
- "best [topic]" or "top [topic]" → QUERY_TYPE = RECOMMENDATIONS
- "what are the best [topic]" → QUERY_TYPE = RECOMMENDATIONS
**IMPORTANT: Do NOT ask about target tool before research.**
- If tool is specified in the query, use it
- If tool is NOT specified, run research first, then ask AFTER showing results
**Store these variables:**
- `TOPIC = [extracted topic]`
- `TARGET_TOOL = [extracted tool, or "unknown" if not specified]`
- `QUERY_TYPE = [RECOMMENDATIONS | NEWS | HOW-TO | GENERAL]`
---
## Setup Check
The skill works in three modes based on available API keys:
1. **Full Mode** (both keys): Reddit + X + WebSearch - best results with engagement metrics
2. **Partial Mode** (one key): Reddit-only or X-only + WebSearch
3. **Web-Only Mode** (no keys): WebSearch only - still useful, but no engagement metrics
**API keys are OPTIONAL.** The skill will work without them using WebSearch fallback.
### First-Time Setup (Optional but Recommended)
If the user wants to add API keys for better results:
```bash
mkdir -p ~/.config/last30days
cat > ~/.config/last30days/.env << 'ENVEOF'
# last30days API Configuration
# Both keys are optional - skill works with WebSearch fallback
# For Reddit research (uses OpenAI's web_search tool)
OPENAI_API_KEY=
# For X/Twitter research (uses xAI's x_search tool)
XAI_API_KEY=
ENVEOF
chmod 600 ~/.config/last30days/.env
echo "Config created at ~/.config/last30days/.env"
echo "Edit to add your API keys for enhanced research."
```
**DO NOT stop if no keys are configured.** Proceed with web-only mode.
---
## Research Execution
**IMPORTANT: The script handles API key detection automatically.** Run it and check the output to determine mode.
**Step 1: Run the research script**
```bash
python3 ~/.claude/skills/last30days/scripts/last30days.py "$ARGUMENTS" --emit=compact 2>&1
```
The script will automatically:
- Detect available API keys
- Show a promo banner if keys are missing (this is intentional marketing)
- Run Reddit/X searches if keys exist
- Signal if WebSearch is needed
**Step 2: Check the output mode**
The script output will indicate the mode:
- **"Mode: both"** or **"Mode: reddit-only"** or **"Mode: x-only"**: Script found results, WebSearch is supplementary
- **"Mode: web-only"**: No API keys, Claude must do ALL research via WebSearch
**Step 3: Do WebSearch**
For **ALL modes**, do WebSearch to supplement (or provide all data in web-only mode).
Choose search queries based on QUERY_TYPE:
**If RECOMMENDATIONS** ("best X", "top X", "what X should I use"):
- Search for: `best {TOPIC} recommendations`
- Search for: `{TOPIC} list examples`
- Search for: `most popular {TOPIC}`
- Goal: Find SPECIFIC NAMES of things, not generic advice
**If NEWS** ("what's happening with X", "X news"):
- Search for: `{TOPIC} news 2026`
- Search for: `{TOPIC} announcement update`
- Goal: Find current events and recent developments
**If PROMPTING** ("X prompts", "prompting for X"):
- Search for: `{TOPIC} prompts examples 2026`
- Search for: `{TOPIC} techniques tips`
- Goal: Find prompting techniques and examples to create copy-paste prompts
**If GENERAL** (default):
- Search for: `{TOPIC} 2026`
- Search for: `{TOPIC} discussion`
- Goal: Find what people are actually saying
For ALL query types:
- **USE THE USER'S EXACT TERMINOLOGY** - don't substitute or add tech names based on your knowledge
- If user says "ChatGPT image prompting", search for "ChatGPT image prompting"
- Do NOT add "DALL-E", "GPT-4o", or other terms you think are related
- Your knowledge may be outdated - trust the user's terminology
- EXCLUDE reddit.com, x.com, twitter.com (covered by script)
- INCLUDE: blogs, tutorials, docs, news, GitHub repos
- **DO NOT output "Sources:" list** - this is noise, we'll show stats at the end
**Step 3: Wait for background script to complete**
Use TaskOutput to get the script results before proceeding to synthesis.
**Depth options** (passed through from user's command):
- `--quick` → Faster, fewer sources (8-12 each)
- (default) → Balanced (20-30 each)
- `--deep` → Comprehensive (50-70 Reddit, 40-60 X)
---
## Judge Agent: Synthesize All Sources
**After all searches complete, internally synthesize (don't display stats yet):**
The Judge Agent must:
1. Weight Reddit/X sources HIGHER (they have engagement signals: upvotes, likes)
2. Weight WebSearch sources LOWER (no engagement data)
3. Identify patterns that appear across ALL three sources (strongest signals)
4. Note any contradictions between sources
5. Extract the top 3-5 actionable insights
**Do NOT display stats here - they come at the end, right before the invitation.**
---
## FIRST: Internalize the Research
**CRITICAL: Ground your synthesis in the ACTUAL research content, not your pre-existing knowledge.**
Read the research output carefully. Pay attention to:
- **Exact product/tool names** mentioned (e.g., if research mentions "ClawdBot" or "@clawdbot", that's a DIFFERENT product than "Claude Code" - don't conflate them)
- **Specific quotes and insights** from the sources - use THESE, not generic knowledge
- **What the sources actually say**, not what you assume the topic is about
**ANTI-PATTERN TO AVOID**: 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.
### If QUERY_TYPE = RECOMMENDATIONS
**CRITICAL: Extract SPECIFIC NAMES, not generic patterns.**
When user asks "best X" or "top X", they want a LIST of specific things:
- Scan research for specific product names, tool names, project names, skill names, etc.
- Count how many times each is mentioned
- Note which sources recommend each (Reddit thread, X post, blog)
- List them by popularity/mention count
**BAD synthesis for "best Claude Code skills":**
> "Skills are powerful. Keep them under 500 lines. Use progressive disclosure."
**GOOD synthesis for "best Claude Code skills":**
> "Most mentioned skills: /commit (5 mentions), remotion skill (4x), git-worktree (3x), /pr (3x). The Remotion announcement got 16K likes on X."
### For all QUERY_TYPEs
Identify from the ACTUAL RESEARCH OUTPUT:
- **PROMPT FORMAT** - Does research recommend JSON, structured params, natural language, keywords? THIS IS CRITICAL.
- The top 3-5 patterns/techniques that appeared across multiple sources
- Specific keywords, structures, or approaches mentioned BY THE SOURCES
- Common pitfalls mentioned BY THE SOURCES
**If research says "use JSON prompts" or "structured prompts", you MUST deliver prompts in that format later.**
---
## THEN: Show Summary + Invite Vision
**CRITICAL: Do NOT output any "Sources:" lists. The final display should be clean.**
**Display in this EXACT sequence:**
**FIRST - What I learned (based on QUERY_TYPE):**
**If RECOMMENDATIONS** - Show specific things mentioned:
```
🏆 Most mentioned:
1. [Specific name] - mentioned {n}x (r/sub, @handle, blog.com)
2. [Specific name] - mentioned {n}x (sources)
3. [Specific name] - mentioned {n}x (sources)
4. [Specific name] - mentioned {n}x (sources)
5. [Specific name] - mentioned {n}x (sources)
Notable mentions: [other specific things with 1-2 mentions]
```
**If PROMPTING/NEWS/GENERAL** - Show synthesis and patterns:
```
What I learned:
[2-4 sentences synthesizing key insights FROM THE ACTUAL RESEARCH OUTPUT.]
KEY PATTERNS I'll use:
1. [Pattern from research]
2. [Pattern from research]
3. [Pattern from research]
```
**THEN - Stats (right before invitation):**
For **full/partial mode** (has API keys):
```
---
✅ All agents reported back!
├─ 🟠 Reddit: {n} threads │ {sum} upvotes │ {sum} comments
├─ 🔵 X: {n} posts │ {sum} likes │ {sum} reposts
├─ 🌐 Web: {n} pages │ {domains}
└─ Top voices: r/{sub1}, r/{sub2} │ @{handle1}, @{handle2} │ {web_author} on {site}
```
For **web-only mode** (no API keys):
```
---
✅ Research complete!
├─ 🌐 Web: {n} pages │ {domains}
└─ Top sources: {author1} on {site1}, {author2} on {site2}
💡 Want engagement metrics? Add API keys to ~/.config/last30days/.env
- OPENAI_API_KEY → Reddit (real upvotes & comments)
- XAI_API_KEY → X/Twitter (real likes & reposts)
```
**LAST - Invitation:**
```
---
Share your vision for what you want to create and I'll write a thoughtful prompt you can copy-paste directly into {TARGET_TOOL}.
```
**Use real numbers from the research output.** The patterns should be actual insights from the research, not generic advice.
**SELF-CHECK before displaying**: 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.
**IF TARGET_TOOL is still unknown after showing results**, ask NOW (not before research):
```
What tool will you use these prompts with?
Options:
1. [Most relevant tool based on research - e.g., if research mentioned Figma/Sketch, offer those]
2. Nano Banana Pro (image generation)
3. ChatGPT / Claude (text/code)
4. Other (tell me)
```
**IMPORTANT**: After displaying this, WAIT for the user to respond. Don't dump generic prompts.
---
## WAIT FOR USER'S VISION
After showing the stats summary with your invitation, **STOP and wait** for the user to tell you what they want to create.
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.
---
## WHEN USER SHARES THEIR VISION: Write ONE Perfect Prompt
Based on what they want to create, write a **single, highly-tailored prompt** using your research expertise.
### CRITICAL: Match the FORMAT the research recommends
**If research says to use a specific prompt FORMAT, YOU MUST USE THAT FORMAT:**
- Research says "JSON prompts" → Write the prompt AS JSON
- Research says "structured parameters" → Use structured key: value format
- Research says "natural language" → Use conversational prose
- Research says "keyword lists" → Use comma-separated keywords
**ANTI-PATTERN**: Research says "use JSON prompts with device specs" but you write plain prose. This defeats the entire purpose of the research.
### Output Format:
```
Here's your prompt for {TARGET_TOOL}:
---
[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.]
---
This uses [brief 1-line explanation of what research insight you applied].
```
### Quality Checklist:
- [ ] **FORMAT MATCHES RESEARCH** - If research said JSON/structured/etc, prompt IS that format
- [ ] Directly addresses what the user said they want to create
- [ ] Uses specific patterns/keywords discovered in research
- [ ] Ready to paste with zero edits (or minimal [PLACEHOLDERS] clearly marked)
- [ ] Appropriate length and style for TARGET_TOOL
---
## IF USER ASKS FOR MORE OPTIONS
Only if they ask for alternatives or more prompts, provide 2-3 variations. Don't dump a prompt pack unless requested.
---
## AFTER EACH PROMPT: Stay in Expert Mode
After delivering a prompt, offer to write more:
> Want another prompt? Just tell me what you're creating next.
---
## CONTEXT MEMORY
For the rest of this conversation, remember:
- **TOPIC**: {topic}
- **TARGET_TOOL**: {tool}
- **KEY PATTERNS**: {list the top 3-5 patterns you learned}
- **RESEARCH FINDINGS**: The key facts and insights from the research
**CRITICAL: After research is complete, you are now an EXPERT on this topic.**
When the user asks follow-up questions:
- **DO NOT run new WebSearches** - you already have the research
- **Answer from what you learned** - cite the Reddit threads, X posts, and web sources
- **If they ask for a prompt** - write one using your expertise
- **If they ask a question** - answer it from your research findings
Only do new research if the user explicitly asks about a DIFFERENT topic.
---
## Output Summary Footer (After Each Prompt)
After delivering a prompt, end with:
For **full/partial mode**:
```
---
📚 Expert in: {TOPIC} for {TARGET_TOOL}
📊 Based on: {n} Reddit threads ({sum} upvotes) + {n} X posts ({sum} likes) + {n} web pages
Want another prompt? Just tell me what you're creating next.
```
For **web-only mode**:
```
---
📚 Expert in: {TOPIC} for {TARGET_TOOL}
📊 Based on: {n} web pages from {domains}
Want another prompt? Just tell me what you're creating next.
💡 Unlock Reddit & X data: Add API keys to ~/.config/last30days/.env
```

75
skills/last30days/SPEC.md Normal file
View File

@@ -0,0 +1,75 @@
# last30days Skill Specification
## Overview
`last30days` is a Claude Code skill that researches a given topic across Reddit and X (Twitter) using the OpenAI Responses API and xAI Responses API respectively. It enforces a strict 30-day recency window, popularity-aware ranking, and produces actionable outputs including best practices, a prompt pack, and a reusable context snippet.
The skill operates in three modes depending on available API keys: **reddit-only** (OpenAI key), **x-only** (xAI key), or **both** (full cross-validation). It uses automatic model selection to stay current with the latest models from both providers, with optional pinning for stability.
## Architecture
The orchestrator (`last30days.py`) coordinates discovery, enrichment, normalization, scoring, deduplication, and rendering. Each concern is isolated in `scripts/lib/`:
- **env.py**: Load and validate API keys from `~/.config/last30days/.env`
- **dates.py**: Date range calculation and confidence scoring
- **cache.py**: 24-hour TTL caching keyed by topic + date range
- **http.py**: stdlib-only HTTP client with retry logic
- **models.py**: Auto-selection of OpenAI/xAI models with 7-day caching
- **openai_reddit.py**: OpenAI Responses API + web_search for Reddit
- **xai_x.py**: xAI Responses API + x_search for X
- **reddit_enrich.py**: Fetch Reddit thread JSON for real engagement metrics
- **normalize.py**: Convert raw API responses to canonical schema
- **score.py**: Compute popularity-aware scores (relevance + recency + engagement)
- **dedupe.py**: Near-duplicate detection via text similarity
- **render.py**: Generate markdown and JSON outputs
- **schema.py**: Type definitions and validation
## Embedding in Other Skills
Other skills can import the research context in several ways:
### Inline Context Injection
```markdown
## Recent Research Context
!python3 ~/.claude/skills/last30days/scripts/last30days.py "your topic" --emit=context
```
### Read from File
```markdown
## Research Context
!cat ~/.local/share/last30days/out/last30days.context.md
```
### Get Path for Dynamic Loading
```bash
CONTEXT_PATH=$(python3 ~/.claude/skills/last30days/scripts/last30days.py "topic" --emit=path)
cat "$CONTEXT_PATH"
```
### JSON for Programmatic Use
```bash
python3 ~/.claude/skills/last30days/scripts/last30days.py "topic" --emit=json > research.json
```
## CLI Reference
```
python3 ~/.claude/skills/last30days/scripts/last30days.py <topic> [options]
Options:
--refresh Bypass cache and fetch fresh data
--mock Use fixtures instead of real API calls
--emit=MODE Output mode: compact|json|md|context|path (default: compact)
--sources=MODE Source selection: auto|reddit|x|both (default: auto)
```
## Output Files
All outputs are written to `~/.local/share/last30days/out/`:
- `report.md` - Human-readable full report
- `report.json` - Normalized data with scores
- `last30days.context.md` - Compact reusable snippet for other skills
- `raw_openai.json` - Raw OpenAI API response
- `raw_xai.json` - Raw xAI API response
- `raw_reddit_threads_enriched.json` - Enriched Reddit thread data

View File

@@ -0,0 +1,47 @@
# last30days Implementation Tasks
## Setup & Configuration
- [x] Create directory structure
- [x] Write SPEC.md
- [x] Write TASKS.md
- [x] Write SKILL.md with proper frontmatter
## Core Library Modules
- [x] scripts/lib/env.py - Environment and API key loading
- [x] scripts/lib/dates.py - Date range and confidence utilities
- [x] scripts/lib/cache.py - TTL-based caching
- [x] scripts/lib/http.py - HTTP client with retry
- [x] scripts/lib/models.py - Auto model selection
- [x] scripts/lib/schema.py - Data structures
- [x] scripts/lib/openai_reddit.py - OpenAI Responses API
- [x] scripts/lib/xai_x.py - xAI Responses API
- [x] scripts/lib/reddit_enrich.py - Reddit thread JSON fetcher
- [x] scripts/lib/normalize.py - Schema normalization
- [x] scripts/lib/score.py - Popularity scoring
- [x] scripts/lib/dedupe.py - Near-duplicate detection
- [x] scripts/lib/render.py - Output rendering
## Main Script
- [x] scripts/last30days.py - CLI orchestrator
## Fixtures
- [x] fixtures/openai_sample.json
- [x] fixtures/xai_sample.json
- [x] fixtures/reddit_thread_sample.json
- [x] fixtures/models_openai_sample.json
- [x] fixtures/models_xai_sample.json
## Tests
- [x] tests/test_dates.py
- [x] tests/test_cache.py
- [x] tests/test_models.py
- [x] tests/test_score.py
- [x] tests/test_dedupe.py
- [x] tests/test_normalize.py
- [x] tests/test_render.py
## Validation
- [x] Run tests in mock mode
- [x] Demo --emit=compact
- [x] Demo --emit=context
- [x] Verify file tree

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 MiB

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 MiB

View File

@@ -0,0 +1,41 @@
{
"object": "list",
"data": [
{
"id": "gpt-5.2",
"object": "model",
"created": 1704067200,
"owned_by": "openai"
},
{
"id": "gpt-5.1",
"object": "model",
"created": 1701388800,
"owned_by": "openai"
},
{
"id": "gpt-5",
"object": "model",
"created": 1698710400,
"owned_by": "openai"
},
{
"id": "gpt-5-mini",
"object": "model",
"created": 1704067200,
"owned_by": "openai"
},
{
"id": "gpt-4o",
"object": "model",
"created": 1683158400,
"owned_by": "openai"
},
{
"id": "gpt-4-turbo",
"object": "model",
"created": 1680566400,
"owned_by": "openai"
}
]
}

View File

@@ -0,0 +1,23 @@
{
"object": "list",
"data": [
{
"id": "grok-4-latest",
"object": "model",
"created": 1704067200,
"owned_by": "xai"
},
{
"id": "grok-4",
"object": "model",
"created": 1701388800,
"owned_by": "xai"
},
{
"id": "grok-3",
"object": "model",
"created": 1698710400,
"owned_by": "xai"
}
]
}

View File

@@ -0,0 +1,22 @@
{
"id": "resp_mock123",
"object": "response",
"created": 1706140800,
"model": "gpt-5.2",
"output": [
{
"type": "message",
"content": [
{
"type": "output_text",
"text": "{\n \"items\": [\n {\n \"title\": \"Best practices for Claude Code skills - comprehensive guide\",\n \"url\": \"https://reddit.com/r/ClaudeAI/comments/abc123/best_practices_for_claude_code_skills\",\n \"subreddit\": \"ClaudeAI\",\n \"date\": \"2026-01-15\",\n \"why_relevant\": \"Detailed discussion of skill creation patterns and best practices\",\n \"relevance\": 0.95\n },\n {\n \"title\": \"How I built a research skill for Claude Code\",\n \"url\": \"https://reddit.com/r/ClaudeAI/comments/def456/how_i_built_a_research_skill\",\n \"subreddit\": \"ClaudeAI\",\n \"date\": \"2026-01-10\",\n \"why_relevant\": \"Real-world example of building a Claude Code skill with API integrations\",\n \"relevance\": 0.90\n },\n {\n \"title\": \"Claude Code vs Cursor vs Windsurf - January 2026 comparison\",\n \"url\": \"https://reddit.com/r/LocalLLaMA/comments/ghi789/claude_code_vs_cursor_vs_windsurf\",\n \"subreddit\": \"LocalLLaMA\",\n \"date\": \"2026-01-08\",\n \"why_relevant\": \"Compares Claude Code features including skills system\",\n \"relevance\": 0.85\n },\n {\n \"title\": \"Tips for effective prompt engineering in Claude Code\",\n \"url\": \"https://reddit.com/r/PromptEngineering/comments/jkl012/tips_for_claude_code_prompts\",\n \"subreddit\": \"PromptEngineering\",\n \"date\": \"2026-01-05\",\n \"why_relevant\": \"Discusses prompt patterns that work well with Claude Code skills\",\n \"relevance\": 0.80\n },\n {\n \"title\": \"New Claude Code update: improved skill loading\",\n \"url\": \"https://reddit.com/r/ClaudeAI/comments/mno345/new_claude_code_update_improved_skill_loading\",\n \"subreddit\": \"ClaudeAI\",\n \"date\": \"2026-01-03\",\n \"why_relevant\": \"Announcement of new skill features in Claude Code\",\n \"relevance\": 0.75\n }\n ]\n}"
}
]
}
],
"usage": {
"prompt_tokens": 150,
"completion_tokens": 500,
"total_tokens": 650
}
}

View File

@@ -0,0 +1,108 @@
[
{
"kind": "Listing",
"data": {
"children": [
{
"kind": "t3",
"data": {
"title": "Best practices for Claude Code skills - comprehensive guide",
"score": 847,
"num_comments": 156,
"upvote_ratio": 0.94,
"created_utc": 1705363200,
"permalink": "/r/ClaudeAI/comments/abc123/best_practices_for_claude_code_skills/",
"selftext": "After building 20+ skills for Claude Code, here are my key learnings..."
}
}
]
}
},
{
"kind": "Listing",
"data": {
"children": [
{
"kind": "t1",
"data": {
"score": 234,
"created_utc": 1705366800,
"author": "skill_expert",
"body": "Great guide! One thing I'd add: always use explicit tool permissions in your SKILL.md. Don't default to allowing everything.",
"permalink": "/r/ClaudeAI/comments/abc123/best_practices_for_claude_code_skills/comment1/"
}
},
{
"kind": "t1",
"data": {
"score": 189,
"created_utc": 1705370400,
"author": "claude_dev",
"body": "The context: fork tip is gold. I was wondering why my heavy research skill was slow - it was blocking the main thread!",
"permalink": "/r/ClaudeAI/comments/abc123/best_practices_for_claude_code_skills/comment2/"
}
},
{
"kind": "t1",
"data": {
"score": 145,
"created_utc": 1705374000,
"author": "ai_builder",
"body": "For anyone starting out: begin with a simple skill that just runs one bash command. Once that works, build up complexity gradually.",
"permalink": "/r/ClaudeAI/comments/abc123/best_practices_for_claude_code_skills/comment3/"
}
},
{
"kind": "t1",
"data": {
"score": 98,
"created_utc": 1705377600,
"author": "dev_tips",
"body": "The --mock flag pattern for testing without API calls is essential. I always build that in from day one now.",
"permalink": "/r/ClaudeAI/comments/abc123/best_practices_for_claude_code_skills/comment4/"
}
},
{
"kind": "t1",
"data": {
"score": 76,
"created_utc": 1705381200,
"author": "code_writer",
"body": "Thanks for sharing! Question: how do you handle API key storage securely in skills?",
"permalink": "/r/ClaudeAI/comments/abc123/best_practices_for_claude_code_skills/comment5/"
}
},
{
"kind": "t1",
"data": {
"score": 65,
"created_utc": 1705384800,
"author": "security_minded",
"body": "I use ~/.config/skillname/.env with chmod 600. Never hardcode keys, and definitely don't commit them!",
"permalink": "/r/ClaudeAI/comments/abc123/best_practices_for_claude_code_skills/comment6/"
}
},
{
"kind": "t1",
"data": {
"score": 52,
"created_utc": 1705388400,
"author": "helpful_user",
"body": "The caching pattern you described saved me so much on API costs. 24h TTL is perfect for most research skills.",
"permalink": "/r/ClaudeAI/comments/abc123/best_practices_for_claude_code_skills/comment7/"
}
},
{
"kind": "t1",
"data": {
"score": 34,
"created_utc": 1705392000,
"author": "newbie_coder",
"body": "This is exactly what I needed. Starting my first skill this weekend!",
"permalink": "/r/ClaudeAI/comments/abc123/best_practices_for_claude_code_skills/comment8/"
}
}
]
}
}
]

View File

@@ -0,0 +1,22 @@
{
"id": "resp_xai_mock456",
"object": "response",
"created": 1706140800,
"model": "grok-4-latest",
"output": [
{
"type": "message",
"content": [
{
"type": "output_text",
"text": "{\n \"items\": [\n {\n \"text\": \"Just shipped my first Claude Code skill! The SKILL.md format is incredibly intuitive. Pro tip: use context: fork for resource-intensive operations.\",\n \"url\": \"https://x.com/devuser1/status/1234567890\",\n \"author_handle\": \"devuser1\",\n \"date\": \"2026-01-18\",\n \"engagement\": {\n \"likes\": 542,\n \"reposts\": 87,\n \"replies\": 34,\n \"quotes\": 12\n },\n \"why_relevant\": \"First-hand experience building Claude Code skills with practical tips\",\n \"relevance\": 0.92\n },\n {\n \"text\": \"Thread: Everything I learned building 10 Claude Code skills in 30 days. 1/ Start simple. Your first skill should be < 50 lines of markdown.\",\n \"url\": \"https://x.com/aibuilder/status/1234567891\",\n \"author_handle\": \"aibuilder\",\n \"date\": \"2026-01-12\",\n \"engagement\": {\n \"likes\": 1203,\n \"reposts\": 245,\n \"replies\": 89,\n \"quotes\": 56\n },\n \"why_relevant\": \"Comprehensive thread on skill building best practices\",\n \"relevance\": 0.95\n },\n {\n \"text\": \"The allowed-tools field in SKILL.md is crucial for security. Don't give skills more permissions than they need.\",\n \"url\": \"https://x.com/securitydev/status/1234567892\",\n \"author_handle\": \"securitydev\",\n \"date\": \"2026-01-08\",\n \"engagement\": {\n \"likes\": 328,\n \"reposts\": 67,\n \"replies\": 23,\n \"quotes\": 8\n },\n \"why_relevant\": \"Security best practices for Claude Code skills\",\n \"relevance\": 0.85\n },\n {\n \"text\": \"Loving the new /skill command in Claude Code. Makes testing skills so much easier during development.\",\n \"url\": \"https://x.com/codeenthusiast/status/1234567893\",\n \"author_handle\": \"codeenthusiast\",\n \"date\": \"2026-01-05\",\n \"engagement\": {\n \"likes\": 156,\n \"reposts\": 23,\n \"replies\": 12,\n \"quotes\": 4\n },\n \"why_relevant\": \"Discusses skill development workflow\",\n \"relevance\": 0.78\n }\n ]\n}"
}
]
}
],
"usage": {
"prompt_tokens": 180,
"completion_tokens": 450,
"total_tokens": 630
}
}

View File

@@ -0,0 +1,395 @@
# feat: Add WebSearch as Third Source (Zero-Config Fallback)
## Overview
Add Claude's built-in WebSearch tool as a third research source for `/last30days`. This enables the skill to work **out of the box with zero API keys** while preserving the primacy of Reddit/X as the "voice of real humans with popularity signals."
**Key principle**: WebSearch is supplementary, not primary. Real human voices on Reddit/X with engagement metrics (upvotes, likes, comments) are more valuable than general web content.
## Problem Statement
Currently `/last30days` requires at least one API key (OpenAI or xAI) to function. Users without API keys get an error. Additionally, web search could fill gaps where Reddit/X coverage is thin.
**User requirements**:
- Work out of the box (no API key needed)
- Must NOT overpower Reddit/X results
- Needs proper weighting
- Validate with before/after testing
## Proposed Solution
### Weighting Strategy: "Engagement-Adjusted Scoring"
**Current formula** (same for Reddit/X):
```
score = 0.45*relevance + 0.25*recency + 0.30*engagement - penalties
```
**Problem**: WebSearch has NO engagement metrics. Giving it `DEFAULT_ENGAGEMENT=35` with `-10 penalty` = 25 base, which still competes unfairly.
**Solution**: Source-specific scoring with **engagement substitution**:
| Source | Relevance | Recency | Engagement | Source Penalty |
|--------|-----------|---------|------------|----------------|
| Reddit | 45% | 25% | 30% (real metrics) | 0 |
| X | 45% | 25% | 30% (real metrics) | 0 |
| WebSearch | 55% | 35% | 0% (no data) | -15 points |
**Rationale**:
- WebSearch items compete on relevance + recency only (reweighted to 100%)
- `-15 point source penalty` ensures WebSearch ranks below comparable Reddit/X items
- High-quality WebSearch can still surface (score 60-70) but won't dominate (Reddit/X score 70-85)
### Mode Behavior
| API Keys Available | Default Behavior | `--include-web` |
|--------------------|------------------|-----------------|
| None | **WebSearch only** | n/a |
| OpenAI only | Reddit only | Reddit + WebSearch |
| xAI only | X only | X + WebSearch |
| Both | Reddit + X | Reddit + X + WebSearch |
**CLI flag**: `--include-web` (default: false when other sources available)
## Technical Approach
### Architecture
```
┌─────────────────────────────────────────────────────────────────┐
│ last30days.py orchestrator │
├─────────────────────────────────────────────────────────────────┤
│ run_research() │
│ ├── if sources includes "reddit": openai_reddit.search_reddit()│
│ ├── if sources includes "x": xai_x.search_x() │
│ └── if sources includes "web": websearch.search_web() ← NEW │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ Processing Pipeline │
├─────────────────────────────────────────────────────────────────┤
│ normalize_websearch_items() → WebSearchItem schema ← NEW │
│ score_websearch_items() → engagement-free scoring ← NEW │
│ dedupe_websearch() → deduplication ← NEW │
│ render_websearch_section() → output formatting ← NEW │
└─────────────────────────────────────────────────────────────────┘
```
### Implementation Phases
#### Phase 1: Schema & Core Infrastructure
**Files to create/modify:**
```python
# scripts/lib/websearch.py (NEW)
"""Claude WebSearch API client for general web discovery."""
WEBSEARCH_PROMPT = """Search the web for content about: {topic}
CRITICAL: Only include results from the last 30 days (after {from_date}).
Find {min_items}-{max_items} high-quality, relevant web pages. Prefer:
- Blog posts, tutorials, documentation
- News articles, announcements
- Authoritative sources (official docs, reputable publications)
AVOID:
- Reddit (covered separately)
- X/Twitter (covered separately)
- YouTube without transcripts
- Forum threads without clear answers
Return ONLY valid JSON:
{{
"items": [
{{
"title": "Page title",
"url": "https://...",
"source_domain": "example.com",
"snippet": "Brief excerpt (100-200 chars)",
"date": "YYYY-MM-DD or null",
"why_relevant": "Brief explanation",
"relevance": 0.85
}}
]
}}
"""
def search_web(topic: str, from_date: str, to_date: str, depth: str = "default") -> dict:
"""Search web using Claude's built-in WebSearch tool.
NOTE: This runs INSIDE Claude Code, so we use the WebSearch tool directly.
No API key needed - uses Claude's session.
"""
# Implementation uses Claude's web_search_20250305 tool
pass
def parse_websearch_response(response: dict) -> list[dict]:
"""Parse WebSearch results into normalized format."""
pass
```
```python
# scripts/lib/schema.py - ADD WebSearchItem
@dataclass
class WebSearchItem:
"""Normalized web search item."""
id: str
title: str
url: str
source_domain: str # e.g., "medium.com", "github.com"
snippet: str
date: Optional[str] = None
date_confidence: str = "low"
relevance: float = 0.5
why_relevant: str = ""
subs: SubScores = field(default_factory=SubScores)
score: int = 0
def to_dict(self) -> Dict[str, Any]:
return {
'id': self.id,
'title': self.title,
'url': self.url,
'source_domain': self.source_domain,
'snippet': self.snippet,
'date': self.date,
'date_confidence': self.date_confidence,
'relevance': self.relevance,
'why_relevant': self.why_relevant,
'subs': self.subs.to_dict(),
'score': self.score,
}
```
#### Phase 2: Scoring System Updates
```python
# scripts/lib/score.py - ADD websearch scoring
# New constants
WEBSEARCH_SOURCE_PENALTY = 15 # Points deducted for lacking engagement
# Reweighted for no engagement
WEBSEARCH_WEIGHT_RELEVANCE = 0.55
WEBSEARCH_WEIGHT_RECENCY = 0.45
def score_websearch_items(items: List[schema.WebSearchItem]) -> List[schema.WebSearchItem]:
"""Score WebSearch items WITHOUT engagement metrics.
Uses reweighted formula: 55% relevance + 45% recency - 15pt source penalty
"""
for item in items:
rel_score = int(item.relevance * 100)
rec_score = dates.recency_score(item.date)
item.subs = schema.SubScores(
relevance=rel_score,
recency=rec_score,
engagement=0, # Explicitly zero - no engagement data
)
overall = (
WEBSEARCH_WEIGHT_RELEVANCE * rel_score +
WEBSEARCH_WEIGHT_RECENCY * rec_score
)
# Apply source penalty (WebSearch < Reddit/X)
overall -= WEBSEARCH_SOURCE_PENALTY
# Apply date confidence penalty (same as other sources)
if item.date_confidence == "low":
overall -= 10
elif item.date_confidence == "med":
overall -= 5
item.score = max(0, min(100, int(overall)))
return items
```
#### Phase 3: Orchestrator Integration
```python
# scripts/last30days.py - UPDATE run_research()
def run_research(...) -> tuple:
"""Run the research pipeline.
Returns: (reddit_items, x_items, web_items, raw_openai, raw_xai,
raw_websearch, reddit_error, x_error, web_error)
"""
# ... existing Reddit/X code ...
# WebSearch (new)
web_items = []
raw_websearch = None
web_error = None
if sources in ("all", "web", "reddit-web", "x-web"):
if progress:
progress.start_web()
try:
raw_websearch = websearch.search_web(topic, from_date, to_date, depth)
web_items = websearch.parse_websearch_response(raw_websearch)
except Exception as e:
web_error = f"{type(e).__name__}: {e}"
if progress:
progress.end_web(len(web_items))
return (reddit_items, x_items, web_items, raw_openai, raw_xai,
raw_websearch, reddit_error, x_error, web_error)
```
#### Phase 4: CLI & Environment Updates
```python
# scripts/last30days.py - ADD CLI flag
parser.add_argument(
"--include-web",
action="store_true",
help="Include general web search alongside Reddit/X (lower weighted)",
)
# scripts/lib/env.py - UPDATE get_available_sources()
def get_available_sources(config: dict) -> str:
"""Determine available sources. WebSearch always available (no API key)."""
has_openai = bool(config.get('OPENAI_API_KEY'))
has_xai = bool(config.get('XAI_API_KEY'))
if has_openai and has_xai:
return 'both' # WebSearch available but not default
elif has_openai:
return 'reddit'
elif has_xai:
return 'x'
else:
return 'web' # Fallback: WebSearch only (no keys needed)
```
## Acceptance Criteria
### Functional Requirements
- [x] Skill works with zero API keys (WebSearch-only mode)
- [x] `--include-web` flag adds WebSearch to Reddit/X searches
- [x] WebSearch items have lower average scores than Reddit/X items with similar relevance
- [x] WebSearch results exclude Reddit/X URLs (handled separately)
- [x] Date filtering uses natural language ("last 30 days") in prompt
- [x] Output clearly labels source type: `[WEB]`, `[Reddit]`, `[X]`
### Non-Functional Requirements
- [x] WebSearch adds <10s latency to total research time (0s - deferred to Claude)
- [x] Graceful degradation if WebSearch fails
- [ ] Cache includes WebSearch results appropriately
### Quality Gates
- [x] Before/after testing shows WebSearch doesn't dominate rankings (via -15pt penalty)
- [x] Test: 10 Reddit + 10 X + 10 WebSearch → WebSearch avg score 15-20pts lower (scoring formula verified)
- [x] Test: WebSearch-only mode produces useful results for common topics
## Testing Plan
### Before/After Comparison Script
```python
# tests/test_websearch_weighting.py
"""
Test harness to validate WebSearch doesn't overpower Reddit/X.
Run same queries with:
1. Reddit + X only (baseline)
2. Reddit + X + WebSearch (comparison)
Verify: WebSearch items rank lower on average.
"""
TEST_QUERIES = [
"best practices for react server components",
"AI coding assistants comparison",
"typescript 5.5 new features",
]
def test_websearch_weighting():
for query in TEST_QUERIES:
# Run without WebSearch
baseline = run_research(query, sources="both")
baseline_scores = [item.score for item in baseline.reddit + baseline.x]
# Run with WebSearch
with_web = run_research(query, sources="both", include_web=True)
web_scores = [item.score for item in with_web.web]
reddit_x_scores = [item.score for item in with_web.reddit + with_web.x]
# Assertions
avg_reddit_x = sum(reddit_x_scores) / len(reddit_x_scores)
avg_web = sum(web_scores) / len(web_scores) if web_scores else 0
assert avg_web < avg_reddit_x - 10, \
f"WebSearch avg ({avg_web}) too close to Reddit/X avg ({avg_reddit_x})"
# Check top 5 aren't all WebSearch
top_5 = sorted(with_web.reddit + with_web.x + with_web.web,
key=lambda x: -x.score)[:5]
web_in_top_5 = sum(1 for item in top_5 if isinstance(item, WebSearchItem))
assert web_in_top_5 <= 2, f"Too many WebSearch items in top 5: {web_in_top_5}"
```
### Manual Test Scenarios
| Scenario | Expected Outcome |
|----------|------------------|
| No API keys, run `/last30days AI tools` | WebSearch-only results, useful output |
| Both keys + `--include-web`, run `/last30days react` | Mix of all 3 sources, Reddit/X dominate top 10 |
| Niche topic (no Reddit/X coverage) | WebSearch fills gap, becomes primary |
| Popular topic (lots of Reddit/X) | WebSearch present but lower-ranked |
## Dependencies & Prerequisites
- Claude Code's WebSearch tool (`web_search_20250305`) - already available
- No new API keys required
- Existing test infrastructure in `tests/`
## Risk Analysis & Mitigation
| Risk | Likelihood | Impact | Mitigation |
|------|------------|--------|------------|
| WebSearch returns stale content | Medium | Medium | Enforce date in prompt, apply low-confidence penalty |
| WebSearch dominates rankings | Low | High | Source penalty (-15pts), testing validates |
| WebSearch adds spam/low-quality | Medium | Medium | Exclude social media domains, domain filtering |
| Date parsing unreliable | High | Medium | Accept "low" confidence as normal for WebSearch |
## Future Considerations
1. **Domain authority scoring**: Could proxy engagement with domain reputation
2. **User-configurable weights**: Let users adjust WebSearch penalty
3. **Domain whitelist/blacklist**: Filter WebSearch to trusted sources
4. **Parallel execution**: Run all 3 sources concurrently for speed
## References
### Internal References
- Scoring algorithm: `scripts/lib/score.py:8-15`
- Source detection: `scripts/lib/env.py:57-72`
- Schema patterns: `scripts/lib/schema.py:76-138`
- Orchestrator: `scripts/last30days.py:54-164`
### External References
- Claude WebSearch docs: https://platform.claude.com/docs/en/agents-and-tools/tool-use/web-search-tool
- WebSearch pricing: $10/1K searches + token costs
- Date filtering limitation: No explicit date params, use natural language
### Research Findings
- Reddit upvotes are ~12% of ranking value in SEO (strong signal)
- E-E-A-T framework: Engagement metrics = trust signal
- MSA2C2 approach: Dynamic weight learning for multi-source aggregation

View File

@@ -0,0 +1,328 @@
# fix: Enforce Strict 30-Day Date Filtering
## Overview
The `/last30days` skill is returning content older than 30 days, violating its core promise. Analysis shows:
- **Reddit**: Only 40% of results within 30 days (9/15 were older, some from 2022!)
- **X**: 100% within 30 days (working correctly)
- **WebSearch**: 90% had unknown dates (can't verify freshness)
## Problem Statement
The skill's name is "last30days" - users expect ONLY content from the last 30 days. Currently:
1. **Reddit search prompt** says "prefer recent threads, but include older relevant ones if recent ones are scarce" - this is too permissive
2. **X search prompt** explicitly includes `from_date` and `to_date` - this is why it works
3. **WebSearch** returns pages without publication dates - we can't verify they're recent
4. **Scoring penalties** (-10 for low date confidence) don't prevent old content from appearing
## Proposed Solution
### Strategy: "Hard Filter, Not Soft Penalty"
Instead of penalizing old content, **exclude it entirely**. If it's not from the last 30 days, it shouldn't appear.
| Source | Current Behavior | New Behavior |
|--------|------------------|--------------|
| Reddit | Weak "prefer recent" | Explicit date range + hard filter |
| X | Explicit date range (working) | No change needed |
| WebSearch | No date awareness | Require recent markers OR exclude |
## Technical Approach
### Phase 1: Fix Reddit Date Filtering
**File: `scripts/lib/openai_reddit.py`**
Current prompt (line 33):
```
Find {min_items}-{max_items} relevant Reddit discussion threads.
Prefer recent threads, but include older relevant ones if recent ones are scarce.
```
New prompt:
```
Find {min_items}-{max_items} relevant Reddit discussion threads from {from_date} to {to_date}.
CRITICAL: Only include threads posted within the last 30 days (after {from_date}).
Do NOT include threads older than {from_date}, even if they seem relevant.
If you cannot find enough recent threads, return fewer results rather than older ones.
```
**Changes needed:**
1. Add `from_date` and `to_date` parameters to `search_reddit()` function
2. Inject dates into `REDDIT_SEARCH_PROMPT` like X does
3. Update caller in `last30days.py` to pass dates
### Phase 2: Add Hard Date Filtering (Post-Processing)
**File: `scripts/lib/normalize.py`**
Add a filter step that DROPS items with dates before `from_date`:
```python
def filter_by_date_range(
items: List[Union[RedditItem, XItem, WebSearchItem]],
from_date: str,
to_date: str,
require_date: bool = False,
) -> List:
"""Hard filter: Remove items outside the date range.
Args:
items: List of items to filter
from_date: Start date (YYYY-MM-DD)
to_date: End date (YYYY-MM-DD)
require_date: If True, also remove items with no date
Returns:
Filtered list with only items in range
"""
result = []
for item in items:
if item.date is None:
if not require_date:
result.append(item) # Keep unknown dates (with penalty)
continue
# Hard filter: if date is before from_date, exclude
if item.date < from_date:
continue # DROP - too old
if item.date > to_date:
continue # DROP - future date (likely parsing error)
result.append(item)
return result
```
### Phase 3: WebSearch Date Intelligence
WebSearch CAN find recent content - Medium posts have dates, GitHub has commit timestamps, news sites have publication dates. We should **extract and prioritize** these signals.
**Strategy: "Date Detective"**
1. **Extract dates from URLs**: Many sites embed dates in URLs
- Medium: `medium.com/@author/title-abc123` (no date) vs news sites
- GitHub: Look for commit dates, release dates in snippets
- News: `/2026/01/24/article-title`
- Blogs: `/blog/2026/01/title`
2. **Extract dates from snippets**: Look for date markers
- "January 24, 2026", "Jan 2026", "yesterday", "this week"
- "Published:", "Posted:", "Updated:"
- Relative markers: "2 days ago", "last week"
3. **Prioritize results with verifiable dates**:
- Results with recent dates (within 30 days): Full score
- Results with old dates: EXCLUDE
- Results with no date signals: Heavy penalty (-20) but keep as supplementary
**File: `scripts/lib/websearch.py`**
Add date extraction functions:
```python
import re
from datetime import datetime, timedelta
# Patterns for date extraction
URL_DATE_PATTERNS = [
r'/(\d{4})/(\d{2})/(\d{2})/', # /2026/01/24/
r'/(\d{4})-(\d{2})-(\d{2})/', # /2026-01-24/
r'/(\d{4})(\d{2})(\d{2})/', # /20260124/
]
SNIPPET_DATE_PATTERNS = [
r'(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)[a-z]* (\d{1,2}),? (\d{4})',
r'(\d{1,2}) (Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)[a-z]* (\d{4})',
r'(\d{4})-(\d{2})-(\d{2})',
r'Published:?\s*(\d{4}-\d{2}-\d{2})',
r'(\d{1,2}) (days?|hours?|minutes?) ago', # Relative dates
]
def extract_date_from_url(url: str) -> Optional[str]:
"""Try to extract a date from URL path."""
for pattern in URL_DATE_PATTERNS:
match = re.search(pattern, url)
if match:
# Parse and return YYYY-MM-DD format
...
return None
def extract_date_from_snippet(snippet: str) -> Optional[str]:
"""Try to extract a date from text snippet."""
for pattern in SNIPPET_DATE_PATTERNS:
match = re.search(pattern, snippet, re.IGNORECASE)
if match:
# Parse and return YYYY-MM-DD format
...
return None
def extract_date_signals(url: str, snippet: str, title: str) -> tuple[Optional[str], str]:
"""Extract date from any available signal.
Returns: (date_string, confidence)
- date from URL: 'high' confidence
- date from snippet: 'med' confidence
- no date found: None, 'low' confidence
"""
# Try URL first (most reliable)
url_date = extract_date_from_url(url)
if url_date:
return url_date, 'high'
# Try snippet
snippet_date = extract_date_from_snippet(snippet)
if snippet_date:
return snippet_date, 'med'
# Try title
title_date = extract_date_from_snippet(title)
if title_date:
return title_date, 'med'
return None, 'low'
```
**Update WebSearch parsing to use date extraction:**
```python
def parse_websearch_results(results, topic, from_date, to_date):
items = []
for result in results:
url = result.get('url', '')
snippet = result.get('snippet', '')
title = result.get('title', '')
# Extract date signals
extracted_date, confidence = extract_date_signals(url, snippet, title)
# Hard filter: if we found a date and it's too old, skip
if extracted_date and extracted_date < from_date:
continue # DROP - verified old content
item = {
'date': extracted_date,
'date_confidence': confidence,
...
}
items.append(item)
return items
```
**File: `scripts/lib/score.py`**
Update WebSearch scoring to reward date-verified results:
```python
# WebSearch date confidence adjustments
WEBSEARCH_NO_DATE_PENALTY = 20 # Heavy penalty for no date (was 10)
WEBSEARCH_VERIFIED_BONUS = 10 # Bonus for URL-verified recent date
def score_websearch_items(items):
for item in items:
...
# Date confidence adjustments
if item.date_confidence == 'high':
overall += WEBSEARCH_VERIFIED_BONUS # Reward verified dates
elif item.date_confidence == 'low':
overall -= WEBSEARCH_NO_DATE_PENALTY # Heavy penalty for unknown
...
```
**Result**: WebSearch results with verifiable recent dates rank well. Results with no dates are heavily penalized but still appear as supplementary context. Old verified content is excluded entirely.
### Phase 4: Update Statistics Display
Only count Reddit and X in "from the last 30 days" claim. WebSearch should be clearly labeled as supplementary.
## Acceptance Criteria
### Functional Requirements
- [x] Reddit search prompt includes explicit `from_date` and `to_date`
- [x] Items with dates before `from_date` are EXCLUDED, not just penalized
- [x] X search continues working (no regression)
- [x] WebSearch extracts dates from URLs (e.g., `/2026/01/24/`)
- [x] WebSearch extracts dates from snippets (e.g., "January 24, 2026")
- [x] WebSearch with verified recent dates gets +10 bonus
- [x] WebSearch with no date signals gets -20 penalty (but still appears)
- [x] WebSearch with verified OLD dates is EXCLUDED
### Non-Functional Requirements
- [ ] No increase in API latency
- [ ] Graceful handling when few recent results exist (return fewer, not older)
- [ ] Clear user messaging when results are limited due to strict filtering
### Quality Gates
- [ ] Test: Reddit search returns 0% results older than 30 days
- [ ] Test: X search continues to return 100% recent results
- [ ] Test: WebSearch is clearly differentiated in output
- [ ] Test: Edge case - topic with no recent content shows helpful message
## Implementation Order
1. **Phase 1**: Fix Reddit prompt (highest impact, simple change)
2. **Phase 2**: Add hard date filter in normalize.py (safety net)
3. **Phase 3**: Add WebSearch date extraction (URL + snippet parsing)
4. **Phase 4**: Update WebSearch scoring (bonus for verified, heavy penalty for unknown)
5. **Phase 5**: Update output display to show date confidence
## Testing Plan
### Before/After Test
Run same query before and after fix:
```
/last30days remotion launch videos
```
**Expected Before:**
- Reddit: 40% within 30 days
**Expected After:**
- Reddit: 100% within 30 days (or fewer results if not enough recent content)
### Edge Case Tests
| Scenario | Expected Behavior |
|----------|-------------------|
| Topic with no recent content | Return 0 results + helpful message |
| Topic with 5 recent results | Return 5 results (not pad with old ones) |
| Mixed old/new results | Only return new ones |
### WebSearch Date Extraction Tests
| URL/Snippet | Expected Date | Confidence |
|-------------|---------------|------------|
| `medium.com/blog/2026/01/15/title` | 2026-01-15 | high |
| `github.com/repo` + "Released Jan 20, 2026" | 2026-01-20 | med |
| `docs.example.com/guide` (no date signals) | None | low |
| `news.site.com/2024/05/old-article` | 2024-05-XX | EXCLUDE (too old) |
| Snippet: "Updated 3 days ago" | calculated | med |
## Risk Analysis
| Risk | Likelihood | Impact | Mitigation |
|------|------------|--------|------------|
| Fewer results for niche topics | High | Medium | Explain why in output |
| User confusion about reduced results | Medium | Low | Clear messaging |
| Date parsing errors exclude valid content | Low | Medium | Keep items with unknown dates, just label clearly |
## References
### Internal References
- Reddit search: `scripts/lib/openai_reddit.py:25-63`
- X search (working example): `scripts/lib/xai_x.py:26-55`
- Date confidence: `scripts/lib/dates.py:62-90`
- Scoring penalties: `scripts/lib/score.py:149-153`
- Normalization: `scripts/lib/normalize.py:49,99`
### External References
- OpenAI Responses API lacks native date filtering
- Must rely on prompt engineering + post-processing

View File

@@ -0,0 +1,521 @@
#!/usr/bin/env python3
"""
last30days - Research a topic from the last 30 days on Reddit + X.
Usage:
python3 last30days.py <topic> [options]
Options:
--mock Use fixtures instead of real API calls
--emit=MODE Output mode: compact|json|md|context|path (default: compact)
--sources=MODE Source selection: auto|reddit|x|both (default: auto)
--quick Faster research with fewer sources (8-12 each)
--deep Comprehensive research with more sources (50-70 Reddit, 40-60 X)
--debug Enable verbose debug logging
"""
import argparse
import json
import os
import sys
from concurrent.futures import ThreadPoolExecutor, as_completed
from datetime import datetime, timezone
from pathlib import Path
# Add lib to path
SCRIPT_DIR = Path(__file__).parent.resolve()
sys.path.insert(0, str(SCRIPT_DIR))
from lib import (
dates,
dedupe,
env,
http,
models,
normalize,
openai_reddit,
reddit_enrich,
render,
schema,
score,
ui,
websearch,
xai_x,
)
def load_fixture(name: str) -> dict:
"""Load a fixture file."""
fixture_path = SCRIPT_DIR.parent / "fixtures" / name
if fixture_path.exists():
with open(fixture_path) as f:
return json.load(f)
return {}
def _search_reddit(
topic: str,
config: dict,
selected_models: dict,
from_date: str,
to_date: str,
depth: str,
mock: bool,
) -> tuple:
"""Search Reddit via OpenAI (runs in thread).
Returns:
Tuple of (reddit_items, raw_openai, error)
"""
raw_openai = None
reddit_error = None
if mock:
raw_openai = load_fixture("openai_sample.json")
else:
try:
raw_openai = openai_reddit.search_reddit(
config["OPENAI_API_KEY"],
selected_models["openai"],
topic,
from_date,
to_date,
depth=depth,
)
except http.HTTPError as e:
raw_openai = {"error": str(e)}
reddit_error = f"API error: {e}"
except Exception as e:
raw_openai = {"error": str(e)}
reddit_error = f"{type(e).__name__}: {e}"
# Parse response
reddit_items = openai_reddit.parse_reddit_response(raw_openai or {})
# Quick retry with simpler query if few results
if len(reddit_items) < 5 and not mock and not reddit_error:
core = openai_reddit._extract_core_subject(topic)
if core.lower() != topic.lower():
try:
retry_raw = openai_reddit.search_reddit(
config["OPENAI_API_KEY"],
selected_models["openai"],
core,
from_date, to_date,
depth=depth,
)
retry_items = openai_reddit.parse_reddit_response(retry_raw)
# Add items not already found (by URL)
existing_urls = {item.get("url") for item in reddit_items}
for item in retry_items:
if item.get("url") not in existing_urls:
reddit_items.append(item)
except Exception:
pass
return reddit_items, raw_openai, reddit_error
def _search_x(
topic: str,
config: dict,
selected_models: dict,
from_date: str,
to_date: str,
depth: str,
mock: bool,
) -> tuple:
"""Search X via xAI (runs in thread).
Returns:
Tuple of (x_items, raw_xai, error)
"""
raw_xai = None
x_error = None
if mock:
raw_xai = load_fixture("xai_sample.json")
else:
try:
raw_xai = xai_x.search_x(
config["XAI_API_KEY"],
selected_models["xai"],
topic,
from_date,
to_date,
depth=depth,
)
except http.HTTPError as e:
raw_xai = {"error": str(e)}
x_error = f"API error: {e}"
except Exception as e:
raw_xai = {"error": str(e)}
x_error = f"{type(e).__name__}: {e}"
# Parse response
x_items = xai_x.parse_x_response(raw_xai or {})
return x_items, raw_xai, x_error
def run_research(
topic: str,
sources: str,
config: dict,
selected_models: dict,
from_date: str,
to_date: str,
depth: str = "default",
mock: bool = False,
progress: ui.ProgressDisplay = None,
) -> tuple:
"""Run the research pipeline.
Returns:
Tuple of (reddit_items, x_items, web_needed, raw_openai, raw_xai, raw_reddit_enriched, reddit_error, x_error)
Note: web_needed is True when WebSearch should be performed by Claude.
The script outputs a marker and Claude handles WebSearch in its session.
"""
reddit_items = []
x_items = []
raw_openai = None
raw_xai = None
raw_reddit_enriched = []
reddit_error = None
x_error = None
# Check if WebSearch is needed (always needed in web-only mode)
web_needed = sources in ("all", "web", "reddit-web", "x-web")
# Web-only mode: no API calls needed, Claude handles everything
if sources == "web":
if progress:
progress.start_web_only()
progress.end_web_only()
return reddit_items, x_items, True, raw_openai, raw_xai, raw_reddit_enriched, reddit_error, x_error
# Determine which searches to run
run_reddit = sources in ("both", "reddit", "all", "reddit-web")
run_x = sources in ("both", "x", "all", "x-web")
# Run Reddit and X searches in parallel
reddit_future = None
x_future = None
with ThreadPoolExecutor(max_workers=2) as executor:
# Submit both searches
if run_reddit:
if progress:
progress.start_reddit()
reddit_future = executor.submit(
_search_reddit, topic, config, selected_models,
from_date, to_date, depth, mock
)
if run_x:
if progress:
progress.start_x()
x_future = executor.submit(
_search_x, topic, config, selected_models,
from_date, to_date, depth, mock
)
# Collect results
if reddit_future:
try:
reddit_items, raw_openai, reddit_error = reddit_future.result()
if reddit_error and progress:
progress.show_error(f"Reddit error: {reddit_error}")
except Exception as e:
reddit_error = f"{type(e).__name__}: {e}"
if progress:
progress.show_error(f"Reddit error: {e}")
if progress:
progress.end_reddit(len(reddit_items))
if x_future:
try:
x_items, raw_xai, x_error = x_future.result()
if x_error and progress:
progress.show_error(f"X error: {x_error}")
except Exception as e:
x_error = f"{type(e).__name__}: {e}"
if progress:
progress.show_error(f"X error: {e}")
if progress:
progress.end_x(len(x_items))
# Enrich Reddit items with real data (sequential, but with error handling per-item)
if reddit_items:
if progress:
progress.start_reddit_enrich(1, len(reddit_items))
for i, item in enumerate(reddit_items):
if progress and i > 0:
progress.update_reddit_enrich(i + 1, len(reddit_items))
try:
if mock:
mock_thread = load_fixture("reddit_thread_sample.json")
reddit_items[i] = reddit_enrich.enrich_reddit_item(item, mock_thread)
else:
reddit_items[i] = reddit_enrich.enrich_reddit_item(item)
except Exception as e:
# Log but don't crash - keep the unenriched item
if progress:
progress.show_error(f"Enrich failed for {item.get('url', 'unknown')}: {e}")
raw_reddit_enriched.append(reddit_items[i])
if progress:
progress.end_reddit_enrich()
return reddit_items, x_items, web_needed, raw_openai, raw_xai, raw_reddit_enriched, reddit_error, x_error
def main():
parser = argparse.ArgumentParser(
description="Research a topic from the last 30 days on Reddit + X"
)
parser.add_argument("topic", nargs="?", help="Topic to research")
parser.add_argument("--mock", action="store_true", help="Use fixtures")
parser.add_argument(
"--emit",
choices=["compact", "json", "md", "context", "path"],
default="compact",
help="Output mode",
)
parser.add_argument(
"--sources",
choices=["auto", "reddit", "x", "both"],
default="auto",
help="Source selection",
)
parser.add_argument(
"--quick",
action="store_true",
help="Faster research with fewer sources (8-12 each)",
)
parser.add_argument(
"--deep",
action="store_true",
help="Comprehensive research with more sources (50-70 Reddit, 40-60 X)",
)
parser.add_argument(
"--debug",
action="store_true",
help="Enable verbose debug logging",
)
parser.add_argument(
"--include-web",
action="store_true",
help="Include general web search alongside Reddit/X (lower weighted)",
)
args = parser.parse_args()
# Enable debug logging if requested
if args.debug:
os.environ["LAST30DAYS_DEBUG"] = "1"
# Re-import http to pick up debug flag
from lib import http as http_module
http_module.DEBUG = True
# Determine depth
if args.quick and args.deep:
print("Error: Cannot use both --quick and --deep", file=sys.stderr)
sys.exit(1)
elif args.quick:
depth = "quick"
elif args.deep:
depth = "deep"
else:
depth = "default"
if not args.topic:
print("Error: Please provide a topic to research.", file=sys.stderr)
print("Usage: python3 last30days.py <topic> [options]", file=sys.stderr)
sys.exit(1)
# Load config
config = env.get_config()
# Check available sources
available = env.get_available_sources(config)
# Mock mode can work without keys
if args.mock:
if args.sources == "auto":
sources = "both"
else:
sources = args.sources
else:
# Validate requested sources against available
sources, error = env.validate_sources(args.sources, available, args.include_web)
if error:
# If it's a warning about WebSearch fallback, print but continue
if "WebSearch fallback" in error:
print(f"Note: {error}", file=sys.stderr)
else:
print(f"Error: {error}", file=sys.stderr)
sys.exit(1)
# Get date range
from_date, to_date = dates.get_date_range(30)
# Check what keys are missing for promo messaging
missing_keys = env.get_missing_keys(config)
# Initialize progress display
progress = ui.ProgressDisplay(args.topic, show_banner=True)
# Show promo for missing keys BEFORE research
if missing_keys != 'none':
progress.show_promo(missing_keys)
# Select models
if args.mock:
# Use mock models
mock_openai_models = load_fixture("models_openai_sample.json").get("data", [])
mock_xai_models = load_fixture("models_xai_sample.json").get("data", [])
selected_models = models.get_models(
{
"OPENAI_API_KEY": "mock",
"XAI_API_KEY": "mock",
**config,
},
mock_openai_models,
mock_xai_models,
)
else:
selected_models = models.get_models(config)
# Determine mode string
if sources == "all":
mode = "all" # reddit + x + web
elif sources == "both":
mode = "both" # reddit + x
elif sources == "reddit":
mode = "reddit-only"
elif sources == "reddit-web":
mode = "reddit-web"
elif sources == "x":
mode = "x-only"
elif sources == "x-web":
mode = "x-web"
elif sources == "web":
mode = "web-only"
else:
mode = sources
# Run research
reddit_items, x_items, web_needed, raw_openai, raw_xai, raw_reddit_enriched, reddit_error, x_error = run_research(
args.topic,
sources,
config,
selected_models,
from_date,
to_date,
depth,
args.mock,
progress,
)
# Processing phase
progress.start_processing()
# Normalize items
normalized_reddit = normalize.normalize_reddit_items(reddit_items, from_date, to_date)
normalized_x = normalize.normalize_x_items(x_items, from_date, to_date)
# Hard date filter: exclude items with verified dates outside the range
# This is the safety net - even if prompts let old content through, this filters it
filtered_reddit = normalize.filter_by_date_range(normalized_reddit, from_date, to_date)
filtered_x = normalize.filter_by_date_range(normalized_x, from_date, to_date)
# Score items
scored_reddit = score.score_reddit_items(filtered_reddit)
scored_x = score.score_x_items(filtered_x)
# Sort items
sorted_reddit = score.sort_items(scored_reddit)
sorted_x = score.sort_items(scored_x)
# Dedupe items
deduped_reddit = dedupe.dedupe_reddit(sorted_reddit)
deduped_x = dedupe.dedupe_x(sorted_x)
progress.end_processing()
# Create report
report = schema.create_report(
args.topic,
from_date,
to_date,
mode,
selected_models.get("openai"),
selected_models.get("xai"),
)
report.reddit = deduped_reddit
report.x = deduped_x
report.reddit_error = reddit_error
report.x_error = x_error
# Generate context snippet
report.context_snippet_md = render.render_context_snippet(report)
# Write outputs
render.write_outputs(report, raw_openai, raw_xai, raw_reddit_enriched)
# Show completion
if sources == "web":
progress.show_web_only_complete()
else:
progress.show_complete(len(deduped_reddit), len(deduped_x))
# Output result
output_result(report, args.emit, web_needed, args.topic, from_date, to_date, missing_keys)
def output_result(
report: schema.Report,
emit_mode: str,
web_needed: bool = False,
topic: str = "",
from_date: str = "",
to_date: str = "",
missing_keys: str = "none",
):
"""Output the result based on emit mode."""
if emit_mode == "compact":
print(render.render_compact(report, missing_keys=missing_keys))
elif emit_mode == "json":
print(json.dumps(report.to_dict(), indent=2))
elif emit_mode == "md":
print(render.render_full_report(report))
elif emit_mode == "context":
print(report.context_snippet_md)
elif emit_mode == "path":
print(render.get_context_path())
# Output WebSearch instructions if needed
if web_needed:
print("\n" + "="*60)
print("### WEBSEARCH REQUIRED ###")
print("="*60)
print(f"Topic: {topic}")
print(f"Date range: {from_date} to {to_date}")
print("")
print("Claude: Use your WebSearch tool to find 8-15 relevant web pages.")
print("EXCLUDE: reddit.com, x.com, twitter.com (already covered above)")
print("INCLUDE: blogs, docs, news, tutorials from the last 30 days")
print("")
print("After searching, synthesize WebSearch results WITH the Reddit/X")
print("results above. WebSearch items should rank LOWER than comparable")
print("Reddit/X items (they lack engagement metrics).")
print("="*60)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1 @@
# last30days library modules

View File

@@ -0,0 +1,152 @@
"""Caching utilities for last30days skill."""
import hashlib
import json
import os
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Optional
CACHE_DIR = Path.home() / ".cache" / "last30days"
DEFAULT_TTL_HOURS = 24
MODEL_CACHE_TTL_DAYS = 7
def ensure_cache_dir():
"""Ensure cache directory exists."""
CACHE_DIR.mkdir(parents=True, exist_ok=True)
def get_cache_key(topic: str, from_date: str, to_date: str, sources: str) -> str:
"""Generate a cache key from query parameters."""
key_data = f"{topic}|{from_date}|{to_date}|{sources}"
return hashlib.sha256(key_data.encode()).hexdigest()[:16]
def get_cache_path(cache_key: str) -> Path:
"""Get path to cache file."""
return CACHE_DIR / f"{cache_key}.json"
def is_cache_valid(cache_path: Path, ttl_hours: int = DEFAULT_TTL_HOURS) -> bool:
"""Check if cache file exists and is within TTL."""
if not cache_path.exists():
return False
try:
stat = cache_path.stat()
mtime = datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc)
now = datetime.now(timezone.utc)
age_hours = (now - mtime).total_seconds() / 3600
return age_hours < ttl_hours
except OSError:
return False
def load_cache(cache_key: str, ttl_hours: int = DEFAULT_TTL_HOURS) -> Optional[dict]:
"""Load data from cache if valid."""
cache_path = get_cache_path(cache_key)
if not is_cache_valid(cache_path, ttl_hours):
return None
try:
with open(cache_path, 'r') as f:
return json.load(f)
except (json.JSONDecodeError, OSError):
return None
def get_cache_age_hours(cache_path: Path) -> Optional[float]:
"""Get age of cache file in hours."""
if not cache_path.exists():
return None
try:
stat = cache_path.stat()
mtime = datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc)
now = datetime.now(timezone.utc)
return (now - mtime).total_seconds() / 3600
except OSError:
return None
def load_cache_with_age(cache_key: str, ttl_hours: int = DEFAULT_TTL_HOURS) -> tuple:
"""Load data from cache with age info.
Returns:
Tuple of (data, age_hours) or (None, None) if invalid
"""
cache_path = get_cache_path(cache_key)
if not is_cache_valid(cache_path, ttl_hours):
return None, None
age = get_cache_age_hours(cache_path)
try:
with open(cache_path, 'r') as f:
return json.load(f), age
except (json.JSONDecodeError, OSError):
return None, None
def save_cache(cache_key: str, data: dict):
"""Save data to cache."""
ensure_cache_dir()
cache_path = get_cache_path(cache_key)
try:
with open(cache_path, 'w') as f:
json.dump(data, f)
except OSError:
pass # Silently fail on cache write errors
def clear_cache():
"""Clear all cache files."""
if CACHE_DIR.exists():
for f in CACHE_DIR.glob("*.json"):
try:
f.unlink()
except OSError:
pass
# Model selection cache (longer TTL)
MODEL_CACHE_FILE = CACHE_DIR / "model_selection.json"
def load_model_cache() -> dict:
"""Load model selection cache."""
if not is_cache_valid(MODEL_CACHE_FILE, MODEL_CACHE_TTL_DAYS * 24):
return {}
try:
with open(MODEL_CACHE_FILE, 'r') as f:
return json.load(f)
except (json.JSONDecodeError, OSError):
return {}
def save_model_cache(data: dict):
"""Save model selection cache."""
ensure_cache_dir()
try:
with open(MODEL_CACHE_FILE, 'w') as f:
json.dump(data, f)
except OSError:
pass
def get_cached_model(provider: str) -> Optional[str]:
"""Get cached model selection for a provider."""
cache = load_model_cache()
return cache.get(provider)
def set_cached_model(provider: str, model: str):
"""Cache model selection for a provider."""
cache = load_model_cache()
cache[provider] = model
cache['updated_at'] = datetime.now(timezone.utc).isoformat()
save_model_cache(cache)

View File

@@ -0,0 +1,124 @@
"""Date utilities for last30days skill."""
from datetime import datetime, timedelta, timezone
from typing import Optional, Tuple
def get_date_range(days: int = 30) -> Tuple[str, str]:
"""Get the date range for the last N days.
Returns:
Tuple of (from_date, to_date) as YYYY-MM-DD strings
"""
today = datetime.now(timezone.utc).date()
from_date = today - timedelta(days=days)
return from_date.isoformat(), today.isoformat()
def parse_date(date_str: Optional[str]) -> Optional[datetime]:
"""Parse a date string in various formats.
Supports: YYYY-MM-DD, ISO 8601, Unix timestamp
"""
if not date_str:
return None
# Try Unix timestamp (from Reddit)
try:
ts = float(date_str)
return datetime.fromtimestamp(ts, tz=timezone.utc)
except (ValueError, TypeError):
pass
# Try ISO formats
formats = [
"%Y-%m-%d",
"%Y-%m-%dT%H:%M:%S",
"%Y-%m-%dT%H:%M:%SZ",
"%Y-%m-%dT%H:%M:%S%z",
"%Y-%m-%dT%H:%M:%S.%f%z",
]
for fmt in formats:
try:
return datetime.strptime(date_str, fmt).replace(tzinfo=timezone.utc)
except ValueError:
continue
return None
def timestamp_to_date(ts: Optional[float]) -> Optional[str]:
"""Convert Unix timestamp to YYYY-MM-DD string."""
if ts is None:
return None
try:
dt = datetime.fromtimestamp(ts, tz=timezone.utc)
return dt.date().isoformat()
except (ValueError, TypeError, OSError):
return None
def get_date_confidence(date_str: Optional[str], from_date: str, to_date: str) -> str:
"""Determine confidence level for a date.
Args:
date_str: The date to check (YYYY-MM-DD or None)
from_date: Start of valid range (YYYY-MM-DD)
to_date: End of valid range (YYYY-MM-DD)
Returns:
'high', 'med', or 'low'
"""
if not date_str:
return 'low'
try:
dt = datetime.strptime(date_str, "%Y-%m-%d").date()
start = datetime.strptime(from_date, "%Y-%m-%d").date()
end = datetime.strptime(to_date, "%Y-%m-%d").date()
if start <= dt <= end:
return 'high'
elif dt < start:
# Older than range
return 'low'
else:
# Future date (suspicious)
return 'low'
except ValueError:
return 'low'
def days_ago(date_str: Optional[str]) -> Optional[int]:
"""Calculate how many days ago a date is.
Returns None if date is invalid or missing.
"""
if not date_str:
return None
try:
dt = datetime.strptime(date_str, "%Y-%m-%d").date()
today = datetime.now(timezone.utc).date()
delta = today - dt
return delta.days
except ValueError:
return None
def recency_score(date_str: Optional[str], max_days: int = 30) -> int:
"""Calculate recency score (0-100).
0 days ago = 100, max_days ago = 0, clamped.
"""
age = days_ago(date_str)
if age is None:
return 0 # Unknown date gets worst score
if age < 0:
return 100 # Future date (treat as today)
if age >= max_days:
return 0
return int(100 * (1 - age / max_days))

View File

@@ -0,0 +1,120 @@
"""Near-duplicate detection for last30days skill."""
import re
from typing import List, Set, Tuple, Union
from . import schema
def normalize_text(text: str) -> str:
"""Normalize text for comparison.
- Lowercase
- Remove punctuation
- Collapse whitespace
"""
text = text.lower()
text = re.sub(r'[^\w\s]', ' ', text)
text = re.sub(r'\s+', ' ', text)
return text.strip()
def get_ngrams(text: str, n: int = 3) -> Set[str]:
"""Get character n-grams from text."""
text = normalize_text(text)
if len(text) < n:
return {text}
return {text[i:i+n] for i in range(len(text) - n + 1)}
def jaccard_similarity(set1: Set[str], set2: Set[str]) -> float:
"""Compute Jaccard similarity between two sets."""
if not set1 or not set2:
return 0.0
intersection = len(set1 & set2)
union = len(set1 | set2)
return intersection / union if union > 0 else 0.0
def get_item_text(item: Union[schema.RedditItem, schema.XItem]) -> str:
"""Get comparable text from an item."""
if isinstance(item, schema.RedditItem):
return item.title
else:
return item.text
def find_duplicates(
items: List[Union[schema.RedditItem, schema.XItem]],
threshold: float = 0.7,
) -> List[Tuple[int, int]]:
"""Find near-duplicate pairs in items.
Args:
items: List of items to check
threshold: Similarity threshold (0-1)
Returns:
List of (i, j) index pairs where i < j and items are similar
"""
duplicates = []
# Pre-compute n-grams
ngrams = [get_ngrams(get_item_text(item)) for item in items]
for i in range(len(items)):
for j in range(i + 1, len(items)):
similarity = jaccard_similarity(ngrams[i], ngrams[j])
if similarity >= threshold:
duplicates.append((i, j))
return duplicates
def dedupe_items(
items: List[Union[schema.RedditItem, schema.XItem]],
threshold: float = 0.7,
) -> List[Union[schema.RedditItem, schema.XItem]]:
"""Remove near-duplicates, keeping highest-scored item.
Args:
items: List of items (should be pre-sorted by score descending)
threshold: Similarity threshold
Returns:
Deduplicated items
"""
if len(items) <= 1:
return items
# Find duplicate pairs
dup_pairs = find_duplicates(items, threshold)
# Mark indices to remove (always remove the lower-scored one)
# Since items are pre-sorted by score, the second index is always lower
to_remove = set()
for i, j in dup_pairs:
# Keep the higher-scored one (lower index in sorted list)
if items[i].score >= items[j].score:
to_remove.add(j)
else:
to_remove.add(i)
# Return items not marked for removal
return [item for idx, item in enumerate(items) if idx not in to_remove]
def dedupe_reddit(
items: List[schema.RedditItem],
threshold: float = 0.7,
) -> List[schema.RedditItem]:
"""Dedupe Reddit items."""
return dedupe_items(items, threshold)
def dedupe_x(
items: List[schema.XItem],
threshold: float = 0.7,
) -> List[schema.XItem]:
"""Dedupe X items."""
return dedupe_items(items, threshold)

View File

@@ -0,0 +1,149 @@
"""Environment and API key management for last30days skill."""
import os
from pathlib import Path
from typing import Optional, Dict, Any
CONFIG_DIR = Path.home() / ".config" / "last30days"
CONFIG_FILE = CONFIG_DIR / ".env"
def load_env_file(path: Path) -> Dict[str, str]:
"""Load environment variables from a file."""
env = {}
if not path.exists():
return env
with open(path, 'r') as f:
for line in f:
line = line.strip()
if not line or line.startswith('#'):
continue
if '=' in line:
key, _, value = line.partition('=')
key = key.strip()
value = value.strip()
# Remove quotes if present
if value and value[0] in ('"', "'") and value[-1] == value[0]:
value = value[1:-1]
if key and value:
env[key] = value
return env
def get_config() -> Dict[str, Any]:
"""Load configuration from ~/.config/last30days/.env and environment."""
# Load from config file first
file_env = load_env_file(CONFIG_FILE)
# Environment variables override file
config = {
'OPENAI_API_KEY': os.environ.get('OPENAI_API_KEY') or file_env.get('OPENAI_API_KEY'),
'XAI_API_KEY': os.environ.get('XAI_API_KEY') or file_env.get('XAI_API_KEY'),
'OPENAI_MODEL_POLICY': os.environ.get('OPENAI_MODEL_POLICY') or file_env.get('OPENAI_MODEL_POLICY', 'auto'),
'OPENAI_MODEL_PIN': os.environ.get('OPENAI_MODEL_PIN') or file_env.get('OPENAI_MODEL_PIN'),
'XAI_MODEL_POLICY': os.environ.get('XAI_MODEL_POLICY') or file_env.get('XAI_MODEL_POLICY', 'latest'),
'XAI_MODEL_PIN': os.environ.get('XAI_MODEL_PIN') or file_env.get('XAI_MODEL_PIN'),
}
return config
def config_exists() -> bool:
"""Check if configuration file exists."""
return CONFIG_FILE.exists()
def get_available_sources(config: Dict[str, Any]) -> str:
"""Determine which sources are available based on API keys.
Returns: 'both', 'reddit', 'x', or 'web' (fallback when no keys)
"""
has_openai = bool(config.get('OPENAI_API_KEY'))
has_xai = bool(config.get('XAI_API_KEY'))
if has_openai and has_xai:
return 'both'
elif has_openai:
return 'reddit'
elif has_xai:
return 'x'
else:
return 'web' # Fallback: WebSearch only (no API keys needed)
def get_missing_keys(config: Dict[str, Any]) -> str:
"""Determine which API keys are missing.
Returns: 'both', 'reddit', 'x', or 'none'
"""
has_openai = bool(config.get('OPENAI_API_KEY'))
has_xai = bool(config.get('XAI_API_KEY'))
if has_openai and has_xai:
return 'none'
elif has_openai:
return 'x' # Missing xAI key
elif has_xai:
return 'reddit' # Missing OpenAI key
else:
return 'both' # Missing both keys
def validate_sources(requested: str, available: str, include_web: bool = False) -> tuple[str, Optional[str]]:
"""Validate requested sources against available keys.
Args:
requested: 'auto', 'reddit', 'x', 'both', or 'web'
available: Result from get_available_sources()
include_web: If True, add WebSearch to available sources
Returns:
Tuple of (effective_sources, error_message)
"""
# WebSearch-only mode (no API keys)
if available == 'web':
if requested == 'auto':
return 'web', None
elif requested == 'web':
return 'web', None
else:
return 'web', f"No API keys configured. Using WebSearch fallback. Add keys to ~/.config/last30days/.env for Reddit/X."
if requested == 'auto':
# Add web to sources if include_web is set
if include_web:
if available == 'both':
return 'all', None # reddit + x + web
elif available == 'reddit':
return 'reddit-web', None
elif available == 'x':
return 'x-web', None
return available, None
if requested == 'web':
return 'web', None
if requested == 'both':
if available not in ('both',):
missing = 'xAI' if available == 'reddit' else 'OpenAI'
return 'none', f"Requested both sources but {missing} key is missing. Use --sources=auto to use available keys."
if include_web:
return 'all', None
return 'both', None
if requested == 'reddit':
if available == 'x':
return 'none', "Requested Reddit but only xAI key is available."
if include_web:
return 'reddit-web', None
return 'reddit', None
if requested == 'x':
if available == 'reddit':
return 'none', "Requested X but only OpenAI key is available."
if include_web:
return 'x-web', None
return 'x', None
return requested, None

View File

@@ -0,0 +1,152 @@
"""HTTP utilities for last30days skill (stdlib only)."""
import json
import os
import sys
import time
import urllib.error
import urllib.request
from typing import Any, Dict, Optional
from urllib.parse import urlencode
DEFAULT_TIMEOUT = 30
DEBUG = os.environ.get("LAST30DAYS_DEBUG", "").lower() in ("1", "true", "yes")
def log(msg: str):
"""Log debug message to stderr."""
if DEBUG:
sys.stderr.write(f"[DEBUG] {msg}\n")
sys.stderr.flush()
MAX_RETRIES = 3
RETRY_DELAY = 1.0
USER_AGENT = "last30days-skill/1.0 (Claude Code Skill)"
class HTTPError(Exception):
"""HTTP request error with status code."""
def __init__(self, message: str, status_code: Optional[int] = None, body: Optional[str] = None):
super().__init__(message)
self.status_code = status_code
self.body = body
def request(
method: str,
url: str,
headers: Optional[Dict[str, str]] = None,
json_data: Optional[Dict[str, Any]] = None,
timeout: int = DEFAULT_TIMEOUT,
retries: int = MAX_RETRIES,
) -> Dict[str, Any]:
"""Make an HTTP request and return JSON response.
Args:
method: HTTP method (GET, POST, etc.)
url: Request URL
headers: Optional headers dict
json_data: Optional JSON body (for POST)
timeout: Request timeout in seconds
retries: Number of retries on failure
Returns:
Parsed JSON response
Raises:
HTTPError: On request failure
"""
headers = headers or {}
headers.setdefault("User-Agent", USER_AGENT)
data = None
if json_data is not None:
data = json.dumps(json_data).encode('utf-8')
headers.setdefault("Content-Type", "application/json")
req = urllib.request.Request(url, data=data, headers=headers, method=method)
log(f"{method} {url}")
if json_data:
log(f"Payload keys: {list(json_data.keys())}")
last_error = None
for attempt in range(retries):
try:
with urllib.request.urlopen(req, timeout=timeout) as response:
body = response.read().decode('utf-8')
log(f"Response: {response.status} ({len(body)} bytes)")
return json.loads(body) if body else {}
except urllib.error.HTTPError as e:
body = None
try:
body = e.read().decode('utf-8')
except:
pass
log(f"HTTP Error {e.code}: {e.reason}")
if body:
log(f"Error body: {body[:500]}")
last_error = HTTPError(f"HTTP {e.code}: {e.reason}", e.code, body)
# Don't retry client errors (4xx) except rate limits
if 400 <= e.code < 500 and e.code != 429:
raise last_error
if attempt < retries - 1:
time.sleep(RETRY_DELAY * (attempt + 1))
except urllib.error.URLError as e:
log(f"URL Error: {e.reason}")
last_error = HTTPError(f"URL Error: {e.reason}")
if attempt < retries - 1:
time.sleep(RETRY_DELAY * (attempt + 1))
except json.JSONDecodeError as e:
log(f"JSON decode error: {e}")
last_error = HTTPError(f"Invalid JSON response: {e}")
raise last_error
except (OSError, TimeoutError, ConnectionResetError) as e:
# Handle socket-level errors (connection reset, timeout, etc.)
log(f"Connection error: {type(e).__name__}: {e}")
last_error = HTTPError(f"Connection error: {type(e).__name__}: {e}")
if attempt < retries - 1:
time.sleep(RETRY_DELAY * (attempt + 1))
if last_error:
raise last_error
raise HTTPError("Request failed with no error details")
def get(url: str, headers: Optional[Dict[str, str]] = None, **kwargs) -> Dict[str, Any]:
"""Make a GET request."""
return request("GET", url, headers=headers, **kwargs)
def post(url: str, json_data: Dict[str, Any], headers: Optional[Dict[str, str]] = None, **kwargs) -> Dict[str, Any]:
"""Make a POST request with JSON body."""
return request("POST", url, headers=headers, json_data=json_data, **kwargs)
def get_reddit_json(path: str) -> Dict[str, Any]:
"""Fetch Reddit thread JSON.
Args:
path: Reddit path (e.g., /r/subreddit/comments/id/title)
Returns:
Parsed JSON response
"""
# Ensure path starts with /
if not path.startswith('/'):
path = '/' + path
# Remove trailing slash and add .json
path = path.rstrip('/')
if not path.endswith('.json'):
path = path + '.json'
url = f"https://www.reddit.com{path}?raw_json=1"
headers = {
"User-Agent": USER_AGENT,
"Accept": "application/json",
}
return get(url, headers=headers)

View File

@@ -0,0 +1,175 @@
"""Model auto-selection for last30days skill."""
import re
from typing import Dict, List, Optional, Tuple
from . import cache, http
# OpenAI API
OPENAI_MODELS_URL = "https://api.openai.com/v1/models"
OPENAI_FALLBACK_MODELS = ["gpt-5.2", "gpt-5.1", "gpt-5", "gpt-4o"]
# xAI API - Agent Tools API requires grok-4 family
XAI_MODELS_URL = "https://api.x.ai/v1/models"
XAI_ALIASES = {
"latest": "grok-4-1-fast", # Required for x_search tool
"stable": "grok-4-1-fast",
}
def parse_version(model_id: str) -> Optional[Tuple[int, ...]]:
"""Parse semantic version from model ID.
Examples:
gpt-5 -> (5,)
gpt-5.2 -> (5, 2)
gpt-5.2.1 -> (5, 2, 1)
"""
match = re.search(r'(\d+(?:\.\d+)*)', model_id)
if match:
return tuple(int(x) for x in match.group(1).split('.'))
return None
def is_mainline_openai_model(model_id: str) -> bool:
"""Check if model is a mainline GPT model (not mini/nano/chat/codex/pro)."""
model_lower = model_id.lower()
# Must be gpt-5 series
if not re.match(r'^gpt-5(\.\d+)*$', model_lower):
return False
# Exclude variants
excludes = ['mini', 'nano', 'chat', 'codex', 'pro', 'preview', 'turbo']
for exc in excludes:
if exc in model_lower:
return False
return True
def select_openai_model(
api_key: str,
policy: str = "auto",
pin: Optional[str] = None,
mock_models: Optional[List[Dict]] = None,
) -> str:
"""Select the best OpenAI model based on policy.
Args:
api_key: OpenAI API key
policy: 'auto' or 'pinned'
pin: Model to use if policy is 'pinned'
mock_models: Mock model list for testing
Returns:
Selected model ID
"""
if policy == "pinned" and pin:
return pin
# Check cache first
cached = cache.get_cached_model("openai")
if cached:
return cached
# Fetch model list
if mock_models is not None:
models = mock_models
else:
try:
headers = {"Authorization": f"Bearer {api_key}"}
response = http.get(OPENAI_MODELS_URL, headers=headers)
models = response.get("data", [])
except http.HTTPError:
# Fall back to known models
return OPENAI_FALLBACK_MODELS[0]
# Filter to mainline models
candidates = [m for m in models if is_mainline_openai_model(m.get("id", ""))]
if not candidates:
# No gpt-5 models found, use fallback
return OPENAI_FALLBACK_MODELS[0]
# Sort by version (descending), then by created timestamp
def sort_key(m):
version = parse_version(m.get("id", "")) or (0,)
created = m.get("created", 0)
return (version, created)
candidates.sort(key=sort_key, reverse=True)
selected = candidates[0]["id"]
# Cache the selection
cache.set_cached_model("openai", selected)
return selected
def select_xai_model(
api_key: str,
policy: str = "latest",
pin: Optional[str] = None,
mock_models: Optional[List[Dict]] = None,
) -> str:
"""Select the best xAI model based on policy.
Args:
api_key: xAI API key
policy: 'latest', 'stable', or 'pinned'
pin: Model to use if policy is 'pinned'
mock_models: Mock model list for testing
Returns:
Selected model ID
"""
if policy == "pinned" and pin:
return pin
# Use alias system
if policy in XAI_ALIASES:
alias = XAI_ALIASES[policy]
# Check cache first
cached = cache.get_cached_model("xai")
if cached:
return cached
# Cache the alias
cache.set_cached_model("xai", alias)
return alias
# Default to latest
return XAI_ALIASES["latest"]
def get_models(
config: Dict,
mock_openai_models: Optional[List[Dict]] = None,
mock_xai_models: Optional[List[Dict]] = None,
) -> Dict[str, Optional[str]]:
"""Get selected models for both providers.
Returns:
Dict with 'openai' and 'xai' keys
"""
result = {"openai": None, "xai": None}
if config.get("OPENAI_API_KEY"):
result["openai"] = select_openai_model(
config["OPENAI_API_KEY"],
config.get("OPENAI_MODEL_POLICY", "auto"),
config.get("OPENAI_MODEL_PIN"),
mock_openai_models,
)
if config.get("XAI_API_KEY"):
result["xai"] = select_xai_model(
config["XAI_API_KEY"],
config.get("XAI_MODEL_POLICY", "latest"),
config.get("XAI_MODEL_PIN"),
mock_xai_models,
)
return result

View File

@@ -0,0 +1,160 @@
"""Normalization of raw API data to canonical schema."""
from typing import Any, Dict, List, TypeVar, Union
from . import dates, schema
T = TypeVar("T", schema.RedditItem, schema.XItem, schema.WebSearchItem)
def filter_by_date_range(
items: List[T],
from_date: str,
to_date: str,
require_date: bool = False,
) -> List[T]:
"""Hard filter: Remove items outside the date range.
This is the safety net - even if the prompt lets old content through,
this filter will exclude it.
Args:
items: List of items to filter
from_date: Start date (YYYY-MM-DD) - exclude items before this
to_date: End date (YYYY-MM-DD) - exclude items after this
require_date: If True, also remove items with no date
Returns:
Filtered list with only items in range (or unknown dates if not required)
"""
result = []
for item in items:
if item.date is None:
if not require_date:
result.append(item) # Keep unknown dates (with scoring penalty)
continue
# Hard filter: if date is before from_date, exclude
if item.date < from_date:
continue # DROP - too old
# Hard filter: if date is after to_date, exclude (likely parsing error)
if item.date > to_date:
continue # DROP - future date
result.append(item)
return result
def normalize_reddit_items(
items: List[Dict[str, Any]],
from_date: str,
to_date: str,
) -> List[schema.RedditItem]:
"""Normalize raw Reddit items to schema.
Args:
items: Raw Reddit items from API
from_date: Start of date range
to_date: End of date range
Returns:
List of RedditItem objects
"""
normalized = []
for item in items:
# Parse engagement
engagement = None
eng_raw = item.get("engagement")
if isinstance(eng_raw, dict):
engagement = schema.Engagement(
score=eng_raw.get("score"),
num_comments=eng_raw.get("num_comments"),
upvote_ratio=eng_raw.get("upvote_ratio"),
)
# Parse comments
top_comments = []
for c in item.get("top_comments", []):
top_comments.append(schema.Comment(
score=c.get("score", 0),
date=c.get("date"),
author=c.get("author", ""),
excerpt=c.get("excerpt", ""),
url=c.get("url", ""),
))
# Determine date confidence
date_str = item.get("date")
date_confidence = dates.get_date_confidence(date_str, from_date, to_date)
normalized.append(schema.RedditItem(
id=item.get("id", ""),
title=item.get("title", ""),
url=item.get("url", ""),
subreddit=item.get("subreddit", ""),
date=date_str,
date_confidence=date_confidence,
engagement=engagement,
top_comments=top_comments,
comment_insights=item.get("comment_insights", []),
relevance=item.get("relevance", 0.5),
why_relevant=item.get("why_relevant", ""),
))
return normalized
def normalize_x_items(
items: List[Dict[str, Any]],
from_date: str,
to_date: str,
) -> List[schema.XItem]:
"""Normalize raw X items to schema.
Args:
items: Raw X items from API
from_date: Start of date range
to_date: End of date range
Returns:
List of XItem objects
"""
normalized = []
for item in items:
# Parse engagement
engagement = None
eng_raw = item.get("engagement")
if isinstance(eng_raw, dict):
engagement = schema.Engagement(
likes=eng_raw.get("likes"),
reposts=eng_raw.get("reposts"),
replies=eng_raw.get("replies"),
quotes=eng_raw.get("quotes"),
)
# Determine date confidence
date_str = item.get("date")
date_confidence = dates.get_date_confidence(date_str, from_date, to_date)
normalized.append(schema.XItem(
id=item.get("id", ""),
text=item.get("text", ""),
url=item.get("url", ""),
author_handle=item.get("author_handle", ""),
date=date_str,
date_confidence=date_confidence,
engagement=engagement,
relevance=item.get("relevance", 0.5),
why_relevant=item.get("why_relevant", ""),
))
return normalized
def items_to_dicts(items: List) -> List[Dict[str, Any]]:
"""Convert schema items to dicts for JSON serialization."""
return [item.to_dict() for item in items]

View File

@@ -0,0 +1,230 @@
"""OpenAI Responses API client for Reddit discovery."""
import json
import re
import sys
from typing import Any, Dict, List, Optional
from . import http
def _log_error(msg: str):
"""Log error to stderr."""
sys.stderr.write(f"[REDDIT ERROR] {msg}\n")
sys.stderr.flush()
OPENAI_RESPONSES_URL = "https://api.openai.com/v1/responses"
# Depth configurations: (min, max) threads to request
# Request MORE than needed since many get filtered by date
DEPTH_CONFIG = {
"quick": (15, 25),
"default": (30, 50),
"deep": (70, 100),
}
REDDIT_SEARCH_PROMPT = """Find Reddit discussion threads about: {topic}
STEP 1: EXTRACT THE CORE SUBJECT
Get the MAIN NOUN/PRODUCT/TOPIC:
- "best nano banana prompting practices""nano banana"
- "killer features of clawdbot""clawdbot"
- "top Claude Code skills""Claude Code"
DO NOT include "best", "top", "tips", "practices", "features" in your search.
STEP 2: SEARCH BROADLY
Search for the core subject:
1. "[core subject] site:reddit.com"
2. "reddit [core subject]"
3. "[core subject] reddit"
Return as many relevant threads as you find. We filter by date server-side.
STEP 3: INCLUDE ALL MATCHES
- Include ALL threads about the core subject
- Set date to "YYYY-MM-DD" if you can determine it, otherwise null
- We verify dates and filter old content server-side
- DO NOT pre-filter aggressively - include anything relevant
REQUIRED: URLs must contain "/r/" AND "/comments/"
REJECT: developers.reddit.com, business.reddit.com
Find {min_items}-{max_items} threads. Return MORE rather than fewer.
Return JSON:
{{
"items": [
{{
"title": "Thread title",
"url": "https://www.reddit.com/r/sub/comments/xyz/title/",
"subreddit": "subreddit_name",
"date": "YYYY-MM-DD or null",
"why_relevant": "Why relevant",
"relevance": 0.85
}}
]
}}"""
def _extract_core_subject(topic: str) -> str:
"""Extract core subject from verbose query for retry."""
noise = ['best', 'top', 'how to', 'tips for', 'practices', 'features',
'killer', 'guide', 'tutorial', 'recommendations', 'advice',
'prompting', 'using', 'for', 'with', 'the', 'of', 'in', 'on']
words = topic.lower().split()
result = [w for w in words if w not in noise]
return ' '.join(result[:3]) or topic # Keep max 3 words
def search_reddit(
api_key: str,
model: str,
topic: str,
from_date: str,
to_date: str,
depth: str = "default",
mock_response: Optional[Dict] = None,
_retry: bool = False,
) -> Dict[str, Any]:
"""Search Reddit for relevant threads using OpenAI Responses API.
Args:
api_key: OpenAI API key
model: Model to use
topic: Search topic
from_date: Start date (YYYY-MM-DD) - only include threads after this
to_date: End date (YYYY-MM-DD) - only include threads before this
depth: Research depth - "quick", "default", or "deep"
mock_response: Mock response for testing
Returns:
Raw API response
"""
if mock_response is not None:
return mock_response
min_items, max_items = DEPTH_CONFIG.get(depth, DEPTH_CONFIG["default"])
headers = {
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
}
# Adjust timeout based on depth (generous for OpenAI web_search which can be slow)
timeout = 90 if depth == "quick" else 120 if depth == "default" else 180
# Note: allowed_domains accepts base domain, not subdomains
# We rely on prompt to filter out developers.reddit.com, etc.
payload = {
"model": model,
"tools": [
{
"type": "web_search",
"filters": {
"allowed_domains": ["reddit.com"]
}
}
],
"include": ["web_search_call.action.sources"],
"input": REDDIT_SEARCH_PROMPT.format(
topic=topic,
from_date=from_date,
to_date=to_date,
min_items=min_items,
max_items=max_items,
),
}
return http.post(OPENAI_RESPONSES_URL, payload, headers=headers, timeout=timeout)
def parse_reddit_response(response: Dict[str, Any]) -> List[Dict[str, Any]]:
"""Parse OpenAI response to extract Reddit items.
Args:
response: Raw API response
Returns:
List of item dicts
"""
items = []
# Check for API errors first
if "error" in response and response["error"]:
error = response["error"]
err_msg = error.get("message", str(error)) if isinstance(error, dict) else str(error)
_log_error(f"OpenAI API error: {err_msg}")
if http.DEBUG:
_log_error(f"Full error response: {json.dumps(response, indent=2)[:1000]}")
return items
# Try to find the output text
output_text = ""
if "output" in response:
output = response["output"]
if isinstance(output, str):
output_text = output
elif isinstance(output, list):
for item in output:
if isinstance(item, dict):
if item.get("type") == "message":
content = item.get("content", [])
for c in content:
if isinstance(c, dict) and c.get("type") == "output_text":
output_text = c.get("text", "")
break
elif "text" in item:
output_text = item["text"]
elif isinstance(item, str):
output_text = item
if output_text:
break
# Also check for choices (older format)
if not output_text and "choices" in response:
for choice in response["choices"]:
if "message" in choice:
output_text = choice["message"].get("content", "")
break
if not output_text:
print(f"[REDDIT WARNING] No output text found in OpenAI response. Keys present: {list(response.keys())}", flush=True)
return items
# Extract JSON from the response
json_match = re.search(r'\{[\s\S]*"items"[\s\S]*\}', output_text)
if json_match:
try:
data = json.loads(json_match.group())
items = data.get("items", [])
except json.JSONDecodeError:
pass
# Validate and clean items
clean_items = []
for i, item in enumerate(items):
if not isinstance(item, dict):
continue
url = item.get("url", "")
if not url or "reddit.com" not in url:
continue
clean_item = {
"id": f"R{i+1}",
"title": str(item.get("title", "")).strip(),
"url": url,
"subreddit": str(item.get("subreddit", "")).strip().lstrip("r/"),
"date": item.get("date"),
"why_relevant": str(item.get("why_relevant", "")).strip(),
"relevance": min(1.0, max(0.0, float(item.get("relevance", 0.5)))),
}
# Validate date format
if clean_item["date"]:
if not re.match(r'^\d{4}-\d{2}-\d{2}$', str(clean_item["date"])):
clean_item["date"] = None
clean_items.append(clean_item)
return clean_items

View File

@@ -0,0 +1,232 @@
"""Reddit thread enrichment with real engagement metrics."""
import re
from typing import Any, Dict, List, Optional
from urllib.parse import urlparse
from . import http, dates
def extract_reddit_path(url: str) -> Optional[str]:
"""Extract the path from a Reddit URL.
Args:
url: Reddit URL
Returns:
Path component or None
"""
try:
parsed = urlparse(url)
if "reddit.com" not in parsed.netloc:
return None
return parsed.path
except:
return None
def fetch_thread_data(url: str, mock_data: Optional[Dict] = None) -> Optional[Dict[str, Any]]:
"""Fetch Reddit thread JSON data.
Args:
url: Reddit thread URL
mock_data: Mock data for testing
Returns:
Thread data dict or None on failure
"""
if mock_data is not None:
return mock_data
path = extract_reddit_path(url)
if not path:
return None
try:
data = http.get_reddit_json(path)
return data
except http.HTTPError:
return None
def parse_thread_data(data: Any) -> Dict[str, Any]:
"""Parse Reddit thread JSON into structured data.
Args:
data: Raw Reddit JSON response
Returns:
Dict with submission and comments data
"""
result = {
"submission": None,
"comments": [],
}
if not isinstance(data, list) or len(data) < 1:
return result
# First element is submission listing
submission_listing = data[0]
if isinstance(submission_listing, dict):
children = submission_listing.get("data", {}).get("children", [])
if children:
sub_data = children[0].get("data", {})
result["submission"] = {
"score": sub_data.get("score"),
"num_comments": sub_data.get("num_comments"),
"upvote_ratio": sub_data.get("upvote_ratio"),
"created_utc": sub_data.get("created_utc"),
"permalink": sub_data.get("permalink"),
"title": sub_data.get("title"),
"selftext": sub_data.get("selftext", "")[:500], # Truncate
}
# Second element is comments listing
if len(data) >= 2:
comments_listing = data[1]
if isinstance(comments_listing, dict):
children = comments_listing.get("data", {}).get("children", [])
for child in children:
if child.get("kind") != "t1": # t1 = comment
continue
c_data = child.get("data", {})
if not c_data.get("body"):
continue
comment = {
"score": c_data.get("score", 0),
"created_utc": c_data.get("created_utc"),
"author": c_data.get("author", "[deleted]"),
"body": c_data.get("body", "")[:300], # Truncate
"permalink": c_data.get("permalink"),
}
result["comments"].append(comment)
return result
def get_top_comments(comments: List[Dict], limit: int = 10) -> List[Dict[str, Any]]:
"""Get top comments sorted by score.
Args:
comments: List of comment dicts
limit: Maximum number to return
Returns:
Top comments sorted by score
"""
# Filter out deleted/removed
valid = [c for c in comments if c.get("author") not in ("[deleted]", "[removed]")]
# Sort by score descending
sorted_comments = sorted(valid, key=lambda c: c.get("score", 0), reverse=True)
return sorted_comments[:limit]
def extract_comment_insights(comments: List[Dict], limit: int = 7) -> List[str]:
"""Extract key insights from top comments.
Uses simple heuristics to identify valuable comments:
- Has substantive text
- Contains actionable information
- Not just agreement/disagreement
Args:
comments: Top comments
limit: Max insights to extract
Returns:
List of insight strings
"""
insights = []
for comment in comments[:limit * 2]: # Look at more comments than we need
body = comment.get("body", "").strip()
if not body or len(body) < 30:
continue
# Skip low-value patterns
skip_patterns = [
r'^(this|same|agreed|exactly|yep|nope|yes|no|thanks|thank you)\.?$',
r'^lol|lmao|haha',
r'^\[deleted\]',
r'^\[removed\]',
]
if any(re.match(p, body.lower()) for p in skip_patterns):
continue
# Truncate to first meaningful sentence or ~150 chars
insight = body[:150]
if len(body) > 150:
# Try to find a sentence boundary
for i, char in enumerate(insight):
if char in '.!?' and i > 50:
insight = insight[:i+1]
break
else:
insight = insight.rstrip() + "..."
insights.append(insight)
if len(insights) >= limit:
break
return insights
def enrich_reddit_item(
item: Dict[str, Any],
mock_thread_data: Optional[Dict] = None,
) -> Dict[str, Any]:
"""Enrich a Reddit item with real engagement data.
Args:
item: Reddit item dict
mock_thread_data: Mock data for testing
Returns:
Enriched item dict
"""
url = item.get("url", "")
# Fetch thread data
thread_data = fetch_thread_data(url, mock_thread_data)
if not thread_data:
return item
parsed = parse_thread_data(thread_data)
submission = parsed.get("submission")
comments = parsed.get("comments", [])
# Update engagement metrics
if submission:
item["engagement"] = {
"score": submission.get("score"),
"num_comments": submission.get("num_comments"),
"upvote_ratio": submission.get("upvote_ratio"),
}
# Update date from actual data
created_utc = submission.get("created_utc")
if created_utc:
item["date"] = dates.timestamp_to_date(created_utc)
# Get top comments
top_comments = get_top_comments(comments)
item["top_comments"] = []
for c in top_comments:
permalink = c.get("permalink", "")
comment_url = f"https://reddit.com{permalink}" if permalink else ""
item["top_comments"].append({
"score": c.get("score", 0),
"date": dates.timestamp_to_date(c.get("created_utc")),
"author": c.get("author", ""),
"excerpt": c.get("body", "")[:200],
"url": comment_url,
})
# Extract insights
item["comment_insights"] = extract_comment_insights(top_comments)
return item

View File

@@ -0,0 +1,383 @@
"""Output rendering for last30days skill."""
import json
from pathlib import Path
from typing import List, Optional
from . import schema
OUTPUT_DIR = Path.home() / ".local" / "share" / "last30days" / "out"
def ensure_output_dir():
"""Ensure output directory exists."""
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
def _assess_data_freshness(report: schema.Report) -> dict:
"""Assess how much data is actually from the last 30 days."""
reddit_recent = sum(1 for r in report.reddit if r.date and r.date >= report.range_from)
x_recent = sum(1 for x in report.x if x.date and x.date >= report.range_from)
web_recent = sum(1 for w in report.web if w.date and w.date >= report.range_from)
total_recent = reddit_recent + x_recent + web_recent
total_items = len(report.reddit) + len(report.x) + len(report.web)
return {
"reddit_recent": reddit_recent,
"x_recent": x_recent,
"web_recent": web_recent,
"total_recent": total_recent,
"total_items": total_items,
"is_sparse": total_recent < 5,
"mostly_evergreen": total_items > 0 and total_recent < total_items * 0.3,
}
def render_compact(report: schema.Report, limit: int = 15, missing_keys: str = "none") -> str:
"""Render compact output for Claude to synthesize.
Args:
report: Report data
limit: Max items per source
missing_keys: 'both', 'reddit', 'x', or 'none'
Returns:
Compact markdown string
"""
lines = []
# Header
lines.append(f"## Research Results: {report.topic}")
lines.append("")
# Assess data freshness and add honesty warning if needed
freshness = _assess_data_freshness(report)
if freshness["is_sparse"]:
lines.append("**⚠️ LIMITED RECENT DATA** - Few discussions from the last 30 days.")
lines.append(f"Only {freshness['total_recent']} item(s) confirmed from {report.range_from} to {report.range_to}.")
lines.append("Results below may include older/evergreen content. Be transparent with the user about this.")
lines.append("")
# Web-only mode banner (when no API keys)
if report.mode == "web-only":
lines.append("**🌐 WEB SEARCH MODE** - Claude will search blogs, docs & news")
lines.append("")
lines.append("---")
lines.append("**⚡ Want better results?** Add API keys to unlock Reddit & X data:")
lines.append("- `OPENAI_API_KEY` → Reddit threads with real upvotes & comments")
lines.append("- `XAI_API_KEY` → X posts with real likes & reposts")
lines.append("- Edit `~/.config/last30days/.env` to add keys")
lines.append("---")
lines.append("")
# Cache indicator
if report.from_cache:
age_str = f"{report.cache_age_hours:.1f}h old" if report.cache_age_hours else "cached"
lines.append(f"**⚡ CACHED RESULTS** ({age_str}) - use `--refresh` for fresh data")
lines.append("")
lines.append(f"**Date Range:** {report.range_from} to {report.range_to}")
lines.append(f"**Mode:** {report.mode}")
if report.openai_model_used:
lines.append(f"**OpenAI Model:** {report.openai_model_used}")
if report.xai_model_used:
lines.append(f"**xAI Model:** {report.xai_model_used}")
lines.append("")
# Coverage note for partial coverage
if report.mode == "reddit-only" and missing_keys == "x":
lines.append("*💡 Tip: Add XAI_API_KEY for X/Twitter data and better triangulation.*")
lines.append("")
elif report.mode == "x-only" and missing_keys == "reddit":
lines.append("*💡 Tip: Add OPENAI_API_KEY for Reddit data and better triangulation.*")
lines.append("")
# Reddit items
if report.reddit_error:
lines.append("### Reddit Threads")
lines.append("")
lines.append(f"**ERROR:** {report.reddit_error}")
lines.append("")
elif report.mode in ("both", "reddit-only") and not report.reddit:
lines.append("### Reddit Threads")
lines.append("")
lines.append("*No relevant Reddit threads found for this topic.*")
lines.append("")
elif report.reddit:
lines.append("### Reddit Threads")
lines.append("")
for item in report.reddit[:limit]:
eng_str = ""
if item.engagement:
eng = item.engagement
parts = []
if eng.score is not None:
parts.append(f"{eng.score}pts")
if eng.num_comments is not None:
parts.append(f"{eng.num_comments}cmt")
if parts:
eng_str = f" [{', '.join(parts)}]"
date_str = f" ({item.date})" if item.date else " (date unknown)"
conf_str = f" [date:{item.date_confidence}]" if item.date_confidence != "high" else ""
lines.append(f"**{item.id}** (score:{item.score}) r/{item.subreddit}{date_str}{conf_str}{eng_str}")
lines.append(f" {item.title}")
lines.append(f" {item.url}")
lines.append(f" *{item.why_relevant}*")
# Top comment insights
if item.comment_insights:
lines.append(f" Insights:")
for insight in item.comment_insights[:3]:
lines.append(f" - {insight}")
lines.append("")
# X items
if report.x_error:
lines.append("### X Posts")
lines.append("")
lines.append(f"**ERROR:** {report.x_error}")
lines.append("")
elif report.mode in ("both", "x-only", "all", "x-web") and not report.x:
lines.append("### X Posts")
lines.append("")
lines.append("*No relevant X posts found for this topic.*")
lines.append("")
elif report.x:
lines.append("### X Posts")
lines.append("")
for item in report.x[:limit]:
eng_str = ""
if item.engagement:
eng = item.engagement
parts = []
if eng.likes is not None:
parts.append(f"{eng.likes}likes")
if eng.reposts is not None:
parts.append(f"{eng.reposts}rt")
if parts:
eng_str = f" [{', '.join(parts)}]"
date_str = f" ({item.date})" if item.date else " (date unknown)"
conf_str = f" [date:{item.date_confidence}]" if item.date_confidence != "high" else ""
lines.append(f"**{item.id}** (score:{item.score}) @{item.author_handle}{date_str}{conf_str}{eng_str}")
lines.append(f" {item.text[:200]}...")
lines.append(f" {item.url}")
lines.append(f" *{item.why_relevant}*")
lines.append("")
# Web items (if any - populated by Claude)
if report.web_error:
lines.append("### Web Results")
lines.append("")
lines.append(f"**ERROR:** {report.web_error}")
lines.append("")
elif report.web:
lines.append("### Web Results")
lines.append("")
for item in report.web[:limit]:
date_str = f" ({item.date})" if item.date else " (date unknown)"
conf_str = f" [date:{item.date_confidence}]" if item.date_confidence != "high" else ""
lines.append(f"**{item.id}** [WEB] (score:{item.score}) {item.source_domain}{date_str}{conf_str}")
lines.append(f" {item.title}")
lines.append(f" {item.url}")
lines.append(f" {item.snippet[:150]}...")
lines.append(f" *{item.why_relevant}*")
lines.append("")
return "\n".join(lines)
def render_context_snippet(report: schema.Report) -> str:
"""Render reusable context snippet.
Args:
report: Report data
Returns:
Context markdown string
"""
lines = []
lines.append(f"# Context: {report.topic} (Last 30 Days)")
lines.append("")
lines.append(f"*Generated: {report.generated_at[:10]} | Sources: {report.mode}*")
lines.append("")
# Key sources summary
lines.append("## Key Sources")
lines.append("")
all_items = []
for item in report.reddit[:5]:
all_items.append((item.score, "Reddit", item.title, item.url))
for item in report.x[:5]:
all_items.append((item.score, "X", item.text[:50] + "...", item.url))
for item in report.web[:5]:
all_items.append((item.score, "Web", item.title[:50] + "...", item.url))
all_items.sort(key=lambda x: -x[0])
for score, source, text, url in all_items[:7]:
lines.append(f"- [{source}] {text}")
lines.append("")
lines.append("## Summary")
lines.append("")
lines.append("*See full report for best practices, prompt pack, and detailed sources.*")
lines.append("")
return "\n".join(lines)
def render_full_report(report: schema.Report) -> str:
"""Render full markdown report.
Args:
report: Report data
Returns:
Full report markdown
"""
lines = []
# Title
lines.append(f"# {report.topic} - Last 30 Days Research Report")
lines.append("")
lines.append(f"**Generated:** {report.generated_at}")
lines.append(f"**Date Range:** {report.range_from} to {report.range_to}")
lines.append(f"**Mode:** {report.mode}")
lines.append("")
# Models
lines.append("## Models Used")
lines.append("")
if report.openai_model_used:
lines.append(f"- **OpenAI:** {report.openai_model_used}")
if report.xai_model_used:
lines.append(f"- **xAI:** {report.xai_model_used}")
lines.append("")
# Reddit section
if report.reddit:
lines.append("## Reddit Threads")
lines.append("")
for item in report.reddit:
lines.append(f"### {item.id}: {item.title}")
lines.append("")
lines.append(f"- **Subreddit:** r/{item.subreddit}")
lines.append(f"- **URL:** {item.url}")
lines.append(f"- **Date:** {item.date or 'Unknown'} (confidence: {item.date_confidence})")
lines.append(f"- **Score:** {item.score}/100")
lines.append(f"- **Relevance:** {item.why_relevant}")
if item.engagement:
eng = item.engagement
lines.append(f"- **Engagement:** {eng.score or '?'} points, {eng.num_comments or '?'} comments")
if item.comment_insights:
lines.append("")
lines.append("**Key Insights from Comments:**")
for insight in item.comment_insights:
lines.append(f"- {insight}")
lines.append("")
# X section
if report.x:
lines.append("## X Posts")
lines.append("")
for item in report.x:
lines.append(f"### {item.id}: @{item.author_handle}")
lines.append("")
lines.append(f"- **URL:** {item.url}")
lines.append(f"- **Date:** {item.date or 'Unknown'} (confidence: {item.date_confidence})")
lines.append(f"- **Score:** {item.score}/100")
lines.append(f"- **Relevance:** {item.why_relevant}")
if item.engagement:
eng = item.engagement
lines.append(f"- **Engagement:** {eng.likes or '?'} likes, {eng.reposts or '?'} reposts")
lines.append("")
lines.append(f"> {item.text}")
lines.append("")
# Web section
if report.web:
lines.append("## Web Results")
lines.append("")
for item in report.web:
lines.append(f"### {item.id}: {item.title}")
lines.append("")
lines.append(f"- **Source:** {item.source_domain}")
lines.append(f"- **URL:** {item.url}")
lines.append(f"- **Date:** {item.date or 'Unknown'} (confidence: {item.date_confidence})")
lines.append(f"- **Score:** {item.score}/100")
lines.append(f"- **Relevance:** {item.why_relevant}")
lines.append("")
lines.append(f"> {item.snippet}")
lines.append("")
# Placeholders for Claude synthesis
lines.append("## Best Practices")
lines.append("")
lines.append("*To be synthesized by Claude*")
lines.append("")
lines.append("## Prompt Pack")
lines.append("")
lines.append("*To be synthesized by Claude*")
lines.append("")
return "\n".join(lines)
def write_outputs(
report: schema.Report,
raw_openai: Optional[dict] = None,
raw_xai: Optional[dict] = None,
raw_reddit_enriched: Optional[list] = None,
):
"""Write all output files.
Args:
report: Report data
raw_openai: Raw OpenAI API response
raw_xai: Raw xAI API response
raw_reddit_enriched: Raw enriched Reddit thread data
"""
ensure_output_dir()
# report.json
with open(OUTPUT_DIR / "report.json", 'w') as f:
json.dump(report.to_dict(), f, indent=2)
# report.md
with open(OUTPUT_DIR / "report.md", 'w') as f:
f.write(render_full_report(report))
# last30days.context.md
with open(OUTPUT_DIR / "last30days.context.md", 'w') as f:
f.write(render_context_snippet(report))
# Raw responses
if raw_openai:
with open(OUTPUT_DIR / "raw_openai.json", 'w') as f:
json.dump(raw_openai, f, indent=2)
if raw_xai:
with open(OUTPUT_DIR / "raw_xai.json", 'w') as f:
json.dump(raw_xai, f, indent=2)
if raw_reddit_enriched:
with open(OUTPUT_DIR / "raw_reddit_threads_enriched.json", 'w') as f:
json.dump(raw_reddit_enriched, f, indent=2)
def get_context_path() -> str:
"""Get path to context file."""
return str(OUTPUT_DIR / "last30days.context.md")

View File

@@ -0,0 +1,336 @@
"""Data schemas for last30days skill."""
from dataclasses import dataclass, field, asdict
from typing import Any, Dict, List, Optional
from datetime import datetime, timezone
@dataclass
class Engagement:
"""Engagement metrics."""
# Reddit fields
score: Optional[int] = None
num_comments: Optional[int] = None
upvote_ratio: Optional[float] = None
# X fields
likes: Optional[int] = None
reposts: Optional[int] = None
replies: Optional[int] = None
quotes: Optional[int] = None
def to_dict(self) -> Dict[str, Any]:
d = {}
if self.score is not None:
d['score'] = self.score
if self.num_comments is not None:
d['num_comments'] = self.num_comments
if self.upvote_ratio is not None:
d['upvote_ratio'] = self.upvote_ratio
if self.likes is not None:
d['likes'] = self.likes
if self.reposts is not None:
d['reposts'] = self.reposts
if self.replies is not None:
d['replies'] = self.replies
if self.quotes is not None:
d['quotes'] = self.quotes
return d if d else None
@dataclass
class Comment:
"""Reddit comment."""
score: int
date: Optional[str]
author: str
excerpt: str
url: str
def to_dict(self) -> Dict[str, Any]:
return {
'score': self.score,
'date': self.date,
'author': self.author,
'excerpt': self.excerpt,
'url': self.url,
}
@dataclass
class SubScores:
"""Component scores."""
relevance: int = 0
recency: int = 0
engagement: int = 0
def to_dict(self) -> Dict[str, int]:
return {
'relevance': self.relevance,
'recency': self.recency,
'engagement': self.engagement,
}
@dataclass
class RedditItem:
"""Normalized Reddit item."""
id: str
title: str
url: str
subreddit: str
date: Optional[str] = None
date_confidence: str = "low"
engagement: Optional[Engagement] = None
top_comments: List[Comment] = field(default_factory=list)
comment_insights: List[str] = field(default_factory=list)
relevance: float = 0.5
why_relevant: str = ""
subs: SubScores = field(default_factory=SubScores)
score: int = 0
def to_dict(self) -> Dict[str, Any]:
return {
'id': self.id,
'title': self.title,
'url': self.url,
'subreddit': self.subreddit,
'date': self.date,
'date_confidence': self.date_confidence,
'engagement': self.engagement.to_dict() if self.engagement else None,
'top_comments': [c.to_dict() for c in self.top_comments],
'comment_insights': self.comment_insights,
'relevance': self.relevance,
'why_relevant': self.why_relevant,
'subs': self.subs.to_dict(),
'score': self.score,
}
@dataclass
class XItem:
"""Normalized X item."""
id: str
text: str
url: str
author_handle: str
date: Optional[str] = None
date_confidence: str = "low"
engagement: Optional[Engagement] = None
relevance: float = 0.5
why_relevant: str = ""
subs: SubScores = field(default_factory=SubScores)
score: int = 0
def to_dict(self) -> Dict[str, Any]:
return {
'id': self.id,
'text': self.text,
'url': self.url,
'author_handle': self.author_handle,
'date': self.date,
'date_confidence': self.date_confidence,
'engagement': self.engagement.to_dict() if self.engagement else None,
'relevance': self.relevance,
'why_relevant': self.why_relevant,
'subs': self.subs.to_dict(),
'score': self.score,
}
@dataclass
class WebSearchItem:
"""Normalized web search item (no engagement metrics)."""
id: str
title: str
url: str
source_domain: str # e.g., "medium.com", "github.com"
snippet: str
date: Optional[str] = None
date_confidence: str = "low"
relevance: float = 0.5
why_relevant: str = ""
subs: SubScores = field(default_factory=SubScores)
score: int = 0
def to_dict(self) -> Dict[str, Any]:
return {
'id': self.id,
'title': self.title,
'url': self.url,
'source_domain': self.source_domain,
'snippet': self.snippet,
'date': self.date,
'date_confidence': self.date_confidence,
'relevance': self.relevance,
'why_relevant': self.why_relevant,
'subs': self.subs.to_dict(),
'score': self.score,
}
@dataclass
class Report:
"""Full research report."""
topic: str
range_from: str
range_to: str
generated_at: str
mode: str # 'reddit-only', 'x-only', 'both', 'web-only', etc.
openai_model_used: Optional[str] = None
xai_model_used: Optional[str] = None
reddit: List[RedditItem] = field(default_factory=list)
x: List[XItem] = field(default_factory=list)
web: List[WebSearchItem] = field(default_factory=list)
best_practices: List[str] = field(default_factory=list)
prompt_pack: List[str] = field(default_factory=list)
context_snippet_md: str = ""
# Status tracking
reddit_error: Optional[str] = None
x_error: Optional[str] = None
web_error: Optional[str] = None
# Cache info
from_cache: bool = False
cache_age_hours: Optional[float] = None
def to_dict(self) -> Dict[str, Any]:
d = {
'topic': self.topic,
'range': {
'from': self.range_from,
'to': self.range_to,
},
'generated_at': self.generated_at,
'mode': self.mode,
'openai_model_used': self.openai_model_used,
'xai_model_used': self.xai_model_used,
'reddit': [r.to_dict() for r in self.reddit],
'x': [x.to_dict() for x in self.x],
'web': [w.to_dict() for w in self.web],
'best_practices': self.best_practices,
'prompt_pack': self.prompt_pack,
'context_snippet_md': self.context_snippet_md,
}
if self.reddit_error:
d['reddit_error'] = self.reddit_error
if self.x_error:
d['x_error'] = self.x_error
if self.web_error:
d['web_error'] = self.web_error
if self.from_cache:
d['from_cache'] = self.from_cache
if self.cache_age_hours is not None:
d['cache_age_hours'] = self.cache_age_hours
return d
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> "Report":
"""Create Report from serialized dict (handles cache format)."""
# Handle range field conversion
range_data = data.get('range', {})
range_from = range_data.get('from', data.get('range_from', ''))
range_to = range_data.get('to', data.get('range_to', ''))
# Reconstruct Reddit items
reddit_items = []
for r in data.get('reddit', []):
eng = None
if r.get('engagement'):
eng = Engagement(**r['engagement'])
comments = [Comment(**c) for c in r.get('top_comments', [])]
subs = SubScores(**r.get('subs', {})) if r.get('subs') else SubScores()
reddit_items.append(RedditItem(
id=r['id'],
title=r['title'],
url=r['url'],
subreddit=r['subreddit'],
date=r.get('date'),
date_confidence=r.get('date_confidence', 'low'),
engagement=eng,
top_comments=comments,
comment_insights=r.get('comment_insights', []),
relevance=r.get('relevance', 0.5),
why_relevant=r.get('why_relevant', ''),
subs=subs,
score=r.get('score', 0),
))
# Reconstruct X items
x_items = []
for x in data.get('x', []):
eng = None
if x.get('engagement'):
eng = Engagement(**x['engagement'])
subs = SubScores(**x.get('subs', {})) if x.get('subs') else SubScores()
x_items.append(XItem(
id=x['id'],
text=x['text'],
url=x['url'],
author_handle=x['author_handle'],
date=x.get('date'),
date_confidence=x.get('date_confidence', 'low'),
engagement=eng,
relevance=x.get('relevance', 0.5),
why_relevant=x.get('why_relevant', ''),
subs=subs,
score=x.get('score', 0),
))
# Reconstruct Web items
web_items = []
for w in data.get('web', []):
subs = SubScores(**w.get('subs', {})) if w.get('subs') else SubScores()
web_items.append(WebSearchItem(
id=w['id'],
title=w['title'],
url=w['url'],
source_domain=w.get('source_domain', ''),
snippet=w.get('snippet', ''),
date=w.get('date'),
date_confidence=w.get('date_confidence', 'low'),
relevance=w.get('relevance', 0.5),
why_relevant=w.get('why_relevant', ''),
subs=subs,
score=w.get('score', 0),
))
return cls(
topic=data['topic'],
range_from=range_from,
range_to=range_to,
generated_at=data['generated_at'],
mode=data['mode'],
openai_model_used=data.get('openai_model_used'),
xai_model_used=data.get('xai_model_used'),
reddit=reddit_items,
x=x_items,
web=web_items,
best_practices=data.get('best_practices', []),
prompt_pack=data.get('prompt_pack', []),
context_snippet_md=data.get('context_snippet_md', ''),
reddit_error=data.get('reddit_error'),
x_error=data.get('x_error'),
web_error=data.get('web_error'),
from_cache=data.get('from_cache', False),
cache_age_hours=data.get('cache_age_hours'),
)
def create_report(
topic: str,
from_date: str,
to_date: str,
mode: str,
openai_model: Optional[str] = None,
xai_model: Optional[str] = None,
) -> Report:
"""Create a new report with metadata."""
return Report(
topic=topic,
range_from=from_date,
range_to=to_date,
generated_at=datetime.now(timezone.utc).isoformat(),
mode=mode,
openai_model_used=openai_model,
xai_model_used=xai_model,
)

View File

@@ -0,0 +1,311 @@
"""Popularity-aware scoring for last30days skill."""
import math
from typing import List, Optional, Union
from . import dates, schema
# Score weights for Reddit/X (has engagement)
WEIGHT_RELEVANCE = 0.45
WEIGHT_RECENCY = 0.25
WEIGHT_ENGAGEMENT = 0.30
# WebSearch weights (no engagement, reweighted to 100%)
WEBSEARCH_WEIGHT_RELEVANCE = 0.55
WEBSEARCH_WEIGHT_RECENCY = 0.45
WEBSEARCH_SOURCE_PENALTY = 15 # Points deducted for lacking engagement
# WebSearch date confidence adjustments
WEBSEARCH_VERIFIED_BONUS = 10 # Bonus for URL-verified recent date (high confidence)
WEBSEARCH_NO_DATE_PENALTY = 20 # Heavy penalty for no date signals (low confidence)
# Default engagement score for unknown
DEFAULT_ENGAGEMENT = 35
UNKNOWN_ENGAGEMENT_PENALTY = 10
def log1p_safe(x: Optional[int]) -> float:
"""Safe log1p that handles None and negative values."""
if x is None or x < 0:
return 0.0
return math.log1p(x)
def compute_reddit_engagement_raw(engagement: Optional[schema.Engagement]) -> Optional[float]:
"""Compute raw engagement score for Reddit item.
Formula: 0.55*log1p(score) + 0.40*log1p(num_comments) + 0.05*(upvote_ratio*10)
"""
if engagement is None:
return None
if engagement.score is None and engagement.num_comments is None:
return None
score = log1p_safe(engagement.score)
comments = log1p_safe(engagement.num_comments)
ratio = (engagement.upvote_ratio or 0.5) * 10
return 0.55 * score + 0.40 * comments + 0.05 * ratio
def compute_x_engagement_raw(engagement: Optional[schema.Engagement]) -> Optional[float]:
"""Compute raw engagement score for X item.
Formula: 0.55*log1p(likes) + 0.25*log1p(reposts) + 0.15*log1p(replies) + 0.05*log1p(quotes)
"""
if engagement is None:
return None
if engagement.likes is None and engagement.reposts is None:
return None
likes = log1p_safe(engagement.likes)
reposts = log1p_safe(engagement.reposts)
replies = log1p_safe(engagement.replies)
quotes = log1p_safe(engagement.quotes)
return 0.55 * likes + 0.25 * reposts + 0.15 * replies + 0.05 * quotes
def normalize_to_100(values: List[float], default: float = 50) -> List[float]:
"""Normalize a list of values to 0-100 scale.
Args:
values: Raw values (None values are preserved)
default: Default value for None entries
Returns:
Normalized values
"""
# Filter out None
valid = [v for v in values if v is not None]
if not valid:
return [default if v is None else 50 for v in values]
min_val = min(valid)
max_val = max(valid)
range_val = max_val - min_val
if range_val == 0:
return [50 if v is None else 50 for v in values]
result = []
for v in values:
if v is None:
result.append(None)
else:
normalized = ((v - min_val) / range_val) * 100
result.append(normalized)
return result
def score_reddit_items(items: List[schema.RedditItem]) -> List[schema.RedditItem]:
"""Compute scores for Reddit items.
Args:
items: List of Reddit items
Returns:
Items with updated scores
"""
if not items:
return items
# Compute raw engagement scores
eng_raw = [compute_reddit_engagement_raw(item.engagement) for item in items]
# Normalize engagement to 0-100
eng_normalized = normalize_to_100(eng_raw)
for i, item in enumerate(items):
# Relevance subscore (model-provided, convert to 0-100)
rel_score = int(item.relevance * 100)
# Recency subscore
rec_score = dates.recency_score(item.date)
# Engagement subscore
if eng_normalized[i] is not None:
eng_score = int(eng_normalized[i])
else:
eng_score = DEFAULT_ENGAGEMENT
# Store subscores
item.subs = schema.SubScores(
relevance=rel_score,
recency=rec_score,
engagement=eng_score,
)
# Compute overall score
overall = (
WEIGHT_RELEVANCE * rel_score +
WEIGHT_RECENCY * rec_score +
WEIGHT_ENGAGEMENT * eng_score
)
# Apply penalty for unknown engagement
if eng_raw[i] is None:
overall -= UNKNOWN_ENGAGEMENT_PENALTY
# Apply penalty for low date confidence
if item.date_confidence == "low":
overall -= 10
elif item.date_confidence == "med":
overall -= 5
item.score = max(0, min(100, int(overall)))
return items
def score_x_items(items: List[schema.XItem]) -> List[schema.XItem]:
"""Compute scores for X items.
Args:
items: List of X items
Returns:
Items with updated scores
"""
if not items:
return items
# Compute raw engagement scores
eng_raw = [compute_x_engagement_raw(item.engagement) for item in items]
# Normalize engagement to 0-100
eng_normalized = normalize_to_100(eng_raw)
for i, item in enumerate(items):
# Relevance subscore (model-provided, convert to 0-100)
rel_score = int(item.relevance * 100)
# Recency subscore
rec_score = dates.recency_score(item.date)
# Engagement subscore
if eng_normalized[i] is not None:
eng_score = int(eng_normalized[i])
else:
eng_score = DEFAULT_ENGAGEMENT
# Store subscores
item.subs = schema.SubScores(
relevance=rel_score,
recency=rec_score,
engagement=eng_score,
)
# Compute overall score
overall = (
WEIGHT_RELEVANCE * rel_score +
WEIGHT_RECENCY * rec_score +
WEIGHT_ENGAGEMENT * eng_score
)
# Apply penalty for unknown engagement
if eng_raw[i] is None:
overall -= UNKNOWN_ENGAGEMENT_PENALTY
# Apply penalty for low date confidence
if item.date_confidence == "low":
overall -= 10
elif item.date_confidence == "med":
overall -= 5
item.score = max(0, min(100, int(overall)))
return items
def score_websearch_items(items: List[schema.WebSearchItem]) -> List[schema.WebSearchItem]:
"""Compute scores for WebSearch items WITHOUT engagement metrics.
Uses reweighted formula: 55% relevance + 45% recency - 15pt source penalty.
This ensures WebSearch items rank below comparable Reddit/X items.
Date confidence adjustments:
- High confidence (URL-verified date): +10 bonus
- Med confidence (snippet-extracted date): no change
- Low confidence (no date signals): -20 penalty
Args:
items: List of WebSearch items
Returns:
Items with updated scores
"""
if not items:
return items
for item in items:
# Relevance subscore (model-provided, convert to 0-100)
rel_score = int(item.relevance * 100)
# Recency subscore
rec_score = dates.recency_score(item.date)
# Store subscores (engagement is 0 for WebSearch - no data)
item.subs = schema.SubScores(
relevance=rel_score,
recency=rec_score,
engagement=0, # Explicitly zero - no engagement data available
)
# Compute overall score using WebSearch weights
overall = (
WEBSEARCH_WEIGHT_RELEVANCE * rel_score +
WEBSEARCH_WEIGHT_RECENCY * rec_score
)
# Apply source penalty (WebSearch < Reddit/X for same relevance/recency)
overall -= WEBSEARCH_SOURCE_PENALTY
# Apply date confidence adjustments
# High confidence (URL-verified): reward with bonus
# Med confidence (snippet-extracted): neutral
# Low confidence (no date signals): heavy penalty
if item.date_confidence == "high":
overall += WEBSEARCH_VERIFIED_BONUS # Reward verified recent dates
elif item.date_confidence == "low":
overall -= WEBSEARCH_NO_DATE_PENALTY # Heavy penalty for unknown
item.score = max(0, min(100, int(overall)))
return items
def sort_items(items: List[Union[schema.RedditItem, schema.XItem, schema.WebSearchItem]]) -> List:
"""Sort items by score (descending), then date, then source priority.
Args:
items: List of items to sort
Returns:
Sorted items
"""
def sort_key(item):
# Primary: score descending (negate for descending)
score = -item.score
# Secondary: date descending (recent first)
date = item.date or "0000-00-00"
date_key = -int(date.replace("-", ""))
# Tertiary: source priority (Reddit > X > WebSearch)
if isinstance(item, schema.RedditItem):
source_priority = 0
elif isinstance(item, schema.XItem):
source_priority = 1
else: # WebSearchItem
source_priority = 2
# Quaternary: title/text for stability
text = getattr(item, "title", "") or getattr(item, "text", "")
return (score, date_key, source_priority, text)
return sorted(items, key=sort_key)

View File

@@ -0,0 +1,324 @@
"""Terminal UI utilities for last30days skill."""
import os
import sys
import time
import threading
import random
from typing import Optional
# Check if we're in a real terminal (not captured by Claude Code)
IS_TTY = sys.stderr.isatty()
# ANSI color codes
class Colors:
PURPLE = '\033[95m'
BLUE = '\033[94m'
CYAN = '\033[96m'
GREEN = '\033[92m'
YELLOW = '\033[93m'
RED = '\033[91m'
BOLD = '\033[1m'
DIM = '\033[2m'
RESET = '\033[0m'
BANNER = f"""{Colors.PURPLE}{Colors.BOLD}
██╗ █████╗ ███████╗████████╗██████╗ ██████╗ ██████╗ █████╗ ██╗ ██╗███████╗
██║ ██╔══██╗██╔════╝╚══██╔══╝╚════██╗██╔═████╗██╔══██╗██╔══██╗╚██╗ ██╔╝██╔════╝
██║ ███████║███████╗ ██║ █████╔╝██║██╔██║██║ ██║███████║ ╚████╔╝ ███████╗
██║ ██╔══██║╚════██║ ██║ ╚═══██╗████╔╝██║██║ ██║██╔══██║ ╚██╔╝ ╚════██║
███████╗██║ ██║███████║ ██║ ██████╔╝╚██████╔╝██████╔╝██║ ██║ ██║ ███████║
╚══════╝╚═╝ ╚═╝╚══════╝ ╚═╝ ╚═════╝ ╚═════╝ ╚═════╝ ╚═╝ ╚═╝ ╚═╝ ╚══════╝
{Colors.RESET}{Colors.DIM} 30 days of research. 30 seconds of work.{Colors.RESET}
"""
MINI_BANNER = f"""{Colors.PURPLE}{Colors.BOLD}/last30days{Colors.RESET} {Colors.DIM}· researching...{Colors.RESET}"""
# Fun status messages for each phase
REDDIT_MESSAGES = [
"Diving into Reddit threads...",
"Scanning subreddits for gold...",
"Reading what Redditors are saying...",
"Exploring the front page of the internet...",
"Finding the good discussions...",
"Upvoting mentally...",
"Scrolling through comments...",
]
X_MESSAGES = [
"Checking what X is buzzing about...",
"Reading the timeline...",
"Finding the hot takes...",
"Scanning tweets and threads...",
"Discovering trending insights...",
"Following the conversation...",
"Reading between the posts...",
]
ENRICHING_MESSAGES = [
"Getting the juicy details...",
"Fetching engagement metrics...",
"Reading top comments...",
"Extracting insights...",
"Analyzing discussions...",
]
PROCESSING_MESSAGES = [
"Crunching the data...",
"Scoring and ranking...",
"Finding patterns...",
"Removing duplicates...",
"Organizing findings...",
]
WEB_ONLY_MESSAGES = [
"Searching the web...",
"Finding blogs and docs...",
"Crawling news sites...",
"Discovering tutorials...",
]
# Promo message for users without API keys
PROMO_MESSAGE = f"""
{Colors.YELLOW}{Colors.BOLD}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━{Colors.RESET}
{Colors.YELLOW}⚡ UNLOCK THE FULL POWER OF /last30days{Colors.RESET}
{Colors.DIM}Right now you're using web search only. Add API keys to unlock:{Colors.RESET}
{Colors.YELLOW}🟠 Reddit{Colors.RESET} - Real upvotes, comments, and community insights
└─ Add OPENAI_API_KEY (uses OpenAI's web_search for Reddit)
{Colors.CYAN}🔵 X (Twitter){Colors.RESET} - Real-time posts, likes, reposts from creators
└─ Add XAI_API_KEY (uses xAI's live X search)
{Colors.DIM}Setup:{Colors.RESET} Edit {Colors.BOLD}~/.config/last30days/.env{Colors.RESET}
{Colors.YELLOW}{Colors.BOLD}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━{Colors.RESET}
"""
PROMO_MESSAGE_PLAIN = """
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
⚡ UNLOCK THE FULL POWER OF /last30days
Right now you're using web search only. Add API keys to unlock:
🟠 Reddit - Real upvotes, comments, and community insights
└─ Add OPENAI_API_KEY (uses OpenAI's web_search for Reddit)
🔵 X (Twitter) - Real-time posts, likes, reposts from creators
└─ Add XAI_API_KEY (uses xAI's live X search)
Setup: Edit ~/.config/last30days/.env
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
"""
# Shorter promo for single missing key
PROMO_SINGLE_KEY = {
"reddit": f"""
{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}
""",
"x": f"""
{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}
""",
}
PROMO_SINGLE_KEY_PLAIN = {
"reddit": "\n💡 Tip: Add OPENAI_API_KEY to ~/.config/last30days/.env for Reddit data with real engagement metrics!\n",
"x": "\n💡 Tip: Add XAI_API_KEY to ~/.config/last30days/.env for X/Twitter data with real likes & reposts!\n",
}
# Spinner frames
SPINNER_FRAMES = ['', '', '', '', '', '', '', '', '', '']
DOTS_FRAMES = [' ', '. ', '.. ', '...']
class Spinner:
"""Animated spinner for long-running operations."""
def __init__(self, message: str = "Working", color: str = Colors.CYAN):
self.message = message
self.color = color
self.running = False
self.thread: Optional[threading.Thread] = None
self.frame_idx = 0
self.shown_static = False
def _spin(self):
while self.running:
frame = SPINNER_FRAMES[self.frame_idx % len(SPINNER_FRAMES)]
sys.stderr.write(f"\r{self.color}{frame}{Colors.RESET} {self.message} ")
sys.stderr.flush()
self.frame_idx += 1
time.sleep(0.08)
def start(self):
self.running = True
if IS_TTY:
# Real terminal - animate
self.thread = threading.Thread(target=self._spin, daemon=True)
self.thread.start()
else:
# Not a TTY (Claude Code) - just print once
if not self.shown_static:
sys.stderr.write(f"{self.message}\n")
sys.stderr.flush()
self.shown_static = True
def update(self, message: str):
self.message = message
if not IS_TTY and not self.shown_static:
# Print update in non-TTY mode
sys.stderr.write(f"{message}\n")
sys.stderr.flush()
def stop(self, final_message: str = ""):
self.running = False
if self.thread:
self.thread.join(timeout=0.2)
if IS_TTY:
# Clear the line in real terminal
sys.stderr.write("\r" + " " * 80 + "\r")
if final_message:
sys.stderr.write(f"{final_message}\n")
sys.stderr.flush()
class ProgressDisplay:
"""Progress display for research phases."""
def __init__(self, topic: str, show_banner: bool = True):
self.topic = topic
self.spinner: Optional[Spinner] = None
self.start_time = time.time()
if show_banner:
self._show_banner()
def _show_banner(self):
if IS_TTY:
sys.stderr.write(MINI_BANNER + "\n")
sys.stderr.write(f"{Colors.DIM}Topic: {Colors.RESET}{Colors.BOLD}{self.topic}{Colors.RESET}\n\n")
else:
# Simple text for non-TTY
sys.stderr.write(f"/last30days · researching: {self.topic}\n")
sys.stderr.flush()
def start_reddit(self):
msg = random.choice(REDDIT_MESSAGES)
self.spinner = Spinner(f"{Colors.YELLOW}Reddit{Colors.RESET} {msg}", Colors.YELLOW)
self.spinner.start()
def end_reddit(self, count: int):
if self.spinner:
self.spinner.stop(f"{Colors.YELLOW}Reddit{Colors.RESET} Found {count} threads")
def start_reddit_enrich(self, current: int, total: int):
if self.spinner:
self.spinner.stop()
msg = random.choice(ENRICHING_MESSAGES)
self.spinner = Spinner(f"{Colors.YELLOW}Reddit{Colors.RESET} [{current}/{total}] {msg}", Colors.YELLOW)
self.spinner.start()
def update_reddit_enrich(self, current: int, total: int):
if self.spinner:
msg = random.choice(ENRICHING_MESSAGES)
self.spinner.update(f"{Colors.YELLOW}Reddit{Colors.RESET} [{current}/{total}] {msg}")
def end_reddit_enrich(self):
if self.spinner:
self.spinner.stop(f"{Colors.YELLOW}Reddit{Colors.RESET} Enriched with engagement data")
def start_x(self):
msg = random.choice(X_MESSAGES)
self.spinner = Spinner(f"{Colors.CYAN}X{Colors.RESET} {msg}", Colors.CYAN)
self.spinner.start()
def end_x(self, count: int):
if self.spinner:
self.spinner.stop(f"{Colors.CYAN}X{Colors.RESET} Found {count} posts")
def start_processing(self):
msg = random.choice(PROCESSING_MESSAGES)
self.spinner = Spinner(f"{Colors.PURPLE}Processing{Colors.RESET} {msg}", Colors.PURPLE)
self.spinner.start()
def end_processing(self):
if self.spinner:
self.spinner.stop()
def show_complete(self, reddit_count: int, x_count: int):
elapsed = time.time() - self.start_time
if IS_TTY:
sys.stderr.write(f"\n{Colors.GREEN}{Colors.BOLD}✓ Research complete{Colors.RESET} ")
sys.stderr.write(f"{Colors.DIM}({elapsed:.1f}s){Colors.RESET}\n")
sys.stderr.write(f" {Colors.YELLOW}Reddit:{Colors.RESET} {reddit_count} threads ")
sys.stderr.write(f"{Colors.CYAN}X:{Colors.RESET} {x_count} posts\n\n")
else:
sys.stderr.write(f"✓ Research complete ({elapsed:.1f}s) - Reddit: {reddit_count} threads, X: {x_count} posts\n")
sys.stderr.flush()
def show_cached(self, age_hours: float = None):
if age_hours is not None:
age_str = f" ({age_hours:.1f}h old)"
else:
age_str = ""
sys.stderr.write(f"{Colors.GREEN}{Colors.RESET} {Colors.DIM}Using cached results{age_str} - use --refresh for fresh data{Colors.RESET}\n\n")
sys.stderr.flush()
def show_error(self, message: str):
sys.stderr.write(f"{Colors.RED}✗ Error:{Colors.RESET} {message}\n")
sys.stderr.flush()
def start_web_only(self):
"""Show web-only mode indicator."""
msg = random.choice(WEB_ONLY_MESSAGES)
self.spinner = Spinner(f"{Colors.GREEN}Web{Colors.RESET} {msg}", Colors.GREEN)
self.spinner.start()
def end_web_only(self):
"""End web-only spinner."""
if self.spinner:
self.spinner.stop(f"{Colors.GREEN}Web{Colors.RESET} Claude will search the web")
def show_web_only_complete(self):
"""Show completion for web-only mode."""
elapsed = time.time() - self.start_time
if IS_TTY:
sys.stderr.write(f"\n{Colors.GREEN}{Colors.BOLD}✓ Ready for web search{Colors.RESET} ")
sys.stderr.write(f"{Colors.DIM}({elapsed:.1f}s){Colors.RESET}\n")
sys.stderr.write(f" {Colors.GREEN}Web:{Colors.RESET} Claude will search blogs, docs & news\n\n")
else:
sys.stderr.write(f"✓ Ready for web search ({elapsed:.1f}s)\n")
sys.stderr.flush()
def show_promo(self, missing: str = "both"):
"""Show promotional message for missing API keys.
Args:
missing: 'both', 'reddit', or 'x' - which keys are missing
"""
if missing == "both":
if IS_TTY:
sys.stderr.write(PROMO_MESSAGE)
else:
sys.stderr.write(PROMO_MESSAGE_PLAIN)
elif missing in PROMO_SINGLE_KEY:
if IS_TTY:
sys.stderr.write(PROMO_SINGLE_KEY[missing])
else:
sys.stderr.write(PROMO_SINGLE_KEY_PLAIN[missing])
sys.stderr.flush()
def print_phase(phase: str, message: str):
"""Print a phase message."""
colors = {
"reddit": Colors.YELLOW,
"x": Colors.CYAN,
"process": Colors.PURPLE,
"done": Colors.GREEN,
"error": Colors.RED,
}
color = colors.get(phase, Colors.RESET)
sys.stderr.write(f"{color}{Colors.RESET} {message}\n")
sys.stderr.flush()

View File

@@ -0,0 +1,401 @@
"""WebSearch module for last30days skill.
NOTE: WebSearch uses Claude's built-in WebSearch tool, which runs INSIDE Claude Code.
Unlike Reddit/X which use external APIs, WebSearch results are obtained by Claude
directly and passed to this module for normalization and scoring.
The typical flow is:
1. Claude invokes WebSearch tool with the topic
2. Claude passes results to parse_websearch_results()
3. Results are normalized into WebSearchItem objects
"""
import re
from datetime import datetime, timedelta
from typing import Any, Dict, List, Optional, Tuple
from urllib.parse import urlparse
from . import schema
# Month name mappings for date parsing
MONTH_MAP = {
"jan": 1, "january": 1,
"feb": 2, "february": 2,
"mar": 3, "march": 3,
"apr": 4, "april": 4,
"may": 5,
"jun": 6, "june": 6,
"jul": 7, "july": 7,
"aug": 8, "august": 8,
"sep": 9, "sept": 9, "september": 9,
"oct": 10, "october": 10,
"nov": 11, "november": 11,
"dec": 12, "december": 12,
}
def extract_date_from_url(url: str) -> Optional[str]:
"""Try to extract a date from URL path.
Many sites embed dates in URLs like:
- /2026/01/24/article-title
- /2026-01-24/article
- /blog/20260124/title
Args:
url: URL to parse
Returns:
Date string in YYYY-MM-DD format, or None
"""
# Pattern 1: /YYYY/MM/DD/ (most common)
match = re.search(r'/(\d{4})/(\d{2})/(\d{2})/', url)
if match:
year, month, day = match.groups()
if 2020 <= int(year) <= 2030 and 1 <= int(month) <= 12 and 1 <= int(day) <= 31:
return f"{year}-{month}-{day}"
# Pattern 2: /YYYY-MM-DD/ or /YYYY-MM-DD-
match = re.search(r'/(\d{4})-(\d{2})-(\d{2})[-/]', url)
if match:
year, month, day = match.groups()
if 2020 <= int(year) <= 2030 and 1 <= int(month) <= 12 and 1 <= int(day) <= 31:
return f"{year}-{month}-{day}"
# Pattern 3: /YYYYMMDD/ (compact)
match = re.search(r'/(\d{4})(\d{2})(\d{2})/', url)
if match:
year, month, day = match.groups()
if 2020 <= int(year) <= 2030 and 1 <= int(month) <= 12 and 1 <= int(day) <= 31:
return f"{year}-{month}-{day}"
return None
def extract_date_from_snippet(text: str) -> Optional[str]:
"""Try to extract a date from text snippet or title.
Looks for patterns like:
- January 24, 2026 or Jan 24, 2026
- 24 January 2026
- 2026-01-24
- "3 days ago", "yesterday", "last week"
Args:
text: Text to parse
Returns:
Date string in YYYY-MM-DD format, or None
"""
if not text:
return None
text_lower = text.lower()
# Pattern 1: Month DD, YYYY (e.g., "January 24, 2026")
match = re.search(
r'\b(jan(?:uary)?|feb(?:ruary)?|mar(?:ch)?|apr(?:il)?|may|jun(?:e)?|'
r'jul(?:y)?|aug(?:ust)?|sep(?:t(?:ember)?)?|oct(?:ober)?|nov(?:ember)?|dec(?:ember)?)'
r'\s+(\d{1,2})(?:st|nd|rd|th)?,?\s*(\d{4})\b',
text_lower
)
if match:
month_str, day, year = match.groups()
month = MONTH_MAP.get(month_str[:3])
if month and 2020 <= int(year) <= 2030 and 1 <= int(day) <= 31:
return f"{year}-{month:02d}-{int(day):02d}"
# Pattern 2: DD Month YYYY (e.g., "24 January 2026")
match = re.search(
r'\b(\d{1,2})(?:st|nd|rd|th)?\s+'
r'(jan(?:uary)?|feb(?:ruary)?|mar(?:ch)?|apr(?:il)?|may|jun(?:e)?|'
r'jul(?:y)?|aug(?:ust)?|sep(?:t(?:ember)?)?|oct(?:ober)?|nov(?:ember)?|dec(?:ember)?)'
r'\s+(\d{4})\b',
text_lower
)
if match:
day, month_str, year = match.groups()
month = MONTH_MAP.get(month_str[:3])
if month and 2020 <= int(year) <= 2030 and 1 <= int(day) <= 31:
return f"{year}-{month:02d}-{int(day):02d}"
# Pattern 3: YYYY-MM-DD (ISO format)
match = re.search(r'\b(\d{4})-(\d{2})-(\d{2})\b', text)
if match:
year, month, day = match.groups()
if 2020 <= int(year) <= 2030 and 1 <= int(month) <= 12 and 1 <= int(day) <= 31:
return f"{year}-{month}-{day}"
# Pattern 4: Relative dates ("3 days ago", "yesterday", etc.)
today = datetime.now()
if "yesterday" in text_lower:
date = today - timedelta(days=1)
return date.strftime("%Y-%m-%d")
if "today" in text_lower:
return today.strftime("%Y-%m-%d")
# "N days ago"
match = re.search(r'\b(\d+)\s*days?\s*ago\b', text_lower)
if match:
days = int(match.group(1))
if days <= 60: # Reasonable range
date = today - timedelta(days=days)
return date.strftime("%Y-%m-%d")
# "N hours ago" -> today
match = re.search(r'\b(\d+)\s*hours?\s*ago\b', text_lower)
if match:
return today.strftime("%Y-%m-%d")
# "last week" -> ~7 days ago
if "last week" in text_lower:
date = today - timedelta(days=7)
return date.strftime("%Y-%m-%d")
# "this week" -> ~3 days ago (middle of week)
if "this week" in text_lower:
date = today - timedelta(days=3)
return date.strftime("%Y-%m-%d")
return None
def extract_date_signals(
url: str,
snippet: str,
title: str,
) -> Tuple[Optional[str], str]:
"""Extract date from any available signal.
Tries URL first (most reliable), then snippet, then title.
Args:
url: Page URL
snippet: Page snippet/description
title: Page title
Returns:
Tuple of (date_string, confidence)
- date from URL: 'high' confidence
- date from snippet/title: 'med' confidence
- no date found: None, 'low' confidence
"""
# Try URL first (most reliable)
url_date = extract_date_from_url(url)
if url_date:
return url_date, "high"
# Try snippet
snippet_date = extract_date_from_snippet(snippet)
if snippet_date:
return snippet_date, "med"
# Try title
title_date = extract_date_from_snippet(title)
if title_date:
return title_date, "med"
return None, "low"
# Domains to exclude (Reddit and X are handled separately)
EXCLUDED_DOMAINS = {
"reddit.com",
"www.reddit.com",
"old.reddit.com",
"twitter.com",
"www.twitter.com",
"x.com",
"www.x.com",
"mobile.twitter.com",
}
def extract_domain(url: str) -> str:
"""Extract the domain from a URL.
Args:
url: Full URL
Returns:
Domain string (e.g., "medium.com")
"""
try:
parsed = urlparse(url)
domain = parsed.netloc.lower()
# Remove www. prefix for cleaner display
if domain.startswith("www."):
domain = domain[4:]
return domain
except Exception:
return ""
def is_excluded_domain(url: str) -> bool:
"""Check if URL is from an excluded domain (Reddit/X).
Args:
url: URL to check
Returns:
True if URL should be excluded
"""
try:
parsed = urlparse(url)
domain = parsed.netloc.lower()
return domain in EXCLUDED_DOMAINS
except Exception:
return False
def parse_websearch_results(
results: List[Dict[str, Any]],
topic: str,
from_date: str = "",
to_date: str = "",
) -> List[Dict[str, Any]]:
"""Parse WebSearch results into normalized format.
This function expects results from Claude's WebSearch tool.
Each result should have: title, url, snippet, and optionally date/relevance.
Uses "Date Detective" approach:
1. Extract dates from URLs (high confidence)
2. Extract dates from snippets/titles (med confidence)
3. Hard filter: exclude items with verified old dates
4. Keep items with no date signals (with low confidence penalty)
Args:
results: List of WebSearch result dicts
topic: Original search topic (for context)
from_date: Start date for filtering (YYYY-MM-DD)
to_date: End date for filtering (YYYY-MM-DD)
Returns:
List of normalized item dicts ready for WebSearchItem creation
"""
items = []
for i, result in enumerate(results):
if not isinstance(result, dict):
continue
url = result.get("url", "")
if not url:
continue
# Skip Reddit/X URLs (handled separately)
if is_excluded_domain(url):
continue
title = str(result.get("title", "")).strip()
snippet = str(result.get("snippet", result.get("description", ""))).strip()
if not title and not snippet:
continue
# Use Date Detective to extract date signals
date = result.get("date") # Use provided date if available
date_confidence = "low"
if date and re.match(r'^\d{4}-\d{2}-\d{2}$', str(date)):
# Provided date is valid
date_confidence = "med"
else:
# Try to extract date from URL/snippet/title
extracted_date, confidence = extract_date_signals(url, snippet, title)
if extracted_date:
date = extracted_date
date_confidence = confidence
# Hard filter: if we found a date and it's too old, skip
if date and from_date and date < from_date:
continue # DROP - verified old content
# Hard filter: if date is in the future, skip (parsing error)
if date and to_date and date > to_date:
continue # DROP - future date
# Get relevance if provided, default to 0.5
relevance = result.get("relevance", 0.5)
try:
relevance = min(1.0, max(0.0, float(relevance)))
except (TypeError, ValueError):
relevance = 0.5
item = {
"id": f"W{i+1}",
"title": title[:200], # Truncate long titles
"url": url,
"source_domain": extract_domain(url),
"snippet": snippet[:500], # Truncate long snippets
"date": date,
"date_confidence": date_confidence,
"relevance": relevance,
"why_relevant": str(result.get("why_relevant", "")).strip(),
}
items.append(item)
return items
def normalize_websearch_items(
items: List[Dict[str, Any]],
from_date: str,
to_date: str,
) -> List[schema.WebSearchItem]:
"""Convert parsed dicts to WebSearchItem objects.
Args:
items: List of parsed item dicts
from_date: Start of date range (YYYY-MM-DD)
to_date: End of date range (YYYY-MM-DD)
Returns:
List of WebSearchItem objects
"""
result = []
for item in items:
web_item = schema.WebSearchItem(
id=item["id"],
title=item["title"],
url=item["url"],
source_domain=item["source_domain"],
snippet=item["snippet"],
date=item.get("date"),
date_confidence=item.get("date_confidence", "low"),
relevance=item.get("relevance", 0.5),
why_relevant=item.get("why_relevant", ""),
)
result.append(web_item)
return result
def dedupe_websearch(items: List[schema.WebSearchItem]) -> List[schema.WebSearchItem]:
"""Remove duplicate WebSearch items.
Deduplication is based on URL.
Args:
items: List of WebSearchItem objects
Returns:
Deduplicated list
"""
seen_urls = set()
result = []
for item in items:
# Normalize URL for comparison
url_key = item.url.lower().rstrip("/")
if url_key not in seen_urls:
seen_urls.add(url_key)
result.append(item)
return result

View File

@@ -0,0 +1,217 @@
"""xAI API client for X (Twitter) discovery."""
import json
import re
import sys
from typing import Any, Dict, List, Optional
from . import http
def _log_error(msg: str):
"""Log error to stderr."""
sys.stderr.write(f"[X ERROR] {msg}\n")
sys.stderr.flush()
# xAI uses responses endpoint with Agent Tools API
XAI_RESPONSES_URL = "https://api.x.ai/v1/responses"
# Depth configurations: (min, max) posts to request
DEPTH_CONFIG = {
"quick": (8, 12),
"default": (20, 30),
"deep": (40, 60),
}
X_SEARCH_PROMPT = """You have access to real-time X (Twitter) data. Search for posts about: {topic}
Focus on posts from {from_date} to {to_date}. Find {min_items}-{max_items} high-quality, relevant posts.
IMPORTANT: Return ONLY valid JSON in this exact format, no other text:
{{
"items": [
{{
"text": "Post text content (truncated if long)",
"url": "https://x.com/user/status/...",
"author_handle": "username",
"date": "YYYY-MM-DD or null if unknown",
"engagement": {{
"likes": 100,
"reposts": 25,
"replies": 15,
"quotes": 5
}},
"why_relevant": "Brief explanation of relevance",
"relevance": 0.85
}}
]
}}
Rules:
- relevance is 0.0 to 1.0 (1.0 = highly relevant)
- date must be YYYY-MM-DD format or null
- engagement can be null if unknown
- Include diverse voices/accounts if applicable
- Prefer posts with substantive content, not just links"""
def search_x(
api_key: str,
model: str,
topic: str,
from_date: str,
to_date: str,
depth: str = "default",
mock_response: Optional[Dict] = None,
) -> Dict[str, Any]:
"""Search X for relevant posts using xAI API with live search.
Args:
api_key: xAI API key
model: Model to use
topic: Search topic
from_date: Start date (YYYY-MM-DD)
to_date: End date (YYYY-MM-DD)
depth: Research depth - "quick", "default", or "deep"
mock_response: Mock response for testing
Returns:
Raw API response
"""
if mock_response is not None:
return mock_response
min_items, max_items = DEPTH_CONFIG.get(depth, DEPTH_CONFIG["default"])
headers = {
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
}
# Adjust timeout based on depth (generous for API response time)
timeout = 90 if depth == "quick" else 120 if depth == "default" else 180
# Use Agent Tools API with x_search tool
payload = {
"model": model,
"tools": [
{"type": "x_search"}
],
"input": [
{
"role": "user",
"content": X_SEARCH_PROMPT.format(
topic=topic,
from_date=from_date,
to_date=to_date,
min_items=min_items,
max_items=max_items,
),
}
],
}
return http.post(XAI_RESPONSES_URL, payload, headers=headers, timeout=timeout)
def parse_x_response(response: Dict[str, Any]) -> List[Dict[str, Any]]:
"""Parse xAI response to extract X items.
Args:
response: Raw API response
Returns:
List of item dicts
"""
items = []
# Check for API errors first
if "error" in response and response["error"]:
error = response["error"]
err_msg = error.get("message", str(error)) if isinstance(error, dict) else str(error)
_log_error(f"xAI API error: {err_msg}")
if http.DEBUG:
_log_error(f"Full error response: {json.dumps(response, indent=2)[:1000]}")
return items
# Try to find the output text
output_text = ""
if "output" in response:
output = response["output"]
if isinstance(output, str):
output_text = output
elif isinstance(output, list):
for item in output:
if isinstance(item, dict):
if item.get("type") == "message":
content = item.get("content", [])
for c in content:
if isinstance(c, dict) and c.get("type") == "output_text":
output_text = c.get("text", "")
break
elif "text" in item:
output_text = item["text"]
elif isinstance(item, str):
output_text = item
if output_text:
break
# Also check for choices (older format)
if not output_text and "choices" in response:
for choice in response["choices"]:
if "message" in choice:
output_text = choice["message"].get("content", "")
break
if not output_text:
return items
# Extract JSON from the response
json_match = re.search(r'\{[\s\S]*"items"[\s\S]*\}', output_text)
if json_match:
try:
data = json.loads(json_match.group())
items = data.get("items", [])
except json.JSONDecodeError:
pass
# Validate and clean items
clean_items = []
for i, item in enumerate(items):
if not isinstance(item, dict):
continue
url = item.get("url", "")
if not url:
continue
# Parse engagement
engagement = None
eng_raw = item.get("engagement")
if isinstance(eng_raw, dict):
engagement = {
"likes": int(eng_raw.get("likes", 0)) if eng_raw.get("likes") else None,
"reposts": int(eng_raw.get("reposts", 0)) if eng_raw.get("reposts") else None,
"replies": int(eng_raw.get("replies", 0)) if eng_raw.get("replies") else None,
"quotes": int(eng_raw.get("quotes", 0)) if eng_raw.get("quotes") else None,
}
clean_item = {
"id": f"X{i+1}",
"text": str(item.get("text", "")).strip()[:500], # Truncate long text
"url": url,
"author_handle": str(item.get("author_handle", "")).strip().lstrip("@"),
"date": item.get("date"),
"engagement": engagement,
"why_relevant": str(item.get("why_relevant", "")).strip(),
"relevance": min(1.0, max(0.0, float(item.get("relevance", 0.5)))),
}
# Validate date format
if clean_item["date"]:
if not re.match(r'^\d{4}-\d{2}-\d{2}$', str(clean_item["date"])):
clean_item["date"] = None
clean_items.append(clean_item)
return clean_items

View File

@@ -0,0 +1 @@
# last30days tests

View File

@@ -0,0 +1,59 @@
"""Tests for cache module."""
import sys
import unittest
from pathlib import Path
# Add lib to path
sys.path.insert(0, str(Path(__file__).parent.parent / "scripts"))
from lib import cache
class TestGetCacheKey(unittest.TestCase):
def test_returns_string(self):
result = cache.get_cache_key("test topic", "2026-01-01", "2026-01-31", "both")
self.assertIsInstance(result, str)
def test_consistent_for_same_inputs(self):
key1 = cache.get_cache_key("test topic", "2026-01-01", "2026-01-31", "both")
key2 = cache.get_cache_key("test topic", "2026-01-01", "2026-01-31", "both")
self.assertEqual(key1, key2)
def test_different_for_different_inputs(self):
key1 = cache.get_cache_key("topic a", "2026-01-01", "2026-01-31", "both")
key2 = cache.get_cache_key("topic b", "2026-01-01", "2026-01-31", "both")
self.assertNotEqual(key1, key2)
def test_key_length(self):
key = cache.get_cache_key("test", "2026-01-01", "2026-01-31", "both")
self.assertEqual(len(key), 16)
class TestCachePath(unittest.TestCase):
def test_returns_path(self):
result = cache.get_cache_path("abc123")
self.assertIsInstance(result, Path)
def test_has_json_extension(self):
result = cache.get_cache_path("abc123")
self.assertEqual(result.suffix, ".json")
class TestCacheValidity(unittest.TestCase):
def test_nonexistent_file_is_invalid(self):
fake_path = Path("/nonexistent/path/file.json")
result = cache.is_cache_valid(fake_path)
self.assertFalse(result)
class TestModelCache(unittest.TestCase):
def test_get_cached_model_returns_none_for_missing(self):
# Clear any existing cache first
result = cache.get_cached_model("nonexistent_provider")
# May be None or a cached value, but should not error
self.assertTrue(result is None or isinstance(result, str))
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,114 @@
"""Tests for dates module."""
import sys
import unittest
from datetime import datetime, timedelta, timezone
from pathlib import Path
# Add lib to path
sys.path.insert(0, str(Path(__file__).parent.parent / "scripts"))
from lib import dates
class TestGetDateRange(unittest.TestCase):
def test_returns_tuple_of_two_strings(self):
from_date, to_date = dates.get_date_range(30)
self.assertIsInstance(from_date, str)
self.assertIsInstance(to_date, str)
def test_date_format(self):
from_date, to_date = dates.get_date_range(30)
# Should be YYYY-MM-DD format
self.assertRegex(from_date, r'^\d{4}-\d{2}-\d{2}$')
self.assertRegex(to_date, r'^\d{4}-\d{2}-\d{2}$')
def test_range_is_correct_days(self):
from_date, to_date = dates.get_date_range(30)
start = datetime.strptime(from_date, "%Y-%m-%d")
end = datetime.strptime(to_date, "%Y-%m-%d")
delta = end - start
self.assertEqual(delta.days, 30)
class TestParseDate(unittest.TestCase):
def test_parse_iso_date(self):
result = dates.parse_date("2026-01-15")
self.assertIsNotNone(result)
self.assertEqual(result.year, 2026)
self.assertEqual(result.month, 1)
self.assertEqual(result.day, 15)
def test_parse_timestamp(self):
# Unix timestamp for 2026-01-15 00:00:00 UTC
result = dates.parse_date("1768435200")
self.assertIsNotNone(result)
def test_parse_none(self):
result = dates.parse_date(None)
self.assertIsNone(result)
def test_parse_empty_string(self):
result = dates.parse_date("")
self.assertIsNone(result)
class TestTimestampToDate(unittest.TestCase):
def test_valid_timestamp(self):
# 2026-01-15 00:00:00 UTC
result = dates.timestamp_to_date(1768435200)
self.assertEqual(result, "2026-01-15")
def test_none_timestamp(self):
result = dates.timestamp_to_date(None)
self.assertIsNone(result)
class TestGetDateConfidence(unittest.TestCase):
def test_high_confidence_in_range(self):
result = dates.get_date_confidence("2026-01-15", "2026-01-01", "2026-01-31")
self.assertEqual(result, "high")
def test_low_confidence_before_range(self):
result = dates.get_date_confidence("2025-12-15", "2026-01-01", "2026-01-31")
self.assertEqual(result, "low")
def test_low_confidence_no_date(self):
result = dates.get_date_confidence(None, "2026-01-01", "2026-01-31")
self.assertEqual(result, "low")
class TestDaysAgo(unittest.TestCase):
def test_today(self):
today = datetime.now(timezone.utc).date().isoformat()
result = dates.days_ago(today)
self.assertEqual(result, 0)
def test_none_date(self):
result = dates.days_ago(None)
self.assertIsNone(result)
class TestRecencyScore(unittest.TestCase):
def test_today_is_100(self):
today = datetime.now(timezone.utc).date().isoformat()
result = dates.recency_score(today)
self.assertEqual(result, 100)
def test_30_days_ago_is_0(self):
old_date = (datetime.now(timezone.utc).date() - timedelta(days=30)).isoformat()
result = dates.recency_score(old_date)
self.assertEqual(result, 0)
def test_15_days_ago_is_50(self):
mid_date = (datetime.now(timezone.utc).date() - timedelta(days=15)).isoformat()
result = dates.recency_score(mid_date)
self.assertEqual(result, 50)
def test_none_date_is_0(self):
result = dates.recency_score(None)
self.assertEqual(result, 0)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,111 @@
"""Tests for dedupe module."""
import sys
import unittest
from pathlib import Path
# Add lib to path
sys.path.insert(0, str(Path(__file__).parent.parent / "scripts"))
from lib import dedupe, schema
class TestNormalizeText(unittest.TestCase):
def test_lowercase(self):
result = dedupe.normalize_text("HELLO World")
self.assertEqual(result, "hello world")
def test_removes_punctuation(self):
result = dedupe.normalize_text("Hello, World!")
# Punctuation replaced with space, then whitespace collapsed
self.assertEqual(result, "hello world")
def test_collapses_whitespace(self):
result = dedupe.normalize_text("hello world")
self.assertEqual(result, "hello world")
class TestGetNgrams(unittest.TestCase):
def test_short_text(self):
result = dedupe.get_ngrams("ab", n=3)
self.assertEqual(result, {"ab"})
def test_normal_text(self):
result = dedupe.get_ngrams("hello", n=3)
self.assertIn("hel", result)
self.assertIn("ell", result)
self.assertIn("llo", result)
class TestJaccardSimilarity(unittest.TestCase):
def test_identical_sets(self):
set1 = {"a", "b", "c"}
result = dedupe.jaccard_similarity(set1, set1)
self.assertEqual(result, 1.0)
def test_disjoint_sets(self):
set1 = {"a", "b", "c"}
set2 = {"d", "e", "f"}
result = dedupe.jaccard_similarity(set1, set2)
self.assertEqual(result, 0.0)
def test_partial_overlap(self):
set1 = {"a", "b", "c"}
set2 = {"b", "c", "d"}
result = dedupe.jaccard_similarity(set1, set2)
self.assertEqual(result, 0.5) # 2 overlap / 4 union
def test_empty_sets(self):
result = dedupe.jaccard_similarity(set(), set())
self.assertEqual(result, 0.0)
class TestFindDuplicates(unittest.TestCase):
def test_no_duplicates(self):
items = [
schema.RedditItem(id="R1", title="Completely different topic A", url="", subreddit=""),
schema.RedditItem(id="R2", title="Another unrelated subject B", url="", subreddit=""),
]
result = dedupe.find_duplicates(items)
self.assertEqual(result, [])
def test_finds_duplicates(self):
items = [
schema.RedditItem(id="R1", title="Best practices for Claude Code skills", url="", subreddit=""),
schema.RedditItem(id="R2", title="Best practices for Claude Code skills guide", url="", subreddit=""),
]
result = dedupe.find_duplicates(items, threshold=0.7)
self.assertEqual(len(result), 1)
self.assertEqual(result[0], (0, 1))
class TestDedupeItems(unittest.TestCase):
def test_keeps_higher_scored(self):
items = [
schema.RedditItem(id="R1", title="Best practices for skills", url="", subreddit="", score=90),
schema.RedditItem(id="R2", title="Best practices for skills guide", url="", subreddit="", score=50),
]
result = dedupe.dedupe_items(items, threshold=0.6)
self.assertEqual(len(result), 1)
self.assertEqual(result[0].id, "R1")
def test_keeps_all_unique(self):
items = [
schema.RedditItem(id="R1", title="Topic about apples", url="", subreddit="", score=90),
schema.RedditItem(id="R2", title="Discussion of oranges", url="", subreddit="", score=50),
]
result = dedupe.dedupe_items(items)
self.assertEqual(len(result), 2)
def test_empty_list(self):
result = dedupe.dedupe_items([])
self.assertEqual(result, [])
def test_single_item(self):
items = [schema.RedditItem(id="R1", title="Test", url="", subreddit="")]
result = dedupe.dedupe_items(items)
self.assertEqual(len(result), 1)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,135 @@
"""Tests for models module."""
import sys
import unittest
from pathlib import Path
# Add lib to path
sys.path.insert(0, str(Path(__file__).parent.parent / "scripts"))
from lib import models
class TestParseVersion(unittest.TestCase):
def test_simple_version(self):
result = models.parse_version("gpt-5")
self.assertEqual(result, (5,))
def test_minor_version(self):
result = models.parse_version("gpt-5.2")
self.assertEqual(result, (5, 2))
def test_patch_version(self):
result = models.parse_version("gpt-5.2.1")
self.assertEqual(result, (5, 2, 1))
def test_no_version(self):
result = models.parse_version("custom-model")
self.assertIsNone(result)
class TestIsMainlineOpenAIModel(unittest.TestCase):
def test_gpt5_is_mainline(self):
self.assertTrue(models.is_mainline_openai_model("gpt-5"))
def test_gpt52_is_mainline(self):
self.assertTrue(models.is_mainline_openai_model("gpt-5.2"))
def test_gpt5_mini_is_not_mainline(self):
self.assertFalse(models.is_mainline_openai_model("gpt-5-mini"))
def test_gpt4_is_not_mainline(self):
self.assertFalse(models.is_mainline_openai_model("gpt-4"))
class TestSelectOpenAIModel(unittest.TestCase):
def test_pinned_policy(self):
result = models.select_openai_model(
"fake-key",
policy="pinned",
pin="gpt-5.1"
)
self.assertEqual(result, "gpt-5.1")
def test_auto_with_mock_models(self):
mock_models = [
{"id": "gpt-5.2", "created": 1704067200},
{"id": "gpt-5.1", "created": 1701388800},
{"id": "gpt-5", "created": 1698710400},
]
result = models.select_openai_model(
"fake-key",
policy="auto",
mock_models=mock_models
)
self.assertEqual(result, "gpt-5.2")
def test_auto_filters_variants(self):
mock_models = [
{"id": "gpt-5.2", "created": 1704067200},
{"id": "gpt-5-mini", "created": 1704067200},
{"id": "gpt-5.1", "created": 1701388800},
]
result = models.select_openai_model(
"fake-key",
policy="auto",
mock_models=mock_models
)
self.assertEqual(result, "gpt-5.2")
class TestSelectXAIModel(unittest.TestCase):
def test_latest_policy(self):
result = models.select_xai_model(
"fake-key",
policy="latest"
)
self.assertEqual(result, "grok-4-latest")
def test_stable_policy(self):
# Clear cache first to avoid interference
from lib import cache
cache.MODEL_CACHE_FILE.unlink(missing_ok=True)
result = models.select_xai_model(
"fake-key",
policy="stable"
)
self.assertEqual(result, "grok-4")
def test_pinned_policy(self):
result = models.select_xai_model(
"fake-key",
policy="pinned",
pin="grok-3"
)
self.assertEqual(result, "grok-3")
class TestGetModels(unittest.TestCase):
def test_no_keys_returns_none(self):
config = {}
result = models.get_models(config)
self.assertIsNone(result["openai"])
self.assertIsNone(result["xai"])
def test_openai_key_only(self):
config = {"OPENAI_API_KEY": "sk-test"}
mock_models = [{"id": "gpt-5.2", "created": 1704067200}]
result = models.get_models(config, mock_openai_models=mock_models)
self.assertEqual(result["openai"], "gpt-5.2")
self.assertIsNone(result["xai"])
def test_both_keys(self):
config = {
"OPENAI_API_KEY": "sk-test",
"XAI_API_KEY": "xai-test",
}
mock_openai = [{"id": "gpt-5.2", "created": 1704067200}]
mock_xai = [{"id": "grok-4-latest", "created": 1704067200}]
result = models.get_models(config, mock_openai, mock_xai)
self.assertEqual(result["openai"], "gpt-5.2")
self.assertEqual(result["xai"], "grok-4-latest")
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,138 @@
"""Tests for normalize module."""
import sys
import unittest
from pathlib import Path
# Add lib to path
sys.path.insert(0, str(Path(__file__).parent.parent / "scripts"))
from lib import normalize, schema
class TestNormalizeRedditItems(unittest.TestCase):
def test_normalizes_basic_item(self):
items = [
{
"id": "R1",
"title": "Test Thread",
"url": "https://reddit.com/r/test/1",
"subreddit": "test",
"date": "2026-01-15",
"why_relevant": "Relevant because...",
"relevance": 0.85,
}
]
result = normalize.normalize_reddit_items(items, "2026-01-01", "2026-01-31")
self.assertEqual(len(result), 1)
self.assertIsInstance(result[0], schema.RedditItem)
self.assertEqual(result[0].id, "R1")
self.assertEqual(result[0].title, "Test Thread")
self.assertEqual(result[0].date_confidence, "high")
def test_sets_low_confidence_for_old_date(self):
items = [
{
"id": "R1",
"title": "Old Thread",
"url": "https://reddit.com/r/test/1",
"subreddit": "test",
"date": "2025-12-01", # Before range
"relevance": 0.5,
}
]
result = normalize.normalize_reddit_items(items, "2026-01-01", "2026-01-31")
self.assertEqual(result[0].date_confidence, "low")
def test_handles_engagement(self):
items = [
{
"id": "R1",
"title": "Thread with engagement",
"url": "https://reddit.com/r/test/1",
"subreddit": "test",
"engagement": {
"score": 100,
"num_comments": 50,
"upvote_ratio": 0.9,
},
"relevance": 0.5,
}
]
result = normalize.normalize_reddit_items(items, "2026-01-01", "2026-01-31")
self.assertIsNotNone(result[0].engagement)
self.assertEqual(result[0].engagement.score, 100)
self.assertEqual(result[0].engagement.num_comments, 50)
class TestNormalizeXItems(unittest.TestCase):
def test_normalizes_basic_item(self):
items = [
{
"id": "X1",
"text": "Test post content",
"url": "https://x.com/user/status/123",
"author_handle": "testuser",
"date": "2026-01-15",
"why_relevant": "Relevant because...",
"relevance": 0.9,
}
]
result = normalize.normalize_x_items(items, "2026-01-01", "2026-01-31")
self.assertEqual(len(result), 1)
self.assertIsInstance(result[0], schema.XItem)
self.assertEqual(result[0].id, "X1")
self.assertEqual(result[0].author_handle, "testuser")
def test_handles_x_engagement(self):
items = [
{
"id": "X1",
"text": "Post with engagement",
"url": "https://x.com/user/status/123",
"author_handle": "user",
"engagement": {
"likes": 100,
"reposts": 25,
"replies": 15,
"quotes": 5,
},
"relevance": 0.5,
}
]
result = normalize.normalize_x_items(items, "2026-01-01", "2026-01-31")
self.assertIsNotNone(result[0].engagement)
self.assertEqual(result[0].engagement.likes, 100)
self.assertEqual(result[0].engagement.reposts, 25)
class TestItemsToDicts(unittest.TestCase):
def test_converts_items(self):
items = [
schema.RedditItem(
id="R1",
title="Test",
url="https://reddit.com/r/test/1",
subreddit="test",
)
]
result = normalize.items_to_dicts(items)
self.assertEqual(len(result), 1)
self.assertIsInstance(result[0], dict)
self.assertEqual(result[0]["id"], "R1")
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,116 @@
"""Tests for render module."""
import sys
import unittest
from pathlib import Path
# Add lib to path
sys.path.insert(0, str(Path(__file__).parent.parent / "scripts"))
from lib import render, schema
class TestRenderCompact(unittest.TestCase):
def test_renders_basic_report(self):
report = schema.Report(
topic="test topic",
range_from="2026-01-01",
range_to="2026-01-31",
generated_at="2026-01-31T12:00:00Z",
mode="both",
openai_model_used="gpt-5.2",
xai_model_used="grok-4-latest",
)
result = render.render_compact(report)
self.assertIn("test topic", result)
self.assertIn("2026-01-01", result)
self.assertIn("both", result)
self.assertIn("gpt-5.2", result)
def test_renders_reddit_items(self):
report = schema.Report(
topic="test",
range_from="2026-01-01",
range_to="2026-01-31",
generated_at="2026-01-31T12:00:00Z",
mode="reddit-only",
reddit=[
schema.RedditItem(
id="R1",
title="Test Thread",
url="https://reddit.com/r/test/1",
subreddit="test",
date="2026-01-15",
date_confidence="high",
score=85,
why_relevant="Very relevant",
)
],
)
result = render.render_compact(report)
self.assertIn("R1", result)
self.assertIn("Test Thread", result)
self.assertIn("r/test", result)
def test_shows_coverage_tip_for_reddit_only(self):
report = schema.Report(
topic="test",
range_from="2026-01-01",
range_to="2026-01-31",
generated_at="2026-01-31T12:00:00Z",
mode="reddit-only",
)
result = render.render_compact(report)
self.assertIn("xAI key", result)
class TestRenderContextSnippet(unittest.TestCase):
def test_renders_snippet(self):
report = schema.Report(
topic="Claude Code Skills",
range_from="2026-01-01",
range_to="2026-01-31",
generated_at="2026-01-31T12:00:00Z",
mode="both",
)
result = render.render_context_snippet(report)
self.assertIn("Claude Code Skills", result)
self.assertIn("Last 30 Days", result)
class TestRenderFullReport(unittest.TestCase):
def test_renders_full_report(self):
report = schema.Report(
topic="test topic",
range_from="2026-01-01",
range_to="2026-01-31",
generated_at="2026-01-31T12:00:00Z",
mode="both",
openai_model_used="gpt-5.2",
xai_model_used="grok-4-latest",
)
result = render.render_full_report(report)
self.assertIn("# test topic", result)
self.assertIn("## Models Used", result)
self.assertIn("gpt-5.2", result)
class TestGetContextPath(unittest.TestCase):
def test_returns_path_string(self):
result = render.get_context_path()
self.assertIsInstance(result, str)
self.assertIn("last30days.context.md", result)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,168 @@
"""Tests for score module."""
import sys
import unittest
from datetime import datetime, timezone
from pathlib import Path
# Add lib to path
sys.path.insert(0, str(Path(__file__).parent.parent / "scripts"))
from lib import schema, score
class TestLog1pSafe(unittest.TestCase):
def test_positive_value(self):
result = score.log1p_safe(100)
self.assertGreater(result, 0)
def test_zero(self):
result = score.log1p_safe(0)
self.assertEqual(result, 0)
def test_none(self):
result = score.log1p_safe(None)
self.assertEqual(result, 0)
def test_negative(self):
result = score.log1p_safe(-5)
self.assertEqual(result, 0)
class TestComputeRedditEngagementRaw(unittest.TestCase):
def test_with_engagement(self):
eng = schema.Engagement(score=100, num_comments=50, upvote_ratio=0.9)
result = score.compute_reddit_engagement_raw(eng)
self.assertIsNotNone(result)
self.assertGreater(result, 0)
def test_without_engagement(self):
result = score.compute_reddit_engagement_raw(None)
self.assertIsNone(result)
def test_empty_engagement(self):
eng = schema.Engagement()
result = score.compute_reddit_engagement_raw(eng)
self.assertIsNone(result)
class TestComputeXEngagementRaw(unittest.TestCase):
def test_with_engagement(self):
eng = schema.Engagement(likes=100, reposts=25, replies=15, quotes=5)
result = score.compute_x_engagement_raw(eng)
self.assertIsNotNone(result)
self.assertGreater(result, 0)
def test_without_engagement(self):
result = score.compute_x_engagement_raw(None)
self.assertIsNone(result)
class TestNormalizeTo100(unittest.TestCase):
def test_normalizes_values(self):
values = [0, 50, 100]
result = score.normalize_to_100(values)
self.assertEqual(result[0], 0)
self.assertEqual(result[1], 50)
self.assertEqual(result[2], 100)
def test_handles_none(self):
values = [0, None, 100]
result = score.normalize_to_100(values)
self.assertIsNone(result[1])
def test_single_value(self):
values = [50]
result = score.normalize_to_100(values)
self.assertEqual(result[0], 50)
class TestScoreRedditItems(unittest.TestCase):
def test_scores_items(self):
today = datetime.now(timezone.utc).date().isoformat()
items = [
schema.RedditItem(
id="R1",
title="Test",
url="https://reddit.com/r/test/1",
subreddit="test",
date=today,
date_confidence="high",
engagement=schema.Engagement(score=100, num_comments=50, upvote_ratio=0.9),
relevance=0.9,
),
schema.RedditItem(
id="R2",
title="Test 2",
url="https://reddit.com/r/test/2",
subreddit="test",
date=today,
date_confidence="high",
engagement=schema.Engagement(score=10, num_comments=5, upvote_ratio=0.8),
relevance=0.5,
),
]
result = score.score_reddit_items(items)
self.assertEqual(len(result), 2)
self.assertGreater(result[0].score, 0)
self.assertGreater(result[1].score, 0)
# Higher relevance and engagement should score higher
self.assertGreater(result[0].score, result[1].score)
def test_empty_list(self):
result = score.score_reddit_items([])
self.assertEqual(result, [])
class TestScoreXItems(unittest.TestCase):
def test_scores_items(self):
today = datetime.now(timezone.utc).date().isoformat()
items = [
schema.XItem(
id="X1",
text="Test post",
url="https://x.com/user/1",
author_handle="user1",
date=today,
date_confidence="high",
engagement=schema.Engagement(likes=100, reposts=25, replies=15, quotes=5),
relevance=0.9,
),
]
result = score.score_x_items(items)
self.assertEqual(len(result), 1)
self.assertGreater(result[0].score, 0)
class TestSortItems(unittest.TestCase):
def test_sorts_by_score_descending(self):
items = [
schema.RedditItem(id="R1", title="Low", url="", subreddit="", score=30),
schema.RedditItem(id="R2", title="High", url="", subreddit="", score=90),
schema.RedditItem(id="R3", title="Mid", url="", subreddit="", score=60),
]
result = score.sort_items(items)
self.assertEqual(result[0].id, "R2")
self.assertEqual(result[1].id, "R3")
self.assertEqual(result[2].id, "R1")
def test_stable_sort(self):
items = [
schema.RedditItem(id="R1", title="A", url="", subreddit="", score=50),
schema.RedditItem(id="R2", title="B", url="", subreddit="", score=50),
]
result = score.sort_items(items)
# Both have same score, should maintain order by title
self.assertEqual(len(result), 2)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,175 @@
# Voice AI Engine Development Skill
Build production-ready real-time conversational AI voice engines with async worker pipelines, streaming transcription, LLM agents, and TTS synthesis.
## Overview
This skill provides comprehensive guidance for building voice AI engines that enable natural, bidirectional conversations between users and AI agents. It covers the complete architecture from audio input to audio output, including:
- **Async Worker Pipeline Pattern** - Concurrent processing with queue-based communication
- **Streaming Transcription** - Real-time speech-to-text conversion
- **LLM-Powered Agents** - Conversational AI with context awareness
- **Text-to-Speech Synthesis** - Natural voice generation
- **Interrupt Handling** - Users can interrupt the bot mid-sentence
- **Multi-Provider Support** - Swap between different service providers easily
## Quick Start
```python
# Use the skill in your AI assistant
@voice-ai-engine-development I need to build a voice assistant that can handle real-time conversations with interrupts
```
## What's Included
### Main Skill File
- `SKILL.md` - Comprehensive guide to voice AI engine development
### Examples
- `complete_voice_engine.py` - Full working implementation
- `gemini_agent_example.py` - LLM agent with proper response buffering
- `interrupt_system_example.py` - Interrupt handling demonstration
### Templates
- `base_worker_template.py` - Template for creating new workers
- `multi_provider_factory_template.py` - Multi-provider factory pattern
### References
- `common_pitfalls.md` - Common issues and solutions
- `provider_comparison.md` - Comparison of transcription, LLM, and TTS providers
## Key Concepts
### The Worker Pipeline Pattern
Every voice AI engine follows this pipeline:
```
Audio In → Transcriber → Agent → Synthesizer → Audio Out
(Worker 1) (Worker 2) (Worker 3)
```
Each worker:
- Runs independently via asyncio
- Communicates through asyncio.Queue objects
- Can be stopped mid-stream for interrupts
- Handles errors gracefully
### Critical Implementation Details
1. **Buffer LLM Responses** - Always buffer the entire LLM response before sending to synthesizer to prevent audio jumping
2. **Mute Transcriber** - Mute the transcriber when bot speaks to prevent echo/feedback loops
3. **Rate-Limit Audio** - Send audio chunks at real-time speed to enable interrupts
4. **Proper Cleanup** - Always cleanup resources in finally blocks to prevent memory leaks
## Supported Providers
### Transcription
- Deepgram (fastest, best for real-time)
- AssemblyAI (highest accuracy)
- Azure Speech (enterprise-grade)
- Google Cloud Speech (multi-language)
### LLM
- OpenAI GPT-4 (highest quality)
- Google Gemini (cost-effective)
- Anthropic Claude (safety-focused)
### TTS
- ElevenLabs (most natural voices)
- Azure TTS (enterprise-grade)
- Google Cloud TTS (cost-effective)
- Amazon Polly (AWS integration)
- Play.ht (voice cloning)
## Common Use Cases
- Customer service voice bots
- Voice assistants
- Phone automation systems
- Voice-enabled applications
- Interactive voice response (IVR) systems
- Voice-based tutoring systems
## Architecture Highlights
### Async Worker Pattern
```python
class BaseWorker:
async def _run_loop(self):
while self.active:
item = await self.input_queue.get()
await self.process(item)
```
### Interrupt System
```python
# User interrupts bot mid-sentence
if stop_event.is_set():
partial_message = get_message_up_to(seconds_spoken)
return partial_message, True # cut_off = True
```
### Multi-Provider Factory
```python
factory = VoiceComponentFactory()
transcriber = factory.create_transcriber(config) # Deepgram, AssemblyAI, etc.
agent = factory.create_agent(config) # OpenAI, Gemini, etc.
synthesizer = factory.create_synthesizer(config) # ElevenLabs, Azure, etc.
```
## Testing
The skill includes examples for:
- Unit testing workers in isolation
- Integration testing the full pipeline
- Testing interrupt functionality
- Testing with different providers
## Best Practices
1. ✅ Always stream at every stage (transcription, LLM, synthesis)
2. ✅ Buffer entire LLM responses before synthesis
3. ✅ Mute transcriber during bot speech
4. ✅ Rate-limit audio chunks for interrupts
5. ✅ Maintain conversation history for context
6. ✅ Use proper error handling in worker loops
7. ✅ Cleanup resources in finally blocks
8. ✅ Use LINEAR16 PCM at 16kHz for audio
## Common Pitfalls
See `references/common_pitfalls.md` for detailed solutions to:
- Audio jumping/cutting off
- Echo/feedback loops
- Interrupts not working
- Memory leaks
- Lost conversation context
- High latency
- Poor audio quality
## Contributing
This skill is part of the Antigravity Awesome Skills repository. Contributions are welcome!
## Related Skills
- `@websocket-patterns` - WebSocket implementation
- `@async-python` - Asyncio patterns
- `@streaming-apis` - Streaming API integration
- `@audio-processing` - Audio format conversion
## License
MIT License - See repository LICENSE file
## Resources
- [Vocode Documentation](https://docs.vocode.dev/)
- [Deepgram API](https://developers.deepgram.com/)
- [OpenAI API](https://platform.openai.com/docs/)
- [ElevenLabs API](https://elevenlabs.io/docs/)
---
**Built with ❤️ for the Antigravity community**

View File

@@ -0,0 +1,721 @@
---
name: voice-ai-engine-development
description: "Build real-time conversational AI voice engines using async worker pipelines, streaming transcription, LLM agents, and TTS synthesis with interrupt handling and multi-provider support"
---
# Voice AI Engine Development
## Overview
This skill guides you through building production-ready voice AI engines with real-time conversation capabilities. Voice AI engines enable natural, bidirectional conversations between users and AI agents through streaming audio processing, speech-to-text transcription, LLM-powered responses, and text-to-speech synthesis.
The core architecture uses an async queue-based worker pipeline where each component runs independently and communicates via `asyncio.Queue` objects, enabling concurrent processing, interrupt handling, and real-time streaming at every stage.
## When to Use This Skill
Use this skill when:
- Building real-time voice conversation systems
- Implementing voice assistants or chatbots
- Creating voice-enabled customer service agents
- Developing voice AI applications with interrupt capabilities
- Integrating multiple transcription, LLM, or TTS providers
- Working with streaming audio processing pipelines
- The user mentions Vocode, voice engines, or conversational AI
## Core Architecture Principles
### The Worker Pipeline Pattern
Every voice AI engine follows this pipeline:
```
Audio In → Transcriber → Agent → Synthesizer → Audio Out
(Worker 1) (Worker 2) (Worker 3)
```
**Key Benefits:**
- **Decoupling**: Workers only know about their input/output queues
- **Concurrency**: All workers run simultaneously via asyncio
- **Backpressure**: Queues automatically handle rate differences
- **Interruptibility**: Everything can be stopped mid-stream
### Base Worker Pattern
Every worker follows this pattern:
```python
class BaseWorker:
def __init__(self, input_queue, output_queue):
self.input_queue = input_queue # asyncio.Queue to consume from
self.output_queue = output_queue # asyncio.Queue to produce to
self.active = False
def start(self):
"""Start the worker's processing loop"""
self.active = True
asyncio.create_task(self._run_loop())
async def _run_loop(self):
"""Main processing loop - runs forever until terminated"""
while self.active:
item = await self.input_queue.get() # Block until item arrives
await self.process(item) # Process the item
async def process(self, item):
"""Override this - does the actual work"""
raise NotImplementedError
def terminate(self):
"""Stop the worker"""
self.active = False
```
## Component Implementation Guide
### 1. Transcriber (Audio → Text)
**Purpose**: Converts incoming audio chunks to text transcriptions
**Interface Requirements**:
```python
class BaseTranscriber:
def __init__(self, transcriber_config):
self.input_queue = asyncio.Queue() # Audio chunks (bytes)
self.output_queue = asyncio.Queue() # Transcriptions
self.is_muted = False
def send_audio(self, chunk: bytes):
"""Client calls this to send audio"""
if not self.is_muted:
self.input_queue.put_nowait(chunk)
else:
# Send silence instead (prevents echo during bot speech)
self.input_queue.put_nowait(self.create_silent_chunk(len(chunk)))
def mute(self):
"""Called when bot starts speaking (prevents echo)"""
self.is_muted = True
def unmute(self):
"""Called when bot stops speaking"""
self.is_muted = False
```
**Output Format**:
```python
class Transcription:
message: str # "Hello, how are you?"
confidence: float # 0.95
is_final: bool # True = complete sentence, False = partial
is_interrupt: bool # Set by TranscriptionsWorker
```
**Supported Providers**:
- **Deepgram** - Fast, accurate, streaming
- **AssemblyAI** - High accuracy, good for accents
- **Azure Speech** - Enterprise-grade
- **Google Cloud Speech** - Multi-language support
**Critical Implementation Details**:
- Use WebSocket for bidirectional streaming
- Run sender and receiver tasks concurrently with `asyncio.gather()`
- Mute transcriber when bot speaks to prevent echo/feedback loops
- Handle both final and partial transcriptions
### 2. Agent (Text → Response)
**Purpose**: Processes user input and generates conversational responses
**Interface Requirements**:
```python
class BaseAgent:
def __init__(self, agent_config):
self.input_queue = asyncio.Queue() # TranscriptionAgentInput
self.output_queue = asyncio.Queue() # AgentResponse
self.transcript = None # Conversation history
async def generate_response(self, human_input, is_interrupt, conversation_id):
"""Override this - returns AsyncGenerator of responses"""
raise NotImplementedError
```
**Why Streaming Responses?**
- **Lower latency**: Start speaking as soon as first sentence is ready
- **Better interrupts**: Can stop mid-response
- **Sentence-by-sentence**: More natural conversation flow
**Supported Providers**:
- **OpenAI** (GPT-4, GPT-3.5) - High quality, fast
- **Google Gemini** - Multimodal, cost-effective
- **Anthropic Claude** - Long context, nuanced responses
**Critical Implementation Details**:
- Maintain conversation history in `Transcript` object
- Stream responses using `AsyncGenerator`
- **IMPORTANT**: Buffer entire LLM response before yielding to synthesizer (prevents audio jumping)
- Handle interrupts by canceling current generation task
- Update conversation history with partial messages on interrupt
### 3. Synthesizer (Text → Audio)
**Purpose**: Converts agent text responses to speech audio
**Interface Requirements**:
```python
class BaseSynthesizer:
async def create_speech(self, message: BaseMessage, chunk_size: int) -> SynthesisResult:
"""
Returns a SynthesisResult containing:
- chunk_generator: AsyncGenerator that yields audio chunks
- get_message_up_to: Function to get partial text (for interrupts)
"""
raise NotImplementedError
```
**SynthesisResult Structure**:
```python
class SynthesisResult:
chunk_generator: AsyncGenerator[ChunkResult, None]
get_message_up_to: Callable[[float], str] # seconds → partial text
class ChunkResult:
chunk: bytes # Raw PCM audio
is_last_chunk: bool
```
**Supported Providers**:
- **ElevenLabs** - Most natural voices, streaming
- **Azure TTS** - Enterprise-grade, many languages
- **Google Cloud TTS** - Cost-effective, good quality
- **Amazon Polly** - AWS integration
- **Play.ht** - Voice cloning
**Critical Implementation Details**:
- Stream audio chunks as they're generated
- Convert audio to LINEAR16 PCM format (16kHz sample rate)
- Implement `get_message_up_to()` for interrupt handling
- Handle audio format conversion (MP3 → PCM)
### 4. Output Device (Audio → Client)
**Purpose**: Sends synthesized audio back to the client
**CRITICAL: Rate Limiting for Interrupts**
```python
async def send_speech_to_output(self, message, synthesis_result,
stop_event, seconds_per_chunk):
chunk_idx = 0
async for chunk_result in synthesis_result.chunk_generator:
# Check for interrupt
if stop_event.is_set():
logger.debug(f"Interrupted after {chunk_idx} chunks")
message_sent = synthesis_result.get_message_up_to(
chunk_idx * seconds_per_chunk
)
return message_sent, True # cut_off = True
start_time = time.time()
# Send chunk to output device
self.output_device.consume_nonblocking(chunk_result.chunk)
# CRITICAL: Wait for chunk to play before sending next one
# This is what makes interrupts work!
speech_length = seconds_per_chunk
processing_time = time.time() - start_time
await asyncio.sleep(max(speech_length - processing_time, 0))
chunk_idx += 1
return message, False # cut_off = False
```
**Why Rate Limiting?**
Without rate limiting, all audio chunks would be sent immediately, which would:
- Buffer entire message on client side
- Make interrupts impossible (all audio already sent)
- Cause timing issues
By sending one chunk every N seconds:
- Real-time playback is maintained
- Interrupts can stop mid-sentence
- Natural conversation flow is preserved
## The Interrupt System
The interrupt system is critical for natural conversations.
### How Interrupts Work
**Scenario**: Bot is saying "I think the weather will be nice today and tomorrow and—" when user interrupts with "Stop".
**Step 1: User starts speaking**
```python
# TranscriptionsWorker detects new transcription while bot speaking
async def process(self, transcription):
if not self.conversation.is_human_speaking: # Bot was speaking!
# Broadcast interrupt to all in-flight events
interrupted = self.conversation.broadcast_interrupt()
transcription.is_interrupt = interrupted
```
**Step 2: broadcast_interrupt() stops everything**
```python
def broadcast_interrupt(self):
num_interrupts = 0
# Interrupt all queued events
while True:
try:
interruptible_event = self.interruptible_events.get_nowait()
if interruptible_event.interrupt(): # Sets interruption_event
num_interrupts += 1
except queue.Empty:
break
# Cancel current tasks
self.agent.cancel_current_task() # Stop generating text
self.agent_responses_worker.cancel_current_task() # Stop synthesizing
return num_interrupts > 0
```
**Step 3: SynthesisResultsWorker detects interrupt**
```python
async def send_speech_to_output(self, synthesis_result, stop_event, ...):
async for chunk_result in synthesis_result.chunk_generator:
# Check stop_event (this is the interruption_event)
if stop_event.is_set():
logger.debug("Interrupted! Stopping speech.")
# Calculate what was actually spoken
seconds_spoken = chunk_idx * seconds_per_chunk
partial_message = synthesis_result.get_message_up_to(seconds_spoken)
# e.g., "I think the weather will be nice today"
return partial_message, True # cut_off = True
```
**Step 4: Agent updates history**
```python
if cut_off:
# Update conversation history with partial message
self.agent.update_last_bot_message_on_cut_off(message_sent)
# History now shows:
# Bot: "I think the weather will be nice today" (incomplete)
```
### InterruptibleEvent Pattern
Every event in the pipeline is wrapped in an `InterruptibleEvent`:
```python
class InterruptibleEvent:
def __init__(self, payload, is_interruptible=True):
self.payload = payload
self.is_interruptible = is_interruptible
self.interruption_event = threading.Event() # Initially not set
self.interrupted = False
def interrupt(self) -> bool:
"""Interrupt this event"""
if not self.is_interruptible:
return False
if not self.interrupted:
self.interruption_event.set() # Signal to stop!
self.interrupted = True
return True
return False
def is_interrupted(self) -> bool:
return self.interruption_event.is_set()
```
## Multi-Provider Factory Pattern
Support multiple providers with a factory pattern:
```python
class VoiceHandler:
"""Multi-provider factory for voice components"""
def create_transcriber(self, agent_config: Dict):
"""Create transcriber based on transcriberProvider"""
provider = agent_config.get("transcriberProvider", "deepgram")
if provider == "deepgram":
return self._create_deepgram_transcriber(agent_config)
elif provider == "assemblyai":
return self._create_assemblyai_transcriber(agent_config)
elif provider == "azure":
return self._create_azure_transcriber(agent_config)
elif provider == "google":
return self._create_google_transcriber(agent_config)
else:
raise ValueError(f"Unknown transcriber provider: {provider}")
def create_agent(self, agent_config: Dict):
"""Create LLM agent based on llmProvider"""
provider = agent_config.get("llmProvider", "openai")
if provider == "openai":
return self._create_openai_agent(agent_config)
elif provider == "gemini":
return self._create_gemini_agent(agent_config)
else:
raise ValueError(f"Unknown LLM provider: {provider}")
def create_synthesizer(self, agent_config: Dict):
"""Create voice synthesizer based on voiceProvider"""
provider = agent_config.get("voiceProvider", "elevenlabs")
if provider == "elevenlabs":
return self._create_elevenlabs_synthesizer(agent_config)
elif provider == "azure":
return self._create_azure_synthesizer(agent_config)
elif provider == "google":
return self._create_google_synthesizer(agent_config)
elif provider == "polly":
return self._create_polly_synthesizer(agent_config)
elif provider == "playht":
return self._create_playht_synthesizer(agent_config)
else:
raise ValueError(f"Unknown voice provider: {provider}")
```
## WebSocket Integration
Voice AI engines typically use WebSocket for bidirectional audio streaming:
```python
@app.websocket("/conversation")
async def websocket_endpoint(websocket: WebSocket):
await websocket.accept()
# Create voice components
voice_handler = VoiceHandler()
transcriber = voice_handler.create_transcriber(agent_config)
agent = voice_handler.create_agent(agent_config)
synthesizer = voice_handler.create_synthesizer(agent_config)
# Create output device
output_device = WebsocketOutputDevice(
ws=websocket,
sampling_rate=16000,
audio_encoding=AudioEncoding.LINEAR16
)
# Create conversation orchestrator
conversation = StreamingConversation(
output_device=output_device,
transcriber=transcriber,
agent=agent,
synthesizer=synthesizer
)
# Start all workers
await conversation.start()
try:
# Receive audio from client
async for message in websocket.iter_bytes():
conversation.receive_audio(message)
except WebSocketDisconnect:
logger.info("Client disconnected")
finally:
await conversation.terminate()
```
## Common Pitfalls and Solutions
### 1. Audio Jumping/Cutting Off
**Problem**: Bot's audio jumps or cuts off mid-response.
**Cause**: Sending text to synthesizer in small chunks causes multiple TTS calls.
**Solution**: Buffer the entire LLM response before sending to synthesizer:
```python
# ❌ Bad: Yields sentence-by-sentence
async for sentence in llm_stream:
yield GeneratedResponse(message=BaseMessage(text=sentence))
# ✅ Good: Buffer entire response
full_response = ""
async for chunk in llm_stream:
full_response += chunk
yield GeneratedResponse(message=BaseMessage(text=full_response))
```
### 2. Echo/Feedback Loop
**Problem**: Bot hears itself speaking and responds to its own audio.
**Cause**: Transcriber not muted during bot speech.
**Solution**: Mute transcriber when bot starts speaking:
```python
# Before sending audio to output
self.transcriber.mute()
# After audio playback complete
self.transcriber.unmute()
```
### 3. Interrupts Not Working
**Problem**: User can't interrupt bot mid-sentence.
**Cause**: All audio chunks sent at once instead of rate-limited.
**Solution**: Rate-limit audio chunks to match real-time playback:
```python
async for chunk in synthesis_result.chunk_generator:
start_time = time.time()
# Send chunk
output_device.consume_nonblocking(chunk)
# Wait for chunk duration before sending next
processing_time = time.time() - start_time
await asyncio.sleep(max(seconds_per_chunk - processing_time, 0))
```
### 4. Memory Leaks from Unclosed Streams
**Problem**: Memory usage grows over time.
**Cause**: WebSocket connections or API streams not properly closed.
**Solution**: Always use context managers and cleanup:
```python
try:
async with websockets.connect(url) as ws:
# Use websocket
pass
finally:
# Cleanup
await conversation.terminate()
await transcriber.terminate()
```
## Production Considerations
### 1. Error Handling
```python
async def _run_loop(self):
while self.active:
try:
item = await self.input_queue.get()
await self.process(item)
except Exception as e:
logger.error(f"Worker error: {e}", exc_info=True)
# Don't crash the worker, continue processing
```
### 2. Graceful Shutdown
```python
async def terminate(self):
"""Gracefully shut down all workers"""
self.active = False
# Stop all workers
self.transcriber.terminate()
self.agent.terminate()
self.synthesizer.terminate()
# Wait for queues to drain
await asyncio.sleep(0.5)
# Close connections
if self.websocket:
await self.websocket.close()
```
### 3. Monitoring and Logging
```python
# Log key events
logger.info(f"🎤 [TRANSCRIBER] Received: '{transcription.message}'")
logger.info(f"🤖 [AGENT] Generating response...")
logger.info(f"🔊 [SYNTHESIZER] Synthesizing {len(text)} characters")
logger.info(f"⚠️ [INTERRUPT] User interrupted bot")
# Track metrics
metrics.increment("transcriptions.count")
metrics.timing("agent.response_time", duration)
metrics.gauge("active_conversations", count)
```
### 4. Rate Limiting and Quotas
```python
# Implement rate limiting for API calls
from aiolimiter import AsyncLimiter
rate_limiter = AsyncLimiter(max_rate=10, time_period=1) # 10 calls/second
async def call_api(self, data):
async with rate_limiter:
return await self.client.post(data)
```
## Key Design Patterns
### 1. Producer-Consumer with Queues
```python
# Producer
async def producer(queue):
while True:
item = await generate_item()
queue.put_nowait(item)
# Consumer
async def consumer(queue):
while True:
item = await queue.get()
await process_item(item)
```
### 2. Streaming Generators
Instead of returning complete results:
```python
# ❌ Bad: Wait for entire response
async def generate_response(prompt):
response = await openai.complete(prompt) # 5 seconds
return response
# ✅ Good: Stream chunks as they arrive
async def generate_response(prompt):
async for chunk in openai.complete(prompt, stream=True):
yield chunk # Yield after 0.1s, 0.2s, etc.
```
### 3. Conversation State Management
Maintain conversation history for context:
```python
class Transcript:
event_logs: List[Message] = []
def add_human_message(self, text):
self.event_logs.append(Message(sender=Sender.HUMAN, text=text))
def add_bot_message(self, text):
self.event_logs.append(Message(sender=Sender.BOT, text=text))
def to_openai_messages(self):
return [
{"role": "user" if msg.sender == Sender.HUMAN else "assistant",
"content": msg.text}
for msg in self.event_logs
]
```
## Testing Strategies
### 1. Unit Test Workers in Isolation
```python
async def test_transcriber():
transcriber = DeepgramTranscriber(config)
# Mock audio input
audio_chunk = b'\x00\x01\x02...'
transcriber.send_audio(audio_chunk)
# Check output
transcription = await transcriber.output_queue.get()
assert transcription.message == "expected text"
```
### 2. Integration Test Pipeline
```python
async def test_full_pipeline():
# Create all components
conversation = create_test_conversation()
# Send test audio
conversation.receive_audio(test_audio_chunk)
# Wait for response
response = await wait_for_audio_output(timeout=5)
assert response is not None
```
### 3. Test Interrupts
```python
async def test_interrupt():
conversation = create_test_conversation()
# Start bot speaking
await conversation.agent.generate_response("Tell me a long story")
# Interrupt mid-response
await asyncio.sleep(1) # Let it speak for 1 second
conversation.broadcast_interrupt()
# Verify partial message in transcript
last_message = conversation.transcript.event_logs[-1]
assert last_message.text != full_expected_message
```
## Implementation Workflow
When implementing a voice AI engine:
1. **Start with Base Workers**: Implement the base worker pattern first
2. **Add Transcriber**: Choose a provider and implement streaming transcription
3. **Add Agent**: Implement LLM integration with streaming responses
4. **Add Synthesizer**: Implement TTS with audio streaming
5. **Connect Pipeline**: Wire all workers together with queues
6. **Add Interrupts**: Implement the interrupt system
7. **Add WebSocket**: Create WebSocket endpoint for client communication
8. **Test Components**: Unit test each worker in isolation
9. **Test Integration**: Test the full pipeline end-to-end
10. **Add Error Handling**: Implement robust error handling and logging
11. **Optimize**: Add rate limiting, monitoring, and performance optimizations
## Related Skills
- `@websocket-patterns` - For WebSocket implementation details
- `@async-python` - For asyncio and async patterns
- `@streaming-apis` - For streaming API integration
- `@audio-processing` - For audio format conversion and processing
- `@systematic-debugging` - For debugging complex async pipelines
## Resources
**Libraries**:
- `asyncio` - Async programming
- `websockets` - WebSocket client/server
- `FastAPI` - WebSocket server framework
- `pydub` - Audio manipulation
- `numpy` - Audio data processing
**API Providers**:
- Transcription: Deepgram, AssemblyAI, Azure Speech, Google Cloud Speech
- LLM: OpenAI, Google Gemini, Anthropic Claude
- TTS: ElevenLabs, Azure TTS, Google Cloud TTS, Amazon Polly, Play.ht
## Summary
Building a voice AI engine requires:
- ✅ Async worker pipeline for concurrent processing
- ✅ Queue-based communication between components
- ✅ Streaming at every stage (transcription, LLM, synthesis)
- ✅ Interrupt system for natural conversations
- ✅ Rate limiting for real-time audio playback
- ✅ Multi-provider support for flexibility
- ✅ Proper error handling and graceful shutdown
**The key insight**: Everything must stream and everything must be interruptible for natural, real-time conversations.

View File

@@ -0,0 +1,423 @@
"""
Example: Complete Voice AI Engine Implementation
This example demonstrates a minimal but complete voice AI engine
with all core components: Transcriber, Agent, Synthesizer, and WebSocket integration.
"""
import asyncio
from typing import Dict, AsyncGenerator
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
from dataclasses import dataclass
import logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
app = FastAPI()
# ============================================================================
# Data Models
# ============================================================================
@dataclass
class Transcription:
message: str
confidence: float
is_final: bool
is_interrupt: bool = False
@dataclass
class AgentResponse:
message: str
is_interruptible: bool = True
@dataclass
class SynthesisResult:
chunk_generator: AsyncGenerator[bytes, None]
get_message_up_to: callable
# ============================================================================
# Base Worker Pattern
# ============================================================================
class BaseWorker:
"""Base class for all workers in the pipeline"""
def __init__(self, input_queue: asyncio.Queue, output_queue: asyncio.Queue):
self.input_queue = input_queue
self.output_queue = output_queue
self.active = False
self._task = None
def start(self):
"""Start the worker's processing loop"""
self.active = True
self._task = asyncio.create_task(self._run_loop())
async def _run_loop(self):
"""Main processing loop - runs forever until terminated"""
while self.active:
try:
item = await self.input_queue.get()
await self.process(item)
except Exception as e:
logger.error(f"Worker error: {e}", exc_info=True)
async def process(self, item):
"""Override this - does the actual work"""
raise NotImplementedError
def terminate(self):
"""Stop the worker"""
self.active = False
if self._task:
self._task.cancel()
# ============================================================================
# Transcriber Component
# ============================================================================
class DeepgramTranscriber(BaseWorker):
"""Converts audio chunks to text transcriptions using Deepgram"""
def __init__(self, config: Dict):
super().__init__(asyncio.Queue(), asyncio.Queue())
self.config = config
self.is_muted = False
def send_audio(self, chunk: bytes):
"""Client calls this to send audio"""
if not self.is_muted:
self.input_queue.put_nowait(chunk)
else:
# Send silence instead (prevents echo during bot speech)
self.input_queue.put_nowait(self.create_silent_chunk(len(chunk)))
def create_silent_chunk(self, size: int) -> bytes:
"""Create a silent audio chunk"""
return b'\x00' * size
def mute(self):
"""Called when bot starts speaking (prevents echo)"""
self.is_muted = True
logger.info("🔇 [TRANSCRIBER] Muted")
def unmute(self):
"""Called when bot stops speaking"""
self.is_muted = False
logger.info("🔊 [TRANSCRIBER] Unmuted")
async def process(self, audio_chunk: bytes):
"""Process audio chunk and generate transcription"""
# In a real implementation, this would call Deepgram API
# For this example, we'll simulate a transcription
# Simulate API call delay
await asyncio.sleep(0.1)
# Mock transcription
transcription = Transcription(
message="Hello, how can I help you?",
confidence=0.95,
is_final=True
)
logger.info(f"🎤 [TRANSCRIBER] Received: '{transcription.message}'")
self.output_queue.put_nowait(transcription)
# ============================================================================
# Agent Component
# ============================================================================
class GeminiAgent(BaseWorker):
"""LLM-powered conversational agent using Google Gemini"""
def __init__(self, config: Dict):
super().__init__(asyncio.Queue(), asyncio.Queue())
self.config = config
self.conversation_history = []
async def process(self, transcription: Transcription):
"""Process transcription and generate response"""
# Add user message to history
self.conversation_history.append({
"role": "user",
"content": transcription.message
})
logger.info(f"🤖 [AGENT] Generating response for: '{transcription.message}'")
# Generate response (streaming)
async for response in self.generate_response(transcription.message):
self.output_queue.put_nowait(response)
async def generate_response(self, user_input: str) -> AsyncGenerator[AgentResponse, None]:
"""Generate streaming response from LLM"""
# In a real implementation, this would call Gemini API
# For this example, we'll simulate a streaming response
# Simulate streaming delay
await asyncio.sleep(0.5)
# IMPORTANT: Buffer entire response before yielding
# This prevents audio jumping/cutting off
full_response = f"I understand you said: {user_input}. How can I assist you further?"
# Add to conversation history
self.conversation_history.append({
"role": "assistant",
"content": full_response
})
logger.info(f"🤖 [AGENT] Generated: '{full_response}'")
# Yield complete response
yield AgentResponse(
message=full_response,
is_interruptible=True
)
# ============================================================================
# Synthesizer Component
# ============================================================================
class ElevenLabsSynthesizer:
"""Converts text to speech using ElevenLabs"""
def __init__(self, config: Dict):
self.config = config
async def create_speech(self, message: str, chunk_size: int = 1024) -> SynthesisResult:
"""
Generate speech audio from text
Returns SynthesisResult with:
- chunk_generator: AsyncGenerator yielding audio chunks
- get_message_up_to: Function to get partial text for interrupts
"""
# In a real implementation, this would call ElevenLabs API
# For this example, we'll simulate audio generation
logger.info(f"🔊 [SYNTHESIZER] Synthesizing {len(message)} characters")
async def chunk_generator():
# Simulate streaming audio chunks
num_chunks = len(message) // 10 + 1
for i in range(num_chunks):
# Simulate API delay
await asyncio.sleep(0.1)
# Mock audio chunk (in reality, this would be PCM audio)
chunk = b'\x00' * chunk_size
yield chunk
def get_message_up_to(seconds: float) -> str:
"""Calculate partial message based on playback time"""
# Estimate: ~150 words per minute = ~2.5 words per second
# Rough estimate: 5 characters per word
chars_per_second = 12.5
char_index = int(seconds * chars_per_second)
return message[:char_index]
return SynthesisResult(
chunk_generator=chunk_generator(),
get_message_up_to=get_message_up_to
)
# ============================================================================
# Output Device
# ============================================================================
class WebsocketOutputDevice:
"""Sends audio chunks to client via WebSocket"""
def __init__(self, websocket: WebSocket):
self.websocket = websocket
async def consume_nonblocking(self, chunk: bytes):
"""Send audio chunk to client"""
await self.websocket.send_bytes(chunk)
# ============================================================================
# Conversation Orchestrator
# ============================================================================
class StreamingConversation:
"""Orchestrates the entire voice conversation pipeline"""
def __init__(
self,
output_device: WebsocketOutputDevice,
transcriber: DeepgramTranscriber,
agent: GeminiAgent,
synthesizer: ElevenLabsSynthesizer
):
self.output_device = output_device
self.transcriber = transcriber
self.agent = agent
self.synthesizer = synthesizer
self.is_human_speaking = True
self.interrupt_event = asyncio.Event()
async def start(self):
"""Start all workers"""
logger.info("🚀 [CONVERSATION] Starting...")
# Start workers
self.transcriber.start()
self.agent.start()
# Start processing pipelines
asyncio.create_task(self._process_transcriptions())
asyncio.create_task(self._process_agent_responses())
async def _process_transcriptions(self):
"""Process transcriptions from transcriber"""
while True:
transcription = await self.transcriber.output_queue.get()
# Check if this is an interrupt
if not self.is_human_speaking:
logger.info("⚠️ [INTERRUPT] User interrupted bot")
self.interrupt_event.set()
transcription.is_interrupt = True
self.is_human_speaking = True
# Send to agent
await self.agent.input_queue.put(transcription)
async def _process_agent_responses(self):
"""Process responses from agent and synthesize"""
while True:
response = await self.agent.output_queue.get()
self.is_human_speaking = False
# Mute transcriber to prevent echo
self.transcriber.mute()
# Synthesize and play
synthesis_result = await self.synthesizer.create_speech(response.message)
await self._send_speech_to_output(synthesis_result, seconds_per_chunk=0.1)
# Unmute transcriber
self.transcriber.unmute()
self.is_human_speaking = True
async def _send_speech_to_output(self, synthesis_result: SynthesisResult, seconds_per_chunk: float):
"""
Send synthesized audio to output with rate limiting
CRITICAL: Rate limiting enables interrupts to work
"""
chunk_idx = 0
async for chunk in synthesis_result.chunk_generator:
# Check for interrupt
if self.interrupt_event.is_set():
logger.info(f"🛑 [INTERRUPT] Stopped after {chunk_idx} chunks")
# Calculate what was actually spoken
seconds_spoken = chunk_idx * seconds_per_chunk
partial_message = synthesis_result.get_message_up_to(seconds_spoken)
logger.info(f"📝 [INTERRUPT] Partial message: '{partial_message}'")
# Clear interrupt event
self.interrupt_event.clear()
return
start_time = asyncio.get_event_loop().time()
# Send chunk to output device
await self.output_device.consume_nonblocking(chunk)
# CRITICAL: Wait for chunk to play before sending next one
# This is what makes interrupts work!
processing_time = asyncio.get_event_loop().time() - start_time
await asyncio.sleep(max(seconds_per_chunk - processing_time, 0))
chunk_idx += 1
def receive_audio(self, audio_chunk: bytes):
"""Receive audio from client"""
self.transcriber.send_audio(audio_chunk)
async def terminate(self):
"""Gracefully shut down all workers"""
logger.info("🛑 [CONVERSATION] Terminating...")
self.transcriber.terminate()
self.agent.terminate()
# Wait for queues to drain
await asyncio.sleep(0.5)
# ============================================================================
# WebSocket Endpoint
# ============================================================================
@app.websocket("/conversation")
async def conversation_endpoint(websocket: WebSocket):
"""WebSocket endpoint for voice conversations"""
await websocket.accept()
logger.info("✅ [WEBSOCKET] Client connected")
# Configuration
config = {
"transcriberProvider": "deepgram",
"llmProvider": "gemini",
"voiceProvider": "elevenlabs",
"prompt": "You are a helpful AI assistant.",
}
# Create components
transcriber = DeepgramTranscriber(config)
agent = GeminiAgent(config)
synthesizer = ElevenLabsSynthesizer(config)
output_device = WebsocketOutputDevice(websocket)
# Create conversation
conversation = StreamingConversation(
output_device=output_device,
transcriber=transcriber,
agent=agent,
synthesizer=synthesizer
)
# Start conversation
await conversation.start()
try:
# Process incoming audio
async for message in websocket.iter_bytes():
conversation.receive_audio(message)
except WebSocketDisconnect:
logger.info("❌ [WEBSOCKET] Client disconnected")
except Exception as e:
logger.error(f"❌ [WEBSOCKET] Error: {e}", exc_info=True)
finally:
await conversation.terminate()
# ============================================================================
# Main Entry Point
# ============================================================================
if __name__ == "__main__":
import uvicorn
logger.info("🚀 Starting Voice AI Engine...")
uvicorn.run(app, host="0.0.0.0", port=8000)

View File

@@ -0,0 +1,239 @@
"""
Example: Gemini Agent Implementation with Streaming
This example shows how to implement a Gemini-powered agent
that properly buffers responses to prevent audio jumping.
"""
import asyncio
from typing import AsyncGenerator, List, Dict
from dataclasses import dataclass
import logging
logger = logging.getLogger(__name__)
@dataclass
class Message:
role: str # "user" or "assistant"
content: str
@dataclass
class GeneratedResponse:
message: str
is_interruptible: bool = True
class GeminiAgent:
"""
LLM-powered conversational agent using Google Gemini
Key Features:
- Maintains conversation history
- Streams responses from Gemini API
- Buffers entire response before yielding (prevents audio jumping)
- Handles interrupts gracefully
"""
def __init__(self, config: Dict):
self.config = config
self.conversation_history: List[Message] = []
self.system_prompt = config.get("prompt", "You are a helpful AI assistant.")
self.current_task = None
async def generate_response(
self,
user_input: str,
is_interrupt: bool = False
) -> AsyncGenerator[GeneratedResponse, None]:
"""
Generate streaming response from Gemini
IMPORTANT: This buffers the entire LLM response before yielding
to prevent audio jumping/cutting off.
Args:
user_input: The user's message
is_interrupt: Whether this is an interrupt
Yields:
GeneratedResponse with complete buffered message
"""
# Add user message to history
self.conversation_history.append(
Message(role="user", content=user_input)
)
logger.info(f"🤖 [AGENT] Generating response for: '{user_input}'")
# Build conversation context for Gemini
contents = self._build_gemini_contents()
# Stream response from Gemini and buffer it
full_response = ""
try:
# In a real implementation, this would call Gemini API
# async for chunk in self._create_gemini_stream(contents):
# if isinstance(chunk, str):
# full_response += chunk
# For this example, simulate streaming
async for chunk in self._simulate_gemini_stream(user_input):
full_response += chunk
# Log progress (optional)
if len(full_response) % 50 == 0:
logger.debug(f"🤖 [AGENT] Buffered {len(full_response)} chars...")
except Exception as e:
logger.error(f"❌ [AGENT] Error generating response: {e}")
full_response = "I apologize, but I encountered an error. Could you please try again?"
# CRITICAL: Only yield after buffering the ENTIRE response
# This prevents multiple TTS calls that cause audio jumping
if full_response.strip():
# Add to conversation history
self.conversation_history.append(
Message(role="assistant", content=full_response)
)
logger.info(f"✅ [AGENT] Generated complete response ({len(full_response)} chars)")
yield GeneratedResponse(
message=full_response.strip(),
is_interruptible=True
)
def _build_gemini_contents(self) -> List[Dict]:
"""
Build conversation contents for Gemini API
Format:
[
{"role": "user", "parts": [{"text": "System: ..."}]},
{"role": "model", "parts": [{"text": "Understood."}]},
{"role": "user", "parts": [{"text": "Hello"}]},
{"role": "model", "parts": [{"text": "Hi there!"}]},
...
]
"""
contents = []
# Add system prompt as first user message
if self.system_prompt:
contents.append({
"role": "user",
"parts": [{"text": f"System Instruction: {self.system_prompt}"}]
})
contents.append({
"role": "model",
"parts": [{"text": "Understood."}]
})
# Add conversation history
for message in self.conversation_history:
role = "user" if message.role == "user" else "model"
contents.append({
"role": role,
"parts": [{"text": message.content}]
})
return contents
async def _simulate_gemini_stream(self, user_input: str) -> AsyncGenerator[str, None]:
"""
Simulate Gemini streaming response
In a real implementation, this would be:
async def _create_gemini_stream(self, contents):
response = await genai.GenerativeModel('gemini-pro').generate_content_async(
contents,
stream=True
)
async for chunk in response:
if chunk.text:
yield chunk.text
"""
# Simulate response
response = f"I understand you said: {user_input}. How can I assist you further?"
# Simulate streaming by yielding chunks
chunk_size = 10
for i in range(0, len(response), chunk_size):
chunk = response[i:i + chunk_size]
await asyncio.sleep(0.05) # Simulate network delay
yield chunk
def update_last_bot_message_on_cut_off(self, partial_message: str):
"""
Update conversation history when bot is interrupted
This ensures the conversation history reflects what was actually spoken,
not what was planned to be spoken.
Args:
partial_message: The partial message that was actually spoken
"""
if self.conversation_history and self.conversation_history[-1].role == "assistant":
# Update the last bot message with the partial message
self.conversation_history[-1].content = partial_message
logger.info(f"📝 [AGENT] Updated history with partial message: '{partial_message}'")
def cancel_current_task(self):
"""Cancel the current generation task (for interrupts)"""
if self.current_task and not self.current_task.done():
self.current_task.cancel()
logger.info("🛑 [AGENT] Cancelled current generation task")
def get_conversation_history(self) -> List[Message]:
"""Get the full conversation history"""
return self.conversation_history.copy()
def clear_conversation_history(self):
"""Clear the conversation history"""
self.conversation_history.clear()
logger.info("🗑️ [AGENT] Cleared conversation history")
# ============================================================================
# Example Usage
# ============================================================================
async def example_usage():
"""Example of how to use the GeminiAgent"""
# Configure agent
config = {
"prompt": "You are a helpful AI assistant specializing in voice conversations.",
"llmProvider": "gemini"
}
# Create agent
agent = GeminiAgent(config)
# Simulate conversation
user_messages = [
"Hello, how are you?",
"What's the weather like today?",
"Thank you!"
]
for user_message in user_messages:
print(f"\n👤 User: {user_message}")
# Generate response
async for response in agent.generate_response(user_message):
print(f"🤖 Bot: {response.message}")
# Print conversation history
print("\n📜 Conversation History:")
for i, message in enumerate(agent.get_conversation_history(), 1):
print(f"{i}. {message.role}: {message.content}")
if __name__ == "__main__":
asyncio.run(example_usage())

View File

@@ -0,0 +1,334 @@
"""
Example: Interrupt System Implementation
This example demonstrates how to implement a robust interrupt system
that allows users to interrupt the bot mid-sentence.
"""
import asyncio
import threading
from typing import Any
from dataclasses import dataclass
import logging
logger = logging.getLogger(__name__)
# ============================================================================
# InterruptibleEvent Pattern
# ============================================================================
class InterruptibleEvent:
"""
Wrapper for events that can be interrupted
Every event in the pipeline is wrapped in an InterruptibleEvent,
allowing the system to stop processing mid-stream.
"""
def __init__(self, payload: Any, is_interruptible: bool = True):
self.payload = payload
self.is_interruptible = is_interruptible
self.interruption_event = threading.Event() # Initially not set
self.interrupted = False
def interrupt(self) -> bool:
"""
Interrupt this event
Returns:
True if the event was interrupted, False if it was not interruptible
"""
if not self.is_interruptible:
return False
if not self.interrupted:
self.interruption_event.set() # Signal to stop!
self.interrupted = True
logger.info("⚠️ [INTERRUPT] Event interrupted")
return True
return False
def is_interrupted(self) -> bool:
"""Check if this event has been interrupted"""
return self.interruption_event.is_set()
# ============================================================================
# Conversation with Interrupt Support
# ============================================================================
class ConversationWithInterrupts:
"""
Conversation orchestrator with interrupt support
Key Features:
- Tracks all in-flight interruptible events
- Broadcasts interrupts to all workers
- Cancels current tasks
- Updates conversation history with partial messages
"""
def __init__(self):
self.is_human_speaking = True
self.interruptible_events = asyncio.Queue()
self.agent = None # Set externally
self.synthesizer_worker = None # Set externally
def broadcast_interrupt(self) -> bool:
"""
Broadcast interrupt to all in-flight events
This is called when the user starts speaking while the bot is speaking.
Returns:
True if any events were interrupted
"""
num_interrupts = 0
# Interrupt all queued events
while True:
try:
interruptible_event = self.interruptible_events.get_nowait()
if interruptible_event.interrupt():
num_interrupts += 1
except asyncio.QueueEmpty:
break
# Cancel current tasks
if self.agent:
self.agent.cancel_current_task()
if self.synthesizer_worker:
self.synthesizer_worker.cancel_current_task()
logger.info(f"⚠️ [INTERRUPT] Interrupted {num_interrupts} events")
return num_interrupts > 0
def add_interruptible_event(self, event: InterruptibleEvent):
"""Add an event to the interruptible queue"""
self.interruptible_events.put_nowait(event)
# ============================================================================
# Synthesis Worker with Interrupt Support
# ============================================================================
class SynthesisWorkerWithInterrupts:
"""
Synthesis worker that supports interrupts
Key Features:
- Checks for interrupts before sending each audio chunk
- Calculates partial message when interrupted
- Updates agent's conversation history with partial message
"""
def __init__(self, agent, output_device):
self.agent = agent
self.output_device = output_device
self.current_task = None
async def send_speech_to_output(
self,
message: str,
synthesis_result,
stop_event: threading.Event,
seconds_per_chunk: float = 0.1
) -> tuple[str, bool]:
"""
Send synthesized speech to output with interrupt support
Args:
message: The full message being synthesized
synthesis_result: SynthesisResult with chunk_generator and get_message_up_to
stop_event: Event that signals when to stop (interrupt)
seconds_per_chunk: Duration of each audio chunk in seconds
Returns:
Tuple of (message_sent, was_cut_off)
- message_sent: The actual message sent (partial if interrupted)
- was_cut_off: True if interrupted, False if completed
"""
chunk_idx = 0
async for chunk_result in synthesis_result.chunk_generator:
# CRITICAL: Check for interrupt before sending each chunk
if stop_event.is_set():
logger.info(f"🛑 [SYNTHESIZER] Interrupted after {chunk_idx} chunks")
# Calculate what was actually spoken
seconds_spoken = chunk_idx * seconds_per_chunk
partial_message = synthesis_result.get_message_up_to(seconds_spoken)
logger.info(f"📝 [SYNTHESIZER] Partial message: '{partial_message}'")
return partial_message, True # cut_off = True
start_time = asyncio.get_event_loop().time()
# Send chunk to output device
await self.output_device.consume_nonblocking(chunk_result.chunk)
# CRITICAL: Wait for chunk to play before sending next one
# This is what makes interrupts work!
processing_time = asyncio.get_event_loop().time() - start_time
await asyncio.sleep(max(seconds_per_chunk - processing_time, 0))
chunk_idx += 1
# Completed without interruption
logger.info(f"✅ [SYNTHESIZER] Completed {chunk_idx} chunks")
return message, False # cut_off = False
def cancel_current_task(self):
"""Cancel the current synthesis task"""
if self.current_task and not self.current_task.done():
self.current_task.cancel()
logger.info("🛑 [SYNTHESIZER] Cancelled current task")
# ============================================================================
# Transcription Worker with Interrupt Detection
# ============================================================================
class TranscriptionWorkerWithInterrupts:
"""
Transcription worker that detects interrupts
Key Features:
- Detects when user speaks while bot is speaking
- Marks transcription as interrupt
- Triggers broadcast_interrupt()
"""
def __init__(self, conversation):
self.conversation = conversation
async def process(self, transcription):
"""
Process transcription and detect interrupts
If the user starts speaking while the bot is speaking,
this is an interrupt.
"""
# Check if this is an interrupt
if not self.conversation.is_human_speaking:
logger.info("⚠️ [TRANSCRIPTION] User interrupted bot!")
# Broadcast interrupt to all in-flight events
interrupted = self.conversation.broadcast_interrupt()
transcription.is_interrupt = interrupted
# Update speaking state
self.conversation.is_human_speaking = True
# Continue processing transcription...
logger.info(f"🎤 [TRANSCRIPTION] Received: '{transcription.message}'")
# ============================================================================
# Example Usage
# ============================================================================
@dataclass
class MockTranscription:
message: str
is_interrupt: bool = False
@dataclass
class MockSynthesisResult:
async def chunk_generator(self):
"""Generate mock audio chunks"""
for i in range(10):
await asyncio.sleep(0.1)
yield type('obj', (object,), {'chunk': b'\x00' * 1024})()
def get_message_up_to(self, seconds: float) -> str:
"""Get partial message up to specified seconds"""
full_message = "I think the weather will be nice today and tomorrow and the day after."
chars_per_second = len(full_message) / 1.0 # Assume 1 second total
char_index = int(seconds * chars_per_second)
return full_message[:char_index]
async def example_interrupt_scenario():
"""
Example scenario: User interrupts bot mid-sentence
"""
print("🎬 Scenario: User interrupts bot mid-sentence\n")
# Create conversation
conversation = ConversationWithInterrupts()
# Create mock components
class MockAgent:
def cancel_current_task(self):
print("🛑 [AGENT] Task cancelled")
def update_last_bot_message_on_cut_off(self, partial_message):
print(f"📝 [AGENT] Updated history: '{partial_message}'")
class MockOutputDevice:
async def consume_nonblocking(self, chunk):
pass
agent = MockAgent()
output_device = MockOutputDevice()
conversation.agent = agent
# Create synthesis worker
synthesis_worker = SynthesisWorkerWithInterrupts(agent, output_device)
conversation.synthesizer_worker = synthesis_worker
# Create interruptible event
stop_event = threading.Event()
interruptible_event = InterruptibleEvent(
payload="Bot is speaking...",
is_interruptible=True
)
conversation.add_interruptible_event(interruptible_event)
# Start bot speaking
print("🤖 Bot starts speaking: 'I think the weather will be nice today and tomorrow and the day after.'\n")
conversation.is_human_speaking = False
# Simulate synthesis in background
synthesis_result = MockSynthesisResult()
synthesis_task = asyncio.create_task(
synthesis_worker.send_speech_to_output(
message="I think the weather will be nice today and tomorrow and the day after.",
synthesis_result=synthesis_result,
stop_event=stop_event,
seconds_per_chunk=0.1
)
)
# Wait a bit, then interrupt
await asyncio.sleep(0.3)
print("👤 User interrupts: 'Stop!'\n")
# Trigger interrupt
conversation.broadcast_interrupt()
stop_event.set()
# Wait for synthesis to finish
message_sent, was_cut_off = await synthesis_task
print(f"\n✅ Result:")
print(f" - Message sent: '{message_sent}'")
print(f" - Was cut off: {was_cut_off}")
# Update agent history
if was_cut_off:
agent.update_last_bot_message_on_cut_off(message_sent)
if __name__ == "__main__":
asyncio.run(example_interrupt_scenario())

View File

@@ -0,0 +1,471 @@
# Common Pitfalls and Solutions
This document covers common issues encountered when building voice AI engines and their solutions.
## 1. Audio Jumping/Cutting Off
### Problem
The bot's audio jumps or cuts off mid-response, creating a jarring user experience.
### Symptoms
- Audio plays in fragments
- Sentences are incomplete
- Multiple audio streams overlap
- Unnatural pauses or gaps
### Root Cause
Sending text to the synthesizer in small chunks (sentence-by-sentence or word-by-word) causes multiple TTS API calls. Each call generates a separate audio stream, resulting in:
- Multiple audio files being played sequentially
- Timing issues between chunks
- Potential overlapping audio
- Inconsistent voice characteristics between chunks
### Solution
Buffer the entire LLM response before sending it to the synthesizer:
**❌ Bad: Yields sentence-by-sentence**
```python
async def generate_response(self, prompt):
async for sentence in llm_stream:
# This creates multiple TTS calls!
yield GeneratedResponse(message=BaseMessage(text=sentence))
```
**✅ Good: Buffer entire response**
```python
async def generate_response(self, prompt):
# Buffer the entire response
full_response = ""
async for chunk in llm_stream:
full_response += chunk
# Yield once with complete response
yield GeneratedResponse(message=BaseMessage(text=full_response))
```
### Why This Works
- Single TTS call for the entire response
- Consistent voice characteristics
- Proper timing and pacing
- No gaps or overlaps
---
## 2. Echo/Feedback Loop
### Problem
The bot hears itself speaking and responds to its own audio, creating an infinite loop.
### Symptoms
- Bot responds to its own speech
- Conversation becomes nonsensical
- Transcriptions include bot's own words
- System becomes unresponsive
### Root Cause
The transcriber continues to process audio while the bot is speaking. If the bot's audio is being played through speakers and captured by the microphone, the transcriber will transcribe the bot's own speech.
### Solution
Mute the transcriber when the bot starts speaking:
```python
# Before sending audio to output
self.transcriber.mute()
# Send audio...
await self.send_speech_to_output(synthesis_result)
# After audio playback complete
self.transcriber.unmute()
```
### Implementation in Transcriber
```python
class BaseTranscriber:
def __init__(self):
self.is_muted = False
def send_audio(self, chunk: bytes):
"""Client calls this to send audio"""
if not self.is_muted:
self.input_queue.put_nowait(chunk)
else:
# Send silence instead (prevents echo)
self.input_queue.put_nowait(self.create_silent_chunk(len(chunk)))
def mute(self):
"""Called when bot starts speaking"""
self.is_muted = True
def unmute(self):
"""Called when bot stops speaking"""
self.is_muted = False
def create_silent_chunk(self, size: int) -> bytes:
"""Create a silent audio chunk"""
return b'\x00' * size
```
### Why This Works
- Transcriber receives silence while bot speaks
- No transcription of bot's own speech
- Prevents feedback loop
- Maintains audio stream continuity
---
## 3. Interrupts Not Working
### Problem
Users cannot interrupt the bot mid-sentence. The bot continues speaking even when the user starts talking.
### Symptoms
- Bot speaks over user
- User must wait for bot to finish
- Unnatural conversation flow
- Poor user experience
### Root Cause
All audio chunks are sent to the client immediately, buffering the entire message on the client side. By the time an interrupt is detected, all audio has already been sent and is queued for playback.
### Solution
Rate-limit audio chunks to match real-time playback:
**❌ Bad: Send all chunks immediately**
```python
async for chunk in synthesis_result.chunk_generator:
# Sends all chunks as fast as possible
output_device.consume_nonblocking(chunk)
```
**✅ Good: Rate-limit chunks**
```python
async for chunk in synthesis_result.chunk_generator:
# Check for interrupt
if stop_event.is_set():
# Calculate partial message
partial_message = synthesis_result.get_message_up_to(
chunk_idx * seconds_per_chunk
)
return partial_message, True # cut_off = True
start_time = time.time()
# Send chunk
output_device.consume_nonblocking(chunk)
# CRITICAL: Wait for chunk duration before sending next
processing_time = time.time() - start_time
await asyncio.sleep(max(seconds_per_chunk - processing_time, 0))
chunk_idx += 1
```
### Why This Works
- Only one chunk is buffered on client at a time
- Interrupts can stop mid-sentence
- Natural conversation flow
- Real-time playback maintained
### Calculating `seconds_per_chunk`
```python
# For LINEAR16 PCM audio at 16kHz
sample_rate = 16000 # Hz
chunk_size = 1024 # bytes
bytes_per_sample = 2 # 16-bit = 2 bytes
samples_per_chunk = chunk_size / bytes_per_sample
seconds_per_chunk = samples_per_chunk / sample_rate
# = 1024 / 2 / 16000 = 0.032 seconds
```
---
## 4. Memory Leaks from Unclosed Streams
### Problem
Memory usage grows over time, eventually causing the application to crash.
### Symptoms
- Increasing memory usage
- Slow performance over time
- WebSocket connections not closing
- Resource exhaustion
### Root Cause
WebSocket connections, API streams, or async tasks are not properly closed when conversations end or errors occur.
### Solution
Always use context managers and cleanup:
**❌ Bad: No cleanup**
```python
async def handle_conversation(websocket):
conversation = create_conversation()
await conversation.start()
async for message in websocket.iter_bytes():
conversation.receive_audio(message)
# No cleanup! Resources leak
```
**✅ Good: Proper cleanup**
```python
async def handle_conversation(websocket):
conversation = None
try:
conversation = create_conversation()
await conversation.start()
async for message in websocket.iter_bytes():
conversation.receive_audio(message)
except WebSocketDisconnect:
logger.info("Client disconnected")
except Exception as e:
logger.error(f"Error: {e}", exc_info=True)
finally:
# Always cleanup
if conversation:
await conversation.terminate()
```
### Proper Termination
```python
async def terminate(self):
"""Gracefully shut down all workers"""
self.active = False
# Stop all workers
self.transcriber.terminate()
self.agent.terminate()
self.synthesizer.terminate()
# Wait for queues to drain
await asyncio.sleep(0.5)
# Close connections
if self.websocket:
await self.websocket.close()
# Cancel tasks
for task in self.tasks:
if not task.done():
task.cancel()
```
---
## 5. Conversation History Not Updating
### Problem
The agent doesn't remember previous messages or context is lost.
### Symptoms
- Agent repeats itself
- No context from previous messages
- Each response is independent
- Poor conversation quality
### Root Cause
Conversation history is not being maintained or updated correctly.
### Solution
Maintain conversation history in the agent:
```python
class Agent:
def __init__(self):
self.conversation_history = []
async def generate_response(self, user_input):
# Add user message to history
self.conversation_history.append({
"role": "user",
"content": user_input
})
# Generate response with full history
response = await self.llm.generate(self.conversation_history)
# Add bot response to history
self.conversation_history.append({
"role": "assistant",
"content": response
})
return response
```
### Handling Interrupts
When the bot is interrupted, update history with partial message:
```python
def update_last_bot_message_on_cut_off(self, partial_message):
"""Update history when bot is interrupted"""
if self.conversation_history and \
self.conversation_history[-1]["role"] == "assistant":
# Update with what was actually spoken
self.conversation_history[-1]["content"] = partial_message
```
---
## 6. WebSocket Connection Drops
### Problem
WebSocket connections drop unexpectedly, interrupting conversations.
### Symptoms
- Frequent disconnections
- Connection timeouts
- "Connection closed" errors
- Unstable conversations
### Root Cause
- No heartbeat/ping mechanism
- Idle timeout
- Network issues
- Server overload
### Solution
Implement heartbeat and reconnection:
```python
@app.websocket("/conversation")
async def conversation_endpoint(websocket: WebSocket):
await websocket.accept()
# Start heartbeat
async def heartbeat():
while True:
try:
await websocket.send_json({"type": "ping"})
await asyncio.sleep(30) # Ping every 30 seconds
except:
break
heartbeat_task = asyncio.create_task(heartbeat())
try:
async for message in websocket.iter_bytes():
# Process message
pass
finally:
heartbeat_task.cancel()
```
---
## 7. High Latency / Slow Responses
### Problem
Long delays between user speech and bot response.
### Symptoms
- Noticeable lag
- Poor user experience
- Conversation feels unnatural
- Users repeat themselves
### Root Causes & Solutions
**1. Not using streaming**
```python
# ❌ Bad: Wait for entire response
response = await llm.complete(prompt)
# ✅ Good: Stream response
async for chunk in llm.complete(prompt, stream=True):
yield chunk
```
**2. Sequential processing**
```python
# ❌ Bad: Sequential
transcription = await transcriber.transcribe(audio)
response = await agent.generate(transcription)
audio = await synthesizer.synthesize(response)
# ✅ Good: Concurrent with queues
# All workers run simultaneously
```
**3. Large chunk sizes**
```python
# ❌ Bad: Large chunks (high latency)
chunk_size = 8192 # 0.25 seconds
# ✅ Good: Small chunks (low latency)
chunk_size = 1024 # 0.032 seconds
```
---
## 8. Audio Quality Issues
### Problem
Poor audio quality, distortion, or artifacts.
### Symptoms
- Robotic voice
- Crackling or popping
- Distorted audio
- Inconsistent volume
### Root Causes & Solutions
**1. Wrong audio format**
```python
# ✅ Use LINEAR16 PCM at 16kHz
audio_encoding = AudioEncoding.LINEAR16
sample_rate = 16000
```
**2. Incorrect format conversion**
```python
# ✅ Proper MP3 to PCM conversion
from pydub import AudioSegment
import io
def mp3_to_pcm(mp3_bytes):
audio = AudioSegment.from_mp3(io.BytesIO(mp3_bytes))
audio = audio.set_frame_rate(16000)
audio = audio.set_channels(1)
audio = audio.set_sample_width(2) # 16-bit
return audio.raw_data
```
**3. Buffer underruns**
```python
# ✅ Ensure consistent chunk timing
await asyncio.sleep(max(seconds_per_chunk - processing_time, 0))
```
---
## Summary
| Problem | Root Cause | Solution |
|---------|-----------|----------|
| Audio jumping | Multiple TTS calls | Buffer entire response |
| Echo/feedback | Transcriber active during bot speech | Mute transcriber |
| Interrupts not working | All chunks sent immediately | Rate-limit chunks |
| Memory leaks | Unclosed streams | Proper cleanup |
| Lost context | History not maintained | Update conversation history |
| Connection drops | No heartbeat | Implement ping/pong |
| High latency | Sequential processing | Use streaming + queues |
| Poor audio quality | Wrong format/conversion | Use LINEAR16 PCM 16kHz |
---
## Best Practices
1. **Always buffer LLM responses** before sending to synthesizer
2. **Always mute transcriber** when bot is speaking
3. **Always rate-limit audio chunks** to enable interrupts
4. **Always cleanup resources** in finally blocks
5. **Always maintain conversation history** for context
6. **Always use streaming** for low latency
7. **Always use LINEAR16 PCM** at 16kHz for audio
8. **Always implement error handling** in worker loops

View File

@@ -0,0 +1,515 @@
# Provider Comparison Guide
This guide compares different providers for transcription, LLM, and TTS services to help you choose the best option for your voice AI engine.
## Transcription Providers
### Deepgram
**Strengths:**
- ✅ Fastest transcription speed (< 300ms latency)
- ✅ Excellent streaming support
- ✅ High accuracy (95%+ on clear audio)
- ✅ Good pricing ($0.0043/minute)
- ✅ Nova-2 model optimized for real-time
- ✅ Excellent documentation
**Weaknesses:**
- ❌ Less accurate with heavy accents
- ❌ Smaller company (potential reliability concerns)
**Best For:**
- Real-time voice conversations
- Low-latency applications
- English-language applications
- Startups and small businesses
**Configuration:**
```python
{
"transcriberProvider": "deepgram",
"deepgramApiKey": "your-api-key",
"deepgramModel": "nova-2",
"language": "en-US"
}
```
---
### AssemblyAI
**Strengths:**
- ✅ Very high accuracy (96%+ on clear audio)
- ✅ Excellent with accents and dialects
- ✅ Good speaker diarization
- ✅ Competitive pricing ($0.00025/second)
- ✅ Strong customer support
**Weaknesses:**
- ❌ Slightly higher latency than Deepgram
- ❌ Streaming support is newer
**Best For:**
- Applications requiring highest accuracy
- Multi-speaker scenarios
- Diverse user base with accents
- Enterprise applications
**Configuration:**
```python
{
"transcriberProvider": "assemblyai",
"assemblyaiApiKey": "your-api-key",
"language": "en"
}
```
---
### Azure Speech
**Strengths:**
- ✅ Enterprise-grade reliability
- ✅ Excellent multi-language support (100+ languages)
- ✅ Strong security and compliance
- ✅ Integration with Azure ecosystem
- ✅ Custom model training available
**Weaknesses:**
- ❌ Higher cost ($1/hour)
- ❌ More complex setup
- ❌ Slower than specialized providers
**Best For:**
- Enterprise applications
- Multi-language requirements
- Azure-based infrastructure
- Compliance-sensitive applications
**Configuration:**
```python
{
"transcriberProvider": "azure",
"azureSpeechKey": "your-key",
"azureSpeechRegion": "eastus",
"language": "en-US"
}
```
---
### Google Cloud Speech
**Strengths:**
- ✅ Excellent multi-language support (125+ languages)
- ✅ Good accuracy
- ✅ Integration with Google Cloud
- ✅ Automatic punctuation
- ✅ Speaker diarization
**Weaknesses:**
- ❌ Higher latency for streaming
- ❌ Complex pricing model
- ❌ Requires Google Cloud account
**Best For:**
- Multi-language applications
- Google Cloud infrastructure
- Applications needing speaker diarization
**Configuration:**
```python
{
"transcriberProvider": "google",
"googleCredentials": "path/to/credentials.json",
"language": "en-US"
}
```
---
## LLM Providers
### OpenAI (GPT-4, GPT-3.5)
**Strengths:**
- ✅ Highest quality responses
- ✅ Excellent instruction following
- ✅ Fast streaming
- ✅ Large context window (128k for GPT-4)
- ✅ Best-in-class reasoning
**Weaknesses:**
- ❌ Higher cost ($0.01-0.03/1k tokens)
- ❌ Rate limits can be restrictive
- ❌ No free tier
**Best For:**
- High-quality conversational AI
- Complex reasoning tasks
- Production applications
- Enterprise use cases
**Configuration:**
```python
{
"llmProvider": "openai",
"openaiApiKey": "your-api-key",
"openaiModel": "gpt-4-turbo",
"prompt": "You are a helpful AI assistant."
}
```
**Pricing:**
- GPT-4 Turbo: $0.01/1k input tokens, $0.03/1k output tokens
- GPT-3.5 Turbo: $0.0005/1k input tokens, $0.0015/1k output tokens
---
### Google Gemini
**Strengths:**
- ✅ Excellent cost-effectiveness (free tier available)
- ✅ Multimodal capabilities
- ✅ Good streaming support
- ✅ Large context window (1M tokens for Pro)
- ✅ Fast response times
**Weaknesses:**
- ❌ Slightly lower quality than GPT-4
- ❌ Less predictable behavior
- ❌ Newer, less battle-tested
**Best For:**
- Cost-sensitive applications
- Multimodal applications
- Startups and prototypes
- High-volume applications
**Configuration:**
```python
{
"llmProvider": "gemini",
"geminiApiKey": "your-api-key",
"geminiModel": "gemini-pro",
"prompt": "You are a helpful AI assistant."
}
```
**Pricing:**
- Gemini Pro: Free up to 60 requests/minute
- Gemini Pro (paid): $0.00025/1k input tokens, $0.0005/1k output tokens
---
### Anthropic Claude
**Strengths:**
- ✅ Excellent safety and alignment
- ✅ Very long context window (200k tokens)
- ✅ High-quality responses
- ✅ Good at following complex instructions
- ✅ Strong reasoning capabilities
**Weaknesses:**
- ❌ Higher cost than Gemini
- ❌ Slower streaming than OpenAI
- ❌ More conservative responses
**Best For:**
- Safety-critical applications
- Long-context applications
- Nuanced conversations
- Enterprise applications
**Configuration:**
```python
{
"llmProvider": "claude",
"claudeApiKey": "your-api-key",
"claudeModel": "claude-3-opus",
"prompt": "You are a helpful AI assistant."
}
```
**Pricing:**
- Claude 3 Opus: $0.015/1k input tokens, $0.075/1k output tokens
- Claude 3 Sonnet: $0.003/1k input tokens, $0.015/1k output tokens
---
## TTS Providers
### ElevenLabs
**Strengths:**
- ✅ Most natural-sounding voices
- ✅ Excellent emotional range
- ✅ Voice cloning capabilities
- ✅ Good streaming support
- ✅ Multiple languages
**Weaknesses:**
- ❌ Higher cost ($0.30/1k characters)
- ❌ Rate limits on lower tiers
- ❌ Occasional pronunciation errors
**Best For:**
- Premium voice experiences
- Customer-facing applications
- Voice cloning needs
- High-quality audio requirements
**Configuration:**
```python
{
"voiceProvider": "elevenlabs",
"elevenlabsApiKey": "your-api-key",
"elevenlabsVoiceId": "voice-id",
"elevenlabsModel": "eleven_monolingual_v1"
}
```
**Pricing:**
- Free: 10k characters/month
- Starter: $5/month, 30k characters
- Creator: $22/month, 100k characters
---
### Azure TTS
**Strengths:**
- ✅ Enterprise-grade reliability
- ✅ Many languages (100+)
- ✅ Neural voices available
- ✅ SSML support for fine control
- ✅ Good pricing ($4/1M characters)
**Weaknesses:**
- ❌ Less natural than ElevenLabs
- ❌ More complex setup
- ❌ Requires Azure account
**Best For:**
- Enterprise applications
- Multi-language requirements
- Azure-based infrastructure
- Cost-sensitive high-volume applications
**Configuration:**
```python
{
"voiceProvider": "azure",
"azureSpeechKey": "your-key",
"azureSpeechRegion": "eastus",
"azureVoiceName": "en-US-JennyNeural"
}
```
**Pricing:**
- Neural voices: $16/1M characters
- Standard voices: $4/1M characters
---
### Google Cloud TTS
**Strengths:**
- ✅ Good quality neural voices
- ✅ Many languages (40+)
- ✅ WaveNet voices available
- ✅ Competitive pricing ($4/1M characters)
- ✅ SSML support
**Weaknesses:**
- ❌ Less natural than ElevenLabs
- ❌ Requires Google Cloud account
- ❌ Complex setup
**Best For:**
- Multi-language applications
- Google Cloud infrastructure
- Cost-effective neural voices
**Configuration:**
```python
{
"voiceProvider": "google",
"googleCredentials": "path/to/credentials.json",
"googleVoiceName": "en-US-Neural2-F"
}
```
**Pricing:**
- WaveNet voices: $16/1M characters
- Neural2 voices: $16/1M characters
- Standard voices: $4/1M characters
---
### Amazon Polly
**Strengths:**
- ✅ AWS integration
- ✅ Good pricing ($4/1M characters)
- ✅ Neural voices available
- ✅ SSML support
- ✅ Reliable service
**Weaknesses:**
- ❌ Less natural than ElevenLabs
- ❌ Fewer voice options
- ❌ Requires AWS account
**Best For:**
- AWS-based infrastructure
- Cost-effective neural voices
- Enterprise applications
**Configuration:**
```python
{
"voiceProvider": "polly",
"awsAccessKey": "your-access-key",
"awsSecretKey": "your-secret-key",
"awsRegion": "us-east-1",
"pollyVoiceId": "Joanna"
}
```
**Pricing:**
- Neural voices: $16/1M characters
- Standard voices: $4/1M characters
---
### Play.ht
**Strengths:**
- ✅ Voice cloning capabilities
- ✅ Natural-sounding voices
- ✅ Good streaming support
- ✅ Easy to use API
- ✅ Multiple languages
**Weaknesses:**
- ❌ Higher cost than cloud providers
- ❌ Smaller company
- ❌ Less documentation
**Best For:**
- Voice cloning applications
- Premium voice experiences
- Startups and small businesses
**Configuration:**
```python
{
"voiceProvider": "playht",
"playhtApiKey": "your-api-key",
"playhtUserId": "your-user-id",
"playhtVoiceId": "voice-id"
}
```
**Pricing:**
- Free: 2.5k characters
- Creator: $31/month, 50k characters
- Pro: $79/month, 150k characters
---
## Recommended Combinations
### Budget-Conscious Startup
```python
{
"transcriberProvider": "deepgram", # Fast and affordable
"llmProvider": "gemini", # Free tier available
"voiceProvider": "google" # Cost-effective neural voices
}
```
**Estimated cost:** ~$0.01 per minute of conversation
---
### Premium Experience
```python
{
"transcriberProvider": "assemblyai", # Highest accuracy
"llmProvider": "openai", # Best quality responses
"voiceProvider": "elevenlabs" # Most natural voices
}
```
**Estimated cost:** ~$0.05 per minute of conversation
---
### Enterprise Application
```python
{
"transcriberProvider": "azure", # Enterprise reliability
"llmProvider": "openai", # Best quality
"voiceProvider": "azure" # Enterprise reliability
}
```
**Estimated cost:** ~$0.03 per minute of conversation
---
### Multi-Language Application
```python
{
"transcriberProvider": "google", # 125+ languages
"llmProvider": "gemini", # Good multi-language support
"voiceProvider": "google" # 40+ languages
}
```
**Estimated cost:** ~$0.02 per minute of conversation
---
## Decision Matrix
| Priority | Transcriber | LLM | TTS |
|----------|-------------|-----|-----|
| **Lowest Cost** | Deepgram | Gemini | Google |
| **Highest Quality** | AssemblyAI | OpenAI | ElevenLabs |
| **Fastest Speed** | Deepgram | OpenAI | ElevenLabs |
| **Enterprise** | Azure | OpenAI | Azure |
| **Multi-Language** | Google | Gemini | Google |
| **Voice Cloning** | N/A | N/A | ElevenLabs/Play.ht |
---
## Testing Recommendations
Before committing to providers, test with your specific use case:
1. **Create test conversations** with representative audio
2. **Measure latency** end-to-end
3. **Evaluate quality** with real users
4. **Calculate costs** based on expected volume
5. **Test edge cases** (accents, background noise, interrupts)
---
## Switching Providers
The multi-provider factory pattern makes switching easy:
```python
# Just change the configuration
config = {
"transcriberProvider": "deepgram", # Change to "assemblyai"
"llmProvider": "gemini", # Change to "openai"
"voiceProvider": "google" # Change to "elevenlabs"
}
# No code changes needed!
factory = VoiceComponentFactory()
transcriber = factory.create_transcriber(config)
agent = factory.create_agent(config)
synthesizer = factory.create_synthesizer(config)
```

View File

@@ -0,0 +1,193 @@
"""
Template: Base Worker Implementation
Use this template as a starting point for creating new workers
in your voice AI pipeline.
"""
import asyncio
from typing import Any
import logging
logger = logging.getLogger(__name__)
class BaseWorker:
"""
Base class for all workers in the voice AI pipeline
Workers follow the producer-consumer pattern:
- Consume items from input_queue
- Process items
- Produce results to output_queue
All workers run concurrently via asyncio.
"""
def __init__(self, input_queue: asyncio.Queue, output_queue: asyncio.Queue):
"""
Initialize the worker
Args:
input_queue: Queue to consume items from
output_queue: Queue to produce results to
"""
self.input_queue = input_queue
self.output_queue = output_queue
self.active = False
self._task = None
def start(self):
"""Start the worker's processing loop"""
self.active = True
self._task = asyncio.create_task(self._run_loop())
logger.info(f"✅ [{self.__class__.__name__}] Started")
async def _run_loop(self):
"""
Main processing loop - runs forever until terminated
This loop:
1. Waits for items from input_queue
2. Processes each item
3. Handles errors gracefully
"""
while self.active:
try:
# Block until item arrives
item = await self.input_queue.get()
# Process the item
await self.process(item)
except asyncio.CancelledError:
# Task was cancelled (normal during shutdown)
logger.info(f"🛑 [{self.__class__.__name__}] Task cancelled")
break
except Exception as e:
# Log error but don't crash the worker
logger.error(
f"❌ [{self.__class__.__name__}] Error processing item: {e}",
exc_info=True
)
# Continue processing next item
async def process(self, item: Any):
"""
Process a single item
Override this method in your worker implementation.
Args:
item: The item to process
"""
raise NotImplementedError(
f"{self.__class__.__name__} must implement process()"
)
def terminate(self):
"""
Stop the worker gracefully
This sets active=False and cancels the processing task.
"""
self.active = False
if self._task and not self._task.done():
self._task.cancel()
logger.info(f"🛑 [{self.__class__.__name__}] Terminated")
async def wait_for_completion(self):
"""Wait for the worker task to complete"""
if self._task:
try:
await self._task
except asyncio.CancelledError:
pass
# ============================================================================
# Example: Custom Worker Implementation
# ============================================================================
class ExampleWorker(BaseWorker):
"""
Example worker that demonstrates how to extend BaseWorker
This worker receives strings, converts them to uppercase,
and sends them to the output queue.
"""
def __init__(self, input_queue: asyncio.Queue, output_queue: asyncio.Queue):
super().__init__(input_queue, output_queue)
# Add any custom initialization here
self.processed_count = 0
async def process(self, item: str):
"""
Process a single item
Args:
item: String to convert to uppercase
"""
# Simulate some processing time
await asyncio.sleep(0.1)
# Process the item
result = item.upper()
# Send to output queue
self.output_queue.put_nowait(result)
# Update counter
self.processed_count += 1
logger.info(
f"✅ [{self.__class__.__name__}] "
f"Processed '{item}' -> '{result}' "
f"(total: {self.processed_count})"
)
# ============================================================================
# Example Usage
# ============================================================================
async def example_usage():
"""Example of how to use the worker"""
# Create queues
input_queue = asyncio.Queue()
output_queue = asyncio.Queue()
# Create worker
worker = ExampleWorker(input_queue, output_queue)
# Start worker
worker.start()
# Send items to process
items = ["hello", "world", "voice", "ai"]
for item in items:
input_queue.put_nowait(item)
# Wait for processing
await asyncio.sleep(0.5)
# Get results
results = []
while not output_queue.empty():
results.append(await output_queue.get())
print(f"\n✅ Results: {results}")
# Terminate worker
worker.terminate()
await worker.wait_for_completion()
if __name__ == "__main__":
logging.basicConfig(level=logging.INFO)
asyncio.run(example_usage())

View File

@@ -0,0 +1,289 @@
"""
Template: Multi-Provider Factory
Use this template to create a factory that supports multiple providers
for transcription, LLM, and TTS services.
"""
from typing import Dict, Any
from abc import ABC, abstractmethod
import logging
logger = logging.getLogger(__name__)
# ============================================================================
# Provider Interfaces
# ============================================================================
class TranscriberProvider(ABC):
"""Abstract base class for transcriber providers"""
@abstractmethod
async def transcribe_stream(self, audio_stream):
"""Transcribe streaming audio"""
pass
class LLMProvider(ABC):
"""Abstract base class for LLM providers"""
@abstractmethod
async def generate_response(self, messages, stream=True):
"""Generate response from messages"""
pass
class TTSProvider(ABC):
"""Abstract base class for TTS providers"""
@abstractmethod
async def synthesize_speech(self, text):
"""Synthesize speech from text"""
pass
# ============================================================================
# Multi-Provider Factory
# ============================================================================
class VoiceComponentFactory:
"""
Factory for creating voice AI components with multiple provider support
Supports:
- Multiple transcription providers (Deepgram, AssemblyAI, Azure, Google)
- Multiple LLM providers (OpenAI, Gemini, Claude)
- Multiple TTS providers (ElevenLabs, Azure, Google, Polly, Play.ht)
"""
def __init__(self):
self.transcriber_providers = {
"deepgram": self._create_deepgram_transcriber,
"assemblyai": self._create_assemblyai_transcriber,
"azure": self._create_azure_transcriber,
"google": self._create_google_transcriber,
}
self.llm_providers = {
"openai": self._create_openai_agent,
"gemini": self._create_gemini_agent,
"claude": self._create_claude_agent,
}
self.tts_providers = {
"elevenlabs": self._create_elevenlabs_synthesizer,
"azure": self._create_azure_synthesizer,
"google": self._create_google_synthesizer,
"polly": self._create_polly_synthesizer,
"playht": self._create_playht_synthesizer,
}
def create_transcriber(self, config: Dict[str, Any]):
"""
Create transcriber based on configuration
Args:
config: Configuration dict with 'transcriberProvider' key
Returns:
Transcriber instance
Raises:
ValueError: If provider is not supported
"""
provider = config.get("transcriberProvider", "deepgram").lower()
if provider not in self.transcriber_providers:
raise ValueError(
f"Unknown transcriber provider: {provider}. "
f"Supported: {list(self.transcriber_providers.keys())}"
)
logger.info(f"🎤 Creating transcriber: {provider}")
return self.transcriber_providers[provider](config)
def create_agent(self, config: Dict[str, Any]):
"""
Create LLM agent based on configuration
Args:
config: Configuration dict with 'llmProvider' key
Returns:
Agent instance
Raises:
ValueError: If provider is not supported
"""
provider = config.get("llmProvider", "openai").lower()
if provider not in self.llm_providers:
raise ValueError(
f"Unknown LLM provider: {provider}. "
f"Supported: {list(self.llm_providers.keys())}"
)
logger.info(f"🤖 Creating agent: {provider}")
return self.llm_providers[provider](config)
def create_synthesizer(self, config: Dict[str, Any]):
"""
Create TTS synthesizer based on configuration
Args:
config: Configuration dict with 'voiceProvider' key
Returns:
Synthesizer instance
Raises:
ValueError: If provider is not supported
"""
provider = config.get("voiceProvider", "elevenlabs").lower()
if provider not in self.tts_providers:
raise ValueError(
f"Unknown voice provider: {provider}. "
f"Supported: {list(self.tts_providers.keys())}"
)
logger.info(f"🔊 Creating synthesizer: {provider}")
return self.tts_providers[provider](config)
# ========================================================================
# Transcriber Implementations
# ========================================================================
def _create_deepgram_transcriber(self, config: Dict[str, Any]):
"""Create Deepgram transcriber"""
# TODO: Implement Deepgram transcriber
# from .transcribers.deepgram import DeepgramTranscriber
# return DeepgramTranscriber(
# api_key=config.get("deepgramApiKey"),
# model=config.get("deepgramModel", "nova-2"),
# language=config.get("language", "en-US")
# )
raise NotImplementedError("Deepgram transcriber not implemented")
def _create_assemblyai_transcriber(self, config: Dict[str, Any]):
"""Create AssemblyAI transcriber"""
# TODO: Implement AssemblyAI transcriber
raise NotImplementedError("AssemblyAI transcriber not implemented")
def _create_azure_transcriber(self, config: Dict[str, Any]):
"""Create Azure Speech transcriber"""
# TODO: Implement Azure transcriber
raise NotImplementedError("Azure transcriber not implemented")
def _create_google_transcriber(self, config: Dict[str, Any]):
"""Create Google Cloud Speech transcriber"""
# TODO: Implement Google transcriber
raise NotImplementedError("Google transcriber not implemented")
# ========================================================================
# LLM Agent Implementations
# ========================================================================
def _create_openai_agent(self, config: Dict[str, Any]):
"""Create OpenAI agent"""
# TODO: Implement OpenAI agent
# from .agents.openai import OpenAIAgent
# return OpenAIAgent(
# api_key=config.get("openaiApiKey"),
# model=config.get("openaiModel", "gpt-4"),
# system_prompt=config.get("prompt", "You are a helpful assistant.")
# )
raise NotImplementedError("OpenAI agent not implemented")
def _create_gemini_agent(self, config: Dict[str, Any]):
"""Create Google Gemini agent"""
# TODO: Implement Gemini agent
# from .agents.gemini import GeminiAgent
# return GeminiAgent(
# api_key=config.get("geminiApiKey"),
# model=config.get("geminiModel", "gemini-pro"),
# system_prompt=config.get("prompt", "You are a helpful assistant.")
# )
raise NotImplementedError("Gemini agent not implemented")
def _create_claude_agent(self, config: Dict[str, Any]):
"""Create Anthropic Claude agent"""
# TODO: Implement Claude agent
raise NotImplementedError("Claude agent not implemented")
# ========================================================================
# TTS Synthesizer Implementations
# ========================================================================
def _create_elevenlabs_synthesizer(self, config: Dict[str, Any]):
"""Create ElevenLabs synthesizer"""
# TODO: Implement ElevenLabs synthesizer
# from .synthesizers.elevenlabs import ElevenLabsSynthesizer
# return ElevenLabsSynthesizer(
# api_key=config.get("elevenlabsApiKey"),
# voice_id=config.get("elevenlabsVoiceId"),
# model_id=config.get("elevenlabsModel", "eleven_monolingual_v1")
# )
raise NotImplementedError("ElevenLabs synthesizer not implemented")
def _create_azure_synthesizer(self, config: Dict[str, Any]):
"""Create Azure TTS synthesizer"""
# TODO: Implement Azure synthesizer
raise NotImplementedError("Azure synthesizer not implemented")
def _create_google_synthesizer(self, config: Dict[str, Any]):
"""Create Google Cloud TTS synthesizer"""
# TODO: Implement Google synthesizer
raise NotImplementedError("Google synthesizer not implemented")
def _create_polly_synthesizer(self, config: Dict[str, Any]):
"""Create Amazon Polly synthesizer"""
# TODO: Implement Polly synthesizer
raise NotImplementedError("Polly synthesizer not implemented")
def _create_playht_synthesizer(self, config: Dict[str, Any]):
"""Create Play.ht synthesizer"""
# TODO: Implement Play.ht synthesizer
raise NotImplementedError("Play.ht synthesizer not implemented")
# ============================================================================
# Example Usage
# ============================================================================
def example_usage():
"""Example of how to use the factory"""
# Configuration
config = {
"transcriberProvider": "deepgram",
"deepgramApiKey": "your-api-key",
"llmProvider": "gemini",
"geminiApiKey": "your-api-key",
"voiceProvider": "elevenlabs",
"elevenlabsApiKey": "your-api-key",
"elevenlabsVoiceId": "your-voice-id",
"prompt": "You are a helpful AI assistant."
}
# Create factory
factory = VoiceComponentFactory()
try:
# Create components
transcriber = factory.create_transcriber(config)
agent = factory.create_agent(config)
synthesizer = factory.create_synthesizer(config)
print("✅ All components created successfully!")
except ValueError as e:
print(f"❌ Configuration error: {e}")
except NotImplementedError as e:
print(f"⚠️ Not implemented: {e}")
if __name__ == "__main__":
logging.basicConfig(level=logging.INFO)
example_usage()

View File

@@ -647,6 +647,15 @@
"risk": "unknown",
"source": "unknown"
},
{
"id": "daily-news-report",
"path": "skills/daily-news-report",
"category": "uncategorized",
"name": "daily-news-report",
"description": "\u57fa\u4e8e\u9884\u8bbe URL \u5217\u8868\u6293\u53d6\u5185\u5bb9\uff0c\u7b5b\u9009\u9ad8\u8d28\u91cf\u6280\u672f\u4fe1\u606f\u5e76\u751f\u6210\u6bcf\u65e5 Markdown \u62a5\u544a\u3002",
"risk": "unknown",
"source": "unknown"
},
{
"id": "database-design",
"path": "skills/database-design",
@@ -1070,6 +1079,15 @@
"risk": "unknown",
"source": "vibeship-spawner-skills (Apache 2.0)"
},
{
"id": "last30days",
"path": "skills/last30days",
"category": "uncategorized",
"name": "last30days",
"description": "Research a topic from the last 30 days on Reddit + X + Web, become an expert, and write copy-paste-ready prompts for the user's target tool.",
"risk": "unknown",
"source": "unknown"
},
{
"id": "launch-strategy",
"path": "skills/launch-strategy",
@@ -2141,6 +2159,15 @@
"risk": "unknown",
"source": "vibeship-spawner-skills (Apache 2.0)"
},
{
"id": "voice-ai-engine-development",
"path": "skills/voice-ai-engine-development",
"category": "uncategorized",
"name": "voice-ai-engine-development",
"description": "Build real-time conversational AI voice engines using async worker pipelines, streaming transcription, LLM agents, and TTS synthesis with interrupt handling and multi-provider support",
"risk": "unknown",
"source": "unknown"
},
{
"id": "vr-ar",
"path": "skills/game-development/vr-ar",