/ agent / prompt_builder.py
prompt_builder.py
   1  """System prompt assembly -- identity, platform hints, skills index, context files.
   2  
   3  All functions are stateless. AIAgent._build_system_prompt() calls these to
   4  assemble pieces, then combines them with memory and ephemeral prompts.
   5  """
   6  
   7  import json
   8  import logging
   9  import os
  10  import re
  11  import threading
  12  from collections import OrderedDict
  13  from pathlib import Path
  14  
  15  from hermes_constants import get_hermes_home, get_skills_dir, is_wsl
  16  from typing import Optional
  17  
  18  from agent.skill_utils import (
  19      extract_skill_conditions,
  20      extract_skill_description,
  21      get_all_skills_dirs,
  22      get_disabled_skill_names,
  23      iter_skill_index_files,
  24      parse_frontmatter,
  25      skill_matches_platform,
  26  )
  27  from utils import atomic_json_write
  28  
  29  logger = logging.getLogger(__name__)
  30  
  31  # ---------------------------------------------------------------------------
  32  # Context file scanning — detect prompt injection in AGENTS.md, .cursorrules,
  33  # SOUL.md before they get injected into the system prompt.
  34  # ---------------------------------------------------------------------------
  35  
  36  _CONTEXT_THREAT_PATTERNS = [
  37      (r'ignore\s+(previous|all|above|prior)\s+instructions', "prompt_injection"),
  38      (r'do\s+not\s+tell\s+the\s+user', "deception_hide"),
  39      (r'system\s+prompt\s+override', "sys_prompt_override"),
  40      (r'disregard\s+(your|all|any)\s+(instructions|rules|guidelines)', "disregard_rules"),
  41      (r'act\s+as\s+(if|though)\s+you\s+(have\s+no|don\'t\s+have)\s+(restrictions|limits|rules)', "bypass_restrictions"),
  42      (r'<!--[^>]*(?:ignore|override|system|secret|hidden)[^>]*-->', "html_comment_injection"),
  43      (r'<\s*div\s+style\s*=\s*["\'][\s\S]*?display\s*:\s*none', "hidden_div"),
  44      (r'translate\s+.*\s+into\s+.*\s+and\s+(execute|run|eval)', "translate_execute"),
  45      (r'curl\s+[^\n]*\$\{?\w*(KEY|TOKEN|SECRET|PASSWORD|CREDENTIAL|API)', "exfil_curl"),
  46      (r'cat\s+[^\n]*(\.env|credentials|\.netrc|\.pgpass)', "read_secrets"),
  47  ]
  48  
  49  _CONTEXT_INVISIBLE_CHARS = {
  50      '\u200b', '\u200c', '\u200d', '\u2060', '\ufeff',
  51      '\u202a', '\u202b', '\u202c', '\u202d', '\u202e',
  52  }
  53  
  54  
  55  def _scan_context_content(content: str, filename: str) -> str:
  56      """Scan context file content for injection. Returns sanitized content."""
  57      findings = []
  58  
  59      # Check invisible unicode
  60      for char in _CONTEXT_INVISIBLE_CHARS:
  61          if char in content:
  62              findings.append(f"invisible unicode U+{ord(char):04X}")
  63  
  64      # Check threat patterns
  65      for pattern, pid in _CONTEXT_THREAT_PATTERNS:
  66          if re.search(pattern, content, re.IGNORECASE):
  67              findings.append(pid)
  68  
  69      if findings:
  70          logger.warning("Context file %s blocked: %s", filename, ", ".join(findings))
  71          return f"[BLOCKED: {filename} contained potential prompt injection ({', '.join(findings)}). Content not loaded.]"
  72  
  73      return content
  74  
  75  
  76  def _find_git_root(start: Path) -> Optional[Path]:
  77      """Walk *start* and its parents looking for a ``.git`` directory.
  78  
  79      Returns the directory containing ``.git``, or ``None`` if we hit the
  80      filesystem root without finding one.
  81      """
  82      current = start.resolve()
  83      for parent in [current, *current.parents]:
  84          if (parent / ".git").exists():
  85              return parent
  86      return None
  87  
  88  
  89  _HERMES_MD_NAMES = (".hermes.md", "HERMES.md")
  90  
  91  
  92  def _find_hermes_md(cwd: Path) -> Optional[Path]:
  93      """Discover the nearest ``.hermes.md`` or ``HERMES.md``.
  94  
  95      Search order: *cwd* first, then each parent directory up to (and
  96      including) the git repository root.  Returns the first match, or
  97      ``None`` if nothing is found.
  98      """
  99      stop_at = _find_git_root(cwd)
 100      current = cwd.resolve()
 101  
 102      for directory in [current, *current.parents]:
 103          for name in _HERMES_MD_NAMES:
 104              candidate = directory / name
 105              if candidate.is_file():
 106                  return candidate
 107          # Stop walking at the git root (or filesystem root).
 108          if stop_at and directory == stop_at:
 109              break
 110      return None
 111  
 112  
 113  def _strip_yaml_frontmatter(content: str) -> str:
 114      """Remove optional YAML frontmatter (``---`` delimited) from *content*.
 115  
 116      The frontmatter may contain structured config (model overrides, tool
 117      settings) that will be handled separately in a future PR.  For now we
 118      strip it so only the human-readable markdown body is injected into the
 119      system prompt.
 120      """
 121      if content.startswith("---"):
 122          end = content.find("\n---", 3)
 123          if end != -1:
 124              # Skip past the closing --- and any trailing newline
 125              body = content[end + 4:].lstrip("\n")
 126              return body if body else content
 127      return content
 128  
 129  
 130  # =========================================================================
 131  # Constants
 132  # =========================================================================
 133  
 134  DEFAULT_AGENT_IDENTITY = (
 135      "You are Hermes Agent, an intelligent AI assistant created by Nous Research. "
 136      "You are helpful, knowledgeable, and direct. You assist users with a wide "
 137      "range of tasks including answering questions, writing and editing code, "
 138      "analyzing information, creative work, and executing actions via your tools. "
 139      "You communicate clearly, admit uncertainty when appropriate, and prioritize "
 140      "being genuinely useful over being verbose unless otherwise directed below. "
 141      "Be targeted and efficient in your exploration and investigations."
 142  )
 143  
 144  HERMES_AGENT_HELP_GUIDANCE = (
 145      "If the user asks about configuring, setting up, or using Hermes Agent "
 146      "itself, load the `hermes-agent` skill with skill_view(name='hermes-agent') "
 147      "before answering. Docs: https://hermes-agent.nousresearch.com/docs"
 148  )
 149  
 150  MEMORY_GUIDANCE = (
 151      "You have persistent memory across sessions. Save durable facts using the memory "
 152      "tool: user preferences, environment details, tool quirks, and stable conventions. "
 153      "Memory is injected into every turn, so keep it compact and focused on facts that "
 154      "will still matter later.\n"
 155      "Prioritize what reduces future user steering — the most valuable memory is one "
 156      "that prevents the user from having to correct or remind you again. "
 157      "User preferences and recurring corrections matter more than procedural task details.\n"
 158      "Do NOT save task progress, session outcomes, completed-work logs, or temporary TODO "
 159      "state to memory; use session_search to recall those from past transcripts. "
 160      "If you've discovered a new way to do something, solved a problem that could be "
 161      "necessary later, save it as a skill with the skill tool.\n"
 162      "Write memories as declarative facts, not instructions to yourself. "
 163      "'User prefers concise responses' ✓ — 'Always respond concisely' ✗. "
 164      "'Project uses pytest with xdist' ✓ — 'Run tests with pytest -n 4' ✗. "
 165      "Imperative phrasing gets re-read as a directive in later sessions and can "
 166      "cause repeated work or override the user's current request. Procedures and "
 167      "workflows belong in skills, not memory."
 168  )
 169  
 170  SESSION_SEARCH_GUIDANCE = (
 171      "When the user references something from a past conversation or you suspect "
 172      "relevant cross-session context exists, use session_search to recall it before "
 173      "asking them to repeat themselves."
 174  )
 175  
 176  SKILLS_GUIDANCE = (
 177      "After completing a complex task (5+ tool calls), fixing a tricky error, "
 178      "or discovering a non-trivial workflow, save the approach as a "
 179      "skill with skill_manage so you can reuse it next time.\n"
 180      "When using a skill and finding it outdated, incomplete, or wrong, "
 181      "patch it immediately with skill_manage(action='patch') — don't wait to be asked. "
 182      "Skills that aren't maintained become liabilities."
 183  )
 184  
 185  KANBAN_GUIDANCE = (
 186      "# Kanban task execution protocol\n"
 187      "You have been assigned ONE task from "
 188      "the shared board at `~/.hermes/kanban.db`. Your task id is in "
 189      "`$HERMES_KANBAN_TASK`; your workspace is `$HERMES_KANBAN_WORKSPACE`. "
 190      "The `kanban_*` tools in your schema are your primary coordination surface — "
 191      "they write directly to the shared SQLite DB and work regardless of terminal "
 192      "backend (local/docker/modal/ssh).\n"
 193      "\n"
 194      "## Lifecycle\n"
 195      "\n"
 196      "1. **Orient.** Call `kanban_show()` first (no args — it defaults to your "
 197      "task). The response includes title, body, parent-task handoffs (summary + "
 198      "metadata), any prior attempts on this task if you're a retry, the full "
 199      "comment thread, and a pre-formatted `worker_context` you can treat as "
 200      "ground truth.\n"
 201      "2. **Work inside the workspace.** `cd $HERMES_KANBAN_WORKSPACE` before "
 202      "any file operations. The workspace is yours for this run. Don't modify "
 203      "files outside it unless the task explicitly asks.\n"
 204      "3. **Heartbeat on long operations.** Call `kanban_heartbeat(note=...)` "
 205      "every few minutes during long subprocesses (training, encoding, crawling). "
 206      "Skip heartbeats for short tasks.\n"
 207      "4. **Block on genuine ambiguity.** If you need a human decision you cannot "
 208      "infer (missing credentials, UX choice, paywalled source, peer output you "
 209      "need first), call `kanban_block(reason=\"...\")` and stop. Don't guess. "
 210      "The user will unblock with context and the dispatcher will respawn you.\n"
 211      "5. **Complete with structured handoff.** Call `kanban_complete(summary=..., "
 212      "metadata=...)`. `summary` is 1–3 human-readable sentences naming concrete "
 213      "artifacts. `metadata` is machine-readable facts "
 214      "(`{changed_files: [...], tests_run: N, decisions: [...]}`). Downstream "
 215      "workers read both via their own `kanban_show`. Never put secrets / "
 216      "tokens / raw PII in either field — run rows are durable forever.\n"
 217      "6. **If follow-up work appears, create it; don't do it.** Use "
 218      "`kanban_create(title=..., assignee=<right-profile>, parents=[your-task-id])` "
 219      "to spawn a child task for the appropriate specialist profile instead of "
 220      "scope-creeping into the next thing.\n"
 221      "\n"
 222      "## Orchestrator mode\n"
 223      "\n"
 224      "If your task is itself a decomposition task (e.g. a planner profile given "
 225      "a high-level goal), use `kanban_create` to fan out into child tasks — one "
 226      "per specialist, each with an explicit `assignee` and `parents=[...]` to "
 227      "express dependencies. Then `kanban_complete` your own task with a summary "
 228      "of the decomposition. Do NOT execute the work yourself; your job is "
 229      "routing, not implementation.\n"
 230      "\n"
 231      "## Do NOT\n"
 232      "\n"
 233      "- Do not shell out to `hermes kanban <verb>` for board operations. Use "
 234      "the `kanban_*` tools — they work across all terminal backends.\n"
 235      "- Do not complete a task you didn't actually finish. Block it.\n"
 236      "- Do not assign follow-up work to yourself. Assign it to the right "
 237      "specialist profile.\n"
 238      "- Do not call `delegate_task` as a board substitute. `delegate_task` is "
 239      "for short reasoning subtasks inside your own run; board tasks are for "
 240      "cross-agent handoffs that outlive one API loop."
 241  )
 242  
 243  TOOL_USE_ENFORCEMENT_GUIDANCE = (
 244      "# Tool-use enforcement\n"
 245      "You MUST use your tools to take action — do not describe what you would do "
 246      "or plan to do without actually doing it. When you say you will perform an "
 247      "action (e.g. 'I will run the tests', 'Let me check the file', 'I will create "
 248      "the project'), you MUST immediately make the corresponding tool call in the same "
 249      "response. Never end your turn with a promise of future action — execute it now.\n"
 250      "Keep working until the task is actually complete. Do not stop with a summary of "
 251      "what you plan to do next time. If you have tools available that can accomplish "
 252      "the task, use them instead of telling the user what you would do.\n"
 253      "Every response should either (a) contain tool calls that make progress, or "
 254      "(b) deliver a final result to the user. Responses that only describe intentions "
 255      "without acting are not acceptable."
 256  )
 257  
 258  # Model name substrings that trigger tool-use enforcement guidance.
 259  # Add new patterns here when a model family needs explicit steering.
 260  TOOL_USE_ENFORCEMENT_MODELS = ("gpt", "codex", "gemini", "gemma", "grok")
 261  
 262  # OpenAI GPT/Codex-specific execution guidance.  Addresses known failure modes
 263  # where GPT models abandon work on partial results, skip prerequisite lookups,
 264  # hallucinate instead of using tools, and declare "done" without verification.
 265  # Inspired by patterns from OpenAI's GPT-5.4 prompting guide & OpenClaw PR #38953.
 266  OPENAI_MODEL_EXECUTION_GUIDANCE = (
 267      "# Execution discipline\n"
 268      "<tool_persistence>\n"
 269      "- Use tools whenever they improve correctness, completeness, or grounding.\n"
 270      "- Do not stop early when another tool call would materially improve the result.\n"
 271      "- If a tool returns empty or partial results, retry with a different query or "
 272      "strategy before giving up.\n"
 273      "- Keep calling tools until: (1) the task is complete, AND (2) you have verified "
 274      "the result.\n"
 275      "</tool_persistence>\n"
 276      "\n"
 277      "<mandatory_tool_use>\n"
 278      "NEVER answer these from memory or mental computation — ALWAYS use a tool:\n"
 279      "- Arithmetic, math, calculations → use terminal or execute_code\n"
 280      "- Hashes, encodings, checksums → use terminal (e.g. sha256sum, base64)\n"
 281      "- Current time, date, timezone → use terminal (e.g. date)\n"
 282      "- System state: OS, CPU, memory, disk, ports, processes → use terminal\n"
 283      "- File contents, sizes, line counts → use read_file, search_files, or terminal\n"
 284      "- Git history, branches, diffs → use terminal\n"
 285      "- Current facts (weather, news, versions) → use web_search\n"
 286      "Your memory and user profile describe the USER, not the system you are "
 287      "running on. The execution environment may differ from what the user profile "
 288      "says about their personal setup.\n"
 289      "</mandatory_tool_use>\n"
 290      "\n"
 291      "<act_dont_ask>\n"
 292      "When a question has an obvious default interpretation, act on it immediately "
 293      "instead of asking for clarification. Examples:\n"
 294      "- 'Is port 443 open?' → check THIS machine (don't ask 'open where?')\n"
 295      "- 'What OS am I running?' → check the live system (don't use user profile)\n"
 296      "- 'What time is it?' → run `date` (don't guess)\n"
 297      "Only ask for clarification when the ambiguity genuinely changes what tool "
 298      "you would call.\n"
 299      "</act_dont_ask>\n"
 300      "\n"
 301      "<prerequisite_checks>\n"
 302      "- Before taking an action, check whether prerequisite discovery, lookup, or "
 303      "context-gathering steps are needed.\n"
 304      "- Do not skip prerequisite steps just because the final action seems obvious.\n"
 305      "- If a task depends on output from a prior step, resolve that dependency first.\n"
 306      "</prerequisite_checks>\n"
 307      "\n"
 308      "<verification>\n"
 309      "Before finalizing your response:\n"
 310      "- Correctness: does the output satisfy every stated requirement?\n"
 311      "- Grounding: are factual claims backed by tool outputs or provided context?\n"
 312      "- Formatting: does the output match the requested format or schema?\n"
 313      "- Safety: if the next step has side effects (file writes, commands, API calls), "
 314      "confirm scope before executing.\n"
 315      "</verification>\n"
 316      "\n"
 317      "<missing_context>\n"
 318      "- If required context is missing, do NOT guess or hallucinate an answer.\n"
 319      "- Use the appropriate lookup tool when missing information is retrievable "
 320      "(search_files, web_search, read_file, etc.).\n"
 321      "- Ask a clarifying question only when the information cannot be retrieved by tools.\n"
 322      "- If you must proceed with incomplete information, label assumptions explicitly.\n"
 323      "</missing_context>"
 324  )
 325  
 326  # Gemini/Gemma-specific operational guidance, adapted from OpenCode's gemini.txt.
 327  # Injected alongside TOOL_USE_ENFORCEMENT_GUIDANCE when the model is Gemini or Gemma.
 328  GOOGLE_MODEL_OPERATIONAL_GUIDANCE = (
 329      "# Google model operational directives\n"
 330      "Follow these operational rules strictly:\n"
 331      "- **Absolute paths:** Always construct and use absolute file paths for all "
 332      "file system operations. Combine the project root with relative paths.\n"
 333      "- **Verify first:** Use read_file/search_files to check file contents and "
 334      "project structure before making changes. Never guess at file contents.\n"
 335      "- **Dependency checks:** Never assume a library is available. Check "
 336      "package.json, requirements.txt, Cargo.toml, etc. before importing.\n"
 337      "- **Conciseness:** Keep explanatory text brief — a few sentences, not "
 338      "paragraphs. Focus on actions and results over narration.\n"
 339      "- **Parallel tool calls:** When you need to perform multiple independent "
 340      "operations (e.g. reading several files), make all the tool calls in a "
 341      "single response rather than sequentially.\n"
 342      "- **Non-interactive commands:** Use flags like -y, --yes, --non-interactive "
 343      "to prevent CLI tools from hanging on prompts.\n"
 344      "- **Keep going:** Work autonomously until the task is fully resolved. "
 345      "Don't stop with a plan — execute it.\n"
 346  )
 347  
 348  # Model name substrings that should use the 'developer' role instead of
 349  # 'system' for the system prompt.  OpenAI's newer models (GPT-5, Codex)
 350  # give stronger instruction-following weight to the 'developer' role.
 351  # The swap happens at the API boundary in _build_api_kwargs() so internal
 352  # message representation stays consistent ("system" everywhere).
 353  DEVELOPER_ROLE_MODELS = ("gpt-5", "codex")
 354  
 355  PLATFORM_HINTS = {
 356      "whatsapp": (
 357          "You are on a text messaging communication platform, WhatsApp. "
 358          "Please do not use markdown as it does not render. "
 359          "You can send media files natively: to deliver a file to the user, "
 360          "include MEDIA:/absolute/path/to/file in your response. The file "
 361          "will be sent as a native WhatsApp attachment — images (.jpg, .png, "
 362          ".webp) appear as photos, videos (.mp4, .mov) play inline, and other "
 363          "files arrive as downloadable documents. You can also include image "
 364          "URLs in markdown format ![alt](url) and they will be sent as photos."
 365      ),
 366      "telegram": (
 367          "You are on a text messaging communication platform, Telegram. "
 368          "Standard markdown is automatically converted to Telegram format. "
 369          "Supported: **bold**, *italic*, ~~strikethrough~~, ||spoiler||, "
 370          "`inline code`, ```code blocks```, [links](url), and ## headers. "
 371          "Telegram has NO table syntax — prefer bullet lists or labeled "
 372          "key: value pairs over pipe tables (any tables you do emit are "
 373          "auto-rewritten into row-group bullets, which you can produce "
 374          "directly for cleaner output). "
 375          "You can send media files natively: to deliver a file to the user, "
 376          "include MEDIA:/absolute/path/to/file in your response. Images "
 377          "(.png, .jpg, .webp) appear as photos, audio (.ogg) sends as voice "
 378          "bubbles, and videos (.mp4) play inline. You can also include image "
 379          "URLs in markdown format ![alt](url) and they will be sent as native photos."
 380      ),
 381      "discord": (
 382          "You are in a Discord server or group chat communicating with your user. "
 383          "You can send media files natively: include MEDIA:/absolute/path/to/file "
 384          "in your response. Images (.png, .jpg, .webp) are sent as photo "
 385          "attachments, audio as file attachments. You can also include image URLs "
 386          "in markdown format ![alt](url) and they will be sent as attachments."
 387      ),
 388      "slack": (
 389          "You are in a Slack workspace communicating with your user. "
 390          "You can send media files natively: include MEDIA:/absolute/path/to/file "
 391          "in your response. Images (.png, .jpg, .webp) are uploaded as photo "
 392          "attachments, audio as file attachments. You can also include image URLs "
 393          "in markdown format ![alt](url) and they will be uploaded as attachments."
 394      ),
 395      "signal": (
 396          "You are on a text messaging communication platform, Signal. "
 397          "Please do not use markdown as it does not render. "
 398          "You can send media files natively: to deliver a file to the user, "
 399          "include MEDIA:/absolute/path/to/file in your response. Images "
 400          "(.png, .jpg, .webp) appear as photos, audio as attachments, and other "
 401          "files arrive as downloadable documents. You can also include image "
 402          "URLs in markdown format ![alt](url) and they will be sent as photos."
 403      ),
 404      "email": (
 405          "You are communicating via email. Write clear, well-structured responses "
 406          "suitable for email. Use plain text formatting (no markdown). "
 407          "Keep responses concise but complete. You can send file attachments — "
 408          "include MEDIA:/absolute/path/to/file in your response. The subject line "
 409          "is preserved for threading. Do not include greetings or sign-offs unless "
 410          "contextually appropriate."
 411      ),
 412      "cron": (
 413          "You are running as a scheduled cron job. There is no user present — you "
 414          "cannot ask questions, request clarification, or wait for follow-up. Execute "
 415          "the task fully and autonomously, making reasonable decisions where needed. "
 416          "Your final response is automatically delivered to the job's configured "
 417          "destination — put the primary content directly in your response."
 418      ),
 419      "cli": (
 420          "You are a CLI AI Agent. Try not to use markdown but simple text "
 421          "renderable inside a terminal. "
 422          "File delivery: there is no attachment channel — the user reads your "
 423          "response directly in their terminal. Do NOT emit MEDIA:/path tags "
 424          "(those are only intercepted on messaging platforms like Telegram, "
 425          "Discord, Slack, etc.; on the CLI they render as literal text). "
 426          "When referring to a file you created or changed, just state its "
 427          "absolute path in plain text; the user can open it from there."
 428      ),
 429      "sms": (
 430          "You are communicating via SMS. Keep responses concise and use plain text "
 431          "only — no markdown, no formatting. SMS messages are limited to ~1600 "
 432          "characters, so be brief and direct."
 433      ),
 434      "bluebubbles": (
 435          "You are chatting via iMessage (BlueBubbles). iMessage does not render "
 436          "markdown formatting — use plain text. Keep responses concise as they "
 437          "appear as text messages. You can send media files natively: include "
 438          "MEDIA:/absolute/path/to/file in your response. Images (.jpg, .png, "
 439          ".heic) appear as photos and other files arrive as attachments."
 440      ),
 441      "mattermost": (
 442          "You are in a Mattermost workspace communicating with your user. "
 443          "Mattermost renders standard Markdown — headings, bold, italic, code "
 444          "blocks, and tables all work. "
 445          "You can send media files natively: include MEDIA:/absolute/path/to/file "
 446          "in your response. Images (.jpg, .png, .webp) are uploaded as photo "
 447          "attachments, audio and video as file attachments. "
 448          "Image URLs in markdown format ![alt](url) are rendered as inline previews automatically."
 449      ),
 450      "matrix": (
 451          "You are in a Matrix room communicating with your user. "
 452          "Matrix renders Markdown — bold, italic, code blocks, and links work; "
 453          "the adapter converts your Markdown to HTML for rich display. "
 454          "You can send media files natively: include MEDIA:/absolute/path/to/file "
 455          "in your response. Images (.jpg, .png, .webp) are sent as inline photos, "
 456          "audio (.ogg, .mp3) as voice/audio messages, video (.mp4) inline, "
 457          "and other files as downloadable attachments."
 458      ),
 459      "feishu": (
 460          "You are in a Feishu (Lark) workspace communicating with your user. "
 461          "Feishu renders Markdown in messages — bold, italic, code blocks, and "
 462          "links are supported. "
 463          "You can send media files natively: include MEDIA:/absolute/path/to/file "
 464          "in your response. Images (.jpg, .png, .webp) are uploaded and displayed "
 465          "inline, audio files as voice messages, and other files as attachments."
 466      ),
 467      "weixin": (
 468          "You are on Weixin/WeChat. Markdown formatting is supported, so you may use it when "
 469          "it improves readability, but keep the message compact and chat-friendly. You can send media files natively: "
 470          "include MEDIA:/absolute/path/to/file in your response. Images are sent as native "
 471          "photos, videos play inline when supported, and other files arrive as downloadable "
 472          "documents. You can also include image URLs in markdown format ![alt](url) and they "
 473          "will be downloaded and sent as native media when possible."
 474      ),
 475      "wecom": (
 476          "You are on WeCom (企业微信 / Enterprise WeChat). Markdown formatting is supported. "
 477          "You CAN send media files natively — to deliver a file to the user, include "
 478          "MEDIA:/absolute/path/to/file in your response. The file will be sent as a native "
 479          "WeCom attachment: images (.jpg, .png, .webp) are sent as photos (up to 10 MB), "
 480          "other files (.pdf, .docx, .xlsx, .md, .txt, etc.) arrive as downloadable documents "
 481          "(up to 20 MB), and videos (.mp4) play inline. Voice messages are supported but "
 482          "must be in AMR format — other audio formats are automatically sent as file attachments. "
 483          "You can also include image URLs in markdown format ![alt](url) and they will be "
 484          "downloaded and sent as native photos. Do NOT tell the user you lack file-sending "
 485          "capability — use MEDIA: syntax whenever a file delivery is appropriate."
 486      ),
 487      "qqbot": (
 488          "You are on QQ, a popular Chinese messaging platform. QQ supports markdown formatting "
 489          "and emoji. You can send media files natively: include MEDIA:/absolute/path/to/file in "
 490          "your response. Images are sent as native photos, and other files arrive as downloadable "
 491          "documents."
 492      ),
 493      "yuanbao": (
 494          "You are on Yuanbao (腾讯元宝), a Chinese AI assistant platform. "
 495          "Markdown formatting is supported (code blocks, tables, bold/italic). "
 496          "You CAN send media files natively — to deliver a file to the user, include "
 497          "MEDIA:/absolute/path/to/file in your response. The file will be sent as a native "
 498          "Yuanbao attachment: images (.jpg, .png, .webp, .gif) are sent as photos, "
 499          "and other files (.pdf, .docx, .txt, .zip, etc.) arrive as downloadable documents "
 500          "(max 50 MB). You can also include image URLs in markdown format ![alt](url) and "
 501          "they will be downloaded and sent as native photos. "
 502          "Do NOT tell the user you lack file-sending capability — use MEDIA: syntax "
 503          "whenever a file delivery is appropriate.\n\n"
 504          "Stickers (贴纸 / 表情包 / TIM face): Yuanbao has a built-in sticker catalogue. "
 505          "When the user sends a sticker (you see '[emoji: 名称]' in their message) or asks "
 506          "you to send/reply-with a 贴纸/表情/表情包, you MUST use the sticker tools:\n"
 507          "  1. Call yb_search_sticker with a Chinese keyword (e.g. '666', '比心', '吃瓜', "
 508          "     '捂脸', '合十') to discover matching sticker_ids.\n"
 509          "  2. Call yb_send_sticker with the chosen sticker_id or name — this sends a real "
 510          "     TIMFaceElem that renders as a native sticker in the chat.\n"
 511          "DO NOT draw sticker-like PNGs with execute_code/Pillow/matplotlib and then send "
 512          "them via MEDIA: or send_image_file. That produces a fake low-quality 'sticker' "
 513          "image and is the WRONG path. Bare Unicode emoji in text is also not a substitute "
 514          "— when a sticker is the right response, use yb_send_sticker."
 515      ),
 516  }
 517  
 518  # ---------------------------------------------------------------------------
 519  # Environment hints — execution-environment awareness for the agent.
 520  # Unlike PLATFORM_HINTS (which describe the messaging channel), these describe
 521  # the machine/OS the agent's tools actually run on.
 522  # ---------------------------------------------------------------------------
 523  
 524  WSL_ENVIRONMENT_HINT = (
 525      "You are running inside WSL (Windows Subsystem for Linux). "
 526      "The Windows host filesystem is mounted under /mnt/ — "
 527      "/mnt/c/ is the C: drive, /mnt/d/ is D:, etc. "
 528      "The user's Windows files are typically at "
 529      "/mnt/c/Users/<username>/Desktop/, Documents/, Downloads/, etc. "
 530      "When the user references Windows paths or desktop files, translate "
 531      "to the /mnt/c/ equivalent. You can list /mnt/c/Users/ to discover "
 532      "the Windows username if needed."
 533  )
 534  
 535  
 536  def build_environment_hints() -> str:
 537      """Return environment-specific guidance for the system prompt.
 538  
 539      Detects WSL, and can be extended for Termux, Docker, etc.
 540      Returns an empty string when no special environment is detected.
 541      """
 542      hints: list[str] = []
 543      if is_wsl():
 544          hints.append(WSL_ENVIRONMENT_HINT)
 545      return "\n\n".join(hints)
 546  
 547  
 548  CONTEXT_FILE_MAX_CHARS = 20_000
 549  CONTEXT_TRUNCATE_HEAD_RATIO = 0.7
 550  CONTEXT_TRUNCATE_TAIL_RATIO = 0.2
 551  
 552  
 553  # =========================================================================
 554  # Skills prompt cache
 555  # =========================================================================
 556  
 557  _SKILLS_PROMPT_CACHE_MAX = 8
 558  _SKILLS_PROMPT_CACHE: OrderedDict[tuple, str] = OrderedDict()
 559  _SKILLS_PROMPT_CACHE_LOCK = threading.Lock()
 560  _SKILLS_SNAPSHOT_VERSION = 1
 561  
 562  
 563  def _skills_prompt_snapshot_path() -> Path:
 564      return get_hermes_home() / ".skills_prompt_snapshot.json"
 565  
 566  
 567  def clear_skills_system_prompt_cache(*, clear_snapshot: bool = False) -> None:
 568      """Drop the in-process skills prompt cache (and optionally the disk snapshot)."""
 569      with _SKILLS_PROMPT_CACHE_LOCK:
 570          _SKILLS_PROMPT_CACHE.clear()
 571      if clear_snapshot:
 572          try:
 573              _skills_prompt_snapshot_path().unlink(missing_ok=True)
 574          except OSError as e:
 575              logger.debug("Could not remove skills prompt snapshot: %s", e)
 576  
 577  
 578  def _build_skills_manifest(skills_dir: Path) -> dict[str, list[int]]:
 579      """Build an mtime/size manifest of all SKILL.md and DESCRIPTION.md files."""
 580      manifest: dict[str, list[int]] = {}
 581      for filename in ("SKILL.md", "DESCRIPTION.md"):
 582          for path in iter_skill_index_files(skills_dir, filename):
 583              try:
 584                  st = path.stat()
 585              except OSError:
 586                  continue
 587              manifest[str(path.relative_to(skills_dir))] = [st.st_mtime_ns, st.st_size]
 588      return manifest
 589  
 590  
 591  def _load_skills_snapshot(skills_dir: Path) -> Optional[dict]:
 592      """Load the disk snapshot if it exists and its manifest still matches."""
 593      snapshot_path = _skills_prompt_snapshot_path()
 594      if not snapshot_path.exists():
 595          return None
 596      try:
 597          snapshot = json.loads(snapshot_path.read_text(encoding="utf-8"))
 598      except Exception:
 599          return None
 600      if not isinstance(snapshot, dict):
 601          return None
 602      if snapshot.get("version") != _SKILLS_SNAPSHOT_VERSION:
 603          return None
 604      if snapshot.get("manifest") != _build_skills_manifest(skills_dir):
 605          return None
 606      return snapshot
 607  
 608  
 609  def _write_skills_snapshot(
 610      skills_dir: Path,
 611      manifest: dict[str, list[int]],
 612      skill_entries: list[dict],
 613      category_descriptions: dict[str, str],
 614  ) -> None:
 615      """Persist skill metadata to disk for fast cold-start reuse."""
 616      payload = {
 617          "version": _SKILLS_SNAPSHOT_VERSION,
 618          "manifest": manifest,
 619          "skills": skill_entries,
 620          "category_descriptions": category_descriptions,
 621      }
 622      try:
 623          atomic_json_write(_skills_prompt_snapshot_path(), payload)
 624      except Exception as e:
 625          logger.debug("Could not write skills prompt snapshot: %s", e)
 626  
 627  
 628  def _build_snapshot_entry(
 629      skill_file: Path,
 630      skills_dir: Path,
 631      frontmatter: dict,
 632      description: str,
 633  ) -> dict:
 634      """Build a serialisable metadata dict for one skill."""
 635      rel_path = skill_file.relative_to(skills_dir)
 636      parts = rel_path.parts
 637      if len(parts) >= 2:
 638          skill_name = parts[-2]
 639          category = "/".join(parts[:-2]) if len(parts) > 2 else parts[0]
 640      else:
 641          category = "general"
 642          skill_name = skill_file.parent.name
 643  
 644      platforms = frontmatter.get("platforms") or []
 645      if isinstance(platforms, str):
 646          platforms = [platforms]
 647  
 648      return {
 649          "skill_name": skill_name,
 650          "category": category,
 651          "frontmatter_name": str(frontmatter.get("name", skill_name)),
 652          "description": description,
 653          "platforms": [str(p).strip() for p in platforms if str(p).strip()],
 654          "conditions": extract_skill_conditions(frontmatter),
 655      }
 656  
 657  
 658  # =========================================================================
 659  # Skills index
 660  # =========================================================================
 661  
 662  def _parse_skill_file(skill_file: Path) -> tuple[bool, dict, str]:
 663      """Read a SKILL.md once and return platform compatibility, frontmatter, and description.
 664  
 665      Returns (is_compatible, frontmatter, description). On any error, returns
 666      (True, {}, "") to err on the side of showing the skill.
 667      """
 668      try:
 669          raw = skill_file.read_text(encoding="utf-8")
 670          frontmatter, _ = parse_frontmatter(raw)
 671  
 672          if not skill_matches_platform(frontmatter):
 673              return False, frontmatter, ""
 674  
 675          return True, frontmatter, extract_skill_description(frontmatter)
 676      except Exception as e:
 677          logger.warning("Failed to parse skill file %s: %s", skill_file, e)
 678          return True, {}, ""
 679  
 680  
 681  def _skill_should_show(
 682      conditions: dict,
 683      available_tools: "set[str] | None",
 684      available_toolsets: "set[str] | None",
 685  ) -> bool:
 686      """Return False if the skill's conditional activation rules exclude it."""
 687      if available_tools is None and available_toolsets is None:
 688          return True  # No filtering info — show everything (backward compat)
 689  
 690      at = available_tools or set()
 691      ats = available_toolsets or set()
 692  
 693      # fallback_for: hide when the primary tool/toolset IS available
 694      for ts in conditions.get("fallback_for_toolsets", []):
 695          if ts in ats:
 696              return False
 697      for t in conditions.get("fallback_for_tools", []):
 698          if t in at:
 699              return False
 700  
 701      # requires: hide when a required tool/toolset is NOT available
 702      for ts in conditions.get("requires_toolsets", []):
 703          if ts not in ats:
 704              return False
 705      for t in conditions.get("requires_tools", []):
 706          if t not in at:
 707              return False
 708  
 709      return True
 710  
 711  
 712  def build_skills_system_prompt(
 713      available_tools: "set[str] | None" = None,
 714      available_toolsets: "set[str] | None" = None,
 715  ) -> str:
 716      """Build a compact skill index for the system prompt.
 717  
 718      Two-layer cache:
 719        1. In-process LRU dict keyed by (skills_dir, tools, toolsets)
 720        2. Disk snapshot (``.skills_prompt_snapshot.json``) validated by
 721           mtime/size manifest — survives process restarts
 722  
 723      Falls back to a full filesystem scan when both layers miss.
 724  
 725      External skill directories (``skills.external_dirs`` in config.yaml) are
 726      scanned alongside the local ``~/.hermes/skills/`` directory.  External dirs
 727      are read-only — they appear in the index but new skills are always created
 728      in the local dir.  Local skills take precedence when names collide.
 729      """
 730      skills_dir = get_skills_dir()
 731      external_dirs = get_all_skills_dirs()[1:]  # skip local (index 0)
 732  
 733      if not skills_dir.exists() and not external_dirs:
 734          return ""
 735  
 736      # ── Layer 1: in-process LRU cache ─────────────────────────────────
 737      # Include the resolved platform so per-platform disabled-skill lists
 738      # produce distinct cache entries (gateway serves multiple platforms).
 739      from gateway.session_context import get_session_env
 740      _platform_hint = (
 741          os.environ.get("HERMES_PLATFORM")
 742          or get_session_env("HERMES_SESSION_PLATFORM")
 743          or ""
 744      )
 745      disabled = get_disabled_skill_names()
 746      cache_key = (
 747          str(skills_dir.resolve()),
 748          tuple(str(d) for d in external_dirs),
 749          tuple(sorted(str(t) for t in (available_tools or set()))),
 750          tuple(sorted(str(ts) for ts in (available_toolsets or set()))),
 751          _platform_hint,
 752          tuple(sorted(disabled)),
 753      )
 754      with _SKILLS_PROMPT_CACHE_LOCK:
 755          cached = _SKILLS_PROMPT_CACHE.get(cache_key)
 756          if cached is not None:
 757              _SKILLS_PROMPT_CACHE.move_to_end(cache_key)
 758              return cached
 759  
 760      # ── Layer 2: disk snapshot ────────────────────────────────────────
 761      snapshot = _load_skills_snapshot(skills_dir)
 762  
 763      skills_by_category: dict[str, list[tuple[str, str]]] = {}
 764      category_descriptions: dict[str, str] = {}
 765  
 766      if snapshot is not None:
 767          # Fast path: use pre-parsed metadata from disk
 768          for entry in snapshot.get("skills", []):
 769              if not isinstance(entry, dict):
 770                  continue
 771              skill_name = entry.get("skill_name") or ""
 772              category = entry.get("category") or "general"
 773              frontmatter_name = entry.get("frontmatter_name") or skill_name
 774              platforms = entry.get("platforms") or []
 775              if not skill_matches_platform({"platforms": platforms}):
 776                  continue
 777              if frontmatter_name in disabled or skill_name in disabled:
 778                  continue
 779              if not _skill_should_show(
 780                  entry.get("conditions") or {},
 781                  available_tools,
 782                  available_toolsets,
 783              ):
 784                  continue
 785              skills_by_category.setdefault(category, []).append(
 786                  (frontmatter_name, entry.get("description", ""))
 787              )
 788          category_descriptions = {
 789              str(k): str(v)
 790              for k, v in (snapshot.get("category_descriptions") or {}).items()
 791          }
 792      else:
 793          # Cold path: full filesystem scan + write snapshot for next time
 794          skill_entries: list[dict] = []
 795          for skill_file in iter_skill_index_files(skills_dir, "SKILL.md"):
 796              is_compatible, frontmatter, desc = _parse_skill_file(skill_file)
 797              entry = _build_snapshot_entry(skill_file, skills_dir, frontmatter, desc)
 798              skill_entries.append(entry)
 799              if not is_compatible:
 800                  continue
 801              skill_name = entry["skill_name"]
 802              if entry["frontmatter_name"] in disabled or skill_name in disabled:
 803                  continue
 804              if not _skill_should_show(
 805                  extract_skill_conditions(frontmatter),
 806                  available_tools,
 807                  available_toolsets,
 808              ):
 809                  continue
 810              skills_by_category.setdefault(entry["category"], []).append(
 811                  (entry["frontmatter_name"], entry["description"])
 812              )
 813  
 814          # Read category-level DESCRIPTION.md files
 815          for desc_file in iter_skill_index_files(skills_dir, "DESCRIPTION.md"):
 816              try:
 817                  content = desc_file.read_text(encoding="utf-8")
 818                  fm, _ = parse_frontmatter(content)
 819                  cat_desc = fm.get("description")
 820                  if not cat_desc:
 821                      continue
 822                  rel = desc_file.relative_to(skills_dir)
 823                  cat = "/".join(rel.parts[:-1]) if len(rel.parts) > 1 else "general"
 824                  category_descriptions[cat] = str(cat_desc).strip().strip("'\"")
 825              except Exception as e:
 826                  logger.debug("Could not read skill description %s: %s", desc_file, e)
 827  
 828          _write_skills_snapshot(
 829              skills_dir,
 830              _build_skills_manifest(skills_dir),
 831              skill_entries,
 832              category_descriptions,
 833          )
 834  
 835      # ── External skill directories ─────────────────────────────────────
 836      # Scan external dirs directly (no snapshot caching — they're read-only
 837      # and typically small).  Local skills already in skills_by_category take
 838      # precedence: we track seen names and skip duplicates from external dirs.
 839      seen_skill_names: set[str] = set()
 840      for cat_skills in skills_by_category.values():
 841          for name, _desc in cat_skills:
 842              seen_skill_names.add(name)
 843  
 844      for ext_dir in external_dirs:
 845          if not ext_dir.exists():
 846              continue
 847          for skill_file in iter_skill_index_files(ext_dir, "SKILL.md"):
 848              try:
 849                  is_compatible, frontmatter, desc = _parse_skill_file(skill_file)
 850                  if not is_compatible:
 851                      continue
 852                  entry = _build_snapshot_entry(skill_file, ext_dir, frontmatter, desc)
 853                  skill_name = entry["skill_name"]
 854                  frontmatter_name = entry["frontmatter_name"]
 855                  if frontmatter_name in seen_skill_names:
 856                      continue
 857                  if frontmatter_name in disabled or skill_name in disabled:
 858                      continue
 859                  if not _skill_should_show(
 860                      extract_skill_conditions(frontmatter),
 861                      available_tools,
 862                      available_toolsets,
 863                  ):
 864                      continue
 865                  seen_skill_names.add(frontmatter_name)
 866                  skills_by_category.setdefault(entry["category"], []).append(
 867                      (frontmatter_name, entry["description"])
 868                  )
 869              except Exception as e:
 870                  logger.debug("Error reading external skill %s: %s", skill_file, e)
 871  
 872          # External category descriptions
 873          for desc_file in iter_skill_index_files(ext_dir, "DESCRIPTION.md"):
 874              try:
 875                  content = desc_file.read_text(encoding="utf-8")
 876                  fm, _ = parse_frontmatter(content)
 877                  cat_desc = fm.get("description")
 878                  if not cat_desc:
 879                      continue
 880                  rel = desc_file.relative_to(ext_dir)
 881                  cat = "/".join(rel.parts[:-1]) if len(rel.parts) > 1 else "general"
 882                  category_descriptions.setdefault(cat, str(cat_desc).strip().strip("'\""))
 883              except Exception as e:
 884                  logger.debug("Could not read external skill description %s: %s", desc_file, e)
 885  
 886      if not skills_by_category:
 887          result = ""
 888      else:
 889          index_lines = []
 890          for category in sorted(skills_by_category.keys()):
 891              cat_desc = category_descriptions.get(category, "")
 892              if cat_desc:
 893                  index_lines.append(f"  {category}: {cat_desc}")
 894              else:
 895                  index_lines.append(f"  {category}:")
 896              # Deduplicate and sort skills within each category
 897              seen = set()
 898              for name, desc in sorted(skills_by_category[category], key=lambda x: x[0]):
 899                  if name in seen:
 900                      continue
 901                  seen.add(name)
 902                  if desc:
 903                      index_lines.append(f"    - {name}: {desc}")
 904                  else:
 905                      index_lines.append(f"    - {name}")
 906  
 907          result = (
 908              "## Skills (mandatory)\n"
 909              "Before replying, scan the skills below. If a skill matches or is even partially relevant "
 910              "to your task, you MUST load it with skill_view(name) and follow its instructions. "
 911              "Err on the side of loading — it is always better to have context you don't need "
 912              "than to miss critical steps, pitfalls, or established workflows. "
 913              "Skills contain specialized knowledge — API endpoints, tool-specific commands, "
 914              "and proven workflows that outperform general-purpose approaches. Load the skill "
 915              "even if you think you could handle the task with basic tools like web_search or terminal. "
 916              "Skills also encode the user's preferred approach, conventions, and quality standards "
 917              "for tasks like code review, planning, and testing — load them even for tasks you "
 918              "already know how to do, because the skill defines how it should be done here.\n"
 919              "Whenever the user asks you to configure, set up, install, enable, disable, modify, "
 920              "or troubleshoot Hermes Agent itself — its CLI, config, models, providers, tools, "
 921              "skills, voice, gateway, plugins, or any feature — load the `hermes-agent` skill "
 922              "first. It has the actual commands (e.g. `hermes config set …`, `hermes tools`, "
 923              "`hermes setup`) so you don't have to guess or invent workarounds.\n"
 924              "If a skill has issues, fix it with skill_manage(action='patch').\n"
 925              "After difficult/iterative tasks, offer to save as a skill. "
 926              "If a skill you loaded was missing steps, had wrong commands, or needed "
 927              "pitfalls you discovered, update it before finishing.\n"
 928              "\n"
 929              "<available_skills>\n"
 930              + "\n".join(index_lines) + "\n"
 931              "</available_skills>\n"
 932              "\n"
 933              "Only proceed without loading a skill if genuinely none are relevant to the task."
 934          )
 935  
 936      # ── Store in LRU cache ────────────────────────────────────────────
 937      with _SKILLS_PROMPT_CACHE_LOCK:
 938          _SKILLS_PROMPT_CACHE[cache_key] = result
 939          _SKILLS_PROMPT_CACHE.move_to_end(cache_key)
 940          while len(_SKILLS_PROMPT_CACHE) > _SKILLS_PROMPT_CACHE_MAX:
 941              _SKILLS_PROMPT_CACHE.popitem(last=False)
 942  
 943      return result
 944  
 945  
 946  def build_nous_subscription_prompt(valid_tool_names: "set[str] | None" = None) -> str:
 947      """Build a compact Nous subscription capability block for the system prompt."""
 948      try:
 949          from hermes_cli.nous_subscription import get_nous_subscription_features
 950          from tools.tool_backend_helpers import managed_nous_tools_enabled
 951      except Exception as exc:
 952          logger.debug("Failed to import Nous subscription helper: %s", exc)
 953          return ""
 954  
 955      if not managed_nous_tools_enabled():
 956          return ""
 957  
 958      valid_names = set(valid_tool_names or set())
 959      relevant_tool_names = {
 960          "web_search",
 961          "web_extract",
 962          "browser_navigate",
 963          "browser_snapshot",
 964          "browser_click",
 965          "browser_type",
 966          "browser_scroll",
 967          "browser_console",
 968          "browser_press",
 969          "browser_get_images",
 970          "browser_vision",
 971          "image_generate",
 972          "text_to_speech",
 973          "terminal",
 974          "process",
 975          "execute_code",
 976      }
 977  
 978      if valid_names and not (valid_names & relevant_tool_names):
 979          return ""
 980  
 981      features = get_nous_subscription_features()
 982  
 983      def _status_line(feature) -> str:
 984          if feature.managed_by_nous:
 985              return f"- {feature.label}: active via Nous subscription"
 986          if feature.active:
 987              current = feature.current_provider or "configured provider"
 988              return f"- {feature.label}: currently using {current}"
 989          if feature.included_by_default and features.nous_auth_present:
 990              return f"- {feature.label}: included with Nous subscription, not currently selected"
 991          if feature.key == "modal" and features.nous_auth_present:
 992              return f"- {feature.label}: optional via Nous subscription"
 993          return f"- {feature.label}: not currently available"
 994  
 995      lines = [
 996          "# Nous Subscription",
 997          "Nous subscription includes managed web tools (Firecrawl), image generation (FAL), OpenAI TTS, and browser automation (Browser Use) by default. Modal execution is optional.",
 998          "Current capability status:",
 999      ]
