"""``hermes logs`` — view and filter Hermes log files. hermes logs # last 50 lines of agent.log hermes logs -f # follow agent.log in real time hermes logs errors # last 50 lines of errors.log hermes logs gateway -n 100 # last 100 lines of gateway.log hermes logs gui -f # follow gui.log (dashboard/pty/ws) hermes logs desktop -f # follow desktop.log (Electron app boot/backend) hermes logs --level WARNING # only WARNING+ lines hermes logs --session abc123 # filter by session ID substring hermes logs --component tools # only tool-related lines hermes logs --since 1h # lines from the last hour hermes logs --since 30m -f # follow, starting 30 min ago """ import re import sys import time from datetime import datetime, timedelta from pathlib import Path from typing import Optional, Sequence from hermes_constants import get_hermes_home, display_hermes_home # Known log files (name → filename) LOG_FILES = { "agent": "agent.log", "errors": "errors.log", "gateway": "gateway.log", "gui": "gui.log", "desktop": "desktop.log", # Every stdio MCP subprocess's stderr (tools/mcp_tool.py redirects it # here, with per-server session markers) — the "MCP output channel". "mcp": "mcp-stderr.log", } # Log line timestamp regex — matches "2026-04-05 22:35:00,123" or # "2026-04-05 22:35:00" at the start of a line. _TS_RE = re.compile(r"^(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2})") # Level extraction — matches " INFO ", " WARNING ", " ERROR ", " DEBUG ", " CRITICAL " _LEVEL_RE = re.compile(r"\s(DEBUG|INFO|WARNING|ERROR|CRITICAL)\s") # Logger name extraction — after level and optional session tag, the next # non-space token before ":" is the logger name. # Matches: "INFO gateway.run:" or "INFO [sess_abc] tools.terminal_tool:" _LOGGER_NAME_RE = re.compile( r"\s(?:DEBUG|INFO|WARNING|ERROR|CRITICAL)" # level r"(?:\s+\[.*?\])?" # optional session tag r"\s+(\S+):" # logger name ) # Level ordering for >= filtering _LEVEL_ORDER = {"DEBUG": 0, "INFO": 1, "WARNING": 2, "ERROR": 3, "CRITICAL": 4} def _parse_since(since_str: str) -> Optional[datetime]: """Parse a relative time string like '1h', '30m', '2d' into a datetime cutoff. Returns None if the string can't be parsed. """ since_str = since_str.strip().lower() match = re.match(r"^(\d+)\s*([smhd])$", since_str) if not match: return None unit = {"s": "seconds", "m": "minutes", "h": "hours", "d": "days"}[match.group(2)] return datetime.now() - timedelta(**{unit: int(match.group(1))}) def _parse_line_timestamp(line: str) -> Optional[datetime]: """Extract timestamp from a log line. Returns None if not parseable.""" m = _TS_RE.match(line) if not m: return None try: return datetime.strptime(m.group(1), "%Y-%m-%d %H:%M:%S") except ValueError: return None def _extract_level(line: str) -> Optional[str]: """Extract the log level from a line.""" m = _LEVEL_RE.search(line) return m.group(1) if m else None def _extract_logger_name(line: str) -> Optional[str]: """Extract the logger name from a log line.""" m = _LOGGER_NAME_RE.search(line) return m.group(1) if m else None def _line_matches_component(line: str, prefixes: Sequence[str]) -> bool: """Check if a log line's logger name starts with any of *prefixes*.""" name = _extract_logger_name(line) return name is not None and name.startswith(tuple(prefixes)) def _matches_filters( line: str, *, min_level: Optional[str] = None, session_filter: Optional[str] = None, since: Optional[datetime] = None, component_prefixes: Optional[Sequence[str]] = None, ) -> bool: """Check if a log line passes all active filters.""" if since is not None: ts = _parse_line_timestamp(line) if ts is not None and ts < since: return False if min_level is not None: level = _extract_level(line) if level is not None and _LEVEL_ORDER.get(level, 0) < _LEVEL_ORDER.get(min_level, 0): return False if session_filter is not None and session_filter not in line: return False return component_prefixes is None or _line_matches_component(line, component_prefixes) def tail_log( log_name: str = "agent", *, num_lines: int = 50, follow: bool = False, level: Optional[str] = None, session: Optional[str] = None, since: Optional[str] = None, component: Optional[str] = None, ) -> None: """Read and display log lines, optionally following in real time.""" filename = LOG_FILES.get(log_name) if filename is None: print(f"Unknown log: {log_name!r}. Available: {', '.join(sorted(LOG_FILES))}") sys.exit(1) log_path = get_hermes_home() / "logs" / filename if not log_path.exists(): print(f"Log file not found: {log_path}") print("(Logs are created when Hermes runs — try 'hermes chat' first)") sys.exit(1) # Parse --since into a datetime cutoff since_dt = None if since: since_dt = _parse_since(since) if since_dt is None: print(f"Invalid --since value: {since!r}. Use format like '1h', '30m', '2d'.") sys.exit(1) min_level = level.upper() if level else None if min_level and min_level not in _LEVEL_ORDER: print(f"Invalid --level: {level!r}. Use DEBUG, INFO, WARNING, ERROR, or CRITICAL.") sys.exit(1) # Resolve component to logger name prefixes component_prefixes = None if component: from hermes_logging import COMPONENT_PREFIXES component_lower = component.lower() if component_lower not in COMPONENT_PREFIXES: available = ", ".join(sorted(COMPONENT_PREFIXES)) print(f"Unknown component: {component!r}. Available: {available}") sys.exit(1) component_prefixes = COMPONENT_PREFIXES[component_lower] filters = dict(min_level=min_level, session_filter=session, since=since_dt, component_prefixes=component_prefixes) has_filters = any(v is not None for v in filters.values()) # Read and display the tail try: lines = _read_tail(log_path, num_lines, has_filters=has_filters, **filters) except PermissionError: print(f"Permission denied: {log_path}") sys.exit(1) # Print header filter_parts = [ f"{label}={value}" for label, value in (("level>", min_level), ("session", session), ("component", component), ("since", since)) if value ] filter_desc = f" [{', '.join(filter_parts)}]" if filter_parts else "" mode = "Ctrl+C to stop" if follow else f"last {num_lines}" print(f"--- {display_hermes_home()}/logs/{filename}{filter_desc} ({mode}) ---") for line in lines: print(line, end="") if not follow: return # Follow mode — poll for new content try: _follow_log(log_path, **filters) except KeyboardInterrupt: print("\n--- stopped ---") def _read_tail( path: Path, num_lines: int, *, has_filters: bool = False, min_level: Optional[str] = None, session_filter: Optional[str] = None, since: Optional[datetime] = None, component_prefixes: Optional[Sequence[str]] = None, ) -> list: """Read the last *num_lines* matching lines from a log file.""" if not has_filters: return _read_last_n_lines(path, num_lines) # Read more lines to ensure we get enough after filtering. # For large files, read last 10K lines and filter down. raw_lines = _read_last_n_lines(path, max(num_lines * 20, 2000)) filtered = [ l for l in raw_lines if _matches_filters(l, min_level=min_level, session_filter=session_filter, since=since, component_prefixes=component_prefixes) ] return filtered[-num_lines:] def _read_all_lines(path: Path) -> list: with open(path, "r", encoding="utf-8", errors="replace") as f: return f.readlines() def _read_last_n_lines(path: Path, n: int) -> list: """Efficiently read the last N lines from a file.""" try: size = path.stat().st_size if size == 0: return [] # For files up to 1MB, just read the whole thing — simple and correct. if size <= 1_048_576: return _read_all_lines(path)[-n:] # For large files, read chunks from the end. with open(path, "rb") as f: chunk_size = 8192 lines = [] pos = size while pos > 0 and len(lines) <= n + 1: read_size = min(chunk_size, pos) pos -= read_size f.seek(pos) chunk = f.read(read_size) chunk_lines = chunk.split(b"\n") if lines: # Merge the last partial line of the new chunk with the # first partial line of what we already have. lines[0] = chunk_lines[-1] + lines[0] lines = chunk_lines[:-1] + lines else: lines = chunk_lines chunk_size = min(chunk_size * 2, 65536) # Decode and return last N non-empty lines. decoded = [raw.decode("utf-8", errors="replace") + "\n" for raw in lines if raw.strip()] return decoded[-n:] except Exception: # Fallback: read entire file return _read_all_lines(path)[-n:] def _follow_log( path: Path, *, min_level: Optional[str] = None, session_filter: Optional[str] = None, since: Optional[datetime] = None, component_prefixes: Optional[Sequence[str]] = None, ) -> None: """Poll a log file for new content and print matching lines.""" with open(path, "r", encoding="utf-8", errors="replace") as f: # Seek to end f.seek(0, 2) while True: line = f.readline() if line: if _matches_filters(line, min_level=min_level, session_filter=session_filter, since=since, component_prefixes=component_prefixes): print(line, end="") sys.stdout.flush() else: time.sleep(0.3) def list_logs() -> None: """Print available log files with sizes.""" log_dir = get_hermes_home() / "logs" if not log_dir.exists(): print(f"No logs directory at {display_hermes_home()}/logs/") return print(f"Log files in {display_hermes_home()}/logs/:\n") found = False for entry in sorted(log_dir.iterdir()): if entry.is_file() and entry.suffix == ".log": st = entry.stat() size = st.st_size mtime = datetime.fromtimestamp(st.st_mtime) if size < 1024: size_str = f"{size}B" elif size < 1024 * 1024: size_str = f"{size / 1024:.1f}KB" else: size_str = f"{size / (1024 * 1024):.1f}MB" age_s = (datetime.now() - mtime).total_seconds() if age_s < 60: age_str = "just now" elif age_s < 3600: age_str = f"{int(age_s / 60)}m ago" elif age_s < 86400: age_str = f"{int(age_s / 3600)}h ago" else: age_str = mtime.strftime("%Y-%m-%d") print(f" {entry.name:<25} {size_str:>8} {age_str}") found = True if not found: print(" (no log files yet — run 'hermes chat' to generate logs)")