|
| 1 | +"""Configuration loader for companion-emergence. |
| 2 | +
|
| 3 | +Merges settings from three sources in increasing priority: |
| 4 | +
|
| 5 | +1. persona/<name>/persona.toml - persona defaults (baseline) |
| 6 | +2. .env in repo root - local overrides |
| 7 | +3. Environment variables - runtime overrides (highest priority) |
| 8 | +
|
| 9 | +Each resolved value is traced so startup can report the effective source. |
| 10 | +""" |
| 11 | + |
| 12 | +from __future__ import annotations |
| 13 | + |
| 14 | +import os |
| 15 | +import tomllib |
| 16 | +from dataclasses import dataclass, field |
| 17 | +from pathlib import Path |
| 18 | + |
| 19 | +# Config keys supported by the framework. Extended in later weeks. |
| 20 | +_SUPPORTED_KEYS: tuple[str, ...] = ( |
| 21 | + "BRIDGE_BIND", |
| 22 | + "PROVIDER", |
| 23 | + "MODEL", |
| 24 | + "NELL_IPC_JID", |
| 25 | +) |
| 26 | + |
| 27 | +# Framework-level defaults, applied as the lowest-priority source so that |
| 28 | +# source_trace explicitly records "default" for unset keys (startup logging |
| 29 | +# can then distinguish "value came from default" from "value missing"). |
| 30 | +_DEFAULTS: dict[str, str] = { |
| 31 | + "BRIDGE_BIND": "127.0.0.1:8765", |
| 32 | + "PROVIDER": "ollama", |
| 33 | + "MODEL": "", |
| 34 | + "NELL_IPC_JID": "", |
| 35 | +} |
| 36 | + |
| 37 | + |
| 38 | +@dataclass |
| 39 | +class Config: |
| 40 | + """Resolved configuration for a persona session. |
| 41 | +
|
| 42 | + Attributes: |
| 43 | + persona_name: Name of the active persona (derived from dir basename). |
| 44 | + bridge_bind: Host:port the bridge listens on. |
| 45 | + provider: LLM provider key (ollama, claude, openai, kimi). |
| 46 | + model: Model tag for the active provider. |
| 47 | + ipc_jid: IPC target for outbox delivery (e.g. WhatsApp JID). Empty |
| 48 | + disables outbox→IPC integration. |
| 49 | + source_trace: Maps each resolved key to the source that provided it. |
| 50 | + """ |
| 51 | + |
| 52 | + persona_name: str = "" |
| 53 | + bridge_bind: str = "127.0.0.1:8765" |
| 54 | + provider: str = "ollama" |
| 55 | + model: str = "" |
| 56 | + ipc_jid: str = "" |
| 57 | + source_trace: dict[str, str] = field(default_factory=dict) |
| 58 | + |
| 59 | + |
| 60 | +def _load_env_file(path: Path) -> dict[str, str]: |
| 61 | + """Parse a simple KEY=VALUE .env file. |
| 62 | +
|
| 63 | + Supports: comment lines starting with `#`, blank lines, surrounding |
| 64 | + single/double quotes, inline comments after the value (`KEY=val # note`). |
| 65 | + Does not support: shell expansion, `export` prefix, multi-line values. |
| 66 | + Always read as UTF-8 regardless of platform default encoding. |
| 67 | + """ |
| 68 | + result: dict[str, str] = {} |
| 69 | + if not path.exists(): |
| 70 | + return result |
| 71 | + for raw_line in path.read_text(encoding="utf-8").splitlines(): |
| 72 | + line = raw_line.strip() |
| 73 | + if not line or line.startswith("#"): |
| 74 | + continue |
| 75 | + if "=" not in line: |
| 76 | + continue |
| 77 | + key, _, value = line.partition("=") |
| 78 | + value = value.strip() |
| 79 | + # Strip inline comment (anything after a ` #` segment). The leading |
| 80 | + # space is required so `#` inside a quoted URL or token survives. |
| 81 | + if " #" in value: |
| 82 | + value = value[: value.index(" #")].rstrip() |
| 83 | + result[key.strip()] = value.strip('"').strip("'") |
| 84 | + return result |
| 85 | + |
| 86 | + |
| 87 | +def _load_persona_toml(persona_dir: Path) -> dict[str, str]: |
| 88 | + """Flatten persona.toml into the framework's config key namespace.""" |
| 89 | + toml_path = persona_dir / "persona.toml" |
| 90 | + if not toml_path.exists(): |
| 91 | + return {} |
| 92 | + with toml_path.open("rb") as fh: |
| 93 | + data = tomllib.load(fh) |
| 94 | + |
| 95 | + flat: dict[str, str] = {} |
| 96 | + bridge = data.get("bridge", {}) |
| 97 | + if "bind" in bridge: |
| 98 | + flat["BRIDGE_BIND"] = str(bridge["bind"]) |
| 99 | + model = data.get("model", {}) |
| 100 | + if "provider" in model: |
| 101 | + flat["PROVIDER"] = str(model["provider"]) |
| 102 | + if "tag" in model: |
| 103 | + flat["MODEL"] = str(model["tag"]) |
| 104 | + return flat |
| 105 | + |
| 106 | + |
| 107 | +def _read_env_vars() -> dict[str, str]: |
| 108 | + """Pull supported keys from os.environ.""" |
| 109 | + return {k: v for k, v in os.environ.items() if k in _SUPPORTED_KEYS} |
| 110 | + |
| 111 | + |
| 112 | +def load_config(persona_dir: Path, env_file: Path | None = None) -> Config: |
| 113 | + """Load and merge config from persona.toml + .env + env vars. |
| 114 | +
|
| 115 | + Args: |
| 116 | + persona_dir: Directory containing persona.toml and persona data. |
| 117 | + env_file: Optional path to a .env file to load. |
| 118 | +
|
| 119 | + Returns: |
| 120 | + Resolved Config with source_trace recording where each value came from. |
| 121 | + """ |
| 122 | + sources: list[tuple[str, dict[str, str]]] = [ |
| 123 | + ("default", dict(_DEFAULTS)), |
| 124 | + ("persona.toml", _load_persona_toml(persona_dir)), |
| 125 | + ] |
| 126 | + if env_file is not None: |
| 127 | + sources.append((".env", _load_env_file(env_file))) |
| 128 | + sources.append(("env", _read_env_vars())) |
| 129 | + |
| 130 | + trace: dict[str, str] = {} |
| 131 | + merged: dict[str, str] = {} |
| 132 | + for source_name, source_values in sources: |
| 133 | + for key, value in source_values.items(): |
| 134 | + merged[key] = value |
| 135 | + trace[key] = source_name |
| 136 | + |
| 137 | + return Config( |
| 138 | + persona_name=persona_dir.name, |
| 139 | + bridge_bind=merged["BRIDGE_BIND"], |
| 140 | + provider=merged["PROVIDER"], |
| 141 | + model=merged["MODEL"], |
| 142 | + ipc_jid=merged["NELL_IPC_JID"], |
| 143 | + source_trace=trace, |
| 144 | + ) |
0 commit comments