nonebot-plugin-hermes 0.2.0__tar.gz → 0.2.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. {nonebot_plugin_hermes-0.2.0 → nonebot_plugin_hermes-0.2.2}/PKG-INFO +21 -10
  2. {nonebot_plugin_hermes-0.2.0 → nonebot_plugin_hermes-0.2.2}/README.md +20 -9
  3. {nonebot_plugin_hermes-0.2.0 → nonebot_plugin_hermes-0.2.2}/nonebot_plugin_hermes/__init__.py +1 -1
  4. {nonebot_plugin_hermes-0.2.0 → nonebot_plugin_hermes-0.2.2}/nonebot_plugin_hermes/config.py +11 -8
  5. {nonebot_plugin_hermes-0.2.0 → nonebot_plugin_hermes-0.2.2}/nonebot_plugin_hermes/core/hermes_client.py +118 -7
  6. nonebot_plugin_hermes-0.2.2/nonebot_plugin_hermes/core/inflight.py +71 -0
  7. {nonebot_plugin_hermes-0.2.0 → nonebot_plugin_hermes-0.2.2}/nonebot_plugin_hermes/core/prompt_builder.py +40 -0
  8. {nonebot_plugin_hermes-0.2.0 → nonebot_plugin_hermes-0.2.2}/nonebot_plugin_hermes/handlers/commands.py +28 -20
  9. {nonebot_plugin_hermes-0.2.0 → nonebot_plugin_hermes-0.2.2}/nonebot_plugin_hermes/handlers/message.py +288 -8
  10. {nonebot_plugin_hermes-0.2.0 → nonebot_plugin_hermes-0.2.2}/nonebot_plugin_hermes/mcp/__init__.py +6 -2
  11. {nonebot_plugin_hermes-0.2.0 → nonebot_plugin_hermes-0.2.2}/nonebot_plugin_hermes.egg-info/PKG-INFO +21 -10
  12. {nonebot_plugin_hermes-0.2.0 → nonebot_plugin_hermes-0.2.2}/nonebot_plugin_hermes.egg-info/SOURCES.txt +3 -0
  13. {nonebot_plugin_hermes-0.2.0 → nonebot_plugin_hermes-0.2.2}/pyproject.toml +1 -1
  14. {nonebot_plugin_hermes-0.2.0 → nonebot_plugin_hermes-0.2.2}/tests/test_hermes_client_structured.py +119 -2
  15. nonebot_plugin_hermes-0.2.2/tests/test_inflight.py +91 -0
  16. nonebot_plugin_hermes-0.2.2/tests/test_message_handler_coalesce.py +543 -0
  17. {nonebot_plugin_hermes-0.2.0 → nonebot_plugin_hermes-0.2.2}/tests/test_prompt_builder.py +65 -0
  18. {nonebot_plugin_hermes-0.2.0 → nonebot_plugin_hermes-0.2.2}/hermes_install_skill.py +0 -0
  19. {nonebot_plugin_hermes-0.2.0 → nonebot_plugin_hermes-0.2.2}/nonebot_plugin_hermes/core/__init__.py +0 -0
  20. {nonebot_plugin_hermes-0.2.0 → nonebot_plugin_hermes-0.2.2}/nonebot_plugin_hermes/core/active_session.py +0 -0
  21. {nonebot_plugin_hermes-0.2.0 → nonebot_plugin_hermes-0.2.2}/nonebot_plugin_hermes/core/bot_registry.py +0 -0
  22. {nonebot_plugin_hermes-0.2.0 → nonebot_plugin_hermes-0.2.2}/nonebot_plugin_hermes/core/message_buffer.py +0 -0
  23. {nonebot_plugin_hermes-0.2.0 → nonebot_plugin_hermes-0.2.2}/nonebot_plugin_hermes/core/outbound.py +0 -0
  24. {nonebot_plugin_hermes-0.2.0 → nonebot_plugin_hermes-0.2.2}/nonebot_plugin_hermes/core/session.py +0 -0
  25. {nonebot_plugin_hermes-0.2.0 → nonebot_plugin_hermes-0.2.2}/nonebot_plugin_hermes/handlers/__init__.py +0 -0
  26. {nonebot_plugin_hermes-0.2.0 → nonebot_plugin_hermes-0.2.2}/nonebot_plugin_hermes/mcp/auth.py +0 -0
  27. {nonebot_plugin_hermes-0.2.0 → nonebot_plugin_hermes-0.2.2}/nonebot_plugin_hermes/mcp/server.py +0 -0
  28. {nonebot_plugin_hermes-0.2.0 → nonebot_plugin_hermes-0.2.2}/nonebot_plugin_hermes/mcp/tools/__init__.py +0 -0
  29. {nonebot_plugin_hermes-0.2.0 → nonebot_plugin_hermes-0.2.2}/nonebot_plugin_hermes/mcp/tools/get_recent_messages.py +0 -0
  30. {nonebot_plugin_hermes-0.2.0 → nonebot_plugin_hermes-0.2.2}/nonebot_plugin_hermes/mcp/tools/list_active_sessions.py +0 -0
  31. {nonebot_plugin_hermes-0.2.0 → nonebot_plugin_hermes-0.2.2}/nonebot_plugin_hermes/mcp/tools/push_message.py +0 -0
  32. {nonebot_plugin_hermes-0.2.0 → nonebot_plugin_hermes-0.2.2}/nonebot_plugin_hermes/scripts/__init__.py +0 -0
  33. {nonebot_plugin_hermes-0.2.0 → nonebot_plugin_hermes-0.2.2}/nonebot_plugin_hermes/scripts/install_skill.py +0 -0
  34. {nonebot_plugin_hermes-0.2.0 → nonebot_plugin_hermes-0.2.2}/nonebot_plugin_hermes/skill/SKILL.md +0 -0
  35. {nonebot_plugin_hermes-0.2.0 → nonebot_plugin_hermes-0.2.2}/nonebot_plugin_hermes/skill/__init__.py +0 -0
  36. {nonebot_plugin_hermes-0.2.0 → nonebot_plugin_hermes-0.2.2}/nonebot_plugin_hermes/tasks/__init__.py +0 -0
  37. {nonebot_plugin_hermes-0.2.0 → nonebot_plugin_hermes-0.2.2}/nonebot_plugin_hermes/tasks/expire_active_sessions.py +0 -0
  38. {nonebot_plugin_hermes-0.2.0 → nonebot_plugin_hermes-0.2.2}/nonebot_plugin_hermes/utils.py +0 -0
  39. {nonebot_plugin_hermes-0.2.0 → nonebot_plugin_hermes-0.2.2}/nonebot_plugin_hermes.egg-info/dependency_links.txt +0 -0
  40. {nonebot_plugin_hermes-0.2.0 → nonebot_plugin_hermes-0.2.2}/nonebot_plugin_hermes.egg-info/entry_points.txt +0 -0
  41. {nonebot_plugin_hermes-0.2.0 → nonebot_plugin_hermes-0.2.2}/nonebot_plugin_hermes.egg-info/requires.txt +0 -0
  42. {nonebot_plugin_hermes-0.2.0 → nonebot_plugin_hermes-0.2.2}/nonebot_plugin_hermes.egg-info/top_level.txt +0 -0
  43. {nonebot_plugin_hermes-0.2.0 → nonebot_plugin_hermes-0.2.2}/setup.cfg +0 -0
  44. {nonebot_plugin_hermes-0.2.0 → nonebot_plugin_hermes-0.2.2}/tests/test_active_session.py +0 -0
  45. {nonebot_plugin_hermes-0.2.0 → nonebot_plugin_hermes-0.2.2}/tests/test_bot_registry.py +0 -0
  46. {nonebot_plugin_hermes-0.2.0 → nonebot_plugin_hermes-0.2.2}/tests/test_mcp_auth.py +0 -0
  47. {nonebot_plugin_hermes-0.2.0 → nonebot_plugin_hermes-0.2.2}/tests/test_mcp_push_message.py +0 -0
  48. {nonebot_plugin_hermes-0.2.0 → nonebot_plugin_hermes-0.2.2}/tests/test_mcp_read_tools.py +0 -0
  49. {nonebot_plugin_hermes-0.2.0 → nonebot_plugin_hermes-0.2.2}/tests/test_message_buffer.py +0 -0
  50. {nonebot_plugin_hermes-0.2.0 → nonebot_plugin_hermes-0.2.2}/tests/test_session_manager.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: nonebot-plugin-hermes
