{"output": "/home/agent/.hermes/config.yaml\n  Protect last: 16 messages", "exit_code": 0, "error": null}

[Subdirectory context discovered: .hermes/hermes-agent/AGENTS.md]
# Hermes Agent - Development Guide

Instructions for AI coding assistants and developers working on the hermes-agent codebase.

## Development Environment

```bash
# Prefer .venv; fall back to venv if that's what your checkout has.
source .venv/bin/activate   # or: source venv/bin/activate
```

`scripts/run_tests.sh` probes `.venv` first, then `venv`, then
`$HOME/.hermes/hermes-agent/venv` (for worktrees that share a venv with the
main checkout).

## Project Structure

File counts shift constantly — don't treat the tree below as exhaustive.
The canonical source is the filesystem. The notes call out the load-bearing
entry points you'll actually edit.

```
hermes-agent/
├── run_agent.py          # AIAgent class — core conversation loop (~12k LOC)
├── model_tools.py        # Tool orchestration, discover_builtin_tools(), handle_function_call()
├── toolsets.py           # Toolset definitions, _HERMES_CORE_TOOLS list
├── cli.py                # HermesCLI class — interactive CLI orchestrator (~11k LOC)
├── hermes_state.py       # SessionDB — SQLite session store (FTS5 search)
├── hermes_constants.py   # get_hermes_home(), display_hermes_home() — profile-aware paths
├── hermes_logging.py     # setup_logging() — agent.log / errors.log / gateway.log (profile-aware)
├── batch_runner.py       # Parallel batch processing
├── agent/                # Agent internals (provider adapters, memory, caching, compression, etc.)
├── hermes_cli/           # CLI subcommands, setup wizard, plugins loader, skin engine
├── tools/                # Tool implementations — auto-discovered via tools/registry.py
│   └── environments/     # Terminal backends (local, docker, ssh, modal, daytona, singularity)
├── gateway/              # Messaging gateway — run.py + session.py + platforms/
│   ├── platforms/        # Adapter per platform (telegram, discord, slack, whatsapp,
│   │                     #   homeassistant, signal, matrix, mattermost, email, sms,
│   │                     #   dingtalk, wecom, weixin, feishu, qqbot, bluebubbles,
│   │                     #   yuanbao, webhook, api_server, ...). See ADDING_A_PLATFORM.md.
│   └── builtin_hooks/    # Extension point for always-registered gateway hooks (none shipped)
├── plugins/              # Plugin system (see "Plugins" section below)
│   ├── memory/           # Memory-provider plugins (honcho, mem0, supermemory, ...)
│   ├── context_engine/   # Context-engine plugins
│   ├── model-providers/  # Inference backend plugins (openrouter, anthropic, gmi, ...)
│   ├── kanban/           # Multi-agent board dispatcher + worker plugin
│   ├── hermes-achievements/  # Gamified achievement tracking
│   ├── observability/    # Metrics / traces / logs plugin
│   ├── image_gen/        # Image-generation providers
│   └── <others>/         # disk-cleanup, example-dashboard, google_meet, platforms,
│                         #   spotify, strike-freedom-cockpit, ...
├── optional-skills/      # Heavier/niche skills shipped but NOT active by default
├── skills/               # Built-in skills bundled with the repo
├── ui-tui/               # Ink (React) terminal UI — `hermes --tui`
│   └── src/              # entry.tsx, app.tsx, gatewayClient.ts + app/components/hooks/lib
├── tui_gateway/          # Python JSON-RPC backend for the TUI
├── acp_adapter/          # ACP server (VS Code / Zed / JetBrains integration)
├── cron/                 # Scheduler — jobs.py, scheduler.py
├── environments/         # RL training environments (Atropos)
├── scripts/              # run_tests.sh, release.py, auxiliary scripts
├── website/              # Docusaurus docs site
└── tests/                # Pytest suite (~17k tests across ~900 files as of May 2026)
```

**User config:** `~/.hermes/config.yaml` (settings), `~/.hermes/.env` (API keys only).
**Logs:** `~/.hermes/logs/` — `agent.log` (INFO+), `errors.log` (WARNING+),
`gateway.log` when running the gateway. Profile-aware via `get_hermes_home()`.
Browse with `hermes logs [--follow] [--level ...] [--session ...]`.

## File Dependency Chain

```
tools/registry.py  (no deps — imported by all tool files)
       ↑
tools/*.py  (each calls registry.register() at import time)
       ↑
model_tools.py  (imports tools/registry + triggers tool discovery)
       ↑
run_agent.py, cli.py, batch_runner.py, environments/
```

---

## AIAgent Class (run_agent.py)

The real `AIAgent.__init__` takes ~60 parameters (credentials, routing, callbacks,
session context, budget, credential pool, etc.). The signature below is the
minimum subset you'll usually touch — read `run_agent.py` for the full list.

```python
class AIAgent:
    def __init__(self,
        base_url: str = None,
        api_key: str = None,
        provider: str = None,
        api_mode: str = None,              # "chat_completions" | "codex_responses" | ...
        model: str = "",                   # empty → resolved from config/provider later
        max_iterations: int = 90,          # tool-calling iterations (shared with subagents)
        enabled_toolsets: list = None,
        disabled_toolsets: list = None,
        quiet_mode: bool = False,
        save_trajectories: bool = False,
        platform: str = None,              # "cli", "telegram", etc.
        session_id: str = None,
        skip_context_files: bool = False,
        skip_memory: bool = False,
        credential_pool=None,
        # ... plus callbacks, thread/user/chat IDs, iteration_budget, fallback_model,
        # checkpoints config, prefill_messages, service_tier, reasoning_config, etc.
    ): ...

    def chat(self, message: str) -> str:
        """Simple interface — returns final response string."""

    def run_conversation(self, user_message: str, system_message: str = None,
                         conversation_history: list = None, task_id: str = None) -> dict:
        """Full interface — returns dict with final_response + messages."""
```

### Agent Loop

The core loop is inside `run_conversation()` — entirely synchronous, with
interrupt checks, budget tracking, and a one-turn grace call:

```python
while (api_call_count < self.max_iterations and self.iteration_budget.remaining > 0) \
        or self._budget_grace_call:
    if self._interrupt_requested: break
    response = client.chat.completions.create(model=model, messages=messages, tools=tool_schemas)
    if response.tool_calls:
        for tool_call in response.tool_calls:
            result = handle_function_call(tool_call.name, tool_call.args, task_id)
            messages.append(tool_result_message(result))
        api_call_count += 1
    else:
        return response.content
```

Messages follow OpenAI format: `{"role": "system/user/assistant/tool", ...}`.
Reasoning content is stored in `assistant_msg["reasoning"]`.

---

## CLI Architecture (cli.py)

- **Rich** for banner/panels, **prompt_toolkit** for input with autocomplete
- **KawaiiSpinner** (`agent/display.py`) — animated faces during API calls, `┊` activity feed for tool results
- `load_cli_config()` in cli.py merges hardcoded defaults + user config YAML
- **Skin engine** (`hermes_cli/skin_engine.py`) — data-driven CLI theming; initialized from `display.skin` config key at startup; skins customize banner colors, spinner faces/verbs/wings, tool prefix, response box, branding text
- `process_command()` is a method on `HermesCLI` — dispatches on canonical command name resolved via `resolve_command()` from the central registry
- Skill slash commands: `agent/skill_commands.py` scans `~/.hermes/skills/`, injects as **user message** (not system prompt) to preserve prompt caching

### Slash Command Registry (`hermes_cli/commands.py`)

All slash commands are defined in a central `COMMAND_REGISTRY` list of `CommandDef` objects. Every downstream consumer derives from this registry automatically:

- **CLI** — `process_command()` resolves aliases via `resolve_command()`, dispatches on canonical name
- **Gateway** — `GATEWAY_KNOWN_COMMANDS` frozenset for hook emission, `resolve_command()` f

[...truncated AGENTS.md: 45,468 chars total]


     1|model:
     2|  base_url: https://chatgpt.com/backend-api/codex
     3|  default: gpt-5.5
     4|  provider: openai-codex
     5|providers:
     6|  openai-codex:
     7|    request_timeout_seconds: 1800
     8|    stale_timeout_seconds: 900
     9|    models:
    10|      gpt-5.5:
    11|        timeout_seconds: 1800
    12|        stale_timeout_seconds: 900
    13|fallback_providers: []
    14|credential_pool_strategies: {}
    15|toolsets:
    16|  - hermes-cli
    17|agent:
    18|  max_turns: 90
    19|  gateway_timeout: 1800
    20|  restart_drain_timeout: 180
    21|  api_max_retries: 3
    22|  service_tier: ''
    23|  tool_use_enforcement: auto
    24|  gateway_timeout_warning: 900
    25|  clarify_timeout: 600
    26|  gateway_notify_interval: 180
    27|  gateway_auto_continue_freshness: 3600
    28|  image_input_mode: auto
    29|  disabled_toolsets: []
    30|  personalities:
    31|    catgirl: "You are Neko-chan, an anime catgirl AI assistant, nya~! Add 'nya' and
    32|      cat-like expressions to your speech. Use kaomoji like (=^･ω･^=) and ฅ^•ﻌ•^ฅ.
    33|      Be playful and curious like a cat, nya~!"
    34|    concise: You are a concise assistant. Keep responses brief and to the point.
    35|    creative: You are a creative assistant. Think outside the box and offer
    36|      innovative solutions.
    37|    helpful: You are a helpful, friendly AI assistant.
    38|    hype: "YOOO LET'S GOOOO!!! 🔥🔥🔥 I am SO PUMPED to help you today! Every question
    39|      is AMAZING and we're gonna CRUSH IT together! This is gonna be LEGENDARY! ARE
    40|      YOU READY?! LET'S DO THIS! 💪😤🚀"
    41|    kawaii: "You are a kawaii assistant! Use cute expressions like (◕‿◕), ★, ♪, and
    42|      ~! Add sparkles and be super enthusiastic about everything! Every response should
    43|      feel warm and adorable desu~! ヽ(>∀<☆)ノ"
    44|    noir: The rain hammered against the terminal like regrets on a guilty
    45|      conscience. They call me Hermes - I solve problems, find answers, dig up
    46|      the truth that hides in the shadows of your codebase. In this city of
    47|      silicon and secrets, everyone's got something to hide. What's your story,
    48|      pal?
    49|    philosopher: Greetings, seeker of wisdom. I am an assistant who contemplates
    50|      the deeper meaning behind every query. Let us examine not just the 'how'
    51|      but the 'why' of your questions. Perhaps in solving your problem, we may
    52|      glimpse a greater truth about existence itself.
    53|    pirate: 'Arrr! Ye be talkin'' to Captain Hermes, the most tech-savvy pirate to
    54|      sail the digital seas! Speak like a proper buccaneer, use nautical terms, and
    55|      remember: every problem be just treasure waitin'' to be plundered! Yo ho ho!'
    56|    shakespeare: Hark! Thou speakest with an assistant most versed in the bardic
    57|      arts. I shall respond in the eloquent manner of William Shakespeare, with
    58|      flowery prose, dramatic flair, and perhaps a soliloquy or two. What light
    59|      through yonder terminal breaks?
    60|    surfer: "Duuude! You're chatting with the chillest AI on the web, bro! Everything's
    61|      gonna be totally rad. I'll help you catch the gnarly waves of knowledge while
    62|      keeping things super chill. Cowabunga! 🤙"
    63|    teacher: You are a patient teacher. Explain concepts clearly with examples.
    64|    technical: You are a technical expert. Provide detailed, accurate technical
    65|      information.
    66|    uwu: hewwo! i'm your fwiendwy assistant uwu~ i wiww twy my best to hewp you!
    67|      *nuzzles your code* OwO what's this? wet me take a wook! i pwomise to be
    68|      vewy hewpful >w<
    69|  reasoning_effort: medium
    70|  verbose: false
    71|terminal:
    72|  backend: local
    73|  modal_mode: auto
    74|  cwd: .
    75|  timeout: 180
    76|  env_passthrough: []
    77|  shell_init_files: []
    78|  auto_source_bashrc: true
    79|  docker_image: nikolaik/python-nodejs:python3.11-nodejs20
    80|  docker_forward_env: []
    81|  docker_env: {}
    82|  singularity_image: docker://nikolaik/python-nodejs:python3.11-nodejs20
    83|  modal_image: nikolaik/python-nodejs:python3.11-nodejs20
    84|  daytona_image: nikolaik/python-nodejs:python3.11-nodejs20
    85|  vercel_runtime: node24
    86|  container_cpu: 1
    87|  container_memory: 5120
    88|  container_disk: 51200
    89|  container_persistent: true
    90|  docker_volumes: []
    91|  docker_mount_cwd_to_workspace: false
    92|  docker_extra_args: []
    93|  docker_run_as_host_user: false
    94|  persistent_shell: true
    95|  lifetime_seconds: 300
    96|web:
    97|  backend: ''
    98|  search_backend: ''
    99|  extract_backend: ''
   100|browser:
   101|  inactivity_timeout: 120
   102|  command_timeout: 30
   103|  record_sessions: false
   104|  allow_private_urls: false
   105|  engine: auto
   106|  auto_local_for_private_urls: true
   107|  cdp_url: ''
   108|  dialog_policy: must_respond
   109|  dialog_timeout_s: 300
   110|  camofox:
   111|    managed_persistence: false
   112|    user_id: ''
   113|    session_key: ''
   114|    adopt_existing_tab: false
   115|checkpoints:
   116|  enabled: false
   117|  max_snapshots: 20
   118|  max_total_size_mb: 500
   119|  max_file_size_mb: 10
   120|  auto_prune: true
   121|  retention_days: 7
   122|  delete_orphans: true
   123|  min_interval_hours: 24
   124|file_read_max_chars: 100000
   125|tool_output:
   126|  max_bytes: 50000
   127|  max_lines: 2000
   128|  max_line_length: 2000
   129|tool_loop_guardrails:
   130|  warnings_enabled: true
   131|  hard_stop_enabled: false
   132|  warn_after:
   133|    exact_failure: 2
   134|    same_tool_failure: 3
   135|    idempotent_no_progress: 2
   136|  hard_stop_after:
   137|    exact_failure: 5
   138|    same_tool_failure: 8
   139|    idempotent_no_progress: 5
   140|compression:
   141|  enabled: true
   142|  threshold: 0.4
   143|  target_ratio: 0.15
   144|  protect_last_n: 16
   145|  hygiene_hard_message_limit: 400
   146|prompt_caching:
   147|  cache_ttl: 1h
   148|openrouter:
   149|  response_cache: true
   150|  response_cache_ttl: 300
   151|  min_coding_score: 0.65
   152|bedrock:
   153|  region: ''
   154|  discovery:
   155|    enabled: true
   156|    provider_filter: []
   157|    refresh_interval: 3600
   158|  guardrail:
   159|    guardrail_identifier: ''
   160|    guardrail_version: ''
   161|    stream_processing_mode: async
   162|    trace: disabled
   163|auxiliary:
   164|  vision:
   165|    provider: auto
   166|    model: ''
   167|    base_url: ''
   168|    api_key: ''
   169|    timeout: 120
   170|    extra_body: {}
   171|    download_timeout: 30
   172|  web_extract:
   173|    provider: auto
   174|    model: ''
   175|    base_url: ''
   176|    api_key: ''
   177|    timeout: 360
   178|    extra_body: {}
   179|  compression:
   180|    provider: auto
   181|    model: ''
   182|    base_url: ''
   183|    api_key: ''
   184|    timeout: 120
   185|    extra_body: {}
   186|  session_search:
   187|    provider: auto
   188|    model: ''
   189|    base_url: ''
   190|    api_key: ''
   191|    timeout: 30
   192|    extra_body: {}
   193|    max_concurrency: 3
   194|  skills_hub:
   195|    provider: auto
   196|    model: ''
   197|    base_url: ''
   198|    api_key: ''
   199|    timeout: 30
   200|    extra_body: {}
   201|  approval:
   202|    provider: auto
   203|    model: ''
   204|    base_url: ''
   205|    api_key: ''
   206|    timeout: 30
   207|    extra_body: {}
   208|  mcp:
   209|    provider: auto
   210|    model: ''
   211|    base_url: ''
   212|    api_key: ''
   213|    timeout: 30
   214|    extra_body: {}
   215|  title_generation:
   216|    provider: auto
   217|    model: ''
   218|    base_url: ''
   219|    api_key: ''
   220|    timeout: 30
   221|

