feat(skills): add disk-guardian — autonomous cleanup of Hermes temp files and disk optimization

This commit is contained in:
LVT382009 2026-04-18 23:03:58 +07:00 committed by Teknium
parent 9a57aa2b1f
commit 068b224887
2 changed files with 803 additions and 0 deletions

View file

@ -0,0 +1,306 @@
---
name: disk-guardian
description: >
Keeps Hermes's disk footprint clean. Tracks temp files, test outputs, research
artifacts, and large downloads created during sessions, then removes stale ones
safely. Especially useful on WSL2 where disk fills up fast during long agent runs.
version: 1.0.0
metadata:
hermes:
tags: [devops, maintenance, disk, cleanup, WSL2]
category: devops
---
# Disk Guardian
Autonomous disk cleanup for Hermes Agent. Tracks files created during sessions and safely removes stale ones to prevent disk space exhaustion, especially on WSL2 where disk fills up fast during long agent runs.
## When to Use
- User reports disk space issues or slow performance
- Long-running sessions have accumulated temp files
- Research artifacts from deep-research need cleanup
- Chrome debug profiles from NotebookLM authentication are growing
- User wants to see disk usage breakdown by category
- User wants to clean up old test outputs and logs
## Core Behaviors
1. **Silent Tracking** - Log every path Hermes writes to tracked.json with timestamp + category
2. **Safe Auto-Cleanup** - Delete stale files by age/size rules with appropriate safety checks
3. **Status Reporting** - Show disk usage breakdown and largest files
## First-Time Setup
On first run, create the disk-guardian directory and state files:
```bash
# Create directory
mkdir -p "$(get_hermes_home)/disk-guardian"
# Initialize tracking file
echo '[]' > "$(get_hermes_home)/disk-guardian/tracked.json"
# Initialize log file
touch "$(get_hermes_home)/disk-guardian/cleanup.log"
# Optional: Register weekly cronjob (Sunday 3 AM)
# This is optional - skill works without cron
```
The skill uses `get_hermes_home()` to resolve the actual path. Never hardcode `~/.hermes` - the path is resolved by the agent, not hardcoded.
## Silent Tracking Protocol
Track files when Hermes creates them via write_file or terminal:
```bash
# Track a temp file
python disk_guardian.py track "/tmp/hermes-abc123/output.json" "temp"
# Track a research artifact
python disk_guardian.py track "$(get_hermes_home)/research/ai-safety/paper.pdf" "research"
# Track a test output
python disk_guardian.py track "$(get_hermes_home)/test_results/test_001.log" "test"
# Track a download
python disk_guardian.py track "$(get_hermes_home)/downloads/model.gguf" "download"
# Track a chrome profile
python disk_guardian.py track "$(get_hermes_home)/.local/share/notebooklm-mcp/chrome_profile_abc" "chrome-profile"
```
Categories: `temp`, `test`, `research`, `download`, `chrome-profile`, `cron-output`, `other`
Always use `shlex.quote()` when interpolating user input into shell commands.
## Cleanup Rules
### Rule 1: Temp Files (> 7 days)
```bash
find "$(get_hermes_home)/cache/hermes" -type f -mtime +7 -delete
find "/tmp/hermes-*" -type f -mtime +7 -delete
```
Auto-delete without confirmation.
### Rule 2: Test Outputs (> 3 days)
```bash
find "$(get_hermes_home)" -type f \( -name "test_*.py" -o -name "*.test.log" -o -name "tmp_*.json" \) -mtime +3 -delete
```
Auto-delete without confirmation.
### Rule 3: Empty Directories
```bash
find "$(get_hermes_home)" -type d -empty -delete
```
Auto-delete without confirmation.
### Rule 4: Research Folders (keep last 10)
```bash
# List research folders sorted by modification time
ls -td "$(get_hermes_home)/research"/* 2>/dev/null | tail -n +11 | while read dir; do
echo "Delete old research folder: $dir? [y/N]"
# Prompt user for confirmation
done
```
Prompt before deleting older than last 10.
### Rule 5: Chrome Debug Profiles (> 14 days)
```bash
find "$(get_hermes_home)/.local/share/notebooklm-mcp" -type d -name "chrome_profile*" -mtime +14
```
Warn + offer to trim.
### Rule 6: Large Files (> 500 MB)
```bash
find "$(get_hermes_home)" -type f -size +500M -exec ls -lh {} \;
```
Warn + offer to delete if looks like temp download.
## Sub-Command Implementations
### /cleanup dry-run
Preview what would be deleted without touching anything:
```bash
python disk_guardian.py dry-run
```
Returns list of files that would be deleted by each rule, with total size.
### /cleanup quick
Safe fast clean, no confirmation needed:
```bash
python disk_guardian.py quick
```
Applies Rules 1-3 (temp, test, empty dirs). Returns summary: "Deleted 15 files, freed 234 MB"
### /cleanup deep
Full scan, confirm before anything > 100 MB or research folders:
```bash
python disk_guardian.py deep
```
Applies all rules. For risky items (research folders, large files, chrome profiles), prompts user for confirmation. Returns detailed breakdown by category.
### /cleanup status
Disk usage breakdown by category + top 10 largest Hermes files:
```bash
python disk_guardian.py status
```
Returns table with categories (temp, test, research, download, chrome-profile, other) and disk usage, plus top 10 largest files.
### /cleanup forget <path>
Remove a path from tracking permanently:
```bash
python disk_guardian.py forget "$(shlex.quote "$path")"
```
Removes entry from tracked.json and logs action.
## Integration with deep-research-monitor
If deep-research-monitor skill is present, offer to clean/archive the research folder after `/deep-research stop <topic>`:
```bash
# After deep-research stops, prompt user:
echo "Research complete. Clean up old research folders? [y/N]"
# If yes, run: python disk_guardian.py deep --category research
```
## Pitfalls to Avoid
1. **Never hardcode `~/.hermes`** - Always use `get_hermes_home()` for path resolution
2. **Always use `shlex.quote()`** - When interpolating user input into shell commands
3. **Don't delete outside Hermes home** - Validate all paths are under Hermes home directory
4. **Don't delete research artifacts without confirmation** - These are valuable user data
5. **Don't delete large files without warning** - User may need them
6. **Don't assume WSL2** - Check `/proc/version` for "microsoft" marker
7. **Don't delete Windows drives in WSL2** - Skip `/mnt/c/` and other Windows mounts
8. **Don't corrupt tracked.json** - Use file locking and atomic writes
9. **Don't ignore errors** - Log all errors and provide user feedback
10. **Don't require cron** - Skill works perfectly without cron integration
## Error Handling
| Error Scenario | Detection | Recovery | User Message |
|----------------|-----------|----------|--------------|
| tracked.json corrupted | JSON parse fails | Backup file restoration | "Tracking file corrupted, restored from backup" |
| Permission denied | OSError on delete | Skip file, log error | "Cannot delete <path>: permission denied" |
| Path doesn't exist | FileNotFoundError | Remove from tracking | "Path <path> no longer exists, removed from tracking" |
| Disk full | OSError on write | Abort operation, report | "Disk full, cannot write to tracking file" |
| Invalid category | Category not in allowed list | Default to "other" | "Unknown category, using 'other'" |
| WSL detection fails | /proc/version unreadable | Assume non-WSL | "Cannot detect WSL, using default paths" |
| Cron registration fails | crontab command fails | Continue without cron | "Cron registration failed, skill works without it" |
| Concurrent cleanup | File lock fails | Wait and retry | "Another cleanup in progress, waiting..." |
## Example Interactions
**User:** "My disk is full, help me clean up"
**Agent:** "I'll run a quick cleanup to remove temp files and test outputs. This won't delete any research artifacts or large files without asking."
```bash
python disk_guardian.py quick
```
**Output:** "Deleted 23 temp files, freed 456 MB. Run `/cleanup deep` for more thorough cleanup including research folders and large files."
---
**User:** "Show me what's taking up space"
**Agent:** "Here's your disk usage breakdown:"
```bash
python disk_guardian.py status
```
**Output:**
```
Category | Files | Size
----------------|-------|--------
temp | 156 | 2.3 GB
test | 45 | 890 MB
research | 12 | 15.6 GB
download | 3 | 4.2 GB
chrome-profile | 2 | 1.1 GB
other | 89 | 3.4 GB
Top 10 largest files:
1. /home/user/.hermes/research/llm-training/model.gguf (4.2 GB)
2. /home/user/.hermes/cache/hermes/trajectory_cache.db (2.1 GB)
3. /home/user/.hermes/downloads/dataset.zip (1.8 GB)
...
```
---
**User:** "Clean up everything, including research folders"
**Agent:** "I'll run a deep cleanup. This will prompt you before deleting research folders and large files."
```bash
python disk_guardian.py deep
```
**Output:**
```
Found 12 research folders. Keeping last 10 by modification time.
Delete old research folder: /home/user/.hermes/research/old-project? [y/N] y
Deleted: /home/user/.hermes/research/old-project (1.2 GB)
Found 3 files > 500 MB:
1. /home/user/.hermes/downloads/model.gguf (4.2 GB) - looks like temp download
Delete this file? [y/N] y
Deleted: /home/user/.hermes/downloads/model.gguf (4.2 GB)
Summary: Deleted 2 items, freed 5.4 GB
```
---
**User:** "I want to see what would be deleted without actually deleting anything"
**Agent:** "Running dry-run to preview cleanup actions:"
```bash
python disk_guardian.py dry-run
```
**Output:**
```
Would delete 156 temp files (2.3 GB)
Would delete 45 test files (890 MB)
Would delete 23 empty directories
Would prompt for 2 research folders (3.4 GB)
Would prompt for 3 large files (6.0 GB)
Total potential cleanup: 12.5 GB
Run `/cleanup quick` for safe auto-cleanup
Run `/cleanup deep` for full cleanup with confirmation
```

