Merge pull request #44038 from NousResearch/hermes/hermes-fb4ee8ce

fix(cli): show quick commands in /help output
This commit is contained in:
Teknium 2026-06-11 03:04:30 -07:00 committed by GitHub
commit 85503dceca
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 254 additions and 0 deletions

50
cli.py
View file

@ -5552,6 +5552,15 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
f"{_escape(desc)} [dim]({skill_count} skills)[/]"
)
quick_commands = self.config.get("quick_commands", {})
if quick_commands:
_cprint(f"\n{_BOLD}Quick Commands{_RST} ({len(quick_commands)} configured):")
for name, qcmd in sorted(quick_commands.items()):
desc = qcmd.get("description", qcmd.get("type", ""))
ChatConsole().print(
f" [bold {_accent_hex()}]{('/' + name):<22}[/] [dim]-[/] {_escape(desc)}"
)
_cprint(f"\n {_DIM}Tip: Just type your message to chat with Hermes!{_RST}")
_cprint(f" {_DIM}Multi-line: Alt+Enter for a new line{_RST}")
_cprint(f" {_DIM}Draft editor: Ctrl+G (Alt+G in VSCode/Cursor){_RST}")
@ -5821,6 +5830,35 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
except Exception:
pass
def _discard_session_if_empty(self, session_id: Optional[str]) -> bool:
"""Drop a just-ended session row when it never gained content.
Starting the CLI and immediately quitting (or rotating with /new,
/clear) used to leave an empty untitled row behind that clutters
``/resume`` and ``hermes sessions list``. Delegates the
check-and-delete to ``SessionDB.delete_session_if_empty``, which
only removes rows with no messages, no title, and no child
sessions. Ported from google-gemini/gemini-cli#27770.
"""
if not self._session_db or not session_id:
return False
# In-memory transcript is authoritative: if this CLI object holds
# conversation messages (flushed to the DB or not), the session is
# not empty. Protects against pruning a real conversation whose DB
# flush failed or hasn't happened yet.
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 new_session(self, silent=False, title=None):
"""Start a fresh session with a new session ID and cleared agent state."""
if self.agent and self.conversation_history:
@ -5837,6 +5875,9 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
self._session_db.end_session(old_session_id, "new_session")
except Exception:
pass
# Don't let immediately-rotated empty sessions pile up in
# /resume and `hermes sessions list` (gemini-cli#27770 port).
self._discard_session_if_empty(old_session_id)
self.session_start = datetime.now()
timestamp_str = self.session_start.strftime("%Y%m%d_%H%M%S")
@ -13074,6 +13115,15 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
self._session_db.end_session(self.agent.session_id, "cli_close")
except (Exception, KeyboardInterrupt) as e:
logger.debug("Could not close session in DB: %s", e)
# Started-and-immediately-quit sessions never gained content;
# drop the empty row so /resume and `hermes sessions list`
# stay clean (gemini-cli#27770 port). No-op for resumed or
# titled sessions and anything with messages or children.
if not getattr(self, '_delete_session_on_exit', False):
try:
self._discard_session_if_empty(self.agent.session_id)
except (Exception, KeyboardInterrupt) as e:
logger.debug("Could not prune empty session: %s", e)
# /exit --delete: also remove the current session's transcripts
# and SQLite history. Ported from google-gemini/gemini-cli#19332.
if getattr(self, '_delete_session_on_exit', False):

View file

