[2026-06-18] taiga-vault: family/how-to/hermes-eagle-mac.md family/how-to/kraken-access.md family/how-to/switch-emulation-rom-infra.md personal/projects/balda/balda-valera.md personal/projects/balda/claudio-klaudiy.md personal/projects/balda/fast-rlm-integration.md personal/projects/balda/setup.md personal/projects/hermes-configs/eagle-config.example.yaml personal/projects/hermes-configs/whale-config.example.yaml personal/projects/personal-os/eagle-dashboard.md
This commit is contained in:
@@ -1,19 +1,3 @@
|
||||
---
|
||||
title: Hermes на Eagle (Mac M4 Max) — Настройка и подводные камни
|
||||
type: reference
|
||||
namespace: work
|
||||
tags:
|
||||
- hermes
|
||||
- mac
|
||||
- eagle
|
||||
- claude-proxy
|
||||
- zulip
|
||||
- pitfalls
|
||||
created: '2026-05-21'
|
||||
updated: '2026-05-22'
|
||||
last_synced: '2026-05-22'
|
||||
confidence: 0.9
|
||||
---
|
||||
# Hermes на Eagle (Mac M4 Max) — Настройка и подводные камни
|
||||
|
||||
Hermes работает нативно (не в Docker) на Mac через `hermes gateway`.
|
||||
@@ -24,7 +8,7 @@ Hermes работает нативно (не в Docker) на Mac через `her
|
||||
|
||||
| Компонент | Расположение | Запуск |
|
||||
|-----------|-------------|--------|
|
||||
| Hermes config | `~/.hermes/config.yaml` | — |
|
||||
| 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 |
|
||||
@@ -225,6 +209,132 @@ Claude падает с "Failed to spawn process". Проверить логи:
|
||||
|
||||
---
|
||||
|
||||
## Схема маршрутизации сообщений
|
||||
|
||||
```
|
||||
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)
|
||||
|
||||
@@ -4,6 +4,13 @@
|
||||
|
||||
## SSH с любой машины
|
||||
|
||||
**Контейнер hermes-kraken:**
|
||||
- Маппинг `/home/kraken/.ssh:/opt/data/.ssh:ro` в docker-compose.yml
|
||||
- Кастомный `/etc/passwd` с пользователем `kraken:x:1000:1000` (файл `passwd-with-kraken` рядом с compose)
|
||||
- `HERMES_UID=1000 HERMES_GID=1000` чтоб entrypoint ремапил пользователя
|
||||
- SSH из контейнера: `ssh -i /opt/data/.ssh/id_ed25519 kraken@localhost`
|
||||
- Ключ `kraken` добавлен в `authorized_keys` на хосте
|
||||
|
||||
`~/.ssh/config` на Eagle:
|
||||
```
|
||||
Host kraken
|
||||
|
||||
@@ -204,7 +204,11 @@ Web UI: `http://192.168.1.15:9117`
|
||||
### API — как Eagle ищет игру
|
||||
```bash
|
||||
# Поиск через Jackett Torznab API
|
||||
<<<<<<< HEAD
|
||||
curl "http://192.168.1.15:9117/api/v2.0/indexers/rutracker/results/torznab/?apikey=JACKETT_KEY&t=search&q=Donkey+Kong+Tropical+Freeze+Switch&cat=8000"
|
||||
=======
|
||||
curl "http://192.168.1.15:9117/api/v2.0/indexers/rutracker/results/torznab/?apikey=***&t=search&q=Donkey+Kong+Tropical+Freeze+Switch&cat=8000"
|
||||
>>>>>>> nas/main
|
||||
# Возвращает XML/JSON с magnet-ссылками и метаданными
|
||||
```
|
||||
|
||||
|
||||
@@ -1,54 +1,88 @@
|
||||
# Балда / Валера — эксплуатация
|
||||
|
||||
## Расположение
|
||||
- Repo: `~/Developer/balda/`
|
||||
- Repo: `~/Developer/balda/` (ветка `main`)
|
||||
- Code: upstream `normahq/balda` + `feat/zulip-transport` влит, `normahq/norma` v0.0.10
|
||||
- **Рабочий compose**: `~/Docker/balda-agent/docker-compose.yaml` → контейнер `balda-agent-valera-1`
|
||||
- Config (persistent): `~/Docker/balda-agent/.config/balda/config.yaml`
|
||||
- Env: `~/Docker/balda-agent/.env`
|
||||
- Дублирующий compose `~/Developer/balda/compose.valera.yaml` — НЕ использовать (сеть `balda_default` без DNS)
|
||||
- Owner: `allowed_owners` в config.yaml (статически, `/start owner=` не нужен)
|
||||
|
||||
## Запуск / рестарт
|
||||
|
||||
```bash
|
||||
cd ~/Docker/balda-agent
|
||||
docker-compose restart
|
||||
docker-compose restart # без пересборки, только конфиг/env не менялись
|
||||
docker-compose up -d # пересоздать контейнер, перечитать .env
|
||||
|
||||
# Rebuild после изменений в коде:
|
||||
docker-compose build && docker-compose up -d
|
||||
cd ~/Docker/claudio-agent && docker-compose build
|
||||
docker tag claudio-agent-claudio:latest balda-agent-valera:latest
|
||||
cd ~/Docker/claudio-agent && docker-compose up -d
|
||||
cd ~/Docker/balda-agent && docker-compose up -d
|
||||
```
|
||||
|
||||
> Валера и Клавдий — **один образ** (`balda-agent-valera`), собирается из одного Dockerfile (`Dockerfile.claudio`). Разница только в конфиге (`config.yaml`) и `.env`, которые монтируются volumes. Сборка идёт из `~/Docker/claudio-agent/` — там есть build секция.
|
||||
|
||||
## Архитектура получения сообщений
|
||||
Валера — **outgoing webhook bot** (bot_type=3). Zulip отправляет webhook только для @mention и DM.
|
||||
|
||||
Для получения всех сообщений в теме — Events API polling (горутина в коде):
|
||||
- `events_polling.enabled: true` в config.yaml
|
||||
- Очередь регистрируется с `all_public_streams=true`
|
||||
- Бот подписывается на все публичные стримы при старте (`SubscribeToPublicStreams`)
|
||||
- `events_polling.enabled: false` в config.yaml
|
||||
- Сообщения приходят только через **outgoing webhook** (webhook_token в .env)
|
||||
- После @mention создаётся сессия; последующие сообщения без @mention обрабатываются
|
||||
|
||||
**После перезапуска**: нужно один раз написать `@Валера <текст>` чтобы создать сессию в теме.
|
||||
**После перезапуска**: написать `@Валера <текст>` чтобы создать сессию.
|
||||
|
||||
## MCP Obsidian — конфиг
|
||||
Balda через SSE подключается к obsidian-mcp. **URL обязательно с `/sse`**:
|
||||
|
||||
```yaml
|
||||
runtime:
|
||||
mcp_servers:
|
||||
obsidian:
|
||||
type: sse
|
||||
url: http://obsidian-mcp:3101/sse # <-- без /sse → 404 Not Found
|
||||
name: obsidian
|
||||
```
|
||||
|
||||
## Контекст (история сообщений)
|
||||
**Работает** (на upstream `normahq/balda` с поддержкой hosted LLM agent sessions). ADK inject'ит историю корректно для OpenAI-совместимых провайдеров.
|
||||
|
||||
Раньше не работало — исправлено в upstream `32e33a8 fix: support hosted MCP toolsets` / `d774c78 fix: use hosted llmagent sessions`.
|
||||
|
||||
## Диагностика молчания
|
||||
1. Проверить логи: `docker logs balda-agent-valera-1 --tail 50`
|
||||
2. Проверить что Events API polling запустился: `zulip events queue registered` в логах
|
||||
3. Стухший NATS-таск (симптом: логов нет после @mention)
|
||||
Фикс: `docker-compose restart` (NATS embedded, состояние в памяти)
|
||||
4. Бот не подписан на стримы (симптом: `processing zulip message trigger=stream` не появляется):
|
||||
```bash
|
||||
curl -u "<bot_email>:<api_key>" https://zulip.qentra.top/api/v1/users/me/subscriptions
|
||||
```
|
||||
Если пустой — должен подписаться при следующем старте. Или вручную через API.
|
||||
|
||||
## Патчи в коде (общие с Клавдием)
|
||||
### 1. MCP ошибка: `failed to list MCP tools: failed to connect: Not Found`
|
||||
**Причина**: в config.yaml url без `/sse` на конце.
|
||||
**Фикс**: `url: http://obsidian-mcp:3101/sse` → `docker-compose restart`.
|
||||
|
||||
В `zulip_handler.go` добавлены:
|
||||
1. `botName` — извлекается из bot_email (часть до @). Используется чтобы отличить свой @mention от чужого.
|
||||
2. `extractOtherMention()` — проверяет текст на @**Name**, где Name не равен botName и не @**all**/@**everyone**. Если найден чужой @mention — сообщение игнорируется.
|
||||
3. Детальное логирование в `handleAutoClaimMention` — видны все шаги от входа до ошибки.
|
||||
### 2. В логах `command running → command handled` за секунду, без `received provider event`
|
||||
**Причина**: стухший embedded NATS. Swarm внутри процесса не доставляет команду до session/task actor'ов.
|
||||
**Фикс**: `docker-compose restart` (если не помогло → `up -d`).
|
||||
|
||||
### 3. Удалён / пустой state.db
|
||||
После удаления balda не может зарегистрировать swarm акторы.
|
||||
**Фикс**: `rm state.db` и `up -d` (balda создаст заново).
|
||||
|
||||
### 4. Нет owner
|
||||
`handleMessage` выходит при `getOwnerID() == 0`.
|
||||
**Фикс**: прописать `allowed_owners` в config.yaml (не нужен `/start owner=`).
|
||||
|
||||
### 5. stream_only_with_session
|
||||
Сообщение в теме без сессии молча дропается.
|
||||
**Фикс**: написать `@Валера <текст>` чтобы создать сессию.
|
||||
|
||||
### 6. Проверить webhook со стороны
|
||||
```bash
|
||||
curl -X POST http://localhost:8091/zulip/webhook \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"type":"test"}'
|
||||
```
|
||||
|
||||
## Ключевые грабли
|
||||
|
||||
### 1. OPENAI_BASE_URL — обязательная env var
|
||||
Norma не читает `base_url` из config.yaml для `provider: deepseek`. Она читает env `OPENAI_BASE_URL`.
|
||||
### OPENAI_BASE_URL — обязательная env var
|
||||
Norma не читает `base_url` из config.yaml для `provider: deepseek`. Читает env `OPENAI_BASE_URL`.
|
||||
Без неё запросы уходят на `api.openai.com` (401).
|
||||
|
||||
**В `.env` обязательно:**
|
||||
@@ -56,37 +90,12 @@ Norma не читает `base_url` из config.yaml для `provider: deepseek`.
|
||||
OPENAI_BASE_URL=https://api.deepseek.com/v1
|
||||
```
|
||||
|
||||
**ВАЖНО:** `docker-compose restart` не перечитывает `.env`. Нужно `docker-compose up -d` (пересоздание контейнера).
|
||||
|
||||
### 2. DOCKER_OPTS — пустая строка убивает старт
|
||||
Если `DOCKER_OPTS=""` (или пустая строка в `.env`), norma падает на парсинге float:
|
||||
```
|
||||
strconv.ParseFloat: parsing ""
|
||||
```
|
||||
|
||||
**Должен быть валидный JSON:**
|
||||
### DOCKER_OPTS — пустая строка убивает старт
|
||||
Должен быть валидный JSON:
|
||||
```
|
||||
DOCKER_OPTS={"max_tokens": 2048, "temperature": 0.7}
|
||||
```
|
||||
|
||||
### 3. composerestart vs compose up -d
|
||||
- `docker-compose restart` — не перечитывает `.env`, не пересоздаёт контейнер
|
||||
- `docker-compose up -d` — пересоздаёт контейнер с обновлённым `.env`
|
||||
|
||||
При изменении `.env` всегда использовать `up -d`.
|
||||
|
||||
## Конфигурация провайдера
|
||||
В `config.yaml` обязательно:
|
||||
```yaml
|
||||
balda:
|
||||
provider: deepseek
|
||||
zulip:
|
||||
events_polling:
|
||||
enabled: true
|
||||
stream_only_with_session: true
|
||||
runtime:
|
||||
providers:
|
||||
deepseek:
|
||||
openai:
|
||||
base_url: https://api.deepseek.com/v1
|
||||
```
|
||||
### composerestart vs compose up -d
|
||||
- `restart` — не перечитывает `.env`
|
||||
- `up -d` — пересоздаёт контейнер с обновлённым `.env`
|
||||
|
||||
@@ -5,33 +5,50 @@
|
||||
| Item | Value |
|
||||
|------|-------|
|
||||
| Repo | `~/Developer/balda/` (один код с Валерой) |
|
||||
| Ветка | `main` |
|
||||
| Compose | `~/Docker/claudio-agent/docker-compose.yaml` → контейнер `claudio-agent-claudio-1` |
|
||||
| Config | `~/Docker/claudio-agent/.config/balda/config.yaml` |
|
||||
| Env | `~/Docker/claudio-agent/.env` |
|
||||
| Dockerfile | `~/Docker/claudio-agent/Dockerfile.claudio` |
|
||||
| Образ | `claudio-agent-claudio` (тагается как `balda-agent-valera` для Валеры) |
|
||||
|
||||
## Запуск / рестарт
|
||||
|
||||
```bash
|
||||
cd ~/Docker/claudio-agent
|
||||
docker-compose restart # без пересборки
|
||||
docker-compose build && docker-compose up -d # после изменений в коде
|
||||
docker-compose up -d # пересоздать контейнер
|
||||
|
||||
# Rebuild после изменений в коде:
|
||||
cd ~/Docker/claudio-agent && docker-compose build
|
||||
docker tag claudio-agent-claudio:latest balda-agent-valera:latest
|
||||
cd ~/Docker/claudio-agent && docker-compose up -d
|
||||
cd ~/Docker/balda-agent && docker-compose up -d
|
||||
```
|
||||
|
||||
> Валера и Клавдий — **один код и один образ** (`claudio-agent-claudio` → `balda-agent-valera`). Разница только в конфиге, `.env` и порте webhook (8091 vs 8092). Сборка всегда из `~/Docker/claudio-agent/`.
|
||||
|
||||
## Архитектура получения сообщений
|
||||
|
||||
Клавдий — **outgoing webhook bot** (bot_type=3). Zulip отправляет webhook только для @mention и DM.
|
||||
|
||||
Для получения всех сообщений в теме — **Events API polling**:
|
||||
- Подписан на все публичные стримы при старте
|
||||
- После @mention создаётся сессия; последующие сообщения без @mention обрабатываются
|
||||
|
||||
## Отличие от Валеры
|
||||
|
||||
- **Провайдер**: `claude` (claude-sonnet-4-6) через Claude Proxy (контейнер, порт 3457)
|
||||
- **Порт webhook**: 8092 (у Валеры 8091)
|
||||
- **База**: отдельная SQLite (NATS embedded)
|
||||
|
||||
## MCP Obsidian — конфиг
|
||||
Аналогично Валере — URL обязательно с `/sse`:
|
||||
```yaml
|
||||
runtime:
|
||||
mcp_servers:
|
||||
obsidian:
|
||||
type: sse
|
||||
url: http://obsidian-mcp:3101/sse
|
||||
name: obsidian
|
||||
```
|
||||
|
||||
## Проблемы и решения
|
||||
|
||||
### Валера отвечал вместо Клавдия на @mention
|
||||
@@ -48,4 +65,12 @@ docker-compose build && docker-compose up -d # после изменений в
|
||||
|
||||
### При первом запуске не отвечает на @mention
|
||||
|
||||
Нужен `/start owner=<token>` в DM. Токен генерируется при первом старте и пишется в лог: `docker logs claudio-agent-claudio-1 | grep -i owner`
|
||||
Если после запуска не отвечает на @mention — проверить в config.yaml `allowed_owners`:
|
||||
```yaml
|
||||
zulip:
|
||||
allowed_owners:
|
||||
- admin@zulip.local
|
||||
```
|
||||
Owner'ы прописаны в config.yaml статически, команда `/start owner=<token>` не нужна.
|
||||
|
||||
При необходимости — пересоздать контейнер: `docker-compose up -d`
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
# fast-rlm MCP HTTP integration with Balda
|
||||
|
||||
## Цель
|
||||
Дать Балде (`balda-agent-valera-1`) инструмент рекурсивного сжатия контекста (RLM) через MCP **HTTP** сервер в отдельном Docker контейнере.
|
||||
|
||||
## Контекст
|
||||
- Хост: Eagle (Mac M4), Docker Desktop arm64
|
||||
- Балда: контейнер `balda-agent-valera-1`, compose в `/Users/admin/Docker/balda-agent/`
|
||||
- Конфиг Балды: `/Users/admin/Docker/balda-agent/.config/balda/config.yaml`
|
||||
- Текущие MCP у Балды: только `obsidian` (SSE, `http://obsidian-mcp:3101`)
|
||||
- fast-rlm ([avbiswas/fast-rlm](https://github.com/avbiswas/fast-rlm)): Python библиотека, MCP **клиент** (не сервер). CLI команды `serve` НЕ существует.
|
||||
- Docker tooling на хосте: только `docker-compose` v1, v2 plugin отсутствует.
|
||||
|
||||
## Провайдер LLM
|
||||
fast-rlm использует OpenAI-совместимый клиент (через переменные `OPENAI_API_KEY` + `OPENAI_BASE_URL`). Для консистентности с Балдой и Whale используем **DeepSeek** как у самой Балды:
|
||||
|
||||
- `OPENAI_BASE_URL=https://api.deepseek.com/v1`
|
||||
- `OPENAI_API_KEY=<значение DEEPSEEK_API_KEY>` (берём из `/Users/admin/Docker/balda-agent/.env`)
|
||||
- Дефолтная модель в `server.py`: `deepseek-chat`
|
||||
- `RLMConfig(primary_agent="deepseek-chat", sub_agent="deepseek-chat", ...)`
|
||||
|
||||
## Подтверждённый API fast-rlm
|
||||
- Top-level: `dir(fast_rlm) = ['RLMConfig', 'run']`
|
||||
- Импорт: `from fast_rlm import RLMConfig` (НЕ `from fast_rlm.config import RLMConfig` — этот путь не существует)
|
||||
- Сигнатура: `fast_rlm.run(query, prefix, config, verbose, output_schema, tools, env_variables, mcp_servers, llm_kwargs)` → возвращает dict с ключом `results`
|
||||
- Дефолты `RLMConfig`: `primary_agent='z-ai/glm-5'`, `sub_agent='minimax/minimax-m2.5'`, `max_depth=3`, `max_money_spent=0.2`, `truncate_len=2000`. Мы их переопределяем на `deepseek-chat`.
|
||||
- fast-rlm требует **Deno** (для Pyodide REPL) — ставится в Dockerfile.
|
||||
|
||||
## Архитектура
|
||||
fast-rlm — это библиотека (`import fast_rlm; fast_rlm.run(...)`). Чтобы Балда могла её использовать через MCP, нужна **обёртка** — отдельный сервис, который экспонирует `fast_rlm.run()` как MCP tool через HTTP-транспорт.
|
||||
|
||||
```
|
||||
┌──────────────────┐ MCP HTTP ┌──────────────────────┐
|
||||
│ balda-agent │ ──────────────────► │ fast-rlm-mcp │
|
||||
│ (Hermes) │ POST /mcp │ FastMCP + mcp[cli] │
|
||||
└──────────────────┘ │ ↓ in-process │
|
||||
│ fast_rlm.run(...) │
|
||||
└──────────────────────┘
|
||||
```
|
||||
|
||||
Обёртка экспонирует один MCP tool: `recursive_context_compress(text, model, budget)`.
|
||||
|
||||
## Файлы (создать)
|
||||
- `/Users/admin/Docker/fast-rlm-mcp/Dockerfile` — `python:3.12-slim` + Deno + fast-rlm + mcp[cli] + uvicorn
|
||||
- `/Users/admin/Docker/fast-rlm-mcp/server.py` — MCP HTTP сервер (FastMCP, transport `streamable-http`, port 3333)
|
||||
- `/Users/admin/Docker/fast-rlm-mcp/docker-compose.yaml` — сервис `fast-rlm-mcp`, порт 3333, в сети `balda_default` external
|
||||
- `/Users/admin/Docker/fast-rlm-mcp/requirements.txt` — `fast-rlm`, `mcp[cli]>=1.2`, `uvicorn[standard]`
|
||||
- `/Users/admin/Docker/fast-rlm-mcp/.env` — `OPENAI_API_KEY=<DEEPSEEK_API_KEY value>`, `OPENAI_BASE_URL=https://api.deepseek.com/v1`, `FAST_RLM_DEFAULT_MODEL=deepseek-chat`. Chmod 600.
|
||||
|
||||
## Изменения существующих файлов
|
||||
`/Users/admin/Docker/balda-agent/.config/balda/config.yaml` — добавить:
|
||||
```yaml
|
||||
mcp_servers:
|
||||
obsidian:
|
||||
type: sse
|
||||
url: http://obsidian-mcp:3101
|
||||
name: obsidian
|
||||
fast-rlm:
|
||||
type: http
|
||||
url: http://fast-rlm-mcp:3333/mcp
|
||||
name: fast-rlm
|
||||
```
|
||||
|
||||
## Шаги выполнения (каждый шаг = реальная проверка)
|
||||
|
||||
### 1. Подтвердить API fast-rlm
|
||||
- **Действие:** `docker run --rm python:3.12-slim sh -c "pip install fast-rlm -q && python -c 'import fast_rlm; help(fast_rlm.run)'"`
|
||||
- **Проверка:** есть функция `fast_rlm.run(...)` с задокументированной сигнатурой. Импорт `from fast_rlm import RLMConfig` работает.
|
||||
- **Статус:** ✅ Сделано.
|
||||
|
||||
### 2. Создать структуру обёртки
|
||||
- **Действие:** `mkdir -p /Users/admin/Docker/fast-rlm-mcp/`, написать 5 файлов (Dockerfile, server.py, docker-compose.yaml, requirements.txt, .env).
|
||||
- **Проверка:** `ls -la /Users/admin/Docker/fast-rlm-mcp/` показывает все 5 файлов с ненулевым размером.
|
||||
|
||||
### 3. Собрать образ
|
||||
- **Действие:** `cd /Users/admin/Docker/fast-rlm-mcp && docker-compose build` (v1, не v2!)
|
||||
- **Проверка:** `docker images | grep fast-rlm-mcp` — образ присутствует. Сборка завершилась без ошибок.
|
||||
|
||||
### 4. Запустить контейнер
|
||||
- **Действие:** `docker-compose up -d`
|
||||
- **Проверка:** `docker ps --format '{{.Names}}\t{{.Status}}' | grep fast-rlm-mcp` — статус `Up`. `docker logs --tail 50 fast-rlm-mcp` — без `Traceback` / `Error`.
|
||||
|
||||
### 5. Smoke-тест HTTP MCP endpoint
|
||||
- **Действие:** `curl -sS -X POST http://localhost:3333/mcp -H 'Content-Type: application/json' -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'`
|
||||
- **Проверка:** JSON-ответ HTTP 200, в массиве `tools` есть `recursive_context_compress`.
|
||||
|
||||
### 6. Подключить к сети Балды
|
||||
- **Действие:** убедиться что контейнер в одной docker-сети с `balda-agent-valera-1` (сеть `balda_default`, external).
|
||||
- **Проверка:** `docker network inspect balda_default` показывает оба контейнера. Из Балды: `docker exec balda-agent-valera-1 wget -qO- http://fast-rlm-mcp:3333/mcp` отвечает.
|
||||
|
||||
### 7. Обновить config.yaml Балды
|
||||
- **Действие:** добавить секцию `fast-rlm` в `mcp_servers`.
|
||||
- **Проверка:** `grep -A3 fast-rlm config.yaml` показывает запись. YAML парсится.
|
||||
|
||||
### 8. Перезапустить Балду
|
||||
- **Действие:** `cd /Users/admin/Docker/balda-agent && docker-compose restart`
|
||||
- **Проверка:** `docker logs --since 60s balda-agent-valera-1` → "MCP server connected: fast-rlm". Без `Failed`. Балда онлайн в Zulip.
|
||||
|
||||
### 9. End-to-end smoke test
|
||||
- **Действие:** в Zulip Балде дать задачу с явным использованием fast-rlm.
|
||||
- **Проверка:** в логах Балды виден вызов tool `recursive_context_compress`. Возвращается результат. Без ошибок.
|
||||
|
||||
## Откат
|
||||
`cd /Users/admin/Docker/fast-rlm-mcp && docker-compose down` + убрать entry из config.yaml + `restart` Балды → состояние до начала.
|
||||
|
||||
## Признанные галлюцинации в этой сессии
|
||||
1. msg 58792-58795: фабрикация "Container Started / Ready" — реально ничего не создавалось.
|
||||
2. Утверждение что `fast-rlm serve --port 3333` существует — такой команды НЕТ. fast-rlm — только клиент.
|
||||
3. Старая запись в памяти про "Балда на Кракене" — устаревшая, противоречит документации. Балда на Eagle.
|
||||
4. Попытка писать доку через native `Write`/`Edit` в `/Users/admin/obsidian/...` из Linux-контейнера — файл попадает в контейнерную ФС, не в реальный vault. **Правильный путь — `mcp_obsidian_write_note` / `mcp_obsidian_patch_note`**.
|
||||
5. Утверждение `from fast_rlm.config import RLMConfig` — такого пути в библиотеке нет, импорт top-level: `from fast_rlm import RLMConfig`.
|
||||
6. `docker compose` (v2) на хосте не установлен — нужен `docker-compose` (v1).
|
||||
7. Идея использовать OpenRouter (`z-ai/glm-5`) как дефолт — отвергнута: используем DeepSeek как у Балды/Whale.
|
||||
8. Утверждение про "актуализированную доку" 16 июня — на хост-FS дока НЕ менялась (правки шли в контейнерную FS). Реальная актуализация через `mcp_obsidian_write_note` — 2026-06-16.
|
||||
|
||||
## Что НЕ делаем без явного подтверждения
|
||||
- Не пушить изменения в git.
|
||||
- Не правим config.yaml до согласования итогового формата секции `fast-rlm`.
|
||||
- Не рестартим Балду без согласования.
|
||||
|
||||
## История
|
||||
- **2026-06-16 ~14:00**: создан после серии фабрикаций и компакций контекста. План построен с явными точками реальной проверки на каждом шаге.
|
||||
- **2026-06-16 ~16:00**: пользователь поправил — провайдер LLM = DeepSeek (как у Балды/Whale), не OpenAI/OpenRouter.
|
||||
- **2026-06-16 ~17:00**: актуализация через `mcp_obsidian_write_note` — добавлены секции "Провайдер LLM" и "Подтверждённый API", compose v1 вместо v2, сеть `balda_default` вместо `balda-agent_default`, файл `.env` в список, Deno в Dockerfile, галлюцинации 5–8.
|
||||
@@ -1,188 +1,84 @@
|
||||
# Balda Setup & Status
|
||||
# Balda — Setup & Config
|
||||
|
||||
> Last updated: 2026-06-11
|
||||
_Последнее обновление: 2026-06-16_
|
||||
|
||||
## Runtime
|
||||
## Хост
|
||||
|
||||
| Item | Value |
|
||||
|------|-------|
|
||||
| Binary | `~/Developer/balda/balda` (built from source) |
|
||||
| Process | Managed by launchd (`com.normahq.balda`) |
|
||||
| Log | `/private/tmp/balda.log` |
|
||||
| Config | `~/Developer/balda/.config/balda/config.yaml` |
|
||||
| State dir | `~/Developer/balda/.config/balda/` |
|
||||
| Sessions | SQLite persistence |
|
||||
| Provider | `deepseek` (openai type, model: `deepseek-chat`, via `https://api.deepseek.com/v1`) |
|
||||
Mac (Eagle), Docker Desktop (arm64 native). Контейнеры собираются из исходников в `~/Developer/balda/`.
|
||||
|
||||
Balda is managed by launchd. Full restart (reloads plist env vars):
|
||||
## Компоненты
|
||||
|
||||
| Бот | Порт | Провайдер | Dockerfile |
|
||||
|-----|------|-----------|------------|
|
||||
| **Валера** (`balda-agent-valera-1`) | `:8091` | DeepSeek | `Dockerfile` (одинаковый) |
|
||||
| **Клавдий** (`claudio-agent-claudio-1`) | `:8092` | Claude (через proxy :3457) | `Dockerfile.claudio` (тот же + arm64 target) |
|
||||
|
||||
## Расположение
|
||||
|
||||
| Item | Валера | Клавдий |
|
||||
|------|--------|---------|
|
||||
| Compose | `~/Docker/balda-agent/docker-compose.yaml` | `~/Docker/claudio-agent/docker-compose.yaml` |
|
||||
| Config volume | `~/Docker/balda-agent/.config/balda/` | `~/Docker/claudio-agent/.config/balda/` |
|
||||
| Env | `~/Docker/balda-agent/.env` | `~/Docker/claudio-agent/.env` |
|
||||
| Dev repo (общий) | `~/Developer/balda/` | `~/Developer/balda/` |
|
||||
|
||||
**Важно:** оба бота собираются из одного репозитория `~/Developer/balda/` (normahq/balda fork). Разница — только в config.yaml (провайдер) и .env (Zulip credentials).
|
||||
|
||||
## Образ
|
||||
|
||||
- Собирается локально из `Dockerfile` (Go, multi-stage: golang:1.26 → alpine:3)
|
||||
- Target: `linux/arm64` (статический Go бинарник)
|
||||
- Image name: `balda-agent-valera:latest` / `claudio-agent-claudio:latest` (по имени compose проекта)
|
||||
- Entrypoint: `/usr/local/bin/balda` (ELF arm64)
|
||||
- Сеть: `balda_default` (bridge, внешняя, создаётся через docker network create)
|
||||
|
||||
## Запуск / рестарт
|
||||
|
||||
**Валера:**
|
||||
```bash
|
||||
launchctl bootout gui/$(id -u) ~/Library/LaunchAgents/com.normahq.balda.plist
|
||||
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.normahq.balda.plist
|
||||
cd ~/Docker/balda-agent
|
||||
docker-compose restart # без пересборки, без перечитывания .env
|
||||
docker-compose up -d # пересоздаёт контейнер с текущим .env
|
||||
docker-compose build && docker-compose up -d # после изменений в коде
|
||||
```
|
||||
|
||||
Quick restart (does NOT reload plist env vars — use bootout/bootstrap for env changes):
|
||||
|
||||
**Клавдий:**
|
||||
```bash
|
||||
launchctl kickstart -k gui/$(id -u)/com.normahq.balda
|
||||
cd ~/Docker/claudio-agent
|
||||
# те же команды
|
||||
```
|
||||
|
||||
## Zulip Webhook
|
||||
**После изменений кода** (в `~/Developer/balda/`): `build && up -d` в каждом compose.
|
||||
|
||||
| Item | Value |
|
||||
|------|-------|
|
||||
| Status | ENABLED |
|
||||
| Listen address | `0.0.0.0:8091` |
|
||||
| Path | `/zulip/webhook` |
|
||||
| Webhook bot URL | `http://host.docker.internal:8091/zulip/webhook` |
|
||||
## .env (общие переменные)
|
||||
|
||||
Port was **changed from 8090 → 8091** on 2026-06-07 (see Port Conflict below).
|
||||
|
||||
## Architecture
|
||||
|
||||
Balda uses the **outgoing webhook** mechanism — Zulip pushes events to Balda's
|
||||
HTTP endpoint. This differs from Eagle/Kit which use polling.
|
||||
|
||||
- Zulip runs in Docker (container: `zulip-zulip-1`), exposed on `127.0.0.1:8000`
|
||||
- Balda runs natively on Mac (not in Docker)
|
||||
- Colima maps `host.docker.internal:PORT → 127.0.0.1:PORT` on the Mac host,
|
||||
so the bot URL uses `host.docker.internal` to reach Balda from inside Docker
|
||||
|
||||
## Soul / Prompt
|
||||
|
||||
The `global_instruction` field in `config.yaml` defines Balda's persona (Валера).
|
||||
The canonical source is `soul.md` in this folder — sync manually to config after edits,
|
||||
then restart Balda.
|
||||
|
||||
Provider-level `system_instructions` are documented in `workspace-context.md`.
|
||||
|
||||
---
|
||||
|
||||
## Bug Fixed 2026-06-07 — Context Canceled on First DB Operation
|
||||
|
||||
**File:** `zulip_handler.go`, line 236
|
||||
|
||||
**Root cause:** The message-processing goroutine was launched with the HTTP
|
||||
request context:
|
||||
|
||||
```go
|
||||
// Before (broken)
|
||||
go h.processMessage(r.Context(), payload)
|
||||
```env
|
||||
OPENAI_BASE_URL=https://api.deepseek.com/v1 # обязательна для deepseek provider
|
||||
# для claude provider не нужна
|
||||
```
|
||||
|
||||
When the handler returns HTTP 200, Go cancels `r.Context()`. The goroutine then
|
||||
hits its first database operation and fails with `context canceled`.
|
||||
**Важно:** `docker-compose restart` не перечитывает `.env`. Только `docker-compose up -d`.
|
||||
|
||||
**Fix:**
|
||||
## Известные грабли
|
||||
|
||||
```go
|
||||
// After (fixed)
|
||||
go h.processMessage(context.WithoutCancel(r.Context()), payload)
|
||||
### 1. OPENAI_BASE_URL — обязательна для DeepSeek
|
||||
Norma не читает `base_url` из config.yaml для `provider: deepseek`. Читает env `OPENAI_BASE_URL`.
|
||||
Без неё запросы уходят на `api.openai.com` (401).
|
||||
|
||||
### 2. DOCKER_OPTS — пустая строка убивает старт
|
||||
```env
|
||||
DOCKER_OPTS={"max_tokens": 2048, "temperature": 0.7}
|
||||
```
|
||||
Не пустая строка! Иначе `strconv.ParseFloat: parsing ""` при старте.
|
||||
|
||||
`context.WithoutCancel` creates a copy of the parent context that is never
|
||||
canceled when the parent is — so the goroutine lives past the HTTP handler return.
|
||||
### 3. exec format error на arm64
|
||||
Симптом: контейнер в crash-цикле, логи повторяют `exec /usr/local/bin/balda: exec format error`.
|
||||
Docker показывает `Up N hours` (накопленное время рестартов), но exec не работает.
|
||||
|
||||
---
|
||||
**Причина:** контейнер создан с `Platform: linux/amd64` (старый Docker Desktop или первый run без platform). Образ arm64, контейнер amd64 — несовместимость.
|
||||
|
||||
## Port Conflict 2026-06-07 — Moved 8090 → 8091
|
||||
**Фикс:** `docker-compose down; docker-compose up -d` — удаляет старый контейнер и создаёт новый с правильной платформой.
|
||||
|
||||
**Root cause:** Colima (Docker runtime) maps `host.docker.internal:PORT →
|
||||
127.0.0.1:PORT` on the Mac host. `openclaw/claude-proxy` was occupying
|
||||
`127.0.0.1:8090`, intercepting Balda's webhook traffic before it reached Balda.
|
||||
|
||||
**Solution:** Changed Balda's listen port from `8090` to `8091` in `config.yaml`.
|
||||
|
||||
### Resolution
|
||||
|
||||
Webhook bot URL updated in Zulip DB via `docker exec psql` on 2026-06-07:
|
||||
`http://host.docker.internal:8090/zulip/webhook` → `http://host.docker.internal:8091/zulip/webhook`
|
||||
|
||||
---
|
||||
|
||||
## Smokescreen SSRF Proxy — Allow Private Ranges (Fixed 2026-06-11)
|
||||
|
||||
**Symptom:** Zulip outgoing webhook to Balda fails with HTTP 407.
|
||||
Zulip logs: `client: OutgoingWebhookResponse` + "Failure! Third party responded with 407".
|
||||
|
||||
**Root cause:** Zulip runs Smokescreen (SSRF proxy) for all outgoing HTTP.
|
||||
`host.docker.internal` resolves to a private IP (`192.168.5.x`), which Smokescreen
|
||||
blocks by default. A `docker restart` does NOT recreate containers, so env vars
|
||||
added to docker-compose aren't picked up — must use `docker compose up -d`.
|
||||
|
||||
**Fix:** Added to `~/Developer/zulip/docker-compose.yml` under the `zulip` service env:
|
||||
|
||||
```yaml
|
||||
PROXY_ALLOW_RANGES: "10.0.0.0/8,172.16.0.0/12,192.168.0.0/16"
|
||||
```
|
||||
|
||||
Then recreated containers:
|
||||
|
||||
```bash
|
||||
cd ~/Developer/zulip
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Smokescreen now starts with `--allow-range` flags for all RFC1918 ranges.
|
||||
Verify with: `docker exec zulip-zulip-1 ps aux | grep smokescreen`
|
||||
|
||||
**Note:** `SETTING_ALLOW_BOTS_TO_MAKE_REQUESTS_TO_PRIVATE_ADDRESSES: "True"` was already
|
||||
set but doesn't affect Smokescreen's `--allow-range` flags — it controls a different
|
||||
check. `PROXY_ALLOW_RANGES` is the correct env var.
|
||||
|
||||
---
|
||||
|
||||
## DeepSeek Provider via norma-local Fork
|
||||
|
||||
Balda's `openai` provider type in norma hardcoded `api.openai.com`. To support
|
||||
DeepSeek (or any OpenAI-compatible API), the norma source was forked locally:
|
||||
|
||||
- Fork path: `~/Developer/norma-local`
|
||||
- Patched files:
|
||||
- `pkg/runtime/hostedagent/openai.go` — added `openAIBaseURL()` reading `OPENAI_BASE_URL` env var
|
||||
- `pkg/runtime/agentfactory/agentfactory.go` — removed MCP restriction for `openai` type
|
||||
- Env var `OPENAI_BASE_URL=https://api.deepseek.com/v1` is set in the launchd plist
|
||||
- go.mod `replace` directive (`normahq/norma v0.0.6 → ../norma-local`) is local only
|
||||
(not committed to the PR branch — needs a separate norma upstream PR or fork)
|
||||
|
||||
To rebuild after patching norma:
|
||||
|
||||
```bash
|
||||
cd ~/Developer/balda
|
||||
go build -o balda ./cmd/balda/
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## CLAUDECODE Environment Bug (Fixed 2026-06-07)
|
||||
|
||||
When launched from an Eagle terminal session, Balda inherited `CLAUDECODE=1`,
|
||||
which caused the Claude Code CLI to refuse to start (it detected a nested invocation).
|
||||
**Fix:** Use `launchctl bootout + bootstrap` to start Balda clean — launchd does
|
||||
not inherit parent shell env vars.
|
||||
|
||||
---
|
||||
|
||||
## Config Snippet (key sections)
|
||||
|
||||
```yaml
|
||||
balda:
|
||||
global_instruction: |
|
||||
<contents of soul.md>
|
||||
|
||||
runtime:
|
||||
providers:
|
||||
deepseek:
|
||||
type: openai
|
||||
openai:
|
||||
api_key: "<DeepSeek API key>"
|
||||
model: deepseek-chat
|
||||
|
||||
zulip:
|
||||
webhook:
|
||||
enabled: true
|
||||
listen: "0.0.0.0:8091"
|
||||
path: /zulip/webhook
|
||||
allowed_owners:
|
||||
- amartemyanov@duckduckgo.com
|
||||
```
|
||||
### 4. После перезапуска нужно @mention для создания сессии
|
||||
Events API polling подхватывает сообщения только если есть активная сессия в теме.
|
||||
После рестарта: написать `@Валера <текст>` (или `@Клавдий`).
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
# Hermes Eagle — example config (tokens redacted)
|
||||
# Source: ~/.hermes/config.yaml
|
||||
# Copy back: cp this ~/.hermes/config.yaml (then restore api_key from password manager)
|
||||
|
||||
model:
|
||||
default: claude-sonnet-4-6
|
||||
provider: custom
|
||||
base_url: 'http://localhost:3457/v1'
|
||||
|
||||
agent:
|
||||
max_turns: 90
|
||||
reasoning_effort: medium
|
||||
gateway_timeout: 0
|
||||
|
||||
terminal:
|
||||
backend: local
|
||||
timeout: 180
|
||||
|
||||
plugins:
|
||||
enabled:
|
||||
- zulip-topic-routing
|
||||
- whale-thread-guard
|
||||
|
||||
mcp_servers:
|
||||
obsidian:
|
||||
command: mcpvault
|
||||
args:
|
||||
- /Users/admin/obsidian
|
||||
ddg-vm:
|
||||
command: npx
|
||||
args:
|
||||
- tsx
|
||||
- /Users/admin/Developer/virfield/server/mcp-server.ts
|
||||
|
||||
# zulip / api keys — restored from password manager
|
||||
@@ -0,0 +1,29 @@
|
||||
# Hermes Whale — example config (tokens redacted)
|
||||
# Source: ~/.hermes/hermes-whale/config.yaml
|
||||
# Copy back: cp this ~/.hermes/hermes-whale/config.yaml
|
||||
|
||||
model:
|
||||
default: deepseek-chat
|
||||
provider: deepseek
|
||||
|
||||
agent:
|
||||
max_turns: 90
|
||||
reasoning_effort: medium
|
||||
gateway_timeout: 1800
|
||||
|
||||
terminal:
|
||||
backend: local
|
||||
timeout: 180
|
||||
|
||||
plugins:
|
||||
enabled:
|
||||
- zulip-topic-routing
|
||||
- eagle-thread-guard
|
||||
|
||||
mcp_servers:
|
||||
obsidian:
|
||||
command: mcpvault
|
||||
args:
|
||||
- /Users/admin/obsidian
|
||||
|
||||
# webhook secret, zulip credentials — restored from password manager
|
||||
@@ -8,6 +8,20 @@ Process supervisor and control panel for all Eagle local services.
|
||||
|
||||
---
|
||||
|
||||
## Management Policy
|
||||
|
||||
**Hermes gateway (Eagle и Whale) стартуются, стопаются и перезапускаются ТОЛЬКО через Eagle Dashboard или его API (`POST /api/services/<id>/start|stop|restart`).**
|
||||
|
||||
Нельзя:
|
||||
- `kill` процесс Hermes вручную
|
||||
- `launchctl` stop/start Hermes
|
||||
- supervisorctl
|
||||
- любой другой прямой способ
|
||||
|
||||
Dashboard supervisor управляет процессами через PID-файлы, отслеживает состояние, не рестартует после ручного стопа (`stop` выставляет флаг, autorestart игнорируется до явного `start`). Health check верится по supervisor PID (точное совпадение), а не по подстроке в cmdline.
|
||||
|
||||
Для дашборда самого (`com.eagle.dashboard`, type: self) — кнопки управления скрыты, так как supervisor не может restart/stop/start свой процесс.
|
||||
|
||||
## Architecture
|
||||
|
||||
FastAPI + HTMX + Tailwind CDN + Alpine.js. No build step, CDN-only frontend.
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
---
|
||||
title: Hermes Cron Jobs
|
||||
aliases: [hermes cron, scheduled jobs, cron jobs]
|
||||
tags: [personal-os, hermes, automation, cron]
|
||||
updated: 2026-06-16
|
||||
---
|
||||
|
||||
# Hermes Cron Jobs
|
||||
|
||||
All scheduled jobs running in Hermes on Eagle.
|
||||
|
||||
## Active Jobs
|
||||
|
||||
| Job | Schedule | Description | Delivery |
|
||||
|-----|----------|-------------|----------|
|
||||
| `data-pipeline` | `*/30 7-21 * * 1-5` | Runs `bash ~/scripts/run-pipeline.sh` | local |
|
||||
| `eod-summary` | `0 18 * * 1-5` | EOD brief with completed/carries-over/blockers | zulip: daily-brief::EOD Summary |
|
||||
| `inbox-check` | `*/30 9-19 * * 1-5` | Inbox triage via Claude CLI | discord: #inbox |
|
||||
| `weekly-plan` | `0 8 * * 1` | Weekly plan via `weekly-plan.md` prompt | zulip: daily-brief::Weekly Plan |
|
||||
| `weekly-review` | `0 17 * * 5` | Weekly review via `weekly-review.md` prompt | zulip: daily-brief::Weekly Review |
|
||||
| `generate-daily-brief` | `0 7 * * 1-5` | Daily brief via `daily-brief.md` prompt | zulip: daily-brief::Daily Brief |
|
||||
| `sync-vault` | `*/5 * * * *` | Syncs obsidian vault via `sync-vault.sh` | local |
|
||||
| `watchlist-sync` | `0 * * * *` | Watchlist sync (TMDB resolver) | local |
|
||||
|
||||
## Paused Jobs
|
||||
|
||||
| Job | Schedule | Notes |
|
||||
|-----|----------|-------|
|
||||
| `retrospector` | `30 17 * * 5` | Paused — experiment, not stabilised |
|
||||
| `executor-autonomous` | `*/30 * * * *` | Paused — replaced by executor-runner |
|
||||
| `executor-runner` | `*/5 * * * *` | Paused — waiting for new executor arch |
|
||||
| `executor-analyzer` | `*/5 * * * *` | Paused — waiting for new executor arch |
|
||||
| `wiki-curation-daily` | `0 2 * * *` | Paused since 2026-05-29 |
|
||||
| `AI-психолог` | `0 23 * * 1,4` | Paused since 2026-05-29 — experiment |
|
||||
| `vault-enrichment` | `0 3 * * 0` | Paused since 2026-05-29 |
|
||||
| `cross-enrichment` | `0 4 * * 0` | Paused since 2026-05-29 |
|
||||
| `memory-curation` | `0 4 * * 0` | Paused since 2026-05-29 |
|
||||
| `proactive-research` | `0 5 * * 6` | Paused since 2026-05-29 |
|
||||
|
||||
## Known Issues
|
||||
|
||||
- `weekly-review` — last run (2026-06-12) failed with `HTTP 500: init handshake timed out after 30000ms`
|
||||
- `generate-daily-brief` — delivery to Zulip hit 502 on 2026-06-16 morning (Zulip was down); run itself succeeded
|
||||
|
||||
## Delivery Targets
|
||||
|
||||
- `local` — saved to `~/.hermes/cron/output/`, not delivered to any chat
|
||||
- `discord: #inbox` — Discord inbox channel
|
||||
- `zulip: daily-brief::*` — Zulip stream `daily-brief`, various topics
|
||||
- `origin` — back to the Zulip thread where the job was created
|
||||
|
||||
## Notes
|
||||
|
||||
- Data pipeline and inbox-check run on overlapping intervals during workdays
|
||||
- `sync-vault` runs every 5 min always (not restricted to workdays)
|
||||
- Jobs use `claude` CLI (`/Users/admin/.local/bin/claude`) for LLM calls
|
||||
- `watchlist-sync` added 2026-06-09, uses skill `watchlist-sync-resolver`
|
||||
@@ -68,6 +68,15 @@ sources:
|
||||
- Zulip transport: организация `zulip.mallexxx.duckdns.org`
|
||||
- Obsidian vault синхронизируется через git
|
||||
|
||||
## Management
|
||||
|
||||
**Hermes gateway (Eagle и Whale) стартуются, стопаются и перезапускаются ТОЛЬКО через Eagle Dashboard или его API.**
|
||||
- Dashboard: `http://localhost:8880`
|
||||
- API: `POST /api/services/hermes/start|stop|restart`
|
||||
- API: `POST /api/services/hermes_whale/start|stop|restart`
|
||||
|
||||
Запрещено: `kill`, `launchctl`, supervisorctl, любые прямые манипуляции процессом. Dashboard supervisor отслеживает PID-файлы и не рестартует после ручного стопа (флаг `_stop_flags`).
|
||||
|
||||
## Связанные страницы
|
||||
|
||||
- [[tech/hermes-eagle-mac]] — детали настройки на Eagle
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
---
|
||||
created: '2026-06-17'
|
||||
status: draft
|
||||
tags:
|
||||
- zulip-router
|
||||
- approval
|
||||
- reactions
|
||||
- hermes
|
||||
- architecture
|
||||
title: 'Zulip Router: Approval Reactions'
|
||||
type: plan
|
||||
updated: '2026-06-17'
|
||||
---
|
||||
# Zulip Router: Approval Reactions
|
||||
|
||||
## Проблема
|
||||
|
||||
Eagle (и Кит) не могут использовать reaction-based approval для dangerous commands.
|
||||
|
||||
### Root cause (3 слоя)
|
||||
|
||||
**Слой 1 — отсутствие `send_exec_approval` в WebhookAdapter:**
|
||||
|
||||
`WebhookAdapter` не имеет метода `send_exec_approval`. Когда приходит dangerous command, gateway видит что у адаптера нет этого метода (проверка `getattr(type(adapter), "send_exec_approval", None)` на строке 18000 run.py) и падает на текстовый fallback с "Reply `/approve`". Реакции не проставляются.
|
||||
|
||||
**Слой 2 — `/approve` через роутер обрывает approval вместо того чтобы подтвердить:**
|
||||
|
||||
Когда пользователь шлёт `/approve` (без @mention), оно приходит через роутер как обычный POST на webhook. В `GatewayMessageHandler._route_to_active_session()` (run.py, строка ~3480):
|
||||
|
||||
1. **Строка 3494:** `running_agent.interrupt(event.text)` — прерывает агента (interrupt = вставляет текст как новое сообщение пользователя)
|
||||
2. **Строка 3504:** `interrupt_gateway_approvals(session_key)` — прерывает ожидающий approval (choice = "interrupted")
|
||||
|
||||
Только **после** interrupt (строки 7819-7826 в `_message_handler`) проверяется "а не `/approve` ли это?" и вызывается `_handle_approve_command`. Но approval уже прерван — `has_blocking_approval(session_key)` возвращает False, и `/approve` отвечает "no pending approvals".
|
||||
|
||||
**Слой 3 — реакции не обрабатываются:**
|
||||
|
||||
`_handle_reaction_event` в `zulip.py` вызывается только из `_poll_once`. У Eagle `ZULIP_POLLING_DISABLED=true` — поллинг выключен. zulip-router регистрирует event_types = ["message"] — реакции не получает.
|
||||
|
||||
### Диагноз (лаконично)
|
||||
|
||||
`_route_to_active_session` interrupt + interrupt_gateway_approvals убивает approval ДО того как `_message_handler` успевает вызвать `_handle_approve_command`. `/approve` приходит как текст → interrupt → approval прерван → `/approve` отвечает "нет ожидающих approval" → ран оборван.
|
||||
|
||||
## Архитектура решения
|
||||
|
||||
### Вариант А (минимальный фикс в run.py)
|
||||
|
||||
Добавить проверку на `/approve`/`/deny` в `_route_to_active_session()` **ДО** interrupt-логики. Если пришла команда аппрува — не прерывать агента, а сразу идти в `_handle_approve_command`.
|
||||
|
||||
**Изменение:** в `_route_to_active_session()`, до строки 3492 (interrupt), добавить:
|
||||
|
||||
```python
|
||||
# /approve и /deny не должны прерывать approval и агента
|
||||
cmd = event.get_command()
|
||||
if cmd in {"approve", "deny"}:
|
||||
return True # пропустить interrupt, approval обработается в _message_handler
|
||||
```
|
||||
|
||||
**Плюсы:** минимальное изменение, чинит `/approve` без @mention через роутер
|
||||
**Минусы:** не чинит реакции, не добавляет pre-seed реакции
|
||||
|
||||
### Вариант Б (через роутер)
|
||||
|
||||
zulip-router уже поллит Zulip Events API — добавить "reaction" в event_types, обрабатывать реакции и слать POST `/approve` или `/deny` на webhook бота.
|
||||
|
||||
**Изменения в роутере:**
|
||||
1. Добавить `"reaction"` в `event_types`
|
||||
2. При `reaction` event: определить кто автор сообщения (по message_id), emoji → choice, POST на webhook бота с телом `{ "message": {...}, "trigger": "approve:once" }`
|
||||
3. Опционально: pre-seed реакции на approval-сообщения от ботов
|
||||
|
||||
**Плюсы:** единое место для reaction routing, не меняет Hermes код
|
||||
**Минусы:** нужно менять роутер + всё равно нужен Вариант А для `/approve` текстом
|
||||
|
||||
### Вариант В (минимальный + реакции)
|
||||
|
||||
Вариант А + роутер: фикс `/approve` в run.py, реакции через роутер.
|
||||
|
||||
## Рекомендация
|
||||
|
||||
**Вариант А (для `/approve`)** и потом **Варианта Б (для реакций)**.
|
||||
|
||||
Вариант А чинит `/approve` сейчас — одно изменение в `_route_to_active_session()`.
|
||||
|
||||
## Изменения в zulip-router (для реакций)
|
||||
|
||||
### 1. Добавить `reaction` в event_types
|
||||
|
||||
```go
|
||||
// main.go
|
||||
eventTypes := []string{"message", "reaction"}
|
||||
```
|
||||
|
||||
### 2. Добавить struct для реакции
|
||||
|
||||
```go
|
||||
type ZulipEvent struct {
|
||||
ID int64 `json:"id"`
|
||||
Type string `json:"type"`
|
||||
Timestamp int64 `json:"timestamp"`
|
||||
Flags []string `json:"flags,omitempty"`
|
||||
Message *Message `json:"message,omitempty"`
|
||||
// Reaction fields (when type == "reaction")
|
||||
Op string `json:"op,omitempty"` // "add" или "remove"
|
||||
UserID int64 `json:"user_id,omitempty"`
|
||||
MessageID int64 `json:"message_id,omitempty"`
|
||||
EmojiName string `json:"emoji_name,omitempty"`
|
||||
EmojiCode string `json:"emoji_code,omitempty"`
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Обработка reaction в processEvent
|
||||
|
||||
```go
|
||||
if ev.Type == "reaction" {
|
||||
processReaction(cfg, store, fwd, ev)
|
||||
return
|
||||
}
|
||||
```
|
||||
|
||||
```go
|
||||
func processReaction(cfg, store, fwd, ev):
|
||||
if ev.Op != "add" → return
|
||||
if int64SliceContains(cfg.BotIDs, ev.UserID) → return
|
||||
|
||||
// emoji → choice
|
||||
choice := ""
|
||||
switch ev.EmojiName {
|
||||
case "+1", "thumbs_up", "white_check_mark": choice = "once"
|
||||
case "lock", "locked": choice = "session"
|
||||
case "infinity": choice = "always"
|
||||
case "-1", "thumbs_down", "cross_mark": choice = "deny"
|
||||
}
|
||||
if choice == "" → return
|
||||
|
||||
// найти владельца сообщения
|
||||
// нужно хранить message_id → bot_name
|
||||
ownerName, ok := store.getMessageOwner(ev.MessageID)
|
||||
if !ok → return
|
||||
|
||||
// найти бота
|
||||
bot := cfg.findBot(ownerName)
|
||||
if bot == nil → return
|
||||
|
||||
// отправить POST с trigger = "approve:" + choice
|
||||
fwd.Forward(bot, message, "approve:"+choice)
|
||||
```
|
||||
|
||||
### 4. Message owner store
|
||||
|
||||
Нужно хранить кто написал сообщение (bot).
|
||||
|
||||
```go
|
||||
// ownership.go — добавить
|
||||
type MessageOwnerStore struct {
|
||||
mu sync.RWMutex
|
||||
entries map[int64]messageOwner // message_id → owner info
|
||||
}
|
||||
|
||||
type messageOwner struct {
|
||||
BotName string `json:"bot_name"`
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
}
|
||||
```
|
||||
|
||||
Заполнять при `processEvent` для сообщений от ботов.
|
||||
|
||||
### 5. Pre-seed реакций (опционально)
|
||||
|
||||
Роутер находит сообщения ботов с маркером `⚠️ **Command Approval Required**` или `⚠️ **Dangerous command requires approval:**` и ставит реакции 👍 🔒 ♾️ 👎.
|
||||
|
||||
```go
|
||||
func seedApprovalReactions(z *ZulipClient, msg *Message) {
|
||||
if !isApprovalMessage(msg.Content) {
|
||||
return
|
||||
}
|
||||
reactions := []struct{name, code string}{
|
||||
{"thumbs_up", "1f44d"},
|
||||
{"locked", "1f512"},
|
||||
{"infinity", "267e"},
|
||||
{"thumbs_down", "1f44e"},
|
||||
}
|
||||
for _, r := range reactions {
|
||||
z.addReaction(msg.ID, r.name, r.code)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 6. addReaction в zulip.go
|
||||
|
||||
```go
|
||||
func (z *ZulipClient) addReaction(messageID int64, emojiName, emojiCode string) error {
|
||||
form := url.Values{}
|
||||
form.Set("emoji_name", emojiName)
|
||||
form.Set("emoji_code", emojiCode)
|
||||
form.Set("reaction_type", "unicode_emoji")
|
||||
|
||||
req, _ := http.NewRequest("POST",
|
||||
fmt.Sprintf("%s/api/v1/messages/%d/reactions", z.Server, messageID),
|
||||
strings.NewReader(form.Encode()))
|
||||
req.SetBasicAuth(z.Email, z.APIKey)
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
|
||||
resp, err := z.HTTP.Do(req)
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
## Изменения в Hermes (для `/approve` текстом)
|
||||
|
||||
### run.py — _route_to_active_session
|
||||
|
||||
Перед interrupt-секцией (строка 3492), добавить:
|
||||
|
||||
```python
|
||||
# /approve и /deny не должны прерывать approval
|
||||
# Они обрабатываются в _message_handler (строка 7819)
|
||||
if event.get_command() in {"approve", "deny"}:
|
||||
return True
|
||||
```
|
||||
|
||||
## TODO
|
||||
|
||||
### Phase 1: `/approve` текстом (срочно)
|
||||
- [ ] Добавить guard в `_route_to_active_session()` — если команда approve/deny, не прерывать агента и approval
|
||||
|
||||
### Phase 2: Реакции через роутер
|
||||
- [ ] Добавить `"reaction"` в event_types роутера
|
||||
- [ ] Добавить обработку reaction в `processEvent`
|
||||
- [ ] Реализовать message_id → bot_name маппинг
|
||||
- [ ] Добавить `addReaction` в zulip.go
|
||||
- [ ] Добавить pre-seed реакций на approval-сообщения
|
||||
- [ ] Проверить что POST с trigger="approve:once" правильно обрабатывается Hermes
|
||||
@@ -1,46 +1,141 @@
|
||||
---
|
||||
tags: [project, hermes, zulip, go, infra]
|
||||
status: deployed
|
||||
created: 2026-06-16
|
||||
---
|
||||
|
||||
# Zulip Router
|
||||
|
||||
_Последнее обновление: 2026-06-17 (skip_mention_forward: устранена двойная доставка при @mention)_
|
||||
|
||||
## Цель
|
||||
|
||||
Event-роутер между Zulip и несколькими Hermes-инстансами.
|
||||
Решает проблему конкурентного получения событий (Валера, Клавдий и другие боты видели все сообщения одновременно, `reset` срабатывал у всех).
|
||||
Event-роутер между Zulip и balda-ботами (Валера, Клавдий).
|
||||
Решает проблему конкурентного получения событий — сейчас оба бота видят все сообщения одновременно.
|
||||
|
||||
## Проблема (root cause)
|
||||
|
||||
- Каждый Hermes-инстанс в polling-режиме регистрирует собственную event queue в Zulip
|
||||
- `reset` — системная команда Hermes, обрабатывается до хука `whale-thread-guard`
|
||||
- Нет механизма ownership топика между инстансами
|
||||
- Каждый balda-бот регистрирует собственную event queue в Zulip Events API
|
||||
- Без роутера оба получают все сообщения, и каждый может ответить на @mention другого
|
||||
- `extractOtherMention()` патч в коде — костыль, не решает проблему полностью
|
||||
|
||||
## Архитектура
|
||||
## Как сейчас (до роутера)
|
||||
|
||||
```
|
||||
Zulip
|
||||
├── Events API ← Валера (valera-bot, poll на :8091) — отключён
|
||||
├── Events API ← Клавдий (claudio-bot, poll на :8092) — отключён
|
||||
└── Webhook → оба (outgoing webhook через @mention)
|
||||
```
|
||||
|
||||
## Архитектура (с роутером)
|
||||
|
||||
```
|
||||
Zulip Event Queue
|
||||
↓ poll (valera-bot credentials)
|
||||
↓ poll (credentials Eagle из ~/.hermes/config.yaml)
|
||||
[zulip-router]
|
||||
├── @Валера → http://hermes-valera:8765/zulip-webhook
|
||||
└── @Клавдий → http://hermes-klavdiy:8765/zulip-webhook
|
||||
↕ ownership: stream+topic → bot (TTL 24h, persisted to /data/ownership.json)
|
||||
│
|
||||
├── @mention бота от человека → POST owner-боту + ownership
|
||||
├── @mention любого бота (включая другого) → ownership, НЕ форвардить
|
||||
├── трейд с ownership → POST owner-боту
|
||||
├── новый тред без @mention → default-боту (из конфига)
|
||||
└── reset / system → только владельцу треда
|
||||
```
|
||||
|
||||
Hermes/Орёл — не трогаем, остаётся в polling-режиме.
|
||||
**Ключевое:** credentials — Eagle/Орла (не новый бот). Роутер регистрирует свою event queue (отдельную от очереди Eagle).
|
||||
|
||||
## Правила маршрутизации
|
||||
## Правила маршрутизации (пошагово)
|
||||
|
||||
1. `@Валера` или `@Valera` в тексте → Валера (+ ownership обновляется)
|
||||
2. `@Клавдий` или `@Klavdiy` в тексте → Клавдий (+ ownership обновляется)
|
||||
3. Топик уже у кого-то → туда же (TTL refreshed)
|
||||
4. Новый топик без @mention → Валера (default)
|
||||
5. `reset` без @mention → уходит только к владельцу топика ✓
|
||||
Для каждого сообщения из event queue:
|
||||
|
||||
1. **Извлечь @mention** — найти все `@**Name**` в тексте (Zulip-формат). Игнорировать `@**all**` и `@**everyone**`. Поиск по Name + Aliases.
|
||||
|
||||
2. **Проверить источник @mention:**
|
||||
- Если сообщение от **человека** (sender_id не из списка ботов) и содержит `@**<бот>**`:
|
||||
- Записать этого бота как владельца треда (stream+topic)
|
||||
- Проверить `skip_mention_forward` для этого бота:
|
||||
- **false** (по умолчанию) — FORWARD сообщение этому боту
|
||||
- **true** — НЕ форвардить (Zulip outgoing webhook уже доставил напрямую). Ownership обновляется.
|
||||
- Если сообщение от **человека** и содержит @mention **другого бота** (из конфига, но не того, что стал бы овнером):
|
||||
- Всё равно записать этого бота как владельца треда
|
||||
- НЕ форвардить (Zulip сам отправит webhook целевому боту по @mention)
|
||||
- Если сообщение от **бота** (из конфига) и содержит @mention другого бота:
|
||||
- Обновить ownership, но НЕ форвардить (Zulip сам доставит)
|
||||
|
||||
3. **Нет @mention, но тред уже закреплён за ботом:**
|
||||
- FORWARD владельцу треда
|
||||
|
||||
4. **Новый тред без @mention:**
|
||||
- FORWARD default-боту (из конфига)
|
||||
|
||||
5. **reset / system-команды без @mention:**
|
||||
- FORWARD только владельцу треда
|
||||
|
||||
### Важный нюанс: @mention от другого бота
|
||||
|
||||
Когда один бот пишет `@Валера` (например, Eagle), Zulip отправляет outgoing webhook Валере напрямую — роутер не должен дублировать это сообщение. Поэтому:
|
||||
|
||||
- Роутер **ставит ownership**, но **не форвардит** если в сообщении есть @mention любого бота из его конфига
|
||||
- Целевой бот получит сообщение через свой собственный outgoing webhook от Zulip
|
||||
|
||||
## Конфиг роутера (`~/Docker/zulip-router/config.yaml`)
|
||||
|
||||
```yaml
|
||||
zulip:
|
||||
bot_email: "router-bot@zulip.qentra.top"
|
||||
api_key: "aOYAXlBV1bZlv871fnTTgbGTBX7R4DeC" # router-bot, не eagle-bot
|
||||
server_url: "https://zulip.qentra.top"
|
||||
webhook_token: "08cd0f..." # совпадает с secret в конфиге Eagle (route eagle)
|
||||
|
||||
bots:
|
||||
- name: "Валера"
|
||||
aliases: ["Valera"]
|
||||
webhook: "http://balda-agent-valera-1:8091/zulip/webhook"
|
||||
skip_mention_forward: true
|
||||
- name: "Клавдий"
|
||||
aliases: ["Klavdiy"]
|
||||
webhook: "http://claudio-agent-claudio-1:8092/zulip/webhook"
|
||||
skip_mention_forward: true
|
||||
- name: "Eagle"
|
||||
bot_email: eagle-bot@zulip.qentra.top
|
||||
aliases: ["Орёл", "орёл", "eagle", "Eagle"]
|
||||
webhook: "http://host.docker.internal:8644/webhooks/eagle"
|
||||
skip_mention_forward: true
|
||||
- name: "Кит"
|
||||
bot_email: whale-bot@zulip.qentra.top
|
||||
aliases: ["Whale", "whale", "кит"]
|
||||
webhook: "http://host.docker.internal:8645/webhooks/whale"
|
||||
skip_mention_forward: true
|
||||
|
||||
# bot_ids — sender_id ботов в Zulip (чтобы отличать сообщения человека от бота)
|
||||
bot_ids:
|
||||
- 9 # Eagle / Орёл (Hermes)
|
||||
- 10 # Клавдий (claudio-bot)
|
||||
- 11 # Валера (balda-bot)
|
||||
- 13 # Кит (whale-bot)
|
||||
- 14 # router-bot
|
||||
|
||||
default_bot: "Валера"
|
||||
skip_bot_messages: true # не форвардить сообщения от ботов (включая router-bot)
|
||||
|
||||
ownership:
|
||||
ttl: 24h
|
||||
persist_path: "/data/ownership.json"
|
||||
```
|
||||
|
||||
## Что меняется в balda
|
||||
|
||||
После деплоя роутера Events API polling уже отключён у обоих ботов (сделано 2026-06-16):
|
||||
|
||||
```yaml
|
||||
zulip:
|
||||
events_polling:
|
||||
enabled: false
|
||||
```
|
||||
|
||||
Сообщения приходят **только через webhook** от роутера (плюс outgoing webhook от Zulip при @mention напрямую).
|
||||
|
||||
## Код
|
||||
|
||||
`~/docker/zulip-router/` — Go, ~260 LOC
|
||||
**Репозиторий:** `~/Developer/zulip-router/` — Go, ~260 LOC (план)
|
||||
|
||||
**Git:** `git init` 2026-06-16. Первый коммит: `a403562` — чистый оригинал от 14:22. `.bak` файлы — слепки конфигов до правок Кита.
|
||||
|
||||
**Deploy:** `~/Docker/zulip-router/docker-compose.yaml`
|
||||
|
||||
| Файл | Назначение |
|
||||
|------|------------|
|
||||
@@ -48,47 +143,135 @@ Hermes/Орёл — не трогаем, остаётся в polling-режим
|
||||
| `config.go` | Config struct + YAML loading + env var expansion |
|
||||
| `zulip.go` | Zulip API client (register_queue, get_events, long-poll) |
|
||||
| `ownership.go` | Topic ownership store (RW mutex + JSON persistence) |
|
||||
| `forwarder.go` | HTTP POST к Hermes webhook endpoints |
|
||||
| `forwarder.go` | HTTP POST к balda webhook endpoints |
|
||||
|
||||
## Deployment
|
||||
|
||||
**Compose:** `~/docker/hermes/docker-compose.yml` — сервис `zulip-router`
|
||||
**Compose:** `~/Docker/zulip-router/docker-compose.yaml`
|
||||
|
||||
**Env vars** (`~/docker/hermes/.env`):
|
||||
```
|
||||
ZULIP_ROUTER_API_KEY=<valera-bot api key из ~/.hermes/profiles/valera/config.yaml>
|
||||
ZULIP_WEBHOOK_TOKEN=zr-secret-2026
|
||||
**Сеть:** `balda_default` (external) — чтобы видеть balda-agent-valera-1 и claudio-agent-claudio-1 по Docker DNS.
|
||||
|
||||
### Credentials: Eagle, не новый бот
|
||||
|
||||
Роутер использует credentials Eagle/Орла из `~/.hermes/config.yaml` → `platforms.zulip`.
|
||||
|
||||
Не создавать нового бота. Eagle уже имеет права на чтение всех публичных стримов.
|
||||
|
||||
### Env vars
|
||||
|
||||
Создать `~/Docker/zulip-router/.env`:
|
||||
|
||||
```env
|
||||
# Credentials Eagle/Орла — из ~/.hermes/config.yaml
|
||||
ZULIP_API_KEY=<скопировать из конфига Eagle>
|
||||
```
|
||||
|
||||
**Запуск после заполнения .env:**
|
||||
### Запуск
|
||||
|
||||
```bash
|
||||
cd ~/docker/hermes
|
||||
docker compose up -d --force-recreate
|
||||
cd ~/Docker/zulip-router
|
||||
docker-compose up -d
|
||||
docker logs zulip-router --tail 20 # проверить poll loop
|
||||
```
|
||||
|
||||
**Перезапуск после изменений в профилях:**
|
||||
```bash
|
||||
docker compose restart hermes-valera hermes-klavdiy
|
||||
```
|
||||
|
||||
## Hermes профили
|
||||
|
||||
Валера и Клавдий переведены в webhook-режим:
|
||||
```yaml
|
||||
platforms:
|
||||
zulip:
|
||||
webhook_port: 8765
|
||||
webhook_path: /zulip-webhook
|
||||
webhook_token: 'zr-secret-2026'
|
||||
```
|
||||
|
||||
Токен должен совпадать с `ZULIP_WEBHOOK_TOKEN` в `.env`.
|
||||
|
||||
## TODO
|
||||
|
||||
- [ ] Заполнить ZULIP_ROUTER_API_KEY в ~/docker/hermes/.env
|
||||
- [ ] `docker compose up -d --force-recreate`
|
||||
- [ ] Проверить логи: `docker compose logs -f zulip-router`
|
||||
- [ ] Протестировать: написать @Клавдий и @Валера в отдельных топиках
|
||||
- [ ] Проверить ownership: обратиться в топик без @mention — должен отвечать последний бот
|
||||
- [ ] При необходимости: добавить Hermes/Орёл в роутер (отдельная задача)
|
||||
- [x] Написать код роутера (~260 LOC)
|
||||
- [x] Создать compose `~/Docker/zulip-router/docker-compose.yaml`
|
||||
- [x] Набить `.env` с credentials Eagle (из `~/.hermes/config.yaml`)
|
||||
- [x] `docker-compose up -d`
|
||||
- [x] Проверить логи: `docker logs zulip-router --tail 50`
|
||||
- [x] Добавить `all_public_streams=true` в register (роутер не получал события)
|
||||
- [x] Добавить guards: timestamp (старт роутера) + skip_bot_messages
|
||||
- [x] Протестировать @mention от человека в новом треде → правильная маршрутизация
|
||||
- [x] Устранить двойную доставку Орлу (skip_mention_forward: true, Jun 17 16:57)
|
||||
- [ ] Протестировать сообщение без @mention в закреплённом треде → ownership
|
||||
- [ ] Удалить `extractOtherMention()` патч из кода balda-ботов (больше не нужен)
|
||||
|
||||
## Опции запуска
|
||||
|
||||
```
|
||||
/app/zulip-router [--config /etc/zulip-router/config.yaml] [--debug]
|
||||
```
|
||||
|
||||
Флаг `--debug` включает `slog.LevelDebug` (structured JSON-text логи).
|
||||
Удобнее: `DEBUG=true` env var (через entrypoint.sh) — подхватывается из docker-compose.
|
||||
|
||||
По умолчанию `DEBUG=false` — только INFO+.
|
||||
|
||||
## Логирование
|
||||
|
||||
Переведено на `log/slog` (built-in Go 1.21+), формат text handler.
|
||||
|
||||
Уровни:
|
||||
- **ERROR** — фатальные ошибки, падения, ошибки форварда
|
||||
- **WARN** — skip unparseable event, owner bot not found
|
||||
- **INFO** — queue registered, router ready (start timestamp), routing decision (mention/owner/default), forward success, http start
|
||||
- **DEBUG** — raw event body, poll response, ownership get/set, forward request/response body, routing skip reasons, guard skips
|
||||
|
||||
Включение: `--debug` флаг или `DEBUG=true` env var.
|
||||
|
||||
## Guards (фильтры сообщений)
|
||||
|
||||
Два guard'а в `processEvent`, выполняются до любой маршрутизации:
|
||||
|
||||
### 1. Timestamp guard (всегда включён)
|
||||
|
||||
Сообщения, созданные **до старта роутера**, скипаются полностью (включая обновление ownership).
|
||||
|
||||
```go
|
||||
startTime := time.Now().Unix() // после регистрации очереди
|
||||
if ev.Timestamp < startTime {
|
||||
slog.Debug("skip: event from before router start", ...)
|
||||
return
|
||||
}
|
||||
```
|
||||
|
||||
Лог: `"router ready, events before this timestamp will be skipped" start_timestamp=<unix>`
|
||||
на уровне INFO (всегда виден).
|
||||
|
||||
На уровне DEBUG — `"skip: event from before router start"` для каждого скипнутого события.
|
||||
|
||||
### 2. Bot sender guard (конфигурируемый)
|
||||
|
||||
Если `skip_bot_messages: true` в конфиге, все сообщения от ботов (sender_id из `bot_ids`) скипаются — не форвардятся и не обновляют ownership.
|
||||
|
||||
Лог (DEBUG): `"skip: bot message (skip_bot_messages=true)"`
|
||||
|
||||
**Зачем:** router-bot может писать сообщения в стримы для теста/уведомлений. Роутер не должен форвардить их обратно ботам.
|
||||
|
||||
## Решённые проблемы
|
||||
|
||||
### 1. Event queue умирала, long-poll зависал (FIXED 2026-06-17)
|
||||
|
||||
**Симптом:** роутер регистрирует очередь, делает `fetchEvents` с `dont_block=false`, и **зависает навсегда**. Логи отсутствуют часами. Сообщения не обрабатываются.
|
||||
|
||||
**Фикс:** `all_public_streams=true` при регистрации очереди + обработка `BAD_EVENT_QUEUE_ID` с перерегистрацией.
|
||||
|
||||
### 2. Роутер не получал события (FIXED 2026-06-17)
|
||||
|
||||
**Причина:** router-bot не был подписан на стримы. Очередь регистрировалась, но событий не получала.
|
||||
**Фикс:** `all_public_streams=true` в `registerQueue`.
|
||||
|
||||
### 3. Сообщения до старта роутера обрабатывались (FIXED 2026-06-17)
|
||||
|
||||
**Фикс:** timestamp guard — сообщения с timestamp < времени старта скипаются.
|
||||
|
||||
### 4. Сообщения от router-bot форвардились ботам (FIXED 2026-06-17)
|
||||
|
||||
**Фикс:** конфигурируемый `skip_bot_messages: true` — сообщения от ботов скипаются.
|
||||
|
||||
### 5. Двойная доставка @mention (FIXED 2026-06-17)
|
||||
|
||||
**Симптом:** Орёл получал одно сообщение дважды — один раз через Zulip outgoing webhook, второй — через роутер.
|
||||
|
||||
**Причина:** роутер форвардил @mention-сообщения, хотя Zulip outgoing webhook уже доставил их напрямую боту.
|
||||
|
||||
**Фикс:** добавлен флаг `skip_mention_forward: true` в конфиг каждого бота, у которого есть outgoing webhook. При @mention от человека:
|
||||
- ownership обновляется (кто владелец треда)
|
||||
- если `skip_mention_forward: true` — forward не делается (логируется `"skip forward: mention delivered via Zulip outgoing webhook"`)
|
||||
- если `skip_mention_forward: false` (по умолчанию) — поведение не меняется
|
||||
|
||||
**Код:** `config.go` → `BotCfg.SkipMentionForward bool`, `main.go` → проверка при @mention от человека.
|
||||
|
||||
**Git:** be53c1d (роутер), 3fba7c8 (Docker конфиг).
|
||||
|
||||
Reference in New Issue
Block a user