Files
hermes-agent/hermes_cli/cli_session_mixin.py
teknium1 5816a768e4 fix(telemetry): CLI /undo N counts the user turns undo_last really removed
_cmd_undo counted role=="user" rows of the old history, so a compaction
handoff (role user, not a turn) added a phantom wasted_tokens row with
tokens_bucket=unknown. The TUI and gateway already pass the real
turns_undone. undo_last now returns turns_undone (None when nothing was
rewound) and _cmd_undo records exactly that.

Probe probe_cli_undo.py (history = handoff + 2 real turns, /undo 5):
  before: wasted_tokens 10k_to_50k x2 + unknown x1 (3 rows)
  after:  wasted_tokens 10k_to_50k x2 (2 rows)
Test test_cli_undo_n_counts_the_user_turns_actually_undone_not_compaction_handoffs
is RED on base (3 == 2), green here.
2026-09-28 12:43:03 -07:00

1193 lines
58 KiB
Python

"""Session lifecycle for the interactive CLI: new/resume/save, undo/retry rewinds, yolo
persistence, manual compression, and exit summary.
Mixin bound onto ``HermesCLI`` via the MRO. cli.py-internal symbols are imported LAZILY
inside each method (``from cli import ...``) — never at module load time (import cycle).
"""
from __future__ import annotations
import contextlib
import os
import shutil
import sys
from hermes_constants import get_hermes_home
from hermes_state_ids import new_session_id
from pathlib import Path
from rich.console import Console
from rich.markup import escape as _escape
from typing import Any, Dict, List, Optional
def _user_turn_indices(history: list) -> list[int]:
"""Indices of *real* user turns: excludes ephemeral scaffolding, display_kind timeline
rows and compaction handoffs — the same predicate as resume turn counting."""
from agent.context_compressor import user_originated_turn_view
from agent.session_persistence import _is_ephemeral_scaffolding
return [
i for i, m in enumerate(history)
if not _is_ephemeral_scaffolding(m) and user_originated_turn_view(m) is not None]
def _timestamp_or(value, default):
"""``datetime.fromtimestamp(value)`` or *default* when the value is missing/unparseable."""
from cli import datetime
if not value:
return default
try:
return datetime.fromtimestamp(float(value))
except Exception:
return default
def _squash(text: str, limit: int = 120) -> str:
"""Collapse whitespace and cap to *limit* cells with an ellipsis."""
text = " ".join(text.split())
return text[: limit - 1] + "…" if len(text) > limit else text
def _dim_notice(cli, msg: str, quiet: bool) -> None:
"""Print a dim notice — raw to stderr on quiet (pre-TUI) paths. Module-level so tests can
call the resume helpers unbound against a minimal stand-in."""
if quiet:
print(msg, file=sys.stderr)
else:
cli._console_print(f"[dim]{_escape(msg)}[/dim]")
def _reset_model_to_config_default(cli, silent: bool) -> None:
"""/new is a full boundary: re-derive model/provider from config.yaml so a
session-only ``/model --session`` switch never leaks into the next session.
Best-effort — an unreachable default must never block /new. Module-level helper (like
``_apply_new_session_title``): tests drive ``new_session`` unbound on a SimpleNamespace."""
from cli import CLI_CONFIG, _cprint, _split_model_config_default, logger
_model_config = CLI_CONFIG.get("model", {})
if isinstance(_model_config, dict):
_raw_default = _model_config.get("default") or _model_config.get("model") or ""
_config_provider = _model_config.get("provider", "")
else:
_raw_default, _config_provider = (_model_config or ""), ""
_config_model, _ = _split_model_config_default(_raw_default)
if not _config_model or _config_model == getattr(cli, "model", None):
return
try:
from hermes_cli.model_switch import switch_model as _switch_model
r = _switch_model(
raw_input=_config_model,
current_provider=cli.provider or "",
current_model=cli.model or "",
current_base_url=cli.base_url or "",
current_api_key=cli.api_key or "",
is_global=False,
explicit_provider=_config_provider or "")
if not r.success:
return
if cli.agent:
cli.agent.switch_model(
new_model=r.new_model, new_provider=r.target_provider, api_key=r.api_key,
base_url=r.base_url, api_mode=r.api_mode,
capabilities=getattr(r, "runtime_capabilities", None))
cli.model = r.new_model
cli.provider = r.target_provider
cli.requested_provider = r.target_provider
cli._explicit_api_key = r.api_key
cli._explicit_base_url = r.base_url
if r.api_key:
cli.api_key = r.api_key
if r.base_url:
cli.base_url = r.base_url
if r.api_mode:
cli.api_mode = r.api_mode
if not silent:
_cprint(f" (model reset to config default: {r.new_model})")
except Exception:
logger.debug("/new model reset to config default failed", exc_info=True)
def _apply_new_session_title(cli, title: str) -> Optional[str]:
"""Sanitize + persist a /new title; returns the stored title or None (untitled)."""
from cli import _cprint
from hermes_state import SessionDB
try:
sanitized = SessionDB.sanitize_title(title)
except ValueError as e:
_cprint(f" Title rejected: {e}")
return None
if not sanitized:
_cprint(" Title is empty after cleanup — session started untitled.")
return None
try:
cli._session_db.set_session_title(cli.session_id, sanitized)
except ValueError as e:
_cprint(f" {e} — session started untitled.")
return None
except Exception:
return None
cli._pending_title = None
cli._status_bar_title_checked_at = 0.0
return sanitized
class CLISessionMixin:
"""Session lifecycle for the interactive CLI: new/resume/save, undo/retry rewinds, yolo
persistence, manual compression, and exit summary."""
def _restore_session_cwd(self, session_meta: dict, *, quiet: bool = False) -> None:
"""Relaunch a resumed session in the directory it was started from.
Idempotent; called from every resume path. Both ``os.chdir()`` and ``TERMINAL_CWD``
are retargeted so the process and the terminal/code-exec tools agree (the local
terminal backend snapshots cwd on first use, after this). No-op when no cwd was
recorded, the directory is gone (dim warning, never a crash), or we're already there.
"""
recorded = (session_meta or {}).get("cwd")
if not recorded:
return
recorded = os.path.expanduser(str(recorded))
try:
current = os.getcwd()
except OSError:
current = None
if current and os.path.realpath(recorded) == os.path.realpath(current):
return
if not os.path.isdir(recorded):
msg = f"⚠ Session's working directory is gone: {recorded} — staying in {current or '.'}"
else:
try:
os.chdir(recorded)
os.environ["TERMINAL_CWD"] = recorded
msg = f"↻ Working directory: {recorded}"
except OSError as e:
msg = f"⚠ Could not enter session's working directory {recorded}: {e}"
_dim_notice(self, msg, quiet)
def _restore_session_yolo(self, session_meta: dict, *, quiet: bool = False) -> None:
"""Re-enable YOLO bypass on resume when the session row's ``model_config.yolo_mode``
says so — the in-memory ``tools.approval._session_yolo`` set starts empty in a fresh
process. No-op when already active or when the process was launched with ``--yolo``."""
try:
from hermes_state import SessionDB
from tools.approval import (
_YOLO_MODE_FROZEN, enable_session_yolo, is_session_yolo_enabled)
except Exception:
return
if _YOLO_MODE_FROZEN or not SessionDB.session_yolo_enabled(session_meta):
return
session_key = self.session_id or "default"
if is_session_yolo_enabled(session_key):
return
enable_session_yolo(session_key)
_dim_notice(self,
"⚡ YOLO mode restored from session — all commands auto-approved. /yolo to turn off.",
quiet)
def _render_resume_history_panel_lines(self, panel) -> list[str]:
"""Render the resume panel at the current terminal width for resize replay."""
from cli import _suspend_output_history
from io import StringIO
buf = StringIO()
console = Console(
file=buf, force_terminal=True, color_system="truecolor", highlight=False,
width=shutil.get_terminal_size((80, 24)).columns)
with _suspend_output_history():
console.print(panel)
return buf.getvalue().rstrip("\n").splitlines()
def _resolve_checkpoint_ref(self, ref: str, checkpoints: list) -> str | None:
"""Resolve a 1-indexed checkpoint number (or pass through a git hash)."""
try:
idx = int(ref) - 1
except ValueError:
return ref
if 0 <= idx < len(checkpoints):
return checkpoints[idx]["hash"]
print(f" Invalid checkpoint number. Use 1-{len(checkpoints)}.")
return None
def _show_status(self):
"""Show compact startup status line."""
from cli import get_tool_definitions
# Avoid pulling the full tool registry into the bare Termux prompt path.
if os.environ.get("HERMES_DEFER_AGENT_STARTUP") == "1":
tool_status = "tools deferred"
else:
tools = get_tool_definitions(enabled_toolsets=self.enabled_toolsets,
disabled_toolsets=self.disabled_toolsets, quiet_mode=True)
tool_status = f"{len(tools) if tools else 0} tools"
model_short = self.model.split("/")[-1] if "/" in self.model else self.model
if len(model_short) > 30:
model_short = model_short[:27] + "..."
api_indicator = "[green bold]●[/]" if self.api_key else "[red bold]●[/]"
try:
from hermes_cli.skin_engine import get_active_skin
skin = get_active_skin()
separator_color = skin.get_color("banner_dim", "#B8860B")
accent_color = skin.get_color("ui_accent", "#FFBF00")
label_color = skin.get_color("ui_label", "#DAA520")
except Exception:
separator_color, accent_color, label_color = "#B8860B", "#FFBF00", "cyan"
sep = f" [dim {separator_color}]·[/] "
toolsets_info = ""
if self.enabled_toolsets and "all" not in self.enabled_toolsets:
toolsets_info = f"{sep}[{label_color}]toolsets: {', '.join(self.enabled_toolsets)}[/]"
provider_info = f"{sep}[dim]provider: {self.provider}[/]"
if self._provider_source:
provider_info += f"{sep}[dim]auth: {self._provider_source}[/]"
self._console_print(
f" {api_indicator} [{accent_color}]{model_short}[/]{sep}"
f"[bold {label_color}]{tool_status}[/]{toolsets_info}{provider_info}")
def _show_session_status(self):
"""Show gateway-style status for the current CLI session."""
from hermes_cli.status_report import build_status_fields, status_lines
session_meta = {}
if self._session_db:
with contextlib.suppress(Exception):
session_meta = self._session_db.get_session(self.session_id) or {}
agent = getattr(self, "agent", None)
fields = build_status_fields(
self.session_id, agent, session_meta,
model=getattr(self, "model", None), provider=getattr(self, "provider", None),
created_fallback=self.session_start, agent_running=bool(getattr(self, "_agent_running", False)),
)
reasoning_label = None
rc = getattr(agent, "reasoning_config", None) or getattr(self, "reasoning_config", None)
if isinstance(rc, dict):
if rc.get("enabled") is False:
reasoning_label = "off"
elif rc.get("effort"):
reasoning_label = str(rc.get("effort"))
show_r = getattr(self, "show_reasoning", None)
if reasoning_label and show_r is not None:
reasoning_label += f" (display: {'on' if show_r else 'off'})"
approval_label = None
try:
from tools.approval import is_approval_bypass_active_for_session
from tools.approval_context import _get_approval_mode
approval_label = _get_approval_mode()
if is_approval_bypass_active_for_session(getattr(self, "session_key", "") or ""):
approval_label += " (YOLO bypass active)"
except Exception:
pass
# Context window usage: reuse the status-bar snapshot (tokens / max / percent).
ctx_label = None
try:
snap = self._get_status_bar_snapshot()
ctx_max = snap.get("context_length")
ctx_pct = snap.get("context_percent")
if ctx_max:
left = ""
if isinstance(ctx_pct, (int, float)):
left = f"{max(0, 100 - int(ctx_pct))}% left · "
ctx_label = f"{left}{snap.get('context_tokens') or 0:,} / {ctx_max:,} tokens used"
except Exception:
ctx_label = None
lines = ["Hermes CLI Status", "", *status_lines(fields, "session_id", "path", "title", "model")]
try:
from agent.i18n import t
from hermes_cli.auth import resolve_provider
from hermes_cli.anon_auth import guest_carries_inference
if resolve_provider("auto") == "nous" and guest_carries_inference():
lines.append(t("gateway.status.free_tier"))
except Exception:
pass
optional = (("Reasoning", reasoning_label), ("Approvals", approval_label), ("Context", ctx_label))
for label, value in optional:
if value:
lines.append(f"{label}: {value}")
lines.extend(status_lines(fields, "created", "last_activity", "tokens", "agent_running"))
self._console_print("\n".join(lines), highlight=False, markup=False)
def _list_recent_sessions(self, limit: int = 10) -> list[dict[str, Any]]:
"""Return recent CLI sessions for in-chat browsing/resume affordances."""
if not self._session_db:
return []
try:
from hermes_cli.session_listing import query_session_listing
from hermes_state_sessions import INTERNAL_LISTING_SOURCES
return query_session_listing(
self._session_db, source="cli", current_session_id=self.session_id,
include_all_sources=False, include_unnamed=True, limit=limit,
exclude_sources=list(INTERNAL_LISTING_SOURCES))
except Exception:
return []
def _show_recent_sessions(self, *, reason: str = "history", limit: int = 10) -> bool:
"""Render recent sessions inline from the active chat TUI.
Returns True when something was shown, False if no session list was available.
"""
from cli import _cli_visible_print
sessions = self._list_recent_sessions(limit=limit)
if not sessions:
return False
from hermes_cli.timefmt import relative_time as _relative_time
_cli_visible_print()
if reason == "history":
_cli_visible_print("(._.) No messages in the current chat yet — here are recent sessions you can resume:")
else:
_cli_visible_print(" Recent sessions:")
_cli_visible_print()
_cli_visible_print(f" {'#':<3} {'Title':<32} {'Preview':<40} {'Last Active':<13} {'ID'}")
_cli_visible_print(f" {'─' * 3} {'─' * 32} {'─' * 40} {'─' * 13} {'─' * 24}")
for idx, session in enumerate(sessions, start=1):
title = session.get("title") or "—"
preview = (session.get("preview") or "")[:38]
last_active = _relative_time(session.get("last_active"), session_id=session.get("id"))
_cli_visible_print(f" {idx:<3} {title:<32} {preview:<40} {last_active:<13} {session['id']}")
_cli_visible_print()
_cli_visible_print(" Use /resume <number>, /resume <session id>, or /resume <session title> to continue.")
_cli_visible_print(" Example: /resume 2")
_cli_visible_print()
return True
def show_history(self):
"""Display conversation history."""
from cli import _cli_visible_print
if not self.conversation_history:
if not self._show_recent_sessions(reason="history"):
_cli_visible_print("(._.) No conversation history yet.")
return
preview_limit = 400
visible_index = 0
hidden_tool_messages = 0
show_ts = bool(getattr(self, "show_timestamps", False))
def _ts_suffix(message: dict) -> str:
# Only annotate when the toggle is on AND the turn has a stored unix
# `timestamp` (SessionDB-restored rows do; live turns may not) — never fabricate.
ts = message.get("timestamp") if show_ts else None
if not ts:
return ""
try:
stamp = _timestamp_or(ts, None)
return f" [{stamp.strftime(getattr(self, 'timestamp_format', '%H:%M'))}]" if stamp else ""
except (ValueError, OSError, TypeError):
return ""
def flush_tool_summary():
nonlocal hidden_tool_messages
if not hidden_tool_messages:
return
noun = "message" if hidden_tool_messages == 1 else "messages"
_cli_visible_print("\n [Tools]")
_cli_visible_print(f" ({hidden_tool_messages} tool {noun} hidden)")
hidden_tool_messages = 0
rule = "+" + "-" * 50 + "+"
for line in ("", rule, "|" + " " * 12 + "(^_^) Conversation History" + " " * 11 + "|", rule):
_cli_visible_print(line)
for msg in self.conversation_history:
role = msg.get("role", "unknown")
if role == "tool":
hidden_tool_messages += 1
continue
if role not in {"user", "assistant"}:
continue
flush_tool_summary()
visible_index += 1
content = msg.get("content")
content_text = "" if content is None else str(content)
preview = content_text[:preview_limit]
suffix = "..." if len(content_text) > preview_limit else ""
if role == "user":
_cli_visible_print(f"\n [You #{visible_index}]{_ts_suffix(msg)}")
_cli_visible_print(f" {preview}{suffix}")
continue
_cli_visible_print(f"\n [Hermes #{visible_index}]{_ts_suffix(msg)}")
n_calls = len(msg.get("tool_calls") or [])
if not content_text:
suffix = ""
preview = "(no text response)"
if n_calls:
preview = f"(requested {n_calls} tool {'call' if n_calls == 1 else 'calls'})"
_cli_visible_print(f" {preview}{suffix}")
flush_tool_summary()
_cli_visible_print()
def _notify_session_boundary(self, event_type: str) -> None:
"""Fire a session-boundary plugin hook (on_session_finalize / on_session_reset).
Non-blocking; errors swallowed. Safe from shutdown, /new, /reset."""
with contextlib.suppress(Exception):
from hermes_cli.lifecycle import finalize_session, invoke_hook
context = {
"session_id": self.agent.session_id if self.agent else None,
"platform": getattr(self, "platform", None) or "cli",
"reason": "new_session" if event_type == "on_session_reset" else "session_boundary"}
if event_type == "on_session_finalize":
finalize_session(**context)
else:
invoke_hook(event_type, **context)
def _discard_session_if_empty(self, session_id: Optional[str]) -> bool:
"""Drop a just-ended session row that never gained content (quit-immediately, /new,
/clear) so it doesn't clutter ``/resume``. ``SessionDB.delete_session_if_empty`` only
removes rows with no messages, no title and no children (gemini-cli#27770 port)."""
from cli import logger
if not self._session_db or not session_id:
return False
# In-memory transcript is authoritative: a real conversation whose DB flush failed
# or hasn't happened yet must never be pruned.
if getattr(self, "conversation_history", None):
return False
try:
from hermes_constants import get_hermes_home as _ghh
return self._session_db.delete_session_if_empty(
session_id, sessions_dir=_ghh() / "sessions")
except Exception:
logger.debug("Could not prune empty session %s", session_id, exc_info=True)
return False
def _launch_session_boundary_memory_flush(
self, history_snapshot: list, *, session_id: Optional[str] = None) -> Optional[list]:
"""Stage old-session memory extraction so /new stays responsive.
The context-engine ``on_session_end`` is delivered synchronously here: cheap (no LLM)
and ordering-sensitive — it must land before ``reset_session_state()`` rebinds the
engine. The memory-provider half (LLM-bound, seconds) is NOT run here: the returned
snapshot goes to ``MemoryManager.commit_session_boundary_async`` as one end→switch
task on the serialized worker, so a late ``on_session_end`` can never run after
``on_session_switch`` and misattribute the old transcript to the new session.
Returns the snapshot to queue, or ``None`` when there is nothing to extract.
"""
from cli import logger
agent = getattr(self, "agent", None)
if not agent or not history_snapshot:
return None
engine = getattr(agent, "context_compressor", None)
if engine is not None and hasattr(engine, "on_session_end"):
try:
engine.on_session_end(session_id or "", history_snapshot)
except Exception:
logger.debug("Context engine on_session_end failed at /new boundary", exc_info=True)
# No memory manager → new_session() falls back to the inline switch path.
if getattr(agent, "_memory_manager", None) is None:
return None
return history_snapshot
def new_session(self, silent=False, title=None):
"""Start a fresh session with a new session ID and cleared agent state."""
from cli import (
CLI_CONFIG, _parse_service_tier_config,
_sync_process_session_id, datetime)
from hermes_cli.cli_model_switch_mixin import _resolve_cli_reasoning
old_session_id = self.session_id
_boundary_snapshot = None
if self.agent:
if self.conversation_history:
# Context-engine boundary now; provider extraction is queued below (after
# rotation) so /new never blocks on the LLM-bound call.
_boundary_snapshot = self._launch_session_boundary_memory_flush(
list(self.conversation_history), session_id=old_session_id)
self._notify_session_boundary("on_session_finalize")
if self._session_db and old_session_id:
# /new can arrive mid-turn before _flush_messages_to_session_db() ran — flush
# the current turn to the OLD session before rotating or it is silently lost.
if self.agent:
with contextlib.suppress(Exception):
# Flush any un-persisted messages from the current turn to the old session *before*
# rotating. /new can be called mid-turn when _flush_messages_to_session_db() has not
# yet run — without this, messages generated during the current turn are silently lost
# on session rotation (#47202).
# See #47202.
# See #47202.
self.agent._flush_messages_to_session_db(
self.conversation_history, conversation_history=self.conversation_history)
with contextlib.suppress(Exception):
self._session_db.end_session(old_session_id, "new_session")
self._discard_session_if_empty(old_session_id)
self.session_start = datetime.now()
self.session_id = new_session_id(self.session_start)
# getattr: tests drive new_session unbound against a SimpleNamespace stand-in.
getattr(self, "_write_terminal_breadcrumb", lambda: None)()
self.conversation_history = []
self._pending_title = None
self._resumed = False
# An explicit -m/--model was for the previous session only.
self._explicit_model_override = False
# Session-scoped overrides (/model --session, /fast, one-turn restores) don't carry over.
# Re-derive model/provider and service tier from config.yaml so a session-only switch never leaks
# into the next session (#48055, #23131).
self._pending_one_turn_model_restore = None
self.service_tier = _parse_service_tier_config(CLI_CONFIG["agent"].get("service_tier", ""))
_reset_model_to_config_default(self, silent)
# After the model reset: the effort belongs to the model the fresh session lands on (a /reasoning
# session override is dropped, the default model's per-model override is kept).
_resolve_cli_reasoning(self)
_sync_process_session_id(self.session_id)
if self.agent:
self.agent.session_id = self.session_id
self.agent.session_start = self.session_start
self.agent.reasoning_config = self.reasoning_config
self.agent.reset_session_state()
if hasattr(self.agent, "_last_flushed_db_idx"):
self.agent._last_flushed_db_idx = 0
if hasattr(self.agent, "_todo_store"):
with contextlib.suppress(Exception):
from tools.todo_tool import TodoStore
self.agent._todo_store = TodoStore()
if hasattr(self.agent, "_invalidate_system_prompt"):
self.agent._invalidate_system_prompt()
if self._session_db:
with contextlib.suppress(Exception):
self.agent._session_db_created = False
self._session_db.create_session(
session_id=self.session_id,
source=os.environ.get("HERMES_SESSION_SOURCE", "cli"),
model=self.model,
model_config={
"max_iterations": self.max_turns, "reasoning_config": self.reasoning_config,
})
self.agent._session_db_created = True
if title:
title = _apply_new_session_title(self, title)
# Tell memory providers the session_id rotated (reset=True flushes per-session
# state) BEFORE the plugin on_session_reset hook. With old history, end-of-session
# extraction and this switch are queued as ONE task on the serialized worker —
# end strictly before switch, without blocking /new. No history → switch inline.
_mm = getattr(self.agent, "_memory_manager", None)
with contextlib.suppress(Exception):
if _mm is not None and _boundary_snapshot:
_mm.commit_session_boundary_async(
_boundary_snapshot, new_session_id=self.session_id,
parent_session_id=old_session_id or "", reason="new_session")
elif _mm is not None:
_mm.on_session_switch(
self.session_id, parent_session_id=old_session_id or "",
reset=True, reason="new_session")
self._notify_session_boundary("on_session_reset")
if not silent:
if title:
print(f"(^_^)v New session started: {title}")
else:
print("(^_^)v New session started!")
def _consume_pending_resume_selection(self, text: str) -> bool:
"""Resolve a bare numeric reply following a bare ``/resume`` prompt.
``/resume`` (no args) arms ``self._pending_resume_sessions``; the next input gets one
chance to be a bare session number. The pending state is one-shot — cleared on the
first input regardless of outcome, so a stray later number is never hijacked.
Returns True if the input was consumed (caller must not treat it as chat).
See #34584.
"""
from cli import _cprint
pending = self._pending_resume_sessions
if not pending:
return False
self._pending_resume_sessions = None
if not isinstance(text, str):
return False
# Only a pure number selects; "/resume 3", titles etc. fall through.
if not text.strip().isdigit():
return False
index = int(text.strip())
if not 1 <= index <= len(pending):
_cprint(f" Resume index {index} is out of range.")
_cprint(" Use /resume with no arguments to see available sessions.")
return True
self._handle_resume_command(f"/resume {index}")
return True
def save_conversation(self, cmd: str = "/save"):
"""Handle ``/save [json|md|html] [filename] [redact]``.
A convenience export only — every message is already persisted to the session DB, so
the live session stays resumable regardless. ``redact`` runs the force-mode secret
redaction pass before writing.
"""
from cli import datetime
from hermes_cli.session_export import (
SAVE_TRANSCRIPT_FORMATS, SAVE_USAGE, normalize_save_format, render_session_for_save)
parts = cmd.split()[1:]
redact = bool(parts) and parts[-1].lower() in ("redact", "--redact")
if redact:
parts = parts[:-1]
if not parts:
print(SAVE_USAGE)
return
try:
fmt = normalize_save_format(parts[0])
except ValueError as e:
print(f"(._.) {e}")
print(SAVE_USAGE)
return
filename = parts[1] if len(parts) > 1 else None
# Prefer the durable DB row (metadata + tool calls); fall back to in-memory history.
# getattr: test doubles may not carry _session_db / session_id.
session_data = None
_db = getattr(self, "_session_db", None)
_sid = getattr(self, "session_id", None)
if _db and _sid:
try:
session_data = _db.export_session(_sid, include_compacted=fmt in SAVE_TRANSCRIPT_FORMATS)
except Exception:
session_data = None
if not session_data:
if not self.conversation_history:
print("(;_;) No conversation to save.")
return
session_data = {
"id": self.session_id, "model": self.model,
"started_at": self.session_start.timestamp(), "messages": self.conversation_history}
if redact:
from hermes_cli.session_export_md import redact_session_data
session_data = redact_session_data(session_data)
saved_dir = get_hermes_home() / "sessions" / "saved"
try:
saved_dir.mkdir(parents=True, exist_ok=True)
except Exception as e:
print(f"(x_x) Failed to create save directory {saved_dir}: {e}")
return
if filename:
path = Path(filename).expanduser()
if not path.is_absolute():
path = Path.cwd() / path
else:
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
path = saved_dir / f"hermes_conversation_{timestamp}.{fmt}"
try:
content = render_session_for_save(session_data, fmt)
with open(path, "w", encoding="utf-8") as f:
f.write(content)
label = {"json": "JSON", "md": "Markdown", "html": "HTML"}[fmt]
print(f"(^_^)v Conversation saved to: {path} ({label})")
# #76354 review F5: the worker thread also rebound the session ContextVar inside its own
# (copied) context, which the caller never sees — and get_session_env() prefers an already-bound
# ContextVar over os.environ. Rebind in the CALLER's context so post-compression
# tools/subprocesses on this thread resolve HERMES_SESSION_ID to the child id after an
# out-of-place rotation (idempotent when no rotation happened).
if self.session_id:
print(f" Resume the live session with: hermes --resume {self.session_id}")
except Exception as e:
print(f"(x_x) Failed to save: {e}")
def _publish_truncated_history(self, truncated: list, *, invalidate_prompt: bool) -> None:
"""Install a rewound history and mirror it onto the agent (flush index reset so the
next turn re-flushes from the truncated head)."""
self.conversation_history = truncated
agent = self.agent
if agent is None:
return
if invalidate_prompt and hasattr(agent, "_invalidate_system_prompt"):
with contextlib.suppress(Exception):
agent._invalidate_system_prompt()
if hasattr(agent, "_last_flushed_db_idx"):
with contextlib.suppress(Exception):
agent._last_flushed_db_idx = len(self.conversation_history)
if hasattr(agent, "_session_messages"):
agent._session_messages = self.conversation_history
if hasattr(agent, "_db_flush_scan_prefix"):
agent._db_flush_scan_prefix = self.conversation_history[:]
def retry_last(self):
"""Retry the last user message: drop the last exchange and return the text to re-send
(None when there is nothing to retry)."""
if not self.conversation_history:
print("(._.) No messages to retry.")
return None
from agent.context_compressor import (
history_before_user_originated_turn, retryable_user_text)
from agent.memory_manager import sanitize_context
warm_history = list(self.conversation_history)
user_indices = _user_turn_indices(warm_history)
if not user_indices:
print("(._.) No user message found to retry.")
return None
# Resolve a lossless live payload before touching persistence or memory. A
# force-user-leading compaction row is one physical carrier: its handoff stays in
# the prefix while only the embedded human ask is retried. Media cannot be replayed
# by /retry, so fail closed before archiving anything.
try:
truncated, live_view = history_before_user_originated_turn(
warm_history, user_indices[-1])
live_content = live_view.get("content")
if isinstance(live_content, str):
live_content = sanitize_context(live_content).strip()
last_message = retryable_user_text(live_content)
except ValueError as exc:
print(f"(._.) Cannot retry that message safely: {exc}")
return None
# Persist the rewind before publishing the shorter in-memory view: the DB owns the
# physical carrier split so archived original + retained scaffold commit atomically.
if self._session_db is not None and self.session_id:
try:
truncated = self._session_db.rewind_user_turn(
self.session_id, -1, warm_history=warm_history, require_retryable=True).prefix
except Exception as exc:
print(f"(x_x) Retry rewind failed; history was not changed: {exc}")
return None
self._publish_truncated_history(truncated, invalidate_prompt=False)
print(f"(^_^)b Retrying: \"{last_message[:60]}{'...' if len(last_message) > 60 else ''}\"")
return last_message
def undo_last(self, n: int = 1, prefill: bool = True):
"""Back up N user turns: truncate history, soft-delete on disk, prefill the composer.
Discards everything from the Nth-from-last user message onward (clamped to the oldest
turn). Rows are soft-deleted in SessionDB (``active=0``, kept for audit), memory
providers get ``on_session_switch(rewound=True)``, and the agent is patched like
/branch does. ``prefill=False`` is for programmatic callers (checkpoint rollback)
that must not touch the input buffer. Returns the number of user turns undone (None when
nothing changed).
"""
from cli import logger
if not self.conversation_history:
print("(._.) No messages to undo.")
return
n = max(n, 1)
from agent.context_compressor import history_before_user_originated_turn
warm_history = list(self.conversation_history)
user_indices = _user_turn_indices(warm_history)
if not user_indices:
print("(._.) No user message found to undo.")
return
turns_undone = min(n, len(user_indices))
target_ordinal = len(user_indices) - turns_undone
cut_idx = user_indices[target_ordinal]
removed_count = len(warm_history) - cut_idx
truncated, live_view = history_before_user_originated_turn(warm_history, cut_idx)
removed_text = self._undo_content_to_text(live_view.get("content"))
rewound_rows = 0
if self._session_db is not None and self.session_id:
try:
outcome = self._session_db.rewind_user_turn(
self.session_id, target_ordinal, warm_history=warm_history)
truncated = outcome.prefix
# Canonical editable prefill: the raw carrier holds the reference-summary wrapper.
removed_text = outcome.live_text or removed_text
rewound_rows = outcome.rewound_count
except Exception as e:
logger.debug("undo: durable rewind failed: %s", e)
print(f"(x_x) Undo failed; history was not changed: {e}")
return
# Publish only after the durable rewind succeeds (or no store exists).
self._publish_truncated_history(truncated, invalidate_prompt=True)
# Same hook /branch fires; rewound=True invalidates per-turn document caches.
_mm = getattr(self.agent, "_memory_manager", None)
# See #21910, #6672.
if _mm is not None and self.session_id:
with contextlib.suppress(Exception):
_mm.on_session_switch(self.session_id, parent_session_id="", reset=False, rewound=True)
turn_word = "turn" if turns_undone == 1 else "turns"
print(
f"(^_^)b Undid {turns_undone} {turn_word} ({rewound_rows or removed_count} message(s)). "
f"Backed up to: \"{removed_text[:60]}{'...' if len(removed_text) > 60 else ''}\"")
print(f" {len(self.conversation_history)} message(s) remaining in history.")
# Editable, not auto-sent (Claude-Code-style).
if prefill and removed_text:
self._prefill_input_buffer(removed_text)
return turns_undone
@staticmethod
def _undo_content_to_text(content) -> str:
"""Flatten message content (str or content-part list) to plain text."""
if isinstance(content, str):
return content
if isinstance(content, list):
parts = [
p.get("text", "") for p in content if isinstance(p, dict) and p.get("type") == "text"
]
return "\n".join(t for t in parts if t)
return ""
def _write_terminal_breadcrumb(self) -> None:
"""Record this terminal's live session for bare ``hermes -c``. Called whenever
``self.session_id`` is (re)assigned so a later bare ``-c`` in THIS terminal resumes
this conversation's live tip. Best-effort; no-op without a terminal identity."""
with contextlib.suppress(Exception):
from hermes_cli.terminal_breadcrumbs import write_breadcrumb
write_breadcrumb(self.session_id)
def _transfer_session_yolo(self, old_session_id: str, new_session_id: str) -> None:
"""Move YOLO bypass state to a new session key when ``self.session_id`` is reassigned
mid-run (/branch, auto-compression rotation) — ``_session_yolo`` is keyed by id, so
without this the toggle silently reverts. Mirrors tui_gateway's rename path."""
if not old_session_id or not new_session_id or old_session_id == new_session_id:
return
try:
from tools.approval import (
disable_session_yolo, enable_session_yolo, is_session_yolo_enabled)
except Exception:
return
if is_session_yolo_enabled(old_session_id):
enable_session_yolo(new_session_id)
disable_session_yolo(old_session_id)
# Carry the persisted flag onto the continuation row so a later --resume restores
# it too. getattr: tests call this unbound against a minimal stand-in.
_persist = getattr(self, "_persist_session_yolo", None)
if _persist:
_persist(new_session_id, True)
def _is_session_yolo_active(self) -> bool:
"""Whether YOLO bypass is on for this session: reads ``tools.approval._session_yolo``
(not a stale env var) and honors the frozen process-start ``--yolo`` flag."""
try:
from tools.approval import _YOLO_MODE_FROZEN, is_session_yolo_enabled
except Exception:
return False
if _YOLO_MODE_FROZEN:
return True
# getattr: __new__-built test fixtures skip __init__; the status-bar builders
# swallow exceptions but would lose every field after the failure.
return is_session_yolo_enabled(getattr(self, "session_id", None) or "default")
def _toggle_yolo(self):
"""Toggle per-session YOLO mode (skip dangerous-command approvals).
Mirrors the gateway/TUI ``/yolo`` handlers. Deliberately does NOT touch
``HERMES_YOLO_MODE``: that env var is frozen into ``tools.approval._YOLO_MODE_FROZEN``
at import (so prompt-injected skills can't flip the bypass), making a later set a
silent no-op. ``run_conversation`` binds ``self.session_id`` as the active approval
key, so the bypass applies to the very next dangerous command.
"""
from cli import _cprint
from hermes_cli.colors import Colors as _Colors
from tools.approval import (
_YOLO_MODE_FROZEN, disable_session_yolo, enable_session_yolo, is_session_yolo_enabled)
# A frozen process-level bypass short-circuits the approval gate ahead of the session
# check — toggling "OFF" would be a false safety claim. Say so instead.
if _YOLO_MODE_FROZEN:
_cprint(
f" ⚡ YOLO is {_Colors.BOLD}{_Colors.RED}locked ON{_Colors.RESET}"
" for this process (started with --yolo / HERMES_YOLO_MODE)."
" /yolo cannot disable it — restart without the flag to"
" re-enable approvals.")
return
session_key = self.session_id or "default"
# getattr: tests call this unbound against a minimal stand-in; persistence is best-effort.
_persist = getattr(self, "_persist_session_yolo", None)
if is_session_yolo_enabled(session_key):
disable_session_yolo(session_key)
if _persist:
_persist(session_key, False)
_cprint(
f" ⚠ YOLO mode {_Colors.BOLD}{_Colors.RED}OFF{_Colors.RESET}"
" — dangerous commands will require approval.")
else:
enable_session_yolo(session_key)
if _persist:
_persist(session_key, True)
_cprint(
f" ⚡ YOLO mode {_Colors.BOLD}{_Colors.GREEN}ON{_Colors.RESET}"
" — all commands auto-approved. Use with caution.")
def _persist_session_yolo(self, session_key: str, enabled: bool) -> None:
"""Persist the YOLO flag to the session row so --resume restores it. Best-effort; the
in-memory toggle is authoritative. Skipped without a store or before the row exists
(rows are created lazily on the first turn)."""
db = getattr(self, "_session_db", None)
if db is None or not session_key or session_key == "default":
return
with contextlib.suppress(Exception):
db.set_session_yolo(session_key, enabled)
def _manual_compress(self, cmd_original: str = ""):
"""Manually trigger context compression.
* ``/compress [<focus>]`` — compress the whole history; an optional focus topic tells
the summariser what to preserve while discarding the rest more aggressively.
* ``/compress here [N]`` — boundary-aware: summarize everything except the most recent
``N`` exchanges (default 2), kept verbatim.
* ``--preview`` reports what would happen and changes nothing.
No ``compression_enabled`` gate: that flag disables *automatic* compaction only, and
the context-overflow error path directs users here when it is off.
"""
from agent.conversation_compression import finalize_context_engine_compression_notification
from agent.conversation_compression_manual import (
AGGRESSIVE_UNSUPPORTED, MIN_MESSAGES, compress_now, parse_compress_args, render_compress_result)
if len(self.conversation_history or ()) < MIN_MESSAGES:
print(f"(._.) Not enough conversation to compress (need at least {MIN_MESSAGES} messages).")
return
if not self.agent:
print("(._.) No active agent -- send a message first.")
return
_parts = (cmd_original or "").strip().split(None, 1)
request = parse_compress_args(_parts[1] if len(_parts) > 1 else "")
if request.aggressive:
print(f"(._.) {AGGRESSIVE_UNSUPPORTED}")
if not request.preview:
return
if request.preview:
for line in render_compress_result(compress_now(self.agent, self.conversation_history, request)):
print(f"🗜️ {line}")
return
original_count = len(self.conversation_history)
with self._busy_command("Compressing context...", blocks_input=False):
try:
if request.partial:
print(f"🗜️ Summarizing up to here: {original_count} messages, "
f"keeping last {request.keep_last} exchange(s) verbatim...")
elif request.focus_topic:
print(f"🗜️ Compressing {original_count} messages, focus: \"{request.focus_topic}\"...")
else:
print(f"🗜️ Compressing {original_count} messages...")
result = compress_now(self.agent, self.conversation_history, request,
task_id=self.session_id or "default")
if result.status != "compressed":
for line in render_compress_result(result):
print(f" {line}")
return
self.conversation_history = result.after_messages
# _compress_context ends the old session and creates a child session on the
# agent. Sync the CLI's session_id so /status, /resume, exit summary and title
# generation point at the live continuation, not the ended parent.
agent_sid = getattr(self.agent, "session_id", None)
if agent_sid and agent_sid != self.session_id:
self.session_id = self.agent.session_id
self._write_terminal_breadcrumb()
self._pending_title = None
# Persist the new handoff from offset 0 so resume can recover it after exit.
self.agent._flush_messages_to_session_db(self.conversation_history, None)
finalize_context_engine_compression_notification(self.agent, committed=True)
summary = result.summary
if summary.get("aborted") or summary.get("fallback_used") or summary.get("refused_would_grow"):
icon = "⚠️"
else:
icon = "🗜️" if summary["noop"] else "✅"
print(f" {icon} {summary['headline']}")
print(f" {summary['token_line']}")
if summary["note"]:
print(f" {summary['note']}")
except Exception as e:
finalize_context_engine_compression_notification(self.agent, committed=False)
print(f" ❌ Compression failed: {e}")
def _persist_prompt_summary(self, icon: str, label: str, detail: str, outcome: str) -> None:
"""Print a one-line scrollback summary of a resolved modal prompt (approval/clarify
panels vanish on repaint); gated by ``display.persist_prompts``."""
from cli import CLI_CONFIG, _DIM, _RST, _cprint
if not CLI_CONFIG.get("display", {}).get("persist_prompts", True):
return
detail, outcome = (_squash(s) for s in (detail, outcome))
_cprint(f"\n{_DIM}{icon} {label}: {detail} → {outcome}{_RST}")
def _clear_terminal_on_exit(self):
"""Clear screen + scrollback (``ESC[3J ESC[2J ESC[H``) so nothing is stranded above
the exit summary. Only safe after ``app.run()`` returned and prompt_toolkit restored
terminal modes. Skips when stdout isn't a console; falls back to ``clear``/``cls``."""
try:
stream = sys.stdout
if stream is None or not stream.isatty():
return
except Exception:
return
try:
stream.write("\033[3J\033[2J\033[H")
stream.flush()
except Exception:
# Fallback for terminals that reject the escape sequence. Never os.system():
# it spawns a cmd.exe/shell window that flashes on Windows (#116904) and a
# minimal container without `clear` on PATH just no-ops through the shell.
try:
import subprocess
from hermes_cli._subprocess_compat import windows_hide_flags
if os.name == "nt":
argv = ["cmd", "/c", "cls"] # `cls` is a cmd builtin, not an exe
else:
clear_bin = shutil.which("clear")
argv = [clear_bin] if clear_bin else []
if argv:
subprocess.run(
argv,
stdin=subprocess.DEVNULL,
creationflags=windows_hide_flags(),
check=False,
)
except Exception:
pass
def _persist_active_session_before_close(self):
"""Best-effort flush of the agent's live ``_session_messages`` before ``end_session()``
— a terminal close/SIGHUP can unwind the app while the agent thread still holds the
current turn only in memory."""
from cli import logger
agent = getattr(self, "agent", None)
if not agent or not hasattr(agent, "_persist_session"):
return
persist_lock = getattr(agent, "_session_persist_lock", None)
def _snapshot_and_persist() -> None:
# Must share the staging lock with ``chat()``: otherwise close can retain a
# history baseline just before chat appends its pending dict, and the later flush
# stamps that dict durable without writing a row.
messages = getattr(agent, "_session_messages", None)
pending_cli_message = getattr(agent, "_pending_cli_user_message", None)
if not isinstance(messages, list):
messages = getattr(self, "conversation_history", None)
if not isinstance(messages, list):
return
if isinstance(pending_cli_message, dict) and not any(
m is pending_cli_message for m in messages):
# The UI accepted a new input but the worker still exposes its prior snapshot.
messages = [*messages, pending_cli_message]
if not messages:
return
# Baseline: the CLI history a normal turn built its new list from, so a signal
# between assigning ``_session_messages`` and the DB flush cannot append the
# durable prefix twice. When both names alias the same live list, marker-only
# persistence would mark an unflushed tail durable — pass None instead.
conversation_history = getattr(self, "conversation_history", None)
if (
isinstance(conversation_history, list)
and conversation_history
and conversation_history[-1] is pending_cli_message):
# Accepted but not yet durable: exclude it from the resumed-history baseline.
conversation_history = conversation_history[:-1]
elif not isinstance(conversation_history, list) or conversation_history is messages:
conversation_history = None
# A first-turn close can precede the cached prompt; build it so the durable
# transcript never gets a NULL system_prompt cache entry.
if getattr(agent, "_cached_system_prompt", None) is None:
try:
from agent.conversation_loop import _restore_or_build_system_prompt
_restore_or_build_system_prompt(agent, None, conversation_history)
except Exception:
logger.debug("Could not build system prompt during CLI close", exc_info=True)
return
if getattr(agent, "_cached_system_prompt", None) is None:
return
agent._ensure_db_session()
agent._persist_session(messages, conversation_history)
if getattr(agent, "session_id", None):
self.session_id = agent.session_id
self._write_terminal_breadcrumb()
try:
# Create the DB session row now that _cached_system_prompt is populated, so the persisted
# snapshot is written non-NULL on the first turn (Issue #45499). Idempotent:
# _ensure_db_session() no-ops once the row exists. Must run BEFORE preflight compression:
# in-place compaction inserts message rows referencing this session (archive_and_compact), and
# rotation creates a child with parent_session_id pointing at it — with PRAGMA foreign_keys=ON,
# a missing parent row fails both INSERTs on a fresh oversized first turn. The user-turn crash
# persist itself runs LATER (after memory prefetch / pre_llm_call), so the row is written once
# with its final api_content — both steps take the same per-agent persist lock as CLI close
# persistence.
if persist_lock is None:
_snapshot_and_persist()
else:
with persist_lock:
_snapshot_and_persist()
except (Exception, KeyboardInterrupt) as e:
logger.debug("Could not persist active CLI session before close: %s", e)
def _print_exit_summary(self, clear_screen: bool = True):
"""Print session resume info on exit. ``clear_screen`` (interactive TUI teardown)
wipes screen + scrollback first; single-query mode passes False to keep the answer.
Args: clear_screen: When True (default), clear the terminal screen and scrollback before printing
the summary. See #38252, #53009.
"""
from cli import datetime
if clear_screen:
# Clear the screen + scrollback before printing the summary so the live bottom chrome (status
# bar, input box, separator rules) and the rest of the session transcript don't get stranded
# above the exit summary (#38252). By this point app.run() has returned and prompt_toolkit has
# restored terminal modes, so writing raw escapes to stdout is safe. ESC[3J clears scrollback,
# ESC[2J clears the visible screen, ESC[H homes the cursor — so the summary prints at a clean
# top-left. Falls back to the platform clear command if stdout isn't a TTY-capable stream.
# Honors NO_COLOR/dumb terminals by skipping silently when there's no real console.
self._clear_terminal_on_exit()
print()
msg_count = len(self.conversation_history)
if not msg_count:
try:
from hermes_cli.skin_engine import get_active_goodbye
goodbye = get_active_goodbye("Goodbye! ☤")
except Exception:
goodbye = "Goodbye! ☤"
print(goodbye)
return
user_msgs = len([m for m in self.conversation_history if m.get("role") == "user"])
tool_calls = len([
m for m in self.conversation_history if m.get("role") == "tool" or m.get("tool_calls")])
elapsed = datetime.now() - self.session_start
hours, remainder = divmod(int(elapsed.total_seconds()), 3600)
minutes, seconds = divmod(remainder, 60)
duration_str = f"{seconds}s"
if hours > 0:
duration_str = f"{hours}h {minutes}m {duration_str}"
elif minutes > 0:
duration_str = f"{minutes}m {duration_str}"
session_title = None
if self._session_db:
with contextlib.suppress(Exception):
session_title = self._session_db.get_session_title(self.session_id)
print("Resume this session with:")
# Session IDs are profile-constrained: non-default profiles need `-p <profile>` in
# the hint ("default"/"custom" use the standard HERMES_HOME).
try:
from hermes_cli.profiles import get_active_profile_name
_active_profile = get_active_profile_name()
except Exception:
_active_profile = "default"
profile_flag = "" if _active_profile in ("default", "custom") else f" -p {_active_profile}"
print(f" hermes --resume {self.session_id}{profile_flag}")
if session_title:
print(f" hermes -c \"{session_title}\"{profile_flag}")
print()
print(f"Session: {self.session_id}")
if session_title:
print(f"Title: {session_title}")
print(f"Duration: {duration_str}")
print(f"Messages: {msg_count} ({user_msgs} user, {tool_calls} tool calls)")