Skill: matusvojtek/briefing-room (version 1.0.3)

--- name: Briefing Room description: "Daily news briefing generator — produces a conversational radio-host-style audio briefing + DOCX document covering weather, X/Twitter trends, web trends, world news, politics, tech, local news, sports, markets, and crypto. macOS only (uses Apple TTS and afplay). Use when user asks for a news briefing, morning briefing, daily update, or similar." metadata: { "openclaw": { "emoji": "📻", "requires": { "bins": ["curl"] } } } --- # Briefing Room 📻 **Your personal daily news briefing — audio + document.** On demand, research and compose a comprehensive ~10 minute news briefing in a conversational radio-host style. Output: audio file (MP3) + formatted document (DOCX). ### 💸 100% Free - **No subscriptions, API keys, or paid services** - Uses free public APIs (Open-Meteo weather, Coinbase prices, Google Trends RSS), web search, and local TTS - TTS is fully local, no keys needed: MLX-Audio Kokoro (English) or Apple `say` (any language) - Reads/writes: `~/.briefing-room/config.json` (settings) and `~/Documents/Briefing Room/` (output) ## First-Run Setup On first use, check if `~/.briefing-room/config.json` exists. If not, run: ```bash python3 SKILL_DIR/scripts/config.py init ``` This creates default config. The user can customize: - **Location** — city, latitude, longitude, timezone (for weather) - **Language** — `en`, `sk`, `de`, etc. - **Voices** — per-language TTS engine and voice selection - **Sections** — which news sections to include - **Output folder** — where briefings are saved Show setup status: ```bash python3 SKILL_DIR/scripts/config.py status ``` ## Quick Start When user asks for a briefing (e.g. "give me a briefing", "morning update", "what's happening today"): 1. Check config exists (run setup if not) 2. Play notification sound: `afplay /System/Library/Sounds/Blow.aiff &` 3. Spawn a sub-agent with the full pipeline task **immediately** 4. Reply: "📻 Briefing Room is firing up — gathering today's news. I'll ping you when it's ready!" 5. **DO NOT BLOCK** — spawn and move on instantly **Language override:** If user says "po slovensky", "v slovenčine", "auf deutsch", "en français", etc. → pass that to the sub-agent. Otherwise use the configured default language. Any language macOS supports will work — the agent writes the script in that language and TTS auto-detects a matching voice. ### Spawn Command ``` sessions_spawn( task="<full pipeline instructions — see below>", label="briefing-room", runTimeoutSeconds=600, cleanup="delete" ) ``` The task message should include ALL the pipeline steps below so the sub-agent is fully self-contained. **Replace all `SKILL_DIR` references with the actual absolute path to this skill's directory.** **Host name:** Read `host.name` from config. If empty, use your own agent name (from your identity). Pass it to the sub-agent as the radio host name (e.g. "Good morning, I'm Jackie, and this is your Briefing Room..."). ## Configuration Config file: `~/.briefing-room/config.json` Read values: ```bash python3 SKILL_DIR/scripts/config.py get location.city python3 SKILL_DIR/scripts/config.py get language python3 SKILL_DIR/scripts/config.py get voices.en.mlx_voice ``` Set values: ```bash python3 SKILL_DIR/scripts/config.py set location.city "Vienna" python3 SKILL_DIR/scripts/config.py set location.latitude 48.21 python3 SKILL_DIR/scripts/config.py set location.longitude 16.37 python3 SKILL_DIR/scripts/config.py set language "de" ``` ### Key Config Options | Key | Default | Description | |-----|---------|-------------| | `location.city` | Bratislava | City name for weather + local news | | `location.latitude` | 48.15 | Weather API latitude | | `location.longitude` | 17.11 | Weather API longitude | | `location.timezone` | Europe/Bratislava | Timezone for weather API | | `language` | en | Default briefing language | | `output.folder` | ~/Documents/Briefing Room | Output directory | | `audio.enabled` | true | Generate audio | | `audio.format` | mp3 | Audio format (mp3, wav, aiff) | | `audio.tts_engine` | auto | TTS engine (auto, mlx, kokoro, builtin) | | `sections` | all 11 (see below) | Which sections to include | | `host.name` | (empty = agent name) | Radio host name for the briefing | | `trends.regions` | united-states,united-kingdom, | X/Twitter trend regions (comma-separated, trailing comma = worldwide) | | `webtrends.regions` | US,GB, | Google Trends regions (ISO codes, trailing comma = worldwide) | ### Voice Configuration Per Language Each language can have its own TTS engine and voice: ```json { "voices": { "en": { "engine": "mlx", "mlx_voice": "af_heart", "mlx_voice_blend": {"af_heart": 0.6, "af_sky": 0.4}, "builtin_voice": "Samantha", "speed": 1.05 }, "sk": { "engine": "builtin", "builtin_voice": "Laura (Enhanced)", "builtin_rate": 220 }, "de": { "engine": "builtin", "builtin_voice": "Petra (Premium)", "builtin_rate": 200 } } } ``` **Engine priority (when `auto`):** - English: mlx → kokoro → builtin - Other languages: builtin (Apple TTS has good multilingual voices) Users can add any language by adding a voices entry + a matching `builtin_voice` from `say -v '?'`. ## Output Structure ``` ~/Documents/Briefing Room/YYYY-MM-DD/ ├── briefing-YYYY-MM-DD-HHMM.docx # Formatted document └── briefing-YYYY-MM-DD-HHMM.mp3 # Audio briefing (~10 min) ``` **Do NOT save the .md working file in the output folder.** Use `/tmp/` for working files, delete after. ## Full Pipeline ### Step 0: Setup ```bash # Read config CITY=$(python3 SKILL_DIR/scripts/config.py get location.city) LAT=$(python3 SKILL_DIR/scripts/config.py get location.latitude) LON=$(python3 SKILL_DIR/scripts/config.py get location.longitude) TZ=$(python3 SKILL_DIR/scripts/config.py get location.timezone) LANG=$(python3 SKILL_DIR/scripts/config.py get language) OUTPUT_FOLDER=$(python3 SKILL_DIR/scripts/config.py get output.folder) DATE=$(date +%Y-%m-%d) TIMESTAMP=$(date +%Y-%m-%d-%H%M) OUTPUT_DIR="$OUTPUT_FOLDER/$DATE" mkdir -p "$OUTPUT_DIR" ``` ### Step 1: Gather Data — Weather Use the configured location coordinates: ```bash # Current weather TZ_ENC="${TZ/\//%2F}" BASE="https://api.open-meteo.com/v1/forecast" CURRENT="temperature_2m,relative_humidity_2m" CURRENT="$CURRENT,apparent_temperature,precipitation" CURRENT="$CURRENT,weather_code,wind_speed_10m" curl -s "$BASE?latitude=$LAT&longitude=$LON\ &current=$CURRENT&timezone=$TZ_ENC" # 7-day forecast DAILY="temperature_2m_max,temperature_2m_min" DAILY="$DAILY,precipitation_sum,weather_code" curl -s "$BASE?latitude=$LAT&longitude=$LON\ &daily=$DAILY&timezone=$TZ_ENC" ``` Or use the helper: `bash SKILL_DIR/scripts/briefing.sh weather` Map `weather_code` to descriptions: - 0: Clear sky ☀️ - 1-3: Partly cloudy ⛅ - 45-48: Fog 🌫️ - 51-55: Drizzle 🌦️ - 61-65: Rain 🌧️ - 71-75: Snow ❄️ - 80-82: Rain showers 🌦️ - 95-99: Thunderstorm ⛈️ ### Step 2: Gather Data — News (Web Search) Use `web_search` tool for each section. Add current date to queries for freshness. Use the configured `$CITY` for local news. **X/Twitter Trends (from getdaytrends.com — real-time, no API key):** ```bash bash SKILL_DIR/scripts/briefing.sh trends ``` This fetches top 25 trends from US, UK, and Worldwide. Use the output to: - Identify the most interesting/newsworthy trends (skip generic ones like "Good Tuesday", "Taco Tuesday") - Filter out non-Latin script trends unless they're globally significant - Pick ~5-10 trends that overlap across regions or seem newsworthy - Use `web_search` to get context on the top trends you selected **Web Trends (from Google Trends RSS — what people are searching):** ```bash bash SKILL_DIR/scripts/briefing.sh webtrends ``` This fetches trending Google searches from US, UK, and Worldwide with: - Search term and approximate traffic volume - Top news headline explaining why it's trending Use this data for the Web Trends section. The headlines already provide context — no extra searching needed for most items. **World News:** ``` web_search("top world news today {date}", count=8) web_search("breaking news today", count=5) ``` **Politics:** ``` web_search("US politics news today {date}", count=5) web_search("EU politics news today {date}", count=5) web_search("geopolitics news today", count=5) ``` **⚠️ Source diversity:** All sources have bias. For balanced reporting: - Search the same story with different framing - Present what happened factually, note what each side says - Don't adopt any outlet's framing as truth - Stick to verifiable facts: numbers, dates, quotes, actions **Tech & AI:** ``` web_search("tech news today {date}", count=5) web_search("AI artificial intelligence news today {date}", count=5) ``` **Local news** (based on configured city): ``` web_search("$CITY news today {date}", count=5) ``` Also search in the configured language if not English: ``` web_search("$CITY [news today] in $LANG {date}", count=5) ``` Examples: - Slovak: `"Bratislava správy dnes"` - German: `"Wien Nachrichten heute"` - Czech: `"Praha zprávy dnes"` **Sports:** ``` web_search("sports news today {date}", count=5) web_search("football soccer results today", count=5) ``` ### Step 3: Gather Data — Markets & Crypto (APIs + Search) ```bash # Or use helper: bash SKILL_DIR/scripts/briefing.sh crypto ``` ```bash curl -s "https://api.coinbase.com/v2/prices/BTC-USD/spot" curl -s "https://api.coinbase.com/v2/prices/ETH-USD/spot" curl -s "https://api.coinbase.com/v2/prices/SOL-USD/spot" curl -s "https://api.coinbase.com/v2/prices/XRP-USD/spot" ``` ``` web_search("S&P 500 Dow Jones Nasdaq today {date}", count=5) web_search("stock market today movers {date}", count=5) web_search("gold price silver price today", count=3) web_search("crypto market today {date}", count=5) ``` ### Step 4: Compose the Briefing Script Write as a **conversational radio-host monologue**. **Style guidelines:** - Write like a smart, engaging radio host — NOT a list of headlines - **Use the host name** — introduce yourself: "Good morning, I'm [host name], and this is your Briefing Room for [date]..." - Sprinkle the name naturally throughout (sign-off, transitions) — don't overdo it - Do NOT start markdown with a `# Title` header — pandoc adds title from metadata - Connect stories with transitions - Add context: "here's why this matters" - **Stay neutral and balanced** — report facts, present sides, let listener decide - Target ~2,500-3,500 words for ~10 minutes - No emojis in the script (break TTS) - Write out numbers/abbreviations for TTS: - "$96,500" → "ninety-six thousand five hundred dollars" - "S&P 500" → "S and P 500" - "BTC" → "Bitcoin" - "°C" → "degrees celsius" **If language is not English**, write the entire script in that language. **Section order:** 1. **Opening** — Date, quick teaser of top stories 2. **Weather** — Current + week outlook for configured city 3. **Trending on X** — What's hot on X/Twitter 4. **Web Trends** — What people are searching (Google Trends) 5. **World** — Top 3-5 global stories 6. **Politics** — US, EU, geopolitics 7. **Tech & AI** — Launches, breakthroughs 8. **Local** — News for configured city/country 9. **Sports** — Headlines, results 10. **Markets** — S&P 500, Dow, Nasdaq, movers 11. **Crypto & Commodities** — BTC, ETH, alts, gold, silver 12. **This Day in History** — 1-2 interesting events that happened on this date 13. **Closing** — Wrap-up, sign-off **This Day in History:** No research needed — use your own knowledge. Pick 1-2 interesting, surprising, or fun events that happened on today's date. Mix it up: science, culture, politics, weird stuff. Keep it conversational: "And before I let you go — did you know that on this day in 1996..." Only include sections from the configured `sections` list. Skip sections the user has removed. Save as `/tmp/briefing_draft_$TIMESTAMP.md` (working file). **For the markdown**, include: - Section headers with emojis: `## 🌤️ Weather`, `## 🌍 World`, `## 📜 This Day in History`, etc. - Source links after key facts - Key data in bold ### Step 5: Generate DOCX ```bash pandoc "/tmp/briefing_draft_$TIMESTAMP.md" \ -o "$OUTPUT_DIR/briefing-$TIMESTAMP.docx" \ --metadata title="Briefing Room - $DATE" ``` If pandoc is not available, skip DOCX and note it. ### Step 6: Generate Audio Read the config to determine TTS engine and voice for the current language. **MLX-Audio (English, or if configured for language):** ```bash python3 SKILL_DIR/scripts/config.py get voices.$LANG.engine # → if "mlx": ``` ```python import os, re, glob, json, subprocess from datetime import datetime timestamp = datetime.now().strftime("%Y-%m-%d-%H%M") # must match TIMESTAMP from Step 0 # Read config config_path = os.path.expanduser("~/.briefing-room/config.json") with open(config_path) as f: config = json.load(f) lang = config.get("language", "en") voices = config.get("voices", {}) voice_cfg = voices.get(lang, voices.get("en", {})) # Read and strip markdown from draft with open(f"/tmp/briefing_draft_{timestamp}.md", "r") as f: text = f.read() text = re.sub(r'#+ ', '', text) text = re.sub(r'\*\*([^*]+)\*\*', r'\1', text) text = re.sub(r'\*([^*]+)\*', r'\1', text) text = re.sub(r'\[([^\]]+)\]\([^)]+\)', r'\1', text) text = re.sub(r'---+', '', text) text = re.sub(r'\n{3,}', '\n\n', text) # Resolve voice blend = voice_cfg.get("mlx_voice_blend") voice = voice_cfg.get("mlx_voice", "af_heart") if blend: model = config.get("mlx_audio", {}).get("model", "mlx-community/Kokoro-82M-bf16") model_slug = model.replace("/", "--") cache_dir = os.path.expanduser(f"~/.cache/huggingface/hub/models--{model_slug}") parts = [] for v, w in sorted(blend.items(), key=lambda x: -x[1]): parts.append(f"{v}_{int(w * 100)}") blend_name = "_".join(parts) + ".safetensors" matches = glob.glob(os.path.join(cache_dir, "snapshots/*/voices", blend_name)) if matches: voice = matches[0] speed = voice_cfg.get("speed", 1.05) lang_code = config.get("mlx_audio", {}).get("lang_code", "a") # Find MLX-Audio mlx_path = config.get("mlx_audio", {}).get("path", "") if not mlx_path: for p in ["~/.openclaw/tools/mlx-audio", "~/.local/share/mlx-audio"]: ep = os.path.expanduser(p) if os.path.exists(os.path.join(ep, ".venv/bin/python3")): mlx_path = ep break # Generate via subprocess (uses MLX-Audio's venv) python_bin = os.path.join(mlx_path, ".venv/bin/python3") # ... generate_audio call with resolved voice, speed, lang_code ``` **Built-in Apple TTS (any language):** If there's no voice configured for the language, auto-detect one: ```bash # Try to get configured voice, fall back to auto-detect VOICE=$(python3 SKILL_DIR/scripts/config.py get voices.$LANG.builtin_voice) if [ "$VOICE" = "None" ] || [ -z "$VOICE" ]; then # Auto-detect: match locale (e.g. sk_SK, de_DE, fr_FR) # Prefer Enhanced/Premium voices, fall back to any VOICE=$(say -v '?' | grep "${LANG}_" \ | grep -i "Enhanced\|Premium" | head -1 \ | sed 's/ *[a-z][a-z]_[A-Z][A-Z].*//' | xargs) [ -z "$VOICE" ] && VOICE=$(say -v '?' \ | grep "${LANG}_" | head -1 \ | sed 's/ *[a-z][a-z]_[A-Z][A-Z].*//' | xargs) fi RATE=$(python3 SKILL_DIR/scripts/config.py get voices.$LANG.builtin_rate) # Strip markdown for TTS DRAFT="/tmp/briefing_draft_$TIMESTAMP.md" TTS_TXT="/tmp/briefing_tts_$TIMESTAMP.txt" sed -E 's/#+//g; s/\*+//g; s/\[([^]]*)\]\([^)]*\)/\1/g' \ "$DRAFT" > "$TTS_TXT" say -v "$VOICE" ${RATE:+-r $RATE} \ -o "$OUTPUT_DIR/briefing-$TIMESTAMP.aiff" \ -f "$TTS_TXT" rm -f "/tmp/briefing_tts_$TIMESTAMP.txt" ``` **Kokoro PyTorch (fallback):** Similar to MLX but uses PyTorch backend. See TubeScribe skill for Kokoro usage patterns. ### Step 6b: Convert to MP3 ```bash # Find the raw audio file (MLX outputs .wav, Apple TTS outputs .aiff) RAW="" for ext in wav aiff; do if [ -f "$OUTPUT_DIR/briefing-$TIMESTAMP.$ext" ]; then RAW="$OUTPUT_DIR/briefing-$TIMESTAMP.$ext" break fi done if [ -n "$RAW" ]; then ffmpeg -y \ -i "$RAW" \ -codec:a libmp3lame -qscale:a 2 \ "$OUTPUT_DIR/briefing-$TIMESTAMP.mp3" if [ -s "$OUTPUT_DIR/briefing-$TIMESTAMP.mp3" ]; then rm "$RAW" fi fi ``` ### Step 6c: Cleanup ```bash rm -f "/tmp/briefing_draft_$TIMESTAMP.md" ``` ### Step 7: Open Output Folder ```bash open "$OUTPUT_DIR" ``` **Do NOT auto-play.** Briefings are long and need playback controls. ### Step 8: Report Report back with: - Date and language of briefing - Sections covered - Top 3-4 headlines - Audio duration - File locations ## Helper Script ```bash bash SKILL_DIR/scripts/briefing.sh setup # Check dependencies + config bash SKILL_DIR/scripts/briefing.sh weather # Fetch weather (uses config location) bash SKILL_DIR/scripts/briefing.sh trends # Fetch X/Twitter trends (US + UK + Worldwide) bash SKILL_DIR/scripts/briefing.sh webtrends # Fetch Google Trends (US + UK + Worldwide) bash SKILL_DIR/scripts/briefing.sh crypto # Fetch crypto prices bash SKILL_DIR/scripts/briefing.sh open # Open today's folder bash SKILL_DIR/scripts/briefing.sh list # List all briefings bash SKILL_DIR/scripts/briefing.sh clean # Remove briefings >30 days old bash SKILL_DIR/scripts/briefing.sh config # Show raw config JSON ``` ## Tips - Full pipeline takes 3-5 minutes (research + composition + TTS) - For shorter briefing, say "quick briefing" — cover top 3 sections only - If markets are closed (weekend/holiday), note it and skip detailed data - The agent IS the intelligence — read search results, compose the script, decide what matters - Users can add new languages by adding a `voices` entry + installing the voice via `say -v '?'` ## Dependencies **Required:** - `curl` — API calls (built into macOS) - `web_search` tool — News research (OpenClaw built-in) **Recommended:** - MLX-Audio Kokoro — fast English TTS - `pandoc` — DOCX generation: `brew install pandoc` - `ffmpeg` — MP3 conversion: `brew install ffmpeg` **Built-in (macOS):** - Apple `say` — multilingual TTS (always available as fallback) ## Error Handling | Issue | Action | |-------|--------| | No config file | Run `python3 SKILL_DIR/scripts/config.py init` | | API timeout | Retry once, skip that source, note it | | Web search empty | Try alternative query, note gaps | | TTS fails | Fall back to Apple `say` (always available) | | Pandoc not found | Skip DOCX, deliver MP3 only | | No internet | Cannot generate — inform user |

