| """disk-cleanup plugin — auto-cleanup of ephemeral Hermes session files. |
| |
| Wires three behaviours: |
| |
| 1. ``post_tool_call`` hook — inspects ``write_file`` and ``terminal`` |
| tool results for newly-created paths matching test/temp patterns |
| under ``HERMES_HOME`` and tracks them silently. Zero agent |
| compliance required. |
| |
| 2. ``on_session_end`` hook — when any test files were auto-tracked |
| during the just-finished turn, runs :func:`disk_cleanup.quick` and |
| logs a single line to ``$HERMES_HOME/disk-cleanup/cleanup.log``. |
| |
| 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 |
| needs to remember to run commands. |
| """ |
|
|
| from __future__ import annotations |
|
|
| import logging |
| import re |
| import shlex |
| import threading |
| from pathlib import Path |
| from typing import Any, Dict, Optional, Set |
|
|
| from . import disk_cleanup as dg |
|
|
| logger = logging.getLogger(__name__) |
|
|
|
|
| |
| |
| |
| |
| _recent_test_tracks: Dict[str, Set[str]] = {} |
| _lock = threading.Lock() |
|
|
|
|
| |
| _WRITE_FILE_PATH_KEY = "path" |
| _TERMINAL_PATH_REGEX = re.compile(r"(?:^|\s)(/[^\s'\"`]+|\~/[^\s'\"`]+)") |
|
|
|
|
| |
| |
| |
|
|
| def _tracker_key(task_id: str, session_id: str) -> str: |
| return task_id or session_id or "default" |
|
|
|
|
| def _record_track(task_id: str, session_id: str, path: Path, category: str) -> None: |
| """Record that we tracked *path* as *category* during this turn.""" |
| if category != "test": |
| return |
| key = _tracker_key(task_id, session_id) |
| with _lock: |
| _recent_test_tracks.setdefault(key, set()).add(str(path)) |
|
|
|
|
| def _drain(task_id: str, session_id: str) -> Set[str]: |
| """Pop the set of test paths tracked during this turn.""" |
| key = _tracker_key(task_id, session_id) |
| with _lock: |
| return _recent_test_tracks.pop(key, set()) |
|
|
|
|
| def _attempt_track(path_str: str, task_id: str, session_id: str) -> None: |
| """Best-effort auto-track. Never raises.""" |
| try: |
| p = Path(path_str).expanduser() |
| except Exception: |
| return |
| if not p.exists(): |
| return |
| category = dg.guess_category(p) |
| if category is None: |
| return |
| newly = dg.track(str(p), category, silent=True) |
| if newly: |
| _record_track(task_id, session_id, p, category) |
|
|
|
|
| def _extract_paths_from_write_file(args: Dict[str, Any]) -> Set[str]: |
| path = args.get(_WRITE_FILE_PATH_KEY) |
| return {path} if isinstance(path, str) and path else set() |
|
|
|
|
| def _extract_paths_from_patch(args: Dict[str, Any]) -> Set[str]: |
| |
| |
| |
| |
| |
| path = args.get("path") |
| return {path} if isinstance(path, str) and path else set() |
|
|
|
|
| def _extract_paths_from_terminal(args: Dict[str, Any], result: str) -> Set[str]: |
| """Best-effort: pull candidate filesystem paths from a terminal command |
| and its output, then let ``guess_category`` / ``is_safe_path`` filter. |
| """ |
| paths: Set[str] = set() |
| cmd = args.get("command") or "" |
| if isinstance(cmd, str) and cmd: |
| |
| try: |
| for tok in shlex.split(cmd, posix=True): |
| if tok.startswith(("/", "~")): |
| paths.add(tok) |
| except ValueError: |
| pass |
| |
| if isinstance(result, str) and len(result) < 4096: |
| for match in _TERMINAL_PATH_REGEX.findall(result): |
| paths.add(match) |
| return paths |
|
|
|
|
| |
| |
| |
|
|
| def _on_post_tool_call( |
| tool_name: str = "", |
| args: Optional[Dict[str, Any]] = None, |
| result: Any = None, |
| task_id: str = "", |
| session_id: str = "", |
| tool_call_id: str = "", |
| **_: Any, |
| ) -> None: |
| """Auto-track ephemeral files created by recent tool calls.""" |
| if not isinstance(args, dict): |
| return |
|
|
| candidates: Set[str] = set() |
| if tool_name == "write_file": |
| candidates = _extract_paths_from_write_file(args) |
| elif tool_name == "patch": |
| candidates = _extract_paths_from_patch(args) |
| elif tool_name == "terminal": |
| candidates = _extract_paths_from_terminal(args, result if isinstance(result, str) else "") |
| else: |
| return |
|
|
| for path_str in candidates: |
| _attempt_track(path_str, task_id, session_id) |
|
|
|
|
| def _on_session_end( |
| session_id: str = "", |
| completed: bool = True, |
| interrupted: bool = False, |
| **_: Any, |
| ) -> None: |
| """Run quick cleanup if any test files were tracked during this turn.""" |
| |
| |
| drained_session = _drain("", session_id) |
| |
| |
| |
| |
| with _lock: |
| task_buckets = list(_recent_test_tracks.keys()) |
| for key in task_buckets: |
| if key and key != session_id: |
| _recent_test_tracks.pop(key, None) |
|
|
| if not drained_session and not task_buckets: |
| return |
|
|
| try: |
| summary = dg.quick() |
| except Exception as exc: |
| logger.debug("disk-cleanup quick cleanup failed: %s", exc) |
| return |
|
|
| if summary["deleted"] or summary["empty_dirs"]: |
| dg._log( |
| f"AUTO_QUICK (session_end): deleted={summary['deleted']} " |
| f"dirs={summary['empty_dirs']} freed={dg.fmt_size(summary['freed'])}" |
| ) |
|
|
|
|
| |
| |
| |
|
|
| _HELP_TEXT = """\ |
| /disk-cleanup — ephemeral-file cleanup |
| |
| Subcommands: |
| status Per-category breakdown + top-10 largest |
| dry-run Preview what quick/deep would delete |
| quick Run safe cleanup now (no prompts) |
| deep Run quick, then list items that need prompts |
| track <path> <category> Manually add a path to tracking |
| forget <path> Stop tracking a path (does not delete) |
| |
| Categories: temp | test | research | download | chrome-profile | cron-output | other |
| |
| All operations are scoped to HERMES_HOME and /tmp/hermes-*. |
| Test files are auto-tracked on write_file / terminal and auto-cleaned at session end. |
| """ |
|
|
|
|
| def _fmt_summary(summary: Dict[str, Any]) -> str: |
| base = ( |
| f"[disk-cleanup] Cleaned {summary['deleted']} files + " |
| f"{summary['empty_dirs']} empty dirs, freed {dg.fmt_size(summary['freed'])}." |
| ) |
| if summary.get("errors"): |
| base += f"\n {len(summary['errors'])} error(s); see cleanup.log." |
| return base |
|
|
|
|
| def _handle_slash(raw_args: str) -> Optional[str]: |
| argv = raw_args.strip().split() |
| if not argv or argv[0] in ("help", "-h", "--help"): |
| return _HELP_TEXT |
|
|
| sub = argv[0] |
|
|
| if sub == "status": |
| return dg.format_status(dg.status()) |
|
|
| if sub == "dry-run": |
| auto, prompt = dg.dry_run() |
| auto_size = sum(i["size"] for i in auto) |
| prompt_size = sum(i["size"] for i in prompt) |
| lines = [ |
| "Dry-run preview (nothing deleted):", |
| f" Auto-delete : {len(auto)} files ({dg.fmt_size(auto_size)})", |
| ] |
| for item in auto: |
| lines.append(f" [{item['category']}] {item['path']}") |
| lines.append( |
| f" Needs prompt: {len(prompt)} files ({dg.fmt_size(prompt_size)})" |
| ) |
| for item in prompt: |
| lines.append(f" [{item['category']}] {item['path']}") |
| lines.append( |
| f"\n Total potential: {dg.fmt_size(auto_size + prompt_size)}" |
| ) |
| return "\n".join(lines) |
|
|
| if sub == "quick": |
| return _fmt_summary(dg.quick()) |
|
|
| if sub == "deep": |
| |
| |
| quick_summary = dg.quick() |
| _auto, prompt_items = dg.dry_run() |
| lines = [_fmt_summary(quick_summary)] |
| if prompt_items: |
| size = sum(i["size"] for i in prompt_items) |
| lines.append( |
| f"\n{len(prompt_items)} item(s) need confirmation " |
| f"({dg.fmt_size(size)}):" |
| ) |
| for item in prompt_items: |
| lines.append(f" [{item['category']}] {item['path']}") |
| lines.append( |
| "\nRun `/disk-cleanup forget <path>` to skip, or delete " |
| "manually via terminal." |
| ) |
| return "\n".join(lines) |
|
|
| if sub == "track": |
| if len(argv) < 3: |
| return "Usage: /disk-cleanup track <path> <category>" |
| path_arg = argv[1] |
| category = argv[2] |
| if category not in dg.ALLOWED_CATEGORIES: |
| return ( |
| f"Unknown category '{category}'. " |
| f"Allowed: {sorted(dg.ALLOWED_CATEGORIES)}" |
| ) |
| if dg.track(path_arg, category, silent=True): |
| return f"Tracked {path_arg} as '{category}'." |
| return ( |
| f"Not tracked (already present, missing, or outside HERMES_HOME): " |
| f"{path_arg}" |
| ) |
|
|
| if sub == "forget": |
| if len(argv) < 2: |
| return "Usage: /disk-cleanup forget <path>" |
| n = dg.forget(argv[1]) |
| return ( |
| f"Removed {n} tracking entr{'y' if n == 1 else 'ies'} for {argv[1]}." |
| if n else f"Not found in tracking: {argv[1]}" |
| ) |
|
|
| return f"Unknown subcommand: {sub}\n\n{_HELP_TEXT}" |
|
|
|
|
| |
| |
| |
|
|
| 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-cleanup", |
| handler=_handle_slash, |
| description="Track and clean up ephemeral Hermes session files.", |
| ) |
|
|