""" Configuration loading for second-brain. Settings are read from config/settings.toml (or a path set via SECOND_BRAIN_CONFIG). All values have sensible defaults so the system works out of the box. """ from __future__ import annotations import os from pathlib import Path from typing import Any try: import tomllib # Python 3.11+ except ModuleNotFoundError: import tomli as tomllib # type: ignore[no-reuse-impl] # --------------------------------------------------------------------------- # Defaults # --------------------------------------------------------------------------- _DEFAULTS: dict[str, Any] = { "db_path": "~/.local/share/second-brain/second_brain.db", "vault_path": "~/Documents/second-brain-vault", "media_dir": "~/.local/share/second-brain/media", "subtitles_dir": "~/.local/share/second-brain/subtitles", "whisper_model": "small", "domains": ["development", "content", "business", "homelab"], "extractor": { # "cli" runs `claude -p` under the Max OAuth subscription (default). # "api" uses the Anthropic SDK and requires ANTHROPIC_API_KEY. "backend": "cli", "model": "claude-sonnet-4-6", "cli_binary": "claude", "timeout_sec": 600, }, "scheduler": { "window_start": "22:00", "window_end": "06:00", # CLI backend: throttle by request rate (no per-token billing). "max_calls_per_hour": 30, "min_gap_seconds": 120, # Legacy API-backend budget; kept for backwards compat. "max_tokens_per_hour": 100_000, }, } DOMAINS = ["development", "content", "business", "homelab"] class Config: """Holds resolved configuration values.""" def __init__(self, raw: dict[str, Any]) -> None: self._raw = raw # --- paths --- @property def db_path(self) -> Path: return Path(self._raw.get("db_path", _DEFAULTS["db_path"])).expanduser() @property def vault_path(self) -> Path: return Path(self._raw.get("vault_path", _DEFAULTS["vault_path"])).expanduser() @property def media_dir(self) -> Path: return Path(self._raw.get("media_dir", _DEFAULTS["media_dir"])).expanduser() @property def subtitles_dir(self) -> Path: return Path( self._raw.get("subtitles_dir", _DEFAULTS["subtitles_dir"]) ).expanduser() # --- extraction --- @property def whisper_model(self) -> str: return self._raw.get("whisper_model", _DEFAULTS["whisper_model"]) @property def domains(self) -> list[str]: return self._raw.get("domains", _DEFAULTS["domains"]) @property def anthropic_api_key(self) -> str | None: return os.getenv("ANTHROPIC_API_KEY") # --- extractor --- @property def extractor(self) -> dict[str, Any]: return {**_DEFAULTS["extractor"], **self._raw.get("extractor", {})} # --- scheduler --- @property def scheduler(self) -> dict[str, Any]: return {**_DEFAULTS["scheduler"], **self._raw.get("scheduler", {})} # --- prompts dir --- @property def prompts_dir(self) -> Path: """Directory containing per-domain prompt templates.""" # src/second_brain/config.py → ../../.. = project root here = Path(__file__).resolve().parent.parent.parent return here / "prompts" def ensure_dirs(self) -> None: """Create runtime directories if they don't exist.""" for d in (self.db_path.parent, self.media_dir, self.subtitles_dir): d.mkdir(parents=True, exist_ok=True) # --------------------------------------------------------------------------- # Loader # --------------------------------------------------------------------------- _config_instance: Config | None = None def load_config(path: Path | None = None) -> Config: """Load (and cache) configuration from a TOML file.""" global _config_instance if _config_instance is not None: return _config_instance if path is None: env_path = os.getenv("SECOND_BRAIN_CONFIG") if env_path: path = Path(env_path) else: # Walk up from CWD looking for config/settings.toml cwd = Path.cwd() for candidate in [cwd / "config" / "settings.toml", cwd / "settings.toml"]: if candidate.exists(): path = candidate break raw: dict[str, Any] = {} if path and path.exists(): with open(path, "rb") as fh: raw = tomllib.load(fh) _config_instance = Config(raw) return _config_instance __all__ = ["Config", "DOMAINS", "load_config"]