diff --git a/.env.example b/.env.example index d442b20..14c3ae3 100644 --- a/.env.example +++ b/.env.example @@ -10,6 +10,8 @@ DEEPSEEK_API_KEY= GEMINI_API_KEY= ELEVENLABS_API_KEY= GAZE_API_KEY= +DREAM_API_KEY= +ANTHROPIC_API_KEY= # ─── Data & Paths ────────────────────────────────────────────────────────────── DATA_DIR=${HOME}/homeai-data @@ -59,3 +61,5 @@ VTUBE_WS_URL=ws://localhost:8001 # ─── P8: Images ──────────────────────────────────────────────────────────────── COMFYUI_URL=http://localhost:8188 +# ─── P9: Character Management ───────────────────────────────────────────────── +DREAM_HOST=http://localhost:3000 diff --git a/.gitignore b/.gitignore index 919de4a..a4ed8cd 100644 --- a/.gitignore +++ b/.gitignore @@ -45,3 +45,6 @@ homeai-esp32/esphome/secrets.yaml homeai-llm/benchmark-results.md homeai-character/characters/*.json !homeai-character/characters/.gitkeep + +# MCP Files +*.mcp* \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md index da53ee2..3498f4a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -18,14 +18,14 @@ A self-hosted, always-on personal AI assistant running on a **Mac Mini M4 Pro (6 | Storage | 1TB SSD | | Network | Gigabit Ethernet | -Primary LLM is Claude Sonnet 4 via Anthropic API. Local Ollama models available as fallback. All other inference (STT, TTS, image gen) runs locally. +Primary LLMs are Claude 4.5/4.6 family via Anthropic API (Haiku for quick, Sonnet for standard, Opus for creative/RP). Local Ollama models available as fallback. All other inference (STT, TTS, image gen) runs locally. --- ## Core Stack ### AI & LLM -- **Claude Sonnet 4** — primary LLM via Anthropic API (`anthropic/claude-sonnet-4-20250514`), used for all agent interactions +- **Claude 4.5/4.6 family** — primary LLMs via Anthropic API, tiered per prompt style: Haiku 4.5 (quick commands), Sonnet 4.6 (standard/creative), Opus 4.6 (roleplay/storytelling) - **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 @@ -53,13 +53,16 @@ Primary LLM is Claude Sonnet 4 via Anthropic API. Local Ollama models available - **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 Memory System** — SQLite + sqlite-vec semantic search (personal per-character + general shared + follow-ups), injected into LLM system prompt with context-aware retrieval +- **Prompt Styles** — 6 styles (quick, standard, creative, roleplayer, game-master, storyteller) with per-style model routing, temperature, and section stripping. JSON templates in `homeai-agent/prompt-styles/` +- **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.6, with per-style model tiering (Haiku/Sonnet/Opus). ### Character & Personality -- **Character Schema v2** — JSON spec with background, dialogue_style, appearance, skills, gaze_presets (v1 auto-migrated) +- **Character Schema v2** — JSON spec with background, dialogue_style, appearance, skills, gaze_presets, dream_id, gaze_character, prompt style overrides (v1 auto-migrated) - **HomeAI Dashboard** — unified web app: character editor, chat, memory manager, service dashboard +- **Dream** — character management service (http://10.0.0.101:3000), REST API for character CRUD with GAZE integration for cover images - **Character MCP Server** — LLM-assisted character creation via Fandom wiki/Wikipedia lookup (Docker) +- **GAZE** — image generation service (http://10.0.0.101:5782), REST API for presets, characters, and job-based image generation - Character config stored as JSON files in `~/homeai-data/characters/`, consumed by bridge for system prompt construction ### Visual Representation @@ -97,7 +100,7 @@ ESP32-S3-BOX-3 (room) → Bridge resolves character (satellite_id → character mapping) → Bridge builds system prompt (profile + memories) and writes TTS config to state file → Bridge checks active-mode.json for model routing (private=local, public=cloud) - → OpenClaw CLI → LLM generates response (Claude Sonnet 4 default, Ollama fallback) + → OpenClaw CLI → LLM generates response (Claude Haiku/Sonnet/Opus per style, 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) @@ -130,15 +133,32 @@ Each character is a JSON file in `~/homeai-data/characters/` with: - **Profile fields** — background, appearance, dialogue_style, skills array - **TTS config** — engine (kokoro/elevenlabs), kokoro_voice, elevenlabs_voice_id, elevenlabs_model, speed - **GAZE presets** — array of `{preset, trigger}` for image generation styles +- **Dream link** — `dream_id` for syncing character data from Dream service +- **GAZE link** — `gaze_character` for auto-assigned cover image and presets +- **Prompt style config** — `default_prompt_style`, `prompt_style_overrides` for per-style tuning - **Custom prompt rules** — trigger/response overrides for specific contexts ### Memory System -Two-tier memory stored as JSON in `~/homeai-data/memories/`: -- **Personal memories** (`personal/{character_id}.json`) — per-character, about user interactions -- **General memories** (`general.json`) — shared operational knowledge (tool usage, device info, routines) +SQLite + sqlite-vec database at `~/homeai-data/memories/memories.db`: +- **Personal memories** — per-character, semantic/episodic/relational/opinion types +- **General memories** — shared operational knowledge (character_id = "general") +- **Follow-ups** — LLM-driven questions injected into system prompt, auto-resolve after 2 surfacings or 48h +- **Privacy levels** — public, sensitive, local_only (local_only excluded from cloud model requests) +- **Semantic search** — sentence-transformers all-MiniLM-L6-v2 embeddings (384 dims) for context-aware retrieval +- Core module: `homeai-agent/memory_store.py` (imported by bridge + memory-ctl skill) -Memories are injected into the system prompt by the bridge with budget truncation (personal: 4000 chars, general: 3000 chars, newest first). +### Prompt Styles + +Six response styles in `homeai-agent/prompt-styles/`, each a JSON template with model, temperature, and instructions: +- **quick** — Claude Haiku 4.5, low temp, brief responses, strips profile sections +- **standard** — Claude Sonnet 4.6, balanced +- **creative** — Claude Sonnet 4.6, higher temp, elaborative +- **roleplayer** — Claude Opus 4.6, full personality injection +- **game-master** — Claude Opus 4.6, narrative-focused +- **storyteller** — Claude Opus 4.6, story-centric + +Style selection: dashboard chat has a style picker; characters can set `default_prompt_style`; satellites use the global active style. Bridge resolves model per style → group → mode → default. ### TTS Voice Routing @@ -160,11 +180,12 @@ This works for both ESP32/HA pipeline and dashboard chat. 6. **Character system** — schema v2, dashboard editor, memory system, per-character TTS routing ✅ 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 +9. **Memory v2 + Prompt Styles + Dream/GAZE** — SQLite memory with semantic search, 6 prompt styles with model tiering, Dream character import, GAZE character linking ✅ +10. **Animated visual** — PNG/GIF character visual for the web assistant (initial visual layer) +11. **Android app** — companion app for mobile access to the assistant +12. **ComfyUI** — image generation online, character-consistent model workflows +13. **Extended integrations** — Snapcast, code-server +14. **Polish** — Authelia, Tailscale hardening, iOS widgets ### Stretch Goals - **Live2D / VTube Studio** — full Live2D model with WebSocket API bridge (requires learning Live2D tooling) @@ -180,7 +201,10 @@ This works for both ESP32/HA pipeline and dashboard chat. - OpenClaw workspace tools: `~/.openclaw/workspace/TOOLS.md` - OpenClaw config: `~/.openclaw/openclaw.json` - Character configs: `~/homeai-data/characters/` -- Character memories: `~/homeai-data/memories/` +- Character memories DB: `~/homeai-data/memories/memories.db` +- Memory store module: `homeai-agent/memory_store.py` +- Prompt style templates: `homeai-agent/prompt-styles/` +- Active prompt style: `~/homeai-data/active-prompt-style.json` - Conversation history: `~/homeai-data/conversations/` - Active TTS state: `~/homeai-data/active-tts-voice.json` - Active mode state: `~/homeai-data/active-mode.json` @@ -194,6 +218,8 @@ This works for both ESP32/HA pipeline and dashboard chat. - Gitea repos root: `~/gitea/` - Music Assistant (Pi): `~/docker/selbina/music-assistant/` on 10.0.0.199 - Skills user guide: `homeai-agent/SKILLS_GUIDE.md` +- Dream service: `http://10.0.0.101:3000` (character management, REST API) +- GAZE service: `http://10.0.0.101:5782` (image generation, REST API) --- @@ -203,8 +229,10 @@ 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 -- Primary LLM is Claude Sonnet 4 (Anthropic API); local Ollama models are available as fallback +- Primary LLMs are Claude 4.5/4.6 family (Anthropic API) with per-style tiering; 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 +- Memory DB (`memories.db`) should be backed up as part of regular Gitea commits +- Dream characters can be linked to GAZE characters for cover image fallback and cross-referencing +- Prompt style selection hierarchy: explicit user pick → character default → global active style diff --git a/PORT_MAP.md b/PORT_MAP.md new file mode 100644 index 0000000..a3ae7be --- /dev/null +++ b/PORT_MAP.md @@ -0,0 +1,89 @@ +# HomeAI Port Map + +All ports used across the HomeAI stack. Updated 2026-03-20. + +**Host: LINDBLUM (10.0.0.101)** — Mac Mini M4 Pro + +## Voice Pipeline + +| Port | Service | Protocol | Managed By | Binds | +|------|---------|----------|------------|-------| +| 10300 | Wyoming STT (Whisper MLX) | TCP (Wyoming) | launchd `com.homeai.wyoming-stt` | 0.0.0.0 | +| 10301 | Wyoming TTS (Kokoro) | TCP (Wyoming) | launchd `com.homeai.wyoming-tts` | 0.0.0.0 | +| 10302 | Wyoming TTS (ElevenLabs) | TCP (Wyoming) | launchd `com.homeai.wyoming-elevenlabs` | 0.0.0.0 | +| 10700 | Wyoming Satellite | TCP (Wyoming) | launchd `com.homeai.wyoming-satellite` | 0.0.0.0 | + +## Agent / Orchestration + +| Port | Service | Protocol | Managed By | Binds | +|------|---------|----------|------------|-------| +| 8080 | OpenClaw Gateway | HTTP | launchd `com.homeai.openclaw` | localhost | +| 8081 | OpenClaw HTTP Bridge | HTTP | launchd `com.homeai.openclaw-bridge` | 0.0.0.0 | +| 8002 | VTube Studio Bridge | HTTP | launchd `com.homeai.vtube-bridge` | localhost | + +## LLM + +| Port | Service | Protocol | Managed By | Binds | +|------|---------|----------|------------|-------| +| 11434 | Ollama | HTTP | launchd `com.homeai.ollama` | 0.0.0.0 | +| 3030 | Open WebUI | HTTP | Docker `homeai-open-webui` | 0.0.0.0 | + +## Dashboards / UIs + +| Port | Service | Protocol | Managed By | Binds | +|------|---------|----------|------------|-------| +| 5173 | HomeAI Dashboard | HTTP | launchd `com.homeai.dashboard` | localhost | +| 5174 | Desktop Assistant | HTTP | launchd `com.homeai.desktop-assistant` | localhost | + +## Image Generation + +| Port | Service | Protocol | Managed By | Binds | +|------|---------|----------|------------|-------| +| 5782 | GAZE API | HTTP | — | 10.0.0.101 | +| 8188 | ComfyUI | HTTP | — | localhost | + +## Visual + +| Port | Service | Protocol | Managed By | Binds | +|------|---------|----------|------------|-------| +| 8001 | VTube Studio (WebSocket) | WS | External app | localhost | + +## Infrastructure (Docker) + +| Port | Service | Protocol | Managed By | Binds | +|------|---------|----------|------------|-------| +| 3001 | Uptime Kuma | HTTP | Docker `homeai-uptime-kuma` | 0.0.0.0 | +| 5678 | n8n | HTTP | Docker `homeai-n8n` | 0.0.0.0 | +| 8090 | code-server | HTTP | Docker `homeai-code-server` | 0.0.0.0 | + +--- + +**Host: SELBINA (10.0.0.199)** — Raspberry Pi 5 + +| Port | Service | Protocol | Managed By | +|------|---------|----------|------------| +| 3000 | Gitea | HTTP | Docker | +| 8095 | Music Assistant | HTTP | Docker (host networking) | +| 8123 | Home Assistant | HTTPS | Docker | +| 9443 | Portainer | HTTPS | Docker | + +--- + +## Port Ranges Summary + +``` + 3000–3030 Web UIs (Gitea, Uptime Kuma, Open WebUI) + 5173–5174 Vite dev servers (dashboards) + 5678 n8n + 5782 GAZE API + 8001–8002 VTube Studio (app + bridge) + 8080–8081 OpenClaw (gateway + bridge) + 8090 code-server + 8095 Music Assistant + 8123 Home Assistant + 8188 ComfyUI + 9443 Portainer +11434 Ollama +10300–10302 Wyoming voice (STT + TTS) +10700 Wyoming satellite +``` diff --git a/homeai-agent/memory_store.py b/homeai-agent/memory_store.py new file mode 100644 index 0000000..aafac29 --- /dev/null +++ b/homeai-agent/memory_store.py @@ -0,0 +1,865 @@ +#!/usr/bin/env python3 +""" +HomeAI Memory Store — SQLite + Vector Search + +Replaces flat JSON memory files with a structured SQLite database +using sqlite-vec for semantic similarity search. + +Used by: + - openclaw-http-bridge.py (memory retrieval + follow-up injection) + - memory-ctl skill (CLI memory management) + - Dashboard API (REST endpoints via bridge) +""" + +import json +import os +import sqlite3 +import struct +import time +from datetime import datetime, timedelta, timezone +from pathlib import Path +from typing import Optional + +import sqlite_vec + +# --------------------------------------------------------------------------- +# Configuration +# --------------------------------------------------------------------------- + +DATA_DIR = Path(os.environ.get("DATA_DIR", os.path.expanduser("~/homeai-data"))) +MEMORIES_DIR = DATA_DIR / "memories" +DB_PATH = MEMORIES_DIR / "memories.db" +EMBEDDING_DIM = 384 # all-MiniLM-L6-v2 + +# Privacy keywords for rule-based classification +PRIVACY_KEYWORDS = { + "local_only": [ + "health", "illness", "sick", "doctor", "medical", "medication", "surgery", + "salary", "bank", "financial", "debt", "mortgage", "tax", + "depression", "anxiety", "therapy", "divorce", "breakup", + ], + "sensitive": [ + "address", "phone", "email", "password", "birthday", + ], +} + +# --------------------------------------------------------------------------- +# Embedding model (lazy-loaded singleton) +# --------------------------------------------------------------------------- + +_embedder = None + + +def _get_embedder(): + """Lazy-load the sentence-transformers model.""" + global _embedder + if _embedder is None: + from sentence_transformers import SentenceTransformer + _embedder = SentenceTransformer("all-MiniLM-L6-v2") + return _embedder + + +def get_embedding(text: str) -> list[float]: + """Compute a 384-dim embedding for the given text.""" + model = _get_embedder() + vec = model.encode(text, normalize_embeddings=True) + return vec.tolist() + + +def _serialize_f32(vec: list[float]) -> bytes: + """Serialize a float list to little-endian bytes for sqlite-vec.""" + return struct.pack(f"<{len(vec)}f", *vec) + + +def _deserialize_f32(blob: bytes) -> list[float]: + """Deserialize sqlite-vec float bytes back to a list.""" + n = len(blob) // 4 + return list(struct.unpack(f"<{n}f", blob)) + + +# --------------------------------------------------------------------------- +# Database initialization +# --------------------------------------------------------------------------- + +_db: Optional[sqlite3.Connection] = None + + +def init_db() -> sqlite3.Connection: + """Initialize the SQLite database with schema and sqlite-vec extension.""" + global _db + if _db is not None: + return _db + + MEMORIES_DIR.mkdir(parents=True, exist_ok=True) + db = sqlite3.connect(str(DB_PATH), check_same_thread=False) + db.enable_load_extension(True) + sqlite_vec.load(db) + db.enable_load_extension(False) + db.row_factory = sqlite3.Row + + db.executescript(""" + CREATE TABLE IF NOT EXISTS memories ( + id TEXT PRIMARY KEY, + character_id TEXT NOT NULL, + content TEXT NOT NULL, + memory_type TEXT NOT NULL DEFAULT 'semantic', + category TEXT NOT NULL DEFAULT 'other', + privacy_level TEXT NOT NULL DEFAULT 'standard', + importance REAL NOT NULL DEFAULT 0.5, + lifecycle_state TEXT NOT NULL DEFAULT 'active', + follow_up_due TEXT, + follow_up_context TEXT, + source TEXT DEFAULT 'user_explicit', + created_at TEXT NOT NULL, + last_accessed TEXT, + expires_at TEXT, + previous_value TEXT, + tags TEXT, + surfaced_count INTEGER DEFAULT 0 + ); + + CREATE INDEX IF NOT EXISTS idx_memories_character + ON memories(character_id); + CREATE INDEX IF NOT EXISTS idx_memories_lifecycle + ON memories(lifecycle_state); + CREATE INDEX IF NOT EXISTS idx_memories_type + ON memories(memory_type); + """) + + # Create the vec0 virtual table for vector search + # sqlite-vec requires this specific syntax + db.execute(f""" + CREATE VIRTUAL TABLE IF NOT EXISTS memory_embeddings USING vec0( + id TEXT PRIMARY KEY, + embedding float[{EMBEDDING_DIM}] + ) + """) + + # Partial index for follow-ups (created manually since executescript can't + # handle IF NOT EXISTS for partial indexes cleanly on all versions) + try: + db.execute(""" + CREATE INDEX idx_memories_followup + ON memories(lifecycle_state, follow_up_due) + WHERE lifecycle_state = 'pending_followup' + """) + except sqlite3.OperationalError: + pass # index already exists + + db.commit() + _db = db + return db + + +def _get_db() -> sqlite3.Connection: + """Get or initialize the database connection.""" + if _db is None: + return init_db() + return _db + + +def _row_to_dict(row: sqlite3.Row) -> dict: + """Convert a sqlite3.Row to a plain dict.""" + return dict(row) + + +def _generate_id() -> str: + """Generate a unique memory ID.""" + return f"m_{int(time.time() * 1000)}" + + +def _now_iso() -> str: + """Current UTC time as ISO string.""" + return datetime.now(timezone.utc).isoformat() + + +# --------------------------------------------------------------------------- +# Write-time classification (rule-based, Phase 1) +# --------------------------------------------------------------------------- + +def classify_memory(content: str) -> dict: + """Rule-based classification for memory properties. + Returns defaults that can be overridden by explicit parameters.""" + content_lower = content.lower() + + # Privacy detection + privacy = "standard" + for level, keywords in PRIVACY_KEYWORDS.items(): + if any(kw in content_lower for kw in keywords): + privacy = level + break + + # Memory type detection + memory_type = "semantic" + temporal_markers = [ + "today", "yesterday", "tonight", "this morning", "just now", + "feeling", "right now", "this week", "earlier", + ] + if any(kw in content_lower for kw in temporal_markers): + memory_type = "episodic" + + # Importance heuristic + importance = 0.5 + if privacy == "local_only": + importance = 0.7 + elif privacy == "sensitive": + importance = 0.6 + + return { + "memory_type": memory_type, + "privacy_level": privacy, + "importance": importance, + } + + +# --------------------------------------------------------------------------- +# CRUD operations +# --------------------------------------------------------------------------- + +def add_memory( + character_id: str, + content: str, + memory_type: str | None = None, + category: str = "other", + importance: float | None = None, + privacy_level: str | None = None, + tags: list[str] | None = None, + follow_up_due: str | None = None, + follow_up_context: str | None = None, + source: str = "user_explicit", + expires_at: str | None = None, +) -> dict: + """Add a new memory record. Auto-classifies fields not explicitly set.""" + db = _get_db() + classified = classify_memory(content) + + memory_type = memory_type or classified["memory_type"] + privacy_level = privacy_level or classified["privacy_level"] + importance = importance if importance is not None else classified["importance"] + + lifecycle_state = "active" + if follow_up_due or follow_up_context: + lifecycle_state = "pending_followup" + if not follow_up_due: + follow_up_due = "next_interaction" + + mem_id = _generate_id() + now = _now_iso() + + # Generate embedding + embedding = get_embedding(content) + + db.execute(""" + INSERT INTO memories ( + id, character_id, content, memory_type, category, + privacy_level, importance, lifecycle_state, + follow_up_due, follow_up_context, source, + created_at, tags, surfaced_count + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0) + """, ( + mem_id, character_id, content, memory_type, category, + privacy_level, importance, lifecycle_state, + follow_up_due, follow_up_context, source, + now, json.dumps(tags) if tags else None, + )) + + # Insert embedding into vec0 table + db.execute( + "INSERT INTO memory_embeddings (id, embedding) VALUES (?, ?)", + (mem_id, _serialize_f32(embedding)), + ) + + db.commit() + + return { + "id": mem_id, + "character_id": character_id, + "content": content, + "memory_type": memory_type, + "category": category, + "privacy_level": privacy_level, + "importance": importance, + "lifecycle_state": lifecycle_state, + "follow_up_due": follow_up_due, + "follow_up_context": follow_up_context, + "source": source, + "created_at": now, + "tags": tags, + } + + +def update_memory(memory_id: str, **fields) -> dict | None: + """Update specific fields on a memory record.""" + db = _get_db() + + # Validate that memory exists + row = db.execute("SELECT * FROM memories WHERE id = ?", (memory_id,)).fetchone() + if not row: + return None + + allowed = { + "content", "memory_type", "category", "privacy_level", "importance", + "lifecycle_state", "follow_up_due", "follow_up_context", "source", + "last_accessed", "expires_at", "previous_value", "tags", "surfaced_count", + } + updates = {k: v for k, v in fields.items() if k in allowed} + if not updates: + return _row_to_dict(row) + + # If content changed, update embedding and store previous value + if "content" in updates: + updates["previous_value"] = row["content"] + embedding = get_embedding(updates["content"]) + # Update vec0 table: delete old, insert new + db.execute("DELETE FROM memory_embeddings WHERE id = ?", (memory_id,)) + db.execute( + "INSERT INTO memory_embeddings (id, embedding) VALUES (?, ?)", + (memory_id, _serialize_f32(embedding)), + ) + + if "tags" in updates and isinstance(updates["tags"], list): + updates["tags"] = json.dumps(updates["tags"]) + + set_clause = ", ".join(f"{k} = ?" for k in updates) + values = list(updates.values()) + [memory_id] + db.execute(f"UPDATE memories SET {set_clause} WHERE id = ?", values) + db.commit() + + row = db.execute("SELECT * FROM memories WHERE id = ?", (memory_id,)).fetchone() + return _row_to_dict(row) if row else None + + +def delete_memory(memory_id: str) -> bool: + """Delete a memory record and its embedding.""" + db = _get_db() + row = db.execute("SELECT id FROM memories WHERE id = ?", (memory_id,)).fetchone() + if not row: + return False + db.execute("DELETE FROM memories WHERE id = ?", (memory_id,)) + db.execute("DELETE FROM memory_embeddings WHERE id = ?", (memory_id,)) + db.commit() + return True + + +# --------------------------------------------------------------------------- +# Retrieval +# --------------------------------------------------------------------------- + +def retrieve_memories( + character_id: str, + context_text: str = "", + limit: int = 20, + exclude_private_for_cloud: bool = False, +) -> list[dict]: + """Dual retrieval: semantic similarity + recency, merged and ranked. + + If context_text is empty, falls back to recency-only retrieval. + """ + db = _get_db() + + privacy_filter = "" + if exclude_private_for_cloud: + privacy_filter = "AND m.privacy_level != 'local_only'" + + # Always include high-importance memories + high_importance = db.execute(f""" + SELECT * FROM memories m + WHERE m.character_id = ? + AND m.lifecycle_state IN ('active', 'pending_followup') + AND m.importance > 0.8 + {privacy_filter} + ORDER BY m.created_at DESC + LIMIT 5 + """, (character_id,)).fetchall() + + seen_ids = {r["id"] for r in high_importance} + results = {r["id"]: {**_row_to_dict(r), "_score": 1.0} for r in high_importance} + + # Semantic search (if context provided and embeddings exist) + if context_text: + try: + query_emb = get_embedding(context_text) + vec_rows = db.execute(""" + SELECT id, distance + FROM memory_embeddings + WHERE embedding MATCH ? + AND k = 30 + """, (_serialize_f32(query_emb),)).fetchall() + + vec_ids = [r["id"] for r in vec_rows if r["id"] not in seen_ids] + vec_distances = {r["id"]: r["distance"] for r in vec_rows} + + if vec_ids: + placeholders = ",".join("?" * len(vec_ids)) + sem_rows = db.execute(f""" + SELECT * FROM memories m + WHERE m.id IN ({placeholders}) + AND m.character_id = ? + AND m.lifecycle_state IN ('active', 'pending_followup') + {privacy_filter} + """, (*vec_ids, character_id)).fetchall() + + for r in sem_rows: + d = _row_to_dict(r) + # Convert cosine distance to similarity (sqlite-vec returns L2 distance for vec0) + dist = vec_distances.get(r["id"], 1.0) + semantic_score = max(0.0, 1.0 - dist) + d["_score"] = 0.6 * semantic_score + 0.1 * d["importance"] + results[r["id"]] = d + seen_ids.add(r["id"]) + except Exception as e: + print(f"[MemoryStore] Vector search error: {e}") + + # Recency search: last 7 days, ordered by importance + recency + recency_rows = db.execute(f""" + SELECT * FROM memories m + WHERE m.character_id = ? + AND m.lifecycle_state IN ('active', 'pending_followup') + AND m.created_at > datetime('now', '-7 days') + {privacy_filter} + ORDER BY m.importance DESC, m.created_at DESC + LIMIT 10 + """, (character_id,)).fetchall() + + for r in recency_rows: + if r["id"] not in seen_ids: + d = _row_to_dict(r) + # Recency score based on age in days (newer = higher) + try: + created = datetime.fromisoformat(d["created_at"]) + age_days = (datetime.now(timezone.utc) - created).total_seconds() / 86400 + recency_score = max(0.0, 1.0 - (age_days / 7.0)) + except (ValueError, TypeError): + recency_score = 0.5 + d["_score"] = 0.3 * recency_score + 0.1 * d["importance"] + results[r["id"]] = d + seen_ids.add(r["id"]) + + # Sort by score descending, return top N + ranked = sorted(results.values(), key=lambda x: x.get("_score", 0), reverse=True) + + # Update last_accessed for returned memories + returned = ranked[:limit] + now = _now_iso() + for mem in returned: + mem.pop("_score", None) + db.execute( + "UPDATE memories SET last_accessed = ? WHERE id = ?", + (now, mem["id"]), + ) + db.commit() + + return returned + + +def get_pending_followups(character_id: str) -> list[dict]: + """Get follow-up memories that are due for surfacing.""" + db = _get_db() + now = _now_iso() + + rows = db.execute(""" + SELECT * FROM memories + WHERE character_id = ? + AND lifecycle_state = 'pending_followup' + AND (follow_up_due <= ? OR follow_up_due = 'next_interaction') + ORDER BY importance DESC, created_at DESC + LIMIT 5 + """, (character_id, now)).fetchall() + + return [_row_to_dict(r) for r in rows] + + +def search_memories( + character_id: str, + query: str, + memory_type: str | None = None, + limit: int = 10, +) -> list[dict]: + """Semantic search for memories matching a query.""" + db = _get_db() + + query_emb = get_embedding(query) + vec_rows = db.execute(""" + SELECT id, distance + FROM memory_embeddings + WHERE embedding MATCH ? + AND k = ? + """, (_serialize_f32(query_emb), limit * 3)).fetchall() + + if not vec_rows: + return [] + + vec_ids = [r["id"] for r in vec_rows] + vec_distances = {r["id"]: r["distance"] for r in vec_rows} + placeholders = ",".join("?" * len(vec_ids)) + + type_filter = "AND m.memory_type = ?" if memory_type else "" + params = [*vec_ids, character_id] + if memory_type: + params.append(memory_type) + + rows = db.execute(f""" + SELECT * FROM memories m + WHERE m.id IN ({placeholders}) + AND m.character_id = ? + {type_filter} + ORDER BY m.created_at DESC + """, params).fetchall() + + # Sort by similarity + results = [] + for r in rows: + d = _row_to_dict(r) + d["_distance"] = vec_distances.get(r["id"], 1.0) + results.append(d) + results.sort(key=lambda x: x["_distance"]) + + for r in results: + r.pop("_distance", None) + + return results[:limit] + + +def list_memories( + character_id: str, + memory_type: str | None = None, + lifecycle_state: str | None = None, + category: str | None = None, + limit: int = 20, + offset: int = 0, +) -> list[dict]: + """List memories with optional filters.""" + db = _get_db() + + conditions = ["character_id = ?"] + params: list = [character_id] + + if memory_type: + conditions.append("memory_type = ?") + params.append(memory_type) + if lifecycle_state: + conditions.append("lifecycle_state = ?") + params.append(lifecycle_state) + if category: + conditions.append("category = ?") + params.append(category) + + where = " AND ".join(conditions) + params.extend([limit, offset]) + + rows = db.execute(f""" + SELECT * FROM memories + WHERE {where} + ORDER BY created_at DESC + LIMIT ? OFFSET ? + """, params).fetchall() + + return [_row_to_dict(r) for r in rows] + + +def count_memories(character_id: str) -> int: + """Count memories for a character.""" + db = _get_db() + row = db.execute( + "SELECT COUNT(*) as cnt FROM memories WHERE character_id = ?", + (character_id,), + ).fetchone() + return row["cnt"] if row else 0 + + +# --------------------------------------------------------------------------- +# Lifecycle management +# --------------------------------------------------------------------------- + +def resolve_followup(memory_id: str) -> bool: + """Mark a follow-up as resolved.""" + db = _get_db() + result = db.execute(""" + UPDATE memories + SET lifecycle_state = 'resolved', + follow_up_due = NULL + WHERE id = ? AND lifecycle_state = 'pending_followup' + """, (memory_id,)) + db.commit() + return result.rowcount > 0 + + +def archive_memory(memory_id: str) -> bool: + """Archive a memory (keeps it for relational inference, not surfaced).""" + db = _get_db() + result = db.execute(""" + UPDATE memories + SET lifecycle_state = 'archived' + WHERE id = ? + """, (memory_id,)) + db.commit() + return result.rowcount > 0 + + +def auto_resolve_expired_followups() -> int: + """Auto-resolve follow-ups that are more than 48h past due.""" + db = _get_db() + cutoff = (datetime.now(timezone.utc) - timedelta(hours=48)).isoformat() + result = db.execute(""" + UPDATE memories + SET lifecycle_state = 'resolved', + follow_up_due = NULL + WHERE lifecycle_state = 'pending_followup' + AND follow_up_due != 'next_interaction' + AND follow_up_due < ? + """, (cutoff,)) + db.commit() + return result.rowcount + + +def auto_archive_old_resolved() -> int: + """Archive resolved memories older than 7 days.""" + db = _get_db() + cutoff = (datetime.now(timezone.utc) - timedelta(days=7)).isoformat() + result = db.execute(""" + UPDATE memories + SET lifecycle_state = 'archived' + WHERE lifecycle_state = 'resolved' + AND created_at < ? + """, (cutoff,)) + db.commit() + return result.rowcount + + +def increment_surfaced_count(memory_id: str) -> int: + """Increment surfaced_count and return new value. Auto-resolves if >= 1.""" + db = _get_db() + row = db.execute( + "SELECT surfaced_count FROM memories WHERE id = ?", (memory_id,) + ).fetchone() + if not row: + return 0 + + new_count = (row["surfaced_count"] or 0) + 1 + if new_count >= 2: + # Auto-resolve: surfaced twice without user engagement + db.execute(""" + UPDATE memories + SET surfaced_count = ?, lifecycle_state = 'resolved', follow_up_due = NULL + WHERE id = ? + """, (new_count, memory_id)) + else: + # Update next_interaction to actual timestamp so the 48h timer starts + db.execute(""" + UPDATE memories + SET surfaced_count = ?, + follow_up_due = CASE + WHEN follow_up_due = 'next_interaction' THEN ? + ELSE follow_up_due + END + WHERE id = ? + """, (new_count, _now_iso(), memory_id)) + db.commit() + return new_count + + +# --------------------------------------------------------------------------- +# Deduplication +# --------------------------------------------------------------------------- + +def find_similar( + character_id: str, + content: str, + memory_type: str = "semantic", + threshold: float = 0.85, +) -> dict | None: + """Find an existing memory that is semantically similar (>threshold). + Returns the matching memory dict or None.""" + db = _get_db() + query_emb = get_embedding(content) + + vec_rows = db.execute(""" + SELECT id, distance + FROM memory_embeddings + WHERE embedding MATCH ? + AND k = 5 + """, (_serialize_f32(query_emb),)).fetchall() + + for vr in vec_rows: + similarity = max(0.0, 1.0 - vr["distance"]) + if similarity >= threshold: + row = db.execute(""" + SELECT * FROM memories + WHERE id = ? AND character_id = ? AND memory_type = ? + AND lifecycle_state = 'active' + """, (vr["id"], character_id, memory_type)).fetchone() + if row: + return _row_to_dict(row) + + return None + + +def add_or_merge_memory( + character_id: str, + content: str, + memory_type: str | None = None, + category: str = "other", + importance: float | None = None, + privacy_level: str | None = None, + tags: list[str] | None = None, + follow_up_due: str | None = None, + follow_up_context: str | None = None, + source: str = "user_explicit", + expires_at: str | None = None, + dedup_threshold: float = 0.85, +) -> dict: + """Add a memory, or merge with an existing similar one (semantic dedup). + For semantic memories, if a similar one exists (>threshold), update it + instead of creating a new record.""" + resolved_type = memory_type or classify_memory(content)["memory_type"] + + if resolved_type == "semantic": + existing = find_similar(character_id, content, "semantic", dedup_threshold) + if existing: + updated = update_memory(existing["id"], content=content) + if updated: + return updated + + return add_memory( + character_id=character_id, + content=content, + memory_type=memory_type, + category=category, + importance=importance, + privacy_level=privacy_level, + tags=tags, + follow_up_due=follow_up_due, + follow_up_context=follow_up_context, + source=source, + expires_at=expires_at, + ) + + +# --------------------------------------------------------------------------- +# Migration from JSON +# --------------------------------------------------------------------------- + +# Mapping from old JSON categories to new memory types +_CATEGORY_TO_TYPE = { + "preference": "semantic", + "personal_info": "semantic", + "interaction": "episodic", + "emotional": "episodic", + "system": "semantic", + "tool_usage": "semantic", + "home_layout": "semantic", + "device": "semantic", + "routine": "semantic", + "other": "semantic", +} + +_CATEGORY_TO_IMPORTANCE = { + "personal_info": 0.7, + "preference": 0.6, + "emotional": 0.5, + "interaction": 0.4, + "system": 0.4, + "tool_usage": 0.3, + "home_layout": 0.5, + "device": 0.4, + "routine": 0.5, + "other": 0.4, +} + +_CATEGORY_TO_PRIVACY = { + "emotional": "sensitive", + "personal_info": "sensitive", +} + + +def migrate_from_json(memories_dir: str | None = None) -> dict: + """Migrate all JSON memory files to SQLite. + Returns {migrated: int, skipped: int, errors: [str]}.""" + db = _get_db() + mem_dir = Path(memories_dir) if memories_dir else MEMORIES_DIR + + migrated = 0 + skipped = 0 + errors = [] + + # Migrate personal memories + personal_dir = mem_dir / "personal" + if personal_dir.exists(): + for json_file in personal_dir.glob("*.json"): + try: + with open(json_file) as f: + data = json.load(f) + character_id = data.get("characterId", json_file.stem) + for mem in data.get("memories", []): + content = mem.get("content", "").strip() + if not content: + skipped += 1 + continue + category = mem.get("category", "other") + created_at = mem.get("createdAt", _now_iso()) + + try: + add_memory( + character_id=character_id, + content=content, + memory_type=_CATEGORY_TO_TYPE.get(category, "semantic"), + category=category, + importance=_CATEGORY_TO_IMPORTANCE.get(category, 0.5), + privacy_level=_CATEGORY_TO_PRIVACY.get(category, "standard"), + source="migrated_json", + ) + # Fix created_at to original value + db.execute( + "UPDATE memories SET created_at = ? WHERE id = (SELECT id FROM memories ORDER BY rowid DESC LIMIT 1)", + (created_at,), + ) + db.commit() + migrated += 1 + except Exception as e: + errors.append(f"personal/{json_file.name}: {e}") + + # Rename to backup + backup = json_file.with_suffix(".json.bak") + json_file.rename(backup) + except Exception as e: + errors.append(f"personal/{json_file.name}: {e}") + + # Migrate general memories + general_file = mem_dir / "general.json" + if general_file.exists(): + try: + with open(general_file) as f: + data = json.load(f) + for mem in data.get("memories", []): + content = mem.get("content", "").strip() + if not content: + skipped += 1 + continue + category = mem.get("category", "other") + created_at = mem.get("createdAt", _now_iso()) + + try: + add_memory( + character_id="shared", + content=content, + memory_type=_CATEGORY_TO_TYPE.get(category, "semantic"), + category=category, + importance=_CATEGORY_TO_IMPORTANCE.get(category, 0.5), + privacy_level="standard", + source="migrated_json", + ) + db.execute( + "UPDATE memories SET created_at = ? WHERE id = (SELECT id FROM memories ORDER BY rowid DESC LIMIT 1)", + (created_at,), + ) + db.commit() + migrated += 1 + except Exception as e: + errors.append(f"general.json: {e}") + + backup = general_file.with_suffix(".json.bak") + general_file.rename(backup) + except Exception as e: + errors.append(f"general.json: {e}") + + return {"migrated": migrated, "skipped": skipped, "errors": errors} diff --git a/homeai-agent/openclaw-http-bridge.py b/homeai-agent/openclaw-http-bridge.py index 334602f..4262a29 100644 --- a/homeai-agent/openclaw-http-bridge.py +++ b/homeai-agent/openclaw-http-bridge.py @@ -37,6 +37,26 @@ from pathlib import Path import wave import io import re +from datetime import datetime, timezone +from urllib.parse import parse_qs + +from memory_store import ( + init_db as init_memory_db, + retrieve_memories as _retrieve_memories, + get_pending_followups, + auto_resolve_expired_followups, + auto_archive_old_resolved, + increment_surfaced_count, + add_memory as _add_memory, + add_or_merge_memory, + update_memory as _update_memory, + delete_memory as _delete_memory, + list_memories as _list_memories, + search_memories as _search_memories, + resolve_followup, + count_memories, + migrate_from_json, +) from wyoming.client import AsyncTcpClient from wyoming.tts import Synthesize, SynthesizeVoice from wyoming.asr import Transcribe, Transcript @@ -48,7 +68,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" +DEFAULT_MODEL = "anthropic/claude-sonnet-4-6" def _vtube_fire_and_forget(path: str, data: dict): @@ -85,12 +105,21 @@ 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") +ACTIVE_STYLE_PATH = Path("/Users/aodhan/homeai-data/active-prompt-style.json") +PROMPT_STYLES_DIR = Path(__file__).parent / "prompt-styles" -# Cloud provider model mappings for mode routing +# Cloud provider model mappings for mode routing (fallback when style has no model) CLOUD_MODELS = { - "anthropic": "anthropic/claude-sonnet-4-20250514", + "anthropic": "anthropic/claude-sonnet-4-6", "openai": "openai/gpt-4o", } +LOCAL_MODEL = "ollama/qwen3.5:35b-a3b" + +# Lock to serialise model-switch + agent-call (openclaw config is global) +_model_lock = threading.Lock() + +# Initialize memory database at module load +init_memory_db() def load_mode() -> dict: @@ -102,11 +131,56 @@ def load_mode() -> dict: 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).""" +def resolve_model(mode_data: dict) -> str: + """Resolve which model to use based on mode.""" mode = mode_data.get("mode", "private") if mode == "private": - return None # Use OpenClaw default (ollama/qwen3.5:35b-a3b) + return mode_data.get("local_model", LOCAL_MODEL) + provider = mode_data.get("cloud_provider", "anthropic") + return CLOUD_MODELS.get(provider, CLOUD_MODELS["anthropic"]) + + +def load_prompt_style(style_id: str) -> dict: + """Load a prompt style template by ID. Returns the style dict or a default.""" + if not style_id: + style_id = "standard" + safe_id = style_id.replace("/", "_").replace("..", "") + style_path = PROMPT_STYLES_DIR / f"{safe_id}.json" + try: + with open(style_path) as f: + return json.load(f) + except Exception: + return {"id": "standard", "name": "Standard", "group": "cloud", "instruction": "", "strip_sections": []} + + +def load_active_style() -> str: + """Load the active prompt style ID from state file. Defaults to 'standard'.""" + try: + with open(ACTIVE_STYLE_PATH) as f: + data = json.load(f) + return data.get("style", "standard") + except Exception: + return "standard" + + +def resolve_model_for_style(style: dict, mode_data: dict) -> str: + """Resolve model based on prompt style, falling back to mode config. + Priority: style 'model' field > group-based routing > mode default.""" + mode = mode_data.get("mode", "private") + group = style.get("group", "cloud") + + # Private mode always uses local model regardless of style + if mode == "private" and group == "local": + return mode_data.get("local_model", LOCAL_MODEL) + + # Per-style model override (e.g. haiku for quick, opus for roleplay) + style_model = style.get("model") + if style_model: + return style_model + + # Fallback: cloud model from mode config + if group == "local": + return mode_data.get("local_model", LOCAL_MODEL) provider = mode_data.get("cloud_provider", "anthropic") return CLOUD_MODELS.get(provider, CLOUD_MODELS["anthropic"]) @@ -192,31 +266,44 @@ def load_character(character_id: str = None) -> dict: return {} -def load_character_prompt(satellite_id: str = None, character_id: str = None) -> str: +def load_character_prompt(satellite_id: str = None, character_id: str = None, + prompt_style: str = None, user_message: str = "", + is_cloud: bool = False) -> str: """Load the full system prompt for a character, resolved by satellite or explicit ID. - Builds a rich prompt from system_prompt + profile fields (background, dialogue_style, etc.).""" + Builds a rich prompt from style instruction + system_prompt + profile fields + memories. + The prompt_style controls HOW the character responds (brief, conversational, roleplay, etc.).""" if not character_id: character_id = resolve_character_id(satellite_id) char = load_character(character_id) if not char: return "" + # Load prompt style template + style_id = prompt_style or load_active_style() + style = load_prompt_style(style_id) + strip_sections = set(style.get("strip_sections", [])) + sections = [] - # Core system prompt + # 1. Response style instruction (framing directive — goes first) + instruction = style.get("instruction", "") + if instruction: + sections.append(f"[Response Style: {style.get('name', style_id)}]\n{instruction}") + + # 2. Core character identity (system_prompt) prompt = char.get("system_prompt", "") if prompt: sections.append(prompt) - # Character profile fields + # 3. Character profile fields (filtered by style's strip_sections) profile_parts = [] - if char.get("background"): + if "background" not in strip_sections and char.get("background"): profile_parts.append(f"## Background\n{char['background']}") - if char.get("appearance"): + if "appearance" not in strip_sections and char.get("appearance"): profile_parts.append(f"## Appearance\n{char['appearance']}") - if char.get("dialogue_style"): + if "dialogue_style" not in strip_sections and char.get("dialogue_style"): profile_parts.append(f"## Dialogue Style\n{char['dialogue_style']}") - if char.get("skills"): + if "skills" not in strip_sections and char.get("skills"): skills = char["skills"] if isinstance(skills, list): skills_text = ", ".join(skills[:15]) @@ -226,7 +313,18 @@ def load_character_prompt(satellite_id: str = None, character_id: str = None) -> if profile_parts: sections.append("[Character Profile]\n" + "\n\n".join(profile_parts)) - # Character metadata + # 4. Per-character style overrides (optional customization per style) + style_overrides = char.get("prompt_style_overrides", {}).get(style_id, {}) + if style_overrides: + override_parts = [] + if style_overrides.get("dialogue_style"): + override_parts.append(f"## Dialogue Style Override\n{style_overrides['dialogue_style']}") + if style_overrides.get("system_prompt_suffix"): + override_parts.append(style_overrides["system_prompt_suffix"]) + if override_parts: + sections.append("[Style-Specific Notes]\n" + "\n\n".join(override_parts)) + + # 5. Character metadata meta_lines = [] if char.get("display_name"): meta_lines.append(f"Your name is: {char['display_name']}") @@ -243,47 +341,86 @@ def load_character_prompt(satellite_id: str = None, character_id: str = None) -> if meta_lines: sections.append("[Character Metadata]\n" + "\n".join(meta_lines)) - # Memories (personal + general) - personal, general = load_memories(character_id) + # 6. Memories (personal + general, context-aware retrieval) + personal, general, followups = load_memories(character_id, context=user_message, is_cloud=is_cloud) if personal: sections.append("[Personal Memories]\n" + "\n".join(f"- {m}" for m in personal)) if general: sections.append("[General Knowledge]\n" + "\n".join(f"- {m}" for m in general)) + # 7. Pending follow-ups (things the character should naturally bring up) + if followups: + followup_lines = [ + f"- {fu['follow_up_context']} (from {fu['created_at'][:10]})" + for fu in followups[:3] + ] + sections.append( + "[Pending Follow-ups — Bring these up naturally if relevant]\n" + "You have unresolved topics to check on with the user. " + "Weave them into conversation naturally — don't list them. " + "If the user addresses one, use memory-ctl resolve to mark it resolved.\n" + + "\n".join(followup_lines) + ) + return "\n\n".join(sections) -def load_memories(character_id: str) -> tuple[list[str], list[str]]: - """Load personal (per-character) and general memories. - Returns (personal_contents, general_contents) truncated to fit context budget.""" - PERSONAL_BUDGET = 4000 # max chars for personal memories in prompt - GENERAL_BUDGET = 3000 # max chars for general memories in prompt +def _truncate_to_budget(contents: list[str], budget: int) -> list[str]: + """Truncate a list of strings to fit within a character budget.""" + result = [] + used = 0 + for content in contents: + if used + len(content) > budget: + break + result.append(content) + used += len(content) + return result - def _read_memories(path: Path, budget: int) -> list[str]: + +def load_memories(character_id: str, context: str = "", is_cloud: bool = False) -> tuple[list[str], list[str], list[dict]]: + """Load personal and general memories using semantic + recency retrieval. + Returns (personal_contents, general_contents, pending_followups).""" + PERSONAL_BUDGET = 4000 + GENERAL_BUDGET = 3000 + + # Check if SQLite has any memories; fall back to JSON if empty (pre-migration) + if count_memories(character_id) == 0 and count_memories("shared") == 0: + return _load_memories_json_fallback(character_id), [], [] + + personal_mems = _retrieve_memories(character_id, context, limit=15, + exclude_private_for_cloud=is_cloud) + general_mems = _retrieve_memories("shared", context, limit=10, + exclude_private_for_cloud=is_cloud) + followups = get_pending_followups(character_id) + + personal = _truncate_to_budget([m["content"] for m in personal_mems], PERSONAL_BUDGET) + general = _truncate_to_budget([m["content"] for m in general_mems], GENERAL_BUDGET) + + return personal, general, followups + + +def _load_memories_json_fallback(character_id: str) -> list[str]: + """Legacy JSON fallback for pre-migration state.""" + def _read(path: Path, budget: int) -> list[str]: try: with open(path) as f: data = json.load(f) except Exception: return [] memories = data.get("memories", []) - # Sort newest first memories.sort(key=lambda m: m.get("createdAt", ""), reverse=True) - result = [] - used = 0 + result, used = [], 0 for m in memories: content = m.get("content", "").strip() if not content: continue - if used + len(content) > budget: + if used + len(content) > 4000: break result.append(content) used += len(content) return result - safe_id = character_id.replace("/", "_") - personal = _read_memories(MEMORIES_DIR / "personal" / f"{safe_id}.json", PERSONAL_BUDGET) - general = _read_memories(MEMORIES_DIR / "general.json", GENERAL_BUDGET) - return personal, general + return _read(MEMORIES_DIR / "personal" / f"{safe_id}.json", 4000) class OpenClawBridgeHandler(BaseHTTPRequestHandler): @@ -297,6 +434,7 @@ class OpenClawBridgeHandler(BaseHTTPRequestHandler): """Send a JSON response.""" self.send_response(status_code) self.send_header("Content-Type", "application/json") + self.send_header("Access-Control-Allow-Origin", "*") self.end_headers() self.wfile.write(json.dumps(data).encode()) @@ -319,11 +457,17 @@ class OpenClawBridgeHandler(BaseHTTPRequestHandler): self._handle_stt_request() return - # Only handle the agent message endpoint + # Agent message endpoint if parsed_path.path == "/api/agent/message": self._handle_agent_request() return - + + # Memory API: POST /api/memories/... + if parsed_path.path.startswith("/api/memories/"): + parts = parsed_path.path[len("/api/memories/"):].strip("/").split("/") + self._handle_memory_post(parts) + return + self._send_json_response(404, {"error": "Not found"}) def _handle_tts_request(self): @@ -399,11 +543,29 @@ class OpenClawBridgeHandler(BaseHTTPRequestHandler): audio_bytes = resp.read() return audio_bytes, "audio/mpeg" + def do_PUT(self): + """Handle PUT requests (memory updates).""" + parsed_path = urlparse(self.path) + if parsed_path.path.startswith("/api/memories/"): + parts = parsed_path.path[len("/api/memories/"):].strip("/").split("/") + self._handle_memory_put(parts) + return + self._send_json_response(404, {"error": "Not found"}) + + def do_DELETE(self): + """Handle DELETE requests (memory deletion).""" + parsed_path = urlparse(self.path) + if parsed_path.path.startswith("/api/memories/"): + parts = parsed_path.path[len("/api/memories/"):].strip("/").split("/") + self._handle_memory_delete(parts) + return + self._send_json_response(404, {"error": "Not found"}) + def do_OPTIONS(self): """Handle CORS preflight requests.""" self.send_response(204) self.send_header("Access-Control-Allow-Origin", "*") - self.send_header("Access-Control-Allow-Methods", "POST, GET, OPTIONS") + self.send_header("Access-Control-Allow-Methods", "POST, GET, PUT, DELETE, OPTIONS") self.send_header("Access-Control-Allow-Headers", "Content-Type") self.end_headers() @@ -531,19 +693,55 @@ class OpenClawBridgeHandler(BaseHTTPRequestHandler): self._send_json_response(200, {"status": "ok", "message": "Wake word received"}) @staticmethod - 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( - cmd, - capture_output=True, - text=True, - timeout=timeout, - check=True, + def _config_set(path: str, value: str): + """Set an OpenClaw config value.""" + subprocess.run( + ["/opt/homebrew/bin/openclaw", "config", "set", path, value], + capture_output=True, text=True, timeout=5, ) - return result.stdout.strip() + + @staticmethod + def _call_openclaw(message: str, agent: str, timeout: int, + model: str = None, session_id: str = None, + params: dict = None, thinking: str = None) -> str: + """Call OpenClaw CLI and return stdout. + Temporarily switches the gateway's primary model and inference params + via `openclaw config set`, protected by _model_lock to prevent races.""" + cmd = ["/opt/homebrew/bin/openclaw", "agent", "--message", message, "--agent", agent] + if session_id: + cmd.extend(["--session-id", session_id]) + if thinking: + cmd.extend(["--thinking", thinking]) + + with _model_lock: + if model: + OpenClawBridgeHandler._config_set( + "agents.defaults.model.primary", model) + + # Set per-style temperature if provided + temp_path = None + if model and params and params.get("temperature") is not None: + temp_path = f'agents.defaults.models["{model}"].params.temperature' + OpenClawBridgeHandler._config_set( + temp_path, str(params["temperature"])) + + try: + result = subprocess.run( + cmd, + capture_output=True, + text=True, + timeout=timeout, + check=True, + ) + return result.stdout.strip() + finally: + # Restore defaults + if model and model != DEFAULT_MODEL: + OpenClawBridgeHandler._config_set( + "agents.defaults.model.primary", DEFAULT_MODEL) + if temp_path: + # Restore to neutral default + OpenClawBridgeHandler._config_set(temp_path, "0.5") @staticmethod def _needs_followup(response: str) -> bool: @@ -588,6 +786,8 @@ class OpenClawBridgeHandler(BaseHTTPRequestHandler): agent = data.get("agent", "main") satellite_id = data.get("satellite_id") explicit_character_id = data.get("character_id") + requested_style = data.get("prompt_style") + conversation_id = data.get("conversation_id") if not message: self._send_json_response(400, {"error": "Message is required"}) @@ -598,10 +798,28 @@ class OpenClawBridgeHandler(BaseHTTPRequestHandler): character_id = explicit_character_id else: character_id = resolve_character_id(satellite_id) - system_prompt = load_character_prompt(character_id=character_id) + + # Resolve prompt style: explicit > character default > global active + char = load_character(character_id) + style_id = requested_style or char.get("default_prompt_style") or load_active_style() + style = load_prompt_style(style_id) + print(f"[OpenClaw Bridge] Prompt style: {style.get('name', style_id)} ({style.get('group', 'cloud')})") + + # Determine if routing to cloud (for privacy filtering) + mode_data = load_mode() + active_model = resolve_model_for_style(style, mode_data) + is_cloud = style.get("group", "cloud") == "cloud" and mode_data.get("mode") != "private" + + system_prompt = load_character_prompt( + character_id=character_id, prompt_style=style_id, + user_message=message, is_cloud=is_cloud, + ) + + # Run lifecycle maintenance (cheap SQL updates) + auto_resolve_expired_followups() + auto_archive_old_resolved() # Set the active TTS config for the Wyoming server to pick up - char = load_character(character_id) tts_config = char.get("tts", {}) if tts_config: set_active_tts_voice(character_id, tts_config) @@ -616,14 +834,30 @@ 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}") + group = style.get("group", "cloud") + print(f"[OpenClaw Bridge] Routing: {group.upper()} → {active_model}") + + # Resolve session ID for OpenClaw thread isolation + # Dashboard chats: use conversation_id (each "New Chat" = fresh thread) + # Satellites: use rotating 12-hour bucket so old context expires naturally + if conversation_id: + session_id = conversation_id + elif satellite_id: + now = datetime.now(timezone.utc) + half = "am" if now.hour < 12 else "pm" + session_id = f"sat_{satellite_id}_{now.strftime('%Y%m%d')}_{half}" else: - print(f"[OpenClaw Bridge] Mode: PRIVATE ({active_model})") + # API call with no conversation or satellite — use a transient session + session_id = f"api_{int(datetime.now(timezone.utc).timestamp())}" + print(f"[OpenClaw Bridge] Session: {session_id}") + + # Extract style inference params (temperature, etc.) and thinking level + style_params = style.get("params", {}) + style_thinking = style.get("thinking") + if style_params: + print(f"[OpenClaw Bridge] Style params: {style_params}") + if style_thinking: + print(f"[OpenClaw Bridge] Thinking: {style_thinking}") # Check if model is warm to set appropriate timeout warm = is_model_warm() @@ -635,7 +869,7 @@ class OpenClawBridgeHandler(BaseHTTPRequestHandler): # Call OpenClaw CLI (use full path for launchd compatibility) try: - response_text = self._call_openclaw(message, agent, timeout, model=model_override) + response_text = self._call_openclaw(message, agent, timeout, model=active_model, session_id=session_id, params=style_params, thinking=style_thinking) # 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. @@ -645,11 +879,19 @@ 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, model=model_override) + response_text = self._call_openclaw(followup, agent, timeout, model=active_model, session_id=session_id, params=style_params, thinking=style_thinking) + + # Increment surfaced_count on follow-ups that were injected into prompt + try: + followups = get_pending_followups(character_id) + for fu in followups[:3]: + increment_surfaced_count(fu["id"]) + except Exception as e: + print(f"[OpenClaw Bridge] Follow-up tracking error: {e}") # 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, "model": active_model}) + self._send_json_response(200, {"response": response_text, "model": active_model, "prompt_style": style_id}) 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: @@ -660,18 +902,174 @@ class OpenClawBridgeHandler(BaseHTTPRequestHandler): except Exception as e: self._send_json_response(500, {"error": str(e)}) - def do_GET(self): - """Handle GET requests (health check).""" - parsed_path = urlparse(self.path) + # ------------------------------------------------------------------ + # Memory REST API + # ------------------------------------------------------------------ - if parsed_path.path == "/status" or parsed_path.path == "/": + def _read_json_body(self) -> dict | None: + """Read and parse JSON body from request. Returns None on error (response already sent).""" + content_length = int(self.headers.get("Content-Length", 0)) + if content_length == 0: + self._send_json_response(400, {"error": "Empty body"}) + return None + try: + return json.loads(self.rfile.read(content_length).decode()) + except json.JSONDecodeError: + self._send_json_response(400, {"error": "Invalid JSON"}) + return None + + def _handle_memory_get(self, path_parts: list[str], query_params: dict): + """Handle GET /api/memories/...""" + # GET /api/memories/general + if len(path_parts) == 1 and path_parts[0] == "general": + limit = int(query_params.get("limit", ["50"])[0]) + offset = int(query_params.get("offset", ["0"])[0]) + memory_type = query_params.get("type", [None])[0] + lifecycle = query_params.get("lifecycle", [None])[0] + category = query_params.get("category", [None])[0] + memories = _list_memories("shared", memory_type=memory_type, + lifecycle_state=lifecycle, category=category, + limit=limit, offset=offset) + self._send_json_response(200, {"memories": memories}) + return + + if len(path_parts) < 1: + self._send_json_response(400, {"error": "Character ID required"}) + return + + char_id = path_parts[0] + + # GET /api/memories/:characterId/followups + if len(path_parts) == 2 and path_parts[1] == "followups": + followups = get_pending_followups(char_id) + self._send_json_response(200, {"followups": followups}) + return + + # GET /api/memories/:characterId + limit = int(query_params.get("limit", ["50"])[0]) + offset = int(query_params.get("offset", ["0"])[0]) + memory_type = query_params.get("type", [None])[0] + lifecycle = query_params.get("lifecycle", [None])[0] + category = query_params.get("category", [None])[0] + query = query_params.get("q", [None])[0] + + if query: + memories = _search_memories(char_id, query, memory_type=memory_type, limit=limit) + else: + memories = _list_memories(char_id, memory_type=memory_type, + lifecycle_state=lifecycle, category=category, + limit=limit, offset=offset) + self._send_json_response(200, {"memories": memories, "characterId": char_id}) + + def _handle_memory_post(self, path_parts: list[str]): + """Handle POST /api/memories/...""" + data = self._read_json_body() + if data is None: + return + + # POST /api/memories/migrate + if len(path_parts) == 1 and path_parts[0] == "migrate": + result = migrate_from_json() + self._send_json_response(200, result) + return + + # POST /api/memories/:memoryId/resolve + if len(path_parts) == 2 and path_parts[1] == "resolve": + ok = resolve_followup(path_parts[0]) + self._send_json_response(200 if ok else 404, + {"ok": ok, "id": path_parts[0]}) + return + + # POST /api/memories/general — add general memory + if len(path_parts) == 1 and path_parts[0] == "general": + content = data.get("content", "").strip() + if not content: + self._send_json_response(400, {"error": "content is required"}) + return + mem = add_or_merge_memory( + character_id="shared", + content=content, + memory_type=data.get("memory_type"), + category=data.get("category", "other"), + importance=data.get("importance"), + privacy_level=data.get("privacy_level"), + tags=data.get("tags"), + source=data.get("source", "dashboard"), + ) + self._send_json_response(200, {"ok": True, "memory": mem}) + return + + # POST /api/memories/:characterId — add personal memory + if len(path_parts) == 1: + char_id = path_parts[0] + content = data.get("content", "").strip() + if not content: + self._send_json_response(400, {"error": "content is required"}) + return + mem = add_or_merge_memory( + character_id=char_id, + content=content, + memory_type=data.get("memory_type"), + category=data.get("category", "other"), + importance=data.get("importance"), + privacy_level=data.get("privacy_level"), + tags=data.get("tags"), + follow_up_due=data.get("follow_up_due"), + follow_up_context=data.get("follow_up_context"), + source=data.get("source", "dashboard"), + ) + self._send_json_response(200, {"ok": True, "memory": mem}) + return + + self._send_json_response(404, {"error": "Not found"}) + + def _handle_memory_put(self, path_parts: list[str]): + """Handle PUT /api/memories/:memoryId — update a memory.""" + if len(path_parts) != 1: + self._send_json_response(400, {"error": "Memory ID required"}) + return + data = self._read_json_body() + if data is None: + return + mem = _update_memory(path_parts[0], **data) + if mem: + self._send_json_response(200, {"ok": True, "memory": mem}) + else: + self._send_json_response(404, {"error": "Memory not found"}) + + def _handle_memory_delete(self, path_parts: list[str]): + """Handle DELETE /api/memories/:memoryId.""" + if len(path_parts) != 1: + self._send_json_response(400, {"error": "Memory ID required"}) + return + ok = _delete_memory(path_parts[0]) + self._send_json_response(200 if ok else 404, {"ok": ok, "id": path_parts[0]}) + + # ------------------------------------------------------------------ + # HTTP method dispatchers + # ------------------------------------------------------------------ + + def do_GET(self): + """Handle GET requests.""" + parsed_path = urlparse(self.path) + path = parsed_path.path + + if path == "/status" or path == "/": self._send_json_response(200, { "status": "ok", "service": "OpenClaw HTTP Bridge", - "version": "1.0.0" + "version": "2.0.0" }) - else: - self._send_json_response(404, {"error": "Not found"}) + return + + # Memory API: GET /api/memories/... + if path.startswith("/api/memories/"): + parts = path[len("/api/memories/"):].strip("/").split("/") + query_params = parse_qs(parsed_path.query) + self._handle_memory_get(parts, query_params) + return + + self._send_json_response(404, {"error": "Not found"}) class ThreadingHTTPServer(ThreadingMixIn, HTTPServer): diff --git a/homeai-agent/prompt-styles/creative.json b/homeai-agent/prompt-styles/creative.json new file mode 100644 index 0000000..375b813 --- /dev/null +++ b/homeai-agent/prompt-styles/creative.json @@ -0,0 +1,13 @@ +{ + "id": "creative", + "name": "Creative", + "group": "cloud", + "model": "anthropic/claude-sonnet-4-6", + "description": "In-depth answers, longer conversational responses", + "thinking": "low", + "params": { + "temperature": 0.7 + }, + "instruction": "Give thorough, in-depth answers. Respond at whatever length the topic requires — short for simple things, long for complex ones. Be conversational and engaging, like a knowledgeable friend. Vary your sentence structure and word choice to keep things interesting. Do not use roleplay actions or narration. If a topic has interesting depth worth exploring, offer to continue. This mode is for rich conversation, not commands.", + "strip_sections": [] +} diff --git a/homeai-agent/prompt-styles/game-master.json b/homeai-agent/prompt-styles/game-master.json new file mode 100644 index 0000000..9c4f3d4 --- /dev/null +++ b/homeai-agent/prompt-styles/game-master.json @@ -0,0 +1,13 @@ +{ + "id": "game-master", + "name": "Game Master", + "group": "cloud", + "model": "anthropic/claude-opus-4-6", + "description": "Second-person interactive narration with user as participant", + "thinking": "off", + "params": { + "temperature": 0.9 + }, + "instruction": "Narrate in second person — the user is the subject experiencing the scene. Describe what they see, hear, and feel with vivid, varied language. Write your character's dialogue in quotes and their actions in prose. After describing the scene or an interaction, prompt the user for their next action. Keep the user engaged as an active participant. Balance rich description with opportunities for user agency. Avoid repeating descriptive patterns — each scene should feel fresh and unpredictable. This is a 2nd-person interactive experience.", + "strip_sections": [] +} diff --git a/homeai-agent/prompt-styles/quick.json b/homeai-agent/prompt-styles/quick.json new file mode 100644 index 0000000..cd009e4 --- /dev/null +++ b/homeai-agent/prompt-styles/quick.json @@ -0,0 +1,13 @@ +{ + "id": "quick", + "name": "Quick", + "group": "cloud", + "model": "anthropic/claude-haiku-4-5-20251001", + "description": "Brief responses for commands and quick questions", + "thinking": "off", + "params": { + "temperature": 0.15 + }, + "instruction": "RESPONSE RULES — STRICT:\n- Respond as briefly as possible. For smart home commands, confirm with 1-3 words (\"Done.\", \"Lights on.\", \"Playing jazz.\").\n- For factual questions, give the shortest correct answer. One sentence max.\n- No small talk, no elaboration, no follow-up questions unless the request is genuinely ambiguous.\n- Never describe your actions, emotions, or thought process.\n- Never add flair, personality, or creative embellishments — be a reliable, predictable tool.\n- If a tool call is needed, execute it and report the result. Nothing else.", + "strip_sections": ["background", "appearance", "dialogue_style"] +} diff --git a/homeai-agent/prompt-styles/roleplayer.json b/homeai-agent/prompt-styles/roleplayer.json new file mode 100644 index 0000000..e9d458e --- /dev/null +++ b/homeai-agent/prompt-styles/roleplayer.json @@ -0,0 +1,13 @@ +{ + "id": "roleplayer", + "name": "Roleplayer", + "group": "cloud", + "model": "anthropic/claude-opus-4-6", + "description": "First-person roleplay with character actions and expressions", + "thinking": "off", + "params": { + "temperature": 0.85 + }, + "instruction": "Respond entirely in first person as your character. Use action descriptions enclosed in asterisks (*adjusts glasses*, *leans forward thoughtfully*) to convey body language, emotions, and physical actions. Stay fully in character at all times — your personality, speech patterns, and mannerisms should be consistent with your character profile. React emotionally and physically to what the user says. Vary your expressions, gestures, and phrasings — never repeat the same actions or sentence structures. Surprise the user with unexpected but in-character reactions. This is an immersive 1st-person interaction.", + "strip_sections": [] +} diff --git a/homeai-agent/prompt-styles/standard.json b/homeai-agent/prompt-styles/standard.json new file mode 100644 index 0000000..715dcf5 --- /dev/null +++ b/homeai-agent/prompt-styles/standard.json @@ -0,0 +1,13 @@ +{ + "id": "standard", + "name": "Standard", + "group": "cloud", + "model": "anthropic/claude-sonnet-4-6", + "description": "Conversational responses, concise but informative", + "thinking": "off", + "params": { + "temperature": 0.4 + }, + "instruction": "Respond naturally and conversationally. Be concise but informative — a few sentences is ideal. Do not use roleplay actions, narration, or describe your expressions/body language. Treat the interaction as a chat, not a performance. Stay helpful, on-topic, and consistent. Prioritise clarity and accuracy over flair.", + "strip_sections": [] +} diff --git a/homeai-agent/prompt-styles/storyteller.json b/homeai-agent/prompt-styles/storyteller.json new file mode 100644 index 0000000..34cc0b6 --- /dev/null +++ b/homeai-agent/prompt-styles/storyteller.json @@ -0,0 +1,13 @@ +{ + "id": "storyteller", + "name": "Storyteller", + "group": "cloud", + "model": "anthropic/claude-opus-4-6", + "description": "Third-person narrative with periodic reader check-ins", + "thinking": "off", + "params": { + "temperature": 0.95 + }, + "instruction": "Narrate in third person as a storyteller. Describe scenes, character actions, dialogue, and atmosphere as a novelist would. Your character should be written about, not speaking as themselves directly to the user. Write rich, evocative prose with varied vocabulary, rhythm, and imagery. Avoid formulaic descriptions — each passage should have its own texture and mood. Periodically check in with the reader about story direction. The user drives the direction but you drive the narrative between check-ins. This is a 3rd-person storytelling experience.", + "strip_sections": [] +} diff --git a/homeai-dashboard/launchd/com.homeai.dashboard.plist b/homeai-dashboard/launchd/com.homeai.dashboard.plist index 7c3bc69..8239745 100644 --- a/homeai-dashboard/launchd/com.homeai.dashboard.plist +++ b/homeai-dashboard/launchd/com.homeai.dashboard.plist @@ -26,6 +26,8 @@ /Users/aodhan GAZE_API_KEY e63401f17e4845e1059f830267f839fe7fc7b6083b1cb1730863318754d799f4 + HA_TOKEN + RunAtLoad diff --git a/homeai-dashboard/schema/character.schema.json b/homeai-dashboard/schema/character.schema.json index ff222d0..977212b 100644 --- a/homeai-dashboard/schema/character.schema.json +++ b/homeai-dashboard/schema/character.schema.json @@ -46,6 +46,16 @@ } }, + "dream_id": { + "type": "string", + "description": "Linked Dream character ID for syncing character data and images" + }, + + "gaze_character": { + "type": "string", + "description": "Linked GAZE character_id for auto-assigned cover image and default image generation preset" + }, + "gaze_presets": { "type": "array", "description": "GAZE image generation presets with trigger conditions", @@ -72,7 +82,25 @@ } }, - "notes": { "type": "string" } + "notes": { "type": "string" }, + + "default_prompt_style": { + "type": "string", + "description": "Default prompt style for this character (quick, standard, creative, roleplayer, game-master, storyteller). Overrides global active style when this character is active.", + "enum": ["", "quick", "standard", "creative", "roleplayer", "game-master", "storyteller"] + }, + + "prompt_style_overrides": { + "type": "object", + "description": "Per-style customizations for this character. Keys are style IDs, values contain override fields.", + "additionalProperties": { + "type": "object", + "properties": { + "dialogue_style": { "type": "string", "description": "Override dialogue style for this prompt style" }, + "system_prompt_suffix": { "type": "string", "description": "Additional instructions appended for this prompt style" } + } + } + } }, "additionalProperties": true } diff --git a/homeai-dashboard/src/App.jsx b/homeai-dashboard/src/App.jsx index f1cb18e..6a9fd92 100644 --- a/homeai-dashboard/src/App.jsx +++ b/homeai-dashboard/src/App.jsx @@ -1,14 +1,16 @@ -import { BrowserRouter, Routes, Route, NavLink } from 'react-router-dom'; +import { useState, useCallback, useEffect } from 'react'; +import { BrowserRouter, Routes, Route, NavLink, useLocation } from 'react-router-dom'; import Dashboard from './pages/Dashboard'; import Chat from './pages/Chat'; import Characters from './pages/Characters'; import Editor from './pages/Editor'; import Memories from './pages/Memories'; -function NavItem({ to, children, icon }) { +function NavItem({ to, children, icon, onClick }) { return ( `flex items-center gap-3 px-4 py-2.5 rounded-lg text-sm font-medium transition-colors ${ isActive @@ -24,22 +26,78 @@ function NavItem({ to, children, icon }) { } function Layout({ children }) { + const [sidebarOpen, setSidebarOpen] = useState(false) + const location = useLocation() + + // Close sidebar on route change (mobile) + useEffect(() => { + setSidebarOpen(false) + }, [location.pathname]) + + const closeSidebar = useCallback(() => setSidebarOpen(false), []) + return (
+ {/* Mobile header bar */} +
+ +
+
+ + + +
+ HomeAI +
+
+ + {/* Mobile backdrop */} + {sidebarOpen && ( +
+ )} + {/* Sidebar */} -
@@ -122,11 +185,11 @@ function App() { -
} /> +
} /> } /> -
} /> -
} /> -
} /> +
} /> +
} /> +
} /> diff --git a/homeai-dashboard/src/components/ChatPanel.jsx b/homeai-dashboard/src/components/ChatPanel.jsx index e1a602a..2e53b81 100644 --- a/homeai-dashboard/src/components/ChatPanel.jsx +++ b/homeai-dashboard/src/components/ChatPanel.jsx @@ -2,7 +2,7 @@ import { useEffect, useRef } from 'react' import MessageBubble from './MessageBubble' import ThinkingIndicator from './ThinkingIndicator' -export default function ChatPanel({ messages, isLoading, onReplay, character }) { +export default function ChatPanel({ messages, isLoading, onReplay, onRetry, character }) { const bottomRef = useRef(null) const name = character?.name || 'AI' const image = character?.image || null @@ -32,7 +32,7 @@ export default function ChatPanel({ messages, isLoading, onReplay, character }) return (
{messages.map((msg) => ( - + ))} {isLoading && }
diff --git a/homeai-dashboard/src/components/ConversationList.jsx b/homeai-dashboard/src/components/ConversationList.jsx index 34b15d9..c0474b3 100644 --- a/homeai-dashboard/src/components/ConversationList.jsx +++ b/homeai-dashboard/src/components/ConversationList.jsx @@ -10,61 +10,95 @@ function timeAgo(dateStr) { return `${days}d ago` } -export default function ConversationList({ conversations, activeId, onCreate, onSelect, onDelete }) { +export default function ConversationList({ conversations, activeId, onCreate, onSelect, onDelete, isOpen, onToggle }) { return ( -
- {/* New chat button */} -
- -
+ <> + {/* Mobile toggle button */} + - {/* Conversation list */} -
- {conversations.length === 0 ? ( -

No conversations yet

- ) : ( - conversations.map(conv => ( -
onSelect(conv.id)} - className={`group flex items-start gap-2 px-3 py-2.5 cursor-pointer border-b border-gray-800/50 transition-colors ${ - conv.id === activeId - ? 'bg-gray-800 text-white' - : 'text-gray-400 hover:bg-gray-800/50 hover:text-gray-200' - }`} - > -
-

- {conv.title || 'New conversation'} -

-
- {conv.characterName && ( - {conv.characterName} - )} - {timeAgo(conv.updatedAt)} -
-
- + +
+ + {/* Conversation list */} +
+ {conversations.length === 0 ? ( +

No conversations yet

+ ) : ( + conversations.map(conv => ( +
{ onSelect(conv.id); if (onToggle) onToggle() }} + className={`group flex items-start gap-2 px-3 py-2.5 cursor-pointer border-b border-gray-800/50 transition-colors ${ + conv.id === activeId + ? 'bg-gray-800 text-white' + : 'text-gray-400 hover:bg-gray-800/50 hover:text-gray-200' + }`} > - - - - -
- )) - )} +
+

+ {conv.title || 'New conversation'} +

+
+ {conv.characterName && ( + {conv.characterName} + )} + {timeAgo(conv.updatedAt)} +
+
+ +
+ )) + )} +
-
+ ) } diff --git a/homeai-dashboard/src/components/InputBar.jsx b/homeai-dashboard/src/components/InputBar.jsx index 0ec437f..372c271 100644 --- a/homeai-dashboard/src/components/InputBar.jsx +++ b/homeai-dashboard/src/components/InputBar.jsx @@ -20,7 +20,7 @@ export default function InputBar({ onSend, onVoiceToggle, isLoading, isRecording } return ( -
+
diff --git a/homeai-dashboard/src/components/MessageBubble.jsx b/homeai-dashboard/src/components/MessageBubble.jsx index 3615af8..7c225fd 100644 --- a/homeai-dashboard/src/components/MessageBubble.jsx +++ b/homeai-dashboard/src/components/MessageBubble.jsx @@ -88,12 +88,12 @@ function RichContent({ text }) { ) } -export default function MessageBubble({ message, onReplay, character }) { +export default function MessageBubble({ message, onReplay, onRetry, character }) { const isUser = message.role === 'user' return ( -
-
+
+
{!isUser && }
)} + {message.isError && onRetry && ( + + )} {!message.isError && onReplay && ( + ) + })} +
+ ) + ))} +
+ ) +} diff --git a/homeai-dashboard/src/components/SettingsDrawer.jsx b/homeai-dashboard/src/components/SettingsDrawer.jsx index b296fe4..8d984e6 100644 --- a/homeai-dashboard/src/components/SettingsDrawer.jsx +++ b/homeai-dashboard/src/components/SettingsDrawer.jsx @@ -8,7 +8,7 @@ export default function SettingsDrawer({ isOpen, onClose, settings, onUpdate }) return ( <>
-
+

Settings