diff --git a/agent/onboarding.py b/agent/onboarding.py index 1596f4ff92..cf66bad108 100644 --- a/agent/onboarding.py +++ b/agent/onboarding.py @@ -25,6 +25,7 @@ logger = logging.getLogger(__name__) BUSY_INPUT_FLAG = "busy_input_prompt" TOOL_PROGRESS_FLAG = "tool_progress_prompt" +OPENCLAW_RESIDUE_FLAG = "openclaw_residue_cleanup" # ------------------------------------------------------------------------- @@ -94,6 +95,35 @@ def tool_progress_hint_cli() -> str: ) +def openclaw_residue_hint_cli() -> str: + """Banner shown the first time Hermes starts and finds ``~/.openclaw/``. + + OpenClaw-era config, memory, and skill paths in ``~/.openclaw/`` will + otherwise attract the agent (memory entries like ``~/.openclaw/config.yaml`` + get carried forward and the agent dutifully reads them). ``hermes claw + cleanup`` renames the directory so the agent stops finding it. + """ + return ( + "Heads up — an OpenClaw workspace was detected at ~/.openclaw/.\n" + "After migrating, the agent can still get confused and read that " + "directory's config/memory instead of Hermes's.\n" + "Run `hermes claw cleanup` to archive it (rename → .openclaw.pre-migration). " + "This tip only shows once; rerun it any time with `hermes claw cleanup`." + ) + + +def detect_openclaw_residue(home: Optional[Path] = None) -> bool: + """Return True if an OpenClaw workspace directory is present in ``$HOME``. + + Pure filesystem check — no side effects. ``home`` override exists for tests. + """ + base = home or Path.home() + try: + return (base / ".openclaw").is_dir() + except OSError: + return False + + # ------------------------------------------------------------------------- # State read / write # ------------------------------------------------------------------------- @@ -149,10 +179,13 @@ def mark_seen(config_path: Path, flag: str) -> bool: __all__ = [ "BUSY_INPUT_FLAG", "TOOL_PROGRESS_FLAG", + "OPENCLAW_RESIDUE_FLAG", "busy_input_hint_gateway", "busy_input_hint_cli", "tool_progress_hint_gateway", "tool_progress_hint_cli", + "openclaw_residue_hint_cli", + "detect_openclaw_residue", "is_seen", "mark_seen", ] diff --git a/cli.py b/cli.py index dec4ed980b..4f8db69a6c 100644 --- a/cli.py +++ b/cli.py @@ -9073,6 +9073,30 @@ class HermesCLI: _welcome_text = "Welcome to Hermes Agent! Type your message or /help for commands." _welcome_color = "#FFF8DC" self._console_print(f"[{_welcome_color}]{_welcome_text}[/]") + # First-time OpenClaw-residue banner — fires once if ~/.openclaw/ exists + # after an OpenClaw→Hermes migration (especially migrations done by + # OpenClaw's own tool, which doesn't archive the source directory). + try: + from agent.onboarding import ( + OPENCLAW_RESIDUE_FLAG, + detect_openclaw_residue, + is_seen, + mark_seen, + openclaw_residue_hint_cli, + ) + if not is_seen(self.config, OPENCLAW_RESIDUE_FLAG) and detect_openclaw_residue(): + try: + _resid_color = _welcome_skin.get_color("banner_dim", "#B8860B") + except Exception: + _resid_color = "#B8860B" + self._console_print(f"[{_resid_color}]{openclaw_residue_hint_cli()}[/]") + try: + from hermes_cli.config import get_config_path as _get_cfg_path_resid + mark_seen(_get_cfg_path_resid(), OPENCLAW_RESIDUE_FLAG) + except Exception: + pass # best-effort — banner will fire again next session + except Exception: + pass # banner is non-critical — never break startup # Show a random tip to help users discover features try: from hermes_cli.tips import get_random_tip diff --git a/optional-skills/migration/openclaw-migration/scripts/openclaw_to_hermes.py b/optional-skills/migration/openclaw-migration/scripts/openclaw_to_hermes.py index beb32aba2c..adfbd9f59b 100644 --- a/optional-skills/migration/openclaw-migration/scripts/openclaw_to_hermes.py +++ b/optional-skills/migration/openclaw-migration/scripts/openclaw_to_hermes.py @@ -380,6 +380,10 @@ def backup_existing(path: Path, backup_root: Path) -> Optional[Path]: # Replace OpenClaw brand names with Hermes in migrated text so that # memory entries, user profiles, SOUL.md, and workspace instructions # read as self-referential to the new agent identity. +# +# Case-preserving: ``OpenClaw`` → ``Hermes`` (prose), but lowercase matches +# like ``openclaw`` → ``hermes`` (so filesystem paths like ``~/.openclaw`` +# become ``~/.hermes`` — the real Hermes home — not the broken ``~/.Hermes``). _REBRAND_PATTERNS: List[Tuple[re.Pattern, str]] = [ (re.compile(r'\bOpen[\s-]?Claw\b', re.IGNORECASE), 'Hermes'), (re.compile(r'\bClawdBot\b', re.IGNORECASE), 'Hermes'), @@ -387,10 +391,31 @@ _REBRAND_PATTERNS: List[Tuple[re.Pattern, str]] = [ ] +def _case_preserving_replacement(replacement: str): + """Return a re.sub replacement fn that lowercases the result when the + matched text was all-lowercase. + + Keeps ``OpenClaw`` → ``Hermes`` but maps ``openclaw`` → ``hermes`` so a + filesystem path like ``~/.openclaw/config.yaml`` rewrites to + ``~/.hermes/config.yaml`` (the real Hermes home) instead of the broken + ``~/.Hermes/config.yaml``. + """ + def _sub(match: "re.Match[str]") -> str: + matched = match.group(0) + if matched and matched.islower(): + return replacement.lower() + return replacement + return _sub + + def rebrand_text(text: str) -> str: - """Replace OpenClaw / ClawdBot / MoltBot brand names with Hermes.""" + """Replace OpenClaw / ClawdBot / MoltBot brand names with Hermes. + + Preserves case so filesystem-path matches (lowercase) don't become + capitalized directory names that don't exist. + """ for pattern, replacement in _REBRAND_PATTERNS: - text = pattern.sub(replacement, text) + text = pattern.sub(_case_preserving_replacement(replacement), text) return text diff --git a/tests/agent/test_onboarding.py b/tests/agent/test_onboarding.py index 4fe357f37d..c886979898 100644 --- a/tests/agent/test_onboarding.py +++ b/tests/agent/test_onboarding.py @@ -7,11 +7,14 @@ import pytest from agent.onboarding import ( BUSY_INPUT_FLAG, + OPENCLAW_RESIDUE_FLAG, TOOL_PROGRESS_FLAG, busy_input_hint_cli, busy_input_hint_gateway, + detect_openclaw_residue, is_seen, mark_seen, + openclaw_residue_hint_cli, tool_progress_hint_cli, tool_progress_hint_gateway, ) @@ -176,3 +179,50 @@ class TestRoundTrip: assert is_seen(loaded, BUSY_INPUT_FLAG) is True assert is_seen(loaded, TOOL_PROGRESS_FLAG) is True + + +# --------------------------------------------------------------------------- +# OpenClaw residue banner +# --------------------------------------------------------------------------- + + +class TestDetectOpenclawResidue: + def test_returns_true_when_openclaw_dir_present(self, tmp_path): + (tmp_path / ".openclaw").mkdir() + assert detect_openclaw_residue(home=tmp_path) is True + + def test_returns_false_when_absent(self, tmp_path): + assert detect_openclaw_residue(home=tmp_path) is False + + def test_returns_false_when_path_is_a_file(self, tmp_path): + # A stray file named ``.openclaw`` is NOT a workspace — skip the banner. + (tmp_path / ".openclaw").write_text("oops") + assert detect_openclaw_residue(home=tmp_path) is False + + def test_default_home_does_not_crash(self): + # Smoke: real $HOME lookup must not raise regardless of state. + assert isinstance(detect_openclaw_residue(), bool) + + +class TestOpenclawResidueHint: + def test_hint_mentions_cleanup_command(self): + msg = openclaw_residue_hint_cli() + assert "hermes claw cleanup" in msg + assert "~/.openclaw" in msg + + def test_hint_not_empty(self): + assert openclaw_residue_hint_cli().strip() + + +class TestOpenclawResidueSeenFlag: + def test_flag_independent_of_other_flags(self, tmp_path): + cfg_path = tmp_path / "config.yaml" + mark_seen(cfg_path, BUSY_INPUT_FLAG) + loaded = yaml.safe_load(cfg_path.read_text()) + assert is_seen(loaded, OPENCLAW_RESIDUE_FLAG) is False + + def test_flag_round_trips(self, tmp_path): + cfg_path = tmp_path / "config.yaml" + assert mark_seen(cfg_path, OPENCLAW_RESIDUE_FLAG) is True + loaded = yaml.safe_load(cfg_path.read_text()) + assert is_seen(loaded, OPENCLAW_RESIDUE_FLAG) is True diff --git a/tests/skills/test_openclaw_migration.py b/tests/skills/test_openclaw_migration.py index 671d764f0d..c880d64532 100644 --- a/tests/skills/test_openclaw_migration.py +++ b/tests/skills/test_openclaw_migration.py @@ -761,19 +761,24 @@ def test_skill_installs_cleanly_under_skills_guard(): def test_rebrand_text_replaces_openclaw_variants(): mod = load_module() + # Mixed-case / capitalized matches → capital-H ``Hermes``. assert mod.rebrand_text("OpenClaw prefers Python 3.11") == "Hermes prefers Python 3.11" assert mod.rebrand_text("I told Open Claw to use dark mode") == "I told Hermes to use dark mode" assert mod.rebrand_text("Open-Claw config is great") == "Hermes config is great" - assert mod.rebrand_text("openclaw should always respond concisely") == "Hermes should always respond concisely" assert mod.rebrand_text("OPENCLAW uses tools well") == "Hermes uses tools well" + # All-lowercase matches → lowercase ``hermes``; this preserves the + # real filesystem path ``~/.hermes`` (Hermes home) when rebranding + # memory entries that reference ``~/.openclaw`` or ``openclaw`` prose. + assert mod.rebrand_text("openclaw should always respond concisely") == "hermes should always respond concisely" def test_rebrand_text_replaces_legacy_bot_names(): mod = load_module() + # Same case-preservation rule as above. assert mod.rebrand_text("ClawdBot remembers my timezone") == "Hermes remembers my timezone" - assert mod.rebrand_text("clawdbot prefers tabs") == "Hermes prefers tabs" + assert mod.rebrand_text("clawdbot prefers tabs") == "hermes prefers tabs" assert mod.rebrand_text("MoltBot was configured for Spanish") == "Hermes was configured for Spanish" - assert mod.rebrand_text("moltbot uses Python") == "Hermes uses Python" + assert mod.rebrand_text("moltbot uses Python") == "hermes uses Python" def test_rebrand_text_preserves_unrelated_content(): @@ -788,6 +793,26 @@ def test_rebrand_text_handles_multiple_replacements(): assert mod.rebrand_text(text) == "Hermes said to ask Hermes about Hermes settings" +def test_rebrand_text_preserves_filesystem_path_casing(): + """Lowercase matches — especially ``.openclaw`` filesystem paths — must + rewrite to lowercase ``.hermes`` (the real Hermes home), not the broken + ``.Hermes``. + + Regression test for @versun's OpenClaw-residue feedback: after migration, + memory entries that referenced ``~/.openclaw/config.yaml`` were being + rewritten to ``~/.Hermes/config.yaml`` — a path that doesn't exist — + and the agent kept trying to read it. + """ + mod = load_module() + assert mod.rebrand_text("config is at ~/.openclaw/config.yaml") == \ + "config is at ~/.hermes/config.yaml" + assert mod.rebrand_text("use .openclaw directory") == "use .hermes directory" + assert mod.rebrand_text("Path.home() / '.openclaw'") == "Path.home() / '.hermes'" + # Sentence with both lowercase path and capitalized prose. + assert mod.rebrand_text("openclaw config path: ~/.openclaw/") == \ + "hermes config path: ~/.hermes/" + + def test_migrate_memory_rebrands_entries(tmp_path): mod = load_module() source_root = tmp_path / "openclaw"