Ctrl K
ComunidadGuías…

rex/config.py

Rama master · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
"""Central configuration loader and CLI utilities for the Rex assistant.

Uses rex_config.json for non-secret settings and the OS-backed vault for secrets.
"""

from __future__ import annotations

# ruff: noqa: I001, UP006, UP035, UP045

import argparse
import json
import os
import sys
from dataclasses import asdict, dataclass, field
from urllib.parse import urlparse

from rex.exception_handler import wrap_entrypoint
from pathlib import Path
import warnings

from typing import ClassVar, Dict, List, Optional

from pydantic import BaseModel, ConfigDict, field_validator

try:
    from dotenv import load_dotenv, set_key
except ImportError:

    def load_dotenv(*args, **kwargs):  # type: ignore[misc]
        return False

    def set_key(env_path: str, key: str, value: str):  # type: ignore[misc]
        path = Path(env_path)
        lines = (
            [line for line in path.read_text().splitlines() if not line.startswith(f"{key}=")]
            if path.exists()
            else []
        )
        lines.append(f"{key}={value}")
        path.write_text("\n".join(lines) + "\n")
        return key, value, True


from rex.assistant_errors import ConfigurationError
from rex.config_manager import get_legacy_env_warnings
from rex.log_paths import DEFAULT_ERROR_LOG_FILE, DEFAULT_RUNTIME_LOG_FILE
from rex.logging_utils import get_logger, set_global_level
from rex.runtime_paths import env_path as resolve_env_path
from rex.runtime_paths import household_data_path

from rex.profile_manager import (
    DEFAULT_PROFILES_DIR,
    apply_profile,
    ensure_default_profile,
    get_active_profile_name,
    load_profile,
)

LOGGER = get_logger(__name__)
OPENROUTER_DEFAULT_BASE_URL = "https://openrouter.ai/api/v1"
ENV_PATH = resolve_env_path()


def _parse_int(name: str, value: Optional[str], *, default: int = 0) -> int:
    """Parse integer from string value.

    Args:
        name: Parameter name (for error messages, unused)
        value: String value to parse
        default: Default value if parsing fails

    Returns:
        Parsed integer or default
    """
    if value is None:
        return default
    try:
        return int(float(value))
    except (ValueError, TypeError):
        return default


def resolve_wakeword_keyword(
    keyword: Optional[str],
    wakeword: Optional[str],
    *,
    default: Optional[str] = None,
) -> Optional[str]:
    for candidate in (keyword, wakeword, default):
        if candidate is None:
            continue
        if isinstance(candidate, str) and candidate.strip():
            return candidate.strip()
    return None


@dataclass
class ModelRoutingConfig:
    """Maps task categories to LLM model identifiers.

    Each field accepts a model identifier string (e.g. ``"gpt-4o"``,
    ``"llama3"``) or an empty string to fall back to the global
    ``AppConfig.llm_model`` setting.  All fields are optional.
    """

    default: str = ""
    coding: str = ""
    reasoning: str = ""
    search: str = ""
    vision: str = ""
    fast: str = ""


@dataclass
class EmailAccountConfig:
    """Configuration for a single email account (IMAP read + SMTP send)."""

    id: str
    address: str
    imap_host: str
    imap_port: int = 993
    smtp_host: str = ""
    smtp_port: int = 587
    credential_ref: str = ""
    use_starttls: bool = True


@dataclass
class UserEmailAccount:
    """Per-user email account entry (US-ME-001).

    Lightweight descriptor that names an email account, its backend type, and
    the ``.env`` key where credentials are stored.  Full connection details
    (host, port, etc.) are resolved at runtime from the credentials key.
    """

    account_id: str
    display_name: str = ""
    backend: str = "imap"  # "imap" | "gmail" | "outlook"
    credentials_key: str = ""  # e.g. "EMAIL_ALICE_WORK" in .env


# ---------------------------------------------------------------------------
# Sub-config Pydantic v2 models (US-001)
# These are additive — AppConfig is unchanged.  Nested fields will be wired
# in US-002.  `extra="ignore"` lets unknown JSON keys pass through silently.
# ---------------------------------------------------------------------------


class AudioConfig(BaseModel):
    """Audio hardware settings."""

    model_config = ConfigDict(extra="ignore")

    sample_rate: int = 16000
    channels: int = 1
    chunk_size: int = 1024
    input_device: Optional[int] = None
    output_device: Optional[int] = None
    vad_sensitivity: float = 0.003  # matches command_vad_rms_threshold default


class VoiceConfig(BaseModel):
    """Voice-pipeline settings (TTS, STT, wake word)."""

    model_config = ConfigDict(extra="ignore")

    tts_engine: str = "xtts"  # maps to tts_provider
    tts_voice: Optional[str] = None
    tts_speed: float = 1.08
    stt_model: str = "base"  # maps to whisper_model
    whisper_device: str = "auto"
    wakeword_model: str = "hey_rex"  # maps to wakeword
    wakeword_sensitivity: float = 0.5  # maps to wakeword_threshold
    wakeword_fallback_keyword: str = "hey jarvis"
    wakeword_backend: str = "openwakeword"


class LLMConfig(BaseModel):
    """Language model settings."""

    model_config = ConfigDict(extra="ignore")

    llm_provider: str = "transformers"
    model_name: Optional[str] = (
        "sshleifer/tiny-gpt2"  # maps to llm_model; None when openai_model is used
    )
    openai_api_key_env: str = "OPENAI_API_KEY"  # env var name (not the value)
    openrouter_api_key_env: str = "OPENROUTER_API_KEY"
    openrouter_model: Optional[str] = None
    openrouter_base_url: str = OPENROUTER_DEFAULT_BASE_URL
    ollama_url: str = "http://localhost:11434"  # maps to ollama_base_url
    context_length: int = 120  # maps to llm_max_tokens
    temperature: float = 0.7  # maps to llm_temperature
    llm_routing_mode: str = "local_preferred"


class ToolsConfig(BaseModel):
    """Tool dispatch settings."""

    model_config = ConfigDict(extra="ignore")

    tool_timeout: float = 10.0  # maps to tool_timeout_seconds
    tool_max_retries: int = 3
    enabled_tools: List[str] = []
    tool_permissions: Dict[str, List[str]] = {}


class IntegrationsConfig(BaseModel):
    """External integration settings."""

    model_config = ConfigDict(extra="ignore")

    home_assistant_base_url: Optional[str] = None  # maps to ha_base_url
    ha_token_env: str = "HA_TOKEN"  # env var name holding the HA token
    email_provider: str = "none"
    calendar_provider: str = "none"
    music_assistant_url: Optional[str] = None
    music_assistant_token_env: str = "MUSIC_ASSISTANT_TOKEN"
    shopping_pwa_pin: Optional[str] = None
    openclaw_gateway_url: str = ""
    openclaw_gateway_timeout: int = 30
    openclaw_gateway_max_retries: int = 3


class UIConfig(BaseModel):
    """GUI and dashboard settings."""

    model_config = ConfigDict(extra="ignore")

    gui_port: int = 5000
    gui_host: str = "127.0.0.1"
    ui_enabled: bool = True
    theme: str = "system"


class SecurityConfig(BaseModel):
    """API security settings."""

    model_config = ConfigDict(extra="ignore")

    api_key_env: str = "REX_SPEAK_API_KEY"  # env var name for the speak API key
    rate_limit_per_minute: int = 30  # maps to rate_limit
    allowed_origins: List[str] = ["*"]


_RATE_LIMIT_PATTERN = r"^\d+\s*(?:per\s+|/)\s*\d*\s*(?:second|minute|hour|day|month|year)s?$"