path: CHANGELOG.md
content: # Changelog

## 1.0.3 - 2026-02-10

### Security
- **Path traversal fix** — `output.folder` config now validated against safe directories (~/Documents, ~/Desktop, ~/Downloads, ~/.briefing-room, /tmp). Rejects arbitrary paths like /etc or ~/.ssh.

## 1.0.2 - 2026-02-10

### Added
- **Real-time X/Twitter trends** — `briefing.sh trends` fetches live trending topics from getdaytrends.com
  - Default regions: US, UK, Worldwide (configurable via `trends.regions`)
  - No API key or login required
  - Sub-agent picks newsworthy trends, skips noise, searches for context
- **Web Trends section** — `briefing.sh webtrends` fetches Google Trends RSS
  - Trending searches with traffic volume + news headlines (context built-in!)
  - Default regions: US, UK, Worldwide (configurable via `webtrends.regions`)
  - No API key required (public RSS feed)
- **This Day in History section** — 1-2 interesting events from today's date, no API needed
- **Host personality** — `host.name` config sets the radio host name (default: auto-uses agent's own name)
- **Configurable trend regions** — `trends.regions` and `webtrends.regions` in config
- Auto-detect macOS voice for any language (prefers Enhanced/Premium variants)
- Any language macOS supports now works out of the box
- OpenClaw metadata (`requires.bins`, emoji)

### Changed
- Now 11 sections (was 9): split Social into "Trending on X" + "Web Trends", added "This Day in History"
- X/Twitter section now uses real trend data instead of generic web searches
- Description: "100% Free" — no misleading locality claims
- Description: explicitly states macOS only
- Clarified TTS is fully local (no keys needed)
- Listed all external endpoints (Open-Meteo, Coinbase, Google Trends RSS)
- Documented file paths read/written
- Updated README: no pip packages required (stdlib only)
- Removed unused color variables from briefing.sh
- Full User-Agent string for Cloudflare compatibility
- Trend fetch handles errors gracefully (shows "(failed to fetch)" per region)

### Fixed
- Trends command works on macOS (no `grep -P` dependency)

## 1.0.1

### Added
- **Configuration system** — `~/.briefing-room/config.json` with all settings
  - Configurable location (city, lat/lon, timezone) for weather
  - Configurable language (en, sk, de, or any macOS-supported)
  - Per-language voice settings (engine, voice, speed, blend)
  - Configurable sections list
  - Configurable output folder
- **Multi-language TTS** — per-language engine selection
  - English: MLX-Audio Kokoro (fast, local)
  - Slovak: Apple Laura (Enhanced)
  - German: Apple Petra (Premium)
  - Any language: add a `voices` entry + pick from `say -v '?'`
- **Auto-detect TTS engines** — finds mlx-audio and kokoro automatically
- **Config CLI** — `scripts/config.py` with status, get, set, init, reset
- **Voice blend support** — resolves pre-blended .safetensors from HF cache
- **Helper script uses config** — weather/crypto commands read location from config

### Changed
- Removed all hardcoded paths and coordinates
- Weather API uses configured location instead of hardcoded Bratislava
- Local news section uses configured city name
- SKILL.md uses `SKILL_DIR` placeholder for portable paths
- Broke long curl/command lines to prevent horizontal overflow
- Updated README with multi-language examples and config docs

### Fixed
- Long code lines causing horizontal scroll on ClawHub
- Silent config corruption now warns on stderr
- `init` command uses deep_copy to avoid mutating defaults
- `set` CLI verifies write succeeded (no false "Set" on failure)
- Moved `glob` and `shutil` imports to top-level (consistent, errors surface at import)
- Added `sys` to top-level imports (was inline in error handler)

## 1.0.0

- Initial release
- 9 sections: Weather, Social, Local, World, Politics, Tech, Sports, Markets, Crypto
- MLX-Audio Kokoro TTS (English)
- Apple Laura TTS (Slovak)
- DOCX + MP3 output
- Sub-agent pattern for non-blocking generation


path: README.md
content: # Briefing Room 📻

**Your personal daily news briefing — audio + document.**

Ask for a briefing and get a comprehensive, conversational radio-host-style update on everything that matters today. Configurable location, language, and sections.

## Features

- 📻 **Radio-Host Style** — Natural, conversational monologue — not a list of headlines
- 🔊 **Audio Briefing** — ~10 minute MP3, perfect for your commute
- 📄 **Formatted Document** — DOCX with sections, key facts, and source links
- 🌍 **11 Sections** — Weather → X Trends → Web Trends → World → Politics → Tech → Local → Sports → Markets → Crypto → This Day in History
- 🌐 **Multi-Language** — English (MLX-Audio Kokoro), Slovak, German, or any macOS voice
- ⚙️ **Configurable** — Location, language, voice, sections — all in `~/.briefing-room/config.json`
- 🆓 **100% Free** — No subscriptions, API keys, or paid services

## Quick Start

Just ask your agent:

- "Give me a briefing"
- "Morning update"
- "What's happening today?"
- "Ranný brífing" (Slovak mode)
- "Tägliches Briefing" (German mode)

## First Run

The skill auto-creates a config on first use. Customize your location:

```bash
python3 scripts/config.py set location.city "Vienna"
python3 scripts/config.py set location.latitude 48.21
python3 scripts/config.py set location.longitude 16.37
python3 scripts/config.py set location.timezone "Europe/Vienna"
```

Check your setup:
```bash
python3 scripts/config.py status
```

## What You Get

```
~/Documents/Briefing Room/2026-02-10/
├── briefing-2026-02-10-0830.docx    # Formatted document with sections
└── briefing-2026-02-10-0830.mp3     # Audio briefing (~10 min)
```

## Configuration

All settings in `~/.briefing-room/config.json`:

| Setting | Default | Description |
|---------|---------|-------------|
| `location.city` | Bratislava | City for weather + local news |
| `location.latitude` | 48.15 | Weather API latitude |
| `location.longitude` | 17.11 | Weather API longitude |
| `language` | en | Briefing language |
| `output.folder` | ~/Documents/Briefing Room | Where briefings are saved |
| `sections` | all 11 | Which sections to include |
| `host.name` | (auto = agent name) | Radio host name for the briefing |
| `trends.regions` | us, uk, worldwide | X/Twitter trend regions (getdaytrends.com slugs) |
| `webtrends.regions` | US, GB, worldwide | Google Trends regions (ISO codes) |

### Voice Per Language

```json
{
  "voices": {
    "en": {"engine": "mlx", "mlx_voice": "af_heart", "speed": 1.05},
    "sk": {"engine": "builtin", "builtin_voice": "Laura (Enhanced)", "builtin_rate": 220},
    "de": {"engine": "builtin", "builtin_voice": "Petra (Premium)", "builtin_rate": 200}
  }
}
```

Add any language — just pick a voice from `say -v '?'` on macOS.
If you set a language without a voice config, it auto-detects a matching macOS voice.

**Supported out of the box:** English, Slovak, German.
**Works with any language** macOS supports — French, Spanish, Italian, Japanese, Chinese, etc.

## Sections

| # | Section | Source |
|---|---------|--------|
| 1 | 🌤️ Weather | Open-Meteo API (your location) |
| 2 | 🐦 Trending on X | getdaytrends.com (real-time X/Twitter trends) |
| 3 | 🔍 Web Trends | Google Trends RSS (what people are searching) |
| 4 | 🌍 World | Web search |
| 5 | 🏛️ Politics | Web search |
| 6 | 💻 Tech & AI | Web search |
| 7 | 🏠 Local | Web search (your city) |
| 8 | ⚽ Sports | Web search |
| 9 | 📈 Markets | Web search + APIs |
| 10 | ₿ Crypto | Coinbase API + Web search |
| 11 | 📜 This Day in History | Agent knowledge (no API needed) |

## Dependencies

**Required:**
- macOS (uses `afplay`, `say`, `curl` — all built-in)
- OpenClaw with `web_search`

**Recommended (enhance quality):**
- [MLX-Audio Kokoro](https://github.com/ml-explore/mlx-audio) — fast English TTS on Apple Silicon
- `pandoc` — DOCX generation (`brew install pandoc`)
- `ffmpeg` — MP3 conversion (`brew install ffmpeg`)

**No pip packages required** — included scripts use only Python standard library.

**Always available:**
- Apple `say` — multilingual TTS fallback (built into macOS)

## Install

```bash
clawhub install briefing-room
```


path: scripts/briefing.sh
content: #!/usr/bin/env bash
# Briefing Room — Helper script for setup and utilities
# The actual briefing is composed by the agent (SKILL.md), not this script.
# This script handles: setup checks, folder creation, audio post-processing.

set -euo pipefail

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
CONFIG_PY="$SCRIPT_DIR/config.py"

# Read config values via Python
cfg() {
    python3 "$CONFIG_PY" get "$1" 2>/dev/null || echo ""
}

BRIEFINGS_DIR="$(cfg output.folder)"
[ -z "$BRIEFINGS_DIR" ] && BRIEFINGS_DIR="$HOME/Documents/Briefing Room"
DATE=$(date +%Y-%m-%d)
OUTPUT_DIR="$BRIEFINGS_DIR/$DATE"

# User-Agent for sites behind Cloudflare
UA="Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"

usage() {
    echo "Briefing Room — Helper Script"
    echo ""
    echo "Usage: briefing.sh <command>"
    echo ""
    echo "Commands:"
    echo "  setup       Check dependencies and show status"
    echo "  init        Create today's output folder"
    echo "  weather     Fetch weather data (JSON)"
    echo "  trends      Fetch X/Twitter trends (US + UK + Worldwide)"
    echo "  webtrends   Fetch Google Trends (US + UK + Worldwide)"
    echo "  crypto      Fetch crypto prices (JSON)"
    echo "  open        Open today's briefing folder"
    echo "  list        List all briefings"
    echo "  clean       Remove briefings older than 30 days"
    echo "  config      Show current configuration"
    echo ""
}

cmd_setup() {
    python3 "$CONFIG_PY" status
}

cmd_init() {
    mkdir -p "$OUTPUT_DIR"
    echo "$OUTPUT_DIR"
}

cmd_weather() {
    local lat lon tz city
    lat="$(cfg location.latitude)"
    lon="$(cfg location.longitude)"
    tz="$(cfg location.timezone)"
    city="$(cfg location.city)"
    # Fallbacks match config.py DEFAULT_CONFIG
    [ -z "$lat" ] && lat="48.15"
    [ -z "$lon" ] && lon="17.11"
    [ -z "$tz" ] && tz="Europe/Bratislava"
    [ -z "$city" ] && city="Bratislava"

    local tz_encoded="${tz/\//%2F}"

    echo "=== Current Weather ($city) ==="
    curl -s --max-time 10 "https://api.open-meteo.com/v1/forecast?latitude=$lat&longitude=$lon&current=temperature_2m,relative_humidity_2m,apparent_temperature,precipitation,weather_code,wind_speed_10m&timezone=$tz_encoded"
    echo ""
    echo ""
    echo "=== 7-Day Forecast ==="
    curl -s --max-time 10 "https://api.open-meteo.com/v1/forecast?latitude=$lat&longitude=$lon&daily=temperature_2m_max,temperature_2m_min,precipitation_sum,weather_code&timezone=$tz_encoded"
    echo ""
}

cmd_trends() {
    echo "=== X/Twitter Trends ==="
    # Configurable regions: comma-separated slugs in config, or default US+UK+Worldwide
    local regions_cfg
    regions_cfg="$(cfg trends.regions)"
    if [ -z "$regions_cfg" ] || [ "$regions_cfg" = "None" ]; then
        regions_cfg="united-states,united-kingdom,"
    fi

    IFS=',' read -ra slugs <<< "$regions_cfg"
    for slug in "${slugs[@]}"; do
        slug="$(echo "$slug" | xargs)"  # trim whitespace
        local label url
        if [ -z "$slug" ]; then
            label="Worldwide"
            url="https://getdaytrends.com/"
        else
            label="${slug//-/ }"
            # Capitalize words
            label="$(echo "$label" | awk '{for(i=1;i<=NF;i++) $i=toupper(substr($i,1,1)) substr($i,2)}1')"
            url="https://getdaytrends.com/$slug/"
        fi
        echo ""
        echo "--- $label ---"
        local html
        html="$(curl -s --max-time 15 -A "$UA" "$url" 2>/dev/null)" || true
        if [ -z "$html" ]; then
            echo "(failed to fetch)"
            continue
        fi
        echo "$html" | python3 -c "
import sys, urllib.parse, re
html = sys.stdin.read()
trends = re.findall(r'/trend/([^/]+)/', html)
seen = set()
for t in trends:
    name = urllib.parse.unquote(t)
    if name not in seen:
        seen.add(name)
        print(name)
" | head -25
    done
}

cmd_webtrends() {
    echo "=== Google Trends (Web) ==="
    # Configurable regions for Google Trends
    local regions_cfg
    regions_cfg="$(cfg webtrends.regions)"
    if [ -z "$regions_cfg" ] || [ "$regions_cfg" = "None" ]; then
        regions_cfg="US,GB,"
    fi

    IFS=',' read -ra geos <<< "$regions_cfg"
    for geo in "${geos[@]}"; do
        geo="$(echo "$geo" | xargs)"  # trim whitespace
        local label url
        if [ -z "$geo" ]; then
            label="Worldwide"
            url="https://trends.google.com/trending/rss?geo="
        else
            label="$geo"
            url="https://trends.google.com/trending/rss?geo=$geo"
        fi
        echo ""
        echo "--- $label ---"
        local xml
        xml="$(curl -s --max-time 15 "$url" 2>/dev/null)" || true
        if [ -z "$xml" ]; then
            echo "(failed to fetch)"
            continue
        fi
        echo "$xml" | python3 -c "
import sys, re, html as html_mod

xml = sys.stdin.read()
items = re.findall(r'<item>(.*?)</item>', xml, re.DOTALL)
for item in items[:20]:
    title = re.search(r'<title>(.*?)</title>', item)
    traffic = re.search(r'<ht:approx_traffic>(.*?)</ht:approx_traffic>', item)
    headlines = re.findall(r'<ht:news_item_title>(.*?)</ht:news_item_title>', item)

    t = html_mod.unescape(title.group(1)) if title else '?'
    tr = traffic.group(1) if traffic else '?'
    h = html_mod.unescape(headlines[0]) if headlines else ''

    print(f'{t} ({tr})')
    if h:
        print(f'  → {h}')
"
    done
}

cmd_crypto() {
    echo "=== Crypto Prices ==="
    for coin in BTC ETH SOL XRP; do
        echo -n "$coin/USD: "
        curl -s --max-time 10 "https://api.coinbase.com/v2/prices/$coin-USD/spot" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d['data']['amount'])" 2>/dev/null || echo "N/A"
    done
}

cmd_open() {
    if [ -d "$OUTPUT_DIR" ]; then
        open "$OUTPUT_DIR"
    else
        echo "No briefing for today ($DATE). Run the briefing first!"
        exit 1
    fi
}

cmd_list() {
    if [ -d "$BRIEFINGS_DIR" ]; then
        echo "📻 Available Briefings:"
        echo ""
        local found=false
        for dir in "$BRIEFINGS_DIR"/20*/; do
            [ -e "$dir" ] || continue
            found=true
            local date
            date=$(basename "$dir")
            local files
            files=$(ls "$dir" 2>/dev/null | tr '\n' ', ' | sed 's/,$//')
            echo "  $date — $files"
        done
        if ! $found; then
            echo "  No briefings yet."
        fi
    else
        echo "No briefings yet."
    fi
}

cmd_clean() {
    local cutoff
    cutoff=$(date -v-30d +%Y-%m-%d 2>/dev/null || date -d "30 days ago" +%Y-%m-%d)
    local count=0
    for dir in "$BRIEFINGS_DIR"/20*/; do
        [ -e "$dir" ] || continue
        local date
        date=$(basename "$dir")
        if [[ "$date" < "$cutoff" ]]; then
            echo "Removing $date..."
            rm -rf "$dir"
            count=$((count + 1))
        fi
    done
    echo "Cleaned up $count old briefing(s)."
}

cmd_config() {
    python3 "$CONFIG_PY" show
}

# Main
case "${1:-}" in
    setup)  cmd_setup ;;
    init)   cmd_init ;;
    weather) cmd_weather ;;
    trends) cmd_trends ;;
    webtrends) cmd_webtrends ;;
    crypto) cmd_crypto ;;
    open)   cmd_open ;;
    list)   cmd_list ;;
    clean)  cmd_clean ;;
    config) cmd_config ;;
    *)      usage ;;
