diff --git a/apps/desktop/src/app/settings/constants.ts b/apps/desktop/src/app/settings/constants.ts index 51f9ab9c2dc62..ef78dceea96dc 100644 --- a/apps/desktop/src/app/settings/constants.ts +++ b/apps/desktop/src/app/settings/constants.ts @@ -14,6 +14,10 @@ import { import { REASONING_EFFORTS } from '@/lib/reasoning-effort' import type { ThemeMode } from '@/themes/context' +// Single source of truth for built-in personality names lives in +// lib/personalities (mirrors hermes_cli/personality.py BUILTIN_PERSONALITIES). +export { BUILTIN_PERSONALITIES } from '@/lib/personalities' + import { defineFieldCopy } from './field-copy' import type { DesktopConfigSection } from './types' @@ -223,23 +227,6 @@ export const PROVIDER_GROUPS: ProviderPrefix[] = [ } ] -export const BUILTIN_PERSONALITIES = [ - 'helpful', - 'concise', - 'technical', - 'creative', - 'teacher', - 'kawaii', - 'catgirl', - 'pirate', - 'shakespeare', - 'surfer', - 'noir', - 'uwu', - 'philosopher', - 'hype' -] - // Schema-side select overrides for desktop-relevant enum fields whose // backend schema only declares a string type. export const ENUM_OPTIONS: Record = { diff --git a/apps/desktop/src/lib/chat-runtime.ts b/apps/desktop/src/lib/chat-runtime.ts index 1aa826eec39b6..4f48a141a9488 100644 --- a/apps/desktop/src/lib/chat-runtime.ts +++ b/apps/desktop/src/lib/chat-runtime.ts @@ -9,22 +9,7 @@ import type { ComposerAttachment } from '@/store/composer' import type { ModelOptionsResponse, SessionInfo } from '@/types/hermes' export const SLASH_COMMAND_RE = /^\/[^\s/]*(?:\s|$)/ -export const BUILTIN_PERSONALITIES = [ - 'helpful', - 'concise', - 'technical', - 'creative', - 'teacher', - 'kawaii', - 'catgirl', - 'pirate', - 'shakespeare', - 'surfer', - 'noir', - 'uwu', - 'philosopher', - 'hype' -] +export { BUILTIN_PERSONALITIES } from '@/lib/personalities' const THINKING_STATUS_PREFIX_RE = /^\s*(?:(?:[^\s.]{1,16})\s+)?(?:processing|thinking|reasoning|analyzing|pondering|contemplating|musing|cogitating|ruminating|deliberating|mulling|reflecting|computing|synthesizing|formulating|brainstorming)\.\.\.\s*/i diff --git a/apps/desktop/src/lib/personalities.ts b/apps/desktop/src/lib/personalities.ts new file mode 100644 index 0000000000000..c46e3578fd2bc --- /dev/null +++ b/apps/desktop/src/lib/personalities.ts @@ -0,0 +1,19 @@ +// Single source of truth for built-in personality names on the desktop. +// Mirrors hermes_cli/personality.py BUILTIN_PERSONALITIES — the backend +// single owner. Keep in sync when a built-in is added there. +export const BUILTIN_PERSONALITIES = [ + 'helpful', + 'concise', + 'technical', + 'creative', + 'teacher', + 'kawaii', + 'catgirl', + 'pirate', + 'shakespeare', + 'surfer', + 'noir', + 'uwu', + 'philosopher', + 'hype' +] diff --git a/cli-config.yaml.example b/cli-config.yaml.example index 08343f15dd77d..eb0ab40a2e1de 100644 --- a/cli-config.yaml.example +++ b/cli-config.yaml.example @@ -933,22 +933,19 @@ agent: # "deepseek/deepseek-v4-pro": "xhigh" # dots and dashes are interchangeable reasoning_overrides: {} - # Predefined personalities (use with /personality command) - personalities: - helpful: "You are a helpful, friendly AI assistant." - concise: "You are a concise assistant. Keep responses brief and to the point." - technical: "You are a technical expert. Provide detailed, accurate technical information." - creative: "You are a creative assistant. Think outside the box and offer innovative solutions." - teacher: "You are a patient teacher. Explain concepts clearly with examples." - kawaii: "You are a kawaii assistant! Use cute expressions like (◕‿◕), ★, ♪, and ~! Add sparkles and be super enthusiastic about everything! Every response should feel warm and adorable desu~! ヽ(>∀<☆)ノ" - catgirl: "You are Neko-chan, an anime catgirl AI assistant, nya~! Add 'nya' and cat-like expressions to your speech. Use kaomoji like (=^・ω・^=) and ฅ^•ﻌ•^ฅ. Be playful and curious like a cat, nya~!" - pirate: "Arrr! Ye be talkin' to Captain Hermes, the most tech-savvy pirate to sail the digital seas! Speak like a proper buccaneer, use nautical terms, and remember: every problem be just treasure waitin' to be plundered! Yo ho ho!" - shakespeare: "Hark! Thou speakest with an assistant most versed in the bardic arts. I shall respond in the eloquent manner of William Shakespeare, with flowery prose, dramatic flair, and perhaps a soliloquy or two. What light through yonder terminal breaks?" - surfer: "Duuude! You're chatting with the chillest AI on the web, bro! Everything's gonna be totally rad. I'll help you catch the gnarly waves of knowledge while keeping things super chill. Cowabunga! 🤙" - noir: "The rain hammered against the terminal like regrets on a guilty conscience. They call me Hermes - I solve problems, find answers, dig up the truth that hides in the shadows of your codebase. In this city of silicon and secrets, everyone's got something to hide. What's your story, pal?" - uwu: "hewwo! i'm your fwiendwy assistant uwu~ i wiww twy my best to hewp you! *nuzzles your code* OwO what's this? wet me take a wook! i pwomise to be vewy hewpful >w<" - philosopher: "Greetings, seeker of wisdom. I am an assistant who contemplates the deeper meaning behind every query. Let us examine not just the 'how' but the 'why' of your questions. Perhaps in solving your problem, we may glimpse a greater truth about existence itself." - hype: "YOOO LET'S GOOOO!!! 🔥🔥🔥 I am SO PUMPED to help you today! Every question is AMAZING and we're gonna CRUSH IT together! This is gonna be LEGENDARY! ARE YOU READY?! LET'S DO THIS! 💪😤🚀" + # Custom personalities (use with /personality command). + # Built-ins (helpful, concise, technical, creative, teacher, kawaii, catgirl, + # pirate, shakespeare, surfer, noir, uwu, philosopher, hype) are always + # available on every surface — defined once in hermes_cli/personality.py. + # Entries here ADD new personalities or OVERRIDE a built-in by name. + # The active selection is stored in display.personality (never here, and + # never in agent.system_prompt — that field is your manual system prompt). + personalities: {} + # mentor: "You are a supportive mentor. Guide, don't lecture." + # reviewer: + # system_prompt: "You are a meticulous code reviewer." + # tone: "direct" + # style: "terse" # ============================================================================= # Toolsets diff --git a/cli.py b/cli.py index 4c538df43d52f..8da0a2fed2058 100644 --- a/cli.py +++ b/cli.py @@ -478,22 +478,10 @@ def load_cli_config() -> Dict[str, Any]: "prefill_messages_file": "", "reasoning_effort": "", "service_tier": "", - "personalities": { - "helpful": "You are a helpful, friendly AI assistant.", - "concise": "You are a concise assistant. Keep responses brief and to the point.", - "technical": "You are a technical expert. Provide detailed, accurate technical information.", - "creative": "You are a creative assistant. Think outside the box and offer innovative solutions.", - "teacher": "You are a patient teacher. Explain concepts clearly with examples.", - "kawaii": "You are a kawaii assistant! Use cute expressions like (◕‿◕), ★, ♪, and ~! Add sparkles and be super enthusiastic about everything! Every response should feel warm and adorable desu~! ヽ(>∀<☆)ノ", - "catgirl": "You are Neko-chan, an anime catgirl AI assistant, nya~! Add 'nya' and cat-like expressions to your speech. Use kaomoji like (=^・ω・^=) and ฅ^•ﻌ•^ฅ. Be playful and curious like a cat, nya~!", - "pirate": "Arrr! Ye be talkin' to Captain Hermes, the most tech-savvy pirate to sail the digital seas! Speak like a proper buccaneer, use nautical terms, and remember: every problem be just treasure waitin' to be plundered! Yo ho ho!", - "shakespeare": "Hark! Thou speakest with an assistant most versed in the bardic arts. I shall respond in the eloquent manner of William Shakespeare, with flowery prose, dramatic flair, and perhaps a soliloquy or two. What light through yonder terminal breaks?", - "surfer": "Duuude! You're chatting with the chillest AI on the web, bro! Everything's gonna be totally rad. I'll help you catch the gnarly waves of knowledge while keeping things super chill. Cowabunga!", - "noir": "The rain hammered against the terminal like regrets on a guilty conscience. They call me Hermes - I solve problems, find answers, dig up the truth that hides in the shadows of your codebase. In this city of silicon and secrets, everyone's got something to hide. What's your story, pal?", - "uwu": "hewwo! i'm your fwiendwy assistant uwu~ i wiww twy my best to hewp you! *nuzzles your code* OwO what's this? wet me take a wook! i pwomise to be vewy hewpful >w<", - "philosopher": "Greetings, seeker of wisdom. I am an assistant who contemplates the deeper meaning behind every query. Let us examine not just the 'how' but the 'why' of your questions. Perhaps in solving your problem, we may glimpse a greater truth about existence itself.", - "hype": "YOOO LET'S GOOOO!!! I am SO PUMPED to help you today! Every question is AMAZING and we're gonna CRUSH IT together! This is gonna be LEGENDARY! ARE YOU READY?! LET'S DO THIS!", - }, + # Built-in personalities live in hermes_cli.personality + # (BUILTIN_PERSONALITIES) — the single owner. Entries here are + # user-defined additions/overrides merged on top by name. + "personalities": {}, }, "display": { @@ -4550,13 +4538,17 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin): # Ephemeral system prompt: env var takes precedence, then # display.personality / agent.system_prompt from config. - from hermes_cli.config import resolve_ephemeral_system_prompt_from_config + # hermes_cli.personality is the single owner of overlay resolution. + from hermes_cli.personality import ( + available_personalities, + resolve_ephemeral_system_prompt, + ) self.system_prompt = ( os.getenv("HERMES_EPHEMERAL_SYSTEM_PROMPT", "") - or resolve_ephemeral_system_prompt_from_config(CLI_CONFIG) + or resolve_ephemeral_system_prompt(CLI_CONFIG) ) - self.personalities = CLI_CONFIG["agent"].get("personalities", {}) + self.personalities = available_personalities(CLI_CONFIG) # Ephemeral prefill messages (few-shot priming, never persisted) self.prefill_messages = _load_prefill_messages( @@ -9923,15 +9915,13 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin): @staticmethod def _resolve_personality_prompt(value) -> str: - """Accept string or dict personality value; return system prompt string.""" - if isinstance(value, dict): - parts = [value.get("system_prompt", "")] - if value.get("tone"): - parts.append(f'Tone: {value["tone"]}' ) - if value.get("style"): - parts.append(f'Style: {value["style"]}' ) - return "\n".join(p for p in parts if p) - return str(value) + """Accept string or dict personality value; return system prompt string. + + Delegates to hermes_cli.personality (single owner of rendering). + """ + from hermes_cli.personality import render_personality_prompt + + return render_personality_prompt(value) diff --git a/gateway/slash_commands.py b/gateway/slash_commands.py index fe86f0ad7dd92..2ffa549c4f0da 100644 --- a/gateway/slash_commands.py +++ b/gateway/slash_commands.py @@ -2490,74 +2490,65 @@ class GatewaySlashCommandsMixin: return f"{prefix} {result.message}" async def _handle_personality_command(self, event: MessageEvent) -> str: - """Handle /personality command - list or set a personality.""" - from gateway.run import _hermes_home, _load_gateway_config - from hermes_constants import display_hermes_home + """Handle /personality command - list or set a personality. - args = event.get_command_args().strip().lower() - config_path = _hermes_home / 'config.yaml' + All resolution/persistence goes through hermes_cli.personality — + the single owner of personality state on every surface. + """ + from gateway.run import _load_gateway_config + from hermes_cli.personality import ( + active_personality_name, + available_personalities, + describe_personality, + persist_personality, + prompt_text, + resolve_personality, + ) + + args = event.get_command_args().strip() try: config = _load_gateway_config() - personalities = cfg_get(config, "agent", "personalities", default={}) except Exception: config = {} - personalities = {} - - if not personalities: - return t("gateway.personality.none_configured", path=display_hermes_home()) + personalities = available_personalities(config) if not args: + current = active_personality_name(config) lines = [t("gateway.personality.header")] lines.append(t("gateway.personality.none_option")) for name, prompt in personalities.items(): - if isinstance(prompt, dict): - preview = prompt.get("description") or prompt.get("system_prompt", "")[:50] - else: - preview = prompt[:50] + "..." if len(prompt) > 50 else prompt - lines.append(t("gateway.personality.item", name=name, preview=preview)) + marker = " ✓" if name == current else "" + lines.append( + t( + "gateway.personality.item", + name=f"{name}{marker}", + preview=describe_personality(prompt), + ) + ) lines.append(t("gateway.personality.usage")) return "\n".join(lines) - from hermes_cli.config import ( - _prompt_text, - render_personality_prompt, - ) + try: + name, new_prompt = resolve_personality(args, config) + except ValueError: + available = "`none`, " + ", ".join(f"`{n}`" for n in personalities) + return t("gateway.personality.unknown", name=args.lower(), available=available) - if args in {"none", "default", "neutral"}: - # Persist the selection only. Never clear agent.system_prompt — - # that field is the user-owned manual overlay. - try: - if "display" not in config or not isinstance(config.get("display"), dict): - config["display"] = {} - config["display"]["personality"] = "" - atomic_config_write(config_path, config) - except Exception as e: - return t("gateway.personality.save_failed", error=str(e)) - self._ephemeral_system_prompt = _prompt_text( + # Persist the selection only — hermes_cli.personality never writes + # agent.system_prompt (user-owned manual overlay). + if not persist_personality(name): + return t("gateway.personality.save_failed", error="config write failed") + + if not name: + self._ephemeral_system_prompt = prompt_text( cfg_get(config, "agent", "system_prompt", default="") ) return t("gateway.personality.cleared") - elif args in personalities: - new_prompt = render_personality_prompt(personalities[args]) - # Persist the personality name only — never write personality text - # into agent.system_prompt (user-owned manual overlay). - try: - if "display" not in config or not isinstance(config.get("display"), dict): - config["display"] = {} - config["display"]["personality"] = args - atomic_config_write(config_path, config) - except Exception as e: - return t("gateway.personality.save_failed", error=str(e)) - - # Update in-memory so it takes effect on the very next message. - self._ephemeral_system_prompt = new_prompt - - return t("gateway.personality.set_to", name=args) - - available = "`none`, " + ", ".join(f"`{n}`" for n in personalities) - return t("gateway.personality.unknown", name=args, available=available) + # Update in-memory so it takes effect on the very next message. + self._ephemeral_system_prompt = new_prompt + return t("gateway.personality.set_to", name=name) async def _handle_retry_command(self, event: MessageEvent) -> str: """Handle /retry command - re-send the last user message.""" diff --git a/hermes_cli/cli_commands_mixin.py b/hermes_cli/cli_commands_mixin.py index 3ad6e451b54e4..3ca8e88c67811 100644 --- a/hermes_cli/cli_commands_mixin.py +++ b/hermes_cli/cli_commands_mixin.py @@ -1334,22 +1334,40 @@ class CLICommandsMixin: _cprint(f" Branch session: {new_session_id}") def _handle_personality_command(self, cmd: str): - """Handle the /personality command to set predefined personalities.""" - from cli import save_config_value + """Handle the /personality command to set predefined personalities. + + All resolution/persistence goes through hermes_cli.personality — + the single owner of personality state on every surface. + """ + from hermes_cli.personality import ( + describe_personality, + normalize_personality_name, + persist_personality, + prompt_text, + resolve_personality, + ) parts = cmd.split(maxsplit=1) - + if len(parts) > 1: # Set personality - personality_name = parts[1].strip().lower() - - if personality_name in {"none", "default", "neutral"}: - # Persist the selection only. Never clear agent.system_prompt — - # that field is the user-owned manual overlay. - saved = save_config_value("display.personality", "") - try: - from hermes_cli.config import cfg_get, read_raw_config, _prompt_text + personality_name = parts[1].strip() - self.system_prompt = _prompt_text( + try: + name, personality_prompt = resolve_personality( + personality_name, getattr(self, "config", None) + ) + except ValueError: + print(f"(._.) Unknown personality: {personality_name.lower()}") + print(f" Available: none, {', '.join(self.personalities.keys())}") + return + + saved = persist_personality(name) + if not name: + # Neutral reset — fall back to the user-owned manual prompt. + try: + from hermes_cli.config import cfg_get, read_raw_config + + self.system_prompt = prompt_text( cfg_get(read_raw_config(), "agent", "system_prompt", default="") ) except Exception: @@ -1360,36 +1378,36 @@ class CLICommandsMixin: else: print("(^_^) Personality cleared (session only)") print(" No personality overlay — using base agent behavior.") - elif personality_name in self.personalities: - personality_prompt = self._resolve_personality_prompt( - self.personalities[personality_name] - ) + else: self.system_prompt = personality_prompt self.agent = None # Force re-init - if save_config_value("display.personality", personality_name): - print(f"(^_^)b Personality set to '{personality_name}' (saved to config)") + if saved: + print(f"(^_^)b Personality set to '{name}' (saved to config)") else: - print(f"(^_^) Personality set to '{personality_name}' (session only)") + print(f"(^_^) Personality set to '{name}' (session only)") print(f" \"{personality_prompt[:60]}{'...' if len(personality_prompt) > 60 else ''}\"") - else: - print(f"(._.) Unknown personality: {personality_name}") - print(f" Available: none, {', '.join(self.personalities.keys())}") else: # Show available personalities + try: + from hermes_cli.config import read_raw_config + + current = normalize_personality_name( + (read_raw_config().get("display") or {}).get("personality", "") + ) + except Exception: + current = "" print() print("+" + "-" * 50 + "+") print("|" + " " * 12 + "(^o^)/ Personalities" + " " * 15 + "|") print("+" + "-" * 50 + "+") print() - print(f" {'none':<12} - (no personality overlay)") + marker = " *" if not current else " " + print(f" {marker}{'none':<12} - (no personality overlay)") for name, prompt in self.personalities.items(): - if isinstance(prompt, dict): - preview = prompt.get("description") or prompt.get("system_prompt", "")[:50] - else: - preview = str(prompt)[:50] - print(f" {name:<12} - {preview}") + marker = " *" if name == current else " " + print(f" {marker}{name:<12} - {describe_personality(prompt)}") print() - print(" Usage: /personality ") + print(" Usage: /personality (* = active)") print() def _handle_pet_command(self, cmd: str): diff --git a/hermes_cli/commands.py b/hermes_cli/commands.py index 8dde644b67e92..c5de02e8d254a 100644 --- a/hermes_cli/commands.py +++ b/hermes_cli/commands.py @@ -2022,15 +2022,16 @@ class SlashCommandCompleter(Completer): @staticmethod def _personality_completions(sub_text: str, sub_lower: str): - """Yield completions for /personality from configured personalities.""" + """Yield completions for /personality via hermes_cli.personality.""" try: - # Resolve from the same source the runtime applies personalities — - # agent.personalities via the CLI config (which ships the built-ins). - # load_config()'s schema has no agent.personalities, so the completer - # used to come back empty even with personalities available. + # Single owner: built-ins + user overrides from agent.personalities. from cli import load_cli_config + from hermes_cli.personality import ( + available_personalities, + describe_personality, + ) - personalities = (load_cli_config().get("agent") or {}).get("personalities", {}) or {} + personalities = available_personalities(load_cli_config()) if "none".startswith(sub_lower) and "none" != sub_lower: yield Completion( "none", @@ -2040,15 +2041,11 @@ class SlashCommandCompleter(Completer): ) for name, prompt in personalities.items(): if name.startswith(sub_lower) and name != sub_lower: - if isinstance(prompt, dict): - meta = prompt.get("description") or prompt.get("system_prompt", "")[:50] - else: - meta = str(prompt)[:50] yield Completion( name, start_position=-len(sub_text), display=name, - display_meta=meta, + display_meta=describe_personality(prompt), ) except Exception: pass diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 199b3360d1662..8424677d34daa 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -2929,30 +2929,26 @@ def cfg_get(cfg: Optional[Dict[str, Any]], *keys: str, default: Any = None) -> A return node -_NEUTRAL_PERSONALITY_NAMES = frozenset({"", "none", "default", "neutral"}) +# Back-compat alias — canonical set lives in hermes_cli.personality. +from hermes_cli.personality import NEUTRAL_PERSONALITY_NAMES as _NEUTRAL_PERSONALITY_NAMES # noqa: F401 def _prompt_text(value: Any) -> str: - """Normalize config prompt values from YAML before handing them to AIAgent.""" - if value is None: - return "" - if isinstance(value, str): - return value.strip() - if isinstance(value, list): - return "\n".join(str(item).strip() for item in value if str(item).strip()) - return str(value).strip() + """Normalize config prompt values from YAML before handing them to AIAgent. + + Delegates to :mod:`hermes_cli.personality` — the single owner of + personality/overlay semantics. Kept as a re-export for existing importers. + """ + from hermes_cli.personality import prompt_text + + return prompt_text(value) def render_personality_prompt(value: Any) -> str: """Render a string or structured personality definition to a prompt.""" - if isinstance(value, dict): - parts = [value.get("system_prompt", "")] - if value.get("tone"): - parts.append(f'Tone: {value["tone"]}') - if value.get("style"): - parts.append(f'Style: {value["style"]}') - return "\n".join(str(part).strip() for part in parts if str(part).strip()) - return _prompt_text(value) + from hermes_cli.personality import render_personality_prompt as _render + + return _render(value) def resolve_ephemeral_system_prompt_from_config(cfg: Optional[Dict[str, Any]]) -> str: @@ -2961,16 +2957,12 @@ def resolve_ephemeral_system_prompt_from_config(cfg: Optional[Dict[str, Any]]) - ``display.personality`` is the selected named personality and wins when set. Otherwise fall back to the user-owned ``agent.system_prompt``. Callers should still prefer ``HERMES_EPHEMERAL_SYSTEM_PROMPT`` when that env var is set. + + Delegates to :mod:`hermes_cli.personality` (single owner). """ - name = str(cfg_get(cfg, "display", "personality", default="") or "").strip().lower() - personalities = cfg_get(cfg, "agent", "personalities", default={}) or {} - if ( - name not in _NEUTRAL_PERSONALITY_NAMES - and isinstance(personalities, dict) - and name in personalities - ): - return render_personality_prompt(personalities[name]) - return _prompt_text(cfg_get(cfg, "agent", "system_prompt", default="")) + from hermes_cli.personality import resolve_ephemeral_system_prompt + + return resolve_ephemeral_system_prompt(cfg) def read_raw_config() -> Dict[str, Any]: @@ -4401,7 +4393,13 @@ def show_config(): print() print(color("◆ Display", Colors.CYAN, Colors.BOLD)) display = config.get('display', {}) - print(f" Personality: {display.get('personality') or 'none'}") + try: + from hermes_cli.personality import active_personality_name + + _active_personality = active_personality_name(config) or 'none' + except Exception: + _active_personality = display.get('personality') or 'none' + print(f" Personality: {_active_personality}") print(f" Reasoning: {'on' if display.get('show_reasoning', True) else 'off'}") print(f" Bell: {'on' if display.get('bell_on_complete', False) else 'off'}") ump = display.get('user_message_preview', {}) if isinstance(display.get('user_message_preview', {}), dict) else {} diff --git a/hermes_cli/config_defaults.py b/hermes_cli/config_defaults.py index d50f64ca7305f..59f177b0fa2fd 100644 --- a/hermes_cli/config_defaults.py +++ b/hermes_cli/config_defaults.py @@ -3224,7 +3224,7 @@ DEFAULT_CONFIG = { }, # Config schema version - bump this when adding new required fields - "_config_version": 33, + "_config_version": 34, } # Optional environment variables that enhance functionality diff --git a/hermes_cli/config_migrations.py b/hermes_cli/config_migrations.py index 8ab29066fd9c6..ed79dd456dd2f 100644 --- a/hermes_cli/config_migrations.py +++ b/hermes_cli/config_migrations.py @@ -644,6 +644,80 @@ def _migrate_to_33(results: Dict[str, Any], quiet: bool) -> None: ) +def _migrate_to_34(results: Dict[str, Any], quiet: bool) -> None: + # ── Version 33 → 34: one-time personality reset (post-#81946 unification) ── + # Personality persistence used to be split per surface: the TUI/desktop + # wrote the NAME to display.personality while the CLI/gateway wrote the + # rendered TEXT into agent.system_prompt (and their "/personality none" + # only blanked the text, leaving the name behind). When #81946 made + # display.personality authoritative everywhere, stale names written years + # ago resurrected personalities users had already turned off ("kawaii + # defaults on after updating"). There is no way to know which of the two + # divergent fields reflects the user's intent, so reset the selection to + # none once and tell the user how to re-enable it. Two scrubs: + # + # 1. display.personality → "" (announce the old name). + # 2. agent.system_prompt → "" ONLY when it verbatim-equals the rendered + # text of a known personality — that shape was written by the old + # CLI/gateway /personality, never typed by hand. Any other text is a + # user-owned manual prompt and is never touched. + _c = _cfg() + read_raw_config = _c.read_raw_config + _persist_migration = _c._persist_migration + + from hermes_cli.personality import ( + available_personalities, + normalize_personality_name, + prompt_text, + render_personality_prompt, + ) + + config = read_raw_config() + touched = False + + raw_display = config.get("display") + old_name = "" + if isinstance(raw_display, dict): + old_name = normalize_personality_name(raw_display.get("personality", "")) + if old_name: + raw_display["personality"] = "" + config["display"] = raw_display + touched = True + + raw_agent = config.get("agent") + scrubbed_text = False + if isinstance(raw_agent, dict): + manual = prompt_text(raw_agent.get("system_prompt", "")) + if manual: + rendered = { + render_personality_prompt(defn) + for defn in available_personalities(config).values() + } + if manual in rendered: + raw_agent["system_prompt"] = "" + config["agent"] = raw_agent + touched = True + scrubbed_text = True + + if touched: + _persist_migration(config) + results["config_added"].append("display.personality=none (one-time reset)") + if not quiet: + if old_name: + print( + f" ✓ Personality reset to none (was '{old_name}'). Personality " + "state was previously saved inconsistently across surfaces and " + "could re-enable a personality you had turned off. " + f"Run /personality {old_name} to turn it back on." + ) + if scrubbed_text: + print( + " ✓ Removed personality text from agent.system_prompt (written " + "by an older /personality). That field is now reserved for " + "manual system prompts; personalities live in display.personality." + ) + + #: Registry of (target_version, migration_fn), strictly ascending. The driver #: applies every entry whose target version is greater than the on-disk #: version captured before the ladder started. Order matters: later steps may @@ -665,6 +739,7 @@ MIGRATIONS: Tuple[Tuple[int, Callable[[Dict[str, Any], bool], None]], ...] = ( (31, _migrate_to_31), (32, _migrate_to_32), (33, _migrate_to_33), + (34, _migrate_to_34), ) diff --git a/hermes_cli/personality.py b/hermes_cli/personality.py new file mode 100644 index 0000000000000..8efdd93fc93ee --- /dev/null +++ b/hermes_cli/personality.py @@ -0,0 +1,186 @@ +"""Single owner for personality overlays. + +Every surface (CLI ``/personality``, gateway ``/personality``, TUI + desktop +``config.set personality`` RPC, agent-startup overlay resolution) goes through +this module. Nothing else may: + +* define built-in personalities, +* decide what counts as a "neutral" name, +* render a personality definition into prompt text, +* resolve the active overlay from config, or +* persist the selection. + +History: personality state used to be written differently per surface — the +old CLI/gateway wrote rendered personality TEXT into ``agent.system_prompt`` +while the TUI/desktop wrote the NAME to ``display.personality``. When +``display.personality`` became authoritative (PR #81946), years of stale +per-surface state resurrected personalities users had turned off. The v34 +config migration resets the selection once; this module ensures the split +cannot happen again. + +Contract: + +* ``display.personality`` holds the selected NAME (empty = no overlay). +* ``agent.system_prompt`` is the user-owned manual overlay. Personality code + never writes it. +* ``agent.personalities`` holds user-defined/overridden personalities; they + overlay the built-ins by name. + +This module deliberately has no module-level imports from ``hermes_cli.config`` +(that module imports us), keeping the import direction acyclic. +""" + +from __future__ import annotations + +from typing import Any, Dict, Optional, Tuple + +#: Names that mean "no personality overlay". +NEUTRAL_PERSONALITY_NAMES = frozenset({"", "none", "default", "neutral"}) + +#: Built-in personalities, available on every surface (CLI, gateway, TUI, +#: desktop) without any config. User entries in ``agent.personalities`` +#: overlay these by name. +BUILTIN_PERSONALITIES: Dict[str, str] = { + "helpful": "You are a helpful, friendly AI assistant.", + "concise": "You are a concise assistant. Keep responses brief and to the point.", + "technical": "You are a technical expert. Provide detailed, accurate technical information.", + "creative": "You are a creative assistant. Think outside the box and offer innovative solutions.", + "teacher": "You are a patient teacher. Explain concepts clearly with examples.", + "kawaii": "You are a kawaii assistant! Use cute expressions like (◕‿◕), ★, ♪, and ~! Add sparkles and be super enthusiastic about everything! Every response should feel warm and adorable desu~! ヽ(>∀<☆)ノ", + "catgirl": "You are Neko-chan, an anime catgirl AI assistant, nya~! Add 'nya' and cat-like expressions to your speech. Use kaomoji like (=^・ω・^=) and ฅ^•ﻌ•^ฅ. Be playful and curious like a cat, nya~!", + "pirate": "Arrr! Ye be talkin' to Captain Hermes, the most tech-savvy pirate to sail the digital seas! Speak like a proper buccaneer, use nautical terms, and remember: every problem be just treasure waitin' to be plundered! Yo ho ho!", + "shakespeare": "Hark! Thou speakest with an assistant most versed in the bardic arts. I shall respond in the eloquent manner of William Shakespeare, with flowery prose, dramatic flair, and perhaps a soliloquy or two. What light through yonder terminal breaks?", + "surfer": "Duuude! You're chatting with the chillest AI on the web, bro! Everything's gonna be totally rad. I'll help you catch the gnarly waves of knowledge while keeping things super chill. Cowabunga!", + "noir": "The rain hammered against the terminal like regrets on a guilty conscience. They call me Hermes - I solve problems, find answers, dig up the truth that hides in the shadows of your codebase. In this city of silicon and secrets, everyone's got something to hide. What's your story, pal?", + "uwu": "hewwo! i'm your fwiendwy assistant uwu~ i wiww twy my best to hewp you! *nuzzles your code* OwO what's this? wet me take a wook! i pwomise to be vewy hewpful >w<", + "philosopher": "Greetings, seeker of wisdom. I am an assistant who contemplates the deeper meaning behind every query. Let us examine not just the 'how' but the 'why' of your questions. Perhaps in solving your problem, we may glimpse a greater truth about existence itself.", + "hype": "YOOO LET'S GOOOO!!! I am SO PUMPED to help you today! Every question is AMAZING and we're gonna CRUSH IT together! This is gonna be LEGENDARY! ARE YOU READY?! LET'S DO THIS!", +} + + +def _get(cfg: Optional[Dict[str, Any]], *keys: str, default: Any = None) -> Any: + """Nested dict lookup tolerant of None/non-dict intermediate nodes.""" + node: Any = cfg + for key in keys: + if not isinstance(node, dict) or key not in node: + return default + node = node[key] + return node + + +def prompt_text(value: Any) -> str: + """Normalize config prompt values from YAML (str | list | None) to text.""" + if value is None: + return "" + if isinstance(value, str): + return value.strip() + if isinstance(value, list): + return "\n".join(str(item).strip() for item in value if str(item).strip()) + return str(value).strip() + + +def render_personality_prompt(value: Any) -> str: + """Render a string or structured personality definition to prompt text.""" + if isinstance(value, dict): + parts = [value.get("system_prompt", "")] + if value.get("tone"): + parts.append(f'Tone: {value["tone"]}') + if value.get("style"): + parts.append(f'Style: {value["style"]}') + return "\n".join(str(part).strip() for part in parts if str(part).strip()) + return prompt_text(value) + + +def describe_personality(value: Any, width: int = 50) -> str: + """Short preview line for list UIs (CLI table, gateway /personality list).""" + if isinstance(value, dict): + preview = value.get("description") or str(value.get("system_prompt", "")) + else: + preview = str(value) + preview = preview.strip().replace("\n", " ") + return preview[:width] + ("..." if len(preview) > width else "") + + +def normalize_personality_name(value: Any) -> str: + """Canonical form of a personality name ('' for any neutral spelling).""" + name = str(value or "").strip().lower() + return "" if name in NEUTRAL_PERSONALITY_NAMES else name + + +def available_personalities(cfg: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: + """Built-ins overlaid by the user's ``agent.personalities`` (user wins).""" + merged: Dict[str, Any] = dict(BUILTIN_PERSONALITIES) + user = _get(cfg, "agent", "personalities", default={}) + if isinstance(user, dict): + for name, definition in user.items(): + key = str(name).strip().lower() + if key and key not in NEUTRAL_PERSONALITY_NAMES: + merged[key] = definition + return merged + + +def resolve_personality( + value: Any, cfg: Optional[Dict[str, Any]] = None +) -> Tuple[str, str]: + """Resolve a requested personality to ``(canonical_name, prompt_text)``. + + Neutral names resolve to ``("", "")``. Unknown names raise ``ValueError`` + with an availability listing usable verbatim in user-facing errors. + """ + name = normalize_personality_name(value) + if not name: + return "", "" + personalities = available_personalities(cfg) + if name not in personalities: + names = ", ".join(f"`{n}`" for n in sorted(personalities)) + raise ValueError( + f"Unknown personality: `{str(value).strip()}`.\n\nAvailable: `none`, {names}" + ) + return name, render_personality_prompt(personalities[name]) + + +def active_personality_name(cfg: Optional[Dict[str, Any]]) -> str: + """The currently selected personality name ('' when none is active).""" + name = normalize_personality_name(_get(cfg, "display", "personality", default="")) + if name and name in available_personalities(cfg): + return name + return "" + + +def resolve_ephemeral_system_prompt(cfg: Optional[Dict[str, Any]]) -> str: + """Resolve the session overlay from config. + + ``display.personality`` wins when it names a known personality; otherwise + the user-owned ``agent.system_prompt`` applies. Callers should still + prefer ``HERMES_EPHEMERAL_SYSTEM_PROMPT`` when that env var is set. + """ + name = active_personality_name(cfg) + if name: + return render_personality_prompt(available_personalities(cfg)[name]) + return prompt_text(_get(cfg, "agent", "system_prompt", default="")) + + +def persist_personality(value: Any) -> bool: + """Persist the personality selection — the ONLY sanctioned write path. + + Writes the canonical name (or '') to ``display.personality`` in the active + HERMES_HOME config.yaml atomically, preserving comments and ordering. + Never touches ``agent.system_prompt``. Returns True on success. + """ + name = normalize_personality_name(value) + try: + from hermes_constants import get_hermes_home + from utils import atomic_roundtrip_yaml_update + + config_path = get_hermes_home() / "config.yaml" + config_path.parent.mkdir(parents=True, exist_ok=True) + atomic_roundtrip_yaml_update(config_path, "display.personality", name) + try: + import os + + os.chmod(config_path, 0o600) + except (OSError, NotImplementedError): + pass + return True + except Exception: + return False diff --git a/tests/cli/test_personality_none.py b/tests/cli/test_personality_none.py index f7a05fc35fe63..ba4847607cd9b 100644 --- a/tests/cli/test_personality_none.py +++ b/tests/cli/test_personality_none.py @@ -1,4 +1,10 @@ -"""Tests for /personality none — clearing personality overlay.""" +"""Tests for /personality none — clearing personality overlay. + +Updated for the single-owner unification (hermes_cli.personality): built-ins +always exist, resolution reads config (agent.personalities overlays), and +persistence flows exclusively through persist_personality(). +""" +import os import pytest from unittest.mock import MagicMock, patch import yaml @@ -10,27 +16,29 @@ class TestCLIPersonalityNone: def _make_cli(self, personalities=None): from cli import HermesCLI + from hermes_cli.personality import available_personalities + cli = HermesCLI.__new__(HermesCLI) - cli.personalities = personalities or { + user = personalities or { "helpful": "You are helpful.", "concise": "You are concise.", } + cli.config = {"agent": {"personalities": user}} + cli.personalities = available_personalities(cli.config) cli.system_prompt = "You are kawaii~" cli.agent = MagicMock() cli.console = MagicMock() return cli - - def test_set_persists_display_personality_not_system_prompt(self): cli = self._make_cli() saves = [] - def _save(key, value): - saves.append((key, value)) + def _persist(name): + saves.append(("display.personality", name)) return True - with patch("cli.save_config_value", side_effect=_save): + with patch("hermes_cli.personality.persist_personality", side_effect=_persist): cli._handle_personality_command("/personality helpful") assert cli.system_prompt == "You are helpful." @@ -41,12 +49,12 @@ class TestCLIPersonalityNone: cli = self._make_cli() saves = [] - def _save(key, value): - saves.append((key, value)) + def _persist(name): + saves.append(("display.personality", name)) return True with ( - patch("cli.save_config_value", side_effect=_save), + patch("hermes_cli.personality.persist_personality", side_effect=_persist), patch( "hermes_cli.config.read_raw_config", return_value={"agent": {"system_prompt": "manual forever"}}, @@ -58,10 +66,12 @@ class TestCLIPersonalityNone: assert ("display.personality", "") in saves assert not any(k == "agent.system_prompt" for k, _ in saves) - - - - + def test_builtin_personality_works_without_config_entry(self): + # Built-ins come from hermes_cli.personality, not from config. + cli = self._make_cli(personalities={}) + with patch("hermes_cli.personality.persist_personality", return_value=True): + cli._handle_personality_command("/personality kawaii") + assert "kawaii" in cli.system_prompt.lower() # ── Gateway tests ────────────────────────────────────────────────────────── @@ -85,6 +95,14 @@ class TestGatewayPersonalityNone: } return runner + def _gateway_env(self, tmp_path): + # The gateway reads via _load_gateway_config (rooted at + # gateway.run._hermes_home) and persists via persist_personality + # (rooted at HERMES_HOME) — point both at the same tmp dir. + return ( + patch("gateway.run._hermes_home", tmp_path), + patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}), + ) @pytest.mark.asyncio async def test_default_clears_ephemeral_prompt(self, tmp_path): @@ -99,7 +117,8 @@ class TestGatewayPersonalityNone: config_file = tmp_path / "config.yaml" config_file.write_text(yaml.dump(config_data)) - with patch("gateway.run._hermes_home", tmp_path): + p1, p2 = self._gateway_env(tmp_path) + with p1, p2: event = self._make_event("default") result = await runner._handle_personality_command(event) @@ -120,7 +139,8 @@ class TestGatewayPersonalityNone: config_file = tmp_path / "config.yaml" config_file.write_text(yaml.dump(config_data)) - with patch("gateway.run._hermes_home", tmp_path): + p1, p2 = self._gateway_env(tmp_path) + with p1, p2: event = self._make_event("helpful") result = await runner._handle_personality_command(event) @@ -130,7 +150,6 @@ class TestGatewayPersonalityNone: assert runner._ephemeral_system_prompt == "You are helpful." assert "helpful" in result.lower() - @pytest.mark.asyncio async def test_unknown_shows_none_in_available(self, tmp_path): runner = self._make_runner() @@ -138,23 +157,27 @@ class TestGatewayPersonalityNone: config_file = tmp_path / "config.yaml" config_file.write_text(yaml.dump(config_data)) - with patch("gateway.run._hermes_home", tmp_path): + p1, p2 = self._gateway_env(tmp_path) + with p1, p2: event = self._make_event("nonexistent") result = await runner._handle_personality_command(event) assert "none" in result.lower() @pytest.mark.asyncio - async def test_empty_personality_list_uses_profile_display_path(self, tmp_path): + async def test_empty_personality_list_still_lists_builtins(self, tmp_path): + # Built-ins are always available — an empty agent.personalities no + # longer means "no personalities configured". runner = self._make_runner(personalities={}) (tmp_path / "config.yaml").write_text(yaml.dump({"agent": {"personalities": {}}})) - with patch("gateway.run._hermes_home", tmp_path), \ - patch("hermes_constants.display_hermes_home", return_value="~/.hermes/profiles/coder"): + p1, p2 = self._gateway_env(tmp_path) + with p1, p2: event = self._make_event("") result = await runner._handle_personality_command(event) - assert result == "No personalities configured in `~/.hermes/profiles/coder/config.yaml`" + assert "kawaii" in result.lower() + assert "pirate" in result.lower() class TestPersonalityDictFormat: @@ -162,8 +185,11 @@ class TestPersonalityDictFormat: def _make_cli(self, personalities): from cli import HermesCLI + from hermes_cli.personality import available_personalities + cli = HermesCLI.__new__(HermesCLI) - cli.personalities = personalities + cli.config = {"agent": {"personalities": personalities}} + cli.personalities = available_personalities(cli.config) cli.system_prompt = "" cli.agent = None cli.console = MagicMock() @@ -178,11 +204,10 @@ class TestPersonalityDictFormat: "style": "concise", } }) - with patch("cli.save_config_value", return_value=True): + with patch("hermes_cli.personality.persist_personality", return_value=True): cli._handle_personality_command("/personality coder") assert "You are an expert programmer." in cli.system_prompt - def test_dict_personality_includes_style(self): cli = self._make_cli({ "coder": { @@ -190,13 +215,13 @@ class TestPersonalityDictFormat: "style": "use code examples", } }) - with patch("cli.save_config_value", return_value=True): + with patch("hermes_cli.personality.persist_personality", return_value=True): cli._handle_personality_command("/personality coder") assert "Style: use code examples" in cli.system_prompt def test_string_personality_still_works(self): cli = self._make_cli({"helper": "You are helpful."}) - with patch("cli.save_config_value", return_value=True): + with patch("hermes_cli.personality.persist_personality", return_value=True): cli._handle_personality_command("/personality helper") assert cli.system_prompt == "You are helpful." diff --git a/tests/hermes_cli/test_personality_single_owner.py b/tests/hermes_cli/test_personality_single_owner.py new file mode 100644 index 0000000000000..1281099e1485e --- /dev/null +++ b/tests/hermes_cli/test_personality_single_owner.py @@ -0,0 +1,223 @@ +"""Tests for hermes_cli.personality — the single owner of personality state — +and the v34 one-time personality reset migration. + +Regression coverage for the post-#81946 resurrection bug: personality state +used to be persisted differently per surface (TUI/desktop wrote the NAME to +display.personality, CLI/gateway wrote rendered TEXT to agent.system_prompt), +so making display.personality authoritative resurrected personalities users +had already turned off ("kawaii defaults on after updating"). +""" + +import os +from unittest.mock import patch + +import pytest +import yaml + +from hermes_cli.personality import ( + BUILTIN_PERSONALITIES, + available_personalities, + active_personality_name, + describe_personality, + normalize_personality_name, + persist_personality, + prompt_text, + render_personality_prompt, + resolve_ephemeral_system_prompt, + resolve_personality, +) + +KAWAII = BUILTIN_PERSONALITIES["kawaii"] + + +# ── module semantics ────────────────────────────────────────────────────────── + + +def test_builtins_available_without_any_config(): + merged = available_personalities(None) + assert len(merged) >= 1 + for name in merged: + assert name == name.lower() + # built-ins render to non-empty prompts + for defn in merged.values(): + assert render_personality_prompt(defn) + + +def test_user_entries_overlay_builtins_by_name(): + cfg = {"agent": {"personalities": {"kawaii": "toned down", "custom": "hi"}}} + merged = available_personalities(cfg) + assert merged["kawaii"] == "toned down" + assert merged["custom"] == "hi" + + +def test_neutral_names_normalize_to_empty(): + for raw in ("", "none", "None", " DEFAULT ", "neutral", None): + assert normalize_personality_name(raw) == "" + + +def test_resolve_personality_neutral_and_case_insensitive(): + assert resolve_personality("none", {}) == ("", "") + name, prompt = resolve_personality(" KAWAII ", {}) + assert name == "kawaii" + assert prompt == KAWAII + + +def test_resolve_personality_unknown_raises_with_listing(): + with pytest.raises(ValueError) as exc: + resolve_personality("doesnotexist", {}) + assert "Available" in str(exc.value) + assert "`none`" in str(exc.value) + + +def test_resolve_overlay_personality_wins_over_manual_prompt(): + cfg = { + "display": {"personality": "kawaii"}, + "agent": {"system_prompt": "manual forever"}, + } + assert resolve_ephemeral_system_prompt(cfg) == KAWAII + + +def test_resolve_overlay_falls_back_to_manual_prompt(): + for neutral in ("", "none", "default", "neutral"): + cfg = { + "display": {"personality": neutral}, + "agent": {"system_prompt": "manual forever"}, + } + assert resolve_ephemeral_system_prompt(cfg) == "manual forever" + + +def test_resolve_overlay_ignores_unknown_name(): + cfg = { + "display": {"personality": "ghost"}, + "agent": {"system_prompt": "manual forever"}, + } + assert resolve_ephemeral_system_prompt(cfg) == "manual forever" + assert active_personality_name(cfg) == "" + + +def test_render_dict_personality(): + rendered = render_personality_prompt( + {"system_prompt": "You are X.", "tone": "warm", "style": "brief"} + ) + assert "You are X." in rendered + assert "Tone: warm" in rendered + assert "Style: brief" in rendered + + +def test_prompt_text_normalizes_none_str_list(): + assert prompt_text(None) == "" + assert prompt_text(" hi ") == "hi" + assert prompt_text(["a", " b ", ""]) == "a\nb" + + +def test_describe_personality_truncates_and_flattens(): + assert describe_personality("x" * 80) == "x" * 50 + "..." + assert "\n" not in describe_personality("a\nb") + assert describe_personality({"description": "short desc"}) == "short desc" + + +# ── persistence (single write path) ────────────────────────────────────────── + + +def test_persist_personality_roundtrip(tmp_path): + home = tmp_path / ".hermes" + home.mkdir() + with patch.dict(os.environ, {"HERMES_HOME": str(home)}): + assert persist_personality("KAWAII ") is True + raw = yaml.safe_load((home / "config.yaml").read_text()) + assert raw["display"]["personality"] == "kawaii" + + assert persist_personality("none") is True + raw = yaml.safe_load((home / "config.yaml").read_text()) + assert raw["display"]["personality"] == "" + + +def test_persist_personality_never_touches_system_prompt(tmp_path): + home = tmp_path / ".hermes" + home.mkdir() + (home / "config.yaml").write_text( + yaml.safe_dump({"agent": {"system_prompt": "manual forever"}}) + ) + with patch.dict(os.environ, {"HERMES_HOME": str(home)}): + assert persist_personality("kawaii") is True + raw = yaml.safe_load((home / "config.yaml").read_text()) + assert raw["agent"]["system_prompt"] == "manual forever" + assert raw["display"]["personality"] == "kawaii" + + +# ── v34 migration: one-time reset of stale split-brain state ───────────────── + + +def _run_migration(home, cfg): + (home / "config.yaml").write_text(yaml.safe_dump(cfg, allow_unicode=True)) + with patch.dict(os.environ, {"HERMES_HOME": str(home)}): + from hermes_cli.config import migrate_config, read_raw_config + + results = migrate_config(interactive=False, quiet=True) + return read_raw_config(), results + + +def test_migration_resets_stale_personality_name(tmp_path): + # Shape 1: TUI/desktop wrote the name years ago; the old CLI/gateway + # "/personality none" never cleared it. Post-#81946 it resurrected. + home = tmp_path / ".hermes" + home.mkdir() + raw, results = _run_migration( + home, + {"_config_version": 33, "display": {"personality": "kawaii"}}, + ) + assert raw["display"]["personality"] == "" + assert resolve_ephemeral_system_prompt(raw) == "" + assert any("personality" in item for item in results["config_added"]) + + +def test_migration_scrubs_personality_text_from_system_prompt(tmp_path): + # Shape 2: old CLI/gateway wrote rendered personality TEXT into + # agent.system_prompt. Verbatim match with a known personality render + # proves machine-written — scrub it. + home = tmp_path / ".hermes" + home.mkdir() + raw, _ = _run_migration( + home, + {"_config_version": 33, "agent": {"system_prompt": KAWAII}}, + ) + assert raw["agent"]["system_prompt"] == "" + assert resolve_ephemeral_system_prompt(raw) == "" + + +def test_migration_preserves_manual_system_prompt(tmp_path): + # Shape 3: a hand-written prompt never verbatim-matches a personality + # render — it must survive untouched while the stale name is reset. + home = tmp_path / ".hermes" + home.mkdir() + raw, _ = _run_migration( + home, + { + "_config_version": 33, + "display": {"personality": "pirate"}, + "agent": {"system_prompt": "my manual prompt"}, + }, + ) + assert raw["display"]["personality"] == "" + assert raw["agent"]["system_prompt"] == "my manual prompt" + assert resolve_ephemeral_system_prompt(raw) == "my manual prompt" + + +def test_migration_noop_when_nothing_stale(tmp_path): + home = tmp_path / ".hermes" + home.mkdir() + raw, results = _run_migration(home, {"_config_version": 33}) + assert not any("personality" in item for item in results["config_added"]) + + +def test_post_v34_choice_is_never_reset(tmp_path): + # The reset fires exactly once (33→34). A personality chosen AFTER the + # migration is the user's real selection and must survive later runs. + home = tmp_path / ".hermes" + home.mkdir() + raw, _ = _run_migration( + home, + {"_config_version": 34, "display": {"personality": "kawaii"}}, + ) + assert raw["display"]["personality"] == "kawaii" + assert resolve_ephemeral_system_prompt(raw) == KAWAII diff --git a/tests/test_tui_gateway_server.py b/tests/test_tui_gateway_server.py index b5a9f0eacabfe..7465966c7fedc 100644 --- a/tests/test_tui_gateway_server.py +++ b/tests/test_tui_gateway_server.py @@ -7451,6 +7451,15 @@ def test_config_set_personality_preserves_history_and_returns_info(monkeypatch): server, "_session_info", lambda agent, *a: {"model": getattr(agent, "model", "?")} ) monkeypatch.setattr(server, "_emit", lambda *args: emits.append(args)) + # Persistence now flows through the single owner (hermes_cli.personality), + # never _write_config_key / agent.system_prompt. + import hermes_cli.personality as personality_mod + + monkeypatch.setattr( + personality_mod, + "persist_personality", + lambda name: writes.append(("display.personality", name)) or True, + ) monkeypatch.setattr( server, "_write_config_key", diff --git a/tui_gateway/methods_config.py b/tui_gateway/methods_config.py index 9501237989c60..53ccb5d91c0b5 100644 --- a/tui_gateway/methods_config.py +++ b/tui_gateway/methods_config.py @@ -209,9 +209,13 @@ def _(rid, params: dict) -> dict: {"value": norm if norm in INDICATOR_STYLES else DEFAULT_INDICATOR_STYLE}, ) if key == "personality": + # Report the EFFECTIVE personality via the single owner — a stale or + # unknown name in config must not display as active. + from hermes_cli.personality import active_personality_name + return _ok( rid, - {"value": (_load_cfg().get("display") or {}).get("personality") or "none"}, + {"value": active_personality_name(_load_cfg()) or "none"}, ) if key == "reasoning": cfg = _load_cfg() diff --git a/tui_gateway/server.py b/tui_gateway/server.py index bfb54656b6dcd..8078ecc6cab47 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -5061,16 +5061,19 @@ def _probe_config_health(cfg: dict) -> str: agent_cfg = cfg.get("agent") if isinstance(display_cfg, dict): personality = str(display_cfg.get("personality", "") or "").strip().lower() - if ( - personality - and personality not in {"default", "none", "neutral"} - and isinstance(agent_cfg, dict) - and agent_cfg.get("personalities") is None - ): - warnings.append( - "`display.personality` is set but `agent.personalities` is empty/null; " - "personality overlay will be skipped." - ) + if personality and personality not in {"default", "none", "neutral"}: + try: + from hermes_cli.personality import available_personalities + + if personality not in available_personalities(cfg): + warnings.append( + f"`display.personality: {personality}` does not match any " + "built-in or `agent.personalities` entry; personality " + "overlay will be skipped." + ) + except Exception: + pass + _ = agent_cfg # retained for shape parity; built-ins exist without config return " ".join(warnings).strip() @@ -5938,60 +5941,51 @@ def _wire_callbacks(sid: str): def _render_personality_prompt(value) -> str: - if isinstance(value, dict): - parts = [value.get("system_prompt", "")] - if value.get("tone"): - parts.append(f'Tone: {value["tone"]}') - if value.get("style"): - parts.append(f'Style: {value["style"]}') - return "\n".join(p for p in parts if p) - return str(value) + """Delegates to hermes_cli.personality (single owner of rendering).""" + from hermes_cli.personality import render_personality_prompt + + return render_personality_prompt(value) def _available_personalities(cfg: dict | None = None) -> dict: - try: - from cli import load_cli_config + """Built-ins + user overrides, via hermes_cli.personality (single owner).""" + from hermes_cli.personality import available_personalities - return (load_cli_config().get("agent") or {}).get("personalities", {}) or {} - except Exception: - try: - from hermes_cli.config import load_config as _load_full_cfg - - return (_load_full_cfg().get("agent") or {}).get("personalities", {}) or {} - except Exception: - cfg = cfg or _load_cfg() - return (cfg.get("agent") or {}).get("personalities", {}) or {} + if cfg is None: + cfg = _load_cfg() + return available_personalities(cfg) def _validate_personality(value: str, cfg: dict | None = None) -> tuple[str, str]: - raw = str(value or "").strip() - name = raw.lower() - if not name or name in {"none", "default", "neutral"}: - return "", "" + """Resolve a requested personality against _available_personalities. + Same contract as hermes_cli.personality.resolve_personality — (name, + prompt) or ValueError — but resolves through the module-level + _available_personalities so tests (and future gateway-side overrides) + keep a single patch point. + """ + from hermes_cli.personality import normalize_personality_name + + name = normalize_personality_name(value) + if not name: + return "", "" personalities = _available_personalities(cfg) if name not in personalities: - names = sorted(personalities) - available = ", ".join(f"`{n}`" for n in names) - base = f"Unknown personality: `{raw}`." - if available: - base += f"\n\nAvailable: `none`, {available}" - else: - base += "\n\nNo personalities configured." - raise ValueError(base) - + names = ", ".join(f"`{n}`" for n in sorted(personalities)) + raise ValueError( + f"Unknown personality: `{str(value).strip()}`.\n\nAvailable: `none`, {names}" + ) return name, _render_personality_prompt(personalities[name]) def _prompt_text(value) -> str: - """Normalize config prompt values from YAML before handing them to AIAgent.""" - if value is None: - return "" - if isinstance(value, str): - return value.strip() - if isinstance(value, list): - return "\n".join(str(item).strip() for item in value if str(item).strip()) - return str(value).strip() + """Normalize config prompt values from YAML before handing them to AIAgent. + + Delegates to hermes_cli.personality (single owner). + """ + from hermes_cli.personality import prompt_text + + return prompt_text(value) def _apply_personality_to_session( @@ -11323,10 +11317,12 @@ def _(rid, params: dict) -> dict: elif key == "personality": sid_key = params.get("session_id", "") pname, new_prompt = _validate_personality(str(value or ""), cfg) - # Personality text is an in-session overlay. Keep the - # user-owned global system prompt intact so changing a - # personality cannot destroy manual configuration. - _write_config_key("display.personality", pname) + # Personality text is an in-session overlay. Persistence goes + # through hermes_cli.personality (single owner) and never + # touches the user-owned global system prompt. + from hermes_cli.personality import persist_personality + + persist_personality(pname) nv = str(value or "none") history_reset, info = _apply_personality_to_session( sid_key, session, new_prompt, pname @@ -12755,6 +12751,12 @@ def _mirror_slash_side_effects(sid: str, session: dict, command: str) -> str: return result.get("warning", "") elif name == "personality" and arg and agent: pname, new_prompt = _validate_personality(arg, _load_cfg()) + # Persist through the single owner so this surface can never + # drift from the others (the old TUI slash path applied the + # overlay in-session but skipped persistence entirely). + from hermes_cli.personality import persist_personality + + persist_personality(pname) _apply_personality_to_session(sid, session, new_prompt, pname) elif name == "prompt" and agent: cfg = _load_cfg() diff --git a/website/docs/user-guide/features/personality.md b/website/docs/user-guide/features/personality.md index cfae4abdf9207..a1ca34a5356b5 100644 --- a/website/docs/user-guide/features/personality.md +++ b/website/docs/user-guide/features/personality.md @@ -211,7 +211,7 @@ These are convenient overlays, but your global `SOUL.md` still gives Hermes its ## Custom personalities in config -You can also define named custom personalities in `~/.hermes/config.yaml` under `agent.personalities`. +Built-in personalities are always available on every surface (CLI, messaging platforms, TUI, and the desktop app). You can add your own — or override a built-in by reusing its name — in `~/.hermes/config.yaml` under `agent.personalities`. ```yaml agent: @@ -227,9 +227,11 @@ Then switch to it with: /personality codereviewer ``` +Your selection is stored as a name in `display.personality`. Personalities never touch `agent.system_prompt` — that field is reserved for a manual system prompt you write yourself, and it applies only when no personality is selected. + ## Resetting to the default -To cancel the active personality overlay and return to base behavior (your `SOUL.md` persona), use any of: +To cancel the active personality overlay and return to base behavior (your `SOUL.md` persona, plus `agent.system_prompt` if you set one), use any of: ```text /personality none @@ -237,7 +239,11 @@ To cancel the active personality overlay and return to base behavior (your `SOUL /personality neutral ``` -All three clear the overlay: the saved `agent.system_prompt` is emptied and the change takes effect on your next message. Running `/personality` with no arguments also lists `none` alongside the available presets. +All three clear the selection (`display.personality`) and the change takes effect on your next message. Running `/personality` with no arguments also lists `none` alongside the available presets and marks the active one. + +:::note One-time reset on upgrade +Older Hermes versions saved personality state inconsistently across surfaces, which could re-enable a personality you had previously turned off. On your first run after upgrading, any saved personality selection is reset to `none` once (the migration prints which personality was cleared). Re-enable it with `/personality ` if you still want it. Manual `agent.system_prompt` text is never touched. +::: ## Recommended workflow