Metadata-Version: 2.4
Name: nonebot-plugin-hermes
Version: 0.5.1
Summary: NoneBot plugin for Hermes Agent — multi-platform AI chatbot via Hermes API Server
License: MIT
Project-URL: Homepage, https://github.com/gsskk/nonebot-plugin-hermes
Requires-Python: >=3.10
Description-Content-Type: text/markdown
Requires-Dist: nonebot2>=2.3.0
Requires-Dist: nonebot-plugin-alconna>=0.50.2
Requires-Dist: nonebot-plugin-apscheduler>=0.5.0
Requires-Dist: nonebot-plugin-localstore>=0.7.0
Requires-Dist: httpx>=0.27.0
Requires-Dist: fastmcp<4,>=3.0
Requires-Dist: json5>=0.9.25
Requires-Dist: pydantic>=2.0
Provides-Extra: dev
Requires-Dist: pytest>=8.0; extra == "dev"
Requires-Dist: pytest-asyncio>=0.23; extra == "dev"
Requires-Dist: ruff<0.17,>=0.16; extra == "dev"
Requires-Dist: nonebot-adapter-onebot>=2.4.6; extra == "dev"

# nonebot-plugin-hermes

中文文档 | [English](https://github.com/gsskk/nonebot-plugin-hermes/blob/main/README_EN.md)

[Hermes Agent](https://github.com/NousResearch/hermes-agent) 的 NoneBot2 插件，通过 Hermes API Server 实现多平台 AI 聊天机器人。

## 支持的平台

通过 NoneBot adapter 机制，本插件自动支持：

- ✅ OneBot v11（NapCatQQ、LLOneBot、go-cqhttp 等）
- ✅ OneBot v12
- ✅ QQ Official Bot
- ✅ Kook（开黑啦）
- ✅ Discord
- ✅ Telegram
- ✅ 飞书
- ✅ 其他 `nonebot-plugin-alconna` 支持的平台

## 工作原理

```
用户消息 → NoneBot Adapter → nonebot-plugin-hermes
  → POST /v1/chat/completions (Hermes API Server)
  → 解析回复 → UniMessage.send() → NoneBot Adapter → 用户
```

## 功能

- ✅ 私聊 / 群聊对话
- ✅ 多轮上下文记忆（基于 Hermes Session）
- ✅ 群聊 @触发 / 关键词触发 / 全部触发
- ✅ **引用消息提取**：自动提取被回复消息中的文本和图片作为 AI 上下文
- ✅ **被动感知 (Chat Awareness)**：在群聊中默默记录最近对话，为下次触发提供完整背景
- ✅ 图片接收（通过 vision 发给 AI）
- ✅ 图片发送（解析 AI 回复中的 markdown 图片）
- ✅ 会话生命周期由 Hermes Agent 管理
- ✅ 白名单（群/用户级别）
- ✅ 内置命令（`/clear` `/ping` `/help` `/hermes-status`）
- 🧪 **群活跃态 (M1, 实验性)**：@bot 后 5 分钟内主动监听群对话，由 Hermes 通过结构化决策判断是否插话
- 🧪 **反向通道 (M1, 实验性)**：内嵌本地 MCP server，让 Hermes 主动 push 消息进群（延迟回复 / 异步通知）
- 🧪 **历史图片召回 (0.3+, 实验性)**：SQLite 持久化消息日志 + 文件系统图字节缓存 + MCP 工具 `get_message_images`，让 Hermes 在用户说"上图"/"刚才那张"时按消息 id 精确取回历史图字节
- 🧪 **OneBot v11 Notice 触发 (0.3.3+, 实验性)**：戳一戳作为第二种 @ 等价触发；有人入群时让 Hermes 自决要不要欢迎（noop 合法，不做模板欢迎语）
- 🧪 **消息段感知扩展 (0.3.4+, 实验性)**：语音/视频/QQ 表情/sticker 占位文本注入 LLM 视野;sticker 自动跳过 vision API。OneBot v11 NapCat 显式 @ 时贴 emoji 回执(`HERMES_ACK_FEEDBACK_ENABLED=true`)
- ✅ **合并转发处理 (0.4.0+)**：群里收到合并转发消息时展开为有限长度摘要；bot 自身长回复在 OneBot v11 群里转为合并转发避免截断

## 快速开始

### 1. 前置条件

- 已安装并运行 Hermes Agent，且 API Server 已启用
- 已安装 NoneBot2 和对应平台的 adapter

### 2. 启用 Hermes API Server

在 `~/.hermes/.env` 中添加配置：

```bash
# 启用 API Server 并指定端口
API_SERVER_ENABLED=true
API_SERVER_PORT=8642
# 如果 NoneBot 和 Hermes 不在同一台机器上，需要监听所有 IP：
# API_SERVER_HOST=0.0.0.0
```

设置 API Key（**必须**，用于会话保持）：

```bash
# 生成密钥
python3 -c "import secrets; print(secrets.token_hex(32))"
# 或 openssl rand -hex 32

# 写入 Hermes 环境配置
echo 'API_SERVER_KEY=your-generated-key' | tee -a ~/.hermes/.env
```

> **Note**: 不设置 `API_SERVER_KEY` 会导致 Session 续接被拒绝，每次对话无法保持上下文。

启动 Hermes Gateway：

```bash
hermes gateway
```

### 3. 安装插件

**方式 A：使用 nb-cli 安装（推荐）**

```bash
nb plugin install nonebot-plugin-hermes
```

**方式 B：使用 pip / uv 安装**

```bash
pip install nonebot-plugin-hermes
# 或 uv add nonebot-plugin-hermes
```

在 `pyproject.toml` 中添加插件（如果是 nb-cli 安装会自动添加）：

```toml
[tool.nonebot]
plugins = ["nonebot_plugin_hermes"]
```

**新建 NoneBot 项目的完整步骤**：

```bash
pip install nb-cli
nb create          # 创建项目，选择 fastapi 驱动器
nb plugin install nonebot-adapter-onebot  # 安装对应平台的适配器，例如 OneBot
nb plugin install nonebot-plugin-hermes   # 安装 Hermes 插件
```

### 4. 配置

复制示例配置：

```bash
cp .env.example .env
```

编辑 `.env`，主要配置：

```env
# OneBot 正向 WebSocket
ONEBOT_WS_URLS=["ws://127.0.0.1:3001"]

# Hermes API
HERMES_API_URL=http://127.0.0.1:8642
HERMES_API_KEY=

# 群聊触发
HERMES_GROUP_TRIGGER=at
```

### 5. 运行

```bash
nb run
```

## 可用的 AI 工具

本插件通过 Hermes 的 `api_server` 平台通信，默认使用 `hermes-api-server` 工具集：

| 工具类别 | 包含的工具 |
|---------|-----------|
| Web 搜索与提取 | `web_search`, `web_extract` |
| 终端与进程 | `terminal`, `process` |
| 文件操作 | `read_file`, `write_file`, `patch`, `search_files` |
| 视觉与图片生成 | `vision_analyze`, `image_generate` |
| 浏览器自动化 | `browser_navigate`, `browser_snapshot` 等 |
| 规划与记忆 | `todo`, `memory`, `session_search` |
| 代码执行与委托 | `execute_code`, `delegate_task` |
| 定时任务 | `cronjob` |
| 智能家居 | `ha_list_entities`, `ha_get_state` 等 |

### 🔒 安全最佳实践：限制 API Server 工具集

默认的 `hermes-api-server` 工具集包含 `terminal`、`execute_code` 等危险工具。**针对不同的部署环境，强烈建议配置不同的受限工具集，特别是在公共群聊中，必须禁止文件读写（`file` 工具）以防敏感信息泄露或被植入后门。**

在 `~/.hermes/config.yaml` 中配置 `platform_toolsets`：

```yaml
platform_toolsets:
  # 其他平台保持默认
  cli: [hermes-cli]
  telegram: [hermes-telegram]

  # API Server 根据部署场景选择工具集 (见下方推荐)
  api_server: [web]
```

推荐的部署安全级别：

| 部署场景 | 推荐配置 | 包含的工具集 | 说明 |
| :--- | :--- | :--- | :--- |
| **🔴 公共群聊 (极简防刷)** | `[web]` | 仅 `web` (联网搜索) | **对外公开机器人的最稳妥配置。** 杜绝文件操作，同时避免画图/识图带来的高昂 API 费用和合规封号风险。 |
| **🟠 公共群聊 (含多媒体)** | `[safe]` | 搜索 + 识图 + 画图 | 等同于 `[web, vision, image_gen]`。增加了视觉能力，但需注意防范 API 被刷或恶意图片封号的风险。 |
| **🟡 内部/信任群聊 (受限读写)** | `[web, vision, image_gen, memory, session_search]` | 搜索 + 多媒体 + 记忆 | 适合公司内部群或好友群。允许发图画图、保留跨会话记忆，但依然严格禁止文件读写。 |
| **🟢 站长私聊 (高级管理)** | `[web, file, vision, image_gen, skills, todo, memory, session_search]` | 包含文件读写、技能管理等 | 适合机器人主人的私聊。有文件读写能力，可通过群白名单机制将其他群屏蔽。 |
| **💀 危险/开发环境 (完全信任)** | `[hermes-api-server]` | 包含终端、代码执行等全部工具 | （默认）仅限开发者自己在安全的隔离环境使用。 |

> [!WARNING]
> **关于 `memory` 和 `session_search` 的跨群隐私泄露风险：**
> Hermes Agent 的底层数据库是全局共享的（无平台/群组隔离）。如果在多群共用的 Agent 上开启这两个工具，**A群的成员可以搜到B群的聊天记录，甚至你的私人终端/私聊记录**。若看重隐私隔离，多群共用时请勿包含 `memory` 和 `session_search`。普通的上下文多轮对话由临时 Session 维护，不受关闭这两个工具的影响。

### 🆔 用户身份与元数据注入

本插件会自动向 Hermes API 注入以下元数据，使后端 LLM 具备环境感知能力：

*   **用户标识** (`user_id`): 用户的平台 ID（如 QQ 号）。
*   **群组标识** (`group_id`): 消息来源群号（私聊则为空）。
*   **适配器名称** (`adapter_name`): 消息来源平台（如 `OneBot V11`, `Discord`, `Telegram` 等）。
*   **私聊状态** (`is_private`): 当前是否为私聊环境。

后端 Prompt 可以通过这些信息实现个性化称呼或针对特定平台的功能逻辑。

### 🔄 会话轮换（0.4.5+）

插件用 `X-Hermes-Session-Id` 请求头维持会话连续性,key 由 `{adapter}+{private|group}+{ids}`
派生(`/clear` 递增 `-gN`)。但这个 id 不是永久不变的:Hermes 自动压缩上下文时会**轮换会话**
—— 旧 id 被置为 `end_reason='compression'` 并关闭,新建一个 continuation 子会话,新 id 通过
**响应头** `X-Hermes-Session-Id` 回传。

插件从 0.4.5 起采纳这个回传值,并把 `internal_id → session key` 映射持久化到消息库同目录的
`session_keys.db`(`/clear` 的 generation 也一并持久化,重启不再复活被清掉的会话)。

0.1.0 ~ 0.4.4 不读这个响应头,后果是每轮都把会话钉回已关闭的父会话:读还能跟随 tip,写全部
失败,而且每压缩一次就再分叉一个兄弟快照。live 子会话超过一个后,Hermes 的
`find_live_compression_child()` 判定歧义并 fail-closed,该会话从此永久写不进去。旧版本的
Hermes 容忍往已关闭会话追加,所以这个问题长期无声;上游 2026-07-23 的
`fix(compression): recover rotated session lineage` 之后变成硬失败,日志会刷
`Session '…' is closed by compression`。存量损坏用 `hermes-repair-sessions` 修。

### 🧠 长期记忆作用域（0.5.0+，默认关）

Hermes 侧的长期记忆(memory provider,目前是 Honcho)默认**不按群区分**。插件不告诉它
"这段对话属于谁"时,它按自己的兜底策略给记忆命名,两种兜底都有问题:

- 全局 / 按目录策略 → **所有群共写一份记忆**,bot 在 A 群知道的事会在 B 群说出来;
- 按会话策略 → 以 Hermes 会话 id 为记忆键,而这个 id **每次自动压缩都会轮换**(见上一节),
  于是记忆每压缩一次换一本,长期什么都攒不下来。

开启本功能后,插件用 `X-Hermes-Session-Key` 请求头显式告诉上游记忆该记在谁名下。这个头与
`X-Hermes-Session-Id` 是两个独立维度:后者管"接哪段对话历史",会随 `/clear` 和自动压缩轮换;
前者管"记忆记在谁名下",跨 `/clear`、跨压缩恒定不变。

```dotenv
HERMES_HONCHO_ENABLED=true
# 群记忆按群还是按人:false = 一个群一份,群成员共享;true = 群内每人一份
HERMES_GROUP_SESSIONS_PER_USER=false
```

部署 Honcho 的 compose 与配置示例见 [`honcho/`](honcho/) 目录(含成本说明与验证方法)。

**前置条件**(缺任一条,功能静默无效):

1. Hermes 端已配好 memory provider(`hermes memory setup`)。Honcho 本身要么用 Honcho Cloud
   (按量计费),要么自托管一套 Postgres + pgvector + FastAPI 服务,不是加个开关就有的东西。
2. 插件配了 `HERMES_API_KEY`。上游对这个头要求鉴权,没 key 时插件不发头并在启动日志 WARN。
3. Hermes 端的 `~/.hermes/honcho.json` **不能有 `peerName` 这个键**。它有值时所有群共用一个
   memory peer,画像层(representation / peer card)会跨群共享,隔离只做到对话记录那一层。
   `hermes memory setup` 向导会把它默认成当前用户名且不接受留空,跑完要手动删掉。
   详见 [`honcho/README.md`](honcho/README.md)。

**切换代价**:开启后记忆作用域改名,此前累积在旧作用域下的记忆不再被读到。数据仍在 Hermes
侧,关掉开关即回原状。另外记忆需要累积,头一两周体感不明显。

记忆 key 默认形如 `agent:main:nonebot-{adapter}:group:{group_id}`,对齐 Hermes 原生 adapter 的
命名格式,`nonebot-` 前缀用于防止与 Hermes 原生 adapter 写进同一个 workspace 时撞名。三个模板
(群共享 / 群按人 / 私聊)都可通过 `HERMES_*_SESSION_KEY_FORMAT` 覆盖,一般不需要。

> [!NOTE]
> 本开关只隔离**记忆**。`session_search` 工具搜的是整个 state.db,不分群(见上文
> "限制 API Server 工具集"的警告),要一并堵住需在 Hermes 端的 `platform_toolsets.api_server`
> 里移除该工具。终端 / 文件类工具的工作区同理不受本开关影响。

## 群活跃态 + 反向通道（M1，实验性）

启用后，bot 在被 @ 之后会进入 5 分钟"活跃窗口"——期间能听到所有群消息（无需再 @），由 Hermes Agent 通过结构化决策（`should_reply` / `should_exit_active`）自行判断是否插话。同时插件起一个本地 MCP server，让 Hermes 可以主动 push 消息进群（延迟回复、异步通知等）。

### 启用

在 `.env` 中：

```env
HERMES_ACTIVE_SESSION_ENABLED=true
HERMES_MCP_ENABLED=true
```

> 启用 `HERMES_ACTIVE_SESSION_ENABLED` 时被动感知会自动开启(消息缓冲是活跃态的依赖),无需再单独设置 `HERMES_PERCEPTION_ENABLED`。后者只在 active=false 的群聊里有意义——给 @bot 那一刻的 LLM 注入旁观历史。

重启后 bot 会：

- 监听 `127.0.0.1:8643` 暴露 MCP 工具:`push_message` / `list_active_sessions` / `get_recent_messages` / `get_message_images`
- 在 @bot 触发后进入 reactive 模式，5 分钟内对群消息做 should_reply 决策（每次插话续期）
- 把每条群消息持久化到 SQLite(默认走 `nonebot-plugin-localstore`,通常 `~/.local/share/nonebot2/nonebot_plugin_hermes/messages.db`)并分配稳定 msg_id;`<recent_messages>` prompt 块的每条历史前缀变成 `[m:<id>]`,Hermes 凭此 id 调 `get_message_images` 取回历史图字节

> ⚠️ **安全注意 ——`HERMES_MCP_HOST` 默认 `127.0.0.1`(loopback)。** 改成监听公网 / 局域网地址在技术上完全可行,但安全后果是:`push_message` 工具能让 bot 往群里发任意内容,而当前防御仅有 Bearer token(明文 HTTP 传输,且与 `HERMES_API_KEY` 同钥匙)。改之前请配套上反向代理(TLS 终结) + 来源 IP ACL,否则任何能 reach 该端口的进程一旦拿到 token 就可以冒名发送。

### 把插件能力告诉 Hermes Agent

插件自带一份 `SKILL.md`（reactive 决策契约 + 反向通道用法），要装到 **Hermes 那台**的
`<HERMES_HOME>/skills/nonebot-bridge/`(默认 `~/.hermes/skills/nonebot-bridge/`)—— 装错机器的话
Hermes 读不到,skill 不生效。安装位置跟随 `HERMES_HOME`(0.5.1+),所以多 profile 部署下按 profile
分别装:`HERMES_HOME=~/.hermes/profiles/<name> hermes-install-skill`。

bot 与 Hermes **同机**时,在 bot 项目目录下任选一种执行：

```bash
# 用 uv 管理依赖
uv run hermes-install-skill

# 或者 bot 项目用普通 venv
.venv/bin/hermes-install-skill

# 或者已激活虚拟环境
hermes-install-skill

# 备用入口（任何能 import nonebot-plugin-hermes 的环境）
python -m hermes_install_skill
```

**分机**部署时,Hermes 那台一般没装插件,不必为此装一遍 —— 脚本只用标准库,克隆仓库直接跑即可
（它按相对路径读同仓库的 `nonebot_plugin_hermes/skill/SKILL.md`,所以要整个仓库目录,不能只拷单文件）：

```bash
git clone https://github.com/gsskk/nonebot-plugin-hermes.git
cd nonebot-plugin-hermes
python3 hermes_install_skill.py
```

（也可以在 bot 那台跑完再把 `~/.hermes/skills/nonebot-bridge/SKILL.md` 拷过去,效果一样。）

然后在 `~/.hermes/config.yaml` 注册插件 MCP server，把 `<HERMES_API_KEY>` 替换为你前面生成的同一把密钥（用于双向鉴权）：

```yaml
mcp_servers:
  nonebot-bridge:
    url: http://127.0.0.1:8643/mcp
    headers: { Authorization: "Bearer <HERMES_API_KEY>" }
```

后续插件 SKILL.md 升级时,用上面同样的入口加 `--force` 重装,例如 `uv run hermes-install-skill --force` 或 `.venv/bin/hermes-install-skill --force`。

## 按群路由到不同 Hermes 接入点（0.5.1+，默认关）

让指定的群走各自的 Hermes **profile**,从而拥有该群独占的工具集、模型和文件工作区。

**先确认你要的是不是这个**:只想让 bot 别把 A 群的事在 B 群说出来,用上文的
`HERMES_HONCHO_ENABLED` 就够了 —— 单进程、不改部署、不用为每个群维护一份 profile。本节解决的是
另一件事:**A 群只能查资料、B 群能跑代码**这种按群给不同能力。它顺带也隔离了记忆(profile 各有
自己的 state.db),代价是每多一个接入点,Hermes 侧就多一份要维护的 `HERMES_HOME`。

### 配置

```dotenv
# 键是 {adapter}:{group_id};未列出的群和所有私聊走默认的 HERMES_API_URL
HERMES_GROUP_ENDPOINTS='{"onebotv11:12345": {"url": "http://127.0.0.1:8642/p/team-a", "key": "<team-a 的 API_SERVER_KEY>"}}'
```

两种部署形态共用同一个 `url` 字段:

| 形态 | Hermes 侧 | `url` 填 |
|------|-----------|----------|
| 多路复用(推荐) | `hermes config set gateway.multiplex_profiles true` 后重启 gateway | `http://host:8642/p/<profile>` |
| 独立进程 | 每个 profile 各起一个 api server | `http://host:8643`(各自端口) |

`key` 留空会沿用全局 `HERMES_API_KEY`,`timeout` 留空沿用 `HERMES_API_TIMEOUT`。

> [!WARNING]
> **profile 名必须全小写**(合法字符 `[a-z0-9][a-z0-9_-]{0,63}`)。`hermes profile create TeamA` 会把名字
> 归一化成小写再落盘(`profiles/teama/`),但 URL 前缀**不做归一化** —— 上游只 `strip()` 后直接和目录名
> 集合比对,所以 `/p/TeamA/` 对着 `profiles/teama/` 会直接 **404**。名字里想分词就用 `-` 或 `_`。

> [!IMPORTANT]
> **指向命名 profile 时 `key` 必填,而且必须与默认 profile 的不同、不短于 16 字符。** 三个原因:
> 上游校验的是该 profile 自己的 `API_SERVER_KEY`(沿用全局 key 必然 401);它是反向通道认身份的
> 依据(见下);而且它是**"忘开 `gateway.multiplex_profiles`"唯一的告警器** —— 多路复用关闭时上游会
> **静默忽略** `/p/<profile>/` 前缀、把请求当默认 profile 处理,此时只有 key 不匹配才会报 401,
> 否则你会看到"一切正常"但零隔离。

### 老的默认接入点不会失效

打开多路复用后,原来那个监听器仍由**默认 profile** 持有:不带前缀的老 URL、老 key 继续可用,
`API_SERVER_KEY` 放在 systemd `Environment=` / docker `environment:` 里也照样读得到(上游对默认
profile 的凭证读取保留了 `os.environ` 回落)。要迁的只有**新增的命名 profile**,它的 key 必须放在
该 profile 自己的 `.env` 里。

### Hermes 侧要做的事

一次性(在**默认 profile** 上做,它才是多路复用器):

```bash
hermes config set gateway.multiplex_profiles true
hermes gateway restart
```

多路复用打开后,**不要**再为次级 profile 单独 `hermes gateway start`,也不要在次级 profile 的
config.yaml 里启用 `api_server` —— 端口绑定类平台留在默认 profile,次级 profile 靠 `/p/<profile>/`
前缀被访问。反过来,如果你选的是"独立进程"形态,那就**别开**多路复用。

`hermes profile create` 结尾打印的 `Next steps` 里那条 `<name> gateway start` 是写给默认的
"一进程一 profile"部署的,**多路复用下别执行**(`<name> setup` 要执行,`<name> chat` 可以用来
验证 key 配好了)。上游确实有守卫会拒绝它并让你改用默认 profile 的 `hermes gateway restart`,
但那个守卫有两个前提、不能当保险:它要**默认 gateway 正在运行**才探测得到(默认 gateway 停着时
这条命令会成功起一个独立进程,之后拉起多路复用器就双绑:同一 bot token 两个 poller、端口冲突),
而且要该 profile 在 `multiplex_profile_allowlist` 的服务范围内,被排除掉时守卫直接放行。

`hermes config set gateway.multiplex_profiles true` 可能会打印一条 `not a recognized config key`
并建议你改成 `gateway.multiplex_profile_allowlist` —— **别照着改**。这个键运行时确实会被读
(`gateway/config.py` 里有专门认它的分支,注释直接点名这条命令),告警只是上游 CLI 的键表没登记
嵌套形式;想消掉就用顶层形式 `hermes config set multiplex_profiles true`(等价)或加 `--force`。
`multiplex_profile_allowlist` 是另一件事:哪些命名 profile 被多路复用器服务,**留空不配 = 全部服务**,
设成 `[]`(或写错类型 fail-safe 成 `[]`)则只服务默认 profile,你的 `/p/team-a/` 会 404。

改完重启,然后用这条确认前缀真的生效(唯一的 ground truth):

```bash
curl -s -o /dev/null -w '%{http_code}\n' \
  -H "Authorization: Bearer <team-a 自己的 API_SERVER_KEY>" \
  http://<hermes-host>:8642/p/team-a/v1/models
# 200 = 生效;401 = 前缀被静默忽略(当默认 profile 处理了,等于没开);
# 404 = 前缀被拒(profile 不存在,或被 allowlist 排除)
```

每个新接入点各一次:

```bash
export TEAM_HOME=~/.hermes/profiles/team-a

hermes profile create team-a                       # 独立 state.db / 记忆 / skills / config.yaml
team-a setup                                       # 给它自己的大模型 API key(见下方 WARNING,别跳过)
echo "API_SERVER_KEY=$(openssl rand -hex 32)" >> $TEAM_HOME/.env   # 必须与默认 profile 不同

# 这个群能用什么能力 —— 本功能唯一不可替代的价值就在这一步。
# 编辑 $TEAM_HOME/config.yaml 的 platform_toolsets.api_server,工具集选法见上文
# 「限制 API Server 工具集」那张表。

HERMES_HOME=$TEAM_HOME hermes-install-skill       # skill 按 profile 分别装

# 反向通道:只给需要它的 profile 配。**两种部署形态写法不同**,见下面
# 「反向通道自动跟着收敛」小节 —— 多路复用下 Bearer 必须配在默认 profile 上,
# 这里写的 header 不会生效。独立进程形态才是下面这条:
HERMES_HOME=$TEAM_HOME hermes mcp add nonebot --url http://<bot>:8643/mcp
#   Bearer 填上面那把 API_SERVER_KEY
```

**两边唯一需要一致的值就是这把 `API_SERVER_KEY`**:插件侧写进 `HERMES_GROUP_ENDPOINTS[...].key`,
Hermes 侧既是它的 `API_SERVER_KEY` 也是它的 MCP token。轮换一次改两处、覆盖两个方向。

`hermes profile create` 还会在 `~/.local/bin/<name>` 生成一个 wrapper(内容是
`exec hermes -p <name> "$@"`),于是:

- **profile 名会变成一个 shell 命令。** 保留名只有 `hermes` / `test` / `tmp` / `root` / `sudo`,
  像 `web`、`top`、`docker` 这种照样能建,并且会在 PATH 里抢在原命令前面 —— 起名前先
  `command -v <name>` 看一眼。
- 之后该 profile 的 hermes 子命令可以直接 `team-a config set …` / `team-a mcp add …`,不必写
  `HERMES_HOME=…`。但 `hermes-install-skill` 是**本插件**的独立 CLI,不走这个 wrapper,仍要带
  `HERMES_HOME=`。

> [!WARNING]
> **多路复用下必须给命名 profile 自己的 LLM provider key** —— 就是大模型厂商那把 API key
> (`ANTHROPIC_API_KEY` / `OPENAI_API_KEY` / `OPENROUTER_API_KEY` / `NOUS_API_KEY` /
> `GEMINI_API_KEY` 之类),没有它 agent 连一次推理都发不出去。注意它与本节另外两把 key 是三件
> 不同的东西:
>
> | 这把 key | 谁验它 | 放哪 |
> |---|---|---|
> | LLM provider key(如 `ANTHROPIC_API_KEY`) | 大模型厂商 | `profiles/<name>/.env` |
> | `API_SERVER_KEY` | Hermes 自己的 api_server 入站鉴权 | `profiles/<name>/.env` |
> | 插件的 `HERMES_API_KEY` / 条目 `key` | 同上,是插件出向呈上的那一把 | bot 的 `.env` |
>
> `hermes profile create` 结尾会提示"否则会继承你 shell 环境里的 key" —— 那只在单 profile 部署
> 成立。多路复用打开后,凭证读取以 profile 的 secret scope 为权威且**不回落 `os.environ`**
> (全局豁免表里只有 PATH / HOME / `API_SERVER_HOST|PORT|ENABLED` 这类部署项,没有任何 API key),
> 所以 `.env` 空着的 profile 里 agent 一次都跑不起来。跑 `<name> setup`,或把 key 直接写进
> `profiles/<name>/.env`。**同一条规则适用于该 profile 用到的所有凭证**,不止 LLM ——
> 搜索(`EXA_API_KEY` 等)、图片生成、memory provider 的 key 也都要在它自己的 `.env` 里。

### 反向通道自动跟着收敛

反向通道(`push_message` / `get_recent_messages` / `get_message_images` / `list_active_sessions`)
**没有第二张 token 表**:调用方呈上哪个接入点的 key,就只能操作该接入点名下的群;呈上全局
`HERMES_API_KEY` 则只能操作**补集**(不在路由表里、或条目没有自己 key 的那些群);两者都不是就 401。
不配路由表时补集 = 全部群,行为与 0.5.0 完全一致。

也就是说:**想被保护的群必须进路由表并指向一个命名 profile**。留在补集里的群,补集那把 key 的持有者
(默认 profile)照样能读能推。

> [!IMPORTANT]
> **多路复用下,"哪个 profile 有反向通道"可以按 profile 控;"它呈哪把 token"不行。** 上游把这件事
> 分成两层:
>
> | 层 | 读谁的 config | 时机 |
> |---|---|---|
> | **连接**(进程里有没有这个 MCP client) | **默认 profile** 的 `config.yaml` | gateway 启动一次,注册表全进程共享 |
> | **可用性**(agent 拿不拿到这些工具) | **被路由到的那个 profile** 的 `config.yaml` | 每请求读 |
>
> 所以多路复用下:
>
> - **Bearer 必须配在默认 profile 上**。命名 profile 里 `hermes mcp add` 写的 `url` / `headers`
>   不会生效 —— 同名 server 全进程共用默认 profile 建的那一个连接。
> - 反过来,**要不要给某个 profile 反向通道是它自己说了算**:它的 `platform_toolsets.api_server`
>   列出该 server 名 = 打开;放特殊哨兵 `no_mcp` = 该 profile 完全没有 MCP 工具;两者都不写则它的
>   MCP 名单为空,同样拿不到。这一层每请求读、改完不用重启。
> - 照最直觉的配法(默认 profile 一个 `nonebot` server),**scope 不随 profile 变**:插件看到的永远
>   是那把共享 token,通常就是全局 `HERMES_API_KEY`(scope = 补集),于是进了路由表的群谁都推不进去
>   —— 会被拒并留 WARNING(fail-closed,但等于那些群没有反向通道)。
>
> **想在多路复用下让 token 也按接入点分开,可以做到** —— MCP 工具名按 server 名 namespace
> (`mcp__<server>__<tool>`),所以同一个 URL 可以用不同名字连多次:
>
> ```yaml
> # ~/.hermes/config.yaml(默认 profile —— 连接与 token 都由它建立)
> mcp_servers:
>   nonebot-team-a:
>     url: http://<bot>:8643/mcp
>     headers: { Authorization: "Bearer <team-a 的 API_SERVER_KEY>" }
>   nonebot-lab:
>     url: http://<bot>:8643/mcp
>     headers: { Authorization: "Bearer <lab 的 API_SERVER_KEY>" }
> ```
>
> ```yaml
> # ~/.hermes/profiles/team-a/config.yaml —— 只声明属于自己的那个名字
> mcp_servers:
>   nonebot-team-a: { url: http://<bot>:8643/mcp }   # url/headers 不生效,声明名字即开启
> ```
>
> 这样 team-a 的 agent 只看到 `mcp__nonebot_team_a__*`,发出的请求带 team-a 那把 token,插件的
> scope 判定就按接入点成立了。**默认 profile 自己也要显式 allowlist**,否则它会拿到全部 server 名、
> 也就拿到了操作别人群的能力:
>
> ```yaml
> # ~/.hermes/config.yaml
> platform_toolsets:
>   api_server: [<你原来那些工具集>, nonebot-default]   # 只列自己那个名字
> ```
>
> 三个代价:**默认 profile 的 config.yaml 里握着全部接入点的 token**(所有连接都由它建,所以默认
> profile 必须可信 + 受限工具集,它的 agent 能读文件就能拿到全部 token);每个 server 名一条常驻
> 连接;名字两处必须一致,拼错就是那个 profile 静默没有工具。Bearer 若写成 `${MCP_*_API_KEY}`
> 引用形式,变量必须在**默认 profile 的 `.env`** 里 —— 插值发生在启动建连时的默认作用域下。
>
> 启动期 `validate_endpoints()` 会对"路由表里有 `/p/<profile>` 形式的 url 且
> `HERMES_MCP_ENABLED=true`"这个组合告警,提醒你按上面这样配。

被拒时 bot 侧会留一条 WARNING,写清调用方属于哪个接入点、它的范围、被拒的目标 —— 排查
"某个群的反向推送忽然不工作"时先看这条。

### 运维成本与已知限制

- 每个 profile 是一份完整的 `HERMES_HOME`:`hermes-repair-sessions` 要按 profile 分别跑,
  skill 升级要按 profile 分别装。
- **改动某个群的路由条目后要对该群 `/clear`**:session 血缘不带接入点维度,旧 session id 在新
  profile 里不存在,上游会静默开一段新会话,同名 session 分居两份 state.db。
- `/ping` 只探当前会话自己的接入点(它对普通用户开放,不能列别群的路由键);逐接入点体检在
  管理员命令 `/hermes-status` 里。
- 启动期的上游能力探测(`/v1/capabilities`)只探默认接入点,命名 profile 的 Hermes 版本偏旧不会告警。
- 启动日志会对路由表里"永远匹配不上的键 / 非 http(s) 地址 / 缺 key / key 过短 / 同一接入点多把 key"
  逐条 WARN。
- `session_search` 仍是另一条跨群通道:分了 profile 自然分开;不分 profile 又想堵,在该 profile 的
  `platform_toolsets.api_server` 里移除该工具。

## 历史图片召回（0.3+，实验性）

在 0.3 起,消息感知 + 反向通道一起开启时,bot 自动启用一条"按消息 id 精确召回历史图"的通路。典型场景:

```
T0    用户 A:  [图片]                    ← 仅文字描述,bot 看到 [图片] 占位
T+5s  用户 B:  @bot 评价下上图
                ↓
                Hermes 看到 prompt 里 [m:1234] A: [图片]
                Hermes 调 get_recent_messages → 知道 m:1234 有图(image_count=1)
                Hermes 调 get_message_images([1234]) → 拿到字节
                下一轮 LLM 真的看到那张图,回复正常
```

技术细节:

- **持久化**:消息进 SQLite,路径由 `nonebot-plugin-localstore` 管理(默认 `~/.local/share/nonebot2/nonebot_plugin_hermes/messages.db`,可被 `LOCALSTORE_*` env vars 整体重定向);自增 id 即 `[m:<id>]` 前缀的 N
- **字节缓存**:perception 看到图后异步抓 URL → 落到 localstore 管理的 cache dir(默认 `~/.cache/nonebot2/nonebot_plugin_hermes/images/<sha256>.<ext>`),LRU 按 atime 淘汰,默认 200MB 上限
- **失败降级**:URL 短效过期 / 缓存被淘汰 / 消息已过 30 天保留期 → MCP 工具返回 `available: false`,Hermes 礼貌告知用户图不可用,不崩
- **保留窗口**:消息 30 天或 10 万条上限(谁先到),整点 :37 后台 vacuum

如果你的 Hermes 后端模型偏弱、识别 `[m:<id>]` 约定不稳,bot 行为退化为今天的"看不到上图"——无 regression。

## 命令

| 命令 | 说明 |
|------|------|
| `/clear` | 重置对话，开始新会话 |
| `/ping` | 检查 Hermes Agent 连接状态 |
| `/help` | 显示帮助信息 |
| `/hermes-status` | 打印 M1 运行时状态（MCP / 活跃 sessions / buffer / registry）。**需在 `HERMES_ADMIN_USERS` 显式授权 `adapter:user_id`**;非管理员调用时静默无响应,且 `/help` 输出里也不出现该命令 |

### 命令行工具

| 命令 | 在哪台跑 | 说明 |
|------|---------|------|
| `hermes-install-skill --force` | **Hermes 那台** | 把 `SKILL.md` 装到 `~/.hermes/skills/nonebot-bridge/`(覆盖已装版本要带 `--force`) |
| `hermes-purge-media` | **bot 那台** | 清理插件消息库(`messages.db`)里内联的 base64 图片字节。默认只报告,`--apply` 写回,`--vacuum` 收缩文件 |
| `hermes-repair-sessions` | **Hermes 那台** | 解开 Hermes `state.db` 里被 compression 血缘歧义卡死的会话。默认只报告,`--apply` 备份后修复 |

「在哪台跑」取决于工具动的是谁的数据,不是谁装了插件。bot 与 Hermes 同机时三个命令都能直接敲;
**分机部署时 Hermes 那台通常并没有装本插件**,此时不需要为了跑工具去装一遍——三个脚本都是仓库
根目录下的单文件、只用标准库、也不 import 本包:

```bash
git clone https://github.com/gsskk/nonebot-plugin-hermes.git
cd nonebot-plugin-hermes
python3 hermes_repair_sessions.py            # 与 hermes-repair-sessions 完全等价
```

(只拷单个文件过去跑也行;`hermes-install-skill` 例外——它要读同仓库里的
`nonebot_plugin_hermes/skill/SKILL.md`,得带上仓库目录。)

`hermes-purge-media` 用于清理历史遗留:早期版本会把 agent 回复里 api_server 内联的
`data:image/…;base64,…` 整段存进消息库,单条可达 MB 级。当前版本写入端与渲染端都已挡住,
这个命令只把存量字节清出去。

在 **bot 那台**(插件装在那儿)执行:

```bash
uv run hermes-purge-media                    # 只报告:每群命中数、最大行、可回收字节
uv run hermes-purge-media --apply --vacuum   # 清理并收缩文件
```

bot 项目用普通 venv 就换成 `.venv/bin/hermes-purge-media`;已激活虚拟环境时可直接敲
`hermes-purge-media`。裸命令只在虚拟环境已激活时才在 PATH 上。

不删消息,只把图片 payload 换成 `[图片]` 占位;幂等,可反复运行。`--vacuum` 需要排它锁,
拿不到时停掉 bot 再跑。

`hermes-repair-sessions` 修的是 Hermes 侧 `state.db` 的会话血缘。症状是 Hermes 日志反复刷:

```
Session '…' is closed by compression; adopt its live continuation before appending messages
compression skipped: … no unique live child could be adopted
```

成因见上文「会话轮换」:0.1.0 ~ 0.4.4 的插件不采纳轮换后的 session id,每压缩一次就从同一个
已关闭的父会话再分叉一个快照子会话;live 子会话超过一个后上游判定歧义并 fail-closed,该会话
从此写不进去 —— 对话记录冻结,上下文还会无限膨胀(压缩永远跑不完)。

在 **Hermes 那台**执行。那台一般没装插件,所以下面直接用单文件形式:

```bash
systemctl stop hermes-gateway     # 修复期间要拿写锁,跑着的 agent 也可能持有旧会话状态

git clone https://github.com/gsskk/nonebot-plugin-hermes.git
cd nonebot-plugin-hermes
python3 hermes_repair_sessions.py            # 只报告:哪些会话卡住、会动哪些行
python3 hermes_repair_sessions.py --apply    # 整库备份后:重开父会话 + 退休快照子会话

systemctl start hermes-gateway
```

与 bot 同机、插件已装时,把这两行换成 `uv run hermes-repair-sessions [--apply]` 即可。

不删任何消息行。**先把插件升级到 0.4.5+ 并重启**,再停掉 gateway 跑修复 —— 否则下一次压缩
会把父会话再次关闭,几轮之内又卡回去。某个子会话若是被真正续写过的 continuation
(消息跨度远超一次批量写入),脚本会跳过该会话并报告,交给人判断。

## 配置项

所有配置项通过 `.env` 文件设置，参见 [.env.example](.env.example) 中的详细注释。

| 配置项 | 默认值 | 说明 |
|--------|--------|------|
| `HERMES_API_URL` | `http://127.0.0.1:8642` | Hermes API Server 地址 |
| `HERMES_API_KEY` | (空) | API 密钥（建议设置以启用会话持久化） |
| `HERMES_API_TIMEOUT` | `300` | API 请求超时时间（秒） |
| `HERMES_GROUP_ENDPOINTS` | `{}` | 按群路由的接入点表,键 `{adapter}:{group_id}`,值 `{"url": …, "key": …, "timeout": …}`。空表 = 全部走 `HERMES_API_URL`。也是反向通道 scope 的唯一来源,详见上文「按群路由到不同 Hermes 接入点」 |
| `HERMES_GROUP_TRIGGER` | `at` | 群聊触发方式: `at` / `all` / `keyword` |
| `HERMES_KEYWORDS` | `["/ai"]` | `keyword` 模式下的触发关键词 |
| `HERMES_PRIVATE_TRIGGER` | `all` | 私聊触发方式: `all` / `allowlist` |
| `HERMES_ALLOW_USERS` | `[]` | 允许私聊的用户 ID 列表 (`allowlist` 模式) |
| `HERMES_ALLOW_GROUPS` | `[]` | 允许响应的群组 ID 列表（空为全部允许） |
| `HERMES_ADMIN_USERS` | `[]` | 管理员白名单,格式 `["telegram:<user_id>", "onebotv11:<user_id>"]`。**默认空集 = deny by default**;`/hermes-status` 等敏感命令必须命中此列表才执行 |
| `HERMES_SESSION_SHARE_GROUP` | `false` | 群内是否共享同一个 session |
| `HERMES_HONCHO_ENABLED` | `false` | 发送 `X-Hermes-Session-Key`,按群/私聊隔离 Hermes 侧长期记忆并让记忆不随压缩轮换重置。需要上游配了 memory provider + 本插件配了 `HERMES_API_KEY`,详见上文「长期记忆作用域」 |
| `HERMES_GROUP_SESSIONS_PER_USER` | `false` | 群记忆按群还是按人。`false` = 一个群一份(成员共享);`true` = 群内每人一份 |
| `HERMES_GROUP_SESSION_KEY_FORMAT` | `agent:main:nonebot-{adapter}:group:{group_id}` | 群共享记忆 key 模板 |
| `HERMES_GROUP_PER_USER_SESSION_KEY_FORMAT` | `agent:main:nonebot-{adapter}:group:{group_id}:{user_id}` | 群按人记忆 key 模板 |
| `HERMES_PRIVATE_SESSION_KEY_FORMAT` | `agent:main:nonebot-{adapter}:dm:{user_id}` | 私聊记忆 key 模板 |
| `HERMES_MAX_LENGTH` | `4000` | 单条回复最大长度（超出后截断） |
| `HERMES_IGNORE_PREFIX` | `["."]` | 以这些字符开头的消息不触发回复 |
| `HERMES_PERCEPTION_ENABLED` | `false` | 群聊 + active_session=false 下,是否在 @bot 时给 LLM 注入旁观历史。**`HERMES_ACTIVE_SESSION_ENABLED=true` 时自动隐含为 on,本开关无效**。私聊永远不注入(Hermes session 已覆盖) |
| `HERMES_PERCEPTION_BUFFER` | `10` | 被动感知缓存的历史消息数量 |
| `HERMES_PERCEPTION_TEXT_LENGTH` | `200` | 被动感知单条历史消息最大长度 |
| `HERMES_PERCEPTION_IMAGE_MODE` | `placeholder` | ⚠️ **0.3 起弃用**——历史图召回改走 `get_message_images` MCP 工具。本配置当前仅控制 `[图片]` 文本占位是否出现(`none`=不加占位;其他值=加占位)。`inline_labeled` 行为已被 MCP 工具流取代,设为该值与 `placeholder` 等效 |
| `HERMES_ACTIVE_SESSION_ENABLED` | `false` | 启用群活跃态（M1）。`false` 时退化为 v0.1.6 等价行为 |
| `HERMES_ACTIVE_SESSION_TTL_SEC` | `300` | 活跃窗口 TTL（秒），每次插话滑动续期 |
| `HERMES_ACTIVE_SWEEP_INTERVAL_SEC` | `30` | 活跃态过期清扫 cron 频率（秒） |
| `HERMES_POKE_TRIGGER_ENABLED` | `false` | OneBot v11:被戳一戳时触发对话（私聊 / 群都生效,等价于被 @）。其他适配器静默忽略 |
| `HERMES_GREET_ON_JOIN` | `false` | OneBot v11:有人加入群且 `HERMES_ACTIVE_SESSION_ENABLED=true` 时,触发一次 reactive turn 让 Hermes 自决是否欢迎(`noop` 是合法返回)。active 关时不触发 |
| `HERMES_ACK_FEEDBACK_ENABLED` | `false` | 用户消息上显示 ack 回执(B-0 实装 OneBot v11 NapCat emoji)。B-0.5 规划扩 TG/Discord 私聊 typing |
| `HERMES_ACK_EMOJI_ID` | `341` | B-0 OneBot v11 路径下贴的 QQ 表情 id(默认 341 = /打招呼;`373` /忙 = 打字动物;`129` /挥手 = 经典挥手) |
| `HERMES_BUFFER_PER_GROUP_CAP` | `200` | ⚠️ **0.3 起空转**——MessageBuffer 改为 SQLite 后端,无内存 per-group 上限;消息淘汰由 `HERMES_STORAGE_MESSAGE_*` 控制。下一个 major 版本会移除 |
| `HERMES_BUFFER_TOTAL_GROUPS_CAP` | `50` | ⚠️ **0.3 起空转**——同上,SQLite 后端无 LRU,改为 retention + 行数上限 |
| `HERMES_MCP_ENABLED` | `false` | 启动内嵌 FastMCP server（M1 反向通道） |
| `HERMES_MCP_HOST` | `127.0.0.1` | MCP server 绑定地址。改成公开地址前请阅读上文「群活跃态 + 反向通道」节的安全注意 |
| `HERMES_MCP_PORT` | `8643` | MCP server 绑定端口 |
| `HERMES_MCP_RECENT_LIMIT_MAX` | `50` | `get_recent_messages` 工具单次最大返回条数 |
| `HERMES_STORAGE_DB_PATH` | (空) | SQLite 消息日志路径。空值走 `nonebot-plugin-localstore` 的 plugin_data_dir(通常 `~/.local/share/nonebot2/nonebot_plugin_hermes/messages.db`),也可被 `LOCALSTORE_*` env vars 重定向 |
| `HERMES_STORAGE_MESSAGE_RETENTION_DAYS` | `30` | 消息日志保留天数,vacuum cron 删超龄行 |
| `HERMES_STORAGE_MESSAGE_MAX_ROWS` | `100000` | 消息日志总行数硬上限,超出按 ts 老到新删 |
| `HERMES_IMAGE_CACHE_DIR` | (空) | 图字节缓存目录。空值走 localstore 的 plugin_cache_dir(通常 `~/.cache/nonebot2/nonebot_plugin_hermes/images/`) |
| `HERMES_IMAGE_CACHE_QUOTA_MB` | `200` | 图缓存总体积上限(MB),vacuum 时按 atime 老到新淘汰 |
| `HERMES_IMAGE_FETCH_TIMEOUT_S` | `10` | 单图 HTTP 抓取超时秒数 |
| `HERMES_IMAGE_FETCH_MAX_ATTEMPTS` | `2` | 单图总尝试次数(1=不重试,2=一次重试,以此类推) |

### Busy notice(显式 @ 被 plumbing 丢单时的可见信号)

当 `_refire` 链触顶 `MAX_REFIRE_DEPTH=3`(同群短时间内塞了 ≥ 4 条 explicit @ 而上游 Hermes 跟不上)时,最新一条 explicit @ 会被 plumbing 丢掉。此时插件会在那条原消息上贴 `HERMES_BUSY_EMOJI_ID`(默认 97 = QQ 经典表情 /擦汗),**不撤销**,作为"我看见了但确实忙不过来"的视觉信号。

与 ack-feedback emoji(`HERMES_ACK_EMOJI_ID`,默认 341 /打招呼)是不同语义:
- ack-feedback:chat() 期间常驻,完成后撤销,表示"工作中"
- busy notice:depth-cap 触顶时常驻,**不撤销**,表示"工作不下去"

默认值刻意取互相区分明显的表情;改默认值前请验证 OneBot 实现端的 emoji_id 映射表。

仅 OneBot v11 群聊路径生效;其它 adapter(Telegram / Discord)或 msg_id 缺失时降级为 WARN 日志,不会文本兜底,避免在 burst 上下文里加噪声。

同样有一类失败路径有 user-visible 兜底:上游 Hermes 5xx / 网络断时,refire 路径上的 explicit @ 会发 `HERMES_TRANSPORT_ERROR_FALLBACK_TEXT`(默认"嗯…我这边遇到点状况,稍后再问一次")。设为空串可关闭文本兜底。

## 限制

由于通过 HTTP API 与 Hermes 通信（而非原生 Gateway Adapter），以下功能不可用：

- ❌ 追问用户（`clarify` 工具）
- ❌ 跨平台发消息（`send_message` 工具）
- ❌ 语音合成发送（`text_to_speech` 工具）
- ❌ 危险命令审批按钮
- ❌ Cron 定时主动推送
- ❌ 中断正在运行的 Agent

## License

MIT