esac


path: scripts/config.py
content: #!/usr/bin/env python3
"""
Briefing Room Configuration
============================

Central configuration management for Briefing Room.
All settings in one place, with defaults and validation.
"""

import os
import sys
import json
import glob
import shutil
from pathlib import Path

# Config location
CONFIG_DIR = Path.home() / ".briefing-room"
CONFIG_FILE = CONFIG_DIR / "config.json"

# Default configuration
DEFAULT_CONFIG = {
    # Location settings (for weather)
    "location": {
        "city": "Bratislava",
        "latitude": 48.15,
        "longitude": 17.11,
        "timezone": "Europe/Bratislava",
    },

    # Language
    "language": "en",  # en, sk, de, etc.

    # Output settings
    "output": {
        "folder": str(Path.home() / "Documents" / "Briefing Room"),
        "open_folder_after": True,
    },

    # Document settings
    "document": {
        "format": "docx",       # docx, html, md
        "engine": "pandoc",     # pandoc (for docx), falls back to html
    },

    # Audio settings
    "audio": {
        "enabled": True,
        "format": "mp3",        # mp3, wav, aiff
        "tts_engine": "auto",   # auto, mlx, kokoro, builtin
        # auto = detect best available: mlx → kokoro → builtin
    },

    # TTS voice settings per language
    # Each language can specify its preferred engine + voice
    "voices": {
        "en": {
            "engine": "mlx",             # mlx, kokoro, builtin
            "mlx_voice": "af_heart",     # voice name or path to .safetensors
            "mlx_voice_blend": None,     # e.g. {"af_heart": 0.6, "af_sky": 0.4} or null
            "builtin_voice": "Samantha", # macOS say voice fallback
            "speed": 1.05,
        },
        "sk": {
            "engine": "builtin",
            "builtin_voice": "Laura (Enhanced)",
            "builtin_rate": 220,
            "speed": 1.0,
        },
        "de": {
            "engine": "builtin",
            "builtin_voice": "Petra (Premium)",
            "builtin_rate": 200,
            "speed": 1.0,
        },
    },

    # MLX-Audio settings (shared)
    "mlx_audio": {
        "path": "",              # auto-detect if empty
        "model": "mlx-community/Kokoro-82M-bf16",
        "lang_code": "a",       # 'a' for American English
    },

    # Kokoro PyTorch settings (fallback)
    "kokoro": {
        "path": "",              # auto-detect if empty
    },

    # News sections to include (order matters)
    "sections": [
        "weather",
        "social",       # X/Twitter trends
        "webtrends",    # Google Trends (web search trends)
        "world",
        "politics",
        "tech",
        "local",        # Local news (based on location.city)
        "sports",
        "markets",
        "crypto",
        "history",      # This Day in History
    ],

    # Host personality
    "host": {
        "name": "",  # Empty = auto (uses agent's own name). Set to override.
    },

    # X/Twitter trends settings
    "trends": {
        # Comma-separated getdaytrends.com region slugs (empty = worldwide)
        # Available: united-states, united-kingdom, germany, france, etc.
        # See full list: https://getdaytrends.com (footer links)
        "regions": "united-states,united-kingdom,",  # trailing comma = worldwide
    },

    # Google Trends (Web Trends) settings
    "webtrends": {
        # Comma-separated ISO country codes (empty = worldwide)
        # Examples: US, GB, DE, FR, SK
        "regions": "US,GB,",  # trailing comma = worldwide
    },

    # Search settings
    "search": {
        "country": "",           # 2-letter code, empty = auto from location
        "local_language": "",    # ISO lang code for local news search, empty = auto
    },

    # Processing
    "processing": {
        "subagent_timeout": 600,
        "cleanup_temp_files": True,
    },
}


