mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-27 22:41:19 +08:00
fix(openclaw-migration): case-preserving brand rewrite + one-time ~/.openclaw residue banner (#16327)
Two related fixes for OpenClaw-residue problems after an OpenClaw→Hermes migration (especially migrations done via OpenClaw's own tool, which doesn't archive the source directory). 1. optional-skills/migration/openclaw-migration/scripts/openclaw_to_hermes.py: rebrand_text() was rewriting ~/.openclaw/config.yaml → ~/.Hermes/config.yaml (capital H — a directory that doesn't exist). Now case-preserving: "OpenClaw" → "Hermes" (prose), but "openclaw" → "hermes" (so filesystem paths land on the real Hermes home). Regex logic unchanged — replacement function now checks if the matched text was all-lowercase and emits the replacement in the matching case. 2. agent/onboarding.py + cli.py: one-time startup banner the first time Hermes launches and finds ~/.openclaw/. Tells the user to run `hermes claw cleanup` to archive it, gated on the existing onboarding seen-flag framework (onboarding.seen.openclaw_residue_cleanup in config.yaml). Fires once per install; re-running requires wiping that flag or running cleanup directly. Tests: - 4 new TestDetectOpenclawResidue tests (present / absent / file-instead- of-dir / default-home smoke) - 2 TestOpenclawResidueHint tests (content check) - 2 TestOpenclawResidueSeenFlag tests (flag isolation + round-trip) - test_rebrand_text_preserves_filesystem_path_casing regression test with 4 scenarios including the exact ~/.openclaw/config.yaml case - Existing test_rebrand_text_* tests updated to the new case-preserving contract (lowercase input → lowercase output) Co-authored-by: teknium1 <teknium@noreply.github.com>
This commit is contained in:
@@ -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",
|
||||
]
|
||||
|
||||
24
cli.py
24
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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user