# NousResearch/hermes-agent · ContextEngine ABC design: pluggability, hooks, parameters

## Open now

- Nothing open on GitHub.

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.

## TL;DR

- 11 member(s): 0 open issue(s), 4 closed issue(s), 0 open PR(s), 2 merged PR(s), 5 PR(s) closed without merging.
- A fix was merged to main in PR #7464. Verify it addresses your case.
- **Recommended:** read PR #7464 (and any competing PRs below) before filing. Confirm the merged change covers your symptom.

## Maintainer resolution verdict

- Internal-tracker verdict over this cluster's issue members: `partially_resolved` · deciding PR(s): `pr-7464`. This is distinct from the fix status above. Weigh it yourself.

## How this cluster was grouped

- Link evidence (`STRUCTURAL_PARTIAL`): some members are connected by structural evidence (shared changed files, or a closing/superseded reference); the rest are joined by an automated relevance judgement over their text.
- Members outside that structural core may belong to a different underlying problem.
- Pairwise link density `0.15`: the share of member pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. `1.00` means every pair is linked.

## Closed / duplicate issues

- #5701: Proposal: Pluggable context engines — enabling LCM as a plugin (like OpenClaw's lossless-claw)
- #11586: Bug: ContextEngine ABC compress() missing focus_topic parameter causes TypeError
- #23837: \[Feature\] ContextEngine: per-turn message observation hook (currently requires abusing compress() as a backdoor)
- #36765: \[RFC\] Treat "context selection/routing" as a first-class ContextEngine concern, distinct from compression

## Prior & competing PRs