def get_config_dir() -> Path:
    CONFIG_DIR.mkdir(parents=True, exist_ok=True)
    return CONFIG_DIR


def load_config() -> dict:
    config = deep_copy(DEFAULT_CONFIG)
    if CONFIG_FILE.exists():
        try:
            with open(CONFIG_FILE, 'r') as f:
                user_config = json.load(f)
            config = deep_merge(config, user_config)
        except json.JSONDecodeError as e:
            print(f"⚠️  Config corrupted: {e}",
                  file=sys.stderr)
            print(f"   Using defaults. Fix or delete: "
                  f"{CONFIG_FILE}", file=sys.stderr)
        except IOError:
            pass
    return config


def save_config(config: dict) -> None:
    get_config_dir()
    with open(CONFIG_FILE, 'w') as f:
        json.dump(config, f, indent=2)


def get_value(key: str, default=None):
    config = load_config()
    keys = key.split('.')
    value = config
    try:
        for k in keys:
            if isinstance(value, list):
                k = int(k)
            value = value[k]
        return value
    except (KeyError, TypeError, IndexError, ValueError):
        return default


SAFE_OUTPUT_PREFIXES = [
    os.path.realpath(os.path.expanduser("~/Documents")),
    os.path.realpath(os.path.expanduser("~/Desktop")),
    os.path.realpath(os.path.expanduser("~/Downloads")),
    os.path.realpath(os.path.expanduser("~/.briefing-room")),
    os.path.realpath("/tmp"),
]

