feat(sessions): auto-prune is opt-in again; ask once when the disk runs low

sessions.auto_prune goes back to false by default. Deleting history is the
user's call, but an install that never decides still needs to hear about it
when the disk holding state.db runs low, or state.db grows until the disk
fills (the reason #54189 turned it on).

While the user's config.yaml has no sessions.auto_prune key, every surface
asks once the disk is low (< 2 GiB free, or < 10% free with < 20 GiB left):

- classic CLI: a y / n / Enter(later) question at startup (TTY only)
- TUI and Desktop: one sticky notification.show per profile per process
- gateway: the question on each served profile's home channel, at most
  once per 7-day snooze window
- /autoprune on|off|later|status answers it on every surface (registry
  executor, so the text is identical); on/off writes the explicit key and
  the question never comes back, later snoozes a week

The prompt estimates what a prune would free (read-only count of ended
sessions past retention_days, scaled by their share of messages) and says
plainly when pruning will not fix the disk: no session old enough, or only
a small amount reclaimable.

The hot-path check is os.stat + shutil.disk_usage + one small YAML read;
the DB estimate only runs once that gate fires, off the prompt thread on
TUI/Desktop.

gateway/run.py and tui_gateway stay under their line caps: the media-cache
chore moves to the new gateway/run_housekeeping_disk.py with the prompt
chore.
This commit is contained in:
Teknium committed 2026-10-16 13:56:20 -07:00
1 parent f6e272304e
commit 968c87f6bc
17 files changed
+495 -53

No files matched your search

+8 -7
View File
@@ -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
+4 -25
View File
@@ -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:
+1 -1
View File
@@ -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 = (
+91
View File
@@ -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
+1 -1
View File
@@ -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_<name>``,
# gateway/run_inbound.py): the rewritten text is returned as the client's ``send`` directive.
_PROMPT_COMMANDS = frozenset({'plan', 'learn', 'init', 'moa', 'blueprint', 'egress'})
+4
View File
@@ -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
+5
View File
@@ -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
+7
View File
@@ -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()
+3
View File
@@ -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",
+3 -1
View File
@@ -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:
+7 -11
View File
@@ -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.
+257
View File
@@ -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
+7
View File
@@ -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}
+62
View File
@@ -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))
+26
View File
@@ -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()
@@ -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 | |
+7 -6
View File
@@ -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).
:::