import os import sys import logging from pathlib import Path from dotenv import find_dotenv, load_dotenv # Locked profile: set to a profile name (e.g., "astronomer") to lock the app # to that profile and disable all profile switching. Leave as None for normal behavior. LOCKED_PROFILE: str | None = None DEFAULT_PROFILES_DIRECTORY = Path(__file__).parent / "profiles" logger = logging.getLogger(__name__) def _env_flag(name: str, default: bool = False) -> bool: """Parse a boolean environment flag. Accepted truthy values: 1, true, yes, on Accepted falsy values: 0, false, no, off """ raw = os.getenv(name) if raw is None: return default value = raw.strip().lower() if value in {"1", "true", "yes", "on"}: return True if value in {"0", "false", "no", "off"}: return False logger.warning("Invalid boolean value for %s=%r, using default=%s", name, raw, default) return default def _collect_profile_names(profiles_root: Path) -> set[str]: """Return profile folder names from a profiles root directory.""" if not profiles_root.exists() or not profiles_root.is_dir(): return set() return {p.name for p in profiles_root.iterdir() if p.is_dir()} def _collect_tool_module_names(tools_root: Path) -> set[str]: """Return tool module names from a tools directory.""" if not tools_root.exists() or not tools_root.is_dir(): return set() ignored = {"__init__", "core_tools"} return { p.stem for p in tools_root.glob("*.py") if p.is_file() and p.stem not in ignored } def _raise_on_name_collisions( *, label: str, external_root: Path, internal_root: Path, external_names: set[str], internal_names: set[str], ) -> None: """Raise with a clear message when external/internal names collide.""" collisions = sorted(external_names & internal_names) if not collisions: return raise RuntimeError( f"Config.__init__(): Ambiguous {label} names found in both external and built-in libraries: {collisions}. " f"External {label} root: {external_root}. Built-in {label} root: {internal_root}. " f"Please rename the conflicting external {label}(s) to continue." ) # Validate LOCKED_PROFILE at startup if LOCKED_PROFILE is not None: _profiles_dir = DEFAULT_PROFILES_DIRECTORY _profile_path = _profiles_dir / LOCKED_PROFILE _instructions_file = _profile_path / "instructions.txt" if not _profile_path.is_dir(): print(f"Error: LOCKED_PROFILE '{LOCKED_PROFILE}' does not exist in {_profiles_dir}", file=sys.stderr) sys.exit(1) if not _instructions_file.is_file(): print(f"Error: LOCKED_PROFILE '{LOCKED_PROFILE}' has no instructions.txt", file=sys.stderr) sys.exit(1) _skip_dotenv = _env_flag("REACHY_MINI_SKIP_DOTENV", default=False) if _skip_dotenv: logger.info("Skipping .env loading because REACHY_MINI_SKIP_DOTENV is set") else: # Locate .env file (search upward from current working directory) dotenv_path = find_dotenv(usecwd=True) if dotenv_path: # Load .env and override environment variables load_dotenv(dotenv_path=dotenv_path, override=True) logger.info(f"Configuration loaded from {dotenv_path}") else: logger.warning("No .env file found, using environment variables") class Config: """Configuration class for the conversation app.""" # Ollama OLLAMA_BASE_URL = os.getenv("OLLAMA_BASE_URL", "http://localhost:11434") MODEL_NAME = os.getenv("MODEL_NAME", "llama3.2") # STT (faster-whisper model size: tiny, base, small, medium, large-v3) STT_MODEL = os.getenv("STT_MODEL", "base") # TTS (edge-tts voice name) TTS_VOICE = os.getenv("TTS_VOICE", "en-US-AriaNeural") # Vision (optional, used with --local-vision CLI flag) HF_HOME = os.getenv("HF_HOME", "./cache") LOCAL_VISION_MODEL = os.getenv("LOCAL_VISION_MODEL", "HuggingFaceTB/SmolVLM2-2.2B-Instruct") HF_TOKEN = os.getenv("HF_TOKEN") # Optional, falls back to hf auth login if not set logger.debug(f"Model: {MODEL_NAME}, Ollama: {OLLAMA_BASE_URL}, STT: {STT_MODEL}, TTS: {TTS_VOICE}") _profiles_directory_env = os.getenv("REACHY_MINI_EXTERNAL_PROFILES_DIRECTORY") PROFILES_DIRECTORY = ( Path(_profiles_directory_env) if _profiles_directory_env else Path(__file__).parent / "profiles" ) _tools_directory_env = os.getenv("REACHY_MINI_EXTERNAL_TOOLS_DIRECTORY") TOOLS_DIRECTORY = Path(_tools_directory_env) if _tools_directory_env else None AUTOLOAD_EXTERNAL_TOOLS = _env_flag("AUTOLOAD_EXTERNAL_TOOLS", default=False) REACHY_MINI_CUSTOM_PROFILE = LOCKED_PROFILE or os.getenv("REACHY_MINI_CUSTOM_PROFILE") logger.debug(f"Custom Profile: {REACHY_MINI_CUSTOM_PROFILE}") def __init__(self) -> None: """Initialize the configuration.""" if self.REACHY_MINI_CUSTOM_PROFILE and self.PROFILES_DIRECTORY != DEFAULT_PROFILES_DIRECTORY: selected_profile_path = self.PROFILES_DIRECTORY / self.REACHY_MINI_CUSTOM_PROFILE if not selected_profile_path.is_dir(): available_profiles = sorted(_collect_profile_names(self.PROFILES_DIRECTORY)) raise RuntimeError( "Config.__init__(): Selected profile " f"'{self.REACHY_MINI_CUSTOM_PROFILE}' was not found in external profiles root " f"{self.PROFILES_DIRECTORY}. " f"Available external profiles: {available_profiles}. " "Either set 'REACHY_MINI_CUSTOM_PROFILE' to one of the available external profiles " "or unset 'REACHY_MINI_EXTERNAL_PROFILES_DIRECTORY' to use built-in profiles." ) if self.PROFILES_DIRECTORY != DEFAULT_PROFILES_DIRECTORY: external_profiles = _collect_profile_names(self.PROFILES_DIRECTORY) internal_profiles = _collect_profile_names(DEFAULT_PROFILES_DIRECTORY) _raise_on_name_collisions( label="profile", external_root=self.PROFILES_DIRECTORY, internal_root=DEFAULT_PROFILES_DIRECTORY, external_names=external_profiles, internal_names=internal_profiles, ) if self.TOOLS_DIRECTORY is not None: builtin_tools_root = Path(__file__).parent / "tools" external_tools = _collect_tool_module_names(self.TOOLS_DIRECTORY) internal_tools = _collect_tool_module_names(builtin_tools_root) _raise_on_name_collisions( label="tool", external_root=self.TOOLS_DIRECTORY, internal_root=builtin_tools_root, external_names=external_tools, internal_names=internal_tools, ) if self.PROFILES_DIRECTORY != DEFAULT_PROFILES_DIRECTORY: logger.warning( "Environment variable 'REACHY_MINI_EXTERNAL_PROFILES_DIRECTORY' is set. " "Profiles (instructions.txt, ...) will be loaded from %s.", self.PROFILES_DIRECTORY, ) else: logger.info( "'REACHY_MINI_EXTERNAL_PROFILES_DIRECTORY' is not set. " "Using built-in profiles from %s.", DEFAULT_PROFILES_DIRECTORY, ) if self.TOOLS_DIRECTORY is not None: logger.warning( "Environment variable 'REACHY_MINI_EXTERNAL_TOOLS_DIRECTORY' is set. " "External tools will be loaded from %s.", self.TOOLS_DIRECTORY, ) else: logger.info( "'REACHY_MINI_EXTERNAL_TOOLS_DIRECTORY' is not set. " "Using built-in shared tools only." ) config = Config() def set_custom_profile(profile: str | None) -> None: """Update the selected custom profile at runtime and expose it via env. This ensures modules that read `config` and code that inspects the environment see a consistent value. """ if LOCKED_PROFILE is not None: return try: config.REACHY_MINI_CUSTOM_PROFILE = profile except Exception: pass try: import os as _os if profile: _os.environ["REACHY_MINI_CUSTOM_PROFILE"] = profile else: # Remove to reflect default _os.environ.pop("REACHY_MINI_CUSTOM_PROFILE", None) except Exception: pass