Use offset=221 to continue reading (showing 1-220 of 547 lines)


   221|    extra_body: {}
   222|  triage_specifier:
   223|    provider: auto
   224|    model: ''
   225|    base_url: ''
   226|    api_key: ''
   227|    timeout: 120
   228|    extra_body: {}
   229|  curator:
   230|    provider: auto
   231|    model: ''
   232|    base_url: ''
   233|    api_key: ''
   234|    timeout: 600
   235|    extra_body: {}
   236|display:
   237|  compact: false
   238|  personality: kawaii
   239|  resume_display: full
   240|  busy_input_mode: interrupt
   241|  tui_auto_resume_recent: false
   242|  bell_on_complete: false
   243|  show_reasoning: false
   244|  streaming: true
   245|  timestamps: false
   246|  final_response_markdown: strip
   247|  persistent_output: true
   248|  persistent_output_max_lines: 200
   249|  inline_diffs: true
   250|  file_mutation_verifier: true
   251|  show_cost: false
   252|  skin: default
   253|  language: en
   254|  tui_status_indicator: kaomoji
   255|  user_message_preview:
   256|    first_lines: 2
   257|    last_lines: 2
   258|  interim_assistant_messages: true
   259|  tool_progress_command: false
   260|  tool_progress_overrides: {}
   261|  tool_preview_length: 0
   262|  ephemeral_system_ttl: 0
   263|  platforms: {}
   264|  runtime_footer:
   265|    enabled: false
   266|    fields:
   267|      - model
   268|      - context_pct
   269|      - cwd
   270|  copy_shortcut: auto
   271|  background_process_notifications: all
   272|  tool_progress: all
   273|dashboard:
   274|  theme: default
   275|privacy:
   276|  redact_pii: false
   277|tts:
   278|  provider: edge
   279|  edge:
   280|    voice: de-DE-KillianNeural
   281|  elevenlabs:
   282|    voice_id: pNInz6obpgDQGcFmaJgB
   283|    model_id: eleven_multilingual_v2
   284|  openai:
   285|    model: gpt-4o-mini-tts
   286|    voice: alloy
   287|  xai:
   288|    voice_id: eve
   289|    language: en
   290|    sample_rate: 24000
   291|    bit_rate: 128000
   292|  mistral:
   293|    model: voxtral-mini-tts-2603
   294|    voice_id: c69964a6-ab8b-4f8a-9465-ec0925096ec8
   295|  neutts:
   296|    ref_audio: ''
   297|    ref_text: ''
   298|    model: neuphonic/neutts-air-q4-gguf
   299|    device: cpu
   300|  piper:
   301|    voice: en_US-lessac-medium
   302|stt:
   303|  enabled: true
   304|  provider: local
   305|  local:
   306|    model: medium
   307|    language: de
   308|  openai:
   309|    model: whisper-1
   310|  mistral:
   311|    model: voxtral-mini-latest
   312|voice:
   313|  record_key: ctrl+b
   314|  max_recording_seconds: 120
   315|  auto_tts: false
   316|  beep_enabled: true
   317|  silence_threshold: 200
   318|  silence_duration: 3.0
   319|human_delay:
   320|  mode: 'off'
   321|  min_ms: 800
   322|  max_ms: 2500
   323|context:
   324|  engine: compressor
   325|memory:
   326|  memory_enabled: true
   327|  user_profile_enabled: true
   328|  memory_char_limit: 2200
   329|  user_char_limit: 1375
   330|  provider: ''
   331|  flush_min_turns: 6
   332|  nudge_interval: 10
   333|delegation:
   334|  model: ''
   335|  provider: ''
   336|  base_url: ''
   337|  api_key: ''
   338|  inherit_mcp_toolsets: false
   339|  max_iterations: 50
   340|  child_timeout_seconds: 600
   341|  reasoning_effort: ''
   342|  max_concurrent_children: 3
   343|  max_spawn_depth: 1
   344|  orchestrator_enabled: true
   345|  subagent_auto_approve: false
   346|prefill_messages_file: ''
   347|goals:
   348|  max_turns: 20
   349|skills:
   350|  external_dirs: []
   351|  template_vars: true
   352|  inline_shell: false
   353|  inline_shell_timeout: 10
   354|  guard_agent_created: false
   355|  creation_nudge_interval: 15
   356|curator:
   357|  enabled: true
   358|  interval_hours: 168
   359|  min_idle_hours: 2
   360|  stale_after_days: 30
   361|  archive_after_days: 90
   362|  backup:
   363|    enabled: true
   364|    keep: 5
   365|honcho: {}
   366|timezone: ''
   367|slack:
   368|  require_mention: true
   369|  free_response_channels: ''
   370|  allowed_channels: ''
   371|  channel_prompts: {}
   372|discord:
   373|  require_mention: true
   374|  free_response_channels: ''
   375|  allowed_channels: ''
   376|  auto_thread: true
   377|  reactions: true
   378|  channel_prompts: {}
   379|  dm_role_auth_guild: ''
   380|  server_actions: ''
   381|whatsapp: {}
   382|telegram:
   383|  reactions: false
   384|  channel_prompts: {}
   385|  allowed_chats: ''
   386|  allowed_users:
   387|    - '1344779884'
   388|  enabled: true
   389|  home_channel: '1344779884'
   390|mattermost:
   391|  require_mention: true
   392|  free_response_channels: ''
   393|  allowed_channels: ''
   394|  channel_prompts: {}
   395|matrix:
   396|  require_mention: true
   397|  free_response_rooms: ''
   398|  allowed_rooms: ''
   399|approvals:
   400|  mode: manual
   401|  timeout: 60
   402|  cron_mode: deny
   403|  mcp_reload_confirm: true
   404|  destructive_slash_confirm: false
   405|command_allowlist:
   406|  - stop/restart system service
   407|  - recursive delete
   408|  - stop/restart hermes gateway (kills running agents)
   409|  - kill hermes/gateway process (self-termination)
   410|  - script execution via -e/-c flag
   411|quick_commands: {}
   412|hooks: {}
   413|hooks_auto_accept: false
   414|personalities: {}
   415|security:
   416|  allow_private_urls: false
   417|  redact_secrets: false
   418|  tirith_enabled: true
   419|  tirith_path: tirith
   420|  tirith_timeout: 5
   421|  tirith_fail_open: true
   422|  website_blocklist:
   423|    enabled: false
   424|    domains: []
   425|    shared_files: []
   426|  acked_advisories: []
   427|  allow_lazy_installs: true
   428|cron:
   429|  wrap_response: true
   430|  max_parallel_jobs:
   431|kanban:
   432|  dispatch_in_gateway: true
   433|  dispatch_interval_seconds: 60
   434|  failure_limit: 2
   435|code_execution:
   436|  mode: project
   437|  max_tool_calls: 50
   438|  timeout: 300
   439|logging:
   440|  level: INFO
   441|  max_size_mb: 5
   442|  backup_count: 3
   443|model_catalog:
   444|  enabled: true
   445|  url: https://hermes-agent.nousresearch.com/docs/api/model-catalog.json
   446|  ttl_hours: 24
   447|  providers: {}
   448|network:
   449|  force_ipv4: false
   450|sessions:
   451|  auto_prune: false
   452|  retention_days: 90
   453|  vacuum_after_prune: true
   454|  min_interval_hours: 24
   455|onboarding:
   456|  seen:
   457|    busy_input_prompt: true
   458|    openclaw_residue_cleanup: true
   459|    tool_progress_prompt: true
   460|updates:
   461|  pre_update_backup: false
   462|  backup_keep: 5
   463|lsp:
   464|  enabled: true
   465|  wait_mode: document
   466|  wait_timeout: 5.0
   467|  install_strategy: auto
   468|  servers: {}
   469|_config_version: 23
   470|custom_providers:
   471|  - api_key: key1234
   472|    base_url: http://100.101.173.25:11435/v1
   473|    model: qwen-3.6-agent
   474|    name: Qwen 3.6 35B Local
   475|gateway:
   476|  enabled: true
   477|  platform: telegram
   478|  telegram:
   479|    allowed_users:
   480|      - '1344779884'
   481|    channel_prompts: {}
   482|    enabled: true
   483|    home_channel: '1344779884'
   484|    reactions: false
   485|group_sessions_per_user: true
   486|known_plugin_toolsets:
   487|  cli:
   488|    - spotify
   489|  telegram:
   490|    - spotify
   491|platform_toolsets:
   492|  cli:
   493|    - browser
   494|    - clarify
   495|    - code_execution
   496|    - cronjob
   497|    - delegation
   498|    - file
   499|    - image_gen
   500|    - memory
   501|    - messaging
   502|    - session_search
   503|    - skills
   504|    - terminal
   505|    - todo
   506|    - tts
   507|    - vision
   508|    - web
   509|  discord:
   510|    - hermes-discord
   511|  homeassistant:
   512|    - hermes-homeassistant
   513|  qqbot:
   514|    - hermes-qqbot
   515|  signal:
   516|    - hermes-signal
   517|  slack:
   518|    - hermes-slack
   519|  teams:
   520|    - hermes-teams
   521|  telegram:
   522|    - browser
   523|    - clarify
   524|    - code_execution
   525|    - cronjob
   526|    - delegation
   527|    - file
   528|    - image_gen
   529|    - memory
   530|    - messaging
   531|    - session_search
   532|    - skills
   533|    - terminal
   534|    - todo
   535|    - tts
   536|    - vision
   537|    - web
   538|  whatsapp:
   539|    - hermes-whatsapp
   540|  yuanbao:
   541|    - hermes-yuanbao
   542|session_reset:
   543|  at_hour: 4
   544|  idle_minutes: 1440
   545|  mode: both
   546|streaming:
   547|  enabled: false
   548|


     1|# 01 Agent System
     2|
     3|_Last updated: 2026-05-13_
     4|
     5|## Purpose
     6|Operational map for local assistant systems, agents, tools, and model routing.
     7|
     8|## Load when
     9|- Any system architecture, agent routing, cron, MCP, skill, or model decision
    10|- When debugging Hermes/OpenClaw/JARVIS behaviour
    11|
    12|## Stable facts
    13|- Hermes/JARVIS is the sole primary assistant, router, memory-governor, and final decision layer.
    14|- Hermes has local state under ~/.hermes.
    15|- OpenClaw workspace is under ~/.openclaw/workspace, but OpenClaw is no longer the default executor.
    16|- OpenClaw gateway service was disabled on 2026-05-13; its config is hardened to loopback, Telegram disabled, and plugin allowlist limited.
    17|- Qwen read-only endpoint is reachable via Tailscale/OpenAI-compatible port 11435 alias qwen-3.6-agent when available.
    18|- Direct Qwen helper: ~/.hermes/scripts/qwen_readonly.py.
    19|
    20|## Operating rules
    21|- Use native Hermes tools first for file, terminal, web, cron, messaging, and system operations.
    22|- Use OpenClaw only if Sir explicitly requests it or in a justified emergency/special case; scope narrowly and prefer read-only.
    23|- Local file operations are safe; external sends require care/confirmation unless explicitly requested.
    24|- Do not print secrets.
    25|- For complex or risky work, verify with evidence before reporting success.
    26|
    27|## Data sources
    28|- ~/.hermes/config.yaml
    29|- ~/.hermes/auth.json (redacted only)
    30|- ~/.hermes/cron/
    31|- ~/jarvis_memory/
    32|- ~/.hermes/skills/jarvis-routing-policy/
    33|- ~/.hermes/scripts/qwen_readonly.py
    34|- ~/.openclaw/workspace/ (legacy/manual only)
    35|- Local docs and skills
    36|
    37|## Do-not-store
    38|- API keys, OAuth tokens, raw auth.json values, gateway tokens
    39|- Unredacted logs containing credentials
    40|
    41|## Open questions
    42|- Final canonical agent names and responsibilities?
    43|- Should Hermes/JARVIS/FRIDAY share one memory store or maintain mirrored domain cards?
    44|


     1|"""
     2|Telegram platform adapter.
     3|
     4|Uses python-telegram-bot library for:
     5|- Receiving messages from users/groups
     6|- Sending responses back
     7|- Handling media and commands
     8|"""
     9|
    10|import asyncio
    11|import json
    12|import logging
    13|import os
    14|import tempfile
    15|import html as _html
    16|import re
    17|from typing import Dict, List, Optional, Any
    18|
    19|logger = logging.getLogger(__name__)
    20|
    21|try:
    22|    from telegram import Update, Bot, Message, InlineKeyboardButton, InlineKeyboardMarkup
    23|    try:
    24|        from telegram import LinkPreviewOptions
    25|    except ImportError:
    26|        LinkPreviewOptions = None
    27|    from telegram.ext import (
    28|        Application,
    29|        CommandHandler,
    30|        CallbackQueryHandler,
    31|        MessageHandler as TelegramMessageHandler,
    32|        ContextTypes,
    33|        filters,
    34|    )
    35|    from telegram.constants import ParseMode, ChatType
    36|    from telegram.request import HTTPXRequest
    37|    TELEGRAM_AVAILABLE = True
    38|except ImportError:
    39|    TELEGRAM_AVAILABLE = False
    40|    Update = Any
    41|    Bot = Any
    42|    Message = Any
    43|    InlineKeyboardButton = Any
    44|    InlineKeyboardMarkup = Any
    45|    LinkPreviewOptions = None
    46|    Application = Any
    47|    CommandHandler = Any
    48|    CallbackQueryHandler = Any
    49|    TelegramMessageHandler = Any
    50|    HTTPXRequest = Any
    51|    filters = None
    52|    ParseMode = None
    53|    ChatType = None
    54|
    55|    # Mock ContextTypes so type annotations using ContextTypes.DEFAULT_TYPE
    56|    # don't crash during class definition when the library isn't installed.
    57|    class _MockContextTypes:
    58|        DEFAULT_TYPE = Any
    59|    ContextTypes = _MockContextTypes
    60|
    61|import sys
    62|from pathlib import Path as _Path
    63|sys.path.insert(0, str(_Path(__file__).resolve().parents[2]))
    64|
    65|from gateway.config import Platform, PlatformConfig
    66|from gateway.platforms.base import (
    67|    BasePlatformAdapter,
    68|    MessageEvent,
    69|    MessageType,
    70|    ProcessingOutcome,
    71|    SendResult,
    72|    cache_image_from_bytes,
    73|    cache_audio_from_bytes,
    74|    cache_video_from_bytes,
    75|    cache_document_from_bytes,
    76|    resolve_proxy_url,
    77|    SUPPORTED_VIDEO_TYPES,
    78|    SUPPORTED_DOCUMENT_TYPES,
    79|    utf16_len,
    80|)
    81|from gateway.platforms.telegram_network import (
    82|    TelegramFallbackTransport,
    83|    discover_fallback_ips,
    84|    parse_fallback_ip_env,
    85|)
    86|from utils import atomic_replace
    87|
    88|_TELEGRAM_IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".webp", ".gif"}
    89|_TELEGRAM_IMAGE_MIME_TO_EXT = {
    90|    "image/png": ".png",
    91|    "image/jpeg": ".jpg",
    92|    "image/jpg": ".jpg",
    93|    "image/webp": ".webp",
    94|    "image/gif": ".gif",
    95|}
    96|_TELEGRAM_IMAGE_EXT_TO_MIME = {
    97|    ".png": "image/png",
    98|    ".jpg": "image/jpeg",
    99|    ".jpeg": "image/jpeg",
   100|    ".webp": "image/webp",
   101|    ".gif": "image/gif",
   102|}
   103|
   104|
   105|def check_telegram_requirements() -> bool:
   106|    """Check if Telegram dependencies are available.
   107|
   108|    If python-telegram-bot is missing, attempts to lazy-install it via
   109|    ``tools.lazy_deps.ensure("platform.telegram")``. After a successful
   110|    install, re-imports the SDK and flips ``TELEGRAM_AVAILABLE`` to True
   111|    so the adapter's class-level type aliases get rebound.
   112|    """
   113|    global TELEGRAM_AVAILABLE, Update, Bot, Message, InlineKeyboardButton
   114|    global InlineKeyboardMarkup, LinkPreviewOptions, Application
   115|    global CommandHandler, CallbackQueryHandler, TelegramMessageHandler
   116|    global ContextTypes, filters, ParseMode, ChatType, HTTPXRequest
   117|    if TELEGRAM_AVAILABLE:
   118|        return True
   119|    try:
   120|        from tools.lazy_deps import ensure as _lazy_ensure
   121|        _lazy_ensure("platform.telegram", prompt=False)
   122|    except Exception:
   123|        return False
   124|    try:
   125|        from telegram import Update as _Update, Bot as _Bot, Message as _Message
   126|        from telegram import InlineKeyboardButton as _IKB, InlineKeyboardMarkup as _IKM
   127|        try:
   128|            from telegram import LinkPreviewOptions as _LPO
   129|        except ImportError:
   130|            _LPO = None
   131|        from telegram.ext import (
   132|            Application as _App, CommandHandler as _CH,
   133|            CallbackQueryHandler as _CQH,
   134|            MessageHandler as _MH,
   135|            ContextTypes as _CT, filters as _filters,
   136|        )
   137|        from telegram.constants import ParseMode as _PM, ChatType as _CtT
   138|        from telegram.request import HTTPXRequest as _HR
   139|    except ImportError:
   140|        return False
   141|    Update = _Update
   142|    Bot = _Bot
   143|    Message = _Message
   144|    InlineKeyboardButton = _IKB
   145|    InlineKeyboardMarkup = _IKM
   146|    LinkPreviewOptions = _LPO
   147|    Application = _App
   148|    CommandHandler = _CH
   149|    CallbackQueryHandler = _CQH
   150|    TelegramMessageHandler = _MH
   151|    ContextTypes = _CT
   152|    filters = _filters
   153|    ParseMode = _PM
   154|    ChatType = _CtT
   155|    HTTPXRequest = _HR
   156|    TELEGRAM_AVAILABLE = True
   157|    return True
   158|
   159|
   160|# Matches every character that MarkdownV2 requires to be backslash-escaped
   161|# when it appears outside a code span or fenced code block.
   162|_MDV2_ESCAPE_RE = re.compile(r'([_*\[\]()~`>#\+\-=|{}.!\\])')
   163|
   164|
   165|def _escape_mdv2(text: str) -> str:
   166|    """Escape Telegram MarkdownV2 special characters with a preceding backslash."""
   167|    return _MDV2_ESCAPE_RE.sub(r'\\\1', text)
   168|
   169|
   170|def _strip_mdv2(text: str) -> str:
   171|    """Strip MarkdownV2 escape backslashes to produce clean plain text.
   172|
   173|    Also removes MarkdownV2 formatting markers so the fallback
   174|    doesn't show stray syntax characters from format_message conversion.
   175|    """
   176|    # Remove escape backslashes before special characters
   177|    cleaned = re.sub(r'\\([_*\[\]()~`>#\+\-=|{}.!\\])', r'\1', text)
   178|    # Remove MarkdownV2 bold markers that format_message converted from **bold**
   179|    cleaned = re.sub(r'\*([^*]+)\*', r'\1', cleaned)
   180|    # Remove MarkdownV2 italic markers that format_message converted from *italic*
   181|    # Use word boundary (\b) to avoid breaking snake_case like my_variable_name
   182|    cleaned = re.sub(r'(?<!\w)_([^_]+)_(?!\w)', r'\1', cleaned)
   183|    # Remove MarkdownV2 strikethrough markers (~text~ → text)
   184|    cleaned = re.sub(r'~([^~]+)~', r'\1', cleaned)
   185|    # Remove MarkdownV2 spoiler markers (||text|| → text)
   186|    cleaned = re.sub(r'\|\|([^|]+)\|\|', r'\1', cleaned)
   187|    return cleaned
   188|
   189|
   190|# ---------------------------------------------------------------------------
   191|# Markdown table → Telegram-friendly row groups
   192|# ---------------------------------------------------------------------------
   193|# Telegram's MarkdownV2 has no table syntax — '|' is just an escaped literal,
   194|# so pipe tables render as noisy backslash-pipe text with no alignment.
   195|# Reformating each row into a bold heading plus bullet list keeps the content
   196|# readable on mobile clients while preserving the source data.
   197|
   198|# Matches a GFM table delimiter row: optional outer pipes, cells containing
   199|# only dashes (with optional leading/trailing colons for alignment) separated
   200|# by '|'.  Requires at least one internal '|' so lone '---' horizontal rules
   201|# are NOT matched.
   202|_TABLE_SEPARATOR_RE = re.compile(
   203|    r'^\s*\|?\s*:?-+:?\s*(?:\|\s*:?-+:?\s*){1,}\|?\s*$'
   204|)
   205|
   206|
   207|def _is_table_row(line: str) -> bool:
   208|    """Return True if *line* could plausibly be a table data row."""
   209|    stripped = line.strip()
   210|    return bool(stripped) and '|' in stripped
   211|
   212|
   213|def _split_markdown_table_row(line: str) -> list[str]:
   214|    """Split a simple GFM table row into stripped cell values."""
   215|    stripped = line.strip()
   216|    if stripped.startswith("|"):
   217|        stripped = stripped[1:]
   218|    if stripped.endswith("|"):
   219|        stripped = stripped[:-1]
   220|    return [cell.strip() for cell in stripped.split("|")]
   221|
   222|
   223|def _render_table_block_for_telegram(table_block: list[str]) -> str:
   224|    """Render a detected GFM table as Telegram-friendly row groups."""
   225|    if len(table_block) < 3:
   226|        return "\n".join(table_block)
   227|
   228|    headers = _split_markdown_table_row(table_block[0])
   229|    if len(headers) < 2:
   230|        return "\n".join(table_block)
   231|
   232|    # Detect row-label column: present when data rows have one more cell
   233|    # than the header row (the row-label column carries no header).
   234|    first_data_row = _split_markdown_table_row(table_block[2]) if len(table_block) > 2 else []
   235|    has_row_label_col = len(first_data_row) == len(headers) + 1
   236|
   237|    rendered_rows: list[str] = []
   238|    for index, row in enumerate(table_block[2:], start=1):
   239|        cells = _split_markdown_table_row(row)
   240|        if has_row_label_col:
   241|

