mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-12 08:51:53 +00:00
Merge pull request #44038 from NousResearch/hermes/hermes-fb4ee8ce
fix(cli): show quick commands in /help output
This commit is contained in:
commit
85503dceca
4 changed files with 254 additions and 0 deletions
50
cli.py
50
cli.py
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
161
tests/test_empty_session_hygiene.py
Normal file
161
tests/test_empty_session_hygiene.py
Normal 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
|
||||
Loading…
Add table
Add a link
Reference in a new issue