@ -3694,6 +3694,48 @@ class SessionDB:
self._remove_session_files(sessions_dir, session_id)
return deleted
def delete_session_if_empty(
self,
session_id: str,
sessions_dir: Optional[Path] = None,
) -> bool:
"""Delete *session_id* only when it never gained resumable content.
A session is considered empty when it has no messages and no
user-assigned title. Used by CLI exit / session-rotation paths so
immediately-started-and-quit sessions don't pile up in ``/resume``
and ``hermes sessions list`` output. (Pattern ported from
google-gemini/gemini-cli#27770.)
The emptiness check and delete run in one transaction, so a message
flushed concurrently by another writer can't be lost. Sessions with
children (delegate subagent runs) are preserved a parent that
spawned work is not "empty" even if its own transcript never
flushed. Returns True if the session was deleted.
"""
def _do(conn):
cursor = conn.execute(
"""
DELETE FROM sessions
WHERE id = ?
AND title IS NULL
AND NOT EXISTS (
SELECT 1 FROM messages WHERE messages.session_id = sessions.id
)
AND NOT EXISTS (
SELECT 1 FROM sessions child
WHERE child.parent_session_id = sessions.id
)
""",
(session_id,),
)
return cursor.rowcount > 0
deleted = self._execute_write(_do)
if deleted:
self._remove_session_files(sessions_dir, session_id)
return bool(deleted)
def delete_sessions(
self,
session_ids: List[str],

View file

@ -75,6 +75,7 @@ AUTHOR_MAP = {
"129007007+HeLLGURD@users.noreply.github.com": "HeLLGURD",
"290859878+synapsesx@users.noreply.github.com": "synapsesx",
"dirtyren@users.noreply.github.com": "dirtyren",
"mvanhorn@MacBook-Pro.local": "mvanhorn",
"470766206@qq.com": "youjunxiaji",
"mharris@parallel.ai": "NormallyGaussian",
"roger@roger.local": "mollusk",

View file

@ -0,0 +1,161 @@
"""Tests for empty-session hygiene — gemini-cli#27770 port.
Starting the CLI and immediately quitting (or rotating sessions with /new)
used to leave empty untitled rows in the session DB that clutter /resume
and `hermes sessions list`. ``SessionDB.delete_session_if_empty`` removes
a just-ended session row only when it never gained resumable content:
no messages, no title, and no child sessions.
"""
import pytest
from hermes_state import SessionDB
@pytest.fixture()
def db(tmp_path):
session_db = SessionDB(db_path=tmp_path / "state.db")
yield session_db
session_db.close()
class TestDeleteSessionIfEmpty:
def test_deletes_empty_untitled_session(self, db):
db.create_session(session_id="empty", source="cli", model="test")
db.end_session("empty", "cli_close")
assert db.delete_session_if_empty("empty") is True
assert db.get_session("empty") is None
def test_keeps_session_with_messages(self, db):
db.create_session(session_id="busy", source="cli", model="test")
db.append_message("busy", role="user", content="hello")
db.end_session("busy", "cli_close")
assert db.delete_session_if_empty("busy") is False
assert db.get_session("busy") is not None
def test_keeps_titled_session(self, db):
"""A user-assigned title is resumable content even without messages."""
db.create_session(session_id="titled", source="cli", model="test")
db.set_session_title("titled", "Important plans")
db.end_session("titled", "cli_close")
assert db.delete_session_if_empty("titled") is False
assert db.get_session("titled") is not None
def test_keeps_session_with_children(self, db):
"""A parent that spawned delegate subagent runs is not empty."""
db.create_session(session_id="parent", source="cli", model="test")
db.create_session(
session_id="child",
source="tool",
model="test",
parent_session_id="parent",
)
db.end_session("parent", "cli_close")
assert db.delete_session_if_empty("parent") is False
assert db.get_session("parent") is not None
assert db.get_session("child") is not None
def test_unknown_session_returns_false(self, db):
assert db.delete_session_if_empty("nope") is False
def test_removes_on_disk_transcripts(self, db, tmp_path):
sessions_dir = tmp_path / "sessions"
sessions_dir.mkdir()
(sessions_dir / "empty.json").write_text("{}", encoding="utf-8")
(sessions_dir / "empty.jsonl").write_text("", encoding="utf-8")
db.create_session(session_id="empty", source="cli", model="test")
db.end_session("empty", "cli_close")
assert db.delete_session_if_empty("empty", sessions_dir=sessions_dir)
assert not (sessions_dir / "empty.json").exists()
assert not (sessions_dir / "empty.jsonl").exists()
def test_no_file_cleanup_when_kept(self, db, tmp_path):
sessions_dir = tmp_path / "sessions"
sessions_dir.mkdir()
(sessions_dir / "busy.json").write_text("{}", encoding="utf-8")
db.create_session(session_id="busy", source="cli", model="test")
db.append_message("busy", role="user", content="hello")
assert not db.delete_session_if_empty("busy", sessions_dir=sessions_dir)
assert (sessions_dir / "busy.json").exists()
def test_empty_session_disappears_from_listing(self, db):
"""The user-facing symptom: empty rows polluting session lists."""
db.create_session(session_id="real", source="cli", model="test")
db.append_message("real", role="user", content="do the thing")
db.end_session("real", "cli_close")
db.create_session(session_id="ghost", source="cli", model="test")
db.end_session("ghost", "cli_close")
ids_before = {s["id"] for s in db.list_sessions_rich(source="cli")}
assert {"real", "ghost"} <= ids_before
db.delete_session_if_empty("ghost")
ids_after = {s["id"] for s in db.list_sessions_rich(source="cli")}
assert "real" in ids_after
assert "ghost" not in ids_after
class TestCLIDiscardSessionIfEmpty:
"""Wiring tests for HermesCLI._discard_session_if_empty."""
def _make_cli(self, db):
from cli import HermesCLI
cli = HermesCLI.__new__(HermesCLI)
cli._session_db = db
cli.conversation_history = []
return cli
def test_discards_empty(self, db):
db.create_session(session_id="empty", source="cli", model="test")
db.end_session("empty", "cli_close")
cli = self._make_cli(db)
assert cli._discard_session_if_empty("empty") is True
assert db.get_session("empty") is None
def test_keeps_nonempty(self, db):
db.create_session(session_id="busy", source="cli", model="test")
db.append_message("busy", role="user", content="hi")
cli = self._make_cli(db)
assert cli._discard_session_if_empty("busy") is False
assert db.get_session("busy") is not None
def test_no_db_is_noop(self):
cli = self._make_cli(None)
assert cli._discard_session_if_empty("anything") is False
def test_none_session_id_is_noop(self, db):
cli = self._make_cli(db)
assert cli._discard_session_if_empty(None) is False
def test_db_error_swallowed(self, db):
class Boom:
def delete_session_if_empty(self, *a, **k):
raise RuntimeError("locked")
cli = self._make_cli(Boom())
assert cli._discard_session_if_empty("x") is False
def test_in_memory_history_blocks_prune(self, db):
"""The live transcript is authoritative: even if the DB row has no
flushed messages yet, a CLI holding conversation history must not
prune the session (covers flush-failed / not-yet-flushed turns)."""
db.create_session(session_id="unflushed", source="cli", model="test")
db.end_session("unflushed", "new_session")
cli = self._make_cli(db)
cli.conversation_history = [{"role": "user", "content": "hello"}]
assert cli._discard_session_if_empty("unflushed") is False
assert db.get_session("unflushed") is not None