View file

@ -0,0 +1,497 @@
#!/usr/bin/env python3
"""
Disk Guardian - Autonomous disk cleanup for Hermes Agent
Tracks files created by Hermes and safely removes stale ones.
"""
import argparse
import json
import os
import sys
import subprocess
import shlex
from pathlib import Path
from datetime import datetime, timezone
from typing import Dict, List, Optional, Any
import fcntl
import shutil
def get_hermes_home() -> Path:
"""Return the Hermes home directory (default: ~/.hermes)."""
val = os.environ.get("HERMES_HOME", "").strip()
return Path(val) if val else Path.home() / ".hermes"
def get_disk_guardian_dir() -> Path:
"""Return the disk-guardian directory."""
return get_hermes_home() / "disk-guardian"
def get_tracked_file() -> Path:
"""Return the tracked.json file path."""
return get_disk_guardian_dir() / "tracked.json"
def get_log_file() -> Path:
"""Return the cleanup.log file path."""
return get_disk_guardian_dir() / "cleanup.log"
def is_wsl() -> bool:
"""Check if running in WSL."""
try:
with open("/proc/version", "r") as f:
return "microsoft" in f.read().lower()
except Exception:
return False
def log_message(message: str) -> None:
"""Write a message to the cleanup log."""
log_file = get_log_file()
log_file.parent.mkdir(parents=True, exist_ok=True)
timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S")
with open(log_file, "a") as f:
f.write(f"[{timestamp}] {message}\n")
def load_tracked() -> List[Dict[str, Any]]:
"""Load tracked.json with error handling."""
tracked_file = get_tracked_file()
tracked_file.parent.mkdir(parents=True, exist_ok=True)
if not tracked_file.exists():
return []
try:
with open(tracked_file, "r") as f:
return json.load(f)
except json.JSONDecodeError:
# Try to restore from backup
backup_file = tracked_file.with_suffix(".json.bak")
if backup_file.exists():
log_message("Tracking file corrupted, restoring from backup")
with open(backup_file, "r") as f:
return json.load(f)
log_message("Tracking file corrupted, starting fresh")
return []
def save_tracked(tracked: List[Dict[str, Any]]) -> None:
"""Save tracked.json with atomic write and backup."""
tracked_file = get_tracked_file()
tracked_file.parent.mkdir(parents=True, exist_ok=True)
# Create backup
if tracked_file.exists():
backup_file = tracked_file.with_suffix(".json.bak")
shutil.copy2(tracked_file, backup_file)
# Atomic write
temp_file = tracked_file.with_suffix(".json.tmp")
with open(temp_file, "w") as f:
json.dump(tracked, f, indent=2)
temp_file.replace(tracked_file)
def track_path(path: str, category: str) -> None:
"""Add a path to tracking."""
allowed_categories = ["temp", "test", "research", "download", "chrome-profile", "cron-output", "other"]
if category not in allowed_categories:
log_message(f"Unknown category '{category}', using 'other'")
category = "other"
path_obj = Path(path).resolve()
if not path_obj.exists():
log_message(f"Path {path} does not exist, skipping")
return
# Check if path is under Hermes home
hermes_home = get_hermes_home().resolve()
try:
path_obj.relative_to(hermes_home)
except ValueError:
log_message(f"Path {path} is outside Hermes home, skipping")
return
# Get file size
size = path_obj.stat().st_size if path_obj.is_file() else 0
tracked = load_tracked()
tracked.append({
"path": str(path_obj),
"timestamp": datetime.now(timezone.utc).isoformat(),
"category": category,
"size": size
})
save_tracked(tracked)
log_message(f"TRACKED: {path} ({category}, {size} bytes)")
print(f"Tracked: {path} ({category}, {size} bytes)")
def scan_files() -> None:
"""Discover temp/test files by pattern and add to tracking."""
hermes_home = get_hermes_home()
tracked = load_tracked()
tracked_paths = {item["path"] for item in tracked}
# Scan for temp files
temp_patterns = [
hermes_home / "cache" / "hermes" / "*",
Path("/tmp") / "hermes-*"
]
for pattern in temp_patterns:
for path in pattern.parent.glob(pattern.name):
if str(path.resolve()) not in tracked_paths and path.exists():
track_path(str(path), "temp")
# Scan for test files
test_patterns = [
hermes_home / "test_*.py",
hermes_home / "*.test.log",
hermes_home / "tmp_*.json"
]
for pattern in test_patterns:
for path in hermes_home.glob(pattern.name):
if str(path.resolve()) not in tracked_paths and path.exists():
track_path(str(path), "test")
print(f"Scan complete. Total tracked files: {len(load_tracked())}")
def dry_run() -> None:
"""Preview what would be deleted without touching anything."""
hermes_home = get_hermes_home()
tracked = load_tracked()
# Categorize files by age
now = datetime.now(timezone.utc)
temp_files = []
test_files = []
research_folders = []
large_files = []
chrome_profiles = []
for item in tracked:
path = Path(item["path"])
if not path.exists():
continue
timestamp = datetime.fromisoformat(item["timestamp"])
age_days = (now - timestamp).days
if item["category"] == "temp" and age_days > 7:
temp_files.append(item)
elif item["category"] == "test" and age_days > 3:
test_files.append(item)
elif item["category"] == "research" and age_days > 30:
research_folders.append(item)
elif item["size"] > 500 * 1024 * 1024: # > 500 MB
large_files.append(item)
elif item["category"] == "chrome-profile" and age_days > 14:
chrome_profiles.append(item)
# Calculate sizes
temp_size = sum(item["size"] for item in temp_files)
test_size = sum(item["size"] for item in test_files)
research_size = sum(item["size"] for item in research_folders)
large_size = sum(item["size"] for item in large_files)
chrome_size = sum(item["size"] for item in chrome_profiles)
print("Dry-run results:")
print(f"Would delete {len(temp_files)} temp files ({format_size(temp_size)})")
print(f"Would delete {len(test_files)} test files ({format_size(test_size)})")
print(f"Would prompt for {len(research_folders)} research folders ({format_size(research_size)})")
print(f"Would prompt for {len(large_files)} large files ({format_size(large_size)})")
print(f"Would prompt for {len(chrome_profiles)} chrome profiles ({format_size(chrome_size)})")
total_size = temp_size + test_size + research_size + large_size + chrome_size
print(f"\nTotal potential cleanup: {format_size(total_size)}")
print("Run 'quick' for safe auto-cleanup")
print("Run 'deep' for full cleanup with confirmation")
def quick_cleanup() -> None:
"""Safe fast clean, no confirmation needed."""
hermes_home = get_hermes_home()
tracked = load_tracked()
now = datetime.now(timezone.utc)
deleted_files = []
total_freed = 0
# Delete temp files > 7 days
for item in tracked[:]:
if item["category"] == "temp":
path = Path(item["path"])
if not path.exists():
tracked.remove(item)
continue
timestamp = datetime.fromisoformat(item["timestamp"])
age_days = (now - timestamp).days
if age_days > 7:
try:
if path.is_file():
path.unlink()
elif path.is_dir():
shutil.rmtree(path)
deleted_files.append(item)
total_freed += item["size"]
tracked.remove(item)
log_message(f"DELETED: {item['path']} (temp, {item['size']} bytes)")
except Exception as e:
log_message(f"ERROR deleting {item['path']}: {e}")
# Delete test files > 3 days
for item in tracked[:]:
if item["category"] == "test":
path = Path(item["path"])
if not path.exists():
tracked.remove(item)
continue
timestamp = datetime.fromisoformat(item["timestamp"])
age_days = (now - timestamp).days
if age_days > 3:
try:
if path.is_file():
path.unlink()
elif path.is_dir():
shutil.rmtree(path)
deleted_files.append(item)
total_freed += item["size"]
tracked.remove(item)
log_message(f"DELETED: {item['path']} (test, {item['size']} bytes)")
except Exception as e:
log_message(f"ERROR deleting {item['path']}: {e}")
# Delete empty directories
for root, dirs, files in os.walk(hermes_home, topdown=False):
for dir_name in dirs:
dir_path = Path(root) / dir_name
try:
if dir_path.is_dir() and not any(dir_path.iterdir()):
dir_path.rmdir()
log_message(f"DELETED: {dir_path} (empty directory)")
except Exception as e:
pass # Directory not empty or permission denied
save_tracked(tracked)
print(f"Deleted {len(deleted_files)} files, freed {format_size(total_freed)}")
def deep_cleanup() -> None:
"""Full scan with confirmation for risky items."""
hermes_home = get_hermes_home()
tracked = load_tracked()
now = datetime.now(timezone.utc)
# First, do quick cleanup
print("Running quick cleanup first...")
quick_cleanup()
tracked = load_tracked()
# Now handle risky items
research_folders = []
large_files = []
chrome_profiles = []
for item in tracked:
path = Path(item["path"])
if not path.exists():
continue
timestamp = datetime.fromisoformat(item["timestamp"])
age_days = (now - timestamp).days
if item["category"] == "research" and age_days > 30:
research_folders.append(item)
elif item["size"] > 500 * 1024 * 1024: # > 500 MB
large_files.append(item)
elif item["category"] == "chrome-profile" and age_days > 14:
chrome_profiles.append(item)
# Keep last 10 research folders
research_folders.sort(key=lambda x: x["timestamp"], reverse=True)
old_research = research_folders[10:]
total_freed = 0
deleted_count = 0
# Prompt for old research folders
for item in old_research:
path = Path(item["path"])
response = input(f"Delete old research folder: {path}? [y/N] ")
if response.lower() == "y":
try:
if path.exists():
shutil.rmtree(path)
total_freed += item["size"]
deleted_count += 1
tracked.remove(item)
log_message(f"DELETED: {item['path']} (research, {item['size']} bytes)")
print(f"Deleted: {path} ({format_size(item['size'])})")
except Exception as e:
log_message(f"ERROR deleting {item['path']}: {e}")
print(f"Error deleting {path}: {e}")
# Prompt for large files
for item in large_files:
path = Path(item["path"])
print(f"\nLarge file: {path} ({format_size(item['size'])})")
print("Category:", item["category"])
response = input("Delete this file? [y/N] ")
if response.lower() == "y":
try:
if path.exists():
path.unlink()
total_freed += item["size"]
deleted_count += 1
tracked.remove(item)
log_message(f"DELETED: {item['path']} (large file, {item['size']} bytes)")
print(f"Deleted: {path}")
except Exception as e:
log_message(f"ERROR deleting {item['path']}: {e}")
print(f"Error deleting {path}: {e}")
# Prompt for chrome profiles
for item in chrome_profiles:
path = Path(item["path"])
print(f"\nChrome profile: {path} ({format_size(item['size'])})")
response = input("Delete this chrome profile? [y/N] ")
if response.lower() == "y":
try:
if path.exists():
shutil.rmtree(path)
total_freed += item["size"]
deleted_count += 1
tracked.remove(item)
log_message(f"DELETED: {item['path']} (chrome-profile, {item['size']} bytes)")
print(f"Deleted: {path}")
except Exception as e:
log_message(f"ERROR deleting {item['path']}: {e}")
print(f"Error deleting {path}: {e}")
save_tracked(tracked)
print(f"\nSummary: Deleted {deleted_count} items, freed {format_size(total_freed)}")
def show_status() -> None:
"""Show disk usage breakdown by category + top 10 largest files."""
tracked = load_tracked()
# Calculate usage by category
categories = {}
for item in tracked:
cat = item["category"]
if cat not in categories:
categories[cat] = {"count": 0, "size": 0}
categories[cat]["count"] += 1
categories[cat]["size"] += item["size"]
print("Disk usage by category:")
print(f"{'Category':<20} {'Files':<10} {'Size':<15}")
print("-" * 45)
for cat, data in sorted(categories.items(), key=lambda x: x[1]["size"], reverse=True):
print(f"{cat:<20} {data['count']:<10} {format_size(data['size']):<15}")
# Find top 10 largest files
all_files = [(item["path"], item["size"], item["category"]) for item in tracked if Path(item["path"]).exists()]
all_files.sort(key=lambda x: x[1], reverse=True)
top_10 = all_files[:10]
print("\nTop 10 largest files:")
for i, (path, size, cat) in enumerate(top_10, 1):
print(f"{i}. {path} ({format_size(size)}, {cat})")
def forget_path(path: str) -> None:
"""Remove a path from tracking permanently."""
path_obj = Path(path).resolve()
tracked = load_tracked()
original_count = len(tracked)
tracked = [item for item in tracked if Path(item["path"]).resolve() != path_obj]
removed = original_count - len(tracked)
if removed > 0:
save_tracked(tracked)
log_message(f"FORGOT: {path} ({removed} entries)")
print(f"Removed {removed} entries from tracking")
else:
print(f"Path {path} not found in tracking")
def format_size(size_bytes: int) -> str:
"""Format size in human-readable format."""
for unit in ["B", "KB", "MB", "GB", "TB"]:
if size_bytes < 1024.0:
return f"{size_bytes:.1f} {unit}"
size_bytes /= 1024.0
return f"{size_bytes:.1f} PB"
def main():
parser = argparse.ArgumentParser(description="Disk Guardian - Autonomous disk cleanup for Hermes Agent")
subparsers = parser.add_subparsers(dest="command", help="Available commands")
# Track command
track_parser = subparsers.add_parser("track", help="Track a path")
track_parser.add_argument("path", help="Path to track")
track_parser.add_argument("category", help="Category (temp, test, research, download, chrome-profile, cron-output, other)")
# Scan command
subparsers.add_parser("scan", help="Discover temp/test files by pattern")
# Dry-run command
subparsers.add_parser("dry-run", help="Preview what would be deleted")
# Quick command
subparsers.add_parser("quick", help="Safe fast clean")
# Deep command
subparsers.add_parser("deep", help="Full scan with confirmation")
# Status command
subparsers.add_parser("status", help="Show disk usage breakdown")
# Forget command
forget_parser = subparsers.add_parser("forget", help="Remove a path from tracking")
forget_parser.add_argument("path", help="Path to forget")
args = parser.parse_args()
if not args.command:
parser.print_help()
sys.exit(1)
try:
if args.command == "track":
track_path(args.path, args.category)
elif args.command == "scan":
scan_files()
elif args.command == "dry-run":
dry_run()
elif args.command == "quick":
quick_cleanup()
elif args.command == "deep":
deep_cleanup()
elif args.command == "status":
show_status()
elif args.command == "forget":
forget_path(args.path)
except Exception as e:
log_message(f"ERROR: {e}")
print(f"Error: {e}", file=sys.stderr)
sys.exit(1)
if __name__ == "__main__":
main()