class MobileApiConfig(BaseModel):
    """Typed configuration for the authenticated mobile API gateway (issue #323).

    Canonical JSON group: ``mobile_api`` in ``config/rex_config.json``.
    The JWT signing secret is not part of this model. It is stored in the
    credential vault as ``REX_JWT_SECRET``.
    """

    model_config = ConfigDict(extra="ignore")

    enabled: bool = False
    host: str = "127.0.0.1"
    port: int = 8765
    advertised_host: str | None = None
    advertised_port: int | None = None
    allowed_origins: List[str] = []
    # S7: any non-loopback host always requires usable TLS regardless of this
    # flag — rex.mobile_api.tls.resolve_mobile_tls() enforces that and fails
    # closed when TLS material cannot be provisioned. This flag only opts a
    # loopback (127.0.0.1/localhost) bind into TLS for local testing; it
    # cannot disable TLS for a non-loopback bind.
    require_tls: bool = False
    api_version: str = "1.0"
    access_token_ttl_seconds: int = 900
    refresh_token_ttl_days: int = 30
    max_json_bytes: int = 1_048_576
    max_audio_bytes: int = 15_728_640
    max_audio_seconds: int = 60
    rate_limit_default: str = "60 per minute"
    rate_limit_login: str = "10 per minute"
    rate_limit_refresh: str = "30 per minute"
    rate_limit_chat: str = "30 per minute"
    rate_limit_voice: str = "10 per minute"
    # Retention window for the cross-transport (user_id, message_id)
    # idempotency records; must cover realistic reconnect/retry behaviour.
    idempotency_retention_hours: int = 48

    @field_validator("host", "api_version")
    @classmethod
    def _non_empty(cls, value: str) -> str:
        value = value.strip()
        if not value:
            raise ValueError("must not be empty")
        return value

    @field_validator("advertised_host")
    @classmethod
    def _valid_advertised_host(cls, value: str | None) -> str | None:
        if value is None:
            return None
        value = value.strip().strip("[]").lower()
        if not value or value in {"0.0.0.0", "::", "*"}:
            raise ValueError("advertised_host must be a concrete host")
        return value

    @field_validator("port", "advertised_port")
    @classmethod
    def _valid_port(cls, value: int | None) -> int | None:
        if value is not None and not 1 <= value <= 65535:
            raise ValueError("port must be between 1 and 65535")
        return value

    @field_validator(
        "access_token_ttl_seconds",
        "refresh_token_ttl_days",
        "max_json_bytes",
        "max_audio_bytes",
        "max_audio_seconds",
        "idempotency_retention_hours",
    )
    @classmethod
    def _positive(cls, value: int) -> int:
        if value <= 0:
            raise ValueError("must be a positive integer")
        return value

    @field_validator(
        "rate_limit_default",
        "rate_limit_login",
        "rate_limit_refresh",
        "rate_limit_chat",
        "rate_limit_voice",
    )
    @classmethod
    def _valid_rate_limit(cls, value: str) -> str:
        import re as _re  # noqa: PLC0415

        value = value.strip()
        if not _re.fullmatch(_RATE_LIMIT_PATTERN, value):
            raise ValueError(f"invalid rate limit string {value!r}; expected e.g. '60 per minute'")
        return value

    @field_validator("allowed_origins")
    @classmethod
    def _valid_origins(cls, value: List[str]) -> List[str]:
        cleaned: List[str] = []
        for origin in value:
            origin = str(origin).strip().rstrip("/")
            if not origin:
                continue
            if origin == "*":
                raise ValueError(
                    "wildcard '*' is not allowed for mobile_api.allowed_origins; "
                    "CORS is deny-by-default"
                )
            cleaned.append(origin)
        return cleaned


