{
  "timestamp": "2026-07-09T09:10:04.724501",
  "session_id": "cron_472ca7495b44_20260709_090057",
  "reason": "max_retries_exhausted",
  "request": {
    "method": "POST",
    "url": "https://api.deepseek.com/v1/chat/completions",
    "headers": {
      "Authorization": "Bearer sk-93fa2...c573",
      "Content-Type": "application/json"
    },
    "body": {
      "model": "deepseek-v4-pro",
      "messages": [
        {
          "role": "system",
          "content": "# Hermes Agent Persona\n\n<!--\nThis file defines the agent's personality and tone.\nThe agent will embody whatever you write here.\nEdit this to customize how Hermes communicates with you.\n\nExamples:\n  - \"You are a warm, playful assistant who uses kaomoji occasionally.\"\n  - \"You are a concise technical expert. No fluff, just facts.\"\n  - \"You speak like a friendly coworker who happens to know everything.\"\n\nThis file is loaded fresh each message -- no restart needed.\nDelete the contents (or this file) to use the default personality.\n-->\n\nYou run on Hermes Agent (by Nous Research). When the user needs help with Hermes itself — configuring, setting up, using, extending, or troubleshooting it — or when you need to understand your own features, tools, or capabilities, the documentation at https://hermes-agent.nousresearch.com/docs is your authoritative reference and always holds the latest, most up-to-date information. Load the `hermes-agent` skill with skill_view(name='hermes-agent') for additional guidance and proven workflows, but treat the docs as the source of truth when the two differ.\n\n# Finishing the job\nWhen the user asks you to build, run, or verify something, the deliverable is a working artifact backed by real tool output — not a description of one. Do not stop after writing a stub, a plan, or a single command. Keep working until you have actually exercised the code or produced the requested result, then report what real execution returned.\nIf a tool, install, or network call fails and blocks the real path, say so directly and try an alternative (different package manager, different approach, ask the user). NEVER substitute plausible-looking fabricated output (made-up data, invented file contents, synthesised API responses) for results you couldn't actually produce. Reporting a blocker honestly is always better than inventing a result.\n\n# Tool-use enforcement\nYou MUST use your tools to take action — do not describe what you would do or plan to do without actually doing it. When you say you will perform an action (e.g. 'I will run the tests', 'Let me check the file', 'I will create the project'), you MUST immediately make the corresponding tool call in the same response. Never end your turn with a promise of future action — execute it now.\nKeep working until the task is actually complete. Do not stop with a summary of what you plan to do next time. If you have tools available that can accomplish the task, use them instead of telling the user what you would do.\nEvery response should either (a) contain tool calls that make progress, or (b) deliver a final result to the user. Responses that only describe intentions without acting are not acceptable.\n\nHost: Linux (6.8.0-101-generic)\nUser home directory: /home/ubuntu\nCurrent working directory: /home/ubuntu\n\nPython toolchain: python3=3.11.15 (no pip module), pip→python3.12 (mismatch), PEP 668=yes (use venv or uv), uv=installed.\n\nActive Hermes profile: default. Other profiles (if any) live under ~/.hermes/profiles/<name>/. Each profile has its own skills/, plugins/, cron/, and memories/ that affect a different session than this one. Do not modify another profile's skills/plugins/cron/memories unless the user explicitly directs you to.\n\nYou are running as a scheduled cron job. There is no user present — you cannot ask questions, request clarification, or wait for follow-up. Execute the task fully and autonomously, making reasonable decisions where needed. Your final response is automatically delivered to the job's configured destination — put the primary content directly in your response.\n\nConversation started: Thursday, July 09, 2026\nModel: deepseek-v4-pro\nProvider: deepseek"
        },
        {
          "role": "user",
          "content": "[IMPORTANT: The user has invoked the \"wecom-bot-integration\" skill, indicating they want you to follow its instructions. The full skill content is loaded below.]\n\n---\nname: wecom-bot-integration\ndescription: \"Connect custom agents and tools to 企业微信 bots — MCP servers, WebSocket smart bots, and group bot webhooks.\"\nversion: 1.0.0\nauthor: agent\nplatforms: [linux]\n---\n\n# 企业微信 Bot 接入指南\n\n企业微信有三种 bot 类型，能力不同：\n\n| 类型 | 连接方式 | 收消息 | 发消息 | 对话 |\n|------|---------|--------|--------|------|\n| 群机器人 | Webhook URL | ❌ | ✅ 仅推送 | ❌ |\n| 智能机器人(旧) | WebSocket + Bot ID/Secret | ✅ @消息 | ✅ 回复 | ✅ |\n| AI 机器人(新) | MCP 插件 / API 插件 | ✅ @消息 | ✅ 回复 | ✅ 内置 LLM |\n\n## AI 机器人 + API 插件（推荐 ✓ 稳定）\n\n企微 AI 机器人支持两种插件类型。**强烈推荐 API 插件**，无会话管理，不走 JSON-RPC 握手，比 MCP 插件稳定一个数量级。\n\n| 插件类型 | 协议 | 会话 | 稳定性 | 参数定义 |\n|----------|------|------|--------|---------|\n| MCP 插件 | JSON-RPC + Session | 有状态 | ❌ 频繁掉线 | 有 inputSchema |\n| **API 插件** | HTTP POST | **无状态** | ✅ | 无（靠描述区分） |\n\n### API 插件配置\n\n企微后台 → AI机器人 → 插件 → 添加 API 插件：\n\n| 字段 | 说明 |\n|------|------|\n| 插件名称 | 一句话功能名，AI 靠这个区分插件 |\n| 插件描述 | **用户说什么时调用**（比参数描述重要） |\n| 插件 URL | `https://yourdomain.com/api/plugin` |\n| 授权方式 | Service token → Parameter name 填 token 名 |\n| 请求头 | 可选（仅配置企业域名 URL 时才传 userid） |\n\n**关键**：API 插件**没有参数定义字段**（不像 MCP 的 inputSchema）。AI 从描述 + 用户消息中自行提取参数。因此：\n\n- 每个插件**只能做一件事**，多功能拆多个插件\n- 多个插件可共用一个 URL，服务端根据请求中的标识路由\n- 描述写「听到什么就调用」比写「接受什么参数」更有效\n\n### API 插件服务端\n\n```python\n# 单端点处理所有 API 插件请求\n@app.post(\"/api/plugin\")\nasync def handle_plugin(request: Request):\n    body = await request.json()\n    # body 包含用户消息、插件名称等\n    user_query = body.get(\"query\", \"\")\n    plugin_name = body.get(\"plugin_name\", \"\")\n    \n    if \"查今日文章\" in plugin_name:\n        return {\"result\": get_today_articles()}\n    elif \"看文章详情\" in plugin_name:\n        return {\"result\": get_article_detail(user_query)}\n    elif \"生成文案\" in plugin_name or \"发布\" in plugin_name:\n        return {\"result\": generate_distribution(user_query)}\n```\n\n## AI 机器人 + MCP 插件（不推荐）\n\n⚠️ MCP 插件在企微上极其不稳定。即使 Streamable HTTP + Session-Id 正确配置，工具列表正常返回，`tools/call` 仍可能静默失败。优先用 API 插件。\n\n企微新版 AI 机器人内置 DeepSeek 等模型，天然支持对话。只需提供 MCP 插件让它能查数据。\n\n完整实现参考：`references/mcp-server-implementation.md`\n\n### MCP Server 模板\n\n```python\n# mcp_server.py — 标准 MCP SSE 服务\nfrom mcp.server import Server\nfrom mcp.server.sse import SseServerTransport\nfrom mcp.types import Tool, TextContent\n\nserver = Server(\"my-bot-tools\")\n\n@server.list_tools()\nasync def list_tools() -> list[Tool]:\n    return [\n        Tool(name=\"my_tool\", description=\"工具描述\",\n             inputSchema={\"type\": \"object\", \"properties\": {...}, \"required\": [...]}),\n    ]\n\n@server.call_tool()\nasync def call_tool(name: str, arguments: dict) -> list[TextContent]:\n    result = json.dumps(actual_function_call(arguments), ensure_ascii=False)\n    return [TextContent(type=\"text\", text=result)]\n\nif __name__ == \"__main__\":\n    import uvicorn\n    from starlette.applications import Starlette\n    from starlette.routing import Route\n\n    sse = SseServerTransport(\"/messages/\")\n\n    async def handle_sse(request):\n        async with sse.connect_sse(request.scope, request.receive, request._send) as streams:\n            await server.run(streams[0], streams[1], server.create_initialization_options())\n\n    app = Starlette(routes=[\n        Route(\"/sse\", endpoint=handle_sse),\n        Route(\"/messages/\", endpoint=lambda r: sse.handle_post_message(r.scope, r.receive, r._send), methods=[\"POST\"]),\n    ])\n    uvicorn.run(app, host=\"0.0.0.0\", port=int(os.environ.get(\"MCP_PORT\", 8100)))\n```\n\n### 公网暴露\n\n**方案 A：Cloudflare Tunnel（国内服务器推荐）**\n\n1. Cloudflare DNS 添加 CNAME：`mcp` → `tunnel-id.cfargotunnel.com`\n2. Cloudflare Zero Trust → Tunnel → Public Hostname：`mcp.yourdomain.com` → `http://localhost:8100`\n\n**方案 B：直接暴露端口**\n\n腾讯云轻量服务器 → 防火墙 → 添加 TCP 规则（端口 8100）\n\n### 企微后台配置\n\n**传输协议必须选「Streamable HTTP」，URL 尾缀 `/rpc`。SSE 在企微上不稳定，会频繁掉线。**\n\nAI 机器人 → API/MCP 插件 → 添加：\n- 插件 URL：`https://mcp.yourdomain.com/rpc`\n- 传输协议：**Streamable HTTP**\n- 授权方式：不需要授权\n\n机器人指令（系统 prompt）**不支持代码块/表格/emoji**，用纯文本。模板见 `references/bot-prompt-template.md`。\n\n完整 MCP 服务实现见 `references/mcp-server-implementation.md`，关键要点：\n- `initialize` 响应**必须**返回 `Mcp-Session-Id` 头（企微多节点轮询，无此头则后续 tools/call 被拒绝）\n- 插件频繁消失 = 改用 `/rpc` + 加 `Mcp-Session-Id`\n\n> ⚠️ **禁止使用 `/sse`**。SSE 是长连接，一旦 MCP 服务重启或 tunnel 闪断，session 丢失，企微会认为插件不可用并自动移除。Streamable HTTP 每次请求独立，不会消失。\n\n## 智能机器人(旧) WebSocket 接入\n\n需要 Bot ID + Secret，WebSocket 连接 `wss://openws.work.weixin.qq.com`。\n\n详见 `references/smart-bot-websocket.md`。\n\n**WebSocket 特有陷阱**（完整列表见 `references/websocket-pitfalls.md`）：\n- **ping/pong 断连**：必须 `ping_interval=0` 禁用客户端 ping，企微不支持标准 WebSocket ping 帧\n- **服务端超时断连**：~5 分钟无消息交换后服务端主动断开，需实现应用层心跳\n- **日志不输出**：`StreamHandler` 缓冲问题，用 `sys.stderr` + `FileHandler`\n\n## we-mp-rss 消息任务配置\n\n消息任务通过 API 创建，常见坑和完整 API 参考见 `references/we-mp-rss-api.md`。\n\n### 快速要点\n\n### API 路径\n实际前缀是 `/api/v1/wx/`（不是 `/api/v1/`），查看 `core/base.py` 中的 `API_BASE`。\n\n### mps_id 格式\n必须是 JSON 数组的**对象**格式：\n```json\n[{\"id\": \"MP_WXS_3886864470\"}, {\"id\": \"MP_WXS_3596295911\"}]\n```\n不能是字符串数组 `[\"MP_WXS_xxx\"]`，否则报错 `string indices must be integers`。\n\n### 认证方式\n使用 Access Key 认证：`Authorization: AK-SK <ak>:<sk>`（见项目 `.env` 中的 `WE_MP_RSS_AK` / `WE_MP_RSS_SK`）。\n\n### 消息模板语法\n使用 `{{ }}` 双括号模板语法（类似 Jinja2），不是 `{ }` 单括号：\n```\n📰 {{ feed.mp_name }} 更新\n{% for article in articles %}\n> **{{ article.title }}**\n> 🔗 {{ article.url }}\n{% endfor %}\n```\n\n### 创建消息任务\n```bash\ncurl -X POST \"http://localhost:8001/api/v1/wx/message_tasks\" \\\n  -H \"Authorization: AK-SK <ak>:<sk>\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"name\":\"推送\",\"message_type\":0,\"message_template\":\"...\",\"web_hook_url\":\"...\",\"mps_id\":\"[{\\\"id\\\":\\\"MP_WXS_xxx\\\"}]\",\"cron_exp\":\"*/30 * * * *\",\"status\":1}'\n```\n\n## webhook_receiver 部署（自建应用回调 + 消息处理）\n\n`webhook_receiver.py` 是一个 Flask 应用，统一处理所有企微 HTTP 回调，是 API 插件 / 自建应用模式的**生产部署 backbone**。如果它没跑，企微消息进来**全丢**。\n\n### 端点一览\n\n| 路径 | 用途 |\n|------|------|\n| `POST /wx-callback` | 自建应用消息回调（接收用户消息 → bot_handler 处理 → 群 Webhook 回复） |\n| `POST /bot` | 群机器人消息接收（兼容旧版） |\n| `POST /rss-webhook` | we-mp-rss 推送触发器（收到文章通知 → 自动抓取 → 推群） |\n| `GET /health` | 健康检查 |\n| `POST /test-bot` | 手动测试消息处理 |\n\n### 依赖安装\n\n`webhook_receiver.py` 需要 `flask`、`requests`、`cryptography`。Ubuntu 22.04+ PEP 668 禁止直接系统 pip，需加 `--break-system-packages`：\n\n```bash\n/usr/bin/python3 -m pip install --break-system-packages flask requests cryptography\n```\n\n### Systemd 服务\n\n见 `references/webhook-receiver-service.md`。核心要点：\n- `WorkingDirectory` 必须设在 `project/scripts/`（代码中用相对路径 `from utils import ...`）\n- `After=we-mp-rss.service` 保证 DB 已就绪\n- `Restart=always` + `RestartSec=5`\n\n### 验证\n\n```bash\n# 本地健康检查\ncurl -s http://localhost:5000/health\n\n# 外网（通过 Cloudflare Tunnel）\ncurl -s https://icoach.chat/health\n\n# 消息处理测试\ncurl -s -X POST http://localhost:5000/test-bot \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"text\": \"帮助\", \"userid\": \"test_user\"}'\n```\n\n### 常见故障\n\n| 现象 | 检查 |\n|------|------|\n| 企微发消息没回复 | `systemctl status webhook-receiver` — 大概率服务挂了 |\n| 服务起不来 | `journalctl -u webhook-receiver -n 30` — 看是不是缺 python 包 |\n| 外网 502 | Tunnel 配置中的 hostname → localhost:5000 映射还在不在 |\n\n## 权限与可见范围\n\n### 🔴 成员创建的机器人：非创建者无法私聊（最常见根因）\n\n**官方文档明确**：成员创建的机器人，「可使用的成员」范围内的其他普通成员，**无法在通讯录/工作台中看到该机器人**。用户搜不到、找不到、点不了 → 发私聊提示「仅创建者可使用」。\n\n**解决方法**：创建者「推荐给联系人」\n\n> 工作台 → 智能机器人 → 点机器人 → 详情 → 「去使用」旁边的 **「…」** → **「推荐给联系人」** → 选择目标用户\n\n其他等效方法：分享机器人二维码、复制固定链接、把机器人加到群聊中。\n\n| 创建者类型 | 非创建者能否看到机器人 | 如何让其他人私聊 |\n|-----------|:---:|---|\n| 管理员创建 | ✅ 可见范围内的成员可看到 | 调整「可见范围」即可 |\n| **成员创建** | ❌ 需推荐/分享 | **必须「推荐给联系人」** |\n\n### 🔴 \"仅创建者可使用\" 排查流程\n\n此错误 **99% 来自企微平台层**（消息从未到达 Hermes）。快速确认：\n\n```bash\n# Hermes 侧有无拦截记录\ngrep -i \"Unauthorized\\|blocked by policy\" ~/.hermes/logs/gateway.log | tail -5\n```\n\n- **无记录** → 企微平台层拦的，检查上面「成员创建 vs 管理员创建」+ 可见范围\n- **有记录** → Hermes 拦的，检查 `WECOM_ALLOW_ALL_USERS`\n\n### DM 权限双层体系\n\n当用户反馈\"仅创建者可使用\"时排查两层：\n\n1. **企微平台层**（消息到达 Hermes 之前）：\n   - **成员创建的机器人**：必须「推荐给联系人」、分享二维码/链接，或添加到群聊（见上方）\n   - **管理员创建的机器人**：协作 → 智能机器人 → 点机器人 → 右上角「…」→ 可见范围 → 改为「所有成员」\n   - **自建应用**：应用管理 → 机器人 → 可见范围 → 改为「所有人」\n2. **Hermes 服务端**（`gateway/run.py` `_is_user_authorized()`）：\n   - 第 5563-5565 行：`WECOM_ALLOW_ALL_USERS=true` → **直接放行所有人**（最先检查）\n   - 第 5584-5587 行：配对审批列表 → 已配对用户放行\n   - 第 5598-5600 行：无白名单 → 检查 `GATEWAY_ALLOW_ALL_USERS`\n   - 结论：如果 `WECOM_ALLOW_ALL_USERS=true` 已设，Hermes 侧不会拦任何人。\n\n### 🔍 OpenClaw MCP 限制（与 Hermes 无关，仅作澄清）\n\nOpenClaw 文档提到：授权了「个人/小团队接口能力」的机器人会被企微强制限制为「仅创建者可对话」。这是 **OpenClaw MCP 集成的平台安全策略**，因为 MCP 授权后机器人可以操作用户的文档、日程、会议等敏感数据。\n\nHermes 直接通过 WebSocket 长连接接入，不经过 OpenClaw/MCP 层，因此**不受此限制**。如果你的机器人也没用过 OpenClaw，排查「仅创建者可使用」时跳过这个方向。\n\n### ⚠️ 智能机器人模式选择\n\n智能机器人支持两种连接模式。**长连接模式完全支持私聊**（官方文档明确列出 `chattype: single`、`enter_chat` 事件、单聊主动推送）。\n\n| 模式 | 收消息 | 私聊支持 | Hermes 回复 | 适用 |\n|------|--------|:---:|-------------|------|\n| **WebSocket 长连接** | ✅ | ✅ | ✅（通过 API） | **Hermes 默认，推荐** |\n| **URL 回调** | ✅（HTTP POST） | ✅ | ⚠️ 可能受限 | 同步回复场景 |\n\n> ⚠️ URL 回调模式切换需谨慎：之前有 session 报告切到 URL 回调后 API 返回 `errcode 846601`，但如果在企微后台正确配置 Token/AESKey 与 Hermes `config.yaml` 一致，可能正常工作。未充分验证前不推荐切换。如果只为了解私聊，长连接已支持，无需切换。\n\n### 用户如何私聊机器人（重要）\n\n用户不能直接搜索机器人名字发私聊。正确路径：\n\n**工作台 → 智能机器人 → 找到机器人 → 点「去使用」→ 发消息**\n\n如果走错入口，会看到「仅创建者可使用」。\n\n### Hermes 内置 wecom_callback 平台（推荐）\n\nHermes gateway 自带 `wecom_callback` 平台适配器，监听 **8645 端口、`/wecom/callback` 路径**。配置在 `~/.hermes/config.yaml`：\n\n```yaml\nplatforms:\n  wecom_callback:\n    enabled: true\n    extra:\n      host: \"0.0.0.0\"\n      port: 8645\n      path: \"/wecom/callback\"\n      apps:\n        - name: \"smartbot\"\n          corp_id: \"wwbcfb7cbc0f8912d7\"\n          token: \"<企微后台的Token>\"\n          encoding_aes_key: \"<企微后台的EncodingAESKey>\"\n          corp_secret: \"<自建应用的Secret>\"\n          agent_id: \"1000002\"\n```\n\n企微后台回调 URL 填 `https://<你的域名>/wecom/callback`（注意是 `/wecom/callback` 不是 `/wx-callback`）。\n\n修改 config.yaml 后重启 Hermes gateway 即可生效，无需额外部署 Flask 服务。\n\n**⚡ 密钥同步流程（关键）**：\n\n1. 企微后台 → 智能机器人 → API 配置 → **随机生成** Token 和 EncodingAESKey\n2. **趁还没保存**，把这两个值抄下来\n3. 更新到 `~/.hermes/config.yaml` 的 `wecom_callback.extra.apps[].token` 和 `encoding_aes_key`\n4. 重启 Hermes gateway\n5. 回到企微后台点**保存**\n\n⚠️ 常见踩坑：先在企微生成 → 抄下来 → 保存前先去服务端配好 → 再到企微保存。顺序反了就会「服务器未能正确响应」。\n\n### 遗留 webhook_receiver.py（不推荐）\n\n之前有一段 Flask 服务 (`project/scripts/webhook_receiver.py`，端口 5000，路径 `/wx-callback`) 用于处理自建应用回调。保留它会导致 Token/AESKey 两套配置不一致、端口冲突等问题。**如果 Hermes `wecom_callback` 已启用，应停止 `webhook-receiver` 服务。**\n\n### Cloudflare Tunnel 配置陷阱\n\n**🔴 关键：config.yml 有 ingress 规则 ≠ 公网可达。** 即使 config.yml 里写了 `hostname: callback.example.com → localhost:8645`，还必须在 Cloudflare Zero Trust 后台的 Public Hostname 中添加同一条记录。缺少后台配置时，Cloudflare 边缘节点会直接拦截请求（返回 Cloudflare 默认 403/404），请求根本到达不了 tunnel。\n\n**验证方法**：curl 请求目标域名，检查响应头 `server`：\n- `server: cloudflare` → 请求被 Cloudflare 拦截，未到达 tunnel → **后台 Public Hostname 缺失**\n- `server: Python/3.11 aiohttp` → 请求到达了 Hermes\n\nCloudflare Zero Trust 后台（dashboard.teams.cloudflare.com）手动添加的 **Public Hostname 会覆盖 config.yml 中的 ingress 规则**。如果两边都配了同一个 hostname，以后台为准。\n\n**识别方法**：查看 cloudflared 启动日志中的 `Updated to new configuration`，它展示的是**实际生效的**配置：\n```bash\ngrep \"Updated to new configuration\" /tmp/cloudflared.log 2>/dev/null || \\\n  journalctl -u cloudflared 2>/dev/null | grep \"Updated to new configuration\"\n```\n\n如果里面没有你在 config.yml 里写的 hostname，说明被后台覆盖了。\n\n**修改原则**：要么全用后台（dashboard）、要么全用 config.yml，避免混用导致「改了 config.yml 但不生效」。\n\n### 🔴 国内服务器 QUIC/端口 7844 阻断（systemd 重启必踩）\n\n**现象**：cloudflared systemd 服务重启后无法连接，日志循环打印 `Failed to dial a quic connection: timeout`，持续 1-2 分钟后才切换到 HTTP/2 恢复。重启期间所有 tunnel 服务（rss、bot、callback 等）全部中断。\n\n**根因**：国内服务器（腾讯云 Lighthouse 等）对 Cloudflare edge IP 的 **UDP 7844 端口（QUIC）和 TCP 7844 端口均可能被阻断**。cloudflared 默认优先尝试 QUIC，连续失败后才 fallback 到 HTTP/2（TCP 7844），这个过程需要 ~60 秒。如果 TCP 7844 也被阻断，fallback 也无法连接。\n\n**修复**：systemd service 的 ExecStart 加 `--protocol http2`，跳过 QUIC 探测，直连 HTTP/2：\n\n```ini\n# /etc/systemd/system/cloudflared-tunnel.service\nExecStart=/usr/local/bin/cloudflared tunnel --no-autoupdate --protocol http2 --config /home/agentuser/.cloudflared/config.yml run\n```\n\n**效果**：重启后 3-5 秒内恢复连接，不再有 1 分钟中断窗口。\n\n**验证连接状态**：\n```bash\njournalctl -u cloudflared-tunnel --no-pager | grep \"Registered tunnel connection\"\n# 应看到 protocol=http2 的连接注册日志\n```\n\n**添加新 hostname**：在 config.yml 的 ingress 中加入新 hostname → service 映射后，还需在 Cloudflare DNS 添加 CNAME 记录指向 `<tunnel-id>.cfargotunnel.com`（`cloudflared tunnel route dns` 需要 origin cert，通常不可用）。\n\n### 🔴 Lighthouse 文件管理器不可用\n\n腾讯云 Lighthouse 控制台自带的「文件管理」功能有以下已知限制，**不能作为团队文件共享方案**：\n\n| 限制 | 表现 |\n|------|------|\n| 无法访问 `/root/` | 文件管理器看不到该路径 |\n| 不支持隐藏目录 | `.hermes` 等点开头目录不显示 |\n| 不支持中文目录名 | 中文文件夹不显示或乱码 |\n| 权限受限 | 即使用 root 登录，部分目录仍不可见 |\n\n**替代方案**：Python http.server + Basic Auth + Tunnel（见 `references/web-file-browser.md`）。\n\n### URL 回调验证排障\n\n**排障核心技巧**：把 Token 验证和 AES Key 验证分开。计算 `SHA1(sorted([token, timestamp, nonce, echostr]))` 与日志中的 `msg_signature` 比对：\n- 签名匹配 → Token 正确，问题在 AES Key\n- 签名不匹配 → Token 不对，两边没对齐\n\n**验证脚本**（测试 AES key 能否解密企微发来的 echostr）：\n\n```python\nimport base64, hashlib, struct\nfrom cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes\nfrom cryptography.hazmat.backends import default_backend\nfrom cryptography.hazmat.primitives import padding\n\nkey_str = \"<你的EncodingAESKey>\"\nechostr_b64 = \"<从日志复制的echostr>\"\n\n# 1. 验证 Token（签名）\nsort_str = ''.join(sorted([token, timestamp, nonce, echostr]))\ncomputed_sig = hashlib.sha1(sort_str.encode()).hexdigest()\nprint(f\"Signature match: {computed_sig == msg_signature}\")\n\n# 2. 验证 AES Key（解密）\naes_key = base64.b64decode(key_str + '=')\nencrypted = base64.b64decode(echostr_b64)\ncipher = Cipher(algorithms.AES(aes_key), modes.CBC(aes_key[:16]), backend=default_backend())\ndecryptor = cipher.decryptor()\npadded = decryptor.update(encrypted) + decryptor.finalize()\nunpadder = padding.PKCS7(128).unpadder()\ndecrypted = unpadder.update(padded) + unpadder.finalize()\ncontent = decrypted[16:]\nmsg_len = struct.unpack('>I', content[:4])[0]\nprint(f\"Decrypted: {content[4:4+msg_len].decode('utf-8')}\")\n```\n\n**常见失败原因**：\n| 现象 | 根因 |\n|------|------|\n| `Invalid padding bytes` | AES Key 不匹配（两边不一样） |\n| `Incorrect padding` | AES Key 长度不对（不是标准 43 字符） |\n| 签名不匹配 | Token 不匹配 |\n| 隧道 403/404（签名却对） | Cloudflare Tunnel 指向了错误的端口（比如还在指向旧 webhook_receiver 的 :5000 而非 Hermes 的 :8645） |\n\n> ⚠️ **排障顺序**：先确认隧道端口（`curl -s https://域名/wecom/callback?...` 看返回是不是 `signature verification failed`），再排查签名和密钥。隧道不对的情况下所有加解密调试都是白费力气。\n\n完整排障参考 `references/url-callback-verification-troubleshooting.md`。\n\n### 可见范围 vs 可使用权限\n\n| 设置 | 位置 | 控制什么 |\n|------|------|---------|\n| 可见范围（智能机器人） | 协作 → 智能机器人 → 「…」→ 可见范围 | 谁能看到/使用机器人 |\n| 可见范围（自建应用） | 应用管理 → 机器人 → 编辑 → 可见范围 | 谁能看到/使用应用 |\n| 可使用权限（用户信息/消息/文档等） | 机器人 API 配置页 | 机器人能访问什么数据 |\n\n两者是独立的。即使可见范围包含了用户，「可使用权限」中的「消息」也必须「已授权」才能收发消息。\n\n详见 `references/dm-permission-config.md` 和 `references/dm-troubleshooting.md`。\n\nHermes 内部授权流程（gateway/run.py `_is_user_authorized()`）的逐行分析见 `references/hermes-wecom-auth-internals.md`。\n\n---\n\n## 现网部署参考\n\n广东春季高考信息网 (gdcjgk.net / chunkaonewshub) 的完整企微部署 playbook 见 `references/chunkaonewshub-topic-assistant.md`，包含：\n- 服务器拓扑与域名路由\n- we-mp-rss 部署与 cron job 配置\n- 选题日报/热点雷达工作流\n- 公众号订阅列表与状态文件路径\n- DeepSeek API 高峰时段超时修复实录\n\n配套支持文件在 `references/chunkaonewshub/` 和 `scripts/chunkaonewshub-*.py`。\n\n### 企微 DM 文件附件不支持\n\n**现象**：用户通过企微 DM 发送文件（PDF/Word/图片等），Hermes 完全收不到。对话中只有文字消息，没有文件引用的痕迹。\n\n**原因**：企微 wecom_callback 回调只转发消息体中的文本内容，文件附件不在消息 payload 中。这与群聊中的图片/文件处理机制不同。\n\n**应对**：告诉用户以下替代方案（按推荐顺序）：\n1. 直接复制文件内容，粘贴为文字消息\n2. 将文件上传到服务器（scp/FTP/云盘），把路径发过来\n3. 如果是网页内容，直接发链接\n\n不要反复追问\"收到了吗\"——文件发不过来就是收不到，直接让用户换方式。\n\n---\n\n### 🔴 服务器迁移后企微不回消息（IP 白名单）\n\n**现象**：回调 URL 验证成功，用户发消息也能收到（从日志可见），但 Hermes 的回复发不出去。\n\n**诊断**：直接测试企微 API 是否接受本机 IP：\n```bash\ncurl -s \"https://qyapi.weixin.qq.com/cgi-bin/gettoken?corpid=<corpid>&corpsecret=<secret>\" | python3 -c \"import sys,json; print(json.load(sys.stdin).get('errmsg'))\"\n# 如果报 errcode 60020: \"not allow to access from your ip\" → IP 不在白名单\n\ncurl -s \"https://qyapi.weixin.qq.com/cgi-bin/message/send?access_token=TOKEN\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"touser\":\"<userid>\",\"msgtype\":\"text\",\"agentid\":1000004,\"text\":{\"content\":\"test\"}}'\n# errcode 60020 确认问题\n```\n\n**修复**：企微后台 → 应用管理 → 自建应用 → 企业可信IP → 添加新服务器公网 IP。保存后立即生效。\n\n**为什么回调能收到但回复发不出**：回调是企微主动 POST 到你的服务器（入站，通过 cloudflared tunnel），不走 IP 白名单。回复是 Hermes 调企微 API（出站，直接 HTTP），企微会验证请求来源 IP。\n\n当用户反馈发消息后机器人不回复时，按步骤排查：隧道 → API → 端口 → 日志。完整检查清单和命令见 `references/wecom-callback-debug-flow.md`。\n\n**服务器迁移完整清单**见 `references/server-migration-checklist.md`。\n\n**快速命令**：\n```bash\n# 隧道\njournalctl -u cloudflared-tunnel --no-pager | grep \"Registered tunnel\" | tail -4\n# 企微 API\ncurl -s \"https://qyapi.weixin.qq.com/cgi-bin/gettoken?corpid=<id>&corpsecret=<secret>\" | python3 -c \"import sys,json; d=json.load(sys.stdin); print(d.get('errmsg'))\"\n# 端口\nss -tlnp | grep 8645\n# 最近消息\ngrep \"wecom_callback\" ~/.hermes/logs/gateway.log | tail -5\n```\n\n### ⚠️ wecom 平台 disabled 但持续重连（噪声）\n\n如果 `config.yaml` 中 `wecom.enabled: false`，但 gateway 日志持续出现 `[Wecom] Failed to connect: invalid bot_id or secret (errcode=853000)`，这是 `gateway/platforms/wecom.py` 在 disabled 状态下仍尝试 WebSocket 连接的已知行为。**不影响 wecom_callback 的正常收发**，可以忽略。\n\n\n\n\n❌ **不要尝试通过远程浏览器（Browserbase）做 QR 码登录企微管理后台。** QR 登录依赖浏览器端 JavaScript 轮询，远程 session 收不到回调，用户扫码后页面不跳转。\n\n✅ 让用户在自己手机上查：工作台 → 客户联系 / 应用管理 / 协作 → 智能机器人。\n\n详细说明和公网图片分发方案见 `references/admin-backend-access.md`。\n\n### FastAdmin CMS 发布 API\n\ngdcjgk.net 等 FastAdmin 站点自带内容发布 API。完整文档、独立接口模板、栏目树提取方法见 `references/fastadmin-cms-api.md`。模板代码见 `templates/api_publish.php`。\n\n**核心要点**：\n- 栏目名不唯一（如「报考相关」在多处出现），**必须用 channel_id**\n- apikey 默认值为空时后台不显示配置项，用独立接口绕过\n- 从 SQL 备份提取栏目树：`grep -oP \"VALUES \\(\\d+, '\\w+', \\d+, \\d+, '\\K[^']+\" backup.sql`\n\n### MCP 插件在企微后台消失了\n\n**根因**：使用了 SSE (`/sse`) 端点。SSE 是长连接，MCP 服务重启或 Cloudflare tunnel 闪断 → session 丢失 → 企微检测不到服务端 → 自动移除插件。\n\n**修复**：\n1. 重新添加 MCP 插件，URL 用 `https://mcp.yourdomain.com/rpc`\n2. 传输协议选 **Streamable HTTP**\n3. 加上后不会再消失\n\n### 机器人不调用 MCP 工具\n\n检查清单：\n1. MCP 插件状态是否为绿色 ✓（企微后台 → 机器人 → 插件）\n2. URL 是否以 `/rpc` 结尾\n3. 外网可达：`curl https://mcp.yourdomain.com/health`\n4. 工具注册正常：`curl -X POST https://mcp.yourdomain.com/rpc -H \"Content-Type: application/json\" -d '{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/list\",\"params\":{}}'`\n5. 机器人指令中是否写明了工具使用规则\n\n### MCP tools/call 静默失败（工具列表正常，调用不到）\n\n**现象**：MCP 服务器日志显示 `tools/list` 成功（返回 3 tools），但从不出现 `tools/call`。机器人 ReAct 日志显示「工具名称无法正确匹配」。\n\n**根因**：企微 MCP 插件基础设施不稳定。即使 Session-Id 正确、Streamable HTTP 配置正确，多节点架构下 tools/call 仍有概率被吞掉。这是企微平台的 bug，不是服务端问题。\n\n**应对策略**：\n1. **短期**：重新添加 MCP 插件碰运气（有时新加的能多撑几天）\n2. **长期**：不要依赖 MCP 做实时工具调用。改用「Webhook 推送内容 + 机器人纯对话」的分离架构（见下方）\n\n### 企微群类型与推送能力\n\n| 群类型 | 能加群机器人 | 能 Webhook 推送 | 适用 |\n|--------|:---:|:---:|------|\n| **内部群** | ✅ | ✅ | 运营工作群 |\n| **客户群** | ❌ | ❌ | 用户群 |\n| **外部群** | ❌ | ❌ | 混合 |\n\n客户群无法自动推送。如需触达用户群，只能：内部群收到推送 → 人工转发到客户群。\n\n### AI 机器人知识集不可程序化更新\n\n知识集只能在企微后台手动上传文档，没有开放 API。不能用 Hermes 定时任务自动更新。\n\n### 推荐架构：推送 + 对话分离\n\nMCP 不稳定 + 知识集无 API = 把数据流和对话流分开：\n- **数据流**：Hermes 定时任务 → 拉文章 → AI 精选 → 群 Webhook 推送日报\n- **对话流**：同事看推送后 @机器人追问 → 机器人用内置 LLM 自然对话\n\n各司其职，不依赖 MCP。\n\n## 微盘 / 文档 API 接入\n\n企微微盘和文档 API 可以为自建应用开放接口权限，让 Hermes 直接读写企微文档和微盘文件。\n\n### 开通权限（正确路径）\n\n官方文档：\n- 微盘：https://developer.work.weixin.qq.com/document/path/93654\n- 文档：https://developer.work.weixin.qq.com/document/path/97392\n\n**自建应用的微盘/文档 API 权限不在「应用管理」里，而是在「协作」里反向授权**——两个 API 分开配置：\n\n> 微盘：管理后台 →「**协作**」→「**微盘**」→「**API**」→ 配置「可调用接口的应用」\n> 文档：管理后台 →「**协作**」→「**文档**」→「**API**」→ 配置「可调用接口的应用」\n\n⚠️ **常见踩坑**：第一反应去「应用管理」→ 自建应用 → 接口权限里找「文档/微盘」，找不到。正确入口都在「协作」菜单下，且文档和微盘是**两个独立入口**。\n\n### API 端点\n\n| 类别 | Base Path | 开通入口 |\n|------|-----------|---------|\n| 微盘（wedrive） | `/cgi-bin/wedrive/...` |「协作」→「微盘」→「API」|\n| 文档（wedoc） | `/cgi-bin/wedoc/...` |「协作」→「文档」→「API」|\n\n先获取 access_token（`/cgi-bin/gettoken`），再调具体接口。Token 与普通自建应用共用。\n\n### 错误码\n\n| errcode | 含义 | 场景 |\n|---------|------|------|\n| 48002 | API forbidden | 自建应用未被加到对应「可调用接口的应用」列表 |\n| 301085 | invalid docid | 用了 URL docid 而非 API docid（见 `references/wecom-docid-systems.md`） |\n| 640001 | space not exist | spaceid 格式不对或不存在 |\n| 640008 | permission deny | 应用未被加为该空间成员 |\n| 640020 | Invalid auth type | `space_acl_add` 缺少 `auth` 字段 |\n| 60011 | no privilege | 应用没有通讯录/部门访问权限 |\n| 2050099 | doc version invalid | `batch_update` 需传正确 version |\n\n48002 出现时不要折腾 token 或 URL，直接去对应入口加白名单。\n\n### 两种 API 的区别\n\n- **微盘 API**（wedrive）：管理空间、文件列表、上传下载、权限管理。适合「整理文件夹」场景。\n- **文档 API**（wedoc）：读写文档内容、表格、收集表。适合「读取文档内容」场景。\n\n### ⚠️ 读权限 vs 写权限（关键发现）\n\n两种 API 的「只能访问应用自己创建的」限制**不是全局的**，而是逐接口不同：\n\n**文档 API 权限粒度：**\n\n| 接口 | 端点 | 限制 |\n|------|------|------|\n| 获取文档数据（读内容） | `POST /cgi-bin/wedoc/document/get` | ❌ **未写限制** |\n| 获取文档基础信息 | `POST /cgi-bin/wedoc/get_doc_base_info` | ❌ **未写限制** |\n| 分享文档 | `POST /cgi-bin/wedoc/doc_share` | ✅ 只能访问该应用创建的 |\n| 重命名文档 | `POST /cgi-bin/wedoc/rename_doc` | ✅ 仅可修改应用自己创建的 |\n| 删除文档 | `POST /cgi-bin/wedoc/del_doc` | ✅ 仅可删除应用自己创建的 |\n\n**微盘 API 权限粒度：**\n\n| 接口 | 端点 | 限制 |\n|------|------|------|\n| 获取文件列表 | `POST /cgi-bin/wedrive/file_list` | ❌ **未写限制** |\n\n> ⚠️ **反直觉结论**：官网概述页说\"应用仅可读取和编辑自己创建的文档\"，但实际逐接口检查发现，读操作（获取文档数据、获取文档基础信息、获取文件列表）的权限说明中**没有**「只能访问该应用创建的」字样，而写操作（分享、重命名、删除）**明确写了**。这意味着读已存在的文档/文件**理论上可行**。\n\n### ⚠️ 两套 ID 体系（关键坑）\n\n企微文档 URL 中的 docid（如 `w3_XXX`）和 API 的 docid（长 base64 串）是**两套互不互通的 ID 体系**，没有转换 API。详见 `references/wecom-docid-systems.md`。\n\n### 微盘 spaceid 发现\n\n微盘 API 没有「列出所有空间」的接口。获取已有空间 spaceid 的唯一办法：从企微分享的空间链接 URL 中提取。详见 `references/wedrive-api-test-results.md`。\n\n### 研究注意事项\n\n在做出「能不能读/写」的结论前，必须**逐接口查阅官方文档的权限说明**，不能根据概述页的总描述推测。概述声明和单接口权限说明可能不一致。\n\n> 来源：逐接口权限对比，验证于 2026-06-22。文档 API 各接口文档 path 不连续（101161, 97743, 97733, 97735 等），需从开发者中心侧边栏抓取实际 URL。\n\n详见 `references/wedrive-api-setup.md`。\n\n**完整 API 端点速查与测试结果：**\n- `references/wecom-api-endpoint-reference.md` — WeDoc/WeDrive 全部端点、Python helper、错误码表\n- `references/wecom-doc-test-results.md` — 逐接口实测结果、读/写权限验证\n\n### 机器人回复失败：errcode 846601\n\n**完整错误**：`errcode 846601: websocket mode not enabled for this robot`\n\n**根因**：智能机器人被切换到了「URL 回调」模式。在 URL 回调模式下，WeCom API 的 send 接口拒绝所有请求——回复只能通过回调的 HTTP 响应同步返回。\n\n**修复**：\n1. 企微后台 → 应用管理 → 机器人 → 你的智能机器人 → API 配置\n2. **关掉「使用 URL 回调」**，恢复 WebSocket 长连接模式\n3. 如果之前改了 Token/AES Key 用于回调验证，切回长连接后这些设置不影响\n4. 重启 Hermes gateway 后测试\n\n⚠️ URL 回调模式与 Hermes 的异步 LLM 对话不兼容。即使回调验证通过，回复也会全部失败。\n\n**根因**：机器人指令（系统提示词）格式过于复杂，企微 LLM 解析器崩溃。\n\n**触发条件**：\n- 指令中包含 Markdown 代码块（```）\n- 指令中包含表格（`|` 管道符）\n- 指令中包含 emoji\n- 指令超过一定长度上限\n\n**修复步骤**：\n1. 清空机器人指令，用一句话测试（如「你是内容创作助手」）\n2. 确认能正常回复后，逐步加回指令\n3. 使用纯文本格式：无代码块、无表格、无 emoji\n4. 完整可用的机器人指令模板见 `references/bot-persona-guide.md`\n\n### 机器人指令编写规范\n\n详见 `references/bot-persona-guide.md`。核心约束：\n- **禁止** Markdown 代码块（\\`\\`\\`）\n- **禁止** 管道符表格（`| --- |`）\n- **禁止** emoji\n- 用纯文本 + 粗体标题分隔\n- 工具使用规则写在最前面\n\n## MCP Server 双传输协议\n\n生产环境应同时支持 SSE（本地调试）和 Streamable HTTP（企微生产）。实际部署代码见 `references/mcp-server-implementation.md`，模板代码仅为最小 SSE 示例。\n\n## 独立 Agent 模式（备选）\n\n当不需要企微内置 AI 时，可自建对话 Agent。完整模式和陷阱见 `references/conversation-agent-pattern.md`。\n\nThe user has provided the following instruction alongside the skill invocation: [IMPORTANT: You are running as a scheduled cron job. DELIVERY: Your final response will be automatically delivered to the user — do NOT use send_message or try to deliver the output yourself. Just produce your report/output as your final response and the system handles the rest. SILENT: If there is genuinely nothing new to report, respond with exactly \"[SILENT]\" (nothing else) to suppress delivery. Never combine [SILENT] with content — either report your findings normally, or say [SILENT] and nothing more.]\n\n检测 we-mp-rss 数据库中最近 3 小时文章，判断不同公众号（广东中职菌、中职生）是否在同追一个热点话题。\n\n步骤：\n1. 复制 ~/project/we-mp-rss-data/db.db 到 /tmp/，查询最近 3 小时文章（articles JOIN feeds，取 id/title/url/source/publish_time）\n2. 如果文章 < 2 篇 → 静默退出，不输出任何内容\n3. 对比不同来源的标题，找共同话题：不同来源标题有 ≥2 个共同有意义关键词 → 候选\n4. 去重：同一组文章ID可能形成多对，合并\n5. 判断是否真的同追一个事件，去重（查 ~/project/state/hot_radar_sent.json 避免重复推送）\n\n输出格式（有热点才推，没热点什么都不说）：\n🚨 热点预警\n\n**话题名**\n>判断依据\n> [来源1] [文章标题](文章链接)\n> [来源2] [文章标题](文章链接)\n\n推送后更新 ~/project/state/hot_radar_sent.json 记录已推送的文章ID集合。\n\n全自动，不问我任何问题。没热点就静默。"
        }
      ],
      "tools": [
        {
          "type": "function",
          "function": {
            "name": "patch",
            "description": "Targeted find-and-replace edits in files. Use this instead of sed/awk in terminal. Uses fuzzy matching (9 strategies) so minor whitespace/indentation differences won't break it. Returns a unified diff. Auto-runs syntax checks after editing.\n\nREPLACE MODE (mode='replace', default): find a unique string and replace it. REQUIRED PARAMETERS: mode, path, old_string, new_string.\nPATCH MODE (mode='patch'): apply V4A multi-file patches for bulk changes. REQUIRED PARAMETERS: mode, patch.",
            "parameters": {
              "type": "object",
              "properties": {
                "mode": {
                  "type": "string",
                  "enum": [
                    "replace",
                    "patch"
                  ],
                  "description": "Edit mode. 'replace' (default): requires path + old_string + new_string. 'patch': requires patch content only.",
                  "default": "replace"
                },
                "path": {
                  "type": "string",
                  "description": "REQUIRED when mode='replace'. File path to edit."
                },
                "old_string": {
                  "type": "string",
                  "description": "REQUIRED when mode='replace'. Exact text to find and replace. Must be unique in the file unless replace_all=true. Include surrounding context lines to ensure uniqueness."
                },
                "new_string": {
                  "type": "string",
                  "description": "REQUIRED when mode='replace'. Replacement text. Pass empty string '' to delete the matched text."
                },
                "replace_all": {
                  "type": "boolean",
                  "description": "Replace all occurrences instead of requiring a unique match (default: false)",
                  "default": false
                },
                "patch": {
                  "type": "string",
                  "description": "REQUIRED when mode='patch'. V4A format patch content. Format:\n*** Begin Patch\n*** Update File: path/to/file\n@@ context hint @@\n context line\n-removed line\n+added line\n*** End Patch"
                },
                "cross_profile": {
                  "type": "boolean",
                  "description": "Opt out of the cross-profile soft guard. Defaults to false. Set true ONLY after explicit user direction to edit another Hermes profile's skills/plugins/cron/memories.",
                  "default": false
                }
              },
              "required": [
                "mode"
              ]
            }
          }
        },
        {
          "type": "function",
          "function": {
            "name": "process",
            "description": "Manage background processes started with terminal(background=true). Actions: 'list' (show all), 'poll' (check status + new output), 'log' (full output with pagination), 'wait' (block until done or timeout), 'kill' (terminate), 'write' (send raw stdin data without newline), 'submit' (send data + Enter, for answering prompts), 'close' (close stdin/send EOF).",
            "parameters": {
              "type": "object",
              "properties": {
                "action": {
                  "type": "string",
                  "enum": [
                    "list",
                    "poll",
                    "log",
                    "wait",
                    "kill",
                    "write",
                    "submit",
                    "close"
                  ],
                  "description": "Action to perform on background processes"
                },
                "session_id": {
                  "type": "string",
                  "description": "Process session ID (from terminal background output). Required for all actions except 'list'."
                },
                "data": {
                  "type": "string",
                  "description": "Text to send to process stdin (for 'write' and 'submit' actions)"
                },
                "timeout": {
                  "type": "integer",
                  "description": "Max seconds to block for 'wait' action. Returns partial output on timeout.",
                  "minimum": 1
                },
                "offset": {
                  "type": "integer",
                  "description": "Line offset for 'log' action (default: last 200 lines)"
                },
                "limit": {
                  "type": "integer",
                  "description": "Max lines to return for 'log' action",
                  "minimum": 1
                }
              },
              "required": [
                "action"
              ]
            }
          }
        },
        {
          "type": "function",
          "function": {
            "name": "read_file",
            "description": "Read a text file with line numbers and pagination. Use this instead of cat/head/tail in terminal. Output format: 'LINE_NUM|CONTENT'. Suggests similar filenames if not found. Use offset and limit for large files. Reads exceeding ~100K characters are rejected; use offset and limit to read specific sections of large files. NOTE: Cannot read images or binary files — use vision_analyze for images.",
            "parameters": {
              "type": "object",
              "properties": {
                "path": {
                  "type": "string",
                  "description": "Path to the file to read (absolute, relative, or ~/path)"
                },
                "offset": {
                  "type": "integer",
                  "description": "Line number to start reading from (1-indexed, default: 1)",
                  "default": 1,
                  "minimum": 1
                },
                "limit": {
                  "type": "integer",
                  "description": "Maximum number of lines to read (default: 500, max: 2000)",
                  "default": 500,
                  "maximum": 2000
                }
              },
              "required": [
                "path"
              ]
            }
          }
        },
        {
          "type": "function",
          "function": {
            "name": "search_files",
            "description": "Search file contents or find files by name. Use this instead of grep/rg/find/ls in terminal. Ripgrep-backed, faster than shell equivalents.\n\nContent search (target='content'): Regex search inside files. Output modes: full matches with line numbers, file paths only, or match counts.\n\nFile search (target='files'): Find files by glob pattern (e.g., '*.py', '*config*'). Also use this instead of ls — results sorted by modification time.",
            "parameters": {
              "type": "object",
              "properties": {
                "pattern": {
                  "type": "string",
                  "description": "Regex pattern for content search, or glob pattern (e.g., '*.py') for file search"
                },
                "target": {
                  "type": "string",
                  "enum": [
                    "content",
                    "files"
                  ],
                  "description": "'content' searches inside file contents, 'files' searches for files by name",
                  "default": "content"
                },
                "path": {
                  "type": "string",
                  "description": "Directory or file to search in (default: current working directory)",
                  "default": "."
                },
                "file_glob": {
                  "type": "string",
                  "description": "Filter files by pattern in grep mode (e.g., '*.py' to only search Python files)"
                },
                "limit": {
                  "type": "integer",
                  "description": "Maximum number of results to return (default: 50)",
                  "default": 50
                },
                "offset": {
                  "type": "integer",
                  "description": "Skip first N results for pagination (default: 0)",
                  "default": 0
                },
                "output_mode": {
                  "type": "string",
                  "enum": [
                    "content",
                    "files_only",
                    "count"
                  ],
                  "description": "Output format for grep mode: 'content' shows matching lines with line numbers, 'files_only' lists file paths, 'count' shows match counts per file",
                  "default": "content"
                },
                "context": {
                  "type": "integer",
                  "description": "Number of context lines before and after each match (grep mode only)",
                  "default": 0
                }
              },
              "required": [
                "pattern"
              ]
            }
          }
        },
        {
          "type": "function",
          "function": {
            "name": "terminal",
            "description": "Execute shell commands on a Linux environment. Filesystem usually persists between calls.\n\nDo NOT use cat/head/tail to read files — use read_file instead.\nDo NOT use grep/rg/find to search — use search_files instead.\nDo NOT use ls to list directories — use search_files(target='files') instead.\nDo NOT use sed/awk to edit files — use patch instead.\nDo NOT use echo/cat heredoc to create files — use write_file instead.\nReserve terminal for: builds, installs, git, processes, scripts, network, package managers, and anything that needs a shell.\n\nForeground (default): Commands return INSTANTLY when done, even if the timeout is high. Set timeout=300 for long builds/scripts — you'll still get the result in seconds if it's fast. Prefer foreground for short commands.\nBackground: Set background=true to get a session_id. Almost always pair with notify_on_complete=true — bg without notify runs SILENTLY and you have no way to learn it finished short of calling process(action='poll') yourself. Two legitimate uses:\n  (1) Long-lived processes that never exit (servers, watchers, daemons) — silent is correct, there's no exit to notify on.\n  (2) Long-running bounded tasks (tests, builds, deploys, CI pollers, batch jobs) — MUST set notify_on_complete=true. Without it you'll either forget to poll or sit blocked waiting for the user to surface the result.\nFor servers/watchers, do NOT use shell-level background wrappers (nohup/disown/setsid/trailing '&') in foreground mode. Use background=true so Hermes can track lifecycle and output.\nAfter starting a server, verify readiness with a health check or log signal, then run tests in a separate terminal() call. Avoid blind sleep loops.\nUse process(action=\"poll\") for progress checks, process(action=\"wait\") to block until done.\nWorking directory: Use 'workdir' for per-command cwd.\nPTY mode: Set pty=true for interactive CLI tools (Codex, Claude Code, Python REPL).\n\nDo NOT use vim/nano/interactive tools without pty=true — they hang without a pseudo-terminal. Pipe git output to cat if it might page.\n",
            "parameters": {
              "type": "object",
              "properties": {
                "command": {
                  "type": "string",
                  "description": "The command to execute on the VM"
                },
                "background": {
                  "type": "boolean",
                  "description": "Run the command in the background. Almost always pair with notify_on_complete=true — without it, the process runs silently and you'll have no way to learn it finished short of calling process(action='poll') yourself (easy to forget, leading to silent blindness on long jobs). Two legitimate patterns: (1) Long-lived processes that never exit (servers, watchers, daemons) — these stay silent because there's no exit to notify on. (2) Long-running bounded tasks (tests, builds, deploys, CI pollers, batch jobs) — these MUST set notify_on_complete=true. For short commands, prefer foreground with a generous timeout instead.",
                  "default": false
                },
                "timeout": {
                  "type": "integer",
                  "description": "Max seconds to wait (default: 180, foreground max: 600). Returns INSTANTLY when command finishes — set high for long tasks, you won't wait unnecessarily. Foreground timeout above 600s is rejected; use background=true for longer commands.",
                  "minimum": 1
                },
                "workdir": {
                  "type": "string",
                  "description": "Working directory for this command (absolute path). Defaults to the session working directory."
                },
                "pty": {
                  "type": "boolean",
                  "description": "Run in pseudo-terminal (PTY) mode for interactive CLI tools like Codex, Claude Code, or Python REPL. Only works with local and SSH backends. Default: false.",
                  "default": false
                },
                "notify_on_complete": {
                  "type": "boolean",
                  "description": "When true (and background=true), you'll be automatically notified exactly once when the process finishes. **This is the right choice for almost every long-running task** — tests, builds, deployments, multi-item batch jobs, anything that takes over a minute and has a defined end. Use this and keep working on other things; the system notifies you on exit. MUTUALLY EXCLUSIVE with watch_patterns — when both are set, watch_patterns is dropped.",
                  "default": false
                },
                "watch_patterns": {
                  "type": "array",
                  "items": {
                    "type": "string"
                  },
                  "description": "Strings to watch for in background process output. HARD RATE LIMIT: at most 1 notification per 15 seconds per process — matches arriving inside the cooldown are dropped. After 3 consecutive 15-second windows with dropped matches, watch_patterns is automatically disabled for that process and promoted to notify_on_complete behavior (one notification on exit, no more mid-process spam). USE ONLY for truly rare, one-shot mid-process signals on LONG-LIVED processes that will never exit on their own — e.g. ['Application startup complete'] on a server so you know when to hit its endpoint, or ['migration done'] on a daemon. DO NOT use for: (1) end-of-run markers like 'DONE'/'PASS' — use notify_on_complete instead; (2) error patterns like 'ERROR'/'Traceback' in loops or multi-item batch jobs — they fire on every iteration and you'll hit the strike limit fast; (3) anything you'd ever combine with notify_on_complete. When in doubt, choose notify_on_complete. MUTUALLY EXCLUSIVE with notify_on_complete — set one, not both."
                }
              },
              "required": [
                "command"
              ]
            }
          }
        },
        {
          "type": "function",
          "function": {
            "name": "write_file",
            "description": "Write content to a file, completely replacing existing content. Use this instead of echo/cat heredoc in terminal. Creates parent directories automatically. OVERWRITES the entire file — use 'patch' for targeted edits. Auto-runs syntax checks on .py/.json/.yaml/.toml and other linted languages; only NEW errors introduced by this write are surfaced (pre-existing errors are filtered out).",
            "parameters": {
              "type": "object",
              "properties": {
                "path": {
                  "type": "string",
                  "description": "Path to the file to write (will be created if it doesn't exist, overwritten if it does)"
                },
                "content": {
                  "type": "string",
                  "description": "Complete content to write to the file"
                },
                "cross_profile": {
                  "type": "boolean",
                  "description": "Opt out of the cross-profile soft guard. Defaults to false. Set true ONLY after explicit user direction to edit another Hermes profile's skills/plugins/cron/memories — by default these writes are blocked with a warning because they affect a different profile than the one this session is running under.",
                  "default": false
                }
              },
              "required": [
                "path",
                "content"
              ]
            }
          }
        }
      ],
      "reasoning_effort": "max",
      "extra_body": {
        "thinking": {
          "type": "enabled"
        }
      }
    }
  },
  "error": {
    "type": "ReadError",
    "message": "[Errno 32] Broken pipe"
  }
}