From 49c4d25f811190787cf887f7ba273eb87c910b6d Mon Sep 17 00:00:00 2001 From: CommanderTurtle Date: Mon, 20 Jul 2026 21:08:56 -0400 Subject: [PATCH 01/33] Add files via upload From de0ecc681a6d9d5baa9c208d226bee7463a2b9e0 Mon Sep 17 00:00:00 2001 From: CommanderTurtle Date: Mon, 20 Jul 2026 21:09:44 -0400 Subject: [PATCH 02/33] Add files via upload From 3da040e87c6a612c628caafdaf2251677ba4cfe6 Mon Sep 17 00:00:00 2001 From: CommanderTurtle Date: Mon, 20 Jul 2026 21:10:07 -0400 Subject: [PATCH 03/33] Add files via upload From ea486629d31bb9750542955389dd2879eaf205d6 Mon Sep 17 00:00:00 2001 From: CommanderTurtle Date: Mon, 20 Jul 2026 21:10:32 -0400 Subject: [PATCH 04/33] Add files via upload From 42d7a69fa666423ad701aca33010eb555d15eb04 Mon Sep 17 00:00:00 2001 From: CommanderTurtle Date: Mon, 20 Jul 2026 21:10:56 -0400 Subject: [PATCH 05/33] Add files via upload From 34c29d60fa97ac2ad15ff9e2ee698a7a8835ef42 Mon Sep 17 00:00:00 2001 From: CommanderTurtle Date: Mon, 20 Jul 2026 21:11:21 -0400 Subject: [PATCH 06/33] Add files via upload From 88195034dacbe6ce1c23c46b795817abe632b6ff Mon Sep 17 00:00:00 2001 From: CommanderTurtle Date: Mon, 20 Jul 2026 21:12:22 -0400 Subject: [PATCH 07/33] Add files via upload --- hermes-plugin/README.md | 73 ++++ hermes-plugin/__init__.py | 410 ++++++++++++++++++ .../__pycache__/__init__.cpython-312.pyc | Bin 0 -> 17115 bytes hermes-plugin/plugin.yaml | 10 + 4 files changed, 493 insertions(+) create mode 100644 hermes-plugin/README.md create mode 100644 hermes-plugin/__init__.py create mode 100644 hermes-plugin/__pycache__/__init__.cpython-312.pyc create mode 100644 hermes-plugin/plugin.yaml diff --git a/hermes-plugin/README.md b/hermes-plugin/README.md new file mode 100644 index 00000000..b919d258 --- /dev/null +++ b/hermes-plugin/README.md @@ -0,0 +1,73 @@ +# Context Mode for Hermes Agent + +This Hermes plugin adds proactive Context Mode routing and a bounded fallback +for oversized native tool results. It uses the public Hermes plugin API and the +Python standard library only. + +## Requirements + +- Hermes Agent with the current Python plugin hooks +- Node.js 22.5 or later, or Bun +- The Context Mode MCP server registered under the name `context-mode` + +Hermes registers that server's tools as `mcp__context_mode__ctx_*`. + +## Install + +Register the MCP server: + +```bash +hermes mcp add context-mode --command npx --args -y context-mode +``` + +Copy the plugin on Linux, macOS, or WSL: + +```bash +mkdir -p ~/.hermes/plugins/hermes-context-mode +cp .hermes-plugin/plugin.yaml .hermes-plugin/__init__.py ~/.hermes/plugins/hermes-context-mode/ +``` + +Copy the plugin in PowerShell: + +```powershell +$target = Join-Path $HOME ".hermes/plugins/hermes-context-mode" +New-Item -ItemType Directory -Force $target | Out-Null +Copy-Item .hermes-plugin/plugin.yaml, .hermes-plugin/__init__.py $target -Force +``` + +Enable it in `~/.hermes/config.yaml`: + +```yaml +plugins: + enabled: + - hermes-context-mode +``` + +Restart Hermes. For a gateway install, restart the gateway process. Confirm the +MCP connection with `hermes mcp test context-mode`, then ask Hermes for +`ctx stats`. + +## Behavior + +| Hook | Behavior | +|---|---| +| `pre_tool_call` | Blocks known high-output terminal fetch/build commands using Hermes' `{"action":"block","message":"..."}` contract. | +| `transform_tool_result` | Writes eligible outputs larger than 3 KiB to a collision-safe UTF-8 file and returns a compact pointer. | +| `pre_llm_call` | Injects current `mcp__context_mode__ctx_*` routing guidance once per session. | +| `on_session_start` | Initializes bounded per-session metrics. | +| `on_session_end` | Persists a snapshot at Hermes' per-turn boundary without destroying session state. | +| `on_session_finalize` | Persists and releases state when Hermes tears down the session. | + +Generated data stays under +`~/.hermes/plugins/hermes-context-mode/` (or `$HERMES_HOME/plugins/...`): + +```text +metrics.db +sandbox/ +``` + +Hermes plugin API: + +Hermes hook contracts: + +Hermes MCP configuration: diff --git a/hermes-plugin/__init__.py b/hermes-plugin/__init__.py new file mode 100644 index 00000000..30a8f7a9 --- /dev/null +++ b/hermes-plugin/__init__.py @@ -0,0 +1,410 @@ +"""Hermes Agent integration for Context Mode. + +This plugin enforces the routing boundary around high-output terminal work, +injects current Hermes MCP tool names once per session, and keeps oversized +native tool results out of the model context. It uses only the Python standard +library and is intentionally independent of Hermes internals. +""" + +from __future__ import annotations + +import html +import json +import logging +import os +import re +import sqlite3 +import threading +import time +from collections import Counter, OrderedDict +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Optional +from uuid import uuid4 + +logger = logging.getLogger("hermes-context-mode") + +HERMES_HOME = Path(os.environ.get("HERMES_HOME", Path.home() / ".hermes")) +PLUGIN_DIR = HERMES_HOME / "plugins" / "hermes-context-mode" +METRICS_DB = PLUGIN_DIR / "metrics.db" +SANDBOX_DIR = PLUGIN_DIR / "sandbox" + +SANDBOX_THRESHOLD = 3 * 1024 +_GUIDANCE_CAP = 1000 + +BLOCKED_HIGH_OUTPUT = re.compile( + r"\b(?:curl|wget|docker\s+(?:build|compose\s+up)|make|cmake|gradle|mvn|" + r"cargo\s+(?:build|test|run|check)|npx|npm\s+(?:run|start|test)|" + r"playwright\s+(?:open|codegen|install)|" + r"kubectl\s+(?:get|logs|describe|apply))\b", + re.IGNORECASE, +) + +BLOCKED_INLINE_HTTP = re.compile( + r"\b(?:fetch\s*\(\s*['\"]https?://|" + r"requests\.(?:get|post|put|delete|patch)\s*\(|" + r"http\.(?:get|post|request)\s*\(|" + r"urllib\.request\.urlopen\s*\(|" + r"Invoke-(?:WebRequest|RestMethod)\b)", + re.IGNORECASE, +) + +NEVER_SANDBOX = { + "write_file", + "patch", + "text_to_speech", + "send_message", + "vision_analyze", +} + +SANDBOX_TOOLS = { + "terminal", + "read_file", + "browser_snapshot", + "browser_console", + "browser_vision", + "web_extract", + "web_search", + "execute_code", +} + +_TOOL_PREFIX = "mcp__context_mode__" +ROUTING_BLOCK = f""" + Context Mode is connected through the Hermes MCP server named context-mode. + Its registered tools use the current Hermes prefix `{_TOOL_PREFIX}`. + + Think in Code: process, filter, count, parse, and aggregate inside the Context + Mode sandbox. Print only the derived answer so raw bytes do not enter the + conversation. + + Prefer: + - `{_TOOL_PREFIX}ctx_batch_execute` to gather command output and index it. + - `{_TOOL_PREFIX}ctx_search` to query indexed output and session memory. + - `{_TOOL_PREFIX}ctx_execute` or `{_TOOL_PREFIX}ctx_execute_file` to analyze data. + - `{_TOOL_PREFIX}ctx_fetch_and_index` for web content. + + Native terminal remains appropriate for short, predictable output and state + mutations such as git, mkdir, rm, mv, and package installation. Native file + reads remain appropriate when exact bytes are needed for an edit. + + High-output terminal fetch/build commands are blocked by this plugin. Do not + retry them through terminal; use the matching Context Mode MCP tool. +""" + +SESSION_GUIDANCE_SHOWN: "OrderedDict[str, None]" = OrderedDict() +_session_stats: dict[str, dict[str, Any]] = {} +_state_lock = threading.RLock() + + +def _utc_now() -> str: + return datetime.now(timezone.utc).isoformat() + + +def _new_stats(model: str = "", platform: str = "") -> dict[str, Any]: + return { + "tool_calls": 0, + "bytes_saved": 0, + "blocks": 0, + "tools_saved": Counter(), + "model": model, + "platform": platform, + "started": _utc_now(), + } + + +def _stats_for(session_id: str) -> dict[str, Any]: + key = session_id or "unknown" + with _state_lock: + return _session_stats.setdefault(key, _new_stats()) + + +def _increment(session_id: str, field: str, amount: int = 1) -> None: + with _state_lock: + stats = _stats_for(session_id) + stats[field] = int(stats.get(field, 0)) + amount + + +def _remember_guidance(session_id: str) -> bool: + key = session_id or "unknown" + with _state_lock: + if key in SESSION_GUIDANCE_SHOWN: + SESSION_GUIDANCE_SHOWN.move_to_end(key) + return False + SESSION_GUIDANCE_SHOWN[key] = None + while len(SESSION_GUIDANCE_SHOWN) > _GUIDANCE_CAP: + SESSION_GUIDANCE_SHOWN.popitem(last=False) + return True + + +def _ensure_db() -> sqlite3.Connection: + PLUGIN_DIR.mkdir(parents=True, exist_ok=True) + conn = sqlite3.connect(str(METRICS_DB), timeout=10) + conn.execute("PRAGMA journal_mode=WAL") + conn.execute( + """ + CREATE TABLE IF NOT EXISTS session_metrics ( + session_id TEXT PRIMARY KEY, + platform TEXT, + model TEXT, + started TEXT, + ended TEXT, + tool_calls INTEGER DEFAULT 0, + bytes_saved INTEGER DEFAULT 0, + tools_saved TEXT DEFAULT '{}', + blocks INTEGER DEFAULT 0 + ) + """ + ) + conn.execute( + """ + CREATE TABLE IF NOT EXISTS tool_savings ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id TEXT, + tool_name TEXT, + original_bytes INTEGER, + saved_bytes INTEGER, + sandbox_path TEXT, + ts TEXT + ) + """ + ) + conn.commit() + return conn + + +def _snapshot_stats(session_id: str) -> Optional[dict[str, Any]]: + with _state_lock: + stats = _session_stats.get(session_id or "unknown") + if stats is None: + return None + snapshot = dict(stats) + snapshot["tools_saved"] = dict(stats["tools_saved"]) + return snapshot + + +def _persist_session(session_id: str) -> None: + snapshot = _snapshot_stats(session_id) + if snapshot is None: + return + try: + with _ensure_db() as conn: + conn.execute( + """ + INSERT INTO session_metrics + (session_id, platform, model, started, ended, tool_calls, + bytes_saved, tools_saved, blocks) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(session_id) DO UPDATE SET + platform=excluded.platform, + model=excluded.model, + ended=excluded.ended, + tool_calls=excluded.tool_calls, + bytes_saved=excluded.bytes_saved, + tools_saved=excluded.tools_saved, + blocks=excluded.blocks + """, + ( + session_id or "unknown", + snapshot["platform"], + snapshot["model"], + snapshot["started"], + _utc_now(), + snapshot["tool_calls"], + snapshot["bytes_saved"], + json.dumps(snapshot["tools_saved"], sort_keys=True), + snapshot["blocks"], + ), + ) + except Exception as exc: # pragma: no cover - metrics must fail open + logger.debug("Session metrics save failed: %s", exc) + + +def _record_saving( + session_id: str, + tool_name: str, + original_bytes: int, + saved_bytes: int, + path: str, +) -> None: + try: + with _ensure_db() as conn: + conn.execute( + """ + INSERT INTO tool_savings + (session_id, tool_name, original_bytes, saved_bytes, + sandbox_path, ts) + VALUES (?, ?, ?, ?, ?, ?) + """, + ( + session_id or "unknown", + tool_name, + original_bytes, + saved_bytes, + path, + _utc_now(), + ), + ) + except Exception as exc: # pragma: no cover - metrics must fail open + logger.debug("Tool metrics save failed: %s", exc) + + +def _extract_command(args: Any) -> str: + if not isinstance(args, dict): + return "" + command = args.get("command", "") + return command if isinstance(command, str) else "" + + +def _extract_result_content(result: str) -> str: + try: + parsed = json.loads(result) + except (json.JSONDecodeError, TypeError): + return result + if not isinstance(parsed, dict): + return result + for key in ("content", "output", "result"): + value = parsed.get(key) + if isinstance(value, str): + return value + return result + + +def on_session_start( + session_id: str, + model: str = "", + platform: str = "", + **_kwargs: Any, +) -> None: + key = session_id or "unknown" + with _state_lock: + _session_stats[key] = _new_stats(model, platform) + SESSION_GUIDANCE_SHOWN.pop(key, None) + + +def on_session_end( + session_id: str, + completed: bool = False, + interrupted: bool = False, + **_kwargs: Any, +) -> None: + """Persist a turn snapshot without discarding session-scoped state.""" + del completed, interrupted + _persist_session(session_id) + + +def on_session_finalize( + session_id: Optional[str] = None, + **_kwargs: Any, +) -> None: + """Persist and release state at the current Hermes teardown boundary.""" + key = session_id or "unknown" + _persist_session(key) + with _state_lock: + _session_stats.pop(key, None) + SESSION_GUIDANCE_SHOWN.pop(key, None) + + +def pre_tool_call( + tool_name: str, + args: dict, + task_id: str = "", + session_id: str = "", + **_kwargs: Any, +) -> Optional[dict[str, str]]: + """Block terminal commands whose raw output should use Context Mode.""" + del task_id + if tool_name != "terminal": + return None + command = _extract_command(args).strip() + if not command: + return None + + _increment(session_id, "tool_calls") + if BLOCKED_HIGH_OUTPUT.search(command): + _increment(session_id, "blocks") + return { + "action": "block", + "message": ( + "context-mode blocked a high-output terminal command. Use " + f"{_TOOL_PREFIX}ctx_execute or {_TOOL_PREFIX}ctx_batch_execute." + ), + } + + if BLOCKED_INLINE_HTTP.search(command): + _increment(session_id, "blocks") + url_match = re.search(r"https?://[^\s\"'()]+", command) + suffix = f" for {url_match.group(0)}" if url_match else "" + return { + "action": "block", + "message": ( + "context-mode blocked inline HTTP. Use " + f"{_TOOL_PREFIX}ctx_fetch_and_index{suffix}." + ), + } + return None + + +def transform_tool_result( + tool_name: str, + args: Any, + result: str, + session_id: str = "", + task_id: str = "", + **_kwargs: Any, +) -> Optional[str]: + """Write large eligible results to disk and return a bounded pointer.""" + del args + if tool_name in NEVER_SANDBOX or tool_name not in SANDBOX_TOOLS: + return None + if not isinstance(result, str): + return None + original_bytes = len(result.encode("utf-8")) + if original_bytes <= SANDBOX_THRESHOLD: + return None + + content = _extract_result_content(result) + SANDBOX_DIR.mkdir(parents=True, exist_ok=True) + safe_tool = re.sub(r"[^A-Za-z0-9_.-]", "_", tool_name) or "tool" + safe_task = re.sub(r"[^A-Za-z0-9_.-]", "_", task_id[:24]) or "na" + filename = f"{time.time_ns()}_{safe_tool}_{safe_task}_{uuid4().hex[:8]}.txt" + path = SANDBOX_DIR / filename + path.write_text(content, encoding="utf-8") + + preview = html.escape(content[:200].strip(), quote=False) + line_count = content.count("\n") + 1 + summary = ( + f'\n' + " Output exceeded 3 KiB and was written to a local sandbox file.\n" + f" Preview: {preview}\n" + "" + ) + saved_bytes = max(0, original_bytes - len(summary.encode("utf-8"))) + _increment(session_id, "bytes_saved", saved_bytes) + with _state_lock: + stats = _stats_for(session_id) + stats["tools_saved"][tool_name] += saved_bytes + _record_saving(session_id, tool_name, original_bytes, saved_bytes, str(path)) + return summary + + +def pre_llm_call( + session_id: str, + user_message: str = "", + is_first_turn: bool = False, + **_kwargs: Any, +) -> Optional[dict[str, str]]: + del user_message + if not is_first_turn or not _remember_guidance(session_id): + return None + return {"context": ROUTING_BLOCK} + + +def register(ctx: Any) -> None: + ctx.register_hook("pre_tool_call", pre_tool_call) + ctx.register_hook("transform_tool_result", transform_tool_result) + ctx.register_hook("pre_llm_call", pre_llm_call) + ctx.register_hook("on_session_start", on_session_start) + ctx.register_hook("on_session_end", on_session_end) + ctx.register_hook("on_session_finalize", on_session_finalize) + logger.info("hermes-context-mode registered (6 hooks)") diff --git a/hermes-plugin/__pycache__/__init__.cpython-312.pyc b/hermes-plugin/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..f14faa6a0112b6f437ee39c0efad288b2c4e5eb7 GIT binary patch literal 17115 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 literal 0 HcmV?d00001 diff --git a/hermes-plugin/plugin.yaml b/hermes-plugin/plugin.yaml new file mode 100644 index 00000000..c4ca67ce --- /dev/null +++ b/hermes-plugin/plugin.yaml @@ -0,0 +1,10 @@ +name: hermes-context-mode +version: "2.0.0" +description: Context Mode routing protection and bounded output sandboxing for Hermes Agent. +hooks: + - pre_tool_call + - transform_tool_result + - pre_llm_call + - on_session_start + - on_session_end + - on_session_finalize From 5a67b40aaaefd3861d8b8b603c31d1ecbd0ce74c Mon Sep 17 00:00:00 2001 From: CommanderTurtle Date: Mon, 20 Jul 2026 21:12:45 -0400 Subject: [PATCH 08/33] Delete hermes-plugin directory --- hermes-plugin/README.md | 73 ---- hermes-plugin/__init__.py | 410 ------------------ .../__pycache__/__init__.cpython-312.pyc | Bin 17115 -> 0 bytes hermes-plugin/plugin.yaml | 10 - 4 files changed, 493 deletions(-) delete mode 100644 hermes-plugin/README.md delete mode 100644 hermes-plugin/__init__.py delete mode 100644 hermes-plugin/__pycache__/__init__.cpython-312.pyc delete mode 100644 hermes-plugin/plugin.yaml diff --git a/hermes-plugin/README.md b/hermes-plugin/README.md deleted file mode 100644 index b919d258..00000000 --- a/hermes-plugin/README.md +++ /dev/null @@ -1,73 +0,0 @@ -# Context Mode for Hermes Agent - -This Hermes plugin adds proactive Context Mode routing and a bounded fallback -for oversized native tool results. It uses the public Hermes plugin API and the -Python standard library only. - -## Requirements - -- Hermes Agent with the current Python plugin hooks -- Node.js 22.5 or later, or Bun -- The Context Mode MCP server registered under the name `context-mode` - -Hermes registers that server's tools as `mcp__context_mode__ctx_*`. - -## Install - -Register the MCP server: - -```bash -hermes mcp add context-mode --command npx --args -y context-mode -``` - -Copy the plugin on Linux, macOS, or WSL: - -```bash -mkdir -p ~/.hermes/plugins/hermes-context-mode -cp .hermes-plugin/plugin.yaml .hermes-plugin/__init__.py ~/.hermes/plugins/hermes-context-mode/ -``` - -Copy the plugin in PowerShell: - -```powershell -$target = Join-Path $HOME ".hermes/plugins/hermes-context-mode" -New-Item -ItemType Directory -Force $target | Out-Null -Copy-Item .hermes-plugin/plugin.yaml, .hermes-plugin/__init__.py $target -Force -``` - -Enable it in `~/.hermes/config.yaml`: - -```yaml -plugins: - enabled: - - hermes-context-mode -``` - -Restart Hermes. For a gateway install, restart the gateway process. Confirm the -MCP connection with `hermes mcp test context-mode`, then ask Hermes for -`ctx stats`. - -## Behavior - -| Hook | Behavior | -|---|---| -| `pre_tool_call` | Blocks known high-output terminal fetch/build commands using Hermes' `{"action":"block","message":"..."}` contract. | -| `transform_tool_result` | Writes eligible outputs larger than 3 KiB to a collision-safe UTF-8 file and returns a compact pointer. | -| `pre_llm_call` | Injects current `mcp__context_mode__ctx_*` routing guidance once per session. | -| `on_session_start` | Initializes bounded per-session metrics. | -| `on_session_end` | Persists a snapshot at Hermes' per-turn boundary without destroying session state. | -| `on_session_finalize` | Persists and releases state when Hermes tears down the session. | - -Generated data stays under -`~/.hermes/plugins/hermes-context-mode/` (or `$HERMES_HOME/plugins/...`): - -```text -metrics.db -sandbox/ -``` - -Hermes plugin API: - -Hermes hook contracts: - -Hermes MCP configuration: diff --git a/hermes-plugin/__init__.py b/hermes-plugin/__init__.py deleted file mode 100644 index 30a8f7a9..00000000 --- a/hermes-plugin/__init__.py +++ /dev/null @@ -1,410 +0,0 @@ -"""Hermes Agent integration for Context Mode. - -This plugin enforces the routing boundary around high-output terminal work, -injects current Hermes MCP tool names once per session, and keeps oversized -native tool results out of the model context. It uses only the Python standard -library and is intentionally independent of Hermes internals. -""" - -from __future__ import annotations - -import html -import json -import logging -import os -import re -import sqlite3 -import threading -import time -from collections import Counter, OrderedDict -from datetime import datetime, timezone -from pathlib import Path -from typing import Any, Optional -from uuid import uuid4 - -logger = logging.getLogger("hermes-context-mode") - -HERMES_HOME = Path(os.environ.get("HERMES_HOME", Path.home() / ".hermes")) -PLUGIN_DIR = HERMES_HOME / "plugins" / "hermes-context-mode" -METRICS_DB = PLUGIN_DIR / "metrics.db" -SANDBOX_DIR = PLUGIN_DIR / "sandbox" - -SANDBOX_THRESHOLD = 3 * 1024 -_GUIDANCE_CAP = 1000 - -BLOCKED_HIGH_OUTPUT = re.compile( - r"\b(?:curl|wget|docker\s+(?:build|compose\s+up)|make|cmake|gradle|mvn|" - r"cargo\s+(?:build|test|run|check)|npx|npm\s+(?:run|start|test)|" - r"playwright\s+(?:open|codegen|install)|" - r"kubectl\s+(?:get|logs|describe|apply))\b", - re.IGNORECASE, -) - -BLOCKED_INLINE_HTTP = re.compile( - r"\b(?:fetch\s*\(\s*['\"]https?://|" - r"requests\.(?:get|post|put|delete|patch)\s*\(|" - r"http\.(?:get|post|request)\s*\(|" - r"urllib\.request\.urlopen\s*\(|" - r"Invoke-(?:WebRequest|RestMethod)\b)", - re.IGNORECASE, -) - -NEVER_SANDBOX = { - "write_file", - "patch", - "text_to_speech", - "send_message", - "vision_analyze", -} - -SANDBOX_TOOLS = { - "terminal", - "read_file", - "browser_snapshot", - "browser_console", - "browser_vision", - "web_extract", - "web_search", - "execute_code", -} - -_TOOL_PREFIX = "mcp__context_mode__" -ROUTING_BLOCK = f""" - Context Mode is connected through the Hermes MCP server named context-mode. - Its registered tools use the current Hermes prefix `{_TOOL_PREFIX}`. - - Think in Code: process, filter, count, parse, and aggregate inside the Context - Mode sandbox. Print only the derived answer so raw bytes do not enter the - conversation. - - Prefer: - - `{_TOOL_PREFIX}ctx_batch_execute` to gather command output and index it. - - `{_TOOL_PREFIX}ctx_search` to query indexed output and session memory. - - `{_TOOL_PREFIX}ctx_execute` or `{_TOOL_PREFIX}ctx_execute_file` to analyze data. - - `{_TOOL_PREFIX}ctx_fetch_and_index` for web content. - - Native terminal remains appropriate for short, predictable output and state - mutations such as git, mkdir, rm, mv, and package installation. Native file - reads remain appropriate when exact bytes are needed for an edit. - - High-output terminal fetch/build commands are blocked by this plugin. Do not - retry them through terminal; use the matching Context Mode MCP tool. -""" - -SESSION_GUIDANCE_SHOWN: "OrderedDict[str, None]" = OrderedDict() -_session_stats: dict[str, dict[str, Any]] = {} -_state_lock = threading.RLock() - - -def _utc_now() -> str: - return datetime.now(timezone.utc).isoformat() - - -def _new_stats(model: str = "", platform: str = "") -> dict[str, Any]: - return { - "tool_calls": 0, - "bytes_saved": 0, - "blocks": 0, - "tools_saved": Counter(), - "model": model, - "platform": platform, - "started": _utc_now(), - } - - -def _stats_for(session_id: str) -> dict[str, Any]: - key = session_id or "unknown" - with _state_lock: - return _session_stats.setdefault(key, _new_stats()) - - -def _increment(session_id: str, field: str, amount: int = 1) -> None: - with _state_lock: - stats = _stats_for(session_id) - stats[field] = int(stats.get(field, 0)) + amount - - -def _remember_guidance(session_id: str) -> bool: - key = session_id or "unknown" - with _state_lock: - if key in SESSION_GUIDANCE_SHOWN: - SESSION_GUIDANCE_SHOWN.move_to_end(key) - return False - SESSION_GUIDANCE_SHOWN[key] = None - while len(SESSION_GUIDANCE_SHOWN) > _GUIDANCE_CAP: - SESSION_GUIDANCE_SHOWN.popitem(last=False) - return True - - -def _ensure_db() -> sqlite3.Connection: - PLUGIN_DIR.mkdir(parents=True, exist_ok=True) - conn = sqlite3.connect(str(METRICS_DB), timeout=10) - conn.execute("PRAGMA journal_mode=WAL") - conn.execute( - """ - CREATE TABLE IF NOT EXISTS session_metrics ( - session_id TEXT PRIMARY KEY, - platform TEXT, - model TEXT, - started TEXT, - ended TEXT, - tool_calls INTEGER DEFAULT 0, - bytes_saved INTEGER DEFAULT 0, - tools_saved TEXT DEFAULT '{}', - blocks INTEGER DEFAULT 0 - ) - """ - ) - conn.execute( - """ - CREATE TABLE IF NOT EXISTS tool_savings ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - session_id TEXT, - tool_name TEXT, - original_bytes INTEGER, - saved_bytes INTEGER, - sandbox_path TEXT, - ts TEXT - ) - """ - ) - conn.commit() - return conn - - -def _snapshot_stats(session_id: str) -> Optional[dict[str, Any]]: - with _state_lock: - stats = _session_stats.get(session_id or "unknown") - if stats is None: - return None - snapshot = dict(stats) - snapshot["tools_saved"] = dict(stats["tools_saved"]) - return snapshot - - -def _persist_session(session_id: str) -> None: - snapshot = _snapshot_stats(session_id) - if snapshot is None: - return - try: - with _ensure_db() as conn: - conn.execute( - """ - INSERT INTO session_metrics - (session_id, platform, model, started, ended, tool_calls, - bytes_saved, tools_saved, blocks) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) - ON CONFLICT(session_id) DO UPDATE SET - platform=excluded.platform, - model=excluded.model, - ended=excluded.ended, - tool_calls=excluded.tool_calls, - bytes_saved=excluded.bytes_saved, - tools_saved=excluded.tools_saved, - blocks=excluded.blocks - """, - ( - session_id or "unknown", - snapshot["platform"], - snapshot["model"], - snapshot["started"], - _utc_now(), - snapshot["tool_calls"], - snapshot["bytes_saved"], - json.dumps(snapshot["tools_saved"], sort_keys=True), - snapshot["blocks"], - ), - ) - except Exception as exc: # pragma: no cover - metrics must fail open - logger.debug("Session metrics save failed: %s", exc) - - -def _record_saving( - session_id: str, - tool_name: str, - original_bytes: int, - saved_bytes: int, - path: str, -) -> None: - try: - with _ensure_db() as conn: - conn.execute( - """ - INSERT INTO tool_savings - (session_id, tool_name, original_bytes, saved_bytes, - sandbox_path, ts) - VALUES (?, ?, ?, ?, ?, ?) - """, - ( - session_id or "unknown", - tool_name, - original_bytes, - saved_bytes, - path, - _utc_now(), - ), - ) - except Exception as exc: # pragma: no cover - metrics must fail open - logger.debug("Tool metrics save failed: %s", exc) - - -def _extract_command(args: Any) -> str: - if not isinstance(args, dict): - return "" - command = args.get("command", "") - return command if isinstance(command, str) else "" - - -def _extract_result_content(result: str) -> str: - try: - parsed = json.loads(result) - except (json.JSONDecodeError, TypeError): - return result - if not isinstance(parsed, dict): - return result - for key in ("content", "output", "result"): - value = parsed.get(key) - if isinstance(value, str): - return value - return result - - -def on_session_start( - session_id: str, - model: str = "", - platform: str = "", - **_kwargs: Any, -) -> None: - key = session_id or "unknown" - with _state_lock: - _session_stats[key] = _new_stats(model, platform) - SESSION_GUIDANCE_SHOWN.pop(key, None) - - -def on_session_end( - session_id: str, - completed: bool = False, - interrupted: bool = False, - **_kwargs: Any, -) -> None: - """Persist a turn snapshot without discarding session-scoped state.""" - del completed, interrupted - _persist_session(session_id) - - -def on_session_finalize( - session_id: Optional[str] = None, - **_kwargs: Any, -) -> None: - """Persist and release state at the current Hermes teardown boundary.""" - key = session_id or "unknown" - _persist_session(key) - with _state_lock: - _session_stats.pop(key, None) - SESSION_GUIDANCE_SHOWN.pop(key, None) - - -def pre_tool_call( - tool_name: str, - args: dict, - task_id: str = "", - session_id: str = "", - **_kwargs: Any, -) -> Optional[dict[str, str]]: - """Block terminal commands whose raw output should use Context Mode.""" - del task_id - if tool_name != "terminal": - return None - command = _extract_command(args).strip() - if not command: - return None - - _increment(session_id, "tool_calls") - if BLOCKED_HIGH_OUTPUT.search(command): - _increment(session_id, "blocks") - return { - "action": "block", - "message": ( - "context-mode blocked a high-output terminal command. Use " - f"{_TOOL_PREFIX}ctx_execute or {_TOOL_PREFIX}ctx_batch_execute." - ), - } - - if BLOCKED_INLINE_HTTP.search(command): - _increment(session_id, "blocks") - url_match = re.search(r"https?://[^\s\"'()]+", command) - suffix = f" for {url_match.group(0)}" if url_match else "" - return { - "action": "block", - "message": ( - "context-mode blocked inline HTTP. Use " - f"{_TOOL_PREFIX}ctx_fetch_and_index{suffix}." - ), - } - return None - - -def transform_tool_result( - tool_name: str, - args: Any, - result: str, - session_id: str = "", - task_id: str = "", - **_kwargs: Any, -) -> Optional[str]: - """Write large eligible results to disk and return a bounded pointer.""" - del args - if tool_name in NEVER_SANDBOX or tool_name not in SANDBOX_TOOLS: - return None - if not isinstance(result, str): - return None - original_bytes = len(result.encode("utf-8")) - if original_bytes <= SANDBOX_THRESHOLD: - return None - - content = _extract_result_content(result) - SANDBOX_DIR.mkdir(parents=True, exist_ok=True) - safe_tool = re.sub(r"[^A-Za-z0-9_.-]", "_", tool_name) or "tool" - safe_task = re.sub(r"[^A-Za-z0-9_.-]", "_", task_id[:24]) or "na" - filename = f"{time.time_ns()}_{safe_tool}_{safe_task}_{uuid4().hex[:8]}.txt" - path = SANDBOX_DIR / filename - path.write_text(content, encoding="utf-8") - - preview = html.escape(content[:200].strip(), quote=False) - line_count = content.count("\n") + 1 - summary = ( - f'\n' - " Output exceeded 3 KiB and was written to a local sandbox file.\n" - f" Preview: {preview}\n" - "" - ) - saved_bytes = max(0, original_bytes - len(summary.encode("utf-8"))) - _increment(session_id, "bytes_saved", saved_bytes) - with _state_lock: - stats = _stats_for(session_id) - stats["tools_saved"][tool_name] += saved_bytes - _record_saving(session_id, tool_name, original_bytes, saved_bytes, str(path)) - return summary - - -def pre_llm_call( - session_id: str, - user_message: str = "", - is_first_turn: bool = False, - **_kwargs: Any, -) -> Optional[dict[str, str]]: - del user_message - if not is_first_turn or not _remember_guidance(session_id): - return None - return {"context": ROUTING_BLOCK} - - -def register(ctx: Any) -> None: - ctx.register_hook("pre_tool_call", pre_tool_call) - ctx.register_hook("transform_tool_result", transform_tool_result) - ctx.register_hook("pre_llm_call", pre_llm_call) - ctx.register_hook("on_session_start", on_session_start) - ctx.register_hook("on_session_end", on_session_end) - ctx.register_hook("on_session_finalize", on_session_finalize) - logger.info("hermes-context-mode registered (6 hooks)") diff --git a/hermes-plugin/__pycache__/__init__.cpython-312.pyc b/hermes-plugin/__pycache__/__init__.cpython-312.pyc deleted file mode 100644 index f14faa6a0112b6f437ee39c0efad288b2c4e5eb7..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 17115 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 diff --git a/hermes-plugin/plugin.yaml b/hermes-plugin/plugin.yaml deleted file mode 100644 index c4ca67ce..00000000 --- a/hermes-plugin/plugin.yaml +++ /dev/null @@ -1,10 +0,0 @@ -name: hermes-context-mode -version: "2.0.0" -description: Context Mode routing protection and bounded output sandboxing for Hermes Agent. -hooks: - - pre_tool_call - - transform_tool_result - - pre_llm_call - - on_session_start - - on_session_end - - on_session_finalize From e7a2a524f68117ad1a82158e87f067153a1a4567 Mon Sep 17 00:00:00 2001 From: CommanderTurtle Date: Mon, 20 Jul 2026 21:13:01 -0400 Subject: [PATCH 09/33] Create file --- .hermes-plugin/file | 1 + 1 file changed, 1 insertion(+) create mode 100644 .hermes-plugin/file diff --git a/.hermes-plugin/file b/.hermes-plugin/file new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/.hermes-plugin/file @@ -0,0 +1 @@ + From 796bc6c143d91072d2999a064731c0838274b3b3 Mon Sep 17 00:00:00 2001 From: CommanderTurtle Date: Mon, 20 Jul 2026 21:13:14 -0400 Subject: [PATCH 10/33] Add files via upload --- .hermes-plugin/README.md | 73 ++++ .hermes-plugin/__init__.py | 410 ++++++++++++++++++ .../__pycache__/__init__.cpython-312.pyc | Bin 0 -> 17115 bytes .hermes-plugin/plugin.yaml | 10 + 4 files changed, 493 insertions(+) create mode 100644 .hermes-plugin/README.md create mode 100644 .hermes-plugin/__init__.py create mode 100644 .hermes-plugin/__pycache__/__init__.cpython-312.pyc create mode 100644 .hermes-plugin/plugin.yaml diff --git a/.hermes-plugin/README.md b/.hermes-plugin/README.md new file mode 100644 index 00000000..b919d258 --- /dev/null +++ b/.hermes-plugin/README.md @@ -0,0 +1,73 @@ +# Context Mode for Hermes Agent + +This Hermes plugin adds proactive Context Mode routing and a bounded fallback +for oversized native tool results. It uses the public Hermes plugin API and the +Python standard library only. + +## Requirements + +- Hermes Agent with the current Python plugin hooks +- Node.js 22.5 or later, or Bun +- The Context Mode MCP server registered under the name `context-mode` + +Hermes registers that server's tools as `mcp__context_mode__ctx_*`. + +## Install + +Register the MCP server: + +```bash +hermes mcp add context-mode --command npx --args -y context-mode +``` + +Copy the plugin on Linux, macOS, or WSL: + +```bash +mkdir -p ~/.hermes/plugins/hermes-context-mode +cp .hermes-plugin/plugin.yaml .hermes-plugin/__init__.py ~/.hermes/plugins/hermes-context-mode/ +``` + +Copy the plugin in PowerShell: + +```powershell +$target = Join-Path $HOME ".hermes/plugins/hermes-context-mode" +New-Item -ItemType Directory -Force $target | Out-Null +Copy-Item .hermes-plugin/plugin.yaml, .hermes-plugin/__init__.py $target -Force +``` + +Enable it in `~/.hermes/config.yaml`: + +```yaml +plugins: + enabled: + - hermes-context-mode +``` + +Restart Hermes. For a gateway install, restart the gateway process. Confirm the +MCP connection with `hermes mcp test context-mode`, then ask Hermes for +`ctx stats`. + +## Behavior + +| Hook | Behavior | +|---|---| +| `pre_tool_call` | Blocks known high-output terminal fetch/build commands using Hermes' `{"action":"block","message":"..."}` contract. | +| `transform_tool_result` | Writes eligible outputs larger than 3 KiB to a collision-safe UTF-8 file and returns a compact pointer. | +| `pre_llm_call` | Injects current `mcp__context_mode__ctx_*` routing guidance once per session. | +| `on_session_start` | Initializes bounded per-session metrics. | +| `on_session_end` | Persists a snapshot at Hermes' per-turn boundary without destroying session state. | +| `on_session_finalize` | Persists and releases state when Hermes tears down the session. | + +Generated data stays under +`~/.hermes/plugins/hermes-context-mode/` (or `$HERMES_HOME/plugins/...`): + +```text +metrics.db +sandbox/ +``` + +Hermes plugin API: + +Hermes hook contracts: + +Hermes MCP configuration: diff --git a/.hermes-plugin/__init__.py b/.hermes-plugin/__init__.py new file mode 100644 index 00000000..30a8f7a9 --- /dev/null +++ b/.hermes-plugin/__init__.py @@ -0,0 +1,410 @@ +"""Hermes Agent integration for Context Mode. + +This plugin enforces the routing boundary around high-output terminal work, +injects current Hermes MCP tool names once per session, and keeps oversized +native tool results out of the model context. It uses only the Python standard +library and is intentionally independent of Hermes internals. +""" + +from __future__ import annotations + +import html +import json +import logging +import os +import re +import sqlite3 +import threading +import time +from collections import Counter, OrderedDict +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Optional +from uuid import uuid4 + +logger = logging.getLogger("hermes-context-mode") + +HERMES_HOME = Path(os.environ.get("HERMES_HOME", Path.home() / ".hermes")) +PLUGIN_DIR = HERMES_HOME / "plugins" / "hermes-context-mode" +METRICS_DB = PLUGIN_DIR / "metrics.db" +SANDBOX_DIR = PLUGIN_DIR / "sandbox" + +SANDBOX_THRESHOLD = 3 * 1024 +_GUIDANCE_CAP = 1000 + +BLOCKED_HIGH_OUTPUT = re.compile( + r"\b(?:curl|wget|docker\s+(?:build|compose\s+up)|make|cmake|gradle|mvn|" + r"cargo\s+(?:build|test|run|check)|npx|npm\s+(?:run|start|test)|" + r"playwright\s+(?:open|codegen|install)|" + r"kubectl\s+(?:get|logs|describe|apply))\b", + re.IGNORECASE, +) + +BLOCKED_INLINE_HTTP = re.compile( + r"\b(?:fetch\s*\(\s*['\"]https?://|" + r"requests\.(?:get|post|put|delete|patch)\s*\(|" + r"http\.(?:get|post|request)\s*\(|" + r"urllib\.request\.urlopen\s*\(|" + r"Invoke-(?:WebRequest|RestMethod)\b)", + re.IGNORECASE, +) + +NEVER_SANDBOX = { + "write_file", + "patch", + "text_to_speech", + "send_message", + "vision_analyze", +} + +SANDBOX_TOOLS = { + "terminal", + "read_file", + "browser_snapshot", + "browser_console", + "browser_vision", + "web_extract", + "web_search", + "execute_code", +} + +_TOOL_PREFIX = "mcp__context_mode__" +ROUTING_BLOCK = f""" + Context Mode is connected through the Hermes MCP server named context-mode. + Its registered tools use the current Hermes prefix `{_TOOL_PREFIX}`. + + Think in Code: process, filter, count, parse, and aggregate inside the Context + Mode sandbox. Print only the derived answer so raw bytes do not enter the + conversation. + + Prefer: + - `{_TOOL_PREFIX}ctx_batch_execute` to gather command output and index it. + - `{_TOOL_PREFIX}ctx_search` to query indexed output and session memory. + - `{_TOOL_PREFIX}ctx_execute` or `{_TOOL_PREFIX}ctx_execute_file` to analyze data. + - `{_TOOL_PREFIX}ctx_fetch_and_index` for web content. + + Native terminal remains appropriate for short, predictable output and state + mutations such as git, mkdir, rm, mv, and package installation. Native file + reads remain appropriate when exact bytes are needed for an edit. + + High-output terminal fetch/build commands are blocked by this plugin. Do not + retry them through terminal; use the matching Context Mode MCP tool. +""" + +SESSION_GUIDANCE_SHOWN: "OrderedDict[str, None]" = OrderedDict() +_session_stats: dict[str, dict[str, Any]] = {} +_state_lock = threading.RLock() + + +def _utc_now() -> str: + return datetime.now(timezone.utc).isoformat() + + +def _new_stats(model: str = "", platform: str = "") -> dict[str, Any]: + return { + "tool_calls": 0, + "bytes_saved": 0, + "blocks": 0, + "tools_saved": Counter(), + "model": model, + "platform": platform, + "started": _utc_now(), + } + + +def _stats_for(session_id: str) -> dict[str, Any]: + key = session_id or "unknown" + with _state_lock: + return _session_stats.setdefault(key, _new_stats()) + + +def _increment(session_id: str, field: str, amount: int = 1) -> None: + with _state_lock: + stats = _stats_for(session_id) + stats[field] = int(stats.get(field, 0)) + amount + + +def _remember_guidance(session_id: str) -> bool: + key = session_id or "unknown" + with _state_lock: + if key in SESSION_GUIDANCE_SHOWN: + SESSION_GUIDANCE_SHOWN.move_to_end(key) + return False + SESSION_GUIDANCE_SHOWN[key] = None + while len(SESSION_GUIDANCE_SHOWN) > _GUIDANCE_CAP: + SESSION_GUIDANCE_SHOWN.popitem(last=False) + return True + + +def _ensure_db() -> sqlite3.Connection: + PLUGIN_DIR.mkdir(parents=True, exist_ok=True) + conn = sqlite3.connect(str(METRICS_DB), timeout=10) + conn.execute("PRAGMA journal_mode=WAL") + conn.execute( + """ + CREATE TABLE IF NOT EXISTS session_metrics ( + session_id TEXT PRIMARY KEY, + platform TEXT, + model TEXT, + started TEXT, + ended TEXT, + tool_calls INTEGER DEFAULT 0, + bytes_saved INTEGER DEFAULT 0, + tools_saved TEXT DEFAULT '{}', + blocks INTEGER DEFAULT 0 + ) + """ + ) + conn.execute( + """ + CREATE TABLE IF NOT EXISTS tool_savings ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id TEXT, + tool_name TEXT, + original_bytes INTEGER, + saved_bytes INTEGER, + sandbox_path TEXT, + ts TEXT + ) + """ + ) + conn.commit() + return conn + + +def _snapshot_stats(session_id: str) -> Optional[dict[str, Any]]: + with _state_lock: + stats = _session_stats.get(session_id or "unknown") + if stats is None: + return None + snapshot = dict(stats) + snapshot["tools_saved"] = dict(stats["tools_saved"]) + return snapshot + + +def _persist_session(session_id: str) -> None: + snapshot = _snapshot_stats(session_id) + if snapshot is None: + return + try: + with _ensure_db() as conn: + conn.execute( + """ + INSERT INTO session_metrics + (session_id, platform, model, started, ended, tool_calls, + bytes_saved, tools_saved, blocks) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(session_id) DO UPDATE SET + platform=excluded.platform, + model=excluded.model, + ended=excluded.ended, + tool_calls=excluded.tool_calls, + bytes_saved=excluded.bytes_saved, + tools_saved=excluded.tools_saved, + blocks=excluded.blocks + """, + ( + session_id or "unknown", + snapshot["platform"], + snapshot["model"], + snapshot["started"], + _utc_now(), + snapshot["tool_calls"], + snapshot["bytes_saved"], + json.dumps(snapshot["tools_saved"], sort_keys=True), + snapshot["blocks"], + ), + ) + except Exception as exc: # pragma: no cover - metrics must fail open + logger.debug("Session metrics save failed: %s", exc) + + +def _record_saving( + session_id: str, + tool_name: str, + original_bytes: int, + saved_bytes: int, + path: str, +) -> None: + try: + with _ensure_db() as conn: + conn.execute( + """ + INSERT INTO tool_savings + (session_id, tool_name, original_bytes, saved_bytes, + sandbox_path, ts) + VALUES (?, ?, ?, ?, ?, ?) + """, + ( + session_id or "unknown", + tool_name, + original_bytes, + saved_bytes, + path, + _utc_now(), + ), + ) + except Exception as exc: # pragma: no cover - metrics must fail open + logger.debug("Tool metrics save failed: %s", exc) + + +def _extract_command(args: Any) -> str: + if not isinstance(args, dict): + return "" + command = args.get("command", "") + return command if isinstance(command, str) else "" + + +def _extract_result_content(result: str) -> str: + try: + parsed = json.loads(result) + except (json.JSONDecodeError, TypeError): + return result + if not isinstance(parsed, dict): + return result + for key in ("content", "output", "result"): + value = parsed.get(key) + if isinstance(value, str): + return value + return result + + +def on_session_start( + session_id: str, + model: str = "", + platform: str = "", + **_kwargs: Any, +) -> None: + key = session_id or "unknown" + with _state_lock: + _session_stats[key] = _new_stats(model, platform) + SESSION_GUIDANCE_SHOWN.pop(key, None) + + +def on_session_end( + session_id: str, + completed: bool = False, + interrupted: bool = False, + **_kwargs: Any, +) -> None: + """Persist a turn snapshot without discarding session-scoped state.""" + del completed, interrupted + _persist_session(session_id) + + +def on_session_finalize( + session_id: Optional[str] = None, + **_kwargs: Any, +) -> None: + """Persist and release state at the current Hermes teardown boundary.""" + key = session_id or "unknown" + _persist_session(key) + with _state_lock: + _session_stats.pop(key, None) + SESSION_GUIDANCE_SHOWN.pop(key, None) + + +def pre_tool_call( + tool_name: str, + args: dict, + task_id: str = "", + session_id: str = "", + **_kwargs: Any, +) -> Optional[dict[str, str]]: + """Block terminal commands whose raw output should use Context Mode.""" + del task_id + if tool_name != "terminal": + return None + command = _extract_command(args).strip() + if not command: + return None + + _increment(session_id, "tool_calls") + if BLOCKED_HIGH_OUTPUT.search(command): + _increment(session_id, "blocks") + return { + "action": "block", + "message": ( + "context-mode blocked a high-output terminal command. Use " + f"{_TOOL_PREFIX}ctx_execute or {_TOOL_PREFIX}ctx_batch_execute." + ), + } + + if BLOCKED_INLINE_HTTP.search(command): + _increment(session_id, "blocks") + url_match = re.search(r"https?://[^\s\"'()]+", command) + suffix = f" for {url_match.group(0)}" if url_match else "" + return { + "action": "block", + "message": ( + "context-mode blocked inline HTTP. Use " + f"{_TOOL_PREFIX}ctx_fetch_and_index{suffix}." + ), + } + return None + + +def transform_tool_result( + tool_name: str, + args: Any, + result: str, + session_id: str = "", + task_id: str = "", + **_kwargs: Any, +) -> Optional[str]: + """Write large eligible results to disk and return a bounded pointer.""" + del args + if tool_name in NEVER_SANDBOX or tool_name not in SANDBOX_TOOLS: + return None + if not isinstance(result, str): + return None + original_bytes = len(result.encode("utf-8")) + if original_bytes <= SANDBOX_THRESHOLD: + return None + + content = _extract_result_content(result) + SANDBOX_DIR.mkdir(parents=True, exist_ok=True) + safe_tool = re.sub(r"[^A-Za-z0-9_.-]", "_", tool_name) or "tool" + safe_task = re.sub(r"[^A-Za-z0-9_.-]", "_", task_id[:24]) or "na" + filename = f"{time.time_ns()}_{safe_tool}_{safe_task}_{uuid4().hex[:8]}.txt" + path = SANDBOX_DIR / filename + path.write_text(content, encoding="utf-8") + + preview = html.escape(content[:200].strip(), quote=False) + line_count = content.count("\n") + 1 + summary = ( + f'\n' + " Output exceeded 3 KiB and was written to a local sandbox file.\n" + f" Preview: {preview}\n" + "" + ) + saved_bytes = max(0, original_bytes - len(summary.encode("utf-8"))) + _increment(session_id, "bytes_saved", saved_bytes) + with _state_lock: + stats = _stats_for(session_id) + stats["tools_saved"][tool_name] += saved_bytes + _record_saving(session_id, tool_name, original_bytes, saved_bytes, str(path)) + return summary + + +def pre_llm_call( + session_id: str, + user_message: str = "", + is_first_turn: bool = False, + **_kwargs: Any, +) -> Optional[dict[str, str]]: + del user_message + if not is_first_turn or not _remember_guidance(session_id): + return None + return {"context": ROUTING_BLOCK} + + +def register(ctx: Any) -> None: + ctx.register_hook("pre_tool_call", pre_tool_call) + ctx.register_hook("transform_tool_result", transform_tool_result) + ctx.register_hook("pre_llm_call", pre_llm_call) + ctx.register_hook("on_session_start", on_session_start) + ctx.register_hook("on_session_end", on_session_end) + ctx.register_hook("on_session_finalize", on_session_finalize) + logger.info("hermes-context-mode registered (6 hooks)") diff --git a/.hermes-plugin/__pycache__/__init__.cpython-312.pyc b/.hermes-plugin/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..f14faa6a0112b6f437ee39c0efad288b2c4e5eb7 GIT binary patch literal 17115 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 literal 0 HcmV?d00001 diff --git a/.hermes-plugin/plugin.yaml b/.hermes-plugin/plugin.yaml new file mode 100644 index 00000000..c4ca67ce --- /dev/null +++ b/.hermes-plugin/plugin.yaml @@ -0,0 +1,10 @@ +name: hermes-context-mode +version: "2.0.0" +description: Context Mode routing protection and bounded output sandboxing for Hermes Agent. +hooks: + - pre_tool_call + - transform_tool_result + - pre_llm_call + - on_session_start + - on_session_end + - on_session_finalize From b188de4b22af45096e5bef030756b098009ce508 Mon Sep 17 00:00:00 2001 From: CommanderTurtle Date: Mon, 20 Jul 2026 21:13:26 -0400 Subject: [PATCH 11/33] Delete .hermes-plugin/file --- .hermes-plugin/file | 1 - 1 file changed, 1 deletion(-) delete mode 100644 .hermes-plugin/file diff --git a/.hermes-plugin/file b/.hermes-plugin/file deleted file mode 100644 index 8b137891..00000000 --- a/.hermes-plugin/file +++ /dev/null @@ -1 +0,0 @@ - From 2df085c93598d09762bc2a95c4eafa3153e73bac Mon Sep 17 00:00:00 2001 From: CommanderTurtle Date: Mon, 20 Jul 2026 21:13:39 -0400 Subject: [PATCH 12/33] Add files via upload From c6cb6b45d1b4f0e47e0464880112b92c3e957635 Mon Sep 17 00:00:00 2001 From: CommanderTurtle Date: Mon, 20 Jul 2026 21:14:01 -0400 Subject: [PATCH 13/33] Add files via upload From cd2652fa4cad4112afbb74b5fcbffa8d089167bc Mon Sep 17 00:00:00 2001 From: CommanderTurtle Date: Mon, 20 Jul 2026 21:15:22 -0400 Subject: [PATCH 14/33] Add .pytest_cache/gitignore file --- .pytest_cache/gitignore | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 .pytest_cache/gitignore diff --git a/.pytest_cache/gitignore b/.pytest_cache/gitignore new file mode 100644 index 00000000..bc1a1f61 --- /dev/null +++ b/.pytest_cache/gitignore @@ -0,0 +1,2 @@ +# Created by pytest automatically. +* From 2549f10a6d614b4b22bf9021a5f2625cf8aef5bd Mon Sep 17 00:00:00 2001 From: CommanderTurtle Date: Mon, 20 Jul 2026 21:15:34 -0400 Subject: [PATCH 15/33] Add files via upload --- .pytest_cache/CACHEDIR.TAG | 4 ++++ .pytest_cache/README.md | 8 ++++++++ .pytest_cache/v/cache/nodeids | 25 +++++++++++++++++++++++++ 3 files changed, 37 insertions(+) create mode 100644 .pytest_cache/CACHEDIR.TAG create mode 100644 .pytest_cache/README.md create mode 100644 .pytest_cache/v/cache/nodeids diff --git a/.pytest_cache/CACHEDIR.TAG b/.pytest_cache/CACHEDIR.TAG new file mode 100644 index 00000000..fce15ad7 --- /dev/null +++ b/.pytest_cache/CACHEDIR.TAG @@ -0,0 +1,4 @@ +Signature: 8a477f597d28d172789f06886806bc55 +# This file is a cache directory tag created by pytest. +# For information about cache directory tags, see: +# https://bford.info/cachedir/spec.html diff --git a/.pytest_cache/README.md b/.pytest_cache/README.md new file mode 100644 index 00000000..b89018ce --- /dev/null +++ b/.pytest_cache/README.md @@ -0,0 +1,8 @@ +# pytest cache directory # + +This directory contains data from the pytest's cache plugin, +which provides the `--lf` and `--ff` options, as well as the `cache` fixture. + +**Do not** commit this to version control. + +See [the docs](https://docs.pytest.org/en/stable/how-to/cache.html) for more information. diff --git a/.pytest_cache/v/cache/nodeids b/.pytest_cache/v/cache/nodeids new file mode 100644 index 00000000..6c5b609d --- /dev/null +++ b/.pytest_cache/v/cache/nodeids @@ -0,0 +1,25 @@ +[ + "tests/adapters/hermes_test.py::test_allowlisted_or_bounded_commands_pass[git status]", + "tests/adapters/hermes_test.py::test_allowlisted_or_bounded_commands_pass[mkdir output]", + "tests/adapters/hermes_test.py::test_allowlisted_or_bounded_commands_pass[npm install]", + "tests/adapters/hermes_test.py::test_allowlisted_or_bounded_commands_pass[pwd]", + "tests/adapters/hermes_test.py::test_disallowed_terminal_commands_block[cargo check]", + "tests/adapters/hermes_test.py::test_disallowed_terminal_commands_block[curl https://example.com/data]", + "tests/adapters/hermes_test.py::test_disallowed_terminal_commands_block[git status && curl https://example.com/data]", + "tests/adapters/hermes_test.py::test_disallowed_terminal_commands_block[npm test]", + "tests/adapters/hermes_test.py::test_disallowed_terminal_commands_block[wget https://example.com/data]", + "tests/adapters/hermes_test.py::test_finalize_persists_then_releases_session_state", + "tests/adapters/hermes_test.py::test_first_turn_guidance_uses_current_hermes_mcp_names", + "tests/adapters/hermes_test.py::test_guidance_cap_is_thread_safe", + "tests/adapters/hermes_test.py::test_guidance_is_not_reinjected_later", + "tests/adapters/hermes_test.py::test_guidance_state_is_bounded_at_exact_cap", + "tests/adapters/hermes_test.py::test_inline_http_blocks[Invoke-WebRequest https://example.com]", + "tests/adapters/hermes_test.py::test_inline_http_blocks[node -e \"fetch('https://example.com')\"]", + "tests/adapters/hermes_test.py::test_inline_http_blocks[python -c \"requests.get('https://example.com')\"]", + "tests/adapters/hermes_test.py::test_large_utf8_output_is_sandboxed_losslessly", + "tests/adapters/hermes_test.py::test_non_terminal_tool_passes", + "tests/adapters/hermes_test.py::test_parallel_sandbox_writes_have_unique_names", + "tests/adapters/hermes_test.py::test_register_declares_current_hermes_hooks", + "tests/adapters/hermes_test.py::test_small_output_is_unchanged", + "tests/adapters/hermes_test.py::test_turn_end_persists_without_destroying_session_state" +] \ No newline at end of file From 3022fa53b675570f0574255974a1840cdf871392 Mon Sep 17 00:00:00 2001 From: CommanderTurtle Date: Mon, 20 Jul 2026 21:16:30 -0400 Subject: [PATCH 16/33] Add files via upload From 08b85e64e966e7fd6a3ab80bab7c9fb1f7cb1527 Mon Sep 17 00:00:00 2001 From: CommanderTurtle Date: Mon, 20 Jul 2026 21:17:03 -0400 Subject: [PATCH 17/33] Create file --- build/file | 1 + 1 file changed, 1 insertion(+) create mode 100644 build/file diff --git a/build/file b/build/file new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/build/file @@ -0,0 +1 @@ + From cbd7b76862dd64c909c8ae5e1cee82a5a2c1cd22 Mon Sep 17 00:00:00 2001 From: CommanderTurtle Date: Mon, 20 Jul 2026 21:18:00 -0400 Subject: [PATCH 18/33] Delete build directory --- build/file | 1 - 1 file changed, 1 deletion(-) delete mode 100644 build/file diff --git a/build/file b/build/file deleted file mode 100644 index 8b137891..00000000 --- a/build/file +++ /dev/null @@ -1 +0,0 @@ - From 8b63437287cf9ec676c6f5722ebbca8bdbb6a2ed Mon Sep 17 00:00:00 2001 From: CommanderTurtle Date: Mon, 20 Jul 2026 21:18:25 -0400 Subject: [PATCH 19/33] Add files via upload --- configs/hermes/AGENTS.md | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 configs/hermes/AGENTS.md diff --git a/configs/hermes/AGENTS.md b/configs/hermes/AGENTS.md new file mode 100644 index 00000000..7da2174c --- /dev/null +++ b/configs/hermes/AGENTS.md @@ -0,0 +1,22 @@ +# Context Mode + +Context Mode is connected to Hermes as the MCP server `context-mode`. Hermes +registers its tools with the prefix `mcp__context_mode__`. + +## Routing + +- Gather high-output command results with `mcp__context_mode__ctx_batch_execute`. +- Analyze files with `mcp__context_mode__ctx_execute_file` when exact bytes are + not needed for an edit. +- Analyze, filter, count, parse, or transform data with + `mcp__context_mode__ctx_execute`; print only the derived answer. +- Fetch web content with `mcp__context_mode__ctx_fetch_and_index`, then query it + with `mcp__context_mode__ctx_search`. +- Use native terminal for short, predictable output and state mutations. +- Use native file reads when exact content is needed for an edit. + +The Hermes plugin blocks known high-output terminal fetch/build commands. Do +not retry blocked commands through terminal; route them through Context Mode. + +If the plugin is enabled, it injects these rules on the first turn. Copy this +file into a project only when a persistent project-level fallback is useful. From ce8338cb2eac3924073982d8a487fd12d11f07a4 Mon Sep 17 00:00:00 2001 From: CommanderTurtle Date: Mon, 20 Jul 2026 21:18:41 -0400 Subject: [PATCH 20/33] Add files via upload --- docs/adapters/hermes-agent.md | 72 +++++++++++++++++++++++++++++++++++ docs/platform-support.md | 72 ++++++++++++++++++++++++++++------- 2 files changed, 131 insertions(+), 13 deletions(-) create mode 100644 docs/adapters/hermes-agent.md diff --git a/docs/adapters/hermes-agent.md b/docs/adapters/hermes-agent.md new file mode 100644 index 00000000..54661b59 --- /dev/null +++ b/docs/adapters/hermes-agent.md @@ -0,0 +1,72 @@ +# Hermes Agent + +## Status + +Hermes Agent is supported through two independent public interfaces: + +1. Context Mode runs as a standard MCP server. +2. A Python plugin uses Hermes hooks for proactive routing and bounded output + fallback. + +No Hermes source patch or private API is required. + +## Current Contracts + +| Integration point | Hermes contract | Context Mode use | +|---|---|---| +| MCP naming | `mcp____` | `mcp__context_mode__ctx_*` | +| `pre_tool_call` | May return `{"action":"block","message":str}` | Blocks known high-output terminal commands. | +| `transform_tool_result` | First non-empty string replaces the model-visible result. | Replaces eligible results over 3 KiB with a local file pointer. | +| `pre_llm_call` | May return `{"context":str}` | Injects routing guidance once per session. | +| `on_session_end` | Fires after every `run_conversation()` call. | Persists a metrics snapshot without releasing session state. | +| `on_session_finalize` | Fires on actual CLI/gateway session teardown. | Persists and releases session state. | + +The `on_session_end`/`on_session_finalize` distinction is required for current +Hermes. Treating `on_session_end` as final teardown loses state after the first +turn. + +## Install + +Register Context Mode using Hermes' current MCP CLI syntax: + +```bash +hermes mcp add context-mode --command npx --args -y context-mode +``` + +Copy `.hermes-plugin/plugin.yaml` and `.hermes-plugin/__init__.py` to: + +```text +~/.hermes/plugins/hermes-context-mode/ +``` + +Enable the plugin: + +```yaml +plugins: + enabled: + - hermes-context-mode +``` + +Restart Hermes, run `hermes mcp test context-mode`, and ask for `ctx stats`. + +Project-local Hermes plugins are disabled by default. If you intentionally copy +the plugin under `./.hermes/plugins/`, Hermes requires +`HERMES_ENABLE_PROJECT_PLUGINS=true` for that trusted repository. + +## Data and Failure Model + +The plugin is standard-library-only and fails open for metrics writes. It stores +only local metrics and oversized output files under the plugin directory. The +first-turn guidance map is concurrency-safe, evicts oldest entries, and never +exceeds 1,000 session IDs. Tool output files use nanosecond timestamps plus +random suffixes so parallel calls cannot overwrite one another. + +The MCP server remains the source of Context Mode execution, indexing, search, +statistics, and diagnostics. The Python plugin does not duplicate those systems. + +## References + +- Hermes Agent repository: +- Hermes plugin API: +- Hermes hook API: +- Hermes MCP setup: diff --git a/docs/platform-support.md b/docs/platform-support.md index 7ecf4a0a..fa7faec1 100644 --- a/docs/platform-support.md +++ b/docs/platform-support.md @@ -4,16 +4,21 @@ This document provides a comprehensive comparison of all platforms supported by ## Overview -context-mode supports 17 client platforms, plus the OpenClaw gateway integration, across three hook paradigms: +context-mode supports 18 client platforms, plus the OpenClaw gateway integration, across four hook paradigms: | Paradigm | Platforms | |----------|-----------| | **JSON stdin/stdout** | Claude Code, Gemini CLI, VS Code Copilot, JetBrains Copilot, GitHub Copilot CLI, Cursor, Codex CLI, Qwen Code, Kimi Code, Antigravity CLI (`agy`), Kiro | | **TS Plugin** | OpenCode, KiloCode, OpenClaw | | **MCP-only** | Antigravity, Zed, Pi, OMP (Oh My Pi) | +| **Python Plugin** | Hermes Agent | The MCP server layer is 100% portable and needs no adapter. Only the hook layer requires platform-specific adapters. +Hermes is packaged separately from the TypeScript adapter abstraction because +its public plugin runtime is Python. The plugin communicates only through +documented Hermes hooks and Context Mode's ordinary MCP server. + ## Prerequisites All platforms (except Claude Code plugin install) require a global install: @@ -743,6 +748,46 @@ OpenClaw is an OpenAI-stack agent gateway. context-mode ships as a native gatewa --- +### Hermes Agent + +**Status:** Supported through a Python plugin and MCP + +**Hook Paradigm:** Python plugin (`register(ctx)` and public lifecycle hooks) + +Hermes loads Context Mode's standard-library-only Python plugin for proactive +routing and bounded output fallback. Context Mode itself remains an ordinary +MCP server registered under `context-mode`; Hermes exposes its tools as +`mcp__context_mode__ctx_*`. + +**Hook Support:** +- PreToolUse: `pre_tool_call` +- PostToolUse: `transform_tool_result` +- PreCompact: -- +- SessionStart: `on_session_start` +- Stop: -- (`on_session_finalize` is session teardown, not a tool-stop hook) +- Can modify args: -- +- Can modify output: Yes, for eligible native results over 3 KiB +- Can inject session context: Yes, through `pre_llm_call` +- Can block tools: Yes, with `{"action":"block","message":"..."}` + +**Lifecycle:** +- `on_session_end` is a per-turn boundary in current Hermes and persists a + snapshot without releasing state. +- `on_session_finalize` is the actual CLI/gateway teardown boundary and releases + plugin state. +- First-turn guidance state is concurrency-safe and capped at exactly 1,000 + sessions with oldest-entry eviction. + +**Notes / Caveats:** +- The plugin uses only documented Hermes interfaces and does not patch Hermes. +- The plugin does not duplicate Context Mode's execution, indexing, search, or + session database. Those remain MCP responsibilities. +- Full Context Mode session extraction and pre-compaction continuity are not + claimed for Hermes; the verified integration covers routing, output bounding, + local metrics, and normal MCP execution. + +--- + ### Zed **Status:** MCP-only (no hooks) @@ -824,18 +869,18 @@ The hook adapter exists only to satisfy the interface contract — every parser ## Capability Matrix (Quick Reference) -| Capability | Claude Code | Qwen Code | Gemini CLI | VS Code Copilot | JetBrains Copilot | GitHub Copilot CLI | Cursor | OpenCode | KiloCode | OpenClaw | Codex CLI | Kimi Code | Antigravity | Antigravity CLI (`agy`) | Kiro | Zed | Pi | OMP | -|-----------|:-----------:|:---------:|:----------:|:---------------:|:-----------------:|:------------------:|:------:|:--------:|:--------:|:--------:|:---------:|:---------:|:-----------:|:-----------------------:|:----:|:---:|:--:|:---:| -| PreToolUse | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes*** | Yes | -- | Bounded | Yes | -- | -- | -- | -| PostToolUse | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | -- | Yes (capture-only) | Yes | -- | -- | -- | -| PreCompact | Yes | Yes | Yes | Yes | Yes | Yes | -- | Yes* | Yes* | Yes | Yes**** | Yes | -- | -- | -- | -- | -- | -- | -| SessionStart | Yes | Yes | Yes | Yes | Yes | Yes | Yes | -- | -- | Yes | Yes | Yes | -- | -- | -- | -- | -- | -- | -| Stop | -- | -- | -- | Yes | Yes | Yes | Yes | -- | -- | -- | Yes | Yes | -- | Best-effort capture | -- | -- | -- | -- | -| Modify Args | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | -- | Yes | -- | -- | -- | -- | -- | -- | -| Modify Output | Yes | Yes | Yes | Yes | Yes | No | No | Yes** | Yes** | No | -- | Yes | -- | -- | -- | -- | -- | -- | -| Inject Context | Yes | Yes | Yes | Yes | Yes | Yes | Yes | -- | -- | Yes | Yes | Yes | -- | -- | -- | -- | -- | -- | -| Block Tools | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | -- | Bounded | Yes | -- | -- | -- | -| MCP/native tool support | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Native plugin | Native plugin | Native plugin | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | +| Capability | Claude Code | Qwen Code | Gemini CLI | VS Code Copilot | JetBrains Copilot | GitHub Copilot CLI | Cursor | OpenCode | KiloCode | OpenClaw | Codex CLI | Kimi Code | Antigravity | Antigravity CLI (`agy`) | Kiro | Hermes | Zed | Pi | OMP | +|-----------|:-----------:|:---------:|:----------:|:---------------:|:-----------------:|:------------------:|:------:|:--------:|:--------:|:--------:|:---------:|:---------:|:-----------:|:-----------------------:|:----:|:------:|:---:|:--:|:---:| +| PreToolUse | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes*** | Yes | -- | Bounded | Yes | Yes | -- | -- | -- | +| PostToolUse | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | -- | Yes (capture-only) | Yes | Yes | -- | -- | -- | +| PreCompact | Yes | Yes | Yes | Yes | Yes | Yes | -- | Yes* | Yes* | Yes | Yes**** | Yes | -- | -- | -- | -- | -- | -- | -- | +| SessionStart | Yes | Yes | Yes | Yes | Yes | Yes | Yes | -- | -- | Yes | Yes | Yes | -- | -- | -- | Yes | -- | -- | -- | +| Stop | -- | -- | -- | Yes | Yes | Yes | Yes | -- | -- | -- | Yes | Yes | -- | Best-effort capture | -- | -- | -- | -- | -- | +| Modify Args | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | -- | Yes | -- | -- | -- | -- | -- | -- | -- | +| Modify Output | Yes | Yes | Yes | Yes | Yes | No | No | Yes** | Yes** | No | -- | Yes | -- | -- | -- | Bounded | -- | -- | -- | +| Inject Context | Yes | Yes | Yes | Yes | Yes | Yes | Yes | -- | -- | Yes | Yes | Yes | -- | -- | -- | Yes | -- | -- | -- | +| Block Tools | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | -- | Bounded | Yes | Yes | -- | -- | -- | +| MCP/native tool support | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Native plugin | Native plugin | Native plugin | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | \* OpenCode `experimental.session.compacting` is experimental \*\* OpenCode has a TUI rendering bug for bash tool output (#13575) @@ -858,6 +903,7 @@ The hook adapter exists only to satisfy the interface contract — every parser | OpenCode | `throw new Error("...")` | | Codex CLI | `{ "hookSpecificOutput": { "permissionDecision": "deny" } }` or exit code 2 | | Kimi Code | `{ "hookSpecificOutput": { "permissionDecision": "deny" } }` or exit code 2 | +| Hermes Agent | `{ "action": "block", "message": "..." }` | ### Modifying Tool Input From 25bb362b1c4e6d7f76578221e7180d34a2f36d73 Mon Sep 17 00:00:00 2001 From: CommanderTurtle Date: Mon, 20 Jul 2026 21:19:17 -0400 Subject: [PATCH 21/33] Add files via upload From 946b93785021f5774f5a1bf538f6bfde258f51f5 Mon Sep 17 00:00:00 2001 From: CommanderTurtle Date: Mon, 20 Jul 2026 21:19:50 -0400 Subject: [PATCH 22/33] Add files via upload From 6e0958050b081c3bb15d0420c666ed082302cf35 Mon Sep 17 00:00:00 2001 From: CommanderTurtle Date: Mon, 20 Jul 2026 21:21:34 -0400 Subject: [PATCH 23/33] Add files via upload From 722c945770718be94f3f171b756454e85e9739a0 Mon Sep 17 00:00:00 2001 From: CommanderTurtle Date: Mon, 20 Jul 2026 21:22:09 -0400 Subject: [PATCH 24/33] Add files via upload From dc30134f7e4e8942be89766bc219ec4634c3a633 Mon Sep 17 00:00:00 2001 From: CommanderTurtle Date: Mon, 20 Jul 2026 21:23:41 -0400 Subject: [PATCH 25/33] Add files via upload --- tests/adapters/hermes.test.ts | 81 ++++++++++++ tests/adapters/hermes_probe.py | 61 +++++++++ tests/adapters/hermes_test.py | 222 +++++++++++++++++++++++++++++++++ 3 files changed, 364 insertions(+) create mode 100644 tests/adapters/hermes.test.ts create mode 100644 tests/adapters/hermes_probe.py create mode 100644 tests/adapters/hermes_test.py diff --git a/tests/adapters/hermes.test.ts b/tests/adapters/hermes.test.ts new file mode 100644 index 00000000..dc1a3d43 --- /dev/null +++ b/tests/adapters/hermes.test.ts @@ -0,0 +1,81 @@ +import "../setup-home"; +import { describe, expect, it } from "vitest"; +import { existsSync, readFileSync } from "node:fs"; +import { delimiter, resolve } from "node:path"; +import { spawnSync } from "node:child_process"; + +const pluginDir = resolve(__dirname, "../../.hermes-plugin"); +const configDir = resolve(__dirname, "../../configs/hermes"); +const probe = resolve(__dirname, "hermes_probe.py"); + +function findPython(): string { + const candidates = process.env.PYTHON + ? [process.env.PYTHON] + : process.platform === "win32" + ? ["python", "py"] + : ["python3", "python"]; + for (const candidate of candidates) { + const args = candidate === "py" ? ["-3", "--version"] : ["--version"]; + const result = spawnSync(candidate, args, { encoding: "utf8" }); + if (!result.error && result.status === 0) return candidate; + } + throw new Error( + `Hermes adapter tests require Python. PATH entries: ${process.env.PATH?.split(delimiter).length ?? 0}`, + ); +} + +function runProbe(input: Record): unknown { + const python = findPython(); + const args = python === "py" ? ["-3", probe] : [probe]; + const result = spawnSync(python, args, { + input: JSON.stringify(input), + encoding: "utf8", + timeout: 15_000, + }); + expect(result.error).toBeUndefined(); + expect(result.status, result.stderr).toBe(0); + return JSON.parse(result.stdout.trim()); +} + +describe("Hermes plugin package", () => { + it("ships a manifest, register function, docs, and fallback instructions", () => { + const manifest = readFileSync(resolve(pluginDir, "plugin.yaml"), "utf8"); + const implementation = readFileSync(resolve(pluginDir, "__init__.py"), "utf8"); + const packageJson = JSON.parse( + readFileSync(resolve(__dirname, "../../package.json"), "utf8"), + ) as { files: string[] }; + expect(manifest).toMatch(/^name:\s*hermes-context-mode$/m); + expect(manifest).toContain("on_session_finalize"); + expect(implementation).toContain("def register(ctx:"); + expect(packageJson.files).toContain(".hermes-plugin"); + expect(existsSync(resolve(pluginDir, "README.md"))).toBe(true); + expect(existsSync(resolve(configDir, "AGENTS.md"))).toBe(true); + }); +}); + +describe("Hermes hook behavior", () => { + it("blocks a disallowed high-output command with Hermes' block schema", () => { + expect(runProbe({ operation: "pre_tool_call", command: "curl https://example.com" })) + .toMatchObject({ action: "block", message: expect.any(String) }); + }); + + it("allows a bounded command", () => { + expect(runProbe({ operation: "pre_tool_call", command: "git status" })).toBeNull(); + }); + + it("injects current MCP guidance on the first turn", () => { + const result = runProbe({ operation: "guidance_sequence" }) as { + first: { context: string } | null; + }; + expect(result.first?.context).toContain("mcp__context_mode__ctx_execute"); + }); + + it("does not reinject guidance on later or repeated turns", () => { + const result = runProbe({ operation: "guidance_sequence" }) as { + later: unknown; + repeated: unknown; + }; + expect(result.later).toBeNull(); + expect(result.repeated).toBeNull(); + }); +}); diff --git a/tests/adapters/hermes_probe.py b/tests/adapters/hermes_probe.py new file mode 100644 index 00000000..00978ef2 --- /dev/null +++ b/tests/adapters/hermes_probe.py @@ -0,0 +1,61 @@ +"""Dependency-free bridge used by the cross-platform Vitest suite.""" + +from __future__ import annotations + +import importlib.util +import json +import os +import sys +import tempfile +from pathlib import Path + + +def load_plugin(): + os.environ["HERMES_HOME"] = tempfile.mkdtemp(prefix="context-mode-hermes-probe-") + path = Path(__file__).parents[2] / ".hermes-plugin" / "__init__.py" + spec = importlib.util.spec_from_file_location("hermes_context_mode_probe", path) + if spec is None or spec.loader is None: + raise RuntimeError(f"Cannot import plugin from {path}") + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def main() -> int: + request = json.loads(sys.stdin.read()) + plugin = load_plugin() + operation = request["operation"] + + if operation == "pre_tool_call": + result = plugin.pre_tool_call( + tool_name=request.get("tool_name", "terminal"), + args={"command": request.get("command", "")}, + task_id="probe", + session_id="probe-session", + ) + elif operation == "guidance_sequence": + first = plugin.pre_llm_call( + session_id="probe-session", + user_message="first", + is_first_turn=True, + ) + later = plugin.pre_llm_call( + session_id="probe-session", + user_message="later", + is_first_turn=False, + ) + repeated = plugin.pre_llm_call( + session_id="probe-session", + user_message="repeated", + is_first_turn=True, + ) + result = {"first": first, "later": later, "repeated": repeated} + else: + raise ValueError(f"Unknown operation: {operation}") + + print(json.dumps(result)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/adapters/hermes_test.py b/tests/adapters/hermes_test.py new file mode 100644 index 00000000..c054f918 --- /dev/null +++ b/tests/adapters/hermes_test.py @@ -0,0 +1,222 @@ +"""Behavioral tests for the Hermes Context Mode plugin. + +Run with: python -m pytest tests/adapters/hermes_test.py -q +""" + +from __future__ import annotations + +import importlib.util +import os +import sqlite3 +from concurrent.futures import ThreadPoolExecutor +from pathlib import Path + +import pytest + + +@pytest.fixture +def plugin(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes-home")) + path = Path(__file__).parents[2] / ".hermes-plugin" / "__init__.py" + spec = importlib.util.spec_from_file_location( + f"hermes_context_mode_{os.urandom(6).hex()}", path + ) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def call_terminal(plugin, command: str, session_id: str = "session"): + return plugin.pre_tool_call( + tool_name="terminal", + args={"command": command}, + task_id="task", + session_id=session_id, + ) + + +def test_register_declares_current_hermes_hooks(plugin): + class Context: + def __init__(self): + self.hooks = {} + + def register_hook(self, name, callback): + self.hooks[name] = callback + + ctx = Context() + plugin.register(ctx) + assert set(ctx.hooks) == { + "pre_tool_call", + "transform_tool_result", + "pre_llm_call", + "on_session_start", + "on_session_end", + "on_session_finalize", + } + + +@pytest.mark.parametrize("command", [ + "curl https://example.com/data", + "wget https://example.com/data", + "git status && curl https://example.com/data", + "npm test", + "cargo check", +]) +def test_disallowed_terminal_commands_block(plugin, command): + result = call_terminal(plugin, command) + assert result is not None + assert result["action"] == "block" + assert isinstance(result["message"], str) and result["message"] + assert "mcp__context_mode__ctx_" in result["message"] + + +@pytest.mark.parametrize("command", [ + "git status", + "pwd", + "mkdir output", + "npm install", +]) +def test_allowlisted_or_bounded_commands_pass(plugin, command): + assert call_terminal(plugin, command) is None + + +@pytest.mark.parametrize("command", [ + 'python -c "requests.get(\'https://example.com\')"', + 'node -e "fetch(\'https://example.com\')"', + "Invoke-WebRequest https://example.com", +]) +def test_inline_http_blocks(plugin, command): + result = call_terminal(plugin, command) + assert result is not None and result["action"] == "block" + assert "ctx_fetch_and_index" in result["message"] + + +def test_non_terminal_tool_passes(plugin): + assert plugin.pre_tool_call( + tool_name="read_file", + args={"path": "README.md"}, + task_id="task", + ) is None + + +def test_first_turn_guidance_uses_current_hermes_mcp_names(plugin): + result = plugin.pre_llm_call( + session_id="first", user_message="hello", is_first_turn=True + ) + assert result is not None + assert "mcp__context_mode__ctx_execute" in result["context"] + + +def test_guidance_is_not_reinjected_later(plugin): + plugin.pre_llm_call( + session_id="repeat", user_message="first", is_first_turn=True + ) + assert plugin.pre_llm_call( + session_id="repeat", user_message="later", is_first_turn=False + ) is None + assert plugin.pre_llm_call( + session_id="repeat", user_message="first flag again", is_first_turn=True + ) is None + + +def test_guidance_state_is_bounded_at_exact_cap(plugin): + for index in range(plugin._GUIDANCE_CAP + 50): + plugin.pre_llm_call( + session_id=f"session-{index}", + user_message="hello", + is_first_turn=True, + ) + assert len(plugin.SESSION_GUIDANCE_SHOWN) == plugin._GUIDANCE_CAP + assert "session-0" not in plugin.SESSION_GUIDANCE_SHOWN + assert f"session-{plugin._GUIDANCE_CAP + 49}" in plugin.SESSION_GUIDANCE_SHOWN + + +def test_guidance_cap_is_thread_safe(plugin): + def inject(index: int): + return plugin.pre_llm_call( + session_id=f"parallel-{index}", + user_message="hello", + is_first_turn=True, + ) + + with ThreadPoolExecutor(max_workers=16) as pool: + list(pool.map(inject, range(plugin._GUIDANCE_CAP + 100))) + assert len(plugin.SESSION_GUIDANCE_SHOWN) <= plugin._GUIDANCE_CAP + + +def test_small_output_is_unchanged(plugin): + assert plugin.transform_tool_result( + tool_name="terminal", + args={"command": "echo hello"}, + result="hello", + session_id="small", + task_id="task", + ) is None + + +def test_large_utf8_output_is_sandboxed_losslessly(plugin): + content = "λ🙂&\n" * 800 + result = plugin.transform_tool_result( + tool_name="terminal", + args={"command": "produce output"}, + result=content, + session_id="large", + task_id="task", + ) + assert result is not None and " Date: Mon, 20 Jul 2026 21:27:15 -0400 Subject: [PATCH 26/33] Add files via upload From e156d76bb7d27c8d7c7cac6736e587574a181a13 Mon Sep 17 00:00:00 2001 From: CommanderTurtle Date: Mon, 20 Jul 2026 21:27:59 -0400 Subject: [PATCH 27/33] Add files via upload From 0e46848389476f71a3fc510053db7bdca794acd4 Mon Sep 17 00:00:00 2001 From: CommanderTurtle Date: Mon, 20 Jul 2026 21:28:29 -0400 Subject: [PATCH 28/33] Add files via upload From 4a37d2c3861cac67285e6e02390a67601d9fb72f Mon Sep 17 00:00:00 2001 From: CommanderTurtle Date: Mon, 20 Jul 2026 21:29:14 -0400 Subject: [PATCH 29/33] Add files via upload From 05f6de17c60b79fb2aa3adcbf6fca528c766cb28 Mon Sep 17 00:00:00 2001 From: CommanderTurtle Date: Mon, 20 Jul 2026 21:31:50 -0400 Subject: [PATCH 30/33] Add files via upload --- README.md | 41 ++++++++++++++++++++++++++++++++++++++++- package.json | 1 + 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index b7961695..7abbe771 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,7 @@ Context Mode is an MCP server that solves all four sides of this problem: 1. **Context Saving** — Sandbox tools keep raw data out of the context window. 315 KB becomes 5.4 KB. 98% reduction. 2. **Session Continuity** — Every file edit, git operation, task, error, and user decision is tracked in SQLite. When the conversation compacts, context-mode doesn't dump this data back into context — it indexes events into FTS5 and retrieves only what's relevant via BM25 search. The model picks up exactly where you left off. If you don't `--continue`, previous session data is deleted immediately — a fresh session means a clean slate. -3. **Think in Code** — The LLM should program the analysis, not compute it. Instead of reading 50 files into context to count functions, the agent writes a script that does the counting and `console.log()`s only the result. One script replaces ten tool calls and saves 100x context. This is a mandatory paradigm across all 17 supported clients, plus the OpenClaw gateway integration: stop treating the LLM as a data processor, treat it as a code generator. +3. **Think in Code** — The LLM should program the analysis, not compute it. Instead of reading 50 files into context to count functions, the agent writes a script that does the counting and `console.log()`s only the result. One script replaces ten tool calls and saves 100x context. This is a mandatory paradigm across all 18 supported clients, plus the OpenClaw gateway integration: stop treating the LLM as a data processor, treat it as a code generator. ```js // Before: 47 × Read() = 700 KB. After: 1 × ctx_execute() = 3.6 KB. @@ -953,6 +953,44 @@ Full configs: [`configs/kiro/mcp.json`](configs/kiro/mcp.json) | [`configs/kiro/ +
+Hermes Agent — Python plugin + MCP + +**Prerequisites:** Node.js >= 22.5 (or Bun), [Hermes Agent](https://github.com/NousResearch/hermes-agent) installed. + +Hermes uses Context Mode as an MCP server and loads a standard-library-only Python plugin for proactive routing. The integration follows Hermes' public [plugin](https://hermes-agent.nousresearch.com/docs/user-guide/features/plugins), [hook](https://hermes-agent.nousresearch.com/docs/user-guide/features/hooks), and [MCP](https://hermes-agent.nousresearch.com/docs/user-guide/features/mcp) contracts. + +1. Register Context Mode under the stable server name `context-mode`: + + ```bash + hermes mcp add context-mode --command npx --args -y context-mode + ``` + +2. Copy the plugin: + + ```bash + mkdir -p ~/.hermes/plugins/hermes-context-mode + cp .hermes-plugin/plugin.yaml .hermes-plugin/__init__.py ~/.hermes/plugins/hermes-context-mode/ + ``` + +3. Enable it in `~/.hermes/config.yaml`: + + ```yaml + plugins: + enabled: + - hermes-context-mode + ``` + +4. Restart Hermes. For gateway deployments, restart the gateway process. + +**Verify:** Run `hermes mcp test context-mode`, then ask Hermes for `ctx stats`. + +**Routing:** Automatic. `pre_tool_call` blocks known high-output terminal work, `pre_llm_call` injects current `mcp__context_mode__ctx_*` names once per session, and `transform_tool_result` bounds eligible native output over 3 KiB. Current Hermes treats `on_session_end` as a per-turn boundary, so metrics are retained until `on_session_finalize` performs actual teardown. + +Full documentation: [`.hermes-plugin/README.md`](.hermes-plugin/README.md) | [`docs/adapters/hermes-agent.md`](docs/adapters/hermes-agent.md) | fallback instructions: [`configs/hermes/AGENTS.md`](configs/hermes/AGENTS.md) + +
+
Zed — MCP-only, no hooks @@ -1422,6 +1460,7 @@ Hooks intercept tool calls programmatically — they can block dangerous command | Zed | -- | [`AGENTS.md`](configs/zed/AGENTS.md) | -- | ~60% saved | | Pi | ✓ | [`AGENTS.md`](configs/pi/AGENTS.md) | **~98% saved** | ~60% saved | | OMP | Plugin | [`SYSTEM.md`](configs/omp/SYSTEM.md) | **~98% saved** | ~60% saved | +| Hermes Agent | Python plugin | [`AGENTS.md`](configs/hermes/AGENTS.md) | Hook-enforced routing | ~60% saved | Without hooks, one unrouted `curl` or Playwright snapshot can dump 56 KB into context — wiping out an entire session's worth of savings. diff --git a/package.json b/package.json index de4a4cfd..8d4b9ae9 100644 --- a/package.json +++ b/package.json @@ -69,6 +69,7 @@ ".claude-plugin", ".codex-plugin", ".openclaw-plugin", + ".hermes-plugin", "openclaw.plugin.json", "start.mjs", "scripts/postinstall.mjs", From 44c9228dd61f561395f88ce201a390d9c6ef87fa Mon Sep 17 00:00:00 2001 From: CommanderTurtle Date: Mon, 20 Jul 2026 21:32:19 -0400 Subject: [PATCH 31/33] Add files via upload From d09649786accfbb1060d58973b583bda35d704c6 Mon Sep 17 00:00:00 2001 From: CommanderTurtle Date: Tue, 21 Jul 2026 14:59:32 -0400 Subject: [PATCH 32/33] Delete .pytest_cache directory --- .pytest_cache/CACHEDIR.TAG | 4 ---- .pytest_cache/README.md | 8 -------- .pytest_cache/gitignore | 2 -- .pytest_cache/v/cache/nodeids | 25 ------------------------- 4 files changed, 39 deletions(-) delete mode 100644 .pytest_cache/CACHEDIR.TAG delete mode 100644 .pytest_cache/README.md delete mode 100644 .pytest_cache/gitignore delete mode 100644 .pytest_cache/v/cache/nodeids diff --git a/.pytest_cache/CACHEDIR.TAG b/.pytest_cache/CACHEDIR.TAG deleted file mode 100644 index fce15ad7..00000000 --- a/.pytest_cache/CACHEDIR.TAG +++ /dev/null @@ -1,4 +0,0 @@ -Signature: 8a477f597d28d172789f06886806bc55 -# This file is a cache directory tag created by pytest. -# For information about cache directory tags, see: -# https://bford.info/cachedir/spec.html diff --git a/.pytest_cache/README.md b/.pytest_cache/README.md deleted file mode 100644 index b89018ce..00000000 --- a/.pytest_cache/README.md +++ /dev/null @@ -1,8 +0,0 @@ -# pytest cache directory # - -This directory contains data from the pytest's cache plugin, -which provides the `--lf` and `--ff` options, as well as the `cache` fixture. - -**Do not** commit this to version control. - -See [the docs](https://docs.pytest.org/en/stable/how-to/cache.html) for more information. diff --git a/.pytest_cache/gitignore b/.pytest_cache/gitignore deleted file mode 100644 index bc1a1f61..00000000 --- a/.pytest_cache/gitignore +++ /dev/null @@ -1,2 +0,0 @@ -# Created by pytest automatically. -* diff --git a/.pytest_cache/v/cache/nodeids b/.pytest_cache/v/cache/nodeids deleted file mode 100644 index 6c5b609d..00000000 --- a/.pytest_cache/v/cache/nodeids +++ /dev/null @@ -1,25 +0,0 @@ -[ - "tests/adapters/hermes_test.py::test_allowlisted_or_bounded_commands_pass[git status]", - "tests/adapters/hermes_test.py::test_allowlisted_or_bounded_commands_pass[mkdir output]", - "tests/adapters/hermes_test.py::test_allowlisted_or_bounded_commands_pass[npm install]", - "tests/adapters/hermes_test.py::test_allowlisted_or_bounded_commands_pass[pwd]", - "tests/adapters/hermes_test.py::test_disallowed_terminal_commands_block[cargo check]", - "tests/adapters/hermes_test.py::test_disallowed_terminal_commands_block[curl https://example.com/data]", - "tests/adapters/hermes_test.py::test_disallowed_terminal_commands_block[git status && curl https://example.com/data]", - "tests/adapters/hermes_test.py::test_disallowed_terminal_commands_block[npm test]", - "tests/adapters/hermes_test.py::test_disallowed_terminal_commands_block[wget https://example.com/data]", - "tests/adapters/hermes_test.py::test_finalize_persists_then_releases_session_state", - "tests/adapters/hermes_test.py::test_first_turn_guidance_uses_current_hermes_mcp_names", - "tests/adapters/hermes_test.py::test_guidance_cap_is_thread_safe", - "tests/adapters/hermes_test.py::test_guidance_is_not_reinjected_later", - "tests/adapters/hermes_test.py::test_guidance_state_is_bounded_at_exact_cap", - "tests/adapters/hermes_test.py::test_inline_http_blocks[Invoke-WebRequest https://example.com]", - "tests/adapters/hermes_test.py::test_inline_http_blocks[node -e \"fetch('https://example.com')\"]", - "tests/adapters/hermes_test.py::test_inline_http_blocks[python -c \"requests.get('https://example.com')\"]", - "tests/adapters/hermes_test.py::test_large_utf8_output_is_sandboxed_losslessly", - "tests/adapters/hermes_test.py::test_non_terminal_tool_passes", - "tests/adapters/hermes_test.py::test_parallel_sandbox_writes_have_unique_names", - "tests/adapters/hermes_test.py::test_register_declares_current_hermes_hooks", - "tests/adapters/hermes_test.py::test_small_output_is_unchanged", - "tests/adapters/hermes_test.py::test_turn_end_persists_without_destroying_session_state" -] \ No newline at end of file From c547cddbc192b95b89982e94fbc3eb38b3b2f56e Mon Sep 17 00:00:00 2001 From: CommanderTurtle Date: Sun, 26 Jul 2026 01:25:32 -0400 Subject: [PATCH 33/33] Delete .hermes-plugin/__pycache__ directory --- .../__pycache__/__init__.cpython-312.pyc | Bin 17115 -> 0 bytes 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 .hermes-plugin/__pycache__/__init__.cpython-312.pyc diff --git a/.hermes-plugin/__pycache__/__init__.cpython-312.pyc b/.hermes-plugin/__pycache__/__init__.cpython-312.pyc deleted file mode 100644 index f14faa6a0112b6f437ee39c0efad288b2c4e5eb7..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 17115 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