@dataclass
class AppConfig:
    """Application configuration combining JSON config and environment secrets."""

    wakeword: str = "hey_rex"
    wakeword_backend: str = "openwakeword"
    wakeword_threshold: float = 0.5
    wakeword_window: float = 1.0
    wakeword_poll_interval: float = 0.01
    wakeword_model_path: Optional[str] = None
    wakeword_embedding_path: Optional[str] = None
    wakeword_fallback_to_builtin: bool = True
    wakeword_fallback_keyword: str = "hey jarvis"
    wakeword_auto_gain: bool = True
    wakeword_target_peak: float = 0.35
    wakeword_max_gain: float = 30.0
    wakeword_min_rms_for_gain: float = 0.0005
    command_duration: float = 5.0
    command_vad_rms_threshold: float = 0.003

    sample_rate: int = 16000
    detection_frame_seconds: float = 1.0
    capture_seconds: float = 5.0

    whisper_model: str = "base"
    whisper_device: str = "auto"
    whisper_language: Optional[str] = "en"
    stt_auto_gain: bool = True
    stt_target_peak: float = 0.45
    stt_max_gain: float = 12.0
    stt_min_rms_for_gain: float = 0.0005
    llm_provider: str = "transformers"
    llm_model: str = "sshleifer/tiny-gpt2"
    llm_max_tokens: int = 120
    voice_max_tokens: int = 150
    llm_temperature: float = 0.7
    autonomy_mode: str = "manual"
    llm_top_p: float = 0.9
    llm_top_k: int = 50
    llm_seed: int = 42
    tts_provider: str = "xtts"
    tts_voice: Optional[str] = None
    tts_speed: float = 1.08
    tts_fast_short_reply_enabled: bool = True
    tts_fast_short_reply_max_chars: int = 140

    speak_api_key: Optional[str] = None
    rate_limit: str = "30/minute"
    allowed_origins: List[str] = field(default_factory=lambda: ["*"])

    memory_max_turns: int = 50
    transcripts_enabled: bool = True
    transcripts_dir: Path = Path("transcripts")
    session_ttl_hours: int = 8
    default_user: Optional[str] = None
    wake_sound_path: Optional[str] = None
    acknowledgment_sound: str = "chime"  # "chime", a .wav path, or a spoken filler phrase
    acknowledgment_mode: str = "sound"  # "sound", "phrase", or "none"
    response_cache_ttl: float = 300.0  # seconds; 0 disables response caching

    active_profile: str = "default"
    capabilities: List[str] = field(default_factory=list)
    personality: str = "Friendly"  # default personality (US-050)

    audio_input_device: Optional[int] = None
    audio_output_device: Optional[int | str] = None

    debug_logging: bool = False
    debug_mode: bool = False  # set via REX_DEBUG=1 or rex --debug
    file_logging_enabled: bool = False
    log_path: Path = DEFAULT_RUNTIME_LOG_FILE
    error_log_path: Path = DEFAULT_ERROR_LOG_FILE
    memory_max_bytes: int = 131072
    warm_runtime_max_cost_mb: float = 6144.0
    warm_runtime_idle_timeout_s: float = 900.0
    conversation_export: bool = True

    brave_api_key: Optional[str] = None
    openai_api_key: Optional[str] = None
    openai_model: Optional[str] = None
    openai_base_url: Optional[str] = None

    openrouter_api_key: Optional[str] = None
    openrouter_model: Optional[str] = None
    openrouter_base_url: str = OPENROUTER_DEFAULT_BASE_URL

    anthropic_api_key: Optional[str] = None
    anthropic_model: Optional[str] = None

    ollama_api_key: Optional[str] = None
    ollama_base_url: str = "http://localhost:11434"
    ollama_use_cloud: bool = False

    search_providers: str = "serpapi,brave,duckduckgo,google"
    speak_language: str = "en"

    followups_enabled: bool = False
    followups_max_per_session: int = 2
    followups_lookback_hours: int = 72
    followups_expire_hours: int = 168

    # Home Assistant integration
    ha_base_url: Optional[str] = None
    ha_token: Optional[str] = None
    ha_secret: Optional[str] = None
    ha_verify_ssl: bool = True
    ha_timeout: float = 10.0
    ha_entity_map: Optional[Dict[str, str]] = None

    # Integration credential detection
    email_provider: str = "none"  # none | gmail | outlook
    calendar_provider: str = "none"  # none | google | outlook

    # Multi-account email config (US-208)
    email_accounts: List[EmailAccountConfig] = field(default_factory=list)
    email_default_account_id: str = ""

    # Per-user multi-email accounts (US-ME-001)
    # Keyed by user_id; each value is the list of email accounts for that user.
    user_email_accounts: Dict[str, List[UserEmailAccount]] = field(default_factory=dict)

    # Per-user default email account selection (issue #303).
    # Keyed by user_id; value is the account_id of that user's default account.
    user_default_email_accounts: Dict[str, str] = field(default_factory=dict)

    # Location and weather
    default_location: Optional[str] = None
    default_timezone: Optional[str] = None
    openweathermap_api_key: Optional[str] = None

    # Conversation history persistence
    persist_history: bool = True
    history_db_path: Path = field(default_factory=lambda: household_data_path("history.db"))
    history_retention_days: int = 30

    # Autonomy budget limits (0 = unlimited)
    autonomy_budget_per_plan_usd: float = 0.0
    autonomy_budget_per_step_usd: float = 0.0

    # Music Assistant integration
    music_assistant_url: Optional[str] = None
    music_assistant_token: Optional[str] = None

    # Telegram bot integration (US-039)
    telegram_bot_token: Optional[str] = None
    telegram_chat_id: Optional[str] = None

    # Push notifications (US-042)
    push_provider: Optional[str] = None  # "ntfy" or "pushover"
    push_token: Optional[str] = None  # bearer token (ntfy) or app token (pushover)
    push_topic: Optional[str] = None  # ntfy topic or pushover user key

    # Room context: maps device IDs to room names (e.g. {"mic_kitchen": "kitchen"})
    device_room_map: Dict[str, str] = field(default_factory=dict)

    # OpenClaw integration
    use_openclaw_tools: bool = False
    use_openclaw_voice_backend: bool = False
    openclaw_gateway_url: str = ""
    openclaw_gateway_timeout: int = 30
    openclaw_gateway_max_retries: int = 3
    openclaw_gateway_token: Optional[str] = None

    # Model routing
    model_routing: ModelRoutingConfig = field(default_factory=ModelRoutingConfig)
    llm_routing_mode: str = "local_preferred"  # "local_preferred", "cloud_only", "local_only"
    cloud_fallback_cooldown_seconds: int = 3600  # cooldown after 429/402 before retrying cloud

    # Voice identity
    speaker_id_threshold: float = 0.75

    # Tool dispatch
    tool_timeout_seconds: float = 10.0

    # Local file access allowlist (US-WIN-001)
    allowed_file_roots: List[str] = field(default_factory=lambda: [str(Path.home())])

    # Computer control confirmation mode (US-055)
    # "always" = confirm all actions, "dangerous_only" = confirm dangerous only, "never" = no confirm
    computer_control_confirmation: str = "dangerous_only"

    # Windows settings — require user confirmation before applying system changes (US-WIN-003)
    require_confirm_system_changes: bool = True

    # Web UI (US-UI-001)
    ui_enabled: bool = True

    # Shopping list PWA (US-SL-004) — optional PIN; empty/None means no auth
    shopping_pwa_pin: Optional[str] = None

    # Smart speaker TTS output (US-SP-002) — name of discovered speaker, or None for local audio
    tts_output_device: Optional[str] = None

    # Smart speaker microphone input (US-SP-003) — name of discovered speaker, or None/auto for local mic
    wake_word_input_device: Optional[str] = None

    # Outbound calling — path to contacts JSON or vCard file (US-PH-003)
    contacts_file: Optional[str] = None

    # Aliases
    llm_backend: Optional[str] = None
    temperature: Optional[float] = None
    max_memory_items: Optional[int] = None
    user_id: str = "default"
    wakeword_keyword: Optional[str] = None

    # Nested sub-configs (US-002) — derived views over flat fields; populated in __post_init__
    audio: Optional[AudioConfig] = field(default=None, repr=False, compare=False)
    voice: Optional[VoiceConfig] = field(default=None, repr=False, compare=False)
    llm: Optional[LLMConfig] = field(default=None, repr=False, compare=False)
    tools: Optional[ToolsConfig] = field(default=None, repr=False, compare=False)
    integrations: Optional[IntegrationsConfig] = field(default=None, repr=False, compare=False)
    ui: Optional[UIConfig] = field(default=None, repr=False, compare=False)
    security: Optional[SecurityConfig] = field(default=None, repr=False, compare=False)

    # Mobile API gateway (issue #323) — canonical nested group, parsed from the
    # ``mobile_api`` JSON section (no flat-field equivalents).
    mobile_api: MobileApiConfig = field(default_factory=MobileApiConfig, repr=False, compare=False)

    # ---------------------------------------------------------------------------
    # US-003 — Deprecated flat-field access map (ClassVar, not a dataclass field)
    # Maps flat field name → sub-config group name; used by __getattribute__
    # ---------------------------------------------------------------------------
    _DEPRECATED_FIELDS: ClassVar[Dict[str, str]] = {
        "llm_provider": "llm",
        "tts_voice": "voice",
        "whisper_device": "voice",
        "openclaw_gateway_url": "integrations",
    }

    def __getattribute__(self, name: str):
        """Emit DeprecationWarning for high-traffic flat fields that now have nested equivalents."""
        _deprecated = type(self).__dict__.get("_DEPRECATED_FIELDS", {})
        if name in _deprecated:
            d = object.__getattribute__(self, "__dict__")
            # Only warn after sub-configs are built; guards against noise during __post_init__
            if d.get("_deprecated_warnings_active"):
                group = _deprecated[name]
                warnings.warn(
                    f"AppConfig.{name} is deprecated. Use config.{group}.{name} instead.",
                    DeprecationWarning,
                    stacklevel=2,
                )
        return object.__getattribute__(self, name)

    # -- Deprecated property aliases (US-003) — sub-config field names as deprecated AppConfig attrs

    @property
    def model_name(self) -> Optional[str]:
        """Deprecated. Use config.llm.model_name instead."""
        warnings.warn(
            "AppConfig.model_name is deprecated. Use config.llm.model_name instead.",
            DeprecationWarning,
            stacklevel=2,
        )
        _llm = object.__getattribute__(self, "llm")
        return _llm.model_name if _llm is not None else object.__getattribute__(self, "llm_model")  # type: ignore[no-any-return]

    @property
    def tts_engine(self) -> str:
        """Deprecated. Use config.voice.tts_engine instead."""
        warnings.warn(
            "AppConfig.tts_engine is deprecated. Use config.voice.tts_engine instead.",
            DeprecationWarning,
            stacklevel=2,
        )
        _voice = object.__getattribute__(self, "voice")
        return (  # type: ignore[no-any-return]
            _voice.tts_engine
            if _voice is not None
            else object.__getattribute__(self, "tts_provider")
        )

    @property
    def wakeword_model(self) -> str:
        """Deprecated. Use config.voice.wakeword_model instead."""
        warnings.warn(
            "AppConfig.wakeword_model is deprecated. Use config.voice.wakeword_model instead.",
            DeprecationWarning,
            stacklevel=2,
        )
        _voice = object.__getattribute__(self, "voice")
        return (  # type: ignore[no-any-return]
            _voice.wakeword_model
            if _voice is not None
            else object.__getattribute__(self, "wakeword")
        )

    @property
    def home_assistant_base_url(self) -> Optional[str]:
        """Deprecated. Use config.integrations.home_assistant_base_url instead."""
        warnings.warn(
            "AppConfig.home_assistant_base_url is deprecated. "
            "Use config.integrations.home_assistant_base_url instead.",
            DeprecationWarning,
            stacklevel=2,
        )
        _intg = object.__getattribute__(self, "integrations")
        return (  # type: ignore[no-any-return]
            _intg.home_assistant_base_url
            if _intg is not None
            else object.__getattribute__(self, "ha_base_url")
        )

    @property
    def tool_timeout(self) -> float:
        """Deprecated. Use config.tools.tool_timeout instead."""
        warnings.warn(
            "AppConfig.tool_timeout is deprecated. Use config.tools.tool_timeout instead.",
            DeprecationWarning,
            stacklevel=2,
        )
        _tools = object.__getattribute__(self, "tools")
        return (  # type: ignore[no-any-return]
            _tools.tool_timeout
            if _tools is not None
            else object.__getattribute__(self, "tool_timeout_seconds")
        )

    @property
    def gui_port(self) -> int:
        """Deprecated. Use config.ui.gui_port instead."""
        warnings.warn(
            "AppConfig.gui_port is deprecated. Use config.ui.gui_port instead.",
            DeprecationWarning,
            stacklevel=2,
        )
        _ui = object.__getattribute__(self, "ui")
        return _ui.gui_port if _ui is not None else 5000

    @property
    def api_key_env(self) -> str:
        """Deprecated. Use config.security.api_key_env instead."""
        warnings.warn(
            "AppConfig.api_key_env is deprecated. Use config.security.api_key_env instead.",
            DeprecationWarning,
            stacklevel=2,
        )
        _sec = object.__getattribute__(self, "security")
        return _sec.api_key_env if _sec is not None else "REX_SPEAK_API_KEY"

    @property
    def rate_limit_per_minute(self) -> int:
        """Deprecated. Use config.security.rate_limit_per_minute instead."""
        warnings.warn(
            "AppConfig.rate_limit_per_minute is deprecated. "
            "Use config.security.rate_limit_per_minute instead.",
            DeprecationWarning,
            stacklevel=2,
        )
        _sec = object.__getattribute__(self, "security")
        return _sec.rate_limit_per_minute if _sec is not None else 30

    def to_dict(self) -> dict:
        # Suppress deprecation warnings from flat-field access during asdict() iteration
        with warnings.catch_warnings():
            warnings.simplefilter("ignore", DeprecationWarning)
            raw = asdict(self)
        # Remove nested sub-config objects (US-002) — derived views; not part of serialised output
        for _key in ("audio", "voice", "llm", "tools", "integrations", "ui", "security"):
            raw.pop(_key, None)
        # mobile_api is canonical nested config with no flat equivalents, so it
        # is serialised as its validated dictionary (it contains no secrets —
        # the JWT secret lives in the credential vault).
        raw["mobile_api"] = self.mobile_api.model_dump()
        raw["transcripts_dir"] = str(self.transcripts_dir)
        raw["log_path"] = str(self.log_path)
        raw["error_log_path"] = str(self.error_log_path)
        return raw

    def __post_init__(self) -> None:
        if self.warm_runtime_max_cost_mb < 0 or self.warm_runtime_idle_timeout_s < 0:
            raise ValueError("warm runtime limits must be non-negative")
        provider = self.llm_provider.lower()
        self.openrouter_base_url = (self.openrouter_base_url or OPENROUTER_DEFAULT_BASE_URL).rstrip(
            "/"
        )
        local_path_providers = {"transformers"}
        if provider in local_path_providers and isinstance(self.llm_model, str):
            model_path = Path(self.llm_model)
            if model_path.is_absolute() or ".." in model_path.parts:
                raise ValueError("llm_model must not contain path traversal components.")
        if provider == "openai" and not self.openai_model:
            raise ValueError("openai.model must be set when llm_provider is 'openai'.")
        if provider == "openrouter":
            if not self.openrouter_model:
                raise ValueError("openrouter.model must be set when llm_provider is 'openrouter'.")
            if self.openrouter_base_url != OPENROUTER_DEFAULT_BASE_URL:
                raise ValueError(
                    "openrouter.base_url must use the official https://openrouter.ai/api/v1 endpoint."
                )
        if provider == "anthropic" and not self.anthropic_model:
            raise ValueError("anthropic.model must be set when llm_provider is 'anthropic'.")
        if self.llm_backend is None:
            self.llm_backend = self.llm_provider
        if self.temperature is None:
            self.temperature = self.llm_temperature
        if self.max_memory_items is None:
            self.max_memory_items = self.memory_max_turns
        self.wakeword_keyword = resolve_wakeword_keyword(self.wakeword_keyword, self.wakeword)

        # Build nested sub-configs from flat fields (US-002)
        self.audio = AudioConfig(
            sample_rate=self.sample_rate,
            input_device=self.audio_input_device,
            output_device=(
                self.audio_output_device if isinstance(self.audio_output_device, int) else None
            ),
            vad_sensitivity=self.command_vad_rms_threshold,
        )
        self.voice = VoiceConfig(
            tts_engine=self.tts_provider,
            tts_voice=self.tts_voice,
            tts_speed=self.tts_speed,
            stt_model=self.whisper_model,
            whisper_device=self.whisper_device,
            wakeword_model=self.wakeword,
            wakeword_sensitivity=self.wakeword_threshold,
            wakeword_fallback_keyword=self.wakeword_fallback_keyword,
            wakeword_backend=self.wakeword_backend,
        )
        self.llm = LLMConfig(
            llm_provider=self.llm_provider,
            model_name=self.llm_model,
            openrouter_model=self.openrouter_model,
            openrouter_base_url=self.openrouter_base_url,
            ollama_url=self.ollama_base_url,
            context_length=self.llm_max_tokens,
            temperature=self.llm_temperature,
            llm_routing_mode=self.llm_routing_mode,
        )
        self.tools = ToolsConfig(
            tool_timeout=self.tool_timeout_seconds,
        )
        _rate_limit_per_min = 30
        if isinstance(self.rate_limit, str) and "/" in self.rate_limit:
            try:
                _rate_limit_per_min = int(self.rate_limit.split("/")[0])
            except (ValueError, IndexError):
                pass
        self.integrations = IntegrationsConfig(
            home_assistant_base_url=self.ha_base_url,
            email_provider=self.email_provider,
            calendar_provider=self.calendar_provider,
            music_assistant_url=self.music_assistant_url,
            shopping_pwa_pin=self.shopping_pwa_pin,
            openclaw_gateway_url=self.openclaw_gateway_url,
            openclaw_gateway_timeout=self.openclaw_gateway_timeout,
            openclaw_gateway_max_retries=self.openclaw_gateway_max_retries,
        )
        self.ui = UIConfig(
            ui_enabled=self.ui_enabled,
        )
        self.security = SecurityConfig(
            rate_limit_per_minute=_rate_limit_per_min,
            allowed_origins=list(self.allowed_origins),
        )
        # Enable deprecation warnings for flat field access (US-003)
        # Must be set LAST so flat field reads during sub-config construction are silent
        self._deprecated_warnings_active = True


