""" config.py — Configuration loading and validation. Loaded once at startup. Fails fast if required variables are missing. All other modules import `settings` from here. """ from __future__ import annotations import os import re import sys from dataclasses import dataclass, field from pathlib import Path from dotenv import load_dotenv # Load .env from the project root (one level above server/) _env_path = Path(__file__).parent.parent / ".env" load_dotenv(_env_path) _PROJECT_ROOT = Path(__file__).parent.parent def _extract_agent_name(fallback: str = "Jarvis") -> str: """Read agent name from SOUL.md. Looks for 'You are **Name**', then the # heading.""" try: soul = (_PROJECT_ROOT / "SOUL.md").read_text(encoding="utf-8") except FileNotFoundError: return fallback # Primary: "You are **Name**" m = re.search(r"You are \*\*([^*]+)\*\*", soul) if m: return m.group(1).strip() # Fallback: first "# Name" heading, dropping anything after " — " for line in soul.splitlines(): if line.startswith("# "): name = line[2:].split("—")[0].strip() if name: return name return fallback def _require(key: str) -> str: """Get a required environment variable, fail fast if missing.""" value = os.getenv(key) if not value: print(f"[aide] FATAL: Required environment variable '{key}' is not set.", file=sys.stderr) print(f"[aide] Copy .env.example to .env and fill in your values.", file=sys.stderr) sys.exit(1) return value def _optional(key: str, default: str = "") -> str: return os.getenv(key, default) @dataclass class Settings: # Required db_master_password: str # AI provider selection — keys are stored in the DB, not here default_provider: str = "anthropic" # "anthropic", "openrouter", or "openai" default_model: str = "" # Empty = use provider's default model # Optional with defaults port: int = 8080 max_tool_calls: int = 20 max_autonomous_runs_per_hour: int = 10 timezone: str = "Europe/Oslo" # Agent identity — derived from SOUL.md at startup, fallback if file absent agent_name: str = "Jarvis" # Model selection — empty list triggers auto-discovery at runtime available_models: list[str] = field(default_factory=list) default_chat_model: str = "" # Database aide_db_url: str = "" def _load() -> Settings: master_password = _require("DB_MASTER_PASSWORD") default_provider = _optional("DEFAULT_PROVIDER", "anthropic").lower() default_model = _optional("DEFAULT_MODEL", "") _known_providers = {"anthropic", "openrouter", "openai"} if default_provider not in _known_providers: print(f"[aide] FATAL: Unknown DEFAULT_PROVIDER '{default_provider}'. Use 'anthropic', 'openrouter', or 'openai'.", file=sys.stderr) sys.exit(1) port = int(_optional("PORT", "8080")) max_tool_calls = int(_optional("MAX_TOOL_CALLS", "20")) max_runs = int(_optional("MAX_AUTONOMOUS_RUNS_PER_HOUR", "10")) timezone = _optional("TIMEZONE", "Europe/Oslo") def _normalize_model(m: str) -> str: """Prepend default_provider if model has no provider prefix.""" parts = m.split(":", 1) if len(parts) == 2 and parts[0] in _known_providers: return m return f"{default_provider}:{m}" available_models: list[str] = [] # unused; kept for backward compat default_chat_model_raw = _optional("DEFAULT_CHAT_MODEL", "") default_chat_model = _normalize_model(default_chat_model_raw) if default_chat_model_raw else "" aide_db_url = _require("AIDE_DB_URL") return Settings( agent_name=_extract_agent_name(), db_master_password=master_password, default_provider=default_provider, default_model=default_model, port=port, max_tool_calls=max_tool_calls, max_autonomous_runs_per_hour=max_runs, timezone=timezone, available_models=available_models, default_chat_model=default_chat_model, aide_db_url=aide_db_url, ) # Module-level singleton — import this everywhere settings = _load()