def _validate_output_path(path: str) -> bool:
    """Reject output.folder paths outside safe directories."""
    expanded = os.path.realpath(os.path.expanduser(path))
    return any(expanded.startswith(prefix) for prefix in SAFE_OUTPUT_PREFIXES)

def set_value(key: str, value) -> bool:
    if key == "output.folder" and isinstance(value, str):
        if not _validate_output_path(value):
            print(f"Error: output.folder must be under ~/Documents, ~/Desktop, ~/Downloads, ~/.briefing-room, or /tmp", file=sys.stderr)
            return False
    config = load_config()
    keys = key.split('.')
    target = config
    for k in keys[:-1]:
        if isinstance(target, list):
            try:
                k = int(k)
            except ValueError:
                return False
            if k < 0 or k >= len(target):
                return False
            target = target[k]
        else:
            if k not in target:
                target[k] = {}
            target = target[k]
    last = keys[-1]
    if isinstance(target, list):
        try:
            last = int(last)
        except ValueError:
            return False
        if 0 <= last < len(target):
            target[last] = value
        else:
            return False
    else:
        target[last] = value
    save_config(config)
    return True


def deep_copy(obj):
    if isinstance(obj, dict):
        return {k: deep_copy(v) for k, v in obj.items()}
    elif isinstance(obj, list):
        return [deep_copy(v) for v in obj]
    return obj


