From d4ac550ea3124140d9ffb3d691d4e401a2ba9976 Mon Sep 17 00:00:00 2001 From: Rohith Pariki Date: Thu, 5 Nov 2026 20:10:17 +0530 Subject: [PATCH] feat(sleep): make transcript sources and backends pluggable with entry points and public harvest helpers - Discover external transcript harvesters via the 'skillopt_sleep.sources' entry-point group or in-memory 'register_source()' - Discover external replay/optimizer backends via 'skillopt_sleep.backends' entry-point group or in-memory 'register_backend()' - Add '--source-module pkg.mod:func' and '--backend-module pkg.mod:Class' flags for unpackaged standalone adapters - Expose standard harvest helpers ('is_headless_replay', 'is_agent_session', 'is_meta_prompt', 'detect_feedback', 'redact_secrets') in 'skillopt_sleep.harvest' for external harvesters - Provide informative ValueError messages with all registered sources and backends on unknown input - Add unit test suite in tests/test_sleep_pluggable_registry.py Fixes #314 --- CHANGELOG.md | 8 ++ skillopt_sleep/__main__.py | 14 +- skillopt_sleep/backend.py | 185 +++++++++++++++++++++++-- skillopt_sleep/cycle.py | 5 +- skillopt_sleep/harvest.py | 21 ++- skillopt_sleep/harvest_sources.py | 115 ++++++++++++++- skillopt_sleep/replay.py | 6 +- tests/test_sleep_pluggable_registry.py | 182 ++++++++++++++++++++++++ 8 files changed, 508 insertions(+), 28 deletions(-) create mode 100644 tests/test_sleep_pluggable_registry.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 35205bcca..90526be68 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,14 @@ All notable changes to SkillOpt are documented here. This project adheres to ## [Unreleased] ### Added +- **Pluggable transcript sources, backends, and public harvest helpers** (#314): + support discovering external transcript harvesters via the + `skillopt_sleep.sources` entry-point group or in-memory `register_source()`, + and external replay/optimizer backends via `skillopt_sleep.backends` or + `register_backend()`. Add `--source-module pkg.mod:func` and `--backend-module + pkg.mod:Class` flags for standalone adapters. Expose standard harvest helpers + (`is_headless_replay`, `is_agent_session`, `is_meta_prompt`, `detect_feedback`, + `redact_secrets`) in `skillopt_sleep.harvest` for external harvesters. - **OfficeQA Parallel Search MCP** as an opt-in `env.search_mode=parallel` provider for `openai_chat`/`qwen_chat`. The default stays `offline` and no config selects Parallel automatically. Selecting it sends the generated diff --git a/skillopt_sleep/__main__.py b/skillopt_sleep/__main__.py index fb0be7ea4..b6b5cadc5 100644 --- a/skillopt_sleep/__main__.py +++ b/skillopt_sleep/__main__.py @@ -100,8 +100,9 @@ def _add_common(p: argparse.ArgumentParser) -> None: p.add_argument("--project", default="") p.add_argument("--scope", default="", choices=["", "all", "invoked"]) p.add_argument("--backend", default="", - choices=["", "mock", "claude", "codex", "copilot", "cursor", "pi", - "opencode", "handoff", "azure_openai"]) + help="replay and reflection backend (built-in, registered, or entry point)") + p.add_argument("--backend-module", default="", + help="custom backend class or factory as 'pkg.mod:Class'") p.add_argument("--model", default="") p.add_argument("--codex-path", default="", help="path to the real @openai/codex binary") p.add_argument("--cursor-path", default="", help="path to the Cursor Agent CLI") @@ -117,8 +118,9 @@ def _add_common(p: argparse.ArgumentParser) -> None: p.add_argument("--cursor-home", default="", help="override ~/.cursor for Cursor session harvest") p.add_argument("--pi-home", default="", help="override ~/.pi for Pi session harvest") p.add_argument("--source", default="", - choices=["", "claude", "codex", "copilot", "copilot_cli", "cursor", "pi", "opencode", "auto"], - help="session transcript source") + help="session transcript source (built-in, registered, or entry point)") + p.add_argument("--source-module", default="", + help="custom transcript source as 'pkg.mod:func'") p.add_argument("--vscode-workspace-storage", default="", help="override VS Code User/workspaceStorage root for copilot source") p.add_argument("--copilot-cli-session-store", default="", @@ -160,6 +162,8 @@ def _cfg_from_args(args, task_meta: Dict[str, Any] | None = None) -> Any: overrides["projects"] = args.scope if args.backend: overrides["backend"] = args.backend + if getattr(args, "backend_module", ""): + overrides["backend_module"] = args.backend_module if args.model: overrides["model"] = args.model if getattr(args, "codex_path", ""): @@ -182,6 +186,8 @@ def _cfg_from_args(args, task_meta: Dict[str, Any] | None = None) -> Any: overrides["cursor_home"] = os.path.abspath(os.path.expanduser(args.cursor_home)) if getattr(args, "source", ""): overrides["transcript_source"] = args.source + if getattr(args, "source_module", ""): + overrides["source_module"] = args.source_module if getattr(args, "vscode_workspace_storage", ""): overrides["vscode_workspace_storage"] = os.path.abspath( os.path.expanduser(args.vscode_workspace_storage) diff --git a/skillopt_sleep/backend.py b/skillopt_sleep/backend.py index 429802dbc..67cfcc9aa 100644 --- a/skillopt_sleep/backend.py +++ b/skillopt_sleep/backend.py @@ -2706,6 +2706,70 @@ def _call(self, prompt: str, *, max_tokens: int = 1024, retries: int = 5) -> str return "" +_CUSTOM_BACKENDS: Dict[str, Any] = {} + + +def register_backend(name: str, factory: Any) -> None: + """Register a custom backend in-memory (#314).""" + key = (name or "").strip().lower() + if not key: + raise ValueError("Backend name cannot be blank") + _CUSTOM_BACKENDS[key] = factory + + +def unregister_backend(name: str) -> None: + """Unregister a custom backend.""" + _CUSTOM_BACKENDS.pop((name or "").strip().lower(), None) + + +def load_entry_point_backends() -> Dict[str, Any]: + """Discover backends registered via entry points (#314).""" + try: + from importlib.metadata import entry_points + except ImportError: + return {} + try: + eps = entry_points(group="skillopt_sleep.backends") + except TypeError: + eps = entry_points().get("skillopt_sleep.backends", []) + out: Dict[str, Any] = {} + for ep in eps: + out[ep.name.strip().lower()] = ep.load + return out + + +def get_supported_backends() -> List[str]: + """Return all known backend names: built-ins, in-memory registered, and entry points.""" + builtins = [ + "azure", + "azure-responses", + "claude", + "codex", + "copilot", + "cursor", + "handoff", + "mock", + "opencode", + "pi", + ] + names = set(builtins) + names.update(_CUSTOM_BACKENDS.keys()) + names.update(load_entry_point_backends().keys()) + return sorted(names) + + +def _invoke_backend_factory(factory: Any, **kwargs: Any) -> Backend: + """Instantiate a backend factory or class with compatible kwargs.""" + try: + return factory(**kwargs) + except TypeError: + model = kwargs.get("model", "") + try: + return factory(model=model) + except TypeError: + return factory() + + def get_backend( name: str, *, @@ -2718,7 +2782,44 @@ def get_backend( opencode_tool_replay: bool = False, azure_endpoint: str = "", project_dir: str = "", + backend_module: str = "", + **kwargs: Any, ) -> Backend: + from skillopt_sleep.harvest_sources import load_module_callable + + if backend_module: + factory = load_module_callable(backend_module) + return _invoke_backend_factory( + factory, + model=model, + claude_path=claude_path, + codex_path=codex_path, + pi_path=pi_path, + cursor_path=cursor_path, + opencode_path=opencode_path, + opencode_tool_replay=opencode_tool_replay, + azure_endpoint=azure_endpoint, + project_dir=project_dir, + **kwargs, + ) + + raw_name = name or "mock" + if isinstance(raw_name, str) and ":" in raw_name: + factory = load_module_callable(raw_name) + return _invoke_backend_factory( + factory, + model=model, + claude_path=claude_path, + codex_path=codex_path, + pi_path=pi_path, + cursor_path=cursor_path, + opencode_path=opencode_path, + opencode_tool_replay=opencode_tool_replay, + azure_endpoint=azure_endpoint, + project_dir=project_dir, + **kwargs, + ) + n = (name or "mock").strip().lower() if n in {"pi", "pi_cli", "pi_coding_agent", "pi-coding-agent"}: return PiCliBackend(model=model, pi_path=pi_path or "pi") @@ -2750,19 +2851,55 @@ def get_backend( return HandoffBackend(model=model, handoff_dir=hdir) if n in {"mock", ""}: return MockBackend() + + if n in _CUSTOM_BACKENDS: + return _invoke_backend_factory( + _CUSTOM_BACKENDS[n], + model=model, + claude_path=claude_path, + codex_path=codex_path, + pi_path=pi_path, + cursor_path=cursor_path, + opencode_path=opencode_path, + opencode_tool_replay=opencode_tool_replay, + azure_endpoint=azure_endpoint, + project_dir=project_dir, + **kwargs, + ) + + ep_backends = load_entry_point_backends() + if n in ep_backends: + factory = ep_backends[n]() + return _invoke_backend_factory( + factory, + model=model, + claude_path=claude_path, + codex_path=codex_path, + pi_path=pi_path, + cursor_path=cursor_path, + opencode_path=opencode_path, + opencode_tool_replay=opencode_tool_replay, + azure_endpoint=azure_endpoint, + project_dir=project_dir, + **kwargs, + ) + + supported = ", ".join(repr(s) for s in get_supported_backends()) raise ValueError( - f"Unknown backend: {name!r}. Supported backends are: 'azure', 'azure-responses', " - "'claude', 'codex', 'copilot', 'cursor', 'handoff', 'mock', 'opencode', 'pi'." + f"Unknown backend: {name!r}. Supported backends are: {supported}." ) def build_backend( *, backend: str = "mock", + backend_module: str = "", model: str = "", optimizer_backend: str = "", + optimizer_backend_module: str = "", optimizer_model: str = "", target_backend: str = "", + target_backend_module: str = "", target_model: str = "", codex_path: str = "", pi_path: str = "", @@ -2780,10 +2917,18 @@ def build_backend( from (backend, model). ``preferences`` (free text) is attached so reflect uses it as a prior (set on the optimizer for dual backends). """ - has_split = any([optimizer_backend, optimizer_model, target_backend, target_model]) + has_split = any([ + optimizer_backend, + optimizer_backend_module, + optimizer_model, + target_backend, + target_backend_module, + target_model, + ]) if not has_split: be = get_backend( backend, + backend_module=backend_module, model=model, codex_path=codex_path, pi_path=pi_path, @@ -2795,16 +2940,30 @@ def build_backend( ) be.preferences = preferences return be - tgt = get_backend(target_backend or backend, model=target_model or model, - codex_path=codex_path, pi_path=pi_path, cursor_path=cursor_path, - opencode_path=opencode_path, opencode_tool_replay=opencode_tool_replay, - azure_endpoint=azure_endpoint, - project_dir=project_dir) - opt = get_backend(optimizer_backend or backend, model=optimizer_model or model, - codex_path=codex_path, pi_path=pi_path, cursor_path=cursor_path, - opencode_path=opencode_path, opencode_tool_replay=opencode_tool_replay, - azure_endpoint=azure_endpoint, - project_dir=project_dir) + tgt = get_backend( + target_backend or backend, + backend_module=target_backend_module or backend_module, + model=target_model or model, + codex_path=codex_path, + pi_path=pi_path, + cursor_path=cursor_path, + opencode_path=opencode_path, + opencode_tool_replay=opencode_tool_replay, + azure_endpoint=azure_endpoint, + project_dir=project_dir, + ) + opt = get_backend( + optimizer_backend or backend, + backend_module=optimizer_backend_module or backend_module, + model=optimizer_model or model, + codex_path=codex_path, + pi_path=pi_path, + cursor_path=cursor_path, + opencode_path=opencode_path, + opencode_tool_replay=opencode_tool_replay, + azure_endpoint=azure_endpoint, + project_dir=project_dir, + ) opt.preferences = preferences # reflect runs on the optimizer dual = DualBackend(target=tgt, optimizer=opt) dual.preferences = preferences diff --git a/skillopt_sleep/cycle.py b/skillopt_sleep/cycle.py index d19f98938..9eb80be1c 100644 --- a/skillopt_sleep/cycle.py +++ b/skillopt_sleep/cycle.py @@ -32,7 +32,6 @@ from skillopt_sleep.harvest_sources import harvest_for_config, normalize_transcript_source from skillopt_sleep.memory import ensure_skill_scaffold from skillopt_sleep.mine import group_tasks_by_skill_hint, mine -from skillopt_sleep.replay import aggregate_scores, replay_batch from skillopt_sleep.multi_skill import ( BUDGET_REACHED, SKIPPED, @@ -42,6 +41,7 @@ consolidate_groups, skill_group_reports, ) +from skillopt_sleep.replay import aggregate_scores, replay_batch from skillopt_sleep.skill_resolver import resolve_skill, skill_search_roots from skillopt_sleep.staging import ( SkillProposal, @@ -684,10 +684,13 @@ def run_sleep_cycle( backend = backend or build_backend( backend=cfg.get("backend", "mock"), + backend_module=cfg.get("backend_module", ""), model=cfg.get("model", ""), optimizer_backend=cfg.get("optimizer_backend", ""), + optimizer_backend_module=cfg.get("optimizer_backend_module", ""), optimizer_model=cfg.get("optimizer_model", ""), target_backend=cfg.get("target_backend", ""), + target_backend_module=cfg.get("target_backend_module", ""), target_model=cfg.get("target_model", ""), codex_path=cfg.get("codex_path", ""), pi_path=cfg.get("pi_path", ""), diff --git a/skillopt_sleep/harvest.py b/skillopt_sleep/harvest.py index 61cb70447..7f0358cb7 100644 --- a/skillopt_sleep/harvest.py +++ b/skillopt_sleep/harvest.py @@ -23,7 +23,6 @@ from skillopt_sleep.types import SessionDigest - # Heuristic phrases that signal the user (dis)approving of prior output. # English-only by default. Users whose sessions are in another language can add # their own phrases via the SKILLOPT_SLEEP_NEG_FEEDBACK / _POS_FEEDBACK env vars @@ -689,3 +688,23 @@ def _eligible(d: SessionDigest) -> bool: sessions=len(digests), ) return digests + + +# ── Public helper API for built-in and out-of-tree harvesters (#314) ────────── +from skillopt_sleep.staging import redact_secrets # noqa: E402 + +is_headless_replay = _is_headless_replay +is_agent_session = _is_agent_session +is_meta_prompt = _is_meta_prompt +detect_feedback = _detect_feedback + +__all__ = [ + "harvest", + "format_harvest_note", + "is_headless_replay", + "is_agent_session", + "is_meta_prompt", + "detect_feedback", + "redact_secrets", +] + diff --git a/skillopt_sleep/harvest_sources.py b/skillopt_sleep/harvest_sources.py index d1954ca64..1794ae968 100644 --- a/skillopt_sleep/harvest_sources.py +++ b/skillopt_sleep/harvest_sources.py @@ -1,7 +1,8 @@ """Source selection for SkillOpt-Sleep transcript harvesting.""" from __future__ import annotations -from typing import Optional +import importlib +from typing import Any, Callable, Dict, List, Optional from skillopt_sleep.harvest import harvest from skillopt_sleep.harvest_codex import harvest_codex @@ -23,6 +24,60 @@ "auto", ) +_CUSTOM_SOURCES: Dict[str, Callable] = {} + + +def register_source(name: str, fn: Callable) -> None: + """Register a custom transcript source in-memory (#314).""" + key = (name or "").strip().lower() + if not key: + raise ValueError("Source name cannot be blank") + _CUSTOM_SOURCES[key] = fn + + +def unregister_source(name: str) -> None: + """Unregister a custom transcript source.""" + _CUSTOM_SOURCES.pop((name or "").strip().lower(), None) + + +def load_module_callable(spec: str) -> Any: + """Import and return a callable or class from a 'pkg.mod:attr' spec (#314).""" + spec = (spec or "").strip() + if ":" not in spec: + raise ValueError( + f"Expected format 'module:attribute' (e.g. 'my_pkg.adapters:harvest_func'), got {spec!r}" + ) + mod_name, attr_name = spec.split(":", 1) + mod = importlib.import_module(mod_name.strip()) + attr = getattr(mod, attr_name.strip(), None) + if attr is None: + raise AttributeError(f"Module {mod_name!r} has no attribute {attr_name!r}") + return attr + + +def load_entry_point_sources() -> Dict[str, Any]: + """Discover transcript sources registered via entry points (#314).""" + try: + from importlib.metadata import entry_points + except ImportError: + return {} + try: + eps = entry_points(group="skillopt_sleep.sources") + except TypeError: + eps = entry_points().get("skillopt_sleep.sources", []) + out: Dict[str, Any] = {} + for ep in eps: + out[ep.name.strip().lower()] = ep.load + return out + + +def get_supported_sources() -> List[str]: + """Return all known source names: built-ins, in-memory registered, and entry points.""" + names = set(SUPPORTED_SOURCES) + names.update(_CUSTOM_SOURCES.keys()) + names.update(load_entry_point_sources().keys()) + return sorted(names) + def normalize_transcript_source(value: object) -> str: """Canonical name of a configured ``transcript_source``. @@ -33,22 +88,48 @@ def normalize_transcript_source(value: object) -> str: spelling (``::Codex``) is not the one the harvested source owns, and skips its older sessions (#312). """ - supported = ", ".join(SUPPORTED_SOURCES) if value is None: return "claude" if not isinstance(value, str): + supported = ", ".join(get_supported_sources()) raise ValueError( f"transcript_source must be a string, not {type(value).__name__} " f"{value!r}. Supported sources: {supported}" ) source = (value or "claude").strip().lower() - if source not in SUPPORTED_SOURCES: + known = get_supported_sources() + if source not in known and ":" not in source: raise ValueError( - f"Unknown transcript source '{value}'. Supported sources: {supported}" + f"Unknown transcript source '{value}'. Supported sources: {', '.join(known)}" ) return source +def _invoke_source_fn( + fn: Any, + cfg: Any, + *, + since_iso: Optional[str] = None, + limit: int = 0, + stats: Optional[dict] = None, +) -> list[SessionDigest]: + scope = cfg.get("projects", "invoked") + invoked_project = cfg.get("invoked_project", "") + try: + return fn( + cfg, + since_iso=since_iso, + limit=limit, + scope=scope, + invoked_project=invoked_project, + ) + except TypeError: + try: + return fn(cfg, since_iso=since_iso, limit=limit) + except TypeError: + return fn(cfg) + + def harvest_for_config( cfg, *, @@ -62,10 +143,34 @@ def harvest_for_config( `skillopt_sleep.harvest.harvest`); the other sources have no repeat filter to report yet and leave it untouched. """ - source = normalize_transcript_source(cfg.get("transcript_source", "claude")) + source_mod = cfg.get("source_module") + if source_mod: + fn = load_module_callable(source_mod) + return _invoke_source_fn(fn, cfg, since_iso=since_iso, limit=limit, stats=stats) + + raw_source = cfg.get("transcript_source", "claude") + if isinstance(raw_source, str) and ":" in raw_source: + fn = load_module_callable(raw_source) + return _invoke_source_fn(fn, cfg, since_iso=since_iso, limit=limit, stats=stats) + + source = normalize_transcript_source(raw_source) scope = cfg.get("projects", "invoked") invoked_project = cfg.get("invoked_project", "") + if source in _CUSTOM_SOURCES: + return _invoke_source_fn( + _CUSTOM_SOURCES[source], + cfg, + since_iso=since_iso, + limit=limit, + stats=stats, + ) + + ep_sources = load_entry_point_sources() + if source in ep_sources: + fn = ep_sources[source]() + return _invoke_source_fn(fn, cfg, since_iso=since_iso, limit=limit, stats=stats) + if source == "codex": return harvest_codex( cfg.codex_archived_sessions_dir, diff --git a/skillopt_sleep/replay.py b/skillopt_sleep/replay.py index 6eba0b861..1c19843cb 100644 --- a/skillopt_sleep/replay.py +++ b/skillopt_sleep/replay.py @@ -10,6 +10,8 @@ """ from __future__ import annotations +import os +from concurrent.futures import ThreadPoolExecutor from typing import List, Tuple from skillopt_sleep.backend import Backend @@ -110,10 +112,6 @@ def replay_one(backend: Backend, task: TaskRecord, skill: str, memory: str, ) -import os -from concurrent.futures import ThreadPoolExecutor - - def replay_batch( backend: Backend, tasks: List[TaskRecord], diff --git a/tests/test_sleep_pluggable_registry.py b/tests/test_sleep_pluggable_registry.py new file mode 100644 index 000000000..6841c3d63 --- /dev/null +++ b/tests/test_sleep_pluggable_registry.py @@ -0,0 +1,182 @@ +"""Tests for pluggable sources, backends, and public harvest helpers (#314). + +Pure-stdlib (unittest), deterministic, no API key, no external dependencies. +""" +from __future__ import annotations + +import unittest +from unittest import mock + +from skillopt_sleep.backend import ( + Backend, + get_backend, + get_supported_backends, + register_backend, + unregister_backend, +) +from skillopt_sleep.config import load_config +from skillopt_sleep.harvest import ( + detect_feedback, + is_agent_session, + is_headless_replay, + is_meta_prompt, + redact_secrets, +) +from skillopt_sleep.harvest_sources import ( + get_supported_sources, + harvest_for_config, + normalize_transcript_source, + register_source, + unregister_source, +) +from skillopt_sleep.types import SessionDigest + + +def sample_harvest(cfg, **kwargs) -> list[SessionDigest]: + return [ + SessionDigest( + session_id="sample-1", + project="test-proj", + started_at="2026-11-05T12:00:00Z", + ended_at="2026-11-05T12:05:00Z", + user_prompts=["write a test"], + n_user_turns=1, + ) + ] + + +class SampleCustomBackend(Backend): + name = "sample-custom" + + def attempt(self, task, skill, memory, sample_id=0): + return "sample output" + + def judge(self, task, response): + return 1.0, 1.0, "sample passed" + + def reflect(self, failures, successes, skill, memory, **kwargs): + return [] + + +class TestPluggableSourcesAndBackends(unittest.TestCase): + def tearDown(self): + unregister_source("test_custom_src") + unregister_backend("test_custom_be") + + def test_custom_source_registration_and_harvest(self): + called = [] + + def my_harvester(cfg, **kwargs): + called.append(True) + return [SessionDigest(session_id="custom-sess-1", project="test-proj")] + + register_source("test_custom_src", my_harvester) + self.assertIn("test_custom_src", get_supported_sources()) + self.assertEqual( + normalize_transcript_source("TEST_CUSTOM_SRC"), "test_custom_src" + ) + + cfg = load_config(transcript_source="test_custom_src") + digests = harvest_for_config(cfg) + self.assertTrue(called) + self.assertEqual(len(digests), 1) + self.assertEqual(digests[0].session_id, "custom-sess-1") + + def test_entry_point_source_discovery(self): + ep = mock.MagicMock() + ep.name = "ep_source" + ep.load.return_value = sample_harvest + + with mock.patch("skillopt_sleep.harvest_sources.load_entry_point_sources", return_value={"ep_source": ep.load}): + self.assertIn("ep_source", get_supported_sources()) + self.assertEqual(normalize_transcript_source("ep_source"), "ep_source") + cfg = load_config(transcript_source="ep_source") + digests = harvest_for_config(cfg) + self.assertEqual(len(digests), 1) + self.assertEqual(digests[0].session_id, "sample-1") + + def test_source_module_invocation(self): + cfg = load_config( + source_module="tests.test_sleep_pluggable_registry:sample_harvest" + ) + digests = harvest_for_config(cfg) + self.assertEqual(len(digests), 1) + self.assertEqual(digests[0].session_id, "sample-1") + + def test_unknown_source_raises_informative_error(self): + with self.assertRaises(ValueError) as ctx: + normalize_transcript_source("unknown_xyz") + self.assertIn("Unknown transcript source 'unknown_xyz'", str(ctx.exception)) + self.assertIn("Supported sources:", str(ctx.exception)) + + def test_custom_backend_registration(self): + register_backend("test_custom_be", SampleCustomBackend) + self.assertIn("test_custom_be", get_supported_backends()) + + be = get_backend("test_custom_be") + self.assertIsInstance(be, SampleCustomBackend) + self.assertEqual(be.name, "sample-custom") + + def test_entry_point_backend_discovery(self): + ep = mock.MagicMock() + ep.name = "ep_backend" + ep.load.return_value = lambda **kw: SampleCustomBackend() + + with mock.patch("skillopt_sleep.backend.load_entry_point_backends", return_value={"ep_backend": ep.load}): + self.assertIn("ep_backend", get_supported_backends()) + be = get_backend("ep_backend") + self.assertIsInstance(be, SampleCustomBackend) + + def test_backend_module_invocation(self): + be = get_backend( + "", + backend_module="tests.test_sleep_pluggable_registry:SampleCustomBackend", + ) + self.assertIsInstance(be, SampleCustomBackend) + + def test_unknown_backend_raises_informative_error(self): + with self.assertRaises(ValueError) as ctx: + get_backend("nonexistent_backend") + self.assertIn("Unknown backend: 'nonexistent_backend'", str(ctx.exception)) + self.assertIn("Supported backends are:", str(ctx.exception)) + + def test_public_harvest_helpers(self): + # 1. is_meta_prompt + self.assertTrue(is_meta_prompt("/clear")) + self.assertTrue(is_meta_prompt("...")) + self.assertFalse(is_meta_prompt("Please write a python test")) + + # 2. detect_feedback + self.assertTrue(any("neg:" in f for f in detect_feedback("this is still broken"))) + self.assertTrue(any("pos:" in f for f in detect_feedback("looks good thanks"))) + + # 3. is_agent_session + agent_digest = SessionDigest( + session_id="agent-s1", + project="test-proj", + user_prompts=["You are a Claude-Mem observer"], + ) + user_digest = SessionDigest( + session_id="user-s1", + project="test-proj", + user_prompts=["Help me refactor the database layer"], + ) + self.assertTrue(is_agent_session(agent_digest)) + self.assertFalse(is_agent_session(user_digest)) + + # 4. is_headless_replay + replay_digest = SessionDigest( + session_id="replay-s1", + project="test-proj", + user_prompts=["## SKILL\nRule A\nNow do task"], + n_user_turns=1, + ) + self.assertTrue(is_headless_replay(replay_digest)) + + # 5. redact_secrets + redacted = redact_secrets("sk-ant-api03-abcdef1234567890abcdef1234567890abcdef123456") + self.assertNotIn("1234567890", redacted) + + +if __name__ == "__main__": + unittest.main()