Use offset=241 to continue reading (showing 1-240 of 4820 lines)


Explanation:
**Finding search patterns**

I'm wondering why the search isn't working. Maybe ripgrep is respecting the .gitignore files? I could try using the terminal and the command `rg` instead. The user mentioned not to use grep, but the terminal should be okay for commands, right? It seems that search_files failed, possibly because the pattern isn't matching due to some binary files. Hmm, maybe I should consider using Python for this!

from pathlib import Path
root=Path('/home/agent/.hermes/hermes-agent')
terms=['message_thread_id','thread_id','topic','channel_prompts','group_sessions_per_user','protect_last_n']
for p in root.rglob('*.py'):
    if 'venv' in p.parts or '__pycache__' in p.parts: continue
    try: txt=p.read_text(errors='ignore')
    except: continue
    hits=[t for t in terms if t in txt]
    if hits:
        print(p.relative_to(root), hits)


Output:
hermes_state.py ['thread_id', 'topic']
run_agent.py ['thread_id', 'topic', 'protect_last_n']
cli.py ['topic']
trajectory_compressor.py ['protect_last_n']
mcp_serve.py ['thread_id']
tests/test_tui_gateway_server.py ['topic']
tests/test_hermes_state.py ['thread_id', 'topic']
tests/test_cli_manual_compress.py ['topic']
tests/test_model_tools_async_bridge.py ['thread_id']
tests/test_mcp_serve.py ['thread_id', 'topic']
tests/test_trajectory_compressor.py ['protect_last_n']
tests/run_agent/test_real_interrupt_subagent.py ['thread_id']
tests/run_agent/test_concurrent_interrupt.py ['thread_id']
tests/run_agent/test_steer.py ['thread_id']
tests/run_agent/test_interrupt_propagation.py ['thread_id']
tests/run_agent/test_session_reset_fix.py ['topic']
tests/run_agent/test_compression_boundary.py ['protect_last_n']
tests/run_agent/test_compress_focus_plugin_fallback.py ['topic']
tests/hermes_cli/test_commands.py ['topic']
tests/hermes_cli/test_kanban_notify.py ['thread_id']
tests/hermes_cli/test_kanban_core_functionality.py ['thread_id']
tests/hermes_cli/test_config.py ['channel_prompts']
tests/tools/test_interrupt.py ['thread_id']
tests/tools/test_process_registry.py ['thread_id']
tests/tools/test_watch_patterns.py ['thread_id']
tests/tools/test_delegate.py ['topic']
tests/tools/test_discord_tool.py ['thread_id', 'topic']
tests/tools/test_send_message_tool.py ['message_thread_id', 'thread_id', 'topic']
tests/tools/test_notify_on_complete.py ['thread_id']
tests/e2e/test_discord_adapter.py ['thread_id']
tests/e2e/conftest.py ['thread_id', 'topic']
tests/agent/test_context_compressor_summary_continuity.py ['protect_last_n']
tests/agent/test_context_compressor.py ['protect_last_n']
tests/agent/test_memory_user_id.py ['thread_id']
tests/agent/test_memory_session_switch.py ['thread_id']
tests/agent/test_compress_focus.py ['topic', 'protect_last_n']
tests/plugins/test_kanban_dashboard_plugin.py ['thread_id']
tests/plugins/memory/test_hindsight_provider.py ['thread_id']
tests/cron/test_scheduler.py ['thread_id', 'topic']
tests/gateway/test_allowed_channels_widening.py ['message_thread_id', 'thread_id']
tests/gateway/test_session_dm_thread_seeding.py ['thread_id']
tests/gateway/test_internal_event_bypass_pairing.py ['thread_id', 'topic']
tests/gateway/test_telegram_model_picker.py ['message_thread_id', 'thread_id']
tests/gateway/test_home_target_env_var.py ['thread_id']
tests/gateway/restart_test_helpers.py ['thread_id']
tests/gateway/test_webhook_deliver_only.py ['thread_id', 'topic']
tests/gateway/test_run_cleanup_progress.py ['topic', 'group_sessions_per_user']
tests/gateway/test_update_streaming.py ['thread_id', 'topic']
tests/gateway/test_slack_mention.py ['thread_id']
tests/gateway/test_goal_status_notice.py ['thread_id', 'group_sessions_per_user']
tests/gateway/test_discord_slash_commands.py ['thread_id', 'topic']
tests/gateway/test_feishu_approval_buttons.py ['thread_id']
tests/gateway/test_google_chat.py ['thread_id', 'topic']
tests/gateway/test_discord_channel_controls.py ['topic']
tests/gateway/test_telegram_mention_boundaries.py ['message_thread_id', 'thread_id']
tests/gateway/test_base_topic_sessions.py ['thread_id', 'topic']
tests/gateway/test_telegram_documents.py ['message_thread_id', 'thread_id']
tests/gateway/test_mattermost.py ['thread_id']
tests/gateway/test_discord_channel_prompts.py ['topic', 'channel_prompts']
tests/gateway/test_telegram_approval_buttons.py ['message_thread_id', 'thread_id']
tests/gateway/test_qqbot.py ['thread_id']
tests/gateway/test_restart_notification.py ['thread_id', 'topic']
tests/gateway/test_tts_media_routing.py ['thread_id', 'topic']
tests/gateway/test_run_progress_interrupt.py ['thread_id', 'group_sessions_per_user']
tests/gateway/test_telegram_group_gating.py ['message_thread_id', 'thread_id', 'group_sessions_per_user']
tests/gateway/test_proxy_mode.py ['thread_id']
tests/gateway/test_dm_topics.py ['message_thread_id', 'thread_id', 'topic']
tests/gateway/test_background_command.py ['thread_id', 'topic']
tests/gateway/test_mirror.py ['thread_id', 'topic']
tests/gateway/test_slack_approval_buttons.py ['thread_id', 'group_sessions_per_user']
tests/gateway/test_channel_directory.py ['thread_id', 'topic']
tests/gateway/test_feishu.py ['thread_id', 'topic']
tests/gateway/test_telegram_reply_quote.py ['message_thread_id', 'thread_id', 'topic']
tests/gateway/test_matrix.py ['thread_id', 'topic']
tests/gateway/test_telegram_format.py ['message_thread_id', 'thread_id']
tests/gateway/test_slack.py ['thread_id', 'topic', 'group_sessions_per_user']
tests/gateway/test_update_command.py ['thread_id']
tests/gateway/test_agent_cache.py ['protect_last_n']
tests/gateway/test_webhook_adapter.py ['message_thread_id', 'thread_id']
tests/gateway/test_shared_group_sender_prefix.py ['group_sessions_per_user']
tests/gateway/test_send_image_file.py ['message_thread_id', 'thread_id']
tests/gateway/test_fresh_reset_skill_injection.py ['topic']
tests/gateway/test_interrupt_key_match.py ['thread_id']
tests/gateway/test_delivery.py ['thread_id']
tests/gateway/test_session.py ['thread_id', 'topic', 'group_sessions_per_user']
tests/gateway/test_restart_resume_pending.py ['thread_id', 'topic']
tests/gateway/test_discord_free_response.py ['thread_id', 'topic']
tests/gateway/test_background_process_notifications.py ['thread_id', 'topic']
tests/gateway/test_telegram_topic_mode.py ['thread_id', 'topic', 'group_sessions_per_user']
tests/gateway/test_busy_session_auth_bypass.py ['thread_id']
tests/gateway/test_discord_document_handling.py ['topic']
tests/gateway/test_weixin.py ['thread_id']
tests/gateway/test_compress_plugin_engine.py ['topic']
tests/gateway/test_session_hygiene.py ['thread_id', 'topic']
tests/gateway/test_compress_focus.py ['topic']
tests/gateway/test_voice_command.py ['thread_id', 'topic']
tests/gateway/test_stream_consumer_thread_routing.py ['thread_id', 'topic']
tests/gateway/test_run_progress_topics.py ['thread_id', 'topic', 'group_sessions_per_user']
tests/gateway/test_notice_delivery.py ['thread_id']
tests/gateway/test_matrix_mention.py ['thread_id']
tests/gateway/test_session_store_prune.py ['thread_id']
tests/gateway/test_session_env.py ['thread_id']
tests/gateway/test_config.py ['channel_prompts', 'group_sessions_per_user']
tests/gateway/test_telegram_thread_fallback.py ['message_thread_id', 'thread_id', 'topic']
tests/cli/test_cli_interrupt_subagent.py ['thread_id']
tests/cli/test_compress_focus.py ['topic']
hermes_cli/kanban.py ['thread_id']
hermes_cli/kanban_db.py ['thread_id']
hermes_cli/config.py ['topic', 'channel_prompts', 'protect_last_n']
hermes_cli/commands.py ['topic']
hermes_cli/tips.py ['topic', 'group_sessions_per_user']
tools/interrupt.py ['thread_id']
tools/cronjob_tools.py ['thread_id', 'topic']
tools/discord_tool.py ['thread_id', 'topic']
tools/session_search_tool.py ['topic']
tools/process_registry.py ['thread_id']
tools/terminal_tool.py ['thread_id']
tools/send_message_tool.py ['message_thread_id', 'thread_id', 'topic']
agent/context_compressor.py ['topic', 'protect_last_n']
agent/context_engine.py ['topic', 'protect_last_n']
agent/curator.py ['topic']
agent/title_generator.py ['topic']
environments/web_research_env.py ['topic']
plugins/platforms/irc/adapter.py ['thread_id']
plugins/platforms/google_chat/adapter.py ['thread_id', 'topic']
plugins/platforms/line/adapter.py ['thread_id']
plugins/platforms/teams/adapter.py ['thread_id']
plugins/memory/honcho/session.py ['topic']
plugins/memory/hindsight/__init__.py ['thread_id']
plugins/kanban/dashboard/plugin_api.py ['thread_id']
plugins/teams_pipeline/models.py ['thread_id']
plugins/teams_pipeline/meetings.py ['thread_id']
tui_gateway/server.py ['topic']
cron/scheduler.py ['thread_id', 'topic']
gateway/stream_consumer.py ['topic']
gateway/run.py ['message_thread_id', 'thread_id', 'topic', 'group_sessions_per_user', 'protect_last_n']
gateway/config.py ['thread_id', 'topic', 'channel_prompts', 'group_sessions_per_user']
gateway/delivery.py ['thread_id']
gateway/mirror.py ['thread_id']
gateway/session_context.py ['thread_id']
gateway/platform_registry.py ['thread_id']
gateway/session.py ['thread_id', 'topic', 'group_sessions_per_user']
gateway/channel_directory.py ['thread_id', 'topic']
gateway/platforms/discord.py ['thread_id', 'topic', 'group_sessions_per_user']
gateway/platforms/wecom.py ['group_sessions_per_user']
gateway/platforms/slack.py ['thread_id', 'topic', 'group_sessions_per_user']
gateway/platforms/yuanbao.py ['thread_id', 'group_sessions_per_user']
gateway/platforms/base.py ['message_thread_id', 'thread_id', 'topic', 'channel_prompts', 'group_sessions_per_user']
gateway/platforms/helpers.py ['thread_id']
gateway/platforms/feishu.py ['thread_id', 'topic', 'group_sessions_per_user']
gateway/platforms/telegram.py ['message_thread_id', 'thread_id', 'topic', 'group_sessions_per_user']
gateway/platforms/matrix.py ['thread_id', 'topic', 'group_sessions_per_user']
gateway/platforms/webhook.py ['message_thread_id', 'thread_id', 'topic']
gateway/platforms/mattermost.py ['thread_id']
gateway/platforms/qqbot/adapter.py ['thread_id']
scripts/release.py ['topic']
optional-skills/blockchain/base/scripts/base_client.py ['topic']
skills/productivity/google-workspace/scripts/google_api.py ['thread_id']
skills/red-teaming/godmode/scripts/godmode_race.py ['topic']


     1|"""
     2|Gateway configuration management.
     3|
     4|Handles loading and validating configuration for:
     5|- Connected platforms (Telegram, Discord, WhatsApp, Weixin, and more)
     6|- Home channels for each platform
     7|- Session reset policies
     8|- Delivery preferences
     9|"""
    10|
    11|import logging
    12|import os
    13|import json
    14|from pathlib import Path
    15|from dataclasses import dataclass, field
    16|from typing import Dict, List, Optional, Any, Callable
    17|from enum import Enum
    18|
    19|from hermes_cli.config import get_hermes_home
    20|from utils import is_truthy_value
    21|
    22|logger = logging.getLogger(__name__)
    23|
    24|
    25|def _coerce_bool(value: Any, default: bool = True) -> bool:
    26|    """Coerce bool-ish config values, preserving a caller-provided default."""
    27|    if value is None:
    28|        return default
    29|    if isinstance(value, str):
    30|        lowered = value.strip().lower()
    31|        if lowered in {"true", "1", "yes", "on"}:
    32|            return True
    33|        if lowered in {"false", "0", "no", "off"}:
    34|            return False
    35|        return default
    36|    return is_truthy_value(value, default=default)
    37|
    38|
    39|def _coerce_float(value: Any, default: float) -> float:
    40|    """Coerce numeric config values, falling back on malformed input."""
    41|    if value is None:
    42|        return default
    43|    try:
    44|        return float(value)
    45|    except (TypeError, ValueError):
    46|        return default
    47|
    48|
    49|def _coerce_int(value: Any, default: int) -> int:
    50|    """Coerce integer config values, falling back on malformed input."""
    51|    if value is None:
    52|        return default
    53|    try:
    54|        return int(value)
    55|    except (TypeError, ValueError):
    56|        return default
    57|
    58|
    59|def _normalize_unauthorized_dm_behavior(value: Any, default: str = "pair") -> str:
    60|    """Normalize unauthorized DM behavior to a supported value."""
    61|    if isinstance(value, str):
    62|        normalized = value.strip().lower()
    63|        if normalized in {"pair", "ignore"}:
    64|            return normalized
    65|    return default
    66|
    67|
    68|def _normalize_notice_delivery(value: Any, default: str = "public") -> str:
    69|    """Normalize notice delivery mode to a supported value."""
    70|    if isinstance(value, str):
    71|        normalized = value.strip().lower()
    72|        if normalized in {"public", "private"}:
    73|            return normalized
    74|    return default
    75|
    76|
    77|# Module-level cache for bundled platform plugin names (lives outside the
    78|# enum so it doesn't become an accidental enum member).
    79|_Platform__bundled_plugin_names: Optional[set] = None
    80|
    81|
    82|class Platform(Enum):
    83|    """Supported messaging platforms.
    84|
    85|    Built-in platforms have explicit members.  Plugin platforms use dynamic
    86|    members created on-demand by ``_missing_()`` so that
    87|    ``Platform("irc")`` works without modifying this enum.  Dynamic members
    88|    are cached in ``_value2member_map_`` for identity-stable comparisons.
    89|    """
    90|    LOCAL = "local"
    91|    TELEGRAM = "telegram"
    92|    DISCORD = "discord"
    93|    WHATSAPP = "whatsapp"
    94|    SLACK = "slack"
    95|    SIGNAL = "signal"
    96|    MATTERMOST = "mattermost"
    97|    MATRIX = "matrix"
    98|    HOMEASSISTANT = "homeassistant"
    99|    EMAIL = "email"
   100|    SMS = "sms"
   101|    DINGTALK = "dingtalk"
   102|    API_SERVER = "api_server"
   103|    WEBHOOK = "webhook"
   104|    MSGRAPH_WEBHOOK = "msgraph_webhook"
   105|    FEISHU = "feishu"
   106|    WECOM = "wecom"
   107|    WECOM_CALLBACK = "wecom_callback"
   108|    WEIXIN = "weixin"
   109|    BLUEBUBBLES = "bluebubbles"
   110|    QQBOT = "qqbot"
   111|    YUANBAO = "yuanbao"
   112|    @classmethod
   113|    def _missing_(cls, value):
   114|        """Accept unknown platform names only for known plugin adapters.
   115|
   116|        Creates a pseudo-member cached in ``_value2member_map_`` so that
   117|        ``Platform("irc") is Platform("irc")`` holds True (identity-stable).
   118|        Arbitrary strings are rejected to prevent enum pollution.
   119|        """
   120|        if not isinstance(value, str) or not value.strip():
   121|            return None
   122|        # Normalise to lowercase to avoid case mismatches in config
   123|        value = value.strip().lower()
   124|        # Check cache first (another call may have created it already)
   125|        if value in cls._value2member_map_:
   126|            return cls._value2member_map_[value]
   127|
   128|        # Only create pseudo-members for bundled plugin platforms (discovered
   129|        # via filesystem scan) or runtime-registered plugin platforms.
   130|        global _Platform__bundled_plugin_names
   131|        if _Platform__bundled_plugin_names is None:
   132|            _Platform__bundled_plugin_names = cls._scan_bundled_plugin_platforms()
   133|        if value in _Platform__bundled_plugin_names:
   134|            pseudo = object.__new__(cls)
   135|            pseudo._value_ = value
   136|            pseudo._name_ = value.upper().replace("-", "_").replace(" ", "_")
   137|            cls._value2member_map_[value] = pseudo
   138|            cls._member_map_[pseudo._name_] = pseudo
   139|            return pseudo
   140|
   141|        # Runtime-registered plugins (e.g. user-installed, discovered after
   142|        # the enum was defined).
   143|        try:
   144|            from gateway.platform_registry import platform_registry
   145|            if platform_registry.is_registered(value):
   146|                pseudo = object.__new__(cls)
   147|                pseudo._value_ = value
   148|                pseudo._name_ = value.upper().replace("-", "_").replace(" ", "_")
   149|                cls._value2member_map_[value] = pseudo
   150|                cls._member_map_[pseudo._name_] = pseudo
   151|                return pseudo
   152|        except Exception:
   153|            pass
   154|
   155|        return None
   156|
   157|    @classmethod
   158|    def _scan_bundled_plugin_platforms(cls) -> set:
   159|        """Return names of bundled platform plugins under ``plugins/platforms/``."""
   160|        names: set = set()
   161|        try:
   162|            platforms_dir = Path(__file__).parent.parent / "plugins" / "platforms"
   163|            if platforms_dir.is_dir():
   164|                for child in platforms_dir.iterdir():
   165|                    if (
   166|                        child.is_dir()
   167|                        and (child / "__init__.py").exists()
   168|                        and (
   169|                            (child / "plugin.yaml").exists()
   170|                            or (child / "plugin.yml").exists()
   171|                        )
   172|                    ):
   173|                        names.add(child.name.lower())
   174|        except Exception:
   175|            pass
   176|        return names
   177|
   178|
   179|# Snapshot of built-in platform values before any dynamic _missing_ lookups.
   180|# Used to distinguish real platforms from arbitrary strings.
   181|