def deep_merge(base: dict, override: dict) -> dict:
    result = deep_copy(base)
    for key, value in override.items():
        if key in result and isinstance(result[key], dict) and isinstance(value, dict):
            result[key] = deep_merge(result[key], value)
        else:
            result[key] = deep_copy(value)
    return result


def reset_to_defaults() -> None:
    save_config(deep_copy(DEFAULT_CONFIG))


def detect_mlx_path() -> str:
    """Find MLX-Audio installation."""
    candidates = [
        Path.home() / ".openclaw" / "tools" / "mlx-audio",
        Path.home() / ".local" / "share" / "mlx-audio",
        Path.home() / "mlx-audio",
    ]
    for p in candidates:
        if (p / ".venv" / "bin" / "python3").exists():
            return str(p)
    return ""


def detect_kokoro_path() -> str:
    """Find Kokoro PyTorch installation."""
    candidates = [
        Path.home() / ".openclaw" / "tools" / "kokoro",
        Path.home() / ".local" / "share" / "kokoro",
        Path("/tmp") / "kokoro-coreml",
        Path.home() / "kokoro-coreml",
    ]
    for p in candidates:
        venv = p / ".venv" / "bin" / "python3"
        if venv.exists():
            return str(p)
    return ""


def detect_tts_engine(lang: str = "en") -> str:
    """Detect best available TTS engine for a language."""
    config = load_config()
    voice_cfg = config.get("voices", {}).get(lang, {})
    preferred = voice_cfg.get("engine", "auto")

    if preferred != "auto":
        return preferred

    # For non-English, prefer builtin (Apple TTS has good multilingual voices)
    if lang != "en":
        return "builtin"

    # For English: mlx → kokoro → builtin
    if detect_mlx_path():
        return "mlx"
    if detect_kokoro_path():
        return "kokoro"
    return "builtin"


