mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-23 10:42:00 +00:00
feat(cli): /timestamps command + timestamps in /history (#50506)
display.timestamps already drove the [HH:MM] suffix on live submitted and streamed message labels, but there was no runtime command to toggle it and /history ignored the setting entirely. Add /timestamps [on|off|status] (alias /ts) and render [HH:MM] in /history for turns that carry a stored unix timestamp (resumed sessions). Live unsaved turns without a stored time are never given a fabricated one. Uses the existing sanctioned non-wire 'timestamp' message key (stripped before the API call in chat_completions), so message-alternation and prompt-cache invariants are untouched.
This commit is contained in:
parent
b9b4756ab4
commit
5ff11a689b
4 changed files with 171 additions and 2 deletions
22
cli.py
22
cli.py
|
|
@ -6216,6 +6216,22 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
|
|||
preview_limit = 400
|
||||
visible_index = 0
|
||||
hidden_tool_messages = 0
|
||||
show_ts = bool(getattr(self, "show_timestamps", False))
|
||||
|
||||
def _ts_suffix(message: dict) -> str:
|
||||
# Messages restored from SessionDB carry a unix `timestamp`; live
|
||||
# unsaved turns may not. Only annotate when both the toggle is on
|
||||
# and the turn actually has a stored time — never fabricate one.
|
||||
if not show_ts:
|
||||
return ""
|
||||
ts = message.get("timestamp")
|
||||
if not ts:
|
||||
return ""
|
||||
try:
|
||||
from datetime import datetime
|
||||
return f" [{datetime.fromtimestamp(float(ts)).strftime('%H:%M')}]"
|
||||
except (ValueError, OSError, TypeError):
|
||||
return ""
|
||||
|
||||
def flush_tool_summary():
|
||||
nonlocal hidden_tool_messages
|
||||
|
|
@ -6249,13 +6265,13 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
|
|||
content_text = "" if content is None else str(content)
|
||||
|
||||
if role == "user":
|
||||
print(f"\n [You #{visible_index}]")
|
||||
print(f"\n [You #{visible_index}]{_ts_suffix(msg)}")
|
||||
print(
|
||||
f" {content_text[:preview_limit]}{'...' if len(content_text) > preview_limit else ''}"
|
||||
)
|
||||
continue
|
||||
|
||||
print(f"\n [Hermes #{visible_index}]")
|
||||
print(f"\n [Hermes #{visible_index}]{_ts_suffix(msg)}")
|
||||
tool_calls = msg.get("tool_calls") or []
|
||||
if content_text:
|
||||
preview = content_text[:preview_limit]
|
||||
|
|
@ -7978,6 +7994,8 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
|
|||
self._status_bar_visible = not self._status_bar_visible
|
||||
state = "visible" if self._status_bar_visible else "hidden"
|
||||
self._console_print(f" Status bar {state}")
|
||||
elif canonical == "timestamps":
|
||||
self._handle_timestamps_command(cmd_original)
|
||||
elif canonical == "verbose":
|
||||
self._toggle_verbose()
|
||||
elif canonical == "footer":
|
||||
|
|
|
|||
|
|
@ -2086,6 +2086,56 @@ class CLICommandsMixin:
|
|||
else:
|
||||
_cprint(" Failed to save runtime_footer setting to config.yaml")
|
||||
|
||||
def _handle_timestamps_command(self, cmd_original: str) -> None:
|
||||
"""Toggle or inspect ``display.timestamps`` from the CLI.
|
||||
|
||||
When on, submitted and streamed message labels carry an ``[HH:MM]``
|
||||
suffix and ``/history`` prefixes each turn with its time (for turns
|
||||
that carry a stored timestamp).
|
||||
|
||||
Usage:
|
||||
/timestamps → toggle
|
||||
/timestamps on|off → explicit
|
||||
/timestamps status → show current state
|
||||
"""
|
||||
from cli import _cprint, save_config_value
|
||||
from hermes_cli.colors import Colors as _Colors
|
||||
|
||||
arg = ""
|
||||
try:
|
||||
parts = (cmd_original or "").strip().split(None, 1)
|
||||
if len(parts) > 1:
|
||||
arg = parts[1].strip().lower()
|
||||
except Exception:
|
||||
arg = ""
|
||||
|
||||
current = bool(getattr(self, "show_timestamps", False))
|
||||
|
||||
if arg in {"status", "?"}:
|
||||
state = "ON" if current else "OFF"
|
||||
_cprint(f" {_Colors.BOLD}Message timestamps:{_Colors.RESET} {state}")
|
||||
return
|
||||
|
||||
if arg in {"on", "enable", "true", "1"}:
|
||||
new_state = True
|
||||
elif arg in {"off", "disable", "false", "0"}:
|
||||
new_state = False
|
||||
elif arg == "":
|
||||
new_state = not current
|
||||
else:
|
||||
_cprint(" Usage: /timestamps [on|off|status]")
|
||||
return
|
||||
|
||||
self.show_timestamps = new_state
|
||||
if save_config_value("display.timestamps", new_state):
|
||||
state = (
|
||||
f"{_Colors.GREEN}ON{_Colors.RESET}" if new_state
|
||||
else f"{_Colors.DIM}OFF{_Colors.RESET}"
|
||||
)
|
||||
_cprint(f" Message timestamps: {state}")
|
||||
else:
|
||||
_cprint(" Failed to save timestamps setting to config.yaml")
|
||||
|
||||
def _handle_reasoning_command(self, cmd: str):
|
||||
"""Handle /reasoning — manage effort level and display toggle.
|
||||
|
||||
|
|
|
|||
|
|
@ -135,6 +135,9 @@ COMMAND_REGISTRY: list[CommandDef] = [
|
|||
args_hint="[name]"),
|
||||
CommandDef("statusbar", "Toggle the context/model status bar", "Configuration",
|
||||
cli_only=True, aliases=("sb",)),
|
||||
CommandDef("timestamps", "Toggle [HH:MM] timestamps on messages and /history", "Configuration",
|
||||
cli_only=True, args_hint="[on|off|status]",
|
||||
subcommands=("on", "off", "status"), aliases=("ts",)),
|
||||
CommandDef("verbose", "Cycle tool progress display: off -> new -> all -> verbose",
|
||||
"Configuration", cli_only=True,
|
||||
gateway_config_gate="display.tool_progress_command"),
|
||||
|
|
|
|||
98
tests/hermes_cli/test_timestamps_command.py
Normal file
98
tests/hermes_cli/test_timestamps_command.py
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
"""Tests for the CLI `/timestamps` toggle and timestamps in `/history`.
|
||||
|
||||
`display.timestamps` already drove the live `[HH:MM]` label suffix on
|
||||
submitted/streamed messages but had no runtime toggle and `/history`
|
||||
ignored it. These assert the new `/timestamps` command flips and persists
|
||||
the flag and that `/history` renders `[HH:MM]` only for turns that carry a
|
||||
stored unix `timestamp` (never fabricating one for live unsaved turns).
|
||||
"""
|
||||
|
||||
import io
|
||||
import sys
|
||||
import time
|
||||
from datetime import datetime
|
||||
|
||||
import yaml
|
||||
|
||||
from hermes_cli.cli_commands_mixin import CLICommandsMixin
|
||||
|
||||
|
||||
class _Stub(CLICommandsMixin):
|
||||
def __init__(self):
|
||||
self.show_timestamps = False
|
||||
|
||||
|
||||
def _seed(tmp_path, monkeypatch, value=False):
|
||||
hh = tmp_path / ".hermes"
|
||||
hh.mkdir()
|
||||
(hh / "config.yaml").write_text(f"display:\n timestamps: {str(value).lower()}\n")
|
||||
monkeypatch.setenv("HERMES_HOME", str(hh))
|
||||
import cli
|
||||
|
||||
monkeypatch.setattr(cli, "_hermes_home", hh, raising=False)
|
||||
return hh
|
||||
|
||||
|
||||
def test_timestamps_on_sets_and_persists(tmp_path, monkeypatch):
|
||||
hh = _seed(tmp_path, monkeypatch)
|
||||
s = _Stub()
|
||||
s._handle_timestamps_command("/timestamps on")
|
||||
assert s.show_timestamps is True
|
||||
assert yaml.safe_load((hh / "config.yaml").read_text())["display"]["timestamps"] is True
|
||||
|
||||
|
||||
def test_timestamps_bare_toggles(tmp_path, monkeypatch):
|
||||
_seed(tmp_path, monkeypatch)
|
||||
s = _Stub()
|
||||
s.show_timestamps = True
|
||||
s._handle_timestamps_command("/timestamps")
|
||||
assert s.show_timestamps is False
|
||||
|
||||
|
||||
def test_timestamps_status_is_noop(tmp_path, monkeypatch):
|
||||
_seed(tmp_path, monkeypatch)
|
||||
s = _Stub()
|
||||
s.show_timestamps = True
|
||||
s._handle_timestamps_command("/timestamps status")
|
||||
assert s.show_timestamps is True
|
||||
|
||||
|
||||
def _render_history(history, show_ts):
|
||||
from cli import HermesCLI
|
||||
|
||||
h = HermesCLI.__new__(HermesCLI)
|
||||
h.show_timestamps = show_ts
|
||||
h.conversation_history = history
|
||||
h._show_recent_sessions = lambda reason="history", limit=10: True
|
||||
buf = io.StringIO()
|
||||
old = sys.stdout
|
||||
sys.stdout = buf
|
||||
try:
|
||||
h.show_history()
|
||||
finally:
|
||||
sys.stdout = old
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def test_history_shows_timestamp_for_stored_turns():
|
||||
ts = time.time()
|
||||
hist = [
|
||||
{"role": "user", "content": "hello", "timestamp": ts},
|
||||
{"role": "assistant", "content": "hi", "timestamp": ts + 60},
|
||||
{"role": "user", "content": "live turn, no ts"},
|
||||
]
|
||||
out = _render_history(hist, show_ts=True)
|
||||
hhmm = datetime.fromtimestamp(ts).strftime("%H:%M")
|
||||
assert f"[You #1] [{hhmm}]" in out
|
||||
assert "[Hermes #2] [" in out
|
||||
# a turn with no stored timestamp must NOT get a fabricated time
|
||||
assert "[You #3]\n" in out
|
||||
|
||||
|
||||
def test_history_hides_timestamps_when_off():
|
||||
ts = time.time()
|
||||
hist = [{"role": "user", "content": "hello", "timestamp": ts}]
|
||||
out = _render_history(hist, show_ts=False)
|
||||
# label present, no [HH:MM] suffix
|
||||
first_label_line = out.split("[You #1]")[1].split("\n")[0]
|
||||
assert "[" not in first_label_line
|
||||
Loading…
Add table
Add a link
Reference in a new issue