_cached_config: Optional[AppConfig] = None

# Required environment variables (secrets only)
REQUIRED_ENV_KEYS: set = set()  # No required env vars - secrets are optional

# Backward compatibility: ENV_MAPPING removed - use rex_config.json for runtime settings
# For migration, see rex.config_manager.ENV_TO_CONFIG_MAPPING
ENV_MAPPING: Dict[str, str] = {}


def _get_nested(data: dict, path: str, default=None):
    """Get value from nested dict using dot notation."""
    keys = path.split(".")
    value = data
    for key in keys:
        if isinstance(value, dict):
            value = value.get(key, default)
        else:
            return default
    return value


def _coerce_float(json_config: dict, path: str, default: float) -> float:
    """Get a float config value, warning if the raw value is a string.

    Pydantic/dataclass coercion silently accepts string-typed floats from
    JSON, which can hide misconfigured ``rex_config.json`` files.  This
    helper logs a WARNING so operators know to fix the source file.

    Raises:
        ConfigurationError: If the value cannot be converted to float.
    """
    raw = _get_nested(json_config, path, default)
    if isinstance(raw, str):
        LOGGER.warning(
            "Config field %r has string value %r — expected float; "
            "coercing automatically.  Fix the value in rex_config.json.",
            path,
            raw,
        )
    try:
        return float(raw)
    except (ValueError, TypeError) as exc:
        raise ConfigurationError(
            f"Config field {path!r} has invalid value {raw!r}: cannot convert to float."
        ) from exc


