"""Configuration loader: env vars → config.yaml → defaults.""" from __future__ import annotations import os from dataclasses import dataclass, field from pathlib import Path import yaml from dotenv import load_dotenv ROOT_DIR = Path(__file__).resolve().parent.parent load_dotenv(ROOT_DIR / ".env") @dataclass class MemoryConfig: max_context_messages: int = 50 flush_threshold: int = 40 embedding_model: str = "all-MiniLM-L6-v2" search_top_k: int = 5 @dataclass class SchedulerConfig: heartbeat_interval_minutes: int = 30 poll_interval_seconds: int = 60 @dataclass class ShellConfig: blocked_commands: list[str] = field( default_factory=lambda: ["rm -rf /", "format", ":(){:|:&};:"] ) require_approval: bool = False @dataclass class ClickUpConfig: api_token: str = "" workspace_id: str = "" space_id: str = "" poll_interval_minutes: int = 20 poll_statuses: list[str] = field(default_factory=lambda: ["to do"]) review_status: str = "internal review" pr_review_status: str = "pr needs review" in_progress_status: str = "in progress" automation_status: str = "automation underway" error_status: str = "error" task_type_field_name: str = "Work Category" default_auto_execute: bool = False poll_task_types: list[str] = field(default_factory=list) skill_map: dict = field(default_factory=dict) enabled: bool = False @dataclass class PressAdvantageConfig: api_key: str = "" base_url: str = "https://app.pressadvantage.com" @dataclass class EmailConfig: smtp_host: str = "smtp.gmail.com" smtp_port: int = 465 username: str = "" password: str = "" default_to: str = "" enabled: bool = False @dataclass class LinkBuildingConfig: blm_dir: str = "E:/dev/Big-Link-Man" watch_folder: str = "" # empty = disabled watch_interval_minutes: int = 60 default_branded_plus_ratio: float = 0.7 @dataclass class AutoCoraConfig: jobs_dir: str = "//PennQnap1/SHARE1/AutoCora/jobs" results_dir: str = "//PennQnap1/SHARE1/AutoCora/results" poll_interval_minutes: int = 5 success_status: str = "running cora" error_status: str = "error" enabled: bool = False cora_categories: list[str] = field( default_factory=lambda: ["Content Creation", "On Page Optimization", "Link Building"] ) cora_human_inbox: str = "" # e.g. "Z:/Cora-For-Human" @dataclass class ApiBudgetConfig: monthly_limit: float = 20.00 # USD - alert when exceeded alert_threshold: float = 0.8 # alert at 80% of limit @dataclass class TimeoutConfig: execution_brain: int = 2700 # 45 minutes blm: int = 1800 # 30 minutes @dataclass class ContentConfig: cora_inbox: str = "" # e.g. "Z:/content-cora-inbox" outline_dir: str = "" # e.g. "Z:/content-outlines" company_capabilities_default: str = ( "All certifications and licenses need to be verified on the company's website." ) @dataclass class NtfyChannelConfig: name: str = "" topic_env_var: str = "" # env var name holding the topic string server: str = "https://ntfy.sh" categories: list[str] = field(default_factory=list) include_patterns: list[str] = field(default_factory=list) exclude_patterns: list[str] = field(default_factory=list) priority: str = "high" # min / low / default / high / urgent tags: str = "" # comma-separated emoji shortcodes @dataclass class NtfyConfig: enabled: bool = False channels: list[NtfyChannelConfig] = field(default_factory=list) @dataclass class GoogleDriveConfig: root_folder_id: str = "" enabled: bool = False @dataclass class GmailApiConfig: enabled: bool = False @dataclass class AgentConfig: """Per-agent configuration for multi-agent support.""" name: str = "default" display_name: str = "CheddahBot" personality_file: str = "" # path to SOUL-like .md file, empty = default model: str = "" # model override, empty = use global chat_model tools: list[str] | None = None # tool name whitelist, None = all skills: list[str] | None = None # skill name filter, None = auto memory_scope: str = "" # memory namespace, empty = shared @dataclass class Config: chat_model: str = "openai/gpt-4o-mini" default_model: str = "claude-sonnet-4.5" host: str = "0.0.0.0" port: int = 7860 ollama_url: str = "http://localhost:11434" lmstudio_url: str = "http://localhost:1234" openrouter_api_key: str = "" memory: MemoryConfig = field(default_factory=MemoryConfig) scheduler: SchedulerConfig = field(default_factory=SchedulerConfig) shell: ShellConfig = field(default_factory=ShellConfig) clickup: ClickUpConfig = field(default_factory=ClickUpConfig) press_advantage: PressAdvantageConfig = field(default_factory=PressAdvantageConfig) email: EmailConfig = field(default_factory=EmailConfig) link_building: LinkBuildingConfig = field(default_factory=LinkBuildingConfig) autocora: AutoCoraConfig = field(default_factory=AutoCoraConfig) api_budget: ApiBudgetConfig = field(default_factory=ApiBudgetConfig) content: ContentConfig = field(default_factory=ContentConfig) timeouts: TimeoutConfig = field(default_factory=TimeoutConfig) ntfy: NtfyConfig = field(default_factory=NtfyConfig) google_drive: GoogleDriveConfig = field(default_factory=GoogleDriveConfig) gmail_api: GmailApiConfig = field(default_factory=GmailApiConfig) agents: list[AgentConfig] = field(default_factory=lambda: [AgentConfig()]) # Derived paths root_dir: Path = field(default_factory=lambda: ROOT_DIR) data_dir: Path = field(default_factory=lambda: ROOT_DIR / "data") identity_dir: Path = field(default_factory=lambda: ROOT_DIR / "identity") memory_dir: Path = field(default_factory=lambda: ROOT_DIR / "memory") skills_dir: Path = field(default_factory=lambda: ROOT_DIR / "skills") db_path: Path = field(default_factory=lambda: ROOT_DIR / "data" / "cheddahbot.db") def load_config() -> Config: """Load config from env vars → config.yaml → defaults.""" cfg = Config() # Load YAML if exists yaml_path = ROOT_DIR / "config.yaml" if yaml_path.exists(): with open(yaml_path) as f: data = yaml.safe_load(f) or {} for key in ("chat_model", "default_model", "host", "port", "ollama_url", "lmstudio_url"): if key in data: setattr(cfg, key, data[key]) if "memory" in data and isinstance(data["memory"], dict): for k, v in data["memory"].items(): if hasattr(cfg.memory, k): setattr(cfg.memory, k, v) if "scheduler" in data and isinstance(data["scheduler"], dict): for k, v in data["scheduler"].items(): if hasattr(cfg.scheduler, k): setattr(cfg.scheduler, k, v) if "shell" in data and isinstance(data["shell"], dict): for k, v in data["shell"].items(): if hasattr(cfg.shell, k): setattr(cfg.shell, k, v) if "clickup" in data and isinstance(data["clickup"], dict): for k, v in data["clickup"].items(): if hasattr(cfg.clickup, k): setattr(cfg.clickup, k, v) if "press_advantage" in data and isinstance(data["press_advantage"], dict): for k, v in data["press_advantage"].items(): if hasattr(cfg.press_advantage, k): setattr(cfg.press_advantage, k, v) if "email" in data and isinstance(data["email"], dict): for k, v in data["email"].items(): if hasattr(cfg.email, k): setattr(cfg.email, k, v) if "link_building" in data and isinstance(data["link_building"], dict): for k, v in data["link_building"].items(): if hasattr(cfg.link_building, k): setattr(cfg.link_building, k, v) if "autocora" in data and isinstance(data["autocora"], dict): for k, v in data["autocora"].items(): if hasattr(cfg.autocora, k): setattr(cfg.autocora, k, v) if "api_budget" in data and isinstance(data["api_budget"], dict): for k, v in data["api_budget"].items(): if hasattr(cfg.api_budget, k): setattr(cfg.api_budget, k, v) if "content" in data and isinstance(data["content"], dict): for k, v in data["content"].items(): if hasattr(cfg.content, k): setattr(cfg.content, k, v) if "timeouts" in data and isinstance(data["timeouts"], dict): for k, v in data["timeouts"].items(): if hasattr(cfg.timeouts, k): setattr(cfg.timeouts, k, int(v)) # ntfy push notifications if "ntfy" in data and isinstance(data["ntfy"], dict): ntfy_data = data["ntfy"] cfg.ntfy.enabled = ntfy_data.get("enabled", False) if "channels" in ntfy_data and isinstance(ntfy_data["channels"], list): cfg.ntfy.channels = [] for ch_data in ntfy_data["channels"]: if isinstance(ch_data, dict): ch = NtfyChannelConfig() for k, v in ch_data.items(): if hasattr(ch, k): setattr(ch, k, v) cfg.ntfy.channels.append(ch) # Google Drive config if "google_drive" in data and isinstance(data["google_drive"], dict): for k, v in data["google_drive"].items(): if hasattr(cfg.google_drive, k): setattr(cfg.google_drive, k, v) # Gmail API config if "gmail_api" in data and isinstance(data["gmail_api"], dict): for k, v in data["gmail_api"].items(): if hasattr(cfg.gmail_api, k): setattr(cfg.gmail_api, k, v) # Multi-agent configs if "agents" in data and isinstance(data["agents"], list): cfg.agents = [] for agent_data in data["agents"]: if isinstance(agent_data, dict): ac = AgentConfig() for k, v in agent_data.items(): if hasattr(ac, k): setattr(ac, k, v) cfg.agents.append(ac) # Ensure at least one agent if not cfg.agents: cfg.agents = [AgentConfig()] # Env var overrides (CHEDDAH_ prefix) cfg.openrouter_api_key = os.getenv("OPENROUTER_API_KEY", "") if cm := os.getenv("CHEDDAH_CHAT_MODEL"): cfg.chat_model = cm if m := os.getenv("CHEDDAH_DEFAULT_MODEL"): cfg.default_model = m if h := os.getenv("CHEDDAH_HOST"): cfg.host = h if p := os.getenv("CHEDDAH_PORT"): cfg.port = int(p) # ClickUp env var overrides if token := os.getenv("CLICKUP_API_TOKEN"): cfg.clickup.api_token = token if ws := os.getenv("CLICKUP_WORKSPACE_ID"): cfg.clickup.workspace_id = ws if sp := os.getenv("CLICKUP_SPACE_ID"): cfg.clickup.space_id = sp # Auto-enable if token is present cfg.clickup.enabled = bool(cfg.clickup.api_token) # Press Advantage env var override if key := os.getenv("PRESS_ADVANTAGE_API"): cfg.press_advantage.api_key = key # Email env var overrides if gmail_user := os.getenv("GMAIL_USERNAME"): cfg.email.username = gmail_user if gmail_pass := os.getenv("GMAIL_APP_PASSWORD"): cfg.email.password = gmail_pass if default_to := os.getenv("EMAIL_DEFAULT_TO"): cfg.email.default_to = default_to cfg.email.enabled = bool(cfg.email.username and cfg.email.password) # Link Building env var overrides if blm_dir := os.getenv("BLM_DIR"): cfg.link_building.blm_dir = blm_dir # Timeout env var overrides (seconds) if t := os.getenv("CHEDDAH_TIMEOUT_EXECUTION_BRAIN"): cfg.timeouts.execution_brain = int(t) if t := os.getenv("CHEDDAH_TIMEOUT_BLM"): cfg.timeouts.blm = int(t) # Ensure data directories exist cfg.data_dir.mkdir(parents=True, exist_ok=True) (cfg.data_dir / "uploads").mkdir(exist_ok=True) (cfg.data_dir / "generated").mkdir(exist_ok=True) cfg.memory_dir.mkdir(parents=True, exist_ok=True) cfg.skills_dir.mkdir(parents=True, exist_ok=True) return cfg