"""/sl — custom session list slash command. Reads column config from ~/.hermes/session_list_config.yaml and renders sessions with user-specified columns and widths. Special column: field "#" renders as the row index (1-based), compatible with /resume . Survives `hermes update` because it lives in ~/.hermes/plugins/. """ from __future__ import annotations import time from datetime import datetime from pathlib import Path from typing import Optional # ── Helpers ────────────────────────────────────────────────────────── def _hermes_home() -> Path: """Return Hermes home, delegating to the authoritative source when available.""" import os, sys # Use official hermes_constants if available (handles profiles, overrides, etc.) try: from hermes_constants import get_hermes_home return get_hermes_home() except ImportError: pass if "HERMES_HOME" in os.environ: return Path(os.environ["HERMES_HOME"]) # Windows: %LOCALAPPDATA%\hermes if sys.platform == "win32": localappdata = os.environ.get("LOCALAPPDATA", os.path.expanduser("~")) return Path(localappdata) / "hermes" return Path(os.path.expanduser("~/.hermes")) def _as_float(v): if v is None: return None if isinstance(v, (int, float)): return float(v) try: return float(v) except (ValueError, TypeError): return v def _relative_time(ts) -> str: ts = _as_float(ts) if not ts: return "?" delta = time.time() - ts if delta < 60: return "just now" if delta < 3600: return f"{int(delta / 60)}m ago" if delta < 86400: return f"{int(delta / 3600)}h ago" if delta < 172800: return "yesterday" if delta < 604800: return f"{int(delta / 86400)}d ago" return datetime.fromtimestamp(ts).strftime("%Y-%m-%d") def _fmt_tokens(session: dict, field: str) -> str: """Format token counts in human-readable form (e.g. '46.8K'). Special computed fields: total_tokens = input + output net_input = input - cache_read (actual new prompt tokens) net_total = net_input + output (actual billable tokens) """ inp = _as_float(session.get("input_tokens")) or 0 out = _as_float(session.get("output_tokens")) or 0 cache = _as_float(session.get("cache_read_tokens")) or 0 if field == "total_tokens": raw = inp + out elif field == "net_input": raw = max(0, inp - cache) elif field == "net_total": raw = max(0, inp - cache) + out else: raw = _as_float(session.get(field)) or 0 if raw >= 1_000_000: return f"{raw / 1_000_000:.1f}M" if raw >= 1_000: return f"{raw / 1_000:.1f}K" return str(int(raw)) def _format_value(session: dict, field: str, index: int = 0) -> str: """Format a single field value. *index* is the 1-based row number for '#' field.""" if field == "#": return str(index) raw = session.get(field) if field == "last_active": return _relative_time(raw) if field in ("started_at", "ended_at"): ts = _as_float(raw) return datetime.fromtimestamp(ts).strftime("%Y-%m-%d %H:%M") if ts else "—" if field == "title": v = session.get(field) if v is None or v == "None" or str(v).strip() == "": return "—" return str(v)[:40] if field == "preview": return (str(raw) if raw else "")[:80] if field == "message_count": return str(int(_as_float(raw) or 0)) if field == "model": return (str(raw) if raw else "?")[:30] if field == "source": return str(raw) if raw else "?" if field in ("input_tokens", "output_tokens", "cache_read_tokens", "cache_write_tokens", "reasoning_tokens", "total_tokens", "net_input", "net_total"): return _fmt_tokens(session, field) return str(raw) if raw is not None else "" def _build_header(columns: list) -> str: parts = [] for col in columns: w = col.get("width", 20) label = col["label"] parts.append(f"{label:<{w}}" if w else label) return " ".join(parts) def _build_row(columns: list, session: dict, index: int = 0) -> str: parts = [] for col in columns: w = col.get("width", 20) val = _format_value(session, col["field"], index=index) if w == 0: parts.append(val) else: parts.append(f"{val[:w]:<{w}}") return " ".join(parts) # ── Default config bootstrapping ───────────────────────────────────── def _ensure_default_config() -> None: """Create session_list_config.yaml with sensible defaults if missing.""" config_path = _hermes_home() / "session_list_config.yaml" if config_path.exists(): return default_config = """\ # Hermes Session List — Column Configuration # # columns: ordered list of columns to display. # field: session row key — available fields: # # (row index), id, source, model, title, started_at, # ended_at, message_count, preview, last_active, # input_tokens, output_tokens, total_tokens, net_input, net_total, # cache_read_tokens, cache_write_tokens, reasoning_tokens # width: column width in characters # (0 = free-form for the LAST column — can overflow) # label: header text # # NOTE: input_tokens = cache miss (Hermes already deducts cache hits). # net_input = max(0, input - cache) — would double-deduct. # # Used by /sl slash command. Survives `hermes update`. columns: - {field: last_active, width: 10, label: Active} - {field: title, width: 42, label: Title} - {field: message_count, width: 4, label: Msgs} - {field: cache_read_tokens, width: 6, label: Hit} - {field: input_tokens, width: 6, label: Miss} - {field: output_tokens, width: 6, label: Out} - {field: id, width: 34, label: Session ID} """ try: config_path.parent.mkdir(parents=True, exist_ok=True) config_path.write_text(default_config, encoding="utf-8") except Exception: pass # Silent fail — handler falls back to hardcoded defaults # ── Slash command handler ──────────────────────────────────────────── def _handle_sl(raw_args: str) -> Optional[str]: """Handle /sl [limit] — show recent sessions with custom columns.""" import yaml config_path = _hermes_home() / "session_list_config.yaml" # Load column config try: if config_path.exists(): cfg = yaml.safe_load(config_path.read_text(encoding="utf-8")) columns = cfg.get("columns", []) else: columns = [] except Exception: columns = [] if not columns: columns = [ {"field": "last_active", "width": 10, "label": "Active"}, {"field": "title", "width": 42, "label": "Title"}, {"field": "message_count", "width": 4, "label": "Msgs"}, {"field": "cache_read_tokens", "width": 6, "label": "Hit"}, {"field": "input_tokens", "width": 6, "label": "Miss"}, {"field": "output_tokens", "width": 6, "label": "Out"}, {"field": "id", "width": 34, "label": "Session ID"}, ] # Parse limit from args limit = 20 args = raw_args.strip().split() if args: try: limit = int(args[0]) except ValueError: pass # Query sessions from hermes_state import SessionDB db = SessionDB() sessions = db.list_sessions_rich(exclude_sources=["tool"], limit=limit) if not sessions: return "(._.) No previous sessions yet." # Render header = _build_header(columns) sep = "─" * len(header) lines = [header, sep] for idx, s in enumerate(sessions, start=1): lines.append(_build_row(columns, s, index=idx)) # Add hint for /resume lines.append("") lines.append("Use /resume to continue. Example: /resume 20260612_222504_8a868b") return "\n".join(lines) # ── Plugin registration ────────────────────────────────────────────── def register(ctx) -> None: # Ensure default config file exists on first load _ensure_default_config() ctx.register_command( "sl", handler=_handle_sl, description="List recent sessions with custom columns (config: ~/.hermes/session_list_config.yaml)", args_hint="[limit]", )