def _coerce_int(json_config: dict, path: str, default: int) -> int:
    """Get an int config value, warning if the raw value is a string.

    See :func:`_coerce_float` for rationale.

    Raises:
        ConfigurationError: If the value cannot be converted to int.
    """
    raw = _get_nested(json_config, path, default)
    if isinstance(raw, str):
        LOGGER.warning(
            "Config field %r has string value %r — expected int; "
            "coercing automatically.  Fix the value in rex_config.json.",
            path,
            raw,
        )
    try:
        return int(float(raw))
    except (ValueError, TypeError) as exc:
        raise ConfigurationError(
            f"Config field {path!r} has invalid value {raw!r}: cannot convert to int."
        ) from exc


def _normalize_calendar_provider(value: object) -> str:
    """Normalize GUI/provider aliases for the calendar service."""
    provider = str(value or "none").strip().lower()
    if provider == "gmail":
        return "google"
    return provider or "none"


def _parse_email_accounts(raw: object) -> List[EmailAccountConfig]:
    """Parse ``email.accounts`` from JSON config into a list of EmailAccountConfig."""
    if not isinstance(raw, list):
        return []
    accounts: List[EmailAccountConfig] = []
    for item in raw:
        if not isinstance(item, dict):
            continue
        try:
            accounts.append(
                EmailAccountConfig(
                    id=str(item["id"]),
                    address=str(item["address"]),
                    imap_host=str(item.get("imap_host", item.get("imap", {}).get("host", ""))),
                    imap_port=int(item.get("imap_port", item.get("imap", {}).get("port", 993))),
                    smtp_host=str(item.get("smtp_host", item.get("smtp", {}).get("host", ""))),
                    smtp_port=int(item.get("smtp_port", item.get("smtp", {}).get("port", 587))),
                    credential_ref=str(item.get("credential_ref", "")),
                    use_starttls=bool(item.get("use_starttls", True)),
                )
            )
        except (KeyError, TypeError, ValueError) as exc:
            LOGGER.warning("Skipping malformed email account entry: %s", exc)
    return accounts


def _parse_user_email_account(raw: dict) -> UserEmailAccount:
    """Parse a single user email account dict."""
    return UserEmailAccount(
        account_id=str(raw["account_id"]),
        display_name=str(raw.get("display_name", "")),
        backend=str(raw.get("backend", "imap")).lower(),
        credentials_key=str(raw.get("credentials_key", "")),
    )


def _parse_user_email_accounts(
    users_block: object,
    legacy_email_accounts: object,
) -> Dict[str, List[UserEmailAccount]]:
    """Parse ``users.{user_id}.email_accounts`` into a per-user dict.

    Migration shim: if the new ``users`` block is absent or empty but the
    legacy ``email.accounts`` list is present, its entries are migrated to the
    ``"default"`` user using their ``id`` as ``account_id`` and ``credential_ref``
    as ``credentials_key``.
    """
    result: Dict[str, List[UserEmailAccount]] = {}

    # Parse new format: users.{user_id}.email_accounts
    if isinstance(users_block, dict):
        for user_id, user_data in users_block.items():
            if not isinstance(user_data, dict):
                continue
            accounts_raw = user_data.get("email_accounts", [])
            if not isinstance(accounts_raw, list):
                continue
            parsed: List[UserEmailAccount] = []
            for entry in accounts_raw:
                if not isinstance(entry, dict) or "account_id" not in entry:
                    continue
                try:
                    parsed.append(_parse_user_email_account(entry))
                except (KeyError, TypeError, ValueError) as exc:
                    LOGGER.warning("Skipping malformed user email account: %s", exc)
            if parsed:
                result[str(user_id)] = parsed

    # Migration shim: promote legacy email.accounts to user "default" if no new entries
    if not result and isinstance(legacy_email_accounts, list):
        migrated: List[UserEmailAccount] = []
        for item in legacy_email_accounts:
            if not isinstance(item, dict):
                continue
            account_id = str(item.get("id", ""))
            if not account_id:
                continue
            migrated.append(
                UserEmailAccount(
                    account_id=account_id,
                    display_name=str(item.get("address", "")),
                    backend="imap",
                    credentials_key=str(item.get("credential_ref", "")),
                )
            )
        if migrated:
            result["default"] = migrated

    return result


def _parse_user_default_email_accounts(users_block: object) -> Dict[str, str]:
    """Parse ``users.{user_id}.default_email_account_id`` into a per-user map.

    Each user's default may only reference an account assigned to that user;
    ownership is enforced at resolution time by ``rex.email_accounts``.
    """
    result: Dict[str, str] = {}
    if not isinstance(users_block, dict):
        return result
    for user_id, user_data in users_block.items():
        if not isinstance(user_data, dict):
            continue
        default_id = user_data.get("default_email_account_id")
        if isinstance(default_id, str) and default_id.strip():
            result[str(user_id)] = default_id.strip()
    return result


def _parse_model_routing(raw: object) -> ModelRoutingConfig:
    """Parse ``model_routing`` block from JSON config."""
    if not isinstance(raw, dict):
        return ModelRoutingConfig()
    return ModelRoutingConfig(
        default=str(raw.get("default", "")),
        coding=str(raw.get("coding", "")),
        reasoning=str(raw.get("reasoning", "")),
        search=str(raw.get("search", "")),
        vision=str(raw.get("vision", "")),
        fast=str(raw.get("fast", "")),
    )


def _parse_allowed_file_roots(raw: object) -> list[str]:
    """Parse ``file_ops.allowed_roots`` list from JSON config.

    Returns a list of root path strings.  Falls back to the user home directory
    when *raw* is not a non-empty list.
    """
    if isinstance(raw, list) and raw:
        return [str(item) for item in raw if item]
    return [str(Path.home())]


def _merge_profile_config(base_config: dict) -> dict:
    profile_name = get_active_profile_name(base_config)
    profiles_dir = base_config.get("profiles_dir", DEFAULT_PROFILES_DIR)
    ensure_default_profile(profiles_dir)
    try:
        profile = load_profile(profile_name, profiles_dir=profiles_dir)
    except FileNotFoundError:
        if profile_name != "default":
            LOGGER.warning("Profile '%s' not found; falling back to 'default'.", profile_name)
            profile = load_profile("default", profiles_dir=profiles_dir)
            profile_name = "default"
        else:
            raise
    merged_config = apply_profile(base_config, profile)
    merged_config["active_profile"] = profile_name
    merged_config.setdefault("profiles_dir", profiles_dir)
    merged_config["capabilities"] = profile.get("capabilities", [])
    return merged_config


def _migrate_wake_word_section(json_config: dict) -> dict:
    """Migrate the legacy ``wake_word`` key to the canonical ``wakeword`` key.

    If ``wake_word`` is present its values are merged into ``wakeword`` (without
    overwriting keys already present in ``wakeword``), and ``wake_word`` is
    removed.  A deprecation notice is logged so operators know to update their
    config files.
    """
    legacy = json_config.pop("wake_word", None)
    if legacy is None:
        return json_config

    LOGGER.warning(
        "Config key 'wake_word' is deprecated — rename it to 'wakeword' in "
        "rex_config.json.  Values have been migrated automatically for this run."
    )
    if isinstance(legacy, dict):
        canonical = json_config.get("wakeword")
        if not isinstance(canonical, dict):
            canonical = {}
            json_config["wakeword"] = canonical
        for k, v in legacy.items():
            canonical.setdefault(k, v)
    return json_config


def _parse_mobile_api_config(raw: object) -> MobileApiConfig:
    """Parse and validate the ``mobile_api`` JSON group.

    Raises:
        ConfigurationError: If any mobile_api value fails validation, so that
            startup fails before serving rather than running misconfigured.
    """
    if not isinstance(raw, dict):
        if raw is not None:
            raise ConfigurationError("Config group 'mobile_api' must be a JSON object.")
        raw = {}
    try:
        return MobileApiConfig(**raw)
    except Exception as exc:
        raise ConfigurationError(f"Invalid 'mobile_api' configuration: {exc}") from exc


