diff --git a/plugins/disk-guardian/README.md b/plugins/disk-cleanup/README.md similarity index 77% rename from plugins/disk-guardian/README.md rename to plugins/disk-cleanup/README.md index 512c1cb62..bc4604732 100644 --- a/plugins/disk-guardian/README.md +++ b/plugins/disk-cleanup/README.md @@ -1,4 +1,4 @@ -# disk-guardian +# disk-cleanup Auto-tracks and cleans up ephemeral files created during Hermes Agent sessions — test scripts, temp outputs, cron logs, stale chrome profiles. @@ -31,19 +31,19 @@ Deletion rules (same as the original PR): ## Slash command ``` -/disk-guardian status # breakdown + top-10 largest -/disk-guardian dry-run # preview without deleting -/disk-guardian quick # run safe cleanup now -/disk-guardian deep # quick + list items needing prompt -/disk-guardian track # manual tracking -/disk-guardian forget # stop tracking +/disk-cleanup status # breakdown + top-10 largest +/disk-cleanup dry-run # preview without deleting +/disk-cleanup quick # run safe cleanup now +/disk-cleanup deep # quick + list items needing prompt +/disk-cleanup track # manual tracking +/disk-cleanup forget # stop tracking ``` ## Safety - `is_safe_path()` rejects anything outside `HERMES_HOME` or `/tmp/hermes-*` - Windows mounts (`/mnt/c` etc.) are rejected -- The state directory `$HERMES_HOME/disk-guardian/` is itself excluded +- The state directory `$HERMES_HOME/disk-cleanup/` is itself excluded - `$HERMES_HOME/logs/`, `memories/`, `sessions/`, `skills/`, `plugins/`, and config files are never tracked - Backup/restore is scoped to `tracked.json` — the plugin never touches diff --git a/plugins/disk-guardian/__init__.py b/plugins/disk-cleanup/__init__.py similarity index 93% rename from plugins/disk-guardian/__init__.py rename to plugins/disk-cleanup/__init__.py index 3b73df6de..0a4b6c7ae 100644 --- a/plugins/disk-guardian/__init__.py +++ b/plugins/disk-cleanup/__init__.py @@ -1,4 +1,4 @@ -"""disk-guardian plugin — auto-cleanup of ephemeral Hermes session files. +"""disk-cleanup plugin — auto-cleanup of ephemeral Hermes session files. Wires three behaviours: @@ -8,10 +8,10 @@ Wires three behaviours: compliance required. 2. ``on_session_end`` hook — when any test files were auto-tracked - during the just-finished turn, runs :func:`disk_guardian.quick` and - logs a single line to ``$HERMES_HOME/disk-guardian/cleanup.log``. + during the just-finished turn, runs :func:`disk_cleanup.quick` and + logs a single line to ``$HERMES_HOME/disk-cleanup/cleanup.log``. -3. ``/disk-guardian`` slash command — manual ``status``, ``dry-run``, +3. ``/disk-cleanup`` slash command — manual ``status``, ``dry-run``, ``quick``, ``deep``, ``track``, ``forget``. Replaces PR #12212's skill-plus-script design: the agent no longer @@ -27,7 +27,7 @@ import threading from pathlib import Path from typing import Any, Dict, Optional, Set -from . import disk_guardian as dg +from . import disk_cleanup as dg logger = logging.getLogger(__name__) @@ -178,7 +178,7 @@ def _on_session_end( try: summary = dg.quick() except Exception as exc: - logger.debug("disk-guardian quick cleanup failed: %s", exc) + logger.debug("disk-cleanup quick cleanup failed: %s", exc) return if summary["deleted"] or summary["empty_dirs"]: @@ -193,7 +193,7 @@ def _on_session_end( # --------------------------------------------------------------------------- _HELP_TEXT = """\ -/disk-guardian — ephemeral-file cleanup +/disk-cleanup — ephemeral-file cleanup Subcommands: status Per-category breakdown + top-10 largest @@ -212,7 +212,7 @@ Test files are auto-tracked on write_file / terminal and auto-cleaned at session def _fmt_summary(summary: Dict[str, Any]) -> str: base = ( - f"[disk-guardian] Cleaned {summary['deleted']} files + " + f"[disk-cleanup] Cleaned {summary['deleted']} files + " f"{summary['empty_dirs']} empty dirs, freed {dg.fmt_size(summary['freed'])}." ) if summary.get("errors"): @@ -268,14 +268,14 @@ def _handle_slash(raw_args: str) -> Optional[str]: for item in prompt_items: lines.append(f" [{item['category']}] {item['path']}") lines.append( - "\nRun `/disk-guardian forget ` to skip, or delete " + "\nRun `/disk-cleanup forget ` to skip, or delete " "manually via terminal." ) return "\n".join(lines) if sub == "track": if len(argv) < 3: - return "Usage: /disk-guardian track " + return "Usage: /disk-cleanup track " path_arg = argv[1] category = argv[2] if category not in dg.ALLOWED_CATEGORIES: @@ -292,7 +292,7 @@ def _handle_slash(raw_args: str) -> Optional[str]: if sub == "forget": if len(argv) < 2: - return "Usage: /disk-guardian forget " + return "Usage: /disk-cleanup forget " n = dg.forget(argv[1]) return ( f"Removed {n} tracking entr{'y' if n == 1 else 'ies'} for {argv[1]}." @@ -310,7 +310,7 @@ def register(ctx) -> None: ctx.register_hook("post_tool_call", _on_post_tool_call) ctx.register_hook("on_session_end", _on_session_end) ctx.register_command( - "disk-guardian", + "disk-cleanup", handler=_handle_slash, description="Track and clean up ephemeral Hermes session files.", ) diff --git a/plugins/disk-guardian/disk_guardian.py b/plugins/disk-cleanup/disk_cleanup.py similarity index 98% rename from plugins/disk-guardian/disk_guardian.py rename to plugins/disk-cleanup/disk_cleanup.py index b6f120c9d..cef269831 100755 --- a/plugins/disk-guardian/disk_guardian.py +++ b/plugins/disk-cleanup/disk_cleanup.py @@ -1,4 +1,4 @@ -"""disk_guardian — ephemeral file cleanup for Hermes Agent. +"""disk_cleanup — ephemeral file cleanup for Hermes Agent. Library module wrapping the deterministic cleanup rules written by @LVT382009 in PR #12212. The plugin ``__init__.py`` wires these @@ -47,7 +47,7 @@ logger = logging.getLogger(__name__) def get_state_dir() -> Path: """State dir — separate from ``$HERMES_HOME/logs/``.""" - return get_hermes_home() / "disk-guardian" + return get_hermes_home() / "disk-cleanup" def get_tracked_file() -> Path: @@ -297,7 +297,7 @@ def quick() -> Dict[str, Any]: hermes_home = get_hermes_home() _PROTECTED_TOP_LEVEL = { "logs", "memories", "sessions", "cron", "cronjobs", - "cache", "skills", "plugins", "disk-guardian", "optional-skills", + "cache", "skills", "plugins", "disk-cleanup", "optional-skills", "hermes-agent", "backups", "profiles", ".worktrees", } empty_removed = 0 @@ -475,7 +475,7 @@ def guess_category(path: Path) -> Optional[str]: rel = path.resolve().relative_to(hermes_home) top = rel.parts[0] if rel.parts else "" if top in { - "disk-guardian", "logs", "memories", "sessions", "config.yaml", + "disk-cleanup", "logs", "memories", "sessions", "config.yaml", "skills", "plugins", ".env", "USER.md", "MEMORY.md", "SOUL.md", "auth.json", "hermes-agent", }: diff --git a/plugins/disk-guardian/plugin.yaml b/plugins/disk-cleanup/plugin.yaml similarity index 93% rename from plugins/disk-guardian/plugin.yaml rename to plugins/disk-cleanup/plugin.yaml index f26f0bae6..fe005c884 100644 --- a/plugins/disk-guardian/plugin.yaml +++ b/plugins/disk-cleanup/plugin.yaml @@ -1,4 +1,4 @@ -name: disk-guardian +name: disk-cleanup version: 2.0.0 description: "Auto-track and clean up ephemeral files (test scripts, temp outputs, cron logs) created during Hermes sessions. Runs via plugin hooks — no agent action required." author: "@LVT382009 (original), NousResearch (plugin port)" diff --git a/tests/plugins/test_disk_guardian_plugin.py b/tests/plugins/test_disk_cleanup_plugin.py similarity index 80% rename from tests/plugins/test_disk_guardian_plugin.py rename to tests/plugins/test_disk_cleanup_plugin.py index 1ea0aba7a..5b6473666 100644 --- a/tests/plugins/test_disk_guardian_plugin.py +++ b/tests/plugins/test_disk_cleanup_plugin.py @@ -1,8 +1,8 @@ -"""Tests for the disk-guardian plugin. +"""Tests for the disk-cleanup plugin. -Covers the bundled plugin at ``plugins/disk-guardian/``: +Covers the bundled plugin at ``plugins/disk-cleanup/``: - * ``disk_guardian`` library: track / forget / dry_run / quick / status, + * ``disk_cleanup`` library: track / forget / dry_run / quick / status, ``is_safe_path`` and ``guess_category`` filtering. * Plugin ``__init__``: ``post_tool_call`` hook auto-tracks files created by ``write_file`` / ``terminal``; ``on_session_end`` hook runs quick @@ -14,7 +14,6 @@ Covers the bundled plugin at ``plugins/disk-guardian/``: import importlib import json -import os import sys from pathlib import Path @@ -23,23 +22,24 @@ import pytest @pytest.fixture(autouse=True) def _isolate_env(tmp_path, monkeypatch): - """Isolate HERMES_HOME + clear plugin module cache for each test.""" + """Isolate HERMES_HOME for each test. + + The global hermetic fixture already redirects HERMES_HOME to a tempdir, + but we want the plugin to work with a predictable subpath. We reset + HERMES_HOME here for clarity. + """ hermes_home = tmp_path / ".hermes" hermes_home.mkdir() monkeypatch.setenv("HERMES_HOME", str(hermes_home)) - # Drop the disk-guardian modules so each test re-imports fresh. - for mod in list(sys.modules.keys()): - if mod.startswith("hermes_plugins.disk_guardian") or mod == "plugins.disk_guardian": - del sys.modules[mod] yield hermes_home def _load_lib(): """Import the plugin's library module directly from the repo path.""" repo_root = Path(__file__).resolve().parents[2] - lib_path = repo_root / "plugins" / "disk-guardian" / "disk_guardian.py" + lib_path = repo_root / "plugins" / "disk-cleanup" / "disk_cleanup.py" spec = importlib.util.spec_from_file_location( - "disk_guardian_under_test", lib_path + "disk_cleanup_under_test", lib_path ) mod = importlib.util.module_from_spec(spec) spec.loader.exec_module(mod) @@ -49,23 +49,23 @@ def _load_lib(): def _load_plugin_init(): """Import the plugin's __init__.py (which depends on the library).""" repo_root = Path(__file__).resolve().parents[2] - plugin_dir = repo_root / "plugins" / "disk-guardian" + plugin_dir = repo_root / "plugins" / "disk-cleanup" # Use the PluginManager's module naming convention so relative imports work. spec = importlib.util.spec_from_file_location( - "hermes_plugins.disk_guardian", + "hermes_plugins.disk_cleanup", plugin_dir / "__init__.py", submodule_search_locations=[str(plugin_dir)], ) - # Ensure parent namespace package exists for the relative `. import disk_guardian` + # Ensure parent namespace package exists for the relative `. import disk_cleanup` import types if "hermes_plugins" not in sys.modules: ns = types.ModuleType("hermes_plugins") ns.__path__ = [] sys.modules["hermes_plugins"] = ns mod = importlib.util.module_from_spec(spec) - mod.__package__ = "hermes_plugins.disk_guardian" + mod.__package__ = "hermes_plugins.disk_cleanup" mod.__path__ = [str(plugin_dir)] - sys.modules["hermes_plugins.disk_guardian"] = mod + sys.modules["hermes_plugins.disk_cleanup"] = mod spec.loader.exec_module(mod) return mod @@ -245,7 +245,7 @@ class TestPostToolCallHook: result="OK", task_id="t1", session_id="s1", ) - tracked_file = _isolate_env / "disk-guardian" / "tracked.json" + tracked_file = _isolate_env / "disk-cleanup" / "tracked.json" data = json.loads(tracked_file.read_text()) assert len(data) == 1 assert data[0]["category"] == "test" @@ -260,7 +260,7 @@ class TestPostToolCallHook: result="OK", task_id="t2", session_id="s2", ) - tracked_file = _isolate_env / "disk-guardian" / "tracked.json" + tracked_file = _isolate_env / "disk-cleanup" / "tracked.json" assert not tracked_file.exists() or tracked_file.read_text().strip() == "[]" def test_terminal_command_picks_up_paths(self, _isolate_env): @@ -273,7 +273,7 @@ class TestPostToolCallHook: result=f"created {p}\n", task_id="t3", session_id="s3", ) - tracked_file = _isolate_env / "disk-guardian" / "tracked.json" + tracked_file = _isolate_env / "disk-cleanup" / "tracked.json" data = json.loads(tracked_file.read_text()) assert any(Path(i["path"]) == p.resolve() for i in data) @@ -286,7 +286,7 @@ class TestPostToolCallHook: task_id="t4", session_id="s4", ) # read_file should never trigger tracking. - tracked_file = _isolate_env / "disk-guardian" / "tracked.json" + tracked_file = _isolate_env / "disk-cleanup" / "tracked.json" assert not tracked_file.exists() or tracked_file.read_text().strip() == "[]" @@ -319,7 +319,7 @@ class TestSlashCommand: def test_help(self, _isolate_env): pi = _load_plugin_init() out = pi._handle_slash("help") - assert "disk-guardian" in out + assert "disk-cleanup" in out assert "status" in out def test_status_empty(self, _isolate_env): @@ -366,61 +366,35 @@ class TestSlashCommand: # --------------------------------------------------------------------------- class TestBundledDiscovery: - def test_disk_guardian_is_discovered_as_bundled(self, _isolate_env, monkeypatch): + def test_disk_cleanup_is_discovered_as_bundled(self, _isolate_env, monkeypatch): # The default hermetic conftest disables bundled plugin discovery. # This test specifically exercises it, so clear the suppression. monkeypatch.delenv("HERMES_DISABLE_BUNDLED_PLUGINS", raising=False) - # Reset plugin manager state so discovery runs fresh. - for mod in list(sys.modules.keys()): - if mod.startswith("hermes_cli.plugins") or mod == "plugins": - del sys.modules[mod] - - repo_root = Path(__file__).resolve().parents[2] - sys.path.insert(0, str(repo_root)) - try: - from hermes_cli import plugins as pmod - mgr = pmod.PluginManager() - mgr.discover_and_load() - assert "disk-guardian" in mgr._plugins - loaded = mgr._plugins["disk-guardian"] - assert loaded.manifest.source == "bundled" - assert loaded.enabled - assert "post_tool_call" in loaded.hooks_registered - assert "on_session_end" in loaded.hooks_registered - assert "disk-guardian" in loaded.commands_registered - finally: - sys.path.pop(0) + from hermes_cli import plugins as pmod + mgr = pmod.PluginManager() + mgr.discover_and_load() + assert "disk-cleanup" in mgr._plugins + loaded = mgr._plugins["disk-cleanup"] + assert loaded.manifest.source == "bundled" + assert loaded.enabled + assert "post_tool_call" in loaded.hooks_registered + assert "on_session_end" in loaded.hooks_registered + assert "disk-cleanup" in loaded.commands_registered def test_memory_and_context_engine_subdirs_skipped(self, _isolate_env, monkeypatch): """Bundled scan must NOT pick up plugins/memory or plugins/context_engine as top-level plugins — they have their own discovery paths.""" monkeypatch.delenv("HERMES_DISABLE_BUNDLED_PLUGINS", raising=False) - for mod in list(sys.modules.keys()): - if mod.startswith("hermes_cli.plugins") or mod == "plugins": - del sys.modules[mod] - repo_root = Path(__file__).resolve().parents[2] - sys.path.insert(0, str(repo_root)) - try: - from hermes_cli import plugins as pmod - mgr = pmod.PluginManager() - mgr.discover_and_load() - assert "memory" not in mgr._plugins - assert "context_engine" not in mgr._plugins - finally: - sys.path.pop(0) + from hermes_cli import plugins as pmod + mgr = pmod.PluginManager() + mgr.discover_and_load() + assert "memory" not in mgr._plugins + assert "context_engine" not in mgr._plugins def test_bundled_scan_suppressed_by_env_var(self, _isolate_env, monkeypatch): """HERMES_DISABLE_BUNDLED_PLUGINS=1 suppresses bundled discovery.""" monkeypatch.setenv("HERMES_DISABLE_BUNDLED_PLUGINS", "1") - for mod in list(sys.modules.keys()): - if mod.startswith("hermes_cli.plugins") or mod == "plugins": - del sys.modules[mod] - repo_root = Path(__file__).resolve().parents[2] - sys.path.insert(0, str(repo_root)) - try: - from hermes_cli import plugins as pmod - mgr = pmod.PluginManager() - mgr.discover_and_load() - assert "disk-guardian" not in mgr._plugins - finally: - sys.path.pop(0) + from hermes_cli import plugins as pmod + mgr = pmod.PluginManager() + mgr.discover_and_load() + assert "disk-cleanup" not in mgr._plugins diff --git a/website/docs/user-guide/features/built-in-plugins.md b/website/docs/user-guide/features/built-in-plugins.md new file mode 100644 index 000000000..fc9ac2d4d --- /dev/null +++ b/website/docs/user-guide/features/built-in-plugins.md @@ -0,0 +1,114 @@ +--- +sidebar_position: 12 +sidebar_label: "Built-in Plugins" +title: "Built-in Plugins" +description: "Plugins shipped with Hermes Agent that run automatically via lifecycle hooks — disk-cleanup and friends" +--- + +# Built-in Plugins + +Hermes ships a small set of plugins bundled with the repository. They live under `/plugins//` and load automatically alongside user-installed plugins in `~/.hermes/plugins/`. They use the same plugin surface as third-party plugins — hooks, tools, slash commands — just maintained in-tree. + +See the [Plugins](/docs/user-guide/features/plugins) page for the general plugin system, and [Build a Hermes Plugin](/docs/guides/build-a-hermes-plugin) to write your own. + +## How discovery works + +The `PluginManager` scans four sources, in order: + +1. **Bundled** — `/plugins//` (what this page documents) +2. **User** — `~/.hermes/plugins//` +3. **Project** — `./.hermes/plugins//` (requires `HERMES_ENABLE_PROJECT_PLUGINS=1`) +4. **Pip entry points** — `hermes_agent.plugins` + +On name collision, later sources win — a user plugin named `disk-cleanup` would replace the bundled one. + +`plugins/memory/` and `plugins/context_engine/` are deliberately excluded from bundled scanning. Those directories use their own discovery paths because memory providers and context engines are single-select providers configured through `hermes memory setup` / `context.engine` in config. + +Bundled plugins respect the same disable mechanism as any other plugin: + +```yaml +# ~/.hermes/config.yaml +plugins: + disabled: + - disk-cleanup +``` + +Or suppress every bundled plugin at once with an env var: + +```bash +HERMES_DISABLE_BUNDLED_PLUGINS=1 hermes chat +``` + +The test suite sets `HERMES_DISABLE_BUNDLED_PLUGINS=1` in its hermetic fixture — tests that exercise bundled discovery clear it explicitly. + +## Currently shipped + +### disk-cleanup + +Auto-tracks and removes ephemeral files created during sessions — test scripts, temp outputs, cron logs, stale chrome profiles — without requiring the agent to remember to call a tool. + +**How it works:** + +| Hook | Behaviour | +|---|---| +| `post_tool_call` | When `write_file` / `terminal` / `patch` creates a file matching `test_*`, `tmp_*`, or `*.test.*` inside `HERMES_HOME` or `/tmp/hermes-*`, track it silently as `test` / `temp` / `cron-output`. | +| `on_session_end` | If any test files were auto-tracked during the turn, run the safe `quick` cleanup and log a one-line summary. Stays silent otherwise. | + +**Deletion rules:** + +| Category | Threshold | Confirmation | +|---|---|---| +| `test` | every session end | Never | +| `temp` | >7 days since tracked | Never | +| `cron-output` | >14 days since tracked | Never | +| empty dirs under HERMES_HOME | always | Never | +| `research` | >30 days, beyond 10 newest | Always (deep only) | +| `chrome-profile` | >14 days since tracked | Always (deep only) | +| files >500 MB | never auto | Always (deep only) | + +**Slash command** — `/disk-cleanup` available in both CLI and gateway sessions: + +``` +/disk-cleanup status # breakdown + top-10 largest +/disk-cleanup dry-run # preview without deleting +/disk-cleanup quick # run safe cleanup now +/disk-cleanup deep # quick + list items needing confirmation +/disk-cleanup track # manual tracking +/disk-cleanup forget # stop tracking (does not delete) +``` + +**State** — everything lives at `$HERMES_HOME/disk-cleanup/`: + +| File | Contents | +|---|---| +| `tracked.json` | Tracked paths with category, size, and timestamp | +| `tracked.json.bak` | Atomic-write backup of the above | +| `cleanup.log` | Append-only audit trail of every track / skip / reject / delete | + +**Safety** — cleanup only ever touches paths under `HERMES_HOME` or `/tmp/hermes-*`. Windows mounts (`/mnt/c/...`) are rejected. Well-known top-level state dirs (`logs/`, `memories/`, `sessions/`, `cron/`, `cache/`, `skills/`, `plugins/`, `disk-cleanup/` itself) are never removed even when empty — a fresh install does not get gutted on first session end. + +To turn it off without uninstalling: + +```yaml +# ~/.hermes/config.yaml +plugins: + disabled: + - disk-cleanup +``` + +## Adding a bundled plugin + +Bundled plugins are written exactly like any other Hermes plugin — see [Build a Hermes Plugin](/docs/guides/build-a-hermes-plugin). The only differences are: + +- Directory lives at `/plugins//` instead of `~/.hermes/plugins//` +- Manifest source is reported as `bundled` in `hermes plugins list` +- User plugins with the same name override the bundled version + +A plugin is a good candidate for bundling when: + +- It has no optional dependencies (or they're already `pip install .[all]` deps) +- The behaviour benefits most users and is opt-out rather than opt-in +- The logic ties into lifecycle hooks that the agent would otherwise have to remember to invoke +- It complements a core capability without expanding the model-visible tool surface + +Counter-examples — things that should stay as user-installable plugins, not bundled: third-party integrations with API keys, niche workflows, large dependency trees, anything that would meaningfully change agent behaviour by default. diff --git a/website/docs/user-guide/features/plugins.md b/website/docs/user-guide/features/plugins.md index bcc927bb4..0f8bbe627 100644 --- a/website/docs/user-guide/features/plugins.md +++ b/website/docs/user-guide/features/plugins.md @@ -95,10 +95,13 @@ Project-local plugins under `./.hermes/plugins/` are disabled by default. Enable | Source | Path | Use case | |--------|------|----------| +| Bundled | `/plugins/` | Ships with Hermes — see [Built-in Plugins](/docs/user-guide/features/built-in-plugins) | | User | `~/.hermes/plugins/` | Personal plugins | | Project | `.hermes/plugins/` | Project-specific plugins (requires `HERMES_ENABLE_PROJECT_PLUGINS=true`) | | pip | `hermes_agent.plugins` entry_points | Distributed packages | +Later sources override earlier ones on name collision, so a user plugin with the same name as a bundled plugin replaces it. `HERMES_DISABLE_BUNDLED_PLUGINS=1` suppresses the bundled scan entirely. + ## Available hooks Plugins can register callbacks for these lifecycle events. See the **[Event Hooks page](/docs/user-guide/features/hooks#plugin-hooks)** for full details, callback signatures, and examples. diff --git a/website/sidebars.ts b/website/sidebars.ts index d57a71dcc..6905b61d1 100644 --- a/website/sidebars.ts +++ b/website/sidebars.ts @@ -51,6 +51,7 @@ const sidebars: SidebarsConfig = { 'user-guide/features/personality', 'user-guide/features/skins', 'user-guide/features/plugins', + 'user-guide/features/built-in-plugins', ], }, {