session.save wrote the in-memory history, so turns in-place compaction archived were missing from the snapshot and sessions.max_export_messages never applied, unlike CLI and messaging /save json since the #130339 fix. The two existing call sites' cap check + export + undone-row filter move into hermes_cli.session_export.load_save_snapshot; the CLI, the messaging gateway (one off-loop hop) and the tui_gateway handler all call it. The TUI handler reads the session's own profile db under that profile's config, refuses an oversized session with rpc error 4131 (the same message as the CLI), and keeps the in-memory export as the fallback when no stored row exists. Turn-isolated sessions run the same handler on the compute host; the host now forwards the handler's error code so the refusal arrives as 4131, not 5011. Refs #130339
1195 lines
58 KiB
Python
1195 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 agent.i18n import t
|
|
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" {t('cli.session.model_reset_default', model=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" {t('cli.session.title_rejected', error=e)}")
|
|
return None
|
|
if not sanitized:
|
|
_cprint(f" {t('cli.session.title_empty_untitled')}")
|
|
return None
|
|
try:
|
|
cli._session_db.set_session_title(cli.session_id, sanitized)
|
|
except ValueError as e:
|
|
_cprint(f" {t('cli.session.title_error_untitled', error=e)}")
|
|
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 = t("cli.session.cwd_gone", recorded=recorded, current=current or ".")
|
|
else:
|
|
try:
|
|
os.chdir(recorded)
|
|
os.environ["TERMINAL_CWD"] = recorded
|
|
msg = t("cli.session.cwd_restored", path=recorded)
|
|
except OSError as e:
|
|
msg = t("cli.session.cwd_enter_failed", path=recorded, error=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, t("cli.session.yolo_restored"), 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" {t('cli.session.invalid_checkpoint', count=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 = t("cli.session.status_tools_deferred")
|
|
else:
|
|
tools = get_tool_definitions(enabled_toolsets=self.enabled_toolsets,
|
|
disabled_toolsets=self.disabled_toolsets, quiet_mode=True)
|
|
tool_status = t("cli.session.status_tools_count", count=len(tools) if tools else 0)
|
|
|
|
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}]{_escape(t('cli.session.status_toolsets', toolsets=', '.join(self.enabled_toolsets)))}[/]"
|
|
provider_info = f"{sep}[dim]{_escape(t('cli.session.status_provider', provider=self.provider))}[/]"
|
|
if self._provider_source:
|
|
provider_info += f"{sep}[dim]{_escape(t('cli.session.status_auth', source=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 = t("cli.shared.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:
|
|
state = t("cli.shared.label_on") if show_r else t("cli.shared.label_off")
|
|
reasoning_label += t("cli.session.reasoning_display_suffix", state=state)
|
|
|
|
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 += t("cli.session.yolo_bypass_suffix")
|
|
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 = t("cli.session.ctx_left_prefix", percent=max(0, 100 - int(ctx_pct)))
|
|
ctx_label = t("cli.session.ctx_tokens_used", left=left, used=f"{snap.get('context_tokens') or 0:,}",
|
|
max=f"{ctx_max:,}")
|
|
except Exception:
|
|
ctx_label = None
|
|
|
|
lines = [t("cli.session.status_title"), "", *status_lines(fields, "session_id", "path", "title", "model")]
|
|
try:
|
|
from hermes_cli.anon_auth import free_tier_route
|
|
|
|
if free_tier_route():
|
|
lines.append(t("gateway.status.free_tier"))
|
|
except Exception:
|
|
pass
|
|
optional = ((t("cli.session.label_reasoning"), reasoning_label),
|
|
(t("cli.session.label_approvals"), approval_label),
|
|
(t("cli.session.label_context"), ctx_label))
|
|
for label, value in optional:
|
|
if value:
|
|
lines.append(t("cli.shared.label_value", label=label, value=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(t("cli.session.recent_intro"))
|
|
else:
|
|
_cli_visible_print(f" {t('cli.session.recent_header')}")
|
|
_cli_visible_print()
|
|
_cli_visible_print(
|
|
f" {'#':<3} {t('cli.session.column_title'):<32} {t('cli.session.column_preview'):<40} "
|
|
f"{t('cli.session.column_last_active'):<13} {t('cli.session.column_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(f" {t('cli.session.resume_usage')}")
|
|
_cli_visible_print(f" {t('cli.session.resume_example')}")
|
|
_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(t("cli.session.history_empty"))
|
|
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
|
|
key = "cli.session.history_tools_hidden_one" if hidden_tool_messages == 1 else "cli.session.history_tools_hidden_other"
|
|
_cli_visible_print(f"\n {t('cli.session.history_tools_header')}")
|
|
_cli_visible_print(f" {t(key, count=hidden_tool_messages)}")
|
|
hidden_tool_messages = 0
|
|
|
|
rule = "+" + "-" * 50 + "+"
|
|
for line in ("", rule, "|" + t("cli.session.history_title").center(50) + "|", 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 {t('cli.session.history_you_header', index=visible_index)}{_ts_suffix(msg)}")
|
|
_cli_visible_print(f" {preview}{suffix}")
|
|
continue
|
|
|
|
_cli_visible_print(
|
|
f"\n {t('cli.session.history_assistant_header', agent_name='Hermes', index=visible_index)}{_ts_suffix(msg)}")
|
|
n_calls = len(msg.get("tool_calls") or [])
|
|
if not content_text:
|
|
suffix = ""
|
|
preview = t("cli.session.history_no_text")
|
|
if n_calls:
|
|
key = "cli.session.history_tool_calls_one" if n_calls == 1 else "cli.session.history_tool_calls_other"
|
|
preview = t(key, count=n_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(t("cli.session.new_session_titled", title=title))
|
|
else:
|
|
print(t("cli.session.new_session"))
|
|
|
|
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" {t('cli.session.resume_index_out_of_range', index=index)}")
|
|
_cprint(f" {t('cli.session.resume_no_args_hint')}")
|
|
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_USAGE, load_save_snapshot, 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:
|
|
from hermes_state import SessionExportTooLargeError
|
|
try:
|
|
session_data = load_save_snapshot(_db, _sid, fmt)
|
|
except SessionExportTooLargeError as e:
|
|
print(f"(._.) {e}")
|
|
return
|
|
except Exception:
|
|
session_data = None
|
|
if not session_data:
|
|
if not self.conversation_history:
|
|
print(t("cli.session.save_nothing"))
|
|
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(t("cli.session.save_dir_failed", path=saved_dir, error=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": t("cli.session.format_markdown"), "html": "HTML"}[fmt]
|
|
print(t("cli.session.save_ok", path=path, label=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" {t('cli.session.save_resume_hint', session_id=self.session_id)}")
|
|
except Exception as e:
|
|
print(t("cli.session.save_failed", error=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(t("cli.session.retry_no_messages"))
|
|
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(t("cli.session.retry_no_user_message"))
|
|
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(t("cli.session.retry_unsafe", error=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(t("cli.session.retry_rewind_failed", error=exc))
|
|
return None
|
|
|
|
self._publish_truncated_history(truncated, invalidate_prompt=False)
|
|
print(t("cli.session.retrying", preview=f"{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(t("cli.session.undo_no_messages"))
|
|
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(t("cli.session.undo_no_user_message"))
|
|
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(t("cli.session.undo_failed", error=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)
|
|
|
|
key = "cli.session.undo_ok_one" if turns_undone == 1 else "cli.session.undo_ok_other"
|
|
print(t(key, count=turns_undone, messages=rewound_rows or removed_count,
|
|
backup=f"{removed_text[:60]}{'...' if len(removed_text) > 60 else ''}"))
|
|
print(f" {t('cli.session.undo_remaining', count=len(self.conversation_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:
|
|
state = f"{_Colors.BOLD}{_Colors.RED}{t('cli.session.yolo_state_locked_on')}{_Colors.RESET}"
|
|
_cprint(f" {t('cli.session.yolo_locked', state=state)}")
|
|
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)
|
|
state = f"{_Colors.BOLD}{_Colors.RED}{t('cli.shared.label_off_upper')}{_Colors.RESET}"
|
|
_cprint(f" {t('cli.session.yolo_off', state=state)}")
|
|
else:
|
|
enable_session_yolo(session_key)
|
|
if _persist:
|
|
_persist(session_key, True)
|
|
state = f"{_Colors.BOLD}{_Colors.GREEN}{t('cli.shared.label_on_upper')}{_Colors.RESET}"
|
|
_cprint(f" {t('cli.session.yolo_on', state=state)}")
|
|
|
|
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(t("cli.session.compress_too_short", min=MIN_MESSAGES))
|
|
return
|
|
if not self.agent:
|
|
print(t("cli.session.compress_no_agent"))
|
|
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(t("cli.session.compress_busy"), blocks_input=False):
|
|
try:
|
|
if request.partial:
|
|
print(t("cli.session.compress_summarizing", count=original_count, keep_last=request.keep_last))
|
|
elif request.focus_topic:
|
|
print(t("cli.session.compress_focus", count=original_count, focus=request.focus_topic))
|
|
else:
|
|
print(t("cli.session.compress_plain", count=original_count))
|
|
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" {t('cli.session.compress_failed', error=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}{t('cli.session.persist_prompt_summary', icon=icon, label=label, detail=detail, outcome=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(t("cli.session.goodbye"))
|
|
except Exception:
|
|
goodbye = t("cli.session.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 = t("cli.shared.duration_s", seconds=seconds)
|
|
if hours > 0:
|
|
duration_str = t("cli.shared.duration_hm_rest", hours=hours, minutes=minutes, rest=duration_str)
|
|
elif minutes > 0:
|
|
duration_str = t("cli.shared.duration_m_rest", minutes=minutes, rest=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(t("cli.session.exit_resume_hint"))
|
|
# 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(t("cli.session.exit_label_session", session_id=self.session_id))
|
|
if session_title:
|
|
print(t("cli.session.exit_label_title", title=session_title))
|
|
print(t("cli.session.exit_label_duration", duration=duration_str))
|
|
print(t("cli.session.exit_label_messages", count=msg_count, user=user_msgs, tool_calls=tool_calls))
|