def _secret_env_or_vault(env_var: str, json_config: dict) -> str | None:
    """Resolve a secret from the household credential vault (S4).

    A configured vault reference is the real production authority: when one
    exists for ``env_var`` and resolves to a value, that value wins
    unconditionally - it is never overridden by the plaintext environment.
    Reading the plaintext ``os.getenv(env_var)`` value is only considered as
    a *fallback*, and only when ``rex.credentials.legacy_plaintext_fallback_enabled()``
    is true (an explicit, non-production development/CI/operator opt-in),
    and only when no vault reference is configured or the configured
    reference does not resolve to a value. In normal (packaged/production)
    operation this flag is unset, so a secret not present in the vault is
    simply absent - never silently read from plaintext ``.env``/process
    environment. Invalid registry or vault data always raises
    ``ConfigurationError`` so tampering is never reclassified as an
    unconfigured credential, regardless of the legacy flag.
    """
    from rex.credentials import (
        credential_context_for_name,
        legacy_plaintext_fallback_enabled,
    )

    def _legacy_fallback() -> str | None:
        if legacy_plaintext_fallback_enabled():
            value = os.getenv(env_var)
            if value:
                return value
        return None

    refs_root = json_config.get("credential_refs")
    if refs_root is None:
        return _legacy_fallback()
    if not isinstance(refs_root, dict):
        raise ConfigurationError("Credential reference registry is invalid")
    household_refs = refs_root.get("household")
    if household_refs is None:
        return _legacy_fallback()
    if not isinstance(household_refs, dict):
        raise ConfigurationError("Household credential reference registry is invalid")
    record = household_refs.get(env_var)
    if record is None:
        return _legacy_fallback()
    allowed_fields = {"ref", "integration", "account", "slot"}
    if isinstance(record, dict) and "migrated_from" in record:
        allowed_fields.add("migrated_from")
    if (
        not isinstance(record, dict)
        or set(record) != allowed_fields
        or not isinstance(record.get("ref"), str)
        or (
            "migrated_from" in record
            and record.get("migrated_from") not in {"env", "credentials.json"}
        )
    ):
        raise ConfigurationError(f"Credential reference metadata for {env_var} is invalid")
    expected_integration, expected_account, expected_slot = credential_context_for_name(env_var)
    if (
        record.get("integration") != expected_integration
        or record.get("account") != expected_account
        or record.get("slot") != expected_slot
    ):
        raise ConfigurationError(f"Credential reference context for {env_var} is invalid")
    try:
        from rex.credential_vault import (
            VaultCorruptedError,
            VaultUnavailableError,
            get_credential_vault,
            validate_credential_ref,
        )
    except ImportError:  # pragma: no cover - module always present in this repo
        return _legacy_fallback()
    try:
        validate_credential_ref(record["ref"])
    except ValueError as exc:
        raise ConfigurationError(f"Credential reference for {env_var} is invalid") from exc
    try:
        vault_value = get_credential_vault(scope="household").get_secret(
            record["ref"],
            integration=expected_integration,
            account=expected_account,
            slot=expected_slot,
        )
    except VaultUnavailableError:
        return _legacy_fallback()
    except VaultCorruptedError as exc:
        raise ConfigurationError(f"Credential vault data for {env_var} is invalid") from exc
    if vault_value:
        return vault_value
    return _legacy_fallback()


