# Conflicts: # AGENTS.md # acp_adapter/edit_approval.py # acp_adapter/server.py # agent/agent_init.py # agent/anthropic_adapter.py # agent/anthropic_credentials.py # agent/auxiliary_client.py # agent/azure_identity_adapter.py # agent/bedrock_adapter.py # agent/browser_registry.py # agent/chat_completion_helpers.py # agent/coding_context.py # agent/context_references.py # agent/conversation_loop.py # agent/copilot_acp_client.py # agent/credits_tracker.py # agent/curator.py # agent/curator_backup.py # agent/deadline.py # agent/display.py # agent/errors.py # agent/estop.py # agent/i18n.py # agent/image_gen_registry.py # agent/image_routing.py # agent/learning_graph.py # agent/learning_mutations.py # agent/lsp/servers.py # agent/model_metadata.py # agent/models_dev.py # agent/monitoring/gateway_health_export.py # agent/monitoring/otlp_exporter.py # agent/pet/store.py # agent/process_bootstrap.py # agent/prompt_builder.py # agent/proxy_sources/iron_proxy.py # agent/secret_sources/_cache.py # agent/secret_sources/bitwarden.py # agent/secret_sources/registry.py # agent/shell_hooks.py # agent/skill_bundles.py # agent/skill_commands.py # agent/skill_utils.py # agent/ssl_guard.py # agent/ssl_verify.py # agent/system_prompt.py # agent/terminal_env_registry.py # agent/trace_upload.py # agent/transcription_registry.py # agent/tts_registry.py # agent/verify/environment.py # agent/vertex_adapter.py # agent/video_gen_registry.py # agent/web_search_registry.py # cli.py # cron/jobs.py # cron/scheduler.py # gateway/agent_cache_pressure.py # gateway/cgroup_cleanup.py # gateway/channel_directory.py # gateway/config.py # gateway/control_socket.py # gateway/dead_targets.py # gateway/drain_control.py # gateway/hooks.py # gateway/kanban_watchers.py # gateway/lifecycle_ledger.py # gateway/mirror.py # gateway/pairing.py # gateway/platform_registry.py # gateway/platforms/helpers.py # gateway/platforms/weixin.py # gateway/readiness.py # gateway/restart_loop_guard.py # gateway/rich_sent_store.py # gateway/run.py # gateway/session.py # gateway/shutdown_flush.py # gateway/shutdown_forensics.py # gateway/slash_commands.py # gateway/status.py # gateway/sticker_cache.py # gateway/whatsapp_identity.py # hermes_bootstrap.py # hermes_cli/_early_recovery.py # hermes_cli/_install_repair.py # hermes_cli/_startup_fast.py # hermes_cli/_subprocess_compat.py # hermes_cli/agent_plugins.py # hermes_cli/auth.py # hermes_cli/backup.py # hermes_cli/banner.py # hermes_cli/browser_connect.py # hermes_cli/build_info.py # hermes_cli/cli_agent_setup_mixin.py # hermes_cli/cli_commands_mixin.py # hermes_cli/codex_models.py # hermes_cli/config.py # hermes_cli/config_defaults.py # hermes_cli/config_migrations.py # hermes_cli/container_boot.py # hermes_cli/dashboard_auth/registry.py # hermes_cli/debug.py # hermes_cli/dep_ensure.py # hermes_cli/doctor.py # hermes_cli/doctor_live.py # hermes_cli/dump.py # hermes_cli/env_loader.py # hermes_cli/foreign_sessions.py # hermes_cli/gateway.py # hermes_cli/gateway_windows.py # hermes_cli/gui_uninstall.py # hermes_cli/image_provenance.py # hermes_cli/install_identity.py # hermes_cli/kanban.py # hermes_cli/kanban_db.py # hermes_cli/linux_desktop_entry.py # hermes_cli/local_runtime/binaries.py # hermes_cli/local_runtime/endpoint.py # hermes_cli/local_runtime/growth.py # hermes_cli/local_runtime/supervisor.py # hermes_cli/logs.py # hermes_cli/macos_tcc_anchor.py # hermes_cli/main.py # hermes_cli/memory_setup.py # hermes_cli/model_catalog.py # hermes_cli/models.py # hermes_cli/nous_subscription.py # hermes_cli/npm_engine.py # hermes_cli/plugin_index.py # hermes_cli/plugins.py # hermes_cli/plugins_cmd.py # hermes_cli/profile_distribution.py # hermes_cli/profiles.py # hermes_cli/prompt_size.py # hermes_cli/psutil_android.py # hermes_cli/runtime_repair.py # hermes_cli/security_advisories.py # hermes_cli/security_audit.py # hermes_cli/security_audit_startup.py # hermes_cli/service_manager.py # hermes_cli/session_export_md.py # hermes_cli/setup.py # hermes_cli/skills_hub.py # hermes_cli/slack_cli.py # hermes_cli/status.py # hermes_cli/subcommands/gateway.py # hermes_cli/subcommands/uninstall.py # hermes_cli/tools_config.py # hermes_cli/uninstall.py # hermes_cli/update_cmd.py # hermes_cli/update_contract.py # hermes_cli/update_inventory.py # hermes_cli/update_lock.py # hermes_cli/update_receipt.py # hermes_cli/urllib_security.py # hermes_cli/web_routers/local_models.py # hermes_cli/web_routers/profiles.py # hermes_cli/web_routers/skills.py # hermes_cli/web_server.py # hermes_constants.py # hermes_state.py # plugins/disk-cleanup/__init__.py # plugins/disk-cleanup/disk_cleanup.py # plugins/google_meet/node/registry.py # plugins/google_meet/node/server.py # plugins/google_meet/process_manager.py # plugins/google_meet/realtime/openai_client.py # plugins/hermes-achievements/dashboard/plugin_api.py # plugins/memory/hindsight/__init__.py # plugins/memory/honcho/__init__.py # plugins/memory/honcho/cli.py # plugins/memory/honcho/client.py # plugins/memory/honcho/oauth.py # plugins/memory/honcho/session.py # plugins/memory/mem0/__init__.py # plugins/memory/mem0/_setup.py # plugins/memory/openviking/__init__.py # plugins/memory/retaindb/__init__.py # plugins/memory/supermemory/__init__.py # plugins/platforms/a2a/protocol.py # plugins/platforms/dingtalk/adapter.py # plugins/platforms/discord/adapter.py # plugins/platforms/feishu/adapter.py # plugins/platforms/google_chat/adapter.py # plugins/platforms/matrix/adapter.py # plugins/platforms/photon/adapter.py # plugins/platforms/photon/auth.py # plugins/platforms/photon/cli.py # plugins/platforms/slack/adapter.py # plugins/platforms/teams/adapter.py # plugins/platforms/telegram/adapter.py # plugins/platforms/wecom/callback_adapter.py # plugins/platforms/whatsapp/adapter.py # plugins/teams_pipeline/store.py # plugins/video_gen/fal/__init__.py # plugins/web/ddgs/provider.py # plugins/web/exa/provider.py # plugins/web/firecrawl/provider.py # plugins/web/parallel/provider.py # tests/agent/test_ssl_ca_guard.py # tests/hermes_cli/test_certifi_repair.py # tests/hermes_cli/test_cmd_update.py # tests/hermes_cli/test_cmd_update_apt.py # tests/hermes_cli/test_dashboard_unified_launch.py # tests/hermes_cli/test_dep_ensure.py # tests/hermes_cli/test_doctor.py # tests/hermes_cli/test_doctor_live.py # tests/hermes_cli/test_gui_command.py # tests/hermes_cli/test_kanban_boards.py # tests/hermes_cli/test_kanban_db.py # tests/hermes_cli/test_lazy_refresh_venv_repair.py # tests/hermes_cli/test_memory_setup_provider_arg.py # tests/hermes_cli/test_nous_subscription.py # tests/hermes_cli/test_pip_install_detection.py # tests/hermes_cli/test_profile_export_credentials.py # tests/hermes_cli/test_psutil_android_extract.py # tests/hermes_cli/test_status.py # tests/hermes_cli/test_tui_npm_install.py # tests/hermes_cli/test_update_fleet_restart_pending.py # tests/hermes_cli/test_update_head_moved_gate.py # tests/hermes_cli/test_update_interrupted_recovery.py # tests/hermes_cli/test_web_server.py # tests/hermes_cli/test_web_ui_build.py # tests/test_hermes_logging.py # tests/test_managed_runtime_resolution.py # tests/tools/test_browser_chromium_autoinstall.py # tests/tools/test_browser_chromium_check.py # tests/tools/test_browser_homebrew_paths.py # tests/tools/test_browser_lightpanda.py # tests/tools/test_browser_npx_warmup.py # tests/tools/test_browser_open_timeout.py # tests/tools/test_browser_orphan_reaper.py # tests/tools/test_browser_real_profile.py # tests/tools/test_browser_suspect_recycle.py # tests/tools/test_find_shell.py # tests/tools/test_local_env_blocklist.py # tests/tools/test_macos_protected_search.py # tests/tui_gateway/test_compute_host.py # tools/approval.py # tools/blueprints.py # tools/bot_mode_dm.py # tools/bot_mode_probe.py # tools/bot_relay.py # tools/browser_tool.py # tools/browser_use_cli.py # tools/checkpoint_manager.py # tools/code_execution_tool.py # tools/code_kernel.py # tools/computer_use/cua_backend.py # tools/cronjob_tools.py # tools/discord_tool.py # tools/environments/base.py # tools/environments/daytona.py # tools/environments/local.py # tools/environments/modal.py # tools/environments/vercel_sandbox.py # tools/fal_common.py # tools/file_operations.py # tools/lazy_deps.py # tools/mcp_tool.py # tools/neutts_synth.py # tools/process_registry.py # tools/read_extract.py # tools/registry.py # tools/skill_ledger.py # tools/skill_linter.py # tools/skill_manager_tool.py # tools/skill_usage.py # tools/skills_ast_audit.py # tools/skills_guard.py # tools/skills_hub.py # tools/skills_sync.py # tools/skills_sync_client.py # tools/skills_tool.py # tools/terminal_scope.py # tools/terminal_tool.py # tools/tirith_security.py # tools/transcription_tools.py # tools/tts_tool.py # tools/vision_tools.py # tools/voice_mode.py # tools/wake_word.py # tools/web_result_cache.py # tools/website_policy.py # tools/working_diff.py # tools/write_approval.py # tui_gateway/entry.py # tui_gateway/methods_tools.py # tui_gateway/server.py
391 lines
15 KiB
Python
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-sig") 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 ----
|