scripts/openclaw_to_hermes.py
scripts/openclaw_to_hermes.pyBrowse 2 files
31,783 tokens
149,390 bytes
Token encoding: o200k_base
Snapshot 24fd22b
← Back to SKILL.md
1#!/usr/bin/env python32"""OpenClaw -> Hermes migration helper.3 4This script migrates the parts of an OpenClaw user footprint that map cleanly5into Hermes Agent, archives selected unmapped docs for manual review, and6reports exactly what was skipped and why.7"""8 9from __future__ import annotations10 11import argparse12import errno13import hashlib14import json15import os16import re17import shutil18import tempfile19from dataclasses import asdict, dataclass, field20from datetime import datetime21from pathlib import Path22from typing import Any, Dict, List, Optional, Sequence, Tuple23 24try:25 import yaml26except Exception: # pragma: no cover - handled at runtime27 yaml = None28 29 30ENTRY_DELIMITER = "\n§\n"31DEFAULT_MEMORY_CHAR_LIMIT = 220032DEFAULT_USER_CHAR_LIMIT = 137533SKILL_CATEGORY_DIRNAME = "openclaw-imports"34SKILL_CATEGORY_DESCRIPTION = (35 "Skills migrated from an OpenClaw workspace."36)37SKILL_CONFLICT_MODES = {"skip", "overwrite", "rename"}38SUPPORTED_SECRET_TARGETS={39 "TELEGRAM_BOT_TOKEN",40 "OPENROUTER_API_KEY",41 "OPENAI_API_KEY",42 "ANTHROPIC_API_KEY",43 "ELEVENLABS_API_KEY",44 "VOICE_TOOLS_OPENAI_KEY",45}46WORKSPACE_INSTRUCTIONS_FILENAME = "AGENTS" + ".md"47MIGRATION_OPTION_METADATA: Dict[str, Dict[str, str]] = {48 "soul": {49 "label": "SOUL.md",50 "description": "Import the OpenClaw persona file into Hermes.",51 },52 "workspace-agents": {53 "label": "Workspace instructions",54 "description": "Copy the OpenClaw workspace instructions file into a chosen workspace.",55 },56 "memory": {57 "label": "MEMORY.md",58 "description": "Import long-term memory entries into Hermes memories.",59 },60 "user-profile": {61 "label": "USER.md",62 "description": "Import user profile entries into Hermes memories.",63 },64 "messaging-settings": {65 "label": "Messaging settings",66 "description": "Import Hermes-compatible messaging settings such as allowlists and working directory.",67 },68 "secret-settings": {69 "label": "Allowlisted secrets",70 "description": "Import the small allowlist of Hermes-compatible secrets when explicitly enabled.",71 },72 "command-allowlist": {73 "label": "Command allowlist",74 "description": "Merge OpenClaw exec approval patterns into Hermes command_allowlist.",75 },76 "skills": {77 "label": "User skills",78 "description": "Copy OpenClaw skills into ~/.hermes/skills/openclaw-imports/.",79 },80 "tts-assets": {81 "label": "TTS assets",82 "description": "Copy compatible workspace TTS assets into ~/.hermes/tts/.",83 },84 "discord-settings": {85 "label": "Discord settings",86 "description": "Import Discord bot token and allowlist into Hermes .env.",87 },88 "slack-settings": {89 "label": "Slack settings",90 "description": "Import Slack bot/app tokens and allowlist into Hermes .env.",91 },92 "whatsapp-settings": {93 "label": "WhatsApp settings",94 "description": "Import WhatsApp allowlist into Hermes .env.",95 },96 "signal-settings": {97 "label": "Signal settings",98 "description": "Import Signal account, HTTP URL, and allowlist into Hermes .env.",99 },100 "provider-keys": {101 "label": "Provider API keys",102 "description": "Import model provider API keys into Hermes .env (requires --migrate-secrets).",103 },104 "model-config": {105 "label": "Default model",106 "description": "Import the default model setting into Hermes config.yaml.",107 },108 "tts-config": {109 "label": "TTS configuration",110 "description": "Import TTS provider and voice settings into Hermes config.yaml.",111 },112 "shared-skills": {113 "label": "Shared skills",114 "description": "Copy shared OpenClaw skills from ~/.openclaw/skills/ into Hermes.",115 },116 "daily-memory": {117 "label": "Daily memory files",118 "description": "Merge daily memory entries from workspace/memory/ into Hermes MEMORY.md.",119 },120 "archive": {121 "label": "Archive unmapped docs",122 "description": "Archive compatible-but-unmapped docs for later manual review.",123 },124 "mcp-servers": {125 "label": "MCP servers",126 "description": "Import MCP server definitions from OpenClaw into Hermes config.yaml.",127 },128 "plugins-config": {129 "label": "Plugins configuration",130 "description": "Archive OpenClaw plugin configuration and installed extensions for manual review.",131 },132 "cron-jobs": {133 "label": "Cron / scheduled tasks",134 "description": "Import cron job definitions. Archive for manual recreation via 'hermes cron'.",135 },136 "hooks-config": {137 "label": "Hooks and webhooks",138 "description": "Archive OpenClaw hook configuration (internal hooks, webhooks, Gmail integration).",139 },140 "agent-config": {141 "label": "Agent defaults and multi-agent setup",142 "description": "Import agent defaults (compaction, context, thinking) into Hermes config. Archive multi-agent list.",143 },144 "gateway-config": {145 "label": "Gateway configuration",146 "description": "Import gateway port and auth settings. Archive full gateway config for manual setup.",147 },148 "session-config": {149 "label": "Session configuration",150 "description": "Archive advanced session settings; automatic reset timers are not imported.",151 },152 "full-providers": {153 "label": "Full model provider definitions",154 "description": "Import custom model providers (baseUrl, apiType, headers) into Hermes custom_providers.",155 },156 "deep-channels": {157 "label": "Deep channel configuration",158 "description": "Import extended channel settings (Matrix, Mattermost, IRC, group configs). Archive complex settings.",159 },160 "browser-config": {161 "label": "Browser configuration",162 "description": "Import browser automation settings into Hermes config.yaml.",163 },164 "tools-config": {165 "label": "Tools configuration",166 "description": "Import tool settings (exec timeout, sandbox, web search) into Hermes config.yaml.",167 },168 "approvals-config": {169 "label": "Approval rules",170 "description": "Import approval mode and rules into Hermes config.yaml approvals section.",171 },172 "memory-backend": {173 "label": "Memory backend configuration",174 "description": "Archive OpenClaw memory backend settings (QMD, vector search, citations) for manual review.",175 },176 "skills-config": {177 "label": "Skills registry configuration",178 "description": "Archive per-skill enabled/config/env settings from OpenClaw skills.entries.",179 },180 "ui-identity": {181 "label": "UI and identity settings",182 "description": "Archive OpenClaw UI theme, assistant identity, and display preferences.",183 },184 "logging-config": {185 "label": "Logging and diagnostics",186 "description": "Archive OpenClaw logging and diagnostics configuration.",187 },188}189MIGRATION_PRESETS: Dict[str, set[str]] = {190 "user-data": {191 "soul",192 "workspace-agents",193 "memory",194 "user-profile",195 "messaging-settings",196 "command-allowlist",197 "skills",198 "tts-assets",199 "discord-settings",200 "slack-settings",201 "whatsapp-settings",202 "signal-settings",203 "model-config",204 "tts-config",205 "shared-skills",206 "daily-memory",207 "archive",208 "mcp-servers",209 "agent-config",210 "session-config",211 "browser-config",212 "tools-config",213 "approvals-config",214 "deep-channels",215 "full-providers",216 "plugins-config",217 "cron-jobs",218 "hooks-config",219 "memory-backend",220 "skills-config",221 "ui-identity",222 "logging-config",223 "gateway-config",224 },225 "full": set(MIGRATION_OPTION_METADATA),226}227 228 229# ───────────────────────────────────────────────────────────────────────230# Item shape constants — kept stable for downstream consumers of report.json.231# Inspired by OpenClaw's src/plugin-sdk/migration.ts so both sides speak the232# same vocabulary. Values intentionally match the strings already produced233# by this script (migrated/archived/skipped/conflict/error) so the addition234# is backward-compatible.235# ───────────────────────────────────────────────────────────────────────236STATUS_MIGRATED = "migrated"237STATUS_ARCHIVED = "archived"238STATUS_SKIPPED = "skipped"239STATUS_CONFLICT = "conflict"240STATUS_ERROR = "error"241STATUS_PLANNED = "planned"242 243REASON_TARGET_EXISTS = "Target exists and overwrite is disabled"244REASON_BLOCKED_BY_APPLY_CONFLICT = "blocked by earlier apply conflict"245 246 247@dataclass248class ItemResult:249 kind: str250 source: Optional[str]251 destination: Optional[str]252 status: str253 reason: str = ""254 details: Dict[str, Any] = field(default_factory=dict)255 sensitive: bool = False256 257 258def parse_selection_values(values: Optional[Sequence[str]]) -> List[str]:259 parsed: List[str] = []260 for value in values or ():261 for part in str(value).split(","):262 part = part.strip().lower()263 if part:264 parsed.append(part)265 return parsed266 267 268def resolve_selected_options(269 include: Optional[Sequence[str]] = None,270 exclude: Optional[Sequence[str]] = None,271 preset: Optional[str] = None,272) -> set[str]:273 include_values = parse_selection_values(include)274 exclude_values = parse_selection_values(exclude)275 valid = set(MIGRATION_OPTION_METADATA)276 preset_name = (preset or "").strip().lower()277 278 if preset_name and preset_name not in MIGRATION_PRESETS:279 raise ValueError(280 "Unknown migration preset: "281 + preset_name282 + ". Valid presets: "283 + ", ".join(sorted(MIGRATION_PRESETS))284 )285 286 unknown = (set(include_values) - {"all"} - valid) | (set(exclude_values) - {"all"} - valid)287 if unknown:288 raise ValueError(289 "Unknown migration option(s): "290 + ", ".join(sorted(unknown))291 + ". Valid options: "292 + ", ".join(sorted(valid))293 )294 295 if preset_name:296 selected = set(MIGRATION_PRESETS[preset_name])297 elif not include_values or "all" in include_values:298 selected = set(valid)299 else:300 selected = set(include_values)301 302 if "all" in exclude_values:303 selected.clear()304 selected -= (set(exclude_values) - {"all"})305 return selected306 307 308def sha256_file(path: Path) -> str:309 h = hashlib.sha256()310 with path.open("rb") as fh:311 for chunk in iter(lambda: fh.read(65536), b""):312 h.update(chunk)313 return h.hexdigest()314 315 316def read_text(path: Path) -> str:317 return path.read_text(encoding="utf-8", errors="replace")318 319 320def normalize_text(text: str) -> str:321 return re.sub(r"\s+", " ", text.strip())322 323 324def ensure_parent(path: Path) -> None:325 path.parent.mkdir(parents=True, exist_ok=True)326 327 328def resolve_secret_input(value: Any, env: Optional[Dict[str, str]] = None) -> Optional[str]:329 """Resolve an OpenClaw SecretInput value to a plain string.330 331 SecretInput can be:332 - A plain string: "sk-..."333 - An env template: "${OPENROUTER_API_KEY}"334 - A SecretRef object: {"source": "env", "id": "OPENROUTER_API_KEY"}335 """336 if isinstance(value, str):337 # Check for env template: "${VAR_NAME}"338 m = re.match(r"^\$\{(\w+)\}$", value.strip())339 if m and env:340 return env.get(m.group(1), "").strip() or None341 return value.strip() or None342 if isinstance(value, dict):343 source = value.get("source", "")344 ref_id = value.get("id", "")345 if source == "env" and ref_id and env:346 return env.get(ref_id, "").strip() or None347 # File/exec sources can't be resolved here — return None348 return None349 350 351class ConfigReadError(RuntimeError):352 """An existing config file is present but cannot be read or parsed.353 354 Signals that a read-modify-write round trip must be abandoned: the caller355 has no idea what the file holds, so writing a merged result back would356 replace real settings with only the keys it merged.357 """358 359 360def load_yaml_file(path: Path) -> Dict[str, Any]:361 """Load a YAML mapping, distinguishing "absent" from "unreadable".362 363 Every config.yaml caller here reads the file, merges a section into it and364 writes the whole mapping straight back. Collapsing a present-but-unreadable365 file to ``{}`` therefore destroys it: a YAML syntax error, a permission366 problem or a broken mount would make the migration replace every setting367 the user had with only the section it merged, and still report ``migrated``.368 369 - Absent, or present but empty -> ``{}``; first-time creation still works.370 - Present but unreadable, unparseable, or not a mapping -> raise371 :class:`ConfigReadError` so the caller refuses and leaves the file372 byte-identical.373 374 ``yaml is None`` (PyYAML not installed) still yields ``{}``: nothing can be375 written in that state either, since :func:`dump_yaml_file` raises.376 """377 if yaml is None or not path.exists():378 return {}379 try:380 # errors="replace" means read_text() cannot raise UnicodeDecodeError;381 # OSError covers races like the file disappearing or being unreadable.382 raw = path.read_text(encoding="utf-8", errors="replace")383 except OSError as exc:384 raise ConfigReadError(385 f"Refusing to overwrite {path}: the existing file cannot be read "386 f"({exc}). Fix the file permissions or move it aside first."387 ) from exc388 try:389 data = yaml.safe_load(raw)390 except yaml.YAMLError as exc:391 raise ConfigReadError(392 f"Refusing to overwrite {path}: the existing file is not valid YAML "393 f"({exc}). Fix it with `hermes config edit` (or move it aside), then "394 f"re-run the migration."395 ) from exc396 # An empty file parses to None — a legitimate state with nothing to lose.397 if data is None:398 return {}399 if not isinstance(data, dict):400 raise ConfigReadError(401 f"Refusing to overwrite {path}: expected the existing file to hold a "402 f"YAML mapping but found {type(data).__name__}. Fix it with "403 f"`hermes config edit` (or move it aside), then re-run the migration."404 )405 return data406 407 408def dump_yaml_file(path: Path, data: Dict[str, Any]) -> None:409 """Write ``data`` as YAML via temp file + fsync + atomic rename.410 411 Only ever reached after :func:`load_yaml_file` has successfully read the412 same path, so the mapping being written is the real file's content plus the413 merged section — never a silently-empty stand-in. Writing atomically means414 an interrupted migration cannot leave a truncated config.yaml behind.415 416 Inlined rather than importing ``utils.atomic_write_text``: this script is417 standalone and runs with only the stdlib on its path. The symlink and418 cross-device handling mirrors ``utils.atomic_replace``: a plain419 ``os.replace`` onto a symlinked config.yaml would replace the *link* with a420 regular file, silently detaching managed deployments that symlink421 ``~/.hermes/config.yaml`` into a dotfiles repo or profile package.422 """423 if yaml is None:424 raise RuntimeError("PyYAML is required to update Hermes config.yaml")425 ensure_parent(path)426 target = os.path.realpath(str(path)) if os.path.islink(str(path)) else str(path)427 fd, tmp_path = tempfile.mkstemp(428 dir=os.path.dirname(target) or ".", prefix=".tmp_", suffix=".yaml"429 )430 try:431 with os.fdopen(fd, "w", encoding="utf-8") as handle:432 handle.write(yaml.safe_dump(data, sort_keys=False, allow_unicode=False))433 handle.flush()434 os.fsync(handle.fileno())435 try:436 os.replace(tmp_path, target)437 except OSError as exc:438 # Cross-device or bind-mount deployments cannot rename into place.439 if exc.errno not in (errno.EXDEV, errno.EBUSY):440 raise441 shutil.copyfile(tmp_path, target)442 try:443 shutil.copystat(tmp_path, target)444 except OSError:445 pass446 # fsync the copied target so the durability claim holds on the447 # cross-device path too (mirrors utils.atomic_replace, including448 # its swallow — a failed fsync must not report the already-copied449 # write as failed).450 try:451 target_fd = os.open(target, os.O_RDONLY)452 try:453 os.fsync(target_fd)454 finally:455 os.close(target_fd)456 except OSError:457 pass458 os.unlink(tmp_path)459 except BaseException:460 try:461 os.unlink(tmp_path)462 except OSError:463 pass464 raise465 466 467def parse_env_file(path: Path) -> Dict[str, str]:468 if not path.exists():469 return {}470 data: Dict[str, str] = {}471 try:472 # errors="replace" means read_text() cannot raise UnicodeDecodeError;473 # OSError covers races like the file disappearing or being unreadable.474 lines = path.read_text(encoding="utf-8", errors="replace").splitlines()475 except OSError:476 return {}477 for raw_line in lines:478 line = raw_line.strip()479 if not line or line.startswith("#") or "=" not in line:480 continue481 key, _, value = line.partition("=")482 data[key.strip()] = value.strip()483 return data484 485 486def save_env_file(path: Path, data: Dict[str, str]) -> None:487 ensure_parent(path)488 lines = [f"{key}={value}" for key, value in data.items()]489 path.write_text("\n".join(lines) + ("\n" if lines else ""), encoding="utf-8")490 491 492def backup_existing(path: Path, backup_root: Path) -> Optional[Path]:493 if not path.exists():494 return None495 rel = Path(*path.parts[1:]) if path.is_absolute() and len(path.parts) > 1 else path496 dest = backup_root / rel497 ensure_parent(dest)498 if path.is_dir():499 shutil.copytree(path, dest, dirs_exist_ok=True)500 else:501 shutil.copy2(path, dest)502 return dest503 504 505# ── Brand rewriting ─────────────────────────────────────────506# Replace OpenClaw brand names with Hermes in migrated text so that507# memory entries, user profiles, SOUL.md, and workspace instructions508# read as self-referential to the new agent identity.509#510# Case-preserving: ``OpenClaw`` → ``Hermes`` (prose), but lowercase matches511# like ``openclaw`` → ``hermes`` (so filesystem paths like ``~/.openclaw``512# become ``~/.hermes`` — the real Hermes home — not the broken ``~/.Hermes``).513_REBRAND_PATTERNS: List[Tuple[re.Pattern, str]] = [514 (re.compile(r'\bOpen[\s-]?Claw\b', re.IGNORECASE), 'Hermes'),515 (re.compile(r'\bClawdBot\b', re.IGNORECASE), 'Hermes'),516 (re.compile(r'\bMoltBot\b', re.IGNORECASE), 'Hermes'),517]518 519 520def _case_preserving_replacement(replacement: str):521 """Return a re.sub replacement fn that lowercases the result when the522 matched text was all-lowercase.523 524 Keeps ``OpenClaw`` → ``Hermes`` but maps ``openclaw`` → ``hermes`` so a525 filesystem path like ``~/.openclaw/config.yaml`` rewrites to526 ``~/.hermes/config.yaml`` (the real Hermes home) instead of the broken527 ``~/.Hermes/config.yaml``.528 """529 def _sub(match: "re.Match[str]") -> str:530 matched = match.group(0)531 if matched and matched.islower():532 return replacement.lower()533 return replacement534 return _sub535 536 537def rebrand_text(text: str) -> str:538 """Replace OpenClaw / ClawdBot / MoltBot brand names with Hermes.539 540 Preserves case so filesystem-path matches (lowercase) don't become541 capitalized directory names that don't exist.542 """543 for pattern, replacement in _REBRAND_PATTERNS:544 text = pattern.sub(_case_preserving_replacement(replacement), text)545 return text546 547 548def parse_existing_memory_entries(path: Path) -> List[str]:549 """Parse a DESTINATION Hermes memory store (memories/MEMORY.md, USER.md).550 551 Splits on ``ENTRY_DELIMITER`` only, matching ``MemoryStore._parse_entries``552 in ``tools/memory_tool.py``: a store with no delimiter is ONE intact entry.553 Do NOT fall back to :func:`extract_markdown_entries` — it is the *source*554 parser (workspace/MEMORY.md and friends), it drops fenced code blocks and555 table rows and splits a block into one entry per bullet, and the merged556 result is written back over the destination.557 """558 if not path.exists():559 return []560 raw = read_text(path)561 if not raw.strip():562 return []563 return [e.strip() for e in raw.split(ENTRY_DELIMITER) if e.strip()]564 565 566def extract_markdown_entries(text: str) -> List[str]:567 entries: List[str] = []568 headings: List[str] = []569 paragraph_lines: List[str] = []570 571 def context_prefix() -> str:572 filtered = [h for h in headings if h and not re.search(r"\b(MEMORY|USER|SOUL|AGENTS|TOOLS|IDENTITY)\.md\b", h, re.I)]573 return " > ".join(filtered)574 575 def flush_paragraph() -> None:576 nonlocal paragraph_lines577 if not paragraph_lines:578 return579 text_block = " ".join(line.strip() for line in paragraph_lines).strip()580 paragraph_lines = []581 if not text_block:582 return583 prefix = context_prefix()584 if prefix:585 entries.append(f"{prefix}: {text_block}")586 else:587 entries.append(text_block)588 589 in_code_block = False590 for raw_line in text.splitlines():591 line = raw_line.rstrip()592 stripped = line.strip()593 594 if stripped.startswith("```"):595 in_code_block = not in_code_block596 flush_paragraph()597 continue598 if in_code_block:599 continue600 601 heading_match = re.match(r"^(#{1,6})\s+(.*\S)\s*$", stripped)602 if heading_match:603 flush_paragraph()604 level = len(heading_match.group(1))605 text_value = heading_match.group(2).strip()606 while len(headings) >= level:607 headings.pop()608 headings.append(text_value)609 continue610 611 bullet_match = re.match(r"^\s*(?:[-*]|\d+\.)\s+(.*\S)\s*$", line)612 if bullet_match:613 flush_paragraph()614 content = bullet_match.group(1).strip()615 prefix = context_prefix()616 entries.append(f"{prefix}: {content}" if prefix else content)617 continue618 619 if not stripped:620 flush_paragraph()621 continue622 623 if stripped.startswith("|") and stripped.endswith("|"):624 flush_paragraph()625 continue626 627 paragraph_lines.append(stripped)628 629 flush_paragraph()630 631 deduped: List[str] = []632 seen = set()633 for entry in entries:634 normalized = normalize_text(entry)635 if not normalized or normalized in seen:636 continue637 seen.add(normalized)638 deduped.append(entry.strip())639 return deduped640 641 642def merge_entries(643 existing: Sequence[str],644 incoming: Sequence[str],645 limit: int,646) -> Tuple[List[str], Dict[str, int], List[str]]:647 merged = list(existing)648 seen = {normalize_text(entry) for entry in existing if entry.strip()}649 stats = {"existing": len(existing), "added": 0, "duplicates": 0, "overflowed": 0}650 overflowed: List[str] = []651 652 current_len = len(ENTRY_DELIMITER.join(merged)) if merged else 0653 654 for entry in incoming:655 normalized = normalize_text(entry)656 if not normalized:657 continue658 if normalized in seen:659 stats["duplicates"] += 1660 continue661 662 candidate_len = len(entry) if not merged else current_len + len(ENTRY_DELIMITER) + len(entry)663 if candidate_len > limit:664 stats["overflowed"] += 1665 overflowed.append(entry)666 continue667 668 merged.append(entry)669 seen.add(normalized)670 current_len = candidate_len671 stats["added"] += 1672 673 return merged, stats, overflowed674 675 676def relative_label(path: Path, root: Path) -> str:677 try:678 return str(path.relative_to(root))679 except ValueError:680 return str(path)681 682 683# ───────────────────────────────────────────────────────────────────────684# Secret redaction for migration reports.685#686# The report JSON persists to disk inside the migration output directory and687# frequently ends up in bug reports or support channels. Anything that looks688# like a credential — by key name or by value shape — is replaced with689# "[redacted]" before the report is written.690#691# Modelled on OpenClaw's src/plugin-sdk/migration.ts so both migration tools692# redact consistently. Pure function — safe to call on any plain-data dict.693# ───────────────────────────────────────────────────────────────────────694REDACTED_MIGRATION_VALUE = "[redacted]"695 696_SECRET_KEY_MARKERS = (697 "accesstoken",698 "apikey",699 "authorization",700 "bearertoken",701 "clientsecret",702 "cookie",703 "credential",704 "password",705 "privatekey",706 "refreshtoken",707 "secret",708)709 710_SECRET_VALUE_PATTERNS = (711 re.compile(r"\bBearer\s+[A-Za-z0-9._~+/=\-]+"),712 re.compile(r"\bsk-[A-Za-z0-9_\-]{8,}\b"),713 re.compile(r"\bgh[pousr]_[A-Za-z0-9_]{16,}\b"),714 re.compile(r"\bxox[abprs]-[A-Za-z0-9\-]{8,}\b"),715 re.compile(r"\bAIza[0-9A-Za-z_\-]{12,}\b"),716)717 718 719def _normalize_secret_key(key: str) -> str:720 return re.sub(r"[^a-z0-9]", "", key.lower())721 722 723def _is_secret_key(key: str) -> bool:724 normalized = _normalize_secret_key(key)725 if normalized == "token" or normalized.endswith("token"):726 return True727 if normalized in {"auth", "authorization"}:728 return True729 return any(marker in normalized for marker in _SECRET_KEY_MARKERS)730 731 732def _redact_string(value: str) -> str:733 for pattern in _SECRET_VALUE_PATTERNS:734 value = pattern.sub(REDACTED_MIGRATION_VALUE, value)735 return value736 737 738def redact_migration_value(value: Any) -> Any:739 """Return a deep copy of ``value`` with secret-looking content replaced.740 741 Applied to every report written to disk. Keys whose normalized form742 matches a credential marker get their value replaced wholesale. Strings743 anywhere in the tree are scanned for common token patterns (sk-..., ghp_...,744 xox*-, AIza*, Bearer ...) and those substrings are replaced inline.745 """746 return _redact_internal(value, set())747 748 749def _redact_internal(value: Any, seen: set) -> Any:750 if isinstance(value, str):751 return _redact_string(value)752 if isinstance(value, (list, tuple)):753 return [_redact_internal(entry, seen) for entry in value]754 if isinstance(value, dict):755 obj_id = id(value)756 if obj_id in seen:757 return REDACTED_MIGRATION_VALUE758 seen.add(obj_id)759 out: Dict[str, Any] = {}760 for key, entry in value.items():761 if isinstance(key, str) and _is_secret_key(key):762 out[key] = REDACTED_MIGRATION_VALUE763 else:764 out[key] = _redact_internal(entry, seen)765 return out766 return value767 768 769def write_report(output_dir: Path, report: Dict[str, Any]) -> None:770 output_dir.mkdir(parents=True, exist_ok=True)771 # Always redact before persisting. Callers who need the raw object772 # (in-process) still get it back from build_report(); only the on-disk773 # copy is redacted.774 redacted = redact_migration_value(report)775 (output_dir / "report.json").write_text(776 json.dumps(redacted, indent=2, ensure_ascii=False) + "\n",777 encoding="utf-8",778 )779 780 grouped: Dict[str, List[Dict[str, Any]]] = {}781 for item in redacted["items"]:782 grouped.setdefault(item["status"], []).append(item)783 784 lines = [785 "# OpenClaw -> Hermes Migration Report",786 "",787 f"- Timestamp: {redacted['timestamp']}",788 f"- Mode: {redacted['mode']}",789 f"- Source: `{redacted['source_root']}`",790 f"- Target: `{redacted['target_root']}`",791 "",792 "## Summary",793 "",794 ]795 796 for key, value in redacted["summary"].items():797 lines.append(f"- {key}: {value}")798 799 warnings = redacted.get("warnings") or []800 if warnings:801 lines.extend(["", "## Warnings", ""])802 for warning in warnings:803 lines.append(f"- {warning}")804 805 lines.extend(["", "## What Was Not Fully Brought Over", ""])806 skipped = grouped.get("skipped", []) + grouped.get("conflict", []) + grouped.get("error", [])807 if not skipped:808 lines.append("- Nothing. All discovered items were either migrated or archived.")809 else:810 for item in skipped:811 source = item["source"] or "(n/a)"812 dest = item["destination"] or "(n/a)"813 reason = item["reason"] or item["status"]814 lines.append(f"- `{source}` -> `{dest}`: {reason}")815 816 next_steps = redacted.get("next_steps") or []817 if next_steps:818 lines.extend(["", "## Next Steps", ""])819 for step in next_steps:820 lines.append(f"- {step}")821 822 (output_dir / "summary.md").write_text("\n".join(lines) + "\n", encoding="utf-8")823 824 825class Migrator:826 def __init__(827 self,828 source_root: Path,829 target_root: Path,830 execute: bool,831 workspace_target: Optional[Path],832 overwrite: bool,833 migrate_secrets: bool,834 output_dir: Optional[Path],835 selected_options: Optional[set[str]] = None,836 preset_name: str = "",837 skill_conflict_mode: str = "skip",838 ):839 self.source_root = source_root840 self.target_root = target_root841 self.execute = execute842 self.workspace_target = workspace_target843 self.overwrite = overwrite844 self.migrate_secrets = migrate_secrets845 self.selected_options = set(selected_options or MIGRATION_OPTION_METADATA.keys())846 self.preset_name = preset_name.strip().lower()847 self.skill_conflict_mode = skill_conflict_mode.strip().lower() or "skip"848 self.timestamp = datetime.now().strftime("%Y%m%dT%H%M%S")849 self.output_dir = output_dir or (850 target_root / "migration" / "openclaw" / self.timestamp if execute else None851 )852 self.archive_dir = self.output_dir / "archive" if self.output_dir else None853 self.backup_dir = self.output_dir / "backups" if self.output_dir else None854 self.overflow_dir = self.output_dir / "overflow" if self.output_dir else None855 self.items: List[ItemResult] = []856 # Once a config.yaml write hits conflict/error mid-run, later857 # config.yaml writes are deliberately short-circuited to avoid858 # leaving config in a partially-written state. Modelled on859 # OpenClaw's extensions/migrate-hermes/apply.ts "blocked by earlier860 # apply conflict" sequencing.861 self._config_apply_blocked: bool = False862 863 # Resolve the configured workspace directory from openclaw.json.864 # Many users (especially those who started before the OpenClaw rebrand)865 # have a custom workspace path (e.g. ~/clawd/) that differs from the866 # default ~/.openclaw/workspace/. Reading agents.defaults.workspace867 # lets source_candidate() find files in the actual workspace.868 self._custom_workspace: Optional[Path] = None869 oc_config = self.load_openclaw_config()870 ws = (oc_config.get("agents", {}).get("defaults", {}).get("workspace") or "").strip()871 if ws:872 ws_path = Path(ws).expanduser().resolve()873 # Only use it if it exists and is outside the source_root tree874 # (otherwise the standard relative-path logic already covers it).875 if ws_path.is_dir():876 try:877 ws_path.relative_to(self.source_root)878 except ValueError:879 # ws_path is outside source_root — use it as custom workspace880 self._custom_workspace = ws_path881 882 # Read-only probe for the memory limits, during construction — nothing883 # is written from here, so an unreadable config just falls back to the884 # defaults. Every path that WRITES config.yaml refuses separately (see885 # run_if_selected), so this cannot become a silent overwrite.886 try:887 config = load_yaml_file(self.target_root / "config.yaml")888 except ConfigReadError:889 config = {}890 mem_cfg = config.get("memory", {}) if isinstance(config.get("memory"), dict) else {}891 self.memory_limit = int(mem_cfg.get("memory_char_limit", DEFAULT_MEMORY_CHAR_LIMIT))892 self.user_limit = int(mem_cfg.get("user_char_limit", DEFAULT_USER_CHAR_LIMIT))893 894 if self.skill_conflict_mode not in SKILL_CONFLICT_MODES:895 raise ValueError(896 "Unknown skill conflict mode: "897 + self.skill_conflict_mode898 + ". Valid modes: "899 + ", ".join(sorted(SKILL_CONFLICT_MODES))900 )901 902 def is_selected(self, option_id: str) -> bool:903 return option_id in self.selected_options904 905 # Option ids that mutate the Hermes config.yaml file. Once any one of906 # them records a conflict/error on config.yaml, subsequent ones are907 # short-circuited to avoid partial writes. Keep in sync with methods908 # that call load_yaml_file(target_root / "config.yaml") + dump_yaml_file.909 _CONFIG_MUTATING_OPTIONS = frozenset({910 "model-config",911 "tts-config",912 "mcp-servers",913 "plugins-config",914 "cron-jobs",915 "hooks-config",916 "agent-config",917 "gateway-config",918 "full-providers",919 "deep-channels",920 "browser-config",921 "tools-config",922 "approvals-config",923 "memory-backend",924 "skills-config",925 "ui-identity",926 "logging-config",927 "command-allowlist",928 })929 930 def record(931 self,932 kind: str,933 source: Optional[Path],934 destination: Optional[Path],935 status: str,936 reason: str = "",937 **details: Any,938 ) -> None:939 sensitive = bool(details.pop("sensitive", False))940 self.items.append(941 ItemResult(942 kind=kind,943 source=str(source) if source else None,944 destination=str(destination) if destination else None,945 status=status,946 reason=reason,947 details=details,948 sensitive=sensitive,949 )950 )951 # Flip the config-block flag when a conflict/error occurs on a952 # config.yaml write. Later config-mutating options will skip rather953 # than attempting a partial write.954 if status in {STATUS_CONFLICT, STATUS_ERROR} and destination is not None:955 dest_str = str(destination)956 if dest_str.endswith("config.yaml") or dest_str.endswith("config.yml"):957 self._config_apply_blocked = True958 959 def source_candidate(self, *relative_paths: str) -> Optional[Path]:960 for rel in relative_paths:961 candidate = self.source_root / rel962 if candidate.exists():963 return candidate964 # OpenClaw renamed workspace/ to workspace-main/ (and workspace-{agentId}965 # for multi-agent). Try the new path as a fallback.966 if rel.startswith("workspace/"):967 suffix = rel[len("workspace/"):]968 for variant in ("workspace-main", "workspace-assistant"):969 alt = self.source_root / variant / suffix970 if alt.exists():971 return alt972 elif rel.startswith("workspace.default/"):973 suffix = rel[len("workspace.default/"):]974 alt = self.source_root / "workspace-main" / suffix975 if alt.exists():976 return alt977 978 # Final fallback: check the configured workspace directory from979 # agents.defaults.workspace in openclaw.json. Users who started980 # before the OpenClaw rebrand (when the project was named clawd /981 # clawdbot) often have a custom workspace path outside ~/.openclaw/.982 if self._custom_workspace:983 for rel in relative_paths:984 # Strip the leading "workspace/" or "workspace.default/"985 # prefix to get the bare filename/subpath.986 for prefix in ("workspace/", "workspace.default/"):987 if rel.startswith(prefix):988 suffix = rel[len(prefix):]989 alt = self._custom_workspace / suffix990 if alt.exists():991 return alt992 break993 994 return None995 996 def resolve_skill_destination(self, destination: Path) -> Path:997 if self.skill_conflict_mode != "rename" or not destination.exists():998 return destination999 1000 suffix = "-imported"1001 candidate = destination.with_name(destination.name + suffix)1002 counter = 21003 while candidate.exists():1004 candidate = destination.with_name(f"{destination.name}{suffix}-{counter}")1005 counter += 11006 return candidate1007 1008 def migrate(self) -> Dict[str, Any]:1009 if not self.source_root.exists():1010 self.record("source", self.source_root, None, "error", "OpenClaw directory does not exist")1011 return self.build_report()1012 1013 config = self.load_openclaw_config()1014 1015 self.run_if_selected("soul", self.migrate_soul)1016 self.run_if_selected("workspace-agents", self.migrate_workspace_agents)1017 self.run_if_selected(1018 "memory",1019 lambda: self.migrate_memory(1020 self.source_candidate("workspace/MEMORY.md", "workspace.default/MEMORY.md"),1021 self.target_root / "memories" / "MEMORY.md",1022 self.memory_limit,1023 kind="memory",1024 ),1025 )1026 self.run_if_selected(1027 "user-profile",1028 lambda: self.migrate_memory(1029 self.source_candidate("workspace/USER.md", "workspace.default/USER.md"),1030 self.target_root / "memories" / "USER.md",1031 self.user_limit,1032 kind="user-profile",1033 ),1034 )1035 self.run_if_selected("messaging-settings", lambda: self.migrate_messaging_settings(config))1036 self.run_if_selected("secret-settings", lambda: self.handle_secret_settings(config))1037 self.run_if_selected("discord-settings", lambda: self.migrate_discord_settings(config))1038 self.run_if_selected("slack-settings", lambda: self.migrate_slack_settings(config))1039 self.run_if_selected("whatsapp-settings", lambda: self.migrate_whatsapp_settings(config))1040 self.run_if_selected("signal-settings", lambda: self.migrate_signal_settings(config))1041 self.run_if_selected("provider-keys", lambda: self.handle_provider_keys(config))1042 self.run_if_selected("model-config", lambda: self.migrate_model_config(config))1043 self.run_if_selected("tts-config", lambda: self.migrate_tts_config(config))1044 self.run_if_selected("command-allowlist", self.migrate_command_allowlist)1045 self.run_if_selected("skills", self.migrate_skills)1046 self.run_if_selected("shared-skills", self.migrate_shared_skills)1047 self.run_if_selected("daily-memory", self.migrate_daily_memory)1048 self.run_if_selected(1049 "tts-assets",1050 lambda: self.copy_tree_non_destructive(1051 self.source_candidate("workspace/tts"),1052 self.target_root / "tts",1053 kind="tts-assets",1054 ignore_dir_names={".venv", "generated", "__pycache__"},1055 ),1056 )1057 self.run_if_selected("archive", self.archive_docs)1058 1059 # ── v2 migration modules ──────────────────────────────1060 self.run_if_selected("mcp-servers", lambda: self.migrate_mcp_servers(config))1061 self.run_if_selected("plugins-config", lambda: self.migrate_plugins_config(config))1062 self.run_if_selected("cron-jobs", lambda: self.migrate_cron_jobs(config))1063 self.run_if_selected("hooks-config", lambda: self.migrate_hooks_config(config))1064 self.run_if_selected("agent-config", lambda: self.migrate_agent_config(config))1065 self.run_if_selected("gateway-config", lambda: self.migrate_gateway_config(config))1066 self.run_if_selected("session-config", lambda: self.migrate_session_config(config))1067 self.run_if_selected("full-providers", lambda: self.migrate_full_providers(config))1068 self.run_if_selected("deep-channels", lambda: self.migrate_deep_channels(config))1069 self.run_if_selected("browser-config", lambda: self.migrate_browser_config(config))1070 self.run_if_selected("tools-config", lambda: self.migrate_tools_config(config))1071 self.run_if_selected("approvals-config", lambda: self.migrate_approvals_config(config))1072 self.run_if_selected("memory-backend", lambda: self.migrate_memory_backend(config))1073 self.run_if_selected("skills-config", lambda: self.migrate_skills_config(config))1074 self.run_if_selected("ui-identity", lambda: self.migrate_ui_identity(config))1075 self.run_if_selected("logging-config", lambda: self.migrate_logging_config(config))1076 1077 # Generate migration notes1078 self.generate_migration_notes()1079 1080 return self.build_report()1081 1082 def run_if_selected(self, option_id: str, func) -> None:1083 if not self.is_selected(option_id):1084 meta = MIGRATION_OPTION_METADATA[option_id]1085 self.record(option_id, None, None, "skipped", "Not selected for this run", option_label=meta["label"])1086 return1087 # If a previous config.yaml write hit a conflict/error during apply,1088 # skip remaining config-mutating options rather than risk a partial1089 # write. Dry-run mode never blocks — the user needs the full preview1090 # to decide how to proceed (re-run with --overwrite, etc.).1091 if (1092 self.execute1093 and self._config_apply_blocked1094 and option_id in self._CONFIG_MUTATING_OPTIONS1095 ):1096 meta = MIGRATION_OPTION_METADATA[option_id]1097 self.record(1098 option_id,1099 None,1100 None,1101 STATUS_SKIPPED,1102 REASON_BLOCKED_BY_APPLY_CONFLICT,1103 option_label=meta["label"],1104 )1105 return1106 try:1107 func()1108 except ConfigReadError as exc:1109 # The destination config.yaml is present but unreadable, so this1110 # step cannot merge into it. Record the refusal against the1111 # config path — record() then flips _config_apply_blocked, and the1112 # remaining config-mutating options short-circuit above instead of1113 # each rediscovering the same unreadable file. The file itself is1114 # left byte-identical.1115 meta = MIGRATION_OPTION_METADATA[option_id]1116 self.record(1117 option_id,1118 None,1119 self.target_root / "config.yaml",1120 STATUS_ERROR,1121 str(exc),1122 option_label=meta["label"],1123 )1124 1125 def build_report(self) -> Dict[str, Any]:1126 summary: Dict[str, int] = {1127 "migrated": 0,1128 "archived": 0,1129 "skipped": 0,1130 "conflict": 0,1131 "error": 0,1132 }1133 for item in self.items:1134 summary[item.status] = summary.get(item.status, 0) + 11135 1136 report = {1137 "timestamp": self.timestamp,1138 "mode": "execute" if self.execute else "dry-run",1139 "source_root": str(self.source_root),1140 "target_root": str(self.target_root),1141 "workspace_target": str(self.workspace_target) if self.workspace_target else None,1142 "output_dir": str(self.output_dir) if self.output_dir else None,1143 "migrate_secrets": self.migrate_secrets,1144 "preset": self.preset_name or None,1145 "skill_conflict_mode": self.skill_conflict_mode,1146 "selection": {1147 "selected": sorted(self.selected_options),1148 "preset": self.preset_name or None,1149 "skill_conflict_mode": self.skill_conflict_mode,1150 "available": [1151 {"id": option_id, **meta}1152 for option_id, meta in MIGRATION_OPTION_METADATA.items()1153 ],1154 "presets": [1155 {"id": preset_id, "selected": sorted(option_ids)}1156 for preset_id, option_ids in MIGRATION_PRESETS.items()1157 ],1158 },1159 "summary": summary,1160 "items": [asdict(item) for item in self.items],1161 "warnings": self._build_warnings(summary),1162 "next_steps": self._build_next_steps(summary),1163 }1164 1165 if self.output_dir:1166 write_report(self.output_dir, report)1167 1168 return report1169 1170 def _build_warnings(self, summary: Dict[str, int]) -> List[str]:1171 """Structured warnings surfaced on the report for downstream consumers.1172 1173 Modelled on OpenClaw's extensions/migrate-hermes/plan.ts warnings[].1174 Keep the messages actionable — they show up in summary.md and the1175 JSON report.1176 """1177 warnings: List[str] = []1178 if summary.get("conflict", 0) > 0:1179 warnings.append(1180 "Conflicts were found. Re-run with --overwrite to replace conflicting "1181 "targets after item-level backups."1182 )1183 if summary.get("error", 0) > 0:1184 warnings.append(1185 "One or more items failed. Inspect the report and re-run after fixing "1186 "the underlying cause."1187 )1188 if self._config_apply_blocked and self.execute:1189 warnings.append(1190 "A config.yaml write hit a conflict or error mid-apply; later config "1191 "items were skipped to avoid a partial write."1192 )1193 # Detect whether secrets were detected but not migrated.1194 provider_keys_skipped = any(1195 item.kind == "provider-keys" and item.status == STATUS_SKIPPED1196 for item in self.items1197 )1198 if provider_keys_skipped and not self.migrate_secrets:1199 warnings.append(1200 "API keys and other credentials were detected but not imported. "1201 "Re-run with --migrate-secrets to copy supported keys into the "1202 "Hermes env file."1203 )1204 return warnings1205 1206 def _build_next_steps(self, summary: Dict[str, int]) -> List[str]:1207 """Human-readable next-step guidance baked into the report."""1208 if not self.execute:1209 return [1210 "Re-run without --dry-run to apply the migration.",1211 "Pass --overwrite to resolve conflicts, or --migrate-secrets to "1212 "include API keys.",1213 ]1214 steps: List[str] = []1215 if summary.get("migrated", 0) > 0:1216 steps.append(1217 "Review the migration report at "1218 f"{self.output_dir}/summary.md"1219 if self.output_dir1220 else "Review the migration report."1221 )1222 steps.append(1223 "Start a new Hermes session (or /reset) to pick up the imported config."1224 )1225 if summary.get("conflict", 0) > 0:1226 steps.append(1227 "Re-run with --overwrite to apply items that were blocked by conflicts."1228 )1229 return steps1230 1231 def maybe_backup(self, path: Path) -> Optional[Path]:1232 if not self.execute or not self.backup_dir or not path.exists():1233 return None1234 return backup_existing(path, self.backup_dir)1235 1236 def write_overflow_entries(self, kind: str, entries: Sequence[str]) -> Optional[Path]:1237 if not entries or not self.overflow_dir:1238 return None1239 self.overflow_dir.mkdir(parents=True, exist_ok=True)1240 filename = f"{kind.replace('-', '_')}_overflow.txt"1241 path = self.overflow_dir / filename1242 path.write_text("\n".join(entries) + "\n", encoding="utf-8")1243 return path1244 1245 def copy_file(self, source: Path, destination: Path, kind: str,1246 transform: Optional[Any] = None) -> None:1247 if not source or not source.exists():1248 return1249 1250 if destination.exists():1251 if not transform and sha256_file(source) == sha256_file(destination):1252 self.record(kind, source, destination, "skipped", "Target already matches source")1253 return1254 if not self.overwrite:1255 self.record(kind, source, destination, "conflict", "Target exists and overwrite is disabled")1256 return1257 1258 if self.execute:1259 backup_path = self.maybe_backup(destination)1260 ensure_parent(destination)1261 if transform:1262 content = read_text(source)1263 content = transform(content)1264 destination.write_text(content, encoding="utf-8")1265 shutil.copystat(source, destination)1266 else:1267 shutil.copy2(source, destination)1268 self.record(kind, source, destination, "migrated", backup=str(backup_path) if backup_path else None)1269 else:1270 self.record(kind, source, destination, "migrated", "Would copy")1271 1272 def migrate_soul(self) -> None:1273 source = self.source_candidate("workspace/SOUL.md", "workspace.default/SOUL.md")1274 if not source:1275 self.record("soul", None, self.target_root / "SOUL.md", "skipped", "No OpenClaw SOUL.md found")1276 return1277 self.copy_file(source, self.target_root / "SOUL.md", kind="soul", transform=rebrand_text)1278 1279 def migrate_workspace_agents(self) -> None:1280 source = self.source_candidate(1281 f"workspace/{WORKSPACE_INSTRUCTIONS_FILENAME}",1282 f"workspace.default/{WORKSPACE_INSTRUCTIONS_FILENAME}",1283 )1284 if source is None:1285 self.record("workspace-agents", "workspace/AGENTS.md", "", "skipped", "Source file not found")1286 return1287 if not self.workspace_target:1288 self.record("workspace-agents", source, None, "skipped", "No workspace target was provided")1289 return1290 destination = self.workspace_target / WORKSPACE_INSTRUCTIONS_FILENAME1291 self.copy_file(source, destination, kind="workspace-agents", transform=rebrand_text)1292 1293 def migrate_memory(self, source: Optional[Path], destination: Path, limit: int, kind: str) -> None:1294 if not source or not source.exists():1295 self.record(kind, None, destination, "skipped", "Source file not found")1296 return1297 1298 incoming = extract_markdown_entries(read_text(source))1299 if not incoming:1300 self.record(kind, source, destination, "skipped", "No importable entries found")1301 return1302 incoming = [rebrand_text(entry) for entry in incoming]1303 1304 existing = parse_existing_memory_entries(destination)1305 merged, stats, overflowed = merge_entries(existing, incoming, limit)1306 details = {1307 "existing_entries": stats["existing"],1308 "added_entries": stats["added"],1309 "duplicate_entries": stats["duplicates"],1310 "overflowed_entries": stats["overflowed"],1311 "char_limit": limit,1312 "final_char_count": len(ENTRY_DELIMITER.join(merged)) if merged else 0,1313 }1314 overflow_file = self.write_overflow_entries(kind, overflowed)1315 if overflow_file is not None:1316 details["overflow_file"] = str(overflow_file)1317 1318 if self.execute:1319 if stats["added"] == 0 and not overflowed:1320 self.record(kind, source, destination, "skipped", "No new entries to import", **details)1321 return1322 backup_path = self.maybe_backup(destination)1323 ensure_parent(destination)1324 destination.write_text(ENTRY_DELIMITER.join(merged) + ("\n" if merged else ""), encoding="utf-8")1325 self.record(1326 kind,1327 source,1328 destination,1329 "migrated",1330 backup=str(backup_path) if backup_path else "",1331 overflow_preview=overflowed[:5],1332 **details,1333 )1334 else:1335 self.record(kind, source, destination, "migrated", "Would merge entries", overflow_preview=overflowed[:5], **details)1336 1337 def migrate_command_allowlist(self) -> None:1338 source = self.source_root / "exec-approvals.json"1339 destination = self.target_root / "config.yaml"1340 if not source.exists():1341 self.record("command-allowlist", None, destination, "skipped", "No OpenClaw exec approvals file found")1342 return1343 if yaml is None:1344 self.record("command-allowlist", source, destination, "error", "PyYAML is not available")1345 return1346 1347 try:1348 data = json.loads(source.read_text(encoding="utf-8", errors="replace"))1349 except json.JSONDecodeError as exc:1350 self.record("command-allowlist", source, destination, "error", f"Invalid JSON: {exc}")1351 return1352 except OSError as exc:1353 self.record("command-allowlist", source, destination, "error", f"Could not read file: {exc}")1354 return1355 1356 patterns: List[str] = []1357 agents = data.get("agents", {})1358 if isinstance(agents, dict):1359 for agent_data in agents.values():1360 allowlist = agent_data.get("allowlist", []) if isinstance(agent_data, dict) else []1361 for entry in allowlist:1362 pattern = entry.get("pattern") if isinstance(entry, dict) else None1363 if pattern:1364 patterns.append(pattern)1365 1366 patterns = sorted(dict.fromkeys(patterns))1367 if not patterns:1368 self.record("command-allowlist", source, destination, "skipped", "No allowlist patterns found")1369 return1370 if not destination.exists():1371 self.record("command-allowlist", source, destination, "skipped", "Hermes config.yaml does not exist yet")1372 return1373 1374 config = load_yaml_file(destination)1375 current = config.get("command_allowlist", [])1376 if not isinstance(current, list):1377 current = []1378 merged = sorted(dict.fromkeys(list(current) + patterns))1379 added = [pattern for pattern in merged if pattern not in current]1380 if not added:1381 self.record("command-allowlist", source, destination, "skipped", "All patterns already present")1382 return1383 1384 if self.execute:1385 backup_path = self.maybe_backup(destination)1386 config["command_allowlist"] = merged1387 dump_yaml_file(destination, config)1388 self.record(1389 "command-allowlist",1390 source,1391 destination,1392 "migrated",1393 backup=str(backup_path) if backup_path else "",1394 added_patterns=added,1395 )1396 else:1397 self.record("command-allowlist", source, destination, "migrated", "Would merge patterns", added_patterns=added)1398 1399 def load_openclaw_config(self) -> Dict[str, Any]:1400 # Check current name and legacy config filenames1401 for name in ("openclaw.json", "clawdbot.json", "moltbot.json"):1402 config_path = self.source_root / name1403 if config_path.exists():1404 try:1405 # errors="replace" means read_text() cannot raise1406 # UnicodeDecodeError; OSError covers unreadable/vanished files.1407 data = json.loads(config_path.read_text(encoding="utf-8", errors="replace"))1408 return data if isinstance(data, dict) else {}1409 except (json.JSONDecodeError, OSError):1410 continue1411 return {}1412 1413 def load_openclaw_env(self) -> Dict[str, str]:1414 """Load the OpenClaw .env file for secrets that live there instead of config."""1415 return parse_env_file(self.source_root / ".env")1416 1417 def merge_env_values(self, additions: Dict[str, str], kind: str, source: Path) -> None:1418 destination = self.target_root / ".env"1419 env_data = parse_env_file(destination)1420 added: Dict[str, str] = {}1421 conflicts: List[str] = []1422 1423 for key, value in additions.items():1424 current = env_data.get(key)1425 if current == value:1426 continue1427 if current and not self.overwrite:1428 conflicts.append(key)1429 continue1430 env_data[key] = value1431 added[key] = value1432 1433 if conflicts and not added:1434 self.record(kind, source, destination, "conflict", "Destination .env already has different values", conflicting_keys=conflicts)1435 return1436 if not conflicts and not added:1437 self.record(kind, source, destination, "skipped", "All env values already present")1438 return1439 1440 if self.execute:1441 backup_path = self.maybe_backup(destination)1442 save_env_file(destination, env_data)1443 self.record(1444 kind,1445 source,1446 destination,1447 "migrated",1448 backup=str(backup_path) if backup_path else "",1449 added_keys=sorted(added.keys()),1450 conflicting_keys=conflicts,1451 )1452 else:1453 self.record(1454 kind,1455 source,1456 destination,1457 "migrated",1458 "Would merge env values",1459 added_keys=sorted(added.keys()),1460 conflicting_keys=conflicts,1461 )1462 1463 def migrate_messaging_settings(self, config: Optional[Dict[str, Any]] = None) -> None:1464 config = config or self.load_openclaw_config()1465 additions: Dict[str, str] = {}1466 1467 workspace = (1468 config.get("agents", {})1469 .get("defaults", {})1470 .get("workspace")1471 )1472 if isinstance(workspace, str) and workspace.strip():1473 ws_path = workspace.strip()1474 # Skip if the workspace points inside the OpenClaw source directory —1475 # that path will be stale after migration and would cause the Hermes1476 # gateway to use the old OpenClaw workspace as its cwd, picking up1477 # OpenClaw's AGENTS.md, MEMORY.md, etc.1478 try:1479 inside_source = Path(ws_path).resolve().is_relative_to(self.source_root.resolve())1480 except (ValueError, OSError):1481 inside_source = False1482 if not inside_source:1483 additions["MESSAGING_CWD"] = ws_path1484 1485 allowlist_path = self.source_root / "credentials" / "telegram-default-allowFrom.json"1486 if allowlist_path.exists():1487 try:1488 allow_data = json.loads(allowlist_path.read_text(encoding="utf-8", errors="replace"))1489 except json.JSONDecodeError:1490 self.record("messaging-settings", allowlist_path, self.target_root / ".env", "error", "Invalid JSON in Telegram allowlist file")1491 except OSError as exc:1492 self.record("messaging-settings", allowlist_path, self.target_root / ".env", "error", f"Could not read Telegram allowlist file: {exc}")1493 else:1494 allow_from = allow_data.get("allowFrom", [])1495 if isinstance(allow_from, list):1496 users = [str(user).strip() for user in allow_from if str(user).strip()]1497 if users:1498 additions["TELEGRAM_ALLOWED_USERS"] = ",".join(users)1499 1500 if additions:1501 self.merge_env_values(additions, "messaging-settings", self.source_root / "openclaw.json")1502 else:1503 self.record("messaging-settings", self.source_root / "openclaw.json", self.target_root / ".env", "skipped", "No Hermes-compatible messaging settings found")1504 1505 def handle_secret_settings(self, config: Optional[Dict[str, Any]] = None) -> None:1506 config = config or self.load_openclaw_config()1507 if self.migrate_secrets:1508 self.migrate_secret_settings(config)1509 return1510 1511 config_path = self.source_root / "openclaw.json"1512 if config_path.exists():1513 self.record(1514 "secret-settings",1515 config_path,1516 self.target_root / ".env",1517 "skipped",1518 "Secret migration disabled. Re-run with --migrate-secrets to import allowlisted secrets.",1519 supported_targets=sorted(SUPPORTED_SECRET_TARGETS),1520 )1521 else:1522 self.record(1523 "secret-settings",1524 config_path,1525 self.target_root / ".env",1526 "skipped",1527 "OpenClaw config file not found",1528 supported_targets=sorted(SUPPORTED_SECRET_TARGETS),1529 )1530 1531 def migrate_secret_settings(self, config: Dict[str, Any]) -> None:1532 secret_additions: Dict[str, str] = {}1533 1534 tg_cfg = config.get("channels", {}).get("telegram", {})1535 telegram_token = self._get_channel_field(tg_cfg, "botToken") if isinstance(tg_cfg, dict) else None1536 if isinstance(telegram_token, str) and telegram_token.strip():1537 secret_additions["TELEGRAM_BOT_TOKEN"] = telegram_token.strip()1538 1539 if secret_additions:1540 self.merge_env_values(secret_additions, "secret-settings", self.source_root / "openclaw.json")1541 else:1542 self.record(1543 "secret-settings",1544 self.source_root / "openclaw.json",1545 self.target_root / ".env",1546 "skipped",1547 "No allowlisted Hermes-compatible secrets found",1548 supported_targets=sorted(SUPPORTED_SECRET_TARGETS),1549 )1550 1551 def _resolve_channel_secret(self, value: Any) -> Optional[str]:1552 """Resolve a channel config value that may be a SecretRef."""1553 return resolve_secret_input(value, self.load_openclaw_env())1554 1555 @staticmethod1556 def _get_channel_field(ch_cfg: Dict[str, Any], field: str) -> Any:1557 """Get a field from channel config, checking both flat and accounts.default layout."""1558 val = ch_cfg.get(field)1559 if val is not None:1560 return val1561 accounts = ch_cfg.get("accounts")1562 if isinstance(accounts, dict):1563 default = accounts.get("default")1564 if isinstance(default, dict):1565 return default.get(field)1566 return None1567 1568 def migrate_discord_settings(self, config: Optional[Dict[str, Any]] = None) -> None:1569 config = config or self.load_openclaw_config()1570 additions: Dict[str, str] = {}1571 discord = config.get("channels", {}).get("discord", {})1572 if isinstance(discord, dict):1573 token = self._get_channel_field(discord, "token")1574 if isinstance(token, str) and token.strip():1575 additions["DISCORD_BOT_TOKEN"] = token.strip()1576 allow_from = self._get_channel_field(discord, "allowFrom") or []1577 if isinstance(allow_from, list):1578 users = [str(u).strip() for u in allow_from if str(u).strip()]1579 if users:1580 additions["DISCORD_ALLOWED_USERS"] = ",".join(users)1581 if additions:1582 self.merge_env_values(additions, "discord-settings", self.source_root / "openclaw.json")1583 else:1584 self.record("discord-settings", self.source_root / "openclaw.json", self.target_root / ".env", "skipped", "No Discord settings found")1585 1586 def migrate_slack_settings(self, config: Optional[Dict[str, Any]] = None) -> None:1587 config = config or self.load_openclaw_config()1588 additions: Dict[str, str] = {}1589 slack = config.get("channels", {}).get("slack", {})1590 if isinstance(slack, dict):1591 bot_token = self._get_channel_field(slack, "botToken")1592 if isinstance(bot_token, str) and bot_token.strip():1593 additions["SLACK_BOT_TOKEN"] = bot_token.strip()1594 app_token = self._get_channel_field(slack, "appToken")1595 if isinstance(app_token, str) and app_token.strip():1596 additions["SLACK_APP_TOKEN"] = app_token.strip()1597 allow_from = self._get_channel_field(slack, "allowFrom") or []1598 if isinstance(allow_from, list):1599 users = [str(u).strip() for u in allow_from if str(u).strip()]1600 if users:1601 additions["SLACK_ALLOWED_USERS"] = ",".join(users)1602 if additions:1603 self.merge_env_values(additions, "slack-settings", self.source_root / "openclaw.json")1604 else:1605 self.record("slack-settings", self.source_root / "openclaw.json", self.target_root / ".env", "skipped", "No Slack settings found")1606 1607 def migrate_whatsapp_settings(self, config: Optional[Dict[str, Any]] = None) -> None:1608 config = config or self.load_openclaw_config()1609 additions: Dict[str, str] = {}1610 whatsapp = config.get("channels", {}).get("whatsapp", {})1611 if isinstance(whatsapp, dict):1612 allow_from = self._get_channel_field(whatsapp, "allowFrom") or []1613 if isinstance(allow_from, list):1614 users = [str(u).strip() for u in allow_from if str(u).strip()]1615 if users:1616 additions["WHATSAPP_ALLOWED_USERS"] = ",".join(users)1617 if additions:1618 self.merge_env_values(additions, "whatsapp-settings", self.source_root / "openclaw.json")1619 else:1620 self.record("whatsapp-settings", self.source_root / "openclaw.json", self.target_root / ".env", "skipped", "No WhatsApp settings found")1621 1622 def migrate_signal_settings(self, config: Optional[Dict[str, Any]] = None) -> None:1623 config = config or self.load_openclaw_config()1624 additions: Dict[str, str] = {}1625 signal = config.get("channels", {}).get("signal", {})1626 if isinstance(signal, dict):1627 account = self._get_channel_field(signal, "account")1628 if isinstance(account, str) and account.strip():1629 additions["SIGNAL_ACCOUNT"] = account.strip()1630 http_url = self._get_channel_field(signal, "httpUrl")1631 if isinstance(http_url, str) and http_url.strip():1632 additions["SIGNAL_HTTP_URL"] = http_url.strip()1633 allow_from = self._get_channel_field(signal, "allowFrom") or []1634 if isinstance(allow_from, list):1635 users = [str(u).strip() for u in allow_from if str(u).strip()]1636 if users:1637 additions["SIGNAL_ALLOWED_USERS"] = ",".join(users)1638 if additions:1639 self.merge_env_values(additions, "signal-settings", self.source_root / "openclaw.json")1640 else:1641 self.record("signal-settings", self.source_root / "openclaw.json", self.target_root / ".env", "skipped", "No Signal settings found")1642 1643 def handle_provider_keys(self, config: Optional[Dict[str, Any]] = None) -> None:1644 config = config or self.load_openclaw_config()1645 if not self.migrate_secrets:1646 config_path = self.source_root / "openclaw.json"1647 self.record(1648 "provider-keys",1649 config_path,1650 self.target_root / ".env",1651 "skipped",1652 "Secret migration disabled. Re-run with --migrate-secrets to import provider API keys.",1653 supported_targets=sorted(SUPPORTED_SECRET_TARGETS),1654 )1655 return1656 self.migrate_provider_keys(config)1657 1658 def migrate_provider_keys(self, config: Dict[str, Any]) -> None:1659 secret_additions: Dict[str, str] = {}1660 1661 # Extract provider API keys from models.providers1662 # Note: apiKey values can be strings, env templates, or SecretRef objects1663 openclaw_env = self.load_openclaw_env()1664 providers = config.get("models", {}).get("providers", {})1665 if isinstance(providers, dict):1666 for provider_name, provider_cfg in providers.items():1667 if not isinstance(provider_cfg, dict):1668 continue1669 raw_key = provider_cfg.get("apiKey")1670 api_key = resolve_secret_input(raw_key, openclaw_env)1671 if not api_key:1672 # Warn if a SecretRef with file/exec source was silently unresolvable1673 if isinstance(raw_key, dict) and raw_key.get("source") in {"file", "exec"}:1674 self.record(1675 "provider-keys",1676 self.source_root / "openclaw.json",1677 None,1678 "skipped",1679 f"Provider '{provider_name}' uses a {raw_key['source']}-backed SecretRef "1680 f"that cannot be auto-migrated. Add this key manually via: hermes config set",1681 )1682 continue1683 1684 base_url = provider_cfg.get("baseUrl", "")1685 api_type = provider_cfg.get("api", "")1686 env_var = None1687 1688 # Match by baseUrl first1689 if isinstance(base_url, str):1690 if "openrouter" in base_url.lower():1691 env_var = "OPENROUTER_API_KEY"1692 elif "openai.com" in base_url.lower():1693 env_var = "OPENAI_API_KEY"1694 elif "anthropic" in base_url.lower():1695 env_var = "ANTHROPIC_API_KEY"1696 1697 # Match by api type1698 if not env_var and isinstance(api_type, str) and api_type == "anthropic-messages":1699 env_var = "ANTHROPIC_API_KEY"1700 1701 # Match by provider name1702 if not env_var:1703 name_lower = provider_name.lower()1704 if name_lower == "openrouter":1705 env_var = "OPENROUTER_API_KEY"1706 elif "openai" in name_lower:1707 env_var = "OPENAI_API_KEY"1708 1709 if env_var:1710 secret_additions[env_var] = api_key1711 1712 # Extract TTS API keys1713 tts = config.get("messages", {}).get("tts", {})1714 if isinstance(tts, dict):1715 elevenlabs = tts.get("elevenlabs", {})1716 if isinstance(elevenlabs, dict):1717 el_key = elevenlabs.get("apiKey")1718 if isinstance(el_key, str) and el_key.strip():1719 secret_additions["ELEVENLABS_API_KEY"] = el_key.strip()1720 openai_tts = tts.get("openai", {})1721 if isinstance(openai_tts, dict):1722 oai_key = openai_tts.get("apiKey")1723 if isinstance(oai_key, str) and oai_key.strip():1724 secret_additions["VOICE_TOOLS_OPENAI_KEY"] = oai_key.strip()1725 1726 # Also check the OpenClaw .env file — many users store keys there1727 # instead of inline in openclaw.json1728 openclaw_env = self.load_openclaw_env()1729 env_key_mapping = {1730 "OPENROUTER_API_KEY": "OPENROUTER_API_KEY",1731 "OPENAI_API_KEY": "OPENAI_API_KEY",1732 "ANTHROPIC_API_KEY": "ANTHROPIC_API_KEY",1733 "ELEVENLABS_API_KEY": "ELEVENLABS_API_KEY",1734 "TELEGRAM_BOT_TOKEN": "TELEGRAM_BOT_TOKEN",1735 "DEEPSEEK_API_KEY": "DEEPSEEK_API_KEY",1736 "GEMINI_API_KEY": "GEMINI_API_KEY",1737 "ZAI_API_KEY": "ZAI_API_KEY",1738 "MINIMAX_API_KEY": "MINIMAX_API_KEY",1739 }1740 for oc_key, hermes_key in env_key_mapping.items():1741 val = openclaw_env.get(oc_key, "").strip()1742 if val and hermes_key not in secret_additions:1743 secret_additions[hermes_key] = val1744 1745 # Check the openclaw.json "env" sub-object — some OpenClaw setups1746 # store API keys here instead of in a separate .env file.1747 # Keys can be at env.<KEY> or env.vars.<KEY>.1748 json_env = config.get("env")1749 if isinstance(json_env, dict):1750 env_vars = json_env.get("vars")1751 sources = [json_env]1752 if isinstance(env_vars, dict):1753 sources.append(env_vars)1754 for src in sources:1755 for oc_key, hermes_key in env_key_mapping.items():1756 val = src.get(oc_key)1757 if isinstance(val, str) and val.strip() and hermes_key not in secret_additions:1758 secret_additions[hermes_key] = val.strip()1759 1760 # Check per-agent auth-profiles.json for additional credentials1761 auth_profiles_path = self.source_root / "agents" / "main" / "agent" / "auth-profiles.json"1762 if auth_profiles_path.exists():1763 try:1764 profiles = json.loads(auth_profiles_path.read_text(encoding="utf-8", errors="replace"))1765 if isinstance(profiles, dict):1766 # auth-profiles.json wraps profiles in a "profiles" key1767 profile_entries = profiles.get("profiles", profiles) if isinstance(profiles.get("profiles"), dict) else profiles1768 for profile_name, profile_data in profile_entries.items():1769 if not isinstance(profile_data, dict):1770 continue1771 # Canonical field is "key", "apiKey" is accepted as alias1772 api_key = profile_data.get("key", "") or profile_data.get("apiKey", "")1773 if not isinstance(api_key, str) or not api_key.strip():1774 continue1775 name_lower = profile_name.lower()1776 if "openrouter" in name_lower and "OPENROUTER_API_KEY" not in secret_additions:1777 secret_additions["OPENROUTER_API_KEY"] = api_key.strip()1778 elif "openai" in name_lower and "OPENAI_API_KEY" not in secret_additions:1779 secret_additions["OPENAI_API_KEY"] = api_key.strip()1780 elif "anthropic" in name_lower and "ANTHROPIC_API_KEY" not in secret_additions:1781 secret_additions["ANTHROPIC_API_KEY"] = api_key.strip()1782 except (json.JSONDecodeError, OSError):1783 pass1784 1785 if secret_additions:1786 self.merge_env_values(secret_additions, "provider-keys", self.source_root / "openclaw.json")1787 else:1788 self.record(1789 "provider-keys",1790 self.source_root / "openclaw.json",1791 self.target_root / ".env",1792 "skipped",1793 "No provider API keys found",1794 supported_targets=sorted(SUPPORTED_SECRET_TARGETS),1795 )1796 1797 def migrate_model_config(self, config: Optional[Dict[str, Any]] = None) -> None:1798 config = config or self.load_openclaw_config()1799 destination = self.target_root / "config.yaml"1800 source_path = self.source_root / "openclaw.json"1801 1802 model_value = config.get("agents", {}).get("defaults", {}).get("model")1803 if model_value is None:1804 self.record("model-config", source_path, destination, "skipped", "No default model found in OpenClaw config")1805 return1806 1807 if isinstance(model_value, dict):1808 model_str = model_value.get("primary")1809 else:1810 model_str = model_value1811 1812 if not isinstance(model_str, str) or not model_str.strip():1813 self.record("model-config", source_path, destination, "skipped", "Default model value is empty or invalid")1814 return1815 1816 model_str = model_str.strip()1817 1818 # Resolve a model alias against the OpenClaw model catalog.1819 # OpenClaw stores agents.defaults.model as either a bare string or1820 # {"primary": "<value>"}, and that value can be either:1821 # - a full provider/model API ID (e.g. "anthropic/claude-opus-4-6"), or1822 # - a display alias (e.g. "Claude Opus 4.6") that maps to one.1823 # The catalog at agents.defaults.models is keyed by the full1824 # provider/model API ID with an "alias" field on the value, e.g.:1825 # {"anthropic/claude-opus-4-6": {"alias": "Claude Opus 4.6"}}1826 # If model_str matches an alias in the catalog, rewrite it to the1827 # catalog key (the real API ID). If it's already an API ID or has1828 # no catalog match, leave it alone and let downstream pass it through.1829 model_catalog = config.get("agents", {}).get("defaults", {}).get("models", {})1830 if isinstance(model_catalog, dict) and model_str not in model_catalog:1831 for api_id, entry in model_catalog.items():1832 if not isinstance(api_id, str):1833 continue1834 if isinstance(entry, dict) and entry.get("alias") == model_str:1835 model_str = api_id1836 break1837 if isinstance(entry, str) and entry == model_str:1838 model_str = api_id1839 break1840 1841 if yaml is None:1842 self.record("model-config", source_path, destination, "error", "PyYAML is not available")1843 return1844 1845 hermes_config = load_yaml_file(destination)1846 current_model = hermes_config.get("model")1847 if current_model == model_str:1848 self.record("model-config", source_path, destination, "skipped", "Model already set to the same value")1849 return1850 if current_model and not self.overwrite:1851 self.record("model-config", source_path, destination, "conflict", "Model already set and overwrite is disabled", current=current_model, incoming=model_str)1852 return1853 1854 if self.execute:1855 backup_path = self.maybe_backup(destination)1856 existing_model = hermes_config.get("model")1857 if isinstance(existing_model, dict):1858 existing_model["default"] = model_str1859 else:1860 hermes_config["model"] = {"default": model_str}1861 dump_yaml_file(destination, hermes_config)1862 self.record("model-config", source_path, destination, "migrated", backup=str(backup_path) if backup_path else "", model=model_str)1863 else:1864 self.record("model-config", source_path, destination, "migrated", "Would set model", model=model_str)1865 1866 def migrate_tts_config(self, config: Optional[Dict[str, Any]] = None) -> None:1867 config = config or self.load_openclaw_config()1868 destination = self.target_root / "config.yaml"1869 source_path = self.source_root / "openclaw.json"1870 1871 tts = config.get("messages", {}).get("tts", {})1872 if not isinstance(tts, dict) or not tts:1873 self.record("tts-config", source_path, destination, "skipped", "No TTS configuration found in OpenClaw config")1874 return1875 1876 if yaml is None:1877 self.record("tts-config", source_path, destination, "error", "PyYAML is not available")1878 return1879 1880 tts_data: Dict[str, Any] = {}1881 1882 provider = tts.get("provider")1883 if isinstance(provider, str) and provider in {"elevenlabs", "openai", "edge", "microsoft"}:1884 # OpenClaw renamed "edge" to "microsoft"; Hermes still uses "edge"1885 tts_data["provider"] = "edge" if provider == "microsoft" else provider1886 1887 # TTS provider settings live under messages.tts.providers.{provider}1888 # in OpenClaw (not messages.tts.elevenlabs directly)1889 providers = tts.get("providers") or {}1890 1891 # Also check the top-level "talk" config which has provider settings too1892 talk_cfg = (config or self.load_openclaw_config()).get("talk") or {}1893 talk_providers = talk_cfg.get("providers") or {}1894 1895 # Merge: messages.tts.providers takes priority, then talk.providers,1896 # then legacy flat keys (messages.tts.elevenlabs, etc.)1897 elevenlabs = (1898 (providers.get("elevenlabs") or {})1899 if isinstance(providers.get("elevenlabs"), dict) else1900 (talk_providers.get("elevenlabs") or {})1901 if isinstance(talk_providers.get("elevenlabs"), dict) else1902 (tts.get("elevenlabs") or {})1903 )1904 if isinstance(elevenlabs, dict):1905 el_settings: Dict[str, str] = {}1906 voice_id = elevenlabs.get("voiceId") or talk_cfg.get("voiceId")1907 if isinstance(voice_id, str) and voice_id.strip():1908 el_settings["voice_id"] = voice_id.strip()1909 model_id = elevenlabs.get("modelId") or talk_cfg.get("modelId")1910 if isinstance(model_id, str) and model_id.strip():1911 el_settings["model_id"] = model_id.strip()1912 if el_settings:1913 tts_data["elevenlabs"] = el_settings1914 1915 openai_tts = (1916 (providers.get("openai") or {})1917 if isinstance(providers.get("openai"), dict) else1918 (talk_providers.get("openai") or {})1919 if isinstance(talk_providers.get("openai"), dict) else1920 (tts.get("openai") or {})1921 )1922 if isinstance(openai_tts, dict):1923 oai_settings: Dict[str, str] = {}1924 oai_model = openai_tts.get("model") or openai_tts.get("modelId")1925 if isinstance(oai_model, str) and oai_model.strip():1926 oai_settings["model"] = oai_model.strip()1927 oai_voice = openai_tts.get("voice")1928 if isinstance(oai_voice, str) and oai_voice.strip():1929 oai_settings["voice"] = oai_voice.strip()1930 if oai_settings:1931 tts_data["openai"] = oai_settings1932 1933 edge_tts = (1934 (providers.get("edge") or providers.get("microsoft") or {})1935 if isinstance(providers.get("edge"), dict) or isinstance(providers.get("microsoft"), dict) else1936 (tts.get("edge") or tts.get("microsoft") or {})1937 )1938 if isinstance(edge_tts, dict):1939 edge_voice = edge_tts.get("voice")1940 if isinstance(edge_voice, str) and edge_voice.strip():1941 tts_data["edge"] = {"voice": edge_voice.strip()}1942 1943 if not tts_data:1944 self.record("tts-config", source_path, destination, "skipped", "No compatible TTS settings found")1945 return1946 1947 hermes_config = load_yaml_file(destination)1948 existing_tts = hermes_config.get("tts", {})1949 if not isinstance(existing_tts, dict):1950 existing_tts = {}1951 1952 if self.execute:1953 backup_path = self.maybe_backup(destination)1954 merged_tts = dict(existing_tts)1955 for key, value in tts_data.items():1956 if isinstance(value, dict) and isinstance(merged_tts.get(key), dict):1957 merged_tts[key] = {**merged_tts[key], **value}1958 else:1959 merged_tts[key] = value1960 hermes_config["tts"] = merged_tts1961 dump_yaml_file(destination, hermes_config)1962 self.record("tts-config", source_path, destination, "migrated", backup=str(backup_path) if backup_path else "", settings=list(tts_data.keys()))1963 else:1964 self.record("tts-config", source_path, destination, "migrated", "Would set TTS config", settings=list(tts_data.keys()))1965 1966 def migrate_shared_skills(self) -> None:1967 # Check all OpenClaw skill sources: managed, personal, project-level1968 skill_sources = [1969 (self.source_root / "skills", "shared-skills", "managed skills"),1970 (Path.home() / ".agents" / "skills", "personal-skills", "personal cross-project skills"),1971 (self.source_root / "workspace" / ".agents" / "skills", "project-skills", "project-level shared skills"),1972 (self.source_root / "workspace.default" / ".agents" / "skills", "project-skills", "project-level shared skills"),1973 ]1974 found_any = False1975 for source_root, kind_label, desc in skill_sources:1976 if source_root.exists():1977 found_any = True1978 self._import_skill_directory(source_root, kind_label, desc)1979 if not found_any:1980 destination_root = self.target_root / "skills" / SKILL_CATEGORY_DIRNAME1981 self.record("shared-skills", None, destination_root, "skipped", "No shared OpenClaw skills directories found")1982 1983 def _import_skill_directory(self, source_root: Path, kind_label: str, desc: str) -> None:1984 """Import skills from a single source directory into openclaw-imports."""1985 destination_root = self.target_root / "skills" / SKILL_CATEGORY_DIRNAME1986 1987 skill_dirs = [p for p in sorted(source_root.iterdir()) if p.is_dir() and (p / "SKILL.md").exists()]1988 if not skill_dirs:1989 self.record(kind_label, source_root, destination_root, "skipped", f"No skills with SKILL.md found in {desc}")1990 return1991 1992 for skill_dir in skill_dirs:1993 destination = destination_root / skill_dir.name1994 final_destination = destination1995 if destination.exists():1996 if self.skill_conflict_mode == "skip":1997 self.record(kind_label, skill_dir, destination, "conflict", "Destination skill already exists")1998 continue1999 if self.skill_conflict_mode == "rename":2000 final_destination = self.resolve_skill_destination(destination)2001 if self.execute:2002 backup_path = None2003 if final_destination == destination and destination.exists():2004 backup_path = self.maybe_backup(destination)2005 final_destination.parent.mkdir(parents=True, exist_ok=True)2006 if final_destination == destination and destination.exists():2007 shutil.rmtree(destination)2008 shutil.copytree(skill_dir, final_destination)2009 details: Dict[str, Any] = {"backup": str(backup_path) if backup_path else ""}2010 if final_destination != destination:2011 details["renamed_from"] = str(destination)2012 self.record(kind_label, skill_dir, final_destination, "migrated", **details)2013 else:2014 if final_destination != destination:2015 self.record(2016 kind_label,2017 skill_dir,2018 final_destination,2019 "migrated",2020 f"Would copy {desc} directory under a renamed folder",2021 renamed_from=str(destination),2022 )2023 else:2024 self.record(kind_label, skill_dir, final_destination, "migrated", f"Would copy {desc} directory")2025 2026 desc_path = destination_root / "DESCRIPTION.md"2027 if self.execute:2028 desc_path.parent.mkdir(parents=True, exist_ok=True)2029 if not desc_path.exists():2030 desc_path.write_text(SKILL_CATEGORY_DESCRIPTION + "\n", encoding="utf-8")2031 elif not desc_path.exists():2032 self.record("shared-skill-category", None, desc_path, "migrated", "Would create category description")2033 2034 def migrate_daily_memory(self) -> None:2035 source_dir = self.source_candidate("workspace/memory")2036 destination = self.target_root / "memories" / "MEMORY.md"2037 if not source_dir or not source_dir.is_dir():2038 self.record("daily-memory", None, destination, "skipped", "No workspace/memory/ directory found")2039 return2040 2041 md_files = sorted(p for p in source_dir.iterdir() if p.is_file() and p.suffix == ".md")2042 if not md_files:2043 self.record("daily-memory", source_dir, destination, "skipped", "No .md files found in workspace/memory/")2044 return2045 2046 all_incoming: List[str] = []2047 for md_file in md_files:2048 try:2049 # read_text() uses errors="replace" so it cannot raise2050 # UnicodeDecodeError; OSError covers unreadable/vanished files.2051 entries = extract_markdown_entries(read_text(md_file))2052 except OSError:2053 continue2054 all_incoming.extend(entries)2055 2056 if not all_incoming:2057 self.record("daily-memory", source_dir, destination, "skipped", "No importable entries found in daily memory files")2058 return2059 all_incoming = [rebrand_text(entry) for entry in all_incoming]2060 2061 existing = parse_existing_memory_entries(destination)2062 merged, stats, overflowed = merge_entries(existing, all_incoming, self.memory_limit)2063 details = {2064 "source_files": len(md_files),2065 "existing_entries": stats["existing"],2066 "added_entries": stats["added"],2067 "duplicate_entries": stats["duplicates"],2068 "overflowed_entries": stats["overflowed"],2069 "char_limit": self.memory_limit,2070 "final_char_count": len(ENTRY_DELIMITER.join(merged)) if merged else 0,2071 }2072 overflow_file = self.write_overflow_entries("daily-memory", overflowed)2073 if overflow_file is not None:2074 details["overflow_file"] = str(overflow_file)2075 2076 if self.execute:2077 if stats["added"] == 0 and not overflowed:2078 self.record("daily-memory", source_dir, destination, "skipped", "No new entries to import", **details)2079 return2080 backup_path = self.maybe_backup(destination)2081 ensure_parent(destination)2082 destination.write_text(ENTRY_DELIMITER.join(merged) + ("\n" if merged else ""), encoding="utf-8")2083 self.record(2084 "daily-memory",2085 source_dir,2086 destination,2087 "migrated",2088 backup=str(backup_path) if backup_path else "",2089 overflow_preview=overflowed[:5],2090 **details,2091 )2092 else:2093 self.record("daily-memory", source_dir, destination, "migrated", "Would merge daily memory entries", overflow_preview=overflowed[:5], **details)2094 2095 def migrate_skills(self) -> None:2096 source_root = self.source_candidate("workspace/skills")2097 destination_root = self.target_root / "skills" / SKILL_CATEGORY_DIRNAME2098 if not source_root or not source_root.exists():2099 self.record("skills", None, destination_root, "skipped", "No OpenClaw skills directory found")2100 return2101 2102 skill_dirs = [p for p in sorted(source_root.iterdir()) if p.is_dir() and (p / "SKILL.md").exists()]2103 if not skill_dirs:2104 self.record("skills", source_root, destination_root, "skipped", "No skills with SKILL.md found")2105 return2106 2107 for skill_dir in skill_dirs:2108 destination = destination_root / skill_dir.name2109 final_destination = destination2110 if destination.exists():2111 if self.skill_conflict_mode == "skip":2112 self.record("skill", skill_dir, destination, "conflict", "Destination skill already exists")2113 continue2114 if self.skill_conflict_mode == "rename":2115 final_destination = self.resolve_skill_destination(destination)2116 if self.execute:2117 backup_path = None2118 if final_destination == destination and destination.exists():2119 backup_path = self.maybe_backup(destination)2120 final_destination.parent.mkdir(parents=True, exist_ok=True)2121 if final_destination == destination and destination.exists():2122 shutil.rmtree(destination)2123 shutil.copytree(skill_dir, final_destination)2124 details: Dict[str, Any] = {"backup": str(backup_path) if backup_path else ""}2125 if final_destination != destination:2126 details["renamed_from"] = str(destination)2127 self.record("skill", skill_dir, final_destination, "migrated", **details)2128 else:2129 if final_destination != destination:2130 self.record(2131 "skill",2132 skill_dir,2133 final_destination,2134 "migrated",2135 "Would copy skill directory under a renamed folder",2136 renamed_from=str(destination),2137 )2138 else:2139 self.record("skill", skill_dir, final_destination, "migrated", "Would copy skill directory")2140 2141 desc_path = destination_root / "DESCRIPTION.md"2142 if self.execute:2143 desc_path.parent.mkdir(parents=True, exist_ok=True)2144 if not desc_path.exists():2145 desc_path.write_text(SKILL_CATEGORY_DESCRIPTION + "\n", encoding="utf-8")2146 elif not desc_path.exists():2147 self.record("skill-category", None, desc_path, "migrated", "Would create category description")2148 2149 def copy_tree_non_destructive(2150 self,2151 source_root: Optional[Path],2152 destination_root: Path,2153 kind: str,2154 ignore_dir_names: Optional[set[str]] = None,2155 ) -> None:2156 if not source_root or not source_root.exists():2157 self.record(kind, None, destination_root, "skipped", "Source directory not found")2158 return2159 2160 ignore_dir_names = ignore_dir_names or set()2161 files = [2162 p2163 for p in source_root.rglob("*")2164 if p.is_file() and not any(part in ignore_dir_names for part in p.relative_to(source_root).parts[:-1])2165 ]2166 if not files:2167 self.record(kind, source_root, destination_root, "skipped", "No files found")2168 return2169 2170 copied = 02171 skipped = 02172 conflicts = 02173 2174 for source in files:2175 rel = source.relative_to(source_root)2176 destination = destination_root / rel2177 if destination.exists():2178 if sha256_file(source) == sha256_file(destination):2179 skipped += 12180 continue2181 if not self.overwrite:2182 conflicts += 12183 self.record(kind, source, destination, "conflict", "Destination file already exists")2184 continue2185 2186 if self.execute:2187 self.maybe_backup(destination)2188 ensure_parent(destination)2189 shutil.copy2(source, destination)2190 copied += 12191 2192 status = "migrated" if copied else "skipped"2193 reason = ""2194 if not copied and conflicts:2195 status = "conflict"2196 reason = "All candidate files conflicted with existing destination files"2197 elif not copied:2198 reason = "No new files to copy"2199 2200 self.record(kind, source_root, destination_root, status, reason, copied_files=copied, unchanged_files=skipped, conflicts=conflicts)2201 2202 def archive_docs(self) -> None:2203 candidates = [2204 self.source_candidate("workspace/IDENTITY.md", "workspace.default/IDENTITY.md"),2205 self.source_candidate("workspace/TOOLS.md", "workspace.default/TOOLS.md"),2206 self.source_candidate("workspace/HEARTBEAT.md", "workspace.default/HEARTBEAT.md"),2207 self.source_candidate("workspace/BOOTSTRAP.md", "workspace.default/BOOTSTRAP.md"),2208 ]2209 for candidate in candidates:2210 if candidate:2211 self.archive_path(candidate, reason="No direct Hermes destination; archived for manual review")2212 2213 for rel in ("workspace/.learnings", "workspace/memory"):2214 candidate = self.source_root / rel2215 if candidate.exists():2216 self.archive_path(candidate, reason="No direct Hermes destination; archived for manual review")2217 2218 partially_extracted = [2219 ("openclaw.json", "Selected Hermes-compatible values were extracted; raw OpenClaw config was not copied."),2220 ("credentials/telegram-default-allowFrom.json", "Selected Hermes-compatible values were extracted; raw credentials file was not copied."),2221 ]2222 for rel, reason in partially_extracted:2223 candidate = self.source_root / rel2224 if candidate.exists():2225 self.record("raw-config-skip", candidate, None, "skipped", reason)2226 2227 skipped_sensitive = [2228 "memory/main.sqlite",2229 "credentials",2230 "devices",2231 "identity",2232 "workspace.zip",2233 ]2234 for rel in skipped_sensitive:2235 candidate = self.source_root / rel2236 if candidate.exists():2237 self.record("sensitive-skip", candidate, None, "skipped", "Contains secrets, binary state, or product-specific runtime data")2238 2239 def archive_path(self, source: Path, reason: str) -> None:2240 destination = self.archive_dir / relative_label(source, self.source_root) if self.archive_dir else None2241 if self.execute and destination is not None:2242 ensure_parent(destination)2243 if source.is_dir():2244 shutil.copytree(source, destination, dirs_exist_ok=True)2245 else:2246 shutil.copy2(source, destination)2247 self.record("archive", source, destination, "archived", reason)2248 else:2249 self.record("archive", source, destination, "archived", reason)2250 2251 # ── MCP servers ─────────────────────────────────────────────2252 def migrate_mcp_servers(self, config: Optional[Dict[str, Any]] = None) -> None:2253 config = config or self.load_openclaw_config()2254 mcp_raw = (config.get("mcp") or {}).get("servers") or {}2255 if not mcp_raw:2256 self.record("mcp-servers", None, None, "skipped", "No MCP servers found in OpenClaw config")2257 return2258 2259 hermes_cfg_path = self.target_root / "config.yaml"2260 hermes_cfg = load_yaml_file(hermes_cfg_path)2261 existing_mcp = hermes_cfg.get("mcp_servers") or {}2262 added = 02263 2264 for name, srv in mcp_raw.items():2265 if not isinstance(srv, dict):2266 continue2267 if name in existing_mcp and not self.overwrite:2268 self.record("mcp-servers", f"mcp.servers.{name}", f"mcp_servers.{name}", "conflict",2269 "MCP server already exists in Hermes config")2270 continue2271 2272 hermes_srv: Dict[str, Any] = {}2273 # STDIO transport2274 if srv.get("command"):2275 hermes_srv["command"] = srv["command"]2276 if srv.get("args"):2277 hermes_srv["args"] = srv["args"]2278 if srv.get("env"):2279 hermes_srv["env"] = srv["env"]2280 if srv.get("cwd"):2281 hermes_srv["cwd"] = srv["cwd"]2282 # HTTP/SSE transport2283 if srv.get("url"):2284 hermes_srv["url"] = srv["url"]2285 if srv.get("headers"):2286 hermes_srv["headers"] = srv["headers"]2287 if srv.get("auth"):2288 hermes_srv["auth"] = srv["auth"]2289 # Common fields2290 if srv.get("enabled") is False:2291 hermes_srv["enabled"] = False2292 if srv.get("timeout"):2293 hermes_srv["timeout"] = srv["timeout"]2294 if srv.get("connectTimeout"):2295 hermes_srv["connect_timeout"] = srv["connectTimeout"]2296 # Tool filtering2297 tools_cfg = srv.get("tools") or {}2298 if tools_cfg.get("include") or tools_cfg.get("exclude"):2299 hermes_srv["tools"] = {}2300 if tools_cfg.get("include"):2301 hermes_srv["tools"]["include"] = tools_cfg["include"]2302 if tools_cfg.get("exclude"):2303 hermes_srv["tools"]["exclude"] = tools_cfg["exclude"]2304 # Sampling2305 sampling = srv.get("sampling")2306 if sampling and isinstance(sampling, dict):2307 hermes_srv["sampling"] = {2308 k: v for k, v in {2309 "enabled": sampling.get("enabled"),2310 "model": sampling.get("model"),2311 "max_tokens_cap": sampling.get("maxTokensCap") or sampling.get("max_tokens_cap"),2312 "timeout": sampling.get("timeout"),2313 "max_rpm": sampling.get("maxRpm") or sampling.get("max_rpm"),2314 }.items() if v is not None2315 }2316 2317 existing_mcp[name] = hermes_srv2318 added += 12319 self.record("mcp-servers", f"mcp.servers.{name}", f"config.yaml mcp_servers.{name}",2320 "migrated", servers_added=added)2321 2322 if added > 0 and self.execute:2323 self.maybe_backup(hermes_cfg_path)2324 hermes_cfg["mcp_servers"] = existing_mcp2325 dump_yaml_file(hermes_cfg_path, hermes_cfg)2326 2327 # ── Plugins ───────────────────────────────────────────────2328 def migrate_plugins_config(self, config: Optional[Dict[str, Any]] = None) -> None:2329 config = config or self.load_openclaw_config()2330 plugins = config.get("plugins") or {}2331 if not plugins:2332 self.record("plugins-config", None, None, "skipped", "No plugins configuration found")2333 return2334 2335 # Archive the full plugins config2336 if self.archive_dir and self.execute:2337 self.archive_dir.mkdir(parents=True, exist_ok=True)2338 dest = self.archive_dir / "plugins-config.json"2339 dest.write_text(json.dumps(plugins, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")2340 self.record("plugins-config", "openclaw.json plugins.*", str(dest), "archived",2341 "Plugins config archived for manual review")2342 else:2343 self.record("plugins-config", "openclaw.json plugins.*", "archive/plugins-config.json",2344 "archived" if not self.execute else "migrated", "Would archive plugins config")2345 2346 # Copy extensions directory if it exists2347 ext_dir = self.source_root / "extensions"2348 if ext_dir.is_dir() and self.archive_dir:2349 dest_ext = self.archive_dir / "extensions"2350 if self.execute:2351 shutil.copytree(ext_dir, dest_ext, dirs_exist_ok=True)2352 self.record("plugins-config", str(ext_dir), str(dest_ext), "archived",2353 "Extensions directory archived")2354 2355 # Extract any plugin env vars2356 entries = plugins.get("entries") or {}2357 for plugin_name, plugin_cfg in entries.items():2358 if isinstance(plugin_cfg, dict):2359 env_vars = plugin_cfg.get("env") or {}2360 api_key = plugin_cfg.get("apiKey")2361 if api_key and self.migrate_secrets:2362 env_key = f"PLUGIN_{plugin_name.upper().replace('-', '_')}_API_KEY"2363 self._set_env_var(env_key, api_key, f"plugins.entries.{plugin_name}.apiKey")2364 2365 # ── Cron jobs ─────────────────────────────────────────────2366 def migrate_cron_jobs(self, config: Optional[Dict[str, Any]] = None) -> None:2367 config = config or self.load_openclaw_config()2368 cron = config.get("cron") or {}2369 cron_store = self.source_root / "cron"2370 found_any = False2371 2372 # Archive the full cron config when present2373 if cron:2374 found_any = True2375 if self.archive_dir and self.execute:2376 self.archive_dir.mkdir(parents=True, exist_ok=True)2377 dest = self.archive_dir / "cron-config.json"2378 dest.write_text(json.dumps(cron, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")2379 self.record("cron-jobs", "openclaw.json cron.*", str(dest), "archived",2380 "Cron config archived. Use 'hermes cron' to recreate jobs manually.")2381 else:2382 self.record("cron-jobs", "openclaw.json cron.*", "archive/cron-config.json",2383 "archived", "Would archive cron config")2384 2385 # Also check for cron store files even when config.cron is missing2386 if cron_store.is_dir() and self.archive_dir:2387 found_any = True2388 dest_cron = self.archive_dir / "cron-store"2389 if self.execute:2390 shutil.copytree(cron_store, dest_cron, dirs_exist_ok=True)2391 self.record("cron-jobs", str(cron_store), str(dest_cron), "archived",2392 "Cron job store archived")2393 2394 if not found_any:2395 self.record("cron-jobs", None, None, "skipped", "No cron configuration found")2396 2397 # ── Hooks ─────────────────────────────────────────────────2398 def migrate_hooks_config(self, config: Optional[Dict[str, Any]] = None) -> None:2399 config = config or self.load_openclaw_config()2400 hooks = config.get("hooks") or {}2401 if not hooks:2402 self.record("hooks-config", None, None, "skipped", "No hooks configuration found")2403 return2404 2405 # Archive the full hooks config2406 if self.archive_dir and self.execute:2407 self.archive_dir.mkdir(parents=True, exist_ok=True)2408 dest = self.archive_dir / "hooks-config.json"2409 dest.write_text(json.dumps(hooks, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")2410 self.record("hooks-config", "openclaw.json hooks.*", str(dest), "archived",2411 "Hooks config archived for manual review")2412 else:2413 self.record("hooks-config", "openclaw.json hooks.*", "archive/hooks-config.json",2414 "archived", "Would archive hooks config")2415 2416 # Copy workspace hooks directory2417 for ws_name in ("workspace", "workspace.default"):2418 hooks_dir = self.source_root / ws_name / "hooks"2419 if hooks_dir.is_dir() and self.archive_dir:2420 dest_hooks = self.archive_dir / "workspace-hooks"2421 if self.execute:2422 shutil.copytree(hooks_dir, dest_hooks, dirs_exist_ok=True)2423 self.record("hooks-config", str(hooks_dir), str(dest_hooks), "archived",2424 "Workspace hooks directory archived")2425 break2426 2427 # ── Agent config ──────────────────────────────────────────2428 def migrate_agent_config(self, config: Optional[Dict[str, Any]] = None) -> None:2429 config = config or self.load_openclaw_config()2430 agents = config.get("agents") or {}2431 defaults = agents.get("defaults") or {}2432 agent_list = agents.get("list") or []2433 2434 if not defaults and not agent_list:2435 self.record("agent-config", None, None, "skipped", "No agent configuration found")2436 return2437 2438 hermes_cfg_path = self.target_root / "config.yaml"2439 hermes_cfg = load_yaml_file(hermes_cfg_path)2440 changes = False2441 2442 # Map agent defaults2443 agent_cfg = hermes_cfg.get("agent") or {}2444 if defaults.get("contextTokens"):2445 # No direct mapping but useful context2446 pass2447 if defaults.get("timeoutSeconds"):2448 agent_cfg["max_turns"] = min(defaults["timeoutSeconds"] // 10, 200)2449 changes = True2450 if defaults.get("verboseDefault"):2451 agent_cfg["verbose"] = defaults["verboseDefault"]2452 changes = True2453 if defaults.get("thinkingDefault"):2454 # Map OpenClaw thinking -> Hermes reasoning_effort2455 thinking = defaults["thinkingDefault"]2456 if thinking in {"always", "high", "xhigh"}:2457 agent_cfg["reasoning_effort"] = "high"2458 elif thinking in {"auto", "medium", "adaptive"}:2459 agent_cfg["reasoning_effort"] = "medium"2460 elif thinking in {"off", "low", "none", "minimal"}:2461 agent_cfg["reasoning_effort"] = "low"2462 changes = True2463 2464 # Map compaction -> compression2465 compaction = defaults.get("compaction") or {}2466 if compaction:2467 compression = hermes_cfg.get("compression") or {}2468 if compaction.get("mode") == "off":2469 compression["enabled"] = False2470 else:2471 compression["enabled"] = True2472 if compaction.get("timeout"):2473 pass # No direct mapping2474 if compaction.get("model"):2475 aux = hermes_cfg.setdefault("auxiliary", {})2476 aux_comp = aux.setdefault("compression", {})2477 aux_comp["model"] = compaction["model"]2478 hermes_cfg["compression"] = compression2479 changes = True2480 2481 # Map humanDelay2482 human_delay = defaults.get("humanDelay") or {}2483 if human_delay:2484 hd = hermes_cfg.get("human_delay") or {}2485 hd_mode = human_delay.get("mode") or ("natural" if human_delay.get("enabled") else None)2486 if hd_mode and hd_mode != "off":2487 hd["mode"] = hd_mode2488 if human_delay.get("minMs"):2489 hd["min_ms"] = human_delay["minMs"]2490 if human_delay.get("maxMs"):2491 hd["max_ms"] = human_delay["maxMs"]2492 hermes_cfg["human_delay"] = hd2493 changes = True2494 2495 # Map userTimezone2496 if defaults.get("userTimezone"):2497 hermes_cfg["timezone"] = defaults["userTimezone"]2498 changes = True2499 2500 # Map terminal/exec settings2501 exec_cfg = (config.get("tools") or {}).get("exec") or {}2502 if exec_cfg:2503 terminal_cfg = hermes_cfg.get("terminal") or {}2504 if exec_cfg.get("timeoutSec") or exec_cfg.get("timeout"):2505 terminal_cfg["timeout"] = exec_cfg.get("timeoutSec") or exec_cfg.get("timeout")2506 changes = True2507 hermes_cfg["terminal"] = terminal_cfg2508 2509 # Map sandbox -> terminal docker settings2510 sandbox = defaults.get("sandbox") or {}2511 if sandbox and sandbox.get("backend") == "docker":2512 terminal_cfg = hermes_cfg.get("terminal") or {}2513 terminal_cfg["backend"] = "docker"2514 if sandbox.get("docker", {}).get("image"):2515 terminal_cfg["docker_image"] = sandbox["docker"]["image"]2516 hermes_cfg["terminal"] = terminal_cfg2517 changes = True2518 2519 if changes:2520 hermes_cfg["agent"] = agent_cfg2521 if self.execute:2522 self.maybe_backup(hermes_cfg_path)2523 dump_yaml_file(hermes_cfg_path, hermes_cfg)2524 self.record("agent-config", "openclaw.json agents.defaults", "config.yaml agent/compression/terminal",2525 "migrated", "Agent defaults mapped to Hermes config")2526 2527 # Archive multi-agent list2528 if agent_list:2529 if self.archive_dir and self.execute:2530 self.archive_dir.mkdir(parents=True, exist_ok=True)2531 dest = self.archive_dir / "agents-list.json"2532 dest.write_text(json.dumps(agent_list, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")2533 self.record("agent-config", "openclaw.json agents.list", "archive/agents-list.json",2534 "archived", f"Multi-agent setup ({len(agent_list)} agents) archived for manual recreation")2535 2536 # Archive bindings2537 bindings = config.get("bindings") or []2538 if bindings:2539 if self.archive_dir and self.execute:2540 self.archive_dir.mkdir(parents=True, exist_ok=True)2541 dest = self.archive_dir / "bindings.json"2542 dest.write_text(json.dumps(bindings, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")2543 self.record("agent-config", "openclaw.json bindings", "archive/bindings.json",2544 "archived", f"Agent routing bindings ({len(bindings)} rules) archived")2545 2546 # ── Gateway config ────────────────────────────────────────2547 def migrate_gateway_config(self, config: Optional[Dict[str, Any]] = None) -> None:2548 config = config or self.load_openclaw_config()2549 gateway = config.get("gateway") or {}2550 if not gateway:2551 self.record("gateway-config", None, None, "skipped", "No gateway configuration found")2552 return2553 2554 # Archive the full gateway config (complex, many settings)2555 if self.archive_dir and self.execute:2556 self.archive_dir.mkdir(parents=True, exist_ok=True)2557 dest = self.archive_dir / "gateway-config.json"2558 dest.write_text(json.dumps(gateway, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")2559 self.record("gateway-config", "openclaw.json gateway.*", "archive/gateway-config.json",2560 "archived", "Gateway config archived. Use 'hermes gateway' to configure.")2561 2562 # Extract gateway auth token to .env if present2563 auth = gateway.get("auth") or {}2564 if auth.get("token") and self.migrate_secrets:2565 self._set_env_var("HERMES_GATEWAY_TOKEN", auth["token"], "gateway.auth.token")2566 2567 # ── Session config ────────────────────────────────────────2568 def migrate_session_config(self, config: Optional[Dict[str, Any]] = None) -> None:2569 config = config or self.load_openclaw_config()2570 session = config.get("session") or {}2571 if not session:2572 self.record("session-config", None, None, "skipped", "No session configuration found")2573 return2574 2575 if session.get("reset") or session.get("resetTriggers") or session.get("reset_triggers"):2576 self.record("session-config", "session reset timers", None, "skipped",2577 "Hermes conversations do not reset on idle or daily timers")2578 2579 # Archive full session config (identity links, thread bindings, etc.)2580 complex_keys = {"identityLinks", "threadBindings", "maintenance", "scope", "sendPolicy"}2581 complex_session = {k: v for k, v in session.items() if k in complex_keys and v}2582 if complex_session and self.archive_dir:2583 if self.execute:2584 self.archive_dir.mkdir(parents=True, exist_ok=True)2585 dest = self.archive_dir / "session-config.json"2586 dest.write_text(json.dumps(complex_session, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")2587 self.record("session-config", "openclaw.json session (advanced)",2588 "archive/session-config.json", "archived",2589 "Advanced session settings archived (identity links, thread bindings, etc.)")2590 2591 # ── Full model providers ──────────────────────────────────2592 def migrate_full_providers(self, config: Optional[Dict[str, Any]] = None) -> None:2593 config = config or self.load_openclaw_config()2594 models = config.get("models") or {}2595 providers = models.get("providers") or {}2596 if not providers:2597 self.record("full-providers", None, None, "skipped", "No model providers found")2598 return2599 2600 hermes_cfg_path = self.target_root / "config.yaml"2601 hermes_cfg = load_yaml_file(hermes_cfg_path)2602 custom_providers = hermes_cfg.get("custom_providers") or []2603 added = 02604 2605 # Well-known providers: just extract API keys2606 WELL_KNOWN = {"openrouter", "openai", "anthropic", "deepseek", "google", "groq"}2607 2608 for prov_name, prov_cfg in providers.items():2609 if not isinstance(prov_cfg, dict):2610 continue2611 2612 # Extract API key to .env2613 api_key = prov_cfg.get("apiKey") or prov_cfg.get("api_key")2614 if api_key and self.migrate_secrets:2615 env_key = f"{prov_name.upper().replace('-', '_')}_API_KEY"2616 self._set_env_var(env_key, api_key, f"models.providers.{prov_name}.apiKey")2617 2618 # For non-well-known providers, create custom_providers entry2619 if prov_name.lower() not in WELL_KNOWN and prov_cfg.get("baseUrl"):2620 # Check if already exists2621 existing_names = {p.get("name", "").lower() for p in custom_providers}2622 if prov_name.lower() in existing_names and not self.overwrite:2623 self.record("full-providers", f"models.providers.{prov_name}",2624 "config.yaml custom_providers", "conflict",2625 f"Provider '{prov_name}' already exists")2626 continue2627 2628 api_type = prov_cfg.get("apiType") or prov_cfg.get("api") or prov_cfg.get("type") or "openai"2629 api_mode_map = {2630 "openai": "chat_completions",2631 "openai-completions": "chat_completions",2632 "openai-responses": "chat_completions",2633 "anthropic": "anthropic_messages",2634 "anthropic-messages": "anthropic_messages",2635 "google-generative-ai": "chat_completions",2636 "cohere": "chat_completions",2637 }2638 entry = {2639 "name": prov_name,2640 "base_url": prov_cfg["baseUrl"],2641 "api_key": "", # referenced from .env2642 "api_mode": api_mode_map.get(api_type, "chat_completions"),2643 }2644 custom_providers.append(entry)2645 added += 12646 self.record("full-providers", f"models.providers.{prov_name}",2647 f"config.yaml custom_providers[{prov_name}]", "migrated")2648 2649 if added > 0 and self.execute:2650 self.maybe_backup(hermes_cfg_path)2651 hermes_cfg["custom_providers"] = custom_providers2652 dump_yaml_file(hermes_cfg_path, hermes_cfg)2653 2654 # Archive model aliases/catalog2655 agent_defaults = (config.get("agents") or {}).get("defaults") or {}2656 model_aliases = agent_defaults.get("models") or {}2657 if model_aliases:2658 if self.archive_dir and self.execute:2659 self.archive_dir.mkdir(parents=True, exist_ok=True)2660 dest = self.archive_dir / "model-aliases.json"2661 dest.write_text(json.dumps(model_aliases, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")2662 self.record("full-providers", "agents.defaults.models", "archive/model-aliases.json",2663 "archived", f"Model aliases/catalog ({len(model_aliases)} entries) archived")2664 2665 # ── Deep channel config ───────────────────────────────────2666 def migrate_deep_channels(self, config: Optional[Dict[str, Any]] = None) -> None:2667 config = config or self.load_openclaw_config()2668 channels = config.get("channels") or {}2669 if not channels:2670 self.record("deep-channels", None, None, "skipped", "No channel configuration found")2671 return2672 2673 # Extended channel token/allowlist mapping2674 CHANNEL_ENV_MAP = {2675 "matrix": {"token": "MATRIX...OKEN", "tokenField": "accessToken", "allowFrom": "MATRIX_ALLOWED_USERS",2676 "extras": {"homeserverUrl": "MATRIX_HOMESERVER_URL", "userId": "MATRIX_USER_ID"}},2677 "mattermost": {"token": "MATTERMOST_BOT_TOKEN", "allowFrom": "MATTERMOST_ALLOWED_USERS",2678 "extras": {"url": "MATTERMOST_URL", "teamId": "MATTERMOST_TEAM_ID"}},2679 "irc": {"extras": {"server": "IRC_SERVER", "nick": "IRC_NICK", "channels": "IRC_CHANNELS"}},2680 "googlechat": {"extras": {"serviceAccountKeyPath": "GOOGLE_CHAT_SA_KEY_PATH"}},2681 "imessage": {},2682 "bluebubbles": {"extras": {"server": "BLUEBUBBLES_SERVER", "password": "BLUEBUBBLES_PASSWORD"}},2683 "msteams": {"token": "MSTEAMS_BOT_TOKEN", "allowFrom": "MSTEAMS_ALLOWED_USERS"},2684 "nostr": {"extras": {"nsec": "NOSTR_NSEC", "relays": "NOSTR_RELAYS"}},2685 "twitch": {"token": "TWITCH_BOT_TOKEN", "extras": {"channels": "TWITCH_CHANNELS"}},2686 }2687 2688 for ch_name, ch_mapping in CHANNEL_ENV_MAP.items():2689 ch_cfg = channels.get(ch_name) or {}2690 if not ch_cfg:2691 continue2692 2693 # Extract tokens (check flat path, then accounts.default)2694 token_field = ch_mapping.get("tokenField", "botToken")2695 bot_token = self._get_channel_field(ch_cfg, token_field)2696 if ch_mapping.get("token") and bot_token and self.migrate_secrets:2697 self._set_env_var(ch_mapping["token"], str(bot_token),2698 f"channels.{ch_name}.{token_field}")2699 allow_val = self._get_channel_field(ch_cfg, "allowFrom")2700 if ch_mapping.get("allowFrom") and allow_val:2701 if isinstance(allow_val, list):2702 allow_val = ",".join(str(x) for x in allow_val)2703 self._set_env_var(ch_mapping["allowFrom"], str(allow_val),2704 f"channels.{ch_name}.allowFrom")2705 # Extra fields2706 for oc_key, env_key in (ch_mapping.get("extras") or {}).items():2707 val = self._get_channel_field(ch_cfg, oc_key)2708 if val:2709 if isinstance(val, list):2710 val = ",".join(str(x) for x in val)2711 is_secret = "password" in oc_key.lower() or "token" in oc_key.lower() or "nsec" in oc_key.lower()2712 if is_secret and not self.migrate_secrets:2713 continue2714 self._set_env_var(env_key, str(val), f"channels.{ch_name}.{oc_key}")2715 2716 # Map Discord-specific settings to Hermes config2717 discord_cfg = channels.get("discord") or {}2718 if discord_cfg:2719 hermes_cfg_path = self.target_root / "config.yaml"2720 hermes_cfg = load_yaml_file(hermes_cfg_path)2721 discord_hermes = hermes_cfg.get("discord") or {}2722 changed = False2723 if "requireMention" in discord_cfg:2724 discord_hermes["require_mention"] = discord_cfg["requireMention"]2725 changed = True2726 if discord_cfg.get("autoThread") is not None:2727 discord_hermes["auto_thread"] = discord_cfg["autoThread"]2728 changed = True2729 if changed and self.execute:2730 hermes_cfg["discord"] = discord_hermes2731 dump_yaml_file(hermes_cfg_path, hermes_cfg)2732 2733 # Archive complex channel configs (group settings, thread bindings, etc.)2734 complex_archive = {}2735 for ch_name, ch_cfg in channels.items():2736 if not isinstance(ch_cfg, dict):2737 continue2738 complex_keys = {k: v for k, v in ch_cfg.items()2739 if k not in {"botToken", "appToken", "allowFrom", "enabled"}2740 and v and k not in {"requireMention", "autoThread"}}2741 if complex_keys:2742 complex_archive[ch_name] = complex_keys2743 2744 if complex_archive and self.archive_dir:2745 if self.execute:2746 self.archive_dir.mkdir(parents=True, exist_ok=True)2747 dest = self.archive_dir / "channels-deep-config.json"2748 dest.write_text(json.dumps(complex_archive, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")2749 self.record("deep-channels", "openclaw.json channels (advanced settings)",2750 "archive/channels-deep-config.json", "archived",2751 f"Deep channel config for {len(complex_archive)} channels archived")2752 2753 # ── Browser config ────────────────────────────────────────2754 def migrate_browser_config(self, config: Optional[Dict[str, Any]] = None) -> None:2755 config = config or self.load_openclaw_config()2756 browser = config.get("browser") or {}2757 if not browser:2758 self.record("browser-config", None, None, "skipped", "No browser configuration found")2759 return2760 2761 hermes_cfg_path = self.target_root / "config.yaml"2762 hermes_cfg = load_yaml_file(hermes_cfg_path)2763 browser_hermes = hermes_cfg.get("browser") or {}2764 changed = False2765 2766 # Map fields that have Hermes equivalents2767 if browser.get("cdpUrl"):2768 browser_hermes["cdp_url"] = browser["cdpUrl"]2769 changed = True2770 if browser.get("headless") is not None:2771 browser_hermes["headless"] = browser["headless"]2772 changed = True2773 2774 if changed:2775 hermes_cfg["browser"] = browser_hermes2776 if self.execute:2777 self.maybe_backup(hermes_cfg_path)2778 dump_yaml_file(hermes_cfg_path, hermes_cfg)2779 self.record("browser-config", "openclaw.json browser.*", "config.yaml browser",2780 "migrated")2781 2782 # Archive remaining browser settings2783 advanced = {k: v for k, v in browser.items()2784 if k not in {"cdpUrl", "headless"} and v}2785 if advanced and self.archive_dir:2786 if self.execute:2787 self.archive_dir.mkdir(parents=True, exist_ok=True)2788 dest = self.archive_dir / "browser-config.json"2789 dest.write_text(json.dumps(advanced, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")2790 self.record("browser-config", "openclaw.json browser (advanced)",2791 "archive/browser-config.json", "archived")2792 2793 # ── Tools config ──────────────────────────────────────────2794 def migrate_tools_config(self, config: Optional[Dict[str, Any]] = None) -> None:2795 config = config or self.load_openclaw_config()2796 tools = config.get("tools") or {}2797 if not tools:2798 self.record("tools-config", None, None, "skipped", "No tools configuration found")2799 return2800 2801 hermes_cfg_path = self.target_root / "config.yaml"2802 hermes_cfg = load_yaml_file(hermes_cfg_path)2803 changed = False2804 2805 # Map exec timeout -> terminal timeout (field is timeoutSec in OpenClaw)2806 exec_cfg = tools.get("exec") or {}2807 timeout_val = exec_cfg.get("timeoutSec") or exec_cfg.get("timeout")2808 if timeout_val:2809 terminal_cfg = hermes_cfg.get("terminal") or {}2810 terminal_cfg["timeout"] = timeout_val2811 hermes_cfg["terminal"] = terminal_cfg2812 changed = True2813 2814 # Map web search API key (path: tools.web.search.brave.apiKey in OpenClaw)2815 web_cfg = tools.get("web") or tools.get("webSearch") or {}2816 search_cfg = web_cfg.get("search") or web_cfg if not web_cfg.get("search") else web_cfg["search"]2817 brave_cfg = search_cfg.get("brave") or {}2818 brave_key = brave_cfg.get("apiKey") or search_cfg.get("braveApiKey") or web_cfg.get("braveApiKey")2819 if brave_key and isinstance(brave_key, str) and self.migrate_secrets:2820 self._set_env_var("BRAVE_API_KEY", brave_key, "tools.web.search.brave.apiKey")2821 2822 if changed and self.execute:2823 self.maybe_backup(hermes_cfg_path)2824 dump_yaml_file(hermes_cfg_path, hermes_cfg)2825 self.record("tools-config", "openclaw.json tools.*", "config.yaml terminal",2826 "migrated")2827 2828 # Archive full tools config2829 if self.archive_dir:2830 if self.execute:2831 self.archive_dir.mkdir(parents=True, exist_ok=True)2832 dest = self.archive_dir / "tools-config.json"2833 dest.write_text(json.dumps(tools, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")2834 self.record("tools-config", "openclaw.json tools (full)", "archive/tools-config.json",2835 "archived", "Full tools config archived for reference")2836 2837 # ── Approvals config ──────────────────────────────────────2838 def migrate_approvals_config(self, config: Optional[Dict[str, Any]] = None) -> None:2839 config = config or self.load_openclaw_config()2840 approvals = config.get("approvals") or {}2841 if not approvals:2842 self.record("approvals-config", None, None, "skipped", "No approvals configuration found")2843 return2844 2845 hermes_cfg_path = self.target_root / "config.yaml"2846 hermes_cfg = load_yaml_file(hermes_cfg_path)2847 2848 # Map approval mode (nested under approvals.exec.mode in OpenClaw)2849 exec_approvals = approvals.get("exec") or {}2850 mode = (exec_approvals.get("mode") if isinstance(exec_approvals, dict) else None) or approvals.get("mode") or approvals.get("defaultMode")2851 if mode:2852 mode_map = {"auto": "off", "always": "manual", "smart": "smart", "manual": "manual"}2853 hermes_mode = mode_map.get(mode, "manual")2854 hermes_cfg.setdefault("approvals", {})["mode"] = hermes_mode2855 if self.execute:2856 self.maybe_backup(hermes_cfg_path)2857 dump_yaml_file(hermes_cfg_path, hermes_cfg)2858 self.record("approvals-config", "openclaw.json approvals.mode",2859 "config.yaml approvals.mode", "migrated", f"Mapped '{mode}' -> '{hermes_mode}'")2860 2861 # Archive full approvals config2862 if len(approvals) > 1 and self.archive_dir:2863 if self.execute:2864 self.archive_dir.mkdir(parents=True, exist_ok=True)2865 dest = self.archive_dir / "approvals-config.json"2866 dest.write_text(json.dumps(approvals, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")2867 self.record("approvals-config", "openclaw.json approvals (rules)",2868 "archive/approvals-config.json", "archived")2869 2870 # ── Memory backend ────────────────────────────────────────2871 def migrate_memory_backend(self, config: Optional[Dict[str, Any]] = None) -> None:2872 config = config or self.load_openclaw_config()2873 memory = config.get("memory") or {}2874 if not memory:2875 self.record("memory-backend", None, None, "skipped", "No memory backend configuration found")2876 return2877 2878 if self.archive_dir and self.execute:2879 self.archive_dir.mkdir(parents=True, exist_ok=True)2880 dest = self.archive_dir / "memory-backend-config.json"2881 dest.write_text(json.dumps(memory, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")2882 self.record("memory-backend", "openclaw.json memory.*", "archive/memory-backend-config.json",2883 "archived", "Memory backend config (QMD, vector search, citations) archived for manual review")2884 2885 # ── Skills config ─────────────────────────────────────────2886 def migrate_skills_config(self, config: Optional[Dict[str, Any]] = None) -> None:2887 config = config or self.load_openclaw_config()2888 skills = config.get("skills") or {}2889 entries = skills.get("entries") or {}2890 if not entries and not skills:2891 self.record("skills-config", None, None, "skipped", "No skills registry configuration found")2892 return2893 2894 if self.archive_dir and self.execute:2895 self.archive_dir.mkdir(parents=True, exist_ok=True)2896 dest = self.archive_dir / "skills-registry-config.json"2897 dest.write_text(json.dumps(skills, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")2898 self.record("skills-config", "openclaw.json skills.*", "archive/skills-registry-config.json",2899 "archived", f"Skills registry config ({len(entries)} entries) archived")2900 2901 # ── UI / Identity ─────────────────────────────────────────2902 def migrate_ui_identity(self, config: Optional[Dict[str, Any]] = None) -> None:2903 config = config or self.load_openclaw_config()2904 ui = config.get("ui") or {}2905 if not ui:2906 self.record("ui-identity", None, None, "skipped", "No UI/identity configuration found")2907 return2908 2909 if self.archive_dir and self.execute:2910 self.archive_dir.mkdir(parents=True, exist_ok=True)2911 dest = self.archive_dir / "ui-identity-config.json"2912 dest.write_text(json.dumps(ui, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")2913 self.record("ui-identity", "openclaw.json ui.*", "archive/ui-identity-config.json",2914 "archived", "UI theme and identity settings archived")2915 2916 # ── Logging / Diagnostics ─────────────────────────────────2917 def migrate_logging_config(self, config: Optional[Dict[str, Any]] = None) -> None:2918 config = config or self.load_openclaw_config()2919 logging_cfg = config.get("logging") or {}2920 diagnostics = config.get("diagnostics") or {}2921 combined = {}2922 if logging_cfg:2923 combined["logging"] = logging_cfg2924 if diagnostics:2925 combined["diagnostics"] = diagnostics2926 if not combined:2927 self.record("logging-config", None, None, "skipped", "No logging/diagnostics configuration found")2928 return2929 2930 if self.archive_dir and self.execute:2931 self.archive_dir.mkdir(parents=True, exist_ok=True)2932 dest = self.archive_dir / "logging-diagnostics-config.json"2933 dest.write_text(json.dumps(combined, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")2934 self.record("logging-config", "openclaw.json logging/diagnostics",2935 "archive/logging-diagnostics-config.json", "archived")2936 2937 # ── Helper: set env var ───────────────────────────────────2938 def _set_env_var(self, key: str, value: str, source_label: str) -> None:2939 env_path = self.target_root / ".env"2940 if self.execute:2941 env_data = parse_env_file(env_path)2942 if key in env_data and not self.overwrite:2943 self.record("env-var", source_label, f".env {key}", "conflict",2944 f"Env var {key} already set")2945 return2946 env_data[key] = value2947 save_env_file(env_path, env_data)2948 self.record("env-var", source_label, f".env {key}", "migrated")2949 2950 # ── Generate migration notes ──────────────────────────────2951 def generate_migration_notes(self) -> None:2952 if not self.output_dir:2953 return2954 notes = [2955 "# OpenClaw -> Hermes Migration Notes",2956 "",2957 "This document lists items that require manual attention after migration.",2958 "",2959 "## PM2 / External Processes",2960 "",2961 "Your PM2 processes (Discord bots, Telegram bots, etc.) are NOT affected",2962 "by this migration. They run independently and will continue working.",2963 "No action needed for PM2-managed processes.",2964 "",2965 ]2966 2967 archived = [i for i in self.items if i.status == "archived"]2968 if archived:2969 notes.extend([2970 "## Archived Items (Manual Review Needed)",2971 "",2972 "These OpenClaw configurations were archived because they don't have a",2973 "direct 1:1 mapping in Hermes. Review each file and recreate manually:",2974 "",2975 ])2976 for item in archived:2977 notes.append(f"- **{item.kind}**: `{item.destination}` -- {item.reason}")2978 notes.append("")2979 2980 conflicts = [i for i in self.items if i.status == "conflict"]2981 if conflicts:2982 notes.extend([2983 "## Conflicts (Existing Hermes Config Not Overwritten)",2984 "",2985 "These items already existed in your Hermes config. Re-run with",2986 "`--overwrite` to force, or merge manually:",2987 "",2988 ])2989 for item in conflicts:2990 notes.append(f"- **{item.kind}**: {item.reason}")2991 notes.append("")2992 2993 has_cron_config_archive = any(2994 i.kind == "cron-jobs" and i.status == "archived" and i.destination and i.destination.endswith("cron-config.json")2995 for i in self.items2996 )2997 has_cron_store_archive = any(2998 i.kind == "cron-jobs" and i.status == "archived" and i.destination and i.destination.endswith("cron-store")2999 for i in self.items3000 )3001 3002 notes.extend([3003 "## IMPORTANT: Archive the OpenClaw Directory",3004 "",3005 "After migration, your OpenClaw directory still exists on disk with workspace",3006 "state files (todo.json, sessions, logs). If the Hermes agent discovers these",3007 "directories, it may read/write to them instead of the Hermes state, causing",3008 "confusion (e.g., cron jobs reading a different todo list than interactive sessions).",3009 "",3010 "**Strongly recommended:** Run `hermes claw cleanup` to rename the OpenClaw",3011 "directory to `.openclaw.pre-migration`. This prevents the agent from finding it.",3012 "The directory is renamed, not deleted — you can undo this at any time.",3013 "",3014 "If you skip this step and notice the agent getting confused about workspaces",3015 "or todo lists, run `hermes claw cleanup` to fix it.",3016 "",3017 "## Hermes-Specific Setup",3018 "",3019 "After migration, you may want to:",3020 "- Run `hermes claw cleanup` to archive the OpenClaw directory (prevents state confusion)",3021 "- Run `hermes setup` to configure any remaining settings",3022 "- Run `hermes mcp list` to verify MCP servers were imported correctly",3023 ])3024 3025 if has_cron_config_archive:3026 notes.append("- Run `hermes cron` to recreate scheduled tasks (see archive/cron-config.json)")3027 elif has_cron_store_archive:3028 notes.append("- Run `hermes cron` to recreate scheduled tasks (see archived cron-store)")3029 3030 # Check if skills were imported3031 has_skills = any(i.kind == "skills" and i.status == "migrated" for i in self.items)3032 if has_skills:3033 notes.extend([3034 "",3035 "## Imported Skills",3036 "",3037 "Imported skills require a new session to take effect. After migration,",3038 "restart your agent or start a new chat session, then run `/skills`",3039 "to verify they loaded correctly.",3040 "",3041 ])3042 3043 # Check if WhatsApp was detected3044 has_whatsapp = any(i.kind == "whatsapp-settings" and i.status == "migrated" for i in self.items)3045 if has_whatsapp:3046 notes.extend([3047 "",3048 "## WhatsApp Requires Re-Pairing",3049 "",3050 "WhatsApp uses QR-code pairing, not token-based auth. Your allowlist",3051 "was migrated, but you must re-pair the device by running:",3052 "",3053 " hermes whatsapp",3054 "",3055 ])3056 3057 notes.extend([3058 "- Run `hermes gateway install` if you need the gateway service",3059 "- Review `~/.hermes/config.yaml` for any adjustments",3060 "",3061 ])3062 3063 if self.execute:3064 self.output_dir.mkdir(parents=True, exist_ok=True)3065 (self.output_dir / "MIGRATION_NOTES.md").write_text(3066 "\n".join(notes) + "\n", encoding="utf-8"3067 )3068 3069 3070def parse_args() -> argparse.Namespace:3071 parser = argparse.ArgumentParser(description="Migrate OpenClaw user state into Hermes Agent.")3072 parser.add_argument("--source", default=str(Path.home() / ".openclaw"), help="OpenClaw home directory")3073 parser.add_argument("--target", default=os.environ.get("HERMES_HOME") or str(Path.home() / ".hermes"), help="Hermes home directory")3074 parser.add_argument(3075 "--workspace-target",3076 help="Optional workspace root where the workspace instructions file should be copied",3077 )3078 parser.add_argument("--execute", action="store_true", help="Apply changes instead of reporting a dry run")3079 parser.add_argument("--overwrite", action="store_true", help="Overwrite existing Hermes targets after backing them up")3080 parser.add_argument(3081 "--migrate-secrets",3082 action="store_true",3083 help="Import a narrow allowlist of Hermes-compatible secrets into the target env file",3084 )3085 parser.add_argument(3086 "--skill-conflict",3087 choices=sorted(SKILL_CONFLICT_MODES),3088 default="skip",3089 help="How to handle imported skill directory conflicts: skip, overwrite, or rename the imported copy.",3090 )3091 parser.add_argument(3092 "--preset",3093 choices=sorted(MIGRATION_PRESETS),3094 help="Apply a named migration preset. 'user-data' excludes allowlisted secrets; 'full' includes all compatible groups.",3095 )3096 parser.add_argument(3097 "--include",3098 action="append",3099 default=[],3100 help="Comma-separated migration option ids to include (default: all). "3101 f"Valid ids: {', '.join(sorted(MIGRATION_OPTION_METADATA))}",3102 )3103 parser.add_argument(3104 "--exclude",3105 action="append",3106 default=[],3107 help="Comma-separated migration option ids to skip. "3108 f"Valid ids: {', '.join(sorted(MIGRATION_OPTION_METADATA))}",3109 )3110 parser.add_argument("--output-dir", help="Where to write report, backups, and archived docs")3111 parser.add_argument(3112 "--json",3113 action="store_true",3114 dest="json_output",3115 help="Print the migration report as JSON on stdout (redacted). "3116 "Combine with no --execute for a safe plan-only machine-readable preview.",3117 )3118 return parser.parse_args()3119 3120 3121def main() -> int:3122 args = parse_args()3123 try:3124 selected_options = resolve_selected_options(args.include, args.exclude, preset=args.preset)3125 except ValueError as exc:3126 print(json.dumps({"error": str(exc)}, indent=2, ensure_ascii=False))3127 return 23128 migrator = Migrator(3129 source_root=Path(os.path.expanduser(args.source)).resolve(),3130 target_root=Path(os.path.expanduser(args.target)).resolve(),3131 execute=bool(args.execute),3132 workspace_target=Path(os.path.expanduser(args.workspace_target)).resolve() if args.workspace_target else None,3133 overwrite=bool(args.overwrite),3134 migrate_secrets=bool(args.migrate_secrets),3135 output_dir=Path(os.path.expanduser(args.output_dir)).resolve() if args.output_dir else None,3136 selected_options=selected_options,3137 preset_name=args.preset or "",3138 skill_conflict_mode=args.skill_conflict,3139 )3140 report = migrator.migrate()3141 3142 # ── Machine-readable JSON mode ────────────────────────────3143 # When --json is set, print the redacted report to stdout and skip the3144 # human-readable terminal recap. Useful for CI and scripted wrappers.3145 if getattr(args, "json_output", False):3146 print(json.dumps(redact_migration_value(report), indent=2, ensure_ascii=False))3147 return 03148 3149 # ── Human-readable terminal recap ─────────────────────────3150 s = report["summary"]3151 items = report["items"]3152 mode_label = "DRY RUN" if not args.execute else "EXECUTED"3153 total = sum(s.values())3154 3155 print()3156 print(" ╔══════════════════════════════════════════════════════╗")3157 print(f" ║ OpenClaw -> Hermes Migration [{mode_label:>8s}] ║")3158 print(" ╠══════════════════════════════════════════════════════╣")3159 print(f" ║ Source: {str(report['source_root'])[:42]:<42s} ║")3160 print(f" ║ Target: {str(report['target_root'])[:42]:<42s} ║")3161 print(" ╠══════════════════════════════════════════════════════╣")3162 print(f" ║ ✔ Migrated: {s.get('migrated', 0):>3d} ◆ Archived: {s.get('archived', 0):>3d} ║")3163 print(f" ║ ⊘ Skipped: {s.get('skipped', 0):>3d} ⚠ Conflicts: {s.get('conflict', 0):>3d} ║")3164 print(f" ║ ✖ Errors: {s.get('error', 0):>3d} Total: {total:>3d} ║")3165 print(" ╚══════════════════════════════════════════════════════╝")3166 3167 # Show what was migrated3168 migrated = [i for i in items if i["status"] == "migrated"]3169 if migrated:3170 print()3171 print(" Migrated:")3172 seen_kinds = set()3173 for item in migrated:3174 label = item["kind"]3175 if label in seen_kinds:3176 continue3177 seen_kinds.add(label)3178 dest = item.get("destination") or ""3179 if dest.startswith(str(report["target_root"])):3180 dest = "~/.hermes/" + dest[len(str(report["target_root"])) + 1:]3181 meta = MIGRATION_OPTION_METADATA.get(label, {})3182 display = meta.get("label", label)3183 print(f" ✔ {display:<35s} -> {dest}")3184 3185 # Show what was archived3186 archived = [i for i in items if i["status"] == "archived"]3187 if archived:3188 print()3189 print(" Archived (manual review needed):")3190 seen_kinds = set()3191 for item in archived:3192 label = item["kind"]3193 if label in seen_kinds:3194 continue3195 seen_kinds.add(label)3196 reason = item.get("reason", "")3197 meta = MIGRATION_OPTION_METADATA.get(label, {})3198 display = meta.get("label", label)3199 short_reason = reason[:50] + "..." if len(reason) > 50 else reason3200 print(f" ◆ {display:<35s} {short_reason}")3201 3202 # Show conflicts3203 conflicts = [i for i in items if i["status"] == "conflict"]3204 if conflicts:3205 print()3206 print(" Conflicts (use --overwrite to force):")3207 for item in conflicts:3208 print(f" ⚠ {item['kind']}: {item.get('reason', '')}")3209 3210 # Show errors3211 errors = [i for i in items if i["status"] == "error"]3212 if errors:3213 print()3214 print(" Errors:")3215 for item in errors:3216 print(f" ✖ {item['kind']}: {item.get('reason', '')}")3217 3218 # PM2 reassurance3219 print()3220 print(" ℹ PM2 processes (Discord/Telegram bots) are NOT affected.")3221 3222 # Next steps3223 if args.execute:3224 print()3225 print(" Next steps:")3226 print(" 1. Review ~/.hermes/config.yaml")3227 print(" 2. Run: hermes mcp list")3228 if any(i["kind"] == "cron-jobs" and i["status"] == "archived" for i in items):3229 print(" 3. Recreate cron jobs: hermes cron")3230 if report.get("output_dir"):3231 print(f" → Full report: {report['output_dir']}/MIGRATION_NOTES.md")3232 elif not args.execute:3233 print()3234 print(" This was a dry run. Add --execute to apply changes.")3235 3236 print()3237 3238 # Also dump JSON for programmatic use3239 if os.environ.get("MIGRATION_JSON_OUTPUT"):3240 print(json.dumps(report, indent=2, ensure_ascii=False))3241 3242 return 0 if s.get("error", 0) == 0 else 13243 3244 3245if __name__ == "__main__":3246 raise SystemExit(main())3247