def build_app_config(json_config: dict) -> AppConfig:
    """Build an AppConfig from a merged JSON configuration."""
    # Migrate legacy wake_word key to canonical wakeword key
    json_config = _migrate_wake_word_section(json_config)
    # Parse allowed origins from JSON config
    allowed_origins_value = _get_nested(json_config, "api.allowed_origins", ["*"])
    if isinstance(allowed_origins_value, str):
        allowed_origins = [
            origin.strip().rstrip("/")
            for origin in allowed_origins_value.split(",")
            if origin.strip()
        ] or ["*"]
    elif isinstance(allowed_origins_value, list):
        allowed_origins = [str(o).strip().rstrip("/") for o in allowed_origins_value if o]
    else:
        allowed_origins = ["*"]

    capabilities_value = _get_nested(json_config, "capabilities", [])
    if isinstance(capabilities_value, list):
        capabilities = [str(item) for item in capabilities_value if item]
    else:
        capabilities = []

    # Build config from JSON config + vault-backed secrets
    config = AppConfig(
        # Wake word settings from JSON (canonical key: wakeword)
        wakeword=_get_nested(json_config, "wakeword.wakeword", "hey_rex") or "hey_rex",
        wakeword_backend=_get_nested(json_config, "wakeword.backend", "openwakeword"),
        wakeword_keyword=_get_nested(json_config, "wakeword.keyword"),
        wakeword_threshold=_coerce_float(json_config, "wakeword.threshold", 0.5),
        wakeword_window=_coerce_float(json_config, "wakeword.window", 1.0),
        wakeword_poll_interval=_coerce_float(json_config, "wakeword.poll_interval", 0.01),
        wake_sound_path=_get_nested(json_config, "wakeword.wake_sound_path"),
        acknowledgment_sound=_get_nested(json_config, "acknowledgment.sound", "chime"),
        acknowledgment_mode=_get_nested(json_config, "acknowledgment.mode", "sound"),
        response_cache_ttl=_coerce_float(json_config, "response_cache.ttl", 300.0),
        wakeword_model_path=_get_nested(json_config, "wakeword.model_path"),
        wakeword_embedding_path=_get_nested(json_config, "wakeword.embedding_path"),
        wakeword_fallback_to_builtin=bool(
            _get_nested(json_config, "wakeword.fallback_to_builtin", True)
        ),
        wakeword_fallback_keyword=_get_nested(
            json_config, "wakeword.fallback_keyword", "hey jarvis"
        ),
        wakeword_auto_gain=bool(_get_nested(json_config, "wakeword.auto_gain", True)),
        wakeword_target_peak=_coerce_float(json_config, "wakeword.target_peak", 0.35),
        wakeword_max_gain=_coerce_float(json_config, "wakeword.max_gain", 30.0),
        wakeword_min_rms_for_gain=_coerce_float(json_config, "wakeword.min_rms_for_gain", 0.0005),
        # Runtime settings from JSON
        command_duration=_coerce_float(json_config, "runtime.command_duration", 5.0),
        command_vad_rms_threshold=_coerce_float(
            json_config, "runtime.command_vad_rms_threshold", 0.003
        ),
        detection_frame_seconds=_coerce_float(json_config, "runtime.detection_frame_seconds", 1.0),
        capture_seconds=_coerce_float(json_config, "runtime.capture_seconds", 5.0),
        memory_max_turns=_coerce_int(json_config, "runtime.memory_max_turns", 50),
        transcripts_enabled=bool(_get_nested(json_config, "runtime.transcripts_enabled", True)),
        transcripts_dir=Path(_get_nested(json_config, "runtime.transcripts_dir", "transcripts")),
        session_ttl_hours=_coerce_int(json_config, "runtime.session_ttl_hours", 8),
        default_user=_get_nested(json_config, "runtime.active_user"),
        conversation_export=bool(_get_nested(json_config, "runtime.conversation_export", True)),
        speak_language=_get_nested(json_config, "runtime.speak_language", "en"),
        user_id=_get_nested(json_config, "runtime.user_id", "default"),
        # Audio settings from JSON
        sample_rate=_coerce_int(json_config, "audio.sample_rate", 16000),
        audio_input_device=_get_nested(json_config, "audio.input_device_index"),
        audio_output_device=_get_nested(json_config, "audio.output_device_index"),
        tts_output_device=_get_nested(json_config, "audio.tts_output_device"),
        wake_word_input_device=_get_nested(json_config, "audio.wake_word_input_device"),
        # Model settings from JSON
        whisper_model=_get_nested(json_config, "models.stt_model", "base"),
        whisper_device=_get_nested(json_config, "models.stt_device", "auto"),
        whisper_language=_get_nested(json_config, "models.stt_language", "en"),
        stt_auto_gain=bool(_get_nested(json_config, "models.stt_auto_gain", True)),
        stt_target_peak=_coerce_float(json_config, "models.stt_target_peak", 0.45),
        stt_max_gain=_coerce_float(json_config, "models.stt_max_gain", 12.0),
        stt_min_rms_for_gain=_coerce_float(json_config, "models.stt_min_rms_for_gain", 0.0005),
        llm_provider=_get_nested(json_config, "models.llm_provider", "transformers"),
        llm_model=_get_nested(json_config, "models.llm_model", "sshleifer/tiny-gpt2"),
        llm_max_tokens=_coerce_int(json_config, "models.llm_max_tokens", 120),
        llm_temperature=_coerce_float(json_config, "models.llm_temperature", 0.7),
        autonomy_mode=_get_nested(json_config, "models.autonomy_mode", "manual"),
        llm_top_p=_coerce_float(json_config, "models.llm_top_p", 0.9),
        llm_top_k=_coerce_int(json_config, "models.llm_top_k", 50),
        llm_seed=_coerce_int(json_config, "models.llm_seed", 42),
        tts_provider=_get_nested(json_config, "models.tts_provider", "xtts"),
        tts_voice=_get_nested(json_config, "models.tts_voice"),
        tts_speed=_coerce_float(json_config, "models.tts_speed", 1.08),
        tts_fast_short_reply_enabled=bool(
            _get_nested(json_config, "models.tts_fast_short_reply_enabled", True)
        ),
        tts_fast_short_reply_max_chars=int(
            _coerce_float(json_config, "models.tts_fast_short_reply_max_chars", 140)
        ),
        # API settings from JSON
        rate_limit=_get_nested(json_config, "api.rate_limit", "30/minute"),
        allowed_origins=allowed_origins,
        # Search settings from JSON
        search_providers=_get_nested(
            json_config, "search.providers", "serpapi,brave,duckduckgo,google"
        ),
        # Home Assistant non-secret config plus vault-backed credentials
        ha_base_url=_get_nested(json_config, "home_assistant.base_url"),
        ha_verify_ssl=bool(_get_nested(json_config, "home_assistant.verify_ssl", True)),
        ha_timeout=_coerce_float(json_config, "home_assistant.timeout", 10.0),
        ha_token=_secret_env_or_vault("HA_TOKEN", json_config),
        ha_secret=_secret_env_or_vault("HA_SECRET", json_config),
        ha_entity_map=None,
        # Ollama from JSON + vault-backed secrets
        ollama_base_url=_get_nested(json_config, "ollama.base_url", "http://localhost:11434"),
        ollama_use_cloud=bool(_get_nested(json_config, "ollama.use_cloud", False)),
        ollama_api_key=_secret_env_or_vault("OLLAMA_API_KEY", json_config),
        # OpenAI from JSON + vault-backed secrets
        openai_model=_get_nested(json_config, "openai.model"),
        openai_base_url=_get_nested(json_config, "openai.base_url"),
        openai_api_key=_secret_env_or_vault("OPENAI_API_KEY", json_config),
        # OpenRouter from JSON + vault-backed secrets
        openrouter_model=_get_nested(json_config, "openrouter.model"),
        openrouter_base_url=(
            _get_nested(json_config, "openrouter.base_url", OPENROUTER_DEFAULT_BASE_URL)
            or OPENROUTER_DEFAULT_BASE_URL
        ),
        openrouter_api_key=_secret_env_or_vault("OPENROUTER_API_KEY", json_config),
        # Anthropic from JSON + vault-backed secrets
        anthropic_model=_get_nested(json_config, "anthropic.model"),
        anthropic_api_key=_secret_env_or_vault("ANTHROPIC_API_KEY", json_config),
        # All secrets from the vault (or explicit unpackaged legacy mode)
        brave_api_key=_secret_env_or_vault("BRAVE_API_KEY", json_config),
        speak_api_key=_secret_env_or_vault("REX_SPEAK_API_KEY", json_config),
        # Logging from JSON + env
        debug_logging=_get_nested(json_config, "runtime.log_level", "INFO").upper() == "DEBUG",
        debug_mode=os.getenv("REX_DEBUG", "0").strip() not in ("0", "false", "no", ""),
        file_logging_enabled=bool(_get_nested(json_config, "runtime.file_logging_enabled", False)),
        log_path=Path(_get_nested(json_config, "runtime.log_path", str(DEFAULT_RUNTIME_LOG_FILE))),
        error_log_path=Path(
            _get_nested(json_config, "runtime.error_log_path", str(DEFAULT_ERROR_LOG_FILE))
        ),
        memory_max_bytes=_coerce_int(json_config, "runtime.memory_max_bytes", 131072),
        warm_runtime_max_cost_mb=_coerce_float(
            json_config, "runtime.warm_runtime_max_cost_mb", 6144.0
        ),
        warm_runtime_idle_timeout_s=_coerce_float(
            json_config, "runtime.warm_runtime_idle_timeout_s", 900.0
        ),
        # Profile metadata
        active_profile=_get_nested(json_config, "active_profile", "default"),
        capabilities=capabilities,
        # Location and weather (location from JSON, API key from env)
        default_location=_get_nested(json_config, "location.default_location"),
        default_timezone=_get_nested(json_config, "location.default_timezone"),
        openweathermap_api_key=_secret_env_or_vault("OPENWEATHERMAP_API_KEY", json_config),
        # Conversational followups
        followups_enabled=bool(_get_nested(json_config, "conversation.followups.enabled", False)),
        followups_max_per_session=_coerce_int(
            json_config, "conversation.followups.max_per_session", 2
        ),
        followups_lookback_hours=_coerce_int(
            json_config, "conversation.followups.lookback_hours", 72
        ),
        followups_expire_hours=_coerce_int(json_config, "conversation.followups.expire_hours", 168),
        # OpenClaw integration
        use_openclaw_tools=bool(_get_nested(json_config, "openclaw.use_tools", False)),
        use_openclaw_voice_backend=bool(
            _get_nested(json_config, "openclaw.use_voice_backend", False)
        ),
        openclaw_gateway_url=_get_nested(json_config, "openclaw.gateway_url", ""),
        openclaw_gateway_timeout=_coerce_int(json_config, "openclaw.gateway_timeout", 30),
        openclaw_gateway_max_retries=_coerce_int(json_config, "openclaw.gateway_max_retries", 3),
        openclaw_gateway_token=_secret_env_or_vault("OPENCLAW_GATEWAY_TOKEN", json_config),
        # Telegram bot integration (US-039)
        telegram_bot_token=_secret_env_or_vault("TELEGRAM_BOT_TOKEN", json_config),
        telegram_chat_id=_get_nested(json_config, "telegram.chat_id"),
        # Push notifications (US-042)
        push_provider=_get_nested(json_config, "notifications.push_provider"),
        push_token=_secret_env_or_vault("PUSH_TOKEN", json_config),
        push_topic=_get_nested(json_config, "notifications.push_topic"),
        # Email/calendar provider selection
        email_provider=_get_nested(json_config, "email.provider", "none"),
        calendar_provider=_normalize_calendar_provider(
            _get_nested(json_config, "calendar.provider", "none")
        ),
        # Multi-account email (US-208)
        email_accounts=_parse_email_accounts(_get_nested(json_config, "email.accounts", [])),
        email_default_account_id=_get_nested(json_config, "email.default_account_id", ""),
        # Per-user multi-email accounts (US-ME-001)
        user_email_accounts=_parse_user_email_accounts(
            _get_nested(json_config, "users", {}),
            _get_nested(json_config, "email.accounts", []),
        ),
        # Per-user default email account selection (issue #303)
        user_default_email_accounts=_parse_user_default_email_accounts(
            _get_nested(json_config, "users", {}),
        ),
        # History persistence
        persist_history=bool(_get_nested(json_config, "runtime.persist_history", True)),
        history_db_path=Path(
            _get_nested(
                json_config,
                "runtime.history_db_path",
                str(household_data_path("history.db")),
            )
        ),
        history_retention_days=_coerce_int(json_config, "runtime.history_retention_days", 30),
        # Model routing
        model_routing=_parse_model_routing(_get_nested(json_config, "model_routing", {})),
        llm_routing_mode=str(
            _get_nested(json_config, "model_routing.llm_routing_mode", "local_preferred")
        ),
        cloud_fallback_cooldown_seconds=_coerce_int(
            json_config, "model_routing.cloud_fallback_cooldown_seconds", 3600
        ),
        # Voice identity
        speaker_id_threshold=_coerce_float(
            json_config, "voice_identity.speaker_id_threshold", 0.75
        ),
        # Local file access allowlist
        allowed_file_roots=_parse_allowed_file_roots(
            _get_nested(json_config, "file_ops.allowed_roots", [])
        ),
        # Windows settings confirmation
        require_confirm_system_changes=bool(
            _get_nested(json_config, "windows.require_confirm_system_changes", True)
        ),
        # Mobile API gateway (issue #323)
        mobile_api=_parse_mobile_api_config(json_config.get("mobile_api")),
    )

    return config


