In-place compaction is the default. It soft-archives every earlier row of a session under the same id (active = 0, compacted = 1), and Desktop and the dashboard still show those turns. The md/qmd export read the session through export_session -> get_messages with the default live-only clause, so it wrote only the compaction summary and the carried tail. verify_export_file then compared the file with that same dict, and delete_session removed every row of the session, including the archived turns that never reached the file. The md/qmd export now reads the display history (include_compacted), for a single session and for --lineage logical. export_session and export_session_lineage take include_compacted, off by default: import_sessions inserts every message as live context, so the JSON export and stranded-session adoption keep reading live rows only. The other transcripts people read had the same hole without the delete: `/save md` and `/save html` (CLI and gateway), `sessions export --format html` (one session or all of them) and `--only user-prompts` each held 1 of 6 answers on a six-turn session after one compaction. They now read the display history too (SAVE_TRANSCRIPT_FORMATS; export_all gains include_compacted and reads per session then, since the display read dedupes per session). `/save json`, JSONL and the dashboard's JSON export stay live-only for the import reason above. Before deleting, the verify step also re-counts the store's display rows for every session the file covers and refuses on a mismatch. A message that lands while the files are written, or a later export change that reads a narrower view, now refuses the delete instead of being removed unseen. Like the adoption retire loop, the re-count runs just before delete_session, not inside its transaction. Rewind rows (undone turns, the superseded originals of a carried tail) are still deleted without being exported, as `hermes sessions delete` does: they are not part of the history the session shows. Measured through the real CLI on a session with 6 turns and one default in-place compaction (15 rows, 13 shown): before, 3 messages were exported and all 15 rows deleted, with answers 1-5 missing from the file; after, 13 messages are exported in display order, then deleted. (cherry picked from commit adeaff1e33f1ae2b8a066ef2374bb29ade50b3b8)
1191 lines
58 KiB
Python
1191 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.
|
|
"""
|
|
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)
|
|
|
|
@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)")
|