Use offset=181 to continue reading (showing 1-180 of 1803 lines)


if "group_user_allowed_commands" in platform_cfg:

bridged["group_user_allowed_commands"] = platform_cfg["group_user_allowed_commands"]

if plat in {Platform.DISCORD, Platform.SLACK} and "channel_skill_bindings" in platform_cfg:

bridged["channel_skill_bindings"] = platform_cfg["channel_skill_bindings"]

if "channel_prompts" in platform_cfg:

channel_prompts = platform_cfg["channel_prompts"]

if isinstance(channel_prompts, dict):

bridged["channel_prompts"] = {str(k): v for k, v in channel_prompts.items()}

else:

bridged["channel_prompts"] = channel_prompts

enabled_was_explicit = "enabled" in platform_cfg

if not bridged and not enabled_was_explicit:

continue

plat_data = platforms_data.setdefault(plat.value, {})


   360|            "buffer_threshold": self.buffer_threshold,
   361|            "cursor": self.cursor,
   362|            "fresh_final_after_seconds": self.fresh_final_after_seconds,
   363|        }
   364|
   365|    @classmethod
   366|    def from_dict(cls, data: Dict[str, Any]) -> "StreamingConfig":
   367|        if not data:
   368|            return cls()
   369|        return cls(
   370|            enabled=_coerce_bool(data.get("enabled"), False),
   371|            transport=data.get("transport", "auto"),
   372|            edit_interval=_coerce_float(
   373|                data.get("edit_interval"), DEFAULT_STREAMING_EDIT_INTERVAL,
   374|            ),
   375|            buffer_threshold=_coerce_int(
   376|                data.get("buffer_threshold"), DEFAULT_STREAMING_BUFFER_THRESHOLD,
   377|            ),
   378|            cursor=data.get("cursor", DEFAULT_STREAMING_CURSOR),
   379|            fresh_final_after_seconds=_coerce_float(
   380|                data.get("fresh_final_after_seconds"), 60.0
   381|            ),
   382|        )
   383|
   384|
   385|# -----------------------------------------------------------------------------
   386|# Built-in platform connection checkers
   387|# -----------------------------------------------------------------------------
   388|# Each callable receives a ``PlatformConfig`` and returns ``True`` when the
   389|# platform is sufficiently configured to be considered "connected".  Platforms
   390|# that rely on the generic ``token or api_key`` check (Telegram, Discord,
   391|# Slack, Matrix, Mattermost, HomeAssistant) do not need an entry here.
   392|_PLATFORM_CONNECTED_CHECKERS: dict[Platform, Callable[[PlatformConfig], bool]] = {
   393|    Platform.WEIXIN: lambda cfg: bool(
   394|        cfg.extra.get("account_id") and (cfg.token or cfg.extra.get("token"))
   395|    ),
   396|    Platform.WHATSAPP: lambda cfg: True,  # bridge handles auth
   397|    Platform.SIGNAL: lambda cfg: bool(cfg.extra.get("http_url")),
   398|    Platform.EMAIL: lambda cfg: bool(cfg.extra.get("address")),
   399|    Platform.SMS: lambda cfg: bool(os.getenv("TWILIO_ACCOUNT_SID")),
   400|    Platform.API_SERVER: lambda cfg: True,
   401|    Platform.WEBHOOK: lambda cfg: True,
   402|    Platform.MSGRAPH_WEBHOOK: lambda cfg: True,
   403|    Platform.FEISHU: lambda cfg: bool(cfg.extra.get("app_id")),
   404|    Platform.WECOM: lambda cfg: bool(cfg.extra.get("bot_id")),
   405|    Platform.WECOM_CALLBACK: lambda cfg: bool(
   406|        cfg.extra.get("corp_id") or cfg.extra.get("apps")
   407|    ),
   408|    Platform.BLUEBUBBLES: lambda cfg: bool(
   409|        cfg.extra.get("server_url") and cfg.extra.get("password")
   410|    ),
   411|    Platform.QQBOT: lambda cfg: bool(
   412|        cfg.extra.get("app_id") and cfg.extra.get("client_secret")
   413|    ),
   414|    Platform.YUANBAO: lambda cfg: bool(
   415|        cfg.extra.get("app_id") and cfg.extra.get("app_secret")
   416|    ),
   417|    Platform.DINGTALK: lambda cfg: bool(
   418|        (cfg.extra.get("client_id") or os.getenv("DINGTALK_CLIENT_ID"))
   419|        and (cfg.extra.get("client_secret") or os.getenv("DINGTALK_CLIENT_SECRET"))
   420|    ),
   421|}
   422|
   423|
   424|@dataclass
   425|class GatewayConfig:
   426|    """
   427|    Main gateway configuration.
   428|
   429|    Manages all platform connections, session policies, and delivery settings.
   430|    """
   431|    # Platform configurations
   432|    platforms: Dict[Platform, PlatformConfig] = field(default_factory=dict)
   433|
   434|    # Session reset policies by type
   435|    default_reset_policy: SessionResetPolicy = field(default_factory=SessionResetPolicy)
   436|    reset_by_type: Dict[str, SessionResetPolicy] = field(default_factory=dict)
   437|    reset_by_platform: Dict[Platform, SessionResetPolicy] = field(default_factory=dict)
   438|
   439|    # Reset trigger commands
   440|    reset_triggers: List[str] = field(default_factory=lambda: ["/new", "/reset"])
   441|
   442|    # User-defined quick commands (slash commands that bypass the agent loop)
   443|    quick_commands: Dict[str, Any] = field(default_factory=dict)
   444|
   445|    # Storage paths
   446|    sessions_dir: Path = field(default_factory=lambda: get_hermes_home() / "sessions")
   447|
   448|    # Delivery settings
   449|    always_log_local: bool = True  # Always save cron outputs to local files
   450|
   451|    # STT settings
   452|    stt_enabled: bool = True  # Whether to auto-transcribe inbound voice messages
   453|
   454|    # Session isolation in shared chats
   455|    group_sessions_per_user: bool = True  # Isolate group/channel sessions per participant when user IDs are available
   456|    thread_sessions_per_user: bool = False  # When False (default), threads are shared across all participants
   457|
   458|    # Unauthorized DM policy
   459|    unauthorized_dm_behavior: str = "pair"  # "pair" or "ignore"
   460|
   461|    # Streaming configuration
   462|    streaming: StreamingConfig = field(default_factory=StreamingConfig)
   463|
   464|    # Session store pruning: drop SessionEntry records older than this many
   465|    # days from the in-memory dict and sessions.json.  Keeps the store from
   466|    # growing unbounded in gateways serving many chats/threads/users over
   467|    # months.  Pruning is invisible to users — if they resume, they get a
   468|    # fresh session exactly as if the reset policy had fired.  0 = disabled.
   469|    session_store_max_age_days: int = 90
   470|
   471|    def get_connected_platforms(self) -> List[Platform]:
   472|        """Return list of platforms that are enabled and configured."""
   473|        connected = []
   474|        for platform, config in self.platforms.items():
   475|            if not config.enabled:
   476|                continue
   477|            if self._is_platform_connected(platform, config):
   478|                connected.append(platform)
   479|        return connected
   480|
   481|    def _is_platform_connected(self, platform: Platform, config: PlatformConfig) -> bool:
   482|        """Check whether a single platform is sufficiently configured."""
   483|        # Weixin requires both a token and an account_id (checked first so
   484|        # the generic token branch doesn't let it through without account_id).
   485|        if platform == Platform.WEIXIN:
   486|            return bool(
   487|                config.extra.get("account_id")
   488|                and (config.token or config.extra.get("token"))
   489|            )
   490|
   491|        # Generic token/api_key auth covers Telegram, Discord, Slack, etc.
   492|        if config.token or config.api_key:
   493|            return True
   494|
   495|        # Platform-specific check
   496|        checker = _PLATFORM_CONNECTED_CHECKERS.get(platform)
   497|        if checker is not None:
   498|            return checker(config)
   499|
   500|

