Files
2026-06-20 08:58:48 +00:00

163 lines
7.0 KiB
Python

"""SkillOpt-Sleep — configuration.
Config is JSON-first (yaml optional) so the engine and the deterministic
experiment run with zero external dependencies. Defaults are safe:
review-gated adoption, single-project scope, bounded token/task budgets.
Resolution order (later wins):
1. built-in DEFAULTS
2. ~/.skillopt-sleep/config.json (or .yaml if PyYAML available)
3. explicit overrides passed to load_config(**overrides)
"""
from __future__ import annotations
import json
import os
from dataclasses import dataclass, field
from typing import Any, Dict, Optional
HOME_STATE_DIR = os.path.expanduser("~/.skillopt-sleep")
CLAUDE_HOME = os.path.expanduser("~/.claude")
CODEX_HOME = os.path.expanduser("~/.codex")
DEFAULTS: Dict[str, Any] = {
# ── scope ──────────────────────────────────────────────────────────────
"claude_home": CLAUDE_HOME,
"codex_home": CODEX_HOME,
"transcript_source": "claude", # "claude" | "codex" | "auto"
"projects": "invoked", # "invoked" | "all" | [list of abs paths]
"invoked_project": "", # filled at runtime (cwd) when projects == "invoked"
"lookback_hours": 72, # harvest window when no prior sleep recorded
# ── budgets ────────────────────────────────────────────────────────────
"max_tasks_per_night": 40,
"max_tokens_per_night": 400_000,
"holdout_fraction": 0.34, # legacy alias for val_fraction
"val_fraction": 0.34, # real tasks reserved to gate updates
"test_fraction": 0.0, # real tasks reserved as the final held-out measure
# ── optimizer ──────────────────────────────────────────────────────────
"backend": "mock", # "mock" | "claude" | "codex" | "copilot"
"model": "", # backend-specific; "" => backend default
"gate_mode": "on", # "on" (validation-gated) | "off" (greedy, no hard filter)
"codex_path": "", # "" => auto-detect the real @openai/codex binary
"edit_budget": 4, # textual learning rate (max edits/night)
"gate_metric": "mixed", # hard | soft | mixed (mixed best for tiny holdouts)
"gate_mixed_weight": 0.5,
"replay_mode": "mock", # "mock" (sandboxed prompt) | "fresh" (worktree)
# ── dream + recall (opt-in; defaults reproduce the prior single-shot loop) ─
"dream_rollouts": 1, # >1 => multi-rollout contrastive reflection per task
"dream_factor": 0, # >0 => add N synthetic variants of each task to the dream
"recall_k": 0, # >0 => recall the K most-similar past tasks into the dream
"evolve_memory": True, # consolidate CLAUDE.md
"evolve_skill": True, # consolidate the managed SKILL.md
"llm_mine": True, # use the backend to mine checkable tasks (real backends)
"target_skill_path": "", # explicit SKILL.md target for repo-scoped agents
"target_task_filter": True, # prefer mined tasks matching target_skill_path/text
"progress": False, # print phase progress to stderr
# ── adoption / safety ──────────────────────────────────────────────────
"auto_adopt": False, # default: stage + require explicit `adopt`
"managed_skill_name": "skillopt-sleep-learned",
"redact_secrets": True,
"seed": 42,
}
@dataclass
class SleepConfig:
data: Dict[str, Any] = field(default_factory=lambda: dict(DEFAULTS))
# convenient attribute access -------------------------------------------
def __getattr__(self, name: str) -> Any:
# only called when normal attribute lookup fails
data = object.__getattribute__(self, "data")
if name in data:
return data[name]
raise AttributeError(name)
def get(self, key: str, default: Any = None) -> Any:
return self.data.get(key, default)
def to_dict(self) -> Dict[str, Any]:
return dict(self.data)
# paths ------------------------------------------------------------------
@property
def state_dir(self) -> str:
# Allow full isolation: if the caller overrides state_dir explicitly,
# honor it; else derive from claude_home's parent so a single
# --claude-home flag isolates transcripts AND state together; else the
# default ~/.skillopt-sleep.
explicit = self.data.get("state_dir")
if explicit:
return explicit
ch = self.data.get("claude_home", CLAUDE_HOME)
if os.path.abspath(ch) != os.path.abspath(CLAUDE_HOME):
return os.path.join(os.path.dirname(os.path.abspath(ch)), ".skillopt-sleep")
return HOME_STATE_DIR
@property
def state_path(self) -> str:
return os.path.join(self.state_dir, "state.json")
@property
def transcripts_dir(self) -> str:
return os.path.join(self.data["claude_home"], "projects")
@property
def codex_archived_sessions_dir(self) -> str:
return os.path.join(self.data["codex_home"], "archived_sessions")
@property
def history_path(self) -> str:
return os.path.join(self.data["claude_home"], "history.jsonl")
@property
def skills_dir(self) -> str:
return os.path.join(self.data["claude_home"], "skills")
def managed_skill_path(self) -> str:
target = self.data.get("target_skill_path") or ""
if target:
target = os.path.expanduser(str(target))
if not os.path.isabs(target):
base = self.data.get("invoked_project") or os.getcwd()
target = os.path.join(base, target)
return os.path.abspath(target)
return os.path.join(
self.skills_dir, self.data["managed_skill_name"], "SKILL.md"
)
def _user_config_path() -> Optional[str]:
for name in ("config.json", "config.yaml", "config.yml"):
p = os.path.join(HOME_STATE_DIR, name)
if os.path.exists(p):
return p
return None
def _load_file(path: str) -> Dict[str, Any]:
if path.endswith((".yaml", ".yml")):
try:
import yaml # optional
with open(path) as f:
return yaml.safe_load(f) or {}
except Exception:
return {}
with open(path) as f:
return json.load(f)
def load_config(**overrides: Any) -> SleepConfig:
data = dict(DEFAULTS)
path = _user_config_path()
if path:
try:
data.update(_load_file(path) or {})
except Exception:
pass
data.update({k: v for k, v in overrides.items() if v is not None})
if data.get("projects") == "invoked" and not data.get("invoked_project"):
data["invoked_project"] = os.getcwd()
return SleepConfig(data=data)