second-brain/src/second_brain/config.py
Travis Herbranson c4793f3a7f switch extractor to Claude CLI backend + pipeline fixes
Run extraction under the Max OAuth subscription via `claude -p` instead
of the per-token Anthropic API. The new src/second_brain/llm/claude_cli.py
spawns the CLI in a hermetic tempdir so the host project's CLAUDE.md,
hooks, MCP config, and settings don't leak into the prompt. Uses
--json-schema with LLMExtraction.model_json_schema() so the CLI guarantees
valid structured output — replaces the brittle markdown-fence stripping
in the old engine. The Anthropic SDK is preserved as an optional "api"
backend selectable via config.

While in here, fix a handful of blockers that the smoke test surfaced:
- scheduler filtered ANALYZED instead of TRANSCRIBED, so it never
  actually advanced any sources
- process command read sources in a closed session, raising
  DetachedInstanceError before any work happened
- config.prompts_dir walked one parent too many, resolving outside
  the project and forcing the fallback prompt for every domain
- compiler called git rev-parse against a vault that was never
  git-init'd; now auto-inits with an empty seed commit and skips empty
  commits cleanly
- datetime.utcnow() deprecated in 3.12+ — single utcnow() helper in
  models.py keeps naive UTC semantics so no DB migration is needed
- sess.query(...).get() deprecated in SA 2.x → sess.get(...)
- dead `import anthropic` removed from compiler

Smoke test (article → process → accept → compile) succeeds end-to-end
with ANTHROPIC_API_KEY unset. a-review run saved under reviews/.

Co-Authored-By: Claude Opus 4 <noreply@anthropic.com>
2026-05-24 21:09:20 -04:00

154 lines
4.6 KiB
Python

"""
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"]