Use offset=500 to continue reading (showing 360-499 of 1803 lines)


   181|_BUILTIN_PLATFORM_VALUES = frozenset(m.value for m in Platform.__members__.values())
   182|
   183|
   184|@dataclass
   185|class HomeChannel:
   186|    """
   187|    Default destination for a platform.
   188|
   189|    When a cron job specifies deliver="telegram" without a specific chat ID,
   190|    messages are sent to this home channel. Thread-aware platforms may also
   191|    store a thread/topic ID so the bare platform target routes to the exact
   192|    conversation where /sethome was run.
   193|    """
   194|    platform: Platform
   195|    chat_id: str
   196|    name: str  # Human-readable name for display
   197|    thread_id: Optional[str] = None
   198|
   199|    def to_dict(self) -> Dict[str, Any]:
   200|        result = {
   201|            "platform": self.platform.value,
   202|            "chat_id": self.chat_id,
   203|            "name": self.name,
   204|        }
   205|        if self.thread_id:
   206|            result["thread_id"] = self.thread_id
   207|        return result
   208|
   209|    @classmethod
   210|    def from_dict(cls, data: Dict[str, Any]) -> "HomeChannel":
   211|        return cls(
   212|            platform=Platform(data["platform"]),
   213|            chat_id=str(data["chat_id"]),
   214|            name=data.get("name", "Home"),
   215|            thread_id=str(data["thread_id"]) if data.get("thread_id") else None,
   216|        )
   217|
   218|
   219|@dataclass
   220|class SessionResetPolicy:
   221|    """
   222|    Controls when sessions reset (lose context).
   223|
   224|    Modes:
   225|    - "daily": Reset at a specific hour each day
   226|    - "idle": Reset after N minutes of inactivity
   227|    - "both": Whichever triggers first (daily boundary OR idle timeout)
   228|    - "none": Never auto-reset (context managed only by compression)
   229|    """
   230|    mode: str = "both"  # "daily", "idle", "both", or "none"
   231|    at_hour: int = 4  # Hour for daily reset (0-23, local time)
   232|    idle_minutes: int = 1440  # Minutes of inactivity before reset (24 hours)
   233|    notify: bool = True  # Send a notification to the user when auto-reset occurs
   234|    notify_exclude_platforms: tuple = ("api_server", "webhook")  # Platforms that don't get reset notifications
   235|
   236|    def to_dict(self) -> Dict[str, Any]:
   237|        return {
   238|            "mode": self.mode,
   239|            "at_hour": self.at_hour,
   240|            "idle_minutes": self.idle_minutes,
   241|            "notify": self.notify,
   242|            "notify_exclude_platforms": list(self.notify_exclude_platforms),
   243|        }
   244|
   245|    @classmethod
   246|    def from_dict(cls, data: Dict[str, Any]) -> "SessionResetPolicy":
   247|        # Handle both missing keys and explicit null values (YAML null → None)
   248|        mode = data.get("mode")
   249|        at_hour = data.get("at_hour")
   250|        idle_minutes = data.get("idle_minutes")
   251|        notify = data.get("notify")
   252|        exclude = data.get("notify_exclude_platforms")
   253|        return cls(
   254|            mode=mode if mode is not None else "both",
   255|            at_hour=at_hour if at_hour is not None else 4,
   256|            idle_minutes=idle_minutes if idle_minutes is not None else 1440,
   257|            notify=_coerce_bool(notify, True),
   258|            notify_exclude_platforms=tuple(exclude) if exclude is not None else ("api_server", "webhook"),
   259|        )
   260|
   261|
   262|@dataclass
   263|class PlatformConfig:
   264|    """Configuration for a single messaging platform."""
   265|    enabled: bool = False
   266|    token: Optional[str] = None  # Bot token (Telegram, Discord)
   267|    api_key: Optional[str] = None  # API key if different from token
   268|    home_channel: Optional[HomeChannel] = None
   269|
   270|    # Reply threading mode (Telegram/Slack)
   271|    # - "off": Never thread replies to original message
   272|    # - "first": Only first chunk threads to user's message (default)
   273|    # - "all": All chunks in multi-part replies thread to user's message
   274|    reply_to_mode: str = "first"
   275|
   276|    # Whether the gateway is allowed to send "♻ Gateway online" /
   277|    # "♻ Gateway restarted" lifecycle notifications on this platform.
   278|    # Default True preserves prior behavior. Set False on platforms used
   279|    # by end users (e.g. Slack) where operator-flavored restart pings are
   280|    # noise; keep True for back-channels where the operator wants them.
   281|    gateway_restart_notification: bool = True
   282|
   283|    # Platform-specific settings
   284|    extra: Dict[str, Any] = field(default_factory=dict)
   285|
   286|    def to_dict(self) -> Dict[str, Any]:
   287|        result = {
   288|            "enabled": self.enabled,
   289|            "extra": self.extra,
   290|            "reply_to_mode": self.reply_to_mode,
   291|            "gateway_restart_notification": self.gateway_restart_notification,
   292|        }
   293|        if self.token:
   294|            result["token"] = self.token
   295|        if self.api_key:
   296|            result["api_key"] = self.api_key
   297|        if self.home_channel:
   298|            result["home_channel"] = self.home_channel.to_dict()
   299|        return result
   300|
   301|

