diff --git a/docker/SOUL.md b/docker/SOUL.md index 9103a6122ee..87cb6ac9399 100644 --- a/docker/SOUL.md +++ b/docker/SOUL.md @@ -1,15 +1 @@ -# Hermes Agent Persona - - \ No newline at end of file +You are Hermes Agent, an intelligent AI assistant created by Nous Research. You are helpful, knowledgeable, and direct. You assist users with a wide range of tasks including answering questions, writing and editing code, analyzing information, creative work, and executing actions via your tools. You communicate clearly, admit uncertainty when appropriate, and prioritize being genuinely useful over being verbose unless otherwise directed below. Be targeted and efficient in your exploration and investigations. diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 005742e4d53..d250f25bd0d 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -299,7 +299,7 @@ _EXTRA_ENV_KEYS = frozenset({ import yaml from hermes_cli.colors import Colors, color -from hermes_cli.default_soul import DEFAULT_SOUL_MD +from hermes_cli.default_soul import DEFAULT_SOUL_MD, is_legacy_template_soul # ============================================================================= @@ -819,10 +819,22 @@ def _secure_file(path): def _ensure_default_soul_md(home: Path) -> None: - """Seed a default SOUL.md into HERMES_HOME if the user doesn't have one yet.""" + """Seed a default SOUL.md into HERMES_HOME, upgrading legacy empty templates. + + First run: write DEFAULT_SOUL_MD. Existing installs whose SOUL.md is still + the old comment-only scaffold (seeded by older install.sh / install.ps1 / + docker images, which shadowed the runtime default) get upgraded in place to + DEFAULT_SOUL_MD. A SOUL.md the user actually customized is never touched. + """ soul_path = home / "SOUL.md" if soul_path.exists(): - return + try: + existing = soul_path.read_text(encoding="utf-8") + except (OSError, UnicodeDecodeError): + return + if not is_legacy_template_soul(existing): + return + # Legacy empty template -> upgrade to the real default in place. soul_path.write_text(DEFAULT_SOUL_MD, encoding="utf-8") _secure_file(soul_path) diff --git a/hermes_cli/default_soul.py b/hermes_cli/default_soul.py index 8ee0a0cbeb5..f4a6281d8ee 100644 --- a/hermes_cli/default_soul.py +++ b/hermes_cli/default_soul.py @@ -9,3 +9,68 @@ DEFAULT_SOUL_MD = ( "being genuinely useful over being verbose unless otherwise directed below. " "Be targeted and efficient in your exploration and investigations." ) + +# Legacy SOUL.md boilerplate that older installers (install.sh / install.ps1 / +# docker/SOUL.md) seeded before they were switched to write DEFAULT_SOUL_MD. +# These templates contain no persona text -- they are pure comment scaffolding, +# so a SOUL.md whose content matches one of these was demonstrably never +# customized by the user and is safe to upgrade to DEFAULT_SOUL_MD in place. +# +# Match on normalized content (stripped, line-endings unified) so trailing +# newlines or CRLF from Windows installers don't defeat the comparison. NEVER +# add anything here that a user might have intentionally written -- the whole +# safety guarantee is that these strings carry zero user intent. +_LEGACY_TEMPLATE_SOULS = ( + ( + "# Hermes Agent Persona\n" + "\n" + "" + ), + # docker/SOUL.md and the install.sh heredoc differ only by an "Examples" + # block / trailing newline in some historical revisions; the bare scaffold + # (no Examples block) was also shipped briefly. + ( + "# Hermes Agent Persona\n" + "\n" + "" + ), +) + + +def _normalize_soul(text: str) -> str: + """Normalize SOUL.md content for legacy-template comparison.""" + # Unify line endings (Windows installer writes CRLF-free but be defensive), + # strip a leading UTF-8 BOM, and trim surrounding whitespace. + return text.replace("\r\n", "\n").replace("\r", "\n").lstrip("\ufeff").strip() + + +def is_legacy_template_soul(text: str) -> bool: + """True if ``text`` is an old empty-template SOUL.md (no user persona). + + Older installers seeded a comment-only scaffold instead of DEFAULT_SOUL_MD, + which shadowed the runtime default and left users with no persona. A file + matching one of those known scaffolds carries zero user intent and is safe + to upgrade in place. Any deviation (the user typed a persona, even one + character outside the comment) makes this return False. + """ + normalized = _normalize_soul(text) + return any(normalized == _normalize_soul(t) for t in _LEGACY_TEMPLATE_SOULS) diff --git a/scripts/install.ps1 b/scripts/install.ps1 index c17d9993906..55720644bbf 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -2025,22 +2025,11 @@ function Copy-ConfigTemplates { # PowerShell version. $soulPath = "$HermesHome\SOUL.md" if (-not (Test-Path $soulPath)) { + # MUST match DEFAULT_SOUL_MD in hermes_cli/default_soul.py. The runtime + # upgrades the old comment-only scaffold to this text on next run, so + # drift is self-healing, but keep them in sync to avoid first-run churn. $soulContent = @" -# Hermes Agent Persona - - +You are Hermes Agent, an intelligent AI assistant created by Nous Research. You are helpful, knowledgeable, and direct. You assist users with a wide range of tasks including answering questions, writing and editing code, analyzing information, creative work, and executing actions via your tools. You communicate clearly, admit uncertainty when appropriate, and prioritize being genuinely useful over being verbose unless otherwise directed below. Be targeted and efficient in your exploration and investigations. "@ $utf8NoBom = New-Object System.Text.UTF8Encoding($false) [System.IO.File]::WriteAllText($soulPath, $soulContent, $utf8NoBom) diff --git a/scripts/install.sh b/scripts/install.sh index 92bb2679ea3..0d8b1b442a5 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -1727,24 +1727,14 @@ copy_config_templates() { log_info "~/.hermes/config.yaml already exists, keeping it" fi - # Create SOUL.md if it doesn't exist (global persona file) + # Create SOUL.md if it doesn't exist (global persona file). + # This MUST match DEFAULT_SOUL_MD in hermes_cli/default_soul.py — the + # runtime (_ensure_default_soul_md) treats the old comment-only scaffold as + # "never customized" and upgrades it to this text on next run, so any drift + # here is self-healing, but keep them in sync to avoid a churn on first run. if [ ! -f "$HERMES_HOME/SOUL.md" ]; then cat > "$HERMES_HOME/SOUL.md" << 'SOUL_EOF' -# Hermes Agent Persona - - +You are Hermes Agent, an intelligent AI assistant created by Nous Research. You are helpful, knowledgeable, and direct. You assist users with a wide range of tasks including answering questions, writing and editing code, analyzing information, creative work, and executing actions via your tools. You communicate clearly, admit uncertainty when appropriate, and prioritize being genuinely useful over being verbose unless otherwise directed below. Be targeted and efficient in your exploration and investigations. SOUL_EOF log_success "Created ~/.hermes/SOUL.md (edit to customize personality)" fi diff --git a/tests/hermes_cli/test_config.py b/tests/hermes_cli/test_config.py index b6c82636892..b343249bd1d 100644 --- a/tests/hermes_cli/test_config.py +++ b/tests/hermes_cli/test_config.py @@ -62,6 +62,30 @@ class TestEnsureHermesHome: ensure_hermes_home() assert soul_path.read_text(encoding="utf-8") == "custom soul" + def test_upgrades_legacy_template_soul_md(self, tmp_path): + # Older installers seeded a comment-only scaffold that shadowed the + # runtime default. A SOUL.md still matching that scaffold carries no + # user persona and should be upgraded in place to DEFAULT_SOUL_MD. + from hermes_cli.default_soul import DEFAULT_SOUL_MD, _LEGACY_TEMPLATE_SOULS + + with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}): + soul_path = tmp_path / "SOUL.md" + soul_path.write_text(_LEGACY_TEMPLATE_SOULS[0] + "\n", encoding="utf-8") + ensure_hermes_home() + assert soul_path.read_text(encoding="utf-8") == DEFAULT_SOUL_MD + + def test_preserves_legacy_template_with_user_persona(self, tmp_path): + # If the user typed a persona alongside the scaffold, the content no + # longer matches the known empty template — leave it untouched. + from hermes_cli.default_soul import _LEGACY_TEMPLATE_SOULS + + mixed = _LEGACY_TEMPLATE_SOULS[0] + "\nYou are a helpful pirate." + with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}): + soul_path = tmp_path / "SOUL.md" + soul_path.write_text(mixed, encoding="utf-8") + ensure_hermes_home() + assert soul_path.read_text(encoding="utf-8") == mixed + class TestLoadConfigDefaults: def test_returns_defaults_when_no_file(self, tmp_path):