diff --git a/cli-config.yaml.example b/cli-config.yaml.example index 70322adbff..95eaab56a4 100644 --- a/cli-config.yaml.example +++ b/cli-config.yaml.example @@ -1035,10 +1035,11 @@ group_sessions_per_user: true # Session Storage Retention (state.db) # ============================================================================= # ~/.hermes/state.db keeps every session, message, and tool call, plus the -# FTS5 search indexes. Since #54189, auto-pruning is ON by default so the file -# stays bounded: at CLI/gateway/cron startup (at most once per -# min_interval_hours) Hermes deletes ENDED sessions whose last activity is -# older than retention_days. Open, pinned, and in-progress sessions are never +# FTS5 search indexes. Auto-pruning is OFF by default; when enabled, at +# CLI/gateway/cron startup (at most once per min_interval_hours) Hermes deletes +# ENDED sessions whose last activity is older than retention_days. While +# auto_prune is unset, Hermes asks once when the disk holding state.db runs +# low (/autoprune on|off|later answers it). Open, pinned, and in-progress sessions are never # deleted. Stale automation sessions (cron/kanban/subagent/one-shot CLI) whose # process died without closing them are first *closed*, then aged through a # further full retention window before removal. @@ -1048,10 +1049,10 @@ group_sessions_per_user: true # of the file's pages are reclaimable — a dense database never pays for a full # rewrite to reclaim a few MB. # -# Uncomment to change the defaults shown; set auto_prune: false to keep every -# ended session forever (the pre-#54189 behavior). +# Uncomment to change the defaults shown; auto_prune: true bounds the file, +# auto_prune: false keeps every ended session and silences the low-disk prompt. # sessions: -# auto_prune: true +# auto_prune: false # retention_days: 90 # vacuum_after_prune: true # min_vacuum_interval_days: 30 diff --git a/gateway/run.py b/gateway/run.py index 2258d9590b..8f33d63497 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -4356,30 +4356,6 @@ def _housekeeping_channel_directory(adapters, loop) -> None: fut.result(timeout=30) -def _housekeeping_media_caches() -> None: - """Every platform media cache prunes on the same hourly cadence (24h max age).""" - from gateway.platforms.base import ( - cleanup_audio_cache, cleanup_document_cache, cleanup_image_cache, cleanup_screenshot_cache, - cleanup_video_cache) - from tools.tool_result_storage import cleanup_spillover_cache - from tools.environments.local import cleanup_terminal_temp_cache - from tools.bot_mode_dm import cleanup_bot_dm_cache - from tools.bot_relay import cleanup_bot_relay_artifacts - from agent.provider_media import MEDIA_CACHE_MAX_AGE_HOURS - - for cache_name, cleanup_fn in ( - ("Image", cleanup_image_cache), ("Document", cleanup_document_cache), - ("Audio", cleanup_audio_cache), ("Video", cleanup_video_cache), - ("Screenshot", cleanup_screenshot_cache), ("Spillover", cleanup_spillover_cache), - ("Terminal temp", cleanup_terminal_temp_cache), ("Bot DM", cleanup_bot_dm_cache), - ("Bot relay", cleanup_bot_relay_artifacts)): - def _one(name=cache_name, fn=cleanup_fn): - removed = fn(max_age_hours=MEDIA_CACHE_MAX_AGE_HOURS) - if removed: - logger.info("%s cache cleanup: removed %d stale file(s)", name, removed) - _housekeeping_chore(f"{cache_name} cache cleanup", _one) - - def _housekeeping_paste_sweep() -> None: from hermes_cli.debug import _sweep_expired_pastes deleted, remaining = _sweep_expired_pastes() @@ -4535,6 +4511,7 @@ def _start_gateway_housekeeping( Cadences are ticks of ``interval``; inner gates own the real cadence.""" from gateway.run_delivery_queue_watch import DRAIN_LABEL, DeliveryQueueWatch, wait_for_next_tick from gateway.run_profile_reconcile import _mcp_config_reconciler, profile_scoped_chore + from gateway.run_housekeeping_disk import housekeeping_media_caches, maybe_post_auto_prune_prompt chores: list[tuple[int, str, Any]] = [ # First every tick: re-stamp ``updated_at`` in gateway_state.json so it is a real heartbeat. # ``hermes gateway status`` / ``/api/status`` warn when it ages past 2x ``interval`` with the @@ -4547,8 +4524,10 @@ def _start_gateway_housekeeping( chores.append((1, DRAIN_LABEL, lambda: _drain_restart_safe_cron_deliveries(adapters, loop, runner))) chores += [ (5, "Channel directory refresh", lambda: adapters and _housekeeping_channel_directory(adapters, loop)), - (60, "Media cache cleanup", _housekeeping_media_caches), + (60, "Media cache cleanup", housekeeping_media_caches), (60, "Paste sweep", _housekeeping_paste_sweep)] + # Low disk + auto-prune undecided: ask on the home channel (two syscalls; snoozed per post). + chores.append((1, "Auto-prune low-disk prompt", lambda: maybe_post_auto_prune_prompt(runner, loop))) if cron_provider is not None: chores.append((5, "Misfire catch-up sweep", lambda: _housekeeping_misfire_catch_up(cron_provider, adapters, loop))) if cron_thread is not None: diff --git a/gateway/run_busy.py b/gateway/run_busy.py index 932e1b8217..1d7d3ad6a5 100644 --- a/gateway/run_busy.py +++ b/gateway/run_busy.py @@ -986,7 +986,7 @@ class GatewayBusySessionMixin: _PLAIN_COMMANDS = ( "status", "context", "restart", "approve", "deny", "pause", "agents", "bg", "btw", "kanban", "subgoal", "heartbeat", "busy", "yolo", "verbose", "footer", "help", - "commands", "profile", "login", "update", "version", + "commands", "profile", "login", "update", "version", "autoprune", ) # Dispatched only on the idle path (busy dispatch has its own allowlist). _IDLE_COMMANDS = ( diff --git a/gateway/run_housekeeping_disk.py b/gateway/run_housekeeping_disk.py new file mode 100644 index 0000000000..2644226b04 --- /dev/null +++ b/gateway/run_housekeeping_disk.py @@ -0,0 +1,91 @@ +"""Gateway housekeeping chores that keep the data disk healthy: stale media-cache cleanup and the +home-channel prompt for the low-disk auto-prune decision (see ``hermes_cli/prune_advisor.py``). + +A headless gateway on a small VPS is where a disk fills up, and its owner may never open the CLI, +so the question goes to each served profile's home channel. It is posted at most once per +snooze window per profile: posting records the snooze, and ``/autoprune on|off`` ends it for good. +Runs from the housekeeping thread; the send is scheduled onto the gateway loop. +""" + +from __future__ import annotations + +import contextlib +import logging +from pathlib import Path + +logger = logging.getLogger(__name__) + + +def housekeeping_media_caches() -> None: + """Every platform media cache prunes on the same hourly cadence (24h max age).""" + from gateway.platforms.base import ( + cleanup_audio_cache, cleanup_document_cache, cleanup_image_cache, cleanup_screenshot_cache, + cleanup_video_cache) + from tools.tool_result_storage import cleanup_spillover_cache + from tools.environments.local import cleanup_terminal_temp_cache + from tools.bot_mode_dm import cleanup_bot_dm_cache + from tools.bot_relay import cleanup_bot_relay_artifacts + from agent.provider_media import MEDIA_CACHE_MAX_AGE_HOURS + from gateway.run import _housekeeping_chore + + for cache_name, cleanup_fn in ( + ("Image", cleanup_image_cache), ("Document", cleanup_document_cache), + ("Audio", cleanup_audio_cache), ("Video", cleanup_video_cache), + ("Screenshot", cleanup_screenshot_cache), ("Spillover", cleanup_spillover_cache), + ("Terminal temp", cleanup_terminal_temp_cache), ("Bot DM", cleanup_bot_dm_cache), + ("Bot relay", cleanup_bot_relay_artifacts)): + def _one(name=cache_name, fn=cleanup_fn): + removed = fn(max_age_hours=MEDIA_CACHE_MAX_AGE_HOURS) + if removed: + logger.info("%s cache cleanup: removed %d stale file(s)", name, removed) + _housekeeping_chore(f"{cache_name} cache cleanup", _one) + + +def maybe_post_auto_prune_prompt(runner, loop) -> None: + """One housekeeping tick: post the prompt to every served profile whose disk is low and who + has not decided. Cheap when nothing is due (two syscalls + a YAML read per profile).""" + if runner is None or loop is None: + return + from agent.async_utils import safe_schedule_threadsafe + from hermes_cli import prune_advisor + from hermes_constants import get_routing_process_hermes_home + + homes: dict = {None: get_routing_process_hermes_home()} + homes.update(getattr(runner, "_served_profile_homes", None) or {}) + for profile, home in homes.items(): + if prune_advisor.should_ask(Path(home)) is None: + continue + advice = prune_advisor.advise(Path(home)) + if advice is None: + continue + fut = safe_schedule_threadsafe(_post(runner, profile, home, advice), loop, logger=logger, + log_message="Auto-prune prompt scheduling error") + # Snooze only on a delivered post: a gateway with no home channel (the one a CLI + # auto-starts) must not swallow the question the CLI/TUI is about to ask. + if fut is not None and fut.result(timeout=60): + prune_advisor.snooze(Path(home)) + + +async def _post(runner, profile, home, advice) -> bool: + """Send to the profile's home channels; True when at least one send was delivered.""" + from gateway.run import _async_profile_runtime_scope + from gateway.warning_notifications import present_notification + from hermes_cli.prune_advisor import advice_lines + + delivered = False + scope = _async_profile_runtime_scope(Path(home)) if profile is not None else contextlib.nullcontext() + async with scope: + for target_profile, platform, _cfg, home_ch, transport in list(runner._served_home_channel_transports()): + if target_profile != profile: + continue + prefix = getattr(transport.adapter, "typed_command_prefix", "/") if transport.adapter else "/" + message = "⚠ " + "\n".join(advice_lines(advice, command_prefix=prefix)) + sent: list[bool] = [] + + async def _send(platform=platform, home_ch=home_ch, transport=transport, message=message): + sent.append(await runner._send_home_channel_message( + platform, home_ch, transport, message, "Auto-prune prompt failed for %s:%s: %s")) + + await present_notification(_send, platform=platform) + delivered = delivered or any(sent) + return delivered diff --git a/gateway/session_commands.py b/gateway/session_commands.py index e5e24a02d9..7819319dfd 100644 --- a/gateway/session_commands.py +++ b/gateway/session_commands.py @@ -24,7 +24,7 @@ _CONTROL_COMMANDS = frozenset({'title'}) # cached agent, the /debug summary upload, /blueprint's job creation. A turn the handler would have # pushed onto the adapter FIFO comes back as a ``send`` directive (gateway/session_command_sink.py). _HANDLER_CONTROLS = frozenset({'goal', 'subgoal', 'loop', 'heartbeat', 'review', 'refine', 'debug', 'curator', - 'login'}) + 'login', 'autoprune'}) # Idle-path commands the messaging gateway answers by rewriting the turn (``_hm_cmd_``, # gateway/run_inbound.py): the rewritten text is returned as the client's ``send`` directive. _PROMPT_COMMANDS = frozenset({'plan', 'learn', 'init', 'moa', 'blueprint', 'egress'}) diff --git a/gateway/slash_commands.py b/gateway/slash_commands.py index 0ac48d5972..0327ccdc44 100644 --- a/gateway/slash_commands.py +++ b/gateway/slash_commands.py @@ -595,6 +595,10 @@ class GatewaySlashCommandsMixin( return t("gateway.draining", count=active_agents) return EphemeralReply(t("gateway.restart.restarting")) + async def _handle_autoprune_command(self, event: MessageEvent) -> str: + """Handle /autoprune — the low-disk auto-prune decision (same text as the CLI).""" + return _execute("autoprune", args=event.get_command_args()).text + async def _handle_version_command(self, event: MessageEvent) -> str: """Handle /version — show the running Hermes Agent version.""" return _execute("version").text diff --git a/hermes_cli/cli_commands_session_tools.py b/hermes_cli/cli_commands_session_tools.py index 221f715b97..e0c84cfae8 100644 --- a/hermes_cli/cli_commands_session_tools.py +++ b/hermes_cli/cli_commands_session_tools.py @@ -270,6 +270,11 @@ class CLICommandsSessionToolsMixin: tools_disable_enable_command(Namespace(**ns)) _cp(*buf.getvalue().splitlines()) + def _handle_autoprune_command(self, cmd: str): + """/autoprune [on|off|later|status] — the low-disk auto-prune decision.""" + from hermes_cli.slash_exec import CommandContext, execute_command + _say_block(execute_command("autoprune", CommandContext(surface="cli", args=cmd.partition(" ")[2])).text) + def _handle_profile_command(self): """Display active profile name and home directory.""" from hermes_cli.slash_exec import CommandContext, execute_command diff --git a/hermes_cli/cli_tui_runtime_mixin.py b/hermes_cli/cli_tui_runtime_mixin.py index e7254aaefb..34a277761d 100644 --- a/hermes_cli/cli_tui_runtime_mixin.py +++ b/hermes_cli/cli_tui_runtime_mixin.py @@ -293,6 +293,13 @@ class CLITuiRuntimeMixin: offer_interactive(cprint=_cprint) except Exception: logger.debug("sandbox image switch offer failed", exc_info=True) + # Low disk + auto-prune never decided: ask once (either answer ends it for good). + try: + from hermes_cli.prune_advisor import offer_interactive as _offer_auto_prune + from cli import _cprint + _offer_auto_prune(cprint=_cprint) + except Exception: + logger.debug("auto-prune offer failed", exc_info=True) if self._resumed and self._preload_resumed_session(): self._display_resumed_history() diff --git a/hermes_cli/commands.py b/hermes_cli/commands.py index 90a325aaec..94e4471898 100644 --- a/hermes_cli/commands.py +++ b/hermes_cli/commands.py @@ -238,6 +238,9 @@ COMMAND_REGISTRY: list[CommandDef] = [ args_hint="[queue|steer|interrupt|status]", subcommands=("queue", "steer", "interrupt", "status"), busy_policy="dispatch", desktop="terminal"), + CommandDef("autoprune", "Turn automatic pruning of old sessions on or off", "Configuration", + args_hint="[on|off|later|status]", subcommands=("on", "off", "later", "status"), + busy_policy="dispatch", execute="autoprune", argument_mode="options"), # Tools & Skills CommandDef("tools", "Manage tools: /tools [list|disable|enable] [name...]", "Tools & Skills", diff --git a/hermes_cli/commands_platforms.py b/hermes_cli/commands_platforms.py index bd068ef06c..f4f4158f8f 100644 --- a/hermes_cli/commands_platforms.py +++ b/hermes_cli/commands_platforms.py @@ -383,7 +383,9 @@ _SLACK_RESERVED_COMMANDS = frozenset({ # parity test reads this set. Aliases are never pinned ahead of canonicals. _SLACK_VIA_HERMES_ONLY = frozenset({ "topup", "moa", "debug", "egress", "init", "version", "diff", "update", "heartbeat", - "refine", "review", "pause", "whoami", "platform", "insights", "login"}) + "refine", "review", "pause", "whoami", "platform", "insights", "login", + # One-time answer to the low-disk prompt; `/hermes autoprune on` reaches it. + "autoprune"}) def _sanitize_slack_name(raw: str) -> str: diff --git a/hermes_cli/config_defaults.py b/hermes_cli/config_defaults.py index 0407a6be40..d87ed644c3 100644 --- a/hermes_cli/config_defaults.py +++ b/hermes_cli/config_defaults.py @@ -2263,18 +2263,14 @@ DEFAULT_CONFIG = { # inserts, /resume listing, and insights queries. "sessions": { # Prune ENDED sessions inactive for retention_days (activity = freshest of live activity / - # latest message / creation) about once per min_interval_hours at startup. Open, pinned, or mid-turn sessions - # are never deleted; stale automation sessions whose process died are *closed*, then get a - # full retention window before removal. - "auto_prune": True, + # latest message / creation) about once per min_interval_hours at CLI/gateway/cron startup. + # Open, pinned, or mid-turn sessions are never deleted; stale automation sessions whose + # process died are *closed*, then get a full retention window before removal. + # Off by default (deleting history is opt-in). While the user's config.yaml has no + # auto_prune key, every surface asks once the disk holding state.db runs low + # (hermes_cli/prune_advisor.py); either answer writes the key and ends the question. + "auto_prune": False, # Inactive days of ended-session history to keep (= `hermes sessions prune`). - # When true, prune ENDED sessions inactive for retention_days once per (roughly) min_interval_hours - # at CLI/gateway/cron startup. Activity is the freshest of live activity (last_activity_at) / latest - # message timestamp / creation time. Sessions that are still open, pinned, or mid-turn are never deleted — the - # only open rows the sweep touches are stale automation sessions (cron/kanban/subagent/one-shot CLI) - # whose process died without closing them; those are *closed*, not deleted, and get a further full - # retention window before removal. Default true since #54189: without it state.db grows without - # bound (multi-GB installs reported within weeks). "retention_days": 90, # Auto-archive (soft-hide, never delete) sessions with no activity for auto_archive_days, # once per min_interval_hours. Pinned sessions are exempt. diff --git a/hermes_cli/prune_advisor.py b/hermes_cli/prune_advisor.py new file mode 100644 index 0000000000..487ad11c07 --- /dev/null +++ b/hermes_cli/prune_advisor.py @@ -0,0 +1,257 @@ +"""Low-disk prompt for ``sessions.auto_prune`` (off by default). + +Auto-prune deletes history, so it is opt-in. Users who never decided still need to hear about it +when the disk holding ``state.db`` runs low: every surface asks once the disk is low, and the +question stops forever as soon as ``sessions.auto_prune`` is written to the user's config.yaml +(either answer). "Later" snoozes for :data:`SNOOZE_SECONDS`. + +The hot-path check is two syscalls (``os.stat`` + ``shutil.disk_usage``, microseconds), so it can +run at every session start. Only when it fires does :func:`advise` open the database read-only to +estimate what a prune would actually free, so the prompt can say plainly when pruning will not +fix the disk (nothing old enough to delete, or the store is a small share of the full disk). +""" + +from __future__ import annotations + +import json +import logging +import os +import shutil +import sqlite3 +import time +from dataclasses import dataclass +from pathlib import Path +from typing import Optional + +logger = logging.getLogger(__name__) + +GIB = 1024 ** 3 +# Low = under 2 GiB free, or under 10% free while also under 20 GiB (a 10%-free 4 TB disk is fine). +LOW_FREE_FLOOR = 2 * GIB +LOW_FREE_PERCENT = 10.0 +LOW_FREE_HEADROOM = 20 * GIB +SNOOZE_SECONDS = 7 * 86400 +_SNOOZE_FILE = ".auto_prune_prompt.json" + + +@dataclass(frozen=True) +class DiskSnapshot: + db_bytes: int + free_bytes: int + total_bytes: int + + @property + def low(self) -> bool: + if self.total_bytes <= 0: + return False + free_pct = self.free_bytes * 100.0 / self.total_bytes + return self.free_bytes < LOW_FREE_FLOOR or ( + free_pct < LOW_FREE_PERCENT and self.free_bytes < LOW_FREE_HEADROOM) + + +@dataclass(frozen=True) +class Advice: + disk: DiskSnapshot + retention_days: int + prunable_sessions: int + total_sessions: int + reclaim_bytes: int # estimate: the prunable share of messages times the file size + + @property + def helps(self) -> bool: + """Whether a prune would free a meaningful amount: at least 1 GiB, or enough to lift the + disk back out of the low band.""" + if self.prunable_sessions == 0: + return False + lifted = DiskSnapshot(self.disk.db_bytes - self.reclaim_bytes, + self.disk.free_bytes + self.reclaim_bytes, self.disk.total_bytes) + return self.reclaim_bytes >= GIB or not lifted.low + + +def _home(home: Optional[Path]) -> Path: + if home is not None: + return Path(home) + from hermes_constants import get_hermes_home + return get_hermes_home() + + +def disk_snapshot(home: Optional[Path] = None) -> Optional[DiskSnapshot]: + """``state.db`` size plus free/total bytes of its filesystem; None when either is unreadable.""" + db = _home(home) / "state.db" + try: + size = os.stat(db).st_size + usage = shutil.disk_usage(db.parent) + except OSError: + return None + return DiskSnapshot(size, usage.free, usage.total) + + +def undecided(home: Optional[Path] = None) -> bool: + """True while the user's own config.yaml has no ``sessions.auto_prune`` (default not chosen).""" + import hermes_yaml as yaml + from hermes_cli.config import read_user_config_raw + try: + sessions = read_user_config_raw(_home(home) / "config.yaml").get("sessions") + except (OSError, ValueError, yaml.YAMLError): # unreadable/broken config: never nag on top of it + return False + return not (isinstance(sessions, dict) and "auto_prune" in sessions) + + +def _snooze_path(home: Optional[Path]) -> Path: + return _home(home) / _SNOOZE_FILE + + +def snoozed(home: Optional[Path] = None, *, now: Optional[float] = None) -> bool: + try: + until = float(json.loads(_snooze_path(home).read_text(encoding="utf-8-sig")).get("snoozed_until", 0)) + except (OSError, ValueError, TypeError, AttributeError): + return False + return (now or time.time()) < until + + +def snooze(home: Optional[Path] = None, seconds: float = SNOOZE_SECONDS) -> None: + from utils import atomic_json_write + atomic_json_write(_snooze_path(home), {"snoozed_until": time.time() + seconds}) + + +def should_ask(home: Optional[Path] = None) -> Optional[DiskSnapshot]: + """The cheap gate every surface runs at session start: the snapshot when the disk is low and + the user has neither decided nor snoozed, else None. Two syscalls plus one small YAML read.""" + snap = disk_snapshot(home) + if snap is None or not snap.low: + return None + if not undecided(home) or snoozed(home): + return None + return snap + + +def advise(home: Optional[Path] = None) -> Optional[Advice]: + """:func:`should_ask` plus the reclaim estimate. Opens state.db read-only (a second or two on + a multi-GB store), so callers run it only after the cheap gate fires or off the prompt thread.""" + snap = should_ask(home) + if snap is None: + return None + from hermes_cli.config import load_config_readonly + retention = int(((load_config_readonly() or {}).get("sessions") or {}).get("retention_days", 90) or 90) + prunable = total = prunable_msgs = total_msgs = 0 + try: + from hermes_state import SessionDB + db = SessionDB(db_path=_home(home) / "state.db", read_only=True) + try: + where, params = db._prune_where(retention, None, {}, whole_lineages=True) + prunable, prunable_msgs = tuple(db._read_one( + f"SELECT COUNT(*), COALESCE(SUM(s.message_count), 0) FROM sessions s WHERE {where}", params) or (0, 0)) + total, total_msgs = tuple(db._read_one( + "SELECT COUNT(*), COALESCE(SUM(message_count), 0) FROM sessions", []) or (0, 0)) + finally: + db.close() + except (sqlite3.Error, OSError, RuntimeError, ValueError) as exc: # a busy/damaged store: no estimate + logger.debug("auto-prune advice: reclaim estimate unavailable: %s", exc) + reclaim = int(snap.db_bytes * prunable_msgs / total_msgs) if total_msgs else 0 + return Advice(snap, retention, int(prunable), int(total), reclaim) + + +def _gb(n: int) -> str: + """Human size: GB at or above 1 GiB, MB below (a 26 MB store must not read "0.0 GB").""" + return f"{n / GIB:.1f} GB" if n >= GIB else f"{max(n, 0) / 1024 ** 2:.0f} MB" + + +def _sessions(n: int) -> str: + return f"{n} session" + ("" if n == 1 else "s") + + +def advice_lines(advice: Advice, *, command_prefix: str = "/") -> list[str]: + """Plain-text prompt shared by every surface (no markup); the last line names the commands.""" + d = advice.disk + lines = [ + f"Low disk space: {_gb(d.free_bytes)} free of {_gb(d.total_bytes)}. " + f"Your session history (state.db) uses {_gb(d.db_bytes)}.", + f"Auto-prune is off. Turning it on deletes ended sessions inactive for more than " + f"{advice.retention_days} days, once a day (pinned and open sessions are kept).", + ] + if advice.prunable_sessions == 0: + held = (f"none of your {_sessions(advice.total_sessions)} is older than {advice.retention_days} days" + if advice.total_sessions else "there is no session history to delete") + lines.append(f"Pruning will not help right now: {held}. Something else is filling this disk.") + elif not advice.helps: + lines.append(f"Pruning would free only about {_gb(advice.reclaim_bytes)} " + f"({_sessions(advice.prunable_sessions)}), which will not fix this disk on its own.") + else: + lines.append(f"Pruning would free about {_gb(advice.reclaim_bytes)} " + f"({advice.prunable_sessions} of {_sessions(advice.total_sessions)}).") + p = command_prefix + lines.append(f"{p}autoprune on to enable, {p}autoprune off to keep everything (never asked again), " + f"{p}autoprune later to be reminded in a week.") + return lines + + +def decide(enable: bool, home: Optional[Path] = None) -> bool: + """Persist the user's answer as an explicit ``sessions.auto_prune``; ends the prompt for good.""" + import hermes_yaml as yaml + from hermes_constants import mkdir_under_hermes_home + from utils import atomic_roundtrip_yaml_update + path = _home(home) / "config.yaml" + try: + mkdir_under_hermes_home(path.parent) + atomic_roundtrip_yaml_update(path, "sessions.auto_prune", bool(enable)) + except (OSError, ValueError, yaml.YAMLError) as exc: + logger.warning("Could not save sessions.auto_prune: %s", exc) + return False + try: + _snooze_path(home).unlink(missing_ok=True) + except OSError: + pass + return True + + +def run_command(args: str, home: Optional[Path] = None) -> str: + """``/autoprune [on|off|later|status]`` — the one handler behind the CLI, TUI, Desktop and + messaging surfaces.""" + from hermes_cli.config import load_config_readonly + arg = (args or "").strip().lower() + if arg in {"on", "enable", "yes"}: + return ("Auto-prune is on: ended sessions inactive past retention_days are deleted once a day." + if decide(True, home) else "Could not save sessions.auto_prune; check config.yaml permissions.") + if arg in {"off", "disable", "no"}: + return ("Auto-prune is off and you will not be asked again. `hermes sessions prune` still works any time." + if decide(False, home) else "Could not save sessions.auto_prune; check config.yaml permissions.") + if arg in {"later", "snooze"}: + snooze(home) + return "OK, I'll ask again in a week if the disk is still low." + sessions = (load_config_readonly() or {}).get("sessions") or {} + state = "on" if sessions.get("auto_prune") else "off" + chosen = "default" if undecided(home) else "your setting" + snap = disk_snapshot(home) + disk = (f" state.db {_gb(snap.db_bytes)}; {_gb(snap.free_bytes)} free of {_gb(snap.total_bytes)}" + f"{' (low)' if snap.low else ''}.") if snap else "" + return (f"Auto-prune is {state} ({chosen}), retention {sessions.get('retention_days', 90)} days.{disk} " + "Usage: /autoprune on | off | later") + + +def offer_interactive(*, cprint, ask=input) -> Optional[bool]: + """Classic-CLI startup offer (TTY only). Returns the decision, or None when nothing was asked + or the user chose later.""" + advice = advise() + if advice is None: + return None + cprint("") + cprint("☤ " + advice_lines(advice)[0]) + for line in advice_lines(advice)[1:-1]: + cprint(f" {line}") + try: + answer = ask(" Enable auto-prune? [y = enable / n = never prune, don't ask again / Enter = remind me later]: ") + except (KeyboardInterrupt, EOFError): + print() + return None + answer = answer.strip().lower() + if answer in {"y", "yes"}: + decide(True) + cprint(" ✓ sessions.auto_prune: true") + return True + if answer in {"n", "no"}: + decide(False) + cprint(" ✓ sessions.auto_prune: false. You won't be asked again.") + return False + snooze() + cprint(" Later. /autoprune on|off decides it any time.") + return None diff --git a/hermes_cli/slash_exec.py b/hermes_cli/slash_exec.py index ba01c1e11c..83bc0056c9 100644 --- a/hermes_cli/slash_exec.py +++ b/hermes_cli/slash_exec.py @@ -76,6 +76,12 @@ def _exec_profile(ctx: CommandContext) -> CommandReply: data={"profile": profile_name, "home": home_display}) +def _exec_autoprune(ctx: CommandContext) -> CommandReply: + """Core /autoprune text — record the auto-prune decision or show its state.""" + from hermes_cli.prune_advisor import run_command + return CommandReply(run_command(ctx.args)) + + def _exec_bundles(ctx: CommandContext) -> CommandReply: """Core /bundles data — installed skill bundles listing.""" try: @@ -180,6 +186,7 @@ EXECUTORS: dict[str, Callable[[CommandContext], CommandReply]] = { "egress": _exec_egress, "profile": _exec_profile, "bundles": _exec_bundles, + "autoprune": _exec_autoprune, "gateway_help": _exec_help, "gateway_commands": _exec_commands} diff --git a/tests/hermes_cli/test_prune_advisor.py b/tests/hermes_cli/test_prune_advisor.py new file mode 100644 index 0000000000..f8fd21c00c --- /dev/null +++ b/tests/hermes_cli/test_prune_advisor.py @@ -0,0 +1,62 @@ +"""Low-disk auto-prune prompt: asks only while undecided, says when pruning will not help.""" + +import shutil +import time +from collections import namedtuple +from pathlib import Path + +import pytest + +from hermes_cli import prune_advisor + +_Usage = namedtuple("_Usage", "total used free") + + +@pytest.fixture +def home(tmp_path, monkeypatch): + from hermes_state import SessionDB + h = tmp_path / "home" + h.mkdir() + monkeypatch.setenv("HERMES_HOME", str(h)) + db = SessionDB(db_path=h / "state.db") + db.create_session("recent", source="cli") + db.append_message("recent", role="user", content="hi") + db.end_session("recent", "user_exit") + db.close() + return h + + +def _disk(monkeypatch, free_gb, total_gb=100): + gib = prune_advisor.GIB + monkeypatch.setattr(shutil, "disk_usage", lambda _p: _Usage(total_gb * gib, (total_gb - free_gb) * gib, free_gb * gib)) + + +def test_asks_only_when_low_and_undecided_and_either_answer_ends_it(home, monkeypatch): + _disk(monkeypatch, free_gb=50) + assert prune_advisor.should_ask(home) is None # plenty of space: silent + _disk(monkeypatch, free_gb=1) + assert prune_advisor.should_ask(home) is not None + prune_advisor.snooze(home) + assert prune_advisor.should_ask(home) is None # "later" holds for the window + prune_advisor._snooze_path(home).unlink() + for answer in (False, True): + assert prune_advisor.decide(answer, home) + assert (home / "config.yaml").read_text(encoding="utf-8-sig").count("auto_prune") == 1 + assert prune_advisor.should_ask(home) is None # an explicit key ends the question + + +def test_prompt_says_pruning_will_not_help_when_nothing_is_old_enough(home, monkeypatch): + _disk(monkeypatch, free_gb=1) + advice = prune_advisor.advise(home) + assert advice is not None and advice.total_sessions == 1 and advice.prunable_sessions == 0 + assert not advice.helps + assert "Pruning will not help" in "\n".join(prune_advisor.advice_lines(advice)) + # Backdate the session past retention: now it is prunable and the prompt offers it. + import sqlite3 + old = time.time() - 200 * 86400 + with sqlite3.connect(home / "state.db") as conn: + conn.execute("UPDATE sessions SET started_at=?, ended_at=?, last_activity_at=?", (old, old, old)) + conn.execute("UPDATE messages SET timestamp=?", (old,)) + advice = prune_advisor.advise(home) + assert advice.prunable_sessions == 1 + assert "will not help" not in "\n".join(prune_advisor.advice_lines(advice)) diff --git a/tui_gateway/session_registry.py b/tui_gateway/session_registry.py index 46f7752b85..50c69cf062 100644 --- a/tui_gateway/session_registry.py +++ b/tui_gateway/session_registry.py @@ -364,6 +364,31 @@ def _init_session( _schedule_mcp_late_refresh(sid, agent) +_AUTO_PRUNE_NOTIFIED: set[str] = set() + + +def _maybe_notify_auto_prune(sid: str, profile_home: str | None) -> None: + """Low disk + auto-prune never decided: one sticky notice (TUI status line / Desktop toast) + per profile per process, on session create and cold resume. The cheap gate runs inline; + the DB estimate runs off-thread.""" + from hermes_cli import prune_advisor + from hermes_constants import get_hermes_home + home = Path(profile_home) if profile_home else get_hermes_home() + if str(home) in _AUTO_PRUNE_NOTIFIED or prune_advisor.should_ask(home) is None: + return + _AUTO_PRUNE_NOTIFIED.add(str(home)) + + def _work() -> None: + advice = prune_advisor.advise(home) + if advice is not None: + _emit("notification.show", sid, { + "text": "⚠ " + " ".join(prune_advisor.advice_lines(advice)), "level": "warn", + "kind": "sticky", "ttl_ms": None, "key": "sessions.auto_prune", "id": "sessions.auto_prune"}) + + from agent.memory_provider import spawn_context_thread + spawn_context_thread(_work, name="auto-prune-advice").start() + + def _new_session_key() -> str: return new_session_id() @@ -522,6 +547,7 @@ def _schedule_agent_build(sid: str, delay: float = 0.05) -> None: def _run(): if (session := _sessions.get(sid)) is not None: _start_agent_build(sid, session) + _maybe_notify_auto_prune(sid, session.get("profile_home")) timer = threading.Timer(delay, _run) timer.daemon = True timer.start() diff --git a/website/docs/developer-guide/gateway-command-parity.md b/website/docs/developer-guide/gateway-command-parity.md index c34d8f2876..1266472871 100644 --- a/website/docs/developer-guide/gateway-command-parity.md +++ b/website/docs/developer-guide/gateway-command-parity.md @@ -26,13 +26,14 @@ narrower routes (`hermes_cli/gateway_chat_commands.py`). setting to this local session (its next turn and `config.get`), saving config.yaml only where the handler does; `client-local (cli_only)` commands are terminal/UI features of a client. -102 registry commands: 28 control, 18 read, 56 refused. +103 registry commands: 29 control, 18 read, 56 refused. | Command | Gateway (`slash.exec`) | Classic CLI | Ink | Desktop | ACP | Notes | |---|---|---|---|---|---|---| | `/agents` (/tasks) | refused | HermesCLI._handle_agents_command | delegation.pause | slash.exec: refused | refused | gateway-wide: lists every chat's running agents and the process's async jobs | | `/approvals` | control | HermesCLI._handle_approvals_command | slash.exec | slash.exec | refused | per-session setting (gateway handler) | | `/approve` | control | messaging-only: unknown command | slash.exec | slash.exec | refused | messaging-only | +| `/autoprune` | control | HermesCLI._handle_autoprune_command | slash.exec | slash.exec | refused | | | `/battery` | refused | HermesCLI._handle_battery_command | sidecar: system.battery (preference not saved) | slash.exec: refused | refused | client-local (cli_only) | | `/bg` | refused | HermesCLI._handle_background_command | prompt.background | action: background | refused | | | `/blueprint` (/bp) | control | HermesCLI._handle_blueprint_command | slash.exec | slash.exec | refused | | diff --git a/website/docs/user-guide/sessions.md b/website/docs/user-guide/sessions.md index b1bb98c423..3d7bce5f2d 100644 --- a/website/docs/user-guide/sessions.md +++ b/website/docs/user-guide/sessions.md @@ -1140,24 +1140,25 @@ Key tables in `state.db`: - Gateway conversations persist across inactivity; use `/new` or `/reset` for an explicit boundary - Before reset, the agent saves memories and skills from the expiring session -- Auto-pruning (**on by default** since #54189): when `sessions.auto_prune` is `true`, ended sessions inactive for `sessions.retention_days` (default 90) are pruned at CLI/gateway/cron startup +- Auto-pruning (**off by default**; opt in): when `sessions.auto_prune` is `true`, ended sessions inactive for `sessions.retention_days` (default 90) are pruned at CLI/gateway/cron startup +- **Low-disk prompt.** While your `config.yaml` has no `sessions.auto_prune` key, Hermes checks the disk holding `state.db` at session start (two filesystem calls, no measurable cost). When that disk is low (under 2 GB free, or under 10% free with less than 20 GB left) it asks once: the classic CLI asks at startup, the TUI and Desktop show a notice, and a gateway posts the question to its home channel. The prompt estimates what a prune would free and says plainly when pruning will *not* fix the disk (no session older than `retention_days`, or the history is a small share of the full disk). Answer with `/autoprune on`, `/autoprune off` (keep everything; never asked again) or `/autoprune later` (reminded in a week); `/autoprune` alone shows the current state - `sessions.retention_days` must be a whole number of days `>= 0`. A negative value (or a missing one) is rejected: startup maintenance logs a warning naming the allowed range and skips the sweep instead of treating the future cutoff as "everything" — `sessions.auto_prune: false` is the switch that disables pruning - After a prune that actually removed rows, `state.db` is `VACUUM`ed to reclaim disk space only when **both** gates pass: at least `sessions.min_vacuum_interval_days` (default 30) have elapsed since the last successful `VACUUM`, **and** more than 25% of the file's pages are reclaimable (`PRAGMA freelist_count / page_count`). A dense database never pays for a full rewrite to reclaim a few MB (SQLite does not shrink the file on plain DELETE) - Pruning runs at most once per `sessions.min_interval_hours` (default 24); the last-run timestamp is tracked inside `state.db` itself so it's shared across every Hermes process in the same `HERMES_HOME` -Without pruning, `state.db` grows without bound — multi-GB files within weeks were reported on gateway + cron installs. If you would rather keep every ended session forever (the pre-#54189 behavior), turn it off in `~/.hermes/config.yaml`: +Without pruning, `state.db` grows without bound — multi-GB files within weeks were reported on gateway + cron installs. To bound it, turn it on in `~/.hermes/config.yaml` (or answer `/autoprune on`): ```yaml sessions: - auto_prune: false # default is true — set false to keep all history + auto_prune: true # default is false — true deletes ended sessions past retention_days retention_days: 90 # keep ended sessions active within this window vacuum_after_prune: true # reclaim disk space after a pruning sweep min_vacuum_interval_days: 30 # don't rewrite the DB more often than this min_interval_hours: 24 # don't re-run the sweep more often than this ``` -Existing installs that already set any of these keys explicitly keep their -values; only unset keys pick up the new defaults. +Installs that set any of these keys explicitly keep their values; only unset +keys follow the defaults. Only **ended** sessions are ever deleted. Active sessions are never auto-pruned, regardless of age. Ended sessions are aged from their last activity — the @@ -1232,5 +1233,5 @@ hermes sessions prune --older-than 30 --yes ``` :::tip -Auto-prune is **on by default**: ended sessions that have been inactive for `sessions.retention_days` (default 90) are removed at startup, and active sessions are never touched (see [Automatic Cleanup](#automatic-cleanup) above). Session history powers `session_search` recall across past conversations, so if you want to keep every ended session forever, set `sessions.auto_prune: false` in `config.yaml`, or raise `retention_days`. With auto-prune off, `hermes sessions prune` remains available for one-off cleanup (observed failure mode without any pruning: a 384 MB `state.db` with ~1000 sessions slowing down FTS5 inserts and `/resume` listing). +Auto-prune is **off by default**, and Hermes asks you once when the disk holding `state.db` runs low (see [Automatic Cleanup](#automatic-cleanup) above). When on, ended sessions inactive for `sessions.retention_days` (default 90) are removed at startup and active sessions are never touched. Session history powers `session_search` recall across past conversations, so the choice is yours: `/autoprune on|off`, or `sessions.auto_prune` in `config.yaml`. With auto-prune off, `hermes sessions prune` remains available for one-off cleanup (observed failure mode without any pruning: a 384 MB `state.db` with ~1000 sessions slowing down FTS5 inserts and `/resume` listing). :::