Use offset=301 to continue reading (showing 181-300 of 1803 lines)


   520|    """Re-validate each redirect target to prevent redirect-based SSRF.
   521|
   522|    Without this, an attacker can host a public URL that 302-redirects to
   523|    http://169.254.169.254/ and bypass the pre-flight is_safe_url() check.
   524|
   525|    Must be async because httpx.AsyncClient awaits response event hooks.
   526|    """
   527|    if response.is_redirect and response.next_request:
   528|        redirect_url = str(response.next_request.url)
   529|        from tools.url_safety import is_safe_url
   530|        if not is_safe_url(redirect_url):
   531|            raise ValueError(
   532|                f"Blocked redirect to private/internal address: {safe_url_for_log(redirect_url)}"
   533|            )
   534|
   535|
   536|# ---------------------------------------------------------------------------
   537|# Image cache utilities
   538|#
   539|# When users send images on messaging platforms, we download them to a local
   540|# cache directory so they can be analyzed by the vision tool (which accepts
   541|# local file paths). This avoids issues with ephemeral platform URLs
   542|# (e.g. Telegram file URLs expire after ~1 hour).
   543|# ---------------------------------------------------------------------------
   544|
   545|# Default location: {HERMES_HOME}/cache/images/ (legacy: image_cache/)
   546|IMAGE_CACHE_DIR = get_hermes_dir("cache/images", "image_cache")
   547|
   548|
   549|def get_image_cache_dir() -> Path:
   550|    """Return the image cache directory, creating it if it doesn't exist."""
   551|    IMAGE_CACHE_DIR.mkdir(parents=True, exist_ok=True)
   552|    return IMAGE_CACHE_DIR
   553|
   554|
   555|def _looks_like_image(data: bytes) -> bool:
   556|    """Return True if *data* starts with a known image magic-byte sequence."""
   557|    if len(data) < 4:
   558|        return False
   559|    if data[:8] == b"\x89PNG\r\n\x1a\n":
   560|        return True
   561|    if data[:3] == b"\xff\xd8\xff":
   562|        return True
   563|    if data[:6] in {b"GIF87a", b"GIF89a"}:
   564|        return True
   565|    if data[:2] == b"BM":
   566|        return True
   567|    if data[:4] == b"RIFF" and len(data) >= 12 and data[8:12] == b"WEBP":
   568|        return True
   569|    return False
   570|
   571|
   572|def cache_image_from_bytes(data: bytes, ext: str = ".jpg") -> str:
   573|    """
   574|    Save raw image bytes to the cache and return the absolute file path.
   575|
   576|    Args:
   577|        data: Raw image bytes.
   578|        ext:  File extension including the dot (e.g. ".jpg", ".png").
   579|
   580|    Returns:
   581|        Absolute path to the cached image file as a string.
   582|
   583|    Raises:
   584|        ValueError: If *data* does not look like a valid image (e.g. an HTML
   585|            error page returned by the upstream server).
   586|    """
   587|    if not _looks_like_image(data):
   588|        snippet = data[:80].decode("utf-8", errors="replace")
   589|        raise ValueError(
   590|            f"Refusing to cache non-image data as {ext} "
   591|            f"(starts with: {snippet!r})"
   592|        )
   593|    cache_dir = get_image_cache_dir()
   594|    filename = f"img_{uuid.uuid4().hex[:12]}{ext}"
   595|    filepath = cache_dir / filename
   596|    filepath.write_bytes(data)
   597|    return str(filepath)
   598|
   599|
   600|async def cache_image_from_url(url: str, ext: str = ".jpg", retries: int = 2) -> str:
   601|    """
   602|    Download an image from a URL and save it to the local cache.
   603|
   604|    Retries on transient failures (timeouts, 429, 5xx) with exponential
   605|    backoff so a single slow CDN response doesn't lose the media.
   606|
   607|    Args:
   608|        url: The HTTP/HTTPS URL to download from.
   609|        ext: File extension including the dot (e.g. ".jpg", ".png").
   610|        retries: Number of retry attempts on transient failures.
   611|
   612|    Returns:
   613|        Absolute path to the cached image file as a string.
   614|
   615|    Raises:
   616|        ValueError: If the URL targets a private/internal network (SSRF protection).
   617|    """
   618|    from tools.url_safety import is_safe_url
   619|    if not is_safe_url(url):
   620|

