Files
hermes-agent/hermes_cli/journey.py
Teknium 2776813df3 compat(plugins): temporary import-path shims for external plugins — ONE commit, revert on schedule
The Sep 2026 decomposition (PR #102117) makes internal import paths a non-API: names now live in
the focused modules that define them. This commit is the ONLY thing keeping the old paths alive,
so external plugins have time to update. It is deliberately a single, unsquashed commit:

    git revert <this sha>

removes every shim, stub and manifest at once on the announced date. Nothing in-tree may depend on
these pointers: scripts/check_compat_pointers.py (wired into lint.yml) fails CI if it does.

What it adds (see COMPAT_MANIFEST.md, compat_manifest.json):
- 332 facade modules get one delimited `PLUGIN-COMPAT` block appended at the end of the file
- 1,172 moved names resolved lazily via a module `__getattr__` (PEP 562) — never a top-level import,
  so no import cycles; facades that already had `__getattr__` get a chained one
- 592 third-party/stdlib names the old modules used to expose, with their original import statements
- 266 public definitions that had been deleted as unused, restored byte-for-byte from the pre-decomposition
  tree (+40 private helpers and 16 imports pulled in only because a restored definition needs them)
- 3 deleted modules recreated as re-export stubs (gateway/startup_watchdog, hermes_cli/observability/
  relay_runtime, tools/environments/modal_utils)
- private names (`_x`) get no pointer: they were never API (3,792 skipped)

Verified: all 335 touched modules import under a fresh HERMES_HOME and every manifest name resolves;
the lint reports zero in-tree uses; ruff clean; targeted suites unchanged.
2026-09-03 17:13:22 -07:00

391 lines
15 KiB
Python

"""``hermes journey`` — what Hermes has learned, on a timeline."""
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"
_CHARTED_SIGNAL_MIN_CONTRAST = 4.5
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 _relative_luminance(rgb: tuple[int, int, int]) -> float:
def channel(value: int) -> float:
n = value / 255
return n / 12.92 if n <= 0.03928 else ((n + 0.055) / 1.055) ** 2.4
red, green, blue = (channel(value) for value in rgb)
return 0.2126 * red + 0.7152 * green + 0.0722 * blue
def _contrast_ratio(foreground: tuple[int, int, int], background: tuple[int, int, int]) -> float:
high, low = sorted((_relative_luminance(foreground), _relative_luminance(background)), reverse=True)
return (high + 0.05) / (low + 0.05)
def _ensure_contrast(color: Optional[str], background: str, minimum: float) -> Optional[str]:
"""Lift a foreground toward the readable pole until it clears ``minimum``."""
if not color:
return None
from agent.learning_graph_render import hex_to_rgb, mix_rgb, rgb_to_hex
foreground_rgb = hex_to_rgb(color)
background_rgb = hex_to_rgb(background)
if _contrast_ratio(foreground_rgb, background_rgb) >= minimum:
return color
pole = (0, 0, 0) if _relative_luminance(background_rgb) > 0.5 else (255, 255, 255)
for step in range(1, 21):
candidate = mix_rgb(foreground_rgb, pole, step * 0.05)
if _contrast_ratio(candidate, background_rgb) >= minimum:
return rgb_to_hex(candidate)
return rgb_to_hex(pole)
def _resolve_charted_signal(style: str, alpha: float) -> Optional[str]:
"""Keep age tinting without allowing explanatory labels to disappear."""
return _ensure_contrast(_resolve(style, alpha), _palette()["bg"], _CHARTED_SIGNAL_MIN_CONTRAST)
def _row_to_text(row: list, color: bool):
from rich.text import Text
text = Text()
for run in row:
chunk, style = run[0], 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", []))
def st(style: Optional[str]) -> Optional[str]:
return style if color else None
title = Text()
title.append("✦ Journey ", style=st(f"bold {_TITLE_COLOR}"))
title.append("· learned skills & memories over time", style=st("grey62"))
parts: list[Any] = [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=st("grey62"))
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=st("grey54"))
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=st("grey54"))
axis_line.append(" " * max(1, inner - len(axis["start"]) - len(axis["end"])))
axis_line.append(axis["end"], style=st("grey54"))
parts.append(axis_line)
pct = int(round(reveal * 100))
foot = Text(" ")
foot.append("◷ ", style=st("grey54"))
foot.append(frame["date"] or "—", style=st(_TITLE_COLOR))
foot.append(f" {frame['visible']}/{count} revealed · {pct}%", style=st("grey54"))
parts.append(foot)
labels = frame.get("labels", [])
if labels:
parts.append(Text(""))
parts.append(Text(" charted signals", style=st("grey62")))
for item in labels[:6]:
row = Text(" ")
row.append(f"{item['key']} ", style=st("grey70"))
signal_style = (
_resolve_charted_signal(item["style"], float(item.get("alpha", 1.0))) if color else None
)
row.append(f"{item['glyph']} ", style=signal_style)
row.append(str(item["label"]), style=signal_style)
meta = str(item["meta"])
row.append(f" {meta if len(meta) <= 32 else meta[:29] + '…'}", style=st("grey54"))
parts.append(row)
for line_text in summary:
parts.append(Text(" " + line_text, style=st("grey62")))
return Group(*parts)
def _console(*, color: bool, width: Optional[int] = None, force: bool = False):
"""A Rich console. ``force`` emits truecolor ANSI even into a captured stream — the interactive CLI
grabs that output and re-renders it through prompt_toolkit (raw escapes to a real terminal would
otherwise be swallowed). Mirrors the ``ChatConsole`` idiom in ``cli.py``.
"""
from rich.console import Console
extra = {"force_terminal": True, "color_system": "truecolor"} if force else {}
return Console(no_color=not color, width=width, **extra)
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(color=color, width=cols, force=bool(getattr(args, "force_color", False)))
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
# ── list / delete / edit ─────────────────────────────────────────────────────
def _cmd_list(args: argparse.Namespace) -> int:
from agent.learning_graph_render import format_date
console = _console(color=not bool(getattr(args, "no_color", False)), force=bool(getattr(args, "force_color", False)))
nodes = sorted(_build_payload().get("nodes", []), key=lambda n: n.get("timestamp") or 0)
if not nodes:
console.print("[grey62]No learning yet.[/grey62]")
return 0
for node in nodes:
glyph = "◆" if node.get("kind") == "memory" else "●"
date = format_date(node.get("timestamp"))
console.print(f"[grey54]{node['id']}[/grey54] {glyph} {node.get('label', '')} [grey54]{date}[/grey54]")
return 0
def _lookup_node(node_id: str) -> Optional[dict]:
"""Resolve a node id via ``node_detail``; print the failure and return None when missing."""
from agent.learning_mutations import node_detail
detail = node_detail(node_id)
if not detail.get("ok"):
print(f" {detail.get('message', 'not found')}")
return None
return detail
def _cmd_delete(args: argparse.Namespace) -> int:
from agent.learning_mutations import delete_node
detail = _lookup_node(args.node)
if detail is None:
return 1
if not getattr(args, "yes", False):
try:
if input(f" Delete {detail['label']!r}? [y/N] ").strip().lower() not in ("y", "yes"):
print(" aborted")
return 1
except (EOFError, KeyboardInterrupt):
print("\n aborted")
return 1
res = delete_node(args.node)
print(f" {res['message']}")
return 0 if res.get("ok") else 1
def _cmd_edit(args: argparse.Namespace) -> int:
from agent.learning_mutations import edit_node
detail = _lookup_node(args.node)
if detail is None:
return 1
edited = _open_in_editor(detail["content"], suffix=".md" if detail["kind"] == "skill" else ".txt")
if edited is None or edited.strip() == detail["content"].strip():
print(" no changes")
return 0
res = edit_node(args.node, edited)
print(f" {res['message']}")
return 0 if res.get("ok") else 1
def _open_in_editor(initial: str, *, suffix: str) -> Optional[str]:
import os
import subprocess
import tempfile
editor = os.environ.get("EDITOR") or os.environ.get("VISUAL") or "vi"
with tempfile.NamedTemporaryFile("w", suffix=suffix, delete=False, encoding="utf-8") as fh:
fh.write(initial)
path = fh.name
try:
subprocess.call([*editor.split(), path])
with open(path, encoding="utf-8") as fh:
return fh.read()
except OSError as exc:
print(f" editor failed: {exc}")
return None
finally:
try:
os.unlink(path)
except OSError:
pass
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.")
# Force ANSI even when stdout is captured — the interactive CLI re-renders it.
parent.add_argument("--force-color", action="store_true", help=argparse.SUPPRESS)
parent.add_argument("--json", action="store_true", help="Print the raw graph payload as JSON and exit.")
parent.set_defaults(func=_cmd_show)
sub = parent.add_subparsers(dest="journey_action")
p_list = sub.add_parser("list", help="List node ids (for delete/edit).")
p_list.add_argument("--no-color", action="store_true")
p_list.add_argument("--force-color", action="store_true", help=argparse.SUPPRESS)
p_list.set_defaults(func=_cmd_list)
p_del = sub.add_parser("delete", help="Delete a learned skill (archived) or memory by node id.")
p_del.add_argument("node", help="Node id (skill name or memory:<source>:<index>; see `journey list`).")
p_del.add_argument("-y", "--yes", action="store_true", help="Skip the confirmation prompt.")
p_del.set_defaults(func=_cmd_delete)
p_edit = sub.add_parser("edit", help="Edit a learned skill or memory by node id in $EDITOR.")
p_edit.add_argument("node", help="Node id (skill name or memory:<source>:<index>; see `journey list`).")
p_edit.set_defaults(func=_cmd_edit)
if __name__ == "__main__":
_p = argparse.ArgumentParser(prog="hermes journey")
register_cli(_p)
_a = _p.parse_args()
sys.exit(_a.func(_a))
# ---- BEGIN PLUGIN-COMPAT (revert-scheduled; see COMPAT_MANIFEST.md) ----
# Names external plugins imported from this module before the Sep 2026 decomposition.
# Internal code MUST NOT use these (scripts/check_compat_pointers.py fails CI if it does).
# The whole block is removed by reverting the commit that added it.
def cmd_journey(args: argparse.Namespace) -> int:
return _cmd_show(args)
# ---- END PLUGIN-COMPAT ----