3
- Version: 0.2.0
3
+ Version: 0.2.2
4
4
  Summary: NoneBot plugin for Hermes Agent — multi-platform AI chatbot via Hermes API Server
5
5
  License: MIT
6
6
  Project-URL: Homepage, https://github.com/gsskk/nonebot-plugin-hermes
@@ -222,24 +222,35 @@ platform_toolsets:
222
222
  在 `.env` 中:
223
223
 
224
224
  ```env
225
- HERMES_PERCEPTION_ENABLED=true # 活跃态依赖消息缓冲做上下文
226
225
  HERMES_ACTIVE_SESSION_ENABLED=true
227
226
  HERMES_MCP_ENABLED=true
228
227
  ```
229
228
 
229
+ > 启用 `HERMES_ACTIVE_SESSION_ENABLED` 时被动感知会自动开启(消息缓冲是活跃态的依赖),无需再单独设置 `HERMES_PERCEPTION_ENABLED`。后者只在 active=false 的群聊里有意义——给 @bot 那一刻的 LLM 注入旁观历史。
230
+
230
231
  重启后 bot 会:
231
232
 
232
233
  - 监听 `127.0.0.1:8643` 暴露 MCP 工具:`push_message` / `list_active_sessions` / `get_recent_messages`
233
234
  - 在 @bot 触发后进入 reactive 模式,5 分钟内对群消息做 should_reply 决策(每次插话续期)
234
235
 
235
- > ⚠️ MCP server 仅监听 loopback,**不要**改 `HERMES_MCP_HOST` 暴露到公网——任何能访问该端口的进程都能向群里推消息。
236
+ > ⚠️ **安全注意 ——`HERMES_MCP_HOST` 默认 `127.0.0.1`(loopback)。** 改成监听公网 / 局域网地址在技术上完全可行,但安全后果是:`push_message` 工具能让 bot 往群里发任意内容,而当前防御仅有 Bearer token(明文 HTTP 传输,且与 `HERMES_API_KEY` 同钥匙)。改之前请配套上反向代理(TLS 终结) + 来源 IP ACL,否则任何能 reach 该端口的进程一旦拿到 token 就可以冒名发送。
236
237
 
237
238
  ### 把插件能力告诉 Hermes Agent
238
239
 
239
- 插件自带一份 `SKILL.md`(reactive 决策契约 + 反向通道用法)。安装后跑一次:
240
+ 插件自带一份 `SKILL.md`(reactive 决策契约 + 反向通道用法)。在 bot 项目目录下任选一种执行(都是把 SKILL.md 装到 `~/.hermes/skills/nonebot-bridge/`):
240
241
 