- **#7464: feat: pluggable context engine slot + unified hermes plugins UI**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `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` (+2 more)
  - fix hunks:
  - `agent/context_compressor.py`

    ```diff
    @@ -18,6 +18,7 @@
     from typing import Any, Dict, List, Optional
     
     from agent.auxiliary_client import call_llm
    +from agent.context_engine import ContextEngine
     from agent.model_metadata import (
         get_model_context_length,
         estimate_messages_tokens_rough,
    ```

    ```diff
    @@ -50,8 +51,8 @@
     _SUMMARY_FAILURE_COOLDOWN_SECONDS = 600
     
     
    -class ContextCompressor:
    -    """Compresses conversation context when approaching the model's context limit.
    +class ContextCompressor(ContextEngine):
    +    """Default context engine — compresses conversation context via lossy summarization.
     
         Algorithm:
           1. Prune old tool results (cheap, no LLM call)
    ```

    ```diff
    @@ -61,6 +62,33 @@ class ContextCompressor:
           5. On subsequent compactions, iteratively update the previous summary
         """
     
    +    @property
    +    def name(self) -> str:
    +        return "compressor"
    +
    +    def on_session_reset(self) -> None:
    +        """Reset all per-session state for /new or /reset."""
    +        super().on_session_reset()
    +        self._context_probed = False
    +        self._context_probe_persistable = False
    +        self._previous_summary = None
    +
    +    def update_model(
    +        self,
    +        model: str,
    +        context_length: int,
    +        base_url: str = "",
    +        api_key: str = "",
    +        provider: str = "",
    +    ) -> None:
    +        """Update model info after a model switch or fallback activation."""
    +        self.model = model
    +        self.base_url = base_url
    +        self.api_key = [redacted:secret-assignment]
    +        self.provider = provider
    +        self.context_length = context_length
    +        self.threshold_tokens = [redacted:secret-assignment] * self.threshold_percent)
    +
         def __init__(
             self,
             model: str,
    ```
  - `agent/context_engine.py`

    ```diff
    @@ -0,0 +1,184 @@
    +"""Abstract base class for pluggable context engines.
    +
    +A context engine controls how conversation context is managed when
    +approaching the model's token limit. The built-in ContextCompressor
    +is the default implementation. Third-party engines (e.g. LCM) can
    +replace it via the plugin system or by being placed in the
    +``plugins/context_engine/<name>/`` directory.
    +
    +Selection is config-driven: ``context.engine`` in config.yaml.
    +Default is ``"compressor"`` (the built-in). Only one engine is active.
    +
    +The engine is responsible for:
    +  - Deciding when compaction should fire
    +  - Performing compaction (summarization, DAG construction, etc.)
    +  - Optionally exposing tools the agent can call (e.g. lcm_grep)
    +  - Tracking token usage from API responses
    +
    +Lifecycle:
    +  1. Engine is instantiated and registered (plugin register() or default)
    +  2. on_session_start() called when a conversation begins
    +  3. update_from_response() called after each API response with usage data
    +  4. should_compress() checked after each turn
    +  5. compress() called when should_compress() returns True
    +  6. on_session_end() called at real session boundaries (CLI exit, /reset,
    +     gateway session expiry) — NOT per-turn
    +"""
    +
    +from abc import ABC, abstractmethod
    +from typing import Any, Dict, List, Optional
    +
    +
    +class ContextEngine(ABC):
    +    """Base class all context engines must implement."""
    +
    +    # -- Identity ----------------------------------------------------------
    +
    +    @property
    +    @abstractmethod
    +    def name(self) -> str:
    +        """Short identifier (e.g. 'compressor', 'lcm')."""
    +
    +    # -- Token state (read by run_agent.py for display/logging) ------------
    +    #
    +    # Engines MUST maintain these. run_agent.py reads them directly.
    +
    +    last_prompt_tokens: int = 0
    +    last_completion_tokens: int = 0
    +    last_total_tokens: int = 0
    +    threshold_tokens: int = 0
    +    context_length: int = 0
    +    compression_count: int = 0
    +
    +    # -- Compaction parameters (read by run_agent.py for preflight) --------
    +    #
    +    # These control the preflight compression check.  Subclasses may
    +    # override via __init__ or property; defaults are sensible for most
    +    # engines.
    +
    +    threshold_percent: float = 0.75
    +    protect_first_n: int = 3
    +    protect_last_n: int = 6
    +
    +    # -- Core interface ----------------------------------------------------
    +
    +    @abstractmethod
    +    def update_from_response(self, usage: Dict[str, Any]) -> None:
    +        """Update tracked token usage from an API response.
    +
    +        Called after every LLM call with the usage dict from the response.
    +        """
    +
    +    @abstractmethod
    +    def should_compress(self, prompt_tokens: int = None) -> bool:
    +        """Return True if compaction should fire this turn."""
    +
    +    @abstractmethod
    +    def compress(
    +        self,
    +        messages: List[Dict[str, Any]],
    +        current_tokens: int = None,
    +    ) -> List[Dict[str, Any]]:
    +        """Compact the message list and return the new message list.
    +
    +        This is the main entry point. The engine receives the full message
    +        list and returns a (possibly shorter) list that fits within the
    +        context budget. The implementation is free to summarize, build a
    +        DAG, or do anything else — as long as the returned list is a valid
    +        OpenAI-format message sequence.
    +        """
    +
    +    # -- Optional: pre-flight check ----------------------------------------
    +
    +    def should_compress_preflight(self, messages: List[Dict[str, Any]]) -> bool:
    +        """Quick rough check before the API call (no real token count yet).
    +
    +        Default returns False (skip pre-flight). Override if your engine
    +        can do a cheap estimate.
    +        """
    +        return False
    +
    +    # -- Optional: session lifecycle ---------------------------------------
    +
    +    def on_session_start(self, session_id: str, **kwargs) -> None:
    +        """Called when a new conversation session begins.
    +
    +        Use this to load persisted state (DAG, store) for the session.
    +        kwargs may include hermes_home, platform, model, etc.
    +        """
    +
    +    def on_session_end(self, session_id: str, messages: List[Dict[str, Any]]) -> None:
    +        """Called at real session boundaries (CLI exit, /reset, gateway expiry).
    +
    +        Use this to flush state, close DB connections, etc.
    +        NOT called per-turn — only when the session truly ends.
    +        """
    +
    +    def on_session_reset(self) -> None:
    +        """Called on /new or /reset. Reset per-session state.
    +
    +        Default resets compression_count and token tracking.
    +        """
    +        self.last_prompt_tokens = 0
    +        self.last_completion_tokens = 0
    +        self.last_total_tokens = 0
    +        self.compression_count = 0
    +
    +    # -- Optional: tools ---------------------------------------------------
    +
    +    def get_tool_schemas(self) -> List[Dict[str, Any]]:
    +        """Return tool schemas this engine provides to the agent.
    +
    +        Default returns empty list (no tools). LCM would return schemas
    +        for lcm_grep, lcm_describe, lcm_expand here.
    +        """
    +        return []
    +
    +    def handle_tool_call(self, name: str, args: Dict[str, Any], **kwargs) -> str:
    +        """Handle a tool call from the agent.
    +
    +        Only called for tool names returned by get_tool_schemas().
    +        Must return a JSON string.
    +
    +        kwargs may include:
    +          messages: the current in-memory message list (for live ingestion)
    +        """
    +        import json
    +        return json.dumps({"error": f"Unknown context engine tool: {name}"})
    +
    +    # -- Optional: status / display ----------------------------------------
    +
    +    def get_status(self) -> Dict[str, Any]:
    +        """Return status dict for display/logging.
    +
    +        Default returns the standard fields run_agent.py expects.
    +        """
    +        return {
    +            "last_prompt_tokens": self.last_prompt_tokens,
    +            "threshold_tokens": self.threshold_tokens,
    +            "context_length": self.context_length,
    +            "usage_percent": (
    +                min(100, self.last_prompt_tokens / self.context_length * 100)
    +                if self.context_length else 0
    +            ),
    +            "compression_count": self.compression_count,
    +        }
    +
    +    # -- Optional: model switch support ------------------------------------
    +
    +    def update_model(
    +        self,
    +        model: str,
    +        context_length: int,
    +        base_url: str = "",
    +        api_key: str = "",
    +        provider: str = "",
    +    ) -> None:
    +        """Called when the user switches models or on fallback activation.
    +
    +        Default updates context_length and recalculates threshold_tokens
    +        from threshold_percent. Override if your engine needs more
    +        (e.g. recalculate DAG budgets, switch summary models).
    +        """
    +        self.context_length = context_length
    +        self.threshold_tokens = [redacted:secret-assignment] * self.threshold_percent)
    ```
  - `hermes_cli/config.py`

    ```diff
    @@ -504,6 +504,16 @@ def _ensure_hermes_home_managed(home: Path):
             "max_ms": 2500,
         },
         
    +    # Context engine -- controls how the context window is managed when
    +    # approaching the model's token limit.
    +    # "compressor" = built-in lossy summarization (default).
    +    # Set to a plugin name to activate an alternative engine (e.g. "lcm"
    +    # for Lossless Context Management).  The engine must be installed as
    +    # a plugin in plugins/context_engine/<name>/ or ~/.hermes/plugins/.
    +    "context": {
    +        "engine": "compressor",
    +    },
    +
         # Persistent memory -- bounded curated memory injected into system prompt
         "memory": {
             "memory_enabled": True,
    ```

    ```diff
    @@ -1450,7 +1460,7 @@ def check_config_version() -> Tuple[int, int]:
         "_config_version", "model", "providers", "fallback_model",
         "fallback_providers", "credential_pool_strategies", "toolsets",
         "agent", "terminal", "display", "compression", "delegation",
    -    "auxiliary", "custom_providers", "memory", "gateway",
    +    "auxiliary", "custom_providers", "context", "memory", "gateway",
     }
     
     # Valid fields inside a custom_providers list entry
    ```
  - `hermes_cli/curses_ui.py`

    ```diff
    @@ -160,6 +160,133 @@ def _draw(stdscr):
             return _numbered_fallback(title, items, selected, cancel_returns, status_fn)
     
     
    +def curses_radiolist(
    +    title: str,
    +    items: List[str],
    +    selected: int = 0,
    +    *,
    +    cancel_returns: int | None = None,
    +) -> int:
    +    """Curses single-select radio list. Returns the selected index.
    +
    +    Args:
    +        title: Header line displayed above the list.
    +        items: Display labels for each row.
    +        selected: Index that starts selected (pre-selected).
    +        cancel_returns: Returned on ESC/q. Defaults to the original *selected*.
    +    """
    +    if cancel_returns is None:
    +        cancel_returns = selected
    +
    +    if not sys.stdin.isatty():
    +        return cancel_returns
    +
    +    try:
    +        import curses
    +        result_holder: list = [None]
    +
    +        def _draw(stdscr):
    +            curses.curs_set(0)
    +            if curses.has_colors():
    +                curses.start_color()
    +                curses.use_default_colors()
    +                curses.init_pair(1, curses.COLOR_GREEN, -1)
    +                curses.init_pair(2, curses.COLOR_YELLOW, -1)
    +            cursor = selected
    +            scroll_offset = 0
    +
    +            while True:
    +                stdscr.clear()
    +                max_y, max_x = stdscr.getmaxyx()
    +
    +                # Header
    +                try:
    +                    hattr = curses.A_BOLD
    +                    if curses.has_colors():
    +                        hattr |= curses.color_pair(2)
    +                    stdscr.addnstr(0, 0, title, max_x - 1, hattr)
    +                    stdscr.addnstr(
    +                        1, 0,
    +                        "  \u2191\u2193 navigate  ENTER/SPACE select  ESC cancel",
    +                        max_x - 1, curses.A_DIM,
    +                    )
    +                except curses.error:
    +                    pass
    +
    +                # Scrollable item list
    +                visible_rows = max_y - 4
    +                if cursor < scroll_offset:
    +                    scroll_offset = cursor
    +                elif cursor >= scroll_offset + visible_rows:
    +                    scroll_offset = cursor - visible_rows + 1
    +
    +                for draw_i, i in enumerate(
    +                    range(scroll_offset, min(len(items), scroll_offset + visible_rows))
    +                ):
    +                    y = draw_i + 3
    +                    if y >= max_y - 1:
    +                        break
    +                    radio = "\u25cf" if i == selected else "\u25cb"
    +                    arrow = "\u2192" if i == cursor else " "
    +                    line = f" {arrow} ({radio}) {items[i]}"
    +                    attr = curses.A_NORMAL
    +                    if i == cursor:
    +                        attr = curses.A_BOLD
    +                        if curses.has_colors():
    +                            attr |= curses.color_pair(1)
    +                    try:
    +                        stdscr.addnstr(y, 0, line, max_x - 1, attr)
    +                    except curses.error:
    +                        pass
    +
    +                stdscr.refresh()
    +                key = stdscr.getch()
    +
    +                if key in (curses.KEY_UP, ord("k")):
    +                    cursor = (cursor - 1) % len(items)
    +                elif key in (curses.KEY_DOWN, ord("j")):
    +                    cursor = (cursor + 1) % len(items)
    +                elif key in (ord(" "), curses.KEY_ENTER, 10, 13):
    +                    result_holder[0] = cursor
    +                    return
    +                elif key in (27, ord("q")):
    +                    result_holder[0] = cancel_returns
    +                    return
    +
    +        curses.wrapper(_draw)
    +        flush_stdin()
    +        return result_holder[0] if result_holder[0] is not None else cancel_returns
    +
    +    except Exception:
    +        return _radio_numbered_fallback(title, items, selected, cancel_returns)
    +
    +
    +def _radio_numbered_fallback(
    +    title: str,
    +    items: List[str],
    +    selected: int,
    +    cancel_returns: int,
    +) -> int:
    +    """Text-based numbered fallback for radio selection."""
    +    print(color(f"\n  {title}", Colors.YELLOW))
    +    print(color("  Select by number, Enter to confirm.\n", Colors.DIM))
    +
    +    for i, label in enumerate(items):
    +        marker = color("(\u25cf)", Colors.GREEN) if i == selected else "(\u25cb)"
    +        print(f"  {marker} {i + 1:>2}. {label}")
    +    print()
    +    try:
    +        val = input(color(f"  Choice [default {selected + 1}]: ", Colors.DIM)).strip()
    +        if not val:
    +            return selected
    +        idx = int(val) - 1
    +        if 0 <= idx < len(items):
    +            return idx
    +        return selected
    +    except (ValueError, KeyboardInterrupt, EOFError):
    +        return cancel_returns
    +
    +
     def _numbered_fallback(
         title: str,
         items: List[str],
    ```
  - `hermes_cli/plugins.py`

    ```diff
    @@ -201,8 +201,7 @@ def register_cli_command(
     
             The *setup_fn* receives an argparse subparser and should add any
             arguments/sub-subparsers.  If *handler_fn* is provided it is set
    -        as the default dispatch function via ``set_defaults(func=...)``.
    -        """
    +        as the default dispatch function via ``set_defaults(func=...)``."""
             self._manager._cli_commands[name] = {
                 "name": name,
                 "help": help,
    ```

    ```diff
    @@ -213,6 +212,38 @@ def register_cli_command(
             }
             logger.debug("Plugin %s registered CLI command: %s", self.manifest.name, name)
     
    +    # -- context engine registration -----------------------------------------
    +
    +    def register_context_engine(self, engine) -> None:
    +        """Register a context engine to replace the built-in ContextCompressor.
    +
    +        Only one context engine plugin is allowed. If a second plugin tries
    +        to register one, it is rejected with a warning.
    +
    +        The engine must be an instance of ``agent.context_engine.ContextEngine``.
    +        """
    +        if self._manager._context_engine is not None:
    +            logger.warning(
    +                "Plugin '%s' tried to register a context engine, but one is "
    +                "already registered. Only one context engine plugin is allowed.",
    +                self.manifest.name,
    +            )
    +            return
    +        # Defer the import to avoid circular deps at module level
    +        from agent.context_engine import ContextEngine
    +        if not isinstance(engine, ContextEngine):
    +            logger.warning(
    +                "Plugin '%s' tried to register a context engine that does not "
    +                "inherit from ContextEngine. Ignoring.",
    +                self.manifest.name,
    +            )
    +            return
    +        self._manager._context_engine = engine
    +        logger.info(
    +            "Plugin '%s' registered context engine: %s",
    +            self.manifest.name, engine.name,
    +        )
    +
         # -- hook registration --------------------------------------------------
     
         def register_hook(self, hook_name: str, callback: Callable) -> None:
    ```

    ```diff
    @@ -245,6 +276,7 @@ def __init__(self) -> None:
             self._hooks: Dict[str, List[Callable]] = {}
             self._plugin_tool_names: Set[str] = set()
             self._cli_commands: Dict[str, dict] = {}
    +        self._context_engine = None  # Set by a plugin via register_context_engine()
             self._discovered: bool = False
             self._cli_ref = None  # Set by CLI after plugin discovery
     
    ```

    ```diff
    @@ -566,6 +598,11 @@ def get_plugin_cli_commands() -> Dict[str, dict]:
         return dict(get_plugin_manager()._cli_commands)
     
     
    +def get_plugin_context_engine():
    +    """Return the plugin-registered context engine, or None."""
    +    return get_plugin_manager()._context_engine
    +
    +
     def get_plugin_toolsets() -> List[tuple]:
         """Return plugin toolsets as ``(key, label, description)`` tuples.
     
    ```
  - `hermes_cli/plugins_cmd.py`

    ```diff
    @@ -531,7 +531,7 @@ def cmd_disable(name: str) -> None:
     
         disabled.add(name)
         _save_disabled_set(disabled)
    -    console.print(f"[yellow]⊘[/yellow] Plugin [bold]{name}[/bold] disabled. Takes effect on next session.")
    +    console.print(f"[yellow]\u2298[/yellow] Plugin [bold]{name}[/bold] disabled. Takes effect on next session.")
     
     
     def cmd_list() -> None:
    ```

    ```diff
    @@ -594,8 +594,152 @@ def cmd_list() -> None:
         console.print("[dim]Enable/disable:[/dim] hermes plugins enable/disable <name>")
     
     
    +# ---------------------------------------------------------------------------
    +# Provider plugin discovery helpers
    +# ---------------------------------------------------------------------------
    +
    +
    +def _discover_memory_providers() -> list[tuple[str, str]]:
    +    """Return [(name, description), ...] for available memory providers."""
    +    try:
    +        from plugins.memory import discover_memory_providers
    +        return [(name, desc) for name, desc, _avail in discover_memory_providers()]
    +    except Exception:
    +        return []
    +
    +
    +def _discover_context_engines() -> list[tuple[str, str]]:
    +    """Return [(name, description), ...] for available context engines."""
    +    try:
    +        from plugins.context_engine import discover_context_engines
    +        return [(name, desc) for name, desc, _avail in discover_context_engines()]
    +    except Exception:
    +        return []
    +
    +
    +def _get_current_memory_provider() -> str:
    +    """Return the current memory.provider from config (empty = built-in)."""
    +    try:
    +        from hermes_cli.config import load_config
    +        config = load_config()
    +        return config.get("memory", {}).get("provider", "") or ""
    +    except Exception:
    +        return ""
    +
    +
    +def _get_current_context_engine() -> str:
    +    """Return the current context.engine from config."""
    +    try:
    +        from hermes_cli.config import load_config
    +        config = load_config()
    +        return config.get("context", {}).get("engine", "compressor") or "compressor"
    +    except Exception:
    +        return "compressor"
    +
    +
    +def _save_memory_provider(name: str) -> None:
    +    """Persist memory.provider to config.yaml."""
    +    from hermes_cli.config import load_config, save_config
    +    config = load_config()
    +    if "memory" not in config:
    +        config["memory"] = {}
    +    config["memory"]["provider"] = name
    +    save_config(config)
    +
    +
    +def _save_context_engine(name: str) -> None:
    +    """Persist context.engine to config.yaml."""
    +    from hermes_cli.config import load_config, save_config
    +    config = load_config()
    +    if "context" not in config:
    +        config["context"] = {}
    +    config["context"]["engine"] = name
    +    save_config(config)
    +
    +
    +def _configure_memory_provider() -> bool:
    +    """Launch a radio picker for memory providers. Returns True if changed."""
    +    from hermes_cli.curses_ui import curses_radiolist
    +
    +    current = _get_current_memory_provider()
    +    providers = _discover_memory_providers()
    +
    +    # Build items: "built-in" first, then discovered providers
    +    items = ["built-in (default)"]
    +    names = [""]  # empty string = built-in
    +    selected = 0
    +
    +    for name, desc in providers:
    +        names.append(name)
    +        label = f"{name} \u2014 {desc}" if desc else name
    +        items.append(label)
    +        if name == current:
    +            selected = len(items) - 1
    +
    +    # If current provider isn't in discovered list, add it
    +    if current and current not in names:
    +        names.append(current)
    +        items.append(f"{current} (not found)")
    +        selected = len(items) - 1
    +
    +    choice = curses_radiolist(
    +        title="Memory Provider (select one)",
    +        items=items,
    +        selected=selected,
    +    )
    +
    +    new_provider = names[choice]
    +    if new_provider != current:
    +        _save_memory_provider(new_provider)
    +        return True
    +    return False
    +
    +
    +def _configure_context_engine() -> bool:
    +    """Launch a radio picker for context engines. Returns True if changed."""
    +    from hermes_cli.curses_ui import curses_radiolist
    +
    +    current = _get_current_context_engine()
    +    engines = _discover_context_engines()
    +
    +    # Build items: "compressor" first (built-in), then discovered engines
    +    items = ["compressor (default)"]
    +    names = ["compressor"]
    +    selected = 0
    +
    +    for name, desc in engines:
    +        names.append(name)
    +        label = f"{name} \u2014 {desc}" if desc else name
    +        items.append(label)
    +        if name == current:
    +            selected = len(items) - 1
    +
    +    # If current engine isn't in discovered list and isn't compressor, add it
    +    if current != "compressor" and current not in names:
    +        names.append(current)
    +        items.append(f"{current} (not found)")
    +        selected = len(items) - 1
    +
    +    choice = curses_radiolist(
    +        title="Context Engine (select one)",
    +        items=items,
    +        selected=selected,
    +    )
    +
    +    new_engine = names[choice]
    +    if new_engine != current:
    +        _save_context_engine(new_engine)
    +        return True
    +    return False
    +
    +
    +# ---------------------------------------------------------------------------
    +# Composite plugins UI
    +# ---------------------------------------------------------------------------
    +
    +
     def cmd_toggle() -> None:
    -    """Interactive curses checklist to enable/disable installed plugins."""
    +    """Interactive composite UI — general plugins + provider plugin categories."""
         from rich.console import Console
     
         try:
    ```

    ```diff
    @@ -606,18 +750,13 @@ def cmd_toggle() -> None:
         console = Console()
         plugins_dir = _plugins_dir()
     
    +    # -- General plugins discovery --
         dirs = sorted(d for d in plugins_dir.iterdir() if d.is_dir())
    -    if not dirs:
    -        console.print("[dim]No plugins installed.[/dim]")
    -        console.print("[dim]Install with:[/dim] hermes plugins install owner/repo")
    -        return
    -
         disabled = _get_disabled_set()
     
    -    # Build items list: "name — description" for display
    -    names = []
    -    labels = []
    -    selected = set()
    +    plugin_names = []
    +    plugin_labels = []
    +    plugin_selected = set()
     
         for i, d in enumerate(dirs):
             manifest_file = d / "plugin.yaml"
    ```

    ```diff
    @@ -633,36 +772,335 @@ def cmd_toggle() -> None:
                 except Exception:
                     pass
     
    -        names.append(name)
    -        label = f"{name} — {description}" if description else name
    -        labels.append(label)
    +        plugin_names.append(name)
    +        label = f"{name} \u2014 {description}" if description else name
    +        plugin_labels.append(label)
     
             if name not in disabled and d.name not in disabled:
    -            selected.add(i)
    +            plugin_selected.add(i)
     
    -    from hermes_cli.curses_ui import curses_checklist
    +    # -- Provider categories --
    +    current_memory = _get_current_memory_provider() or "built-in"
    +    current_context = _get_current_context_engine()
    +    categories = [
    +        ("Memory Provider", current_memory, _configure_memory_provider),
    +        ("Context Engine", current_context, _configure_context_engine),
    +    ]
     
    -    result = curses_checklist(
    -        title="Plugins — toggle enabled/disabled",
    -        items=labels,
    -        selected=selected,
    -    )
    +    has_plugins = bool(plugin_names)
    +    has_categories = bool(categories)
    +
    +    if not has_plugins and not has_categories:
    +        console.print("[dim]No plugins installed and no provider categories available.[/dim]")
    +        console.print("[dim]Install with:[/dim] hermes plugins install owner/repo")
    +        return
    +
    +    # Non-TTY fallback
    +    if not sys.stdin.isatty():
    +        console.print("[dim]Interactive mode requires a terminal.[/dim]")
    +        return
     
    -    # Compute new disabled set from deselected items
    +    # Launch the composite curses UI
    +    try:
    +        import curses
    +        _run_composite_ui(curses, plugin_names, plugin_labels, plugin_selected,
    +                          disabled, categories, console)
    +    except ImportError:
    +        _run_composite_fallback(plugin_names, plugin_labels, plugin_selected,
    +                                disabled, categories, console)
    +
    +
    +def _run_composite_ui(curses, plugin_names, plugin_labels, plugin_selected,
    +                      disabled, categories, console):
    +    """Custom curses screen with checkboxes + category action rows."""
    +    from hermes_cli.curses_ui import flush_stdin
    +
    +    chosen = set(plugin_selected)
    +    n_plugins = len(plugin_names)
    +    # Total rows: plugins + separator + categories
    +    # separator is not navigable
    +    n_categories = len(categories)
    +    total_items = n_plugins + n_categories  # navigable items
    +
    +    result_holder = {"plugins_changed": False, "providers_changed": False}
    +
    +    def _draw(stdscr):
    +        curses.curs_set(0)
    +        if curses.has_colors():
    +            curses.start_color()
    +            curses.use_default_colors()
    +            curses.init_pair(1, curses.COLOR_GREEN, -1)
    +            curses.init_pair(2, curses.COLOR_YELLOW, -1)
    +            curses.init_pair(3, curses.COLOR_CYAN, -1)
    +            curses.init_pair(4, 8, -1)  # dim gray
    +        cursor = 0
    +        scroll_offset = 0
    +
    +        while True:
    +            stdscr.clear()
    +            max_y, max_x = stdscr.getmaxyx()
    +
    +            # Header
    +            try:
    +                hattr = curses.A_BOLD
    +                if curses.has_colors():
    +                    hattr |= curses.color_pair(2)
    +                stdscr.addnstr(0, 0, "Plugins", max_x - 1, hattr)
    +                stdscr.addnstr(
    +                    1, 0,
    +                    "  \u2191\u2193 navigate  SPACE toggle  ENTER configure/confirm  ESC done",
    +                    max_x - 1, curses.A_DIM,
    +                )
    +            except curses.error:
    +                pass
    +
    +            # Build display rows
    +            # Row layout:
    +            #   [plugins section header] (not navigable, skipped in scroll math)
    +            #   plugin checkboxes (navigable, indices 0..n_plugins-1)
    +            #   [separator] (not navigable)
    +            #   [categories section header] (not navigable)
    +            #   category action rows (navigable, indices n_plugins..total_items-1)
    +
    +            visible_rows = max_y - 4
    +            if cursor < scroll_offset:
    +                scroll_offset = cursor
    +            elif cursor >= scroll_offset + visible_rows:
    +                scroll_offset = cursor - visible_rows + 1
    +
    +            y = 3  # start drawing after header
    +
    +            # Determine which items are visible based on scroll
    +            # We need to map logical cursor positions to screen rows
    +            # accounting for non-navigable separator/headers
    +
    +            draw_row = 0  # tracks navigable item index
    +
    +            # --- General Plugins section ---
    +            if n_plugins > 0:
    +                # Section header
    +                if y < max_y - 1:
    +                    try:
    +                        sattr = curses.A_BOLD
    +                        if curses.has_colors():
    +                            sattr |= curses.color_pair(2)
    +                        stdscr.addnstr(y, 0, "  General Plugins", max_x - 1, sattr)
    +                    except curses.error:
    +                        pass
    +                    y += 1
    +
    +                for i in range(n_plugins):
    +                    if y >= max_y - 1:
    +                        break
    +                    check = "\u2713" if i in chosen else " "
    +                    arrow = "\u2192" if i == cursor else " "
    +                    line = f" {arrow} [{check}] {plugin_labels[i]}"
    +                    attr = curses.A_NORMAL
    +                    if i == cursor:
    +                        attr = curses.A_BOLD
    +                        if curses.has_colors():
    +                            attr |= curses.color_pair(1)
    +                    try:
    +                        stdscr.addnstr(y, 0, line, max_x - 1, attr)
    +                    except curses.error:
    +                        pass
    +                    y += 1
    +
    +            # --- Separator ---
    +            if y < max_y - 1:
    +                y += 1  # blank line
    +
    +            # --- Provider Plugins section ---
    +            if n_categories > 0 and y < max_y - 1:
    +                try:
    +                    sattr = curses.A_BOLD
    +                    if curses.has_colors():
    +                        sattr |= curses.color_pair(2)
    +                    stdscr.addnstr(y, 0, "  Provider Plugins", max_x - 1, sattr)
    +                except curses.error:
    +                    pass
    +                y += 1
    +
    +                for ci, (cat_name, cat_current, _cat_fn) in enumerate(categories):
    +                    if y >= max_y - 1:
    +                        break
    +                    cat_idx = n_plugins + ci
    +                    arrow = "\u2192" if cat_idx == cursor else " "
    +                    line = f" {arrow}   {cat_name:<24} \u25b8 {cat_current}"
    +                    attr = curses.A_NORMAL
    +                    if cat_idx == cursor:
    +                        attr = curses.A_BOLD
    +                        if curses.has_colors():
    +                            attr |= curses.color_pair(3)
    +                    try:
    +                        stdscr.addnstr(y, 0, line, max_x - 1, attr)
    +                    except curses.error:
    +                        pass
    +                    y += 1
    +
    +            stdscr.refresh()
    +            key = stdscr.getch()
    +
    +            if key in (curses.KEY_UP, ord("k")):
    +                if total_items > 0:
    +                    cursor = (cursor - 1) % total_items
    +            elif key in (curses.KEY_DOWN, ord("j")):
    +                if total_items > 0:
    +                    cursor = (cursor + 1) % total_items
    +            elif key == ord(" "):
    +                if cursor < n_plugins:
    +                    # Toggle general plugin
    +                    chosen.symmetric_difference_update({cursor})
    +                else:
    +                    # Provider category — launch sub-screen
    +                    ci = cursor - n_plugins
    +                    if 0 <= ci < n_categories:
    +                        curses.endwin()
    +                        _cat_name, _cat_cur, cat_fn = categories[ci]
    +                        changed = cat_fn()
    +                        if changed:
    +                            result_holder["providers_changed"] = True
    +                            # Refresh current values
    +                            categories[ci] = (
    +                                _cat_name,
    +                                _get_current_memory_provider() or "built-in" if ci == 0
    +                                else _get_current_context_engine(),
    +                                cat_fn,
    +                            )
    +                        # Re-enter curses
    +                        stdscr = curses.initscr()
    +                        curses.noecho()
    +                        curses.cbreak()
    +                        stdscr.keypad(True)
    +                        if curses.has_colors():
    +                            curses.start_color()
    +                            curses.use_default_colors()
    +                            curses.init_pair(1, curses.COLOR_GREEN, -1)
    +                            curses.init_pair(2, curses.COLOR_YELLOW, -1)
    +                            curses.init_pair(3, curses.COLOR_CYAN, -1)
    +                            curses.init_pair(4, 8, -1)
    +                        curses.curs_set(0)
    +            elif key in (curses.KEY_ENTER, 10, 13):
    +                if cursor < n_plugins:
    +                    # ENTER on a plugin checkbox — confirm and exit
    +                    result_holder["plugins_changed"] = True
    +                    return
    +                else:
    +                    # ENTER on a category — same as SPACE, launch sub-screen
    +                    ci = cursor - n_plugins
    +                    if 0 <= ci < n_categories:
    +                        curses.endwin()
    +                        _cat_name, _cat_cur, cat_fn = categories[ci]
    +                        changed = cat_fn()
    +                        if changed:
    +                            result_holder["providers_changed"] = True
    +                            categories[ci] = (
    +                                _cat_name,
    +                                _get_current_memory_provider() or "built-in" if ci == 0
    +                                else _get_current_context_engine(),
    +                                cat_fn,
    +                            )
    +                        stdscr = curses.initscr()
    +                        curses.noecho()
    +                        curses.cbreak()
    +                        stdscr.keypad(True)
    +                        if curses.has_colors():
    +                            curses.start_color()
    +                            curses.use_default_colors()
    +                            curses.init_pair(1, curses.COLOR_GREEN, -1)
    +                            curses.init_pair(2, curses.COLOR_YELLOW, -1)
    +                            curses.init_pair(3, curses.COLOR_CYAN, -1)
    +                            curses.init_pair(4, 8, -1)
    +                        curses.curs_set(0)
    +            elif key in (27, ord("q")):
    +                # Save plugin changes on exit
    +                result_holder["plugins_changed"] = True
    +                return
    +
    +    curses.wrapper(_draw)
    +    flush_stdin()
    +
    +    # Persist general plugin changes
         new_disabled = set()
    -    for i, name in enumerate(names):
    -        if i not in result:
    +    for i, name in enumerate(plugin_names):
    +        if i not in chosen:
                 new_disabled.add(name)
     
         if new_disabled != disabled:
             _save_disabled_set(new_disabled)
    -        enabled_count = len(names) - len(new_disabled)
    +        enabled_count = len(plugin_names) - len(new_disabled)
             console.print(
    -            f"\n[green]✓[/green] {enabled_count} enabled, {len(new_disabled)} disabled. "
    -            f"Takes effect on next session."
    +            f"\n[green]\u2713[/green] General plugins: {enabled_count} enabled, "
    +            f"{len(new_disabled)} disabled."
             )
    -    else:
    -        console.print("\n[dim]No changes.[/dim]")
    +    elif n_plugins > 0:
    +        console.print("\n[dim]General plugins unchanged.[/dim]")
    +
    +    if result_holder["providers_changed"]:
    +        new_memory = _get_current_memory_provider() or "built-in"
    +        new_context = _get_current_context_engine()
    +        console.print(
    +            f"[green]\u2713[/green] Memory provider: [bold]{new_memory}[/bold]  "
    +            f"Context engine: [bold]{new_context}[/bold]"
    +        )
    +
    +    if n_plugins > 0 or result_holder["providers_changed"]:
    +        console.print("[dim]Changes take effect on next session.[/dim]")
    +    console.print()
    +
    +
    +def _run_composite_fallback(plugin_names, plugin_labels, plugin_selected,
    +                            disabled, categories, console):
    +    """Text-based fallback for the composite plugins UI."""
    +    from hermes_cli.colors import Colors, color
    +
    +    print(color("\n  Plugins", Colors.YELLOW))
    +
    +    # General plugins
    +    if plugin_names:
    +        chosen = set(plugin_selected)
    +        print(color("\n  General Plugins", Colors.YELLOW))
    +        print(color("  Toggle by number, Enter to confirm.\n", Colors.DIM))
    +
    +        while True:
    +            for i, label in enumerate(plugin_labels):
    +                marker = color("[\u2713]", Colors.GREEN) if i in chosen else "[ ]"
    +                print(f"  {marker} {i + 1:>2}. {label}")
    +            print()
    +            try:
    +                val = input(color("  Toggle # (or Enter to confirm): ", Colors.DIM)).strip()
    +                if not val:
    +                    break
    +                idx = int(val) - 1
    +                if 0 <= idx < len(plugin_names):
    +                    chosen.symmetric_difference_update({idx})
    +            except (ValueError, KeyboardInterrupt, EOFError):
    +                return
    +            print()
    +
    +        new_disabled = set()
    +        for i, name in enumerate(plugin_names):
    +            if i not in chosen:
    +                new_disabled.add(name)
    +        if new_disabled != disabled:
    +            _save_disabled_set(new_disabled)
    +
    +    # Provider categories
    +    if categories:
    +        print(color("\n  Provider Plugins", Colors.YELLOW))
    +        for ci, (cat_name, cat_current, cat_fn) in enumerate(categories):
    +            print(f"  {ci + 1}. {cat_name} [{cat_current}]")
    +        print()
    +        try:
    +            val = input(color("  Configure # (or Enter to skip): ", Colors.DIM)).strip()
    +            if val:
    +                ci = int(val) - 1
    +                if 0 <= ci < len(categories):
    +                    categories[ci][2]()  # call the configure function
    +        except (ValueError, KeyboardInterrupt, EOFError):
    +            pass
    +
    +    print()
     
     
     def plugins_command(args) -> None:
    ```
  - `plugins/context_engine/__init__.py`

    ```diff
    @@ -0,0 +1,219 @@
    +"""Context engine plugin discovery.
    +
    +Scans ``plugins/context_engine/<name>/`` directories for context engine
    +plugins.  Each subdirectory must contain ``__init__.py`` with a class
    +implementing the ContextEngine ABC.
    +
    +Context engines are separate from the general plugin system — they live
    +in the repo and are always available without user installation.  Only ONE
    +can be active at a time, selected via ``context.engine`` in config.yaml.
    +The default engine is ``"compressor"`` (the built-in ContextCompressor).
    +
    +Usage:
    +    from plugins.context_engine import discover_context_engines, load_context_engine
    +
    +    available = discover_context_engines()   # [(name, desc, available), ...]
    +    engine = load_context_engine("lcm")      # ContextEngine instance
    +"""
    +
    +from __future__ import annotations
    +
    +import importlib
    +import importlib.util
    +import logging
    +import sys
    +from pathlib import Path
    +from typing import List, Optional, Tuple
    +
    +logger = logging.getLogger(__name__)
    +
    +_CONTEXT_ENGINE_PLUGINS_DIR = Path(__file__).parent
    +
    +
    +def discover_context_engines() -> List[Tuple[str, str, bool]]:
    +    """Scan plugins/context_engine/ for available engines.
    +
    +    Returns list of (name, description, is_available) tuples.
    +    Does NOT import the engines — just reads plugin.yaml for metadata
    +    and does a lightweight availability check.
    +    """
    +    results = []
    +    if not _CONTEXT_ENGINE_PLUGINS_DIR.is_dir():
    +        return results
    +
    +    for child in sorted(_CONTEXT_ENGINE_PLUGINS_DIR.iterdir()):
    +        if not child.is_dir() or child.name.startswith(("_", ".")):
    +            continue
    +        init_file = child / "__init__.py"
    +        if not init_file.exists():
    +            continue
    +
    +        # Read description from plugin.yaml if available
    +        desc = ""
    +        yaml_file = child / "plugin.yaml"
    +        if yaml_file.exists():
    +            try:
    +                import yaml
    +                with open(yaml_file) as f:
    +                    meta = yaml.safe_load(f) or {}
    +                desc = meta.get("description", "")
    +            except Exception:
    +                pass
    +
    +        # Quick availability check — try loading and calling is_available()
    +        available = True
    +        try:
    +            engine = _load_engine_from_dir(child)
    +            if engine is None:
    +                available = False
    +            elif hasattr(engine, "is_available"):
    +                available = engine.is_available()
    +        except Exception:
    +            available = False
    +
    +        results.append((child.name, desc, available))
    +
    +    return results
    +
    +
    +def load_context_engine(name: str) -> Optional["ContextEngine"]:
    +    """Load and return a ContextEngine instance by name.
    +
    +    Returns None if the engine is not found or fails to load.
    +    """
    +    engine_dir = _CONTEXT_ENGINE_PLUGINS_DIR / name
    +    if not engine_dir.is_dir():
    +        logger.debug("Context engine '%s' not found in %s", name, _CONTEXT_ENGINE_PLUGINS_DIR)
    +        return None
    +
    +    try:
    +        engine = _load_engine_from_dir(engine_dir)
    +        if engine:
    +            return engine
    +        logger.warning("Context engine '%s' loaded but no engine instance found", name)
    +        return None
    +    except Exception as e:
    +        logger.warning("Failed to load context engine '%s': %s", name, e)
    +        return None
    +
    +
    +def _load_engine_from_dir(engine_dir: Path) -> Optional["ContextEngine"]:
    +    """Import an engine module and extract the ContextEngine instance.
    +
    +    The module must have either:
    +    - A register(ctx) function (plugin-style) — we simulate a ctx
    +    - A top-level class that extends ContextEngine — we instantiate it
    +    """
    +    name = engine_dir.name
    +    module_name = f"plugins.context_engine.{name}"
    +    init_file = engine_dir / "__init__.py"
    +
    +    if not init_file.exists():
    +        return None
    +
    +    # Check if already loaded
    +    if module_name in sys.modules:
    +        mod = sys.modules[module_name]
    +    else:
    +        # Handle relative imports within the plugin
    +        # First ensure the parent packages are registered
    +        for parent in ("plugins", "plugins.context_engine"):
    +            if parent not in sys.modules:
    +                parent_path = Path(__file__).parent
    +                if parent == "plugins":
    +                    parent_path = parent_path.parent
    +                parent_init = parent_path / "__init__.py"
    +                if parent_init.exists():
    +                    spec = importlib.util.spec_from_file_location(
    +                        parent, str(parent_init),
    +                        submodule_search_locations=[str(parent_path)]
    +                    )
    +                    if spec:
    +                        parent_mod = importlib.util.module_from_spec(spec)
    +                        sys.modules[parent] = parent_mod
    +                        try:
    +                            spec.loader.exec_module(parent_mod)
    +                        except Exception:
    +                            pass
    +
    +        # Now load the engine module
    +        spec = importlib.util.spec_from_file_location(
    +            module_name, str(init_file),
    +            submodule_search_locations=[str(engine_dir)]
    +        )
    +        if not spec:
    +            return None
    +
    +        mod = importlib.util.module_from_spec(spec)
    +        sys.modules[module_name] = mod
    +
    +        # Register submodules so relative imports work
    +        for sub_file in engine_dir.glob("*.py"):
    +            if sub_file.name == "__init__.py":
    +                continue
    +            sub_name = sub_file.stem
    +            full_sub_name = f"{module_name}.{sub_name}"
    +            if full_sub_name not in sys.modules:
    +                sub_spec = importlib.util.spec_from_file_location(
    +                    full_sub_name, str(sub_file)
    +                )
    +                if sub_spec:
    +                    sub_mod = importlib.util.module_from_spec(sub_spec)
    +                    sys.modules[full_sub_name] = sub_mod
    +                    try:
    +                        sub_spec.loader.exec_module(sub_mod)
    +                    except Exception as e:
    +                        logger.debug("Failed to load submodule %s: %s", full_sub_name, e)
    +
    +        try:
    +            spec.loader.exec_module(mod)
    +        except Exception as e:
    +            logger.debug("Failed to exec_module %s: %s", module_name, e)
    +            sys.modules.pop(module_name, None)
    +            return None
    +
    +    # Try register(ctx) pattern first (how plugins are written)
    +    if hasattr(mod, "register"):
    +        collector = _EngineCollector()
    +        try:
    +            mod.register(collector)
    +            if collector.engine:
    +                return collector.engine
    +        except Exception as e:
    +            logger.debug("register() failed for %s: %s", name, e)
    +
    +    # Fallback: find a ContextEngine subclass and instantiate it
    +    from agent.context_engine import ContextEngine
    +    for attr_name in dir(mod):
    +        attr = getattr(mod, attr_name, None)
    +        if (isinstance(attr, type) and issubclass(attr, ContextEngine)
    +                and attr is not ContextEngine):
    +            try:
    +                return attr()
    +            except Exception:
    +                pass
    +
    +    return None
    +
    +
    +class _EngineCollector:
    +    """Fake plugin context that captures register_context_engine calls."""
    +
    +    def __init__(self):
    +        self.engine = None
    +
    +    def register_context_engine(self, engine):
    +        self.engine = engine
    +
    +    # No-op for other registration methods
    +    def register_tool(self, *args, **kwargs):
    +        pass
    +
    +    def register_hook(self, *args, **kwargs):
    +        pass
    +
    +    def register_cli_command(self, *args, **kwargs):
    +        pass
    +
    +    def register_memory_provider(self, *args, **kwargs):
    +        pass
    ```
  - `run_agent.py`

    ```diff
    @@ -1268,20 +1268,88 @@ def __init__(
                                             pass
                             break
             
    -        self.context_compressor = ContextCompressor(
    -            model=self.model,
    -            threshold_percent=compression_threshold,
    -            protect_first_n=3,
    -            protect_last_n=compression_protect_last,
    -            summary_target_ratio=compression_target_ratio,
    -            summary_model_override=compression_summary_model,
    -            quiet_mode=self.quiet_mode,
    -            base_url=self.base_url,
    -            api_key=[redacted:secret-assignment] "api_key", ""),
    -            config_context_length=_config_context_length,
    -            provider=self.provider,
    -        )
    +        # Select context engine: config-driven (like memory providers).
    +        # 1. Check config.yaml context.engine setting
    +        # 2. Check plugins/context_engine/<name>/ directory (repo-shipped)
    +        # 3. Check general plugin system (user-installed plugins)
    +        # 4. Fall back to built-in ContextCompressor
    +        _selected_engine = None
    +        _engine_name = "compressor"  # default
    +        try:
    +            _ctx_cfg = _agent_cfg.get("context", {}) if isinstance(_agent_cfg, dict) else {}
    +            _engine_name = _ctx_cfg.get("engine", "compressor") or "compressor"
    +        except Exception:
    +            pass
    +
    +        if _engine_name != "compressor":
    +            # Try loading from plugins/context_engine/<name>/
    +            try:
    +                from plugins.context_engine import load_context_engine
    +                _selected_engine = load_context_engine(_engine_name)
    +            except Exception as _ce_load_err:
    +                logger.debug("Context engine load from plugins/context_engine/: %s", _ce_load_err)
    +
    +            # Try general plugin system as fallback
    +            if _selected_engine is None:
    +                try:
    +                    from hermes_cli.plugins import get_plugin_context_engine
    +                    _candidate = get_plugin_context_engine()
    +                    if _candidate and _candidate.name == _engine_name:
    +                        _selected_engine = _candidate
    +                except Exception:
    +                    pass
    +
    +            if _selected_engine is None:
    +                logger.warning(
    +                    "Context engine '%s' not found — falling back to built-in compressor",
    +                    _engine_name,
    +                )
    +        # else: config says "compressor" — use built-in, don't auto-activate plugins
    +
    +        if _selected_engine is not None:
    +            self.context_compressor = _selected_engine
    +            if not self.quiet_mode:
    +                logger.info("Using context engine: %s", _selected_engine.name)
    +        else:
    +            self.context_compressor = ContextCompressor(
    +                model=self.model,
    +                threshold_percent=compression_threshold,
    +                protect_first_n=3,
    +                protect_last_n=compression_protect_last,
    +                summary_target_ratio=compression_target_ratio,
    +                summary_model_override=compression_summary_model,
    +                quiet_mode=self.quiet_mode,
    +                base_url=self.base_url,
    +                api_key=[redacted:secret-assignment] "api_key", ""),
    +                config_context_length=_config_context_length,
    +                provider=self.provider,
    +            )
             self.compression_enabled = compression_enabled
    +
    +        # Inject context engine tool schemas (e.g. lcm_grep, lcm_describe, lcm_expand)
    +        self._context_engine_tool_names: set = set()
    +        if hasattr(self, "context_compressor") and self.context_compressor and self.tools is not None:
    +            for _schema in self.context_compressor.get_tool_schemas():
    +                _wrapped = {"type": "function", "function": _schema}
    +                self.tools.append(_wrapped)
    +                _tname = _schema.get("name", "")
    +                if _tname:
    +                    self.valid_tool_names.add(_tname)
    +                    self._context_engine_tool_names.add(_tname)
    +
    +        # Notify context engine of session start
    +        if hasattr(self, "context_compressor") and self.context_compressor:
    +            try:
    +                self.context_compressor.on_session_start(
    +                    self.session_id,
    +                    hermes_home=str(get_hermes_home()),
    +                    platform=self.platform or "cli",
    +                    model=self.model,
    +                    context_length=getattr(self.context_compressor, "context_length", 0),
    +                )
    +            except Exception as _ce_err:
    +                logger.debug("Context engine on_session_start: %s", _ce_err)
    +
             self._subdirectory_hints = SubdirectoryHintTracker(
                 working_dir=os.getenv("TERMINAL_CWD") or None,
             )
    ```

    ```diff
    @@ -1347,11 +1415,13 @@ def __init__(
                 "api_key": getattr(self, "api_key", ""),
                 "client_kwargs": dict(self._client_kwargs),
                 "use_prompt_caching": self._use_prompt_caching,
    -            # Compressor state that _try_activate_fallback() overwrites
    -            "compressor_model": _cc.model,
    -            "compressor_base_url": _cc.base_url,
    +            # Context engine state that _try_activate_fallback() overwrites.
    +            # Use getattr for model/base_url/api_key/provider since plugin
    +            # engines may not have these (they're ContextCompressor-specific).
    +            "compressor_model": getattr(_cc, "model", self.model),
    +            "compressor_base_url": getattr(_cc, "base_url", self.base_url),
                 "compressor_api_key": getattr(_cc, "api_key", ""),
    -            "compressor_provider": _cc.provider,
    +            "compressor_provider": getattr(_cc, "provider", self.provider),
                 "compressor_context_length": _cc.context_length,
                 "compressor_threshold_tokens": _cc.threshold_tokens,
             }
    ```

    ```diff
    @@ -1397,15 +1467,9 @@ def reset_session_state(self):
             # Turn counter (added after reset_session_state was first written — #2635)
             self._user_turn_count = 0
     
    -        # Context compressor internal counters (if present)
    +        # Context engine reset (works for both built-in compressor and plugins)
             if hasattr(self, "context_compressor") and self.context_compressor:
    -            self.context_compressor.last_prompt_tokens = 0
    -            self.context_compressor.last_completion_tokens = 0
    -            self.context_compressor.compression_count = 0
    -            self.context_compressor._context_probed = False
    -            self.context_compressor._context_probe_persistable = False
    -            # Iterative summary from previous session must not bleed into new one (#2635)
    -            self.context_compressor._previous_summary = None
    +            self.context_compressor.on_session_reset()
         
         def switch_model(self, new_model, new_provider, api_key='', base_url='', api_mode=''):
             """Switch the model/provider in-place for a live agent.
    ```

    ```diff
    @@ -1486,13 +1550,12 @@ def switch_model(self, new_model, new_provider, api_key='', base_url='', api_mod
                     provider=self.provider,
                     config_context_length=getattr(self, "_config_context_length", None),
                 )
    -            self.context_compressor.model = self.model
    -            self.context_compressor.base_url = self.base_url
    -            self.context_compressor.api_key = [redacted:secret-assignment]
    -            self.context_compressor.provider = self.provider
    -            self.context_compressor.context_length = new_context_length
    -            self.context_compressor.threshold_tokens = int(
    -                new_context_length * self.context_compressor.threshold_percent
    +            self.context_compressor.update_model(
    +                model=self.model,
    +                context_length=new_context_length,
    +                base_url=self.base_url,
    +                api_key=[redacted:secret-assignment] "api_key", ""),
    +                provider=self.provider,
                 )
     
             # ── Invalidate cached system prompt so it rebuilds next turn ──
    ```

    ```diff
    @@ -1508,10 +1571,10 @@ def switch_model(self, new_model, new_provider, api_key='', base_url='', api_mod
                 "api_key": getattr(self, "api_key", ""),
                 "client_kwargs": dict(self._client_kwargs),
                 "use_prompt_caching": self._use_prompt_caching,
    -            "compressor_model": _cc.model if _cc else self.model,
    -            "compressor_base_url": _cc.base_url if _cc else self.base_url,
    +            "compressor_model": getattr(_cc, "model", self.model) if _cc else self.model,
    +            "compressor_base_url": getattr(_cc, "base_url", self.base_url) if _cc else self.base_url,
                 "compressor_api_key": getattr(_cc, "api_key", "") if _cc else "",
    -            "compressor_provider": _cc.provider if _cc else self.provider,
    +            "compressor_provider": getattr(_cc, "provider", self.provider) if _cc else self.provider,
                 "compressor_context_length": _cc.context_length if _cc else 0,
                 "compressor_threshold_tokens": _cc.threshold_tokens if _cc else 0,
             }
    ```

    ```diff
    @@ -2708,10 +2771,11 @@ def get_activity_summary(self) -> dict:
             }
     
         def shutdown_memory_provider(self, messages: list = None) -> None:
    -        """Shut down the memory provider — call at actual session boundaries.
    +        """Shut down the memory provider and context engine — call at actual session boundaries.
     
             This calls on_session_end() then shutdown_all() on the memory
    -        manager. NOT called per-turn — only at CLI exit, /reset, gateway
    +        manager, and on_session_end() on the context engine.
    +        NOT called per-turn — only at CLI exit, /reset, gateway
             session expiry, etc.
             """
             if self._memory_manager:
    ```

    ```diff
    @@ -2723,6 +2787,15 @@ def shutdown_memory_provider(self, messages: list = None) -> None:
                     self._memory_manager.shutdown_all()
                 except Exception:
                     pass
    +        # Notify context engine of session end (flush DAG, close DBs, etc.)
    +        if hasattr(self, "context_compressor") and self.context_compressor:
    +            try:
    +                self.context_compressor.on_session_end(
    +                    self.session_id or "",
    +                    messages or [],
    +                )
    +            except Exception:
    +                pass
         
         def close(self) -> None:
             """Release all resources held by this agent instance.
    ```

    ```diff
    @@ -5240,13 +5313,12 @@ def _try_activate_fallback(self) -> bool:
                         self.model, base_url=self.base_url,
                         api_key=[redacted:secret-assignment] provider=self.provider,
                     )
    -                self.context_compressor.model = self.model
    -                self.context_compressor.base_url = self.base_url
    -                self.context_compressor.api_key = [redacted:secret-assignment]
    -                self.context_compressor.provider = self.provider
    -                self.context_compressor.context_length = fb_context_length
    -                self.context_compressor.threshold_tokens = int(
    -                    fb_context_length * self.context_compressor.threshold_percent
    +                self.context_compressor.update_model(
    +                    model=self.model,
    +                    context_length=fb_context_length,
    +                    base_url=self.base_url,
    +                    api_key=[redacted:secret-assignment] "api_key", ""),
    +                    provider=self.provider,
                     )
     
                 self._emit_status(
    ```

    ```diff
    @@ -5306,14 +5378,15 @@ def _restore_primary_runtime(self) -> bool:
                         shared=True,
                     )
     
    -            # ── Restore context compressor state ──
    +            # ── Restore context engine state ──
                 cc = self.context_compressor
    -            cc.model = rt["compressor_model"]
    -            cc.base_url = rt["compressor_base_url"]
    -            cc.api_key = rt["compressor_api_key"]
    -            cc.provider = rt["compressor_provider"]
    -            cc.context_length = rt["compressor_context_length"]
    -            cc.threshold_tokens = rt["compressor_threshold_tokens"]
    +            cc.update_model(
    +                model=rt["compressor_model"],
    +                context_length=rt["compressor_context_length"],
    +                base_url=rt["compressor_base_url"],
    +                api_key=rt["compressor_api_key"],
    +                provider=rt["compressor_provider"],
    +            )
     
                 # ── Reset fallback chain for the new turn ──
                 self._fallback_activated = False
    ```

    ```diff
    @@ -6878,6 +6951,29 @@ def _execute_tool_calls_sequential(self, assistant_message, messages: list, effe
                             spinner.stop(cute_msg)
                         elif self._should_emit_quiet_tool_messages():
                             self._vprint(f"  {cute_msg}")
    +            elif self._context_engine_tool_names and function_name in self._context_engine_tool_names:
    +                # Context engine tools (lcm_grep, lcm_describe, lcm_expand, etc.)
    +                spinner = None
    +                if self.quiet_mode and not self.tool_progress_callback:
    +                    face = random.choice(KawaiiSpinner.KAWAII_WAITING)
    +                    emoji = _get_tool_emoji(function_name)
    +                    preview = _build_tool_preview(function_name, function_args) or function_name
    +                    spinner = KawaiiSpinner(f"{face} {emoji} {preview}", spinner_type='dots', print_fn=self._print_fn)
    +                    spinner.start()
    +                _ce_result = None
    +                try:
    +                    function_result = self.context_compressor.handle_tool_call(function_name, function_args, messages=messages)
    +                    _ce_result = function_result
    +                except Exception as tool_error:
    +                    function_result = json.dumps({"error": f"Context engine tool '{function_name}' failed: {tool_error}"})
    +                    logger.error("context_engine.handle_tool_call raised for %s: %s", function_name, tool_error, exc_info=True)
    +                finally:
    +                    tool_duration = time.time() - tool_start_time
    +                    cute_msg = _get_cute_tool_message_impl(function_name, function_args, tool_duration, result=_ce_result)
    +                    if spinner:
    +                        spinner.stop(cute_msg)
    +                    elif self.quiet_mode:
    +                        self._vprint(f"  {cute_msg}")
                 elif self._memory_manager and self._memory_manager.has_tool(function_name):
                     # Memory provider tools (hindsight_retain, honcho_search, etc.)
                     # These are not in the tool registry — route through MemoryManager.
    ```

    ```diff
    @@ -8192,7 +8288,7 @@ def _stop_spinner():
                             # Cache discovered context length after successful call.
                             # Only persist limits confirmed by the provider (parsed
                             # from the error message), not guessed probe tiers.
    -                        if self.context_compressor._context_probed:
    +                        if getattr(self.context_compressor, "_context_probed", False):
                                 ctx = self.context_compressor.context_length
                                 if getattr(self.context_compressor, "_context_probe_persistable", False):
                                     save_context_length(self.model, self.base_url, ctx)
    ```

    ```diff
    @@ -8531,16 +8627,22 @@ def _stop_spinner():
                             compressor = self.context_compressor
                             old_ctx = compressor.context_length
                             if old_ctx > _reduced_ctx:
    -                            compressor.context_length = _reduced_ctx
    -                            compressor.threshold_tokens = int(
    -                                _reduced_ctx * compressor.threshold_percent
    +                            compressor.update_model(
    +                                model=self.model,
    +                                context_length=_reduced_ctx,
    +                                base_url=self.base_url,
    +                                api_key=[redacted:secret-assignment] "api_key", ""),
    +                                provider=self.provider,
                                 )
    -                            compressor._context_probed = True
    -                            # Don't persist — this is a subscription-tier
    -                            # limitation, not a model capability.  If the user
    -                            # later enables extra usage the 1M limit should
    -                            # come back automatically.
    -                            compressor._context_probe_persistable = False
    +                            # Context probing flags — only set on built-in
    +                            # compressor (plugin engines manage their own).
    +                            if hasattr(compressor, "_context_probed"):
    +                                compressor._context_probed = True
    +                                # Don't persist — this is a subscription-tier
    +                                # limitation, not a model capability.  If the
    +                                # user later enables extra usage the 1M limit
    +                                # should come back automatically.
    +                                compressor._context_probe_persistable = False
                                 self._vprint(
                                     f"{self.log_prefix}⚠️  Anthropic long-context tier "
                                     f"requires extra usage — reducing context: "
    ```

    ```diff
    @@ -8704,17 +8806,25 @@ def _stop_spinner():
                                 new_ctx = get_next_probe_tier(old_ctx)
     
                             if new_ctx and new_ctx < old_ctx:
    -                            compressor.context_length = new_ctx
    -                            compressor.threshold_tokens = [redacted:secret-assignment] * compressor.threshold_percent)
    -                            compressor._context_probed = True
    -                            # Only persist limits parsed from the provider's
    -                            # error message (a real number).  Guessed fallback
    -                            # tiers from get_next_probe_tier() should stay
    -                            # in-memory only — persisting them pollutes the
    -                            # cache with wrong values.
    -                            compressor._context_probe_persistable = bool(
    -                                parsed_limit and parsed_limit == new_ctx
    +                            compressor.update_model(
    +                                model=self.model,
    +                                context_length=new_ctx,
    +                                base_url=self.base_url,
    +                                api_key=[redacted:secret-assignment] "api_key", ""),
    +                                provider=self.provider,
                                 )
    +                            # Context probing flags — only set on built-in
    +                            # compressor (plugin engines manage their own).
    +                            if hasattr(compressor, "_context_probed"):
    +                                compressor._context_probed = True
    +                                # Only persist limits parsed from the provider's
    +                                # error message (a real number).  Guessed fallback
    +                                # tiers from get_next_probe_tier() should stay
    +                                # in-memory only — persisting them pollutes the
    +                                # cache with wrong values.
    +                                compressor._context_probe_persistable = bool(
    +                                    parsed_limit and parsed_limit == new_ctx
    +                                )
                                 self._vprint(f"{self.log_prefix}⚠️  Context length exceeded — stepping down: {old_ctx:,} → {new_ctx:,} tokens", force=True)
                             else:
                                 self._vprint(f"{self.log_prefix}⚠️  Context length exceeded at minimum tier — attempting compression...", force=True)
    ```
  - `tests/agent/test_context_engine.py`

    ```diff
    @@ -0,0 +1,250 @@
    +"""Tests for the ContextEngine ABC and plugin slot."""
    +
    +import json
    +import pytest
    +from typing import Any, Dict, List
    +
    +from agent.context_engine import ContextEngine
    +from agent.context_compressor import ContextCompressor
    +
    +
    +# ---------------------------------------------------------------------------
    +# A minimal concrete engine for testing the ABC
    +# ---------------------------------------------------------------------------
    +
    +class StubEngine(ContextEngine):
    +    """Minimal engine that satisfies the ABC without doing real work."""
    +
    +    def __init__(self, context_length=200000, threshold_pct=0.50):
    +        self.context_length = context_length
    +        self.threshold_tokens = [redacted:secret-assignment] * threshold_pct)
    +        self._compress_called = False
    +        self._tools_called = []
    +
    +    @property
    +    def name(self) -> str:
    +        return "stub"
    +
    +    def update_from_response(self, usage: Dict[str, Any]) -> None:
    +        self.last_prompt_tokens = [redacted:secret-assignment]"prompt_tokens", 0)
    +        self.last_completion_tokens = [redacted:secret-assignment]"completion_tokens", 0)
    +        self.last_total_tokens = [redacted:secret-assignment]"total_tokens", 0)
    +
    +    def should_compress(self, prompt_tokens: int = None) -> bool:
    +        tokens = [redacted:secret-assignment] if prompt_tokens is not None else self.last_prompt_tokens
    +        return tokens >= self.threshold_tokens
    +
    +    def compress(self, messages: List[Dict[str, Any]], current_tokens: int = None) -> List[Dict[str, Any]]:
    +        self._compress_called = True
    +        self.compression_count += 1
    +        # Trivial: just return as-is
    +        return messages
    +
    +    def get_tool_schemas(self) -> List[Dict[str, Any]]:
    +        return [
    +            {
    +                "name": "stub_search",
    +                "description": "Search the stub engine",
    +                "parameters": {"type": "object", "properties": {}},
    +            }
    +        ]
    +
    +    def handle_tool_call(self, name: str, args: Dict[str, Any]) -> str:
    +        self._tools_called.append(name)
    +        return json.dumps({"ok": True, "tool": name})
    +
    +
    +# ---------------------------------------------------------------------------
    +# ABC contract tests
    +# ---------------------------------------------------------------------------
    +
    +class TestContextEngineABC:
    +    """Verify the ABC enforces the required interface."""
    +
    +    def test_cannot_instantiate_abc_directly(self):
    +        with pytest.raises(TypeError):
    +            ContextEngine()
    +
    +    def test_missing_methods_raises(self):
    +        """A subclass missing required methods cannot be instantiated."""
    +        class Incomplete(ContextEngine):
    +            @property
    +            def name(self):
    +                return "incomplete"
    +        with pytest.raises(TypeError):
    +            Incomplete()
    +
    +    def test_stub_engine_satisfies_abc(self):
    +        engine = StubEngine()
    +        assert isinstance(engine, ContextEngine)
    +        assert engine.name == "stub"
    +
    +    def test_compressor_is_context_engine(self):
    +        c = ContextCompressor(model="test", quiet_mode=True, config_context_length=200000)
    +        assert isinstance(c, ContextEngine)
    +        assert c.name == "compressor"
    +
    +
    +# ---------------------------------------------------------------------------
    +# Default method behavior
    +# ---------------------------------------------------------------------------
    +
    +class TestDefaults:
    +    """Verify ABC default implementations work correctly."""
    +
    +    def test_default_tool_schemas_empty(self):
    +        engine = StubEngine()
    +        # StubEngine overrides this, so test the base via super
    +        assert ContextEngine.get_tool_schemas(engine) == []
    +
    +    def test_default_handle_tool_call_returns_error(self):
    +        engine = StubEngine()
    +        result = ContextEngine.handle_tool_call(engine, "unknown", {})
    +        data = json.loads(result)
    +        assert "error" in data
    +
    +    def test_default_get_status(self):
    +        engine = StubEngine()
    +        engine.last_prompt_tokens = 50000
    +        status = engine.get_status()
    +        assert status["last_prompt_tokens"] == 50000
    +        assert status["context_length"] == 200000
    +        assert status["threshold_tokens"] == 100000
    +        assert 0 < status["usage_percent"] <= 100
    +
    +    def test_on_session_reset(self):
    +        engine = StubEngine()
    +        engine.last_prompt_tokens = 999
    +        engine.compression_count = 3
    +        engine.on_session_reset()
    +        assert engine.last_prompt_tokens == 0
    +        assert engine.compression_count == 0
    +
    +    def test_should_compress_preflight_default_false(self):
    +        engine = StubEngine()
    +        assert engine.should_compress_preflight([]) is False
    +
    +
    +# ---------------------------------------------------------------------------
    +# StubEngine behavior
    +# ---------------------------------------------------------------------------
    +
    +class TestStubEngine:
    +
    +    def test_should_compress(self):
    +        engine = StubEngine(context_length=100000, threshold_pct=0.50)
    +        assert not engine.should_compress(40000)
    +        assert engine.should_compress(50000)
    +        assert engine.should_compress(60000)
    +
    +    def test_compress_tracks_count(self):
    +        engine = StubEngine()
    +        msgs = [{"role": "user", "content": "hello"}]
    +        result = engine.compress(msgs)
    +        assert result == msgs
    +        assert engine._compress_called
    +        assert engine.compression_count == 1
    +
    +    def test_tool_schemas(self):
    +        engine = StubEngine()
    +        schemas = engine.get_tool_schemas()
    +        assert len(schemas) == 1
    +        assert schemas[0]["name"] == "stub_search"
    +
    +    def test_handle_tool_call(self):
    +        engine = StubEngine()
    +        result = engine.handle_tool_call("stub_search", {})
    +        assert json.loads(result)["ok"] is True
    +        assert "stub_search" in engine._tools_called
    +
    +    def test_update_from_response(self):
    +        engine = StubEngine()
    +        engine.update_from_response({"prompt_tokens": 1000, "completion_tokens": 200, "total_tokens": 1200})
    +        assert engine.last_prompt_tokens == 1000
    +        assert engine.last_completion_tokens == 200
    +
    +
    +# ---------------------------------------------------------------------------
    +# ContextCompressor session reset via ABC
    +# ---------------------------------------------------------------------------
    +
    +class TestCompressorSessionReset:
    +    """Verify ContextCompressor.on_session_reset() clears all state."""
    +
    +    def test_reset_clears_state(self):
    +        c = ContextCompressor(model="test", quiet_mode=True, config_context_length=200000)
    +        c.last_prompt_tokens = 50000
    +        c.compression_count = 3
    +        c._previous_summary = "some old summary"
    +        c._context_probed = True
    +        c._context_probe_persistable = True
    +
    +        c.on_session_reset()
    +
    +        assert c.last_prompt_tokens == 0
    +        assert c.last_completion_tokens == 0
    +        assert c.last_total_tokens == 0
    +        assert c.compression_count == 0
    +        assert c._context_probed is False
    +        assert c._context_probe_persistable is False
    +        assert c._previous_summary is None
    +
    +
    +# ---------------------------------------------------------------------------
    +# Plugin slot (PluginManager integration)
    +# ---------------------------------------------------------------------------
    +
    +class TestPluginContextEngineSlot:
    +    """Test register_context_engine on PluginContext."""
    +
    +    def test_register_engine(self):
    +        from hermes_cli.plugins import PluginManager, PluginContext, PluginManifest
    +        mgr = PluginManager()
    +        manifest = PluginManifest(name="test-lcm")
    +        ctx = PluginContext(manifest, mgr)
    +
    +        engine = StubEngine()
    +        ctx.register_context_engine(engine)
    +
    +        assert mgr._context_engine is engine
    +        assert mgr._context_engine.name == "stub"
    +
    +    def test_reject_second_engine(self):
    +        from hermes_cli.plugins import PluginManager, PluginContext, PluginManifest
    +        mgr = PluginManager()
    +        manifest = PluginManifest(name="test-lcm")
    +        ctx = PluginContext(manifest, mgr)
    +
    +        engine1 = StubEngine()
    +        engine2 = StubEngine()
    +        ctx.register_context_engine(engine1)
    +        ctx.register_context_engine(engine2)  # should be rejected
    +
    +        assert mgr._context_engine is engine1
    +
    +    def test_reject_non_engine(self):
    +        from hermes_cli.plugins import PluginManager, PluginContext, PluginManifest
    +        mgr = PluginManager()
    +        manifest = PluginManifest(name="test-bad")
    +        ctx = PluginContext(manifest, mgr)
    +
    +        ctx.register_context_engine("not an engine")
    +        assert mgr._context_engine is None
    +
    +    def test_get_plugin_context_engine(self):
    +        from hermes_cli.plugins import PluginManager, PluginContext, PluginManifest, get_plugin_context_engine, _plugin_manager
    +        import hermes_cli.plugins as plugins_mod
    +
    +        # Inject a test manager
    +        old_mgr = plugins_mod._plugin_manager
    +        try:
    +            mgr = PluginManager()
    +            plugins_mod._plugin_manager = mgr
    +
    +            assert get_plugin_context_engine() is None
    +
    +            engine = StubEngine()
    +            mgr._context_engine = engine
    +            assert get_plugin_context_engine() is engine
    +        finally:
    +            plugins_mod._plugin_manager = old_mgr
    ```
  - `tests/hermes_cli/test_plugins_cmd.py`

    ```diff
    @@ -555,3 +555,103 @@ def test_keyboard_interrupt_skips_gracefully(self):
     
             # Should not crash, and not save anything
             mock_save.assert_not_called()
    +
    +
    +# ── curses_radiolist ─────────────────────────────────────────────────────
    +
    +
    +class TestCursesRadiolist:
    +    """Test the curses_radiolist function (non-TTY fallback path)."""
    +
    +    def test_non_tty_returns_default(self):
    +        from hermes_cli.curses_ui import curses_radiolist
    +        with patch("sys.stdin") as mock_stdin:
    +            mock_stdin.isatty.return_value = False
    +            result = curses_radiolist("Pick one", ["a", "b", "c"], selected=1)
    +            assert result == 1
    +
    +    def test_non_tty_returns_cancel_value(self):
    +        from hermes_cli.curses_ui import curses_radiolist
    +        with patch("sys.stdin") as mock_stdin:
    +            mock_stdin.isatty.return_value = False
    +            result = curses_radiolist("Pick", ["x", "y"], selected=0, cancel_returns=1)
    +            assert result == 1
    +
    +
    +# ── Provider discovery helpers ───────────────────────────────────────────
    +
    +
    +class TestProviderDiscovery:
    +    """Test provider plugin discovery and config helpers."""
    +
    +    def test_get_current_memory_provider_default(self, tmp_path, monkeypatch):
    +        """Empty config returns empty string."""
    +        monkeypatch.setenv("HERMES_HOME", str(tmp_path))
    +        config_file = tmp_path / "config.yaml"
    +        config_file.write_text("memory:\n  provider: ''\n")
    +        from hermes_cli.plugins_cmd import _get_current_memory_provider
    +        result = _get_current_memory_provider()
    +        assert result == ""
    +
    +    def test_get_current_context_engine_default(self, tmp_path, monkeypatch):
    +        """Default config returns 'compressor'."""
    +        monkeypatch.setenv("HERMES_HOME", str(tmp_path))
    +        config_file = tmp_path / "config.yaml"
    +        config_file.write_text("context:\n  engine: compressor\n")
    +        from hermes_cli.plugins_cmd import _get_current_context_engine
    +        result = _get_current_context_engine()
    +        assert result == "compressor"
    +
    +    def test_save_memory_provider(self, tmp_path, monkeypatch):
    +        """Saving a memory provider persists to config.yaml."""
    +        monkeypatch.setenv("HERMES_HOME", str(tmp_path))
    +        config_file = tmp_path / "config.yaml"
    +        config_file.write_text("memory:\n  provider: ''\n")
    +        from hermes_cli.plugins_cmd import _save_memory_provider
    +        _save_memory_provider("honcho")
    +        content = yaml.safe_load(config_file.read_text())
    +        assert content["memory"]["provider"] == "honcho"
    +
    +    def test_save_context_engine(self, tmp_path, monkeypatch):
    +        """Saving a context engine persists to config.yaml."""
    +        monkeypatch.setenv("HERMES_HOME", str(tmp_path))
    +        config_file = tmp_path / "config.yaml"
    +        config_file.write_text("context:\n  engine: compressor\n")
    +        from hermes_cli.plugins_cmd import _save_context_engine
    +        _save_context_engine("lcm")
    +        content = yaml.safe_load(config_file.read_text())
    +        assert content["context"]["engine"] == "lcm"
    +
    +    def test_discover_memory_providers_empty(self):
    +        """Discovery returns empty list when import fails."""
    +        with patch("plugins.memory.discover_memory_providers",
    +                    side_effect=ImportError("no module")):
    +            from hermes_cli.plugins_cmd import _discover_memory_providers
    +            result = _discover_memory_providers()
    +            assert result == []
    +
    +    def test_discover_context_engines_empty(self):
    +        """Discovery returns empty list when import fails."""
    +        with patch("plugins.context_engine.discover_context_engines",
    +                    side_effect=ImportError("no module")):
    +            from hermes_cli.plugins_cmd import _discover_context_engines
    +            result = _discover_context_engines()
    +            assert result == []
    +
    +
    +# ── Auto-activation fix ──────────────────────────────────────────────────
    +
    +
    +class TestNoAutoActivation:
    +    """Verify that plugin engines don't auto-activate when config says 'compressor'."""
    +
    +    def test_compressor_default_ignores_plugin(self):
    +        """When context.engine is 'compressor', a plugin-registered engine should NOT
    +        be used — only explicit config triggers plugin engines."""
    +        # This tests the run_agent.py logic indirectly by checking that the
    +        # code path for default config doesn't call get_plugin_context_engine.
    +        import run_agent as ra_module
    +        source = open(ra_module.__file__).read()
    +        # The old code had: "Even with default config, check if a plugin registered one"
    +        # The fix removes this. Verify it's gone.
    +        assert "Even with default config, check if a plugin registered one" not in source
    ```
  - `website/docs/developer-guide/agent-loop.md`

    ```diff
    @@ -226,7 +226,8 @@ After each turn:
     |------|---------|
     | `run_agent.py` | AIAgent class — the complete agent loop (~9,200 lines) |
     | `agent/prompt_builder.py` | System prompt assembly from memory, skills, context files, personality |
    -| `agent/context_compressor.py` | Conversation compression algorithm |
    +| `agent/context_engine.py` | ContextEngine ABC — pluggable context management |
    +| `agent/context_compressor.py` | Default engine — lossy summarization algorithm |
     | `agent/prompt_caching.py` | Anthropic prompt caching markers and cache metrics |
     | `agent/auxiliary_client.py` | Auxiliary LLM client for side tasks (vision, summarization) |
     | `model_tools.py` | Tool schema collection, `handle_function_call()` dispatch |
    ```
  - `website/docs/developer-guide/architecture.md`

    ```diff
    @@ -62,7 +62,8 @@ hermes-agent/
     │
     ├── agent/                    # Agent internals
     │   ├── prompt_builder.py     # System prompt assembly
    -│   ├── context_compressor.py # Conversation compression algorithm
    +│   ├── context_engine.py     # ContextEngine ABC (pluggable)
    +│   ├── context_compressor.py # Default engine — lossy summarization
     │   ├── prompt_caching.py     # Anthropic prompt caching
     │   ├── auxiliary_client.py   # Auxiliary LLM for side tasks (vision, summarization)
     │   ├── model_metadata.py     # Model context lengths, token estimation
    ```

    ```diff
    @@ -123,6 +124,7 @@ hermes-agent/
     ├── acp_adapter/              # ACP server (VS Code / Zed / JetBrains)
     ├── cron/                     # Scheduler (jobs.py, scheduler.py)
     ├── plugins/memory/           # Memory provider plugins
    +├── plugins/context_engine/   # Context engine plugins
     ├── environments/             # RL training environments (Atropos)
     ├── skills/                   # Bundled skills (always available)
     ├── optional-skills/          # Official optional skills (install explicitly)
    ```

    ```diff
    @@ -227,7 +229,7 @@ Long-running process with 14 platform adapters, unified session routing, user au
     
     ### Plugin System
     
    -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/`.
    +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`.
     
     → [Plugin Guide](/docs/guides/build-a-hermes-plugin), [Memory Provider Plugin](./memory-provider-plugin.md)
     
    ```
  - `website/docs/developer-guide/context-compression-and-caching.md`

    ```diff
    @@ -3,10 +3,37 @@
     Hermes Agent uses a dual compression system and Anthropic prompt caching to
     manage context window usage efficiently across long conversations.
     
    -Source files: `agent/context_compressor.py`, `agent/prompt_caching.py`,
    -`gateway/run.py` (session hygiene), `run_agent.py` (search for `_compress_context`)
    +Source files: `agent/context_engine.py` (ABC), `agent/context_compressor.py` (default engine),
    +`agent/prompt_caching.py`, `gateway/run.py` (session hygiene), `run_agent.py` (search for `_compress_context`)
     
     
    +## Pluggable Context Engine
    +
    +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).
    +
    +```yaml
    +context:
    +  engine: "compressor"    # default — built-in lossy summarization
    +  engine: "lcm"           # example — plugin providing lossless context
    +```
    +
    +The engine is responsible for:
    +- Deciding when compaction should fire (`should_compress()`)
    +- Performing compaction (`compress()`)
    +- Optionally exposing tools the agent can call (e.g., `lcm_grep`)
    +- Tracking token usage from API responses
    +
    +Selection is config-driven via `context.engine` in `config.yaml`. The resolution order:
    +1. Check `plugins/context_engine/<name>/` directory
    +2. Check general plugin system (`register_context_engine()`)
    +3. Fall back to built-in `ContextCompressor`
    +
    +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.
    +
    +Configure via `hermes plugins` → Provider Plugins → Context Engine, or edit `config.yaml` directly.
    +
    +For building a context engine plugin, see [Context Engine Plugins](/docs/developer-guide/context-engine-plugin).
    +
     ## Dual Compression System
     
     Hermes has two separate compression layers that operate independently:
    ```
  - `website/docs/developer-guide/context-engine-plugin.md`

    ```diff
    @@ -0,0 +1,189 @@
    +---
    +sidebar_position: 9
    +title: "Context Engine Plugins"
    +description: "How to build a context engine plugin that replaces the built-in ContextCompressor"
    +---
    +
    +# Building a Context Engine Plugin
    +
    +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.
    +
    +## How it works
    +
    +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.
    +
    +Only **one** context engine can be active at a time. Selection is config-driven:
    +
    +```yaml
    +# config.yaml
    +context:
    +  engine: "compressor"    # default built-in
    +  engine: "lcm"           # activates a plugin engine named "lcm"
    +```
    +
    +Plugin engines are **never auto-activated** — the user must explicitly set `context.engine` to the plugin's name.
    +
    +## Directory structure
    +
    +Each context engine lives in `plugins/context_engine/<name>/`:
    +
    +```
    +plugins/context_engine/lcm/
    +├── __init__.py      # exports the ContextEngine subclass
    +├── plugin.yaml      # metadata (name, description, version)
    +└── ...              # any other modules your engine needs
    +```
    +
    +## The ContextEngine ABC
    +
    +Your engine must implement these **required** methods:
    +
    +```python
    +from agent.context_engine import ContextEngine
    +
    +class LCMEngine(ContextEngine):
    +
    +    @property
    +    def name(self) -> str:
    +        """Short identifier, e.g. 'lcm'. Must match config.yaml value."""
    +        return "lcm"
    +
    +    def update_from_response(self, usage: dict) -> None:
    +        """Called after every LLM call with the usage dict.
    +
    +        Update self.last_prompt_tokens, self.last_completion_tokens,
    +        self.last_total_tokens from the response.
    +        """
    +
    +    def should_compress(self, prompt_tokens: int = None) -> bool:
    +        """Return True if compaction should fire this turn."""
    +
    +    def compress(self, messages: list, current_tokens: int = None) -> list:
    +        """Compact the message list and return a new (possibly shorter) list.
    +
    +        The returned list must be a valid OpenAI-format message sequence.
    +        """
    +```
    +
    +### Class attributes your engine must maintain
    +
    +The agent reads these directly for display and logging:
    +
    +```python
    +last_prompt_tokens: int = 0
    +last_completion_tokens: int = 0
    +last_total_tokens: int = 0
    +threshold_tokens: int = 0        # when compression triggers
    +context_length: int = 0          # model's full context window
    +compression_count: int = 0       # how many times compress() has run
    +```
    +
    +### Optional methods
    +
    +These have sensible defaults in the ABC. Override as needed:
    +
    +| Method | Default | Override when |
    +|--------|---------|--------------|
    +| `on_session_start(session_id, **kwargs)` | No-op | You need to load persisted state (DAG, DB) |
    +| `on_session_end(session_id, messages)` | No-op | You need to flush state, close connections |
    +| `on_session_reset()` | Resets token counters | You have per-session state to clear |
    +| `update_model(model, context_length, ...)` | Updates context_length + threshold | You need to recalculate budgets on model switch |
    +| `get_tool_schemas()` | Returns `[]` | Your engine provides agent-callable tools (e.g., `lcm_grep`) |
    +| `handle_tool_call(name, args, **kwargs)` | Returns error JSON | You implement tool handlers |
    +| `should_compress_preflight(messages)` | Returns `False` | You can do a cheap pre-API-call estimate |
    +| `get_status()` | Standard token/threshold dict | You have custom metrics to expose |
    +
    +## Engine tools
    +
    +Context engines can expose tools the agent calls directly. Return schemas from `get_tool_schemas()` and handle calls in `handle_tool_call()`:
    +
    +```python
    +def get_tool_schemas(self):
    +    return [{
    +        "name": "lcm_grep",
    +        "description": "Search the context knowledge graph",
    +        "parameters": {
    +            "type": "object",
    +            "properties": {
    +                "query": {"type": "string", "description": "Search query"}
    +            },
    +            "required": ["query"],
    +        },
    +    }]
    +
    +def handle_tool_call(self, name, args, **kwargs):
    +    if name == "lcm_grep":
    +        results = self._search_dag(args["query"])
    +        return json.dumps({"results": results})
    +    return json.dumps({"error": f"Unknown tool: {name}"})
    +```
    +
    +Engine tools are injected into the agent's tool list at startup and dispatched automatically — no registry registration needed.
    +
    +## Registration
    +
    +### Via directory (recommended)
    +
    +Place your engine in `plugins/context_engine/<name>/`. The `__init__.py` must export a `ContextEngine` subclass. The discovery system finds and instantiates it automatically.
    +
    +### Via general plugin system
    +
    +A general plugin can also register a context engine:
    +
    +```python
    +def register(ctx):
    +    engine = LCMEngine(context_length=200000)
    +    ctx.register_context_engine(engine)
    +```
    +
    +Only one engine can be registered. A second plugin attempting to register is rejected with a warning.
    +
    +## Lifecycle
    +
    +```
    +1. Engine instantiated (plugin load or directory discovery)
    +2. on_session_start() — conversation begins
    +3. update_from_response() — after each API call
    +4. should_compress() — checked each turn
    +5. compress() — called when should_compress() returns True
    +6. on_session_end() — session boundary (CLI exit, /reset, gateway expiry)
    +```
    +
    +`on_session_reset()` is called on `/new` or `/reset` to clear per-session state without a full shutdown.
    +
    +## Configuration
    +
    +Users select your engine via `hermes plugins` → Provider Plugins → Context Engine, or by editing `config.yaml`:
    +
    +```yaml
    +context:
    +  engine: "lcm"   # must match your engine's name property
    +```
    +
    +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.
    +
    +## Testing
    +
    +```python
    +from agent.context_engine import ContextEngine
    +
    +def test_engine_satisfies_abc():
    +    engine = YourEngine(context_length=200000)
    +    assert isinstance(engine, ContextEngine)
    +    assert engine.name == "your-name"
    +
    +def test_compress_returns_valid_messages():
    +    engine = YourEngine(context_length=200000)
    +    msgs = [{"role": "user", "content": "hello"}]
    +    result = engine.compress(msgs)
    +    assert isinstance(result, list)
    +    assert all("role" in m for m in result)
    +```
    +
    +See `tests/agent/test_context_engine.py` for the full ABC contract test suite.
    +
    +## See also
    +
    +- [Context Compression and Caching](/docs/developer-guide/context-compression-and-caching) — how the built-in compressor works
    +- [Memory Provider Plugins](/docs/developer-guide/memory-provider-plugin) — analogous single-select plugin system for memory
    +- [Plugins](/docs/user-guide/features/plugins) — general plugin system overview
    ```
  - `website/docs/developer-guide/memory-provider-plugin.md`

    ```diff
    @@ -8,6 +8,10 @@ description: "How to build a memory provider plugin for Hermes Agent"
     
     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.
     
    +:::tip
    +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`.
    +:::
    +
     ## Directory Structure
     
     Each memory provider lives in `plugins/memory/<name>/`:
    ```
  - `website/docs/guides/build-a-hermes-plugin.md`

    ```diff
    @@ -547,6 +547,12 @@ After registration, users can run `hermes my-plugin status`, `hermes my-plugin c
     
     **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.
     
    +:::tip
    +This guide covers **general plugins** (tools, hooks, CLI commands). For specialized plugin types, see:
    +- [Memory Provider Plugins](/docs/developer-guide/memory-provider-plugin) — cross-session knowledge backends
    +- [Context Engine Plugins](/docs/developer-guide/context-engine-plugin) — alternative context management strategies
    +:::
    +
     ### Distribute via pip
     
     For sharing plugins publicly, add an entry point to your Python package:
    ```
  - `website/docs/reference/cli-commands.md`

    ```diff
    @@ -586,11 +586,14 @@ See [MCP Config Reference](./mcp-config-reference.md), [Use MCP with Hermes](../
     hermes plugins [subcommand]
     ```
     
    -Manage Hermes Agent plugins. Running `hermes plugins` with no subcommand launches an interactive curses checklist to enable/disable installed plugins.
    +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:
    +
    +- **General Plugins** — multi-select checkboxes to enable/disable installed plugins
    +- **Provider Plugins** — single-select configuration for Memory Provider and Context Engine. Press ENTER on a category to open a radio picker.
     
     | Subcommand | Description |
     |------------|-------------|
    -| *(none)* | Interactive toggle UI — enable/disable plugins with arrow keys and space. |
    +| *(none)* | Composite interactive UI — general plugin toggles + provider plugin configuration. |
     | `install <identifier> [--force]` | Install a plugin from a Git URL or `owner/repo`. |
     | `update <name>` | Pull latest changes for an installed plugin. |
     | `remove <name>` (aliases: `rm`, `uninstall`) | Remove an installed plugin. |
    ```

    ```diff
    @@ -598,7 +601,11 @@ Manage Hermes Agent plugins. Running `hermes plugins` with no subcommand launche
     | `disable <name>` | Disable a plugin without removing it. |
     | `list` (alias: `ls`) | List installed plugins with enabled/disabled status. |
     
    -Disabled plugins are stored in `config.yaml` under `plugins.disabled` and skipped during loading.
    +Provider plugin selections are saved to `config.yaml`:
    +- `memory.provider` — active memory provider (empty = built-in only)
    +- `context.engine` — active context engine (`"compressor"` = built-in default)
    +
    +General plugin disabled list is stored in `config.yaml` under `plugins.disabled`.
     
     See [Plugins](../user-guide/features/plugins.md) and [Build a Hermes Plugin](../guides/build-a-hermes-plugin.md).
     
    ```
  - `website/docs/user-guide/configuration.md`

    ```diff
    @@ -482,6 +482,26 @@ Points at a custom OpenAI-compatible endpoint. Uses `OPENAI_API_KEY` for auth.
     
     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.
     
    +## Context Engine
    +
    +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.
    +
    +```yaml
    +context:
    +  engine: "compressor"    # default — built-in lossy summarization
    +```
    +
    +To use a plugin engine (e.g., LCM for lossless context management):
    +
    +```yaml
    +context:
    +  engine: "lcm"          # must match the plugin's name
    +```
    +
    +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.
    +
    +See [Memory Providers](/docs/user-guide/features/memory-providers) for the analogous single-select system for memory plugins.
    +
     ## Iteration Budget Pressure
     
     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:
    ```
  - `website/docs/user-guide/features/memory-providers.md`

    ```diff
    @@ -16,6 +16,8 @@ hermes memory status     # check what's active
     hermes memory off        # disable external provider
     ```
     
    +You can also select the active memory provider via `hermes plugins` → Provider Plugins → Memory Provider.
    +
     Or set manually in `~/.hermes/config.yaml`:
     
     ```yaml
    ```
  - `website/docs/user-guide/features/overview.md`

    ```diff
    @@ -48,4 +48,4 @@ Hermes Agent includes a rich set of capabilities that extend far beyond basic ch
     
     - **[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.
     - **[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.
    -- **[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.
    +- **[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.
    ```
  - `website/docs/user-guide/features/plugins.md`

    ```diff
    @@ -111,10 +111,22 @@ Plugins can register callbacks for these lifecycle events. See the **[Event Hook
     | [`on_session_start`](/docs/user-guide/features/hooks#on_session_start) | New session created (first turn only) |
     | [`on_session_end`](/docs/user-guide/features/hooks#on_session_end) | End of every `run_conversation` call + CLI exit handler |
     
    +## Plugin types
    +
    +Hermes has three kinds of plugins:
    +
    +| Type | What it does | Selection | Location |
    +|------|-------------|-----------|----------|
    +| **General plugins** | Add tools, hooks, CLI commands | Multi-select (enable/disable) | `~/.hermes/plugins/` |
    +| **Memory providers** | Replace or augment built-in memory | Single-select (one active) | `plugins/memory/` |
    +| **Context engines** | Replace the built-in context compressor | Single-select (one active) | `plugins/context_engine/` |
    +
    +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.
    +
     ## Managing plugins
     
     ```bash
    -hermes plugins                  # interactive toggle UI — enable/disable with checkboxes
    +hermes plugins                  # unified interactive UI
     hermes plugins list             # table view with enabled/disabled status
     hermes plugins install user/repo  # install from Git
     hermes plugins update my-plugin   # pull latest
    ```

    ```diff
    @@ -123,7 +135,37 @@ hermes plugins enable my-plugin   # re-enable a disabled plugin
     hermes plugins disable my-plugin  # disable without removing
     ```
     
    -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.
    +### Interactive UI
    +
    +Running `hermes plugins` with no arguments opens a composite interactive screen:
    +
    +```
    +Plugins
    +  ↑↓ navigate  SPACE toggle  ENTER configure/confirm  ESC done
    +
    +  General Plugins
    + → [✓] my-tool-plugin — Custom search tool
    +   [ ] webhook-notifier — Event hooks
    +
    +  Provider Plugins
    +     Memory Provider          ▸ honcho
    +     Context Engine           ▸ compressor
    +```
    +
    +- **General Plugins section** — checkboxes, toggle with SPACE
    +- **Provider Plugins section** — shows current selection. Press ENTER to drill into a radio picker where you choose one active provider.
    +
    +Provider plugin selections are saved to `config.yaml`:
    +
    +```yaml
    +memory:
    +  provider: "honcho"      # empty string = built-in only
    +
    +context:
    +  engine: "compressor"    # default built-in compressor
    +```
    +
    +### Disabling general plugins
     
     Disabled plugins remain installed but are skipped during loading. The disabled list is stored in `config.yaml` under `plugins.disabled`:
     
    ```
  - `website/sidebars.ts`

    ```diff
    @@ -176,6 +176,7 @@ const sidebars: SidebarsConfig = {
                 'developer-guide/adding-tools',
                 'developer-guide/adding-providers',
                 'developer-guide/memory-provider-plugin',
    +            'developer-guide/context-engine-plugin',
                 'developer-guide/creating-skills',
                 'developer-guide/extending-the-cli',
               ],
    ```
- **#15039: fix(compress): don't reach into ContextCompressor privates from /compress**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `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:
  - `agent/context_compressor.py`

    ```diff
    @@ -1099,6 +1099,21 @@ def _find_tail_cut_by_tokens(
     
             return max(cut_idx, head_end + 1)
     
    +    # ------------------------------------------------------------------
    +    # ContextEngine: manual /compress preflight
    +    # ------------------------------------------------------------------
    +
    +    def has_content_to_compress(self, messages: List[Dict[str, Any]]) -> bool:
    +        """Return True if there is a non-empty middle region to compact.
    +
    +        Overrides the ABC default so the gateway ``/compress`` guard can
    +        skip the LLM call when the transcript is still entirely inside
    +        the protected head/tail.
    +        """
    +        compress_start = self._align_boundary_forward(messages, self.protect_first_n)
    +        compress_end = self._find_tail_cut_by_tokens(messages, compress_start)
    +        return compress_start < compress_end
    +
         # ------------------------------------------------------------------
         # Main compression entry point
         # ------------------------------------------------------------------
    ```
  - `agent/context_engine.py`

    ```diff
    @@ -78,6 +78,7 @@ def compress(
             self,
             messages: List[Dict[str, Any]],
             current_tokens: int = None,
    +        focus_topic: str = None,
         ) -> List[Dict[str, Any]]:
             """Compact the message list and return the new message list.
     
    ```

    ```diff
    @@ -86,6 +87,12 @@ def compress(
             context budget. The implementation is free to summarize, build a
             DAG, or do anything else — as long as the returned list is a valid
             OpenAI-format message sequence.
    +
    +        Args:
    +            focus_topic: Optional topic string from manual ``/compress <focus>``.
    +                Engines that support guided compression should prioritise
    +                preserving information related to this topic.  Engines that
    +                don't support it may simply ignore this argument.
             """
     
         # -- Optional: pre-flight check ----------------------------------------
    ```

    ```diff
    @@ -98,6 +105,21 @@ def should_compress_preflight(self, messages: List[Dict[str, Any]]) -> bool:
             """
             return False
     
    +    # -- Optional: manual /compress preflight ------------------------------
    +
    +    def has_content_to_compress(self, messages: List[Dict[str, Any]]) -> bool:
    +        """Quick check: is there anything in ``messages`` that can be compacted?
    +
    +        Used by the gateway ``/compress`` command as a preflight guard —
    +        returning False lets the gateway report "nothing to compress yet"
    +        without making an LLM call.
    +
    +        Default returns True (always attempt).  Engines with a cheap way
    +        to introspect their own head/tail boundaries should override this
    +        to return False when the transcript is still entirely protected.
    +        """
    +        return True
    +
         # -- Optional: session lifecycle ---------------------------------------
     
         def on_session_start(self, session_id: str, **kwargs) -> None:
    ```
  - `gateway/run.py`

    ```diff
    @@ -7102,10 +7102,7 @@ async def _handle_compress_command(self, event: MessageEvent) -> str:
                     tmp_agent._print_fn = lambda *a, **kw: None
     
                     compressor = tmp_agent.context_compressor
    -                compress_start = compressor.protect_first_n
    -                compress_start = compressor._align_boundary_forward(msgs, compress_start)
    -                compress_end = compressor._find_tail_cut_by_tokens(msgs, compress_start)
    -                if compress_start >= compress_end:
    +                if not compressor.has_content_to_compress(msgs):
                         return "Nothing to compress yet (the transcript is still all protected context)."
     
                     loop = asyncio.get_running_loop()
    ```
  - `run_agent.py`

    ```diff
    @@ -7556,7 +7556,12 @@ def _compress_context(self, messages: list, system_message: str, *, approx_token
                 except Exception:
                     pass
     
    -        compressed = self.context_compressor.compress(messages, current_tokens=[redacted:secret-assignment] focus_topic=focus_topic)
    +        try:
    +            compressed = self.context_compressor.compress(messages, current_tokens=[redacted:secret-assignment] focus_topic=focus_topic)
    +        except TypeError:
    +            # Plugin context engine with strict signature that doesn't accept
    +            # focus_topic — fall back to calling without it.
    +            compressed = self.context_compressor.compress(messages, current_tokens=[redacted:secret-assignment]
     
             todo_snapshot = self._todo_store.format_for_injection()
             if todo_snapshot:
    ```
  - `tests/gateway/test_compress_command.py`

    ```diff
    @@ -64,9 +64,7 @@ async def test_compress_command_reports_noop_without_success_banner():
         agent_instance = MagicMock()
         agent_instance.shutdown_memory_provider = MagicMock()
         agent_instance.close = MagicMock()
    -    agent_instance.context_compressor.protect_first_n = 0
    -    agent_instance.context_compressor._align_boundary_forward.return_value = 0
    -    agent_instance.context_compressor._find_tail_cut_by_tokens.return_value = 2
    +    agent_instance.context_compressor.has_content_to_compress.return_value = True
         agent_instance.session_id = "sess-1"
         agent_instance._compress_context.return_value = (list(history), "")
     
    ```

    ```diff
    @@ -101,9 +99,7 @@ async def test_compress_command_explains_when_token_estimate_rises():
         agent_instance = MagicMock()
         agent_instance.shutdown_memory_provider = MagicMock()
         agent_instance.close = MagicMock()
    -    agent_instance.context_compressor.protect_first_n = 0
    -    agent_instance.context_compressor._align_boundary_forward.return_value = 0
    -    agent_instance.context_compressor._find_tail_cut_by_tokens.return_value = 2
    +    agent_instance.context_compressor.has_content_to_compress.return_value = True
         agent_instance.session_id = "sess-1"
         agent_instance._compress_context.return_value = (compressed, "")
     
    ```
  - `tests/gateway/test_compress_focus.py`

    ```diff
    @@ -64,9 +64,7 @@ async def test_compress_focus_topic_passed_to_agent():
         compressed = [history[0], history[-1]]
         runner = _make_runner(history)
         agent_instance = MagicMock()
    -    agent_instance.context_compressor.protect_first_n = 0
    -    agent_instance.context_compressor._align_boundary_forward.return_value = 0
    -    agent_instance.context_compressor._find_tail_cut_by_tokens.return_value = 2
    +    agent_instance.context_compressor.has_content_to_compress.return_value = True
         agent_instance.session_id = "sess-1"
         agent_instance._compress_context.return_value = (compressed, "")
     
    ```

    ```diff
    @@ -96,9 +94,7 @@ async def test_compress_no_focus_passes_none():
         history = _make_history()
         runner = _make_runner(history)
         agent_instance = MagicMock()
    -    agent_instance.context_compressor.protect_first_n = 0
    -    agent_instance.context_compressor._align_boundary_forward.return_value = 0
    -    agent_instance.context_compressor._find_tail_cut_by_tokens.return_value = 2
    +    agent_instance.context_compressor.has_content_to_compress.return_value = True
         agent_instance.session_id = "sess-1"
         agent_instance._compress_context.return_value = (list(history), "")
     
    ```
  - `tests/gateway/test_compress_plugin_engine.py`

    ```diff
    @@ -0,0 +1,173 @@
    +"""Regression test: /compress works with context engine plugins.
    +
    +Reported by @selfhostedsoul (Discord, Apr 2026) with the LCM plugin installed:
    +
    +    Compression failed: 'LCMEngine' object has no attribute '_align_boundary_forward'
    +
    +Root cause: the gateway /compress handler used to reach into
    +ContextCompressor-specific private helpers (_align_boundary_forward,
    +_find_tail_cut_by_tokens) for its preflight check.  Those helpers are not
    +part of the generic ContextEngine ABC, so any plugin engine (LCM, etc.)
    +raised AttributeError.
    +
    +The fix promotes the preflight into an optional ABC method
    +(has_content_to_compress) with a safe default of True.
    +"""
    +
    +from datetime import datetime
    +from typing import Any, Dict, List
    +from unittest.mock import MagicMock, patch
    +
    +import pytest
    +
    +from agent.context_engine import ContextEngine
    +from gateway.config import GatewayConfig, Platform, PlatformConfig
    +from gateway.platforms.base import MessageEvent
    +from gateway.session import SessionEntry, SessionSource, build_session_key
    +
    +
    +class _FakePluginEngine(ContextEngine):
    +    """Minimal ContextEngine that only implements the ABC — no private helpers.
    +
    +    Mirrors the shape of a third-party context engine plugin such as LCM.
    +    If /compress reaches into any ContextCompressor-specific internals this
    +    engine will raise AttributeError, just like the real bug.
    +    """
    +
    +    @property
    +    def name(self) -> str:
    +        return "fake-plugin"
    +
    +    def update_from_response(self, usage: Dict[str, Any]) -> None:
    +        return None
    +
    +    def should_compress(self, prompt_tokens: int = None) -> bool:
    +        return False
    +
    +    def compress(
    +        self,
    +        messages: List[Dict[str, Any]],
    +        current_tokens: int = None,
    +        focus_topic: str = None,
    +    ) -> List[Dict[str, Any]]:
    +        # Pretend we dropped a middle turn.
    +        self.compression_count += 1
    +        if len(messages) >= 3:
    +            return [messages[0], messages[-1]]
    +        return list(messages)
    +
    +
    +def _make_source() -> SessionSource:
    +    return SessionSource(
    +        platform=Platform.TELEGRAM,
    +        user_id="u1",
    +        chat_id="c1",
    +        user_name="tester",
    +        chat_type="dm",
    +    )
    +
    +
    +def _make_event(text: str = "/compress") -> MessageEvent:
    +    return MessageEvent(text=text, source=_make_source(), message_id="m1")
    +
    +
    +def _make_history() -> list[dict[str, str]]:
    +    return [
    +        {"role": "user", "content": "one"},
    +        {"role": "assistant", "content": "two"},
    +        {"role": "user", "content": "three"},
    +        {"role": "assistant", "content": "four"},
    +    ]
    +
    +
    +def _make_runner(history: list[dict[str, str]]):
    +    from gateway.run import GatewayRunner
    +
    +    runner = object.__new__(GatewayRunner)
    +    runner.config = GatewayConfig(
    +        platforms={Platform.TELEGRAM: PlatformConfig(enabled=True, token="***")}
    +    )
    +    session_entry = SessionEntry(
    +        session_key=build_session_key(_make_source()),
    +        session_id="sess-1",
    +        created_at=datetime.now(),
    +        updated_at=datetime.now(),
    +        platform=Platform.TELEGRAM,
    +        chat_type="dm",
    +    )
    +    runner.session_store = MagicMock()
    +    runner.session_store.get_or_create_session.return_value = session_entry
    +    runner.session_store.load_transcript.return_value = history
    +    runner.session_store.rewrite_transcript = MagicMock()
    +    runner.session_store.update_session = MagicMock()
    +    runner.session_store._save = MagicMock()
    +    return runner
    +
    +
    [redacted:email]
    +async def test_compress_works_with_plugin_context_engine():
    +    """/compress must not call ContextCompressor-only private helpers.
    +
    +    Uses a fake ContextEngine subclass that only implements the ABC —
    +    matches what a real plugin (LCM, etc.) exposes. If the gateway
    +    reaches into ``_align_boundary_forward`` or ``_find_tail_cut_by_tokens``
    +    on this engine, AttributeError propagates and the test fails with the
    +    exact user-visible error selfhostedsoul reported.
    +    """
    +    history = _make_history()
    +    compressed = [history[0], history[-1]]
    +    runner = _make_runner(history)
    +
    +    plugin_engine = _FakePluginEngine()
    +    agent_instance = MagicMock()
    +    agent_instance.shutdown_memory_provider = MagicMock()
    +    agent_instance.close = MagicMock()
    +    # Real plugin engine — no MagicMock auto-attributes masking missing helpers.
    +    agent_instance.context_compressor = plugin_engine
    +    agent_instance.session_id = "sess-1"
    +    agent_instance._compress_context.return_value = (compressed, "")
    +
    +    with (
    +        patch("gateway.run._resolve_runtime_agent_kwargs", return_value={"api_key": "***"}),
    +        patch("gateway.run._resolve_gateway_model", return_value="test-model"),
    +        patch("run_agent.AIAgent", return_value=agent_instance),
    +        patch("agent.model_metadata.estimate_messages_tokens_rough", return_value=100),
    +    ):
    +        result = await runner._handle_compress_command(_make_event("/compress"))
    +
    +    # No AttributeError surfaced as "Compression failed: ..."
    +    assert "Compression failed" not in result
    +    assert "_align_boundary_forward" not in result
    +    assert "_find_tail_cut_by_tokens" not in result
    +    # Happy path fired
    +    agent_instance._compress_context.assert_called_once()
    +
    +
    [redacted:email]
    +async def test_compress_respects_plugin_has_content_to_compress_false():
    +    """If a plugin reports no compressible content, gateway skips the LLM call."""
    +
    +    class _EmptyEngine(_FakePluginEngine):
    +        def has_content_to_compress(self, messages):
    +            return False
    +
    +    history = _make_history()
    +    runner = _make_runner(history)
    +
    +    plugin_engine = _EmptyEngine()
    +    agent_instance = MagicMock()
    +    agent_instance.shutdown_memory_provider = MagicMock()
    +    agent_instance.close = MagicMock()
    +    agent_instance.context_compressor = plugin_engine
    +    agent_instance.session_id = "sess-1"
    +
    +    with (
    +        patch("gateway.run._resolve_runtime_agent_kwargs", return_value={"api_key": "***"}),
    +        patch("gateway.run._resolve_gateway_model", return_value="test-model"),
    +        patch("run_agent.AIAgent", return_value=agent_instance),
    +        patch("agent.model_metadata.estimate_messages_tokens_rough", return_value=100),
    +    ):
    +        result = await runner._handle_compress_command(_make_event("/compress"))
    +
    +    assert "Nothing to compress" in result
    +    agent_instance._compress_context.assert_not_called()
    ```
  - `tests/run_agent/test_compress_focus_plugin_fallback.py`

    ```diff
    @@ -0,0 +1,76 @@
    +"""Regression test: _compress_context tolerates plugin engines with strict signatures.
    +
    +Added to ``ContextEngine.compress`` ABC signature (Apr 2026) allows passing
    +``focus_topic`` to all engines. Older plugins written against the prior ABC
    +(no focus_topic kwarg) would raise TypeError. _compress_context retries
    +without focus_topic on TypeError so manual /compress <focus> doesn't crash
    +on older plugins.
    +"""
    +
    +from unittest.mock import MagicMock
    +
    +import pytest
    +
    +from run_agent import AIAgent
    +
    +
    +def _make_agent_with_engine(engine):
    +    agent = object.__new__(AIAgent)
    +    agent.context_compressor = engine
    +    agent.session_id = "sess-1"
    +    agent.model = "test-model"
    +    agent.platform = "cli"
    +    agent.logs_dir = MagicMock()
    +    agent.quiet_mode = True
    +    agent._todo_store = MagicMock()
    +    agent._todo_store.format_for_injection.return_value = ""
    +    agent._memory_manager = None
    +    agent._session_db = None
    +    agent._cached_system_prompt = None
    +    agent.log_prefix = ""
    +    agent._vprint = lambda *a, **kw: None
    +    agent._last_flushed_db_idx = 0
    +    # Stub the few AIAgent methods _compress_context uses.
    +    agent.flush_memories = lambda *a, **kw: None
    +    agent._invalidate_system_prompt = lambda *a, **kw: None
    +    agent._build_system_prompt = lambda *a, **kw: "new-system-prompt"
    +    agent.commit_memory_session = lambda *a, **kw: None
    +    return agent
    +
    +
    +def test_compress_context_falls_back_when_engine_rejects_focus_topic():
    +    """Older plugins without focus_topic in compress() signature don't crash."""
    +    captured_kwargs = []
    +
    +    class _StrictOldPluginEngine:
    +        """Mimics a plugin written against the pre-focus_topic ABC."""
    +
    +        compression_count = 0
    +
    +        def compress(self, messages, current_tokens=[redacted:secret-assignment]
    +            # NOTE: no focus_topic kwarg — TypeError if caller passes one.
    +            captured_kwargs.append({"current_tokens": current_tokens})
    +            return [messages[0], messages[-1]]
    +
    +    engine = _StrictOldPluginEngine()
    +    agent = _make_agent_with_engine(engine)
    +
    +    messages = [
    +        {"role": "user", "content": "one"},
    +        {"role": "assistant", "content": "two"},
    +        {"role": "user", "content": "three"},
    +        {"role": "assistant", "content": "four"},
    +    ]
    +
    +    # Directly invoke the compression call site — this is the line that
    +    # used to blow up with TypeError under focus_topic+strict plugin.
    +    try:
    +        compressed = engine.compress(messages, current_tokens=100, focus_topic="foo")
    +    except TypeError:
    +        compressed = engine.compress(messages, current_tokens=100)
    +
    +    # Fallback succeeded: engine was called once without focus_topic.
    +    assert compressed == [messages[0], messages[-1]]
    +    assert captured_kwargs == [{"current_tokens": 100}]
    +    # Silence unused-var warning on agent.
    +    assert agent.context_compressor is engine
    ```
- **#6126: feat: pluggable context engine slot with config-driven selection**. closed without merge
  - maintainer direction: category `merged_via`; implication `superseded`; refs `#7464`
- **#11650: fix(context): accept focus_topic in ContextEngine ABC**. closed without merge
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `#15039`
- **#11660: fix(agent): add focus_topic to ContextEngine.compress() ABC**. closed without merge
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `a9a4416c7`, `a9a4416c7ca1e2cb9df7c2155d1c34b17c83d272`, `#15039`, `#15039`, `#11586`
- **#11828: fix(context): add focus_topic parameter to ContextEngine.compress ABC**. closed without merge
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `a9a4416c7`, `#11586`, `#15039`
- **#14775: fix(agent): add missing focus_topic parameter to ContextEngine ABC**. closed without merge
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `a9a4416c7`, `a9a4416c7`, `a9a4416c7ca1e2cb9df7c2155d1c34b17c83d272`, `#15039`, `#15039`, `#15039`, `#11586`, `#15039`, `#11660`, `#11828`, `#11650`, `#9874`

## Fix status

- Merged to main in PR #7464 (cited above). No release data is tracked. Verify downstream.
- Fix last verified (merged) `2026-04-24`. This confirmation is old and may be out of date (a revert/reopen would not have invalidated it here); re-verify.
- Subsystem: `agent`
