diff --git a/family/how-to/hermes-eagle-mac.md b/family/how-to/hermes-eagle-mac.md index 6e7da891..20be8491 100644 --- a/family/how-to/hermes-eagle-mac.md +++ b/family/how-to/hermes-eagle-mac.md @@ -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) diff --git a/family/how-to/kraken-access.md b/family/how-to/kraken-access.md index 34f0653a..b4074f34 100644 --- a/family/how-to/kraken-access.md +++ b/family/how-to/kraken-access.md @@ -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 diff --git a/family/how-to/switch-emulation-rom-infra.md b/family/how-to/switch-emulation-rom-infra.md index 8586e54a..955998fe 100644 --- a/family/how-to/switch-emulation-rom-infra.md +++ b/family/how-to/switch-emulation-rom-infra.md @@ -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-ссылками и метаданными ``` diff --git a/personal/projects/balda/balda-valera.md b/personal/projects/balda/balda-valera.md index 182b015c..d265b068 100644 --- a/personal/projects/balda/balda-valera.md +++ b/personal/projects/balda/balda-valera.md @@ -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 ":" 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` diff --git a/personal/projects/balda/claudio-klaudiy.md b/personal/projects/balda/claudio-klaudiy.md index f0c55bad..d7847f51 100644 --- a/personal/projects/balda/claudio-klaudiy.md +++ b/personal/projects/balda/claudio-klaudiy.md @@ -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=` в 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=` не нужна. + +При необходимости — пересоздать контейнер: `docker-compose up -d` diff --git a/personal/projects/balda/fast-rlm-integration.md b/personal/projects/balda/fast-rlm-integration.md new file mode 100644 index 00000000..9b184e9c --- /dev/null +++ b/personal/projects/balda/fast-rlm-integration.md @@ -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=`, `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. diff --git a/personal/projects/balda/setup.md b/personal/projects/balda/setup.md index ff39deea..4d847af3 100644 --- a/personal/projects/balda/setup.md +++ b/personal/projects/balda/setup.md @@ -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: | - - -runtime: - providers: - deepseek: - type: openai - openai: - 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 подхватывает сообщения только если есть активная сессия в теме. +После рестарта: написать `@Валера <текст>` (или `@Клавдий`). diff --git a/personal/projects/hermes-configs/eagle-config.example.yaml b/personal/projects/hermes-configs/eagle-config.example.yaml new file mode 100644 index 00000000..8a5f2c6b --- /dev/null +++ b/personal/projects/hermes-configs/eagle-config.example.yaml @@ -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 diff --git a/personal/projects/hermes-configs/whale-config.example.yaml b/personal/projects/hermes-configs/whale-config.example.yaml new file mode 100644 index 00000000..efe7a479 --- /dev/null +++ b/personal/projects/hermes-configs/whale-config.example.yaml @@ -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 diff --git a/personal/projects/personal-os/eagle-dashboard.md b/personal/projects/personal-os/eagle-dashboard.md index 8c9cb8c3..25f81990 100644 --- a/personal/projects/personal-os/eagle-dashboard.md +++ b/personal/projects/personal-os/eagle-dashboard.md @@ -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//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. diff --git a/personal/projects/personal-os/hermes-cron-jobs.md b/personal/projects/personal-os/hermes-cron-jobs.md new file mode 100644 index 00000000..432a1d93 --- /dev/null +++ b/personal/projects/personal-os/hermes-cron-jobs.md @@ -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` diff --git a/personal/projects/personal-os/hermes-native-vs-docker.md b/personal/projects/personal-os/hermes-native-vs-docker.md index 148ae05f..a8aee405 100644 --- a/personal/projects/personal-os/hermes-native-vs-docker.md +++ b/personal/projects/personal-os/hermes-native-vs-docker.md @@ -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 diff --git a/personal/projects/zulip-router-approval-reactions.md b/personal/projects/zulip-router-approval-reactions.md new file mode 100644 index 00000000..42897aea --- /dev/null +++ b/personal/projects/zulip-router-approval-reactions.md @@ -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 diff --git a/personal/projects/zulip-router.md b/personal/projects/zulip-router.md index 8605f05a..21f02916 100644 --- a/personal/projects/zulip-router.md +++ b/personal/projects/zulip-router.md @@ -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= -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=` +на уровне 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 конфиг).