241
242
  ```bash
242
- hermes-install-skill # 复制 SKILL.md 到 ~/.hermes/skills/nonebot-bridge/
243
+ # uv 管理依赖
244
+ uv run hermes-install-skill
245
+
246
+ # 或者 bot 项目用普通 venv
247
+ .venv/bin/hermes-install-skill
248
+
249
+ # 或者已激活虚拟环境
250
+ hermes-install-skill
251
+
252
+ # 备用入口(任何能 import nonebot-plugin-hermes 的环境)
253
+ python -m hermes_install_skill
243
254
  ```
244
255
 
245
256
  然后在 `~/.hermes/config.yaml` 注册插件 MCP server,把 `<HERMES_API_KEY>` 替换为你前面生成的同一把密钥(用于双向鉴权):
@@ -251,7 +262,7 @@ mcp_servers:
251
262
  headers: { Authorization: "Bearer <HERMES_API_KEY>" }
252
263
  ```
253
264
 
254
- 后续插件 SKILL.md 升级时跑 `hermes-install-skill --force` 重装。
265
+ 后续插件 SKILL.md 升级时,用上面同样的入口加 `--force` 重装,例如 `uv run hermes-install-skill --force` 或 `.venv/bin/hermes-install-skill --force`。
255
266
 
256
267
  ## 命令
257
268
 
@@ -260,7 +271,7 @@ mcp_servers:
260
271
  | `/clear` | 重置对话,开始新会话 |
261
272
  | `/ping` | 检查 Hermes Agent 连接状态 |
262
273
  | `/help` | 显示帮助信息 |
263
- | `/hermes-status` | 打印 M1 运行时状态(MCP / 活跃 sessions / buffer / registry |
274
+ | `/hermes-status` | 打印 M1 运行时状态(MCP / 活跃 sessions / buffer / registry)。**需在 `HERMES_ADMIN_USERS` 显式授权 `adapter:user_id`**;非管理员调用时静默无响应,且 `/help` 输出里也不出现该命令 |
264
275
 
265
276
  ## 配置项
266
277
 
@@ -276,10 +287,11 @@ mcp_servers:
276
287
  | `HERMES_PRIVATE_TRIGGER` | `all` | 私聊触发方式: `all` / `allowlist` |
277
288
  | `HERMES_ALLOW_USERS` | `[]` | 允许私聊的用户 ID 列表 (`allowlist` 模式) |
278
289
  | `HERMES_ALLOW_GROUPS` | `[]` | 允许响应的群组 ID 列表(空为全部允许) |
290
+ | `HERMES_ADMIN_USERS` | `[]` | 管理员白名单,格式 `["telegram:<user_id>", "onebotv11:<user_id>"]`。**默认空集 = deny by default**;`/hermes-status` 等敏感命令必须命中此列表才执行 |
279
291
  | `HERMES_SESSION_SHARE_GROUP` | `false` | 群内是否共享同一个 session |
280
292
  | `HERMES_MAX_LENGTH` | `4000` | 单条回复最大长度(超出后截断) |
281
293
  | `HERMES_IGNORE_PREFIX` | `["."]` | 以这些字符开头的消息不触发回复 |
282
- | `HERMES_PERCEPTION_ENABLED` | `false` | 是否开启被动感知 |
294
+ | `HERMES_PERCEPTION_ENABLED` | `false` | 群聊 + active_session=false 下,是否在 @bot 时给 LLM 注入旁观历史。**`HERMES_ACTIVE_SESSION_ENABLED=true` 时自动隐含为 on,本开关无效**。私聊永远不注入(Hermes session 已覆盖) |
283
295
  | `HERMES_PERCEPTION_BUFFER` | `10` | 被动感知缓存的历史消息数量 |
284
296
  | `HERMES_PERCEPTION_TEXT_LENGTH` | `200` | 被动感知单条历史消息最大长度 |
285
297
  | `HERMES_PERCEPTION_IMAGE_MODE` | `placeholder` | 历史图片模式: `placeholder`(纯文本占位,推荐) / `inline_labeled`(带标签随多模态发送,适合跨图诉求) / `none`(不提) |
@@ -289,10 +301,9 @@ mcp_servers:
289
301
  | `HERMES_BUFFER_PER_GROUP_CAP` | `200` | MessageBuffer 每群最近消息上限 |
290
302
  | `HERMES_BUFFER_TOTAL_GROUPS_CAP` | `50` | MessageBuffer 跨群总容量(LRU 驱逐) |
291
303
  | `HERMES_MCP_ENABLED` | `false` | 启动内嵌 FastMCP server(M1 反向通道) |
292
- | `HERMES_MCP_HOST` | `127.0.0.1` | MCP server 绑定地址(**勿改为非 loopback**) |
304
+ | `HERMES_MCP_HOST` | `127.0.0.1` | MCP server 绑定地址。改成公开地址前请阅读上文「群活跃态 + 反向通道」节的安全注意 |
293
305
  | `HERMES_MCP_PORT` | `8643` | MCP server 绑定端口 |
294
306
  | `HERMES_MCP_RECENT_LIMIT_MAX` | `50` | `get_recent_messages` 工具单次最大返回条数 |
295
- | `HERMES_STRUCTURED_PATH` | `prompt` | reactive 结构化输出路径: `prompt`(JSON5 解析) / `tools`(OpenAI tool_choice) |
296
307
 
297
308
  ## 限制
298
309
 
@@ -203,24 +203,35 @@ platform_toolsets:
203
203
  在 `.env` 中:
204
204
 
205
205
  ```env
206
- HERMES_PERCEPTION_ENABLED=true # 活跃态依赖消息缓冲做上下文
207
206
  HERMES_ACTIVE_SESSION_ENABLED=true
208
207
  HERMES_MCP_ENABLED=true
209
208
  ```
210
209
 
210
+ > 启用 `HERMES_ACTIVE_SESSION_ENABLED` 时被动感知会自动开启(消息缓冲是活跃态的依赖),无需再单独设置 `HERMES_PERCEPTION_ENABLED`。后者只在 active=false 的群聊里有意义——给 @bot 那一刻的 LLM 注入旁观历史。
211
+
211
212
  重启后 bot 会:
212
213
 
213
214
  - 监听 `127.0.0.1:8643` 暴露 MCP 工具:`push_message` / `list_active_sessions` / `get_recent_messages`
214
215
  - 在 @bot 触发后进入 reactive 模式,5 分钟内对群消息做 should_reply 决策(每次插话续期)
215
216
 
216
- > ⚠️ MCP server 仅监听 loopback,**不要**改 `HERMES_MCP_HOST` 暴露到公网——任何能访问该端口的进程都能向群里推消息。
217
+ > ⚠️ **安全注意 ——`HERMES_MCP_HOST` 默认 `127.0.0.1`(loopback)。** 改成监听公网 / 局域网地址在技术上完全可行,但安全后果是:`push_message` 工具能让 bot 往群里发任意内容,而当前防御仅有 Bearer token(明文 HTTP 传输,且与 `HERMES_API_KEY` 同钥匙)。改之前请配套上反向代理(TLS 终结) + 来源 IP ACL,否则任何能 reach 该端口的进程一旦拿到 token 就可以冒名发送。
217
218
 
218
219
  ### 把插件能力告诉 Hermes Agent
219
220
 
220
- 插件自带一份 `SKILL.md`(reactive 决策契约 + 反向通道用法)。安装后跑一次:
221
+ 插件自带一份 `SKILL.md`(reactive 决策契约 + 反向通道用法)。在 bot 项目目录下任选一种执行(都是把 SKILL.md 装到 `~/.hermes/skills/nonebot-bridge/`):
221
222
 
222
223
  ```bash
223
- hermes-install-skill # 复制 SKILL.md 到 ~/.hermes/skills/nonebot-bridge/
224
+ # uv 管理依赖
225
+ uv run hermes-install-skill
226
+
227
+ # 或者 bot 项目用普通 venv
228
+ .venv/bin/hermes-install-skill
229
+
230
+ # 或者已激活虚拟环境
231
+ hermes-install-skill
232
+
233
+ # 备用入口(任何能 import nonebot-plugin-hermes 的环境)
234
+ python -m hermes_install_skill
224
235
  ```
225
236
 
226
237
  然后在 `~/.hermes/config.yaml` 注册插件 MCP server,把 `<HERMES_API_KEY>` 替换为你前面生成的同一把密钥(用于双向鉴权):
@@ -232,7 +243,7 @@ mcp_servers:
232
243
  headers: { Authorization: "Bearer <HERMES_API_KEY>" }
233
244
  ```
234
245
 
235
- 后续插件 SKILL.md 升级时跑 `hermes-install-skill --force` 重装。
246
+ 后续插件 SKILL.md 升级时,用上面同样的入口加 `--force` 重装,例如 `uv run hermes-install-skill --force` 或 `.venv/bin/hermes-install-skill --force`。
236
247
 
237
248
  ## 命令
238
249
 
@@ -241,7 +252,7 @@ mcp_servers:
241
252
  | `/clear` | 重置对话,开始新会话 |
242
253
  | `/ping` | 检查 Hermes Agent 连接状态 |
243
254
  | `/help` | 显示帮助信息 |
244
- | `/hermes-status` | 打印 M1 运行时状态(MCP / 活跃 sessions / buffer / registry |
255
+ | `/hermes-status` | 打印 M1 运行时状态(MCP / 活跃 sessions / buffer / registry)。**需在 `HERMES_ADMIN_USERS` 显式授权 `adapter:user_id`**;非管理员调用时静默无响应,且 `/help` 输出里也不出现该命令 |
245
256
 
246
257
  ## 配置项
247
258
 
@@ -257,10 +268,11 @@ mcp_servers:
257
268
  | `HERMES_PRIVATE_TRIGGER` | `all` | 私聊触发方式: `all` / `allowlist` |
258
269
  | `HERMES_ALLOW_USERS` | `[]` | 允许私聊的用户 ID 列表 (`allowlist` 模式) |
259
270
  | `HERMES_ALLOW_GROUPS` | `[]` | 允许响应的群组 ID 列表(空为全部允许) |
271
+ | `HERMES_ADMIN_USERS` | `[]` | 管理员白名单,格式 `["telegram:<user_id>", "onebotv11:<user_id>"]`。**默认空集 = deny by default**;`/hermes-status` 等敏感命令必须命中此列表才执行 |
260
272
  | `HERMES_SESSION_SHARE_GROUP` | `false` | 群内是否共享同一个 session |
261
273
  | `HERMES_MAX_LENGTH` | `4000` | 单条回复最大长度(超出后截断) |
262
274
  | `HERMES_IGNORE_PREFIX` | `["."]` | 以这些字符开头的消息不触发回复 |
263
- | `HERMES_PERCEPTION_ENABLED` | `false` | 是否开启被动感知 |
275
+ | `HERMES_PERCEPTION_ENABLED` | `false` | 群聊 + active_session=false 下,是否在 @bot 时给 LLM 注入旁观历史。**`HERMES_ACTIVE_SESSION_ENABLED=true` 时自动隐含为 on,本开关无效**。私聊永远不注入(Hermes session 已覆盖) |
264
276
  | `HERMES_PERCEPTION_BUFFER` | `10` | 被动感知缓存的历史消息数量 |
265
277
  | `HERMES_PERCEPTION_TEXT_LENGTH` | `200` | 被动感知单条历史消息最大长度 |
266
278
  | `HERMES_PERCEPTION_IMAGE_MODE` | `placeholder` | 历史图片模式: `placeholder`(纯文本占位,推荐) / `inline_labeled`(带标签随多模态发送,适合跨图诉求) / `none`(不提) |
@@ -270,10 +282,9 @@ mcp_servers:
270
282
  | `HERMES_BUFFER_PER_GROUP_CAP` | `200` | MessageBuffer 每群最近消息上限 |
271
283
  | `HERMES_BUFFER_TOTAL_GROUPS_CAP` | `50` | MessageBuffer 跨群总容量(LRU 驱逐) |
272
284
  | `HERMES_MCP_ENABLED` | `false` | 启动内嵌 FastMCP server(M1 反向通道) |
273
- | `HERMES_MCP_HOST` | `127.0.0.1` | MCP server 绑定地址(**勿改为非 loopback**) |
285
+ | `HERMES_MCP_HOST` | `127.0.0.1` | MCP server 绑定地址。改成公开地址前请阅读上文「群活跃态 + 反向通道」节的安全注意 |
274
286
  | `HERMES_MCP_PORT` | `8643` | MCP server 绑定端口 |
275
287
  | `HERMES_MCP_RECENT_LIMIT_MAX` | `50` | `get_recent_messages` 工具单次最大返回条数 |
276
- | `HERMES_STRUCTURED_PATH` | `prompt` | reactive 结构化输出路径: `prompt`(JSON5 解析) / `tools`(OpenAI tool_choice) |
277
288
 
278
289
  ## 限制
279
290
 
@@ -13,7 +13,7 @@ require("nonebot_plugin_apscheduler")
13
13
 
14
14
  from .config import Config, plugin_config
15
15
 
16
- __version__ = "0.2.0"
16
+ __version__ = "0.2.2"
17
17
 
18
18
  __plugin_meta__ = PluginMetadata(
19
19
  name="Hermes Agent",
@@ -4,7 +4,7 @@
4
4
  所有配置项通过 NoneBot 的 .env 文件读取,前缀为 HERMES_。
5
5
  """
6
6
 
7
- from typing import Literal, Set
7
+ from typing import Set
8
8
 
9
9
  from nonebot import get_plugin_config
10
10
  from pydantic import BaseModel, Field
@@ -37,6 +37,12 @@ class Config(BaseModel):
37
37
  hermes_allow_groups: Set[str] = set()
38
38
  """允许响应的群组 ID(空 = 全部允许)"""
39
39
 
40
+ hermes_admin_users: Set[str] = set()
41
+ """管理员白名单(adapter+user 复合 ID)。用于 /hermes-status 等敏感命令。
42
+ 格式:`{adapter}:{user_id}`,adapter 取小写 + 去空格点(同 get_adapter_name)。
43
+ 例:["telegram:7055555877", "onebotv11:12345678"]
44
+ 空集 = 不允许任何人使用敏感命令(默认 deny)。"""
45
+
40
46
  # --- 会话 ---
41
47
  hermes_session_share_group: bool = False
42
48
  """群内是否共享同一个 session(False = 每人独立)"""
@@ -88,7 +94,10 @@ class Config(BaseModel):
88
94
  """是否启动内嵌 FastMCP server(False 时 Hermes 反向调用全失败,出向不影响)"""
89
95
 
90
96
  hermes_mcp_host: str = "127.0.0.1"
91
- """MCP server bind host,**绝不暴露到非 loopback**"""
97
+ """MCP server bind host. 默认 loopback (127.0.0.1)。
98
+ 改监听公网/局域网在技术上可行,但安全代价:push_message 能让 bot 往群里发
99
+ 任意内容,前端防御只有 Bearer token(明文 HTTP,且与 HERMES_API_KEY 同
100
+ 钥匙)。要改请配套反向代理 + TLS + 来源 IP ACL。"""
92
101
 
93
102
  hermes_mcp_port: int = 8643
94
103
  """MCP server bind port"""
@@ -97,11 +106,5 @@ class Config(BaseModel):
97
106
  """get_recent_messages 工具单次返回上限。最小 1——0/负值会让工具静默返空,
98
107
  Pydantic 在启动期校验防 misconfig。"""
99
108
 
100
- # --- M1: 结构化输出路径(由 P0-spike 决定) ---
101
- hermes_structured_path: Literal["tools", "prompt"] = "prompt"
102
- """tools = 路径 A(tools+tool_choice);prompt = 路径 B(JSON5)。
103
- Task 3 spike (2026-05-09) 结论:Hermes 不透传 tools/tool_choice 给底层 LLM,
104
- 必须用 prompt 强约束 + JSON5 容错解析。"""
105
-
106
109
 
107
110
  plugin_config = get_plugin_config(Config)
@@ -7,6 +7,7 @@ M1-mem 路径 B(P0-spike 决策):tools/tool_choice 被 Hermes 吞掉,改用 syst
7
7
 
8
8
  from __future__ import annotations
9
9
 
10
+ import json
10
11
  import re
11
12
  from dataclasses import dataclass, field
12
13
  from typing import Any, Dict, List, Literal, Optional, Tuple, Union
@@ -23,6 +24,16 @@ UserContent = Union[str, List[Dict[str, Any]]]
23
24
  _MD_IMAGE_PATTERN = re.compile(r"!\[([^\]]*)\]\(([^)]+)\)")
24
25
  _MEDIA_TAG_PATTERN = re.compile(r"MEDIA:(\S+)")
25
26
 
27
+ # Hermes apiserver 把 provider 端错误包成 502, body 形如:
28
+ # {"error": {"message": "Error code: 400 - {'error': {'message': '...'}}"}}
29
+ # 把外层 502 当真因显示会误导, 因此抠出内层 status 与 reason。
30
+ _INNER_STATUS_RE = re.compile(r"Error code:\s*(\d+)")
31
+ _VISION_UNSUPPORTED_RE = re.compile(
32
+ r"unknown variant\s+[`'\"]image_url|image_url.*not.*support|does not support.*image|"
33
+ r"input_image.*not.*support|multimodal.*not.*support",
34
+ re.IGNORECASE,
35
+ )
36
+
26
37
  # 提取首个 {...} 块。
27
38
  # 当前正则只支持嵌套一层(`\{[^{}]*\}` 出现在外层 `\{...\}` 中)。
28
39
  # M1 submit_decision schema 全平,够用。如未来 schema 加 nested object,
@@ -36,10 +47,101 @@ _DECISION_HINT = (
36
47
  " reply_text (string, optional, required when should_reply=true)\n"
37
48
  " topic_hint (string, optional)\n"
38
49
  " should_exit_active (boolean, optional)\n"
39
- "Output ONLY the JSON object, no preamble, no postscript, no markdown fences."
50
+ "Output ONLY the JSON object, no preamble, no postscript, no markdown fences.\n"
51
+ "All string values MUST be single-line; escape line breaks inside strings as \\n (no raw newlines)."
40
52
  )
41
53
 
42
54
 
55
+ def _escape_raw_newlines_in_strings(s: str) -> str:
56
+ """把 JSON 字符串字面量内部的裸 \\n/\\r/\\t 转义掉,让 json5 能解析。
57
+
58
+ LLM 经常在 reply_text 里嵌真换行(段落分隔),JSON5 字符串不允许;首发 json5
59
+ 抛 `Unexpected "\\n"` 后我们走这一遍状态机重试一次。状态:跟踪 " / ' 进出
60
+ string、`\\X` 整对透传(不参与 quote 计数),quoted 区里把裸控制字符替换。
61
+ """
62
+ out: List[str] = []
63
+ in_string = False
64
+ quote = ""
65
+ i = 0
66
+ n = len(s)
67
+ while i < n:
68
+ c = s[i]
69
+ if not in_string:
70
+ if c == '"' or c == "'":
71
+ in_string = True
72
+ quote = c
73
+ out.append(c)
74
+ i += 1
75
+ continue
76
+ # 在 string 内
77
+ if c == "\\" and i + 1 < n:
78
+ # 转义序列整对透传(含 \" / \' / \\ / \n 等),不参与 quote 计数
79
+ out.append(s[i : i + 2])
80
+ i += 2
81
+ continue
82
+ if c == quote:
83
+ in_string = False
84
+ quote = ""
85
+ out.append(c)
86
+ i += 1
87
+ continue
88
+ if c == "\n":
89
+ out.append("\\n")
90
+ elif c == "\r":
91
+ out.append("\\r")
92
+ elif c == "\t":
93
+ out.append("\\t")
94
+ else:
95
+ out.append(c)
96
+ i += 1
97
+ return "".join(out)
98
+
99
+
100
+ def _summarize_error_body(body: str) -> Tuple[str, Optional[int]]:
101
+ """从 Hermes 错误响应体里抠出可读 reason 与内层 status。
102
+
103
+ Hermes apiserver 习惯把 provider 端错误外包成 502, body 是 JSON, `error.message`
104
+ 形如 `"Error code: 400 - {...}"`。直接把 200 字符 body 倒进日志噪音大、
105
+ 用户看到只剩外层 502 完全不知道发生了什么, 所以这里先剥一层。
106
+
107
+ 返回 (reason_snippet, inner_status_or_None);body 不可解析时退化到 body 截断。
108
+ """
109
+ if not body:
110
+ return "(empty body)", None
111
+ raw = body.strip()
112
+ try:
113
+ parsed = json.loads(raw)
114
+ except (json.JSONDecodeError, ValueError):
115
+ return raw[:200], None
116
+ err = parsed.get("error") if isinstance(parsed, dict) else None
117
+ if isinstance(err, dict):
118
+ msg = str(err.get("message") or err).strip()
119
+ elif isinstance(err, str):
120
+ msg = err.strip()
121
+ else:
122
+ msg = raw[:200]
123
+ inner_status: Optional[int] = None
124
+ m = _INNER_STATUS_RE.search(msg)
125
+ if m:
126
+ try:
127
+ inner_status = int(m.group(1))
128
+ except ValueError:
129
+ inner_status = None
130
+ return msg[:300], inner_status
131
+
132
+
133
+ def _user_facing_error(reason: str) -> str:
134
+ """把 _summarize_error_body 的 reason 翻译成给群里发的简短提示。
135
+
136
+ 命中已知模式(目前: 图片输入被非 vision 模型拒)就给精准提示;否则带 reason 片段
137
+ 让用户能直接看到真因, 不再只露一个误导性的 502。
138
+ """
139
+ if _VISION_UNSUPPORTED_RE.search(reason):
140
+ return "⚠️ 当前主模型不支持图片识别,请改用文字提问或换用 vision 模型"
141
+ snippet = reason.strip().splitlines()[0][:140] if reason.strip() else "未知错误"
142
+ return f"⚠️ AI 服务异常: {snippet}"
143
+
144
+
43
145
  def extract_response_media(text: str) -> Tuple[str, List[str]]:
44
146
  """从 Hermes 回复中提取 markdown 图片 / MEDIA: 标签 URL,返回 (清洗后文本, URL 列表)。"""
45
147
  media_urls: List[str] = []
@@ -55,16 +157,24 @@ def extract_response_media(text: str) -> Tuple[str, List[str]]:
55
157
 
56
158
 
57
159
  def _try_parse_first_json_block(text: str) -> Optional[Dict[str, Any]]:
58
- """从模型回复中提取首个 {...} 块并 JSON5 解析。失败返回 None,调用方记 parse_failed。"""
160
+ """从模型回复中提取首个 {...} 块并 JSON5 解析。失败返回 None,调用方记 parse_failed。
161
+
162
+ 两段式回退:json5 首发失败 → 走 _escape_raw_newlines_in_strings 把字符串内
163
+ 的裸控制字符转义后重试。两次都失败才返回 None。
164
+ """
59
165
  if not text:
60
166
  return None
61
167
  m = _FIRST_JSON_BLOCK.search(text)
62
168
  if not m:
63
169
  return None
170
+ candidate = m.group(0)
64
171
  try:
65
- parsed = json5.loads(m.group(0))
172
+ parsed = json5.loads(candidate)
66
173
  except Exception:
67
- return None
174
+ try:
175
+ parsed = json5.loads(_escape_raw_newlines_in_strings(candidate))
176
+ except Exception:
177
+ return None
68
178
  if not isinstance(parsed, dict):
69
179
  return None
70
180
  return parsed
@@ -212,10 +322,11 @@ class HermesClient:
212
322
  async with httpx.AsyncClient(timeout=self.timeout) as client:
213
323
  resp = await client.post(url, json=payload, headers=headers)
214
324
  if resp.status_code != 200:
215
- body = resp.text[:200]
216
- logger.error(f"[HERMES] API 返回 {resp.status_code}: {body}")
325
+ reason, inner_status = _summarize_error_body(resp.text)
326
+ inner_tag = f" inner={inner_status}" if inner_status else ""
327
+ logger.error(f"[HERMES] upstream HTTP {resp.status_code}{inner_tag}: {reason}")
217
328
  return ChatResult(
218
- raw_text=f"⚠️ AI 服务返回错误 ({resp.status_code})",
329
+ raw_text=_user_facing_error(reason),
219
330
  parse_failed=True,
220
331
  is_transport_error=True,
221
332
  )
@@ -0,0 +1,71 @@
1
+ """In-flight 调用追踪 + coalesce 重燃支持。
2
+
3
+ 修同一 (adapter, group_id|user_id) 上事件 task 并发调 chat() 的 bug:
4
+ in-flight 时新消息只更新 pending 单元,等当前一发完成后再合并跑一次。
5
+
6
+ 线程安全:**否**。预设单线程 asyncio 事件循环,与 ActiveSessionManager 一致。
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from dataclasses import dataclass
12
+ from typing import Dict, Literal, Optional, Tuple
13
+
14
+ from .message_buffer import BufferedMessage
15
+
16
+
17
+ # Refire 链最大深度。超过则丢 pending、warn,等下一个新触发。
18
+ # 一次 burst 最多产出 1(主回) + MAX_REFIRE_DEPTH(链尾)= 4 发回复。
19
+ MAX_REFIRE_DEPTH = 3
20
+
21
+
22
+ @dataclass
23
+ class InflightSlot:
24
+ started_at: int
25
+ pending: Optional[BufferedMessage] = None
26
+
27
+
28
+ class InflightRegistry:
29
+ """per-target 非阻塞 busy 标记 + pending 单元。
30
+
31
+ Key 约定:
32
+ - 群: ("adapter", "group:" + group_id)
33
+ - 私聊: ("adapter", "private:" + user_id)
34
+
35
+ 不持有任何 asyncio.Task 引用 —— 重燃由 caller 用 create_task 自己接手,
36
+ registry 只负责「现在有没有人在跑」+「跑完后是否要再跑一次」两个状态。
37
+ """
38
+
39
+ def __init__(self) -> None:
40
+ self._slots: Dict[Tuple[str, str], InflightSlot] = {}
41
+
42
+ def try_enter(
43
+ self,
44
+ key: Tuple[str, str],
45
+ current_msg: BufferedMessage,
46
+ now_ms: int,
47
+ ) -> Literal["entered", "pending_set"]:
48
+ """无 slot → 占位 started_at=now_ms,返回 'entered'。
49
+ 有 slot → 把 current_msg 写进 pending(覆盖旧 pending),返回 'pending_set'。
50
+ """
51
+ slot = self._slots.get(key)
52
+ if slot is None:
53
+ self._slots[key] = InflightSlot(started_at=now_ms)
54
+ return "entered"
55
+ slot.pending = current_msg
56
+ return "pending_set"
57
+
58
+ def take_pending(self, key: Tuple[str, str]) -> Optional[BufferedMessage]:
59
+ """Destructive read。无 slot 或 pending 为 None 都返回 None。"""
60
+ slot = self._slots.get(key)
61
+ if slot is None:
62
+ return None
63
+ msg = slot.pending
64
+ slot.pending = None
65
+ return msg
66
+
67
+ def exit(self, key: Tuple[str, str]) -> None:
68
+ """释放 slot。pending 仍在的话由调用方自行先 take_pending。
69
+ slot 不存在则 no-op。
70
+ """
71
+ self._slots.pop(key, None)
@@ -75,6 +75,46 @@ def build_reactive_system_prompt(
75
75
  return "\n".join(runtime_lines) + "\n\n" + decision_block
76
76
 
77
77
 
78
+ def build_passive_system_prompt(
79
+ *,
80
+ adapter: str,
81
+ is_private: bool,
82
+ user_id: str,
83
+ group_id: Optional[str],
84
+ recent_messages: Sequence[BufferedMessage],
85
+ ) -> str:
86
+ """passive 路径的 system prompt:Message Context 头(对齐 hermes_client.chat
87
+ 默认拼装)+ 可选 <recent_messages> 块。
88
+
89
+ 用于补回 0.1.6 在群聊 + active_session=false 默认配置下的「旁观历史注入」:
90
+ @bot 那一刻让 LLM 看到群里其他人之前在聊什么(Hermes 自身只记得 user↔bot
91
+ 来回,看不到群里旁观对话)。
92
+
93
+ 私聊调用方应传空 recent(0.1.6 起私聊就不跑 perception——1:1 没有旁观第三方)。
94
+
95
+ 注:Message Context 5 行必须与 hermes_client.chat 的默认拼装保持一致,
96
+ 任一处改格式记得同步另一处(测试 test_passive_prompt_* 是约束)。
97
+ """
98
+ ctx_lines = [f"Platform: {adapter or 'unknown'}"]
99
+ ctx_lines.append("Chat Type: " + ("Private" if is_private else "Group"))
100
+ if user_id:
101
+ ctx_lines.append(f"User ID: {user_id}")
102
+ if not is_private and group_id:
103
+ ctx_lines.append(f"Group ID: {group_id}")
104
+ sp = "Message Context:\n" + "\n".join(ctx_lines)
105
+
106
+ if not recent_messages:
107
+ return sp
108
+
109
+ history_lines = ["<recent_messages>"]
110
+ for m in reversed(list(recent_messages)):
111
+ prefix = "[bot] " if m.is_bot else ""
112
+ speaker = m.nickname or m.user_id
113
+ history_lines.append(f"{prefix}{speaker}: {m.content}")
114
+ history_lines.append("</recent_messages>")
115
+ return sp + "\n\n" + "\n".join(history_lines)
116
+
117
+
78
118
  def build_reactive_user_content(
79
119
  *,
80
120
  recent_messages: Sequence[BufferedMessage],
@@ -9,7 +9,7 @@ from __future__ import annotations
9
9
  import time
10
10
 
11
11
  import nonebot_plugin_alconna as alconna
12
- from nonebot import on_command
12
+ from nonebot import logger, on_command
13
13
  from nonebot.adapters import Bot, Event
14
14
  from nonebot.matcher import Matcher
15
15
 
@@ -80,27 +80,26 @@ async def handle_help(bot: Bot, event: Event, matcher: Matcher):
80
80
  if not check_isolation(event, target):
81
81
  matcher.skip()
82
82
 
83
+ # 是否管理员决定要不要把 /hermes-status 暴露给当前用户
84
+ adapter_name = get_adapter_name(target)
85
+ user_id = event.get_user_id() or ""
86
+ is_admin = f"{adapter_name}:{user_id}" in plugin_config.hermes_admin_users
87
+
83
88
  if target.private:
84
- help_text = (
85
- "🤖 Hermes Agent 帮助\n\n"
86
- "直接发送消息即可与 AI 对话。\n\n"
87
- "命令:\n"
88
- "/clear - 重置对话\n"
89
- "/ping - 检查连接状态\n"
90
- "/hermes-status - 查看插件运行时状态\n"
91
- "/help - 显示本帮助"
92
- )
89
+ intro = "🤖 Hermes Agent 帮助\n\n直接发送消息即可与 AI 对话。\n\n命令:\n"
93
90
  else:
94
- help_text = (
95
- "🤖 Hermes Agent 帮助\n\n"
96
- "@我 发送消息即可与 AI 对话。\n\n"
97
- "命令:\n"
98
- "/clear - 重置对话\n"
99
- "/ping - 检查连接状态\n"
100
- "/hermes-status - 查看插件运行时状态\n"
101
- "/help - 显示本帮助"
102
- )
91
+ intro = "🤖 Hermes Agent 帮助\n\n@我 发送消息即可与 AI 对话。\n\n命令:\n"
103
92
 
93
+ lines = [
94
+ "/clear - 重置对话",
95
+ "/ping - 检查连接状态",
96
+ "/help - 显示本帮助",
97
+ ]
98
+ if is_admin:
99
+ # 管理员才看见运行时状态命令,普通用户视角下此命令"不存在"
100
+ lines.append("/hermes-status - 查看插件运行时状态(管理员)")
101
+
102
+ help_text = intro + "\n".join(lines)
104
103
  await alconna.UniMessage(help_text).send(target=target, bot=bot)
105
104
 
106
105
 
@@ -115,6 +114,16 @@ async def handle_status(bot: Bot, event: Event, matcher: Matcher):
115
114
  if not check_isolation(event, target):
116
115
  matcher.skip()
117
116
 
117
+ # /hermes-status 暴露内部运行时(活跃群、buffer 内容、bot 路由),
118
+ # 应限制在管理员白名单内。**隐身策略**:对非管理员完全静默,不发"未授权"
119
+ # 提示——避免把命令存在性暴露给一般用户。空集 = deny by default。
120
+ adapter_name = get_adapter_name(target)
121
+ user_id = event.get_user_id() or ""
122
+ admin_key = f"{adapter_name}:{user_id}"
123
+ if admin_key not in plugin_config.hermes_admin_users:
124
+ logger.debug(f"[HERMES] /hermes-status silent skip for {admin_key}")
125
+ return # block=True 已阻断后续 matcher,直接 return 即静默
126
+
118
127
  now_ms = int(time.time() * 1000)
119
128
 
120
129
  # MCP 状态
@@ -159,7 +168,6 @@ async def handle_status(bot: Bot, event: Event, matcher: Matcher):
159
168
  "🔍 Hermes Plugin M1-mem 状态",
160
169
  f"MCP: {mcp_line}",
161
170
  f"active_session: {active_line}",
162
- f"structured_path: {plugin_config.hermes_structured_path}",
163
171
  f"hermes_api: {plugin_config.hermes_api_url}",
164
172
  "",
165
173
  f"📊 ActiveSessions: {active_count} 个活跃",