feat: Music Assistant, Claude primary LLM, model tag in chat, setup.sh rewrite

- Deploy Music Assistant on Pi (10.0.0.199:8095) with host networking for
  Chromecast mDNS discovery, Spotify + SMB library support
- Switch primary LLM from Ollama to Claude Sonnet 4 (Anthropic API),
  local models remain as fallback
- Add model info tag under each assistant message in dashboard chat,
  persisted in conversation JSON
- Rewrite homeai-agent/setup.sh: loads .env, injects API keys into plists,
  symlinks plists to ~/Library/LaunchAgents/, smoke tests services
- Update install_service() in common.sh to use symlinks instead of copies
- Open UFW ports on Pi for Music Assistant (8095, 8097, 8927)
- Add ANTHROPIC_API_KEY to openclaw + bridge launchd plists

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Aodhan Collins
2026-03-18 22:21:28 +00:00
parent 60eb89ea42
commit 117254d560
17 changed files with 1399 additions and 361 deletions

View File

@@ -18,14 +18,15 @@ A self-hosted, always-on personal AI assistant running on a **Mac Mini M4 Pro (6
| Storage | 1TB SSD |
| Network | Gigabit Ethernet |
All AI inference runs locally on this machine. No cloud dependency required (cloud APIs optional).
Primary LLM is Claude Sonnet 4 via Anthropic API. Local Ollama models available as fallback. All other inference (STT, TTS, image gen) runs locally.
---
## Core Stack
### AI & LLM
- **Ollama** — local LLM runtime (target models: Llama 3.3 70B, Qwen 2.5 72B)
- **Claude Sonnet 4** — primary LLM via Anthropic API (`anthropic/claude-sonnet-4-20250514`), used for all agent interactions
- **Ollama** — local LLM runtime (fallback models: Llama 3.3 70B, Qwen 3.5 35B-A3B, Qwen 2.5 7B)
- **Model keep-warm daemon** — `preload-models.sh` runs as a loop, checks every 5 min, re-pins evicted models with `keep_alive=-1`. Keeps `qwen2.5:7b` (small/fast) and `$HOMEAI_MEDIUM_MODEL` (default: `qwen3.5:35b-a3b`) always loaded in VRAM. Medium model is configurable via env var for per-persona model assignment.
- **Open WebUI** — browser-based chat interface, runs as Docker container
@@ -45,13 +46,15 @@ All AI inference runs locally on this machine. No cloud dependency required (clo
### Smart Home
- **Home Assistant** — smart home control platform (Docker)
- **Wyoming Protocol** — bridges Whisper STT + Kokoro/Piper TTS into Home Assistant
- **Music Assistant** — self-hosted music control, integrates with Home Assistant
- **Music Assistant** — self-hosted music control (Docker on Pi at 10.0.0.199:8095), Spotify + SMB library + Chromecast players
- **Snapcast** — multi-room synchronised audio output
### AI Agent / Orchestration
- **OpenClaw** — primary AI agent layer; receives voice commands, calls tools, manages personality
- **OpenClaw Skills** — 13 skills total: home-assistant, image-generation, voice-assistant, vtube-studio, memory, service-monitor, character, routine, music, workflow, gitea, calendar, mode
- **n8n** — visual workflow automation (Docker), chains AI actions
- **Character Memory System** — two-tier JSON-based memories (personal per-character + general shared), injected into LLM system prompt with budget truncation
- **Public/Private Mode** — routes requests to local Ollama (private) or cloud LLMs (public) with per-category overrides via `active-mode.json`. Default primary model is Claude Sonnet 4.
### Character & Personality
- **Character Schema v2** — JSON spec with background, dialogue_style, appearance, skills, gaze_presets (v1 auto-migrated)
@@ -93,7 +96,8 @@ ESP32-S3-BOX-3 (room)
→ HA conversation agent → OpenClaw HTTP Bridge
→ Bridge resolves character (satellite_id → character mapping)
→ Bridge builds system prompt (profile + memories) and writes TTS config to state file
OpenClaw CLI → Ollama LLM generates response
Bridge checks active-mode.json for model routing (private=local, public=cloud)
→ OpenClaw CLI → LLM generates response (Claude Sonnet 4 default, Ollama fallback)
→ Response dispatched:
→ Wyoming TTS reads state file → routes to Kokoro (local) or ElevenLabs (cloud)
→ Audio sent back to ESP32-S3-BOX-3 (spoken response)
@@ -154,11 +158,13 @@ This works for both ESP32/HA pipeline and dashboard chat.
4. **OpenClaw** — installed, onboarded, connected to Ollama and Home Assistant ✅
5. **ESP32-S3-BOX-3** — ESPHome flash, Wyoming Satellite, display faces ✅
6. **Character system** — schema v2, dashboard editor, memory system, per-character TTS routing ✅
7. **Animated visual** — PNG/GIF character visual for the web assistant (initial visual layer)
8. **Android app** — companion app for mobile access to the assistant
9. **ComfyUI** — image generation online, character-consistent model workflows
10. **Extended integrations** — n8n workflows, Music Assistant, Snapcast, Gitea, code-server
11. **Polish** — Authelia, Tailscale hardening, iOS widgets
7. **OpenClaw skills expansion** — 9 new skills (memory, monitor, character, routine, music, workflow, gitea, calendar, mode) + public/private mode routing ✅
8. **Music Assistant** — deployed on Pi (10.0.0.199:8095), Spotify + SMB + Chromecast players ✅
9. **Animated visual** — PNG/GIF character visual for the web assistant (initial visual layer)
10. **Android app** — companion app for mobile access to the assistant
11. **ComfyUI** — image generation online, character-consistent model workflows
12. **Extended integrations** — Snapcast, code-server
13. **Polish** — Authelia, Tailscale hardening, iOS widgets
### Stretch Goals
- **Live2D / VTube Studio** — full Live2D model with WebSocket API bridge (requires learning Live2D tooling)
@@ -167,18 +173,27 @@ This works for both ESP32/HA pipeline and dashboard chat.
## Key Paths & Conventions
- All Docker compose files: `~/server/docker/`
- Launchd plists (source): `homeai-*/launchd/` (symlinked to `~/Library/LaunchAgents/`)
- Docker compose (Mac Mini): `homeai-infra/docker/docker-compose.yml`
- Docker compose (Pi/SELBINA): `~/docker/selbina/` on 10.0.0.199
- OpenClaw skills: `~/.openclaw/skills/`
- OpenClaw workspace tools: `~/.openclaw/workspace/TOOLS.md`
- OpenClaw config: `~/.openclaw/openclaw.json`
- Character configs: `~/homeai-data/characters/`
- Character memories: `~/homeai-data/memories/`
- Conversation history: `~/homeai-data/conversations/`
- Active TTS state: `~/homeai-data/active-tts-voice.json`
- Active mode state: `~/homeai-data/active-mode.json`
- Satellite → character map: `~/homeai-data/satellite-map.json`
- Local routines: `~/homeai-data/routines/`
- Voice reminders: `~/homeai-data/reminders.json`
- Whisper models: `~/models/whisper/`
- Ollama models: managed by Ollama at `~/.ollama/models/`
- ComfyUI models: `~/ComfyUI/models/`
- Voice reference audio: `~/voices/`
- Gitea repos root: `~/gitea/`
- Music Assistant (Pi): `~/docker/selbina/music-assistant/` on 10.0.0.199
- Skills user guide: `homeai-agent/SKILLS_GUIDE.md`
---
@@ -188,6 +203,8 @@ This works for both ESP32/HA pipeline and dashboard chat.
- ESP32-S3-BOX-3 units are dumb satellites — all intelligence stays on Mac Mini
- The character JSON schema (from Character Manager) should be treated as a versioned spec; pipeline components read from it, never hardcode personality values
- OpenClaw skills are the primary extension mechanism — new capabilities = new skills
- Prefer local models; cloud API keys (Anthropic, OpenAI) are fallback only
- Primary LLM is Claude Sonnet 4 (Anthropic API); local Ollama models are available as fallback
- Launchd plists are symlinked from repo source to ~/Library/LaunchAgents/ — edit source, then bootout/bootstrap to reload
- Music Assistant runs on Pi (10.0.0.199), not Mac Mini — needs host networking for Chromecast mDNS discovery
- VTube Studio API bridge should be a standalone OpenClaw skill with clear event interface
- mem0 memory store should be backed up as part of regular Gitea commits

10
TODO.md
View File

@@ -16,7 +16,7 @@
- [x] `docker compose up -d` — bring all services up
- [x] Home Assistant onboarding — long-lived access token generated, stored in `.env`
- [ ] Install Tailscale, verify all services reachable on Tailnet
- [ ] Uptime Kuma: add monitors for all services, configure mobile alerts
- [x] Uptime Kuma: add monitors for all services, configure mobile alerts
- [ ] Verify all containers survive a cold reboot
### P2 · homeai-llm
@@ -30,7 +30,7 @@
- [x] Deploy Open WebUI via Docker compose (port 3030)
- [x] Verify Open WebUI connected to Ollama, all models available
- [x] Run pipeline benchmark (homeai-voice/scripts/benchmark_pipeline.py) — STT/LLM/TTS latency profiled
- [ ] Add Ollama + Open WebUI to Uptime Kuma monitors
- [x] Add Ollama + Open WebUI to Uptime Kuma monitors
---
@@ -56,7 +56,7 @@
- [ ] Install Chatterbox TTS (MPS build), test with sample `.wav`
- [ ] Install Qwen3-TTS via MLX (fallback)
- [ ] Train custom wake word using character name
- [ ] Add Wyoming STT/TTS to Uptime Kuma monitors
- [x] Add Wyoming STT/TTS to Uptime Kuma monitors
---
@@ -197,8 +197,8 @@
### P10 · Integrations & Polish
- [ ] Deploy Music Assistant (Docker), integrate with Home Assistant
- [ ] Write `skills/music` SKILL.md for OpenClaw
- [x] Deploy Music Assistant (Docker on Pi 10.0.0.199:8095), Spotify + SMB + Chromecast
- [x] Write `skills/music` SKILL.md for OpenClaw
- [ ] Deploy Snapcast server on Mac Mini
- [ ] Configure Snapcast clients on ESP32 units for multi-room audio
- [ ] Configure Authelia as 2FA layer in front of web UIs

View File

@@ -1,37 +1,38 @@
# P4: homeai-agent — AI Agent, Skills & Automation
> Phase 3 | Depends on: P1 (HA), P2 (Ollama), P3 (Wyoming/TTS), P5 (character JSON)
---
## Goal
OpenClaw running as the primary AI agent: receives voice/text input, loads character persona, calls tools (skills), manages memory (mem0), dispatches responses (TTS, HA actions, VTube expressions). n8n handles scheduled/automated workflows.
> Phase 4 | Depends on: P1 (HA), P2 (Ollama), P3 (Wyoming/TTS), P5 (character JSON)
> Status: **COMPLETE** (all skills implemented)
---
## Architecture
```
Voice input (text from P3 Wyoming STT)
Voice input (text from Wyoming STT via HA pipeline)
OpenClaw API (port 8080)
loads character JSON from P5
System prompt construction
Ollama LLM (P2) — llama3.3:70b
↓ response + tool calls
Skill dispatcher
├── home_assistant.py → HA REST API (P1)
├── memory.py → mem0 (local)
├── vtube_studio.py → VTube WS (P7)
├── comfyui.py → ComfyUI API (P8)
├── music.py → Music Assistant (Phase 7)
── weather.py → HA sensor data
OpenClaw HTTP Bridge (port 8081)
resolves character, loads memories, checks mode
System prompt construction (profile + memories)
checks active-mode.json for model routing
OpenClaw CLI → LLM (Ollama local or cloud API)
↓ response + tool calls via exec
Skill dispatcher (CLIs on PATH)
├── ha-ctl → Home Assistant REST API
├── memory-ctl → JSON memory files
├── monitor-ctl → service health checks
├── character-ctl → character switching
├── routine-ctl → scenes, scripts, multi-step routines
── music-ctl → media player control
├── workflow-ctl → n8n workflow triggering
├── gitea-ctl → Gitea repo/issue queries
├── calendar-ctl → HA calendar + voice reminders
├── mode-ctl → public/private LLM routing
├── gaze-ctl → image generation
└── vtube-ctl → VTube Studio expressions
↓ final response text
TTS dispatch:
├── Chatterbox (voice clone, if active)
└── Kokoro (via Wyoming, fallback)
TTS dispatch (via active-tts-voice.json):
├── Kokoro (local, Wyoming)
└── ElevenLabs (cloud API)
Audio playback to appropriate room
```
@@ -40,296 +41,148 @@ OpenClaw API (port 8080)
## OpenClaw Setup
### Installation
```bash
# Confirm OpenClaw supports Ollama — check repo for latest install method
pip install openclaw
# or
git clone https://github.com/<openclaw-repo>/openclaw
pip install -e .
```
**Key question:** Verify OpenClaw's Ollama/OpenAI-compatible backend support before installation. If OpenClaw doesn't support local Ollama natively, use a thin adapter layer pointing its OpenAI endpoint at `http://localhost:11434/v1`.
### Config — `~/.openclaw/config.yaml`
```yaml
version: 1
llm:
provider: ollama # or openai-compatible
base_url: http://localhost:11434/v1
model: llama3.3:70b
fast_model: qwen2.5:7b # used for quick intent classification
character:
active: aria
config_dir: ~/.openclaw/characters/
memory:
provider: mem0
store_path: ~/.openclaw/memory/
embedding_model: nomic-embed-text
embedding_url: http://localhost:11434/v1
api:
host: 0.0.0.0
port: 8080
tts:
primary: chatterbox # when voice clone active
fallback: kokoro-wyoming # Wyoming TTS endpoint
wyoming_tts_url: tcp://localhost:10301
wake:
endpoint: /wake # openWakeWord POSTs here to trigger listening
```
- **Runtime:** Node.js global install at `/opt/homebrew/bin/openclaw` (v2026.3.2)
- **Config:** `~/.openclaw/openclaw.json`
- **Gateway:** port 8080, mode local, launchd: `com.homeai.openclaw`
- **Default model:** `ollama/qwen3.5:35b-a3b` (MoE, 35B total, 3B active, 26.7 tok/s)
- **Cloud models (public mode):** `anthropic/claude-sonnet-4-20250514`, `openai/gpt-4o`
- **Critical:** `commands.native: true` in config (enables exec tool for CLI skills)
- **Critical:** `contextWindow: 32768` for large models (prevents GPU OOM)
---
## Skills
## Skills (13 total)
All skills live in `~/.openclaw/skills/` (symlinked from `homeai-agent/skills/`).
All skills follow the same pattern:
- `~/.openclaw/skills/<name>/SKILL.md` — metadata + agent instructions
- `~/.openclaw/skills/<name>/<tool>` — executable Python CLI (stdlib only)
- Symlinked to `/opt/homebrew/bin/` for PATH access
- Agent invokes via `exec` tool
- Documented in `~/.openclaw/workspace/TOOLS.md`
### `home_assistant.py`
### Existing Skills (4)
Wraps the HA REST API for common smart home actions.
| Skill | CLI | Description |
|-------|-----|-------------|
| home-assistant | `ha-ctl` | Smart home device control |
| image-generation | `gaze-ctl` | Image generation via ComfyUI/GAZE |
| voice-assistant | (none) | Voice pipeline handling |
| vtube-studio | `vtube-ctl` | VTube Studio expression control |
**Functions:**
- `turn_on(entity_id, **kwargs)` — lights, switches, media players
- `turn_off(entity_id)`
- `toggle(entity_id)`
- `set_light(entity_id, brightness=None, color_temp=None, rgb_color=None)`
- `run_scene(scene_id)`
- `get_state(entity_id)` → returns state + attributes
- `list_entities(domain=None)` → returns entity list
### New Skills (9) — Added 2026-03-17
Uses `HA_URL` and `HA_TOKEN` from `.env.services`.
| Skill | CLI | Description |
|-------|-----|-------------|
| memory | `memory-ctl` | Store/search/recall memories |
| service-monitor | `monitor-ctl` | Service health checks |
| character | `character-ctl` | Character switching |
| routine | `routine-ctl` | Scenes and multi-step routines |
| music | `music-ctl` | Media player control |
| workflow | `workflow-ctl` | n8n workflow management |
| gitea | `gitea-ctl` | Gitea repo/issue/PR queries |
| calendar | `calendar-ctl` | Calendar events and voice reminders |
| mode | `mode-ctl` | Public/private LLM routing |
### `memory.py`
Wraps mem0 for persistent long-term memory.
**Functions:**
- `remember(text, category=None)` — store a memory
- `recall(query, limit=5)` — semantic search over memories
- `forget(memory_id)` — delete a specific memory
- `list_recent(n=10)` — list most recent memories
mem0 uses `nomic-embed-text` via Ollama for embeddings.
### `weather.py`
Pulls weather data from Home Assistant sensors (local weather station or HA weather integration).
**Functions:**
- `get_current()` → temp, humidity, conditions
- `get_forecast(days=3)` → forecast array
### `timer.py`
Simple timer/reminder management.
**Functions:**
- `set_timer(duration_seconds, label=None)` → fires HA notification/TTS on expiry
- `set_reminder(datetime_str, message)` → schedules future TTS playback
- `list_timers()`
- `cancel_timer(timer_id)`
### `music.py` (stub — completed in Phase 7)
```python
def play(query: str): ... # "play jazz" → Music Assistant
def pause(): ...
def skip(): ...
def set_volume(level: int): ... # 0-100
```
### `vtube_studio.py` (implemented in P7)
Stub in P4, full implementation in P7:
```python
def trigger_expression(event: str): ... # "thinking", "happy", etc.
def set_parameter(name: str, value: float): ...
```
### `comfyui.py` (implemented in P8)
Stub in P4, full implementation in P8:
```python
def generate(workflow: str, params: dict) -> str: ... # returns image path
```
See `SKILLS_GUIDE.md` for full user documentation.
---
## mem0 — Long-Term Memory
## HTTP Bridge
### Setup
**File:** `openclaw-http-bridge.py` (runs in homeai-voice-env)
**Port:** 8081, launchd: `com.homeai.openclaw-bridge`
```bash
pip install mem0ai
```
### Config
```python
from mem0 import Memory
config = {
"llm": {
"provider": "ollama",
"config": {
"model": "llama3.3:70b",
"ollama_base_url": "http://localhost:11434",
}
},
"embedder": {
"provider": "ollama",
"config": {
"model": "nomic-embed-text",
"ollama_base_url": "http://localhost:11434",
}
},
"vector_store": {
"provider": "chroma",
"config": {
"collection_name": "homeai_memory",
"path": "~/.openclaw/memory/chroma",
}
}
}
memory = Memory.from_config(config)
```
> **Decision point:** Start with Chroma (local file-based). If semantic recall quality is poor, migrate to Qdrant (Docker container).
### Backup
Daily cron (via launchd) commits mem0 data to Gitea:
```bash
#!/usr/bin/env bash
cd ~/.openclaw/memory
git add .
git commit -m "mem0 backup $(date +%Y-%m-%d)"
git push origin main
```
---
## n8n Workflows
n8n runs in Docker (deployed in P1). Workflows exported as JSON and stored in `homeai-agent/workflows/`.
### Starter Workflows
**`morning-briefing.json`**
- Trigger: time-based (e.g., 7:30 AM on weekdays)
- Steps: fetch weather → fetch calendar events → compose briefing → POST to OpenClaw TTS → speak aloud
**`notification-router.json`**
- Trigger: HA webhook (new notification)
- Steps: classify urgency → if high: TTS immediately; if low: queue for next interaction
**`memory-backup.json`**
- Trigger: daily schedule
- Steps: commit mem0 data to Gitea
### n8n ↔ OpenClaw Integration
OpenClaw exposes a webhook endpoint that n8n can call to trigger TTS or run a skill:
```
POST http://localhost:8080/speak
{
"text": "Good morning. It is 7:30 and the weather is...",
"room": "all"
}
```
---
## API Surface (OpenClaw)
Key endpoints consumed by other projects:
### Endpoints
| Endpoint | Method | Description |
|---|---|---|
| `/chat` | POST | Send text, get response (+ fires skills) |
| `/wake` | POST | Wake word trigger from openWakeWord |
| `/speak` | POST | TTS only — no LLM, just speak text |
| `/skill/<name>` | POST | Call a specific skill directly |
| `/memory` | GET/POST | Read/write memories |
|----------|--------|-------------|
| `/api/agent/message` | POST | Send message → LLM → response |
| `/api/tts` | POST | Text-to-speech (Kokoro or ElevenLabs) |
| `/api/stt` | POST | Speech-to-text (Wyoming/Whisper) |
| `/wake` | POST | Wake word notification |
| `/status` | GET | Health check |
---
### Request Flow
## Directory Layout
1. Resolve character: explicit `character_id` > `satellite_id` mapping > default
2. Build system prompt: profile fields + metadata + personal/general memories
3. Write TTS config to `active-tts-voice.json`
4. Load mode from `active-mode.json`, resolve model (private → local, public → cloud)
5. Call OpenClaw CLI with `--model` flag if public mode
6. Detect/re-prompt if model promises action but doesn't call exec tool
7. Return response
```
homeai-agent/
├── skills/
│ ├── home_assistant.py
│ ├── memory.py
│ ├── weather.py
│ ├── timer.py
│ ├── music.py # stub
│ ├── vtube_studio.py # stub
│ └── comfyui.py # stub
├── workflows/
│ ├── morning-briefing.json
│ ├── notification-router.json
│ └── memory-backup.json
└── config/
├── config.yaml.example
└── mem0-config.py
```
### Timeout Strategy
| State | Timeout |
|-------|---------|
| Model warm (loaded in VRAM) | 120s |
| Model cold (loading) | 180s |
---
## Interface Contracts
## Daemons
**Consumes:**
- Ollama API: `http://localhost:11434/v1`
- HA API: `$HA_URL` with `$HA_TOKEN`
- Wyoming TTS: `tcp://localhost:10301`
- Character JSON: `~/.openclaw/characters/<active>.json` (from P5)
**Exposes:**
- OpenClaw HTTP API: `http://localhost:8080` — consumed by P3 (voice), P7 (visual triggers), P8 (image skill)
**Add to `.env.services`:**
```dotenv
OPENCLAW_URL=http://localhost:8080
```
| Daemon | Plist | Purpose |
|--------|-------|---------|
| `com.homeai.openclaw` | `launchd/com.homeai.openclaw.plist` | OpenClaw gateway (port 8080) |
| `com.homeai.openclaw-bridge` | `launchd/com.homeai.openclaw-bridge.plist` | HTTP bridge (port 8081) |
| `com.homeai.reminder-daemon` | `launchd/com.homeai.reminder-daemon.plist` | Voice reminder checker (60s interval) |
---
## Implementation Steps
## Data Files
- [ ] Confirm OpenClaw installation method and Ollama compatibility
- [ ] Install OpenClaw, write `config.yaml` pointing at Ollama and HA
- [ ] Verify OpenClaw responds to a basic text query via `/chat`
- [ ] Write `home_assistant.py` skill — test lights on/off via voice
- [ ] Write `memory.py` skill — test store and recall
- [ ] Write `weather.py` skill — verify HA weather sensor data
- [ ] Write `timer.py` skill — test set/fire a timer
- [ ] Write skill stubs: `music.py`, `vtube_studio.py`, `comfyui.py`
- [ ] Set up mem0 with Chroma backend, test semantic recall
- [ ] Write and test memory backup launchd job
- [ ] Deploy n8n via Docker (P1 task if not done)
- [ ] Build morning briefing n8n workflow
- [ ] Symlink `homeai-agent/skills/``~/.openclaw/skills/`
- [ ] Verify full voice → agent → HA action flow (with P3 pipeline)
| File | Purpose |
|------|---------|
| `~/homeai-data/memories/personal/*.json` | Per-character memories |
| `~/homeai-data/memories/general.json` | Shared general memories |
| `~/homeai-data/characters/*.json` | Character profiles (schema v2) |
| `~/homeai-data/satellite-map.json` | Satellite → character mapping |
| `~/homeai-data/active-tts-voice.json` | Current TTS engine/voice |
| `~/homeai-data/active-mode.json` | Public/private mode state |
| `~/homeai-data/routines/*.json` | Local routine definitions |
| `~/homeai-data/reminders.json` | Pending voice reminders |
| `~/homeai-data/conversations/*.json` | Chat conversation history |
---
## Success Criteria
## Environment Variables (OpenClaw Plist)
- [ ] "Turn on the living room lights" → lights turn on via HA
- [ ] "Remember that I prefer jazz in the mornings" → mem0 stores it; "What do I like in the mornings?" → recalls it
- [ ] Morning briefing n8n workflow fires on schedule and speaks via TTS
- [ ] OpenClaw `/status` returns healthy
- [ ] OpenClaw survives Mac Mini reboot (launchd or Docker — TBD based on OpenClaw's preferred run method)
| Variable | Purpose |
|----------|---------|
| `HASS_TOKEN` / `HA_TOKEN` | Home Assistant API token |
| `HA_URL` | Home Assistant URL |
| `GAZE_API_KEY` | Image generation API key |
| `N8N_API_KEY` | n8n automation API key |
| `GITEA_TOKEN` | Gitea API token |
| `ANTHROPIC_API_KEY` | Claude API key (public mode) |
| `OPENAI_API_KEY` | OpenAI API key (public mode) |
---
## Implementation Status
- [x] OpenClaw installed and configured
- [x] HTTP bridge with character resolution and memory injection
- [x] ha-ctl — smart home control
- [x] gaze-ctl — image generation
- [x] vtube-ctl — VTube Studio expressions
- [x] memory-ctl — memory store/search/recall
- [x] monitor-ctl — service health checks
- [x] character-ctl — character switching
- [x] routine-ctl — scenes and multi-step routines
- [x] music-ctl — media player control
- [x] workflow-ctl — n8n workflow triggering
- [x] gitea-ctl — Gitea integration
- [x] calendar-ctl — calendar + voice reminders
- [x] mode-ctl — public/private LLM routing
- [x] Bridge mode routing (active-mode.json → --model flag)
- [x] Cloud providers in openclaw.json (Anthropic, OpenAI)
- [x] Dashboard /api/mode endpoint
- [x] Reminder daemon (com.homeai.reminder-daemon)
- [x] TOOLS.md updated with all skills
- [ ] Set N8N_API_KEY (requires generating in n8n UI)
- [ ] Set GITEA_TOKEN (requires generating in Gitea UI)
- [ ] Set ANTHROPIC_API_KEY / OPENAI_API_KEY for public mode
- [ ] End-to-end voice test of each skill

View File

@@ -0,0 +1,386 @@
# OpenClaw Skills — User Guide
> All skills are invoked by voice or chat. Say a natural command and the AI agent will route it to the right tool automatically.
---
## Quick Reference
| Skill | CLI | What it does |
|-------|-----|-------------|
| Home Assistant | `ha-ctl` | Control lights, switches, sensors, climate |
| Image Generation | `gaze-ctl` | Generate images via ComfyUI/GAZE |
| Memory | `memory-ctl` | Store and recall things about you |
| Service Monitor | `monitor-ctl` | Check if services are running |
| Character Switcher | `character-ctl` | Switch AI personalities |
| Routines & Scenes | `routine-ctl` | Create and trigger multi-step automations |
| Music | `music-ctl` | Play, pause, skip, volume control |
| n8n Workflows | `workflow-ctl` | Trigger automation workflows |
| Gitea | `gitea-ctl` | Query repos, commits, issues |
| Calendar & Reminders | `calendar-ctl` | View calendar, set voice reminders |
| Public/Private Mode | `mode-ctl` | Route to local or cloud LLMs |
---
## Phase A — Core Skills
### Memory (`memory-ctl`)
The agent can remember things about you and recall them later. Memories persist across conversations and are visible in the dashboard.
**Voice examples:**
- "Remember that my favorite color is blue"
- "I take my coffee black"
- "What do you know about me?"
- "Forget that I said I like jazz"
**CLI usage:**
```bash
memory-ctl add personal "User's favorite color is blue" --category preference
memory-ctl add general "Living room speaker is a Sonos" --category fact
memory-ctl search "coffee"
memory-ctl list --type personal
memory-ctl delete <memory_id>
```
**Categories:** `preference`, `fact`, `routine`
**How it works:** Memories are stored as JSON in `~/homeai-data/memories/`. Personal memories are per-character (each character has their own relationship with you). General memories are shared across all characters.
---
### Service Monitor (`monitor-ctl`)
Ask the assistant if everything is healthy, check specific services, or see what models are loaded.
**Voice examples:**
- "Is everything running?"
- "What models are loaded?"
- "Is Home Assistant up?"
- "Show me the Docker containers"
**CLI usage:**
```bash
monitor-ctl status # Full health check (all services)
monitor-ctl check ollama # Single service
monitor-ctl ollama # Models loaded, VRAM usage
monitor-ctl docker # Docker container status
```
**Services checked:** Ollama, OpenClaw Bridge, OpenClaw Gateway, Wyoming STT, Wyoming TTS, Dashboard, n8n, Uptime Kuma, Home Assistant, Gitea
---
### Character Switcher (`character-ctl`)
Switch between AI personalities on the fly. Each character has their own voice, personality, and memories.
**Voice examples:**
- "Talk to Aria"
- "Switch to Sucy"
- "Who can I talk to?"
- "Who am I talking to?"
- "Tell me about Aria"
**CLI usage:**
```bash
character-ctl list # See all characters
character-ctl active # Who is the current default
character-ctl switch "Aria" # Switch (fuzzy name matching)
character-ctl info "Sucy" # Character profile
character-ctl map homeai-kitchen.local aria_123 # Map a satellite to a character
```
**How it works:** Switching updates the default character in `satellite-map.json` and writes the TTS voice config. The new character takes effect on the next request.
---
## Phase B — Home Assistant Extensions
### Routines & Scenes (`routine-ctl`)
Create and trigger Home Assistant scenes and multi-step routines by voice.
**Voice examples:**
- "Activate movie mode"
- "Run the bedtime routine"
- "What scenes do I have?"
- "Create a morning routine"
**CLI usage:**
```bash
routine-ctl list-scenes # HA scenes
routine-ctl list-scripts # HA scripts
routine-ctl trigger "movie_mode" # Activate scene/script
routine-ctl create-scene "cozy" --entities '[{"entity_id":"light.lamp","state":"on","brightness":80}]'
routine-ctl create-routine "bedtime" --steps '[
{"type":"ha","cmd":"off \"All Lights\""},
{"type":"delay","seconds":2},
{"type":"tts","text":"Good night!"}
]'
routine-ctl run "bedtime" # Execute routine
routine-ctl list-routines # List local routines
routine-ctl delete-routine "bedtime" # Remove routine
```
**Step types:**
| Type | Description | Fields |
|------|-------------|--------|
| `scene` | Trigger an HA scene | `target` (scene name) |
| `ha` | Run an ha-ctl command | `cmd` (e.g. `off "Lamp"`) |
| `delay` | Wait between steps | `seconds` |
| `tts` | Speak text aloud | `text` |
**Storage:** Routines are saved as JSON in `~/homeai-data/routines/`.
---
### Music Control (`music-ctl`)
Control music playback through Home Assistant media players — works with Spotify, Music Assistant, Chromecast, and any HA media player.
**Voice examples:**
- "Play some jazz"
- "Pause the music"
- "Next song"
- "What's playing?"
- "Turn the volume to 50"
- "Play Bohemian Rhapsody on the kitchen speaker"
- "Shuffle on"
**CLI usage:**
```bash
music-ctl players # List available players
music-ctl play "jazz" # Search and play
music-ctl play # Resume paused playback
music-ctl pause # Pause
music-ctl next # Skip to next
music-ctl prev # Go to previous
music-ctl volume 50 # Set volume (0-100)
music-ctl now-playing # Current track info
music-ctl shuffle on # Enable shuffle
music-ctl play "rock" --player media_player.kitchen # Target specific player
```
**How it works:** All commands go through HA's `media_player` services. The `--player` flag defaults to the first active (playing/paused) player. Multi-room audio works through Snapcast zones, which appear as separate `media_player` entities.
**Prerequisites:** At least one media player configured in Home Assistant (Spotify integration, Music Assistant, or Chromecast).
---
## Phase C — External Service Skills
### n8n Workflows (`workflow-ctl`)
List and trigger n8n automation workflows by voice.
**Voice examples:**
- "Run the backup workflow"
- "What workflows do I have?"
- "Did the last workflow succeed?"
**CLI usage:**
```bash
workflow-ctl list # All workflows
workflow-ctl trigger "backup" # Trigger by name (fuzzy match)
workflow-ctl trigger "abc123" --data '{"key":"val"}' # Trigger with data
workflow-ctl status <execution_id> # Check execution result
workflow-ctl history --limit 5 # Recent executions
```
**Setup required:**
1. Generate an API key in n8n: Settings → API → Create API Key
2. Set `N8N_API_KEY` in the OpenClaw launchd plist
3. Restart OpenClaw: `launchctl kickstart -k gui/501/com.homeai.openclaw`
---
### Gitea (`gitea-ctl`)
Query your self-hosted Gitea repositories, commits, issues, and pull requests.
**Voice examples:**
- "What repos do I have?"
- "Show recent commits for homeai"
- "Any open issues?"
- "Create an issue for the TTS bug"
**CLI usage:**
```bash
gitea-ctl repos # List all repos
gitea-ctl commits aodhan/homeai --limit 5 # Recent commits
gitea-ctl issues aodhan/homeai --state open # Open issues
gitea-ctl prs aodhan/homeai # Pull requests
gitea-ctl create-issue aodhan/homeai "Bug title" --body "Description here"
```
**Setup required:**
1. Generate a token in Gitea: Settings → Applications → Generate Token
2. Set `GITEA_TOKEN` in the OpenClaw launchd plist
3. Restart OpenClaw
---
### Calendar & Reminders (`calendar-ctl`)
Read calendar events from Home Assistant and set voice reminders that speak via TTS when due.
**Voice examples:**
- "What's on my calendar today?"
- "What's coming up this week?"
- "Remind me in 30 minutes to check the oven"
- "Remind me at 5pm to call mum"
- "What reminders do I have?"
- "Cancel that reminder"
**CLI usage:**
```bash
calendar-ctl today # Today's events
calendar-ctl upcoming --days 3 # Next 3 days
calendar-ctl add "Dentist" --start 2026-03-18T14:00:00 --end 2026-03-18T15:00:00
calendar-ctl remind "Check the oven" --at "in 30 minutes"
calendar-ctl remind "Call mum" --at "at 5pm"
calendar-ctl remind "Team standup" --at "tomorrow 9am"
calendar-ctl reminders # List pending
calendar-ctl cancel-reminder <id> # Cancel
```
**Supported time formats:**
| Format | Example |
|--------|---------|
| Relative | `in 30 minutes`, `in 2 hours` |
| Absolute | `at 5pm`, `at 17:00`, `at 5:30pm` |
| Tomorrow | `tomorrow 9am`, `tomorrow at 14:00` |
| Combined | `in 1 hour 30 minutes` |
**How reminders work:** A background daemon (`com.homeai.reminder-daemon`) checks `~/homeai-data/reminders.json` every 60 seconds. When a reminder is due, it POSTs to the TTS bridge and speaks the reminder aloud. Fired reminders are automatically cleaned up after 24 hours.
**Prerequisites:** Calendar entity configured in Home Assistant (Google Calendar, CalDAV, or local calendar integration).
---
## Phase D — Public/Private Mode
### Mode Controller (`mode-ctl`)
Route AI requests to local LLMs (private, no data leaves the machine) or cloud LLMs (public, faster/more capable) with per-category overrides.
**Voice examples:**
- "Switch to public mode"
- "Go private"
- "What mode am I in?"
- "Use Claude for coding"
- "Keep health queries private"
**CLI usage:**
```bash
mode-ctl status # Current mode and overrides
mode-ctl private # All requests → local Ollama
mode-ctl public # All requests → cloud LLM
mode-ctl set-provider anthropic # Use Claude (default)
mode-ctl set-provider openai # Use GPT-4o
mode-ctl override coding public # Always use cloud for coding
mode-ctl override health private # Always keep health local
mode-ctl list-overrides # Show all category rules
```
**Default category rules:**
| Always Private | Always Public | Follows Global Mode |
|---------------|--------------|-------------------|
| Personal finance | Web search | General chat |
| Health | Coding help | Smart home |
| Passwords | Complex reasoning | Music |
| Private conversations | Translation | Calendar |
**How it works:** The HTTP bridge reads `~/homeai-data/active-mode.json` before each request. Based on the mode and any category overrides, it passes `--model` to the OpenClaw CLI to route to either `ollama/qwen3.5:35b-a3b` (private) or `anthropic/claude-sonnet-4-20250514` / `openai/gpt-4o` (public).
**Setup required for public mode:**
1. Set `ANTHROPIC_API_KEY` and/or `OPENAI_API_KEY` in the OpenClaw launchd plist
2. Restart OpenClaw: `launchctl kickstart -k gui/501/com.homeai.openclaw`
**Dashboard:** The mode can also be toggled via the dashboard API at `GET/POST /api/mode`.
---
## Administration
### Adding API Keys
All API keys are stored in the OpenClaw launchd plist at:
```
~/gitea/homeai/homeai-agent/launchd/com.homeai.openclaw.plist
```
After editing, deploy and restart:
```bash
cp ~/gitea/homeai/homeai-agent/launchd/com.homeai.openclaw.plist ~/Library/LaunchAgents/
launchctl kickstart -k gui/501/com.homeai.openclaw
```
### Environment Variables
| Variable | Purpose | Required for |
|----------|---------|-------------|
| `HASS_TOKEN` | Home Assistant API token | ha-ctl, routine-ctl, music-ctl, calendar-ctl |
| `HA_URL` | Home Assistant URL | Same as above |
| `GAZE_API_KEY` | Image generation API key | gaze-ctl |
| `N8N_API_KEY` | n8n automation API key | workflow-ctl |
| `GITEA_TOKEN` | Gitea API token | gitea-ctl |
| `ANTHROPIC_API_KEY` | Claude API key | mode-ctl (public mode) |
| `OPENAI_API_KEY` | OpenAI API key | mode-ctl (public mode) |
### Skill File Locations
```
~/.openclaw/skills/
├── home-assistant/ ha-ctl → /opt/homebrew/bin/ha-ctl
├── image-generation/ gaze-ctl → /opt/homebrew/bin/gaze-ctl
├── memory/ memory-ctl → /opt/homebrew/bin/memory-ctl
├── service-monitor/ monitor-ctl → /opt/homebrew/bin/monitor-ctl
├── character/ character-ctl → /opt/homebrew/bin/character-ctl
├── routine/ routine-ctl → /opt/homebrew/bin/routine-ctl
├── music/ music-ctl → /opt/homebrew/bin/music-ctl
├── workflow/ workflow-ctl → /opt/homebrew/bin/workflow-ctl
├── gitea/ gitea-ctl → /opt/homebrew/bin/gitea-ctl
├── calendar/ calendar-ctl → /opt/homebrew/bin/calendar-ctl
├── mode/ mode-ctl → /opt/homebrew/bin/mode-ctl
├── voice-assistant/ (no CLI)
└── vtube-studio/ vtube-ctl → /opt/homebrew/bin/vtube-ctl
```
### Data File Locations
| File | Purpose |
|------|---------|
| `~/homeai-data/memories/personal/*.json` | Per-character memories |
| `~/homeai-data/memories/general.json` | Shared general memories |
| `~/homeai-data/characters/*.json` | Character profiles |
| `~/homeai-data/satellite-map.json` | Satellite → character mapping |
| `~/homeai-data/active-tts-voice.json` | Current TTS voice config |
| `~/homeai-data/active-mode.json` | Public/private mode state |
| `~/homeai-data/routines/*.json` | Local routine definitions |
| `~/homeai-data/reminders.json` | Pending voice reminders |
| `~/homeai-data/conversations/*.json` | Chat conversation history |
### Creating a New Skill
Every skill follows the same pattern:
1. Create directory: `~/.openclaw/skills/<name>/`
2. Write `SKILL.md` with YAML frontmatter (`name`, `description`) + usage docs
3. Create Python CLI (stdlib only: `urllib.request`, `json`, `os`, `sys`, `re`, `datetime`)
4. `chmod +x` the CLI and symlink to `/opt/homebrew/bin/`
5. Add env vars to the OpenClaw launchd plist if needed
6. Add a section to `~/.openclaw/workspace/TOOLS.md`
7. Restart OpenClaw: `launchctl kickstart -k gui/501/com.homeai.openclaw`
8. Test: `openclaw agent --message "test prompt" --agent main`
### Daemons
| Daemon | Plist | Purpose |
|--------|-------|---------|
| `com.homeai.reminder-daemon` | `homeai-agent/launchd/com.homeai.reminder-daemon.plist` | Fires TTS reminders when due |
| `com.homeai.openclaw` | `homeai-agent/launchd/com.homeai.openclaw.plist` | OpenClaw gateway |
| `com.homeai.openclaw-bridge` | `homeai-agent/launchd/com.homeai.openclaw-bridge.plist` | HTTP bridge (voice pipeline) |
| `com.homeai.preload-models` | `homeai-llm/scripts/preload-models.sh` | Keeps models warm in VRAM |

View File

@@ -37,6 +37,8 @@
<string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin</string>
<key>ELEVENLABS_API_KEY</key>
<string>sk_ec10e261c6190307a37aa161a9583504dcf25a0cabe5dbd5</string>
<key>ANTHROPIC_API_KEY</key>
<string>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</string>
</dict>
</dict>
</plist>

View File

@@ -30,6 +30,18 @@
<string>eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJmZGQ1NzZlYWNkMTU0ZTY2ODY1OTkzYTlhNTIxM2FmNyIsImlhdCI6MTc3MjU4ODYyOCwiZXhwIjoyMDg3OTQ4NjI4fQ.CTAU1EZgpVLp_aRnk4vg6cQqwS5N-p8jQkAAXTxFmLY</string>
<key>GAZE_API_KEY</key>
<string>e63401f17e4845e1059f830267f839fe7fc7b6083b1cb1730863318754d799f4</string>
<key>N8N_URL</key>
<string>http://localhost:5678</string>
<key>N8N_API_KEY</key>
<string></string>
<key>GITEA_URL</key>
<string>http://10.0.0.199:3000</string>
<key>GITEA_TOKEN</key>
<string></string>
<key>ANTHROPIC_API_KEY</key>
<string>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</string>
<key>OPENAI_API_KEY</key>
<string></string>
</dict>
<key>RunAtLoad</key>

View File

@@ -0,0 +1,30 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.homeai.reminder-daemon</string>
<key>ProgramArguments</key>
<array>
<string>/Users/aodhan/homeai-voice-env/bin/python3</string>
<string>/Users/aodhan/gitea/homeai/homeai-agent/reminder-daemon.py</string>
</array>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
<key>StandardOutPath</key>
<string>/tmp/homeai-reminder-daemon.log</string>
<key>StandardErrorPath</key>
<string>/tmp/homeai-reminder-daemon-error.log</string>
<key>ThrottleInterval</key>
<integer>10</integer>
</dict>
</plist>

View File

@@ -48,6 +48,7 @@ TIMEOUT_WARM = 120 # Model already loaded in VRAM
TIMEOUT_COLD = 180 # Model needs loading first (~10-20s load + inference)
OLLAMA_PS_URL = "http://localhost:11434/api/ps"
VTUBE_BRIDGE_URL = "http://localhost:8002"
DEFAULT_MODEL = "anthropic/claude-sonnet-4-20250514"
def _vtube_fire_and_forget(path: str, data: dict):
@@ -83,6 +84,31 @@ CHARACTERS_DIR = Path("/Users/aodhan/homeai-data/characters")
SATELLITE_MAP_PATH = Path("/Users/aodhan/homeai-data/satellite-map.json")
MEMORIES_DIR = Path("/Users/aodhan/homeai-data/memories")
ACTIVE_TTS_VOICE_PATH = Path("/Users/aodhan/homeai-data/active-tts-voice.json")
ACTIVE_MODE_PATH = Path("/Users/aodhan/homeai-data/active-mode.json")
# Cloud provider model mappings for mode routing
CLOUD_MODELS = {
"anthropic": "anthropic/claude-sonnet-4-20250514",
"openai": "openai/gpt-4o",
}
def load_mode() -> dict:
"""Load the public/private mode configuration."""
try:
with open(ACTIVE_MODE_PATH) as f:
return json.load(f)
except Exception:
return {"mode": "private", "cloud_provider": "anthropic", "overrides": {}}
def resolve_model(mode_data: dict) -> str | None:
"""Resolve which model to use based on mode. Returns None for default (private/local)."""
mode = mode_data.get("mode", "private")
if mode == "private":
return None # Use OpenClaw default (ollama/qwen3.5:35b-a3b)
provider = mode_data.get("cloud_provider", "anthropic")
return CLOUD_MODELS.get(provider, CLOUD_MODELS["anthropic"])
def clean_text_for_tts(text: str) -> str:
@@ -505,10 +531,13 @@ class OpenClawBridgeHandler(BaseHTTPRequestHandler):
self._send_json_response(200, {"status": "ok", "message": "Wake word received"})
@staticmethod
def _call_openclaw(message: str, agent: str, timeout: int) -> str:
def _call_openclaw(message: str, agent: str, timeout: int, model: str = None) -> str:
"""Call OpenClaw CLI and return stdout."""
cmd = ["/opt/homebrew/bin/openclaw", "agent", "--message", message, "--agent", agent]
if model:
cmd.extend(["--model", model])
result = subprocess.run(
["/opt/homebrew/bin/openclaw", "agent", "--message", message, "--agent", agent],
cmd,
capture_output=True,
text=True,
timeout=timeout,
@@ -587,6 +616,15 @@ class OpenClawBridgeHandler(BaseHTTPRequestHandler):
if system_prompt:
message = f"System Context: {system_prompt}\n\nUser Request: {message}"
# Load mode and resolve model routing
mode_data = load_mode()
model_override = resolve_model(mode_data)
active_model = model_override or DEFAULT_MODEL
if model_override:
print(f"[OpenClaw Bridge] Mode: PUBLIC → {model_override}")
else:
print(f"[OpenClaw Bridge] Mode: PRIVATE ({active_model})")
# Check if model is warm to set appropriate timeout
warm = is_model_warm()
timeout = TIMEOUT_WARM if warm else TIMEOUT_COLD
@@ -597,7 +635,7 @@ class OpenClawBridgeHandler(BaseHTTPRequestHandler):
# Call OpenClaw CLI (use full path for launchd compatibility)
try:
response_text = self._call_openclaw(message, agent, timeout)
response_text = self._call_openclaw(message, agent, timeout, model=model_override)
# Re-prompt if the model promised to act but didn't call a tool.
# Detect "I'll do X" / "Let me X" responses that lack any result.
@@ -607,11 +645,11 @@ class OpenClawBridgeHandler(BaseHTTPRequestHandler):
"You just said you would do something but didn't actually call the exec tool. "
"Do NOT explain what you will do — call the tool NOW using exec and return the result."
)
response_text = self._call_openclaw(followup, agent, timeout)
response_text = self._call_openclaw(followup, agent, timeout, model=model_override)
# Signal avatar: idle (TTS handler will override to 'speaking' if voice is used)
_vtube_fire_and_forget("/expression", {"event": "idle"})
self._send_json_response(200, {"response": response_text})
self._send_json_response(200, {"response": response_text, "model": active_model})
except subprocess.TimeoutExpired:
self._send_json_response(504, {"error": f"OpenClaw command timed out after {timeout}s (model was {'warm' if warm else 'cold'})"})
except subprocess.CalledProcessError as e:

90
homeai-agent/reminder-daemon.py Executable file
View File

@@ -0,0 +1,90 @@
#!/usr/bin/env python3
"""
HomeAI Reminder Daemon — checks ~/homeai-data/reminders.json every 60s
and fires TTS via POST http://localhost:8081/api/tts when reminders are due.
"""
import json
import os
import time
import urllib.request
from datetime import datetime
REMINDERS_FILE = os.path.expanduser("~/homeai-data/reminders.json")
TTS_URL = "http://localhost:8081/api/tts"
CHECK_INTERVAL = 60 # seconds
def load_reminders():
try:
with open(REMINDERS_FILE) as f:
return json.load(f)
except (FileNotFoundError, json.JSONDecodeError):
return {"reminders": []}
def save_reminders(data):
with open(REMINDERS_FILE, "w") as f:
json.dump(data, f, indent=2)
def fire_tts(message):
"""Speak reminder via the OpenClaw bridge TTS endpoint."""
try:
payload = json.dumps({"text": f"Reminder: {message}"}).encode()
req = urllib.request.Request(
TTS_URL,
data=payload,
headers={"Content-Type": "application/json"},
method="POST"
)
urllib.request.urlopen(req, timeout=30)
print(f"[{datetime.now().isoformat()}] TTS fired: {message}")
return True
except Exception as e:
print(f"[{datetime.now().isoformat()}] TTS error: {e}")
return False
def check_reminders():
data = load_reminders()
now = datetime.now()
changed = False
for r in data.get("reminders", []):
if r.get("fired"):
continue
try:
due = datetime.fromisoformat(r["due_at"])
except (KeyError, ValueError):
continue
if now >= due:
print(f"[{now.isoformat()}] Reminder due: {r.get('message', '?')}")
fire_tts(r["message"])
r["fired"] = True
changed = True
if changed:
# Clean up fired reminders older than 24h
cutoff = (now.timestamp() - 86400) * 1000
data["reminders"] = [
r for r in data["reminders"]
if not r.get("fired") or int(r.get("id", "0")) > cutoff
]
save_reminders(data)
def main():
print(f"[{datetime.now().isoformat()}] Reminder daemon started (check every {CHECK_INTERVAL}s)")
while True:
try:
check_reminders()
except Exception as e:
print(f"[{datetime.now().isoformat()}] Error: {e}")
time.sleep(CHECK_INTERVAL)
if __name__ == "__main__":
main()

230
homeai-agent/setup.sh Normal file → Executable file
View File

@@ -1,17 +1,20 @@
#!/usr/bin/env bash
# homeai-agent/setup.sh — P4: OpenClaw agent + skills + mem0
# homeai-agent/setup.sh — OpenClaw agent, HTTP bridge, skills, reminder daemon
#
# Components:
# - OpenClaw — AI agent runtime (port 8080)
# - skills/ — home_assistant, memory, weather, timer, music stubs
# - mem0long-term memory (Chroma backend)
# - n8n workflows — morning briefing, notification router, memory backup
# - OpenClaw gateway — AI agent runtime (port 8080)
# - OpenClaw HTTP bridge — HA ↔ OpenClaw translator (port 8081)
# - 13 skillshome-assistant, image-generation, voice-assistant,
# vtube-studio, memory, service-monitor, character,
# routine, music, workflow, gitea, calendar, mode
# - Reminder daemon — fires TTS when reminders are due
#
# Prerequisites:
# - P1 (homeai-infra) — Home Assistant running, HA_TOKEN set
# - P2 (homeai-llm) — Ollama running with llama3.3:70b + nomic-embed-text
# - P3 (homeai-voice) — Wyoming TTS running (for voice output)
# - P5 (homeai-character) — aria.json character config exists
# - Ollama running (port 11434)
# - Home Assistant reachable (HA_TOKEN set in .env)
# - Wyoming TTS running (port 10301)
# - homeai-voice-env venv exists (for bridge + reminder daemon)
# - At least one character JSON in ~/homeai-data/characters/
set -euo pipefail
@@ -19,47 +22,196 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
source "${REPO_DIR}/scripts/common.sh"
log_section "P4: Agent (OpenClaw + skills + mem0)"
log_section "P4: Agent (OpenClaw + HTTP Bridge + Skills)"
detect_platform
# ─── Prerequisite check ────────────────────────────────────────────────────────
# ─── Load environment ────────────────────────────────────────────────────────
ENV_FILE="${REPO_DIR}/.env"
if [[ -f "$ENV_FILE" ]]; then
log_info "Loading .env..."
load_env "$ENV_FILE"
else
log_warn "No .env found at ${ENV_FILE} — API keys may be missing"
fi
# ─── Prerequisite checks ────────────────────────────────────────────────────
log_info "Checking prerequisites..."
for service in "http://localhost:11434:Ollama(P2)" "http://localhost:8123:HomeAssistant(P1)"; do
url="${service%%:*}"; name="${service##*:}"
if ! curl -sf "$url" -o /dev/null 2>/dev/null; then
require_command node "brew install node"
require_command openclaw "npm install -g openclaw"
VOICE_ENV="${HOME}/homeai-voice-env"
if [[ ! -d "$VOICE_ENV" ]]; then
die "homeai-voice-env not found at $VOICE_ENV — run homeai-voice/setup.sh first"
fi
# Check key services (non-fatal)
for check in "http://localhost:11434:Ollama" "http://localhost:10301:Wyoming-TTS"; do
url="${check%%:*}"; name="${check##*:}"
if curl -sf "$url" -o /dev/null 2>/dev/null; then
log_success "$name reachable"
else
log_warn "$name not reachable at $url"
fi
done
load_env_services
if [[ -z "${HA_TOKEN:-}" ]]; then
log_warn "HA_TOKEN not set in ~/.env.services — needed for home_assistant skill"
# Check required env vars
MISSING_KEYS=()
[[ -z "${HA_TOKEN:-}" ]] && MISSING_KEYS+=("HA_TOKEN")
[[ -z "${ANTHROPIC_API_KEY:-}" ]] && MISSING_KEYS+=("ANTHROPIC_API_KEY")
if [[ ${#MISSING_KEYS[@]} -gt 0 ]]; then
log_warn "Missing env vars: ${MISSING_KEYS[*]} — set these in ${ENV_FILE}"
fi
# ─── TODO: Implementation ──────────────────────────────────────────────────────
# ─── Ensure data directories ─────────────────────────────────────────────────
DATA_DIR="${HOME}/homeai-data"
for dir in characters memories memories/personal conversations routines; do
mkdir -p "${DATA_DIR}/${dir}"
done
log_success "Data directories verified"
# ─── OpenClaw config ─────────────────────────────────────────────────────────
OPENCLAW_DIR="${HOME}/.openclaw"
OPENCLAW_CONFIG="${OPENCLAW_DIR}/openclaw.json"
if [[ ! -f "$OPENCLAW_CONFIG" ]]; then
die "OpenClaw config not found at $OPENCLAW_CONFIG — run: openclaw doctor --fix"
fi
log_success "OpenClaw config exists at $OPENCLAW_CONFIG"
# Verify Anthropic provider is configured
if ! grep -q '"anthropic"' "$OPENCLAW_CONFIG" 2>/dev/null; then
log_warn "Anthropic provider not found in openclaw.json — add it for Claude support"
fi
# ─── Install skills ──────────────────────────────────────────────────────────
SKILLS_SRC="${SCRIPT_DIR}/skills"
SKILLS_DEST="${OPENCLAW_DIR}/skills"
if [[ -d "$SKILLS_SRC" ]]; then
log_info "Syncing skills..."
mkdir -p "$SKILLS_DEST"
for skill_dir in "$SKILLS_SRC"/*/; do
skill_name="$(basename "$skill_dir")"
dest="${SKILLS_DEST}/${skill_name}"
if [[ -L "$dest" ]]; then
log_info " ${skill_name} (symlinked)"
elif [[ -d "$dest" ]]; then
# Replace copy with symlink
rm -rf "$dest"
ln -s "$skill_dir" "$dest"
log_step "${skill_name} → symlinked"
else
ln -s "$skill_dir" "$dest"
log_step "${skill_name} → installed"
fi
done
log_success "Skills synced ($(ls -d "$SKILLS_DEST"/*/ 2>/dev/null | wc -l | tr -d ' ') total)"
else
log_warn "No skills directory at $SKILLS_SRC"
fi
# ─── Install launchd services (macOS) ────────────────────────────────────────
if [[ "$OS_TYPE" == "macos" ]]; then
log_info "Installing launchd agents..."
LAUNCHD_DIR="${SCRIPT_DIR}/launchd"
AGENTS_DIR="${HOME}/Library/LaunchAgents"
mkdir -p "$AGENTS_DIR"
# Inject API keys into plists that need them
_inject_plist_key() {
local plist="$1" key="$2" value="$3"
if [[ -n "$value" ]] && grep -q "<key>${key}</key>" "$plist" 2>/dev/null; then
# Use python for reliable XML-safe replacement
python3 -c "
import sys, re
with open('$plist') as f: content = f.read()
pattern = r'(<key>${key}</key>\s*<string>)[^<]*(</string>)'
content = re.sub(pattern, r'\g<1>${value}\g<2>', content)
with open('$plist', 'w') as f: f.write(content)
"
fi
}
# Update API keys in plist source files before linking
OPENCLAW_PLIST="${LAUNCHD_DIR}/com.homeai.openclaw.plist"
BRIDGE_PLIST="${LAUNCHD_DIR}/com.homeai.openclaw-bridge.plist"
if [[ -f "$OPENCLAW_PLIST" ]]; then
_inject_plist_key "$OPENCLAW_PLIST" "ANTHROPIC_API_KEY" "${ANTHROPIC_API_KEY:-}"
_inject_plist_key "$OPENCLAW_PLIST" "OPENAI_API_KEY" "${OPENAI_API_KEY:-}"
_inject_plist_key "$OPENCLAW_PLIST" "HA_TOKEN" "${HA_TOKEN:-}"
_inject_plist_key "$OPENCLAW_PLIST" "HASS_TOKEN" "${HA_TOKEN:-}"
_inject_plist_key "$OPENCLAW_PLIST" "GITEA_TOKEN" "${GITEA_TOKEN:-}"
_inject_plist_key "$OPENCLAW_PLIST" "N8N_API_KEY" "${N8N_API_KEY:-}"
fi
if [[ -f "$BRIDGE_PLIST" ]]; then
_inject_plist_key "$BRIDGE_PLIST" "ANTHROPIC_API_KEY" "${ANTHROPIC_API_KEY:-}"
_inject_plist_key "$BRIDGE_PLIST" "ELEVENLABS_API_KEY" "${ELEVENLABS_API_KEY:-}"
fi
# Symlink and load each plist
for plist in "$LAUNCHD_DIR"/*.plist; do
[[ ! -f "$plist" ]] && continue
plist_name="$(basename "$plist")"
plist_label="${plist_name%.plist}"
dest="${AGENTS_DIR}/${plist_name}"
# Unload if already running
launchctl bootout "gui/$(id -u)/${plist_label}" 2>/dev/null || true
# Symlink source → LaunchAgents
ln -sf "$(cd "$(dirname "$plist")" && pwd)/${plist_name}" "$dest"
# Load
launchctl bootstrap "gui/$(id -u)" "$dest" 2>/dev/null && \
log_success " ${plist_label} → loaded" || \
log_warn " ${plist_label} → failed to load (check: launchctl print gui/$(id -u)/${plist_label})"
done
fi
# ─── Smoke test ──────────────────────────────────────────────────────────────
log_info "Running smoke tests..."
sleep 2 # Give services a moment to start
# Check gateway
if curl -sf "http://localhost:8080" -o /dev/null 2>/dev/null; then
log_success "OpenClaw gateway responding on :8080"
else
log_warn "OpenClaw gateway not responding on :8080 — check: tail /tmp/homeai-openclaw.log"
fi
# Check bridge
if curl -sf "http://localhost:8081/status" -o /dev/null 2>/dev/null; then
log_success "HTTP bridge responding on :8081"
else
log_warn "HTTP bridge not responding on :8081 — check: tail /tmp/homeai-openclaw-bridge.log"
fi
# ─── Summary ─────────────────────────────────────────────────────────────────
print_summary "Agent Setup Complete" \
"OpenClaw gateway" "http://localhost:8080" \
"HTTP bridge" "http://localhost:8081" \
"OpenClaw config" "$OPENCLAW_CONFIG" \
"Skills directory" "$SKILLS_DEST" \
"Character data" "${DATA_DIR}/characters/" \
"Memory data" "${DATA_DIR}/memories/" \
"Reminder data" "${DATA_DIR}/reminders.json" \
"Gateway log" "/tmp/homeai-openclaw.log" \
"Bridge log" "/tmp/homeai-openclaw-bridge.log"
cat <<'EOF'
┌─────────────────────────────────────────────────────────────────┐
P4: homeai-agent — NOT YET IMPLEMENTED │
│ │
│ OPEN QUESTION: Which OpenClaw version/fork to use? │
│ Decide before implementing. See homeai-agent/PLAN.md. │
│ │
Implementation steps: │
1. Install OpenClaw (pip install or git clone) │
│ 2. Create ~/.openclaw/config.yaml from config/config.yaml.example │
│ 3. Create skills: home_assistant, memory, weather, timer, music│
│ 4. Install mem0 + Chroma backend │
│ 5. Create systemd/launchd service for OpenClaw (port 8080) │
│ 6. Import n8n workflows from workflows/ │
│ 7. Smoke test: POST /chat "turn on living room lights" │
│ │
│ Interface contracts: │
│ OPENCLAW_URL=http://localhost:8080 │
└─────────────────────────────────────────────────────────────────┘
To reload a service after editing its plist:
launchctl bootout gui/$(id -u)/com.homeai.<service>
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.homeai.<service>.plist
To test the agent:
curl -X POST http://localhost:8081/api/agent/message \
-H 'Content-Type: application/json' \
-d '{"message":"say hello","agent":"main"}'
EOF
log_info "P4 is not yet implemented. See homeai-agent/PLAN.md for details."
exit 0

View File

@@ -107,16 +107,25 @@ export default function MessageBubble({ message, onReplay, character }) {
>
{isUser ? message.content : <RichContent text={message.content} />}
</div>
{!isUser && !message.isError && onReplay && (
<button
onClick={() => onReplay(message.content)}
className="mt-1 ml-1 text-gray-500 hover:text-indigo-400 transition-colors"
title="Replay audio"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15.536 8.464a5 5 0 010 7.072M17.95 6.05a8 8 0 010 11.9M6.5 9H4a1 1 0 00-1 1v4a1 1 0 001 1h2.5l4 4V5l-4 4z" />
</svg>
</button>
{!isUser && (
<div className="flex items-center gap-2 mt-1 ml-1">
{message.model && (
<span className="text-[10px] text-gray-500 font-mono">
{message.model}
</span>
)}
{!message.isError && onReplay && (
<button
onClick={() => onReplay(message.content)}
className="text-gray-500 hover:text-indigo-400 transition-colors"
title="Replay audio"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15.536 8.464a5 5 0 010 7.072M17.95 6.05a8 8 0 010 11.9M6.5 9H4a1 1 0 00-1 1v4a1 1 0 001 1h2.5l4 4V5l-4 4z" />
</svg>
</button>
)}
</div>
)}
</div>
</div>

View File

@@ -80,12 +80,13 @@ export function useChat(conversationId, conversationMeta, onConversationUpdate)
setIsLoading(true)
try {
const response = await sendMessage(text.trim(), conversationMeta?.characterId || null)
const { response, model } = await sendMessage(text.trim(), conversationMeta?.characterId || null)
const assistantMsg = {
id: Date.now() + 1,
role: 'assistant',
content: response,
timestamp: new Date().toISOString(),
...(model && { model }),
}
const allMessages = [...newMessages, assistantMsg]
setMessages(allMessages)

View File

@@ -31,7 +31,7 @@ export async function sendMessage(text, characterId = null) {
throw new Error(err.error || `HTTP ${res.status}`)
}
const data = await res.json()
return data.response
return { response: data.response, model: data.model || null }
}
export async function synthesize(text, voice, engine = 'kokoro', model = null) {

View File

@@ -6,6 +6,7 @@ const CHARACTERS_DIR = '/Users/aodhan/homeai-data/characters'
const SATELLITE_MAP_PATH = '/Users/aodhan/homeai-data/satellite-map.json'
const CONVERSATIONS_DIR = '/Users/aodhan/homeai-data/conversations'
const MEMORIES_DIR = '/Users/aodhan/homeai-data/memories'
const MODE_PATH = '/Users/aodhan/homeai-data/active-mode.json'
const GAZE_HOST = 'http://10.0.0.101:5782'
const GAZE_API_KEY = process.env.GAZE_API_KEY || ''
@@ -649,6 +650,54 @@ print(json.dumps(c.model_dump(), default=str))
}
}
function modePlugin() {
return {
name: 'mode-api',
configureServer(server) {
server.middlewares.use('/api/mode', async (req, res, next) => {
if (req.method === 'OPTIONS') {
res.writeHead(204, { 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Methods': 'GET,POST', 'Access-Control-Allow-Headers': 'Content-Type' })
res.end()
return
}
const { readFile, writeFile } = await import('fs/promises')
const DEFAULT_MODE = { mode: 'private', cloud_provider: 'anthropic', cloud_model: 'claude-sonnet-4-20250514', local_model: 'ollama/qwen3.5:35b-a3b', overrides: {}, updated_at: '' }
if (req.method === 'GET') {
try {
const raw = await readFile(MODE_PATH, 'utf-8')
res.writeHead(200, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' })
res.end(raw)
} catch {
res.writeHead(200, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' })
res.end(JSON.stringify(DEFAULT_MODE))
}
return
}
if (req.method === 'POST') {
try {
const chunks = []
for await (const chunk of req) chunks.push(chunk)
const data = JSON.parse(Buffer.concat(chunks).toString())
data.updated_at = new Date().toISOString()
await writeFile(MODE_PATH, JSON.stringify(data, null, 2))
res.writeHead(200, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' })
res.end(JSON.stringify({ ok: true }))
} catch (err) {
res.writeHead(500, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ error: err.message }))
}
return
}
next()
})
},
}
}
function bridgeProxyPlugin() {
return {
name: 'bridge-proxy',
@@ -725,6 +774,7 @@ export default defineConfig({
gazeProxyPlugin(),
characterLookupPlugin(),
healthCheckPlugin(),
modePlugin(),
bridgeProxyPlugin(),
tailwindcss(),
react(),

View File

@@ -85,6 +85,7 @@ services:
- homeai.service=n8n
- homeai.url=http://localhost:5678
networks:
homeai:
external: true

389
plans/OPENCLAW_SKILLS.md Normal file
View File

@@ -0,0 +1,389 @@
# OpenClaw Skills Expansion Plan
## Context
The HomeAI project has 4 custom OpenClaw skills (home-assistant, voice-assistant, image-generation, vtube-studio) and a working voice pipeline. The user wants to build 8 new skills plus a Public/Private mode system to dramatically expand what the assistant can do via voice and chat.
## Skill Format Convention
Every skill follows the established pattern from ha-ctl:
- Lives in `~/.openclaw/skills/<name>/`
- `SKILL.md` with YAML frontmatter (name, description) + agent instructions
- Optional Python CLI (stdlib only: `urllib.request`, `json`, `os`, `sys`, `re`, `datetime`)
- CLI symlinked to `/opt/homebrew/bin/` for PATH access
- Agent invokes via `exec` tool
- Entry added to `~/.openclaw/workspace/TOOLS.md` for reinforcement
- New env vars added to `homeai-agent/launchd/com.homeai.openclaw.plist`
---
## Phase A — Core Skills (no new services needed)
### 1. Memory Recall (`memory-ctl`)
**Purpose:** Let the agent actively store, search, and recall memories mid-conversation.
**Files:**
- `~/.openclaw/skills/memory/SKILL.md`
- `~/.openclaw/skills/memory/memory-ctl` → symlink `/opt/homebrew/bin/memory-ctl`
**Commands:**
```
memory-ctl add <personal|general> "<content>" [--category preference|fact|routine] [--character-id ID]
memory-ctl search "<query>" [--type personal|general] [--character-id ID]
memory-ctl list [--type personal|general] [--character-id ID] [--limit 10]
memory-ctl delete <memory_id> [--type personal|general] [--character-id ID]
```
**Details:**
- Reads/writes existing files: `~/homeai-data/memories/personal/{char_id}.json` and `general.json`
- Matches existing schema: `{"memories": [{"id": "m_<timestamp>", "content": "...", "category": "...", "createdAt": "..."}]}`
- Search: keyword token matching (split query, score by substring hits in content)
- `--character-id` defaults to `HOMEAI_CHARACTER_ID` env var or satellite-map default
- Dashboard memory UI will immediately reflect agent-created memories (same files)
- **Env vars:** `HOMEAI_CHARACTER_ID` (optional, set by bridge)
---
### 2. Service Monitor (`monitor-ctl`)
**Purpose:** "Is everything running?" → spoken health report.
**Files:**
- `~/.openclaw/skills/service-monitor/SKILL.md`
- `~/.openclaw/skills/service-monitor/monitor-ctl` → symlink `/opt/homebrew/bin/monitor-ctl`
**Commands:**
```
monitor-ctl status # All services summary
monitor-ctl check <service> # Single service (ollama, bridge, ha, tts, stt, dashboard, n8n, gitea, kuma)
monitor-ctl ollama # Ollama-specific: loaded models, VRAM
monitor-ctl docker # Docker container status (runs: docker ps --format json)
```
**Details:**
- Checks hardcoded service endpoints with 3s timeout:
- Ollama (`localhost:11434/api/ps`), Bridge (`localhost:8081/status`), Gateway (`localhost:8080/status`)
- Wyoming STT (TCP `localhost:10300`), TTS (TCP `localhost:10301`)
- Dashboard (`localhost:5173`), n8n (`localhost:5678`), Kuma (`localhost:3001`)
- HA (`10.0.0.199:8123/api/`), Gitea (`10.0.0.199:3000`)
- `ollama` subcommand parses `/api/ps` for model names, sizes, expiry
- `docker` runs `docker ps --format '{{json .}}'` via subprocess
- Pure stdlib (`urllib.request` + `socket.create_connection` for TCP)
- **Env vars:** Uses existing `HASS_TOKEN`, `HA_URL`
---
### 3. Character Switcher (`character-ctl`)
**Purpose:** "Talk to Aria" → swap persona, TTS voice, system prompt.
**Files:**
- `~/.openclaw/skills/character/SKILL.md`
- `~/.openclaw/skills/character/character-ctl` → symlink `/opt/homebrew/bin/character-ctl`
**Commands:**
```
character-ctl list # All characters (name, id, tts engine)
character-ctl active # Current default character
character-ctl switch "<name_or_id>" # Set as default
character-ctl info "<name_or_id>" # Profile summary
character-ctl map <satellite_id> <character_id> # Map satellite → character
```
**Details:**
- Reads character JSONs from `~/homeai-data/characters/`
- `switch` updates `satellite-map.json` default + writes `active-tts-voice.json`
- Fuzzy name resolution: case-insensitive match on `display_name``name``id` → partial match
- Switch takes effect on next bridge request (`SKILL.md` tells agent to inform user)
- **Env vars:** None new
---
## Phase B — Home Assistant Extensions
### 4. Routine/Scene Builder (`routine-ctl`)
**Purpose:** Create and trigger multi-device scenes and routines from voice.
**Files:**
- `~/.openclaw/skills/routine/SKILL.md`
- `~/.openclaw/skills/routine/routine-ctl` → symlink `/opt/homebrew/bin/routine-ctl`
**Commands:**
```
routine-ctl list-scenes # HA scenes
routine-ctl list-scripts # HA scripts
routine-ctl trigger "<scene_or_script>" # Activate
routine-ctl create-scene "<name>" --entities '[{"entity_id":"light.x","state":"on","brightness":128}]'
routine-ctl list-routines # Local multi-step routines
routine-ctl create-routine "<name>" --steps '[{"type":"scene","target":"movie_mode"},{"type":"delay","seconds":5},{"type":"ha","cmd":"off \"TV Backlight\""}]'
routine-ctl run "<routine_name>" # Execute steps sequentially
```
**Details:**
- HA scenes via REST API: `POST /api/services/scene/turn_on`, `POST /api/services/scene/create`
- Local routines stored in `~/homeai-data/routines/*.json`
- Step types: `scene` (trigger HA scene), `ha` (subprocess call to ha-ctl), `delay` (sleep), `tts` (curl to bridge `/api/tts`)
- `run` executes steps sequentially, reports progress
- **New data path:** `~/homeai-data/routines/`
- **Env vars:** Uses existing `HASS_TOKEN`, `HA_URL`
---
### 5. Music Control (`music-ctl`)
**Purpose:** Play/control music with multi-room and Spotify support.
**Files:**
- `~/.openclaw/skills/music/SKILL.md`
- `~/.openclaw/skills/music/music-ctl` → symlink `/opt/homebrew/bin/music-ctl`
**Commands:**
```
music-ctl players # List media_player entities
music-ctl play ["query"] [--player ID] # Play/resume (search + play if query given)
music-ctl pause [--player ID] # Pause
music-ctl next / prev [--player ID] # Skip tracks
music-ctl volume <0-100> [--player ID] # Set volume
music-ctl now-playing [--player ID] # Current track info
music-ctl queue [--player ID] # Queue contents
music-ctl shuffle <on|off> [--player ID] # Toggle shuffle
music-ctl search "<query>" # Search library
```
**Details:**
- All commands go through HA `media_player` services (same API pattern as ha-ctl)
- `play` with query uses `media_player/play_media` with `media_content_type: music`
- Spotify appears as a `media_player` entity via HA Spotify integration — no separate API needed
- `players` lists all `media_player` entities (Music Assistant zones, Spotify Connect, Chromecast, etc.)
- `--player` defaults to first active player or a configurable default
- Multi-room: Snapcast zones appear as separate `media_player` entities
- `now-playing` reads state attributes: `media_title`, `media_artist`, `media_album`, `media_position`
- **Env vars:** Uses existing `HASS_TOKEN`, `HA_URL`
- **Prerequisite:** Music Assistant Docker container configured + HA integration, OR Spotify HA integration
---
## Phase C — External Service Skills
### 6. n8n Workflow Trigger (`workflow-ctl`)
**Purpose:** List and trigger n8n workflows by voice.
**Files:**
- `~/.openclaw/skills/workflow/SKILL.md`
- `~/.openclaw/skills/workflow/workflow-ctl` → symlink `/opt/homebrew/bin/workflow-ctl`
**Commands:**
```
workflow-ctl list # All workflows (name, active, id)
workflow-ctl trigger "<name_or_id>" [--data '{"key":"val"}'] # Fire webhook
workflow-ctl status <execution_id> # Execution status
workflow-ctl history [--limit 10] # Recent executions
```
**Details:**
- n8n REST API at `http://localhost:5678/api/v1/`
- Auth via API key header: `X-N8N-API-KEY`
- `trigger` prefers webhook trigger (`POST /webhook/<path>`), falls back to `POST /api/v1/workflows/<id>/execute`
- Fuzzy name matching on workflow names
- **Env vars (new):** `N8N_URL` (default `http://localhost:5678`), `N8N_API_KEY` (generate in n8n Settings → API)
---
### 7. Gitea Integration (`gitea-ctl`)
**Purpose:** Query self-hosted repos, commits, issues, PRs.
**Files:**
- `~/.openclaw/skills/gitea/SKILL.md`
- `~/.openclaw/skills/gitea/gitea-ctl` → symlink `/opt/homebrew/bin/gitea-ctl`
**Commands:**
```
gitea-ctl repos [--limit 20] # List repos
gitea-ctl commits <owner/repo> [--limit 10] # Recent commits
gitea-ctl issues <owner/repo> [--state open] # List issues
gitea-ctl prs <owner/repo> [--state open] # List PRs
gitea-ctl create-issue <owner/repo> "<title>" [--body TEXT]
```
**Details:**
- Gitea REST API v1 at `http://10.0.0.199:3000/api/v1/`
- Auth: `Authorization: token <GITEA_TOKEN>`
- Pure stdlib `urllib.request`
- **Env vars (new):** `GITEA_URL` (default `http://10.0.0.199:3000`), `GITEA_TOKEN` (generate in Gitea → Settings → Applications)
---
### 8. Calendar/Reminders (`calendar-ctl`)
**Purpose:** Read calendar, create events, set voice reminders.
**Files:**
- `~/.openclaw/skills/calendar/SKILL.md`
- `~/.openclaw/skills/calendar/calendar-ctl` → symlink `/opt/homebrew/bin/calendar-ctl`
**Commands:**
```
calendar-ctl today [--calendar ID] # Today's events
calendar-ctl upcoming [--days 7] # Next N days
calendar-ctl add "<summary>" --start <ISO> --end <ISO> [--calendar ID]
calendar-ctl remind "<message>" --at "<time>" # Set reminder (e.g. "in 30 minutes", "at 5pm", "tomorrow 9am")
calendar-ctl reminders # List pending reminders
calendar-ctl cancel-reminder <id> # Cancel reminder
```
**Details:**
- Calendar read: `GET /api/calendars/<entity_id>?start=<ISO>&end=<ISO>` via HA API
- Calendar write: `POST /api/services/calendar/create_event`
- Reminders stored locally in `~/homeai-data/reminders.json`
- Relative time parsing with `datetime` + `re` (stdlib): "in 30 minutes", "at 5pm", "tomorrow 9am"
- Reminder daemon (`com.homeai.reminder-daemon`): Python script checking `reminders.json` every 60s, fires TTS via `POST http://localhost:8081/api/tts` when due
- **New data path:** `~/homeai-data/reminders.json`
- **New daemon:** `homeai-agent/reminder-daemon.py` + `homeai-agent/launchd/com.homeai.reminder-daemon.plist`
- **Env vars:** Uses existing `HASS_TOKEN`, `HA_URL`
---
## Phase D — Public/Private Mode System
### 9. Mode Controller (`mode-ctl`)
**Purpose:** Route requests to cloud LLMs (speed/power) or local LLMs (privacy) with per-task rules and manual toggle.
**Files:**
- `~/.openclaw/skills/mode/SKILL.md`
- `~/.openclaw/skills/mode/mode-ctl` → symlink `/opt/homebrew/bin/mode-ctl`
**Commands:**
```
mode-ctl status # Current mode + overrides
mode-ctl private # Switch to local-only
mode-ctl public # Switch to cloud LLMs
mode-ctl set-provider <anthropic|openai> # Preferred cloud provider
mode-ctl override <category> <private|public> # Per-category routing
mode-ctl list-overrides # Show all overrides
```
**State file:** `~/homeai-data/active-mode.json`
```json
{
"mode": "private",
"cloud_provider": "anthropic",
"cloud_model": "claude-sonnet-4-20250514",
"overrides": {
"web_search": "public",
"coding": "public",
"personal_finance": "private",
"health": "private"
},
"updated_at": "2026-03-17T..."
}
```
**How model routing works — Bridge modification:**
The HTTP bridge (`openclaw-http-bridge.py`) is modified to:
1. New function `load_mode()` reads `active-mode.json`
2. New function `resolve_model(mode, category=None)` returns model string
3. In `_handle_agent_request()`, after character resolution, check mode → pass `--model` flag to OpenClaw CLI
- **Private:** `ollama/qwen3.5:35b-a3b` (current default, no change)
- **Public:** `anthropic/claude-sonnet-4-20250514` or `openai/gpt-4o` (per provider setting)
**OpenClaw config changes (`openclaw.json`):** Add cloud providers to `models.providers`:
```json
"anthropic": {
"baseUrl": "https://api.anthropic.com/v1",
"apiKey": "${ANTHROPIC_API_KEY}",
"api": "anthropic",
"models": [{"id": "claude-sonnet-4-20250514", "contextWindow": 200000, "maxTokens": 8192}]
},
"openai": {
"baseUrl": "https://api.openai.com/v1",
"apiKey": "${OPENAI_API_KEY}",
"api": "openai",
"models": [{"id": "gpt-4o", "contextWindow": 128000, "maxTokens": 4096}]
}
```
**Per-task classification:** The `SKILL.md` provides a category reference table. The agent self-classifies each request and checks overrides. Default categories:
- **Always private:** personal finance, health, passwords, private conversations
- **Always public:** web search, coding help, complex reasoning, translation
- **Follow global mode:** general chat, smart home, music, calendar
**Dashboard integration:** Add mode toggle to dashboard sidebar via new Vite middleware endpoint `GET/POST /api/mode` reading/writing `active-mode.json`.
- **Env vars (new):** `ANTHROPIC_API_KEY`, `OPENAI_API_KEY` (add to OpenClaw plist)
- **Bridge file modified:** `homeai-agent/openclaw-http-bridge.py` — add ~40 lines for mode loading + model resolution
---
## Implementation Order
| # | Skill | Complexity | Dependencies |
|---|-------|-----------|-------------|
| 1 | `memory-ctl` | Simple | None |
| 2 | `monitor-ctl` | Simple | None |
| 3 | `character-ctl` | Simple | None |
| 4 | `routine-ctl` | Medium | ha-ctl existing |
| 5 | `music-ctl` | Medium | Music Assistant or Spotify in HA |
| 6 | `workflow-ctl` | Simple | n8n API key |
| 7 | `gitea-ctl` | Simple | Gitea API token |
| 8 | `calendar-ctl` | Medium | HA calendar + new reminder daemon |
| 9 | `mode-ctl` | High | Cloud API keys + bridge modification |
## Per-Skill Implementation Steps
For each skill:
1. Create `SKILL.md` with frontmatter + agent instructions + examples
2. Create Python CLI (`chmod +x`), stdlib only
3. Symlink to `/opt/homebrew/bin/`
4. Test CLI standalone: `<tool> --help`, `<tool> <command>`
5. Add env vars to `com.homeai.openclaw.plist` if needed
6. Restart OpenClaw: `launchctl kickstart -k gui/501/com.homeai.openclaw`
7. Add section to `~/.openclaw/workspace/TOOLS.md`
8. Test via: `openclaw agent --message "test prompt" --agent main`
9. Test via voice: wake word + spoken command
## Verification
- **Unit test each CLI:** Run each command manually and verify JSON output
- **Agent test:** `openclaw agent --message "remember that my favorite color is blue"` (memory-ctl)
- **Voice test:** Wake word → "Is everything running?" → spoken health report (monitor-ctl)
- **Mode test:** `mode-ctl public` → send a complex query → verify it routes to cloud model in bridge logs
- **Dashboard test:** Check memory UI shows agent-created memories, mode toggle works
- **Cross-skill test:** "Switch to Sucy and play some jazz" → character-ctl + music-ctl in one turn
## Critical Files to Modify
| File | Changes |
|------|---------|
| `~/.openclaw/workspace/TOOLS.md` | Add sections for all 9 new skills |
| `homeai-agent/openclaw-http-bridge.py` | Mode routing (Phase D only) |
| `homeai-agent/launchd/com.homeai.openclaw.plist` | New env vars |
| `~/.openclaw/openclaw.json` | Add anthropic + openai providers (Phase D) |
| `homeai-dashboard/vite.config.js` | `/api/mode` endpoint (Phase D) |
## New Files Created
- `~/.openclaw/skills/memory/` (SKILL.md + memory-ctl)
- `~/.openclaw/skills/service-monitor/` (SKILL.md + monitor-ctl)
- `~/.openclaw/skills/character/` (SKILL.md + character-ctl)
- `~/.openclaw/skills/routine/` (SKILL.md + routine-ctl)
- `~/.openclaw/skills/music/` (SKILL.md + music-ctl)
- `~/.openclaw/skills/workflow/` (SKILL.md + workflow-ctl)
- `~/.openclaw/skills/gitea/` (SKILL.md + gitea-ctl)
- `~/.openclaw/skills/calendar/` (SKILL.md + calendar-ctl + reminder-daemon.py)
- `~/.openclaw/skills/mode/` (SKILL.md + mode-ctl)
- `~/homeai-data/routines/` (directory)
- `~/homeai-data/reminders.json` (file)
- `~/homeai-data/active-mode.json` (file)
- `homeai-agent/reminder-daemon.py` + launchd plist

View File

@@ -228,11 +228,19 @@ install_service() {
log_warn "No launchd plist at $launchd_file — skipping service install."
return
fi
local plist_dest="${HOME}/Library/LaunchAgents/$(basename "$launchd_file")"
local plist_name
plist_name="$(basename "$launchd_file")"
local plist_dest="${HOME}/Library/LaunchAgents/${plist_name}"
local plist_label="${plist_name%.plist}"
local abs_source
abs_source="$(cd "$(dirname "$launchd_file")" && pwd)/$(basename "$launchd_file")"
log_step "Installing launchd agent: $name"
cp "$launchd_file" "$plist_dest"
launchctl load -w "$plist_dest"
log_success "LaunchAgent '$name' installed and loaded."
# Unload existing service if running
launchctl bootout "gui/$(id -u)/${plist_label}" 2>/dev/null || true
# Symlink so edits to repo source take effect on reload
ln -sf "$abs_source" "$plist_dest"
launchctl bootstrap "gui/$(id -u)" "$plist_dest"
log_success "LaunchAgent '$name' symlinked and loaded."
fi
}