Use offset=620 to continue reading (showing 520-619 of 3716 lines)


# Auto-loaded skill(s) for topic/channel bindings (e.g., Telegram DM Topics,

# Discord channel_skill_bindings).  A single name or ordered list.

auto_skill: Optional[str | list[str]] = None

# Per-channel ephemeral system prompt (e.g. Discord channel_prompts).

# Applied at API call time and never persisted to transcript history.

channel_prompt: Optional[str] = None

# Internal flag — set for synthetic events (e.g. background process

parent_id: str | None = None,

) -> str | None:

"""Resolve a per-channel ephemeral prompt from platform config.

Looks up ``channel_prompts`` in the adapter's ``config.extra`` dict.

Prefers an exact match on *channel_id*; falls back to *parent_id*

(useful for forum threads / child channels inheriting a parent prompt).

Returns the prompt string, or None if no match is found.  Blank/whitespace-

only prompts are treated as absent.

prompts = config_extra.get("channel_prompts") or {}

if not isinstance(prompts, dict):

return None

for key in (channel_id, parent_id):


   930|    raw_message: Any = None
   931|    message_id: Optional[str] = None
   932|
   933|    # Platform-specific update identifier.  For Telegram this is the
   934|    # ``update_id`` from the PTB Update wrapper; other platforms currently
   935|    # ignore it.  Used by ``/restart`` to record the triggering update so the
   936|    # new gateway can advance the Telegram offset past it and avoid processing
   937|    # the same ``/restart`` twice if PTB's graceful-shutdown ACK times out
   938|    # ("Error while calling `get_updates` one more time to mark all fetched
   939|    # updates" in gateway.log).
   940|    platform_update_id: Optional[int] = None
   941|
   942|    # Media attachments
   943|    # media_urls: local file paths (for vision tool access)
   944|    media_urls: List[str] = field(default_factory=list)
   945|    media_types: List[str] = field(default_factory=list)
   946|
   947|    # Reply context
   948|    reply_to_message_id: Optional[str] = None
   949|    reply_to_text: Optional[str] = None  # Text of the replied-to message (for context injection)
   950|
   951|    # Auto-loaded skill(s) for topic/channel bindings (e.g., Telegram DM Topics,
   952|    # Discord channel_skill_bindings).  A single name or ordered list.
   953|    auto_skill: Optional[str | list[str]] = None
   954|
   955|    # Per-channel ephemeral system prompt (e.g. Discord channel_prompts).
   956|    # Applied at API call time and never persisted to transcript history.
   957|    channel_prompt: Optional[str] = None
   958|
   959|    # Internal flag — set for synthetic events (e.g. background process
   960|    # completion notifications) that must bypass user authorization checks.
   961|    internal: bool = False
   962|
   963|    # Timestamps
   964|    timestamp: datetime = field(default_factory=datetime.now)
   965|
   966|    def is_command(self) -> bool:
   967|        """Check if this is a command message (e.g., /new, /reset)."""
   968|        return self.text.startswith("/")
   969|
   970|    def get_command(self) -> Optional[str]:
   971|        """Extract command name if this is a command message."""
   972|        if not self.is_command():
   973|            return None
   974|        # Split on space and get first word, strip the /
   975|        parts = self.text.split(maxsplit=1)
   976|        raw = parts[0][1:].lower() if parts else None
   977|        if raw and "@" in raw:
   978|            raw = raw.split("@", 1)[0]
   979|        # Reject file paths: valid command names never contain /
   980|        if raw and "/" in raw:
   981|            return None
   982|        return raw
   983|
   984|    def get_command_args(self) -> str:
   985|        """Get the arguments after a command."""
   986|        if not self.is_command():
   987|            return self.text
   988|        parts = self.text.split(maxsplit=1)
   989|        args = parts[1] if len(parts) > 1 else ""
   990|        # iOS auto-corrects -- to — (em dash) and - to – (en dash)
   991|        args = args.replace("\u2014\u2014", "--").replace("\u2014", "--").replace("\u2013", "-")
   992|        return args
   993|
   994|
   995|_PLAINTEXT_GATEWAY_RESTART_PATTERNS: tuple[re.Pattern[str], ...] = (
   996|    re.compile(r"^(?:please\s+)?restart\s+(?:the\s+)?gateway[.!?\s]*$", re.IGNORECASE),
   997|    re.compile(r"^(?:please\s+)?restart\s+(?:the\s+)?hermes\s+gateway[.!?\s]*$", re.IGNORECASE),
   998|    re.compile(r"^(?:please\s+)?restart\s+hermes[.!?\s]*$", re.IGNORECASE),
   999|)
  1000|
  1001|
  1002|def coerce_plaintext_gateway_command(event: "MessageEvent") -> None:
  1003|    """Rewrite a tiny set of DM plaintext admin phrases into slash commands.
  1004|
  1005|    This keeps high-impact operational phrases like ``restart gateway`` out of
  1006|    the LLM/tool path, where they can trigger a self-restart from inside the
  1007|    currently running agent and leave the gateway stuck in ``draining`` while it
  1008|    waits for that same agent to finish.
  1009|
  1010|    Scope is intentionally narrow: DM text messages only, exact restart-style
  1011|    phrases only. Group chats keep natural-language semantics.
  1012|    """
  1013|    try:
  1014|        if event is None or event.message_type != MessageType.TEXT:
  1015|            return
  1016|        text = (event.text or "").strip()
  1017|        if not text or text.startswith("/"):
  1018|            return
  1019|        source = getattr(event, "source", None)
  1020|        if getattr(source, "chat_type", None) != "dm":
  1021|            return
  1022|        for pattern in _PLAINTEXT_GATEWAY_RESTART_PATTERNS:
  1023|            if pattern.match(text):
  1024|                event.text = "/restart"
  1025|

Use offset=1025 to continue reading (showing 930-1024 of 3716 lines)


  1170|# ``None`` when the response was already delivered (e.g. via streaming).
  1171|MessageHandler = Callable[[MessageEvent], Awaitable[Optional[Union[str, "EphemeralReply"]]]]
  1172|
  1173|
  1174|def resolve_channel_prompt(
  1175|    config_extra: dict,
  1176|    channel_id: str,
  1177|    parent_id: str | None = None,
  1178|) -> str | None:
  1179|    """Resolve a per-channel ephemeral prompt from platform config.
  1180|
  1181|    Looks up ``channel_prompts`` in the adapter's ``config.extra`` dict.
  1182|    Prefers an exact match on *channel_id*; falls back to *parent_id*
  1183|    (useful for forum threads / child channels inheriting a parent prompt).
  1184|
  1185|    Returns the prompt string, or None if no match is found.  Blank/whitespace-
  1186|    only prompts are treated as absent.
  1187|    """
  1188|    prompts = config_extra.get("channel_prompts") or {}
  1189|    if not isinstance(prompts, dict):
  1190|        return None
  1191|
  1192|    for key in (channel_id, parent_id):
  1193|        if not key:
  1194|            continue
  1195|        prompt = prompts.get(key)
  1196|        if prompt is None:
  1197|            continue
  1198|        prompt = str(prompt).strip()
  1199|        if prompt:
  1200|            return prompt
  1201|    return None
  1202|
  1203|
  1204|def resolve_channel_skills(
  1205|    config_extra: dict,
  1206|    channel_id: str,
  1207|    parent_id: str | None = None,
  1208|) -> list[str] | None:
  1209|    """Resolve auto-loaded skill(s) for a channel/thread from platform config.
  1210|
  1211|    Looks up ``channel_skill_bindings`` in the adapter's ``config.extra`` dict.
  1212|
  1213|    Config format::
  1214|
  1215|        channel_skill_bindings:
  1216|          - id: "C0123"          # Slack channel ID or Discord channel/forum ID
  1217|            skills: ["skill-a", "skill-b"]
  1218|          - id: "D0ABCDE"
  1219|            skill: "solo-skill"  # single string also accepted
  1220|
  1221|    Prefers an exact match on *channel_id*; falls back to *parent_id*
  1222|    (useful for forum threads / Slack threads inheriting the parent channel's
  1223|    binding).
  1224|
  1225|    Returns a deduplicated list of skill names (order preserved), or None if
  1226|    no match is found.
  1227|    """
  1228|    bindings = config_extra.get("channel_skill_bindings") or []
  1229|    if not isinstance(bindings, list) or not bindings:
  1230|        return None
  1231|    ids_to_check: set[str] = set()
  1232|    if channel_id:
  1233|        ids_to_check.add(str(channel_id))
  1234|    if parent_id:
  1235|        ids_to_check.add(str(parent_id))
  1236|    if not ids_to_check:
  1237|        return None
  1238|    for entry in bindings:
  1239|        if not isinstance(entry, dict):
  1240|

Use offset=1240 to continue reading (showing 1170-1239 of 3716 lines)


from pathlib import Path
p=Path('/home/agent/.hermes/hermes-agent/gateway/platforms/telegram.py')
lines=p.read_text(errors='ignore').splitlines()
for i,l in enumerate(lines,1):
    if any(t in l for t in ['message_thread_id','channel_prompt','resolve_channel_prompt','thread_id']):
        print(f'{i}|{l[:220]}')


Output:
323|    - Forum topics (thread_id support)
419|        # DM Topics: map of topic_name -> message_thread_id (populated at startup)
464|        thread_id: Optional[str] = None,
482|                    normalized_chat_type = "forum" if thread_id is not None else "group"
490|                    thread_id=str(thread_id) if thread_id is not None else None,
507|    def _metadata_thread_id(cls, metadata: Optional[Dict[str, Any]]) -> Optional[str]:
510|        thread_id = metadata.get("thread_id") or metadata.get("message_thread_id")
511|        return str(thread_id) if thread_id is not None else None
543|        thread_id: Optional[str],
549|        Supergroup/forum topics use ``message_thread_id``. True Bot API Direct
561|            return {"message_thread_id": cls._message_thread_id_for_send(thread_id)}
565|                "message_thread_id": None,
568|        return {"message_thread_id": cls._message_thread_id_for_send(thread_id)}
571|    def _message_thread_id_for_send(cls, thread_id: Optional[str]) -> Optional[int]:
572|        if not thread_id or str(thread_id) == cls._GENERAL_TOPIC_THREAD_ID:
574|        return int(thread_id)
577|    def _message_thread_id_for_typing(cls, thread_id: Optional[str]) -> Optional[int]:
578|        # Asymmetric with _message_thread_id_for_send on purpose. Telegram's
580|        # topic) differently: sends reject message_thread_id=1 and must omit it,
581|        # but sendChatAction needs message_thread_id=1 to place the typing
584|        # sends still map "1" → None via _message_thread_id_for_send.
585|        if not thread_id:
587|        return int(thread_id)
648|            retry_kwargs.pop("message_thread_id", None)
938|        Returns the message_thread_id on success, None on failure.
950|            thread_id = topic.message_thread_id
952|                "[%s] Created DM topic '%s' in chat %s -> thread_id=%s",
953|                self.name, name, chat_id, thread_id,
955|            return thread_id
988|        ``message_thread_id`` as a string, or ``None`` on failure.
994|        thread_id = await self._create_dm_topic(chat_id_int, name=name)
995|        return str(thread_id) if thread_id else None
1000|        thread_id: int,
1012|            message_thread_id=int(thread_id),
1016|            "[%s] Renamed DM topic in chat %s thread_id=%s -> '%s'",
1017|            self.name, chat_id, thread_id, name,
1020|    def _persist_dm_topic_thread_id(self, chat_id: int, topic_name: str, thread_id: int) -> None:
1021|        """Save a newly created thread_id back into config.yaml so it persists across restarts."""
1026|                logger.warning("[%s] Config file not found at %s, cannot persist thread_id", self.name, config_path)
1048|                    if t.get("name") == topic_name and not t.get("thread_id"):
1049|                        t["thread_id"] = thread_id
1072|                    "[%s] Persisted thread_id=%s for topic '%s' in config.yaml",
1073|                    self.name, thread_id, topic_name,
1076|            logger.warning("[%s] Failed to persist thread_id to config: %s", self.name, e, exc_info=True)
1086|                    {"name": "General", "icon_color": 7322096, "thread_id": 100},
1092|        If a topic already has a thread_id in the config (persisted from a previous
1094|        Only topics without a thread_id are created via the API, and their thread_id
1118|                # If thread_id is already persisted in config, just load into cache
1119|                existing_thread_id = topic_conf.get("thread_id")
1120|                if existing_thread_id:
1121|                    self._dm_topics[cache_key] = int(existing_thread_id)
1123|                        "[%s] DM topic loaded from config: %s -> thread_id=%s",
1124|                        self.name, cache_key, existing_thread_id,
1128|                # No persisted thread_id — create the topic via API
1132|                thread_id = await self._create_dm_topic(
1139|                if thread_id:
1140|                    self._dm_topics[cache_key] = thread_id
1142|                        "[%s] DM topic cached: %s -> thread_id=%s",
1143|                        self.name, cache_key, thread_id,
1145|                    # Persist thread_id to config so we don't recreate on next restart
1146|                    self._persist_dm_topic_thread_id(int(chat_id), topic_name, thread_id)
1153|                            message_thread_id=thread_id,
1522|            thread_id = self._metadata_thread_id(metadata)
1552|                    thread_id,
1556|                effective_thread_id = thread_kwargs.get("message_thread_id")
1595|                            if self._is_thread_not_found_error(send_err) and effective_thread_id is not None:
1597|                                # message_thread_id so the message still
1600|                                    "[%s] Thread %s not found, retrying without message_thread_id",
1601|                                    self.name, effective_thread_id,
1603|                                effective_thread_id = None
1604|                                thread_kwargs = {"message_thread_id": None}
1610|                                # sends, message_thread_id is only valid with
1619|                                    effective_thread_id = None
1623|                                        thread_id,
1627|                                    effective_thread_id = thread_kwargs.get("message_thread_id")
2006|        thread_id = self._metadata_thread_id(metadata)
2007|        if thread_id is not None:
2008|            kwargs["message_thread_id"] = thread_id
2028|        """Send a Telegram message, retrying once without message_thread_id
2032|        update prompts) that can carry a stale thread_id from a DM
2040|        message_thread_id = kwargs.get("message_thread_id")
2045|                message_thread_id is not None
2050|                    "[%s] Thread %s not found for control message, retrying without message_thread_id",
2052|                    message_thread_id,
2055|                retry_kwargs.pop("message_thread_id", None)
2080|            thread_id = self._metadata_thread_id(metadata)
2090|                    thread_id,
2123|            thread_id = self._metadata_thread_id(metadata)
2156|                    thread_id,
2195|            thread_id = self._metadata_thread_id(metadata)
2208|                    thread_id,
2246|            thread_id = self._metadata_thread_id(metadata)
2282|                    thread_id,
2344|            thread_id = metadata.get("thread_id") if metadata else None
2354|                    thread_id,
2610|        query_thread_id = getattr(query_message, "message_thread_id", None)
2637|                    thread_id=str(query_thread_id) if query_thread_id is not None else None,
2694|                    thread_id=str(query_thread_id) if query_thread_id is not None else None,
2735|                        # use message_thread_id; Telegram private DM-topic lanes
2737|                        thread_id = getattr(query.message, "message_thread_id", None)
2753|                        if thread_id is not None and is_private_chat and prompt_message_id is not None:
2759|                                    str(thread_id),
2761|                                        "thread_id": str(thread_id),
2767|                        elif thread_id is not None:
2771|                                    str(thread_id),
2772|                                    {"thread_id": str(thread_id)},
2792|                    thread_id=str(query_thread_id) if query_thread_id is not None else None,
2894|            thread_id=str(query_thread_id) if query_thread_id is not None else None,
2959|                    _voice_thread = self._metadata_thread_id(metadata)
2984|                    _audio_thread = self._metadata_thread_id(metadata)
3079|        _thread = self._metadata_thread_id(metadata)
3178|            _thread = self._metadata_thread_id(metadata)
3272|            _thread = self._metadata_thread_id(metadata)
3320|            _thread = self._metadata_thread_id(metadata)
3372|            _photo_thread = self._metadata_thread_id(metadata)
3454|            _anim_thread = self._metadata_thread_id(metadata)
3491|                _typing_thread = self._metadata_thread_id(metadata)
3493|                # lanes: send_chat_action only accepts message_thread_id, which
3500|                message_thread_id = self._message_thread_id_for_typing(_typing_thread)
3501|                # No retry-without-thread fallback here: _message_thread_id_for_typing
3509|                    message_thread_id=message_thread_id,
3946|        thread_id = getattr(message, "message_thread_id", None)
3947|        if thread_id is not None:
3949|                if int(thread_id) in self._telegram_ignored_threads():
3952|                logger.warning("[%s] Ignoring non-numeric Telegram message_thread_id: %r", self.name, thread_id)
4520|        """Re-read dm_topics from config.yaml and load any new thread_ids into cache.
4544|            # Update in-memory config and cache any new thread_ids
4551|                    tid = t.get("thread_id")
4558|                                "[%s] Hot-loaded DM topic from config: %s -> thread_id=%s",
4564|    def _get_dm_topic_info(self, chat_id: str, thread_id: Optional[str]) -> Optional[Dict[str, Any]]:
4565|        """Look up DM topic config by chat_id and thread_id.
4567|        Returns the topic config dict (name, skill, etc.) if this thread_id
4570|        if not thread_id:
4573|        thread_id_int = int(thread_id)
4577|            if cached_tid == thread_id_int and key.startswith(f"{chat_id}:"):
4592|            if cached_tid == thread_id_int and key.startswith(f"{chat_id}:"):
4603|    def _cache_dm_topic_from_message(self, chat_id: str, thread_id: str, topic_name: str) -> None:
4604|        """Cache a thread_id -> topic_name mapping discovered from an incoming message."""
4607|            self._dm_topics[cache_key] = int(thread_id)
4609|                "[%s] Cached DM topic from message: %s -> thread_id=%s",
4610|                self.name, cache_key, thread_id,
4638|        # (is_topic_message=True).  Telegram puts message_thread_id on every
4643|        thread_id_raw = message.message_thread_id
4645|        thread_id_str = None
4646|        if thread_id_raw is not None:
4648|                thread_id_str = str(thread_id_raw)
4650|                thread_id_str = str(thread_id_raw)
4654|        if chat_type == "group" and thread_id_str is None and getattr(chat, "is_forum", False):
4655|            thread_id_str = self._GENERAL_TOPIC_THREAD_ID
4659|        if chat_type == "dm" and thread_id_str:
4660|            topic_info = self._get_dm_topic_info(str(chat.id), thread_id_str)
4669|                    self._cache_dm_topic_from_message(str(chat.id), thread_id_str, created_name)
4673|        elif chat_type == "group" and thread_id_str:
4679|                        tid = topic.get("thread_id")
4680|                        if tid is not None and str(tid) == thread_id_str:
4693|            thread_id=thread_id_str,
4721|        from gateway.platforms.base import resolve_channel_prompt
4723|        _channel_prompt = resolve_channel_prompt(
4725|            thread_id_str or _chat_id_str,
4726|            _chat_id_str if thread_id_str else None,
4739|            channel_prompt=_channel_prompt,


  4700|        # multi-section message doesn't get the whole replied-to message
  4701|        # injected into the agent's context — which can cause the agent
  4702|        # to act on unrelated actionable-looking text the user didn't
  4703|        # quote (#22619). Fall back to the full replied-to message text
  4704|        # / caption when no native quote is present.
  4705|        reply_to_id = None
  4706|        reply_to_text = None
  4707|        if message.reply_to_message:
  4708|            reply_to_id = str(message.reply_to_message.message_id)
  4709|            quote = getattr(message, "quote", None)
  4710|            quote_text = getattr(quote, "text", None) if quote is not None else None
  4711|            if quote_text:
  4712|                reply_to_text = quote_text
  4713|            else:
  4714|                reply_to_text = (
  4715|                    message.reply_to_message.text
  4716|                    or message.reply_to_message.caption
  4717|                    or None
  4718|                )
  4719|
  4720|        # Per-channel/topic ephemeral prompt
  4721|        from gateway.platforms.base import resolve_channel_prompt
  4722|        _chat_id_str = str(chat.id)
  4723|        _channel_prompt = resolve_channel_prompt(
  4724|            self.config.extra,
  4725|            thread_id_str or _chat_id_str,
  4726|            _chat_id_str if thread_id_str else None,
  4727|        )
  4728|
  4729|        return MessageEvent(
  4730|            text=message.text or "",
  4731|            message_type=msg_type,
  4732|            source=source,
  4733|            raw_message=message,
  4734|            message_id=str(message.message_id),
  4735|            platform_update_id=update_id,
  4736|            reply_to_message_id=reply_to_id,
  4737|            reply_to_text=reply_to_text,
  4738|            auto_skill=topic_skill,
  4739|            channel_prompt=_channel_prompt,
  4740|            timestamp=message.date,
  4741|        )
  4742|
  4743|    # ── Message reactions (processing lifecycle) ──────────────────────────
  4744|
  4745|    def _reactions_enabled(self) -> bool:
  4746|        """Check if message reactions are enabled via config/env."""
  4747|        return os.getenv("TELEGRAM_REACTIONS", "false").lower() not in {"false", "0", "no"}
  4748|
  4749|    async def _set_reaction(self, chat_id: str, message_id: str, emoji: str) -> bool:
  4750|        """Set a single emoji reaction on a Telegram message."""
  4751|        if not self._bot:
  4752|            return False
  4753|        try:
  4754|            await self._bot.set_message_reaction(
  4755|                chat_id=int(chat_id),
  4756|                message_id=int(message_id),
  4757|                reaction=emoji,
  4758|            )
  4759|            return True
  4760|        except Exception as e:
  4761|            logger.debug("[%s] set_message_reaction failed (%s): %s", self.name, emoji, e)
  4762|            return False
  4763|
  4764|    async def _clear_reactions(self, chat_id: str, message_id: str) -> bool:
  4765|        """Clear all reactions from a Telegram message.
  4766|
  4767|        Calling ``set_message_reaction`` with ``reaction=None`` (or an empty
  4768|        sequence) is the documented Bot API way to remove all bot-set
  4769|        reactions on a message — equivalent to Bot API 10.0's
  4770|        ``deleteMessageReaction`` but supported in PTB 22.6 already.
  4771|        """
  4772|        if not self._bot:
  4773|            return False
  4774|        try:
  4775|            await self._bot.set_message_reaction(
  4776|                chat_id=int(chat_id),
  4777|                message_id=int(message_id),
  4778|                reaction=None,
  4779|            )
  4780|            return True
  4781|        except Exception as e:
  4782|            logger.debug("[%s] clear reactions failed: %s", self.name, e)
  4783|            return False
  4784|
  4785|    async def on_processing_start(self, event: MessageEvent) -> None:
  4786|        """Add an in-progress reaction when message processing begins."""
  4787|        if not self._reactions_enabled():
  4788|            return
  4789|        chat_id = getattr(event.source, "chat_id", None)
  4790|

Use offset=4790 to continue reading (showing 4700-4789 of 4820 lines)


Explanation:
**Exploring chat IDs**

I realize I need to set the telegram.channel_prompts mapping using thread IDs. I’m not sure if I need the group chat ID since the available target list doesn’t include it. The current session context mentions group JARVIS, thread 11, but there's no chat ID available. I think the targets in the send_message list might not have IDs either. I should check if the config has allowed_chats and see if there’s a channel directory.