* refactor(plugins): remove the Sep 2026 decomposition compat layer on schedule The PLUGIN-COMPAT layer (2776813df3+d63e380324+0a5164cebe) kept pre-#102117 import paths alive for external plugins until 2026-09-14. That window closed two weeks ago; since then the loader has already been skipping plugins that use the old paths. This removes the layer itself: - 328 appended `PLUGIN-COMPAT` blocks (lazy `__getattr__` pointer tables, re-exported third-party names, restored dead definitions) and the three re-export stub modules (gateway/startup_watchdog, hermes_cli/observability/relay_runtime, tools/environments/modal_utils) - COMPAT_MANIFEST.md, compat_manifest.json, scripts/check_compat_pointers.py and its lint step - the reporting surfaces: CLI banner notice, `hermes plugins compat`, the `hermes doctor` section, the post-update notice, the Desktop one-time dialog, the loader's pre-import skip and the `plugins.allow_deprecated_imports` escape hatch An external plugin that still imports an old path now fails to load with its ImportError as the reason in `hermes plugins list`, the same path as any broken plugin. hermes_cli/plugin_compat.py stays as three inert stubs (compat_report, removal_in_effect, summary_lines): an already-running pre-removal `hermes update` lazy-imports them after the checkout swap (tests/compat/old_updater_surface.json). In-tree fallout, both already dead: hermes_cli/setup.py::_check_espeak_ng (no callers; its `shutil` came from a compat block) and gateway/config.py::SessionResetPolicy ("retained solely for the scheduled plugin-compat window"). Two test_run_agent patches targeted the removed `run_agent.handle_function_call` pointer; they now patch `model_tools.handle_function_call`, the seam production reads, like every sibling test in that file. * chore: retrigger CI (zero-job startup_failure phantom) * test: drop resolution allowlist rows for the two deleted which() sites hermes_cli/setup.py::_check_espeak_ng (dead) and tools/skillevaluator_scan.py::scanner_available (a restored definition inside a PLUGIN-COMPAT block) no longer exist; the stale-row gate requires their allowlist entries go with them.
338 lines
14 KiB
Python
338 lines
14 KiB
Python
"""Import sessions from foreign coding agents (Claude Code, Codex CLI). Foreign files are only ever read;
|
|
imported history must satisfy the provider role-alternation invariant (see ``_merge_turns``)."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import contextlib
|
|
import json
|
|
import os
|
|
import re
|
|
import sys
|
|
from dataclasses import dataclass
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
from stat import S_ISREG
|
|
from typing import Any, Dict, List, Optional, Tuple
|
|
|
|
from hermes_state_ids import new_session_id
|
|
|
|
# User-message texts that are really injected context wrappers, not typed input.
|
|
_WRAPPER_TAG_RE = re.compile(
|
|
r"^<(?:user_instructions|environment_context|recommended_plugins|"
|
|
r"skills_instructions|permissions[_-]instructions|turn_context|"
|
|
r"command-name|command-message|local-command-stdout|system-reminder)\b", re.IGNORECASE)
|
|
|
|
_TITLE_MAX = 60
|
|
_SOURCE_LABELS = {"claude": "Claude Code", "codex": "Codex CLI"}
|
|
_SOURCE_DB_NAMES = {"claude": "claude-code", "codex": "codex-cli"}
|
|
|
|
|
|
@dataclass
|
|
class ForeignSession:
|
|
"""A discoverable session in another tool's on-disk store."""
|
|
|
|
source: str # "claude" | "codex"
|
|
path: Path
|
|
mtime: float
|
|
cwd: Optional[str] = None
|
|
title_guess: Optional[str] = None
|
|
turn_count: int = 0
|
|
session_id: Optional[str] = None # the foreign tool's own id
|
|
|
|
@property
|
|
def label(self) -> str:
|
|
title = (self.title_guess or "").strip() or self.path.stem
|
|
return f"[{_SOURCE_LABELS.get(self.source, self.source)}] {title[:_TITLE_MAX]}"
|
|
|
|
|
|
def _read_json_lines(path: Path):
|
|
"""Yield parsed JSON objects, silently skipping unparseable lines."""
|
|
try:
|
|
with open(path, "r", encoding="utf-8-sig", errors="replace") as f:
|
|
for line in f:
|
|
line = line.strip()
|
|
if not line:
|
|
continue
|
|
try:
|
|
obj = json.loads(line)
|
|
except (json.JSONDecodeError, ValueError):
|
|
continue
|
|
if isinstance(obj, dict):
|
|
yield obj
|
|
except OSError:
|
|
return
|
|
|
|
|
|
def _block_text(block: Any) -> str:
|
|
"""Plain text of one content block; tool_result, thinking/reasoning and unknown types yield ''."""
|
|
if isinstance(block, str):
|
|
return block
|
|
if not isinstance(block, dict):
|
|
return ""
|
|
btype = block.get("type")
|
|
if btype in ("text", "input_text", "output_text"):
|
|
return text if isinstance(text := block.get("text"), str) else ""
|
|
if btype == "tool_use": # Claude Code assistant block
|
|
return f"[ran tool: {block.get('name') or 'tool'}]"
|
|
return "[image]" if btype == "image" else ""
|
|
|
|
|
|
def _flatten_blocks(content: Any) -> str:
|
|
"""Flatten a message ``content`` (string or block list) to plain text."""
|
|
if isinstance(content, str):
|
|
return content
|
|
if not isinstance(content, list):
|
|
return ""
|
|
return "\n\n".join(p for p in (_block_text(b).strip() for b in content) if p)
|
|
|
|
|
|
def _merge_turns(raw_turns: List[Tuple[str, str]]) -> List[Dict[str, str]]:
|
|
"""Merge consecutive same-role turns; guarantee strict alternation.
|
|
|
|
A leading assistant turn (session began before the log window) gets a minimal user stub so the
|
|
first message is always ``user``; this is the only place a stub is ever inserted.
|
|
"""
|
|
merged: List[Dict[str, str]] = []
|
|
for role, text in raw_turns:
|
|
if not (text := text.strip()):
|
|
continue
|
|
if merged and merged[-1]["role"] == role:
|
|
merged[-1]["content"] += "\n\n" + text
|
|
else:
|
|
merged.append({"role": role, "content": text})
|
|
if merged and merged[0]["role"] == "assistant":
|
|
merged.insert(0, {"role": "user", "content": "(imported conversation begins with an assistant reply)"})
|
|
return merged
|
|
|
|
|
|
def _message_turn(message: Any) -> Optional[Tuple[str, str]]:
|
|
"""Normalize one message dict into a ``(role, text)`` turn, or None when it is not importable."""
|
|
role = message.get("role") if isinstance(message, dict) else None
|
|
if role not in ("user", "assistant"):
|
|
return None
|
|
text = _flatten_blocks(message.get("content"))
|
|
return None if not text or (role == "user" and _WRAPPER_TAG_RE.match(text.lstrip())) else (role, text)
|
|
|
|
|
|
def _first_user_line(turns: List[Tuple[str, str]]) -> Optional[str]:
|
|
for role, text in turns:
|
|
if role == "user" and (line := text.strip().partition("\n")[0].strip()):
|
|
return line[:_TITLE_MAX * 2]
|
|
return None
|
|
|
|
|
|
def _parsed(turns: List[Tuple[str, str]], cwd: Optional[str], session_id: Optional[str],
|
|
title: Optional[str] = None) -> Dict[str, Any]:
|
|
return {"turns": _merge_turns(turns), "cwd": cwd, "title_guess": title or _first_user_line(turns),
|
|
"session_id": session_id}
|
|
|
|
|
|
def parse_claude_session(path: Path) -> Dict[str, Any]:
|
|
"""Parse one Claude Code session JSONL into normalized turns + meta."""
|
|
turns: List[Tuple[str, str]] = []
|
|
cwd = summary = session_id = None
|
|
for obj in _read_json_lines(path):
|
|
otype = obj.get("type")
|
|
if otype == "summary":
|
|
if isinstance(s := obj.get("summary"), str) and s.strip():
|
|
summary = s.strip()
|
|
elif otype in ("user", "assistant") and not (obj.get("isSidechain") or obj.get("isMeta")):
|
|
if cwd is None and isinstance(obj.get("cwd"), str):
|
|
cwd = obj["cwd"]
|
|
if session_id is None and isinstance(obj.get("sessionId"), str):
|
|
session_id = obj["sessionId"]
|
|
if turn := _message_turn(obj.get("message")):
|
|
turns.append(turn)
|
|
return _parsed(turns, cwd, session_id, summary)
|
|
|
|
|
|
def parse_codex_session(path: Path) -> Dict[str, Any]:
|
|
"""Parse one Codex CLI rollout JSONL into normalized turns + meta."""
|
|
turns: List[Tuple[str, str]] = []
|
|
cwd = session_id = None
|
|
for obj in _read_json_lines(path):
|
|
otype, payload = obj.get("type"), obj.get("payload")
|
|
if not isinstance(payload, dict):
|
|
continue
|
|
if otype == "session_meta":
|
|
if isinstance(payload.get("cwd"), str):
|
|
cwd = payload["cwd"]
|
|
if isinstance(sid := payload.get("session_id") or payload.get("id"), str):
|
|
session_id = sid
|
|
elif otype == "response_item":
|
|
ptype = payload.get("type")
|
|
if ptype == "message" and (turn := _message_turn(payload)): # developer/system payloads skipped
|
|
turns.append(turn)
|
|
elif ptype in ("custom_tool_call", "function_call", "local_shell_call"):
|
|
# Assistant activity; merged into neighbours later. Tool outputs / reasoning skipped.
|
|
name = payload.get("name") or payload.get("tool") or "tool"
|
|
turns.append(("assistant", f"[ran tool: {name}]"))
|
|
return _parsed(turns, cwd, session_id)
|
|
|
|
|
|
# source -> (default root under ~, env override var, subdir under the env root, glob pattern,
|
|
# recursive, parser)
|
|
_SOURCES = {
|
|
"claude": ((".claude", "projects"), "CLAUDE_CONFIG_DIR", "projects", "*/*.jsonl", False,
|
|
parse_claude_session),
|
|
"codex": ((".codex", "sessions"), "CODEX_HOME", "sessions", "rollout-*.jsonl", True,
|
|
parse_codex_session),
|
|
}
|
|
|
|
|
|
def _parser(source: str):
|
|
return _SOURCES[source][5]
|
|
|
|
|
|
def _default_root(source: str) -> Path:
|
|
"""Default session store for *source*, honoring the tool's own relocation env var.
|
|
|
|
Claude Code moves its whole config dir with ``CLAUDE_CONFIG_DIR``; Codex CLI with
|
|
``CODEX_HOME``. A blank/whitespace value is treated as unset (an empty override must not
|
|
resolve to a relative ``"projects"`` under the CWD). Ported from cline/cline#13827."""
|
|
default_parts, env_var, env_subdir, *_ = _SOURCES[source]
|
|
override = os.environ.get(env_var, "").strip()
|
|
if override:
|
|
return Path(override).expanduser() / env_subdir
|
|
return Path.home().joinpath(*default_parts)
|
|
|
|
|
|
def _walk(source: str, root: Optional[Path] = None) -> List[Tuple[Path, os.stat_result]]:
|
|
"""Regular log files of *source* under *root* (default: the tool's env-aware store, see
|
|
``_default_root``) as ``(path, stat)``, newest first. Symlinks escaping the root and
|
|
unreadable/rotated entries are skipped, so one bad file never hides the rest. Shared by the
|
|
CLI picker and the desktop browser."""
|
|
pattern, recursive = _SOURCES[source][3], _SOURCES[source][4]
|
|
root = (Path(root) if root else _default_root(source)).resolve()
|
|
found: List[Tuple[Path, os.stat_result]] = []
|
|
for path in (root.rglob(pattern) if recursive else root.glob(pattern)) if root.is_dir() else ():
|
|
try:
|
|
resolved = path.resolve()
|
|
st = resolved.stat()
|
|
except OSError:
|
|
continue
|
|
if resolved.is_relative_to(root) and S_ISREG(st.st_mode):
|
|
found.append((resolved, st))
|
|
found.sort(key=lambda item: item[1].st_mtime, reverse=True)
|
|
return found
|
|
|
|
|
|
def _list_sessions(source: str, root: Optional[Path]) -> List[ForeignSession]:
|
|
parse = _parser(source)
|
|
results: List[ForeignSession] = []
|
|
for path, st in _walk(source, root):
|
|
parsed = parse(path)
|
|
if parsed["turns"]:
|
|
results.append(ForeignSession(source, path, st.st_mtime, parsed["cwd"], parsed["title_guess"],
|
|
len(parsed["turns"]), parsed["session_id"]))
|
|
return results
|
|
|
|
|
|
def import_foreign_session(source: str, path, db=None) -> str:
|
|
"""Import one foreign session into the Hermes SessionDB; returns the new Hermes session id.
|
|
|
|
Raises ``ValueError`` on unknown source or a session with no usable conversation turns."""
|
|
source = (source or "").strip().lower().lstrip("@")
|
|
if source not in _SOURCE_LABELS:
|
|
raise ValueError(f"Unknown foreign session source: {source!r}")
|
|
path = Path(path).expanduser()
|
|
if not path.is_file():
|
|
raise ValueError(f"Session file not found: {path}")
|
|
parsed = _parser(source)(path)
|
|
turns = parsed["turns"]
|
|
if not turns:
|
|
raise ValueError(f"No user/assistant conversation turns found in {path}")
|
|
first_user = _first_user_line([(t["role"], t["content"]) for t in turns]) or path.stem
|
|
if len(first_user) > _TITLE_MAX:
|
|
first_user = first_user[: _TITLE_MAX - 1] + "…"
|
|
tool = _SOURCE_DB_NAMES[source]
|
|
owns_db = db is None
|
|
if owns_db:
|
|
from hermes_state_registry import acquire
|
|
db = acquire() # the CLI resume that follows acquires this same handle
|
|
try:
|
|
session_id = new_session_id()
|
|
origin = {"imported_from": {"tool": tool, "path": str(path), "foreign_session_id": parsed.get("session_id")}}
|
|
db.create_session(session_id, source=tool, cwd=parsed.get("cwd"), origin_json=json.dumps(origin))
|
|
for turn in turns:
|
|
db.append_message(session_id, turn["role"], turn["content"])
|
|
with contextlib.suppress(Exception): # title is cosmetic; the import itself succeeded
|
|
db.set_session_title(session_id, f"Imported from {_SOURCE_LABELS[source]}: {first_user}")
|
|
return session_id
|
|
finally:
|
|
if owns_db:
|
|
with contextlib.suppress(Exception):
|
|
db.close()
|
|
|
|
|
|
def gather_foreign_sessions(source: Optional[str] = None, *, claude_root: Optional[Path] = None,
|
|
codex_root: Optional[Path] = None, limit: int = 25) -> List[ForeignSession]:
|
|
"""List foreign sessions across sources, newest first."""
|
|
sessions = [s for name, root in (("claude", claude_root), ("codex", codex_root)) if source in (None, name)
|
|
for s in _list_sessions(name, root)]
|
|
sessions.sort(key=lambda s: s.mtime, reverse=True)
|
|
return sessions[:limit] if limit else sessions
|
|
|
|
|
|
def pick_foreign_session(source: Optional[str] = None, *, limit: int = 25) -> Optional[ForeignSession]:
|
|
"""Interactive numbered picker. Returns None when nothing was chosen."""
|
|
sessions = gather_foreign_sessions(source, limit=limit)
|
|
if not sessions:
|
|
where = _SOURCE_LABELS.get(source or "", "Claude Code or Codex CLI")
|
|
print(f"No {where} sessions found on this machine.")
|
|
return None
|
|
print("Foreign sessions (newest first):")
|
|
for i, s in enumerate(sessions, 1):
|
|
ws = f" ({os.path.basename(s.cwd.rstrip('/')) or s.cwd})" if s.cwd else ""
|
|
print(f" {i:>2}. {datetime.fromtimestamp(s.mtime):%Y-%m-%d %H:%M} {s.label}{ws} [{s.turn_count} turns]")
|
|
if not sys.stdin.isatty():
|
|
print("Non-interactive terminal — pass the file path directly:\n"
|
|
" hermes sessions import --from claude|codex <path>")
|
|
return None
|
|
try:
|
|
raw = input(f"Import which session? [1-{len(sessions)}, empty to cancel] ").strip()
|
|
idx = int(raw) if raw else None
|
|
except (EOFError, KeyboardInterrupt):
|
|
return None
|
|
except ValueError:
|
|
print(f"Not a number: {raw}")
|
|
return None
|
|
if idx is not None and 1 <= idx <= len(sessions):
|
|
return sessions[idx - 1]
|
|
if idx is not None:
|
|
print(f"Out of range: {idx}")
|
|
return None
|
|
|
|
|
|
def run_sessions_import(args, db=None) -> Optional[str]:
|
|
"""`hermes sessions import` entry point. Returns new session id or None."""
|
|
source = getattr(args, "from_source", None)
|
|
path = getattr(args, "path", None)
|
|
if path:
|
|
# A missing file is reported as such, not as the misleading "cannot infer source".
|
|
if not Path(path).exists():
|
|
print(f"Error: file not found: {path}")
|
|
return None
|
|
if not source: # guess from the path shape; a codex match wins over a claude match
|
|
p = str(path)
|
|
if "/.claude/" in p or p.endswith(".jsonl") and "claude" in p:
|
|
source = "claude"
|
|
if "/.codex/" in p or Path(p).name.startswith("rollout-"):
|
|
source = "codex"
|
|
if not source:
|
|
print("Cannot infer source from path; pass --from claude|codex.")
|
|
return None
|
|
chosen_path = Path(path)
|
|
else:
|
|
if (picked := pick_foreign_session(source)) is None:
|
|
return None
|
|
source, chosen_path = picked.source, picked.path
|
|
try:
|
|
session_id = import_foreign_session(source, chosen_path, db=db)
|
|
except ValueError as e:
|
|
print(f"Error: {e}")
|
|
return None
|
|
print(f"✓ Imported {_SOURCE_LABELS.get(source, source)} session as {session_id}")
|
|
print(f" Continue it with: hermes --resume {session_id}")
|
|
return session_id
|