{"repo":"NousResearch/hermes-agent","public_id":"5701","display_label":"ContextEngine ABC design: pluggability, hooks, parameters","summary":"These items cluster around evolving the ContextEngine abstract base class — fixing missing parameters like focus_topic, adding per-turn observation hooks, and enabling pluggable/external context engine implementations. Multiple overlapping fixes and proposals address the same interface gaps from different angles.","subsystem":"agent","counts":{"members":11,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":4,"merged_prs":2,"attempt_prs":5},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":7464},"resolution":{"state":"partially_resolved","by":"pr-7464"},"freshness":{"state":"stale","last_verified_at":"2026-04-24T09:55:43+00:00","is_stale":true},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.1469090909090909},"fix_edges":[{"issue_number":5701,"pr_number":7464,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":11586,"pr_number":15039,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":14886,"pr_number":15039,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false}],"members":[{"kind":"issue","number":5701,"title":"Proposal: Pluggable context engines — enabling LCM as a plugin (like OpenClaw's lossless-claw)","url":null,"state":"closed"},{"kind":"issue","number":11586,"title":"Bug: ContextEngine ABC compress() missing focus_topic parameter causes TypeError","url":null,"state":"closed"},{"kind":"issue","number":23837,"title":"[Feature] ContextEngine: per-turn message observation hook (currently requires abusing compress() as a backdoor)","url":null,"state":"closed"},{"kind":"issue","number":36765,"title":"[RFC] Treat \"context selection/routing\" as a first-class ContextEngine concern, distinct from compression","url":null,"state":"closed"},{"kind":"pr","number":6126,"title":"feat: pluggable context engine slot with config-driven selection","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#7464","number":7464}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":7464,"title":"feat: pluggable context engine slot + unified hermes plugins UI","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["agent/context_compressor.py","agent/context_engine.py","hermes_cli/config.py","hermes_cli/curses_ui.py","hermes_cli/plugins.py","hermes_cli/plugins_cmd.py","plugins/context_engine/__init__.py","run_agent.py","tests/agent/test_context_engine.py","tests/hermes_cli/test_plugins_cmd.py","website/docs/developer-guide/agent-loop.md","website/docs/developer-guide/architecture.md","website/docs/developer-guide/context-compression-and-caching.md","website/docs/developer-guide/context-engine-plugin.md","website/docs/developer-guide/memory-provider-plugin.md","website/docs/guides/build-a-hermes-plugin.md","website/docs/reference/cli-commands.md","website/docs/user-guide/configuration.md","website/docs/user-guide/features/memory-providers.md","website/docs/user-guide/features/overview.md","website/docs/user-guide/features/plugins.md","website/sidebars.ts"],"fix_hunks":[{"file":"agent/context_compressor.py","hunks":["@@ -18,6 +18,7 @@\n from typing import Any, Dict, List, Optional\n \n from agent.auxiliary_client import call_llm\n+from agent.context_engine import ContextEngine\n from agent.model_metadata import (\n     get_model_context_length,\n     estimate_messages_tokens_rough,","@@ -50,8 +51,8 @@\n _SUMMARY_FAILURE_COOLDOWN_SECONDS = 600\n \n \n-class ContextCompressor:\n-    \"\"\"Compresses conversation context when approaching the model's context limit.\n+class ContextCompressor(ContextEngine):\n+    \"\"\"Default context engine — compresses conversation context via lossy summarization.\n \n     Algorithm:\n       1. Prune old tool results (cheap, no LLM call)","@@ -61,6 +62,33 @@ class ContextCompressor:\n       5. On subsequent compactions, iteratively update the previous summary\n     \"\"\"\n \n+    @property\n+    def name(self) -> str:\n+        return \"compressor\"\n+\n+    def on_session_reset(self) -> None:\n+        \"\"\"Reset all per-session state for /new or /reset.\"\"\"\n+        super().on_session_reset()\n+        self._context_probed = False\n+        self._context_probe_persistable = False\n+        self._previous_summary = None\n+\n+    def update_model(\n+        self,\n+        model: str,\n+        context_length: int,\n+        base_url: str = \"\",\n+        api_key: str = \"\",\n+        provider: str = \"\",\n+    ) -> None:\n+        \"\"\"Update model info after a model switch or fallback activation.\"\"\"\n+        self.model = model\n+        self.base_url = base_url\n+        self.api_key = [redacted:secret-assignment]\n+        self.provider = provider\n+        self.context_length = context_length\n+        self.threshold_tokens = [redacted:secret-assignment] * self.threshold_percent)\n+\n     def __init__(\n         self,\n         model: str,"]},{"file":"agent/context_engine.py","hunks":["@@ -0,0 +1,184 @@\n+\"\"\"Abstract base class for pluggable context engines.\n+\n+A context engine controls how conversation context is managed when\n+approaching the model's token limit. The built-in ContextCompressor\n+is the default implementation. Third-party engines (e.g. LCM) can\n+replace it via the plugin system or by being placed in the\n+``plugins/context_engine/<name>/`` directory.\n+\n+Selection is config-driven: ``context.engine`` in config.yaml.\n+Default is ``\"compressor\"`` (the built-in). Only one engine is active.\n+\n+The engine is responsible for:\n+  - Deciding when compaction should fire\n+  - Performing compaction (summarization, DAG construction, etc.)\n+  - Optionally exposing tools the agent can call (e.g. lcm_grep)\n+  - Tracking token usage from API responses\n+\n+Lifecycle:\n+  1. Engine is instantiated and registered (plugin register() or default)\n+  2. on_session_start() called when a conversation begins\n+  3. update_from_response() called after each API response with usage data\n+  4. should_compress() checked after each turn\n+  5. compress() called when should_compress() returns True\n+  6. on_session_end() called at real session boundaries (CLI exit, /reset,\n+     gateway session expiry) — NOT per-turn\n+\"\"\"\n+\n+from abc import ABC, abstractmethod\n+from typing import Any, Dict, List, Optional\n+\n+\n+class ContextEngine(ABC):\n+    \"\"\"Base class all context engines must implement.\"\"\"\n+\n+    # -- Identity ----------------------------------------------------------\n+\n+    @property\n+    @abstractmethod\n+    def name(self) -> str:\n+        \"\"\"Short identifier (e.g. 'compressor', 'lcm').\"\"\"\n+\n+    # -- Token state (read by run_agent.py for display/logging) ------------\n+    #\n+    # Engines MUST maintain these. run_agent.py reads them directly.\n+\n+    last_prompt_tokens: int = 0\n+    last_completion_tokens: int = 0\n+    last_total_tokens: int = 0\n+    threshold_tokens: int = 0\n+    context_length: int = 0\n+    compression_count: int = 0\n+\n+    # -- Compaction parameters (read by run_agent.py for preflight) --------\n+    #\n+    # These control the preflight compression check.  Subclasses may\n+    # override via __init__ or property; defaults are sensible for most\n+    # engines.\n+\n+    threshold_percent: float = 0.75\n+    protect_first_n: int = 3\n+    protect_last_n: int = 6\n+\n+    # -- Core interface ----------------------------------------------------\n+\n+    @abstractmethod\n+    def update_from_response(self, usage: Dict[str, Any]) -> None:\n+        \"\"\"Update tracked token usage from an API response.\n+\n+        Called after every LLM call with the usage dict from the response.\n+        \"\"\"\n+\n+    @abstractmethod\n+    def should_compress(self, prompt_tokens: int = None) -> bool:\n+        \"\"\"Return True if compaction should fire this turn.\"\"\"\n+\n+    @abstractmethod\n+    def compress(\n+        self,\n+        messages: List[Dict[str, Any]],\n+        current_tokens: int = None,\n+    ) -> List[Dict[str, Any]]:\n+        \"\"\"Compact the message list and return the new message list.\n+\n+        This is the main entry point. The engine receives the full message\n+        list and returns a (possibly shorter) list that fits within the\n+        context budget. The implementation is free to summarize, build a\n+        DAG, or do anything else — as long as the returned list is a valid\n+        OpenAI-format message sequence.\n+        \"\"\"\n+\n+    # -- Optional: pre-flight check ----------------------------------------\n+\n+    def should_compress_preflight(self, messages: List[Dict[str, Any]]) -> bool:\n+        \"\"\"Quick rough check before the API call (no real token count yet).\n+\n+        Default returns False (skip pre-flight). Override if your engine\n+        can do a cheap estimate.\n+        \"\"\"\n+        return False\n+\n+    # -- Optional: session lifecycle ---------------------------------------\n+\n+    def on_session_start(self, session_id: str, **kwargs) -> None:\n+        \"\"\"Called when a new conversation session begins.\n+\n+        Use this to load persisted state (DAG, store) for the session.\n+        kwargs may include hermes_home, platform, model, etc.\n+        \"\"\"\n+\n+    def on_session_end(self, session_id: str, messages: List[Dict[str, Any]]) -> None:\n+        \"\"\"Called at real session boundaries (CLI exit, /reset, gateway expiry).\n+\n+        Use this to flush state, close DB connections, etc.\n+        NOT called per-turn — only when the session truly ends.\n+        \"\"\"\n+\n+    def on_session_reset(self) -> None:\n+        \"\"\"Called on /new or /reset. Reset per-session state.\n+\n+        Default resets compression_count and token tracking.\n+        \"\"\"\n+        self.last_prompt_tokens = 0\n+        self.last_completion_tokens = 0\n+        self.last_total_tokens = 0\n+        self.compression_count = 0\n+\n+    # -- Optional: tools ---------------------------------------------------\n+\n+    def get_tool_schemas(self) -> List[Dict[str, Any]]:\n+        \"\"\"Return tool schemas this engine provides to the agent.\n+\n+        Default returns empty list (no tools). LCM would return schemas\n+        for lcm_grep, lcm_describe, lcm_expand here.\n+        \"\"\"\n+        return []\n+\n+    def handle_tool_call(self, name: str, args: Dict[str, Any], **kwargs) -> str:\n+        \"\"\"Handle a tool call from the agent.\n+\n+        Only called for tool names returned by get_tool_schemas().\n+        Must return a JSON string.\n+\n+        kwargs may include:\n+          messages: the current in-memory message list (for live ingestion)\n+        \"\"\"\n+        import json\n+        return json.dumps({\"error\": f\"Unknown context engine tool: {name}\"})\n+\n+    # -- Optional: status / display ----------------------------------------\n+\n+    def get_status(self) -> Dict[str, Any]:\n+        \"\"\"Return status dict for display/logging.\n+\n+        Default returns the standard fields run_agent.py expects.\n+        \"\"\"\n+        return {\n+            \"last_prompt_tokens\": self.last_prompt_tokens,\n+            \"threshold_tokens\": self.threshold_tokens,\n+            \"context_length\": self.context_length,\n+            \"usage_percent\": (\n+                min(100, self.last_prompt_tokens / self.context_length * 100)\n+                if self.context_length else 0\n+            ),\n+            \"compression_count\": self.compression_count,\n+        }\n+\n+    # -- Optional: model switch support ------------------------------------\n+\n+    def update_model(\n+        self,\n+        model: str,\n+        context_length: int,\n+        base_url: str = \"\",\n+        api_key: str = \"\",\n+        provider: str = \"\",\n+    ) -> None:\n+        \"\"\"Called when the user switches models or on fallback activation.\n+\n+        Default updates context_length and recalculates threshold_tokens\n+        from threshold_percent. Override if your engine needs more\n+        (e.g. recalculate DAG budgets, switch summary models).\n+        \"\"\"\n+        self.context_length = context_length\n+        self.threshold_tokens = [redacted:secret-assignment] * self.threshold_percent)"]},{"file":"hermes_cli/config.py","hunks":["@@ -504,6 +504,16 @@ def _ensure_hermes_home_managed(home: Path):\n         \"max_ms\": 2500,\n     },\n     \n+    # Context engine -- controls how the context window is managed when\n+    # approaching the model's token limit.\n+    # \"compressor\" = built-in lossy summarization (default).\n+    # Set to a plugin name to activate an alternative engine (e.g. \"lcm\"\n+    # for Lossless Context Management).  The engine must be installed as\n+    # a plugin in plugins/context_engine/<name>/ or ~/.hermes/plugins/.\n+    \"context\": {\n+        \"engine\": \"compressor\",\n+    },\n+\n     # Persistent memory -- bounded curated memory injected into system prompt\n     \"memory\": {\n         \"memory_enabled\": True,","@@ -1450,7 +1460,7 @@ def check_config_version() -> Tuple[int, int]:\n     \"_config_version\", \"model\", \"providers\", \"fallback_model\",\n     \"fallback_providers\", \"credential_pool_strategies\", \"toolsets\",\n     \"agent\", \"terminal\", \"display\", \"compression\", \"delegation\",\n-    \"auxiliary\", \"custom_providers\", \"memory\", \"gateway\",\n+    \"auxiliary\", \"custom_providers\", \"context\", \"memory\", \"gateway\",\n }\n \n # Valid fields inside a custom_providers list entry"]},{"file":"hermes_cli/curses_ui.py","hunks":["@@ -160,6 +160,133 @@ def _draw(stdscr):\n         return _numbered_fallback(title, items, selected, cancel_returns, status_fn)\n \n \n+def curses_radiolist(\n+    title: str,\n+    items: List[str],\n+    selected: int = 0,\n+    *,\n+    cancel_returns: int | None = None,\n+) -> int:\n+    \"\"\"Curses single-select radio list. Returns the selected index.\n+\n+    Args:\n+        title: Header line displayed above the list.\n+        items: Display labels for each row.\n+        selected: Index that starts selected (pre-selected).\n+        cancel_returns: Returned on ESC/q. Defaults to the original *selected*.\n+    \"\"\"\n+    if cancel_returns is None:\n+        cancel_returns = selected\n+\n+    if not sys.stdin.isatty():\n+        return cancel_returns\n+\n+    try:\n+        import curses\n+        result_holder: list = [None]\n+\n+        def _draw(stdscr):\n+            curses.curs_set(0)\n+            if curses.has_colors():\n+                curses.start_color()\n+                curses.use_default_colors()\n+                curses.init_pair(1, curses.COLOR_GREEN, -1)\n+                curses.init_pair(2, curses.COLOR_YELLOW, -1)\n+            cursor = selected\n+            scroll_offset = 0\n+\n+            while True:\n+                stdscr.clear()\n+                max_y, max_x = stdscr.getmaxyx()\n+\n+                # Header\n+                try:\n+                    hattr = curses.A_BOLD\n+                    if curses.has_colors():\n+                        hattr |= curses.color_pair(2)\n+                    stdscr.addnstr(0, 0, title, max_x - 1, hattr)\n+                    stdscr.addnstr(\n+                        1, 0,\n+                        \"  \\u2191\\u2193 navigate  ENTER/SPACE select  ESC cancel\",\n+                        max_x - 1, curses.A_DIM,\n+                    )\n+                except curses.error:\n+                    pass\n+\n+                # Scrollable item list\n+                visible_rows = max_y - 4\n+                if cursor < scroll_offset:\n+                    scroll_offset = cursor\n+                elif cursor >= scroll_offset + visible_rows:\n+                    scroll_offset = cursor - visible_rows + 1\n+\n+                for draw_i, i in enumerate(\n+                    range(scroll_offset, min(len(items), scroll_offset + visible_rows))\n+                ):\n+                    y = draw_i + 3\n+                    if y >= max_y - 1:\n+                        break\n+                    radio = \"\\u25cf\" if i == selected else \"\\u25cb\"\n+                    arrow = \"\\u2192\" if i == cursor else \" \"\n+                    line = f\" {arrow} ({radio}) {items[i]}\"\n+                    attr = curses.A_NORMAL\n+                    if i == cursor:\n+                        attr = curses.A_BOLD\n+                        if curses.has_colors():\n+                            attr |= curses.color_pair(1)\n+                    try:\n+                        stdscr.addnstr(y, 0, line, max_x - 1, attr)\n+                    except curses.error:\n+                        pass\n+\n+                stdscr.refresh()\n+                key = stdscr.getch()\n+\n+                if key in (curses.KEY_UP, ord(\"k\")):\n+                    cursor = (cursor - 1) % len(items)\n+                elif key in (curses.KEY_DOWN, ord(\"j\")):\n+                    cursor = (cursor + 1) % len(items)\n+                elif key in (ord(\" \"), curses.KEY_ENTER, 10, 13):\n+                    result_holder[0] = cursor\n+                    return\n+                elif key in (27, ord(\"q\")):\n+                    result_holder[0] = cancel_returns\n+                    return\n+\n+        curses.wrapper(_draw)\n+        flush_stdin()\n+        return result_holder[0] if result_holder[0] is not None else cancel_returns\n+\n+    except Exception:\n+        return _radio_numbered_fallback(title, items, selected, cancel_returns)\n+\n+\n+def _radio_numbered_fallback(\n+    title: str,\n+    items: List[str],\n+    selected: int,\n+    cancel_returns: int,\n+) -> int:\n+    \"\"\"Text-based numbered fallback for radio selection.\"\"\"\n+    print(color(f\"\\n  {title}\", Colors.YELLOW))\n+    print(color(\"  Select by number, Enter to confirm.\\n\", Colors.DIM))\n+\n+    for i, label in enumerate(items):\n+        marker = color(\"(\\u25cf)\", Colors.GREEN) if i == selected else \"(\\u25cb)\"\n+        print(f\"  {marker} {i + 1:>2}. {label}\")\n+    print()\n+    try:\n+        val = input(color(f\"  Choice [default {selected + 1}]: \", Colors.DIM)).strip()\n+        if not val:\n+            return selected\n+        idx = int(val) - 1\n+        if 0 <= idx < len(items):\n+            return idx\n+        return selected\n+    except (ValueError, KeyboardInterrupt, EOFError):\n+        return cancel_returns\n+\n+\n def _numbered_fallback(\n     title: str,\n     items: List[str],"]},{"file":"hermes_cli/plugins.py","hunks":["@@ -201,8 +201,7 @@ def register_cli_command(\n \n         The *setup_fn* receives an argparse subparser and should add any\n         arguments/sub-subparsers.  If *handler_fn* is provided it is set\n-        as the default dispatch function via ``set_defaults(func=...)``.\n-        \"\"\"\n+        as the default dispatch function via ``set_defaults(func=...)``.\"\"\"\n         self._manager._cli_commands[name] = {\n             \"name\": name,\n             \"help\": help,","@@ -213,6 +212,38 @@ def register_cli_command(\n         }\n         logger.debug(\"Plugin %s registered CLI command: %s\", self.manifest.name, name)\n \n+    # -- context engine registration -----------------------------------------\n+\n+    def register_context_engine(self, engine) -> None:\n+        \"\"\"Register a context engine to replace the built-in ContextCompressor.\n+\n+        Only one context engine plugin is allowed. If a second plugin tries\n+        to register one, it is rejected with a warning.\n+\n+        The engine must be an instance of ``agent.context_engine.ContextEngine``.\n+        \"\"\"\n+        if self._manager._context_engine is not None:\n+            logger.warning(\n+                \"Plugin '%s' tried to register a context engine, but one is \"\n+                \"already registered. Only one context engine plugin is allowed.\",\n+                self.manifest.name,\n+            )\n+            return\n+        # Defer the import to avoid circular deps at module level\n+        from agent.context_engine import ContextEngine\n+        if not isinstance(engine, ContextEngine):\n+            logger.warning(\n+                \"Plugin '%s' tried to register a context engine that does not \"\n+                \"inherit from ContextEngine. Ignoring.\",\n+                self.manifest.name,\n+            )\n+            return\n+        self._manager._context_engine = engine\n+        logger.info(\n+            \"Plugin '%s' registered context engine: %s\",\n+            self.manifest.name, engine.name,\n+        )\n+\n     # -- hook registration --------------------------------------------------\n \n     def register_hook(self, hook_name: str, callback: Callable) -> None:","@@ -245,6 +276,7 @@ def __init__(self) -> None:\n         self._hooks: Dict[str, List[Callable]] = {}\n         self._plugin_tool_names: Set[str] = set()\n         self._cli_commands: Dict[str, dict] = {}\n+        self._context_engine = None  # Set by a plugin via register_context_engine()\n         self._discovered: bool = False\n         self._cli_ref = None  # Set by CLI after plugin discovery\n ","@@ -566,6 +598,11 @@ def get_plugin_cli_commands() -> Dict[str, dict]:\n     return dict(get_plugin_manager()._cli_commands)\n \n \n+def get_plugin_context_engine():\n+    \"\"\"Return the plugin-registered context engine, or None.\"\"\"\n+    return get_plugin_manager()._context_engine\n+\n+\n def get_plugin_toolsets() -> List[tuple]:\n     \"\"\"Return plugin toolsets as ``(key, label, description)`` tuples.\n "]},{"file":"hermes_cli/plugins_cmd.py","hunks":["@@ -531,7 +531,7 @@ def cmd_disable(name: str) -> None:\n \n     disabled.add(name)\n     _save_disabled_set(disabled)\n-    console.print(f\"[yellow]⊘[/yellow] Plugin [bold]{name}[/bold] disabled. Takes effect on next session.\")\n+    console.print(f\"[yellow]\\u2298[/yellow] Plugin [bold]{name}[/bold] disabled. Takes effect on next session.\")\n \n \n def cmd_list() -> None:","@@ -594,8 +594,152 @@ def cmd_list() -> None:\n     console.print(\"[dim]Enable/disable:[/dim] hermes plugins enable/disable <name>\")\n \n \n+# ---------------------------------------------------------------------------\n+# Provider plugin discovery helpers\n+# ---------------------------------------------------------------------------\n+\n+\n+def _discover_memory_providers() -> list[tuple[str, str]]:\n+    \"\"\"Return [(name, description), ...] for available memory providers.\"\"\"\n+    try:\n+        from plugins.memory import discover_memory_providers\n+        return [(name, desc) for name, desc, _avail in discover_memory_providers()]\n+    except Exception:\n+        return []\n+\n+\n+def _discover_context_engines() -> list[tuple[str, str]]:\n+    \"\"\"Return [(name, description), ...] for available context engines.\"\"\"\n+    try:\n+        from plugins.context_engine import discover_context_engines\n+        return [(name, desc) for name, desc, _avail in discover_context_engines()]\n+    except Exception:\n+        return []\n+\n+\n+def _get_current_memory_provider() -> str:\n+    \"\"\"Return the current memory.provider from config (empty = built-in).\"\"\"\n+    try:\n+        from hermes_cli.config import load_config\n+        config = load_config()\n+        return config.get(\"memory\", {}).get(\"provider\", \"\") or \"\"\n+    except Exception:\n+        return \"\"\n+\n+\n+def _get_current_context_engine() -> str:\n+    \"\"\"Return the current context.engine from config.\"\"\"\n+    try:\n+        from hermes_cli.config import load_config\n+        config = load_config()\n+        return config.get(\"context\", {}).get(\"engine\", \"compressor\") or \"compressor\"\n+    except Exception:\n+        return \"compressor\"\n+\n+\n+def _save_memory_provider(name: str) -> None:\n+    \"\"\"Persist memory.provider to config.yaml.\"\"\"\n+    from hermes_cli.config import load_config, save_config\n+    config = load_config()\n+    if \"memory\" not in config:\n+        config[\"memory\"] = {}\n+    config[\"memory\"][\"provider\"] = name\n+    save_config(config)\n+\n+\n+def _save_context_engine(name: str) -> None:\n+    \"\"\"Persist context.engine to config.yaml.\"\"\"\n+    from hermes_cli.config import load_config, save_config\n+    config = load_config()\n+    if \"context\" not in config:\n+        config[\"context\"] = {}\n+    config[\"context\"][\"engine\"] = name\n+    save_config(config)\n+\n+\n+def _configure_memory_provider() -> bool:\n+    \"\"\"Launch a radio picker for memory providers. Returns True if changed.\"\"\"\n+    from hermes_cli.curses_ui import curses_radiolist\n+\n+    current = _get_current_memory_provider()\n+    providers = _discover_memory_providers()\n+\n+    # Build items: \"built-in\" first, then discovered providers\n+    items = [\"built-in (default)\"]\n+    names = [\"\"]  # empty string = built-in\n+    selected = 0\n+\n+    for name, desc in providers:\n+        names.append(name)\n+        label = f\"{name} \\u2014 {desc}\" if desc else name\n+        items.append(label)\n+        if name == current:\n+            selected = len(items) - 1\n+\n+    # If current provider isn't in discovered list, add it\n+    if current and current not in names:\n+        names.append(current)\n+        items.append(f\"{current} (not found)\")\n+        selected = len(items) - 1\n+\n+    choice = curses_radiolist(\n+        title=\"Memory Provider (select one)\",\n+        items=items,\n+        selected=selected,\n+    )\n+\n+    new_provider = names[choice]\n+    if new_provider != current:\n+        _save_memory_provider(new_provider)\n+        return True\n+    return False\n+\n+\n+def _configure_context_engine() -> bool:\n+    \"\"\"Launch a radio picker for context engines. Returns True if changed.\"\"\"\n+    from hermes_cli.curses_ui import curses_radiolist\n+\n+    current = _get_current_context_engine()\n+    engines = _discover_context_engines()\n+\n+    # Build items: \"compressor\" first (built-in), then discovered engines\n+    items = [\"compressor (default)\"]\n+    names = [\"compressor\"]\n+    selected = 0\n+\n+    for name, desc in engines:\n+        names.append(name)\n+        label = f\"{name} \\u2014 {desc}\" if desc else name\n+        items.append(label)\n+        if name == current:\n+            selected = len(items) - 1\n+\n+    # If current engine isn't in discovered list and isn't compressor, add it\n+    if current != \"compressor\" and current not in names:\n+        names.append(current)\n+        items.append(f\"{current} (not found)\")\n+        selected = len(items) - 1\n+\n+    choice = curses_radiolist(\n+        title=\"Context Engine (select one)\",\n+        items=items,\n+        selected=selected,\n+    )\n+\n+    new_engine = names[choice]\n+    if new_engine != current:\n+        _save_context_engine(new_engine)\n+        return True\n+    return False\n+\n+\n+# ---------------------------------------------------------------------------\n+# Composite plugins UI\n+# ---------------------------------------------------------------------------\n+\n+\n def cmd_toggle() -> None:\n-    \"\"\"Interactive curses checklist to enable/disable installed plugins.\"\"\"\n+    \"\"\"Interactive composite UI — general plugins + provider plugin categories.\"\"\"\n     from rich.console import Console\n \n     try:","@@ -606,18 +750,13 @@ def cmd_toggle() -> None:\n     console = Console()\n     plugins_dir = _plugins_dir()\n \n+    # -- General plugins discovery --\n     dirs = sorted(d for d in plugins_dir.iterdir() if d.is_dir())\n-    if not dirs:\n-        console.print(\"[dim]No plugins installed.[/dim]\")\n-        console.print(\"[dim]Install with:[/dim] hermes plugins install owner/repo\")\n-        return\n-\n     disabled = _get_disabled_set()\n \n-    # Build items list: \"name — description\" for display\n-    names = []\n-    labels = []\n-    selected = set()\n+    plugin_names = []\n+    plugin_labels = []\n+    plugin_selected = set()\n \n     for i, d in enumerate(dirs):\n         manifest_file = d / \"plugin.yaml\"","@@ -633,36 +772,335 @@ def cmd_toggle() -> None:\n             except Exception:\n                 pass\n \n-        names.append(name)\n-        label = f\"{name} — {description}\" if description else name\n-        labels.append(label)\n+        plugin_names.append(name)\n+        label = f\"{name} \\u2014 {description}\" if description else name\n+        plugin_labels.append(label)\n \n         if name not in disabled and d.name not in disabled:\n-            selected.add(i)\n+            plugin_selected.add(i)\n \n-    from hermes_cli.curses_ui import curses_checklist\n+    # -- Provider categories --\n+    current_memory = _get_current_memory_provider() or \"built-in\"\n+    current_context = _get_current_context_engine()\n+    categories = [\n+        (\"Memory Provider\", current_memory, _configure_memory_provider),\n+        (\"Context Engine\", current_context, _configure_context_engine),\n+    ]\n \n-    result = curses_checklist(\n-        title=\"Plugins — toggle enabled/disabled\",\n-        items=labels,\n-        selected=selected,\n-    )\n+    has_plugins = bool(plugin_names)\n+    has_categories = bool(categories)\n+\n+    if not has_plugins and not has_categories:\n+        console.print(\"[dim]No plugins installed and no provider categories available.[/dim]\")\n+        console.print(\"[dim]Install with:[/dim] hermes plugins install owner/repo\")\n+        return\n+\n+    # Non-TTY fallback\n+    if not sys.stdin.isatty():\n+        console.print(\"[dim]Interactive mode requires a terminal.[/dim]\")\n+        return\n \n-    # Compute new disabled set from deselected items\n+    # Launch the composite curses UI\n+    try:\n+        import curses\n+        _run_composite_ui(curses, plugin_names, plugin_labels, plugin_selected,\n+                          disabled, categories, console)\n+    except ImportError:\n+        _run_composite_fallback(plugin_names, plugin_labels, plugin_selected,\n+                                disabled, categories, console)\n+\n+\n+def _run_composite_ui(curses, plugin_names, plugin_labels, plugin_selected,\n+                      disabled, categories, console):\n+    \"\"\"Custom curses screen with checkboxes + category action rows.\"\"\"\n+    from hermes_cli.curses_ui import flush_stdin\n+\n+    chosen = set(plugin_selected)\n+    n_plugins = len(plugin_names)\n+    # Total rows: plugins + separator + categories\n+    # separator is not navigable\n+    n_categories = len(categories)\n+    total_items = n_plugins + n_categories  # navigable items\n+\n+    result_holder = {\"plugins_changed\": False, \"providers_changed\": False}\n+\n+    def _draw(stdscr):\n+        curses.curs_set(0)\n+        if curses.has_colors():\n+            curses.start_color()\n+            curses.use_default_colors()\n+            curses.init_pair(1, curses.COLOR_GREEN, -1)\n+            curses.init_pair(2, curses.COLOR_YELLOW, -1)\n+            curses.init_pair(3, curses.COLOR_CYAN, -1)\n+            curses.init_pair(4, 8, -1)  # dim gray\n+        cursor = 0\n+        scroll_offset = 0\n+\n+        while True:\n+            stdscr.clear()\n+            max_y, max_x = stdscr.getmaxyx()\n+\n+            # Header\n+            try:\n+                hattr = curses.A_BOLD\n+                if curses.has_colors():\n+                    hattr |= curses.color_pair(2)\n+                stdscr.addnstr(0, 0, \"Plugins\", max_x - 1, hattr)\n+                stdscr.addnstr(\n+                    1, 0,\n+                    \"  \\u2191\\u2193 navigate  SPACE toggle  ENTER configure/confirm  ESC done\",\n+                    max_x - 1, curses.A_DIM,\n+                )\n+            except curses.error:\n+                pass\n+\n+            # Build display rows\n+            # Row layout:\n+            #   [plugins section header] (not navigable, skipped in scroll math)\n+            #   plugin checkboxes (navigable, indices 0..n_plugins-1)\n+            #   [separator] (not navigable)\n+            #   [categories section header] (not navigable)\n+            #   category action rows (navigable, indices n_plugins..total_items-1)\n+\n+            visible_rows = max_y - 4\n+            if cursor < scroll_offset:\n+                scroll_offset = cursor\n+            elif cursor >= scroll_offset + visible_rows:\n+                scroll_offset = cursor - visible_rows + 1\n+\n+            y = 3  # start drawing after header\n+\n+            # Determine which items are visible based on scroll\n+            # We need to map logical cursor positions to screen rows\n+            # accounting for non-navigable separator/headers\n+\n+            draw_row = 0  # tracks navigable item index\n+\n+            # --- General Plugins section ---\n+            if n_plugins > 0:\n+                # Section header\n+                if y < max_y - 1:\n+                    try:\n+                        sattr = curses.A_BOLD\n+                        if curses.has_colors():\n+                            sattr |= curses.color_pair(2)\n+                        stdscr.addnstr(y, 0, \"  General Plugins\", max_x - 1, sattr)\n+                    except curses.error:\n+                        pass\n+                    y += 1\n+\n+                for i in range(n_plugins):\n+                    if y >= max_y - 1:\n+                        break\n+                    check = \"\\u2713\" if i in chosen else \" \"\n+                    arrow = \"\\u2192\" if i == cursor else \" \"\n+                    line = f\" {arrow} [{check}] {plugin_labels[i]}\"\n+                    attr = curses.A_NORMAL\n+                    if i == cursor:\n+                        attr = curses.A_BOLD\n+                        if curses.has_colors():\n+                            attr |= curses.color_pair(1)\n+                    try:\n+                        stdscr.addnstr(y, 0, line, max_x - 1, attr)\n+                    except curses.error:\n+                        pass\n+                    y += 1\n+\n+            # --- Separator ---\n+            if y < max_y - 1:\n+                y += 1  # blank line\n+\n+            # --- Provider Plugins section ---\n+            if n_categories > 0 and y < max_y - 1:\n+                try:\n+                    sattr = curses.A_BOLD\n+                    if curses.has_colors():\n+                        sattr |= curses.color_pair(2)\n+                    stdscr.addnstr(y, 0, \"  Provider Plugins\", max_x - 1, sattr)\n+                except curses.error:\n+                    pass\n+                y += 1\n+\n+                for ci, (cat_name, cat_current, _cat_fn) in enumerate(categories):\n+                    if y >= max_y - 1:\n+                        break\n+                    cat_idx = n_plugins + ci\n+                    arrow = \"\\u2192\" if cat_idx == cursor else \" \"\n+                    line = f\" {arrow}   {cat_name:<24} \\u25b8 {cat_current}\"\n+                    attr = curses.A_NORMAL\n+                    if cat_idx == cursor:\n+                        attr = curses.A_BOLD\n+                        if curses.has_colors():\n+                            attr |= curses.color_pair(3)\n+                    try:\n+                        stdscr.addnstr(y, 0, line, max_x - 1, attr)\n+                    except curses.error:\n+                        pass\n+                    y += 1\n+\n+            stdscr.refresh()\n+            key = stdscr.getch()\n+\n+            if key in (curses.KEY_UP, ord(\"k\")):\n+                if total_items > 0:\n+                    cursor = (cursor - 1) % total_items\n+            elif key in (curses.KEY_DOWN, ord(\"j\")):\n+                if total_items > 0:\n+                    cursor = (cursor + 1) % total_items\n+            elif key == ord(\" \"):\n+                if cursor < n_plugins:\n+                    # Toggle general plugin\n+                    chosen.symmetric_difference_update({cursor})\n+                else:\n+                    # Provider category — launch sub-screen\n+                    ci = cursor - n_plugins\n+                    if 0 <= ci < n_categories:\n+                        curses.endwin()\n+                        _cat_name, _cat_cur, cat_fn = categories[ci]\n+                        changed = cat_fn()\n+                        if changed:\n+                            result_holder[\"providers_changed\"] = True\n+                            # Refresh current values\n+                            categories[ci] = (\n+                                _cat_name,\n+                                _get_current_memory_provider() or \"built-in\" if ci == 0\n+                                else _get_current_context_engine(),\n+                                cat_fn,\n+                            )\n+                        # Re-enter curses\n+                        stdscr = curses.initscr()\n+                        curses.noecho()\n+                        curses.cbreak()\n+                        stdscr.keypad(True)\n+                        if curses.has_colors():\n+                            curses.start_color()\n+                            curses.use_default_colors()\n+                            curses.init_pair(1, curses.COLOR_GREEN, -1)\n+                            curses.init_pair(2, curses.COLOR_YELLOW, -1)\n+                            curses.init_pair(3, curses.COLOR_CYAN, -1)\n+                            curses.init_pair(4, 8, -1)\n+                        curses.curs_set(0)\n+            elif key in (curses.KEY_ENTER, 10, 13):\n+                if cursor < n_plugins:\n+                    # ENTER on a plugin checkbox — confirm and exit\n+                    result_holder[\"plugins_changed\"] = True\n+                    return\n+                else:\n+                    # ENTER on a category — same as SPACE, launch sub-screen\n+                    ci = cursor - n_plugins\n+                    if 0 <= ci < n_categories:\n+                        curses.endwin()\n+                        _cat_name, _cat_cur, cat_fn = categories[ci]\n+                        changed = cat_fn()\n+                        if changed:\n+                            result_holder[\"providers_changed\"] = True\n+                            categories[ci] = (\n+                                _cat_name,\n+                                _get_current_memory_provider() or \"built-in\" if ci == 0\n+                                else _get_current_context_engine(),\n+                                cat_fn,\n+                            )\n+                        stdscr = curses.initscr()\n+                        curses.noecho()\n+                        curses.cbreak()\n+                        stdscr.keypad(True)\n+                        if curses.has_colors():\n+                            curses.start_color()\n+                            curses.use_default_colors()\n+                            curses.init_pair(1, curses.COLOR_GREEN, -1)\n+                            curses.init_pair(2, curses.COLOR_YELLOW, -1)\n+                            curses.init_pair(3, curses.COLOR_CYAN, -1)\n+                            curses.init_pair(4, 8, -1)\n+                        curses.curs_set(0)\n+            elif key in (27, ord(\"q\")):\n+                # Save plugin changes on exit\n+                result_holder[\"plugins_changed\"] = True\n+                return\n+\n+    curses.wrapper(_draw)\n+    flush_stdin()\n+\n+    # Persist general plugin changes\n     new_disabled = set()\n-    for i, name in enumerate(names):\n-        if i not in result:\n+    for i, name in enumerate(plugin_names):\n+        if i not in chosen:\n             new_disabled.add(name)\n \n     if new_disabled != disabled:\n         _save_disabled_set(new_disabled)\n-        enabled_count = len(names) - len(new_disabled)\n+        enabled_count = len(plugin_names) - len(new_disabled)\n         console.print(\n-            f\"\\n[green]✓[/green] {enabled_count} enabled, {len(new_disabled)} disabled. \"\n-            f\"Takes effect on next session.\"\n+            f\"\\n[green]\\u2713[/green] General plugins: {enabled_count} enabled, \"\n+            f\"{len(new_disabled)} disabled.\"\n         )\n-    else:\n-        console.print(\"\\n[dim]No changes.[/dim]\")\n+    elif n_plugins > 0:\n+        console.print(\"\\n[dim]General plugins unchanged.[/dim]\")\n+\n+    if result_holder[\"providers_changed\"]:\n+        new_memory = _get_current_memory_provider() or \"built-in\"\n+        new_context = _get_current_context_engine()\n+        console.print(\n+            f\"[green]\\u2713[/green] Memory provider: [bold]{new_memory}[/bold]  \"\n+            f\"Context engine: [bold]{new_context}[/bold]\"\n+        )\n+\n+    if n_plugins > 0 or result_holder[\"providers_changed\"]:\n+        console.print(\"[dim]Changes take effect on next session.[/dim]\")\n+    console.print()\n+\n+\n+def _run_composite_fallback(plugin_names, plugin_labels, plugin_selected,\n+                            disabled, categories, console):\n+    \"\"\"Text-based fallback for the composite plugins UI.\"\"\"\n+    from hermes_cli.colors import Colors, color\n+\n+    print(color(\"\\n  Plugins\", Colors.YELLOW))\n+\n+    # General plugins\n+    if plugin_names:\n+        chosen = set(plugin_selected)\n+        print(color(\"\\n  General Plugins\", Colors.YELLOW))\n+        print(color(\"  Toggle by number, Enter to confirm.\\n\", Colors.DIM))\n+\n+        while True:\n+            for i, label in enumerate(plugin_labels):\n+                marker = color(\"[\\u2713]\", Colors.GREEN) if i in chosen else \"[ ]\"\n+                print(f\"  {marker} {i + 1:>2}. {label}\")\n+            print()\n+            try:\n+                val = input(color(\"  Toggle # (or Enter to confirm): \", Colors.DIM)).strip()\n+                if not val:\n+                    break\n+                idx = int(val) - 1\n+                if 0 <= idx < len(plugin_names):\n+                    chosen.symmetric_difference_update({idx})\n+            except (ValueError, KeyboardInterrupt, EOFError):\n+                return\n+            print()\n+\n+        new_disabled = set()\n+        for i, name in enumerate(plugin_names):\n+            if i not in chosen:\n+                new_disabled.add(name)\n+        if new_disabled != disabled:\n+            _save_disabled_set(new_disabled)\n+\n+    # Provider categories\n+    if categories:\n+        print(color(\"\\n  Provider Plugins\", Colors.YELLOW))\n+        for ci, (cat_name, cat_current, cat_fn) in enumerate(categories):\n+            print(f\"  {ci + 1}. {cat_name} [{cat_current}]\")\n+        print()\n+        try:\n+            val = input(color(\"  Configure # (or Enter to skip): \", Colors.DIM)).strip()\n+            if val:\n+                ci = int(val) - 1\n+                if 0 <= ci < len(categories):\n+                    categories[ci][2]()  # call the configure function\n+        except (ValueError, KeyboardInterrupt, EOFError):\n+            pass\n+\n+    print()\n \n \n def plugins_command(args) -> None:"]},{"file":"plugins/context_engine/__init__.py","hunks":["@@ -0,0 +1,219 @@\n+\"\"\"Context engine plugin discovery.\n+\n+Scans ``plugins/context_engine/<name>/`` directories for context engine\n+plugins.  Each subdirectory must contain ``__init__.py`` with a class\n+implementing the ContextEngine ABC.\n+\n+Context engines are separate from the general plugin system — they live\n+in the repo and are always available without user installation.  Only ONE\n+can be active at a time, selected via ``context.engine`` in config.yaml.\n+The default engine is ``\"compressor\"`` (the built-in ContextCompressor).\n+\n+Usage:\n+    from plugins.context_engine import discover_context_engines, load_context_engine\n+\n+    available = discover_context_engines()   # [(name, desc, available), ...]\n+    engine = load_context_engine(\"lcm\")      # ContextEngine instance\n+\"\"\"\n+\n+from __future__ import annotations\n+\n+import importlib\n+import importlib.util\n+import logging\n+import sys\n+from pathlib import Path\n+from typing import List, Optional, Tuple\n+\n+logger = logging.getLogger(__name__)\n+\n+_CONTEXT_ENGINE_PLUGINS_DIR = Path(__file__).parent\n+\n+\n+def discover_context_engines() -> List[Tuple[str, str, bool]]:\n+    \"\"\"Scan plugins/context_engine/ for available engines.\n+\n+    Returns list of (name, description, is_available) tuples.\n+    Does NOT import the engines — just reads plugin.yaml for metadata\n+    and does a lightweight availability check.\n+    \"\"\"\n+    results = []\n+    if not _CONTEXT_ENGINE_PLUGINS_DIR.is_dir():\n+        return results\n+\n+    for child in sorted(_CONTEXT_ENGINE_PLUGINS_DIR.iterdir()):\n+        if not child.is_dir() or child.name.startswith((\"_\", \".\")):\n+            continue\n+        init_file = child / \"__init__.py\"\n+        if not init_file.exists():\n+            continue\n+\n+        # Read description from plugin.yaml if available\n+        desc = \"\"\n+        yaml_file = child / \"plugin.yaml\"\n+        if yaml_file.exists():\n+            try:\n+                import yaml\n+                with open(yaml_file) as f:\n+                    meta = yaml.safe_load(f) or {}\n+                desc = meta.get(\"description\", \"\")\n+            except Exception:\n+                pass\n+\n+        # Quick availability check — try loading and calling is_available()\n+        available = True\n+        try:\n+            engine = _load_engine_from_dir(child)\n+            if engine is None:\n+                available = False\n+            elif hasattr(engine, \"is_available\"):\n+                available = engine.is_available()\n+        except Exception:\n+            available = False\n+\n+        results.append((child.name, desc, available))\n+\n+    return results\n+\n+\n+def load_context_engine(name: str) -> Optional[\"ContextEngine\"]:\n+    \"\"\"Load and return a ContextEngine instance by name.\n+\n+    Returns None if the engine is not found or fails to load.\n+    \"\"\"\n+    engine_dir = _CONTEXT_ENGINE_PLUGINS_DIR / name\n+    if not engine_dir.is_dir():\n+        logger.debug(\"Context engine '%s' not found in %s\", name, _CONTEXT_ENGINE_PLUGINS_DIR)\n+        return None\n+\n+    try:\n+        engine = _load_engine_from_dir(engine_dir)\n+        if engine:\n+            return engine\n+        logger.warning(\"Context engine '%s' loaded but no engine instance found\", name)\n+        return None\n+    except Exception as e:\n+        logger.warning(\"Failed to load context engine '%s': %s\", name, e)\n+        return None\n+\n+\n+def _load_engine_from_dir(engine_dir: Path) -> Optional[\"ContextEngine\"]:\n+    \"\"\"Import an engine module and extract the ContextEngine instance.\n+\n+    The module must have either:\n+    - A register(ctx) function (plugin-style) — we simulate a ctx\n+    - A top-level class that extends ContextEngine — we instantiate it\n+    \"\"\"\n+    name = engine_dir.name\n+    module_name = f\"plugins.context_engine.{name}\"\n+    init_file = engine_dir / \"__init__.py\"\n+\n+    if not init_file.exists():\n+        return None\n+\n+    # Check if already loaded\n+    if module_name in sys.modules:\n+        mod = sys.modules[module_name]\n+    else:\n+        # Handle relative imports within the plugin\n+        # First ensure the parent packages are registered\n+        for parent in (\"plugins\", \"plugins.context_engine\"):\n+            if parent not in sys.modules:\n+                parent_path = Path(__file__).parent\n+                if parent == \"plugins\":\n+                    parent_path = parent_path.parent\n+                parent_init = parent_path / \"__init__.py\"\n+                if parent_init.exists():\n+                    spec = importlib.util.spec_from_file_location(\n+                        parent, str(parent_init),\n+                        submodule_search_locations=[str(parent_path)]\n+                    )\n+                    if spec:\n+                        parent_mod = importlib.util.module_from_spec(spec)\n+                        sys.modules[parent] = parent_mod\n+                        try:\n+                            spec.loader.exec_module(parent_mod)\n+                        except Exception:\n+                            pass\n+\n+        # Now load the engine module\n+        spec = importlib.util.spec_from_file_location(\n+            module_name, str(init_file),\n+            submodule_search_locations=[str(engine_dir)]\n+        )\n+        if not spec:\n+            return None\n+\n+        mod = importlib.util.module_from_spec(spec)\n+        sys.modules[module_name] = mod\n+\n+        # Register submodules so relative imports work\n+        for sub_file in engine_dir.glob(\"*.py\"):\n+            if sub_file.name == \"__init__.py\":\n+                continue\n+            sub_name = sub_file.stem\n+            full_sub_name = f\"{module_name}.{sub_name}\"\n+            if full_sub_name not in sys.modules:\n+                sub_spec = importlib.util.spec_from_file_location(\n+                    full_sub_name, str(sub_file)\n+                )\n+                if sub_spec:\n+                    sub_mod = importlib.util.module_from_spec(sub_spec)\n+                    sys.modules[full_sub_name] = sub_mod\n+                    try:\n+                        sub_spec.loader.exec_module(sub_mod)\n+                    except Exception as e:\n+                        logger.debug(\"Failed to load submodule %s: %s\", full_sub_name, e)\n+\n+        try:\n+            spec.loader.exec_module(mod)\n+        except Exception as e:\n+            logger.debug(\"Failed to exec_module %s: %s\", module_name, e)\n+            sys.modules.pop(module_name, None)\n+            return None\n+\n+    # Try register(ctx) pattern first (how plugins are written)\n+    if hasattr(mod, \"register\"):\n+        collector = _EngineCollector()\n+        try:\n+            mod.register(collector)\n+            if collector.engine:\n+                return collector.engine\n+        except Exception as e:\n+            logger.debug(\"register() failed for %s: %s\", name, e)\n+\n+    # Fallback: find a ContextEngine subclass and instantiate it\n+    from agent.context_engine import ContextEngine\n+    for attr_name in dir(mod):\n+        attr = getattr(mod, attr_name, None)\n+        if (isinstance(attr, type) and issubclass(attr, ContextEngine)\n+                and attr is not ContextEngine):\n+            try:\n+                return attr()\n+            except Exception:\n+                pass\n+\n+    return None\n+\n+\n+class _EngineCollector:\n+    \"\"\"Fake plugin context that captures register_context_engine calls.\"\"\"\n+\n+    def __init__(self):\n+        self.engine = None\n+\n+    def register_context_engine(self, engine):\n+        self.engine = engine\n+\n+    # No-op for other registration methods\n+    def register_tool(self, *args, **kwargs):\n+        pass\n+\n+    def register_hook(self, *args, **kwargs):\n+        pass\n+\n+    def register_cli_command(self, *args, **kwargs):\n+        pass\n+\n+    def register_memory_provider(self, *args, **kwargs):\n+        pass"]},{"file":"run_agent.py","hunks":["@@ -1268,20 +1268,88 @@ def __init__(\n                                         pass\n                         break\n         \n-        self.context_compressor = ContextCompressor(\n-            model=self.model,\n-            threshold_percent=compression_threshold,\n-            protect_first_n=3,\n-            protect_last_n=compression_protect_last,\n-            summary_target_ratio=compression_target_ratio,\n-            summary_model_override=compression_summary_model,\n-            quiet_mode=self.quiet_mode,\n-            base_url=self.base_url,\n-            api_key=[redacted:secret-assignment] \"api_key\", \"\"),\n-            config_context_length=_config_context_length,\n-            provider=self.provider,\n-        )\n+        # Select context engine: config-driven (like memory providers).\n+        # 1. Check config.yaml context.engine setting\n+        # 2. Check plugins/context_engine/<name>/ directory (repo-shipped)\n+        # 3. Check general plugin system (user-installed plugins)\n+        # 4. Fall back to built-in ContextCompressor\n+        _selected_engine = None\n+        _engine_name = \"compressor\"  # default\n+        try:\n+            _ctx_cfg = _agent_cfg.get(\"context\", {}) if isinstance(_agent_cfg, dict) else {}\n+            _engine_name = _ctx_cfg.get(\"engine\", \"compressor\") or \"compressor\"\n+        except Exception:\n+            pass\n+\n+        if _engine_name != \"compressor\":\n+            # Try loading from plugins/context_engine/<name>/\n+            try:\n+                from plugins.context_engine import load_context_engine\n+                _selected_engine = load_context_engine(_engine_name)\n+            except Exception as _ce_load_err:\n+                logger.debug(\"Context engine load from plugins/context_engine/: %s\", _ce_load_err)\n+\n+            # Try general plugin system as fallback\n+            if _selected_engine is None:\n+                try:\n+                    from hermes_cli.plugins import get_plugin_context_engine\n+                    _candidate = get_plugin_context_engine()\n+                    if _candidate and _candidate.name == _engine_name:\n+                        _selected_engine = _candidate\n+                except Exception:\n+                    pass\n+\n+            if _selected_engine is None:\n+                logger.warning(\n+                    \"Context engine '%s' not found — falling back to built-in compressor\",\n+                    _engine_name,\n+                )\n+        # else: config says \"compressor\" — use built-in, don't auto-activate plugins\n+\n+        if _selected_engine is not None:\n+            self.context_compressor = _selected_engine\n+            if not self.quiet_mode:\n+                logger.info(\"Using context engine: %s\", _selected_engine.name)\n+        else:\n+            self.context_compressor = ContextCompressor(\n+                model=self.model,\n+                threshold_percent=compression_threshold,\n+                protect_first_n=3,\n+                protect_last_n=compression_protect_last,\n+                summary_target_ratio=compression_target_ratio,\n+                summary_model_override=compression_summary_model,\n+                quiet_mode=self.quiet_mode,\n+                base_url=self.base_url,\n+                api_key=[redacted:secret-assignment] \"api_key\", \"\"),\n+                config_context_length=_config_context_length,\n+                provider=self.provider,\n+            )\n         self.compression_enabled = compression_enabled\n+\n+        # Inject context engine tool schemas (e.g. lcm_grep, lcm_describe, lcm_expand)\n+        self._context_engine_tool_names: set = set()\n+        if hasattr(self, \"context_compressor\") and self.context_compressor and self.tools is not None:\n+            for _schema in self.context_compressor.get_tool_schemas():\n+                _wrapped = {\"type\": \"function\", \"function\": _schema}\n+                self.tools.append(_wrapped)\n+                _tname = _schema.get(\"name\", \"\")\n+                if _tname:\n+                    self.valid_tool_names.add(_tname)\n+                    self._context_engine_tool_names.add(_tname)\n+\n+        # Notify context engine of session start\n+        if hasattr(self, \"context_compressor\") and self.context_compressor:\n+            try:\n+                self.context_compressor.on_session_start(\n+                    self.session_id,\n+                    hermes_home=str(get_hermes_home()),\n+                    platform=self.platform or \"cli\",\n+                    model=self.model,\n+                    context_length=getattr(self.context_compressor, \"context_length\", 0),\n+                )\n+            except Exception as _ce_err:\n+                logger.debug(\"Context engine on_session_start: %s\", _ce_err)\n+\n         self._subdirectory_hints = SubdirectoryHintTracker(\n             working_dir=os.getenv(\"TERMINAL_CWD\") or None,\n         )","@@ -1347,11 +1415,13 @@ def __init__(\n             \"api_key\": getattr(self, \"api_key\", \"\"),\n             \"client_kwargs\": dict(self._client_kwargs),\n             \"use_prompt_caching\": self._use_prompt_caching,\n-            # Compressor state that _try_activate_fallback() overwrites\n-            \"compressor_model\": _cc.model,\n-            \"compressor_base_url\": _cc.base_url,\n+            # Context engine state that _try_activate_fallback() overwrites.\n+            # Use getattr for model/base_url/api_key/provider since plugin\n+            # engines may not have these (they're ContextCompressor-specific).\n+            \"compressor_model\": getattr(_cc, \"model\", self.model),\n+            \"compressor_base_url\": getattr(_cc, \"base_url\", self.base_url),\n             \"compressor_api_key\": getattr(_cc, \"api_key\", \"\"),\n-            \"compressor_provider\": _cc.provider,\n+            \"compressor_provider\": getattr(_cc, \"provider\", self.provider),\n             \"compressor_context_length\": _cc.context_length,\n             \"compressor_threshold_tokens\": _cc.threshold_tokens,\n         }","@@ -1397,15 +1467,9 @@ def reset_session_state(self):\n         # Turn counter (added after reset_session_state was first written — #2635)\n         self._user_turn_count = 0\n \n-        # Context compressor internal counters (if present)\n+        # Context engine reset (works for both built-in compressor and plugins)\n         if hasattr(self, \"context_compressor\") and self.context_compressor:\n-            self.context_compressor.last_prompt_tokens = 0\n-            self.context_compressor.last_completion_tokens = 0\n-            self.context_compressor.compression_count = 0\n-            self.context_compressor._context_probed = False\n-            self.context_compressor._context_probe_persistable = False\n-            # Iterative summary from previous session must not bleed into new one (#2635)\n-            self.context_compressor._previous_summary = None\n+            self.context_compressor.on_session_reset()\n     \n     def switch_model(self, new_model, new_provider, api_key='', base_url='', api_mode=''):\n         \"\"\"Switch the model/provider in-place for a live agent.","@@ -1486,13 +1550,12 @@ def switch_model(self, new_model, new_provider, api_key='', base_url='', api_mod\n                 provider=self.provider,\n                 config_context_length=getattr(self, \"_config_context_length\", None),\n             )\n-            self.context_compressor.model = self.model\n-            self.context_compressor.base_url = self.base_url\n-            self.context_compressor.api_key = [redacted:secret-assignment]\n-            self.context_compressor.provider = self.provider\n-            self.context_compressor.context_length = new_context_length\n-            self.context_compressor.threshold_tokens = int(\n-                new_context_length * self.context_compressor.threshold_percent\n+            self.context_compressor.update_model(\n+                model=self.model,\n+                context_length=new_context_length,\n+                base_url=self.base_url,\n+                api_key=[redacted:secret-assignment] \"api_key\", \"\"),\n+                provider=self.provider,\n             )\n \n         # ── Invalidate cached system prompt so it rebuilds next turn ──","@@ -1508,10 +1571,10 @@ def switch_model(self, new_model, new_provider, api_key='', base_url='', api_mod\n             \"api_key\": getattr(self, \"api_key\", \"\"),\n             \"client_kwargs\": dict(self._client_kwargs),\n             \"use_prompt_caching\": self._use_prompt_caching,\n-            \"compressor_model\": _cc.model if _cc else self.model,\n-            \"compressor_base_url\": _cc.base_url if _cc else self.base_url,\n+            \"compressor_model\": getattr(_cc, \"model\", self.model) if _cc else self.model,\n+            \"compressor_base_url\": getattr(_cc, \"base_url\", self.base_url) if _cc else self.base_url,\n             \"compressor_api_key\": getattr(_cc, \"api_key\", \"\") if _cc else \"\",\n-            \"compressor_provider\": _cc.provider if _cc else self.provider,\n+            \"compressor_provider\": getattr(_cc, \"provider\", self.provider) if _cc else self.provider,\n             \"compressor_context_length\": _cc.context_length if _cc else 0,\n             \"compressor_threshold_tokens\": _cc.threshold_tokens if _cc else 0,\n         }","@@ -2708,10 +2771,11 @@ def get_activity_summary(self) -> dict:\n         }\n \n     def shutdown_memory_provider(self, messages: list = None) -> None:\n-        \"\"\"Shut down the memory provider — call at actual session boundaries.\n+        \"\"\"Shut down the memory provider and context engine — call at actual session boundaries.\n \n         This calls on_session_end() then shutdown_all() on the memory\n-        manager. NOT called per-turn — only at CLI exit, /reset, gateway\n+        manager, and on_session_end() on the context engine.\n+        NOT called per-turn — only at CLI exit, /reset, gateway\n         session expiry, etc.\n         \"\"\"\n         if self._memory_manager:","@@ -2723,6 +2787,15 @@ def shutdown_memory_provider(self, messages: list = None) -> None:\n                 self._memory_manager.shutdown_all()\n             except Exception:\n                 pass\n+        # Notify context engine of session end (flush DAG, close DBs, etc.)\n+        if hasattr(self, \"context_compressor\") and self.context_compressor:\n+            try:\n+                self.context_compressor.on_session_end(\n+                    self.session_id or \"\",\n+                    messages or [],\n+                )\n+            except Exception:\n+                pass\n     \n     def close(self) -> None:\n         \"\"\"Release all resources held by this agent instance.","@@ -5240,13 +5313,12 @@ def _try_activate_fallback(self) -> bool:\n                     self.model, base_url=self.base_url,\n                     api_key=[redacted:secret-assignment] provider=self.provider,\n                 )\n-                self.context_compressor.model = self.model\n-                self.context_compressor.base_url = self.base_url\n-                self.context_compressor.api_key = [redacted:secret-assignment]\n-                self.context_compressor.provider = self.provider\n-                self.context_compressor.context_length = fb_context_length\n-                self.context_compressor.threshold_tokens = int(\n-                    fb_context_length * self.context_compressor.threshold_percent\n+                self.context_compressor.update_model(\n+                    model=self.model,\n+                    context_length=fb_context_length,\n+                    base_url=self.base_url,\n+                    api_key=[redacted:secret-assignment] \"api_key\", \"\"),\n+                    provider=self.provider,\n                 )\n \n             self._emit_status(","@@ -5306,14 +5378,15 @@ def _restore_primary_runtime(self) -> bool:\n                     shared=True,\n                 )\n \n-            # ── Restore context compressor state ──\n+            # ── Restore context engine state ──\n             cc = self.context_compressor\n-            cc.model = rt[\"compressor_model\"]\n-            cc.base_url = rt[\"compressor_base_url\"]\n-            cc.api_key = rt[\"compressor_api_key\"]\n-            cc.provider = rt[\"compressor_provider\"]\n-            cc.context_length = rt[\"compressor_context_length\"]\n-            cc.threshold_tokens = rt[\"compressor_threshold_tokens\"]\n+            cc.update_model(\n+                model=rt[\"compressor_model\"],\n+                context_length=rt[\"compressor_context_length\"],\n+                base_url=rt[\"compressor_base_url\"],\n+                api_key=rt[\"compressor_api_key\"],\n+                provider=rt[\"compressor_provider\"],\n+            )\n \n             # ── Reset fallback chain for the new turn ──\n             self._fallback_activated = False","@@ -6878,6 +6951,29 @@ def _execute_tool_calls_sequential(self, assistant_message, messages: list, effe\n                         spinner.stop(cute_msg)\n                     elif self._should_emit_quiet_tool_messages():\n                         self._vprint(f\"  {cute_msg}\")\n+            elif self._context_engine_tool_names and function_name in self._context_engine_tool_names:\n+                # Context engine tools (lcm_grep, lcm_describe, lcm_expand, etc.)\n+                spinner = None\n+                if self.quiet_mode and not self.tool_progress_callback:\n+                    face = random.choice(KawaiiSpinner.KAWAII_WAITING)\n+                    emoji = _get_tool_emoji(function_name)\n+                    preview = _build_tool_preview(function_name, function_args) or function_name\n+                    spinner = KawaiiSpinner(f\"{face} {emoji} {preview}\", spinner_type='dots', print_fn=self._print_fn)\n+                    spinner.start()\n+                _ce_result = None\n+                try:\n+                    function_result = self.context_compressor.handle_tool_call(function_name, function_args, messages=messages)\n+                    _ce_result = function_result\n+                except Exception as tool_error:\n+                    function_result = json.dumps({\"error\": f\"Context engine tool '{function_name}' failed: {tool_error}\"})\n+                    logger.error(\"context_engine.handle_tool_call raised for %s: %s\", function_name, tool_error, exc_info=True)\n+                finally:\n+                    tool_duration = time.time() - tool_start_time\n+                    cute_msg = _get_cute_tool_message_impl(function_name, function_args, tool_duration, result=_ce_result)\n+                    if spinner:\n+                        spinner.stop(cute_msg)\n+                    elif self.quiet_mode:\n+                        self._vprint(f\"  {cute_msg}\")\n             elif self._memory_manager and self._memory_manager.has_tool(function_name):\n                 # Memory provider tools (hindsight_retain, honcho_search, etc.)\n                 # These are not in the tool registry — route through MemoryManager.","@@ -8192,7 +8288,7 @@ def _stop_spinner():\n                         # Cache discovered context length after successful call.\n                         # Only persist limits confirmed by the provider (parsed\n                         # from the error message), not guessed probe tiers.\n-                        if self.context_compressor._context_probed:\n+                        if getattr(self.context_compressor, \"_context_probed\", False):\n                             ctx = self.context_compressor.context_length\n                             if getattr(self.context_compressor, \"_context_probe_persistable\", False):\n                                 save_context_length(self.model, self.base_url, ctx)","@@ -8531,16 +8627,22 @@ def _stop_spinner():\n                         compressor = self.context_compressor\n                         old_ctx = compressor.context_length\n                         if old_ctx > _reduced_ctx:\n-                            compressor.context_length = _reduced_ctx\n-                            compressor.threshold_tokens = int(\n-                                _reduced_ctx * compressor.threshold_percent\n+                            compressor.update_model(\n+                                model=self.model,\n+                                context_length=_reduced_ctx,\n+                                base_url=self.base_url,\n+                                api_key=[redacted:secret-assignment] \"api_key\", \"\"),\n+                                provider=self.provider,\n                             )\n-                            compressor._context_probed = True\n-                            # Don't persist — this is a subscription-tier\n-                            # limitation, not a model capability.  If the user\n-                            # later enables extra usage the 1M limit should\n-                            # come back automatically.\n-                            compressor._context_probe_persistable = False\n+                            # Context probing flags — only set on built-in\n+                            # compressor (plugin engines manage their own).\n+                            if hasattr(compressor, \"_context_probed\"):\n+                                compressor._context_probed = True\n+                                # Don't persist — this is a subscription-tier\n+                                # limitation, not a model capability.  If the\n+                                # user later enables extra usage the 1M limit\n+                                # should come back automatically.\n+                                compressor._context_probe_persistable = False\n                             self._vprint(\n                                 f\"{self.log_prefix}⚠️  Anthropic long-context tier \"\n                                 f\"requires extra usage — reducing context: \"","@@ -8704,17 +8806,25 @@ def _stop_spinner():\n                             new_ctx = get_next_probe_tier(old_ctx)\n \n                         if new_ctx and new_ctx < old_ctx:\n-                            compressor.context_length = new_ctx\n-                            compressor.threshold_tokens = [redacted:secret-assignment] * compressor.threshold_percent)\n-                            compressor._context_probed = True\n-                            # Only persist limits parsed from the provider's\n-                            # error message (a real number).  Guessed fallback\n-                            # tiers from get_next_probe_tier() should stay\n-                            # in-memory only — persisting them pollutes the\n-                            # cache with wrong values.\n-                            compressor._context_probe_persistable = bool(\n-                                parsed_limit and parsed_limit == new_ctx\n+                            compressor.update_model(\n+                                model=self.model,\n+                                context_length=new_ctx,\n+                                base_url=self.base_url,\n+                                api_key=[redacted:secret-assignment] \"api_key\", \"\"),\n+                                provider=self.provider,\n                             )\n+                            # Context probing flags — only set on built-in\n+                            # compressor (plugin engines manage their own).\n+                            if hasattr(compressor, \"_context_probed\"):\n+                                compressor._context_probed = True\n+                                # Only persist limits parsed from the provider's\n+                                # error message (a real number).  Guessed fallback\n+                                # tiers from get_next_probe_tier() should stay\n+                                # in-memory only — persisting them pollutes the\n+                                # cache with wrong values.\n+                                compressor._context_probe_persistable = bool(\n+                                    parsed_limit and parsed_limit == new_ctx\n+                                )\n                             self._vprint(f\"{self.log_prefix}⚠️  Context length exceeded — stepping down: {old_ctx:,} → {new_ctx:,} tokens\", force=True)\n                         else:\n                             self._vprint(f\"{self.log_prefix}⚠️  Context length exceeded at minimum tier — attempting compression...\", force=True)"]},{"file":"tests/agent/test_context_engine.py","hunks":["@@ -0,0 +1,250 @@\n+\"\"\"Tests for the ContextEngine ABC and plugin slot.\"\"\"\n+\n+import json\n+import pytest\n+from typing import Any, Dict, List\n+\n+from agent.context_engine import ContextEngine\n+from agent.context_compressor import ContextCompressor\n+\n+\n+# ---------------------------------------------------------------------------\n+# A minimal concrete engine for testing the ABC\n+# ---------------------------------------------------------------------------\n+\n+class StubEngine(ContextEngine):\n+    \"\"\"Minimal engine that satisfies the ABC without doing real work.\"\"\"\n+\n+    def __init__(self, context_length=200000, threshold_pct=0.50):\n+        self.context_length = context_length\n+        self.threshold_tokens = [redacted:secret-assignment] * threshold_pct)\n+        self._compress_called = False\n+        self._tools_called = []\n+\n+    @property\n+    def name(self) -> str:\n+        return \"stub\"\n+\n+    def update_from_response(self, usage: Dict[str, Any]) -> None:\n+        self.last_prompt_tokens = [redacted:secret-assignment]\"prompt_tokens\", 0)\n+        self.last_completion_tokens = [redacted:secret-assignment]\"completion_tokens\", 0)\n+        self.last_total_tokens = [redacted:secret-assignment]\"total_tokens\", 0)\n+\n+    def should_compress(self, prompt_tokens: int = None) -> bool:\n+        tokens = [redacted:secret-assignment] if prompt_tokens is not None else self.last_prompt_tokens\n+        return tokens >= self.threshold_tokens\n+\n+    def compress(self, messages: List[Dict[str, Any]], current_tokens: int = None) -> List[Dict[str, Any]]:\n+        self._compress_called = True\n+        self.compression_count += 1\n+        # Trivial: just return as-is\n+        return messages\n+\n+    def get_tool_schemas(self) -> List[Dict[str, Any]]:\n+        return [\n+            {\n+                \"name\": \"stub_search\",\n+                \"description\": \"Search the stub engine\",\n+                \"parameters\": {\"type\": \"object\", \"properties\": {}},\n+            }\n+        ]\n+\n+    def handle_tool_call(self, name: str, args: Dict[str, Any]) -> str:\n+        self._tools_called.append(name)\n+        return json.dumps({\"ok\": True, \"tool\": name})\n+\n+\n+# ---------------------------------------------------------------------------\n+# ABC contract tests\n+# ---------------------------------------------------------------------------\n+\n+class TestContextEngineABC:\n+    \"\"\"Verify the ABC enforces the required interface.\"\"\"\n+\n+    def test_cannot_instantiate_abc_directly(self):\n+        with pytest.raises(TypeError):\n+            ContextEngine()\n+\n+    def test_missing_methods_raises(self):\n+        \"\"\"A subclass missing required methods cannot be instantiated.\"\"\"\n+        class Incomplete(ContextEngine):\n+            @property\n+            def name(self):\n+                return \"incomplete\"\n+        with pytest.raises(TypeError):\n+            Incomplete()\n+\n+    def test_stub_engine_satisfies_abc(self):\n+        engine = StubEngine()\n+        assert isinstance(engine, ContextEngine)\n+        assert engine.name == \"stub\"\n+\n+    def test_compressor_is_context_engine(self):\n+        c = ContextCompressor(model=\"test\", quiet_mode=True, config_context_length=200000)\n+        assert isinstance(c, ContextEngine)\n+        assert c.name == \"compressor\"\n+\n+\n+# ---------------------------------------------------------------------------\n+# Default method behavior\n+# ---------------------------------------------------------------------------\n+\n+class TestDefaults:\n+    \"\"\"Verify ABC default implementations work correctly.\"\"\"\n+\n+    def test_default_tool_schemas_empty(self):\n+        engine = StubEngine()\n+        # StubEngine overrides this, so test the base via super\n+        assert ContextEngine.get_tool_schemas(engine) == []\n+\n+    def test_default_handle_tool_call_returns_error(self):\n+        engine = StubEngine()\n+        result = ContextEngine.handle_tool_call(engine, \"unknown\", {})\n+        data = json.loads(result)\n+        assert \"error\" in data\n+\n+    def test_default_get_status(self):\n+        engine = StubEngine()\n+        engine.last_prompt_tokens = 50000\n+        status = engine.get_status()\n+        assert status[\"last_prompt_tokens\"] == 50000\n+        assert status[\"context_length\"] == 200000\n+        assert status[\"threshold_tokens\"] == 100000\n+        assert 0 < status[\"usage_percent\"] <= 100\n+\n+    def test_on_session_reset(self):\n+        engine = StubEngine()\n+        engine.last_prompt_tokens = 999\n+        engine.compression_count = 3\n+        engine.on_session_reset()\n+        assert engine.last_prompt_tokens == 0\n+        assert engine.compression_count == 0\n+\n+    def test_should_compress_preflight_default_false(self):\n+        engine = StubEngine()\n+        assert engine.should_compress_preflight([]) is False\n+\n+\n+# ---------------------------------------------------------------------------\n+# StubEngine behavior\n+# ---------------------------------------------------------------------------\n+\n+class TestStubEngine:\n+\n+    def test_should_compress(self):\n+        engine = StubEngine(context_length=100000, threshold_pct=0.50)\n+        assert not engine.should_compress(40000)\n+        assert engine.should_compress(50000)\n+        assert engine.should_compress(60000)\n+\n+    def test_compress_tracks_count(self):\n+        engine = StubEngine()\n+        msgs = [{\"role\": \"user\", \"content\": \"hello\"}]\n+        result = engine.compress(msgs)\n+        assert result == msgs\n+        assert engine._compress_called\n+        assert engine.compression_count == 1\n+\n+    def test_tool_schemas(self):\n+        engine = StubEngine()\n+        schemas = engine.get_tool_schemas()\n+        assert len(schemas) == 1\n+        assert schemas[0][\"name\"] == \"stub_search\"\n+\n+    def test_handle_tool_call(self):\n+        engine = StubEngine()\n+        result = engine.handle_tool_call(\"stub_search\", {})\n+        assert json.loads(result)[\"ok\"] is True\n+        assert \"stub_search\" in engine._tools_called\n+\n+    def test_update_from_response(self):\n+        engine = StubEngine()\n+        engine.update_from_response({\"prompt_tokens\": 1000, \"completion_tokens\": 200, \"total_tokens\": 1200})\n+        assert engine.last_prompt_tokens == 1000\n+        assert engine.last_completion_tokens == 200\n+\n+\n+# ---------------------------------------------------------------------------\n+# ContextCompressor session reset via ABC\n+# ---------------------------------------------------------------------------\n+\n+class TestCompressorSessionReset:\n+    \"\"\"Verify ContextCompressor.on_session_reset() clears all state.\"\"\"\n+\n+    def test_reset_clears_state(self):\n+        c = ContextCompressor(model=\"test\", quiet_mode=True, config_context_length=200000)\n+        c.last_prompt_tokens = 50000\n+        c.compression_count = 3\n+        c._previous_summary = \"some old summary\"\n+        c._context_probed = True\n+        c._context_probe_persistable = True\n+\n+        c.on_session_reset()\n+\n+        assert c.last_prompt_tokens == 0\n+        assert c.last_completion_tokens == 0\n+        assert c.last_total_tokens == 0\n+        assert c.compression_count == 0\n+        assert c._context_probed is False\n+        assert c._context_probe_persistable is False\n+        assert c._previous_summary is None\n+\n+\n+# ---------------------------------------------------------------------------\n+# Plugin slot (PluginManager integration)\n+# ---------------------------------------------------------------------------\n+\n+class TestPluginContextEngineSlot:\n+    \"\"\"Test register_context_engine on PluginContext.\"\"\"\n+\n+    def test_register_engine(self):\n+        from hermes_cli.plugins import PluginManager, PluginContext, PluginManifest\n+        mgr = PluginManager()\n+        manifest = PluginManifest(name=\"test-lcm\")\n+        ctx = PluginContext(manifest, mgr)\n+\n+        engine = StubEngine()\n+        ctx.register_context_engine(engine)\n+\n+        assert mgr._context_engine is engine\n+        assert mgr._context_engine.name == \"stub\"\n+\n+    def test_reject_second_engine(self):\n+        from hermes_cli.plugins import PluginManager, PluginContext, PluginManifest\n+        mgr = PluginManager()\n+        manifest = PluginManifest(name=\"test-lcm\")\n+        ctx = PluginContext(manifest, mgr)\n+\n+        engine1 = StubEngine()\n+        engine2 = StubEngine()\n+        ctx.register_context_engine(engine1)\n+        ctx.register_context_engine(engine2)  # should be rejected\n+\n+        assert mgr._context_engine is engine1\n+\n+    def test_reject_non_engine(self):\n+        from hermes_cli.plugins import PluginManager, PluginContext, PluginManifest\n+        mgr = PluginManager()\n+        manifest = PluginManifest(name=\"test-bad\")\n+        ctx = PluginContext(manifest, mgr)\n+\n+        ctx.register_context_engine(\"not an engine\")\n+        assert mgr._context_engine is None\n+\n+    def test_get_plugin_context_engine(self):\n+        from hermes_cli.plugins import PluginManager, PluginContext, PluginManifest, get_plugin_context_engine, _plugin_manager\n+        import hermes_cli.plugins as plugins_mod\n+\n+        # Inject a test manager\n+        old_mgr = plugins_mod._plugin_manager\n+        try:\n+            mgr = PluginManager()\n+            plugins_mod._plugin_manager = mgr\n+\n+            assert get_plugin_context_engine() is None\n+\n+            engine = StubEngine()\n+            mgr._context_engine = engine\n+            assert get_plugin_context_engine() is engine\n+        finally:\n+            plugins_mod._plugin_manager = old_mgr"]},{"file":"tests/hermes_cli/test_plugins_cmd.py","hunks":["@@ -555,3 +555,103 @@ def test_keyboard_interrupt_skips_gracefully(self):\n \n         # Should not crash, and not save anything\n         mock_save.assert_not_called()\n+\n+\n+# ── curses_radiolist ─────────────────────────────────────────────────────\n+\n+\n+class TestCursesRadiolist:\n+    \"\"\"Test the curses_radiolist function (non-TTY fallback path).\"\"\"\n+\n+    def test_non_tty_returns_default(self):\n+        from hermes_cli.curses_ui import curses_radiolist\n+        with patch(\"sys.stdin\") as mock_stdin:\n+            mock_stdin.isatty.return_value = False\n+            result = curses_radiolist(\"Pick one\", [\"a\", \"b\", \"c\"], selected=1)\n+            assert result == 1\n+\n+    def test_non_tty_returns_cancel_value(self):\n+        from hermes_cli.curses_ui import curses_radiolist\n+        with patch(\"sys.stdin\") as mock_stdin:\n+            mock_stdin.isatty.return_value = False\n+            result = curses_radiolist(\"Pick\", [\"x\", \"y\"], selected=0, cancel_returns=1)\n+            assert result == 1\n+\n+\n+# ── Provider discovery helpers ───────────────────────────────────────────\n+\n+\n+class TestProviderDiscovery:\n+    \"\"\"Test provider plugin discovery and config helpers.\"\"\"\n+\n+    def test_get_current_memory_provider_default(self, tmp_path, monkeypatch):\n+        \"\"\"Empty config returns empty string.\"\"\"\n+        monkeypatch.setenv(\"HERMES_HOME\", str(tmp_path))\n+        config_file = tmp_path / \"config.yaml\"\n+        config_file.write_text(\"memory:\\n  provider: ''\\n\")\n+        from hermes_cli.plugins_cmd import _get_current_memory_provider\n+        result = _get_current_memory_provider()\n+        assert result == \"\"\n+\n+    def test_get_current_context_engine_default(self, tmp_path, monkeypatch):\n+        \"\"\"Default config returns 'compressor'.\"\"\"\n+        monkeypatch.setenv(\"HERMES_HOME\", str(tmp_path))\n+        config_file = tmp_path / \"config.yaml\"\n+        config_file.write_text(\"context:\\n  engine: compressor\\n\")\n+        from hermes_cli.plugins_cmd import _get_current_context_engine\n+        result = _get_current_context_engine()\n+        assert result == \"compressor\"\n+\n+    def test_save_memory_provider(self, tmp_path, monkeypatch):\n+        \"\"\"Saving a memory provider persists to config.yaml.\"\"\"\n+        monkeypatch.setenv(\"HERMES_HOME\", str(tmp_path))\n+        config_file = tmp_path / \"config.yaml\"\n+        config_file.write_text(\"memory:\\n  provider: ''\\n\")\n+        from hermes_cli.plugins_cmd import _save_memory_provider\n+        _save_memory_provider(\"honcho\")\n+        content = yaml.safe_load(config_file.read_text())\n+        assert content[\"memory\"][\"provider\"] == \"honcho\"\n+\n+    def test_save_context_engine(self, tmp_path, monkeypatch):\n+        \"\"\"Saving a context engine persists to config.yaml.\"\"\"\n+        monkeypatch.setenv(\"HERMES_HOME\", str(tmp_path))\n+        config_file = tmp_path / \"config.yaml\"\n+        config_file.write_text(\"context:\\n  engine: compressor\\n\")\n+        from hermes_cli.plugins_cmd import _save_context_engine\n+        _save_context_engine(\"lcm\")\n+        content = yaml.safe_load(config_file.read_text())\n+        assert content[\"context\"][\"engine\"] == \"lcm\"\n+\n+    def test_discover_memory_providers_empty(self):\n+        \"\"\"Discovery returns empty list when import fails.\"\"\"\n+        with patch(\"plugins.memory.discover_memory_providers\",\n+                    side_effect=ImportError(\"no module\")):\n+            from hermes_cli.plugins_cmd import _discover_memory_providers\n+            result = _discover_memory_providers()\n+            assert result == []\n+\n+    def test_discover_context_engines_empty(self):\n+        \"\"\"Discovery returns empty list when import fails.\"\"\"\n+        with patch(\"plugins.context_engine.discover_context_engines\",\n+                    side_effect=ImportError(\"no module\")):\n+            from hermes_cli.plugins_cmd import _discover_context_engines\n+            result = _discover_context_engines()\n+            assert result == []\n+\n+\n+# ── Auto-activation fix ──────────────────────────────────────────────────\n+\n+\n+class TestNoAutoActivation:\n+    \"\"\"Verify that plugin engines don't auto-activate when config says 'compressor'.\"\"\"\n+\n+    def test_compressor_default_ignores_plugin(self):\n+        \"\"\"When context.engine is 'compressor', a plugin-registered engine should NOT\n+        be used — only explicit config triggers plugin engines.\"\"\"\n+        # This tests the run_agent.py logic indirectly by checking that the\n+        # code path for default config doesn't call get_plugin_context_engine.\n+        import run_agent as ra_module\n+        source = open(ra_module.__file__).read()\n+        # The old code had: \"Even with default config, check if a plugin registered one\"\n+        # The fix removes this. Verify it's gone.\n+        assert \"Even with default config, check if a plugin registered one\" not in source"]},{"file":"website/docs/developer-guide/agent-loop.md","hunks":["@@ -226,7 +226,8 @@ After each turn:\n |------|---------|\n | `run_agent.py` | AIAgent class — the complete agent loop (~9,200 lines) |\n | `agent/prompt_builder.py` | System prompt assembly from memory, skills, context files, personality |\n-| `agent/context_compressor.py` | Conversation compression algorithm |\n+| `agent/context_engine.py` | ContextEngine ABC — pluggable context management |\n+| `agent/context_compressor.py` | Default engine — lossy summarization algorithm |\n | `agent/prompt_caching.py` | Anthropic prompt caching markers and cache metrics |\n | `agent/auxiliary_client.py` | Auxiliary LLM client for side tasks (vision, summarization) |\n | `model_tools.py` | Tool schema collection, `handle_function_call()` dispatch |"]},{"file":"website/docs/developer-guide/architecture.md","hunks":["@@ -62,7 +62,8 @@ hermes-agent/\n │\n ├── agent/                    # Agent internals\n │   ├── prompt_builder.py     # System prompt assembly\n-│   ├── context_compressor.py # Conversation compression algorithm\n+│   ├── context_engine.py     # ContextEngine ABC (pluggable)\n+│   ├── context_compressor.py # Default engine — lossy summarization\n │   ├── prompt_caching.py     # Anthropic prompt caching\n │   ├── auxiliary_client.py   # Auxiliary LLM for side tasks (vision, summarization)\n │   ├── model_metadata.py     # Model context lengths, token estimation","@@ -123,6 +124,7 @@ hermes-agent/\n ├── acp_adapter/              # ACP server (VS Code / Zed / JetBrains)\n ├── cron/                     # Scheduler (jobs.py, scheduler.py)\n ├── plugins/memory/           # Memory provider plugins\n+├── plugins/context_engine/   # Context engine plugins\n ├── environments/             # RL training environments (Atropos)\n ├── skills/                   # Bundled skills (always available)\n ├── optional-skills/          # Official optional skills (install explicitly)","@@ -227,7 +229,7 @@ Long-running process with 14 platform adapters, unified session routing, user au\n \n ### Plugin System\n \n-Three discovery sources: `~/.hermes/plugins/` (user), `.hermes/plugins/` (project), and pip entry points. Plugins register tools, hooks, and CLI commands through a context API. Memory providers are a specialized plugin type under `plugins/memory/`.\n+Three discovery sources: `~/.hermes/plugins/` (user), `.hermes/plugins/` (project), and pip entry points. Plugins register tools, hooks, and CLI commands through a context API. Two specialized plugin types exist: memory providers (`plugins/memory/`) and context engines (`plugins/context_engine/`). Both are single-select — only one of each can be active at a time, configured via `hermes plugins` or `config.yaml`.\n \n → [Plugin Guide](/docs/guides/build-a-hermes-plugin), [Memory Provider Plugin](./memory-provider-plugin.md)\n "]},{"file":"website/docs/developer-guide/context-compression-and-caching.md","hunks":["@@ -3,10 +3,37 @@\n Hermes Agent uses a dual compression system and Anthropic prompt caching to\n manage context window usage efficiently across long conversations.\n \n-Source files: `agent/context_compressor.py`, `agent/prompt_caching.py`,\n-`gateway/run.py` (session hygiene), `run_agent.py` (search for `_compress_context`)\n+Source files: `agent/context_engine.py` (ABC), `agent/context_compressor.py` (default engine),\n+`agent/prompt_caching.py`, `gateway/run.py` (session hygiene), `run_agent.py` (search for `_compress_context`)\n \n \n+## Pluggable Context Engine\n+\n+Context management is built on the `ContextEngine` ABC (`agent/context_engine.py`). The built-in `ContextCompressor` is the default implementation, but plugins can replace it with alternative engines (e.g., Lossless Context Management).\n+\n+```yaml\n+context:\n+  engine: \"compressor\"    # default — built-in lossy summarization\n+  engine: \"lcm\"           # example — plugin providing lossless context\n+```\n+\n+The engine is responsible for:\n+- Deciding when compaction should fire (`should_compress()`)\n+- Performing compaction (`compress()`)\n+- Optionally exposing tools the agent can call (e.g., `lcm_grep`)\n+- Tracking token usage from API responses\n+\n+Selection is config-driven via `context.engine` in `config.yaml`. The resolution order:\n+1. Check `plugins/context_engine/<name>/` directory\n+2. Check general plugin system (`register_context_engine()`)\n+3. Fall back to built-in `ContextCompressor`\n+\n+Plugin engines are **never auto-activated** — the user must explicitly set `context.engine` to the plugin's name. The default `\"compressor\"` always uses the built-in.\n+\n+Configure via `hermes plugins` → Provider Plugins → Context Engine, or edit `config.yaml` directly.\n+\n+For building a context engine plugin, see [Context Engine Plugins](/docs/developer-guide/context-engine-plugin).\n+\n ## Dual Compression System\n \n Hermes has two separate compression layers that operate independently:"]},{"file":"website/docs/developer-guide/context-engine-plugin.md","hunks":["@@ -0,0 +1,189 @@\n+---\n+sidebar_position: 9\n+title: \"Context Engine Plugins\"\n+description: \"How to build a context engine plugin that replaces the built-in ContextCompressor\"\n+---\n+\n+# Building a Context Engine Plugin\n+\n+Context engine plugins replace the built-in `ContextCompressor` with an alternative strategy for managing conversation context. For example, a Lossless Context Management (LCM) engine that builds a knowledge DAG instead of lossy summarization.\n+\n+## How it works\n+\n+The agent's context management is built on the `ContextEngine` ABC (`agent/context_engine.py`). The built-in `ContextCompressor` is the default implementation. Plugin engines must implement the same interface.\n+\n+Only **one** context engine can be active at a time. Selection is config-driven:\n+\n+```yaml\n+# config.yaml\n+context:\n+  engine: \"compressor\"    # default built-in\n+  engine: \"lcm\"           # activates a plugin engine named \"lcm\"\n+```\n+\n+Plugin engines are **never auto-activated** — the user must explicitly set `context.engine` to the plugin's name.\n+\n+## Directory structure\n+\n+Each context engine lives in `plugins/context_engine/<name>/`:\n+\n+```\n+plugins/context_engine/lcm/\n+├── __init__.py      # exports the ContextEngine subclass\n+├── plugin.yaml      # metadata (name, description, version)\n+└── ...              # any other modules your engine needs\n+```\n+\n+## The ContextEngine ABC\n+\n+Your engine must implement these **required** methods:\n+\n+```python\n+from agent.context_engine import ContextEngine\n+\n+class LCMEngine(ContextEngine):\n+\n+    @property\n+    def name(self) -> str:\n+        \"\"\"Short identifier, e.g. 'lcm'. Must match config.yaml value.\"\"\"\n+        return \"lcm\"\n+\n+    def update_from_response(self, usage: dict) -> None:\n+        \"\"\"Called after every LLM call with the usage dict.\n+\n+        Update self.last_prompt_tokens, self.last_completion_tokens,\n+        self.last_total_tokens from the response.\n+        \"\"\"\n+\n+    def should_compress(self, prompt_tokens: int = None) -> bool:\n+        \"\"\"Return True if compaction should fire this turn.\"\"\"\n+\n+    def compress(self, messages: list, current_tokens: int = None) -> list:\n+        \"\"\"Compact the message list and return a new (possibly shorter) list.\n+\n+        The returned list must be a valid OpenAI-format message sequence.\n+        \"\"\"\n+```\n+\n+### Class attributes your engine must maintain\n+\n+The agent reads these directly for display and logging:\n+\n+```python\n+last_prompt_tokens: int = 0\n+last_completion_tokens: int = 0\n+last_total_tokens: int = 0\n+threshold_tokens: int = 0        # when compression triggers\n+context_length: int = 0          # model's full context window\n+compression_count: int = 0       # how many times compress() has run\n+```\n+\n+### Optional methods\n+\n+These have sensible defaults in the ABC. Override as needed:\n+\n+| Method | Default | Override when |\n+|--------|---------|--------------|\n+| `on_session_start(session_id, **kwargs)` | No-op | You need to load persisted state (DAG, DB) |\n+| `on_session_end(session_id, messages)` | No-op | You need to flush state, close connections |\n+| `on_session_reset()` | Resets token counters | You have per-session state to clear |\n+| `update_model(model, context_length, ...)` | Updates context_length + threshold | You need to recalculate budgets on model switch |\n+| `get_tool_schemas()` | Returns `[]` | Your engine provides agent-callable tools (e.g., `lcm_grep`) |\n+| `handle_tool_call(name, args, **kwargs)` | Returns error JSON | You implement tool handlers |\n+| `should_compress_preflight(messages)` | Returns `False` | You can do a cheap pre-API-call estimate |\n+| `get_status()` | Standard token/threshold dict | You have custom metrics to expose |\n+\n+## Engine tools\n+\n+Context engines can expose tools the agent calls directly. Return schemas from `get_tool_schemas()` and handle calls in `handle_tool_call()`:\n+\n+```python\n+def get_tool_schemas(self):\n+    return [{\n+        \"name\": \"lcm_grep\",\n+        \"description\": \"Search the context knowledge graph\",\n+        \"parameters\": {\n+            \"type\": \"object\",\n+            \"properties\": {\n+                \"query\": {\"type\": \"string\", \"description\": \"Search query\"}\n+            },\n+            \"required\": [\"query\"],\n+        },\n+    }]\n+\n+def handle_tool_call(self, name, args, **kwargs):\n+    if name == \"lcm_grep\":\n+        results = self._search_dag(args[\"query\"])\n+        return json.dumps({\"results\": results})\n+    return json.dumps({\"error\": f\"Unknown tool: {name}\"})\n+```\n+\n+Engine tools are injected into the agent's tool list at startup and dispatched automatically — no registry registration needed.\n+\n+## Registration\n+\n+### Via directory (recommended)\n+\n+Place your engine in `plugins/context_engine/<name>/`. The `__init__.py` must export a `ContextEngine` subclass. The discovery system finds and instantiates it automatically.\n+\n+### Via general plugin system\n+\n+A general plugin can also register a context engine:\n+\n+```python\n+def register(ctx):\n+    engine = LCMEngine(context_length=200000)\n+    ctx.register_context_engine(engine)\n+```\n+\n+Only one engine can be registered. A second plugin attempting to register is rejected with a warning.\n+\n+## Lifecycle\n+\n+```\n+1. Engine instantiated (plugin load or directory discovery)\n+2. on_session_start() — conversation begins\n+3. update_from_response() — after each API call\n+4. should_compress() — checked each turn\n+5. compress() — called when should_compress() returns True\n+6. on_session_end() — session boundary (CLI exit, /reset, gateway expiry)\n+```\n+\n+`on_session_reset()` is called on `/new` or `/reset` to clear per-session state without a full shutdown.\n+\n+## Configuration\n+\n+Users select your engine via `hermes plugins` → Provider Plugins → Context Engine, or by editing `config.yaml`:\n+\n+```yaml\n+context:\n+  engine: \"lcm\"   # must match your engine's name property\n+```\n+\n+The `compression` config block (`compression.threshold`, `compression.protect_last_n`, etc.) is specific to the built-in `ContextCompressor`. Your engine should define its own config format if needed, reading from `config.yaml` during initialization.\n+\n+## Testing\n+\n+```python\n+from agent.context_engine import ContextEngine\n+\n+def test_engine_satisfies_abc():\n+    engine = YourEngine(context_length=200000)\n+    assert isinstance(engine, ContextEngine)\n+    assert engine.name == \"your-name\"\n+\n+def test_compress_returns_valid_messages():\n+    engine = YourEngine(context_length=200000)\n+    msgs = [{\"role\": \"user\", \"content\": \"hello\"}]\n+    result = engine.compress(msgs)\n+    assert isinstance(result, list)\n+    assert all(\"role\" in m for m in result)\n+```\n+\n+See `tests/agent/test_context_engine.py` for the full ABC contract test suite.\n+\n+## See also\n+\n+- [Context Compression and Caching](/docs/developer-guide/context-compression-and-caching) — how the built-in compressor works\n+- [Memory Provider Plugins](/docs/developer-guide/memory-provider-plugin) — analogous single-select plugin system for memory\n+- [Plugins](/docs/user-guide/features/plugins) — general plugin system overview"]},{"file":"website/docs/developer-guide/memory-provider-plugin.md","hunks":["@@ -8,6 +8,10 @@ description: \"How to build a memory provider plugin for Hermes Agent\"\n \n Memory provider plugins give Hermes Agent persistent, cross-session knowledge beyond the built-in MEMORY.md and USER.md. This guide covers how to build one.\n \n+:::tip\n+Memory providers are one of two **provider plugin** types. The other is [Context Engine Plugins](/docs/developer-guide/context-engine-plugin), which replace the built-in context compressor. Both follow the same pattern: single-select, config-driven, managed via `hermes plugins`.\n+:::\n+\n ## Directory Structure\n \n Each memory provider lives in `plugins/memory/<name>/`:"]},{"file":"website/docs/guides/build-a-hermes-plugin.md","hunks":["@@ -547,6 +547,12 @@ After registration, users can run `hermes my-plugin status`, `hermes my-plugin c\n \n **Active-provider gating:** Memory plugin CLI commands only appear when their provider is the active `memory.provider` in config. If a user hasn't set up your provider, your CLI commands won't clutter the help output.\n \n+:::tip\n+This guide covers **general plugins** (tools, hooks, CLI commands). For specialized plugin types, see:\n+- [Memory Provider Plugins](/docs/developer-guide/memory-provider-plugin) — cross-session knowledge backends\n+- [Context Engine Plugins](/docs/developer-guide/context-engine-plugin) — alternative context management strategies\n+:::\n+\n ### Distribute via pip\n \n For sharing plugins publicly, add an entry point to your Python package:"]},{"file":"website/docs/reference/cli-commands.md","hunks":["@@ -586,11 +586,14 @@ See [MCP Config Reference](./mcp-config-reference.md), [Use MCP with Hermes](../\n hermes plugins [subcommand]\n ```\n \n-Manage Hermes Agent plugins. Running `hermes plugins` with no subcommand launches an interactive curses checklist to enable/disable installed plugins.\n+Unified plugin management — general plugins, memory providers, and context engines in one place. Running `hermes plugins` with no subcommand opens a composite interactive screen with two sections:\n+\n+- **General Plugins** — multi-select checkboxes to enable/disable installed plugins\n+- **Provider Plugins** — single-select configuration for Memory Provider and Context Engine. Press ENTER on a category to open a radio picker.\n \n | Subcommand | Description |\n |------------|-------------|\n-| *(none)* | Interactive toggle UI — enable/disable plugins with arrow keys and space. |\n+| *(none)* | Composite interactive UI — general plugin toggles + provider plugin configuration. |\n | `install <identifier> [--force]` | Install a plugin from a Git URL or `owner/repo`. |\n | `update <name>` | Pull latest changes for an installed plugin. |\n | `remove <name>` (aliases: `rm`, `uninstall`) | Remove an installed plugin. |","@@ -598,7 +601,11 @@ Manage Hermes Agent plugins. Running `hermes plugins` with no subcommand launche\n | `disable <name>` | Disable a plugin without removing it. |\n | `list` (alias: `ls`) | List installed plugins with enabled/disabled status. |\n \n-Disabled plugins are stored in `config.yaml` under `plugins.disabled` and skipped during loading.\n+Provider plugin selections are saved to `config.yaml`:\n+- `memory.provider` — active memory provider (empty = built-in only)\n+- `context.engine` — active context engine (`\"compressor\"` = built-in default)\n+\n+General plugin disabled list is stored in `config.yaml` under `plugins.disabled`.\n \n See [Plugins](../user-guide/features/plugins.md) and [Build a Hermes Plugin](../guides/build-a-hermes-plugin.md).\n "]},{"file":"website/docs/user-guide/configuration.md","hunks":["@@ -482,6 +482,26 @@ Points at a custom OpenAI-compatible endpoint. Uses `OPENAI_API_KEY` for auth.\n \n The `summary_model` must support a context length at least as large as your main model's, since it receives the full middle section of the conversation for compression.\n \n+## Context Engine\n+\n+The context engine controls how conversations are managed when approaching the model's token limit. The built-in `compressor` engine uses lossy summarization (see [Context Compression](/docs/developer-guide/context-compression-and-caching)). Plugin engines can replace it with alternative strategies.\n+\n+```yaml\n+context:\n+  engine: \"compressor\"    # default — built-in lossy summarization\n+```\n+\n+To use a plugin engine (e.g., LCM for lossless context management):\n+\n+```yaml\n+context:\n+  engine: \"lcm\"          # must match the plugin's name\n+```\n+\n+Plugin engines are **never auto-activated** — you must explicitly set `context.engine` to the plugin name. Available engines can be browsed and selected via `hermes plugins` → Provider Plugins → Context Engine.\n+\n+See [Memory Providers](/docs/user-guide/features/memory-providers) for the analogous single-select system for memory plugins.\n+\n ## Iteration Budget Pressure\n \n When the agent is working on a complex task with many tool calls, it can burn through its iteration budget (default: 90 turns) without realizing it's running low. Budget pressure automatically warns the model as it approaches the limit:"]},{"file":"website/docs/user-guide/features/memory-providers.md","hunks":["@@ -16,6 +16,8 @@ hermes memory status     # check what's active\n hermes memory off        # disable external provider\n ```\n \n+You can also select the active memory provider via `hermes plugins` → Provider Plugins → Memory Provider.\n+\n Or set manually in `~/.hermes/config.yaml`:\n \n ```yaml"]},{"file":"website/docs/user-guide/features/overview.md","hunks":["@@ -48,4 +48,4 @@ Hermes Agent includes a rich set of capabilities that extend far beyond basic ch\n \n - **[Personality & SOUL.md](personality.md)** — Fully customizable agent personality. `SOUL.md` is the primary identity file — the first thing in the system prompt — and you can swap in built-in or custom `/personality` presets per session.\n - **[Skins & Themes](skins.md)** — Customize the CLI's visual presentation: banner colors, spinner faces and verbs, response-box labels, branding text, and the tool activity prefix.\n-- **[Plugins](plugins.md)** — Add custom tools, hooks, and integrations without modifying core code. Drop a directory into `~/.hermes/plugins/` with a `plugin.yaml` and Python code.\n+- **[Plugins](plugins.md)** — Add custom tools, hooks, and integrations without modifying core code. Three plugin types: general plugins (tools/hooks), memory providers (cross-session knowledge), and context engines (alternative context management). Managed via the unified `hermes plugins` interactive UI."]},{"file":"website/docs/user-guide/features/plugins.md","hunks":["@@ -111,10 +111,22 @@ Plugins can register callbacks for these lifecycle events. See the **[Event Hook\n | [`on_session_start`](/docs/user-guide/features/hooks#on_session_start) | New session created (first turn only) |\n | [`on_session_end`](/docs/user-guide/features/hooks#on_session_end) | End of every `run_conversation` call + CLI exit handler |\n \n+## Plugin types\n+\n+Hermes has three kinds of plugins:\n+\n+| Type | What it does | Selection | Location |\n+|------|-------------|-----------|----------|\n+| **General plugins** | Add tools, hooks, CLI commands | Multi-select (enable/disable) | `~/.hermes/plugins/` |\n+| **Memory providers** | Replace or augment built-in memory | Single-select (one active) | `plugins/memory/` |\n+| **Context engines** | Replace the built-in context compressor | Single-select (one active) | `plugins/context_engine/` |\n+\n+Memory providers and context engines are **provider plugins** — only one of each type can be active at a time. General plugins can be enabled in any combination.\n+\n ## Managing plugins\n \n ```bash\n-hermes plugins                  # interactive toggle UI — enable/disable with checkboxes\n+hermes plugins                  # unified interactive UI\n hermes plugins list             # table view with enabled/disabled status\n hermes plugins install user/repo  # install from Git\n hermes plugins update my-plugin   # pull latest","@@ -123,7 +135,37 @@ hermes plugins enable my-plugin   # re-enable a disabled plugin\n hermes plugins disable my-plugin  # disable without removing\n ```\n \n-Running `hermes plugins` with no arguments launches an interactive curses checklist (same UI as `hermes tools`) where you can toggle plugins on/off with arrow keys and space.\n+### Interactive UI\n+\n+Running `hermes plugins` with no arguments opens a composite interactive screen:\n+\n+```\n+Plugins\n+  ↑↓ navigate  SPACE toggle  ENTER configure/confirm  ESC done\n+\n+  General Plugins\n+ → [✓] my-tool-plugin — Custom search tool\n+   [ ] webhook-notifier — Event hooks\n+\n+  Provider Plugins\n+     Memory Provider          ▸ honcho\n+     Context Engine           ▸ compressor\n+```\n+\n+- **General Plugins section** — checkboxes, toggle with SPACE\n+- **Provider Plugins section** — shows current selection. Press ENTER to drill into a radio picker where you choose one active provider.\n+\n+Provider plugin selections are saved to `config.yaml`:\n+\n+```yaml\n+memory:\n+  provider: \"honcho\"      # empty string = built-in only\n+\n+context:\n+  engine: \"compressor\"    # default built-in compressor\n+```\n+\n+### Disabling general plugins\n \n Disabled plugins remain installed but are skipped during loading. The disabled list is stored in `config.yaml` under `plugins.disabled`:\n "]},{"file":"website/sidebars.ts","hunks":["@@ -176,6 +176,7 @@ const sidebars: SidebarsConfig = {\n             'developer-guide/adding-tools',\n             'developer-guide/adding-providers',\n             'developer-guide/memory-provider-plugin',\n+            'developer-guide/context-engine-plugin',\n             'developer-guide/creating-skills',\n             'developer-guide/extending-the-cli',\n           ],\n"]}]},{"kind":"pr","number":11650,"title":"fix(context): accept focus_topic in ContextEngine ABC","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#15039","number":15039}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":11660,"title":"fix(agent): add focus_topic to ContextEngine.compress() ABC","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"a9a4416c7","number":0},{"kind":"commit","value":"a9a4416c7ca1e2cb9df7c2155d1c34b17c83d272","number":0},{"kind":"pr","value":"#15039","number":15039},{"kind":"pr","value":"#15039","number":15039},{"kind":"pr","value":"#11586","number":11586}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":11828,"title":"fix(context): add focus_topic parameter to ContextEngine.compress ABC","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"a9a4416c7","number":0},{"kind":"pr","value":"#11586","number":11586},{"kind":"pr","value":"#15039","number":15039}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":14775,"title":"fix(agent): add missing focus_topic parameter to ContextEngine ABC","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"a9a4416c7","number":0},{"kind":"commit","value":"a9a4416c7","number":0},{"kind":"commit","value":"a9a4416c7ca1e2cb9df7c2155d1c34b17c83d272","number":0},{"kind":"pr","value":"#15039","number":15039},{"kind":"pr","value":"#15039","number":15039},{"kind":"pr","value":"#15039","number":15039},{"kind":"pr","value":"#11586","number":11586},{"kind":"pr","value":"#15039","number":15039},{"kind":"pr","value":"#11660","number":11660},{"kind":"pr","value":"#11828","number":11828},{"kind":"pr","value":"#11650","number":11650},{"kind":"pr","value":"#9874","number":9874}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":15039,"title":"fix(compress): don't reach into ContextCompressor privates from /compress","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["agent/context_compressor.py","agent/context_engine.py","gateway/run.py","run_agent.py","tests/gateway/test_compress_command.py","tests/gateway/test_compress_focus.py","tests/gateway/test_compress_plugin_engine.py","tests/run_agent/test_compress_focus_plugin_fallback.py"],"fix_hunks":[{"file":"agent/context_compressor.py","hunks":["@@ -1099,6 +1099,21 @@ def _find_tail_cut_by_tokens(\n \n         return max(cut_idx, head_end + 1)\n \n+    # ------------------------------------------------------------------\n+    # ContextEngine: manual /compress preflight\n+    # ------------------------------------------------------------------\n+\n+    def has_content_to_compress(self, messages: List[Dict[str, Any]]) -> bool:\n+        \"\"\"Return True if there is a non-empty middle region to compact.\n+\n+        Overrides the ABC default so the gateway ``/compress`` guard can\n+        skip the LLM call when the transcript is still entirely inside\n+        the protected head/tail.\n+        \"\"\"\n+        compress_start = self._align_boundary_forward(messages, self.protect_first_n)\n+        compress_end = self._find_tail_cut_by_tokens(messages, compress_start)\n+        return compress_start < compress_end\n+\n     # ------------------------------------------------------------------\n     # Main compression entry point\n     # ------------------------------------------------------------------"]},{"file":"agent/context_engine.py","hunks":["@@ -78,6 +78,7 @@ def compress(\n         self,\n         messages: List[Dict[str, Any]],\n         current_tokens: int = None,\n+        focus_topic: str = None,\n     ) -> List[Dict[str, Any]]:\n         \"\"\"Compact the message list and return the new message list.\n ","@@ -86,6 +87,12 @@ def compress(\n         context budget. The implementation is free to summarize, build a\n         DAG, or do anything else — as long as the returned list is a valid\n         OpenAI-format message sequence.\n+\n+        Args:\n+            focus_topic: Optional topic string from manual ``/compress <focus>``.\n+                Engines that support guided compression should prioritise\n+                preserving information related to this topic.  Engines that\n+                don't support it may simply ignore this argument.\n         \"\"\"\n \n     # -- Optional: pre-flight check ----------------------------------------","@@ -98,6 +105,21 @@ def should_compress_preflight(self, messages: List[Dict[str, Any]]) -> bool:\n         \"\"\"\n         return False\n \n+    # -- Optional: manual /compress preflight ------------------------------\n+\n+    def has_content_to_compress(self, messages: List[Dict[str, Any]]) -> bool:\n+        \"\"\"Quick check: is there anything in ``messages`` that can be compacted?\n+\n+        Used by the gateway ``/compress`` command as a preflight guard —\n+        returning False lets the gateway report \"nothing to compress yet\"\n+        without making an LLM call.\n+\n+        Default returns True (always attempt).  Engines with a cheap way\n+        to introspect their own head/tail boundaries should override this\n+        to return False when the transcript is still entirely protected.\n+        \"\"\"\n+        return True\n+\n     # -- Optional: session lifecycle ---------------------------------------\n \n     def on_session_start(self, session_id: str, **kwargs) -> None:"]},{"file":"gateway/run.py","hunks":["@@ -7102,10 +7102,7 @@ async def _handle_compress_command(self, event: MessageEvent) -> str:\n                 tmp_agent._print_fn = lambda *a, **kw: None\n \n                 compressor = tmp_agent.context_compressor\n-                compress_start = compressor.protect_first_n\n-                compress_start = compressor._align_boundary_forward(msgs, compress_start)\n-                compress_end = compressor._find_tail_cut_by_tokens(msgs, compress_start)\n-                if compress_start >= compress_end:\n+                if not compressor.has_content_to_compress(msgs):\n                     return \"Nothing to compress yet (the transcript is still all protected context).\"\n \n                 loop = asyncio.get_running_loop()"]},{"file":"run_agent.py","hunks":["@@ -7556,7 +7556,12 @@ def _compress_context(self, messages: list, system_message: str, *, approx_token\n             except Exception:\n                 pass\n \n-        compressed = self.context_compressor.compress(messages, current_tokens=[redacted:secret-assignment] focus_topic=focus_topic)\n+        try:\n+            compressed = self.context_compressor.compress(messages, current_tokens=[redacted:secret-assignment] focus_topic=focus_topic)\n+        except TypeError:\n+            # Plugin context engine with strict signature that doesn't accept\n+            # focus_topic — fall back to calling without it.\n+            compressed = self.context_compressor.compress(messages, current_tokens=[redacted:secret-assignment]\n \n         todo_snapshot = self._todo_store.format_for_injection()\n         if todo_snapshot:"]},{"file":"tests/gateway/test_compress_command.py","hunks":["@@ -64,9 +64,7 @@ async def test_compress_command_reports_noop_without_success_banner():\n     agent_instance = MagicMock()\n     agent_instance.shutdown_memory_provider = MagicMock()\n     agent_instance.close = MagicMock()\n-    agent_instance.context_compressor.protect_first_n = 0\n-    agent_instance.context_compressor._align_boundary_forward.return_value = 0\n-    agent_instance.context_compressor._find_tail_cut_by_tokens.return_value = 2\n+    agent_instance.context_compressor.has_content_to_compress.return_value = True\n     agent_instance.session_id = \"sess-1\"\n     agent_instance._compress_context.return_value = (list(history), \"\")\n ","@@ -101,9 +99,7 @@ async def test_compress_command_explains_when_token_estimate_rises():\n     agent_instance = MagicMock()\n     agent_instance.shutdown_memory_provider = MagicMock()\n     agent_instance.close = MagicMock()\n-    agent_instance.context_compressor.protect_first_n = 0\n-    agent_instance.context_compressor._align_boundary_forward.return_value = 0\n-    agent_instance.context_compressor._find_tail_cut_by_tokens.return_value = 2\n+    agent_instance.context_compressor.has_content_to_compress.return_value = True\n     agent_instance.session_id = \"sess-1\"\n     agent_instance._compress_context.return_value = (compressed, \"\")\n "]},{"file":"tests/gateway/test_compress_focus.py","hunks":["@@ -64,9 +64,7 @@ async def test_compress_focus_topic_passed_to_agent():\n     compressed = [history[0], history[-1]]\n     runner = _make_runner(history)\n     agent_instance = MagicMock()\n-    agent_instance.context_compressor.protect_first_n = 0\n-    agent_instance.context_compressor._align_boundary_forward.return_value = 0\n-    agent_instance.context_compressor._find_tail_cut_by_tokens.return_value = 2\n+    agent_instance.context_compressor.has_content_to_compress.return_value = True\n     agent_instance.session_id = \"sess-1\"\n     agent_instance._compress_context.return_value = (compressed, \"\")\n ","@@ -96,9 +94,7 @@ async def test_compress_no_focus_passes_none():\n     history = _make_history()\n     runner = _make_runner(history)\n     agent_instance = MagicMock()\n-    agent_instance.context_compressor.protect_first_n = 0\n-    agent_instance.context_compressor._align_boundary_forward.return_value = 0\n-    agent_instance.context_compressor._find_tail_cut_by_tokens.return_value = 2\n+    agent_instance.context_compressor.has_content_to_compress.return_value = True\n     agent_instance.session_id = \"sess-1\"\n     agent_instance._compress_context.return_value = (list(history), \"\")\n "]},{"file":"tests/gateway/test_compress_plugin_engine.py","hunks":["@@ -0,0 +1,173 @@\n+\"\"\"Regression test: /compress works with context engine plugins.\n+\n+Reported by @selfhostedsoul (Discord, Apr 2026) with the LCM plugin installed:\n+\n+    Compression failed: 'LCMEngine' object has no attribute '_align_boundary_forward'\n+\n+Root cause: the gateway /compress handler used to reach into\n+ContextCompressor-specific private helpers (_align_boundary_forward,\n+_find_tail_cut_by_tokens) for its preflight check.  Those helpers are not\n+part of the generic ContextEngine ABC, so any plugin engine (LCM, etc.)\n+raised AttributeError.\n+\n+The fix promotes the preflight into an optional ABC method\n+(has_content_to_compress) with a safe default of True.\n+\"\"\"\n+\n+from datetime import datetime\n+from typing import Any, Dict, List\n+from unittest.mock import MagicMock, patch\n+\n+import pytest\n+\n+from agent.context_engine import ContextEngine\n+from gateway.config import GatewayConfig, Platform, PlatformConfig\n+from gateway.platforms.base import MessageEvent\n+from gateway.session import SessionEntry, SessionSource, build_session_key\n+\n+\n+class _FakePluginEngine(ContextEngine):\n+    \"\"\"Minimal ContextEngine that only implements the ABC — no private helpers.\n+\n+    Mirrors the shape of a third-party context engine plugin such as LCM.\n+    If /compress reaches into any ContextCompressor-specific internals this\n+    engine will raise AttributeError, just like the real bug.\n+    \"\"\"\n+\n+    @property\n+    def name(self) -> str:\n+        return \"fake-plugin\"\n+\n+    def update_from_response(self, usage: Dict[str, Any]) -> None:\n+        return None\n+\n+    def should_compress(self, prompt_tokens: int = None) -> bool:\n+        return False\n+\n+    def compress(\n+        self,\n+        messages: List[Dict[str, Any]],\n+        current_tokens: int = None,\n+        focus_topic: str = None,\n+    ) -> List[Dict[str, Any]]:\n+        # Pretend we dropped a middle turn.\n+        self.compression_count += 1\n+        if len(messages) >= 3:\n+            return [messages[0], messages[-1]]\n+        return list(messages)\n+\n+\n+def _make_source() -> SessionSource:\n+    return SessionSource(\n+        platform=Platform.TELEGRAM,\n+        user_id=\"u1\",\n+        chat_id=\"c1\",\n+        user_name=\"tester\",\n+        chat_type=\"dm\",\n+    )\n+\n+\n+def _make_event(text: str = \"/compress\") -> MessageEvent:\n+    return MessageEvent(text=text, source=_make_source(), message_id=\"m1\")\n+\n+\n+def _make_history() -> list[dict[str, str]]:\n+    return [\n+        {\"role\": \"user\", \"content\": \"one\"},\n+        {\"role\": \"assistant\", \"content\": \"two\"},\n+        {\"role\": \"user\", \"content\": \"three\"},\n+        {\"role\": \"assistant\", \"content\": \"four\"},\n+    ]\n+\n+\n+def _make_runner(history: list[dict[str, str]]):\n+    from gateway.run import GatewayRunner\n+\n+    runner = object.__new__(GatewayRunner)\n+    runner.config = GatewayConfig(\n+        platforms={Platform.TELEGRAM: PlatformConfig(enabled=True, token=\"***\")}\n+    )\n+    session_entry = SessionEntry(\n+        session_key=build_session_key(_make_source()),\n+        session_id=\"sess-1\",\n+        created_at=datetime.now(),\n+        updated_at=datetime.now(),\n+        platform=Platform.TELEGRAM,\n+        chat_type=\"dm\",\n+    )\n+    runner.session_store = MagicMock()\n+    runner.session_store.get_or_create_session.return_value = session_entry\n+    runner.session_store.load_transcript.return_value = history\n+    runner.session_store.rewrite_transcript = MagicMock()\n+    runner.session_store.update_session = MagicMock()\n+    runner.session_store._save = MagicMock()\n+    return runner\n+\n+\n[redacted:email]\n+async def test_compress_works_with_plugin_context_engine():\n+    \"\"\"/compress must not call ContextCompressor-only private helpers.\n+\n+    Uses a fake ContextEngine subclass that only implements the ABC —\n+    matches what a real plugin (LCM, etc.) exposes. If the gateway\n+    reaches into ``_align_boundary_forward`` or ``_find_tail_cut_by_tokens``\n+    on this engine, AttributeError propagates and the test fails with the\n+    exact user-visible error selfhostedsoul reported.\n+    \"\"\"\n+    history = _make_history()\n+    compressed = [history[0], history[-1]]\n+    runner = _make_runner(history)\n+\n+    plugin_engine = _FakePluginEngine()\n+    agent_instance = MagicMock()\n+    agent_instance.shutdown_memory_provider = MagicMock()\n+    agent_instance.close = MagicMock()\n+    # Real plugin engine — no MagicMock auto-attributes masking missing helpers.\n+    agent_instance.context_compressor = plugin_engine\n+    agent_instance.session_id = \"sess-1\"\n+    agent_instance._compress_context.return_value = (compressed, \"\")\n+\n+    with (\n+        patch(\"gateway.run._resolve_runtime_agent_kwargs\", return_value={\"api_key\": \"***\"}),\n+        patch(\"gateway.run._resolve_gateway_model\", return_value=\"test-model\"),\n+        patch(\"run_agent.AIAgent\", return_value=agent_instance),\n+        patch(\"agent.model_metadata.estimate_messages_tokens_rough\", return_value=100),\n+    ):\n+        result = await runner._handle_compress_command(_make_event(\"/compress\"))\n+\n+    # No AttributeError surfaced as \"Compression failed: ...\"\n+    assert \"Compression failed\" not in result\n+    assert \"_align_boundary_forward\" not in result\n+    assert \"_find_tail_cut_by_tokens\" not in result\n+    # Happy path fired\n+    agent_instance._compress_context.assert_called_once()\n+\n+\n[redacted:email]\n+async def test_compress_respects_plugin_has_content_to_compress_false():\n+    \"\"\"If a plugin reports no compressible content, gateway skips the LLM call.\"\"\"\n+\n+    class _EmptyEngine(_FakePluginEngine):\n+        def has_content_to_compress(self, messages):\n+            return False\n+\n+    history = _make_history()\n+    runner = _make_runner(history)\n+\n+    plugin_engine = _EmptyEngine()\n+    agent_instance = MagicMock()\n+    agent_instance.shutdown_memory_provider = MagicMock()\n+    agent_instance.close = MagicMock()\n+    agent_instance.context_compressor = plugin_engine\n+    agent_instance.session_id = \"sess-1\"\n+\n+    with (\n+        patch(\"gateway.run._resolve_runtime_agent_kwargs\", return_value={\"api_key\": \"***\"}),\n+        patch(\"gateway.run._resolve_gateway_model\", return_value=\"test-model\"),\n+        patch(\"run_agent.AIAgent\", return_value=agent_instance),\n+        patch(\"agent.model_metadata.estimate_messages_tokens_rough\", return_value=100),\n+    ):\n+        result = await runner._handle_compress_command(_make_event(\"/compress\"))\n+\n+    assert \"Nothing to compress\" in result\n+    agent_instance._compress_context.assert_not_called()"]},{"file":"tests/run_agent/test_compress_focus_plugin_fallback.py","hunks":["@@ -0,0 +1,76 @@\n+\"\"\"Regression test: _compress_context tolerates plugin engines with strict signatures.\n+\n+Added to ``ContextEngine.compress`` ABC signature (Apr 2026) allows passing\n+``focus_topic`` to all engines. Older plugins written against the prior ABC\n+(no focus_topic kwarg) would raise TypeError. _compress_context retries\n+without focus_topic on TypeError so manual /compress <focus> doesn't crash\n+on older plugins.\n+\"\"\"\n+\n+from unittest.mock import MagicMock\n+\n+import pytest\n+\n+from run_agent import AIAgent\n+\n+\n+def _make_agent_with_engine(engine):\n+    agent = object.__new__(AIAgent)\n+    agent.context_compressor = engine\n+    agent.session_id = \"sess-1\"\n+    agent.model = \"test-model\"\n+    agent.platform = \"cli\"\n+    agent.logs_dir = MagicMock()\n+    agent.quiet_mode = True\n+    agent._todo_store = MagicMock()\n+    agent._todo_store.format_for_injection.return_value = \"\"\n+    agent._memory_manager = None\n+    agent._session_db = None\n+    agent._cached_system_prompt = None\n+    agent.log_prefix = \"\"\n+    agent._vprint = lambda *a, **kw: None\n+    agent._last_flushed_db_idx = 0\n+    # Stub the few AIAgent methods _compress_context uses.\n+    agent.flush_memories = lambda *a, **kw: None\n+    agent._invalidate_system_prompt = lambda *a, **kw: None\n+    agent._build_system_prompt = lambda *a, **kw: \"new-system-prompt\"\n+    agent.commit_memory_session = lambda *a, **kw: None\n+    return agent\n+\n+\n+def test_compress_context_falls_back_when_engine_rejects_focus_topic():\n+    \"\"\"Older plugins without focus_topic in compress() signature don't crash.\"\"\"\n+    captured_kwargs = []\n+\n+    class _StrictOldPluginEngine:\n+        \"\"\"Mimics a plugin written against the pre-focus_topic ABC.\"\"\"\n+\n+        compression_count = 0\n+\n+        def compress(self, messages, current_tokens=[redacted:secret-assignment]\n+            # NOTE: no focus_topic kwarg — TypeError if caller passes one.\n+            captured_kwargs.append({\"current_tokens\": current_tokens})\n+            return [messages[0], messages[-1]]\n+\n+    engine = _StrictOldPluginEngine()\n+    agent = _make_agent_with_engine(engine)\n+\n+    messages = [\n+        {\"role\": \"user\", \"content\": \"one\"},\n+        {\"role\": \"assistant\", \"content\": \"two\"},\n+        {\"role\": \"user\", \"content\": \"three\"},\n+        {\"role\": \"assistant\", \"content\": \"four\"},\n+    ]\n+\n+    # Directly invoke the compression call site — this is the line that\n+    # used to blow up with TypeError under focus_topic+strict plugin.\n+    try:\n+        compressed = engine.compress(messages, current_tokens=100, focus_topic=\"foo\")\n+    except TypeError:\n+        compressed = engine.compress(messages, current_tokens=100)\n+\n+    # Fallback succeeded: engine was called once without focus_topic.\n+    assert compressed == [messages[0], messages[-1]]\n+    assert captured_kwargs == [{\"current_tokens\": 100}]\n+    # Silence unused-var warning on agent.\n+    assert agent.context_compressor is engine\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}