def load_config(
    *, env_path: Optional[Path] = None, reload: bool = False, json_config: Optional[dict] = None
) -> AppConfig:
    """Load non-secret configuration and resolve vault-backed secrets.

    Args:
        env_path: Legacy .env path, consulted only with explicit operator opt-in
        reload: Force reload instead of using cached config
        json_config: Pre-loaded JSON config dict (if None, loads from rex/config_manager)

    Returns:
        AppConfig with runtime settings from JSON and secrets from the vault

    Note:
        Non-secret environment variables are now ignored. Use rex_config.json instead.
    """
    global _cached_config
    if not reload and json_config is None:
        config_module = sys.modules.get("config")
        if config_module is not None:
            cached = getattr(config_module, "_cached_config", None)
            if cached is not None:
                _cached_config = cached
                return _cached_config
        if _cached_config is not None:
            return _cached_config

    # Persisted plaintext `.env` has no authority by default. Loading it into
    # process state is permitted only in explicit legacy/operator mode.
    from rex.credentials import legacy_plaintext_fallback_enabled

    if legacy_plaintext_fallback_enabled():
        load_dotenv(env_path or ENV_PATH, override=False)

    # Load JSON config for runtime settings
    if json_config is None:
        from rex.config_manager import load_config as load_json_config

        json_config = load_json_config()

    # Warn about legacy environment variables
    warnings = get_legacy_env_warnings()
    if warnings:
        for warning in warnings[:3]:  # Limit to first 3 to avoid spam
            print(warning, file=sys.stderr)
        if len(warnings) > 3:
            print(
                f"... and {len(warnings) - 3} more legacy env vars. "
                f"Run 'rex-config migrate-legacy-env' to migrate all.",
                file=sys.stderr,
            )

    try:
        json_config = _merge_profile_config(json_config)
    except Exception as exc:
        raise ConfigurationError(f"Profile loading failed: {exc}") from exc

    config = build_app_config(json_config)

    validate_config(config)
    _cached_config = config

    if config.debug_logging:
        set_global_level(10)

    return config


def validate_config(config: AppConfig) -> None:
    if not (0 < config.wakeword_threshold <= 1):
        raise ConfigurationError("wakeword_threshold must be between 0 and 1.")
    if config.command_duration <= 0:
        raise ConfigurationError("command_duration must be positive.")
    if config.wakeword_window <= 0:
        raise ConfigurationError("wakeword_window must be positive.")
    if config.llm_max_tokens <= 0:
        raise ConfigurationError("llm_max_tokens must be positive.")
    if not (0 <= config.llm_temperature <= 5.0):
        raise ConfigurationError("llm_temperature must be between 0 and 5.")
    if not isinstance(config.autonomy_mode, str) or config.autonomy_mode not in {
        "manual",
        "supervised",
        "full-auto",
    }:
        raise ConfigurationError("autonomy_mode must be manual, supervised, or full-auto.")
    if config.memory_max_turns <= 0:
        raise ConfigurationError("memory_max_turns must be positive.")
    if config.use_openclaw_tools or config.use_openclaw_voice_backend:
        integrations = config.integrations
        if integrations is None:
            raise ConfigurationError("OpenClaw integration settings are unavailable.")
        gateway_url = integrations.openclaw_gateway_url.strip()
        gateway_token = (config.openclaw_gateway_token or "").strip()
        if not gateway_url or not gateway_token:
            raise ConfigurationError(
                "OpenClaw features are enabled but a gateway URL and token are not both configured."
            )
        parsed_gateway = urlparse(gateway_url)
        if parsed_gateway.scheme not in {"http", "https"} or not parsed_gateway.hostname:
            raise ConfigurationError(
                "OpenClaw features require a valid HTTP(S) gateway URL and token."
            )


def reload_settings(
    *, env_path: Optional[Path] = None, json_config: Optional[dict] = None
) -> AppConfig:
    """Reload configuration, optionally with new JSON config."""
    return load_config(env_path=env_path, reload=True, json_config=json_config)


def show_config(config: Optional[AppConfig] = None) -> None:
    """Print the resolved configuration to stdout in stable JSON format."""
    cfg = config or load_config()
    print(json.dumps(cfg.to_dict(), indent=2, sort_keys=True, default=str))


def _cmd_show(args: argparse.Namespace) -> int:
    """Print the current configuration."""
    cfg = load_config(env_path=ENV_PATH, reload=True)
    show_config(cfg)
    return 0


def _cmd_migrate_legacy_env(args: argparse.Namespace) -> int:
    """Migrate legacy environment variables to rex_config.json."""
    from rex.config_manager import migrate_legacy_env_to_config

    env_path = Path(args.env_path) if args.env_path else ENV_PATH
    notes = migrate_legacy_env_to_config(
        env_path=env_path,
        config_path=args.config_path,
        dry_run=args.dry_run,
    )
    for note in notes:
        print(note)
    return 0


@wrap_entrypoint
def cli(argv: Optional[List[str]] = None) -> int:
    parser = argparse.ArgumentParser(
        prog="rex-config",
        description="Configure Rex Assistant",
    )
    subparsers = parser.add_subparsers(dest="command")

    # show
    show_parser = subparsers.add_parser(
        "show",
        help="Print current configuration",
    )
    show_parser.set_defaults(func=_cmd_show)

    # migrate-legacy-env
    migrate_parser = subparsers.add_parser(
        "migrate-legacy-env",
        help="Migrate legacy environment variables into config/rex_config.json",
        description=(
            "Reads legacy non-secret environment variables (e.g. OPENAI_BASE_URL) "
            "and writes their values into config/rex_config.json. Existing non-default "
            "config values are never overwritten."
        ),
    )
    migrate_parser.add_argument(
        "--config-path",
        default="config/rex_config.json",
        help="Path to rex_config.json (default: config/rex_config.json)",
    )
    migrate_parser.add_argument(
        "--env-path",
        default=None,
        help=("Path to .env file to read legacy variables from " "(default: repo root .env)"),
    )
    migrate_parser.add_argument(
        "--dry-run",
        action="store_true",
        help="Show what would be migrated without writing any changes",
    )
    migrate_parser.set_defaults(func=_cmd_migrate_legacy_env)

    # Backward compat: --show and --reload still work
    parser.add_argument("--show", action="store_true", help=argparse.SUPPRESS)
    parser.add_argument("--reload", action="store_true", help=argparse.SUPPRESS)

    args = parser.parse_args(argv)

    ENV_PATH.touch(exist_ok=True)

    # Handle legacy flags
    if args.show or args.reload:
        cfg = load_config(env_path=ENV_PATH, reload=True)
        show_config(cfg)
        return 0

    if args.command is None:
        parser.print_help()
        return 0

    return args.func(args)  # type: ignore[no-any-return]


settings = load_config()
Settings = AppConfig

if __name__ == "__main__":
    import sys

    try:
        raise SystemExit(cli())
    except ConfigurationError as exc:
        LOGGER.error("Config error: %s", exc)
        raise SystemExit(1) from exc