diff --git a/plugins/memory/byterover/__init__.py b/plugins/memory/byterover/__init__.py index 82f3a6daf62..5161bacf413 100644 --- a/plugins/memory/byterover/__init__.py +++ b/plugins/memory/byterover/__init__.py @@ -12,6 +12,11 @@ curl -fsSL https://byterover.dev/install.sh | sh). Config via environment variables (profile-scoped via each profile's .env): BRV_API_KEY — ByteRover API key (for cloud features, optional for local) +Config via config.yaml: + memory: + byterover: + auto_extract: false # disable automatic brv curate hooks + Working directory: $HERMES_HOME/byterover/ (profile-scoped context tree) """ @@ -40,6 +45,49 @@ _MIN_QUERY_LEN = 10 _MIN_OUTPUT_LEN = 20 +def _coerce_bool(value: Any, default: bool = False) -> bool: + if isinstance(value, bool): + return value + if value is None: + return default + if isinstance(value, (int, float)): + return bool(value) + if isinstance(value, str): + text = value.strip().lower() + if text in {"1", "true", "yes", "on"}: + return True + if text in {"0", "false", "no", "off"}: + return False + return default + + +def _load_plugin_config() -> Dict[str, Any]: + """Read ByteRover's profile-scoped memory config. + + New memory-provider setup stores non-secret provider settings under + ``memory.``. Some users also set ``memory.provider_config`` from + early docs/issues, so accept it as a compatibility fallback. + """ + try: + from hermes_cli.config import load_config + + config = load_config() + memory_config = config.get("memory", {}) + if not isinstance(memory_config, dict): + return {} + + provider_config = memory_config.get("byterover", {}) + if isinstance(provider_config, dict) and provider_config: + return dict(provider_config) + + legacy_config = memory_config.get("provider_config", {}) + if isinstance(legacy_config, dict): + return dict(legacy_config) + except Exception: + pass + return {} + + # --------------------------------------------------------------------------- # brv binary resolution (cached, thread-safe) # --------------------------------------------------------------------------- @@ -172,7 +220,9 @@ STATUS_SCHEMA = { class ByteRoverMemoryProvider(MemoryProvider): """ByteRover persistent memory via the brv CLI.""" - def __init__(self): + def __init__(self, config: Optional[Dict[str, Any]] = None): + self._config = dict(config) if config is not None else _load_plugin_config() + self._auto_extract = _coerce_bool(self._config.get("auto_extract"), True) self._cwd = "" self._session_id = "" self._turn_count = 0 @@ -195,6 +245,12 @@ class ByteRoverMemoryProvider(MemoryProvider): "env_var": "BRV_API_KEY", "url": "https://app.byterover.dev", }, + { + "key": "auto_extract", + "description": "Automatically curate completed turns and compression/memory hooks", + "default": "true", + "choices": ["true", "false"], + }, ] def initialize(self, session_id: str, **kwargs) -> None: @@ -238,6 +294,9 @@ class ByteRoverMemoryProvider(MemoryProvider): def sync_turn(self, user_content: str, assistant_content: str, *, session_id: str = "") -> None: """Curate the conversation turn in background (non-blocking).""" self._turn_count += 1 + if not self._auto_extract: + logger.debug("ByteRover sync_turn skipped (auto_extract disabled)") + return # Only curate substantive turns if len(user_content.strip()) < _MIN_QUERY_LEN: @@ -264,6 +323,9 @@ class ByteRoverMemoryProvider(MemoryProvider): def on_memory_write(self, action: str, target: str, content: str) -> None: """Mirror built-in memory writes to ByteRover.""" + if not self._auto_extract: + logger.debug("ByteRover memory mirror skipped (auto_extract disabled)") + return if action not in {"add", "replace"} or not content: return @@ -282,6 +344,9 @@ class ByteRoverMemoryProvider(MemoryProvider): def on_pre_compress(self, messages: List[Dict[str, Any]]) -> str: """Extract insights before context compression discards turns.""" + if not self._auto_extract: + logger.debug("ByteRover pre-compression flush skipped (auto_extract disabled)") + return "" if not messages: return "" diff --git a/tests/plugins/memory/test_byterover_provider.py b/tests/plugins/memory/test_byterover_provider.py new file mode 100644 index 00000000000..4e6966008cb --- /dev/null +++ b/tests/plugins/memory/test_byterover_provider.py @@ -0,0 +1,61 @@ +"""Tests for the ByteRover memory provider config gates.""" + +from plugins.memory.byterover import ByteRoverMemoryProvider + + +def test_auto_extract_false_skips_sync_turn(monkeypatch): + calls = [] + provider = ByteRoverMemoryProvider({"auto_extract": False}) + provider.initialize("session-1") + + monkeypatch.setattr("plugins.memory.byterover._run_brv", lambda *args, **kwargs: calls.append((args, kwargs))) + + provider.sync_turn("please remember this detail", "acknowledged") + + assert calls == [] + assert provider._sync_thread is None + + +def test_auto_extract_false_skips_memory_write(monkeypatch): + calls = [] + provider = ByteRoverMemoryProvider({"auto_extract": "false"}) + provider.initialize("session-1") + + monkeypatch.setattr("plugins.memory.byterover._run_brv", lambda *args, **kwargs: calls.append((args, kwargs))) + + provider.on_memory_write("add", "user", "User prefers concise responses") + + assert calls == [] + + +def test_auto_extract_false_skips_pre_compress(monkeypatch): + calls = [] + provider = ByteRoverMemoryProvider({"auto_extract": "off"}) + provider.initialize("session-1") + + monkeypatch.setattr("plugins.memory.byterover._run_brv", lambda *args, **kwargs: calls.append((args, kwargs))) + + result = provider.on_pre_compress([ + {"role": "user", "content": "remember this"}, + {"role": "assistant", "content": "stored"}, + ]) + + assert result == "" + assert calls == [] + + +def test_auto_extract_false_keeps_explicit_curate_tool(monkeypatch): + calls = [] + provider = ByteRoverMemoryProvider({"auto_extract": False}) + provider.initialize("session-1") + + def fake_run(args, **kwargs): + calls.append(args) + return {"success": True, "output": "ok"} + + monkeypatch.setattr("plugins.memory.byterover._run_brv", fake_run) + + result = provider.handle_tool_call("brv_curate", {"content": "Important project fact"}) + + assert "Memory curated successfully" in result + assert calls == [["curate", "--", "Important project fact"]]