mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-25 00:51:20 +00:00
docs(plugins): rename disk-guardian to disk-cleanup + bundled-plugins docs
The original name was cute but non-obvious; disk-cleanup says what it does. Plugin directory, script, state path, log lines, slash command, and test module all renamed. No user-visible state exists yet, so no migration path is needed. New website page "Built-in Plugins" documents the <repo>/plugins/<name>/ source, how discovery interacts with user/project plugins, the HERMES_DISABLE_BUNDLED_PLUGINS escape hatch, disk-cleanup's hook behaviour and deletion rules, and guidance on when a plugin belongs bundled vs. user-installable. Added to the Features → Core sidebar next to the main Plugins page, with a cross-reference from plugins.md.
This commit is contained in:
parent
1386e277e5
commit
a25c8c6a56
8 changed files with 184 additions and 92 deletions
|
|
@ -1,4 +1,4 @@
|
||||||
# disk-guardian
|
# disk-cleanup
|
||||||
|
|
||||||
Auto-tracks and cleans up ephemeral files created during Hermes Agent
|
Auto-tracks and cleans up ephemeral files created during Hermes Agent
|
||||||
sessions — test scripts, temp outputs, cron logs, stale chrome profiles.
|
sessions — test scripts, temp outputs, cron logs, stale chrome profiles.
|
||||||
|
|
@ -31,19 +31,19 @@ Deletion rules (same as the original PR):
|
||||||
## Slash command
|
## Slash command
|
||||||
|
|
||||||
```
|
```
|
||||||
/disk-guardian status # breakdown + top-10 largest
|
/disk-cleanup status # breakdown + top-10 largest
|
||||||
/disk-guardian dry-run # preview without deleting
|
/disk-cleanup dry-run # preview without deleting
|
||||||
/disk-guardian quick # run safe cleanup now
|
/disk-cleanup quick # run safe cleanup now
|
||||||
/disk-guardian deep # quick + list items needing prompt
|
/disk-cleanup deep # quick + list items needing prompt
|
||||||
/disk-guardian track <path> <category> # manual tracking
|
/disk-cleanup track <path> <category> # manual tracking
|
||||||
/disk-guardian forget <path> # stop tracking
|
/disk-cleanup forget <path> # stop tracking
|
||||||
```
|
```
|
||||||
|
|
||||||
## Safety
|
## Safety
|
||||||
|
|
||||||
- `is_safe_path()` rejects anything outside `HERMES_HOME` or `/tmp/hermes-*`
|
- `is_safe_path()` rejects anything outside `HERMES_HOME` or `/tmp/hermes-*`
|
||||||
- Windows mounts (`/mnt/c` etc.) are rejected
|
- 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/`,
|
- `$HERMES_HOME/logs/`, `memories/`, `sessions/`, `skills/`, `plugins/`,
|
||||||
and config files are never tracked
|
and config files are never tracked
|
||||||
- Backup/restore is scoped to `tracked.json` — the plugin never touches
|
- Backup/restore is scoped to `tracked.json` — the plugin never touches
|
||||||
|
|
@ -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:
|
Wires three behaviours:
|
||||||
|
|
||||||
|
|
@ -8,10 +8,10 @@ Wires three behaviours:
|
||||||
compliance required.
|
compliance required.
|
||||||
|
|
||||||
2. ``on_session_end`` hook — when any test files were auto-tracked
|
2. ``on_session_end`` hook — when any test files were auto-tracked
|
||||||
during the just-finished turn, runs :func:`disk_guardian.quick` and
|
during the just-finished turn, runs :func:`disk_cleanup.quick` and
|
||||||
logs a single line to ``$HERMES_HOME/disk-guardian/cleanup.log``.
|
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``.
|
``quick``, ``deep``, ``track``, ``forget``.
|
||||||
|
|
||||||
Replaces PR #12212's skill-plus-script design: the agent no longer
|
Replaces PR #12212's skill-plus-script design: the agent no longer
|
||||||
|
|
@ -27,7 +27,7 @@ import threading
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Dict, Optional, Set
|
from typing import Any, Dict, Optional, Set
|
||||||
|
|
||||||
from . import disk_guardian as dg
|
from . import disk_cleanup as dg
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
@ -178,7 +178,7 @@ def _on_session_end(
|
||||||
try:
|
try:
|
||||||
summary = dg.quick()
|
summary = dg.quick()
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.debug("disk-guardian quick cleanup failed: %s", exc)
|
logger.debug("disk-cleanup quick cleanup failed: %s", exc)
|
||||||
return
|
return
|
||||||
|
|
||||||
if summary["deleted"] or summary["empty_dirs"]:
|
if summary["deleted"] or summary["empty_dirs"]:
|
||||||
|
|
@ -193,7 +193,7 @@ def _on_session_end(
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
_HELP_TEXT = """\
|
_HELP_TEXT = """\
|
||||||
/disk-guardian — ephemeral-file cleanup
|
/disk-cleanup — ephemeral-file cleanup
|
||||||
|
|
||||||
Subcommands:
|
Subcommands:
|
||||||
status Per-category breakdown + top-10 largest
|
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:
|
def _fmt_summary(summary: Dict[str, Any]) -> str:
|
||||||
base = (
|
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'])}."
|
f"{summary['empty_dirs']} empty dirs, freed {dg.fmt_size(summary['freed'])}."
|
||||||
)
|
)
|
||||||
if summary.get("errors"):
|
if summary.get("errors"):
|
||||||
|
|
@ -268,14 +268,14 @@ def _handle_slash(raw_args: str) -> Optional[str]:
|
||||||
for item in prompt_items:
|
for item in prompt_items:
|
||||||
lines.append(f" [{item['category']}] {item['path']}")
|
lines.append(f" [{item['category']}] {item['path']}")
|
||||||
lines.append(
|
lines.append(
|
||||||
"\nRun `/disk-guardian forget <path>` to skip, or delete "
|
"\nRun `/disk-cleanup forget <path>` to skip, or delete "
|
||||||
"manually via terminal."
|
"manually via terminal."
|
||||||
)
|
)
|
||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
|
|
||||||
if sub == "track":
|
if sub == "track":
|
||||||
if len(argv) < 3:
|
if len(argv) < 3:
|
||||||
return "Usage: /disk-guardian track <path> <category>"
|
return "Usage: /disk-cleanup track <path> <category>"
|
||||||
path_arg = argv[1]
|
path_arg = argv[1]
|
||||||
category = argv[2]
|
category = argv[2]
|
||||||
if category not in dg.ALLOWED_CATEGORIES:
|
if category not in dg.ALLOWED_CATEGORIES:
|
||||||
|
|
@ -292,7 +292,7 @@ def _handle_slash(raw_args: str) -> Optional[str]:
|
||||||
|
|
||||||
if sub == "forget":
|
if sub == "forget":
|
||||||
if len(argv) < 2:
|
if len(argv) < 2:
|
||||||
return "Usage: /disk-guardian forget <path>"
|
return "Usage: /disk-cleanup forget <path>"
|
||||||
n = dg.forget(argv[1])
|
n = dg.forget(argv[1])
|
||||||
return (
|
return (
|
||||||
f"Removed {n} tracking entr{'y' if n == 1 else 'ies'} for {argv[1]}."
|
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("post_tool_call", _on_post_tool_call)
|
||||||
ctx.register_hook("on_session_end", _on_session_end)
|
ctx.register_hook("on_session_end", _on_session_end)
|
||||||
ctx.register_command(
|
ctx.register_command(
|
||||||
"disk-guardian",
|
"disk-cleanup",
|
||||||
handler=_handle_slash,
|
handler=_handle_slash,
|
||||||
description="Track and clean up ephemeral Hermes session files.",
|
description="Track and clean up ephemeral Hermes session files.",
|
||||||
)
|
)
|
||||||
|
|
@ -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
|
Library module wrapping the deterministic cleanup rules written by
|
||||||
@LVT382009 in PR #12212. The plugin ``__init__.py`` wires these
|
@LVT382009 in PR #12212. The plugin ``__init__.py`` wires these
|
||||||
|
|
@ -47,7 +47,7 @@ logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
def get_state_dir() -> Path:
|
def get_state_dir() -> Path:
|
||||||
"""State dir — separate from ``$HERMES_HOME/logs/``."""
|
"""State dir — separate from ``$HERMES_HOME/logs/``."""
|
||||||
return get_hermes_home() / "disk-guardian"
|
return get_hermes_home() / "disk-cleanup"
|
||||||
|
|
||||||
|
|
||||||
def get_tracked_file() -> Path:
|
def get_tracked_file() -> Path:
|
||||||
|
|
@ -297,7 +297,7 @@ def quick() -> Dict[str, Any]:
|
||||||
hermes_home = get_hermes_home()
|
hermes_home = get_hermes_home()
|
||||||
_PROTECTED_TOP_LEVEL = {
|
_PROTECTED_TOP_LEVEL = {
|
||||||
"logs", "memories", "sessions", "cron", "cronjobs",
|
"logs", "memories", "sessions", "cron", "cronjobs",
|
||||||
"cache", "skills", "plugins", "disk-guardian", "optional-skills",
|
"cache", "skills", "plugins", "disk-cleanup", "optional-skills",
|
||||||
"hermes-agent", "backups", "profiles", ".worktrees",
|
"hermes-agent", "backups", "profiles", ".worktrees",
|
||||||
}
|
}
|
||||||
empty_removed = 0
|
empty_removed = 0
|
||||||
|
|
@ -475,7 +475,7 @@ def guess_category(path: Path) -> Optional[str]:
|
||||||
rel = path.resolve().relative_to(hermes_home)
|
rel = path.resolve().relative_to(hermes_home)
|
||||||
top = rel.parts[0] if rel.parts else ""
|
top = rel.parts[0] if rel.parts else ""
|
||||||
if top in {
|
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",
|
"skills", "plugins", ".env", "USER.md", "MEMORY.md", "SOUL.md",
|
||||||
"auth.json", "hermes-agent",
|
"auth.json", "hermes-agent",
|
||||||
}:
|
}:
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
name: disk-guardian
|
name: disk-cleanup
|
||||||
version: 2.0.0
|
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."
|
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)"
|
author: "@LVT382009 (original), NousResearch (plugin port)"
|
||||||
|
|
@ -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.
|
``is_safe_path`` and ``guess_category`` filtering.
|
||||||
* Plugin ``__init__``: ``post_tool_call`` hook auto-tracks files created
|
* Plugin ``__init__``: ``post_tool_call`` hook auto-tracks files created
|
||||||
by ``write_file`` / ``terminal``; ``on_session_end`` hook runs quick
|
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 importlib
|
||||||
import json
|
import json
|
||||||
import os
|
|
||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
@ -23,23 +22,24 @@ import pytest
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
@pytest.fixture(autouse=True)
|
||||||
def _isolate_env(tmp_path, monkeypatch):
|
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 = tmp_path / ".hermes"
|
||||||
hermes_home.mkdir()
|
hermes_home.mkdir()
|
||||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
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
|
yield hermes_home
|
||||||
|
|
||||||
|
|
||||||
def _load_lib():
|
def _load_lib():
|
||||||
"""Import the plugin's library module directly from the repo path."""
|
"""Import the plugin's library module directly from the repo path."""
|
||||||
repo_root = Path(__file__).resolve().parents[2]
|
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(
|
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)
|
mod = importlib.util.module_from_spec(spec)
|
||||||
spec.loader.exec_module(mod)
|
spec.loader.exec_module(mod)
|
||||||
|
|
@ -49,23 +49,23 @@ def _load_lib():
|
||||||
def _load_plugin_init():
|
def _load_plugin_init():
|
||||||
"""Import the plugin's __init__.py (which depends on the library)."""
|
"""Import the plugin's __init__.py (which depends on the library)."""
|
||||||
repo_root = Path(__file__).resolve().parents[2]
|
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.
|
# Use the PluginManager's module naming convention so relative imports work.
|
||||||
spec = importlib.util.spec_from_file_location(
|
spec = importlib.util.spec_from_file_location(
|
||||||
"hermes_plugins.disk_guardian",
|
"hermes_plugins.disk_cleanup",
|
||||||
plugin_dir / "__init__.py",
|
plugin_dir / "__init__.py",
|
||||||
submodule_search_locations=[str(plugin_dir)],
|
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
|
import types
|
||||||
if "hermes_plugins" not in sys.modules:
|
if "hermes_plugins" not in sys.modules:
|
||||||
ns = types.ModuleType("hermes_plugins")
|
ns = types.ModuleType("hermes_plugins")
|
||||||
ns.__path__ = []
|
ns.__path__ = []
|
||||||
sys.modules["hermes_plugins"] = ns
|
sys.modules["hermes_plugins"] = ns
|
||||||
mod = importlib.util.module_from_spec(spec)
|
mod = importlib.util.module_from_spec(spec)
|
||||||
mod.__package__ = "hermes_plugins.disk_guardian"
|
mod.__package__ = "hermes_plugins.disk_cleanup"
|
||||||
mod.__path__ = [str(plugin_dir)]
|
mod.__path__ = [str(plugin_dir)]
|
||||||
sys.modules["hermes_plugins.disk_guardian"] = mod
|
sys.modules["hermes_plugins.disk_cleanup"] = mod
|
||||||
spec.loader.exec_module(mod)
|
spec.loader.exec_module(mod)
|
||||||
return mod
|
return mod
|
||||||
|
|
||||||
|
|
@ -245,7 +245,7 @@ class TestPostToolCallHook:
|
||||||
result="OK",
|
result="OK",
|
||||||
task_id="t1", session_id="s1",
|
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())
|
data = json.loads(tracked_file.read_text())
|
||||||
assert len(data) == 1
|
assert len(data) == 1
|
||||||
assert data[0]["category"] == "test"
|
assert data[0]["category"] == "test"
|
||||||
|
|
@ -260,7 +260,7 @@ class TestPostToolCallHook:
|
||||||
result="OK",
|
result="OK",
|
||||||
task_id="t2", session_id="s2",
|
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() == "[]"
|
assert not tracked_file.exists() or tracked_file.read_text().strip() == "[]"
|
||||||
|
|
||||||
def test_terminal_command_picks_up_paths(self, _isolate_env):
|
def test_terminal_command_picks_up_paths(self, _isolate_env):
|
||||||
|
|
@ -273,7 +273,7 @@ class TestPostToolCallHook:
|
||||||
result=f"created {p}\n",
|
result=f"created {p}\n",
|
||||||
task_id="t3", session_id="s3",
|
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())
|
data = json.loads(tracked_file.read_text())
|
||||||
assert any(Path(i["path"]) == p.resolve() for i in data)
|
assert any(Path(i["path"]) == p.resolve() for i in data)
|
||||||
|
|
||||||
|
|
@ -286,7 +286,7 @@ class TestPostToolCallHook:
|
||||||
task_id="t4", session_id="s4",
|
task_id="t4", session_id="s4",
|
||||||
)
|
)
|
||||||
# read_file should never trigger tracking.
|
# 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() == "[]"
|
assert not tracked_file.exists() or tracked_file.read_text().strip() == "[]"
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -319,7 +319,7 @@ class TestSlashCommand:
|
||||||
def test_help(self, _isolate_env):
|
def test_help(self, _isolate_env):
|
||||||
pi = _load_plugin_init()
|
pi = _load_plugin_init()
|
||||||
out = pi._handle_slash("help")
|
out = pi._handle_slash("help")
|
||||||
assert "disk-guardian" in out
|
assert "disk-cleanup" in out
|
||||||
assert "status" in out
|
assert "status" in out
|
||||||
|
|
||||||
def test_status_empty(self, _isolate_env):
|
def test_status_empty(self, _isolate_env):
|
||||||
|
|
@ -366,61 +366,35 @@ class TestSlashCommand:
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
class TestBundledDiscovery:
|
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.
|
# The default hermetic conftest disables bundled plugin discovery.
|
||||||
# This test specifically exercises it, so clear the suppression.
|
# This test specifically exercises it, so clear the suppression.
|
||||||
monkeypatch.delenv("HERMES_DISABLE_BUNDLED_PLUGINS", raising=False)
|
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
|
from hermes_cli import plugins as pmod
|
||||||
mgr = pmod.PluginManager()
|
mgr = pmod.PluginManager()
|
||||||
mgr.discover_and_load()
|
mgr.discover_and_load()
|
||||||
assert "disk-guardian" in mgr._plugins
|
assert "disk-cleanup" in mgr._plugins
|
||||||
loaded = mgr._plugins["disk-guardian"]
|
loaded = mgr._plugins["disk-cleanup"]
|
||||||
assert loaded.manifest.source == "bundled"
|
assert loaded.manifest.source == "bundled"
|
||||||
assert loaded.enabled
|
assert loaded.enabled
|
||||||
assert "post_tool_call" in loaded.hooks_registered
|
assert "post_tool_call" in loaded.hooks_registered
|
||||||
assert "on_session_end" in loaded.hooks_registered
|
assert "on_session_end" in loaded.hooks_registered
|
||||||
assert "disk-guardian" in loaded.commands_registered
|
assert "disk-cleanup" in loaded.commands_registered
|
||||||
finally:
|
|
||||||
sys.path.pop(0)
|
|
||||||
|
|
||||||
def test_memory_and_context_engine_subdirs_skipped(self, _isolate_env, monkeypatch):
|
def test_memory_and_context_engine_subdirs_skipped(self, _isolate_env, monkeypatch):
|
||||||
"""Bundled scan must NOT pick up plugins/memory or plugins/context_engine
|
"""Bundled scan must NOT pick up plugins/memory or plugins/context_engine
|
||||||
as top-level plugins — they have their own discovery paths."""
|
as top-level plugins — they have their own discovery paths."""
|
||||||
monkeypatch.delenv("HERMES_DISABLE_BUNDLED_PLUGINS", raising=False)
|
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
|
from hermes_cli import plugins as pmod
|
||||||
mgr = pmod.PluginManager()
|
mgr = pmod.PluginManager()
|
||||||
mgr.discover_and_load()
|
mgr.discover_and_load()
|
||||||
assert "memory" not in mgr._plugins
|
assert "memory" not in mgr._plugins
|
||||||
assert "context_engine" not in mgr._plugins
|
assert "context_engine" not in mgr._plugins
|
||||||
finally:
|
|
||||||
sys.path.pop(0)
|
|
||||||
|
|
||||||
def test_bundled_scan_suppressed_by_env_var(self, _isolate_env, monkeypatch):
|
def test_bundled_scan_suppressed_by_env_var(self, _isolate_env, monkeypatch):
|
||||||
"""HERMES_DISABLE_BUNDLED_PLUGINS=1 suppresses bundled discovery."""
|
"""HERMES_DISABLE_BUNDLED_PLUGINS=1 suppresses bundled discovery."""
|
||||||
monkeypatch.setenv("HERMES_DISABLE_BUNDLED_PLUGINS", "1")
|
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
|
from hermes_cli import plugins as pmod
|
||||||
mgr = pmod.PluginManager()
|
mgr = pmod.PluginManager()
|
||||||
mgr.discover_and_load()
|
mgr.discover_and_load()
|
||||||
assert "disk-guardian" not in mgr._plugins
|
assert "disk-cleanup" not in mgr._plugins
|
||||||
finally:
|
|
||||||
sys.path.pop(0)
|
|
||||||
114
website/docs/user-guide/features/built-in-plugins.md
Normal file
114
website/docs/user-guide/features/built-in-plugins.md
Normal file
|
|
@ -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 `<repo>/plugins/<name>/` 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** — `<repo>/plugins/<name>/` (what this page documents)
|
||||||
|
2. **User** — `~/.hermes/plugins/<name>/`
|
||||||
|
3. **Project** — `./.hermes/plugins/<name>/` (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 <path> <category> # manual tracking
|
||||||
|
/disk-cleanup forget <path> # 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 `<repo>/plugins/<name>/` instead of `~/.hermes/plugins/<name>/`
|
||||||
|
- 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.
|
||||||
|
|
@ -95,10 +95,13 @@ Project-local plugins under `./.hermes/plugins/` are disabled by default. Enable
|
||||||
|
|
||||||
| Source | Path | Use case |
|
| Source | Path | Use case |
|
||||||
|--------|------|----------|
|
|--------|------|----------|
|
||||||
|
| Bundled | `<repo>/plugins/` | Ships with Hermes — see [Built-in Plugins](/docs/user-guide/features/built-in-plugins) |
|
||||||
| User | `~/.hermes/plugins/` | Personal plugins |
|
| User | `~/.hermes/plugins/` | Personal plugins |
|
||||||
| Project | `.hermes/plugins/` | Project-specific plugins (requires `HERMES_ENABLE_PROJECT_PLUGINS=true`) |
|
| Project | `.hermes/plugins/` | Project-specific plugins (requires `HERMES_ENABLE_PROJECT_PLUGINS=true`) |
|
||||||
| pip | `hermes_agent.plugins` entry_points | Distributed packages |
|
| 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
|
## 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.
|
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.
|
||||||
|
|
|
||||||
|
|
@ -51,6 +51,7 @@ const sidebars: SidebarsConfig = {
|
||||||
'user-guide/features/personality',
|
'user-guide/features/personality',
|
||||||
'user-guide/features/skins',
|
'user-guide/features/skins',
|
||||||
'user-guide/features/plugins',
|
'user-guide/features/plugins',
|
||||||
|
'user-guide/features/built-in-plugins',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue