Files
SkillOpt/skillopt_sleep/config.py
T
DB Lee 21f93c16c7 Add GitHub Copilot backend to SkillOpt-Sleep
Add CopilotCliBackend that drives the GitHub Copilot CLI in
non-interactive mode (copilot -p ... --output-format json) and parses the
JSONL event stream for assistant.message content. Registered as the
'copilot' backend (with aliases) and wired through the CLI, config,
experiment harness, and the Copilot MCP server's backend enum.

- Force UTF-8 decoding of CLI output (fixes cp1252 UnicodeDecodeError on
  Windows when responses contain non-cp1252 bytes).
- Minimise per-call startup: isolated COPILOT_HOME with built-in MCPs and
  custom instructions disabled, so user MCP servers are not spawned per
  call (~5x faster: 36s -> 7.4s). Override via SKILLOPT_SLEEP_COPILOT_HOME
  / SKILLOPT_SLEEP_COPILOT_MODEL / SKILLOPT_SLEEP_COPILOT_FULL_ENV.

Validated end-to-end on real held-out tasks (researcher persona:
0.42 -> 1.00 lift; gate correctly rejects non-improving edits).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-17 17:25:50 -07:00

153 lines
6.5 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)
# ── 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:
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)