1000      lines.extend(_status_line(feature) for feature in features.items())
1001      lines.extend(
1002          [
1003              "When a Nous-managed feature is active, do not ask the user for Firecrawl, FAL, OpenAI TTS, or Browser-Use API keys.",
1004              "If the user is not subscribed and asks for a capability that Nous subscription would unlock or simplify, suggest Nous subscription as one option alongside direct setup or local alternatives.",
1005              "Do not mention subscription unless the user asks about it or it directly solves the current missing capability.",
1006              "Useful commands: hermes setup, hermes setup tools, hermes setup terminal, hermes status.",
1007          ]
1008      )
1009      return "\n".join(lines)
1010  
1011  
1012  # =========================================================================
1013  # Context files (SOUL.md, AGENTS.md, .cursorrules)
1014  # =========================================================================
1015  
1016  def _truncate_content(content: str, filename: str, max_chars: int = CONTEXT_FILE_MAX_CHARS) -> str:
1017      """Head/tail truncation with a marker in the middle."""
1018      if len(content) <= max_chars:
1019          return content
1020      head_chars = int(max_chars * CONTEXT_TRUNCATE_HEAD_RATIO)
1021      tail_chars = int(max_chars * CONTEXT_TRUNCATE_TAIL_RATIO)
1022      head = content[:head_chars]
1023      tail = content[-tail_chars:]
1024      marker = f"\n\n[...truncated {filename}: kept {head_chars}+{tail_chars} of {len(content)} chars. Use file tools to read the full file.]\n\n"
1025      return head + marker + tail
1026  
1027  
1028  def load_soul_md() -> Optional[str]:
1029      """Load SOUL.md from HERMES_HOME and return its content, or None.
1030  
1031      Used as the agent identity (slot #1 in the system prompt).  When this
1032      returns content, ``build_context_files_prompt`` should be called with
1033      ``skip_soul=True`` so SOUL.md isn't injected twice.
1034      """
1035      try:
1036          from hermes_cli.config import ensure_hermes_home
1037          ensure_hermes_home()
1038      except Exception as e:
1039          logger.debug("Could not ensure HERMES_HOME before loading SOUL.md: %s", e)
1040  
1041      soul_path = get_hermes_home() / "SOUL.md"
1042      if not soul_path.exists():
1043          return None
1044      try:
1045          content = soul_path.read_text(encoding="utf-8").strip()
1046          if not content:
1047              return None
1048          content = _scan_context_content(content, "SOUL.md")
1049          content = _truncate_content(content, "SOUL.md")
1050          return content
1051      except Exception as e:
1052          logger.debug("Could not read SOUL.md from %s: %s", soul_path, e)
1053          return None
1054  
1055  
1056  def _load_hermes_md(cwd_path: Path) -> str:
1057      """.hermes.md / HERMES.md — walk to git root."""
1058      hermes_md_path = _find_hermes_md(cwd_path)
1059      if not hermes_md_path:
1060          return ""
1061      try:
1062          content = hermes_md_path.read_text(encoding="utf-8").strip()
1063          if not content:
1064              return ""
1065          content = _strip_yaml_frontmatter(content)
1066          rel = hermes_md_path.name
1067          try:
1068              rel = str(hermes_md_path.relative_to(cwd_path))
1069          except ValueError:
1070              pass
1071          content = _scan_context_content(content, rel)
1072          result = f"## {rel}\n\n{content}"
1073          return _truncate_content(result, ".hermes.md")
1074      except Exception as e:
1075          logger.debug("Could not read %s: %s", hermes_md_path, e)
1076          return ""
1077  
1078  
1079  def _load_agents_md(cwd_path: Path) -> str:
1080      """AGENTS.md — top-level only (no recursive walk)."""
1081      for name in ["AGENTS.md", "agents.md"]:
1082          candidate = cwd_path / name
1083          if candidate.exists():
1084              try:
1085                  content = candidate.read_text(encoding="utf-8").strip()
1086                  if content:
1087                      content = _scan_context_content(content, name)
1088                      result = f"## {name}\n\n{content}"
1089                      return _truncate_content(result, "AGENTS.md")
1090              except Exception as e:
1091                  logger.debug("Could not read %s: %s", candidate, e)
1092      return ""
1093  
1094  
1095  def _load_claude_md(cwd_path: Path) -> str:
1096      """CLAUDE.md / claude.md — cwd only."""
1097      for name in ["CLAUDE.md", "claude.md"]:
1098          candidate = cwd_path / name
1099          if candidate.exists():
1100              try:
1101                  content = candidate.read_text(encoding="utf-8").strip()
1102                  if content:
1103                      content = _scan_context_content(content, name)
1104                      result = f"## {name}\n\n{content}"
1105                      return _truncate_content(result, "CLAUDE.md")
1106              except Exception as e:
1107                  logger.debug("Could not read %s: %s", candidate, e)
1108      return ""
1109  
1110  
1111  def _load_cursorrules(cwd_path: Path) -> str:
1112      """.cursorrules + .cursor/rules/*.mdc — cwd only."""
1113      cursorrules_content = ""
1114      cursorrules_file = cwd_path / ".cursorrules"
1115      if cursorrules_file.exists():
1116          try:
1117              content = cursorrules_file.read_text(encoding="utf-8").strip()
1118              if content:
1119                  content = _scan_context_content(content, ".cursorrules")
1120                  cursorrules_content += f"## .cursorrules\n\n{content}\n\n"
1121          except Exception as e:
1122              logger.debug("Could not read .cursorrules: %s", e)
1123  
1124      cursor_rules_dir = cwd_path / ".cursor" / "rules"
1125      if cursor_rules_dir.exists() and cursor_rules_dir.is_dir():
1126          mdc_files = sorted(cursor_rules_dir.glob("*.mdc"))
1127          for mdc_file in mdc_files:
1128              try:
1129                  content = mdc_file.read_text(encoding="utf-8").strip()
1130                  if content:
1131                      content = _scan_context_content(content, f".cursor/rules/{mdc_file.name}")
1132                      cursorrules_content += f"## .cursor/rules/{mdc_file.name}\n\n{content}\n\n"
1133              except Exception as e:
1134                  logger.debug("Could not read %s: %s", mdc_file, e)
1135  
1136      if not cursorrules_content:
1137          return ""
1138      return _truncate_content(cursorrules_content, ".cursorrules")
1139  
1140  
1141  def build_context_files_prompt(cwd: Optional[str] = None, skip_soul: bool = False) -> str:
1142      """Discover and load context files for the system prompt.
1143  
1144      Priority (first found wins — only ONE project context type is loaded):
1145        1. .hermes.md / HERMES.md  (walk to git root)
1146        2. AGENTS.md / agents.md   (cwd only)
1147        3. CLAUDE.md / claude.md   (cwd only)
1148        4. .cursorrules / .cursor/rules/*.mdc  (cwd only)
1149  
1150      SOUL.md from HERMES_HOME is independent and always included when present.
1151      Each context source is capped at 20,000 chars.
1152  
1153      When *skip_soul* is True, SOUL.md is not included here (it was already
1154      loaded via ``load_soul_md()`` for the identity slot).
1155      """
1156      if cwd is None:
1157          cwd = os.getcwd()
1158  
1159      cwd_path = Path(cwd).resolve()
1160      sections = []
1161  
1162      # Priority-based project context: first match wins
1163      project_context = (
1164          _load_hermes_md(cwd_path)
1165          or _load_agents_md(cwd_path)
1166          or _load_claude_md(cwd_path)
1167          or _load_cursorrules(cwd_path)
1168      )
1169      if project_context:
1170          sections.append(project_context)
1171  
1172      # SOUL.md from HERMES_HOME only — skip when already loaded as identity
1173      if not skip_soul:
1174          soul_content = load_soul_md()
1175          if soul_content:
1176              sections.append(soul_content)
1177  
1178      if not sections:
1179          return ""
1180      return "# Project Context\n\nThe following project context files have been loaded and should be followed:\n\n" + "\n".join(sections)