def resolve_voice_path(config: dict) -> str:
    """Resolve the voice file path for MLX-Audio."""
    lang = config.get("language", "en")
    voice_cfg = config.get("voices", {}).get(lang, config.get("voices", {}).get("en", {}))
    blend = voice_cfg.get("mlx_voice_blend")
    voice = voice_cfg.get("mlx_voice", "af_heart")

    if blend and isinstance(blend, dict):
        # Look for pre-blended .safetensors in HuggingFace cache
        model = config.get("mlx_audio", {}).get("model", "mlx-community/Kokoro-82M-bf16")
        model_slug = model.replace("/", "--")
        cache_dir = Path.home() / ".cache" / "huggingface" / "hub" / f"models--{model_slug}"

        # Build expected filename from blend
        parts = []
        for v, w in sorted(blend.items(), key=lambda x: -x[1]):
            parts.append(f"{v}_{int(w * 100)}")
        blend_name = "_".join(parts) + ".safetensors"

        # Search snapshots
        matches = glob.glob(str(cache_dir / "snapshots" / "*" / "voices" / blend_name))
        if matches:
            return matches[0]

        # Try without blend — fall back to single voice
        matches = glob.glob(str(cache_dir / "snapshots" / "*" / "voices" / f"{voice}.safetensors"))
        if matches:
            return matches[0]

    # Single voice name — return as-is (mlx-audio resolves it)
    return voice


