344 lines
15 KiB
Markdown
344 lines
15 KiB
Markdown
# Hermes на Eagle (Mac M4 Max) — Настройка и подводные камни
|
||
|
||
Hermes работает нативно (не в Docker) на Mac через `hermes gateway`.
|
||
Транспорт — Zulip (запущен в Docker). Провайдер модели — openclaw-claude-proxy
|
||
(см. ниже).
|
||
|
||
## Компоненты
|
||
|
||
| Компонент | Расположение | Запуск |
|
||
|-----------|-------------|--------|
|
||
| Hermes config | `~/.hermes/config.yaml` | Eagle Dashboard (supervisor) — `POST /api/services/hermes/start` |
|
||
| claude-proxy (Claude proxy) | `/opt/homebrew/bin/claude-proxy` | launchd `ai.claude-proxy` |
|
||
| Zulip stack | `~/Developer/zulip/docker-compose.yml` | `docker compose up -d` |
|
||
| Obsidian MCP | mcpvault | встроен в Hermes toolset |
|
||
|
||
---
|
||
|
||
## Claude CLI прокси — обход rate limit Claude API
|
||
|
||
> **Актуальный прокси (июнь 2026):** Python `claude-code-openai-wrapper` на
|
||
> порту 8090. Детальная документация: [[claude-python-cli-proxy]].
|
||
|
||
### Проблема
|
||
|
||
`provider: claude-code` в Hermes использует OAuth-токен напрямую через
|
||
API Anthropic — и упирается в rate limit подписки. Лимиты сбрасываются
|
||
раз в час. API-ключа нет (политика организации).
|
||
|
||
### Почему не cmappy
|
||
|
||
cmappy (`claude-max-proxy-py`) молча выбрасывает поле `tools` из запроса —
|
||
передаёт только текст в `claude --print`. Результат: Hermes не может
|
||
использовать **ни один инструмент** (скиллы, MCP, терминал). Только голый
|
||
чат.
|
||
|
||
### Решение: openclaw-claude-proxy
|
||
|
||
[mehdic/claude-proxy](https://github.com/mehdic/claude-proxy) (npm:
|
||
`openclaw-claude-proxy`) — Node.js сервер, запускает `claude --print` как
|
||
subprocess и предоставляет OpenAI-совместимый `/v1/chat/completions` на
|
||
порту 3456. **Поддерживает tool_use** — инжектирует схемы инструментов в
|
||
системный промпт, парсит JSON tool_call из ответа, возвращает стандартный
|
||
OpenAI `tool_calls`. Caller (Hermes) сам выполняет инструменты.
|
||
|
||
**Важно:** `CLAUDE_PROXY_TOOLS_TRANSLATION=1` НЕ включать — этот режим
|
||
выполняет MCP инструменты внутри CLI и Hermes ничего не получает.
|
||
|
||
### Установка
|
||
|
||
```bash
|
||
npm install -g openclaw-claude-proxy
|
||
```
|
||
|
||
### Wrapper-скрипт (обязателен для launchd)
|
||
|
||
`~/.local/bin/claude-proxy-start.sh`:
|
||
```bash
|
||
#!/bin/zsh
|
||
# launchd не наследует среду login-сессии — токен нужно загружать явно
|
||
set -a
|
||
source /Users/admin/.hermes/.env 2>/dev/null
|
||
set +a
|
||
export PATH="/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:$PATH"
|
||
exec /opt/homebrew/bin/claude-proxy 3456
|
||
```
|
||
|
||
**Pitfall:** без явного `source ~/.hermes/.env` claude-proxy не видит
|
||
`CLAUDE_CODE_OAUTH_TOKEN` и прогревочные процессы падают с "Not logged in".
|
||
|
||
**Pitfall:** без явного PATH Claude CLI не найден (`/opt/homebrew/bin/claude`
|
||
не в launchd PATH).
|
||
|
||
### launchd сервис
|
||
|
||
`~/Library/LaunchAgents/ai.claude-proxy.plist`:
|
||
```xml
|
||
<?xml version="1.0" encoding="UTF-8"?>
|
||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
|
||
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||
<plist version="1.0">
|
||
<dict>
|
||
<key>Label</key><string>ai.claude-proxy</string>
|
||
<key>ProgramArguments</key>
|
||
<array>
|
||
<string>/Users/admin/.local/bin/claude-proxy-start.sh</string>
|
||
</array>
|
||
<key>EnvironmentVariables</key>
|
||
<dict>
|
||
<key>HOME</key><string>/Users/admin</string>
|
||
</dict>
|
||
<key>RunAtLoad</key><true/>
|
||
<key>KeepAlive</key><true/>
|
||
<key>StandardOutPath</key>
|
||
<string>/Users/admin/.hermes/logs/claude-proxy.log</string>
|
||
<key>StandardErrorPath</key>
|
||
<string>/Users/admin/.hermes/logs/claude-proxy.log</string>
|
||
</dict>
|
||
</plist>
|
||
```
|
||
|
||
```bash
|
||
launchctl load ~/Library/LaunchAgents/ai.claude-proxy.plist
|
||
```
|
||
|
||
**Pitfall при перезагрузке:** если старый процесс ещё держит порт 3456:
|
||
```bash
|
||
lsof -ti :3456 | xargs kill -9
|
||
launchctl unload ~/Library/LaunchAgents/ai.claude-proxy.plist
|
||
launchctl load ~/Library/LaunchAgents/ai.claude-proxy.plist
|
||
```
|
||
|
||
### Конфигурация Hermes
|
||
|
||
`~/.hermes/config.yaml` (секция model):
|
||
```yaml
|
||
model:
|
||
default: claude-sonnet-4-6
|
||
# provider: claude-code # отключён — упирается в rate limit OAuth API
|
||
provider: custom
|
||
base_url: 'http://localhost:3456/v1'
|
||
```
|
||
|
||
**Pitfall:** `provider: openai` не существует в Hermes — нужно `custom`.
|
||
|
||
**Pitfall:** `base_url` должен включать `/v1` (Hermes дописывает
|
||
`/chat/completions`). Без `/v1` → 404.
|
||
|
||
---
|
||
|
||
## Zulip Docker — подводные камни
|
||
|
||
### RabbitMQ: пользователи сбрасываются после перезапуска
|
||
|
||
**Симптом:** Zulip отдаёт 500 на `/api/v1/register`. В логах RabbitMQ —
|
||
паника Khepri (Raft WAL). Пользователи в RabbitMQ исчезают.
|
||
|
||
**Причина:** RabbitMQ 4.x использует Khepri вместо Mnesia. При переполнении
|
||
диска WAL не может записаться → Khepri сбрасывает состояние → пользователи
|
||
исчезают. `RABBITMQ_DEFAULT_USER/PASS` применяются только при **первом
|
||
старте** с пустым volume — повторный запуск их не восстанавливает.
|
||
|
||
**Решение:**
|
||
|
||
1. `docker system prune` — освободить место на диске (Docker VM sparse disk
|
||
не освобождает место автоматически).
|
||
2. Добавить `RABBITMQ_ERLANG_COOKIE` в env rabbitmq (стабилизирует cookie
|
||
через перезапуски).
|
||
3. При повреждённом volume — стереть и пересоздать:
|
||
```bash
|
||
docker compose down
|
||
docker volume rm zulip_zulip-rabbitmq
|
||
docker compose up -d
|
||
```
|
||
|
||
### Log rotation (обязательно!)
|
||
|
||
Без ротации логи заполняют Docker VM (~6 ГБ за несколько месяцев).
|
||
|
||
`docker-compose.yml` — добавить к каждому сервису:
|
||
```yaml
|
||
# zulip:
|
||
logging:
|
||
driver: json-file
|
||
options:
|
||
max-size: "50m"
|
||
max-file: "5"
|
||
|
||
# rabbitmq, memcached, redis:
|
||
logging:
|
||
driver: json-file
|
||
options:
|
||
max-size: "20m"
|
||
max-file: "3"
|
||
```
|
||
|
||
### Docker VM sparse disk
|
||
|
||
Mac Docker Desktop использует sparse virtual disk. Место, освобождённое
|
||
внутри VM, не возвращается хосту автоматически. `docker system prune`
|
||
запускает compaction.
|
||
|
||
---
|
||
|
||
## Obsidian MCP
|
||
|
||
Везде используется `mcpvault` (не `obsidian-mcp`).
|
||
|
||
| Конфиг | Путь |
|
||
|--------|------|
|
||
| Claude Code CLI | `~/.claude/.mcp.json` |
|
||
| Claude Desktop App | `~/Library/Application Support/Claude/claude_desktop_config.json` |
|
||
| Hermes | встроен через toolset |
|
||
|
||
Пример конфига (одинаковый для обоих):
|
||
```json
|
||
{
|
||
"mcpServers": {
|
||
"obsidian": {
|
||
"command": "mcpvault",
|
||
"args": ["/Users/admin/obsidian"]
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
**Pitfall:** `obsidian-mcp` (npm) был удалён — если остался в конфиге,
|
||
Claude падает с "Failed to spawn process". Проверить логи:
|
||
`~/Library/Logs/Claude/mcp-server-obsidian.log`.
|
||
|
||
---
|
||
|
||
## Схема маршрутизации сообщений
|
||
|
||
```
|
||
Zulip Events API
|
||
↓ poll (credentials Eagle)
|
||
[zulip-router] (Go, Docker)
|
||
│
|
||
├── (без @mention, тред закреплён) → trigger=owner → POST боту
|
||
├── (новый тред без @mention) → trigger=default → POST default-боту
|
||
│
|
||
└── @mention → ownership обновляется, НО НЕ форвардит
|
||
(бот получит через Zulip outgoing webhook напрямую)
|
||
|
||
Zulip Outgoing Webhook (при @mention)
|
||
│
|
||
├── @Орёл → POST host.docker.internal:8644/webhooks/eagle
|
||
├── @Кит → POST host.docker.internal:8645/webhooks/whale
|
||
├── @Валера → POST balda-agent:8091
|
||
└── @Клавдий → POST claudio-agent:8092
|
||
```
|
||
|
||
### Кто что получает
|
||
|
||
| Компонент | @mention (Zulip outgoing webhook) | Без @mention (через zulip-router) | Шлёт ответы через |
|
||
|-----------|----------------------------------|-----------------------------------|-------------------|
|
||
| **Eagle (Hermes gateway)** | POST `/webhooks/eagle` напрямую | POST `/webhooks/eagle` (trigger=owner/default) | Zulip API (бот `eagle-bot`) |
|
||
| **Кит (Hermes gateway)** | POST `/webhooks/whale` напрямую | POST `/webhooks/whale` (trigger=owner/default) | Zulip API (бот `whale-bot`) |
|
||
| **Валера (balda-agent)** | POST `:8091` напрямую | POST `:8091` (trigger=owner/default) | Zulip API (бот `valera-bot`) |
|
||
| **Клавдий (claudio-agent)** | POST `:8092` напрямую | POST `:8092` (trigger=owner/default) | Zulip API (бот `claudio-bot`) |
|
||
|
||
**Два пути доставки:**
|
||
1. **@mention** — Zulip сам шлёт outgoing webhook на webhook URL бота напрямую (минуя роутер). Роутер видит это сообщение в event queue, обновляет ownership, но НЕ форвардит (бот уже получил).
|
||
2. **Без @mention** — если тред закреплён за ботом (ownership), роутер форвардит. Если треда нет — default-боту.
|
||
|
||
**Важно:** Eagle и Кит НЕ регистрируют свои event queue в Zulip. Сообщения без @mention приходят через zulip-router. Роутер использует credentials Eagle для поллинга Zulip Events API, но Eagle сам в Zulip не поллит.
|
||
|
||
### Что не так (исторически, сейчас пофиксено)
|
||
|
||
Роутер после установки ownership шлёт ВСЕ сообщения из треда владельцу,
|
||
даже если @mention адресован другому боту.
|
||
|
||
**Должно быть:** если @mention другого бота — ownership обновляется,
|
||
НО НЕ форвардить. Бот получит сообщение через свой outgoing webhook
|
||
(от Zulip напрямую).
|
||
|
||
**Текущее состояние (2026-06-17):** ownership обновляется через @mention
|
||
от другого бота, но НЕ форвардит — `processEvent` проверяет
|
||
`!senderIsBot && !isOtherBotMention()` для mention-сообщений.
|
||
Плюс guard `skip_bot_messages: true` отсекает все бот-сообщения целиком.
|
||
|
||
---
|
||
|
||
## Eagle: интеграция с zulip-router (2026-06-17)
|
||
|
||
### Архитектура
|
||
|
||
У Eagle **два пути получения сообщений:**
|
||
|
||
1. **@mention (`@**Орёл**`)** → Zulip outgoing webhook → POST напрямую на `host.docker.internal:8644/webhooks/eagle`
|
||
(минуя zulip-router). Eagle принимает через `WebhookAdapter` в `webhook.py`.
|
||
|
||
2. **Без @mention, тред закреплён** → zulip-router → trigger=owner → POST `host.docker.internal:8644/webhooks/eagle`
|
||
|
||
Ответ уходит в Zulip напрямую через Zulip API (бот `eagle-bot`), **в тот же стрим и топик** откуда пришло сообщение — chat_id выводится в `_deliver_cross_platform` из `payload.display_recipient::subject`.
|
||
|
||
**Важно:** Eagle НЕ запускает polling Zulip Events API (`ZULIP_POLLING_DISABLED=true`).
|
||
|
||
### Конфиг Eagle (актуальный)
|
||
|
||
`~/.hermes/config.yaml` — секция `platforms`:
|
||
```yaml
|
||
platforms:
|
||
webhook:
|
||
enabled: true
|
||
extra:
|
||
host: "0.0.0.0"
|
||
port: 8644
|
||
routes:
|
||
eagle:
|
||
secret: INSECURE_NO_AUTH
|
||
prompt: '{{message.content}}'
|
||
deliver: zulip
|
||
```
|
||
|
||
- `host: "0.0.0.0"` — слушаем на всех интерфейсах, чтобы Docker (zulip-router через `host.docker.internal`) мог достучаться
|
||
- `secret: INSECURE_NO_AUTH` — Zulip 10.x outgoing webhook не шлёт HMAC-подпись в заголовках. Safety rail на non-loopback снят патчем в коде
|
||
- `deliver: zulip` — ответ перенаправляется в Zulip платформу
|
||
- chat_id выводится из payload: `message.display_recipient::message.subject` (код в `_deliver_cross_platform`)
|
||
|
||
### Изменения в коде Hermes
|
||
|
||
В `gateway/platforms/webhook.py` (закоммичено `d4e98a8b0`):
|
||
1. `_BUILTIN_DELIVER_PLATFORMS` — добавлен `"zulip"`
|
||
2. Safety rail INSECURE_NO_AUTH на non-loopback — убран
|
||
3. `_validate_signature` — поддержка Zulip token в JSON body (+ gzip-декодирование)
|
||
4. `_deliver_cross_platform` — при `deliver=zulip` и отсутствии `deliver_extra.chat_id` chat_id выводится из `payload.message.display_recipient::message.subject`
|
||
5. Debug-логи: HEADERS, ZULIP_RAW_BODY, cross-platform diagnostics
|
||
|
||
### Контекст сессий (webhook)
|
||
|
||
**Проблема:** Каждое сообщение через webhook получало уникальный `chat_id = webhook:eagle:{delivery_id}`, поэтому каждое @mention создавало новую сессию без истории.
|
||
|
||
**Решение (2026-06-17):** Два уровня фикса:
|
||
|
||
1. **Hermes webhook.py** (`gateway/platforms/webhook.py`) — `session_chat_id` теперь определяется приоритетно:
|
||
- `X-Chat-Id` заголовок (ставится zulip-router как `stream::topic`)
|
||
- Zulip outgoing webhook payload (`message.display_recipient::subject`)
|
||
- fallback — delivery_id (для non-Zulip webhook-ов)
|
||
2. **zulip-router** (`forwarder.go`) — при POST добавляет заголовок `X-Chat-Id: stream::topic`
|
||
|
||
Это покрывает оба пути доставки:
|
||
- **через роутер** (без @mention) — `X-Chat-Id` от роутера
|
||
- **напрямую от Zulip** (@mention) — Hermes сам определяет `stream::topic` из payload
|
||
|
||
Важно: у разных ботов (Eagle vs Кит) разный `route_name` в `session_chat_id`, поэтому их сессии не смешиваются даже при одинаковом `stream::topic`.
|
||
|
||
Git: `forwarder.go` — в репо роутера. `webhook.py` — патч в vendor Hermes (не коммитится).
|
||
|
||
### Управление
|
||
|
||
Только через Eagle Dashboard:
|
||
- `http://localhost:8880` (localhost)
|
||
- `POST /api/services/hermes/start|stop|restart`
|
||
|
||
---
|
||
|
||
## Связанные страницы
|
||
|
||
- [[claude-python-cli-proxy]] — Python Claude CLI proxy (claude-code-openai-wrapper, порт 8090)
|
||
- [[tech/hermes-docker-kraken]] — Hermes на Кракене (Docker)
|
||
- [[tech/kraken-network]] — сетевая топология
|
||
- [[concepts/hermes-deployment-patterns]] — сравнение трёх моделей деплоя Hermes
|