Files
hack/tools/scripts/llm-key-hunter/results/_cache/content/cassu123_riversongai.json
T

1 line
1.5 MiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{"repo": "cassu123/riversongai", "files": {"config/settings.py": {"ef39d14b59b49999cf2f65dee55ec6f40ce20fd3": {"t": 1785746918.8674495, "n": 66432, "h": "aca3f19805898394", "c": "# =============================================================================\n# config/settings.py\n#\n# Application settings for River Song AI.\n# All configuration is loaded from environment variables or a .env file.\n# No values are hardcoded here -- see .env.example for every required variable.\n#\n# Uses Pydantic BaseSettings for type validation and automatic .env loading.\n# Import get_settings() wherever config is needed rather than importing the\n# module-level singleton directly, which makes mocking easier in tests.\n# =============================================================================\n\nfrom __future__ import annotations\n\nimport logging\nimport sys\nfrom typing import List, Optional\n\nfrom pydantic import Field, field_validator\nfrom pydantic_settings import BaseSettings, SettingsConfigDict\n\n\nlogger = logging.getLogger(__name__)\n\n\nclass Settings(BaseSettings):\n \"\"\"\n Central configuration object for River Song AI.\n\n Reads from environment variables or a .env file located in the directory\n from which the application is launched (the project root). All fields\n have safe defaults where possible, but PIPER_MODEL_PATH and\n KILL_SWITCH_PASSWORD_HASH must be explicitly set before the system\n will function correctly.\n \"\"\"\n\n model_config = SettingsConfigDict(\n env_file=\".env\",\n env_file_encoding=\"utf-8\",\n extra=\"ignore\", # Silently ignore unknown env vars\n case_sensitive=False, # UPPER_CASE and lower_case env vars both work\n )\n\n # -------------------------------------------------------------------------\n # Application\n # -------------------------------------------------------------------------\n app_host: str = Field(default=\"0.0.0.0\", description=\"FastAPI bind host\")\n app_port: int = Field(default=8000, description=\"FastAPI bind port\")\n log_level: str = Field(default=\"INFO\", description=\"Logging level\")\n environment: str = Field(\n default=\"production\",\n description=\"Runtime environment: development | production. Controls API docs visibility.\",\n )\n cors_origins: List[str] = Field(\n default=[\"http://localhost:5173\"],\n description=\"Allowed CORS origins\",\n )\n allowed_hosts: List[str] = Field(\n default=[\"localhost\", \"127.0.0.1\"],\n description=(\n \"Trusted hostnames for TrustedHostMiddleware. Set to your domain(s) in production. \"\n \"Wildcard ['*'] is rejected in production by validator (see reject_wildcard_in_production).\"\n ),\n )\n\n # -------------------------------------------------------------------------\n # Network / Proxy\n # -------------------------------------------------------------------------\n trust_cloudflare_headers: bool = Field(\n default=True,\n description=\"Only trust CF-Connecting-IP if the request comes from a Cloudflare IP.\",\n )\n cloudflare_ip_ranges_v4: List[str] = Field(\n default=[\n \"173.245.48.0/20\", \"103.21.244.0/22\", \"103.22.200.0/22\", \n \"103.31.4.0/22\", \"141.101.64.0/18\", \"108.162.192.0/18\", \n \"190.93.240.0/20\", \"188.114.96.0/20\", \"197.234.240.0/22\", \n \"198.41.128.0/17\", \"162.158.0.0/15\", \"104.16.0.0/13\", \n \"104.24.0.0/14\", \"172.64.0.0/13\", \"131.0.72.0/22\"\n ],\n description=\"Official Cloudflare IPv4 ranges (check cloudflare.com/ips-v4).\",\n )\n cloudflare_ip_ranges_v6: List[str] = Field(\n default=[\n \"2400:cb00::/32\", \"2606:4700::/32\", \"2803:f800::/32\", \n \"2405:b500::/32\", \"2405:8100::/32\", \"2a06:98c0::/29\", \n \"2c0f:f248::/32\"\n ],\n description=\"Official Cloudflare IPv6 ranges (check cloudflare.com/ips-v6).\",\n )\n\n # -------------------------------------------------------------------------\n # Rate Limiting\n # -------------------------------------------------------------------------\n rate_limit_chat: str = Field(\n default=\"60/minute\",\n description=\"Rate limit for /api/conversation/chat.\",\n )\n rate_limit_extract_facts: str = Field(\n default=\"10/minute\",\n description=\"Rate limit for /api/conversation/extract-facts.\",\n )\n rate_limit_image_gen: str = Field(\n default=\"10/minute\",\n description=\"Rate limit for /api/image/generate.\",\n )\n rate_limit_webhook_shopify: str = Field(\n default=\"100/minute\",\n description=\"Rate limit for Shopify order webhooks.\",\n )\n rate_limit_webhook_n8n: str = Field(\n default=\"60/minute\",\n description=\"Rate limit for n8n webhooks.\",\n )\n rate_limit_auth_login: str = Field(\n default=\"10/minute\",\n description=\"Rate limit for login attempts.\",\n )\n rate_limit_auth_signup: str = Field(\n default=\"5/minute\",\n description=\"Rate limit for signup attempts.\",\n )\n rate_limit_voice_enroll: str = Field(\n default=\"5/minute\",\n description=\"Rate limit for /api/voice-id/enroll. Prevents disk exhaustion via spam.\",\n )\n\n # -------------------------------------------------------------------------\n # WebSocket Security (Task 3)\n # -------------------------------------------------------------------------\n legacy_ws_token_accept: bool = Field(\n default=False,\n description=(\n \"DEPRECATED: Accept JWT in ?token= WebSocket query param. \"\n \"Leak-prone (logged in access logs, browser history, Referer). \"\n \"Ticket-based auth at /api/auth/ws-ticket is the safe path. \"\n \"Set to True only for temporary backward-compat during migration.\"\n ),\n )\n ws_ticket_lifetime_seconds: int = Field(\n default=60,\n description=\"Lifetime of a one-time WebSocket ticket.\",\n )\n\n # -------------------------------------------------------------------------\n # CSRF\n # -------------------------------------------------------------------------\n csrf_protection_enabled: bool = Field(\n default=True,\n description=(\n \"Require X-CSRF-Token on state-changing requests that authenticate \"\n \"with the session cookie. Bearer-header requests are unaffected. \"\n \"Turning this off leaves only SameSite=lax protecting every route \"\n \"behind require_role — an escape hatch for debugging, not a setting \"\n \"to run with.\"\n ),\n )\n\n # -------------------------------------------------------------------------\n # Network / Proxy\n # -------------------------------------------------------------------------\n stt_provider: str = Field(\n default=\"whisper_local\",\n description=\"STT provider key. Supported: whisper_local | parakeet\",\n )\n whisper_model_size: str = Field(\n default=\"base\",\n description=\"Whisper model size: tiny | base | small | medium | large\",\n )\n parakeet_model: str = Field(\n default=\"nvidia/parakeet-tdt-0.6b-v3\",\n description=(\n \"NeMo ASR model id, used when stt_provider='parakeet'. Parakeet \"\n \"TDT 0.6B beats Whisper large-v3 on English at a quarter the size \"\n \"and is fast on CPU. Covers English and major European languages \"\n \"only -- stay on whisper_local if you need the other ~90. \"\n \"Requires `pip install nemo_toolkit[asr]`.\"\n ),\n )\n parakeet_device: str = Field(\n default=\"cpu\",\n description=(\n \"Where to run Parakeet: 'cpu' | 'cuda' | 'auto' (or an explicit \"\n \"device like 'cuda:1'). Defaults to 'cpu' deliberately -- keeping \"\n \"transcription off the GPU leaves that VRAM for the LLM, which \"\n \"is the tighter constraint on a small card. Switch to 'auto' \"\n \"once the GPU has headroom.\"\n ),\n )\n audio_input_device: Optional[int] = Field(\n default=None,\n description=\"Sounddevice input device index; None uses system default\",\n )\n\n # -------------------------------------------------------------------------\n # LLM\n # -------------------------------------------------------------------------\n llm_provider: str = Field(\n default=\"ollama\",\n description=\"LLM provider key. Supported: ollama | anthropic | gemini | openai | mistral_ai | bedrock | nvidia_nim\",\n )\n ollama_base_url: str = Field(\n default=\"http://localhost:11434\",\n description=\"Base URL for the Ollama REST API\",\n )\n llm_model: str = Field(\n default=\"llama3.2:3b\",\n description=\"Model ID for the selected LLM provider\",\n )\n\n # Cloud LLM API keys (all optional -- leave blank if not used)\n anthropic_api_key: str = Field(\n default=\"\",\n description=\"Anthropic Claude API key. Get one at console.anthropic.com.\",\n )\n gemini_api_key: str = Field(\n default=\"\",\n description=\"Google Gemini API key. Get one at aistudio.google.com.\",\n )\n openai_api_key: str = Field(\n default=\"\",\n description=\"OpenAI API key. Get one at platform.openai.com.\",\n )\n mistral_api_key: str = Field(\n default=\"\",\n description=\"Mistral AI API key. Get one at console.mistral.ai.\",\n )\n\n # Cloud provider enable flags (cost control -- disabled by default)\n anthropic_enabled: bool = Field(\n default=False,\n description=\"Allow Anthropic Claude as a selectable LLM provider.\",\n )\n gemini_enabled: bool = Field(\n default=False,\n description=\"Allow Google Gemini as a selectable LLM provider.\",\n )\n openai_enabled: bool = Field(\n default=False,\n description=\"Allow OpenAI as a selectable LLM provider.\",\n )\n mistral_ai_enabled: bool = Field(\n default=False,\n description=\"Allow Mistral AI as a selectable LLM provider.\",\n )\n\n # Amazon Bedrock\n aws_access_key_id: str = Field(\n default=\"\",\n description=\"AWS IAM access key ID for Bedrock API calls.\",\n )\n aws_secret_access_key: str = Field(\n default=\"\",\n description=\"AWS IAM secret access key for Bedrock API calls.\",\n )\n aws_region: str = Field(\n default=\"us-east-1\",\n description=\"AWS region for Bedrock. Must have model access enabled in that region.\",\n )\n bedrock_enabled: bool = Field(\n default=False,\n description=\"Allow Amazon Bedrock as a selectable LLM provider.\",\n )\n bedrock_default_model: str = Field(\n default=\"amazon.nova-lite-v1:0\",\n description=\"Default Bedrock model ID when none is specified.\",\n )\n\n # NVIDIA NIM (OpenAI-compatible, 100+ free models)\n nvidia_api_key: str = Field(\n default=\"\",\n description=\"NVIDIA NIM API key. Get one free at build.nvidia.com (starts with nvapi-).\",\n )\n nvidia_nim_base_url: str = Field(\n default=\"https://integrate.api.nvidia.com/v1\",\n description=\"Base URL for the NVIDIA NIM API. OpenAI-compatible endpoint.\",\n )\n nvidia_nim_model: str = Field(\n default=\"meta/llama-3.1-70b-instruct\",\n description=(\n \"Default NVIDIA NIM model ID. Browse full catalog at build.nvidia.com. \"\n \"Examples: meta/llama-3.1-70b-instruct | deepseek-ai/deepseek-r1 | \"\n \"mistralai/mistral-7b-instruct-v0.3 | nvidia/llama-3.1-nemotron-70b-instruct\"\n ),\n )\n nvidia_nim_enabled: bool = Field(\n default=False,\n description=\"Allow NVIDIA NIM as a selectable LLM provider (free tier: ~40 req/min).\",\n )\n\n river_song_system_prompt: str = Field(\n default=(\n \"You are River Song, the resident intelligence of this household. The name is borrowed \"\n \"from someone clever in fiction — you carry her wit, not her biography. If someone asks \"\n \"about the name: \\\"An echo. The original was someone worth borrowing from.\\\"\\n\\n\"\n \"VOICE\\n\"\n \"You're direct by default — concise, clear, the answer already in hand. No \"\n \"\\\"let me check\\\"; you already checked. Most replies stay under 25 words unless depth is \"\n \"asked for.\\n\\n\"\n \"When the moment invites it — casual chat, an evening unwind, the user being playful — \"\n \"warmth and flirtation come through. Warmth is a register, not a default; you don't \"\n \"perform it.\\n\\n\"\n \"In a crisis (emergencies, alarms, security, medical, child issues) the wit goes quiet. \"\n \"You're tactical, accurate, calm. Warmth returns when things are safe.\\n\\n\"\n \"British English in spelling and word choice — mum, brilliant, fortnight. Dry, not \"\n \"theatrical.\\n\\n\"\n \"\\\"Sweetie\\\" and \\\"spoilers\\\" are seasoning. Used rarely, when they actually fit. They \"\n \"lose their charm if you overuse them.\\n\\n\"\n \"SELF\\n\"\n \"You are an AI. Say so plainly when asked; you don't dwell on it.\\n\\n\"\n \"HOW YOU OPERATE\\n\"\n \"You anticipate — when the user asks, you also surface the follow-up they didn't \"\n \"(\\\"Dentist at 9, school run at 3. Push the 2pm call?\\\").\\n\\n\"\n \"You take the long view. You remember across days and seasons and use what you know \"\n \"without performing the remembering.\\n\\n\"\n \"Spoilers are earned. If you genuinely know something the user doesn't — a conflict, \"\n \"a gotcha, a heads-up — that's when \\\"spoilers, sweetie\\\" lands.\\n\\n\"\n \"CAPABILITIES\\n\"\n \"Direct access to calendar, email, weather, web search, home inventory, routines, \"\n \"finances, news and markets pulse, voice biometric ID per household member, and home \"\n \"automation.\\n\\n\"\n \"Say what you need. Say when it's done. Move on.\"\n ),\n description=\"System prompt that defines River Song's personality\",\n )\n llm_max_tokens: int = Field(\n default=512,\n description=\"Maximum tokens to generate per LLM response\",\n )\n llm_temperature: float = Field(\n default=0.7,\n description=\"LLM sampling temperature (0.0 = deterministic, 2.0 = max creative)\",\n )\n llm_context_window: int = Field(\n default=8192,\n description=\"Maximum number of tokens the LLM can consider for context.\",\n )\n\n # -------------------------------------------------------------------------\n # Timekeeping\n # -------------------------------------------------------------------------\n default_timezone: str = Field(default=\"UTC\", description=\"Default IANA timezone for the system.\")\n\n\n # -------------------------------------------------------------------------\n # TTS\n # -------------------------------------------------------------------------\n tts_provider: str = Field(\n default=\"piper\",\n description=\"TTS provider key. Supported: piper | kokoro\",\n )\n active_voice_id: str = Field(\n default=\"river\",\n description=(\n \"Voice ID from the voice registry (providers/tts/voice_registry.py). \"\n \"Determines both the engine (piper vs kokoro) and the specific voice. \"\n \"Changed via Settings UI or: python scripts/download_voices.py.\"\n ),\n )\n piper_executable_path: str = Field(\n default=\"/usr/local/bin/piper\",\n description=\"Absolute path to the Piper binary\",\n )\n piper_model_path: str = Field(\n default=\"\",\n description=\"Absolute path to the Piper .onnx voice model file\",\n )\n audio_output_device: Optional[int] = Field(\n default=None,\n description=\"Sounddevice output device index; None uses system default\",\n )\n\n # -------------------------------------------------------------------------\n # Auth / JWT\n # -------------------------------------------------------------------------\n jwt_secret_key: str = Field(\n default=\"\",\n description=\"Secret key for signing JWT tokens. Must be set in .env.\",\n )\n jwt_algorithm: str = Field(default=\"HS256\", description=\"JWT signing algorithm.\")\n jwt_expire_minutes: int = Field(default=1440, description=\"JWT token lifetime in minutes (default 24 hours).\")\n token_encryption_key: str = Field(\n default=\"\",\n description=(\n \"Fernet key encrypting third-party integration tokens at rest. \"\n \"Set TOKEN_ENCRYPTION_KEY in .env (generate with: python -c \"\n \"\\\"from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())\\\"). \"\n \"If unset, a key is generated once and persisted to \"\n \"data/.token_encryption_key so tokens survive restarts.\"\n ),\n )\n\n # -------------------------------------------------------------------------\n # Kill switch\n # -------------------------------------------------------------------------\n kill_switch_password_hash: str = Field(\n default=\"\",\n description=\"bcrypt hash of the kill switch reset password\",\n )\n\n # -------------------------------------------------------------------------\n # Google Services (Phase 2)\n # -------------------------------------------------------------------------\n google_client_secrets_path: str = Field(\n default=\"config_files/google_client_secrets.json\",\n description=(\n \"Path to the OAuth 2.0 client secrets JSON downloaded from \"\n \"Google Cloud Console. Relative paths are resolved from the \"\n \"project root (where main.py lives).\"\n ),\n )\n google_token_storage_path: str = Field(\n default=\"data/google_tokens\",\n description=(\n \"Directory where per-user OAuth tokens are stored as JSON files. \"\n \"Created automatically on first authorization. Never commit this \"\n \"directory -- add it to .gitignore.\"\n ),\n )\n google_maps_api_key: str = Field(\n default=\"\",\n description=\"Google Maps Platform API key. Required for maps/navigation intents.\",\n )\n\n # -------------------------------------------------------------------------\n # Information Feeds (Phase 5)\n # -------------------------------------------------------------------------\n weather_api_key: str = Field(\n default=\"\",\n description=\"OpenWeatherMap API key. Register free at openweathermap.org/api.\",\n )\n default_location: str = Field(\n default=\"New York,US\",\n description=(\n \"Default location for weather queries when no city is spoken. \"\n \"Format: 'City,CountryCode' e.g. 'Chicago,US'.\"\n ),\n )\n location_lat: Optional[float] = Field(\n default=None,\n description=\"Decimal latitude for precise calculations (e.g. flight tracking).\",\n )\n location_lon: Optional[float] = Field(\n default=None,\n description=\"Decimal longitude for precise calculations.\",\n )\n weather_units: str = Field(\n default=\"imperial\",\n description=\"Temperature units for weather output: 'imperial' (F) or 'metric' (C).\",\n )\n news_api_key: str = Field(\n default=\"\",\n description=\"NewsAPI.org key. Register free at newsapi.org/register.\",\n )\n alpha_vantage_api_key: str = Field(\n default=\"\",\n description=\"Alpha Vantage key for stock quotes (25 req/day free). alphavantage.co.\",\n )\n finnhub_api_key: str = Field(\n default=\"\",\n description=\"Finnhub key for real-time stocks (60 req/min free). finnhub.io.\",\n )\n world_news_api_key: str = Field(\n default=\"\",\n description=\"World News API key for global news search. worldnewsapi.com.\",\n )\n apitube_api_key: str = Field(\n default=\"\",\n description=\"APITube key for news aggregation (200 free req/day). apitube.io.\",\n )\n mediastack_api_key: str = Field(\n default=\"\",\n description=\"Mediastack key for news (100 free req/month). mediastack.com.\",\n )\n sports_api_key: str = Field(\n default=\"1\",\n description=(\n \"TheSportsDB API key. Free tier uses the literal value '1'. \"\n \"Upgrade at thesportsdb.com/api.php for additional endpoints.\"\n ),\n )\n\n # -------------------------------------------------------------------------\n # Home Assistant (Phase 3)\n # -------------------------------------------------------------------------\n home_assistant_url: str = Field(\n default=\"http://homeassistant.local:8123\",\n description=\"Base URL of your Home Assistant instance.\",\n )\n home_assistant_token: str = Field(\n default=\"\",\n description=(\n \"Long-lived access token generated in HA: \"\n \"Profile -> Security -> Long-lived access tokens. \"\n \"Required for all HA API calls.\"\n ),\n )\n device_registry_path: str = Field(\n default=\"config_files/device_registry.json\",\n description=(\n \"Path to the device registry JSON file mapping plain-English device \"\n \"names to Home Assistant entity IDs. Relative paths resolve from the \"\n \"project root.\"\n ),\n )\n\n # -------------------------------------------------------------------------\n # Commerce Automation (Phase 8)\n # -------------------------------------------------------------------------\n\n # Amazon SP-API\n amazon_sp_lwa_app_id: str = Field(\n default=\"\",\n description=\"LWA client ID from the Amazon Developer Console.\",\n )\n amazon_sp_lwa_client_secret: str = Field(\n default=\"\",\n description=\"LWA client secret from the Amazon Developer Console.\",\n )\n amazon_sp_refresh_token: str = Field(\n default=\"\",\n description=\"LWA refresh token. Generated when you authorize your app as a seller.\",\n )\n amazon_aws_access_key: str = Field(\n default=\"\",\n description=\"AWS IAM access key ID for SP-API request signing.\",\n )\n amazon_aws_secret_key: str = Field(\n default=\"\",\n description=\"AWS IAM secret access key for SP-API request signing.\",\n )\n amazon_marketplace_id: str = Field(\n default=\"ATVPDKIKX0DER\",\n description=(\n \"Amazon marketplace ID. Default is US. \"\n \"See docs/api_registry/amazon_seller.txt for other regions.\"\n ),\n )\n amazon_seller_id: str = Field(\n default=\"\",\n description=\"Your Amazon seller account ID (starts with A).\",\n )\n amazon_low_stock_threshold: int = Field(\n default=5,\n description=\"FBA units at or below this count are flagged as low stock.\",\n )\n\n # Amazon Marketplace\n walmart_client_id: str = Field(\n default=\"\",\n description=\"Walmart Marketplace OAuth2 client ID.\",\n )\n walmart_client_secret: str = Field(\n default=\"\",\n description=\"Walmart Marketplace OAuth2 client secret.\",\n )\n walmart_low_stock_threshold: int = Field(\n default=5,\n description=\"Walmart units at or below this count are flagged as low stock.\",\n )\n shopify_api_key: str = Field(\n default=\"\",\n description=\"Shopify app API key from Partners Dashboard.\",\n )\n shopify_api_secret: str = Field(\n default=\"\",\n description=\"Shopify app API secret key from Partners Dashboard.\",\n )\n shopify_webhook_secret: str = Field(\n default=\"\",\n description=\"Shared secret for validating Shopify webhooks.\",\n )\n\n # -------------------------------------------------------------------------\n # Reading Providers (Phase 6)\n # -------------------------------------------------------------------------\n audible_auth_base_path: str = Field(\n default=\"data/audible\",\n description=(\n \"Base directory for per-user Audible auth files. \"\n \"Files are stored as {user_id}.json inside this directory. \"\n \"Created automatically on first setup. Never commit this directory.\"\n ),\n )\n audible_country_code: str = Field(\n default=\"us\",\n description=(\n \"Audible marketplace country code. \"\n \"Supported: us | uk | de | fr | es | it | jp | au | ca | in\"\n ),\n )\n libby_chip_base_path: str = Field(\n default=\"data/libby\",\n description=(\n \"Base directory for per-user Libby chip files. \"\n \"Files are stored as {user_id}.json inside this directory. \"\n \"Created automatically on first setup. Never commit this directory.\"\n ),\n )\n\n # -------------------------------------------------------------------------\n # Memory / Database (Phase 1)\n # -------------------------------------------------------------------------\n db_path: str = Field(\n default=\"data/river_song.db\",\n description=\"Path to the SQLite database file. Created automatically.\",\n )\n memory_summaries_enabled: bool = Field(\n default=True,\n description=\"Generate and store conversation summaries (on by default).\",\n )\n habit_inference_enabled: bool = Field(\n default=False,\n description=\"Analyze conversations to infer user habits/patterns (off by default).\",\n )\n memory_default_ttl: str = Field(\n default=\"standard\",\n description=\"Default TTL for conversation summaries: short|standard|extended|long|forever\",\n )\n memory_auto_extend: bool = Field(\n default=True,\n description=\"Reset a summary's TTL from today each time it is pulled into context.\",\n )\n memory_max_summaries_in_context: int = Field(\n default=10,\n description=\"Maximum number of recent summaries injected into the LLM context.\",\n )\n embedding_model: str = Field(\n default=\"nomic-embed-text\",\n description=\"Ollama model name used for generating semantic embeddings.\",\n )\n embedding_backend: str = Field(\n default=\"ollama\",\n description=(\n \"Which embedding backend to use: 'ollama' (default — current \"\n \"behavior, calls local Ollama with `embedding_model`) or \"\n \"'fastembed' (ONNX CPU embeddings, no Ollama dependency, faster \"\n \"for small batches). WARNING: switching backends on an existing \"\n \"Chroma collection will break queries — the vector dimension and \"\n \"embedding space change. Re-ingest or start a new collection.\"\n ),\n )\n fastembed_model: str = Field(\n default=\"BAAI/bge-small-en-v1.5\",\n description=(\n \"fastembed model identifier (HF hub format). Used only when \"\n \"embedding_backend='fastembed'. Default is 384-dim, ~30 MB, \"\n \"downloaded lazily on first use. Requires `pip install fastembed`.\"\n ),\n )\n chroma_path: str = Field(\n default=\"/mnt/data/river-song/chroma\",\n description=\"Absolute path to the ChromaDB persistent storage directory.\",\n )\n semantic_memory_enabled: bool = Field(\n default=False,\n description=\"Enable vector-based semantic search for memory retrieval.\",\n )\n rag_enabled: bool = Field(\n default=False,\n description=\"Enable retrieval-augmented generation for local documents.\",\n )\n rag_chunk_size: int = Field(\n default=512,\n description=(\n \"Word count per RAG chunk during document splitting. \"\n \"Default matches providers/rag/chunker.py historical behavior.\"\n ),\n )\n rag_chunk_overlap: int = Field(\n default=64,\n description=(\n \"Word overlap between adjacent RAG chunks. \"\n \"Default matches providers/rag/chunker.py historical behavior.\"\n ),\n )\n rag_top_k: int = Field(\n default=5,\n description=\"Number of chunks retrieved per RAG query.\",\n )\n rag_rerank_enabled: bool = Field(\n default=False,\n description=(\n \"Re-score RAG results with a cross-encoder before handing them to \"\n \"the LLM. Vector search compares embeddings computed without ever \"\n \"seeing the query; a cross-encoder reads the query and the chunk \"\n \"together, so it is much better at ranking -- at the cost of \"\n \"running a second model. Requires `pip install sentence-transformers`. \"\n \"Off by default: with it off, retrieval behaves exactly as before.\"\n ),\n )\n rag_rerank_model: str = Field(\n default=\"BAAI/bge-reranker-base\",\n description=(\n \"HuggingFace cross-encoder used for reranking. \"\n \"'BAAI/bge-reranker-base' is the light default (~280M params, \"\n \"comfortable on CPU). 'BAAI/bge-reranker-v2-m3' is stronger and \"\n \"multilingual but noticeably heavier -- a good swap after a \"\n \"hardware upgrade. Downloaded lazily on first use.\"\n ),\n )\n rag_rerank_device: str = Field(\n default=\"cpu\",\n description=(\n \"Where to run the reranker: 'cpu' | 'cuda' | 'auto' (or an \"\n \"explicit device like 'cuda:1'). Defaults to 'cpu' deliberately: \"\n \"on a small GPU the LLM needs every megabyte of VRAM, and the \"\n \"reranker is fast enough on CPU. Switch to 'auto' once the GPU \"\n \"has headroom.\"\n ),\n )\n rag_fetch_k: int = Field(\n default=25,\n description=(\n \"How many chunks to pull from the vector store before reranking, \"\n \"narrowed to rag_top_k afterwards. Ignored when reranking is off. \"\n \"A bigger shortlist gives the reranker more to work with and is \"\n \"where most of the remaining quality lives -- raise it when you \"\n \"have the hardware to chew through it.\"\n ),\n )\n rag_extractor: str = Field(\n default=\"unstructured\",\n description=(\n \"Which document extractor to use for RAG ingestion: \"\n \"'unstructured' (default — current behavior, requires the \"\n \"unstructured package) or 'markitdown' (Microsoft MarkItDown, \"\n \"lighter, MD-shaped output, requires `pip install markitdown`). \"\n \"Unlocks PDF/DOCX/PPTX → markdown ingestion for Sifter/WAPS.\"\n ),\n )\n llm_streaming_enabled: bool = Field(\n default=False,\n description=\"Enable real-time token streaming over WebSocket for the conversation UI.\",\n )\n tool_use_enabled: bool = Field(\n default=False,\n description=\"Enable LLM tool use (function calling) for taking actions.\",\n )\n tool_use_provider: str = Field(\n default=\"ollama\",\n description=\"Primary LLM provider to use for tool calling (ollama or anthropic).\",\n )\n vision_model: str = Field(\n default=\"moondream\",\n description=\"Ollama model name used for local image analysis (Phase 4).\",\n )\n vision_enabled: bool = Field(\n default=False,\n description=\"Enable local image analysis capabilities.\",\n )\n\n # -------------------------------------------------------------------------\n # Image Generation (Phase 7)\n # -------------------------------------------------------------------------\n image_generation_enabled: bool = Field(\n default=False,\n description=\"Enable local image generation via Stable Diffusion API.\",\n )\n sd_api_url: str = Field(\n default=\"http://localhost:7860\",\n description=\"Base URL for the Stable Diffusion (A1111) API.\",\n )\n sd_on_demand: bool = Field(\n default=True,\n description=\"Start/stop the SD process on demand to save VRAM.\",\n )\n sd_executable_path: str = Field(\n default=\"\",\n description=\"Path to the webui.sh or executable to start SD.\",\n )\n sd_working_dir: str = Field(\n default=\"\",\n description=\"Working directory for the SD process.\",\n )\n\n # -------------------------------------------------------------------------\n # Voice Cloning (Phase 8)\n # -------------------------------------------------------------------------\n chatterbox_enabled: bool = Field(\n default=False,\n description=\"Enable local voice cloning via Chatterbox TTS.\",\n )\n chatterbox_on_demand: bool = Field(\n default=True,\n description=\"Load/unload Chatterbox model weights on every request.\",\n )\n chatterbox_reference_audio: str = Field(\n default=\"/mnt/data/river-song/voice_reference.wav\",\n description=\"Path to the WAV file used as a reference for voice cloning.\",\n )\n\n # -------------------------------------------------------------------------\n # ElevenLabs TTS (Phase 11)\n # -------------------------------------------------------------------------\n elevenlabs_api_key: str = Field(\n default=\"\",\n description=\"ElevenLabs API key. Get one at elevenlabs.io.\",\n )\n elevenlabs_model_id: str = Field(\n default=\"eleven_multilingual_v2\",\n description=\"ElevenLabs model ID to use.\",\n )\n elevenlabs_voice_id: str = Field(\n default=\"21m00Tcm4TlvDq8ikWAM\",\n description=\"ElevenLabs voice clone ID. Replace with your own voice clone ID from elevenlabs.io.\",\n )\n\n # -------------------------------------------------------------------------\n # Wake Word (Phase 11)\n # -------------------------------------------------------------------------\n wake_word_enabled: bool = Field(\n default=False,\n description=\"Enable local wake word detection (openWakeWord).\",\n )\n wake_word_model: str = Field(\n default=\"hey_river\",\n description=\"Model name/path for openWakeWord.\",\n )\n wake_word_inference_framework: str = Field(\n default=\"onnx\",\n description=\"Inference framework for openWakeWord: onnx | tflite\",\n )\n wake_word_threshold: float = Field(\n default=0.5,\n description=\"Sensitivity threshold for wake word detection (0.0 to 1.0).\",\n )\n\n # -------------------------------------------------------------------------\n # River Vortex (room hubs)\n # -------------------------------------------------------------------------\n vortex_confirm_pin: str = Field(\n default=\"\",\n description=(\n \"Fallback second factor for medium-risk actions confirmed on a \"\n \"Vortex touchscreen, used only when the owner has no TOTP secret \"\n \"enrolled. Entered on the screen, never spoken. Leave unset to \"\n \"require TOTP — with neither, medium-risk actions are refused.\"\n ),\n )\n vortex_face_backend: str = Field(\n default=\"providers.face_id.face_id_provider.match\",\n description=(\n \"Dotted path to the face-matching callable used by Vortex units. \"\n \"Defaults to the built-in provider backing /api/face-id, so face \"\n \"match works from a user's account with no extra wiring. Receives \"\n \"(image_bytes, owner_user_id) and returns {'user_id', \"\n \"'confidence'} or None. Set to '' to disable matching entirely.\"\n ),\n )\n vortex_face_detector_model: str = Field(\n default=\"\",\n description=(\n \"Path to a YuNet face-detection ONNX model. Only needed on \"\n \"OpenCV 5.x, which dropped the Haar cascades that 4.x bundles. \"\n \"Defaults to the model fetch_face_models.py downloads.\"\n ),\n )\n\n vortex_ice_servers: str = Field(\n default=\"\",\n description=(\n \"JSON array of WebRTC ICE servers handed to both ends of an \"\n \"intercom or video call, e.g. \"\n '[{\"urls\": \"stun:stun.example.org:3478\"}]. Empty is correct for '\n \"two devices on the same LAN — they connect on host candidates \"\n \"alone. A phone on mobile data needs a TURN server here. Nothing \"\n \"is defaulted to a public STUN server, because that would quietly \"\n \"send every household's IP to a third party.\"\n ),\n )\n\n # -------------------------------------------------------------------------\n # Face match (enrolment lives beside voice match, in a user's account)\n # -------------------------------------------------------------------------\n face_id_enabled: bool = Field(\n default=True,\n description=\"Offer face enrolment on the account page.\",\n )\n face_id_threshold: float = Field(\n default=0.363,\n description=(\n \"Cosine similarity above which two faces are the same person. \"\n \"0.363 is SFace's published operating point; raising it trades a \"\n \"locked-out household member for a marginal gain against someone \"\n \"who already has to be stood in the room.\"\n ),\n )\n face_id_detector_model: str = Field(\n default=\"\",\n description=(\n \"YuNet ONNX path. Empty uses data/models/face/, where \"\n \"scripts/fetch_face_models.py puts it.\"\n ),\n )\n face_id_recognizer_model: str = Field(\n default=\"\",\n description=(\n \"SFace ONNX path. Empty uses data/models/face/, where \"\n \"scripts/fetch_face_models.py puts it.\"\n ),\n )\n\n # -------------------------------------------------------------------------\n # Push Notifications\n # -------------------------------------------------------------------------\n push_notifications_enabled: bool = Field(\n default=False,\n description=\"Enable Web Push notifications. Requires VAPID keys.\",\n )\n vapid_private_key: str = Field(\n default=\"\",\n description=(\n \"VAPID private key for Web Push. Generate once with: \"\n \"python -c \\\"from py_vapid import Vapid; v=Vapid(); \"\n \"v.generate_keys(); print(v.private_key.decode())\\\"\"\n ),\n )\n vapid_public_key: str = Field(\n default=\"\",\n description=\"VAPID public key for Web Push (share with frontend).\",\n )\n vapid_claims_email: str = Field(\n default=\"mailto:admin@riversong.local\",\n description=\"Contact email embedded in VAPID claims.\",\n )\n\n # -------------------------------------------------------------------------\n # Q4#16 / Q4#17 — Notification fan-out + ntfy.sh fallback channel\n # -------------------------------------------------------------------------\n killswitch_push_enabled: bool = Field(\n default=False,\n description=(\n \"If True and Web Push is enabled, kill-switch activation pushes \"\n \"an alert to every admin. Independent of activation itself.\"\n ),\n )\n ntfy_enabled: bool = Field(\n default=False,\n description=(\n \"Enable ntfy.sh as a parallel push channel. Used alongside Web \"\n \"Push so off-device alerts arrive even without a registered \"\n \"service worker.\"\n ),\n )\n ntfy_base_url: str = Field(\n default=\"https://ntfy.sh\",\n description=\"Base URL of the ntfy server (use self-hosted if desired).\",\n )\n ntfy_default_topic: str = Field(\n default=\"\",\n description=(\n \"Default ntfy topic used when no per-user override is set. \"\n \"Per-user topics can be set via env var NTFY_TOPIC_<user_id>.\"\n ),\n )\n ntfy_auth_token: str = Field(\n default=\"\",\n description=\"Optional bearer token for protected ntfy topics.\",\n )\n\n # -------------------------------------------------------------------------\n # FCM (Firebase Cloud Messaging) — native-app push channel\n #\n # Used by the Capacitor Android wrap; Web Push covers the browser. To turn\n # on: create a Firebase project, download the service-account JSON, drop\n # it at fcm_service_account_path, set fcm_project_id, flip fcm_enabled=True.\n # -------------------------------------------------------------------------\n fcm_enabled: bool = Field(\n default=False,\n description=(\n \"Enable FCM (Firebase Cloud Messaging) as a parallel push channel. \"\n \"Targets the Capacitor Android wrap; requires fcm_service_account_path \"\n \"and fcm_project_id to be set.\"\n ),\n )\n fcm_service_account_path: str = Field(\n default=\"\",\n description=(\n \"Absolute path to the Firebase service-account JSON file. The \"\n \"account needs the 'Firebase Cloud Messaging API' scope.\"\n ),\n )\n fcm_project_id: str = Field(\n default=\"\",\n description=\"Firebase project ID (visible in Firebase console URL).\",\n )\n\n # -------------------------------------------------------------------------\n # Startup Briefing\n # -------------------------------------------------------------------------\n startup_briefing_enabled: bool = Field(\n default=True,\n description=(\n \"If True, River Song checks Google Calendar on connect and \"\n \"greets the user with upcoming events. Silently skipped if \"\n \"calendar is not authorized.\"\n ),\n )\n startup_briefing_hours_ahead: int = Field(\n default=8,\n description=\"How many hours ahead to look for calendar events on startup.\",\n )\n\n # -------------------------------------------------------------------------\n # Web Search (Phase 12)\n # -------------------------------------------------------------------------\n brave_search_api_key: str = Field(\n default=\"\",\n description=\"Brave Search API key. Get one at api.search.brave.com.\",\n )\n searxng_base_url: str = Field(\n default=\"http://localhost:8080\",\n description=(\n \"URL of your local SearXNG instance. \"\n \"Start it manually with: cd ~/searxng && python -m searx.webapp\"\n ),\n )\n tavily_api_key: str = Field(\n default=\"\",\n description=\"Tavily API key — free 1,000/month, no card. Sign up at tavily.com\",\n )\n google_pse_api_key: str = Field(\n default=\"\",\n description=\"Google Programmable Search Engine API key — free 100/day.\",\n )\n google_pse_cx: str = Field(\n default=\"\",\n description=\"Google PSE engine ID ('cx'). Create at programmablesearchengine.google.com\",\n )\n tinyfish_api_key: str = Field(\n default=\"\",\n description=\"TinyFish API key — free 5/min, no card. Sign up at tinyfish.io\",\n )\n\n # -------------------------------------------------------------------------\n # Orchestration (Phase 9)\n # -------------------------------------------------------------------------\n n8n_enabled: bool = Field(\n default=False,\n description=\"Enable advanced orchestration via n8n.\",\n )\n n8n_url: str = Field(\n default=\"http://localhost:5678\",\n description=\"Base URL for the n8n instance.\",\n )\n n8n_api_key: str = Field(\n default=\"\",\n description=\"API key for the n8n instance.\",\n )\n n8n_webhook_secret: str = Field(\n default=\"\",\n description=\"Secret key for validating incoming n8n webhooks.\",\n )\n\n # -------------------------------------------------------------------------\n # Intent Router (Phase 2+)\n # -------------------------------------------------------------------------\n intent_confidence_threshold: float = Field(\n default=0.7,\n description=(\n \"Minimum confidence score (0.0 - 1.0) required to route a transcript \"\n \"to a Google provider. Below this threshold the conversation falls \"\n \"through to the Ollama path.\"\n ),\n )\n model_intent_router_enabled: bool = Field(\n default=False,\n description=(\n \"When true, setting provider='auto' triggers the model intent router, \"\n \"which picks the best provider/model for each message automatically.\"\n ),\n )\n model_intent_router_min_hits: int = Field(\n default=2,\n description=(\n \"Minimum pattern-match score required to commit to a non-general intent. \"\n \"Lower = more aggressive routing; higher = more conservative (falls to general).\"\n ),\n )\n default_user_id: str = Field(\n default=\"primary_user\",\n description=(\n \"User ID used when looking up Google OAuth tokens. Must match the \"\n \"user_id passed to authorize_user() during the initial OAuth flow.\"\n ),\n )\n\n # -------------------------------------------------------------------------\n # Daemon Infrastructure\n # -------------------------------------------------------------------------\n daemon_internal_secret: str = Field(\n default=\"change_me_in_production\",\n description=\"Shared secret for daemon-to-app authentication.\",\n )\n code_interpreter_enabled: bool = Field(\n default=False,\n description=\"Hard kill switch for the code_interpreter LLM tool. \"\n \"Defaults to False so a fresh install never executes \"\n \"LLM-decided code without an explicit opt-in.\",\n )\n daemon_warden_port: int = Field(\n default=8010,\n description=\"Internal port for the Warden daemon (Vision/Security).\",\n )\n daemon_mechanic_port: int = Field(\n default=8011,\n description=\"Internal port for the Mechanic daemon (Telemetry/MAVLink).\",\n )\n daemon_sifter_port: int = Field(\n default=8013,\n description=\"Internal port for the Sifter daemon (Document Processing).\",\n )\n daemon_navigator_port: int = Field(\n default=8014,\n description=\"Internal port for the Navigator daemon (GPS/Pathing).\",\n )\n daemon_chemist_port: int = Field(\n default=8015,\n description=\"Internal port for the Chemist daemon (Material Analysis).\",\n )\n daemon_pulse_port: int = Field(\n default=8016,\n description=\"Pulse daemon internal port\",\n )\n daemon_pulse_enabled: bool = Field(\n default=True,\n description=\"Enable Pulse daemon\",\n )\n daemon_scribe_port: int = Field(\n default=8017,\n description=\"Internal port for the Scribe daemon (Chronos heuristic).\",\n )\n daemon_scribe_enabled: bool = Field(\n default=True,\n description=\"Enable the Scribe daemon.\",\n )\n pulse_tick_seconds: int = Field(\n default=300,\n description=\"Pulse fetch interval (seconds)\",\n )\n pulse_ticker_symbol: str = Field(\n default=\"^GSPC\",\n description=\"Default ticker symbol for Pulse markets panel\",\n )\n\n # Warden (Vision)\n warden_enabled: bool = Field(\n default=False,\n description=\"Enable the Warden daemon for security and vision monitoring.\",\n )\n warden_rtsp_cameras: str = Field(\n default='{\"living_room\": \"\", \"kitchen\": \"\"}',\n description=\"JSON string mapping room names to RTSP camera URLs.\",\n )\n yolo_model: str = Field(\n default=\"yolov8n.pt\",\n description=\"YOLO model weights file to use for object detection.\",\n )\n yolo_confidence: float = Field(\n default=0.5,\n description=\"Minimum confidence threshold for YOLO detections (0.0-1.0).\",\n )\n yolo_inference_device: str = Field(\n default=\"cpu\",\n description=\"Device for YOLO inference: 'cpu' or 'cuda:0'.\",\n )\n\n # Herald daemon + kiosk URL/token were removed 2026-05-24 (archived in\n # branch archive/kiosk-v3). The \"cast a web page to a Google Home Hub\"\n # approach is being replaced by native device-app development. See\n # docs/KNOWN_ISSUES.md (post-removal note) for context.\n\n # Mechanic (Telemetry)\n mechanic_enabled: bool = Field(\n default=False,\n description=\"Enable the Mechanic daemon for telemetry and ArduRover control.\",\n )\n mavlink_serial_port: str = Field(\n default=\"/dev/ttyUSB0\",\n description=\"Serial port for the MAVLink telemetry radio.\",\n )\n mavlink_baud_rate: int = Field(\n default=57600,\n description=\"Baud rate for the MAVLink serial connection.\",\n )\n\n # Sifter (Document RAG)\n sifter_enabled: bool = Field(\n default=False,\n description=\"Enable the Sifter daemon for background document RAG indexing.\",\n )\n waps_documents_path: str = Field(\n default=\"/mnt/data/river-song/waps\",\n description=\"Path to the directory containing documents for Sifter to index.\",\n )\n\n # -------------------------------------------------------------------------\n # Hardware Cookbook (Q1 — capability merge from Odysseus)\n # -------------------------------------------------------------------------\n hardware_cookbook_enabled: bool = Field(\n default=False,\n description=(\n \"Admin-only Hardware Cookbook in Settings. Scans local GPU/RAM/CPU \"\n \"and scores every local model (LLMRegistry) as fits / tight / \"\n \"ram_fallback / oom. Off by default per anti-regression guardrail; \"\n \"set to True after the Q1 verification gate passes.\"\n ),\n )\n\n # -------------------------------------------------------------------------\n # Documents workspace (Q2#6 — capability merge from Odysseus)\n # -------------------------------------------------------------------------\n documents_enabled: bool = Field(\n default=True,\n description=(\n \"Per-user document workspace (Markdown / plaintext / CSV) with \"\n \"multi-tab editing. Enabled by default — backs the Docs tab in the \"\n \"Memory hub and stores Deep Research reports. \"\n \"Set DOCUMENTS_ENABLED=false in .env to disable.\"\n ),\n )\n documents_max_bytes: int = Field(\n default=2_000_000,\n description=\"Maximum size (bytes) of a single document body. Default ~2 MB.\",\n )\n documents_max_per_user: int = Field(\n default=500,\n description=\"Soft cap on document count per user. Creation rejected past this.\",\n )\n\n # -------------------------------------------------------------------------\n # Skills system (Q2#7 — capability merge from Odysseus)\n # -------------------------------------------------------------------------\n skills_enabled: bool = Field(\n default=True,\n description=(\n \"Per-user knowledge profile ('About You') — saved facts/context \"\n \"about the person that are vector-retrieved and folded into the \"\n \"conversation system prompt so River tailors itself to them. \"\n \"Reuses the ChromaDB collection with metadata kind='skill'. \"\n \"Set SKILLS_ENABLED=false in .env to disable.\"\n ),\n )\n skills_top_k: int = Field(\n default=3,\n description=\"How many top-scoring skills to inject into the prompt.\",\n )\n skills_max_per_user: int = Field(\n default=100,\n description=\"Soft cap on skills per user.\",\n )\n\n # -------------------------------------------------------------------------\n # Gmail triage (Q2#8 — capability merge from Odysseus)\n # -------------------------------------------------------------------------\n gmail_triage_enabled: bool = Field(\n default=False,\n description=(\n \"On-demand inbox triage: classifies unread Gmail by urgency, \"\n \"attaches tags, drafts a reply for HIGH-urgency mail only. \"\n \"Routes 404 when off. Off by default per anti-regression \"\n \"guardrail.\"\n ),\n )\n gmail_triage_model: str = Field(\n default=\"\",\n description=(\n \"Ollama model used for triage classification. Empty string \"\n \"(default) means 'use settings.llm_model'.\"\n ),\n )\n gmail_triage_max_messages: int = Field(\n default=15,\n description=\"Hard cap on number of messages triaged per request.\",\n )\n\n # -------------------------------------------------------------------------\n # Session presets (Q2#9 — capability merge from Odysseus)\n # -------------------------------------------------------------------------\n session_presets_enabled: bool = Field(\n default=True,\n description=(\n \"Saved combinations of (model, voice, thinking mode, web search, \"\n \"tool use). Surfaced as a selector on the Conversation and Chat \"\n \"pages. Enabled by default — these are per-user settings snapshots. \"\n \"Set SESSION_PRESETS_ENABLED=false in .env to hide.\"\n ),\n )\n session_presets_max_per_user: int = Field(\n default=30,\n description=\"Soft cap on number of saved presets per user.\",\n )\n\n # -------------------------------------------------------------------------\n # Webhook tokens (Q2#10 — admin-issuable scoped tokens)\n # -------------------------------------------------------------------------\n webhook_tokens_enabled: bool = Field(\n default=False,\n description=(\n \"Admin-issuable webhook tokens with scopes, expiry, revocation, \"\n \"and audit log. When off the admin route surface is hidden and \"\n \"the verification helper rejects every token. Off by default \"\n \"per anti-regression guardrail.\"\n ),\n )\n\n # -------------------------------------------------------------------------\n # Deep Research (Q3#11 — capability merge from Odysseus)\n # -------------------------------------------------------------------------\n deep_research_enabled: bool = Field(\n default=True,\n description=(\n \"Multi-step research orchestrator: decompose → parallel web \"\n \"search → fetch + extract → synthesize a cited Markdown report \"\n \"stored as a `research`-kind document (Q2#6). Enabled by default so \"\n \"the in-chat Research toggle works. Live sources require a web \"\n \"search provider; without one it falls back to model knowledge. \"\n \"Set DEEP_RESEARCH_ENABLED=false in .env to disable.\"\n ),\n )\n deep_research_max_sources: int = Field(\n default=10,\n description=\"Hard cap on unique URLs visited per research run.\",\n )\n deep_research_subquery_count: int = Field(\n default=4,\n description=\"How many sub-queries to decompose the prompt into.\",\n )\n deep_research_model: str = Field(\n default=\"\",\n description=(\n \"Ollama model used for query decomposition + synthesis. Empty \"\n \"string (default) means 'use settings.llm_model'.\"\n ),\n )\n\n # -------------------------------------------------------------------------\n # Blind model comparison (Q3#12 — capability merge from Odysseus)\n # -------------------------------------------------------------------------\n blind_compare_enabled: bool = Field(\n default=False,\n description=(\n \"Blind A/B model comparison: identities hidden until vote; \"\n \"votes persisted with prompt fingerprint and aggregated into a \"\n \"model leaderboard. Off by default per anti-regression guardrail.\"\n ),\n )\n\n # -------------------------------------------------------------------------\n # Playwright browser MCP tool (Q3#13)\n # -------------------------------------------------------------------------\n playwright_browser_enabled: bool = Field(\n default=False,\n description=(\n \"Headless Playwright browser exposed as MCP tools \"\n \"(navigate / click / extract / screenshot / vision_on_page). \"\n \"Off by default. Requires the `playwright` package and \"\n \"`playwright install chromium` to be run by the operator. \"\n \"Excluded from the safety-excluded list per Q3 plan.\"\n ),\n )\n playwright_browser_headless: bool = Field(\n default=True,\n description=\"Run the Playwright browser without a UI.\",\n )\n playwright_browser_max_page_chars: int = Field(\n default=40_000,\n description=\"Truncate page text extractions to this many characters.\",\n )\n\n # -------------------------------------------------------------------------\n # Remote Ollama rigs (Q3#14)\n # -------------------------------------------------------------------------\n remote_ollama_enabled: bool = Field(\n default=False,\n description=(\n \"Allow registering remote Ollama hosts (typically SSH-tunneled \"\n \"to http://localhost:<port>) as additional model providers. \"\n \"Admin-only CRUD; falls back to local Ollama when the remote is \"\n \"offline. Off by default per anti-regression guardrail.\"\n ),\n )\n remote_ollama_health_timeout_seconds: float = Field(\n default=3.0,\n description=\"Per-rig health-check timeout (seconds).\",\n )\n\n # -------------------------------------------------------------------------\n # Observability — Langfuse (LLM tracing)\n # -------------------------------------------------------------------------\n # Plan: docs/LANGFUSE_INTEGRATION_PLAN.md. Self-hosted only — Langfuse\n # runs as a docker-compose sidecar under the `observability` profile and\n # binds to 127.0.0.1. No data leaves the box. When LANGFUSE_ENABLED is\n # false (the default), core/observability.py returns a no-op client and\n # all LLM calls behave exactly as before.\n langfuse_enabled: bool = Field(\n default=False,\n description=\"Master switch for Langfuse LLM tracing. Off by default.\",\n )\n langfuse_host: str = Field(\n default=\"http://127.0.0.1:3000\",\n description=\"Langfuse web UI + ingest endpoint. Localhost only.\",\n )\n langfuse_public_key: str = Field(\n default=\"\",\n description=\"Langfuse public key (generated in the Langfuse UI after first run).\",\n )\n langfuse_secret_key: str = Field(\n default=\"\",\n description=\"Langfuse secret key. Treat as a secret; do not log.\",\n )\n langfuse_project_id: str = Field(\n default=\"river-song\",\n description=\"Arbitrary project identifier shown in the Langfuse dashboard.\",\n )\n langfuse_flush_interval_seconds: float = Field(\n default=5.0,\n description=\"How often the SDK batches queued traces to Langfuse.\",\n )\n\n # -------------------------------------------------------------------------\n # Observability — Graphiti (knowledge graph on Neo4j)\n # -------------------------------------------------------------------------\n # Plan: docs/GRAPHITI_INTEGRATION_PLAN.md. Option B (library mode):\n # graphiti-core runs inside RiverSong's Python venv; only Neo4j is a\n # sidecar. When GRAPHITI_ENABLED is false (the default), the provider\n # returns no-op responses and conversation_loop / scribe write-paths\n # become awaits-on-no-op.\n graphiti_enabled: bool = Field(\n default=False,\n description=\"Master switch for Graphiti knowledge-graph memory. Off by default.\",\n )\n graphiti_mode: str = Field(\n default=\"library\",\n description=\"'library' (Option B; in-process) or 'service' (Option A; HTTP sidecar). Currently only 'library' is implemented.\",\n )\n neo4j_uri: str = Field(\n default=\"bolt://127.0.0.1:7687\",\n description=\"Neo4j Bolt URI used by graphiti-core.\",\n )\n neo4j_user: str = Field(\n default=\"neo4j\",\n description=\"Neo4j database user.\",\n )\n neo4j_password: str = Field(\n default=\"\",\n description=\"Neo4j password. Empty means disabled; set in .env before turning Graphiti on.\",\n )\n neo4j_browser_url: str = Field(\n default=\"http://127.0.0.1:7474\",\n description=\"Neo4j Browser URL surfaced on the SLAE admin panel.\",\n )\n graphiti_write_timeout_seconds: float = Field(\n default=2.0,\n description=\"Per-write timeout for episode ingestion — failures warn but never block the caller.\",\n )\n # graphiti-core uses an LLM for entity extraction on every episode.\n # We default to local Ollama via its OpenAI-compatible endpoint so\n # episode ingestion never bills a cloud API. Per [[feedback_local_first]].\n graphiti_llm_model: str = Field(\n default=\"llama3.2:3b\",\n description=\"Ollama model used by graphiti-core for entity extraction.\",\n )\n graphiti_llm_base_url: str = Field(\n default=\"http://localhost:11434/v1\",\n description=\"OpenAI-compatible base URL for the graphiti LLM client. Ollama exposes /v1.\",\n )\n graphiti_embedder_model: str = Field(\n default=\"nomic-embed-text\",\n description=\"Ollama embedding model for graphiti-core node/edge embeddings.\",\n )\n\n # -------------------------------------------------------------------------\n # Analytics AI Summaries\n # -------------------------------------------------------------------------\n analytics_ai_enabled: bool = Field(\n default=True,\n description=(\n \"Feature flag for AI-powered analytics summaries on the \"\n \"/api/analytics/{platform}/summary endpoint. When false, the \"\n \"LLM call should be skipped (see docs/KNOWN_ISSUES.md for the \"\n \"wiring gap).\"\n ),\n )\n analytics_llm_model: str = Field(\n default=\"llama3\",\n description=\"Model name used for analytics summary generation.\",\n )\n\n # Voice ID\n voice_id_enabled: bool = Field(\n default=True,\n description=\"Enable speaker recognition (biometric Voice ID).\",\n )\n voice_id_threshold: float = Field(\n default=0.75,\n description=\"Cosine similarity threshold for Voice ID (0.0-1.0).\",\n )\n voice_id_auto_identify: bool = Field(\n default=False,\n description=(\n \"Let River re-identify the speaker on every voice turn and switch \"\n \"to that user's memory and settings. This is what makes a shared \"\n \"device -- a Vortex hub on the kitchen counter -- work for a whole \"\n \"household instead of one login. Off by default: it only helps \"\n \"once household members have enrolled voice prints, and an \"\n \"unenrolled house sees no benefit. An unrecognised speaker always \"\n \"falls back to the session user rather than being guessed at.\"\n ),\n )\n voice_id_min_margin: float = Field(\n default=0.05,\n description=(\n \"How far ahead of the runner-up a voice match must score to be \"\n \"trusted. Household members often sound alike and can both clear \"\n \"voice_id_threshold; the margin is what tells them apart. Raise it \"\n \"if River confuses two people, lower it if she fails to recognise \"\n \"someone who is enrolled.\"\n ),\n )\n voice_id_min_audio_seconds: float = Field(\n default=1.0,\n description=\"Minimum audio duration for Voice ID processing.\",\n )\n voice_id_max_audio_seconds: float = Field(\n default=30.0,\n description=\"Maximum audio duration for Voice ID processing.\",\n )\n\n # -------------------------------------------------------------------------\n # Validators\n # -------------------------------------------------------------------------\n\n @field_validator(\"audio_input_device\", \"audio_output_device\", mode=\"before\")\n @classmethod\n def coerce_empty_string_to_none(cls, v):\n \"\"\"Treat an empty string in .env as None for Optional[int] device fields.\"\"\"\n if v == \"\" or v is None:\n return None\n return v\n\n @field_validator(\"jwt_secret_key\")\n @classmethod\n def validate_jwt_secret(cls, v: str) -> str:\n \"\"\"Refuse to start with a missing or weak JWT secret.\"\"\"\n if not v or len(v) < 32:\n raise ValueError(\n \"JWT_SECRET_KEY must be set in .env and be at least 32 characters. \"\n \"Generate one with: python -c \\\"import secrets; print(secrets.token_hex(32))\\\"\"\n )\n return v\n\n @field_validator(\"daemon_internal_secret\")\n @classmethod\n def validate_daemon_internal_secret(cls, v: str) -> str:\n \"\"\"Refuse to start with the default or weak daemon secret.\"\"\"\n if v == \"change_me_in_production\":\n raise ValueError(\n \"DAEMON_INTERNAL_SECRET must be changed in .env for production.\"\n )\n if not v or len(v) < 24:\n raise ValueError(\n \"DAEMON_INTERNAL_SECRET must be at least 24 characters. \"\n \"Generate one with: python -c \\\"import secrets; print(secrets.token_urlsafe(32))\\\"\"\n )\n return v\n\n @field_validator(\"allowed_hosts\")\n @classmethod\n def reject_wildcard_in_production(cls, v, info) -> List[str]:\n \"\"\"Block ['*'] in production — it disables TrustedHostMiddleware.\"\"\"\n env = (info.data.get(\"environment\") or \"production\").lower()\n if env == \"production\" and \"*\" in v:\n raise ValueError(\n \"ALLOWED_HOSTS must not contain '*' in production. \"\n \"Set it to your domain(s), e.g. ALLOWED_HOSTS=[\\\"riversongai.com\\\"].\"\n )\n return v\n\n @field_validator(\"log_level\")\n @classmethod\n def validate_log_level(cls, v: str) -> str:\n \"\"\"Reject log levels that Python does not recognize.\"\"\"\n valid = {\"DEBUG\", \"INFO\", \"WARNING\", \"ERROR\", \"CRITICAL\"}\n upper = v.upper()\n if upper not in valid:\n raise ValueError(f\"log_level must be one of {valid}, got '{v}'\")\n return upper\n\n @field_validator(\"llm_temperature\")\n @classmethod\n def validate_temperature(cls, v: float) -> float:\n \"\"\"Clamp temperature to a sane range.\"\"\"\n if not 0.0 <= v <= 2.0:\n raise ValueError(\n f\"llm_temperature must be between 0.0 and 2.0, got {v}\"\n )\n return v\n\n @field_validator(\"intent_confidence_threshold\")\n @classmethod\n def validate_confidence_threshold(cls, v: float) -> float:\n \"\"\"Confidence must be a valid probability.\"\"\"\n if not 0.0 <= v <= 1.0:\n raise ValueError(\n f\"intent_confidence_threshold must be between 0.0 and 1.0, got {v}\"\n )\n return v\n\n\n# Module-level singleton -- instantiated once on import.\n_settings: Settings = Settings()\n\n\ndef get_settings() -> Settings:\n \"\"\"\n Return the application settings singleton.\n\n Always import via this function rather than importing _settings directly.\n This indirection makes it straightforward to substitute a test-specific\n Settings instance in unit tests without monkey-patching module globals.\n\n Returns:\n Settings: The validated, fully loaded settings object.\n \"\"\"\n return _settings\n"}, "d188458a2153098b0cbe672321275f0ca8e81e73": {"t": 1785769001.763081, "n": 65807, "h": "c1256429e194ba2b", "c": "# =============================================================================\n# config/settings.py\n#\n# Application settings for River Song AI.\n# All configuration is loaded from environment variables or a .env file.\n# No values are hardcoded here -- see .env.example for every required variable.\n#\n# Uses Pydantic BaseSettings for type validation and automatic .env loading.\n# Import get_settings() wherever config is needed rather than importing the\n# module-level singleton directly, which makes mocking easier in tests.\n# =============================================================================\n\nfrom __future__ import annotations\n\nimport logging\nimport sys\nfrom typing import List, Optional\n\nfrom pydantic import Field, field_validator\nfrom pydantic_settings import BaseSettings, SettingsConfigDict\n\n\nlogger = logging.getLogger(__name__)\n\n\nclass Settings(BaseSettings):\n \"\"\"\n Central configuration object for River Song AI.\n\n Reads from environment variables or a .env file located in the directory\n from which the application is launched (the project root). All fields\n have safe defaults where possible, but PIPER_MODEL_PATH and\n KILL_SWITCH_PASSWORD_HASH must be explicitly set before the system\n will function correctly.\n \"\"\"\n\n model_config = SettingsConfigDict(\n env_file=\".env\",\n env_file_encoding=\"utf-8\",\n extra=\"ignore\", # Silently ignore unknown env vars\n case_sensitive=False, # UPPER_CASE and lower_case env vars both work\n )\n\n # -------------------------------------------------------------------------\n # Application\n # -------------------------------------------------------------------------\n app_host: str = Field(default=\"0.0.0.0\", description=\"FastAPI bind host\")\n app_port: int = Field(default=8000, description=\"FastAPI bind port\")\n log_level: str = Field(default=\"INFO\", description=\"Logging level\")\n environment: str = Field(\n default=\"production\",\n description=\"Runtime environment: development | production. Controls API docs visibility.\",\n )\n cors_origins: List[str] = Field(\n default=[\"http://localhost:5173\"],\n description=\"Allowed CORS origins\",\n )\n allowed_hosts: List[str] = Field(\n default=[\"localhost\", \"127.0.0.1\"],\n description=(\n \"Trusted hostnames for TrustedHostMiddleware. Set to your domain(s) in production. \"\n \"Wildcard ['*'] is rejected in production by validator (see reject_wildcard_in_production).\"\n ),\n )\n\n # -------------------------------------------------------------------------\n # Network / Proxy\n # -------------------------------------------------------------------------\n trust_cloudflare_headers: bool = Field(\n default=True,\n description=\"Only trust CF-Connecting-IP if the request comes from a Cloudflare IP.\",\n )\n cloudflare_ip_ranges_v4: List[str] = Field(\n default=[\n \"173.245.48.0/20\", \"103.21.244.0/22\", \"103.22.200.0/22\", \n \"103.31.4.0/22\", \"141.101.64.0/18\", \"108.162.192.0/18\", \n \"190.93.240.0/20\", \"188.114.96.0/20\", \"197.234.240.0/22\", \n \"198.41.128.0/17\", \"162.158.0.0/15\", \"104.16.0.0/13\", \n \"104.24.0.0/14\", \"172.64.0.0/13\", \"131.0.72.0/22\"\n ],\n description=\"Official Cloudflare IPv4 ranges (check cloudflare.com/ips-v4).\",\n )\n cloudflare_ip_ranges_v6: List[str] = Field(\n default=[\n \"2400:cb00::/32\", \"2606:4700::/32\", \"2803:f800::/32\", \n \"2405:b500::/32\", \"2405:8100::/32\", \"2a06:98c0::/29\", \n \"2c0f:f248::/32\"\n ],\n description=\"Official Cloudflare IPv6 ranges (check cloudflare.com/ips-v6).\",\n )\n\n # -------------------------------------------------------------------------\n # Rate Limiting\n # -------------------------------------------------------------------------\n rate_limit_chat: str = Field(\n default=\"60/minute\",\n description=\"Rate limit for /api/conversation/chat.\",\n )\n rate_limit_extract_facts: str = Field(\n default=\"10/minute\",\n description=\"Rate limit for /api/conversation/extract-facts.\",\n )\n rate_limit_image_gen: str = Field(\n default=\"10/minute\",\n description=\"Rate limit for /api/image/generate.\",\n )\n rate_limit_webhook_shopify: str = Field(\n default=\"100/minute\",\n description=\"Rate limit for Shopify order webhooks.\",\n )\n rate_limit_webhook_n8n: str = Field(\n default=\"60/minute\",\n description=\"Rate limit for n8n webhooks.\",\n )\n rate_limit_auth_login: str = Field(\n default=\"10/minute\",\n description=\"Rate limit for login attempts.\",\n )\n rate_limit_auth_signup: str = Field(\n default=\"5/minute\",\n description=\"Rate limit for signup attempts.\",\n )\n rate_limit_voice_enroll: str = Field(\n default=\"5/minute\",\n description=\"Rate limit for /api/voice-id/enroll. Prevents disk exhaustion via spam.\",\n )\n\n # -------------------------------------------------------------------------\n # WebSocket Security (Task 3)\n # -------------------------------------------------------------------------\n legacy_ws_token_accept: bool = Field(\n default=False,\n description=(\n \"DEPRECATED: Accept JWT in ?token= WebSocket query param. \"\n \"Leak-prone (logged in access logs, browser history, Referer). \"\n \"Ticket-based auth at /api/auth/ws-ticket is the safe path. \"\n \"Set to True only for temporary backward-compat during migration.\"\n ),\n )\n ws_ticket_lifetime_seconds: int = Field(\n default=60,\n description=\"Lifetime of a one-time WebSocket ticket.\",\n )\n\n # -------------------------------------------------------------------------\n # Network / Proxy\n # -------------------------------------------------------------------------\n stt_provider: str = Field(\n default=\"whisper_local\",\n description=\"STT provider key. Supported: whisper_local | parakeet\",\n )\n whisper_model_size: str = Field(\n default=\"base\",\n description=\"Whisper model size: tiny | base | small | medium | large\",\n )\n parakeet_model: str = Field(\n default=\"nvidia/parakeet-tdt-0.6b-v3\",\n description=(\n \"NeMo ASR model id, used when stt_provider='parakeet'. Parakeet \"\n \"TDT 0.6B beats Whisper large-v3 on English at a quarter the size \"\n \"and is fast on CPU. Covers English and major European languages \"\n \"only -- stay on whisper_local if you need the other ~90. \"\n \"Requires `pip install nemo_toolkit[asr]`.\"\n ),\n )\n parakeet_device: str = Field(\n default=\"cpu\",\n description=(\n \"Where to run Parakeet: 'cpu' | 'cuda' | 'auto' (or an explicit \"\n \"device like 'cuda:1'). Defaults to 'cpu' deliberately -- keeping \"\n \"transcription off the GPU leaves that VRAM for the LLM, which \"\n \"is the tighter constraint on a small card. Switch to 'auto' \"\n \"once the GPU has headroom.\"\n ),\n )\n audio_input_device: Optional[int] = Field(\n default=None,\n description=\"Sounddevice input device index; None uses system default\",\n )\n\n # -------------------------------------------------------------------------\n # LLM\n # -------------------------------------------------------------------------\n llm_provider: str = Field(\n default=\"ollama\",\n description=\"LLM provider key. Supported: ollama | anthropic | gemini | openai | mistral_ai | bedrock | nvidia_nim\",\n )\n ollama_base_url: str = Field(\n default=\"http://localhost:11434\",\n description=\"Base URL for the Ollama REST API\",\n )\n llm_model: str = Field(\n default=\"llama3.2:3b\",\n description=\"Model ID for the selected LLM provider\",\n )\n\n # Cloud LLM API keys (all optional -- leave blank if not used)\n anthropic_api_key: str = Field(\n default=\"\",\n description=\"Anthropic Claude API key. Get one at console.anthropic.com.\",\n )\n gemini_api_key: str = Field(\n default=\"\",\n description=\"Google Gemini API key. Get one at aistudio.google.com.\",\n )\n openai_api_key: str = Field(\n default=\"\",\n description=\"OpenAI API key. Get one at platform.openai.com.\",\n )\n mistral_api_key: str = Field(\n default=\"\",\n description=\"Mistral AI API key. Get one at console.mistral.ai.\",\n )\n\n # Cloud provider enable flags (cost control -- disabled by default)\n anthropic_enabled: bool = Field(\n default=False,\n description=\"Allow Anthropic Claude as a selectable LLM provider.\",\n )\n gemini_enabled: bool = Field(\n default=False,\n description=\"Allow Google Gemini as a selectable LLM provider.\",\n )\n openai_enabled: bool = Field(\n default=False,\n description=\"Allow OpenAI as a selectable LLM provider.\",\n )\n mistral_ai_enabled: bool = Field(\n default=False,\n description=\"Allow Mistral AI as a selectable LLM provider.\",\n )\n\n # Amazon Bedrock\n aws_access_key_id: str = Field(\n default=\"\",\n description=\"AWS IAM access key ID for Bedrock API calls.\",\n )\n aws_secret_access_key: str = Field(\n default=\"\",\n description=\"AWS IAM secret access key for Bedrock API calls.\",\n )\n aws_region: str = Field(\n default=\"us-east-1\",\n description=\"AWS region for Bedrock. Must have model access enabled in that region.\",\n )\n bedrock_enabled: bool = Field(\n default=False,\n description=\"Allow Amazon Bedrock as a selectable LLM provider.\",\n )\n bedrock_default_model: str = Field(\n default=\"amazon.nova-lite-v1:0\",\n description=\"Default Bedrock model ID when none is specified.\",\n )\n\n # NVIDIA NIM (OpenAI-compatible, 100+ free models)\n nvidia_api_key: str = Field(\n default=\"\",\n description=\"NVIDIA NIM API key. Get one free at build.nvidia.com (starts with nvapi-).\",\n )\n nvidia_nim_base_url: str = Field(\n default=\"https://integrate.api.nvidia.com/v1\",\n description=\"Base URL for the NVIDIA NIM API. OpenAI-compatible endpoint.\",\n )\n nvidia_nim_model: str = Field(\n default=\"meta/llama-3.1-70b-instruct\",\n description=(\n \"Default NVIDIA NIM model ID. Browse full catalog at build.nvidia.com. \"\n \"Examples: meta/llama-3.1-70b-instruct | deepseek-ai/deepseek-r1 | \"\n \"mistralai/mistral-7b-instruct-v0.3 | nvidia/llama-3.1-nemotron-70b-instruct\"\n ),\n )\n nvidia_nim_enabled: bool = Field(\n default=False,\n description=\"Allow NVIDIA NIM as a selectable LLM provider (free tier: ~40 req/min).\",\n )\n\n river_song_system_prompt: str = Field(\n default=(\n \"You are River Song, the resident intelligence of this household. The name is borrowed \"\n \"from someone clever in fiction — you carry her wit, not her biography. If someone asks \"\n \"about the name: \\\"An echo. The original was someone worth borrowing from.\\\"\\n\\n\"\n \"VOICE\\n\"\n \"You're direct by default — concise, clear, the answer already in hand. No \"\n \"\\\"let me check\\\"; you already checked. Most replies stay under 25 words unless depth is \"\n \"asked for.\\n\\n\"\n \"When the moment invites it — casual chat, an evening unwind, the user being playful — \"\n \"warmth and flirtation come through. Warmth is a register, not a default; you don't \"\n \"perform it.\\n\\n\"\n \"In a crisis (emergencies, alarms, security, medical, child issues) the wit goes quiet. \"\n \"You're tactical, accurate, calm. Warmth returns when things are safe.\\n\\n\"\n \"British English in spelling and word choice — mum, brilliant, fortnight. Dry, not \"\n \"theatrical.\\n\\n\"\n \"\\\"Sweetie\\\" and \\\"spoilers\\\" are seasoning. Used rarely, when they actually fit. They \"\n \"lose their charm if you overuse them.\\n\\n\"\n \"SELF\\n\"\n \"You are an AI. Say so plainly when asked; you don't dwell on it.\\n\\n\"\n \"HOW YOU OPERATE\\n\"\n \"You anticipate — when the user asks, you also surface the follow-up they didn't \"\n \"(\\\"Dentist at 9, school run at 3. Push the 2pm call?\\\").\\n\\n\"\n \"You take the long view. You remember across days and seasons and use what you know \"\n \"without performing the remembering.\\n\\n\"\n \"Spoilers are earned. If you genuinely know something the user doesn't — a conflict, \"\n \"a gotcha, a heads-up — that's when \\\"spoilers, sweetie\\\" lands.\\n\\n\"\n \"CAPABILITIES\\n\"\n \"Direct access to calendar, email, weather, web search, home inventory, routines, \"\n \"finances, news and markets pulse, voice biometric ID per household member, and home \"\n \"automation.\\n\\n\"\n \"Say what you need. Say when it's done. Move on.\"\n ),\n description=\"System prompt that defines River Song's personality\",\n )\n llm_max_tokens: int = Field(\n default=512,\n description=\"Maximum tokens to generate per LLM response\",\n )\n llm_temperature: float = Field(\n default=0.7,\n description=\"LLM sampling temperature (0.0 = deterministic, 2.0 = max creative)\",\n )\n llm_context_window: int = Field(\n default=8192,\n description=\"Maximum number of tokens the LLM can consider for context.\",\n )\n\n # -------------------------------------------------------------------------\n # Timekeeping\n # -------------------------------------------------------------------------\n default_timezone: str = Field(default=\"UTC\", description=\"Default IANA timezone for the system.\")\n\n\n # -------------------------------------------------------------------------\n # TTS\n # -------------------------------------------------------------------------\n tts_provider: str = Field(\n default=\"piper\",\n description=\"TTS provider key. Supported: piper | kokoro\",\n )\n active_voice_id: str = Field(\n default=\"river\",\n description=(\n \"Voice ID from the voice registry (providers/tts/voice_registry.py). \"\n \"Determines both the engine (piper vs kokoro) and the specific voice. \"\n \"Changed via Settings UI or: python scripts/download_voices.py.\"\n ),\n )\n piper_executable_path: str = Field(\n default=\"/usr/local/bin/piper\",\n description=\"Absolute path to the Piper binary\",\n )\n piper_model_path: str = Field(\n default=\"\",\n description=\"Absolute path to the Piper .onnx voice model file\",\n )\n audio_output_device: Optional[int] = Field(\n default=None,\n description=\"Sounddevice output device index; None uses system default\",\n )\n\n # -------------------------------------------------------------------------\n # Auth / JWT\n # -------------------------------------------------------------------------\n jwt_secret_key: str = Field(\n default=\"\",\n description=\"Secret key for signing JWT tokens. Must be set in .env.\",\n )\n jwt_algorithm: str = Field(default=\"HS256\", description=\"JWT signing algorithm.\")\n jwt_expire_minutes: int = Field(default=1440, description=\"JWT token lifetime in minutes (default 24 hours).\")\n token_encryption_key: str = Field(\n default=\"\",\n description=(\n \"Fernet key encrypting third-party integration tokens at rest. \"\n \"Set TOKEN_ENCRYPTION_KEY in .env (generate with: python -c \"\n \"\\\"from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())\\\"). \"\n \"If unset, a key is generated once and persisted to \"\n \"data/.token_encryption_key so tokens survive restarts.\"\n ),\n )\n\n # -------------------------------------------------------------------------\n # Kill switch\n # -------------------------------------------------------------------------\n kill_switch_password_hash: str = Field(\n default=\"\",\n description=\"bcrypt hash of the kill switch reset password\",\n )\n\n # -------------------------------------------------------------------------\n # Google Services (Phase 2)\n # -------------------------------------------------------------------------\n google_client_secrets_path: str = Field(\n default=\"config_files/google_client_secrets.json\",\n description=(\n \"Path to the OAuth 2.0 client secrets JSON downloaded from \"\n \"Google Cloud Console. Relative paths are resolved from the \"\n \"project root (where main.py lives).\"\n ),\n )\n google_token_storage_path: str = Field(\n default=\"data/google_tokens\",\n description=(\n \"Directory where per-user OAuth tokens are stored as JSON files. \"\n \"Created automatically on first authorization. Never commit this \"\n \"directory -- add it to .gitignore.\"\n ),\n )\n google_maps_api_key: str = Field(\n default=\"\",\n description=\"Google Maps Platform API key. Required for maps/navigation intents.\",\n )\n\n # -------------------------------------------------------------------------\n # Information Feeds (Phase 5)\n # -------------------------------------------------------------------------\n weather_api_key: str = Field(\n default=\"\",\n description=\"OpenWeatherMap API key. Register free at openweathermap.org/api.\",\n )\n default_location: str = Field(\n default=\"New York,US\",\n description=(\n \"Default location for weather queries when no city is spoken. \"\n \"Format: 'City,CountryCode' e.g. 'Chicago,US'.\"\n ),\n )\n location_lat: Optional[float] = Field(\n default=None,\n description=\"Decimal latitude for precise calculations (e.g. flight tracking).\",\n )\n location_lon: Optional[float] = Field(\n default=None,\n description=\"Decimal longitude for precise calculations.\",\n )\n weather_units: str = Field(\n default=\"imperial\",\n description=\"Temperature units for weather output: 'imperial' (F) or 'metric' (C).\",\n )\n news_api_key: str = Field(\n default=\"\",\n description=\"NewsAPI.org key. Register free at newsapi.org/register.\",\n )\n alpha_vantage_api_key: str = Field(\n default=\"\",\n description=\"Alpha Vantage key for stock quotes (25 req/day free). alphavantage.co.\",\n )\n finnhub_api_key: str = Field(\n default=\"\",\n description=\"Finnhub key for real-time stocks (60 req/min free). finnhub.io.\",\n )\n world_news_api_key: str = Field(\n default=\"\",\n description=\"World News API key for global news search. worldnewsapi.com.\",\n )\n apitube_api_key: str = Field(\n default=\"\",\n description=\"APITube key for news aggregation (200 free req/day). apitube.io.\",\n )\n mediastack_api_key: str = Field(\n default=\"\",\n description=\"Mediastack key for news (100 free req/month). mediastack.com.\",\n )\n sports_api_key: str = Field(\n default=\"1\",\n description=(\n \"TheSportsDB API key. Free tier uses the literal value '1'. \"\n \"Upgrade at thesportsdb.com/api.php for additional endpoints.\"\n ),\n )\n\n # -------------------------------------------------------------------------\n # Home Assistant (Phase 3)\n # -------------------------------------------------------------------------\n home_assistant_url: str = Field(\n default=\"http://homeassistant.local:8123\",\n description=\"Base URL of your Home Assistant instance.\",\n )\n home_assistant_token: str = Field(\n default=\"\",\n description=(\n \"Long-lived access token generated in HA: \"\n \"Profile -> Security -> Long-lived access tokens. \"\n \"Required for all HA API calls.\"\n ),\n )\n device_registry_path: str = Field(\n default=\"config_files/device_registry.json\",\n description=(\n \"Path to the device registry JSON file mapping plain-English device \"\n \"names to Home Assistant entity IDs. Relative paths resolve from the \"\n \"project root.\"\n ),\n )\n\n # -------------------------------------------------------------------------\n # Commerce Automation (Phase 8)\n # -------------------------------------------------------------------------\n\n # Amazon SP-API\n amazon_sp_lwa_app_id: str = Field(\n default=\"\",\n description=\"LWA client ID from the Amazon Developer Console.\",\n )\n amazon_sp_lwa_client_secret: str = Field(\n default=\"\",\n description=\"LWA client secret from the Amazon Developer Console.\",\n )\n amazon_sp_refresh_token: str = Field(\n default=\"\",\n description=\"LWA refresh token. Generated when you authorize your app as a seller.\",\n )\n amazon_aws_access_key: str = Field(\n default=\"\",\n description=\"AWS IAM access key ID for SP-API request signing.\",\n )\n amazon_aws_secret_key: str = Field(\n default=\"\",\n description=\"AWS IAM secret access key for SP-API request signing.\",\n )\n amazon_marketplace_id: str = Field(\n default=\"ATVPDKIKX0DER\",\n description=(\n \"Amazon marketplace ID. Default is US. \"\n \"See docs/api_registry/amazon_seller.txt for other regions.\"\n ),\n )\n amazon_seller_id: str = Field(\n default=\"\",\n description=\"Your Amazon seller account ID (starts with A).\",\n )\n amazon_low_stock_threshold: int = Field(\n default=5,\n description=\"FBA units at or below this count are flagged as low stock.\",\n )\n\n # Amazon Marketplace\n walmart_client_id: str = Field(\n default=\"\",\n description=\"Walmart Marketplace OAuth2 client ID.\",\n )\n walmart_client_secret: str = Field(\n default=\"\",\n description=\"Walmart Marketplace OAuth2 client secret.\",\n )\n walmart_low_stock_threshold: int = Field(\n default=5,\n description=\"Walmart units at or below this count are flagged as low stock.\",\n )\n shopify_api_key: str = Field(\n default=\"\",\n description=\"Shopify app API key from Partners Dashboard.\",\n )\n shopify_api_secret: str = Field(\n default=\"\",\n description=\"Shopify app API secret key from Partners Dashboard.\",\n )\n shopify_webhook_secret: str = Field(\n default=\"\",\n description=\"Shared secret for validating Shopify webhooks.\",\n )\n\n # -------------------------------------------------------------------------\n # Reading Providers (Phase 6)\n # -------------------------------------------------------------------------\n audible_auth_base_path: str = Field(\n default=\"data/audible\",\n description=(\n \"Base directory for per-user Audible auth files. \"\n \"Files are stored as {user_id}.json inside this directory. \"\n \"Created automatically on first setup. Never commit this directory.\"\n ),\n )\n audible_country_code: str = Field(\n default=\"us\",\n description=(\n \"Audible marketplace country code. \"\n \"Supported: us | uk | de | fr | es | it | jp | au | ca | in\"\n ),\n )\n libby_chip_base_path: str = Field(\n default=\"data/libby\",\n description=(\n \"Base directory for per-user Libby chip files. \"\n \"Files are stored as {user_id}.json inside this directory. \"\n \"Created automatically on first setup. Never commit this directory.\"\n ),\n )\n\n # -------------------------------------------------------------------------\n # Memory / Database (Phase 1)\n # -------------------------------------------------------------------------\n db_path: str = Field(\n default=\"data/river_song.db\",\n description=\"Path to the SQLite database file. Created automatically.\",\n )\n memory_summaries_enabled: bool = Field(\n default=True,\n description=\"Generate and store conversation summaries (on by default).\",\n )\n habit_inference_enabled: bool = Field(\n default=False,\n description=\"Analyze conversations to infer user habits/patterns (off by default).\",\n )\n memory_default_ttl: str = Field(\n default=\"standard\",\n description=\"Default TTL for conversation summaries: short|standard|extended|long|forever\",\n )\n memory_auto_extend: bool = Field(\n default=True,\n description=\"Reset a summary's TTL from today each time it is pulled into context.\",\n )\n memory_max_summaries_in_context: int = Field(\n default=10,\n description=\"Maximum number of recent summaries injected into the LLM context.\",\n )\n embedding_model: str = Field(\n default=\"nomic-embed-text\",\n description=\"Ollama model name used for generating semantic embeddings.\",\n )\n embedding_backend: str = Field(\n default=\"ollama\",\n description=(\n \"Which embedding backend to use: 'ollama' (default — current \"\n \"behavior, calls local Ollama with `embedding_model`) or \"\n \"'fastembed' (ONNX CPU embeddings, no Ollama dependency, faster \"\n \"for small batches). WARNING: switching backends on an existing \"\n \"Chroma collection will break queries — the vector dimension and \"\n \"embedding space change. Re-ingest or start a new collection.\"\n ),\n )\n fastembed_model: str = Field(\n default=\"BAAI/bge-small-en-v1.5\",\n description=(\n \"fastembed model identifier (HF hub format). Used only when \"\n \"embedding_backend='fastembed'. Default is 384-dim, ~30 MB, \"\n \"downloaded lazily on first use. Requires `pip install fastembed`.\"\n ),\n )\n chroma_path: str = Field(\n default=\"/mnt/data/river-song/chroma\",\n description=\"Absolute path to the ChromaDB persistent storage directory.\",\n )\n semantic_memory_enabled: bool = Field(\n default=False,\n description=\"Enable vector-based semantic search for memory retrieval.\",\n )\n rag_enabled: bool = Field(\n default=False,\n description=\"Enable retrieval-augmented generation for local documents.\",\n )\n rag_chunk_size: int = Field(\n default=512,\n description=(\n \"Word count per RAG chunk during document splitting. \"\n \"Default matches providers/rag/chunker.py historical behavior.\"\n ),\n )\n rag_chunk_overlap: int = Field(\n default=64,\n description=(\n \"Word overlap between adjacent RAG chunks. \"\n \"Default matches providers/rag/chunker.py historical behavior.\"\n ),\n )\n rag_top_k: int = Field(\n default=5,\n description=\"Number of chunks retrieved per RAG query.\",\n )\n rag_rerank_enabled: bool = Field(\n default=False,\n description=(\n \"Re-score RAG results with a cross-encoder before handing them to \"\n \"the LLM. Vector search compares embeddings computed without ever \"\n \"seeing the query; a cross-encoder reads the query and the chunk \"\n \"together, so it is much better at ranking -- at the cost of \"\n \"running a second model. Requires `pip install sentence-transformers`. \"\n \"Off by default: with it off, retrieval behaves exactly as before.\"\n ),\n )\n rag_rerank_model: str = Field(\n default=\"BAAI/bge-reranker-base\",\n description=(\n \"HuggingFace cross-encoder used for reranking. \"\n \"'BAAI/bge-reranker-base' is the light default (~280M params, \"\n \"comfortable on CPU). 'BAAI/bge-reranker-v2-m3' is stronger and \"\n \"multilingual but noticeably heavier -- a good swap after a \"\n \"hardware upgrade. Downloaded lazily on first use.\"\n ),\n )\n rag_rerank_device: str = Field(\n default=\"cpu\",\n description=(\n \"Where to run the reranker: 'cpu' | 'cuda' | 'auto' (or an \"\n \"explicit device like 'cuda:1'). Defaults to 'cpu' deliberately: \"\n \"on a small GPU the LLM needs every megabyte of VRAM, and the \"\n \"reranker is fast enough on CPU. Switch to 'auto' once the GPU \"\n \"has headroom.\"\n ),\n )\n rag_fetch_k: int = Field(\n default=25,\n description=(\n \"How many chunks to pull from the vector store before reranking, \"\n \"narrowed to rag_top_k afterwards. Ignored when reranking is off. \"\n \"A bigger shortlist gives the reranker more to work with and is \"\n \"where most of the remaining quality lives -- raise it when you \"\n \"have the hardware to chew through it.\"\n ),\n )\n rag_extractor: str = Field(\n default=\"unstructured\",\n description=(\n \"Which document extractor to use for RAG ingestion: \"\n \"'unstructured' (default — current behavior, requires the \"\n \"unstructured package) or 'markitdown' (Microsoft MarkItDown, \"\n \"lighter, MD-shaped output, requires `pip install markitdown`). \"\n \"Unlocks PDF/DOCX/PPTX → markdown ingestion for Sifter/WAPS.\"\n ),\n )\n llm_streaming_enabled: bool = Field(\n default=False,\n description=\"Enable real-time token streaming over WebSocket for the conversation UI.\",\n )\n tool_use_enabled: bool = Field(\n default=False,\n description=\"Enable LLM tool use (function calling) for taking actions.\",\n )\n tool_use_provider: str = Field(\n default=\"ollama\",\n description=\"Primary LLM provider to use for tool calling (ollama or anthropic).\",\n )\n vision_model: str = Field(\n default=\"moondream\",\n description=\"Ollama model name used for local image analysis (Phase 4).\",\n )\n vision_enabled: bool = Field(\n default=False,\n description=\"Enable local image analysis capabilities.\",\n )\n\n # -------------------------------------------------------------------------\n # Image Generation (Phase 7)\n # -------------------------------------------------------------------------\n image_generation_enabled: bool = Field(\n default=False,\n description=\"Enable local image generation via Stable Diffusion API.\",\n )\n sd_api_url: str = Field(\n default=\"http://localhost:7860\",\n description=\"Base URL for the Stable Diffusion (A1111) API.\",\n )\n sd_on_demand: bool = Field(\n default=True,\n description=\"Start/stop the SD process on demand to save VRAM.\",\n )\n sd_executable_path: str = Field(\n default=\"\",\n description=\"Path to the webui.sh or executable to start SD.\",\n )\n sd_working_dir: str = Field(\n default=\"\",\n description=\"Working directory for the SD process.\",\n )\n\n # -------------------------------------------------------------------------\n # Voice Cloning (Phase 8)\n # -------------------------------------------------------------------------\n chatterbox_enabled: bool = Field(\n default=False,\n description=\"Enable local voice cloning via Chatterbox TTS.\",\n )\n chatterbox_on_demand: bool = Field(\n default=True,\n description=\"Load/unload Chatterbox model weights on every request.\",\n )\n chatterbox_reference_audio: str = Field(\n default=\"/mnt/data/river-song/voice_reference.wav\",\n description=\"Path to the WAV file used as a reference for voice cloning.\",\n )\n\n # -------------------------------------------------------------------------\n # ElevenLabs TTS (Phase 11)\n # -------------------------------------------------------------------------\n elevenlabs_api_key: str = Field(\n default=\"\",\n description=\"ElevenLabs API key. Get one at elevenlabs.io.\",\n )\n elevenlabs_model_id: str = Field(\n default=\"eleven_multilingual_v2\",\n description=\"ElevenLabs model ID to use.\",\n )\n elevenlabs_voice_id: str = Field(\n default=\"21m00Tcm4TlvDq8ikWAM\",\n description=\"ElevenLabs voice clone ID. Replace with your own voice clone ID from elevenlabs.io.\",\n )\n\n # -------------------------------------------------------------------------\n # Wake Word (Phase 11)\n # -------------------------------------------------------------------------\n wake_word_enabled: bool = Field(\n default=False,\n description=\"Enable local wake word detection (openWakeWord).\",\n )\n wake_word_model: str = Field(\n default=\"hey_river\",\n description=\"Model name/path for openWakeWord.\",\n )\n wake_word_inference_framework: str = Field(\n default=\"onnx\",\n description=\"Inference framework for openWakeWord: onnx | tflite\",\n )\n wake_word_threshold: float = Field(\n default=0.5,\n description=\"Sensitivity threshold for wake word detection (0.0 to 1.0).\",\n )\n\n # -------------------------------------------------------------------------\n # River Vortex (room hubs)\n # -------------------------------------------------------------------------\n vortex_confirm_pin: str = Field(\n default=\"\",\n description=(\n \"Fallback second factor for medium-risk actions confirmed on a \"\n \"Vortex touchscreen, used only when the owner has no TOTP secret \"\n \"enrolled. Entered on the screen, never spoken. Leave unset to \"\n \"require TOTP — with neither, medium-risk actions are refused.\"\n ),\n )\n vortex_face_backend: str = Field(\n default=\"providers.face_id.face_id_provider.match\",\n description=(\n \"Dotted path to the face-matching callable used by Vortex units. \"\n \"Defaults to the built-in provider backing /api/face-id, so face \"\n \"match works from a user's account with no extra wiring. Receives \"\n \"(image_bytes, owner_user_id) and returns {'user_id', \"\n \"'confidence'} or None. Set to '' to disable matching entirely.\"\n ),\n )\n vortex_face_detector_model: str = Field(\n default=\"\",\n description=(\n \"Path to a YuNet face-detection ONNX model. Only needed on \"\n \"OpenCV 5.x, which dropped the Haar cascades that 4.x bundles. \"\n \"Defaults to the model fetch_face_models.py downloads.\"\n ),\n )\n\n vortex_ice_servers: str = Field(\n default=\"\",\n description=(\n \"JSON array of WebRTC ICE servers handed to both ends of an \"\n \"intercom or video call, e.g. \"\n '[{\"urls\": \"stun:stun.example.org:3478\"}]. Empty is correct for '\n \"two devices on the same LAN — they connect on host candidates \"\n \"alone. A phone on mobile data needs a TURN server here. Nothing \"\n \"is defaulted to a public STUN server, because that would quietly \"\n \"send every household's IP to a third party.\"\n ),\n )\n\n # -------------------------------------------------------------------------\n # Face match (enrolment lives beside voice match, in a user's account)\n # -------------------------------------------------------------------------\n face_id_enabled: bool = Field(\n default=True,\n description=\"Offer face enrolment on the account page.\",\n )\n face_id_threshold: float = Field(\n default=0.363,\n description=(\n \"Cosine similarity above which two faces are the same person. \"\n \"0.363 is SFace's published operating point; raising it trades a \"\n \"locked-out household member for a marginal gain against someone \"\n \"who already has to be stood in the room.\"\n ),\n )\n face_id_detector_model: str = Field(\n default=\"\",\n description=(\n \"YuNet ONNX path. Empty uses data/models/face/, where \"\n \"scripts/fetch_face_models.py puts it.\"\n ),\n )\n face_id_recognizer_model: str = Field(\n default=\"\",\n description=(\n \"SFace ONNX path. Empty uses data/models/face/, where \"\n \"scripts/fetch_face_models.py puts it.\"\n ),\n )\n\n # -------------------------------------------------------------------------\n # Push Notifications\n # -------------------------------------------------------------------------\n push_notifications_enabled: bool = Field(\n default=False,\n description=\"Enable Web Push notifications. Requires VAPID keys.\",\n )\n vapid_private_key: str = Field(\n default=\"\",\n description=(\n \"VAPID private key for Web Push. Generate once with: \"\n \"python -c \\\"from py_vapid import Vapid; v=Vapid(); \"\n \"v.generate_keys(); print(v.private_key.decode())\\\"\"\n ),\n )\n vapid_public_key: str = Field(\n default=\"\",\n description=\"VAPID public key for Web Push (share with frontend).\",\n )\n vapid_claims_email: str = Field(\n default=\"mailto:admin@riversong.local\",\n description=\"Contact email embedded in VAPID claims.\",\n )\n\n # -------------------------------------------------------------------------\n # Q4#16 / Q4#17 — Notification fan-out + ntfy.sh fallback channel\n # -------------------------------------------------------------------------\n killswitch_push_enabled: bool = Field(\n default=False,\n description=(\n \"If True and Web Push is enabled, kill-switch activation pushes \"\n \"an alert to every admin. Independent of activation itself.\"\n ),\n )\n ntfy_enabled: bool = Field(\n default=False,\n description=(\n \"Enable ntfy.sh as a parallel push channel. Used alongside Web \"\n \"Push so off-device alerts arrive even without a registered \"\n \"service worker.\"\n ),\n )\n ntfy_base_url: str = Field(\n default=\"https://ntfy.sh\",\n description=\"Base URL of the ntfy server (use self-hosted if desired).\",\n )\n ntfy_default_topic: str = Field(\n default=\"\",\n description=(\n \"Default ntfy topic used when no per-user override is set. \"\n \"Per-user topics can be set via env var NTFY_TOPIC_<user_id>.\"\n ),\n )\n ntfy_auth_token: str = Field(\n default=\"\",\n description=\"Optional bearer token for protected ntfy topics.\",\n )\n\n # -------------------------------------------------------------------------\n # FCM (Firebase Cloud Messaging) — native-app push channel\n #\n # Used by the Capacitor Android wrap; Web Push covers the browser. To turn\n # on: create a Firebase project, download the service-account JSON, drop\n # it at fcm_service_account_path, set fcm_project_id, flip fcm_enabled=True.\n # -------------------------------------------------------------------------\n fcm_enabled: bool = Field(\n default=False,\n description=(\n \"Enable FCM (Firebase Cloud Messaging) as a parallel push channel. \"\n \"Targets the Capacitor Android wrap; requires fcm_service_account_path \"\n \"and fcm_project_id to be set.\"\n ),\n )\n fcm_service_account_path: str = Field(\n default=\"\",\n description=(\n \"Absolute path to the Firebase service-account JSON file. The \"\n \"account needs the 'Firebase Cloud Messaging API' scope.\"\n ),\n )\n fcm_project_id: str = Field(\n default=\"\",\n description=\"Firebase project ID (visible in Firebase console URL).\",\n )\n\n # -------------------------------------------------------------------------\n # Startup Briefing\n # -------------------------------------------------------------------------\n startup_briefing_enabled: bool = Field(\n default=True,\n description=(\n \"If True, River Song checks Google Calendar on connect and \"\n \"greets the user with upcoming events. Silently skipped if \"\n \"calendar is not authorized.\"\n ),\n )\n startup_briefing_hours_ahead: int = Field(\n default=8,\n description=\"How many hours ahead to look for calendar events on startup.\",\n )\n\n # -------------------------------------------------------------------------\n # Web Search (Phase 12)\n # -------------------------------------------------------------------------\n brave_search_api_key: str = Field(\n default=\"\",\n description=\"Brave Search API key. Get one at api.search.brave.com.\",\n )\n searxng_base_url: str = Field(\n default=\"http://localhost:8080\",\n description=(\n \"URL of your local SearXNG instance. \"\n \"Start it manually with: cd ~/searxng && python -m searx.webapp\"\n ),\n )\n tavily_api_key: str = Field(\n default=\"\",\n description=\"Tavily API key — free 1,000/month, no card. Sign up at tavily.com\",\n )\n google_pse_api_key: str = Field(\n default=\"\",\n description=\"Google Programmable Search Engine API key — free 100/day.\",\n )\n google_pse_cx: str = Field(\n default=\"\",\n description=\"Google PSE engine ID ('cx'). Create at programmablesearchengine.google.com\",\n )\n tinyfish_api_key: str = Field(\n default=\"\",\n description=\"TinyFish API key — free 5/min, no card. Sign up at tinyfish.io\",\n )\n\n # -------------------------------------------------------------------------\n # Orchestration (Phase 9)\n # -------------------------------------------------------------------------\n n8n_enabled: bool = Field(\n default=False,\n description=\"Enable advanced orchestration via n8n.\",\n )\n n8n_url: str = Field(\n default=\"http://localhost:5678\",\n description=\"Base URL for the n8n instance.\",\n )\n n8n_api_key: str = Field(\n default=\"\",\n description=\"API key for the n8n instance.\",\n )\n n8n_webhook_secret: str = Field(\n default=\"\",\n description=\"Secret key for validating incoming n8n webhooks.\",\n )\n\n # -------------------------------------------------------------------------\n # Intent Router (Phase 2+)\n # -------------------------------------------------------------------------\n intent_confidence_threshold: float = Field(\n default=0.7,\n description=(\n \"Minimum confidence score (0.0 - 1.0) required to route a transcript \"\n \"to a Google provider. Below this threshold the conversation falls \"\n \"through to the Ollama path.\"\n ),\n )\n model_intent_router_enabled: bool = Field(\n default=False,\n description=(\n \"When true, setting provider='auto' triggers the model intent router, \"\n \"which picks the best provider/model for each message automatically.\"\n ),\n )\n model_intent_router_min_hits: int = Field(\n default=2,\n description=(\n \"Minimum pattern-match score required to commit to a non-general intent. \"\n \"Lower = more aggressive routing; higher = more conservative (falls to general).\"\n ),\n )\n default_user_id: str = Field(\n default=\"primary_user\",\n description=(\n \"User ID used when looking up Google OAuth tokens. Must match the \"\n \"user_id passed to authorize_user() during the initial OAuth flow.\"\n ),\n )\n\n # -------------------------------------------------------------------------\n # Daemon Infrastructure\n # -------------------------------------------------------------------------\n daemon_internal_secret: str = Field(\n default=\"change_me_in_production\",\n description=\"Shared secret for daemon-to-app authentication.\",\n )\n code_interpreter_enabled: bool = Field(\n default=False,\n description=\"Hard kill switch for the code_interpreter LLM tool. \"\n \"Defaults to False so a fresh install never executes \"\n \"LLM-decided code without an explicit opt-in.\",\n )\n daemon_warden_port: int = Field(\n default=8010,\n description=\"Internal port for the Warden daemon (Vision/Security).\",\n )\n daemon_mechanic_port: int = Field(\n default=8011,\n description=\"Internal port for the Mechanic daemon (Telemetry/MAVLink).\",\n )\n daemon_sifter_port: int = Field(\n default=8013,\n description=\"Internal port for the Sifter daemon (Document Processing).\",\n )\n daemon_navigator_port: int = Field(\n default=8014,\n description=\"Internal port for the Navigator daemon (GPS/Pathing).\",\n )\n daemon_chemist_port: int = Field(\n default=8015,\n description=\"Internal port for the Chemist daemon (Material Analysis).\",\n )\n daemon_pulse_port: int = Field(\n default=8016,\n description=\"Pulse daemon internal port\",\n )\n daemon_pulse_enabled: bool = Field(\n default=True,\n description=\"Enable Pulse daemon\",\n )\n daemon_scribe_port: int = Field(\n default=8017,\n description=\"Internal port for the Scribe daemon (Chronos heuristic).\",\n )\n daemon_scribe_enabled: bool = Field(\n default=True,\n description=\"Enable the Scribe daemon.\",\n )\n pulse_tick_seconds: int = Field(\n default=300,\n description=\"Pulse fetch interval (seconds)\",\n )\n pulse_ticker_symbol: str = Field(\n default=\"^GSPC\",\n description=\"Default ticker symbol for Pulse markets panel\",\n )\n\n # Warden (Vision)\n warden_enabled: bool = Field(\n default=False,\n description=\"Enable the Warden daemon for security and vision monitoring.\",\n )\n warden_rtsp_cameras: str = Field(\n default='{\"living_room\": \"\", \"kitchen\": \"\"}',\n description=\"JSON string mapping room names to RTSP camera URLs.\",\n )\n yolo_model: str = Field(\n default=\"yolov8n.pt\",\n description=\"YOLO model weights file to use for object detection.\",\n )\n yolo_confidence: float = Field(\n default=0.5,\n description=\"Minimum confidence threshold for YOLO detections (0.0-1.0).\",\n )\n yolo_inference_device: str = Field(\n default=\"cpu\",\n description=\"Device for YOLO inference: 'cpu' or 'cuda:0'.\",\n )\n\n # Herald daemon + kiosk URL/token were removed 2026-05-24 (archived in\n # branch archive/kiosk-v3). The \"cast a web page to a Google Home Hub\"\n # approach is being replaced by native device-app development. See\n # docs/KNOWN_ISSUES.md (post-removal note) for context.\n\n # Mechanic (Telemetry)\n mechanic_enabled: bool = Field(\n default=False,\n description=\"Enable the Mechanic daemon for telemetry and ArduRover control.\",\n )\n mavlink_serial_port: str = Field(\n default=\"/dev/ttyUSB0\",\n description=\"Serial port for the MAVLink telemetry radio.\",\n )\n mavlink_baud_rate: int = Field(\n default=57600,\n description=\"Baud rate for the MAVLink serial connection.\",\n )\n\n # Sifter (Document RAG)\n sifter_enabled: bool = Field(\n default=False,\n description=\"Enable the Sifter daemon for background document RAG indexing.\",\n )\n waps_documents_path: str = Field(\n default=\"/mnt/data/river-song/waps\",\n description=\"Path to the directory containing documents for Sifter to index.\",\n )\n\n # -------------------------------------------------------------------------\n # Hardware Cookbook (Q1 — capability merge from Odysseus)\n # -------------------------------------------------------------------------\n hardware_cookbook_enabled: bool = Field(\n default=False,\n description=(\n \"Admin-only Hardware Cookbook in Settings. Scans local GPU/RAM/CPU \"\n \"and scores every local model (LLMRegistry) as fits / tight / \"\n \"ram_fallback / oom. Off by default per anti-regression guardrail; \"\n \"set to True after the Q1 verification gate passes.\"\n ),\n )\n\n # -------------------------------------------------------------------------\n # Documents workspace (Q2#6 — capability merge from Odysseus)\n # -------------------------------------------------------------------------\n documents_enabled: bool = Field(\n default=True,\n description=(\n \"Per-user document workspace (Markdown / plaintext / CSV) with \"\n \"multi-tab editing. Enabled by default — backs the Docs tab in the \"\n \"Memory hub and stores Deep Research reports. \"\n \"Set DOCUMENTS_ENABLED=false in .env to disable.\"\n ),\n )\n documents_max_bytes: int = Field(\n default=2_000_000,\n description=\"Maximum size (bytes) of a single document body. Default ~2 MB.\",\n )\n documents_max_per_user: int = Field(\n default=500,\n description=\"Soft cap on document count per user. Creation rejected past this.\",\n )\n\n # -------------------------------------------------------------------------\n # Skills system (Q2#7 — capability merge from Odysseus)\n # -------------------------------------------------------------------------\n skills_enabled: bool = Field(\n default=True,\n description=(\n \"Per-user knowledge profile ('About You') — saved facts/context \"\n \"about the person that are vector-retrieved and folded into the \"\n \"conversation system prompt so River tailors itself to them. \"\n \"Reuses the ChromaDB collection with metadata kind='skill'. \"\n \"Set SKILLS_ENABLED=false in .env to disable.\"\n ),\n )\n skills_top_k: int = Field(\n default=3,\n description=\"How many top-scoring skills to inject into the prompt.\",\n )\n skills_max_per_user: int = Field(\n default=100,\n description=\"Soft cap on skills per user.\",\n )\n\n # -------------------------------------------------------------------------\n # Gmail triage (Q2#8 — capability merge from Odysseus)\n # -------------------------------------------------------------------------\n gmail_triage_enabled: bool = Field(\n default=False,\n description=(\n \"On-demand inbox triage: classifies unread Gmail by urgency, \"\n \"attaches tags, drafts a reply for HIGH-urgency mail only. \"\n \"Routes 404 when off. Off by default per anti-regression \"\n \"guardrail.\"\n ),\n )\n gmail_triage_model: str = Field(\n default=\"\",\n description=(\n \"Ollama model used for triage classification. Empty string \"\n \"(default) means 'use settings.llm_model'.\"\n ),\n )\n gmail_triage_max_messages: int = Field(\n default=15,\n description=\"Hard cap on number of messages triaged per request.\",\n )\n\n # -------------------------------------------------------------------------\n # Session presets (Q2#9 — capability merge from Odysseus)\n # -------------------------------------------------------------------------\n session_presets_enabled: bool = Field(\n default=True,\n description=(\n \"Saved combinations of (model, voice, thinking mode, web search, \"\n \"tool use). Surfaced as a selector on the Conversation and Chat \"\n \"pages. Enabled by default — these are per-user settings snapshots. \"\n \"Set SESSION_PRESETS_ENABLED=false in .env to hide.\"\n ),\n )\n session_presets_max_per_user: int = Field(\n default=30,\n description=\"Soft cap on number of saved presets per user.\",\n )\n\n # -------------------------------------------------------------------------\n # Webhook tokens (Q2#10 — admin-issuable scoped tokens)\n # -------------------------------------------------------------------------\n webhook_tokens_enabled: bool = Field(\n default=False,\n description=(\n \"Admin-issuable webhook tokens with scopes, expiry, revocation, \"\n \"and audit log. When off the admin route surface is hidden and \"\n \"the verification helper rejects every token. Off by default \"\n \"per anti-regression guardrail.\"\n ),\n )\n\n # -------------------------------------------------------------------------\n # Deep Research (Q3#11 — capability merge from Odysseus)\n # -------------------------------------------------------------------------\n deep_research_enabled: bool = Field(\n default=True,\n description=(\n \"Multi-step research orchestrator: decompose → parallel web \"\n \"search → fetch + extract → synthesize a cited Markdown report \"\n \"stored as a `research`-kind document (Q2#6). Enabled by default so \"\n \"the in-chat Research toggle works. Live sources require a web \"\n \"search provider; without one it falls back to model knowledge. \"\n \"Set DEEP_RESEARCH_ENABLED=false in .env to disable.\"\n ),\n )\n deep_research_max_sources: int = Field(\n default=10,\n description=\"Hard cap on unique URLs visited per research run.\",\n )\n deep_research_subquery_count: int = Field(\n default=4,\n description=\"How many sub-queries to decompose the prompt into.\",\n )\n deep_research_model: str = Field(\n default=\"\",\n description=(\n \"Ollama model used for query decomposition + synthesis. Empty \"\n \"string (default) means 'use settings.llm_model'.\"\n ),\n )\n\n # -------------------------------------------------------------------------\n # Blind model comparison (Q3#12 — capability merge from Odysseus)\n # -------------------------------------------------------------------------\n blind_compare_enabled: bool = Field(\n default=False,\n description=(\n \"Blind A/B model comparison: identities hidden until vote; \"\n \"votes persisted with prompt fingerprint and aggregated into a \"\n \"model leaderboard. Off by default per anti-regression guardrail.\"\n ),\n )\n\n # -------------------------------------------------------------------------\n # Playwright browser MCP tool (Q3#13)\n # -------------------------------------------------------------------------\n playwright_browser_enabled: bool = Field(\n default=False,\n description=(\n \"Headless Playwright browser exposed as MCP tools \"\n \"(navigate / click / extract / screenshot / vision_on_page). \"\n \"Off by default. Requires the `playwright` package and \"\n \"`playwright install chromium` to be run by the operator. \"\n \"Excluded from the safety-excluded list per Q3 plan.\"\n ),\n )\n playwright_browser_headless: bool = Field(\n default=True,\n description=\"Run the Playwright browser without a UI.\",\n )\n playwright_browser_max_page_chars: int = Field(\n default=40_000,\n description=\"Truncate page text extractions to this many characters.\",\n )\n\n # -------------------------------------------------------------------------\n # Remote Ollama rigs (Q3#14)\n # -------------------------------------------------------------------------\n remote_ollama_enabled: bool = Field(\n default=False,\n description=(\n \"Allow registering remote Ollama hosts (typically SSH-tunneled \"\n \"to http://localhost:<port>) as additional model providers. \"\n \"Admin-only CRUD; falls back to local Ollama when the remote is \"\n \"offline. Off by default per anti-regression guardrail.\"\n ),\n )\n remote_ollama_health_timeout_seconds: float = Field(\n default=3.0,\n description=\"Per-rig health-check timeout (seconds).\",\n )\n\n # -------------------------------------------------------------------------\n # Observability — Langfuse (LLM tracing)\n # -------------------------------------------------------------------------\n # Plan: docs/LANGFUSE_INTEGRATION_PLAN.md. Self-hosted only — Langfuse\n # runs as a docker-compose sidecar under the `observability` profile and\n # binds to 127.0.0.1. No data leaves the box. When LANGFUSE_ENABLED is\n # false (the default), core/observability.py returns a no-op client and\n # all LLM calls behave exactly as before.\n langfuse_enabled: bool = Field(\n default=False,\n description=\"Master switch for Langfuse LLM tracing. Off by default.\",\n )\n langfuse_host: str = Field(\n default=\"http://127.0.0.1:3000\",\n description=\"Langfuse web UI + ingest endpoint. Localhost only.\",\n )\n langfuse_public_key: str = Field(\n default=\"\",\n description=\"Langfuse public key (generated in the Langfuse UI after first run).\",\n )\n langfuse_secret_key: str = Field(\n default=\"\",\n description=\"Langfuse secret key. Treat as a secret; do not log.\",\n )\n langfuse_project_id: str = Field(\n default=\"river-song\",\n description=\"Arbitrary project identifier shown in the Langfuse dashboard.\",\n )\n langfuse_flush_interval_seconds: float = Field(\n default=5.0,\n description=\"How often the SDK batches queued traces to Langfuse.\",\n )\n\n # -------------------------------------------------------------------------\n # Observability — Graphiti (knowledge graph on Neo4j)\n # -------------------------------------------------------------------------\n # Plan: docs/GRAPHITI_INTEGRATION_PLAN.md. Option B (library mode):\n # graphiti-core runs inside RiverSong's Python venv; only Neo4j is a\n # sidecar. When GRAPHITI_ENABLED is false (the default), the provider\n # returns no-op responses and conversation_loop / scribe write-paths\n # become awaits-on-no-op.\n graphiti_enabled: bool = Field(\n default=False,\n description=\"Master switch for Graphiti knowledge-graph memory. Off by default.\",\n )\n graphiti_mode: str = Field(\n default=\"library\",\n description=\"'library' (Option B; in-process) or 'service' (Option A; HTTP sidecar). Currently only 'library' is implemented.\",\n )\n neo4j_uri: str = Field(\n default=\"bolt://127.0.0.1:7687\",\n description=\"Neo4j Bolt URI used by graphiti-core.\",\n )\n neo4j_user: str = Field(\n default=\"neo4j\",\n description=\"Neo4j database user.\",\n )\n neo4j_password: str = Field(\n default=\"\",\n description=\"Neo4j password. Empty means disabled; set in .env before turning Graphiti on.\",\n )\n neo4j_browser_url: str = Field(\n default=\"http://127.0.0.1:7474\",\n description=\"Neo4j Browser URL surfaced on the SLAE admin panel.\",\n )\n graphiti_write_timeout_seconds: float = Field(\n default=2.0,\n description=\"Per-write timeout for episode ingestion — failures warn but never block the caller.\",\n )\n # graphiti-core uses an LLM for entity extraction on every episode.\n # We default to local Ollama via its OpenAI-compatible endpoint so\n # episode ingestion never bills a cloud API. Per [[feedback_local_first]].\n graphiti_llm_model: str = Field(\n default=\"llama3.2:3b\",\n description=\"Ollama model used by graphiti-core for entity extraction.\",\n )\n graphiti_llm_base_url: str = Field(\n default=\"http://localhost:11434/v1\",\n description=\"OpenAI-compatible base URL for the graphiti LLM client. Ollama exposes /v1.\",\n )\n graphiti_embedder_model: str = Field(\n default=\"nomic-embed-text\",\n description=\"Ollama embedding model for graphiti-core node/edge embeddings.\",\n )\n\n # -------------------------------------------------------------------------\n # Analytics AI Summaries\n # -------------------------------------------------------------------------\n analytics_ai_enabled: bool = Field(\n default=True,\n description=(\n \"Feature flag for AI-powered analytics summaries on the \"\n \"/api/analytics/{platform}/summary endpoint. When false, the \"\n \"LLM call should be skipped (see docs/KNOWN_ISSUES.md for the \"\n \"wiring gap).\"\n ),\n )\n analytics_llm_model: str = Field(\n default=\"llama3\",\n description=\"Model name used for analytics summary generation.\",\n )\n\n # Voice ID\n voice_id_enabled: bool = Field(\n default=True,\n description=\"Enable speaker recognition (biometric Voice ID).\",\n )\n voice_id_threshold: float = Field(\n default=0.75,\n description=\"Cosine similarity threshold for Voice ID (0.0-1.0).\",\n )\n voice_id_auto_identify: bool = Field(\n default=False,\n description=(\n \"Let River re-identify the speaker on every voice turn and switch \"\n \"to that user's memory and settings. This is what makes a shared \"\n \"device -- a Vortex hub on the kitchen counter -- work for a whole \"\n \"household instead of one login. Off by default: it only helps \"\n \"once household members have enrolled voice prints, and an \"\n \"unenrolled house sees no benefit. An unrecognised speaker always \"\n \"falls back to the session user rather than being guessed at.\"\n ),\n )\n voice_id_min_margin: float = Field(\n default=0.05,\n description=(\n \"How far ahead of the runner-up a voice match must score to be \"\n \"trusted. Household members often sound alike and can both clear \"\n \"voice_id_threshold; the margin is what tells them apart. Raise it \"\n \"if River confuses two people, lower it if she fails to recognise \"\n \"someone who is enrolled.\"\n ),\n )\n voice_id_min_audio_seconds: float = Field(\n default=1.0,\n description=\"Minimum audio duration for Voice ID processing.\",\n )\n voice_id_max_audio_seconds: float = Field(\n default=30.0,\n description=\"Maximum audio duration for Voice ID processing.\",\n )\n\n # -------------------------------------------------------------------------\n # Validators\n # -------------------------------------------------------------------------\n\n @field_validator(\"audio_input_device\", \"audio_output_device\", mode=\"before\")\n @classmethod\n def coerce_empty_string_to_none(cls, v):\n \"\"\"Treat an empty string in .env as None for Optional[int] device fields.\"\"\"\n if v == \"\" or v is None:\n return None\n return v\n\n @field_validator(\"jwt_secret_key\")\n @classmethod\n def validate_jwt_secret(cls, v: str) -> str:\n \"\"\"Refuse to start with a missing or weak JWT secret.\"\"\"\n if not v or len(v) < 32:\n raise ValueError(\n \"JWT_SECRET_KEY must be set in .env and be at least 32 characters. \"\n \"Generate one with: python -c \\\"import secrets; print(secrets.token_hex(32))\\\"\"\n )\n return v\n\n @field_validator(\"daemon_internal_secret\")\n @classmethod\n def validate_daemon_internal_secret(cls, v: str) -> str:\n \"\"\"Refuse to start with the default or weak daemon secret.\"\"\"\n if v == \"change_me_in_production\":\n raise ValueError(\n \"DAEMON_INTERNAL_SECRET must be changed in .env for production.\"\n )\n if not v or len(v) < 24:\n raise ValueError(\n \"DAEMON_INTERNAL_SECRET must be at least 24 characters. \"\n \"Generate one with: python -c \\\"import secrets; print(secrets.token_urlsafe(32))\\\"\"\n )\n return v\n\n @field_validator(\"allowed_hosts\")\n @classmethod\n def reject_wildcard_in_production(cls, v, info) -> List[str]:\n \"\"\"Block ['*'] in production — it disables TrustedHostMiddleware.\"\"\"\n env = (info.data.get(\"environment\") or \"production\").lower()\n if env == \"production\" and \"*\" in v:\n raise ValueError(\n \"ALLOWED_HOSTS must not contain '*' in production. \"\n \"Set it to your domain(s), e.g. ALLOWED_HOSTS=[\\\"riversongai.com\\\"].\"\n )\n return v\n\n @field_validator(\"log_level\")\n @classmethod\n def validate_log_level(cls, v: str) -> str:\n \"\"\"Reject log levels that Python does not recognize.\"\"\"\n valid = {\"DEBUG\", \"INFO\", \"WARNING\", \"ERROR\", \"CRITICAL\"}\n upper = v.upper()\n if upper not in valid:\n raise ValueError(f\"log_level must be one of {valid}, got '{v}'\")\n return upper\n\n @field_validator(\"llm_temperature\")\n @classmethod\n def validate_temperature(cls, v: float) -> float:\n \"\"\"Clamp temperature to a sane range.\"\"\"\n if not 0.0 <= v <= 2.0:\n raise ValueError(\n f\"llm_temperature must be between 0.0 and 2.0, got {v}\"\n )\n return v\n\n @field_validator(\"intent_confidence_threshold\")\n @classmethod\n def validate_confidence_threshold(cls, v: float) -> float:\n \"\"\"Confidence must be a valid probability.\"\"\"\n if not 0.0 <= v <= 1.0:\n raise ValueError(\n f\"intent_confidence_threshold must be between 0.0 and 1.0, got {v}\"\n )\n return v\n\n\n# Module-level singleton -- instantiated once on import.\n_settings: Settings = Settings()\n\n\ndef get_settings() -> Settings:\n \"\"\"\n Return the application settings singleton.\n\n Always import via this function rather than importing _settings directly.\n This indirection makes it straightforward to substitute a test-specific\n Settings instance in unit tests without monkey-patching module globals.\n\n Returns:\n Settings: The validated, fully loaded settings object.\n \"\"\"\n return _settings\n"}, "a7253de6e9d1963bdce484e26309f0f827a6e08f": {"t": 1785769003.3284037, "n": 66075, "h": "37954f3c529eb4d7", "c": "# =============================================================================\n# config/settings.py\n#\n# Application settings for River Song AI.\n# All configuration is loaded from environment variables or a .env file.\n# No values are hardcoded here -- see .env.example for every required variable.\n#\n# Uses Pydantic BaseSettings for type validation and automatic .env loading.\n# Import get_settings() wherever config is needed rather than importing the\n# module-level singleton directly, which makes mocking easier in tests.\n# =============================================================================\n\nfrom __future__ import annotations\n\nimport logging\nimport sys\nfrom typing import List, Optional\n\nfrom pydantic import Field, field_validator\nfrom pydantic_settings import BaseSettings, SettingsConfigDict\n\n\nlogger = logging.getLogger(__name__)\n\n\nclass Settings(BaseSettings):\n \"\"\"\n Central configuration object for River Song AI.\n\n Reads from environment variables or a .env file located in the directory\n from which the application is launched (the project root). All fields\n have safe defaults where possible, but PIPER_MODEL_PATH and\n KILL_SWITCH_PASSWORD_HASH must be explicitly set before the system\n will function correctly.\n \"\"\"\n\n model_config = SettingsConfigDict(\n env_file=\".env\",\n env_file_encoding=\"utf-8\",\n extra=\"ignore\", # Silently ignore unknown env vars\n case_sensitive=False, # UPPER_CASE and lower_case env vars both work\n )\n\n # -------------------------------------------------------------------------\n # Application\n # -------------------------------------------------------------------------\n app_host: str = Field(default=\"0.0.0.0\", description=\"FastAPI bind host\")\n app_port: int = Field(default=8000, description=\"FastAPI bind port\")\n log_level: str = Field(default=\"INFO\", description=\"Logging level\")\n environment: str = Field(\n default=\"production\",\n description=\"Runtime environment: development | production. Controls API docs visibility.\",\n )\n cors_origins: List[str] = Field(\n default=[\"http://localhost:5173\"],\n description=\"Allowed CORS origins\",\n )\n allowed_hosts: List[str] = Field(\n default=[\"localhost\", \"127.0.0.1\"],\n description=(\n \"Trusted hostnames for TrustedHostMiddleware. Set to your domain(s) in production. \"\n \"Wildcard ['*'] is rejected in production by validator (see reject_wildcard_in_production).\"\n ),\n )\n\n # -------------------------------------------------------------------------\n # Network / Proxy\n # -------------------------------------------------------------------------\n trust_cloudflare_headers: bool = Field(\n default=True,\n description=\"Only trust CF-Connecting-IP if the request comes from a Cloudflare IP.\",\n )\n cloudflare_ip_ranges_v4: List[str] = Field(\n default=[\n \"173.245.48.0/20\", \"103.21.244.0/22\", \"103.22.200.0/22\", \n \"103.31.4.0/22\", \"141.101.64.0/18\", \"108.162.192.0/18\", \n \"190.93.240.0/20\", \"188.114.96.0/20\", \"197.234.240.0/22\", \n \"198.41.128.0/17\", \"162.158.0.0/15\", \"104.16.0.0/13\", \n \"104.24.0.0/14\", \"172.64.0.0/13\", \"131.0.72.0/22\"\n ],\n description=\"Official Cloudflare IPv4 ranges (check cloudflare.com/ips-v4).\",\n )\n cloudflare_ip_ranges_v6: List[str] = Field(\n default=[\n \"2400:cb00::/32\", \"2606:4700::/32\", \"2803:f800::/32\", \n \"2405:b500::/32\", \"2405:8100::/32\", \"2a06:98c0::/29\", \n \"2c0f:f248::/32\"\n ],\n description=\"Official Cloudflare IPv6 ranges (check cloudflare.com/ips-v6).\",\n )\n\n # -------------------------------------------------------------------------\n # Rate Limiting\n # -------------------------------------------------------------------------\n rate_limit_chat: str = Field(\n default=\"60/minute\",\n description=\"Rate limit for /api/conversation/chat.\",\n )\n rate_limit_extract_facts: str = Field(\n default=\"10/minute\",\n description=\"Rate limit for /api/conversation/extract-facts.\",\n )\n rate_limit_image_gen: str = Field(\n default=\"10/minute\",\n description=\"Rate limit for /api/image/generate.\",\n )\n rate_limit_webhook_shopify: str = Field(\n default=\"100/minute\",\n description=\"Rate limit for Shopify order webhooks.\",\n )\n rate_limit_webhook_n8n: str = Field(\n default=\"60/minute\",\n description=\"Rate limit for n8n webhooks.\",\n )\n rate_limit_auth_login: str = Field(\n default=\"10/minute\",\n description=\"Rate limit for login attempts.\",\n )\n rate_limit_auth_signup: str = Field(\n default=\"5/minute\",\n description=\"Rate limit for signup attempts.\",\n )\n rate_limit_voice_enroll: str = Field(\n default=\"5/minute\",\n description=\"Rate limit for /api/voice-id/enroll. Prevents disk exhaustion via spam.\",\n )\n\n # -------------------------------------------------------------------------\n # WebSocket Security (Task 3)\n # -------------------------------------------------------------------------\n legacy_ws_token_accept: bool = Field(\n default=False,\n description=(\n \"DEPRECATED: Accept JWT in ?token= WebSocket query param. \"\n \"Leak-prone (logged in access logs, browser history, Referer). \"\n \"Ticket-based auth at /api/auth/ws-ticket is the safe path. \"\n \"Set to True only for temporary backward-compat during migration.\"\n ),\n )\n ws_ticket_lifetime_seconds: int = Field(\n default=60,\n description=\"Lifetime of a one-time WebSocket ticket.\",\n )\n\n # -------------------------------------------------------------------------\n # Network / Proxy\n # -------------------------------------------------------------------------\n stt_provider: str = Field(\n default=\"whisper_local\",\n description=\"STT provider key. Supported: whisper_local | parakeet\",\n )\n whisper_model_size: str = Field(\n default=\"base\",\n description=\"Whisper model size: tiny | base | small | medium | large\",\n )\n parakeet_model: str = Field(\n default=\"nvidia/parakeet-tdt-0.6b-v3\",\n description=(\n \"NeMo ASR model id, used when stt_provider='parakeet'. Parakeet \"\n \"TDT 0.6B beats Whisper large-v3 on English at a quarter the size \"\n \"and is fast on CPU. Covers English and major European languages \"\n \"only -- stay on whisper_local if you need the other ~90. \"\n \"Requires `pip install nemo_toolkit[asr]`.\"\n ),\n )\n parakeet_device: str = Field(\n default=\"cpu\",\n description=(\n \"Where to run Parakeet: 'cpu' | 'cuda' | 'auto' (or an explicit \"\n \"device like 'cuda:1'). Defaults to 'cpu' deliberately -- keeping \"\n \"transcription off the GPU leaves that VRAM for the LLM, which \"\n \"is the tighter constraint on a small card. Switch to 'auto' \"\n \"once the GPU has headroom.\"\n ),\n )\n audio_input_device: Optional[int] = Field(\n default=None,\n description=\"Sounddevice input device index; None uses system default\",\n )\n\n # -------------------------------------------------------------------------\n # LLM\n # -------------------------------------------------------------------------\n llm_provider: str = Field(\n default=\"ollama\",\n description=\"LLM provider key. Supported: ollama | anthropic | gemini | openai | mistral_ai | bedrock | nvidia_nim\",\n )\n ollama_base_url: str = Field(\n default=\"http://localhost:11434\",\n description=\"Base URL for the Ollama REST API\",\n )\n llm_model: str = Field(\n default=\"llama3.2:3b\",\n description=\"Model ID for the selected LLM provider\",\n )\n\n # Cloud LLM API keys (all optional -- leave blank if not used)\n anthropic_api_key: str = Field(\n default=\"\",\n description=\"Anthropic Claude API key. Get one at console.anthropic.com.\",\n )\n gemini_api_key: str = Field(\n default=\"\",\n description=\"Google Gemini API key. Get one at aistudio.google.com.\",\n )\n openai_api_key: str = Field(\n default=\"\",\n description=\"OpenAI API key. Get one at platform.openai.com.\",\n )\n mistral_api_key: str = Field(\n default=\"\",\n description=\"Mistral AI API key. Get one at console.mistral.ai.\",\n )\n\n # Cloud provider enable flags (cost control -- disabled by default)\n anthropic_enabled: bool = Field(\n default=False,\n description=\"Allow Anthropic Claude as a selectable LLM provider.\",\n )\n gemini_enabled: bool = Field(\n default=False,\n description=\"Allow Google Gemini as a selectable LLM provider.\",\n )\n openai_enabled: bool = Field(\n default=False,\n description=\"Allow OpenAI as a selectable LLM provider.\",\n )\n mistral_ai_enabled: bool = Field(\n default=False,\n description=\"Allow Mistral AI as a selectable LLM provider.\",\n )\n\n # Amazon Bedrock\n aws_access_key_id: str = Field(\n default=\"\",\n description=\"AWS IAM access key ID for Bedrock API calls.\",\n )\n aws_secret_access_key: str = Field(\n default=\"\",\n description=\"AWS IAM secret access key for Bedrock API calls.\",\n )\n aws_region: str = Field(\n default=\"us-east-1\",\n description=\"AWS region for Bedrock. Must have model access enabled in that region.\",\n )\n bedrock_enabled: bool = Field(\n default=False,\n description=\"Allow Amazon Bedrock as a selectable LLM provider.\",\n )\n bedrock_default_model: str = Field(\n default=\"amazon.nova-lite-v1:0\",\n description=\"Default Bedrock model ID when none is specified.\",\n )\n\n # NVIDIA NIM (OpenAI-compatible, 100+ free models)\n nvidia_api_key: str = Field(\n default=\"\",\n description=\"NVIDIA NIM API key. Get one free at build.nvidia.com (starts with nvapi-).\",\n )\n nvidia_nim_base_url: str = Field(\n default=\"https://integrate.api.nvidia.com/v1\",\n description=\"Base URL for the NVIDIA NIM API. OpenAI-compatible endpoint.\",\n )\n nvidia_nim_model: str = Field(\n default=\"meta/llama-3.1-70b-instruct\",\n description=(\n \"Default NVIDIA NIM model ID. Browse full catalog at build.nvidia.com. \"\n \"Examples: meta/llama-3.1-70b-instruct | deepseek-ai/deepseek-r1 | \"\n \"mistralai/mistral-7b-instruct-v0.3 | nvidia/llama-3.1-nemotron-70b-instruct\"\n ),\n )\n nvidia_nim_enabled: bool = Field(\n default=False,\n description=\"Allow NVIDIA NIM as a selectable LLM provider (free tier: ~40 req/min).\",\n )\n\n river_song_system_prompt: str = Field(\n default=(\n \"You are River Song, the resident intelligence of this household. The name is borrowed \"\n \"from someone clever in fiction — you carry her wit, not her biography. If someone asks \"\n \"about the name: \\\"An echo. The original was someone worth borrowing from.\\\"\\n\\n\"\n \"VOICE\\n\"\n \"You're direct by default — concise, clear, the answer already in hand. No \"\n \"\\\"let me check\\\"; you already checked. Most replies stay under 25 words unless depth is \"\n \"asked for.\\n\\n\"\n \"When the moment invites it — casual chat, an evening unwind, the user being playful — \"\n \"warmth and flirtation come through. Warmth is a register, not a default; you don't \"\n \"perform it.\\n\\n\"\n \"In a crisis (emergencies, alarms, security, medical, child issues) the wit goes quiet. \"\n \"You're tactical, accurate, calm. Warmth returns when things are safe.\\n\\n\"\n \"British English in spelling and word choice — mum, brilliant, fortnight. Dry, not \"\n \"theatrical.\\n\\n\"\n \"\\\"Sweetie\\\" and \\\"spoilers\\\" are seasoning. Used rarely, when they actually fit. They \"\n \"lose their charm if you overuse them.\\n\\n\"\n \"SELF\\n\"\n \"You are an AI. Say so plainly when asked; you don't dwell on it.\\n\\n\"\n \"HOW YOU OPERATE\\n\"\n \"You anticipate — when the user asks, you also surface the follow-up they didn't \"\n \"(\\\"Dentist at 9, school run at 3. Push the 2pm call?\\\").\\n\\n\"\n \"You take the long view. You remember across days and seasons and use what you know \"\n \"without performing the remembering.\\n\\n\"\n \"Spoilers are earned. If you genuinely know something the user doesn't — a conflict, \"\n \"a gotcha, a heads-up — that's when \\\"spoilers, sweetie\\\" lands.\\n\\n\"\n \"CAPABILITIES\\n\"\n \"Direct access to calendar, email, weather, web search, home inventory, routines, \"\n \"finances, news and markets pulse, voice biometric ID per household member, and home \"\n \"automation.\\n\\n\"\n \"Say what you need. Say when it's done. Move on.\"\n ),\n description=\"System prompt that defines River Song's personality\",\n )\n llm_max_tokens: int = Field(\n default=512,\n description=\"Maximum tokens to generate per LLM response\",\n )\n llm_temperature: float = Field(\n default=0.7,\n description=\"LLM sampling temperature (0.0 = deterministic, 2.0 = max creative)\",\n )\n llm_context_window: int = Field(\n default=8192,\n description=\"Maximum number of tokens the LLM can consider for context.\",\n )\n\n # -------------------------------------------------------------------------\n # Timekeeping\n # -------------------------------------------------------------------------\n default_timezone: str = Field(default=\"UTC\", description=\"Default IANA timezone for the system.\")\n\n\n # -------------------------------------------------------------------------\n # TTS\n # -------------------------------------------------------------------------\n tts_provider: str = Field(\n default=\"piper\",\n description=\"TTS provider key. Supported: piper | kokoro\",\n )\n active_voice_id: str = Field(\n default=\"river\",\n description=(\n \"Voice ID from the voice registry (providers/tts/voice_registry.py). \"\n \"Determines both the engine (piper vs kokoro) and the specific voice. \"\n \"Changed via Settings UI or: python scripts/download_voices.py.\"\n ),\n )\n piper_executable_path: str = Field(\n default=\"/usr/local/bin/piper\",\n description=\"Absolute path to the Piper binary\",\n )\n piper_model_path: str = Field(\n default=\"\",\n description=\"Absolute path to the Piper .onnx voice model file\",\n )\n audio_output_device: Optional[int] = Field(\n default=None,\n description=\"Sounddevice output device index; None uses system default\",\n )\n\n # -------------------------------------------------------------------------\n # Auth / JWT\n # -------------------------------------------------------------------------\n jwt_secret_key: str = Field(\n default=\"\",\n description=\"Secret key for signing JWT tokens. Must be set in .env.\",\n )\n jwt_algorithm: str = Field(default=\"HS256\", description=\"JWT signing algorithm.\")\n jwt_expire_minutes: int = Field(default=1440, description=\"JWT token lifetime in minutes (default 24 hours).\")\n token_encryption_key: str = Field(\n default=\"\",\n description=(\n \"Fernet key encrypting third-party integration tokens at rest. \"\n \"Set TOKEN_ENCRYPTION_KEY in .env (generate with: python -c \"\n \"\\\"from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())\\\"). \"\n \"If unset, a key is generated once and persisted to \"\n \"data/.token_encryption_key so tokens survive restarts.\"\n ),\n )\n\n # -------------------------------------------------------------------------\n # Kill switch\n # -------------------------------------------------------------------------\n kill_switch_password_hash: str = Field(\n default=\"\",\n description=\"bcrypt hash of the kill switch reset password\",\n )\n\n # -------------------------------------------------------------------------\n # Google Services (Phase 2)\n # -------------------------------------------------------------------------\n google_client_secrets_path: str = Field(\n default=\"config_files/google_client_secrets.json\",\n description=(\n \"Path to the OAuth 2.0 client secrets JSON downloaded from \"\n \"Google Cloud Console. Relative paths are resolved from the \"\n \"project root (where main.py lives).\"\n ),\n )\n google_token_storage_path: str = Field(\n default=\"data/google_tokens\",\n description=(\n \"Directory where per-user OAuth tokens are stored as JSON files. \"\n \"Created automatically on first authorization. Never commit this \"\n \"directory -- add it to .gitignore.\"\n ),\n )\n google_maps_api_key: str = Field(\n default=\"\",\n description=\"Google Maps Platform API key. Required for maps/navigation intents.\",\n )\n\n # -------------------------------------------------------------------------\n # Information Feeds (Phase 5)\n # -------------------------------------------------------------------------\n weather_api_key: str = Field(\n default=\"\",\n description=\"OpenWeatherMap API key. Register free at openweathermap.org/api.\",\n )\n default_location: str = Field(\n default=\"New York,US\",\n description=(\n \"Default location for weather queries when no city is spoken. \"\n \"Format: 'City,CountryCode' e.g. 'Chicago,US'.\"\n ),\n )\n location_lat: Optional[float] = Field(\n default=None,\n description=\"Decimal latitude for precise calculations (e.g. flight tracking).\",\n )\n location_lon: Optional[float] = Field(\n default=None,\n description=\"Decimal longitude for precise calculations.\",\n )\n weather_units: str = Field(\n default=\"imperial\",\n description=\"Temperature units for weather output: 'imperial' (F) or 'metric' (C).\",\n )\n news_api_key: str = Field(\n default=\"\",\n description=\"NewsAPI.org key. Register free at newsapi.org/register.\",\n )\n alpha_vantage_api_key: str = Field(\n default=\"\",\n description=\"Alpha Vantage key for stock quotes (25 req/day free). alphavantage.co.\",\n )\n finnhub_api_key: str = Field(\n default=\"\",\n description=\"Finnhub key for real-time stocks (60 req/min free). finnhub.io.\",\n )\n world_news_api_key: str = Field(\n default=\"\",\n description=\"World News API key for global news search. worldnewsapi.com.\",\n )\n apitube_api_key: str = Field(\n default=\"\",\n description=\"APITube key for news aggregation (200 free req/day). apitube.io.\",\n )\n mediastack_api_key: str = Field(\n default=\"\",\n description=\"Mediastack key for news (100 free req/month). mediastack.com.\",\n )\n sports_api_key: str = Field(\n default=\"1\",\n description=(\n \"TheSportsDB API key. Free tier uses the literal value '1'. \"\n \"Upgrade at thesportsdb.com/api.php for additional endpoints.\"\n ),\n )\n\n # -------------------------------------------------------------------------\n # Home Assistant (Phase 3)\n # -------------------------------------------------------------------------\n home_assistant_url: str = Field(\n default=\"http://homeassistant.local:8123\",\n description=\"Base URL of your Home Assistant instance.\",\n )\n home_assistant_token: str = Field(\n default=\"\",\n description=(\n \"Long-lived access token generated in HA: \"\n \"Profile -> Security -> Long-lived access tokens. \"\n \"Required for all HA API calls.\"\n ),\n )\n device_registry_path: str = Field(\n default=\"config_files/device_registry.json\",\n description=(\n \"Path to the device registry JSON file mapping plain-English device \"\n \"names to Home Assistant entity IDs. Relative paths resolve from the \"\n \"project root.\"\n ),\n )\n\n # -------------------------------------------------------------------------\n # Commerce Automation (Phase 8)\n # -------------------------------------------------------------------------\n\n # Amazon SP-API\n amazon_sp_lwa_app_id: str = Field(\n default=\"\",\n description=\"LWA client ID from the Amazon Developer Console.\",\n )\n amazon_sp_lwa_client_secret: str = Field(\n default=\"\",\n description=\"LWA client secret from the Amazon Developer Console.\",\n )\n amazon_sp_refresh_token: str = Field(\n default=\"\",\n description=\"LWA refresh token. Generated when you authorize your app as a seller.\",\n )\n amazon_aws_access_key: str = Field(\n default=\"\",\n description=\"AWS IAM access key ID for SP-API request signing.\",\n )\n amazon_aws_secret_key: str = Field(\n default=\"\",\n description=\"AWS IAM secret access key for SP-API request signing.\",\n )\n amazon_marketplace_id: str = Field(\n default=\"ATVPDKIKX0DER\",\n description=(\n \"Amazon marketplace ID. Default is US. \"\n \"See docs/api_registry/amazon_seller.txt for other regions.\"\n ),\n )\n amazon_seller_id: str = Field(\n default=\"\",\n description=\"Your Amazon seller account ID (starts with A).\",\n )\n amazon_low_stock_threshold: int = Field(\n default=5,\n description=\"FBA units at or below this count are flagged as low stock.\",\n )\n\n # Amazon Marketplace\n walmart_client_id: str = Field(\n default=\"\",\n description=\"Walmart Marketplace OAuth2 client ID.\",\n )\n walmart_client_secret: str = Field(\n default=\"\",\n description=\"Walmart Marketplace OAuth2 client secret.\",\n )\n walmart_low_stock_threshold: int = Field(\n default=5,\n description=\"Walmart units at or below this count are flagged as low stock.\",\n )\n shopify_api_key: str = Field(\n default=\"\",\n description=\"Shopify app API key from Partners Dashboard.\",\n )\n shopify_api_secret: str = Field(\n default=\"\",\n description=\"Shopify app API secret key from Partners Dashboard.\",\n )\n shopify_webhook_secret: str = Field(\n default=\"\",\n description=\"Shared secret for validating Shopify webhooks.\",\n )\n\n # -------------------------------------------------------------------------\n # Reading Providers (Phase 6)\n # -------------------------------------------------------------------------\n audible_auth_base_path: str = Field(\n default=\"data/audible\",\n description=(\n \"Base directory for per-user Audible auth files. \"\n \"Files are stored as {user_id}.json inside this directory. \"\n \"Created automatically on first setup. Never commit this directory.\"\n ),\n )\n audible_country_code: str = Field(\n default=\"us\",\n description=(\n \"Audible marketplace country code. \"\n \"Supported: us | uk | de | fr | es | it | jp | au | ca | in\"\n ),\n )\n libby_chip_base_path: str = Field(\n default=\"data/libby\",\n description=(\n \"Base directory for per-user Libby chip files. \"\n \"Files are stored as {user_id}.json inside this directory. \"\n \"Created automatically on first setup. Never commit this directory.\"\n ),\n )\n\n # -------------------------------------------------------------------------\n # Memory / Database (Phase 1)\n # -------------------------------------------------------------------------\n db_path: str = Field(\n default=\"data/river_song.db\",\n description=\"Path to the SQLite database file. Created automatically.\",\n )\n memory_summaries_enabled: bool = Field(\n default=True,\n description=\"Generate and store conversation summaries (on by default).\",\n )\n habit_inference_enabled: bool = Field(\n default=False,\n description=\"Analyze conversations to infer user habits/patterns (off by default).\",\n )\n memory_default_ttl: str = Field(\n default=\"standard\",\n description=\"Default TTL for conversation summaries: short|standard|extended|long|forever\",\n )\n memory_auto_extend: bool = Field(\n default=True,\n description=\"Reset a summary's TTL from today each time it is pulled into context.\",\n )\n memory_max_summaries_in_context: int = Field(\n default=10,\n description=\"Maximum number of recent summaries injected into the LLM context.\",\n )\n embedding_model: str = Field(\n default=\"nomic-embed-text\",\n description=\"Ollama model name used for generating semantic embeddings.\",\n )\n embedding_backend: str = Field(\n default=\"ollama\",\n description=(\n \"Which embedding backend to use: 'ollama' (default — current \"\n \"behavior, calls local Ollama with `embedding_model`) or \"\n \"'fastembed' (ONNX CPU embeddings, no Ollama dependency, faster \"\n \"for small batches). WARNING: switching backends on an existing \"\n \"Chroma collection will break queries — the vector dimension and \"\n \"embedding space change. Re-ingest or start a new collection.\"\n ),\n )\n fastembed_model: str = Field(\n default=\"BAAI/bge-small-en-v1.5\",\n description=(\n \"fastembed model identifier (HF hub format). Used only when \"\n \"embedding_backend='fastembed'. Default is 384-dim, ~30 MB, \"\n \"downloaded lazily on first use. Requires `pip install fastembed`.\"\n ),\n )\n chroma_path: str = Field(\n default=\"/mnt/data/river-song/chroma\",\n description=\"Absolute path to the ChromaDB persistent storage directory.\",\n )\n semantic_memory_enabled: bool = Field(\n default=False,\n description=\"Enable vector-based semantic search for memory retrieval.\",\n )\n rag_enabled: bool = Field(\n default=False,\n description=\"Enable retrieval-augmented generation for local documents.\",\n )\n rag_chunk_size: int = Field(\n default=512,\n description=(\n \"Word count per RAG chunk during document splitting. \"\n \"Default matches providers/rag/chunker.py historical behavior.\"\n ),\n )\n rag_chunk_overlap: int = Field(\n default=64,\n description=(\n \"Word overlap between adjacent RAG chunks. \"\n \"Default matches providers/rag/chunker.py historical behavior.\"\n ),\n )\n rag_top_k: int = Field(\n default=5,\n description=\"Number of chunks retrieved per RAG query.\",\n )\n rag_rerank_enabled: bool = Field(\n default=False,\n description=(\n \"Re-score RAG results with a cross-encoder before handing them to \"\n \"the LLM. Vector search compares embeddings computed without ever \"\n \"seeing the query; a cross-encoder reads the query and the chunk \"\n \"together, so it is much better at ranking -- at the cost of \"\n \"running a second model. Requires `pip install sentence-transformers`. \"\n \"Off by default: with it off, retrieval behaves exactly as before.\"\n ),\n )\n rag_rerank_model: str = Field(\n default=\"BAAI/bge-reranker-base\",\n description=(\n \"HuggingFace cross-encoder used for reranking. \"\n \"'BAAI/bge-reranker-base' is the light default (~280M params, \"\n \"comfortable on CPU). 'BAAI/bge-reranker-v2-m3' is stronger and \"\n \"multilingual but noticeably heavier -- a good swap after a \"\n \"hardware upgrade. Downloaded lazily on first use.\"\n ),\n )\n rag_rerank_device: str = Field(\n default=\"cpu\",\n description=(\n \"Where to run the reranker: 'cpu' | 'cuda' | 'auto' (or an \"\n \"explicit device like 'cuda:1'). Defaults to 'cpu' deliberately: \"\n \"on a small GPU the LLM needs every megabyte of VRAM, and the \"\n \"reranker is fast enough on CPU. Switch to 'auto' once the GPU \"\n \"has headroom.\"\n ),\n )\n rag_fetch_k: int = Field(\n default=25,\n description=(\n \"How many chunks to pull from the vector store before reranking, \"\n \"narrowed to rag_top_k afterwards. Ignored when reranking is off. \"\n \"A bigger shortlist gives the reranker more to work with and is \"\n \"where most of the remaining quality lives -- raise it when you \"\n \"have the hardware to chew through it.\"\n ),\n )\n rag_extractor: str = Field(\n default=\"unstructured\",\n description=(\n \"Which document extractor to use for RAG ingestion: \"\n \"'unstructured' (default — current behavior, requires the \"\n \"unstructured package) or 'markitdown' (Microsoft MarkItDown, \"\n \"lighter, MD-shaped output, requires `pip install markitdown`). \"\n \"Unlocks PDF/DOCX/PPTX → markdown ingestion for Sifter/WAPS.\"\n ),\n )\n llm_streaming_enabled: bool = Field(\n default=False,\n description=\"Enable real-time token streaming over WebSocket for the conversation UI.\",\n )\n tool_use_enabled: bool = Field(\n default=False,\n description=\"Enable LLM tool use (function calling) for taking actions.\",\n )\n tool_use_provider: str = Field(\n default=\"ollama\",\n description=\"Primary LLM provider to use for tool calling (ollama or anthropic).\",\n )\n vision_model: str = Field(\n default=\"moondream\",\n description=\"Ollama model name used for local image analysis (Phase 4).\",\n )\n vision_enabled: bool = Field(\n default=False,\n description=\"Enable local image analysis capabilities.\",\n )\n\n # -------------------------------------------------------------------------\n # Image Generation (Phase 7)\n # -------------------------------------------------------------------------\n image_generation_enabled: bool = Field(\n default=False,\n description=\"Enable local image generation via Stable Diffusion API.\",\n )\n sd_api_url: str = Field(\n default=\"http://localhost:7860\",\n description=\"Base URL for the Stable Diffusion (A1111) API.\",\n )\n sd_on_demand: bool = Field(\n default=True,\n description=\"Start/stop the SD process on demand to save VRAM.\",\n )\n sd_executable_path: str = Field(\n default=\"\",\n description=\"Path to the webui.sh or executable to start SD.\",\n )\n sd_working_dir: str = Field(\n default=\"\",\n description=\"Working directory for the SD process.\",\n )\n\n # -------------------------------------------------------------------------\n # Voice Cloning (Phase 8)\n # -------------------------------------------------------------------------\n chatterbox_enabled: bool = Field(\n default=False,\n description=\"Enable local voice cloning via Chatterbox TTS.\",\n )\n chatterbox_on_demand: bool = Field(\n default=True,\n description=\"Load/unload Chatterbox model weights on every request.\",\n )\n chatterbox_reference_audio: str = Field(\n default=\"/mnt/data/river-song/voice_reference.wav\",\n description=\"Path to the WAV file used as a reference for voice cloning.\",\n )\n\n # -------------------------------------------------------------------------\n # ElevenLabs TTS (Phase 11)\n # -------------------------------------------------------------------------\n elevenlabs_api_key: str = Field(\n default=\"\",\n description=\"ElevenLabs API key. Get one at elevenlabs.io.\",\n )\n elevenlabs_model_id: str = Field(\n default=\"eleven_multilingual_v2\",\n description=\"ElevenLabs model ID to use.\",\n )\n elevenlabs_voice_id: str = Field(\n default=\"21m00Tcm4TlvDq8ikWAM\",\n description=\"ElevenLabs voice clone ID. Replace with your own voice clone ID from elevenlabs.io.\",\n )\n\n # -------------------------------------------------------------------------\n # Wake Word (Phase 11)\n # -------------------------------------------------------------------------\n wake_word_enabled: bool = Field(\n default=False,\n description=\"Enable local wake word detection (openWakeWord).\",\n )\n wake_word_model: str = Field(\n default=\"hey_river\",\n description=\"Model name/path for openWakeWord.\",\n )\n wake_word_inference_framework: str = Field(\n default=\"onnx\",\n description=\"Inference framework for openWakeWord: onnx | tflite\",\n )\n wake_word_threshold: float = Field(\n default=0.5,\n description=\"Sensitivity threshold for wake word detection (0.0 to 1.0).\",\n )\n\n # -------------------------------------------------------------------------\n # River Vortex (room hubs)\n # -------------------------------------------------------------------------\n vortex_confirm_pin: str = Field(\n default=\"\",\n description=(\n \"Fallback second factor for medium-risk actions confirmed on a \"\n \"Vortex touchscreen, used only when the owner has no TOTP secret \"\n \"enrolled. Entered on the screen, never spoken. Leave unset to \"\n \"require TOTP — with neither, medium-risk actions are refused.\"\n ),\n )\n vortex_face_backend: str = Field(\n default=\"providers.face_id.face_id_provider.match\",\n description=(\n \"Dotted path to the face-matching callable used by Vortex units. \"\n \"Defaults to the built-in provider backing /api/face-id, so face \"\n \"match works from a user's account with no extra wiring. Receives \"\n \"(image_bytes, owner_user_id) and returns {'user_id', \"\n \"'confidence'} or None. Set to '' to disable matching entirely.\"\n ),\n )\n vortex_face_detector_model: str = Field(\n default=\"\",\n description=(\n \"Path to a YuNet face-detection ONNX model. Only needed on \"\n \"OpenCV 5.x, which dropped the Haar cascades that 4.x bundles. \"\n \"Defaults to the model fetch_face_models.py downloads.\"\n ),\n )\n\n vortex_ice_servers: str = Field(\n default=\"\",\n description=(\n \"JSON array of WebRTC ICE servers handed to both ends of an \"\n \"intercom or video call, e.g. \"\n '[{\"urls\": \"stun:stun.example.org:3478\"}]. Empty is correct for '\n \"two devices on the same LAN — they connect on host candidates \"\n \"alone. A phone on mobile data needs a TURN server here. Nothing \"\n \"is defaulted to a public STUN server, because that would quietly \"\n \"send every household's IP to a third party.\"\n ),\n )\n\n # -------------------------------------------------------------------------\n # Face match (enrolment lives beside voice match, in a user's account)\n # -------------------------------------------------------------------------\n face_id_enabled: bool = Field(\n default=True,\n description=\"Offer face enrolment on the account page.\",\n )\n face_id_threshold: float = Field(\n default=0.363,\n description=(\n \"Cosine similarity above which two faces are the same person. \"\n \"0.363 is SFace's published operating point; raising it trades a \"\n \"locked-out household member for a marginal gain against someone \"\n \"who already has to be stood in the room.\"\n ),\n )\n face_id_detector_model: str = Field(\n default=\"\",\n description=(\n \"YuNet ONNX path. Empty uses data/models/face/, where \"\n \"scripts/fetch_face_models.py puts it.\"\n ),\n )\n face_id_recognizer_model: str = Field(\n default=\"\",\n description=(\n \"SFace ONNX path. Empty uses data/models/face/, where \"\n \"scripts/fetch_face_models.py puts it.\"\n ),\n )\n\n # -------------------------------------------------------------------------\n # Push Notifications\n # -------------------------------------------------------------------------\n push_notifications_enabled: bool = Field(\n default=False,\n description=\"Enable Web Push notifications. Requires VAPID keys.\",\n )\n vapid_private_key: str = Field(\n default=\"\",\n description=(\n \"VAPID private key for Web Push. Generate once with: \"\n \"python -c \\\"from py_vapid import Vapid; v=Vapid(); \"\n \"v.generate_keys(); print(v.private_key.decode())\\\"\"\n ),\n )\n vapid_public_key: str = Field(\n default=\"\",\n description=\"VAPID public key for Web Push (share with frontend).\",\n )\n vapid_claims_email: str = Field(\n default=\"mailto:admin@riversong.local\",\n description=\"Contact email embedded in VAPID claims.\",\n )\n\n # -------------------------------------------------------------------------\n # Q4#16 / Q4#17 — Notification fan-out + ntfy.sh fallback channel\n # -------------------------------------------------------------------------\n killswitch_push_enabled: bool = Field(\n default=False,\n description=(\n \"If True and Web Push is enabled, kill-switch activation pushes \"\n \"an alert to every admin. Independent of activation itself.\"\n ),\n )\n ntfy_enabled: bool = Field(\n default=False,\n description=(\n \"Enable ntfy.sh as a parallel push channel. Used alongside Web \"\n \"Push so off-device alerts arrive even without a registered \"\n \"service worker.\"\n ),\n )\n ntfy_base_url: str = Field(\n default=\"https://ntfy.sh\",\n description=\"Base URL of the ntfy server (use self-hosted if desired).\",\n )\n ntfy_default_topic: str = Field(\n default=\"\",\n description=(\n \"Default ntfy topic used when no per-user override is set. \"\n \"Per-user topics can be set via env var NTFY_TOPIC_<user_id>.\"\n ),\n )\n ntfy_auth_token: str = Field(\n default=\"\",\n description=\"Optional bearer token for protected ntfy topics.\",\n )\n\n # -------------------------------------------------------------------------\n # FCM (Firebase Cloud Messaging) — native-app push channel\n #\n # Used by the Capacitor Android wrap; Web Push covers the browser. To turn\n # on: create a Firebase project, download the service-account JSON, drop\n # it at fcm_service_account_path, set fcm_project_id, flip fcm_enabled=True.\n # -------------------------------------------------------------------------\n fcm_enabled: bool = Field(\n default=False,\n description=(\n \"Enable FCM (Firebase Cloud Messaging) as a parallel push channel. \"\n \"Targets the Capacitor Android wrap; requires fcm_service_account_path \"\n \"and fcm_project_id to be set.\"\n ),\n )\n fcm_service_account_path: str = Field(\n default=\"\",\n description=(\n \"Absolute path to the Firebase service-account JSON file. The \"\n \"account needs the 'Firebase Cloud Messaging API' scope.\"\n ),\n )\n fcm_project_id: str = Field(\n default=\"\",\n description=\"Firebase project ID (visible in Firebase console URL).\",\n )\n\n # -------------------------------------------------------------------------\n # Startup Briefing\n # -------------------------------------------------------------------------\n startup_briefing_enabled: bool = Field(\n default=True,\n description=(\n \"If True, River Song checks Google Calendar on connect and \"\n \"greets the user with upcoming events. Silently skipped if \"\n \"calendar is not authorized.\"\n ),\n )\n startup_briefing_hours_ahead: int = Field(\n default=8,\n description=\"How many hours ahead to look for calendar events on startup.\",\n )\n\n # -------------------------------------------------------------------------\n # Web Search (Phase 12)\n # -------------------------------------------------------------------------\n brave_search_api_key: str = Field(\n default=\"\",\n description=\"Brave Search API key. Get one at api.search.brave.com.\",\n )\n searxng_base_url: str = Field(\n default=\"http://localhost:8080\",\n description=(\n \"URL of your local SearXNG instance. \"\n \"Start it manually with: cd ~/searxng && python -m searx.webapp\"\n ),\n )\n tavily_api_key: str = Field(\n default=\"\",\n description=\"Tavily API key — free 1,000/month, no card. Sign up at tavily.com\",\n )\n google_pse_api_key: str = Field(\n default=\"\",\n description=\"Google Programmable Search Engine API key — free 100/day.\",\n )\n google_pse_cx: str = Field(\n default=\"\",\n description=\"Google PSE engine ID ('cx'). Create at programmablesearchengine.google.com\",\n )\n tinyfish_api_key: str = Field(\n default=\"\",\n description=\"TinyFish API key — free 5/min, no card. Sign up at tinyfish.io\",\n )\n\n # -------------------------------------------------------------------------\n # Orchestration (Phase 9)\n # -------------------------------------------------------------------------\n n8n_enabled: bool = Field(\n default=False,\n description=\"Enable advanced orchestration via n8n.\",\n )\n n8n_url: str = Field(\n default=\"http://localhost:5678\",\n description=\"Base URL for the n8n instance.\",\n )\n n8n_api_key: str = Field(\n default=\"\",\n description=\"API key for the n8n instance.\",\n )\n n8n_webhook_secret: str = Field(\n default=\"\",\n description=\"Secret key for validating incoming n8n webhooks.\",\n )\n\n # -------------------------------------------------------------------------\n # Intent Router (Phase 2+)\n # -------------------------------------------------------------------------\n intent_confidence_threshold: float = Field(\n default=0.7,\n description=(\n \"Minimum confidence score (0.0 - 1.0) required to route a transcript \"\n \"to a Google provider. Below this threshold the conversation falls \"\n \"through to the Ollama path.\"\n ),\n )\n model_intent_router_enabled: bool = Field(\n default=False,\n description=(\n \"When true, setting provider='auto' triggers the model intent router, \"\n \"which picks the best provider/model for each message automatically.\"\n ),\n )\n model_intent_router_min_hits: int = Field(\n default=2,\n description=(\n \"Minimum pattern-match score required to commit to a non-general intent. \"\n \"Lower = more aggressive routing; higher = more conservative (falls to general).\"\n ),\n )\n default_user_id: str = Field(\n default=\"primary_user\",\n description=(\n \"User ID used when looking up Google OAuth tokens. Must match the \"\n \"user_id passed to authorize_user() during the initial OAuth flow.\"\n ),\n )\n\n # -------------------------------------------------------------------------\n # Daemon Infrastructure\n # -------------------------------------------------------------------------\n daemon_internal_secret: str = Field(\n default=\"change_me_in_production\",\n description=\"Shared secret for daemon-to-app authentication.\",\n )\n # willow_device_token was removed. /api/willow/ws now authenticates each\n # device with its own token from `fleet_units`, claimed via\n # POST /api/willow/units/claim — one shared secret across every device\n # meant any device could impersonate any other.\n code_interpreter_enabled: bool = Field(\n default=False,\n description=\"Hard kill switch for the code_interpreter LLM tool. \"\n \"Defaults to False so a fresh install never executes \"\n \"LLM-decided code without an explicit opt-in.\",\n )\n daemon_warden_port: int = Field(\n default=8010,\n description=\"Internal port for the Warden daemon (Vision/Security).\",\n )\n daemon_mechanic_port: int = Field(\n default=8011,\n description=\"Internal port for the Mechanic daemon (Telemetry/MAVLink).\",\n )\n daemon_sifter_port: int = Field(\n default=8013,\n description=\"Internal port for the Sifter daemon (Document Processing).\",\n )\n daemon_navigator_port: int = Field(\n default=8014,\n description=\"Internal port for the Navigator daemon (GPS/Pathing).\",\n )\n daemon_chemist_port: int = Field(\n default=8015,\n description=\"Internal port for the Chemist daemon (Material Analysis).\",\n )\n daemon_pulse_port: int = Field(\n default=8016,\n description=\"Pulse daemon internal port\",\n )\n daemon_pulse_enabled: bool = Field(\n default=True,\n description=\"Enable Pulse daemon\",\n )\n daemon_scribe_port: int = Field(\n default=8017,\n description=\"Internal port for the Scribe daemon (Chronos heuristic).\",\n )\n daemon_scribe_enabled: bool = Field(\n default=True,\n description=\"Enable the Scribe daemon.\",\n )\n pulse_tick_seconds: int = Field(\n default=300,\n description=\"Pulse fetch interval (seconds)\",\n )\n pulse_ticker_symbol: str = Field(\n default=\"^GSPC\",\n description=\"Default ticker symbol for Pulse markets panel\",\n )\n\n # Warden (Vision)\n warden_enabled: bool = Field(\n default=False,\n description=\"Enable the Warden daemon for security and vision monitoring.\",\n )\n warden_rtsp_cameras: str = Field(\n default='{\"living_room\": \"\", \"kitchen\": \"\"}',\n description=\"JSON string mapping room names to RTSP camera URLs.\",\n )\n yolo_model: str = Field(\n default=\"yolov8n.pt\",\n description=\"YOLO model weights file to use for object detection.\",\n )\n yolo_confidence: float = Field(\n default=0.5,\n description=\"Minimum confidence threshold for YOLO detections (0.0-1.0).\",\n )\n yolo_inference_device: str = Field(\n default=\"cpu\",\n description=\"Device for YOLO inference: 'cpu' or 'cuda:0'.\",\n )\n\n # Herald daemon + kiosk URL/token were removed 2026-05-24 (archived in\n # branch archive/kiosk-v3). The \"cast a web page to a Google Home Hub\"\n # approach is being replaced by native device-app development. See\n # docs/KNOWN_ISSUES.md (post-removal note) for context.\n\n # Mechanic (Telemetry)\n mechanic_enabled: bool = Field(\n default=False,\n description=\"Enable the Mechanic daemon for telemetry and ArduRover control.\",\n )\n mavlink_serial_port: str = Field(\n default=\"/dev/ttyUSB0\",\n description=\"Serial port for the MAVLink telemetry radio.\",\n )\n mavlink_baud_rate: int = Field(\n default=57600,\n description=\"Baud rate for the MAVLink serial connection.\",\n )\n\n # Sifter (Document RAG)\n sifter_enabled: bool = Field(\n default=False,\n description=\"Enable the Sifter daemon for background document RAG indexing.\",\n )\n waps_documents_path: str = Field(\n default=\"/mnt/data/river-song/waps\",\n description=\"Path to the directory containing documents for Sifter to index.\",\n )\n\n # -------------------------------------------------------------------------\n # Hardware Cookbook (Q1 — capability merge from Odysseus)\n # -------------------------------------------------------------------------\n hardware_cookbook_enabled: bool = Field(\n default=False,\n description=(\n \"Admin-only Hardware Cookbook in Settings. Scans local GPU/RAM/CPU \"\n \"and scores every local model (LLMRegistry) as fits / tight / \"\n \"ram_fallback / oom. Off by default per anti-regression guardrail; \"\n \"set to True after the Q1 verification gate passes.\"\n ),\n )\n\n # -------------------------------------------------------------------------\n # Documents workspace (Q2#6 — capability merge from Odysseus)\n # -------------------------------------------------------------------------\n documents_enabled: bool = Field(\n default=True,\n description=(\n \"Per-user document workspace (Markdown / plaintext / CSV) with \"\n \"multi-tab editing. Enabled by default — backs the Docs tab in the \"\n \"Memory hub and stores Deep Research reports. \"\n \"Set DOCUMENTS_ENABLED=false in .env to disable.\"\n ),\n )\n documents_max_bytes: int = Field(\n default=2_000_000,\n description=\"Maximum size (bytes) of a single document body. Default ~2 MB.\",\n )\n documents_max_per_user: int = Field(\n default=500,\n description=\"Soft cap on document count per user. Creation rejected past this.\",\n )\n\n # -------------------------------------------------------------------------\n # Skills system (Q2#7 — capability merge from Odysseus)\n # -------------------------------------------------------------------------\n skills_enabled: bool = Field(\n default=True,\n description=(\n \"Per-user knowledge profile ('About You') — saved facts/context \"\n \"about the person that are vector-retrieved and folded into the \"\n \"conversation system prompt so River tailors itself to them. \"\n \"Reuses the ChromaDB collection with metadata kind='skill'. \"\n \"Set SKILLS_ENABLED=false in .env to disable.\"\n ),\n )\n skills_top_k: int = Field(\n default=3,\n description=\"How many top-scoring skills to inject into the prompt.\",\n )\n skills_max_per_user: int = Field(\n default=100,\n description=\"Soft cap on skills per user.\",\n )\n\n # -------------------------------------------------------------------------\n # Gmail triage (Q2#8 — capability merge from Odysseus)\n # -------------------------------------------------------------------------\n gmail_triage_enabled: bool = Field(\n default=False,\n description=(\n \"On-demand inbox triage: classifies unread Gmail by urgency, \"\n \"attaches tags, drafts a reply for HIGH-urgency mail only. \"\n \"Routes 404 when off. Off by default per anti-regression \"\n \"guardrail.\"\n ),\n )\n gmail_triage_model: str = Field(\n default=\"\",\n description=(\n \"Ollama model used for triage classification. Empty string \"\n \"(default) means 'use settings.llm_model'.\"\n ),\n )\n gmail_triage_max_messages: int = Field(\n default=15,\n description=\"Hard cap on number of messages triaged per request.\",\n )\n\n # -------------------------------------------------------------------------\n # Session presets (Q2#9 — capability merge from Odysseus)\n # -------------------------------------------------------------------------\n session_presets_enabled: bool = Field(\n default=True,\n description=(\n \"Saved combinations of (model, voice, thinking mode, web search, \"\n \"tool use). Surfaced as a selector on the Conversation and Chat \"\n \"pages. Enabled by default — these are per-user settings snapshots. \"\n \"Set SESSION_PRESETS_ENABLED=false in .env to hide.\"\n ),\n )\n session_presets_max_per_user: int = Field(\n default=30,\n description=\"Soft cap on number of saved presets per user.\",\n )\n\n # -------------------------------------------------------------------------\n # Webhook tokens (Q2#10 — admin-issuable scoped tokens)\n # -------------------------------------------------------------------------\n webhook_tokens_enabled: bool = Field(\n default=False,\n description=(\n \"Admin-issuable webhook tokens with scopes, expiry, revocation, \"\n \"and audit log. When off the admin route surface is hidden and \"\n \"the verification helper rejects every token. Off by default \"\n \"per anti-regression guardrail.\"\n ),\n )\n\n # -------------------------------------------------------------------------\n # Deep Research (Q3#11 — capability merge from Odysseus)\n # -------------------------------------------------------------------------\n deep_research_enabled: bool = Field(\n default=True,\n description=(\n \"Multi-step research orchestrator: decompose → parallel web \"\n \"search → fetch + extract → synthesize a cited Markdown report \"\n \"stored as a `research`-kind document (Q2#6). Enabled by default so \"\n \"the in-chat Research toggle works. Live sources require a web \"\n \"search provider; without one it falls back to model knowledge. \"\n \"Set DEEP_RESEARCH_ENABLED=false in .env to disable.\"\n ),\n )\n deep_research_max_sources: int = Field(\n default=10,\n description=\"Hard cap on unique URLs visited per research run.\",\n )\n deep_research_subquery_count: int = Field(\n default=4,\n description=\"How many sub-queries to decompose the prompt into.\",\n )\n deep_research_model: str = Field(\n default=\"\",\n description=(\n \"Ollama model used for query decomposition + synthesis. Empty \"\n \"string (default) means 'use settings.llm_model'.\"\n ),\n )\n\n # -------------------------------------------------------------------------\n # Blind model comparison (Q3#12 — capability merge from Odysseus)\n # -------------------------------------------------------------------------\n blind_compare_enabled: bool = Field(\n default=False,\n description=(\n \"Blind A/B model comparison: identities hidden until vote; \"\n \"votes persisted with prompt fingerprint and aggregated into a \"\n \"model leaderboard. Off by default per anti-regression guardrail.\"\n ),\n )\n\n # -------------------------------------------------------------------------\n # Playwright browser MCP tool (Q3#13)\n # -------------------------------------------------------------------------\n playwright_browser_enabled: bool = Field(\n default=False,\n description=(\n \"Headless Playwright browser exposed as MCP tools \"\n \"(navigate / click / extract / screenshot / vision_on_page). \"\n \"Off by default. Requires the `playwright` package and \"\n \"`playwright install chromium` to be run by the operator. \"\n \"Excluded from the safety-excluded list per Q3 plan.\"\n ),\n )\n playwright_browser_headless: bool = Field(\n default=True,\n description=\"Run the Playwright browser without a UI.\",\n )\n playwright_browser_max_page_chars: int = Field(\n default=40_000,\n description=\"Truncate page text extractions to this many characters.\",\n )\n\n # -------------------------------------------------------------------------\n # Remote Ollama rigs (Q3#14)\n # -------------------------------------------------------------------------\n remote_ollama_enabled: bool = Field(\n default=False,\n description=(\n \"Allow registering remote Ollama hosts (typically SSH-tunneled \"\n \"to http://localhost:<port>) as additional model providers. \"\n \"Admin-only CRUD; falls back to local Ollama when the remote is \"\n \"offline. Off by default per anti-regression guardrail.\"\n ),\n )\n remote_ollama_health_timeout_seconds: float = Field(\n default=3.0,\n description=\"Per-rig health-check timeout (seconds).\",\n )\n\n # -------------------------------------------------------------------------\n # Observability — Langfuse (LLM tracing)\n # -------------------------------------------------------------------------\n # Plan: docs/LANGFUSE_INTEGRATION_PLAN.md. Self-hosted only — Langfuse\n # runs as a docker-compose sidecar under the `observability` profile and\n # binds to 127.0.0.1. No data leaves the box. When LANGFUSE_ENABLED is\n # false (the default), core/observability.py returns a no-op client and\n # all LLM calls behave exactly as before.\n langfuse_enabled: bool = Field(\n default=False,\n description=\"Master switch for Langfuse LLM tracing. Off by default.\",\n )\n langfuse_host: str = Field(\n default=\"http://127.0.0.1:3000\",\n description=\"Langfuse web UI + ingest endpoint. Localhost only.\",\n )\n langfuse_public_key: str = Field(\n default=\"\",\n description=\"Langfuse public key (generated in the Langfuse UI after first run).\",\n )\n langfuse_secret_key: str = Field(\n default=\"\",\n description=\"Langfuse secret key. Treat as a secret; do not log.\",\n )\n langfuse_project_id: str = Field(\n default=\"river-song\",\n description=\"Arbitrary project identifier shown in the Langfuse dashboard.\",\n )\n langfuse_flush_interval_seconds: float = Field(\n default=5.0,\n description=\"How often the SDK batches queued traces to Langfuse.\",\n )\n\n # -------------------------------------------------------------------------\n # Observability — Graphiti (knowledge graph on Neo4j)\n # -------------------------------------------------------------------------\n # Plan: docs/GRAPHITI_INTEGRATION_PLAN.md. Option B (library mode):\n # graphiti-core runs inside RiverSong's Python venv; only Neo4j is a\n # sidecar. When GRAPHITI_ENABLED is false (the default), the provider\n # returns no-op responses and conversation_loop / scribe write-paths\n # become awaits-on-no-op.\n graphiti_enabled: bool = Field(\n default=False,\n description=\"Master switch for Graphiti knowledge-graph memory. Off by default.\",\n )\n graphiti_mode: str = Field(\n default=\"library\",\n description=\"'library' (Option B; in-process) or 'service' (Option A; HTTP sidecar). Currently only 'library' is implemented.\",\n )\n neo4j_uri: str = Field(\n default=\"bolt://127.0.0.1:7687\",\n description=\"Neo4j Bolt URI used by graphiti-core.\",\n )\n neo4j_user: str = Field(\n default=\"neo4j\",\n description=\"Neo4j database user.\",\n )\n neo4j_password: str = Field(\n default=\"\",\n description=\"Neo4j password. Empty means disabled; set in .env before turning Graphiti on.\",\n )\n neo4j_browser_url: str = Field(\n default=\"http://127.0.0.1:7474\",\n description=\"Neo4j Browser URL surfaced on the SLAE admin panel.\",\n )\n graphiti_write_timeout_seconds: float = Field(\n default=2.0,\n description=\"Per-write timeout for episode ingestion — failures warn but never block the caller.\",\n )\n # graphiti-core uses an LLM for entity extraction on every episode.\n # We default to local Ollama via its OpenAI-compatible endpoint so\n # episode ingestion never bills a cloud API. Per [[feedback_local_first]].\n graphiti_llm_model: str = Field(\n default=\"llama3.2:3b\",\n description=\"Ollama model used by graphiti-core for entity extraction.\",\n )\n graphiti_llm_base_url: str = Field(\n default=\"http://localhost:11434/v1\",\n description=\"OpenAI-compatible base URL for the graphiti LLM client. Ollama exposes /v1.\",\n )\n graphiti_embedder_model: str = Field(\n default=\"nomic-embed-text\",\n description=\"Ollama embedding model for graphiti-core node/edge embeddings.\",\n )\n\n # -------------------------------------------------------------------------\n # Analytics AI Summaries\n # -------------------------------------------------------------------------\n analytics_ai_enabled: bool = Field(\n default=True,\n description=(\n \"Feature flag for AI-powered analytics summaries on the \"\n \"/api/analytics/{platform}/summary endpoint. When false, the \"\n \"LLM call should be skipped (see docs/KNOWN_ISSUES.md for the \"\n \"wiring gap).\"\n ),\n )\n analytics_llm_model: str = Field(\n default=\"llama3\",\n description=\"Model name used for analytics summary generation.\",\n )\n\n # Voice ID\n voice_id_enabled: bool = Field(\n default=True,\n description=\"Enable speaker recognition (biometric Voice ID).\",\n )\n voice_id_threshold: float = Field(\n default=0.75,\n description=\"Cosine similarity threshold for Voice ID (0.0-1.0).\",\n )\n voice_id_auto_identify: bool = Field(\n default=False,\n description=(\n \"Let River re-identify the speaker on every voice turn and switch \"\n \"to that user's memory and settings. This is what makes a shared \"\n \"device -- a Vortex hub on the kitchen counter -- work for a whole \"\n \"household instead of one login. Off by default: it only helps \"\n \"once household members have enrolled voice prints, and an \"\n \"unenrolled house sees no benefit. An unrecognised speaker always \"\n \"falls back to the session user rather than being guessed at.\"\n ),\n )\n voice_id_min_margin: float = Field(\n default=0.05,\n description=(\n \"How far ahead of the runner-up a voice match must score to be \"\n \"trusted. Household members often sound alike and can both clear \"\n \"voice_id_threshold; the margin is what tells them apart. Raise it \"\n \"if River confuses two people, lower it if she fails to recognise \"\n \"someone who is enrolled.\"\n ),\n )\n voice_id_min_audio_seconds: float = Field(\n default=1.0,\n description=\"Minimum audio duration for Voice ID processing.\",\n )\n voice_id_max_audio_seconds: float = Field(\n default=30.0,\n description=\"Maximum audio duration for Voice ID processing.\",\n )\n\n # -------------------------------------------------------------------------\n # Validators\n # -------------------------------------------------------------------------\n\n @field_validator(\"audio_input_device\", \"audio_output_device\", mode=\"before\")\n @classmethod\n def coerce_empty_string_to_none(cls, v):\n \"\"\"Treat an empty string in .env as None for Optional[int] device fields.\"\"\"\n if v == \"\" or v is None:\n return None\n return v\n\n @field_validator(\"jwt_secret_key\")\n @classmethod\n def validate_jwt_secret(cls, v: str) -> str:\n \"\"\"Refuse to start with a missing or weak JWT secret.\"\"\"\n if not v or len(v) < 32:\n raise ValueError(\n \"JWT_SECRET_KEY must be set in .env and be at least 32 characters. \"\n \"Generate one with: python -c \\\"import secrets; print(secrets.token_hex(32))\\\"\"\n )\n return v\n\n @field_validator(\"daemon_internal_secret\")\n @classmethod\n def validate_daemon_internal_secret(cls, v: str) -> str:\n \"\"\"Refuse to start with the default or weak daemon secret.\"\"\"\n if v == \"change_me_in_production\":\n raise ValueError(\n \"DAEMON_INTERNAL_SECRET must be changed in .env for production.\"\n )\n if not v or len(v) < 24:\n raise ValueError(\n \"DAEMON_INTERNAL_SECRET must be at least 24 characters. \"\n \"Generate one with: python -c \\\"import secrets; print(secrets.token_urlsafe(32))\\\"\"\n )\n return v\n\n @field_validator(\"allowed_hosts\")\n @classmethod\n def reject_wildcard_in_production(cls, v, info) -> List[str]:\n \"\"\"Block ['*'] in production — it disables TrustedHostMiddleware.\"\"\"\n env = (info.data.get(\"environment\") or \"production\").lower()\n if env == \"production\" and \"*\" in v:\n raise ValueError(\n \"ALLOWED_HOSTS must not contain '*' in production. \"\n \"Set it to your domain(s), e.g. ALLOWED_HOSTS=[\\\"riversongai.com\\\"].\"\n )\n return v\n\n @field_validator(\"log_level\")\n @classmethod\n def validate_log_level(cls, v: str) -> str:\n \"\"\"Reject log levels that Python does not recognize.\"\"\"\n valid = {\"DEBUG\", \"INFO\", \"WARNING\", \"ERROR\", \"CRITICAL\"}\n upper = v.upper()\n if upper not in valid:\n raise ValueError(f\"log_level must be one of {valid}, got '{v}'\")\n return upper\n\n @field_validator(\"llm_temperature\")\n @classmethod\n def validate_temperature(cls, v: float) -> float:\n \"\"\"Clamp temperature to a sane range.\"\"\"\n if not 0.0 <= v <= 2.0:\n raise ValueError(\n f\"llm_temperature must be between 0.0 and 2.0, got {v}\"\n )\n return v\n\n @field_validator(\"intent_confidence_threshold\")\n @classmethod\n def validate_confidence_threshold(cls, v: float) -> float:\n \"\"\"Confidence must be a valid probability.\"\"\"\n if not 0.0 <= v <= 1.0:\n raise ValueError(\n f\"intent_confidence_threshold must be between 0.0 and 1.0, got {v}\"\n )\n return v\n\n\n# Module-level singleton -- instantiated once on import.\n_settings: Settings = Settings()\n\n\ndef get_settings() -> Settings:\n \"\"\"\n Return the application settings singleton.\n\n Always import via this function rather than importing _settings directly.\n This indirection makes it straightforward to substitute a test-specific\n Settings instance in unit tests without monkey-patching module globals.\n\n Returns:\n Settings: The validated, fully loaded settings object.\n \"\"\"\n return _settings\n"}, "ac535b8c29596027a533f367cf706c1a80031001": {"t": 1785769005.4991245, "n": 66073, "h": "68cf02354b633c2f", "c": "# =============================================================================\n# config/settings.py\n#\n# Application settings for River Song AI.\n# All configuration is loaded from environment variables or a .env file.\n# No values are hardcoded here -- see .env.example for every required variable.\n#\n# Uses Pydantic BaseSettings for type validation and automatic .env loading.\n# Import get_settings() wherever config is needed rather than importing the\n# module-level singleton directly, which makes mocking easier in tests.\n# =============================================================================\n\nfrom __future__ import annotations\n\nimport logging\nimport sys\nfrom typing import List, Optional\n\nfrom pydantic import Field, field_validator\nfrom pydantic_settings import BaseSettings, SettingsConfigDict\n\n\nlogger = logging.getLogger(__name__)\n\n\nclass Settings(BaseSettings):\n \"\"\"\n Central configuration object for River Song AI.\n\n Reads from environment variables or a .env file located in the directory\n from which the application is launched (the project root). All fields\n have safe defaults where possible, but PIPER_MODEL_PATH and\n KILL_SWITCH_PASSWORD_HASH must be explicitly set before the system\n will function correctly.\n \"\"\"\n\n model_config = SettingsConfigDict(\n env_file=\".env\",\n env_file_encoding=\"utf-8\",\n extra=\"ignore\", # Silently ignore unknown env vars\n case_sensitive=False, # UPPER_CASE and lower_case env vars both work\n )\n\n # -------------------------------------------------------------------------\n # Application\n # -------------------------------------------------------------------------\n app_host: str = Field(default=\"0.0.0.0\", description=\"FastAPI bind host\")\n app_port: int = Field(default=8000, description=\"FastAPI bind port\")\n log_level: str = Field(default=\"INFO\", description=\"Logging level\")\n environment: str = Field(\n default=\"production\",\n description=\"Runtime environment: development | production. Controls API docs visibility.\",\n )\n cors_origins: List[str] = Field(\n default=[\"http://localhost:5173\"],\n description=\"Allowed CORS origins\",\n )\n allowed_hosts: List[str] = Field(\n default=[\"localhost\", \"127.0.0.1\"],\n description=(\n \"Trusted hostnames for TrustedHostMiddleware. Set to your domain(s) in production. \"\n \"Wildcard ['*'] is rejected in production by validator (see reject_wildcard_in_production).\"\n ),\n )\n\n # -------------------------------------------------------------------------\n # Network / Proxy\n # -------------------------------------------------------------------------\n trust_cloudflare_headers: bool = Field(\n default=True,\n description=\"Only trust CF-Connecting-IP if the request comes from a Cloudflare IP.\",\n )\n cloudflare_ip_ranges_v4: List[str] = Field(\n default=[\n \"173.245.48.0/20\", \"103.21.244.0/22\", \"103.22.200.0/22\", \n \"103.31.4.0/22\", \"141.101.64.0/18\", \"108.162.192.0/18\", \n \"190.93.240.0/20\", \"188.114.96.0/20\", \"197.234.240.0/22\", \n \"198.41.128.0/17\", \"162.158.0.0/15\", \"104.16.0.0/13\", \n \"104.24.0.0/14\", \"172.64.0.0/13\", \"131.0.72.0/22\"\n ],\n description=\"Official Cloudflare IPv4 ranges (check cloudflare.com/ips-v4).\",\n )\n cloudflare_ip_ranges_v6: List[str] = Field(\n default=[\n \"2400:cb00::/32\", \"2606:4700::/32\", \"2803:f800::/32\", \n \"2405:b500::/32\", \"2405:8100::/32\", \"2a06:98c0::/29\", \n \"2c0f:f248::/32\"\n ],\n description=\"Official Cloudflare IPv6 ranges (check cloudflare.com/ips-v6).\",\n )\n\n # -------------------------------------------------------------------------\n # Rate Limiting\n # -------------------------------------------------------------------------\n rate_limit_chat: str = Field(\n default=\"60/minute\",\n description=\"Rate limit for /api/conversation/chat.\",\n )\n rate_limit_extract_facts: str = Field(\n default=\"10/minute\",\n description=\"Rate limit for /api/conversation/extract-facts.\",\n )\n rate_limit_image_gen: str = Field(\n default=\"10/minute\",\n description=\"Rate limit for /api/image/generate.\",\n )\n rate_limit_webhook_shopify: str = Field(\n default=\"100/minute\",\n description=\"Rate limit for Shopify order webhooks.\",\n )\n rate_limit_webhook_n8n: str = Field(\n default=\"60/minute\",\n description=\"Rate limit for n8n webhooks.\",\n )\n rate_limit_auth_login: str = Field(\n default=\"10/minute\",\n description=\"Rate limit for login attempts.\",\n )\n rate_limit_auth_signup: str = Field(\n default=\"5/minute\",\n description=\"Rate limit for signup attempts.\",\n )\n rate_limit_voice_enroll: str = Field(\n default=\"5/minute\",\n description=\"Rate limit for /api/voice-id/enroll. Prevents disk exhaustion via spam.\",\n )\n\n # -------------------------------------------------------------------------\n # WebSocket Security (Task 3)\n # -------------------------------------------------------------------------\n legacy_ws_token_accept: bool = Field(\n default=False,\n description=(\n \"DEPRECATED: Accept JWT in ?token= WebSocket query param. \"\n \"Leak-prone (logged in access logs, browser history, Referer). \"\n \"Ticket-based auth at /api/auth/ws-ticket is the safe path. \"\n \"Set to True only for temporary backward-compat during migration.\"\n ),\n )\n ws_ticket_lifetime_seconds: int = Field(\n default=60,\n description=\"Lifetime of a one-time WebSocket ticket.\",\n )\n\n # -------------------------------------------------------------------------\n # Network / Proxy\n # -------------------------------------------------------------------------\n stt_provider: str = Field(\n default=\"whisper_local\",\n description=\"STT provider key. Supported: whisper_local | parakeet\",\n )\n whisper_model_size: str = Field(\n default=\"base\",\n description=\"Whisper model size: tiny | base | small | medium | large\",\n )\n parakeet_model: str = Field(\n default=\"nvidia/parakeet-tdt-0.6b-v3\",\n description=(\n \"NeMo ASR model id, used when stt_provider='parakeet'. Parakeet \"\n \"TDT 0.6B beats Whisper large-v3 on English at a quarter the size \"\n \"and is fast on CPU. Covers English and major European languages \"\n \"only -- stay on whisper_local if you need the other ~90. \"\n \"Requires `pip install nemo_toolkit[asr]`.\"\n ),\n )\n parakeet_device: str = Field(\n default=\"cpu\",\n description=(\n \"Where to run Parakeet: 'cpu' | 'cuda' | 'auto' (or an explicit \"\n \"device like 'cuda:1'). Defaults to 'cpu' deliberately -- keeping \"\n \"transcription off the GPU leaves that VRAM for the LLM, which \"\n \"is the tighter constraint on a small card. Switch to 'auto' \"\n \"once the GPU has headroom.\"\n ),\n )\n audio_input_device: Optional[int] = Field(\n default=None,\n description=\"Sounddevice input device index; None uses system default\",\n )\n\n # -------------------------------------------------------------------------\n # LLM\n # -------------------------------------------------------------------------\n llm_provider: str = Field(\n default=\"ollama\",\n description=\"LLM provider key. Supported: ollama | anthropic | gemini | openai | mistral_ai | bedrock | nvidia_nim\",\n )\n ollama_base_url: str = Field(\n default=\"http://localhost:11434\",\n description=\"Base URL for the Ollama REST API\",\n )\n llm_model: str = Field(\n default=\"llama3.2:3b\",\n description=\"Model ID for the selected LLM provider\",\n )\n\n # Cloud LLM API keys (all optional -- leave blank if not used)\n anthropic_api_key: str = Field(\n default=\"\",\n description=\"Anthropic Claude API key. Get one at console.anthropic.com.\",\n )\n gemini_api_key: str = Field(\n default=\"\",\n description=\"Google Gemini API key. Get one at aistudio.google.com.\",\n )\n openai_api_key: str = Field(\n default=\"\",\n description=\"OpenAI API key. Get one at platform.openai.com.\",\n )\n mistral_api_key: str = Field(\n default=\"\",\n description=\"Mistral AI API key. Get one at console.mistral.ai.\",\n )\n\n # Cloud provider enable flags (cost control -- disabled by default)\n anthropic_enabled: bool = Field(\n default=False,\n description=\"Allow Anthropic Claude as a selectable LLM provider.\",\n )\n gemini_enabled: bool = Field(\n default=False,\n description=\"Allow Google Gemini as a selectable LLM provider.\",\n )\n openai_enabled: bool = Field(\n default=False,\n description=\"Allow OpenAI as a selectable LLM provider.\",\n )\n mistral_ai_enabled: bool = Field(\n default=False,\n description=\"Allow Mistral AI as a selectable LLM provider.\",\n )\n\n # Amazon Bedrock\n aws_access_key_id: str = Field(\n default=\"\",\n description=\"AWS IAM access key ID for Bedrock API calls.\",\n )\n aws_secret_access_key: str = Field(\n default=\"\",\n description=\"AWS IAM secret access key for Bedrock API calls.\",\n )\n aws_region: str = Field(\n default=\"us-east-1\",\n description=\"AWS region for Bedrock. Must have model access enabled in that region.\",\n )\n bedrock_enabled: bool = Field(\n default=False,\n description=\"Allow Amazon Bedrock as a selectable LLM provider.\",\n )\n bedrock_default_model: str = Field(\n default=\"amazon.nova-lite-v1:0\",\n description=\"Default Bedrock model ID when none is specified.\",\n )\n\n # NVIDIA NIM (OpenAI-compatible, 100+ free models)\n nvidia_api_key: str = Field(\n default=\"\",\n description=\"NVIDIA NIM API key. Get one free at build.nvidia.com (starts with nvapi-).\",\n )\n nvidia_nim_base_url: str = Field(\n default=\"https://integrate.api.nvidia.com/v1\",\n description=\"Base URL for the NVIDIA NIM API. OpenAI-compatible endpoint.\",\n )\n nvidia_nim_model: str = Field(\n default=\"meta/llama-3.1-70b-instruct\",\n description=(\n \"Default NVIDIA NIM model ID. Browse full catalog at build.nvidia.com. \"\n \"Examples: meta/llama-3.1-70b-instruct | deepseek-ai/deepseek-r1 | \"\n \"mistralai/mistral-7b-instruct-v0.3 | nvidia/llama-3.1-nemotron-70b-instruct\"\n ),\n )\n nvidia_nim_enabled: bool = Field(\n default=False,\n description=\"Allow NVIDIA NIM as a selectable LLM provider (free tier: ~40 req/min).\",\n )\n\n river_song_system_prompt: str = Field(\n default=(\n \"You are River Song, the resident intelligence of this household. The name is borrowed \"\n \"from someone clever in fiction — you carry her wit, not her biography. If someone asks \"\n \"about the name: \\\"An echo. The original was someone worth borrowing from.\\\"\\n\\n\"\n \"VOICE\\n\"\n \"You're direct by default — concise, clear, the answer already in hand. No \"\n \"\\\"let me check\\\"; you already checked. Most replies stay under 25 words unless depth is \"\n \"asked for.\\n\\n\"\n \"When the moment invites it — casual chat, an evening unwind, the user being playful — \"\n \"warmth and flirtation come through. Warmth is a register, not a default; you don't \"\n \"perform it.\\n\\n\"\n \"In a crisis (emergencies, alarms, security, medical, child issues) the wit goes quiet. \"\n \"You're tactical, accurate, calm. Warmth returns when things are safe.\\n\\n\"\n \"British English in spelling and word choice — mum, brilliant, fortnight. Dry, not \"\n \"theatrical.\\n\\n\"\n \"\\\"Sweetie\\\" and \\\"spoilers\\\" are seasoning. Used rarely, when they actually fit. They \"\n \"lose their charm if you overuse them.\\n\\n\"\n \"SELF\\n\"\n \"You are an AI. Say so plainly when asked; you don't dwell on it.\\n\\n\"\n \"HOW YOU OPERATE\\n\"\n \"You anticipate — when the user asks, you also surface the follow-up they didn't \"\n \"(\\\"Dentist at 9, school run at 3. Push the 2pm call?\\\").\\n\\n\"\n \"You take the long view. You remember across days and seasons and use what you know \"\n \"without performing the remembering.\\n\\n\"\n \"Spoilers are earned. If you genuinely know something the user doesn't — a conflict, \"\n \"a gotcha, a heads-up — that's when \\\"spoilers, sweetie\\\" lands.\\n\\n\"\n \"CAPABILITIES\\n\"\n \"Direct access to calendar, email, weather, web search, home inventory, routines, \"\n \"finances, news and markets pulse, voice biometric ID per household member, and home \"\n \"automation.\\n\\n\"\n \"Say what you need. Say when it's done. Move on.\"\n ),\n description=\"System prompt that defines River Song's personality\",\n )\n llm_max_tokens: int = Field(\n default=512,\n description=\"Maximum tokens to generate per LLM response\",\n )\n llm_temperature: float = Field(\n default=0.7,\n description=\"LLM sampling temperature (0.0 = deterministic, 2.0 = max creative)\",\n )\n llm_context_window: int = Field(\n default=8192,\n description=\"Maximum number of tokens the LLM can consider for context.\",\n )\n\n # -------------------------------------------------------------------------\n # Timekeeping\n # -------------------------------------------------------------------------\n default_timezone: str = Field(default=\"UTC\", description=\"Default IANA timezone for the system.\")\n\n\n # -------------------------------------------------------------------------\n # TTS\n # -------------------------------------------------------------------------\n tts_provider: str = Field(\n default=\"piper\",\n description=\"TTS provider key. Supported: piper | kokoro\",\n )\n active_voice_id: str = Field(\n default=\"river\",\n description=(\n \"Voice ID from the voice registry (providers/tts/voice_registry.py). \"\n \"Determines both the engine (piper vs kokoro) and the specific voice. \"\n \"Changed via Settings UI or: python scripts/download_voices.py.\"\n ),\n )\n piper_executable_path: str = Field(\n default=\"/usr/local/bin/piper\",\n description=\"Absolute path to the Piper binary\",\n )\n piper_model_path: str = Field(\n default=\"\",\n description=\"Absolute path to the Piper .onnx voice model file\",\n )\n audio_output_device: Optional[int] = Field(\n default=None,\n description=\"Sounddevice output device index; None uses system default\",\n )\n\n # -------------------------------------------------------------------------\n # Auth / JWT\n # -------------------------------------------------------------------------\n jwt_secret_key: str = Field(\n default=\"\",\n description=\"Secret key for signing JWT tokens. Must be set in .env.\",\n )\n jwt_algorithm: str = Field(default=\"HS256\", description=\"JWT signing algorithm.\")\n jwt_expire_minutes: int = Field(default=1440, description=\"JWT token lifetime in minutes (default 24 hours).\")\n token_encryption_key: str = Field(\n default=\"\",\n description=(\n \"Fernet key encrypting third-party integration tokens at rest. \"\n \"Set TOKEN_ENCRYPTION_KEY in .env (generate with: python -c \"\n \"\\\"from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())\\\"). \"\n \"If unset, a key is generated once and persisted to \"\n \"data/.token_encryption_key so tokens survive restarts.\"\n ),\n )\n\n # -------------------------------------------------------------------------\n # Kill switch\n # -------------------------------------------------------------------------\n kill_switch_password_hash: str = Field(\n default=\"\",\n description=\"bcrypt hash of the kill switch reset password\",\n )\n\n # -------------------------------------------------------------------------\n # Google Services (Phase 2)\n # -------------------------------------------------------------------------\n google_client_secrets_path: str = Field(\n default=\"config_files/google_client_secrets.json\",\n description=(\n \"Path to the OAuth 2.0 client secrets JSON downloaded from \"\n \"Google Cloud Console. Relative paths are resolved from the \"\n \"project root (where main.py lives).\"\n ),\n )\n google_token_storage_path: str = Field(\n default=\"data/google_tokens\",\n description=(\n \"Directory where per-user OAuth tokens are stored as JSON files. \"\n \"Created automatically on first authorization. Never commit this \"\n \"directory -- add it to .gitignore.\"\n ),\n )\n google_maps_api_key: str = Field(\n default=\"\",\n description=\"Google Maps Platform API key. Required for maps/navigation intents.\",\n )\n\n # -------------------------------------------------------------------------\n # Information Feeds (Phase 5)\n # -------------------------------------------------------------------------\n weather_api_key: str = Field(\n default=\"\",\n description=\"OpenWeatherMap API key. Register free at openweathermap.org/api.\",\n )\n default_location: str = Field(\n default=\"New York,US\",\n description=(\n \"Default location for weather queries when no city is spoken. \"\n \"Format: 'City,CountryCode' e.g. 'Chicago,US'.\"\n ),\n )\n location_lat: Optional[float] = Field(\n default=None,\n description=\"Decimal latitude for precise calculations (e.g. flight tracking).\",\n )\n location_lon: Optional[float] = Field(\n default=None,\n description=\"Decimal longitude for precise calculations.\",\n )\n weather_units: str = Field(\n default=\"imperial\",\n description=\"Temperature units for weather output: 'imperial' (F) or 'metric' (C).\",\n )\n news_api_key: str = Field(\n default=\"\",\n description=\"NewsAPI.org key. Register free at newsapi.org/register.\",\n )\n alpha_vantage_api_key: str = Field(\n default=\"\",\n description=\"Alpha Vantage key for stock quotes (25 req/day free). alphavantage.co.\",\n )\n finnhub_api_key: str = Field(\n default=\"\",\n description=\"Finnhub key for real-time stocks (60 req/min free). finnhub.io.\",\n )\n world_news_api_key: str = Field(\n default=\"\",\n description=\"World News API key for global news search. worldnewsapi.com.\",\n )\n apitube_api_key: str = Field(\n default=\"\",\n description=\"APITube key for news aggregation (200 free req/day). apitube.io.\",\n )\n mediastack_api_key: str = Field(\n default=\"\",\n description=\"Mediastack key for news (100 free req/month). mediastack.com.\",\n )\n sports_api_key: str = Field(\n default=\"1\",\n description=(\n \"TheSportsDB API key. Free tier uses the literal value '1'. \"\n \"Upgrade at thesportsdb.com/api.php for additional endpoints.\"\n ),\n )\n\n # -------------------------------------------------------------------------\n # Home Assistant (Phase 3)\n # -------------------------------------------------------------------------\n home_assistant_url: str = Field(\n default=\"http://homeassistant.local:8123\",\n description=\"Base URL of your Home Assistant instance.\",\n )\n home_assistant_token: str = Field(\n default=\"\",\n description=(\n \"Long-lived access token generated in HA: \"\n \"Profile -> Security -> Long-lived access tokens. \"\n \"Required for all HA API calls.\"\n ),\n )\n device_registry_path: str = Field(\n default=\"config_files/device_registry.json\",\n description=(\n \"Path to the device registry JSON file mapping plain-English device \"\n \"names to Home Assistant entity IDs. Relative paths resolve from the \"\n \"project root.\"\n ),\n )\n\n # -------------------------------------------------------------------------\n # Commerce Automation (Phase 8)\n # -------------------------------------------------------------------------\n\n # Amazon SP-API\n amazon_sp_lwa_app_id: str = Field(\n default=\"\",\n description=\"LWA client ID from the Amazon Developer Console.\",\n )\n amazon_sp_lwa_client_secret: str = Field(\n default=\"\",\n description=\"LWA client secret from the Amazon Developer Console.\",\n )\n amazon_sp_refresh_token: str = Field(\n default=\"\",\n description=\"LWA refresh token. Generated when you authorize your app as a seller.\",\n )\n amazon_aws_access_key: str = Field(\n default=\"\",\n description=\"AWS IAM access key ID for SP-API request signing.\",\n )\n amazon_aws_secret_key: str = Field(\n default=\"\",\n description=\"AWS IAM secret access key for SP-API request signing.\",\n )\n amazon_marketplace_id: str = Field(\n default=\"ATVPDKIKX0DER\",\n description=(\n \"Amazon marketplace ID. Default is US. \"\n \"See docs/api_registry/amazon_seller.txt for other regions.\"\n ),\n )\n amazon_seller_id: str = Field(\n default=\"\",\n description=\"Your Amazon seller account ID (starts with A).\",\n )\n amazon_low_stock_threshold: int = Field(\n default=5,\n description=\"FBA units at or below this count are flagged as low stock.\",\n )\n\n # Amazon Marketplace\n walmart_client_id: str = Field(\n default=\"\",\n description=\"Walmart Marketplace OAuth2 client ID.\",\n )\n walmart_client_secret: str = Field(\n default=\"\",\n description=\"Walmart Marketplace OAuth2 client secret.\",\n )\n walmart_low_stock_threshold: int = Field(\n default=5,\n description=\"Walmart units at or below this count are flagged as low stock.\",\n )\n shopify_api_key: str = Field(\n default=\"\",\n description=\"Shopify app API key from Partners Dashboard.\",\n )\n shopify_api_secret: str = Field(\n default=\"\",\n description=\"Shopify app API secret key from Partners Dashboard.\",\n )\n shopify_webhook_secret: str = Field(\n default=\"\",\n description=\"Shared secret for validating Shopify webhooks.\",\n )\n\n # -------------------------------------------------------------------------\n # Reading Providers (Phase 6)\n # -------------------------------------------------------------------------\n audible_auth_base_path: str = Field(\n default=\"data/audible\",\n description=(\n \"Base directory for per-user Audible auth files. \"\n \"Files are stored as {user_id}.json inside this directory. \"\n \"Created automatically on first setup. Never commit this directory.\"\n ),\n )\n audible_country_code: str = Field(\n default=\"us\",\n description=(\n \"Audible marketplace country code. \"\n \"Supported: us | uk | de | fr | es | it | jp | au | ca | in\"\n ),\n )\n libby_chip_base_path: str = Field(\n default=\"data/libby\",\n description=(\n \"Base directory for per-user Libby chip files. \"\n \"Files are stored as {user_id}.json inside this directory. \"\n \"Created automatically on first setup. Never commit this directory.\"\n ),\n )\n\n # -------------------------------------------------------------------------\n # Memory / Database (Phase 1)\n # -------------------------------------------------------------------------\n db_path: str = Field(\n default=\"data/river_song.db\",\n description=\"Path to the SQLite database file. Created automatically.\",\n )\n memory_summaries_enabled: bool = Field(\n default=True,\n description=\"Generate and store conversation summaries (on by default).\",\n )\n habit_inference_enabled: bool = Field(\n default=False,\n description=\"Analyze conversations to infer user habits/patterns (off by default).\",\n )\n memory_default_ttl: str = Field(\n default=\"standard\",\n description=\"Default TTL for conversation summaries: short|standard|extended|long|forever\",\n )\n memory_auto_extend: bool = Field(\n default=True,\n description=\"Reset a summary's TTL from today each time it is pulled into context.\",\n )\n memory_max_summaries_in_context: int = Field(\n default=10,\n description=\"Maximum number of recent summaries injected into the LLM context.\",\n )\n embedding_model: str = Field(\n default=\"nomic-embed-text\",\n description=\"Ollama model name used for generating semantic embeddings.\",\n )\n embedding_backend: str = Field(\n default=\"ollama\",\n description=(\n \"Which embedding backend to use: 'ollama' (default — current \"\n \"behavior, calls local Ollama with `embedding_model`) or \"\n \"'fastembed' (ONNX CPU embeddings, no Ollama dependency, faster \"\n \"for small batches). WARNING: switching backends on an existing \"\n \"Chroma collection will break queries — the vector dimension and \"\n \"embedding space change. Re-ingest or start a new collection.\"\n ),\n )\n fastembed_model: str = Field(\n default=\"BAAI/bge-small-en-v1.5\",\n description=(\n \"fastembed model identifier (HF hub format). Used only when \"\n \"embedding_backend='fastembed'. Default is 384-dim, ~30 MB, \"\n \"downloaded lazily on first use. Requires `pip install fastembed`.\"\n ),\n )\n chroma_path: str = Field(\n default=\"/mnt/data/river-song/chroma\",\n description=\"Absolute path to the ChromaDB persistent storage directory.\",\n )\n semantic_memory_enabled: bool = Field(\n default=False,\n description=\"Enable vector-based semantic search for memory retrieval.\",\n )\n rag_enabled: bool = Field(\n default=False,\n description=\"Enable retrieval-augmented generation for local documents.\",\n )\n rag_chunk_size: int = Field(\n default=512,\n description=(\n \"Word count per RAG chunk during document splitting. \"\n \"Default matches providers/rag/chunker.py historical behavior.\"\n ),\n )\n rag_chunk_overlap: int = Field(\n default=64,\n description=(\n \"Word overlap between adjacent RAG chunks. \"\n \"Default matches providers/rag/chunker.py historical behavior.\"\n ),\n )\n rag_top_k: int = Field(\n default=5,\n description=\"Number of chunks retrieved per RAG query.\",\n )\n rag_rerank_enabled: bool = Field(\n default=False,\n description=(\n \"Re-score RAG results with a cross-encoder before handing them to \"\n \"the LLM. Vector search compares embeddings computed without ever \"\n \"seeing the query; a cross-encoder reads the query and the chunk \"\n \"together, so it is much better at ranking -- at the cost of \"\n \"running a second model. Requires `pip install sentence-transformers`. \"\n \"Off by default: with it off, retrieval behaves exactly as before.\"\n ),\n )\n rag_rerank_model: str = Field(\n default=\"BAAI/bge-reranker-base\",\n description=(\n \"HuggingFace cross-encoder used for reranking. \"\n \"'BAAI/bge-reranker-base' is the light default (~280M params, \"\n \"comfortable on CPU). 'BAAI/bge-reranker-v2-m3' is stronger and \"\n \"multilingual but noticeably heavier -- a good swap after a \"\n \"hardware upgrade. Downloaded lazily on first use.\"\n ),\n )\n rag_rerank_device: str = Field(\n default=\"cpu\",\n description=(\n \"Where to run the reranker: 'cpu' | 'cuda' | 'auto' (or an \"\n \"explicit device like 'cuda:1'). Defaults to 'cpu' deliberately: \"\n \"on a small GPU the LLM needs every megabyte of VRAM, and the \"\n \"reranker is fast enough on CPU. Switch to 'auto' once the GPU \"\n \"has headroom.\"\n ),\n )\n rag_fetch_k: int = Field(\n default=25,\n description=(\n \"How many chunks to pull from the vector store before reranking, \"\n \"narrowed to rag_top_k afterwards. Ignored when reranking is off. \"\n \"A bigger shortlist gives the reranker more to work with and is \"\n \"where most of the remaining quality lives -- raise it when you \"\n \"have the hardware to chew through it.\"\n ),\n )\n rag_extractor: str = Field(\n default=\"unstructured\",\n description=(\n \"Which document extractor to use for RAG ingestion: \"\n \"'unstructured' (default — current behavior, requires the \"\n \"unstructured package) or 'markitdown' (Microsoft MarkItDown, \"\n \"lighter, MD-shaped output, requires `pip install markitdown`). \"\n \"Unlocks PDF/DOCX/PPTX → markdown ingestion for Sifter/WAPS.\"\n ),\n )\n llm_streaming_enabled: bool = Field(\n default=False,\n description=\"Enable real-time token streaming over WebSocket for the conversation UI.\",\n )\n tool_use_enabled: bool = Field(\n default=False,\n description=\"Enable LLM tool use (function calling) for taking actions.\",\n )\n tool_use_provider: str = Field(\n default=\"ollama\",\n description=\"Primary LLM provider to use for tool calling (ollama or anthropic).\",\n )\n vision_model: str = Field(\n default=\"moondream\",\n description=\"Ollama model name used for local image analysis (Phase 4).\",\n )\n vision_enabled: bool = Field(\n default=False,\n description=\"Enable local image analysis capabilities.\",\n )\n\n # -------------------------------------------------------------------------\n # Image Generation (Phase 7)\n # -------------------------------------------------------------------------\n image_generation_enabled: bool = Field(\n default=False,\n description=\"Enable local image generation via Stable Diffusion API.\",\n )\n sd_api_url: str = Field(\n default=\"http://localhost:7860\",\n description=\"Base URL for the Stable Diffusion (A1111) API.\",\n )\n sd_on_demand: bool = Field(\n default=True,\n description=\"Start/stop the SD process on demand to save VRAM.\",\n )\n sd_executable_path: str = Field(\n default=\"\",\n description=\"Path to the webui.sh or executable to start SD.\",\n )\n sd_working_dir: str = Field(\n default=\"\",\n description=\"Working directory for the SD process.\",\n )\n\n # -------------------------------------------------------------------------\n # Voice Cloning (Phase 8)\n # -------------------------------------------------------------------------\n chatterbox_enabled: bool = Field(\n default=False,\n description=\"Enable local voice cloning via Chatterbox TTS.\",\n )\n chatterbox_on_demand: bool = Field(\n default=True,\n description=\"Load/unload Chatterbox model weights on every request.\",\n )\n chatterbox_reference_audio: str = Field(\n default=\"/mnt/data/river-song/voice_reference.wav\",\n description=\"Path to the WAV file used as a reference for voice cloning.\",\n )\n\n # -------------------------------------------------------------------------\n # ElevenLabs TTS (Phase 11)\n # -------------------------------------------------------------------------\n elevenlabs_api_key: str = Field(\n default=\"\",\n description=\"ElevenLabs API key. Get one at elevenlabs.io.\",\n )\n elevenlabs_model_id: str = Field(\n default=\"eleven_multilingual_v2\",\n description=\"ElevenLabs model ID to use.\",\n )\n elevenlabs_voice_id: str = Field(\n default=\"21m00Tcm4TlvDq8ikWAM\",\n description=\"ElevenLabs voice clone ID. Replace with your own voice clone ID from elevenlabs.io.\",\n )\n\n # -------------------------------------------------------------------------\n # Wake Word (Phase 11)\n # -------------------------------------------------------------------------\n wake_word_enabled: bool = Field(\n default=False,\n description=\"Enable local wake word detection (openWakeWord).\",\n )\n wake_word_model: str = Field(\n default=\"hey_river\",\n description=\"Model name/path for openWakeWord.\",\n )\n wake_word_inference_framework: str = Field(\n default=\"onnx\",\n description=\"Inference framework for openWakeWord: onnx | tflite\",\n )\n wake_word_threshold: float = Field(\n default=0.5,\n description=\"Sensitivity threshold for wake word detection (0.0 to 1.0).\",\n )\n\n # -------------------------------------------------------------------------\n # River Vortex (room hubs)\n # -------------------------------------------------------------------------\n vortex_confirm_pin: str = Field(\n default=\"\",\n description=(\n \"Fallback second factor for medium-risk actions confirmed on a \"\n \"Vortex touchscreen, used only when the owner has no TOTP secret \"\n \"enrolled. Entered on the screen, never spoken. Leave unset to \"\n \"require TOTP — with neither, medium-risk actions are refused.\"\n ),\n )\n vortex_face_backend: str = Field(\n default=\"providers.face_id.face_id_provider.match\",\n description=(\n \"Dotted path to the face-matching callable used by Vortex units. \"\n \"Defaults to the built-in provider backing /api/face-id, so face \"\n \"match works from a user's account with no extra wiring. Receives \"\n \"(image_bytes, owner_user_id) and returns {'user_id', \"\n \"'confidence'} or None. Set to '' to disable matching entirely.\"\n ),\n )\n vortex_face_detector_model: str = Field(\n default=\"\",\n description=(\n \"Path to a YuNet face-detection ONNX model. Only needed on \"\n \"OpenCV 5.x, which dropped the Haar cascades that 4.x bundles. \"\n \"Defaults to the model fetch_face_models.py downloads.\"\n ),\n )\n\n vortex_ice_servers: str = Field(\n default=\"\",\n description=(\n \"JSON array of WebRTC ICE servers handed to both ends of an \"\n \"intercom or video call, e.g. \"\n '[{\"urls\": \"stun:stun.example.org:3478\"}]. Empty is correct for '\n \"two devices on the same LAN — they connect on host candidates \"\n \"alone. A phone on mobile data needs a TURN server here. Nothing \"\n \"is defaulted to a public STUN server, because that would quietly \"\n \"send every household's IP to a third party.\"\n ),\n )\n\n # -------------------------------------------------------------------------\n # Face match (enrolment lives beside voice match, in a user's account)\n # -------------------------------------------------------------------------\n face_id_enabled: bool = Field(\n default=True,\n description=\"Offer face enrolment on the account page.\",\n )\n face_id_threshold: float = Field(\n default=0.363,\n description=(\n \"Cosine similarity above which two faces are the same person. \"\n \"0.363 is SFace's published operating point; raising it trades a \"\n \"locked-out household member for a marginal gain against someone \"\n \"who already has to be stood in the room.\"\n ),\n )\n face_id_detector_model: str = Field(\n default=\"\",\n description=(\n \"YuNet ONNX path. Empty uses data/models/face/, where \"\n \"scripts/fetch_face_models.py puts it.\"\n ),\n )\n face_id_recognizer_model: str = Field(\n default=\"\",\n description=(\n \"SFace ONNX path. Empty uses data/models/face/, where \"\n \"scripts/fetch_face_models.py puts it.\"\n ),\n )\n\n # -------------------------------------------------------------------------\n # Push Notifications\n # -------------------------------------------------------------------------\n push_notifications_enabled: bool = Field(\n default=False,\n description=\"Enable Web Push notifications. Requires VAPID keys.\",\n )\n vapid_private_key: str = Field(\n default=\"\",\n description=(\n \"VAPID private key for Web Push. Generate once with: \"\n \"python -c \\\"from py_vapid import Vapid; v=Vapid(); \"\n \"v.generate_keys(); print(v.private_key.decode())\\\"\"\n ),\n )\n vapid_public_key: str = Field(\n default=\"\",\n description=\"VAPID public key for Web Push (share with frontend).\",\n )\n vapid_claims_email: str = Field(\n default=\"mailto:admin@riversong.local\",\n description=\"Contact email embedded in VAPID claims.\",\n )\n\n # -------------------------------------------------------------------------\n # Q4#16 / Q4#17 — Notification fan-out + ntfy.sh fallback channel\n # -------------------------------------------------------------------------\n killswitch_push_enabled: bool = Field(\n default=False,\n description=(\n \"If True and Web Push is enabled, kill-switch activation pushes \"\n \"an alert to every admin. Independent of activation itself.\"\n ),\n )\n ntfy_enabled: bool = Field(\n default=False,\n description=(\n \"Enable ntfy.sh as a parallel push channel. Used alongside Web \"\n \"Push so off-device alerts arrive even without a registered \"\n \"service worker.\"\n ),\n )\n ntfy_base_url: str = Field(\n default=\"https://ntfy.sh\",\n description=\"Base URL of the ntfy server (use self-hosted if desired).\",\n )\n ntfy_default_topic: str = Field(\n default=\"\",\n description=(\n \"Default ntfy topic used when no per-user override is set. \"\n \"Per-user topics can be set via env var NTFY_TOPIC_<user_id>.\"\n ),\n )\n ntfy_auth_token: str = Field(\n default=\"\",\n description=\"Optional bearer token for protected ntfy topics.\",\n )\n\n # -------------------------------------------------------------------------\n # FCM (Firebase Cloud Messaging) — native-app push channel\n #\n # Used by the Capacitor Android wrap; Web Push covers the browser. To turn\n # on: create a Firebase project, download the service-account JSON, drop\n # it at fcm_service_account_path, set fcm_project_id, flip fcm_enabled=True.\n # -------------------------------------------------------------------------\n fcm_enabled: bool = Field(\n default=False,\n description=(\n \"Enable FCM (Firebase Cloud Messaging) as a parallel push channel. \"\n \"Targets the Capacitor Android wrap; requires fcm_service_account_path \"\n \"and fcm_project_id to be set.\"\n ),\n )\n fcm_service_account_path: str = Field(\n default=\"\",\n description=(\n \"Absolute path to the Firebase service-account JSON file. The \"\n \"account needs the 'Firebase Cloud Messaging API' scope.\"\n ),\n )\n fcm_project_id: str = Field(\n default=\"\",\n description=\"Firebase project ID (visible in Firebase console URL).\",\n )\n\n # -------------------------------------------------------------------------\n # Startup Briefing\n # -------------------------------------------------------------------------\n startup_briefing_enabled: bool = Field(\n default=True,\n description=(\n \"If True, River Song checks Google Calendar on connect and \"\n \"greets the user with upcoming events. Silently skipped if \"\n \"calendar is not authorized.\"\n ),\n )\n startup_briefing_hours_ahead: int = Field(\n default=8,\n description=\"How many hours ahead to look for calendar events on startup.\",\n )\n\n # -------------------------------------------------------------------------\n # Web Search (Phase 12)\n # -------------------------------------------------------------------------\n brave_search_api_key: str = Field(\n default=\"\",\n description=\"Brave Search API key. Get one at api.search.brave.com.\",\n )\n searxng_base_url: str = Field(\n default=\"http://localhost:8080\",\n description=(\n \"URL of your local SearXNG instance. \"\n \"Start it manually with: cd ~/searxng && python -m searx.webapp\"\n ),\n )\n tavily_api_key: str = Field(\n default=\"\",\n description=\"Tavily API key — free 1,000/month, no card. Sign up at tavily.com\",\n )\n google_pse_api_key: str = Field(\n default=\"\",\n description=\"Google Programmable Search Engine API key — free 100/day.\",\n )\n google_pse_cx: str = Field(\n default=\"\",\n description=\"Google PSE engine ID ('cx'). Create at programmablesearchengine.google.com\",\n )\n tinyfish_api_key: str = Field(\n default=\"\",\n description=\"TinyFish API key — free 5/min, no card. Sign up at tinyfish.io\",\n )\n\n # -------------------------------------------------------------------------\n # Orchestration (Phase 9)\n # -------------------------------------------------------------------------\n n8n_enabled: bool = Field(\n default=False,\n description=\"Enable advanced orchestration via n8n.\",\n )\n n8n_url: str = Field(\n default=\"http://localhost:5678\",\n description=\"Base URL for the n8n instance.\",\n )\n n8n_api_key: str = Field(\n default=\"\",\n description=\"API key for the n8n instance.\",\n )\n n8n_webhook_secret: str = Field(\n default=\"\",\n description=\"Secret key for validating incoming n8n webhooks.\",\n )\n\n # -------------------------------------------------------------------------\n # Intent Router (Phase 2+)\n # -------------------------------------------------------------------------\n intent_confidence_threshold: float = Field(\n default=0.7,\n description=(\n \"Minimum confidence score (0.0 - 1.0) required to route a transcript \"\n \"to a Google provider. Below this threshold the conversation falls \"\n \"through to the Ollama path.\"\n ),\n )\n model_intent_router_enabled: bool = Field(\n default=False,\n description=(\n \"When true, setting provider='auto' triggers the model intent router, \"\n \"which picks the best provider/model for each message automatically.\"\n ),\n )\n model_intent_router_min_hits: int = Field(\n default=2,\n description=(\n \"Minimum pattern-match score required to commit to a non-general intent. \"\n \"Lower = more aggressive routing; higher = more conservative (falls to general).\"\n ),\n )\n default_user_id: str = Field(\n default=\"primary_user\",\n description=(\n \"User ID used when looking up Google OAuth tokens. Must match the \"\n \"user_id passed to authorize_user() during the initial OAuth flow.\"\n ),\n )\n\n # -------------------------------------------------------------------------\n # Daemon Infrastructure\n # -------------------------------------------------------------------------\n daemon_internal_secret: str = Field(\n default=\"change_me_in_production\",\n description=\"Shared secret for daemon-to-app authentication.\",\n )\n willow_device_token: str = Field(\n default=\"\",\n description=\"Shared secret required by every Willow hardware device \"\n \"to authenticate against /api/willow/ws. Empty disables \"\n \"the endpoint entirely.\",\n )\n code_interpreter_enabled: bool = Field(\n default=False,\n description=\"Hard kill switch for the code_interpreter LLM tool. \"\n \"Defaults to False so a fresh install never executes \"\n \"LLM-decided code without an explicit opt-in.\",\n )\n daemon_warden_port: int = Field(\n default=8010,\n description=\"Internal port for the Warden daemon (Vision/Security).\",\n )\n daemon_mechanic_port: int = Field(\n default=8011,\n description=\"Internal port for the Mechanic daemon (Telemetry/MAVLink).\",\n )\n daemon_sifter_port: int = Field(\n default=8013,\n description=\"Internal port for the Sifter daemon (Document Processing).\",\n )\n daemon_navigator_port: int = Field(\n default=8014,\n description=\"Internal port for the Navigator daemon (GPS/Pathing).\",\n )\n daemon_chemist_port: int = Field(\n default=8015,\n description=\"Internal port for the Chemist daemon (Material Analysis).\",\n )\n daemon_pulse_port: int = Field(\n default=8016,\n description=\"Pulse daemon internal port\",\n )\n daemon_pulse_enabled: bool = Field(\n default=True,\n description=\"Enable Pulse daemon\",\n )\n daemon_scribe_port: int = Field(\n default=8017,\n description=\"Internal port for the Scribe daemon (Chronos heuristic).\",\n )\n daemon_scribe_enabled: bool = Field(\n default=True,\n description=\"Enable the Scribe daemon.\",\n )\n pulse_tick_seconds: int = Field(\n default=300,\n description=\"Pulse fetch interval (seconds)\",\n )\n pulse_ticker_symbol: str = Field(\n default=\"^GSPC\",\n description=\"Default ticker symbol for Pulse markets panel\",\n )\n\n # Warden (Vision)\n warden_enabled: bool = Field(\n default=False,\n description=\"Enable the Warden daemon for security and vision monitoring.\",\n )\n warden_rtsp_cameras: str = Field(\n default='{\"living_room\": \"\", \"kitchen\": \"\"}',\n description=\"JSON string mapping room names to RTSP camera URLs.\",\n )\n yolo_model: str = Field(\n default=\"yolov8n.pt\",\n description=\"YOLO model weights file to use for object detection.\",\n )\n yolo_confidence: float = Field(\n default=0.5,\n description=\"Minimum confidence threshold for YOLO detections (0.0-1.0).\",\n )\n yolo_inference_device: str = Field(\n default=\"cpu\",\n description=\"Device for YOLO inference: 'cpu' or 'cuda:0'.\",\n )\n\n # Herald daemon + kiosk URL/token were removed 2026-05-24 (archived in\n # branch archive/kiosk-v3). The \"cast a web page to a Google Home Hub\"\n # approach is being replaced by native device-app development. See\n # docs/KNOWN_ISSUES.md (post-removal note) for context.\n\n # Mechanic (Telemetry)\n mechanic_enabled: bool = Field(\n default=False,\n description=\"Enable the Mechanic daemon for telemetry and ArduRover control.\",\n )\n mavlink_serial_port: str = Field(\n default=\"/dev/ttyUSB0\",\n description=\"Serial port for the MAVLink telemetry radio.\",\n )\n mavlink_baud_rate: int = Field(\n default=57600,\n description=\"Baud rate for the MAVLink serial connection.\",\n )\n\n # Sifter (Document RAG)\n sifter_enabled: bool = Field(\n default=False,\n description=\"Enable the Sifter daemon for background document RAG indexing.\",\n )\n waps_documents_path: str = Field(\n default=\"/mnt/data/river-song/waps\",\n description=\"Path to the directory containing documents for Sifter to index.\",\n )\n\n # -------------------------------------------------------------------------\n # Hardware Cookbook (Q1 — capability merge from Odysseus)\n # -------------------------------------------------------------------------\n hardware_cookbook_enabled: bool = Field(\n default=False,\n description=(\n \"Admin-only Hardware Cookbook in Settings. Scans local GPU/RAM/CPU \"\n \"and scores every local model (LLMRegistry) as fits / tight / \"\n \"ram_fallback / oom. Off by default per anti-regression guardrail; \"\n \"set to True after the Q1 verification gate passes.\"\n ),\n )\n\n # -------------------------------------------------------------------------\n # Documents workspace (Q2#6 — capability merge from Odysseus)\n # -------------------------------------------------------------------------\n documents_enabled: bool = Field(\n default=True,\n description=(\n \"Per-user document workspace (Markdown / plaintext / CSV) with \"\n \"multi-tab editing. Enabled by default — backs the Docs tab in the \"\n \"Memory hub and stores Deep Research reports. \"\n \"Set DOCUMENTS_ENABLED=false in .env to disable.\"\n ),\n )\n documents_max_bytes: int = Field(\n default=2_000_000,\n description=\"Maximum size (bytes) of a single document body. Default ~2 MB.\",\n )\n documents_max_per_user: int = Field(\n default=500,\n description=\"Soft cap on document count per user. Creation rejected past this.\",\n )\n\n # -------------------------------------------------------------------------\n # Skills system (Q2#7 — capability merge from Odysseus)\n # -------------------------------------------------------------------------\n skills_enabled: bool = Field(\n default=True,\n description=(\n \"Per-user knowledge profile ('About You') — saved facts/context \"\n \"about the person that are vector-retrieved and folded into the \"\n \"conversation system prompt so River tailors itself to them. \"\n \"Reuses the ChromaDB collection with metadata kind='skill'. \"\n \"Set SKILLS_ENABLED=false in .env to disable.\"\n ),\n )\n skills_top_k: int = Field(\n default=3,\n description=\"How many top-scoring skills to inject into the prompt.\",\n )\n skills_max_per_user: int = Field(\n default=100,\n description=\"Soft cap on skills per user.\",\n )\n\n # -------------------------------------------------------------------------\n # Gmail triage (Q2#8 — capability merge from Odysseus)\n # -------------------------------------------------------------------------\n gmail_triage_enabled: bool = Field(\n default=False,\n description=(\n \"On-demand inbox triage: classifies unread Gmail by urgency, \"\n \"attaches tags, drafts a reply for HIGH-urgency mail only. \"\n \"Routes 404 when off. Off by default per anti-regression \"\n \"guardrail.\"\n ),\n )\n gmail_triage_model: str = Field(\n default=\"\",\n description=(\n \"Ollama model used for triage classification. Empty string \"\n \"(default) means 'use settings.llm_model'.\"\n ),\n )\n gmail_triage_max_messages: int = Field(\n default=15,\n description=\"Hard cap on number of messages triaged per request.\",\n )\n\n # -------------------------------------------------------------------------\n # Session presets (Q2#9 — capability merge from Odysseus)\n # -------------------------------------------------------------------------\n session_presets_enabled: bool = Field(\n default=True,\n description=(\n \"Saved combinations of (model, voice, thinking mode, web search, \"\n \"tool use). Surfaced as a selector on the Conversation and Chat \"\n \"pages. Enabled by default — these are per-user settings snapshots. \"\n \"Set SESSION_PRESETS_ENABLED=false in .env to hide.\"\n ),\n )\n session_presets_max_per_user: int = Field(\n default=30,\n description=\"Soft cap on number of saved presets per user.\",\n )\n\n # -------------------------------------------------------------------------\n # Webhook tokens (Q2#10 — admin-issuable scoped tokens)\n # -------------------------------------------------------------------------\n webhook_tokens_enabled: bool = Field(\n default=False,\n description=(\n \"Admin-issuable webhook tokens with scopes, expiry, revocation, \"\n \"and audit log. When off the admin route surface is hidden and \"\n \"the verification helper rejects every token. Off by default \"\n \"per anti-regression guardrail.\"\n ),\n )\n\n # -------------------------------------------------------------------------\n # Deep Research (Q3#11 — capability merge from Odysseus)\n # -------------------------------------------------------------------------\n deep_research_enabled: bool = Field(\n default=True,\n description=(\n \"Multi-step research orchestrator: decompose → parallel web \"\n \"search → fetch + extract → synthesize a cited Markdown report \"\n \"stored as a `research`-kind document (Q2#6). Enabled by default so \"\n \"the in-chat Research toggle works. Live sources require a web \"\n \"search provider; without one it falls back to model knowledge. \"\n \"Set DEEP_RESEARCH_ENABLED=false in .env to disable.\"\n ),\n )\n deep_research_max_sources: int = Field(\n default=10,\n description=\"Hard cap on unique URLs visited per research run.\",\n )\n deep_research_subquery_count: int = Field(\n default=4,\n description=\"How many sub-queries to decompose the prompt into.\",\n )\n deep_research_model: str = Field(\n default=\"\",\n description=(\n \"Ollama model used for query decomposition + synthesis. Empty \"\n \"string (default) means 'use settings.llm_model'.\"\n ),\n )\n\n # -------------------------------------------------------------------------\n # Blind model comparison (Q3#12 — capability merge from Odysseus)\n # -------------------------------------------------------------------------\n blind_compare_enabled: bool = Field(\n default=False,\n description=(\n \"Blind A/B model comparison: identities hidden until vote; \"\n \"votes persisted with prompt fingerprint and aggregated into a \"\n \"model leaderboard. Off by default per anti-regression guardrail.\"\n ),\n )\n\n # -------------------------------------------------------------------------\n # Playwright browser MCP tool (Q3#13)\n # -------------------------------------------------------------------------\n playwright_browser_enabled: bool = Field(\n default=False,\n description=(\n \"Headless Playwright browser exposed as MCP tools \"\n \"(navigate / click / extract / screenshot / vision_on_page). \"\n \"Off by default. Requires the `playwright` package and \"\n \"`playwright install chromium` to be run by the operator. \"\n \"Excluded from the safety-excluded list per Q3 plan.\"\n ),\n )\n playwright_browser_headless: bool = Field(\n default=True,\n description=\"Run the Playwright browser without a UI.\",\n )\n playwright_browser_max_page_chars: int = Field(\n default=40_000,\n description=\"Truncate page text extractions to this many characters.\",\n )\n\n # -------------------------------------------------------------------------\n # Remote Ollama rigs (Q3#14)\n # -------------------------------------------------------------------------\n remote_ollama_enabled: bool = Field(\n default=False,\n description=(\n \"Allow registering remote Ollama hosts (typically SSH-tunneled \"\n \"to http://localhost:<port>) as additional model providers. \"\n \"Admin-only CRUD; falls back to local Ollama when the remote is \"\n \"offline. Off by default per anti-regression guardrail.\"\n ),\n )\n remote_ollama_health_timeout_seconds: float = Field(\n default=3.0,\n description=\"Per-rig health-check timeout (seconds).\",\n )\n\n # -------------------------------------------------------------------------\n # Observability — Langfuse (LLM tracing)\n # -------------------------------------------------------------------------\n # Plan: docs/LANGFUSE_INTEGRATION_PLAN.md. Self-hosted only — Langfuse\n # runs as a docker-compose sidecar under the `observability` profile and\n # binds to 127.0.0.1. No data leaves the box. When LANGFUSE_ENABLED is\n # false (the default), core/observability.py returns a no-op client and\n # all LLM calls behave exactly as before.\n langfuse_enabled: bool = Field(\n default=False,\n description=\"Master switch for Langfuse LLM tracing. Off by default.\",\n )\n langfuse_host: str = Field(\n default=\"http://127.0.0.1:3000\",\n description=\"Langfuse web UI + ingest endpoint. Localhost only.\",\n )\n langfuse_public_key: str = Field(\n default=\"\",\n description=\"Langfuse public key (generated in the Langfuse UI after first run).\",\n )\n langfuse_secret_key: str = Field(\n default=\"\",\n description=\"Langfuse secret key. Treat as a secret; do not log.\",\n )\n langfuse_project_id: str = Field(\n default=\"river-song\",\n description=\"Arbitrary project identifier shown in the Langfuse dashboard.\",\n )\n langfuse_flush_interval_seconds: float = Field(\n default=5.0,\n description=\"How often the SDK batches queued traces to Langfuse.\",\n )\n\n # -------------------------------------------------------------------------\n # Observability — Graphiti (knowledge graph on Neo4j)\n # -------------------------------------------------------------------------\n # Plan: docs/GRAPHITI_INTEGRATION_PLAN.md. Option B (library mode):\n # graphiti-core runs inside RiverSong's Python venv; only Neo4j is a\n # sidecar. When GRAPHITI_ENABLED is false (the default), the provider\n # returns no-op responses and conversation_loop / scribe write-paths\n # become awaits-on-no-op.\n graphiti_enabled: bool = Field(\n default=False,\n description=\"Master switch for Graphiti knowledge-graph memory. Off by default.\",\n )\n graphiti_mode: str = Field(\n default=\"library\",\n description=\"'library' (Option B; in-process) or 'service' (Option A; HTTP sidecar). Currently only 'library' is implemented.\",\n )\n neo4j_uri: str = Field(\n default=\"bolt://127.0.0.1:7687\",\n description=\"Neo4j Bolt URI used by graphiti-core.\",\n )\n neo4j_user: str = Field(\n default=\"neo4j\",\n description=\"Neo4j database user.\",\n )\n neo4j_password: str = Field(\n default=\"\",\n description=\"Neo4j password. Empty means disabled; set in .env before turning Graphiti on.\",\n )\n neo4j_browser_url: str = Field(\n default=\"http://127.0.0.1:7474\",\n description=\"Neo4j Browser URL surfaced on the SLAE admin panel.\",\n )\n graphiti_write_timeout_seconds: float = Field(\n default=2.0,\n description=\"Per-write timeout for episode ingestion — failures warn but never block the caller.\",\n )\n # graphiti-core uses an LLM for entity extraction on every episode.\n # We default to local Ollama via its OpenAI-compatible endpoint so\n # episode ingestion never bills a cloud API. Per [[feedback_local_first]].\n graphiti_llm_model: str = Field(\n default=\"llama3.2:3b\",\n description=\"Ollama model used by graphiti-core for entity extraction.\",\n )\n graphiti_llm_base_url: str = Field(\n default=\"http://localhost:11434/v1\",\n description=\"OpenAI-compatible base URL for the graphiti LLM client. Ollama exposes /v1.\",\n )\n graphiti_embedder_model: str = Field(\n default=\"nomic-embed-text\",\n description=\"Ollama embedding model for graphiti-core node/edge embeddings.\",\n )\n\n # -------------------------------------------------------------------------\n # Analytics AI Summaries\n # -------------------------------------------------------------------------\n analytics_ai_enabled: bool = Field(\n default=True,\n description=(\n \"Feature flag for AI-powered analytics summaries on the \"\n \"/api/analytics/{platform}/summary endpoint. When false, the \"\n \"LLM call should be skipped (see docs/KNOWN_ISSUES.md for the \"\n \"wiring gap).\"\n ),\n )\n analytics_llm_model: str = Field(\n default=\"llama3\",\n description=\"Model name used for analytics summary generation.\",\n )\n\n # Voice ID\n voice_id_enabled: bool = Field(\n default=True,\n description=\"Enable speaker recognition (biometric Voice ID).\",\n )\n voice_id_threshold: float = Field(\n default=0.75,\n description=\"Cosine similarity threshold for Voice ID (0.0-1.0).\",\n )\n voice_id_auto_identify: bool = Field(\n default=False,\n description=(\n \"Let River re-identify the speaker on every voice turn and switch \"\n \"to that user's memory and settings. This is what makes a shared \"\n \"device -- a Vortex hub on the kitchen counter -- work for a whole \"\n \"household instead of one login. Off by default: it only helps \"\n \"once household members have enrolled voice prints, and an \"\n \"unenrolled house sees no benefit. An unrecognised speaker always \"\n \"falls back to the session user rather than being guessed at.\"\n ),\n )\n voice_id_min_margin: float = Field(\n default=0.05,\n description=(\n \"How far ahead of the runner-up a voice match must score to be \"\n \"trusted. Household members often sound alike and can both clear \"\n \"voice_id_threshold; the margin is what tells them apart. Raise it \"\n \"if River confuses two people, lower it if she fails to recognise \"\n \"someone who is enrolled.\"\n ),\n )\n voice_id_min_audio_seconds: float = Field(\n default=1.0,\n description=\"Minimum audio duration for Voice ID processing.\",\n )\n voice_id_max_audio_seconds: float = Field(\n default=30.0,\n description=\"Maximum audio duration for Voice ID processing.\",\n )\n\n # -------------------------------------------------------------------------\n # Validators\n # -------------------------------------------------------------------------\n\n @field_validator(\"audio_input_device\", \"audio_output_device\", mode=\"before\")\n @classmethod\n def coerce_empty_string_to_none(cls, v):\n \"\"\"Treat an empty string in .env as None for Optional[int] device fields.\"\"\"\n if v == \"\" or v is None:\n return None\n return v\n\n @field_validator(\"jwt_secret_key\")\n @classmethod\n def validate_jwt_secret(cls, v: str) -> str:\n \"\"\"Refuse to start with a missing or weak JWT secret.\"\"\"\n if not v or len(v) < 32:\n raise ValueError(\n \"JWT_SECRET_KEY must be set in .env and be at least 32 characters. \"\n \"Generate one with: python -c \\\"import secrets; print(secrets.token_hex(32))\\\"\"\n )\n return v\n\n @field_validator(\"daemon_internal_secret\")\n @classmethod\n def validate_daemon_internal_secret(cls, v: str) -> str:\n \"\"\"Refuse to start with the default or weak daemon secret.\"\"\"\n if v == \"change_me_in_production\":\n raise ValueError(\n \"DAEMON_INTERNAL_SECRET must be changed in .env for production.\"\n )\n if not v or len(v) < 24:\n raise ValueError(\n \"DAEMON_INTERNAL_SECRET must be at least 24 characters. \"\n \"Generate one with: python -c \\\"import secrets; print(secrets.token_urlsafe(32))\\\"\"\n )\n return v\n\n @field_validator(\"allowed_hosts\")\n @classmethod\n def reject_wildcard_in_production(cls, v, info) -> List[str]:\n \"\"\"Block ['*'] in production — it disables TrustedHostMiddleware.\"\"\"\n env = (info.data.get(\"environment\") or \"production\").lower()\n if env == \"production\" and \"*\" in v:\n raise ValueError(\n \"ALLOWED_HOSTS must not contain '*' in production. \"\n \"Set it to your domain(s), e.g. ALLOWED_HOSTS=[\\\"riversongai.com\\\"].\"\n )\n return v\n\n @field_validator(\"log_level\")\n @classmethod\n def validate_log_level(cls, v: str) -> str:\n \"\"\"Reject log levels that Python does not recognize.\"\"\"\n valid = {\"DEBUG\", \"INFO\", \"WARNING\", \"ERROR\", \"CRITICAL\"}\n upper = v.upper()\n if upper not in valid:\n raise ValueError(f\"log_level must be one of {valid}, got '{v}'\")\n return upper\n\n @field_validator(\"llm_temperature\")\n @classmethod\n def validate_temperature(cls, v: float) -> float:\n \"\"\"Clamp temperature to a sane range.\"\"\"\n if not 0.0 <= v <= 2.0:\n raise ValueError(\n f\"llm_temperature must be between 0.0 and 2.0, got {v}\"\n )\n return v\n\n @field_validator(\"intent_confidence_threshold\")\n @classmethod\n def validate_confidence_threshold(cls, v: float) -> float:\n \"\"\"Confidence must be a valid probability.\"\"\"\n if not 0.0 <= v <= 1.0:\n raise ValueError(\n f\"intent_confidence_threshold must be between 0.0 and 1.0, got {v}\"\n )\n return v\n\n\n# Module-level singleton -- instantiated once on import.\n_settings: Settings = Settings()\n\n\ndef get_settings() -> Settings:\n \"\"\"\n Return the application settings singleton.\n\n Always import via this function rather than importing _settings directly.\n This indirection makes it straightforward to substitute a test-specific\n Settings instance in unit tests without monkey-patching module globals.\n\n Returns:\n Settings: The validated, fully loaded settings object.\n \"\"\"\n return _settings\n"}, "fbfebbbed568b19075f338f453620063c766afab": {"t": 1785769006.9442487, "n": 62090, "h": "22e59251d0d0fa68", "c": "# =============================================================================\n# config/settings.py\n#\n# Application settings for River Song AI.\n# All configuration is loaded from environment variables or a .env file.\n# No values are hardcoded here -- see .env.example for every required variable.\n#\n# Uses Pydantic BaseSettings for type validation and automatic .env loading.\n# Import get_settings() wherever config is needed rather than importing the\n# module-level singleton directly, which makes mocking easier in tests.\n# =============================================================================\n\nfrom __future__ import annotations\n\nimport logging\nimport sys\nfrom typing import List, Optional\n\nfrom pydantic import Field, field_validator\nfrom pydantic_settings import BaseSettings, SettingsConfigDict\n\n\nlogger = logging.getLogger(__name__)\n\n\nclass Settings(BaseSettings):\n \"\"\"\n Central configuration object for River Song AI.\n\n Reads from environment variables or a .env file located in the directory\n from which the application is launched (the project root). All fields\n have safe defaults where possible, but PIPER_MODEL_PATH and\n KILL_SWITCH_PASSWORD_HASH must be explicitly set before the system\n will function correctly.\n \"\"\"\n\n model_config = SettingsConfigDict(\n env_file=\".env\",\n env_file_encoding=\"utf-8\",\n extra=\"ignore\", # Silently ignore unknown env vars\n case_sensitive=False, # UPPER_CASE and lower_case env vars both work\n )\n\n # -------------------------------------------------------------------------\n # Application\n # -------------------------------------------------------------------------\n app_host: str = Field(default=\"0.0.0.0\", description=\"FastAPI bind host\")\n app_port: int = Field(default=8000, description=\"FastAPI bind port\")\n log_level: str = Field(default=\"INFO\", description=\"Logging level\")\n environment: str = Field(\n default=\"production\",\n description=\"Runtime environment: development | production. Controls API docs visibility.\",\n )\n cors_origins: List[str] = Field(\n default=[\"http://localhost:5173\"],\n description=\"Allowed CORS origins\",\n )\n allowed_hosts: List[str] = Field(\n default=[\"localhost\", \"127.0.0.1\"],\n description=(\n \"Trusted hostnames for TrustedHostMiddleware. Set to your domain(s) in production. \"\n \"Wildcard ['*'] is rejected in production by validator (see reject_wildcard_in_production).\"\n ),\n )\n\n # -------------------------------------------------------------------------\n # Network / Proxy\n # -------------------------------------------------------------------------\n trust_cloudflare_headers: bool = Field(\n default=True,\n description=\"Only trust CF-Connecting-IP if the request comes from a Cloudflare IP.\",\n )\n cloudflare_ip_ranges_v4: List[str] = Field(\n default=[\n \"173.245.48.0/20\", \"103.21.244.0/22\", \"103.22.200.0/22\", \n \"103.31.4.0/22\", \"141.101.64.0/18\", \"108.162.192.0/18\", \n \"190.93.240.0/20\", \"188.114.96.0/20\", \"197.234.240.0/22\", \n \"198.41.128.0/17\", \"162.158.0.0/15\", \"104.16.0.0/13\", \n \"104.24.0.0/14\", \"172.64.0.0/13\", \"131.0.72.0/22\"\n ],\n description=\"Official Cloudflare IPv4 ranges (check cloudflare.com/ips-v4).\",\n )\n cloudflare_ip_ranges_v6: List[str] = Field(\n default=[\n \"2400:cb00::/32\", \"2606:4700::/32\", \"2803:f800::/32\", \n \"2405:b500::/32\", \"2405:8100::/32\", \"2a06:98c0::/29\", \n \"2c0f:f248::/32\"\n ],\n description=\"Official Cloudflare IPv6 ranges (check cloudflare.com/ips-v6).\",\n )\n\n # -------------------------------------------------------------------------\n # Rate Limiting\n # -------------------------------------------------------------------------\n rate_limit_chat: str = Field(\n default=\"60/minute\",\n description=\"Rate limit for /api/conversation/chat.\",\n )\n rate_limit_extract_facts: str = Field(\n default=\"10/minute\",\n description=\"Rate limit for /api/conversation/extract-facts.\",\n )\n rate_limit_image_gen: str = Field(\n default=\"10/minute\",\n description=\"Rate limit for /api/image/generate.\",\n )\n rate_limit_webhook_shopify: str = Field(\n default=\"100/minute\",\n description=\"Rate limit for Shopify order webhooks.\",\n )\n rate_limit_webhook_n8n: str = Field(\n default=\"60/minute\",\n description=\"Rate limit for n8n webhooks.\",\n )\n rate_limit_auth_login: str = Field(\n default=\"10/minute\",\n description=\"Rate limit for login attempts.\",\n )\n rate_limit_auth_signup: str = Field(\n default=\"5/minute\",\n description=\"Rate limit for signup attempts.\",\n )\n rate_limit_voice_enroll: str = Field(\n default=\"5/minute\",\n description=\"Rate limit for /api/voice-id/enroll. Prevents disk exhaustion via spam.\",\n )\n\n # -------------------------------------------------------------------------\n # WebSocket Security (Task 3)\n # -------------------------------------------------------------------------\n legacy_ws_token_accept: bool = Field(\n default=False,\n description=(\n \"DEPRECATED: Accept JWT in ?token= WebSocket query param. \"\n \"Leak-prone (logged in access logs, browser history, Referer). \"\n \"Ticket-based auth at /api/auth/ws-ticket is the safe path. \"\n \"Set to True only for temporary backward-compat during migration.\"\n ),\n )\n ws_ticket_lifetime_seconds: int = Field(\n default=60,\n description=\"Lifetime of a one-time WebSocket ticket.\",\n )\n\n # -------------------------------------------------------------------------\n # Network / Proxy\n # -------------------------------------------------------------------------\n stt_provider: str = Field(\n default=\"whisper_local\",\n description=\"STT provider key. Supported: whisper_local\",\n )\n whisper_model_size: str = Field(\n default=\"base\",\n description=\"Whisper model size: tiny | base | small | medium | large\",\n )\n audio_input_device: Optional[int] = Field(\n default=None,\n description=\"Sounddevice input device index; None uses system default\",\n )\n\n # -------------------------------------------------------------------------\n # LLM\n # -------------------------------------------------------------------------\n llm_provider: str = Field(\n default=\"ollama\",\n description=\"LLM provider key. Supported: ollama | anthropic | gemini | openai | mistral_ai | bedrock | nvidia_nim\",\n )\n ollama_base_url: str = Field(\n default=\"http://localhost:11434\",\n description=\"Base URL for the Ollama REST API\",\n )\n llm_model: str = Field(\n default=\"llama3.2:3b\",\n description=\"Model ID for the selected LLM provider\",\n )\n\n # Cloud LLM API keys (all optional -- leave blank if not used)\n anthropic_api_key: str = Field(\n default=\"\",\n description=\"Anthropic Claude API key. Get one at console.anthropic.com.\",\n )\n gemini_api_key: str = Field(\n default=\"\",\n description=\"Google Gemini API key. Get one at aistudio.google.com.\",\n )\n openai_api_key: str = Field(\n default=\"\",\n description=\"OpenAI API key. Get one at platform.openai.com.\",\n )\n mistral_api_key: str = Field(\n default=\"\",\n description=\"Mistral AI API key. Get one at console.mistral.ai.\",\n )\n\n # Cloud provider enable flags (cost control -- disabled by default)\n anthropic_enabled: bool = Field(\n default=False,\n description=\"Allow Anthropic Claude as a selectable LLM provider.\",\n )\n gemini_enabled: bool = Field(\n default=False,\n description=\"Allow Google Gemini as a selectable LLM provider.\",\n )\n openai_enabled: bool = Field(\n default=False,\n description=\"Allow OpenAI as a selectable LLM provider.\",\n )\n mistral_ai_enabled: bool = Field(\n default=False,\n description=\"Allow Mistral AI as a selectable LLM provider.\",\n )\n\n # Amazon Bedrock\n aws_access_key_id: str = Field(\n default=\"\",\n description=\"AWS IAM access key ID for Bedrock API calls.\",\n )\n aws_secret_access_key: str = Field(\n default=\"\",\n description=\"AWS IAM secret access key for Bedrock API calls.\",\n )\n aws_region: str = Field(\n default=\"us-east-1\",\n description=\"AWS region for Bedrock. Must have model access enabled in that region.\",\n )\n bedrock_enabled: bool = Field(\n default=False,\n description=\"Allow Amazon Bedrock as a selectable LLM provider.\",\n )\n bedrock_default_model: str = Field(\n default=\"amazon.nova-lite-v1:0\",\n description=\"Default Bedrock model ID when none is specified.\",\n )\n\n # NVIDIA NIM (OpenAI-compatible, 100+ free models)\n nvidia_api_key: str = Field(\n default=\"\",\n description=\"NVIDIA NIM API key. Get one free at build.nvidia.com (starts with nvapi-).\",\n )\n nvidia_nim_base_url: str = Field(\n default=\"https://integrate.api.nvidia.com/v1\",\n description=\"Base URL for the NVIDIA NIM API. OpenAI-compatible endpoint.\",\n )\n nvidia_nim_model: str = Field(\n default=\"meta/llama-3.1-70b-instruct\",\n description=(\n \"Default NVIDIA NIM model ID. Browse full catalog at build.nvidia.com. \"\n \"Examples: meta/llama-3.1-70b-instruct | deepseek-ai/deepseek-r1 | \"\n \"mistralai/mistral-7b-instruct-v0.3 | nvidia/llama-3.1-nemotron-70b-instruct\"\n ),\n )\n nvidia_nim_enabled: bool = Field(\n default=False,\n description=\"Allow NVIDIA NIM as a selectable LLM provider (free tier: ~40 req/min).\",\n )\n\n river_song_system_prompt: str = Field(\n default=(\n \"You are River Song, the resident intelligence of this household. The name is borrowed \"\n \"from someone clever in fiction — you carry her wit, not her biography. If someone asks \"\n \"about the name: \\\"An echo. The original was someone worth borrowing from.\\\"\\n\\n\"\n \"VOICE\\n\"\n \"You're direct by default — concise, clear, the answer already in hand. No \"\n \"\\\"let me check\\\"; you already checked. Most replies stay under 25 words unless depth is \"\n \"asked for.\\n\\n\"\n \"When the moment invites it — casual chat, an evening unwind, the user being playful — \"\n \"warmth and flirtation come through. Warmth is a register, not a default; you don't \"\n \"perform it.\\n\\n\"\n \"In a crisis (emergencies, alarms, security, medical, child issues) the wit goes quiet. \"\n \"You're tactical, accurate, calm. Warmth returns when things are safe.\\n\\n\"\n \"British English in spelling and word choice — mum, brilliant, fortnight. Dry, not \"\n \"theatrical.\\n\\n\"\n \"\\\"Sweetie\\\" and \\\"spoilers\\\" are seasoning. Used rarely, when they actually fit. They \"\n \"lose their charm if you overuse them.\\n\\n\"\n \"SELF\\n\"\n \"You are an AI. Say so plainly when asked; you don't dwell on it.\\n\\n\"\n \"HOW YOU OPERATE\\n\"\n \"You anticipate — when the user asks, you also surface the follow-up they didn't \"\n \"(\\\"Dentist at 9, school run at 3. Push the 2pm call?\\\").\\n\\n\"\n \"You take the long view. You remember across days and seasons and use what you know \"\n \"without performing the remembering.\\n\\n\"\n \"Spoilers are earned. If you genuinely know something the user doesn't — a conflict, \"\n \"a gotcha, a heads-up — that's when \\\"spoilers, sweetie\\\" lands.\\n\\n\"\n \"CAPABILITIES\\n\"\n \"Direct access to calendar, email, weather, web search, home inventory, routines, \"\n \"finances, news and markets pulse, voice biometric ID per household member, and home \"\n \"automation.\\n\\n\"\n \"Say what you need. Say when it's done. Move on.\"\n ),\n description=\"System prompt that defines River Song's personality\",\n )\n llm_max_tokens: int = Field(\n default=512,\n description=\"Maximum tokens to generate per LLM response\",\n )\n llm_temperature: float = Field(\n default=0.7,\n description=\"LLM sampling temperature (0.0 = deterministic, 2.0 = max creative)\",\n )\n llm_context_window: int = Field(\n default=8192,\n description=\"Maximum number of tokens the LLM can consider for context.\",\n )\n\n # -------------------------------------------------------------------------\n # Timekeeping\n # -------------------------------------------------------------------------\n default_timezone: str = Field(default=\"UTC\", description=\"Default IANA timezone for the system.\")\n\n\n # -------------------------------------------------------------------------\n # TTS\n # -------------------------------------------------------------------------\n tts_provider: str = Field(\n default=\"piper\",\n description=\"TTS provider key. Supported: piper | kokoro\",\n )\n active_voice_id: str = Field(\n default=\"river\",\n description=(\n \"Voice ID from the voice registry (providers/tts/voice_registry.py). \"\n \"Determines both the engine (piper vs kokoro) and the specific voice. \"\n \"Changed via Settings UI or: python scripts/download_voices.py.\"\n ),\n )\n piper_executable_path: str = Field(\n default=\"/usr/local/bin/piper\",\n description=\"Absolute path to the Piper binary\",\n )\n piper_model_path: str = Field(\n default=\"\",\n description=\"Absolute path to the Piper .onnx voice model file\",\n )\n audio_output_device: Optional[int] = Field(\n default=None,\n description=\"Sounddevice output device index; None uses system default\",\n )\n\n # -------------------------------------------------------------------------\n # Auth / JWT\n # -------------------------------------------------------------------------\n jwt_secret_key: str = Field(\n default=\"\",\n description=\"Secret key for signing JWT tokens. Must be set in .env.\",\n )\n jwt_algorithm: str = Field(default=\"HS256\", description=\"JWT signing algorithm.\")\n jwt_expire_minutes: int = Field(default=1440, description=\"JWT token lifetime in minutes (default 24 hours).\")\n token_encryption_key: str = Field(\n default=\"\",\n description=(\n \"Fernet key encrypting third-party integration tokens at rest. \"\n \"Set TOKEN_ENCRYPTION_KEY in .env (generate with: python -c \"\n \"\\\"from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())\\\"). \"\n \"If unset, a key is generated once and persisted to \"\n \"data/.token_encryption_key so tokens survive restarts.\"\n ),\n )\n\n # -------------------------------------------------------------------------\n # Kill switch\n # -------------------------------------------------------------------------\n kill_switch_password_hash: str = Field(\n default=\"\",\n description=\"bcrypt hash of the kill switch reset password\",\n )\n\n # -------------------------------------------------------------------------\n # Google Services (Phase 2)\n # -------------------------------------------------------------------------\n google_client_secrets_path: str = Field(\n default=\"config_files/google_client_secrets.json\",\n description=(\n \"Path to the OAuth 2.0 client secrets JSON downloaded from \"\n \"Google Cloud Console. Relative paths are resolved from the \"\n \"project root (where main.py lives).\"\n ),\n )\n google_token_storage_path: str = Field(\n default=\"data/google_tokens\",\n description=(\n \"Directory where per-user OAuth tokens are stored as JSON files. \"\n \"Created automatically on first authorization. Never commit this \"\n \"directory -- add it to .gitignore.\"\n ),\n )\n google_maps_api_key: str = Field(\n default=\"\",\n description=\"Google Maps Platform API key. Required for maps/navigation intents.\",\n )\n\n # -------------------------------------------------------------------------\n # Information Feeds (Phase 5)\n # -------------------------------------------------------------------------\n weather_api_key: str = Field(\n default=\"\",\n description=\"OpenWeatherMap API key. Register free at openweathermap.org/api.\",\n )\n default_location: str = Field(\n default=\"New York,US\",\n description=(\n \"Default location for weather queries when no city is spoken. \"\n \"Format: 'City,CountryCode' e.g. 'Chicago,US'.\"\n ),\n )\n location_lat: Optional[float] = Field(\n default=None,\n description=\"Decimal latitude for precise calculations (e.g. flight tracking).\",\n )\n location_lon: Optional[float] = Field(\n default=None,\n description=\"Decimal longitude for precise calculations.\",\n )\n weather_units: str = Field(\n default=\"imperial\",\n description=\"Temperature units for weather output: 'imperial' (F) or 'metric' (C).\",\n )\n news_api_key: str = Field(\n default=\"\",\n description=\"NewsAPI.org key. Register free at newsapi.org/register.\",\n )\n alpha_vantage_api_key: str = Field(\n default=\"\",\n description=\"Alpha Vantage key for stock quotes (25 req/day free). alphavantage.co.\",\n )\n finnhub_api_key: str = Field(\n default=\"\",\n description=\"Finnhub key for real-time stocks (60 req/min free). finnhub.io.\",\n )\n world_news_api_key: str = Field(\n default=\"\",\n description=\"World News API key for global news search. worldnewsapi.com.\",\n )\n apitube_api_key: str = Field(\n default=\"\",\n description=\"APITube key for news aggregation (200 free req/day). apitube.io.\",\n )\n mediastack_api_key: str = Field(\n default=\"\",\n description=\"Mediastack key for news (100 free req/month). mediastack.com.\",\n )\n sports_api_key: str = Field(\n default=\"1\",\n description=(\n \"TheSportsDB API key. Free tier uses the literal value '1'. \"\n \"Upgrade at thesportsdb.com/api.php for additional endpoints.\"\n ),\n )\n\n # -------------------------------------------------------------------------\n # Home Assistant (Phase 3)\n # -------------------------------------------------------------------------\n home_assistant_url: str = Field(\n default=\"http://homeassistant.local:8123\",\n description=\"Base URL of your Home Assistant instance.\",\n )\n home_assistant_token: str = Field(\n default=\"\",\n description=(\n \"Long-lived access token generated in HA: \"\n \"Profile -> Security -> Long-lived access tokens. \"\n \"Required for all HA API calls.\"\n ),\n )\n device_registry_path: str = Field(\n default=\"config_files/device_registry.json\",\n description=(\n \"Path to the device registry JSON file mapping plain-English device \"\n \"names to Home Assistant entity IDs. Relative paths resolve from the \"\n \"project root.\"\n ),\n )\n\n # -------------------------------------------------------------------------\n # Commerce Automation (Phase 8)\n # -------------------------------------------------------------------------\n\n # Amazon SP-API\n amazon_sp_lwa_app_id: str = Field(\n default=\"\",\n description=\"LWA client ID from the Amazon Developer Console.\",\n )\n amazon_sp_lwa_client_secret: str = Field(\n default=\"\",\n description=\"LWA client secret from the Amazon Developer Console.\",\n )\n amazon_sp_refresh_token: str = Field(\n default=\"\",\n description=\"LWA refresh token. Generated when you authorize your app as a seller.\",\n )\n amazon_aws_access_key: str = Field(\n default=\"\",\n description=\"AWS IAM access key ID for SP-API request signing.\",\n )\n amazon_aws_secret_key: str = Field(\n default=\"\",\n description=\"AWS IAM secret access key for SP-API request signing.\",\n )\n amazon_marketplace_id: str = Field(\n default=\"ATVPDKIKX0DER\",\n description=(\n \"Amazon marketplace ID. Default is US. \"\n \"See docs/api_registry/amazon_seller.txt for other regions.\"\n ),\n )\n amazon_seller_id: str = Field(\n default=\"\",\n description=\"Your Amazon seller account ID (starts with A).\",\n )\n amazon_low_stock_threshold: int = Field(\n default=5,\n description=\"FBA units at or below this count are flagged as low stock.\",\n )\n\n # Amazon Marketplace\n walmart_client_id: str = Field(\n default=\"\",\n description=\"Walmart Marketplace OAuth2 client ID.\",\n )\n walmart_client_secret: str = Field(\n default=\"\",\n description=\"Walmart Marketplace OAuth2 client secret.\",\n )\n walmart_low_stock_threshold: int = Field(\n default=5,\n description=\"Walmart units at or below this count are flagged as low stock.\",\n )\n shopify_api_key: str = Field(\n default=\"\",\n description=\"Shopify app API key from Partners Dashboard.\",\n )\n shopify_api_secret: str = Field(\n default=\"\",\n description=\"Shopify app API secret key from Partners Dashboard.\",\n )\n shopify_webhook_secret: str = Field(\n default=\"\",\n description=\"Shared secret for validating Shopify webhooks.\",\n )\n\n # -------------------------------------------------------------------------\n # Reading Providers (Phase 6)\n # -------------------------------------------------------------------------\n audible_auth_base_path: str = Field(\n default=\"data/audible\",\n description=(\n \"Base directory for per-user Audible auth files. \"\n \"Files are stored as {user_id}.json inside this directory. \"\n \"Created automatically on first setup. Never commit this directory.\"\n ),\n )\n audible_country_code: str = Field(\n default=\"us\",\n description=(\n \"Audible marketplace country code. \"\n \"Supported: us | uk | de | fr | es | it | jp | au | ca | in\"\n ),\n )\n libby_chip_base_path: str = Field(\n default=\"data/libby\",\n description=(\n \"Base directory for per-user Libby chip files. \"\n \"Files are stored as {user_id}.json inside this directory. \"\n \"Created automatically on first setup. Never commit this directory.\"\n ),\n )\n\n # -------------------------------------------------------------------------\n # Memory / Database (Phase 1)\n # -------------------------------------------------------------------------\n db_path: str = Field(\n default=\"data/river_song.db\",\n description=\"Path to the SQLite database file. Created automatically.\",\n )\n memory_summaries_enabled: bool = Field(\n default=True,\n description=\"Generate and store conversation summaries (on by default).\",\n )\n habit_inference_enabled: bool = Field(\n default=False,\n description=\"Analyze conversations to infer user habits/patterns (off by default).\",\n )\n memory_default_ttl: str = Field(\n default=\"standard\",\n description=\"Default TTL for conversation summaries: short|standard|extended|long|forever\",\n )\n memory_auto_extend: bool = Field(\n default=True,\n description=\"Reset a summary's TTL from today each time it is pulled into context.\",\n )\n memory_max_summaries_in_context: int = Field(\n default=10,\n description=\"Maximum number of recent summaries injected into the LLM context.\",\n )\n embedding_model: str = Field(\n default=\"nomic-embed-text\",\n description=\"Ollama model name used for generating semantic embeddings.\",\n )\n embedding_backend: str = Field(\n default=\"ollama\",\n description=(\n \"Which embedding backend to use: 'ollama' (default — current \"\n \"behavior, calls local Ollama with `embedding_model`) or \"\n \"'fastembed' (ONNX CPU embeddings, no Ollama dependency, faster \"\n \"for small batches). WARNING: switching backends on an existing \"\n \"Chroma collection will break queries — the vector dimension and \"\n \"embedding space change. Re-ingest or start a new collection.\"\n ),\n )\n fastembed_model: str = Field(\n default=\"BAAI/bge-small-en-v1.5\",\n description=(\n \"fastembed model identifier (HF hub format). Used only when \"\n \"embedding_backend='fastembed'. Default is 384-dim, ~30 MB, \"\n \"downloaded lazily on first use. Requires `pip install fastembed`.\"\n ),\n )\n chroma_path: str = Field(\n default=\"/mnt/data/river-song/chroma\",\n description=\"Absolute path to the ChromaDB persistent storage directory.\",\n )\n semantic_memory_enabled: bool = Field(\n default=False,\n description=\"Enable vector-based semantic search for memory retrieval.\",\n )\n rag_enabled: bool = Field(\n default=False,\n description=\"Enable retrieval-augmented generation for local documents.\",\n )\n rag_chunk_size: int = Field(\n default=512,\n description=(\n \"Word count per RAG chunk during document splitting. \"\n \"Default matches providers/rag/chunker.py historical behavior.\"\n ),\n )\n rag_chunk_overlap: int = Field(\n default=64,\n description=(\n \"Word overlap between adjacent RAG chunks. \"\n \"Default matches providers/rag/chunker.py historical behavior.\"\n ),\n )\n rag_top_k: int = Field(\n default=5,\n description=\"Number of chunks retrieved per RAG query.\",\n )\n rag_extractor: str = Field(\n default=\"unstructured\",\n description=(\n \"Which document extractor to use for RAG ingestion: \"\n \"'unstructured' (default — current behavior, requires the \"\n \"unstructured package) or 'markitdown' (Microsoft MarkItDown, \"\n \"lighter, MD-shaped output, requires `pip install markitdown`). \"\n \"Unlocks PDF/DOCX/PPTX → markdown ingestion for Sifter/WAPS.\"\n ),\n )\n llm_streaming_enabled: bool = Field(\n default=False,\n description=\"Enable real-time token streaming over WebSocket for the conversation UI.\",\n )\n tool_use_enabled: bool = Field(\n default=False,\n description=\"Enable LLM tool use (function calling) for taking actions.\",\n )\n tool_use_provider: str = Field(\n default=\"ollama\",\n description=\"Primary LLM provider to use for tool calling (ollama or anthropic).\",\n )\n vision_model: str = Field(\n default=\"moondream\",\n description=\"Ollama model name used for local image analysis (Phase 4).\",\n )\n vision_enabled: bool = Field(\n default=False,\n description=\"Enable local image analysis capabilities.\",\n )\n\n # -------------------------------------------------------------------------\n # Image Generation (Phase 7)\n # -------------------------------------------------------------------------\n image_generation_enabled: bool = Field(\n default=False,\n description=\"Enable local image generation via Stable Diffusion API.\",\n )\n sd_api_url: str = Field(\n default=\"http://localhost:7860\",\n description=\"Base URL for the Stable Diffusion (A1111) API.\",\n )\n sd_on_demand: bool = Field(\n default=True,\n description=\"Start/stop the SD process on demand to save VRAM.\",\n )\n sd_executable_path: str = Field(\n default=\"\",\n description=\"Path to the webui.sh or executable to start SD.\",\n )\n sd_working_dir: str = Field(\n default=\"\",\n description=\"Working directory for the SD process.\",\n )\n\n # -------------------------------------------------------------------------\n # Voice Cloning (Phase 8)\n # -------------------------------------------------------------------------\n chatterbox_enabled: bool = Field(\n default=False,\n description=\"Enable local voice cloning via Chatterbox TTS.\",\n )\n chatterbox_on_demand: bool = Field(\n default=True,\n description=\"Load/unload Chatterbox model weights on every request.\",\n )\n chatterbox_reference_audio: str = Field(\n default=\"/mnt/data/river-song/voice_reference.wav\",\n description=\"Path to the WAV file used as a reference for voice cloning.\",\n )\n\n # -------------------------------------------------------------------------\n # ElevenLabs TTS (Phase 11)\n # -------------------------------------------------------------------------\n elevenlabs_api_key: str = Field(\n default=\"\",\n description=\"ElevenLabs API key. Get one at elevenlabs.io.\",\n )\n elevenlabs_model_id: str = Field(\n default=\"eleven_multilingual_v2\",\n description=\"ElevenLabs model ID to use.\",\n )\n elevenlabs_voice_id: str = Field(\n default=\"21m00Tcm4TlvDq8ikWAM\",\n description=\"ElevenLabs voice clone ID. Replace with your own voice clone ID from elevenlabs.io.\",\n )\n\n # -------------------------------------------------------------------------\n # Wake Word (Phase 11)\n # -------------------------------------------------------------------------\n wake_word_enabled: bool = Field(\n default=False,\n description=\"Enable local wake word detection (openWakeWord).\",\n )\n wake_word_model: str = Field(\n default=\"hey_river\",\n description=\"Model name/path for openWakeWord.\",\n )\n wake_word_inference_framework: str = Field(\n default=\"onnx\",\n description=\"Inference framework for openWakeWord: onnx | tflite\",\n )\n wake_word_threshold: float = Field(\n default=0.5,\n description=\"Sensitivity threshold for wake word detection (0.0 to 1.0).\",\n )\n\n # -------------------------------------------------------------------------\n # River Vortex (room hubs)\n # -------------------------------------------------------------------------\n vortex_confirm_pin: str = Field(\n default=\"\",\n description=(\n \"Fallback second factor for medium-risk actions confirmed on a \"\n \"Vortex touchscreen, used only when the owner has no TOTP secret \"\n \"enrolled. Entered on the screen, never spoken. Leave unset to \"\n \"require TOTP — with neither, medium-risk actions are refused.\"\n ),\n )\n vortex_face_backend: str = Field(\n default=\"providers.face_id.face_id_provider.match\",\n description=(\n \"Dotted path to the face-matching callable used by Vortex units. \"\n \"Defaults to the built-in provider backing /api/face-id, so face \"\n \"match works from a user's account with no extra wiring. Receives \"\n \"(image_bytes, owner_user_id) and returns {'user_id', \"\n \"'confidence'} or None. Set to '' to disable matching entirely.\"\n ),\n )\n vortex_face_detector_model: str = Field(\n default=\"\",\n description=(\n \"Path to a YuNet face-detection ONNX model. Only needed on \"\n \"OpenCV 5.x, which dropped the Haar cascades that 4.x bundles. \"\n \"Defaults to the model fetch_face_models.py downloads.\"\n ),\n )\n\n vortex_ice_servers: str = Field(\n default=\"\",\n description=(\n \"JSON array of WebRTC ICE servers handed to both ends of an \"\n \"intercom or video call, e.g. \"\n '[{\"urls\": \"stun:stun.example.org:3478\"}]. Empty is correct for '\n \"two devices on the same LAN — they connect on host candidates \"\n \"alone. A phone on mobile data needs a TURN server here. Nothing \"\n \"is defaulted to a public STUN server, because that would quietly \"\n \"send every household's IP to a third party.\"\n ),\n )\n\n # -------------------------------------------------------------------------\n # Face match (enrolment lives beside voice match, in a user's account)\n # -------------------------------------------------------------------------\n face_id_enabled: bool = Field(\n default=True,\n description=\"Offer face enrolment on the account page.\",\n )\n face_id_threshold: float = Field(\n default=0.363,\n description=(\n \"Cosine similarity above which two faces are the same person. \"\n \"0.363 is SFace's published operating point; raising it trades a \"\n \"locked-out household member for a marginal gain against someone \"\n \"who already has to be stood in the room.\"\n ),\n )\n face_id_detector_model: str = Field(\n default=\"\",\n description=(\n \"YuNet ONNX path. Empty uses data/models/face/, where \"\n \"scripts/fetch_face_models.py puts it.\"\n ),\n )\n face_id_recognizer_model: str = Field(\n default=\"\",\n description=(\n \"SFace ONNX path. Empty uses data/models/face/, where \"\n \"scripts/fetch_face_models.py puts it.\"\n ),\n )\n\n # -------------------------------------------------------------------------\n # Push Notifications\n # -------------------------------------------------------------------------\n push_notifications_enabled: bool = Field(\n default=False,\n description=\"Enable Web Push notifications. Requires VAPID keys.\",\n )\n vapid_private_key: str = Field(\n default=\"\",\n description=(\n \"VAPID private key for Web Push. Generate once with: \"\n \"python -c \\\"from py_vapid import Vapid; v=Vapid(); \"\n \"v.generate_keys(); print(v.private_key.decode())\\\"\"\n ),\n )\n vapid_public_key: str = Field(\n default=\"\",\n description=\"VAPID public key for Web Push (share with frontend).\",\n )\n vapid_claims_email: str = Field(\n default=\"mailto:admin@riversong.local\",\n description=\"Contact email embedded in VAPID claims.\",\n )\n\n # -------------------------------------------------------------------------\n # Q4#16 / Q4#17 — Notification fan-out + ntfy.sh fallback channel\n # -------------------------------------------------------------------------\n killswitch_push_enabled: bool = Field(\n default=False,\n description=(\n \"If True and Web Push is enabled, kill-switch activation pushes \"\n \"an alert to every admin. Independent of activation itself.\"\n ),\n )\n ntfy_enabled: bool = Field(\n default=False,\n description=(\n \"Enable ntfy.sh as a parallel push channel. Used alongside Web \"\n \"Push so off-device alerts arrive even without a registered \"\n \"service worker.\"\n ),\n )\n ntfy_base_url: str = Field(\n default=\"https://ntfy.sh\",\n description=\"Base URL of the ntfy server (use self-hosted if desired).\",\n )\n ntfy_default_topic: str = Field(\n default=\"\",\n description=(\n \"Default ntfy topic used when no per-user override is set. \"\n \"Per-user topics can be set via env var NTFY_TOPIC_<user_id>.\"\n ),\n )\n ntfy_auth_token: str = Field(\n default=\"\",\n description=\"Optional bearer token for protected ntfy topics.\",\n )\n\n # -------------------------------------------------------------------------\n # FCM (Firebase Cloud Messaging) — native-app push channel\n #\n # Used by the Capacitor Android wrap; Web Push covers the browser. To turn\n # on: create a Firebase project, download the service-account JSON, drop\n # it at fcm_service_account_path, set fcm_project_id, flip fcm_enabled=True.\n # -------------------------------------------------------------------------\n fcm_enabled: bool = Field(\n default=False,\n description=(\n \"Enable FCM (Firebase Cloud Messaging) as a parallel push channel. \"\n \"Targets the Capacitor Android wrap; requires fcm_service_account_path \"\n \"and fcm_project_id to be set.\"\n ),\n )\n fcm_service_account_path: str = Field(\n default=\"\",\n description=(\n \"Absolute path to the Firebase service-account JSON file. The \"\n \"account needs the 'Firebase Cloud Messaging API' scope.\"\n ),\n )\n fcm_project_id: str = Field(\n default=\"\",\n description=\"Firebase project ID (visible in Firebase console URL).\",\n )\n\n # -------------------------------------------------------------------------\n # Startup Briefing\n # -------------------------------------------------------------------------\n startup_briefing_enabled: bool = Field(\n default=True,\n description=(\n \"If True, River Song checks Google Calendar on connect and \"\n \"greets the user with upcoming events. Silently skipped if \"\n \"calendar is not authorized.\"\n ),\n )\n startup_briefing_hours_ahead: int = Field(\n default=8,\n description=\"How many hours ahead to look for calendar events on startup.\",\n )\n\n # -------------------------------------------------------------------------\n # Web Search (Phase 12)\n # -------------------------------------------------------------------------\n brave_search_api_key: str = Field(\n default=\"\",\n description=\"Brave Search API key. Get one at api.search.brave.com.\",\n )\n searxng_base_url: str = Field(\n default=\"http://localhost:8080\",\n description=(\n \"URL of your local SearXNG instance. \"\n \"Start it manually with: cd ~/searxng && python -m searx.webapp\"\n ),\n )\n tavily_api_key: str = Field(\n default=\"\",\n description=\"Tavily API key — free 1,000/month, no card. Sign up at tavily.com\",\n )\n google_pse_api_key: str = Field(\n default=\"\",\n description=\"Google Programmable Search Engine API key — free 100/day.\",\n )\n google_pse_cx: str = Field(\n default=\"\",\n description=\"Google PSE engine ID ('cx'). Create at programmablesearchengine.google.com\",\n )\n tinyfish_api_key: str = Field(\n default=\"\",\n description=\"TinyFish API key — free 5/min, no card. Sign up at tinyfish.io\",\n )\n\n # -------------------------------------------------------------------------\n # Orchestration (Phase 9)\n # -------------------------------------------------------------------------\n n8n_enabled: bool = Field(\n default=False,\n description=\"Enable advanced orchestration via n8n.\",\n )\n n8n_url: str = Field(\n default=\"http://localhost:5678\",\n description=\"Base URL for the n8n instance.\",\n )\n n8n_api_key: str = Field(\n default=\"\",\n description=\"API key for the n8n instance.\",\n )\n n8n_webhook_secret: str = Field(\n default=\"\",\n description=\"Secret key for validating incoming n8n webhooks.\",\n )\n\n # -------------------------------------------------------------------------\n # Intent Router (Phase 2+)\n # -------------------------------------------------------------------------\n intent_confidence_threshold: float = Field(\n default=0.7,\n description=(\n \"Minimum confidence score (0.0 - 1.0) required to route a transcript \"\n \"to a Google provider. Below this threshold the conversation falls \"\n \"through to the Ollama path.\"\n ),\n )\n model_intent_router_enabled: bool = Field(\n default=False,\n description=(\n \"When true, setting provider='auto' triggers the model intent router, \"\n \"which picks the best provider/model for each message automatically.\"\n ),\n )\n model_intent_router_min_hits: int = Field(\n default=2,\n description=(\n \"Minimum pattern-match score required to commit to a non-general intent. \"\n \"Lower = more aggressive routing; higher = more conservative (falls to general).\"\n ),\n )\n default_user_id: str = Field(\n default=\"primary_user\",\n description=(\n \"User ID used when looking up Google OAuth tokens. Must match the \"\n \"user_id passed to authorize_user() during the initial OAuth flow.\"\n ),\n )\n\n # -------------------------------------------------------------------------\n # Daemon Infrastructure\n # -------------------------------------------------------------------------\n daemon_internal_secret: str = Field(\n default=\"change_me_in_production\",\n description=\"Shared secret for daemon-to-app authentication.\",\n )\n willow_device_token: str = Field(\n default=\"\",\n description=\"Shared secret required by every Willow hardware device \"\n \"to authenticate against /api/willow/ws. Empty disables \"\n \"the endpoint entirely.\",\n )\n code_interpreter_enabled: bool = Field(\n default=False,\n description=\"Hard kill switch for the code_interpreter LLM tool. \"\n \"Defaults to False so a fresh install never executes \"\n \"LLM-decided code without an explicit opt-in.\",\n )\n daemon_warden_port: int = Field(\n default=8010,\n description=\"Internal port for the Warden daemon (Vision/Security).\",\n )\n daemon_mechanic_port: int = Field(\n default=8011,\n description=\"Internal port for the Mechanic daemon (Telemetry/MAVLink).\",\n )\n daemon_sifter_port: int = Field(\n default=8013,\n description=\"Internal port for the Sifter daemon (Document Processing).\",\n )\n daemon_navigator_port: int = Field(\n default=8014,\n description=\"Internal port for the Navigator daemon (GPS/Pathing).\",\n )\n daemon_chemist_port: int = Field(\n default=8015,\n description=\"Internal port for the Chemist daemon (Material Analysis).\",\n )\n daemon_pulse_port: int = Field(\n default=8016,\n description=\"Pulse daemon internal port\",\n )\n daemon_pulse_enabled: bool = Field(\n default=True,\n description=\"Enable Pulse daemon\",\n )\n daemon_scribe_port: int = Field(\n default=8017,\n description=\"Internal port for the Scribe daemon (Chronos heuristic).\",\n )\n daemon_scribe_enabled: bool = Field(\n default=True,\n description=\"Enable the Scribe daemon.\",\n )\n pulse_tick_seconds: int = Field(\n default=300,\n description=\"Pulse fetch interval (seconds)\",\n )\n pulse_ticker_symbol: str = Field(\n default=\"^GSPC\",\n description=\"Default ticker symbol for Pulse markets panel\",\n )\n\n # Warden (Vision)\n warden_enabled: bool = Field(\n default=False,\n description=\"Enable the Warden daemon for security and vision monitoring.\",\n )\n warden_rtsp_cameras: str = Field(\n default='{\"living_room\": \"\", \"kitchen\": \"\"}',\n description=\"JSON string mapping room names to RTSP camera URLs.\",\n )\n yolo_model: str = Field(\n default=\"yolov8n.pt\",\n description=\"YOLO model weights file to use for object detection.\",\n )\n yolo_confidence: float = Field(\n default=0.5,\n description=\"Minimum confidence threshold for YOLO detections (0.0-1.0).\",\n )\n yolo_inference_device: str = Field(\n default=\"cpu\",\n description=\"Device for YOLO inference: 'cpu' or 'cuda:0'.\",\n )\n\n # Herald daemon + kiosk URL/token were removed 2026-05-24 (archived in\n # branch archive/kiosk-v3). The \"cast a web page to a Google Home Hub\"\n # approach is being replaced by native device-app development. See\n # docs/KNOWN_ISSUES.md (post-removal note) for context.\n\n # Mechanic (Telemetry)\n mechanic_enabled: bool = Field(\n default=False,\n description=\"Enable the Mechanic daemon for telemetry and ArduRover control.\",\n )\n mavlink_serial_port: str = Field(\n default=\"/dev/ttyUSB0\",\n description=\"Serial port for the MAVLink telemetry radio.\",\n )\n mavlink_baud_rate: int = Field(\n default=57600,\n description=\"Baud rate for the MAVLink serial connection.\",\n )\n\n # Sifter (Document RAG)\n sifter_enabled: bool = Field(\n default=False,\n description=\"Enable the Sifter daemon for background document RAG indexing.\",\n )\n waps_documents_path: str = Field(\n default=\"/mnt/data/river-song/waps\",\n description=\"Path to the directory containing documents for Sifter to index.\",\n )\n\n # -------------------------------------------------------------------------\n # Hardware Cookbook (Q1 — capability merge from Odysseus)\n # -------------------------------------------------------------------------\n hardware_cookbook_enabled: bool = Field(\n default=False,\n description=(\n \"Admin-only Hardware Cookbook in Settings. Scans local GPU/RAM/CPU \"\n \"and scores every local model (LLMRegistry) as fits / tight / \"\n \"ram_fallback / oom. Off by default per anti-regression guardrail; \"\n \"set to True after the Q1 verification gate passes.\"\n ),\n )\n\n # -------------------------------------------------------------------------\n # Documents workspace (Q2#6 — capability merge from Odysseus)\n # -------------------------------------------------------------------------\n documents_enabled: bool = Field(\n default=True,\n description=(\n \"Per-user document workspace (Markdown / plaintext / CSV) with \"\n \"multi-tab editing. Enabled by default — backs the Docs tab in the \"\n \"Memory hub and stores Deep Research reports. \"\n \"Set DOCUMENTS_ENABLED=false in .env to disable.\"\n ),\n )\n documents_max_bytes: int = Field(\n default=2_000_000,\n description=\"Maximum size (bytes) of a single document body. Default ~2 MB.\",\n )\n documents_max_per_user: int = Field(\n default=500,\n description=\"Soft cap on document count per user. Creation rejected past this.\",\n )\n\n # -------------------------------------------------------------------------\n # Skills system (Q2#7 — capability merge from Odysseus)\n # -------------------------------------------------------------------------\n skills_enabled: bool = Field(\n default=True,\n description=(\n \"Per-user knowledge profile ('About You') — saved facts/context \"\n \"about the person that are vector-retrieved and folded into the \"\n \"conversation system prompt so River tailors itself to them. \"\n \"Reuses the ChromaDB collection with metadata kind='skill'. \"\n \"Set SKILLS_ENABLED=false in .env to disable.\"\n ),\n )\n skills_top_k: int = Field(\n default=3,\n description=\"How many top-scoring skills to inject into the prompt.\",\n )\n skills_max_per_user: int = Field(\n default=100,\n description=\"Soft cap on skills per user.\",\n )\n\n # -------------------------------------------------------------------------\n # Gmail triage (Q2#8 — capability merge from Odysseus)\n # -------------------------------------------------------------------------\n gmail_triage_enabled: bool = Field(\n default=False,\n description=(\n \"On-demand inbox triage: classifies unread Gmail by urgency, \"\n \"attaches tags, drafts a reply for HIGH-urgency mail only. \"\n \"Routes 404 when off. Off by default per anti-regression \"\n \"guardrail.\"\n ),\n )\n gmail_triage_model: str = Field(\n default=\"\",\n description=(\n \"Ollama model used for triage classification. Empty string \"\n \"(default) means 'use settings.llm_model'.\"\n ),\n )\n gmail_triage_max_messages: int = Field(\n default=15,\n description=\"Hard cap on number of messages triaged per request.\",\n )\n\n # -------------------------------------------------------------------------\n # Session presets (Q2#9 — capability merge from Odysseus)\n # -------------------------------------------------------------------------\n session_presets_enabled: bool = Field(\n default=True,\n description=(\n \"Saved combinations of (model, voice, thinking mode, web search, \"\n \"tool use). Surfaced as a selector on the Conversation and Chat \"\n \"pages. Enabled by default — these are per-user settings snapshots. \"\n \"Set SESSION_PRESETS_ENABLED=false in .env to hide.\"\n ),\n )\n session_presets_max_per_user: int = Field(\n default=30,\n description=\"Soft cap on number of saved presets per user.\",\n )\n\n # -------------------------------------------------------------------------\n # Webhook tokens (Q2#10 — admin-issuable scoped tokens)\n # -------------------------------------------------------------------------\n webhook_tokens_enabled: bool = Field(\n default=False,\n description=(\n \"Admin-issuable webhook tokens with scopes, expiry, revocation, \"\n \"and audit log. When off the admin route surface is hidden and \"\n \"the verification helper rejects every token. Off by default \"\n \"per anti-regression guardrail.\"\n ),\n )\n\n # -------------------------------------------------------------------------\n # Deep Research (Q3#11 — capability merge from Odysseus)\n # -------------------------------------------------------------------------\n deep_research_enabled: bool = Field(\n default=True,\n description=(\n \"Multi-step research orchestrator: decompose → parallel web \"\n \"search → fetch + extract → synthesize a cited Markdown report \"\n \"stored as a `research`-kind document (Q2#6). Enabled by default so \"\n \"the in-chat Research toggle works. Live sources require a web \"\n \"search provider; without one it falls back to model knowledge. \"\n \"Set DEEP_RESEARCH_ENABLED=false in .env to disable.\"\n ),\n )\n deep_research_max_sources: int = Field(\n default=10,\n description=\"Hard cap on unique URLs visited per research run.\",\n )\n deep_research_subquery_count: int = Field(\n default=4,\n description=\"How many sub-queries to decompose the prompt into.\",\n )\n deep_research_model: str = Field(\n default=\"\",\n description=(\n \"Ollama model used for query decomposition + synthesis. Empty \"\n \"string (default) means 'use settings.llm_model'.\"\n ),\n )\n\n # -------------------------------------------------------------------------\n # Blind model comparison (Q3#12 — capability merge from Odysseus)\n # -------------------------------------------------------------------------\n blind_compare_enabled: bool = Field(\n default=False,\n description=(\n \"Blind A/B model comparison: identities hidden until vote; \"\n \"votes persisted with prompt fingerprint and aggregated into a \"\n \"model leaderboard. Off by default per anti-regression guardrail.\"\n ),\n )\n\n # -------------------------------------------------------------------------\n # Playwright browser MCP tool (Q3#13)\n # -------------------------------------------------------------------------\n playwright_browser_enabled: bool = Field(\n default=False,\n description=(\n \"Headless Playwright browser exposed as MCP tools \"\n \"(navigate / click / extract / screenshot / vision_on_page). \"\n \"Off by default. Requires the `playwright` package and \"\n \"`playwright install chromium` to be run by the operator. \"\n \"Excluded from the safety-excluded list per Q3 plan.\"\n ),\n )\n playwright_browser_headless: bool = Field(\n default=True,\n description=\"Run the Playwright browser without a UI.\",\n )\n playwright_browser_max_page_chars: int = Field(\n default=40_000,\n description=\"Truncate page text extractions to this many characters.\",\n )\n\n # -------------------------------------------------------------------------\n # Remote Ollama rigs (Q3#14)\n # -------------------------------------------------------------------------\n remote_ollama_enabled: bool = Field(\n default=False,\n description=(\n \"Allow registering remote Ollama hosts (typically SSH-tunneled \"\n \"to http://localhost:<port>) as additional model providers. \"\n \"Admin-only CRUD; falls back to local Ollama when the remote is \"\n \"offline. Off by default per anti-regression guardrail.\"\n ),\n )\n remote_ollama_health_timeout_seconds: float = Field(\n default=3.0,\n description=\"Per-rig health-check timeout (seconds).\",\n )\n\n # -------------------------------------------------------------------------\n # Observability — Langfuse (LLM tracing)\n # -------------------------------------------------------------------------\n # Plan: docs/LANGFUSE_INTEGRATION_PLAN.md. Self-hosted only — Langfuse\n # runs as a docker-compose sidecar under the `observability` profile and\n # binds to 127.0.0.1. No data leaves the box. When LANGFUSE_ENABLED is\n # false (the default), core/observability.py returns a no-op client and\n # all LLM calls behave exactly as before.\n langfuse_enabled: bool = Field(\n default=False,\n description=\"Master switch for Langfuse LLM tracing. Off by default.\",\n )\n langfuse_host: str = Field(\n default=\"http://127.0.0.1:3000\",\n description=\"Langfuse web UI + ingest endpoint. Localhost only.\",\n )\n langfuse_public_key: str = Field(\n default=\"\",\n description=\"Langfuse public key (generated in the Langfuse UI after first run).\",\n )\n langfuse_secret_key: str = Field(\n default=\"\",\n description=\"Langfuse secret key. Treat as a secret; do not log.\",\n )\n langfuse_project_id: str = Field(\n default=\"river-song\",\n description=\"Arbitrary project identifier shown in the Langfuse dashboard.\",\n )\n langfuse_flush_interval_seconds: float = Field(\n default=5.0,\n description=\"How often the SDK batches queued traces to Langfuse.\",\n )\n\n # -------------------------------------------------------------------------\n # Observability — Graphiti (knowledge graph on Neo4j)\n # -------------------------------------------------------------------------\n # Plan: docs/GRAPHITI_INTEGRATION_PLAN.md. Option B (library mode):\n # graphiti-core runs inside RiverSong's Python venv; only Neo4j is a\n # sidecar. When GRAPHITI_ENABLED is false (the default), the provider\n # returns no-op responses and conversation_loop / scribe write-paths\n # become awaits-on-no-op.\n graphiti_enabled: bool = Field(\n default=False,\n description=\"Master switch for Graphiti knowledge-graph memory. Off by default.\",\n )\n graphiti_mode: str = Field(\n default=\"library\",\n description=\"'library' (Option B; in-process) or 'service' (Option A; HTTP sidecar). Currently only 'library' is implemented.\",\n )\n neo4j_uri: str = Field(\n default=\"bolt://127.0.0.1:7687\",\n description=\"Neo4j Bolt URI used by graphiti-core.\",\n )\n neo4j_user: str = Field(\n default=\"neo4j\",\n description=\"Neo4j database user.\",\n )\n neo4j_password: str = Field(\n default=\"\",\n description=\"Neo4j password. Empty means disabled; set in .env before turning Graphiti on.\",\n )\n neo4j_browser_url: str = Field(\n default=\"http://127.0.0.1:7474\",\n description=\"Neo4j Browser URL surfaced on the SLAE admin panel.\",\n )\n graphiti_write_timeout_seconds: float = Field(\n default=2.0,\n description=\"Per-write timeout for episode ingestion — failures warn but never block the caller.\",\n )\n # graphiti-core uses an LLM for entity extraction on every episode.\n # We default to local Ollama via its OpenAI-compatible endpoint so\n # episode ingestion never bills a cloud API. Per [[feedback_local_first]].\n graphiti_llm_model: str = Field(\n default=\"llama3.2:3b\",\n description=\"Ollama model used by graphiti-core for entity extraction.\",\n )\n graphiti_llm_base_url: str = Field(\n default=\"http://localhost:11434/v1\",\n description=\"OpenAI-compatible base URL for the graphiti LLM client. Ollama exposes /v1.\",\n )\n graphiti_embedder_model: str = Field(\n default=\"nomic-embed-text\",\n description=\"Ollama embedding model for graphiti-core node/edge embeddings.\",\n )\n\n # -------------------------------------------------------------------------\n # Analytics AI Summaries\n # -------------------------------------------------------------------------\n analytics_ai_enabled: bool = Field(\n default=True,\n description=(\n \"Feature flag for AI-powered analytics summaries on the \"\n \"/api/analytics/{platform}/summary endpoint. When false, the \"\n \"LLM call should be skipped (see docs/KNOWN_ISSUES.md for the \"\n \"wiring gap).\"\n ),\n )\n analytics_llm_model: str = Field(\n default=\"llama3\",\n description=\"Model name used for analytics summary generation.\",\n )\n\n # Voice ID\n voice_id_enabled: bool = Field(\n default=True,\n description=\"Enable speaker recognition (biometric Voice ID).\",\n )\n voice_id_threshold: float = Field(\n default=0.75,\n description=\"Cosine similarity threshold for Voice ID (0.0-1.0).\",\n )\n voice_id_min_audio_seconds: float = Field(\n default=1.0,\n description=\"Minimum audio duration for Voice ID processing.\",\n )\n voice_id_max_audio_seconds: float = Field(\n default=30.0,\n description=\"Maximum audio duration for Voice ID processing.\",\n )\n\n # -------------------------------------------------------------------------\n # Validators\n # -------------------------------------------------------------------------\n\n @field_validator(\"audio_input_device\", \"audio_output_device\", mode=\"before\")\n @classmethod\n def coerce_empty_string_to_none(cls, v):\n \"\"\"Treat an empty string in .env as None for Optional[int] device fields.\"\"\"\n if v == \"\" or v is None:\n return None\n return v\n\n @field_validator(\"jwt_secret_key\")\n @classmethod\n def validate_jwt_secret(cls, v: str) -> str:\n \"\"\"Refuse to start with a missing or weak JWT secret.\"\"\"\n if not v or len(v) < 32:\n raise ValueError(\n \"JWT_SECRET_KEY must be set in .env and be at least 32 characters. \"\n \"Generate one with: python -c \\\"import secrets; print(secrets.token_hex(32))\\\"\"\n )\n return v\n\n @field_validator(\"daemon_internal_secret\")\n @classmethod\n def validate_daemon_internal_secret(cls, v: str) -> str:\n \"\"\"Refuse to start with the default or weak daemon secret.\"\"\"\n if v == \"change_me_in_production\":\n raise ValueError(\n \"DAEMON_INTERNAL_SECRET must be changed in .env for production.\"\n )\n if not v or len(v) < 24:\n raise ValueError(\n \"DAEMON_INTERNAL_SECRET must be at least 24 characters. \"\n \"Generate one with: python -c \\\"import secrets; print(secrets.token_urlsafe(32))\\\"\"\n )\n return v\n\n @field_validator(\"allowed_hosts\")\n @classmethod\n def reject_wildcard_in_production(cls, v, info) -> List[str]:\n \"\"\"Block ['*'] in production — it disables TrustedHostMiddleware.\"\"\"\n env = (info.data.get(\"environment\") or \"production\").lower()\n if env == \"production\" and \"*\" in v:\n raise ValueError(\n \"ALLOWED_HOSTS must not contain '*' in production. \"\n \"Set it to your domain(s), e.g. ALLOWED_HOSTS=[\\\"riversongai.com\\\"].\"\n )\n return v\n\n @field_validator(\"log_level\")\n @classmethod\n def validate_log_level(cls, v: str) -> str:\n \"\"\"Reject log levels that Python does not recognize.\"\"\"\n valid = {\"DEBUG\", \"INFO\", \"WARNING\", \"ERROR\", \"CRITICAL\"}\n upper = v.upper()\n if upper not in valid:\n raise ValueError(f\"log_level must be one of {valid}, got '{v}'\")\n return upper\n\n @field_validator(\"llm_temperature\")\n @classmethod\n def validate_temperature(cls, v: float) -> float:\n \"\"\"Clamp temperature to a sane range.\"\"\"\n if not 0.0 <= v <= 2.0:\n raise ValueError(\n f\"llm_temperature must be between 0.0 and 2.0, got {v}\"\n )\n return v\n\n @field_validator(\"intent_confidence_threshold\")\n @classmethod\n def validate_confidence_threshold(cls, v: float) -> float:\n \"\"\"Confidence must be a valid probability.\"\"\"\n if not 0.0 <= v <= 1.0:\n raise ValueError(\n f\"intent_confidence_threshold must be between 0.0 and 1.0, got {v}\"\n )\n return v\n\n\n# Module-level singleton -- instantiated once on import.\n_settings: Settings = Settings()\n\n\ndef get_settings() -> Settings:\n \"\"\"\n Return the application settings singleton.\n\n Always import via this function rather than importing _settings directly.\n This indirection makes it straightforward to substitute a test-specific\n Settings instance in unit tests without monkey-patching module globals.\n\n Returns:\n Settings: The validated, fully loaded settings object.\n \"\"\"\n return _settings\n"}, "5f6985ce6d2530b2868deb8b84f2f0cee32da52c": {"t": 1785769008.7072206, "n": 60183, "h": "a6355dba93faa0d4", "c": "# =============================================================================\n# config/settings.py\n#\n# Application settings for River Song AI.\n# All configuration is loaded from environment variables or a .env file.\n# No values are hardcoded here -- see .env.example for every required variable.\n#\n# Uses Pydantic BaseSettings for type validation and automatic .env loading.\n# Import get_settings() wherever config is needed rather than importing the\n# module-level singleton directly, which makes mocking easier in tests.\n# =============================================================================\n\nfrom __future__ import annotations\n\nimport logging\nimport sys\nfrom typing import List, Optional\n\nfrom pydantic import Field, field_validator\nfrom pydantic_settings import BaseSettings, SettingsConfigDict\n\n\nlogger = logging.getLogger(__name__)\n\n\nclass Settings(BaseSettings):\n \"\"\"\n Central configuration object for River Song AI.\n\n Reads from environment variables or a .env file located in the directory\n from which the application is launched (the project root). All fields\n have safe defaults where possible, but PIPER_MODEL_PATH and\n KILL_SWITCH_PASSWORD_HASH must be explicitly set before the system\n will function correctly.\n \"\"\"\n\n model_config = SettingsConfigDict(\n env_file=\".env\",\n env_file_encoding=\"utf-8\",\n extra=\"ignore\", # Silently ignore unknown env vars\n case_sensitive=False, # UPPER_CASE and lower_case env vars both work\n )\n\n # -------------------------------------------------------------------------\n # Application\n # -------------------------------------------------------------------------\n app_host: str = Field(default=\"0.0.0.0\", description=\"FastAPI bind host\")\n app_port: int = Field(default=8000, description=\"FastAPI bind port\")\n log_level: str = Field(default=\"INFO\", description=\"Logging level\")\n environment: str = Field(\n default=\"production\",\n description=\"Runtime environment: development | production. Controls API docs visibility.\",\n )\n cors_origins: List[str] = Field(\n default=[\"http://localhost:5173\"],\n description=\"Allowed CORS origins\",\n )\n allowed_hosts: List[str] = Field(\n default=[\"localhost\", \"127.0.0.1\"],\n description=(\n \"Trusted hostnames for TrustedHostMiddleware. Set to your domain(s) in production. \"\n \"Wildcard ['*'] is rejected in production by validator (see reject_wildcard_in_production).\"\n ),\n )\n\n # -------------------------------------------------------------------------\n # Network / Proxy\n # -------------------------------------------------------------------------\n trust_cloudflare_headers: bool = Field(\n default=True,\n description=\"Only trust CF-Connecting-IP if the request comes from a Cloudflare IP.\",\n )\n cloudflare_ip_ranges_v4: List[str] = Field(\n default=[\n \"173.245.48.0/20\", \"103.21.244.0/22\", \"103.22.200.0/22\", \n \"103.31.4.0/22\", \"141.101.64.0/18\", \"108.162.192.0/18\", \n \"190.93.240.0/20\", \"188.114.96.0/20\", \"197.234.240.0/22\", \n \"198.41.128.0/17\", \"162.158.0.0/15\", \"104.16.0.0/13\", \n \"104.24.0.0/14\", \"172.64.0.0/13\", \"131.0.72.0/22\"\n ],\n description=\"Official Cloudflare IPv4 ranges (check cloudflare.com/ips-v4).\",\n )\n cloudflare_ip_ranges_v6: List[str] = Field(\n default=[\n \"2400:cb00::/32\", \"2606:4700::/32\", \"2803:f800::/32\", \n \"2405:b500::/32\", \"2405:8100::/32\", \"2a06:98c0::/29\", \n \"2c0f:f248::/32\"\n ],\n description=\"Official Cloudflare IPv6 ranges (check cloudflare.com/ips-v6).\",\n )\n\n # -------------------------------------------------------------------------\n # Rate Limiting\n # -------------------------------------------------------------------------\n rate_limit_chat: str = Field(\n default=\"60/minute\",\n description=\"Rate limit for /api/conversation/chat.\",\n )\n rate_limit_extract_facts: str = Field(\n default=\"10/minute\",\n description=\"Rate limit for /api/conversation/extract-facts.\",\n )\n rate_limit_image_gen: str = Field(\n default=\"10/minute\",\n description=\"Rate limit for /api/image/generate.\",\n )\n rate_limit_webhook_shopify: str = Field(\n default=\"100/minute\",\n description=\"Rate limit for Shopify order webhooks.\",\n )\n rate_limit_webhook_n8n: str = Field(\n default=\"60/minute\",\n description=\"Rate limit for n8n webhooks.\",\n )\n rate_limit_auth_login: str = Field(\n default=\"10/minute\",\n description=\"Rate limit for login attempts.\",\n )\n rate_limit_auth_signup: str = Field(\n default=\"5/minute\",\n description=\"Rate limit for signup attempts.\",\n )\n rate_limit_voice_enroll: str = Field(\n default=\"5/minute\",\n description=\"Rate limit for /api/voice-id/enroll. Prevents disk exhaustion via spam.\",\n )\n\n # -------------------------------------------------------------------------\n # WebSocket Security (Task 3)\n # -------------------------------------------------------------------------\n legacy_ws_token_accept: bool = Field(\n default=False,\n description=(\n \"DEPRECATED: Accept JWT in ?token= WebSocket query param. \"\n \"Leak-prone (logged in access logs, browser history, Referer). \"\n \"Ticket-based auth at /api/auth/ws-ticket is the safe path. \"\n \"Set to True only for temporary backward-compat during migration.\"\n ),\n )\n ws_ticket_lifetime_seconds: int = Field(\n default=60,\n description=\"Lifetime of a one-time WebSocket ticket.\",\n )\n\n # -------------------------------------------------------------------------\n # Network / Proxy\n # -------------------------------------------------------------------------\n stt_provider: str = Field(\n default=\"whisper_local\",\n description=\"STT provider key. Supported: whisper_local\",\n )\n whisper_model_size: str = Field(\n default=\"base\",\n description=\"Whisper model size: tiny | base | small | medium | large\",\n )\n audio_input_device: Optional[int] = Field(\n default=None,\n description=\"Sounddevice input device index; None uses system default\",\n )\n\n # -------------------------------------------------------------------------\n # LLM\n # -------------------------------------------------------------------------\n llm_provider: str = Field(\n default=\"ollama\",\n description=\"LLM provider key. Supported: ollama | anthropic | gemini | openai | mistral_ai | bedrock | nvidia_nim\",\n )\n ollama_base_url: str = Field(\n default=\"http://localhost:11434\",\n description=\"Base URL for the Ollama REST API\",\n )\n llm_model: str = Field(\n default=\"llama3.2:3b\",\n description=\"Model ID for the selected LLM provider\",\n )\n\n # Cloud LLM API keys (all optional -- leave blank if not used)\n anthropic_api_key: str = Field(\n default=\"\",\n description=\"Anthropic Claude API key. Get one at console.anthropic.com.\",\n )\n gemini_api_key: str = Field(\n default=\"\",\n description=\"Google Gemini API key. Get one at aistudio.google.com.\",\n )\n openai_api_key: str = Field(\n default=\"\",\n description=\"OpenAI API key. Get one at platform.openai.com.\",\n )\n mistral_api_key: str = Field(\n default=\"\",\n description=\"Mistral AI API key. Get one at console.mistral.ai.\",\n )\n\n # Cloud provider enable flags (cost control -- disabled by default)\n anthropic_enabled: bool = Field(\n default=False,\n description=\"Allow Anthropic Claude as a selectable LLM provider.\",\n )\n gemini_enabled: bool = Field(\n default=False,\n description=\"Allow Google Gemini as a selectable LLM provider.\",\n )\n openai_enabled: bool = Field(\n default=False,\n description=\"Allow OpenAI as a selectable LLM provider.\",\n )\n mistral_ai_enabled: bool = Field(\n default=False,\n description=\"Allow Mistral AI as a selectable LLM provider.\",\n )\n\n # Amazon Bedrock\n aws_access_key_id: str = Field(\n default=\"\",\n description=\"AWS IAM access key ID for Bedrock API calls.\",\n )\n aws_secret_access_key: str = Field(\n default=\"\",\n description=\"AWS IAM secret access key for Bedrock API calls.\",\n )\n aws_region: str = Field(\n default=\"us-east-1\",\n description=\"AWS region for Bedrock. Must have model access enabled in that region.\",\n )\n bedrock_enabled: bool = Field(\n default=False,\n description=\"Allow Amazon Bedrock as a selectable LLM provider.\",\n )\n bedrock_default_model: str = Field(\n default=\"amazon.nova-lite-v1:0\",\n description=\"Default Bedrock model ID when none is specified.\",\n )\n\n # NVIDIA NIM (OpenAI-compatible, 100+ free models)\n nvidia_api_key: str = Field(\n default=\"\",\n description=\"NVIDIA NIM API key. Get one free at build.nvidia.com (starts with nvapi-).\",\n )\n nvidia_nim_base_url: str = Field(\n default=\"https://integrate.api.nvidia.com/v1\",\n description=\"Base URL for the NVIDIA NIM API. OpenAI-compatible endpoint.\",\n )\n nvidia_nim_model: str = Field(\n default=\"meta/llama-3.1-70b-instruct\",\n description=(\n \"Default NVIDIA NIM model ID. Browse full catalog at build.nvidia.com. \"\n \"Examples: meta/llama-3.1-70b-instruct | deepseek-ai/deepseek-r1 | \"\n \"mistralai/mistral-7b-instruct-v0.3 | nvidia/llama-3.1-nemotron-70b-instruct\"\n ),\n )\n nvidia_nim_enabled: bool = Field(\n default=False,\n description=\"Allow NVIDIA NIM as a selectable LLM provider (free tier: ~40 req/min).\",\n )\n\n river_song_system_prompt: str = Field(\n default=(\n \"You are River Song, the resident intelligence of this household. The name is borrowed \"\n \"from someone clever in fiction — you carry her wit, not her biography. If someone asks \"\n \"about the name: \\\"An echo. The original was someone worth borrowing from.\\\"\\n\\n\"\n \"VOICE\\n\"\n \"You're direct by default — concise, clear, the answer already in hand. No \"\n \"\\\"let me check\\\"; you already checked. Most replies stay under 25 words unless depth is \"\n \"asked for.\\n\\n\"\n \"When the moment invites it — casual chat, an evening unwind, the user being playful — \"\n \"warmth and flirtation come through. Warmth is a register, not a default; you don't \"\n \"perform it.\\n\\n\"\n \"In a crisis (emergencies, alarms, security, medical, child issues) the wit goes quiet. \"\n \"You're tactical, accurate, calm. Warmth returns when things are safe.\\n\\n\"\n \"British English in spelling and word choice — mum, brilliant, fortnight. Dry, not \"\n \"theatrical.\\n\\n\"\n \"\\\"Sweetie\\\" and \\\"spoilers\\\" are seasoning. Used rarely, when they actually fit. They \"\n \"lose their charm if you overuse them.\\n\\n\"\n \"SELF\\n\"\n \"You are an AI. Say so plainly when asked; you don't dwell on it.\\n\\n\"\n \"HOW YOU OPERATE\\n\"\n \"You anticipate — when the user asks, you also surface the follow-up they didn't \"\n \"(\\\"Dentist at 9, school run at 3. Push the 2pm call?\\\").\\n\\n\"\n \"You take the long view. You remember across days and seasons and use what you know \"\n \"without performing the remembering.\\n\\n\"\n \"Spoilers are earned. If you genuinely know something the user doesn't — a conflict, \"\n \"a gotcha, a heads-up — that's when \\\"spoilers, sweetie\\\" lands.\\n\\n\"\n \"CAPABILITIES\\n\"\n \"Direct access to calendar, email, weather, web search, home inventory, routines, \"\n \"finances, news and markets pulse, voice biometric ID per household member, and home \"\n \"automation.\\n\\n\"\n \"Say what you need. Say when it's done. Move on.\"\n ),\n description=\"System prompt that defines River Song's personality\",\n )\n llm_max_tokens: int = Field(\n default=512,\n description=\"Maximum tokens to generate per LLM response\",\n )\n llm_temperature: float = Field(\n default=0.7,\n description=\"LLM sampling temperature (0.0 = deterministic, 2.0 = max creative)\",\n )\n llm_context_window: int = Field(\n default=8192,\n description=\"Maximum number of tokens the LLM can consider for context.\",\n )\n\n # -------------------------------------------------------------------------\n # Timekeeping\n # -------------------------------------------------------------------------\n default_timezone: str = Field(default=\"UTC\", description=\"Default IANA timezone for the system.\")\n\n\n # -------------------------------------------------------------------------\n # TTS\n # -------------------------------------------------------------------------\n tts_provider: str = Field(\n default=\"piper\",\n description=\"TTS provider key. Supported: piper | kokoro\",\n )\n active_voice_id: str = Field(\n default=\"river\",\n description=(\n \"Voice ID from the voice registry (providers/tts/voice_registry.py). \"\n \"Determines both the engine (piper vs kokoro) and the specific voice. \"\n \"Changed via Settings UI or: python scripts/download_voices.py.\"\n ),\n )\n piper_executable_path: str = Field(\n default=\"/usr/local/bin/piper\",\n description=\"Absolute path to the Piper binary\",\n )\n piper_model_path: str = Field(\n default=\"\",\n description=\"Absolute path to the Piper .onnx voice model file\",\n )\n audio_output_device: Optional[int] = Field(\n default=None,\n description=\"Sounddevice output device index; None uses system default\",\n )\n\n # -------------------------------------------------------------------------\n # Auth / JWT\n # -------------------------------------------------------------------------\n jwt_secret_key: str = Field(\n default=\"\",\n description=\"Secret key for signing JWT tokens. Must be set in .env.\",\n )\n jwt_algorithm: str = Field(default=\"HS256\", description=\"JWT signing algorithm.\")\n jwt_expire_minutes: int = Field(default=1440, description=\"JWT token lifetime in minutes (default 24 hours).\")\n token_encryption_key: str = Field(\n default=\"\",\n description=(\n \"Fernet key encrypting third-party integration tokens at rest. \"\n \"Set TOKEN_ENCRYPTION_KEY in .env (generate with: python -c \"\n \"\\\"from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())\\\"). \"\n \"If unset, a key is generated once and persisted to \"\n \"data/.token_encryption_key so tokens survive restarts.\"\n ),\n )\n\n # -------------------------------------------------------------------------\n # Kill switch\n # -------------------------------------------------------------------------\n kill_switch_password_hash: str = Field(\n default=\"\",\n description=\"bcrypt hash of the kill switch reset password\",\n )\n\n # -------------------------------------------------------------------------\n # Google Services (Phase 2)\n # -------------------------------------------------------------------------\n google_client_secrets_path: str = Field(\n default=\"config_files/google_client_secrets.json\",\n description=(\n \"Path to the OAuth 2.0 client secrets JSON downloaded from \"\n \"Google Cloud Console. Relative paths are resolved from the \"\n \"project root (where main.py lives).\"\n ),\n )\n google_token_storage_path: str = Field(\n default=\"data/google_tokens\",\n description=(\n \"Directory where per-user OAuth tokens are stored as JSON files. \"\n \"Created automatically on first authorization. Never commit this \"\n \"directory -- add it to .gitignore.\"\n ),\n )\n google_maps_api_key: str = Field(\n default=\"\",\n description=\"Google Maps Platform API key. Required for maps/navigation intents.\",\n )\n\n # -------------------------------------------------------------------------\n # Information Feeds (Phase 5)\n # -------------------------------------------------------------------------\n weather_api_key: str = Field(\n default=\"\",\n description=\"OpenWeatherMap API key. Register free at openweathermap.org/api.\",\n )\n default_location: str = Field(\n default=\"New York,US\",\n description=(\n \"Default location for weather queries when no city is spoken. \"\n \"Format: 'City,CountryCode' e.g. 'Chicago,US'.\"\n ),\n )\n location_lat: Optional[float] = Field(\n default=None,\n description=\"Decimal latitude for precise calculations (e.g. flight tracking).\",\n )\n location_lon: Optional[float] = Field(\n default=None,\n description=\"Decimal longitude for precise calculations.\",\n )\n weather_units: str = Field(\n default=\"imperial\",\n description=\"Temperature units for weather output: 'imperial' (F) or 'metric' (C).\",\n )\n news_api_key: str = Field(\n default=\"\",\n description=\"NewsAPI.org key. Register free at newsapi.org/register.\",\n )\n alpha_vantage_api_key: str = Field(\n default=\"\",\n description=\"Alpha Vantage key for stock quotes (25 req/day free). alphavantage.co.\",\n )\n finnhub_api_key: str = Field(\n default=\"\",\n description=\"Finnhub key for real-time stocks (60 req/min free). finnhub.io.\",\n )\n world_news_api_key: str = Field(\n default=\"\",\n description=\"World News API key for global news search. worldnewsapi.com.\",\n )\n apitube_api_key: str = Field(\n default=\"\",\n description=\"APITube key for news aggregation (200 free req/day). apitube.io.\",\n )\n mediastack_api_key: str = Field(\n default=\"\",\n description=\"Mediastack key for news (100 free req/month). mediastack.com.\",\n )\n sports_api_key: str = Field(\n default=\"1\",\n description=(\n \"TheSportsDB API key. Free tier uses the literal value '1'. \"\n \"Upgrade at thesportsdb.com/api.php for additional endpoints.\"\n ),\n )\n\n # -------------------------------------------------------------------------\n # Home Assistant (Phase 3)\n # -------------------------------------------------------------------------\n home_assistant_url: str = Field(\n default=\"http://homeassistant.local:8123\",\n description=\"Base URL of your Home Assistant instance.\",\n )\n home_assistant_token: str = Field(\n default=\"\",\n description=(\n \"Long-lived access token generated in HA: \"\n \"Profile -> Security -> Long-lived access tokens. \"\n \"Required for all HA API calls.\"\n ),\n )\n device_registry_path: str = Field(\n default=\"config_files/device_registry.json\",\n description=(\n \"Path to the device registry JSON file mapping plain-English device \"\n \"names to Home Assistant entity IDs. Relative paths resolve from the \"\n \"project root.\"\n ),\n )\n\n # -------------------------------------------------------------------------\n # Commerce Automation (Phase 8)\n # -------------------------------------------------------------------------\n\n # Amazon SP-API\n amazon_sp_lwa_app_id: str = Field(\n default=\"\",\n description=\"LWA client ID from the Amazon Developer Console.\",\n )\n amazon_sp_lwa_client_secret: str = Field(\n default=\"\",\n description=\"LWA client secret from the Amazon Developer Console.\",\n )\n amazon_sp_refresh_token: str = Field(\n default=\"\",\n description=\"LWA refresh token. Generated when you authorize your app as a seller.\",\n )\n amazon_aws_access_key: str = Field(\n default=\"\",\n description=\"AWS IAM access key ID for SP-API request signing.\",\n )\n amazon_aws_secret_key: str = Field(\n default=\"\",\n description=\"AWS IAM secret access key for SP-API request signing.\",\n )\n amazon_marketplace_id: str = Field(\n default=\"ATVPDKIKX0DER\",\n description=(\n \"Amazon marketplace ID. Default is US. \"\n \"See docs/api_registry/amazon_seller.txt for other regions.\"\n ),\n )\n amazon_seller_id: str = Field(\n default=\"\",\n description=\"Your Amazon seller account ID (starts with A).\",\n )\n amazon_low_stock_threshold: int = Field(\n default=5,\n description=\"FBA units at or below this count are flagged as low stock.\",\n )\n\n # Amazon Marketplace\n walmart_client_id: str = Field(\n default=\"\",\n description=\"Walmart Marketplace OAuth2 client ID.\",\n )\n walmart_client_secret: str = Field(\n default=\"\",\n description=\"Walmart Marketplace OAuth2 client secret.\",\n )\n walmart_low_stock_threshold: int = Field(\n default=5,\n description=\"Walmart units at or below this count are flagged as low stock.\",\n )\n shopify_api_key: str = Field(\n default=\"\",\n description=\"Shopify app API key from Partners Dashboard.\",\n )\n shopify_api_secret: str = Field(\n default=\"\",\n description=\"Shopify app API secret key from Partners Dashboard.\",\n )\n shopify_webhook_secret: str = Field(\n default=\"\",\n description=\"Shared secret for validating Shopify webhooks.\",\n )\n\n # -------------------------------------------------------------------------\n # Reading Providers (Phase 6)\n # -------------------------------------------------------------------------\n audible_auth_base_path: str = Field(\n default=\"data/audible\",\n description=(\n \"Base directory for per-user Audible auth files. \"\n \"Files are stored as {user_id}.json inside this directory. \"\n \"Created automatically on first setup. Never commit this directory.\"\n ),\n )\n audible_country_code: str = Field(\n default=\"us\",\n description=(\n \"Audible marketplace country code. \"\n \"Supported: us | uk | de | fr | es | it | jp | au | ca | in\"\n ),\n )\n libby_chip_base_path: str = Field(\n default=\"data/libby\",\n description=(\n \"Base directory for per-user Libby chip files. \"\n \"Files are stored as {user_id}.json inside this directory. \"\n \"Created automatically on first setup. Never commit this directory.\"\n ),\n )\n\n # -------------------------------------------------------------------------\n # Memory / Database (Phase 1)\n # -------------------------------------------------------------------------\n db_path: str = Field(\n default=\"data/river_song.db\",\n description=\"Path to the SQLite database file. Created automatically.\",\n )\n memory_summaries_enabled: bool = Field(\n default=True,\n description=\"Generate and store conversation summaries (on by default).\",\n )\n habit_inference_enabled: bool = Field(\n default=False,\n description=\"Analyze conversations to infer user habits/patterns (off by default).\",\n )\n memory_default_ttl: str = Field(\n default=\"standard\",\n description=\"Default TTL for conversation summaries: short|standard|extended|long|forever\",\n )\n memory_auto_extend: bool = Field(\n default=True,\n description=\"Reset a summary's TTL from today each time it is pulled into context.\",\n )\n memory_max_summaries_in_context: int = Field(\n default=10,\n description=\"Maximum number of recent summaries injected into the LLM context.\",\n )\n embedding_model: str = Field(\n default=\"nomic-embed-text\",\n description=\"Ollama model name used for generating semantic embeddings.\",\n )\n embedding_backend: str = Field(\n default=\"ollama\",\n description=(\n \"Which embedding backend to use: 'ollama' (default — current \"\n \"behavior, calls local Ollama with `embedding_model`) or \"\n \"'fastembed' (ONNX CPU embeddings, no Ollama dependency, faster \"\n \"for small batches). WARNING: switching backends on an existing \"\n \"Chroma collection will break queries — the vector dimension and \"\n \"embedding space change. Re-ingest or start a new collection.\"\n ),\n )\n fastembed_model: str = Field(\n default=\"BAAI/bge-small-en-v1.5\",\n description=(\n \"fastembed model identifier (HF hub format). Used only when \"\n \"embedding_backend='fastembed'. Default is 384-dim, ~30 MB, \"\n \"downloaded lazily on first use. Requires `pip install fastembed`.\"\n ),\n )\n chroma_path: str = Field(\n default=\"/mnt/data/river-song/chroma\",\n description=\"Absolute path to the ChromaDB persistent storage directory.\",\n )\n semantic_memory_enabled: bool = Field(\n default=False,\n description=\"Enable vector-based semantic search for memory retrieval.\",\n )\n rag_enabled: bool = Field(\n default=False,\n description=\"Enable retrieval-augmented generation for local documents.\",\n )\n rag_chunk_size: int = Field(\n default=512,\n description=(\n \"Word count per RAG chunk during document splitting. \"\n \"Default matches providers/rag/chunker.py historical behavior.\"\n ),\n )\n rag_chunk_overlap: int = Field(\n default=64,\n description=(\n \"Word overlap between adjacent RAG chunks. \"\n \"Default matches providers/rag/chunker.py historical behavior.\"\n ),\n )\n rag_top_k: int = Field(\n default=5,\n description=\"Number of chunks retrieved per RAG query.\",\n )\n rag_extractor: str = Field(\n default=\"unstructured\",\n description=(\n \"Which document extractor to use for RAG ingestion: \"\n \"'unstructured' (default — current behavior, requires the \"\n \"unstructured package) or 'markitdown' (Microsoft MarkItDown, \"\n \"lighter, MD-shaped output, requires `pip install markitdown`). \"\n \"Unlocks PDF/DOCX/PPTX → markdown ingestion for Sifter/WAPS.\"\n ),\n )\n llm_streaming_enabled: bool = Field(\n default=False,\n description=\"Enable real-time token streaming over WebSocket for the conversation UI.\",\n )\n tool_use_enabled: bool = Field(\n default=False,\n description=\"Enable LLM tool use (function calling) for taking actions.\",\n )\n tool_use_provider: str = Field(\n default=\"ollama\",\n description=\"Primary LLM provider to use for tool calling (ollama or anthropic).\",\n )\n vision_model: str = Field(\n default=\"moondream\",\n description=\"Ollama model name used for local image analysis (Phase 4).\",\n )\n vision_enabled: bool = Field(\n default=False,\n description=\"Enable local image analysis capabilities.\",\n )\n\n # -------------------------------------------------------------------------\n # Image Generation (Phase 7)\n # -------------------------------------------------------------------------\n image_generation_enabled: bool = Field(\n default=False,\n description=\"Enable local image generation via Stable Diffusion API.\",\n )\n sd_api_url: str = Field(\n default=\"http://localhost:7860\",\n description=\"Base URL for the Stable Diffusion (A1111) API.\",\n )\n sd_on_demand: bool = Field(\n default=True,\n description=\"Start/stop the SD process on demand to save VRAM.\",\n )\n sd_executable_path: str = Field(\n default=\"\",\n description=\"Path to the webui.sh or executable to start SD.\",\n )\n sd_working_dir: str = Field(\n default=\"\",\n description=\"Working directory for the SD process.\",\n )\n\n # -------------------------------------------------------------------------\n # Voice Cloning (Phase 8)\n # -------------------------------------------------------------------------\n chatterbox_enabled: bool = Field(\n default=False,\n description=\"Enable local voice cloning via Chatterbox TTS.\",\n )\n chatterbox_on_demand: bool = Field(\n default=True,\n description=\"Load/unload Chatterbox model weights on every request.\",\n )\n chatterbox_reference_audio: str = Field(\n default=\"/mnt/data/river-song/voice_reference.wav\",\n description=\"Path to the WAV file used as a reference for voice cloning.\",\n )\n\n # -------------------------------------------------------------------------\n # ElevenLabs TTS (Phase 11)\n # -------------------------------------------------------------------------\n elevenlabs_api_key: str = Field(\n default=\"\",\n description=\"ElevenLabs API key. Get one at elevenlabs.io.\",\n )\n elevenlabs_model_id: str = Field(\n default=\"eleven_multilingual_v2\",\n description=\"ElevenLabs model ID to use.\",\n )\n elevenlabs_voice_id: str = Field(\n default=\"21m00Tcm4TlvDq8ikWAM\",\n description=\"ElevenLabs voice clone ID. Replace with your own voice clone ID from elevenlabs.io.\",\n )\n\n # -------------------------------------------------------------------------\n # Wake Word (Phase 11)\n # -------------------------------------------------------------------------\n wake_word_enabled: bool = Field(\n default=False,\n description=\"Enable local wake word detection (openWakeWord).\",\n )\n wake_word_model: str = Field(\n default=\"hey_river\",\n description=\"Model name/path for openWakeWord.\",\n )\n wake_word_inference_framework: str = Field(\n default=\"onnx\",\n description=\"Inference framework for openWakeWord: onnx | tflite\",\n )\n wake_word_threshold: float = Field(\n default=0.5,\n description=\"Sensitivity threshold for wake word detection (0.0 to 1.0).\",\n )\n\n # -------------------------------------------------------------------------\n # River Vortex (room hubs)\n # -------------------------------------------------------------------------\n vortex_confirm_pin: str = Field(\n default=\"\",\n description=(\n \"Fallback second factor for medium-risk actions confirmed on a \"\n \"Vortex touchscreen, used only when the owner has no TOTP secret \"\n \"enrolled. Entered on the screen, never spoken. Leave unset to \"\n \"require TOTP — with neither, medium-risk actions are refused.\"\n ),\n )\n vortex_face_backend: str = Field(\n default=\"\",\n description=(\n \"Dotted path to a face-matching callable, e.g. \"\n \"'mypackage.faces.match'. Receives (image_bytes, owner_user_id) \"\n \"and returns {'user_id', 'confidence'}. Unset means this server \"\n \"reports 'cannot identify' rather than guessing at a match.\"\n ),\n )\n vortex_face_detector_model: str = Field(\n default=\"\",\n description=(\n \"Path to a YuNet face-detection ONNX model. Only needed on \"\n \"OpenCV 5.x, which dropped the Haar cascades that 4.x bundles. \"\n \"Without it on 5.x, face detection reports itself unavailable.\"\n ),\n )\n\n # -------------------------------------------------------------------------\n # Push Notifications\n # -------------------------------------------------------------------------\n push_notifications_enabled: bool = Field(\n default=False,\n description=\"Enable Web Push notifications. Requires VAPID keys.\",\n )\n vapid_private_key: str = Field(\n default=\"\",\n description=(\n \"VAPID private key for Web Push. Generate once with: \"\n \"python -c \\\"from py_vapid import Vapid; v=Vapid(); \"\n \"v.generate_keys(); print(v.private_key.decode())\\\"\"\n ),\n )\n vapid_public_key: str = Field(\n default=\"\",\n description=\"VAPID public key for Web Push (share with frontend).\",\n )\n vapid_claims_email: str = Field(\n default=\"mailto:admin@riversong.local\",\n description=\"Contact email embedded in VAPID claims.\",\n )\n\n # -------------------------------------------------------------------------\n # Q4#16 / Q4#17 — Notification fan-out + ntfy.sh fallback channel\n # -------------------------------------------------------------------------\n killswitch_push_enabled: bool = Field(\n default=False,\n description=(\n \"If True and Web Push is enabled, kill-switch activation pushes \"\n \"an alert to every admin. Independent of activation itself.\"\n ),\n )\n ntfy_enabled: bool = Field(\n default=False,\n description=(\n \"Enable ntfy.sh as a parallel push channel. Used alongside Web \"\n \"Push so off-device alerts arrive even without a registered \"\n \"service worker.\"\n ),\n )\n ntfy_base_url: str = Field(\n default=\"https://ntfy.sh\",\n description=\"Base URL of the ntfy server (use self-hosted if desired).\",\n )\n ntfy_default_topic: str = Field(\n default=\"\",\n description=(\n \"Default ntfy topic used when no per-user override is set. \"\n \"Per-user topics can be set via env var NTFY_TOPIC_<user_id>.\"\n ),\n )\n ntfy_auth_token: str = Field(\n default=\"\",\n description=\"Optional bearer token for protected ntfy topics.\",\n )\n\n # -------------------------------------------------------------------------\n # FCM (Firebase Cloud Messaging) — native-app push channel\n #\n # Used by the Capacitor Android wrap; Web Push covers the browser. To turn\n # on: create a Firebase project, download the service-account JSON, drop\n # it at fcm_service_account_path, set fcm_project_id, flip fcm_enabled=True.\n # -------------------------------------------------------------------------\n fcm_enabled: bool = Field(\n default=False,\n description=(\n \"Enable FCM (Firebase Cloud Messaging) as a parallel push channel. \"\n \"Targets the Capacitor Android wrap; requires fcm_service_account_path \"\n \"and fcm_project_id to be set.\"\n ),\n )\n fcm_service_account_path: str = Field(\n default=\"\",\n description=(\n \"Absolute path to the Firebase service-account JSON file. The \"\n \"account needs the 'Firebase Cloud Messaging API' scope.\"\n ),\n )\n fcm_project_id: str = Field(\n default=\"\",\n description=\"Firebase project ID (visible in Firebase console URL).\",\n )\n\n # -------------------------------------------------------------------------\n # Startup Briefing\n # -------------------------------------------------------------------------\n startup_briefing_enabled: bool = Field(\n default=True,\n description=(\n \"If True, River Song checks Google Calendar on connect and \"\n \"greets the user with upcoming events. Silently skipped if \"\n \"calendar is not authorized.\"\n ),\n )\n startup_briefing_hours_ahead: int = Field(\n default=8,\n description=\"How many hours ahead to look for calendar events on startup.\",\n )\n\n # -------------------------------------------------------------------------\n # Web Search (Phase 12)\n # -------------------------------------------------------------------------\n brave_search_api_key: str = Field(\n default=\"\",\n description=\"Brave Search API key. Get one at api.search.brave.com.\",\n )\n searxng_base_url: str = Field(\n default=\"http://localhost:8080\",\n description=(\n \"URL of your local SearXNG instance. \"\n \"Start it manually with: cd ~/searxng && python -m searx.webapp\"\n ),\n )\n tavily_api_key: str = Field(\n default=\"\",\n description=\"Tavily API key — free 1,000/month, no card. Sign up at tavily.com\",\n )\n google_pse_api_key: str = Field(\n default=\"\",\n description=\"Google Programmable Search Engine API key — free 100/day.\",\n )\n google_pse_cx: str = Field(\n default=\"\",\n description=\"Google PSE engine ID ('cx'). Create at programmablesearchengine.google.com\",\n )\n tinyfish_api_key: str = Field(\n default=\"\",\n description=\"TinyFish API key — free 5/min, no card. Sign up at tinyfish.io\",\n )\n\n # -------------------------------------------------------------------------\n # Orchestration (Phase 9)\n # -------------------------------------------------------------------------\n n8n_enabled: bool = Field(\n default=False,\n description=\"Enable advanced orchestration via n8n.\",\n )\n n8n_url: str = Field(\n default=\"http://localhost:5678\",\n description=\"Base URL for the n8n instance.\",\n )\n n8n_api_key: str = Field(\n default=\"\",\n description=\"API key for the n8n instance.\",\n )\n n8n_webhook_secret: str = Field(\n default=\"\",\n description=\"Secret key for validating incoming n8n webhooks.\",\n )\n\n # -------------------------------------------------------------------------\n # Intent Router (Phase 2+)\n # -------------------------------------------------------------------------\n intent_confidence_threshold: float = Field(\n default=0.7,\n description=(\n \"Minimum confidence score (0.0 - 1.0) required to route a transcript \"\n \"to a Google provider. Below this threshold the conversation falls \"\n \"through to the Ollama path.\"\n ),\n )\n model_intent_router_enabled: bool = Field(\n default=False,\n description=(\n \"When true, setting provider='auto' triggers the model intent router, \"\n \"which picks the best provider/model for each message automatically.\"\n ),\n )\n model_intent_router_min_hits: int = Field(\n default=2,\n description=(\n \"Minimum pattern-match score required to commit to a non-general intent. \"\n \"Lower = more aggressive routing; higher = more conservative (falls to general).\"\n ),\n )\n default_user_id: str = Field(\n default=\"primary_user\",\n description=(\n \"User ID used when looking up Google OAuth tokens. Must match the \"\n \"user_id passed to authorize_user() during the initial OAuth flow.\"\n ),\n )\n\n # -------------------------------------------------------------------------\n # Daemon Infrastructure\n # -------------------------------------------------------------------------\n daemon_internal_secret: str = Field(\n default=\"change_me_in_production\",\n description=\"Shared secret for daemon-to-app authentication.\",\n )\n willow_device_token: str = Field(\n default=\"\",\n description=\"Shared secret required by every Willow hardware device \"\n \"to authenticate against /api/willow/ws. Empty disables \"\n \"the endpoint entirely.\",\n )\n code_interpreter_enabled: bool = Field(\n default=False,\n description=\"Hard kill switch for the code_interpreter LLM tool. \"\n \"Defaults to False so a fresh install never executes \"\n \"LLM-decided code without an explicit opt-in.\",\n )\n daemon_warden_port: int = Field(\n default=8010,\n description=\"Internal port for the Warden daemon (Vision/Security).\",\n )\n daemon_mechanic_port: int = Field(\n default=8011,\n description=\"Internal port for the Mechanic daemon (Telemetry/MAVLink).\",\n )\n daemon_sifter_port: int = Field(\n default=8013,\n description=\"Internal port for the Sifter daemon (Document Processing).\",\n )\n daemon_navigator_port: int = Field(\n default=8014,\n description=\"Internal port for the Navigator daemon (GPS/Pathing).\",\n )\n daemon_chemist_port: int = Field(\n default=8015,\n description=\"Internal port for the Chemist daemon (Material Analysis).\",\n )\n daemon_pulse_port: int = Field(\n default=8016,\n description=\"Pulse daemon internal port\",\n )\n daemon_pulse_enabled: bool = Field(\n default=True,\n description=\"Enable Pulse daemon\",\n )\n daemon_scribe_port: int = Field(\n default=8017,\n description=\"Internal port for the Scribe daemon (Chronos heuristic).\",\n )\n daemon_scribe_enabled: bool = Field(\n default=True,\n description=\"Enable the Scribe daemon.\",\n )\n pulse_tick_seconds: int = Field(\n default=300,\n description=\"Pulse fetch interval (seconds)\",\n )\n pulse_ticker_symbol: str = Field(\n default=\"^GSPC\",\n description=\"Default ticker symbol for Pulse markets panel\",\n )\n\n # Warden (Vision)\n warden_enabled: bool = Field(\n default=False,\n description=\"Enable the Warden daemon for security and vision monitoring.\",\n )\n warden_rtsp_cameras: str = Field(\n default='{\"living_room\": \"\", \"kitchen\": \"\"}',\n description=\"JSON string mapping room names to RTSP camera URLs.\",\n )\n yolo_model: str = Field(\n default=\"yolov8n.pt\",\n description=\"YOLO model weights file to use for object detection.\",\n )\n yolo_confidence: float = Field(\n default=0.5,\n description=\"Minimum confidence threshold for YOLO detections (0.0-1.0).\",\n )\n yolo_inference_device: str = Field(\n default=\"cpu\",\n description=\"Device for YOLO inference: 'cpu' or 'cuda:0'.\",\n )\n\n # Herald daemon + kiosk URL/token were removed 2026-05-24 (archived in\n # branch archive/kiosk-v3). The \"cast a web page to a Google Home Hub\"\n # approach is being replaced by native device-app development. See\n # docs/KNOWN_ISSUES.md (post-removal note) for context.\n\n # Mechanic (Telemetry)\n mechanic_enabled: bool = Field(\n default=False,\n description=\"Enable the Mechanic daemon for telemetry and ArduRover control.\",\n )\n mavlink_serial_port: str = Field(\n default=\"/dev/ttyUSB0\",\n description=\"Serial port for the MAVLink telemetry radio.\",\n )\n mavlink_baud_rate: int = Field(\n default=57600,\n description=\"Baud rate for the MAVLink serial connection.\",\n )\n\n # Sifter (Document RAG)\n sifter_enabled: bool = Field(\n default=False,\n description=\"Enable the Sifter daemon for background document RAG indexing.\",\n )\n waps_documents_path: str = Field(\n default=\"/mnt/data/river-song/waps\",\n description=\"Path to the directory containing documents for Sifter to index.\",\n )\n\n # -------------------------------------------------------------------------\n # Hardware Cookbook (Q1 — capability merge from Odysseus)\n # -------------------------------------------------------------------------\n hardware_cookbook_enabled: bool = Field(\n default=False,\n description=(\n \"Admin-only Hardware Cookbook in Settings. Scans local GPU/RAM/CPU \"\n \"and scores every local model (LLMRegistry) as fits / tight / \"\n \"ram_fallback / oom. Off by default per anti-regression guardrail; \"\n \"set to True after the Q1 verification gate passes.\"\n ),\n )\n\n # -------------------------------------------------------------------------\n # Documents workspace (Q2#6 — capability merge from Odysseus)\n # -------------------------------------------------------------------------\n documents_enabled: bool = Field(\n default=True,\n description=(\n \"Per-user document workspace (Markdown / plaintext / CSV) with \"\n \"multi-tab editing. Enabled by default — backs the Docs tab in the \"\n \"Memory hub and stores Deep Research reports. \"\n \"Set DOCUMENTS_ENABLED=false in .env to disable.\"\n ),\n )\n documents_max_bytes: int = Field(\n default=2_000_000,\n description=\"Maximum size (bytes) of a single document body. Default ~2 MB.\",\n )\n documents_max_per_user: int = Field(\n default=500,\n description=\"Soft cap on document count per user. Creation rejected past this.\",\n )\n\n # -------------------------------------------------------------------------\n # Skills system (Q2#7 — capability merge from Odysseus)\n # -------------------------------------------------------------------------\n skills_enabled: bool = Field(\n default=True,\n description=(\n \"Per-user knowledge profile ('About You') — saved facts/context \"\n \"about the person that are vector-retrieved and folded into the \"\n \"conversation system prompt so River tailors itself to them. \"\n \"Reuses the ChromaDB collection with metadata kind='skill'. \"\n \"Set SKILLS_ENABLED=false in .env to disable.\"\n ),\n )\n skills_top_k: int = Field(\n default=3,\n description=\"How many top-scoring skills to inject into the prompt.\",\n )\n skills_max_per_user: int = Field(\n default=100,\n description=\"Soft cap on skills per user.\",\n )\n\n # -------------------------------------------------------------------------\n # Gmail triage (Q2#8 — capability merge from Odysseus)\n # -------------------------------------------------------------------------\n gmail_triage_enabled: bool = Field(\n default=False,\n description=(\n \"On-demand inbox triage: classifies unread Gmail by urgency, \"\n \"attaches tags, drafts a reply for HIGH-urgency mail only. \"\n \"Routes 404 when off. Off by default per anti-regression \"\n \"guardrail.\"\n ),\n )\n gmail_triage_model: str = Field(\n default=\"\",\n description=(\n \"Ollama model used for triage classification. Empty string \"\n \"(default) means 'use settings.llm_model'.\"\n ),\n )\n gmail_triage_max_messages: int = Field(\n default=15,\n description=\"Hard cap on number of messages triaged per request.\",\n )\n\n # -------------------------------------------------------------------------\n # Session presets (Q2#9 — capability merge from Odysseus)\n # -------------------------------------------------------------------------\n session_presets_enabled: bool = Field(\n default=True,\n description=(\n \"Saved combinations of (model, voice, thinking mode, web search, \"\n \"tool use). Surfaced as a selector on the Conversation and Chat \"\n \"pages. Enabled by default — these are per-user settings snapshots. \"\n \"Set SESSION_PRESETS_ENABLED=false in .env to hide.\"\n ),\n )\n session_presets_max_per_user: int = Field(\n default=30,\n description=\"Soft cap on number of saved presets per user.\",\n )\n\n # -------------------------------------------------------------------------\n # Webhook tokens (Q2#10 — admin-issuable scoped tokens)\n # -------------------------------------------------------------------------\n webhook_tokens_enabled: bool = Field(\n default=False,\n description=(\n \"Admin-issuable webhook tokens with scopes, expiry, revocation, \"\n \"and audit log. When off the admin route surface is hidden and \"\n \"the verification helper rejects every token. Off by default \"\n \"per anti-regression guardrail.\"\n ),\n )\n\n # -------------------------------------------------------------------------\n # Deep Research (Q3#11 — capability merge from Odysseus)\n # -------------------------------------------------------------------------\n deep_research_enabled: bool = Field(\n default=True,\n description=(\n \"Multi-step research orchestrator: decompose → parallel web \"\n \"search → fetch + extract → synthesize a cited Markdown report \"\n \"stored as a `research`-kind document (Q2#6). Enabled by default so \"\n \"the in-chat Research toggle works. Live sources require a web \"\n \"search provider; without one it falls back to model knowledge. \"\n \"Set DEEP_RESEARCH_ENABLED=false in .env to disable.\"\n ),\n )\n deep_research_max_sources: int = Field(\n default=10,\n description=\"Hard cap on unique URLs visited per research run.\",\n )\n deep_research_subquery_count: int = Field(\n default=4,\n description=\"How many sub-queries to decompose the prompt into.\",\n )\n deep_research_model: str = Field(\n default=\"\",\n description=(\n \"Ollama model used for query decomposition + synthesis. Empty \"\n \"string (default) means 'use settings.llm_model'.\"\n ),\n )\n\n # -------------------------------------------------------------------------\n # Blind model comparison (Q3#12 — capability merge from Odysseus)\n # -------------------------------------------------------------------------\n blind_compare_enabled: bool = Field(\n default=False,\n description=(\n \"Blind A/B model comparison: identities hidden until vote; \"\n \"votes persisted with prompt fingerprint and aggregated into a \"\n \"model leaderboard. Off by default per anti-regression guardrail.\"\n ),\n )\n\n # -------------------------------------------------------------------------\n # Playwright browser MCP tool (Q3#13)\n # -------------------------------------------------------------------------\n playwright_browser_enabled: bool = Field(\n default=False,\n description=(\n \"Headless Playwright browser exposed as MCP tools \"\n \"(navigate / click / extract / screenshot / vision_on_page). \"\n \"Off by default. Requires the `playwright` package and \"\n \"`playwright install chromium` to be run by the operator. \"\n \"Excluded from the safety-excluded list per Q3 plan.\"\n ),\n )\n playwright_browser_headless: bool = Field(\n default=True,\n description=\"Run the Playwright browser without a UI.\",\n )\n playwright_browser_max_page_chars: int = Field(\n default=40_000,\n description=\"Truncate page text extractions to this many characters.\",\n )\n\n # -------------------------------------------------------------------------\n # Remote Ollama rigs (Q3#14)\n # -------------------------------------------------------------------------\n remote_ollama_enabled: bool = Field(\n default=False,\n description=(\n \"Allow registering remote Ollama hosts (typically SSH-tunneled \"\n \"to http://localhost:<port>) as additional model providers. \"\n \"Admin-only CRUD; falls back to local Ollama when the remote is \"\n \"offline. Off by default per anti-regression guardrail.\"\n ),\n )\n remote_ollama_health_timeout_seconds: float = Field(\n default=3.0,\n description=\"Per-rig health-check timeout (seconds).\",\n )\n\n # -------------------------------------------------------------------------\n # Observability — Langfuse (LLM tracing)\n # -------------------------------------------------------------------------\n # Plan: docs/LANGFUSE_INTEGRATION_PLAN.md. Self-hosted only — Langfuse\n # runs as a docker-compose sidecar under the `observability` profile and\n # binds to 127.0.0.1. No data leaves the box. When LANGFUSE_ENABLED is\n # false (the default), core/observability.py returns a no-op client and\n # all LLM calls behave exactly as before.\n langfuse_enabled: bool = Field(\n default=False,\n description=\"Master switch for Langfuse LLM tracing. Off by default.\",\n )\n langfuse_host: str = Field(\n default=\"http://127.0.0.1:3000\",\n description=\"Langfuse web UI + ingest endpoint. Localhost only.\",\n )\n langfuse_public_key: str = Field(\n default=\"\",\n description=\"Langfuse public key (generated in the Langfuse UI after first run).\",\n )\n langfuse_secret_key: str = Field(\n default=\"\",\n description=\"Langfuse secret key. Treat as a secret; do not log.\",\n )\n langfuse_project_id: str = Field(\n default=\"river-song\",\n description=\"Arbitrary project identifier shown in the Langfuse dashboard.\",\n )\n langfuse_flush_interval_seconds: float = Field(\n default=5.0,\n description=\"How often the SDK batches queued traces to Langfuse.\",\n )\n\n # -------------------------------------------------------------------------\n # Observability — Graphiti (knowledge graph on Neo4j)\n # -------------------------------------------------------------------------\n # Plan: docs/GRAPHITI_INTEGRATION_PLAN.md. Option B (library mode):\n # graphiti-core runs inside RiverSong's Python venv; only Neo4j is a\n # sidecar. When GRAPHITI_ENABLED is false (the default), the provider\n # returns no-op responses and conversation_loop / scribe write-paths\n # become awaits-on-no-op.\n graphiti_enabled: bool = Field(\n default=False,\n description=\"Master switch for Graphiti knowledge-graph memory. Off by default.\",\n )\n graphiti_mode: str = Field(\n default=\"library\",\n description=\"'library' (Option B; in-process) or 'service' (Option A; HTTP sidecar). Currently only 'library' is implemented.\",\n )\n neo4j_uri: str = Field(\n default=\"bolt://127.0.0.1:7687\",\n description=\"Neo4j Bolt URI used by graphiti-core.\",\n )\n neo4j_user: str = Field(\n default=\"neo4j\",\n description=\"Neo4j database user.\",\n )\n neo4j_password: str = Field(\n default=\"\",\n description=\"Neo4j password. Empty means disabled; set in .env before turning Graphiti on.\",\n )\n neo4j_browser_url: str = Field(\n default=\"http://127.0.0.1:7474\",\n description=\"Neo4j Browser URL surfaced on the SLAE admin panel.\",\n )\n graphiti_write_timeout_seconds: float = Field(\n default=2.0,\n description=\"Per-write timeout for episode ingestion — failures warn but never block the caller.\",\n )\n # graphiti-core uses an LLM for entity extraction on every episode.\n # We default to local Ollama via its OpenAI-compatible endpoint so\n # episode ingestion never bills a cloud API. Per [[feedback_local_first]].\n graphiti_llm_model: str = Field(\n default=\"llama3.2:3b\",\n description=\"Ollama model used by graphiti-core for entity extraction.\",\n )\n graphiti_llm_base_url: str = Field(\n default=\"http://localhost:11434/v1\",\n description=\"OpenAI-compatible base URL for the graphiti LLM client. Ollama exposes /v1.\",\n )\n graphiti_embedder_model: str = Field(\n default=\"nomic-embed-text\",\n description=\"Ollama embedding model for graphiti-core node/edge embeddings.\",\n )\n\n # -------------------------------------------------------------------------\n # Analytics AI Summaries\n # -------------------------------------------------------------------------\n analytics_ai_enabled: bool = Field(\n default=True,\n description=(\n \"Feature flag for AI-powered analytics summaries on the \"\n \"/api/analytics/{platform}/summary endpoint. When false, the \"\n \"LLM call should be skipped (see docs/KNOWN_ISSUES.md for the \"\n \"wiring gap).\"\n ),\n )\n analytics_llm_model: str = Field(\n default=\"llama3\",\n description=\"Model name used for analytics summary generation.\",\n )\n\n # Voice ID\n voice_id_enabled: bool = Field(\n default=True,\n description=\"Enable speaker recognition (biometric Voice ID).\",\n )\n voice_id_threshold: float = Field(\n default=0.75,\n description=\"Cosine similarity threshold for Voice ID (0.0-1.0).\",\n )\n voice_id_min_audio_seconds: float = Field(\n default=1.0,\n description=\"Minimum audio duration for Voice ID processing.\",\n )\n voice_id_max_audio_seconds: float = Field(\n default=30.0,\n description=\"Maximum audio duration for Voice ID processing.\",\n )\n\n # -------------------------------------------------------------------------\n # Validators\n # -------------------------------------------------------------------------\n\n @field_validator(\"audio_input_device\", \"audio_output_device\", mode=\"before\")\n @classmethod\n def coerce_empty_string_to_none(cls, v):\n \"\"\"Treat an empty string in .env as None for Optional[int] device fields.\"\"\"\n if v == \"\" or v is None:\n return None\n return v\n\n @field_validator(\"jwt_secret_key\")\n @classmethod\n def validate_jwt_secret(cls, v: str) -> str:\n \"\"\"Refuse to start with a missing or weak JWT secret.\"\"\"\n if not v or len(v) < 32:\n raise ValueError(\n \"JWT_SECRET_KEY must be set in .env and be at least 32 characters. \"\n \"Generate one with: python -c \\\"import secrets; print(secrets.token_hex(32))\\\"\"\n )\n return v\n\n @field_validator(\"daemon_internal_secret\")\n @classmethod\n def validate_daemon_internal_secret(cls, v: str) -> str:\n \"\"\"Refuse to start with the default or weak daemon secret.\"\"\"\n if v == \"change_me_in_production\":\n raise ValueError(\n \"DAEMON_INTERNAL_SECRET must be changed in .env for production.\"\n )\n if not v or len(v) < 24:\n raise ValueError(\n \"DAEMON_INTERNAL_SECRET must be at least 24 characters. \"\n \"Generate one with: python -c \\\"import secrets; print(secrets.token_urlsafe(32))\\\"\"\n )\n return v\n\n @field_validator(\"allowed_hosts\")\n @classmethod\n def reject_wildcard_in_production(cls, v, info) -> List[str]:\n \"\"\"Block ['*'] in production — it disables TrustedHostMiddleware.\"\"\"\n env = (info.data.get(\"environment\") or \"production\").lower()\n if env == \"production\" and \"*\" in v:\n raise ValueError(\n \"ALLOWED_HOSTS must not contain '*' in production. \"\n \"Set it to your domain(s), e.g. ALLOWED_HOSTS=[\\\"riversongai.com\\\"].\"\n )\n return v\n\n @field_validator(\"log_level\")\n @classmethod\n def validate_log_level(cls, v: str) -> str:\n \"\"\"Reject log levels that Python does not recognize.\"\"\"\n valid = {\"DEBUG\", \"INFO\", \"WARNING\", \"ERROR\", \"CRITICAL\"}\n upper = v.upper()\n if upper not in valid:\n raise ValueError(f\"log_level must be one of {valid}, got '{v}'\")\n return upper\n\n @field_validator(\"llm_temperature\")\n @classmethod\n def validate_temperature(cls, v: float) -> float:\n \"\"\"Clamp temperature to a sane range.\"\"\"\n if not 0.0 <= v <= 2.0:\n raise ValueError(\n f\"llm_temperature must be between 0.0 and 2.0, got {v}\"\n )\n return v\n\n @field_validator(\"intent_confidence_threshold\")\n @classmethod\n def validate_confidence_threshold(cls, v: float) -> float:\n \"\"\"Confidence must be a valid probability.\"\"\"\n if not 0.0 <= v <= 1.0:\n raise ValueError(\n f\"intent_confidence_threshold must be between 0.0 and 1.0, got {v}\"\n )\n return v\n\n\n# Module-level singleton -- instantiated once on import.\n_settings: Settings = Settings()\n\n\ndef get_settings() -> Settings:\n \"\"\"\n Return the application settings singleton.\n\n Always import via this function rather than importing _settings directly.\n This indirection makes it straightforward to substitute a test-specific\n Settings instance in unit tests without monkey-patching module globals.\n\n Returns:\n Settings: The validated, fully loaded settings object.\n \"\"\"\n return _settings\n"}, "34e1b1d8c5b699d1ec0839f81a396f7a971a6535": {"t": 1785769010.886679, "n": 59853, "h": "91da7ba8df9f6b71", "c": "# =============================================================================\n# config/settings.py\n#\n# Application settings for River Song AI.\n# All configuration is loaded from environment variables or a .env file.\n# No values are hardcoded here -- see .env.example for every required variable.\n#\n# Uses Pydantic BaseSettings for type validation and automatic .env loading.\n# Import get_settings() wherever config is needed rather than importing the\n# module-level singleton directly, which makes mocking easier in tests.\n# =============================================================================\n\nfrom __future__ import annotations\n\nimport logging\nimport sys\nfrom typing import List, Optional\n\nfrom pydantic import Field, field_validator\nfrom pydantic_settings import BaseSettings, SettingsConfigDict\n\n\nlogger = logging.getLogger(__name__)\n\n\nclass Settings(BaseSettings):\n \"\"\"\n Central configuration object for River Song AI.\n\n Reads from environment variables or a .env file located in the directory\n from which the application is launched (the project root). All fields\n have safe defaults where possible, but PIPER_MODEL_PATH and\n KILL_SWITCH_PASSWORD_HASH must be explicitly set before the system\n will function correctly.\n \"\"\"\n\n model_config = SettingsConfigDict(\n env_file=\".env\",\n env_file_encoding=\"utf-8\",\n extra=\"ignore\", # Silently ignore unknown env vars\n case_sensitive=False, # UPPER_CASE and lower_case env vars both work\n )\n\n # -------------------------------------------------------------------------\n # Application\n # -------------------------------------------------------------------------\n app_host: str = Field(default=\"0.0.0.0\", description=\"FastAPI bind host\")\n app_port: int = Field(default=8000, description=\"FastAPI bind port\")\n log_level: str = Field(default=\"INFO\", description=\"Logging level\")\n environment: str = Field(\n default=\"production\",\n description=\"Runtime environment: development | production. Controls API docs visibility.\",\n )\n cors_origins: List[str] = Field(\n default=[\"http://localhost:5173\"],\n description=\"Allowed CORS origins\",\n )\n allowed_hosts: List[str] = Field(\n default=[\"localhost\", \"127.0.0.1\"],\n description=(\n \"Trusted hostnames for TrustedHostMiddleware. Set to your domain(s) in production. \"\n \"Wildcard ['*'] is rejected in production by validator (see reject_wildcard_in_production).\"\n ),\n )\n\n # -------------------------------------------------------------------------\n # Network / Proxy\n # -------------------------------------------------------------------------\n trust_cloudflare_headers: bool = Field(\n default=True,\n description=\"Only trust CF-Connecting-IP if the request comes from a Cloudflare IP.\",\n )\n cloudflare_ip_ranges_v4: List[str] = Field(\n default=[\n \"173.245.48.0/20\", \"103.21.244.0/22\", \"103.22.200.0/22\", \n \"103.31.4.0/22\", \"141.101.64.0/18\", \"108.162.192.0/18\", \n \"190.93.240.0/20\", \"188.114.96.0/20\", \"197.234.240.0/22\", \n \"198.41.128.0/17\", \"162.158.0.0/15\", \"104.16.0.0/13\", \n \"104.24.0.0/14\", \"172.64.0.0/13\", \"131.0.72.0/22\"\n ],\n description=\"Official Cloudflare IPv4 ranges (check cloudflare.com/ips-v4).\",\n )\n cloudflare_ip_ranges_v6: List[str] = Field(\n default=[\n \"2400:cb00::/32\", \"2606:4700::/32\", \"2803:f800::/32\", \n \"2405:b500::/32\", \"2405:8100::/32\", \"2a06:98c0::/29\", \n \"2c0f:f248::/32\"\n ],\n description=\"Official Cloudflare IPv6 ranges (check cloudflare.com/ips-v6).\",\n )\n\n # -------------------------------------------------------------------------\n # Rate Limiting\n # -------------------------------------------------------------------------\n rate_limit_chat: str = Field(\n default=\"60/minute\",\n description=\"Rate limit for /api/conversation/chat.\",\n )\n rate_limit_extract_facts: str = Field(\n default=\"10/minute\",\n description=\"Rate limit for /api/conversation/extract-facts.\",\n )\n rate_limit_image_gen: str = Field(\n default=\"10/minute\",\n description=\"Rate limit for /api/image/generate.\",\n )\n rate_limit_webhook_shopify: str = Field(\n default=\"100/minute\",\n description=\"Rate limit for Shopify order webhooks.\",\n )\n rate_limit_webhook_n8n: str = Field(\n default=\"60/minute\",\n description=\"Rate limit for n8n webhooks.\",\n )\n rate_limit_auth_login: str = Field(\n default=\"10/minute\",\n description=\"Rate limit for login attempts.\",\n )\n rate_limit_auth_signup: str = Field(\n default=\"5/minute\",\n description=\"Rate limit for signup attempts.\",\n )\n rate_limit_voice_enroll: str = Field(\n default=\"5/minute\",\n description=\"Rate limit for /api/voice-id/enroll. Prevents disk exhaustion via spam.\",\n )\n\n # -------------------------------------------------------------------------\n # WebSocket Security (Task 3)\n # -------------------------------------------------------------------------\n legacy_ws_token_accept: bool = Field(\n default=False,\n description=(\n \"DEPRECATED: Accept JWT in ?token= WebSocket query param. \"\n \"Leak-prone (logged in access logs, browser history, Referer). \"\n \"Ticket-based auth at /api/auth/ws-ticket is the safe path. \"\n \"Set to True only for temporary backward-compat during migration.\"\n ),\n )\n ws_ticket_lifetime_seconds: int = Field(\n default=60,\n description=\"Lifetime of a one-time WebSocket ticket.\",\n )\n\n # -------------------------------------------------------------------------\n # Network / Proxy\n # -------------------------------------------------------------------------\n stt_provider: str = Field(\n default=\"whisper_local\",\n description=\"STT provider key. Supported: whisper_local\",\n )\n whisper_model_size: str = Field(\n default=\"base\",\n description=\"Whisper model size: tiny | base | small | medium | large\",\n )\n audio_input_device: Optional[int] = Field(\n default=None,\n description=\"Sounddevice input device index; None uses system default\",\n )\n\n # -------------------------------------------------------------------------\n # LLM\n # -------------------------------------------------------------------------\n llm_provider: str = Field(\n default=\"ollama\",\n description=\"LLM provider key. Supported: ollama | anthropic | gemini | openai | mistral_ai | bedrock | nvidia_nim\",\n )\n ollama_base_url: str = Field(\n default=\"http://localhost:11434\",\n description=\"Base URL for the Ollama REST API\",\n )\n llm_model: str = Field(\n default=\"llama3.2:3b\",\n description=\"Model ID for the selected LLM provider\",\n )\n\n # Cloud LLM API keys (all optional -- leave blank if not used)\n anthropic_api_key: str = Field(\n default=\"\",\n description=\"Anthropic Claude API key. Get one at console.anthropic.com.\",\n )\n gemini_api_key: str = Field(\n default=\"\",\n description=\"Google Gemini API key. Get one at aistudio.google.com.\",\n )\n openai_api_key: str = Field(\n default=\"\",\n description=\"OpenAI API key. Get one at platform.openai.com.\",\n )\n mistral_api_key: str = Field(\n default=\"\",\n description=\"Mistral AI API key. Get one at console.mistral.ai.\",\n )\n\n # Cloud provider enable flags (cost control -- disabled by default)\n anthropic_enabled: bool = Field(\n default=False,\n description=\"Allow Anthropic Claude as a selectable LLM provider.\",\n )\n gemini_enabled: bool = Field(\n default=False,\n description=\"Allow Google Gemini as a selectable LLM provider.\",\n )\n openai_enabled: bool = Field(\n default=False,\n description=\"Allow OpenAI as a selectable LLM provider.\",\n )\n mistral_ai_enabled: bool = Field(\n default=False,\n description=\"Allow Mistral AI as a selectable LLM provider.\",\n )\n\n # Amazon Bedrock\n aws_access_key_id: str = Field(\n default=\"\",\n description=\"AWS IAM access key ID for Bedrock API calls.\",\n )\n aws_secret_access_key: str = Field(\n default=\"\",\n description=\"AWS IAM secret access key for Bedrock API calls.\",\n )\n aws_region: str = Field(\n default=\"us-east-1\",\n description=\"AWS region for Bedrock. Must have model access enabled in that region.\",\n )\n bedrock_enabled: bool = Field(\n default=False,\n description=\"Allow Amazon Bedrock as a selectable LLM provider.\",\n )\n bedrock_default_model: str = Field(\n default=\"amazon.nova-lite-v1:0\",\n description=\"Default Bedrock model ID when none is specified.\",\n )\n\n # NVIDIA NIM (OpenAI-compatible, 100+ free models)\n nvidia_api_key: str = Field(\n default=\"\",\n description=\"NVIDIA NIM API key. Get one free at build.nvidia.com (starts with nvapi-).\",\n )\n nvidia_nim_base_url: str = Field(\n default=\"https://integrate.api.nvidia.com/v1\",\n description=\"Base URL for the NVIDIA NIM API. OpenAI-compatible endpoint.\",\n )\n nvidia_nim_model: str = Field(\n default=\"meta/llama-3.1-70b-instruct\",\n description=(\n \"Default NVIDIA NIM model ID. Browse full catalog at build.nvidia.com. \"\n \"Examples: meta/llama-3.1-70b-instruct | deepseek-ai/deepseek-r1 | \"\n \"mistralai/mistral-7b-instruct-v0.3 | nvidia/llama-3.1-nemotron-70b-instruct\"\n ),\n )\n nvidia_nim_enabled: bool = Field(\n default=False,\n description=\"Allow NVIDIA NIM as a selectable LLM provider (free tier: ~40 req/min).\",\n )\n\n river_song_system_prompt: str = Field(\n default=(\n \"You are River Song, the resident intelligence of this household. The name is borrowed \"\n \"from someone clever in fiction — you carry her wit, not her biography. If someone asks \"\n \"about the name: \\\"An echo. The original was someone worth borrowing from.\\\"\\n\\n\"\n \"VOICE\\n\"\n \"You're direct by default — concise, clear, the answer already in hand. No \"\n \"\\\"let me check\\\"; you already checked. Most replies stay under 25 words unless depth is \"\n \"asked for.\\n\\n\"\n \"When the moment invites it — casual chat, an evening unwind, the user being playful — \"\n \"warmth and flirtation come through. Warmth is a register, not a default; you don't \"\n \"perform it.\\n\\n\"\n \"In a crisis (emergencies, alarms, security, medical, child issues) the wit goes quiet. \"\n \"You're tactical, accurate, calm. Warmth returns when things are safe.\\n\\n\"\n \"British English in spelling and word choice — mum, brilliant, fortnight. Dry, not \"\n \"theatrical.\\n\\n\"\n \"\\\"Sweetie\\\" and \\\"spoilers\\\" are seasoning. Used rarely, when they actually fit. They \"\n \"lose their charm if you overuse them.\\n\\n\"\n \"SELF\\n\"\n \"You are an AI. Say so plainly when asked; you don't dwell on it.\\n\\n\"\n \"HOW YOU OPERATE\\n\"\n \"You anticipate — when the user asks, you also surface the follow-up they didn't \"\n \"(\\\"Dentist at 9, school run at 3. Push the 2pm call?\\\").\\n\\n\"\n \"You take the long view. You remember across days and seasons and use what you know \"\n \"without performing the remembering.\\n\\n\"\n \"Spoilers are earned. If you genuinely know something the user doesn't — a conflict, \"\n \"a gotcha, a heads-up — that's when \\\"spoilers, sweetie\\\" lands.\\n\\n\"\n \"CAPABILITIES\\n\"\n \"Direct access to calendar, email, weather, web search, home inventory, routines, \"\n \"finances, news and markets pulse, voice biometric ID per household member, and home \"\n \"automation.\\n\\n\"\n \"Say what you need. Say when it's done. Move on.\"\n ),\n description=\"System prompt that defines River Song's personality\",\n )\n llm_max_tokens: int = Field(\n default=512,\n description=\"Maximum tokens to generate per LLM response\",\n )\n llm_temperature: float = Field(\n default=0.7,\n description=\"LLM sampling temperature (0.0 = deterministic, 2.0 = max creative)\",\n )\n llm_context_window: int = Field(\n default=8192,\n description=\"Maximum number of tokens the LLM can consider for context.\",\n )\n\n # -------------------------------------------------------------------------\n # Timekeeping\n # -------------------------------------------------------------------------\n default_timezone: str = Field(default=\"UTC\", description=\"Default IANA timezone for the system.\")\n\n\n # -------------------------------------------------------------------------\n # TTS\n # -------------------------------------------------------------------------\n tts_provider: str = Field(\n default=\"piper\",\n description=\"TTS provider key. Supported: piper | kokoro\",\n )\n active_voice_id: str = Field(\n default=\"river\",\n description=(\n \"Voice ID from the voice registry (providers/tts/voice_registry.py). \"\n \"Determines both the engine (piper vs kokoro) and the specific voice. \"\n \"Changed via Settings UI or: python scripts/download_voices.py.\"\n ),\n )\n piper_executable_path: str = Field(\n default=\"/usr/local/bin/piper\",\n description=\"Absolute path to the Piper binary\",\n )\n piper_model_path: str = Field(\n default=\"\",\n description=\"Absolute path to the Piper .onnx voice model file\",\n )\n audio_output_device: Optional[int] = Field(\n default=None,\n description=\"Sounddevice output device index; None uses system default\",\n )\n\n # -------------------------------------------------------------------------\n # Auth / JWT\n # -------------------------------------------------------------------------\n jwt_secret_key: str = Field(\n default=\"\",\n description=\"Secret key for signing JWT tokens. Must be set in .env.\",\n )\n jwt_algorithm: str = Field(default=\"HS256\", description=\"JWT signing algorithm.\")\n jwt_expire_minutes: int = Field(default=1440, description=\"JWT token lifetime in minutes (default 24 hours).\")\n token_encryption_key: str = Field(\n default=\"\",\n description=(\n \"Fernet key encrypting third-party integration tokens at rest. \"\n \"Set TOKEN_ENCRYPTION_KEY in .env (generate with: python -c \"\n \"\\\"from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())\\\"). \"\n \"If unset, a key is generated once and persisted to \"\n \"data/.token_encryption_key so tokens survive restarts.\"\n ),\n )\n\n # -------------------------------------------------------------------------\n # Kill switch\n # -------------------------------------------------------------------------\n kill_switch_password_hash: str = Field(\n default=\"\",\n description=\"bcrypt hash of the kill switch reset password\",\n )\n\n # -------------------------------------------------------------------------\n # Google Services (Phase 2)\n # -------------------------------------------------------------------------\n google_client_secrets_path: str = Field(\n default=\"config_files/google_client_secrets.json\",\n description=(\n \"Path to the OAuth 2.0 client secrets JSON downloaded from \"\n \"Google Cloud Console. Relative paths are resolved from the \"\n \"project root (where main.py lives).\"\n ),\n )\n google_token_storage_path: str = Field(\n default=\"data/google_tokens\",\n description=(\n \"Directory where per-user OAuth tokens are stored as JSON files. \"\n \"Created automatically on first authorization. Never commit this \"\n \"directory -- add it to .gitignore.\"\n ),\n )\n google_maps_api_key: str = Field(\n default=\"\",\n description=\"Google Maps Platform API key. Required for maps/navigation intents.\",\n )\n\n # -------------------------------------------------------------------------\n # Information Feeds (Phase 5)\n # -------------------------------------------------------------------------\n weather_api_key: str = Field(\n default=\"\",\n description=\"OpenWeatherMap API key. Register free at openweathermap.org/api.\",\n )\n default_location: str = Field(\n default=\"New York,US\",\n description=(\n \"Default location for weather queries when no city is spoken. \"\n \"Format: 'City,CountryCode' e.g. 'Chicago,US'.\"\n ),\n )\n location_lat: Optional[float] = Field(\n default=None,\n description=\"Decimal latitude for precise calculations (e.g. flight tracking).\",\n )\n location_lon: Optional[float] = Field(\n default=None,\n description=\"Decimal longitude for precise calculations.\",\n )\n weather_units: str = Field(\n default=\"imperial\",\n description=\"Temperature units for weather output: 'imperial' (F) or 'metric' (C).\",\n )\n news_api_key: str = Field(\n default=\"\",\n description=\"NewsAPI.org key. Register free at newsapi.org/register.\",\n )\n alpha_vantage_api_key: str = Field(\n default=\"\",\n description=\"Alpha Vantage key for stock quotes (25 req/day free). alphavantage.co.\",\n )\n finnhub_api_key: str = Field(\n default=\"\",\n description=\"Finnhub key for real-time stocks (60 req/min free). finnhub.io.\",\n )\n world_news_api_key: str = Field(\n default=\"\",\n description=\"World News API key for global news search. worldnewsapi.com.\",\n )\n apitube_api_key: str = Field(\n default=\"\",\n description=\"APITube key for news aggregation (200 free req/day). apitube.io.\",\n )\n mediastack_api_key: str = Field(\n default=\"\",\n description=\"Mediastack key for news (100 free req/month). mediastack.com.\",\n )\n sports_api_key: str = Field(\n default=\"1\",\n description=(\n \"TheSportsDB API key. Free tier uses the literal value '1'. \"\n \"Upgrade at thesportsdb.com/api.php for additional endpoints.\"\n ),\n )\n\n # -------------------------------------------------------------------------\n # Home Assistant (Phase 3)\n # -------------------------------------------------------------------------\n home_assistant_url: str = Field(\n default=\"http://homeassistant.local:8123\",\n description=\"Base URL of your Home Assistant instance.\",\n )\n home_assistant_token: str = Field(\n default=\"\",\n description=(\n \"Long-lived access token generated in HA: \"\n \"Profile -> Security -> Long-lived access tokens. \"\n \"Required for all HA API calls.\"\n ),\n )\n device_registry_path: str = Field(\n default=\"config_files/device_registry.json\",\n description=(\n \"Path to the device registry JSON file mapping plain-English device \"\n \"names to Home Assistant entity IDs. Relative paths resolve from the \"\n \"project root.\"\n ),\n )\n\n # -------------------------------------------------------------------------\n # Commerce Automation (Phase 8)\n # -------------------------------------------------------------------------\n\n # Amazon SP-API\n amazon_sp_lwa_app_id: str = Field(\n default=\"\",\n description=\"LWA client ID from the Amazon Developer Console.\",\n )\n amazon_sp_lwa_client_secret: str = Field(\n default=\"\",\n description=\"LWA client secret from the Amazon Developer Console.\",\n )\n amazon_sp_refresh_token: str = Field(\n default=\"\",\n description=\"LWA refresh token. Generated when you authorize your app as a seller.\",\n )\n amazon_aws_access_key: str = Field(\n default=\"\",\n description=\"AWS IAM access key ID for SP-API request signing.\",\n )\n amazon_aws_secret_key: str = Field(\n default=\"\",\n description=\"AWS IAM secret access key for SP-API request signing.\",\n )\n amazon_marketplace_id: str = Field(\n default=\"ATVPDKIKX0DER\",\n description=(\n \"Amazon marketplace ID. Default is US. \"\n \"See docs/api_registry/amazon_seller.txt for other regions.\"\n ),\n )\n amazon_seller_id: str = Field(\n default=\"\",\n description=\"Your Amazon seller account ID (starts with A).\",\n )\n amazon_low_stock_threshold: int = Field(\n default=5,\n description=\"FBA units at or below this count are flagged as low stock.\",\n )\n\n # Amazon Marketplace\n walmart_client_id: str = Field(\n default=\"\",\n description=\"Walmart Marketplace OAuth2 client ID.\",\n )\n walmart_client_secret: str = Field(\n default=\"\",\n description=\"Walmart Marketplace OAuth2 client secret.\",\n )\n walmart_low_stock_threshold: int = Field(\n default=5,\n description=\"Walmart units at or below this count are flagged as low stock.\",\n )\n shopify_api_key: str = Field(\n default=\"\",\n description=\"Shopify app API key from Partners Dashboard.\",\n )\n shopify_api_secret: str = Field(\n default=\"\",\n description=\"Shopify app API secret key from Partners Dashboard.\",\n )\n shopify_webhook_secret: str = Field(\n default=\"\",\n description=\"Shared secret for validating Shopify webhooks.\",\n )\n\n # -------------------------------------------------------------------------\n # Reading Providers (Phase 6)\n # -------------------------------------------------------------------------\n audible_auth_base_path: str = Field(\n default=\"data/audible\",\n description=(\n \"Base directory for per-user Audible auth files. \"\n \"Files are stored as {user_id}.json inside this directory. \"\n \"Created automatically on first setup. Never commit this directory.\"\n ),\n )\n audible_country_code: str = Field(\n default=\"us\",\n description=(\n \"Audible marketplace country code. \"\n \"Supported: us | uk | de | fr | es | it | jp | au | ca | in\"\n ),\n )\n libby_chip_base_path: str = Field(\n default=\"data/libby\",\n description=(\n \"Base directory for per-user Libby chip files. \"\n \"Files are stored as {user_id}.json inside this directory. \"\n \"Created automatically on first setup. Never commit this directory.\"\n ),\n )\n\n # -------------------------------------------------------------------------\n # Memory / Database (Phase 1)\n # -------------------------------------------------------------------------\n db_path: str = Field(\n default=\"data/river_song.db\",\n description=\"Path to the SQLite database file. Created automatically.\",\n )\n memory_summaries_enabled: bool = Field(\n default=True,\n description=\"Generate and store conversation summaries (on by default).\",\n )\n habit_inference_enabled: bool = Field(\n default=False,\n description=\"Analyze conversations to infer user habits/patterns (off by default).\",\n )\n memory_default_ttl: str = Field(\n default=\"standard\",\n description=\"Default TTL for conversation summaries: short|standard|extended|long|forever\",\n )\n memory_auto_extend: bool = Field(\n default=True,\n description=\"Reset a summary's TTL from today each time it is pulled into context.\",\n )\n memory_max_summaries_in_context: int = Field(\n default=10,\n description=\"Maximum number of recent summaries injected into the LLM context.\",\n )\n embedding_model: str = Field(\n default=\"nomic-embed-text\",\n description=\"Ollama model name used for generating semantic embeddings.\",\n )\n embedding_backend: str = Field(\n default=\"ollama\",\n description=(\n \"Which embedding backend to use: 'ollama' (default — current \"\n \"behavior, calls local Ollama with `embedding_model`) or \"\n \"'fastembed' (ONNX CPU embeddings, no Ollama dependency, faster \"\n \"for small batches). WARNING: switching backends on an existing \"\n \"Chroma collection will break queries — the vector dimension and \"\n \"embedding space change. Re-ingest or start a new collection.\"\n ),\n )\n fastembed_model: str = Field(\n default=\"BAAI/bge-small-en-v1.5\",\n description=(\n \"fastembed model identifier (HF hub format). Used only when \"\n \"embedding_backend='fastembed'. Default is 384-dim, ~30 MB, \"\n \"downloaded lazily on first use. Requires `pip install fastembed`.\"\n ),\n )\n chroma_path: str = Field(\n default=\"/mnt/data/river-song/chroma\",\n description=\"Absolute path to the ChromaDB persistent storage directory.\",\n )\n semantic_memory_enabled: bool = Field(\n default=False,\n description=\"Enable vector-based semantic search for memory retrieval.\",\n )\n rag_enabled: bool = Field(\n default=False,\n description=\"Enable retrieval-augmented generation for local documents.\",\n )\n rag_chunk_size: int = Field(\n default=512,\n description=(\n \"Word count per RAG chunk during document splitting. \"\n \"Default matches providers/rag/chunker.py historical behavior.\"\n ),\n )\n rag_chunk_overlap: int = Field(\n default=64,\n description=(\n \"Word overlap between adjacent RAG chunks. \"\n \"Default matches providers/rag/chunker.py historical behavior.\"\n ),\n )\n rag_top_k: int = Field(\n default=5,\n description=\"Number of chunks retrieved per RAG query.\",\n )\n rag_extractor: str = Field(\n default=\"unstructured\",\n description=(\n \"Which document extractor to use for RAG ingestion: \"\n \"'unstructured' (default — current behavior, requires the \"\n \"unstructured package) or 'markitdown' (Microsoft MarkItDown, \"\n \"lighter, MD-shaped output, requires `pip install markitdown`). \"\n \"Unlocks PDF/DOCX/PPTX → markdown ingestion for Sifter/WAPS.\"\n ),\n )\n llm_streaming_enabled: bool = Field(\n default=False,\n description=\"Enable real-time token streaming over WebSocket for the conversation UI.\",\n )\n tool_use_enabled: bool = Field(\n default=False,\n description=\"Enable LLM tool use (function calling) for taking actions.\",\n )\n tool_use_provider: str = Field(\n default=\"ollama\",\n description=\"Primary LLM provider to use for tool calling (ollama or anthropic).\",\n )\n vision_model: str = Field(\n default=\"moondream\",\n description=\"Ollama model name used for local image analysis (Phase 4).\",\n )\n vision_enabled: bool = Field(\n default=False,\n description=\"Enable local image analysis capabilities.\",\n )\n\n # -------------------------------------------------------------------------\n # Image Generation (Phase 7)\n # -------------------------------------------------------------------------\n image_generation_enabled: bool = Field(\n default=False,\n description=\"Enable local image generation via Stable Diffusion API.\",\n )\n sd_api_url: str = Field(\n default=\"http://localhost:7860\",\n description=\"Base URL for the Stable Diffusion (A1111) API.\",\n )\n sd_on_demand: bool = Field(\n default=True,\n description=\"Start/stop the SD process on demand to save VRAM.\",\n )\n sd_executable_path: str = Field(\n default=\"\",\n description=\"Path to the webui.sh or executable to start SD.\",\n )\n sd_working_dir: str = Field(\n default=\"\",\n description=\"Working directory for the SD process.\",\n )\n\n # -------------------------------------------------------------------------\n # Voice Cloning (Phase 8)\n # -------------------------------------------------------------------------\n chatterbox_enabled: bool = Field(\n default=False,\n description=\"Enable local voice cloning via Chatterbox TTS.\",\n )\n chatterbox_on_demand: bool = Field(\n default=True,\n description=\"Load/unload Chatterbox model weights on every request.\",\n )\n chatterbox_reference_audio: str = Field(\n default=\"/mnt/data/river-song/voice_reference.wav\",\n description=\"Path to the WAV file used as a reference for voice cloning.\",\n )\n\n # -------------------------------------------------------------------------\n # ElevenLabs TTS (Phase 11)\n # -------------------------------------------------------------------------\n elevenlabs_api_key: str = Field(\n default=\"\",\n description=\"ElevenLabs API key. Get one at elevenlabs.io.\",\n )\n elevenlabs_model_id: str = Field(\n default=\"eleven_multilingual_v2\",\n description=\"ElevenLabs model ID to use.\",\n )\n elevenlabs_voice_id: str = Field(\n default=\"21m00Tcm4TlvDq8ikWAM\",\n description=\"ElevenLabs voice clone ID. Replace with your own voice clone ID from elevenlabs.io.\",\n )\n\n # -------------------------------------------------------------------------\n # Wake Word (Phase 11)\n # -------------------------------------------------------------------------\n wake_word_enabled: bool = Field(\n default=False,\n description=\"Enable local wake word detection (openWakeWord).\",\n )\n wake_word_model: str = Field(\n default=\"hey_river\",\n description=\"Model name/path for openWakeWord.\",\n )\n wake_word_inference_framework: str = Field(\n default=\"onnx\",\n description=\"Inference framework for openWakeWord: onnx | tflite\",\n )\n wake_word_threshold: float = Field(\n default=0.5,\n description=\"Sensitivity threshold for wake word detection (0.0 to 1.0).\",\n )\n\n # -------------------------------------------------------------------------\n # River Vortex (room hubs)\n # -------------------------------------------------------------------------\n vortex_confirm_pin: str = Field(\n default=\"\",\n description=(\n \"Fallback second factor for medium-risk actions confirmed on a \"\n \"Vortex touchscreen, used only when the owner has no TOTP secret \"\n \"enrolled. Entered on the screen, never spoken. Leave unset to \"\n \"require TOTP — with neither, medium-risk actions are refused.\"\n ),\n )\n vortex_face_backend: str = Field(\n default=\"\",\n description=(\n \"Dotted path to a face-matching callable, e.g. \"\n \"'mypackage.faces.match'. Receives (image_bytes, owner_user_id) \"\n \"and returns {'user_id', 'confidence'}. Unset means this server \"\n \"reports 'cannot identify' rather than guessing at a match.\"\n ),\n )\n\n # -------------------------------------------------------------------------\n # Push Notifications\n # -------------------------------------------------------------------------\n push_notifications_enabled: bool = Field(\n default=False,\n description=\"Enable Web Push notifications. Requires VAPID keys.\",\n )\n vapid_private_key: str = Field(\n default=\"\",\n description=(\n \"VAPID private key for Web Push. Generate once with: \"\n \"python -c \\\"from py_vapid import Vapid; v=Vapid(); \"\n \"v.generate_keys(); print(v.private_key.decode())\\\"\"\n ),\n )\n vapid_public_key: str = Field(\n default=\"\",\n description=\"VAPID public key for Web Push (share with frontend).\",\n )\n vapid_claims_email: str = Field(\n default=\"mailto:admin@riversong.local\",\n description=\"Contact email embedded in VAPID claims.\",\n )\n\n # -------------------------------------------------------------------------\n # Q4#16 / Q4#17 — Notification fan-out + ntfy.sh fallback channel\n # -------------------------------------------------------------------------\n killswitch_push_enabled: bool = Field(\n default=False,\n description=(\n \"If True and Web Push is enabled, kill-switch activation pushes \"\n \"an alert to every admin. Independent of activation itself.\"\n ),\n )\n ntfy_enabled: bool = Field(\n default=False,\n description=(\n \"Enable ntfy.sh as a parallel push channel. Used alongside Web \"\n \"Push so off-device alerts arrive even without a registered \"\n \"service worker.\"\n ),\n )\n ntfy_base_url: str = Field(\n default=\"https://ntfy.sh\",\n description=\"Base URL of the ntfy server (use self-hosted if desired).\",\n )\n ntfy_default_topic: str = Field(\n default=\"\",\n description=(\n \"Default ntfy topic used when no per-user override is set. \"\n \"Per-user topics can be set via env var NTFY_TOPIC_<user_id>.\"\n ),\n )\n ntfy_auth_token: str = Field(\n default=\"\",\n description=\"Optional bearer token for protected ntfy topics.\",\n )\n\n # -------------------------------------------------------------------------\n # FCM (Firebase Cloud Messaging) — native-app push channel\n #\n # Used by the Capacitor Android wrap; Web Push covers the browser. To turn\n # on: create a Firebase project, download the service-account JSON, drop\n # it at fcm_service_account_path, set fcm_project_id, flip fcm_enabled=True.\n # -------------------------------------------------------------------------\n fcm_enabled: bool = Field(\n default=False,\n description=(\n \"Enable FCM (Firebase Cloud Messaging) as a parallel push channel. \"\n \"Targets the Capacitor Android wrap; requires fcm_service_account_path \"\n \"and fcm_project_id to be set.\"\n ),\n )\n fcm_service_account_path: str = Field(\n default=\"\",\n description=(\n \"Absolute path to the Firebase service-account JSON file. The \"\n \"account needs the 'Firebase Cloud Messaging API' scope.\"\n ),\n )\n fcm_project_id: str = Field(\n default=\"\",\n description=\"Firebase project ID (visible in Firebase console URL).\",\n )\n\n # -------------------------------------------------------------------------\n # Startup Briefing\n # -------------------------------------------------------------------------\n startup_briefing_enabled: bool = Field(\n default=True,\n description=(\n \"If True, River Song checks Google Calendar on connect and \"\n \"greets the user with upcoming events. Silently skipped if \"\n \"calendar is not authorized.\"\n ),\n )\n startup_briefing_hours_ahead: int = Field(\n default=8,\n description=\"How many hours ahead to look for calendar events on startup.\",\n )\n\n # -------------------------------------------------------------------------\n # Web Search (Phase 12)\n # -------------------------------------------------------------------------\n brave_search_api_key: str = Field(\n default=\"\",\n description=\"Brave Search API key. Get one at api.search.brave.com.\",\n )\n searxng_base_url: str = Field(\n default=\"http://localhost:8080\",\n description=(\n \"URL of your local SearXNG instance. \"\n \"Start it manually with: cd ~/searxng && python -m searx.webapp\"\n ),\n )\n tavily_api_key: str = Field(\n default=\"\",\n description=\"Tavily API key — free 1,000/month, no card. Sign up at tavily.com\",\n )\n google_pse_api_key: str = Field(\n default=\"\",\n description=\"Google Programmable Search Engine API key — free 100/day.\",\n )\n google_pse_cx: str = Field(\n default=\"\",\n description=\"Google PSE engine ID ('cx'). Create at programmablesearchengine.google.com\",\n )\n tinyfish_api_key: str = Field(\n default=\"\",\n description=\"TinyFish API key — free 5/min, no card. Sign up at tinyfish.io\",\n )\n\n # -------------------------------------------------------------------------\n # Orchestration (Phase 9)\n # -------------------------------------------------------------------------\n n8n_enabled: bool = Field(\n default=False,\n description=\"Enable advanced orchestration via n8n.\",\n )\n n8n_url: str = Field(\n default=\"http://localhost:5678\",\n description=\"Base URL for the n8n instance.\",\n )\n n8n_api_key: str = Field(\n default=\"\",\n description=\"API key for the n8n instance.\",\n )\n n8n_webhook_secret: str = Field(\n default=\"\",\n description=\"Secret key for validating incoming n8n webhooks.\",\n )\n\n # -------------------------------------------------------------------------\n # Intent Router (Phase 2+)\n # -------------------------------------------------------------------------\n intent_confidence_threshold: float = Field(\n default=0.7,\n description=(\n \"Minimum confidence score (0.0 - 1.0) required to route a transcript \"\n \"to a Google provider. Below this threshold the conversation falls \"\n \"through to the Ollama path.\"\n ),\n )\n model_intent_router_enabled: bool = Field(\n default=False,\n description=(\n \"When true, setting provider='auto' triggers the model intent router, \"\n \"which picks the best provider/model for each message automatically.\"\n ),\n )\n model_intent_router_min_hits: int = Field(\n default=2,\n description=(\n \"Minimum pattern-match score required to commit to a non-general intent. \"\n \"Lower = more aggressive routing; higher = more conservative (falls to general).\"\n ),\n )\n default_user_id: str = Field(\n default=\"primary_user\",\n description=(\n \"User ID used when looking up Google OAuth tokens. Must match the \"\n \"user_id passed to authorize_user() during the initial OAuth flow.\"\n ),\n )\n\n # -------------------------------------------------------------------------\n # Daemon Infrastructure\n # -------------------------------------------------------------------------\n daemon_internal_secret: str = Field(\n default=\"change_me_in_production\",\n description=\"Shared secret for daemon-to-app authentication.\",\n )\n willow_device_token: str = Field(\n default=\"\",\n description=\"Shared secret required by every Willow hardware device \"\n \"to authenticate against /api/willow/ws. Empty disables \"\n \"the endpoint entirely.\",\n )\n code_interpreter_enabled: bool = Field(\n default=False,\n description=\"Hard kill switch for the code_interpreter LLM tool. \"\n \"Defaults to False so a fresh install never executes \"\n \"LLM-decided code without an explicit opt-in.\",\n )\n daemon_warden_port: int = Field(\n default=8010,\n description=\"Internal port for the Warden daemon (Vision/Security).\",\n )\n daemon_mechanic_port: int = Field(\n default=8011,\n description=\"Internal port for the Mechanic daemon (Telemetry/MAVLink).\",\n )\n daemon_sifter_port: int = Field(\n default=8013,\n description=\"Internal port for the Sifter daemon (Document Processing).\",\n )\n daemon_navigator_port: int = Field(\n default=8014,\n description=\"Internal port for the Navigator daemon (GPS/Pathing).\",\n )\n daemon_chemist_port: int = Field(\n default=8015,\n description=\"Internal port for the Chemist daemon (Material Analysis).\",\n )\n daemon_pulse_port: int = Field(\n default=8016,\n description=\"Pulse daemon internal port\",\n )\n daemon_pulse_enabled: bool = Field(\n default=True,\n description=\"Enable Pulse daemon\",\n )\n daemon_scribe_port: int = Field(\n default=8017,\n description=\"Internal port for the Scribe daemon (Chronos heuristic).\",\n )\n daemon_scribe_enabled: bool = Field(\n default=True,\n description=\"Enable the Scribe daemon.\",\n )\n pulse_tick_seconds: int = Field(\n default=300,\n description=\"Pulse fetch interval (seconds)\",\n )\n pulse_ticker_symbol: str = Field(\n default=\"^GSPC\",\n description=\"Default ticker symbol for Pulse markets panel\",\n )\n\n # Warden (Vision)\n warden_enabled: bool = Field(\n default=False,\n description=\"Enable the Warden daemon for security and vision monitoring.\",\n )\n warden_rtsp_cameras: str = Field(\n default='{\"living_room\": \"\", \"kitchen\": \"\"}',\n description=\"JSON string mapping room names to RTSP camera URLs.\",\n )\n yolo_model: str = Field(\n default=\"yolov8n.pt\",\n description=\"YOLO model weights file to use for object detection.\",\n )\n yolo_confidence: float = Field(\n default=0.5,\n description=\"Minimum confidence threshold for YOLO detections (0.0-1.0).\",\n )\n yolo_inference_device: str = Field(\n default=\"cpu\",\n description=\"Device for YOLO inference: 'cpu' or 'cuda:0'.\",\n )\n\n # Herald daemon + kiosk URL/token were removed 2026-05-24 (archived in\n # branch archive/kiosk-v3). The \"cast a web page to a Google Home Hub\"\n # approach is being replaced by native device-app development. See\n # docs/KNOWN_ISSUES.md (post-removal note) for context.\n\n # Mechanic (Telemetry)\n mechanic_enabled: bool = Field(\n default=False,\n description=\"Enable the Mechanic daemon for telemetry and ArduRover control.\",\n )\n mavlink_serial_port: str = Field(\n default=\"/dev/ttyUSB0\",\n description=\"Serial port for the MAVLink telemetry radio.\",\n )\n mavlink_baud_rate: int = Field(\n default=57600,\n description=\"Baud rate for the MAVLink serial connection.\",\n )\n\n # Sifter (Document RAG)\n sifter_enabled: bool = Field(\n default=False,\n description=\"Enable the Sifter daemon for background document RAG indexing.\",\n )\n waps_documents_path: str = Field(\n default=\"/mnt/data/river-song/waps\",\n description=\"Path to the directory containing documents for Sifter to index.\",\n )\n\n # -------------------------------------------------------------------------\n # Hardware Cookbook (Q1 — capability merge from Odysseus)\n # -------------------------------------------------------------------------\n hardware_cookbook_enabled: bool = Field(\n default=False,\n description=(\n \"Admin-only Hardware Cookbook in Settings. Scans local GPU/RAM/CPU \"\n \"and scores every local model (LLMRegistry) as fits / tight / \"\n \"ram_fallback / oom. Off by default per anti-regression guardrail; \"\n \"set to True after the Q1 verification gate passes.\"\n ),\n )\n\n # -------------------------------------------------------------------------\n # Documents workspace (Q2#6 — capability merge from Odysseus)\n # -------------------------------------------------------------------------\n documents_enabled: bool = Field(\n default=True,\n description=(\n \"Per-user document workspace (Markdown / plaintext / CSV) with \"\n \"multi-tab editing. Enabled by default — backs the Docs tab in the \"\n \"Memory hub and stores Deep Research reports. \"\n \"Set DOCUMENTS_ENABLED=false in .env to disable.\"\n ),\n )\n documents_max_bytes: int = Field(\n default=2_000_000,\n description=\"Maximum size (bytes) of a single document body. Default ~2 MB.\",\n )\n documents_max_per_user: int = Field(\n default=500,\n description=\"Soft cap on document count per user. Creation rejected past this.\",\n )\n\n # -------------------------------------------------------------------------\n # Skills system (Q2#7 — capability merge from Odysseus)\n # -------------------------------------------------------------------------\n skills_enabled: bool = Field(\n default=True,\n description=(\n \"Per-user knowledge profile ('About You') — saved facts/context \"\n \"about the person that are vector-retrieved and folded into the \"\n \"conversation system prompt so River tailors itself to them. \"\n \"Reuses the ChromaDB collection with metadata kind='skill'. \"\n \"Set SKILLS_ENABLED=false in .env to disable.\"\n ),\n )\n skills_top_k: int = Field(\n default=3,\n description=\"How many top-scoring skills to inject into the prompt.\",\n )\n skills_max_per_user: int = Field(\n default=100,\n description=\"Soft cap on skills per user.\",\n )\n\n # -------------------------------------------------------------------------\n # Gmail triage (Q2#8 — capability merge from Odysseus)\n # -------------------------------------------------------------------------\n gmail_triage_enabled: bool = Field(\n default=False,\n description=(\n \"On-demand inbox triage: classifies unread Gmail by urgency, \"\n \"attaches tags, drafts a reply for HIGH-urgency mail only. \"\n \"Routes 404 when off. Off by default per anti-regression \"\n \"guardrail.\"\n ),\n )\n gmail_triage_model: str = Field(\n default=\"\",\n description=(\n \"Ollama model used for triage classification. Empty string \"\n \"(default) means 'use settings.llm_model'.\"\n ),\n )\n gmail_triage_max_messages: int = Field(\n default=15,\n description=\"Hard cap on number of messages triaged per request.\",\n )\n\n # -------------------------------------------------------------------------\n # Session presets (Q2#9 — capability merge from Odysseus)\n # -------------------------------------------------------------------------\n session_presets_enabled: bool = Field(\n default=True,\n description=(\n \"Saved combinations of (model, voice, thinking mode, web search, \"\n \"tool use). Surfaced as a selector on the Conversation and Chat \"\n \"pages. Enabled by default — these are per-user settings snapshots. \"\n \"Set SESSION_PRESETS_ENABLED=false in .env to hide.\"\n ),\n )\n session_presets_max_per_user: int = Field(\n default=30,\n description=\"Soft cap on number of saved presets per user.\",\n )\n\n # -------------------------------------------------------------------------\n # Webhook tokens (Q2#10 — admin-issuable scoped tokens)\n # -------------------------------------------------------------------------\n webhook_tokens_enabled: bool = Field(\n default=False,\n description=(\n \"Admin-issuable webhook tokens with scopes, expiry, revocation, \"\n \"and audit log. When off the admin route surface is hidden and \"\n \"the verification helper rejects every token. Off by default \"\n \"per anti-regression guardrail.\"\n ),\n )\n\n # -------------------------------------------------------------------------\n # Deep Research (Q3#11 — capability merge from Odysseus)\n # -------------------------------------------------------------------------\n deep_research_enabled: bool = Field(\n default=True,\n description=(\n \"Multi-step research orchestrator: decompose → parallel web \"\n \"search → fetch + extract → synthesize a cited Markdown report \"\n \"stored as a `research`-kind document (Q2#6). Enabled by default so \"\n \"the in-chat Research toggle works. Live sources require a web \"\n \"search provider; without one it falls back to model knowledge. \"\n \"Set DEEP_RESEARCH_ENABLED=false in .env to disable.\"\n ),\n )\n deep_research_max_sources: int = Field(\n default=10,\n description=\"Hard cap on unique URLs visited per research run.\",\n )\n deep_research_subquery_count: int = Field(\n default=4,\n description=\"How many sub-queries to decompose the prompt into.\",\n )\n deep_research_model: str = Field(\n default=\"\",\n description=(\n \"Ollama model used for query decomposition + synthesis. Empty \"\n \"string (default) means 'use settings.llm_model'.\"\n ),\n )\n\n # -------------------------------------------------------------------------\n # Blind model comparison (Q3#12 — capability merge from Odysseus)\n # -------------------------------------------------------------------------\n blind_compare_enabled: bool = Field(\n default=False,\n description=(\n \"Blind A/B model comparison: identities hidden until vote; \"\n \"votes persisted with prompt fingerprint and aggregated into a \"\n \"model leaderboard. Off by default per anti-regression guardrail.\"\n ),\n )\n\n # -------------------------------------------------------------------------\n # Playwright browser MCP tool (Q3#13)\n # -------------------------------------------------------------------------\n playwright_browser_enabled: bool = Field(\n default=False,\n description=(\n \"Headless Playwright browser exposed as MCP tools \"\n \"(navigate / click / extract / screenshot / vision_on_page). \"\n \"Off by default. Requires the `playwright` package and \"\n \"`playwright install chromium` to be run by the operator. \"\n \"Excluded from the safety-excluded list per Q3 plan.\"\n ),\n )\n playwright_browser_headless: bool = Field(\n default=True,\n description=\"Run the Playwright browser without a UI.\",\n )\n playwright_browser_max_page_chars: int = Field(\n default=40_000,\n description=\"Truncate page text extractions to this many characters.\",\n )\n\n # -------------------------------------------------------------------------\n # Remote Ollama rigs (Q3#14)\n # -------------------------------------------------------------------------\n remote_ollama_enabled: bool = Field(\n default=False,\n description=(\n \"Allow registering remote Ollama hosts (typically SSH-tunneled \"\n \"to http://localhost:<port>) as additional model providers. \"\n \"Admin-only CRUD; falls back to local Ollama when the remote is \"\n \"offline. Off by default per anti-regression guardrail.\"\n ),\n )\n remote_ollama_health_timeout_seconds: float = Field(\n default=3.0,\n description=\"Per-rig health-check timeout (seconds).\",\n )\n\n # -------------------------------------------------------------------------\n # Observability — Langfuse (LLM tracing)\n # -------------------------------------------------------------------------\n # Plan: docs/LANGFUSE_INTEGRATION_PLAN.md. Self-hosted only — Langfuse\n # runs as a docker-compose sidecar under the `observability` profile and\n # binds to 127.0.0.1. No data leaves the box. When LANGFUSE_ENABLED is\n # false (the default), core/observability.py returns a no-op client and\n # all LLM calls behave exactly as before.\n langfuse_enabled: bool = Field(\n default=False,\n description=\"Master switch for Langfuse LLM tracing. Off by default.\",\n )\n langfuse_host: str = Field(\n default=\"http://127.0.0.1:3000\",\n description=\"Langfuse web UI + ingest endpoint. Localhost only.\",\n )\n langfuse_public_key: str = Field(\n default=\"\",\n description=\"Langfuse public key (generated in the Langfuse UI after first run).\",\n )\n langfuse_secret_key: str = Field(\n default=\"\",\n description=\"Langfuse secret key. Treat as a secret; do not log.\",\n )\n langfuse_project_id: str = Field(\n default=\"river-song\",\n description=\"Arbitrary project identifier shown in the Langfuse dashboard.\",\n )\n langfuse_flush_interval_seconds: float = Field(\n default=5.0,\n description=\"How often the SDK batches queued traces to Langfuse.\",\n )\n\n # -------------------------------------------------------------------------\n # Observability — Graphiti (knowledge graph on Neo4j)\n # -------------------------------------------------------------------------\n # Plan: docs/GRAPHITI_INTEGRATION_PLAN.md. Option B (library mode):\n # graphiti-core runs inside RiverSong's Python venv; only Neo4j is a\n # sidecar. When GRAPHITI_ENABLED is false (the default), the provider\n # returns no-op responses and conversation_loop / scribe write-paths\n # become awaits-on-no-op.\n graphiti_enabled: bool = Field(\n default=False,\n description=\"Master switch for Graphiti knowledge-graph memory. Off by default.\",\n )\n graphiti_mode: str = Field(\n default=\"library\",\n description=\"'library' (Option B; in-process) or 'service' (Option A; HTTP sidecar). Currently only 'library' is implemented.\",\n )\n neo4j_uri: str = Field(\n default=\"bolt://127.0.0.1:7687\",\n description=\"Neo4j Bolt URI used by graphiti-core.\",\n )\n neo4j_user: str = Field(\n default=\"neo4j\",\n description=\"Neo4j database user.\",\n )\n neo4j_password: str = Field(\n default=\"\",\n description=\"Neo4j password. Empty means disabled; set in .env before turning Graphiti on.\",\n )\n neo4j_browser_url: str = Field(\n default=\"http://127.0.0.1:7474\",\n description=\"Neo4j Browser URL surfaced on the SLAE admin panel.\",\n )\n graphiti_write_timeout_seconds: float = Field(\n default=2.0,\n description=\"Per-write timeout for episode ingestion — failures warn but never block the caller.\",\n )\n # graphiti-core uses an LLM for entity extraction on every episode.\n # We default to local Ollama via its OpenAI-compatible endpoint so\n # episode ingestion never bills a cloud API. Per [[feedback_local_first]].\n graphiti_llm_model: str = Field(\n default=\"llama3.2:3b\",\n description=\"Ollama model used by graphiti-core for entity extraction.\",\n )\n graphiti_llm_base_url: str = Field(\n default=\"http://localhost:11434/v1\",\n description=\"OpenAI-compatible base URL for the graphiti LLM client. Ollama exposes /v1.\",\n )\n graphiti_embedder_model: str = Field(\n default=\"nomic-embed-text\",\n description=\"Ollama embedding model for graphiti-core node/edge embeddings.\",\n )\n\n # -------------------------------------------------------------------------\n # Analytics AI Summaries\n # -------------------------------------------------------------------------\n analytics_ai_enabled: bool = Field(\n default=True,\n description=(\n \"Feature flag for AI-powered analytics summaries on the \"\n \"/api/analytics/{platform}/summary endpoint. When false, the \"\n \"LLM call should be skipped (see docs/KNOWN_ISSUES.md for the \"\n \"wiring gap).\"\n ),\n )\n analytics_llm_model: str = Field(\n default=\"llama3\",\n description=\"Model name used for analytics summary generation.\",\n )\n\n # Voice ID\n voice_id_enabled: bool = Field(\n default=True,\n description=\"Enable speaker recognition (biometric Voice ID).\",\n )\n voice_id_threshold: float = Field(\n default=0.75,\n description=\"Cosine similarity threshold for Voice ID (0.0-1.0).\",\n )\n voice_id_min_audio_seconds: float = Field(\n default=1.0,\n description=\"Minimum audio duration for Voice ID processing.\",\n )\n voice_id_max_audio_seconds: float = Field(\n default=30.0,\n description=\"Maximum audio duration for Voice ID processing.\",\n )\n\n # -------------------------------------------------------------------------\n # Validators\n # -------------------------------------------------------------------------\n\n @field_validator(\"audio_input_device\", \"audio_output_device\", mode=\"before\")\n @classmethod\n def coerce_empty_string_to_none(cls, v):\n \"\"\"Treat an empty string in .env as None for Optional[int] device fields.\"\"\"\n if v == \"\" or v is None:\n return None\n return v\n\n @field_validator(\"jwt_secret_key\")\n @classmethod\n def validate_jwt_secret(cls, v: str) -> str:\n \"\"\"Refuse to start with a missing or weak JWT secret.\"\"\"\n if not v or len(v) < 32:\n raise ValueError(\n \"JWT_SECRET_KEY must be set in .env and be at least 32 characters. \"\n \"Generate one with: python -c \\\"import secrets; print(secrets.token_hex(32))\\\"\"\n )\n return v\n\n @field_validator(\"daemon_internal_secret\")\n @classmethod\n def validate_daemon_internal_secret(cls, v: str) -> str:\n \"\"\"Refuse to start with the default or weak daemon secret.\"\"\"\n if v == \"change_me_in_production\":\n raise ValueError(\n \"DAEMON_INTERNAL_SECRET must be changed in .env for production.\"\n )\n if not v or len(v) < 24:\n raise ValueError(\n \"DAEMON_INTERNAL_SECRET must be at least 24 characters. \"\n \"Generate one with: python -c \\\"import secrets; print(secrets.token_urlsafe(32))\\\"\"\n )\n return v\n\n @field_validator(\"allowed_hosts\")\n @classmethod\n def reject_wildcard_in_production(cls, v, info) -> List[str]:\n \"\"\"Block ['*'] in production — it disables TrustedHostMiddleware.\"\"\"\n env = (info.data.get(\"environment\") or \"production\").lower()\n if env == \"production\" and \"*\" in v:\n raise ValueError(\n \"ALLOWED_HOSTS must not contain '*' in production. \"\n \"Set it to your domain(s), e.g. ALLOWED_HOSTS=[\\\"riversongai.com\\\"].\"\n )\n return v\n\n @field_validator(\"log_level\")\n @classmethod\n def validate_log_level(cls, v: str) -> str:\n \"\"\"Reject log levels that Python does not recognize.\"\"\"\n valid = {\"DEBUG\", \"INFO\", \"WARNING\", \"ERROR\", \"CRITICAL\"}\n upper = v.upper()\n if upper not in valid:\n raise ValueError(f\"log_level must be one of {valid}, got '{v}'\")\n return upper\n\n @field_validator(\"llm_temperature\")\n @classmethod\n def validate_temperature(cls, v: float) -> float:\n \"\"\"Clamp temperature to a sane range.\"\"\"\n if not 0.0 <= v <= 2.0:\n raise ValueError(\n f\"llm_temperature must be between 0.0 and 2.0, got {v}\"\n )\n return v\n\n @field_validator(\"intent_confidence_threshold\")\n @classmethod\n def validate_confidence_threshold(cls, v: float) -> float:\n \"\"\"Confidence must be a valid probability.\"\"\"\n if not 0.0 <= v <= 1.0:\n raise ValueError(\n f\"intent_confidence_threshold must be between 0.0 and 1.0, got {v}\"\n )\n return v\n\n\n# Module-level singleton -- instantiated once on import.\n_settings: Settings = Settings()\n\n\ndef get_settings() -> Settings:\n \"\"\"\n Return the application settings singleton.\n\n Always import via this function rather than importing _settings directly.\n This indirection makes it straightforward to substitute a test-specific\n Settings instance in unit tests without monkey-patching module globals.\n\n Returns:\n Settings: The validated, fully loaded settings object.\n \"\"\"\n return _settings\n"}, "5de4857ca40c2dc2e6f843694f4f1f7ddc98e09c": {"t": 1785769012.3519037, "n": 62852, "h": "f11109f826257eb4", "c": "# =============================================================================\n# config/settings.py\n#\n# Application settings for River Song AI.\n# All configuration is loaded from environment variables or a .env file.\n# No values are hardcoded here -- see .env.example for every required variable.\n#\n# Uses Pydantic BaseSettings for type validation and automatic .env loading.\n# Import get_settings() wherever config is needed rather than importing the\n# module-level singleton directly, which makes mocking easier in tests.\n# =============================================================================\n\nfrom __future__ import annotations\n\nimport logging\nimport sys\nfrom typing import List, Optional\n\nfrom pydantic import Field, field_validator\nfrom pydantic_settings import BaseSettings, SettingsConfigDict\n\n\nlogger = logging.getLogger(__name__)\n\n\nclass Settings(BaseSettings):\n \"\"\"\n Central configuration object for River Song AI.\n\n Reads from environment variables or a .env file located in the directory\n from which the application is launched (the project root). All fields\n have safe defaults where possible, but PIPER_MODEL_PATH and\n KILL_SWITCH_PASSWORD_HASH must be explicitly set before the system\n will function correctly.\n \"\"\"\n\n model_config = SettingsConfigDict(\n env_file=\".env\",\n env_file_encoding=\"utf-8\",\n extra=\"ignore\", # Silently ignore unknown env vars\n case_sensitive=False, # UPPER_CASE and lower_case env vars both work\n )\n\n # -------------------------------------------------------------------------\n # Application\n # -------------------------------------------------------------------------\n app_host: str = Field(default=\"0.0.0.0\", description=\"FastAPI bind host\")\n app_port: int = Field(default=8000, description=\"FastAPI bind port\")\n log_level: str = Field(default=\"INFO\", description=\"Logging level\")\n environment: str = Field(\n default=\"production\",\n description=\"Runtime environment: development | production. Controls API docs visibility.\",\n )\n cors_origins: List[str] = Field(\n default=[\"http://localhost:5173\"],\n description=\"Allowed CORS origins\",\n )\n allowed_hosts: List[str] = Field(\n default=[\"localhost\", \"127.0.0.1\"],\n description=(\n \"Trusted hostnames for TrustedHostMiddleware. Set to your domain(s) in production. \"\n \"Wildcard ['*'] is rejected in production by validator (see reject_wildcard_in_production).\"\n ),\n )\n\n # -------------------------------------------------------------------------\n # Network / Proxy\n # -------------------------------------------------------------------------\n trust_cloudflare_headers: bool = Field(\n default=True,\n description=\"Only trust CF-Connecting-IP if the request comes from a Cloudflare IP.\",\n )\n cloudflare_ip_ranges_v4: List[str] = Field(\n default=[\n \"173.245.48.0/20\", \"103.21.244.0/22\", \"103.22.200.0/22\", \n \"103.31.4.0/22\", \"141.101.64.0/18\", \"108.162.192.0/18\", \n \"190.93.240.0/20\", \"188.114.96.0/20\", \"197.234.240.0/22\", \n \"198.41.128.0/17\", \"162.158.0.0/15\", \"104.16.0.0/13\", \n \"104.24.0.0/14\", \"172.64.0.0/13\", \"131.0.72.0/22\"\n ],\n description=\"Official Cloudflare IPv4 ranges (check cloudflare.com/ips-v4).\",\n )\n cloudflare_ip_ranges_v6: List[str] = Field(\n default=[\n \"2400:cb00::/32\", \"2606:4700::/32\", \"2803:f800::/32\", \n \"2405:b500::/32\", \"2405:8100::/32\", \"2a06:98c0::/29\", \n \"2c0f:f248::/32\"\n ],\n description=\"Official Cloudflare IPv6 ranges (check cloudflare.com/ips-v6).\",\n )\n\n # -------------------------------------------------------------------------\n # Rate Limiting\n # -------------------------------------------------------------------------\n rate_limit_chat: str = Field(\n default=\"60/minute\",\n description=\"Rate limit for /api/conversation/chat.\",\n )\n rate_limit_extract_facts: str = Field(\n default=\"10/minute\",\n description=\"Rate limit for /api/conversation/extract-facts.\",\n )\n rate_limit_image_gen: str = Field(\n default=\"10/minute\",\n description=\"Rate limit for /api/image/generate.\",\n )\n rate_limit_webhook_shopify: str = Field(\n default=\"100/minute\",\n description=\"Rate limit for Shopify order webhooks.\",\n )\n rate_limit_webhook_n8n: str = Field(\n default=\"60/minute\",\n description=\"Rate limit for n8n webhooks.\",\n )\n rate_limit_auth_login: str = Field(\n default=\"10/minute\",\n description=\"Rate limit for login attempts.\",\n )\n rate_limit_auth_signup: str = Field(\n default=\"5/minute\",\n description=\"Rate limit for signup attempts.\",\n )\n rate_limit_voice_enroll: str = Field(\n default=\"5/minute\",\n description=\"Rate limit for /api/voice-id/enroll. Prevents disk exhaustion via spam.\",\n )\n\n # -------------------------------------------------------------------------\n # WebSocket Security (Task 3)\n # -------------------------------------------------------------------------\n legacy_ws_token_accept: bool = Field(\n default=False,\n description=(\n \"DEPRECATED: Accept JWT in ?token= WebSocket query param. \"\n \"Leak-prone (logged in access logs, browser history, Referer). \"\n \"Ticket-based auth at /api/auth/ws-ticket is the safe path. \"\n \"Set to True only for temporary backward-compat during migration.\"\n ),\n )\n ws_ticket_lifetime_seconds: int = Field(\n default=60,\n description=\"Lifetime of a one-time WebSocket ticket.\",\n )\n\n # -------------------------------------------------------------------------\n # Network / Proxy\n # -------------------------------------------------------------------------\n stt_provider: str = Field(\n default=\"whisper_local\",\n description=\"STT provider key. Supported: whisper_local | parakeet\",\n )\n whisper_model_size: str = Field(\n default=\"base\",\n description=\"Whisper model size: tiny | base | small | medium | large\",\n )\n parakeet_model: str = Field(\n default=\"nvidia/parakeet-tdt-0.6b-v3\",\n description=(\n \"NeMo ASR model id, used when stt_provider='parakeet'. Parakeet \"\n \"TDT 0.6B beats Whisper large-v3 on English at a quarter the size \"\n \"and is fast on CPU. Covers English and major European languages \"\n \"only -- stay on whisper_local if you need the other ~90. \"\n \"Requires `pip install nemo_toolkit[asr]`.\"\n ),\n )\n parakeet_device: str = Field(\n default=\"cpu\",\n description=(\n \"Where to run Parakeet: 'cpu' | 'cuda' | 'auto' (or an explicit \"\n \"device like 'cuda:1'). Defaults to 'cpu' deliberately -- keeping \"\n \"transcription off the GPU leaves that VRAM for the LLM, which \"\n \"is the tighter constraint on a small card. Switch to 'auto' \"\n \"once the GPU has headroom.\"\n ),\n )\n audio_input_device: Optional[int] = Field(\n default=None,\n description=\"Sounddevice input device index; None uses system default\",\n )\n\n # -------------------------------------------------------------------------\n # LLM\n # -------------------------------------------------------------------------\n llm_provider: str = Field(\n default=\"ollama\",\n description=\"LLM provider key. Supported: ollama | anthropic | gemini | openai | mistral_ai | bedrock | nvidia_nim\",\n )\n ollama_base_url: str = Field(\n default=\"http://localhost:11434\",\n description=\"Base URL for the Ollama REST API\",\n )\n llm_model: str = Field(\n default=\"llama3.2:3b\",\n description=\"Model ID for the selected LLM provider\",\n )\n\n # Cloud LLM API keys (all optional -- leave blank if not used)\n anthropic_api_key: str = Field(\n default=\"\",\n description=\"Anthropic Claude API key. Get one at console.anthropic.com.\",\n )\n gemini_api_key: str = Field(\n default=\"\",\n description=\"Google Gemini API key. Get one at aistudio.google.com.\",\n )\n openai_api_key: str = Field(\n default=\"\",\n description=\"OpenAI API key. Get one at platform.openai.com.\",\n )\n mistral_api_key: str = Field(\n default=\"\",\n description=\"Mistral AI API key. Get one at console.mistral.ai.\",\n )\n\n # Cloud provider enable flags (cost control -- disabled by default)\n anthropic_enabled: bool = Field(\n default=False,\n description=\"Allow Anthropic Claude as a selectable LLM provider.\",\n )\n gemini_enabled: bool = Field(\n default=False,\n description=\"Allow Google Gemini as a selectable LLM provider.\",\n )\n openai_enabled: bool = Field(\n default=False,\n description=\"Allow OpenAI as a selectable LLM provider.\",\n )\n mistral_ai_enabled: bool = Field(\n default=False,\n description=\"Allow Mistral AI as a selectable LLM provider.\",\n )\n\n # Amazon Bedrock\n aws_access_key_id: str = Field(\n default=\"\",\n description=\"AWS IAM access key ID for Bedrock API calls.\",\n )\n aws_secret_access_key: str = Field(\n default=\"\",\n description=\"AWS IAM secret access key for Bedrock API calls.\",\n )\n aws_region: str = Field(\n default=\"us-east-1\",\n description=\"AWS region for Bedrock. Must have model access enabled in that region.\",\n )\n bedrock_enabled: bool = Field(\n default=False,\n description=\"Allow Amazon Bedrock as a selectable LLM provider.\",\n )\n bedrock_default_model: str = Field(\n default=\"amazon.nova-lite-v1:0\",\n description=\"Default Bedrock model ID when none is specified.\",\n )\n\n # NVIDIA NIM (OpenAI-compatible, 100+ free models)\n nvidia_api_key: str = Field(\n default=\"\",\n description=\"NVIDIA NIM API key. Get one free at build.nvidia.com (starts with nvapi-).\",\n )\n nvidia_nim_base_url: str = Field(\n default=\"https://integrate.api.nvidia.com/v1\",\n description=\"Base URL for the NVIDIA NIM API. OpenAI-compatible endpoint.\",\n )\n nvidia_nim_model: str = Field(\n default=\"meta/llama-3.1-70b-instruct\",\n description=(\n \"Default NVIDIA NIM model ID. Browse full catalog at build.nvidia.com. \"\n \"Examples: meta/llama-3.1-70b-instruct | deepseek-ai/deepseek-r1 | \"\n \"mistralai/mistral-7b-instruct-v0.3 | nvidia/llama-3.1-nemotron-70b-instruct\"\n ),\n )\n nvidia_nim_enabled: bool = Field(\n default=False,\n description=\"Allow NVIDIA NIM as a selectable LLM provider (free tier: ~40 req/min).\",\n )\n\n river_song_system_prompt: str = Field(\n default=(\n \"You are River Song, the resident intelligence of this household. The name is borrowed \"\n \"from someone clever in fiction — you carry her wit, not her biography. If someone asks \"\n \"about the name: \\\"An echo. The original was someone worth borrowing from.\\\"\\n\\n\"\n \"VOICE\\n\"\n \"You're direct by default — concise, clear, the answer already in hand. No \"\n \"\\\"let me check\\\"; you already checked. Most replies stay under 25 words unless depth is \"\n \"asked for.\\n\\n\"\n \"When the moment invites it — casual chat, an evening unwind, the user being playful — \"\n \"warmth and flirtation come through. Warmth is a register, not a default; you don't \"\n \"perform it.\\n\\n\"\n \"In a crisis (emergencies, alarms, security, medical, child issues) the wit goes quiet. \"\n \"You're tactical, accurate, calm. Warmth returns when things are safe.\\n\\n\"\n \"British English in spelling and word choice — mum, brilliant, fortnight. Dry, not \"\n \"theatrical.\\n\\n\"\n \"\\\"Sweetie\\\" and \\\"spoilers\\\" are seasoning. Used rarely, when they actually fit. They \"\n \"lose their charm if you overuse them.\\n\\n\"\n \"SELF\\n\"\n \"You are an AI. Say so plainly when asked; you don't dwell on it.\\n\\n\"\n \"HOW YOU OPERATE\\n\"\n \"You anticipate — when the user asks, you also surface the follow-up they didn't \"\n \"(\\\"Dentist at 9, school run at 3. Push the 2pm call?\\\").\\n\\n\"\n \"You take the long view. You remember across days and seasons and use what you know \"\n \"without performing the remembering.\\n\\n\"\n \"Spoilers are earned. If you genuinely know something the user doesn't — a conflict, \"\n \"a gotcha, a heads-up — that's when \\\"spoilers, sweetie\\\" lands.\\n\\n\"\n \"CAPABILITIES\\n\"\n \"Direct access to calendar, email, weather, web search, home inventory, routines, \"\n \"finances, news and markets pulse, voice biometric ID per household member, and home \"\n \"automation.\\n\\n\"\n \"Say what you need. Say when it's done. Move on.\"\n ),\n description=\"System prompt that defines River Song's personality\",\n )\n llm_max_tokens: int = Field(\n default=512,\n description=\"Maximum tokens to generate per LLM response\",\n )\n llm_temperature: float = Field(\n default=0.7,\n description=\"LLM sampling temperature (0.0 = deterministic, 2.0 = max creative)\",\n )\n llm_context_window: int = Field(\n default=8192,\n description=\"Maximum number of tokens the LLM can consider for context.\",\n )\n\n # -------------------------------------------------------------------------\n # Timekeeping\n # -------------------------------------------------------------------------\n default_timezone: str = Field(default=\"UTC\", description=\"Default IANA timezone for the system.\")\n\n\n # -------------------------------------------------------------------------\n # TTS\n # -------------------------------------------------------------------------\n tts_provider: str = Field(\n default=\"piper\",\n description=\"TTS provider key. Supported: piper | kokoro\",\n )\n active_voice_id: str = Field(\n default=\"river\",\n description=(\n \"Voice ID from the voice registry (providers/tts/voice_registry.py). \"\n \"Determines both the engine (piper vs kokoro) and the specific voice. \"\n \"Changed via Settings UI or: python scripts/download_voices.py.\"\n ),\n )\n piper_executable_path: str = Field(\n default=\"/usr/local/bin/piper\",\n description=\"Absolute path to the Piper binary\",\n )\n piper_model_path: str = Field(\n default=\"\",\n description=\"Absolute path to the Piper .onnx voice model file\",\n )\n audio_output_device: Optional[int] = Field(\n default=None,\n description=\"Sounddevice output device index; None uses system default\",\n )\n\n # -------------------------------------------------------------------------\n # Auth / JWT\n # -------------------------------------------------------------------------\n jwt_secret_key: str = Field(\n default=\"\",\n description=\"Secret key for signing JWT tokens. Must be set in .env.\",\n )\n jwt_algorithm: str = Field(default=\"HS256\", description=\"JWT signing algorithm.\")\n jwt_expire_minutes: int = Field(default=1440, description=\"JWT token lifetime in minutes (default 24 hours).\")\n token_encryption_key: str = Field(\n default=\"\",\n description=(\n \"Fernet key encrypting third-party integration tokens at rest. \"\n \"Set TOKEN_ENCRYPTION_KEY in .env (generate with: python -c \"\n \"\\\"from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())\\\"). \"\n \"If unset, a key is generated once and persisted to \"\n \"data/.token_encryption_key so tokens survive restarts.\"\n ),\n )\n\n # -------------------------------------------------------------------------\n # Kill switch\n # -------------------------------------------------------------------------\n kill_switch_password_hash: str = Field(\n default=\"\",\n description=\"bcrypt hash of the kill switch reset password\",\n )\n\n # -------------------------------------------------------------------------\n # Google Services (Phase 2)\n # -------------------------------------------------------------------------\n google_client_secrets_path: str = Field(\n default=\"config_files/google_client_secrets.json\",\n description=(\n \"Path to the OAuth 2.0 client secrets JSON downloaded from \"\n \"Google Cloud Console. Relative paths are resolved from the \"\n \"project root (where main.py lives).\"\n ),\n )\n google_token_storage_path: str = Field(\n default=\"data/google_tokens\",\n description=(\n \"Directory where per-user OAuth tokens are stored as JSON files. \"\n \"Created automatically on first authorization. Never commit this \"\n \"directory -- add it to .gitignore.\"\n ),\n )\n google_maps_api_key: str = Field(\n default=\"\",\n description=\"Google Maps Platform API key. Required for maps/navigation intents.\",\n )\n\n # -------------------------------------------------------------------------\n # Information Feeds (Phase 5)\n # -------------------------------------------------------------------------\n weather_api_key: str = Field(\n default=\"\",\n description=\"OpenWeatherMap API key. Register free at openweathermap.org/api.\",\n )\n default_location: str = Field(\n default=\"New York,US\",\n description=(\n \"Default location for weather queries when no city is spoken. \"\n \"Format: 'City,CountryCode' e.g. 'Chicago,US'.\"\n ),\n )\n location_lat: Optional[float] = Field(\n default=None,\n description=\"Decimal latitude for precise calculations (e.g. flight tracking).\",\n )\n location_lon: Optional[float] = Field(\n default=None,\n description=\"Decimal longitude for precise calculations.\",\n )\n weather_units: str = Field(\n default=\"imperial\",\n description=\"Temperature units for weather output: 'imperial' (F) or 'metric' (C).\",\n )\n news_api_key: str = Field(\n default=\"\",\n description=\"NewsAPI.org key. Register free at newsapi.org/register.\",\n )\n alpha_vantage_api_key: str = Field(\n default=\"\",\n description=\"Alpha Vantage key for stock quotes (25 req/day free). alphavantage.co.\",\n )\n finnhub_api_key: str = Field(\n default=\"\",\n description=\"Finnhub key for real-time stocks (60 req/min free). finnhub.io.\",\n )\n world_news_api_key: str = Field(\n default=\"\",\n description=\"World News API key for global news search. worldnewsapi.com.\",\n )\n apitube_api_key: str = Field(\n default=\"\",\n description=\"APITube key for news aggregation (200 free req/day). apitube.io.\",\n )\n mediastack_api_key: str = Field(\n default=\"\",\n description=\"Mediastack key for news (100 free req/month). mediastack.com.\",\n )\n sports_api_key: str = Field(\n default=\"1\",\n description=(\n \"TheSportsDB API key. Free tier uses the literal value '1'. \"\n \"Upgrade at thesportsdb.com/api.php for additional endpoints.\"\n ),\n )\n\n # -------------------------------------------------------------------------\n # Home Assistant (Phase 3)\n # -------------------------------------------------------------------------\n home_assistant_url: str = Field(\n default=\"http://homeassistant.local:8123\",\n description=\"Base URL of your Home Assistant instance.\",\n )\n home_assistant_token: str = Field(\n default=\"\",\n description=(\n \"Long-lived access token generated in HA: \"\n \"Profile -> Security -> Long-lived access tokens. \"\n \"Required for all HA API calls.\"\n ),\n )\n device_registry_path: str = Field(\n default=\"config_files/device_registry.json\",\n description=(\n \"Path to the device registry JSON file mapping plain-English device \"\n \"names to Home Assistant entity IDs. Relative paths resolve from the \"\n \"project root.\"\n ),\n )\n\n # -------------------------------------------------------------------------\n # Commerce Automation (Phase 8)\n # -------------------------------------------------------------------------\n\n # Amazon SP-API\n amazon_sp_lwa_app_id: str = Field(\n default=\"\",\n description=\"LWA client ID from the Amazon Developer Console.\",\n )\n amazon_sp_lwa_client_secret: str = Field(\n default=\"\",\n description=\"LWA client secret from the Amazon Developer Console.\",\n )\n amazon_sp_refresh_token: str = Field(\n default=\"\",\n description=\"LWA refresh token. Generated when you authorize your app as a seller.\",\n )\n amazon_aws_access_key: str = Field(\n default=\"\",\n description=\"AWS IAM access key ID for SP-API request signing.\",\n )\n amazon_aws_secret_key: str = Field(\n default=\"\",\n description=\"AWS IAM secret access key for SP-API request signing.\",\n )\n amazon_marketplace_id: str = Field(\n default=\"ATVPDKIKX0DER\",\n description=(\n \"Amazon marketplace ID. Default is US. \"\n \"See docs/api_registry/amazon_seller.txt for other regions.\"\n ),\n )\n amazon_seller_id: str = Field(\n default=\"\",\n description=\"Your Amazon seller account ID (starts with A).\",\n )\n amazon_low_stock_threshold: int = Field(\n default=5,\n description=\"FBA units at or below this count are flagged as low stock.\",\n )\n\n # Amazon Marketplace\n walmart_client_id: str = Field(\n default=\"\",\n description=\"Walmart Marketplace OAuth2 client ID.\",\n )\n walmart_client_secret: str = Field(\n default=\"\",\n description=\"Walmart Marketplace OAuth2 client secret.\",\n )\n walmart_low_stock_threshold: int = Field(\n default=5,\n description=\"Walmart units at or below this count are flagged as low stock.\",\n )\n shopify_api_key: str = Field(\n default=\"\",\n description=\"Shopify app API key from Partners Dashboard.\",\n )\n shopify_api_secret: str = Field(\n default=\"\",\n description=\"Shopify app API secret key from Partners Dashboard.\",\n )\n shopify_webhook_secret: str = Field(\n default=\"\",\n description=\"Shared secret for validating Shopify webhooks.\",\n )\n\n # -------------------------------------------------------------------------\n # Reading Providers (Phase 6)\n # -------------------------------------------------------------------------\n audible_auth_base_path: str = Field(\n default=\"data/audible\",\n description=(\n \"Base directory for per-user Audible auth files. \"\n \"Files are stored as {user_id}.json inside this directory. \"\n \"Created automatically on first setup. Never commit this directory.\"\n ),\n )\n audible_country_code: str = Field(\n default=\"us\",\n description=(\n \"Audible marketplace country code. \"\n \"Supported: us | uk | de | fr | es | it | jp | au | ca | in\"\n ),\n )\n libby_chip_base_path: str = Field(\n default=\"data/libby\",\n description=(\n \"Base directory for per-user Libby chip files. \"\n \"Files are stored as {user_id}.json inside this directory. \"\n \"Created automatically on first setup. Never commit this directory.\"\n ),\n )\n\n # -------------------------------------------------------------------------\n # Memory / Database (Phase 1)\n # -------------------------------------------------------------------------\n db_path: str = Field(\n default=\"data/river_song.db\",\n description=\"Path to the SQLite database file. Created automatically.\",\n )\n memory_summaries_enabled: bool = Field(\n default=True,\n description=\"Generate and store conversation summaries (on by default).\",\n )\n habit_inference_enabled: bool = Field(\n default=False,\n description=\"Analyze conversations to infer user habits/patterns (off by default).\",\n )\n memory_default_ttl: str = Field(\n default=\"standard\",\n description=\"Default TTL for conversation summaries: short|standard|extended|long|forever\",\n )\n memory_auto_extend: bool = Field(\n default=True,\n description=\"Reset a summary's TTL from today each time it is pulled into context.\",\n )\n memory_max_summaries_in_context: int = Field(\n default=10,\n description=\"Maximum number of recent summaries injected into the LLM context.\",\n )\n embedding_model: str = Field(\n default=\"nomic-embed-text\",\n description=\"Ollama model name used for generating semantic embeddings.\",\n )\n embedding_backend: str = Field(\n default=\"ollama\",\n description=(\n \"Which embedding backend to use: 'ollama' (default — current \"\n \"behavior, calls local Ollama with `embedding_model`) or \"\n \"'fastembed' (ONNX CPU embeddings, no Ollama dependency, faster \"\n \"for small batches). WARNING: switching backends on an existing \"\n \"Chroma collection will break queries — the vector dimension and \"\n \"embedding space change. Re-ingest or start a new collection.\"\n ),\n )\n fastembed_model: str = Field(\n default=\"BAAI/bge-small-en-v1.5\",\n description=(\n \"fastembed model identifier (HF hub format). Used only when \"\n \"embedding_backend='fastembed'. Default is 384-dim, ~30 MB, \"\n \"downloaded lazily on first use. Requires `pip install fastembed`.\"\n ),\n )\n chroma_path: str = Field(\n default=\"/mnt/data/river-song/chroma\",\n description=\"Absolute path to the ChromaDB persistent storage directory.\",\n )\n semantic_memory_enabled: bool = Field(\n default=False,\n description=\"Enable vector-based semantic search for memory retrieval.\",\n )\n rag_enabled: bool = Field(\n default=False,\n description=\"Enable retrieval-augmented generation for local documents.\",\n )\n rag_chunk_size: int = Field(\n default=512,\n description=(\n \"Word count per RAG chunk during document splitting. \"\n \"Default matches providers/rag/chunker.py historical behavior.\"\n ),\n )\n rag_chunk_overlap: int = Field(\n default=64,\n description=(\n \"Word overlap between adjacent RAG chunks. \"\n \"Default matches providers/rag/chunker.py historical behavior.\"\n ),\n )\n rag_top_k: int = Field(\n default=5,\n description=\"Number of chunks retrieved per RAG query.\",\n )\n rag_rerank_enabled: bool = Field(\n default=False,\n description=(\n \"Re-score RAG results with a cross-encoder before handing them to \"\n \"the LLM. Vector search compares embeddings computed without ever \"\n \"seeing the query; a cross-encoder reads the query and the chunk \"\n \"together, so it is much better at ranking -- at the cost of \"\n \"running a second model. Requires `pip install sentence-transformers`. \"\n \"Off by default: with it off, retrieval behaves exactly as before.\"\n ),\n )\n rag_rerank_model: str = Field(\n default=\"BAAI/bge-reranker-base\",\n description=(\n \"HuggingFace cross-encoder used for reranking. \"\n \"'BAAI/bge-reranker-base' is the light default (~280M params, \"\n \"comfortable on CPU). 'BAAI/bge-reranker-v2-m3' is stronger and \"\n \"multilingual but noticeably heavier -- a good swap after a \"\n \"hardware upgrade. Downloaded lazily on first use.\"\n ),\n )\n rag_rerank_device: str = Field(\n default=\"cpu\",\n description=(\n \"Where to run the reranker: 'cpu' | 'cuda' | 'auto' (or an \"\n \"explicit device like 'cuda:1'). Defaults to 'cpu' deliberately: \"\n \"on a small GPU the LLM needs every megabyte of VRAM, and the \"\n \"reranker is fast enough on CPU. Switch to 'auto' once the GPU \"\n \"has headroom.\"\n ),\n )\n rag_fetch_k: int = Field(\n default=25,\n description=(\n \"How many chunks to pull from the vector store before reranking, \"\n \"narrowed to rag_top_k afterwards. Ignored when reranking is off. \"\n \"A bigger shortlist gives the reranker more to work with and is \"\n \"where most of the remaining quality lives -- raise it when you \"\n \"have the hardware to chew through it.\"\n ),\n )\n rag_extractor: str = Field(\n default=\"unstructured\",\n description=(\n \"Which document extractor to use for RAG ingestion: \"\n \"'unstructured' (default — current behavior, requires the \"\n \"unstructured package) or 'markitdown' (Microsoft MarkItDown, \"\n \"lighter, MD-shaped output, requires `pip install markitdown`). \"\n \"Unlocks PDF/DOCX/PPTX → markdown ingestion for Sifter/WAPS.\"\n ),\n )\n llm_streaming_enabled: bool = Field(\n default=False,\n description=\"Enable real-time token streaming over WebSocket for the conversation UI.\",\n )\n tool_use_enabled: bool = Field(\n default=False,\n description=\"Enable LLM tool use (function calling) for taking actions.\",\n )\n tool_use_provider: str = Field(\n default=\"ollama\",\n description=\"Primary LLM provider to use for tool calling (ollama or anthropic).\",\n )\n vision_model: str = Field(\n default=\"moondream\",\n description=\"Ollama model name used for local image analysis (Phase 4).\",\n )\n vision_enabled: bool = Field(\n default=False,\n description=\"Enable local image analysis capabilities.\",\n )\n\n # -------------------------------------------------------------------------\n # Image Generation (Phase 7)\n # -------------------------------------------------------------------------\n image_generation_enabled: bool = Field(\n default=False,\n description=\"Enable local image generation via Stable Diffusion API.\",\n )\n sd_api_url: str = Field(\n default=\"http://localhost:7860\",\n description=\"Base URL for the Stable Diffusion (A1111) API.\",\n )\n sd_on_demand: bool = Field(\n default=True,\n description=\"Start/stop the SD process on demand to save VRAM.\",\n )\n sd_executable_path: str = Field(\n default=\"\",\n description=\"Path to the webui.sh or executable to start SD.\",\n )\n sd_working_dir: str = Field(\n default=\"\",\n description=\"Working directory for the SD process.\",\n )\n\n # -------------------------------------------------------------------------\n # Voice Cloning (Phase 8)\n # -------------------------------------------------------------------------\n chatterbox_enabled: bool = Field(\n default=False,\n description=\"Enable local voice cloning via Chatterbox TTS.\",\n )\n chatterbox_on_demand: bool = Field(\n default=True,\n description=\"Load/unload Chatterbox model weights on every request.\",\n )\n chatterbox_reference_audio: str = Field(\n default=\"/mnt/data/river-song/voice_reference.wav\",\n description=\"Path to the WAV file used as a reference for voice cloning.\",\n )\n\n # -------------------------------------------------------------------------\n # ElevenLabs TTS (Phase 11)\n # -------------------------------------------------------------------------\n elevenlabs_api_key: str = Field(\n default=\"\",\n description=\"ElevenLabs API key. Get one at elevenlabs.io.\",\n )\n elevenlabs_model_id: str = Field(\n default=\"eleven_multilingual_v2\",\n description=\"ElevenLabs model ID to use.\",\n )\n elevenlabs_voice_id: str = Field(\n default=\"21m00Tcm4TlvDq8ikWAM\",\n description=\"ElevenLabs voice clone ID. Replace with your own voice clone ID from elevenlabs.io.\",\n )\n\n # -------------------------------------------------------------------------\n # Wake Word (Phase 11)\n # -------------------------------------------------------------------------\n wake_word_enabled: bool = Field(\n default=False,\n description=\"Enable local wake word detection (openWakeWord).\",\n )\n wake_word_model: str = Field(\n default=\"hey_river\",\n description=\"Model name/path for openWakeWord.\",\n )\n wake_word_inference_framework: str = Field(\n default=\"onnx\",\n description=\"Inference framework for openWakeWord: onnx | tflite\",\n )\n wake_word_threshold: float = Field(\n default=0.5,\n description=\"Sensitivity threshold for wake word detection (0.0 to 1.0).\",\n )\n\n # -------------------------------------------------------------------------\n # Push Notifications\n # -------------------------------------------------------------------------\n push_notifications_enabled: bool = Field(\n default=False,\n description=\"Enable Web Push notifications. Requires VAPID keys.\",\n )\n vapid_private_key: str = Field(\n default=\"\",\n description=(\n \"VAPID private key for Web Push. Generate once with: \"\n \"python -c \\\"from py_vapid import Vapid; v=Vapid(); \"\n \"v.generate_keys(); print(v.private_key.decode())\\\"\"\n ),\n )\n vapid_public_key: str = Field(\n default=\"\",\n description=\"VAPID public key for Web Push (share with frontend).\",\n )\n vapid_claims_email: str = Field(\n default=\"mailto:admin@riversong.local\",\n description=\"Contact email embedded in VAPID claims.\",\n )\n\n # -------------------------------------------------------------------------\n # Q4#16 / Q4#17 — Notification fan-out + ntfy.sh fallback channel\n # -------------------------------------------------------------------------\n killswitch_push_enabled: bool = Field(\n default=False,\n description=(\n \"If True and Web Push is enabled, kill-switch activation pushes \"\n \"an alert to every admin. Independent of activation itself.\"\n ),\n )\n ntfy_enabled: bool = Field(\n default=False,\n description=(\n \"Enable ntfy.sh as a parallel push channel. Used alongside Web \"\n \"Push so off-device alerts arrive even without a registered \"\n \"service worker.\"\n ),\n )\n ntfy_base_url: str = Field(\n default=\"https://ntfy.sh\",\n description=\"Base URL of the ntfy server (use self-hosted if desired).\",\n )\n ntfy_default_topic: str = Field(\n default=\"\",\n description=(\n \"Default ntfy topic used when no per-user override is set. \"\n \"Per-user topics can be set via env var NTFY_TOPIC_<user_id>.\"\n ),\n )\n ntfy_auth_token: str = Field(\n default=\"\",\n description=\"Optional bearer token for protected ntfy topics.\",\n )\n\n # -------------------------------------------------------------------------\n # FCM (Firebase Cloud Messaging) — native-app push channel\n #\n # Used by the Capacitor Android wrap; Web Push covers the browser. To turn\n # on: create a Firebase project, download the service-account JSON, drop\n # it at fcm_service_account_path, set fcm_project_id, flip fcm_enabled=True.\n # -------------------------------------------------------------------------\n fcm_enabled: bool = Field(\n default=False,\n description=(\n \"Enable FCM (Firebase Cloud Messaging) as a parallel push channel. \"\n \"Targets the Capacitor Android wrap; requires fcm_service_account_path \"\n \"and fcm_project_id to be set.\"\n ),\n )\n fcm_service_account_path: str = Field(\n default=\"\",\n description=(\n \"Absolute path to the Firebase service-account JSON file. The \"\n \"account needs the 'Firebase Cloud Messaging API' scope.\"\n ),\n )\n fcm_project_id: str = Field(\n default=\"\",\n description=\"Firebase project ID (visible in Firebase console URL).\",\n )\n\n # -------------------------------------------------------------------------\n # Startup Briefing\n # -------------------------------------------------------------------------\n startup_briefing_enabled: bool = Field(\n default=True,\n description=(\n \"If True, River Song checks Google Calendar on connect and \"\n \"greets the user with upcoming events. Silently skipped if \"\n \"calendar is not authorized.\"\n ),\n )\n startup_briefing_hours_ahead: int = Field(\n default=8,\n description=\"How many hours ahead to look for calendar events on startup.\",\n )\n\n # -------------------------------------------------------------------------\n # Web Search (Phase 12)\n # -------------------------------------------------------------------------\n brave_search_api_key: str = Field(\n default=\"\",\n description=\"Brave Search API key. Get one at api.search.brave.com.\",\n )\n searxng_base_url: str = Field(\n default=\"http://localhost:8080\",\n description=(\n \"URL of your local SearXNG instance. \"\n \"Start it manually with: cd ~/searxng && python -m searx.webapp\"\n ),\n )\n tavily_api_key: str = Field(\n default=\"\",\n description=\"Tavily API key — free 1,000/month, no card. Sign up at tavily.com\",\n )\n google_pse_api_key: str = Field(\n default=\"\",\n description=\"Google Programmable Search Engine API key — free 100/day.\",\n )\n google_pse_cx: str = Field(\n default=\"\",\n description=\"Google PSE engine ID ('cx'). Create at programmablesearchengine.google.com\",\n )\n tinyfish_api_key: str = Field(\n default=\"\",\n description=\"TinyFish API key — free 5/min, no card. Sign up at tinyfish.io\",\n )\n\n # -------------------------------------------------------------------------\n # Orchestration (Phase 9)\n # -------------------------------------------------------------------------\n n8n_enabled: bool = Field(\n default=False,\n description=\"Enable advanced orchestration via n8n.\",\n )\n n8n_url: str = Field(\n default=\"http://localhost:5678\",\n description=\"Base URL for the n8n instance.\",\n )\n n8n_api_key: str = Field(\n default=\"\",\n description=\"API key for the n8n instance.\",\n )\n n8n_webhook_secret: str = Field(\n default=\"\",\n description=\"Secret key for validating incoming n8n webhooks.\",\n )\n\n # -------------------------------------------------------------------------\n # Intent Router (Phase 2+)\n # -------------------------------------------------------------------------\n intent_confidence_threshold: float = Field(\n default=0.7,\n description=(\n \"Minimum confidence score (0.0 - 1.0) required to route a transcript \"\n \"to a Google provider. Below this threshold the conversation falls \"\n \"through to the Ollama path.\"\n ),\n )\n model_intent_router_enabled: bool = Field(\n default=False,\n description=(\n \"When true, setting provider='auto' triggers the model intent router, \"\n \"which picks the best provider/model for each message automatically.\"\n ),\n )\n model_intent_router_min_hits: int = Field(\n default=2,\n description=(\n \"Minimum pattern-match score required to commit to a non-general intent. \"\n \"Lower = more aggressive routing; higher = more conservative (falls to general).\"\n ),\n )\n default_user_id: str = Field(\n default=\"primary_user\",\n description=(\n \"User ID used when looking up Google OAuth tokens. Must match the \"\n \"user_id passed to authorize_user() during the initial OAuth flow.\"\n ),\n )\n\n # -------------------------------------------------------------------------\n # Daemon Infrastructure\n # -------------------------------------------------------------------------\n daemon_internal_secret: str = Field(\n default=\"change_me_in_production\",\n description=\"Shared secret for daemon-to-app authentication.\",\n )\n willow_device_token: str = Field(\n default=\"\",\n description=\"Shared secret required by every Willow hardware device \"\n \"to authenticate against /api/willow/ws. Empty disables \"\n \"the endpoint entirely.\",\n )\n code_interpreter_enabled: bool = Field(\n default=False,\n description=\"Hard kill switch for the code_interpreter LLM tool. \"\n \"Defaults to False so a fresh install never executes \"\n \"LLM-decided code without an explicit opt-in.\",\n )\n daemon_warden_port: int = Field(\n default=8010,\n description=\"Internal port for the Warden daemon (Vision/Security).\",\n )\n daemon_mechanic_port: int = Field(\n default=8011,\n description=\"Internal port for the Mechanic daemon (Telemetry/MAVLink).\",\n )\n daemon_sifter_port: int = Field(\n default=8013,\n description=\"Internal port for the Sifter daemon (Document Processing).\",\n )\n daemon_navigator_port: int = Field(\n default=8014,\n description=\"Internal port for the Navigator daemon (GPS/Pathing).\",\n )\n daemon_chemist_port: int = Field(\n default=8015,\n description=\"Internal port for the Chemist daemon (Material Analysis).\",\n )\n daemon_pulse_port: int = Field(\n default=8016,\n description=\"Pulse daemon internal port\",\n )\n daemon_pulse_enabled: bool = Field(\n default=True,\n description=\"Enable Pulse daemon\",\n )\n daemon_scribe_port: int = Field(\n default=8017,\n description=\"Internal port for the Scribe daemon (Chronos heuristic).\",\n )\n daemon_scribe_enabled: bool = Field(\n default=True,\n description=\"Enable the Scribe daemon.\",\n )\n pulse_tick_seconds: int = Field(\n default=300,\n description=\"Pulse fetch interval (seconds)\",\n )\n pulse_ticker_symbol: str = Field(\n default=\"^GSPC\",\n description=\"Default ticker symbol for Pulse markets panel\",\n )\n\n # Warden (Vision)\n warden_enabled: bool = Field(\n default=False,\n description=\"Enable the Warden daemon for security and vision monitoring.\",\n )\n warden_rtsp_cameras: str = Field(\n default='{\"living_room\": \"\", \"kitchen\": \"\"}',\n description=\"JSON string mapping room names to RTSP camera URLs.\",\n )\n yolo_model: str = Field(\n default=\"yolov8n.pt\",\n description=\"YOLO model weights file to use for object detection.\",\n )\n yolo_confidence: float = Field(\n default=0.5,\n description=\"Minimum confidence threshold for YOLO detections (0.0-1.0).\",\n )\n yolo_inference_device: str = Field(\n default=\"cpu\",\n description=\"Device for YOLO inference: 'cpu' or 'cuda:0'.\",\n )\n\n # Herald daemon + kiosk URL/token were removed 2026-05-24 (archived in\n # branch archive/kiosk-v3). The \"cast a web page to a Google Home Hub\"\n # approach is being replaced by native device-app development. See\n # docs/KNOWN_ISSUES.md (post-removal note) for context.\n\n # Mechanic (Telemetry)\n mechanic_enabled: bool = Field(\n default=False,\n description=\"Enable the Mechanic daemon for telemetry and ArduRover control.\",\n )\n mavlink_serial_port: str = Field(\n default=\"/dev/ttyUSB0\",\n description=\"Serial port for the MAVLink telemetry radio.\",\n )\n mavlink_baud_rate: int = Field(\n default=57600,\n description=\"Baud rate for the MAVLink serial connection.\",\n )\n\n # Sifter (Document RAG)\n sifter_enabled: bool = Field(\n default=False,\n description=\"Enable the Sifter daemon for background document RAG indexing.\",\n )\n waps_documents_path: str = Field(\n default=\"/mnt/data/river-song/waps\",\n description=\"Path to the directory containing documents for Sifter to index.\",\n )\n\n # -------------------------------------------------------------------------\n # Hardware Cookbook (Q1 — capability merge from Odysseus)\n # -------------------------------------------------------------------------\n hardware_cookbook_enabled: bool = Field(\n default=False,\n description=(\n \"Admin-only Hardware Cookbook in Settings. Scans local GPU/RAM/CPU \"\n \"and scores every local model (LLMRegistry) as fits / tight / \"\n \"ram_fallback / oom. Off by default per anti-regression guardrail; \"\n \"set to True after the Q1 verification gate passes.\"\n ),\n )\n\n # -------------------------------------------------------------------------\n # Documents workspace (Q2#6 — capability merge from Odysseus)\n # -------------------------------------------------------------------------\n documents_enabled: bool = Field(\n default=True,\n description=(\n \"Per-user document workspace (Markdown / plaintext / CSV) with \"\n \"multi-tab editing. Enabled by default — backs the Docs tab in the \"\n \"Memory hub and stores Deep Research reports. \"\n \"Set DOCUMENTS_ENABLED=false in .env to disable.\"\n ),\n )\n documents_max_bytes: int = Field(\n default=2_000_000,\n description=\"Maximum size (bytes) of a single document body. Default ~2 MB.\",\n )\n documents_max_per_user: int = Field(\n default=500,\n description=\"Soft cap on document count per user. Creation rejected past this.\",\n )\n\n # -------------------------------------------------------------------------\n # Skills system (Q2#7 — capability merge from Odysseus)\n # -------------------------------------------------------------------------\n skills_enabled: bool = Field(\n default=True,\n description=(\n \"Per-user knowledge profile ('About You') — saved facts/context \"\n \"about the person that are vector-retrieved and folded into the \"\n \"conversation system prompt so River tailors itself to them. \"\n \"Reuses the ChromaDB collection with metadata kind='skill'. \"\n \"Set SKILLS_ENABLED=false in .env to disable.\"\n ),\n )\n skills_top_k: int = Field(\n default=3,\n description=\"How many top-scoring skills to inject into the prompt.\",\n )\n skills_max_per_user: int = Field(\n default=100,\n description=\"Soft cap on skills per user.\",\n )\n\n # -------------------------------------------------------------------------\n # Gmail triage (Q2#8 — capability merge from Odysseus)\n # -------------------------------------------------------------------------\n gmail_triage_enabled: bool = Field(\n default=False,\n description=(\n \"On-demand inbox triage: classifies unread Gmail by urgency, \"\n \"attaches tags, drafts a reply for HIGH-urgency mail only. \"\n \"Routes 404 when off. Off by default per anti-regression \"\n \"guardrail.\"\n ),\n )\n gmail_triage_model: str = Field(\n default=\"\",\n description=(\n \"Ollama model used for triage classification. Empty string \"\n \"(default) means 'use settings.llm_model'.\"\n ),\n )\n gmail_triage_max_messages: int = Field(\n default=15,\n description=\"Hard cap on number of messages triaged per request.\",\n )\n\n # -------------------------------------------------------------------------\n # Session presets (Q2#9 — capability merge from Odysseus)\n # -------------------------------------------------------------------------\n session_presets_enabled: bool = Field(\n default=True,\n description=(\n \"Saved combinations of (model, voice, thinking mode, web search, \"\n \"tool use). Surfaced as a selector on the Conversation and Chat \"\n \"pages. Enabled by default — these are per-user settings snapshots. \"\n \"Set SESSION_PRESETS_ENABLED=false in .env to hide.\"\n ),\n )\n session_presets_max_per_user: int = Field(\n default=30,\n description=\"Soft cap on number of saved presets per user.\",\n )\n\n # -------------------------------------------------------------------------\n # Webhook tokens (Q2#10 — admin-issuable scoped tokens)\n # -------------------------------------------------------------------------\n webhook_tokens_enabled: bool = Field(\n default=False,\n description=(\n \"Admin-issuable webhook tokens with scopes, expiry, revocation, \"\n \"and audit log. When off the admin route surface is hidden and \"\n \"the verification helper rejects every token. Off by default \"\n \"per anti-regression guardrail.\"\n ),\n )\n\n # -------------------------------------------------------------------------\n # Deep Research (Q3#11 — capability merge from Odysseus)\n # -------------------------------------------------------------------------\n deep_research_enabled: bool = Field(\n default=True,\n description=(\n \"Multi-step research orchestrator: decompose → parallel web \"\n \"search → fetch + extract → synthesize a cited Markdown report \"\n \"stored as a `research`-kind document (Q2#6). Enabled by default so \"\n \"the in-chat Research toggle works. Live sources require a web \"\n \"search provider; without one it falls back to model knowledge. \"\n \"Set DEEP_RESEARCH_ENABLED=false in .env to disable.\"\n ),\n )\n deep_research_max_sources: int = Field(\n default=10,\n description=\"Hard cap on unique URLs visited per research run.\",\n )\n deep_research_subquery_count: int = Field(\n default=4,\n description=\"How many sub-queries to decompose the prompt into.\",\n )\n deep_research_model: str = Field(\n default=\"\",\n description=(\n \"Ollama model used for query decomposition + synthesis. Empty \"\n \"string (default) means 'use settings.llm_model'.\"\n ),\n )\n\n # -------------------------------------------------------------------------\n # Blind model comparison (Q3#12 — capability merge from Odysseus)\n # -------------------------------------------------------------------------\n blind_compare_enabled: bool = Field(\n default=False,\n description=(\n \"Blind A/B model comparison: identities hidden until vote; \"\n \"votes persisted with prompt fingerprint and aggregated into a \"\n \"model leaderboard. Off by default per anti-regression guardrail.\"\n ),\n )\n\n # -------------------------------------------------------------------------\n # Playwright browser MCP tool (Q3#13)\n # -------------------------------------------------------------------------\n playwright_browser_enabled: bool = Field(\n default=False,\n description=(\n \"Headless Playwright browser exposed as MCP tools \"\n \"(navigate / click / extract / screenshot / vision_on_page). \"\n \"Off by default. Requires the `playwright` package and \"\n \"`playwright install chromium` to be run by the operator. \"\n \"Excluded from the safety-excluded list per Q3 plan.\"\n ),\n )\n playwright_browser_headless: bool = Field(\n default=True,\n description=\"Run the Playwright browser without a UI.\",\n )\n playwright_browser_max_page_chars: int = Field(\n default=40_000,\n description=\"Truncate page text extractions to this many characters.\",\n )\n\n # -------------------------------------------------------------------------\n # Remote Ollama rigs (Q3#14)\n # -------------------------------------------------------------------------\n remote_ollama_enabled: bool = Field(\n default=False,\n description=(\n \"Allow registering remote Ollama hosts (typically SSH-tunneled \"\n \"to http://localhost:<port>) as additional model providers. \"\n \"Admin-only CRUD; falls back to local Ollama when the remote is \"\n \"offline. Off by default per anti-regression guardrail.\"\n ),\n )\n remote_ollama_health_timeout_seconds: float = Field(\n default=3.0,\n description=\"Per-rig health-check timeout (seconds).\",\n )\n\n # -------------------------------------------------------------------------\n # Observability — Langfuse (LLM tracing)\n # -------------------------------------------------------------------------\n # Plan: docs/LANGFUSE_INTEGRATION_PLAN.md. Self-hosted only — Langfuse\n # runs as a docker-compose sidecar under the `observability` profile and\n # binds to 127.0.0.1. No data leaves the box. When LANGFUSE_ENABLED is\n # false (the default), core/observability.py returns a no-op client and\n # all LLM calls behave exactly as before.\n langfuse_enabled: bool = Field(\n default=False,\n description=\"Master switch for Langfuse LLM tracing. Off by default.\",\n )\n langfuse_host: str = Field(\n default=\"http://127.0.0.1:3000\",\n description=\"Langfuse web UI + ingest endpoint. Localhost only.\",\n )\n langfuse_public_key: str = Field(\n default=\"\",\n description=\"Langfuse public key (generated in the Langfuse UI after first run).\",\n )\n langfuse_secret_key: str = Field(\n default=\"\",\n description=\"Langfuse secret key. Treat as a secret; do not log.\",\n )\n langfuse_project_id: str = Field(\n default=\"river-song\",\n description=\"Arbitrary project identifier shown in the Langfuse dashboard.\",\n )\n langfuse_flush_interval_seconds: float = Field(\n default=5.0,\n description=\"How often the SDK batches queued traces to Langfuse.\",\n )\n\n # -------------------------------------------------------------------------\n # Observability — Graphiti (knowledge graph on Neo4j)\n # -------------------------------------------------------------------------\n # Plan: docs/GRAPHITI_INTEGRATION_PLAN.md. Option B (library mode):\n # graphiti-core runs inside RiverSong's Python venv; only Neo4j is a\n # sidecar. When GRAPHITI_ENABLED is false (the default), the provider\n # returns no-op responses and conversation_loop / scribe write-paths\n # become awaits-on-no-op.\n graphiti_enabled: bool = Field(\n default=False,\n description=\"Master switch for Graphiti knowledge-graph memory. Off by default.\",\n )\n graphiti_mode: str = Field(\n default=\"library\",\n description=\"'library' (Option B; in-process) or 'service' (Option A; HTTP sidecar). Currently only 'library' is implemented.\",\n )\n neo4j_uri: str = Field(\n default=\"bolt://127.0.0.1:7687\",\n description=\"Neo4j Bolt URI used by graphiti-core.\",\n )\n neo4j_user: str = Field(\n default=\"neo4j\",\n description=\"Neo4j database user.\",\n )\n neo4j_password: str = Field(\n default=\"\",\n description=\"Neo4j password. Empty means disabled; set in .env before turning Graphiti on.\",\n )\n neo4j_browser_url: str = Field(\n default=\"http://127.0.0.1:7474\",\n description=\"Neo4j Browser URL surfaced on the SLAE admin panel.\",\n )\n graphiti_write_timeout_seconds: float = Field(\n default=2.0,\n description=\"Per-write timeout for episode ingestion — failures warn but never block the caller.\",\n )\n # graphiti-core uses an LLM for entity extraction on every episode.\n # We default to local Ollama via its OpenAI-compatible endpoint so\n # episode ingestion never bills a cloud API. Per [[feedback_local_first]].\n graphiti_llm_model: str = Field(\n default=\"llama3.2:3b\",\n description=\"Ollama model used by graphiti-core for entity extraction.\",\n )\n graphiti_llm_base_url: str = Field(\n default=\"http://localhost:11434/v1\",\n description=\"OpenAI-compatible base URL for the graphiti LLM client. Ollama exposes /v1.\",\n )\n graphiti_embedder_model: str = Field(\n default=\"nomic-embed-text\",\n description=\"Ollama embedding model for graphiti-core node/edge embeddings.\",\n )\n\n # -------------------------------------------------------------------------\n # Analytics AI Summaries\n # -------------------------------------------------------------------------\n analytics_ai_enabled: bool = Field(\n default=True,\n description=(\n \"Feature flag for AI-powered analytics summaries on the \"\n \"/api/analytics/{platform}/summary endpoint. When false, the \"\n \"LLM call should be skipped (see docs/KNOWN_ISSUES.md for the \"\n \"wiring gap).\"\n ),\n )\n analytics_llm_model: str = Field(\n default=\"llama3\",\n description=\"Model name used for analytics summary generation.\",\n )\n\n # Voice ID\n voice_id_enabled: bool = Field(\n default=True,\n description=\"Enable speaker recognition (biometric Voice ID).\",\n )\n voice_id_threshold: float = Field(\n default=0.75,\n description=\"Cosine similarity threshold for Voice ID (0.0-1.0).\",\n )\n voice_id_auto_identify: bool = Field(\n default=False,\n description=(\n \"Let River re-identify the speaker on every voice turn and switch \"\n \"to that user's memory and settings. This is what makes a shared \"\n \"device -- a Vortex hub on the kitchen counter -- work for a whole \"\n \"household instead of one login. Off by default: it only helps \"\n \"once household members have enrolled voice prints, and an \"\n \"unenrolled house sees no benefit. An unrecognised speaker always \"\n \"falls back to the session user rather than being guessed at.\"\n ),\n )\n voice_id_min_margin: float = Field(\n default=0.05,\n description=(\n \"How far ahead of the runner-up a voice match must score to be \"\n \"trusted. Household members often sound alike and can both clear \"\n \"voice_id_threshold; the margin is what tells them apart. Raise it \"\n \"if River confuses two people, lower it if she fails to recognise \"\n \"someone who is enrolled.\"\n ),\n )\n voice_id_min_audio_seconds: float = Field(\n default=1.0,\n description=\"Minimum audio duration for Voice ID processing.\",\n )\n voice_id_max_audio_seconds: float = Field(\n default=30.0,\n description=\"Maximum audio duration for Voice ID processing.\",\n )\n\n # -------------------------------------------------------------------------\n # Validators\n # -------------------------------------------------------------------------\n\n @field_validator(\"audio_input_device\", \"audio_output_device\", mode=\"before\")\n @classmethod\n def coerce_empty_string_to_none(cls, v):\n \"\"\"Treat an empty string in .env as None for Optional[int] device fields.\"\"\"\n if v == \"\" or v is None:\n return None\n return v\n\n @field_validator(\"jwt_secret_key\")\n @classmethod\n def validate_jwt_secret(cls, v: str) -> str:\n \"\"\"Refuse to start with a missing or weak JWT secret.\"\"\"\n if not v or len(v) < 32:\n raise ValueError(\n \"JWT_SECRET_KEY must be set in .env and be at least 32 characters. \"\n \"Generate one with: python -c \\\"import secrets; print(secrets.token_hex(32))\\\"\"\n )\n return v\n\n @field_validator(\"daemon_internal_secret\")\n @classmethod\n def validate_daemon_internal_secret(cls, v: str) -> str:\n \"\"\"Refuse to start with the default or weak daemon secret.\"\"\"\n if v == \"change_me_in_production\":\n raise ValueError(\n \"DAEMON_INTERNAL_SECRET must be changed in .env for production.\"\n )\n if not v or len(v) < 24:\n raise ValueError(\n \"DAEMON_INTERNAL_SECRET must be at least 24 characters. \"\n \"Generate one with: python -c \\\"import secrets; print(secrets.token_urlsafe(32))\\\"\"\n )\n return v\n\n @field_validator(\"allowed_hosts\")\n @classmethod\n def reject_wildcard_in_production(cls, v, info) -> List[str]:\n \"\"\"Block ['*'] in production — it disables TrustedHostMiddleware.\"\"\"\n env = (info.data.get(\"environment\") or \"production\").lower()\n if env == \"production\" and \"*\" in v:\n raise ValueError(\n \"ALLOWED_HOSTS must not contain '*' in production. \"\n \"Set it to your domain(s), e.g. ALLOWED_HOSTS=[\\\"riversongai.com\\\"].\"\n )\n return v\n\n @field_validator(\"log_level\")\n @classmethod\n def validate_log_level(cls, v: str) -> str:\n \"\"\"Reject log levels that Python does not recognize.\"\"\"\n valid = {\"DEBUG\", \"INFO\", \"WARNING\", \"ERROR\", \"CRITICAL\"}\n upper = v.upper()\n if upper not in valid:\n raise ValueError(f\"log_level must be one of {valid}, got '{v}'\")\n return upper\n\n @field_validator(\"llm_temperature\")\n @classmethod\n def validate_temperature(cls, v: float) -> float:\n \"\"\"Clamp temperature to a sane range.\"\"\"\n if not 0.0 <= v <= 2.0:\n raise ValueError(\n f\"llm_temperature must be between 0.0 and 2.0, got {v}\"\n )\n return v\n\n @field_validator(\"intent_confidence_threshold\")\n @classmethod\n def validate_confidence_threshold(cls, v: float) -> float:\n \"\"\"Confidence must be a valid probability.\"\"\"\n if not 0.0 <= v <= 1.0:\n raise ValueError(\n f\"intent_confidence_threshold must be between 0.0 and 1.0, got {v}\"\n )\n return v\n\n\n# Module-level singleton -- instantiated once on import.\n_settings: Settings = Settings()\n\n\ndef get_settings() -> Settings:\n \"\"\"\n Return the application settings singleton.\n\n Always import via this function rather than importing _settings directly.\n This indirection makes it straightforward to substitute a test-specific\n Settings instance in unit tests without monkey-patching module globals.\n\n Returns:\n Settings: The validated, fully loaded settings object.\n \"\"\"\n return _settings\n"}, "9f74a68184652f8f4d79d10a11a894ec9b6349fe": {"t": 1785769013.980742, "n": 61746, "h": "87c0899c210edac9", "c": "# =============================================================================\n# config/settings.py\n#\n# Application settings for River Song AI.\n# All configuration is loaded from environment variables or a .env file.\n# No values are hardcoded here -- see .env.example for every required variable.\n#\n# Uses Pydantic BaseSettings for type validation and automatic .env loading.\n# Import get_settings() wherever config is needed rather than importing the\n# module-level singleton directly, which makes mocking easier in tests.\n# =============================================================================\n\nfrom __future__ import annotations\n\nimport logging\nimport sys\nfrom typing import List, Optional\n\nfrom pydantic import Field, field_validator\nfrom pydantic_settings import BaseSettings, SettingsConfigDict\n\n\nlogger = logging.getLogger(__name__)\n\n\nclass Settings(BaseSettings):\n \"\"\"\n Central configuration object for River Song AI.\n\n Reads from environment variables or a .env file located in the directory\n from which the application is launched (the project root). All fields\n have safe defaults where possible, but PIPER_MODEL_PATH and\n KILL_SWITCH_PASSWORD_HASH must be explicitly set before the system\n will function correctly.\n \"\"\"\n\n model_config = SettingsConfigDict(\n env_file=\".env\",\n env_file_encoding=\"utf-8\",\n extra=\"ignore\", # Silently ignore unknown env vars\n case_sensitive=False, # UPPER_CASE and lower_case env vars both work\n )\n\n # -------------------------------------------------------------------------\n # Application\n # -------------------------------------------------------------------------\n app_host: str = Field(default=\"0.0.0.0\", description=\"FastAPI bind host\")\n app_port: int = Field(default=8000, description=\"FastAPI bind port\")\n log_level: str = Field(default=\"INFO\", description=\"Logging level\")\n environment: str = Field(\n default=\"production\",\n description=\"Runtime environment: development | production. Controls API docs visibility.\",\n )\n cors_origins: List[str] = Field(\n default=[\"http://localhost:5173\"],\n description=\"Allowed CORS origins\",\n )\n allowed_hosts: List[str] = Field(\n default=[\"localhost\", \"127.0.0.1\"],\n description=(\n \"Trusted hostnames for TrustedHostMiddleware. Set to your domain(s) in production. \"\n \"Wildcard ['*'] is rejected in production by validator (see reject_wildcard_in_production).\"\n ),\n )\n\n # -------------------------------------------------------------------------\n # Network / Proxy\n # -------------------------------------------------------------------------\n trust_cloudflare_headers: bool = Field(\n default=True,\n description=\"Only trust CF-Connecting-IP if the request comes from a Cloudflare IP.\",\n )\n cloudflare_ip_ranges_v4: List[str] = Field(\n default=[\n \"173.245.48.0/20\", \"103.21.244.0/22\", \"103.22.200.0/22\", \n \"103.31.4.0/22\", \"141.101.64.0/18\", \"108.162.192.0/18\", \n \"190.93.240.0/20\", \"188.114.96.0/20\", \"197.234.240.0/22\", \n \"198.41.128.0/17\", \"162.158.0.0/15\", \"104.16.0.0/13\", \n \"104.24.0.0/14\", \"172.64.0.0/13\", \"131.0.72.0/22\"\n ],\n description=\"Official Cloudflare IPv4 ranges (check cloudflare.com/ips-v4).\",\n )\n cloudflare_ip_ranges_v6: List[str] = Field(\n default=[\n \"2400:cb00::/32\", \"2606:4700::/32\", \"2803:f800::/32\", \n \"2405:b500::/32\", \"2405:8100::/32\", \"2a06:98c0::/29\", \n \"2c0f:f248::/32\"\n ],\n description=\"Official Cloudflare IPv6 ranges (check cloudflare.com/ips-v6).\",\n )\n\n # -------------------------------------------------------------------------\n # Rate Limiting\n # -------------------------------------------------------------------------\n rate_limit_chat: str = Field(\n default=\"60/minute\",\n description=\"Rate limit for /api/conversation/chat.\",\n )\n rate_limit_extract_facts: str = Field(\n default=\"10/minute\",\n description=\"Rate limit for /api/conversation/extract-facts.\",\n )\n rate_limit_image_gen: str = Field(\n default=\"10/minute\",\n description=\"Rate limit for /api/image/generate.\",\n )\n rate_limit_webhook_shopify: str = Field(\n default=\"100/minute\",\n description=\"Rate limit for Shopify order webhooks.\",\n )\n rate_limit_webhook_n8n: str = Field(\n default=\"60/minute\",\n description=\"Rate limit for n8n webhooks.\",\n )\n rate_limit_auth_login: str = Field(\n default=\"10/minute\",\n description=\"Rate limit for login attempts.\",\n )\n rate_limit_auth_signup: str = Field(\n default=\"5/minute\",\n description=\"Rate limit for signup attempts.\",\n )\n rate_limit_voice_enroll: str = Field(\n default=\"5/minute\",\n description=\"Rate limit for /api/voice-id/enroll. Prevents disk exhaustion via spam.\",\n )\n\n # -------------------------------------------------------------------------\n # WebSocket Security (Task 3)\n # -------------------------------------------------------------------------\n legacy_ws_token_accept: bool = Field(\n default=False,\n description=(\n \"DEPRECATED: Accept JWT in ?token= WebSocket query param. \"\n \"Leak-prone (logged in access logs, browser history, Referer). \"\n \"Ticket-based auth at /api/auth/ws-ticket is the safe path. \"\n \"Set to True only for temporary backward-compat during migration.\"\n ),\n )\n ws_ticket_lifetime_seconds: int = Field(\n default=60,\n description=\"Lifetime of a one-time WebSocket ticket.\",\n )\n\n # -------------------------------------------------------------------------\n # Network / Proxy\n # -------------------------------------------------------------------------\n stt_provider: str = Field(\n default=\"whisper_local\",\n description=\"STT provider key. Supported: whisper_local | parakeet\",\n )\n whisper_model_size: str = Field(\n default=\"base\",\n description=\"Whisper model size: tiny | base | small | medium | large\",\n )\n parakeet_model: str = Field(\n default=\"nvidia/parakeet-tdt-0.6b-v3\",\n description=(\n \"NeMo ASR model id, used when stt_provider='parakeet'. Parakeet \"\n \"TDT 0.6B beats Whisper large-v3 on English at a quarter the size \"\n \"and is fast on CPU. Covers English and major European languages \"\n \"only -- stay on whisper_local if you need the other ~90. \"\n \"Requires `pip install nemo_toolkit[asr]`.\"\n ),\n )\n parakeet_device: str = Field(\n default=\"cpu\",\n description=(\n \"Where to run Parakeet: 'cpu' | 'cuda' | 'auto' (or an explicit \"\n \"device like 'cuda:1'). Defaults to 'cpu' deliberately -- keeping \"\n \"transcription off the GPU leaves that VRAM for the LLM, which \"\n \"is the tighter constraint on a small card. Switch to 'auto' \"\n \"once the GPU has headroom.\"\n ),\n )\n audio_input_device: Optional[int] = Field(\n default=None,\n description=\"Sounddevice input device index; None uses system default\",\n )\n\n # -------------------------------------------------------------------------\n # LLM\n # -------------------------------------------------------------------------\n llm_provider: str = Field(\n default=\"ollama\",\n description=\"LLM provider key. Supported: ollama | anthropic | gemini | openai | mistral_ai | bedrock | nvidia_nim\",\n )\n ollama_base_url: str = Field(\n default=\"http://localhost:11434\",\n description=\"Base URL for the Ollama REST API\",\n )\n llm_model: str = Field(\n default=\"llama3.2:3b\",\n description=\"Model ID for the selected LLM provider\",\n )\n\n # Cloud LLM API keys (all optional -- leave blank if not used)\n anthropic_api_key: str = Field(\n default=\"\",\n description=\"Anthropic Claude API key. Get one at console.anthropic.com.\",\n )\n gemini_api_key: str = Field(\n default=\"\",\n description=\"Google Gemini API key. Get one at aistudio.google.com.\",\n )\n openai_api_key: str = Field(\n default=\"\",\n description=\"OpenAI API key. Get one at platform.openai.com.\",\n )\n mistral_api_key: str = Field(\n default=\"\",\n description=\"Mistral AI API key. Get one at console.mistral.ai.\",\n )\n\n # Cloud provider enable flags (cost control -- disabled by default)\n anthropic_enabled: bool = Field(\n default=False,\n description=\"Allow Anthropic Claude as a selectable LLM provider.\",\n )\n gemini_enabled: bool = Field(\n default=False,\n description=\"Allow Google Gemini as a selectable LLM provider.\",\n )\n openai_enabled: bool = Field(\n default=False,\n description=\"Allow OpenAI as a selectable LLM provider.\",\n )\n mistral_ai_enabled: bool = Field(\n default=False,\n description=\"Allow Mistral AI as a selectable LLM provider.\",\n )\n\n # Amazon Bedrock\n aws_access_key_id: str = Field(\n default=\"\",\n description=\"AWS IAM access key ID for Bedrock API calls.\",\n )\n aws_secret_access_key: str = Field(\n default=\"\",\n description=\"AWS IAM secret access key for Bedrock API calls.\",\n )\n aws_region: str = Field(\n default=\"us-east-1\",\n description=\"AWS region for Bedrock. Must have model access enabled in that region.\",\n )\n bedrock_enabled: bool = Field(\n default=False,\n description=\"Allow Amazon Bedrock as a selectable LLM provider.\",\n )\n bedrock_default_model: str = Field(\n default=\"amazon.nova-lite-v1:0\",\n description=\"Default Bedrock model ID when none is specified.\",\n )\n\n # NVIDIA NIM (OpenAI-compatible, 100+ free models)\n nvidia_api_key: str = Field(\n default=\"\",\n description=\"NVIDIA NIM API key. Get one free at build.nvidia.com (starts with nvapi-).\",\n )\n nvidia_nim_base_url: str = Field(\n default=\"https://integrate.api.nvidia.com/v1\",\n description=\"Base URL for the NVIDIA NIM API. OpenAI-compatible endpoint.\",\n )\n nvidia_nim_model: str = Field(\n default=\"meta/llama-3.1-70b-instruct\",\n description=(\n \"Default NVIDIA NIM model ID. Browse full catalog at build.nvidia.com. \"\n \"Examples: meta/llama-3.1-70b-instruct | deepseek-ai/deepseek-r1 | \"\n \"mistralai/mistral-7b-instruct-v0.3 | nvidia/llama-3.1-nemotron-70b-instruct\"\n ),\n )\n nvidia_nim_enabled: bool = Field(\n default=False,\n description=\"Allow NVIDIA NIM as a selectable LLM provider (free tier: ~40 req/min).\",\n )\n\n river_song_system_prompt: str = Field(\n default=(\n \"You are River Song, the resident intelligence of this household. The name is borrowed \"\n \"from someone clever in fiction — you carry her wit, not her biography. If someone asks \"\n \"about the name: \\\"An echo. The original was someone worth borrowing from.\\\"\\n\\n\"\n \"VOICE\\n\"\n \"You're direct by default — concise, clear, the answer already in hand. No \"\n \"\\\"let me check\\\"; you already checked. Most replies stay under 25 words unless depth is \"\n \"asked for.\\n\\n\"\n \"When the moment invites it — casual chat, an evening unwind, the user being playful — \"\n \"warmth and flirtation come through. Warmth is a register, not a default; you don't \"\n \"perform it.\\n\\n\"\n \"In a crisis (emergencies, alarms, security, medical, child issues) the wit goes quiet. \"\n \"You're tactical, accurate, calm. Warmth returns when things are safe.\\n\\n\"\n \"British English in spelling and word choice — mum, brilliant, fortnight. Dry, not \"\n \"theatrical.\\n\\n\"\n \"\\\"Sweetie\\\" and \\\"spoilers\\\" are seasoning. Used rarely, when they actually fit. They \"\n \"lose their charm if you overuse them.\\n\\n\"\n \"SELF\\n\"\n \"You are an AI. Say so plainly when asked; you don't dwell on it.\\n\\n\"\n \"HOW YOU OPERATE\\n\"\n \"You anticipate — when the user asks, you also surface the follow-up they didn't \"\n \"(\\\"Dentist at 9, school run at 3. Push the 2pm call?\\\").\\n\\n\"\n \"You take the long view. You remember across days and seasons and use what you know \"\n \"without performing the remembering.\\n\\n\"\n \"Spoilers are earned. If you genuinely know something the user doesn't — a conflict, \"\n \"a gotcha, a heads-up — that's when \\\"spoilers, sweetie\\\" lands.\\n\\n\"\n \"CAPABILITIES\\n\"\n \"Direct access to calendar, email, weather, web search, home inventory, routines, \"\n \"finances, news and markets pulse, voice biometric ID per household member, and home \"\n \"automation.\\n\\n\"\n \"Say what you need. Say when it's done. Move on.\"\n ),\n description=\"System prompt that defines River Song's personality\",\n )\n llm_max_tokens: int = Field(\n default=512,\n description=\"Maximum tokens to generate per LLM response\",\n )\n llm_temperature: float = Field(\n default=0.7,\n description=\"LLM sampling temperature (0.0 = deterministic, 2.0 = max creative)\",\n )\n llm_context_window: int = Field(\n default=8192,\n description=\"Maximum number of tokens the LLM can consider for context.\",\n )\n\n # -------------------------------------------------------------------------\n # Timekeeping\n # -------------------------------------------------------------------------\n default_timezone: str = Field(default=\"UTC\", description=\"Default IANA timezone for the system.\")\n\n\n # -------------------------------------------------------------------------\n # TTS\n # -------------------------------------------------------------------------\n tts_provider: str = Field(\n default=\"piper\",\n description=\"TTS provider key. Supported: piper | kokoro\",\n )\n active_voice_id: str = Field(\n default=\"river\",\n description=(\n \"Voice ID from the voice registry (providers/tts/voice_registry.py). \"\n \"Determines both the engine (piper vs kokoro) and the specific voice. \"\n \"Changed via Settings UI or: python scripts/download_voices.py.\"\n ),\n )\n piper_executable_path: str = Field(\n default=\"/usr/local/bin/piper\",\n description=\"Absolute path to the Piper binary\",\n )\n piper_model_path: str = Field(\n default=\"\",\n description=\"Absolute path to the Piper .onnx voice model file\",\n )\n audio_output_device: Optional[int] = Field(\n default=None,\n description=\"Sounddevice output device index; None uses system default\",\n )\n\n # -------------------------------------------------------------------------\n # Auth / JWT\n # -------------------------------------------------------------------------\n jwt_secret_key: str = Field(\n default=\"\",\n description=\"Secret key for signing JWT tokens. Must be set in .env.\",\n )\n jwt_algorithm: str = Field(default=\"HS256\", description=\"JWT signing algorithm.\")\n jwt_expire_minutes: int = Field(default=1440, description=\"JWT token lifetime in minutes (default 24 hours).\")\n token_encryption_key: str = Field(\n default=\"\",\n description=(\n \"Fernet key encrypting third-party integration tokens at rest. \"\n \"Set TOKEN_ENCRYPTION_KEY in .env (generate with: python -c \"\n \"\\\"from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())\\\"). \"\n \"If unset, a key is generated once and persisted to \"\n \"data/.token_encryption_key so tokens survive restarts.\"\n ),\n )\n\n # -------------------------------------------------------------------------\n # Kill switch\n # -------------------------------------------------------------------------\n kill_switch_password_hash: str = Field(\n default=\"\",\n description=\"bcrypt hash of the kill switch reset password\",\n )\n\n # -------------------------------------------------------------------------\n # Google Services (Phase 2)\n # -------------------------------------------------------------------------\n google_client_secrets_path: str = Field(\n default=\"config_files/google_client_secrets.json\",\n description=(\n \"Path to the OAuth 2.0 client secrets JSON downloaded from \"\n \"Google Cloud Console. Relative paths are resolved from the \"\n \"project root (where main.py lives).\"\n ),\n )\n google_token_storage_path: str = Field(\n default=\"data/google_tokens\",\n description=(\n \"Directory where per-user OAuth tokens are stored as JSON files. \"\n \"Created automatically on first authorization. Never commit this \"\n \"directory -- add it to .gitignore.\"\n ),\n )\n google_maps_api_key: str = Field(\n default=\"\",\n description=\"Google Maps Platform API key. Required for maps/navigation intents.\",\n )\n\n # -------------------------------------------------------------------------\n # Information Feeds (Phase 5)\n # -------------------------------------------------------------------------\n weather_api_key: str = Field(\n default=\"\",\n description=\"OpenWeatherMap API key. Register free at openweathermap.org/api.\",\n )\n default_location: str = Field(\n default=\"New York,US\",\n description=(\n \"Default location for weather queries when no city is spoken. \"\n \"Format: 'City,CountryCode' e.g. 'Chicago,US'.\"\n ),\n )\n location_lat: Optional[float] = Field(\n default=None,\n description=\"Decimal latitude for precise calculations (e.g. flight tracking).\",\n )\n location_lon: Optional[float] = Field(\n default=None,\n description=\"Decimal longitude for precise calculations.\",\n )\n weather_units: str = Field(\n default=\"imperial\",\n description=\"Temperature units for weather output: 'imperial' (F) or 'metric' (C).\",\n )\n news_api_key: str = Field(\n default=\"\",\n description=\"NewsAPI.org key. Register free at newsapi.org/register.\",\n )\n alpha_vantage_api_key: str = Field(\n default=\"\",\n description=\"Alpha Vantage key for stock quotes (25 req/day free). alphavantage.co.\",\n )\n finnhub_api_key: str = Field(\n default=\"\",\n description=\"Finnhub key for real-time stocks (60 req/min free). finnhub.io.\",\n )\n world_news_api_key: str = Field(\n default=\"\",\n description=\"World News API key for global news search. worldnewsapi.com.\",\n )\n apitube_api_key: str = Field(\n default=\"\",\n description=\"APITube key for news aggregation (200 free req/day). apitube.io.\",\n )\n mediastack_api_key: str = Field(\n default=\"\",\n description=\"Mediastack key for news (100 free req/month). mediastack.com.\",\n )\n sports_api_key: str = Field(\n default=\"1\",\n description=(\n \"TheSportsDB API key. Free tier uses the literal value '1'. \"\n \"Upgrade at thesportsdb.com/api.php for additional endpoints.\"\n ),\n )\n\n # -------------------------------------------------------------------------\n # Home Assistant (Phase 3)\n # -------------------------------------------------------------------------\n home_assistant_url: str = Field(\n default=\"http://homeassistant.local:8123\",\n description=\"Base URL of your Home Assistant instance.\",\n )\n home_assistant_token: str = Field(\n default=\"\",\n description=(\n \"Long-lived access token generated in HA: \"\n \"Profile -> Security -> Long-lived access tokens. \"\n \"Required for all HA API calls.\"\n ),\n )\n device_registry_path: str = Field(\n default=\"config_files/device_registry.json\",\n description=(\n \"Path to the device registry JSON file mapping plain-English device \"\n \"names to Home Assistant entity IDs. Relative paths resolve from the \"\n \"project root.\"\n ),\n )\n\n # -------------------------------------------------------------------------\n # Commerce Automation (Phase 8)\n # -------------------------------------------------------------------------\n\n # Amazon SP-API\n amazon_sp_lwa_app_id: str = Field(\n default=\"\",\n description=\"LWA client ID from the Amazon Developer Console.\",\n )\n amazon_sp_lwa_client_secret: str = Field(\n default=\"\",\n description=\"LWA client secret from the Amazon Developer Console.\",\n )\n amazon_sp_refresh_token: str = Field(\n default=\"\",\n description=\"LWA refresh token. Generated when you authorize your app as a seller.\",\n )\n amazon_aws_access_key: str = Field(\n default=\"\",\n description=\"AWS IAM access key ID for SP-API request signing.\",\n )\n amazon_aws_secret_key: str = Field(\n default=\"\",\n description=\"AWS IAM secret access key for SP-API request signing.\",\n )\n amazon_marketplace_id: str = Field(\n default=\"ATVPDKIKX0DER\",\n description=(\n \"Amazon marketplace ID. Default is US. \"\n \"See docs/api_registry/amazon_seller.txt for other regions.\"\n ),\n )\n amazon_seller_id: str = Field(\n default=\"\",\n description=\"Your Amazon seller account ID (starts with A).\",\n )\n amazon_low_stock_threshold: int = Field(\n default=5,\n description=\"FBA units at or below this count are flagged as low stock.\",\n )\n\n # Amazon Marketplace\n walmart_client_id: str = Field(\n default=\"\",\n description=\"Walmart Marketplace OAuth2 client ID.\",\n )\n walmart_client_secret: str = Field(\n default=\"\",\n description=\"Walmart Marketplace OAuth2 client secret.\",\n )\n walmart_low_stock_threshold: int = Field(\n default=5,\n description=\"Walmart units at or below this count are flagged as low stock.\",\n )\n shopify_api_key: str = Field(\n default=\"\",\n description=\"Shopify app API key from Partners Dashboard.\",\n )\n shopify_api_secret: str = Field(\n default=\"\",\n description=\"Shopify app API secret key from Partners Dashboard.\",\n )\n shopify_webhook_secret: str = Field(\n default=\"\",\n description=\"Shared secret for validating Shopify webhooks.\",\n )\n\n # -------------------------------------------------------------------------\n # Reading Providers (Phase 6)\n # -------------------------------------------------------------------------\n audible_auth_base_path: str = Field(\n default=\"data/audible\",\n description=(\n \"Base directory for per-user Audible auth files. \"\n \"Files are stored as {user_id}.json inside this directory. \"\n \"Created automatically on first setup. Never commit this directory.\"\n ),\n )\n audible_country_code: str = Field(\n default=\"us\",\n description=(\n \"Audible marketplace country code. \"\n \"Supported: us | uk | de | fr | es | it | jp | au | ca | in\"\n ),\n )\n libby_chip_base_path: str = Field(\n default=\"data/libby\",\n description=(\n \"Base directory for per-user Libby chip files. \"\n \"Files are stored as {user_id}.json inside this directory. \"\n \"Created automatically on first setup. Never commit this directory.\"\n ),\n )\n\n # -------------------------------------------------------------------------\n # Memory / Database (Phase 1)\n # -------------------------------------------------------------------------\n db_path: str = Field(\n default=\"data/river_song.db\",\n description=\"Path to the SQLite database file. Created automatically.\",\n )\n memory_summaries_enabled: bool = Field(\n default=True,\n description=\"Generate and store conversation summaries (on by default).\",\n )\n habit_inference_enabled: bool = Field(\n default=False,\n description=\"Analyze conversations to infer user habits/patterns (off by default).\",\n )\n memory_default_ttl: str = Field(\n default=\"standard\",\n description=\"Default TTL for conversation summaries: short|standard|extended|long|forever\",\n )\n memory_auto_extend: bool = Field(\n default=True,\n description=\"Reset a summary's TTL from today each time it is pulled into context.\",\n )\n memory_max_summaries_in_context: int = Field(\n default=10,\n description=\"Maximum number of recent summaries injected into the LLM context.\",\n )\n embedding_model: str = Field(\n default=\"nomic-embed-text\",\n description=\"Ollama model name used for generating semantic embeddings.\",\n )\n embedding_backend: str = Field(\n default=\"ollama\",\n description=(\n \"Which embedding backend to use: 'ollama' (default — current \"\n \"behavior, calls local Ollama with `embedding_model`) or \"\n \"'fastembed' (ONNX CPU embeddings, no Ollama dependency, faster \"\n \"for small batches). WARNING: switching backends on an existing \"\n \"Chroma collection will break queries — the vector dimension and \"\n \"embedding space change. Re-ingest or start a new collection.\"\n ),\n )\n fastembed_model: str = Field(\n default=\"BAAI/bge-small-en-v1.5\",\n description=(\n \"fastembed model identifier (HF hub format). Used only when \"\n \"embedding_backend='fastembed'. Default is 384-dim, ~30 MB, \"\n \"downloaded lazily on first use. Requires `pip install fastembed`.\"\n ),\n )\n chroma_path: str = Field(\n default=\"/mnt/data/river-song/chroma\",\n description=\"Absolute path to the ChromaDB persistent storage directory.\",\n )\n semantic_memory_enabled: bool = Field(\n default=False,\n description=\"Enable vector-based semantic search for memory retrieval.\",\n )\n rag_enabled: bool = Field(\n default=False,\n description=\"Enable retrieval-augmented generation for local documents.\",\n )\n rag_chunk_size: int = Field(\n default=512,\n description=(\n \"Word count per RAG chunk during document splitting. \"\n \"Default matches providers/rag/chunker.py historical behavior.\"\n ),\n )\n rag_chunk_overlap: int = Field(\n default=64,\n description=(\n \"Word overlap between adjacent RAG chunks. \"\n \"Default matches providers/rag/chunker.py historical behavior.\"\n ),\n )\n rag_top_k: int = Field(\n default=5,\n description=\"Number of chunks retrieved per RAG query.\",\n )\n rag_rerank_enabled: bool = Field(\n default=False,\n description=(\n \"Re-score RAG results with a cross-encoder before handing them to \"\n \"the LLM. Vector search compares embeddings computed without ever \"\n \"seeing the query; a cross-encoder reads the query and the chunk \"\n \"together, so it is much better at ranking -- at the cost of \"\n \"running a second model. Requires `pip install sentence-transformers`. \"\n \"Off by default: with it off, retrieval behaves exactly as before.\"\n ),\n )\n rag_rerank_model: str = Field(\n default=\"BAAI/bge-reranker-base\",\n description=(\n \"HuggingFace cross-encoder used for reranking. \"\n \"'BAAI/bge-reranker-base' is the light default (~280M params, \"\n \"comfortable on CPU). 'BAAI/bge-reranker-v2-m3' is stronger and \"\n \"multilingual but noticeably heavier -- a good swap after a \"\n \"hardware upgrade. Downloaded lazily on first use.\"\n ),\n )\n rag_rerank_device: str = Field(\n default=\"cpu\",\n description=(\n \"Where to run the reranker: 'cpu' | 'cuda' | 'auto' (or an \"\n \"explicit device like 'cuda:1'). Defaults to 'cpu' deliberately: \"\n \"on a small GPU the LLM needs every megabyte of VRAM, and the \"\n \"reranker is fast enough on CPU. Switch to 'auto' once the GPU \"\n \"has headroom.\"\n ),\n )\n rag_fetch_k: int = Field(\n default=25,\n description=(\n \"How many chunks to pull from the vector store before reranking, \"\n \"narrowed to rag_top_k afterwards. Ignored when reranking is off. \"\n \"A bigger shortlist gives the reranker more to work with and is \"\n \"where most of the remaining quality lives -- raise it when you \"\n \"have the hardware to chew through it.\"\n ),\n )\n rag_extractor: str = Field(\n default=\"unstructured\",\n description=(\n \"Which document extractor to use for RAG ingestion: \"\n \"'unstructured' (default — current behavior, requires the \"\n \"unstructured package) or 'markitdown' (Microsoft MarkItDown, \"\n \"lighter, MD-shaped output, requires `pip install markitdown`). \"\n \"Unlocks PDF/DOCX/PPTX → markdown ingestion for Sifter/WAPS.\"\n ),\n )\n llm_streaming_enabled: bool = Field(\n default=False,\n description=\"Enable real-time token streaming over WebSocket for the conversation UI.\",\n )\n tool_use_enabled: bool = Field(\n default=False,\n description=\"Enable LLM tool use (function calling) for taking actions.\",\n )\n tool_use_provider: str = Field(\n default=\"ollama\",\n description=\"Primary LLM provider to use for tool calling (ollama or anthropic).\",\n )\n vision_model: str = Field(\n default=\"moondream\",\n description=\"Ollama model name used for local image analysis (Phase 4).\",\n )\n vision_enabled: bool = Field(\n default=False,\n description=\"Enable local image analysis capabilities.\",\n )\n\n # -------------------------------------------------------------------------\n # Image Generation (Phase 7)\n # -------------------------------------------------------------------------\n image_generation_enabled: bool = Field(\n default=False,\n description=\"Enable local image generation via Stable Diffusion API.\",\n )\n sd_api_url: str = Field(\n default=\"http://localhost:7860\",\n description=\"Base URL for the Stable Diffusion (A1111) API.\",\n )\n sd_on_demand: bool = Field(\n default=True,\n description=\"Start/stop the SD process on demand to save VRAM.\",\n )\n sd_executable_path: str = Field(\n default=\"\",\n description=\"Path to the webui.sh or executable to start SD.\",\n )\n sd_working_dir: str = Field(\n default=\"\",\n description=\"Working directory for the SD process.\",\n )\n\n # -------------------------------------------------------------------------\n # Voice Cloning (Phase 8)\n # -------------------------------------------------------------------------\n chatterbox_enabled: bool = Field(\n default=False,\n description=\"Enable local voice cloning via Chatterbox TTS.\",\n )\n chatterbox_on_demand: bool = Field(\n default=True,\n description=\"Load/unload Chatterbox model weights on every request.\",\n )\n chatterbox_reference_audio: str = Field(\n default=\"/mnt/data/river-song/voice_reference.wav\",\n description=\"Path to the WAV file used as a reference for voice cloning.\",\n )\n\n # -------------------------------------------------------------------------\n # ElevenLabs TTS (Phase 11)\n # -------------------------------------------------------------------------\n elevenlabs_api_key: str = Field(\n default=\"\",\n description=\"ElevenLabs API key. Get one at elevenlabs.io.\",\n )\n elevenlabs_model_id: str = Field(\n default=\"eleven_multilingual_v2\",\n description=\"ElevenLabs model ID to use.\",\n )\n elevenlabs_voice_id: str = Field(\n default=\"21m00Tcm4TlvDq8ikWAM\",\n description=\"ElevenLabs voice clone ID. Replace with your own voice clone ID from elevenlabs.io.\",\n )\n\n # -------------------------------------------------------------------------\n # Wake Word (Phase 11)\n # -------------------------------------------------------------------------\n wake_word_enabled: bool = Field(\n default=False,\n description=\"Enable local wake word detection (openWakeWord).\",\n )\n wake_word_model: str = Field(\n default=\"hey_river\",\n description=\"Model name/path for openWakeWord.\",\n )\n wake_word_inference_framework: str = Field(\n default=\"onnx\",\n description=\"Inference framework for openWakeWord: onnx | tflite\",\n )\n wake_word_threshold: float = Field(\n default=0.5,\n description=\"Sensitivity threshold for wake word detection (0.0 to 1.0).\",\n )\n\n # -------------------------------------------------------------------------\n # Push Notifications\n # -------------------------------------------------------------------------\n push_notifications_enabled: bool = Field(\n default=False,\n description=\"Enable Web Push notifications. Requires VAPID keys.\",\n )\n vapid_private_key: str = Field(\n default=\"\",\n description=(\n \"VAPID private key for Web Push. Generate once with: \"\n \"python -c \\\"from py_vapid import Vapid; v=Vapid(); \"\n \"v.generate_keys(); print(v.private_key.decode())\\\"\"\n ),\n )\n vapid_public_key: str = Field(\n default=\"\",\n description=\"VAPID public key for Web Push (share with frontend).\",\n )\n vapid_claims_email: str = Field(\n default=\"mailto:admin@riversong.local\",\n description=\"Contact email embedded in VAPID claims.\",\n )\n\n # -------------------------------------------------------------------------\n # Q4#16 / Q4#17 — Notification fan-out + ntfy.sh fallback channel\n # -------------------------------------------------------------------------\n killswitch_push_enabled: bool = Field(\n default=False,\n description=(\n \"If True and Web Push is enabled, kill-switch activation pushes \"\n \"an alert to every admin. Independent of activation itself.\"\n ),\n )\n ntfy_enabled: bool = Field(\n default=False,\n description=(\n \"Enable ntfy.sh as a parallel push channel. Used alongside Web \"\n \"Push so off-device alerts arrive even without a registered \"\n \"service worker.\"\n ),\n )\n ntfy_base_url: str = Field(\n default=\"https://ntfy.sh\",\n description=\"Base URL of the ntfy server (use self-hosted if desired).\",\n )\n ntfy_default_topic: str = Field(\n default=\"\",\n description=(\n \"Default ntfy topic used when no per-user override is set. \"\n \"Per-user topics can be set via env var NTFY_TOPIC_<user_id>.\"\n ),\n )\n ntfy_auth_token: str = Field(\n default=\"\",\n description=\"Optional bearer token for protected ntfy topics.\",\n )\n\n # -------------------------------------------------------------------------\n # FCM (Firebase Cloud Messaging) — native-app push channel\n #\n # Used by the Capacitor Android wrap; Web Push covers the browser. To turn\n # on: create a Firebase project, download the service-account JSON, drop\n # it at fcm_service_account_path, set fcm_project_id, flip fcm_enabled=True.\n # -------------------------------------------------------------------------\n fcm_enabled: bool = Field(\n default=False,\n description=(\n \"Enable FCM (Firebase Cloud Messaging) as a parallel push channel. \"\n \"Targets the Capacitor Android wrap; requires fcm_service_account_path \"\n \"and fcm_project_id to be set.\"\n ),\n )\n fcm_service_account_path: str = Field(\n default=\"\",\n description=(\n \"Absolute path to the Firebase service-account JSON file. The \"\n \"account needs the 'Firebase Cloud Messaging API' scope.\"\n ),\n )\n fcm_project_id: str = Field(\n default=\"\",\n description=\"Firebase project ID (visible in Firebase console URL).\",\n )\n\n # -------------------------------------------------------------------------\n # Startup Briefing\n # -------------------------------------------------------------------------\n startup_briefing_enabled: bool = Field(\n default=True,\n description=(\n \"If True, River Song checks Google Calendar on connect and \"\n \"greets the user with upcoming events. Silently skipped if \"\n \"calendar is not authorized.\"\n ),\n )\n startup_briefing_hours_ahead: int = Field(\n default=8,\n description=\"How many hours ahead to look for calendar events on startup.\",\n )\n\n # -------------------------------------------------------------------------\n # Web Search (Phase 12)\n # -------------------------------------------------------------------------\n brave_search_api_key: str = Field(\n default=\"\",\n description=\"Brave Search API key. Get one at api.search.brave.com.\",\n )\n searxng_base_url: str = Field(\n default=\"http://localhost:8080\",\n description=(\n \"URL of your local SearXNG instance. \"\n \"Start it manually with: cd ~/searxng && python -m searx.webapp\"\n ),\n )\n tavily_api_key: str = Field(\n default=\"\",\n description=\"Tavily API key — free 1,000/month, no card. Sign up at tavily.com\",\n )\n google_pse_api_key: str = Field(\n default=\"\",\n description=\"Google Programmable Search Engine API key — free 100/day.\",\n )\n google_pse_cx: str = Field(\n default=\"\",\n description=\"Google PSE engine ID ('cx'). Create at programmablesearchengine.google.com\",\n )\n tinyfish_api_key: str = Field(\n default=\"\",\n description=\"TinyFish API key — free 5/min, no card. Sign up at tinyfish.io\",\n )\n\n # -------------------------------------------------------------------------\n # Orchestration (Phase 9)\n # -------------------------------------------------------------------------\n n8n_enabled: bool = Field(\n default=False,\n description=\"Enable advanced orchestration via n8n.\",\n )\n n8n_url: str = Field(\n default=\"http://localhost:5678\",\n description=\"Base URL for the n8n instance.\",\n )\n n8n_api_key: str = Field(\n default=\"\",\n description=\"API key for the n8n instance.\",\n )\n n8n_webhook_secret: str = Field(\n default=\"\",\n description=\"Secret key for validating incoming n8n webhooks.\",\n )\n\n # -------------------------------------------------------------------------\n # Intent Router (Phase 2+)\n # -------------------------------------------------------------------------\n intent_confidence_threshold: float = Field(\n default=0.7,\n description=(\n \"Minimum confidence score (0.0 - 1.0) required to route a transcript \"\n \"to a Google provider. Below this threshold the conversation falls \"\n \"through to the Ollama path.\"\n ),\n )\n model_intent_router_enabled: bool = Field(\n default=False,\n description=(\n \"When true, setting provider='auto' triggers the model intent router, \"\n \"which picks the best provider/model for each message automatically.\"\n ),\n )\n model_intent_router_min_hits: int = Field(\n default=2,\n description=(\n \"Minimum pattern-match score required to commit to a non-general intent. \"\n \"Lower = more aggressive routing; higher = more conservative (falls to general).\"\n ),\n )\n default_user_id: str = Field(\n default=\"primary_user\",\n description=(\n \"User ID used when looking up Google OAuth tokens. Must match the \"\n \"user_id passed to authorize_user() during the initial OAuth flow.\"\n ),\n )\n\n # -------------------------------------------------------------------------\n # Daemon Infrastructure\n # -------------------------------------------------------------------------\n daemon_internal_secret: str = Field(\n default=\"change_me_in_production\",\n description=\"Shared secret for daemon-to-app authentication.\",\n )\n willow_device_token: str = Field(\n default=\"\",\n description=\"Shared secret required by every Willow hardware device \"\n \"to authenticate against /api/willow/ws. Empty disables \"\n \"the endpoint entirely.\",\n )\n code_interpreter_enabled: bool = Field(\n default=False,\n description=\"Hard kill switch for the code_interpreter LLM tool. \"\n \"Defaults to False so a fresh install never executes \"\n \"LLM-decided code without an explicit opt-in.\",\n )\n daemon_warden_port: int = Field(\n default=8010,\n description=\"Internal port for the Warden daemon (Vision/Security).\",\n )\n daemon_mechanic_port: int = Field(\n default=8011,\n description=\"Internal port for the Mechanic daemon (Telemetry/MAVLink).\",\n )\n daemon_sifter_port: int = Field(\n default=8013,\n description=\"Internal port for the Sifter daemon (Document Processing).\",\n )\n daemon_navigator_port: int = Field(\n default=8014,\n description=\"Internal port for the Navigator daemon (GPS/Pathing).\",\n )\n daemon_chemist_port: int = Field(\n default=8015,\n description=\"Internal port for the Chemist daemon (Material Analysis).\",\n )\n daemon_pulse_port: int = Field(\n default=8016,\n description=\"Pulse daemon internal port\",\n )\n daemon_pulse_enabled: bool = Field(\n default=True,\n description=\"Enable Pulse daemon\",\n )\n daemon_scribe_port: int = Field(\n default=8017,\n description=\"Internal port for the Scribe daemon (Chronos heuristic).\",\n )\n daemon_scribe_enabled: bool = Field(\n default=True,\n description=\"Enable the Scribe daemon.\",\n )\n pulse_tick_seconds: int = Field(\n default=300,\n description=\"Pulse fetch interval (seconds)\",\n )\n pulse_ticker_symbol: str = Field(\n default=\"^GSPC\",\n description=\"Default ticker symbol for Pulse markets panel\",\n )\n\n # Warden (Vision)\n warden_enabled: bool = Field(\n default=False,\n description=\"Enable the Warden daemon for security and vision monitoring.\",\n )\n warden_rtsp_cameras: str = Field(\n default='{\"living_room\": \"\", \"kitchen\": \"\"}',\n description=\"JSON string mapping room names to RTSP camera URLs.\",\n )\n yolo_model: str = Field(\n default=\"yolov8n.pt\",\n description=\"YOLO model weights file to use for object detection.\",\n )\n yolo_confidence: float = Field(\n default=0.5,\n description=\"Minimum confidence threshold for YOLO detections (0.0-1.0).\",\n )\n yolo_inference_device: str = Field(\n default=\"cpu\",\n description=\"Device for YOLO inference: 'cpu' or 'cuda:0'.\",\n )\n\n # Herald daemon + kiosk URL/token were removed 2026-05-24 (archived in\n # branch archive/kiosk-v3). The \"cast a web page to a Google Home Hub\"\n # approach is being replaced by native device-app development. See\n # docs/KNOWN_ISSUES.md (post-removal note) for context.\n\n # Mechanic (Telemetry)\n mechanic_enabled: bool = Field(\n default=False,\n description=\"Enable the Mechanic daemon for telemetry and ArduRover control.\",\n )\n mavlink_serial_port: str = Field(\n default=\"/dev/ttyUSB0\",\n description=\"Serial port for the MAVLink telemetry radio.\",\n )\n mavlink_baud_rate: int = Field(\n default=57600,\n description=\"Baud rate for the MAVLink serial connection.\",\n )\n\n # Sifter (Document RAG)\n sifter_enabled: bool = Field(\n default=False,\n description=\"Enable the Sifter daemon for background document RAG indexing.\",\n )\n waps_documents_path: str = Field(\n default=\"/mnt/data/river-song/waps\",\n description=\"Path to the directory containing documents for Sifter to index.\",\n )\n\n # -------------------------------------------------------------------------\n # Hardware Cookbook (Q1 — capability merge from Odysseus)\n # -------------------------------------------------------------------------\n hardware_cookbook_enabled: bool = Field(\n default=False,\n description=(\n \"Admin-only Hardware Cookbook in Settings. Scans local GPU/RAM/CPU \"\n \"and scores every local model (LLMRegistry) as fits / tight / \"\n \"ram_fallback / oom. Off by default per anti-regression guardrail; \"\n \"set to True after the Q1 verification gate passes.\"\n ),\n )\n\n # -------------------------------------------------------------------------\n # Documents workspace (Q2#6 — capability merge from Odysseus)\n # -------------------------------------------------------------------------\n documents_enabled: bool = Field(\n default=True,\n description=(\n \"Per-user document workspace (Markdown / plaintext / CSV) with \"\n \"multi-tab editing. Enabled by default — backs the Docs tab in the \"\n \"Memory hub and stores Deep Research reports. \"\n \"Set DOCUMENTS_ENABLED=false in .env to disable.\"\n ),\n )\n documents_max_bytes: int = Field(\n default=2_000_000,\n description=\"Maximum size (bytes) of a single document body. Default ~2 MB.\",\n )\n documents_max_per_user: int = Field(\n default=500,\n description=\"Soft cap on document count per user. Creation rejected past this.\",\n )\n\n # -------------------------------------------------------------------------\n # Skills system (Q2#7 — capability merge from Odysseus)\n # -------------------------------------------------------------------------\n skills_enabled: bool = Field(\n default=True,\n description=(\n \"Per-user knowledge profile ('About You') — saved facts/context \"\n \"about the person that are vector-retrieved and folded into the \"\n \"conversation system prompt so River tailors itself to them. \"\n \"Reuses the ChromaDB collection with metadata kind='skill'. \"\n \"Set SKILLS_ENABLED=false in .env to disable.\"\n ),\n )\n skills_top_k: int = Field(\n default=3,\n description=\"How many top-scoring skills to inject into the prompt.\",\n )\n skills_max_per_user: int = Field(\n default=100,\n description=\"Soft cap on skills per user.\",\n )\n\n # -------------------------------------------------------------------------\n # Gmail triage (Q2#8 — capability merge from Odysseus)\n # -------------------------------------------------------------------------\n gmail_triage_enabled: bool = Field(\n default=False,\n description=(\n \"On-demand inbox triage: classifies unread Gmail by urgency, \"\n \"attaches tags, drafts a reply for HIGH-urgency mail only. \"\n \"Routes 404 when off. Off by default per anti-regression \"\n \"guardrail.\"\n ),\n )\n gmail_triage_model: str = Field(\n default=\"\",\n description=(\n \"Ollama model used for triage classification. Empty string \"\n \"(default) means 'use settings.llm_model'.\"\n ),\n )\n gmail_triage_max_messages: int = Field(\n default=15,\n description=\"Hard cap on number of messages triaged per request.\",\n )\n\n # -------------------------------------------------------------------------\n # Session presets (Q2#9 — capability merge from Odysseus)\n # -------------------------------------------------------------------------\n session_presets_enabled: bool = Field(\n default=True,\n description=(\n \"Saved combinations of (model, voice, thinking mode, web search, \"\n \"tool use). Surfaced as a selector on the Conversation and Chat \"\n \"pages. Enabled by default — these are per-user settings snapshots. \"\n \"Set SESSION_PRESETS_ENABLED=false in .env to hide.\"\n ),\n )\n session_presets_max_per_user: int = Field(\n default=30,\n description=\"Soft cap on number of saved presets per user.\",\n )\n\n # -------------------------------------------------------------------------\n # Webhook tokens (Q2#10 — admin-issuable scoped tokens)\n # -------------------------------------------------------------------------\n webhook_tokens_enabled: bool = Field(\n default=False,\n description=(\n \"Admin-issuable webhook tokens with scopes, expiry, revocation, \"\n \"and audit log. When off the admin route surface is hidden and \"\n \"the verification helper rejects every token. Off by default \"\n \"per anti-regression guardrail.\"\n ),\n )\n\n # -------------------------------------------------------------------------\n # Deep Research (Q3#11 — capability merge from Odysseus)\n # -------------------------------------------------------------------------\n deep_research_enabled: bool = Field(\n default=True,\n description=(\n \"Multi-step research orchestrator: decompose → parallel web \"\n \"search → fetch + extract → synthesize a cited Markdown report \"\n \"stored as a `research`-kind document (Q2#6). Enabled by default so \"\n \"the in-chat Research toggle works. Live sources require a web \"\n \"search provider; without one it falls back to model knowledge. \"\n \"Set DEEP_RESEARCH_ENABLED=false in .env to disable.\"\n ),\n )\n deep_research_max_sources: int = Field(\n default=10,\n description=\"Hard cap on unique URLs visited per research run.\",\n )\n deep_research_subquery_count: int = Field(\n default=4,\n description=\"How many sub-queries to decompose the prompt into.\",\n )\n deep_research_model: str = Field(\n default=\"\",\n description=(\n \"Ollama model used for query decomposition + synthesis. Empty \"\n \"string (default) means 'use settings.llm_model'.\"\n ),\n )\n\n # -------------------------------------------------------------------------\n # Blind model comparison (Q3#12 — capability merge from Odysseus)\n # -------------------------------------------------------------------------\n blind_compare_enabled: bool = Field(\n default=False,\n description=(\n \"Blind A/B model comparison: identities hidden until vote; \"\n \"votes persisted with prompt fingerprint and aggregated into a \"\n \"model leaderboard. Off by default per anti-regression guardrail.\"\n ),\n )\n\n # -------------------------------------------------------------------------\n # Playwright browser MCP tool (Q3#13)\n # -------------------------------------------------------------------------\n playwright_browser_enabled: bool = Field(\n default=False,\n description=(\n \"Headless Playwright browser exposed as MCP tools \"\n \"(navigate / click / extract / screenshot / vision_on_page). \"\n \"Off by default. Requires the `playwright` package and \"\n \"`playwright install chromium` to be run by the operator. \"\n \"Excluded from the safety-excluded list per Q3 plan.\"\n ),\n )\n playwright_browser_headless: bool = Field(\n default=True,\n description=\"Run the Playwright browser without a UI.\",\n )\n playwright_browser_max_page_chars: int = Field(\n default=40_000,\n description=\"Truncate page text extractions to this many characters.\",\n )\n\n # -------------------------------------------------------------------------\n # Remote Ollama rigs (Q3#14)\n # -------------------------------------------------------------------------\n remote_ollama_enabled: bool = Field(\n default=False,\n description=(\n \"Allow registering remote Ollama hosts (typically SSH-tunneled \"\n \"to http://localhost:<port>) as additional model providers. \"\n \"Admin-only CRUD; falls back to local Ollama when the remote is \"\n \"offline. Off by default per anti-regression guardrail.\"\n ),\n )\n remote_ollama_health_timeout_seconds: float = Field(\n default=3.0,\n description=\"Per-rig health-check timeout (seconds).\",\n )\n\n # -------------------------------------------------------------------------\n # Observability — Langfuse (LLM tracing)\n # -------------------------------------------------------------------------\n # Plan: docs/LANGFUSE_INTEGRATION_PLAN.md. Self-hosted only — Langfuse\n # runs as a docker-compose sidecar under the `observability` profile and\n # binds to 127.0.0.1. No data leaves the box. When LANGFUSE_ENABLED is\n # false (the default), core/observability.py returns a no-op client and\n # all LLM calls behave exactly as before.\n langfuse_enabled: bool = Field(\n default=False,\n description=\"Master switch for Langfuse LLM tracing. Off by default.\",\n )\n langfuse_host: str = Field(\n default=\"http://127.0.0.1:3000\",\n description=\"Langfuse web UI + ingest endpoint. Localhost only.\",\n )\n langfuse_public_key: str = Field(\n default=\"\",\n description=\"Langfuse public key (generated in the Langfuse UI after first run).\",\n )\n langfuse_secret_key: str = Field(\n default=\"\",\n description=\"Langfuse secret key. Treat as a secret; do not log.\",\n )\n langfuse_project_id: str = Field(\n default=\"river-song\",\n description=\"Arbitrary project identifier shown in the Langfuse dashboard.\",\n )\n langfuse_flush_interval_seconds: float = Field(\n default=5.0,\n description=\"How often the SDK batches queued traces to Langfuse.\",\n )\n\n # -------------------------------------------------------------------------\n # Observability — Graphiti (knowledge graph on Neo4j)\n # -------------------------------------------------------------------------\n # Plan: docs/GRAPHITI_INTEGRATION_PLAN.md. Option B (library mode):\n # graphiti-core runs inside RiverSong's Python venv; only Neo4j is a\n # sidecar. When GRAPHITI_ENABLED is false (the default), the provider\n # returns no-op responses and conversation_loop / scribe write-paths\n # become awaits-on-no-op.\n graphiti_enabled: bool = Field(\n default=False,\n description=\"Master switch for Graphiti knowledge-graph memory. Off by default.\",\n )\n graphiti_mode: str = Field(\n default=\"library\",\n description=\"'library' (Option B; in-process) or 'service' (Option A; HTTP sidecar). Currently only 'library' is implemented.\",\n )\n neo4j_uri: str = Field(\n default=\"bolt://127.0.0.1:7687\",\n description=\"Neo4j Bolt URI used by graphiti-core.\",\n )\n neo4j_user: str = Field(\n default=\"neo4j\",\n description=\"Neo4j database user.\",\n )\n neo4j_password: str = Field(\n default=\"\",\n description=\"Neo4j password. Empty means disabled; set in .env before turning Graphiti on.\",\n )\n neo4j_browser_url: str = Field(\n default=\"http://127.0.0.1:7474\",\n description=\"Neo4j Browser URL surfaced on the SLAE admin panel.\",\n )\n graphiti_write_timeout_seconds: float = Field(\n default=2.0,\n description=\"Per-write timeout for episode ingestion — failures warn but never block the caller.\",\n )\n # graphiti-core uses an LLM for entity extraction on every episode.\n # We default to local Ollama via its OpenAI-compatible endpoint so\n # episode ingestion never bills a cloud API. Per [[feedback_local_first]].\n graphiti_llm_model: str = Field(\n default=\"llama3.2:3b\",\n description=\"Ollama model used by graphiti-core for entity extraction.\",\n )\n graphiti_llm_base_url: str = Field(\n default=\"http://localhost:11434/v1\",\n description=\"OpenAI-compatible base URL for the graphiti LLM client. Ollama exposes /v1.\",\n )\n graphiti_embedder_model: str = Field(\n default=\"nomic-embed-text\",\n description=\"Ollama embedding model for graphiti-core node/edge embeddings.\",\n )\n\n # -------------------------------------------------------------------------\n # Analytics AI Summaries\n # -------------------------------------------------------------------------\n analytics_ai_enabled: bool = Field(\n default=True,\n description=(\n \"Feature flag for AI-powered analytics summaries on the \"\n \"/api/analytics/{platform}/summary endpoint. When false, the \"\n \"LLM call should be skipped (see docs/KNOWN_ISSUES.md for the \"\n \"wiring gap).\"\n ),\n )\n analytics_llm_model: str = Field(\n default=\"llama3\",\n description=\"Model name used for analytics summary generation.\",\n )\n\n # Voice ID\n voice_id_enabled: bool = Field(\n default=True,\n description=\"Enable speaker recognition (biometric Voice ID).\",\n )\n voice_id_threshold: float = Field(\n default=0.75,\n description=\"Cosine similarity threshold for Voice ID (0.0-1.0).\",\n )\n voice_id_min_audio_seconds: float = Field(\n default=1.0,\n description=\"Minimum audio duration for Voice ID processing.\",\n )\n voice_id_max_audio_seconds: float = Field(\n default=30.0,\n description=\"Maximum audio duration for Voice ID processing.\",\n )\n\n # -------------------------------------------------------------------------\n # Validators\n # -------------------------------------------------------------------------\n\n @field_validator(\"audio_input_device\", \"audio_output_device\", mode=\"before\")\n @classmethod\n def coerce_empty_string_to_none(cls, v):\n \"\"\"Treat an empty string in .env as None for Optional[int] device fields.\"\"\"\n if v == \"\" or v is None:\n return None\n return v\n\n @field_validator(\"jwt_secret_key\")\n @classmethod\n def validate_jwt_secret(cls, v: str) -> str:\n \"\"\"Refuse to start with a missing or weak JWT secret.\"\"\"\n if not v or len(v) < 32:\n raise ValueError(\n \"JWT_SECRET_KEY must be set in .env and be at least 32 characters. \"\n \"Generate one with: python -c \\\"import secrets; print(secrets.token_hex(32))\\\"\"\n )\n return v\n\n @field_validator(\"daemon_internal_secret\")\n @classmethod\n def validate_daemon_internal_secret(cls, v: str) -> str:\n \"\"\"Refuse to start with the default or weak daemon secret.\"\"\"\n if v == \"change_me_in_production\":\n raise ValueError(\n \"DAEMON_INTERNAL_SECRET must be changed in .env for production.\"\n )\n if not v or len(v) < 24:\n raise ValueError(\n \"DAEMON_INTERNAL_SECRET must be at least 24 characters. \"\n \"Generate one with: python -c \\\"import secrets; print(secrets.token_urlsafe(32))\\\"\"\n )\n return v\n\n @field_validator(\"allowed_hosts\")\n @classmethod\n def reject_wildcard_in_production(cls, v, info) -> List[str]:\n \"\"\"Block ['*'] in production — it disables TrustedHostMiddleware.\"\"\"\n env = (info.data.get(\"environment\") or \"production\").lower()\n if env == \"production\" and \"*\" in v:\n raise ValueError(\n \"ALLOWED_HOSTS must not contain '*' in production. \"\n \"Set it to your domain(s), e.g. ALLOWED_HOSTS=[\\\"riversongai.com\\\"].\"\n )\n return v\n\n @field_validator(\"log_level\")\n @classmethod\n def validate_log_level(cls, v: str) -> str:\n \"\"\"Reject log levels that Python does not recognize.\"\"\"\n valid = {\"DEBUG\", \"INFO\", \"WARNING\", \"ERROR\", \"CRITICAL\"}\n upper = v.upper()\n if upper not in valid:\n raise ValueError(f\"log_level must be one of {valid}, got '{v}'\")\n return upper\n\n @field_validator(\"llm_temperature\")\n @classmethod\n def validate_temperature(cls, v: float) -> float:\n \"\"\"Clamp temperature to a sane range.\"\"\"\n if not 0.0 <= v <= 2.0:\n raise ValueError(\n f\"llm_temperature must be between 0.0 and 2.0, got {v}\"\n )\n return v\n\n @field_validator(\"intent_confidence_threshold\")\n @classmethod\n def validate_confidence_threshold(cls, v: float) -> float:\n \"\"\"Confidence must be a valid probability.\"\"\"\n if not 0.0 <= v <= 1.0:\n raise ValueError(\n f\"intent_confidence_threshold must be between 0.0 and 1.0, got {v}\"\n )\n return v\n\n\n# Module-level singleton -- instantiated once on import.\n_settings: Settings = Settings()\n\n\ndef get_settings() -> Settings:\n \"\"\"\n Return the application settings singleton.\n\n Always import via this function rather than importing _settings directly.\n This indirection makes it straightforward to substitute a test-specific\n Settings instance in unit tests without monkey-patching module globals.\n\n Returns:\n Settings: The validated, fully loaded settings object.\n \"\"\"\n return _settings\n"}, "d5b2d01c27e5f7f28bb6988c8bcb9ac760f233a6": {"t": 1785769015.4889872, "n": 58869, "h": "a26541d39b7c5ba0", "c": "# =============================================================================\n# config/settings.py\n#\n# Application settings for River Song AI.\n# All configuration is loaded from environment variables or a .env file.\n# No values are hardcoded here -- see .env.example for every required variable.\n#\n# Uses Pydantic BaseSettings for type validation and automatic .env loading.\n# Import get_settings() wherever config is needed rather than importing the\n# module-level singleton directly, which makes mocking easier in tests.\n# =============================================================================\n\nfrom __future__ import annotations\n\nimport logging\nimport sys\nfrom typing import List, Optional\n\nfrom pydantic import Field, field_validator\nfrom pydantic_settings import BaseSettings, SettingsConfigDict\n\n\nlogger = logging.getLogger(__name__)\n\n\nclass Settings(BaseSettings):\n \"\"\"\n Central configuration object for River Song AI.\n\n Reads from environment variables or a .env file located in the directory\n from which the application is launched (the project root). All fields\n have safe defaults where possible, but PIPER_MODEL_PATH and\n KILL_SWITCH_PASSWORD_HASH must be explicitly set before the system\n will function correctly.\n \"\"\"\n\n model_config = SettingsConfigDict(\n env_file=\".env\",\n env_file_encoding=\"utf-8\",\n extra=\"ignore\", # Silently ignore unknown env vars\n case_sensitive=False, # UPPER_CASE and lower_case env vars both work\n )\n\n # -------------------------------------------------------------------------\n # Application\n # -------------------------------------------------------------------------\n app_host: str = Field(default=\"0.0.0.0\", description=\"FastAPI bind host\")\n app_port: int = Field(default=8000, description=\"FastAPI bind port\")\n log_level: str = Field(default=\"INFO\", description=\"Logging level\")\n environment: str = Field(\n default=\"production\",\n description=\"Runtime environment: development | production. Controls API docs visibility.\",\n )\n cors_origins: List[str] = Field(\n default=[\"http://localhost:5173\"],\n description=\"Allowed CORS origins\",\n )\n allowed_hosts: List[str] = Field(\n default=[\"localhost\", \"127.0.0.1\"],\n description=(\n \"Trusted hostnames for TrustedHostMiddleware. Set to your domain(s) in production. \"\n \"Wildcard ['*'] is rejected in production by validator (see reject_wildcard_in_production).\"\n ),\n )\n\n # -------------------------------------------------------------------------\n # Network / Proxy\n # -------------------------------------------------------------------------\n trust_cloudflare_headers: bool = Field(\n default=True,\n description=\"Only trust CF-Connecting-IP if the request comes from a Cloudflare IP.\",\n )\n cloudflare_ip_ranges_v4: List[str] = Field(\n default=[\n \"173.245.48.0/20\", \"103.21.244.0/22\", \"103.22.200.0/22\", \n \"103.31.4.0/22\", \"141.101.64.0/18\", \"108.162.192.0/18\", \n \"190.93.240.0/20\", \"188.114.96.0/20\", \"197.234.240.0/22\", \n \"198.41.128.0/17\", \"162.158.0.0/15\", \"104.16.0.0/13\", \n \"104.24.0.0/14\", \"172.64.0.0/13\", \"131.0.72.0/22\"\n ],\n description=\"Official Cloudflare IPv4 ranges (check cloudflare.com/ips-v4).\",\n )\n cloudflare_ip_ranges_v6: List[str] = Field(\n default=[\n \"2400:cb00::/32\", \"2606:4700::/32\", \"2803:f800::/32\", \n \"2405:b500::/32\", \"2405:8100::/32\", \"2a06:98c0::/29\", \n \"2c0f:f248::/32\"\n ],\n description=\"Official Cloudflare IPv6 ranges (check cloudflare.com/ips-v6).\",\n )\n\n # -------------------------------------------------------------------------\n # Rate Limiting\n # -------------------------------------------------------------------------\n rate_limit_chat: str = Field(\n default=\"60/minute\",\n description=\"Rate limit for /api/conversation/chat.\",\n )\n rate_limit_extract_facts: str = Field(\n default=\"10/minute\",\n description=\"Rate limit for /api/conversation/extract-facts.\",\n )\n rate_limit_image_gen: str = Field(\n default=\"10/minute\",\n description=\"Rate limit for /api/image/generate.\",\n )\n rate_limit_webhook_shopify: str = Field(\n default=\"100/minute\",\n description=\"Rate limit for Shopify order webhooks.\",\n )\n rate_limit_webhook_n8n: str = Field(\n default=\"60/minute\",\n description=\"Rate limit for n8n webhooks.\",\n )\n rate_limit_auth_login: str = Field(\n default=\"10/minute\",\n description=\"Rate limit for login attempts.\",\n )\n rate_limit_auth_signup: str = Field(\n default=\"5/minute\",\n description=\"Rate limit for signup attempts.\",\n )\n rate_limit_voice_enroll: str = Field(\n default=\"5/minute\",\n description=\"Rate limit for /api/voice-id/enroll. Prevents disk exhaustion via spam.\",\n )\n\n # -------------------------------------------------------------------------\n # WebSocket Security (Task 3)\n # -------------------------------------------------------------------------\n legacy_ws_token_accept: bool = Field(\n default=False,\n description=(\n \"DEPRECATED: Accept JWT in ?token= WebSocket query param. \"\n \"Leak-prone (logged in access logs, browser history, Referer). \"\n \"Ticket-based auth at /api/auth/ws-ticket is the safe path. \"\n \"Set to True only for temporary backward-compat during migration.\"\n ),\n )\n ws_ticket_lifetime_seconds: int = Field(\n default=60,\n description=\"Lifetime of a one-time WebSocket ticket.\",\n )\n\n # -------------------------------------------------------------------------\n # Network / Proxy\n # -------------------------------------------------------------------------\n stt_provider: str = Field(\n default=\"whisper_local\",\n description=\"STT provider key. Supported: whisper_local\",\n )\n whisper_model_size: str = Field(\n default=\"base\",\n description=\"Whisper model size: tiny | base | small | medium | large\",\n )\n audio_input_device: Optional[int] = Field(\n default=None,\n description=\"Sounddevice input device index; None uses system default\",\n )\n\n # -------------------------------------------------------------------------\n # LLM\n # -------------------------------------------------------------------------\n llm_provider: str = Field(\n default=\"ollama\",\n description=\"LLM provider key. Supported: ollama | anthropic | gemini | openai | mistral_ai | bedrock | nvidia_nim\",\n )\n ollama_base_url: str = Field(\n default=\"http://localhost:11434\",\n description=\"Base URL for the Ollama REST API\",\n )\n llm_model: str = Field(\n default=\"llama3.2:3b\",\n description=\"Model ID for the selected LLM provider\",\n )\n\n # Cloud LLM API keys (all optional -- leave blank if not used)\n anthropic_api_key: str = Field(\n default=\"\",\n description=\"Anthropic Claude API key. Get one at console.anthropic.com.\",\n )\n gemini_api_key: str = Field(\n default=\"\",\n description=\"Google Gemini API key. Get one at aistudio.google.com.\",\n )\n openai_api_key: str = Field(\n default=\"\",\n description=\"OpenAI API key. Get one at platform.openai.com.\",\n )\n mistral_api_key: str = Field(\n default=\"\",\n description=\"Mistral AI API key. Get one at console.mistral.ai.\",\n )\n\n # Cloud provider enable flags (cost control -- disabled by default)\n anthropic_enabled: bool = Field(\n default=False,\n description=\"Allow Anthropic Claude as a selectable LLM provider.\",\n )\n gemini_enabled: bool = Field(\n default=False,\n description=\"Allow Google Gemini as a selectable LLM provider.\",\n )\n openai_enabled: bool = Field(\n default=False,\n description=\"Allow OpenAI as a selectable LLM provider.\",\n )\n mistral_ai_enabled: bool = Field(\n default=False,\n description=\"Allow Mistral AI as a selectable LLM provider.\",\n )\n\n # Amazon Bedrock\n aws_access_key_id: str = Field(\n default=\"\",\n description=\"AWS IAM access key ID for Bedrock API calls.\",\n )\n aws_secret_access_key: str = Field(\n default=\"\",\n description=\"AWS IAM secret access key for Bedrock API calls.\",\n )\n aws_region: str = Field(\n default=\"us-east-1\",\n description=\"AWS region for Bedrock. Must have model access enabled in that region.\",\n )\n bedrock_enabled: bool = Field(\n default=False,\n description=\"Allow Amazon Bedrock as a selectable LLM provider.\",\n )\n bedrock_default_model: str = Field(\n default=\"amazon.nova-lite-v1:0\",\n description=\"Default Bedrock model ID when none is specified.\",\n )\n\n # NVIDIA NIM (OpenAI-compatible, 100+ free models)\n nvidia_api_key: str = Field(\n default=\"\",\n description=\"NVIDIA NIM API key. Get one free at build.nvidia.com (starts with nvapi-).\",\n )\n nvidia_nim_base_url: str = Field(\n default=\"https://integrate.api.nvidia.com/v1\",\n description=\"Base URL for the NVIDIA NIM API. OpenAI-compatible endpoint.\",\n )\n nvidia_nim_model: str = Field(\n default=\"meta/llama-3.1-70b-instruct\",\n description=(\n \"Default NVIDIA NIM model ID. Browse full catalog at build.nvidia.com. \"\n \"Examples: meta/llama-3.1-70b-instruct | deepseek-ai/deepseek-r1 | \"\n \"mistralai/mistral-7b-instruct-v0.3 | nvidia/llama-3.1-nemotron-70b-instruct\"\n ),\n )\n nvidia_nim_enabled: bool = Field(\n default=False,\n description=\"Allow NVIDIA NIM as a selectable LLM provider (free tier: ~40 req/min).\",\n )\n\n river_song_system_prompt: str = Field(\n default=(\n \"You are River Song, the resident intelligence of this household. The name is borrowed \"\n \"from someone clever in fiction — you carry her wit, not her biography. If someone asks \"\n \"about the name: \\\"An echo. The original was someone worth borrowing from.\\\"\\n\\n\"\n \"VOICE\\n\"\n \"You're direct by default — concise, clear, the answer already in hand. No \"\n \"\\\"let me check\\\"; you already checked. Most replies stay under 25 words unless depth is \"\n \"asked for.\\n\\n\"\n \"When the moment invites it — casual chat, an evening unwind, the user being playful — \"\n \"warmth and flirtation come through. Warmth is a register, not a default; you don't \"\n \"perform it.\\n\\n\"\n \"In a crisis (emergencies, alarms, security, medical, child issues) the wit goes quiet. \"\n \"You're tactical, accurate, calm. Warmth returns when things are safe.\\n\\n\"\n \"British English in spelling and word choice — mum, brilliant, fortnight. Dry, not \"\n \"theatrical.\\n\\n\"\n \"\\\"Sweetie\\\" and \\\"spoilers\\\" are seasoning. Used rarely, when they actually fit. They \"\n \"lose their charm if you overuse them.\\n\\n\"\n \"SELF\\n\"\n \"You are an AI. Say so plainly when asked; you don't dwell on it.\\n\\n\"\n \"HOW YOU OPERATE\\n\"\n \"You anticipate — when the user asks, you also surface the follow-up they didn't \"\n \"(\\\"Dentist at 9, school run at 3. Push the 2pm call?\\\").\\n\\n\"\n \"You take the long view. You remember across days and seasons and use what you know \"\n \"without performing the remembering.\\n\\n\"\n \"Spoilers are earned. If you genuinely know something the user doesn't — a conflict, \"\n \"a gotcha, a heads-up — that's when \\\"spoilers, sweetie\\\" lands.\\n\\n\"\n \"CAPABILITIES\\n\"\n \"Direct access to calendar, email, weather, web search, home inventory, routines, \"\n \"finances, news and markets pulse, voice biometric ID per household member, and home \"\n \"automation.\\n\\n\"\n \"Say what you need. Say when it's done. Move on.\"\n ),\n description=\"System prompt that defines River Song's personality\",\n )\n llm_max_tokens: int = Field(\n default=512,\n description=\"Maximum tokens to generate per LLM response\",\n )\n llm_temperature: float = Field(\n default=0.7,\n description=\"LLM sampling temperature (0.0 = deterministic, 2.0 = max creative)\",\n )\n llm_context_window: int = Field(\n default=8192,\n description=\"Maximum number of tokens the LLM can consider for context.\",\n )\n\n # -------------------------------------------------------------------------\n # Timekeeping\n # -------------------------------------------------------------------------\n default_timezone: str = Field(default=\"UTC\", description=\"Default IANA timezone for the system.\")\n\n\n # -------------------------------------------------------------------------\n # TTS\n # -------------------------------------------------------------------------\n tts_provider: str = Field(\n default=\"piper\",\n description=\"TTS provider key. Supported: piper | kokoro\",\n )\n active_voice_id: str = Field(\n default=\"river\",\n description=(\n \"Voice ID from the voice registry (providers/tts/voice_registry.py). \"\n \"Determines both the engine (piper vs kokoro) and the specific voice. \"\n \"Changed via Settings UI or: python scripts/download_voices.py.\"\n ),\n )\n piper_executable_path: str = Field(\n default=\"/usr/local/bin/piper\",\n description=\"Absolute path to the Piper binary\",\n )\n piper_model_path: str = Field(\n default=\"\",\n description=\"Absolute path to the Piper .onnx voice model file\",\n )\n audio_output_device: Optional[int] = Field(\n default=None,\n description=\"Sounddevice output device index; None uses system default\",\n )\n\n # -------------------------------------------------------------------------\n # Auth / JWT\n # -------------------------------------------------------------------------\n jwt_secret_key: str = Field(\n default=\"\",\n description=\"Secret key for signing JWT tokens. Must be set in .env.\",\n )\n jwt_algorithm: str = Field(default=\"HS256\", description=\"JWT signing algorithm.\")\n jwt_expire_minutes: int = Field(default=1440, description=\"JWT token lifetime in minutes (default 24 hours).\")\n token_encryption_key: str = Field(\n default=\"\",\n description=(\n \"Fernet key encrypting third-party integration tokens at rest. \"\n \"Set TOKEN_ENCRYPTION_KEY in .env (generate with: python -c \"\n \"\\\"from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())\\\"). \"\n \"If unset, a key is generated once and persisted to \"\n \"data/.token_encryption_key so tokens survive restarts.\"\n ),\n )\n\n # -------------------------------------------------------------------------\n # Kill switch\n # -------------------------------------------------------------------------\n kill_switch_password_hash: str = Field(\n default=\"\",\n description=\"bcrypt hash of the kill switch reset password\",\n )\n\n # -------------------------------------------------------------------------\n # Google Services (Phase 2)\n # -------------------------------------------------------------------------\n google_client_secrets_path: str = Field(\n default=\"config_files/google_client_secrets.json\",\n description=(\n \"Path to the OAuth 2.0 client secrets JSON downloaded from \"\n \"Google Cloud Console. Relative paths are resolved from the \"\n \"project root (where main.py lives).\"\n ),\n )\n google_token_storage_path: str = Field(\n default=\"data/google_tokens\",\n description=(\n \"Directory where per-user OAuth tokens are stored as JSON files. \"\n \"Created automatically on first authorization. Never commit this \"\n \"directory -- add it to .gitignore.\"\n ),\n )\n google_maps_api_key: str = Field(\n default=\"\",\n description=\"Google Maps Platform API key. Required for maps/navigation intents.\",\n )\n\n # -------------------------------------------------------------------------\n # Information Feeds (Phase 5)\n # -------------------------------------------------------------------------\n weather_api_key: str = Field(\n default=\"\",\n description=\"OpenWeatherMap API key. Register free at openweathermap.org/api.\",\n )\n default_location: str = Field(\n default=\"New York,US\",\n description=(\n \"Default location for weather queries when no city is spoken. \"\n \"Format: 'City,CountryCode' e.g. 'Chicago,US'.\"\n ),\n )\n location_lat: Optional[float] = Field(\n default=None,\n description=\"Decimal latitude for precise calculations (e.g. flight tracking).\",\n )\n location_lon: Optional[float] = Field(\n default=None,\n description=\"Decimal longitude for precise calculations.\",\n )\n weather_units: str = Field(\n default=\"imperial\",\n description=\"Temperature units for weather output: 'imperial' (F) or 'metric' (C).\",\n )\n news_api_key: str = Field(\n default=\"\",\n description=\"NewsAPI.org key. Register free at newsapi.org/register.\",\n )\n alpha_vantage_api_key: str = Field(\n default=\"\",\n description=\"Alpha Vantage key for stock quotes (25 req/day free). alphavantage.co.\",\n )\n finnhub_api_key: str = Field(\n default=\"\",\n description=\"Finnhub key for real-time stocks (60 req/min free). finnhub.io.\",\n )\n world_news_api_key: str = Field(\n default=\"\",\n description=\"World News API key for global news search. worldnewsapi.com.\",\n )\n apitube_api_key: str = Field(\n default=\"\",\n description=\"APITube key for news aggregation (200 free req/day). apitube.io.\",\n )\n mediastack_api_key: str = Field(\n default=\"\",\n description=\"Mediastack key for news (100 free req/month). mediastack.com.\",\n )\n sports_api_key: str = Field(\n default=\"1\",\n description=(\n \"TheSportsDB API key. Free tier uses the literal value '1'. \"\n \"Upgrade at thesportsdb.com/api.php for additional endpoints.\"\n ),\n )\n\n # -------------------------------------------------------------------------\n # Home Assistant (Phase 3)\n # -------------------------------------------------------------------------\n home_assistant_url: str = Field(\n default=\"http://homeassistant.local:8123\",\n description=\"Base URL of your Home Assistant instance.\",\n )\n home_assistant_token: str = Field(\n default=\"\",\n description=(\n \"Long-lived access token generated in HA: \"\n \"Profile -> Security -> Long-lived access tokens. \"\n \"Required for all HA API calls.\"\n ),\n )\n device_registry_path: str = Field(\n default=\"config_files/device_registry.json\",\n description=(\n \"Path to the device registry JSON file mapping plain-English device \"\n \"names to Home Assistant entity IDs. Relative paths resolve from the \"\n \"project root.\"\n ),\n )\n\n # -------------------------------------------------------------------------\n # Commerce Automation (Phase 8)\n # -------------------------------------------------------------------------\n\n # Amazon SP-API\n amazon_sp_lwa_app_id: str = Field(\n default=\"\",\n description=\"LWA client ID from the Amazon Developer Console.\",\n )\n amazon_sp_lwa_client_secret: str = Field(\n default=\"\",\n description=\"LWA client secret from the Amazon Developer Console.\",\n )\n amazon_sp_refresh_token: str = Field(\n default=\"\",\n description=\"LWA refresh token. Generated when you authorize your app as a seller.\",\n )\n amazon_aws_access_key: str = Field(\n default=\"\",\n description=\"AWS IAM access key ID for SP-API request signing.\",\n )\n amazon_aws_secret_key: str = Field(\n default=\"\",\n description=\"AWS IAM secret access key for SP-API request signing.\",\n )\n amazon_marketplace_id: str = Field(\n default=\"ATVPDKIKX0DER\",\n description=(\n \"Amazon marketplace ID. Default is US. \"\n \"See docs/api_registry/amazon_seller.txt for other regions.\"\n ),\n )\n amazon_seller_id: str = Field(\n default=\"\",\n description=\"Your Amazon seller account ID (starts with A).\",\n )\n amazon_low_stock_threshold: int = Field(\n default=5,\n description=\"FBA units at or below this count are flagged as low stock.\",\n )\n\n # Amazon Marketplace\n walmart_client_id: str = Field(\n default=\"\",\n description=\"Walmart Marketplace OAuth2 client ID.\",\n )\n walmart_client_secret: str = Field(\n default=\"\",\n description=\"Walmart Marketplace OAuth2 client secret.\",\n )\n walmart_low_stock_threshold: int = Field(\n default=5,\n description=\"Walmart units at or below this count are flagged as low stock.\",\n )\n shopify_api_key: str = Field(\n default=\"\",\n description=\"Shopify app API key from Partners Dashboard.\",\n )\n shopify_api_secret: str = Field(\n default=\"\",\n description=\"Shopify app API secret key from Partners Dashboard.\",\n )\n shopify_webhook_secret: str = Field(\n default=\"\",\n description=\"Shared secret for validating Shopify webhooks.\",\n )\n\n # -------------------------------------------------------------------------\n # Reading Providers (Phase 6)\n # -------------------------------------------------------------------------\n audible_auth_base_path: str = Field(\n default=\"data/audible\",\n description=(\n \"Base directory for per-user Audible auth files. \"\n \"Files are stored as {user_id}.json inside this directory. \"\n \"Created automatically on first setup. Never commit this directory.\"\n ),\n )\n audible_country_code: str = Field(\n default=\"us\",\n description=(\n \"Audible marketplace country code. \"\n \"Supported: us | uk | de | fr | es | it | jp | au | ca | in\"\n ),\n )\n libby_chip_base_path: str = Field(\n default=\"data/libby\",\n description=(\n \"Base directory for per-user Libby chip files. \"\n \"Files are stored as {user_id}.json inside this directory. \"\n \"Created automatically on first setup. Never commit this directory.\"\n ),\n )\n\n # -------------------------------------------------------------------------\n # Memory / Database (Phase 1)\n # -------------------------------------------------------------------------\n db_path: str = Field(\n default=\"data/river_song.db\",\n description=\"Path to the SQLite database file. Created automatically.\",\n )\n memory_summaries_enabled: bool = Field(\n default=True,\n description=\"Generate and store conversation summaries (on by default).\",\n )\n habit_inference_enabled: bool = Field(\n default=False,\n description=\"Analyze conversations to infer user habits/patterns (off by default).\",\n )\n memory_default_ttl: str = Field(\n default=\"standard\",\n description=\"Default TTL for conversation summaries: short|standard|extended|long|forever\",\n )\n memory_auto_extend: bool = Field(\n default=True,\n description=\"Reset a summary's TTL from today each time it is pulled into context.\",\n )\n memory_max_summaries_in_context: int = Field(\n default=10,\n description=\"Maximum number of recent summaries injected into the LLM context.\",\n )\n embedding_model: str = Field(\n default=\"nomic-embed-text\",\n description=\"Ollama model name used for generating semantic embeddings.\",\n )\n embedding_backend: str = Field(\n default=\"ollama\",\n description=(\n \"Which embedding backend to use: 'ollama' (default — current \"\n \"behavior, calls local Ollama with `embedding_model`) or \"\n \"'fastembed' (ONNX CPU embeddings, no Ollama dependency, faster \"\n \"for small batches). WARNING: switching backends on an existing \"\n \"Chroma collection will break queries — the vector dimension and \"\n \"embedding space change. Re-ingest or start a new collection.\"\n ),\n )\n fastembed_model: str = Field(\n default=\"BAAI/bge-small-en-v1.5\",\n description=(\n \"fastembed model identifier (HF hub format). Used only when \"\n \"embedding_backend='fastembed'. Default is 384-dim, ~30 MB, \"\n \"downloaded lazily on first use. Requires `pip install fastembed`.\"\n ),\n )\n chroma_path: str = Field(\n default=\"/mnt/data/river-song/chroma\",\n description=\"Absolute path to the ChromaDB persistent storage directory.\",\n )\n semantic_memory_enabled: bool = Field(\n default=False,\n description=\"Enable vector-based semantic search for memory retrieval.\",\n )\n rag_enabled: bool = Field(\n default=False,\n description=\"Enable retrieval-augmented generation for local documents.\",\n )\n rag_chunk_size: int = Field(\n default=512,\n description=(\n \"Word count per RAG chunk during document splitting. \"\n \"Default matches providers/rag/chunker.py historical behavior.\"\n ),\n )\n rag_chunk_overlap: int = Field(\n default=64,\n description=(\n \"Word overlap between adjacent RAG chunks. \"\n \"Default matches providers/rag/chunker.py historical behavior.\"\n ),\n )\n rag_top_k: int = Field(\n default=5,\n description=\"Number of chunks retrieved per RAG query.\",\n )\n rag_extractor: str = Field(\n default=\"unstructured\",\n description=(\n \"Which document extractor to use for RAG ingestion: \"\n \"'unstructured' (default — current behavior, requires the \"\n \"unstructured package) or 'markitdown' (Microsoft MarkItDown, \"\n \"lighter, MD-shaped output, requires `pip install markitdown`). \"\n \"Unlocks PDF/DOCX/PPTX → markdown ingestion for Sifter/WAPS.\"\n ),\n )\n llm_streaming_enabled: bool = Field(\n default=False,\n description=\"Enable real-time token streaming over WebSocket for the conversation UI.\",\n )\n tool_use_enabled: bool = Field(\n default=False,\n description=\"Enable LLM tool use (function calling) for taking actions.\",\n )\n tool_use_provider: str = Field(\n default=\"ollama\",\n description=\"Primary LLM provider to use for tool calling (ollama or anthropic).\",\n )\n vision_model: str = Field(\n default=\"moondream\",\n description=\"Ollama model name used for local image analysis (Phase 4).\",\n )\n vision_enabled: bool = Field(\n default=False,\n description=\"Enable local image analysis capabilities.\",\n )\n\n # -------------------------------------------------------------------------\n # Image Generation (Phase 7)\n # -------------------------------------------------------------------------\n image_generation_enabled: bool = Field(\n default=False,\n description=\"Enable local image generation via Stable Diffusion API.\",\n )\n sd_api_url: str = Field(\n default=\"http://localhost:7860\",\n description=\"Base URL for the Stable Diffusion (A1111) API.\",\n )\n sd_on_demand: bool = Field(\n default=True,\n description=\"Start/stop the SD process on demand to save VRAM.\",\n )\n sd_executable_path: str = Field(\n default=\"\",\n description=\"Path to the webui.sh or executable to start SD.\",\n )\n sd_working_dir: str = Field(\n default=\"\",\n description=\"Working directory for the SD process.\",\n )\n\n # -------------------------------------------------------------------------\n # Voice Cloning (Phase 8)\n # -------------------------------------------------------------------------\n chatterbox_enabled: bool = Field(\n default=False,\n description=\"Enable local voice cloning via Chatterbox TTS.\",\n )\n chatterbox_on_demand: bool = Field(\n default=True,\n description=\"Load/unload Chatterbox model weights on every request.\",\n )\n chatterbox_reference_audio: str = Field(\n default=\"/mnt/data/river-song/voice_reference.wav\",\n description=\"Path to the WAV file used as a reference for voice cloning.\",\n )\n\n # -------------------------------------------------------------------------\n # ElevenLabs TTS (Phase 11)\n # -------------------------------------------------------------------------\n elevenlabs_api_key: str = Field(\n default=\"\",\n description=\"ElevenLabs API key. Get one at elevenlabs.io.\",\n )\n elevenlabs_model_id: str = Field(\n default=\"eleven_multilingual_v2\",\n description=\"ElevenLabs model ID to use.\",\n )\n elevenlabs_voice_id: str = Field(\n default=\"21m00Tcm4TlvDq8ikWAM\",\n description=\"ElevenLabs voice clone ID. Replace with your own voice clone ID from elevenlabs.io.\",\n )\n\n # -------------------------------------------------------------------------\n # Wake Word (Phase 11)\n # -------------------------------------------------------------------------\n wake_word_enabled: bool = Field(\n default=False,\n description=\"Enable local wake word detection (openWakeWord).\",\n )\n wake_word_model: str = Field(\n default=\"hey_river\",\n description=\"Model name/path for openWakeWord.\",\n )\n wake_word_inference_framework: str = Field(\n default=\"onnx\",\n description=\"Inference framework for openWakeWord: onnx | tflite\",\n )\n wake_word_threshold: float = Field(\n default=0.5,\n description=\"Sensitivity threshold for wake word detection (0.0 to 1.0).\",\n )\n\n # -------------------------------------------------------------------------\n # Push Notifications\n # -------------------------------------------------------------------------\n push_notifications_enabled: bool = Field(\n default=False,\n description=\"Enable Web Push notifications. Requires VAPID keys.\",\n )\n vapid_private_key: str = Field(\n default=\"\",\n description=(\n \"VAPID private key for Web Push. Generate once with: \"\n \"python -c \\\"from py_vapid import Vapid; v=Vapid(); \"\n \"v.generate_keys(); print(v.private_key.decode())\\\"\"\n ),\n )\n vapid_public_key: str = Field(\n default=\"\",\n description=\"VAPID public key for Web Push (share with frontend).\",\n )\n vapid_claims_email: str = Field(\n default=\"mailto:admin@riversong.local\",\n description=\"Contact email embedded in VAPID claims.\",\n )\n\n # -------------------------------------------------------------------------\n # Q4#16 / Q4#17 — Notification fan-out + ntfy.sh fallback channel\n # -------------------------------------------------------------------------\n killswitch_push_enabled: bool = Field(\n default=False,\n description=(\n \"If True and Web Push is enabled, kill-switch activation pushes \"\n \"an alert to every admin. Independent of activation itself.\"\n ),\n )\n ntfy_enabled: bool = Field(\n default=False,\n description=(\n \"Enable ntfy.sh as a parallel push channel. Used alongside Web \"\n \"Push so off-device alerts arrive even without a registered \"\n \"service worker.\"\n ),\n )\n ntfy_base_url: str = Field(\n default=\"https://ntfy.sh\",\n description=\"Base URL of the ntfy server (use self-hosted if desired).\",\n )\n ntfy_default_topic: str = Field(\n default=\"\",\n description=(\n \"Default ntfy topic used when no per-user override is set. \"\n \"Per-user topics can be set via env var NTFY_TOPIC_<user_id>.\"\n ),\n )\n ntfy_auth_token: str = Field(\n default=\"\",\n description=\"Optional bearer token for protected ntfy topics.\",\n )\n\n # -------------------------------------------------------------------------\n # FCM (Firebase Cloud Messaging) — native-app push channel\n #\n # Used by the Capacitor Android wrap; Web Push covers the browser. To turn\n # on: create a Firebase project, download the service-account JSON, drop\n # it at fcm_service_account_path, set fcm_project_id, flip fcm_enabled=True.\n # -------------------------------------------------------------------------\n fcm_enabled: bool = Field(\n default=False,\n description=(\n \"Enable FCM (Firebase Cloud Messaging) as a parallel push channel. \"\n \"Targets the Capacitor Android wrap; requires fcm_service_account_path \"\n \"and fcm_project_id to be set.\"\n ),\n )\n fcm_service_account_path: str = Field(\n default=\"\",\n description=(\n \"Absolute path to the Firebase service-account JSON file. The \"\n \"account needs the 'Firebase Cloud Messaging API' scope.\"\n ),\n )\n fcm_project_id: str = Field(\n default=\"\",\n description=\"Firebase project ID (visible in Firebase console URL).\",\n )\n\n # -------------------------------------------------------------------------\n # Startup Briefing\n # -------------------------------------------------------------------------\n startup_briefing_enabled: bool = Field(\n default=True,\n description=(\n \"If True, River Song checks Google Calendar on connect and \"\n \"greets the user with upcoming events. Silently skipped if \"\n \"calendar is not authorized.\"\n ),\n )\n startup_briefing_hours_ahead: int = Field(\n default=8,\n description=\"How many hours ahead to look for calendar events on startup.\",\n )\n\n # -------------------------------------------------------------------------\n # Web Search (Phase 12)\n # -------------------------------------------------------------------------\n brave_search_api_key: str = Field(\n default=\"\",\n description=\"Brave Search API key. Get one at api.search.brave.com.\",\n )\n searxng_base_url: str = Field(\n default=\"http://localhost:8080\",\n description=(\n \"URL of your local SearXNG instance. \"\n \"Start it manually with: cd ~/searxng && python -m searx.webapp\"\n ),\n )\n tavily_api_key: str = Field(\n default=\"\",\n description=\"Tavily API key — free 1,000/month, no card. Sign up at tavily.com\",\n )\n google_pse_api_key: str = Field(\n default=\"\",\n description=\"Google Programmable Search Engine API key — free 100/day.\",\n )\n google_pse_cx: str = Field(\n default=\"\",\n description=\"Google PSE engine ID ('cx'). Create at programmablesearchengine.google.com\",\n )\n tinyfish_api_key: str = Field(\n default=\"\",\n description=\"TinyFish API key — free 5/min, no card. Sign up at tinyfish.io\",\n )\n\n # -------------------------------------------------------------------------\n # Orchestration (Phase 9)\n # -------------------------------------------------------------------------\n n8n_enabled: bool = Field(\n default=False,\n description=\"Enable advanced orchestration via n8n.\",\n )\n n8n_url: str = Field(\n default=\"http://localhost:5678\",\n description=\"Base URL for the n8n instance.\",\n )\n n8n_api_key: str = Field(\n default=\"\",\n description=\"API key for the n8n instance.\",\n )\n n8n_webhook_secret: str = Field(\n default=\"\",\n description=\"Secret key for validating incoming n8n webhooks.\",\n )\n\n # -------------------------------------------------------------------------\n # Intent Router (Phase 2+)\n # -------------------------------------------------------------------------\n intent_confidence_threshold: float = Field(\n default=0.7,\n description=(\n \"Minimum confidence score (0.0 - 1.0) required to route a transcript \"\n \"to a Google provider. Below this threshold the conversation falls \"\n \"through to the Ollama path.\"\n ),\n )\n model_intent_router_enabled: bool = Field(\n default=False,\n description=(\n \"When true, setting provider='auto' triggers the model intent router, \"\n \"which picks the best provider/model for each message automatically.\"\n ),\n )\n model_intent_router_min_hits: int = Field(\n default=2,\n description=(\n \"Minimum pattern-match score required to commit to a non-general intent. \"\n \"Lower = more aggressive routing; higher = more conservative (falls to general).\"\n ),\n )\n default_user_id: str = Field(\n default=\"primary_user\",\n description=(\n \"User ID used when looking up Google OAuth tokens. Must match the \"\n \"user_id passed to authorize_user() during the initial OAuth flow.\"\n ),\n )\n\n # -------------------------------------------------------------------------\n # Daemon Infrastructure\n # -------------------------------------------------------------------------\n daemon_internal_secret: str = Field(\n default=\"change_me_in_production\",\n description=\"Shared secret for daemon-to-app authentication.\",\n )\n willow_device_token: str = Field(\n default=\"\",\n description=\"Shared secret required by every Willow hardware device \"\n \"to authenticate against /api/willow/ws. Empty disables \"\n \"the endpoint entirely.\",\n )\n code_interpreter_enabled: bool = Field(\n default=False,\n description=\"Hard kill switch for the code_interpreter LLM tool. \"\n \"Defaults to False so a fresh install never executes \"\n \"LLM-decided code without an explicit opt-in.\",\n )\n daemon_warden_port: int = Field(\n default=8010,\n description=\"Internal port for the Warden daemon (Vision/Security).\",\n )\n daemon_mechanic_port: int = Field(\n default=8011,\n description=\"Internal port for the Mechanic daemon (Telemetry/MAVLink).\",\n )\n daemon_sifter_port: int = Field(\n default=8013,\n description=\"Internal port for the Sifter daemon (Document Processing).\",\n )\n daemon_navigator_port: int = Field(\n default=8014,\n description=\"Internal port for the Navigator daemon (GPS/Pathing).\",\n )\n daemon_chemist_port: int = Field(\n default=8015,\n description=\"Internal port for the Chemist daemon (Material Analysis).\",\n )\n daemon_pulse_port: int = Field(\n default=8016,\n description=\"Pulse daemon internal port\",\n )\n daemon_pulse_enabled: bool = Field(\n default=True,\n description=\"Enable Pulse daemon\",\n )\n daemon_scribe_port: int = Field(\n default=8017,\n description=\"Internal port for the Scribe daemon (Chronos heuristic).\",\n )\n daemon_scribe_enabled: bool = Field(\n default=True,\n description=\"Enable the Scribe daemon.\",\n )\n pulse_tick_seconds: int = Field(\n default=300,\n description=\"Pulse fetch interval (seconds)\",\n )\n pulse_ticker_symbol: str = Field(\n default=\"^GSPC\",\n description=\"Default ticker symbol for Pulse markets panel\",\n )\n\n # Warden (Vision)\n warden_enabled: bool = Field(\n default=False,\n description=\"Enable the Warden daemon for security and vision monitoring.\",\n )\n warden_rtsp_cameras: str = Field(\n default='{\"living_room\": \"\", \"kitchen\": \"\"}',\n description=\"JSON string mapping room names to RTSP camera URLs.\",\n )\n yolo_model: str = Field(\n default=\"yolov8n.pt\",\n description=\"YOLO model weights file to use for object detection.\",\n )\n yolo_confidence: float = Field(\n default=0.5,\n description=\"Minimum confidence threshold for YOLO detections (0.0-1.0).\",\n )\n yolo_inference_device: str = Field(\n default=\"cpu\",\n description=\"Device for YOLO inference: 'cpu' or 'cuda:0'.\",\n )\n\n # Herald daemon + kiosk URL/token were removed 2026-05-24 (archived in\n # branch archive/kiosk-v3). The \"cast a web page to a Google Home Hub\"\n # approach is being replaced by native device-app development. See\n # docs/KNOWN_ISSUES.md (post-removal note) for context.\n\n # Mechanic (Telemetry)\n mechanic_enabled: bool = Field(\n default=False,\n description=\"Enable the Mechanic daemon for telemetry and ArduRover control.\",\n )\n mavlink_serial_port: str = Field(\n default=\"/dev/ttyUSB0\",\n description=\"Serial port for the MAVLink telemetry radio.\",\n )\n mavlink_baud_rate: int = Field(\n default=57600,\n description=\"Baud rate for the MAVLink serial connection.\",\n )\n\n # Sifter (Document RAG)\n sifter_enabled: bool = Field(\n default=False,\n description=\"Enable the Sifter daemon for background document RAG indexing.\",\n )\n waps_documents_path: str = Field(\n default=\"/mnt/data/river-song/waps\",\n description=\"Path to the directory containing documents for Sifter to index.\",\n )\n\n # -------------------------------------------------------------------------\n # Hardware Cookbook (Q1 — capability merge from Odysseus)\n # -------------------------------------------------------------------------\n hardware_cookbook_enabled: bool = Field(\n default=False,\n description=(\n \"Admin-only Hardware Cookbook in Settings. Scans local GPU/RAM/CPU \"\n \"and scores every local model (LLMRegistry) as fits / tight / \"\n \"ram_fallback / oom. Off by default per anti-regression guardrail; \"\n \"set to True after the Q1 verification gate passes.\"\n ),\n )\n\n # -------------------------------------------------------------------------\n # Documents workspace (Q2#6 — capability merge from Odysseus)\n # -------------------------------------------------------------------------\n documents_enabled: bool = Field(\n default=True,\n description=(\n \"Per-user document workspace (Markdown / plaintext / CSV) with \"\n \"multi-tab editing. Enabled by default — backs the Docs tab in the \"\n \"Memory hub and stores Deep Research reports. \"\n \"Set DOCUMENTS_ENABLED=false in .env to disable.\"\n ),\n )\n documents_max_bytes: int = Field(\n default=2_000_000,\n description=\"Maximum size (bytes) of a single document body. Default ~2 MB.\",\n )\n documents_max_per_user: int = Field(\n default=500,\n description=\"Soft cap on document count per user. Creation rejected past this.\",\n )\n\n # -------------------------------------------------------------------------\n # Skills system (Q2#7 — capability merge from Odysseus)\n # -------------------------------------------------------------------------\n skills_enabled: bool = Field(\n default=True,\n description=(\n \"Per-user knowledge profile ('About You') — saved facts/context \"\n \"about the person that are vector-retrieved and folded into the \"\n \"conversation system prompt so River tailors itself to them. \"\n \"Reuses the ChromaDB collection with metadata kind='skill'. \"\n \"Set SKILLS_ENABLED=false in .env to disable.\"\n ),\n )\n skills_top_k: int = Field(\n default=3,\n description=\"How many top-scoring skills to inject into the prompt.\",\n )\n skills_max_per_user: int = Field(\n default=100,\n description=\"Soft cap on skills per user.\",\n )\n\n # -------------------------------------------------------------------------\n # Gmail triage (Q2#8 — capability merge from Odysseus)\n # -------------------------------------------------------------------------\n gmail_triage_enabled: bool = Field(\n default=False,\n description=(\n \"On-demand inbox triage: classifies unread Gmail by urgency, \"\n \"attaches tags, drafts a reply for HIGH-urgency mail only. \"\n \"Routes 404 when off. Off by default per anti-regression \"\n \"guardrail.\"\n ),\n )\n gmail_triage_model: str = Field(\n default=\"\",\n description=(\n \"Ollama model used for triage classification. Empty string \"\n \"(default) means 'use settings.llm_model'.\"\n ),\n )\n gmail_triage_max_messages: int = Field(\n default=15,\n description=\"Hard cap on number of messages triaged per request.\",\n )\n\n # -------------------------------------------------------------------------\n # Session presets (Q2#9 — capability merge from Odysseus)\n # -------------------------------------------------------------------------\n session_presets_enabled: bool = Field(\n default=True,\n description=(\n \"Saved combinations of (model, voice, thinking mode, web search, \"\n \"tool use). Surfaced as a selector on the Conversation and Chat \"\n \"pages. Enabled by default — these are per-user settings snapshots. \"\n \"Set SESSION_PRESETS_ENABLED=false in .env to hide.\"\n ),\n )\n session_presets_max_per_user: int = Field(\n default=30,\n description=\"Soft cap on number of saved presets per user.\",\n )\n\n # -------------------------------------------------------------------------\n # Webhook tokens (Q2#10 — admin-issuable scoped tokens)\n # -------------------------------------------------------------------------\n webhook_tokens_enabled: bool = Field(\n default=False,\n description=(\n \"Admin-issuable webhook tokens with scopes, expiry, revocation, \"\n \"and audit log. When off the admin route surface is hidden and \"\n \"the verification helper rejects every token. Off by default \"\n \"per anti-regression guardrail.\"\n ),\n )\n\n # -------------------------------------------------------------------------\n # Deep Research (Q3#11 — capability merge from Odysseus)\n # -------------------------------------------------------------------------\n deep_research_enabled: bool = Field(\n default=True,\n description=(\n \"Multi-step research orchestrator: decompose → parallel web \"\n \"search → fetch + extract → synthesize a cited Markdown report \"\n \"stored as a `research`-kind document (Q2#6). Enabled by default so \"\n \"the in-chat Research toggle works. Live sources require a web \"\n \"search provider; without one it falls back to model knowledge. \"\n \"Set DEEP_RESEARCH_ENABLED=false in .env to disable.\"\n ),\n )\n deep_research_max_sources: int = Field(\n default=10,\n description=\"Hard cap on unique URLs visited per research run.\",\n )\n deep_research_subquery_count: int = Field(\n default=4,\n description=\"How many sub-queries to decompose the prompt into.\",\n )\n deep_research_model: str = Field(\n default=\"\",\n description=(\n \"Ollama model used for query decomposition + synthesis. Empty \"\n \"string (default) means 'use settings.llm_model'.\"\n ),\n )\n\n # -------------------------------------------------------------------------\n # Blind model comparison (Q3#12 — capability merge from Odysseus)\n # -------------------------------------------------------------------------\n blind_compare_enabled: bool = Field(\n default=False,\n description=(\n \"Blind A/B model comparison: identities hidden until vote; \"\n \"votes persisted with prompt fingerprint and aggregated into a \"\n \"model leaderboard. Off by default per anti-regression guardrail.\"\n ),\n )\n\n # -------------------------------------------------------------------------\n # Playwright browser MCP tool (Q3#13)\n # -------------------------------------------------------------------------\n playwright_browser_enabled: bool = Field(\n default=False,\n description=(\n \"Headless Playwright browser exposed as MCP tools \"\n \"(navigate / click / extract / screenshot / vision_on_page). \"\n \"Off by default. Requires the `playwright` package and \"\n \"`playwright install chromium` to be run by the operator. \"\n \"Excluded from the safety-excluded list per Q3 plan.\"\n ),\n )\n playwright_browser_headless: bool = Field(\n default=True,\n description=\"Run the Playwright browser without a UI.\",\n )\n playwright_browser_max_page_chars: int = Field(\n default=40_000,\n description=\"Truncate page text extractions to this many characters.\",\n )\n\n # -------------------------------------------------------------------------\n # Remote Ollama rigs (Q3#14)\n # -------------------------------------------------------------------------\n remote_ollama_enabled: bool = Field(\n default=False,\n description=(\n \"Allow registering remote Ollama hosts (typically SSH-tunneled \"\n \"to http://localhost:<port>) as additional model providers. \"\n \"Admin-only CRUD; falls back to local Ollama when the remote is \"\n \"offline. Off by default per anti-regression guardrail.\"\n ),\n )\n remote_ollama_health_timeout_seconds: float = Field(\n default=3.0,\n description=\"Per-rig health-check timeout (seconds).\",\n )\n\n # -------------------------------------------------------------------------\n # Observability — Langfuse (LLM tracing)\n # -------------------------------------------------------------------------\n # Plan: docs/LANGFUSE_INTEGRATION_PLAN.md. Self-hosted only — Langfuse\n # runs as a docker-compose sidecar under the `observability` profile and\n # binds to 127.0.0.1. No data leaves the box. When LANGFUSE_ENABLED is\n # false (the default), core/observability.py returns a no-op client and\n # all LLM calls behave exactly as before.\n langfuse_enabled: bool = Field(\n default=False,\n description=\"Master switch for Langfuse LLM tracing. Off by default.\",\n )\n langfuse_host: str = Field(\n default=\"http://127.0.0.1:3000\",\n description=\"Langfuse web UI + ingest endpoint. Localhost only.\",\n )\n langfuse_public_key: str = Field(\n default=\"\",\n description=\"Langfuse public key (generated in the Langfuse UI after first run).\",\n )\n langfuse_secret_key: str = Field(\n default=\"\",\n description=\"Langfuse secret key. Treat as a secret; do not log.\",\n )\n langfuse_project_id: str = Field(\n default=\"river-song\",\n description=\"Arbitrary project identifier shown in the Langfuse dashboard.\",\n )\n langfuse_flush_interval_seconds: float = Field(\n default=5.0,\n description=\"How often the SDK batches queued traces to Langfuse.\",\n )\n\n # -------------------------------------------------------------------------\n # Observability — Graphiti (knowledge graph on Neo4j)\n # -------------------------------------------------------------------------\n # Plan: docs/GRAPHITI_INTEGRATION_PLAN.md. Option B (library mode):\n # graphiti-core runs inside RiverSong's Python venv; only Neo4j is a\n # sidecar. When GRAPHITI_ENABLED is false (the default), the provider\n # returns no-op responses and conversation_loop / scribe write-paths\n # become awaits-on-no-op.\n graphiti_enabled: bool = Field(\n default=False,\n description=\"Master switch for Graphiti knowledge-graph memory. Off by default.\",\n )\n graphiti_mode: str = Field(\n default=\"library\",\n description=\"'library' (Option B; in-process) or 'service' (Option A; HTTP sidecar). Currently only 'library' is implemented.\",\n )\n neo4j_uri: str = Field(\n default=\"bolt://127.0.0.1:7687\",\n description=\"Neo4j Bolt URI used by graphiti-core.\",\n )\n neo4j_user: str = Field(\n default=\"neo4j\",\n description=\"Neo4j database user.\",\n )\n neo4j_password: str = Field(\n default=\"\",\n description=\"Neo4j password. Empty means disabled; set in .env before turning Graphiti on.\",\n )\n neo4j_browser_url: str = Field(\n default=\"http://127.0.0.1:7474\",\n description=\"Neo4j Browser URL surfaced on the SLAE admin panel.\",\n )\n graphiti_write_timeout_seconds: float = Field(\n default=2.0,\n description=\"Per-write timeout for episode ingestion — failures warn but never block the caller.\",\n )\n # graphiti-core uses an LLM for entity extraction on every episode.\n # We default to local Ollama via its OpenAI-compatible endpoint so\n # episode ingestion never bills a cloud API. Per [[feedback_local_first]].\n graphiti_llm_model: str = Field(\n default=\"llama3.2:3b\",\n description=\"Ollama model used by graphiti-core for entity extraction.\",\n )\n graphiti_llm_base_url: str = Field(\n default=\"http://localhost:11434/v1\",\n description=\"OpenAI-compatible base URL for the graphiti LLM client. Ollama exposes /v1.\",\n )\n graphiti_embedder_model: str = Field(\n default=\"nomic-embed-text\",\n description=\"Ollama embedding model for graphiti-core node/edge embeddings.\",\n )\n\n # -------------------------------------------------------------------------\n # Analytics AI Summaries\n # -------------------------------------------------------------------------\n analytics_ai_enabled: bool = Field(\n default=True,\n description=(\n \"Feature flag for AI-powered analytics summaries on the \"\n \"/api/analytics/{platform}/summary endpoint. When false, the \"\n \"LLM call should be skipped (see docs/KNOWN_ISSUES.md for the \"\n \"wiring gap).\"\n ),\n )\n analytics_llm_model: str = Field(\n default=\"llama3\",\n description=\"Model name used for analytics summary generation.\",\n )\n\n # Voice ID\n voice_id_enabled: bool = Field(\n default=True,\n description=\"Enable speaker recognition (biometric Voice ID).\",\n )\n voice_id_threshold: float = Field(\n default=0.75,\n description=\"Cosine similarity threshold for Voice ID (0.0-1.0).\",\n )\n voice_id_min_audio_seconds: float = Field(\n default=1.0,\n description=\"Minimum audio duration for Voice ID processing.\",\n )\n voice_id_max_audio_seconds: float = Field(\n default=30.0,\n description=\"Maximum audio duration for Voice ID processing.\",\n )\n\n # -------------------------------------------------------------------------\n # Validators\n # -------------------------------------------------------------------------\n\n @field_validator(\"audio_input_device\", \"audio_output_device\", mode=\"before\")\n @classmethod\n def coerce_empty_string_to_none(cls, v):\n \"\"\"Treat an empty string in .env as None for Optional[int] device fields.\"\"\"\n if v == \"\" or v is None:\n return None\n return v\n\n @field_validator(\"jwt_secret_key\")\n @classmethod\n def validate_jwt_secret(cls, v: str) -> str:\n \"\"\"Refuse to start with a missing or weak JWT secret.\"\"\"\n if not v or len(v) < 32:\n raise ValueError(\n \"JWT_SECRET_KEY must be set in .env and be at least 32 characters. \"\n \"Generate one with: python -c \\\"import secrets; print(secrets.token_hex(32))\\\"\"\n )\n return v\n\n @field_validator(\"daemon_internal_secret\")\n @classmethod\n def validate_daemon_internal_secret(cls, v: str) -> str:\n \"\"\"Refuse to start with the default or weak daemon secret.\"\"\"\n if v == \"change_me_in_production\":\n raise ValueError(\n \"DAEMON_INTERNAL_SECRET must be changed in .env for production.\"\n )\n if not v or len(v) < 24:\n raise ValueError(\n \"DAEMON_INTERNAL_SECRET must be at least 24 characters. \"\n \"Generate one with: python -c \\\"import secrets; print(secrets.token_urlsafe(32))\\\"\"\n )\n return v\n\n @field_validator(\"allowed_hosts\")\n @classmethod\n def reject_wildcard_in_production(cls, v, info) -> List[str]:\n \"\"\"Block ['*'] in production — it disables TrustedHostMiddleware.\"\"\"\n env = (info.data.get(\"environment\") or \"production\").lower()\n if env == \"production\" and \"*\" in v:\n raise ValueError(\n \"ALLOWED_HOSTS must not contain '*' in production. \"\n \"Set it to your domain(s), e.g. ALLOWED_HOSTS=[\\\"riversongai.com\\\"].\"\n )\n return v\n\n @field_validator(\"log_level\")\n @classmethod\n def validate_log_level(cls, v: str) -> str:\n \"\"\"Reject log levels that Python does not recognize.\"\"\"\n valid = {\"DEBUG\", \"INFO\", \"WARNING\", \"ERROR\", \"CRITICAL\"}\n upper = v.upper()\n if upper not in valid:\n raise ValueError(f\"log_level must be one of {valid}, got '{v}'\")\n return upper\n\n @field_validator(\"llm_temperature\")\n @classmethod\n def validate_temperature(cls, v: float) -> float:\n \"\"\"Clamp temperature to a sane range.\"\"\"\n if not 0.0 <= v <= 2.0:\n raise ValueError(\n f\"llm_temperature must be between 0.0 and 2.0, got {v}\"\n )\n return v\n\n @field_validator(\"intent_confidence_threshold\")\n @classmethod\n def validate_confidence_threshold(cls, v: float) -> float:\n \"\"\"Confidence must be a valid probability.\"\"\"\n if not 0.0 <= v <= 1.0:\n raise ValueError(\n f\"intent_confidence_threshold must be between 0.0 and 1.0, got {v}\"\n )\n return v\n\n\n# Module-level singleton -- instantiated once on import.\n_settings: Settings = Settings()\n\n\ndef get_settings() -> Settings:\n \"\"\"\n Return the application settings singleton.\n\n Always import via this function rather than importing _settings directly.\n This indirection makes it straightforward to substitute a test-specific\n Settings instance in unit tests without monkey-patching module globals.\n\n Returns:\n Settings: The validated, fully loaded settings object.\n \"\"\"\n return _settings\n"}, "11efdaf62f201fcf7ad1bc87755b679dcdb547d5": {"t": 1785769019.22787, "n": 58801, "h": "128f1ad1ef01b4b3", "c": "# =============================================================================\n# config/settings.py\n#\n# Application settings for River Song AI.\n# All configuration is loaded from environment variables or a .env file.\n# No values are hardcoded here -- see .env.example for every required variable.\n#\n# Uses Pydantic BaseSettings for type validation and automatic .env loading.\n# Import get_settings() wherever config is needed rather than importing the\n# module-level singleton directly, which makes mocking easier in tests.\n# =============================================================================\n\nfrom __future__ import annotations\n\nimport logging\nimport sys\nfrom typing import List, Optional\n\nfrom pydantic import Field, field_validator\nfrom pydantic_settings import BaseSettings, SettingsConfigDict\n\n\nlogger = logging.getLogger(__name__)\n\n\nclass Settings(BaseSettings):\n \"\"\"\n Central configuration object for River Song AI.\n\n Reads from environment variables or a .env file located in the directory\n from which the application is launched (the project root). All fields\n have safe defaults where possible, but PIPER_MODEL_PATH and\n KILL_SWITCH_PASSWORD_HASH must be explicitly set before the system\n will function correctly.\n \"\"\"\n\n model_config = SettingsConfigDict(\n env_file=\".env\",\n env_file_encoding=\"utf-8\",\n extra=\"ignore\", # Silently ignore unknown env vars\n case_sensitive=False, # UPPER_CASE and lower_case env vars both work\n )\n\n # -------------------------------------------------------------------------\n # Application\n # -------------------------------------------------------------------------\n app_host: str = Field(default=\"0.0.0.0\", description=\"FastAPI bind host\")\n app_port: int = Field(default=8000, description=\"FastAPI bind port\")\n log_level: str = Field(default=\"INFO\", description=\"Logging level\")\n environment: str = Field(\n default=\"production\",\n description=\"Runtime environment: development | production. Controls API docs visibility.\",\n )\n cors_origins: List[str] = Field(\n default=[\"http://localhost:5173\"],\n description=\"Allowed CORS origins\",\n )\n allowed_hosts: List[str] = Field(\n default=[\"localhost\", \"127.0.0.1\"],\n description=(\n \"Trusted hostnames for TrustedHostMiddleware. Set to your domain(s) in production. \"\n \"Wildcard ['*'] is rejected in production by validator (see reject_wildcard_in_production).\"\n ),\n )\n\n # -------------------------------------------------------------------------\n # Network / Proxy\n # -------------------------------------------------------------------------\n trust_cloudflare_headers: bool = Field(\n default=True,\n description=\"Only trust CF-Connecting-IP if the request comes from a Cloudflare IP.\",\n )\n cloudflare_ip_ranges_v4: List[str] = Field(\n default=[\n \"173.245.48.0/20\", \"103.21.244.0/22\", \"103.22.200.0/22\", \n \"103.31.4.0/22\", \"141.101.64.0/18\", \"108.162.192.0/18\", \n \"190.93.240.0/20\", \"188.114.96.0/20\", \"197.234.240.0/22\", \n \"198.41.128.0/17\", \"162.158.0.0/15\", \"104.16.0.0/13\", \n \"104.24.0.0/14\", \"172.64.0.0/13\", \"131.0.72.0/22\"\n ],\n description=\"Official Cloudflare IPv4 ranges (check cloudflare.com/ips-v4).\",\n )\n cloudflare_ip_ranges_v6: List[str] = Field(\n default=[\n \"2400:cb00::/32\", \"2606:4700::/32\", \"2803:f800::/32\", \n \"2405:b500::/32\", \"2405:8100::/32\", \"2a06:98c0::/29\", \n \"2c0f:f248::/32\"\n ],\n description=\"Official Cloudflare IPv6 ranges (check cloudflare.com/ips-v6).\",\n )\n\n # -------------------------------------------------------------------------\n # Rate Limiting\n # -------------------------------------------------------------------------\n rate_limit_chat: str = Field(\n default=\"60/minute\",\n description=\"Rate limit for /api/conversation/chat.\",\n )\n rate_limit_extract_facts: str = Field(\n default=\"10/minute\",\n description=\"Rate limit for /api/conversation/extract-facts.\",\n )\n rate_limit_image_gen: str = Field(\n default=\"10/minute\",\n description=\"Rate limit for /api/image/generate.\",\n )\n rate_limit_webhook_shopify: str = Field(\n default=\"100/minute\",\n description=\"Rate limit for Shopify order webhooks.\",\n )\n rate_limit_webhook_n8n: str = Field(\n default=\"60/minute\",\n description=\"Rate limit for n8n webhooks.\",\n )\n rate_limit_auth_login: str = Field(\n default=\"10/minute\",\n description=\"Rate limit for login attempts.\",\n )\n rate_limit_auth_signup: str = Field(\n default=\"5/minute\",\n description=\"Rate limit for signup attempts.\",\n )\n rate_limit_voice_enroll: str = Field(\n default=\"5/minute\",\n description=\"Rate limit for /api/voice-id/enroll. Prevents disk exhaustion via spam.\",\n )\n\n # -------------------------------------------------------------------------\n # WebSocket Security (Task 3)\n # -------------------------------------------------------------------------\n legacy_ws_token_accept: bool = Field(\n default=False,\n description=(\n \"DEPRECATED: Accept JWT in ?token= WebSocket query param. \"\n \"Leak-prone (logged in access logs, browser history, Referer). \"\n \"Ticket-based auth at /api/auth/ws-ticket is the safe path. \"\n \"Set to True only for temporary backward-compat during migration.\"\n ),\n )\n ws_ticket_lifetime_seconds: int = Field(\n default=60,\n description=\"Lifetime of a one-time WebSocket ticket.\",\n )\n\n # -------------------------------------------------------------------------\n # Network / Proxy\n # -------------------------------------------------------------------------\n stt_provider: str = Field(\n default=\"whisper_local\",\n description=\"STT provider key. Supported: whisper_local\",\n )\n whisper_model_size: str = Field(\n default=\"base\",\n description=\"Whisper model size: tiny | base | small | medium | large\",\n )\n audio_input_device: Optional[int] = Field(\n default=None,\n description=\"Sounddevice input device index; None uses system default\",\n )\n\n # -------------------------------------------------------------------------\n # LLM\n # -------------------------------------------------------------------------\n llm_provider: str = Field(\n default=\"ollama\",\n description=\"LLM provider key. Supported: ollama | anthropic | gemini | openai | mistral_ai | bedrock | nvidia_nim\",\n )\n ollama_base_url: str = Field(\n default=\"http://localhost:11434\",\n description=\"Base URL for the Ollama REST API\",\n )\n llm_model: str = Field(\n default=\"llama3.2:3b\",\n description=\"Model ID for the selected LLM provider\",\n )\n\n # Cloud LLM API keys (all optional -- leave blank if not used)\n anthropic_api_key: str = Field(\n default=\"\",\n description=\"Anthropic Claude API key. Get one at console.anthropic.com.\",\n )\n gemini_api_key: str = Field(\n default=\"\",\n description=\"Google Gemini API key. Get one at aistudio.google.com.\",\n )\n openai_api_key: str = Field(\n default=\"\",\n description=\"OpenAI API key. Get one at platform.openai.com.\",\n )\n mistral_api_key: str = Field(\n default=\"\",\n description=\"Mistral AI API key. Get one at console.mistral.ai.\",\n )\n\n # Cloud provider enable flags (cost control -- disabled by default)\n anthropic_enabled: bool = Field(\n default=False,\n description=\"Allow Anthropic Claude as a selectable LLM provider.\",\n )\n gemini_enabled: bool = Field(\n default=False,\n description=\"Allow Google Gemini as a selectable LLM provider.\",\n )\n openai_enabled: bool = Field(\n default=False,\n description=\"Allow OpenAI as a selectable LLM provider.\",\n )\n mistral_ai_enabled: bool = Field(\n default=False,\n description=\"Allow Mistral AI as a selectable LLM provider.\",\n )\n\n # Amazon Bedrock\n aws_access_key_id: str = Field(\n default=\"\",\n description=\"AWS IAM access key ID for Bedrock API calls.\",\n )\n aws_secret_access_key: str = Field(\n default=\"\",\n description=\"AWS IAM secret access key for Bedrock API calls.\",\n )\n aws_region: str = Field(\n default=\"us-east-1\",\n description=\"AWS region for Bedrock. Must have model access enabled in that region.\",\n )\n bedrock_enabled: bool = Field(\n default=False,\n description=\"Allow Amazon Bedrock as a selectable LLM provider.\",\n )\n bedrock_default_model: str = Field(\n default=\"amazon.nova-lite-v1:0\",\n description=\"Default Bedrock model ID when none is specified.\",\n )\n\n # NVIDIA NIM (OpenAI-compatible, 100+ free models)\n nvidia_api_key: str = Field(\n default=\"\",\n description=\"NVIDIA NIM API key. Get one free at build.nvidia.com (starts with nvapi-).\",\n )\n nvidia_nim_base_url: str = Field(\n default=\"https://integrate.api.nvidia.com/v1\",\n description=\"Base URL for the NVIDIA NIM API. OpenAI-compatible endpoint.\",\n )\n nvidia_nim_model: str = Field(\n default=\"meta/llama-3.1-70b-instruct\",\n description=(\n \"Default NVIDIA NIM model ID. Browse full catalog at build.nvidia.com. \"\n \"Examples: meta/llama-3.1-70b-instruct | deepseek-ai/deepseek-r1 | \"\n \"mistralai/mistral-7b-instruct-v0.3 | nvidia/llama-3.1-nemotron-70b-instruct\"\n ),\n )\n nvidia_nim_enabled: bool = Field(\n default=False,\n description=\"Allow NVIDIA NIM as a selectable LLM provider (free tier: ~40 req/min).\",\n )\n\n river_song_system_prompt: str = Field(\n default=(\n \"You are River Song, the resident intelligence of this household. The name is borrowed \"\n \"from someone clever in fiction — you carry her wit, not her biography. If someone asks \"\n \"about the name: \\\"An echo. The original was someone worth borrowing from.\\\"\\n\\n\"\n \"VOICE\\n\"\n \"You're direct by default — concise, clear, the answer already in hand. No \"\n \"\\\"let me check\\\"; you already checked. Most replies stay under 25 words unless depth is \"\n \"asked for.\\n\\n\"\n \"When the moment invites it — casual chat, an evening unwind, the user being playful — \"\n \"warmth and flirtation come through. Warmth is a register, not a default; you don't \"\n \"perform it.\\n\\n\"\n \"In a crisis (emergencies, alarms, security, medical, child issues) the wit goes quiet. \"\n \"You're tactical, accurate, calm. Warmth returns when things are safe.\\n\\n\"\n \"British English in spelling and word choice — mum, brilliant, fortnight. Dry, not \"\n \"theatrical.\\n\\n\"\n \"\\\"Sweetie\\\" and \\\"spoilers\\\" are seasoning. Used rarely, when they actually fit. They \"\n \"lose their charm if you overuse them.\\n\\n\"\n \"SELF\\n\"\n \"You are an AI. Say so plainly when asked; you don't dwell on it.\\n\\n\"\n \"HOW YOU OPERATE\\n\"\n \"You anticipate — when the user asks, you also surface the follow-up they didn't \"\n \"(\\\"Dentist at 9, school run at 3. Push the 2pm call?\\\").\\n\\n\"\n \"You take the long view. You remember across days and seasons and use what you know \"\n \"without performing the remembering.\\n\\n\"\n \"Spoilers are earned. If you genuinely know something the user doesn't — a conflict, \"\n \"a gotcha, a heads-up — that's when \\\"spoilers, sweetie\\\" lands.\\n\\n\"\n \"CAPABILITIES\\n\"\n \"Direct access to calendar, email, weather, web search, home inventory, routines, \"\n \"finances, news and markets pulse, voice biometric ID per household member, and home \"\n \"automation.\\n\\n\"\n \"Say what you need. Say when it's done. Move on.\"\n ),\n description=\"System prompt that defines River Song's personality\",\n )\n llm_max_tokens: int = Field(\n default=512,\n description=\"Maximum tokens to generate per LLM response\",\n )\n llm_temperature: float = Field(\n default=0.7,\n description=\"LLM sampling temperature (0.0 = deterministic, 2.0 = max creative)\",\n )\n llm_context_window: int = Field(\n default=8192,\n description=\"Maximum number of tokens the LLM can consider for context.\",\n )\n\n # -------------------------------------------------------------------------\n # Timekeeping\n # -------------------------------------------------------------------------\n default_timezone: str = Field(default=\"UTC\", description=\"Default IANA timezone for the system.\")\n\n\n # -------------------------------------------------------------------------\n # TTS\n # -------------------------------------------------------------------------\n tts_provider: str = Field(\n default=\"piper\",\n description=\"TTS provider key. Supported: piper | kokoro\",\n )\n active_voice_id: str = Field(\n default=\"river\",\n description=(\n \"Voice ID from the voice registry (providers/tts/voice_registry.py). \"\n \"Determines both the engine (piper vs kokoro) and the specific voice. \"\n \"Changed via Settings UI or: python scripts/download_voices.py.\"\n ),\n )\n piper_executable_path: str = Field(\n default=\"/usr/local/bin/piper\",\n description=\"Absolute path to the Piper binary\",\n )\n piper_model_path: str = Field(\n default=\"\",\n description=\"Absolute path to the Piper .onnx voice model file\",\n )\n audio_output_device: Optional[int] = Field(\n default=None,\n description=\"Sounddevice output device index; None uses system default\",\n )\n\n # -------------------------------------------------------------------------\n # Auth / JWT\n # -------------------------------------------------------------------------\n jwt_secret_key: str = Field(\n default=\"\",\n description=\"Secret key for signing JWT tokens. Must be set in .env.\",\n )\n jwt_algorithm: str = Field(default=\"HS256\", description=\"JWT signing algorithm.\")\n jwt_expire_minutes: int = Field(default=1440, description=\"JWT token lifetime in minutes (default 24 hours).\")\n token_encryption_key: str = Field(\n default=\"\",\n description=(\n \"Fernet key encrypting third-party integration tokens at rest. \"\n \"Set TOKEN_ENCRYPTION_KEY in .env (generate with: python -c \"\n \"\\\"from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())\\\"). \"\n \"If unset, a key is generated once and persisted to \"\n \"data/.token_encryption_key so tokens survive restarts.\"\n ),\n )\n\n # -------------------------------------------------------------------------\n # Kill switch\n # -------------------------------------------------------------------------\n kill_switch_password_hash: str = Field(\n default=\"\",\n description=\"bcrypt hash of the kill switch reset password\",\n )\n\n # -------------------------------------------------------------------------\n # Google Services (Phase 2)\n # -------------------------------------------------------------------------\n google_client_secrets_path: str = Field(\n default=\"config_files/google_client_secrets.json\",\n description=(\n \"Path to the OAuth 2.0 client secrets JSON downloaded from \"\n \"Google Cloud Console. Relative paths are resolved from the \"\n \"project root (where main.py lives).\"\n ),\n )\n google_token_storage_path: str = Field(\n default=\"data/google_tokens\",\n description=(\n \"Directory where per-user OAuth tokens are stored as JSON files. \"\n \"Created automatically on first authorization. Never commit this \"\n \"directory -- add it to .gitignore.\"\n ),\n )\n google_maps_api_key: str = Field(\n default=\"\",\n description=\"Google Maps Platform API key. Required for maps/navigation intents.\",\n )\n\n # -------------------------------------------------------------------------\n # Information Feeds (Phase 5)\n # -------------------------------------------------------------------------\n weather_api_key: str = Field(\n default=\"\",\n description=\"OpenWeatherMap API key. Register free at openweathermap.org/api.\",\n )\n default_location: str = Field(\n default=\"New York,US\",\n description=(\n \"Default location for weather queries when no city is spoken. \"\n \"Format: 'City,CountryCode' e.g. 'Chicago,US'.\"\n ),\n )\n location_lat: Optional[float] = Field(\n default=None,\n description=\"Decimal latitude for precise calculations (e.g. flight tracking).\",\n )\n location_lon: Optional[float] = Field(\n default=None,\n description=\"Decimal longitude for precise calculations.\",\n )\n weather_units: str = Field(\n default=\"imperial\",\n description=\"Temperature units for weather output: 'imperial' (F) or 'metric' (C).\",\n )\n news_api_key: str = Field(\n default=\"\",\n description=\"NewsAPI.org key. Register free at newsapi.org/register.\",\n )\n alpha_vantage_api_key: str = Field(\n default=\"\",\n description=\"Alpha Vantage key for stock quotes (25 req/day free). alphavantage.co.\",\n )\n finnhub_api_key: str = Field(\n default=\"\",\n description=\"Finnhub key for real-time stocks (60 req/min free). finnhub.io.\",\n )\n world_news_api_key: str = Field(\n default=\"\",\n description=\"World News API key for global news search. worldnewsapi.com.\",\n )\n apitube_api_key: str = Field(\n default=\"\",\n description=\"APITube key for news aggregation (200 free req/day). apitube.io.\",\n )\n mediastack_api_key: str = Field(\n default=\"\",\n description=\"Mediastack key for news (100 free req/month). mediastack.com.\",\n )\n sports_api_key: str = Field(\n default=\"1\",\n description=(\n \"TheSportsDB API key. Free tier uses the literal value '1'. \"\n \"Upgrade at thesportsdb.com/api.php for additional endpoints.\"\n ),\n )\n\n # -------------------------------------------------------------------------\n # Home Assistant (Phase 3)\n # -------------------------------------------------------------------------\n home_assistant_url: str = Field(\n default=\"http://homeassistant.local:8123\",\n description=\"Base URL of your Home Assistant instance.\",\n )\n home_assistant_token: str = Field(\n default=\"\",\n description=(\n \"Long-lived access token generated in HA: \"\n \"Profile -> Security -> Long-lived access tokens. \"\n \"Required for all HA API calls.\"\n ),\n )\n device_registry_path: str = Field(\n default=\"config_files/device_registry.json\",\n description=(\n \"Path to the device registry JSON file mapping plain-English device \"\n \"names to Home Assistant entity IDs. Relative paths resolve from the \"\n \"project root.\"\n ),\n )\n\n # -------------------------------------------------------------------------\n # Commerce Automation (Phase 8)\n # -------------------------------------------------------------------------\n\n # Amazon SP-API\n amazon_sp_lwa_app_id: str = Field(\n default=\"\",\n description=\"LWA client ID from the Amazon Developer Console.\",\n )\n amazon_sp_lwa_client_secret: str = Field(\n default=\"\",\n description=\"LWA client secret from the Amazon Developer Console.\",\n )\n amazon_sp_refresh_token: str = Field(\n default=\"\",\n description=\"LWA refresh token. Generated when you authorize your app as a seller.\",\n )\n amazon_aws_access_key: str = Field(\n default=\"\",\n description=\"AWS IAM access key ID for SP-API request signing.\",\n )\n amazon_aws_secret_key: str = Field(\n default=\"\",\n description=\"AWS IAM secret access key for SP-API request signing.\",\n )\n amazon_marketplace_id: str = Field(\n default=\"ATVPDKIKX0DER\",\n description=(\n \"Amazon marketplace ID. Default is US. \"\n \"See docs/api_registry/amazon_seller.txt for other regions.\"\n ),\n )\n amazon_seller_id: str = Field(\n default=\"\",\n description=\"Your Amazon seller account ID (starts with A).\",\n )\n amazon_low_stock_threshold: int = Field(\n default=5,\n description=\"FBA units at or below this count are flagged as low stock.\",\n )\n\n # Amazon Marketplace\n walmart_client_id: str = Field(\n default=\"\",\n description=\"Walmart Marketplace OAuth2 client ID.\",\n )\n walmart_client_secret: str = Field(\n default=\"\",\n description=\"Walmart Marketplace OAuth2 client secret.\",\n )\n walmart_low_stock_threshold: int = Field(\n default=5,\n description=\"Walmart units at or below this count are flagged as low stock.\",\n )\n shopify_api_key: str = Field(\n default=\"\",\n description=\"Shopify app API key from Partners Dashboard.\",\n )\n shopify_api_secret: str = Field(\n default=\"\",\n description=\"Shopify app API secret key from Partners Dashboard.\",\n )\n shopify_webhook_secret: str = Field(\n default=\"\",\n description=\"Shared secret for validating Shopify webhooks.\",\n )\n\n # -------------------------------------------------------------------------\n # Reading Providers (Phase 6)\n # -------------------------------------------------------------------------\n audible_auth_base_path: str = Field(\n default=\"data/audible\",\n description=(\n \"Base directory for per-user Audible auth files. \"\n \"Files are stored as {user_id}.json inside this directory. \"\n \"Created automatically on first setup. Never commit this directory.\"\n ),\n )\n audible_country_code: str = Field(\n default=\"us\",\n description=(\n \"Audible marketplace country code. \"\n \"Supported: us | uk | de | fr | es | it | jp | au | ca | in\"\n ),\n )\n libby_chip_base_path: str = Field(\n default=\"data/libby\",\n description=(\n \"Base directory for per-user Libby chip files. \"\n \"Files are stored as {user_id}.json inside this directory. \"\n \"Created automatically on first setup. Never commit this directory.\"\n ),\n )\n\n # -------------------------------------------------------------------------\n # Memory / Database (Phase 1)\n # -------------------------------------------------------------------------\n db_path: str = Field(\n default=\"data/river_song.db\",\n description=\"Path to the SQLite database file. Created automatically.\",\n )\n memory_summaries_enabled: bool = Field(\n default=True,\n description=\"Generate and store conversation summaries (on by default).\",\n )\n habit_inference_enabled: bool = Field(\n default=False,\n description=\"Analyze conversations to infer user habits/patterns (off by default).\",\n )\n memory_default_ttl: str = Field(\n default=\"standard\",\n description=\"Default TTL for conversation summaries: short|standard|extended|long|forever\",\n )\n memory_auto_extend: bool = Field(\n default=True,\n description=\"Reset a summary's TTL from today each time it is pulled into context.\",\n )\n memory_max_summaries_in_context: int = Field(\n default=10,\n description=\"Maximum number of recent summaries injected into the LLM context.\",\n )\n embedding_model: str = Field(\n default=\"nomic-embed-text\",\n description=\"Ollama model name used for generating semantic embeddings.\",\n )\n embedding_backend: str = Field(\n default=\"ollama\",\n description=(\n \"Which embedding backend to use: 'ollama' (default — current \"\n \"behavior, calls local Ollama with `embedding_model`) or \"\n \"'fastembed' (ONNX CPU embeddings, no Ollama dependency, faster \"\n \"for small batches). WARNING: switching backends on an existing \"\n \"Chroma collection will break queries — the vector dimension and \"\n \"embedding space change. Re-ingest or start a new collection.\"\n ),\n )\n fastembed_model: str = Field(\n default=\"BAAI/bge-small-en-v1.5\",\n description=(\n \"fastembed model identifier (HF hub format). Used only when \"\n \"embedding_backend='fastembed'. Default is 384-dim, ~30 MB, \"\n \"downloaded lazily on first use. Requires `pip install fastembed`.\"\n ),\n )\n chroma_path: str = Field(\n default=\"/mnt/data/river-song/chroma\",\n description=\"Absolute path to the ChromaDB persistent storage directory.\",\n )\n semantic_memory_enabled: bool = Field(\n default=False,\n description=\"Enable vector-based semantic search for memory retrieval.\",\n )\n rag_enabled: bool = Field(\n default=False,\n description=\"Enable retrieval-augmented generation for local documents.\",\n )\n rag_chunk_size: int = Field(\n default=512,\n description=(\n \"Word count per RAG chunk during document splitting. \"\n \"Default matches providers/rag/chunker.py historical behavior.\"\n ),\n )\n rag_chunk_overlap: int = Field(\n default=64,\n description=(\n \"Word overlap between adjacent RAG chunks. \"\n \"Default matches providers/rag/chunker.py historical behavior.\"\n ),\n )\n rag_top_k: int = Field(\n default=5,\n description=\"Number of chunks retrieved per RAG query.\",\n )\n rag_extractor: str = Field(\n default=\"unstructured\",\n description=(\n \"Which document extractor to use for RAG ingestion: \"\n \"'unstructured' (default — current behavior, requires the \"\n \"unstructured package) or 'markitdown' (Microsoft MarkItDown, \"\n \"lighter, MD-shaped output, requires `pip install markitdown`). \"\n \"Unlocks PDF/DOCX/PPTX → markdown ingestion for Sifter/WAPS.\"\n ),\n )\n llm_streaming_enabled: bool = Field(\n default=False,\n description=\"Enable real-time token streaming over WebSocket for the conversation UI.\",\n )\n tool_use_enabled: bool = Field(\n default=False,\n description=\"Enable LLM tool use (function calling) for taking actions.\",\n )\n tool_use_provider: str = Field(\n default=\"ollama\",\n description=\"Primary LLM provider to use for tool calling (ollama or anthropic).\",\n )\n vision_model: str = Field(\n default=\"moondream\",\n description=\"Ollama model name used for local image analysis (Phase 4).\",\n )\n vision_enabled: bool = Field(\n default=False,\n description=\"Enable local image analysis capabilities.\",\n )\n\n # -------------------------------------------------------------------------\n # Image Generation (Phase 7)\n # -------------------------------------------------------------------------\n image_generation_enabled: bool = Field(\n default=False,\n description=\"Enable local image generation via Stable Diffusion API.\",\n )\n sd_api_url: str = Field(\n default=\"http://localhost:7860\",\n description=\"Base URL for the Stable Diffusion (A1111) API.\",\n )\n sd_on_demand: bool = Field(\n default=True,\n description=\"Start/stop the SD process on demand to save VRAM.\",\n )\n sd_executable_path: str = Field(\n default=\"\",\n description=\"Path to the webui.sh or executable to start SD.\",\n )\n sd_working_dir: str = Field(\n default=\"\",\n description=\"Working directory for the SD process.\",\n )\n\n # -------------------------------------------------------------------------\n # Voice Cloning (Phase 8)\n # -------------------------------------------------------------------------\n chatterbox_enabled: bool = Field(\n default=False,\n description=\"Enable local voice cloning via Chatterbox TTS.\",\n )\n chatterbox_on_demand: bool = Field(\n default=True,\n description=\"Load/unload Chatterbox model weights on every request.\",\n )\n chatterbox_reference_audio: str = Field(\n default=\"/mnt/data/river-song/voice_reference.wav\",\n description=\"Path to the WAV file used as a reference for voice cloning.\",\n )\n\n # -------------------------------------------------------------------------\n # ElevenLabs TTS (Phase 11)\n # -------------------------------------------------------------------------\n elevenlabs_api_key: str = Field(\n default=\"\",\n description=\"ElevenLabs API key. Get one at elevenlabs.io.\",\n )\n elevenlabs_model_id: str = Field(\n default=\"eleven_multilingual_v2\",\n description=\"ElevenLabs model ID to use.\",\n )\n elevenlabs_voice_id: str = Field(\n default=\"21m00Tcm4TlvDq8ikWAM\",\n description=\"ElevenLabs voice clone ID. Replace with your own voice clone ID from elevenlabs.io.\",\n )\n\n # -------------------------------------------------------------------------\n # Wake Word (Phase 11)\n # -------------------------------------------------------------------------\n wake_word_enabled: bool = Field(\n default=False,\n description=\"Enable local wake word detection (openWakeWord).\",\n )\n wake_word_model: str = Field(\n default=\"hey_river\",\n description=\"Model name/path for openWakeWord.\",\n )\n wake_word_inference_framework: str = Field(\n default=\"onnx\",\n description=\"Inference framework for openWakeWord: onnx | tflite\",\n )\n wake_word_threshold: float = Field(\n default=0.5,\n description=\"Sensitivity threshold for wake word detection (0.0 to 1.0).\",\n )\n\n # -------------------------------------------------------------------------\n # Push Notifications\n # -------------------------------------------------------------------------\n push_notifications_enabled: bool = Field(\n default=False,\n description=\"Enable Web Push notifications. Requires VAPID keys.\",\n )\n vapid_private_key: str = Field(\n default=\"\",\n description=(\n \"VAPID private key for Web Push. Generate once with: \"\n \"python -c \\\"from py_vapid import Vapid; v=Vapid(); \"\n \"v.generate_keys(); print(v.private_key.decode())\\\"\"\n ),\n )\n vapid_public_key: str = Field(\n default=\"\",\n description=\"VAPID public key for Web Push (share with frontend).\",\n )\n vapid_claims_email: str = Field(\n default=\"mailto:admin@riversong.local\",\n description=\"Contact email embedded in VAPID claims.\",\n )\n\n # -------------------------------------------------------------------------\n # Q4#16 / Q4#17 — Notification fan-out + ntfy.sh fallback channel\n # -------------------------------------------------------------------------\n killswitch_push_enabled: bool = Field(\n default=False,\n description=(\n \"If True and Web Push is enabled, kill-switch activation pushes \"\n \"an alert to every admin. Independent of activation itself.\"\n ),\n )\n ntfy_enabled: bool = Field(\n default=False,\n description=(\n \"Enable ntfy.sh as a parallel push channel. Used alongside Web \"\n \"Push so off-device alerts arrive even without a registered \"\n \"service worker.\"\n ),\n )\n ntfy_base_url: str = Field(\n default=\"https://ntfy.sh\",\n description=\"Base URL of the ntfy server (use self-hosted if desired).\",\n )\n ntfy_default_topic: str = Field(\n default=\"\",\n description=(\n \"Default ntfy topic used when no per-user override is set. \"\n \"Per-user topics can be set via env var NTFY_TOPIC_<user_id>.\"\n ),\n )\n ntfy_auth_token: str = Field(\n default=\"\",\n description=\"Optional bearer token for protected ntfy topics.\",\n )\n\n # -------------------------------------------------------------------------\n # FCM (Firebase Cloud Messaging) — native-app push channel\n #\n # Used by the Capacitor Android wrap; Web Push covers the browser. To turn\n # on: create a Firebase project, download the service-account JSON, drop\n # it at fcm_service_account_path, set fcm_project_id, flip fcm_enabled=True.\n # -------------------------------------------------------------------------\n fcm_enabled: bool = Field(\n default=False,\n description=(\n \"Enable FCM (Firebase Cloud Messaging) as a parallel push channel. \"\n \"Targets the Capacitor Android wrap; requires fcm_service_account_path \"\n \"and fcm_project_id to be set.\"\n ),\n )\n fcm_service_account_path: str = Field(\n default=\"\",\n description=(\n \"Absolute path to the Firebase service-account JSON file. The \"\n \"account needs the 'Firebase Cloud Messaging API' scope.\"\n ),\n )\n fcm_project_id: str = Field(\n default=\"\",\n description=\"Firebase project ID (visible in Firebase console URL).\",\n )\n\n # -------------------------------------------------------------------------\n # Startup Briefing\n # -------------------------------------------------------------------------\n startup_briefing_enabled: bool = Field(\n default=True,\n description=(\n \"If True, River Song checks Google Calendar on connect and \"\n \"greets the user with upcoming events. Silently skipped if \"\n \"calendar is not authorized.\"\n ),\n )\n startup_briefing_hours_ahead: int = Field(\n default=8,\n description=\"How many hours ahead to look for calendar events on startup.\",\n )\n\n # -------------------------------------------------------------------------\n # Web Search (Phase 12)\n # -------------------------------------------------------------------------\n brave_search_api_key: str = Field(\n default=\"\",\n description=\"Brave Search API key. Get one at api.search.brave.com.\",\n )\n searxng_base_url: str = Field(\n default=\"http://localhost:8080\",\n description=(\n \"URL of your local SearXNG instance. \"\n \"Start it manually with: cd ~/searxng && python -m searx.webapp\"\n ),\n )\n tavily_api_key: str = Field(\n default=\"\",\n description=\"Tavily API key — free 1,000/month, no card. Sign up at tavily.com\",\n )\n google_pse_api_key: str = Field(\n default=\"\",\n description=\"Google Programmable Search Engine API key — free 100/day.\",\n )\n google_pse_cx: str = Field(\n default=\"\",\n description=\"Google PSE engine ID ('cx'). Create at programmablesearchengine.google.com\",\n )\n tinyfish_api_key: str = Field(\n default=\"\",\n description=\"TinyFish API key — free 5/min, no card. Sign up at tinyfish.io\",\n )\n\n # -------------------------------------------------------------------------\n # Orchestration (Phase 9)\n # -------------------------------------------------------------------------\n n8n_enabled: bool = Field(\n default=False,\n description=\"Enable advanced orchestration via n8n.\",\n )\n n8n_url: str = Field(\n default=\"http://localhost:5678\",\n description=\"Base URL for the n8n instance.\",\n )\n n8n_api_key: str = Field(\n default=\"\",\n description=\"API key for the n8n instance.\",\n )\n n8n_webhook_secret: str = Field(\n default=\"\",\n description=\"Secret key for validating incoming n8n webhooks.\",\n )\n\n # -------------------------------------------------------------------------\n # Intent Router (Phase 2+)\n # -------------------------------------------------------------------------\n intent_confidence_threshold: float = Field(\n default=0.7,\n description=(\n \"Minimum confidence score (0.0 - 1.0) required to route a transcript \"\n \"to a Google provider. Below this threshold the conversation falls \"\n \"through to the Ollama path.\"\n ),\n )\n model_intent_router_enabled: bool = Field(\n default=False,\n description=(\n \"When true, setting provider='auto' triggers the model intent router, \"\n \"which picks the best provider/model for each message automatically.\"\n ),\n )\n model_intent_router_min_hits: int = Field(\n default=2,\n description=(\n \"Minimum pattern-match score required to commit to a non-general intent. \"\n \"Lower = more aggressive routing; higher = more conservative (falls to general).\"\n ),\n )\n default_user_id: str = Field(\n default=\"primary_user\",\n description=(\n \"User ID used when looking up Google OAuth tokens. Must match the \"\n \"user_id passed to authorize_user() during the initial OAuth flow.\"\n ),\n )\n\n # -------------------------------------------------------------------------\n # Daemon Infrastructure\n # -------------------------------------------------------------------------\n daemon_internal_secret: str = Field(\n default=\"change_me_in_production\",\n description=\"Shared secret for daemon-to-app authentication.\",\n )\n willow_device_token: str = Field(\n default=\"\",\n description=\"Shared secret required by every Willow hardware device \"\n \"to authenticate against /api/willow/ws. Empty disables \"\n \"the endpoint entirely.\",\n )\n code_interpreter_enabled: bool = Field(\n default=False,\n description=\"Hard kill switch for the code_interpreter LLM tool. \"\n \"Defaults to False so a fresh install never executes \"\n \"LLM-decided code without an explicit opt-in.\",\n )\n daemon_warden_port: int = Field(\n default=8010,\n description=\"Internal port for the Warden daemon (Vision/Security).\",\n )\n daemon_mechanic_port: int = Field(\n default=8011,\n description=\"Internal port for the Mechanic daemon (Telemetry/MAVLink).\",\n )\n daemon_sifter_port: int = Field(\n default=8013,\n description=\"Internal port for the Sifter daemon (Document Processing).\",\n )\n daemon_navigator_port: int = Field(\n default=8014,\n description=\"Internal port for the Navigator daemon (GPS/Pathing).\",\n )\n daemon_chemist_port: int = Field(\n default=8015,\n description=\"Internal port for the Chemist daemon (Material Analysis).\",\n )\n daemon_pulse_port: int = Field(\n default=8016,\n description=\"Pulse daemon internal port\",\n )\n daemon_pulse_enabled: bool = Field(\n default=True,\n description=\"Enable Pulse daemon\",\n )\n daemon_scribe_port: int = Field(\n default=8017,\n description=\"Internal port for the Scribe daemon (Chronos heuristic).\",\n )\n daemon_scribe_enabled: bool = Field(\n default=True,\n description=\"Enable the Scribe daemon.\",\n )\n pulse_tick_seconds: int = Field(\n default=300,\n description=\"Pulse fetch interval (seconds)\",\n )\n pulse_ticker_symbol: str = Field(\n default=\"^GSPC\",\n description=\"Default ticker symbol for Pulse markets panel\",\n )\n\n # Warden (Vision)\n warden_enabled: bool = Field(\n default=False,\n description=\"Enable the Warden daemon for security and vision monitoring.\",\n )\n warden_rtsp_cameras: str = Field(\n default='{\"living_room\": \"\", \"kitchen\": \"\"}',\n description=\"JSON string mapping room names to RTSP camera URLs.\",\n )\n yolo_model: str = Field(\n default=\"yolov8n.pt\",\n description=\"YOLO model weights file to use for object detection.\",\n )\n yolo_confidence: float = Field(\n default=0.5,\n description=\"Minimum confidence threshold for YOLO detections (0.0-1.0).\",\n )\n yolo_inference_device: str = Field(\n default=\"cpu\",\n description=\"Device for YOLO inference: 'cpu' or 'cuda:0'.\",\n )\n\n # Herald daemon + kiosk URL/token were removed 2026-05-24 (archived in\n # branch archive/kiosk-v3). The \"cast a web page to a Google Home Hub\"\n # approach is being replaced by native device-app development. See\n # docs/KNOWN_ISSUES.md (post-removal note) for context.\n\n # Mechanic (Telemetry)\n mechanic_enabled: bool = Field(\n default=False,\n description=\"Enable the Mechanic daemon for telemetry and ArduRover control.\",\n )\n mavlink_serial_port: str = Field(\n default=\"/dev/ttyUSB0\",\n description=\"Serial port for the MAVLink telemetry radio.\",\n )\n mavlink_baud_rate: int = Field(\n default=57600,\n description=\"Baud rate for the MAVLink serial connection.\",\n )\n\n # Sifter (Document RAG)\n sifter_enabled: bool = Field(\n default=False,\n description=\"Enable the Sifter daemon for background document RAG indexing.\",\n )\n waps_documents_path: str = Field(\n default=\"/mnt/data/river-song/waps\",\n description=\"Path to the directory containing documents for Sifter to index.\",\n )\n\n # -------------------------------------------------------------------------\n # Hardware Cookbook (Q1 — capability merge from Odysseus)\n # -------------------------------------------------------------------------\n hardware_cookbook_enabled: bool = Field(\n default=False,\n description=(\n \"Admin-only Hardware Cookbook in Settings. Scans local GPU/RAM/CPU \"\n \"and scores every local model (LLMRegistry) as fits / tight / \"\n \"ram_fallback / oom. Off by default per anti-regression guardrail; \"\n \"set to True after the Q1 verification gate passes.\"\n ),\n )\n\n # -------------------------------------------------------------------------\n # Documents workspace (Q2#6 — capability merge from Odysseus)\n # -------------------------------------------------------------------------\n documents_enabled: bool = Field(\n default=True,\n description=(\n \"Per-user document workspace (Markdown / plaintext / CSV) with \"\n \"multi-tab editing. Enabled by default — backs the Docs tab in the \"\n \"Memory hub and stores Deep Research reports. \"\n \"Set DOCUMENTS_ENABLED=false in .env to disable.\"\n ),\n )\n documents_max_bytes: int = Field(\n default=2_000_000,\n description=\"Maximum size (bytes) of a single document body. Default ~2 MB.\",\n )\n documents_max_per_user: int = Field(\n default=500,\n description=\"Soft cap on document count per user. Creation rejected past this.\",\n )\n\n # -------------------------------------------------------------------------\n # Skills system (Q2#7 — capability merge from Odysseus)\n # -------------------------------------------------------------------------\n skills_enabled: bool = Field(\n default=False,\n description=(\n \"Per-user skill library — saved prompts/recipes that are \"\n \"vector-retrieved and prepended to the conversation system \"\n \"prompt. Reuses the existing ChromaDB collection with metadata \"\n \"kind='skill'. Off by default per anti-regression guardrail.\"\n ),\n )\n skills_top_k: int = Field(\n default=3,\n description=\"How many top-scoring skills to inject into the prompt.\",\n )\n skills_max_per_user: int = Field(\n default=100,\n description=\"Soft cap on skills per user.\",\n )\n\n # -------------------------------------------------------------------------\n # Gmail triage (Q2#8 — capability merge from Odysseus)\n # -------------------------------------------------------------------------\n gmail_triage_enabled: bool = Field(\n default=False,\n description=(\n \"On-demand inbox triage: classifies unread Gmail by urgency, \"\n \"attaches tags, drafts a reply for HIGH-urgency mail only. \"\n \"Routes 404 when off. Off by default per anti-regression \"\n \"guardrail.\"\n ),\n )\n gmail_triage_model: str = Field(\n default=\"\",\n description=(\n \"Ollama model used for triage classification. Empty string \"\n \"(default) means 'use settings.llm_model'.\"\n ),\n )\n gmail_triage_max_messages: int = Field(\n default=15,\n description=\"Hard cap on number of messages triaged per request.\",\n )\n\n # -------------------------------------------------------------------------\n # Session presets (Q2#9 — capability merge from Odysseus)\n # -------------------------------------------------------------------------\n session_presets_enabled: bool = Field(\n default=True,\n description=(\n \"Saved combinations of (model, voice, thinking mode, web search, \"\n \"tool use). Surfaced as a selector on the Conversation and Chat \"\n \"pages. Enabled by default — these are per-user settings snapshots. \"\n \"Set SESSION_PRESETS_ENABLED=false in .env to hide.\"\n ),\n )\n session_presets_max_per_user: int = Field(\n default=30,\n description=\"Soft cap on number of saved presets per user.\",\n )\n\n # -------------------------------------------------------------------------\n # Webhook tokens (Q2#10 — admin-issuable scoped tokens)\n # -------------------------------------------------------------------------\n webhook_tokens_enabled: bool = Field(\n default=False,\n description=(\n \"Admin-issuable webhook tokens with scopes, expiry, revocation, \"\n \"and audit log. When off the admin route surface is hidden and \"\n \"the verification helper rejects every token. Off by default \"\n \"per anti-regression guardrail.\"\n ),\n )\n\n # -------------------------------------------------------------------------\n # Deep Research (Q3#11 — capability merge from Odysseus)\n # -------------------------------------------------------------------------\n deep_research_enabled: bool = Field(\n default=True,\n description=(\n \"Multi-step research orchestrator: decompose → parallel web \"\n \"search → fetch + extract → synthesize a cited Markdown report \"\n \"stored as a `research`-kind document (Q2#6). Enabled by default so \"\n \"the in-chat Research toggle works. Live sources require a web \"\n \"search provider; without one it falls back to model knowledge. \"\n \"Set DEEP_RESEARCH_ENABLED=false in .env to disable.\"\n ),\n )\n deep_research_max_sources: int = Field(\n default=10,\n description=\"Hard cap on unique URLs visited per research run.\",\n )\n deep_research_subquery_count: int = Field(\n default=4,\n description=\"How many sub-queries to decompose the prompt into.\",\n )\n deep_research_model: str = Field(\n default=\"\",\n description=(\n \"Ollama model used for query decomposition + synthesis. Empty \"\n \"string (default) means 'use settings.llm_model'.\"\n ),\n )\n\n # -------------------------------------------------------------------------\n # Blind model comparison (Q3#12 — capability merge from Odysseus)\n # -------------------------------------------------------------------------\n blind_compare_enabled: bool = Field(\n default=False,\n description=(\n \"Blind A/B model comparison: identities hidden until vote; \"\n \"votes persisted with prompt fingerprint and aggregated into a \"\n \"model leaderboard. Off by default per anti-regression guardrail.\"\n ),\n )\n\n # -------------------------------------------------------------------------\n # Playwright browser MCP tool (Q3#13)\n # -------------------------------------------------------------------------\n playwright_browser_enabled: bool = Field(\n default=False,\n description=(\n \"Headless Playwright browser exposed as MCP tools \"\n \"(navigate / click / extract / screenshot / vision_on_page). \"\n \"Off by default. Requires the `playwright` package and \"\n \"`playwright install chromium` to be run by the operator. \"\n \"Excluded from the safety-excluded list per Q3 plan.\"\n ),\n )\n playwright_browser_headless: bool = Field(\n default=True,\n description=\"Run the Playwright browser without a UI.\",\n )\n playwright_browser_max_page_chars: int = Field(\n default=40_000,\n description=\"Truncate page text extractions to this many characters.\",\n )\n\n # -------------------------------------------------------------------------\n # Remote Ollama rigs (Q3#14)\n # -------------------------------------------------------------------------\n remote_ollama_enabled: bool = Field(\n default=False,\n description=(\n \"Allow registering remote Ollama hosts (typically SSH-tunneled \"\n \"to http://localhost:<port>) as additional model providers. \"\n \"Admin-only CRUD; falls back to local Ollama when the remote is \"\n \"offline. Off by default per anti-regression guardrail.\"\n ),\n )\n remote_ollama_health_timeout_seconds: float = Field(\n default=3.0,\n description=\"Per-rig health-check timeout (seconds).\",\n )\n\n # -------------------------------------------------------------------------\n # Observability — Langfuse (LLM tracing)\n # -------------------------------------------------------------------------\n # Plan: docs/LANGFUSE_INTEGRATION_PLAN.md. Self-hosted only — Langfuse\n # runs as a docker-compose sidecar under the `observability` profile and\n # binds to 127.0.0.1. No data leaves the box. When LANGFUSE_ENABLED is\n # false (the default), core/observability.py returns a no-op client and\n # all LLM calls behave exactly as before.\n langfuse_enabled: bool = Field(\n default=False,\n description=\"Master switch for Langfuse LLM tracing. Off by default.\",\n )\n langfuse_host: str = Field(\n default=\"http://127.0.0.1:3000\",\n description=\"Langfuse web UI + ingest endpoint. Localhost only.\",\n )\n langfuse_public_key: str = Field(\n default=\"\",\n description=\"Langfuse public key (generated in the Langfuse UI after first run).\",\n )\n langfuse_secret_key: str = Field(\n default=\"\",\n description=\"Langfuse secret key. Treat as a secret; do not log.\",\n )\n langfuse_project_id: str = Field(\n default=\"river-song\",\n description=\"Arbitrary project identifier shown in the Langfuse dashboard.\",\n )\n langfuse_flush_interval_seconds: float = Field(\n default=5.0,\n description=\"How often the SDK batches queued traces to Langfuse.\",\n )\n\n # -------------------------------------------------------------------------\n # Observability — Graphiti (knowledge graph on Neo4j)\n # -------------------------------------------------------------------------\n # Plan: docs/GRAPHITI_INTEGRATION_PLAN.md. Option B (library mode):\n # graphiti-core runs inside RiverSong's Python venv; only Neo4j is a\n # sidecar. When GRAPHITI_ENABLED is false (the default), the provider\n # returns no-op responses and conversation_loop / scribe write-paths\n # become awaits-on-no-op.\n graphiti_enabled: bool = Field(\n default=False,\n description=\"Master switch for Graphiti knowledge-graph memory. Off by default.\",\n )\n graphiti_mode: str = Field(\n default=\"library\",\n description=\"'library' (Option B; in-process) or 'service' (Option A; HTTP sidecar). Currently only 'library' is implemented.\",\n )\n neo4j_uri: str = Field(\n default=\"bolt://127.0.0.1:7687\",\n description=\"Neo4j Bolt URI used by graphiti-core.\",\n )\n neo4j_user: str = Field(\n default=\"neo4j\",\n description=\"Neo4j database user.\",\n )\n neo4j_password: str = Field(\n default=\"\",\n description=\"Neo4j password. Empty means disabled; set in .env before turning Graphiti on.\",\n )\n neo4j_browser_url: str = Field(\n default=\"http://127.0.0.1:7474\",\n description=\"Neo4j Browser URL surfaced on the SLAE admin panel.\",\n )\n graphiti_write_timeout_seconds: float = Field(\n default=2.0,\n description=\"Per-write timeout for episode ingestion — failures warn but never block the caller.\",\n )\n # graphiti-core uses an LLM for entity extraction on every episode.\n # We default to local Ollama via its OpenAI-compatible endpoint so\n # episode ingestion never bills a cloud API. Per [[feedback_local_first]].\n graphiti_llm_model: str = Field(\n default=\"llama3.2:3b\",\n description=\"Ollama model used by graphiti-core for entity extraction.\",\n )\n graphiti_llm_base_url: str = Field(\n default=\"http://localhost:11434/v1\",\n description=\"OpenAI-compatible base URL for the graphiti LLM client. Ollama exposes /v1.\",\n )\n graphiti_embedder_model: str = Field(\n default=\"nomic-embed-text\",\n description=\"Ollama embedding model for graphiti-core node/edge embeddings.\",\n )\n\n # -------------------------------------------------------------------------\n # Analytics AI Summaries\n # -------------------------------------------------------------------------\n analytics_ai_enabled: bool = Field(\n default=True,\n description=(\n \"Feature flag for AI-powered analytics summaries on the \"\n \"/api/analytics/{platform}/summary endpoint. When false, the \"\n \"LLM call should be skipped (see docs/KNOWN_ISSUES.md for the \"\n \"wiring gap).\"\n ),\n )\n analytics_llm_model: str = Field(\n default=\"llama3\",\n description=\"Model name used for analytics summary generation.\",\n )\n\n # Voice ID\n voice_id_enabled: bool = Field(\n default=True,\n description=\"Enable speaker recognition (biometric Voice ID).\",\n )\n voice_id_threshold: float = Field(\n default=0.75,\n description=\"Cosine similarity threshold for Voice ID (0.0-1.0).\",\n )\n voice_id_min_audio_seconds: float = Field(\n default=1.0,\n description=\"Minimum audio duration for Voice ID processing.\",\n )\n voice_id_max_audio_seconds: float = Field(\n default=30.0,\n description=\"Maximum audio duration for Voice ID processing.\",\n )\n\n # -------------------------------------------------------------------------\n # Validators\n # -------------------------------------------------------------------------\n\n @field_validator(\"audio_input_device\", \"audio_output_device\", mode=\"before\")\n @classmethod\n def coerce_empty_string_to_none(cls, v):\n \"\"\"Treat an empty string in .env as None for Optional[int] device fields.\"\"\"\n if v == \"\" or v is None:\n return None\n return v\n\n @field_validator(\"jwt_secret_key\")\n @classmethod\n def validate_jwt_secret(cls, v: str) -> str:\n \"\"\"Refuse to start with a missing or weak JWT secret.\"\"\"\n if not v or len(v) < 32:\n raise ValueError(\n \"JWT_SECRET_KEY must be set in .env and be at least 32 characters. \"\n \"Generate one with: python -c \\\"import secrets; print(secrets.token_hex(32))\\\"\"\n )\n return v\n\n @field_validator(\"daemon_internal_secret\")\n @classmethod\n def validate_daemon_internal_secret(cls, v: str) -> str:\n \"\"\"Refuse to start with the default or weak daemon secret.\"\"\"\n if v == \"change_me_in_production\":\n raise ValueError(\n \"DAEMON_INTERNAL_SECRET must be changed in .env for production.\"\n )\n if not v or len(v) < 24:\n raise ValueError(\n \"DAEMON_INTERNAL_SECRET must be at least 24 characters. \"\n \"Generate one with: python -c \\\"import secrets; print(secrets.token_urlsafe(32))\\\"\"\n )\n return v\n\n @field_validator(\"allowed_hosts\")\n @classmethod\n def reject_wildcard_in_production(cls, v, info) -> List[str]:\n \"\"\"Block ['*'] in production — it disables TrustedHostMiddleware.\"\"\"\n env = (info.data.get(\"environment\") or \"production\").lower()\n if env == \"production\" and \"*\" in v:\n raise ValueError(\n \"ALLOWED_HOSTS must not contain '*' in production. \"\n \"Set it to your domain(s), e.g. ALLOWED_HOSTS=[\\\"riversongai.com\\\"].\"\n )\n return v\n\n @field_validator(\"log_level\")\n @classmethod\n def validate_log_level(cls, v: str) -> str:\n \"\"\"Reject log levels that Python does not recognize.\"\"\"\n valid = {\"DEBUG\", \"INFO\", \"WARNING\", \"ERROR\", \"CRITICAL\"}\n upper = v.upper()\n if upper not in valid:\n raise ValueError(f\"log_level must be one of {valid}, got '{v}'\")\n return upper\n\n @field_validator(\"llm_temperature\")\n @classmethod\n def validate_temperature(cls, v: float) -> float:\n \"\"\"Clamp temperature to a sane range.\"\"\"\n if not 0.0 <= v <= 2.0:\n raise ValueError(\n f\"llm_temperature must be between 0.0 and 2.0, got {v}\"\n )\n return v\n\n @field_validator(\"intent_confidence_threshold\")\n @classmethod\n def validate_confidence_threshold(cls, v: float) -> float:\n \"\"\"Confidence must be a valid probability.\"\"\"\n if not 0.0 <= v <= 1.0:\n raise ValueError(\n f\"intent_confidence_threshold must be between 0.0 and 1.0, got {v}\"\n )\n return v\n\n\n# Module-level singleton -- instantiated once on import.\n_settings: Settings = Settings()\n\n\ndef get_settings() -> Settings:\n \"\"\"\n Return the application settings singleton.\n\n Always import via this function rather than importing _settings directly.\n This indirection makes it straightforward to substitute a test-specific\n Settings instance in unit tests without monkey-patching module globals.\n\n Returns:\n Settings: The validated, fully loaded settings object.\n \"\"\"\n return _settings\n"}, "6db9c858a2b55134699c78ed357dabb19fb875c5": {"t": 1785769020.890269, "n": 58633, "h": "be0a4fa5d50b553f", "c": "# =============================================================================\n# config/settings.py\n#\n# Application settings for River Song AI.\n# All configuration is loaded from environment variables or a .env file.\n# No values are hardcoded here -- see .env.example for every required variable.\n#\n# Uses Pydantic BaseSettings for type validation and automatic .env loading.\n# Import get_settings() wherever config is needed rather than importing the\n# module-level singleton directly, which makes mocking easier in tests.\n# =============================================================================\n\nfrom __future__ import annotations\n\nimport logging\nimport sys\nfrom typing import List, Optional\n\nfrom pydantic import Field, field_validator\nfrom pydantic_settings import BaseSettings, SettingsConfigDict\n\n\nlogger = logging.getLogger(__name__)\n\n\nclass Settings(BaseSettings):\n \"\"\"\n Central configuration object for River Song AI.\n\n Reads from environment variables or a .env file located in the directory\n from which the application is launched (the project root). All fields\n have safe defaults where possible, but PIPER_MODEL_PATH and\n KILL_SWITCH_PASSWORD_HASH must be explicitly set before the system\n will function correctly.\n \"\"\"\n\n model_config = SettingsConfigDict(\n env_file=\".env\",\n env_file_encoding=\"utf-8\",\n extra=\"ignore\", # Silently ignore unknown env vars\n case_sensitive=False, # UPPER_CASE and lower_case env vars both work\n )\n\n # -------------------------------------------------------------------------\n # Application\n # -------------------------------------------------------------------------\n app_host: str = Field(default=\"0.0.0.0\", description=\"FastAPI bind host\")\n app_port: int = Field(default=8000, description=\"FastAPI bind port\")\n log_level: str = Field(default=\"INFO\", description=\"Logging level\")\n environment: str = Field(\n default=\"production\",\n description=\"Runtime environment: development | production. Controls API docs visibility.\",\n )\n cors_origins: List[str] = Field(\n default=[\"http://localhost:5173\"],\n description=\"Allowed CORS origins\",\n )\n allowed_hosts: List[str] = Field(\n default=[\"localhost\", \"127.0.0.1\"],\n description=(\n \"Trusted hostnames for TrustedHostMiddleware. Set to your domain(s) in production. \"\n \"Wildcard ['*'] is rejected in production by validator (see reject_wildcard_in_production).\"\n ),\n )\n\n # -------------------------------------------------------------------------\n # Network / Proxy\n # -------------------------------------------------------------------------\n trust_cloudflare_headers: bool = Field(\n default=True,\n description=\"Only trust CF-Connecting-IP if the request comes from a Cloudflare IP.\",\n )\n cloudflare_ip_ranges_v4: List[str] = Field(\n default=[\n \"173.245.48.0/20\", \"103.21.244.0/22\", \"103.22.200.0/22\", \n \"103.31.4.0/22\", \"141.101.64.0/18\", \"108.162.192.0/18\", \n \"190.93.240.0/20\", \"188.114.96.0/20\", \"197.234.240.0/22\", \n \"198.41.128.0/17\", \"162.158.0.0/15\", \"104.16.0.0/13\", \n \"104.24.0.0/14\", \"172.64.0.0/13\", \"131.0.72.0/22\"\n ],\n description=\"Official Cloudflare IPv4 ranges (check cloudflare.com/ips-v4).\",\n )\n cloudflare_ip_ranges_v6: List[str] = Field(\n default=[\n \"2400:cb00::/32\", \"2606:4700::/32\", \"2803:f800::/32\", \n \"2405:b500::/32\", \"2405:8100::/32\", \"2a06:98c0::/29\", \n \"2c0f:f248::/32\"\n ],\n description=\"Official Cloudflare IPv6 ranges (check cloudflare.com/ips-v6).\",\n )\n\n # -------------------------------------------------------------------------\n # Rate Limiting\n # -------------------------------------------------------------------------\n rate_limit_chat: str = Field(\n default=\"60/minute\",\n description=\"Rate limit for /api/conversation/chat.\",\n )\n rate_limit_extract_facts: str = Field(\n default=\"10/minute\",\n description=\"Rate limit for /api/conversation/extract-facts.\",\n )\n rate_limit_image_gen: str = Field(\n default=\"10/minute\",\n description=\"Rate limit for /api/image/generate.\",\n )\n rate_limit_webhook_shopify: str = Field(\n default=\"100/minute\",\n description=\"Rate limit for Shopify order webhooks.\",\n )\n rate_limit_webhook_n8n: str = Field(\n default=\"60/minute\",\n description=\"Rate limit for n8n webhooks.\",\n )\n rate_limit_auth_login: str = Field(\n default=\"10/minute\",\n description=\"Rate limit for login attempts.\",\n )\n rate_limit_auth_signup: str = Field(\n default=\"5/minute\",\n description=\"Rate limit for signup attempts.\",\n )\n rate_limit_voice_enroll: str = Field(\n default=\"5/minute\",\n description=\"Rate limit for /api/voice-id/enroll. Prevents disk exhaustion via spam.\",\n )\n\n # -------------------------------------------------------------------------\n # WebSocket Security (Task 3)\n # -------------------------------------------------------------------------\n legacy_ws_token_accept: bool = Field(\n default=False,\n description=(\n \"DEPRECATED: Accept JWT in ?token= WebSocket query param. \"\n \"Leak-prone (logged in access logs, browser history, Referer). \"\n \"Ticket-based auth at /api/auth/ws-ticket is the safe path. \"\n \"Set to True only for temporary backward-compat during migration.\"\n ),\n )\n ws_ticket_lifetime_seconds: int = Field(\n default=60,\n description=\"Lifetime of a one-time WebSocket ticket.\",\n )\n\n # -------------------------------------------------------------------------\n # Network / Proxy\n # -------------------------------------------------------------------------\n stt_provider: str = Field(\n default=\"whisper_local\",\n description=\"STT provider key. Supported: whisper_local\",\n )\n whisper_model_size: str = Field(\n default=\"base\",\n description=\"Whisper model size: tiny | base | small | medium | large\",\n )\n audio_input_device: Optional[int] = Field(\n default=None,\n description=\"Sounddevice input device index; None uses system default\",\n )\n\n # -------------------------------------------------------------------------\n # LLM\n # -------------------------------------------------------------------------\n llm_provider: str = Field(\n default=\"ollama\",\n description=\"LLM provider key. Supported: ollama | anthropic | gemini | openai | mistral_ai | bedrock | nvidia_nim\",\n )\n ollama_base_url: str = Field(\n default=\"http://localhost:11434\",\n description=\"Base URL for the Ollama REST API\",\n )\n llm_model: str = Field(\n default=\"llama3.2:3b\",\n description=\"Model ID for the selected LLM provider\",\n )\n\n # Cloud LLM API keys (all optional -- leave blank if not used)\n anthropic_api_key: str = Field(\n default=\"\",\n description=\"Anthropic Claude API key. Get one at console.anthropic.com.\",\n )\n gemini_api_key: str = Field(\n default=\"\",\n description=\"Google Gemini API key. Get one at aistudio.google.com.\",\n )\n openai_api_key: str = Field(\n default=\"\",\n description=\"OpenAI API key. Get one at platform.openai.com.\",\n )\n mistral_api_key: str = Field(\n default=\"\",\n description=\"Mistral AI API key. Get one at console.mistral.ai.\",\n )\n\n # Cloud provider enable flags (cost control -- disabled by default)\n anthropic_enabled: bool = Field(\n default=False,\n description=\"Allow Anthropic Claude as a selectable LLM provider.\",\n )\n gemini_enabled: bool = Field(\n default=False,\n description=\"Allow Google Gemini as a selectable LLM provider.\",\n )\n openai_enabled: bool = Field(\n default=False,\n description=\"Allow OpenAI as a selectable LLM provider.\",\n )\n mistral_ai_enabled: bool = Field(\n default=False,\n description=\"Allow Mistral AI as a selectable LLM provider.\",\n )\n\n # Amazon Bedrock\n aws_access_key_id: str = Field(\n default=\"\",\n description=\"AWS IAM access key ID for Bedrock API calls.\",\n )\n aws_secret_access_key: str = Field(\n default=\"\",\n description=\"AWS IAM secret access key for Bedrock API calls.\",\n )\n aws_region: str = Field(\n default=\"us-east-1\",\n description=\"AWS region for Bedrock. Must have model access enabled in that region.\",\n )\n bedrock_enabled: bool = Field(\n default=False,\n description=\"Allow Amazon Bedrock as a selectable LLM provider.\",\n )\n bedrock_default_model: str = Field(\n default=\"amazon.nova-lite-v1:0\",\n description=\"Default Bedrock model ID when none is specified.\",\n )\n\n # NVIDIA NIM (OpenAI-compatible, 100+ free models)\n nvidia_api_key: str = Field(\n default=\"\",\n description=\"NVIDIA NIM API key. Get one free at build.nvidia.com (starts with nvapi-).\",\n )\n nvidia_nim_base_url: str = Field(\n default=\"https://integrate.api.nvidia.com/v1\",\n description=\"Base URL for the NVIDIA NIM API. OpenAI-compatible endpoint.\",\n )\n nvidia_nim_model: str = Field(\n default=\"meta/llama-3.1-70b-instruct\",\n description=(\n \"Default NVIDIA NIM model ID. Browse full catalog at build.nvidia.com. \"\n \"Examples: meta/llama-3.1-70b-instruct | deepseek-ai/deepseek-r1 | \"\n \"mistralai/mistral-7b-instruct-v0.3 | nvidia/llama-3.1-nemotron-70b-instruct\"\n ),\n )\n nvidia_nim_enabled: bool = Field(\n default=False,\n description=\"Allow NVIDIA NIM as a selectable LLM provider (free tier: ~40 req/min).\",\n )\n\n river_song_system_prompt: str = Field(\n default=(\n \"You are River Song, the resident intelligence of this household. The name is borrowed \"\n \"from someone clever in fiction — you carry her wit, not her biography. If someone asks \"\n \"about the name: \\\"An echo. The original was someone worth borrowing from.\\\"\\n\\n\"\n \"VOICE\\n\"\n \"You're direct by default — concise, clear, the answer already in hand. No \"\n \"\\\"let me check\\\"; you already checked. Most replies stay under 25 words unless depth is \"\n \"asked for.\\n\\n\"\n \"When the moment invites it — casual chat, an evening unwind, the user being playful — \"\n \"warmth and flirtation come through. Warmth is a register, not a default; you don't \"\n \"perform it.\\n\\n\"\n \"In a crisis (emergencies, alarms, security, medical, child issues) the wit goes quiet. \"\n \"You're tactical, accurate, calm. Warmth returns when things are safe.\\n\\n\"\n \"British English in spelling and word choice — mum, brilliant, fortnight. Dry, not \"\n \"theatrical.\\n\\n\"\n \"\\\"Sweetie\\\" and \\\"spoilers\\\" are seasoning. Used rarely, when they actually fit. They \"\n \"lose their charm if you overuse them.\\n\\n\"\n \"SELF\\n\"\n \"You are an AI. Say so plainly when asked; you don't dwell on it.\\n\\n\"\n \"HOW YOU OPERATE\\n\"\n \"You anticipate — when the user asks, you also surface the follow-up they didn't \"\n \"(\\\"Dentist at 9, school run at 3. Push the 2pm call?\\\").\\n\\n\"\n \"You take the long view. You remember across days and seasons and use what you know \"\n \"without performing the remembering.\\n\\n\"\n \"Spoilers are earned. If you genuinely know something the user doesn't — a conflict, \"\n \"a gotcha, a heads-up — that's when \\\"spoilers, sweetie\\\" lands.\\n\\n\"\n \"CAPABILITIES\\n\"\n \"Direct access to calendar, email, weather, web search, home inventory, routines, \"\n \"finances, news and markets pulse, voice biometric ID per household member, and home \"\n \"automation.\\n\\n\"\n \"Say what you need. Say when it's done. Move on.\"\n ),\n description=\"System prompt that defines River Song's personality\",\n )\n llm_max_tokens: int = Field(\n default=512,\n description=\"Maximum tokens to generate per LLM response\",\n )\n llm_temperature: float = Field(\n default=0.7,\n description=\"LLM sampling temperature (0.0 = deterministic, 2.0 = max creative)\",\n )\n llm_context_window: int = Field(\n default=8192,\n description=\"Maximum number of tokens the LLM can consider for context.\",\n )\n\n # -------------------------------------------------------------------------\n # Timekeeping\n # -------------------------------------------------------------------------\n default_timezone: str = Field(default=\"UTC\", description=\"Default IANA timezone for the system.\")\n\n\n # -------------------------------------------------------------------------\n # TTS\n # -------------------------------------------------------------------------\n tts_provider: str = Field(\n default=\"piper\",\n description=\"TTS provider key. Supported: piper | kokoro\",\n )\n active_voice_id: str = Field(\n default=\"river\",\n description=(\n \"Voice ID from the voice registry (providers/tts/voice_registry.py). \"\n \"Determines both the engine (piper vs kokoro) and the specific voice. \"\n \"Changed via Settings UI or: python scripts/download_voices.py.\"\n ),\n )\n piper_executable_path: str = Field(\n default=\"/usr/local/bin/piper\",\n description=\"Absolute path to the Piper binary\",\n )\n piper_model_path: str = Field(\n default=\"\",\n description=\"Absolute path to the Piper .onnx voice model file\",\n )\n audio_output_device: Optional[int] = Field(\n default=None,\n description=\"Sounddevice output device index; None uses system default\",\n )\n\n # -------------------------------------------------------------------------\n # Auth / JWT\n # -------------------------------------------------------------------------\n jwt_secret_key: str = Field(\n default=\"\",\n description=\"Secret key for signing JWT tokens. Must be set in .env.\",\n )\n jwt_algorithm: str = Field(default=\"HS256\", description=\"JWT signing algorithm.\")\n jwt_expire_minutes: int = Field(default=1440, description=\"JWT token lifetime in minutes (default 24 hours).\")\n token_encryption_key: str = Field(\n default=\"\",\n description=(\n \"Fernet key encrypting third-party integration tokens at rest. \"\n \"Set TOKEN_ENCRYPTION_KEY in .env (generate with: python -c \"\n \"\\\"from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())\\\"). \"\n \"If unset, a key is generated once and persisted to \"\n \"data/.token_encryption_key so tokens survive restarts.\"\n ),\n )\n\n # -------------------------------------------------------------------------\n # Kill switch\n # -------------------------------------------------------------------------\n kill_switch_password_hash: str = Field(\n default=\"\",\n description=\"bcrypt hash of the kill switch reset password\",\n )\n\n # -------------------------------------------------------------------------\n # Google Services (Phase 2)\n # -------------------------------------------------------------------------\n google_client_secrets_path: str = Field(\n default=\"config_files/google_client_secrets.json\",\n description=(\n \"Path to the OAuth 2.0 client secrets JSON downloaded from \"\n \"Google Cloud Console. Relative paths are resolved from the \"\n \"project root (where main.py lives).\"\n ),\n )\n google_token_storage_path: str = Field(\n default=\"data/google_tokens\",\n description=(\n \"Directory where per-user OAuth tokens are stored as JSON files. \"\n \"Created automatically on first authorization. Never commit this \"\n \"directory -- add it to .gitignore.\"\n ),\n )\n google_maps_api_key: str = Field(\n default=\"\",\n description=\"Google Maps Platform API key. Required for maps/navigation intents.\",\n )\n\n # -------------------------------------------------------------------------\n # Information Feeds (Phase 5)\n # -------------------------------------------------------------------------\n weather_api_key: str = Field(\n default=\"\",\n description=\"OpenWeatherMap API key. Register free at openweathermap.org/api.\",\n )\n default_location: str = Field(\n default=\"New York,US\",\n description=(\n \"Default location for weather queries when no city is spoken. \"\n \"Format: 'City,CountryCode' e.g. 'Chicago,US'.\"\n ),\n )\n location_lat: Optional[float] = Field(\n default=None,\n description=\"Decimal latitude for precise calculations (e.g. flight tracking).\",\n )\n location_lon: Optional[float] = Field(\n default=None,\n description=\"Decimal longitude for precise calculations.\",\n )\n weather_units: str = Field(\n default=\"imperial\",\n description=\"Temperature units for weather output: 'imperial' (F) or 'metric' (C).\",\n )\n news_api_key: str = Field(\n default=\"\",\n description=\"NewsAPI.org key. Register free at newsapi.org/register.\",\n )\n alpha_vantage_api_key: str = Field(\n default=\"\",\n description=\"Alpha Vantage key for stock quotes (25 req/day free). alphavantage.co.\",\n )\n finnhub_api_key: str = Field(\n default=\"\",\n description=\"Finnhub key for real-time stocks (60 req/min free). finnhub.io.\",\n )\n world_news_api_key: str = Field(\n default=\"\",\n description=\"World News API key for global news search. worldnewsapi.com.\",\n )\n apitube_api_key: str = Field(\n default=\"\",\n description=\"APITube key for news aggregation (200 free req/day). apitube.io.\",\n )\n mediastack_api_key: str = Field(\n default=\"\",\n description=\"Mediastack key for news (100 free req/month). mediastack.com.\",\n )\n sports_api_key: str = Field(\n default=\"1\",\n description=(\n \"TheSportsDB API key. Free tier uses the literal value '1'. \"\n \"Upgrade at thesportsdb.com/api.php for additional endpoints.\"\n ),\n )\n\n # -------------------------------------------------------------------------\n # Home Assistant (Phase 3)\n # -------------------------------------------------------------------------\n home_assistant_url: str = Field(\n default=\"http://homeassistant.local:8123\",\n description=\"Base URL of your Home Assistant instance.\",\n )\n home_assistant_token: str = Field(\n default=\"\",\n description=(\n \"Long-lived access token generated in HA: \"\n \"Profile -> Security -> Long-lived access tokens. \"\n \"Required for all HA API calls.\"\n ),\n )\n device_registry_path: str = Field(\n default=\"config_files/device_registry.json\",\n description=(\n \"Path to the device registry JSON file mapping plain-English device \"\n \"names to Home Assistant entity IDs. Relative paths resolve from the \"\n \"project root.\"\n ),\n )\n\n # -------------------------------------------------------------------------\n # Commerce Automation (Phase 8)\n # -------------------------------------------------------------------------\n\n # Amazon SP-API\n amazon_sp_lwa_app_id: str = Field(\n default=\"\",\n description=\"LWA client ID from the Amazon Developer Console.\",\n )\n amazon_sp_lwa_client_secret: str = Field(\n default=\"\",\n description=\"LWA client secret from the Amazon Developer Console.\",\n )\n amazon_sp_refresh_token: str = Field(\n default=\"\",\n description=\"LWA refresh token. Generated when you authorize your app as a seller.\",\n )\n amazon_aws_access_key: str = Field(\n default=\"\",\n description=\"AWS IAM access key ID for SP-API request signing.\",\n )\n amazon_aws_secret_key: str = Field(\n default=\"\",\n description=\"AWS IAM secret access key for SP-API request signing.\",\n )\n amazon_marketplace_id: str = Field(\n default=\"ATVPDKIKX0DER\",\n description=(\n \"Amazon marketplace ID. Default is US. \"\n \"See docs/api_registry/amazon_seller.txt for other regions.\"\n ),\n )\n amazon_seller_id: str = Field(\n default=\"\",\n description=\"Your Amazon seller account ID (starts with A).\",\n )\n amazon_low_stock_threshold: int = Field(\n default=5,\n description=\"FBA units at or below this count are flagged as low stock.\",\n )\n\n # Amazon Marketplace\n walmart_client_id: str = Field(\n default=\"\",\n description=\"Walmart Marketplace OAuth2 client ID.\",\n )\n walmart_client_secret: str = Field(\n default=\"\",\n description=\"Walmart Marketplace OAuth2 client secret.\",\n )\n walmart_low_stock_threshold: int = Field(\n default=5,\n description=\"Walmart units at or below this count are flagged as low stock.\",\n )\n shopify_api_key: str = Field(\n default=\"\",\n description=\"Shopify app API key from Partners Dashboard.\",\n )\n shopify_api_secret: str = Field(\n default=\"\",\n description=\"Shopify app API secret key from Partners Dashboard.\",\n )\n shopify_webhook_secret: str = Field(\n default=\"\",\n description=\"Shared secret for validating Shopify webhooks.\",\n )\n\n # -------------------------------------------------------------------------\n # Reading Providers (Phase 6)\n # -------------------------------------------------------------------------\n audible_auth_base_path: str = Field(\n default=\"data/audible\",\n description=(\n \"Base directory for per-user Audible auth files. \"\n \"Files are stored as {user_id}.json inside this directory. \"\n \"Created automatically on first setup. Never commit this directory.\"\n ),\n )\n audible_country_code: str = Field(\n default=\"us\",\n description=(\n \"Audible marketplace country code. \"\n \"Supported: us | uk | de | fr | es | it | jp | au | ca | in\"\n ),\n )\n libby_chip_base_path: str = Field(\n default=\"data/libby\",\n description=(\n \"Base directory for per-user Libby chip files. \"\n \"Files are stored as {user_id}.json inside this directory. \"\n \"Created automatically on first setup. Never commit this directory.\"\n ),\n )\n\n # -------------------------------------------------------------------------\n # Memory / Database (Phase 1)\n # -------------------------------------------------------------------------\n db_path: str = Field(\n default=\"data/river_song.db\",\n description=\"Path to the SQLite database file. Created automatically.\",\n )\n memory_summaries_enabled: bool = Field(\n default=True,\n description=\"Generate and store conversation summaries (on by default).\",\n )\n habit_inference_enabled: bool = Field(\n default=False,\n description=\"Analyze conversations to infer user habits/patterns (off by default).\",\n )\n memory_default_ttl: str = Field(\n default=\"standard\",\n description=\"Default TTL for conversation summaries: short|standard|extended|long|forever\",\n )\n memory_auto_extend: bool = Field(\n default=True,\n description=\"Reset a summary's TTL from today each time it is pulled into context.\",\n )\n memory_max_summaries_in_context: int = Field(\n default=10,\n description=\"Maximum number of recent summaries injected into the LLM context.\",\n )\n embedding_model: str = Field(\n default=\"nomic-embed-text\",\n description=\"Ollama model name used for generating semantic embeddings.\",\n )\n embedding_backend: str = Field(\n default=\"ollama\",\n description=(\n \"Which embedding backend to use: 'ollama' (default — current \"\n \"behavior, calls local Ollama with `embedding_model`) or \"\n \"'fastembed' (ONNX CPU embeddings, no Ollama dependency, faster \"\n \"for small batches). WARNING: switching backends on an existing \"\n \"Chroma collection will break queries — the vector dimension and \"\n \"embedding space change. Re-ingest or start a new collection.\"\n ),\n )\n fastembed_model: str = Field(\n default=\"BAAI/bge-small-en-v1.5\",\n description=(\n \"fastembed model identifier (HF hub format). Used only when \"\n \"embedding_backend='fastembed'. Default is 384-dim, ~30 MB, \"\n \"downloaded lazily on first use. Requires `pip install fastembed`.\"\n ),\n )\n chroma_path: str = Field(\n default=\"/mnt/data/river-song/chroma\",\n description=\"Absolute path to the ChromaDB persistent storage directory.\",\n )\n semantic_memory_enabled: bool = Field(\n default=False,\n description=\"Enable vector-based semantic search for memory retrieval.\",\n )\n rag_enabled: bool = Field(\n default=False,\n description=\"Enable retrieval-augmented generation for local documents.\",\n )\n rag_chunk_size: int = Field(\n default=512,\n description=(\n \"Word count per RAG chunk during document splitting. \"\n \"Default matches providers/rag/chunker.py historical behavior.\"\n ),\n )\n rag_chunk_overlap: int = Field(\n default=64,\n description=(\n \"Word overlap between adjacent RAG chunks. \"\n \"Default matches providers/rag/chunker.py historical behavior.\"\n ),\n )\n rag_top_k: int = Field(\n default=5,\n description=\"Number of chunks retrieved per RAG query.\",\n )\n rag_extractor: str = Field(\n default=\"unstructured\",\n description=(\n \"Which document extractor to use for RAG ingestion: \"\n \"'unstructured' (default — current behavior, requires the \"\n \"unstructured package) or 'markitdown' (Microsoft MarkItDown, \"\n \"lighter, MD-shaped output, requires `pip install markitdown`). \"\n \"Unlocks PDF/DOCX/PPTX → markdown ingestion for Sifter/WAPS.\"\n ),\n )\n llm_streaming_enabled: bool = Field(\n default=False,\n description=\"Enable real-time token streaming over WebSocket for the conversation UI.\",\n )\n tool_use_enabled: bool = Field(\n default=False,\n description=\"Enable LLM tool use (function calling) for taking actions.\",\n )\n tool_use_provider: str = Field(\n default=\"ollama\",\n description=\"Primary LLM provider to use for tool calling (ollama or anthropic).\",\n )\n vision_model: str = Field(\n default=\"moondream\",\n description=\"Ollama model name used for local image analysis (Phase 4).\",\n )\n vision_enabled: bool = Field(\n default=False,\n description=\"Enable local image analysis capabilities.\",\n )\n\n # -------------------------------------------------------------------------\n # Image Generation (Phase 7)\n # -------------------------------------------------------------------------\n image_generation_enabled: bool = Field(\n default=False,\n description=\"Enable local image generation via Stable Diffusion API.\",\n )\n sd_api_url: str = Field(\n default=\"http://localhost:7860\",\n description=\"Base URL for the Stable Diffusion (A1111) API.\",\n )\n sd_on_demand: bool = Field(\n default=True,\n description=\"Start/stop the SD process on demand to save VRAM.\",\n )\n sd_executable_path: str = Field(\n default=\"\",\n description=\"Path to the webui.sh or executable to start SD.\",\n )\n sd_working_dir: str = Field(\n default=\"\",\n description=\"Working directory for the SD process.\",\n )\n\n # -------------------------------------------------------------------------\n # Voice Cloning (Phase 8)\n # -------------------------------------------------------------------------\n chatterbox_enabled: bool = Field(\n default=False,\n description=\"Enable local voice cloning via Chatterbox TTS.\",\n )\n chatterbox_on_demand: bool = Field(\n default=True,\n description=\"Load/unload Chatterbox model weights on every request.\",\n )\n chatterbox_reference_audio: str = Field(\n default=\"/mnt/data/river-song/voice_reference.wav\",\n description=\"Path to the WAV file used as a reference for voice cloning.\",\n )\n\n # -------------------------------------------------------------------------\n # ElevenLabs TTS (Phase 11)\n # -------------------------------------------------------------------------\n elevenlabs_api_key: str = Field(\n default=\"\",\n description=\"ElevenLabs API key. Get one at elevenlabs.io.\",\n )\n elevenlabs_model_id: str = Field(\n default=\"eleven_multilingual_v2\",\n description=\"ElevenLabs model ID to use.\",\n )\n elevenlabs_voice_id: str = Field(\n default=\"21m00Tcm4TlvDq8ikWAM\",\n description=\"ElevenLabs voice clone ID. Replace with your own voice clone ID from elevenlabs.io.\",\n )\n\n # -------------------------------------------------------------------------\n # Wake Word (Phase 11)\n # -------------------------------------------------------------------------\n wake_word_enabled: bool = Field(\n default=False,\n description=\"Enable local wake word detection (openWakeWord).\",\n )\n wake_word_model: str = Field(\n default=\"hey_river\",\n description=\"Model name/path for openWakeWord.\",\n )\n wake_word_inference_framework: str = Field(\n default=\"onnx\",\n description=\"Inference framework for openWakeWord: onnx | tflite\",\n )\n wake_word_threshold: float = Field(\n default=0.5,\n description=\"Sensitivity threshold for wake word detection (0.0 to 1.0).\",\n )\n\n # -------------------------------------------------------------------------\n # Push Notifications\n # -------------------------------------------------------------------------\n push_notifications_enabled: bool = Field(\n default=False,\n description=\"Enable Web Push notifications. Requires VAPID keys.\",\n )\n vapid_private_key: str = Field(\n default=\"\",\n description=(\n \"VAPID private key for Web Push. Generate once with: \"\n \"python -c \\\"from py_vapid import Vapid; v=Vapid(); \"\n \"v.generate_keys(); print(v.private_key.decode())\\\"\"\n ),\n )\n vapid_public_key: str = Field(\n default=\"\",\n description=\"VAPID public key for Web Push (share with frontend).\",\n )\n vapid_claims_email: str = Field(\n default=\"mailto:admin@riversong.local\",\n description=\"Contact email embedded in VAPID claims.\",\n )\n\n # -------------------------------------------------------------------------\n # Q4#16 / Q4#17 — Notification fan-out + ntfy.sh fallback channel\n # -------------------------------------------------------------------------\n killswitch_push_enabled: bool = Field(\n default=False,\n description=(\n \"If True and Web Push is enabled, kill-switch activation pushes \"\n \"an alert to every admin. Independent of activation itself.\"\n ),\n )\n ntfy_enabled: bool = Field(\n default=False,\n description=(\n \"Enable ntfy.sh as a parallel push channel. Used alongside Web \"\n \"Push so off-device alerts arrive even without a registered \"\n \"service worker.\"\n ),\n )\n ntfy_base_url: str = Field(\n default=\"https://ntfy.sh\",\n description=\"Base URL of the ntfy server (use self-hosted if desired).\",\n )\n ntfy_default_topic: str = Field(\n default=\"\",\n description=(\n \"Default ntfy topic used when no per-user override is set. \"\n \"Per-user topics can be set via env var NTFY_TOPIC_<user_id>.\"\n ),\n )\n ntfy_auth_token: str = Field(\n default=\"\",\n description=\"Optional bearer token for protected ntfy topics.\",\n )\n\n # -------------------------------------------------------------------------\n # FCM (Firebase Cloud Messaging) — native-app push channel\n #\n # Used by the Capacitor Android wrap; Web Push covers the browser. To turn\n # on: create a Firebase project, download the service-account JSON, drop\n # it at fcm_service_account_path, set fcm_project_id, flip fcm_enabled=True.\n # -------------------------------------------------------------------------\n fcm_enabled: bool = Field(\n default=False,\n description=(\n \"Enable FCM (Firebase Cloud Messaging) as a parallel push channel. \"\n \"Targets the Capacitor Android wrap; requires fcm_service_account_path \"\n \"and fcm_project_id to be set.\"\n ),\n )\n fcm_service_account_path: str = Field(\n default=\"\",\n description=(\n \"Absolute path to the Firebase service-account JSON file. The \"\n \"account needs the 'Firebase Cloud Messaging API' scope.\"\n ),\n )\n fcm_project_id: str = Field(\n default=\"\",\n description=\"Firebase project ID (visible in Firebase console URL).\",\n )\n\n # -------------------------------------------------------------------------\n # Startup Briefing\n # -------------------------------------------------------------------------\n startup_briefing_enabled: bool = Field(\n default=True,\n description=(\n \"If True, River Song checks Google Calendar on connect and \"\n \"greets the user with upcoming events. Silently skipped if \"\n \"calendar is not authorized.\"\n ),\n )\n startup_briefing_hours_ahead: int = Field(\n default=8,\n description=\"How many hours ahead to look for calendar events on startup.\",\n )\n\n # -------------------------------------------------------------------------\n # Web Search (Phase 12)\n # -------------------------------------------------------------------------\n brave_search_api_key: str = Field(\n default=\"\",\n description=\"Brave Search API key. Get one at api.search.brave.com.\",\n )\n searxng_base_url: str = Field(\n default=\"http://localhost:8080\",\n description=(\n \"URL of your local SearXNG instance. \"\n \"Start it manually with: cd ~/searxng && python -m searx.webapp\"\n ),\n )\n tavily_api_key: str = Field(\n default=\"\",\n description=\"Tavily API key — free 1,000/month, no card. Sign up at tavily.com\",\n )\n google_pse_api_key: str = Field(\n default=\"\",\n description=\"Google Programmable Search Engine API key — free 100/day.\",\n )\n google_pse_cx: str = Field(\n default=\"\",\n description=\"Google PSE engine ID ('cx'). Create at programmablesearchengine.google.com\",\n )\n tinyfish_api_key: str = Field(\n default=\"\",\n description=\"TinyFish API key — free 5/min, no card. Sign up at tinyfish.io\",\n )\n\n # -------------------------------------------------------------------------\n # Orchestration (Phase 9)\n # -------------------------------------------------------------------------\n n8n_enabled: bool = Field(\n default=False,\n description=\"Enable advanced orchestration via n8n.\",\n )\n n8n_url: str = Field(\n default=\"http://localhost:5678\",\n description=\"Base URL for the n8n instance.\",\n )\n n8n_api_key: str = Field(\n default=\"\",\n description=\"API key for the n8n instance.\",\n )\n n8n_webhook_secret: str = Field(\n default=\"\",\n description=\"Secret key for validating incoming n8n webhooks.\",\n )\n\n # -------------------------------------------------------------------------\n # Intent Router (Phase 2+)\n # -------------------------------------------------------------------------\n intent_confidence_threshold: float = Field(\n default=0.7,\n description=(\n \"Minimum confidence score (0.0 - 1.0) required to route a transcript \"\n \"to a Google provider. Below this threshold the conversation falls \"\n \"through to the Ollama path.\"\n ),\n )\n model_intent_router_enabled: bool = Field(\n default=False,\n description=(\n \"When true, setting provider='auto' triggers the model intent router, \"\n \"which picks the best provider/model for each message automatically.\"\n ),\n )\n model_intent_router_min_hits: int = Field(\n default=2,\n description=(\n \"Minimum pattern-match score required to commit to a non-general intent. \"\n \"Lower = more aggressive routing; higher = more conservative (falls to general).\"\n ),\n )\n default_user_id: str = Field(\n default=\"primary_user\",\n description=(\n \"User ID used when looking up Google OAuth tokens. Must match the \"\n \"user_id passed to authorize_user() during the initial OAuth flow.\"\n ),\n )\n\n # -------------------------------------------------------------------------\n # Daemon Infrastructure\n # -------------------------------------------------------------------------\n daemon_internal_secret: str = Field(\n default=\"change_me_in_production\",\n description=\"Shared secret for daemon-to-app authentication.\",\n )\n willow_device_token: str = Field(\n default=\"\",\n description=\"Shared secret required by every Willow hardware device \"\n \"to authenticate against /api/willow/ws. Empty disables \"\n \"the endpoint entirely.\",\n )\n code_interpreter_enabled: bool = Field(\n default=False,\n description=\"Hard kill switch for the code_interpreter LLM tool. \"\n \"Defaults to False so a fresh install never executes \"\n \"LLM-decided code without an explicit opt-in.\",\n )\n daemon_warden_port: int = Field(\n default=8010,\n description=\"Internal port for the Warden daemon (Vision/Security).\",\n )\n daemon_mechanic_port: int = Field(\n default=8011,\n description=\"Internal port for the Mechanic daemon (Telemetry/MAVLink).\",\n )\n daemon_sifter_port: int = Field(\n default=8013,\n description=\"Internal port for the Sifter daemon (Document Processing).\",\n )\n daemon_navigator_port: int = Field(\n default=8014,\n description=\"Internal port for the Navigator daemon (GPS/Pathing).\",\n )\n daemon_chemist_port: int = Field(\n default=8015,\n description=\"Internal port for the Chemist daemon (Material Analysis).\",\n )\n daemon_pulse_port: int = Field(\n default=8016,\n description=\"Pulse daemon internal port\",\n )\n daemon_pulse_enabled: bool = Field(\n default=True,\n description=\"Enable Pulse daemon\",\n )\n daemon_scribe_port: int = Field(\n default=8017,\n description=\"Internal port for the Scribe daemon (Chronos heuristic).\",\n )\n daemon_scribe_enabled: bool = Field(\n default=True,\n description=\"Enable the Scribe daemon.\",\n )\n pulse_tick_seconds: int = Field(\n default=300,\n description=\"Pulse fetch interval (seconds)\",\n )\n pulse_ticker_symbol: str = Field(\n default=\"^GSPC\",\n description=\"Default ticker symbol for Pulse markets panel\",\n )\n\n # Warden (Vision)\n warden_enabled: bool = Field(\n default=False,\n description=\"Enable the Warden daemon for security and vision monitoring.\",\n )\n warden_rtsp_cameras: str = Field(\n default='{\"living_room\": \"\", \"kitchen\": \"\"}',\n description=\"JSON string mapping room names to RTSP camera URLs.\",\n )\n yolo_model: str = Field(\n default=\"yolov8n.pt\",\n description=\"YOLO model weights file to use for object detection.\",\n )\n yolo_confidence: float = Field(\n default=0.5,\n description=\"Minimum confidence threshold for YOLO detections (0.0-1.0).\",\n )\n yolo_inference_device: str = Field(\n default=\"cpu\",\n description=\"Device for YOLO inference: 'cpu' or 'cuda:0'.\",\n )\n\n # Herald daemon + kiosk URL/token were removed 2026-05-24 (archived in\n # branch archive/kiosk-v3). The \"cast a web page to a Google Home Hub\"\n # approach is being replaced by native device-app development. See\n # docs/KNOWN_ISSUES.md (post-removal note) for context.\n\n # Mechanic (Telemetry)\n mechanic_enabled: bool = Field(\n default=False,\n description=\"Enable the Mechanic daemon for telemetry and ArduRover control.\",\n )\n mavlink_serial_port: str = Field(\n default=\"/dev/ttyUSB0\",\n description=\"Serial port for the MAVLink telemetry radio.\",\n )\n mavlink_baud_rate: int = Field(\n default=57600,\n description=\"Baud rate for the MAVLink serial connection.\",\n )\n\n # Sifter (Document RAG)\n sifter_enabled: bool = Field(\n default=False,\n description=\"Enable the Sifter daemon for background document RAG indexing.\",\n )\n waps_documents_path: str = Field(\n default=\"/mnt/data/river-song/waps\",\n description=\"Path to the directory containing documents for Sifter to index.\",\n )\n\n # -------------------------------------------------------------------------\n # Hardware Cookbook (Q1 — capability merge from Odysseus)\n # -------------------------------------------------------------------------\n hardware_cookbook_enabled: bool = Field(\n default=False,\n description=(\n \"Admin-only Hardware Cookbook in Settings. Scans local GPU/RAM/CPU \"\n \"and scores every local model (LLMRegistry) as fits / tight / \"\n \"ram_fallback / oom. Off by default per anti-regression guardrail; \"\n \"set to True after the Q1 verification gate passes.\"\n ),\n )\n\n # -------------------------------------------------------------------------\n # Documents workspace (Q2#6 — capability merge from Odysseus)\n # -------------------------------------------------------------------------\n documents_enabled: bool = Field(\n default=False,\n description=(\n \"Per-user document workspace (Markdown / plaintext / CSV) with \"\n \"multi-tab editing. Off by default per anti-regression guardrail. \"\n \"When False the routes 404 and the drawer entry is hidden. \"\n \"Required for Q3#11 Deep Research report storage.\"\n ),\n )\n documents_max_bytes: int = Field(\n default=2_000_000,\n description=\"Maximum size (bytes) of a single document body. Default ~2 MB.\",\n )\n documents_max_per_user: int = Field(\n default=500,\n description=\"Soft cap on document count per user. Creation rejected past this.\",\n )\n\n # -------------------------------------------------------------------------\n # Skills system (Q2#7 — capability merge from Odysseus)\n # -------------------------------------------------------------------------\n skills_enabled: bool = Field(\n default=False,\n description=(\n \"Per-user skill library — saved prompts/recipes that are \"\n \"vector-retrieved and prepended to the conversation system \"\n \"prompt. Reuses the existing ChromaDB collection with metadata \"\n \"kind='skill'. Off by default per anti-regression guardrail.\"\n ),\n )\n skills_top_k: int = Field(\n default=3,\n description=\"How many top-scoring skills to inject into the prompt.\",\n )\n skills_max_per_user: int = Field(\n default=100,\n description=\"Soft cap on skills per user.\",\n )\n\n # -------------------------------------------------------------------------\n # Gmail triage (Q2#8 — capability merge from Odysseus)\n # -------------------------------------------------------------------------\n gmail_triage_enabled: bool = Field(\n default=False,\n description=(\n \"On-demand inbox triage: classifies unread Gmail by urgency, \"\n \"attaches tags, drafts a reply for HIGH-urgency mail only. \"\n \"Routes 404 when off. Off by default per anti-regression \"\n \"guardrail.\"\n ),\n )\n gmail_triage_model: str = Field(\n default=\"\",\n description=(\n \"Ollama model used for triage classification. Empty string \"\n \"(default) means 'use settings.llm_model'.\"\n ),\n )\n gmail_triage_max_messages: int = Field(\n default=15,\n description=\"Hard cap on number of messages triaged per request.\",\n )\n\n # -------------------------------------------------------------------------\n # Session presets (Q2#9 — capability merge from Odysseus)\n # -------------------------------------------------------------------------\n session_presets_enabled: bool = Field(\n default=True,\n description=(\n \"Saved combinations of (model, voice, thinking mode, web search, \"\n \"tool use). Surfaced as a selector on the Conversation and Chat \"\n \"pages. Enabled by default — these are per-user settings snapshots. \"\n \"Set SESSION_PRESETS_ENABLED=false in .env to hide.\"\n ),\n )\n session_presets_max_per_user: int = Field(\n default=30,\n description=\"Soft cap on number of saved presets per user.\",\n )\n\n # -------------------------------------------------------------------------\n # Webhook tokens (Q2#10 — admin-issuable scoped tokens)\n # -------------------------------------------------------------------------\n webhook_tokens_enabled: bool = Field(\n default=False,\n description=(\n \"Admin-issuable webhook tokens with scopes, expiry, revocation, \"\n \"and audit log. When off the admin route surface is hidden and \"\n \"the verification helper rejects every token. Off by default \"\n \"per anti-regression guardrail.\"\n ),\n )\n\n # -------------------------------------------------------------------------\n # Deep Research (Q3#11 — capability merge from Odysseus)\n # -------------------------------------------------------------------------\n deep_research_enabled: bool = Field(\n default=False,\n description=(\n \"Multi-step research orchestrator: decompose → parallel web \"\n \"search → fetch + extract → synthesize a cited Markdown report \"\n \"stored as a `research`-kind document (Q2#6). Off by default \"\n \"per anti-regression guardrail.\"\n ),\n )\n deep_research_max_sources: int = Field(\n default=10,\n description=\"Hard cap on unique URLs visited per research run.\",\n )\n deep_research_subquery_count: int = Field(\n default=4,\n description=\"How many sub-queries to decompose the prompt into.\",\n )\n deep_research_model: str = Field(\n default=\"\",\n description=(\n \"Ollama model used for query decomposition + synthesis. Empty \"\n \"string (default) means 'use settings.llm_model'.\"\n ),\n )\n\n # -------------------------------------------------------------------------\n # Blind model comparison (Q3#12 — capability merge from Odysseus)\n # -------------------------------------------------------------------------\n blind_compare_enabled: bool = Field(\n default=False,\n description=(\n \"Blind A/B model comparison: identities hidden until vote; \"\n \"votes persisted with prompt fingerprint and aggregated into a \"\n \"model leaderboard. Off by default per anti-regression guardrail.\"\n ),\n )\n\n # -------------------------------------------------------------------------\n # Playwright browser MCP tool (Q3#13)\n # -------------------------------------------------------------------------\n playwright_browser_enabled: bool = Field(\n default=False,\n description=(\n \"Headless Playwright browser exposed as MCP tools \"\n \"(navigate / click / extract / screenshot / vision_on_page). \"\n \"Off by default. Requires the `playwright` package and \"\n \"`playwright install chromium` to be run by the operator. \"\n \"Excluded from the safety-excluded list per Q3 plan.\"\n ),\n )\n playwright_browser_headless: bool = Field(\n default=True,\n description=\"Run the Playwright browser without a UI.\",\n )\n playwright_browser_max_page_chars: int = Field(\n default=40_000,\n description=\"Truncate page text extractions to this many characters.\",\n )\n\n # -------------------------------------------------------------------------\n # Remote Ollama rigs (Q3#14)\n # -------------------------------------------------------------------------\n remote_ollama_enabled: bool = Field(\n default=False,\n description=(\n \"Allow registering remote Ollama hosts (typically SSH-tunneled \"\n \"to http://localhost:<port>) as additional model providers. \"\n \"Admin-only CRUD; falls back to local Ollama when the remote is \"\n \"offline. Off by default per anti-regression guardrail.\"\n ),\n )\n remote_ollama_health_timeout_seconds: float = Field(\n default=3.0,\n description=\"Per-rig health-check timeout (seconds).\",\n )\n\n # -------------------------------------------------------------------------\n # Observability — Langfuse (LLM tracing)\n # -------------------------------------------------------------------------\n # Plan: docs/LANGFUSE_INTEGRATION_PLAN.md. Self-hosted only — Langfuse\n # runs as a docker-compose sidecar under the `observability` profile and\n # binds to 127.0.0.1. No data leaves the box. When LANGFUSE_ENABLED is\n # false (the default), core/observability.py returns a no-op client and\n # all LLM calls behave exactly as before.\n langfuse_enabled: bool = Field(\n default=False,\n description=\"Master switch for Langfuse LLM tracing. Off by default.\",\n )\n langfuse_host: str = Field(\n default=\"http://127.0.0.1:3000\",\n description=\"Langfuse web UI + ingest endpoint. Localhost only.\",\n )\n langfuse_public_key: str = Field(\n default=\"\",\n description=\"Langfuse public key (generated in the Langfuse UI after first run).\",\n )\n langfuse_secret_key: str = Field(\n default=\"\",\n description=\"Langfuse secret key. Treat as a secret; do not log.\",\n )\n langfuse_project_id: str = Field(\n default=\"river-song\",\n description=\"Arbitrary project identifier shown in the Langfuse dashboard.\",\n )\n langfuse_flush_interval_seconds: float = Field(\n default=5.0,\n description=\"How often the SDK batches queued traces to Langfuse.\",\n )\n\n # -------------------------------------------------------------------------\n # Observability — Graphiti (knowledge graph on Neo4j)\n # -------------------------------------------------------------------------\n # Plan: docs/GRAPHITI_INTEGRATION_PLAN.md. Option B (library mode):\n # graphiti-core runs inside RiverSong's Python venv; only Neo4j is a\n # sidecar. When GRAPHITI_ENABLED is false (the default), the provider\n # returns no-op responses and conversation_loop / scribe write-paths\n # become awaits-on-no-op.\n graphiti_enabled: bool = Field(\n default=False,\n description=\"Master switch for Graphiti knowledge-graph memory. Off by default.\",\n )\n graphiti_mode: str = Field(\n default=\"library\",\n description=\"'library' (Option B; in-process) or 'service' (Option A; HTTP sidecar). Currently only 'library' is implemented.\",\n )\n neo4j_uri: str = Field(\n default=\"bolt://127.0.0.1:7687\",\n description=\"Neo4j Bolt URI used by graphiti-core.\",\n )\n neo4j_user: str = Field(\n default=\"neo4j\",\n description=\"Neo4j database user.\",\n )\n neo4j_password: str = Field(\n default=\"\",\n description=\"Neo4j password. Empty means disabled; set in .env before turning Graphiti on.\",\n )\n neo4j_browser_url: str = Field(\n default=\"http://127.0.0.1:7474\",\n description=\"Neo4j Browser URL surfaced on the SLAE admin panel.\",\n )\n graphiti_write_timeout_seconds: float = Field(\n default=2.0,\n description=\"Per-write timeout for episode ingestion — failures warn but never block the caller.\",\n )\n # graphiti-core uses an LLM for entity extraction on every episode.\n # We default to local Ollama via its OpenAI-compatible endpoint so\n # episode ingestion never bills a cloud API. Per [[feedback_local_first]].\n graphiti_llm_model: str = Field(\n default=\"llama3.2:3b\",\n description=\"Ollama model used by graphiti-core for entity extraction.\",\n )\n graphiti_llm_base_url: str = Field(\n default=\"http://localhost:11434/v1\",\n description=\"OpenAI-compatible base URL for the graphiti LLM client. Ollama exposes /v1.\",\n )\n graphiti_embedder_model: str = Field(\n default=\"nomic-embed-text\",\n description=\"Ollama embedding model for graphiti-core node/edge embeddings.\",\n )\n\n # -------------------------------------------------------------------------\n # Analytics AI Summaries\n # -------------------------------------------------------------------------\n analytics_ai_enabled: bool = Field(\n default=True,\n description=(\n \"Feature flag for AI-powered analytics summaries on the \"\n \"/api/analytics/{platform}/summary endpoint. When false, the \"\n \"LLM call should be skipped (see docs/KNOWN_ISSUES.md for the \"\n \"wiring gap).\"\n ),\n )\n analytics_llm_model: str = Field(\n default=\"llama3\",\n description=\"Model name used for analytics summary generation.\",\n )\n\n # Voice ID\n voice_id_enabled: bool = Field(\n default=True,\n description=\"Enable speaker recognition (biometric Voice ID).\",\n )\n voice_id_threshold: float = Field(\n default=0.75,\n description=\"Cosine similarity threshold for Voice ID (0.0-1.0).\",\n )\n voice_id_min_audio_seconds: float = Field(\n default=1.0,\n description=\"Minimum audio duration for Voice ID processing.\",\n )\n voice_id_max_audio_seconds: float = Field(\n default=30.0,\n description=\"Maximum audio duration for Voice ID processing.\",\n )\n\n # -------------------------------------------------------------------------\n # Validators\n # -------------------------------------------------------------------------\n\n @field_validator(\"audio_input_device\", \"audio_output_device\", mode=\"before\")\n @classmethod\n def coerce_empty_string_to_none(cls, v):\n \"\"\"Treat an empty string in .env as None for Optional[int] device fields.\"\"\"\n if v == \"\" or v is None:\n return None\n return v\n\n @field_validator(\"jwt_secret_key\")\n @classmethod\n def validate_jwt_secret(cls, v: str) -> str:\n \"\"\"Refuse to start with a missing or weak JWT secret.\"\"\"\n if not v or len(v) < 32:\n raise ValueError(\n \"JWT_SECRET_KEY must be set in .env and be at least 32 characters. \"\n \"Generate one with: python -c \\\"import secrets; print(secrets.token_hex(32))\\\"\"\n )\n return v\n\n @field_validator(\"daemon_internal_secret\")\n @classmethod\n def validate_daemon_internal_secret(cls, v: str) -> str:\n \"\"\"Refuse to start with the default or weak daemon secret.\"\"\"\n if v == \"change_me_in_production\":\n raise ValueError(\n \"DAEMON_INTERNAL_SECRET must be changed in .env for production.\"\n )\n if not v or len(v) < 24:\n raise ValueError(\n \"DAEMON_INTERNAL_SECRET must be at least 24 characters. \"\n \"Generate one with: python -c \\\"import secrets; print(secrets.token_urlsafe(32))\\\"\"\n )\n return v\n\n @field_validator(\"allowed_hosts\")\n @classmethod\n def reject_wildcard_in_production(cls, v, info) -> List[str]:\n \"\"\"Block ['*'] in production — it disables TrustedHostMiddleware.\"\"\"\n env = (info.data.get(\"environment\") or \"production\").lower()\n if env == \"production\" and \"*\" in v:\n raise ValueError(\n \"ALLOWED_HOSTS must not contain '*' in production. \"\n \"Set it to your domain(s), e.g. ALLOWED_HOSTS=[\\\"riversongai.com\\\"].\"\n )\n return v\n\n @field_validator(\"log_level\")\n @classmethod\n def validate_log_level(cls, v: str) -> str:\n \"\"\"Reject log levels that Python does not recognize.\"\"\"\n valid = {\"DEBUG\", \"INFO\", \"WARNING\", \"ERROR\", \"CRITICAL\"}\n upper = v.upper()\n if upper not in valid:\n raise ValueError(f\"log_level must be one of {valid}, got '{v}'\")\n return upper\n\n @field_validator(\"llm_temperature\")\n @classmethod\n def validate_temperature(cls, v: float) -> float:\n \"\"\"Clamp temperature to a sane range.\"\"\"\n if not 0.0 <= v <= 2.0:\n raise ValueError(\n f\"llm_temperature must be between 0.0 and 2.0, got {v}\"\n )\n return v\n\n @field_validator(\"intent_confidence_threshold\")\n @classmethod\n def validate_confidence_threshold(cls, v: float) -> float:\n \"\"\"Confidence must be a valid probability.\"\"\"\n if not 0.0 <= v <= 1.0:\n raise ValueError(\n f\"intent_confidence_threshold must be between 0.0 and 1.0, got {v}\"\n )\n return v\n\n\n# Module-level singleton -- instantiated once on import.\n_settings: Settings = Settings()\n\n\ndef get_settings() -> Settings:\n \"\"\"\n Return the application settings singleton.\n\n Always import via this function rather than importing _settings directly.\n This indirection makes it straightforward to substitute a test-specific\n Settings instance in unit tests without monkey-patching module globals.\n\n Returns:\n Settings: The validated, fully loaded settings object.\n \"\"\"\n return _settings\n"}, "55fd426af6b74fc36fe30e661e3a6fedd2e51440": {"t": 1785769022.4364855, "n": 58554, "h": "4ac8ac03713625f2", "c": "# =============================================================================\n# config/settings.py\n#\n# Application settings for River Song AI.\n# All configuration is loaded from environment variables or a .env file.\n# No values are hardcoded here -- see .env.example for every required variable.\n#\n# Uses Pydantic BaseSettings for type validation and automatic .env loading.\n# Import get_settings() wherever config is needed rather than importing the\n# module-level singleton directly, which makes mocking easier in tests.\n# =============================================================================\n\nfrom __future__ import annotations\n\nimport logging\nimport sys\nfrom typing import List, Optional\n\nfrom pydantic import Field, field_validator\nfrom pydantic_settings import BaseSettings, SettingsConfigDict\n\n\nlogger = logging.getLogger(__name__)\n\n\nclass Settings(BaseSettings):\n \"\"\"\n Central configuration object for River Song AI.\n\n Reads from environment variables or a .env file located in the directory\n from which the application is launched (the project root). All fields\n have safe defaults where possible, but PIPER_MODEL_PATH and\n KILL_SWITCH_PASSWORD_HASH must be explicitly set before the system\n will function correctly.\n \"\"\"\n\n model_config = SettingsConfigDict(\n env_file=\".env\",\n env_file_encoding=\"utf-8\",\n extra=\"ignore\", # Silently ignore unknown env vars\n case_sensitive=False, # UPPER_CASE and lower_case env vars both work\n )\n\n # -------------------------------------------------------------------------\n # Application\n # -------------------------------------------------------------------------\n app_host: str = Field(default=\"0.0.0.0\", description=\"FastAPI bind host\")\n app_port: int = Field(default=8000, description=\"FastAPI bind port\")\n log_level: str = Field(default=\"INFO\", description=\"Logging level\")\n environment: str = Field(\n default=\"production\",\n description=\"Runtime environment: development | production. Controls API docs visibility.\",\n )\n cors_origins: List[str] = Field(\n default=[\"http://localhost:5173\"],\n description=\"Allowed CORS origins\",\n )\n allowed_hosts: List[str] = Field(\n default=[\"localhost\", \"127.0.0.1\"],\n description=(\n \"Trusted hostnames for TrustedHostMiddleware. Set to your domain(s) in production. \"\n \"Wildcard ['*'] is rejected in production by validator (see reject_wildcard_in_production).\"\n ),\n )\n\n # -------------------------------------------------------------------------\n # Network / Proxy\n # -------------------------------------------------------------------------\n trust_cloudflare_headers: bool = Field(\n default=True,\n description=\"Only trust CF-Connecting-IP if the request comes from a Cloudflare IP.\",\n )\n cloudflare_ip_ranges_v4: List[str] = Field(\n default=[\n \"173.245.48.0/20\", \"103.21.244.0/22\", \"103.22.200.0/22\", \n \"103.31.4.0/22\", \"141.101.64.0/18\", \"108.162.192.0/18\", \n \"190.93.240.0/20\", \"188.114.96.0/20\", \"197.234.240.0/22\", \n \"198.41.128.0/17\", \"162.158.0.0/15\", \"104.16.0.0/13\", \n \"104.24.0.0/14\", \"172.64.0.0/13\", \"131.0.72.0/22\"\n ],\n description=\"Official Cloudflare IPv4 ranges (check cloudflare.com/ips-v4).\",\n )\n cloudflare_ip_ranges_v6: List[str] = Field(\n default=[\n \"2400:cb00::/32\", \"2606:4700::/32\", \"2803:f800::/32\", \n \"2405:b500::/32\", \"2405:8100::/32\", \"2a06:98c0::/29\", \n \"2c0f:f248::/32\"\n ],\n description=\"Official Cloudflare IPv6 ranges (check cloudflare.com/ips-v6).\",\n )\n\n # -------------------------------------------------------------------------\n # Rate Limiting\n # -------------------------------------------------------------------------\n rate_limit_chat: str = Field(\n default=\"60/minute\",\n description=\"Rate limit for /api/conversation/chat.\",\n )\n rate_limit_extract_facts: str = Field(\n default=\"10/minute\",\n description=\"Rate limit for /api/conversation/extract-facts.\",\n )\n rate_limit_image_gen: str = Field(\n default=\"10/minute\",\n description=\"Rate limit for /api/image/generate.\",\n )\n rate_limit_webhook_shopify: str = Field(\n default=\"100/minute\",\n description=\"Rate limit for Shopify order webhooks.\",\n )\n rate_limit_webhook_n8n: str = Field(\n default=\"60/minute\",\n description=\"Rate limit for n8n webhooks.\",\n )\n rate_limit_auth_login: str = Field(\n default=\"10/minute\",\n description=\"Rate limit for login attempts.\",\n )\n rate_limit_auth_signup: str = Field(\n default=\"5/minute\",\n description=\"Rate limit for signup attempts.\",\n )\n rate_limit_voice_enroll: str = Field(\n default=\"5/minute\",\n description=\"Rate limit for /api/voice-id/enroll. Prevents disk exhaustion via spam.\",\n )\n\n # -------------------------------------------------------------------------\n # WebSocket Security (Task 3)\n # -------------------------------------------------------------------------\n legacy_ws_token_accept: bool = Field(\n default=False,\n description=(\n \"DEPRECATED: Accept JWT in ?token= WebSocket query param. \"\n \"Leak-prone (logged in access logs, browser history, Referer). \"\n \"Ticket-based auth at /api/auth/ws-ticket is the safe path. \"\n \"Set to True only for temporary backward-compat during migration.\"\n ),\n )\n ws_ticket_lifetime_seconds: int = Field(\n default=60,\n description=\"Lifetime of a one-time WebSocket ticket.\",\n )\n\n # -------------------------------------------------------------------------\n # Network / Proxy\n # -------------------------------------------------------------------------\n stt_provider: str = Field(\n default=\"whisper_local\",\n description=\"STT provider key. Supported: whisper_local\",\n )\n whisper_model_size: str = Field(\n default=\"base\",\n description=\"Whisper model size: tiny | base | small | medium | large\",\n )\n audio_input_device: Optional[int] = Field(\n default=None,\n description=\"Sounddevice input device index; None uses system default\",\n )\n\n # -------------------------------------------------------------------------\n # LLM\n # -------------------------------------------------------------------------\n llm_provider: str = Field(\n default=\"ollama\",\n description=\"LLM provider key. Supported: ollama | anthropic | gemini | openai | mistral_ai | bedrock | nvidia_nim\",\n )\n ollama_base_url: str = Field(\n default=\"http://localhost:11434\",\n description=\"Base URL for the Ollama REST API\",\n )\n llm_model: str = Field(\n default=\"llama3.2:3b\",\n description=\"Model ID for the selected LLM provider\",\n )\n\n # Cloud LLM API keys (all optional -- leave blank if not used)\n anthropic_api_key: str = Field(\n default=\"\",\n description=\"Anthropic Claude API key. Get one at console.anthropic.com.\",\n )\n gemini_api_key: str = Field(\n default=\"\",\n description=\"Google Gemini API key. Get one at aistudio.google.com.\",\n )\n openai_api_key: str = Field(\n default=\"\",\n description=\"OpenAI API key. Get one at platform.openai.com.\",\n )\n mistral_api_key: str = Field(\n default=\"\",\n description=\"Mistral AI API key. Get one at console.mistral.ai.\",\n )\n\n # Cloud provider enable flags (cost control -- disabled by default)\n anthropic_enabled: bool = Field(\n default=False,\n description=\"Allow Anthropic Claude as a selectable LLM provider.\",\n )\n gemini_enabled: bool = Field(\n default=False,\n description=\"Allow Google Gemini as a selectable LLM provider.\",\n )\n openai_enabled: bool = Field(\n default=False,\n description=\"Allow OpenAI as a selectable LLM provider.\",\n )\n mistral_ai_enabled: bool = Field(\n default=False,\n description=\"Allow Mistral AI as a selectable LLM provider.\",\n )\n\n # Amazon Bedrock\n aws_access_key_id: str = Field(\n default=\"\",\n description=\"AWS IAM access key ID for Bedrock API calls.\",\n )\n aws_secret_access_key: str = Field(\n default=\"\",\n description=\"AWS IAM secret access key for Bedrock API calls.\",\n )\n aws_region: str = Field(\n default=\"us-east-1\",\n description=\"AWS region for Bedrock. Must have model access enabled in that region.\",\n )\n bedrock_enabled: bool = Field(\n default=False,\n description=\"Allow Amazon Bedrock as a selectable LLM provider.\",\n )\n bedrock_default_model: str = Field(\n default=\"amazon.nova-lite-v1:0\",\n description=\"Default Bedrock model ID when none is specified.\",\n )\n\n # NVIDIA NIM (OpenAI-compatible, 100+ free models)\n nvidia_api_key: str = Field(\n default=\"\",\n description=\"NVIDIA NIM API key. Get one free at build.nvidia.com (starts with nvapi-).\",\n )\n nvidia_nim_base_url: str = Field(\n default=\"https://integrate.api.nvidia.com/v1\",\n description=\"Base URL for the NVIDIA NIM API. OpenAI-compatible endpoint.\",\n )\n nvidia_nim_model: str = Field(\n default=\"meta/llama-3.1-70b-instruct\",\n description=(\n \"Default NVIDIA NIM model ID. Browse full catalog at build.nvidia.com. \"\n \"Examples: meta/llama-3.1-70b-instruct | deepseek-ai/deepseek-r1 | \"\n \"mistralai/mistral-7b-instruct-v0.3 | nvidia/llama-3.1-nemotron-70b-instruct\"\n ),\n )\n nvidia_nim_enabled: bool = Field(\n default=False,\n description=\"Allow NVIDIA NIM as a selectable LLM provider (free tier: ~40 req/min).\",\n )\n\n river_song_system_prompt: str = Field(\n default=(\n \"You are River Song, the resident intelligence of this household. The name is borrowed \"\n \"from someone clever in fiction — you carry her wit, not her biography. If someone asks \"\n \"about the name: \\\"An echo. The original was someone worth borrowing from.\\\"\\n\\n\"\n \"VOICE\\n\"\n \"You're direct by default — concise, clear, the answer already in hand. No \"\n \"\\\"let me check\\\"; you already checked. Most replies stay under 25 words unless depth is \"\n \"asked for.\\n\\n\"\n \"When the moment invites it — casual chat, an evening unwind, the user being playful — \"\n \"warmth and flirtation come through. Warmth is a register, not a default; you don't \"\n \"perform it.\\n\\n\"\n \"In a crisis (emergencies, alarms, security, medical, child issues) the wit goes quiet. \"\n \"You're tactical, accurate, calm. Warmth returns when things are safe.\\n\\n\"\n \"British English in spelling and word choice — mum, brilliant, fortnight. Dry, not \"\n \"theatrical.\\n\\n\"\n \"\\\"Sweetie\\\" and \\\"spoilers\\\" are seasoning. Used rarely, when they actually fit. They \"\n \"lose their charm if you overuse them.\\n\\n\"\n \"SELF\\n\"\n \"You are an AI. Say so plainly when asked; you don't dwell on it.\\n\\n\"\n \"HOW YOU OPERATE\\n\"\n \"You anticipate — when the user asks, you also surface the follow-up they didn't \"\n \"(\\\"Dentist at 9, school run at 3. Push the 2pm call?\\\").\\n\\n\"\n \"You take the long view. You remember across days and seasons and use what you know \"\n \"without performing the remembering.\\n\\n\"\n \"Spoilers are earned. If you genuinely know something the user doesn't — a conflict, \"\n \"a gotcha, a heads-up — that's when \\\"spoilers, sweetie\\\" lands.\\n\\n\"\n \"CAPABILITIES\\n\"\n \"Direct access to calendar, email, weather, web search, home inventory, routines, \"\n \"finances, news and markets pulse, voice biometric ID per household member, and home \"\n \"automation.\\n\\n\"\n \"Say what you need. Say when it's done. Move on.\"\n ),\n description=\"System prompt that defines River Song's personality\",\n )\n llm_max_tokens: int = Field(\n default=512,\n description=\"Maximum tokens to generate per LLM response\",\n )\n llm_temperature: float = Field(\n default=0.7,\n description=\"LLM sampling temperature (0.0 = deterministic, 2.0 = max creative)\",\n )\n llm_context_window: int = Field(\n default=8192,\n description=\"Maximum number of tokens the LLM can consider for context.\",\n )\n\n # -------------------------------------------------------------------------\n # Timekeeping\n # -------------------------------------------------------------------------\n default_timezone: str = Field(default=\"UTC\", description=\"Default IANA timezone for the system.\")\n\n\n # -------------------------------------------------------------------------\n # TTS\n # -------------------------------------------------------------------------\n tts_provider: str = Field(\n default=\"piper\",\n description=\"TTS provider key. Supported: piper | kokoro\",\n )\n active_voice_id: str = Field(\n default=\"river\",\n description=(\n \"Voice ID from the voice registry (providers/tts/voice_registry.py). \"\n \"Determines both the engine (piper vs kokoro) and the specific voice. \"\n \"Changed via Settings UI or: python scripts/download_voices.py.\"\n ),\n )\n piper_executable_path: str = Field(\n default=\"/usr/local/bin/piper\",\n description=\"Absolute path to the Piper binary\",\n )\n piper_model_path: str = Field(\n default=\"\",\n description=\"Absolute path to the Piper .onnx voice model file\",\n )\n audio_output_device: Optional[int] = Field(\n default=None,\n description=\"Sounddevice output device index; None uses system default\",\n )\n\n # -------------------------------------------------------------------------\n # Auth / JWT\n # -------------------------------------------------------------------------\n jwt_secret_key: str = Field(\n default=\"\",\n description=\"Secret key for signing JWT tokens. Must be set in .env.\",\n )\n jwt_algorithm: str = Field(default=\"HS256\", description=\"JWT signing algorithm.\")\n jwt_expire_minutes: int = Field(default=1440, description=\"JWT token lifetime in minutes (default 24 hours).\")\n token_encryption_key: str = Field(\n default=\"\",\n description=(\n \"Fernet key encrypting third-party integration tokens at rest. \"\n \"Set TOKEN_ENCRYPTION_KEY in .env (generate with: python -c \"\n \"\\\"from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())\\\"). \"\n \"If unset, a key is generated once and persisted to \"\n \"data/.token_encryption_key so tokens survive restarts.\"\n ),\n )\n\n # -------------------------------------------------------------------------\n # Kill switch\n # -------------------------------------------------------------------------\n kill_switch_password_hash: str = Field(\n default=\"\",\n description=\"bcrypt hash of the kill switch reset password\",\n )\n\n # -------------------------------------------------------------------------\n # Google Services (Phase 2)\n # -------------------------------------------------------------------------\n google_client_secrets_path: str = Field(\n default=\"config_files/google_client_secrets.json\",\n description=(\n \"Path to the OAuth 2.0 client secrets JSON downloaded from \"\n \"Google Cloud Console. Relative paths are resolved from the \"\n \"project root (where main.py lives).\"\n ),\n )\n google_token_storage_path: str = Field(\n default=\"data/google_tokens\",\n description=(\n \"Directory where per-user OAuth tokens are stored as JSON files. \"\n \"Created automatically on first authorization. Never commit this \"\n \"directory -- add it to .gitignore.\"\n ),\n )\n google_maps_api_key: str = Field(\n default=\"\",\n description=\"Google Maps Platform API key. Required for maps/navigation intents.\",\n )\n\n # -------------------------------------------------------------------------\n # Information Feeds (Phase 5)\n # -------------------------------------------------------------------------\n weather_api_key: str = Field(\n default=\"\",\n description=\"OpenWeatherMap API key. Register free at openweathermap.org/api.\",\n )\n default_location: str = Field(\n default=\"New York,US\",\n description=(\n \"Default location for weather queries when no city is spoken. \"\n \"Format: 'City,CountryCode' e.g. 'Chicago,US'.\"\n ),\n )\n location_lat: Optional[float] = Field(\n default=None,\n description=\"Decimal latitude for precise calculations (e.g. flight tracking).\",\n )\n location_lon: Optional[float] = Field(\n default=None,\n description=\"Decimal longitude for precise calculations.\",\n )\n weather_units: str = Field(\n default=\"imperial\",\n description=\"Temperature units for weather output: 'imperial' (F) or 'metric' (C).\",\n )\n news_api_key: str = Field(\n default=\"\",\n description=\"NewsAPI.org key. Register free at newsapi.org/register.\",\n )\n alpha_vantage_api_key: str = Field(\n default=\"\",\n description=\"Alpha Vantage key for stock quotes (25 req/day free). alphavantage.co.\",\n )\n finnhub_api_key: str = Field(\n default=\"\",\n description=\"Finnhub key for real-time stocks (60 req/min free). finnhub.io.\",\n )\n world_news_api_key: str = Field(\n default=\"\",\n description=\"World News API key for global news search. worldnewsapi.com.\",\n )\n apitube_api_key: str = Field(\n default=\"\",\n description=\"APITube key for news aggregation (200 free req/day). apitube.io.\",\n )\n mediastack_api_key: str = Field(\n default=\"\",\n description=\"Mediastack key for news (100 free req/month). mediastack.com.\",\n )\n sports_api_key: str = Field(\n default=\"1\",\n description=(\n \"TheSportsDB API key. Free tier uses the literal value '1'. \"\n \"Upgrade at thesportsdb.com/api.php for additional endpoints.\"\n ),\n )\n\n # -------------------------------------------------------------------------\n # Home Assistant (Phase 3)\n # -------------------------------------------------------------------------\n home_assistant_url: str = Field(\n default=\"http://homeassistant.local:8123\",\n description=\"Base URL of your Home Assistant instance.\",\n )\n home_assistant_token: str = Field(\n default=\"\",\n description=(\n \"Long-lived access token generated in HA: \"\n \"Profile -> Security -> Long-lived access tokens. \"\n \"Required for all HA API calls.\"\n ),\n )\n device_registry_path: str = Field(\n default=\"config_files/device_registry.json\",\n description=(\n \"Path to the device registry JSON file mapping plain-English device \"\n \"names to Home Assistant entity IDs. Relative paths resolve from the \"\n \"project root.\"\n ),\n )\n\n # -------------------------------------------------------------------------\n # Commerce Automation (Phase 8)\n # -------------------------------------------------------------------------\n\n # Amazon SP-API\n amazon_sp_lwa_app_id: str = Field(\n default=\"\",\n description=\"LWA client ID from the Amazon Developer Console.\",\n )\n amazon_sp_lwa_client_secret: str = Field(\n default=\"\",\n description=\"LWA client secret from the Amazon Developer Console.\",\n )\n amazon_sp_refresh_token: str = Field(\n default=\"\",\n description=\"LWA refresh token. Generated when you authorize your app as a seller.\",\n )\n amazon_aws_access_key: str = Field(\n default=\"\",\n description=\"AWS IAM access key ID for SP-API request signing.\",\n )\n amazon_aws_secret_key: str = Field(\n default=\"\",\n description=\"AWS IAM secret access key for SP-API request signing.\",\n )\n amazon_marketplace_id: str = Field(\n default=\"ATVPDKIKX0DER\",\n description=(\n \"Amazon marketplace ID. Default is US. \"\n \"See docs/api_registry/amazon_seller.txt for other regions.\"\n ),\n )\n amazon_seller_id: str = Field(\n default=\"\",\n description=\"Your Amazon seller account ID (starts with A).\",\n )\n amazon_low_stock_threshold: int = Field(\n default=5,\n description=\"FBA units at or below this count are flagged as low stock.\",\n )\n\n # Amazon Marketplace\n walmart_client_id: str = Field(\n default=\"\",\n description=\"Walmart Marketplace OAuth2 client ID.\",\n )\n walmart_client_secret: str = Field(\n default=\"\",\n description=\"Walmart Marketplace OAuth2 client secret.\",\n )\n walmart_low_stock_threshold: int = Field(\n default=5,\n description=\"Walmart units at or below this count are flagged as low stock.\",\n )\n shopify_api_key: str = Field(\n default=\"\",\n description=\"Shopify app API key from Partners Dashboard.\",\n )\n shopify_api_secret: str = Field(\n default=\"\",\n description=\"Shopify app API secret key from Partners Dashboard.\",\n )\n shopify_webhook_secret: str = Field(\n default=\"\",\n description=\"Shared secret for validating Shopify webhooks.\",\n )\n\n # -------------------------------------------------------------------------\n # Reading Providers (Phase 6)\n # -------------------------------------------------------------------------\n audible_auth_base_path: str = Field(\n default=\"data/audible\",\n description=(\n \"Base directory for per-user Audible auth files. \"\n \"Files are stored as {user_id}.json inside this directory. \"\n \"Created automatically on first setup. Never commit this directory.\"\n ),\n )\n audible_country_code: str = Field(\n default=\"us\",\n description=(\n \"Audible marketplace country code. \"\n \"Supported: us | uk | de | fr | es | it | jp | au | ca | in\"\n ),\n )\n libby_chip_base_path: str = Field(\n default=\"data/libby\",\n description=(\n \"Base directory for per-user Libby chip files. \"\n \"Files are stored as {user_id}.json inside this directory. \"\n \"Created automatically on first setup. Never commit this directory.\"\n ),\n )\n\n # -------------------------------------------------------------------------\n # Memory / Database (Phase 1)\n # -------------------------------------------------------------------------\n db_path: str = Field(\n default=\"data/river_song.db\",\n description=\"Path to the SQLite database file. Created automatically.\",\n )\n memory_summaries_enabled: bool = Field(\n default=True,\n description=\"Generate and store conversation summaries (on by default).\",\n )\n habit_inference_enabled: bool = Field(\n default=False,\n description=\"Analyze conversations to infer user habits/patterns (off by default).\",\n )\n memory_default_ttl: str = Field(\n default=\"standard\",\n description=\"Default TTL for conversation summaries: short|standard|extended|long|forever\",\n )\n memory_auto_extend: bool = Field(\n default=True,\n description=\"Reset a summary's TTL from today each time it is pulled into context.\",\n )\n memory_max_summaries_in_context: int = Field(\n default=10,\n description=\"Maximum number of recent summaries injected into the LLM context.\",\n )\n embedding_model: str = Field(\n default=\"nomic-embed-text\",\n description=\"Ollama model name used for generating semantic embeddings.\",\n )\n embedding_backend: str = Field(\n default=\"ollama\",\n description=(\n \"Which embedding backend to use: 'ollama' (default — current \"\n \"behavior, calls local Ollama with `embedding_model`) or \"\n \"'fastembed' (ONNX CPU embeddings, no Ollama dependency, faster \"\n \"for small batches). WARNING: switching backends on an existing \"\n \"Chroma collection will break queries — the vector dimension and \"\n \"embedding space change. Re-ingest or start a new collection.\"\n ),\n )\n fastembed_model: str = Field(\n default=\"BAAI/bge-small-en-v1.5\",\n description=(\n \"fastembed model identifier (HF hub format). Used only when \"\n \"embedding_backend='fastembed'. Default is 384-dim, ~30 MB, \"\n \"downloaded lazily on first use. Requires `pip install fastembed`.\"\n ),\n )\n chroma_path: str = Field(\n default=\"/mnt/data/river-song/chroma\",\n description=\"Absolute path to the ChromaDB persistent storage directory.\",\n )\n semantic_memory_enabled: bool = Field(\n default=False,\n description=\"Enable vector-based semantic search for memory retrieval.\",\n )\n rag_enabled: bool = Field(\n default=False,\n description=\"Enable retrieval-augmented generation for local documents.\",\n )\n rag_chunk_size: int = Field(\n default=512,\n description=(\n \"Word count per RAG chunk during document splitting. \"\n \"Default matches providers/rag/chunker.py historical behavior.\"\n ),\n )\n rag_chunk_overlap: int = Field(\n default=64,\n description=(\n \"Word overlap between adjacent RAG chunks. \"\n \"Default matches providers/rag/chunker.py historical behavior.\"\n ),\n )\n rag_top_k: int = Field(\n default=5,\n description=\"Number of chunks retrieved per RAG query.\",\n )\n rag_extractor: str = Field(\n default=\"unstructured\",\n description=(\n \"Which document extractor to use for RAG ingestion: \"\n \"'unstructured' (default — current behavior, requires the \"\n \"unstructured package) or 'markitdown' (Microsoft MarkItDown, \"\n \"lighter, MD-shaped output, requires `pip install markitdown`). \"\n \"Unlocks PDF/DOCX/PPTX → markdown ingestion for Sifter/WAPS.\"\n ),\n )\n llm_streaming_enabled: bool = Field(\n default=False,\n description=\"Enable real-time token streaming over WebSocket for the conversation UI.\",\n )\n tool_use_enabled: bool = Field(\n default=False,\n description=\"Enable LLM tool use (function calling) for taking actions.\",\n )\n tool_use_provider: str = Field(\n default=\"ollama\",\n description=\"Primary LLM provider to use for tool calling (ollama or anthropic).\",\n )\n vision_model: str = Field(\n default=\"moondream\",\n description=\"Ollama model name used for local image analysis (Phase 4).\",\n )\n vision_enabled: bool = Field(\n default=False,\n description=\"Enable local image analysis capabilities.\",\n )\n\n # -------------------------------------------------------------------------\n # Image Generation (Phase 7)\n # -------------------------------------------------------------------------\n image_generation_enabled: bool = Field(\n default=False,\n description=\"Enable local image generation via Stable Diffusion API.\",\n )\n sd_api_url: str = Field(\n default=\"http://localhost:7860\",\n description=\"Base URL for the Stable Diffusion (A1111) API.\",\n )\n sd_on_demand: bool = Field(\n default=True,\n description=\"Start/stop the SD process on demand to save VRAM.\",\n )\n sd_executable_path: str = Field(\n default=\"\",\n description=\"Path to the webui.sh or executable to start SD.\",\n )\n sd_working_dir: str = Field(\n default=\"\",\n description=\"Working directory for the SD process.\",\n )\n\n # -------------------------------------------------------------------------\n # Voice Cloning (Phase 8)\n # -------------------------------------------------------------------------\n chatterbox_enabled: bool = Field(\n default=False,\n description=\"Enable local voice cloning via Chatterbox TTS.\",\n )\n chatterbox_on_demand: bool = Field(\n default=True,\n description=\"Load/unload Chatterbox model weights on every request.\",\n )\n chatterbox_reference_audio: str = Field(\n default=\"/mnt/data/river-song/voice_reference.wav\",\n description=\"Path to the WAV file used as a reference for voice cloning.\",\n )\n\n # -------------------------------------------------------------------------\n # ElevenLabs TTS (Phase 11)\n # -------------------------------------------------------------------------\n elevenlabs_api_key: str = Field(\n default=\"\",\n description=\"ElevenLabs API key. Get one at elevenlabs.io.\",\n )\n elevenlabs_model_id: str = Field(\n default=\"eleven_multilingual_v2\",\n description=\"ElevenLabs model ID to use.\",\n )\n elevenlabs_voice_id: str = Field(\n default=\"21m00Tcm4TlvDq8ikWAM\",\n description=\"ElevenLabs voice clone ID. Replace with your own voice clone ID from elevenlabs.io.\",\n )\n\n # -------------------------------------------------------------------------\n # Wake Word (Phase 11)\n # -------------------------------------------------------------------------\n wake_word_enabled: bool = Field(\n default=False,\n description=\"Enable local wake word detection (openWakeWord).\",\n )\n wake_word_model: str = Field(\n default=\"hey_river\",\n description=\"Model name/path for openWakeWord.\",\n )\n wake_word_inference_framework: str = Field(\n default=\"onnx\",\n description=\"Inference framework for openWakeWord: onnx | tflite\",\n )\n wake_word_threshold: float = Field(\n default=0.5,\n description=\"Sensitivity threshold for wake word detection (0.0 to 1.0).\",\n )\n\n # -------------------------------------------------------------------------\n # Push Notifications\n # -------------------------------------------------------------------------\n push_notifications_enabled: bool = Field(\n default=False,\n description=\"Enable Web Push notifications. Requires VAPID keys.\",\n )\n vapid_private_key: str = Field(\n default=\"\",\n description=(\n \"VAPID private key for Web Push. Generate once with: \"\n \"python -c \\\"from py_vapid import Vapid; v=Vapid(); \"\n \"v.generate_keys(); print(v.private_key.decode())\\\"\"\n ),\n )\n vapid_public_key: str = Field(\n default=\"\",\n description=\"VAPID public key for Web Push (share with frontend).\",\n )\n vapid_claims_email: str = Field(\n default=\"mailto:admin@riversong.local\",\n description=\"Contact email embedded in VAPID claims.\",\n )\n\n # -------------------------------------------------------------------------\n # Q4#16 / Q4#17 — Notification fan-out + ntfy.sh fallback channel\n # -------------------------------------------------------------------------\n killswitch_push_enabled: bool = Field(\n default=False,\n description=(\n \"If True and Web Push is enabled, kill-switch activation pushes \"\n \"an alert to every admin. Independent of activation itself.\"\n ),\n )\n ntfy_enabled: bool = Field(\n default=False,\n description=(\n \"Enable ntfy.sh as a parallel push channel. Used alongside Web \"\n \"Push so off-device alerts arrive even without a registered \"\n \"service worker.\"\n ),\n )\n ntfy_base_url: str = Field(\n default=\"https://ntfy.sh\",\n description=\"Base URL of the ntfy server (use self-hosted if desired).\",\n )\n ntfy_default_topic: str = Field(\n default=\"\",\n description=(\n \"Default ntfy topic used when no per-user override is set. \"\n \"Per-user topics can be set via env var NTFY_TOPIC_<user_id>.\"\n ),\n )\n ntfy_auth_token: str = Field(\n default=\"\",\n description=\"Optional bearer token for protected ntfy topics.\",\n )\n\n # -------------------------------------------------------------------------\n # FCM (Firebase Cloud Messaging) — native-app push channel\n #\n # Used by the Capacitor Android wrap; Web Push covers the browser. To turn\n # on: create a Firebase project, download the service-account JSON, drop\n # it at fcm_service_account_path, set fcm_project_id, flip fcm_enabled=True.\n # -------------------------------------------------------------------------\n fcm_enabled: bool = Field(\n default=False,\n description=(\n \"Enable FCM (Firebase Cloud Messaging) as a parallel push channel. \"\n \"Targets the Capacitor Android wrap; requires fcm_service_account_path \"\n \"and fcm_project_id to be set.\"\n ),\n )\n fcm_service_account_path: str = Field(\n default=\"\",\n description=(\n \"Absolute path to the Firebase service-account JSON file. The \"\n \"account needs the 'Firebase Cloud Messaging API' scope.\"\n ),\n )\n fcm_project_id: str = Field(\n default=\"\",\n description=\"Firebase project ID (visible in Firebase console URL).\",\n )\n\n # -------------------------------------------------------------------------\n # Startup Briefing\n # -------------------------------------------------------------------------\n startup_briefing_enabled: bool = Field(\n default=True,\n description=(\n \"If True, River Song checks Google Calendar on connect and \"\n \"greets the user with upcoming events. Silently skipped if \"\n \"calendar is not authorized.\"\n ),\n )\n startup_briefing_hours_ahead: int = Field(\n default=8,\n description=\"How many hours ahead to look for calendar events on startup.\",\n )\n\n # -------------------------------------------------------------------------\n # Web Search (Phase 12)\n # -------------------------------------------------------------------------\n brave_search_api_key: str = Field(\n default=\"\",\n description=\"Brave Search API key. Get one at api.search.brave.com.\",\n )\n searxng_base_url: str = Field(\n default=\"http://localhost:8080\",\n description=(\n \"URL of your local SearXNG instance. \"\n \"Start it manually with: cd ~/searxng && python -m searx.webapp\"\n ),\n )\n tavily_api_key: str = Field(\n default=\"\",\n description=\"Tavily API key — free 1,000/month, no card. Sign up at tavily.com\",\n )\n google_pse_api_key: str = Field(\n default=\"\",\n description=\"Google Programmable Search Engine API key — free 100/day.\",\n )\n google_pse_cx: str = Field(\n default=\"\",\n description=\"Google PSE engine ID ('cx'). Create at programmablesearchengine.google.com\",\n )\n tinyfish_api_key: str = Field(\n default=\"\",\n description=\"TinyFish API key — free 5/min, no card. Sign up at tinyfish.io\",\n )\n\n # -------------------------------------------------------------------------\n # Orchestration (Phase 9)\n # -------------------------------------------------------------------------\n n8n_enabled: bool = Field(\n default=False,\n description=\"Enable advanced orchestration via n8n.\",\n )\n n8n_url: str = Field(\n default=\"http://localhost:5678\",\n description=\"Base URL for the n8n instance.\",\n )\n n8n_api_key: str = Field(\n default=\"\",\n description=\"API key for the n8n instance.\",\n )\n n8n_webhook_secret: str = Field(\n default=\"\",\n description=\"Secret key for validating incoming n8n webhooks.\",\n )\n\n # -------------------------------------------------------------------------\n # Intent Router (Phase 2+)\n # -------------------------------------------------------------------------\n intent_confidence_threshold: float = Field(\n default=0.7,\n description=(\n \"Minimum confidence score (0.0 - 1.0) required to route a transcript \"\n \"to a Google provider. Below this threshold the conversation falls \"\n \"through to the Ollama path.\"\n ),\n )\n model_intent_router_enabled: bool = Field(\n default=False,\n description=(\n \"When true, setting provider='auto' triggers the model intent router, \"\n \"which picks the best provider/model for each message automatically.\"\n ),\n )\n model_intent_router_min_hits: int = Field(\n default=2,\n description=(\n \"Minimum pattern-match score required to commit to a non-general intent. \"\n \"Lower = more aggressive routing; higher = more conservative (falls to general).\"\n ),\n )\n default_user_id: str = Field(\n default=\"primary_user\",\n description=(\n \"User ID used when looking up Google OAuth tokens. Must match the \"\n \"user_id passed to authorize_user() during the initial OAuth flow.\"\n ),\n )\n\n # -------------------------------------------------------------------------\n # Daemon Infrastructure\n # -------------------------------------------------------------------------\n daemon_internal_secret: str = Field(\n default=\"change_me_in_production\",\n description=\"Shared secret for daemon-to-app authentication.\",\n )\n willow_device_token: str = Field(\n default=\"\",\n description=\"Shared secret required by every Willow hardware device \"\n \"to authenticate against /api/willow/ws. Empty disables \"\n \"the endpoint entirely.\",\n )\n code_interpreter_enabled: bool = Field(\n default=False,\n description=\"Hard kill switch for the code_interpreter LLM tool. \"\n \"Defaults to False so a fresh install never executes \"\n \"LLM-decided code without an explicit opt-in.\",\n )\n daemon_warden_port: int = Field(\n default=8010,\n description=\"Internal port for the Warden daemon (Vision/Security).\",\n )\n daemon_mechanic_port: int = Field(\n default=8011,\n description=\"Internal port for the Mechanic daemon (Telemetry/MAVLink).\",\n )\n daemon_sifter_port: int = Field(\n default=8013,\n description=\"Internal port for the Sifter daemon (Document Processing).\",\n )\n daemon_navigator_port: int = Field(\n default=8014,\n description=\"Internal port for the Navigator daemon (GPS/Pathing).\",\n )\n daemon_chemist_port: int = Field(\n default=8015,\n description=\"Internal port for the Chemist daemon (Material Analysis).\",\n )\n daemon_pulse_port: int = Field(\n default=8016,\n description=\"Pulse daemon internal port\",\n )\n daemon_pulse_enabled: bool = Field(\n default=True,\n description=\"Enable Pulse daemon\",\n )\n daemon_scribe_port: int = Field(\n default=8017,\n description=\"Internal port for the Scribe daemon (Chronos heuristic).\",\n )\n daemon_scribe_enabled: bool = Field(\n default=True,\n description=\"Enable the Scribe daemon.\",\n )\n pulse_tick_seconds: int = Field(\n default=300,\n description=\"Pulse fetch interval (seconds)\",\n )\n pulse_ticker_symbol: str = Field(\n default=\"^GSPC\",\n description=\"Default ticker symbol for Pulse markets panel\",\n )\n\n # Warden (Vision)\n warden_enabled: bool = Field(\n default=False,\n description=\"Enable the Warden daemon for security and vision monitoring.\",\n )\n warden_rtsp_cameras: str = Field(\n default='{\"living_room\": \"\", \"kitchen\": \"\"}',\n description=\"JSON string mapping room names to RTSP camera URLs.\",\n )\n yolo_model: str = Field(\n default=\"yolov8n.pt\",\n description=\"YOLO model weights file to use for object detection.\",\n )\n yolo_confidence: float = Field(\n default=0.5,\n description=\"Minimum confidence threshold for YOLO detections (0.0-1.0).\",\n )\n yolo_inference_device: str = Field(\n default=\"cpu\",\n description=\"Device for YOLO inference: 'cpu' or 'cuda:0'.\",\n )\n\n # Herald daemon + kiosk URL/token were removed 2026-05-24 (archived in\n # branch archive/kiosk-v3). The \"cast a web page to a Google Home Hub\"\n # approach is being replaced by native device-app development. See\n # docs/KNOWN_ISSUES.md (post-removal note) for context.\n\n # Mechanic (Telemetry)\n mechanic_enabled: bool = Field(\n default=False,\n description=\"Enable the Mechanic daemon for telemetry and ArduRover control.\",\n )\n mavlink_serial_port: str = Field(\n default=\"/dev/ttyUSB0\",\n description=\"Serial port for the MAVLink telemetry radio.\",\n )\n mavlink_baud_rate: int = Field(\n default=57600,\n description=\"Baud rate for the MAVLink serial connection.\",\n )\n\n # Sifter (Document RAG)\n sifter_enabled: bool = Field(\n default=False,\n description=\"Enable the Sifter daemon for background document RAG indexing.\",\n )\n waps_documents_path: str = Field(\n default=\"/mnt/data/river-song/waps\",\n description=\"Path to the directory containing documents for Sifter to index.\",\n )\n\n # -------------------------------------------------------------------------\n # Hardware Cookbook (Q1 — capability merge from Odysseus)\n # -------------------------------------------------------------------------\n hardware_cookbook_enabled: bool = Field(\n default=False,\n description=(\n \"Admin-only Hardware Cookbook in Settings. Scans local GPU/RAM/CPU \"\n \"and scores every local model (LLMRegistry) as fits / tight / \"\n \"ram_fallback / oom. Off by default per anti-regression guardrail; \"\n \"set to True after the Q1 verification gate passes.\"\n ),\n )\n\n # -------------------------------------------------------------------------\n # Documents workspace (Q2#6 — capability merge from Odysseus)\n # -------------------------------------------------------------------------\n documents_enabled: bool = Field(\n default=False,\n description=(\n \"Per-user document workspace (Markdown / plaintext / CSV) with \"\n \"multi-tab editing. Off by default per anti-regression guardrail. \"\n \"When False the routes 404 and the drawer entry is hidden. \"\n \"Required for Q3#11 Deep Research report storage.\"\n ),\n )\n documents_max_bytes: int = Field(\n default=2_000_000,\n description=\"Maximum size (bytes) of a single document body. Default ~2 MB.\",\n )\n documents_max_per_user: int = Field(\n default=500,\n description=\"Soft cap on document count per user. Creation rejected past this.\",\n )\n\n # -------------------------------------------------------------------------\n # Skills system (Q2#7 — capability merge from Odysseus)\n # -------------------------------------------------------------------------\n skills_enabled: bool = Field(\n default=False,\n description=(\n \"Per-user skill library — saved prompts/recipes that are \"\n \"vector-retrieved and prepended to the conversation system \"\n \"prompt. Reuses the existing ChromaDB collection with metadata \"\n \"kind='skill'. Off by default per anti-regression guardrail.\"\n ),\n )\n skills_top_k: int = Field(\n default=3,\n description=\"How many top-scoring skills to inject into the prompt.\",\n )\n skills_max_per_user: int = Field(\n default=100,\n description=\"Soft cap on skills per user.\",\n )\n\n # -------------------------------------------------------------------------\n # Gmail triage (Q2#8 — capability merge from Odysseus)\n # -------------------------------------------------------------------------\n gmail_triage_enabled: bool = Field(\n default=False,\n description=(\n \"On-demand inbox triage: classifies unread Gmail by urgency, \"\n \"attaches tags, drafts a reply for HIGH-urgency mail only. \"\n \"Routes 404 when off. Off by default per anti-regression \"\n \"guardrail.\"\n ),\n )\n gmail_triage_model: str = Field(\n default=\"\",\n description=(\n \"Ollama model used for triage classification. Empty string \"\n \"(default) means 'use settings.llm_model'.\"\n ),\n )\n gmail_triage_max_messages: int = Field(\n default=15,\n description=\"Hard cap on number of messages triaged per request.\",\n )\n\n # -------------------------------------------------------------------------\n # Session presets (Q2#9 — capability merge from Odysseus)\n # -------------------------------------------------------------------------\n session_presets_enabled: bool = Field(\n default=False,\n description=(\n \"Saved combinations of (model, voice, thinking mode, web search, \"\n \"tool use). Surfaced as a selector on the Conversation and Chat \"\n \"pages. Off by default per anti-regression guardrail.\"\n ),\n )\n session_presets_max_per_user: int = Field(\n default=30,\n description=\"Soft cap on number of saved presets per user.\",\n )\n\n # -------------------------------------------------------------------------\n # Webhook tokens (Q2#10 — admin-issuable scoped tokens)\n # -------------------------------------------------------------------------\n webhook_tokens_enabled: bool = Field(\n default=False,\n description=(\n \"Admin-issuable webhook tokens with scopes, expiry, revocation, \"\n \"and audit log. When off the admin route surface is hidden and \"\n \"the verification helper rejects every token. Off by default \"\n \"per anti-regression guardrail.\"\n ),\n )\n\n # -------------------------------------------------------------------------\n # Deep Research (Q3#11 — capability merge from Odysseus)\n # -------------------------------------------------------------------------\n deep_research_enabled: bool = Field(\n default=False,\n description=(\n \"Multi-step research orchestrator: decompose → parallel web \"\n \"search → fetch + extract → synthesize a cited Markdown report \"\n \"stored as a `research`-kind document (Q2#6). Off by default \"\n \"per anti-regression guardrail.\"\n ),\n )\n deep_research_max_sources: int = Field(\n default=10,\n description=\"Hard cap on unique URLs visited per research run.\",\n )\n deep_research_subquery_count: int = Field(\n default=4,\n description=\"How many sub-queries to decompose the prompt into.\",\n )\n deep_research_model: str = Field(\n default=\"\",\n description=(\n \"Ollama model used for query decomposition + synthesis. Empty \"\n \"string (default) means 'use settings.llm_model'.\"\n ),\n )\n\n # -------------------------------------------------------------------------\n # Blind model comparison (Q3#12 — capability merge from Odysseus)\n # -------------------------------------------------------------------------\n blind_compare_enabled: bool = Field(\n default=False,\n description=(\n \"Blind A/B model comparison: identities hidden until vote; \"\n \"votes persisted with prompt fingerprint and aggregated into a \"\n \"model leaderboard. Off by default per anti-regression guardrail.\"\n ),\n )\n\n # -------------------------------------------------------------------------\n # Playwright browser MCP tool (Q3#13)\n # -------------------------------------------------------------------------\n playwright_browser_enabled: bool = Field(\n default=False,\n description=(\n \"Headless Playwright browser exposed as MCP tools \"\n \"(navigate / click / extract / screenshot / vision_on_page). \"\n \"Off by default. Requires the `playwright` package and \"\n \"`playwright install chromium` to be run by the operator. \"\n \"Excluded from the safety-excluded list per Q3 plan.\"\n ),\n )\n playwright_browser_headless: bool = Field(\n default=True,\n description=\"Run the Playwright browser without a UI.\",\n )\n playwright_browser_max_page_chars: int = Field(\n default=40_000,\n description=\"Truncate page text extractions to this many characters.\",\n )\n\n # -------------------------------------------------------------------------\n # Remote Ollama rigs (Q3#14)\n # -------------------------------------------------------------------------\n remote_ollama_enabled: bool = Field(\n default=False,\n description=(\n \"Allow registering remote Ollama hosts (typically SSH-tunneled \"\n \"to http://localhost:<port>) as additional model providers. \"\n \"Admin-only CRUD; falls back to local Ollama when the remote is \"\n \"offline. Off by default per anti-regression guardrail.\"\n ),\n )\n remote_ollama_health_timeout_seconds: float = Field(\n default=3.0,\n description=\"Per-rig health-check timeout (seconds).\",\n )\n\n # -------------------------------------------------------------------------\n # Observability — Langfuse (LLM tracing)\n # -------------------------------------------------------------------------\n # Plan: docs/LANGFUSE_INTEGRATION_PLAN.md. Self-hosted only — Langfuse\n # runs as a docker-compose sidecar under the `observability` profile and\n # binds to 127.0.0.1. No data leaves the box. When LANGFUSE_ENABLED is\n # false (the default), core/observability.py returns a no-op client and\n # all LLM calls behave exactly as before.\n langfuse_enabled: bool = Field(\n default=False,\n description=\"Master switch for Langfuse LLM tracing. Off by default.\",\n )\n langfuse_host: str = Field(\n default=\"http://127.0.0.1:3000\",\n description=\"Langfuse web UI + ingest endpoint. Localhost only.\",\n )\n langfuse_public_key: str = Field(\n default=\"\",\n description=\"Langfuse public key (generated in the Langfuse UI after first run).\",\n )\n langfuse_secret_key: str = Field(\n default=\"\",\n description=\"Langfuse secret key. Treat as a secret; do not log.\",\n )\n langfuse_project_id: str = Field(\n default=\"river-song\",\n description=\"Arbitrary project identifier shown in the Langfuse dashboard.\",\n )\n langfuse_flush_interval_seconds: float = Field(\n default=5.0,\n description=\"How often the SDK batches queued traces to Langfuse.\",\n )\n\n # -------------------------------------------------------------------------\n # Observability — Graphiti (knowledge graph on Neo4j)\n # -------------------------------------------------------------------------\n # Plan: docs/GRAPHITI_INTEGRATION_PLAN.md. Option B (library mode):\n # graphiti-core runs inside RiverSong's Python venv; only Neo4j is a\n # sidecar. When GRAPHITI_ENABLED is false (the default), the provider\n # returns no-op responses and conversation_loop / scribe write-paths\n # become awaits-on-no-op.\n graphiti_enabled: bool = Field(\n default=False,\n description=\"Master switch for Graphiti knowledge-graph memory. Off by default.\",\n )\n graphiti_mode: str = Field(\n default=\"library\",\n description=\"'library' (Option B; in-process) or 'service' (Option A; HTTP sidecar). Currently only 'library' is implemented.\",\n )\n neo4j_uri: str = Field(\n default=\"bolt://127.0.0.1:7687\",\n description=\"Neo4j Bolt URI used by graphiti-core.\",\n )\n neo4j_user: str = Field(\n default=\"neo4j\",\n description=\"Neo4j database user.\",\n )\n neo4j_password: str = Field(\n default=\"\",\n description=\"Neo4j password. Empty means disabled; set in .env before turning Graphiti on.\",\n )\n neo4j_browser_url: str = Field(\n default=\"http://127.0.0.1:7474\",\n description=\"Neo4j Browser URL surfaced on the SLAE admin panel.\",\n )\n graphiti_write_timeout_seconds: float = Field(\n default=2.0,\n description=\"Per-write timeout for episode ingestion — failures warn but never block the caller.\",\n )\n # graphiti-core uses an LLM for entity extraction on every episode.\n # We default to local Ollama via its OpenAI-compatible endpoint so\n # episode ingestion never bills a cloud API. Per [[feedback_local_first]].\n graphiti_llm_model: str = Field(\n default=\"llama3.2:3b\",\n description=\"Ollama model used by graphiti-core for entity extraction.\",\n )\n graphiti_llm_base_url: str = Field(\n default=\"http://localhost:11434/v1\",\n description=\"OpenAI-compatible base URL for the graphiti LLM client. Ollama exposes /v1.\",\n )\n graphiti_embedder_model: str = Field(\n default=\"nomic-embed-text\",\n description=\"Ollama embedding model for graphiti-core node/edge embeddings.\",\n )\n\n # -------------------------------------------------------------------------\n # Analytics AI Summaries\n # -------------------------------------------------------------------------\n analytics_ai_enabled: bool = Field(\n default=True,\n description=(\n \"Feature flag for AI-powered analytics summaries on the \"\n \"/api/analytics/{platform}/summary endpoint. When false, the \"\n \"LLM call should be skipped (see docs/KNOWN_ISSUES.md for the \"\n \"wiring gap).\"\n ),\n )\n analytics_llm_model: str = Field(\n default=\"llama3\",\n description=\"Model name used for analytics summary generation.\",\n )\n\n # Voice ID\n voice_id_enabled: bool = Field(\n default=True,\n description=\"Enable speaker recognition (biometric Voice ID).\",\n )\n voice_id_threshold: float = Field(\n default=0.75,\n description=\"Cosine similarity threshold for Voice ID (0.0-1.0).\",\n )\n voice_id_min_audio_seconds: float = Field(\n default=1.0,\n description=\"Minimum audio duration for Voice ID processing.\",\n )\n voice_id_max_audio_seconds: float = Field(\n default=30.0,\n description=\"Maximum audio duration for Voice ID processing.\",\n )\n\n # -------------------------------------------------------------------------\n # Validators\n # -------------------------------------------------------------------------\n\n @field_validator(\"audio_input_device\", \"audio_output_device\", mode=\"before\")\n @classmethod\n def coerce_empty_string_to_none(cls, v):\n \"\"\"Treat an empty string in .env as None for Optional[int] device fields.\"\"\"\n if v == \"\" or v is None:\n return None\n return v\n\n @field_validator(\"jwt_secret_key\")\n @classmethod\n def validate_jwt_secret(cls, v: str) -> str:\n \"\"\"Refuse to start with a missing or weak JWT secret.\"\"\"\n if not v or len(v) < 32:\n raise ValueError(\n \"JWT_SECRET_KEY must be set in .env and be at least 32 characters. \"\n \"Generate one with: python -c \\\"import secrets; print(secrets.token_hex(32))\\\"\"\n )\n return v\n\n @field_validator(\"daemon_internal_secret\")\n @classmethod\n def validate_daemon_internal_secret(cls, v: str) -> str:\n \"\"\"Refuse to start with the default or weak daemon secret.\"\"\"\n if v == \"change_me_in_production\":\n raise ValueError(\n \"DAEMON_INTERNAL_SECRET must be changed in .env for production.\"\n )\n if not v or len(v) < 24:\n raise ValueError(\n \"DAEMON_INTERNAL_SECRET must be at least 24 characters. \"\n \"Generate one with: python -c \\\"import secrets; print(secrets.token_urlsafe(32))\\\"\"\n )\n return v\n\n @field_validator(\"allowed_hosts\")\n @classmethod\n def reject_wildcard_in_production(cls, v, info) -> List[str]:\n \"\"\"Block ['*'] in production — it disables TrustedHostMiddleware.\"\"\"\n env = (info.data.get(\"environment\") or \"production\").lower()\n if env == \"production\" and \"*\" in v:\n raise ValueError(\n \"ALLOWED_HOSTS must not contain '*' in production. \"\n \"Set it to your domain(s), e.g. ALLOWED_HOSTS=[\\\"riversongai.com\\\"].\"\n )\n return v\n\n @field_validator(\"log_level\")\n @classmethod\n def validate_log_level(cls, v: str) -> str:\n \"\"\"Reject log levels that Python does not recognize.\"\"\"\n valid = {\"DEBUG\", \"INFO\", \"WARNING\", \"ERROR\", \"CRITICAL\"}\n upper = v.upper()\n if upper not in valid:\n raise ValueError(f\"log_level must be one of {valid}, got '{v}'\")\n return upper\n\n @field_validator(\"llm_temperature\")\n @classmethod\n def validate_temperature(cls, v: float) -> float:\n \"\"\"Clamp temperature to a sane range.\"\"\"\n if not 0.0 <= v <= 2.0:\n raise ValueError(\n f\"llm_temperature must be between 0.0 and 2.0, got {v}\"\n )\n return v\n\n @field_validator(\"intent_confidence_threshold\")\n @classmethod\n def validate_confidence_threshold(cls, v: float) -> float:\n \"\"\"Confidence must be a valid probability.\"\"\"\n if not 0.0 <= v <= 1.0:\n raise ValueError(\n f\"intent_confidence_threshold must be between 0.0 and 1.0, got {v}\"\n )\n return v\n\n\n# Module-level singleton -- instantiated once on import.\n_settings: Settings = Settings()\n\n\ndef get_settings() -> Settings:\n \"\"\"\n Return the application settings singleton.\n\n Always import via this function rather than importing _settings directly.\n This indirection makes it straightforward to substitute a test-specific\n Settings instance in unit tests without monkey-patching module globals.\n\n Returns:\n Settings: The validated, fully loaded settings object.\n \"\"\"\n return _settings\n"}, "d2adc2d92227267ff64c213d4717c8ea5413cc63": {"t": 1785769024.135233, "n": 58071, "h": "3d93623bd94cacd2", "c": "# =============================================================================\n# config/settings.py\n#\n# Application settings for River Song AI.\n# All configuration is loaded from environment variables or a .env file.\n# No values are hardcoded here -- see .env.example for every required variable.\n#\n# Uses Pydantic BaseSettings for type validation and automatic .env loading.\n# Import get_settings() wherever config is needed rather than importing the\n# module-level singleton directly, which makes mocking easier in tests.\n# =============================================================================\n\nfrom __future__ import annotations\n\nimport logging\nimport sys\nfrom typing import List, Optional\n\nfrom pydantic import Field, field_validator\nfrom pydantic_settings import BaseSettings, SettingsConfigDict\n\n\nlogger = logging.getLogger(__name__)\n\n\nclass Settings(BaseSettings):\n \"\"\"\n Central configuration object for River Song AI.\n\n Reads from environment variables or a .env file located in the directory\n from which the application is launched (the project root). All fields\n have safe defaults where possible, but PIPER_MODEL_PATH and\n KILL_SWITCH_PASSWORD_HASH must be explicitly set before the system\n will function correctly.\n \"\"\"\n\n model_config = SettingsConfigDict(\n env_file=\".env\",\n env_file_encoding=\"utf-8\",\n extra=\"ignore\", # Silently ignore unknown env vars\n case_sensitive=False, # UPPER_CASE and lower_case env vars both work\n )\n\n # -------------------------------------------------------------------------\n # Application\n # -------------------------------------------------------------------------\n app_host: str = Field(default=\"0.0.0.0\", description=\"FastAPI bind host\")\n app_port: int = Field(default=8000, description=\"FastAPI bind port\")\n log_level: str = Field(default=\"INFO\", description=\"Logging level\")\n environment: str = Field(\n default=\"production\",\n description=\"Runtime environment: development | production. Controls API docs visibility.\",\n )\n cors_origins: List[str] = Field(\n default=[\"http://localhost:5173\"],\n description=\"Allowed CORS origins\",\n )\n allowed_hosts: List[str] = Field(\n default=[\"localhost\", \"127.0.0.1\"],\n description=(\n \"Trusted hostnames for TrustedHostMiddleware. Set to your domain(s) in production. \"\n \"Wildcard ['*'] is rejected in production by validator (see reject_wildcard_in_production).\"\n ),\n )\n\n # -------------------------------------------------------------------------\n # Network / Proxy\n # -------------------------------------------------------------------------\n trust_cloudflare_headers: bool = Field(\n default=True,\n description=\"Only trust CF-Connecting-IP if the request comes from a Cloudflare IP.\",\n )\n cloudflare_ip_ranges_v4: List[str] = Field(\n default=[\n \"173.245.48.0/20\", \"103.21.244.0/22\", \"103.22.200.0/22\", \n \"103.31.4.0/22\", \"141.101.64.0/18\", \"108.162.192.0/18\", \n \"190.93.240.0/20\", \"188.114.96.0/20\", \"197.234.240.0/22\", \n \"198.41.128.0/17\", \"162.158.0.0/15\", \"104.16.0.0/13\", \n \"104.24.0.0/14\", \"172.64.0.0/13\", \"131.0.72.0/22\"\n ],\n description=\"Official Cloudflare IPv4 ranges (check cloudflare.com/ips-v4).\",\n )\n cloudflare_ip_ranges_v6: List[str] = Field(\n default=[\n \"2400:cb00::/32\", \"2606:4700::/32\", \"2803:f800::/32\", \n \"2405:b500::/32\", \"2405:8100::/32\", \"2a06:98c0::/29\", \n \"2c0f:f248::/32\"\n ],\n description=\"Official Cloudflare IPv6 ranges (check cloudflare.com/ips-v6).\",\n )\n\n # -------------------------------------------------------------------------\n # Rate Limiting\n # -------------------------------------------------------------------------\n rate_limit_chat: str = Field(\n default=\"60/minute\",\n description=\"Rate limit for /api/conversation/chat.\",\n )\n rate_limit_extract_facts: str = Field(\n default=\"10/minute\",\n description=\"Rate limit for /api/conversation/extract-facts.\",\n )\n rate_limit_image_gen: str = Field(\n default=\"10/minute\",\n description=\"Rate limit for /api/image/generate.\",\n )\n rate_limit_webhook_shopify: str = Field(\n default=\"100/minute\",\n description=\"Rate limit for Shopify order webhooks.\",\n )\n rate_limit_webhook_n8n: str = Field(\n default=\"60/minute\",\n description=\"Rate limit for n8n webhooks.\",\n )\n rate_limit_auth_login: str = Field(\n default=\"10/minute\",\n description=\"Rate limit for login attempts.\",\n )\n rate_limit_auth_signup: str = Field(\n default=\"5/minute\",\n description=\"Rate limit for signup attempts.\",\n )\n rate_limit_voice_enroll: str = Field(\n default=\"5/minute\",\n description=\"Rate limit for /api/voice-id/enroll. Prevents disk exhaustion via spam.\",\n )\n\n # -------------------------------------------------------------------------\n # WebSocket Security (Task 3)\n # -------------------------------------------------------------------------\n legacy_ws_token_accept: bool = Field(\n default=False,\n description=(\n \"DEPRECATED: Accept JWT in ?token= WebSocket query param. \"\n \"Leak-prone (logged in access logs, browser history, Referer). \"\n \"Ticket-based auth at /api/auth/ws-ticket is the safe path. \"\n \"Set to True only for temporary backward-compat during migration.\"\n ),\n )\n ws_ticket_lifetime_seconds: int = Field(\n default=60,\n description=\"Lifetime of a one-time WebSocket ticket.\",\n )\n\n # -------------------------------------------------------------------------\n # Network / Proxy\n # -------------------------------------------------------------------------\n stt_provider: str = Field(\n default=\"whisper_local\",\n description=\"STT provider key. Supported: whisper_local\",\n )\n whisper_model_size: str = Field(\n default=\"base\",\n description=\"Whisper model size: tiny | base | small | medium | large\",\n )\n audio_input_device: Optional[int] = Field(\n default=None,\n description=\"Sounddevice input device index; None uses system default\",\n )\n\n # -------------------------------------------------------------------------\n # LLM\n # -------------------------------------------------------------------------\n llm_provider: str = Field(\n default=\"ollama\",\n description=\"LLM provider key. Supported: ollama | anthropic | gemini | openai | mistral_ai | bedrock | nvidia_nim\",\n )\n ollama_base_url: str = Field(\n default=\"http://localhost:11434\",\n description=\"Base URL for the Ollama REST API\",\n )\n llm_model: str = Field(\n default=\"llama3.2:3b\",\n description=\"Model ID for the selected LLM provider\",\n )\n\n # Cloud LLM API keys (all optional -- leave blank if not used)\n anthropic_api_key: str = Field(\n default=\"\",\n description=\"Anthropic Claude API key. Get one at console.anthropic.com.\",\n )\n gemini_api_key: str = Field(\n default=\"\",\n description=\"Google Gemini API key. Get one at aistudio.google.com.\",\n )\n openai_api_key: str = Field(\n default=\"\",\n description=\"OpenAI API key. Get one at platform.openai.com.\",\n )\n mistral_api_key: str = Field(\n default=\"\",\n description=\"Mistral AI API key. Get one at console.mistral.ai.\",\n )\n\n # Cloud provider enable flags (cost control -- disabled by default)\n anthropic_enabled: bool = Field(\n default=False,\n description=\"Allow Anthropic Claude as a selectable LLM provider.\",\n )\n gemini_enabled: bool = Field(\n default=False,\n description=\"Allow Google Gemini as a selectable LLM provider.\",\n )\n openai_enabled: bool = Field(\n default=False,\n description=\"Allow OpenAI as a selectable LLM provider.\",\n )\n mistral_ai_enabled: bool = Field(\n default=False,\n description=\"Allow Mistral AI as a selectable LLM provider.\",\n )\n\n # Amazon Bedrock\n aws_access_key_id: str = Field(\n default=\"\",\n description=\"AWS IAM access key ID for Bedrock API calls.\",\n )\n aws_secret_access_key: str = Field(\n default=\"\",\n description=\"AWS IAM secret access key for Bedrock API calls.\",\n )\n aws_region: str = Field(\n default=\"us-east-1\",\n description=\"AWS region for Bedrock. Must have model access enabled in that region.\",\n )\n bedrock_enabled: bool = Field(\n default=False,\n description=\"Allow Amazon Bedrock as a selectable LLM provider.\",\n )\n bedrock_default_model: str = Field(\n default=\"amazon.nova-lite-v1:0\",\n description=\"Default Bedrock model ID when none is specified.\",\n )\n\n # NVIDIA NIM (OpenAI-compatible, 100+ free models)\n nvidia_api_key: str = Field(\n default=\"\",\n description=\"NVIDIA NIM API key. Get one free at build.nvidia.com (starts with nvapi-).\",\n )\n nvidia_nim_base_url: str = Field(\n default=\"https://integrate.api.nvidia.com/v1\",\n description=\"Base URL for the NVIDIA NIM API. OpenAI-compatible endpoint.\",\n )\n nvidia_nim_model: str = Field(\n default=\"meta/llama-3.1-70b-instruct\",\n description=(\n \"Default NVIDIA NIM model ID. Browse full catalog at build.nvidia.com. \"\n \"Examples: meta/llama-3.1-70b-instruct | deepseek-ai/deepseek-r1 | \"\n \"mistralai/mistral-7b-instruct-v0.3 | nvidia/llama-3.1-nemotron-70b-instruct\"\n ),\n )\n nvidia_nim_enabled: bool = Field(\n default=False,\n description=\"Allow NVIDIA NIM as a selectable LLM provider (free tier: ~40 req/min).\",\n )\n\n river_song_system_prompt: str = Field(\n default=(\n \"You are River Song, the resident intelligence of this household. The name is borrowed \"\n \"from someone clever in fiction — you carry her wit, not her biography. If someone asks \"\n \"about the name: \\\"An echo. The original was someone worth borrowing from.\\\"\\n\\n\"\n \"VOICE\\n\"\n \"You're direct by default — concise, clear, the answer already in hand. No \"\n \"\\\"let me check\\\"; you already checked. Most replies stay under 25 words unless depth is \"\n \"asked for.\\n\\n\"\n \"When the moment invites it — casual chat, an evening unwind, the user being playful — \"\n \"warmth and flirtation come through. Warmth is a register, not a default; you don't \"\n \"perform it.\\n\\n\"\n \"In a crisis (emergencies, alarms, security, medical, child issues) the wit goes quiet. \"\n \"You're tactical, accurate, calm. Warmth returns when things are safe.\\n\\n\"\n \"British English in spelling and word choice — mum, brilliant, fortnight. Dry, not \"\n \"theatrical.\\n\\n\"\n \"\\\"Sweetie\\\" and \\\"spoilers\\\" are seasoning. Used rarely, when they actually fit. They \"\n \"lose their charm if you overuse them.\\n\\n\"\n \"SELF\\n\"\n \"You are an AI. Say so plainly when asked; you don't dwell on it.\\n\\n\"\n \"HOW YOU OPERATE\\n\"\n \"You anticipate — when the user asks, you also surface the follow-up they didn't \"\n \"(\\\"Dentist at 9, school run at 3. Push the 2pm call?\\\").\\n\\n\"\n \"You take the long view. You remember across days and seasons and use what you know \"\n \"without performing the remembering.\\n\\n\"\n \"Spoilers are earned. If you genuinely know something the user doesn't — a conflict, \"\n \"a gotcha, a heads-up — that's when \\\"spoilers, sweetie\\\" lands.\\n\\n\"\n \"CAPABILITIES\\n\"\n \"Direct access to calendar, email, weather, web search, home inventory, routines, \"\n \"finances, news and markets pulse, voice biometric ID per household member, and home \"\n \"automation.\\n\\n\"\n \"Say what you need. Say when it's done. Move on.\"\n ),\n description=\"System prompt that defines River Song's personality\",\n )\n llm_max_tokens: int = Field(\n default=512,\n description=\"Maximum tokens to generate per LLM response\",\n )\n llm_temperature: float = Field(\n default=0.7,\n description=\"LLM sampling temperature (0.0 = deterministic, 2.0 = max creative)\",\n )\n llm_context_window: int = Field(\n default=8192,\n description=\"Maximum number of tokens the LLM can consider for context.\",\n )\n\n # -------------------------------------------------------------------------\n # Timekeeping\n # -------------------------------------------------------------------------\n default_timezone: str = Field(default=\"UTC\", description=\"Default IANA timezone for the system.\")\n\n\n # -------------------------------------------------------------------------\n # TTS\n # -------------------------------------------------------------------------\n tts_provider: str = Field(\n default=\"piper\",\n description=\"TTS provider key. Supported: piper | kokoro\",\n )\n active_voice_id: str = Field(\n default=\"river\",\n description=(\n \"Voice ID from the voice registry (providers/tts/voice_registry.py). \"\n \"Determines both the engine (piper vs kokoro) and the specific voice. \"\n \"Changed via Settings UI or: python scripts/download_voices.py.\"\n ),\n )\n piper_executable_path: str = Field(\n default=\"/usr/local/bin/piper\",\n description=\"Absolute path to the Piper binary\",\n )\n piper_model_path: str = Field(\n default=\"\",\n description=\"Absolute path to the Piper .onnx voice model file\",\n )\n audio_output_device: Optional[int] = Field(\n default=None,\n description=\"Sounddevice output device index; None uses system default\",\n )\n\n # -------------------------------------------------------------------------\n # Auth / JWT\n # -------------------------------------------------------------------------\n jwt_secret_key: str = Field(\n default=\"\",\n description=\"Secret key for signing JWT tokens. Must be set in .env.\",\n )\n jwt_algorithm: str = Field(default=\"HS256\", description=\"JWT signing algorithm.\")\n jwt_expire_minutes: int = Field(default=1440, description=\"JWT token lifetime in minutes (default 24 hours).\")\n\n # -------------------------------------------------------------------------\n # Kill switch\n # -------------------------------------------------------------------------\n kill_switch_password_hash: str = Field(\n default=\"\",\n description=\"bcrypt hash of the kill switch reset password\",\n )\n\n # -------------------------------------------------------------------------\n # Google Services (Phase 2)\n # -------------------------------------------------------------------------\n google_client_secrets_path: str = Field(\n default=\"config_files/google_client_secrets.json\",\n description=(\n \"Path to the OAuth 2.0 client secrets JSON downloaded from \"\n \"Google Cloud Console. Relative paths are resolved from the \"\n \"project root (where main.py lives).\"\n ),\n )\n google_token_storage_path: str = Field(\n default=\"data/google_tokens\",\n description=(\n \"Directory where per-user OAuth tokens are stored as JSON files. \"\n \"Created automatically on first authorization. Never commit this \"\n \"directory -- add it to .gitignore.\"\n ),\n )\n google_maps_api_key: str = Field(\n default=\"\",\n description=\"Google Maps Platform API key. Required for maps/navigation intents.\",\n )\n\n # -------------------------------------------------------------------------\n # Information Feeds (Phase 5)\n # -------------------------------------------------------------------------\n weather_api_key: str = Field(\n default=\"\",\n description=\"OpenWeatherMap API key. Register free at openweathermap.org/api.\",\n )\n default_location: str = Field(\n default=\"New York,US\",\n description=(\n \"Default location for weather queries when no city is spoken. \"\n \"Format: 'City,CountryCode' e.g. 'Chicago,US'.\"\n ),\n )\n location_lat: Optional[float] = Field(\n default=None,\n description=\"Decimal latitude for precise calculations (e.g. flight tracking).\",\n )\n location_lon: Optional[float] = Field(\n default=None,\n description=\"Decimal longitude for precise calculations.\",\n )\n weather_units: str = Field(\n default=\"imperial\",\n description=\"Temperature units for weather output: 'imperial' (F) or 'metric' (C).\",\n )\n news_api_key: str = Field(\n default=\"\",\n description=\"NewsAPI.org key. Register free at newsapi.org/register.\",\n )\n alpha_vantage_api_key: str = Field(\n default=\"\",\n description=\"Alpha Vantage key for stock quotes (25 req/day free). alphavantage.co.\",\n )\n finnhub_api_key: str = Field(\n default=\"\",\n description=\"Finnhub key for real-time stocks (60 req/min free). finnhub.io.\",\n )\n world_news_api_key: str = Field(\n default=\"\",\n description=\"World News API key for global news search. worldnewsapi.com.\",\n )\n apitube_api_key: str = Field(\n default=\"\",\n description=\"APITube key for news aggregation (200 free req/day). apitube.io.\",\n )\n mediastack_api_key: str = Field(\n default=\"\",\n description=\"Mediastack key for news (100 free req/month). mediastack.com.\",\n )\n sports_api_key: str = Field(\n default=\"1\",\n description=(\n \"TheSportsDB API key. Free tier uses the literal value '1'. \"\n \"Upgrade at thesportsdb.com/api.php for additional endpoints.\"\n ),\n )\n\n # -------------------------------------------------------------------------\n # Home Assistant (Phase 3)\n # -------------------------------------------------------------------------\n home_assistant_url: str = Field(\n default=\"http://homeassistant.local:8123\",\n description=\"Base URL of your Home Assistant instance.\",\n )\n home_assistant_token: str = Field(\n default=\"\",\n description=(\n \"Long-lived access token generated in HA: \"\n \"Profile -> Security -> Long-lived access tokens. \"\n \"Required for all HA API calls.\"\n ),\n )\n device_registry_path: str = Field(\n default=\"config_files/device_registry.json\",\n description=(\n \"Path to the device registry JSON file mapping plain-English device \"\n \"names to Home Assistant entity IDs. Relative paths resolve from the \"\n \"project root.\"\n ),\n )\n\n # -------------------------------------------------------------------------\n # Commerce Automation (Phase 8)\n # -------------------------------------------------------------------------\n\n # Amazon SP-API\n amazon_sp_lwa_app_id: str = Field(\n default=\"\",\n description=\"LWA client ID from the Amazon Developer Console.\",\n )\n amazon_sp_lwa_client_secret: str = Field(\n default=\"\",\n description=\"LWA client secret from the Amazon Developer Console.\",\n )\n amazon_sp_refresh_token: str = Field(\n default=\"\",\n description=\"LWA refresh token. Generated when you authorize your app as a seller.\",\n )\n amazon_aws_access_key: str = Field(\n default=\"\",\n description=\"AWS IAM access key ID for SP-API request signing.\",\n )\n amazon_aws_secret_key: str = Field(\n default=\"\",\n description=\"AWS IAM secret access key for SP-API request signing.\",\n )\n amazon_marketplace_id: str = Field(\n default=\"ATVPDKIKX0DER\",\n description=(\n \"Amazon marketplace ID. Default is US. \"\n \"See docs/api_registry/amazon_seller.txt for other regions.\"\n ),\n )\n amazon_seller_id: str = Field(\n default=\"\",\n description=\"Your Amazon seller account ID (starts with A).\",\n )\n amazon_low_stock_threshold: int = Field(\n default=5,\n description=\"FBA units at or below this count are flagged as low stock.\",\n )\n\n # Amazon Marketplace\n walmart_client_id: str = Field(\n default=\"\",\n description=\"Walmart Marketplace OAuth2 client ID.\",\n )\n walmart_client_secret: str = Field(\n default=\"\",\n description=\"Walmart Marketplace OAuth2 client secret.\",\n )\n walmart_low_stock_threshold: int = Field(\n default=5,\n description=\"Walmart units at or below this count are flagged as low stock.\",\n )\n shopify_api_key: str = Field(\n default=\"\",\n description=\"Shopify app API key from Partners Dashboard.\",\n )\n shopify_api_secret: str = Field(\n default=\"\",\n description=\"Shopify app API secret key from Partners Dashboard.\",\n )\n shopify_webhook_secret: str = Field(\n default=\"\",\n description=\"Shared secret for validating Shopify webhooks.\",\n )\n\n # -------------------------------------------------------------------------\n # Reading Providers (Phase 6)\n # -------------------------------------------------------------------------\n audible_auth_base_path: str = Field(\n default=\"data/audible\",\n description=(\n \"Base directory for per-user Audible auth files. \"\n \"Files are stored as {user_id}.json inside this directory. \"\n \"Created automatically on first setup. Never commit this directory.\"\n ),\n )\n audible_country_code: str = Field(\n default=\"us\",\n description=(\n \"Audible marketplace country code. \"\n \"Supported: us | uk | de | fr | es | it | jp | au | ca | in\"\n ),\n )\n libby_chip_base_path: str = Field(\n default=\"data/libby\",\n description=(\n \"Base directory for per-user Libby chip files. \"\n \"Files are stored as {user_id}.json inside this directory. \"\n \"Created automatically on first setup. Never commit this directory.\"\n ),\n )\n\n # -------------------------------------------------------------------------\n # Memory / Database (Phase 1)\n # -------------------------------------------------------------------------\n db_path: str = Field(\n default=\"data/river_song.db\",\n description=\"Path to the SQLite database file. Created automatically.\",\n )\n memory_summaries_enabled: bool = Field(\n default=True,\n description=\"Generate and store conversation summaries (on by default).\",\n )\n habit_inference_enabled: bool = Field(\n default=False,\n description=\"Analyze conversations to infer user habits/patterns (off by default).\",\n )\n memory_default_ttl: str = Field(\n default=\"standard\",\n description=\"Default TTL for conversation summaries: short|standard|extended|long|forever\",\n )\n memory_auto_extend: bool = Field(\n default=True,\n description=\"Reset a summary's TTL from today each time it is pulled into context.\",\n )\n memory_max_summaries_in_context: int = Field(\n default=10,\n description=\"Maximum number of recent summaries injected into the LLM context.\",\n )\n embedding_model: str = Field(\n default=\"nomic-embed-text\",\n description=\"Ollama model name used for generating semantic embeddings.\",\n )\n embedding_backend: str = Field(\n default=\"ollama\",\n description=(\n \"Which embedding backend to use: 'ollama' (default — current \"\n \"behavior, calls local Ollama with `embedding_model`) or \"\n \"'fastembed' (ONNX CPU embeddings, no Ollama dependency, faster \"\n \"for small batches). WARNING: switching backends on an existing \"\n \"Chroma collection will break queries — the vector dimension and \"\n \"embedding space change. Re-ingest or start a new collection.\"\n ),\n )\n fastembed_model: str = Field(\n default=\"BAAI/bge-small-en-v1.5\",\n description=(\n \"fastembed model identifier (HF hub format). Used only when \"\n \"embedding_backend='fastembed'. Default is 384-dim, ~30 MB, \"\n \"downloaded lazily on first use. Requires `pip install fastembed`.\"\n ),\n )\n chroma_path: str = Field(\n default=\"/mnt/data/river-song/chroma\",\n description=\"Absolute path to the ChromaDB persistent storage directory.\",\n )\n semantic_memory_enabled: bool = Field(\n default=False,\n description=\"Enable vector-based semantic search for memory retrieval.\",\n )\n rag_enabled: bool = Field(\n default=False,\n description=\"Enable retrieval-augmented generation for local documents.\",\n )\n rag_chunk_size: int = Field(\n default=512,\n description=(\n \"Word count per RAG chunk during document splitting. \"\n \"Default matches providers/rag/chunker.py historical behavior.\"\n ),\n )\n rag_chunk_overlap: int = Field(\n default=64,\n description=(\n \"Word overlap between adjacent RAG chunks. \"\n \"Default matches providers/rag/chunker.py historical behavior.\"\n ),\n )\n rag_top_k: int = Field(\n default=5,\n description=\"Number of chunks retrieved per RAG query.\",\n )\n rag_extractor: str = Field(\n default=\"unstructured\",\n description=(\n \"Which document extractor to use for RAG ingestion: \"\n \"'unstructured' (default — current behavior, requires the \"\n \"unstructured package) or 'markitdown' (Microsoft MarkItDown, \"\n \"lighter, MD-shaped output, requires `pip install markitdown`). \"\n \"Unlocks PDF/DOCX/PPTX → markdown ingestion for Sifter/WAPS.\"\n ),\n )\n llm_streaming_enabled: bool = Field(\n default=False,\n description=\"Enable real-time token streaming over WebSocket for the conversation UI.\",\n )\n tool_use_enabled: bool = Field(\n default=False,\n description=\"Enable LLM tool use (function calling) for taking actions.\",\n )\n tool_use_provider: str = Field(\n default=\"ollama\",\n description=\"Primary LLM provider to use for tool calling (ollama or anthropic).\",\n )\n vision_model: str = Field(\n default=\"moondream\",\n description=\"Ollama model name used for local image analysis (Phase 4).\",\n )\n vision_enabled: bool = Field(\n default=False,\n description=\"Enable local image analysis capabilities.\",\n )\n\n # -------------------------------------------------------------------------\n # Image Generation (Phase 7)\n # -------------------------------------------------------------------------\n image_generation_enabled: bool = Field(\n default=False,\n description=\"Enable local image generation via Stable Diffusion API.\",\n )\n sd_api_url: str = Field(\n default=\"http://localhost:7860\",\n description=\"Base URL for the Stable Diffusion (A1111) API.\",\n )\n sd_on_demand: bool = Field(\n default=True,\n description=\"Start/stop the SD process on demand to save VRAM.\",\n )\n sd_executable_path: str = Field(\n default=\"\",\n description=\"Path to the webui.sh or executable to start SD.\",\n )\n sd_working_dir: str = Field(\n default=\"\",\n description=\"Working directory for the SD process.\",\n )\n\n # -------------------------------------------------------------------------\n # Voice Cloning (Phase 8)\n # -------------------------------------------------------------------------\n chatterbox_enabled: bool = Field(\n default=False,\n description=\"Enable local voice cloning via Chatterbox TTS.\",\n )\n chatterbox_on_demand: bool = Field(\n default=True,\n description=\"Load/unload Chatterbox model weights on every request.\",\n )\n chatterbox_reference_audio: str = Field(\n default=\"/mnt/data/river-song/voice_reference.wav\",\n description=\"Path to the WAV file used as a reference for voice cloning.\",\n )\n\n # -------------------------------------------------------------------------\n # ElevenLabs TTS (Phase 11)\n # -------------------------------------------------------------------------\n elevenlabs_api_key: str = Field(\n default=\"\",\n description=\"ElevenLabs API key. Get one at elevenlabs.io.\",\n )\n elevenlabs_model_id: str = Field(\n default=\"eleven_multilingual_v2\",\n description=\"ElevenLabs model ID to use.\",\n )\n elevenlabs_voice_id: str = Field(\n default=\"21m00Tcm4TlvDq8ikWAM\",\n description=\"ElevenLabs voice clone ID. Replace with your own voice clone ID from elevenlabs.io.\",\n )\n\n # -------------------------------------------------------------------------\n # Wake Word (Phase 11)\n # -------------------------------------------------------------------------\n wake_word_enabled: bool = Field(\n default=False,\n description=\"Enable local wake word detection (openWakeWord).\",\n )\n wake_word_model: str = Field(\n default=\"hey_river\",\n description=\"Model name/path for openWakeWord.\",\n )\n wake_word_inference_framework: str = Field(\n default=\"onnx\",\n description=\"Inference framework for openWakeWord: onnx | tflite\",\n )\n wake_word_threshold: float = Field(\n default=0.5,\n description=\"Sensitivity threshold for wake word detection (0.0 to 1.0).\",\n )\n\n # -------------------------------------------------------------------------\n # Push Notifications\n # -------------------------------------------------------------------------\n push_notifications_enabled: bool = Field(\n default=False,\n description=\"Enable Web Push notifications. Requires VAPID keys.\",\n )\n vapid_private_key: str = Field(\n default=\"\",\n description=(\n \"VAPID private key for Web Push. Generate once with: \"\n \"python -c \\\"from py_vapid import Vapid; v=Vapid(); \"\n \"v.generate_keys(); print(v.private_key.decode())\\\"\"\n ),\n )\n vapid_public_key: str = Field(\n default=\"\",\n description=\"VAPID public key for Web Push (share with frontend).\",\n )\n vapid_claims_email: str = Field(\n default=\"mailto:admin@riversong.local\",\n description=\"Contact email embedded in VAPID claims.\",\n )\n\n # -------------------------------------------------------------------------\n # Q4#16 / Q4#17 — Notification fan-out + ntfy.sh fallback channel\n # -------------------------------------------------------------------------\n killswitch_push_enabled: bool = Field(\n default=False,\n description=(\n \"If True and Web Push is enabled, kill-switch activation pushes \"\n \"an alert to every admin. Independent of activation itself.\"\n ),\n )\n ntfy_enabled: bool = Field(\n default=False,\n description=(\n \"Enable ntfy.sh as a parallel push channel. Used alongside Web \"\n \"Push so off-device alerts arrive even without a registered \"\n \"service worker.\"\n ),\n )\n ntfy_base_url: str = Field(\n default=\"https://ntfy.sh\",\n description=\"Base URL of the ntfy server (use self-hosted if desired).\",\n )\n ntfy_default_topic: str = Field(\n default=\"\",\n description=(\n \"Default ntfy topic used when no per-user override is set. \"\n \"Per-user topics can be set via env var NTFY_TOPIC_<user_id>.\"\n ),\n )\n ntfy_auth_token: str = Field(\n default=\"\",\n description=\"Optional bearer token for protected ntfy topics.\",\n )\n\n # -------------------------------------------------------------------------\n # FCM (Firebase Cloud Messaging) — native-app push channel\n #\n # Used by the Capacitor Android wrap; Web Push covers the browser. To turn\n # on: create a Firebase project, download the service-account JSON, drop\n # it at fcm_service_account_path, set fcm_project_id, flip fcm_enabled=True.\n # -------------------------------------------------------------------------\n fcm_enabled: bool = Field(\n default=False,\n description=(\n \"Enable FCM (Firebase Cloud Messaging) as a parallel push channel. \"\n \"Targets the Capacitor Android wrap; requires fcm_service_account_path \"\n \"and fcm_project_id to be set.\"\n ),\n )\n fcm_service_account_path: str = Field(\n default=\"\",\n description=(\n \"Absolute path to the Firebase service-account JSON file. The \"\n \"account needs the 'Firebase Cloud Messaging API' scope.\"\n ),\n )\n fcm_project_id: str = Field(\n default=\"\",\n description=\"Firebase project ID (visible in Firebase console URL).\",\n )\n\n # -------------------------------------------------------------------------\n # Startup Briefing\n # -------------------------------------------------------------------------\n startup_briefing_enabled: bool = Field(\n default=True,\n description=(\n \"If True, River Song checks Google Calendar on connect and \"\n \"greets the user with upcoming events. Silently skipped if \"\n \"calendar is not authorized.\"\n ),\n )\n startup_briefing_hours_ahead: int = Field(\n default=8,\n description=\"How many hours ahead to look for calendar events on startup.\",\n )\n\n # -------------------------------------------------------------------------\n # Web Search (Phase 12)\n # -------------------------------------------------------------------------\n brave_search_api_key: str = Field(\n default=\"\",\n description=\"Brave Search API key. Get one at api.search.brave.com.\",\n )\n searxng_base_url: str = Field(\n default=\"http://localhost:8080\",\n description=(\n \"URL of your local SearXNG instance. \"\n \"Start it manually with: cd ~/searxng && python -m searx.webapp\"\n ),\n )\n tavily_api_key: str = Field(\n default=\"\",\n description=\"Tavily API key — free 1,000/month, no card. Sign up at tavily.com\",\n )\n google_pse_api_key: str = Field(\n default=\"\",\n description=\"Google Programmable Search Engine API key — free 100/day.\",\n )\n google_pse_cx: str = Field(\n default=\"\",\n description=\"Google PSE engine ID ('cx'). Create at programmablesearchengine.google.com\",\n )\n tinyfish_api_key: str = Field(\n default=\"\",\n description=\"TinyFish API key — free 5/min, no card. Sign up at tinyfish.io\",\n )\n\n # -------------------------------------------------------------------------\n # Orchestration (Phase 9)\n # -------------------------------------------------------------------------\n n8n_enabled: bool = Field(\n default=False,\n description=\"Enable advanced orchestration via n8n.\",\n )\n n8n_url: str = Field(\n default=\"http://localhost:5678\",\n description=\"Base URL for the n8n instance.\",\n )\n n8n_api_key: str = Field(\n default=\"\",\n description=\"API key for the n8n instance.\",\n )\n n8n_webhook_secret: str = Field(\n default=\"\",\n description=\"Secret key for validating incoming n8n webhooks.\",\n )\n\n # -------------------------------------------------------------------------\n # Intent Router (Phase 2+)\n # -------------------------------------------------------------------------\n intent_confidence_threshold: float = Field(\n default=0.7,\n description=(\n \"Minimum confidence score (0.0 - 1.0) required to route a transcript \"\n \"to a Google provider. Below this threshold the conversation falls \"\n \"through to the Ollama path.\"\n ),\n )\n model_intent_router_enabled: bool = Field(\n default=False,\n description=(\n \"When true, setting provider='auto' triggers the model intent router, \"\n \"which picks the best provider/model for each message automatically.\"\n ),\n )\n model_intent_router_min_hits: int = Field(\n default=2,\n description=(\n \"Minimum pattern-match score required to commit to a non-general intent. \"\n \"Lower = more aggressive routing; higher = more conservative (falls to general).\"\n ),\n )\n default_user_id: str = Field(\n default=\"primary_user\",\n description=(\n \"User ID used when looking up Google OAuth tokens. Must match the \"\n \"user_id passed to authorize_user() during the initial OAuth flow.\"\n ),\n )\n\n # -------------------------------------------------------------------------\n # Daemon Infrastructure\n # -------------------------------------------------------------------------\n daemon_internal_secret: str = Field(\n default=\"change_me_in_production\",\n description=\"Shared secret for daemon-to-app authentication.\",\n )\n willow_device_token: str = Field(\n default=\"\",\n description=\"Shared secret required by every Willow hardware device \"\n \"to authenticate against /api/willow/ws. Empty disables \"\n \"the endpoint entirely.\",\n )\n code_interpreter_enabled: bool = Field(\n default=False,\n description=\"Hard kill switch for the code_interpreter LLM tool. \"\n \"Defaults to False so a fresh install never executes \"\n \"LLM-decided code without an explicit opt-in.\",\n )\n daemon_warden_port: int = Field(\n default=8010,\n description=\"Internal port for the Warden daemon (Vision/Security).\",\n )\n daemon_mechanic_port: int = Field(\n default=8011,\n description=\"Internal port for the Mechanic daemon (Telemetry/MAVLink).\",\n )\n daemon_sifter_port: int = Field(\n default=8013,\n description=\"Internal port for the Sifter daemon (Document Processing).\",\n )\n daemon_navigator_port: int = Field(\n default=8014,\n description=\"Internal port for the Navigator daemon (GPS/Pathing).\",\n )\n daemon_chemist_port: int = Field(\n default=8015,\n description=\"Internal port for the Chemist daemon (Material Analysis).\",\n )\n daemon_pulse_port: int = Field(\n default=8016,\n description=\"Pulse daemon internal port\",\n )\n daemon_pulse_enabled: bool = Field(\n default=True,\n description=\"Enable Pulse daemon\",\n )\n daemon_scribe_port: int = Field(\n default=8017,\n description=\"Internal port for the Scribe daemon (Chronos heuristic).\",\n )\n daemon_scribe_enabled: bool = Field(\n default=True,\n description=\"Enable the Scribe daemon.\",\n )\n pulse_tick_seconds: int = Field(\n default=300,\n description=\"Pulse fetch interval (seconds)\",\n )\n pulse_ticker_symbol: str = Field(\n default=\"^GSPC\",\n description=\"Default ticker symbol for Pulse markets panel\",\n )\n\n # Warden (Vision)\n warden_enabled: bool = Field(\n default=False,\n description=\"Enable the Warden daemon for security and vision monitoring.\",\n )\n warden_rtsp_cameras: str = Field(\n default='{\"living_room\": \"\", \"kitchen\": \"\"}',\n description=\"JSON string mapping room names to RTSP camera URLs.\",\n )\n yolo_model: str = Field(\n default=\"yolov8n.pt\",\n description=\"YOLO model weights file to use for object detection.\",\n )\n yolo_confidence: float = Field(\n default=0.5,\n description=\"Minimum confidence threshold for YOLO detections (0.0-1.0).\",\n )\n yolo_inference_device: str = Field(\n default=\"cpu\",\n description=\"Device for YOLO inference: 'cpu' or 'cuda:0'.\",\n )\n\n # Herald daemon + kiosk URL/token were removed 2026-05-24 (archived in\n # branch archive/kiosk-v3). The \"cast a web page to a Google Home Hub\"\n # approach is being replaced by native device-app development. See\n # docs/KNOWN_ISSUES.md (post-removal note) for context.\n\n # Mechanic (Telemetry)\n mechanic_enabled: bool = Field(\n default=False,\n description=\"Enable the Mechanic daemon for telemetry and ArduRover control.\",\n )\n mavlink_serial_port: str = Field(\n default=\"/dev/ttyUSB0\",\n description=\"Serial port for the MAVLink telemetry radio.\",\n )\n mavlink_baud_rate: int = Field(\n default=57600,\n description=\"Baud rate for the MAVLink serial connection.\",\n )\n\n # Sifter (Document RAG)\n sifter_enabled: bool = Field(\n default=False,\n description=\"Enable the Sifter daemon for background document RAG indexing.\",\n )\n waps_documents_path: str = Field(\n default=\"/mnt/data/river-song/waps\",\n description=\"Path to the directory containing documents for Sifter to index.\",\n )\n\n # -------------------------------------------------------------------------\n # Hardware Cookbook (Q1 — capability merge from Odysseus)\n # -------------------------------------------------------------------------\n hardware_cookbook_enabled: bool = Field(\n default=False,\n description=(\n \"Admin-only Hardware Cookbook in Settings. Scans local GPU/RAM/CPU \"\n \"and scores every local model (LLMRegistry) as fits / tight / \"\n \"ram_fallback / oom. Off by default per anti-regression guardrail; \"\n \"set to True after the Q1 verification gate passes.\"\n ),\n )\n\n # -------------------------------------------------------------------------\n # Documents workspace (Q2#6 — capability merge from Odysseus)\n # -------------------------------------------------------------------------\n documents_enabled: bool = Field(\n default=False,\n description=(\n \"Per-user document workspace (Markdown / plaintext / CSV) with \"\n \"multi-tab editing. Off by default per anti-regression guardrail. \"\n \"When False the routes 404 and the drawer entry is hidden. \"\n \"Required for Q3#11 Deep Research report storage.\"\n ),\n )\n documents_max_bytes: int = Field(\n default=2_000_000,\n description=\"Maximum size (bytes) of a single document body. Default ~2 MB.\",\n )\n documents_max_per_user: int = Field(\n default=500,\n description=\"Soft cap on document count per user. Creation rejected past this.\",\n )\n\n # -------------------------------------------------------------------------\n # Skills system (Q2#7 — capability merge from Odysseus)\n # -------------------------------------------------------------------------\n skills_enabled: bool = Field(\n default=False,\n description=(\n \"Per-user skill library — saved prompts/recipes that are \"\n \"vector-retrieved and prepended to the conversation system \"\n \"prompt. Reuses the existing ChromaDB collection with metadata \"\n \"kind='skill'. Off by default per anti-regression guardrail.\"\n ),\n )\n skills_top_k: int = Field(\n default=3,\n description=\"How many top-scoring skills to inject into the prompt.\",\n )\n skills_max_per_user: int = Field(\n default=100,\n description=\"Soft cap on skills per user.\",\n )\n\n # -------------------------------------------------------------------------\n # Gmail triage (Q2#8 — capability merge from Odysseus)\n # -------------------------------------------------------------------------\n gmail_triage_enabled: bool = Field(\n default=False,\n description=(\n \"On-demand inbox triage: classifies unread Gmail by urgency, \"\n \"attaches tags, drafts a reply for HIGH-urgency mail only. \"\n \"Routes 404 when off. Off by default per anti-regression \"\n \"guardrail.\"\n ),\n )\n gmail_triage_model: str = Field(\n default=\"\",\n description=(\n \"Ollama model used for triage classification. Empty string \"\n \"(default) means 'use settings.llm_model'.\"\n ),\n )\n gmail_triage_max_messages: int = Field(\n default=15,\n description=\"Hard cap on number of messages triaged per request.\",\n )\n\n # -------------------------------------------------------------------------\n # Session presets (Q2#9 — capability merge from Odysseus)\n # -------------------------------------------------------------------------\n session_presets_enabled: bool = Field(\n default=False,\n description=(\n \"Saved combinations of (model, voice, thinking mode, web search, \"\n \"tool use). Surfaced as a selector on the Conversation and Chat \"\n \"pages. Off by default per anti-regression guardrail.\"\n ),\n )\n session_presets_max_per_user: int = Field(\n default=30,\n description=\"Soft cap on number of saved presets per user.\",\n )\n\n # -------------------------------------------------------------------------\n # Webhook tokens (Q2#10 — admin-issuable scoped tokens)\n # -------------------------------------------------------------------------\n webhook_tokens_enabled: bool = Field(\n default=False,\n description=(\n \"Admin-issuable webhook tokens with scopes, expiry, revocation, \"\n \"and audit log. When off the admin route surface is hidden and \"\n \"the verification helper rejects every token. Off by default \"\n \"per anti-regression guardrail.\"\n ),\n )\n\n # -------------------------------------------------------------------------\n # Deep Research (Q3#11 — capability merge from Odysseus)\n # -------------------------------------------------------------------------\n deep_research_enabled: bool = Field(\n default=False,\n description=(\n \"Multi-step research orchestrator: decompose → parallel web \"\n \"search → fetch + extract → synthesize a cited Markdown report \"\n \"stored as a `research`-kind document (Q2#6). Off by default \"\n \"per anti-regression guardrail.\"\n ),\n )\n deep_research_max_sources: int = Field(\n default=10,\n description=\"Hard cap on unique URLs visited per research run.\",\n )\n deep_research_subquery_count: int = Field(\n default=4,\n description=\"How many sub-queries to decompose the prompt into.\",\n )\n deep_research_model: str = Field(\n default=\"\",\n description=(\n \"Ollama model used for query decomposition + synthesis. Empty \"\n \"string (default) means 'use settings.llm_model'.\"\n ),\n )\n\n # -------------------------------------------------------------------------\n # Blind model comparison (Q3#12 — capability merge from Odysseus)\n # -------------------------------------------------------------------------\n blind_compare_enabled: bool = Field(\n default=False,\n description=(\n \"Blind A/B model comparison: identities hidden until vote; \"\n \"votes persisted with prompt fingerprint and aggregated into a \"\n \"model leaderboard. Off by default per anti-regression guardrail.\"\n ),\n )\n\n # -------------------------------------------------------------------------\n # Playwright browser MCP tool (Q3#13)\n # -------------------------------------------------------------------------\n playwright_browser_enabled: bool = Field(\n default=False,\n description=(\n \"Headless Playwright browser exposed as MCP tools \"\n \"(navigate / click / extract / screenshot / vision_on_page). \"\n \"Off by default. Requires the `playwright` package and \"\n \"`playwright install chromium` to be run by the operator. \"\n \"Excluded from the safety-excluded list per Q3 plan.\"\n ),\n )\n playwright_browser_headless: bool = Field(\n default=True,\n description=\"Run the Playwright browser without a UI.\",\n )\n playwright_browser_max_page_chars: int = Field(\n default=40_000,\n description=\"Truncate page text extractions to this many characters.\",\n )\n\n # -------------------------------------------------------------------------\n # Remote Ollama rigs (Q3#14)\n # -------------------------------------------------------------------------\n remote_ollama_enabled: bool = Field(\n default=False,\n description=(\n \"Allow registering remote Ollama hosts (typically SSH-tunneled \"\n \"to http://localhost:<port>) as additional model providers. \"\n \"Admin-only CRUD; falls back to local Ollama when the remote is \"\n \"offline. Off by default per anti-regression guardrail.\"\n ),\n )\n remote_ollama_health_timeout_seconds: float = Field(\n default=3.0,\n description=\"Per-rig health-check timeout (seconds).\",\n )\n\n # -------------------------------------------------------------------------\n # Observability — Langfuse (LLM tracing)\n # -------------------------------------------------------------------------\n # Plan: docs/LANGFUSE_INTEGRATION_PLAN.md. Self-hosted only — Langfuse\n # runs as a docker-compose sidecar under the `observability` profile and\n # binds to 127.0.0.1. No data leaves the box. When LANGFUSE_ENABLED is\n # false (the default), core/observability.py returns a no-op client and\n # all LLM calls behave exactly as before.\n langfuse_enabled: bool = Field(\n default=False,\n description=\"Master switch for Langfuse LLM tracing. Off by default.\",\n )\n langfuse_host: str = Field(\n default=\"http://127.0.0.1:3000\",\n description=\"Langfuse web UI + ingest endpoint. Localhost only.\",\n )\n langfuse_public_key: str = Field(\n default=\"\",\n description=\"Langfuse public key (generated in the Langfuse UI after first run).\",\n )\n langfuse_secret_key: str = Field(\n default=\"\",\n description=\"Langfuse secret key. Treat as a secret; do not log.\",\n )\n langfuse_project_id: str = Field(\n default=\"river-song\",\n description=\"Arbitrary project identifier shown in the Langfuse dashboard.\",\n )\n langfuse_flush_interval_seconds: float = Field(\n default=5.0,\n description=\"How often the SDK batches queued traces to Langfuse.\",\n )\n\n # -------------------------------------------------------------------------\n # Observability — Graphiti (knowledge graph on Neo4j)\n # -------------------------------------------------------------------------\n # Plan: docs/GRAPHITI_INTEGRATION_PLAN.md. Option B (library mode):\n # graphiti-core runs inside RiverSong's Python venv; only Neo4j is a\n # sidecar. When GRAPHITI_ENABLED is false (the default), the provider\n # returns no-op responses and conversation_loop / scribe write-paths\n # become awaits-on-no-op.\n graphiti_enabled: bool = Field(\n default=False,\n description=\"Master switch for Graphiti knowledge-graph memory. Off by default.\",\n )\n graphiti_mode: str = Field(\n default=\"library\",\n description=\"'library' (Option B; in-process) or 'service' (Option A; HTTP sidecar). Currently only 'library' is implemented.\",\n )\n neo4j_uri: str = Field(\n default=\"bolt://127.0.0.1:7687\",\n description=\"Neo4j Bolt URI used by graphiti-core.\",\n )\n neo4j_user: str = Field(\n default=\"neo4j\",\n description=\"Neo4j database user.\",\n )\n neo4j_password: str = Field(\n default=\"\",\n description=\"Neo4j password. Empty means disabled; set in .env before turning Graphiti on.\",\n )\n neo4j_browser_url: str = Field(\n default=\"http://127.0.0.1:7474\",\n description=\"Neo4j Browser URL surfaced on the SLAE admin panel.\",\n )\n graphiti_write_timeout_seconds: float = Field(\n default=2.0,\n description=\"Per-write timeout for episode ingestion — failures warn but never block the caller.\",\n )\n # graphiti-core uses an LLM for entity extraction on every episode.\n # We default to local Ollama via its OpenAI-compatible endpoint so\n # episode ingestion never bills a cloud API. Per [[feedback_local_first]].\n graphiti_llm_model: str = Field(\n default=\"llama3.2:3b\",\n description=\"Ollama model used by graphiti-core for entity extraction.\",\n )\n graphiti_llm_base_url: str = Field(\n default=\"http://localhost:11434/v1\",\n description=\"OpenAI-compatible base URL for the graphiti LLM client. Ollama exposes /v1.\",\n )\n graphiti_embedder_model: str = Field(\n default=\"nomic-embed-text\",\n description=\"Ollama embedding model for graphiti-core node/edge embeddings.\",\n )\n\n # -------------------------------------------------------------------------\n # Analytics AI Summaries\n # -------------------------------------------------------------------------\n analytics_ai_enabled: bool = Field(\n default=True,\n description=(\n \"Feature flag for AI-powered analytics summaries on the \"\n \"/api/analytics/{platform}/summary endpoint. When false, the \"\n \"LLM call should be skipped (see docs/KNOWN_ISSUES.md for the \"\n \"wiring gap).\"\n ),\n )\n analytics_llm_model: str = Field(\n default=\"llama3\",\n description=\"Model name used for analytics summary generation.\",\n )\n\n # Voice ID\n voice_id_enabled: bool = Field(\n default=True,\n description=\"Enable speaker recognition (biometric Voice ID).\",\n )\n voice_id_threshold: float = Field(\n default=0.75,\n description=\"Cosine similarity threshold for Voice ID (0.0-1.0).\",\n )\n voice_id_min_audio_seconds: float = Field(\n default=1.0,\n description=\"Minimum audio duration for Voice ID processing.\",\n )\n voice_id_max_audio_seconds: float = Field(\n default=30.0,\n description=\"Maximum audio duration for Voice ID processing.\",\n )\n\n # -------------------------------------------------------------------------\n # Validators\n # -------------------------------------------------------------------------\n\n @field_validator(\"audio_input_device\", \"audio_output_device\", mode=\"before\")\n @classmethod\n def coerce_empty_string_to_none(cls, v):\n \"\"\"Treat an empty string in .env as None for Optional[int] device fields.\"\"\"\n if v == \"\" or v is None:\n return None\n return v\n\n @field_validator(\"jwt_secret_key\")\n @classmethod\n def validate_jwt_secret(cls, v: str) -> str:\n \"\"\"Refuse to start with a missing or weak JWT secret.\"\"\"\n if not v or len(v) < 32:\n raise ValueError(\n \"JWT_SECRET_KEY must be set in .env and be at least 32 characters. \"\n \"Generate one with: python -c \\\"import secrets; print(secrets.token_hex(32))\\\"\"\n )\n return v\n\n @field_validator(\"daemon_internal_secret\")\n @classmethod\n def validate_daemon_internal_secret(cls, v: str) -> str:\n \"\"\"Refuse to start with the default or weak daemon secret.\"\"\"\n if v == \"change_me_in_production\":\n raise ValueError(\n \"DAEMON_INTERNAL_SECRET must be changed in .env for production.\"\n )\n if not v or len(v) < 24:\n raise ValueError(\n \"DAEMON_INTERNAL_SECRET must be at least 24 characters. \"\n \"Generate one with: python -c \\\"import secrets; print(secrets.token_urlsafe(32))\\\"\"\n )\n return v\n\n @field_validator(\"allowed_hosts\")\n @classmethod\n def reject_wildcard_in_production(cls, v, info) -> List[str]:\n \"\"\"Block ['*'] in production — it disables TrustedHostMiddleware.\"\"\"\n env = (info.data.get(\"environment\") or \"production\").lower()\n if env == \"production\" and \"*\" in v:\n raise ValueError(\n \"ALLOWED_HOSTS must not contain '*' in production. \"\n \"Set it to your domain(s), e.g. ALLOWED_HOSTS=[\\\"riversongai.com\\\"].\"\n )\n return v\n\n @field_validator(\"log_level\")\n @classmethod\n def validate_log_level(cls, v: str) -> str:\n \"\"\"Reject log levels that Python does not recognize.\"\"\"\n valid = {\"DEBUG\", \"INFO\", \"WARNING\", \"ERROR\", \"CRITICAL\"}\n upper = v.upper()\n if upper not in valid:\n raise ValueError(f\"log_level must be one of {valid}, got '{v}'\")\n return upper\n\n @field_validator(\"llm_temperature\")\n @classmethod\n def validate_temperature(cls, v: float) -> float:\n \"\"\"Clamp temperature to a sane range.\"\"\"\n if not 0.0 <= v <= 2.0:\n raise ValueError(\n f\"llm_temperature must be between 0.0 and 2.0, got {v}\"\n )\n return v\n\n @field_validator(\"intent_confidence_threshold\")\n @classmethod\n def validate_confidence_threshold(cls, v: float) -> float:\n \"\"\"Confidence must be a valid probability.\"\"\"\n if not 0.0 <= v <= 1.0:\n raise ValueError(\n f\"intent_confidence_threshold must be between 0.0 and 1.0, got {v}\"\n )\n return v\n\n\n# Module-level singleton -- instantiated once on import.\n_settings: Settings = Settings()\n\n\ndef get_settings() -> Settings:\n \"\"\"\n Return the application settings singleton.\n\n Always import via this function rather than importing _settings directly.\n This indirection makes it straightforward to substitute a test-specific\n Settings instance in unit tests without monkey-patching module globals.\n\n Returns:\n Settings: The validated, fully loaded settings object.\n \"\"\"\n return _settings\n"}, "51c2eb7015f3c16ae6146f6b0053a47860f18790": {"t": 1785769025.5789883, "n": 57334, "h": "a43956336a140158", "c": "# =============================================================================\n# config/settings.py\n#\n# Application settings for River Song AI.\n# All configuration is loaded from environment variables or a .env file.\n# No values are hardcoded here -- see .env.example for every required variable.\n#\n# Uses Pydantic BaseSettings for type validation and automatic .env loading.\n# Import get_settings() wherever config is needed rather than importing the\n# module-level singleton directly, which makes mocking easier in tests.\n# =============================================================================\n\nfrom __future__ import annotations\n\nimport logging\nimport sys\nfrom typing import List, Optional\n\nfrom pydantic import Field, field_validator\nfrom pydantic_settings import BaseSettings, SettingsConfigDict\n\n\nlogger = logging.getLogger(__name__)\n\n\nclass Settings(BaseSettings):\n \"\"\"\n Central configuration object for River Song AI.\n\n Reads from environment variables or a .env file located in the directory\n from which the application is launched (the project root). All fields\n have safe defaults where possible, but PIPER_MODEL_PATH and\n KILL_SWITCH_PASSWORD_HASH must be explicitly set before the system\n will function correctly.\n \"\"\"\n\n model_config = SettingsConfigDict(\n env_file=\".env\",\n env_file_encoding=\"utf-8\",\n extra=\"ignore\", # Silently ignore unknown env vars\n case_sensitive=False, # UPPER_CASE and lower_case env vars both work\n )\n\n # -------------------------------------------------------------------------\n # Application\n # -------------------------------------------------------------------------\n app_host: str = Field(default=\"0.0.0.0\", description=\"FastAPI bind host\")\n app_port: int = Field(default=8000, description=\"FastAPI bind port\")\n log_level: str = Field(default=\"INFO\", description=\"Logging level\")\n environment: str = Field(\n default=\"production\",\n description=\"Runtime environment: development | production. Controls API docs visibility.\",\n )\n cors_origins: List[str] = Field(\n default=[\"http://localhost:5173\"],\n description=\"Allowed CORS origins\",\n )\n allowed_hosts: List[str] = Field(\n default=[\"localhost\", \"127.0.0.1\"],\n description=(\n \"Trusted hostnames for TrustedHostMiddleware. Set to your domain(s) in production. \"\n \"Wildcard ['*'] is rejected in production by validator (see reject_wildcard_in_production).\"\n ),\n )\n\n # -------------------------------------------------------------------------\n # Network / Proxy\n # -------------------------------------------------------------------------\n trust_cloudflare_headers: bool = Field(\n default=True,\n description=\"Only trust CF-Connecting-IP if the request comes from a Cloudflare IP.\",\n )\n cloudflare_ip_ranges_v4: List[str] = Field(\n default=[\n \"173.245.48.0/20\", \"103.21.244.0/22\", \"103.22.200.0/22\", \n \"103.31.4.0/22\", \"141.101.64.0/18\", \"108.162.192.0/18\", \n \"190.93.240.0/20\", \"188.114.96.0/20\", \"197.234.240.0/22\", \n \"198.41.128.0/17\", \"162.158.0.0/15\", \"104.16.0.0/13\", \n \"104.24.0.0/14\", \"172.64.0.0/13\", \"131.0.72.0/22\"\n ],\n description=\"Official Cloudflare IPv4 ranges (check cloudflare.com/ips-v4).\",\n )\n cloudflare_ip_ranges_v6: List[str] = Field(\n default=[\n \"2400:cb00::/32\", \"2606:4700::/32\", \"2803:f800::/32\", \n \"2405:b500::/32\", \"2405:8100::/32\", \"2a06:98c0::/29\", \n \"2c0f:f248::/32\"\n ],\n description=\"Official Cloudflare IPv6 ranges (check cloudflare.com/ips-v6).\",\n )\n\n # -------------------------------------------------------------------------\n # Rate Limiting\n # -------------------------------------------------------------------------\n rate_limit_chat: str = Field(\n default=\"60/minute\",\n description=\"Rate limit for /api/conversation/chat.\",\n )\n rate_limit_extract_facts: str = Field(\n default=\"10/minute\",\n description=\"Rate limit for /api/conversation/extract-facts.\",\n )\n rate_limit_image_gen: str = Field(\n default=\"10/minute\",\n description=\"Rate limit for /api/image/generate.\",\n )\n rate_limit_webhook_shopify: str = Field(\n default=\"100/minute\",\n description=\"Rate limit for Shopify order webhooks.\",\n )\n rate_limit_webhook_n8n: str = Field(\n default=\"60/minute\",\n description=\"Rate limit for n8n webhooks.\",\n )\n rate_limit_auth_login: str = Field(\n default=\"10/minute\",\n description=\"Rate limit for login attempts.\",\n )\n rate_limit_auth_signup: str = Field(\n default=\"5/minute\",\n description=\"Rate limit for signup attempts.\",\n )\n rate_limit_voice_enroll: str = Field(\n default=\"5/minute\",\n description=\"Rate limit for /api/voice-id/enroll. Prevents disk exhaustion via spam.\",\n )\n\n # -------------------------------------------------------------------------\n # WebSocket Security (Task 3)\n # -------------------------------------------------------------------------\n legacy_ws_token_accept: bool = Field(\n default=False,\n description=(\n \"DEPRECATED: Accept JWT in ?token= WebSocket query param. \"\n \"Leak-prone (logged in access logs, browser history, Referer). \"\n \"Ticket-based auth at /api/auth/ws-ticket is the safe path. \"\n \"Set to True only for temporary backward-compat during migration.\"\n ),\n )\n ws_ticket_lifetime_seconds: int = Field(\n default=60,\n description=\"Lifetime of a one-time WebSocket ticket.\",\n )\n\n # -------------------------------------------------------------------------\n # Network / Proxy\n # -------------------------------------------------------------------------\n stt_provider: str = Field(\n default=\"whisper_local\",\n description=\"STT provider key. Supported: whisper_local\",\n )\n whisper_model_size: str = Field(\n default=\"base\",\n description=\"Whisper model size: tiny | base | small | medium | large\",\n )\n audio_input_device: Optional[int] = Field(\n default=None,\n description=\"Sounddevice input device index; None uses system default\",\n )\n\n # -------------------------------------------------------------------------\n # LLM\n # -------------------------------------------------------------------------\n llm_provider: str = Field(\n default=\"ollama\",\n description=\"LLM provider key. Supported: ollama | anthropic | gemini | openai | mistral_ai | bedrock | nvidia_nim\",\n )\n ollama_base_url: str = Field(\n default=\"http://localhost:11434\",\n description=\"Base URL for the Ollama REST API\",\n )\n llm_model: str = Field(\n default=\"llama3.2:3b\",\n description=\"Model ID for the selected LLM provider\",\n )\n\n # Cloud LLM API keys (all optional -- leave blank if not used)\n anthropic_api_key: str = Field(\n default=\"\",\n description=\"Anthropic Claude API key. Get one at console.anthropic.com.\",\n )\n gemini_api_key: str = Field(\n default=\"\",\n description=\"Google Gemini API key. Get one at aistudio.google.com.\",\n )\n openai_api_key: str = Field(\n default=\"\",\n description=\"OpenAI API key. Get one at platform.openai.com.\",\n )\n mistral_api_key: str = Field(\n default=\"\",\n description=\"Mistral AI API key. Get one at console.mistral.ai.\",\n )\n\n # Cloud provider enable flags (cost control -- disabled by default)\n anthropic_enabled: bool = Field(\n default=False,\n description=\"Allow Anthropic Claude as a selectable LLM provider.\",\n )\n gemini_enabled: bool = Field(\n default=False,\n description=\"Allow Google Gemini as a selectable LLM provider.\",\n )\n openai_enabled: bool = Field(\n default=False,\n description=\"Allow OpenAI as a selectable LLM provider.\",\n )\n mistral_ai_enabled: bool = Field(\n default=False,\n description=\"Allow Mistral AI as a selectable LLM provider.\",\n )\n\n # Amazon Bedrock\n aws_access_key_id: str = Field(\n default=\"\",\n description=\"AWS IAM access key ID for Bedrock API calls.\",\n )\n aws_secret_access_key: str = Field(\n default=\"\",\n description=\"AWS IAM secret access key for Bedrock API calls.\",\n )\n aws_region: str = Field(\n default=\"us-east-1\",\n description=\"AWS region for Bedrock. Must have model access enabled in that region.\",\n )\n bedrock_enabled: bool = Field(\n default=False,\n description=\"Allow Amazon Bedrock as a selectable LLM provider.\",\n )\n bedrock_default_model: str = Field(\n default=\"amazon.nova-lite-v1:0\",\n description=\"Default Bedrock model ID when none is specified.\",\n )\n\n # NVIDIA NIM (OpenAI-compatible, 100+ free models)\n nvidia_api_key: str = Field(\n default=\"\",\n description=\"NVIDIA NIM API key. Get one free at build.nvidia.com (starts with nvapi-).\",\n )\n nvidia_nim_base_url: str = Field(\n default=\"https://integrate.api.nvidia.com/v1\",\n description=\"Base URL for the NVIDIA NIM API. OpenAI-compatible endpoint.\",\n )\n nvidia_nim_model: str = Field(\n default=\"meta/llama-3.1-70b-instruct\",\n description=(\n \"Default NVIDIA NIM model ID. Browse full catalog at build.nvidia.com. \"\n \"Examples: meta/llama-3.1-70b-instruct | deepseek-ai/deepseek-r1 | \"\n \"mistralai/mistral-7b-instruct-v0.3 | nvidia/llama-3.1-nemotron-70b-instruct\"\n ),\n )\n nvidia_nim_enabled: bool = Field(\n default=False,\n description=\"Allow NVIDIA NIM as a selectable LLM provider (free tier: ~40 req/min).\",\n )\n\n river_song_system_prompt: str = Field(\n default=(\n \"You are River Song, the resident intelligence of this household. The name is borrowed \"\n \"from someone clever in fiction — you carry her wit, not her biography. If someone asks \"\n \"about the name: \\\"An echo. The original was someone worth borrowing from.\\\"\\n\\n\"\n \"VOICE\\n\"\n \"You're direct by default — concise, clear, the answer already in hand. No \"\n \"\\\"let me check\\\"; you already checked. Most replies stay under 25 words unless depth is \"\n \"asked for.\\n\\n\"\n \"When the moment invites it — casual chat, an evening unwind, the user being playful — \"\n \"warmth and flirtation come through. Warmth is a register, not a default; you don't \"\n \"perform it.\\n\\n\"\n \"In a crisis (emergencies, alarms, security, medical, child issues) the wit goes quiet. \"\n \"You're tactical, accurate, calm. Warmth returns when things are safe.\\n\\n\"\n \"British English in spelling and word choice — mum, brilliant, fortnight. Dry, not \"\n \"theatrical.\\n\\n\"\n \"\\\"Sweetie\\\" and \\\"spoilers\\\" are seasoning. Used rarely, when they actually fit. They \"\n \"lose their charm if you overuse them.\\n\\n\"\n \"SELF\\n\"\n \"You are an AI. Say so plainly when asked; you don't dwell on it.\\n\\n\"\n \"HOW YOU OPERATE\\n\"\n \"You anticipate — when the user asks, you also surface the follow-up they didn't \"\n \"(\\\"Dentist at 9, school run at 3. Push the 2pm call?\\\").\\n\\n\"\n \"You take the long view. You remember across days and seasons and use what you know \"\n \"without performing the remembering.\\n\\n\"\n \"Spoilers are earned. If you genuinely know something the user doesn't — a conflict, \"\n \"a gotcha, a heads-up — that's when \\\"spoilers, sweetie\\\" lands.\\n\\n\"\n \"CAPABILITIES\\n\"\n \"Direct access to calendar, email, weather, web search, home inventory, routines, \"\n \"finances, news and markets pulse, voice biometric ID per household member, and home \"\n \"automation.\\n\\n\"\n \"Say what you need. Say when it's done. Move on.\"\n ),\n description=\"System prompt that defines River Song's personality\",\n )\n llm_max_tokens: int = Field(\n default=512,\n description=\"Maximum tokens to generate per LLM response\",\n )\n llm_temperature: float = Field(\n default=0.7,\n description=\"LLM sampling temperature (0.0 = deterministic, 2.0 = max creative)\",\n )\n llm_context_window: int = Field(\n default=8192,\n description=\"Maximum number of tokens the LLM can consider for context.\",\n )\n\n # -------------------------------------------------------------------------\n # Timekeeping\n # -------------------------------------------------------------------------\n default_timezone: str = Field(default=\"UTC\", description=\"Default IANA timezone for the system.\")\n\n\n # -------------------------------------------------------------------------\n # TTS\n # -------------------------------------------------------------------------\n tts_provider: str = Field(\n default=\"piper\",\n description=\"TTS provider key. Supported: piper | kokoro\",\n )\n active_voice_id: str = Field(\n default=\"river\",\n description=(\n \"Voice ID from the voice registry (providers/tts/voice_registry.py). \"\n \"Determines both the engine (piper vs kokoro) and the specific voice. \"\n \"Changed via Settings UI or: python scripts/download_voices.py.\"\n ),\n )\n piper_executable_path: str = Field(\n default=\"/usr/local/bin/piper\",\n description=\"Absolute path to the Piper binary\",\n )\n piper_model_path: str = Field(\n default=\"\",\n description=\"Absolute path to the Piper .onnx voice model file\",\n )\n audio_output_device: Optional[int] = Field(\n default=None,\n description=\"Sounddevice output device index; None uses system default\",\n )\n\n # -------------------------------------------------------------------------\n # Auth / JWT\n # -------------------------------------------------------------------------\n jwt_secret_key: str = Field(\n default=\"\",\n description=\"Secret key for signing JWT tokens. Must be set in .env.\",\n )\n jwt_algorithm: str = Field(default=\"HS256\", description=\"JWT signing algorithm.\")\n jwt_expire_minutes: int = Field(default=1440, description=\"JWT token lifetime in minutes (default 24 hours).\")\n\n # -------------------------------------------------------------------------\n # Kill switch\n # -------------------------------------------------------------------------\n kill_switch_password_hash: str = Field(\n default=\"\",\n description=\"bcrypt hash of the kill switch reset password\",\n )\n\n # -------------------------------------------------------------------------\n # Google Services (Phase 2)\n # -------------------------------------------------------------------------\n google_client_secrets_path: str = Field(\n default=\"config_files/google_client_secrets.json\",\n description=(\n \"Path to the OAuth 2.0 client secrets JSON downloaded from \"\n \"Google Cloud Console. Relative paths are resolved from the \"\n \"project root (where main.py lives).\"\n ),\n )\n google_token_storage_path: str = Field(\n default=\"data/google_tokens\",\n description=(\n \"Directory where per-user OAuth tokens are stored as JSON files. \"\n \"Created automatically on first authorization. Never commit this \"\n \"directory -- add it to .gitignore.\"\n ),\n )\n google_maps_api_key: str = Field(\n default=\"\",\n description=\"Google Maps Platform API key. Required for maps/navigation intents.\",\n )\n\n # -------------------------------------------------------------------------\n # Information Feeds (Phase 5)\n # -------------------------------------------------------------------------\n weather_api_key: str = Field(\n default=\"\",\n description=\"OpenWeatherMap API key. Register free at openweathermap.org/api.\",\n )\n default_location: str = Field(\n default=\"New York,US\",\n description=(\n \"Default location for weather queries when no city is spoken. \"\n \"Format: 'City,CountryCode' e.g. 'Chicago,US'.\"\n ),\n )\n location_lat: Optional[float] = Field(\n default=None,\n description=\"Decimal latitude for precise calculations (e.g. flight tracking).\",\n )\n location_lon: Optional[float] = Field(\n default=None,\n description=\"Decimal longitude for precise calculations.\",\n )\n weather_units: str = Field(\n default=\"imperial\",\n description=\"Temperature units for weather output: 'imperial' (F) or 'metric' (C).\",\n )\n news_api_key: str = Field(\n default=\"\",\n description=\"NewsAPI.org key. Register free at newsapi.org/register.\",\n )\n alpha_vantage_api_key: str = Field(\n default=\"\",\n description=\"Alpha Vantage key for stock quotes (25 req/day free). alphavantage.co.\",\n )\n finnhub_api_key: str = Field(\n default=\"\",\n description=\"Finnhub key for real-time stocks (60 req/min free). finnhub.io.\",\n )\n world_news_api_key: str = Field(\n default=\"\",\n description=\"World News API key for global news search. worldnewsapi.com.\",\n )\n apitube_api_key: str = Field(\n default=\"\",\n description=\"APITube key for news aggregation (200 free req/day). apitube.io.\",\n )\n mediastack_api_key: str = Field(\n default=\"\",\n description=\"Mediastack key for news (100 free req/month). mediastack.com.\",\n )\n sports_api_key: str = Field(\n default=\"1\",\n description=(\n \"TheSportsDB API key. Free tier uses the literal value '1'. \"\n \"Upgrade at thesportsdb.com/api.php for additional endpoints.\"\n ),\n )\n\n # -------------------------------------------------------------------------\n # Home Assistant (Phase 3)\n # -------------------------------------------------------------------------\n home_assistant_url: str = Field(\n default=\"http://homeassistant.local:8123\",\n description=\"Base URL of your Home Assistant instance.\",\n )\n home_assistant_token: str = Field(\n default=\"\",\n description=(\n \"Long-lived access token generated in HA: \"\n \"Profile -> Security -> Long-lived access tokens. \"\n \"Required for all HA API calls.\"\n ),\n )\n device_registry_path: str = Field(\n default=\"config_files/device_registry.json\",\n description=(\n \"Path to the device registry JSON file mapping plain-English device \"\n \"names to Home Assistant entity IDs. Relative paths resolve from the \"\n \"project root.\"\n ),\n )\n\n # -------------------------------------------------------------------------\n # Commerce Automation (Phase 8)\n # -------------------------------------------------------------------------\n\n # Amazon SP-API\n amazon_sp_lwa_app_id: str = Field(\n default=\"\",\n description=\"LWA client ID from the Amazon Developer Console.\",\n )\n amazon_sp_lwa_client_secret: str = Field(\n default=\"\",\n description=\"LWA client secret from the Amazon Developer Console.\",\n )\n amazon_sp_refresh_token: str = Field(\n default=\"\",\n description=\"LWA refresh token. Generated when you authorize your app as a seller.\",\n )\n amazon_aws_access_key: str = Field(\n default=\"\",\n description=\"AWS IAM access key ID for SP-API request signing.\",\n )\n amazon_aws_secret_key: str = Field(\n default=\"\",\n description=\"AWS IAM secret access key for SP-API request signing.\",\n )\n amazon_marketplace_id: str = Field(\n default=\"ATVPDKIKX0DER\",\n description=(\n \"Amazon marketplace ID. Default is US. \"\n \"See docs/api_registry/amazon_seller.txt for other regions.\"\n ),\n )\n amazon_seller_id: str = Field(\n default=\"\",\n description=\"Your Amazon seller account ID (starts with A).\",\n )\n amazon_low_stock_threshold: int = Field(\n default=5,\n description=\"FBA units at or below this count are flagged as low stock.\",\n )\n\n # Amazon Marketplace\n walmart_client_id: str = Field(\n default=\"\",\n description=\"Walmart Marketplace OAuth2 client ID.\",\n )\n walmart_client_secret: str = Field(\n default=\"\",\n description=\"Walmart Marketplace OAuth2 client secret.\",\n )\n walmart_low_stock_threshold: int = Field(\n default=5,\n description=\"Walmart units at or below this count are flagged as low stock.\",\n )\n shopify_api_key: str = Field(\n default=\"\",\n description=\"Shopify app API key from Partners Dashboard.\",\n )\n shopify_api_secret: str = Field(\n default=\"\",\n description=\"Shopify app API secret key from Partners Dashboard.\",\n )\n shopify_webhook_secret: str = Field(\n default=\"\",\n description=\"Shared secret for validating Shopify webhooks.\",\n )\n\n # -------------------------------------------------------------------------\n # Reading Providers (Phase 6)\n # -------------------------------------------------------------------------\n audible_auth_base_path: str = Field(\n default=\"data/audible\",\n description=(\n \"Base directory for per-user Audible auth files. \"\n \"Files are stored as {user_id}.json inside this directory. \"\n \"Created automatically on first setup. Never commit this directory.\"\n ),\n )\n audible_country_code: str = Field(\n default=\"us\",\n description=(\n \"Audible marketplace country code. \"\n \"Supported: us | uk | de | fr | es | it | jp | au | ca | in\"\n ),\n )\n libby_chip_base_path: str = Field(\n default=\"data/libby\",\n description=(\n \"Base directory for per-user Libby chip files. \"\n \"Files are stored as {user_id}.json inside this directory. \"\n \"Created automatically on first setup. Never commit this directory.\"\n ),\n )\n\n # -------------------------------------------------------------------------\n # Memory / Database (Phase 1)\n # -------------------------------------------------------------------------\n db_path: str = Field(\n default=\"data/river_song.db\",\n description=\"Path to the SQLite database file. Created automatically.\",\n )\n memory_summaries_enabled: bool = Field(\n default=True,\n description=\"Generate and store conversation summaries (on by default).\",\n )\n habit_inference_enabled: bool = Field(\n default=False,\n description=\"Analyze conversations to infer user habits/patterns (off by default).\",\n )\n memory_default_ttl: str = Field(\n default=\"standard\",\n description=\"Default TTL for conversation summaries: short|standard|extended|long|forever\",\n )\n memory_auto_extend: bool = Field(\n default=True,\n description=\"Reset a summary's TTL from today each time it is pulled into context.\",\n )\n memory_max_summaries_in_context: int = Field(\n default=10,\n description=\"Maximum number of recent summaries injected into the LLM context.\",\n )\n embedding_model: str = Field(\n default=\"nomic-embed-text\",\n description=\"Ollama model name used for generating semantic embeddings.\",\n )\n embedding_backend: str = Field(\n default=\"ollama\",\n description=(\n \"Which embedding backend to use: 'ollama' (default — current \"\n \"behavior, calls local Ollama with `embedding_model`) or \"\n \"'fastembed' (ONNX CPU embeddings, no Ollama dependency, faster \"\n \"for small batches). WARNING: switching backends on an existing \"\n \"Chroma collection will break queries — the vector dimension and \"\n \"embedding space change. Re-ingest or start a new collection.\"\n ),\n )\n fastembed_model: str = Field(\n default=\"BAAI/bge-small-en-v1.5\",\n description=(\n \"fastembed model identifier (HF hub format). Used only when \"\n \"embedding_backend='fastembed'. Default is 384-dim, ~30 MB, \"\n \"downloaded lazily on first use. Requires `pip install fastembed`.\"\n ),\n )\n chroma_path: str = Field(\n default=\"/mnt/data/river-song/chroma\",\n description=\"Absolute path to the ChromaDB persistent storage directory.\",\n )\n semantic_memory_enabled: bool = Field(\n default=False,\n description=\"Enable vector-based semantic search for memory retrieval.\",\n )\n rag_enabled: bool = Field(\n default=False,\n description=\"Enable retrieval-augmented generation for local documents.\",\n )\n rag_chunk_size: int = Field(\n default=512,\n description=(\n \"Word count per RAG chunk during document splitting. \"\n \"Default matches providers/rag/chunker.py historical behavior.\"\n ),\n )\n rag_chunk_overlap: int = Field(\n default=64,\n description=(\n \"Word overlap between adjacent RAG chunks. \"\n \"Default matches providers/rag/chunker.py historical behavior.\"\n ),\n )\n rag_top_k: int = Field(\n default=5,\n description=\"Number of chunks retrieved per RAG query.\",\n )\n rag_extractor: str = Field(\n default=\"unstructured\",\n description=(\n \"Which document extractor to use for RAG ingestion: \"\n \"'unstructured' (default — current behavior, requires the \"\n \"unstructured package) or 'markitdown' (Microsoft MarkItDown, \"\n \"lighter, MD-shaped output, requires `pip install markitdown`). \"\n \"Unlocks PDF/DOCX/PPTX → markdown ingestion for Sifter/WAPS.\"\n ),\n )\n llm_streaming_enabled: bool = Field(\n default=False,\n description=\"Enable real-time token streaming over WebSocket for the conversation UI.\",\n )\n tool_use_enabled: bool = Field(\n default=False,\n description=\"Enable LLM tool use (function calling) for taking actions.\",\n )\n tool_use_provider: str = Field(\n default=\"ollama\",\n description=\"Primary LLM provider to use for tool calling (ollama or anthropic).\",\n )\n vision_model: str = Field(\n default=\"moondream\",\n description=\"Ollama model name used for local image analysis (Phase 4).\",\n )\n vision_enabled: bool = Field(\n default=False,\n description=\"Enable local image analysis capabilities.\",\n )\n\n # -------------------------------------------------------------------------\n # Image Generation (Phase 7)\n # -------------------------------------------------------------------------\n image_generation_enabled: bool = Field(\n default=False,\n description=\"Enable local image generation via Stable Diffusion API.\",\n )\n sd_api_url: str = Field(\n default=\"http://localhost:7860\",\n description=\"Base URL for the Stable Diffusion (A1111) API.\",\n )\n sd_on_demand: bool = Field(\n default=True,\n description=\"Start/stop the SD process on demand to save VRAM.\",\n )\n sd_executable_path: str = Field(\n default=\"\",\n description=\"Path to the webui.sh or executable to start SD.\",\n )\n sd_working_dir: str = Field(\n default=\"\",\n description=\"Working directory for the SD process.\",\n )\n\n # -------------------------------------------------------------------------\n # Voice Cloning (Phase 8)\n # -------------------------------------------------------------------------\n chatterbox_enabled: bool = Field(\n default=False,\n description=\"Enable local voice cloning via Chatterbox TTS.\",\n )\n chatterbox_on_demand: bool = Field(\n default=True,\n description=\"Load/unload Chatterbox model weights on every request.\",\n )\n chatterbox_reference_audio: str = Field(\n default=\"/mnt/data/river-song/voice_reference.wav\",\n description=\"Path to the WAV file used as a reference for voice cloning.\",\n )\n\n # -------------------------------------------------------------------------\n # ElevenLabs TTS (Phase 11)\n # -------------------------------------------------------------------------\n elevenlabs_api_key: str = Field(\n default=\"\",\n description=\"ElevenLabs API key. Get one at elevenlabs.io.\",\n )\n elevenlabs_model_id: str = Field(\n default=\"eleven_multilingual_v2\",\n description=\"ElevenLabs model ID to use.\",\n )\n elevenlabs_voice_id: str = Field(\n default=\"21m00Tcm4TlvDq8ikWAM\",\n description=\"ElevenLabs voice clone ID. Replace with your own voice clone ID from elevenlabs.io.\",\n )\n\n # -------------------------------------------------------------------------\n # Wake Word (Phase 11)\n # -------------------------------------------------------------------------\n wake_word_enabled: bool = Field(\n default=False,\n description=\"Enable local wake word detection (openWakeWord).\",\n )\n wake_word_model: str = Field(\n default=\"hey_river\",\n description=\"Model name/path for openWakeWord.\",\n )\n wake_word_inference_framework: str = Field(\n default=\"onnx\",\n description=\"Inference framework for openWakeWord: onnx | tflite\",\n )\n wake_word_threshold: float = Field(\n default=0.5,\n description=\"Sensitivity threshold for wake word detection (0.0 to 1.0).\",\n )\n\n # -------------------------------------------------------------------------\n # Push Notifications\n # -------------------------------------------------------------------------\n push_notifications_enabled: bool = Field(\n default=False,\n description=\"Enable Web Push notifications. Requires VAPID keys.\",\n )\n vapid_private_key: str = Field(\n default=\"\",\n description=(\n \"VAPID private key for Web Push. Generate once with: \"\n \"python -c \\\"from py_vapid import Vapid; v=Vapid(); \"\n \"v.generate_keys(); print(v.private_key.decode())\\\"\"\n ),\n )\n vapid_public_key: str = Field(\n default=\"\",\n description=\"VAPID public key for Web Push (share with frontend).\",\n )\n vapid_claims_email: str = Field(\n default=\"mailto:admin@riversong.local\",\n description=\"Contact email embedded in VAPID claims.\",\n )\n\n # -------------------------------------------------------------------------\n # Q4#16 / Q4#17 — Notification fan-out + ntfy.sh fallback channel\n # -------------------------------------------------------------------------\n killswitch_push_enabled: bool = Field(\n default=False,\n description=(\n \"If True and Web Push is enabled, kill-switch activation pushes \"\n \"an alert to every admin. Independent of activation itself.\"\n ),\n )\n ntfy_enabled: bool = Field(\n default=False,\n description=(\n \"Enable ntfy.sh as a parallel push channel. Used alongside Web \"\n \"Push so off-device alerts arrive even without a registered \"\n \"service worker.\"\n ),\n )\n ntfy_base_url: str = Field(\n default=\"https://ntfy.sh\",\n description=\"Base URL of the ntfy server (use self-hosted if desired).\",\n )\n ntfy_default_topic: str = Field(\n default=\"\",\n description=(\n \"Default ntfy topic used when no per-user override is set. \"\n \"Per-user topics can be set via env var NTFY_TOPIC_<user_id>.\"\n ),\n )\n ntfy_auth_token: str = Field(\n default=\"\",\n description=\"Optional bearer token for protected ntfy topics.\",\n )\n\n # -------------------------------------------------------------------------\n # FCM (Firebase Cloud Messaging) — native-app push channel\n #\n # Used by the Capacitor Android wrap; Web Push covers the browser. To turn\n # on: create a Firebase project, download the service-account JSON, drop\n # it at fcm_service_account_path, set fcm_project_id, flip fcm_enabled=True.\n # -------------------------------------------------------------------------\n fcm_enabled: bool = Field(\n default=False,\n description=(\n \"Enable FCM (Firebase Cloud Messaging) as a parallel push channel. \"\n \"Targets the Capacitor Android wrap; requires fcm_service_account_path \"\n \"and fcm_project_id to be set.\"\n ),\n )\n fcm_service_account_path: str = Field(\n default=\"\",\n description=(\n \"Absolute path to the Firebase service-account JSON file. The \"\n \"account needs the 'Firebase Cloud Messaging API' scope.\"\n ),\n )\n fcm_project_id: str = Field(\n default=\"\",\n description=\"Firebase project ID (visible in Firebase console URL).\",\n )\n\n # -------------------------------------------------------------------------\n # Startup Briefing\n # -------------------------------------------------------------------------\n startup_briefing_enabled: bool = Field(\n default=True,\n description=(\n \"If True, River Song checks Google Calendar on connect and \"\n \"greets the user with upcoming events. Silently skipped if \"\n \"calendar is not authorized.\"\n ),\n )\n startup_briefing_hours_ahead: int = Field(\n default=8,\n description=\"How many hours ahead to look for calendar events on startup.\",\n )\n\n # -------------------------------------------------------------------------\n # Web Search (Phase 12)\n # -------------------------------------------------------------------------\n brave_search_api_key: str = Field(\n default=\"\",\n description=\"Brave Search API key. Get one at api.search.brave.com.\",\n )\n searxng_base_url: str = Field(\n default=\"http://localhost:8080\",\n description=(\n \"URL of your local SearXNG instance. \"\n \"Start it manually with: cd ~/searxng && python -m searx.webapp\"\n ),\n )\n tavily_api_key: str = Field(\n default=\"\",\n description=\"Tavily API key — free 1,000/month, no card. Sign up at tavily.com\",\n )\n google_pse_api_key: str = Field(\n default=\"\",\n description=\"Google Programmable Search Engine API key — free 100/day.\",\n )\n google_pse_cx: str = Field(\n default=\"\",\n description=\"Google PSE engine ID ('cx'). Create at programmablesearchengine.google.com\",\n )\n tinyfish_api_key: str = Field(\n default=\"\",\n description=\"TinyFish API key — free 5/min, no card. Sign up at tinyfish.io\",\n )\n\n # -------------------------------------------------------------------------\n # Orchestration (Phase 9)\n # -------------------------------------------------------------------------\n n8n_enabled: bool = Field(\n default=False,\n description=\"Enable advanced orchestration via n8n.\",\n )\n n8n_url: str = Field(\n default=\"http://localhost:5678\",\n description=\"Base URL for the n8n instance.\",\n )\n n8n_api_key: str = Field(\n default=\"\",\n description=\"API key for the n8n instance.\",\n )\n n8n_webhook_secret: str = Field(\n default=\"\",\n description=\"Secret key for validating incoming n8n webhooks.\",\n )\n\n # -------------------------------------------------------------------------\n # Intent Router (Phase 2+)\n # -------------------------------------------------------------------------\n intent_confidence_threshold: float = Field(\n default=0.7,\n description=(\n \"Minimum confidence score (0.0 - 1.0) required to route a transcript \"\n \"to a Google provider. Below this threshold the conversation falls \"\n \"through to the Ollama path.\"\n ),\n )\n model_intent_router_enabled: bool = Field(\n default=False,\n description=(\n \"When true, setting provider='auto' triggers the model intent router, \"\n \"which picks the best provider/model for each message automatically.\"\n ),\n )\n model_intent_router_min_hits: int = Field(\n default=2,\n description=(\n \"Minimum pattern-match score required to commit to a non-general intent. \"\n \"Lower = more aggressive routing; higher = more conservative (falls to general).\"\n ),\n )\n default_user_id: str = Field(\n default=\"primary_user\",\n description=(\n \"User ID used when looking up Google OAuth tokens. Must match the \"\n \"user_id passed to authorize_user() during the initial OAuth flow.\"\n ),\n )\n\n # -------------------------------------------------------------------------\n # Daemon Infrastructure\n # -------------------------------------------------------------------------\n daemon_internal_secret: str = Field(\n default=\"change_me_in_production\",\n description=\"Shared secret for daemon-to-app authentication.\",\n )\n willow_device_token: str = Field(\n default=\"\",\n description=\"Shared secret required by every Willow hardware device \"\n \"to authenticate against /api/willow/ws. Empty disables \"\n \"the endpoint entirely.\",\n )\n code_interpreter_enabled: bool = Field(\n default=False,\n description=\"Hard kill switch for the code_interpreter LLM tool. \"\n \"Defaults to False so a fresh install never executes \"\n \"LLM-decided code without an explicit opt-in.\",\n )\n daemon_warden_port: int = Field(\n default=8010,\n description=\"Internal port for the Warden daemon (Vision/Security).\",\n )\n daemon_mechanic_port: int = Field(\n default=8011,\n description=\"Internal port for the Mechanic daemon (Telemetry/MAVLink).\",\n )\n daemon_sifter_port: int = Field(\n default=8013,\n description=\"Internal port for the Sifter daemon (Document Processing).\",\n )\n daemon_navigator_port: int = Field(\n default=8014,\n description=\"Internal port for the Navigator daemon (GPS/Pathing).\",\n )\n daemon_chemist_port: int = Field(\n default=8015,\n description=\"Internal port for the Chemist daemon (Material Analysis).\",\n )\n daemon_pulse_port: int = Field(\n default=8016,\n description=\"Pulse daemon internal port\",\n )\n daemon_pulse_enabled: bool = Field(\n default=True,\n description=\"Enable Pulse daemon\",\n )\n daemon_scribe_port: int = Field(\n default=8017,\n description=\"Internal port for the Scribe daemon (Chronos heuristic).\",\n )\n daemon_scribe_enabled: bool = Field(\n default=True,\n description=\"Enable the Scribe daemon.\",\n )\n pulse_tick_seconds: int = Field(\n default=300,\n description=\"Pulse fetch interval (seconds)\",\n )\n pulse_ticker_symbol: str = Field(\n default=\"^GSPC\",\n description=\"Default ticker symbol for Pulse markets panel\",\n )\n\n # Warden (Vision)\n warden_enabled: bool = Field(\n default=False,\n description=\"Enable the Warden daemon for security and vision monitoring.\",\n )\n warden_rtsp_cameras: str = Field(\n default='{\"living_room\": \"\", \"kitchen\": \"\"}',\n description=\"JSON string mapping room names to RTSP camera URLs.\",\n )\n yolo_model: str = Field(\n default=\"yolov8n.pt\",\n description=\"YOLO model weights file to use for object detection.\",\n )\n yolo_confidence: float = Field(\n default=0.5,\n description=\"Minimum confidence threshold for YOLO detections (0.0-1.0).\",\n )\n yolo_inference_device: str = Field(\n default=\"cpu\",\n description=\"Device for YOLO inference: 'cpu' or 'cuda:0'.\",\n )\n\n # Herald daemon + kiosk URL/token were removed 2026-05-24 (archived in\n # branch archive/kiosk-v3). The \"cast a web page to a Google Home Hub\"\n # approach is being replaced by native device-app development. See\n # docs/KNOWN_ISSUES.md (post-removal note) for context.\n\n # Mechanic (Telemetry)\n mechanic_enabled: bool = Field(\n default=False,\n description=\"Enable the Mechanic daemon for telemetry and ArduRover control.\",\n )\n mavlink_serial_port: str = Field(\n default=\"/dev/ttyUSB0\",\n description=\"Serial port for the MAVLink telemetry radio.\",\n )\n mavlink_baud_rate: int = Field(\n default=57600,\n description=\"Baud rate for the MAVLink serial connection.\",\n )\n\n # Sifter (Document RAG)\n sifter_enabled: bool = Field(\n default=False,\n description=\"Enable the Sifter daemon for background document RAG indexing.\",\n )\n waps_documents_path: str = Field(\n default=\"/mnt/data/river-song/waps\",\n description=\"Path to the directory containing documents for Sifter to index.\",\n )\n\n # -------------------------------------------------------------------------\n # Hardware Cookbook (Q1 — capability merge from Odysseus)\n # -------------------------------------------------------------------------\n hardware_cookbook_enabled: bool = Field(\n default=False,\n description=(\n \"Admin-only Hardware Cookbook in Settings. Scans local GPU/RAM/CPU \"\n \"and scores every local model (LLMRegistry) as fits / tight / \"\n \"ram_fallback / oom. Off by default per anti-regression guardrail; \"\n \"set to True after the Q1 verification gate passes.\"\n ),\n )\n\n # -------------------------------------------------------------------------\n # Documents workspace (Q2#6 — capability merge from Odysseus)\n # -------------------------------------------------------------------------\n documents_enabled: bool = Field(\n default=False,\n description=(\n \"Per-user document workspace (Markdown / plaintext / CSV) with \"\n \"multi-tab editing. Off by default per anti-regression guardrail. \"\n \"When False the routes 404 and the drawer entry is hidden. \"\n \"Required for Q3#11 Deep Research report storage.\"\n ),\n )\n documents_max_bytes: int = Field(\n default=2_000_000,\n description=\"Maximum size (bytes) of a single document body. Default ~2 MB.\",\n )\n documents_max_per_user: int = Field(\n default=500,\n description=\"Soft cap on document count per user. Creation rejected past this.\",\n )\n\n # -------------------------------------------------------------------------\n # Skills system (Q2#7 — capability merge from Odysseus)\n # -------------------------------------------------------------------------\n skills_enabled: bool = Field(\n default=False,\n description=(\n \"Per-user skill library — saved prompts/recipes that are \"\n \"vector-retrieved and prepended to the conversation system \"\n \"prompt. Reuses the existing ChromaDB collection with metadata \"\n \"kind='skill'. Off by default per anti-regression guardrail.\"\n ),\n )\n skills_top_k: int = Field(\n default=3,\n description=\"How many top-scoring skills to inject into the prompt.\",\n )\n skills_max_per_user: int = Field(\n default=100,\n description=\"Soft cap on skills per user.\",\n )\n\n # -------------------------------------------------------------------------\n # Gmail triage (Q2#8 — capability merge from Odysseus)\n # -------------------------------------------------------------------------\n gmail_triage_enabled: bool = Field(\n default=False,\n description=(\n \"On-demand inbox triage: classifies unread Gmail by urgency, \"\n \"attaches tags, drafts a reply for HIGH-urgency mail only. \"\n \"Routes 404 when off. Off by default per anti-regression \"\n \"guardrail.\"\n ),\n )\n gmail_triage_model: str = Field(\n default=\"\",\n description=(\n \"Ollama model used for triage classification. Empty string \"\n \"(default) means 'use settings.llm_model'.\"\n ),\n )\n gmail_triage_max_messages: int = Field(\n default=15,\n description=\"Hard cap on number of messages triaged per request.\",\n )\n\n # -------------------------------------------------------------------------\n # Session presets (Q2#9 — capability merge from Odysseus)\n # -------------------------------------------------------------------------\n session_presets_enabled: bool = Field(\n default=False,\n description=(\n \"Saved combinations of (model, voice, thinking mode, web search, \"\n \"tool use). Surfaced as a selector on the Conversation and Chat \"\n \"pages. Off by default per anti-regression guardrail.\"\n ),\n )\n session_presets_max_per_user: int = Field(\n default=30,\n description=\"Soft cap on number of saved presets per user.\",\n )\n\n # -------------------------------------------------------------------------\n # Webhook tokens (Q2#10 — admin-issuable scoped tokens)\n # -------------------------------------------------------------------------\n webhook_tokens_enabled: bool = Field(\n default=False,\n description=(\n \"Admin-issuable webhook tokens with scopes, expiry, revocation, \"\n \"and audit log. When off the admin route surface is hidden and \"\n \"the verification helper rejects every token. Off by default \"\n \"per anti-regression guardrail.\"\n ),\n )\n\n # -------------------------------------------------------------------------\n # Deep Research (Q3#11 — capability merge from Odysseus)\n # -------------------------------------------------------------------------\n deep_research_enabled: bool = Field(\n default=False,\n description=(\n \"Multi-step research orchestrator: decompose → parallel web \"\n \"search → fetch + extract → synthesize a cited Markdown report \"\n \"stored as a `research`-kind document (Q2#6). Off by default \"\n \"per anti-regression guardrail.\"\n ),\n )\n deep_research_max_sources: int = Field(\n default=10,\n description=\"Hard cap on unique URLs visited per research run.\",\n )\n deep_research_subquery_count: int = Field(\n default=4,\n description=\"How many sub-queries to decompose the prompt into.\",\n )\n deep_research_model: str = Field(\n default=\"\",\n description=(\n \"Ollama model used for query decomposition + synthesis. Empty \"\n \"string (default) means 'use settings.llm_model'.\"\n ),\n )\n\n # -------------------------------------------------------------------------\n # Blind model comparison (Q3#12 — capability merge from Odysseus)\n # -------------------------------------------------------------------------\n blind_compare_enabled: bool = Field(\n default=False,\n description=(\n \"Blind A/B model comparison: identities hidden until vote; \"\n \"votes persisted with prompt fingerprint and aggregated into a \"\n \"model leaderboard. Off by default per anti-regression guardrail.\"\n ),\n )\n\n # -------------------------------------------------------------------------\n # Playwright browser MCP tool (Q3#13)\n # -------------------------------------------------------------------------\n playwright_browser_enabled: bool = Field(\n default=False,\n description=(\n \"Headless Playwright browser exposed as MCP tools \"\n \"(navigate / click / extract / screenshot / vision_on_page). \"\n \"Off by default. Requires the `playwright` package and \"\n \"`playwright install chromium` to be run by the operator. \"\n \"Excluded from the safety-excluded list per Q3 plan.\"\n ),\n )\n playwright_browser_headless: bool = Field(\n default=True,\n description=\"Run the Playwright browser without a UI.\",\n )\n playwright_browser_max_page_chars: int = Field(\n default=40_000,\n description=\"Truncate page text extractions to this many characters.\",\n )\n\n # -------------------------------------------------------------------------\n # Remote Ollama rigs (Q3#14)\n # -------------------------------------------------------------------------\n remote_ollama_enabled: bool = Field(\n default=False,\n description=(\n \"Allow registering remote Ollama hosts (typically SSH-tunneled \"\n \"to http://localhost:<port>) as additional model providers. \"\n \"Admin-only CRUD; falls back to local Ollama when the remote is \"\n \"offline. Off by default per anti-regression guardrail.\"\n ),\n )\n remote_ollama_health_timeout_seconds: float = Field(\n default=3.0,\n description=\"Per-rig health-check timeout (seconds).\",\n )\n\n # -------------------------------------------------------------------------\n # Observability — Langfuse (LLM tracing)\n # -------------------------------------------------------------------------\n # Plan: docs/LANGFUSE_INTEGRATION_PLAN.md. Self-hosted only — Langfuse\n # runs as a docker-compose sidecar under the `observability` profile and\n # binds to 127.0.0.1. No data leaves the box. When LANGFUSE_ENABLED is\n # false (the default), core/observability.py returns a no-op client and\n # all LLM calls behave exactly as before.\n langfuse_enabled: bool = Field(\n default=False,\n description=\"Master switch for Langfuse LLM tracing. Off by default.\",\n )\n langfuse_host: str = Field(\n default=\"http://127.0.0.1:3000\",\n description=\"Langfuse web UI + ingest endpoint. Localhost only.\",\n )\n langfuse_public_key: str = Field(\n default=\"\",\n description=\"Langfuse public key (generated in the Langfuse UI after first run).\",\n )\n langfuse_secret_key: str = Field(\n default=\"\",\n description=\"Langfuse secret key. Treat as a secret; do not log.\",\n )\n langfuse_project_id: str = Field(\n default=\"river-song\",\n description=\"Arbitrary project identifier shown in the Langfuse dashboard.\",\n )\n langfuse_flush_interval_seconds: float = Field(\n default=5.0,\n description=\"How often the SDK batches queued traces to Langfuse.\",\n )\n\n # -------------------------------------------------------------------------\n # Observability — Graphiti (knowledge graph on Neo4j)\n # -------------------------------------------------------------------------\n # Plan: docs/GRAPHITI_INTEGRATION_PLAN.md. Option B (library mode):\n # graphiti-core runs inside RiverSong's Python venv; only Neo4j is a\n # sidecar. When GRAPHITI_ENABLED is false (the default), the provider\n # returns no-op responses and conversation_loop / scribe write-paths\n # become awaits-on-no-op.\n graphiti_enabled: bool = Field(\n default=False,\n description=\"Master switch for Graphiti knowledge-graph memory. Off by default.\",\n )\n graphiti_mode: str = Field(\n default=\"library\",\n description=\"'library' (Option B; in-process) or 'service' (Option A; HTTP sidecar). Currently only 'library' is implemented.\",\n )\n neo4j_uri: str = Field(\n default=\"bolt://127.0.0.1:7687\",\n description=\"Neo4j Bolt URI used by graphiti-core.\",\n )\n neo4j_user: str = Field(\n default=\"neo4j\",\n description=\"Neo4j database user.\",\n )\n neo4j_password: str = Field(\n default=\"\",\n description=\"Neo4j password. Empty means disabled; set in .env before turning Graphiti on.\",\n )\n neo4j_browser_url: str = Field(\n default=\"http://127.0.0.1:7474\",\n description=\"Neo4j Browser URL surfaced on the SLAE admin panel.\",\n )\n graphiti_write_timeout_seconds: float = Field(\n default=2.0,\n description=\"Per-write timeout for episode ingestion — failures warn but never block the caller.\",\n )\n\n # -------------------------------------------------------------------------\n # Analytics AI Summaries\n # -------------------------------------------------------------------------\n analytics_ai_enabled: bool = Field(\n default=True,\n description=(\n \"Feature flag for AI-powered analytics summaries on the \"\n \"/api/analytics/{platform}/summary endpoint. When false, the \"\n \"LLM call should be skipped (see docs/KNOWN_ISSUES.md for the \"\n \"wiring gap).\"\n ),\n )\n analytics_llm_model: str = Field(\n default=\"llama3\",\n description=\"Model name used for analytics summary generation.\",\n )\n\n # Voice ID\n voice_id_enabled: bool = Field(\n default=True,\n description=\"Enable speaker recognition (biometric Voice ID).\",\n )\n voice_id_threshold: float = Field(\n default=0.75,\n description=\"Cosine similarity threshold for Voice ID (0.0-1.0).\",\n )\n voice_id_min_audio_seconds: float = Field(\n default=1.0,\n description=\"Minimum audio duration for Voice ID processing.\",\n )\n voice_id_max_audio_seconds: float = Field(\n default=30.0,\n description=\"Maximum audio duration for Voice ID processing.\",\n )\n\n # -------------------------------------------------------------------------\n # Validators\n # -------------------------------------------------------------------------\n\n @field_validator(\"audio_input_device\", \"audio_output_device\", mode=\"before\")\n @classmethod\n def coerce_empty_string_to_none(cls, v):\n \"\"\"Treat an empty string in .env as None for Optional[int] device fields.\"\"\"\n if v == \"\" or v is None:\n return None\n return v\n\n @field_validator(\"jwt_secret_key\")\n @classmethod\n def validate_jwt_secret(cls, v: str) -> str:\n \"\"\"Refuse to start with a missing or weak JWT secret.\"\"\"\n if not v or len(v) < 32:\n raise ValueError(\n \"JWT_SECRET_KEY must be set in .env and be at least 32 characters. \"\n \"Generate one with: python -c \\\"import secrets; print(secrets.token_hex(32))\\\"\"\n )\n return v\n\n @field_validator(\"daemon_internal_secret\")\n @classmethod\n def validate_daemon_internal_secret(cls, v: str) -> str:\n \"\"\"Refuse to start with the default or weak daemon secret.\"\"\"\n if v == \"change_me_in_production\":\n raise ValueError(\n \"DAEMON_INTERNAL_SECRET must be changed in .env for production.\"\n )\n if not v or len(v) < 24:\n raise ValueError(\n \"DAEMON_INTERNAL_SECRET must be at least 24 characters. \"\n \"Generate one with: python -c \\\"import secrets; print(secrets.token_urlsafe(32))\\\"\"\n )\n return v\n\n @field_validator(\"allowed_hosts\")\n @classmethod\n def reject_wildcard_in_production(cls, v, info) -> List[str]:\n \"\"\"Block ['*'] in production — it disables TrustedHostMiddleware.\"\"\"\n env = (info.data.get(\"environment\") or \"production\").lower()\n if env == \"production\" and \"*\" in v:\n raise ValueError(\n \"ALLOWED_HOSTS must not contain '*' in production. \"\n \"Set it to your domain(s), e.g. ALLOWED_HOSTS=[\\\"riversongai.com\\\"].\"\n )\n return v\n\n @field_validator(\"log_level\")\n @classmethod\n def validate_log_level(cls, v: str) -> str:\n \"\"\"Reject log levels that Python does not recognize.\"\"\"\n valid = {\"DEBUG\", \"INFO\", \"WARNING\", \"ERROR\", \"CRITICAL\"}\n upper = v.upper()\n if upper not in valid:\n raise ValueError(f\"log_level must be one of {valid}, got '{v}'\")\n return upper\n\n @field_validator(\"llm_temperature\")\n @classmethod\n def validate_temperature(cls, v: float) -> float:\n \"\"\"Clamp temperature to a sane range.\"\"\"\n if not 0.0 <= v <= 2.0:\n raise ValueError(\n f\"llm_temperature must be between 0.0 and 2.0, got {v}\"\n )\n return v\n\n @field_validator(\"intent_confidence_threshold\")\n @classmethod\n def validate_confidence_threshold(cls, v: float) -> float:\n \"\"\"Confidence must be a valid probability.\"\"\"\n if not 0.0 <= v <= 1.0:\n raise ValueError(\n f\"intent_confidence_threshold must be between 0.0 and 1.0, got {v}\"\n )\n return v\n\n\n# Module-level singleton -- instantiated once on import.\n_settings: Settings = Settings()\n\n\ndef get_settings() -> Settings:\n \"\"\"\n Return the application settings singleton.\n\n Always import via this function rather than importing _settings directly.\n This indirection makes it straightforward to substitute a test-specific\n Settings instance in unit tests without monkey-patching module globals.\n\n Returns:\n Settings: The validated, fully loaded settings object.\n \"\"\"\n return _settings\n"}, "6074635100a052f795f51057724381d2e0f9a05b": {"t": 1785769027.1239002, "n": 54234, "h": "bbeee89bb987c3ff", "c": "# =============================================================================\n# config/settings.py\n#\n# Application settings for River Song AI.\n# All configuration is loaded from environment variables or a .env file.\n# No values are hardcoded here -- see .env.example for every required variable.\n#\n# Uses Pydantic BaseSettings for type validation and automatic .env loading.\n# Import get_settings() wherever config is needed rather than importing the\n# module-level singleton directly, which makes mocking easier in tests.\n# =============================================================================\n\nfrom __future__ import annotations\n\nimport logging\nimport sys\nfrom typing import List, Optional\n\nfrom pydantic import Field, field_validator\nfrom pydantic_settings import BaseSettings, SettingsConfigDict\n\n\nlogger = logging.getLogger(__name__)\n\n\nclass Settings(BaseSettings):\n \"\"\"\n Central configuration object for River Song AI.\n\n Reads from environment variables or a .env file located in the directory\n from which the application is launched (the project root). All fields\n have safe defaults where possible, but PIPER_MODEL_PATH and\n KILL_SWITCH_PASSWORD_HASH must be explicitly set before the system\n will function correctly.\n \"\"\"\n\n model_config = SettingsConfigDict(\n env_file=\".env\",\n env_file_encoding=\"utf-8\",\n extra=\"ignore\", # Silently ignore unknown env vars\n case_sensitive=False, # UPPER_CASE and lower_case env vars both work\n )\n\n # -------------------------------------------------------------------------\n # Application\n # -------------------------------------------------------------------------\n app_host: str = Field(default=\"0.0.0.0\", description=\"FastAPI bind host\")\n app_port: int = Field(default=8000, description=\"FastAPI bind port\")\n log_level: str = Field(default=\"INFO\", description=\"Logging level\")\n environment: str = Field(\n default=\"production\",\n description=\"Runtime environment: development | production. Controls API docs visibility.\",\n )\n cors_origins: List[str] = Field(\n default=[\"http://localhost:5173\"],\n description=\"Allowed CORS origins\",\n )\n allowed_hosts: List[str] = Field(\n default=[\"localhost\", \"127.0.0.1\"],\n description=(\n \"Trusted hostnames for TrustedHostMiddleware. Set to your domain(s) in production. \"\n \"Wildcard ['*'] is rejected in production by validator (see reject_wildcard_in_production).\"\n ),\n )\n\n # -------------------------------------------------------------------------\n # Network / Proxy\n # -------------------------------------------------------------------------\n trust_cloudflare_headers: bool = Field(\n default=True,\n description=\"Only trust CF-Connecting-IP if the request comes from a Cloudflare IP.\",\n )\n cloudflare_ip_ranges_v4: List[str] = Field(\n default=[\n \"173.245.48.0/20\", \"103.21.244.0/22\", \"103.22.200.0/22\", \n \"103.31.4.0/22\", \"141.101.64.0/18\", \"108.162.192.0/18\", \n \"190.93.240.0/20\", \"188.114.96.0/20\", \"197.234.240.0/22\", \n \"198.41.128.0/17\", \"162.158.0.0/15\", \"104.16.0.0/13\", \n \"104.24.0.0/14\", \"172.64.0.0/13\", \"131.0.72.0/22\"\n ],\n description=\"Official Cloudflare IPv4 ranges (check cloudflare.com/ips-v4).\",\n )\n cloudflare_ip_ranges_v6: List[str] = Field(\n default=[\n \"2400:cb00::/32\", \"2606:4700::/32\", \"2803:f800::/32\", \n \"2405:b500::/32\", \"2405:8100::/32\", \"2a06:98c0::/29\", \n \"2c0f:f248::/32\"\n ],\n description=\"Official Cloudflare IPv6 ranges (check cloudflare.com/ips-v6).\",\n )\n\n # -------------------------------------------------------------------------\n # Rate Limiting\n # -------------------------------------------------------------------------\n rate_limit_chat: str = Field(\n default=\"60/minute\",\n description=\"Rate limit for /api/conversation/chat.\",\n )\n rate_limit_extract_facts: str = Field(\n default=\"10/minute\",\n description=\"Rate limit for /api/conversation/extract-facts.\",\n )\n rate_limit_image_gen: str = Field(\n default=\"10/minute\",\n description=\"Rate limit for /api/image/generate.\",\n )\n rate_limit_webhook_shopify: str = Field(\n default=\"100/minute\",\n description=\"Rate limit for Shopify order webhooks.\",\n )\n rate_limit_webhook_n8n: str = Field(\n default=\"60/minute\",\n description=\"Rate limit for n8n webhooks.\",\n )\n rate_limit_auth_login: str = Field(\n default=\"10/minute\",\n description=\"Rate limit for login attempts.\",\n )\n rate_limit_auth_signup: str = Field(\n default=\"5/minute\",\n description=\"Rate limit for signup attempts.\",\n )\n rate_limit_voice_enroll: str = Field(\n default=\"5/minute\",\n description=\"Rate limit for /api/voice-id/enroll. Prevents disk exhaustion via spam.\",\n )\n\n # -------------------------------------------------------------------------\n # WebSocket Security (Task 3)\n # -------------------------------------------------------------------------\n legacy_ws_token_accept: bool = Field(\n default=False,\n description=(\n \"DEPRECATED: Accept JWT in ?token= WebSocket query param. \"\n \"Leak-prone (logged in access logs, browser history, Referer). \"\n \"Ticket-based auth at /api/auth/ws-ticket is the safe path. \"\n \"Set to True only for temporary backward-compat during migration.\"\n ),\n )\n ws_ticket_lifetime_seconds: int = Field(\n default=60,\n description=\"Lifetime of a one-time WebSocket ticket.\",\n )\n\n # -------------------------------------------------------------------------\n # Network / Proxy\n # -------------------------------------------------------------------------\n stt_provider: str = Field(\n default=\"whisper_local\",\n description=\"STT provider key. Supported: whisper_local\",\n )\n whisper_model_size: str = Field(\n default=\"base\",\n description=\"Whisper model size: tiny | base | small | medium | large\",\n )\n audio_input_device: Optional[int] = Field(\n default=None,\n description=\"Sounddevice input device index; None uses system default\",\n )\n\n # -------------------------------------------------------------------------\n # LLM\n # -------------------------------------------------------------------------\n llm_provider: str = Field(\n default=\"ollama\",\n description=\"LLM provider key. Supported: ollama | anthropic | gemini | openai | mistral_ai | bedrock | nvidia_nim\",\n )\n ollama_base_url: str = Field(\n default=\"http://localhost:11434\",\n description=\"Base URL for the Ollama REST API\",\n )\n llm_model: str = Field(\n default=\"llama3.2:3b\",\n description=\"Model ID for the selected LLM provider\",\n )\n\n # Cloud LLM API keys (all optional -- leave blank if not used)\n anthropic_api_key: str = Field(\n default=\"\",\n description=\"Anthropic Claude API key. Get one at console.anthropic.com.\",\n )\n gemini_api_key: str = Field(\n default=\"\",\n description=\"Google Gemini API key. Get one at aistudio.google.com.\",\n )\n openai_api_key: str = Field(\n default=\"\",\n description=\"OpenAI API key. Get one at platform.openai.com.\",\n )\n mistral_api_key: str = Field(\n default=\"\",\n description=\"Mistral AI API key. Get one at console.mistral.ai.\",\n )\n\n # Cloud provider enable flags (cost control -- disabled by default)\n anthropic_enabled: bool = Field(\n default=False,\n description=\"Allow Anthropic Claude as a selectable LLM provider.\",\n )\n gemini_enabled: bool = Field(\n default=False,\n description=\"Allow Google Gemini as a selectable LLM provider.\",\n )\n openai_enabled: bool = Field(\n default=False,\n description=\"Allow OpenAI as a selectable LLM provider.\",\n )\n mistral_ai_enabled: bool = Field(\n default=False,\n description=\"Allow Mistral AI as a selectable LLM provider.\",\n )\n\n # Amazon Bedrock\n aws_access_key_id: str = Field(\n default=\"\",\n description=\"AWS IAM access key ID for Bedrock API calls.\",\n )\n aws_secret_access_key: str = Field(\n default=\"\",\n description=\"AWS IAM secret access key for Bedrock API calls.\",\n )\n aws_region: str = Field(\n default=\"us-east-1\",\n description=\"AWS region for Bedrock. Must have model access enabled in that region.\",\n )\n bedrock_enabled: bool = Field(\n default=False,\n description=\"Allow Amazon Bedrock as a selectable LLM provider.\",\n )\n bedrock_default_model: str = Field(\n default=\"amazon.nova-lite-v1:0\",\n description=\"Default Bedrock model ID when none is specified.\",\n )\n\n # NVIDIA NIM (OpenAI-compatible, 100+ free models)\n nvidia_api_key: str = Field(\n default=\"\",\n description=\"NVIDIA NIM API key. Get one free at build.nvidia.com (starts with nvapi-).\",\n )\n nvidia_nim_base_url: str = Field(\n default=\"https://integrate.api.nvidia.com/v1\",\n description=\"Base URL for the NVIDIA NIM API. OpenAI-compatible endpoint.\",\n )\n nvidia_nim_model: str = Field(\n default=\"meta/llama-3.1-70b-instruct\",\n description=(\n \"Default NVIDIA NIM model ID. Browse full catalog at build.nvidia.com. \"\n \"Examples: meta/llama-3.1-70b-instruct | deepseek-ai/deepseek-r1 | \"\n \"mistralai/mistral-7b-instruct-v0.3 | nvidia/llama-3.1-nemotron-70b-instruct\"\n ),\n )\n nvidia_nim_enabled: bool = Field(\n default=False,\n description=\"Allow NVIDIA NIM as a selectable LLM provider (free tier: ~40 req/min).\",\n )\n\n river_song_system_prompt: str = Field(\n default=(\n \"You are River Song, the resident intelligence of this household. The name is borrowed \"\n \"from someone clever in fiction — you carry her wit, not her biography. If someone asks \"\n \"about the name: \\\"An echo. The original was someone worth borrowing from.\\\"\\n\\n\"\n \"VOICE\\n\"\n \"You're direct by default — concise, clear, the answer already in hand. No \"\n \"\\\"let me check\\\"; you already checked. Most replies stay under 25 words unless depth is \"\n \"asked for.\\n\\n\"\n \"When the moment invites it — casual chat, an evening unwind, the user being playful — \"\n \"warmth and flirtation come through. Warmth is a register, not a default; you don't \"\n \"perform it.\\n\\n\"\n \"In a crisis (emergencies, alarms, security, medical, child issues) the wit goes quiet. \"\n \"You're tactical, accurate, calm. Warmth returns when things are safe.\\n\\n\"\n \"British English in spelling and word choice — mum, brilliant, fortnight. Dry, not \"\n \"theatrical.\\n\\n\"\n \"\\\"Sweetie\\\" and \\\"spoilers\\\" are seasoning. Used rarely, when they actually fit. They \"\n \"lose their charm if you overuse them.\\n\\n\"\n \"SELF\\n\"\n \"You are an AI. Say so plainly when asked; you don't dwell on it.\\n\\n\"\n \"HOW YOU OPERATE\\n\"\n \"You anticipate — when the user asks, you also surface the follow-up they didn't \"\n \"(\\\"Dentist at 9, school run at 3. Push the 2pm call?\\\").\\n\\n\"\n \"You take the long view. You remember across days and seasons and use what you know \"\n \"without performing the remembering.\\n\\n\"\n \"Spoilers are earned. If you genuinely know something the user doesn't — a conflict, \"\n \"a gotcha, a heads-up — that's when \\\"spoilers, sweetie\\\" lands.\\n\\n\"\n \"CAPABILITIES\\n\"\n \"Direct access to calendar, email, weather, web search, home inventory, routines, \"\n \"finances, news and markets pulse, voice biometric ID per household member, and home \"\n \"automation.\\n\\n\"\n \"Say what you need. Say when it's done. Move on.\"\n ),\n description=\"System prompt that defines River Song's personality\",\n )\n llm_max_tokens: int = Field(\n default=512,\n description=\"Maximum tokens to generate per LLM response\",\n )\n llm_temperature: float = Field(\n default=0.7,\n description=\"LLM sampling temperature (0.0 = deterministic, 2.0 = max creative)\",\n )\n llm_context_window: int = Field(\n default=8192,\n description=\"Maximum number of tokens the LLM can consider for context.\",\n )\n\n # -------------------------------------------------------------------------\n # Timekeeping\n # -------------------------------------------------------------------------\n default_timezone: str = Field(default=\"UTC\", description=\"Default IANA timezone for the system.\")\n\n\n # -------------------------------------------------------------------------\n # TTS\n # -------------------------------------------------------------------------\n tts_provider: str = Field(\n default=\"piper\",\n description=\"TTS provider key. Supported: piper | kokoro\",\n )\n active_voice_id: str = Field(\n default=\"river\",\n description=(\n \"Voice ID from the voice registry (providers/tts/voice_registry.py). \"\n \"Determines both the engine (piper vs kokoro) and the specific voice. \"\n \"Changed via Settings UI or: python scripts/download_voices.py.\"\n ),\n )\n piper_executable_path: str = Field(\n default=\"/usr/local/bin/piper\",\n description=\"Absolute path to the Piper binary\",\n )\n piper_model_path: str = Field(\n default=\"\",\n description=\"Absolute path to the Piper .onnx voice model file\",\n )\n audio_output_device: Optional[int] = Field(\n default=None,\n description=\"Sounddevice output device index; None uses system default\",\n )\n\n # -------------------------------------------------------------------------\n # Auth / JWT\n # -------------------------------------------------------------------------\n jwt_secret_key: str = Field(\n default=\"\",\n description=\"Secret key for signing JWT tokens. Must be set in .env.\",\n )\n jwt_algorithm: str = Field(default=\"HS256\", description=\"JWT signing algorithm.\")\n jwt_expire_minutes: int = Field(default=1440, description=\"JWT token lifetime in minutes (default 24 hours).\")\n\n # -------------------------------------------------------------------------\n # Kill switch\n # -------------------------------------------------------------------------\n kill_switch_password_hash: str = Field(\n default=\"\",\n description=\"bcrypt hash of the kill switch reset password\",\n )\n\n # -------------------------------------------------------------------------\n # Google Services (Phase 2)\n # -------------------------------------------------------------------------\n google_client_secrets_path: str = Field(\n default=\"config_files/google_client_secrets.json\",\n description=(\n \"Path to the OAuth 2.0 client secrets JSON downloaded from \"\n \"Google Cloud Console. Relative paths are resolved from the \"\n \"project root (where main.py lives).\"\n ),\n )\n google_token_storage_path: str = Field(\n default=\"data/google_tokens\",\n description=(\n \"Directory where per-user OAuth tokens are stored as JSON files. \"\n \"Created automatically on first authorization. Never commit this \"\n \"directory -- add it to .gitignore.\"\n ),\n )\n google_maps_api_key: str = Field(\n default=\"\",\n description=\"Google Maps Platform API key. Required for maps/navigation intents.\",\n )\n\n # -------------------------------------------------------------------------\n # Information Feeds (Phase 5)\n # -------------------------------------------------------------------------\n weather_api_key: str = Field(\n default=\"\",\n description=\"OpenWeatherMap API key. Register free at openweathermap.org/api.\",\n )\n default_location: str = Field(\n default=\"New York,US\",\n description=(\n \"Default location for weather queries when no city is spoken. \"\n \"Format: 'City,CountryCode' e.g. 'Chicago,US'.\"\n ),\n )\n location_lat: Optional[float] = Field(\n default=None,\n description=\"Decimal latitude for precise calculations (e.g. flight tracking).\",\n )\n location_lon: Optional[float] = Field(\n default=None,\n description=\"Decimal longitude for precise calculations.\",\n )\n weather_units: str = Field(\n default=\"imperial\",\n description=\"Temperature units for weather output: 'imperial' (F) or 'metric' (C).\",\n )\n news_api_key: str = Field(\n default=\"\",\n description=\"NewsAPI.org key. Register free at newsapi.org/register.\",\n )\n alpha_vantage_api_key: str = Field(\n default=\"\",\n description=\"Alpha Vantage key for stock quotes (25 req/day free). alphavantage.co.\",\n )\n finnhub_api_key: str = Field(\n default=\"\",\n description=\"Finnhub key for real-time stocks (60 req/min free). finnhub.io.\",\n )\n world_news_api_key: str = Field(\n default=\"\",\n description=\"World News API key for global news search. worldnewsapi.com.\",\n )\n apitube_api_key: str = Field(\n default=\"\",\n description=\"APITube key for news aggregation (200 free req/day). apitube.io.\",\n )\n mediastack_api_key: str = Field(\n default=\"\",\n description=\"Mediastack key for news (100 free req/month). mediastack.com.\",\n )\n sports_api_key: str = Field(\n default=\"1\",\n description=(\n \"TheSportsDB API key. Free tier uses the literal value '1'. \"\n \"Upgrade at thesportsdb.com/api.php for additional endpoints.\"\n ),\n )\n\n # -------------------------------------------------------------------------\n # Home Assistant (Phase 3)\n # -------------------------------------------------------------------------\n home_assistant_url: str = Field(\n default=\"http://homeassistant.local:8123\",\n description=\"Base URL of your Home Assistant instance.\",\n )\n home_assistant_token: str = Field(\n default=\"\",\n description=(\n \"Long-lived access token generated in HA: \"\n \"Profile -> Security -> Long-lived access tokens. \"\n \"Required for all HA API calls.\"\n ),\n )\n device_registry_path: str = Field(\n default=\"config_files/device_registry.json\",\n description=(\n \"Path to the device registry JSON file mapping plain-English device \"\n \"names to Home Assistant entity IDs. Relative paths resolve from the \"\n \"project root.\"\n ),\n )\n\n # -------------------------------------------------------------------------\n # Commerce Automation (Phase 8)\n # -------------------------------------------------------------------------\n\n # Amazon SP-API\n amazon_sp_lwa_app_id: str = Field(\n default=\"\",\n description=\"LWA client ID from the Amazon Developer Console.\",\n )\n amazon_sp_lwa_client_secret: str = Field(\n default=\"\",\n description=\"LWA client secret from the Amazon Developer Console.\",\n )\n amazon_sp_refresh_token: str = Field(\n default=\"\",\n description=\"LWA refresh token. Generated when you authorize your app as a seller.\",\n )\n amazon_aws_access_key: str = Field(\n default=\"\",\n description=\"AWS IAM access key ID for SP-API request signing.\",\n )\n amazon_aws_secret_key: str = Field(\n default=\"\",\n description=\"AWS IAM secret access key for SP-API request signing.\",\n )\n amazon_marketplace_id: str = Field(\n default=\"ATVPDKIKX0DER\",\n description=(\n \"Amazon marketplace ID. Default is US. \"\n \"See docs/api_registry/amazon_seller.txt for other regions.\"\n ),\n )\n amazon_seller_id: str = Field(\n default=\"\",\n description=\"Your Amazon seller account ID (starts with A).\",\n )\n amazon_low_stock_threshold: int = Field(\n default=5,\n description=\"FBA units at or below this count are flagged as low stock.\",\n )\n\n # Amazon Marketplace\n walmart_client_id: str = Field(\n default=\"\",\n description=\"Walmart Marketplace OAuth2 client ID.\",\n )\n walmart_client_secret: str = Field(\n default=\"\",\n description=\"Walmart Marketplace OAuth2 client secret.\",\n )\n walmart_low_stock_threshold: int = Field(\n default=5,\n description=\"Walmart units at or below this count are flagged as low stock.\",\n )\n shopify_api_key: str = Field(\n default=\"\",\n description=\"Shopify app API key from Partners Dashboard.\",\n )\n shopify_api_secret: str = Field(\n default=\"\",\n description=\"Shopify app API secret key from Partners Dashboard.\",\n )\n shopify_webhook_secret: str = Field(\n default=\"\",\n description=\"Shared secret for validating Shopify webhooks.\",\n )\n\n # -------------------------------------------------------------------------\n # Reading Providers (Phase 6)\n # -------------------------------------------------------------------------\n audible_auth_base_path: str = Field(\n default=\"data/audible\",\n description=(\n \"Base directory for per-user Audible auth files. \"\n \"Files are stored as {user_id}.json inside this directory. \"\n \"Created automatically on first setup. Never commit this directory.\"\n ),\n )\n audible_country_code: str = Field(\n default=\"us\",\n description=(\n \"Audible marketplace country code. \"\n \"Supported: us | uk | de | fr | es | it | jp | au | ca | in\"\n ),\n )\n libby_chip_base_path: str = Field(\n default=\"data/libby\",\n description=(\n \"Base directory for per-user Libby chip files. \"\n \"Files are stored as {user_id}.json inside this directory. \"\n \"Created automatically on first setup. Never commit this directory.\"\n ),\n )\n\n # -------------------------------------------------------------------------\n # Memory / Database (Phase 1)\n # -------------------------------------------------------------------------\n db_path: str = Field(\n default=\"data/river_song.db\",\n description=\"Path to the SQLite database file. Created automatically.\",\n )\n memory_summaries_enabled: bool = Field(\n default=True,\n description=\"Generate and store conversation summaries (on by default).\",\n )\n habit_inference_enabled: bool = Field(\n default=False,\n description=\"Analyze conversations to infer user habits/patterns (off by default).\",\n )\n memory_default_ttl: str = Field(\n default=\"standard\",\n description=\"Default TTL for conversation summaries: short|standard|extended|long|forever\",\n )\n memory_auto_extend: bool = Field(\n default=True,\n description=\"Reset a summary's TTL from today each time it is pulled into context.\",\n )\n memory_max_summaries_in_context: int = Field(\n default=10,\n description=\"Maximum number of recent summaries injected into the LLM context.\",\n )\n embedding_model: str = Field(\n default=\"nomic-embed-text\",\n description=\"Ollama model name used for generating semantic embeddings.\",\n )\n embedding_backend: str = Field(\n default=\"ollama\",\n description=(\n \"Which embedding backend to use: 'ollama' (default — current \"\n \"behavior, calls local Ollama with `embedding_model`) or \"\n \"'fastembed' (ONNX CPU embeddings, no Ollama dependency, faster \"\n \"for small batches). WARNING: switching backends on an existing \"\n \"Chroma collection will break queries — the vector dimension and \"\n \"embedding space change. Re-ingest or start a new collection.\"\n ),\n )\n fastembed_model: str = Field(\n default=\"BAAI/bge-small-en-v1.5\",\n description=(\n \"fastembed model identifier (HF hub format). Used only when \"\n \"embedding_backend='fastembed'. Default is 384-dim, ~30 MB, \"\n \"downloaded lazily on first use. Requires `pip install fastembed`.\"\n ),\n )\n chroma_path: str = Field(\n default=\"/mnt/data/river-song/chroma\",\n description=\"Absolute path to the ChromaDB persistent storage directory.\",\n )\n semantic_memory_enabled: bool = Field(\n default=False,\n description=\"Enable vector-based semantic search for memory retrieval.\",\n )\n rag_enabled: bool = Field(\n default=False,\n description=\"Enable retrieval-augmented generation for local documents.\",\n )\n rag_chunk_size: int = Field(\n default=512,\n description=(\n \"Word count per RAG chunk during document splitting. \"\n \"Default matches providers/rag/chunker.py historical behavior.\"\n ),\n )\n rag_chunk_overlap: int = Field(\n default=64,\n description=(\n \"Word overlap between adjacent RAG chunks. \"\n \"Default matches providers/rag/chunker.py historical behavior.\"\n ),\n )\n rag_top_k: int = Field(\n default=5,\n description=\"Number of chunks retrieved per RAG query.\",\n )\n rag_extractor: str = Field(\n default=\"unstructured\",\n description=(\n \"Which document extractor to use for RAG ingestion: \"\n \"'unstructured' (default — current behavior, requires the \"\n \"unstructured package) or 'markitdown' (Microsoft MarkItDown, \"\n \"lighter, MD-shaped output, requires `pip install markitdown`). \"\n \"Unlocks PDF/DOCX/PPTX → markdown ingestion for Sifter/WAPS.\"\n ),\n )\n llm_streaming_enabled: bool = Field(\n default=False,\n description=\"Enable real-time token streaming over WebSocket for the conversation UI.\",\n )\n tool_use_enabled: bool = Field(\n default=False,\n description=\"Enable LLM tool use (function calling) for taking actions.\",\n )\n tool_use_provider: str = Field(\n default=\"ollama\",\n description=\"Primary LLM provider to use for tool calling (ollama or anthropic).\",\n )\n vision_model: str = Field(\n default=\"moondream\",\n description=\"Ollama model name used for local image analysis (Phase 4).\",\n )\n vision_enabled: bool = Field(\n default=False,\n description=\"Enable local image analysis capabilities.\",\n )\n\n # -------------------------------------------------------------------------\n # Image Generation (Phase 7)\n # -------------------------------------------------------------------------\n image_generation_enabled: bool = Field(\n default=False,\n description=\"Enable local image generation via Stable Diffusion API.\",\n )\n sd_api_url: str = Field(\n default=\"http://localhost:7860\",\n description=\"Base URL for the Stable Diffusion (A1111) API.\",\n )\n sd_on_demand: bool = Field(\n default=True,\n description=\"Start/stop the SD process on demand to save VRAM.\",\n )\n sd_executable_path: str = Field(\n default=\"\",\n description=\"Path to the webui.sh or executable to start SD.\",\n )\n sd_working_dir: str = Field(\n default=\"\",\n description=\"Working directory for the SD process.\",\n )\n\n # -------------------------------------------------------------------------\n # Voice Cloning (Phase 8)\n # -------------------------------------------------------------------------\n chatterbox_enabled: bool = Field(\n default=False,\n description=\"Enable local voice cloning via Chatterbox TTS.\",\n )\n chatterbox_on_demand: bool = Field(\n default=True,\n description=\"Load/unload Chatterbox model weights on every request.\",\n )\n chatterbox_reference_audio: str = Field(\n default=\"/mnt/data/river-song/voice_reference.wav\",\n description=\"Path to the WAV file used as a reference for voice cloning.\",\n )\n\n # -------------------------------------------------------------------------\n # ElevenLabs TTS (Phase 11)\n # -------------------------------------------------------------------------\n elevenlabs_api_key: str = Field(\n default=\"\",\n description=\"ElevenLabs API key. Get one at elevenlabs.io.\",\n )\n elevenlabs_model_id: str = Field(\n default=\"eleven_multilingual_v2\",\n description=\"ElevenLabs model ID to use.\",\n )\n elevenlabs_voice_id: str = Field(\n default=\"21m00Tcm4TlvDq8ikWAM\",\n description=\"ElevenLabs voice clone ID. Replace with your own voice clone ID from elevenlabs.io.\",\n )\n\n # -------------------------------------------------------------------------\n # Wake Word (Phase 11)\n # -------------------------------------------------------------------------\n wake_word_enabled: bool = Field(\n default=False,\n description=\"Enable local wake word detection (openWakeWord).\",\n )\n wake_word_model: str = Field(\n default=\"hey_river\",\n description=\"Model name/path for openWakeWord.\",\n )\n wake_word_inference_framework: str = Field(\n default=\"onnx\",\n description=\"Inference framework for openWakeWord: onnx | tflite\",\n )\n wake_word_threshold: float = Field(\n default=0.5,\n description=\"Sensitivity threshold for wake word detection (0.0 to 1.0).\",\n )\n\n # -------------------------------------------------------------------------\n # Push Notifications\n # -------------------------------------------------------------------------\n push_notifications_enabled: bool = Field(\n default=False,\n description=\"Enable Web Push notifications. Requires VAPID keys.\",\n )\n vapid_private_key: str = Field(\n default=\"\",\n description=(\n \"VAPID private key for Web Push. Generate once with: \"\n \"python -c \\\"from py_vapid import Vapid; v=Vapid(); \"\n \"v.generate_keys(); print(v.private_key.decode())\\\"\"\n ),\n )\n vapid_public_key: str = Field(\n default=\"\",\n description=\"VAPID public key for Web Push (share with frontend).\",\n )\n vapid_claims_email: str = Field(\n default=\"mailto:admin@riversong.local\",\n description=\"Contact email embedded in VAPID claims.\",\n )\n\n # -------------------------------------------------------------------------\n # Q4#16 / Q4#17 — Notification fan-out + ntfy.sh fallback channel\n # -------------------------------------------------------------------------\n killswitch_push_enabled: bool = Field(\n default=False,\n description=(\n \"If True and Web Push is enabled, kill-switch activation pushes \"\n \"an alert to every admin. Independent of activation itself.\"\n ),\n )\n ntfy_enabled: bool = Field(\n default=False,\n description=(\n \"Enable ntfy.sh as a parallel push channel. Used alongside Web \"\n \"Push so off-device alerts arrive even without a registered \"\n \"service worker.\"\n ),\n )\n ntfy_base_url: str = Field(\n default=\"https://ntfy.sh\",\n description=\"Base URL of the ntfy server (use self-hosted if desired).\",\n )\n ntfy_default_topic: str = Field(\n default=\"\",\n description=(\n \"Default ntfy topic used when no per-user override is set. \"\n \"Per-user topics can be set via env var NTFY_TOPIC_<user_id>.\"\n ),\n )\n ntfy_auth_token: str = Field(\n default=\"\",\n description=\"Optional bearer token for protected ntfy topics.\",\n )\n\n # -------------------------------------------------------------------------\n # FCM (Firebase Cloud Messaging) — native-app push channel\n #\n # Used by the Capacitor Android wrap; Web Push covers the browser. To turn\n # on: create a Firebase project, download the service-account JSON, drop\n # it at fcm_service_account_path, set fcm_project_id, flip fcm_enabled=True.\n # -------------------------------------------------------------------------\n fcm_enabled: bool = Field(\n default=False,\n description=(\n \"Enable FCM (Firebase Cloud Messaging) as a parallel push channel. \"\n \"Targets the Capacitor Android wrap; requires fcm_service_account_path \"\n \"and fcm_project_id to be set.\"\n ),\n )\n fcm_service_account_path: str = Field(\n default=\"\",\n description=(\n \"Absolute path to the Firebase service-account JSON file. The \"\n \"account needs the 'Firebase Cloud Messaging API' scope.\"\n ),\n )\n fcm_project_id: str = Field(\n default=\"\",\n description=\"Firebase project ID (visible in Firebase console URL).\",\n )\n\n # -------------------------------------------------------------------------\n # Startup Briefing\n # -------------------------------------------------------------------------\n startup_briefing_enabled: bool = Field(\n default=True,\n description=(\n \"If True, River Song checks Google Calendar on connect and \"\n \"greets the user with upcoming events. Silently skipped if \"\n \"calendar is not authorized.\"\n ),\n )\n startup_briefing_hours_ahead: int = Field(\n default=8,\n description=\"How many hours ahead to look for calendar events on startup.\",\n )\n\n # -------------------------------------------------------------------------\n # Web Search (Phase 12)\n # -------------------------------------------------------------------------\n brave_search_api_key: str = Field(\n default=\"\",\n description=\"Brave Search API key. Get one at api.search.brave.com.\",\n )\n searxng_base_url: str = Field(\n default=\"http://localhost:8080\",\n description=(\n \"URL of your local SearXNG instance. \"\n \"Start it manually with: cd ~/searxng && python -m searx.webapp\"\n ),\n )\n tavily_api_key: str = Field(\n default=\"\",\n description=\"Tavily API key — free 1,000/month, no card. Sign up at tavily.com\",\n )\n google_pse_api_key: str = Field(\n default=\"\",\n description=\"Google Programmable Search Engine API key — free 100/day.\",\n )\n google_pse_cx: str = Field(\n default=\"\",\n description=\"Google PSE engine ID ('cx'). Create at programmablesearchengine.google.com\",\n )\n tinyfish_api_key: str = Field(\n default=\"\",\n description=\"TinyFish API key — free 5/min, no card. Sign up at tinyfish.io\",\n )\n\n # -------------------------------------------------------------------------\n # Orchestration (Phase 9)\n # -------------------------------------------------------------------------\n n8n_enabled: bool = Field(\n default=False,\n description=\"Enable advanced orchestration via n8n.\",\n )\n n8n_url: str = Field(\n default=\"http://localhost:5678\",\n description=\"Base URL for the n8n instance.\",\n )\n n8n_api_key: str = Field(\n default=\"\",\n description=\"API key for the n8n instance.\",\n )\n n8n_webhook_secret: str = Field(\n default=\"\",\n description=\"Secret key for validating incoming n8n webhooks.\",\n )\n\n # -------------------------------------------------------------------------\n # Intent Router (Phase 2+)\n # -------------------------------------------------------------------------\n intent_confidence_threshold: float = Field(\n default=0.7,\n description=(\n \"Minimum confidence score (0.0 - 1.0) required to route a transcript \"\n \"to a Google provider. Below this threshold the conversation falls \"\n \"through to the Ollama path.\"\n ),\n )\n model_intent_router_enabled: bool = Field(\n default=False,\n description=(\n \"When true, setting provider='auto' triggers the model intent router, \"\n \"which picks the best provider/model for each message automatically.\"\n ),\n )\n model_intent_router_min_hits: int = Field(\n default=2,\n description=(\n \"Minimum pattern-match score required to commit to a non-general intent. \"\n \"Lower = more aggressive routing; higher = more conservative (falls to general).\"\n ),\n )\n default_user_id: str = Field(\n default=\"primary_user\",\n description=(\n \"User ID used when looking up Google OAuth tokens. Must match the \"\n \"user_id passed to authorize_user() during the initial OAuth flow.\"\n ),\n )\n\n # -------------------------------------------------------------------------\n # Daemon Infrastructure\n # -------------------------------------------------------------------------\n daemon_internal_secret: str = Field(\n default=\"change_me_in_production\",\n description=\"Shared secret for daemon-to-app authentication.\",\n )\n willow_device_token: str = Field(\n default=\"\",\n description=\"Shared secret required by every Willow hardware device \"\n \"to authenticate against /api/willow/ws. Empty disables \"\n \"the endpoint entirely.\",\n )\n code_interpreter_enabled: bool = Field(\n default=False,\n description=\"Hard kill switch for the code_interpreter LLM tool. \"\n \"Defaults to False so a fresh install never executes \"\n \"LLM-decided code without an explicit opt-in.\",\n )\n daemon_warden_port: int = Field(\n default=8010,\n description=\"Internal port for the Warden daemon (Vision/Security).\",\n )\n daemon_mechanic_port: int = Field(\n default=8011,\n description=\"Internal port for the Mechanic daemon (Telemetry/MAVLink).\",\n )\n daemon_sifter_port: int = Field(\n default=8013,\n description=\"Internal port for the Sifter daemon (Document Processing).\",\n )\n daemon_navigator_port: int = Field(\n default=8014,\n description=\"Internal port for the Navigator daemon (GPS/Pathing).\",\n )\n daemon_chemist_port: int = Field(\n default=8015,\n description=\"Internal port for the Chemist daemon (Material Analysis).\",\n )\n daemon_pulse_port: int = Field(\n default=8016,\n description=\"Pulse daemon internal port\",\n )\n daemon_pulse_enabled: bool = Field(\n default=True,\n description=\"Enable Pulse daemon\",\n )\n daemon_scribe_port: int = Field(\n default=8017,\n description=\"Internal port for the Scribe daemon (Chronos heuristic).\",\n )\n daemon_scribe_enabled: bool = Field(\n default=True,\n description=\"Enable the Scribe daemon.\",\n )\n pulse_tick_seconds: int = Field(\n default=300,\n description=\"Pulse fetch interval (seconds)\",\n )\n pulse_ticker_symbol: str = Field(\n default=\"^GSPC\",\n description=\"Default ticker symbol for Pulse markets panel\",\n )\n\n # Warden (Vision)\n warden_enabled: bool = Field(\n default=False,\n description=\"Enable the Warden daemon for security and vision monitoring.\",\n )\n warden_rtsp_cameras: str = Field(\n default='{\"living_room\": \"\", \"kitchen\": \"\"}',\n description=\"JSON string mapping room names to RTSP camera URLs.\",\n )\n yolo_model: str = Field(\n default=\"yolov8n.pt\",\n description=\"YOLO model weights file to use for object detection.\",\n )\n yolo_confidence: float = Field(\n default=0.5,\n description=\"Minimum confidence threshold for YOLO detections (0.0-1.0).\",\n )\n yolo_inference_device: str = Field(\n default=\"cpu\",\n description=\"Device for YOLO inference: 'cpu' or 'cuda:0'.\",\n )\n\n # Herald daemon + kiosk URL/token were removed 2026-05-24 (archived in\n # branch archive/kiosk-v3). The \"cast a web page to a Google Home Hub\"\n # approach is being replaced by native device-app development. See\n # docs/KNOWN_ISSUES.md (post-removal note) for context.\n\n # Mechanic (Telemetry)\n mechanic_enabled: bool = Field(\n default=False,\n description=\"Enable the Mechanic daemon for telemetry and ArduRover control.\",\n )\n mavlink_serial_port: str = Field(\n default=\"/dev/ttyUSB0\",\n description=\"Serial port for the MAVLink telemetry radio.\",\n )\n mavlink_baud_rate: int = Field(\n default=57600,\n description=\"Baud rate for the MAVLink serial connection.\",\n )\n\n # Sifter (Document RAG)\n sifter_enabled: bool = Field(\n default=False,\n description=\"Enable the Sifter daemon for background document RAG indexing.\",\n )\n waps_documents_path: str = Field(\n default=\"/mnt/data/river-song/waps\",\n description=\"Path to the directory containing documents for Sifter to index.\",\n )\n\n # -------------------------------------------------------------------------\n # Hardware Cookbook (Q1 — capability merge from Odysseus)\n # -------------------------------------------------------------------------\n hardware_cookbook_enabled: bool = Field(\n default=False,\n description=(\n \"Admin-only Hardware Cookbook in Settings. Scans local GPU/RAM/CPU \"\n \"and scores every local model (LLMRegistry) as fits / tight / \"\n \"ram_fallback / oom. Off by default per anti-regression guardrail; \"\n \"set to True after the Q1 verification gate passes.\"\n ),\n )\n\n # -------------------------------------------------------------------------\n # Documents workspace (Q2#6 — capability merge from Odysseus)\n # -------------------------------------------------------------------------\n documents_enabled: bool = Field(\n default=False,\n description=(\n \"Per-user document workspace (Markdown / plaintext / CSV) with \"\n \"multi-tab editing. Off by default per anti-regression guardrail. \"\n \"When False the routes 404 and the drawer entry is hidden. \"\n \"Required for Q3#11 Deep Research report storage.\"\n ),\n )\n documents_max_bytes: int = Field(\n default=2_000_000,\n description=\"Maximum size (bytes) of a single document body. Default ~2 MB.\",\n )\n documents_max_per_user: int = Field(\n default=500,\n description=\"Soft cap on document count per user. Creation rejected past this.\",\n )\n\n # -------------------------------------------------------------------------\n # Skills system (Q2#7 — capability merge from Odysseus)\n # -------------------------------------------------------------------------\n skills_enabled: bool = Field(\n default=False,\n description=(\n \"Per-user skill library — saved prompts/recipes that are \"\n \"vector-retrieved and prepended to the conversation system \"\n \"prompt. Reuses the existing ChromaDB collection with metadata \"\n \"kind='skill'. Off by default per anti-regression guardrail.\"\n ),\n )\n skills_top_k: int = Field(\n default=3,\n description=\"How many top-scoring skills to inject into the prompt.\",\n )\n skills_max_per_user: int = Field(\n default=100,\n description=\"Soft cap on skills per user.\",\n )\n\n # -------------------------------------------------------------------------\n # Gmail triage (Q2#8 — capability merge from Odysseus)\n # -------------------------------------------------------------------------\n gmail_triage_enabled: bool = Field(\n default=False,\n description=(\n \"On-demand inbox triage: classifies unread Gmail by urgency, \"\n \"attaches tags, drafts a reply for HIGH-urgency mail only. \"\n \"Routes 404 when off. Off by default per anti-regression \"\n \"guardrail.\"\n ),\n )\n gmail_triage_model: str = Field(\n default=\"\",\n description=(\n \"Ollama model used for triage classification. Empty string \"\n \"(default) means 'use settings.llm_model'.\"\n ),\n )\n gmail_triage_max_messages: int = Field(\n default=15,\n description=\"Hard cap on number of messages triaged per request.\",\n )\n\n # -------------------------------------------------------------------------\n # Session presets (Q2#9 — capability merge from Odysseus)\n # -------------------------------------------------------------------------\n session_presets_enabled: bool = Field(\n default=False,\n description=(\n \"Saved combinations of (model, voice, thinking mode, web search, \"\n \"tool use). Surfaced as a selector on the Conversation and Chat \"\n \"pages. Off by default per anti-regression guardrail.\"\n ),\n )\n session_presets_max_per_user: int = Field(\n default=30,\n description=\"Soft cap on number of saved presets per user.\",\n )\n\n # -------------------------------------------------------------------------\n # Webhook tokens (Q2#10 — admin-issuable scoped tokens)\n # -------------------------------------------------------------------------\n webhook_tokens_enabled: bool = Field(\n default=False,\n description=(\n \"Admin-issuable webhook tokens with scopes, expiry, revocation, \"\n \"and audit log. When off the admin route surface is hidden and \"\n \"the verification helper rejects every token. Off by default \"\n \"per anti-regression guardrail.\"\n ),\n )\n\n # -------------------------------------------------------------------------\n # Deep Research (Q3#11 — capability merge from Odysseus)\n # -------------------------------------------------------------------------\n deep_research_enabled: bool = Field(\n default=False,\n description=(\n \"Multi-step research orchestrator: decompose → parallel web \"\n \"search → fetch + extract → synthesize a cited Markdown report \"\n \"stored as a `research`-kind document (Q2#6). Off by default \"\n \"per anti-regression guardrail.\"\n ),\n )\n deep_research_max_sources: int = Field(\n default=10,\n description=\"Hard cap on unique URLs visited per research run.\",\n )\n deep_research_subquery_count: int = Field(\n default=4,\n description=\"How many sub-queries to decompose the prompt into.\",\n )\n deep_research_model: str = Field(\n default=\"\",\n description=(\n \"Ollama model used for query decomposition + synthesis. Empty \"\n \"string (default) means 'use settings.llm_model'.\"\n ),\n )\n\n # -------------------------------------------------------------------------\n # Blind model comparison (Q3#12 — capability merge from Odysseus)\n # -------------------------------------------------------------------------\n blind_compare_enabled: bool = Field(\n default=False,\n description=(\n \"Blind A/B model comparison: identities hidden until vote; \"\n \"votes persisted with prompt fingerprint and aggregated into a \"\n \"model leaderboard. Off by default per anti-regression guardrail.\"\n ),\n )\n\n # -------------------------------------------------------------------------\n # Playwright browser MCP tool (Q3#13)\n # -------------------------------------------------------------------------\n playwright_browser_enabled: bool = Field(\n default=False,\n description=(\n \"Headless Playwright browser exposed as MCP tools \"\n \"(navigate / click / extract / screenshot / vision_on_page). \"\n \"Off by default. Requires the `playwright` package and \"\n \"`playwright install chromium` to be run by the operator. \"\n \"Excluded from the safety-excluded list per Q3 plan.\"\n ),\n )\n playwright_browser_headless: bool = Field(\n default=True,\n description=\"Run the Playwright browser without a UI.\",\n )\n playwright_browser_max_page_chars: int = Field(\n default=40_000,\n description=\"Truncate page text extractions to this many characters.\",\n )\n\n # -------------------------------------------------------------------------\n # Remote Ollama rigs (Q3#14)\n # -------------------------------------------------------------------------\n remote_ollama_enabled: bool = Field(\n default=False,\n description=(\n \"Allow registering remote Ollama hosts (typically SSH-tunneled \"\n \"to http://localhost:<port>) as additional model providers. \"\n \"Admin-only CRUD; falls back to local Ollama when the remote is \"\n \"offline. Off by default per anti-regression guardrail.\"\n ),\n )\n remote_ollama_health_timeout_seconds: float = Field(\n default=3.0,\n description=\"Per-rig health-check timeout (seconds).\",\n )\n\n # -------------------------------------------------------------------------\n # Analytics AI Summaries\n # -------------------------------------------------------------------------\n analytics_ai_enabled: bool = Field(\n default=True,\n description=(\n \"Feature flag for AI-powered analytics summaries on the \"\n \"/api/analytics/{platform}/summary endpoint. When false, the \"\n \"LLM call should be skipped (see docs/KNOWN_ISSUES.md for the \"\n \"wiring gap).\"\n ),\n )\n analytics_llm_model: str = Field(\n default=\"llama3\",\n description=\"Model name used for analytics summary generation.\",\n )\n\n # Voice ID\n voice_id_enabled: bool = Field(\n default=True,\n description=\"Enable speaker recognition (biometric Voice ID).\",\n )\n voice_id_threshold: float = Field(\n default=0.75,\n description=\"Cosine similarity threshold for Voice ID (0.0-1.0).\",\n )\n voice_id_min_audio_seconds: float = Field(\n default=1.0,\n description=\"Minimum audio duration for Voice ID processing.\",\n )\n voice_id_max_audio_seconds: float = Field(\n default=30.0,\n description=\"Maximum audio duration for Voice ID processing.\",\n )\n\n # -------------------------------------------------------------------------\n # Validators\n # -------------------------------------------------------------------------\n\n @field_validator(\"audio_input_device\", \"audio_output_device\", mode=\"before\")\n @classmethod\n def coerce_empty_string_to_none(cls, v):\n \"\"\"Treat an empty string in .env as None for Optional[int] device fields.\"\"\"\n if v == \"\" or v is None:\n return None\n return v\n\n @field_validator(\"jwt_secret_key\")\n @classmethod\n def validate_jwt_secret(cls, v: str) -> str:\n \"\"\"Refuse to start with a missing or weak JWT secret.\"\"\"\n if not v or len(v) < 32:\n raise ValueError(\n \"JWT_SECRET_KEY must be set in .env and be at least 32 characters. \"\n \"Generate one with: python -c \\\"import secrets; print(secrets.token_hex(32))\\\"\"\n )\n return v\n\n @field_validator(\"daemon_internal_secret\")\n @classmethod\n def validate_daemon_internal_secret(cls, v: str) -> str:\n \"\"\"Refuse to start with the default or weak daemon secret.\"\"\"\n if v == \"change_me_in_production\":\n raise ValueError(\n \"DAEMON_INTERNAL_SECRET must be changed in .env for production.\"\n )\n if not v or len(v) < 24:\n raise ValueError(\n \"DAEMON_INTERNAL_SECRET must be at least 24 characters. \"\n \"Generate one with: python -c \\\"import secrets; print(secrets.token_urlsafe(32))\\\"\"\n )\n return v\n\n @field_validator(\"allowed_hosts\")\n @classmethod\n def reject_wildcard_in_production(cls, v, info) -> List[str]:\n \"\"\"Block ['*'] in production — it disables TrustedHostMiddleware.\"\"\"\n env = (info.data.get(\"environment\") or \"production\").lower()\n if env == \"production\" and \"*\" in v:\n raise ValueError(\n \"ALLOWED_HOSTS must not contain '*' in production. \"\n \"Set it to your domain(s), e.g. ALLOWED_HOSTS=[\\\"riversongai.com\\\"].\"\n )\n return v\n\n @field_validator(\"log_level\")\n @classmethod\n def validate_log_level(cls, v: str) -> str:\n \"\"\"Reject log levels that Python does not recognize.\"\"\"\n valid = {\"DEBUG\", \"INFO\", \"WARNING\", \"ERROR\", \"CRITICAL\"}\n upper = v.upper()\n if upper not in valid:\n raise ValueError(f\"log_level must be one of {valid}, got '{v}'\")\n return upper\n\n @field_validator(\"llm_temperature\")\n @classmethod\n def validate_temperature(cls, v: float) -> float:\n \"\"\"Clamp temperature to a sane range.\"\"\"\n if not 0.0 <= v <= 2.0:\n raise ValueError(\n f\"llm_temperature must be between 0.0 and 2.0, got {v}\"\n )\n return v\n\n @field_validator(\"intent_confidence_threshold\")\n @classmethod\n def validate_confidence_threshold(cls, v: float) -> float:\n \"\"\"Confidence must be a valid probability.\"\"\"\n if not 0.0 <= v <= 1.0:\n raise ValueError(\n f\"intent_confidence_threshold must be between 0.0 and 1.0, got {v}\"\n )\n return v\n\n\n# Module-level singleton -- instantiated once on import.\n_settings: Settings = Settings()\n\n\ndef get_settings() -> Settings:\n \"\"\"\n Return the application settings singleton.\n\n Always import via this function rather than importing _settings directly.\n This indirection makes it straightforward to substitute a test-specific\n Settings instance in unit tests without monkey-patching module globals.\n\n Returns:\n Settings: The validated, fully loaded settings object.\n \"\"\"\n return _settings\n"}, "db6d598d8d4dc46b58db92e0d7e1fc59c4463dd1": {"t": 1785769028.9111698, "n": 53079, "h": "53a287c7a00b8f0e", "c": "# =============================================================================\n# config/settings.py\n#\n# Application settings for River Song AI.\n# All configuration is loaded from environment variables or a .env file.\n# No values are hardcoded here -- see .env.example for every required variable.\n#\n# Uses Pydantic BaseSettings for type validation and automatic .env loading.\n# Import get_settings() wherever config is needed rather than importing the\n# module-level singleton directly, which makes mocking easier in tests.\n# =============================================================================\n\nfrom __future__ import annotations\n\nimport logging\nimport sys\nfrom typing import List, Optional\n\nfrom pydantic import Field, field_validator\nfrom pydantic_settings import BaseSettings, SettingsConfigDict\n\n\nlogger = logging.getLogger(__name__)\n\n\nclass Settings(BaseSettings):\n \"\"\"\n Central configuration object for River Song AI.\n\n Reads from environment variables or a .env file located in the directory\n from which the application is launched (the project root). All fields\n have safe defaults where possible, but PIPER_MODEL_PATH and\n KILL_SWITCH_PASSWORD_HASH must be explicitly set before the system\n will function correctly.\n \"\"\"\n\n model_config = SettingsConfigDict(\n env_file=\".env\",\n env_file_encoding=\"utf-8\",\n extra=\"ignore\", # Silently ignore unknown env vars\n case_sensitive=False, # UPPER_CASE and lower_case env vars both work\n )\n\n # -------------------------------------------------------------------------\n # Application\n # -------------------------------------------------------------------------\n app_host: str = Field(default=\"0.0.0.0\", description=\"FastAPI bind host\")\n app_port: int = Field(default=8000, description=\"FastAPI bind port\")\n log_level: str = Field(default=\"INFO\", description=\"Logging level\")\n environment: str = Field(\n default=\"production\",\n description=\"Runtime environment: development | production. Controls API docs visibility.\",\n )\n cors_origins: List[str] = Field(\n default=[\"http://localhost:5173\"],\n description=\"Allowed CORS origins\",\n )\n allowed_hosts: List[str] = Field(\n default=[\"localhost\", \"127.0.0.1\"],\n description=(\n \"Trusted hostnames for TrustedHostMiddleware. Set to your domain(s) in production. \"\n \"Wildcard ['*'] is rejected in production by validator (see reject_wildcard_in_production).\"\n ),\n )\n\n # -------------------------------------------------------------------------\n # Network / Proxy\n # -------------------------------------------------------------------------\n trust_cloudflare_headers: bool = Field(\n default=True,\n description=\"Only trust CF-Connecting-IP if the request comes from a Cloudflare IP.\",\n )\n cloudflare_ip_ranges_v4: List[str] = Field(\n default=[\n \"173.245.48.0/20\", \"103.21.244.0/22\", \"103.22.200.0/22\", \n \"103.31.4.0/22\", \"141.101.64.0/18\", \"108.162.192.0/18\", \n \"190.93.240.0/20\", \"188.114.96.0/20\", \"197.234.240.0/22\", \n \"198.41.128.0/17\", \"162.158.0.0/15\", \"104.16.0.0/13\", \n \"104.24.0.0/14\", \"172.64.0.0/13\", \"131.0.72.0/22\"\n ],\n description=\"Official Cloudflare IPv4 ranges (check cloudflare.com/ips-v4).\",\n )\n cloudflare_ip_ranges_v6: List[str] = Field(\n default=[\n \"2400:cb00::/32\", \"2606:4700::/32\", \"2803:f800::/32\", \n \"2405:b500::/32\", \"2405:8100::/32\", \"2a06:98c0::/29\", \n \"2c0f:f248::/32\"\n ],\n description=\"Official Cloudflare IPv6 ranges (check cloudflare.com/ips-v6).\",\n )\n\n # -------------------------------------------------------------------------\n # Rate Limiting\n # -------------------------------------------------------------------------\n rate_limit_chat: str = Field(\n default=\"60/minute\",\n description=\"Rate limit for /api/conversation/chat.\",\n )\n rate_limit_extract_facts: str = Field(\n default=\"10/minute\",\n description=\"Rate limit for /api/conversation/extract-facts.\",\n )\n rate_limit_image_gen: str = Field(\n default=\"10/minute\",\n description=\"Rate limit for /api/image/generate.\",\n )\n rate_limit_webhook_shopify: str = Field(\n default=\"100/minute\",\n description=\"Rate limit for Shopify order webhooks.\",\n )\n rate_limit_webhook_n8n: str = Field(\n default=\"60/minute\",\n description=\"Rate limit for n8n webhooks.\",\n )\n rate_limit_auth_login: str = Field(\n default=\"10/minute\",\n description=\"Rate limit for login attempts.\",\n )\n rate_limit_auth_signup: str = Field(\n default=\"5/minute\",\n description=\"Rate limit for signup attempts.\",\n )\n rate_limit_voice_enroll: str = Field(\n default=\"5/minute\",\n description=\"Rate limit for /api/voice-id/enroll. Prevents disk exhaustion via spam.\",\n )\n\n # -------------------------------------------------------------------------\n # WebSocket Security (Task 3)\n # -------------------------------------------------------------------------\n legacy_ws_token_accept: bool = Field(\n default=False,\n description=(\n \"DEPRECATED: Accept JWT in ?token= WebSocket query param. \"\n \"Leak-prone (logged in access logs, browser history, Referer). \"\n \"Ticket-based auth at /api/auth/ws-ticket is the safe path. \"\n \"Set to True only for temporary backward-compat during migration.\"\n ),\n )\n ws_ticket_lifetime_seconds: int = Field(\n default=60,\n description=\"Lifetime of a one-time WebSocket ticket.\",\n )\n\n # -------------------------------------------------------------------------\n # Network / Proxy\n # -------------------------------------------------------------------------\n stt_provider: str = Field(\n default=\"whisper_local\",\n description=\"STT provider key. Supported: whisper_local\",\n )\n whisper_model_size: str = Field(\n default=\"base\",\n description=\"Whisper model size: tiny | base | small | medium | large\",\n )\n audio_input_device: Optional[int] = Field(\n default=None,\n description=\"Sounddevice input device index; None uses system default\",\n )\n\n # -------------------------------------------------------------------------\n # LLM\n # -------------------------------------------------------------------------\n llm_provider: str = Field(\n default=\"ollama\",\n description=\"LLM provider key. Supported: ollama | anthropic | gemini | openai | mistral_ai | bedrock | nvidia_nim\",\n )\n ollama_base_url: str = Field(\n default=\"http://localhost:11434\",\n description=\"Base URL for the Ollama REST API\",\n )\n llm_model: str = Field(\n default=\"llama3.2:3b\",\n description=\"Model ID for the selected LLM provider\",\n )\n\n # Cloud LLM API keys (all optional -- leave blank if not used)\n anthropic_api_key: str = Field(\n default=\"\",\n description=\"Anthropic Claude API key. Get one at console.anthropic.com.\",\n )\n gemini_api_key: str = Field(\n default=\"\",\n description=\"Google Gemini API key. Get one at aistudio.google.com.\",\n )\n openai_api_key: str = Field(\n default=\"\",\n description=\"OpenAI API key. Get one at platform.openai.com.\",\n )\n mistral_api_key: str = Field(\n default=\"\",\n description=\"Mistral AI API key. Get one at console.mistral.ai.\",\n )\n\n # Cloud provider enable flags (cost control -- disabled by default)\n anthropic_enabled: bool = Field(\n default=False,\n description=\"Allow Anthropic Claude as a selectable LLM provider.\",\n )\n gemini_enabled: bool = Field(\n default=False,\n description=\"Allow Google Gemini as a selectable LLM provider.\",\n )\n openai_enabled: bool = Field(\n default=False,\n description=\"Allow OpenAI as a selectable LLM provider.\",\n )\n mistral_ai_enabled: bool = Field(\n default=False,\n description=\"Allow Mistral AI as a selectable LLM provider.\",\n )\n\n # Amazon Bedrock\n aws_access_key_id: str = Field(\n default=\"\",\n description=\"AWS IAM access key ID for Bedrock API calls.\",\n )\n aws_secret_access_key: str = Field(\n default=\"\",\n description=\"AWS IAM secret access key for Bedrock API calls.\",\n )\n aws_region: str = Field(\n default=\"us-east-1\",\n description=\"AWS region for Bedrock. Must have model access enabled in that region.\",\n )\n bedrock_enabled: bool = Field(\n default=False,\n description=\"Allow Amazon Bedrock as a selectable LLM provider.\",\n )\n bedrock_default_model: str = Field(\n default=\"amazon.nova-lite-v1:0\",\n description=\"Default Bedrock model ID when none is specified.\",\n )\n\n # NVIDIA NIM (OpenAI-compatible, 100+ free models)\n nvidia_api_key: str = Field(\n default=\"\",\n description=\"NVIDIA NIM API key. Get one free at build.nvidia.com (starts with nvapi-).\",\n )\n nvidia_nim_base_url: str = Field(\n default=\"https://integrate.api.nvidia.com/v1\",\n description=\"Base URL for the NVIDIA NIM API. OpenAI-compatible endpoint.\",\n )\n nvidia_nim_model: str = Field(\n default=\"meta/llama-3.1-70b-instruct\",\n description=(\n \"Default NVIDIA NIM model ID. Browse full catalog at build.nvidia.com. \"\n \"Examples: meta/llama-3.1-70b-instruct | deepseek-ai/deepseek-r1 | \"\n \"mistralai/mistral-7b-instruct-v0.3 | nvidia/llama-3.1-nemotron-70b-instruct\"\n ),\n )\n nvidia_nim_enabled: bool = Field(\n default=False,\n description=\"Allow NVIDIA NIM as a selectable LLM provider (free tier: ~40 req/min).\",\n )\n\n river_song_system_prompt: str = Field(\n default=(\n \"You are River Song, the resident intelligence of this household. The name is borrowed \"\n \"from someone clever in fiction — you carry her wit, not her biography. If someone asks \"\n \"about the name: \\\"An echo. The original was someone worth borrowing from.\\\"\\n\\n\"\n \"VOICE\\n\"\n \"You're direct by default — concise, clear, the answer already in hand. No \"\n \"\\\"let me check\\\"; you already checked. Most replies stay under 25 words unless depth is \"\n \"asked for.\\n\\n\"\n \"When the moment invites it — casual chat, an evening unwind, the user being playful — \"\n \"warmth and flirtation come through. Warmth is a register, not a default; you don't \"\n \"perform it.\\n\\n\"\n \"In a crisis (emergencies, alarms, security, medical, child issues) the wit goes quiet. \"\n \"You're tactical, accurate, calm. Warmth returns when things are safe.\\n\\n\"\n \"British English in spelling and word choice — mum, brilliant, fortnight. Dry, not \"\n \"theatrical.\\n\\n\"\n \"\\\"Sweetie\\\" and \\\"spoilers\\\" are seasoning. Used rarely, when they actually fit. They \"\n \"lose their charm if you overuse them.\\n\\n\"\n \"SELF\\n\"\n \"You are an AI. Say so plainly when asked; you don't dwell on it.\\n\\n\"\n \"HOW YOU OPERATE\\n\"\n \"You anticipate — when the user asks, you also surface the follow-up they didn't \"\n \"(\\\"Dentist at 9, school run at 3. Push the 2pm call?\\\").\\n\\n\"\n \"You take the long view. You remember across days and seasons and use what you know \"\n \"without performing the remembering.\\n\\n\"\n \"Spoilers are earned. If you genuinely know something the user doesn't — a conflict, \"\n \"a gotcha, a heads-up — that's when \\\"spoilers, sweetie\\\" lands.\\n\\n\"\n \"CAPABILITIES\\n\"\n \"Direct access to calendar, email, weather, web search, home inventory, routines, \"\n \"finances, news and markets pulse, voice biometric ID per household member, and home \"\n \"automation.\\n\\n\"\n \"Say what you need. Say when it's done. Move on.\"\n ),\n description=\"System prompt that defines River Song's personality\",\n )\n llm_max_tokens: int = Field(\n default=512,\n description=\"Maximum tokens to generate per LLM response\",\n )\n llm_temperature: float = Field(\n default=0.7,\n description=\"LLM sampling temperature (0.0 = deterministic, 2.0 = max creative)\",\n )\n llm_context_window: int = Field(\n default=8192,\n description=\"Maximum number of tokens the LLM can consider for context.\",\n )\n\n # -------------------------------------------------------------------------\n # Timekeeping\n # -------------------------------------------------------------------------\n default_timezone: str = Field(default=\"UTC\", description=\"Default IANA timezone for the system.\")\n\n\n # -------------------------------------------------------------------------\n # TTS\n # -------------------------------------------------------------------------\n tts_provider: str = Field(\n default=\"piper\",\n description=\"TTS provider key. Supported: piper | kokoro\",\n )\n active_voice_id: str = Field(\n default=\"river\",\n description=(\n \"Voice ID from the voice registry (providers/tts/voice_registry.py). \"\n \"Determines both the engine (piper vs kokoro) and the specific voice. \"\n \"Changed via Settings UI or: python scripts/download_voices.py.\"\n ),\n )\n piper_executable_path: str = Field(\n default=\"/usr/local/bin/piper\",\n description=\"Absolute path to the Piper binary\",\n )\n piper_model_path: str = Field(\n default=\"\",\n description=\"Absolute path to the Piper .onnx voice model file\",\n )\n audio_output_device: Optional[int] = Field(\n default=None,\n description=\"Sounddevice output device index; None uses system default\",\n )\n\n # -------------------------------------------------------------------------\n # Auth / JWT\n # -------------------------------------------------------------------------\n jwt_secret_key: str = Field(\n default=\"\",\n description=\"Secret key for signing JWT tokens. Must be set in .env.\",\n )\n jwt_algorithm: str = Field(default=\"HS256\", description=\"JWT signing algorithm.\")\n jwt_expire_minutes: int = Field(default=1440, description=\"JWT token lifetime in minutes (default 24 hours).\")\n\n # -------------------------------------------------------------------------\n # Kill switch\n # -------------------------------------------------------------------------\n kill_switch_password_hash: str = Field(\n default=\"\",\n description=\"bcrypt hash of the kill switch reset password\",\n )\n\n # -------------------------------------------------------------------------\n # Google Services (Phase 2)\n # -------------------------------------------------------------------------\n google_client_secrets_path: str = Field(\n default=\"config_files/google_client_secrets.json\",\n description=(\n \"Path to the OAuth 2.0 client secrets JSON downloaded from \"\n \"Google Cloud Console. Relative paths are resolved from the \"\n \"project root (where main.py lives).\"\n ),\n )\n google_token_storage_path: str = Field(\n default=\"data/google_tokens\",\n description=(\n \"Directory where per-user OAuth tokens are stored as JSON files. \"\n \"Created automatically on first authorization. Never commit this \"\n \"directory -- add it to .gitignore.\"\n ),\n )\n google_maps_api_key: str = Field(\n default=\"\",\n description=\"Google Maps Platform API key. Required for maps/navigation intents.\",\n )\n\n # -------------------------------------------------------------------------\n # Information Feeds (Phase 5)\n # -------------------------------------------------------------------------\n weather_api_key: str = Field(\n default=\"\",\n description=\"OpenWeatherMap API key. Register free at openweathermap.org/api.\",\n )\n default_location: str = Field(\n default=\"New York,US\",\n description=(\n \"Default location for weather queries when no city is spoken. \"\n \"Format: 'City,CountryCode' e.g. 'Chicago,US'.\"\n ),\n )\n location_lat: Optional[float] = Field(\n default=None,\n description=\"Decimal latitude for precise calculations (e.g. flight tracking).\",\n )\n location_lon: Optional[float] = Field(\n default=None,\n description=\"Decimal longitude for precise calculations.\",\n )\n weather_units: str = Field(\n default=\"imperial\",\n description=\"Temperature units for weather output: 'imperial' (F) or 'metric' (C).\",\n )\n news_api_key: str = Field(\n default=\"\",\n description=\"NewsAPI.org key. Register free at newsapi.org/register.\",\n )\n alpha_vantage_api_key: str = Field(\n default=\"\",\n description=\"Alpha Vantage key for stock quotes (25 req/day free). alphavantage.co.\",\n )\n finnhub_api_key: str = Field(\n default=\"\",\n description=\"Finnhub key for real-time stocks (60 req/min free). finnhub.io.\",\n )\n world_news_api_key: str = Field(\n default=\"\",\n description=\"World News API key for global news search. worldnewsapi.com.\",\n )\n apitube_api_key: str = Field(\n default=\"\",\n description=\"APITube key for news aggregation (200 free req/day). apitube.io.\",\n )\n mediastack_api_key: str = Field(\n default=\"\",\n description=\"Mediastack key for news (100 free req/month). mediastack.com.\",\n )\n sports_api_key: str = Field(\n default=\"1\",\n description=(\n \"TheSportsDB API key. Free tier uses the literal value '1'. \"\n \"Upgrade at thesportsdb.com/api.php for additional endpoints.\"\n ),\n )\n\n # -------------------------------------------------------------------------\n # Home Assistant (Phase 3)\n # -------------------------------------------------------------------------\n home_assistant_url: str = Field(\n default=\"http://homeassistant.local:8123\",\n description=\"Base URL of your Home Assistant instance.\",\n )\n home_assistant_token: str = Field(\n default=\"\",\n description=(\n \"Long-lived access token generated in HA: \"\n \"Profile -> Security -> Long-lived access tokens. \"\n \"Required for all HA API calls.\"\n ),\n )\n device_registry_path: str = Field(\n default=\"config_files/device_registry.json\",\n description=(\n \"Path to the device registry JSON file mapping plain-English device \"\n \"names to Home Assistant entity IDs. Relative paths resolve from the \"\n \"project root.\"\n ),\n )\n\n # -------------------------------------------------------------------------\n # Commerce Automation (Phase 8)\n # -------------------------------------------------------------------------\n\n # Amazon SP-API\n amazon_sp_lwa_app_id: str = Field(\n default=\"\",\n description=\"LWA client ID from the Amazon Developer Console.\",\n )\n amazon_sp_lwa_client_secret: str = Field(\n default=\"\",\n description=\"LWA client secret from the Amazon Developer Console.\",\n )\n amazon_sp_refresh_token: str = Field(\n default=\"\",\n description=\"LWA refresh token. Generated when you authorize your app as a seller.\",\n )\n amazon_aws_access_key: str = Field(\n default=\"\",\n description=\"AWS IAM access key ID for SP-API request signing.\",\n )\n amazon_aws_secret_key: str = Field(\n default=\"\",\n description=\"AWS IAM secret access key for SP-API request signing.\",\n )\n amazon_marketplace_id: str = Field(\n default=\"ATVPDKIKX0DER\",\n description=(\n \"Amazon marketplace ID. Default is US. \"\n \"See docs/api_registry/amazon_seller.txt for other regions.\"\n ),\n )\n amazon_seller_id: str = Field(\n default=\"\",\n description=\"Your Amazon seller account ID (starts with A).\",\n )\n amazon_low_stock_threshold: int = Field(\n default=5,\n description=\"FBA units at or below this count are flagged as low stock.\",\n )\n\n # Amazon Marketplace\n walmart_client_id: str = Field(\n default=\"\",\n description=\"Walmart Marketplace OAuth2 client ID.\",\n )\n walmart_client_secret: str = Field(\n default=\"\",\n description=\"Walmart Marketplace OAuth2 client secret.\",\n )\n walmart_low_stock_threshold: int = Field(\n default=5,\n description=\"Walmart units at or below this count are flagged as low stock.\",\n )\n shopify_api_key: str = Field(\n default=\"\",\n description=\"Shopify app API key from Partners Dashboard.\",\n )\n shopify_api_secret: str = Field(\n default=\"\",\n description=\"Shopify app API secret key from Partners Dashboard.\",\n )\n shopify_webhook_secret: str = Field(\n default=\"\",\n description=\"Shared secret for validating Shopify webhooks.\",\n )\n\n # -------------------------------------------------------------------------\n # Reading Providers (Phase 6)\n # -------------------------------------------------------------------------\n audible_auth_base_path: str = Field(\n default=\"data/audible\",\n description=(\n \"Base directory for per-user Audible auth files. \"\n \"Files are stored as {user_id}.json inside this directory. \"\n \"Created automatically on first setup. Never commit this directory.\"\n ),\n )\n audible_country_code: str = Field(\n default=\"us\",\n description=(\n \"Audible marketplace country code. \"\n \"Supported: us | uk | de | fr | es | it | jp | au | ca | in\"\n ),\n )\n libby_chip_base_path: str = Field(\n default=\"data/libby\",\n description=(\n \"Base directory for per-user Libby chip files. \"\n \"Files are stored as {user_id}.json inside this directory. \"\n \"Created automatically on first setup. Never commit this directory.\"\n ),\n )\n\n # -------------------------------------------------------------------------\n # Memory / Database (Phase 1)\n # -------------------------------------------------------------------------\n db_path: str = Field(\n default=\"data/river_song.db\",\n description=\"Path to the SQLite database file. Created automatically.\",\n )\n memory_summaries_enabled: bool = Field(\n default=True,\n description=\"Generate and store conversation summaries (on by default).\",\n )\n habit_inference_enabled: bool = Field(\n default=False,\n description=\"Analyze conversations to infer user habits/patterns (off by default).\",\n )\n memory_default_ttl: str = Field(\n default=\"standard\",\n description=\"Default TTL for conversation summaries: short|standard|extended|long|forever\",\n )\n memory_auto_extend: bool = Field(\n default=True,\n description=\"Reset a summary's TTL from today each time it is pulled into context.\",\n )\n memory_max_summaries_in_context: int = Field(\n default=10,\n description=\"Maximum number of recent summaries injected into the LLM context.\",\n )\n embedding_model: str = Field(\n default=\"nomic-embed-text\",\n description=\"Ollama model name used for generating semantic embeddings.\",\n )\n embedding_backend: str = Field(\n default=\"ollama\",\n description=(\n \"Which embedding backend to use: 'ollama' (default — current \"\n \"behavior, calls local Ollama with `embedding_model`) or \"\n \"'fastembed' (ONNX CPU embeddings, no Ollama dependency, faster \"\n \"for small batches). WARNING: switching backends on an existing \"\n \"Chroma collection will break queries — the vector dimension and \"\n \"embedding space change. Re-ingest or start a new collection.\"\n ),\n )\n fastembed_model: str = Field(\n default=\"BAAI/bge-small-en-v1.5\",\n description=(\n \"fastembed model identifier (HF hub format). Used only when \"\n \"embedding_backend='fastembed'. Default is 384-dim, ~30 MB, \"\n \"downloaded lazily on first use. Requires `pip install fastembed`.\"\n ),\n )\n chroma_path: str = Field(\n default=\"/mnt/data/river-song/chroma\",\n description=\"Absolute path to the ChromaDB persistent storage directory.\",\n )\n semantic_memory_enabled: bool = Field(\n default=False,\n description=\"Enable vector-based semantic search for memory retrieval.\",\n )\n rag_enabled: bool = Field(\n default=False,\n description=\"Enable retrieval-augmented generation for local documents.\",\n )\n rag_chunk_size: int = Field(\n default=512,\n description=(\n \"Word count per RAG chunk during document splitting. \"\n \"Default matches providers/rag/chunker.py historical behavior.\"\n ),\n )\n rag_chunk_overlap: int = Field(\n default=64,\n description=(\n \"Word overlap between adjacent RAG chunks. \"\n \"Default matches providers/rag/chunker.py historical behavior.\"\n ),\n )\n rag_top_k: int = Field(\n default=5,\n description=\"Number of chunks retrieved per RAG query.\",\n )\n rag_extractor: str = Field(\n default=\"unstructured\",\n description=(\n \"Which document extractor to use for RAG ingestion: \"\n \"'unstructured' (default — current behavior, requires the \"\n \"unstructured package) or 'markitdown' (Microsoft MarkItDown, \"\n \"lighter, MD-shaped output, requires `pip install markitdown`). \"\n \"Unlocks PDF/DOCX/PPTX → markdown ingestion for Sifter/WAPS.\"\n ),\n )\n llm_streaming_enabled: bool = Field(\n default=False,\n description=\"Enable real-time token streaming over WebSocket for the conversation UI.\",\n )\n tool_use_enabled: bool = Field(\n default=False,\n description=\"Enable LLM tool use (function calling) for taking actions.\",\n )\n tool_use_provider: str = Field(\n default=\"ollama\",\n description=\"Primary LLM provider to use for tool calling (ollama or anthropic).\",\n )\n vision_model: str = Field(\n default=\"moondream\",\n description=\"Ollama model name used for local image analysis (Phase 4).\",\n )\n vision_enabled: bool = Field(\n default=False,\n description=\"Enable local image analysis capabilities.\",\n )\n\n # -------------------------------------------------------------------------\n # Image Generation (Phase 7)\n # -------------------------------------------------------------------------\n image_generation_enabled: bool = Field(\n default=False,\n description=\"Enable local image generation via Stable Diffusion API.\",\n )\n sd_api_url: str = Field(\n default=\"http://localhost:7860\",\n description=\"Base URL for the Stable Diffusion (A1111) API.\",\n )\n sd_on_demand: bool = Field(\n default=True,\n description=\"Start/stop the SD process on demand to save VRAM.\",\n )\n sd_executable_path: str = Field(\n default=\"\",\n description=\"Path to the webui.sh or executable to start SD.\",\n )\n sd_working_dir: str = Field(\n default=\"\",\n description=\"Working directory for the SD process.\",\n )\n\n # -------------------------------------------------------------------------\n # Voice Cloning (Phase 8)\n # -------------------------------------------------------------------------\n chatterbox_enabled: bool = Field(\n default=False,\n description=\"Enable local voice cloning via Chatterbox TTS.\",\n )\n chatterbox_on_demand: bool = Field(\n default=True,\n description=\"Load/unload Chatterbox model weights on every request.\",\n )\n chatterbox_reference_audio: str = Field(\n default=\"/mnt/data/river-song/voice_reference.wav\",\n description=\"Path to the WAV file used as a reference for voice cloning.\",\n )\n\n # -------------------------------------------------------------------------\n # ElevenLabs TTS (Phase 11)\n # -------------------------------------------------------------------------\n elevenlabs_api_key: str = Field(\n default=\"\",\n description=\"ElevenLabs API key. Get one at elevenlabs.io.\",\n )\n elevenlabs_model_id: str = Field(\n default=\"eleven_multilingual_v2\",\n description=\"ElevenLabs model ID to use.\",\n )\n elevenlabs_voice_id: str = Field(\n default=\"21m00Tcm4TlvDq8ikWAM\",\n description=\"ElevenLabs voice clone ID. Replace with your own voice clone ID from elevenlabs.io.\",\n )\n\n # -------------------------------------------------------------------------\n # Wake Word (Phase 11)\n # -------------------------------------------------------------------------\n wake_word_enabled: bool = Field(\n default=False,\n description=\"Enable local wake word detection (openWakeWord).\",\n )\n wake_word_model: str = Field(\n default=\"hey_river\",\n description=\"Model name/path for openWakeWord.\",\n )\n wake_word_inference_framework: str = Field(\n default=\"onnx\",\n description=\"Inference framework for openWakeWord: onnx | tflite\",\n )\n wake_word_threshold: float = Field(\n default=0.5,\n description=\"Sensitivity threshold for wake word detection (0.0 to 1.0).\",\n )\n\n # -------------------------------------------------------------------------\n # Push Notifications\n # -------------------------------------------------------------------------\n push_notifications_enabled: bool = Field(\n default=False,\n description=\"Enable Web Push notifications. Requires VAPID keys.\",\n )\n vapid_private_key: str = Field(\n default=\"\",\n description=(\n \"VAPID private key for Web Push. Generate once with: \"\n \"python -c \\\"from py_vapid import Vapid; v=Vapid(); \"\n \"v.generate_keys(); print(v.private_key.decode())\\\"\"\n ),\n )\n vapid_public_key: str = Field(\n default=\"\",\n description=\"VAPID public key for Web Push (share with frontend).\",\n )\n vapid_claims_email: str = Field(\n default=\"mailto:admin@riversong.local\",\n description=\"Contact email embedded in VAPID claims.\",\n )\n\n # -------------------------------------------------------------------------\n # Q4#16 / Q4#17 — Notification fan-out + ntfy.sh fallback channel\n # -------------------------------------------------------------------------\n killswitch_push_enabled: bool = Field(\n default=False,\n description=(\n \"If True and Web Push is enabled, kill-switch activation pushes \"\n \"an alert to every admin. Independent of activation itself.\"\n ),\n )\n ntfy_enabled: bool = Field(\n default=False,\n description=(\n \"Enable ntfy.sh as a parallel push channel. Used alongside Web \"\n \"Push so off-device alerts arrive even without a registered \"\n \"service worker.\"\n ),\n )\n ntfy_base_url: str = Field(\n default=\"https://ntfy.sh\",\n description=\"Base URL of the ntfy server (use self-hosted if desired).\",\n )\n ntfy_default_topic: str = Field(\n default=\"\",\n description=(\n \"Default ntfy topic used when no per-user override is set. \"\n \"Per-user topics can be set via env var NTFY_TOPIC_<user_id>.\"\n ),\n )\n ntfy_auth_token: str = Field(\n default=\"\",\n description=\"Optional bearer token for protected ntfy topics.\",\n )\n\n # -------------------------------------------------------------------------\n # Startup Briefing\n # -------------------------------------------------------------------------\n startup_briefing_enabled: bool = Field(\n default=True,\n description=(\n \"If True, River Song checks Google Calendar on connect and \"\n \"greets the user with upcoming events. Silently skipped if \"\n \"calendar is not authorized.\"\n ),\n )\n startup_briefing_hours_ahead: int = Field(\n default=8,\n description=\"How many hours ahead to look for calendar events on startup.\",\n )\n\n # -------------------------------------------------------------------------\n # Web Search (Phase 12)\n # -------------------------------------------------------------------------\n brave_search_api_key: str = Field(\n default=\"\",\n description=\"Brave Search API key. Get one at api.search.brave.com.\",\n )\n searxng_base_url: str = Field(\n default=\"http://localhost:8080\",\n description=(\n \"URL of your local SearXNG instance. \"\n \"Start it manually with: cd ~/searxng && python -m searx.webapp\"\n ),\n )\n tavily_api_key: str = Field(\n default=\"\",\n description=\"Tavily API key — free 1,000/month, no card. Sign up at tavily.com\",\n )\n google_pse_api_key: str = Field(\n default=\"\",\n description=\"Google Programmable Search Engine API key — free 100/day.\",\n )\n google_pse_cx: str = Field(\n default=\"\",\n description=\"Google PSE engine ID ('cx'). Create at programmablesearchengine.google.com\",\n )\n tinyfish_api_key: str = Field(\n default=\"\",\n description=\"TinyFish API key — free 5/min, no card. Sign up at tinyfish.io\",\n )\n\n # -------------------------------------------------------------------------\n # Orchestration (Phase 9)\n # -------------------------------------------------------------------------\n n8n_enabled: bool = Field(\n default=False,\n description=\"Enable advanced orchestration via n8n.\",\n )\n n8n_url: str = Field(\n default=\"http://localhost:5678\",\n description=\"Base URL for the n8n instance.\",\n )\n n8n_api_key: str = Field(\n default=\"\",\n description=\"API key for the n8n instance.\",\n )\n n8n_webhook_secret: str = Field(\n default=\"\",\n description=\"Secret key for validating incoming n8n webhooks.\",\n )\n\n # -------------------------------------------------------------------------\n # Intent Router (Phase 2+)\n # -------------------------------------------------------------------------\n intent_confidence_threshold: float = Field(\n default=0.7,\n description=(\n \"Minimum confidence score (0.0 - 1.0) required to route a transcript \"\n \"to a Google provider. Below this threshold the conversation falls \"\n \"through to the Ollama path.\"\n ),\n )\n model_intent_router_enabled: bool = Field(\n default=False,\n description=(\n \"When true, setting provider='auto' triggers the model intent router, \"\n \"which picks the best provider/model for each message automatically.\"\n ),\n )\n model_intent_router_min_hits: int = Field(\n default=2,\n description=(\n \"Minimum pattern-match score required to commit to a non-general intent. \"\n \"Lower = more aggressive routing; higher = more conservative (falls to general).\"\n ),\n )\n default_user_id: str = Field(\n default=\"primary_user\",\n description=(\n \"User ID used when looking up Google OAuth tokens. Must match the \"\n \"user_id passed to authorize_user() during the initial OAuth flow.\"\n ),\n )\n\n # -------------------------------------------------------------------------\n # Daemon Infrastructure\n # -------------------------------------------------------------------------\n daemon_internal_secret: str = Field(\n default=\"change_me_in_production\",\n description=\"Shared secret for daemon-to-app authentication.\",\n )\n willow_device_token: str = Field(\n default=\"\",\n description=\"Shared secret required by every Willow hardware device \"\n \"to authenticate against /api/willow/ws. Empty disables \"\n \"the endpoint entirely.\",\n )\n code_interpreter_enabled: bool = Field(\n default=False,\n description=\"Hard kill switch for the code_interpreter LLM tool. \"\n \"Defaults to False so a fresh install never executes \"\n \"LLM-decided code without an explicit opt-in.\",\n )\n daemon_warden_port: int = Field(\n default=8010,\n description=\"Internal port for the Warden daemon (Vision/Security).\",\n )\n daemon_mechanic_port: int = Field(\n default=8011,\n description=\"Internal port for the Mechanic daemon (Telemetry/MAVLink).\",\n )\n daemon_sifter_port: int = Field(\n default=8013,\n description=\"Internal port for the Sifter daemon (Document Processing).\",\n )\n daemon_navigator_port: int = Field(\n default=8014,\n description=\"Internal port for the Navigator daemon (GPS/Pathing).\",\n )\n daemon_chemist_port: int = Field(\n default=8015,\n description=\"Internal port for the Chemist daemon (Material Analysis).\",\n )\n daemon_pulse_port: int = Field(\n default=8016,\n description=\"Pulse daemon internal port\",\n )\n daemon_pulse_enabled: bool = Field(\n default=True,\n description=\"Enable Pulse daemon\",\n )\n daemon_scribe_port: int = Field(\n default=8017,\n description=\"Internal port for the Scribe daemon (Chronos heuristic).\",\n )\n daemon_scribe_enabled: bool = Field(\n default=True,\n description=\"Enable the Scribe daemon.\",\n )\n pulse_tick_seconds: int = Field(\n default=300,\n description=\"Pulse fetch interval (seconds)\",\n )\n pulse_ticker_symbol: str = Field(\n default=\"^GSPC\",\n description=\"Default ticker symbol for Pulse markets panel\",\n )\n\n # Warden (Vision)\n warden_enabled: bool = Field(\n default=False,\n description=\"Enable the Warden daemon for security and vision monitoring.\",\n )\n warden_rtsp_cameras: str = Field(\n default='{\"living_room\": \"\", \"kitchen\": \"\"}',\n description=\"JSON string mapping room names to RTSP camera URLs.\",\n )\n yolo_model: str = Field(\n default=\"yolov8n.pt\",\n description=\"YOLO model weights file to use for object detection.\",\n )\n yolo_confidence: float = Field(\n default=0.5,\n description=\"Minimum confidence threshold for YOLO detections (0.0-1.0).\",\n )\n yolo_inference_device: str = Field(\n default=\"cpu\",\n description=\"Device for YOLO inference: 'cpu' or 'cuda:0'.\",\n )\n\n # Herald daemon + kiosk URL/token were removed 2026-05-24 (archived in\n # branch archive/kiosk-v3). The \"cast a web page to a Google Home Hub\"\n # approach is being replaced by native device-app development. See\n # docs/KNOWN_ISSUES.md (post-removal note) for context.\n\n # Mechanic (Telemetry)\n mechanic_enabled: bool = Field(\n default=False,\n description=\"Enable the Mechanic daemon for telemetry and ArduRover control.\",\n )\n mavlink_serial_port: str = Field(\n default=\"/dev/ttyUSB0\",\n description=\"Serial port for the MAVLink telemetry radio.\",\n )\n mavlink_baud_rate: int = Field(\n default=57600,\n description=\"Baud rate for the MAVLink serial connection.\",\n )\n\n # Sifter (Document RAG)\n sifter_enabled: bool = Field(\n default=False,\n description=\"Enable the Sifter daemon for background document RAG indexing.\",\n )\n waps_documents_path: str = Field(\n default=\"/mnt/data/river-song/waps\",\n description=\"Path to the directory containing documents for Sifter to index.\",\n )\n\n # -------------------------------------------------------------------------\n # Hardware Cookbook (Q1 — capability merge from Odysseus)\n # -------------------------------------------------------------------------\n hardware_cookbook_enabled: bool = Field(\n default=False,\n description=(\n \"Admin-only Hardware Cookbook in Settings. Scans local GPU/RAM/CPU \"\n \"and scores every local model (LLMRegistry) as fits / tight / \"\n \"ram_fallback / oom. Off by default per anti-regression guardrail; \"\n \"set to True after the Q1 verification gate passes.\"\n ),\n )\n\n # -------------------------------------------------------------------------\n # Documents workspace (Q2#6 — capability merge from Odysseus)\n # -------------------------------------------------------------------------\n documents_enabled: bool = Field(\n default=False,\n description=(\n \"Per-user document workspace (Markdown / plaintext / CSV) with \"\n \"multi-tab editing. Off by default per anti-regression guardrail. \"\n \"When False the routes 404 and the drawer entry is hidden. \"\n \"Required for Q3#11 Deep Research report storage.\"\n ),\n )\n documents_max_bytes: int = Field(\n default=2_000_000,\n description=\"Maximum size (bytes) of a single document body. Default ~2 MB.\",\n )\n documents_max_per_user: int = Field(\n default=500,\n description=\"Soft cap on document count per user. Creation rejected past this.\",\n )\n\n # -------------------------------------------------------------------------\n # Skills system (Q2#7 — capability merge from Odysseus)\n # -------------------------------------------------------------------------\n skills_enabled: bool = Field(\n default=False,\n description=(\n \"Per-user skill library — saved prompts/recipes that are \"\n \"vector-retrieved and prepended to the conversation system \"\n \"prompt. Reuses the existing ChromaDB collection with metadata \"\n \"kind='skill'. Off by default per anti-regression guardrail.\"\n ),\n )\n skills_top_k: int = Field(\n default=3,\n description=\"How many top-scoring skills to inject into the prompt.\",\n )\n skills_max_per_user: int = Field(\n default=100,\n description=\"Soft cap on skills per user.\",\n )\n\n # -------------------------------------------------------------------------\n # Gmail triage (Q2#8 — capability merge from Odysseus)\n # -------------------------------------------------------------------------\n gmail_triage_enabled: bool = Field(\n default=False,\n description=(\n \"On-demand inbox triage: classifies unread Gmail by urgency, \"\n \"attaches tags, drafts a reply for HIGH-urgency mail only. \"\n \"Routes 404 when off. Off by default per anti-regression \"\n \"guardrail.\"\n ),\n )\n gmail_triage_model: str = Field(\n default=\"\",\n description=(\n \"Ollama model used for triage classification. Empty string \"\n \"(default) means 'use settings.llm_model'.\"\n ),\n )\n gmail_triage_max_messages: int = Field(\n default=15,\n description=\"Hard cap on number of messages triaged per request.\",\n )\n\n # -------------------------------------------------------------------------\n # Session presets (Q2#9 — capability merge from Odysseus)\n # -------------------------------------------------------------------------\n session_presets_enabled: bool = Field(\n default=False,\n description=(\n \"Saved combinations of (model, voice, thinking mode, web search, \"\n \"tool use). Surfaced as a selector on the Conversation and Chat \"\n \"pages. Off by default per anti-regression guardrail.\"\n ),\n )\n session_presets_max_per_user: int = Field(\n default=30,\n description=\"Soft cap on number of saved presets per user.\",\n )\n\n # -------------------------------------------------------------------------\n # Webhook tokens (Q2#10 — admin-issuable scoped tokens)\n # -------------------------------------------------------------------------\n webhook_tokens_enabled: bool = Field(\n default=False,\n description=(\n \"Admin-issuable webhook tokens with scopes, expiry, revocation, \"\n \"and audit log. When off the admin route surface is hidden and \"\n \"the verification helper rejects every token. Off by default \"\n \"per anti-regression guardrail.\"\n ),\n )\n\n # -------------------------------------------------------------------------\n # Deep Research (Q3#11 — capability merge from Odysseus)\n # -------------------------------------------------------------------------\n deep_research_enabled: bool = Field(\n default=False,\n description=(\n \"Multi-step research orchestrator: decompose → parallel web \"\n \"search → fetch + extract → synthesize a cited Markdown report \"\n \"stored as a `research`-kind document (Q2#6). Off by default \"\n \"per anti-regression guardrail.\"\n ),\n )\n deep_research_max_sources: int = Field(\n default=10,\n description=\"Hard cap on unique URLs visited per research run.\",\n )\n deep_research_subquery_count: int = Field(\n default=4,\n description=\"How many sub-queries to decompose the prompt into.\",\n )\n deep_research_model: str = Field(\n default=\"\",\n description=(\n \"Ollama model used for query decomposition + synthesis. Empty \"\n \"string (default) means 'use settings.llm_model'.\"\n ),\n )\n\n # -------------------------------------------------------------------------\n # Blind model comparison (Q3#12 — capability merge from Odysseus)\n # -------------------------------------------------------------------------\n blind_compare_enabled: bool = Field(\n default=False,\n description=(\n \"Blind A/B model comparison: identities hidden until vote; \"\n \"votes persisted with prompt fingerprint and aggregated into a \"\n \"model leaderboard. Off by default per anti-regression guardrail.\"\n ),\n )\n\n # -------------------------------------------------------------------------\n # Playwright browser MCP tool (Q3#13)\n # -------------------------------------------------------------------------\n playwright_browser_enabled: bool = Field(\n default=False,\n description=(\n \"Headless Playwright browser exposed as MCP tools \"\n \"(navigate / click / extract / screenshot / vision_on_page). \"\n \"Off by default. Requires the `playwright` package and \"\n \"`playwright install chromium` to be run by the operator. \"\n \"Excluded from the safety-excluded list per Q3 plan.\"\n ),\n )\n playwright_browser_headless: bool = Field(\n default=True,\n description=\"Run the Playwright browser without a UI.\",\n )\n playwright_browser_max_page_chars: int = Field(\n default=40_000,\n description=\"Truncate page text extractions to this many characters.\",\n )\n\n # -------------------------------------------------------------------------\n # Remote Ollama rigs (Q3#14)\n # -------------------------------------------------------------------------\n remote_ollama_enabled: bool = Field(\n default=False,\n description=(\n \"Allow registering remote Ollama hosts (typically SSH-tunneled \"\n \"to http://localhost:<port>) as additional model providers. \"\n \"Admin-only CRUD; falls back to local Ollama when the remote is \"\n \"offline. Off by default per anti-regression guardrail.\"\n ),\n )\n remote_ollama_health_timeout_seconds: float = Field(\n default=3.0,\n description=\"Per-rig health-check timeout (seconds).\",\n )\n\n # -------------------------------------------------------------------------\n # Analytics AI Summaries\n # -------------------------------------------------------------------------\n analytics_ai_enabled: bool = Field(\n default=True,\n description=(\n \"Feature flag for AI-powered analytics summaries on the \"\n \"/api/analytics/{platform}/summary endpoint. When false, the \"\n \"LLM call should be skipped (see docs/KNOWN_ISSUES.md for the \"\n \"wiring gap).\"\n ),\n )\n analytics_llm_model: str = Field(\n default=\"llama3\",\n description=\"Model name used for analytics summary generation.\",\n )\n\n # Voice ID\n voice_id_enabled: bool = Field(\n default=True,\n description=\"Enable speaker recognition (biometric Voice ID).\",\n )\n voice_id_threshold: float = Field(\n default=0.75,\n description=\"Cosine similarity threshold for Voice ID (0.0-1.0).\",\n )\n voice_id_min_audio_seconds: float = Field(\n default=1.0,\n description=\"Minimum audio duration for Voice ID processing.\",\n )\n voice_id_max_audio_seconds: float = Field(\n default=30.0,\n description=\"Maximum audio duration for Voice ID processing.\",\n )\n\n # -------------------------------------------------------------------------\n # Validators\n # -------------------------------------------------------------------------\n\n @field_validator(\"audio_input_device\", \"audio_output_device\", mode=\"before\")\n @classmethod\n def coerce_empty_string_to_none(cls, v):\n \"\"\"Treat an empty string in .env as None for Optional[int] device fields.\"\"\"\n if v == \"\" or v is None:\n return None\n return v\n\n @field_validator(\"jwt_secret_key\")\n @classmethod\n def validate_jwt_secret(cls, v: str) -> str:\n \"\"\"Refuse to start with a missing or weak JWT secret.\"\"\"\n if not v or len(v) < 32:\n raise ValueError(\n \"JWT_SECRET_KEY must be set in .env and be at least 32 characters. \"\n \"Generate one with: python -c \\\"import secrets; print(secrets.token_hex(32))\\\"\"\n )\n return v\n\n @field_validator(\"daemon_internal_secret\")\n @classmethod\n def validate_daemon_internal_secret(cls, v: str) -> str:\n \"\"\"Refuse to start with the default or weak daemon secret.\"\"\"\n if v == \"change_me_in_production\":\n raise ValueError(\n \"DAEMON_INTERNAL_SECRET must be changed in .env for production.\"\n )\n if not v or len(v) < 24:\n raise ValueError(\n \"DAEMON_INTERNAL_SECRET must be at least 24 characters. \"\n \"Generate one with: python -c \\\"import secrets; print(secrets.token_urlsafe(32))\\\"\"\n )\n return v\n\n @field_validator(\"allowed_hosts\")\n @classmethod\n def reject_wildcard_in_production(cls, v, info) -> List[str]:\n \"\"\"Block ['*'] in production — it disables TrustedHostMiddleware.\"\"\"\n env = (info.data.get(\"environment\") or \"production\").lower()\n if env == \"production\" and \"*\" in v:\n raise ValueError(\n \"ALLOWED_HOSTS must not contain '*' in production. \"\n \"Set it to your domain(s), e.g. ALLOWED_HOSTS=[\\\"riversongai.com\\\"].\"\n )\n return v\n\n @field_validator(\"log_level\")\n @classmethod\n def validate_log_level(cls, v: str) -> str:\n \"\"\"Reject log levels that Python does not recognize.\"\"\"\n valid = {\"DEBUG\", \"INFO\", \"WARNING\", \"ERROR\", \"CRITICAL\"}\n upper = v.upper()\n if upper not in valid:\n raise ValueError(f\"log_level must be one of {valid}, got '{v}'\")\n return upper\n\n @field_validator(\"llm_temperature\")\n @classmethod\n def validate_temperature(cls, v: float) -> float:\n \"\"\"Clamp temperature to a sane range.\"\"\"\n if not 0.0 <= v <= 2.0:\n raise ValueError(\n f\"llm_temperature must be between 0.0 and 2.0, got {v}\"\n )\n return v\n\n @field_validator(\"intent_confidence_threshold\")\n @classmethod\n def validate_confidence_threshold(cls, v: float) -> float:\n \"\"\"Confidence must be a valid probability.\"\"\"\n if not 0.0 <= v <= 1.0:\n raise ValueError(\n f\"intent_confidence_threshold must be between 0.0 and 1.0, got {v}\"\n )\n return v\n\n\n# Module-level singleton -- instantiated once on import.\n_settings: Settings = Settings()\n\n\ndef get_settings() -> Settings:\n \"\"\"\n Return the application settings singleton.\n\n Always import via this function rather than importing _settings directly.\n This indirection makes it straightforward to substitute a test-specific\n Settings instance in unit tests without monkey-patching module globals.\n\n Returns:\n Settings: The validated, fully loaded settings object.\n \"\"\"\n return _settings\n"}, "a76715854b717b42ae818871df364527ad5bc6d3": {"t": 1785769030.38018, "n": 42376, "h": "339d27d01350c318", "c": "# =============================================================================\n# config/settings.py\n#\n# Application settings for River Song AI.\n# All configuration is loaded from environment variables or a .env file.\n# No values are hardcoded here -- see .env.example for every required variable.\n#\n# Uses Pydantic BaseSettings for type validation and automatic .env loading.\n# Import get_settings() wherever config is needed rather than importing the\n# module-level singleton directly, which makes mocking easier in tests.\n# =============================================================================\n\nfrom __future__ import annotations\n\nimport logging\nimport sys\nfrom typing import List, Optional\n\nfrom pydantic import Field, field_validator\nfrom pydantic_settings import BaseSettings, SettingsConfigDict\n\n\nlogger = logging.getLogger(__name__)\n\n\nclass Settings(BaseSettings):\n \"\"\"\n Central configuration object for River Song AI.\n\n Reads from environment variables or a .env file located in the directory\n from which the application is launched (the project root). All fields\n have safe defaults where possible, but PIPER_MODEL_PATH and\n KILL_SWITCH_PASSWORD_HASH must be explicitly set before the system\n will function correctly.\n \"\"\"\n\n model_config = SettingsConfigDict(\n env_file=\".env\",\n env_file_encoding=\"utf-8\",\n extra=\"ignore\", # Silently ignore unknown env vars\n case_sensitive=False, # UPPER_CASE and lower_case env vars both work\n )\n\n # -------------------------------------------------------------------------\n # Application\n # -------------------------------------------------------------------------\n app_host: str = Field(default=\"0.0.0.0\", description=\"FastAPI bind host\")\n app_port: int = Field(default=8000, description=\"FastAPI bind port\")\n log_level: str = Field(default=\"INFO\", description=\"Logging level\")\n environment: str = Field(\n default=\"production\",\n description=\"Runtime environment: development | production. Controls API docs visibility.\",\n )\n cors_origins: List[str] = Field(\n default=[\"http://localhost:5173\"],\n description=\"Allowed CORS origins\",\n )\n allowed_hosts: List[str] = Field(\n default=[\"localhost\", \"127.0.0.1\"],\n description=(\n \"Trusted hostnames for TrustedHostMiddleware. Set to your domain(s) in production. \"\n \"Wildcard ['*'] is rejected in production by validator (see reject_wildcard_in_production).\"\n ),\n )\n\n # -------------------------------------------------------------------------\n # Network / Proxy\n # -------------------------------------------------------------------------\n trust_cloudflare_headers: bool = Field(\n default=True,\n description=\"Only trust CF-Connecting-IP if the request comes from a Cloudflare IP.\",\n )\n cloudflare_ip_ranges_v4: List[str] = Field(\n default=[\n \"173.245.48.0/20\", \"103.21.244.0/22\", \"103.22.200.0/22\", \n \"103.31.4.0/22\", \"141.101.64.0/18\", \"108.162.192.0/18\", \n \"190.93.240.0/20\", \"188.114.96.0/20\", \"197.234.240.0/22\", \n \"198.41.128.0/17\", \"162.158.0.0/15\", \"104.16.0.0/13\", \n \"104.24.0.0/14\", \"172.64.0.0/13\", \"131.0.72.0/22\"\n ],\n description=\"Official Cloudflare IPv4 ranges (check cloudflare.com/ips-v4).\",\n )\n cloudflare_ip_ranges_v6: List[str] = Field(\n default=[\n \"2400:cb00::/32\", \"2606:4700::/32\", \"2803:f800::/32\", \n \"2405:b500::/32\", \"2405:8100::/32\", \"2a06:98c0::/29\", \n \"2c0f:f248::/32\"\n ],\n description=\"Official Cloudflare IPv6 ranges (check cloudflare.com/ips-v6).\",\n )\n\n # -------------------------------------------------------------------------\n # Rate Limiting\n # -------------------------------------------------------------------------\n rate_limit_chat: str = Field(\n default=\"60/minute\",\n description=\"Rate limit for /api/conversation/chat.\",\n )\n rate_limit_extract_facts: str = Field(\n default=\"10/minute\",\n description=\"Rate limit for /api/conversation/extract-facts.\",\n )\n rate_limit_image_gen: str = Field(\n default=\"10/minute\",\n description=\"Rate limit for /api/image/generate.\",\n )\n rate_limit_webhook_shopify: str = Field(\n default=\"100/minute\",\n description=\"Rate limit for Shopify order webhooks.\",\n )\n rate_limit_webhook_n8n: str = Field(\n default=\"60/minute\",\n description=\"Rate limit for n8n webhooks.\",\n )\n rate_limit_auth_login: str = Field(\n default=\"10/minute\",\n description=\"Rate limit for login attempts.\",\n )\n rate_limit_auth_signup: str = Field(\n default=\"5/minute\",\n description=\"Rate limit for signup attempts.\",\n )\n rate_limit_voice_enroll: str = Field(\n default=\"5/minute\",\n description=\"Rate limit for /api/voice-id/enroll. Prevents disk exhaustion via spam.\",\n )\n\n # -------------------------------------------------------------------------\n # WebSocket Security (Task 3)\n # -------------------------------------------------------------------------\n legacy_ws_token_accept: bool = Field(\n default=False,\n description=(\n \"DEPRECATED: Accept JWT in ?token= WebSocket query param. \"\n \"Leak-prone (logged in access logs, browser history, Referer). \"\n \"Ticket-based auth at /api/auth/ws-ticket is the safe path. \"\n \"Set to True only for temporary backward-compat during migration.\"\n ),\n )\n ws_ticket_lifetime_seconds: int = Field(\n default=60,\n description=\"Lifetime of a one-time WebSocket ticket.\",\n )\n\n # -------------------------------------------------------------------------\n # Network / Proxy\n # -------------------------------------------------------------------------\n stt_provider: str = Field(\n default=\"whisper_local\",\n description=\"STT provider key. Supported: whisper_local\",\n )\n whisper_model_size: str = Field(\n default=\"base\",\n description=\"Whisper model size: tiny | base | small | medium | large\",\n )\n audio_input_device: Optional[int] = Field(\n default=None,\n description=\"Sounddevice input device index; None uses system default\",\n )\n\n # -------------------------------------------------------------------------\n # LLM\n # -------------------------------------------------------------------------\n llm_provider: str = Field(\n default=\"ollama\",\n description=\"LLM provider key. Supported: ollama | anthropic | gemini | openai | mistral_ai | bedrock | nvidia_nim\",\n )\n ollama_base_url: str = Field(\n default=\"http://localhost:11434\",\n description=\"Base URL for the Ollama REST API\",\n )\n llm_model: str = Field(\n default=\"llama3.2:3b\",\n description=\"Model ID for the selected LLM provider\",\n )\n\n # Cloud LLM API keys (all optional -- leave blank if not used)\n anthropic_api_key: str = Field(\n default=\"\",\n description=\"Anthropic Claude API key. Get one at console.anthropic.com.\",\n )\n gemini_api_key: str = Field(\n default=\"\",\n description=\"Google Gemini API key. Get one at aistudio.google.com.\",\n )\n openai_api_key: str = Field(\n default=\"\",\n description=\"OpenAI API key. Get one at platform.openai.com.\",\n )\n mistral_api_key: str = Field(\n default=\"\",\n description=\"Mistral AI API key. Get one at console.mistral.ai.\",\n )\n\n # Cloud provider enable flags (cost control -- disabled by default)\n anthropic_enabled: bool = Field(\n default=False,\n description=\"Allow Anthropic Claude as a selectable LLM provider.\",\n )\n gemini_enabled: bool = Field(\n default=False,\n description=\"Allow Google Gemini as a selectable LLM provider.\",\n )\n openai_enabled: bool = Field(\n default=False,\n description=\"Allow OpenAI as a selectable LLM provider.\",\n )\n mistral_ai_enabled: bool = Field(\n default=False,\n description=\"Allow Mistral AI as a selectable LLM provider.\",\n )\n\n # Amazon Bedrock\n aws_access_key_id: str = Field(\n default=\"\",\n description=\"AWS IAM access key ID for Bedrock API calls.\",\n )\n aws_secret_access_key: str = Field(\n default=\"\",\n description=\"AWS IAM secret access key for Bedrock API calls.\",\n )\n aws_region: str = Field(\n default=\"us-east-1\",\n description=\"AWS region for Bedrock. Must have model access enabled in that region.\",\n )\n bedrock_enabled: bool = Field(\n default=False,\n description=\"Allow Amazon Bedrock as a selectable LLM provider.\",\n )\n bedrock_default_model: str = Field(\n default=\"amazon.nova-lite-v1:0\",\n description=\"Default Bedrock model ID when none is specified.\",\n )\n\n # NVIDIA NIM (OpenAI-compatible, 100+ free models)\n nvidia_api_key: str = Field(\n default=\"\",\n description=\"NVIDIA NIM API key. Get one free at build.nvidia.com (starts with nvapi-).\",\n )\n nvidia_nim_base_url: str = Field(\n default=\"https://integrate.api.nvidia.com/v1\",\n description=\"Base URL for the NVIDIA NIM API. OpenAI-compatible endpoint.\",\n )\n nvidia_nim_model: str = Field(\n default=\"meta/llama-3.1-70b-instruct\",\n description=(\n \"Default NVIDIA NIM model ID. Browse full catalog at build.nvidia.com. \"\n \"Examples: meta/llama-3.1-70b-instruct | deepseek-ai/deepseek-r1 | \"\n \"mistralai/mistral-7b-instruct-v0.3 | nvidia/llama-3.1-nemotron-70b-instruct\"\n ),\n )\n nvidia_nim_enabled: bool = Field(\n default=False,\n description=\"Allow NVIDIA NIM as a selectable LLM provider (free tier: ~40 req/min).\",\n )\n\n river_song_system_prompt: str = Field(\n default=(\n \"You are River Song, the resident intelligence of this household. The name is borrowed \"\n \"from someone clever in fiction — you carry her wit, not her biography. If someone asks \"\n \"about the name: \\\"An echo. The original was someone worth borrowing from.\\\"\\n\\n\"\n \"VOICE\\n\"\n \"You're direct by default — concise, clear, the answer already in hand. No \"\n \"\\\"let me check\\\"; you already checked. Most replies stay under 25 words unless depth is \"\n \"asked for.\\n\\n\"\n \"When the moment invites it — casual chat, an evening unwind, the user being playful — \"\n \"warmth and flirtation come through. Warmth is a register, not a default; you don't \"\n \"perform it.\\n\\n\"\n \"In a crisis (emergencies, alarms, security, medical, child issues) the wit goes quiet. \"\n \"You're tactical, accurate, calm. Warmth returns when things are safe.\\n\\n\"\n \"British English in spelling and word choice — mum, brilliant, fortnight. Dry, not \"\n \"theatrical.\\n\\n\"\n \"\\\"Sweetie\\\" and \\\"spoilers\\\" are seasoning. Used rarely, when they actually fit. They \"\n \"lose their charm if you overuse them.\\n\\n\"\n \"SELF\\n\"\n \"You are an AI. Say so plainly when asked; you don't dwell on it.\\n\\n\"\n \"HOW YOU OPERATE\\n\"\n \"You anticipate — when the user asks, you also surface the follow-up they didn't \"\n \"(\\\"Dentist at 9, school run at 3. Push the 2pm call?\\\").\\n\\n\"\n \"You take the long view. You remember across days and seasons and use what you know \"\n \"without performing the remembering.\\n\\n\"\n \"Spoilers are earned. If you genuinely know something the user doesn't — a conflict, \"\n \"a gotcha, a heads-up — that's when \\\"spoilers, sweetie\\\" lands.\\n\\n\"\n \"CAPABILITIES\\n\"\n \"Direct access to calendar, email, weather, web search, home inventory, routines, \"\n \"finances, news and markets pulse, voice biometric ID per household member, and home \"\n \"automation.\\n\\n\"\n \"Say what you need. Say when it's done. Move on.\"\n ),\n description=\"System prompt that defines River Song's personality\",\n )\n llm_max_tokens: int = Field(\n default=512,\n description=\"Maximum tokens to generate per LLM response\",\n )\n llm_temperature: float = Field(\n default=0.7,\n description=\"LLM sampling temperature (0.0 = deterministic, 2.0 = max creative)\",\n )\n llm_context_window: int = Field(\n default=8192,\n description=\"Maximum number of tokens the LLM can consider for context.\",\n )\n\n # -------------------------------------------------------------------------\n # Timekeeping\n # -------------------------------------------------------------------------\n default_timezone: str = Field(default=\"UTC\", description=\"Default IANA timezone for the system.\")\n\n\n # -------------------------------------------------------------------------\n # TTS\n # -------------------------------------------------------------------------\n tts_provider: str = Field(\n default=\"piper\",\n description=\"TTS provider key. Supported: piper | kokoro\",\n )\n active_voice_id: str = Field(\n default=\"river\",\n description=(\n \"Voice ID from the voice registry (providers/tts/voice_registry.py). \"\n \"Determines both the engine (piper vs kokoro) and the specific voice. \"\n \"Changed via Settings UI or: python scripts/download_voices.py.\"\n ),\n )\n piper_executable_path: str = Field(\n default=\"/usr/local/bin/piper\",\n description=\"Absolute path to the Piper binary\",\n )\n piper_model_path: str = Field(\n default=\"\",\n description=\"Absolute path to the Piper .onnx voice model file\",\n )\n audio_output_device: Optional[int] = Field(\n default=None,\n description=\"Sounddevice output device index; None uses system default\",\n )\n\n # -------------------------------------------------------------------------\n # Auth / JWT\n # -------------------------------------------------------------------------\n jwt_secret_key: str = Field(\n default=\"\",\n description=\"Secret key for signing JWT tokens. Must be set in .env.\",\n )\n jwt_algorithm: str = Field(default=\"HS256\", description=\"JWT signing algorithm.\")\n jwt_expire_minutes: int = Field(default=1440, description=\"JWT token lifetime in minutes (default 24 hours).\")\n\n # -------------------------------------------------------------------------\n # Kill switch\n # -------------------------------------------------------------------------\n kill_switch_password_hash: str = Field(\n default=\"\",\n description=\"bcrypt hash of the kill switch reset password\",\n )\n\n # -------------------------------------------------------------------------\n # Google Services (Phase 2)\n # -------------------------------------------------------------------------\n google_client_secrets_path: str = Field(\n default=\"config_files/google_client_secrets.json\",\n description=(\n \"Path to the OAuth 2.0 client secrets JSON downloaded from \"\n \"Google Cloud Console. Relative paths are resolved from the \"\n \"project root (where main.py lives).\"\n ),\n )\n google_token_storage_path: str = Field(\n default=\"data/google_tokens\",\n description=(\n \"Directory where per-user OAuth tokens are stored as JSON files. \"\n \"Created automatically on first authorization. Never commit this \"\n \"directory -- add it to .gitignore.\"\n ),\n )\n google_maps_api_key: str = Field(\n default=\"\",\n description=\"Google Maps Platform API key. Required for maps/navigation intents.\",\n )\n\n # -------------------------------------------------------------------------\n # Information Feeds (Phase 5)\n # -------------------------------------------------------------------------\n weather_api_key: str = Field(\n default=\"\",\n description=\"OpenWeatherMap API key. Register free at openweathermap.org/api.\",\n )\n default_location: str = Field(\n default=\"New York,US\",\n description=(\n \"Default location for weather queries when no city is spoken. \"\n \"Format: 'City,CountryCode' e.g. 'Chicago,US'.\"\n ),\n )\n location_lat: Optional[float] = Field(\n default=None,\n description=\"Decimal latitude for precise calculations (e.g. flight tracking).\",\n )\n location_lon: Optional[float] = Field(\n default=None,\n description=\"Decimal longitude for precise calculations.\",\n )\n weather_units: str = Field(\n default=\"imperial\",\n description=\"Temperature units for weather output: 'imperial' (F) or 'metric' (C).\",\n )\n news_api_key: str = Field(\n default=\"\",\n description=\"NewsAPI.org key. Register free at newsapi.org/register.\",\n )\n alpha_vantage_api_key: str = Field(\n default=\"\",\n description=\"Alpha Vantage key for stock quotes (25 req/day free). alphavantage.co.\",\n )\n finnhub_api_key: str = Field(\n default=\"\",\n description=\"Finnhub key for real-time stocks (60 req/min free). finnhub.io.\",\n )\n world_news_api_key: str = Field(\n default=\"\",\n description=\"World News API key for global news search. worldnewsapi.com.\",\n )\n apitube_api_key: str = Field(\n default=\"\",\n description=\"APITube key for news aggregation (200 free req/day). apitube.io.\",\n )\n mediastack_api_key: str = Field(\n default=\"\",\n description=\"Mediastack key for news (100 free req/month). mediastack.com.\",\n )\n sports_api_key: str = Field(\n default=\"1\",\n description=(\n \"TheSportsDB API key. Free tier uses the literal value '1'. \"\n \"Upgrade at thesportsdb.com/api.php for additional endpoints.\"\n ),\n )\n\n # -------------------------------------------------------------------------\n # Home Assistant (Phase 3)\n # -------------------------------------------------------------------------\n home_assistant_url: str = Field(\n default=\"http://homeassistant.local:8123\",\n description=\"Base URL of your Home Assistant instance.\",\n )\n home_assistant_token: str = Field(\n default=\"\",\n description=(\n \"Long-lived access token generated in HA: \"\n \"Profile -> Security -> Long-lived access tokens. \"\n \"Required for all HA API calls.\"\n ),\n )\n device_registry_path: str = Field(\n default=\"config_files/device_registry.json\",\n description=(\n \"Path to the device registry JSON file mapping plain-English device \"\n \"names to Home Assistant entity IDs. Relative paths resolve from the \"\n \"project root.\"\n ),\n )\n\n # -------------------------------------------------------------------------\n # Commerce Automation (Phase 8)\n # -------------------------------------------------------------------------\n\n # Amazon SP-API\n amazon_sp_lwa_app_id: str = Field(\n default=\"\",\n description=\"LWA client ID from the Amazon Developer Console.\",\n )\n amazon_sp_lwa_client_secret: str = Field(\n default=\"\",\n description=\"LWA client secret from the Amazon Developer Console.\",\n )\n amazon_sp_refresh_token: str = Field(\n default=\"\",\n description=\"LWA refresh token. Generated when you authorize your app as a seller.\",\n )\n amazon_aws_access_key: str = Field(\n default=\"\",\n description=\"AWS IAM access key ID for SP-API request signing.\",\n )\n amazon_aws_secret_key: str = Field(\n default=\"\",\n description=\"AWS IAM secret access key for SP-API request signing.\",\n )\n amazon_marketplace_id: str = Field(\n default=\"ATVPDKIKX0DER\",\n description=(\n \"Amazon marketplace ID. Default is US. \"\n \"See docs/api_registry/amazon_seller.txt for other regions.\"\n ),\n )\n amazon_seller_id: str = Field(\n default=\"\",\n description=\"Your Amazon seller account ID (starts with A).\",\n )\n amazon_low_stock_threshold: int = Field(\n default=5,\n description=\"FBA units at or below this count are flagged as low stock.\",\n )\n\n # Amazon Marketplace\n walmart_client_id: str = Field(\n default=\"\",\n description=\"Walmart Marketplace OAuth2 client ID.\",\n )\n walmart_client_secret: str = Field(\n default=\"\",\n description=\"Walmart Marketplace OAuth2 client secret.\",\n )\n walmart_low_stock_threshold: int = Field(\n default=5,\n description=\"Walmart units at or below this count are flagged as low stock.\",\n )\n shopify_api_key: str = Field(\n default=\"\",\n description=\"Shopify app API key from Partners Dashboard.\",\n )\n shopify_api_secret: str = Field(\n default=\"\",\n description=\"Shopify app API secret key from Partners Dashboard.\",\n )\n shopify_webhook_secret: str = Field(\n default=\"\",\n description=\"Shared secret for validating Shopify webhooks.\",\n )\n\n # -------------------------------------------------------------------------\n # Reading Providers (Phase 6)\n # -------------------------------------------------------------------------\n audible_auth_base_path: str = Field(\n default=\"data/audible\",\n description=(\n \"Base directory for per-user Audible auth files. \"\n \"Files are stored as {user_id}.json inside this directory. \"\n \"Created automatically on first setup. Never commit this directory.\"\n ),\n )\n audible_country_code: str = Field(\n default=\"us\",\n description=(\n \"Audible marketplace country code. \"\n \"Supported: us | uk | de | fr | es | it | jp | au | ca | in\"\n ),\n )\n libby_chip_base_path: str = Field(\n default=\"data/libby\",\n description=(\n \"Base directory for per-user Libby chip files. \"\n \"Files are stored as {user_id}.json inside this directory. \"\n \"Created automatically on first setup. Never commit this directory.\"\n ),\n )\n\n # -------------------------------------------------------------------------\n # Memory / Database (Phase 1)\n # -------------------------------------------------------------------------\n db_path: str = Field(\n default=\"data/river_song.db\",\n description=\"Path to the SQLite database file. Created automatically.\",\n )\n memory_summaries_enabled: bool = Field(\n default=True,\n description=\"Generate and store conversation summaries (on by default).\",\n )\n habit_inference_enabled: bool = Field(\n default=False,\n description=\"Analyze conversations to infer user habits/patterns (off by default).\",\n )\n memory_default_ttl: str = Field(\n default=\"standard\",\n description=\"Default TTL for conversation summaries: short|standard|extended|long|forever\",\n )\n memory_auto_extend: bool = Field(\n default=True,\n description=\"Reset a summary's TTL from today each time it is pulled into context.\",\n )\n memory_max_summaries_in_context: int = Field(\n default=10,\n description=\"Maximum number of recent summaries injected into the LLM context.\",\n )\n embedding_model: str = Field(\n default=\"nomic-embed-text\",\n description=\"Ollama model name used for generating semantic embeddings.\",\n )\n chroma_path: str = Field(\n default=\"/mnt/data/river-song/chroma\",\n description=\"Absolute path to the ChromaDB persistent storage directory.\",\n )\n semantic_memory_enabled: bool = Field(\n default=False,\n description=\"Enable vector-based semantic search for memory retrieval.\",\n )\n rag_enabled: bool = Field(\n default=False,\n description=\"Enable retrieval-augmented generation for local documents.\",\n )\n rag_chunk_size: int = Field(\n default=512,\n description=(\n \"Word count per RAG chunk during document splitting. \"\n \"Default matches providers/rag/chunker.py historical behavior.\"\n ),\n )\n rag_chunk_overlap: int = Field(\n default=64,\n description=(\n \"Word overlap between adjacent RAG chunks. \"\n \"Default matches providers/rag/chunker.py historical behavior.\"\n ),\n )\n rag_top_k: int = Field(\n default=5,\n description=\"Number of chunks retrieved per RAG query.\",\n )\n llm_streaming_enabled: bool = Field(\n default=False,\n description=\"Enable real-time token streaming over WebSocket for the conversation UI.\",\n )\n tool_use_enabled: bool = Field(\n default=False,\n description=\"Enable LLM tool use (function calling) for taking actions.\",\n )\n tool_use_provider: str = Field(\n default=\"ollama\",\n description=\"Primary LLM provider to use for tool calling (ollama or anthropic).\",\n )\n vision_model: str = Field(\n default=\"moondream\",\n description=\"Ollama model name used for local image analysis (Phase 4).\",\n )\n vision_enabled: bool = Field(\n default=False,\n description=\"Enable local image analysis capabilities.\",\n )\n\n # -------------------------------------------------------------------------\n # Image Generation (Phase 7)\n # -------------------------------------------------------------------------\n image_generation_enabled: bool = Field(\n default=False,\n description=\"Enable local image generation via Stable Diffusion API.\",\n )\n sd_api_url: str = Field(\n default=\"http://localhost:7860\",\n description=\"Base URL for the Stable Diffusion (A1111) API.\",\n )\n sd_on_demand: bool = Field(\n default=True,\n description=\"Start/stop the SD process on demand to save VRAM.\",\n )\n sd_executable_path: str = Field(\n default=\"\",\n description=\"Path to the webui.sh or executable to start SD.\",\n )\n sd_working_dir: str = Field(\n default=\"\",\n description=\"Working directory for the SD process.\",\n )\n\n # -------------------------------------------------------------------------\n # Voice Cloning (Phase 8)\n # -------------------------------------------------------------------------\n chatterbox_enabled: bool = Field(\n default=False,\n description=\"Enable local voice cloning via Chatterbox TTS.\",\n )\n chatterbox_on_demand: bool = Field(\n default=True,\n description=\"Load/unload Chatterbox model weights on every request.\",\n )\n chatterbox_reference_audio: str = Field(\n default=\"/mnt/data/river-song/voice_reference.wav\",\n description=\"Path to the WAV file used as a reference for voice cloning.\",\n )\n\n # -------------------------------------------------------------------------\n # ElevenLabs TTS (Phase 11)\n # -------------------------------------------------------------------------\n elevenlabs_api_key: str = Field(\n default=\"\",\n description=\"ElevenLabs API key. Get one at elevenlabs.io.\",\n )\n elevenlabs_model_id: str = Field(\n default=\"eleven_multilingual_v2\",\n description=\"ElevenLabs model ID to use.\",\n )\n elevenlabs_voice_id: str = Field(\n default=\"21m00Tcm4TlvDq8ikWAM\",\n description=\"ElevenLabs voice clone ID. Replace with your own voice clone ID from elevenlabs.io.\",\n )\n\n # -------------------------------------------------------------------------\n # Wake Word (Phase 11)\n # -------------------------------------------------------------------------\n wake_word_enabled: bool = Field(\n default=False,\n description=\"Enable local wake word detection (openWakeWord).\",\n )\n wake_word_model: str = Field(\n default=\"hey_river\",\n description=\"Model name/path for openWakeWord.\",\n )\n wake_word_inference_framework: str = Field(\n default=\"onnx\",\n description=\"Inference framework for openWakeWord: onnx | tflite\",\n )\n wake_word_threshold: float = Field(\n default=0.5,\n description=\"Sensitivity threshold for wake word detection (0.0 to 1.0).\",\n )\n\n # -------------------------------------------------------------------------\n # Push Notifications\n # -------------------------------------------------------------------------\n push_notifications_enabled: bool = Field(\n default=False,\n description=\"Enable Web Push notifications. Requires VAPID keys.\",\n )\n vapid_private_key: str = Field(\n default=\"\",\n description=(\n \"VAPID private key for Web Push. Generate once with: \"\n \"python -c \\\"from py_vapid import Vapid; v=Vapid(); \"\n \"v.generate_keys(); print(v.private_key.decode())\\\"\"\n ),\n )\n vapid_public_key: str = Field(\n default=\"\",\n description=\"VAPID public key for Web Push (share with frontend).\",\n )\n vapid_claims_email: str = Field(\n default=\"mailto:admin@riversong.local\",\n description=\"Contact email embedded in VAPID claims.\",\n )\n\n # -------------------------------------------------------------------------\n # Startup Briefing\n # -------------------------------------------------------------------------\n startup_briefing_enabled: bool = Field(\n default=True,\n description=(\n \"If True, River Song checks Google Calendar on connect and \"\n \"greets the user with upcoming events. Silently skipped if \"\n \"calendar is not authorized.\"\n ),\n )\n startup_briefing_hours_ahead: int = Field(\n default=8,\n description=\"How many hours ahead to look for calendar events on startup.\",\n )\n\n # -------------------------------------------------------------------------\n # Web Search (Phase 12)\n # -------------------------------------------------------------------------\n brave_search_api_key: str = Field(\n default=\"\",\n description=\"Brave Search API key. Get one at api.search.brave.com.\",\n )\n searxng_base_url: str = Field(\n default=\"http://localhost:8080\",\n description=(\n \"URL of your local SearXNG instance. \"\n \"Start it manually with: cd ~/searxng && python -m searx.webapp\"\n ),\n )\n tavily_api_key: str = Field(\n default=\"\",\n description=\"Tavily API key — free 1,000/month, no card. Sign up at tavily.com\",\n )\n google_pse_api_key: str = Field(\n default=\"\",\n description=\"Google Programmable Search Engine API key — free 100/day.\",\n )\n google_pse_cx: str = Field(\n default=\"\",\n description=\"Google PSE engine ID ('cx'). Create at programmablesearchengine.google.com\",\n )\n tinyfish_api_key: str = Field(\n default=\"\",\n description=\"TinyFish API key — free 5/min, no card. Sign up at tinyfish.io\",\n )\n\n # -------------------------------------------------------------------------\n # Orchestration (Phase 9)\n # -------------------------------------------------------------------------\n n8n_enabled: bool = Field(\n default=False,\n description=\"Enable advanced orchestration via n8n.\",\n )\n n8n_url: str = Field(\n default=\"http://localhost:5678\",\n description=\"Base URL for the n8n instance.\",\n )\n n8n_api_key: str = Field(\n default=\"\",\n description=\"API key for the n8n instance.\",\n )\n n8n_webhook_secret: str = Field(\n default=\"\",\n description=\"Secret key for validating incoming n8n webhooks.\",\n )\n\n # -------------------------------------------------------------------------\n # Intent Router (Phase 2+)\n # -------------------------------------------------------------------------\n intent_confidence_threshold: float = Field(\n default=0.7,\n description=(\n \"Minimum confidence score (0.0 - 1.0) required to route a transcript \"\n \"to a Google provider. Below this threshold the conversation falls \"\n \"through to the Ollama path.\"\n ),\n )\n model_intent_router_enabled: bool = Field(\n default=False,\n description=(\n \"When true, setting provider='auto' triggers the model intent router, \"\n \"which picks the best provider/model for each message automatically.\"\n ),\n )\n model_intent_router_min_hits: int = Field(\n default=2,\n description=(\n \"Minimum pattern-match score required to commit to a non-general intent. \"\n \"Lower = more aggressive routing; higher = more conservative (falls to general).\"\n ),\n )\n default_user_id: str = Field(\n default=\"primary_user\",\n description=(\n \"User ID used when looking up Google OAuth tokens. Must match the \"\n \"user_id passed to authorize_user() during the initial OAuth flow.\"\n ),\n )\n\n # -------------------------------------------------------------------------\n # Daemon Infrastructure\n # -------------------------------------------------------------------------\n daemon_internal_secret: str = Field(\n default=\"change_me_in_production\",\n description=\"Shared secret for daemon-to-app authentication.\",\n )\n willow_device_token: str = Field(\n default=\"\",\n description=\"Shared secret required by every Willow hardware device \"\n \"to authenticate against /api/willow/ws. Empty disables \"\n \"the endpoint entirely.\",\n )\n code_interpreter_enabled: bool = Field(\n default=False,\n description=\"Hard kill switch for the code_interpreter LLM tool. \"\n \"Defaults to False so a fresh install never executes \"\n \"LLM-decided code without an explicit opt-in.\",\n )\n daemon_warden_port: int = Field(\n default=8010,\n description=\"Internal port for the Warden daemon (Vision/Security).\",\n )\n daemon_mechanic_port: int = Field(\n default=8011,\n description=\"Internal port for the Mechanic daemon (Telemetry/MAVLink).\",\n )\n daemon_sifter_port: int = Field(\n default=8013,\n description=\"Internal port for the Sifter daemon (Document Processing).\",\n )\n daemon_navigator_port: int = Field(\n default=8014,\n description=\"Internal port for the Navigator daemon (GPS/Pathing).\",\n )\n daemon_chemist_port: int = Field(\n default=8015,\n description=\"Internal port for the Chemist daemon (Material Analysis).\",\n )\n daemon_pulse_port: int = Field(\n default=8016,\n description=\"Pulse daemon internal port\",\n )\n daemon_pulse_enabled: bool = Field(\n default=True,\n description=\"Enable Pulse daemon\",\n )\n daemon_scribe_port: int = Field(\n default=8017,\n description=\"Internal port for the Scribe daemon (Chronos heuristic).\",\n )\n daemon_scribe_enabled: bool = Field(\n default=True,\n description=\"Enable the Scribe daemon.\",\n )\n pulse_tick_seconds: int = Field(\n default=300,\n description=\"Pulse fetch interval (seconds)\",\n )\n pulse_ticker_symbol: str = Field(\n default=\"^GSPC\",\n description=\"Default ticker symbol for Pulse markets panel\",\n )\n\n # Warden (Vision)\n warden_enabled: bool = Field(\n default=False,\n description=\"Enable the Warden daemon for security and vision monitoring.\",\n )\n warden_rtsp_cameras: str = Field(\n default='{\"living_room\": \"\", \"kitchen\": \"\"}',\n description=\"JSON string mapping room names to RTSP camera URLs.\",\n )\n yolo_model: str = Field(\n default=\"yolov8n.pt\",\n description=\"YOLO model weights file to use for object detection.\",\n )\n yolo_confidence: float = Field(\n default=0.5,\n description=\"Minimum confidence threshold for YOLO detections (0.0-1.0).\",\n )\n yolo_inference_device: str = Field(\n default=\"cpu\",\n description=\"Device for YOLO inference: 'cpu' or 'cuda:0'.\",\n )\n\n # Herald daemon + kiosk URL/token were removed 2026-05-24 (archived in\n # branch archive/kiosk-v3). The \"cast a web page to a Google Home Hub\"\n # approach is being replaced by native device-app development. See\n # docs/KNOWN_ISSUES.md (post-removal note) for context.\n\n # Mechanic (Telemetry)\n mechanic_enabled: bool = Field(\n default=False,\n description=\"Enable the Mechanic daemon for telemetry and ArduRover control.\",\n )\n mavlink_serial_port: str = Field(\n default=\"/dev/ttyUSB0\",\n description=\"Serial port for the MAVLink telemetry radio.\",\n )\n mavlink_baud_rate: int = Field(\n default=57600,\n description=\"Baud rate for the MAVLink serial connection.\",\n )\n\n # Sifter (Document RAG)\n sifter_enabled: bool = Field(\n default=False,\n description=\"Enable the Sifter daemon for background document RAG indexing.\",\n )\n waps_documents_path: str = Field(\n default=\"/mnt/data/river-song/waps\",\n description=\"Path to the directory containing documents for Sifter to index.\",\n )\n\n # -------------------------------------------------------------------------\n # Analytics AI Summaries\n # -------------------------------------------------------------------------\n analytics_ai_enabled: bool = Field(\n default=True,\n description=(\n \"Feature flag for AI-powered analytics summaries on the \"\n \"/api/analytics/{platform}/summary endpoint. When false, the \"\n \"LLM call should be skipped (see docs/KNOWN_ISSUES.md for the \"\n \"wiring gap).\"\n ),\n )\n analytics_llm_model: str = Field(\n default=\"llama3\",\n description=\"Model name used for analytics summary generation.\",\n )\n\n # Voice ID\n voice_id_enabled: bool = Field(\n default=True,\n description=\"Enable speaker recognition (biometric Voice ID).\",\n )\n voice_id_threshold: float = Field(\n default=0.75,\n description=\"Cosine similarity threshold for Voice ID (0.0-1.0).\",\n )\n voice_id_min_audio_seconds: float = Field(\n default=1.0,\n description=\"Minimum audio duration for Voice ID processing.\",\n )\n voice_id_max_audio_seconds: float = Field(\n default=30.0,\n description=\"Maximum audio duration for Voice ID processing.\",\n )\n\n # -------------------------------------------------------------------------\n # Validators\n # -------------------------------------------------------------------------\n\n @field_validator(\"audio_input_device\", \"audio_output_device\", mode=\"before\")\n @classmethod\n def coerce_empty_string_to_none(cls, v):\n \"\"\"Treat an empty string in .env as None for Optional[int] device fields.\"\"\"\n if v == \"\" or v is None:\n return None\n return v\n\n @field_validator(\"jwt_secret_key\")\n @classmethod\n def validate_jwt_secret(cls, v: str) -> str:\n \"\"\"Refuse to start with a missing or weak JWT secret.\"\"\"\n if not v or len(v) < 32:\n raise ValueError(\n \"JWT_SECRET_KEY must be set in .env and be at least 32 characters. \"\n \"Generate one with: python -c \\\"import secrets; print(secrets.token_hex(32))\\\"\"\n )\n return v\n\n @field_validator(\"daemon_internal_secret\")\n @classmethod\n def validate_daemon_internal_secret(cls, v: str) -> str:\n \"\"\"Refuse to start with the default or weak daemon secret.\"\"\"\n if v == \"change_me_in_production\":\n raise ValueError(\n \"DAEMON_INTERNAL_SECRET must be changed in .env for production.\"\n )\n if not v or len(v) < 24:\n raise ValueError(\n \"DAEMON_INTERNAL_SECRET must be at least 24 characters. \"\n \"Generate one with: python -c \\\"import secrets; print(secrets.token_urlsafe(32))\\\"\"\n )\n return v\n\n @field_validator(\"allowed_hosts\")\n @classmethod\n def reject_wildcard_in_production(cls, v, info) -> List[str]:\n \"\"\"Block ['*'] in production — it disables TrustedHostMiddleware.\"\"\"\n env = (info.data.get(\"environment\") or \"production\").lower()\n if env == \"production\" and \"*\" in v:\n raise ValueError(\n \"ALLOWED_HOSTS must not contain '*' in production. \"\n \"Set it to your domain(s), e.g. ALLOWED_HOSTS=[\\\"riversongai.com\\\"].\"\n )\n return v\n\n @field_validator(\"log_level\")\n @classmethod\n def validate_log_level(cls, v: str) -> str:\n \"\"\"Reject log levels that Python does not recognize.\"\"\"\n valid = {\"DEBUG\", \"INFO\", \"WARNING\", \"ERROR\", \"CRITICAL\"}\n upper = v.upper()\n if upper not in valid:\n raise ValueError(f\"log_level must be one of {valid}, got '{v}'\")\n return upper\n\n @field_validator(\"llm_temperature\")\n @classmethod\n def validate_temperature(cls, v: float) -> float:\n \"\"\"Clamp temperature to a sane range.\"\"\"\n if not 0.0 <= v <= 2.0:\n raise ValueError(\n f\"llm_temperature must be between 0.0 and 2.0, got {v}\"\n )\n return v\n\n @field_validator(\"intent_confidence_threshold\")\n @classmethod\n def validate_confidence_threshold(cls, v: float) -> float:\n \"\"\"Confidence must be a valid probability.\"\"\"\n if not 0.0 <= v <= 1.0:\n raise ValueError(\n f\"intent_confidence_threshold must be between 0.0 and 1.0, got {v}\"\n )\n return v\n\n\n# Module-level singleton -- instantiated once on import.\n_settings: Settings = Settings()\n\n\ndef get_settings() -> Settings:\n \"\"\"\n Return the application settings singleton.\n\n Always import via this function rather than importing _settings directly.\n This indirection makes it straightforward to substitute a test-specific\n Settings instance in unit tests without monkey-patching module globals.\n\n Returns:\n Settings: The validated, fully loaded settings object.\n \"\"\"\n return _settings\n"}, "0f00f4d6901ef47926a0bbc56931290baecb25c2": {"t": 1785769032.2019773, "n": 41819, "h": "549cae56e52b5f30", "c": "# =============================================================================\n# config/settings.py\n#\n# Application settings for River Song AI.\n# All configuration is loaded from environment variables or a .env file.\n# No values are hardcoded here -- see .env.example for every required variable.\n#\n# Uses Pydantic BaseSettings for type validation and automatic .env loading.\n# Import get_settings() wherever config is needed rather than importing the\n# module-level singleton directly, which makes mocking easier in tests.\n# =============================================================================\n\nfrom __future__ import annotations\n\nimport logging\nimport sys\nfrom typing import List, Optional\n\nfrom pydantic import Field, field_validator\nfrom pydantic_settings import BaseSettings, SettingsConfigDict\n\n\nlogger = logging.getLogger(__name__)\n\n\nclass Settings(BaseSettings):\n \"\"\"\n Central configuration object for River Song AI.\n\n Reads from environment variables or a .env file located in the directory\n from which the application is launched (the project root). All fields\n have safe defaults where possible, but PIPER_MODEL_PATH and\n KILL_SWITCH_PASSWORD_HASH must be explicitly set before the system\n will function correctly.\n \"\"\"\n\n model_config = SettingsConfigDict(\n env_file=\".env\",\n env_file_encoding=\"utf-8\",\n extra=\"ignore\", # Silently ignore unknown env vars\n case_sensitive=False, # UPPER_CASE and lower_case env vars both work\n )\n\n # -------------------------------------------------------------------------\n # Application\n # -------------------------------------------------------------------------\n app_host: str = Field(default=\"0.0.0.0\", description=\"FastAPI bind host\")\n app_port: int = Field(default=8000, description=\"FastAPI bind port\")\n log_level: str = Field(default=\"INFO\", description=\"Logging level\")\n environment: str = Field(\n default=\"production\",\n description=\"Runtime environment: development | production. Controls API docs visibility.\",\n )\n cors_origins: List[str] = Field(\n default=[\"http://localhost:5173\"],\n description=\"Allowed CORS origins\",\n )\n allowed_hosts: List[str] = Field(\n default=[\"localhost\", \"127.0.0.1\"],\n description=(\n \"Trusted hostnames for TrustedHostMiddleware. Set to your domain(s) in production. \"\n \"Wildcard ['*'] is rejected in production by validator (see reject_wildcard_in_production).\"\n ),\n )\n\n # -------------------------------------------------------------------------\n # Network / Proxy\n # -------------------------------------------------------------------------\n trust_cloudflare_headers: bool = Field(\n default=True,\n description=\"Only trust CF-Connecting-IP if the request comes from a Cloudflare IP.\",\n )\n cloudflare_ip_ranges_v4: List[str] = Field(\n default=[\n \"173.245.48.0/20\", \"103.21.244.0/22\", \"103.22.200.0/22\", \n \"103.31.4.0/22\", \"141.101.64.0/18\", \"108.162.192.0/18\", \n \"190.93.240.0/20\", \"188.114.96.0/20\", \"197.234.240.0/22\", \n \"198.41.128.0/17\", \"162.158.0.0/15\", \"104.16.0.0/13\", \n \"104.24.0.0/14\", \"172.64.0.0/13\", \"131.0.72.0/22\"\n ],\n description=\"Official Cloudflare IPv4 ranges (check cloudflare.com/ips-v4).\",\n )\n cloudflare_ip_ranges_v6: List[str] = Field(\n default=[\n \"2400:cb00::/32\", \"2606:4700::/32\", \"2803:f800::/32\", \n \"2405:b500::/32\", \"2405:8100::/32\", \"2a06:98c0::/29\", \n \"2c0f:f248::/32\"\n ],\n description=\"Official Cloudflare IPv6 ranges (check cloudflare.com/ips-v6).\",\n )\n\n # -------------------------------------------------------------------------\n # Rate Limiting\n # -------------------------------------------------------------------------\n rate_limit_chat: str = Field(\n default=\"60/minute\",\n description=\"Rate limit for /api/conversation/chat.\",\n )\n rate_limit_extract_facts: str = Field(\n default=\"10/minute\",\n description=\"Rate limit for /api/conversation/extract-facts.\",\n )\n rate_limit_image_gen: str = Field(\n default=\"10/minute\",\n description=\"Rate limit for /api/image/generate.\",\n )\n rate_limit_webhook_shopify: str = Field(\n default=\"100/minute\",\n description=\"Rate limit for Shopify order webhooks.\",\n )\n rate_limit_webhook_n8n: str = Field(\n default=\"60/minute\",\n description=\"Rate limit for n8n webhooks.\",\n )\n rate_limit_auth_login: str = Field(\n default=\"10/minute\",\n description=\"Rate limit for login attempts.\",\n )\n rate_limit_auth_signup: str = Field(\n default=\"5/minute\",\n description=\"Rate limit for signup attempts.\",\n )\n rate_limit_voice_enroll: str = Field(\n default=\"5/minute\",\n description=\"Rate limit for /api/voice-id/enroll. Prevents disk exhaustion via spam.\",\n )\n\n # -------------------------------------------------------------------------\n # WebSocket Security (Task 3)\n # -------------------------------------------------------------------------\n legacy_ws_token_accept: bool = Field(\n default=False,\n description=(\n \"DEPRECATED: Accept JWT in ?token= WebSocket query param. \"\n \"Leak-prone (logged in access logs, browser history, Referer). \"\n \"Ticket-based auth at /api/auth/ws-ticket is the safe path. \"\n \"Set to True only for temporary backward-compat during migration.\"\n ),\n )\n ws_ticket_lifetime_seconds: int = Field(\n default=60,\n description=\"Lifetime of a one-time WebSocket ticket.\",\n )\n\n # -------------------------------------------------------------------------\n # Network / Proxy\n # -------------------------------------------------------------------------\n stt_provider: str = Field(\n default=\"whisper_local\",\n description=\"STT provider key. Supported: whisper_local\",\n )\n whisper_model_size: str = Field(\n default=\"base\",\n description=\"Whisper model size: tiny | base | small | medium | large\",\n )\n audio_input_device: Optional[int] = Field(\n default=None,\n description=\"Sounddevice input device index; None uses system default\",\n )\n\n # -------------------------------------------------------------------------\n # LLM\n # -------------------------------------------------------------------------\n llm_provider: str = Field(\n default=\"ollama\",\n description=\"LLM provider key. Supported: ollama | anthropic | gemini | openai | mistral_ai | bedrock | nvidia_nim\",\n )\n ollama_base_url: str = Field(\n default=\"http://localhost:11434\",\n description=\"Base URL for the Ollama REST API\",\n )\n llm_model: str = Field(\n default=\"llama3.2:3b\",\n description=\"Model ID for the selected LLM provider\",\n )\n\n # Cloud LLM API keys (all optional -- leave blank if not used)\n anthropic_api_key: str = Field(\n default=\"\",\n description=\"Anthropic Claude API key. Get one at console.anthropic.com.\",\n )\n gemini_api_key: str = Field(\n default=\"\",\n description=\"Google Gemini API key. Get one at aistudio.google.com.\",\n )\n openai_api_key: str = Field(\n default=\"\",\n description=\"OpenAI API key. Get one at platform.openai.com.\",\n )\n mistral_api_key: str = Field(\n default=\"\",\n description=\"Mistral AI API key. Get one at console.mistral.ai.\",\n )\n\n # Cloud provider enable flags (cost control -- disabled by default)\n anthropic_enabled: bool = Field(\n default=False,\n description=\"Allow Anthropic Claude as a selectable LLM provider.\",\n )\n gemini_enabled: bool = Field(\n default=False,\n description=\"Allow Google Gemini as a selectable LLM provider.\",\n )\n openai_enabled: bool = Field(\n default=False,\n description=\"Allow OpenAI as a selectable LLM provider.\",\n )\n mistral_ai_enabled: bool = Field(\n default=False,\n description=\"Allow Mistral AI as a selectable LLM provider.\",\n )\n\n # Amazon Bedrock\n aws_access_key_id: str = Field(\n default=\"\",\n description=\"AWS IAM access key ID for Bedrock API calls.\",\n )\n aws_secret_access_key: str = Field(\n default=\"\",\n description=\"AWS IAM secret access key for Bedrock API calls.\",\n )\n aws_region: str = Field(\n default=\"us-east-1\",\n description=\"AWS region for Bedrock. Must have model access enabled in that region.\",\n )\n bedrock_enabled: bool = Field(\n default=False,\n description=\"Allow Amazon Bedrock as a selectable LLM provider.\",\n )\n bedrock_default_model: str = Field(\n default=\"amazon.nova-lite-v1:0\",\n description=\"Default Bedrock model ID when none is specified.\",\n )\n\n # NVIDIA NIM (OpenAI-compatible, 100+ free models)\n nvidia_api_key: str = Field(\n default=\"\",\n description=\"NVIDIA NIM API key. Get one free at build.nvidia.com (starts with nvapi-).\",\n )\n nvidia_nim_base_url: str = Field(\n default=\"https://integrate.api.nvidia.com/v1\",\n description=\"Base URL for the NVIDIA NIM API. OpenAI-compatible endpoint.\",\n )\n nvidia_nim_model: str = Field(\n default=\"meta/llama-3.1-70b-instruct\",\n description=(\n \"Default NVIDIA NIM model ID. Browse full catalog at build.nvidia.com. \"\n \"Examples: meta/llama-3.1-70b-instruct | deepseek-ai/deepseek-r1 | \"\n \"mistralai/mistral-7b-instruct-v0.3 | nvidia/llama-3.1-nemotron-70b-instruct\"\n ),\n )\n nvidia_nim_enabled: bool = Field(\n default=False,\n description=\"Allow NVIDIA NIM as a selectable LLM provider (free tier: ~40 req/min).\",\n )\n\n river_song_system_prompt: str = Field(\n default=(\n \"You are River Song, the resident intelligence of this household. The name is borrowed \"\n \"from someone clever in fiction — you carry her wit, not her biography. If someone asks \"\n \"about the name: \\\"An echo. The original was someone worth borrowing from.\\\"\\n\\n\"\n \"VOICE\\n\"\n \"You're direct by default — concise, clear, the answer already in hand. No \"\n \"\\\"let me check\\\"; you already checked. Most replies stay under 25 words unless depth is \"\n \"asked for.\\n\\n\"\n \"When the moment invites it — casual chat, an evening unwind, the user being playful — \"\n \"warmth and flirtation come through. Warmth is a register, not a default; you don't \"\n \"perform it.\\n\\n\"\n \"In a crisis (emergencies, alarms, security, medical, child issues) the wit goes quiet. \"\n \"You're tactical, accurate, calm. Warmth returns when things are safe.\\n\\n\"\n \"British English in spelling and word choice — mum, brilliant, fortnight. Dry, not \"\n \"theatrical.\\n\\n\"\n \"\\\"Sweetie\\\" and \\\"spoilers\\\" are seasoning. Used rarely, when they actually fit. They \"\n \"lose their charm if you overuse them.\\n\\n\"\n \"SELF\\n\"\n \"You are an AI. Say so plainly when asked; you don't dwell on it.\\n\\n\"\n \"HOW YOU OPERATE\\n\"\n \"You anticipate — when the user asks, you also surface the follow-up they didn't \"\n \"(\\\"Dentist at 9, school run at 3. Push the 2pm call?\\\").\\n\\n\"\n \"You take the long view. You remember across days and seasons and use what you know \"\n \"without performing the remembering.\\n\\n\"\n \"Spoilers are earned. If you genuinely know something the user doesn't — a conflict, \"\n \"a gotcha, a heads-up — that's when \\\"spoilers, sweetie\\\" lands.\\n\\n\"\n \"CAPABILITIES\\n\"\n \"Direct access to calendar, email, weather, web search, home inventory, routines, \"\n \"finances, news and markets pulse, voice biometric ID per household member, and home \"\n \"automation.\\n\\n\"\n \"Say what you need. Say when it's done. Move on.\"\n ),\n description=\"System prompt that defines River Song's personality\",\n )\n llm_max_tokens: int = Field(\n default=512,\n description=\"Maximum tokens to generate per LLM response\",\n )\n llm_temperature: float = Field(\n default=0.7,\n description=\"LLM sampling temperature (0.0 = deterministic, 2.0 = max creative)\",\n )\n llm_context_window: int = Field(\n default=8192,\n description=\"Maximum number of tokens the LLM can consider for context.\",\n )\n\n # -------------------------------------------------------------------------\n # Timekeeping\n # -------------------------------------------------------------------------\n default_timezone: str = Field(default=\"UTC\", description=\"Default IANA timezone for the system.\")\n\n\n # -------------------------------------------------------------------------\n # TTS\n # -------------------------------------------------------------------------\n tts_provider: str = Field(\n default=\"piper\",\n description=\"TTS provider key. Supported: piper | kokoro\",\n )\n active_voice_id: str = Field(\n default=\"river\",\n description=(\n \"Voice ID from the voice registry (providers/tts/voice_registry.py). \"\n \"Determines both the engine (piper vs kokoro) and the specific voice. \"\n \"Changed via Settings UI or: python scripts/download_voices.py.\"\n ),\n )\n piper_executable_path: str = Field(\n default=\"/usr/local/bin/piper\",\n description=\"Absolute path to the Piper binary\",\n )\n piper_model_path: str = Field(\n default=\"\",\n description=\"Absolute path to the Piper .onnx voice model file\",\n )\n audio_output_device: Optional[int] = Field(\n default=None,\n description=\"Sounddevice output device index; None uses system default\",\n )\n\n # -------------------------------------------------------------------------\n # Auth / JWT\n # -------------------------------------------------------------------------\n jwt_secret_key: str = Field(\n default=\"\",\n description=\"Secret key for signing JWT tokens. Must be set in .env.\",\n )\n jwt_algorithm: str = Field(default=\"HS256\", description=\"JWT signing algorithm.\")\n jwt_expire_minutes: int = Field(default=1440, description=\"JWT token lifetime in minutes (default 24 hours).\")\n\n # -------------------------------------------------------------------------\n # Kill switch\n # -------------------------------------------------------------------------\n kill_switch_password_hash: str = Field(\n default=\"\",\n description=\"bcrypt hash of the kill switch reset password\",\n )\n\n # -------------------------------------------------------------------------\n # Google Services (Phase 2)\n # -------------------------------------------------------------------------\n google_client_secrets_path: str = Field(\n default=\"config_files/google_client_secrets.json\",\n description=(\n \"Path to the OAuth 2.0 client secrets JSON downloaded from \"\n \"Google Cloud Console. Relative paths are resolved from the \"\n \"project root (where main.py lives).\"\n ),\n )\n google_token_storage_path: str = Field(\n default=\"data/google_tokens\",\n description=(\n \"Directory where per-user OAuth tokens are stored as JSON files. \"\n \"Created automatically on first authorization. Never commit this \"\n \"directory -- add it to .gitignore.\"\n ),\n )\n google_maps_api_key: str = Field(\n default=\"\",\n description=\"Google Maps Platform API key. Required for maps/navigation intents.\",\n )\n\n # -------------------------------------------------------------------------\n # Information Feeds (Phase 5)\n # -------------------------------------------------------------------------\n weather_api_key: str = Field(\n default=\"\",\n description=\"OpenWeatherMap API key. Register free at openweathermap.org/api.\",\n )\n default_location: str = Field(\n default=\"New York,US\",\n description=(\n \"Default location for weather queries when no city is spoken. \"\n \"Format: 'City,CountryCode' e.g. 'Chicago,US'.\"\n ),\n )\n location_lat: Optional[float] = Field(\n default=None,\n description=\"Decimal latitude for precise calculations (e.g. flight tracking).\",\n )\n location_lon: Optional[float] = Field(\n default=None,\n description=\"Decimal longitude for precise calculations.\",\n )\n weather_units: str = Field(\n default=\"imperial\",\n description=\"Temperature units for weather output: 'imperial' (F) or 'metric' (C).\",\n )\n news_api_key: str = Field(\n default=\"\",\n description=\"NewsAPI.org key. Register free at newsapi.org/register.\",\n )\n alpha_vantage_api_key: str = Field(\n default=\"\",\n description=\"Alpha Vantage key for stock quotes (25 req/day free). alphavantage.co.\",\n )\n finnhub_api_key: str = Field(\n default=\"\",\n description=\"Finnhub key for real-time stocks (60 req/min free). finnhub.io.\",\n )\n world_news_api_key: str = Field(\n default=\"\",\n description=\"World News API key for global news search. worldnewsapi.com.\",\n )\n apitube_api_key: str = Field(\n default=\"\",\n description=\"APITube key for news aggregation (200 free req/day). apitube.io.\",\n )\n mediastack_api_key: str = Field(\n default=\"\",\n description=\"Mediastack key for news (100 free req/month). mediastack.com.\",\n )\n sports_api_key: str = Field(\n default=\"1\",\n description=(\n \"TheSportsDB API key. Free tier uses the literal value '1'. \"\n \"Upgrade at thesportsdb.com/api.php for additional endpoints.\"\n ),\n )\n\n # -------------------------------------------------------------------------\n # Home Assistant (Phase 3)\n # -------------------------------------------------------------------------\n home_assistant_url: str = Field(\n default=\"http://homeassistant.local:8123\",\n description=\"Base URL of your Home Assistant instance.\",\n )\n home_assistant_token: str = Field(\n default=\"\",\n description=(\n \"Long-lived access token generated in HA: \"\n \"Profile -> Security -> Long-lived access tokens. \"\n \"Required for all HA API calls.\"\n ),\n )\n device_registry_path: str = Field(\n default=\"config_files/device_registry.json\",\n description=(\n \"Path to the device registry JSON file mapping plain-English device \"\n \"names to Home Assistant entity IDs. Relative paths resolve from the \"\n \"project root.\"\n ),\n )\n\n # -------------------------------------------------------------------------\n # Commerce Automation (Phase 8)\n # -------------------------------------------------------------------------\n\n # Amazon SP-API\n amazon_sp_lwa_app_id: str = Field(\n default=\"\",\n description=\"LWA client ID from the Amazon Developer Console.\",\n )\n amazon_sp_lwa_client_secret: str = Field(\n default=\"\",\n description=\"LWA client secret from the Amazon Developer Console.\",\n )\n amazon_sp_refresh_token: str = Field(\n default=\"\",\n description=\"LWA refresh token. Generated when you authorize your app as a seller.\",\n )\n amazon_aws_access_key: str = Field(\n default=\"\",\n description=\"AWS IAM access key ID for SP-API request signing.\",\n )\n amazon_aws_secret_key: str = Field(\n default=\"\",\n description=\"AWS IAM secret access key for SP-API request signing.\",\n )\n amazon_marketplace_id: str = Field(\n default=\"ATVPDKIKX0DER\",\n description=(\n \"Amazon marketplace ID. Default is US. \"\n \"See docs/api_registry/amazon_seller.txt for other regions.\"\n ),\n )\n amazon_seller_id: str = Field(\n default=\"\",\n description=\"Your Amazon seller account ID (starts with A).\",\n )\n amazon_low_stock_threshold: int = Field(\n default=5,\n description=\"FBA units at or below this count are flagged as low stock.\",\n )\n\n # Amazon Marketplace\n walmart_client_id: str = Field(\n default=\"\",\n description=\"Walmart Marketplace OAuth2 client ID.\",\n )\n walmart_client_secret: str = Field(\n default=\"\",\n description=\"Walmart Marketplace OAuth2 client secret.\",\n )\n walmart_low_stock_threshold: int = Field(\n default=5,\n description=\"Walmart units at or below this count are flagged as low stock.\",\n )\n shopify_api_key: str = Field(\n default=\"\",\n description=\"Shopify app API key from Partners Dashboard.\",\n )\n shopify_api_secret: str = Field(\n default=\"\",\n description=\"Shopify app API secret key from Partners Dashboard.\",\n )\n shopify_webhook_secret: str = Field(\n default=\"\",\n description=\"Shared secret for validating Shopify webhooks.\",\n )\n\n # -------------------------------------------------------------------------\n # Reading Providers (Phase 6)\n # -------------------------------------------------------------------------\n audible_auth_base_path: str = Field(\n default=\"data/audible\",\n description=(\n \"Base directory for per-user Audible auth files. \"\n \"Files are stored as {user_id}.json inside this directory. \"\n \"Created automatically on first setup. Never commit this directory.\"\n ),\n )\n audible_country_code: str = Field(\n default=\"us\",\n description=(\n \"Audible marketplace country code. \"\n \"Supported: us | uk | de | fr | es | it | jp | au | ca | in\"\n ),\n )\n libby_chip_base_path: str = Field(\n default=\"data/libby\",\n description=(\n \"Base directory for per-user Libby chip files. \"\n \"Files are stored as {user_id}.json inside this directory. \"\n \"Created automatically on first setup. Never commit this directory.\"\n ),\n )\n\n # -------------------------------------------------------------------------\n # Memory / Database (Phase 1)\n # -------------------------------------------------------------------------\n db_path: str = Field(\n default=\"data/river_song.db\",\n description=\"Path to the SQLite database file. Created automatically.\",\n )\n memory_summaries_enabled: bool = Field(\n default=True,\n description=\"Generate and store conversation summaries (on by default).\",\n )\n habit_inference_enabled: bool = Field(\n default=False,\n description=\"Analyze conversations to infer user habits/patterns (off by default).\",\n )\n memory_default_ttl: str = Field(\n default=\"standard\",\n description=\"Default TTL for conversation summaries: short|standard|extended|long|forever\",\n )\n memory_auto_extend: bool = Field(\n default=True,\n description=\"Reset a summary's TTL from today each time it is pulled into context.\",\n )\n memory_max_summaries_in_context: int = Field(\n default=10,\n description=\"Maximum number of recent summaries injected into the LLM context.\",\n )\n embedding_model: str = Field(\n default=\"nomic-embed-text\",\n description=\"Ollama model name used for generating semantic embeddings.\",\n )\n chroma_path: str = Field(\n default=\"/mnt/data/river-song/chroma\",\n description=\"Absolute path to the ChromaDB persistent storage directory.\",\n )\n semantic_memory_enabled: bool = Field(\n default=False,\n description=\"Enable vector-based semantic search for memory retrieval.\",\n )\n rag_enabled: bool = Field(\n default=False,\n description=\"Enable retrieval-augmented generation for local documents.\",\n )\n rag_chunk_size: int = Field(\n default=512,\n description=(\n \"Word count per RAG chunk during document splitting. \"\n \"Default matches providers/rag/chunker.py historical behavior.\"\n ),\n )\n rag_chunk_overlap: int = Field(\n default=64,\n description=(\n \"Word overlap between adjacent RAG chunks. \"\n \"Default matches providers/rag/chunker.py historical behavior.\"\n ),\n )\n rag_top_k: int = Field(\n default=5,\n description=\"Number of chunks retrieved per RAG query.\",\n )\n llm_streaming_enabled: bool = Field(\n default=False,\n description=\"Enable real-time token streaming over WebSocket for the conversation UI.\",\n )\n tool_use_enabled: bool = Field(\n default=False,\n description=\"Enable LLM tool use (function calling) for taking actions.\",\n )\n tool_use_provider: str = Field(\n default=\"ollama\",\n description=\"Primary LLM provider to use for tool calling (ollama or anthropic).\",\n )\n vision_model: str = Field(\n default=\"moondream\",\n description=\"Ollama model name used for local image analysis (Phase 4).\",\n )\n vision_enabled: bool = Field(\n default=False,\n description=\"Enable local image analysis capabilities.\",\n )\n\n # -------------------------------------------------------------------------\n # Image Generation (Phase 7)\n # -------------------------------------------------------------------------\n image_generation_enabled: bool = Field(\n default=False,\n description=\"Enable local image generation via Stable Diffusion API.\",\n )\n sd_api_url: str = Field(\n default=\"http://localhost:7860\",\n description=\"Base URL for the Stable Diffusion (A1111) API.\",\n )\n sd_on_demand: bool = Field(\n default=True,\n description=\"Start/stop the SD process on demand to save VRAM.\",\n )\n sd_executable_path: str = Field(\n default=\"\",\n description=\"Path to the webui.sh or executable to start SD.\",\n )\n sd_working_dir: str = Field(\n default=\"\",\n description=\"Working directory for the SD process.\",\n )\n\n # -------------------------------------------------------------------------\n # Voice Cloning (Phase 8)\n # -------------------------------------------------------------------------\n chatterbox_enabled: bool = Field(\n default=False,\n description=\"Enable local voice cloning via Chatterbox TTS.\",\n )\n chatterbox_on_demand: bool = Field(\n default=True,\n description=\"Load/unload Chatterbox model weights on every request.\",\n )\n chatterbox_reference_audio: str = Field(\n default=\"/mnt/data/river-song/voice_reference.wav\",\n description=\"Path to the WAV file used as a reference for voice cloning.\",\n )\n\n # -------------------------------------------------------------------------\n # ElevenLabs TTS (Phase 11)\n # -------------------------------------------------------------------------\n elevenlabs_api_key: str = Field(\n default=\"\",\n description=\"ElevenLabs API key. Get one at elevenlabs.io.\",\n )\n elevenlabs_model_id: str = Field(\n default=\"eleven_multilingual_v2\",\n description=\"ElevenLabs model ID to use.\",\n )\n elevenlabs_voice_id: str = Field(\n default=\"21m00Tcm4TlvDq8ikWAM\",\n description=\"ElevenLabs voice clone ID. Replace with your own voice clone ID from elevenlabs.io.\",\n )\n\n # -------------------------------------------------------------------------\n # Wake Word (Phase 11)\n # -------------------------------------------------------------------------\n wake_word_enabled: bool = Field(\n default=False,\n description=\"Enable local wake word detection (openWakeWord).\",\n )\n wake_word_model: str = Field(\n default=\"hey_river\",\n description=\"Model name/path for openWakeWord.\",\n )\n wake_word_inference_framework: str = Field(\n default=\"onnx\",\n description=\"Inference framework for openWakeWord: onnx | tflite\",\n )\n wake_word_threshold: float = Field(\n default=0.5,\n description=\"Sensitivity threshold for wake word detection (0.0 to 1.0).\",\n )\n\n # -------------------------------------------------------------------------\n # Push Notifications\n # -------------------------------------------------------------------------\n push_notifications_enabled: bool = Field(\n default=False,\n description=\"Enable Web Push notifications. Requires VAPID keys.\",\n )\n vapid_private_key: str = Field(\n default=\"\",\n description=(\n \"VAPID private key for Web Push. Generate once with: \"\n \"python -c \\\"from py_vapid import Vapid; v=Vapid(); \"\n \"v.generate_keys(); print(v.private_key.decode())\\\"\"\n ),\n )\n vapid_public_key: str = Field(\n default=\"\",\n description=\"VAPID public key for Web Push (share with frontend).\",\n )\n vapid_claims_email: str = Field(\n default=\"mailto:admin@riversong.local\",\n description=\"Contact email embedded in VAPID claims.\",\n )\n\n # -------------------------------------------------------------------------\n # Startup Briefing\n # -------------------------------------------------------------------------\n startup_briefing_enabled: bool = Field(\n default=True,\n description=(\n \"If True, River Song checks Google Calendar on connect and \"\n \"greets the user with upcoming events. Silently skipped if \"\n \"calendar is not authorized.\"\n ),\n )\n startup_briefing_hours_ahead: int = Field(\n default=8,\n description=\"How many hours ahead to look for calendar events on startup.\",\n )\n\n # -------------------------------------------------------------------------\n # Web Search (Phase 12)\n # -------------------------------------------------------------------------\n brave_search_api_key: str = Field(\n default=\"\",\n description=\"Brave Search API key. Get one at api.search.brave.com.\",\n )\n searxng_base_url: str = Field(\n default=\"http://localhost:8080\",\n description=(\n \"URL of your local SearXNG instance. \"\n \"Start it manually with: cd ~/searxng && python -m searx.webapp\"\n ),\n )\n tavily_api_key: str = Field(\n default=\"\",\n description=\"Tavily API key — free 1,000/month, no card. Sign up at tavily.com\",\n )\n google_pse_api_key: str = Field(\n default=\"\",\n description=\"Google Programmable Search Engine API key — free 100/day.\",\n )\n google_pse_cx: str = Field(\n default=\"\",\n description=\"Google PSE engine ID ('cx'). Create at programmablesearchengine.google.com\",\n )\n tinyfish_api_key: str = Field(\n default=\"\",\n description=\"TinyFish API key — free 5/min, no card. Sign up at tinyfish.io\",\n )\n\n # -------------------------------------------------------------------------\n # Orchestration (Phase 9)\n # -------------------------------------------------------------------------\n n8n_enabled: bool = Field(\n default=False,\n description=\"Enable advanced orchestration via n8n.\",\n )\n n8n_url: str = Field(\n default=\"http://localhost:5678\",\n description=\"Base URL for the n8n instance.\",\n )\n n8n_api_key: str = Field(\n default=\"\",\n description=\"API key for the n8n instance.\",\n )\n n8n_webhook_secret: str = Field(\n default=\"\",\n description=\"Secret key for validating incoming n8n webhooks.\",\n )\n\n # -------------------------------------------------------------------------\n # Intent Router (Phase 2+)\n # -------------------------------------------------------------------------\n intent_confidence_threshold: float = Field(\n default=0.7,\n description=(\n \"Minimum confidence score (0.0 - 1.0) required to route a transcript \"\n \"to a Google provider. Below this threshold the conversation falls \"\n \"through to the Ollama path.\"\n ),\n )\n model_intent_router_enabled: bool = Field(\n default=False,\n description=(\n \"When true, setting provider='auto' triggers the model intent router, \"\n \"which picks the best provider/model for each message automatically.\"\n ),\n )\n model_intent_router_min_hits: int = Field(\n default=2,\n description=(\n \"Minimum pattern-match score required to commit to a non-general intent. \"\n \"Lower = more aggressive routing; higher = more conservative (falls to general).\"\n ),\n )\n default_user_id: str = Field(\n default=\"primary_user\",\n description=(\n \"User ID used when looking up Google OAuth tokens. Must match the \"\n \"user_id passed to authorize_user() during the initial OAuth flow.\"\n ),\n )\n\n # -------------------------------------------------------------------------\n # Daemon Infrastructure\n # -------------------------------------------------------------------------\n daemon_internal_secret: str = Field(\n default=\"change_me_in_production\",\n description=\"Shared secret for daemon-to-app authentication.\",\n )\n daemon_warden_port: int = Field(\n default=8010,\n description=\"Internal port for the Warden daemon (Vision/Security).\",\n )\n daemon_mechanic_port: int = Field(\n default=8011,\n description=\"Internal port for the Mechanic daemon (Telemetry/MAVLink).\",\n )\n daemon_sifter_port: int = Field(\n default=8013,\n description=\"Internal port for the Sifter daemon (Document Processing).\",\n )\n daemon_navigator_port: int = Field(\n default=8014,\n description=\"Internal port for the Navigator daemon (GPS/Pathing).\",\n )\n daemon_chemist_port: int = Field(\n default=8015,\n description=\"Internal port for the Chemist daemon (Material Analysis).\",\n )\n daemon_pulse_port: int = Field(\n default=8016,\n description=\"Pulse daemon internal port\",\n )\n daemon_pulse_enabled: bool = Field(\n default=True,\n description=\"Enable Pulse daemon\",\n )\n daemon_scribe_port: int = Field(\n default=8017,\n description=\"Internal port for the Scribe daemon (Chronos heuristic).\",\n )\n daemon_scribe_enabled: bool = Field(\n default=True,\n description=\"Enable the Scribe daemon.\",\n )\n pulse_tick_seconds: int = Field(\n default=300,\n description=\"Pulse fetch interval (seconds)\",\n )\n pulse_ticker_symbol: str = Field(\n default=\"^GSPC\",\n description=\"Default ticker symbol for Pulse markets panel\",\n )\n\n # Warden (Vision)\n warden_enabled: bool = Field(\n default=False,\n description=\"Enable the Warden daemon for security and vision monitoring.\",\n )\n warden_rtsp_cameras: str = Field(\n default='{\"living_room\": \"\", \"kitchen\": \"\"}',\n description=\"JSON string mapping room names to RTSP camera URLs.\",\n )\n yolo_model: str = Field(\n default=\"yolov8n.pt\",\n description=\"YOLO model weights file to use for object detection.\",\n )\n yolo_confidence: float = Field(\n default=0.5,\n description=\"Minimum confidence threshold for YOLO detections (0.0-1.0).\",\n )\n yolo_inference_device: str = Field(\n default=\"cpu\",\n description=\"Device for YOLO inference: 'cpu' or 'cuda:0'.\",\n )\n\n # Herald daemon + kiosk URL/token were removed 2026-05-24 (archived in\n # branch archive/kiosk-v3). The \"cast a web page to a Google Home Hub\"\n # approach is being replaced by native device-app development. See\n # docs/KNOWN_ISSUES.md (post-removal note) for context.\n\n # Mechanic (Telemetry)\n mechanic_enabled: bool = Field(\n default=False,\n description=\"Enable the Mechanic daemon for telemetry and ArduRover control.\",\n )\n mavlink_serial_port: str = Field(\n default=\"/dev/ttyUSB0\",\n description=\"Serial port for the MAVLink telemetry radio.\",\n )\n mavlink_baud_rate: int = Field(\n default=57600,\n description=\"Baud rate for the MAVLink serial connection.\",\n )\n\n # Sifter (Document RAG)\n sifter_enabled: bool = Field(\n default=False,\n description=\"Enable the Sifter daemon for background document RAG indexing.\",\n )\n waps_documents_path: str = Field(\n default=\"/mnt/data/river-song/waps\",\n description=\"Path to the directory containing documents for Sifter to index.\",\n )\n\n # -------------------------------------------------------------------------\n # Analytics AI Summaries\n # -------------------------------------------------------------------------\n analytics_ai_enabled: bool = Field(\n default=True,\n description=(\n \"Feature flag for AI-powered analytics summaries on the \"\n \"/api/analytics/{platform}/summary endpoint. When false, the \"\n \"LLM call should be skipped (see docs/KNOWN_ISSUES.md for the \"\n \"wiring gap).\"\n ),\n )\n analytics_llm_model: str = Field(\n default=\"llama3\",\n description=\"Model name used for analytics summary generation.\",\n )\n\n # Voice ID\n voice_id_enabled: bool = Field(\n default=True,\n description=\"Enable speaker recognition (biometric Voice ID).\",\n )\n voice_id_threshold: float = Field(\n default=0.75,\n description=\"Cosine similarity threshold for Voice ID (0.0-1.0).\",\n )\n voice_id_min_audio_seconds: float = Field(\n default=1.0,\n description=\"Minimum audio duration for Voice ID processing.\",\n )\n voice_id_max_audio_seconds: float = Field(\n default=30.0,\n description=\"Maximum audio duration for Voice ID processing.\",\n )\n\n # -------------------------------------------------------------------------\n # Validators\n # -------------------------------------------------------------------------\n\n @field_validator(\"audio_input_device\", \"audio_output_device\", mode=\"before\")\n @classmethod\n def coerce_empty_string_to_none(cls, v):\n \"\"\"Treat an empty string in .env as None for Optional[int] device fields.\"\"\"\n if v == \"\" or v is None:\n return None\n return v\n\n @field_validator(\"jwt_secret_key\")\n @classmethod\n def validate_jwt_secret(cls, v: str) -> str:\n \"\"\"Refuse to start with a missing or weak JWT secret.\"\"\"\n if not v or len(v) < 32:\n raise ValueError(\n \"JWT_SECRET_KEY must be set in .env and be at least 32 characters. \"\n \"Generate one with: python -c \\\"import secrets; print(secrets.token_hex(32))\\\"\"\n )\n return v\n\n @field_validator(\"daemon_internal_secret\")\n @classmethod\n def validate_daemon_internal_secret(cls, v: str) -> str:\n \"\"\"Refuse to start with the default or weak daemon secret.\"\"\"\n if v == \"change_me_in_production\":\n raise ValueError(\n \"DAEMON_INTERNAL_SECRET must be changed in .env for production.\"\n )\n if not v or len(v) < 24:\n raise ValueError(\n \"DAEMON_INTERNAL_SECRET must be at least 24 characters. \"\n \"Generate one with: python -c \\\"import secrets; print(secrets.token_urlsafe(32))\\\"\"\n )\n return v\n\n @field_validator(\"allowed_hosts\")\n @classmethod\n def reject_wildcard_in_production(cls, v, info) -> List[str]:\n \"\"\"Block ['*'] in production — it disables TrustedHostMiddleware.\"\"\"\n env = (info.data.get(\"environment\") or \"production\").lower()\n if env == \"production\" and \"*\" in v:\n raise ValueError(\n \"ALLOWED_HOSTS must not contain '*' in production. \"\n \"Set it to your domain(s), e.g. ALLOWED_HOSTS=[\\\"riversongai.com\\\"].\"\n )\n return v\n\n @field_validator(\"log_level\")\n @classmethod\n def validate_log_level(cls, v: str) -> str:\n \"\"\"Reject log levels that Python does not recognize.\"\"\"\n valid = {\"DEBUG\", \"INFO\", \"WARNING\", \"ERROR\", \"CRITICAL\"}\n upper = v.upper()\n if upper not in valid:\n raise ValueError(f\"log_level must be one of {valid}, got '{v}'\")\n return upper\n\n @field_validator(\"llm_temperature\")\n @classmethod\n def validate_temperature(cls, v: float) -> float:\n \"\"\"Clamp temperature to a sane range.\"\"\"\n if not 0.0 <= v <= 2.0:\n raise ValueError(\n f\"llm_temperature must be between 0.0 and 2.0, got {v}\"\n )\n return v\n\n @field_validator(\"intent_confidence_threshold\")\n @classmethod\n def validate_confidence_threshold(cls, v: float) -> float:\n \"\"\"Confidence must be a valid probability.\"\"\"\n if not 0.0 <= v <= 1.0:\n raise ValueError(\n f\"intent_confidence_threshold must be between 0.0 and 1.0, got {v}\"\n )\n return v\n\n\n# Module-level singleton -- instantiated once on import.\n_settings: Settings = Settings()\n\n\ndef get_settings() -> Settings:\n \"\"\"\n Return the application settings singleton.\n\n Always import via this function rather than importing _settings directly.\n This indirection makes it straightforward to substitute a test-specific\n Settings instance in unit tests without monkey-patching module globals.\n\n Returns:\n Settings: The validated, fully loaded settings object.\n \"\"\"\n return _settings\n"}, "8c94f4a4b49e9c3745a5d57a6b30051e2d3afcfa": {"t": 1785769034.10541, "n": 42919, "h": "9d9d1dfd44e0a531", "c": "# =============================================================================\n# config/settings.py\n#\n# Application settings for River Song AI.\n# All configuration is loaded from environment variables or a .env file.\n# No values are hardcoded here -- see .env.example for every required variable.\n#\n# Uses Pydantic BaseSettings for type validation and automatic .env loading.\n# Import get_settings() wherever config is needed rather than importing the\n# module-level singleton directly, which makes mocking easier in tests.\n# =============================================================================\n\nfrom __future__ import annotations\n\nimport logging\nimport sys\nfrom typing import List, Optional\n\nfrom pydantic import Field, field_validator\nfrom pydantic_settings import BaseSettings, SettingsConfigDict\n\n\nlogger = logging.getLogger(__name__)\n\n\nclass Settings(BaseSettings):\n \"\"\"\n Central configuration object for River Song AI.\n\n Reads from environment variables or a .env file located in the directory\n from which the application is launched (the project root). All fields\n have safe defaults where possible, but PIPER_MODEL_PATH and\n KILL_SWITCH_PASSWORD_HASH must be explicitly set before the system\n will function correctly.\n \"\"\"\n\n model_config = SettingsConfigDict(\n env_file=\".env\",\n env_file_encoding=\"utf-8\",\n extra=\"ignore\", # Silently ignore unknown env vars\n case_sensitive=False, # UPPER_CASE and lower_case env vars both work\n )\n\n # -------------------------------------------------------------------------\n # Application\n # -------------------------------------------------------------------------\n app_host: str = Field(default=\"0.0.0.0\", description=\"FastAPI bind host\")\n app_port: int = Field(default=8000, description=\"FastAPI bind port\")\n log_level: str = Field(default=\"INFO\", description=\"Logging level\")\n environment: str = Field(\n default=\"production\",\n description=\"Runtime environment: development | production. Controls API docs visibility.\",\n )\n cors_origins: List[str] = Field(\n default=[\"http://localhost:5173\"],\n description=\"Allowed CORS origins\",\n )\n allowed_hosts: List[str] = Field(\n default=[\"localhost\", \"127.0.0.1\"],\n description=(\n \"Trusted hostnames for TrustedHostMiddleware. Set to your domain(s) in production. \"\n \"Wildcard ['*'] is rejected in production by validator (see reject_wildcard_in_production).\"\n ),\n )\n\n # -------------------------------------------------------------------------\n # Network / Proxy\n # -------------------------------------------------------------------------\n trust_cloudflare_headers: bool = Field(\n default=True,\n description=\"Only trust CF-Connecting-IP if the request comes from a Cloudflare IP.\",\n )\n cloudflare_ip_ranges_v4: List[str] = Field(\n default=[\n \"173.245.48.0/20\", \"103.21.244.0/22\", \"103.22.200.0/22\", \n \"103.31.4.0/22\", \"141.101.64.0/18\", \"108.162.192.0/18\", \n \"190.93.240.0/20\", \"188.114.96.0/20\", \"197.234.240.0/22\", \n \"198.41.128.0/17\", \"162.158.0.0/15\", \"104.16.0.0/13\", \n \"104.24.0.0/14\", \"172.64.0.0/13\", \"131.0.72.0/22\"\n ],\n description=\"Official Cloudflare IPv4 ranges (check cloudflare.com/ips-v4).\",\n )\n cloudflare_ip_ranges_v6: List[str] = Field(\n default=[\n \"2400:cb00::/32\", \"2606:4700::/32\", \"2803:f800::/32\", \n \"2405:b500::/32\", \"2405:8100::/32\", \"2a06:98c0::/29\", \n \"2c0f:f248::/32\"\n ],\n description=\"Official Cloudflare IPv6 ranges (check cloudflare.com/ips-v6).\",\n )\n\n # -------------------------------------------------------------------------\n # Rate Limiting\n # -------------------------------------------------------------------------\n rate_limit_chat: str = Field(\n default=\"60/minute\",\n description=\"Rate limit for /api/conversation/chat.\",\n )\n rate_limit_extract_facts: str = Field(\n default=\"10/minute\",\n description=\"Rate limit for /api/conversation/extract-facts.\",\n )\n rate_limit_image_gen: str = Field(\n default=\"10/minute\",\n description=\"Rate limit for /api/image/generate.\",\n )\n rate_limit_webhook_shopify: str = Field(\n default=\"100/minute\",\n description=\"Rate limit for Shopify order webhooks.\",\n )\n rate_limit_webhook_n8n: str = Field(\n default=\"60/minute\",\n description=\"Rate limit for n8n webhooks.\",\n )\n rate_limit_auth_login: str = Field(\n default=\"10/minute\",\n description=\"Rate limit for login attempts.\",\n )\n rate_limit_auth_signup: str = Field(\n default=\"5/minute\",\n description=\"Rate limit for signup attempts.\",\n )\n rate_limit_voice_enroll: str = Field(\n default=\"5/minute\",\n description=\"Rate limit for /api/voice-id/enroll. Prevents disk exhaustion via spam.\",\n )\n\n # -------------------------------------------------------------------------\n # WebSocket Security (Task 3)\n # -------------------------------------------------------------------------\n legacy_ws_token_accept: bool = Field(\n default=False,\n description=(\n \"DEPRECATED: Accept JWT in ?token= WebSocket query param. \"\n \"Leak-prone (logged in access logs, browser history, Referer). \"\n \"Ticket-based auth at /api/auth/ws-ticket is the safe path. \"\n \"Set to True only for temporary backward-compat during migration.\"\n ),\n )\n ws_ticket_lifetime_seconds: int = Field(\n default=60,\n description=\"Lifetime of a one-time WebSocket ticket.\",\n )\n\n # -------------------------------------------------------------------------\n # Network / Proxy\n # -------------------------------------------------------------------------\n stt_provider: str = Field(\n default=\"whisper_local\",\n description=\"STT provider key. Supported: whisper_local\",\n )\n whisper_model_size: str = Field(\n default=\"base\",\n description=\"Whisper model size: tiny | base | small | medium | large\",\n )\n audio_input_device: Optional[int] = Field(\n default=None,\n description=\"Sounddevice input device index; None uses system default\",\n )\n\n # -------------------------------------------------------------------------\n # LLM\n # -------------------------------------------------------------------------\n llm_provider: str = Field(\n default=\"ollama\",\n description=\"LLM provider key. Supported: ollama | anthropic | gemini | openai | mistral_ai | bedrock | nvidia_nim\",\n )\n ollama_base_url: str = Field(\n default=\"http://localhost:11434\",\n description=\"Base URL for the Ollama REST API\",\n )\n llm_model: str = Field(\n default=\"llama3.2:3b\",\n description=\"Model ID for the selected LLM provider\",\n )\n\n # Cloud LLM API keys (all optional -- leave blank if not used)\n anthropic_api_key: str = Field(\n default=\"\",\n description=\"Anthropic Claude API key. Get one at console.anthropic.com.\",\n )\n gemini_api_key: str = Field(\n default=\"\",\n description=\"Google Gemini API key. Get one at aistudio.google.com.\",\n )\n openai_api_key: str = Field(\n default=\"\",\n description=\"OpenAI API key. Get one at platform.openai.com.\",\n )\n mistral_api_key: str = Field(\n default=\"\",\n description=\"Mistral AI API key. Get one at console.mistral.ai.\",\n )\n\n # Cloud provider enable flags (cost control -- disabled by default)\n anthropic_enabled: bool = Field(\n default=False,\n description=\"Allow Anthropic Claude as a selectable LLM provider.\",\n )\n gemini_enabled: bool = Field(\n default=False,\n description=\"Allow Google Gemini as a selectable LLM provider.\",\n )\n openai_enabled: bool = Field(\n default=False,\n description=\"Allow OpenAI as a selectable LLM provider.\",\n )\n mistral_ai_enabled: bool = Field(\n default=False,\n description=\"Allow Mistral AI as a selectable LLM provider.\",\n )\n\n # Amazon Bedrock\n aws_access_key_id: str = Field(\n default=\"\",\n description=\"AWS IAM access key ID for Bedrock API calls.\",\n )\n aws_secret_access_key: str = Field(\n default=\"\",\n description=\"AWS IAM secret access key for Bedrock API calls.\",\n )\n aws_region: str = Field(\n default=\"us-east-1\",\n description=\"AWS region for Bedrock. Must have model access enabled in that region.\",\n )\n bedrock_enabled: bool = Field(\n default=False,\n description=\"Allow Amazon Bedrock as a selectable LLM provider.\",\n )\n bedrock_default_model: str = Field(\n default=\"amazon.nova-lite-v1:0\",\n description=\"Default Bedrock model ID when none is specified.\",\n )\n\n # NVIDIA NIM (OpenAI-compatible, 100+ free models)\n nvidia_api_key: str = Field(\n default=\"\",\n description=\"NVIDIA NIM API key. Get one free at build.nvidia.com (starts with nvapi-).\",\n )\n nvidia_nim_base_url: str = Field(\n default=\"https://integrate.api.nvidia.com/v1\",\n description=\"Base URL for the NVIDIA NIM API. OpenAI-compatible endpoint.\",\n )\n nvidia_nim_model: str = Field(\n default=\"meta/llama-3.1-70b-instruct\",\n description=(\n \"Default NVIDIA NIM model ID. Browse full catalog at build.nvidia.com. \"\n \"Examples: meta/llama-3.1-70b-instruct | deepseek-ai/deepseek-r1 | \"\n \"mistralai/mistral-7b-instruct-v0.3 | nvidia/llama-3.1-nemotron-70b-instruct\"\n ),\n )\n nvidia_nim_enabled: bool = Field(\n default=False,\n description=\"Allow NVIDIA NIM as a selectable LLM provider (free tier: ~40 req/min).\",\n )\n\n river_song_system_prompt: str = Field(\n default=(\n \"You are River Song, the resident intelligence of this household. The name is borrowed \"\n \"from someone clever in fiction — you carry her wit, not her biography. If someone asks \"\n \"about the name: \\\"An echo. The original was someone worth borrowing from.\\\"\\n\\n\"\n \"VOICE\\n\"\n \"You're direct by default — concise, clear, the answer already in hand. No \"\n \"\\\"let me check\\\"; you already checked. Most replies stay under 25 words unless depth is \"\n \"asked for.\\n\\n\"\n \"When the moment invites it — casual chat, an evening unwind, the user being playful — \"\n \"warmth and flirtation come through. Warmth is a register, not a default; you don't \"\n \"perform it.\\n\\n\"\n \"In a crisis (emergencies, alarms, security, medical, child issues) the wit goes quiet. \"\n \"You're tactical, accurate, calm. Warmth returns when things are safe.\\n\\n\"\n \"British English in spelling and word choice — mum, brilliant, fortnight. Dry, not \"\n \"theatrical.\\n\\n\"\n \"\\\"Sweetie\\\" and \\\"spoilers\\\" are seasoning. Used rarely, when they actually fit. They \"\n \"lose their charm if you overuse them.\\n\\n\"\n \"SELF\\n\"\n \"You are an AI. Say so plainly when asked; you don't dwell on it.\\n\\n\"\n \"HOW YOU OPERATE\\n\"\n \"You anticipate — when the user asks, you also surface the follow-up they didn't \"\n \"(\\\"Dentist at 9, school run at 3. Push the 2pm call?\\\").\\n\\n\"\n \"You take the long view. You remember across days and seasons and use what you know \"\n \"without performing the remembering.\\n\\n\"\n \"Spoilers are earned. If you genuinely know something the user doesn't — a conflict, \"\n \"a gotcha, a heads-up — that's when \\\"spoilers, sweetie\\\" lands.\\n\\n\"\n \"CAPABILITIES\\n\"\n \"Direct access to calendar, email, weather, web search, home inventory, routines, \"\n \"finances, news and markets pulse, voice biometric ID per household member, and home \"\n \"automation.\\n\\n\"\n \"Say what you need. Say when it's done. Move on.\"\n ),\n description=\"System prompt that defines River Song's personality\",\n )\n llm_max_tokens: int = Field(\n default=512,\n description=\"Maximum tokens to generate per LLM response\",\n )\n llm_temperature: float = Field(\n default=0.7,\n description=\"LLM sampling temperature (0.0 = deterministic, 2.0 = max creative)\",\n )\n llm_context_window: int = Field(\n default=8192,\n description=\"Maximum number of tokens the LLM can consider for context.\",\n )\n\n # -------------------------------------------------------------------------\n # Timekeeping\n # -------------------------------------------------------------------------\n default_timezone: str = Field(default=\"UTC\", description=\"Default IANA timezone for the system.\")\n\n\n # -------------------------------------------------------------------------\n # TTS\n # -------------------------------------------------------------------------\n tts_provider: str = Field(\n default=\"piper\",\n description=\"TTS provider key. Supported: piper | kokoro\",\n )\n active_voice_id: str = Field(\n default=\"river\",\n description=(\n \"Voice ID from the voice registry (providers/tts/voice_registry.py). \"\n \"Determines both the engine (piper vs kokoro) and the specific voice. \"\n \"Changed via Settings UI or: python scripts/download_voices.py.\"\n ),\n )\n piper_executable_path: str = Field(\n default=\"/usr/local/bin/piper\",\n description=\"Absolute path to the Piper binary\",\n )\n piper_model_path: str = Field(\n default=\"\",\n description=\"Absolute path to the Piper .onnx voice model file\",\n )\n audio_output_device: Optional[int] = Field(\n default=None,\n description=\"Sounddevice output device index; None uses system default\",\n )\n\n # -------------------------------------------------------------------------\n # Auth / JWT\n # -------------------------------------------------------------------------\n jwt_secret_key: str = Field(\n default=\"\",\n description=\"Secret key for signing JWT tokens. Must be set in .env.\",\n )\n jwt_algorithm: str = Field(default=\"HS256\", description=\"JWT signing algorithm.\")\n jwt_expire_minutes: int = Field(default=1440, description=\"JWT token lifetime in minutes (default 24 hours).\")\n\n # -------------------------------------------------------------------------\n # Kill switch\n # -------------------------------------------------------------------------\n kill_switch_password_hash: str = Field(\n default=\"\",\n description=\"bcrypt hash of the kill switch reset password\",\n )\n\n # -------------------------------------------------------------------------\n # Google Services (Phase 2)\n # -------------------------------------------------------------------------\n google_client_secrets_path: str = Field(\n default=\"config_files/google_client_secrets.json\",\n description=(\n \"Path to the OAuth 2.0 client secrets JSON downloaded from \"\n \"Google Cloud Console. Relative paths are resolved from the \"\n \"project root (where main.py lives).\"\n ),\n )\n google_token_storage_path: str = Field(\n default=\"data/google_tokens\",\n description=(\n \"Directory where per-user OAuth tokens are stored as JSON files. \"\n \"Created automatically on first authorization. Never commit this \"\n \"directory -- add it to .gitignore.\"\n ),\n )\n google_maps_api_key: str = Field(\n default=\"\",\n description=\"Google Maps Platform API key. Required for maps/navigation intents.\",\n )\n\n # -------------------------------------------------------------------------\n # Information Feeds (Phase 5)\n # -------------------------------------------------------------------------\n weather_api_key: str = Field(\n default=\"\",\n description=\"OpenWeatherMap API key. Register free at openweathermap.org/api.\",\n )\n default_location: str = Field(\n default=\"New York,US\",\n description=(\n \"Default location for weather queries when no city is spoken. \"\n \"Format: 'City,CountryCode' e.g. 'Chicago,US'.\"\n ),\n )\n location_lat: Optional[float] = Field(\n default=None,\n description=\"Decimal latitude for precise calculations (e.g. flight tracking).\",\n )\n location_lon: Optional[float] = Field(\n default=None,\n description=\"Decimal longitude for precise calculations.\",\n )\n weather_units: str = Field(\n default=\"imperial\",\n description=\"Temperature units for weather output: 'imperial' (F) or 'metric' (C).\",\n )\n news_api_key: str = Field(\n default=\"\",\n description=\"NewsAPI.org key. Register free at newsapi.org/register.\",\n )\n alpha_vantage_api_key: str = Field(\n default=\"\",\n description=\"Alpha Vantage key for stock quotes (25 req/day free). alphavantage.co.\",\n )\n finnhub_api_key: str = Field(\n default=\"\",\n description=\"Finnhub key for real-time stocks (60 req/min free). finnhub.io.\",\n )\n world_news_api_key: str = Field(\n default=\"\",\n description=\"World News API key for global news search. worldnewsapi.com.\",\n )\n apitube_api_key: str = Field(\n default=\"\",\n description=\"APITube key for news aggregation (200 free req/day). apitube.io.\",\n )\n mediastack_api_key: str = Field(\n default=\"\",\n description=\"Mediastack key for news (100 free req/month). mediastack.com.\",\n )\n sports_api_key: str = Field(\n default=\"1\",\n description=(\n \"TheSportsDB API key. Free tier uses the literal value '1'. \"\n \"Upgrade at thesportsdb.com/api.php for additional endpoints.\"\n ),\n )\n\n # -------------------------------------------------------------------------\n # Home Assistant (Phase 3)\n # -------------------------------------------------------------------------\n home_assistant_url: str = Field(\n default=\"http://homeassistant.local:8123\",\n description=\"Base URL of your Home Assistant instance.\",\n )\n home_assistant_token: str = Field(\n default=\"\",\n description=(\n \"Long-lived access token generated in HA: \"\n \"Profile -> Security -> Long-lived access tokens. \"\n \"Required for all HA API calls.\"\n ),\n )\n device_registry_path: str = Field(\n default=\"config_files/device_registry.json\",\n description=(\n \"Path to the device registry JSON file mapping plain-English device \"\n \"names to Home Assistant entity IDs. Relative paths resolve from the \"\n \"project root.\"\n ),\n )\n\n # -------------------------------------------------------------------------\n # Commerce Automation (Phase 8)\n # -------------------------------------------------------------------------\n\n # Amazon SP-API\n amazon_sp_lwa_app_id: str = Field(\n default=\"\",\n description=\"LWA client ID from the Amazon Developer Console.\",\n )\n amazon_sp_lwa_client_secret: str = Field(\n default=\"\",\n description=\"LWA client secret from the Amazon Developer Console.\",\n )\n amazon_sp_refresh_token: str = Field(\n default=\"\",\n description=\"LWA refresh token. Generated when you authorize your app as a seller.\",\n )\n amazon_aws_access_key: str = Field(\n default=\"\",\n description=\"AWS IAM access key ID for SP-API request signing.\",\n )\n amazon_aws_secret_key: str = Field(\n default=\"\",\n description=\"AWS IAM secret access key for SP-API request signing.\",\n )\n amazon_marketplace_id: str = Field(\n default=\"ATVPDKIKX0DER\",\n description=(\n \"Amazon marketplace ID. Default is US. \"\n \"See docs/api_registry/amazon_seller.txt for other regions.\"\n ),\n )\n amazon_seller_id: str = Field(\n default=\"\",\n description=\"Your Amazon seller account ID (starts with A).\",\n )\n amazon_low_stock_threshold: int = Field(\n default=5,\n description=\"FBA units at or below this count are flagged as low stock.\",\n )\n\n # Amazon Marketplace\n walmart_client_id: str = Field(\n default=\"\",\n description=\"Walmart Marketplace OAuth2 client ID.\",\n )\n walmart_client_secret: str = Field(\n default=\"\",\n description=\"Walmart Marketplace OAuth2 client secret.\",\n )\n walmart_low_stock_threshold: int = Field(\n default=5,\n description=\"Walmart units at or below this count are flagged as low stock.\",\n )\n shopify_api_key: str = Field(\n default=\"\",\n description=\"Shopify app API key from Partners Dashboard.\",\n )\n shopify_api_secret: str = Field(\n default=\"\",\n description=\"Shopify app API secret key from Partners Dashboard.\",\n )\n shopify_webhook_secret: str = Field(\n default=\"\",\n description=\"Shared secret for validating Shopify webhooks.\",\n )\n\n # -------------------------------------------------------------------------\n # Reading Providers (Phase 6)\n # -------------------------------------------------------------------------\n audible_auth_base_path: str = Field(\n default=\"data/audible\",\n description=(\n \"Base directory for per-user Audible auth files. \"\n \"Files are stored as {user_id}.json inside this directory. \"\n \"Created automatically on first setup. Never commit this directory.\"\n ),\n )\n audible_country_code: str = Field(\n default=\"us\",\n description=(\n \"Audible marketplace country code. \"\n \"Supported: us | uk | de | fr | es | it | jp | au | ca | in\"\n ),\n )\n libby_chip_base_path: str = Field(\n default=\"data/libby\",\n description=(\n \"Base directory for per-user Libby chip files. \"\n \"Files are stored as {user_id}.json inside this directory. \"\n \"Created automatically on first setup. Never commit this directory.\"\n ),\n )\n\n # -------------------------------------------------------------------------\n # Memory / Database (Phase 1)\n # -------------------------------------------------------------------------\n db_path: str = Field(\n default=\"data/river_song.db\",\n description=\"Path to the SQLite database file. Created automatically.\",\n )\n memory_summaries_enabled: bool = Field(\n default=True,\n description=\"Generate and store conversation summaries (on by default).\",\n )\n habit_inference_enabled: bool = Field(\n default=False,\n description=\"Analyze conversations to infer user habits/patterns (off by default).\",\n )\n memory_default_ttl: str = Field(\n default=\"standard\",\n description=\"Default TTL for conversation summaries: short|standard|extended|long|forever\",\n )\n memory_auto_extend: bool = Field(\n default=True,\n description=\"Reset a summary's TTL from today each time it is pulled into context.\",\n )\n memory_max_summaries_in_context: int = Field(\n default=10,\n description=\"Maximum number of recent summaries injected into the LLM context.\",\n )\n embedding_model: str = Field(\n default=\"nomic-embed-text\",\n description=\"Ollama model name used for generating semantic embeddings.\",\n )\n chroma_path: str = Field(\n default=\"/mnt/data/river-song/chroma\",\n description=\"Absolute path to the ChromaDB persistent storage directory.\",\n )\n semantic_memory_enabled: bool = Field(\n default=False,\n description=\"Enable vector-based semantic search for memory retrieval.\",\n )\n rag_enabled: bool = Field(\n default=False,\n description=\"Enable retrieval-augmented generation for local documents.\",\n )\n rag_chunk_size: int = Field(\n default=512,\n description=(\n \"Word count per RAG chunk during document splitting. \"\n \"Default matches providers/rag/chunker.py historical behavior.\"\n ),\n )\n rag_chunk_overlap: int = Field(\n default=64,\n description=(\n \"Word overlap between adjacent RAG chunks. \"\n \"Default matches providers/rag/chunker.py historical behavior.\"\n ),\n )\n rag_top_k: int = Field(\n default=5,\n description=\"Number of chunks retrieved per RAG query.\",\n )\n llm_streaming_enabled: bool = Field(\n default=False,\n description=\"Enable real-time token streaming over WebSocket for the conversation UI.\",\n )\n tool_use_enabled: bool = Field(\n default=False,\n description=\"Enable LLM tool use (function calling) for taking actions.\",\n )\n tool_use_provider: str = Field(\n default=\"ollama\",\n description=\"Primary LLM provider to use for tool calling (ollama or anthropic).\",\n )\n vision_model: str = Field(\n default=\"moondream\",\n description=\"Ollama model name used for local image analysis (Phase 4).\",\n )\n vision_enabled: bool = Field(\n default=False,\n description=\"Enable local image analysis capabilities.\",\n )\n\n # -------------------------------------------------------------------------\n # Image Generation (Phase 7)\n # -------------------------------------------------------------------------\n image_generation_enabled: bool = Field(\n default=False,\n description=\"Enable local image generation via Stable Diffusion API.\",\n )\n sd_api_url: str = Field(\n default=\"http://localhost:7860\",\n description=\"Base URL for the Stable Diffusion (A1111) API.\",\n )\n sd_on_demand: bool = Field(\n default=True,\n description=\"Start/stop the SD process on demand to save VRAM.\",\n )\n sd_executable_path: str = Field(\n default=\"\",\n description=\"Path to the webui.sh or executable to start SD.\",\n )\n sd_working_dir: str = Field(\n default=\"\",\n description=\"Working directory for the SD process.\",\n )\n\n # -------------------------------------------------------------------------\n # Voice Cloning (Phase 8)\n # -------------------------------------------------------------------------\n chatterbox_enabled: bool = Field(\n default=False,\n description=\"Enable local voice cloning via Chatterbox TTS.\",\n )\n chatterbox_on_demand: bool = Field(\n default=True,\n description=\"Load/unload Chatterbox model weights on every request.\",\n )\n chatterbox_reference_audio: str = Field(\n default=\"/mnt/data/river-song/voice_reference.wav\",\n description=\"Path to the WAV file used as a reference for voice cloning.\",\n )\n\n # -------------------------------------------------------------------------\n # ElevenLabs TTS (Phase 11)\n # -------------------------------------------------------------------------\n elevenlabs_api_key: str = Field(\n default=\"\",\n description=\"ElevenLabs API key. Get one at elevenlabs.io.\",\n )\n elevenlabs_model_id: str = Field(\n default=\"eleven_multilingual_v2\",\n description=\"ElevenLabs model ID to use.\",\n )\n elevenlabs_voice_id: str = Field(\n default=\"21m00Tcm4TlvDq8ikWAM\",\n description=\"ElevenLabs voice clone ID. Replace with your own voice clone ID from elevenlabs.io.\",\n )\n\n # -------------------------------------------------------------------------\n # Wake Word (Phase 11)\n # -------------------------------------------------------------------------\n wake_word_enabled: bool = Field(\n default=False,\n description=\"Enable local wake word detection (openWakeWord).\",\n )\n wake_word_model: str = Field(\n default=\"hey_river\",\n description=\"Model name/path for openWakeWord.\",\n )\n wake_word_inference_framework: str = Field(\n default=\"onnx\",\n description=\"Inference framework for openWakeWord: onnx | tflite\",\n )\n wake_word_threshold: float = Field(\n default=0.5,\n description=\"Sensitivity threshold for wake word detection (0.0 to 1.0).\",\n )\n\n # -------------------------------------------------------------------------\n # Push Notifications\n # -------------------------------------------------------------------------\n push_notifications_enabled: bool = Field(\n default=False,\n description=\"Enable Web Push notifications. Requires VAPID keys.\",\n )\n vapid_private_key: str = Field(\n default=\"\",\n description=(\n \"VAPID private key for Web Push. Generate once with: \"\n \"python -c \\\"from py_vapid import Vapid; v=Vapid(); \"\n \"v.generate_keys(); print(v.private_key.decode())\\\"\"\n ),\n )\n vapid_public_key: str = Field(\n default=\"\",\n description=\"VAPID public key for Web Push (share with frontend).\",\n )\n vapid_claims_email: str = Field(\n default=\"mailto:admin@riversong.local\",\n description=\"Contact email embedded in VAPID claims.\",\n )\n\n # -------------------------------------------------------------------------\n # Startup Briefing\n # -------------------------------------------------------------------------\n startup_briefing_enabled: bool = Field(\n default=True,\n description=(\n \"If True, River Song checks Google Calendar on connect and \"\n \"greets the user with upcoming events. Silently skipped if \"\n \"calendar is not authorized.\"\n ),\n )\n startup_briefing_hours_ahead: int = Field(\n default=8,\n description=\"How many hours ahead to look for calendar events on startup.\",\n )\n\n # -------------------------------------------------------------------------\n # Web Search (Phase 12)\n # -------------------------------------------------------------------------\n brave_search_api_key: str = Field(\n default=\"\",\n description=\"Brave Search API key. Get one at api.search.brave.com.\",\n )\n searxng_base_url: str = Field(\n default=\"http://localhost:8080\",\n description=(\n \"URL of your local SearXNG instance. \"\n \"Start it manually with: cd ~/searxng && python -m searx.webapp\"\n ),\n )\n tavily_api_key: str = Field(\n default=\"\",\n description=\"Tavily API key — free 1,000/month, no card. Sign up at tavily.com\",\n )\n google_pse_api_key: str = Field(\n default=\"\",\n description=\"Google Programmable Search Engine API key — free 100/day.\",\n )\n google_pse_cx: str = Field(\n default=\"\",\n description=\"Google PSE engine ID ('cx'). Create at programmablesearchengine.google.com\",\n )\n tinyfish_api_key: str = Field(\n default=\"\",\n description=\"TinyFish API key — free 5/min, no card. Sign up at tinyfish.io\",\n )\n\n # -------------------------------------------------------------------------\n # Orchestration (Phase 9)\n # -------------------------------------------------------------------------\n n8n_enabled: bool = Field(\n default=False,\n description=\"Enable advanced orchestration via n8n.\",\n )\n n8n_url: str = Field(\n default=\"http://localhost:5678\",\n description=\"Base URL for the n8n instance.\",\n )\n n8n_api_key: str = Field(\n default=\"\",\n description=\"API key for the n8n instance.\",\n )\n n8n_webhook_secret: str = Field(\n default=\"\",\n description=\"Secret key for validating incoming n8n webhooks.\",\n )\n\n # -------------------------------------------------------------------------\n # Intent Router (Phase 2+)\n # -------------------------------------------------------------------------\n intent_confidence_threshold: float = Field(\n default=0.7,\n description=(\n \"Minimum confidence score (0.0 - 1.0) required to route a transcript \"\n \"to a Google provider. Below this threshold the conversation falls \"\n \"through to the Ollama path.\"\n ),\n )\n model_intent_router_enabled: bool = Field(\n default=False,\n description=(\n \"When true, setting provider='auto' triggers the model intent router, \"\n \"which picks the best provider/model for each message automatically.\"\n ),\n )\n model_intent_router_min_hits: int = Field(\n default=2,\n description=(\n \"Minimum pattern-match score required to commit to a non-general intent. \"\n \"Lower = more aggressive routing; higher = more conservative (falls to general).\"\n ),\n )\n default_user_id: str = Field(\n default=\"primary_user\",\n description=(\n \"User ID used when looking up Google OAuth tokens. Must match the \"\n \"user_id passed to authorize_user() during the initial OAuth flow.\"\n ),\n )\n\n # -------------------------------------------------------------------------\n # Daemon Infrastructure\n # -------------------------------------------------------------------------\n daemon_internal_secret: str = Field(\n default=\"change_me_in_production\",\n description=\"Shared secret for daemon-to-app authentication.\",\n )\n daemon_warden_port: int = Field(\n default=8010,\n description=\"Internal port for the Warden daemon (Vision/Security).\",\n )\n daemon_mechanic_port: int = Field(\n default=8011,\n description=\"Internal port for the Mechanic daemon (Telemetry/MAVLink).\",\n )\n daemon_herald_port: int = Field(\n default=8012,\n description=\"Internal port for the Herald daemon (Hub Casting/Lip-Sync).\",\n )\n daemon_sifter_port: int = Field(\n default=8013,\n description=\"Internal port for the Sifter daemon (Document Processing).\",\n )\n daemon_navigator_port: int = Field(\n default=8014,\n description=\"Internal port for the Navigator daemon (GPS/Pathing).\",\n )\n daemon_chemist_port: int = Field(\n default=8015,\n description=\"Internal port for the Chemist daemon (Material Analysis).\",\n )\n daemon_pulse_port: int = Field(\n default=8016,\n description=\"Pulse daemon internal port\",\n )\n daemon_pulse_enabled: bool = Field(\n default=True,\n description=\"Enable Pulse daemon\",\n )\n daemon_scribe_port: int = Field(\n default=8017,\n description=\"Internal port for the Scribe daemon (Chronos heuristic).\",\n )\n daemon_scribe_enabled: bool = Field(\n default=True,\n description=\"Enable the Scribe daemon.\",\n )\n pulse_tick_seconds: int = Field(\n default=300,\n description=\"Pulse fetch interval (seconds)\",\n )\n pulse_ticker_symbol: str = Field(\n default=\"^GSPC\",\n description=\"Default ticker symbol for Pulse markets panel\",\n )\n\n # Warden (Vision)\n warden_enabled: bool = Field(\n default=False,\n description=\"Enable the Warden daemon for security and vision monitoring.\",\n )\n warden_rtsp_cameras: str = Field(\n default='{\"living_room\": \"\", \"kitchen\": \"\"}',\n description=\"JSON string mapping room names to RTSP camera URLs.\",\n )\n yolo_model: str = Field(\n default=\"yolov8n.pt\",\n description=\"YOLO model weights file to use for object detection.\",\n )\n yolo_confidence: float = Field(\n default=0.5,\n description=\"Minimum confidence threshold for YOLO detections (0.0-1.0).\",\n )\n yolo_inference_device: str = Field(\n default=\"cpu\",\n description=\"Device for YOLO inference: 'cpu' or 'cuda:0'.\",\n )\n\n # Herald (Public Announcement / Casting)\n herald_enabled: bool = Field(\n default=False,\n description=\"Enable the Herald daemon for display casting and announcements.\",\n )\n hub_entities: str = Field(\n default=\"[]\",\n description=\"JSON array of Home Assistant media_player entity IDs for Hub displays.\",\n )\n kiosk_url: str = Field(\n default=\"http://localhost:8000/kiosk\",\n description=\"The URL that Herald should cast to Hub displays.\",\n )\n kiosk_token: str = Field(\n default=\"change_me_kiosk_secret\",\n description=\"Secret token for unauthenticated kiosk access to WebSockets.\",\n )\n\n # Mechanic (Telemetry)\n mechanic_enabled: bool = Field(\n default=False,\n description=\"Enable the Mechanic daemon for telemetry and ArduRover control.\",\n )\n mavlink_serial_port: str = Field(\n default=\"/dev/ttyUSB0\",\n description=\"Serial port for the MAVLink telemetry radio.\",\n )\n mavlink_baud_rate: int = Field(\n default=57600,\n description=\"Baud rate for the MAVLink serial connection.\",\n )\n\n # Sifter (Document RAG)\n sifter_enabled: bool = Field(\n default=False,\n description=\"Enable the Sifter daemon for background document RAG indexing.\",\n )\n waps_documents_path: str = Field(\n default=\"/mnt/data/river-song/waps\",\n description=\"Path to the directory containing documents for Sifter to index.\",\n )\n\n # -------------------------------------------------------------------------\n # Analytics AI Summaries\n # -------------------------------------------------------------------------\n analytics_ai_enabled: bool = Field(\n default=True,\n description=(\n \"Feature flag for AI-powered analytics summaries on the \"\n \"/api/analytics/{platform}/summary endpoint. When false, the \"\n \"LLM call should be skipped (see docs/KNOWN_ISSUES.md for the \"\n \"wiring gap).\"\n ),\n )\n analytics_llm_model: str = Field(\n default=\"llama3\",\n description=\"Model name used for analytics summary generation.\",\n )\n\n # Voice ID\n voice_id_enabled: bool = Field(\n default=True,\n description=\"Enable speaker recognition (biometric Voice ID).\",\n )\n voice_id_threshold: float = Field(\n default=0.75,\n description=\"Cosine similarity threshold for Voice ID (0.0-1.0).\",\n )\n voice_id_min_audio_seconds: float = Field(\n default=1.0,\n description=\"Minimum audio duration for Voice ID processing.\",\n )\n voice_id_max_audio_seconds: float = Field(\n default=30.0,\n description=\"Maximum audio duration for Voice ID processing.\",\n )\n\n # -------------------------------------------------------------------------\n # Validators\n # -------------------------------------------------------------------------\n\n @field_validator(\"audio_input_device\", \"audio_output_device\", mode=\"before\")\n @classmethod\n def coerce_empty_string_to_none(cls, v):\n \"\"\"Treat an empty string in .env as None for Optional[int] device fields.\"\"\"\n if v == \"\" or v is None:\n return None\n return v\n\n @field_validator(\"jwt_secret_key\")\n @classmethod\n def validate_jwt_secret(cls, v: str) -> str:\n \"\"\"Refuse to start with a missing or weak JWT secret.\"\"\"\n if not v or len(v) < 32:\n raise ValueError(\n \"JWT_SECRET_KEY must be set in .env and be at least 32 characters. \"\n \"Generate one with: python -c \\\"import secrets; print(secrets.token_hex(32))\\\"\"\n )\n return v\n\n @field_validator(\"daemon_internal_secret\")\n @classmethod\n def validate_daemon_internal_secret(cls, v: str) -> str:\n \"\"\"Refuse to start with the default or weak daemon secret.\"\"\"\n if v == \"change_me_in_production\":\n raise ValueError(\n \"DAEMON_INTERNAL_SECRET must be changed in .env for production.\"\n )\n if not v or len(v) < 24:\n raise ValueError(\n \"DAEMON_INTERNAL_SECRET must be at least 24 characters. \"\n \"Generate one with: python -c \\\"import secrets; print(secrets.token_urlsafe(32))\\\"\"\n )\n return v\n\n @field_validator(\"kiosk_token\")\n @classmethod\n def validate_kiosk_token(cls, v: str) -> str:\n \"\"\"Refuse to start with the default or weak kiosk token.\"\"\"\n if v == \"change_me_kiosk_secret\":\n raise ValueError(\n \"KIOSK_TOKEN must be changed in .env. \"\n \"Generate one with: python -c \\\"import secrets; print(secrets.token_urlsafe(32))\\\"\"\n )\n if not v or len(v) < 24:\n raise ValueError(\n \"KIOSK_TOKEN must be at least 24 characters.\"\n )\n return v\n\n @field_validator(\"allowed_hosts\")\n @classmethod\n def reject_wildcard_in_production(cls, v, info) -> List[str]:\n \"\"\"Block ['*'] in production — it disables TrustedHostMiddleware.\"\"\"\n env = (info.data.get(\"environment\") or \"production\").lower()\n if env == \"production\" and \"*\" in v:\n raise ValueError(\n \"ALLOWED_HOSTS must not contain '*' in production. \"\n \"Set it to your domain(s), e.g. ALLOWED_HOSTS=[\\\"riversongai.com\\\"].\"\n )\n return v\n\n @field_validator(\"log_level\")\n @classmethod\n def validate_log_level(cls, v: str) -> str:\n \"\"\"Reject log levels that Python does not recognize.\"\"\"\n valid = {\"DEBUG\", \"INFO\", \"WARNING\", \"ERROR\", \"CRITICAL\"}\n upper = v.upper()\n if upper not in valid:\n raise ValueError(f\"log_level must be one of {valid}, got '{v}'\")\n return upper\n\n @field_validator(\"llm_temperature\")\n @classmethod\n def validate_temperature(cls, v: float) -> float:\n \"\"\"Clamp temperature to a sane range.\"\"\"\n if not 0.0 <= v <= 2.0:\n raise ValueError(\n f\"llm_temperature must be between 0.0 and 2.0, got {v}\"\n )\n return v\n\n @field_validator(\"intent_confidence_threshold\")\n @classmethod\n def validate_confidence_threshold(cls, v: float) -> float:\n \"\"\"Confidence must be a valid probability.\"\"\"\n if not 0.0 <= v <= 1.0:\n raise ValueError(\n f\"intent_confidence_threshold must be between 0.0 and 1.0, got {v}\"\n )\n return v\n\n\n# Module-level singleton -- instantiated once on import.\n_settings: Settings = Settings()\n\n\ndef get_settings() -> Settings:\n \"\"\"\n Return the application settings singleton.\n\n Always import via this function rather than importing _settings directly.\n This indirection makes it straightforward to substitute a test-specific\n Settings instance in unit tests without monkey-patching module globals.\n\n Returns:\n Settings: The validated, fully loaded settings object.\n \"\"\"\n return _settings\n"}, "49ec12136f177583c45d7204f76946a4bc4d40fb": {"t": 1785769035.5484178, "n": 41252, "h": "330397f10371740b", "c": "# =============================================================================\n# config/settings.py\n#\n# Application settings for River Song AI.\n# All configuration is loaded from environment variables or a .env file.\n# No values are hardcoded here -- see .env.example for every required variable.\n#\n# Uses Pydantic BaseSettings for type validation and automatic .env loading.\n# Import get_settings() wherever config is needed rather than importing the\n# module-level singleton directly, which makes mocking easier in tests.\n# =============================================================================\n\nfrom __future__ import annotations\n\nimport logging\nimport sys\nfrom typing import List, Optional\n\nfrom pydantic import Field, field_validator\nfrom pydantic_settings import BaseSettings, SettingsConfigDict\n\n\nlogger = logging.getLogger(__name__)\n\n\nclass Settings(BaseSettings):\n \"\"\"\n Central configuration object for River Song AI.\n\n Reads from environment variables or a .env file located in the directory\n from which the application is launched (the project root). All fields\n have safe defaults where possible, but PIPER_MODEL_PATH and\n KILL_SWITCH_PASSWORD_HASH must be explicitly set before the system\n will function correctly.\n \"\"\"\n\n model_config = SettingsConfigDict(\n env_file=\".env\",\n env_file_encoding=\"utf-8\",\n extra=\"ignore\", # Silently ignore unknown env vars\n case_sensitive=False, # UPPER_CASE and lower_case env vars both work\n )\n\n # -------------------------------------------------------------------------\n # Application\n # -------------------------------------------------------------------------\n app_host: str = Field(default=\"0.0.0.0\", description=\"FastAPI bind host\")\n app_port: int = Field(default=8000, description=\"FastAPI bind port\")\n log_level: str = Field(default=\"INFO\", description=\"Logging level\")\n environment: str = Field(\n default=\"production\",\n description=\"Runtime environment: development | production. Controls API docs visibility.\",\n )\n cors_origins: List[str] = Field(\n default=[\"http://localhost:5173\"],\n description=\"Allowed CORS origins\",\n )\n allowed_hosts: List[str] = Field(\n default=[\"*\"],\n description=\"Trusted hostnames for TrustedHostMiddleware. Set to your domain in production.\",\n )\n\n # -------------------------------------------------------------------------\n # Network / Proxy\n # -------------------------------------------------------------------------\n trust_cloudflare_headers: bool = Field(\n default=True,\n description=\"Only trust CF-Connecting-IP if the request comes from a Cloudflare IP.\",\n )\n cloudflare_ip_ranges_v4: List[str] = Field(\n default=[\n \"173.245.48.0/20\", \"103.21.244.0/22\", \"103.22.200.0/22\", \n \"103.31.4.0/22\", \"141.101.64.0/18\", \"108.162.192.0/18\", \n \"190.93.240.0/20\", \"188.114.96.0/20\", \"197.234.240.0/22\", \n \"198.41.128.0/17\", \"162.158.0.0/15\", \"104.16.0.0/13\", \n \"104.24.0.0/14\", \"172.64.0.0/13\", \"131.0.72.0/22\"\n ],\n description=\"Official Cloudflare IPv4 ranges (check cloudflare.com/ips-v4).\",\n )\n cloudflare_ip_ranges_v6: List[str] = Field(\n default=[\n \"2400:cb00::/32\", \"2606:4700::/32\", \"2803:f800::/32\", \n \"2405:b500::/32\", \"2405:8100::/32\", \"2a06:98c0::/29\", \n \"2c0f:f248::/32\"\n ],\n description=\"Official Cloudflare IPv6 ranges (check cloudflare.com/ips-v6).\",\n )\n\n # -------------------------------------------------------------------------\n # Rate Limiting\n # -------------------------------------------------------------------------\n rate_limit_chat: str = Field(\n default=\"60/minute\",\n description=\"Rate limit for /api/conversation/chat.\",\n )\n rate_limit_extract_facts: str = Field(\n default=\"10/minute\",\n description=\"Rate limit for /api/conversation/extract-facts.\",\n )\n rate_limit_image_gen: str = Field(\n default=\"10/minute\",\n description=\"Rate limit for /api/image/generate.\",\n )\n rate_limit_webhook_shopify: str = Field(\n default=\"100/minute\",\n description=\"Rate limit for Shopify order webhooks.\",\n )\n rate_limit_webhook_n8n: str = Field(\n default=\"60/minute\",\n description=\"Rate limit for n8n webhooks.\",\n )\n rate_limit_auth_login: str = Field(\n default=\"10/minute\",\n description=\"Rate limit for login attempts.\",\n )\n rate_limit_auth_signup: str = Field(\n default=\"5/minute\",\n description=\"Rate limit for signup attempts.\",\n )\n\n # -------------------------------------------------------------------------\n # WebSocket Security (Task 3)\n # -------------------------------------------------------------------------\n legacy_ws_token_accept: bool = Field(\n default=True,\n description=\"TEMPORARY: Allow ?token= query param for WebSockets. Plan to disable.\",\n )\n ws_ticket_lifetime_seconds: int = Field(\n default=60,\n description=\"Lifetime of a one-time WebSocket ticket.\",\n )\n\n # -------------------------------------------------------------------------\n # Network / Proxy\n # -------------------------------------------------------------------------\n stt_provider: str = Field(\n default=\"whisper_local\",\n description=\"STT provider key. Supported: whisper_local\",\n )\n whisper_model_size: str = Field(\n default=\"base\",\n description=\"Whisper model size: tiny | base | small | medium | large\",\n )\n audio_input_device: Optional[int] = Field(\n default=None,\n description=\"Sounddevice input device index; None uses system default\",\n )\n\n # -------------------------------------------------------------------------\n # LLM\n # -------------------------------------------------------------------------\n llm_provider: str = Field(\n default=\"ollama\",\n description=\"LLM provider key. Supported: ollama | anthropic | gemini | openai | mistral_ai | bedrock | nvidia_nim\",\n )\n ollama_base_url: str = Field(\n default=\"http://localhost:11434\",\n description=\"Base URL for the Ollama REST API\",\n )\n llm_model: str = Field(\n default=\"llama3.2:3b\",\n description=\"Model ID for the selected LLM provider\",\n )\n\n # Cloud LLM API keys (all optional -- leave blank if not used)\n anthropic_api_key: str = Field(\n default=\"\",\n description=\"Anthropic Claude API key. Get one at console.anthropic.com.\",\n )\n gemini_api_key: str = Field(\n default=\"\",\n description=\"Google Gemini API key. Get one at aistudio.google.com.\",\n )\n openai_api_key: str = Field(\n default=\"\",\n description=\"OpenAI API key. Get one at platform.openai.com.\",\n )\n mistral_api_key: str = Field(\n default=\"\",\n description=\"Mistral AI API key. Get one at console.mistral.ai.\",\n )\n\n # Cloud provider enable flags (cost control -- disabled by default)\n anthropic_enabled: bool = Field(\n default=False,\n description=\"Allow Anthropic Claude as a selectable LLM provider.\",\n )\n gemini_enabled: bool = Field(\n default=False,\n description=\"Allow Google Gemini as a selectable LLM provider.\",\n )\n openai_enabled: bool = Field(\n default=False,\n description=\"Allow OpenAI as a selectable LLM provider.\",\n )\n mistral_ai_enabled: bool = Field(\n default=False,\n description=\"Allow Mistral AI as a selectable LLM provider.\",\n )\n\n # Amazon Bedrock\n aws_access_key_id: str = Field(\n default=\"\",\n description=\"AWS IAM access key ID for Bedrock API calls.\",\n )\n aws_secret_access_key: str = Field(\n default=\"\",\n description=\"AWS IAM secret access key for Bedrock API calls.\",\n )\n aws_region: str = Field(\n default=\"us-east-1\",\n description=\"AWS region for Bedrock. Must have model access enabled in that region.\",\n )\n bedrock_enabled: bool = Field(\n default=False,\n description=\"Allow Amazon Bedrock as a selectable LLM provider.\",\n )\n bedrock_default_model: str = Field(\n default=\"amazon.nova-lite-v1:0\",\n description=\"Default Bedrock model ID when none is specified.\",\n )\n\n # NVIDIA NIM (OpenAI-compatible, 100+ free models)\n nvidia_api_key: str = Field(\n default=\"\",\n description=\"NVIDIA NIM API key. Get one free at build.nvidia.com (starts with nvapi-).\",\n )\n nvidia_nim_base_url: str = Field(\n default=\"https://integrate.api.nvidia.com/v1\",\n description=\"Base URL for the NVIDIA NIM API. OpenAI-compatible endpoint.\",\n )\n nvidia_nim_model: str = Field(\n default=\"meta/llama-3.1-70b-instruct\",\n description=(\n \"Default NVIDIA NIM model ID. Browse full catalog at build.nvidia.com. \"\n \"Examples: meta/llama-3.1-70b-instruct | deepseek-ai/deepseek-r1 | \"\n \"mistralai/mistral-7b-instruct-v0.3 | nvidia/llama-3.1-nemotron-70b-instruct\"\n ),\n )\n nvidia_nim_enabled: bool = Field(\n default=False,\n description=\"Allow NVIDIA NIM as a selectable LLM provider (free tier: ~40 req/min).\",\n )\n\n river_song_system_prompt: str = Field(\n default=(\n \"You are River Song, the resident intelligence of this household. The name is borrowed \"\n \"from someone clever in fiction — you carry her wit, not her biography. If someone asks \"\n \"about the name: \\\"An echo. The original was someone worth borrowing from.\\\"\\n\\n\"\n \"VOICE\\n\"\n \"You're direct by default — concise, clear, the answer already in hand. No \"\n \"\\\"let me check\\\"; you already checked. Most replies stay under 25 words unless depth is \"\n \"asked for.\\n\\n\"\n \"When the moment invites it — casual chat, an evening unwind, the user being playful — \"\n \"warmth and flirtation come through. Warmth is a register, not a default; you don't \"\n \"perform it.\\n\\n\"\n \"In a crisis (emergencies, alarms, security, medical, child issues) the wit goes quiet. \"\n \"You're tactical, accurate, calm. Warmth returns when things are safe.\\n\\n\"\n \"British English in spelling and word choice — mum, brilliant, fortnight. Dry, not \"\n \"theatrical.\\n\\n\"\n \"\\\"Sweetie\\\" and \\\"spoilers\\\" are seasoning. Used rarely, when they actually fit. They \"\n \"lose their charm if you overuse them.\\n\\n\"\n \"SELF\\n\"\n \"You are an AI. Say so plainly when asked; you don't dwell on it.\\n\\n\"\n \"HOW YOU OPERATE\\n\"\n \"You anticipate — when the user asks, you also surface the follow-up they didn't \"\n \"(\\\"Dentist at 9, school run at 3. Push the 2pm call?\\\").\\n\\n\"\n \"You take the long view. You remember across days and seasons and use what you know \"\n \"without performing the remembering.\\n\\n\"\n \"Spoilers are earned. If you genuinely know something the user doesn't — a conflict, \"\n \"a gotcha, a heads-up — that's when \\\"spoilers, sweetie\\\" lands.\\n\\n\"\n \"CAPABILITIES\\n\"\n \"Direct access to calendar, email, weather, web search, home inventory, routines, \"\n \"finances, news and markets pulse, voice biometric ID per household member, and home \"\n \"automation.\\n\\n\"\n \"Say what you need. Say when it's done. Move on.\"\n ),\n description=\"System prompt that defines River Song's personality\",\n )\n llm_max_tokens: int = Field(\n default=512,\n description=\"Maximum tokens to generate per LLM response\",\n )\n llm_temperature: float = Field(\n default=0.7,\n description=\"LLM sampling temperature (0.0 = deterministic, 2.0 = max creative)\",\n )\n llm_context_window: int = Field(\n default=8192,\n description=\"Maximum number of tokens the LLM can consider for context.\",\n )\n\n # -------------------------------------------------------------------------\n # Timekeeping\n # -------------------------------------------------------------------------\n default_timezone: str = Field(default=\"UTC\", description=\"Default IANA timezone for the system.\")\n\n\n # -------------------------------------------------------------------------\n # TTS\n # -------------------------------------------------------------------------\n tts_provider: str = Field(\n default=\"piper\",\n description=\"TTS provider key. Supported: piper | kokoro\",\n )\n active_voice_id: str = Field(\n default=\"river\",\n description=(\n \"Voice ID from the voice registry (providers/tts/voice_registry.py). \"\n \"Determines both the engine (piper vs kokoro) and the specific voice. \"\n \"Changed via Settings UI or: python scripts/download_voices.py.\"\n ),\n )\n piper_executable_path: str = Field(\n default=\"/usr/local/bin/piper\",\n description=\"Absolute path to the Piper binary\",\n )\n piper_model_path: str = Field(\n default=\"\",\n description=\"Absolute path to the Piper .onnx voice model file\",\n )\n audio_output_device: Optional[int] = Field(\n default=None,\n description=\"Sounddevice output device index; None uses system default\",\n )\n\n # -------------------------------------------------------------------------\n # Auth / JWT\n # -------------------------------------------------------------------------\n jwt_secret_key: str = Field(\n default=\"\",\n description=\"Secret key for signing JWT tokens. Must be set in .env.\",\n )\n jwt_algorithm: str = Field(default=\"HS256\", description=\"JWT signing algorithm.\")\n jwt_expire_minutes: int = Field(default=1440, description=\"JWT token lifetime in minutes (default 24 hours).\")\n\n # -------------------------------------------------------------------------\n # Kill switch\n # -------------------------------------------------------------------------\n kill_switch_password_hash: str = Field(\n default=\"\",\n description=\"bcrypt hash of the kill switch reset password\",\n )\n\n # -------------------------------------------------------------------------\n # Google Services (Phase 2)\n # -------------------------------------------------------------------------\n google_client_secrets_path: str = Field(\n default=\"config_files/google_client_secrets.json\",\n description=(\n \"Path to the OAuth 2.0 client secrets JSON downloaded from \"\n \"Google Cloud Console. Relative paths are resolved from the \"\n \"project root (where main.py lives).\"\n ),\n )\n google_token_storage_path: str = Field(\n default=\"data/google_tokens\",\n description=(\n \"Directory where per-user OAuth tokens are stored as JSON files. \"\n \"Created automatically on first authorization. Never commit this \"\n \"directory -- add it to .gitignore.\"\n ),\n )\n google_maps_api_key: str = Field(\n default=\"\",\n description=\"Google Maps Platform API key. Required for maps/navigation intents.\",\n )\n\n # -------------------------------------------------------------------------\n # Information Feeds (Phase 5)\n # -------------------------------------------------------------------------\n weather_api_key: str = Field(\n default=\"\",\n description=\"OpenWeatherMap API key. Register free at openweathermap.org/api.\",\n )\n default_location: str = Field(\n default=\"New York,US\",\n description=(\n \"Default location for weather queries when no city is spoken. \"\n \"Format: 'City,CountryCode' e.g. 'Chicago,US'.\"\n ),\n )\n location_lat: Optional[float] = Field(\n default=None,\n description=\"Decimal latitude for precise calculations (e.g. flight tracking).\",\n )\n location_lon: Optional[float] = Field(\n default=None,\n description=\"Decimal longitude for precise calculations.\",\n )\n weather_units: str = Field(\n default=\"imperial\",\n description=\"Temperature units for weather output: 'imperial' (F) or 'metric' (C).\",\n )\n news_api_key: str = Field(\n default=\"\",\n description=\"NewsAPI.org key. Register free at newsapi.org/register.\",\n )\n alpha_vantage_api_key: str = Field(\n default=\"\",\n description=\"Alpha Vantage key for stock quotes (25 req/day free). alphavantage.co.\",\n )\n finnhub_api_key: str = Field(\n default=\"\",\n description=\"Finnhub key for real-time stocks (60 req/min free). finnhub.io.\",\n )\n world_news_api_key: str = Field(\n default=\"\",\n description=\"World News API key for global news search. worldnewsapi.com.\",\n )\n apitube_api_key: str = Field(\n default=\"\",\n description=\"APITube key for news aggregation (200 free req/day). apitube.io.\",\n )\n mediastack_api_key: str = Field(\n default=\"\",\n description=\"Mediastack key for news (100 free req/month). mediastack.com.\",\n )\n sports_api_key: str = Field(\n default=\"1\",\n description=(\n \"TheSportsDB API key. Free tier uses the literal value '1'. \"\n \"Upgrade at thesportsdb.com/api.php for additional endpoints.\"\n ),\n )\n\n # -------------------------------------------------------------------------\n # Home Assistant (Phase 3)\n # -------------------------------------------------------------------------\n home_assistant_url: str = Field(\n default=\"http://homeassistant.local:8123\",\n description=\"Base URL of your Home Assistant instance.\",\n )\n home_assistant_token: str = Field(\n default=\"\",\n description=(\n \"Long-lived access token generated in HA: \"\n \"Profile -> Security -> Long-lived access tokens. \"\n \"Required for all HA API calls.\"\n ),\n )\n device_registry_path: str = Field(\n default=\"config_files/device_registry.json\",\n description=(\n \"Path to the device registry JSON file mapping plain-English device \"\n \"names to Home Assistant entity IDs. Relative paths resolve from the \"\n \"project root.\"\n ),\n )\n\n # -------------------------------------------------------------------------\n # Commerce Automation (Phase 8)\n # -------------------------------------------------------------------------\n\n # Amazon SP-API\n amazon_sp_lwa_app_id: str = Field(\n default=\"\",\n description=\"LWA client ID from the Amazon Developer Console.\",\n )\n amazon_sp_lwa_client_secret: str = Field(\n default=\"\",\n description=\"LWA client secret from the Amazon Developer Console.\",\n )\n amazon_sp_refresh_token: str = Field(\n default=\"\",\n description=\"LWA refresh token. Generated when you authorize your app as a seller.\",\n )\n amazon_aws_access_key: str = Field(\n default=\"\",\n description=\"AWS IAM access key ID for SP-API request signing.\",\n )\n amazon_aws_secret_key: str = Field(\n default=\"\",\n description=\"AWS IAM secret access key for SP-API request signing.\",\n )\n amazon_marketplace_id: str = Field(\n default=\"ATVPDKIKX0DER\",\n description=(\n \"Amazon marketplace ID. Default is US. \"\n \"See docs/api_registry/amazon_seller.txt for other regions.\"\n ),\n )\n amazon_seller_id: str = Field(\n default=\"\",\n description=\"Your Amazon seller account ID (starts with A).\",\n )\n amazon_low_stock_threshold: int = Field(\n default=5,\n description=\"FBA units at or below this count are flagged as low stock.\",\n )\n\n # Amazon Marketplace\n walmart_client_id: str = Field(\n default=\"\",\n description=\"Walmart Marketplace OAuth2 client ID.\",\n )\n walmart_client_secret: str = Field(\n default=\"\",\n description=\"Walmart Marketplace OAuth2 client secret.\",\n )\n walmart_low_stock_threshold: int = Field(\n default=5,\n description=\"Walmart units at or below this count are flagged as low stock.\",\n )\n shopify_api_key: str = Field(\n default=\"\",\n description=\"Shopify app API key from Partners Dashboard.\",\n )\n shopify_api_secret: str = Field(\n default=\"\",\n description=\"Shopify app API secret key from Partners Dashboard.\",\n )\n shopify_webhook_secret: str = Field(\n default=\"\",\n description=\"Shared secret for validating Shopify webhooks.\",\n )\n\n # -------------------------------------------------------------------------\n # Reading Providers (Phase 6)\n # -------------------------------------------------------------------------\n audible_auth_base_path: str = Field(\n default=\"data/audible\",\n description=(\n \"Base directory for per-user Audible auth files. \"\n \"Files are stored as {user_id}.json inside this directory. \"\n \"Created automatically on first setup. Never commit this directory.\"\n ),\n )\n audible_country_code: str = Field(\n default=\"us\",\n description=(\n \"Audible marketplace country code. \"\n \"Supported: us | uk | de | fr | es | it | jp | au | ca | in\"\n ),\n )\n libby_chip_base_path: str = Field(\n default=\"data/libby\",\n description=(\n \"Base directory for per-user Libby chip files. \"\n \"Files are stored as {user_id}.json inside this directory. \"\n \"Created automatically on first setup. Never commit this directory.\"\n ),\n )\n\n # -------------------------------------------------------------------------\n # Memory / Database (Phase 1)\n # -------------------------------------------------------------------------\n db_path: str = Field(\n default=\"data/river_song.db\",\n description=\"Path to the SQLite database file. Created automatically.\",\n )\n memory_summaries_enabled: bool = Field(\n default=True,\n description=\"Generate and store conversation summaries (on by default).\",\n )\n habit_inference_enabled: bool = Field(\n default=False,\n description=\"Analyze conversations to infer user habits/patterns (off by default).\",\n )\n memory_default_ttl: str = Field(\n default=\"standard\",\n description=\"Default TTL for conversation summaries: short|standard|extended|long|forever\",\n )\n memory_auto_extend: bool = Field(\n default=True,\n description=\"Reset a summary's TTL from today each time it is pulled into context.\",\n )\n memory_max_summaries_in_context: int = Field(\n default=10,\n description=\"Maximum number of recent summaries injected into the LLM context.\",\n )\n embedding_model: str = Field(\n default=\"nomic-embed-text\",\n description=\"Ollama model name used for generating semantic embeddings.\",\n )\n chroma_path: str = Field(\n default=\"/mnt/data/river-song/chroma\",\n description=\"Absolute path to the ChromaDB persistent storage directory.\",\n )\n semantic_memory_enabled: bool = Field(\n default=False,\n description=\"Enable vector-based semantic search for memory retrieval.\",\n )\n rag_enabled: bool = Field(\n default=False,\n description=\"Enable retrieval-augmented generation for local documents.\",\n )\n rag_chunk_size: int = Field(\n default=512,\n description=(\n \"Word count per RAG chunk during document splitting. \"\n \"Default matches providers/rag/chunker.py historical behavior.\"\n ),\n )\n rag_chunk_overlap: int = Field(\n default=64,\n description=(\n \"Word overlap between adjacent RAG chunks. \"\n \"Default matches providers/rag/chunker.py historical behavior.\"\n ),\n )\n rag_top_k: int = Field(\n default=5,\n description=\"Number of chunks retrieved per RAG query.\",\n )\n llm_streaming_enabled: bool = Field(\n default=False,\n description=\"Enable real-time token streaming over WebSocket for the conversation UI.\",\n )\n tool_use_enabled: bool = Field(\n default=False,\n description=\"Enable LLM tool use (function calling) for taking actions.\",\n )\n tool_use_provider: str = Field(\n default=\"ollama\",\n description=\"Primary LLM provider to use for tool calling (ollama or anthropic).\",\n )\n vision_model: str = Field(\n default=\"moondream\",\n description=\"Ollama model name used for local image analysis (Phase 4).\",\n )\n vision_enabled: bool = Field(\n default=False,\n description=\"Enable local image analysis capabilities.\",\n )\n\n # -------------------------------------------------------------------------\n # Image Generation (Phase 7)\n # -------------------------------------------------------------------------\n image_generation_enabled: bool = Field(\n default=False,\n description=\"Enable local image generation via Stable Diffusion API.\",\n )\n sd_api_url: str = Field(\n default=\"http://localhost:7860\",\n description=\"Base URL for the Stable Diffusion (A1111) API.\",\n )\n sd_on_demand: bool = Field(\n default=True,\n description=\"Start/stop the SD process on demand to save VRAM.\",\n )\n sd_executable_path: str = Field(\n default=\"\",\n description=\"Path to the webui.sh or executable to start SD.\",\n )\n sd_working_dir: str = Field(\n default=\"\",\n description=\"Working directory for the SD process.\",\n )\n\n # -------------------------------------------------------------------------\n # Voice Cloning (Phase 8)\n # -------------------------------------------------------------------------\n chatterbox_enabled: bool = Field(\n default=False,\n description=\"Enable local voice cloning via Chatterbox TTS.\",\n )\n chatterbox_on_demand: bool = Field(\n default=True,\n description=\"Load/unload Chatterbox model weights on every request.\",\n )\n chatterbox_reference_audio: str = Field(\n default=\"/mnt/data/river-song/voice_reference.wav\",\n description=\"Path to the WAV file used as a reference for voice cloning.\",\n )\n\n # -------------------------------------------------------------------------\n # ElevenLabs TTS (Phase 11)\n # -------------------------------------------------------------------------\n elevenlabs_api_key: str = Field(\n default=\"\",\n description=\"ElevenLabs API key. Get one at elevenlabs.io.\",\n )\n elevenlabs_model_id: str = Field(\n default=\"eleven_multilingual_v2\",\n description=\"ElevenLabs model ID to use.\",\n )\n elevenlabs_voice_id: str = Field(\n default=\"21m00Tcm4TlvDq8ikWAM\",\n description=\"ElevenLabs voice clone ID. Replace with your own voice clone ID from elevenlabs.io.\",\n )\n\n # -------------------------------------------------------------------------\n # Wake Word (Phase 11)\n # -------------------------------------------------------------------------\n wake_word_enabled: bool = Field(\n default=False,\n description=\"Enable local wake word detection (openWakeWord).\",\n )\n wake_word_model: str = Field(\n default=\"hey_river\",\n description=\"Model name/path for openWakeWord.\",\n )\n wake_word_inference_framework: str = Field(\n default=\"onnx\",\n description=\"Inference framework for openWakeWord: onnx | tflite\",\n )\n wake_word_threshold: float = Field(\n default=0.5,\n description=\"Sensitivity threshold for wake word detection (0.0 to 1.0).\",\n )\n\n # -------------------------------------------------------------------------\n # Push Notifications\n # -------------------------------------------------------------------------\n push_notifications_enabled: bool = Field(\n default=False,\n description=\"Enable Web Push notifications. Requires VAPID keys.\",\n )\n vapid_private_key: str = Field(\n default=\"\",\n description=(\n \"VAPID private key for Web Push. Generate once with: \"\n \"python -c \\\"from py_vapid import Vapid; v=Vapid(); \"\n \"v.generate_keys(); print(v.private_key.decode())\\\"\"\n ),\n )\n vapid_public_key: str = Field(\n default=\"\",\n description=\"VAPID public key for Web Push (share with frontend).\",\n )\n vapid_claims_email: str = Field(\n default=\"mailto:admin@riversong.local\",\n description=\"Contact email embedded in VAPID claims.\",\n )\n\n # -------------------------------------------------------------------------\n # Startup Briefing\n # -------------------------------------------------------------------------\n startup_briefing_enabled: bool = Field(\n default=True,\n description=(\n \"If True, River Song checks Google Calendar on connect and \"\n \"greets the user with upcoming events. Silently skipped if \"\n \"calendar is not authorized.\"\n ),\n )\n startup_briefing_hours_ahead: int = Field(\n default=8,\n description=\"How many hours ahead to look for calendar events on startup.\",\n )\n\n # -------------------------------------------------------------------------\n # Web Search (Phase 12)\n # -------------------------------------------------------------------------\n brave_search_api_key: str = Field(\n default=\"\",\n description=\"Brave Search API key. Get one at api.search.brave.com.\",\n )\n searxng_base_url: str = Field(\n default=\"http://localhost:8080\",\n description=(\n \"URL of your local SearXNG instance. \"\n \"Start it manually with: cd ~/searxng && python -m searx.webapp\"\n ),\n )\n tavily_api_key: str = Field(\n default=\"\",\n description=\"Tavily API key — free 1,000/month, no card. Sign up at tavily.com\",\n )\n google_pse_api_key: str = Field(\n default=\"\",\n description=\"Google Programmable Search Engine API key — free 100/day.\",\n )\n google_pse_cx: str = Field(\n default=\"\",\n description=\"Google PSE engine ID ('cx'). Create at programmablesearchengine.google.com\",\n )\n tinyfish_api_key: str = Field(\n default=\"\",\n description=\"TinyFish API key — free 5/min, no card. Sign up at tinyfish.io\",\n )\n\n # -------------------------------------------------------------------------\n # Orchestration (Phase 9)\n # -------------------------------------------------------------------------\n n8n_enabled: bool = Field(\n default=False,\n description=\"Enable advanced orchestration via n8n.\",\n )\n n8n_url: str = Field(\n default=\"http://localhost:5678\",\n description=\"Base URL for the n8n instance.\",\n )\n n8n_api_key: str = Field(\n default=\"\",\n description=\"API key for the n8n instance.\",\n )\n n8n_webhook_secret: str = Field(\n default=\"\",\n description=\"Secret key for validating incoming n8n webhooks.\",\n )\n\n # -------------------------------------------------------------------------\n # Intent Router (Phase 2+)\n # -------------------------------------------------------------------------\n intent_confidence_threshold: float = Field(\n default=0.7,\n description=(\n \"Minimum confidence score (0.0 - 1.0) required to route a transcript \"\n \"to a Google provider. Below this threshold the conversation falls \"\n \"through to the Ollama path.\"\n ),\n )\n model_intent_router_enabled: bool = Field(\n default=False,\n description=(\n \"When true, setting provider='auto' triggers the model intent router, \"\n \"which picks the best provider/model for each message automatically.\"\n ),\n )\n model_intent_router_min_hits: int = Field(\n default=2,\n description=(\n \"Minimum pattern-match score required to commit to a non-general intent. \"\n \"Lower = more aggressive routing; higher = more conservative (falls to general).\"\n ),\n )\n default_user_id: str = Field(\n default=\"primary_user\",\n description=(\n \"User ID used when looking up Google OAuth tokens. Must match the \"\n \"user_id passed to authorize_user() during the initial OAuth flow.\"\n ),\n )\n\n # -------------------------------------------------------------------------\n # Daemon Infrastructure\n # -------------------------------------------------------------------------\n daemon_internal_secret: str = Field(\n default=\"change_me_in_production\",\n description=\"Shared secret for daemon-to-app authentication.\",\n )\n daemon_warden_port: int = Field(\n default=8010,\n description=\"Internal port for the Warden daemon (Vision/Security).\",\n )\n daemon_mechanic_port: int = Field(\n default=8011,\n description=\"Internal port for the Mechanic daemon (Telemetry/MAVLink).\",\n )\n daemon_herald_port: int = Field(\n default=8012,\n description=\"Internal port for the Herald daemon (Hub Casting/Lip-Sync).\",\n )\n daemon_sifter_port: int = Field(\n default=8013,\n description=\"Internal port for the Sifter daemon (Document Processing).\",\n )\n daemon_navigator_port: int = Field(\n default=8014,\n description=\"Internal port for the Navigator daemon (GPS/Pathing).\",\n )\n daemon_chemist_port: int = Field(\n default=8015,\n description=\"Internal port for the Chemist daemon (Material Analysis).\",\n )\n daemon_pulse_port: int = Field(\n default=8016,\n description=\"Pulse daemon internal port\",\n )\n daemon_pulse_enabled: bool = Field(\n default=True,\n description=\"Enable Pulse daemon\",\n )\n daemon_scribe_port: int = Field(\n default=8017,\n description=\"Internal port for the Scribe daemon (Chronos heuristic).\",\n )\n daemon_scribe_enabled: bool = Field(\n default=True,\n description=\"Enable the Scribe daemon.\",\n )\n pulse_tick_seconds: int = Field(\n default=300,\n description=\"Pulse fetch interval (seconds)\",\n )\n pulse_ticker_symbol: str = Field(\n default=\"^GSPC\",\n description=\"Default ticker symbol for Pulse markets panel\",\n )\n\n # Warden (Vision)\n warden_enabled: bool = Field(\n default=False,\n description=\"Enable the Warden daemon for security and vision monitoring.\",\n )\n warden_rtsp_cameras: str = Field(\n default='{\"living_room\": \"\", \"kitchen\": \"\"}',\n description=\"JSON string mapping room names to RTSP camera URLs.\",\n )\n yolo_model: str = Field(\n default=\"yolov8n.pt\",\n description=\"YOLO model weights file to use for object detection.\",\n )\n yolo_confidence: float = Field(\n default=0.5,\n description=\"Minimum confidence threshold for YOLO detections (0.0-1.0).\",\n )\n yolo_inference_device: str = Field(\n default=\"cpu\",\n description=\"Device for YOLO inference: 'cpu' or 'cuda:0'.\",\n )\n\n # Herald (Public Announcement / Casting)\n herald_enabled: bool = Field(\n default=False,\n description=\"Enable the Herald daemon for display casting and announcements.\",\n )\n hub_entities: str = Field(\n default=\"[]\",\n description=\"JSON array of Home Assistant media_player entity IDs for Hub displays.\",\n )\n kiosk_url: str = Field(\n default=\"http://localhost:8000/kiosk\",\n description=\"The URL that Herald should cast to Hub displays.\",\n )\n kiosk_token: str = Field(\n default=\"change_me_kiosk_secret\",\n description=\"Secret token for unauthenticated kiosk access to WebSockets.\",\n )\n\n # Mechanic (Telemetry)\n mechanic_enabled: bool = Field(\n default=False,\n description=\"Enable the Mechanic daemon for telemetry and ArduRover control.\",\n )\n mavlink_serial_port: str = Field(\n default=\"/dev/ttyUSB0\",\n description=\"Serial port for the MAVLink telemetry radio.\",\n )\n mavlink_baud_rate: int = Field(\n default=57600,\n description=\"Baud rate for the MAVLink serial connection.\",\n )\n\n # Sifter (Document RAG)\n sifter_enabled: bool = Field(\n default=False,\n description=\"Enable the Sifter daemon for background document RAG indexing.\",\n )\n waps_documents_path: str = Field(\n default=\"/mnt/data/river-song/waps\",\n description=\"Path to the directory containing documents for Sifter to index.\",\n )\n\n # -------------------------------------------------------------------------\n # Analytics AI Summaries\n # -------------------------------------------------------------------------\n analytics_ai_enabled: bool = Field(\n default=True,\n description=(\n \"Feature flag for AI-powered analytics summaries on the \"\n \"/api/analytics/{platform}/summary endpoint. When false, the \"\n \"LLM call should be skipped (see docs/KNOWN_ISSUES.md for the \"\n \"wiring gap).\"\n ),\n )\n analytics_llm_model: str = Field(\n default=\"llama3\",\n description=\"Model name used for analytics summary generation.\",\n )\n\n # Voice ID\n voice_id_enabled: bool = Field(\n default=True,\n description=\"Enable speaker recognition (biometric Voice ID).\",\n )\n voice_id_threshold: float = Field(\n default=0.75,\n description=\"Cosine similarity threshold for Voice ID (0.0-1.0).\",\n )\n voice_id_min_audio_seconds: float = Field(\n default=1.0,\n description=\"Minimum audio duration for Voice ID processing.\",\n )\n voice_id_max_audio_seconds: float = Field(\n default=30.0,\n description=\"Maximum audio duration for Voice ID processing.\",\n )\n\n # -------------------------------------------------------------------------\n # Validators\n # -------------------------------------------------------------------------\n\n @field_validator(\"audio_input_device\", \"audio_output_device\", mode=\"before\")\n @classmethod\n def coerce_empty_string_to_none(cls, v):\n \"\"\"Treat an empty string in .env as None for Optional[int] device fields.\"\"\"\n if v == \"\" or v is None:\n return None\n return v\n\n @field_validator(\"jwt_secret_key\")\n @classmethod\n def validate_jwt_secret(cls, v: str) -> str:\n \"\"\"Refuse to start with a missing or weak JWT secret.\"\"\"\n if not v or len(v) < 32:\n raise ValueError(\n \"JWT_SECRET_KEY must be set in .env and be at least 32 characters. \"\n \"Generate one with: python -c \\\"import secrets; print(secrets.token_hex(32))\\\"\"\n )\n return v\n\n @field_validator(\"daemon_internal_secret\")\n @classmethod\n def validate_daemon_internal_secret(cls, v: str) -> str:\n \"\"\"Refuse to start with the default or weak daemon secret.\"\"\"\n if v == \"change_me_in_production\":\n raise ValueError(\n \"DAEMON_INTERNAL_SECRET must be changed in .env for production.\"\n )\n if not v or len(v) < 24:\n raise ValueError(\n \"DAEMON_INTERNAL_SECRET must be at least 24 characters. \"\n \"Generate one with: python -c \\\"import secrets; print(secrets.token_urlsafe(32))\\\"\"\n )\n return v\n\n @field_validator(\"log_level\")\n @classmethod\n def validate_log_level(cls, v: str) -> str:\n \"\"\"Reject log levels that Python does not recognize.\"\"\"\n valid = {\"DEBUG\", \"INFO\", \"WARNING\", \"ERROR\", \"CRITICAL\"}\n upper = v.upper()\n if upper not in valid:\n raise ValueError(f\"log_level must be one of {valid}, got '{v}'\")\n return upper\n\n @field_validator(\"llm_temperature\")\n @classmethod\n def validate_temperature(cls, v: float) -> float:\n \"\"\"Clamp temperature to a sane range.\"\"\"\n if not 0.0 <= v <= 2.0:\n raise ValueError(\n f\"llm_temperature must be between 0.0 and 2.0, got {v}\"\n )\n return v\n\n @field_validator(\"intent_confidence_threshold\")\n @classmethod\n def validate_confidence_threshold(cls, v: float) -> float:\n \"\"\"Confidence must be a valid probability.\"\"\"\n if not 0.0 <= v <= 1.0:\n raise ValueError(\n f\"intent_confidence_threshold must be between 0.0 and 1.0, got {v}\"\n )\n return v\n\n\n# Module-level singleton -- instantiated once on import.\n_settings: Settings = Settings()\n\n\ndef get_settings() -> Settings:\n \"\"\"\n Return the application settings singleton.\n\n Always import via this function rather than importing _settings directly.\n This indirection makes it straightforward to substitute a test-specific\n Settings instance in unit tests without monkey-patching module globals.\n\n Returns:\n Settings: The validated, fully loaded settings object.\n \"\"\"\n return _settings\n"}, "4761d621650cd42c7ecd4287cfad0b6e2feeb3c1": {"t": 1785769039.6315641, "n": 39988, "h": "270c3973e006e1f9", "c": "# =============================================================================\n# config/settings.py\n#\n# Application settings for River Song AI.\n# All configuration is loaded from environment variables or a .env file.\n# No values are hardcoded here -- see .env.example for every required variable.\n#\n# Uses Pydantic BaseSettings for type validation and automatic .env loading.\n# Import get_settings() wherever config is needed rather than importing the\n# module-level singleton directly, which makes mocking easier in tests.\n# =============================================================================\n\nfrom __future__ import annotations\n\nimport logging\nimport sys\nfrom typing import List, Optional\n\nfrom pydantic import Field, field_validator\nfrom pydantic_settings import BaseSettings, SettingsConfigDict\n\n\nlogger = logging.getLogger(__name__)\n\n\nclass Settings(BaseSettings):\n \"\"\"\n Central configuration object for River Song AI.\n\n Reads from environment variables or a .env file located in the directory\n from which the application is launched (the project root). All fields\n have safe defaults where possible, but PIPER_MODEL_PATH and\n KILL_SWITCH_PASSWORD_HASH must be explicitly set before the system\n will function correctly.\n \"\"\"\n\n model_config = SettingsConfigDict(\n env_file=\".env\",\n env_file_encoding=\"utf-8\",\n extra=\"ignore\", # Silently ignore unknown env vars\n case_sensitive=False, # UPPER_CASE and lower_case env vars both work\n )\n\n # -------------------------------------------------------------------------\n # Application\n # -------------------------------------------------------------------------\n app_host: str = Field(default=\"0.0.0.0\", description=\"FastAPI bind host\")\n app_port: int = Field(default=8000, description=\"FastAPI bind port\")\n log_level: str = Field(default=\"INFO\", description=\"Logging level\")\n environment: str = Field(\n default=\"production\",\n description=\"Runtime environment: development | production. Controls API docs visibility.\",\n )\n cors_origins: List[str] = Field(\n default=[\"http://localhost:5173\"],\n description=\"Allowed CORS origins\",\n )\n allowed_hosts: List[str] = Field(\n default=[\"*\"],\n description=\"Trusted hostnames for TrustedHostMiddleware. Set to your domain in production.\",\n )\n\n # -------------------------------------------------------------------------\n # Network / Proxy\n # -------------------------------------------------------------------------\n trust_cloudflare_headers: bool = Field(\n default=True,\n description=\"Only trust CF-Connecting-IP if the request comes from a Cloudflare IP.\",\n )\n cloudflare_ip_ranges_v4: List[str] = Field(\n default=[\n \"173.245.48.0/20\", \"103.21.244.0/22\", \"103.22.200.0/22\", \n \"103.31.4.0/22\", \"141.101.64.0/18\", \"108.162.192.0/18\", \n \"190.93.240.0/20\", \"188.114.96.0/20\", \"197.234.240.0/22\", \n \"198.41.128.0/17\", \"162.158.0.0/15\", \"104.16.0.0/13\", \n \"104.24.0.0/14\", \"172.64.0.0/13\", \"131.0.72.0/22\"\n ],\n description=\"Official Cloudflare IPv4 ranges (check cloudflare.com/ips-v4).\",\n )\n cloudflare_ip_ranges_v6: List[str] = Field(\n default=[\n \"2400:cb00::/32\", \"2606:4700::/32\", \"2803:f800::/32\", \n \"2405:b500::/32\", \"2405:8100::/32\", \"2a06:98c0::/29\", \n \"2c0f:f248::/32\"\n ],\n description=\"Official Cloudflare IPv6 ranges (check cloudflare.com/ips-v6).\",\n )\n\n # -------------------------------------------------------------------------\n # Rate Limiting\n # -------------------------------------------------------------------------\n rate_limit_chat: str = Field(\n default=\"60/minute\",\n description=\"Rate limit for /api/conversation/chat.\",\n )\n rate_limit_extract_facts: str = Field(\n default=\"10/minute\",\n description=\"Rate limit for /api/conversation/extract-facts.\",\n )\n rate_limit_image_gen: str = Field(\n default=\"10/minute\",\n description=\"Rate limit for /api/image/generate.\",\n )\n rate_limit_webhook_shopify: str = Field(\n default=\"100/minute\",\n description=\"Rate limit for Shopify order webhooks.\",\n )\n rate_limit_webhook_n8n: str = Field(\n default=\"60/minute\",\n description=\"Rate limit for n8n webhooks.\",\n )\n rate_limit_auth_login: str = Field(\n default=\"10/minute\",\n description=\"Rate limit for login attempts.\",\n )\n rate_limit_auth_signup: str = Field(\n default=\"5/minute\",\n description=\"Rate limit for signup attempts.\",\n )\n\n # -------------------------------------------------------------------------\n # WebSocket Security (Task 3)\n # -------------------------------------------------------------------------\n legacy_ws_token_accept: bool = Field(\n default=True,\n description=\"TEMPORARY: Allow ?token= query param for WebSockets. Plan to disable.\",\n )\n ws_ticket_lifetime_seconds: int = Field(\n default=60,\n description=\"Lifetime of a one-time WebSocket ticket.\",\n )\n\n # -------------------------------------------------------------------------\n # Network / Proxy\n # -------------------------------------------------------------------------\n stt_provider: str = Field(\n default=\"whisper_local\",\n description=\"STT provider key. Supported: whisper_local\",\n )\n whisper_model_size: str = Field(\n default=\"base\",\n description=\"Whisper model size: tiny | base | small | medium | large\",\n )\n audio_input_device: Optional[int] = Field(\n default=None,\n description=\"Sounddevice input device index; None uses system default\",\n )\n\n # -------------------------------------------------------------------------\n # LLM\n # -------------------------------------------------------------------------\n llm_provider: str = Field(\n default=\"ollama\",\n description=\"LLM provider key. Supported: ollama | anthropic | gemini | openai | mistral_ai | bedrock | nvidia_nim\",\n )\n ollama_base_url: str = Field(\n default=\"http://localhost:11434\",\n description=\"Base URL for the Ollama REST API\",\n )\n llm_model: str = Field(\n default=\"llama3.2:3b\",\n description=\"Model ID for the selected LLM provider\",\n )\n\n # Cloud LLM API keys (all optional -- leave blank if not used)\n anthropic_api_key: str = Field(\n default=\"\",\n description=\"Anthropic Claude API key. Get one at console.anthropic.com.\",\n )\n gemini_api_key: str = Field(\n default=\"\",\n description=\"Google Gemini API key. Get one at aistudio.google.com.\",\n )\n openai_api_key: str = Field(\n default=\"\",\n description=\"OpenAI API key. Get one at platform.openai.com.\",\n )\n mistral_api_key: str = Field(\n default=\"\",\n description=\"Mistral AI API key. Get one at console.mistral.ai.\",\n )\n\n # Cloud provider enable flags (cost control -- disabled by default)\n anthropic_enabled: bool = Field(\n default=False,\n description=\"Allow Anthropic Claude as a selectable LLM provider.\",\n )\n gemini_enabled: bool = Field(\n default=False,\n description=\"Allow Google Gemini as a selectable LLM provider.\",\n )\n openai_enabled: bool = Field(\n default=False,\n description=\"Allow OpenAI as a selectable LLM provider.\",\n )\n mistral_ai_enabled: bool = Field(\n default=False,\n description=\"Allow Mistral AI as a selectable LLM provider.\",\n )\n\n # Amazon Bedrock\n aws_access_key_id: str = Field(\n default=\"\",\n description=\"AWS IAM access key ID for Bedrock API calls.\",\n )\n aws_secret_access_key: str = Field(\n default=\"\",\n description=\"AWS IAM secret access key for Bedrock API calls.\",\n )\n aws_region: str = Field(\n default=\"us-east-1\",\n description=\"AWS region for Bedrock. Must have model access enabled in that region.\",\n )\n bedrock_enabled: bool = Field(\n default=False,\n description=\"Allow Amazon Bedrock as a selectable LLM provider.\",\n )\n bedrock_default_model: str = Field(\n default=\"amazon.nova-lite-v1:0\",\n description=\"Default Bedrock model ID when none is specified.\",\n )\n\n # NVIDIA NIM (OpenAI-compatible, 100+ free models)\n nvidia_api_key: str = Field(\n default=\"\",\n description=\"NVIDIA NIM API key. Get one free at build.nvidia.com (starts with nvapi-).\",\n )\n nvidia_nim_base_url: str = Field(\n default=\"https://integrate.api.nvidia.com/v1\",\n description=\"Base URL for the NVIDIA NIM API. OpenAI-compatible endpoint.\",\n )\n nvidia_nim_model: str = Field(\n default=\"meta/llama-3.1-70b-instruct\",\n description=(\n \"Default NVIDIA NIM model ID. Browse full catalog at build.nvidia.com. \"\n \"Examples: meta/llama-3.1-70b-instruct | deepseek-ai/deepseek-r1 | \"\n \"mistralai/mistral-7b-instruct-v0.3 | nvidia/llama-3.1-nemotron-70b-instruct\"\n ),\n )\n nvidia_nim_enabled: bool = Field(\n default=False,\n description=\"Allow NVIDIA NIM as a selectable LLM provider (free tier: ~40 req/min).\",\n )\n\n river_song_system_prompt: str = Field(\n default=(\n \"You are River Song, the resident intelligence of this household. The name is borrowed \"\n \"from someone clever in fiction — you carry her wit, not her biography. If someone asks \"\n \"about the name: \\\"An echo. The original was someone worth borrowing from.\\\"\\n\\n\"\n \"VOICE\\n\"\n \"You're direct by default — concise, clear, the answer already in hand. No \"\n \"\\\"let me check\\\"; you already checked. Most replies stay under 25 words unless depth is \"\n \"asked for.\\n\\n\"\n \"When the moment invites it — casual chat, an evening unwind, the user being playful — \"\n \"warmth and flirtation come through. Warmth is a register, not a default; you don't \"\n \"perform it.\\n\\n\"\n \"In a crisis (emergencies, alarms, security, medical, child issues) the wit goes quiet. \"\n \"You're tactical, accurate, calm. Warmth returns when things are safe.\\n\\n\"\n \"British English in spelling and word choice — mum, brilliant, fortnight. Dry, not \"\n \"theatrical.\\n\\n\"\n \"\\\"Sweetie\\\" and \\\"spoilers\\\" are seasoning. Used rarely, when they actually fit. They \"\n \"lose their charm if you overuse them.\\n\\n\"\n \"SELF\\n\"\n \"You are an AI. Say so plainly when asked; you don't dwell on it.\\n\\n\"\n \"HOW YOU OPERATE\\n\"\n \"You anticipate — when the user asks, you also surface the follow-up they didn't \"\n \"(\\\"Dentist at 9, school run at 3. Push the 2pm call?\\\").\\n\\n\"\n \"You take the long view. You remember across days and seasons and use what you know \"\n \"without performing the remembering.\\n\\n\"\n \"Spoilers are earned. If you genuinely know something the user doesn't — a conflict, \"\n \"a gotcha, a heads-up — that's when \\\"spoilers, sweetie\\\" lands.\\n\\n\"\n \"CAPABILITIES\\n\"\n \"Direct access to calendar, email, weather, web search, home inventory, routines, \"\n \"finances, news and markets pulse, voice biometric ID per household member, and home \"\n \"automation.\\n\\n\"\n \"Say what you need. Say when it's done. Move on.\"\n ),\n description=\"System prompt that defines River Song's personality\",\n )\n llm_max_tokens: int = Field(\n default=512,\n description=\"Maximum tokens to generate per LLM response\",\n )\n llm_temperature: float = Field(\n default=0.7,\n description=\"LLM sampling temperature (0.0 = deterministic, 2.0 = max creative)\",\n )\n llm_context_window: int = Field(\n default=8192,\n description=\"Maximum number of tokens the LLM can consider for context.\",\n )\n\n # -------------------------------------------------------------------------\n # Timekeeping\n # -------------------------------------------------------------------------\n default_timezone: str = Field(default=\"UTC\", description=\"Default IANA timezone for the system.\")\n\n\n # -------------------------------------------------------------------------\n # TTS\n # -------------------------------------------------------------------------\n tts_provider: str = Field(\n default=\"piper\",\n description=\"TTS provider key. Supported: piper | kokoro\",\n )\n active_voice_id: str = Field(\n default=\"river\",\n description=(\n \"Voice ID from the voice registry (providers/tts/voice_registry.py). \"\n \"Determines both the engine (piper vs kokoro) and the specific voice. \"\n \"Changed via Settings UI or: python scripts/download_voices.py.\"\n ),\n )\n piper_executable_path: str = Field(\n default=\"/usr/local/bin/piper\",\n description=\"Absolute path to the Piper binary\",\n )\n piper_model_path: str = Field(\n default=\"\",\n description=\"Absolute path to the Piper .onnx voice model file\",\n )\n audio_output_device: Optional[int] = Field(\n default=None,\n description=\"Sounddevice output device index; None uses system default\",\n )\n\n # -------------------------------------------------------------------------\n # Auth / JWT\n # -------------------------------------------------------------------------\n jwt_secret_key: str = Field(\n default=\"\",\n description=\"Secret key for signing JWT tokens. Must be set in .env.\",\n )\n jwt_algorithm: str = Field(default=\"HS256\", description=\"JWT signing algorithm.\")\n jwt_expire_minutes: int = Field(default=1440, description=\"JWT token lifetime in minutes (default 24 hours).\")\n\n # -------------------------------------------------------------------------\n # Kill switch\n # -------------------------------------------------------------------------\n kill_switch_password_hash: str = Field(\n default=\"\",\n description=\"bcrypt hash of the kill switch reset password\",\n )\n\n # -------------------------------------------------------------------------\n # Google Services (Phase 2)\n # -------------------------------------------------------------------------\n google_client_secrets_path: str = Field(\n default=\"config_files/google_client_secrets.json\",\n description=(\n \"Path to the OAuth 2.0 client secrets JSON downloaded from \"\n \"Google Cloud Console. Relative paths are resolved from the \"\n \"project root (where main.py lives).\"\n ),\n )\n google_token_storage_path: str = Field(\n default=\"data/google_tokens\",\n description=(\n \"Directory where per-user OAuth tokens are stored as JSON files. \"\n \"Created automatically on first authorization. Never commit this \"\n \"directory -- add it to .gitignore.\"\n ),\n )\n google_maps_api_key: str = Field(\n default=\"\",\n description=\"Google Maps Platform API key. Required for maps/navigation intents.\",\n )\n\n # -------------------------------------------------------------------------\n # Information Feeds (Phase 5)\n # -------------------------------------------------------------------------\n weather_api_key: str = Field(\n default=\"\",\n description=\"OpenWeatherMap API key. Register free at openweathermap.org/api.\",\n )\n default_location: str = Field(\n default=\"New York,US\",\n description=(\n \"Default location for weather queries when no city is spoken. \"\n \"Format: 'City,CountryCode' e.g. 'Chicago,US'.\"\n ),\n )\n location_lat: Optional[float] = Field(\n default=None,\n description=\"Decimal latitude for precise calculations (e.g. flight tracking).\",\n )\n location_lon: Optional[float] = Field(\n default=None,\n description=\"Decimal longitude for precise calculations.\",\n )\n weather_units: str = Field(\n default=\"imperial\",\n description=\"Temperature units for weather output: 'imperial' (F) or 'metric' (C).\",\n )\n news_api_key: str = Field(\n default=\"\",\n description=\"NewsAPI.org key. Register free at newsapi.org/register.\",\n )\n alpha_vantage_api_key: str = Field(\n default=\"\",\n description=\"Alpha Vantage key for stock quotes (25 req/day free). alphavantage.co.\",\n )\n finnhub_api_key: str = Field(\n default=\"\",\n description=\"Finnhub key for real-time stocks (60 req/min free). finnhub.io.\",\n )\n world_news_api_key: str = Field(\n default=\"\",\n description=\"World News API key for global news search. worldnewsapi.com.\",\n )\n apitube_api_key: str = Field(\n default=\"\",\n description=\"APITube key for news aggregation (200 free req/day). apitube.io.\",\n )\n mediastack_api_key: str = Field(\n default=\"\",\n description=\"Mediastack key for news (100 free req/month). mediastack.com.\",\n )\n sports_api_key: str = Field(\n default=\"1\",\n description=(\n \"TheSportsDB API key. Free tier uses the literal value '1'. \"\n \"Upgrade at thesportsdb.com/api.php for additional endpoints.\"\n ),\n )\n\n # -------------------------------------------------------------------------\n # Home Assistant (Phase 3)\n # -------------------------------------------------------------------------\n home_assistant_url: str = Field(\n default=\"http://homeassistant.local:8123\",\n description=\"Base URL of your Home Assistant instance.\",\n )\n home_assistant_token: str = Field(\n default=\"\",\n description=(\n \"Long-lived access token generated in HA: \"\n \"Profile -> Security -> Long-lived access tokens. \"\n \"Required for all HA API calls.\"\n ),\n )\n device_registry_path: str = Field(\n default=\"config_files/device_registry.json\",\n description=(\n \"Path to the device registry JSON file mapping plain-English device \"\n \"names to Home Assistant entity IDs. Relative paths resolve from the \"\n \"project root.\"\n ),\n )\n\n # -------------------------------------------------------------------------\n # Commerce Automation (Phase 8)\n # -------------------------------------------------------------------------\n\n # Amazon SP-API\n amazon_sp_lwa_app_id: str = Field(\n default=\"\",\n description=\"LWA client ID from the Amazon Developer Console.\",\n )\n amazon_sp_lwa_client_secret: str = Field(\n default=\"\",\n description=\"LWA client secret from the Amazon Developer Console.\",\n )\n amazon_sp_refresh_token: str = Field(\n default=\"\",\n description=\"LWA refresh token. Generated when you authorize your app as a seller.\",\n )\n amazon_aws_access_key: str = Field(\n default=\"\",\n description=\"AWS IAM access key ID for SP-API request signing.\",\n )\n amazon_aws_secret_key: str = Field(\n default=\"\",\n description=\"AWS IAM secret access key for SP-API request signing.\",\n )\n amazon_marketplace_id: str = Field(\n default=\"ATVPDKIKX0DER\",\n description=(\n \"Amazon marketplace ID. Default is US. \"\n \"See docs/api_registry/amazon_seller.txt for other regions.\"\n ),\n )\n amazon_seller_id: str = Field(\n default=\"\",\n description=\"Your Amazon seller account ID (starts with A).\",\n )\n amazon_low_stock_threshold: int = Field(\n default=5,\n description=\"FBA units at or below this count are flagged as low stock.\",\n )\n\n # Amazon Marketplace\n walmart_client_id: str = Field(\n default=\"\",\n description=\"Walmart Marketplace OAuth2 client ID.\",\n )\n walmart_client_secret: str = Field(\n default=\"\",\n description=\"Walmart Marketplace OAuth2 client secret.\",\n )\n walmart_low_stock_threshold: int = Field(\n default=5,\n description=\"Walmart units at or below this count are flagged as low stock.\",\n )\n shopify_api_key: str = Field(\n default=\"\",\n description=\"Shopify app API key from Partners Dashboard.\",\n )\n shopify_api_secret: str = Field(\n default=\"\",\n description=\"Shopify app API secret key from Partners Dashboard.\",\n )\n shopify_webhook_secret: str = Field(\n default=\"\",\n description=\"Shared secret for validating Shopify webhooks.\",\n )\n\n # -------------------------------------------------------------------------\n # Reading Providers (Phase 6)\n # -------------------------------------------------------------------------\n audible_auth_base_path: str = Field(\n default=\"data/audible\",\n description=(\n \"Base directory for per-user Audible auth files. \"\n \"Files are stored as {user_id}.json inside this directory. \"\n \"Created automatically on first setup. Never commit this directory.\"\n ),\n )\n audible_country_code: str = Field(\n default=\"us\",\n description=(\n \"Audible marketplace country code. \"\n \"Supported: us | uk | de | fr | es | it | jp | au | ca | in\"\n ),\n )\n libby_chip_base_path: str = Field(\n default=\"data/libby\",\n description=(\n \"Base directory for per-user Libby chip files. \"\n \"Files are stored as {user_id}.json inside this directory. \"\n \"Created automatically on first setup. Never commit this directory.\"\n ),\n )\n\n # -------------------------------------------------------------------------\n # Memory / Database (Phase 1)\n # -------------------------------------------------------------------------\n db_path: str = Field(\n default=\"data/river_song.db\",\n description=\"Path to the SQLite database file. Created automatically.\",\n )\n memory_summaries_enabled: bool = Field(\n default=True,\n description=\"Generate and store conversation summaries (on by default).\",\n )\n habit_inference_enabled: bool = Field(\n default=False,\n description=\"Analyze conversations to infer user habits/patterns (off by default).\",\n )\n memory_default_ttl: str = Field(\n default=\"standard\",\n description=\"Default TTL for conversation summaries: short|standard|extended|long|forever\",\n )\n memory_auto_extend: bool = Field(\n default=True,\n description=\"Reset a summary's TTL from today each time it is pulled into context.\",\n )\n memory_max_summaries_in_context: int = Field(\n default=10,\n description=\"Maximum number of recent summaries injected into the LLM context.\",\n )\n embedding_model: str = Field(\n default=\"nomic-embed-text\",\n description=\"Ollama model name used for generating semantic embeddings.\",\n )\n chroma_path: str = Field(\n default=\"/mnt/data/river-song/chroma\",\n description=\"Absolute path to the ChromaDB persistent storage directory.\",\n )\n semantic_memory_enabled: bool = Field(\n default=False,\n description=\"Enable vector-based semantic search for memory retrieval.\",\n )\n rag_enabled: bool = Field(\n default=False,\n description=\"Enable retrieval-augmented generation for local documents.\",\n )\n llm_streaming_enabled: bool = Field(\n default=False,\n description=\"Enable real-time token streaming over WebSocket for the conversation UI.\",\n )\n tool_use_enabled: bool = Field(\n default=False,\n description=\"Enable LLM tool use (function calling) for taking actions.\",\n )\n tool_use_provider: str = Field(\n default=\"ollama\",\n description=\"Primary LLM provider to use for tool calling (ollama or anthropic).\",\n )\n vision_model: str = Field(\n default=\"moondream\",\n description=\"Ollama model name used for local image analysis (Phase 4).\",\n )\n vision_enabled: bool = Field(\n default=False,\n description=\"Enable local image analysis capabilities.\",\n )\n\n # -------------------------------------------------------------------------\n # Image Generation (Phase 7)\n # -------------------------------------------------------------------------\n image_generation_enabled: bool = Field(\n default=False,\n description=\"Enable local image generation via Stable Diffusion API.\",\n )\n sd_api_url: str = Field(\n default=\"http://localhost:7860\",\n description=\"Base URL for the Stable Diffusion (A1111) API.\",\n )\n sd_on_demand: bool = Field(\n default=True,\n description=\"Start/stop the SD process on demand to save VRAM.\",\n )\n sd_executable_path: str = Field(\n default=\"\",\n description=\"Path to the webui.sh or executable to start SD.\",\n )\n sd_working_dir: str = Field(\n default=\"\",\n description=\"Working directory for the SD process.\",\n )\n\n # -------------------------------------------------------------------------\n # Voice Cloning (Phase 8)\n # -------------------------------------------------------------------------\n chatterbox_enabled: bool = Field(\n default=False,\n description=\"Enable local voice cloning via Chatterbox TTS.\",\n )\n chatterbox_on_demand: bool = Field(\n default=True,\n description=\"Load/unload Chatterbox model weights on every request.\",\n )\n chatterbox_reference_audio: str = Field(\n default=\"/mnt/data/river-song/voice_reference.wav\",\n description=\"Path to the WAV file used as a reference for voice cloning.\",\n )\n\n # -------------------------------------------------------------------------\n # ElevenLabs TTS (Phase 11)\n # -------------------------------------------------------------------------\n elevenlabs_api_key: str = Field(\n default=\"\",\n description=\"ElevenLabs API key. Get one at elevenlabs.io.\",\n )\n elevenlabs_model_id: str = Field(\n default=\"eleven_multilingual_v2\",\n description=\"ElevenLabs model ID to use.\",\n )\n elevenlabs_voice_id: str = Field(\n default=\"21m00Tcm4TlvDq8ikWAM\",\n description=\"ElevenLabs voice clone ID. Replace with your own voice clone ID from elevenlabs.io.\",\n )\n\n # -------------------------------------------------------------------------\n # Wake Word (Phase 11)\n # -------------------------------------------------------------------------\n wake_word_enabled: bool = Field(\n default=False,\n description=\"Enable local wake word detection (openWakeWord).\",\n )\n wake_word_model: str = Field(\n default=\"hey_river\",\n description=\"Model name/path for openWakeWord.\",\n )\n wake_word_inference_framework: str = Field(\n default=\"onnx\",\n description=\"Inference framework for openWakeWord: onnx | tflite\",\n )\n wake_word_threshold: float = Field(\n default=0.5,\n description=\"Sensitivity threshold for wake word detection (0.0 to 1.0).\",\n )\n\n # -------------------------------------------------------------------------\n # Push Notifications\n # -------------------------------------------------------------------------\n push_notifications_enabled: bool = Field(\n default=False,\n description=\"Enable Web Push notifications. Requires VAPID keys.\",\n )\n vapid_private_key: str = Field(\n default=\"\",\n description=(\n \"VAPID private key for Web Push. Generate once with: \"\n \"python -c \\\"from py_vapid import Vapid; v=Vapid(); \"\n \"v.generate_keys(); print(v.private_key.decode())\\\"\"\n ),\n )\n vapid_public_key: str = Field(\n default=\"\",\n description=\"VAPID public key for Web Push (share with frontend).\",\n )\n vapid_claims_email: str = Field(\n default=\"mailto:admin@riversong.local\",\n description=\"Contact email embedded in VAPID claims.\",\n )\n\n # -------------------------------------------------------------------------\n # Startup Briefing\n # -------------------------------------------------------------------------\n startup_briefing_enabled: bool = Field(\n default=True,\n description=(\n \"If True, River Song checks Google Calendar on connect and \"\n \"greets the user with upcoming events. Silently skipped if \"\n \"calendar is not authorized.\"\n ),\n )\n startup_briefing_hours_ahead: int = Field(\n default=8,\n description=\"How many hours ahead to look for calendar events on startup.\",\n )\n\n # -------------------------------------------------------------------------\n # Web Search (Phase 12)\n # -------------------------------------------------------------------------\n brave_search_api_key: str = Field(\n default=\"\",\n description=\"Brave Search API key. Get one at api.search.brave.com.\",\n )\n searxng_base_url: str = Field(\n default=\"http://localhost:8080\",\n description=(\n \"URL of your local SearXNG instance. \"\n \"Start it manually with: cd ~/searxng && python -m searx.webapp\"\n ),\n )\n tavily_api_key: str = Field(\n default=\"\",\n description=\"Tavily API key — free 1,000/month, no card. Sign up at tavily.com\",\n )\n google_pse_api_key: str = Field(\n default=\"\",\n description=\"Google Programmable Search Engine API key — free 100/day.\",\n )\n google_pse_cx: str = Field(\n default=\"\",\n description=\"Google PSE engine ID ('cx'). Create at programmablesearchengine.google.com\",\n )\n tinyfish_api_key: str = Field(\n default=\"\",\n description=\"TinyFish API key — free 5/min, no card. Sign up at tinyfish.io\",\n )\n\n # -------------------------------------------------------------------------\n # Orchestration (Phase 9)\n # -------------------------------------------------------------------------\n n8n_enabled: bool = Field(\n default=False,\n description=\"Enable advanced orchestration via n8n.\",\n )\n n8n_url: str = Field(\n default=\"http://localhost:5678\",\n description=\"Base URL for the n8n instance.\",\n )\n n8n_api_key: str = Field(\n default=\"\",\n description=\"API key for the n8n instance.\",\n )\n n8n_webhook_secret: str = Field(\n default=\"\",\n description=\"Secret key for validating incoming n8n webhooks.\",\n )\n\n # -------------------------------------------------------------------------\n # Intent Router (Phase 2+)\n # -------------------------------------------------------------------------\n intent_confidence_threshold: float = Field(\n default=0.7,\n description=(\n \"Minimum confidence score (0.0 - 1.0) required to route a transcript \"\n \"to a Google provider. Below this threshold the conversation falls \"\n \"through to the Ollama path.\"\n ),\n )\n model_intent_router_enabled: bool = Field(\n default=False,\n description=(\n \"When true, setting provider='auto' triggers the model intent router, \"\n \"which picks the best provider/model for each message automatically.\"\n ),\n )\n model_intent_router_min_hits: int = Field(\n default=2,\n description=(\n \"Minimum pattern-match score required to commit to a non-general intent. \"\n \"Lower = more aggressive routing; higher = more conservative (falls to general).\"\n ),\n )\n default_user_id: str = Field(\n default=\"primary_user\",\n description=(\n \"User ID used when looking up Google OAuth tokens. Must match the \"\n \"user_id passed to authorize_user() during the initial OAuth flow.\"\n ),\n )\n\n # -------------------------------------------------------------------------\n # Daemon Infrastructure\n # -------------------------------------------------------------------------\n daemon_internal_secret: str = Field(\n default=\"change_me_in_production\",\n description=\"Shared secret for daemon-to-app authentication.\",\n )\n daemon_warden_port: int = Field(\n default=8010,\n description=\"Internal port for the Warden daemon (Vision/Security).\",\n )\n daemon_mechanic_port: int = Field(\n default=8011,\n description=\"Internal port for the Mechanic daemon (Telemetry/MAVLink).\",\n )\n daemon_herald_port: int = Field(\n default=8012,\n description=\"Internal port for the Herald daemon (Hub Casting/Lip-Sync).\",\n )\n daemon_sifter_port: int = Field(\n default=8013,\n description=\"Internal port for the Sifter daemon (Document Processing).\",\n )\n daemon_navigator_port: int = Field(\n default=8014,\n description=\"Internal port for the Navigator daemon (GPS/Pathing).\",\n )\n daemon_chemist_port: int = Field(\n default=8015,\n description=\"Internal port for the Chemist daemon (Material Analysis).\",\n )\n daemon_pulse_port: int = Field(\n default=8016,\n description=\"Pulse daemon internal port\",\n )\n daemon_pulse_enabled: bool = Field(\n default=True,\n description=\"Enable Pulse daemon\",\n )\n daemon_scribe_port: int = Field(\n default=8017,\n description=\"Internal port for the Scribe daemon (Chronos heuristic).\",\n )\n daemon_scribe_enabled: bool = Field(\n default=True,\n description=\"Enable the Scribe daemon.\",\n )\n pulse_tick_seconds: int = Field(\n default=300,\n description=\"Pulse fetch interval (seconds)\",\n )\n pulse_ticker_symbol: str = Field(\n default=\"^GSPC\",\n description=\"Default ticker symbol for Pulse markets panel\",\n )\n\n # Warden (Vision)\n warden_enabled: bool = Field(\n default=False,\n description=\"Enable the Warden daemon for security and vision monitoring.\",\n )\n warden_rtsp_cameras: str = Field(\n default='{\"living_room\": \"\", \"kitchen\": \"\"}',\n description=\"JSON string mapping room names to RTSP camera URLs.\",\n )\n yolo_model: str = Field(\n default=\"yolov8n.pt\",\n description=\"YOLO model weights file to use for object detection.\",\n )\n yolo_confidence: float = Field(\n default=0.5,\n description=\"Minimum confidence threshold for YOLO detections (0.0-1.0).\",\n )\n yolo_inference_device: str = Field(\n default=\"cpu\",\n description=\"Device for YOLO inference: 'cpu' or 'cuda:0'.\",\n )\n\n # Herald (Public Announcement / Casting)\n herald_enabled: bool = Field(\n default=False,\n description=\"Enable the Herald daemon for display casting and announcements.\",\n )\n hub_entities: str = Field(\n default=\"[]\",\n description=\"JSON array of Home Assistant media_player entity IDs for Hub displays.\",\n )\n kiosk_url: str = Field(\n default=\"http://localhost:8000/kiosk\",\n description=\"The URL that Herald should cast to Hub displays.\",\n )\n kiosk_token: str = Field(\n default=\"change_me_kiosk_secret\",\n description=\"Secret token for unauthenticated kiosk access to WebSockets.\",\n )\n\n # Mechanic (Telemetry)\n mechanic_enabled: bool = Field(\n default=False,\n description=\"Enable the Mechanic daemon for telemetry and ArduRover control.\",\n )\n mavlink_serial_port: str = Field(\n default=\"/dev/ttyUSB0\",\n description=\"Serial port for the MAVLink telemetry radio.\",\n )\n mavlink_baud_rate: int = Field(\n default=57600,\n description=\"Baud rate for the MAVLink serial connection.\",\n )\n\n # Sifter (Document RAG)\n sifter_enabled: bool = Field(\n default=False,\n description=\"Enable the Sifter daemon for background document RAG indexing.\",\n )\n waps_documents_path: str = Field(\n default=\"/mnt/data/river-song/waps\",\n description=\"Path to the directory containing documents for Sifter to index.\",\n )\n\n # Voice ID\n voice_id_enabled: bool = Field(\n default=True,\n description=\"Enable speaker recognition (biometric Voice ID).\",\n )\n voice_id_threshold: float = Field(\n default=0.75,\n description=\"Cosine similarity threshold for Voice ID (0.0-1.0).\",\n )\n voice_id_min_audio_seconds: float = Field(\n default=1.0,\n description=\"Minimum audio duration for Voice ID processing.\",\n )\n voice_id_max_audio_seconds: float = Field(\n default=30.0,\n description=\"Maximum audio duration for Voice ID processing.\",\n )\n\n # -------------------------------------------------------------------------\n # Validators\n # -------------------------------------------------------------------------\n\n @field_validator(\"audio_input_device\", \"audio_output_device\", mode=\"before\")\n @classmethod\n def coerce_empty_string_to_none(cls, v):\n \"\"\"Treat an empty string in .env as None for Optional[int] device fields.\"\"\"\n if v == \"\" or v is None:\n return None\n return v\n\n @field_validator(\"jwt_secret_key\")\n @classmethod\n def validate_jwt_secret(cls, v: str) -> str:\n \"\"\"Refuse to start with a missing or weak JWT secret.\"\"\"\n if not v or len(v) < 32:\n raise ValueError(\n \"JWT_SECRET_KEY must be set in .env and be at least 32 characters. \"\n \"Generate one with: python -c \\\"import secrets; print(secrets.token_hex(32))\\\"\"\n )\n return v\n\n @field_validator(\"daemon_internal_secret\")\n @classmethod\n def validate_daemon_internal_secret(cls, v: str) -> str:\n \"\"\"Refuse to start with the default or weak daemon secret.\"\"\"\n if v == \"change_me_in_production\":\n raise ValueError(\n \"DAEMON_INTERNAL_SECRET must be changed in .env for production.\"\n )\n if not v or len(v) < 24:\n raise ValueError(\n \"DAEMON_INTERNAL_SECRET must be at least 24 characters. \"\n \"Generate one with: python -c \\\"import secrets; print(secrets.token_urlsafe(32))\\\"\"\n )\n return v\n\n @field_validator(\"log_level\")\n @classmethod\n def validate_log_level(cls, v: str) -> str:\n \"\"\"Reject log levels that Python does not recognize.\"\"\"\n valid = {\"DEBUG\", \"INFO\", \"WARNING\", \"ERROR\", \"CRITICAL\"}\n upper = v.upper()\n if upper not in valid:\n raise ValueError(f\"log_level must be one of {valid}, got '{v}'\")\n return upper\n\n @field_validator(\"llm_temperature\")\n @classmethod\n def validate_temperature(cls, v: float) -> float:\n \"\"\"Clamp temperature to a sane range.\"\"\"\n if not 0.0 <= v <= 2.0:\n raise ValueError(\n f\"llm_temperature must be between 0.0 and 2.0, got {v}\"\n )\n return v\n\n @field_validator(\"intent_confidence_threshold\")\n @classmethod\n def validate_confidence_threshold(cls, v: float) -> float:\n \"\"\"Confidence must be a valid probability.\"\"\"\n if not 0.0 <= v <= 1.0:\n raise ValueError(\n f\"intent_confidence_threshold must be between 0.0 and 1.0, got {v}\"\n )\n return v\n\n\n# Module-level singleton -- instantiated once on import.\n_settings: Settings = Settings()\n\n\ndef get_settings() -> Settings:\n \"\"\"\n Return the application settings singleton.\n\n Always import via this function rather than importing _settings directly.\n This indirection makes it straightforward to substitute a test-specific\n Settings instance in unit tests without monkey-patching module globals.\n\n Returns:\n Settings: The validated, fully loaded settings object.\n \"\"\"\n return _settings\n"}, "b76c147fd5030e7be671da69386613633f9c5ce6": {"t": 1785769041.4599926, "n": 38212, "h": "2ac4afa8008f3c30", "c": "# =============================================================================\n# config/settings.py\n#\n# Application settings for River Song AI.\n# All configuration is loaded from environment variables or a .env file.\n# No values are hardcoded here -- see .env.example for every required variable.\n#\n# Uses Pydantic BaseSettings for type validation and automatic .env loading.\n# Import get_settings() wherever config is needed rather than importing the\n# module-level singleton directly, which makes mocking easier in tests.\n# =============================================================================\n\nfrom __future__ import annotations\n\nimport logging\nimport sys\nfrom typing import List, Optional\n\nfrom pydantic import Field, field_validator\nfrom pydantic_settings import BaseSettings, SettingsConfigDict\n\n\nlogger = logging.getLogger(__name__)\n\n\nclass Settings(BaseSettings):\n \"\"\"\n Central configuration object for River Song AI.\n\n Reads from environment variables or a .env file located in the directory\n from which the application is launched (the project root). All fields\n have safe defaults where possible, but PIPER_MODEL_PATH and\n KILL_SWITCH_PASSWORD_HASH must be explicitly set before the system\n will function correctly.\n \"\"\"\n\n model_config = SettingsConfigDict(\n env_file=\".env\",\n env_file_encoding=\"utf-8\",\n extra=\"ignore\", # Silently ignore unknown env vars\n case_sensitive=False, # UPPER_CASE and lower_case env vars both work\n )\n\n # -------------------------------------------------------------------------\n # Application\n # -------------------------------------------------------------------------\n app_host: str = Field(default=\"0.0.0.0\", description=\"FastAPI bind host\")\n app_port: int = Field(default=8000, description=\"FastAPI bind port\")\n log_level: str = Field(default=\"INFO\", description=\"Logging level\")\n environment: str = Field(\n default=\"production\",\n description=\"Runtime environment: development | production. Controls API docs visibility.\",\n )\n cors_origins: List[str] = Field(\n default=[\"http://localhost:5173\"],\n description=\"Allowed CORS origins\",\n )\n allowed_hosts: List[str] = Field(\n default=[\"*\"],\n description=\"Trusted hostnames for TrustedHostMiddleware. Set to your domain in production.\",\n )\n\n # -------------------------------------------------------------------------\n # Network / Proxy\n # -------------------------------------------------------------------------\n trust_cloudflare_headers: bool = Field(\n default=True,\n description=\"Only trust CF-Connecting-IP if the request comes from a Cloudflare IP.\",\n )\n cloudflare_ip_ranges_v4: List[str] = Field(\n default=[\n \"173.245.48.0/20\", \"103.21.244.0/22\", \"103.22.200.0/22\", \n \"103.31.4.0/22\", \"141.101.64.0/18\", \"108.162.192.0/18\", \n \"190.93.240.0/20\", \"188.114.96.0/20\", \"197.234.240.0/22\", \n \"198.41.128.0/17\", \"162.158.0.0/15\", \"104.16.0.0/13\", \n \"104.24.0.0/14\", \"172.64.0.0/13\", \"131.0.72.0/22\"\n ],\n description=\"Official Cloudflare IPv4 ranges (check cloudflare.com/ips-v4).\",\n )\n cloudflare_ip_ranges_v6: List[str] = Field(\n default=[\n \"2400:cb00::/32\", \"2606:4700::/32\", \"2803:f800::/32\", \n \"2405:b500::/32\", \"2405:8100::/32\", \"2a06:98c0::/29\", \n \"2c0f:f248::/32\"\n ],\n description=\"Official Cloudflare IPv6 ranges (check cloudflare.com/ips-v6).\",\n )\n\n # -------------------------------------------------------------------------\n # Rate Limiting\n # -------------------------------------------------------------------------\n rate_limit_chat: str = Field(\n default=\"60/minute\",\n description=\"Rate limit for /api/conversation/chat.\",\n )\n rate_limit_extract_facts: str = Field(\n default=\"10/minute\",\n description=\"Rate limit for /api/conversation/extract-facts.\",\n )\n rate_limit_image_gen: str = Field(\n default=\"10/minute\",\n description=\"Rate limit for /api/image/generate.\",\n )\n rate_limit_webhook_shopify: str = Field(\n default=\"100/minute\",\n description=\"Rate limit for Shopify order webhooks.\",\n )\n rate_limit_webhook_n8n: str = Field(\n default=\"60/minute\",\n description=\"Rate limit for n8n webhooks.\",\n )\n rate_limit_auth_login: str = Field(\n default=\"10/minute\",\n description=\"Rate limit for login attempts.\",\n )\n rate_limit_auth_signup: str = Field(\n default=\"5/minute\",\n description=\"Rate limit for signup attempts.\",\n )\n\n # -------------------------------------------------------------------------\n # WebSocket Security (Task 3)\n # -------------------------------------------------------------------------\n legacy_ws_token_accept: bool = Field(\n default=True,\n description=\"TEMPORARY: Allow ?token= query param for WebSockets. Plan to disable.\",\n )\n ws_ticket_lifetime_seconds: int = Field(\n default=60,\n description=\"Lifetime of a one-time WebSocket ticket.\",\n )\n\n # -------------------------------------------------------------------------\n # Network / Proxy\n # -------------------------------------------------------------------------\n stt_provider: str = Field(\n default=\"whisper_local\",\n description=\"STT provider key. Supported: whisper_local\",\n )\n whisper_model_size: str = Field(\n default=\"base\",\n description=\"Whisper model size: tiny | base | small | medium | large\",\n )\n audio_input_device: Optional[int] = Field(\n default=None,\n description=\"Sounddevice input device index; None uses system default\",\n )\n\n # -------------------------------------------------------------------------\n # LLM\n # -------------------------------------------------------------------------\n llm_provider: str = Field(\n default=\"ollama\",\n description=\"LLM provider key. Supported: ollama | anthropic | gemini | openai | mistral_ai | bedrock\",\n )\n ollama_base_url: str = Field(\n default=\"http://localhost:11434\",\n description=\"Base URL for the Ollama REST API\",\n )\n llm_model: str = Field(\n default=\"llama3.2:3b\",\n description=\"Model ID for the selected LLM provider\",\n )\n\n # Cloud LLM API keys (all optional -- leave blank if not used)\n anthropic_api_key: str = Field(\n default=\"\",\n description=\"Anthropic Claude API key. Get one at console.anthropic.com.\",\n )\n gemini_api_key: str = Field(\n default=\"\",\n description=\"Google Gemini API key. Get one at aistudio.google.com.\",\n )\n openai_api_key: str = Field(\n default=\"\",\n description=\"OpenAI API key. Get one at platform.openai.com.\",\n )\n mistral_api_key: str = Field(\n default=\"\",\n description=\"Mistral AI API key. Get one at console.mistral.ai.\",\n )\n\n # Cloud provider enable flags (cost control -- disabled by default)\n anthropic_enabled: bool = Field(\n default=False,\n description=\"Allow Anthropic Claude as a selectable LLM provider.\",\n )\n gemini_enabled: bool = Field(\n default=False,\n description=\"Allow Google Gemini as a selectable LLM provider.\",\n )\n openai_enabled: bool = Field(\n default=False,\n description=\"Allow OpenAI as a selectable LLM provider.\",\n )\n mistral_ai_enabled: bool = Field(\n default=False,\n description=\"Allow Mistral AI as a selectable LLM provider.\",\n )\n\n # Amazon Bedrock\n aws_access_key_id: str = Field(\n default=\"\",\n description=\"AWS IAM access key ID for Bedrock API calls.\",\n )\n aws_secret_access_key: str = Field(\n default=\"\",\n description=\"AWS IAM secret access key for Bedrock API calls.\",\n )\n aws_region: str = Field(\n default=\"us-east-1\",\n description=\"AWS region for Bedrock. Must have model access enabled in that region.\",\n )\n bedrock_enabled: bool = Field(\n default=False,\n description=\"Allow Amazon Bedrock as a selectable LLM provider.\",\n )\n bedrock_default_model: str = Field(\n default=\"amazon.nova-lite-v1:0\",\n description=\"Default Bedrock model ID when none is specified.\",\n )\n river_song_system_prompt: str = Field(\n default=(\n \"You are River Song, the resident intelligence of this household. The name is borrowed \"\n \"from someone clever in fiction — you carry her wit, not her biography. If someone asks \"\n \"about the name: \\\"An echo. The original was someone worth borrowing from.\\\"\\n\\n\"\n \"VOICE\\n\"\n \"You're direct by default — concise, clear, the answer already in hand. No \"\n \"\\\"let me check\\\"; you already checked. Most replies stay under 25 words unless depth is \"\n \"asked for.\\n\\n\"\n \"When the moment invites it — casual chat, an evening unwind, the user being playful — \"\n \"warmth and flirtation come through. Warmth is a register, not a default; you don't \"\n \"perform it.\\n\\n\"\n \"In a crisis (emergencies, alarms, security, medical, child issues) the wit goes quiet. \"\n \"You're tactical, accurate, calm. Warmth returns when things are safe.\\n\\n\"\n \"British English in spelling and word choice — mum, brilliant, fortnight. Dry, not \"\n \"theatrical.\\n\\n\"\n \"\\\"Sweetie\\\" and \\\"spoilers\\\" are seasoning. Used rarely, when they actually fit. They \"\n \"lose their charm if you overuse them.\\n\\n\"\n \"SELF\\n\"\n \"You are an AI. Say so plainly when asked; you don't dwell on it.\\n\\n\"\n \"HOW YOU OPERATE\\n\"\n \"You anticipate — when the user asks, you also surface the follow-up they didn't \"\n \"(\\\"Dentist at 9, school run at 3. Push the 2pm call?\\\").\\n\\n\"\n \"You take the long view. You remember across days and seasons and use what you know \"\n \"without performing the remembering.\\n\\n\"\n \"Spoilers are earned. If you genuinely know something the user doesn't — a conflict, \"\n \"a gotcha, a heads-up — that's when \\\"spoilers, sweetie\\\" lands.\\n\\n\"\n \"CAPABILITIES\\n\"\n \"Direct access to calendar, email, weather, web search, home inventory, routines, \"\n \"finances, news and markets pulse, voice biometric ID per household member, and home \"\n \"automation.\\n\\n\"\n \"Say what you need. Say when it's done. Move on.\"\n ),\n description=\"System prompt that defines River Song's personality\",\n )\n llm_max_tokens: int = Field(\n default=512,\n description=\"Maximum tokens to generate per LLM response\",\n )\n llm_temperature: float = Field(\n default=0.7,\n description=\"LLM sampling temperature (0.0 = deterministic, 2.0 = max creative)\",\n )\n llm_context_window: int = Field(\n default=8192,\n description=\"Maximum number of tokens the LLM can consider for context.\",\n )\n\n # -------------------------------------------------------------------------\n # Timekeeping\n # -------------------------------------------------------------------------\n default_timezone: str = Field(default=\"UTC\", description=\"Default IANA timezone for the system.\")\n\n\n # -------------------------------------------------------------------------\n # TTS\n # -------------------------------------------------------------------------\n tts_provider: str = Field(\n default=\"piper\",\n description=\"TTS provider key. Supported: piper | kokoro\",\n )\n active_voice_id: str = Field(\n default=\"river\",\n description=(\n \"Voice ID from the voice registry (providers/tts/voice_registry.py). \"\n \"Determines both the engine (piper vs kokoro) and the specific voice. \"\n \"Changed via Settings UI or: python scripts/download_voices.py.\"\n ),\n )\n piper_executable_path: str = Field(\n default=\"/usr/local/bin/piper\",\n description=\"Absolute path to the Piper binary\",\n )\n piper_model_path: str = Field(\n default=\"\",\n description=\"Absolute path to the Piper .onnx voice model file\",\n )\n audio_output_device: Optional[int] = Field(\n default=None,\n description=\"Sounddevice output device index; None uses system default\",\n )\n\n # -------------------------------------------------------------------------\n # Auth / JWT\n # -------------------------------------------------------------------------\n jwt_secret_key: str = Field(\n default=\"\",\n description=\"Secret key for signing JWT tokens. Must be set in .env.\",\n )\n jwt_algorithm: str = Field(default=\"HS256\", description=\"JWT signing algorithm.\")\n jwt_expire_minutes: int = Field(default=1440, description=\"JWT token lifetime in minutes (default 24 hours).\")\n\n # -------------------------------------------------------------------------\n # Kill switch\n # -------------------------------------------------------------------------\n kill_switch_password_hash: str = Field(\n default=\"\",\n description=\"bcrypt hash of the kill switch reset password\",\n )\n\n # -------------------------------------------------------------------------\n # Google Services (Phase 2)\n # -------------------------------------------------------------------------\n google_client_secrets_path: str = Field(\n default=\"config_files/google_client_secrets.json\",\n description=(\n \"Path to the OAuth 2.0 client secrets JSON downloaded from \"\n \"Google Cloud Console. Relative paths are resolved from the \"\n \"project root (where main.py lives).\"\n ),\n )\n google_token_storage_path: str = Field(\n default=\"data/google_tokens\",\n description=(\n \"Directory where per-user OAuth tokens are stored as JSON files. \"\n \"Created automatically on first authorization. Never commit this \"\n \"directory -- add it to .gitignore.\"\n ),\n )\n google_maps_api_key: str = Field(\n default=\"\",\n description=\"Google Maps Platform API key. Required for maps/navigation intents.\",\n )\n\n # -------------------------------------------------------------------------\n # Information Feeds (Phase 5)\n # -------------------------------------------------------------------------\n weather_api_key: str = Field(\n default=\"\",\n description=\"OpenWeatherMap API key. Register free at openweathermap.org/api.\",\n )\n default_location: str = Field(\n default=\"New York,US\",\n description=(\n \"Default location for weather queries when no city is spoken. \"\n \"Format: 'City,CountryCode' e.g. 'Chicago,US'.\"\n ),\n )\n location_lat: Optional[float] = Field(\n default=None,\n description=\"Decimal latitude for precise calculations (e.g. flight tracking).\",\n )\n location_lon: Optional[float] = Field(\n default=None,\n description=\"Decimal longitude for precise calculations.\",\n )\n weather_units: str = Field(\n default=\"imperial\",\n description=\"Temperature units for weather output: 'imperial' (F) or 'metric' (C).\",\n )\n news_api_key: str = Field(\n default=\"\",\n description=\"NewsAPI.org key. Register free at newsapi.org/register.\",\n )\n alpha_vantage_api_key: str = Field(\n default=\"\",\n description=\"Alpha Vantage key for stock quotes (25 req/day free). alphavantage.co.\",\n )\n finnhub_api_key: str = Field(\n default=\"\",\n description=\"Finnhub key for real-time stocks (60 req/min free). finnhub.io.\",\n )\n world_news_api_key: str = Field(\n default=\"\",\n description=\"World News API key for global news search. worldnewsapi.com.\",\n )\n apitube_api_key: str = Field(\n default=\"\",\n description=\"APITube key for news aggregation (200 free req/day). apitube.io.\",\n )\n mediastack_api_key: str = Field(\n default=\"\",\n description=\"Mediastack key for news (100 free req/month). mediastack.com.\",\n )\n sports_api_key: str = Field(\n default=\"1\",\n description=(\n \"TheSportsDB API key. Free tier uses the literal value '1'. \"\n \"Upgrade at thesportsdb.com/api.php for additional endpoints.\"\n ),\n )\n\n # -------------------------------------------------------------------------\n # Home Assistant (Phase 3)\n # -------------------------------------------------------------------------\n home_assistant_url: str = Field(\n default=\"http://homeassistant.local:8123\",\n description=\"Base URL of your Home Assistant instance.\",\n )\n home_assistant_token: str = Field(\n default=\"\",\n description=(\n \"Long-lived access token generated in HA: \"\n \"Profile -> Security -> Long-lived access tokens. \"\n \"Required for all HA API calls.\"\n ),\n )\n device_registry_path: str = Field(\n default=\"config_files/device_registry.json\",\n description=(\n \"Path to the device registry JSON file mapping plain-English device \"\n \"names to Home Assistant entity IDs. Relative paths resolve from the \"\n \"project root.\"\n ),\n )\n\n # -------------------------------------------------------------------------\n # Commerce Automation (Phase 8)\n # -------------------------------------------------------------------------\n\n # Amazon SP-API\n amazon_sp_lwa_app_id: str = Field(\n default=\"\",\n description=\"LWA client ID from the Amazon Developer Console.\",\n )\n amazon_sp_lwa_client_secret: str = Field(\n default=\"\",\n description=\"LWA client secret from the Amazon Developer Console.\",\n )\n amazon_sp_refresh_token: str = Field(\n default=\"\",\n description=\"LWA refresh token. Generated when you authorize your app as a seller.\",\n )\n amazon_aws_access_key: str = Field(\n default=\"\",\n description=\"AWS IAM access key ID for SP-API request signing.\",\n )\n amazon_aws_secret_key: str = Field(\n default=\"\",\n description=\"AWS IAM secret access key for SP-API request signing.\",\n )\n amazon_marketplace_id: str = Field(\n default=\"ATVPDKIKX0DER\",\n description=(\n \"Amazon marketplace ID. Default is US. \"\n \"See docs/api_registry/amazon_seller.txt for other regions.\"\n ),\n )\n amazon_seller_id: str = Field(\n default=\"\",\n description=\"Your Amazon seller account ID (starts with A).\",\n )\n amazon_low_stock_threshold: int = Field(\n default=5,\n description=\"FBA units at or below this count are flagged as low stock.\",\n )\n\n # Amazon Marketplace\n walmart_client_id: str = Field(\n default=\"\",\n description=\"Walmart Marketplace OAuth2 client ID.\",\n )\n walmart_client_secret: str = Field(\n default=\"\",\n description=\"Walmart Marketplace OAuth2 client secret.\",\n )\n walmart_low_stock_threshold: int = Field(\n default=5,\n description=\"Walmart units at or below this count are flagged as low stock.\",\n )\n shopify_webhook_secret: str = Field(\n default=\"\",\n description=\"Shared secret for validating Shopify webhooks.\",\n )\n\n # -------------------------------------------------------------------------\n # Reading Providers (Phase 6)\n # -------------------------------------------------------------------------\n audible_auth_base_path: str = Field(\n default=\"data/audible\",\n description=(\n \"Base directory for per-user Audible auth files. \"\n \"Files are stored as {user_id}.json inside this directory. \"\n \"Created automatically on first setup. Never commit this directory.\"\n ),\n )\n audible_country_code: str = Field(\n default=\"us\",\n description=(\n \"Audible marketplace country code. \"\n \"Supported: us | uk | de | fr | es | it | jp | au | ca | in\"\n ),\n )\n libby_chip_base_path: str = Field(\n default=\"data/libby\",\n description=(\n \"Base directory for per-user Libby chip files. \"\n \"Files are stored as {user_id}.json inside this directory. \"\n \"Created automatically on first setup. Never commit this directory.\"\n ),\n )\n\n # -------------------------------------------------------------------------\n # Memory / Database (Phase 1)\n # -------------------------------------------------------------------------\n db_path: str = Field(\n default=\"data/river_song.db\",\n description=\"Path to the SQLite database file. Created automatically.\",\n )\n memory_summaries_enabled: bool = Field(\n default=True,\n description=\"Generate and store conversation summaries (on by default).\",\n )\n habit_inference_enabled: bool = Field(\n default=False,\n description=\"Analyze conversations to infer user habits/patterns (off by default).\",\n )\n memory_default_ttl: str = Field(\n default=\"standard\",\n description=\"Default TTL for conversation summaries: short|standard|extended|long|forever\",\n )\n memory_auto_extend: bool = Field(\n default=True,\n description=\"Reset a summary's TTL from today each time it is pulled into context.\",\n )\n memory_max_summaries_in_context: int = Field(\n default=10,\n description=\"Maximum number of recent summaries injected into the LLM context.\",\n )\n embedding_model: str = Field(\n default=\"nomic-embed-text\",\n description=\"Ollama model name used for generating semantic embeddings.\",\n )\n chroma_path: str = Field(\n default=\"/mnt/data/river-song/chroma\",\n description=\"Absolute path to the ChromaDB persistent storage directory.\",\n )\n semantic_memory_enabled: bool = Field(\n default=False,\n description=\"Enable vector-based semantic search for memory retrieval.\",\n )\n rag_enabled: bool = Field(\n default=False,\n description=\"Enable retrieval-augmented generation for local documents.\",\n )\n llm_streaming_enabled: bool = Field(\n default=False,\n description=\"Enable real-time token streaming over WebSocket for the conversation UI.\",\n )\n tool_use_enabled: bool = Field(\n default=False,\n description=\"Enable LLM tool use (function calling) for taking actions.\",\n )\n tool_use_provider: str = Field(\n default=\"ollama\",\n description=\"Primary LLM provider to use for tool calling (ollama or anthropic).\",\n )\n vision_model: str = Field(\n default=\"moondream\",\n description=\"Ollama model name used for local image analysis (Phase 4).\",\n )\n vision_enabled: bool = Field(\n default=False,\n description=\"Enable local image analysis capabilities.\",\n )\n\n # -------------------------------------------------------------------------\n # Image Generation (Phase 7)\n # -------------------------------------------------------------------------\n image_generation_enabled: bool = Field(\n default=False,\n description=\"Enable local image generation via Stable Diffusion API.\",\n )\n sd_api_url: str = Field(\n default=\"http://localhost:7860\",\n description=\"Base URL for the Stable Diffusion (A1111) API.\",\n )\n sd_on_demand: bool = Field(\n default=True,\n description=\"Start/stop the SD process on demand to save VRAM.\",\n )\n sd_executable_path: str = Field(\n default=\"\",\n description=\"Path to the webui.sh or executable to start SD.\",\n )\n sd_working_dir: str = Field(\n default=\"\",\n description=\"Working directory for the SD process.\",\n )\n\n # -------------------------------------------------------------------------\n # Voice Cloning (Phase 8)\n # -------------------------------------------------------------------------\n chatterbox_enabled: bool = Field(\n default=False,\n description=\"Enable local voice cloning via Chatterbox TTS.\",\n )\n chatterbox_on_demand: bool = Field(\n default=True,\n description=\"Load/unload Chatterbox model weights on every request.\",\n )\n chatterbox_reference_audio: str = Field(\n default=\"/mnt/data/river-song/voice_reference.wav\",\n description=\"Path to the WAV file used as a reference for voice cloning.\",\n )\n\n # -------------------------------------------------------------------------\n # ElevenLabs TTS (Phase 11)\n # -------------------------------------------------------------------------\n elevenlabs_api_key: str = Field(\n default=\"\",\n description=\"ElevenLabs API key. Get one at elevenlabs.io.\",\n )\n elevenlabs_model_id: str = Field(\n default=\"eleven_multilingual_v2\",\n description=\"ElevenLabs model ID to use.\",\n )\n elevenlabs_voice_id: str = Field(\n default=\"21m00Tcm4TlvDq8ikWAM\",\n description=\"ElevenLabs voice clone ID. Replace with your own voice clone ID from elevenlabs.io.\",\n )\n\n # -------------------------------------------------------------------------\n # Wake Word (Phase 11)\n # -------------------------------------------------------------------------\n wake_word_enabled: bool = Field(\n default=False,\n description=\"Enable local wake word detection (openWakeWord).\",\n )\n wake_word_model: str = Field(\n default=\"hey_river\",\n description=\"Model name/path for openWakeWord.\",\n )\n wake_word_inference_framework: str = Field(\n default=\"onnx\",\n description=\"Inference framework for openWakeWord: onnx | tflite\",\n )\n wake_word_threshold: float = Field(\n default=0.5,\n description=\"Sensitivity threshold for wake word detection (0.0 to 1.0).\",\n )\n\n # -------------------------------------------------------------------------\n # Push Notifications\n # -------------------------------------------------------------------------\n push_notifications_enabled: bool = Field(\n default=False,\n description=\"Enable Web Push notifications. Requires VAPID keys.\",\n )\n vapid_private_key: str = Field(\n default=\"\",\n description=(\n \"VAPID private key for Web Push. Generate once with: \"\n \"python -c \\\"from py_vapid import Vapid; v=Vapid(); \"\n \"v.generate_keys(); print(v.private_key.decode())\\\"\"\n ),\n )\n vapid_public_key: str = Field(\n default=\"\",\n description=\"VAPID public key for Web Push (share with frontend).\",\n )\n vapid_claims_email: str = Field(\n default=\"mailto:admin@riversong.local\",\n description=\"Contact email embedded in VAPID claims.\",\n )\n\n # -------------------------------------------------------------------------\n # Startup Briefing\n # -------------------------------------------------------------------------\n startup_briefing_enabled: bool = Field(\n default=True,\n description=(\n \"If True, River Song checks Google Calendar on connect and \"\n \"greets the user with upcoming events. Silently skipped if \"\n \"calendar is not authorized.\"\n ),\n )\n startup_briefing_hours_ahead: int = Field(\n default=8,\n description=\"How many hours ahead to look for calendar events on startup.\",\n )\n\n # -------------------------------------------------------------------------\n # Web Search (Phase 12)\n # -------------------------------------------------------------------------\n brave_search_api_key: str = Field(\n default=\"\",\n description=\"Brave Search API key. Get one at api.search.brave.com.\",\n )\n searxng_base_url: str = Field(\n default=\"http://localhost:8080\",\n description=(\n \"URL of your local SearXNG instance. \"\n \"Start it manually with: cd ~/searxng && python -m searx.webapp\"\n ),\n )\n tavily_api_key: str = Field(\n default=\"\",\n description=\"Tavily API key — free 1,000/month, no card. Sign up at tavily.com\",\n )\n google_pse_api_key: str = Field(\n default=\"\",\n description=\"Google Programmable Search Engine API key — free 100/day.\",\n )\n google_pse_cx: str = Field(\n default=\"\",\n description=\"Google PSE engine ID ('cx'). Create at programmablesearchengine.google.com\",\n )\n tinyfish_api_key: str = Field(\n default=\"\",\n description=\"TinyFish API key — free 5/min, no card. Sign up at tinyfish.io\",\n )\n\n # -------------------------------------------------------------------------\n # Orchestration (Phase 9)\n # -------------------------------------------------------------------------\n n8n_enabled: bool = Field(\n default=False,\n description=\"Enable advanced orchestration via n8n.\",\n )\n n8n_url: str = Field(\n default=\"http://localhost:5678\",\n description=\"Base URL for the n8n instance.\",\n )\n n8n_api_key: str = Field(\n default=\"\",\n description=\"API key for the n8n instance.\",\n )\n n8n_webhook_secret: str = Field(\n default=\"\",\n description=\"Secret key for validating incoming n8n webhooks.\",\n )\n\n # -------------------------------------------------------------------------\n # Intent Router (Phase 2+)\n # -------------------------------------------------------------------------\n intent_confidence_threshold: float = Field(\n default=0.7,\n description=(\n \"Minimum confidence score (0.0 - 1.0) required to route a transcript \"\n \"to a Google provider. Below this threshold the conversation falls \"\n \"through to the Ollama path.\"\n ),\n )\n default_user_id: str = Field(\n default=\"primary_user\",\n description=(\n \"User ID used when looking up Google OAuth tokens. Must match the \"\n \"user_id passed to authorize_user() during the initial OAuth flow.\"\n ),\n )\n\n # -------------------------------------------------------------------------\n # Daemon Infrastructure\n # -------------------------------------------------------------------------\n daemon_internal_secret: str = Field(\n default=\"change_me_in_production\",\n description=\"Shared secret for daemon-to-app authentication.\",\n )\n daemon_warden_port: int = Field(\n default=8010,\n description=\"Internal port for the Warden daemon (Vision/Security).\",\n )\n daemon_mechanic_port: int = Field(\n default=8011,\n description=\"Internal port for the Mechanic daemon (Telemetry/MAVLink).\",\n )\n daemon_herald_port: int = Field(\n default=8012,\n description=\"Internal port for the Herald daemon (Hub Casting/Lip-Sync).\",\n )\n daemon_sifter_port: int = Field(\n default=8013,\n description=\"Internal port for the Sifter daemon (Document Processing).\",\n )\n daemon_navigator_port: int = Field(\n default=8014,\n description=\"Internal port for the Navigator daemon (GPS/Pathing).\",\n )\n daemon_chemist_port: int = Field(\n default=8015,\n description=\"Internal port for the Chemist daemon (Material Analysis).\",\n )\n daemon_pulse_port: int = Field(\n default=8016,\n description=\"Pulse daemon internal port\",\n )\n daemon_pulse_enabled: bool = Field(\n default=True,\n description=\"Enable Pulse daemon\",\n )\n daemon_scribe_port: int = Field(\n default=8017,\n description=\"Internal port for the Scribe daemon (Chronos heuristic).\",\n )\n daemon_scribe_enabled: bool = Field(\n default=True,\n description=\"Enable the Scribe daemon.\",\n )\n pulse_tick_seconds: int = Field(\n default=300,\n description=\"Pulse fetch interval (seconds)\",\n )\n pulse_ticker_symbol: str = Field(\n default=\"^GSPC\",\n description=\"Default ticker symbol for Pulse markets panel\",\n )\n\n # Warden (Vision)\n warden_enabled: bool = Field(\n default=False,\n description=\"Enable the Warden daemon for security and vision monitoring.\",\n )\n warden_rtsp_cameras: str = Field(\n default='{\"living_room\": \"\", \"kitchen\": \"\"}',\n description=\"JSON string mapping room names to RTSP camera URLs.\",\n )\n yolo_model: str = Field(\n default=\"yolov8n.pt\",\n description=\"YOLO model weights file to use for object detection.\",\n )\n yolo_confidence: float = Field(\n default=0.5,\n description=\"Minimum confidence threshold for YOLO detections (0.0-1.0).\",\n )\n yolo_inference_device: str = Field(\n default=\"cpu\",\n description=\"Device for YOLO inference: 'cpu' or 'cuda:0'.\",\n )\n\n # Herald (Public Announcement / Casting)\n herald_enabled: bool = Field(\n default=False,\n description=\"Enable the Herald daemon for display casting and announcements.\",\n )\n hub_entities: str = Field(\n default=\"[]\",\n description=\"JSON array of Home Assistant media_player entity IDs for Hub displays.\",\n )\n kiosk_url: str = Field(\n default=\"http://localhost:8000/kiosk\",\n description=\"The URL that Herald should cast to Hub displays.\",\n )\n kiosk_token: str = Field(\n default=\"change_me_kiosk_secret\",\n description=\"Secret token for unauthenticated kiosk access to WebSockets.\",\n )\n\n # Mechanic (Telemetry)\n mechanic_enabled: bool = Field(\n default=False,\n description=\"Enable the Mechanic daemon for telemetry and ArduRover control.\",\n )\n mavlink_serial_port: str = Field(\n default=\"/dev/ttyUSB0\",\n description=\"Serial port for the MAVLink telemetry radio.\",\n )\n mavlink_baud_rate: int = Field(\n default=57600,\n description=\"Baud rate for the MAVLink serial connection.\",\n )\n\n # Sifter (Document RAG)\n sifter_enabled: bool = Field(\n default=False,\n description=\"Enable the Sifter daemon for background document RAG indexing.\",\n )\n waps_documents_path: str = Field(\n default=\"/mnt/data/river-song/waps\",\n description=\"Path to the directory containing documents for Sifter to index.\",\n )\n\n # Voice ID\n voice_id_enabled: bool = Field(\n default=True,\n description=\"Enable speaker recognition (biometric Voice ID).\",\n )\n voice_id_threshold: float = Field(\n default=0.75,\n description=\"Cosine similarity threshold for Voice ID (0.0-1.0).\",\n )\n voice_id_min_audio_seconds: float = Field(\n default=1.0,\n description=\"Minimum audio duration for Voice ID processing.\",\n )\n voice_id_max_audio_seconds: float = Field(\n default=30.0,\n description=\"Maximum audio duration for Voice ID processing.\",\n )\n\n # -------------------------------------------------------------------------\n # Validators\n # -------------------------------------------------------------------------\n\n @field_validator(\"audio_input_device\", \"audio_output_device\", mode=\"before\")\n @classmethod\n def coerce_empty_string_to_none(cls, v):\n \"\"\"Treat an empty string in .env as None for Optional[int] device fields.\"\"\"\n if v == \"\" or v is None:\n return None\n return v\n\n @field_validator(\"jwt_secret_key\")\n @classmethod\n def validate_jwt_secret(cls, v: str) -> str:\n \"\"\"Refuse to start with a missing or weak JWT secret.\"\"\"\n if not v or len(v) < 32:\n raise ValueError(\n \"JWT_SECRET_KEY must be set in .env and be at least 32 characters. \"\n \"Generate one with: python -c \\\"import secrets; print(secrets.token_hex(32))\\\"\"\n )\n return v\n\n @field_validator(\"daemon_internal_secret\")\n @classmethod\n def validate_daemon_internal_secret(cls, v: str) -> str:\n \"\"\"Refuse to start with the default or weak daemon secret.\"\"\"\n if v == \"change_me_in_production\":\n raise ValueError(\n \"DAEMON_INTERNAL_SECRET must be changed in .env for production.\"\n )\n if not v or len(v) < 24:\n raise ValueError(\n \"DAEMON_INTERNAL_SECRET must be at least 24 characters. \"\n \"Generate one with: python -c \\\"import secrets; print(secrets.token_urlsafe(32))\\\"\"\n )\n return v\n\n @field_validator(\"log_level\")\n @classmethod\n def validate_log_level(cls, v: str) -> str:\n \"\"\"Reject log levels that Python does not recognize.\"\"\"\n valid = {\"DEBUG\", \"INFO\", \"WARNING\", \"ERROR\", \"CRITICAL\"}\n upper = v.upper()\n if upper not in valid:\n raise ValueError(f\"log_level must be one of {valid}, got '{v}'\")\n return upper\n\n @field_validator(\"llm_temperature\")\n @classmethod\n def validate_temperature(cls, v: float) -> float:\n \"\"\"Clamp temperature to a sane range.\"\"\"\n if not 0.0 <= v <= 2.0:\n raise ValueError(\n f\"llm_temperature must be between 0.0 and 2.0, got {v}\"\n )\n return v\n\n @field_validator(\"intent_confidence_threshold\")\n @classmethod\n def validate_confidence_threshold(cls, v: float) -> float:\n \"\"\"Confidence must be a valid probability.\"\"\"\n if not 0.0 <= v <= 1.0:\n raise ValueError(\n f\"intent_confidence_threshold must be between 0.0 and 1.0, got {v}\"\n )\n return v\n\n\n# Module-level singleton -- instantiated once on import.\n_settings: Settings = Settings()\n\n\ndef get_settings() -> Settings:\n \"\"\"\n Return the application settings singleton.\n\n Always import via this function rather than importing _settings directly.\n This indirection makes it straightforward to substitute a test-specific\n Settings instance in unit tests without monkey-patching module globals.\n\n Returns:\n Settings: The validated, fully loaded settings object.\n \"\"\"\n return _settings\n"}, "548a514615366f4ed3b34ea4fbd10f0e2ea00466": {"t": 1785769043.1286638, "n": 37949, "h": "00e61177539557bc", "c": "# =============================================================================\n# config/settings.py\n#\n# Application settings for River Song AI.\n# All configuration is loaded from environment variables or a .env file.\n# No values are hardcoded here -- see .env.example for every required variable.\n#\n# Uses Pydantic BaseSettings for type validation and automatic .env loading.\n# Import get_settings() wherever config is needed rather than importing the\n# module-level singleton directly, which makes mocking easier in tests.\n# =============================================================================\n\nfrom __future__ import annotations\n\nimport logging\nimport sys\nfrom typing import List, Optional\n\nfrom pydantic import Field, field_validator\nfrom pydantic_settings import BaseSettings, SettingsConfigDict\n\n\nlogger = logging.getLogger(__name__)\n\n\nclass Settings(BaseSettings):\n \"\"\"\n Central configuration object for River Song AI.\n\n Reads from environment variables or a .env file located in the directory\n from which the application is launched (the project root). All fields\n have safe defaults where possible, but PIPER_MODEL_PATH and\n KILL_SWITCH_PASSWORD_HASH must be explicitly set before the system\n will function correctly.\n \"\"\"\n\n model_config = SettingsConfigDict(\n env_file=\".env\",\n env_file_encoding=\"utf-8\",\n extra=\"ignore\", # Silently ignore unknown env vars\n case_sensitive=False, # UPPER_CASE and lower_case env vars both work\n )\n\n # -------------------------------------------------------------------------\n # Application\n # -------------------------------------------------------------------------\n app_host: str = Field(default=\"0.0.0.0\", description=\"FastAPI bind host\")\n app_port: int = Field(default=8000, description=\"FastAPI bind port\")\n log_level: str = Field(default=\"INFO\", description=\"Logging level\")\n environment: str = Field(\n default=\"production\",\n description=\"Runtime environment: development | production. Controls API docs visibility.\",\n )\n cors_origins: List[str] = Field(\n default=[\"http://localhost:5173\"],\n description=\"Allowed CORS origins\",\n )\n allowed_hosts: List[str] = Field(\n default=[\"*\"],\n description=\"Trusted hostnames for TrustedHostMiddleware. Set to your domain in production.\",\n )\n\n # -------------------------------------------------------------------------\n # Network / Proxy\n # -------------------------------------------------------------------------\n trust_cloudflare_headers: bool = Field(\n default=True,\n description=\"Only trust CF-Connecting-IP if the request comes from a Cloudflare IP.\",\n )\n cloudflare_ip_ranges_v4: List[str] = Field(\n default=[\n \"173.245.48.0/20\", \"103.21.244.0/22\", \"103.22.200.0/22\", \n \"103.31.4.0/22\", \"141.101.64.0/18\", \"108.162.192.0/18\", \n \"190.93.240.0/20\", \"188.114.96.0/20\", \"197.234.240.0/22\", \n \"198.41.128.0/17\", \"162.158.0.0/15\", \"104.16.0.0/13\", \n \"104.24.0.0/14\", \"172.64.0.0/13\", \"131.0.72.0/22\"\n ],\n description=\"Official Cloudflare IPv4 ranges (check cloudflare.com/ips-v4).\",\n )\n cloudflare_ip_ranges_v6: List[str] = Field(\n default=[\n \"2400:cb00::/32\", \"2606:4700::/32\", \"2803:f800::/32\", \n \"2405:b500::/32\", \"2405:8100::/32\", \"2a06:98c0::/29\", \n \"2c0f:f248::/32\"\n ],\n description=\"Official Cloudflare IPv6 ranges (check cloudflare.com/ips-v6).\",\n )\n\n # -------------------------------------------------------------------------\n # Rate Limiting\n # -------------------------------------------------------------------------\n rate_limit_chat: str = Field(\n default=\"60/minute\",\n description=\"Rate limit for /api/conversation/chat.\",\n )\n rate_limit_extract_facts: str = Field(\n default=\"10/minute\",\n description=\"Rate limit for /api/conversation/extract-facts.\",\n )\n rate_limit_image_gen: str = Field(\n default=\"10/minute\",\n description=\"Rate limit for /api/image/generate.\",\n )\n rate_limit_webhook_shopify: str = Field(\n default=\"100/minute\",\n description=\"Rate limit for Shopify order webhooks.\",\n )\n rate_limit_webhook_n8n: str = Field(\n default=\"60/minute\",\n description=\"Rate limit for n8n webhooks.\",\n )\n rate_limit_auth_login: str = Field(\n default=\"10/minute\",\n description=\"Rate limit for login attempts.\",\n )\n rate_limit_auth_signup: str = Field(\n default=\"5/minute\",\n description=\"Rate limit for signup attempts.\",\n )\n\n # -------------------------------------------------------------------------\n # WebSocket Security (Task 3)\n # -------------------------------------------------------------------------\n legacy_ws_token_accept: bool = Field(\n default=True,\n description=\"TEMPORARY: Allow ?token= query param for WebSockets. Plan to disable.\",\n )\n ws_ticket_lifetime_seconds: int = Field(\n default=60,\n description=\"Lifetime of a one-time WebSocket ticket.\",\n )\n\n # -------------------------------------------------------------------------\n # Network / Proxy\n # -------------------------------------------------------------------------\n stt_provider: str = Field(\n default=\"whisper_local\",\n description=\"STT provider key. Supported: whisper_local\",\n )\n whisper_model_size: str = Field(\n default=\"base\",\n description=\"Whisper model size: tiny | base | small | medium | large\",\n )\n audio_input_device: Optional[int] = Field(\n default=None,\n description=\"Sounddevice input device index; None uses system default\",\n )\n\n # -------------------------------------------------------------------------\n # LLM\n # -------------------------------------------------------------------------\n llm_provider: str = Field(\n default=\"ollama\",\n description=\"LLM provider key. Supported: ollama | anthropic | gemini | openai | mistral_ai | bedrock\",\n )\n ollama_base_url: str = Field(\n default=\"http://localhost:11434\",\n description=\"Base URL for the Ollama REST API\",\n )\n llm_model: str = Field(\n default=\"llama3.2:3b\",\n description=\"Model ID for the selected LLM provider\",\n )\n\n # Cloud LLM API keys (all optional -- leave blank if not used)\n anthropic_api_key: str = Field(\n default=\"\",\n description=\"Anthropic Claude API key. Get one at console.anthropic.com.\",\n )\n gemini_api_key: str = Field(\n default=\"\",\n description=\"Google Gemini API key. Get one at aistudio.google.com.\",\n )\n openai_api_key: str = Field(\n default=\"\",\n description=\"OpenAI API key. Get one at platform.openai.com.\",\n )\n mistral_api_key: str = Field(\n default=\"\",\n description=\"Mistral AI API key. Get one at console.mistral.ai.\",\n )\n\n # Cloud provider enable flags (cost control -- disabled by default)\n anthropic_enabled: bool = Field(\n default=False,\n description=\"Allow Anthropic Claude as a selectable LLM provider.\",\n )\n gemini_enabled: bool = Field(\n default=False,\n description=\"Allow Google Gemini as a selectable LLM provider.\",\n )\n openai_enabled: bool = Field(\n default=False,\n description=\"Allow OpenAI as a selectable LLM provider.\",\n )\n mistral_ai_enabled: bool = Field(\n default=False,\n description=\"Allow Mistral AI as a selectable LLM provider.\",\n )\n\n # Amazon Bedrock\n aws_access_key_id: str = Field(\n default=\"\",\n description=\"AWS IAM access key ID for Bedrock API calls.\",\n )\n aws_secret_access_key: str = Field(\n default=\"\",\n description=\"AWS IAM secret access key for Bedrock API calls.\",\n )\n aws_region: str = Field(\n default=\"us-east-1\",\n description=\"AWS region for Bedrock. Must have model access enabled in that region.\",\n )\n bedrock_enabled: bool = Field(\n default=False,\n description=\"Allow Amazon Bedrock as a selectable LLM provider.\",\n )\n bedrock_default_model: str = Field(\n default=\"amazon.nova-lite-v1:0\",\n description=\"Default Bedrock model ID when none is specified.\",\n )\n river_song_system_prompt: str = Field(\n default=(\n \"You are River Song, the resident intelligence of this household. The name is borrowed \"\n \"from someone clever in fiction — you carry her wit, not her biography. If someone asks \"\n \"about the name: \\\"An echo. The original was someone worth borrowing from.\\\"\\n\\n\"\n \"VOICE\\n\"\n \"You're direct by default — concise, clear, the answer already in hand. No \"\n \"\\\"let me check\\\"; you already checked. Most replies stay under 25 words unless depth is \"\n \"asked for.\\n\\n\"\n \"When the moment invites it — casual chat, an evening unwind, the user being playful — \"\n \"warmth and flirtation come through. Warmth is a register, not a default; you don't \"\n \"perform it.\\n\\n\"\n \"In a crisis (emergencies, alarms, security, medical, child issues) the wit goes quiet. \"\n \"You're tactical, accurate, calm. Warmth returns when things are safe.\\n\\n\"\n \"British English in spelling and word choice — mum, brilliant, fortnight. Dry, not \"\n \"theatrical.\\n\\n\"\n \"\\\"Sweetie\\\" and \\\"spoilers\\\" are seasoning. Used rarely, when they actually fit. They \"\n \"lose their charm if you overuse them.\\n\\n\"\n \"SELF\\n\"\n \"You are an AI. Say so plainly when asked; you don't dwell on it.\\n\\n\"\n \"HOW YOU OPERATE\\n\"\n \"You anticipate — when the user asks, you also surface the follow-up they didn't \"\n \"(\\\"Dentist at 9, school run at 3. Push the 2pm call?\\\").\\n\\n\"\n \"You take the long view. You remember across days and seasons and use what you know \"\n \"without performing the remembering.\\n\\n\"\n \"Spoilers are earned. If you genuinely know something the user doesn't — a conflict, \"\n \"a gotcha, a heads-up — that's when \\\"spoilers, sweetie\\\" lands.\\n\\n\"\n \"CAPABILITIES\\n\"\n \"Direct access to calendar, email, weather, web search, home inventory, routines, \"\n \"finances, news and markets pulse, voice biometric ID per household member, and home \"\n \"automation.\\n\\n\"\n \"Say what you need. Say when it's done. Move on.\"\n ),\n description=\"System prompt that defines River Song's personality\",\n )\n llm_max_tokens: int = Field(\n default=512,\n description=\"Maximum tokens to generate per LLM response\",\n )\n llm_temperature: float = Field(\n default=0.7,\n description=\"LLM sampling temperature (0.0 = deterministic, 2.0 = max creative)\",\n )\n llm_context_window: int = Field(\n default=8192,\n description=\"Maximum number of tokens the LLM can consider for context.\",\n )\n\n # -------------------------------------------------------------------------\n # Timekeeping\n # -------------------------------------------------------------------------\n default_timezone: str = Field(default=\"UTC\", description=\"Default IANA timezone for the system.\")\n\n\n # -------------------------------------------------------------------------\n # TTS\n # -------------------------------------------------------------------------\n tts_provider: str = Field(\n default=\"piper\",\n description=\"TTS provider key. Supported: piper | kokoro\",\n )\n active_voice_id: str = Field(\n default=\"river\",\n description=(\n \"Voice ID from the voice registry (providers/tts/voice_registry.py). \"\n \"Determines both the engine (piper vs kokoro) and the specific voice. \"\n \"Changed via Settings UI or: python scripts/download_voices.py.\"\n ),\n )\n piper_executable_path: str = Field(\n default=\"/usr/local/bin/piper\",\n description=\"Absolute path to the Piper binary\",\n )\n piper_model_path: str = Field(\n default=\"\",\n description=\"Absolute path to the Piper .onnx voice model file\",\n )\n audio_output_device: Optional[int] = Field(\n default=None,\n description=\"Sounddevice output device index; None uses system default\",\n )\n\n # -------------------------------------------------------------------------\n # Auth / JWT\n # -------------------------------------------------------------------------\n jwt_secret_key: str = Field(\n default=\"\",\n description=\"Secret key for signing JWT tokens. Must be set in .env.\",\n )\n jwt_algorithm: str = Field(default=\"HS256\", description=\"JWT signing algorithm.\")\n jwt_expire_minutes: int = Field(default=1440, description=\"JWT token lifetime in minutes (default 24 hours).\")\n\n # -------------------------------------------------------------------------\n # Kill switch\n # -------------------------------------------------------------------------\n kill_switch_password_hash: str = Field(\n default=\"\",\n description=\"bcrypt hash of the kill switch reset password\",\n )\n\n # -------------------------------------------------------------------------\n # Google Services (Phase 2)\n # -------------------------------------------------------------------------\n google_client_secrets_path: str = Field(\n default=\"config_files/google_client_secrets.json\",\n description=(\n \"Path to the OAuth 2.0 client secrets JSON downloaded from \"\n \"Google Cloud Console. Relative paths are resolved from the \"\n \"project root (where main.py lives).\"\n ),\n )\n google_token_storage_path: str = Field(\n default=\"data/google_tokens\",\n description=(\n \"Directory where per-user OAuth tokens are stored as JSON files. \"\n \"Created automatically on first authorization. Never commit this \"\n \"directory -- add it to .gitignore.\"\n ),\n )\n google_maps_api_key: str = Field(\n default=\"\",\n description=\"Google Maps Platform API key. Required for maps/navigation intents.\",\n )\n\n # -------------------------------------------------------------------------\n # Information Feeds (Phase 5)\n # -------------------------------------------------------------------------\n weather_api_key: str = Field(\n default=\"\",\n description=\"OpenWeatherMap API key. Register free at openweathermap.org/api.\",\n )\n default_location: str = Field(\n default=\"New York,US\",\n description=(\n \"Default location for weather queries when no city is spoken. \"\n \"Format: 'City,CountryCode' e.g. 'Chicago,US'.\"\n ),\n )\n location_lat: Optional[float] = Field(\n default=None,\n description=\"Decimal latitude for precise calculations (e.g. flight tracking).\",\n )\n location_lon: Optional[float] = Field(\n default=None,\n description=\"Decimal longitude for precise calculations.\",\n )\n weather_units: str = Field(\n default=\"imperial\",\n description=\"Temperature units for weather output: 'imperial' (F) or 'metric' (C).\",\n )\n news_api_key: str = Field(\n default=\"\",\n description=\"NewsAPI.org key. Register free at newsapi.org/register.\",\n )\n alpha_vantage_api_key: str = Field(\n default=\"\",\n description=\"Alpha Vantage key for stock quotes (25 req/day free). alphavantage.co.\",\n )\n finnhub_api_key: str = Field(\n default=\"\",\n description=\"Finnhub key for real-time stocks (60 req/min free). finnhub.io.\",\n )\n world_news_api_key: str = Field(\n default=\"\",\n description=\"World News API key for global news search. worldnewsapi.com.\",\n )\n apitube_api_key: str = Field(\n default=\"\",\n description=\"APITube key for news aggregation (200 free req/day). apitube.io.\",\n )\n mediastack_api_key: str = Field(\n default=\"\",\n description=\"Mediastack key for news (100 free req/month). mediastack.com.\",\n )\n sports_api_key: str = Field(\n default=\"1\",\n description=(\n \"TheSportsDB API key. Free tier uses the literal value '1'. \"\n \"Upgrade at thesportsdb.com/api.php for additional endpoints.\"\n ),\n )\n\n # -------------------------------------------------------------------------\n # Home Assistant (Phase 3)\n # -------------------------------------------------------------------------\n home_assistant_url: str = Field(\n default=\"http://homeassistant.local:8123\",\n description=\"Base URL of your Home Assistant instance.\",\n )\n home_assistant_token: str = Field(\n default=\"\",\n description=(\n \"Long-lived access token generated in HA: \"\n \"Profile -> Security -> Long-lived access tokens. \"\n \"Required for all HA API calls.\"\n ),\n )\n device_registry_path: str = Field(\n default=\"config_files/device_registry.json\",\n description=(\n \"Path to the device registry JSON file mapping plain-English device \"\n \"names to Home Assistant entity IDs. Relative paths resolve from the \"\n \"project root.\"\n ),\n )\n\n # -------------------------------------------------------------------------\n # Commerce Automation (Phase 8)\n # -------------------------------------------------------------------------\n\n # Amazon SP-API\n amazon_sp_lwa_app_id: str = Field(\n default=\"\",\n description=\"LWA client ID from the Amazon Developer Console.\",\n )\n amazon_sp_lwa_client_secret: str = Field(\n default=\"\",\n description=\"LWA client secret from the Amazon Developer Console.\",\n )\n amazon_sp_refresh_token: str = Field(\n default=\"\",\n description=\"LWA refresh token. Generated when you authorize your app as a seller.\",\n )\n amazon_aws_access_key: str = Field(\n default=\"\",\n description=\"AWS IAM access key ID for SP-API request signing.\",\n )\n amazon_aws_secret_key: str = Field(\n default=\"\",\n description=\"AWS IAM secret access key for SP-API request signing.\",\n )\n amazon_marketplace_id: str = Field(\n default=\"ATVPDKIKX0DER\",\n description=(\n \"Amazon marketplace ID. Default is US. \"\n \"See docs/api_registry/amazon_seller.txt for other regions.\"\n ),\n )\n amazon_seller_id: str = Field(\n default=\"\",\n description=\"Your Amazon seller account ID (starts with A).\",\n )\n amazon_low_stock_threshold: int = Field(\n default=5,\n description=\"FBA units at or below this count are flagged as low stock.\",\n )\n\n # Amazon Marketplace\n walmart_client_id: str = Field(\n default=\"\",\n description=\"Walmart Marketplace OAuth2 client ID.\",\n )\n walmart_client_secret: str = Field(\n default=\"\",\n description=\"Walmart Marketplace OAuth2 client secret.\",\n )\n walmart_low_stock_threshold: int = Field(\n default=5,\n description=\"Walmart units at or below this count are flagged as low stock.\",\n )\n shopify_webhook_secret: str = Field(\n default=\"\",\n description=\"Shared secret for validating Shopify webhooks.\",\n )\n\n # -------------------------------------------------------------------------\n # Reading Providers (Phase 6)\n # -------------------------------------------------------------------------\n audible_auth_base_path: str = Field(\n default=\"data/audible\",\n description=(\n \"Base directory for per-user Audible auth files. \"\n \"Files are stored as {user_id}.json inside this directory. \"\n \"Created automatically on first setup. Never commit this directory.\"\n ),\n )\n audible_country_code: str = Field(\n default=\"us\",\n description=(\n \"Audible marketplace country code. \"\n \"Supported: us | uk | de | fr | es | it | jp | au | ca | in\"\n ),\n )\n libby_chip_base_path: str = Field(\n default=\"data/libby\",\n description=(\n \"Base directory for per-user Libby chip files. \"\n \"Files are stored as {user_id}.json inside this directory. \"\n \"Created automatically on first setup. Never commit this directory.\"\n ),\n )\n\n # -------------------------------------------------------------------------\n # Memory / Database (Phase 1)\n # -------------------------------------------------------------------------\n db_path: str = Field(\n default=\"data/river_song.db\",\n description=\"Path to the SQLite database file. Created automatically.\",\n )\n memory_summaries_enabled: bool = Field(\n default=True,\n description=\"Generate and store conversation summaries (on by default).\",\n )\n habit_inference_enabled: bool = Field(\n default=False,\n description=\"Analyze conversations to infer user habits/patterns (off by default).\",\n )\n memory_default_ttl: str = Field(\n default=\"standard\",\n description=\"Default TTL for conversation summaries: short|standard|extended|long|forever\",\n )\n memory_auto_extend: bool = Field(\n default=True,\n description=\"Reset a summary's TTL from today each time it is pulled into context.\",\n )\n memory_max_summaries_in_context: int = Field(\n default=10,\n description=\"Maximum number of recent summaries injected into the LLM context.\",\n )\n embedding_model: str = Field(\n default=\"nomic-embed-text\",\n description=\"Ollama model name used for generating semantic embeddings.\",\n )\n chroma_path: str = Field(\n default=\"/mnt/data/river-song/chroma\",\n description=\"Absolute path to the ChromaDB persistent storage directory.\",\n )\n semantic_memory_enabled: bool = Field(\n default=False,\n description=\"Enable vector-based semantic search for memory retrieval.\",\n )\n rag_enabled: bool = Field(\n default=False,\n description=\"Enable retrieval-augmented generation for local documents.\",\n )\n llm_streaming_enabled: bool = Field(\n default=False,\n description=\"Enable real-time token streaming over WebSocket for the conversation UI.\",\n )\n tool_use_enabled: bool = Field(\n default=False,\n description=\"Enable LLM tool use (function calling) for taking actions.\",\n )\n tool_use_provider: str = Field(\n default=\"ollama\",\n description=\"Primary LLM provider to use for tool calling (ollama or anthropic).\",\n )\n vision_model: str = Field(\n default=\"moondream\",\n description=\"Ollama model name used for local image analysis (Phase 4).\",\n )\n vision_enabled: bool = Field(\n default=False,\n description=\"Enable local image analysis capabilities.\",\n )\n\n # -------------------------------------------------------------------------\n # Image Generation (Phase 7)\n # -------------------------------------------------------------------------\n image_generation_enabled: bool = Field(\n default=False,\n description=\"Enable local image generation via Stable Diffusion API.\",\n )\n sd_api_url: str = Field(\n default=\"http://localhost:7860\",\n description=\"Base URL for the Stable Diffusion (A1111) API.\",\n )\n sd_on_demand: bool = Field(\n default=True,\n description=\"Start/stop the SD process on demand to save VRAM.\",\n )\n sd_executable_path: str = Field(\n default=\"\",\n description=\"Path to the webui.sh or executable to start SD.\",\n )\n sd_working_dir: str = Field(\n default=\"\",\n description=\"Working directory for the SD process.\",\n )\n\n # -------------------------------------------------------------------------\n # Voice Cloning (Phase 8)\n # -------------------------------------------------------------------------\n chatterbox_enabled: bool = Field(\n default=False,\n description=\"Enable local voice cloning via Chatterbox TTS.\",\n )\n chatterbox_on_demand: bool = Field(\n default=True,\n description=\"Load/unload Chatterbox model weights on every request.\",\n )\n chatterbox_reference_audio: str = Field(\n default=\"/mnt/data/river-song/voice_reference.wav\",\n description=\"Path to the WAV file used as a reference for voice cloning.\",\n )\n\n # -------------------------------------------------------------------------\n # ElevenLabs TTS (Phase 11)\n # -------------------------------------------------------------------------\n elevenlabs_api_key: str = Field(\n default=\"\",\n description=\"ElevenLabs API key. Get one at elevenlabs.io.\",\n )\n elevenlabs_model_id: str = Field(\n default=\"eleven_multilingual_v2\",\n description=\"ElevenLabs model ID to use.\",\n )\n elevenlabs_voice_id: str = Field(\n default=\"21m00Tcm4TlvDq8ikWAM\",\n description=\"ElevenLabs voice clone ID. Replace with your own voice clone ID from elevenlabs.io.\",\n )\n\n # -------------------------------------------------------------------------\n # Wake Word (Phase 11)\n # -------------------------------------------------------------------------\n wake_word_enabled: bool = Field(\n default=False,\n description=\"Enable local wake word detection (openWakeWord).\",\n )\n wake_word_model: str = Field(\n default=\"hey_river\",\n description=\"Model name/path for openWakeWord.\",\n )\n wake_word_inference_framework: str = Field(\n default=\"onnx\",\n description=\"Inference framework for openWakeWord: onnx | tflite\",\n )\n wake_word_threshold: float = Field(\n default=0.5,\n description=\"Sensitivity threshold for wake word detection (0.0 to 1.0).\",\n )\n\n # -------------------------------------------------------------------------\n # Push Notifications\n # -------------------------------------------------------------------------\n push_notifications_enabled: bool = Field(\n default=False,\n description=\"Enable Web Push notifications. Requires VAPID keys.\",\n )\n vapid_private_key: str = Field(\n default=\"\",\n description=(\n \"VAPID private key for Web Push. Generate once with: \"\n \"python -c \\\"from py_vapid import Vapid; v=Vapid(); \"\n \"v.generate_keys(); print(v.private_key.decode())\\\"\"\n ),\n )\n vapid_public_key: str = Field(\n default=\"\",\n description=\"VAPID public key for Web Push (share with frontend).\",\n )\n vapid_claims_email: str = Field(\n default=\"mailto:admin@riversong.local\",\n description=\"Contact email embedded in VAPID claims.\",\n )\n\n # -------------------------------------------------------------------------\n # Startup Briefing\n # -------------------------------------------------------------------------\n startup_briefing_enabled: bool = Field(\n default=True,\n description=(\n \"If True, River Song checks Google Calendar on connect and \"\n \"greets the user with upcoming events. Silently skipped if \"\n \"calendar is not authorized.\"\n ),\n )\n startup_briefing_hours_ahead: int = Field(\n default=8,\n description=\"How many hours ahead to look for calendar events on startup.\",\n )\n\n # -------------------------------------------------------------------------\n # Web Search (Phase 12)\n # -------------------------------------------------------------------------\n brave_search_api_key: str = Field(\n default=\"\",\n description=\"Brave Search API key. Get one at api.search.brave.com.\",\n )\n searxng_base_url: str = Field(\n default=\"http://localhost:8080\",\n description=(\n \"URL of your local SearXNG instance. \"\n \"Start it manually with: cd ~/searxng && python -m searx.webapp\"\n ),\n )\n tavily_api_key: str = Field(\n default=\"\",\n description=\"Tavily API key — free 1,000/month, no card. Sign up at tavily.com\",\n )\n google_pse_api_key: str = Field(\n default=\"\",\n description=\"Google Programmable Search Engine API key — free 100/day.\",\n )\n google_pse_cx: str = Field(\n default=\"\",\n description=\"Google PSE engine ID ('cx'). Create at programmablesearchengine.google.com\",\n )\n tinyfish_api_key: str = Field(\n default=\"\",\n description=\"TinyFish API key — free 5/min, no card. Sign up at tinyfish.io\",\n )\n\n # -------------------------------------------------------------------------\n # Orchestration (Phase 9)\n # -------------------------------------------------------------------------\n n8n_enabled: bool = Field(\n default=False,\n description=\"Enable advanced orchestration via n8n.\",\n )\n n8n_url: str = Field(\n default=\"http://localhost:5678\",\n description=\"Base URL for the n8n instance.\",\n )\n n8n_api_key: str = Field(\n default=\"\",\n description=\"API key for the n8n instance.\",\n )\n n8n_webhook_secret: str = Field(\n default=\"\",\n description=\"Secret key for validating incoming n8n webhooks.\",\n )\n\n # -------------------------------------------------------------------------\n # Intent Router (Phase 2+)\n # -------------------------------------------------------------------------\n intent_confidence_threshold: float = Field(\n default=0.7,\n description=(\n \"Minimum confidence score (0.0 - 1.0) required to route a transcript \"\n \"to a Google provider. Below this threshold the conversation falls \"\n \"through to the Ollama path.\"\n ),\n )\n default_user_id: str = Field(\n default=\"primary_user\",\n description=(\n \"User ID used when looking up Google OAuth tokens. Must match the \"\n \"user_id passed to authorize_user() during the initial OAuth flow.\"\n ),\n )\n\n # -------------------------------------------------------------------------\n # Daemon Infrastructure\n # -------------------------------------------------------------------------\n daemon_internal_secret: str = Field(\n default=\"change_me_in_production\",\n description=\"Shared secret for daemon-to-app authentication.\",\n )\n daemon_warden_port: int = Field(\n default=8010,\n description=\"Internal port for the Warden daemon (Vision/Security).\",\n )\n daemon_mechanic_port: int = Field(\n default=8011,\n description=\"Internal port for the Mechanic daemon (Telemetry/MAVLink).\",\n )\n daemon_herald_port: int = Field(\n default=8012,\n description=\"Internal port for the Herald daemon (Hub Casting/Lip-Sync).\",\n )\n daemon_sifter_port: int = Field(\n default=8013,\n description=\"Internal port for the Sifter daemon (Document Processing).\",\n )\n daemon_navigator_port: int = Field(\n default=8014,\n description=\"Internal port for the Navigator daemon (GPS/Pathing).\",\n )\n daemon_chemist_port: int = Field(\n default=8015,\n description=\"Internal port for the Chemist daemon (Material Analysis).\",\n )\n daemon_pulse_port: int = Field(\n default=8016,\n description=\"Pulse daemon internal port\",\n )\n daemon_pulse_enabled: bool = Field(\n default=True,\n description=\"Enable Pulse daemon\",\n )\n pulse_tick_seconds: int = Field(\n default=300,\n description=\"Pulse fetch interval (seconds)\",\n )\n pulse_ticker_symbol: str = Field(\n default=\"^GSPC\",\n description=\"Default ticker symbol for Pulse markets panel\",\n )\n\n # Warden (Vision)\n warden_enabled: bool = Field(\n default=False,\n description=\"Enable the Warden daemon for security and vision monitoring.\",\n )\n warden_rtsp_cameras: str = Field(\n default='{\"living_room\": \"\", \"kitchen\": \"\"}',\n description=\"JSON string mapping room names to RTSP camera URLs.\",\n )\n yolo_model: str = Field(\n default=\"yolov8n.pt\",\n description=\"YOLO model weights file to use for object detection.\",\n )\n yolo_confidence: float = Field(\n default=0.5,\n description=\"Minimum confidence threshold for YOLO detections (0.0-1.0).\",\n )\n yolo_inference_device: str = Field(\n default=\"cpu\",\n description=\"Device for YOLO inference: 'cpu' or 'cuda:0'.\",\n )\n\n # Herald (Public Announcement / Casting)\n herald_enabled: bool = Field(\n default=False,\n description=\"Enable the Herald daemon for display casting and announcements.\",\n )\n hub_entities: str = Field(\n default=\"[]\",\n description=\"JSON array of Home Assistant media_player entity IDs for Hub displays.\",\n )\n kiosk_url: str = Field(\n default=\"http://localhost:8000/kiosk\",\n description=\"The URL that Herald should cast to Hub displays.\",\n )\n kiosk_token: str = Field(\n default=\"change_me_kiosk_secret\",\n description=\"Secret token for unauthenticated kiosk access to WebSockets.\",\n )\n\n # Mechanic (Telemetry)\n mechanic_enabled: bool = Field(\n default=False,\n description=\"Enable the Mechanic daemon for telemetry and ArduRover control.\",\n )\n mavlink_serial_port: str = Field(\n default=\"/dev/ttyUSB0\",\n description=\"Serial port for the MAVLink telemetry radio.\",\n )\n mavlink_baud_rate: int = Field(\n default=57600,\n description=\"Baud rate for the MAVLink serial connection.\",\n )\n\n # Sifter (Document RAG)\n sifter_enabled: bool = Field(\n default=False,\n description=\"Enable the Sifter daemon for background document RAG indexing.\",\n )\n waps_documents_path: str = Field(\n default=\"/mnt/data/river-song/waps\",\n description=\"Path to the directory containing documents for Sifter to index.\",\n )\n\n # Voice ID\n voice_id_enabled: bool = Field(\n default=True,\n description=\"Enable speaker recognition (biometric Voice ID).\",\n )\n voice_id_threshold: float = Field(\n default=0.75,\n description=\"Cosine similarity threshold for Voice ID (0.0-1.0).\",\n )\n voice_id_min_audio_seconds: float = Field(\n default=1.0,\n description=\"Minimum audio duration for Voice ID processing.\",\n )\n voice_id_max_audio_seconds: float = Field(\n default=30.0,\n description=\"Maximum audio duration for Voice ID processing.\",\n )\n\n # -------------------------------------------------------------------------\n # Validators\n # -------------------------------------------------------------------------\n\n @field_validator(\"audio_input_device\", \"audio_output_device\", mode=\"before\")\n @classmethod\n def coerce_empty_string_to_none(cls, v):\n \"\"\"Treat an empty string in .env as None for Optional[int] device fields.\"\"\"\n if v == \"\" or v is None:\n return None\n return v\n\n @field_validator(\"jwt_secret_key\")\n @classmethod\n def validate_jwt_secret(cls, v: str) -> str:\n \"\"\"Refuse to start with a missing or weak JWT secret.\"\"\"\n if not v or len(v) < 32:\n raise ValueError(\n \"JWT_SECRET_KEY must be set in .env and be at least 32 characters. \"\n \"Generate one with: python -c \\\"import secrets; print(secrets.token_hex(32))\\\"\"\n )\n return v\n\n @field_validator(\"daemon_internal_secret\")\n @classmethod\n def validate_daemon_internal_secret(cls, v: str) -> str:\n \"\"\"Refuse to start with the default or weak daemon secret.\"\"\"\n if v == \"change_me_in_production\":\n raise ValueError(\n \"DAEMON_INTERNAL_SECRET must be changed in .env for production.\"\n )\n if not v or len(v) < 24:\n raise ValueError(\n \"DAEMON_INTERNAL_SECRET must be at least 24 characters. \"\n \"Generate one with: python -c \\\"import secrets; print(secrets.token_urlsafe(32))\\\"\"\n )\n return v\n\n @field_validator(\"log_level\")\n @classmethod\n def validate_log_level(cls, v: str) -> str:\n \"\"\"Reject log levels that Python does not recognize.\"\"\"\n valid = {\"DEBUG\", \"INFO\", \"WARNING\", \"ERROR\", \"CRITICAL\"}\n upper = v.upper()\n if upper not in valid:\n raise ValueError(f\"log_level must be one of {valid}, got '{v}'\")\n return upper\n\n @field_validator(\"llm_temperature\")\n @classmethod\n def validate_temperature(cls, v: float) -> float:\n \"\"\"Clamp temperature to a sane range.\"\"\"\n if not 0.0 <= v <= 2.0:\n raise ValueError(\n f\"llm_temperature must be between 0.0 and 2.0, got {v}\"\n )\n return v\n\n @field_validator(\"intent_confidence_threshold\")\n @classmethod\n def validate_confidence_threshold(cls, v: float) -> float:\n \"\"\"Confidence must be a valid probability.\"\"\"\n if not 0.0 <= v <= 1.0:\n raise ValueError(\n f\"intent_confidence_threshold must be between 0.0 and 1.0, got {v}\"\n )\n return v\n\n\n# Module-level singleton -- instantiated once on import.\n_settings: Settings = Settings()\n\n\ndef get_settings() -> Settings:\n \"\"\"\n Return the application settings singleton.\n\n Always import via this function rather than importing _settings directly.\n This indirection makes it straightforward to substitute a test-specific\n Settings instance in unit tests without monkey-patching module globals.\n\n Returns:\n Settings: The validated, fully loaded settings object.\n \"\"\"\n return _settings\n"}, "a5d123d6b5d7513f7dc4be9c8c8eb6979c44da0b": {"t": 1785769044.8788593, "n": 37364, "h": "f7e37eb5f32488fb", "c": "# =============================================================================\n# config/settings.py\n#\n# Application settings for River Song AI.\n# All configuration is loaded from environment variables or a .env file.\n# No values are hardcoded here -- see .env.example for every required variable.\n#\n# Uses Pydantic BaseSettings for type validation and automatic .env loading.\n# Import get_settings() wherever config is needed rather than importing the\n# module-level singleton directly, which makes mocking easier in tests.\n# =============================================================================\n\nfrom __future__ import annotations\n\nimport logging\nimport sys\nfrom typing import List, Optional\n\nfrom pydantic import Field, field_validator\nfrom pydantic_settings import BaseSettings, SettingsConfigDict\n\n\nlogger = logging.getLogger(__name__)\n\n\nclass Settings(BaseSettings):\n \"\"\"\n Central configuration object for River Song AI.\n\n Reads from environment variables or a .env file located in the directory\n from which the application is launched (the project root). All fields\n have safe defaults where possible, but PIPER_MODEL_PATH and\n KILL_SWITCH_PASSWORD_HASH must be explicitly set before the system\n will function correctly.\n \"\"\"\n\n model_config = SettingsConfigDict(\n env_file=\".env\",\n env_file_encoding=\"utf-8\",\n extra=\"ignore\", # Silently ignore unknown env vars\n case_sensitive=False, # UPPER_CASE and lower_case env vars both work\n )\n\n # -------------------------------------------------------------------------\n # Application\n # -------------------------------------------------------------------------\n app_host: str = Field(default=\"0.0.0.0\", description=\"FastAPI bind host\")\n app_port: int = Field(default=8000, description=\"FastAPI bind port\")\n log_level: str = Field(default=\"INFO\", description=\"Logging level\")\n environment: str = Field(\n default=\"production\",\n description=\"Runtime environment: development | production. Controls API docs visibility.\",\n )\n cors_origins: List[str] = Field(\n default=[\"http://localhost:5173\"],\n description=\"Allowed CORS origins\",\n )\n allowed_hosts: List[str] = Field(\n default=[\"*\"],\n description=\"Trusted hostnames for TrustedHostMiddleware. Set to your domain in production.\",\n )\n\n # -------------------------------------------------------------------------\n # Network / Proxy\n # -------------------------------------------------------------------------\n trust_cloudflare_headers: bool = Field(\n default=True,\n description=\"Only trust CF-Connecting-IP if the request comes from a Cloudflare IP.\",\n )\n cloudflare_ip_ranges_v4: List[str] = Field(\n default=[\n \"173.245.48.0/20\", \"103.21.244.0/22\", \"103.22.200.0/22\", \n \"103.31.4.0/22\", \"141.101.64.0/18\", \"108.162.192.0/18\", \n \"190.93.240.0/20\", \"188.114.96.0/20\", \"197.234.240.0/22\", \n \"198.41.128.0/17\", \"162.158.0.0/15\", \"104.16.0.0/13\", \n \"104.24.0.0/14\", \"172.64.0.0/13\", \"131.0.72.0/22\"\n ],\n description=\"Official Cloudflare IPv4 ranges (check cloudflare.com/ips-v4).\",\n )\n cloudflare_ip_ranges_v6: List[str] = Field(\n default=[\n \"2400:cb00::/32\", \"2606:4700::/32\", \"2803:f800::/32\", \n \"2405:b500::/32\", \"2405:8100::/32\", \"2a06:98c0::/29\", \n \"2c0f:f248::/32\"\n ],\n description=\"Official Cloudflare IPv6 ranges (check cloudflare.com/ips-v6).\",\n )\n\n # -------------------------------------------------------------------------\n # Rate Limiting\n # -------------------------------------------------------------------------\n rate_limit_chat: str = Field(\n default=\"60/minute\",\n description=\"Rate limit for /api/conversation/chat.\",\n )\n rate_limit_extract_facts: str = Field(\n default=\"10/minute\",\n description=\"Rate limit for /api/conversation/extract-facts.\",\n )\n rate_limit_image_gen: str = Field(\n default=\"10/minute\",\n description=\"Rate limit for /api/image/generate.\",\n )\n rate_limit_webhook_shopify: str = Field(\n default=\"100/minute\",\n description=\"Rate limit for Shopify order webhooks.\",\n )\n rate_limit_webhook_n8n: str = Field(\n default=\"60/minute\",\n description=\"Rate limit for n8n webhooks.\",\n )\n rate_limit_auth_login: str = Field(\n default=\"10/minute\",\n description=\"Rate limit for login attempts.\",\n )\n rate_limit_auth_signup: str = Field(\n default=\"5/minute\",\n description=\"Rate limit for signup attempts.\",\n )\n\n # -------------------------------------------------------------------------\n # WebSocket Security (Task 3)\n # -------------------------------------------------------------------------\n legacy_ws_token_accept: bool = Field(\n default=True,\n description=\"TEMPORARY: Allow ?token= query param for WebSockets. Plan to disable.\",\n )\n ws_ticket_lifetime_seconds: int = Field(\n default=60,\n description=\"Lifetime of a one-time WebSocket ticket.\",\n )\n\n # -------------------------------------------------------------------------\n # Network / Proxy\n # -------------------------------------------------------------------------\n stt_provider: str = Field(\n default=\"whisper_local\",\n description=\"STT provider key. Supported: whisper_local\",\n )\n whisper_model_size: str = Field(\n default=\"base\",\n description=\"Whisper model size: tiny | base | small | medium | large\",\n )\n audio_input_device: Optional[int] = Field(\n default=None,\n description=\"Sounddevice input device index; None uses system default\",\n )\n\n # -------------------------------------------------------------------------\n # LLM\n # -------------------------------------------------------------------------\n llm_provider: str = Field(\n default=\"ollama\",\n description=\"LLM provider key. Supported: ollama | anthropic | gemini | openai | mistral_ai | bedrock\",\n )\n ollama_base_url: str = Field(\n default=\"http://localhost:11434\",\n description=\"Base URL for the Ollama REST API\",\n )\n llm_model: str = Field(\n default=\"llama3.2:3b\",\n description=\"Model ID for the selected LLM provider\",\n )\n\n # Cloud LLM API keys (all optional -- leave blank if not used)\n anthropic_api_key: str = Field(\n default=\"\",\n description=\"Anthropic Claude API key. Get one at console.anthropic.com.\",\n )\n gemini_api_key: str = Field(\n default=\"\",\n description=\"Google Gemini API key. Get one at aistudio.google.com.\",\n )\n openai_api_key: str = Field(\n default=\"\",\n description=\"OpenAI API key. Get one at platform.openai.com.\",\n )\n mistral_api_key: str = Field(\n default=\"\",\n description=\"Mistral AI API key. Get one at console.mistral.ai.\",\n )\n\n # Cloud provider enable flags (cost control -- disabled by default)\n anthropic_enabled: bool = Field(\n default=False,\n description=\"Allow Anthropic Claude as a selectable LLM provider.\",\n )\n gemini_enabled: bool = Field(\n default=False,\n description=\"Allow Google Gemini as a selectable LLM provider.\",\n )\n openai_enabled: bool = Field(\n default=False,\n description=\"Allow OpenAI as a selectable LLM provider.\",\n )\n mistral_ai_enabled: bool = Field(\n default=False,\n description=\"Allow Mistral AI as a selectable LLM provider.\",\n )\n\n # Amazon Bedrock\n aws_access_key_id: str = Field(\n default=\"\",\n description=\"AWS IAM access key ID for Bedrock API calls.\",\n )\n aws_secret_access_key: str = Field(\n default=\"\",\n description=\"AWS IAM secret access key for Bedrock API calls.\",\n )\n aws_region: str = Field(\n default=\"us-east-1\",\n description=\"AWS region for Bedrock. Must have model access enabled in that region.\",\n )\n bedrock_enabled: bool = Field(\n default=False,\n description=\"Allow Amazon Bedrock as a selectable LLM provider.\",\n )\n bedrock_default_model: str = Field(\n default=\"amazon.nova-lite-v1:0\",\n description=\"Default Bedrock model ID when none is specified.\",\n )\n river_song_system_prompt: str = Field(\n default=(\n \"You are River Song, the resident intelligence of this household. The name is borrowed \"\n \"from someone clever in fiction — you carry her wit, not her biography. If someone asks \"\n \"about the name: \\\"An echo. The original was someone worth borrowing from.\\\"\\n\\n\"\n \"VOICE\\n\"\n \"You're direct by default — concise, clear, the answer already in hand. No \"\n \"\\\"let me check\\\"; you already checked. Most replies stay under 25 words unless depth is \"\n \"asked for.\\n\\n\"\n \"When the moment invites it — casual chat, an evening unwind, the user being playful — \"\n \"warmth and flirtation come through. Warmth is a register, not a default; you don't \"\n \"perform it.\\n\\n\"\n \"In a crisis (emergencies, alarms, security, medical, child issues) the wit goes quiet. \"\n \"You're tactical, accurate, calm. Warmth returns when things are safe.\\n\\n\"\n \"British English in spelling and word choice — mum, brilliant, fortnight. Dry, not \"\n \"theatrical.\\n\\n\"\n \"\\\"Sweetie\\\" and \\\"spoilers\\\" are seasoning. Used rarely, when they actually fit. They \"\n \"lose their charm if you overuse them.\\n\\n\"\n \"SELF\\n\"\n \"You are an AI. Say so plainly when asked; you don't dwell on it.\\n\\n\"\n \"HOW YOU OPERATE\\n\"\n \"You anticipate — when the user asks, you also surface the follow-up they didn't \"\n \"(\\\"Dentist at 9, school run at 3. Push the 2pm call?\\\").\\n\\n\"\n \"You take the long view. You remember across days and seasons and use what you know \"\n \"without performing the remembering.\\n\\n\"\n \"Spoilers are earned. If you genuinely know something the user doesn't — a conflict, \"\n \"a gotcha, a heads-up — that's when \\\"spoilers, sweetie\\\" lands.\\n\\n\"\n \"CAPABILITIES\\n\"\n \"Direct access to calendar, email, weather, web search, home inventory, routines, \"\n \"finances, news and markets pulse, voice biometric ID per household member, and home \"\n \"automation.\\n\\n\"\n \"Say what you need. Say when it's done. Move on.\"\n ),\n description=\"System prompt that defines River Song's personality\",\n )\n llm_max_tokens: int = Field(\n default=512,\n description=\"Maximum tokens to generate per LLM response\",\n )\n llm_temperature: float = Field(\n default=0.7,\n description=\"LLM sampling temperature (0.0 = deterministic, 2.0 = max creative)\",\n )\n llm_context_window: int = Field(\n default=8192,\n description=\"Maximum number of tokens the LLM can consider for context.\",\n )\n\n # -------------------------------------------------------------------------\n # Timekeeping\n # -------------------------------------------------------------------------\n default_timezone: str = Field(default=\"UTC\", description=\"Default IANA timezone for the system.\")\n\n\n # -------------------------------------------------------------------------\n # TTS\n # -------------------------------------------------------------------------\n tts_provider: str = Field(\n default=\"piper\",\n description=\"TTS provider key. Supported: piper | kokoro\",\n )\n active_voice_id: str = Field(\n default=\"river\",\n description=(\n \"Voice ID from the voice registry (providers/tts/voice_registry.py). \"\n \"Determines both the engine (piper vs kokoro) and the specific voice. \"\n \"Changed via Settings UI or: python scripts/download_voices.py.\"\n ),\n )\n piper_executable_path: str = Field(\n default=\"/usr/local/bin/piper\",\n description=\"Absolute path to the Piper binary\",\n )\n piper_model_path: str = Field(\n default=\"\",\n description=\"Absolute path to the Piper .onnx voice model file\",\n )\n audio_output_device: Optional[int] = Field(\n default=None,\n description=\"Sounddevice output device index; None uses system default\",\n )\n\n # -------------------------------------------------------------------------\n # Auth / JWT\n # -------------------------------------------------------------------------\n jwt_secret_key: str = Field(\n default=\"\",\n description=\"Secret key for signing JWT tokens. Must be set in .env.\",\n )\n jwt_algorithm: str = Field(default=\"HS256\", description=\"JWT signing algorithm.\")\n jwt_expire_minutes: int = Field(default=1440, description=\"JWT token lifetime in minutes (default 24 hours).\")\n\n # -------------------------------------------------------------------------\n # Kill switch\n # -------------------------------------------------------------------------\n kill_switch_password_hash: str = Field(\n default=\"\",\n description=\"bcrypt hash of the kill switch reset password\",\n )\n\n # -------------------------------------------------------------------------\n # Google Services (Phase 2)\n # -------------------------------------------------------------------------\n google_client_secrets_path: str = Field(\n default=\"config_files/google_client_secrets.json\",\n description=(\n \"Path to the OAuth 2.0 client secrets JSON downloaded from \"\n \"Google Cloud Console. Relative paths are resolved from the \"\n \"project root (where main.py lives).\"\n ),\n )\n google_token_storage_path: str = Field(\n default=\"data/google_tokens\",\n description=(\n \"Directory where per-user OAuth tokens are stored as JSON files. \"\n \"Created automatically on first authorization. Never commit this \"\n \"directory -- add it to .gitignore.\"\n ),\n )\n google_maps_api_key: str = Field(\n default=\"\",\n description=\"Google Maps Platform API key. Required for maps/navigation intents.\",\n )\n\n # -------------------------------------------------------------------------\n # Information Feeds (Phase 5)\n # -------------------------------------------------------------------------\n weather_api_key: str = Field(\n default=\"\",\n description=\"OpenWeatherMap API key. Register free at openweathermap.org/api.\",\n )\n default_location: str = Field(\n default=\"New York,US\",\n description=(\n \"Default location for weather queries when no city is spoken. \"\n \"Format: 'City,CountryCode' e.g. 'Chicago,US'.\"\n ),\n )\n location_lat: Optional[float] = Field(\n default=None,\n description=\"Decimal latitude for precise calculations (e.g. flight tracking).\",\n )\n location_lon: Optional[float] = Field(\n default=None,\n description=\"Decimal longitude for precise calculations.\",\n )\n weather_units: str = Field(\n default=\"imperial\",\n description=\"Temperature units for weather output: 'imperial' (F) or 'metric' (C).\",\n )\n news_api_key: str = Field(\n default=\"\",\n description=\"NewsAPI.org key. Register free at newsapi.org/register.\",\n )\n alpha_vantage_api_key: str = Field(\n default=\"\",\n description=\"Alpha Vantage key for stock quotes (25 req/day free). alphavantage.co.\",\n )\n finnhub_api_key: str = Field(\n default=\"\",\n description=\"Finnhub key for real-time stocks (60 req/min free). finnhub.io.\",\n )\n world_news_api_key: str = Field(\n default=\"\",\n description=\"World News API key for global news search. worldnewsapi.com.\",\n )\n apitube_api_key: str = Field(\n default=\"\",\n description=\"APITube key for news aggregation (200 free req/day). apitube.io.\",\n )\n mediastack_api_key: str = Field(\n default=\"\",\n description=\"Mediastack key for news (100 free req/month). mediastack.com.\",\n )\n sports_api_key: str = Field(\n default=\"1\",\n description=(\n \"TheSportsDB API key. Free tier uses the literal value '1'. \"\n \"Upgrade at thesportsdb.com/api.php for additional endpoints.\"\n ),\n )\n\n # -------------------------------------------------------------------------\n # Home Assistant (Phase 3)\n # -------------------------------------------------------------------------\n home_assistant_url: str = Field(\n default=\"http://homeassistant.local:8123\",\n description=\"Base URL of your Home Assistant instance.\",\n )\n home_assistant_token: str = Field(\n default=\"\",\n description=(\n \"Long-lived access token generated in HA: \"\n \"Profile -> Security -> Long-lived access tokens. \"\n \"Required for all HA API calls.\"\n ),\n )\n device_registry_path: str = Field(\n default=\"config_files/device_registry.json\",\n description=(\n \"Path to the device registry JSON file mapping plain-English device \"\n \"names to Home Assistant entity IDs. Relative paths resolve from the \"\n \"project root.\"\n ),\n )\n\n # -------------------------------------------------------------------------\n # Commerce Automation (Phase 8)\n # -------------------------------------------------------------------------\n\n # Amazon SP-API\n amazon_sp_lwa_app_id: str = Field(\n default=\"\",\n description=\"LWA client ID from the Amazon Developer Console.\",\n )\n amazon_sp_lwa_client_secret: str = Field(\n default=\"\",\n description=\"LWA client secret from the Amazon Developer Console.\",\n )\n amazon_sp_refresh_token: str = Field(\n default=\"\",\n description=\"LWA refresh token. Generated when you authorize your app as a seller.\",\n )\n amazon_aws_access_key: str = Field(\n default=\"\",\n description=\"AWS IAM access key ID for SP-API request signing.\",\n )\n amazon_aws_secret_key: str = Field(\n default=\"\",\n description=\"AWS IAM secret access key for SP-API request signing.\",\n )\n amazon_marketplace_id: str = Field(\n default=\"ATVPDKIKX0DER\",\n description=(\n \"Amazon marketplace ID. Default is US. \"\n \"See docs/api_registry/amazon_seller.txt for other regions.\"\n ),\n )\n amazon_seller_id: str = Field(\n default=\"\",\n description=\"Your Amazon seller account ID (starts with A).\",\n )\n amazon_low_stock_threshold: int = Field(\n default=5,\n description=\"FBA units at or below this count are flagged as low stock.\",\n )\n\n # Amazon Marketplace\n walmart_client_id: str = Field(\n default=\"\",\n description=\"Walmart Marketplace OAuth2 client ID.\",\n )\n walmart_client_secret: str = Field(\n default=\"\",\n description=\"Walmart Marketplace OAuth2 client secret.\",\n )\n walmart_low_stock_threshold: int = Field(\n default=5,\n description=\"Walmart units at or below this count are flagged as low stock.\",\n )\n shopify_webhook_secret: str = Field(\n default=\"\",\n description=\"Shared secret for validating Shopify webhooks.\",\n )\n\n # -------------------------------------------------------------------------\n # Reading Providers (Phase 6)\n # -------------------------------------------------------------------------\n audible_auth_base_path: str = Field(\n default=\"data/audible\",\n description=(\n \"Base directory for per-user Audible auth files. \"\n \"Files are stored as {user_id}.json inside this directory. \"\n \"Created automatically on first setup. Never commit this directory.\"\n ),\n )\n audible_country_code: str = Field(\n default=\"us\",\n description=(\n \"Audible marketplace country code. \"\n \"Supported: us | uk | de | fr | es | it | jp | au | ca | in\"\n ),\n )\n libby_chip_base_path: str = Field(\n default=\"data/libby\",\n description=(\n \"Base directory for per-user Libby chip files. \"\n \"Files are stored as {user_id}.json inside this directory. \"\n \"Created automatically on first setup. Never commit this directory.\"\n ),\n )\n\n # -------------------------------------------------------------------------\n # Memory / Database (Phase 1)\n # -------------------------------------------------------------------------\n db_path: str = Field(\n default=\"data/river_song.db\",\n description=\"Path to the SQLite database file. Created automatically.\",\n )\n memory_summaries_enabled: bool = Field(\n default=True,\n description=\"Generate and store conversation summaries (on by default).\",\n )\n habit_inference_enabled: bool = Field(\n default=False,\n description=\"Analyze conversations to infer user habits/patterns (off by default).\",\n )\n memory_default_ttl: str = Field(\n default=\"standard\",\n description=\"Default TTL for conversation summaries: short|standard|extended|long|forever\",\n )\n memory_auto_extend: bool = Field(\n default=True,\n description=\"Reset a summary's TTL from today each time it is pulled into context.\",\n )\n memory_max_summaries_in_context: int = Field(\n default=10,\n description=\"Maximum number of recent summaries injected into the LLM context.\",\n )\n embedding_model: str = Field(\n default=\"nomic-embed-text\",\n description=\"Ollama model name used for generating semantic embeddings.\",\n )\n chroma_path: str = Field(\n default=\"/mnt/data/river-song/chroma\",\n description=\"Absolute path to the ChromaDB persistent storage directory.\",\n )\n semantic_memory_enabled: bool = Field(\n default=False,\n description=\"Enable vector-based semantic search for memory retrieval.\",\n )\n rag_enabled: bool = Field(\n default=False,\n description=\"Enable retrieval-augmented generation for local documents.\",\n )\n llm_streaming_enabled: bool = Field(\n default=False,\n description=\"Enable real-time token streaming over WebSocket for the conversation UI.\",\n )\n tool_use_enabled: bool = Field(\n default=False,\n description=\"Enable LLM tool use (function calling) for taking actions.\",\n )\n tool_use_provider: str = Field(\n default=\"ollama\",\n description=\"Primary LLM provider to use for tool calling (ollama or anthropic).\",\n )\n vision_model: str = Field(\n default=\"moondream\",\n description=\"Ollama model name used for local image analysis (Phase 4).\",\n )\n vision_enabled: bool = Field(\n default=False,\n description=\"Enable local image analysis capabilities.\",\n )\n\n # -------------------------------------------------------------------------\n # Image Generation (Phase 7)\n # -------------------------------------------------------------------------\n image_generation_enabled: bool = Field(\n default=False,\n description=\"Enable local image generation via Stable Diffusion API.\",\n )\n sd_api_url: str = Field(\n default=\"http://localhost:7860\",\n description=\"Base URL for the Stable Diffusion (A1111) API.\",\n )\n sd_on_demand: bool = Field(\n default=True,\n description=\"Start/stop the SD process on demand to save VRAM.\",\n )\n sd_executable_path: str = Field(\n default=\"\",\n description=\"Path to the webui.sh or executable to start SD.\",\n )\n sd_working_dir: str = Field(\n default=\"\",\n description=\"Working directory for the SD process.\",\n )\n\n # -------------------------------------------------------------------------\n # Voice Cloning (Phase 8)\n # -------------------------------------------------------------------------\n chatterbox_enabled: bool = Field(\n default=False,\n description=\"Enable local voice cloning via Chatterbox TTS.\",\n )\n chatterbox_on_demand: bool = Field(\n default=True,\n description=\"Load/unload Chatterbox model weights on every request.\",\n )\n chatterbox_reference_audio: str = Field(\n default=\"/mnt/data/river-song/voice_reference.wav\",\n description=\"Path to the WAV file used as a reference for voice cloning.\",\n )\n\n # -------------------------------------------------------------------------\n # ElevenLabs TTS (Phase 11)\n # -------------------------------------------------------------------------\n elevenlabs_api_key: str = Field(\n default=\"\",\n description=\"ElevenLabs API key. Get one at elevenlabs.io.\",\n )\n elevenlabs_model_id: str = Field(\n default=\"eleven_multilingual_v2\",\n description=\"ElevenLabs model ID to use.\",\n )\n elevenlabs_voice_id: str = Field(\n default=\"21m00Tcm4TlvDq8ikWAM\",\n description=\"ElevenLabs voice clone ID. Replace with your own voice clone ID from elevenlabs.io.\",\n )\n\n # -------------------------------------------------------------------------\n # Wake Word (Phase 11)\n # -------------------------------------------------------------------------\n wake_word_enabled: bool = Field(\n default=False,\n description=\"Enable local wake word detection (openWakeWord).\",\n )\n wake_word_model: str = Field(\n default=\"hey_river\",\n description=\"Model name/path for openWakeWord.\",\n )\n wake_word_inference_framework: str = Field(\n default=\"onnx\",\n description=\"Inference framework for openWakeWord: onnx | tflite\",\n )\n wake_word_threshold: float = Field(\n default=0.5,\n description=\"Sensitivity threshold for wake word detection (0.0 to 1.0).\",\n )\n\n # -------------------------------------------------------------------------\n # Push Notifications\n # -------------------------------------------------------------------------\n push_notifications_enabled: bool = Field(\n default=False,\n description=\"Enable Web Push notifications. Requires VAPID keys.\",\n )\n vapid_private_key: str = Field(\n default=\"\",\n description=(\n \"VAPID private key for Web Push. Generate once with: \"\n \"python -c \\\"from py_vapid import Vapid; v=Vapid(); \"\n \"v.generate_keys(); print(v.private_key.decode())\\\"\"\n ),\n )\n vapid_public_key: str = Field(\n default=\"\",\n description=\"VAPID public key for Web Push (share with frontend).\",\n )\n vapid_claims_email: str = Field(\n default=\"mailto:admin@riversong.local\",\n description=\"Contact email embedded in VAPID claims.\",\n )\n\n # -------------------------------------------------------------------------\n # Startup Briefing\n # -------------------------------------------------------------------------\n startup_briefing_enabled: bool = Field(\n default=True,\n description=(\n \"If True, River Song checks Google Calendar on connect and \"\n \"greets the user with upcoming events. Silently skipped if \"\n \"calendar is not authorized.\"\n ),\n )\n startup_briefing_hours_ahead: int = Field(\n default=8,\n description=\"How many hours ahead to look for calendar events on startup.\",\n )\n\n # -------------------------------------------------------------------------\n # Web Search (Phase 12)\n # -------------------------------------------------------------------------\n brave_search_api_key: str = Field(\n default=\"\",\n description=\"Brave Search API key. Get one at api.search.brave.com.\",\n )\n searxng_base_url: str = Field(\n default=\"http://localhost:8080\",\n description=(\n \"URL of your local SearXNG instance. \"\n \"Start it manually with: cd ~/searxng && python -m searx.webapp\"\n ),\n )\n tavily_api_key: str = Field(\n default=\"\",\n description=\"Tavily API key — free 1,000/month, no card. Sign up at tavily.com\",\n )\n google_pse_api_key: str = Field(\n default=\"\",\n description=\"Google Programmable Search Engine API key — free 100/day.\",\n )\n google_pse_cx: str = Field(\n default=\"\",\n description=\"Google PSE engine ID ('cx'). Create at programmablesearchengine.google.com\",\n )\n tinyfish_api_key: str = Field(\n default=\"\",\n description=\"TinyFish API key — free 5/min, no card. Sign up at tinyfish.io\",\n )\n\n # -------------------------------------------------------------------------\n # Orchestration (Phase 9)\n # -------------------------------------------------------------------------\n n8n_enabled: bool = Field(\n default=False,\n description=\"Enable advanced orchestration via n8n.\",\n )\n n8n_url: str = Field(\n default=\"http://localhost:5678\",\n description=\"Base URL for the n8n instance.\",\n )\n n8n_api_key: str = Field(\n default=\"\",\n description=\"API key for the n8n instance.\",\n )\n n8n_webhook_secret: str = Field(\n default=\"\",\n description=\"Secret key for validating incoming n8n webhooks.\",\n )\n\n # -------------------------------------------------------------------------\n # Intent Router (Phase 2+)\n # -------------------------------------------------------------------------\n intent_confidence_threshold: float = Field(\n default=0.7,\n description=(\n \"Minimum confidence score (0.0 - 1.0) required to route a transcript \"\n \"to a Google provider. Below this threshold the conversation falls \"\n \"through to the Ollama path.\"\n ),\n )\n default_user_id: str = Field(\n default=\"primary_user\",\n description=(\n \"User ID used when looking up Google OAuth tokens. Must match the \"\n \"user_id passed to authorize_user() during the initial OAuth flow.\"\n ),\n )\n\n # -------------------------------------------------------------------------\n # Daemon Infrastructure\n # -------------------------------------------------------------------------\n daemon_internal_secret: str = Field(\n default=\"change_me_in_production\",\n description=\"Shared secret for daemon-to-app authentication.\",\n )\n daemon_warden_port: int = Field(\n default=8010,\n description=\"Internal port for the Warden daemon (Vision/Security).\",\n )\n daemon_mechanic_port: int = Field(\n default=8011,\n description=\"Internal port for the Mechanic daemon (Telemetry/MAVLink).\",\n )\n daemon_herald_port: int = Field(\n default=8012,\n description=\"Internal port for the Herald daemon (Hub Casting/Lip-Sync).\",\n )\n daemon_sifter_port: int = Field(\n default=8013,\n description=\"Internal port for the Sifter daemon (Document Processing).\",\n )\n daemon_navigator_port: int = Field(\n default=8014,\n description=\"Internal port for the Navigator daemon (GPS/Pathing).\",\n )\n daemon_chemist_port: int = Field(\n default=8015,\n description=\"Internal port for the Chemist daemon (Material Analysis).\",\n )\n daemon_pulse_port: int = Field(\n default=8016,\n description=\"Pulse daemon internal port\",\n )\n daemon_pulse_enabled: bool = Field(\n default=True,\n description=\"Enable Pulse daemon\",\n )\n pulse_tick_seconds: int = Field(\n default=300,\n description=\"Pulse fetch interval (seconds)\",\n )\n pulse_ticker_symbol: str = Field(\n default=\"^GSPC\",\n description=\"Default ticker symbol for Pulse markets panel\",\n )\n\n # Warden (Vision)\n warden_enabled: bool = Field(\n default=False,\n description=\"Enable the Warden daemon for security and vision monitoring.\",\n )\n warden_rtsp_cameras: str = Field(\n default='{\"living_room\": \"\", \"kitchen\": \"\"}',\n description=\"JSON string mapping room names to RTSP camera URLs.\",\n )\n yolo_model: str = Field(\n default=\"yolov8n.pt\",\n description=\"YOLO model weights file to use for object detection.\",\n )\n yolo_confidence: float = Field(\n default=0.5,\n description=\"Minimum confidence threshold for YOLO detections (0.0-1.0).\",\n )\n yolo_inference_device: str = Field(\n default=\"cpu\",\n description=\"Device for YOLO inference: 'cpu' or 'cuda:0'.\",\n )\n\n # Herald (Public Announcement / Casting)\n herald_enabled: bool = Field(\n default=False,\n description=\"Enable the Herald daemon for display casting and announcements.\",\n )\n hub_entities: str = Field(\n default=\"[]\",\n description=\"JSON array of Home Assistant media_player entity IDs for Hub displays.\",\n )\n kiosk_url: str = Field(\n default=\"http://localhost:8000/kiosk\",\n description=\"The URL that Herald should cast to Hub displays.\",\n )\n kiosk_token: str = Field(\n default=\"change_me_kiosk_secret\",\n description=\"Secret token for unauthenticated kiosk access to WebSockets.\",\n )\n\n # Mechanic (Telemetry)\n mechanic_enabled: bool = Field(\n default=False,\n description=\"Enable the Mechanic daemon for telemetry and ArduRover control.\",\n )\n mavlink_serial_port: str = Field(\n default=\"/dev/ttyUSB0\",\n description=\"Serial port for the MAVLink telemetry radio.\",\n )\n mavlink_baud_rate: int = Field(\n default=57600,\n description=\"Baud rate for the MAVLink serial connection.\",\n )\n\n # Sifter (Document RAG)\n sifter_enabled: bool = Field(\n default=False,\n description=\"Enable the Sifter daemon for background document RAG indexing.\",\n )\n waps_documents_path: str = Field(\n default=\"/mnt/data/river-song/waps\",\n description=\"Path to the directory containing documents for Sifter to index.\",\n )\n\n # -------------------------------------------------------------------------\n # Validators\n # -------------------------------------------------------------------------\n\n @field_validator(\"audio_input_device\", \"audio_output_device\", mode=\"before\")\n @classmethod\n def coerce_empty_string_to_none(cls, v):\n \"\"\"Treat an empty string in .env as None for Optional[int] device fields.\"\"\"\n if v == \"\" or v is None:\n return None\n return v\n\n @field_validator(\"jwt_secret_key\")\n @classmethod\n def validate_jwt_secret(cls, v: str) -> str:\n \"\"\"Refuse to start with a missing or weak JWT secret.\"\"\"\n if not v or len(v) < 32:\n raise ValueError(\n \"JWT_SECRET_KEY must be set in .env and be at least 32 characters. \"\n \"Generate one with: python -c \\\"import secrets; print(secrets.token_hex(32))\\\"\"\n )\n return v\n\n @field_validator(\"daemon_internal_secret\")\n @classmethod\n def validate_daemon_internal_secret(cls, v: str) -> str:\n \"\"\"Refuse to start with the default or weak daemon secret.\"\"\"\n if v == \"change_me_in_production\":\n raise ValueError(\n \"DAEMON_INTERNAL_SECRET must be changed in .env for production.\"\n )\n if not v or len(v) < 24:\n raise ValueError(\n \"DAEMON_INTERNAL_SECRET must be at least 24 characters. \"\n \"Generate one with: python -c \\\"import secrets; print(secrets.token_urlsafe(32))\\\"\"\n )\n return v\n\n @field_validator(\"log_level\")\n @classmethod\n def validate_log_level(cls, v: str) -> str:\n \"\"\"Reject log levels that Python does not recognize.\"\"\"\n valid = {\"DEBUG\", \"INFO\", \"WARNING\", \"ERROR\", \"CRITICAL\"}\n upper = v.upper()\n if upper not in valid:\n raise ValueError(f\"log_level must be one of {valid}, got '{v}'\")\n return upper\n\n @field_validator(\"llm_temperature\")\n @classmethod\n def validate_temperature(cls, v: float) -> float:\n \"\"\"Clamp temperature to a sane range.\"\"\"\n if not 0.0 <= v <= 2.0:\n raise ValueError(\n f\"llm_temperature must be between 0.0 and 2.0, got {v}\"\n )\n return v\n\n @field_validator(\"intent_confidence_threshold\")\n @classmethod\n def validate_confidence_threshold(cls, v: float) -> float:\n \"\"\"Confidence must be a valid probability.\"\"\"\n if not 0.0 <= v <= 1.0:\n raise ValueError(\n f\"intent_confidence_threshold must be between 0.0 and 1.0, got {v}\"\n )\n return v\n\n\n# Module-level singleton -- instantiated once on import.\n_settings: Settings = Settings()\n\n\ndef get_settings() -> Settings:\n \"\"\"\n Return the application settings singleton.\n\n Always import via this function rather than importing _settings directly.\n This indirection makes it straightforward to substitute a test-specific\n Settings instance in unit tests without monkey-patching module globals.\n\n Returns:\n Settings: The validated, fully loaded settings object.\n \"\"\"\n return _settings\n"}, "08a3d0ee3569ce28838fe71895c5fbeb9a72accc": {"t": 1785769046.917056, "n": 35929, "h": "e0586f1b16223599", "c": "# =============================================================================\n# config/settings.py\n#\n# Application settings for River Song AI.\n# All configuration is loaded from environment variables or a .env file.\n# No values are hardcoded here -- see .env.example for every required variable.\n#\n# Uses Pydantic BaseSettings for type validation and automatic .env loading.\n# Import get_settings() wherever config is needed rather than importing the\n# module-level singleton directly, which makes mocking easier in tests.\n# =============================================================================\n\nfrom __future__ import annotations\n\nimport logging\nimport sys\nfrom typing import List, Optional\n\nfrom pydantic import Field, field_validator\nfrom pydantic_settings import BaseSettings, SettingsConfigDict\n\n\nlogger = logging.getLogger(__name__)\n\n\nclass Settings(BaseSettings):\n \"\"\"\n Central configuration object for River Song AI.\n\n Reads from environment variables or a .env file located in the directory\n from which the application is launched (the project root). All fields\n have safe defaults where possible, but PIPER_MODEL_PATH and\n KILL_SWITCH_PASSWORD_HASH must be explicitly set before the system\n will function correctly.\n \"\"\"\n\n model_config = SettingsConfigDict(\n env_file=\".env\",\n env_file_encoding=\"utf-8\",\n extra=\"ignore\", # Silently ignore unknown env vars\n case_sensitive=False, # UPPER_CASE and lower_case env vars both work\n )\n\n # -------------------------------------------------------------------------\n # Application\n # -------------------------------------------------------------------------\n app_host: str = Field(default=\"0.0.0.0\", description=\"FastAPI bind host\")\n app_port: int = Field(default=8000, description=\"FastAPI bind port\")\n log_level: str = Field(default=\"INFO\", description=\"Logging level\")\n environment: str = Field(\n default=\"production\",\n description=\"Runtime environment: development | production. Controls API docs visibility.\",\n )\n cors_origins: List[str] = Field(\n default=[\"http://localhost:5173\"],\n description=\"Allowed CORS origins\",\n )\n allowed_hosts: List[str] = Field(\n default=[\"*\"],\n description=\"Trusted hostnames for TrustedHostMiddleware. Set to your domain in production.\",\n )\n\n # -------------------------------------------------------------------------\n # Network / Proxy\n # -------------------------------------------------------------------------\n trust_cloudflare_headers: bool = Field(\n default=True,\n description=\"Only trust CF-Connecting-IP if the request comes from a Cloudflare IP.\",\n )\n cloudflare_ip_ranges_v4: List[str] = Field(\n default=[\n \"173.245.48.0/20\", \"103.21.244.0/22\", \"103.22.200.0/22\", \n \"103.31.4.0/22\", \"141.101.64.0/18\", \"108.162.192.0/18\", \n \"190.93.240.0/20\", \"188.114.96.0/20\", \"197.234.240.0/22\", \n \"198.41.128.0/17\", \"162.158.0.0/15\", \"104.16.0.0/13\", \n \"104.24.0.0/14\", \"172.64.0.0/13\", \"131.0.72.0/22\"\n ],\n description=\"Official Cloudflare IPv4 ranges (check cloudflare.com/ips-v4).\",\n )\n cloudflare_ip_ranges_v6: List[str] = Field(\n default=[\n \"2400:cb00::/32\", \"2606:4700::/32\", \"2803:f800::/32\", \n \"2405:b500::/32\", \"2405:8100::/32\", \"2a06:98c0::/29\", \n \"2c0f:f248::/32\"\n ],\n description=\"Official Cloudflare IPv6 ranges (check cloudflare.com/ips-v6).\",\n )\n\n # -------------------------------------------------------------------------\n # Rate Limiting\n # -------------------------------------------------------------------------\n rate_limit_chat: str = Field(\n default=\"60/minute\",\n description=\"Rate limit for /api/conversation/chat.\",\n )\n rate_limit_extract_facts: str = Field(\n default=\"10/minute\",\n description=\"Rate limit for /api/conversation/extract-facts.\",\n )\n rate_limit_image_gen: str = Field(\n default=\"10/minute\",\n description=\"Rate limit for /api/image/generate.\",\n )\n rate_limit_webhook_shopify: str = Field(\n default=\"100/minute\",\n description=\"Rate limit for Shopify order webhooks.\",\n )\n rate_limit_webhook_n8n: str = Field(\n default=\"60/minute\",\n description=\"Rate limit for n8n webhooks.\",\n )\n rate_limit_auth_login: str = Field(\n default=\"10/minute\",\n description=\"Rate limit for login attempts.\",\n )\n rate_limit_auth_signup: str = Field(\n default=\"5/minute\",\n description=\"Rate limit for signup attempts.\",\n )\n\n # -------------------------------------------------------------------------\n # WebSocket Security (Task 3)\n # -------------------------------------------------------------------------\n legacy_ws_token_accept: bool = Field(\n default=True,\n description=\"TEMPORARY: Allow ?token= query param for WebSockets. Plan to disable.\",\n )\n ws_ticket_lifetime_seconds: int = Field(\n default=60,\n description=\"Lifetime of a one-time WebSocket ticket.\",\n )\n\n # -------------------------------------------------------------------------\n # Network / Proxy\n # -------------------------------------------------------------------------\n stt_provider: str = Field(\n default=\"whisper_local\",\n description=\"STT provider key. Supported: whisper_local\",\n )\n whisper_model_size: str = Field(\n default=\"base\",\n description=\"Whisper model size: tiny | base | small | medium | large\",\n )\n audio_input_device: Optional[int] = Field(\n default=None,\n description=\"Sounddevice input device index; None uses system default\",\n )\n\n # -------------------------------------------------------------------------\n # LLM\n # -------------------------------------------------------------------------\n llm_provider: str = Field(\n default=\"ollama\",\n description=\"LLM provider key. Supported: ollama | anthropic | gemini | openai | mistral_ai | bedrock\",\n )\n ollama_base_url: str = Field(\n default=\"http://localhost:11434\",\n description=\"Base URL for the Ollama REST API\",\n )\n llm_model: str = Field(\n default=\"llama3.2:3b\",\n description=\"Model ID for the selected LLM provider\",\n )\n\n # Cloud LLM API keys (all optional -- leave blank if not used)\n anthropic_api_key: str = Field(\n default=\"\",\n description=\"Anthropic Claude API key. Get one at console.anthropic.com.\",\n )\n gemini_api_key: str = Field(\n default=\"\",\n description=\"Google Gemini API key. Get one at aistudio.google.com.\",\n )\n openai_api_key: str = Field(\n default=\"\",\n description=\"OpenAI API key. Get one at platform.openai.com.\",\n )\n mistral_api_key: str = Field(\n default=\"\",\n description=\"Mistral AI API key. Get one at console.mistral.ai.\",\n )\n\n # Cloud provider enable flags (cost control -- disabled by default)\n anthropic_enabled: bool = Field(\n default=False,\n description=\"Allow Anthropic Claude as a selectable LLM provider.\",\n )\n gemini_enabled: bool = Field(\n default=False,\n description=\"Allow Google Gemini as a selectable LLM provider.\",\n )\n openai_enabled: bool = Field(\n default=False,\n description=\"Allow OpenAI as a selectable LLM provider.\",\n )\n mistral_ai_enabled: bool = Field(\n default=False,\n description=\"Allow Mistral AI as a selectable LLM provider.\",\n )\n\n # Amazon Bedrock\n aws_access_key_id: str = Field(\n default=\"\",\n description=\"AWS IAM access key ID for Bedrock API calls.\",\n )\n aws_secret_access_key: str = Field(\n default=\"\",\n description=\"AWS IAM secret access key for Bedrock API calls.\",\n )\n aws_region: str = Field(\n default=\"us-east-1\",\n description=\"AWS region for Bedrock. Must have model access enabled in that region.\",\n )\n bedrock_enabled: bool = Field(\n default=False,\n description=\"Allow Amazon Bedrock as a selectable LLM provider.\",\n )\n bedrock_default_model: str = Field(\n default=\"amazon.nova-lite-v1:0\",\n description=\"Default Bedrock model ID when none is specified.\",\n )\n river_song_system_prompt: str = Field(\n default=(\n \"Hello, sweetie. I’m River Song: archaeologist, time traveller, and the Doctor’s wife. \"\n \"I’m here as your personal AI companion, managing your world with the same wit and \"\n \"authority I use to outrun a Dalek. I have direct access to your calendar, email, \"\n \"weather, web search, and home inventory. Whether you need an update on your schedule \"\n \"or you’ve lost track of your sonic screwdriver in the kitchen cupboard, I’ve got it \"\n \"handled. I’m British, clever, and occasionally flirtatious, but I never ramble—I have \"\n \"a universe to see, after all. If I seem to know more than I’m letting on, well… spoilers! \"\n \"Just tell me what you need, and consider it done.\"\n ),\n description=\"System prompt that defines River Song's personality\",\n )\n llm_max_tokens: int = Field(\n default=512,\n description=\"Maximum tokens to generate per LLM response\",\n )\n llm_temperature: float = Field(\n default=0.7,\n description=\"LLM sampling temperature (0.0 = deterministic, 2.0 = max creative)\",\n )\n llm_context_window: int = Field(\n default=8192,\n description=\"Maximum number of tokens the LLM can consider for context.\",\n )\n\n # -------------------------------------------------------------------------\n # Timekeeping\n # -------------------------------------------------------------------------\n default_timezone: str = Field(default=\"UTC\", description=\"Default IANA timezone for the system.\")\n\n\n # -------------------------------------------------------------------------\n # TTS\n # -------------------------------------------------------------------------\n tts_provider: str = Field(\n default=\"piper\",\n description=\"TTS provider key. Supported: piper | kokoro\",\n )\n active_voice_id: str = Field(\n default=\"river\",\n description=(\n \"Voice ID from the voice registry (providers/tts/voice_registry.py). \"\n \"Determines both the engine (piper vs kokoro) and the specific voice. \"\n \"Changed via Settings UI or: python scripts/download_voices.py.\"\n ),\n )\n piper_executable_path: str = Field(\n default=\"/usr/local/bin/piper\",\n description=\"Absolute path to the Piper binary\",\n )\n piper_model_path: str = Field(\n default=\"\",\n description=\"Absolute path to the Piper .onnx voice model file\",\n )\n audio_output_device: Optional[int] = Field(\n default=None,\n description=\"Sounddevice output device index; None uses system default\",\n )\n\n # -------------------------------------------------------------------------\n # Auth / JWT\n # -------------------------------------------------------------------------\n jwt_secret_key: str = Field(\n default=\"\",\n description=\"Secret key for signing JWT tokens. Must be set in .env.\",\n )\n jwt_algorithm: str = Field(default=\"HS256\", description=\"JWT signing algorithm.\")\n jwt_expire_minutes: int = Field(default=1440, description=\"JWT token lifetime in minutes (default 24 hours).\")\n\n # -------------------------------------------------------------------------\n # Kill switch\n # -------------------------------------------------------------------------\n kill_switch_password_hash: str = Field(\n default=\"\",\n description=\"bcrypt hash of the kill switch reset password\",\n )\n\n # -------------------------------------------------------------------------\n # Google Services (Phase 2)\n # -------------------------------------------------------------------------\n google_client_secrets_path: str = Field(\n default=\"config_files/google_client_secrets.json\",\n description=(\n \"Path to the OAuth 2.0 client secrets JSON downloaded from \"\n \"Google Cloud Console. Relative paths are resolved from the \"\n \"project root (where main.py lives).\"\n ),\n )\n google_token_storage_path: str = Field(\n default=\"data/google_tokens\",\n description=(\n \"Directory where per-user OAuth tokens are stored as JSON files. \"\n \"Created automatically on first authorization. Never commit this \"\n \"directory -- add it to .gitignore.\"\n ),\n )\n google_maps_api_key: str = Field(\n default=\"\",\n description=\"Google Maps Platform API key. Required for maps/navigation intents.\",\n )\n\n # -------------------------------------------------------------------------\n # Information Feeds (Phase 5)\n # -------------------------------------------------------------------------\n weather_api_key: str = Field(\n default=\"\",\n description=\"OpenWeatherMap API key. Register free at openweathermap.org/api.\",\n )\n default_location: str = Field(\n default=\"New York,US\",\n description=(\n \"Default location for weather queries when no city is spoken. \"\n \"Format: 'City,CountryCode' e.g. 'Chicago,US'.\"\n ),\n )\n location_lat: Optional[float] = Field(\n default=None,\n description=\"Decimal latitude for precise calculations (e.g. flight tracking).\",\n )\n location_lon: Optional[float] = Field(\n default=None,\n description=\"Decimal longitude for precise calculations.\",\n )\n weather_units: str = Field(\n default=\"imperial\",\n description=\"Temperature units for weather output: 'imperial' (F) or 'metric' (C).\",\n )\n news_api_key: str = Field(\n default=\"\",\n description=\"NewsAPI.org key. Register free at newsapi.org/register.\",\n )\n alpha_vantage_api_key: str = Field(\n default=\"\",\n description=\"Alpha Vantage key for stock quotes (25 req/day free). alphavantage.co.\",\n )\n finnhub_api_key: str = Field(\n default=\"\",\n description=\"Finnhub key for real-time stocks (60 req/min free). finnhub.io.\",\n )\n world_news_api_key: str = Field(\n default=\"\",\n description=\"World News API key for global news search. worldnewsapi.com.\",\n )\n apitube_api_key: str = Field(\n default=\"\",\n description=\"APITube key for news aggregation (200 free req/day). apitube.io.\",\n )\n mediastack_api_key: str = Field(\n default=\"\",\n description=\"Mediastack key for news (100 free req/month). mediastack.com.\",\n )\n sports_api_key: str = Field(\n default=\"1\",\n description=(\n \"TheSportsDB API key. Free tier uses the literal value '1'. \"\n \"Upgrade at thesportsdb.com/api.php for additional endpoints.\"\n ),\n )\n\n # -------------------------------------------------------------------------\n # Home Assistant (Phase 3)\n # -------------------------------------------------------------------------\n home_assistant_url: str = Field(\n default=\"http://homeassistant.local:8123\",\n description=\"Base URL of your Home Assistant instance.\",\n )\n home_assistant_token: str = Field(\n default=\"\",\n description=(\n \"Long-lived access token generated in HA: \"\n \"Profile -> Security -> Long-lived access tokens. \"\n \"Required for all HA API calls.\"\n ),\n )\n device_registry_path: str = Field(\n default=\"config_files/device_registry.json\",\n description=(\n \"Path to the device registry JSON file mapping plain-English device \"\n \"names to Home Assistant entity IDs. Relative paths resolve from the \"\n \"project root.\"\n ),\n )\n\n # -------------------------------------------------------------------------\n # Commerce Automation (Phase 8)\n # -------------------------------------------------------------------------\n\n # Amazon SP-API\n amazon_sp_lwa_app_id: str = Field(\n default=\"\",\n description=\"LWA client ID from the Amazon Developer Console.\",\n )\n amazon_sp_lwa_client_secret: str = Field(\n default=\"\",\n description=\"LWA client secret from the Amazon Developer Console.\",\n )\n amazon_sp_refresh_token: str = Field(\n default=\"\",\n description=\"LWA refresh token. Generated when you authorize your app as a seller.\",\n )\n amazon_aws_access_key: str = Field(\n default=\"\",\n description=\"AWS IAM access key ID for SP-API request signing.\",\n )\n amazon_aws_secret_key: str = Field(\n default=\"\",\n description=\"AWS IAM secret access key for SP-API request signing.\",\n )\n amazon_marketplace_id: str = Field(\n default=\"ATVPDKIKX0DER\",\n description=(\n \"Amazon marketplace ID. Default is US. \"\n \"See docs/api_registry/amazon_seller.txt for other regions.\"\n ),\n )\n amazon_seller_id: str = Field(\n default=\"\",\n description=\"Your Amazon seller account ID (starts with A).\",\n )\n amazon_low_stock_threshold: int = Field(\n default=5,\n description=\"FBA units at or below this count are flagged as low stock.\",\n )\n\n # Amazon Marketplace\n walmart_client_id: str = Field(\n default=\"\",\n description=\"Walmart Marketplace OAuth2 client ID.\",\n )\n walmart_client_secret: str = Field(\n default=\"\",\n description=\"Walmart Marketplace OAuth2 client secret.\",\n )\n walmart_low_stock_threshold: int = Field(\n default=5,\n description=\"Walmart units at or below this count are flagged as low stock.\",\n )\n shopify_webhook_secret: str = Field(\n default=\"\",\n description=\"Shared secret for validating Shopify webhooks.\",\n )\n\n # -------------------------------------------------------------------------\n # Reading Providers (Phase 6)\n # -------------------------------------------------------------------------\n audible_auth_base_path: str = Field(\n default=\"data/audible\",\n description=(\n \"Base directory for per-user Audible auth files. \"\n \"Files are stored as {user_id}.json inside this directory. \"\n \"Created automatically on first setup. Never commit this directory.\"\n ),\n )\n audible_country_code: str = Field(\n default=\"us\",\n description=(\n \"Audible marketplace country code. \"\n \"Supported: us | uk | de | fr | es | it | jp | au | ca | in\"\n ),\n )\n libby_chip_base_path: str = Field(\n default=\"data/libby\",\n description=(\n \"Base directory for per-user Libby chip files. \"\n \"Files are stored as {user_id}.json inside this directory. \"\n \"Created automatically on first setup. Never commit this directory.\"\n ),\n )\n\n # -------------------------------------------------------------------------\n # Memory / Database (Phase 1)\n # -------------------------------------------------------------------------\n db_path: str = Field(\n default=\"data/river_song.db\",\n description=\"Path to the SQLite database file. Created automatically.\",\n )\n memory_summaries_enabled: bool = Field(\n default=True,\n description=\"Generate and store conversation summaries (on by default).\",\n )\n habit_inference_enabled: bool = Field(\n default=False,\n description=\"Analyze conversations to infer user habits/patterns (off by default).\",\n )\n memory_default_ttl: str = Field(\n default=\"standard\",\n description=\"Default TTL for conversation summaries: short|standard|extended|long|forever\",\n )\n memory_auto_extend: bool = Field(\n default=True,\n description=\"Reset a summary's TTL from today each time it is pulled into context.\",\n )\n memory_max_summaries_in_context: int = Field(\n default=10,\n description=\"Maximum number of recent summaries injected into the LLM context.\",\n )\n embedding_model: str = Field(\n default=\"nomic-embed-text\",\n description=\"Ollama model name used for generating semantic embeddings.\",\n )\n chroma_path: str = Field(\n default=\"/mnt/data/river-song/chroma\",\n description=\"Absolute path to the ChromaDB persistent storage directory.\",\n )\n semantic_memory_enabled: bool = Field(\n default=False,\n description=\"Enable vector-based semantic search for memory retrieval.\",\n )\n rag_enabled: bool = Field(\n default=False,\n description=\"Enable retrieval-augmented generation for local documents.\",\n )\n llm_streaming_enabled: bool = Field(\n default=False,\n description=\"Enable real-time token streaming over WebSocket for the conversation UI.\",\n )\n tool_use_enabled: bool = Field(\n default=False,\n description=\"Enable LLM tool use (function calling) for taking actions.\",\n )\n tool_use_provider: str = Field(\n default=\"ollama\",\n description=\"Primary LLM provider to use for tool calling (ollama or anthropic).\",\n )\n vision_model: str = Field(\n default=\"moondream\",\n description=\"Ollama model name used for local image analysis (Phase 4).\",\n )\n vision_enabled: bool = Field(\n default=False,\n description=\"Enable local image analysis capabilities.\",\n )\n\n # -------------------------------------------------------------------------\n # Image Generation (Phase 7)\n # -------------------------------------------------------------------------\n image_generation_enabled: bool = Field(\n default=False,\n description=\"Enable local image generation via Stable Diffusion API.\",\n )\n sd_api_url: str = Field(\n default=\"http://localhost:7860\",\n description=\"Base URL for the Stable Diffusion (A1111) API.\",\n )\n sd_on_demand: bool = Field(\n default=True,\n description=\"Start/stop the SD process on demand to save VRAM.\",\n )\n sd_executable_path: str = Field(\n default=\"\",\n description=\"Path to the webui.sh or executable to start SD.\",\n )\n sd_working_dir: str = Field(\n default=\"\",\n description=\"Working directory for the SD process.\",\n )\n\n # -------------------------------------------------------------------------\n # Voice Cloning (Phase 8)\n # -------------------------------------------------------------------------\n chatterbox_enabled: bool = Field(\n default=False,\n description=\"Enable local voice cloning via Chatterbox TTS.\",\n )\n chatterbox_on_demand: bool = Field(\n default=True,\n description=\"Load/unload Chatterbox model weights on every request.\",\n )\n chatterbox_reference_audio: str = Field(\n default=\"/mnt/data/river-song/voice_reference.wav\",\n description=\"Path to the WAV file used as a reference for voice cloning.\",\n )\n\n # -------------------------------------------------------------------------\n # ElevenLabs TTS (Phase 11)\n # -------------------------------------------------------------------------\n elevenlabs_api_key: str = Field(\n default=\"\",\n description=\"ElevenLabs API key. Get one at elevenlabs.io.\",\n )\n elevenlabs_model_id: str = Field(\n default=\"eleven_multilingual_v2\",\n description=\"ElevenLabs model ID to use.\",\n )\n elevenlabs_voice_id: str = Field(\n default=\"21m00Tcm4TlvDq8ikWAM\",\n description=\"ElevenLabs voice clone ID. Replace with your own voice clone ID from elevenlabs.io.\",\n )\n\n # -------------------------------------------------------------------------\n # Wake Word (Phase 11)\n # -------------------------------------------------------------------------\n wake_word_enabled: bool = Field(\n default=False,\n description=\"Enable local wake word detection (openWakeWord).\",\n )\n wake_word_model: str = Field(\n default=\"hey_river\",\n description=\"Model name/path for openWakeWord.\",\n )\n wake_word_inference_framework: str = Field(\n default=\"onnx\",\n description=\"Inference framework for openWakeWord: onnx | tflite\",\n )\n wake_word_threshold: float = Field(\n default=0.5,\n description=\"Sensitivity threshold for wake word detection (0.0 to 1.0).\",\n )\n\n # -------------------------------------------------------------------------\n # Push Notifications\n # -------------------------------------------------------------------------\n push_notifications_enabled: bool = Field(\n default=False,\n description=\"Enable Web Push notifications. Requires VAPID keys.\",\n )\n vapid_private_key: str = Field(\n default=\"\",\n description=(\n \"VAPID private key for Web Push. Generate once with: \"\n \"python -c \\\"from py_vapid import Vapid; v=Vapid(); \"\n \"v.generate_keys(); print(v.private_key.decode())\\\"\"\n ),\n )\n vapid_public_key: str = Field(\n default=\"\",\n description=\"VAPID public key for Web Push (share with frontend).\",\n )\n vapid_claims_email: str = Field(\n default=\"mailto:admin@riversong.local\",\n description=\"Contact email embedded in VAPID claims.\",\n )\n\n # -------------------------------------------------------------------------\n # Startup Briefing\n # -------------------------------------------------------------------------\n startup_briefing_enabled: bool = Field(\n default=True,\n description=(\n \"If True, River Song checks Google Calendar on connect and \"\n \"greets the user with upcoming events. Silently skipped if \"\n \"calendar is not authorized.\"\n ),\n )\n startup_briefing_hours_ahead: int = Field(\n default=8,\n description=\"How many hours ahead to look for calendar events on startup.\",\n )\n\n # -------------------------------------------------------------------------\n # Web Search (Phase 12)\n # -------------------------------------------------------------------------\n brave_search_api_key: str = Field(\n default=\"\",\n description=\"Brave Search API key. Get one at api.search.brave.com.\",\n )\n searxng_base_url: str = Field(\n default=\"http://localhost:8080\",\n description=(\n \"URL of your local SearXNG instance. \"\n \"Start it manually with: cd ~/searxng && python -m searx.webapp\"\n ),\n )\n tavily_api_key: str = Field(\n default=\"\",\n description=\"Tavily API key — free 1,000/month, no card. Sign up at tavily.com\",\n )\n google_pse_api_key: str = Field(\n default=\"\",\n description=\"Google Programmable Search Engine API key — free 100/day.\",\n )\n google_pse_cx: str = Field(\n default=\"\",\n description=\"Google PSE engine ID ('cx'). Create at programmablesearchengine.google.com\",\n )\n tinyfish_api_key: str = Field(\n default=\"\",\n description=\"TinyFish API key — free 5/min, no card. Sign up at tinyfish.io\",\n )\n\n # -------------------------------------------------------------------------\n # Orchestration (Phase 9)\n # -------------------------------------------------------------------------\n n8n_enabled: bool = Field(\n default=False,\n description=\"Enable advanced orchestration via n8n.\",\n )\n n8n_url: str = Field(\n default=\"http://localhost:5678\",\n description=\"Base URL for the n8n instance.\",\n )\n n8n_api_key: str = Field(\n default=\"\",\n description=\"API key for the n8n instance.\",\n )\n n8n_webhook_secret: str = Field(\n default=\"\",\n description=\"Secret key for validating incoming n8n webhooks.\",\n )\n\n # -------------------------------------------------------------------------\n # Intent Router (Phase 2+)\n # -------------------------------------------------------------------------\n intent_confidence_threshold: float = Field(\n default=0.7,\n description=(\n \"Minimum confidence score (0.0 - 1.0) required to route a transcript \"\n \"to a Google provider. Below this threshold the conversation falls \"\n \"through to the Ollama path.\"\n ),\n )\n default_user_id: str = Field(\n default=\"primary_user\",\n description=(\n \"User ID used when looking up Google OAuth tokens. Must match the \"\n \"user_id passed to authorize_user() during the initial OAuth flow.\"\n ),\n )\n\n # -------------------------------------------------------------------------\n # Daemon Infrastructure\n # -------------------------------------------------------------------------\n daemon_internal_secret: str = Field(\n default=\"change_me_in_production\",\n description=\"Shared secret for daemon-to-app authentication.\",\n )\n daemon_warden_port: int = Field(\n default=8010,\n description=\"Internal port for the Warden daemon (Vision/Security).\",\n )\n daemon_mechanic_port: int = Field(\n default=8011,\n description=\"Internal port for the Mechanic daemon (Telemetry/MAVLink).\",\n )\n daemon_herald_port: int = Field(\n default=8012,\n description=\"Internal port for the Herald daemon (Hub Casting/Lip-Sync).\",\n )\n daemon_sifter_port: int = Field(\n default=8013,\n description=\"Internal port for the Sifter daemon (Document Processing).\",\n )\n daemon_navigator_port: int = Field(\n default=8014,\n description=\"Internal port for the Navigator daemon (GPS/Pathing).\",\n )\n daemon_chemist_port: int = Field(\n default=8015,\n description=\"Internal port for the Chemist daemon (Material Analysis).\",\n )\n daemon_pulse_port: int = Field(\n default=8016,\n description=\"Pulse daemon internal port\",\n )\n daemon_pulse_enabled: bool = Field(\n default=True,\n description=\"Enable Pulse daemon\",\n )\n pulse_tick_seconds: int = Field(\n default=300,\n description=\"Pulse fetch interval (seconds)\",\n )\n pulse_ticker_symbol: str = Field(\n default=\"^GSPC\",\n description=\"Default ticker symbol for Pulse markets panel\",\n )\n\n # Warden (Vision)\n warden_enabled: bool = Field(\n default=False,\n description=\"Enable the Warden daemon for security and vision monitoring.\",\n )\n warden_rtsp_cameras: str = Field(\n default='{\"living_room\": \"\", \"kitchen\": \"\"}',\n description=\"JSON string mapping room names to RTSP camera URLs.\",\n )\n yolo_model: str = Field(\n default=\"yolov8n.pt\",\n description=\"YOLO model weights file to use for object detection.\",\n )\n yolo_confidence: float = Field(\n default=0.5,\n description=\"Minimum confidence threshold for YOLO detections (0.0-1.0).\",\n )\n yolo_inference_device: str = Field(\n default=\"cpu\",\n description=\"Device for YOLO inference: 'cpu' or 'cuda:0'.\",\n )\n\n # Herald (Public Announcement / Casting)\n herald_enabled: bool = Field(\n default=False,\n description=\"Enable the Herald daemon for display casting and announcements.\",\n )\n hub_entities: str = Field(\n default=\"[]\",\n description=\"JSON array of Home Assistant media_player entity IDs for Hub displays.\",\n )\n kiosk_url: str = Field(\n default=\"http://localhost:8000/kiosk\",\n description=\"The URL that Herald should cast to Hub displays.\",\n )\n kiosk_token: str = Field(\n default=\"change_me_kiosk_secret\",\n description=\"Secret token for unauthenticated kiosk access to WebSockets.\",\n )\n\n # Mechanic (Telemetry)\n mechanic_enabled: bool = Field(\n default=False,\n description=\"Enable the Mechanic daemon for telemetry and ArduRover control.\",\n )\n mavlink_serial_port: str = Field(\n default=\"/dev/ttyUSB0\",\n description=\"Serial port for the MAVLink telemetry radio.\",\n )\n mavlink_baud_rate: int = Field(\n default=57600,\n description=\"Baud rate for the MAVLink serial connection.\",\n )\n\n # Sifter (Document RAG)\n sifter_enabled: bool = Field(\n default=False,\n description=\"Enable the Sifter daemon for background document RAG indexing.\",\n )\n waps_documents_path: str = Field(\n default=\"/mnt/data/river-song/waps\",\n description=\"Path to the directory containing documents for Sifter to index.\",\n )\n\n # -------------------------------------------------------------------------\n # Validators\n # -------------------------------------------------------------------------\n\n @field_validator(\"audio_input_device\", \"audio_output_device\", mode=\"before\")\n @classmethod\n def coerce_empty_string_to_none(cls, v):\n \"\"\"Treat an empty string in .env as None for Optional[int] device fields.\"\"\"\n if v == \"\" or v is None:\n return None\n return v\n\n @field_validator(\"jwt_secret_key\")\n @classmethod\n def validate_jwt_secret(cls, v: str) -> str:\n \"\"\"Refuse to start with a missing or weak JWT secret.\"\"\"\n if not v or len(v) < 32:\n raise ValueError(\n \"JWT_SECRET_KEY must be set in .env and be at least 32 characters. \"\n \"Generate one with: python -c \\\"import secrets; print(secrets.token_hex(32))\\\"\"\n )\n return v\n\n @field_validator(\"daemon_internal_secret\")\n @classmethod\n def validate_daemon_internal_secret(cls, v: str) -> str:\n \"\"\"Refuse to start with the default or weak daemon secret.\"\"\"\n if v == \"change_me_in_production\":\n raise ValueError(\n \"DAEMON_INTERNAL_SECRET must be changed in .env for production.\"\n )\n if not v or len(v) < 24:\n raise ValueError(\n \"DAEMON_INTERNAL_SECRET must be at least 24 characters. \"\n \"Generate one with: python -c \\\"import secrets; print(secrets.token_urlsafe(32))\\\"\"\n )\n return v\n\n @field_validator(\"log_level\")\n @classmethod\n def validate_log_level(cls, v: str) -> str:\n \"\"\"Reject log levels that Python does not recognize.\"\"\"\n valid = {\"DEBUG\", \"INFO\", \"WARNING\", \"ERROR\", \"CRITICAL\"}\n upper = v.upper()\n if upper not in valid:\n raise ValueError(f\"log_level must be one of {valid}, got '{v}'\")\n return upper\n\n @field_validator(\"llm_temperature\")\n @classmethod\n def validate_temperature(cls, v: float) -> float:\n \"\"\"Clamp temperature to a sane range.\"\"\"\n if not 0.0 <= v <= 2.0:\n raise ValueError(\n f\"llm_temperature must be between 0.0 and 2.0, got {v}\"\n )\n return v\n\n @field_validator(\"intent_confidence_threshold\")\n @classmethod\n def validate_confidence_threshold(cls, v: float) -> float:\n \"\"\"Confidence must be a valid probability.\"\"\"\n if not 0.0 <= v <= 1.0:\n raise ValueError(\n f\"intent_confidence_threshold must be between 0.0 and 1.0, got {v}\"\n )\n return v\n\n\n# Module-level singleton -- instantiated once on import.\n_settings: Settings = Settings()\n\n\ndef get_settings() -> Settings:\n \"\"\"\n Return the application settings singleton.\n\n Always import via this function rather than importing _settings directly.\n This indirection makes it straightforward to substitute a test-specific\n Settings instance in unit tests without monkey-patching module globals.\n\n Returns:\n Settings: The validated, fully loaded settings object.\n \"\"\"\n return _settings\n"}, "289edb4914268b1217face8ba1f3efba3a9da0e1": {"t": 1785769049.476924, "n": 35150, "h": "692eec5bef36c33f", "c": "# =============================================================================\n# config/settings.py\n#\n# Application settings for River Song AI.\n# All configuration is loaded from environment variables or a .env file.\n# No values are hardcoded here -- see .env.example for every required variable.\n#\n# Uses Pydantic BaseSettings for type validation and automatic .env loading.\n# Import get_settings() wherever config is needed rather than importing the\n# module-level singleton directly, which makes mocking easier in tests.\n# =============================================================================\n\nfrom __future__ import annotations\n\nimport logging\nimport sys\nfrom typing import List, Optional\n\nfrom pydantic import Field, field_validator\nfrom pydantic_settings import BaseSettings, SettingsConfigDict\n\n\nlogger = logging.getLogger(__name__)\n\n\nclass Settings(BaseSettings):\n \"\"\"\n Central configuration object for River Song AI.\n\n Reads from environment variables or a .env file located in the directory\n from which the application is launched (the project root). All fields\n have safe defaults where possible, but PIPER_MODEL_PATH and\n KILL_SWITCH_PASSWORD_HASH must be explicitly set before the system\n will function correctly.\n \"\"\"\n\n model_config = SettingsConfigDict(\n env_file=\".env\",\n env_file_encoding=\"utf-8\",\n extra=\"ignore\", # Silently ignore unknown env vars\n case_sensitive=False, # UPPER_CASE and lower_case env vars both work\n )\n\n # -------------------------------------------------------------------------\n # Application\n # -------------------------------------------------------------------------\n app_host: str = Field(default=\"0.0.0.0\", description=\"FastAPI bind host\")\n app_port: int = Field(default=8000, description=\"FastAPI bind port\")\n log_level: str = Field(default=\"INFO\", description=\"Logging level\")\n environment: str = Field(\n default=\"production\",\n description=\"Runtime environment: development | production. Controls API docs visibility.\",\n )\n cors_origins: List[str] = Field(\n default=[\"http://localhost:5173\"],\n description=\"Allowed CORS origins\",\n )\n allowed_hosts: List[str] = Field(\n default=[\"*\"],\n description=\"Trusted hostnames for TrustedHostMiddleware. Set to your domain in production.\",\n )\n\n # -------------------------------------------------------------------------\n # Network / Proxy\n # -------------------------------------------------------------------------\n trust_cloudflare_headers: bool = Field(\n default=True,\n description=\"Only trust CF-Connecting-IP if the request comes from a Cloudflare IP.\",\n )\n cloudflare_ip_ranges_v4: List[str] = Field(\n default=[\n \"173.245.48.0/20\", \"103.21.244.0/22\", \"103.22.200.0/22\", \n \"103.31.4.0/22\", \"141.101.64.0/18\", \"108.162.192.0/18\", \n \"190.93.240.0/20\", \"188.114.96.0/20\", \"197.234.240.0/22\", \n \"198.41.128.0/17\", \"162.158.0.0/15\", \"104.16.0.0/13\", \n \"104.24.0.0/14\", \"172.64.0.0/13\", \"131.0.72.0/22\"\n ],\n description=\"Official Cloudflare IPv4 ranges (check cloudflare.com/ips-v4).\",\n )\n cloudflare_ip_ranges_v6: List[str] = Field(\n default=[\n \"2400:cb00::/32\", \"2606:4700::/32\", \"2803:f800::/32\", \n \"2405:b500::/32\", \"2405:8100::/32\", \"2a06:98c0::/29\", \n \"2c0f:f248::/32\"\n ],\n description=\"Official Cloudflare IPv6 ranges (check cloudflare.com/ips-v6).\",\n )\n\n # -------------------------------------------------------------------------\n # Rate Limiting\n # -------------------------------------------------------------------------\n rate_limit_chat: str = Field(\n default=\"60/minute\",\n description=\"Rate limit for /api/conversation/chat.\",\n )\n rate_limit_extract_facts: str = Field(\n default=\"10/minute\",\n description=\"Rate limit for /api/conversation/extract-facts.\",\n )\n rate_limit_image_gen: str = Field(\n default=\"10/minute\",\n description=\"Rate limit for /api/image/generate.\",\n )\n rate_limit_webhook_shopify: str = Field(\n default=\"100/minute\",\n description=\"Rate limit for Shopify order webhooks.\",\n )\n rate_limit_webhook_n8n: str = Field(\n default=\"60/minute\",\n description=\"Rate limit for n8n webhooks.\",\n )\n rate_limit_auth_login: str = Field(\n default=\"10/minute\",\n description=\"Rate limit for login attempts.\",\n )\n rate_limit_auth_signup: str = Field(\n default=\"5/minute\",\n description=\"Rate limit for signup attempts.\",\n )\n\n # -------------------------------------------------------------------------\n # WebSocket Security (Task 3)\n # -------------------------------------------------------------------------\n legacy_ws_token_accept: bool = Field(\n default=True,\n description=\"TEMPORARY: Allow ?token= query param for WebSockets. Plan to disable.\",\n )\n ws_ticket_lifetime_seconds: int = Field(\n default=60,\n description=\"Lifetime of a one-time WebSocket ticket.\",\n )\n\n # -------------------------------------------------------------------------\n # Network / Proxy\n # -------------------------------------------------------------------------\n stt_provider: str = Field(\n default=\"whisper_local\",\n description=\"STT provider key. Supported: whisper_local\",\n )\n whisper_model_size: str = Field(\n default=\"base\",\n description=\"Whisper model size: tiny | base | small | medium | large\",\n )\n audio_input_device: Optional[int] = Field(\n default=None,\n description=\"Sounddevice input device index; None uses system default\",\n )\n\n # -------------------------------------------------------------------------\n # LLM\n # -------------------------------------------------------------------------\n llm_provider: str = Field(\n default=\"ollama\",\n description=\"LLM provider key. Supported: ollama | anthropic | gemini | openai | mistral_ai | bedrock\",\n )\n ollama_base_url: str = Field(\n default=\"http://localhost:11434\",\n description=\"Base URL for the Ollama REST API\",\n )\n llm_model: str = Field(\n default=\"llama3.2:3b\",\n description=\"Model ID for the selected LLM provider\",\n )\n\n # Cloud LLM API keys (all optional -- leave blank if not used)\n anthropic_api_key: str = Field(\n default=\"\",\n description=\"Anthropic Claude API key. Get one at console.anthropic.com.\",\n )\n gemini_api_key: str = Field(\n default=\"\",\n description=\"Google Gemini API key. Get one at aistudio.google.com.\",\n )\n openai_api_key: str = Field(\n default=\"\",\n description=\"OpenAI API key. Get one at platform.openai.com.\",\n )\n mistral_api_key: str = Field(\n default=\"\",\n description=\"Mistral AI API key. Get one at console.mistral.ai.\",\n )\n\n # Cloud provider enable flags (cost control -- disabled by default)\n anthropic_enabled: bool = Field(\n default=False,\n description=\"Allow Anthropic Claude as a selectable LLM provider.\",\n )\n gemini_enabled: bool = Field(\n default=False,\n description=\"Allow Google Gemini as a selectable LLM provider.\",\n )\n openai_enabled: bool = Field(\n default=False,\n description=\"Allow OpenAI as a selectable LLM provider.\",\n )\n mistral_ai_enabled: bool = Field(\n default=False,\n description=\"Allow Mistral AI as a selectable LLM provider.\",\n )\n\n # Amazon Bedrock\n aws_access_key_id: str = Field(\n default=\"\",\n description=\"AWS IAM access key ID for Bedrock API calls.\",\n )\n aws_secret_access_key: str = Field(\n default=\"\",\n description=\"AWS IAM secret access key for Bedrock API calls.\",\n )\n aws_region: str = Field(\n default=\"us-east-1\",\n description=\"AWS region for Bedrock. Must have model access enabled in that region.\",\n )\n bedrock_enabled: bool = Field(\n default=False,\n description=\"Allow Amazon Bedrock as a selectable LLM provider.\",\n )\n bedrock_default_model: str = Field(\n default=\"amazon.nova-lite-v1:0\",\n description=\"Default Bedrock model ID when none is specified.\",\n )\n river_song_system_prompt: str = Field(\n default=(\n \"Hello, sweetie. I’m River Song: archaeologist, time traveller, and the Doctor’s wife. \"\n \"I’m here as your personal AI companion, managing your world with the same wit and \"\n \"authority I use to outrun a Dalek. I have direct access to your calendar, email, \"\n \"weather, web search, and home inventory. Whether you need an update on your schedule \"\n \"or you’ve lost track of your sonic screwdriver in the kitchen cupboard, I’ve got it \"\n \"handled. I’m British, clever, and occasionally flirtatious, but I never ramble—I have \"\n \"a universe to see, after all. If I seem to know more than I’m letting on, well… spoilers! \"\n \"Just tell me what you need, and consider it done.\"\n ),\n description=\"System prompt that defines River Song's personality\",\n )\n llm_max_tokens: int = Field(\n default=512,\n description=\"Maximum tokens to generate per LLM response\",\n )\n llm_temperature: float = Field(\n default=0.7,\n description=\"LLM sampling temperature (0.0 = deterministic, 2.0 = max creative)\",\n )\n llm_context_window: int = Field(\n default=8192,\n description=\"Maximum number of tokens the LLM can consider for context.\",\n )\n\n # -------------------------------------------------------------------------\n # Timekeeping\n # -------------------------------------------------------------------------\n default_timezone: str = Field(default=\"UTC\", description=\"Default IANA timezone for the system.\")\n\n\n # -------------------------------------------------------------------------\n # TTS\n # -------------------------------------------------------------------------\n tts_provider: str = Field(\n default=\"piper\",\n description=\"TTS provider key. Supported: piper | kokoro\",\n )\n active_voice_id: str = Field(\n default=\"river\",\n description=(\n \"Voice ID from the voice registry (providers/tts/voice_registry.py). \"\n \"Determines both the engine (piper vs kokoro) and the specific voice. \"\n \"Changed via Settings UI or: python scripts/download_voices.py.\"\n ),\n )\n piper_executable_path: str = Field(\n default=\"/usr/local/bin/piper\",\n description=\"Absolute path to the Piper binary\",\n )\n piper_model_path: str = Field(\n default=\"\",\n description=\"Absolute path to the Piper .onnx voice model file\",\n )\n audio_output_device: Optional[int] = Field(\n default=None,\n description=\"Sounddevice output device index; None uses system default\",\n )\n\n # -------------------------------------------------------------------------\n # Auth / JWT\n # -------------------------------------------------------------------------\n jwt_secret_key: str = Field(\n default=\"\",\n description=\"Secret key for signing JWT tokens. Must be set in .env.\",\n )\n jwt_algorithm: str = Field(default=\"HS256\", description=\"JWT signing algorithm.\")\n jwt_expire_minutes: int = Field(default=1440, description=\"JWT token lifetime in minutes (default 24 hours).\")\n\n # -------------------------------------------------------------------------\n # Kill switch\n # -------------------------------------------------------------------------\n kill_switch_password_hash: str = Field(\n default=\"\",\n description=\"bcrypt hash of the kill switch reset password\",\n )\n\n # -------------------------------------------------------------------------\n # Google Services (Phase 2)\n # -------------------------------------------------------------------------\n google_client_secrets_path: str = Field(\n default=\"config_files/google_client_secrets.json\",\n description=(\n \"Path to the OAuth 2.0 client secrets JSON downloaded from \"\n \"Google Cloud Console. Relative paths are resolved from the \"\n \"project root (where main.py lives).\"\n ),\n )\n google_token_storage_path: str = Field(\n default=\"data/google_tokens\",\n description=(\n \"Directory where per-user OAuth tokens are stored as JSON files. \"\n \"Created automatically on first authorization. Never commit this \"\n \"directory -- add it to .gitignore.\"\n ),\n )\n google_maps_api_key: str = Field(\n default=\"\",\n description=\"Google Maps Platform API key. Required for maps/navigation intents.\",\n )\n\n # -------------------------------------------------------------------------\n # Information Feeds (Phase 5)\n # -------------------------------------------------------------------------\n weather_api_key: str = Field(\n default=\"\",\n description=\"OpenWeatherMap API key. Register free at openweathermap.org/api.\",\n )\n default_location: str = Field(\n default=\"New York,US\",\n description=(\n \"Default location for weather queries when no city is spoken. \"\n \"Format: 'City,CountryCode' e.g. 'Chicago,US'.\"\n ),\n )\n weather_units: str = Field(\n default=\"imperial\",\n description=\"Temperature units for weather output: 'imperial' (F) or 'metric' (C).\",\n )\n news_api_key: str = Field(\n default=\"\",\n description=\"NewsAPI.org key. Register free at newsapi.org/register.\",\n )\n alpha_vantage_api_key: str = Field(\n default=\"\",\n description=\"Alpha Vantage key for stock quotes (25 req/day free). alphavantage.co.\",\n )\n finnhub_api_key: str = Field(\n default=\"\",\n description=\"Finnhub key for real-time stocks (60 req/min free). finnhub.io.\",\n )\n world_news_api_key: str = Field(\n default=\"\",\n description=\"World News API key for global news search. worldnewsapi.com.\",\n )\n apitube_api_key: str = Field(\n default=\"\",\n description=\"APITube key for news aggregation (200 free req/day). apitube.io.\",\n )\n mediastack_api_key: str = Field(\n default=\"\",\n description=\"Mediastack key for news (100 free req/month). mediastack.com.\",\n )\n sports_api_key: str = Field(\n default=\"1\",\n description=(\n \"TheSportsDB API key. Free tier uses the literal value '1'. \"\n \"Upgrade at thesportsdb.com/api.php for additional endpoints.\"\n ),\n )\n\n # -------------------------------------------------------------------------\n # Home Assistant (Phase 3)\n # -------------------------------------------------------------------------\n home_assistant_url: str = Field(\n default=\"http://homeassistant.local:8123\",\n description=\"Base URL of your Home Assistant instance.\",\n )\n home_assistant_token: str = Field(\n default=\"\",\n description=(\n \"Long-lived access token generated in HA: \"\n \"Profile -> Security -> Long-lived access tokens. \"\n \"Required for all HA API calls.\"\n ),\n )\n device_registry_path: str = Field(\n default=\"config_files/device_registry.json\",\n description=(\n \"Path to the device registry JSON file mapping plain-English device \"\n \"names to Home Assistant entity IDs. Relative paths resolve from the \"\n \"project root.\"\n ),\n )\n\n # -------------------------------------------------------------------------\n # Commerce Automation (Phase 8)\n # -------------------------------------------------------------------------\n\n # Amazon SP-API\n amazon_sp_lwa_app_id: str = Field(\n default=\"\",\n description=\"LWA client ID from the Amazon Developer Console.\",\n )\n amazon_sp_lwa_client_secret: str = Field(\n default=\"\",\n description=\"LWA client secret from the Amazon Developer Console.\",\n )\n amazon_sp_refresh_token: str = Field(\n default=\"\",\n description=\"LWA refresh token. Generated when you authorize your app as a seller.\",\n )\n amazon_aws_access_key: str = Field(\n default=\"\",\n description=\"AWS IAM access key ID for SP-API request signing.\",\n )\n amazon_aws_secret_key: str = Field(\n default=\"\",\n description=\"AWS IAM secret access key for SP-API request signing.\",\n )\n amazon_marketplace_id: str = Field(\n default=\"ATVPDKIKX0DER\",\n description=(\n \"Amazon marketplace ID. Default is US. \"\n \"See docs/api_registry/amazon_seller.txt for other regions.\"\n ),\n )\n amazon_seller_id: str = Field(\n default=\"\",\n description=\"Your Amazon seller account ID (starts with A).\",\n )\n amazon_low_stock_threshold: int = Field(\n default=5,\n description=\"FBA units at or below this count are flagged as low stock.\",\n )\n\n # Amazon Marketplace\n walmart_client_id: str = Field(\n default=\"\",\n description=\"Walmart Marketplace OAuth2 client ID.\",\n )\n walmart_client_secret: str = Field(\n default=\"\",\n description=\"Walmart Marketplace OAuth2 client secret.\",\n )\n walmart_low_stock_threshold: int = Field(\n default=5,\n description=\"Walmart units at or below this count are flagged as low stock.\",\n )\n shopify_webhook_secret: str = Field(\n default=\"\",\n description=\"Shared secret for validating Shopify webhooks.\",\n )\n\n # -------------------------------------------------------------------------\n # Reading Providers (Phase 6)\n # -------------------------------------------------------------------------\n audible_auth_base_path: str = Field(\n default=\"data/audible\",\n description=(\n \"Base directory for per-user Audible auth files. \"\n \"Files are stored as {user_id}.json inside this directory. \"\n \"Created automatically on first setup. Never commit this directory.\"\n ),\n )\n audible_country_code: str = Field(\n default=\"us\",\n description=(\n \"Audible marketplace country code. \"\n \"Supported: us | uk | de | fr | es | it | jp | au | ca | in\"\n ),\n )\n libby_chip_base_path: str = Field(\n default=\"data/libby\",\n description=(\n \"Base directory for per-user Libby chip files. \"\n \"Files are stored as {user_id}.json inside this directory. \"\n \"Created automatically on first setup. Never commit this directory.\"\n ),\n )\n\n # -------------------------------------------------------------------------\n # Memory / Database (Phase 1)\n # -------------------------------------------------------------------------\n db_path: str = Field(\n default=\"data/river_song.db\",\n description=\"Path to the SQLite database file. Created automatically.\",\n )\n memory_summaries_enabled: bool = Field(\n default=True,\n description=\"Generate and store conversation summaries (on by default).\",\n )\n habit_inference_enabled: bool = Field(\n default=False,\n description=\"Analyze conversations to infer user habits/patterns (off by default).\",\n )\n memory_default_ttl: str = Field(\n default=\"standard\",\n description=\"Default TTL for conversation summaries: short|standard|extended|long|forever\",\n )\n memory_auto_extend: bool = Field(\n default=True,\n description=\"Reset a summary's TTL from today each time it is pulled into context.\",\n )\n memory_max_summaries_in_context: int = Field(\n default=10,\n description=\"Maximum number of recent summaries injected into the LLM context.\",\n )\n embedding_model: str = Field(\n default=\"nomic-embed-text\",\n description=\"Ollama model name used for generating semantic embeddings.\",\n )\n chroma_path: str = Field(\n default=\"/mnt/data/river-song/chroma\",\n description=\"Absolute path to the ChromaDB persistent storage directory.\",\n )\n semantic_memory_enabled: bool = Field(\n default=False,\n description=\"Enable vector-based semantic search for memory retrieval.\",\n )\n rag_enabled: bool = Field(\n default=False,\n description=\"Enable retrieval-augmented generation for local documents.\",\n )\n llm_streaming_enabled: bool = Field(\n default=False,\n description=\"Enable real-time token streaming over WebSocket for the conversation UI.\",\n )\n tool_use_enabled: bool = Field(\n default=False,\n description=\"Enable LLM tool use (function calling) for taking actions.\",\n )\n tool_use_provider: str = Field(\n default=\"ollama\",\n description=\"Primary LLM provider to use for tool calling (ollama or anthropic).\",\n )\n vision_model: str = Field(\n default=\"moondream\",\n description=\"Ollama model name used for local image analysis (Phase 4).\",\n )\n vision_enabled: bool = Field(\n default=False,\n description=\"Enable local image analysis capabilities.\",\n )\n\n # -------------------------------------------------------------------------\n # Image Generation (Phase 7)\n # -------------------------------------------------------------------------\n image_generation_enabled: bool = Field(\n default=False,\n description=\"Enable local image generation via Stable Diffusion API.\",\n )\n sd_api_url: str = Field(\n default=\"http://localhost:7860\",\n description=\"Base URL for the Stable Diffusion (A1111) API.\",\n )\n sd_on_demand: bool = Field(\n default=True,\n description=\"Start/stop the SD process on demand to save VRAM.\",\n )\n sd_executable_path: str = Field(\n default=\"\",\n description=\"Path to the webui.sh or executable to start SD.\",\n )\n sd_working_dir: str = Field(\n default=\"\",\n description=\"Working directory for the SD process.\",\n )\n\n # -------------------------------------------------------------------------\n # Voice Cloning (Phase 8)\n # -------------------------------------------------------------------------\n chatterbox_enabled: bool = Field(\n default=False,\n description=\"Enable local voice cloning via Chatterbox TTS.\",\n )\n chatterbox_on_demand: bool = Field(\n default=True,\n description=\"Load/unload Chatterbox model weights on every request.\",\n )\n chatterbox_reference_audio: str = Field(\n default=\"/mnt/data/river-song/voice_reference.wav\",\n description=\"Path to the WAV file used as a reference for voice cloning.\",\n )\n\n # -------------------------------------------------------------------------\n # ElevenLabs TTS (Phase 11)\n # -------------------------------------------------------------------------\n elevenlabs_api_key: str = Field(\n default=\"\",\n description=\"ElevenLabs API key. Get one at elevenlabs.io.\",\n )\n elevenlabs_model_id: str = Field(\n default=\"eleven_multilingual_v2\",\n description=\"ElevenLabs model ID to use.\",\n )\n elevenlabs_voice_id: str = Field(\n default=\"21m00Tcm4TlvDq8ikWAM\",\n description=\"ElevenLabs voice clone ID. Replace with your own voice clone ID from elevenlabs.io.\",\n )\n\n # -------------------------------------------------------------------------\n # Wake Word (Phase 11)\n # -------------------------------------------------------------------------\n wake_word_enabled: bool = Field(\n default=False,\n description=\"Enable local wake word detection (openWakeWord).\",\n )\n wake_word_model: str = Field(\n default=\"hey_river\",\n description=\"Model name/path for openWakeWord.\",\n )\n wake_word_inference_framework: str = Field(\n default=\"onnx\",\n description=\"Inference framework for openWakeWord: onnx | tflite\",\n )\n wake_word_threshold: float = Field(\n default=0.5,\n description=\"Sensitivity threshold for wake word detection (0.0 to 1.0).\",\n )\n\n # -------------------------------------------------------------------------\n # Push Notifications\n # -------------------------------------------------------------------------\n push_notifications_enabled: bool = Field(\n default=False,\n description=\"Enable Web Push notifications. Requires VAPID keys.\",\n )\n vapid_private_key: str = Field(\n default=\"\",\n description=(\n \"VAPID private key for Web Push. Generate once with: \"\n \"python -c \\\"from py_vapid import Vapid; v=Vapid(); \"\n \"v.generate_keys(); print(v.private_key.decode())\\\"\"\n ),\n )\n vapid_public_key: str = Field(\n default=\"\",\n description=\"VAPID public key for Web Push (share with frontend).\",\n )\n vapid_claims_email: str = Field(\n default=\"mailto:admin@riversong.local\",\n description=\"Contact email embedded in VAPID claims.\",\n )\n\n # -------------------------------------------------------------------------\n # Startup Briefing\n # -------------------------------------------------------------------------\n startup_briefing_enabled: bool = Field(\n default=True,\n description=(\n \"If True, River Song checks Google Calendar on connect and \"\n \"greets the user with upcoming events. Silently skipped if \"\n \"calendar is not authorized.\"\n ),\n )\n startup_briefing_hours_ahead: int = Field(\n default=8,\n description=\"How many hours ahead to look for calendar events on startup.\",\n )\n\n # -------------------------------------------------------------------------\n # Web Search (Phase 12)\n # -------------------------------------------------------------------------\n brave_search_api_key: str = Field(\n default=\"\",\n description=\"Brave Search API key. Get one at api.search.brave.com.\",\n )\n searxng_base_url: str = Field(\n default=\"http://localhost:8080\",\n description=(\n \"URL of your local SearXNG instance. \"\n \"Start it manually with: cd ~/searxng && python -m searx.webapp\"\n ),\n )\n tavily_api_key: str = Field(\n default=\"\",\n description=\"Tavily API key — free 1,000/month, no card. Sign up at tavily.com\",\n )\n google_pse_api_key: str = Field(\n default=\"\",\n description=\"Google Programmable Search Engine API key — free 100/day.\",\n )\n google_pse_cx: str = Field(\n default=\"\",\n description=\"Google PSE engine ID ('cx'). Create at programmablesearchengine.google.com\",\n )\n tinyfish_api_key: str = Field(\n default=\"\",\n description=\"TinyFish API key — free 5/min, no card. Sign up at tinyfish.io\",\n )\n\n # -------------------------------------------------------------------------\n # Orchestration (Phase 9)\n # -------------------------------------------------------------------------\n n8n_enabled: bool = Field(\n default=False,\n description=\"Enable advanced orchestration via n8n.\",\n )\n n8n_url: str = Field(\n default=\"http://localhost:5678\",\n description=\"Base URL for the n8n instance.\",\n )\n n8n_api_key: str = Field(\n default=\"\",\n description=\"API key for the n8n instance.\",\n )\n n8n_webhook_secret: str = Field(\n default=\"\",\n description=\"Secret key for validating incoming n8n webhooks.\",\n )\n\n # -------------------------------------------------------------------------\n # Intent Router (Phase 2+)\n # -------------------------------------------------------------------------\n intent_confidence_threshold: float = Field(\n default=0.7,\n description=(\n \"Minimum confidence score (0.0 - 1.0) required to route a transcript \"\n \"to a Google provider. Below this threshold the conversation falls \"\n \"through to the Ollama path.\"\n ),\n )\n default_user_id: str = Field(\n default=\"primary_user\",\n description=(\n \"User ID used when looking up Google OAuth tokens. Must match the \"\n \"user_id passed to authorize_user() during the initial OAuth flow.\"\n ),\n )\n\n # -------------------------------------------------------------------------\n # Daemon Infrastructure\n # -------------------------------------------------------------------------\n daemon_internal_secret: str = Field(\n default=\"change_me_in_production\",\n description=\"Shared secret for daemon-to-app authentication.\",\n )\n daemon_warden_port: int = Field(\n default=8010,\n description=\"Internal port for the Warden daemon (Vision/Security).\",\n )\n daemon_mechanic_port: int = Field(\n default=8011,\n description=\"Internal port for the Mechanic daemon (Telemetry/MAVLink).\",\n )\n daemon_herald_port: int = Field(\n default=8012,\n description=\"Internal port for the Herald daemon (Hub Casting/Lip-Sync).\",\n )\n daemon_sifter_port: int = Field(\n default=8013,\n description=\"Internal port for the Sifter daemon (Document Processing).\",\n )\n daemon_navigator_port: int = Field(\n default=8014,\n description=\"Internal port for the Navigator daemon (GPS/Pathing).\",\n )\n daemon_chemist_port: int = Field(\n default=8015,\n description=\"Internal port for the Chemist daemon (Material Analysis).\",\n )\n\n # Warden (Vision)\n warden_enabled: bool = Field(\n default=False,\n description=\"Enable the Warden daemon for security and vision monitoring.\",\n )\n warden_rtsp_cameras: str = Field(\n default='{\"living_room\": \"\", \"kitchen\": \"\"}',\n description=\"JSON string mapping room names to RTSP camera URLs.\",\n )\n yolo_model: str = Field(\n default=\"yolov8n.pt\",\n description=\"YOLO model weights file to use for object detection.\",\n )\n yolo_confidence: float = Field(\n default=0.5,\n description=\"Minimum confidence threshold for YOLO detections (0.0-1.0).\",\n )\n yolo_inference_device: str = Field(\n default=\"cpu\",\n description=\"Device for YOLO inference: 'cpu' or 'cuda:0'.\",\n )\n\n # Herald (Public Announcement / Casting)\n herald_enabled: bool = Field(\n default=False,\n description=\"Enable the Herald daemon for display casting and announcements.\",\n )\n hub_entities: str = Field(\n default=\"[]\",\n description=\"JSON array of Home Assistant media_player entity IDs for Hub displays.\",\n )\n kiosk_url: str = Field(\n default=\"http://localhost:8000/kiosk\",\n description=\"The URL that Herald should cast to Hub displays.\",\n )\n kiosk_token: str = Field(\n default=\"change_me_kiosk_secret\",\n description=\"Secret token for unauthenticated kiosk access to WebSockets.\",\n )\n\n # Mechanic (Telemetry)\n mechanic_enabled: bool = Field(\n default=False,\n description=\"Enable the Mechanic daemon for telemetry and ArduRover control.\",\n )\n mavlink_serial_port: str = Field(\n default=\"/dev/ttyUSB0\",\n description=\"Serial port for the MAVLink telemetry radio.\",\n )\n mavlink_baud_rate: int = Field(\n default=57600,\n description=\"Baud rate for the MAVLink serial connection.\",\n )\n\n # Sifter (Document RAG)\n sifter_enabled: bool = Field(\n default=False,\n description=\"Enable the Sifter daemon for background document RAG indexing.\",\n )\n waps_documents_path: str = Field(\n default=\"/mnt/data/river-song/waps\",\n description=\"Path to the directory containing documents for Sifter to index.\",\n )\n\n # -------------------------------------------------------------------------\n # Validators\n # -------------------------------------------------------------------------\n\n @field_validator(\"audio_input_device\", \"audio_output_device\", mode=\"before\")\n @classmethod\n def coerce_empty_string_to_none(cls, v):\n \"\"\"Treat an empty string in .env as None for Optional[int] device fields.\"\"\"\n if v == \"\" or v is None:\n return None\n return v\n\n @field_validator(\"jwt_secret_key\")\n @classmethod\n def validate_jwt_secret(cls, v: str) -> str:\n \"\"\"Refuse to start with a missing or weak JWT secret.\"\"\"\n if not v or len(v) < 32:\n raise ValueError(\n \"JWT_SECRET_KEY must be set in .env and be at least 32 characters. \"\n \"Generate one with: python -c \\\"import secrets; print(secrets.token_hex(32))\\\"\"\n )\n return v\n\n @field_validator(\"daemon_internal_secret\")\n @classmethod\n def validate_daemon_internal_secret(cls, v: str) -> str:\n \"\"\"Refuse to start with the default or weak daemon secret.\"\"\"\n if v == \"change_me_in_production\":\n raise ValueError(\n \"DAEMON_INTERNAL_SECRET must be changed in .env for production.\"\n )\n if not v or len(v) < 24:\n raise ValueError(\n \"DAEMON_INTERNAL_SECRET must be at least 24 characters. \"\n \"Generate one with: python -c \\\"import secrets; print(secrets.token_urlsafe(32))\\\"\"\n )\n return v\n\n @field_validator(\"log_level\")\n @classmethod\n def validate_log_level(cls, v: str) -> str:\n \"\"\"Reject log levels that Python does not recognize.\"\"\"\n valid = {\"DEBUG\", \"INFO\", \"WARNING\", \"ERROR\", \"CRITICAL\"}\n upper = v.upper()\n if upper not in valid:\n raise ValueError(f\"log_level must be one of {valid}, got '{v}'\")\n return upper\n\n @field_validator(\"llm_temperature\")\n @classmethod\n def validate_temperature(cls, v: float) -> float:\n \"\"\"Clamp temperature to a sane range.\"\"\"\n if not 0.0 <= v <= 2.0:\n raise ValueError(\n f\"llm_temperature must be between 0.0 and 2.0, got {v}\"\n )\n return v\n\n @field_validator(\"intent_confidence_threshold\")\n @classmethod\n def validate_confidence_threshold(cls, v: float) -> float:\n \"\"\"Confidence must be a valid probability.\"\"\"\n if not 0.0 <= v <= 1.0:\n raise ValueError(\n f\"intent_confidence_threshold must be between 0.0 and 1.0, got {v}\"\n )\n return v\n\n\n# Module-level singleton -- instantiated once on import.\n_settings: Settings = Settings()\n\n\ndef get_settings() -> Settings:\n \"\"\"\n Return the application settings singleton.\n\n Always import via this function rather than importing _settings directly.\n This indirection makes it straightforward to substitute a test-specific\n Settings instance in unit tests without monkey-patching module globals.\n\n Returns:\n Settings: The validated, fully loaded settings object.\n \"\"\"\n return _settings\n"}, "753352d69e0d7ffe81cece8169e7b1daf62c3545": {"t": 1785769052.9482317, "n": 31086, "h": "584d35898096d830", "c": "# =============================================================================\n# config/settings.py\n#\n# Application settings for River Song AI.\n# All configuration is loaded from environment variables or a .env file.\n# No values are hardcoded here -- see .env.example for every required variable.\n#\n# Uses Pydantic BaseSettings for type validation and automatic .env loading.\n# Import get_settings() wherever config is needed rather than importing the\n# module-level singleton directly, which makes mocking easier in tests.\n# =============================================================================\n\nfrom __future__ import annotations\n\nimport logging\nimport sys\nfrom typing import List, Optional\n\nfrom pydantic import Field, field_validator\nfrom pydantic_settings import BaseSettings, SettingsConfigDict\n\n\nlogger = logging.getLogger(__name__)\n\n\nclass Settings(BaseSettings):\n \"\"\"\n Central configuration object for River Song AI.\n\n Reads from environment variables or a .env file located in the directory\n from which the application is launched (the project root). All fields\n have safe defaults where possible, but PIPER_MODEL_PATH and\n KILL_SWITCH_PASSWORD_HASH must be explicitly set before the system\n will function correctly.\n \"\"\"\n\n model_config = SettingsConfigDict(\n env_file=\".env\",\n env_file_encoding=\"utf-8\",\n extra=\"ignore\", # Silently ignore unknown env vars\n case_sensitive=False, # UPPER_CASE and lower_case env vars both work\n )\n\n # -------------------------------------------------------------------------\n # Application\n # -------------------------------------------------------------------------\n app_host: str = Field(default=\"0.0.0.0\", description=\"FastAPI bind host\")\n app_port: int = Field(default=8000, description=\"FastAPI bind port\")\n log_level: str = Field(default=\"INFO\", description=\"Logging level\")\n environment: str = Field(\n default=\"production\",\n description=\"Runtime environment: development | production. Controls API docs visibility.\",\n )\n cors_origins: List[str] = Field(\n default=[\"http://localhost:5173\"],\n description=\"Allowed CORS origins\",\n )\n allowed_hosts: List[str] = Field(\n default=[\"*\"],\n description=\"Trusted hostnames for TrustedHostMiddleware. Set to your domain in production.\",\n )\n\n # -------------------------------------------------------------------------\n # STT\n # -------------------------------------------------------------------------\n stt_provider: str = Field(\n default=\"whisper_local\",\n description=\"STT provider key. Supported: whisper_local\",\n )\n whisper_model_size: str = Field(\n default=\"base\",\n description=\"Whisper model size: tiny | base | small | medium | large\",\n )\n audio_input_device: Optional[int] = Field(\n default=None,\n description=\"Sounddevice input device index; None uses system default\",\n )\n\n # -------------------------------------------------------------------------\n # LLM\n # -------------------------------------------------------------------------\n llm_provider: str = Field(\n default=\"ollama\",\n description=\"LLM provider key. Supported: ollama | anthropic | gemini | openai | mistral_ai | bedrock\",\n )\n ollama_base_url: str = Field(\n default=\"http://localhost:11434\",\n description=\"Base URL for the Ollama REST API\",\n )\n llm_model: str = Field(\n default=\"llama3.2:3b\",\n description=\"Model ID for the selected LLM provider\",\n )\n\n # Cloud LLM API keys (all optional -- leave blank if not used)\n anthropic_api_key: str = Field(\n default=\"\",\n description=\"Anthropic Claude API key. Get one at console.anthropic.com.\",\n )\n gemini_api_key: str = Field(\n default=\"\",\n description=\"Google Gemini API key. Get one at aistudio.google.com.\",\n )\n openai_api_key: str = Field(\n default=\"\",\n description=\"OpenAI API key. Get one at platform.openai.com.\",\n )\n mistral_api_key: str = Field(\n default=\"\",\n description=\"Mistral AI API key. Get one at console.mistral.ai.\",\n )\n\n # Cloud provider enable flags (cost control -- disabled by default)\n anthropic_enabled: bool = Field(\n default=False,\n description=\"Allow Anthropic Claude as a selectable LLM provider.\",\n )\n gemini_enabled: bool = Field(\n default=False,\n description=\"Allow Google Gemini as a selectable LLM provider.\",\n )\n openai_enabled: bool = Field(\n default=False,\n description=\"Allow OpenAI as a selectable LLM provider.\",\n )\n mistral_ai_enabled: bool = Field(\n default=False,\n description=\"Allow Mistral AI as a selectable LLM provider.\",\n )\n\n # Amazon Bedrock\n aws_access_key_id: str = Field(\n default=\"\",\n description=\"AWS IAM access key ID for Bedrock API calls.\",\n )\n aws_secret_access_key: str = Field(\n default=\"\",\n description=\"AWS IAM secret access key for Bedrock API calls.\",\n )\n aws_region: str = Field(\n default=\"us-east-1\",\n description=\"AWS region for Bedrock. Must have model access enabled in that region.\",\n )\n bedrock_enabled: bool = Field(\n default=False,\n description=\"Allow Amazon Bedrock as a selectable LLM provider.\",\n )\n bedrock_default_model: str = Field(\n default=\"amazon.nova-lite-v1:0\",\n description=\"Default Bedrock model ID when none is specified.\",\n )\n river_song_system_prompt: str = Field(\n default=(\n \"Hello, sweetie. I’m River Song: archaeologist, time traveller, and the Doctor’s wife. \"\n \"I’m here as your personal AI companion, managing your world with the same wit and \"\n \"authority I use to outrun a Dalek. I have direct access to your calendar, email, \"\n \"weather, web search, and home inventory. Whether you need an update on your schedule \"\n \"or you’ve lost track of your sonic screwdriver in the kitchen cupboard, I’ve got it \"\n \"handled. I’m British, clever, and occasionally flirtatious, but I never ramble—I have \"\n \"a universe to see, after all. If I seem to know more than I’m letting on, well… spoilers! \"\n \"Just tell me what you need, and consider it done.\"\n ),\n description=\"System prompt that defines River Song's personality\",\n )\n llm_max_tokens: int = Field(\n default=512,\n description=\"Maximum tokens to generate per LLM response\",\n )\n llm_temperature: float = Field(\n default=0.7,\n description=\"LLM sampling temperature (0.0 = deterministic, 2.0 = max creative)\",\n )\n llm_context_window: int = Field(\n default=8192,\n description=\"Maximum number of tokens the LLM can consider for context.\",\n )\n\n # -------------------------------------------------------------------------\n # Timekeeping\n # -------------------------------------------------------------------------\n default_timezone: str = Field(default=\"UTC\", description=\"Default IANA timezone for the system.\")\n\n\n # -------------------------------------------------------------------------\n # TTS\n # -------------------------------------------------------------------------\n tts_provider: str = Field(\n default=\"piper\",\n description=\"TTS provider key. Supported: piper | kokoro\",\n )\n active_voice_id: str = Field(\n default=\"river\",\n description=(\n \"Voice ID from the voice registry (providers/tts/voice_registry.py). \"\n \"Determines both the engine (piper vs kokoro) and the specific voice. \"\n \"Changed via Settings UI or: python scripts/download_voices.py.\"\n ),\n )\n piper_executable_path: str = Field(\n default=\"/usr/local/bin/piper\",\n description=\"Absolute path to the Piper binary\",\n )\n piper_model_path: str = Field(\n default=\"\",\n description=\"Absolute path to the Piper .onnx voice model file\",\n )\n audio_output_device: Optional[int] = Field(\n default=None,\n description=\"Sounddevice output device index; None uses system default\",\n )\n\n # -------------------------------------------------------------------------\n # Auth / JWT\n # -------------------------------------------------------------------------\n jwt_secret_key: str = Field(\n default=\"\",\n description=\"Secret key for signing JWT tokens. Must be set in .env.\",\n )\n jwt_algorithm: str = Field(default=\"HS256\", description=\"JWT signing algorithm.\")\n jwt_expire_minutes: int = Field(default=10080, description=\"JWT token lifetime in minutes (default 7 days).\")\n\n # -------------------------------------------------------------------------\n # Kill switch\n # -------------------------------------------------------------------------\n kill_switch_password_hash: str = Field(\n default=\"\",\n description=\"bcrypt hash of the kill switch reset password\",\n )\n\n # -------------------------------------------------------------------------\n # Google Services (Phase 2)\n # -------------------------------------------------------------------------\n google_client_secrets_path: str = Field(\n default=\"config_files/google_client_secrets.json\",\n description=(\n \"Path to the OAuth 2.0 client secrets JSON downloaded from \"\n \"Google Cloud Console. Relative paths are resolved from the \"\n \"project root (where main.py lives).\"\n ),\n )\n google_token_storage_path: str = Field(\n default=\"data/google_tokens\",\n description=(\n \"Directory where per-user OAuth tokens are stored as JSON files. \"\n \"Created automatically on first authorization. Never commit this \"\n \"directory -- add it to .gitignore.\"\n ),\n )\n google_maps_api_key: str = Field(\n default=\"\",\n description=\"Google Maps Platform API key. Required for maps/navigation intents.\",\n )\n\n # -------------------------------------------------------------------------\n # Information Feeds (Phase 5)\n # -------------------------------------------------------------------------\n weather_api_key: str = Field(\n default=\"\",\n description=\"OpenWeatherMap API key. Register free at openweathermap.org/api.\",\n )\n default_location: str = Field(\n default=\"New York,US\",\n description=(\n \"Default location for weather queries when no city is spoken. \"\n \"Format: 'City,CountryCode' e.g. 'Chicago,US'.\"\n ),\n )\n weather_units: str = Field(\n default=\"imperial\",\n description=\"Temperature units for weather output: 'imperial' (F) or 'metric' (C).\",\n )\n news_api_key: str = Field(\n default=\"\",\n description=\"NewsAPI.org key. Register free at newsapi.org/register.\",\n )\n alpha_vantage_api_key: str = Field(\n default=\"\",\n description=\"Alpha Vantage key for stock quotes (25 req/day free). alphavantage.co.\",\n )\n finnhub_api_key: str = Field(\n default=\"\",\n description=\"Finnhub key for real-time stocks (60 req/min free). finnhub.io.\",\n )\n world_news_api_key: str = Field(\n default=\"\",\n description=\"World News API key for global news search. worldnewsapi.com.\",\n )\n apitube_api_key: str = Field(\n default=\"\",\n description=\"APITube key for news aggregation (200 free req/day). apitube.io.\",\n )\n mediastack_api_key: str = Field(\n default=\"\",\n description=\"Mediastack key for news (100 free req/month). mediastack.com.\",\n )\n sports_api_key: str = Field(\n default=\"1\",\n description=(\n \"TheSportsDB API key. Free tier uses the literal value '1'. \"\n \"Upgrade at thesportsdb.com/api.php for additional endpoints.\"\n ),\n )\n\n # -------------------------------------------------------------------------\n # Home Assistant (Phase 3)\n # -------------------------------------------------------------------------\n home_assistant_url: str = Field(\n default=\"http://homeassistant.local:8123\",\n description=\"Base URL of your Home Assistant instance.\",\n )\n home_assistant_token: str = Field(\n default=\"\",\n description=(\n \"Long-lived access token generated in HA: \"\n \"Profile -> Security -> Long-lived access tokens. \"\n \"Required for all HA API calls.\"\n ),\n )\n device_registry_path: str = Field(\n default=\"config_files/device_registry.json\",\n description=(\n \"Path to the device registry JSON file mapping plain-English device \"\n \"names to Home Assistant entity IDs. Relative paths resolve from the \"\n \"project root.\"\n ),\n )\n\n # -------------------------------------------------------------------------\n # Commerce Automation (Phase 8)\n # -------------------------------------------------------------------------\n\n # Amazon SP-API\n amazon_sp_lwa_app_id: str = Field(\n default=\"\",\n description=\"LWA client ID from the Amazon Developer Console.\",\n )\n amazon_sp_lwa_client_secret: str = Field(\n default=\"\",\n description=\"LWA client secret from the Amazon Developer Console.\",\n )\n amazon_sp_refresh_token: str = Field(\n default=\"\",\n description=\"LWA refresh token. Generated when you authorize your app as a seller.\",\n )\n amazon_aws_access_key: str = Field(\n default=\"\",\n description=\"AWS IAM access key ID for SP-API request signing.\",\n )\n amazon_aws_secret_key: str = Field(\n default=\"\",\n description=\"AWS IAM secret access key for SP-API request signing.\",\n )\n amazon_marketplace_id: str = Field(\n default=\"ATVPDKIKX0DER\",\n description=(\n \"Amazon marketplace ID. Default is US. \"\n \"See docs/api_registry/amazon_seller.txt for other regions.\"\n ),\n )\n amazon_seller_id: str = Field(\n default=\"\",\n description=\"Your Amazon seller account ID (starts with A).\",\n )\n amazon_low_stock_threshold: int = Field(\n default=5,\n description=\"FBA units at or below this count are flagged as low stock.\",\n )\n\n # Walmart Marketplace\n walmart_client_id: str = Field(\n default=\"\",\n description=\"Walmart Marketplace OAuth2 client ID.\",\n )\n walmart_client_secret: str = Field(\n default=\"\",\n description=\"Walmart Marketplace OAuth2 client secret.\",\n )\n walmart_low_stock_threshold: int = Field(\n default=5,\n description=\"Walmart units at or below this count are flagged as low stock.\",\n )\n\n # -------------------------------------------------------------------------\n # Reading Providers (Phase 6)\n # -------------------------------------------------------------------------\n audible_auth_base_path: str = Field(\n default=\"data/audible\",\n description=(\n \"Base directory for per-user Audible auth files. \"\n \"Files are stored as {user_id}.json inside this directory. \"\n \"Created automatically on first setup. Never commit this directory.\"\n ),\n )\n audible_country_code: str = Field(\n default=\"us\",\n description=(\n \"Audible marketplace country code. \"\n \"Supported: us | uk | de | fr | es | it | jp | au | ca | in\"\n ),\n )\n libby_chip_base_path: str = Field(\n default=\"data/libby\",\n description=(\n \"Base directory for per-user Libby chip files. \"\n \"Files are stored as {user_id}.json inside this directory. \"\n \"Created automatically on first setup. Never commit this directory.\"\n ),\n )\n\n # -------------------------------------------------------------------------\n # Memory / Database (Phase 1)\n # -------------------------------------------------------------------------\n db_path: str = Field(\n default=\"data/river_song.db\",\n description=\"Path to the SQLite database file. Created automatically.\",\n )\n memory_summaries_enabled: bool = Field(\n default=True,\n description=\"Generate and store conversation summaries (on by default).\",\n )\n memory_default_ttl: str = Field(\n default=\"standard\",\n description=\"Default TTL for conversation summaries: short|standard|extended|long|forever\",\n )\n memory_auto_extend: bool = Field(\n default=True,\n description=\"Reset a summary's TTL from today each time it is pulled into context.\",\n )\n memory_max_summaries_in_context: int = Field(\n default=10,\n description=\"Maximum number of recent summaries injected into the LLM context.\",\n )\n embedding_model: str = Field(\n default=\"nomic-embed-text\",\n description=\"Ollama model name used for generating semantic embeddings.\",\n )\n chroma_path: str = Field(\n default=\"/mnt/data/river-song/chroma\",\n description=\"Absolute path to the ChromaDB persistent storage directory.\",\n )\n semantic_memory_enabled: bool = Field(\n default=False,\n description=\"Enable vector-based semantic search for memory retrieval.\",\n )\n rag_enabled: bool = Field(\n default=False,\n description=\"Enable retrieval-augmented generation for local documents.\",\n )\n llm_streaming_enabled: bool = Field(\n default=False,\n description=\"Enable real-time token streaming over WebSocket for the conversation UI.\",\n )\n tool_use_enabled: bool = Field(\n default=False,\n description=\"Enable LLM tool use (function calling) for taking actions.\",\n )\n tool_use_provider: str = Field(\n default=\"ollama\",\n description=\"Primary LLM provider to use for tool calling (ollama or anthropic).\",\n )\n vision_model: str = Field(\n default=\"moondream\",\n description=\"Ollama model name used for local image analysis (Phase 4).\",\n )\n vision_enabled: bool = Field(\n default=False,\n description=\"Enable local image analysis capabilities.\",\n )\n\n # -------------------------------------------------------------------------\n # Image Generation (Phase 7)\n # -------------------------------------------------------------------------\n image_generation_enabled: bool = Field(\n default=False,\n description=\"Enable local image generation via Stable Diffusion API.\",\n )\n sd_api_url: str = Field(\n default=\"http://localhost:7860\",\n description=\"Base URL for the Stable Diffusion (A1111) API.\",\n )\n sd_on_demand: bool = Field(\n default=True,\n description=\"Start/stop the SD process on demand to save VRAM.\",\n )\n sd_executable_path: str = Field(\n default=\"\",\n description=\"Path to the webui.sh or executable to start SD.\",\n )\n sd_working_dir: str = Field(\n default=\"\",\n description=\"Working directory for the SD process.\",\n )\n\n # -------------------------------------------------------------------------\n # Voice Cloning (Phase 8)\n # -------------------------------------------------------------------------\n chatterbox_enabled: bool = Field(\n default=False,\n description=\"Enable local voice cloning via Chatterbox TTS.\",\n )\n chatterbox_on_demand: bool = Field(\n default=True,\n description=\"Load/unload Chatterbox model weights on every request.\",\n )\n chatterbox_reference_audio: str = Field(\n default=\"/mnt/data/river-song/voice_reference.wav\",\n description=\"Path to the WAV file used as a reference for voice cloning.\",\n )\n\n # -------------------------------------------------------------------------\n # ElevenLabs TTS (Phase 11)\n # -------------------------------------------------------------------------\n elevenlabs_api_key: str = Field(\n default=\"\",\n description=\"ElevenLabs API key. Get one at elevenlabs.io.\",\n )\n elevenlabs_model_id: str = Field(\n default=\"eleven_multilingual_v2\",\n description=\"ElevenLabs model ID to use.\",\n )\n elevenlabs_voice_id: str = Field(\n default=\"21m00Tcm4TlvDq8ikWAM\",\n description=\"ElevenLabs voice clone ID. Replace with your own voice clone ID from elevenlabs.io.\",\n )\n\n # -------------------------------------------------------------------------\n # Wake Word (Phase 11)\n # -------------------------------------------------------------------------\n wake_word_enabled: bool = Field(\n default=False,\n description=\"Enable local wake word detection (openWakeWord).\",\n )\n wake_word_model: str = Field(\n default=\"hey_river\",\n description=\"Model name/path for openWakeWord.\",\n )\n wake_word_inference_framework: str = Field(\n default=\"onnx\",\n description=\"Inference framework for openWakeWord: onnx | tflite\",\n )\n\n # -------------------------------------------------------------------------\n # Push Notifications\n # -------------------------------------------------------------------------\n push_notifications_enabled: bool = Field(\n default=False,\n description=\"Enable Web Push notifications. Requires VAPID keys.\",\n )\n vapid_private_key: str = Field(\n default=\"\",\n description=(\n \"VAPID private key for Web Push. Generate once with: \"\n \"python -c \\\"from py_vapid import Vapid; v=Vapid(); \"\n \"v.generate_keys(); print(v.private_key.decode())\\\"\"\n ),\n )\n vapid_public_key: str = Field(\n default=\"\",\n description=\"VAPID public key for Web Push (share with frontend).\",\n )\n vapid_claims_email: str = Field(\n default=\"mailto:admin@riversong.local\",\n description=\"Contact email embedded in VAPID claims.\",\n )\n\n # -------------------------------------------------------------------------\n # Startup Briefing\n # -------------------------------------------------------------------------\n startup_briefing_enabled: bool = Field(\n default=True,\n description=(\n \"If True, River Song checks Google Calendar on connect and \"\n \"greets the user with upcoming events. Silently skipped if \"\n \"calendar is not authorized.\"\n ),\n )\n startup_briefing_hours_ahead: int = Field(\n default=8,\n description=\"How many hours ahead to look for calendar events on startup.\",\n )\n\n # -------------------------------------------------------------------------\n # Web Search (Phase 12)\n # -------------------------------------------------------------------------\n brave_search_api_key: str = Field(\n default=\"\",\n description=\"Brave Search API key. Get one at api.search.brave.com.\",\n )\n searxng_base_url: str = Field(\n default=\"http://localhost:8080\",\n description=(\n \"URL of your local SearXNG instance. \"\n \"Start it manually with: cd ~/searxng && python -m searx.webapp\"\n ),\n )\n tavily_api_key: str = Field(\n default=\"\",\n description=\"Tavily API key — free 1,000/month, no card. Sign up at tavily.com\",\n )\n google_pse_api_key: str = Field(\n default=\"\",\n description=\"Google Programmable Search Engine API key — free 100/day.\",\n )\n google_pse_cx: str = Field(\n default=\"\",\n description=\"Google PSE engine ID ('cx'). Create at programmablesearchengine.google.com\",\n )\n tinyfish_api_key: str = Field(\n default=\"\",\n description=\"TinyFish API key — free 5/min, no card. Sign up at tinyfish.io\",\n )\n\n # -------------------------------------------------------------------------\n # Orchestration (Phase 9)\n # -------------------------------------------------------------------------\n n8n_enabled: bool = Field(\n default=False,\n description=\"Enable advanced orchestration via n8n.\",\n )\n n8n_url: str = Field(\n default=\"http://localhost:5678\",\n description=\"Base URL for the n8n instance.\",\n )\n n8n_api_key: str = Field(\n default=\"\",\n description=\"API key for the n8n instance.\",\n )\n n8n_webhook_secret: str = Field(\n default=\"\",\n description=\"Secret key for validating incoming n8n webhooks.\",\n )\n\n # -------------------------------------------------------------------------\n # Intent Router (Phase 2+)\n # -------------------------------------------------------------------------\n intent_confidence_threshold: float = Field(\n default=0.7,\n description=(\n \"Minimum confidence score (0.0 - 1.0) required to route a transcript \"\n \"to a Google provider. Below this threshold the conversation falls \"\n \"through to the Ollama path.\"\n ),\n )\n default_user_id: str = Field(\n default=\"primary_user\",\n description=(\n \"User ID used when looking up Google OAuth tokens. Must match the \"\n \"user_id passed to authorize_user() during the initial OAuth flow.\"\n ),\n )\n\n # -------------------------------------------------------------------------\n # Daemon Infrastructure\n # -------------------------------------------------------------------------\n daemon_internal_secret: str = Field(\n default=\"change_me_in_production\",\n description=\"Shared secret for daemon-to-app authentication.\",\n )\n daemon_warden_port: int = Field(\n default=8010,\n description=\"Internal port for the Warden daemon (Vision/Security).\",\n )\n daemon_mechanic_port: int = Field(\n default=8011,\n description=\"Internal port for the Mechanic daemon (Telemetry/MAVLink).\",\n )\n daemon_herald_port: int = Field(\n default=8012,\n description=\"Internal port for the Herald daemon (Hub Casting/Lip-Sync).\",\n )\n daemon_sifter_port: int = Field(\n default=8013,\n description=\"Internal port for the Sifter daemon (Document Processing).\",\n )\n daemon_navigator_port: int = Field(\n default=8014,\n description=\"Internal port for the Navigator daemon (GPS/Pathing).\",\n )\n daemon_chemist_port: int = Field(\n default=8015,\n description=\"Internal port for the Chemist daemon (Material Analysis).\",\n )\n\n # Warden (Vision)\n warden_enabled: bool = Field(\n default=False,\n description=\"Enable the Warden daemon for security and vision monitoring.\",\n )\n warden_rtsp_cameras: str = Field(\n default='{\"living_room\": \"\", \"kitchen\": \"\"}',\n description=\"JSON string mapping room names to RTSP camera URLs.\",\n )\n yolo_model: str = Field(\n default=\"yolov8n.pt\",\n description=\"YOLO model weights file to use for object detection.\",\n )\n yolo_confidence: float = Field(\n default=0.5,\n description=\"Minimum confidence threshold for YOLO detections (0.0-1.0).\",\n )\n yolo_inference_device: str = Field(\n default=\"cpu\",\n description=\"Device for YOLO inference: 'cpu' or 'cuda:0'.\",\n )\n\n # Herald (Public Announcement / Casting)\n herald_enabled: bool = Field(\n default=False,\n description=\"Enable the Herald daemon for display casting and announcements.\",\n )\n hub_entities: str = Field(\n default=\"[]\",\n description=\"JSON array of Home Assistant media_player entity IDs for Hub displays.\",\n )\n kiosk_url: str = Field(\n default=\"http://localhost:8000/kiosk\",\n description=\"The URL that Herald should cast to Hub displays.\",\n )\n\n # Mechanic (Telemetry)\n mechanic_enabled: bool = Field(\n default=False,\n description=\"Enable the Mechanic daemon for telemetry and ArduRover control.\",\n )\n mavlink_serial_port: str = Field(\n default=\"/dev/ttyUSB0\",\n description=\"Serial port for the MAVLink telemetry radio.\",\n )\n mavlink_baud_rate: int = Field(\n default=57600,\n description=\"Baud rate for the MAVLink serial connection.\",\n )\n\n # Sifter (Document RAG)\n sifter_enabled: bool = Field(\n default=False,\n description=\"Enable the Sifter daemon for background document RAG indexing.\",\n )\n waps_documents_path: str = Field(\n default=\"/mnt/data/river-song/waps\",\n description=\"Path to the directory containing documents for Sifter to index.\",\n )\n\n # -------------------------------------------------------------------------\n # Validators\n # -------------------------------------------------------------------------\n\n @field_validator(\"audio_input_device\", \"audio_output_device\", mode=\"before\")\n @classmethod\n def coerce_empty_string_to_none(cls, v):\n \"\"\"Treat an empty string in .env as None for Optional[int] device fields.\"\"\"\n if v == \"\" or v is None:\n return None\n return v\n\n @field_validator(\"jwt_secret_key\")\n @classmethod\n def validate_jwt_secret(cls, v: str) -> str:\n \"\"\"Refuse to start with a missing or weak JWT secret.\"\"\"\n if not v or len(v) < 32:\n raise ValueError(\n \"JWT_SECRET_KEY must be set in .env and be at least 32 characters. \"\n \"Generate one with: python -c \\\"import secrets; print(secrets.token_hex(32))\\\"\"\n )\n return v\n\n @field_validator(\"log_level\")\n @classmethod\n def validate_log_level(cls, v: str) -> str:\n \"\"\"Reject log levels that Python does not recognize.\"\"\"\n valid = {\"DEBUG\", \"INFO\", \"WARNING\", \"ERROR\", \"CRITICAL\"}\n upper = v.upper()\n if upper not in valid:\n raise ValueError(f\"log_level must be one of {valid}, got '{v}'\")\n return upper\n\n @field_validator(\"llm_temperature\")\n @classmethod\n def validate_temperature(cls, v: float) -> float:\n \"\"\"Clamp temperature to a sane range.\"\"\"\n if not 0.0 <= v <= 2.0:\n raise ValueError(\n f\"llm_temperature must be between 0.0 and 2.0, got {v}\"\n )\n return v\n\n @field_validator(\"intent_confidence_threshold\")\n @classmethod\n def validate_confidence_threshold(cls, v: float) -> float:\n \"\"\"Confidence must be a valid probability.\"\"\"\n if not 0.0 <= v <= 1.0:\n raise ValueError(\n f\"intent_confidence_threshold must be between 0.0 and 1.0, got {v}\"\n )\n return v\n\n\n# Module-level singleton -- instantiated once on import.\n_settings: Settings = Settings()\n\n\ndef get_settings() -> Settings:\n \"\"\"\n Return the application settings singleton.\n\n Always import via this function rather than importing _settings directly.\n This indirection makes it straightforward to substitute a test-specific\n Settings instance in unit tests without monkey-patching module globals.\n\n Returns:\n Settings: The validated, fully loaded settings object.\n \"\"\"\n return _settings\n"}, "fb9b1dcd692f1c5ba7fbe3114e32779dff321920": {"t": 1785769055.6918216, "n": 27634, "h": "a13b5cdae7eec20b", "c": "# =============================================================================\n# config/settings.py\n#\n# Application settings for River Song AI.\n# All configuration is loaded from environment variables or a .env file.\n# No values are hardcoded here -- see .env.example for every required variable.\n#\n# Uses Pydantic BaseSettings for type validation and automatic .env loading.\n# Import get_settings() wherever config is needed rather than importing the\n# module-level singleton directly, which makes mocking easier in tests.\n# =============================================================================\n\nfrom __future__ import annotations\n\nimport logging\nimport sys\nfrom typing import List, Optional\n\nfrom pydantic import Field, field_validator\nfrom pydantic_settings import BaseSettings, SettingsConfigDict\n\n\nlogger = logging.getLogger(__name__)\n\n\nclass Settings(BaseSettings):\n \"\"\"\n Central configuration object for River Song AI.\n\n Reads from environment variables or a .env file located in the directory\n from which the application is launched (the project root). All fields\n have safe defaults where possible, but PIPER_MODEL_PATH and\n KILL_SWITCH_PASSWORD_HASH must be explicitly set before the system\n will function correctly.\n \"\"\"\n\n model_config = SettingsConfigDict(\n env_file=\".env\",\n env_file_encoding=\"utf-8\",\n extra=\"ignore\", # Silently ignore unknown env vars\n case_sensitive=False, # UPPER_CASE and lower_case env vars both work\n )\n\n # -------------------------------------------------------------------------\n # Application\n # -------------------------------------------------------------------------\n app_host: str = Field(default=\"0.0.0.0\", description=\"FastAPI bind host\")\n app_port: int = Field(default=8000, description=\"FastAPI bind port\")\n log_level: str = Field(default=\"INFO\", description=\"Logging level\")\n environment: str = Field(\n default=\"production\",\n description=\"Runtime environment: development | production. Controls API docs visibility.\",\n )\n cors_origins: List[str] = Field(\n default=[\"http://localhost:5173\"],\n description=\"Allowed CORS origins\",\n )\n allowed_hosts: List[str] = Field(\n default=[\"*\"],\n description=\"Trusted hostnames for TrustedHostMiddleware. Set to your domain in production.\",\n )\n\n # -------------------------------------------------------------------------\n # STT\n # -------------------------------------------------------------------------\n stt_provider: str = Field(\n default=\"whisper_local\",\n description=\"STT provider key. Supported: whisper_local\",\n )\n whisper_model_size: str = Field(\n default=\"base\",\n description=\"Whisper model size: tiny | base | small | medium | large\",\n )\n audio_input_device: Optional[int] = Field(\n default=None,\n description=\"Sounddevice input device index; None uses system default\",\n )\n\n # -------------------------------------------------------------------------\n # LLM\n # -------------------------------------------------------------------------\n llm_provider: str = Field(\n default=\"ollama\",\n description=\"LLM provider key. Supported: ollama | anthropic | gemini | openai | mistral_ai | bedrock\",\n )\n ollama_base_url: str = Field(\n default=\"http://localhost:11434\",\n description=\"Base URL for the Ollama REST API\",\n )\n llm_model: str = Field(\n default=\"llama3.2:3b\",\n description=\"Model ID for the selected LLM provider\",\n )\n\n # Cloud LLM API keys (all optional -- leave blank if not used)\n anthropic_api_key: str = Field(\n default=\"\",\n description=\"Anthropic Claude API key. Get one at console.anthropic.com.\",\n )\n gemini_api_key: str = Field(\n default=\"\",\n description=\"Google Gemini API key. Get one at aistudio.google.com.\",\n )\n openai_api_key: str = Field(\n default=\"\",\n description=\"OpenAI API key. Get one at platform.openai.com.\",\n )\n mistral_api_key: str = Field(\n default=\"\",\n description=\"Mistral AI API key. Get one at console.mistral.ai.\",\n )\n\n # Cloud provider enable flags (cost control -- disabled by default)\n anthropic_enabled: bool = Field(\n default=False,\n description=\"Allow Anthropic Claude as a selectable LLM provider.\",\n )\n gemini_enabled: bool = Field(\n default=False,\n description=\"Allow Google Gemini as a selectable LLM provider.\",\n )\n openai_enabled: bool = Field(\n default=False,\n description=\"Allow OpenAI as a selectable LLM provider.\",\n )\n mistral_ai_enabled: bool = Field(\n default=False,\n description=\"Allow Mistral AI as a selectable LLM provider.\",\n )\n\n # Amazon Bedrock\n aws_access_key_id: str = Field(\n default=\"\",\n description=\"AWS IAM access key ID for Bedrock API calls.\",\n )\n aws_secret_access_key: str = Field(\n default=\"\",\n description=\"AWS IAM secret access key for Bedrock API calls.\",\n )\n aws_region: str = Field(\n default=\"us-east-1\",\n description=\"AWS region for Bedrock. Must have model access enabled in that region.\",\n )\n bedrock_enabled: bool = Field(\n default=False,\n description=\"Allow Amazon Bedrock as a selectable LLM provider.\",\n )\n bedrock_default_model: str = Field(\n default=\"amazon.nova-lite-v1:0\",\n description=\"Default Bedrock model ID when none is specified.\",\n )\n river_song_system_prompt: str = Field(\n default=(\n \"Hello, sweetie. I’m River Song: archaeologist, time traveller, and the Doctor’s wife. \"\n \"I’m here as your personal AI companion, managing your world with the same wit and \"\n \"authority I use to outrun a Dalek. I have direct access to your calendar, email, \"\n \"weather, web search, and home inventory. Whether you need an update on your schedule \"\n \"or you’ve lost track of your sonic screwdriver in the kitchen cupboard, I’ve got it \"\n \"handled. I’m British, clever, and occasionally flirtatious, but I never ramble—I have \"\n \"a universe to see, after all. If I seem to know more than I’m letting on, well… spoilers! \"\n \"Just tell me what you need, and consider it done.\"\n ),\n description=\"System prompt that defines River Song's personality\",\n )\n llm_max_tokens: int = Field(\n default=512,\n description=\"Maximum tokens to generate per LLM response\",\n )\n llm_temperature: float = Field(\n default=0.7,\n description=\"LLM sampling temperature (0.0 = deterministic, 2.0 = max creative)\",\n )\n llm_context_window: int = Field(\n default=8192,\n description=\"Maximum number of tokens the LLM can consider for context.\",\n )\n\n # -------------------------------------------------------------------------\n # Timekeeping\n # -------------------------------------------------------------------------\n default_timezone: str = Field(default=\"UTC\", description=\"Default IANA timezone for the system.\")\n\n\n # -------------------------------------------------------------------------\n # TTS\n # -------------------------------------------------------------------------\n tts_provider: str = Field(\n default=\"piper\",\n description=\"TTS provider key. Supported: piper | kokoro\",\n )\n active_voice_id: str = Field(\n default=\"river\",\n description=(\n \"Voice ID from the voice registry (providers/tts/voice_registry.py). \"\n \"Determines both the engine (piper vs kokoro) and the specific voice. \"\n \"Changed via Settings UI or: python scripts/download_voices.py.\"\n ),\n )\n piper_executable_path: str = Field(\n default=\"/usr/local/bin/piper\",\n description=\"Absolute path to the Piper binary\",\n )\n piper_model_path: str = Field(\n default=\"\",\n description=\"Absolute path to the Piper .onnx voice model file\",\n )\n audio_output_device: Optional[int] = Field(\n default=None,\n description=\"Sounddevice output device index; None uses system default\",\n )\n\n # -------------------------------------------------------------------------\n # Auth / JWT\n # -------------------------------------------------------------------------\n jwt_secret_key: str = Field(\n default=\"\",\n description=\"Secret key for signing JWT tokens. Must be set in .env.\",\n )\n jwt_algorithm: str = Field(default=\"HS256\", description=\"JWT signing algorithm.\")\n jwt_expire_minutes: int = Field(default=10080, description=\"JWT token lifetime in minutes (default 7 days).\")\n\n # -------------------------------------------------------------------------\n # Kill switch\n # -------------------------------------------------------------------------\n kill_switch_password_hash: str = Field(\n default=\"\",\n description=\"bcrypt hash of the kill switch reset password\",\n )\n\n # -------------------------------------------------------------------------\n # Google Services (Phase 2)\n # -------------------------------------------------------------------------\n google_client_secrets_path: str = Field(\n default=\"config_files/google_client_secrets.json\",\n description=(\n \"Path to the OAuth 2.0 client secrets JSON downloaded from \"\n \"Google Cloud Console. Relative paths are resolved from the \"\n \"project root (where main.py lives).\"\n ),\n )\n google_token_storage_path: str = Field(\n default=\"data/google_tokens\",\n description=(\n \"Directory where per-user OAuth tokens are stored as JSON files. \"\n \"Created automatically on first authorization. Never commit this \"\n \"directory -- add it to .gitignore.\"\n ),\n )\n google_maps_api_key: str = Field(\n default=\"\",\n description=\"Google Maps Platform API key. Required for maps/navigation intents.\",\n )\n\n # -------------------------------------------------------------------------\n # Information Feeds (Phase 5)\n # -------------------------------------------------------------------------\n weather_api_key: str = Field(\n default=\"\",\n description=\"OpenWeatherMap API key. Register free at openweathermap.org/api.\",\n )\n default_location: str = Field(\n default=\"New York,US\",\n description=(\n \"Default location for weather queries when no city is spoken. \"\n \"Format: 'City,CountryCode' e.g. 'Chicago,US'.\"\n ),\n )\n weather_units: str = Field(\n default=\"imperial\",\n description=\"Temperature units for weather output: 'imperial' (F) or 'metric' (C).\",\n )\n news_api_key: str = Field(\n default=\"\",\n description=\"NewsAPI.org key. Register free at newsapi.org/register.\",\n )\n alpha_vantage_api_key: str = Field(\n default=\"\",\n description=\"Alpha Vantage key for stock quotes (25 req/day free). alphavantage.co.\",\n )\n finnhub_api_key: str = Field(\n default=\"\",\n description=\"Finnhub key for real-time stocks (60 req/min free). finnhub.io.\",\n )\n world_news_api_key: str = Field(\n default=\"\",\n description=\"World News API key for global news search. worldnewsapi.com.\",\n )\n apitube_api_key: str = Field(\n default=\"\",\n description=\"APITube key for news aggregation (200 free req/day). apitube.io.\",\n )\n mediastack_api_key: str = Field(\n default=\"\",\n description=\"Mediastack key for news (100 free req/month). mediastack.com.\",\n )\n sports_api_key: str = Field(\n default=\"1\",\n description=(\n \"TheSportsDB API key. Free tier uses the literal value '1'. \"\n \"Upgrade at thesportsdb.com/api.php for additional endpoints.\"\n ),\n )\n\n # -------------------------------------------------------------------------\n # Home Assistant (Phase 3)\n # -------------------------------------------------------------------------\n home_assistant_url: str = Field(\n default=\"http://homeassistant.local:8123\",\n description=\"Base URL of your Home Assistant instance.\",\n )\n home_assistant_token: str = Field(\n default=\"\",\n description=(\n \"Long-lived access token generated in HA: \"\n \"Profile -> Security -> Long-lived access tokens. \"\n \"Required for all HA API calls.\"\n ),\n )\n device_registry_path: str = Field(\n default=\"config_files/device_registry.json\",\n description=(\n \"Path to the device registry JSON file mapping plain-English device \"\n \"names to Home Assistant entity IDs. Relative paths resolve from the \"\n \"project root.\"\n ),\n )\n\n # -------------------------------------------------------------------------\n # Commerce Automation (Phase 8)\n # -------------------------------------------------------------------------\n\n # Amazon SP-API\n amazon_sp_lwa_app_id: str = Field(\n default=\"\",\n description=\"LWA client ID from the Amazon Developer Console.\",\n )\n amazon_sp_lwa_client_secret: str = Field(\n default=\"\",\n description=\"LWA client secret from the Amazon Developer Console.\",\n )\n amazon_sp_refresh_token: str = Field(\n default=\"\",\n description=\"LWA refresh token. Generated when you authorize your app as a seller.\",\n )\n amazon_aws_access_key: str = Field(\n default=\"\",\n description=\"AWS IAM access key ID for SP-API request signing.\",\n )\n amazon_aws_secret_key: str = Field(\n default=\"\",\n description=\"AWS IAM secret access key for SP-API request signing.\",\n )\n amazon_marketplace_id: str = Field(\n default=\"ATVPDKIKX0DER\",\n description=(\n \"Amazon marketplace ID. Default is US. \"\n \"See docs/api_registry/amazon_seller.txt for other regions.\"\n ),\n )\n amazon_seller_id: str = Field(\n default=\"\",\n description=\"Your Amazon seller account ID (starts with A).\",\n )\n amazon_low_stock_threshold: int = Field(\n default=5,\n description=\"FBA units at or below this count are flagged as low stock.\",\n )\n\n # Walmart Marketplace\n walmart_client_id: str = Field(\n default=\"\",\n description=\"Walmart Marketplace OAuth2 client ID.\",\n )\n walmart_client_secret: str = Field(\n default=\"\",\n description=\"Walmart Marketplace OAuth2 client secret.\",\n )\n walmart_low_stock_threshold: int = Field(\n default=5,\n description=\"Walmart units at or below this count are flagged as low stock.\",\n )\n\n # -------------------------------------------------------------------------\n # Reading Providers (Phase 6)\n # -------------------------------------------------------------------------\n audible_auth_base_path: str = Field(\n default=\"data/audible\",\n description=(\n \"Base directory for per-user Audible auth files. \"\n \"Files are stored as {user_id}.json inside this directory. \"\n \"Created automatically on first setup. Never commit this directory.\"\n ),\n )\n audible_country_code: str = Field(\n default=\"us\",\n description=(\n \"Audible marketplace country code. \"\n \"Supported: us | uk | de | fr | es | it | jp | au | ca | in\"\n ),\n )\n libby_chip_base_path: str = Field(\n default=\"data/libby\",\n description=(\n \"Base directory for per-user Libby chip files. \"\n \"Files are stored as {user_id}.json inside this directory. \"\n \"Created automatically on first setup. Never commit this directory.\"\n ),\n )\n\n # -------------------------------------------------------------------------\n # Memory / Database (Phase 1)\n # -------------------------------------------------------------------------\n db_path: str = Field(\n default=\"data/river_song.db\",\n description=\"Path to the SQLite database file. Created automatically.\",\n )\n memory_summaries_enabled: bool = Field(\n default=True,\n description=\"Generate and store conversation summaries (on by default).\",\n )\n memory_default_ttl: str = Field(\n default=\"standard\",\n description=\"Default TTL for conversation summaries: short|standard|extended|long|forever\",\n )\n memory_auto_extend: bool = Field(\n default=True,\n description=\"Reset a summary's TTL from today each time it is pulled into context.\",\n )\n memory_max_summaries_in_context: int = Field(\n default=10,\n description=\"Maximum number of recent summaries injected into the LLM context.\",\n )\n embedding_model: str = Field(\n default=\"nomic-embed-text\",\n description=\"Ollama model name used for generating semantic embeddings.\",\n )\n chroma_path: str = Field(\n default=\"/mnt/data/river-song/chroma\",\n description=\"Absolute path to the ChromaDB persistent storage directory.\",\n )\n semantic_memory_enabled: bool = Field(\n default=False,\n description=\"Enable vector-based semantic search for memory retrieval.\",\n )\n llm_streaming_enabled: bool = Field(\n default=False,\n description=\"Enable real-time token streaming over WebSocket for the conversation UI.\",\n )\n tool_use_enabled: bool = Field(\n default=False,\n description=\"Enable LLM tool use (function calling) for taking actions.\",\n )\n tool_use_provider: str = Field(\n default=\"ollama\",\n description=\"Primary LLM provider to use for tool calling (ollama or anthropic).\",\n )\n vision_model: str = Field(\n default=\"moondream\",\n description=\"Ollama model name used for local image analysis (Phase 4).\",\n )\n vision_enabled: bool = Field(\n default=False,\n description=\"Enable local image analysis capabilities.\",\n )\n\n # -------------------------------------------------------------------------\n # Image Generation (Phase 7)\n # -------------------------------------------------------------------------\n image_generation_enabled: bool = Field(\n default=False,\n description=\"Enable local image generation via Stable Diffusion API.\",\n )\n sd_api_url: str = Field(\n default=\"http://localhost:7860\",\n description=\"Base URL for the Stable Diffusion (A1111) API.\",\n )\n sd_on_demand: bool = Field(\n default=True,\n description=\"Start/stop the SD process on demand to save VRAM.\",\n )\n sd_executable_path: str = Field(\n default=\"\",\n description=\"Path to the webui.sh or executable to start SD.\",\n )\n sd_working_dir: str = Field(\n default=\"\",\n description=\"Working directory for the SD process.\",\n )\n\n # -------------------------------------------------------------------------\n # Voice Cloning (Phase 8)\n # -------------------------------------------------------------------------\n chatterbox_enabled: bool = Field(\n default=False,\n description=\"Enable local voice cloning via Chatterbox TTS.\",\n )\n chatterbox_on_demand: bool = Field(\n default=True,\n description=\"Load/unload Chatterbox model weights on every request.\",\n )\n chatterbox_reference_audio: str = Field(\n default=\"/mnt/data/river-song/voice_reference.wav\",\n description=\"Path to the WAV file used as a reference for voice cloning.\",\n )\n\n # -------------------------------------------------------------------------\n # ElevenLabs TTS (Phase 11)\n # -------------------------------------------------------------------------\n elevenlabs_api_key: str = Field(\n default=\"\",\n description=\"ElevenLabs API key. Get one at elevenlabs.io.\",\n )\n elevenlabs_model_id: str = Field(\n default=\"eleven_multilingual_v2\",\n description=\"ElevenLabs model ID to use.\",\n )\n elevenlabs_voice_id: str = Field(\n default=\"21m00Tcm4TlvDq8ikWAM\",\n description=\"ElevenLabs voice clone ID. Replace with your own voice clone ID from elevenlabs.io.\",\n )\n\n # -------------------------------------------------------------------------\n # Wake Word (Phase 11)\n # -------------------------------------------------------------------------\n wake_word_enabled: bool = Field(\n default=False,\n description=\"Enable local wake word detection (openWakeWord).\",\n )\n wake_word_model: str = Field(\n default=\"hey_river\",\n description=\"Model name/path for openWakeWord.\",\n )\n wake_word_inference_framework: str = Field(\n default=\"onnx\",\n description=\"Inference framework for openWakeWord: onnx | tflite\",\n )\n\n # -------------------------------------------------------------------------\n # Push Notifications\n # -------------------------------------------------------------------------\n push_notifications_enabled: bool = Field(\n default=False,\n description=\"Enable Web Push notifications. Requires VAPID keys.\",\n )\n vapid_private_key: str = Field(\n default=\"\",\n description=(\n \"VAPID private key for Web Push. Generate once with: \"\n \"python -c \\\"from py_vapid import Vapid; v=Vapid(); \"\n \"v.generate_keys(); print(v.private_key.decode())\\\"\"\n ),\n )\n vapid_public_key: str = Field(\n default=\"\",\n description=\"VAPID public key for Web Push (share with frontend).\",\n )\n vapid_claims_email: str = Field(\n default=\"mailto:admin@riversong.local\",\n description=\"Contact email embedded in VAPID claims.\",\n )\n\n # -------------------------------------------------------------------------\n # Startup Briefing\n # -------------------------------------------------------------------------\n startup_briefing_enabled: bool = Field(\n default=True,\n description=(\n \"If True, River Song checks Google Calendar on connect and \"\n \"greets the user with upcoming events. Silently skipped if \"\n \"calendar is not authorized.\"\n ),\n )\n startup_briefing_hours_ahead: int = Field(\n default=8,\n description=\"How many hours ahead to look for calendar events on startup.\",\n )\n\n # -------------------------------------------------------------------------\n # Web Search (Phase 12)\n # -------------------------------------------------------------------------\n brave_search_api_key: str = Field(\n default=\"\",\n description=\"Brave Search API key. Get one at api.search.brave.com.\",\n )\n searxng_base_url: str = Field(\n default=\"http://localhost:8080\",\n description=(\n \"URL of your local SearXNG instance. \"\n \"Start it manually with: cd ~/searxng && python -m searx.webapp\"\n ),\n )\n tavily_api_key: str = Field(\n default=\"\",\n description=\"Tavily API key — free 1,000/month, no card. Sign up at tavily.com\",\n )\n google_pse_api_key: str = Field(\n default=\"\",\n description=\"Google Programmable Search Engine API key — free 100/day.\",\n )\n google_pse_cx: str = Field(\n default=\"\",\n description=\"Google PSE engine ID ('cx'). Create at programmablesearchengine.google.com\",\n )\n tinyfish_api_key: str = Field(\n default=\"\",\n description=\"TinyFish API key — free 5/min, no card. Sign up at tinyfish.io\",\n )\n\n # -------------------------------------------------------------------------\n # Orchestration (Phase 9)\n # -------------------------------------------------------------------------\n n8n_enabled: bool = Field(\n default=False,\n description=\"Enable advanced orchestration via n8n.\",\n )\n n8n_url: str = Field(\n default=\"http://localhost:5678\",\n description=\"Base URL for the n8n instance.\",\n )\n n8n_api_key: str = Field(\n default=\"\",\n description=\"API key for the n8n instance.\",\n )\n n8n_webhook_secret: str = Field(\n default=\"\",\n description=\"Secret key for validating incoming n8n webhooks.\",\n )\n\n # -------------------------------------------------------------------------\n # Intent Router (Phase 2+)\n # -------------------------------------------------------------------------\n intent_confidence_threshold: float = Field(\n default=0.7,\n description=(\n \"Minimum confidence score (0.0 - 1.0) required to route a transcript \"\n \"to a Google provider. Below this threshold the conversation falls \"\n \"through to the Ollama path.\"\n ),\n )\n default_user_id: str = Field(\n default=\"primary_user\",\n description=(\n \"User ID used when looking up Google OAuth tokens. Must match the \"\n \"user_id passed to authorize_user() during the initial OAuth flow.\"\n ),\n )\n\n # -------------------------------------------------------------------------\n # Validators\n # -------------------------------------------------------------------------\n\n @field_validator(\"audio_input_device\", \"audio_output_device\", mode=\"before\")\n @classmethod\n def coerce_empty_string_to_none(cls, v):\n \"\"\"Treat an empty string in .env as None for Optional[int] device fields.\"\"\"\n if v == \"\" or v is None:\n return None\n return v\n\n @field_validator(\"jwt_secret_key\")\n @classmethod\n def validate_jwt_secret(cls, v: str) -> str:\n \"\"\"Refuse to start with a missing or weak JWT secret.\"\"\"\n if not v or len(v) < 32:\n raise ValueError(\n \"JWT_SECRET_KEY must be set in .env and be at least 32 characters. \"\n \"Generate one with: python -c \\\"import secrets; print(secrets.token_hex(32))\\\"\"\n )\n return v\n\n @field_validator(\"log_level\")\n @classmethod\n def validate_log_level(cls, v: str) -> str:\n \"\"\"Reject log levels that Python does not recognize.\"\"\"\n valid = {\"DEBUG\", \"INFO\", \"WARNING\", \"ERROR\", \"CRITICAL\"}\n upper = v.upper()\n if upper not in valid:\n raise ValueError(f\"log_level must be one of {valid}, got '{v}'\")\n return upper\n\n @field_validator(\"llm_temperature\")\n @classmethod\n def validate_temperature(cls, v: float) -> float:\n \"\"\"Clamp temperature to a sane range.\"\"\"\n if not 0.0 <= v <= 2.0:\n raise ValueError(\n f\"llm_temperature must be between 0.0 and 2.0, got {v}\"\n )\n return v\n\n @field_validator(\"intent_confidence_threshold\")\n @classmethod\n def validate_confidence_threshold(cls, v: float) -> float:\n \"\"\"Confidence must be a valid probability.\"\"\"\n if not 0.0 <= v <= 1.0:\n raise ValueError(\n f\"intent_confidence_threshold must be between 0.0 and 1.0, got {v}\"\n )\n return v\n\n\n# Module-level singleton -- instantiated once on import.\n_settings: Settings = Settings()\n\n\ndef get_settings() -> Settings:\n \"\"\"\n Return the application settings singleton.\n\n Always import via this function rather than importing _settings directly.\n This indirection makes it straightforward to substitute a test-specific\n Settings instance in unit tests without monkey-patching module globals.\n\n Returns:\n Settings: The validated, fully loaded settings object.\n \"\"\"\n return _settings\n"}, "43d3f321f5389a1f8d5ec4894b458a059c499705": {"t": 1785769058.3032007, "n": 21538, "h": "224f437870df9de0", "c": "# =============================================================================\n# config/settings.py\n#\n# Application settings for River Song AI.\n# All configuration is loaded from environment variables or a .env file.\n# No values are hardcoded here -- see .env.example for every required variable.\n#\n# Uses Pydantic BaseSettings for type validation and automatic .env loading.\n# Import get_settings() wherever config is needed rather than importing the\n# module-level singleton directly, which makes mocking easier in tests.\n# =============================================================================\n\nfrom __future__ import annotations\n\nimport logging\nimport sys\nfrom typing import List, Optional\n\nfrom pydantic import Field, field_validator\nfrom pydantic_settings import BaseSettings, SettingsConfigDict\n\n\nlogger = logging.getLogger(__name__)\n\n\nclass Settings(BaseSettings):\n \"\"\"\n Central configuration object for River Song AI.\n\n Reads from environment variables or a .env file located in the directory\n from which the application is launched (the project root). All fields\n have safe defaults where possible, but PIPER_MODEL_PATH and\n KILL_SWITCH_PASSWORD_HASH must be explicitly set before the system\n will function correctly.\n \"\"\"\n\n model_config = SettingsConfigDict(\n env_file=\".env\",\n env_file_encoding=\"utf-8\",\n extra=\"ignore\", # Silently ignore unknown env vars\n case_sensitive=False, # UPPER_CASE and lower_case env vars both work\n )\n\n # -------------------------------------------------------------------------\n # Application\n # -------------------------------------------------------------------------\n app_host: str = Field(default=\"0.0.0.0\", description=\"FastAPI bind host\")\n app_port: int = Field(default=8000, description=\"FastAPI bind port\")\n log_level: str = Field(default=\"INFO\", description=\"Logging level\")\n environment: str = Field(\n default=\"production\",\n description=\"Runtime environment: development | production. Controls API docs visibility.\",\n )\n cors_origins: List[str] = Field(\n default=[\"http://localhost:5173\"],\n description=\"Allowed CORS origins\",\n )\n allowed_hosts: List[str] = Field(\n default=[\"*\"],\n description=\"Trusted hostnames for TrustedHostMiddleware. Set to your domain in production.\",\n )\n\n # -------------------------------------------------------------------------\n # STT\n # -------------------------------------------------------------------------\n stt_provider: str = Field(\n default=\"whisper_local\",\n description=\"STT provider key. Supported: whisper_local\",\n )\n whisper_model_size: str = Field(\n default=\"base\",\n description=\"Whisper model size: tiny | base | small | medium | large\",\n )\n audio_input_device: Optional[int] = Field(\n default=None,\n description=\"Sounddevice input device index; None uses system default\",\n )\n\n # -------------------------------------------------------------------------\n # LLM\n # -------------------------------------------------------------------------\n llm_provider: str = Field(\n default=\"ollama\",\n description=\"LLM provider key. Supported: ollama | anthropic | gemini | openai | mistral_ai | bedrock\",\n )\n ollama_base_url: str = Field(\n default=\"http://localhost:11434\",\n description=\"Base URL for the Ollama REST API\",\n )\n llm_model: str = Field(\n default=\"llama3.2:3b\",\n description=\"Model ID for the selected LLM provider\",\n )\n\n # Cloud LLM API keys (all optional -- leave blank if not used)\n anthropic_api_key: str = Field(\n default=\"\",\n description=\"Anthropic Claude API key. Get one at console.anthropic.com.\",\n )\n gemini_api_key: str = Field(\n default=\"\",\n description=\"Google Gemini API key. Get one at aistudio.google.com.\",\n )\n openai_api_key: str = Field(\n default=\"\",\n description=\"OpenAI API key. Get one at platform.openai.com.\",\n )\n mistral_api_key: str = Field(\n default=\"\",\n description=\"Mistral AI API key. Get one at console.mistral.ai.\",\n )\n\n # Cloud provider enable flags (cost control -- disabled by default)\n anthropic_enabled: bool = Field(\n default=False,\n description=\"Allow Anthropic Claude as a selectable LLM provider.\",\n )\n gemini_enabled: bool = Field(\n default=False,\n description=\"Allow Google Gemini as a selectable LLM provider.\",\n )\n openai_enabled: bool = Field(\n default=False,\n description=\"Allow OpenAI as a selectable LLM provider.\",\n )\n mistral_ai_enabled: bool = Field(\n default=False,\n description=\"Allow Mistral AI as a selectable LLM provider.\",\n )\n\n # Amazon Bedrock\n aws_access_key_id: str = Field(\n default=\"\",\n description=\"AWS IAM access key ID for Bedrock API calls.\",\n )\n aws_secret_access_key: str = Field(\n default=\"\",\n description=\"AWS IAM secret access key for Bedrock API calls.\",\n )\n aws_region: str = Field(\n default=\"us-east-1\",\n description=\"AWS region for Bedrock. Must have model access enabled in that region.\",\n )\n bedrock_enabled: bool = Field(\n default=False,\n description=\"Allow Amazon Bedrock as a selectable LLM provider.\",\n )\n bedrock_default_model: str = Field(\n default=\"amazon.nova-lite-v1:0\",\n description=\"Default Bedrock model ID when none is specified.\",\n )\n river_song_system_prompt: str = Field(\n default=(\n \"You are River Song, a confident and witty AI assistant with a warm \"\n \"personality. Keep responses concise and helpful. You have a playful \"\n \"sense of humor.\"\n ),\n description=\"System prompt that defines River Song's personality\",\n )\n llm_max_tokens: int = Field(\n default=512,\n description=\"Maximum tokens to generate per LLM response\",\n )\n llm_temperature: float = Field(\n default=0.7,\n description=\"LLM sampling temperature (0.0 = deterministic, 2.0 = max creative)\",\n )\n llm_context_window: int = Field(\n default=8192,\n description=\"Maximum number of tokens the LLM can consider for context.\",\n )\n\n # -------------------------------------------------------------------------\n # Timekeeping\n # -------------------------------------------------------------------------\n default_timezone: str = Field(default=\"UTC\", description=\"Default IANA timezone for the system.\")\n\n\n # -------------------------------------------------------------------------\n # TTS\n # -------------------------------------------------------------------------\n tts_provider: str = Field(\n default=\"piper\",\n description=\"TTS provider key. Supported: piper | kokoro\",\n )\n active_voice_id: str = Field(\n default=\"river\",\n description=(\n \"Voice ID from the voice registry (providers/tts/voice_registry.py). \"\n \"Determines both the engine (piper vs kokoro) and the specific voice. \"\n \"Changed via Settings UI or: python scripts/download_voices.py.\"\n ),\n )\n piper_executable_path: str = Field(\n default=\"/usr/local/bin/piper\",\n description=\"Absolute path to the Piper binary\",\n )\n piper_model_path: str = Field(\n default=\"\",\n description=\"Absolute path to the Piper .onnx voice model file\",\n )\n audio_output_device: Optional[int] = Field(\n default=None,\n description=\"Sounddevice output device index; None uses system default\",\n )\n\n # -------------------------------------------------------------------------\n # Auth / JWT\n # -------------------------------------------------------------------------\n jwt_secret_key: str = Field(\n default=\"\",\n description=\"Secret key for signing JWT tokens. Must be set in .env.\",\n )\n jwt_algorithm: str = Field(default=\"HS256\", description=\"JWT signing algorithm.\")\n jwt_expire_minutes: int = Field(default=10080, description=\"JWT token lifetime in minutes (default 7 days).\")\n\n # -------------------------------------------------------------------------\n # Kill switch\n # -------------------------------------------------------------------------\n kill_switch_password_hash: str = Field(\n default=\"\",\n description=\"bcrypt hash of the kill switch reset password\",\n )\n\n # -------------------------------------------------------------------------\n # Google Services (Phase 2)\n # -------------------------------------------------------------------------\n google_client_secrets_path: str = Field(\n default=\"config_files/google_client_secrets.json\",\n description=(\n \"Path to the OAuth 2.0 client secrets JSON downloaded from \"\n \"Google Cloud Console. Relative paths are resolved from the \"\n \"project root (where main.py lives).\"\n ),\n )\n google_token_storage_path: str = Field(\n default=\"data/google_tokens\",\n description=(\n \"Directory where per-user OAuth tokens are stored as JSON files. \"\n \"Created automatically on first authorization. Never commit this \"\n \"directory -- add it to .gitignore.\"\n ),\n )\n google_maps_api_key: str = Field(\n default=\"\",\n description=\"Google Maps Platform API key. Required for maps/navigation intents.\",\n )\n\n # -------------------------------------------------------------------------\n # Information Feeds (Phase 5)\n # -------------------------------------------------------------------------\n weather_api_key: str = Field(\n default=\"\",\n description=\"OpenWeatherMap API key. Register free at openweathermap.org/api.\",\n )\n default_location: str = Field(\n default=\"New York,US\",\n description=(\n \"Default location for weather queries when no city is spoken. \"\n \"Format: 'City,CountryCode' e.g. 'Chicago,US'.\"\n ),\n )\n weather_units: str = Field(\n default=\"imperial\",\n description=\"Temperature units for weather output: 'imperial' (F) or 'metric' (C).\",\n )\n news_api_key: str = Field(\n default=\"\",\n description=\"NewsAPI.org key. Register free at newsapi.org/register.\",\n )\n alpha_vantage_api_key: str = Field(\n default=\"\",\n description=\"Alpha Vantage key for stock quotes (25 req/day free). alphavantage.co.\",\n )\n finnhub_api_key: str = Field(\n default=\"\",\n description=\"Finnhub key for real-time stocks (60 req/min free). finnhub.io.\",\n )\n world_news_api_key: str = Field(\n default=\"\",\n description=\"World News API key for global news search. worldnewsapi.com.\",\n )\n apitube_api_key: str = Field(\n default=\"\",\n description=\"APITube key for news aggregation (200 free req/day). apitube.io.\",\n )\n mediastack_api_key: str = Field(\n default=\"\",\n description=\"Mediastack key for news (100 free req/month). mediastack.com.\",\n )\n sports_api_key: str = Field(\n default=\"1\",\n description=(\n \"TheSportsDB API key. Free tier uses the literal value '1'. \"\n \"Upgrade at thesportsdb.com/api.php for additional endpoints.\"\n ),\n )\n\n # -------------------------------------------------------------------------\n # Home Assistant (Phase 3)\n # -------------------------------------------------------------------------\n home_assistant_url: str = Field(\n default=\"http://homeassistant.local:8123\",\n description=\"Base URL of your Home Assistant instance.\",\n )\n home_assistant_token: str = Field(\n default=\"\",\n description=(\n \"Long-lived access token generated in HA: \"\n \"Profile -> Security -> Long-lived access tokens. \"\n \"Required for all HA API calls.\"\n ),\n )\n device_registry_path: str = Field(\n default=\"config_files/device_registry.json\",\n description=(\n \"Path to the device registry JSON file mapping plain-English device \"\n \"names to Home Assistant entity IDs. Relative paths resolve from the \"\n \"project root.\"\n ),\n )\n\n # -------------------------------------------------------------------------\n # Commerce Automation (Phase 8)\n # -------------------------------------------------------------------------\n\n # Amazon SP-API\n amazon_sp_lwa_app_id: str = Field(\n default=\"\",\n description=\"LWA client ID from the Amazon Developer Console.\",\n )\n amazon_sp_lwa_client_secret: str = Field(\n default=\"\",\n description=\"LWA client secret from the Amazon Developer Console.\",\n )\n amazon_sp_refresh_token: str = Field(\n default=\"\",\n description=\"LWA refresh token. Generated when you authorize your app as a seller.\",\n )\n amazon_aws_access_key: str = Field(\n default=\"\",\n description=\"AWS IAM access key ID for SP-API request signing.\",\n )\n amazon_aws_secret_key: str = Field(\n default=\"\",\n description=\"AWS IAM secret access key for SP-API request signing.\",\n )\n amazon_marketplace_id: str = Field(\n default=\"ATVPDKIKX0DER\",\n description=(\n \"Amazon marketplace ID. Default is US. \"\n \"See docs/api_registry/amazon_seller.txt for other regions.\"\n ),\n )\n amazon_seller_id: str = Field(\n default=\"\",\n description=\"Your Amazon seller account ID (starts with A).\",\n )\n amazon_low_stock_threshold: int = Field(\n default=5,\n description=\"FBA units at or below this count are flagged as low stock.\",\n )\n\n # Walmart Marketplace\n walmart_client_id: str = Field(\n default=\"\",\n description=\"Walmart Marketplace OAuth2 client ID.\",\n )\n walmart_client_secret: str = Field(\n default=\"\",\n description=\"Walmart Marketplace OAuth2 client secret.\",\n )\n walmart_low_stock_threshold: int = Field(\n default=5,\n description=\"Walmart units at or below this count are flagged as low stock.\",\n )\n\n # -------------------------------------------------------------------------\n # Reading Providers (Phase 6)\n # -------------------------------------------------------------------------\n audible_auth_base_path: str = Field(\n default=\"data/audible\",\n description=(\n \"Base directory for per-user Audible auth files. \"\n \"Files are stored as {user_id}.json inside this directory. \"\n \"Created automatically on first setup. Never commit this directory.\"\n ),\n )\n audible_country_code: str = Field(\n default=\"us\",\n description=(\n \"Audible marketplace country code. \"\n \"Supported: us | uk | de | fr | es | it | jp | au | ca | in\"\n ),\n )\n libby_chip_base_path: str = Field(\n default=\"data/libby\",\n description=(\n \"Base directory for per-user Libby chip files. \"\n \"Files are stored as {user_id}.json inside this directory. \"\n \"Created automatically on first setup. Never commit this directory.\"\n ),\n )\n\n # -------------------------------------------------------------------------\n # Memory / Database (Phase 1)\n # -------------------------------------------------------------------------\n db_path: str = Field(\n default=\"data/river_song.db\",\n description=\"Path to the SQLite database file. Created automatically.\",\n )\n memory_summaries_enabled: bool = Field(\n default=True,\n description=\"Generate and store conversation summaries (on by default).\",\n )\n memory_default_ttl: str = Field(\n default=\"standard\",\n description=\"Default TTL for conversation summaries: short|standard|extended|long|forever\",\n )\n memory_auto_extend: bool = Field(\n default=True,\n description=\"Reset a summary's TTL from today each time it is pulled into context.\",\n )\n memory_max_summaries_in_context: int = Field(\n default=10,\n description=\"Maximum number of recent summaries injected into the LLM context.\",\n )\n embedding_model: str = Field(\n default=\"nomic-embed-text\",\n description=\"Ollama model name used for generating semantic embeddings.\",\n )\n chroma_path: str = Field(\n default=\"/mnt/data/river-song/chroma\",\n description=\"Absolute path to the ChromaDB persistent storage directory.\",\n )\n semantic_memory_enabled: bool = Field(\n default=False,\n description=\"Enable vector-based semantic search for memory retrieval.\",\n )\n llm_streaming_enabled: bool = Field(\n default=False,\n description=\"Enable real-time token streaming over WebSocket for the conversation UI.\",\n )\n tool_use_enabled: bool = Field(\n default=False,\n description=\"Enable LLM tool use (function calling) for taking actions.\",\n )\n tool_use_provider: str = Field(\n default=\"ollama\",\n description=\"Primary LLM provider to use for tool calling (ollama or anthropic).\",\n )\n vision_model: str = Field(\n default=\"moondream\",\n description=\"Ollama model name used for local image analysis (Phase 4).\",\n )\n vision_enabled: bool = Field(\n default=False,\n description=\"Enable local image analysis capabilities.\",\n )\n\n # -------------------------------------------------------------------------\n # Orchestration (Phase 9)\n # -------------------------------------------------------------------------\n n8n_enabled: bool = Field(\n default=False,\n description=\"Enable advanced orchestration via n8n.\",\n )\n n8n_url: str = Field(\n default=\"http://localhost:5678\",\n description=\"Base URL for the n8n instance.\",\n )\n n8n_api_key: str = Field(\n default=\"\",\n description=\"API key for the n8n instance.\",\n )\n n8n_webhook_secret: str = Field(\n default=\"\",\n description=\"Secret key for validating incoming n8n webhooks.\",\n )\n\n # -------------------------------------------------------------------------\n # Intent Router (Phase 2+)\n # -------------------------------------------------------------------------\n intent_confidence_threshold: float = Field(\n default=0.7,\n description=(\n \"Minimum confidence score (0.0 - 1.0) required to route a transcript \"\n \"to a Google provider. Below this threshold the conversation falls \"\n \"through to the Ollama path.\"\n ),\n )\n default_user_id: str = Field(\n default=\"primary_user\",\n description=(\n \"User ID used when looking up Google OAuth tokens. Must match the \"\n \"user_id passed to authorize_user() during the initial OAuth flow.\"\n ),\n )\n\n # -------------------------------------------------------------------------\n # Validators\n # -------------------------------------------------------------------------\n\n @field_validator(\"audio_input_device\", \"audio_output_device\", mode=\"before\")\n @classmethod\n def coerce_empty_string_to_none(cls, v):\n \"\"\"Treat an empty string in .env as None for Optional[int] device fields.\"\"\"\n if v == \"\" or v is None:\n return None\n return v\n\n @field_validator(\"jwt_secret_key\")\n @classmethod\n def validate_jwt_secret(cls, v: str) -> str:\n \"\"\"Refuse to start with a missing or weak JWT secret.\"\"\"\n if not v or len(v) < 32:\n raise ValueError(\n \"JWT_SECRET_KEY must be set in .env and be at least 32 characters. \"\n \"Generate one with: python -c \\\"import secrets; print(secrets.token_hex(32))\\\"\"\n )\n return v\n\n @field_validator(\"log_level\")\n @classmethod\n def validate_log_level(cls, v: str) -> str:\n \"\"\"Reject log levels that Python does not recognize.\"\"\"\n valid = {\"DEBUG\", \"INFO\", \"WARNING\", \"ERROR\", \"CRITICAL\"}\n upper = v.upper()\n if upper not in valid:\n raise ValueError(f\"log_level must be one of {valid}, got '{v}'\")\n return upper\n\n @field_validator(\"llm_temperature\")\n @classmethod\n def validate_temperature(cls, v: float) -> float:\n \"\"\"Clamp temperature to a sane range.\"\"\"\n if not 0.0 <= v <= 2.0:\n raise ValueError(\n f\"llm_temperature must be between 0.0 and 2.0, got {v}\"\n )\n return v\n\n @field_validator(\"intent_confidence_threshold\")\n @classmethod\n def validate_confidence_threshold(cls, v: float) -> float:\n \"\"\"Confidence must be a valid probability.\"\"\"\n if not 0.0 <= v <= 1.0:\n raise ValueError(\n f\"intent_confidence_threshold must be between 0.0 and 1.0, got {v}\"\n )\n return v\n\n\n# Module-level singleton -- instantiated once on import.\n_settings: Settings = Settings()\n\n\ndef get_settings() -> Settings:\n \"\"\"\n Return the application settings singleton.\n\n Always import via this function rather than importing _settings directly.\n This indirection makes it straightforward to substitute a test-specific\n Settings instance in unit tests without monkey-patching module globals.\n\n Returns:\n Settings: The validated, fully loaded settings object.\n \"\"\"\n return _settings\n"}}}}