hermes-agent/hermes_cli/journey.py
Brooklyn Nicholson e971dc1e9d feat(journey): CLI + TUI learning timeline (/journey)
Terminal rendition of the desktop Star Map / Memory Graph: learned skills
and memories on a timeline, shared by `hermes journey` and the TUI
`/journey` overlay via one size-aware Python renderer
(agent/learning_graph_render.py).

- TUI overlay mirrors /agents: static chart overview + selectable slice
  list → slice detail → single skill/memory body, with the shared
  inverse-row selection treatment and a pinned footer.
- Reuse primitives: extract OverlayScrollbar into its own module (now
  shared with agentsOverlay), scroll the item body via ScrollBox, and
  unify both lists through one table-driven ListRow.
- No animation/playback in the TUI — pure data; the renderer's reveal
  scrubber stays available in the CLI (`--play`, `--reveal`).
2026-06-30 04:44:58 -05:00

250 lines
8.9 KiB
Python

"""``hermes journey`` — what Hermes has learned, on a timeline.
A terminal-native rendition of the desktop Star Map / Memory Graph: a horizontal
timeline bar chart of learned skills and memories over time (oldest at top,
newest at bottom) plus the playable constellation scrubber. Graph assembly,
layout, and the (ported-from-desktop) palette all live in
``agent.learning_graph`` / ``agent.learning_graph_render`` so the CLI, the TUI
``/journey`` overlay, and the desktop panel draw the same data.
"""
from __future__ import annotations
import argparse
import shutil
import sys
import time
from functools import lru_cache
from typing import Any, Optional
_TITLE_COLOR = "#E8C463"
def _build_payload() -> dict[str, Any]:
from agent.learning_graph import build_learning_graph
return build_learning_graph()
@lru_cache(maxsize=1)
def _primary_hex() -> str:
"""The active skin's primary color (mirrors the TUI theme primary)."""
try:
from hermes_cli.skin_engine import get_active_skin
skin = get_active_skin()
return skin.get_color("ui_primary", "") or skin.get_color("banner_title", "#FFD700")
except Exception:
return "#FFD700"
@lru_cache(maxsize=1)
def _palette() -> dict[str, str]:
from agent.learning_graph_render import derive_palette
return derive_palette(_primary_hex(), dark=True)
def _fade(base: Optional[str], alpha: float) -> Optional[str]:
from agent.learning_graph_render import hex_to_rgb, mix_rgb, rgb_to_hex
if not base:
return None
if alpha >= 0.999:
return base
return rgb_to_hex(mix_rgb(hex_to_rgb(_palette()["bg"]), hex_to_rgb(base), alpha))
def _resolve(style: str, alpha: float) -> Optional[str]:
"""Fade the style's base ink toward the background by ``alpha`` (rgba-over-bg)."""
return _fade(_palette().get(style), alpha)
def _row_to_text(row: list, color: bool):
from rich.text import Text
text = Text()
for run in row:
chunk = run[0]
style = run[1]
alpha = run[2] if len(run) > 2 else 1.0
override = run[3] if len(run) > 3 else None
if not color:
text.append(chunk)
elif override:
text.append(chunk, style=_fade(override, alpha))
else:
text.append(chunk, style=_resolve(style, alpha))
return text
def _term_size(width: Optional[int], height: Optional[int]) -> tuple[int, int]:
size = shutil.get_terminal_size((90, 30))
return max(40, width or size.columns), max(10, height or size.lines)
def _frame_renderable(payload, *, cols, rows, reveal, color):
from rich.console import Group
from rich.text import Text
from agent import learning_graph_render as render
legend = render.build_legend(payload)
categories = render.category_legend(payload)
summary = render.build_summary(payload)
axis = render.axis_labels(payload)
# Lines are pad_left(2), so content must fit in cols-2.
inner = max(24, cols - 2)
# Reserve rows for title/legend/blank/axis/footer/labels + summary; field gets rest.
field_rows = max(6, rows - 10 - len(summary))
frame = render.render_graph(payload, cols=inner, rows=field_rows, reveal=reveal)
count = len(payload.get("nodes", []))
parts: list[Any] = []
title = Text()
title.append("✦ Journey ", style=f"bold {_TITLE_COLOR}" if color else None)
title.append("· learned skills & memories over time", style="grey62" if color else None)
parts.append(title)
legend_line = Text(" ")
for i, item in enumerate(legend):
if i:
legend_line.append(" ")
legend_line.append(item["glyph"] + " ", style=_resolve(item["style"], 1.0) if color else None)
legend_line.append(item["label"], style="grey62" if color else None)
parts.append(legend_line)
if categories:
cat_line = Text(" ")
for i, item in enumerate(categories):
if i:
cat_line.append(" ")
cat_line.append(item["glyph"] + " ", style=_fade(item.get("color"), 1.0) if color else None)
cat_line.append(item["label"], style="grey54" if color else None)
parts.append(cat_line)
parts.append(Text(""))
for grow in frame["grid"]:
line = _row_to_text(grow, color)
line.pad_left(2)
parts.append(line)
# Date axis under the field (oldest → now), with the playhead date centered.
axis_line = Text(" ")
axis_line.append(axis["start"], style="grey54" if color else None)
gap = max(1, inner - len(axis["start"]) - len(axis["end"]))
axis_line.append(" " * gap)
axis_line.append(axis["end"], style="grey54" if color else None)
parts.append(axis_line)
pct = int(round(reveal * 100))
foot = Text(" ")
foot.append("", style="grey54" if color else None)
foot.append(frame["date"] or "", style=_TITLE_COLOR if color else None)
foot.append(f" {frame['visible']}/{count} revealed · {pct}%", style="grey54" if color else None)
parts.append(foot)
labels = frame.get("labels", [])
if labels:
parts.append(Text(""))
heading = Text(" charted signals", style="grey62" if color else None)
parts.append(heading)
def label_row(item) -> Text:
row = Text(" ")
row.append(f"{item['key']} ", style="grey70" if color else None)
row.append(f"{item['glyph']} ", style=_resolve(item["style"], float(item.get("alpha", 1.0))) if color else None)
row.append(str(item["label"]), style=_resolve(item["style"], float(item.get("alpha", 1.0))) if color else None)
meta = str(item["meta"])
row.append(f" {meta if len(meta) <= 32 else meta[:29] + ''}", style="grey54" if color else None)
return row
for item in labels[:6]:
row = label_row(item)
parts.append(row)
for line_text in summary:
parts.append(Text(" " + line_text, style="grey62" if color else None))
return Group(*parts)
def _cmd_show(args: argparse.Namespace) -> int:
from rich.console import Console
if getattr(args, "json", False):
import json
Console(no_color=bool(getattr(args, "no_color", False))).print_json(json.dumps(_build_payload()))
return 0
payload = _build_payload()
color = not bool(getattr(args, "no_color", False))
cols, rows = _term_size(getattr(args, "width", None), getattr(args, "height", None))
console = Console(no_color=not color, width=cols)
if not payload.get("nodes"):
console.print(
"[grey62]No learning yet — use Hermes a while and your learned skills and "
"memories will start mapping out here.[/grey62]"
)
return 0
if getattr(args, "play", False):
return _play(console, payload, cols=cols, rows=rows, color=color, fps=getattr(args, "fps", 12))
reveal = _clamp(float(getattr(args, "reveal", 1.0) or 1.0), 0.0, 1.0)
console.print(_frame_renderable(payload, cols=cols, rows=rows, reveal=reveal, color=color))
return 0
def _play(console, payload, *, cols, rows, color, fps: int) -> int:
from rich.live import Live
frames = 42
delay = 1.0 / max(1, min(60, fps))
try:
with Live(console=console, refresh_per_second=max(1, fps), screen=False) as live:
for i in range(frames):
reveal = i / (frames - 1)
live.update(_frame_renderable(payload, cols=cols, rows=rows, reveal=reveal, color=color))
time.sleep(delay)
live.update(_frame_renderable(payload, cols=cols, rows=rows, reveal=1.0, color=color))
except KeyboardInterrupt:
console.print("[grey54]interrupted[/grey54]")
return 130
return 0
def _clamp(v: float, lo: float, hi: float) -> float:
return lo if v < lo else hi if v > hi else v
def register_cli(parent: argparse.ArgumentParser) -> None:
parent.add_argument(
"--reveal",
type=float,
default=1.0,
metavar="0..1",
help="Render the timeline built up to this point (0=oldest, 1=now).",
)
parent.add_argument("--play", action="store_true", help="Animate the build-up over time (Ctrl-C to stop).")
parent.add_argument("--fps", type=int, default=12, help="Animation frames per second for --play (default 12).")
parent.add_argument("--width", type=int, default=None, help="Override render width in columns.")
parent.add_argument("--height", type=int, default=None, help="Override render height in rows.")
parent.add_argument("--no-color", action="store_true", help="Disable color output.")
parent.add_argument("--json", action="store_true", help="Print the raw graph payload as JSON and exit.")
parent.set_defaults(func=_cmd_show)
def cmd_journey(args: argparse.Namespace) -> int:
return _cmd_show(args)
if __name__ == "__main__":
_p = argparse.ArgumentParser(prog="hermes journey")
register_cli(_p)
_a = _p.parse_args()
sys.exit(_a.func(_a))