def print_config() -> None:
    config = load_config()
    print(json.dumps(config, indent=2))


def print_status() -> None:
    """Print a status summary with detected capabilities."""
    config = load_config()
    loc = config["location"]
    lang = config["language"]

    print("📻 Briefing Room — Configuration")
    print("=" * 45)
    print(f"Config: {CONFIG_FILE}")
    print()
    print(f"Location: {loc['city']} ({loc['latitude']}, {loc['longitude']})")
    print(f"Language: {lang}")
    print(f"Output:   {config['output']['folder']}")
    print(f"Document: {config['document']['format']} ({config['document']['engine']})")
    print(f"Audio:    {'enabled' if config['audio']['enabled'] else 'disabled'} ({config['audio']['format']})")
    print()

    print("TTS Engines:")
    mlx = detect_mlx_path()
    kokoro = detect_kokoro_path()
    print(f"  {'✓' if mlx else '✗'} MLX-Audio: {mlx or 'not found'}")
    print(f"  {'✓' if kokoro else '✗'} Kokoro:    {kokoro or 'not found'}")
    print(f"  ✓ Built-in:  macOS say (always available)")
    print()

    print("Voice Config:")
    for lang_code, vcfg in config.get("voices", {}).items():
        engine = vcfg.get("engine", "auto")
        if engine == "auto":
            engine = f"auto → {detect_tts_engine(lang_code)}"
        voice_name = vcfg.get("mlx_voice", vcfg.get("builtin_voice", "default"))
        print(f"  {lang_code}: {engine} ({voice_name})")
    print()

    print(f"Sections: {', '.join(config.get('sections', []))}")

    # Check dependencies
    print()
    print("Dependencies:")
    for name, cmd in [("curl", "curl"), ("pandoc", "pandoc"), ("ffmpeg", "ffmpeg")]:
        found = shutil.which(cmd)
        print(f"  {'✓' if found else '✗'} {name}")


# CLI interface
if __name__ == "__main__":
    if len(sys.argv) == 1 or sys.argv[1] == "status":
        print_status()

    elif sys.argv[1] == "show":
        print_config()

    elif sys.argv[1] == "get" and len(sys.argv) == 3:
        value = get_value(sys.argv[2])
        print(value if value is not None else "(not set)")

    elif sys.argv[1] == "set" and len(sys.argv) == 4:
        key, value = sys.argv[2], sys.argv[3]
        try:
            value = json.loads(value)
        except json.JSONDecodeError:
            pass
        if set_value(key, value):
            print(f"Set {key} = {value}")
        else:
            print(f"⚠️  Could not set {key} (invalid path?)")

    elif sys.argv[1] == "reset":
        reset_to_defaults()
        print("Config reset to defaults.")

    elif sys.argv[1] == "path":
        print(CONFIG_FILE)

    elif sys.argv[1] == "init":
        # First-run setup — create config with defaults
        if CONFIG_FILE.exists():
            print(f"Config already exists: {CONFIG_FILE}")
        else:
            save_config(deep_copy(DEFAULT_CONFIG))
            print(f"Created config: {CONFIG_FILE}")
            print("Edit it to customize location, language, and voices.")
        print_status()

    else:
        print("Usage:")
        print("  python config.py              # Show status")
        print("  python config.py show         # Show raw config JSON")
        print("  python config.py get KEY      # Get value (dot notation)")
        print("  python config.py set KEY VAL  # Set value")
        print("  python config.py init         # Create default config")
        print("  python config.py reset        # Reset to defaults")
        print("  python config.py path         # Show config file path")


Security scan verdicts — ClawScan: clean | static analysis: clean | VirusTotal: clean | SkillSpector: suspicious (CRITICAL)