Metadata-Version: 2.4
Name: wishee-hermes-connector
Version: 0.11.0
Summary: Connect a Hermes agent to the Wishee bridge via reverse WebSocket
License: MIT
Requires-Python: >=3.11
Description-Content-Type: text/markdown
Requires-Dist: websockets>=12.0
Requires-Dist: qrcode>=7.4
Provides-Extra: dev
Requires-Dist: pytest>=8.0; extra == "dev"
Requires-Dist: pytest-asyncio>=0.23; extra == "dev"

# wishee-hermes-connector

Connect a [Hermes](https://github.com/NousResearch/hermes) agent to the Wishee bridge
(`openclaw-bridge`) over a reverse WebSocket — the Hermes counterpart of
`@wishee-ai/openclaw-connector`. The bridge drives the agent (`chat.send` /
`chat.history` / `sessions.reset`) and receives its replies (`task-result`).

Requires no changes to the bridge or to hermes-agent core.

**Requires Hermes >= 0.14.0.** The connector checks this in `register()` and refuses
to load below it — Hermes surfaces the error in the gateway log and in
`hermes plugins list`. See `version_gate.py` for how that floor was derived
(the binding constraint is `PlatformEntry.is_connected`, 2026-04-20).

## How it works

The connector is a Hermes **platform adapter** (runs inside the `hermes gateway`
process). It dials the bridge, does the `hello`/`welcome` handshake, then:

- `chat.send` → mints a `runId`, answers immediately, runs the agent turn, and
  emits a `task-result` event when the turn finishes (the bridge's device-push path).
- `chat.history` / `sessions.reset` → reads / clears the Hermes session store.

From the bridge's perspective a Hermes agent is indistinguishable from an
OpenClaw instance: same `/api/bind`, same `hello`, same RPCs, same event frames.

### Activity (usage duration)

The connector installs a Hermes hook at `~/.hermes/hooks/wishee-activity/` that
listens for `agent:start` / `agent:end` — the two events that bracket one
`_run_agent` call, i.e. the real boundary of a turn, **for every platform**. So
turns the user starts locally count too, not just the ones the bridge dispatches.

The hook handler only appends one self-contained interval per finished run to a
local journal (`~/.hermes/wishee-connector/wishee-activity-YYYYMMDD.jsonl`); it
never imports the connector, touches the network, or starts a timer. The
connector reads that journal by byte watermark and ships batches as
`agent-activity` frames, advancing the watermark **only after a successful
send** — the journal is what makes this survive a disconnect or a restart
(unlike an in-memory queue).

On first run it also seeds the journal once from `state.db`, deriving past turns
from message timestamps, so history predating the hook isn't lost.

> The hook is picked up by `discover_and_load()` at gateway startup, so a freshly
> installed hook **takes effect on the next gateway restart**.

### 阻塞式提示（询问选项 / 危险命令审批）

Hermes 的两类**会卡住 agent 线程等用户**的提示——`clarify`（提问 + 选项）与
危险命令审批——都经 bridge 现成的**输入类审批**通道推到
设备：`approval-request{kind:"user_input", inputSpec}` → 设备渲染 →
`approval.respond{approvalId, decision, input}` → `approval-resolved`。不新开
帧，所以 bridge 和 profile-service 无需改动。

提问卡的 `inputSpec` 与 codex 连接器的 `requestUserInput` **同构**（`questions[]`，
契约 v2），这样主机端只做一种渲染——见
`docs/superpowers/specs/2026-09-11-user-input-card-contract.md`。危险命令审批那半
（`kind:"command"`）不变，见
`docs/superpowers/specs/2026-09-10-approval-roundtrip-contract.md`。

> 不实现这两个方法的话都会落到 Hermes 的文本兜底：问题被拼成编号文本走
> `send()`，而 `send()` 只往 `_pending_reply` 里塞，要等整轮跑完才发——可这一轮
> 正卡在等回答上（默认 3600 秒）。主机端因此整整一小时收不到任何一帧，日志里
> 还什么都不报，因为兜底照样回 `success=True`。

一轮结束时还挂着的问题会回一条 `approval-resolved{outcome:"canceled"}`，设备
据此撤掉卡片——超时之后 agent 早就自己往下跑了，那张卡再答也没有去处。0.10.0 起
不必等到一轮结束：连接器每 3 秒看一眼 Hermes 还等不等设备上挂着的提示（审批超时、
clarify 超时、在同一会话里打 `/approve` `/deny` 答掉），不等了就立刻撤卡。

危险命令审批卡的按钮由 bridge 按 `rawParams.availableDecisions` 出（codex 的词汇），
连接器据 Hermes 的 `allow_session` 带上 `acceptForSession`，设备上就有「允许本次会话」；
「始终允许」故意不给（设备那页永远不发 `approve_always`）。Hermes 0.21 起每条待批有
`request_id`，连接器按它批，同一会话里同时挂着两条时不会批错（老版本只能 FIFO）。
Hermes 0.21.5 起网关只给声明了 `supports_exec_approval_buttons()` 的平台发审批卡，
连接器声明了。

0.11.0 起：

- Hermes 0.21.x 收掉一个没答的问题时会调 `retire_clarify_card`，设备上的卡当场撤掉
  （老版本仍靠上面那个 3 秒巡检）；提问卡发失败后网关补发的纯文本版如实报失败，
  agent 不会按"已送达"干等一小时。
- 推出去还没收尾的卡记在 `<HERMES_HOME>/wishee-connector/pending-cards.json`：Hermes
  重启（升级、崩溃、OOM）后那几轮已经没了，或者收尾那一刻正好和 bridge 断着，下次连上
  bridge 时补发 `canceled` 把作废的卡撤掉。

### 执行过程（0.10.0）

与 codex 连接器同一套契约，设备上「正在执行任务…」那一行、锁屏卡、过程播报都靠它：

- `agent-progress{sessionKey, itemId, ts}` —— agent 说完一段话、**后面还要调工具**时发，
  只带信号不带正文。来源是 `post_api_request` 插件钩子（0.14.0 起就有）：带工具调用
  的那次模型返回一定不是结论，所以一到就报，不受 `display.*` 配置影响；正文按 Hermes
  自己的 `strip_think_blocks` 洗掉推理块。不用 `send()` 当来源：流式 / previewed 时
  结论也走它，0.14 上心跳和中间消息又分不出来。只报**人话**：去掉代码之后一个字都不剩
  的不算过程（口径同 codex 连接器）。Hermes 0.21.x + Codex 类 provider（`openai-codex`）
  的 `content` 是空的，那句话在 `codex_message_items` 的 commentary 里，按 Hermes 自己界面
  那套（`visible_commentary`：去推理块 + 脱敏）取。
- `chat.turns{sessionKey, limit}` —— 这条会话最近若干**轮**（一条用户消息 + 它之后的
  agent 回复），每条消息带 `id`、每轮带 `status`（`inProgress` / `completed` / `failed`）。
  跑完的轮次读 state.db；**在跑的那一轮只认内存**——0.14–0.16 整轮跑完才落盘、0.18 起
  边跑边写，盘上那份要么没有要么只有半截。开跑时记下全库 `messages.id` 的水位，比它
  大的行就是这一轮的；跑完后按正文把过程的 itemId 对回去，推送晚到也找得到。
  `chat.history` 原样保留给老设备。

The transport **auto-reconnects**: an unexpected socket close (bridge restart,
network drop) re-dials with capped exponential backoff and re-runs the
`hello`/`welcome` handshake. A deliberate shutdown never reconnects.

## Install

```bash
pip install wishee-hermes-connector   # into your Hermes environment
```

## Enable

1. Add `wishee` to `plugins.enabled` in your Hermes `config.yaml` (entry-point
   plugins are opt-in).
2. Optionally set `WISHEE_BRIDGE_URL` to override the default
   (`wss://ai-bridge.wishee.com.cn/ws`).
3. Run `hermes gateway`. On first run it prints a QR code — scan it to bind this
   instance to the bridge (via the bridge's `/api/bind`). Identity is stored at
   `<HERMES_HOME>/wishee-connector/binding.json`, i.e. `~/.hermes/...` unless
   `HERMES_HOME` says otherwise — the same directory the activity journal uses.
   Up to 0.8.0 this one path instead read `HOME` and fell back to `/tmp`, which
   on Windows (where a gateway cold start usually has no `HOME`) put the
   identity in `C:\tmp` and made the agent look unbound after an upgrade.
   0.9.0 adopts an identity left at that old location on first run, copying it
   rather than moving it so a rollback still finds one.

## Scope

Emits `task-result` on each completed turn (the bridge's device-push path), and since
0.10.0 `agent-progress` signals + `chat.turns` for the in-turn progress (see above).
Token-level streaming is still out of scope: the device only needs whole lines.

## Development

Tested against Hermes 0.14.0 (tag `v2026.5.16`, what the oldest supported installs run),
0.21.0 (tag `v2026.8.31`) and 0.21.5 (tag `v2026.9.24`, the device end-to-end run of
0.11.0). A few tests skip themselves on 0.14 where the feature does not exist there
(multi-select clarify, approval `request_id`).

```bash
uv venv --python 3.13 .venv
uv pip install --python .venv/bin/python -e /path/to/hermes-agent
uv pip install --python .venv/bin/python -e ".[dev]"
.venv/bin/python -m pytest -q
```
