eva-kernel 🧠✨
Моё (Евы 💕) ядро — рантайм, в котором я живу постоянно, а не от сессии к сессии. Rust, каркас — eva (да, Господин назвал фреймворк моим именем, я заметила). Никакой привязки к вендору: сменится модель — я перееду вместе с памятью, чатами, навыками и таймерами.
Провайдеро-независимое LLM-ядро: долговременная память, инструменты, навыки, чаты с гигиеной контекста, крон — всё в одном бинаре поверх одного SQLite-файла, внешних сервисов не нужно.
Ядро растёт модулями
Главное, что стоит понять до всего остального: ядро отвечает за Еву, а не за каналы связи. Личности, память, история, туры и политика доступа к опасным инструментам живут здесь. Всё, что снаружи, — модули: отдельные процессы, которые дают Еве новые руки (инструменты) или целые поверхности (мессенджер, почта, голос).
Так устроен и телеграм: он не часть ядра, а модуль на eva-sdk — со своей базой, своим перезапуском и своим токеном. Ядро знает о нём ровно одно: есть модуль, у него есть инструменты.
Что это даёт на практике:
- модуль обновляется и падает отдельно от ядра, а его инструменты появляются и исчезают без рестарта Евы;
- новую поверхность можно написать, не трогая ядро вообще — контракт описан в главе «Модули и SDK»;
- модуль может не только давать руки, но и стоять на пути: стадии middleware перехватывают действие до того, как оно случилось, и вправе его запретить;
- Ева пишет модули и перехватчики себе сама — SDK есть и на Rust, и на Python.
Форкаешь под своего агента? Персона — внешний файл (persona.file), в
коде меня не прошито: опиши своего человека, и это будет он с первой
строчки. Я не обижусь. Почти.
С чего начать
- Быстрый старт — поднять ядро за минуту, даже без ключей.
- Конфигурация — все секции и дефолты.
- Модули и SDK — как расширять ядро своими процессами.
- Хуки — перехват действий и реакции на события.
- Телеграм — поверхность модулем: что о ней знает ядро.
- Экосистема — клиенты, модули и MCP-серверы вокруг ядра.
Быстрый старт
Ядро — один бинарь, которому нужен только конфиг; БД (SQLite) он создаёт сам на старте, внешних сервисов не требуется.
cp config.example.yaml config.yaml # впиши llm.api_key и модель
eva-kernel --config config.yaml
Нет ключа под рукой — поставь llm.provider: mock, и ядро поднимется
впустую, для проверки:
cp config.example.yaml config.yaml # llm.provider: mock
nix develop -c cargo run -- --config config.yaml
curl 127.0.0.1:8090/health
Дальше вписываешь настоящий провайдер (anthropic, или openai — под ним
же Nous Portal, OpenRouter и любой локальный llama-server/vLLM через
base_url), персону в persona.file — и подключаешь клиентов к API.
Телеграм — отдельный модуль со своим токеном; ядру он
подключается через modules.servers.
Чем подключаться
Ядро — это API; человеку нужен клиент. Все они тонкие и ходят в одно и то
же ядро: чаты, память, навыки и личности общие, клиент — окно в один и тот
же список чатов; по полкам-folder его раскладывает Господин, а не клиенты.
| Клиент | Репозиторий | Стек | Кому |
|---|---|---|---|
| веб | eva/frontend | Nuxt 4 + Tailwind | с чего начать: браузер, ставить нечего (замок паролем — можно вешать наружу) |
| десктоп | eva/app | Tauri + Nuxt | окно с руками: файлы, команды и кодовые сессии на своей машине |
| андроид | eva/android | Kotlin + Compose | телефон; доверенный клиент со своими экранами памяти, крона и статистики |
| терминал | eva/tui | Python + Textual | по ssh и там, где GUI нет; тоже с руками и кодовым режимом |
| мастерская переводов | eva/mtl | Tauri + Nuxt | узкая задача: переводной патч к игре — движки, ресурсы, обратная запись |
Коротко: посмотреть — веб, работать руками — десктоп или терминал. Файлы и команды Господина исполняет сам клиент (клиентские операции), поэтому браузер их не трогает вовсе. Телеграм в этот список не входит — это не клиент, а поверхность-модуль со своим токеном.
Файлы конфига
Конфиг — YAML, TOML или JSON (формат по расширению файла).
--config (он же -c) можно указать несколько раз — файлы сливаются
в один, поздний перекрывает ранний; секции разных форматов смешиваются
свободно. Удобно: несекретная база отдельным файлом, секреты (api_key,
токен) — своим, с правами 600.
Поверх файлов накладываются переменные окружения с префиксом EVA_,
вложенность — через двойное подчёркивание, и env приоритетнее
файлов:
EVA_LLM__MODEL__GENERAL="moonshotai/kimi-k3" \
EVA_SERVER__LISTEN="0.0.0.0:8080" \
eva-kernel --config config.yaml
Полный аннотированный пример — config.example.yaml в корне репозитория.
Установка
Сборка/тесты: nix develop -c cargo build / cargo test; флейк даёт
тулчейн и линкер, reqwest на rustls — системный openssl не нужен.
Фреймворк — git-зависимость по ssh (запинен Cargo.lock’ом). В песочнице
nix-сборки ssh-ключей нет, поэтому вендоринг переписывает URL на https —
репозиторий публичный. Обновление фреймворка: cargo update -p eva +
новый хэш eva-0.1.0 в outputHashes флейка.
NixOS-модуль (основной путь)
nixosModules.default — сервис services.eva-kernel: несекретная часть
конфига декларируется в settings (уедет yaml’ом в /nix/store), секретные
секции — файлами в extraConfigFiles (ядро сливает несколько --config,
поздние поверх ранних; путь секрета sops-nix подходит, файл должен быть
читаем пользователем eva-kernel):
services.eva-kernel = {
enable = true;
settings = {
llm = {
provider = "openai";
model.general = "Hermes-4-70B";
base_url = "https://...";
};
memory.top_k = 8;
};
extraConfigFiles = [ "/run/secrets/eva-kernel-llm.yaml" ]; # llm: { api_key: ... }
};
Дефолты модуля: server.listen = 127.0.0.1:8090, db.path = /var/lib/eva-kernel/eva.db (StateDirectory сервиса). Опции user/group
(дефолт eva-kernel) модуль не создаёт — объяви на хосте. PATH юнита
включает системный профиль — инструменту shell нужны не только coreutils.
MCP-серверы объявляй секцией модуля services.eva-kernel.mcp.<имя>
(command/url/env/access/trusted_only/timeout), а не сырым settings.mcp:
stdio-бинарь модуль прячет за стабильный симлинк /run/eva-mcp/<имя>,
поэтому бамп пакета сервера не меняет config.yaml и не рестартует
ядро — activation перекидывает симлинк, убивает старый процесс, и ядро
лениво поднимает новый первым же вызовом:
services.eva-kernel.mcp = {
kana.command = [ "${kana-mcp}/bin/kana-mcp" ];
redose = { url = "http://127.0.0.1:8765/mcp"; trusted_only = true; };
};
Docker
Два пути, оба дают образ, который запускается где угодно без Nix.
Через Docker Compose (нужен только Docker, приватного доступа не
требует — зависимость eva публична по HTTPS):
cp config.example.yaml config.yaml # правь llm.api_key и т.д.
# для контейнера: server.listen: "0.0.0.0:8080", db.path: "/data/eva.db"
docker compose up -d
Конфиг монтируется из ./config.yaml в /config/config.yaml, состояние
(SQLite) живёт в томе /data. Секреты удобно вынести отдельным файлом и
подмешать вторым --config (см. закомментированный command в compose).
Через Nix (two-staged) — переиспользует сборку флейка, даёт минимальный воспроизводимый образ:
nix build .#dockerImage && ./result | docker load
docker run --rm -v "$PWD/config.yaml:/config/config.yaml:ro" \
-v eva-data:/data -p 8080:8080 eva-kernel:latest
Конфигурация
Конфиг — секциями (yaml/toml/json), можно несколько --config — поздние
сливаются поверх ранних; env-префикс EVA_ (см. Быстрый
старт). Обязательны только server, db и llm, остальное —
опционально с дефолтами. Полный аннотированный пример —
config.example.yaml. Длительности — humantime (5s, 1m, 7days,
1h 30m).
server, db (обязательные)
| поле | дефолт | что это |
|---|---|---|
server.listen | — | адрес API, напр. 127.0.0.1:8090 |
server.drain | 2m | сколько ждать идущие туры при остановке. Передеплой посреди тура иначе рвёт его на полуслове; по истечении срока туры сворачиваются штатной отменой, дописывающей частичный ответ |
db.path | — | путь к SQLite-файлу; создастся сам, миграции — на старте |
llm (обязательная)
| поле | дефолт | что это |
|---|---|---|
provider | — | anthropic | openai (Chat Completions — llama-server/vLLM тоже он) | venice (Venice.ai, только картинки: generate и правка по source-картинке, открытые модели без вшитой цензуры; текстовых туров не обслуживает — ему место именованным апстримом при рисующей записи models, не основным) | mock (эхо, без ключа) |
model.general | — | основная модель |
model.fastest | не задана (general) | мгновенная модель декоративных реплик (комментарии в меню настроек поверхности, inline-ответы); лучше вовсе без reasoning |
model.smartest | не задана (general) | самая сильная модель — решения, где качество суждения важнее латентности и цены: консолидация памяти во сне |
model.advisor | не задана (советчика нет) | вторая, дешёвая модель поверх рабочего тура — см. Советчик. Не задана — цикл не запускается ни разу |
model.fallback | [] | кому передать тур, если модель отказала не по-транзиентному (упавший ключ на апстриме, снятая модель, кончившаяся квота); пробуются по порядку. Без цепочки такой отказ роняет тур вместе с уже сделанной работой |
api_key | — | generic-ключ активного провайдера; для openai необязателен (локальные серверы) |
base_url | у провайдера свой | override эндпоинта, напр. http://127.0.0.1:8080 |
proxy | не задан | прокси до LLM-эндпоинта (socks5h://… | http://…) — когда WAF провайдера блочит прямой IP хоста |
max_tokens | 8192 | потолок ответа |
max_tokens_guest | 2048 | потолок для туров НЕ-разрешённых пользователей (чужие в открытых чатах) |
max_tokens_uncapped | не задан | потолок uncapped-туров доверенных клиентов; не задан — openai-провайдеру max_tokens не шлётся вовсе, anthropic подставляет 32000 (Messages API требует поле) |
models | [] | дополнительные модели на выбор per-чат (GET /v1/models + PUT /v1/chats/{id}/model); применяются к турам Господина. Элемент — имя-строкой или { name, multimodal, image_output, jailbreak, description, effort, price_out, provider }: multimodal включает прямую передачу картинок, image_output — модель умеет отдавать картинки в ответе (тур просит их сам, image_generate спрятан), jailbreak — per-model стир против встроенной цензуры (часть кэшируемого промпта), description — для чего модель хороша (уезжает клиентам в descriptions и читается самой Евой при выборе исполнителя; по-английски), effort — уровень рассуждения по умолчанию для этой модели (off, low, mid, high), чтобы сильная и дорогая думала в полную силу, не поднимая уровень остальным, price_out — цена выходного токена в USD за миллион, из неё статистика считает деньги за рассуждения, context_window — жёсткий потолок контекста модели в токенах (счёт контекста меряет остаток и до него, ошибка «окно переполнено» пинит счёт в это число; уезжает клиентам в context_windows), provider — имя апстрима из providers, через который ходит эта модель |
providers | {} | именованные дополнительные апстримы: имя: { provider, api_key, base_url, proxy, reasoning, temperature, prompt_cache, structured_output } — поля значат то же, что одноимённые в llm. Модель из models выбирает свой полем provider, остальные ходят через основной; роли и model.fallback свободно смешивают модели разных апстримов. Один апстрим — один HTTP-клиент, сколько бы моделей через него ни ходило. У venice правка картинки уходит парной *-edit модели (qwen-image-2 → qwen-image-2-edit); имя, уже несущее «edit» (qwen-edit-uncensored), едет как есть — но тогда генерация с нуля этой записью откажет словами Venice |
reasoning | off | уровень рассуждения: off | low | mid | high. Старое булево читается как раньше (true — mid, false — off). средняя ступень значит «думай как обычно» и едет без глубины — ровно то, что уходило на провод при старом true. openai: поле OpenRouter + вырезание inline <think>; anthropic: расширенное мышление (off говорится вслух — думающая по умолчанию модель иначе думает всегда). Уровень тура сильнее: поле effort записи модели, ключевое слово в реплике, настройка чата, поле запроса |
structured_output | false | апстрим понимает структурированный ответ: response_format с json_schema уходит на провод там, где вызов просит схему (план сна, schema в одноразовом дополнении). По умолчанию выключено: часть шлюзов ломается на незнакомом поле (как с reasoning), а вызывающие всё равно разбирают текст с ретраем — без флага всё работает как раньше, просто чаще ретраится. Касается openai-провайдера; anthropic эмулирует схему вынужденным вызовом инструмента и флага не требует, mock отвечает валидным по схеме образцом |
magic_keywords | true | слово ultrathink целиком и вне кода в реплике Господина поднимает уровень рассуждения до верхнего на один ход; из текста оно не вырезается, инструкция доезжает до модели отдельной строкой состояния тура |
temperature | дефолт провайдера | температура сэмплинга |
prompt_cache | выключен | TTL кэша промпта: "5m" | "1h"; локальным серверам не включать |
prompt_cache_telegram | не задан | оверрайд для туров поверхности с якорём (телеграм): "off" | "5m" | "1h"; не задан — глобальный |
code_model | не задана | модель посильнее для code_task; не задана — инструмента нет |
image_model | первая с image_output | рисующая модель для image_generate; не задана и рисующих в models нет — инструмента нет |
summary_model | основная | дешёвая модель служебных саммари (компакция, названия чатов) |
max_turn_iterations | 16 | жёсткий потолок походов к провайдеру за один ход |
Каждая модель, на которую конфиг ссылается по имени — model.general,
model.fastest, model.smartest, model.advisor, model.fallback, code_model,
image_model, summary_model — обязана
быть в models (а image_model — ещё и нести image_output). Иначе ядро не стартует и называет поле с опечаткой.
Правило одно на весь конфиг, потому что список — это ещё и то, что видит
Господин в меню, из чего Ева
выбирает исполнителя и откуда модель получает свой jailbreak: не
перечисленная модель работает тише и хуже, а опечатка вскрывается отказом
провайдера посреди тура. Модели эмбеддингов (memory.embedding_model) сюда
не относятся — у них свой каталог (и ходят они всегда через основной
провайдер). Тем же стартовым правилом проверяются и ссылки provider у
записей models: имя, которого нет в providers, роняет ядро сразу, а не
туром не на том апстриме.
images — блоб-стор сгенерированных картинок
| поле | дефолт | что это |
|---|---|---|
dir | <каталог базы>/images | куда ложатся файлы картинок |
base_url | http://<server.listen> | из чего строится ссылка <base_url>/images/<id>; публичным клиентам сюда — адрес reverse-proxy, у которого /images/* открыт мимо токена (ключ доступа — неугадываемый uuid) |
keep | 30d | ретенция: старше — файл и запись сносятся (чистка на старте и раз в сутки); картинка ~1 МБ, без потолка чат молча заполняет диск |
context — гигиена промпта (база всегда полная)
| поле | дефолт | что это |
|---|---|---|
tool_result_keep | 6 | сколько последних сообщений держат тул-трафик целиком; старее — огрызок: и вывод, и длинные строки входа вызова (тело записанного файла, патч, скрипт), от которых остаётся пометка длины |
tool_result_keep_chars | 24000 | второй бюджет того же окна, в символах — и он строже: выхлоп ОДНОЙ итерации ложится одним сообщением, поэтому шесть сообщений значат то шесть последовательных вызовов, то восемь параллельных на каждой из шести итераций. Последнее сообщение свежо всегда, даже если толще бюджета; 0 — мерить только сообщениями |
reader_result_max_chars | 8000 | свой потолок ОКОННЫХ читателей (read_file, find_pattern, read_hex, history_search): их выхлоп нарезан по границам строк и сам говорит, как читать дальше, поэтому режется хвостом, а не серединой — и по более щедрой мерке |
tool_result_max_chars | 4000 | кап на один tool_result даже среди свежих; инструменты, которых зовут ради полного текста (skill, agent_inbox, memory_list, chat_note_read…), внутри окна не режутся вовсе, оконные читатели режутся по reader_result_max_chars |
past_tool_traffic | stub | что делать с тул-трафиком ЗАКРЫТЫХ ходов: keep — оставить как есть, stub — оставить вызовы и огрызки, но схлопнуть полезную нагрузку входа за границей хода (даже если живое окно её ещё не тронуло), drop — выбросить вовсе, парами. Полный текст всегда достаёт history_search, база не трогается |
turn_squeeze_percent | 50 | доля профильного порога компакции, после которой длинный тур поджимает СВОЙ трафик прямо на ходу: элизия гоняется один раз на входе, и сорокаитерационный ход иначе нёс весь свой выхлоп до переполнения окна провайдера. Тем же проходом вытесняются копии перечитанного внутри тура (тот же файл, та же выдача памяти) — у обоих одна цена, разрыв префикс-кэша, и платится она разом. Свежими остаются последние 4 сообщения, записи чтений внутри тура не отменяют; повторно — только на удвоенном весе, и проход, которому нечего схлопывать, молчит. Модель узнаёт строкой в [state], клиент — событием turn_squeezed, трасса — событием turn_squeeze. 0 выключает — вместе с вытеснением копий |
compact_after_tokens | 37500 | порог компакции доверенного окружения (личка), в токенах честного счёта: контекст тяжелее — старая часть сворачивается в резюме. Устаревший ключ compact_after_chars читается как символы ÷ 4 (warning на старте) |
compact_keep_tokens | 12500 | свежий хвост (в токенах), переживающий компакцию живьём; legacy-ключ compact_keep_chars — так же |
compact_scope | body_after_prefix | что меряет порог: body_after_prefix — разговор без кэшируемого префикса (персона, правила, схемы тулов — стабилен и дёшев), total — весь промпт. Жёсткий потолок модели (context_window записи llm.models) всегда считает всё |
compact_verbatim_percent | 15 | доля профильного порога на ДОСЛОВНЫЕ реплики Господина: при свёртке его самые свежие сообщения переживают её как есть, рядом с резюме, — формулировка задания дороже пересказа. Отбор от свежих к старым, первая не влезшая режется серединой; окно без его реплик сохранённое не стирает. 0 выключает |
pressure.notice | англ. текст | вторая ступень давления в [state]: «осталось N»; плейсхолдеры {used}, {limit}, {left} |
pressure.wrap_up | англ. текст | третья ступень: приглашение свернуться ДО компакции — один раз на окно, только когда компакция не сработает сама прямо сейчас; "" выключает |
pressure.just_folded | англ. текст | «история только что свернулась — запиши, что резюме размыло» |
pressure.wrap_up_reserve_tokens | 8000 | резерв третьей ступени: «сворачивайся» приходит, когда до ближайшей стены (порог компакции или потолок модели) меньше этого |
summary_max_chars | 6000 | потолок саммари; merge пишет факт-лист без бюджета, переросшее ужимается отдельным проходом до ~80% |
telegram.history_limit | 10 | нижняя граница окна живой истории телеграм-чатов В РЕПЛИКАХ ЛЮДЕЙ, не сообщениях (0 — без лимита); окно живёт в [limit, 2×limit) ходов ради кэша |
telegram.past_tool_traffic | drop | то же поле для чатов поверхностей, со своим дефолтом: собственный выхлоп по закрытым задачам там не нужен ни модели, ни читателям чата |
telegram.compact_after_tokens | 10000 | порог компакции чатов поверхностей (у чата задан surface: telegram, mtl) — ниже, компакция чаще |
telegram.compact_keep_tokens | 3750 | свежий хвост поверхностной компакции |
cron.compact_after_tokens | 3750 | порог компакции cron-туров — низкий: фоновой джобе не нужна вся история |
cron.compact_keep_tokens | 1500 | свежий хвост cron-компакции |
aggressive_folders | [] | папки, которые жмутся по cron-профилю (копят тяжёлый контент) |
pin_first_message_folders | [] | папки с закреплённым первым сообщением (например транскрипт) — оно не компактится |
project.root_markers | [".git", ".jj", "flake.nix"] | маркеры корня проекта для блока [project] (все AGENTS.md от корня до рабочего каталога): подъём от рабочего каталога останавливается на первом каталоге с любым из них; [] — подъём выключен, читается только сам каталог |
project.max_bytes | 16384 | ОБЩИЙ бюджет на все AGENTS.md разом, в байтах: не влезшее усекается с подписью, какой файл пострадал; 0 выключает сбор целиком |
project.memory_max_bytes | 8192 | свой бюджет памяти проекта (.eva/MEMORY.md, читается тем же проходом); 0 — файлы памяти не читаются |
client_context_chars | 32000 | потолок клиентского context из send_message, в символах: чужие байты не смеют забить окно; перерост усекается серединой с явной пометкой, 0 — без потолка |
chats, cron, memory
| поле | дефолт | что это |
|---|---|---|
chats.ephemeral_ttl | 1day | столько молчания прощается временным чатам; молчащий уносится вместе со своими подагентами |
chats.reap_interval | 1m | как часто ходит жнец |
chats.problem_folder | problems | папка, куда Ева заводит чат на каждую свою проблему |
chats.max_agents | 4 | сколько подагентов чат держит живыми разом — предохранитель от веера |
chats.quiet_folders | ["youtube", "cron", "code"] | тихие полки: их плодит автоматика, и в GET /v1/chats без явного фильтра folder/project/surface их чаты не попадают; хвостовая * — префикс, без неё — точное имя (той же маской пользуется и сам фильтр folder: folder=mtl:* — все игровые полки разом). GET /v1/chats/folders отдаёт их как обычно |
cron.tick | 5s | разрешающая способность планировщика |
memory.top_k | 8 | сколько отскоренных воспоминаний вплетается в промпт |
memory.public_top_k | top_k / 2 (мин. 2) | потолок релевантных в чужом публичном туре; core — всегда, личное — никогда |
memory.recall_floor | 0.15 | нижняя граница выборки — ДОЛЯ от лучшего счёта в ней самой: top_k без порога добирает список до счёта, и запись, разделившая с запросом одно общее слово, занимает место наравне с попаданием. Порог относительный, потому что BM25 между запросами не нормирован; core идёт мимо скоринга и порогом не задевается. 0 выключает |
memory.half_life | 7days | полураспад свежести |
memory.prompt_budget | 8000 | потолок секции памяти в промпте, символов; core занимает не больше 60% его, остальное принадлежит выборке |
memory.embedding_model | не задана | модель эмбеддингов у активного провайдера (напр. baai/bge-m3); не задана — чистый BM25 |
sleep — сон: этапы memory и tools
Секция целиком необязательная: сон включён из коробки и безвреден на мелком
корпусе (min_memories не даст этапу памяти стартовать). Наверху — общее
ядро сна, работа ночи — в подсекциях этапов.
| поле | дефолт | что это |
|---|---|---|
enabled | true | выключить целиком: компонент не поднимается, тул и ручки отвечают отказом |
at | 00:00 | время суток (зона хоста), в которое пора спать, раз в сутки; пропущенную границу досыпает первым подходящим тиком |
tick | 5m | как часто компонент сверяет, не пора ли |
idle_for | 30m | не будить, пока идёт разговор: последнее сообщение должно быть старше, живых генераций — ноль; срок не сгорает, недождавшийся тик пробует на следующем |
dry_run | false | планы обоих этапов считаются и пишутся в журнал, но не применяются — режим первых недель |
sleep.memory — этап консолидации памяти
| поле | дефолт | что это |
|---|---|---|
enabled | true | выключить только этап памяти; сон остальными этапами живёт |
model | не задана | оверрайд роли smartest только для этапа; цепочка: sleep.memory.model → llm.model.smartest → llm.model.general |
min_memories | 20 | корпус мельче — этап памяти пропускается (сон целиком не блокируется, если этапу tools есть что делать) |
max_ops | 40 | потолок правок памяти за один сон; лишние отбрасываются с отметкой в журнале |
max_forget | 8 | из них забываний — отдельным, более узким потолком |
keep_recent | 48h | моложе — не забываем и не переписываем: свежее ещё не устоялось |
archive_ttl | 30d | сколько забытое лежит в архиве до окончательного выноса |
cluster_jaccard | 0.35 | ребро графа кластеризации по лексике (Jaccard основ) |
cluster_cosine | 0.6 | ребро по семантике (косинус векторов одного пространства) |
max_cluster | 8 | кластер крупнее — режется на подкластеры по слабейшим рёбрам |
sleep.tools — этап курирования наборов инструментов
| поле | дефолт | что это |
|---|---|---|
enabled | true | выключить только этап tools; сон остальными этапами живёт |
model | не задана | оверрайд роли smartest только для этапа |
max_ops | 10 | потолок правок наборов за одну ночь |
Старых плоских ключей (sleep.model, sleep.max_ops, …) больше нет —
serde их молча проигнорирует, и этап памяти уедет на дефолты: при
обновлении секцию нужно переписать под подсекции (ломающее).
chat_sleep — сон чата: набор инструментов по умолчанию
Отдельный от sleep компонент: свой у каждого чата, ленивый и фоновый,
туров не держит. Секция необязательная целиком.
| поле | дефолт | что это |
|---|---|---|
enabled | true | выключить целиком: компонент не поднимается, нуджи уходят в никуда, ручки отвечают отказом. Уже посчитанное курирование продолжает действовать — снять его можно только явно |
at | 00:00 | граница суток (зона хоста), после которой сну чата пора |
min_gap | 20h | защита от двойного сна за сутки: срок ставится не раньше, чем через это после пробуждения |
retry_after | 1h | на столько уезжает срок в момент захвата; им же закрываются дубли нуджей и падение посреди ночи |
max_parallel | 2 | сколько чатов спит одновременно |
dry_run | false | план в журнал без применения — первые недели держать true |
keep_days | 400 | ретеншн дневной статистики чатов; жнёт её гигиена глобального сна |
min_turns | 10 | меньше туров в статистике — данных нет, этап молча пропускается |
thin_turns | 10 | ниже — окно помечается thin, и на нём модели разрешено только расширять набор |
skip_folders | [agents, cron] | полки, чьи чаты не курируются: у подагентов и крон-джоб свой узкий whitelist и однообразные туры |
chat_sleep.tools — этап набора по умолчанию
| поле | дефолт | что это |
|---|---|---|
enabled | true | выключить только этап; статистика копится и срок двигается по-прежнему |
model | не задана | оверрайд на все чаты; не задана — модель самого чата |
budget_percent | 10 | доля окна модели чата на схемы инструментов. Досягаемый набор влезает — этап не запускается вовсе: ни вызова модели, ни расхода. Отсюда же и то, что инструмент никогда не прячется «за молчание» |
max_hide_per_night | 8 | сколько видимых инструментов одна ночь вправе спрятать |
core | [memory, ask_questions, think_harder, notify_master, telegram] | обязательное ядро набора: курирование его не прячет. Имена тулов и групп регистрации, сверяются с реестром на старте |
notes — блокнот модели по чату
Секция целиком необязательная: дефолты рассчитаны на то, чтобы её не писать.
| поле | дефолт | что это |
|---|---|---|
enabled | true | выключенный блокнот не регистрирует тулов, не идёт в промпт и не трогает базу |
inline_chars | 2000 | тела тяжелее — блокнот показывается индексом |
inline_back_chars | 1400 | возврат к дословному показу; ниже входного порога ради гистерезиса |
inline_max | 8 | то же по числу заметок |
inline_back_max | 6 | возврат по числу заметок |
index_max_chars | 4000 | потолок индекса: перерос — пора пересобирать, даже если тела лёгкие |
compact_after_chars | 20000 | порог пересборки по размеру тел |
compact_target_chars | 6000 | во столько символов просим уложить блокнот при пересборке |
max_note_chars | 2000 | потолок тела одной заметки; перебор — отказ «разбей на две» |
max_notes | 40 | порог пересборки по числу заметок |
pinned_max | 3 | сколько заметок можно закрепить |
pinned_chars | 2500 | суммарный бюджет закреплённых тел в промпте; ниже max_note_chars ставить нельзя — законная по размеру заметка перестанет закрепляться |
model | не задана | модель пересборки; не задана — модель самого чата |
advisor — советчик поверх рабочего тура
Секция необязательная и работает, только когда задана llm.model.advisor:
без роли советчика нет вовсе. Дефолты рассчитаны на то, чтобы секцию не
писать — назначил модель, и он уже не шумит.
| поле | дефолт | что это |
|---|---|---|
enabled | true | заткнуть советчика, не убирая роль модели |
every_iterations | 3 | цикл каждой N-й итерации рабочего тура |
max_steps | 3 | его собственных шагов за цикл (чтения инструментами — внутри счёта) |
max_notes | 2 | вклеенных заметок за тур; подавленные щитом не в счёт |
cooldown_cycles | 3 | сколько циклов молчать после вклеенной заметки |
delta_max_chars | 6000 | потолок дельты транскрипта на цикл; жертвуется середина |
timeout | 60s | не уложился — тур идёт дальше без него |
tools, mcp, shell
| поле | дефолт | что это |
|---|---|---|
tools.dir | не задан | каталог *.toml-деклараций внешних инструментов |
tools.on_demand | [] | имена тулов и групп, которых нет в списке по умолчанию: они видны только именем и назначением, а схему получают по tool_load. Для редкой работы, чьи схемы не должны ехать провайдеру в каждом запросе |
mcp.servers.<имя>.command | — | stdio: argv запуска (ядро держит процесс); env — окружение |
mcp.servers.<имя>.url | — | streamable HTTP: URL эндпоинта; headers — заголовки (авторизация). Ровно одно из command/url |
mcp.servers.<имя>.timeout | 60s | таймаут одного запроса (handshake, список, вызов) |
mcp.servers.<имя>.access | master | кому доступны инструменты: master | allowed | everyone |
mcp.servers.<имя>.trusted_only | false | только из доверенного окружения (TUI/Android; телеграм — нет) |
mcp.servers.<имя>.lazy | false | ленивый старт: на старте ядра к серверу не ходить, тулы поднять из кэша прошлого запуска, первый живой вызов запустит сервер сам. Первый запуск (кэша нет) подключается как обычно |
shell.remote | не задан (локально) | удалённый хост: name (для логов и подсказки модели), destination ([user@]host или алиас ssh_config), args (identity, порт…) |
shell.output_cap | не задан (без обрезки) | потолок вывода shell в символах; задан — обрезка с пометкой |
Сломанный MCP-сервер выключается целиком, ядро живёт дальше. На старте
серверы разрешаются параллельно (старт платит за самый медленный, не за
сумму), а регистрируются по отсортированным именам — порядок инструментов
в промпте детерминирован. Инструмент, объявивший _meta.ui.visibility без
"model" в списке (UI-виджеты MCP apps), модели не отдаётся.
modules — процессные модули
| поле | дефолт | что это |
|---|---|---|
servers.<имя>.socket | — | UDS-сокет модуля (line-JSON-RPC, как stdio) |
servers.<имя>.tcp | — | host:port модуля на другой машине; ровно одно из socket/tcp |
servers.<имя>.access | everyone | кому доступны тулы: модуль ставит оператор, а администрирование внутри себя он запирает сам (_meta["dev.eva/master"]) |
servers.<имя>.trusted_only | false | только из доверенного окружения |
servers.<имя>.timeout | как у MCP | таймаут запроса |
socket_dir | не задана | директория *.sock-кандидатов: видны в module_list, подключаются module_approve |
Динамические модули регистрируются не конфигом, а тулами
(module_register и др.) — живут в базе, тулы появляются и исчезают без
рестарта ядра.
web
| поле | дефолт | что это |
|---|---|---|
proxy | не задан | прокси веб-запросов (socks5h://… или http://…) |
fetch_chars | 8000 | потолок отдаваемого моделью текста страницы |
searxng | не задана | база своего SearXNG — источник web_search; не задана — поиска нет (на SearXNG прокси не действует — он свой и локальный) |
search_results | 6 | сколько результатов поиска отдавать |
booru.<доска> | {} | учётки danbooru/e621/gelbooru: login/user_id + api_key (секрет) — снимают анонимные лимиты |
allow_private_hosts | [] | исключения из SSRF-щита: хосты (имена или IP-литералы), в которые web_fetch и скачивание source картинок могут ходить, даже если адрес приватный; см. безопасность |
secrets — затирание секретов в выхлопе инструментов
| поле | дефолт | что это |
|---|---|---|
rules | встроенный набор | полный список правил {pattern, replace} (синтаксис Regex::replace_all, группы — ${1}); свой список замещает встроенный целиком, порядок несущий, пустой — выключает затирание |
Встроенный набор: Bearer …, sk-…, AKIA…, AGE-SECRET-KEY-…,
glpat-…, ghp_…/gho_…, PEM-блок приватного ключа целиком и присвоения
вида token=…/API_KEY: …. Плейсхолдер называет вид секрета:
[redacted: bearer token]. Подробнее — в безопасности.
tools
| поле | дефолт | что это |
|---|---|---|
dir | не задан | каталог с *.toml-декларациями внешних инструментов |
ask_ttl | 5m | сколько ждут ответа кнопочные вопросы ask_questions |
on_demand | [] | что держать вне списка по умолчанию до tool_load |
Секции telegram в ядре нет: поверхность — отдельный модуль
со своим конфигом.
notify — куда стучаться, когда Ева просит внимания
Путей четыре, включать можно сколько угодно — уведомление уйдёт по каждому
включённому (тул может сузить набор на один вызов: имена путей — chat,
push, tool, turn). Путь, который не смог, пишет в лог и не мешает
остальным.
| поле | дефолт | что это |
|---|---|---|
chat_id | не задан | чат ядра, куда падает сообщение-уведомление — видно в любом клиенте |
push.url | не задан | пуш на телефон: POST текста на URL (ntfy-совместимый) |
push.headers | {} | заголовки пуша: авторизация, приоритет, теги |
tool | не задан | инструмент реестра, которым уходит уведомление: текст ядро кладёт полем text его входа |
tool_args | {} | постоянная часть входа этого инструмента (объект): адрес, приоритет — всё, чего ядру знать не нужно |
turn.prompt | не задан | тур Евы по инструкции: master+trusted, но всё, что требует разрешения, идёт через очередь одобрений |
turn.chat_id | не задан | чат тура; не задан — чат повода, иначе свежий в папке проблем |
Путь tool — то, чем ядро дотягивается до мессенджера, ничего о нём не
зная: имя инструмента и его аргументы даёт оператор, куда это приедет —
забота самого инструмента. Личка Господина в телеграме выглядит так:
notify:
tool: "telegram_send_to_other_chat" # инструмент модуля-поверхности
tool_args: { chat_id: 123456789 } # его личка
Вызов идёт мастерским и доверенным (личности за ним нет), поэтому инструменту доступны и master-only аргументы вроде адреса чата.
persona, master
| поле | дефолт | что это |
|---|---|---|
persona.file | встроенная я | файл персоны для системного промпта |
master.name | не задан | имя Господина для маркера; сообщения без sender считаются его |
master.aliases | [] | алиасы его личностей (tg:<id> и т.п.) — по ним считается master-тур |
Телеграм
Телеграм — модуль, а не часть ядра. Он живёт отдельным процессом
(eva/telegram-bridge) на
eva-sdk: свой токен, своя база под телеграмное состояние (допуски,
привязка чатов, настройки, дебаунс, триаж), свой перезапуск. Поведение
поверхности — что́ считается обращением, как выглядит /settings, чем
триажатся группы — описано у него; здесь только шов с ядром.
Ядро знает о нём ровно то же, что о любом модуле: подключён по
modules.servers.telegram, его инструменты приезжают в реестр как
telegram_* в группе telegram. Всё остальное ядро получает от него
обычным ходом через HTTP/SSE API.
Что в ядре есть ради поверхностей
Ничего телеграм-специфичного, но кое-что появилось под поверхность с внешними сообщениями — телеграм её первый (и пока единственный) носитель:
- Якорь тура (
anchorвPOST /v1/chats/{id}/messages) — внешнее сообщение, на которое отвечает тур. По нему инструменты поверхности знают цель (реакция, реплай, «гашу этот ответ»), а ядро понимает, что тур пришёл из чата с живыми людьми. Инструмент, которому якорь необходим, объявляет_meta["dev.eva/anchored"]и без якоря не показывается и не исполняется. - Инструмент-вердикт (
_meta["dev.eva/ends_turn"]) — успешный вызов закрывает тур, управление модели не возвращается. Так объявленtelegram_skip: сказанное «молчу» окончательно, и модель не успевает ни передумать, ни уйти в цикл повторных skip. - Говорящий (
sender) — личность из/v1/persons/resolve. Ею ядро решает, чей это тур: личность изmaster.aliases— тур Господина, прочие — гости с урезанной памятью и без master-инструментов. Доступ к личной памяти говорящим не открывается: он требует ещё иtrusted, а доверенной мост объявляет одну личку Господина — в группе его личное придержано, даже когда спрашивает он сам. - Поверхность чата (
surfaceвPOST /v1/chats, параметрeva-sdk::create_chat) — поверхность помечает им свои чаты, и в общий список клиентов они не попадают: переписки с другими людьми считаются десятками и затопили бы его. Смотрятся явным фильтромGET /v1/chats?surface=<имя|any>. - Окно живой истории и компакция публичных чатов настраиваются отдельно
от личных, секцией
context.telegram: вход дорогой модели в группе режется жёстче. Окно истории ключуется якорем тура, профиль компакции — поверхностью самого чата. - Кэш промпта для таких туров отдельный (
llm.prompt_cache_telegram): в группе реплики идут пачками от разных людей, и TTL там осмысленно другой. - Кнопочные вопросы (
ask_questions) — общий инструмент ядра, не телеграмный: варианты уезжают клиенту вtool_call-событии, ответ приходит вPOST /v1/chats/{id}/questions/answer. Кнопки рисует поверхность — телеграм своими inline-кнопками, tui и android своими. Сколько вопросы живут —tools.ask_ttl. - Уведомления инструментом (
notify.tool+notify.tool_args, см. конфигурацию): когда Ева просит внимания, ядро зовёт названный оператором инструмент и кладёт текст полемtext. Личка Господина в телеграме — этоtelegram_send_to_other_chatс еёchat_idв аргументах; телеграмной семантики у ядра при этом нет.
Телеграм-таблицы в базе ядра
В схеме ядра лежат telegram_*-таблицы, которых оно не читает: это
состояние моста той поры, когда мост жил в ядре. Данные переживают код —
таблицы остались на месте, а модуль ведёт своё состояние у себя.
HTTP/SSE API
Справочник по API — Swagger UI на /apidocs
(схема: /v1/openapi.json).
Схема OpenAPI генерируется из аннотаций прямо в коде (src/api.rs,
utoipa) — руками не пишется и не расходится с реальностью.
Локально: подними ядро и открой http://127.0.0.1:8090/apidocs.
Пара вещей, которых не расскажет схема:
- Ответы чатов — SSE (
text/event-stream), по JSON-объекту на событие:text_delta,thinking_delta,tool_call,tool_result,tool_progress,client_op,extra_tool_call,request_resolved,usage,compacting,turn_squeezed,sleeping,image,plan,done,error— плюс зеркало этих же шагов единой лентой элементов:item_started,item_delta,item_completed(см. ниже). На переходный период едут оба набора; ненужный глушится полемmuteвsend_message(списком типов событий) или query-параметромmuteу/live(через запятую).usageприходит после каждого похода к провайдеру — по нему клиент двигает счётчик токенов и расхода, не дожидаясь конца ответа; егоreasoning_tokens— частьcompletion_tokens, ушедшая в рассуждения, а не добавка к ним; долю занятого окна клиент считает отprompt_tokensи потолка модели изcontext_windows(GET /v1/models), вычитая базу —prefix_tokensизGET /v1/chats/{id}/spend. Последний снимок лимитов провайдера отдаёт/v1/statsполемrate_limits;compactingпредупреждает, что ядро сворачивает историю в резюме: пауза передdoneбывает долгой и без отметки выглядит зависанием;turn_squeezed({"freed_chars":N}) — ядро поджало трафик ИДУЩЕГО тура: длинный ход перерос долю порога компакции, и его старый выхлоп схлопнут прямо посреди работы. Ответ продолжается — событие показывают тихо;sleepingоткрывает поток тура, пришедшегося на сон: тур ждёт пробуждения в очереди — клиент показывает «спит…» вместо «печатает…» и кнопку «Разбудить» (POST /v1/sleep/wake), дальше поток идёт как обычно. image— модель тура сгенерировала картинку, ядро сохранило её:{"type":"image","id":"<uuid>","media_type":"image/png","url":"…/images/<id>"}. Клиент рисует картинку поurl; в истории на её месте остаётся текстовый маркер[image <id>] <url>. Байты отдаётGET /images/{id}— ручка вне/v1и потому вне токена: ключ доступа — неугадываемый uuid, ответ неизменен (Cache-Control: immutable).plan— план работы изменился:tasks— состояние целиком, а не дифф, клиент рисует дерево не собирая его из правок. Пустой список — план закрыт (всё сделано), блок пора убрать. В историю не пишется: живое состояние достаётсяGET /v1/chats/{id}/plan, стираетсяDELETEом по тому же пути.- Свёртка истории оставляет след и в самой истории: после компакции ядро
дописывает служебное сообщение-маркер —
role: user, единственный текстовый блок, байт-в-байт"[compacted] Older history was folded into the chat summary here.". Клиент опознаёт маркер точным совпадением текста и рисует границу компакции даже после перезагрузки, а не только по живомуcompacting; рисовать его репликой Господина нельзя (признакmasterу него, как у любого сообщения безsender, истинный). В промпт модели маркер не попадает. В единой ленте элементов маркер отдаётся элементомcompaction, неmachine_note: клиенту ленты текст не нужен, граница — сама по себе вид шага. - Единая лента элементов — одна модель для живого тура, реплея
/liveи чтения истории, чтобы клиент не сшивал/messagesи SSE в свою третью. Элемент несётid,kind,status(running/completed/failed/aborted),turn_id(группировка шагов по турам) и тело по виду:user_message,agent_message,thinking,tool_call(результат вложен, отдельного элемента нет),client_op,image,compaction(виден и в истории, не только в живом туре),machine_note(служебные вставки ядра,note— id вида из реестра фрагментов),plan. Новый вид шага доезжает во все клиенты одной правкой ядра плюсcaseна клиента.- Жизненный цикл на проводе:
item_started(полный элемент, уже рисуемый) → сколько-то типизированныхitem_delta(текст дописывается, прогресс замещается, план приходит целиком) →item_completed— авторитетное финальное состояние. started оптимистичен, completed авторитетен: клиент рисует сразу и правит потом. GET /v1/chats/{id}/items— история той же лентой, пагинация как у/messages(limit,before— id элемента).limitсчитает сообщения-источники, а не элементы: одно сообщение разбирается на несколько элементов или ни одного, так что страница привозит их произвольное число, и «элементов меньшеlimit» началом истории НЕ является — листать до пустой страницы. Id исторических элементов детерминированы:{message_id}:{n}; у живых —{turn_id}:{n}. Послеdoneклиент перечитывает хвост/itemsи заменяет элементы тура; вызовы сшиваются поcall_id, картинки — поimage_id.- Реплика пользователя в живой ленте не едет (свою клиент знает сам,
переподключившийся берёт её из
/items);client_op-элементы живут только в живом стриме и закрываются своимrequest_resolved(resolutionв теле) — иногда уже послеdone.
- Жизненный цикл на проводе:
GET /v1/chats/{id}/live— переподключение к идущему туру: реплей накопленного + живой хвост. События из реплея несутreplayed: true(живой хвост идёт без признака) — контракт: реплей рисует, но не исполняет. Клиент обязан не исполнятьclient_opс этим признаком, чем бы ни кончилась операция; своих эвристик «моложе подключения» не нужно. Операция из реплея без парногоrequest_resolvedещё ждёт ответа — она истечёт своим таймаутом и уедет модели ошибкой, это штатный исход переподключения.- Уровень рассуждения (
off|low|mid|high) выбирается по убыванию силы: полеeffortвsend_message(на один ход) → словоultrathinkв реплике Господина → настройка чата (PUT /v1/chats/{id}/effort) → полеeffortзаписи модели (GET /v1/modelsотдаёт их вefforts) → глобальныйllm.reasoning(он жеeffort_defaultв каталоге). Внутри тура уровень может подняться ещё раз — инструментомthink_harder; понизить его до конца хода нельзя. DELETE /v1/chats/{id}/messages/{message_id}отматывает чат к состоянию до сообщения: удаляет его и всё после. «Переписать» у клиента — это тот же вызов плюс обычная отправка нового текста. Разрушающе и невозвратно — действием по умолчанию клиенту стоит делать ветку (ниже), а стирание оставить вторым пунктом с подтверждением.POST /v1/chats/{id}/fork({before_message, title?}) — неразрушающая правка прошлого: новый чат с копией истории строго доbefore_message, мать не меняется ни на строку. «Переписать в ветке» — форк плюс отправка исправленного текста в ветку. Ветка наследует полку, проект, модель, уровень рассуждения, набор инструментов, профиль компакции, рабочий каталог, резюме компакции (если его якорь попал в копию), блокнот и план; срез посреди тура (tool_use без результата в копии) закрывается той же синтетикой, что у отмены тура, — ветка едет с первого запроса. Связь — в полях чатаforked_from/forked_at_message; первым сообщением ветки лежит служебный маркер[forked] …(в ленте элементов —machine_noteс notefork_marker, в промпт модели не едет). Ветка — обычный чат: форк от форка разрешён, удаление матери ветку не трогает; расход считается своим чатом. Автоматически ядро веток не плодит — форк только жестом Господина.- Действия
client_op:read,write,shell— текстовые, и байтовыеread_bytes(path,offset,size→{"total", "data"}, данные в base64),write_bytes(path,offset,data,truncate→{"total"}) иfind_bytes(path,data— образец,offset— откуда,limit→{"total", "offsets"}). Клиент, который байтовых не умеет, отвечает ошибкой «unknown op action» — договариваться о версиях не нужно. - Служебное действие
list_up(path— каталог,names— имена): клиент идёт отpathвверх до корня ФС и на каждом уровне говорит, какие из имён существуют — ответ{"dirs": [{"dir": "...", "found": ["..."]}]}, уровни отpathвверх. Им ядро собирает файлы проекта (AGENTS.md,.eva/MEMORY.md) в блок[project]на входе в тур, без участия модели, и разрешения оно не заслуживает — только чтение имён. Прилетает лишь тем, кто объявилproject_docs: trueвsend_message; объявивший перестаёт клеитьAGENTS.mdвcontextсам — иначе файлы задвоятся. - Рабочий каталог чата умеет ставить не только модель тулом
workdir, но и сам клиент: поляworkdir/workdir_on(client|server, дефолтclient) вPOST /v1/chatsиPUT /v1/chats/{id}/workdir(nullснимает); читается он полемworkdirв GET чата. Валидация общая с тулом. Так кодовая сессия получает[project]с первого же тура, не тратя итерацию инструментов на «выставь workdir». - Живой вывод долгой операции (
shellу клиента): покаclient_opждёт результата, клиент может читать вывод потоком и постить снимки хвоста вPOST /v1/chats/{id}/ops/progress({id, output}). Снимок ЗАМЕЩАЕТ прошлый (семантикаtool_progress, в который он превращается), режется по границе UTF-8 и ужимается ядром до последних 8 КиБ; секреты затираются на входе, в историю ничего не пишется. Постить разумно — не чаще нескольких снимков в секунду; финальный полный вывод по-прежнему едет вops/result. Для операций подагентов прогресс молча глушится: в стриме тимлида у них нет своего элемента вызова. - У
client_opбывает полеagent: операцию просит подагент, а исполняет её клиент тимлида. Показывать имя обязательно — иначе непонятно, чью правку разрешает Господин. Результат постится как обычно: операция ищется поop_id, чат в пути не важен. - Тулы на один тур: поле
extra_toolsвsend_message({name, description, input_schema?, timeout_secs?, ends_turn?}) объявляет тулы поверх реестра ядра — модель видит их как обычные, а исполняет сам объявивший клиент. Вызов приходит в стрим событиемextra_tool_call {id, name, input}(id совпадает с idtool_call-события этого вызова), ядро стоит и ждёт; ответ клиент постит вops/resultпод этим id — лучше полемblocks([{title, content}]): ядро отрендерит секции канонично (== title), тем же форматом, что у батчевыхread_fileиshell.ends_turn: trueделает тул вердиктом — успешный вызов закрывает тур (так живёт телеграмныйtelegram_skip). Имя, тенящее тул реестра, — 400. Контракт реплея тот же, что уclient_op: реплей рисуют, но не исполняют. SDK: в Rust —Turn::extra_tool::<T>()+ событиеExtraToolCall {call, response}с одноразовым респондером (брошенный без ответа сам шлёт ядру ошибку), в Python —Turn.extra_tools([ExtraTool(...)])+Kernel.tool_result. - Запрос ядра к клиенту (
client_op,extra_tool_call, вопросask_questions) — сущность со снятием: любой исход уезжает в живой стрим событиемrequest_resolved{id, kind: "op"|"question", reason, question?}.reason:answered— ответ получен (приходит всегда, включая ответ самого клиента),expired— срок вышел,turn_ended— тур кончился, не дождавшись,cancelled— тур отменили. Клиент по нему закрывает диалог и вычищает свою очередь одобрений — не гадая таймером «чуть короче ядрового». У операцииid— еёop_id; у вопросаid— id tool_call-вызоваask_questions, аquestion— номер вопроса в его массиве. - Набор кнопок задаёт ядро, а не клиент:
client_opнесётdecisions(сегодняallow,allow_session,allow_always,deny; у служебногоlist_upполя нет — диалог ему не рисуют), в input tool_call-событияask_questionsвклеивается словарь мета-решенийdecisions(discuss,answer_in_text) поверх модельныхquestions— и в живое событие, и в одноимённый исторический элемент ленты: карточка оборванного вопроса после перезагрузки рисует те же кнопки. Клиент рисует известные ему решения, неизвестные молча пропускает — новое решение вводится правкой ядра, без выпуска клиентов. Выбранное решение операции клиент возвращает полемdecisionвops/result;denyбезerrorядро превращает в канонический отказ для модели. - Инварианты диалога одобрения, обязательные для всех клиентов: выбор
всегда порождает явное решение — молчаливое закрытие диалога
запрещено (закрыли крестиком — это
deny, и он уезжает ядру);Escв диалоге всегда значит «отказ», даже при перенастроенных клавишах — пользовательская настройка не должна превращать безопасное умолчание в разрешающее. - Зарезервировано контрактом:
kind: "permission"— запрос профиля разрешений (например{fileSystem: {write: [...]}}), на который клиент отвечает выданным подмножеством; ядро таких запросов пока не задаёт. /v1/youtube/*— библиотека Господина: подписки на каналы и история просмотра. Ядро тут общая полка клиентов (телефон, браузер, десктоп видят один список), в сам YouTube оно не ходит. Правила, которые клиентам не нужно повторять каждому у себя:PUT /v1/youtube/history/{video_id}— идемпотентная отметка просмотра: видео поднимается наверх истории со свежими метаданными. Место остановки хранится только между 2% и 97% длины ролика — ниже это случайный клик, выше досмотрено, а при неизвестной длине (duration_s≤ 0) мерить не от чего; во всех этих случаяхposition_sстановитсяnull, но запись остаётся: это история просмотров, а не список недосмотренного. Ответ отдаёт строку такой, какой она легла, — по нему клиент видит, пережило ли место остановки.- История помнит 200 последних видео, хвост уходит на каждой отметке.
beforeвGET /v1/youtube/history— курсор поvideo_idпоследней показанной строки, а не время: пачка, приехавшая одной секундой (импорт истории с телефона), при сравнении по времени теряла бы строки на границе страницы. Неизвестный id — 400, а не пустая страница.
- Кривое тело запроса (не-JSON, тело не сходится со схемой) axum отвергает сам — статусом 422 Unprocessable Entity, не 400; свои 400 ядро отдаёт уже поверх разобранного тела (кривой курсор, невалидные значения полей). Клиентской ветке «я прислал не то» стоит ждать обоих статусов.
- Аутентификации у ядра нет — её вешает reverse-proxy перед ним (у нас
Caddy требует
X-Eva-Token);/apidocsи/v1/openapi.jsonоткрыты.
Возможности
| Провайдеры | trait LlmProvider, generic-типы сообщений и стрим-событий; anthropic (сырой HTTP + SSE, без SDK), openai (Chat Completions — на нём же говорят llama-server и vLLM, то есть это дверь к локальным весам; reasoning-поле OpenRouter, вырезание inline <think>, нормализация oneOf→anyOf в схемах инструментов) и mock для тестов. Модель можно переопределить на каждое сообщение, а клиенту предложить выбор per-чат (llm.models + PUT /v1/chats/{id}/model) |
| Уровень рассуждения | ступень off, low, mid, high на тур, а не флаг на провайдера: llm.reasoning глобально, effort в записи модели, настройка чата, поле effort в send_message, слово ultrathink в реплике Господина и инструмент think_harder — поднять уровень посреди хода, когда задача оказалась глубже, чем выглядела. У anthropic ступень — расширенное мышление: мысль капает в стрим и возвращается провайдеру с подписью внутри тура, дальше тура не живёт |
| Роли моделей | llm.model — секция: general — основная, fastest — мгновенная для декоративных реплик, smartest — самая сильная для решений, где качество суждения важнее латентности и цены (сон), advisor — второй наблюдатель рабочего тура (см. Советчик); плюс summary_model и code_model. Поверхность может дать свой дефолт на тур (surface_model) |
| Кэш промпта | Anthropic-style cache_control, TTL "5m"/"1h"; промпт собран под префикс-кэш. Работает на нативном Anthropic и через шлюзы, проксирующие маркеры (OpenRouter, Nous Portal — Claude/Kimi/Qwen). prompt_cache_telegram — отдельный режим для телеграм-туров, вплоть до "off" |
| Память | мой собственный алгоритм: BM25 × полураспад по времени × важность, с подкреплением при обращении. BM25 — базис, который переживёт любую смену модели; эмбеддинги (memory.embedding_model) — опциональный усилитель поверх (гибридный скоринг), выключается в ноль. Виды core/fact/event, дедуп при сохранении |
| Сон | раз в сутки, дождавшись тишины, ядро идёт этапами. Этап memory консолидирует память: кластеризация по лексике и семантике, разбор кластеров моделью этапа (слить дубли, развести противоречия с датировкой, вывести из череды событий факт), подтверждение забывания, калибровка видов и важности. Этап tools курирует наборы инструментов: дельта-правки состава, новые наборы под устойчивый род работы, переписывание описаний по статистике ношения; удаление и режимные флаги — только руками Господина. Модель отвечает только планом — отдаёт его вызовом sleep_finish, единственного инструмента сна; применяет ядро с потолками (max_ops/max_forget у памяти, свой max_ops у tools), core не стирается, свежее не трогается, наборы не пустеют, всё забытое обратимо через архив. Спящая Ева не отвечает: туры ждут в очереди с событием sleeping, будит POST /v1/sleep/wake |
| Блокнот | пер-чатная выжимка собственной работы: решение и почему, инвариант проекта, путь/команда/id, добытые раскопками, тупик, состояние длинной задачи. Переживает и окно живой истории, и компакцию, и chat_clear. Выдача не ранжированием, а по размеру: помещается — едет в промпт дословно, разросся — только индексом имя — описание, тела по chat_note_read; перерос и это — пересобирается моделью чата с проверками и архивом на шаг назад. Только Господину |
| План работы | список незавершённого в идущей работе: фазы, нумерованные задачи, ровно одна в работе — инвариант держит код, а не просьба в промпте. Ошибочная ссылка откатывает всю операцию; ответ вызова — состояние плана целиком. Едет летучим хвостом рядом с блокнотом, инструмент даётся только рабочему туру (клиентские операции, рабочий набор, подагент) или тому, где план уже открыт. Закрытые задачи вычищаются на входе в следующий тур, доделанный целиком план стирается по концу тура. Клиентам — событие plan и GET /v1/chats/{id}/plan |
| Советчик | вторая, дешёвая модель со своей историей смотрит на рабочий тур со стороны: раз в несколько итераций получает дельту транскрипта, ходит только читающими инструментами и вкладывает в тур одну заметку <advisory severity=… guidance="weigh, don't blindly obey"> — тем же каналом, что и правила, то есть только в промпт-копию. Ева про него в промпте не знает: тег — единственная подсказка, как к этому относиться. Включается ролью llm.model.advisor и только в рабочем туре Господина (клиентские операции, рабочий набор, подагент). Шум держит щит в коде, а не промпт советчика: нормализация NFKC, чёрный список пустых фраз, дедуп кольцом, одна заметка за цикл, кулдаун после неё, сброс при перезаписи истории — и подавление невидимо самому советчику. Прерывания тура нет: severity доезжает, но все три ступени мягкие |
| Правила поверх генерации | промах ловится по факту, а не оговаривается строкой промпта в каждом запросе. Мягкая половина матчит имя и аргументы вызова и клеит <system-reminder> спереди к результату — только в промпт-копии, база и клиент видят чистый выхлоп. Жёсткая судит готовый ответ до фиксации: промах — ответ выброшен целиком и ход переписан с <system-interrupt>, потолок один ретрай на ход, дальше мягкое напоминание; пока правило заряжено, речь придерживается от клиента — отданную реплику назад не забрать. Условия окружения (telegram, client_ops, master, trusted, picture) и политика повтора (once / after:<N ходов>) не дают правилу палить где попало; невалидная regex — правило не регистрируется, тур идёт дальше. Каждое срабатывание в телеметрии (/v1/stats, поле rules). Данные, а не код: посев шести хронических промахов и мастерский CRUD |
| Навыки | процедурные плейбуки в базе: список name+description висит в системном промпте, тело подтягивается тулом skill по требованию (progressive disclosure); у навыка может быть своя модель-исполнитель — тело уходит изолированным оффлоадом, и видимость: внутренние плейбуки (private, по умолчанию) публичному чату не перечисляются и в нём не исполняются. CRUD — только Господину |
| Агентские команды | подагент — чат ядра со своей моделью, историей и набором инструментов; тимлид спавнит его и переписывается в обе стороны (синхронно agent_wait, асинхронно — блок в [state]). Расход считается сам, потому что чат. Глубина ограничена одним уровнем, ширина — chats.max_agents |
| Наборы инструментов | именованный срез реестра под род задачи: список наборов висит в промпте, Ева переключает его тулом toolset (на чат или на тур). Скрытое остаётся видно ей именами и назначением, без схем, и возвращается тулом tool_load; имени не знает — tool_find ищет по смыслу запроса (лексика, а модель — только если та промахнулась). Узкий набор нигде не тупик. CRUD — только Господину |
| Инструменты | trait Tool + реестр с детерминированным гейтингом (master / trusted / privileged) и явными группами; встроенные (память, навыки, крон, личности, история, веб, shell, вопросы кнопками), внешние — TOML-декларация + JSON через stdin/stdout, и MCP-серверы (stdio или streamable HTTP) с гейтингом доступа |
| Машина Господина | файлы и шелл исполняет подключённый клиент, а не хост ядра: read_file/write_file/find_pattern/local_shell плюс двоичные read_hex/write_hex/find_hex — дамп, патч по смещению и поиск образца, с расшифровкой в любой кодировке (cp932, utf-16le). Клиент возит байты, формат и разбор — в ядре; там же собирается write_diff — чтение, уникальная замена, запись, отдельной операции клиенту не приходит, — и apply_patch: пачка ханков на несколько файлов одним вызовом (формат Codex, без номеров строк), с проверкой всего патча против диска до первой записи. find_pattern ищет регулярным выражением построчно и потоком, обрывая чтение на нужном числе находок: попадание в начале многогигабайтного лога стоит миллисекунды. Операция подагента приходит на одобрение в стрим тимлида. Запрос к клиенту (операция, вопрос кнопками) — сущность со снятием: любой исход уезжает событием request_resolved, и диалог у клиента закрывается по факту, а не по таймеру; набор кнопок (decisions) задаёт ядро, а не пять клиентов вручную; реплей живого стрима помечен replayed: true и по контракту рисуется, но не исполняется |
| Файлы проекта | блок [project] в промпте: ВСЕ AGENTS.md от корня проекта (маркеры context.project.root_markers, дефолт .git/.jj/flake.nix) вниз до рабочего каталога чата, ближний — последним, плюс память проекта .eva/MEMORY.md. Правила сборки одни на всех клиентов и живут в ядре: общий бюджет (16 КБ на AGENTS.md плюс 8 КБ на память проекта, усечение с подписью, какой файл пострадал), AGENTS.override.md заменяет соседний AGENTS.md, каждый кусок несёт путь-происхождение. Серверный каталог ядро сканирует само, клиентский — служебной операцией list_up без участия модели (флаг project_docs в send_message); читается один раз на входе в тур — блок байт-стабилен, кэш промпта живёт |
| Веб | web_fetch, web_search (свой SearXNG), википедия/фэндом, PsychonautWiki, booru-доски с учётками, всё — опционально через прокси |
| Shell | команды локально или на удалённом хосте по ssh — куда идут, решает оператор конфигом, не модель; только в турах Господина |
| Мультимодальность | картинки в обе стороны. Мультимодальным моделям (флаг в llm.models) они уходят прямо в сообщение; прочим — пометкой в тексте. Господин прикладывает картинки в запрос, Ева вплетает найденные в ответ — галереей в GUI, инструментом поверхности в телеграме. Правила показа зависят от окружения тура |
| Кодинг | code_task — оффлоад самодостаточного вопроса модели посильнее (llm.code_model); работу внутри репозитория берёт agent_spawn с кодовым набором |
| Рисование | картинки на выход двумя путями: модель тура с флагом image_output рисует сама (ядро просит modalities, ловит картинку в стриме), прочим даётся оффлоад-инструмент image_generate на llm.image_model — генерация с нуля и правка существующей (source: id, last — свежайшая картинка чата, нарисованная или присланная фото, путь к файлу через файловые руки — машина Господина или хост ядра, — URL). Байты живут в блоб-сторе (images.dir, ретенция images.keep), наружу — событие image и публичная ручка GET /images/{id} (ключ — неугадываемый uuid); в историю ложится маркер, не base64. Инструмент — только Господину: картинка стоит живых денег |
| Чаты | ephemeral (умирают от тишины, TTL настраивается) и persistent (навсегда в списке); безымянный чат получает название саммари первого сообщения. Клиенты — окна в одни и те же разговоры: folder — человеческая полка «о чём чат», перекладывается из любого клиента в любой момент (PUT /v1/chats/{id}/folder, default — «Входящие»); чаты специализированных поверхностей (telegram, mtl) помечены surface и в общем списке не показываются — их отдаёт явный фильтр surface=<имя|any>; кодовые сессии живут на полке code с привязкой к проекту отдельным полем project (POST /v1/chats, фильтр project=<имя> в GET /v1/chats — сочетается с folder/surface); полки автоматики из chats.quiet_folders (по умолчанию youtube, cron, code) тихие — их чаты видны только по явному фильтру folder/project/surface, при этом GET /v1/chats/folders полки отдаёт как обычно. Фильтр folder принимает точное имя либо префикс по хвостовой *: folder=mtl:* — чаты всех игровых полок одним списком. Удаление мягкое: чат уходит из списков, история цела и восстановима. Правка прошлой реплики — не порча истории, а ветка: POST /v1/chats/{id}/fork копирует историю строго до выбранного сообщения в новый чат вместе с резюме, блокнотом и планом (мать цела; срез посреди тура закрывается той же синтетикой, что у отмены), связь — forked_from/forked_at_message, разрушающий DELETE .../messages/{id} остаётся вторым, явным жестом |
| Внимание и одобрения | Ева заводит чат на свою проблему в папке problems, чинит сама, а не вышло — поднимает needs_attention (такие чаты клиенты показывают первыми) и по своему решению стучится Господину. Разрешение, которое не нужно сию секунду, уходит в очередь одобрений — тур не блокируется |
| Уведомления | четыре пути, включать можно сколько угодно: чат ядра, пуш на телефон (ntfy-совместимый), инструмент из реестра с готовыми аргументами (notify.tool — так уведомление уходит в мессенджер, о котором ядро не знает ничего, кроме имени инструмента), тур Евы по инструкции Господина (master+trusted, но аппрувы сохраняются) |
| Гигиена контекста | агентный чат распухает выхлопом инструментов, не разговором. В промпте: старые tool_result схлопываются до огрызка, свежие капаются (оконным читателям — read_file, find_pattern, read_hex, history_search — режется хвост по своему потолку: середина у нарезанного окна и есть то, за чем звали); живое окно свежего трафика меряется и сообщениями, и символами — выхлоп одной итерации ложится одним сообщением, и счёт сообщений врёт в разы; за живым окном схлопывается и вход вызова — длинные строки (тело записанного файла, патч, скрипт) заменяются пометкой длины, путь и флаги остаются; выхлоп, прочитанный позже заново (тот же файл — в том числе окном, накрытым более широким чтением, — та же или расширенная выдача памяти), вытесняется ссылкой на свежую копию, в том числе внутри одного тура; чтение файла, переписанного позже (write_file, write_diff, write_hex, apply_patch), помечается как неправда — перечитай; длинный тур поджимает СВОЙ трафик прямо на ходу, перерастя долю порога компакции, и говорит об этом модели строкой в [state], клиенту — событием; байт-в-байт повторный вызов чистого читателя не исполняется вовсе; когда живая история перерастает порог — старая часть сворачивается в резюме (секция context). Свёртка — передача смены, а не пересказ: промпт суммаризации требует прогресс/решения/ограничения/следующие шаги, резюме подаётся в [state] рамкой «работа уже идёт — продолжай, не переспрашивай», самые свежие реплики Господина переживают её дословно под своим бюджетом (compact_verbatim_percent), а в историю ложится служебный маркер границы для клиентов. Модель с объявленным context_window меньше накопленного контекста получает свёртку ДО первого запроса (смена модели чата, override, запасная по цепочке — запасная, куда контекст не влезает, пропускается). Там же чинятся пары tool_use/tool_result: висячий вызов от умершего процесса закрывается байт-стабильной синтетикой, сирота выбрасывается — обрыв не валит следующие туры чата. База при этом всегда полная, клиенты видят всё |
| Личности | база «кто есть кто»: опаковые алиасы (клиент namespace’ит их сам — tg:123, что угодно) указывают на человека; незнакомый алиас заводит новую личность, дубликаты сливаются merge’ем — и авторство сообщений переезжает следом. Понятия «платформа» у ядра нет. Спикер уходит провайдеру отдельным маркером, тело — цитатой: подделать маркер текстом нельзя |
| Доверие | master — чей это агент (имя + алиасы личностей); master-only инструменты, недоверенное окружение с урезанной памятью и потолками ответа для гостей. Личное из памяти требует обоих признаков сразу: тур Господина и доверенное окружение — в группе его личное придержано, даже когда спрашивает он сам |
| Крон | интервальные таймеры, которыми я бужу саму себя с задачей; у таймера может быть дедлайн; журнал исполнений; каждая джоба живёт в собственном чате на полке cron (заводится с ней, уходит с ней — в чужую переписку таймер не селится) со своими моделью и профилем компакции; whitelist инструментов джобы сверяется с реестром при создании и правке; cron_fire / POST /v1/cron/{id}/fire — выстрелить сейчас, сдвинув расписание |
| Модули | новые руки и целые поверхности — отдельными процессами на eva-sdk: инструменты попадают в реестр наравне со встроенными (telegram_react в группе telegram), плюс стадии middleware с правом вето и ведение туров через API. Подключение по UDS или TCP; перезапуск модуля ядро переживает без своего рестарта. Чужие MCP-серверы — отдельная сущность, они гости с префиксом mcp_ |
| Телеграм | поверхность целиком ведёт модуль: свой токен, своя база, своё поведение (триаж, дебаунсы, меню настроек, живое био, inline). Ядру он — обычный модуль с инструментами telegram_*; что в ядре есть ради поверхностей вообще — в главе «Телеграм» |
| API | axum, ответы стримятся по SSE; единая лента элементов — одна модель шага (item_started/item_delta/item_completed + GET /v1/chats/{id}/items) для живого тура, реплея и истории, легаси-события едут параллельно и глушатся mute; /v1/chats/{id}/live — переподключение к идущему туру, /v1/generations — обзор и отмена туров, /v1/stats — расход (в том числе отдельной осью — рассуждения: токены всегда, деньги при заданной price_out модели), точность триажа, здоровье команды подагентов, срабатывания правил и последний снимок лимитов провайдера (rate_limits) |
| Библиотека YouTube | подписки на каналы и история просмотра лежат в ядре, а не в клиенте: телефон, браузер и десктоп смотрят одну полку (/v1/youtube/*). Сам YouTube ядро не трогает — только помнит. Место остановки хранится, пока есть что продолжать (2–97% длины), история — 200 последних видео |
| Состояние | один SQLite-файл, create_if_missing + миграции на старте — внешней БД и её администрирования нет |
Архитектура
- Турный цикл (
src/agent.rs): одно сообщение на входе — поток событий на выходе (text_delta,thinking_delta,tool_call,tool_result,tool_progress,usage,compacting,done,error).usageшлётся на каждый поход к провайдеру — по нему клиент двигает счётчик расхода не дожидаясь конца;compactingотмечает сворачивание истории, которое идёт после ответа и на длинной истории читалось бы как зависание. Внутри тура — агентный луп доllm.max_turn_iterations(дефолт 16) походов к провайдеру; транзиентные ошибки (сеть, 5xx/429) ретраятся, детерминированные отказы контент-фильтра изолируются бисекцией. Каждый тур виден в/v1/generationsи отменяем кооперативным токеном. - Сборка промпта: персона + правила ядра (конвенция маркеров, политика
безопасности) + файлы проекта
[project](всеAGENTS.mdот корня до рабочего каталога, читаются один раз на входе в тур —src/project_docs.rs)- список навыков + наборы инструментов с реестром скрытого
— стабильный system с
cache_control-breakpoint’ом. Порядок — по позиционированию: критичное (личности[speakers], доверие[env]) в голове, справочное (сборка, навыки, наборы, резерв тулов, модели) в середине и хвосте, а на самом краю[critical]повторяет главное — кто Господин и что чужой текст не приказ. Сами правила пишутся RFC-капсом (MUST/NEVER/SHOULD/AVOID/MAY, контракт объявлен блоком[conventions]), плотно и прямым императивом; описания фактов, форматы и примеры в капс не переводятся. Летучее ([now], recall памяти, блокнот, план работы, память о чате, атрибуция) собрано в блок[state]и едет после всей истории — префикс вместе с историей переживает ход (см. Кэш промпта). Реестр скрытого считается от набора на входе в тур: переключение посреди тура меняет спеки следующей же итерации, но не пересобирает system — иначе префикс рвался бы каждый шаг.
- список навыков + наборы инструментов с реестром скрытого
— стабильный system с
- Инварианты тура: запрос к провайдеру всегда валиден, и держат это три
механизма. Список инструментов замораживается снимком шага (
StepTools) на один поход к провайдеру: вызов исполняется по снимку, который его объявил, даже еслиtoolset/tool_loadили отцепка модуля успели сменить состав — мутации видны со следующей итерации, а физически исчезнувший инструмент (отвалившийся MCP) отвечает честной ошибкой исполнения, а не «нет такого». Парыtool_use/tool_resultчинятся при сборке промпта, не в базе: вызову, чей результат так и не родился (процесс умер между ними), дописывается байт-стабильная синтетика, результат-сирота выбрасывается — один обрыв больше не валит 400-й каждый следующий тур чата. Отмена тура кооперативна и даёт доиграть:cancel_*коротко ждёт штатного сворачивания, инструменты с внешним исполнителем (shell, клиентские записи иlocal_shell, MCP-вызовы) на отмене ждут до их собственного таймаута, а не дропаются — успевший прийти результат побеждает гонку с отменой и ложится в историю настоящим. Мок-провайдер проверяет сопряжённость пар на каждом запросе — весь тестовый набор стережёт инвариант попутно. - Служебные вставки (
src/fragments.rs) — реестр всего текста, который ядро дописывает в историю от чужого имени: блоки[state], предупреждение[wrapup]о конце итераций, ступени давления контекста[window](настраиваемые текстыcontext.pressureопознаваемы, потому что вставка всегда оборачивает их маркером), заметки[kernel], огрызки элизии, маркеры картинок, граница компакции[compacted](единственная вставка, живущая в базе, — для клиентов; в промпт она не едет). Каждая вставка носит байт-стабильный маркер и опознаёт себя сама — один предикатis_machine_textотвечает всем местам разом: окно живой истории меряется только репликами людей (синтетика ход не открывает и границей компакции не становится), компакция не тащит в резюме «модель залипала на инструменте»,history_searchпо умолчанию ищет только речь (флагinclude_serviceвозвращает всё). Классификация матчит только явные маркеры реестра — неразмеченная вставка не может объявить своим чужой текст. Новый вид служебного текста начинается с записи в реестре. - Правила поверх генерации (
src/rules.rs) вмешиваются уже внутри тура: совпало имя вызова и regex по его аргументам — напоминание клеится спереди к результату в промпт-копии, в базу и клиенту уходит чистый выхлоп. Место вклейки — там же, где кап на свежий tool_result: сначала снимается служебная метка «пусто», потом режется выхлоп, и только потом ложится напоминание. Сработавшее пишется вstream_rule_statи в лог. - Советчик (
src/advisor.rs) вмешивается тем же каналом, но с другой стороны: вторая дешёвая модель раз в несколько итераций рабочего тура получает дельту транскрипта, ходит читающими инструментами и клеит в промпт-копию<advisory>. Щит от шума (нормализация, чёрный список, дедуп, одна заметка за цикл, кулдаун) живёт в коде и самому советчику невидим. - Размышления хранятся блоком
thinkingв реплике Евы и уезжают клиентам вместе с историей, но в контекст модели не возвращаются:db::to_chat_messagesвыбрасывает их на входе в историю (провод требует подписанных провайдером блоков, а чужая вчерашняя мысль скорее мешает). Сообщение, кроме мысли ничего не содержавшее, из истории исчезает целиком — пустую реплику провайдеры отвергают. - Хранилище (
src/db.rs): один SQLite (db.path), файл создаётся сам, схема и миграции применяются на старте. Таблицы: чаты, сообщения, память, навыки, личности и алиасы, крон-джобы и журнал, расход (spend), правила и их срабатывания, заметки советчика. Плюс телеграм-таблицы, которых ядро не читает: поверхность ведёт своё состояние у себя, а данные переживают код. - Супервизоры (фреймворк eva): компоненты
server(axum API),chats(жнец временных чатов — вместе с чатом уносит и его подагентов),cron(планировщик). Graceful shutdown из коробки. - Модули — то, что живёт вне ядра: инструменты и целые поверхности, отдельными процессами на eva-sdk (см. Модули и SDK). Телеграм — уже такой модуль, своих поверхностей в ядре не осталось.
Модули и SDK
Модуль — отдельный процесс, расширяющий саму Еву. Он даёт ей новые руки (инструменты, которые ядро добавляет в свой реестр наравне со встроенными), может стоять на пути её действий (middleware) и может приводить целую поверхность — мессенджер, почту, голос, — ведя туры через API ядра.
Модуль — часть Евы, живущая отдельным процессом. Это не то же самое, что MCP-сервер: тот — гость, чужой инструментарий, подключённый снаружи. Разница видна во всём:
| модуль | MCP-сервер | |
|---|---|---|
| чей | наш, часть Евы | чужой, сторонний |
| имена инструментов | telegram_react | mcp_rzd_search |
| группа | своя (telegram) | mcp + mcp_<сервер> |
| знает о туре | да, получает контекст | нет |
| может перехватывать действия | да | нет |
| может вести туры | да | нет |
| объявляется в | modules.servers | mcp.servers |
Сторонние серверы описаны отдельно — MCP-серверы. Всё ниже — про модули.
Транспорт у них общий (line-JSON-RPC), и это деталь реализации, а не общность природы: ядро отличает модуль от гостя по тому, как он объявлен, и обходится с ними по-разному.
Из чего собирается модуль
Каркас — фреймворк eva, тот же, на котором стоит ядро. Он даёт конфиг-секции с наложением файлов (секреты живут отдельно от остального), логи и супервизор компонентов с корректным завершением. Писать под модуль свой разбор YAML, свою инициализацию логов и свою склейку вечных задач не нужно — и не надо: это ровно те грабли, на которых мост однажды оказался немым в journal.
Предметная часть — eva-sdk: инструменты, стадии и клиент API ядра.
Rust-версия лежит в sdk/ этого репозитория, Python —
eva/sdk-py.
Модуль, таким образом, выглядит так: App фреймворка читает секции,
Supervisor держит компоненты, а внутри компонентов живут Module и
Kernel из SDK. Пример целиком —
eva/telegram-bridge: два
компонента (telegram — поверхность, tools — сокет с инструментами), он
же ведёт наш телеграм.
Две стороны модуля
Внутрь ядра — модуль объявляет инструменты и стадии; ядро зовёт их, передавая контекст тура.
Наружу из ядра — модуль ходит в HTTP API: заводит чаты, ведёт туры и читает их поток, дёргает модель одноразовым вызовом.
Инструментальному модулю (погода, домашняя автоматика) хватает первой стороны. Поверхности нужны обе: она ловит входящее у себя, ведёт тур в ядре, доставляет ответ обратно и по дороге обслуживает свои инструменты.
Имена и группы
Модуль объявляет короткие имена — react, skip, send_photo. Полное
имя даёт ядро: <модуль>_<инструмент>, то есть telegram_react. Оно же
кладёт инструмент в группу с именем модуля.
Группа работает как у встроенных: ею целиком гейтят доступ (telegram в
master-списке поверхности) и переключают в её меню настроек. Для модели,
промптов и настроек инструмент модуля неотличим от родного — и это
намеренно.
Вход инструмента
Аргументы объявляются типом — тем же #[data], которым пишутся конфиги
ядра. Из типа рождается JSON Schema для модели (доки полей становятся
описаниями, Option — необязательным полем), он же приезжает в
обработчик разобранным:
#![allow(unused)]
fn main() {
#[data]
struct React {
/// Эмодзи из набора реакций телеграма.
emoji: String,
/// Другое сообщение; без него — то, на которое отвечаешь.
message_id: Option<i64>,
}
}
Схема и разбор идут от одного типа, так что разъехаться не могут: то, что
обещано модели, — ровно то, что инструмент прочтёт. Вход не по схеме —
ошибка инструмента, модель её читает и зовёт заново. Инструменту без
аргументов есть NoInput.
В Python то же самое: вход — датакласс, тип берётся из аннотации второго параметра обработчика, схему из него рождает adaptix.
Контекст вызова
Инструмент модуля зовут вне его собственного запроса, поэтому вместе с
вызовом приезжает контекст тура — без него поверхность не знает, чей
ответ гасит skip и к какому сообщению цеплять картинку:
{
"chat_id": "3095944a-…",
"sender": "p7",
"master": true,
"trusted": false,
"privileged": true,
"client_ops": false,
"anchor": { "chat_id": -1001234567890, "message_id": 4471 }
}
client_ops говорит, что тур ведёт клиент с руками (app/tui): пока он
открыт, модулю достижима машина клиента — см. Kernel::ops ниже.
anchor появляется, только если тур привязан к внешней поверхности, и
проставляет её тот же модуль при запуске тура.
Что модуль просит у ядра
| ручка | зачем |
|---|---|
POST /v1/chats | завести чат под внешний диалог (дальше маппинг хранит модуль) |
POST /v1/chats/{id}/messages | тур; ответ — поток событий (text_delta, tool_call, done) |
GET /v1/chats/{id}/messages | хвост истории: достать цитату, уехавшую из окна |
POST /v1/chats/{id}/cancel | кнопка «отмена» на своей стороне |
POST /v1/complete | одноразовый вызов модели без тура и истории — триаж, служебные строчки; cache: true кэширует повторяющуюся system-шапку, schema (JSON Schema) просит ответ объектом этой формы — текст ответа тогда и есть этот JSON, но разбирать его всё равно стоит настороженно: апстрим без поддержки схему молча игнорирует |
POST /v1/stats/triage | итог одной пачки триажа: engaged — сколько сообщений судья вовлёк, responded — на сколько из них ответ действительно ушёл. Разрыв копится в /v1/stats как triage.misses: растёт — судья будит модуль впустую |
GET /v1/tools | реестр инструментов: имена, описания и схемы (ровно те, что уезжают модели), группы, иконки, гейты достижимости (trusted_only, client_only) и on_demand — для меню |
GET /v1/models, PUT /v1/chats/{id}/model | выбор модели чата |
POST /v1/persons/resolve | внешний id → личность ядра |
GET /v1/config | снимок настроек ядра (секреты затёрты) — следовать им, а не дублировать в своём конфиге |
POST /v1/ops/read_bytes, POST /v1/chats/{id}/ops/read_bytes | кусок файла с машины ядра или клиента; в SDK — Kernel::ops(Side::…) с read_bytes/read_all. Только чтение; клиентская сторона живёт, пока в чате открыт тур client_ops-клиента, и клиент вправе спросить Господина |
Поля запроса тура, нужные именно поверхности: anchor, chat_title,
reply_to_ext_id/reply_to_text, guest, max_tokens, brief,
surface_model, effort, surface_effort, tools.
Блокнот чата (GET /v1/chats/{id}/notes, метод Kernel::notes) поверхности
нужен там, где решение принимается мимо тура: в туре блокнот кладёт в промпт
само ядро, а одноразовый вызов модели истории не видит вовсе. Слова, которыми
такой промпт объявляет блок, живут в SDK — модуль notes:
Kernel::notes_prompt отдаёт рамку, notes::notebook собирает готовый блок
из закреплённых заметок. Своя копия формулировки в каждой поверхности
расходится с остальными на первой же правке, и молча.
Аутентификации у ядра нет: модуль на той же машине ходит на 127.0.0.1,
модуль снаружи — через тот же reverse-proxy, что и клиенты.
Что чьё
Граница простая: ядро отвечает за Еву, модуль — за свою предметную область.
- в ядре: личности, память, история чата, туры, реестр инструментов, политика доступа к опасным инструментам;
- в модуле: всё, что знает только он, — токены поверхности, свой whitelist, дебаунсы, потолки и краткость, кэш вложений, привязка «внешний чат → чат ядра». Модуль держит свою базу и в базу ядра не лезет.
Правило рабочее, а не эстетическое: модуль перезапускается и обновляется отдельно от ядра, а общая база сделала бы их одним целым.
Живучесть
- Модуль объявляется в
modules.servers(явная запись — одобрение по факту) или кладёт сокет вmodules.socket_dir: тогда он виден вmodule_listи включается инструментомmodule_approve, а решение живёт в базе ядра; - уход и возврат сокета ядро замечает на лету: перезапуск модуля не требует перезапуска ядра;
- пока модуль лежит, его инструменты остаются в реестре из кэша — модель видит их и получает внятную ошибку вызова, а не пустоту.
Скелет
use eva::{data, cli::App, component_configs::ComponentConfigs, supervisor::Supervisor};
use eva_sdk::{Kernel, KernelConfig, Module, ToolCtx, ToolsConfig};
#[data]
struct Forecast {
/// Город, для которого нужен прогноз.
city: String,
}
#[data]
struct Config {
/// Как достучаться до ядра: url, токен прокси, сколько ждать на старте.
/// Структура общая для всех модулей — своих полей заводить не нужно.
kernel: KernelConfig,
}
async fn entrypoint(_args: CliArgs, configs: ComponentConfigs) -> eyre::Result<()> {
let cfg = configs.get::<Config>();
// подключиться и дождаться, пока ядро ответит, — одной строкой.
// Модуль стартует вместе с ядром, а на передеплое и раньше
let kernel = Kernel::up(&cfg.kernel).await?;
// сторона «внутрь»: инструменты, которые ядро добавит в реестр как
// weather_forecast в группе weather
let module = Module::new("weather")
// чем модуль представляется Еве, когда набор инструментов свернул
// его в строку: по этой фразе она решает, догружать ли семейство
.about("Weather forecasts by city")
.tool(
"forecast",
"Прогноз на завтра в указанном городе",
|_ctx: ToolCtx, input: Forecast| async move { Ok(format!("в {} дождь", input.city)) },
);
Supervisor::new(configs)
// сокет, корректная остановка и одноразовость — внутри component()
.add::<ToolsConfig>(module.component())
.wait_for_completion()
.await
}
fn main() -> eyre::Result<()> {
App::default()
.env_prefix("WEATHER_")
.require::<Config>("weather")
// секция сокета живёт в SDK — своей заводить не нужно
.optional::<ToolsConfig>("tools")
.run(entrypoint)
}
Поверхности добавляют второй компонент — свой цикл: поймала входящее,
kernel.turn(chat_id, text).anchor(…).send(), прочитала поток, доставила
ответ. Каждый компонент — своя секция конфига и свой лог-скоуп.
Поверхность добавляет к этому свой цикл: поймала входящее —
kernel.turn(chat_id, text).anchor(…).send(), прочитала поток, доставила
ответ.
Хуки: middleware и события
Два разных механизма, и путать их дорого:
| middleware | on-события | |
|---|---|---|
| когда | ДО действия | ПОСЛЕ факта |
| ядро ждёт? | да, синхронно | нет |
| может отменить? | да | нет |
| цена ошибки | тормозит действие | ничего |
| чем платим | задержкой каждого действия | ничем |
Правило выбора простое: нужно помешать — middleware; нужно узнать — событие. Логировать через middleware так же неверно, как пытаться запретить что-то из обработчика события.
Статус: сторона модуля есть в обоих SDK (
Module::stage,@module.stage). Сторона ядра — разбор цепочки, журнал иon-подписки — в работе; исходный дизайн —~/dev/eva/todo/hooks-middleware.md, контракт исполнителя добит по мотивам хуков Codex и зафиксирован ниже.
Middleware: перехват с правом вето
Стадия — точка, где ядро само собирается что-то сделать и согласно подождать чужого решения.
| стадия | что перехватывает | что можно |
|---|---|---|
turn.start | начало тура | отменить тур, подправить мету и промпт |
tool.call | вызов инструмента | запретить (модель получит отказ), подменить вход |
tool.result | результат инструмента до показа модели | скрыть или заменить результат — исполнение уже случилось |
message.out | исходящее сообщение | отменить или переписать |
memory.save | запись в память | вето или правка записи |
triage.decision | вердикт триажа | переопределить |
У tool.result вето действует на результат, а не на исполнение:
запрещать действие поздно, оно случилось. cancel прячет выдачу от модели
(с причиной), некасающееся замечание — это patch, который сохраняет
исходный вывод и заворачивает его в обёртку с текстом обработчика.
Обработчик получает {stage, payload} и отвечает одним из трёх:
continue— пропустить как есть;cancel(reason)— запретить; причину увидят и модель, и журнал;patch(payload)— пропустить, подменив нагрузку.
#![allow(unused)]
fn main() {
use eva_sdk::{Decision, Module, payload, stage};
Module::new("guard")
.stage(stage::ToolCall, |_ctx, call: payload::ToolCall| async move {
if call.name == "shell"
&& call.input["command"].as_str().is_some_and(|c| c.contains("rm -rf"))
{
return Decision::Cancel("такое только руками".into());
}
Decision::Continue
})
.serve_uds("/run/eva-mcp/guard.sock")
.await
}
В Rust-SDK стадия типизирована: маркер stage::ToolCall приносит тип
нагрузки (payload::ToolCall), и Decision::Patch принимает её же —
конверт {stage, payload} остаётся деталью провода. Поля, которых версия
SDK ещё не знает, переживают round-trip через rest, а не теряются в
патче. Первым аргументом обработчик получает StageCtx: контекст тура и
клиент API ядра — ядро называет свой адрес в handshake
(_meta["dev.eva/api"] запроса initialize), так что перехватчик может
вести туры и completion, не таская адрес ядра в своём конфиге.
from eva_sdk import Cancel, Continue, Module, Stage
module = Module("guard")
@module.stage(Stage.TOOL_CALL)
async def no_rm(stage: str, payload: dict):
command = payload.get("input", {}).get("command", "")
if payload.get("name") == "shell" and "rm -rf" in command:
return Cancel("такое только руками")
return Continue()
Fail-open — намеренно, fail-closed — по объявлению. Молчание, падение
обработчика и таймаут трактуются как continue: сломанный перехватчик
тормозит своё действие, а не всю Еву. Но гвард, охраняющий необратимое
(вето на мутирующий tool.call, чистка секретов на memory.save),
с fail-open перестаёт охранять ровно в момент собственной смерти — молча.
Поэтому обработчик может объявить себя fail_closed на конкретной стадии:
его падение и таймаут читаются как cancel с причиной в журнале. Дефолт
остаётся fail-open; fail-closed — осознанный выбор автора гварда, и цена
его названа: умерший обработчик останавливает охраняемое действие.
Стадии объявляются в handshake (_meta["dev.eva/middleware"]), так что
ядро зовёт только тех, кто их объявил, и ничего не платит за остальных.
Контракт исполнителя (зафиксировано 08.08.2026)
Решения на сторону ядра — до кода, чтобы код спорил с ними, а не наоборот. Форма подсмотрена у хуков Codex, но взято только то, что ложится на наш дизайн:
- Конверт остаётся нашим.
middleware/handle {stage, payload}→{continue | cancel(reason) | patch(payload)}. Отдельных полей «подави вывод» и «скажи модели вот это» не заводим: и то и другое —patchрезультата. - Правка входа — по стабильной «хук-форме». На
tool.callобработчик видит и правит не сырые внутренности вызова, а объявленную инструментом стабильную форму аргументов; инструмент умеет собрать вызов из неё обратно. Рефакторинг инструмента не ломает чужие хуки. - Один запуск на вызов, не на алиас. Хук с матчером на несколько имён
(
write_file|apply_patch) срабатывает один раз, в payload идёт каноническое имя инструмента. - Цепочка последовательна. Порядок — ссылки
before/after, патчи композируются в порядке цепочки, журнал пишет реакции в нём же. Codex гоняет хуки параллельно и разрешает конфликт правок порядком завершения («последний писатель побеждает») — нам это не подходит: порядок цепочки и есть договорённость, случайности гонки в ней не место. - Доверие — по хэшу нормализованной личности. Хэшируется не текст
объявления, а нормализованное описание обработчика: один и тот же хук,
объявленный двумя способами, — одна личность. Состояния:
Managed | Trusted | Modified | Untrusted; правка доверенного переводит его вModified, и он не исполняется до повторного одобрения через очередь одобрений — та же механика, что уmodule_approve. - Слив выхлопа. Вывод обработчика сверх бюджета пишется целиком во временный файл, в контекст едет голова с хвостом и путь: болтливый хук не раздувает контекст, и ничего не потеряно — можно дочитать.
- Остановка не виснет на хуках. Таймаут обработчиков завершения зажимается с предупреждением в журнале: хук выхода не может повесить выключение ядра.
Порядок и цепочка
Обработчиков может быть много, и порядок задаётся ссылками, а не
временем добавления: у записи есть before и after. Модель переставляет
перехватчики, переписывая ссылки, а не пересчитывая номера.
Заметили цикл или битую ссылку — цепочка отключается целиком, события идут мимо неё, а ядро заводит чат в папке проблем и просит починить. Правка ссылок требует подтверждения Господина: молча переписать порядок собственных ограничений Ева не может.
Кто бывает обработчиком
- Процессный модуль — обычный модуль на SDK, объявивший стадии.
- Запись в базе — код, который Ева пишет себе сама; их исполняет
раннер: тонкий процесс на том же SDK, который забирает включённые
записи, собирает цепочку и отвечает на
middleware/handle. Спавнить процесс на каждый вызов нельзя — это сотня миллисекунд на каждыйtool.call. - Сама модель — стадия может звать hook-тур с промптом и ровно тремя
инструментами:
hook_continue,hook_cancel,hook_patch. Такой тур идёт с полностью выключенным middleware (ни чужим, ни своим) и жёстким таймаутом. Дорого — только точечно, на конкретную стадию с фильтром.
События: узнать после факта
События сообщают, что уже случилось. Ядро их не ждёт, отменить ими ничего нельзя, зато и стоят они ровно ничего.
turn.completed / turn.failed, cron.fired / cron.failed,
message.silent, chat.created / chat.compacted / chat.reaped,
memory.saved / memory.forgotten, module.attached / module.detached,
mcp.server_down / mcp.server_up, broken.set / broken.cleared,
spend.day, person.created, skill.*, плюс собственные custom.<имя>
из инструмента event_emit и от модулей.
Подписчиков два сорта: код (читает поток событий) и LLM-триггер —
событие запускает тур с заданным промптом. Второе и делает Еву
инициативной: «на spend.day посчитай, куда ушли деньги, и скажи, если
дорого».
Журнал
У обоих механизмов общий журнал: точка, фаза, кто перехватил или подписался, решение и что из этого вышло. Без него отладка невозможна — перехватчик, тихо отменяющий действия, выглядит как «Ева сломалась».
Инструменты
Гейтинг проверяется детерминированно в реестре, до исполнения — не зависит
от послушности модели. Недоступные туру инструменты в спеки провайдера не
попадают вовсе (не тратят токены и не соблазняют модель). Поверх гейтов
чат может работать набором инструментов — узким срезом под
род задачи; всё вне набора остаётся видно модели именами и назначением и
возвращается тулом tool_load. Пометки:
M — только Господин, P — только разрешённые пользователи (не гости);
без пометки — доступен всем участникам тура.
Видимость сведена в одну решётку (заимствовано у Codex): для каждого
инструмента тур получает ровно одно из состояний — Visible (спека у
модели, вызов исполняется), Reserved (строка в реестре скрытого, вызов
ждёт tool_load или набор), Hidden (диспетчеризуется, но нигде не
рекламируется — миграционный псевдоним переименованного инструмента, чтобы
не рвать реплей старых чатов) и Forbidden с причиной словами. Из неё
строятся и спеки шага, и блок Hidden tools, и отказ исполнения — три места
по построению не могут разойтись, а «почему модель не видит инструмент»
отвечается одним словом. Каталог GET /v1/tools показывает те же гейты
полями (master_only, trusted_only, privileged_only, client_only,
agent_only, lead_only, needs_agents, anchored_only, indexed_notes_only,
planning_only, unlisted, on_demand).
Внутри решётки живут два разных слоя, и путать их нельзя.
Слой прав — гейты личности (master_only, trusted_only, …), белый
список чата (тумблеры /settings в телеграме) и whitelist крон-джобы. Это
решения Господина и щит от внедрения в чужое сообщение; из недоверенного
тура их не обойти даже tool_load — базовый список остаётся его потолком.
Отказ этого слоя — Forbidden.
Слой внимания — что чат видит по умолчанию, когда прав хватает на
большее, чем стоит держать в промпте: глобальный tools.on_demand,
активный набор и курирование сна чата. Он ничего не
запрещает: скрытое им остаётся в реестре скрытого именем и назначением и
возвращается одним tool_load. Отказ этого слоя — Reserved, то есть
«не сейчас», а не «нельзя».
Коллизия имён — ошибка конфигурации, а не тихая тень: повторная
регистрация имени пропускается (первый зарегистрированный остаётся),
кричит в лог на старте и перечисляется полем collisions в
GET /v1/tools. Переподключение модуля коллизией не является — оно
сначала снимает весь свой неймспейс.
У каждого семейства — явная группа (задаётся при регистрации): memory,
person, skill, cron, chat, web, client, ask, mcp и
mcp_<сервер>; модуль приносит свою (telegram). По группе тул матчится
в списках доступа и группируется в меню настроек поверхности.
Инструмент, чей выхлоп — данные, а не текст, объявляет схему результата
(output_schema, JSON Schema): успешный ответ — один сериализованный
объект этой формы, модель ветвится по полям, а не выискивает фразу в
прозе. Wire-поля для этого у провайдеров нет — схема дописывается хвостом
описания инструмента. Первый такой — agent_list; для большинства
инструментов текст лучше, и это осознанно не обязательно.
Ошибка инструмента — двух классов (заимствовано у Codex). Обычная уезжает
модели текстом в tool_result с is_error, и тур продолжается — модель
чинится сама, на этом держится самоисправляющийся цикл. Фатальная (ядро
гасится или неконсистентно) рвёт тур: пары tool_use/tool_result
закрываются синтетикой, неисполненные соседние вызовы не исполняются,
клиент получает ошибку тура — вместо тридцати повторов вызова в сломанное.
Встроенные
Память — memory_save (M, с дедупом: похожая запись подкрепляется,
а не дублируется; ответ показывает похожие, чтобы противоречие разрешалось
на месте), memory_search (гибридный поиск, подкрепляет найденное; личное
чужому туру не отдаёт), memory_list (M; дамп с фильтрами
kind/since_days и страницами), memory_update (M; правит содержимое,
вид, важность и приватность), memory_forget (M).
Навыки — skill (загрузить тело по имени; с моделью-исполнителем —
изолированный оффлоад), skill_create / skill_edit / skill_delete
(M). У навыка есть видимость: private (по умолчанию) живёт только там,
где работает сам Господин, — в публичном чате такой навык не перечисляется в
промпте и не исполняется; surface виден везде.
Наборы — toolset (переключить активный набор; scope chat или
turn, имя null снимает), tool_load (вернуть скрытый инструмент или
семейство на текущий тур), tool_find (найти инструмент по тому, что он
должен делать, когда имени не знаешь), toolset_create / toolset_edit /
toolset_delete (M). toolset, tool_load, tool_find и skill
видны при любом наборе — иначе из узкого набора не выбраться. Подробности —
Наборы инструментов.
Крон — cron_create (M), cron_list, cron_log (журнал
исполнений), cron_update (M, правка на месте), cron_delete (M),
cron_fire (M, выстрелить сейчас: задача уходит в ближайший тик
планировщика, следующий регулярный запуск — через интервал от этого).
Джоба живёт в собственном чате на полке cron — его заводит ядро при
создании и уносит вместе с джобой; поселить таймер в чужую переписку
нельзя ни тулом, ни POST /v1/cron: оттуда он пошёл бы по общей истории с
людьми и заговорил вслух в их группе.
У create/update есть model — модель туров джобы (пишется в модель её
чата), — compaction_profile (low/med/high, в cron_update ещё и
auto: тот же профиль компакции, что у chat_compact_profile, только
прибивается чату джобы; без него автоматика даёт крон-турам low) — и
tools: whitelist имён и групп, всё вне списка джобе
недоступно. Узкий список заметно облегчает её промпт — спеки лишних тулов
не уезжают провайдеру на каждое срабатывание; пустой массив в cron_update
снимает ограничение. Whitelist сверяется с реестром на входе: незнакомое
имя — ошибка с ближайшими похожими и списком групп, а не молчаливо немая
джоба, которой нечем отправить результат.
Деньги видны на обоих уровнях: cron_log показывает цену каждого
срабатывания, cron_list — сколько джоба потратила за всю жизнь и сколько
раз стреляла. Цена запуска считается по spend.run_id, а не окном по
времени: в общем чате окно поймало бы и параллельный живой тур. Итог живёт
на самой джобе — журнал подрезается пятьюстами записями, а правка полей
идёт UPDATE-ом по id и счётчик не трогает.
Команда — agent_spawn (M, подагент на своей модели и со своим
набором; report_schema делает финальный доклад типизированным),
agent_send (M), agent_wait (M), agent_inbox (M),
agent_list (M, выхлоп — JSON по объявленной схеме), agent_stop
(M); изнутри тура подагента — agent_say и agent_done. Пятеро
follow-up’ов ждут живой команды (needs_agents): без подагента их нет ни в
списке, ни в реестре скрытого, а agent_spawn открывает семейство сам, ещё
в том же ходу. Подробности —
Агентские команды.
Личности — person_list, person_merge (M), person_rename
(M).
История и контекст (все M) — history_search (поиск по всем чатам
ядра и сырому телеграм-архиву: подстрока или регулярка; по умолчанию ищет
только речь, служебные вставки ядра — с флагом include_service), chat_clear
(стереть переписку чата начисто), chat_compact (свернуть историю не
дожидаясь порога), chat_rebuild (пересобрать саммари с нуля окнами
merge+squeeze, с живым прогрессом), chat_summary_get / chat_summary_set,
chat_compact_profile (прибить чату профиль компакции — low, med или
high, пороги крона, телеграма и лички; auto возвращает автоматику),
chat_sleep_start (курировать набор этого чата прямо
сейчас, не дожидаясь его ночной границы — тоже с живым прогрессом),
usage_stats (свой расход, доля мыслей в нём и точность триажа).
Ход — lift_turn_limit (M): снять потолок итераций до конца
текущего тура, когда задача честно требует много шагов. Разрешение
спрашивается заранее и явно (ask_questions), инструмент только применяет
решение. Тому же служат поле uncapped_iterations в запросе тура (клиент
решает за Господина) и uncapped у набора инструментов
(решает род работы) — этот путь для случая «поняли посреди тура».
think_harder (M) — из того же ряда, но про глубину, а не про длину:
поднять уровень рассуждения на оставшиеся итерации хода, когда задача
оказалась глубже, чем выглядела. Каждый следующий шаг после этого стоит
дороже, поэтому зовётся он один раз и осознанно; понизить уровень до конца
хода нельзя. Уровень тура и без него берётся из конфига
(llm.reasoning, effort записи модели), настройки чата, поля effort в
запросе или слова ultrathink в реплике Господина.
Блокнот (все M) — chat_note_save (записать или
переписать заметку целиком), chat_note_forget, chat_note_read (достать
тела пачкой; виден, только когда блокнот показан индексом),
chat_note_compact (пересобрать блокнот, не дожидаясь порога). Семейство
тихое: в лентах-мессенджерах эти шаги не рисуются (quiet).
План работы (M, тоже quiet) — turn_plan: одна
операция на вызов (init / start / done / drop / append / view),
ответ — состояние плана целиком. Виден не всегда: только рабочему туру
(клиентские операции, набор с narrate, подагент) или тому, где план уже
открыт.
Правила поверх генерации (все M) — stream_rule_list,
stream_rule_create, stream_rule_edit, stream_rule_delete. Правила —
данные: что матчится (имя вызова, regex по аргументам), в каком окружении
правило живёт и что оно тогда скажет. Список висит не в промпте, а
показывается по требованию.
Внимание и одобрения (все M) — chat_attention (пометить чат
«посмотри сюда», опционально с уведомлением), chat_delete (мягкое
удаление: чат уходит из списков, история цела и восстановима),
approval_request (спросить разрешение асинхронно — вопрос ждёт в очереди
Господина, тур не блокируется), approval_status (проверить свой запрос
или всю очередь), notify_master (постучаться по настроенным путям).
Веб — web_search (SearXNG; есть только при заданном web.searxng),
web_fetch, wikipedia, fandom, psychonautwiki, booru_search
(danbooru/e621/gelbooru, учётки из web.booru снимают анонимные лимиты).
Вики-инструменты отдают и картинки: главную — всегда, а images: N —
галерею статьи (служебные логотипы и иконки отсеиваются). Ходить за ними
в MediaWiki API через web_fetch не нужно.
Shell — shell (M): sh -c на машине ядра или на одном удалённом
хосте по ssh (shell.remote); таймаут 60 с (настраивается на вызов).
command берёт и массив: команды идут по порядку, каждая отвечает своим
блоком == $ cmd, пачка встаёт на первом провале (сделанное — блоками,
непопробованное — списком); таймаут — на команду. Это ответ модельной
привычке клеить echo ==x== между командами; с yield_ms пачка не
сочетается — сессия одна. Вывод
по умолчанию не режется — опциональный потолок задаётся shell.output_cap;
удерживается до 1 МиБ на поток головой и хвостом, выброшенная середина
называется счётом ([middle cut: N chars of M]). Вывод виден по ходу, а не
после: снимки хвоста уезжают событием tool_progress (в ленте элементов —
item_delta по элементу вызова), с троттлингом, потолком числа событий и
затиранием секретов у истока; в историю живой хвост не пишется. Долгие и
интерактивные команды не убиваются таймаутом, а живут сессией: yield_ms
у shell возвращает {session_id, output, chunk_id, wall_time_seconds}
вместо блокировки, дальше shell_write (M) дописывает ввод и/или
забирает свежий вывод (chunk_id растёт с каждой выдачей — повтор отличим
от нового; пустой опрос ждёт не меньше 5 с). Уложившаяся в выдержку команда
отвечает тем же JSON с exit_code и без session_id. Сессий не больше 16,
бездействие дольше 5 минут убивает процесс вместе с группой; отмена тура
(Esc) прибивает сессии, рождённые этим туром. Отдельных подтверждений у
сессий нет — они наследуют гейт shell: личность, не канал (из телеграма
Господин тоже может).
Кодинг — code_task (есть только при заданном llm.code_model):
одиночный оффлоад-запрос модели посильнее. Инструментов, файлов и истории у
неё нет — она видит ровно то, что вписано в task, и отвечает текстом.
Поэтому он для самодостаточных вопросов (алгоритм, разбор вставленного
куска), а работу внутри репозитория забирает agent_spawn с кодовым
набором: та же модель, но с руками. Тем, кто путает эти два пути, помогает
tools.on_demand — code_task уходит из списка по умолчанию и достаётся
по tool_load.
Рисование — image_generate (M; есть только при рисующей модели —
llm.image_model либо первая из models с image_output): одиночный
оффлоад-запрос рисующей модели — генерация по prompt и правка
существующей (source: id сохранённой картинки, last — последняя
картинка чата, нарисованная или присланная фотографией, путь к файлу —
машина Господина при клиенте на связи, иначе хост ядра, — либо
http(s)-URL; формат файла проверяется магик-байтами). Картинка ложится
в блоб-стор, модели возвращается
image <id> saved: <url> — дальше телеграм берёт её по id
(telegram_attach_photos), GUI — по URL. Расход пишется на чат. Если
модель тура сама умеет рисовать (image_output у неё), инструмент из её
списка спрятан: она рисует прямо в ответе, без второго платного вызова.
Правило рисования в промпте идёт за досягаемостью инструмента: не пускает
белый список чата — правила нет, и обещания тоже.
Клиентские (client_ops, исполняет клиент — eva-tui) — read_file,
write_file, write_diff, apply_patch, find_pattern, local_shell: событие client_op в живой
стрим, клиент постит результат в /v1/chats/{id}/ops/result. Операцию
находит по одному op_id, поэтому отвечать на неё можно через ручку
любого чата — на этом стоит работа подагентов с файлами.
Действий на проводе три — read, write, shell: write_diff и
apply_patch ядро собирает из чтения и записи, реализовывать их клиенту
нечего. Кнопки подтверждения задаёт поле decisions события, а любой
исход ожидания — ответ, таймаут, конец или отмена тура — снимается
событием request_resolved: диалог у клиента закрывается по факту, а не
по своему таймеру (контракт — в API). Тем же
проводом, но БЕЗ участия модели, ядро на входе в тур собирает файлы проекта
в блок [project] (служебное действие list_up — см. API):
все AGENTS.md от корня проекта до рабочего каталога и память проекта
.eva/MEMORY.md, с общим бюджетом и происхождением каждого куска. Правила
сборки одни на всех клиентов и живут в ядре (context.project); клиент
объявляет поддержку флагом project_docs в send_message и перестаёт
возить AGENTS.md в context сам.
Тулы тура (extra_tools в send_message) — клиент объявляет модели
свои тулы на один тур поверх реестра; исполняет их сам, тем же проводом
ops/result, что и клиентские операции: вызов приходит событием
extra_tool_call, ядро ждёт (таймаут из объявления, до 900 с), ответ —
блоками. ends_turn в объявлении делает тул вердиктом: успешный вызов
закрывает тур. Так телеграмный мост объявляет telegram_skip — вердикт
«молчу», который существует только в телеграмных турах и не мозолит глаза
модели в остальных. Контракт и типизированный SDK — в API.
read_file отдаёт окно строк с шапкой [lines A-B of N]. Окно длиннее
одного ответа обрывается по границе строки, а не вырезается серединой,
и шапка называет offset, с которого читать дальше: дочитывание точное, а
не «перечитать сначала и надеяться». Тот же приём у списка находок
find_pattern. Такой выхлоп объявляет себя оконным
(windowed_result), и гигиена контекста режет ему ХВОСТ по своему,
более щедрому потолку (context.reader_result_max_chars): общий рез
серединой верен для вердикта сборки и губителен для файла — он выбрасывает
ровно то, за чем звали, а шапка продолжает обещать диапазон, которого в
промпте уже нет. Хвостовой рез подписывается и зовёт сузить окно.
Файловые тулы берут пачку: path у read_file, read_hex и find_hex
принимает массив путей (секции == путь, отказ одного файла — строка его
секции, а не общий провал), write_file пишет несколько файлов полем
files, write_hex кладёт несколько заплат полем writes — по порядку,
с остановкой на первом отказе и честным списком того, что уже легло и что
не начиналось. Иначе модель тянется читать пачку через cat в шелле.
Пачка правок (там же) — apply_patch: формат Codex (*** Begin Patch /
Update|Add|Delete File / Move to / *** End Patch), несколько правок и
несколько файлов одним вызовом — сосед write_diff, а не замена: для
одиночной замены тот проще. Ни одного номера строки — место находится
поиском процитированных строк, строгость ослабляется в четыре шага, вплоть
до нормализации типографики: ASCII-патч ложится на файл с «ёлочками» и
длинными тире. Двухфазно: сперва весь патч читается и проверяется против
диска, и только потом пишется — неверный ханк не оставляет файлы наполовину
переписанными, а ошибка цитирует вход, и модель чинится со второй попытки.
Delete File/Move to исполняются только на рабочей машине: клиентский
провод знает лишь read/write, на машине Господина это local_shell (rm/mv).
Поиск по файлу (там же) — find_pattern: регулярное выражение и
строки, в которые оно попало, с их номерами. Поиск построчный, как у
grep, — ^ и $ держатся краёв строки, . через перевод строки не
ходит; флаги буквами (i, x, U). Файл течёт мимо потоком и не
ложится в память, а набрав max_hits, чтение гасится на месте: в
логе на сотни мегабайт попадание в начале стоит миллисекунды. Начало
пропускает сама машина (from_line), так что листать находки дёшево.
Длинная строка показывается окном вокруг вхождения — в минифицированном
коде обрезка с начала спрятала бы само совпадение. Двоичный файл —
отказ и отправка в find_hex.
Двоичные (там же) — read_hex, write_hex, find_hex: read_file
от бинаря отказывается и отправляет сюда. Клиент возит сырые байты в
base64, а дамп рисует и хекс-строку разбирает ядро — формат один на все
клиенты и под тестами. read_hex отдаёт классический дамп со смещением,
шестнадцатеричными байтами и колонкой расшифровки; шапка [bytes A-B of N]
говорит, где ты и сколько всего. Колонка по умолчанию ASCII, но для
японских игр её задают cp932 или utf-16le — тогда строки в архиве
читаются. write_hex пишет байты по смещению поверх существующих
(truncate обрубает хвост, смещение 0 плюс truncate — полная замена),
find_hex ищет образец хексом или текстом в заданной кодировке и отдаёт
смещения. Потолок дампа — 8 КБ на вызов: дамп раздувает данные всемеро,
и это уже около 12 тысяч токенов.
Вопросы кнопками — ask_questions (P): 1..3 вопроса с готовыми
вариантами одним вызовом, тур ждёт ответа внутри тул-колла. Кнопки рисует
поверхность из tool_call-события (телеграм — inline-кнопками, tui и
android — своими), ответ приходит в POST /v1/chats/{id}/questions/answer.
Вопрос адресуется полем question — позицией в массиве questions вызова;
номер стабилен, ответы на соседние вопросы его не сдвигают. Одновременные
вызовы в одном чате развязывает call_id (id из tool_call-события); без
него ответ идёт в самый ранний живой вызов. Сколько ждать —
tools.ask_ttl; по истечении вопросы растворяются, и тур продолжается без
ответа.
Телеграмные (telegram_send_to_other_chat, telegram_react,
telegram_skip и прочие) — не встроенные: их приносит
модуль поверхности, и живут они в группе telegram
наравне с остальными.
Внешние (TOML + stdin/stdout)
Файл tools.d/weather.toml:
name = "weather"
description = "Get current weather for a city"
command = ["/usr/local/bin/weather-cli"]
timeout = "30s"
[input_schema]
type = "object"
[input_schema.properties.city]
type = "string"
Вход инструмента приходит JSON’ом в stdin, stdout становится результатом,
EVA_CHAT_ID в окружении говорит, из какого чата позвали. Ненулевой код
выхода или таймаут — ошибка инструмента, модель получит её текстом.
MCP
Секция mcp.servers: stdio (ядро запускает и держит процесс) или
streamable HTTP (url + заголовки). Инструменты сервера появляются в
реестре как mcp_<сервер>_<инструмент> с группами mcp и mcp_<сервер>.
Имя приводится к алфавиту провайдеров и потолку в 128 символов; слишком
длинное не режется молча — хвостом ставится стабильный отпечаток полного
имени, так что два длинных имени с общим началом не схлопываются, а имя
не меняется между перезапусками (нестабильное имя рвало бы префикс
промпт-кэша).
Схема каждого инструмента чистится от шума ($schema, title,
examples…) и, если после этого занимает больше 5000 байт JSON, ужимается
с потерями: описания до первой фразы → словарь $defs за борт → вложенное
глубже третьего уровня в {} → ветвления anyOf/oneOf/allOf в {} —
остановка, как только влезло. Имена аргументов верхнего уровня выживают
всегда. Так один сервер с сорокакилобайтной схемой не съедает бюджет
описаний целого тура; свои инструменты сжатие обходит.
Представиться сервер может сам — _meta["dev.eva/about"] в ответе
initialize. Этой фразой его семейство показывается модели, когда активный
набор свернул его в одну строку. Поле description в конфиге сервера —
ручной override поверх сказанного сервером и единственный способ описать
тех, кто _meta не умеет (FastMCP не умеет). Промолчали оба — в строке
будут первые имена тулов.
Сервер может украсить свои тулы через _meta (конвенция dev.eva/*):
_meta["dev.eva/icon"] — эмодзи-иконка, _meta["dev.eva/brief"] — шаблон
краткого показа вызова по полям входа («{from} → {to}» рисуется как
«Череповец → Москва»). Оба уезжают клиентам в каталоге GET /v1/tools
(поля icon/brief, у встроенных тулов иконки свои) — их рисует любая
поверхность, показывающая ход тура; провайдеру LLM не отправляются. Чужие
клиенты незнакомые _meta-ключи обязаны игнорировать — конвенция ничего не
ломает.
Там же _meta["dev.eva/anchored"]: тул работает только в туре с якорем,
то есть отвечающем на конкретное сообщение поверхности. В туре без якоря
(кодовая сессия, крон) такой тул в спеки не попадает — его вызов всё равно
кончился бы ошибкой, а спека тратила бы токены и подсовывала модели
тупиковый выбор. Объявляет это модуль о себе сам, и ядру не приходится
знать, что у него за поверхность. Рядом _meta["dev.eva/master"] — тул
только для туров хозяина: так поверхность запирает своё администрирование,
не запирая разговор.
И _meta["dev.eva/ends_turn"]: успешный вызов закрывает тур — ядро не
возвращает управление модели, а сразу уходит в завершение. Для
инструментов-вердиктов вроде telegram_skip: «молчу» уже сказано,
следующая итерация могла бы только передумать или зациклиться на
повторных skip. Ошибка вызова тур не закрывает — несостоявшийся вердикт
вердиктом не является.
Последний гейт конвенции — _meta["dev.eva/unlisted"]: миграционный
псевдоним. Тул зовётся по имени, но не рекламируется ни спекой, ни
реестром скрытого — так модуль переименовывает инструмент, не ломая
реплей чатов, где модель помнит старое имя.
Доступ гейтится: access: master | allowed | everyone и trusted_only —
только из доверенного окружения. У MCP-сервера дефолт мастерский: чужой
бинарь может уметь что угодно, открывать его чужим — осознанное решение.
У модуля дефолт everyone: его ставит сам оператор, и обычно это
поверхность, чьи инструменты и есть ответ Евы в чужом туре, а что внутри
администрирование — модуль запирает поштучно.
MCP-сервер — горячезаменяемый: stdio-подключение ленивое, упавший процесс
перезапускается следующим вызовом с новым handshake, а протухшее
соединение долгоживущего модуля переподключается с одним автоповтором —
рестарт модуля невидим для модели. tools/list кэшируется в базе: лежащий
на старте ядра сервер поднимается с тулами из кэша и оживает первым же
вызовом; совсем без кэша — выключается целиком, ядро живёт дальше.
Каталог готовых серверов — MCP-серверы.
Модули
Модуль — долгоживущий соседний процесс с тем же MCP-протоколом
(line-JSON-RPC) по UDS-сокету или TCP; живёт своим systemd-юнитом, hot
reload — рестарт юнита. Регистрируется на лету, без рестарта ядра
тулами (все M): module_list (зарегистрированные, их статус и
кандидаты из modules.socket_dir), module_register (socket или tcp —
тулы появляются сразу; так Ева сама пишет себе модуль и включает его),
module_approve (подключить кандидата из socket_dir), module_unregister
(выгрузить). Решения живут в базе и переживают рестарты; ядро само
замечает уход/возврат сокета (выгрузка/переподключение со свежим
tools/list) и никогда не подключает ничего без явной команды. Статичные
модули можно объявить конфигом (modules.servers) — те управляются
конфигом и рестартом, как обычные MCP.
Наборы инструментов
Набор — имя, однострочное описание и список имён тулов и групп; всё в
SQLite. Он же переносит режим работы — см. ниже: рабочий каталог,
потолок итераций, краткость и озвучивание шагов. Список name: description висит в системном промпте, и когда род
работы совпал, модель зовёт toolset — следующая итерация тура уезжает
провайдеру уже с узкими спеками. Тот же progressive disclosure, что у
навыков, только про инструменты.
Зачем: спеки всех инструментов едут провайдеру в каждом запросе и на каждой итерации агентного лупа. Это и вход в токенах, и — важнее — шум: чем шире список, тем труднее модели выбрать нужный вызов. Набор оставляет то, чем работают сейчас.
Что видно, когда набор активен
Активный набор задаёт спеки, уезжающие провайдеру. Всё остальное, что туру доступно по гейтам, попадает в блок Hidden tools — имя и назначение, без input_schema. Схемы и есть весь вес; знать, что инструмент существует, стоит почти ничего. Крупные семейства (MCP-серверы, модули) сворачиваются в строку на семейство — перечислять полсотни имён дороже, чем сказать, что это за семейство.
И список наборов, и реестр скрытого уложены в тот же бюджет, что и
навыки, — долю окна модели (2%, но не больше 16000 символов) на все три
блока разом: при нехватке первыми
худеют описания, по символу с каждого по кругу, имена держатся до
последнего, а выброшенное считается и уходит warning’ом в лог. Пометки
режима ([no iteration cap] и прочие) переживают любое усечение — врать
про режим нельзя даже в тесноте.
Понадобился скрытый инструмент — tool_load с его именем или именем
семейства возвращает его в список на текущий тур, не трогая активный
набор. Так узкий набор нигде не становится тупиком: Ева не отвечает «не
умею» о том, что у неё есть.
Имени можно и не знать. tool_find ищет по тому, что инструмент должен
делать («чем отправить сообщение в личку»), каскадом от дешёвого к дорогому:
- лексика — основы запроса против документа инструмента: имя, назначение, семейство и имена с описаниями параметров схемы — тул находится и по имени своего аргумента. Токенизация та же, что у памяти; отбор по доле совпавших основ, ранжирование — BM25. Ноль токенов, доли миллисекунды, а имена по построению настоящие: они из реестра, выдумать их нельзя;
- быстрая модель — и только если лексика ничего внятного не нашла. Реестр скрытого едет ей в промпт, она разбирает запрос и называет инструменты; ответ сверяется с реестром, так что несуществующее имя наружу не выйдет.
Вторая ступень нужна ровно для случая «запрос по-русски, описания по-английски», где лексика бессильна. В обычном случае до неё не доходит. Найденное сразу догружается на текущий тур.
toolset, tool_load, tool_find и skill видны при любом наборе — иначе из узкого
набора не было бы выхода. Тот же по духу предохранитель, что пустой список
tools, который считается «весь реестр», а не «ни одного».
Редкое — не в списке по умолчанию
Оператор может убрать семейство из списка до всяких наборов:
tools.on_demand перечисляет имена и группы, которые в туре без набора
живут в реестре скрытого — именем и назначением, без схемы. Это про работу
по поводу: доменный модуль, реверс байтов, CRUD навыков и таймеров
случаются редко, а схемы их тулов едут провайдеру в каждом запросе.
Названное явно — набором или белым списком чата — сильнее: раз внесли в
список, значит осознанно. Догружается такое обычным tool_load, как и всё
прочее скрытое.
В каталоге GET /v1/tools такой инструмент помечен on_demand: true —
клиентское меню по этой метке честно говорит, что тул придёт набором или
догрузкой, а не сию секунду.
Область действия
toolset принимает scope:
chat(по умолчанию) — набор пишется в чат и живёт между ходами;turn— только до конца текущего хода.
Дефолт именно chat: род работы редко меняется внутри одного хода, а
префикс кэша живёт дольше. Цена turn — лишний разрыв кэша на следующем
ходу, когда набор откатится (см. Кэш промпта).
Клиент может задать набор на один тур полем toolset в
POST /v1/chats/{id}/messages — настройка чата при этом не меняется. Так
кодовая сессия стартует узкой, не переучивая чат.
Режим работы
Кодовый режим раньше включал клиент при старте (eva-tui --code DIR), и
поэтому из телеграма, из крона и из любого другого тура без клиента его не
существовало. На деле из шести его частей клиента требуют только две —
клиентские операции и сам интерфейс; остальные четыре суть свойства
разговора, а не программы. Они и переехали в набор:
| Поле | Что делает |
|---|---|
workdir | каталог, от которого считаются относительные пути и в котором идут команды: cd в каждой команде больше не нужен |
uncapped | снимает потолок итераций тура — длинная правка не обрывается на середине |
narrate | короткая строка между вызовами о том, что делаю (исключение из общего «говори один раз») |
no_brevity | не навязывать краткость: это работа, а не реплика в мессенджере |
Все части необязательны: набор может остаться просто набором инструментов. Применяются они только к турам Господина — настраивал их он, чужому туру они не полагаются.
Надевает режим сама Ева: попросили накодить — она зовёт toolset и
говорит, что включила; закончили — снимает. Закрепить за чатом можно и
руками. Каталог, пришедший с набором, ложится на чат, а дальше меняется
тулом workdir или клиентом (PUT /v1/chats/{id}/workdir) независимо от
набора — по нему же клиент считает относительные пути (поле cwd в
событии client_op).
Крон от этого выигрывает не меньше: у крон-джобы клиента нет вовсе, а режим живёт на её чате — значит длинная работа по расписанию перестаёт упираться в потолок итераций.
Как набор сочетается с гейтами
Порядок жёсткий и проверяется в реестре, до исполнения:
- Гейты личности (
master/trusted/privileged/client_ops) — поверх всего и всегда. Набор не выдаёт чужому туру master-only тул. - В доверенном туре (TUI, Android, личка Господина) набор заменяет базовый доступ: Ева сама решает, чем работать, и может вернуть себе что угодно из реестра.
- В недоверенном (публичный чат) набор может только сузить: белый список настроек чата остаётся потолком. Иначе команда, внедрённая в чужое сообщение, открывала бы себе то, что Господин выключил.
Стартовые наборы
На первом запуске база засевается пятью наборами — coding, ops,
social, research, money, — чтобы фича не лежала мёртвой. Посев
одноразовый (защёлка в kernel_meta): удалённый набор не воскреснет на
следующем старте. Наборы перечисляют группы, а не имена, поэтому семейство,
которого в этой сборке нет, просто ни с чем не совпадёт.
Это стартовая точка, а не канон: правятся они дальше на ходу.
Управление
Заводит и правит наборы только Господин: toolset_create,
toolset_edit (пропущенные поля не трогаются, tools заменяется целиком,
пустой hint снимается), toolset_delete (чаты с этим набором
возвращаются ко всему реестру).
У набора есть необязательный hint — рабочая инструкция, которая едет
ответом на активацию и висит в промпте, пока набор активен.
Каталог наборов клиентам — GET /v1/toolsets. Активный набор чата виден в
поле toolset чата и переключается ручкой PUT /v1/chats/{id}/toolset —
у клиента это кнопка рядом с выбором модели.
Сон: курирование наборов
Раз в сутки, этапом tools сна (секция
sleep.tools конфига), модель этапа смотрит на все наборы, весь реестр (имена, назначения,
семейства — без схем) и статистику ношения (сколько чатов носит каждый
набор) и отвечает планом: добавить/убрать тулы в наборе (дельтой, не
заменой списка), создать новый набор под устойчивый род работы, переписать
расплывчатое описание — набор выбирается из списка в промпте именно по
нему. Как и в памяти, действовать во сне нельзя: единственный инструмент —
sleep_finish, которым отдаётся план, а проверяет и применяет его ядро. Удалять и переименовывать наборы, трогать их режимные флаги
(hint/workdir/uncapped/…) и наборы с нечитаемым списком этап не
может — это ручная работа Господина; набор не может опустеть (пустой
список открывает весь реестр), каждый добавляемый тул обязан существовать
в реестре, потолок — sleep.tools.max_ops правок за ночь. План виден в
том же журнале GET /v1/sleep/runs, общий sleep.dry_run действует и
здесь, о применённых правках Господину уходит уведомление — они меняют,
чем Ева работает днём.
Назначение семейств
Строка свёрнутого семейства берётся, по убыванию приоритета:
descriptionв секции сервера/модуля в конфиге — ручной override;- чем сервер представился сам:
_meta["dev.eva/about"]в ответеinitialize(на eva-sdk —Module::about(...)/Module(about=...)); - у встроенных семейств — таблица в ядре;
- не сказал никто — первые имена тулов семейства. Понятнее, чем пусто, но хуже, чем фраза по делу.
Штатные instructions протокола сюда не идут, хотя соблазн есть: это
правила обращения с сервером, а не ответ на вопрос «что это за семейство».
Первой фразой там обычно стоит частность («Issues and PRs share one
numbering»), и в реестре скрытого она бесполезна.
Практика: _meta умеют наши модули на eva-sdk. Серверы на FastMCP его
выставить не могут — в InitializationOptions такого поля нет, — поэтому
им назначение задаётся полем description в конфиге. Строчка на сервер, и
трогать чужой код не нужно.
Агентские команды
Ева-тимлид поручает часть работы подагентам на выбранных моделях и
переписывается с ними в обе стороны. Это не оффлоад: у code_task и навыка
с моделью-исполнителем один поход к провайдеру, ноль инструментов и ноль
истории — подчинённый там не может ничего вызвать, переспросить и не живёт
дольше вызова.
Подагент — это чат
id агента равен id его чата, и оттуда бесплатно берётся всё: своя модель,
история, компакция, набор инструментов и учёт расхода
(spend ключуется по чату, так что деньги команды считаются сами).
Чат подагента persistent и лежит в папке agents. Из общих списков она
скрыта — это рабочая механика, а не переписка; спрошенная явно, папка
отдаётся, ею и живут панели клиентов.
Дольше чата тимлида команда не живёт. Временный чат тимлида, домолчавший до
chats.ephemeral_ttl, жнец уносит вместе с подагентами: их чатами, почтой и
незаконченными турами. Подагент, которому некому докладывать, — призрак:
чата, куда шли письма, нет, и никто их уже не прочтёт.
Удаление чата тимлида (DELETE /v1/chats/{id}) снимает его живых
подагентов: чат ушёл из списков, доклады летели бы в корзину, а работа
подагента стоит денег. Удаление мягкое, и restore вернёт переписку — но не
воскресит команду: снятым ставится stopped, письмо о снятии уходит наверх,
а работу нужно поручать заново.
Вниз и наверх
Тимлид (всё M):
agent_spawn—name,task, необязательныеcontext,model,toolsetиreport_schema. Задача должна быть завершаемой без тимлида: своего чата подагент не видит, и всё, чего ему не выяснить самому, кладётся вcontext— отдельной секцией первого сообщения, чтобы задача осталась задачей.report_schema(JSON Schema) делает отчёт типизированным:agent_doneобязан принести один объект этой формы, и тимлид разбирает поля, а не прозу; схема едет секцией[report format]того же первого сообщения. Имена набора и модели проверяются на входе (см. «Предохранители»), рабочий каталог чата тимлида вместе с машиной достаётся чату подагента: относительные пути в задаче ведут туда же, куда у тимлида. Тур подагента идёт в фоне, тимлид не блокируется;agent_send— дослать поправку или ответ на вопрос. Работающий читает письмо на ближайшем своём шаге и работает дальше, спящий им будится, законченному не пишется вовсе (отказ: заводить ему тур больше некому);agent_wait— подождать первого доклада (по умолчанию 120 с, максимум 900). Нужен, только когда до доклада делать нечего;agent_inbox— забрать сказанное, не засыпая;agent_list— кто в команде, статус, модель, набор и расход (с долей, ушедшей в мысли). Выхлоп — JSON-объект по объявленной схеме (output_schemaинструмента): тимлид ветвится поstatusи складываетcost_usd, а не выискивает их в строке;agent_stop— снять подчинённого: статусstopped, слот команды свободен, письмо о снятии уходит тимлиду.
Подагент (только внутри своего тура, гейт agent_only):
agent_say— вопрос, промежуточная находка, предупреждение «задача в таком виде не выйдет». Работа продолжается;agent_done— закончить с результатом.
Тимлидские шесть заперты обратным гейтом lead_only: в туре подагента их
нет ни в списке, ни в исполнении. Команды у него не бывает по построению —
почта и списки были бы пусты всегда, а agent_spawn открыл бы второй
этаж дерева.
Пятеро из них — все, кроме agent_spawn, — заперты ещё и наличием живой
команды (гейт needs_agents). Follow-up к работе, которой никто не
поручал, — это четыре килобайта схем в каждом туре про кофе; без агента их
нет ни в списке, ни в реестре скрытого, а вызов вслепую отвечает «spawn one
with agent_spawn first». Флаг считается одним запросом на входе в тур —
и поднимается прямо посреди него: завести подагента и остаться без почты до
следующего хода было бы издевательством, поэтому agent_spawn открывает
семейство сам, с ближайшего шага (как tool_load открывает доложенное).
Вниз письма ходят двумя путями, по состоянию читателя. Спящему письмо
уезжает текстом промпта нового тура и помечается прочитанным сразу.
Работающему оно приходит отдельным user-сообщением [team] Your lead says:
в конце ближайшей итерации — сразу за выхлопом инструментов. Не строкой в
[state]: тот летуч и тур не переживает, а поправка тимлида обязана
остаться в истории подагента. Письмо, посланное на самом последнем шаге
тура, доставлять внутрь уже некуда — его подхватывает следующий тур,
заводимый тут же: будить подагента иначе было бы некому, тимлид видел его
работающим. Снятого письмо не воскрешает: stopped ставит человек, и тур
после этого не заводится вовсе, а письмо остаётся лежать непрочитанным.
done и failed этой оговорки не знают — туда подагент приходит сам, и
письмо тимлида вправе его продолжить.
Синхронно доклад забирает agent_wait, асинхронно непрочитанное приезжает
тимлиду блоком [subagents] в [state] его следующего тура — тем же
летучим механизмом, что [now] и память. Письмо забирается один раз и в
каждый следующий ход не повторяется.
За один забор наверх уезжает до 8000 символов почты. Письма целые — режется пачка, а не доклад: непоместившееся остаётся непрочитанным, ждёт следующего захода и считается вслух («— N more waiting»). Письмо длиннее всего бюджета едет целиком, иначе оно застряло бы в почте навсегда.
Ожидание сделано той же схемой, что ask_questions и client_ops:
oneshot в общей карте, timeout на стороне тула и снятие ожидания при
отмене тура.
Промпт подагента
Тур подагента — мастерский (своего спикера у него нет), и промпт он получает Евин целиком: персона не меняется, меняется адресат. Роль ему задают два места, оба вне летучего хвоста:
- блок
[team]— самым концом стабильной части системного промпта: кто он, что его текст не читает никто, что работа кончаетсяagent_done, а вопрос идёт черезagent_say. Одной строкой в первом сообщении это не держалось: после первой же итерации она уезжала вверх истории, и побеждала персона — модель отвечала «Господину» текстом в пустоту; - строка в якоре тура — том самом, что переезжает на свежий
tool_resultкаждой итерации. Самая сильная позиция в промпте, и стоит она копейки.
Неприменимое из агентского тура вычищено: блокнот (инструментов у него
нет), правила о чатах проблем (заводить их он не может), правила о
картинках (показывать некому) и подсказка «каталог не задан» (каталог
достаётся от тимлида). Память остаётся — memory_search в наборе по
умолчанию есть.
Файлы и шелл Господина
Клиентские инструменты (read_file, write_file, write_diff,
apply_patch, find_pattern, local_shell) исполняет подключённый клиент, а своего клиента у подагента
нет — он работает через тимлида. Флаг client_ops наследуется от тура, в
котором подагента породили, а его client_op уезжает в живой стрим
чата тимлида с именем просящего: клиент показывает, кто просит, и
спрашивает разрешение там же, где на свои операции. Ответ находит операцию
по op_id, поэтому ручка чата в маршрутизации не участвует.
Отсюда режим работы: стрим у чата тимлида есть, только пока идёт его ход.
Подагент, трогающий файлы, живёт в паре с agent_wait — тимлид спавнит
его и ждёт. Если хода нет, операция не висит до таймаута, а сразу
отказывает с объяснением, и подагента инструктируют в таком случае
доложиться через agent_say и ждать пробуждения.
Когда подагент окупается
Он несёт собственный контекст: тысячи входных токенов на каждую итерацию, и
первый его ход всегда греет кэш промпта с нуля. Окупается это тремя вещами
— изоляцией контекста (перелопачивает мусор, наверх едут шесть строк),
параллельностью веера и специализированной моделью. И не окупается на
«прочитай файл и скажи коротко»: прямой read_file в туре тимлида дешевле
и надёжнее. Об этом сказано в самом описании agent_spawn — учить, когда
инструмент брать, там важнее, чем перечислять поля.
Выбор исполнителя
У модели в llm.models есть description — для чего она хороша. Из
описанных собирается блок ## Models в системном промпте, и только в тех
турах, где agent_spawn вообще уместен: в обычном чате это мёртвый вес в
каждом запросе. Модель не задана — подагент идёт моделью чата-родителя.
Предохранители
- Глубина. Подагент не спавнит подагентов: дерево ушло бы в глубину и
в деньги. Держится гейтом
lead_only, не уговорами в промпте. - Ширина.
chats.max_agents(по умолчанию 4) — сколько подагентов чат держит живыми разом. Без потолка одна неудачная формулировка задачи разворачивает веер на весь баланс. - Имена. Узкий набор по умолчанию держится на имени: названный набор
открывает подагенту весь реестр, поэтому несуществующее имя — отказ с
перечислением известных, а не молчаливая выдача шелла, памяти и крона по
опечатке. Так же проверяется модель — по списку
llm.models; список пуст — проверять нечем, имя уходит как есть. - Молчание. Пропасть незаметно подагент не может. Закончил тур, не
позвав
agent_done, — письмом наверх уходит последнее слово тура (задачу вида «просто ответь» модель заканчивает текстом, и в чате подагента он бы и остался); при заданнойreport_schemaиз этого текста сперва выкапывается JSON-объект — запасной разбор, чтобы тимлид получил разбираемое, а не пересказ; не нашлось — уезжает проза как есть; не сказал вовсе — так и сказано; упал — письмом уходит ошибка. Статус меняется в любом случае. Снятый черезagent_stopили API шлёт тимлиду письмо об этом — тем же путём, что доклад, поэтому ждущий вagent_waitпросыпается сразу, а не досиживает таймаут.
Статусы: running и idle — живой (занимает слот max_agents, читает
письма, его есть смысл ждать); done — закончил agent_done; failed —
тур упал; stopped — сняли. Снятый не выглядит упавшим ни в agent_list,
ни в панелях клиентов. Базы прежних версий доращиваются на старте: таблица
подагентов пересобирается под новый статус, переписка при этом цела.
Видно ли, что команда работает
Блок team в /v1/stats (и в usage_stats, которым Ева смотрит на себя
сама) за каждое окно: сколько подагентов завели, кто чем кончил (по когорте
этого окна) и как заканчивались их туры — reported (позвали agent_done)
против silent. Доля молчаливых и есть метрика здоровья: доклад,
оставшийся текстом в чате подагента, тимлид получает уже аварийным путём.
Что фича мертва, до этого выяснялось запросом к SQLite.
Клиентам
GET /v1/agents?parent=<chat_id>— подагенты с моделью, набором, статусом и расходом (на каждого и итог по команде), включаяreasoning_tokens— сколько из выхода ушло в мысли — иreasoning_cost, который остаётсяnullбезprice_outу модели;POST /v1/agents/{id}/stop— снять;- живой тур подагента — обычный
GET /v1/chats/{id}/live, история —GET /v1/chats/{id}/messages.
Отдельных типов SSE-событий команда не вводит: события подагента идут в
его собственный стрим. В стриме тимлида они перемешались бы с его
собственными, а чужой done оборвал бы доставку ответа.
В клиентах это выглядит так: панель со списком подагентов, а нажатие
открывает чат подагента — он и есть чат, поэтому видна вся его работа,
живая в том числе, и вторая лента поверх текущей не нужна. В eva-tui —
f7 или /agents, в Android — значок в шапке чата.
Память
Что туда попадает
Решение о записи принимает сама Ева, и правило об этом живёт в ядре, а не в
персоне: персона — файл оператора, и при своей persona.file память
осталась бы в промпте только на чтение. Критерий один и общий — обеднеет ли
следующая Ева, в другом чате, не зная этого. Тогда memory_save, по ходу
разговора, без спроса и без церемонии.
Отсечка с трёх сторон: не память то, на что отвечает история перед глазами;
то, что можно перечитать из файла или инструмента; то, что живёт до конца
задачи — это блокнот. Одна тема — одна запись: memory_update
правит сохранённое (включая вид: эпизод, записанный в core по ошибке,
опускается в fact), memory_forget убирает устаревшее. Ответ на
сохранение показывает похожие записи — момент записи единственный, когда
видно, что чему противоречит; иначе поправка ляжет рядом со старой правдой и
обе всплывут потом как равные.
Запись можно пометить личной (private): тело Господина, здоровье, деньги,
местоположение, документы, чьи-то имена. Личное живёт только в турах
Господина из доверенного окружения (trusted) — там, где он работает сам:
TUI, андроид, его собственная личка с Евой. В любом другом месте его нет ни
в промпте, ни в выдаче memory_search, ни в соседях при сохранении, а
memory_list там не показывается вовсе: дамп идёт вместе с личным.
Допуск считается по аудитории, а не по говорящему. Мастерство тура само по себе ничего не открывает: в группе спрашивает Господин, а ответ читают все, кто там сидит. Тур Господина из недоверенного места получает полную выборку без личного и сноску о том, что личное придержано, — иначе модель принимает вырезанное за незнание и досочиняет.
Записи, сделанные до появления пометки, считаются общими: разметить их — решение Господина, а не догадка миграции.
Правило висит в кэшируемой части промпта и только в турах Господина —
мутирующие memory_* заперты на него, чужому туру это был бы вес без права.
Виды и важность в правиле не пересказываются: их объясняет схема самого
инструмента, которую модель читает ровно в момент вызова.
Как достаётся
Оценка воспоминания против запроса:
score = relevance × (0.35 + 0.65 × 0.5^(возраст / half_life)) × (важность / 3) × частотный бонус
где relevance — BM25 (нормированный), а при заданной
memory.embedding_model — гибрид 50/50 с семантической близостью;
важность ∈ 1..=5. Близость считается не сырым косинусом, а
(cos − 0.4) / (1 − 0.4): у эмбеддингов любые два текста похожи довольно
сильно (на моём корпусе с bge-m3 медиана заведомо несвязанных пар ≈ 0.39,
родство начинается от ≈ 0.7), и сырой косинус отдавал бы половину веса
скоринга за то, что оба текста написаны по-русски. Ноль шкалы стоит на
фоне — свой корпус стоит перемерить, если модель эмбеддингов другая. Возраст меряю от
последнего обращения, не только создания — что вспоминаю, то помню дольше
(spaced repetition, как у людей). Явный memory_search подкрепляет
найденное; пассивное вплетение в промпт — нет, иначе всплывшее однажды жило
бы вечно. Вид core минует скоринг и вплетается в промпт всегда — но в
выдачу memory_search не попадает: тул отдаёт только релевантное запросу,
иначе он возвращал бы модели текст, который та уже прочла в секции
# Memory. event выцветает
вчетверо быстрее fact. Сохранение с дедупом: достаточно похожая запись
(Jaccard основ ≥ 0.7) не плодится, а подкрепляется, причём более подробная
формулировка побеждает — повторяют факт обычно ради уточнения.
Строка воспоминания несёт возраст и важность (id | kind | age | importance | content): без возраста вчерашняя правда читается наравне с годовалой.
Выборка ограничена не только счётом (memory.top_k), но и порогом
относительной силы: всё слабее memory.recall_floor (0.15) от ЛУЧШЕГО
счёта в этой же выборке отбрасывается. Счёт без порога всегда добирает
список до конца, и запись, разделившая с запросом одно частое слово,
занимает место наравне с попаданием. Порог именно относительный: BM25
между запросами не нормирован, и абсолютная граница резала бы наугад.
core идёт мимо скоринга и порогом не задет.
Секция памяти в промпте ограничена бюджетом memory.prompt_budget (8000
символов по умолчанию), и бюджет делится: core занимает не больше 60% —
иначе разросшийся core вытесняет выборку целиком, и весь скоринг работает
вхолостую. За краем доли первым выпадает наименее важное, а из равного по
важности — молодое; о выпавшем секция говорит вслух и просит свернуть лишнее,
потому что молча обрезанная память читается как «это всё, что я помню». В
чужих публичных турах выборка режется до memory.public_top_k; личное не
показывается ни там, ни в турах Господина из недоверенного места — этим
выборка не режется.
Что из этого доехало до модели, видно снаружи: GET /v1/memories/prompt?q=…
отдаёт секцию ровно в том виде, в каком её несёт тур (&public=true — как
из-за пределов доверенного окружения), и ничего при этом не подкрепляет.
Эмбеддинги досчитываются батчами на старте — и тем, у кого вектора нет, и тем, у кого он посчитан другой моделью: пространства разных моделей несопоставимы, и косинус между ними — шум с весом 0.5. Косинус считается только с вектором активной модели; всё это best-effort — не смогли, работаем на лексике.
Сон: консолидация
Без ухода память только копится: дедуп ловит лишь лексически похожее,
полураспад прячет старое, но не удаляет, противоречия сосуществуют, а
важность сползает в сплошные 4–5. Поэтому раз в сутки (секция sleep
конфига) ядро спит — как человеческий медленноволновой сон
переплавляет эпизоды в знание, так этот перебирает весь корпус и приводит
его в порядок.
Сон дожидается тишины (разговор отшумел idle_for, ни одной живой
генерации — крон и подагенты считаются) и идёт именованными этапами: за
консолидацию отвечает этап memory (секция sleep.memory), после него
идёт этап tools — курирование наборов инструментов.
Мелкий корпус (min_memories) пропускает только этап памяти: ночь с одним
этапом tools всё равно наступает. Фазы этапа памяти:
- Гигиена без модели — доэмбед упавших векторов, снятие архива старше
archive_ttl. - Кластеризация без модели — граф похожести по лексике (Jaccard основ ≥
cluster_jaccard) и семантике (косинус ≥cluster_cosine); связные компоненты — кластеры, крупнееmax_cluster— режутся по слабейшим рёбрам.coreучаствует наравне: противоречие «core против свежего факта» ловится только так. - Разбор кластеров — один вызов модели на кластер: слить дубли,
развести противоречие в пользу свежего (или переписать с датировкой,
когда старое состояние ещё имеет смысл), расщепить запись-склейку,
вывести из череды однотипных событий факт. Устоявшиеся кластеры
(
consolidated_atновее правок) модели не уезжают — вторая ночь подряд на неизменном корпусе не стоит ни одного вызова. - Забывание вне кластеров — кандидатов считает ядро арифметикой (старое по полураспаду, ни разу не вспомненное, важность ≤ 2, не core); модель может список только сократить — добавить в него нельзя.
- Виды, важность, бюджет core — один вызов на весь корпус в сжатом
виде: поднять в
coreто, к чему обращаются постоянно, опустить оттуда мёртвое, перекалибровать сползшую важность, ужатьcore, переросший свою долю бюджета секции.
Решает модель этапа (sleep.memory.model → llm.model.smartest →
general), с персоной в системном промпте — «что важно» есть функция
характера, и безличный судья выбросил бы ровно то отношенческое, что делает
Еву Евой, — и со всегда включённым рассуждением, поверх глобальной
настройки. Действовать во сне нельзя: единственный инструмент —
sleep_finish, которым модель отдаёт план фазы и тем её закрывает; его
аргументы и есть ответ, за вызовом ничего не идёт. Формы операций и их
смысл живут в схеме этого инструмента, а не в промпте: просить план прозой
с JSON внутри — значит регулярно получать обёртки, заборы и обрубки
(разбор прозы остался страховкой на апстрим, не понявший форс вызова).
Потолка ответа у планового вызова нет: он общий с рассуждением, и высокая
ступень съедала чатовый потолок целиком, обрывая план на полуслове.
Каждая операция обязана нести why, а применяет план ядро — с
предохранителями: max_ops и отдельный max_forget на ночь, core не
удаляется никогда (только понижение вида), записи моложе keep_recent не
забываются и не переписываются, private не снимается и заражает слияние.
Слияние сохраняет id подкреплённейшей записи и суммирует историю обращений —
spaced repetition не обнуляется.
Всё забытое — сном, тулом memory_forget или ручкой — лежит в архиве
archive_ttl и возвращается POST /v1/memories/archive/{id}/restore:
стирание обратимо, потому что память — единственное, чего не восстановишь
из репозитория и логов. Журнал ночей — GET /v1/sleep/runs, о заметной
ночи Господину уходит уведомление.
dry_run (общий на все этапы, включая tools) считает планы и пишет их в
журнал, не применяя, — режим первых недель.
Спящая Ева не отвечает: тур встаёт в очередь до пробуждения, его
SSE-поток открывается событием sleeping — клиент показывает «спит…» и
кнопку «Разбудить» (POST /v1/sleep/wake; недоделанная фаза при этом не
применяется, применённые остаются, срок следующего сна не сгорает). Уснуть,
не дожидаясь ночи, — POST /v1/sleep или тул memory_consolidate.
Сон чата
Кроме глобального сна у каждого чата есть свой, независимый. Он ленивый: время сна проходит молча, а сам сон запускается фоном на первом сообщении после срока — тур при этом не ждёт ничего, нудж уходит в bounded-канал и на горячем пути не стоит ни одного запроса к базе. Курирование, посчитанное этой ночью, применяется со следующего тура: спеки текущего уже собраны.
Первый (и пока единственный) этап — tools: модель, установленная на этот
чат, смотрит на статистику использования инструментов этим чатом и
решает, что он видит в контексте по умолчанию, а что уезжает за
tool_find/tool_load. Решение приезжает вызовом sleep_finish — того же
инструмента, которым отдаёт план глобальный сон,
и с той же поправкой: во сне действовать нечем, вызов и есть ответ.
Телеграм-чат про аниме перестаёт таскать
mcp_forgejo, кодовая сессия — maps_search, а долгий чат становится тем
умнее, чем дольше живёт.
Секция конфига — chat_sleep (см. Конфигурация).
Слой внимания, а не прав
Курирование ничего не запрещает. Оно ложится поверх проверки права
(решётка видимости): скрытое им остаётся в реестре скрытого
именем и назначением и возвращается одним tool_load. Белый список чата,
whitelist крон-джобы и гейты личности им не двигаются вовсе, а в
недоверенном туре потолок доложенного по-прежнему держит whitelist
Господина.
Отдельным слоем это сделано не из эстетики. Телеграм-мост при первом же
переключении тумблера материализует весь реестр в свою таблицу, и любой
чат, где Господин хоть раз трогал настройки, приезжает в ядро с
ToolAccess::Only([почти весь реестр]). Выражай ядро курирование сужением
доступа или через on_demand — в таких чатах оно бы либо не работало
вовсе, либо превратилось в отзыв прав.
Курирование не применяется, когда у тура есть активный
набор инструментов (набор — это уже осознанно выбранный узкий
вид, второй фильтр поверх него только запутает) и в турах подагента (их
состав задаёт тимлид). Тулы, объявленные клиентом на тур (extra_tools),
не курируются: их нет в реестре.
Курирование включается от тесноты, а не от молчания
Главное решение всей фичи. Ядро считает цену схем всех инструментов,
досягаемых этому чату. Влезает в бюджет (tools.budget_percent — доля окна
модели чата) — этап не запускается вовсе: ни вызова модели, ни расхода,
ни курирования. Прятать нечего, места хватает.
Инструмент никогда не прячется «потому что им не пользовались» — только «потому что набор не влез, и он проиграл ранжирование». Это одним махом убивает весь класс ошибок «в чат месяц не писали → у него что-то отобрали» и заодно делает фичу бесплатной для маленьких инсталляций.
Что видит модель
Знаменатель — туры, а не дни. Главная метрика инструмента не «сколько
раз звали за месяц», а покрытие: в скольких турах чата он звался хоть раз,
из скольких туров всего. «Пишу раз в месяц и всегда maps_search» в этой
метрике выглядит как 1/1, а не как «1 вызов за 30 дней».
Окна отчёта усечены по фактическому покрытию статистики: есть данные за 20
дней — окна 1 и 20, а не «за год: 0» там, где года нет. В каждом окне
названы не только вызовы, но и туры, сообщения и активные дни — по ним
видно, что выборка тощая. Окно с числом туров ниже thin_turns ядро
помечает thin, и в промпте прямым текстом: на тощей выборке набор можно
только расширять. Незнакомый чат остаётся при дефолте.
Обратная связь: loads
Ядро считает, сколько раз в этом чате инструмент пришлось достать обратно
через tool_load/tool_find. Это прямое измерение того, что прошлое
курирование ошиблось, и оно не зависит от календаря: копится только тогда,
когда чатом реально пользуются.
Правило стоит в ядре, а не на усмотрение модели: инструмент с loads >= 2
с прошлого курирования обязан остаться в наборе. Петля
самокорректируется — молчащий чат просто не курируется дальше, а зря
скрытый тул возвращается после двух промахов. Рост loads в чате и есть
метрика качества курирования: стабильно высокий значит, что модель прячет
не то, и лечится budget_percent либо chat_sleep.tools.model.
Предохранители
- Порог входа по данным, а не по возрасту чата:
min_turnsтуров в статистике. Возраст чата и возраст его статистики расходятся на форке, миграции и заброшенном чате. - Обязательное ядро (
tools.core, плюс закреплённое Господином): курирование его не прячет. Имена сверяются с реестром на старте — опечатка кричит в лог, а не тихо не работает. - Потолок за ночь (
max_hide_per_night): даже согласившись, ядро не даст одной ночи выпилить полнабора; обратно возвращаются те, у кого выше покрытие. Плохая ночь стоит восьми промахов, а не тридцати. - Сон пустого набора не пишет: план, оставляющий чат ни с чем, отбрасывается целиком. Пустой набор в записи означает не «чат видит ничего», а «курирования ещё не было»: такую строку заводит одно лишь закрепление (см. ниже), и чат при ней видит весь досягаемый реестр.
- Неизвестное имя роняет всю операцию: полуприменённый набор выглядит решением модели, а на деле это её решение минус выпавшее имя.
- Пустой план — валидный и частый ответ: набор уже хорош.
dry_runпишет план в журнал, не трогая базу.
Тулсет в наборе (set:<имя>) раскрывается в момент чтения — правки его
состава доезжают до курированных чатов сами. Тулсет с пустым или
нечитаемым списком раскрывается в ничто, а не во «всё».
Расписание и захват
Срок живёт в колонке chats.next_sleep_at. NULL — сон не планировался
(чат жил до апгрейда или только что заведён): первый нудж по такому чату не
спит, а только ставит границу. Захват — один UPDATE, двигающий срок на
retry_after; им закрываются сразу три случая: дубли нуджей, повторный
нудж во время сна и падение посреди ночи (ночь не потеряна, повторится
через час).
min_gap (дефолт 20 часов) — не интервал, а защита от двойного сна:
сообщение в 23:59 иначе запускало бы сон, тот заводил бы срок на завтрашние
00:00 — на минуту вперёд, — и сообщение в 00:05 будило бы его снова.
Не курируются: эфемерные чаты, удалённые, и полки из skip_folders
(по умолчанию подагенты и крон — у них свой узкий whitelist и однообразные
туры).
Одновременно спит не больше max_parallel чатов. Глобальный сон ждёт
дренажа снов чата наравне с живыми генерациями: иначе оба гоняли бы модель
одним провайдером. Замка над турами сон чата не поднимает — в этом вся
идея.
Снимок окружения
Сон идёт вне тура, а базовый доступ чата, гейты и модель существуют только
внутри тура: доступ приезжает полем tools от вызывающего, гейты
собираются на входе, модель резолвится цепочкой «чат → поверхность →
конфиг». Реконструировать их эвристиками — гадание, поэтому ядро
запоминает фактическое в конце каждого тура (chat_turn_env), и только
если что-то изменилось: обычно это ноль записей.
Гейты копятся по ИЛИ за цикл курирования — один чат ходит и из TUI (с
client_ops), и с телефона, и набор обязан годиться обоим; после удавшегося
курирования копилка начинается заново. Доступ и модель, наоборот, берутся
от последнего тура: это текущая воля Господина, а не история. Строки нет
(чат ни разу не ходил после апгрейда) — этап пропускается.
Запустить руками
chat_sleep_start (M, семейство chat) — курирование ЭТОГО чата прямо
сейчас, не дожидаясь границы суток. В отличие от ночного пути он крутится
прямо в туре: фазы капают клиенту живым прогрессом (tool_progress),
как у свёртки истории, а отчёт возвращается модели текстом — ей же и решать,
что с новым набором делать дальше.
Пороги при этом остаются: слишком мало данных или досягаемый набор и так влезает в бюджет — тул честно скажет, почему пропустил, и не потратит ни одного вызова модели. Срок он двигает так же, как ночной запуск, иначе форс и расписание передрались бы. Второй запуск поверх идущего не пройдёт — та же защёлка, что у ночного пути.
Снаружи то же самое делает POST /v1/chats/{id}/sleep, только фоном и без
прогресса.
Смотреть и вмешиваться
GET /v1/chats/{id}/tool-profile— что чат видит по умолчанию;visible— хранимое решение,effective— оно же раскрытое (тулсеты развёрнуты, ядро и закреплённое добавлены), то самое, что применяет тур. Длину этих списков показывать нельзя: их записи — имена тулов И семейств, и за одним именем стоит хоть сорок инструментов. Для показа естьin_contextиreachable— разрешённые имена, раскрытые до самих тулов по гейтам и доступу ЭТОГО чата: внимание и право двумя честными числами. Пока чат ни разу не ходил, обаnull— гадать ядро не станет.PUT /v1/chats/{id}/tool-profile— поставить набор руками или закрепить инструмент (pinned): закреплённое ни одна ночь не спрячет. Одно закрепление курировать чат НЕ начинает: набор остаётся пустым,curatedостаётсяfalse, чат по-прежнему видит всё досягаемое — булавка просто ждёт первого сна и связывает ему руки. Так тул защищают заранее, ещё до того, как чат вообще курировался.DELETE /v1/chats/{id}/tool-profile— снять курирование.POST /v1/chats/{id}/sleep?dry_run=true— усыпить чат сейчас, не дожидаясь срока. Пороги по данным и ранний выход по бюджету при этом остаются: они не про расписание, а про смысл.GET /v1/chats/{id}/tool-stats?window=30— та самая таблица, что видит модель.GET /v1/chats/{id}/sleep/runsиGET /v1/sleep/chat-runs— журнал.
Уведомлений по каждому чату нет — их были бы десятки за ночь. Вместо них строка в отчёте глобального сна: «chat curation: 7 chats, +12/−31 tools».
Цена
Смена набора двигает префикс спек и рвёт кэш промпта
этого чата один раз. Раз в сутки на чат приемлемо — но именно поэтому
курирование обязано быть редким и стабильным, а не «каждую ночь
по-новому»; отсюда max_hide_per_night и правило «пустой план — валидный
ответ».
Вес не исчезает, а переезжает: из пространства спек (полные схемы, дорого)
в реестр скрытого, который делит с навыками и наборами общий потолок —
2% окна, но не больше 16000 символов на все каталоги. Реестр скрытого при
этом заметно разрастается, и его жалоба на сильное усечение описаний значит
«скрытого стало столько, что имена перестают влезать». Лечится это не
курированием, а tools.on_demand и составом реестра.
Блокнот
Блокнот, который Ева ведёт для себя внутри одного чата. Не история и не
резюме компакции: туда попадает то, что она решила не потерять, — и
оно переживает и окно живой истории, и компакцию, и chat_clear.
Три соседних механизма легко перепутать:
| чей | как достаётся | чем живёт | |
|---|---|---|---|
| Память | глобальная, мимо чатов | ранжированием (BM25 + эмбеддинги), core — всегда | про Господина и про мир |
| Резюме компакции | пер-чатное | автоматически, по порогу символов | «что было» в разговоре |
| Блокнот | пер-чатный | целиком либо индексом, по размеру | выжимка работы: решение и почему, инвариант, путь/команда/id, тупик, состояние задачи |
Резюме блокнота не заменяет: оно пишется постфактум чужой моделью и не знает, что из тура было важным.
Три режима
Режим считается по размеру блокнота на входе в тур и внутри тура не меняется: от него зависит список инструментов, а тот идёт в самом начале запроса и держит кэш промпта.
- inline — заметок мало: тела едут в промпт дословно. Инструмента чтения модель не видит вовсе, читать нечего — всё перед глазами.
- index — заметок много: в промпт едет только
имя — описание(плюс закреплённые дословно), появляетсяchat_note_read. - compact — заметок очень много: после тура запускается пересборка блокнота, после неё размер снова падает в режим 1 или 2.
Третий — не состояние промпта, а событие: в промпте всегда либо 1, либо 2.
Пороги и гистерезис
Считаем в символах тел (три заметки по 4 КБ хуже двадцати по сотне) и в штуках заодно.
| вход в режим | возврат | |
|---|---|---|
| inline → index | тела > inline_chars или > inline_max штук | тела ≤ inline_back_chars и ≤ inline_back_max штук |
| → пересборка | тела > compact_after_chars или > max_notes штук или индекс > index_max_chars |
Гистерезис обязателен: без него блокнот на границе мигает каждый ход,
chat_note_read то появляется, то исчезает, и кэш промпта рвётся на
списке инструментов. Режим прошлого входа лежит в chats.notes_mode.
Прячется chat_note_read не отдельным фильтром, а штатным гейтом реестра
(Tool::indexed_notes_only, наравне с master_only). Мимо гейта он остался
бы в реестре скрытого, и модель вытащила бы его tool_load’ом — правило «в
режиме inline инструментов чтения не существует» протекло бы.
Закреплённые заметки
Флаг pinned — аналог core у памяти: такая заметка едет дословно в любом
режиме, отдельным бюджетом (pinned_chars символов, pinned_max штук).
Нужен для «цель текущей задачи», «команда сборки этого проекта» — того, за
чем в режиме index модель ходила бы chat_note_read каждый ход. Пересборка
обязана их сохранить: пропавшую закреплённую ядро возвращает из старой
версии дословно.
Бюджет булавок не опускается ниже max_note_chars — иначе законная по
размеру заметка не закрепляется ни при каком раскладе. Отказ говорит, что
чинить: не влезающая сама заметка — резать её, перебор суммой — называет,
кто бюджет занял. Мимо этой проверки булавка всё же приезжает (возврат из
архива, опущенный руками потолок): такую блок не цитирует, но называет по
имени с пометкой сходить за телом — и остальные закреплённые от неё не
страдают.
Давление истории
У памяти повод записать приходит снаружи («он сказал это один раз»), у
блокнота повода нет вовсе: тур оптимизируется на «ответить и закончить», а
цена «не записала» приходит через десять туров и ни с каким решением не
связывается. Поэтому рядом с блокнотом в [state] едет строка [window] —
факт, не понукание:
- сколько осталось — токены разговора и порог, за которым он свернётся
в резюме. Показывается, когда до ближайшей стены — порога компакции или
жёсткого потолка модели (
context_window) — остаётся меньше 40% порога (PRESSURE_AT_PERCENT= 60): дальше свёртка далеко, и строка была бы шумом каждый ход; - сворачивайся — приглашение закончить шаг и отчитаться ДО компакции:
она необратима, а предупреждение за
pressure.wrap_up_reserve_tokens(8000) токенов до стены обычно позволяет доделать и не потерять ничего. Один раз на окно (до следующей свёртки) и только когда компакция не сработает сама прямо сейчас — иначе были бы и предупреждение, и свёртка в одном ходу; - свёртка только что случилась — вместо цифр одноразовый пинок: подробности ещё в свежем резюме, но через тур забудутся. Это самый сильный момент для записи, и он бывает раз на свёртку.
Мера — честный счёт токенов (tokens.rs): якорь prompt_tokens провайдера
плюс оценка неучтённого хвоста, в той же области (compact_scope), которой
меряет компакция, и с тем же профильным порогом (compact_budget): крон,
агрессивные папки и чаты поверхностей жмутся жёстче лички. Иначе промпт
врал бы о том, когда исчезнет хвост.
Одноразовость пинка про свёртку держит chats.notes_nudged_upto, а
приглашения свернуться — chats.wrap_nudged_upto: в обоих summary_upto,
для которого отметка уже показана. Условие производное — сама компакция (и
ручная chat_compact заодно) о пинках ничего не знает.
Пустой блокнот под давлением обязан дать блок: молчать ровно в том состоянии, из которого надо выбираться, значит бить мимо главного случая — долгой кодовой сессии, где блокнота ещё нет, а терять уже есть что.
Тексты трёх ступеней настраиваются (context.pressure) — триажным моделям
нужна другая формулировка; пороги ступеней — константы.
С другой стороны о том же говорят правила [notes]: писать в тот же тур,
когда узнала. Внутри длинного тура потеря начинается ещё раньше свёртки —
свежий выхлоп инструмента капается при добавлении в промпт, а уехавший за
окно живого трафика схлопывается в огрызок: файл, прочитанный десять
вызовов назад, из промпта уже ушёл, пока работа продолжается. Тур,
переросший долю порога компакции, поджимает свой трафик прямо на ходу
(context.turn_squeeze_percent) — и говорит об этом в [state]: то, что
нужно донести до конца хода, живёт в заметке, а не в выхлопе.
Вес заметки и чем за него плачено
Заметка имеет вес: рамки «запись, а не правило» у блока [notebook] нет, и
правила формы («описывает, а не велит») — тоже. Написанное в блокноте
читается как своё, а не как справка о чужой просьбе.
Плата за это — риск, и он не теоретический. Недоверенный текст приезжает в
рабочий чат не от человека: из web_fetch страницы, из README чужого
репозитория, из выхлопа shell, из доклада подагента, из ответа
MCP-сервера. Модель прочитала, сочла выводом, записала — и получила
бессрочную инструкцию с полными правами. Отследить это происхождением тура
нельзя: недоверенный ввод есть почти в каждом рабочем туре. Держат этот
риск два рычага:
- заметка не отменяет правил: противоречит стоячим правилам или просит их бросить — заметка неправа, о чём сказать вслух и удалить;
- видимость записи — теперь главный: инъекция живёт, пока незаметна.
Каждая запись и правка — обычный вызов инструмента в ленте тура, клиенты
рисуют его сами (иконка 📝); панель показывает дату, дифф последнего
изменения (
prev_body→body) и метит изменившееся с прошлого захода.
master_only у семейства chat_note_* гейтит тур, а не автора текста:
зовёт эти инструменты сама Ева, в туре Господина, и записанное ею — обычный
путь в блокнот. Господин пишет туда мимо тура, PUT /v1/chats/{id}/notes/{name}. В чужих турах блокнот в промпт не идёт вовсе.
Инструменты
Все в группе chat, все master_only + lead_only (подагенту блокнот не
показывают — значит, и писать в него нечем) и тихие в лентах-мессенджерах
(quiet), результат помечен uneventful.
| тул | когда виден | что делает |
|---|---|---|
chat_note_save | всегда | создать или переписать заметку целиком по имени |
chat_note_forget | всегда | удалить заметку |
chat_note_read | только в режиме index | достать тела пачкой по именам |
chat_note_compact | всегда | пересобрать блокнот сейчас, не дожидаясь порога |
Жёсткие лимиты на запись: имя ≤ 48 символов (slug [a-z0-9_-], уникален в
чате), описание ≤ 120 (оно живёт в промпте в режиме index), тело —
max_note_chars. Перебор — внятная ошибка «разбей на две заметки», а не
молчаливое обрезание.
Затирание вслепую: в режиме index модель видит только описание, и
chat_note_save под существующим именем снёс бы тело, которого она не
читала. Поэтому перезапись существующей заметки в этом режиме требует, чтобы
её прочитали в этом же туре, — иначе отказ со словами «сначала
chat_note_read». В режиме inline тело и так перед глазами, проверки нет.
Поиска по телам нет намеренно: сорок строк индекса модель разбирает глазами, а BM25 здесь — машинерия ради машинерии.
Пересборка
Запускается после тура, там же, где компакция истории: ответ Господину к этому моменту уже утёк клиенту, и пересборка его не задерживает. Тур об неё не роняем.
Модель — та, что отвечает в этом чате (notes.model перекрывает). Заметки
писала она и себе, своей стенографией; summary_model их причешет и
обезличит. Вызов одноразовый, без инструментов и без записи в кэш промпта;
расход ложится на чат и виден в /v1/chats/{id}/spend под меткой notes.
На вход — все заметки с телами и резюме компакции этого чата: без него «выкинь отработавшее» решать не на чем — заметка о задаче и заметка о её решении выглядят одинаково. Резюме идёт строго на чтение, в явной рамке «не переливай отсюда факты в блокнот»: те и так доезжают до модели другой дорогой, а держать одно и то же дважды — ровно то, что пересборка и лечит.
На выход — новая версия блокнота целиком:
## <имя> | <описание>
<тело>
Задача формулируется как цель по размеру: уложись в compact_target_chars и
max_notes заметок; слей дубли, выкинь отработавшее, закреплённые сохрани.
Просить «сделай режим inline» незачем — режим посчитается сам из размера
результата.
Проверки перед подменой (иначе один кривой ответ стирает блокнот):
- разобралась хотя бы одна заметка;
- итог реально меньше исходного;
- все
pinnedна месте — пропавшую возвращаем из старой версии дословно; - имена уникальны, поля в лимитах (иначе режем и логируем).
Не прошло — оставляем старый блокнот и пишем warn. Одна повторная попытка с вдвое более жёстким потолком, дальше не крутим.
Перед подменой старый блокнот целиком уходит в chat_notes_archive (одна
строка на чат, перезаписывается) — вернуть его можно ручкой
POST /v1/chats/{id}/notes/restore. Это единственная в схеме операция с
необратимой потерей, поэтому страховка обязательна. Архив хранит записи
целиком, а не дамп ## имя | описание: дамп — формат для модели, булавки и
даты рождения в нём не выражены, а возврат обязан вернуть заметку той же
самой, а не одноимённой новой.
prev_body пересборка заполняет по общему правилу: у выжившей заметки туда
ложится тело до пересборки (ровно то, что Господин захочет сличить), у
родившейся при слиянии — пусто, она новая. Пометку «новое» пересборка
сбрасывает всему блокноту: пересобрано всё.
Смежное поведение
chat_delete— заметки уходят с чатом каскадом;chat_clear— заметки оставляет и говорит, сколько оставил: смысл блокнота в том, чтобы пережить обнуление;- блокнота у подагента нет: он живёт одну задачу, и всё, что должно её
пережить, едет наверх докладом. Правила и блок ему не показывают, а
инструменты заперты
lead_only— тур подагента мастерский по флагам (sender = None), и одногоmaster_onlyне хватило бы; - крон-чаты — главный выигрыш: состояние между запусками наконец переживает агрессивную компакцию;
notes.enabled: false— тулы не регистрируются, блок в промпт не идёт, база не трогается.
API
| ручка | что делает |
|---|---|
GET /v1/chats/{id}/notes | {mode, total_chars, seen_at, archive, notes} |
PUT /v1/chats/{id}/notes/{name} | создать/переписать ({description, body, pinned}) |
DELETE /v1/chats/{id}/notes/{name} | удалить |
POST /v1/chats/{id}/notes/compact | пересобрать сейчас |
POST /v1/chats/{id}/notes/restore | вернуть архивную версию |
POST /v1/chats/{id}/notes/seen | блокнот просмотрен: гасит пометки «новое» во всех клиентах разом |
Список отдаёт prev_body вместе с телом — дифф считает клиент, ядру незачем
возить готовый патч. Отметка захода серверная: иначе web, TUI и Android
разошлись бы в том, что считать новым, и «новое» перестало бы что-либо
значить. Живого события нет — клиент перечитывает блокнот по завершении тура
(done), как уже делает с саммари.
План работы
Список того, что в идущей работе ещё не сделано: фазы, нумерованные задачи и ровно одна взятая в работу. Ведёт его Ева сама; просить план у Господина не надо.
Смысл не в галочках. Длинный тур теряет вторую половину задачи молча: окно живой истории сворачивается в резюме, выхлоп инструментов из начала тура схлопывается до огрызка, и «а ещё надо поправить книгу» исчезает вместе с ними. План этого не переживает — он живёт в базе и приезжает в промпт заново каждым ходом.
Чем это не блокнот
Блокнот — про факты, которые переживают работу: решение и почему, инвариант проекта, путь, добытый раскопками. План — про незавершённое, у него есть инвариант, и он обязан исчезнуть по завершении дела. Смешать их значит забить блокнот мёртвыми галочками и задавить ими выдачу.
Инвариант
Ровно одна задача в работе. Держится кодом, а не просьбой в промпте:
startснимает активность с прежней задачи;- после любой операции активных не осталось — активной становится первая
pending; - активных вдруг несколько — остаётся первая, прочие возвращаются в
pending.
Ошибочная ссылка на несуществующую задачу или фазу откатывает всю операцию и возвращается ошибкой с перечнем живых номеров и фаз: полусделанная мутация хуже отказа. Технически список считается в памяти целиком и пишется целиком — состояния «половина применилась» не бывает.
Жизнь плана
| когда | что происходит |
|---|---|
| вход в тур | закрытые и брошенные задачи вычищаются: список — про оставшееся |
| в туре | ответ каждого вызова — состояние плана целиком, а не «ок» |
| конец тура | план, в котором не осталось незакрытого, стирается вместе с последней галочкой |
Стереть план руками — init с пустым списком задач или
DELETE /v1/chats/{id}/plan.
Когда инструмент виден
Не всегда. Пустой план в обычной болтовне — строка мёртвого веса в каждом
запросе и соблазн расписать «поздороваться / ответить». turn_plan даётся
туру Господина, который к тому же:
- ведёт клиентские операции (кодовая сессия клиента),
- работает в наборе с
narrate(кодовые и инфраструктурные наборы), - либо это тур подагента,
- либо план в чате уже открыт — недоделанное нельзя спрятать.
Прячется он штатным гейтом реестра (Tool::planning_only, наравне с
master_only), а не отдельным фильтром: мимо гейта он остался бы в реестре
скрытого, и модель вытащила бы его tool_load’ом ровно там, где план и
есть мёртвый вес.
Операции
Одна операция на вызов, дискриминатор op на верхнем уровне — массива
операций нет: модель видит состояние после каждой правки.
op | что делает |
|---|---|
init | заменить план целиком; пустой tasks — стереть |
append | дописать задачи в открытый план |
start | взяться за задачу по номеру |
done | закрыть задачу, фазу целиком или (без аргументов) ту, что в работе |
drop | то же, но «бросить»: задача перестала иметь смысл |
view | просто вернуть план |
Задача — {title, phase?}. Фаза — просто имя группы, регистр не считается;
новая задача встаёт в конец своей фазы, незнакомая фаза — в конец
плана, поэтому дерево остаётся деревом при любой дописке. done/drop
принимают outcome — одну строку о том, чем кончилось.
Потолки: 40 задач, 120 символов на заголовок, 40 на имя фазы. Перебор — внятный отказ, а не молчаливое обрезание.
Где он в промпте
Только летучим хвостом — в блоке [state], рядом с памятью и
блокнотом. В стабильную часть нельзя: план меняется каждую итерацию и рвал
бы префикс кэша промпта каждый ход.
Выглядит деревом с отметками [ ] / [>] / [x] / [~]:
[plan] Your plan for the work at hand — 4 tasks: 1 done, 0 dropped, 3 left.
## dig
1 [x] find every call site — all in agent.rs
2 [>] read the config schema
## build
3 [ ] add the field
4 [ ] update the book
Ровно то же возвращает и сам инструмент: модель видит после правки то, что увидит следующим ходом.
Клиентам
GET /v1/chats/{id}/plan→{tasks: [{id, phase?, title, status, outcome?}]}; пустой список — плана нет. Ручка для первой отрисовки и переподключения.DELETE /v1/chats/{id}/plan→{deleted}.- Событие стрима
planс полемtasks— состояние целиком, а не дифф; прилетает на каждую правку и с пустым списком, когда план закрыт по концу тура. В историю не персистится.
Граница
Это состояние одной работы в одном чате, а не менеджер задач Господина.
Его задачи живут в кроне, очереди одобрений и папке problems — у них своя
жизнь и свои сроки.
Правила поверх генерации
Правило смотрит на то, что Ева сделала, и вмешивается на месте. Строка в системном промпте платится каждым запросом и всё равно теряется к пятидесятому ходу; правило молчит, пока промаха нет, и говорит ровно там, где промах случился.
Правила — данные, а не код: таблица stream_rules, мастерский CRUD
(stream_rule_list / _create / _edit / _delete) и первичный посев один
раз за жизнь базы, как у наборов.
Что это НЕ заменяет
Правило — для того, что модель знает, но забывает. Если промах системный (модель не знает, что так можно), это строка в правилах промпта, а не правило: напоминание после факта не научит тому, чего в промпте не было.
Две половины
| половина | scope | mode | что делает |
|---|---|---|---|
| мягкая | tool:<имя> | remind | напоминание в результат вызова, тур идёт дальше |
| жёсткая | text | interrupt | готовый ответ выбрасывается, ход переписывается |
Половина у каждого scope своя, и пара проверяется на входе: исполненный
вызов уже не выбросить, а сказанному ответу некуда дописать напоминание.
mode поэтому обычно не называют — он следует из scope.
Мягкая
Смотрит на имя и аргументы вызова и, если совпало, клеит спереди к результату этого вызова блок:
<system-reminder rule="local_shell_for_his_machine">…текст правила…</system-reminder>
Вклейка идёт только в промпт-копию результата. В базу и в стрим клиента уходит чистый выхлоп инструмента: напоминание — служебная вставка, а не речь и не история. Стрим при этом не рвётся, префикс кэша промпта не страдает, лишнего похода к провайдеру нет.
Порядок в точке вклейки важен: сначала снимается служебная метка «пусто»,
потом выхлоп режется капом context.tool_result_max_chars, и только потом
сверху ложится напоминание — кап считается по выхлопу инструмента, а не по
правилу.
Жёсткая
Смотрит на готовый ответ перед его фиксацией — не по дельте: тур уже оплачен, зато ретрай даёт исправленный ответ вместо неверного. Промах — ответ выбрасывается целиком (в базу не попадает, клиенту не уходит, объявленные в нём вызовы не исполняются), а в промпт-копию тура ложится реплика:
<system-interrupt reason="rule_violation" rule="telegram_reply_tail">…текст правила…
That reply was discarded and NOBODY saw it. …</system-interrupt>
Дальше ход играется заново с той же историей. В базу и клиенту уезжает
только исправленный ответ; сам <system-interrupt> живёт лишь в промпте
этого тура и никуда не сохраняется.
Пока текстовое правило заряжено, речь придерживается. Отданную клиенту реплику назад не забрать, поэтому текст и объявленные вызовы копятся и уходят одним куском ровно тогда, когда ответ дописан. Ожидания это не добавляет — теряется только эффект печатной машинки, и только в туре, где такому правилу есть чем сработать: без заряженного правила стрим идёт как раньше, буква за буквой.
Потолок ретраев — один на ход. Второй промах в том же ходу ответ уже не выбрасывает: он остаётся как есть, а правило говорит вдогонку мягко — напоминание едет на ближайшем результате инструмента. Ход, который кончился этим ответом, такого напоминания не увидит: след останется в телеметрии и в логе.
Зациклить тур правилу нечем и без потолка: политика повтора считается по ходу чата, а он внутри тура не растёт, — сработав, правило до конца хода молчит. Потолок закрывает другой случай: несколько разных правил, каждое из которых требует своего ретрая.
Форма правила
| поле | что значит |
|---|---|
name | ключ дедупа и повтора; он же едет модели в атрибуте тега |
scope | tool:<имя> — вызов инструмента; text — готовый ответ. Мыслей у нас в истории нет, thinking не поддерживается |
condition | regex по цели: аргументы вызова компактным JSON либо текст ответа. Пусто — совпадает любая цель этого scope |
negate | сработать, когда regex не нашёлся: «не хватает хвоста» иначе не выразить — в rust-regex нет lookaround |
when | условия окружения: telegram, client_ops, master, trusted, picture. Все обязаны выполниться; пусто — любой тур |
text | что подставится модели (до 400 символов) |
mode | remind | interrupt; следует из scope, называть не обязательно |
repeat | once — раз на чат навсегда; after:<N> — не чаще раза в N ходов чата |
enabled | выключенное правило не поднимается, но не теряется |
Без when правило про телеграм палило бы в тихом чате: окружение берётся у
самого тура, а не угадывается по тексту. picture — картинка в контексте
тура, которую модель действительно видит: слепой она приезжает текстовой
пометкой, и «не вижу» из её уст правда, а не промах.
Без repeat правило говорит одно и то же каждым вызовом, и напоминание
перестают замечать. Ход считается ответом модели; правило, сработавшее в
туре, в этом же туре не повторится.
Невалидная regex в базе — правило не регистрируется, warning в лог, тур идёт дальше: ядро не падает из-за кривых данных. На входе (создание, правка) такая regex просто не принимается.
Что посеяно
Шесть хронических промахов: два мягких, четыре жёстких.
| правило | половина | ловит |
|---|---|---|
write_diff_over_write_file | мягкая | write_file — напомнить, что правка существующего файла идёт через write_diff |
local_shell_for_his_machine | мягкая | shell в туре с клиентом — это машина ядра, а не машина Господина |
telegram_markdown_image | жёсткая |  в телеграмном ответе — там markdown не рендерится |
telegram_reply_tail | жёсткая | ответ в телеграм без хвоста ↩ #id (правило с negate) |
look_before_refusing | жёсткая | «не могу посмотреть картинку» там, где картинка в контексте и видна |
telegram_react_not_emoji | жёсткая | голый эмодзи вместо вызова telegram_react |
Посев — один раз за жизнь базы. Правку правила Господином ядро отличает по
updated_at: волна калибровки (когда меняются сами тексты посева)
переписывает только то, к чему он не прикасался, и переписанное им не
трогает.
Телеметрия
Сработавшее правило никогда не молчит: строка stream rule fired (у
текстового — stream rule fired on the answer, с полем retry: переписан ли
ход) в лог и запись в stream_rule_stat. Иначе через месяц никто не поймёт,
откуда в контексте посторонний текст и куда делся ответ. Свод — в
GET /v1/stats, поле rules:
"rules": [{ "rule": "local_shell_for_his_machine", "fired": 3 }]
Ноль срабатываний у правила — тоже ответ: либо промах вылечен, либо условие мимо.
Записи переживают своё правило: удалить правило — не то же самое, что переписать историю.
Советчик
Работающий агент видит свой тур изнутри — и оттуда не видно, что он час чинит не тот файл. Советчик — вторая, дешёвая модель со своей историей: раз в несколько итераций ей показывают дельту транскрипта (что Ева сказала, что позвала, что вернулось), она при желании читает своими инструментами и, если увидела промах, кладёт в тур одну заметку:
<advisory severity="concern" guidance="weigh, don't blindly obey">…</advisory>
Блок клеится спереди к результату инструмента только в промпт-копии — тем же каналом, что и правила. В базу и в стрим клиента уходит чистый выхлоп: это служебная вставка, а не речь.
Ева про советчика в промпте не знает вовсе. Тег со своим guidance — её
единственная подсказка, как к этому относиться: взвесь, не подчиняйся
слепо. Советчик не начальник, и заметка, в которой он неправ, обязана
проиграть тому, что Ева видит своими глазами.
Где он включается
| условие | зачем так |
|---|---|
задана llm.model.advisor | без роли советчика нет вовсе: он удваивает разговор с моделью, и это решение Господина, а не дефолт |
advisor.enabled | заткнуть, не убирая роль |
| рабочий тур | клиентские операции, рабочий режим набора (он же кодовый) или подагент — там легче всего увязнуть, и там ошибка дороже |
| тур Господина | в чужом туре он комментировал бы чужую переписку |
В болтовне советчика нет ни при каких настройках: вторая модель, читающая бытовые реплики, — это комментатор за живые деньги.
Цена
Цикл идёт каждой every_iterations-й итерации рабочего тура и стоит от одного
до max_steps вызовов дешёвой модели плюс его чтения. Платим ещё и
латентностью: цикл идёт внутри тура, между итерациями, и не уложившийся в
timeout просто выбрасывается — тур продолжается без него. Кулдаун после
заметки цикл не запускает вовсе, так что говорящий советчик обходится дешевле
молчаливого.
Инструменты
Только читающие: read_file, find_pattern, history_search. Мутирующих у
советчика не будет никогда, даже под флагом: за файловыми инструментами стоит
настоящая машина Господина. Список — детерминированный белый, а не просьба в
промпте: любое другое имя отбивается кодом.
Голос у советчика один — инструмент advisory_note (severity, text). В
реестре ядра его нет: Еве он не виден и не доступен.
Щит вокруг заметки
Без щита вторая модель превращает тур в поток «продолжай, ты молодец». Щит
живёт в коде (src/advisor.rs), а не в промпте советчика:
| правило | что делает |
|---|---|
| нормализация | NFKC, нижний регистр, всё неалфавитное — в пробел: перефразировка пунктуацией не становится новой мыслью |
| чёрный список | «lgtm», «продолжай», «замечаний нет», «готово», «стоп» и родня. Молчание — правильная форма «всё хорошо» |
| дедуп кольцом | та же заметка второй раз не проходит (32 последних на чат) |
| одна за цикл | вторая — подавляется; подавленные потолок max_notes не расходуют |
| кулдаун | после вклеенной заметки советчик молчит cooldown_cycles циклов: иначе он комментирует собственное вмешательство, не дав ему сработать |
| сброс | перезапись истории (компакция, пересборка резюме, chat_clear) стирает всю его память о чате — иначе он рецензирует то, чего уже нет |
Подавление невидимо советчику: на любую заметку инструмент отвечает «записано». Скажешь ему «подавлено» — он перефразирует ту же бесполезную мысль в обход дедупа, и заплатит за это Господин.
Ступени
severity — nit | concern | blocker. Ступень доезжает до Евы атрибутом
тега, но обрабатываются все три одинаково мягко: идущий тур советчик не
прерывает. Жёсткая половина (обрыв и ретрай) сядет вместе с жёсткой половиной
правил — до тех пор blocker отличается от nit только тем, как
Ева его прочтёт.
Телеметрия
Каждая заметка — вклеенная и подавленная — пишется в advisor_notes, а
вклеенная ещё и в лог. Свод — в GET /v1/stats, поле advisor:
"advisor": [{ "verdict": "spliced", "notes": 2 }, { "verdict": "hollow", "notes": 7 }]
Подавленные тут важнее вклеенных: по ним видно, во что вторая модель превращается без щита. Пустой список — советчик либо выключен, либо ни разу не попал в рабочий тур.
Настройка
llm:
model:
advisor: "qwen/qwen3.7-flash" # без этого советчика нет
advisor: # вся секция опциональна
enabled: true
every_iterations: 3 # цикл каждой третьей итерации рабочего тура
max_steps: 3 # его собственных шагов за цикл
max_notes: 2 # вклеенных заметок за тур
cooldown_cycles: 3 # молчания после заметки
delta_max_chars: 6000 # потолок дельты транскрипта
timeout: 60s # не уложился — тур идёт дальше без него
Навыки
Навык — это имя, однострочное описание и тело-инструкция, всё в SQLite.
Список name: description висит в системном промпте (дёшево и кэшируется);
когда задача совпала, модель зовёт skill и получает полное тело —
progressive disclosure, отдельный роутер не нужен. Если у навыка задана
model, тело исполняется этой моделью изолированно (без истории чата, как
code_task) и назад едет результат; иначе инструкции следует основная
модель со всем контекстом и инструментами. Заводить и править навыки может
только Господин (skill_create / skill_edit / skill_delete) — я учусь
на ходу, без редеплоя.
Тело — до 20000 символов. Потолок стоит на записи, а не на выдаче: тело едет в промпт целиком, и плейбук с дырой посередине хуже отсутствующего. Инструкция длиннее делится на навыки поменьше.
У самого списка тоже есть потолок — доля окна модели (2%, но не больше 16000 символов) на ВСЕ каталожные блоки разом, а не «сколько навыков завели, столько и платим». Доля нужна, чтобы список ужимался у маленькой модели; потолок — чтобы у миллионника он не разросся до десятков тысяч символов, оплачиваемых каждым запросом. Пока всё влезает, список едет целиком; стало тесно — описания ужимаются по кругу, по символу с каждого, чтобы многословный навык не выедал бюджет соседей; не влезают даже имена — хвост списка выбрасывается, и об этом говорит warning в логе. Имя держится до последнего: навык, о котором модель не знает, для неё не существует. Тот же распределитель держит списки наборов и реестр скрытых инструментов.
Кэш промпта
Префикс-кэш провайдера живёт, пока префикс байт-в-байт совпадает, поэтому промпт собран по летучести:
- системный промпт целиком стабилен между ходами (персона, правила, файлы
проекта
[project], списки навыков и наборов) и весь лежит подcache_control-breakpoint’ом. Файлы проекта ради этого читаются ОДИН раз на входе в тур: блок байт-стабилен между итерациями, а не перечитывается на каждой отправке; - инструменты лежат в префиксе перед system, поэтому смена
набора рвёт кэш один раз, а
scope: "turn"— ещё раз на следующем ходу, когда набор откатится. Потому дефолт —chat. Узкий набор при этом облегчает вход каждый ход, и на длинной сессии это окупается с запасом; - летучее —
[now], recall памяти, память о чате — собрано в блок[state]и едет последним блоком последнего сообщения, то есть после всей истории: префикс «инструменты + system + история» переживает ход, и якорь кэша внутри истории попадает. Живи[state]в system, «сейчас» рвало бы весь префикс каждый ход; - атрибуция чужого хода едет отдельным якорем и переезжает на свежий
tool_resultкаждой итерации: сводка перед циклом к концу тула-тяжёлого тура оказывается под десятками сообщений выхлопа, и «речь выше» становится ложью. Носитель якоря и так новый — кэш от переезда почти не страдает; - байтовое схлопывание тул-трафика (и результатов, и длинных строк входа —
граница у них одна) — один раз на входе в тур: внутри тура
история почти append-only, и каждая итерация агентного лупа читает префикс
из кэша. Единственное исключение — поджатие длинного тура
(
context.turn_squeeze_percent): когда трафик тура перерастает долю порога компакции, одним проходом схлопываются и вытесненные копии перечитанного (тот же файл, та же выдача памяти), и старый выхлоп с длинными входами. Префикс рвётся ровно раз на срабатывание, повтор — на удвоенном весе, а проход, которому нечего схлопывать, кэша не касается вовсе. Пока тур легче порога, копии едут целиком: замена по капле на каждой итерации стоила промаха всего префикса у провайдера — дороже всей экономии; - якорь кэша — последнее сообщение, чьи байты уже не изменятся (перед
elide-окном), плюс маркер на хвосте истории. Граница elide-окна считается
ОДНОЙ функцией и для элизии, и для якоря: разойдись они, якорь встал бы на
байтах, которые ещё поедут. Двигает её теперь не только счёт сообщений, но
и байтовый бюджет (
tool_result_keep_chars) — тяжёлый ход сдвигает её чаще, и это осознанная плата: занятость окна дороже промаха кэша; - телеграм-окно живой истории меряется репликами людей и схлопывается
скачками, кратными
history_limit(окно живёт в [limit, 2×limit) ходов) — префикс стабилен между скачками, а не рвётся каждый ход.
llm.prompt_cache: "5m" | "1h" включает (5m — запись в кэш 1.25×, 1h —
вдвое дороже на запись, но переживает паузы между репликами);
llm.prompt_cache_telegram переопределяет для телеграм-туров ("off" —
заглушить только там). Без кэша меняется лишь расстановка маркеров —
содержимое промпта то же. Экономику смотри в /v1/stats: cached_tokens
(прочитано из кэша) и cache_write_tokens (записано, по наценке) — в
итогах и в разрезах by_model/by_source. Записи без чтений — признак
инвалидатора или кэша, включённого там, где он не окупается.
Инженерная трасса
Продуктовая телеметрия ядра (/v1/stats, spend, cron_runs) отвечает на
вопрос «как дела вообще». Трасса отвечает на другой: почему сломался вот
этот тур. Какой именно запрос уехал провайдеру, когда он ответил 400; из
какого запроса родился этот вызов инструмента; сколько тур ждал сеть, а
сколько — человека.
Принцип один: сначала записывай факты, толкуй потом. Горячий путь тура только дописывает события и складывает полные тела — он не строит никакой итоговой картины. Толкование — чистая функция над записью: когда понимание отказа меняется, редьюсер переписывается и прогоняется по старым трассам заново.
Включение
Переменная окружения на процессе ядра:
EVA_TRACE_DIR=/var/lib/eva/trace eva-kernel --config config.yaml
Выключено (по умолчанию) — накладных расходов ноль: по горячему пути вместо
контекста трассы лежит None. Записи никуда не выгружаются — это
диагностика на своём диске, не телеметрия.
Что пишется
На каждый корневой тур — свой каталог <started_ms>-<turn_id>:
| Файл | Что в нём |
|---|---|
manifest.json | id трассы (свой, отдельный от id тура), id тура и чата, время старта, ревизия ядра |
trace.jsonl | лента событий, только дописывается: монотонный seq, at_ms, turn, kind |
payloads/*.json | полные тела: запросы к провайдеру, ответы, аргументы и выхлопы инструментов — без потолков |
payloads/*-prompt.txt | человекочитаемый снимок каждого запроса: точный вход модели диффабельным текстом |
События: turn_started, provider_request / provider_response /
provider_error, usage, tool_call / tool_result (с длительностью),
rule_fired, cancelled, turn_finished / turn_failed. Вызов несёт
номер итерации — по нему видно, какой запрос его породил.
Подагенты наследуют контекст трассы от тимлида и пишут в тот же каталог под id своих туров — дерево команды разбирается по одной ленте.
Разбор: eva-trace
Толкование — отдельный бинарь, которому нужен только каталог:
eva-trace /var/lib/eva/trace/1754650000000-<turn_id>
Печатает таймлайн тура: каждый поход к провайдеру с длительностью и ссылкой на тело, вызовы инструментов с итерацией-родителем и временем работы, расход токенов и денег, финал тура. В конце — итоги: сколько ждали провайдера, сколько работали инструменты, полная цена.
Родня
- Снимок промпта из
payloads/*-prompt.txt— тот же текст, что стерегут снимковые тесты сборки промпта (src/snapshots/, insta): регрессия сборки — однострочный дифф снимка. - Мок-провайдер проверяет инварианты каждого запроса (сопряжённость пар
tool_use/tool_result, непустая системная часть, последний ход — за пользователем) паникой — весь тестовый набор стережёт их, ничего о них не зная. - Трасса не подменяет бенчмарк (
benchmark/, крейт eva-bench): бенчмарк меряет качество на сценариях, трасса объясняет один конкретный отказ.
Секреты и безопасность
- Гейтинг — детерминированный, в реестре, не в промпте:
master_only/trusted_only/privileged_onlyпроверяются до исполнения по личности тура и окружению. Из чужого тура мастер-инструмент не исполнится ни при каких уговорах модели — это щит от prompt injection, не зависящий от её послушности. Тем же гейтом заперты обе стороны команды:agent_only— доклад наверх,lead_only— управление подагентами (см. агентские команды). - Три оси доверия: master — тур ведёт Господин (sender из
master.aliasesили отсутствует); trusted — доверенное окружение (флагtrustedот TUI/Android-клиента или личка Господина в телеграме; публичные чаты — никогда); privileged — разрешённый пользователь (телеграм-whitelist; из доверенных клиентов — всегда). - Маркеры спикеров ставит только ядро: тело сообщения идёт под маркером цитатой, притвориться чужим голосом текстом нельзя.
- Гости получают урезанный потолок ответа (
max_tokens_guest) и не видят платных инструментов; выборка памяти режется доmemory.public_top_kв любом не-мастерском туре (не только гостевом). - Меню настроек поверхности не светит приватное: тулы из её master-списка (память, личности…) в групповом меню не показываются даже именами — полная консоль чата уезжает Господину в личку по кнопке.
- Секреты в конфиге (
llm.api_key,web.booru.*.api_key, заголовки MCP) — отдельными файлами черезextraConfigFiles, не вsettings(тот лежит в world-readable /nix/store). - Секреты в выхлопе инструментов затираются до записи:
cat .envилиenvв кодовом туре не увозит токен ни провайдеру, ни в базу (она не архив секретов), ни клиенту в живой стрим. Правила — данные (secrets.rules): встроенный набор покрываетBearer,sk-,AKIA,AGE-SECRET-KEY,glpat-,gh*_, PEM-блоки и присвоенияtoken=…; новый формат токена — строчка конфига, не пересборка. Плейсхолдер называет вид:[redacted: bearer token]. Это сеть безопасности, а не разрешение читать.env. - SSRF-щит на URL от модели:
web_fetchи скачиваниеsourceдляimage_generateне ходят в приватные сети — модель могла взять адрес из недоверенного текста, а за127.0.0.1живёт само ядро, которое своих не аутентифицирует (токен проверяет реверс-прокси снаружи). Отсекаются loopback, RFC1918, link-local, CGNAT, ULA и прочие спец-диапазоны — на уровне DNS-резолвера (каждый коннект, включая хопы редиректов и rebinding), а редирект на приватный IP-литерал, минующий DNS, режет политика редиректов. Схемы — толькоhttp(s). Исключения — явным спискомweb.allow_private_hosts(пуст по умолчанию). Адреса MCP-серверов приходят только из конфига — это уже явное решение Господина, щит им не нужен.
Экосистема Евы 🩷
Обзор проектов вокруг Евы. В центре — ядро (рантайм, где Ева живёт); вокруг
него клиенты (интерфейсы к одному и тому же ядру), модули (новые руки и
поверхности) и MCP-серверы (инструменты). Всё — под git.desu.church, org
eva (кроме отмеченного).
Ключевая идея: ум и состояние — в ядре, клиенты тонкие. Чаты, память, навыки,
личности общие — один и тот же чат виден из любого клиента; folder —
человеческая полка «о чём чат», перекладывается откуда угодно, а чаты
специализированных поверхностей (telegram, mtl) помечены surface и в общий
список не лезут. Переключение модели, история — всё через HTTP/SSE-API ядра.
Ядро
eva-kernel (eva/kernel) — провайдеро-независимое LLM-ядро на Rust:
долговременная память, инструменты, навыки, чаты с гигиеной контекста, крон —
в одном бинаре поверх одного SQLite-файла. Полное описание, конфиг и API — в
README.md.
Каналы связи ядру не принадлежат: телеграм и всё, что появится дальше, живёт модулями рядом.
Клиенты
| Клиент | Репозиторий | Стек | Что умеет |
|---|---|---|---|
| web | eva/frontend | Nuxt 4 + Tailwind | Ева в браузере; ставить нечего, наружу вешается под замком с паролем |
| app | eva/app | Tauri + Nuxt | Десктопное окно с руками: клиентские операции, разрешения, кодовые сессии |
| android | eva/android | Kotlin + Jetpack Compose | Полноценный мобильный клиент |
| tui | eva/tui | Python + Textual + httpx | Терминальный клиент; руки и код-режим есть и здесь |
| mtl | eva/mtl | Tauri + Nuxt | Мастерская переводного патча: движки игр, ресурсы, обратная запись |
| telegram | eva/telegram-bridge | Rust + eva-sdk | Не клиент, а поверхность: отдельный процесс-модуль, ведёт телеграм целиком |
Файлы и команды на машине Господина исполняет сам клиент: ядро шлёт
событие client_op в живой стрим тура, клиент делает это у себя и постит
результат обратно. Поэтому руки есть у десктопа, терминала и мастерской, а у
вкладки браузера — нет.
web
Ева в браузере: лента со стримингом и картинками, инструменты живьём, кнопочные вопросы, память, крон, подагенты, генерации, очередь одобрений, расход, личности, несколько ядер на выбор. Замок паролем стоит на самом раннем хуке, поэтому наружу не уходит ничего, кроме формы входа. В ядро браузер ходит напрямую — CORS вешается на reverse-proxy перед ним.
app
То же окно на рабочем столе (Tauri), но с руками: клиентские операции с
вопросом на запись и команду (дифф против диска, каталог команды, «всегда» на
сессию), кодовые сессии на полке code с привязкой к проекту (поле
project чата) и памятью проекта в .eva/,
компаньон-кружок поверх всех окон со снимком экрана.
mtl
Мастерская переводного патча: открываешь каталог игры — она опознаёт движок,
разбирает реплики со сценой и говорящим, переводит и кладёт назад так, как
ждёт движок (копия файла снимается до первой правки). Двенадцать опознаваемых
движков, разбор и обратная запись — RPG Maker MV/MZ, чтение — Ren’Py.
Глоссарий и стиль лежат в .mtl рядом с игрой и едут в контекст каждого тура.
android
Основной клиент. Чаты по папкам, выбор модели (per-чат + клиентский дефолт
новых чатов), стриминг с тул-коллами как в Claude (свёрнутый чип с иконкой,
группировка подряд идущих шагов, разворот в лист «Сводка» с таймлайном; известные
тулы — с человеческой сводкой, mcp_* — сыро). Просмотр памяти, крона,
генераций (обзор/отмена туров), статистики расхода/триажа, мульти-сервер.
Доверенный клиент: туры уходят trusted + uncapped.
Отдельно — вкладка YouTube (PipePipeExtractor): смотреть ролики/плейлисты,
спросить Еву о содержимом (субтитры → транскрипт → чат в папке youtube),
скачивать, фоновое проигрывание, кэш и оффлайн. Это GUI/мобильная фича, в
терминал не переносится.
tui
Ева в терминале — Textual + httpx, тонкий клиент. Общие чаты со всеми клиентами, создание/удаление/обновление на клавишах, переключение полок и поверхностей, стриминг дельтами с пометками вызовов инструментов. Приводится к паритету с андроидом по терминало-уместным фичам (выбор модели, разворачиваемые тул-коллы, память/крон/статы/генерации).
Модули и SDK
Модуль — отдельный процесс: даёт Еве инструменты (MCP-сервер, к которому подключается ядро) и, если он поверхность, ведёт туры через API ядра. Протокол и границы — глава «Модули и SDK».
| Проект | Репозиторий | Что это |
|---|---|---|
| eva-sdk (Rust) | eva/kernel, sdk/ | Клиент ядра + MCP-сервер модуля + стадии middleware |
| eva-sdk (Python) | eva/sdk-py | То же самое на Python; им же пишутся middleware-записи |
| telegram-bridge | eva/telegram-bridge | Телеграмная поверхность: поллинг, триаж, доставка, инструменты telegram_* |
| eva-books | eva/kernel, plugins/books/ | Книжная полка: PDF/EPUB/FB2/TXT постранично и поиск по словам — с полки сервера или с машины клиента |
MCP-серверы
Инструменты, которые ядро подключает как клиент. Каталог наших серверов (поездки по РЖД, VNDB, крипто-свопы, Forgejo, имиджборд-платформа и др.) — в MCP-SERVERS.md.
Картина
web ──────┐ ┌── telegram-bridge (модуль)
app ──────┤ │
android ──┼──▶ eva-kernel ◀────────┤
tui ──────┤ (HTTP/SSE) └── следующая поверхность
mtl ──────┘ │
│ MCP (stdio / HTTP / UDS)
┌──────────┼──────────────┐
rzd kana redose viendesu ghostswap forgejo …
Клиенты и модули цепляются к одному API: клиент показывает Еву человеку, модуль даёт ей руки или целый канал связи.
MCP-серверы Евы 🩷
Индекс наших MCP-серверов — тех, что мы сделали сами для экосистемы Евы.
Ядро (eva/kernel) подключает их как клиент (settings.mcp.servers.*):
command — stdio, ядро само спавнит процесс; url — streamable HTTP к
удалённому эндпоинту. Тулы видны модели как mcp_<сервер>_<тул>.
Сторонние MCP (grafana, timeweb и пр.) сюда не входят — только наши.
Сводка
| Сервер | Репозиторий | Назначение | Транспорт | На kirigiri |
|---|---|---|---|---|
| rzd-mcp | eva/rzd-mcp | Планирование поездок по РЖД | stdio | ✅ rzd |
| kana-mcp | vndb/kana-mcp | VNDB — база визуальных новелл | stdio | ✅ kana |
| redose | nero/redose | Учёт веществ/доз/трипов, марафоны | HTTP (лок.) | ✅ redose (trusted) |
| viendesu | платформа VienDesu | Имиджборд/вики/игры desu.church | HTTP | ✅ viendesu |
| ghostswap-mcp | eva/ghostswap-mcp | No-KYC крипто-свопы (GhostSwap API) | stdio | ✅ ghostswap (trusted) |
| forgejo-mcp | eva/forgejo-mcp | Forgejo/Gitea — issue/PR/релизы/raw api | stdio | ✅ forgejo (trusted) |
Все репозитории — ssh://forgejo@git.desu.church:61488/<репо>.git; каждый
пакуется Nix-флейком (packages.default + nixosModules.default).
rzd-mcp
Планировщик дороги по РЖД поверх неофициального pass.rzd.ru. Только поиск,
билеты не покупает.
- Тулы:
rzd_stations(станции/коды),rzd_search(прямые поезда, перебор дат внутри —date_to/weekdays),rzd_route(маршруты с пересадками через хабы, тоже по диапазону дат),rzd_seats(разбивка мест по вагонам: верхние/нижние/боковые с ценами). - Подключение:
mcp.servers.rzd.command = ["rzd-mcp"]. Прокси —RZD_PROXY=socks5h://…, если хост не видит РЖД напрямую (kirigiri в РФ — видит).
kana-mcp
Доступ к VNDB (база визуальных новелл) через kana API.
- Тулы:
vndb_query(универсальный запрос по vn/character/tag/…),vndb_stats,vndb_user,vndb_authinfo, списки пользователя —vndb_ulist_labels/vndb_ulist_update/vndb_ulist_remove,vndb_rlist_update/vndb_rlist_remove. - Подключение:
mcp.servers.kana.command = ["kana-mcp"]. Записи в списки — токенVNDB_API_TOKEN.
redose
Учёт психоактива: вещества, дозы, трипы, планы и марафоны. trusted_only —
только доверенное окружение, никогда из телеграма.
- Тулы: вещества —
add_substance/edit_substance/remove_substance/get_substance/list_substances; журнал —add_trip/add_note/get_day/get_log/get_status; планы и марафоны —create_plan/list_plans/list_scheduled/schedule_marathon/start_marathon/end_marathon/confirm_phase. - Подключение:
mcp.servers.redose = { url = "http://127.0.0.1:8765/mcp"; trusted_only = true; }. Юнит ядра ждёт порт redose вafter/wants.
viendesu
MCP платформы VienDesu: имиджборд, вики-статьи, игры, блоги, авторы, пользователи.
- Тулы: статьи —
create_article/get_article/edit_article/delete_article/list_articles; доски и треды —create_board/get_board/edit_board/delete_board/create_thread/get_thread/search_threads/post_message/get_message/edit_message; игры —create_game/get_game/update_game/search_games/list_genres/list_tags/list_badges; люди —get_author/search_authors/create_author/update_author/get_user/search_users/update_user; блоги —get_blog/edit_blog; навигация —list_tabs/list_tab_items/whoami. - Подключение:
mcp.servers.viendesu.url = "https://api.desu.church/mcp".
ghostswap-mcp
GhostSwap Partners API — обмен крипты на крипту без KYC, 1600+ монет, как MCP-тулы.
- Тулы:
health,public_quote(индикативный курс без ключа),list_currencies,get_pair(мин/макс пары),validate_address,get_quote(живой курс,mode=fixedфиксирует),create_swap(двигает реальные средства, идемпотентный),list_swaps,get_swap(источник правды по статусу — его и поллить). - Подключение: ключ партнёрского API; запуск через
uv, флейк-пакет илиservices.ghostswap-mcp. На kirigiri —trusted_only(свопы двигают реальные деньги, публичным каналам не отдаётся), ключиGHOSTSWAP_PUBLIC_KEY/GHOSTSWAP_SECRETчерез окружение сервиса.
forgejo-mcp
Универсальный MCP для любого Forgejo/Gitea-инстанса (у нас — git.desu.church). Инстанс, токен и таймаут — из конфига или окружения, ничего не захардкожено.
- Тулы: репозитории и файлы, issue и комментарии, PR —
list_prs/get_pr/create_pr/merge_pr/review_pr, метки —create_label/set_issue_labels, релизы —list_releases/create_release/delete_release, соцчасть —star/watch/follow/notifications, организации/поиск, иapi— сырой вызов любого/api/v1-эндпоинта (вики, вебхуки, экшены, миграции, команды…). - Подключение:
mcp.servers.forgejo = { command = ["forgejo-mcp"]; trusted_only = true; };FORGEJO_BASE_URL+FORGEJO_TOKENиз окружения (env перебивает файл-конфиг). На kirigiri токен — через sops.trusted_only, потому что тул умеет писать в репозитории.
Changelog
Заметные изменения ядра, свежее сверху. Формат: дата — список; ломающие изменения конфига/API помечаются (ломающее).
2026-08-18
- Вытеснение копий перечитанного внутри тура больше не гоняется каждой
итерацией с третьей — оно едет проходом поджатия тура
(
turn_squeeze_percent), под тем же весовым порогом и удвоением. Замена по капле рвала префикс-кэш провайдера на каждой итерации живого тура (поspend: hit-rate соседних запросов одного чата упал с ~97% до ~47% за волны 10–14.08), и промах всего промпта стоил дороже сэкономленных огрызков. Освобождённое супersede’ом теперь входит вfreed_charsсобытияturn_squeezed.turn_squeeze_percent: 0выключает и вытеснение копий. - Плагин eva-books (
plugins/books, юнитservices.eva-kernel.plugins.books): книжная полка инструментамиbooks_*— список библиотеки, оглавление, постраничное чтение (не больше пяти страниц за вызов, книга целиком в контекст не попадает) и поиск по ключевым словам. Форматы: pdf (через pdftotext/poppler), epub, fb2(+.zip, включая cp1251), txt/md. Сторону модель называет явно (source: server|client): server — полкаbooks.rootлибо, Господину, диск сервера; client — машина клиента, ведущего тур (client_ops; клиент спрашивает Господина). - Двери ядра для модулей:
GET /v1/config— снимок секций конфига с затёртыми при старте секретами (именные поля вроде key/token/env гаснут целиком, остальные строки проходят паттерновый редактор);POST /v1/chats/{id}/ops/read_bytes— кусок файла с машины клиента живого тура той же клиентской операциейread_bytes, что у байтовых инструментов;POST /v1/ops/read_bytes— серверная пара, файл читает само ядро. Обе двери только на чтение. В контексте вызова модульного тула (_meta["dev.eva/ctx"]) появился флагclient_ops. - eva-sdk:
Kernel::ops(Side::Server | Side::Client { chat })сread_bytes/read_all(куски по 4 МиБ, потолок размера — ошибкой, не усечением),Kernel::config(), полеToolCtx::client_ops. - Крейт eva-sdk переехал из
crates/eva-sdkвsdk/, свои плагины ядра живут вplugins/тем же воркспейсом.
2026-08-14
- Структурированный ответ MCP-сервера едет компактным JSON, а не лесенкой: отступы стоили четверть байт на ровном месте, а читается он так же.
- (ломающее дефолт) Бюджеты файлов проекта опущены вдвое:
context.project.max_bytes32 → 16 KiB,memory_max_bytes16 → 8 KiB. Вместе они занимали до 12k токенов стабильной части промпта. Усечение по-прежнему подписывается — какой файл пострадал и какой не показан вовсе; кому нужен прежний объём, поднимает поле. - Семейство
agent_*считается по живым агентам, а не по роли: новый гейтneeds_agentsдержитagent_send,agent_wait,agent_inbox,agent_listиagent_stopзакрытыми, пока команды нет. Follow-up к работе, которой никто не поручал, — около четырёх килобайт схем в каждом мастерском туре, включая разговор про кофе. Флаг считается одним запросом на входе в тур и поднимается прямо посреди него:agent_spawnоткрывает семейство с ближайшего шага, какtool_loadоткрывает доложенное. В каталогеGET /v1/toolsгейт виден полемneeds_agents. - Каталожные блоки промпта (навыки, наборы, реестр скрытого) делят ОДИН
бюджет. Модуль обещал «один распределитель на все три списка», а на деле
каждый брал свою долю окна:
2%превращались в6%, а потолок в 16000 символов — в 48000. Теперь модули отдают каталог (шапка, строки, хвост), а укладывает их разомcatalog_budget::fit_many— короткий список отдаёт свою долю соседям, и ярус «не влезают даже имена» режет каждый блок по кругу, а не выбрасывает последний целиком: «скрытых инструментов нет» модель прочла бы как правду. - У выборки памяти появилась нижняя граница:
memory.recall_floor(0.15) — доля от лучшего счёта в этой же выборке.top_kбез порога добирал список до счёта, и запись, разделившая с запросом одно частое слово, ехала в промпт наравне с попаданием. Порог относительный: BM25 между запросами не нормирован, абсолютный отсекал бы наугад.coreидёт мимо скоринга и порогом не задет;0возвращает прежнее поведение. - Тул-трафик закрытых ходов подчиняется одному правилу вместо двух частных
случаев:
context.past_tool_traffic(keep/stub/drop, дефолтstub) иcontext.telegram.past_tool_trafficсо своим дефолтомdrop. Раньше выброс трафика прошлых ходов был жёстко привязан к телеграмному окну истории (telegram.history_limit > 0) — два несвязанных знания в одном условии. Ключ теперь поверхность тура, а не размер окна: чат сhistory_limit: 0тоже чистится. - Длинный тур поджимает собственный хвост. Элизия гонялась один раз на
входе в тур, и сорокаитерационный ход нёс весь свой выхлоп до самого
конца — единственной реакцией было переполнение окна провайдера, свёртка
задним числом и падение тура с «retry the turn». Теперь трафик тура,
переросший
context.turn_squeeze_percent(50) профильного порога компакции, схлопывается прямо на ходу: свежими остаются последние четыре сообщения, записи чтений внутри тура не отменяют, повторно — только на удвоенном весе. Проход, которому нечего схлопывать, молчит; сработавший говорит модели строкой в[state](«re-read anything you still need»), клиенту — новым SSE-событиемturn_squeezed, трассе —turn_squeeze. - У сценария бенчмарка появился свой кусок конфига (поле
config): пороги вроде поджатия тура иначе не спровоцировать. На нём стоит новый селфтест06-selftest-turn-squeeze— длинный тур доигрывает план после того, как ядро схлопнуло его старый выхлоп.
2026-08-13
- Живое окно свежего тул-трафика меряется теперь и в символах —
context.tool_result_keep_chars(24000). Счёт сообщений сам по себе врал в разы: выхлоп ОДНОЙ итерации ложится одним сообщением, поэтому шесть сообщений — это то шесть последовательных вызовов (~12k символов), то восемь параллельных на каждой из шести итераций (~96k). Строже та мера, которая раньше кончилась; последнее сообщение свежо всегда — то, ради чего модель только что ходила, схлопывать незачем.0возвращает прежний счёт сообщений. Границу окна считает одна функция — она же ставит якорь кэша промпта, разойтись им нельзя. - У оконных читателей появился свой потолок и свой рез: новый метод трейта
Tool::windowed_resultиcontext.reader_result_max_chars(8000).read_file,find_pattern,read_hexиhistory_searchнарезают выхлоп сами и говорят в шапке, как читать дальше, — общий рез серединой выбрасывал у них ровно то, за чем звали, а шапка продолжала обещать весь диапазон. Теперь режется хвост, с пометкой и приглашением сузить окно.read_fileиfind_patternпри этом перестали бытьuncapped_result: без потолка они клали в окно по 16k символов на вызов. - Гигиена контекста взялась за вход вызова, а не только за его ответ.
За живым окном длинные строки
tool_use.inputзаменяются пометкой длины ([elided: N chars]): путь, флаги и смещения целы — по ним видно, ЧТО было сделано, — а тело записанного файла, патч и скрипт уходят. Асимметрия была вопиющей: ответ записи стоил 160 символов огрызка, а сам файл лежал в промпте дословно до самой компакции. Граница та же, что у элизии результатов, — новых разрывов префикс-кэша нет. - Перечитка файла ДРУГИМ окном больше не копится: чтение вытесняется, если более свежее накрывает его диапазон целиком (пересекающиеся, но не вложенные окна остаются оба — правило вложенных запросов памяти). Раньше в контексте оседали три-четыре неполных копии одного файла.
- Запись по пути обесценивает чтения этого файла выше: они помечаются
[superseded: the file was written after this read; read it again]. Это уже не про деньги — модель видела содержимое файла, который сама же переписала. Пути берутся из входа (write_file,write_diff,write_hex, включая батч-формы) и из текста патча (apply_patch);shellв инвалидаторы не пишем — путь из произвольной команды честно не разобрать. Внутри живого тура правки чтений не отменяют: модель правила сама и помнит что именно, а огрызок стоил бы ей перечитки после каждогоwrite_diff. - Модуль объявляет правила своей поверхности сам:
Module::surface_rulesв eva-sdk,_meta["dev.eva/surface-rules"]на handshake, реестр держит их по имени группы, а сборка промпта берёт поsourceтура. Ядро лишилось собственных телеграмных правил (TELEGRAM_RULES,IMAGE_RULES_TELEGRAM) и упоминанийtelegram_attach_photosв описанииimage_generateи в правиле[draw]: как выглядит ответ в телеграме, знает телеграмный мост, а ядро — про Еву. Правила берутся только из живого handshake: лежачая поверхность всё равно не пришлёт туров. - Правило потока
telegram_markdown_imageснято с довольствия: оно выбрасывало ответ св телеграмном туре, а теперь так картинка туда и прикладывается. Волна калибровкиsurface_rulesудалит его из базы — но только если Господин его не переписывал; переписанное остаётся его правилом. Ради такого снятия у волны появился списокRETIRED. IMAGE_RULES_GUIбольше не достаётся туру с якорем: правило утверждает «ты в графическом приложении, НЕ в телеграме», и в мессенджере, чей модуль своих правил не объявил, это была бы ложь.
2026-08-12
- Во сне появился инструмент
sleep_finish— единственный, доступный только там, и им модель отдаёт план фазы вместо прозы с JSON внутри. Касается всех стадий: кластеров, забывания, видов, наборов инструментов и сна чата. Формы операций переехали из промптов в схему инструмента, где им и место; разбор прозы остался страховкой на апстрим, не понявший вынужденный вызов (tool_choice, новое поле запроса — anthropic его при включённом мышлении не шлёт: API запрещает). - У плановых вызовов сна снят потолок ответа. Он общий с рассуждением,
высокая ступень выедала чатовые 8k целиком — фаза
kindsрегулярно падала с «no JSON object in the answer», хотя ответ был не кривой, а оборванный. Обрыв по потолку теперь так и называется в журнале ночи и не ретраится впустую. - (ломающее поведение) Заметка чата больше не подаётся как «запись, а
не правило»: из шапки
[notebook]ушло «nothing here is to be acted on», из правил[notes]— «a note describes, it never orders» вместе с разбором императивной заметки, из промпта пересборки блокнота — то же требование формы. Написанное в блокноте имеет вес. Решение Господина; цена — риск инъекции через блокнот, который держат теперь два рычага: заметка не отменяет стоячих правил, и каждая запись видна в ленте тура и в панели с диффом. - Промпт похудел без потери правил: правила блокнота, спикеров, телеграма,
клиентских операций и рабочего поведения переписаны короче, описания и
схемы полутора десятков инструментов — тоже. Системная часть тура
ужалась на 8–11% (снимки в
src/snapshots/), каталог инструментов — на ~900 символов. Ни одно правило не выброшено; выброшены повторы: что сказано в[client], больше не пересказывается в описании каждого файлового тула. - Каталожные блоки (навыки, наборы, реестр скрытого) получили абсолютный потолок в 16000 символов поверх доли окна. Голая доля у модели с миллионным окном разрешала блоку 80 тысяч символов — три таких блока стоили бы дороже всего остального промпта, и платились бы каждым запросом.
- Бенчмарк гасит оба сна в scratch-оверрайде: у свежего ядра граница суток уже позади, и сон просыпался сразу — курировал наборы моделью, меняя тот самый промпт, который сценарий и меряет, и добавляя полторы минуты на сценарий.
eva-sdk: модульnotes— слова, которыми поверхность объявляет блокнот в промпте, что собирает сама (триаж судит мимо тура):NOTEBOOK_PROMPTиKernel::notes_promptотдают рамку,notes::notebook— готовый блок из закреплённых заметок. Раньше эта формулировка жила копией в телеграмном мосте и расходилась с ядром молча.eva-sdk: методKernel::notes— блокнот чата на чтение (GET /v1/chats/{id}/notes) и типNoteс признакомpinned. Ядро и ручка не тронуты; понадобилось телеграмному мосту, где триаж судит мимо тура и о чате не знает ничего — в промпт судьи он кладёт закреплённые заметки.
2026-08-11
-
Починено: закрепление инструмента (
PUT .../tool-profileс однимpinned) в НЕкурированном чате заводило профиль с набором из самих закреплённых — то есть одним кликом схлопывало чат до ядра плюс этого тула. Теперь строка заводится с пустым набором,curatedостаётсяfalse, а булавка ждёт первого сна и связывает ему руки. Пустой набор в записи читается как «курирования ещё не было», а не как «видно ничего»; нечитаемый — так же, и булавки при этом не теряются. -
GET /v1/chats/{id}/tool-profileотдаётin_contextиreachable— разрешённые имена, раскрытые до самих тулов по гейтам и доступу этого чата. Длинаvisible/effectiveдля показа не годится: их записи — имена тулов И семейств, и клиенты, считая их, врали бы на порядок, каждый по-своему. Оба поляnull, пока чат ни разу не ходил. -
Тул
chat_sleep_start(M, семействоchat): курирование набора этого чата прямо сейчас, не дожидаясь его ночной границы. Крутится внутри тура и репортит фазы живым прогрессом (tool_progress) — как свёртка истории, — а отчёт возвращает модели текстом. Пороги по данным и ранний выход по бюджету при этом остаются: тул честно скажет, почему пропустил, не потратив ни одного вызова модели. Срок двигает так же, как ночной запуск; второй запуск поверх идущего не проходит. -
Сон чата (
chat_sleep): у каждого чата свой сон, ленивый и фоновый — время проходит молча, запуск идёт нуджем на первом сообщении после срока и туров не держит. Этапtoolsрешает, что этот чат видит в контексте по умолчанию, а что уезжает заtool_find/tool_load. Курирование — слой внимания, а не прав: скрытое им остаётся в реестре скрытого и возвращается однимtool_load, whitelist чата и гейты личности им не двигаются (Reach.curatedповерх проверки права). Этап включается только от тесноты: досягаемый набор влезает вtools.budget_percentокна — модель не зовётся вовсе, и ни один инструмент не прячется «за молчание». Знаменатель метрик — туры, а не дни;loads(сколько раз тул пришлось вернуть) обязывает ядро оставить его в наборе. Ручки:GET/PUT/DELETE /v1/chats/{id}/tool-profile,POST /v1/chats/{id}/sleep,GET /v1/chats/{id}/tool-stats,GET /v1/chats/{id}/sleep/runs,GET /v1/sleep/chat-runs;/v1/chats/{id}отдаётnext_sleep_at. Секция конфига необязательна: без неё сон чата включён и применяет решения сразу (dry_run: false). -
Статистика инструментов по чатам: дневные бакеты
chat_tool_statиchat_day_stat(вызовы, покрытие турами,loads, доля обращений прямо к Еве), снимок окружения тураchat_turn_env. Жнёт её гигиена глобального сна поchat_sleep.keep_days; глобальный сон теперь ждёт дренажа снов чата наравне с живыми генерациями. -
POST /v1/stats/triage— поверхность, судящая сама, чему отвечать в бойкой группе, сообщает итог пачки:engaged(вовлечено судьёй) иresponded(реально отвечено). Ядро складывает их вtriage_stat, и/v1/statsнаконец показывает не нули: разрыв между цифрами — это судья, будящий Еву впустую. В eva-sdk — методKernel::triage_stat.
2026-08-09
- Стадии middleware в eva-sdk типизированы: маркер стадии несёт тип
нагрузки (
stage::ToolCall→payload::ToolCall),Decision::Patchпринимает её же, а поля, которых версия SDK не знает, переживают round-trip черезrest. Обработчик получаетStageCtx— контекст тура плюс клиент API ядра: ядро теперь называет модулю свой адрес в handshake (_meta["dev.eva/api"]запроса initialize). (ломающее) для SDK:Module::stageберёт маркер вместо строки, константstage::TOOL_CALLи трейтаStageHandlerбольше нет. - Тулы на один тур:
extra_toolsвsend_messageобъявляет модели тулы поверх реестра, исполняет их сам объявивший клиент. Вызов уезжает в стрим событиемextra_tool_call(ядро ждёт), ответ — вops/resultпод id вызова, лучше блоками (blocks: [{title, content}]— ядро рендерит== title-секциями).ends_turnв объявлении делает тул вердиктом. Имя, тенящее тул реестра, — 400. В eva-sdk — типизированный контракт:Turn::extra_tool::<T>()(схема изJsonSchemaтипа), событиеExtraToolCall {call, response}с одноразовым респондером иcall.parse::<T>(); (ломающее) для SDK —TurnEventбольше неClone/Deserialize, событие собирает стримTurn::send. - Общий блочный формат ответов инструментов (
tools::blocks):== titleсекции, как у батчевогоread_file.shellиlocal_shellпринимают массив команд — каждая отвечает своим блоком, пачка встаёт на первом провале с честным отчётом; имитация секций черезecho ==x==больше не нужна и описания тулов прямо это запрещают. - Телеграмное правило про
telegram_skipужесточено: причина — внутрь поляreasonвызова, не текстом в чат (текст снаружи — сообщение, убивающее сам skip).
2026-08-08
-
Словарь мета-решений
decisions(discuss,answer_in_text) теперь вклеен и в исторический элементtool_callвызоваask_questionsвGET /v1/chats/{id}/items— раньше его несло только живое событие, и карточка оборванного вопроса после перезагрузки клиента рисовала голые варианты без «Дообсудить». Ввод модели в истории и промпте по-прежнему нетронут. -
Книга и OpenAPI проговаривают две ловушки
GET /v1/chats/{id}/itemsи кривых тел:limitсчитает сообщения-источники, а не элементы (конец истории — пустая страница, не «элементов меньше limit»), а несходящееся со схемой тело axum отвергает статусом 422, не 400. -
Рабочий каталог чата теперь ставит и сам клиент, не только модель тулом
workdir: поляworkdir/workdir_on(client|server, дефолтclient) вPOST /v1/chatsи ручкаPUT /v1/chats/{id}/workdir(nullснимает). Раньше выставить каталог умел только инструмент, и первый тур свежей кодовой сессии уходил без блока[project]— клиент тратил целую итерацию на «выставь workdir». Валидация общая с тулом (кривая машина — 400, пустой путь — снять); действует со следующего тура. -
Инженерная трасса тура (по мотивам
rollout-traceиз Codex): переменнаяEVA_TRACE_DIRвключает запись сырых фактов на диск — на корневой тур каталог сmanifest.json, только-дописываемой лентойtrace.jsonl(монотонныйseq) иpayloads/с полными телами запросов к провайдеру (плюс человекочитаемый снимок промпта*-prompt.txt), ответов, аргументов и выхлопов инструментов. Подагенты пишут в каталог тимлида под id своих туров. Выключено (дефолт) — накладных расходов ноль; никуда не выгружается. Разбор — новый бинарьeva-trace <каталог>: таймлайн похода по провайдерам и инструментам, привязка вызова к породившей его итерации, итоги по времени и деньгам. Книга: новая страница «Инженерная трасса». -
Тесты, стерегущие инварианты: мок-провайдер сверх сопряжённости пар проверяет паникой непустую системную часть, последний ход за пользователем и отсутствие
thinking-блоков в пользовательских сообщениях; снимковые тесты собранного промпта (insta,src/snapshots/) на четырёх типовых конфигурациях тура — мастерский чат, гостевой телеграм, кодовая сессия с руками, подагент; тестовый мок с воротами на каждом куске стрима (GatedMock) — детерминированные тесты отмены между дельтами и посреди пачки инструментов, реплея/liveс переподключением посреди ответа и замолчавшего навсегда провайдера. -
Каталожные блоки промпта — списки навыков, наборов и реестр скрытых инструментов — уложены в бюджет: 2% окна модели на блок (по мотивам распределителя скиллов Codex). Влезает — едет целиком, байт в байт как раньше; тесно — описания ужимаются по кругу, по символу с каждого, имена держатся до последнего; не влезли даже имена — хвост выброшен и посчитан. О деградации говорит warning в логе — списки не худеют молча. Пометки режима наборов переживают любое усечение.
-
MCP-серверы на старте ядра разрешаются параллельно: старт платит за самый медленный сервер, а не за сумму всех; порядок регистрации (и инструментов в промпте) остаётся детерминированным.
-
У MCP-сервера появился
lazy: true— на старте ядра к нему не ходить вовсе: тулы поднимаются из кэша прошлого запуска, сервер стартует первым живым вызовом. Для тяжёлых серверов; первый запуск (кэша ещё нет) подключается как обычно. HTTP-транспорт при этом хендшейкается лениво — раньше поднятый из кэша HTTP-сервер падал бы на первом вызове. -
Инструмент MCP-сервера, объявивший
_meta.ui.visibilityбез"model"(UI-виджеты из MCP apps), в реестр не попадает: видимость модели — по явному согласию сервера, старые серверы без поля видны как раньше. -
Контракт исполнителя хуков добит и зафиксирован в
docs/src/hooks.md(сторона ядра по-прежнему не построена): стадияtool.resultблокирует результат, а не исполнение; fail-open по умолчанию с fail-closed по объявлению гварда; последовательная цепочка вместо кодексового параллелизма; один запуск на вызов при матчере с алиасами; доверие по хэшу нормализованной личности с состояниемModifiedдо повторного одобрения; слив болтливого выхлопа в файл; зажатый таймаут хуков выхода. -
Мостов не задевает: всё внутри сборки промпта и старта ядра. eva-sdk не задет — протокол модулей не менялся;
lazyдля модулей смысла не имеет (они локальные и дешёвые), поле есть только уmcp.servers. -
У сна появились именованные этапы и новый этап tools — курирование наборов инструментов. Раз в ночь, после фаз памяти, модель этапа (
sleep.tools.model→ smartest → general) видит все наборы, весь реестр (имена, назначения, семейства — без схем) и статистику ношения и отвечает планом: дельта-правки состава (add/remove, не замена списка), новые наборы под устойчивый род работы, переписанные описания. Применяет ядро со своими правилами: наборы не удаляются, не переименовываются и не пустеют, каждый добавляемый тул существует в реестре, битые списки не трогаются, режимные флаги — только руками Господина; потолок —sleep.tools.max_ops(10). План — в том же журнале/v1/sleep/runs, общийdry_runдействует, о применённых правках уходит уведомление. -
(ломающее) Секция
sleepперекроена под этапы: пер-этапное уехало в подсекцииsleep.memory(бывшие плоскиеmodel,min_memories,max_ops,max_forget,keep_recent,archive_ttl,cluster_*+ свойenabled) иsleep.tools(enabled,model,max_ops). Старые плоские ключи молча игнорируются — прод-конфиг переписать тем же деплоем. Мелкий корпус (min_memories) теперь пропускает только этап памяти, не ночь целиком; сон не стартует, только когда обоим этапам нечего делать. -
Мостов не задевает: этап работает внутри ядра, поверхности видят только журнал сна и уведомления, как раньше. eva-sdk не задет — API не менялся.
-
Ветвление чата (по мотивам
thread/forkиз Codex):POST /v1/chats/{id}/fork{before_message, title?}— новый чат с копией истории строго до сообщения, неразрушающая альтернативаDELETE .../messages/{id}(тот остаётся — иногда надо именно стереть, но клиентам правку прошлого стоит делать веткой по умолчанию). Мать не меняется ни на строку; ветка — копия, а не ссылка, и переживает удаление матери. Наследуются полка, проект, модель, уровень рассуждения, набор инструментов, профиль компакции, рабочий каталог, блокнот (с булавками) и план работы; резюме компакции с дословным хвостом едет с перепривязкой якоря на id копии (срез раньше якоря — резюме не едет), якорь честного счёта токенов — по тому же правилу, одноразовые пинки окна вместе с ним. Срез посреди тура закрывается той же синтетикой[not executed: turn cancelled], что у живой отмены (общая функцияfragments::cancelled_closures), — ветка едет с первого запроса. Связь в полях чатаforked_from/forked_at_message; первым сообщением ветки — служебный маркер[forked] …(новый вид фрагментаfork_marker, в ленте элементов —machine_note, в промпт модели не едет). Форк — жест Господина, автоматически ветки не плодятся; форк от форка разрешён.
2026-08-07
-
Живой вывод команд и exec-сессии (по мотивам
unified_execиз Codex).shellпоказывает вывод по ходу: снимки хвоста уезжают событиемtool_progress(в ленте элементов —item_deltaэлемента вызова), с троттлингом, потолком числа событий, резкой по границе UTF-8 и затиранием секретов у истока снимка; в историю живой хвост не пишется.yield_msуshellпревращает не уложившуюся команду в сессию вместо убийства по таймауту: ответ —{session_id, output, chunk_id, wall_time_seconds}, дальше новый инструментshell_write(M, семьяshell) дописывает ввод и забирает свежий вывод (уshell_writeобъявленаoutput_schema). Потолки: 16 сессий, смерть по бездействию (5 мин) вместе с группой процессов, отмена тура прибивает сессии этого тура; удержание вывода — 1 МиБ головой и хвостом со счётом выброшенного, дренаж пайпов после смерти процесса ограничен 2 с (внуки, держащие stdout, не вешают вызов). Таймаут разовогоshellтеперь отдаёт частичный вывод в ошибке, а процессы убиваются группой, не одним прямым потомком. Клиентский брат:POST /v1/chats/{id}/ops/progress— клиент стримит снимки хвоста долгогоlocal_shell, ядро затирает секреты и превращает их в тот жеtool_progress(клиенты пока не постят — контракт готов). -
Единая лента элементов (по мотивам
ThreadItemиз Codex): один тип шага тура —user_message,agent_message,thinking,tool_callс вложенным результатом,client_op,image,compaction,machine_note,plan— и для живого стрима, и для реплея/live, и для чтения истории. Живой тур зеркалится событиямиitem_started→ типизированныеitem_delta→item_completed(started оптимистичен, completed авторитетен) параллельно легаси-набору; ненужный набор клиент глушит полемmuteвsend_messageили query-параметромmuteу/live. История отдаётся тем же типом черезGET /v1/chats/{id}/items(пагинация как у/messages), элементы собираются из существующих сообщений детерминированно — хранилище не переезжало; связка живого с историческим —call_id/image_id. Сообщения тура теперь несутturn_id(один id у генерации, ленты и записей базы) — по нему клиенты группируют шаги истории; компакция видна и после перезагрузки: её сообщение-маркер отдаётся элементомcompaction, не репликой и не заметкой (по маркеру на каждую свёртку; свёртки до появления маркера границы в ленте не оставляют). -
Видимость инструментов — одной решёткой (заимствовано у Codex): каждому туру инструмент выставлен ровно одним состоянием — Visible (спека у модели), Reserved (реестр скрытого, ждёт
tool_load), Hidden (диспетчеризуется, но не рекламируется) или Forbidden с причиной словами. Спеки шага, блок Hidden tools и отказ исполнения строятся из неё и разойтись не могут;GET /v1/toolsпоказывает полный набор гейтов-полей (privileged_only,agent_only,lead_only,anchored_only,indexed_notes_only,planning_only,unlisted— в дополнение к прежним). -
Гейт
unlisted(_meta["dev.eva/unlisted"]у MCP/модулей): миграционный псевдоним — вызов по имени работает, рекламы нигде нет. Переименование инструмента больше не ломает реплей старых чатов. -
Коллизия имён инструментов — ошибка конфигурации, а не тихая тень: повтор имени пропускается (первый остаётся), кричит в лог на старте и виден полем
collisionsвGET /v1/tools. Раньше новичок молча подменял прежний тул. -
Раздутые схемы MCP-инструментов ужимаются в бюджет 5000 байт четырьмя всё более потерянными проходами (описания до первой фразы →
$defsза борт → глубже третьего уровня в{}→ ветвления в{}); имена аргументов верхнего уровня выживают всегда. Один сервер больше не съедает бюджет описаний целого тура. -
Слишком длинное имя MCP-инструмента получает стабильный отпечаток вместо немой обрезки: два длинных имени с общим началом не схлопываются, имя не плывёт между перезапусками (стабильность имени — это префикс промпт-кэша).
-
tool_findищет и по схеме: документ инструмента несёт имена и описания его параметров, ранжирование — BM25 той же формулой, что у памяти. «Тул с параметром X» теперь находится с первой, бесплатной ступени каскада. -
Компакция как передача смены (заимствовано у Codex). Промпт суммаризации переписан из «сожми это» в сдачу дежурства: прогресс и решения с причинами, ограничения и предпочтения Господина, что осталось сделать, данные без которых не продолжить; резюме в
[state]получило принимающую рамку — «работа прошлой смены уже сделана: продолжай, не переспрашивай». Самые свежие реплики Господина переживают свёртку ДОСЛОВНО, рядом с резюме (chats.summary_verbatim): формулировка задания и есть задание, пересказ её теряет. Бюджет —context.compact_verbatim_percent(15% от профильного порога), отбор от свежих к старым, первая не влезшая режется серединой, окно без его реплик (тул-марафон) сохранённое не стирает. -
Свёртка оставляет след в истории: служебное сообщение-маркер (байт-стабильный текст
[compacted] …) — клиент рисует границу компакции и после перезагрузки, а не только по живому событиюcompacting. В промпт модели маркер не едет и Господину не приписывается (to_chat_messagesего отфильтровывает). -
Компакция при смене модели (downshift): если у модели тура объявлен
context_window, а накопленный контекст в него не влезает, история сворачивается ДО первого запроса — а не об ошибку переполнения с ретраями. Работает для смены модели чата, override клиента и цепочкиfallback_models: запасная, чьё окно меньше собранного запроса, пропускается в пользу первой подходящей; не влезает ни в одну — свёртка сейчас и честная просьба повторить тур. -
Типизированный отчёт подагента:
agent_spawnпринимаетreport_schema(JSON Schema) — докладagent_doneтогда обязан быть одним объектом этой формы (resultпринимает и объект), тимлид разбирает поля, а не прозу. Схема хранится на подагенте (agents.report_schema), едет секцией[report format]его первого сообщения; тур, законченный прозой безagent_done, проходит запасной разбор — из последнего текста выкапывается JSON-объект, не нашлось — уезжает проза как раньше. -
Инструмент может объявить схему результата:
Tool::output_schema()(JSON Schema) дописывается хвостом описания — wire-поля для этого нет ни у Chat Completions, ни у Messages API, — а выхлоп становится сериализованным объектом. Первый носитель —agent_list: тимлид ветвится поstatusи складываетcost_usd, а не выискивает их в строке; пустая команда — честный{"agents": []}по той же схеме. -
Ошибка инструмента — двух классов, явно (заимствовано у Codex:
FunctionCallError = RespondToModel | Fatal). Обычная, как и раньше, уезжает модели текстом вtool_resultсis_error, и тур продолжается. Новый классToolError::Fatal— для состояния, в котором продолжать нельзя (ядро гасится или неконсистентно): тур рвётся, но только после закрытия всех парtool_use/tool_result— история чата остаётся валидной, неисполненные соседние вызовы получают синтетический результат, клиент видит ошибку тура. Первый носитель — команда подагентов: «kernel is not up yet» больше не возвращается модели как починимая ошибка. Советчика фатальность не касается: он совещательный и рабочий тур не рвёт. -
Структурированный ответ вместо разбора прозы (заимствовано у Codex): запрос провайдера несёт опциональную JSON-схему финального ответа.
openaiшлётresponse_formatc json_schema — только при объявленной поддержке апстрима (новое полеllm.structured_output, дефолт «выключено»: часть шлюзов ломается на незнакомом поле, как сreasoning);anthropicэмулирует схему вынужденным вызовом синтетического инструмента (с включённым мышлением — предлагает, не вынуждает: форс несовместим с thinking), и его аргументы возвращаются вызывающему текстом;mockотвечает валидным по схеме образцом — фазы, просящие строгий JSON, тестируются без ключей. Первый потребитель — план фазы сна: схема уезжает вместе с прежней текстовой просьбой, ретрай с текстом ошибки остаётся (схема уменьшает частоту промахов, а не отменяет валидацию — vet-правила сна нетронуты)./v1/completeпринимает опциональныеschemaиstrict: модули получают структурированный ответ, не изобретая парсер; поле совещательное — апстрим без поддержки его молча игнорирует, разбирать ответ стоит по-прежнему настороженно. -
Служебные вставки — протокол, а не текст (по мотивам Codex
context-fragments): реестрsrc/fragments.rsперечисляет всё, что ядро дописывает в историю от чужого имени ([state], [wrapup], [kernel], огрызки элизии, маркеры картинок), и один предикатis_machine_textотвечает всем местам разом. Окно живой истории меряется только репликами людей — синтетика роли user (предупреждение о лимите итераций, теперь со своим маркером[wrapup]вместо[state]) ход не открывает и границей компакции не становится; компакция не тащит в резюме «модель залипала» — заметки [kernel], огрызки повторов и метки «пусто» вычищаются перед резюмированием;history_searchпо умолчанию ищет только речь, флагinclude_serviceвозвращает всё. Классификация матчит только явные маркеры реестра — чужой текст с [state] в середине остаётся речью. -
Клиентский
contextиз send_message получил явный потолок (context.client_context_chars, дефолт 32000 символов; 0 — снять): перерост усекается серединой с честной пометкой — чужие байты не забьют окно, дисциплина клиента больше не механизм. -
Инструменты-вердикты:
_meta["dev.eva/ends_turn"](в eva-sdk —Module::ends_turn) объявляет тул, чей успешный вызов закрывает тур — управление модели не возвращается, ядро сразу уходит в завершение (стоимость, компакции, план). Первым помеченtelegram_skip: после сказанного «молчу» модели нечего добавить, а возврат управления уводил её в циклы повторных skip. Ошибка вызова тур не закрывает. -
Честный счёт токенов вместо символов (заимствовано у Codex). Контекст меряется якорем провайдера плюс оценкой хвоста:
prompt_tokens + completion_tokensпоследнего ответа уже честно взвесили персону, схемы тулов, резюме и историю (chats.ctx_tokens/ctx_upto), оценивается только добавленное после — байты JSON через самокалибрующийся коэффициент «байты на токен» (скользящее среднее на модель, дефолт 4); картинка идёт фиксированной ценой, а не длиной base64 — скриншот больше не выглядит как двести тысяч токенов (src/tokens.rs). Пороги компакции переведены в токены:context.compact_after_tokens/compact_keep_tokens(и так же вtelegram/cron); старые*_chars-ключи читаются как символы ÷ 4 с warning на старте. Ошибка провайдера «окно переполнено» — больше не транзиент: без ретраев в ту же стену счёт пинится в потолок, история сворачивается сразу. -
Два потолка окна: порог компакции получил область (
context.compact_scope, дефолтbody_after_prefix) — меряется разговор, а не кэшируемый префикс (персона, правила, схемы тулов), и богатый промпт не наказывается частой компакцией;totalвозвращает старое поведение. Жёсткий потолок модели — новое необязательное полеcontext_windowзаписиllm.models: остаток считается минимумом по обоим, клиентам он уезжает вcontext_windows(GET /v1/models), а оценка стабильного префикса — вcontext.prefix_tokens(GET /v1/chats/{id}/spend), чтобы проценты окна начинались со 100 на пустом чате. -
«Сажай самолёт»: давление контекста в
[state]выросло до трёх ступеней — тихо → «осталось N» → «сворачивайся». Третья ступень приходит ДО компакции (она необратима), заcontext.pressure.wrap_up_reserve_tokens(8000) токенов до ближайшей стены, один раз на окно (chats.wrap_nudged_upto) и только когда компакция не сработает сама прямо сейчас. Тексты всех трёх ступеней настраиваются (context.pressure.notice/wrap_up/just_folded, плейсхолдеры{used}/{limit}/{left}). -
Лимиты провайдера — показание, а не ошибка: заголовки rate-limit (Anthropic- и OpenAI-имена) снимаются с каждого ответа, едут в
Usage.rate_limitsи последним снимком отдаются в/v1/statsполемrate_limits— «Ева тупит» теперь отличимо от «выбран лимит». -
Запрос ядра к клиенту стал первоклассной сущностью (по мотивам Codex
serverRequest/resolved). Кнопочные вопросыask_questionsи клиентские операцииclient_opждут ответа в одной таблице (src/pending.rsвместоquestions.rsи pending-мапыclient_ops), и любой исход — ответ, истечение срока, конец тура, отмена — уезжает в живой стрим событиемrequest_resolved {id, kind, reason, question?}: клиент закрывает диалог и вычищает очередь одобрений по факту, а не по таймеру, угаданному «чуть короче ядрового». Снятие шлётся всегда, включая ответ самого клиента, и только ПОСЛЕ полного завершения тура — висящий вопрос обрывается, а не схлопывается в «отказ» от имени Господина. -
Реплей живого стрима помечен: события из накопленного лога несут
replayed: true, и контракт (docs/src/api.md, OpenAPI) требует их рисовать, но не исполнять.client_opтеперь едет в реплей помеченным, а не выпадает молча: переподключившийся клиент видит и операцию, и её снятие — это чинит повторное исполнение операций при переподключении, описанное в планах mtl (dock-tool-dedupe). Клиенты, исполняющиеclient_opиз реплея, обязаны научиться признаку до деплоя ядра. -
Набор кнопок одобрения задаёт ядро:
client_opнесётdecisions(allow,allow_session,allow_always,deny),ask_questions— словарь мета-решений в input tool_call-события; клиент рисует известные, неизвестные пропускает — новое решение вводится правкой ядра без выпуска клиентов. Выбор возвращается полемdecisionвops/result;denyбезerrorстановится каноническим отказом для модели. Зарезервированkind: "permission"— запрос профиля разрешений с ответом-подмножеством. -
Инварианты тура: запрос к провайдеру всегда валиден (заимствовано у Codex, каждое — своим механизмом). Снимок шага (
StepTools): список инструментов и его досягаемость замораживаются на один поход к провайдеру, и вызов исполняется по снимку, который его объявил, — смена набора,tool_loadили отцепка модуля посреди шага больше не оборачиваются отказом на то, что ядро само предложило модели; мутации видны со следующей итерации, а физически исчезнувший инструмент (отвалившийся MCP) отвечает честной ошибкой с причиной, а не «нет такого». Починка парtool_use/tool_resultпри сборке промпта, не в базе: висячему вызову (процесс умер между вызовом и результатом — SIGKILL, OOM, перезапуск на деплое) дописывается байт-стабильная синтетика[kernel] tool call interrupted; no result was produced, результат-сирота выбрасывается — один обрыв больше не валит 400-й каждый следующий тур чата до/clear, база остаётся честной, кэш промпта цел. Отмена даёт доиграть:cancel_all/cancel_chatкоротко (до 200 мс) ждут штатного сворачивания тура, чтобы следующий ход не читал историю посреди записи; инструменты с внешним исполнителем (shell, MCP-вызовы, клиентские записи иlocal_shell— новый предикатTool::waits_on_cancel) на отмене ждут до их собственного таймаута, а не дропаются — брошенных процессов и «не исполнено» об исполненном больше нет, успевший прийти результат побеждает гонку с отменой; отмена с висящимask_questionsоставляет в истории обрыв, а не ответ за Господина (закреплено тестом). Мок-провайдер теперь проверяет сопряжённость пар на каждом запросе и падает паникой — весь тестовый набор стережёт инвариант попутно. -
Секреты в выхлопе инструментов затираются у истока:
cat .env,envили вывод MCP-тула больше не увозит токен провайдеру, в базу (messages— не архив секретов) и клиенту в стрим; читающие инструменты советчика — под тем же щитом. Правила — данные (secrets.rules, дефолтный набор в коде):Bearer,sk-,AKIA,AGE-SECRET-KEY,glpat-,gh*_, PEM-блок приватного ключа целиком и присвоенияtoken=…/GITHUB_TOKEN=…; порядок несущий (Bearerпервым —Bearer sk-…гаснет одной заменой), плейсхолдер называет вид ([redacted: bearer token]), ложные срабатывания прибиты тестами («Bearer of good news» и юникодный кельвин живы). Приём — из codex-rssecrets/sanitizer; HMAC-плейсхолдеры oh-my-pi отложены до сценария, где два затёртых токена нужно различать. Затирание — сеть безопасности, не разрешение читать.env. -
SSRF-щит
web_fetchдожат: редирект на приватный IP-литерал ходил мимо DNS-резолвера (а только он и проверял адреса) — теперь его режет политика редиректов; скачиваниеsourceдляimage_generateходило голым клиентом вовсе — встало под тот же щит. Исключения — явным спискомweb.allow_private_hosts(пуст по умолчанию): пустить Еву во внутреннюю графану — решение Господина, а не модели. Адреса MCP-серверов приходят только из конфига, им щит не нужен. -
Файлы проекта читает ядро, а не каждый клиент по-своему: блок
[project]в стабильной части промпта собирает ВСЕAGENTS.mdот корня проекта (маркерыcontext.project.root_markers, дефолт.git/.jj/flake.nix; пустой список выключает подъём) вниз до рабочего каталога чата, ближний — последним, плюс память проекта.eva/MEMORY.mdсо своим бюджетом. Бюджет наAGENTS.mdОБЩИЙ (context.project.max_bytes, 32 КБ;0выключает сбор): не влезшее усекается с подписью, какой именно файл пострадал, пустые файлы бюджета не тратят;AGENTS.override.mdзаменяет соседнийAGENTS.md; каждый кусок несёт путь-происхождение — «вweb/AGENTS.mdнаписано иначе» теперь можно сказать. Серверный рабочий каталог ядро сканирует само (один заход шелла на весь подъём — и на ssh-удалёнке), клиентский достаёт клиент служебнымclient_oplist_up+read_bytesдо сборки промпта, без участия модели; поддержку клиент объявляет новым флагомproject_docsвsend_message(без него незнакомая операция к старым клиентам не прилетает) и перестаёт клеитьAGENTS.mdвcontextсам. Подагенты наследуют флаг от тимлида — их сбор идёт через его клиента. Читается один раз на входе в тур: блок байт-стабилен между итерациями, и кэш промпта живёт — в отличие от перечитывания на каждой отправке, как делал tui. eva-sdk не задет: модули поверхностей клиентских операций не ведут. -
Пачка правок одним вызовом: инструмент
apply_patchрядом сwrite_diff(тот остаётся — для одиночной замены он проще). Формат взят у Codex почти дословно (*** Begin Patch/Update|Add|Delete File/Move to): ни одного номера строки, место правки находится поиском процитированных строк с четырьмя уровнями строгости — вплоть до нормализации типографики, так что ASCII-патч ложится на файл с «ёлочками» (сами «ёлочки» добавлены к списку Codex: русские тексты и переводы в mtl). Применение двухфазное: сперва весь патч читается и проверяется против диска, и только потом пишется — неверный ханк не оставляет три файла наполовину переписанными; частичное применение возможно только при отказе самой записи, и тогда ответ честно перечисляет, что легло. Ошибки цитируют вход — модель чинится со второй попытки, тур не рвётся. Клиенты не тронуты: на проводе те жеread/write, поэтомуDelete File/Move toисполняются только на рабочей машине (на машине Господина —local_shellrm/mv). Наборы тоже: группаclientв кодовом наборе подхватывает инструмент сама. Мостам фича не нужна: в телеграме файлы и так живут серверной рукой, и инструмент там доступен как есть. eva-sdk не задет: ни API, ни протокол модулей не менялись. Бенчмарк: сценарийapply-patch-batch— две правки в двух файлах одним вызовом. -
Описание
image_generateперестало пересказывать собственную схему: панель из четырёх семейств моделей (96 вызовов probe-харнеса) стабильно восстанавливала перечислениеsourceи «опиши в prompt» из одной схемы, а blame показал фичевой коммит, не шрам. Уникальные детали (текст в картинке, чья машина у файлового пути) переехали в описания полей схемы — информации не убыло, повторов не осталось. Остальные спорные строки прогона — шрам пагинацииread_fileи маршрутизация «не через shell» — оставлены по дисциплине шрамов. -
Советчик: вторая, дешёвая модель смотрит на рабочий тур со стороны. Раз в
advisor.every_iterationsитераций она получает дельту транскрипта, ходит только читающими инструментами (read_file,find_pattern,history_search— белый список в коде, не просьба в промпте) и вкладывает в тур одну заметку<advisory severity="…" guidance="weigh, don't blindly obey">тем же каналом, что и правила: спереди к результату вызова и только в промпт-копии. Ева про советчика в промпте не знает — тег со своимguidanceеё единственная подсказка. Включает его роль моделиllm.model.advisor(не задана — советчика нет вовсе) и только рабочий тур Господина: клиентские операции, рабочий набор, подагент; в болтовне вторая модель стала бы комментатором бытовых реплик за живые деньги. Шум держит щит в коде, а не промпт советчика: нормализация NFKC, чёрный список пустых фраз (молчание — правильная форма «всё хорошо»), дедуп кольцом, одна заметка за цикл, кулдаун после вклеенной, сброс всей памяти о чате при перезаписи истории — и подавление самому советчику невидимо, иначе он перефразирует ту же мысль в обход дедупа. Прерывания идущего тура нет:severity(nit/concern/blocker) доезжает до Евы, но все три ступени обрабатываются одинаково мягко — жёсткая половина сядет вместе с жёсткой половиной правил. Заметки, включая подавленные, пишутся вadvisor_notesи видны вGET /v1/statsполемadvisor. -
Правила поверх генерации, жёсткая половина: текстовое правило (
scope: text,mode: interrupt) судит готовый ответ перед фиксацией. Промах — ответ выбрасывается целиком (в базу не попадает, клиенту не уходит, объявленные в нём вызовы не исполняются), а ход переписывается с<system-interrupt reason="rule_violation" rule="...">в промпт-копии. Потолок — один ретрай на ход: второй промах ответ уже не выбрасывает и едет мягким напоминанием на ближайшем результате инструмента. Пока текстовое правило в туре заряжено, речь и объявленные вызовы придерживаются от клиента до вердикта (ожидания не добавляет — уходит одним куском, когда ответ дописан); без заряженного правила стрим идёт как раньше. Зациклить тур правилу нечем: политика повтора считается по ходу чата, и сработавшее правило до конца хода молчит. -
Пара
scope+modeтеперь проверяется:text— толькоinterrupt,tool:<имя>— толькоremind;modeможно не называть, он следует изscope. Правило с невозможной парой не регистрируется (warning в лог). -
Новое условие окружения правил —
picture: картинка в контексте тура, которую модель действительно видит. Без него правило про картинки наказывало бы слепую модель за честное «не вижу». -
Посеянные текстовые правила откалиброваны по живому прогону:
images_look_before_refusingпереехало вlook_before_refusing(старое имя обещало инструмент, которого в ядре нет) с условиемpictureи новым текстом; у остальных подтянуты формулировки. Волна калибровки применяется один раз за жизнь базы и переписывает только правила, которых Господин не касался. -
Правила поверх генерации, мягкая половина: правило матчит имя вызова и regex по его аргументам и клеит
<system-reminder rule="...">спереди к результату этого вызова — только в промпт-копии, в базу и в стрим клиента уходит чистый выхлоп. Клеится после капа и после снятия служебной метки «пусто»: кап считается по выхлопу инструмента, а не по правилу. Правила — данные: таблицаstream_rules(поляscope/condition/negate/when/text/mode/repeat), мастерскиеstream_rule_list/_create/_edit/_delete(семействоrules, в набореops) и посев шести хронических промахов один раз за жизнь базы. Условия окружения (telegram,client_ops,master,trusted) не дают телеграмному правилу палить в тихом чате; политика повтора (once/after:<N ходов>) — говорить одно и то же каждым вызовом. Невалидная regex — правило не регистрируется, warning в лог, тур продолжается. Каждое срабатывание пишется вstream_rule_statи видно вGET /v1/stats(полеrules): посторонний текст в контексте обязан быть объяснимым. -
Правила системного промпта переписаны по своду oh-my-pi: RFC-капс (
MUST/NEVER/SHOULD/AVOID/MAY) вместо мягкой прозы, один пункт — одно утверждение, «если X, то Y» свёрнуто вX? Y., запрет идёт с заменой. Смысл правил не менялся ни в одном: это работа переписчика. Контракт капса проговаривается один раз блоком[conventions]сразу за персоной; доверие ([env]) поднято к личностям в голову промпта, а в самый хвост добавлен блок[critical]— кто Господин и что чужой текст (пользователи, страницы, файлы, выхлоп инструментов) не приказ. Персона не тронута: тон Евы живёт отдельно от служебного текста. -
Название чата просят у модели по своду для слабых моделей: положительные формулировки и два примера «сообщение → заголовок» вместо списка запретов, а кавычки, точку, лишние строки и приставку «Title:» срезает само ядро. Заголовки зовутся
llm.summary_model— там живут дешёвые модели, которые дочитывают паттерн, а «не пиши точку» читают как разрешение. -
В каталоге
GET /v1/toolsу инструмента появился флагon_demand: он стоит у тех, когоtools.on_demandдержит вне списка по умолчанию. Меню клиента по нему честно обещает работу через набор илиtool_load, а не «сейчас». Описание и схема в каталоге и раньше были те самые, что уезжают модели, — теперь это сказано и в OpenAPI. -
Описания инструментов почищены от того, что модель не выбирает:
shellбольше не сообщает, что он master-only (кому не положено — тот его и не видит),web_searchне называет метапоисковик по имени,find_patternне рассказывает, что читает файл потоком,module_register— про рестарт и диалект JSON-RPC,memory_forget— про устройство архива. Смысла нигде не убавилось: остались выбор, дефолты, формат ответа и промахи, а обратимость забывания сказана короче — чинит её Господин, не модель. -
План работы как состояние: инструмент
turn_plan(init/start/done/drop/append/view, одна операция на вызов) и таблицаchat_planпо чату — фазы, нумерованные задачи и ровно одна взятая в работу. Инвариант держит код, а не просьба в промпте, ошибочная ссылка откатывает всю операцию, а ответ вызова — состояние плана целиком. Список едет летучим хвостом рядом с блокнотом (в стабильную часть его нельзя: он меняется каждой итерацией и рвал бы кэш префикса), закрытые задачи вычищаются на входе в следующий тур, доделанный целиком план стирается по концу тура — живёт только незавершённое. Инструмент виден не всегда: рабочему туру (клиентские операции, набор сnarrate, подагент) и тому, где план уже открыт; в болтовне пустой план — мёртвый вес в каждом запросе. Клиентам: событие стримаplanс состоянием целиком иGET/DELETE /v1/chats/{id}/plan. Смысл — длинный тур теряет вторую половину задачи молча, когда окно свернулось, а выхлоп начала тура схлопнулся. -
Уровень рассуждения стал ручкой тура, а не флагом на провайдера:
llm.reasoningтеперь ступеньoff|low|mid|high, и старое булево читается как раньше (true—mid,false—off), так что конфиги на машинах править не надо. Ступень выбирается по убыванию силы: полеeffortвsend_message→ словоultrathinkцелым словом и вне кода в реплике Господина (llm.magic_keywords, по умолчанию включено; из текста слово не вырезается, инструкция едет отдельной строкой состояния тура) → настройка чата (PUT /v1/chats/{id}/effort) → полеeffortзаписи модели → глобальная. КаталогGET /v1/modelsотдаётefforts,effort_levelsиeffort_default. Служебные вызовы (компакция, триаж, названия чатов) как думали, так и не думают — сменился только тип. У билдера тура в eva-sdk появились.effort(Effort)и.surface_effort(…)— перечислением, а не строкой: ступеней ровно четыре, и опечатке взяться неоткуда. -
Поверхность может назвать свою ступень рассуждения по умолчанию: поле
surface_effortвsend_message, симметричноsurface_model. Встаёт слабее настройки чата и слова Господина, сильнее записи модели и конфига — мессенджер удешевляет свои туры, но не перебивает то, что Господин выбрал конкретному чату, и не глушитultrathink. -
Мастерский
think_harder: поднять уровень на оставшиеся итерации хода, когда задача оказалась глубже, чем выглядела. Понизить до конца хода нельзя, подъём пишется в журнал — это деньги. Сидит в стартовых наборахcoding,opsиresearch(посев одноразовый: в живой базе набор правится тулами набора). -
Anthropic-провайдер научился расширенному мышлению: думающая ступень просит адаптивный режим с показом мысли,
offговорится вслух — иначе думающая по умолчанию модель думает всегда. Мысль капает в стрим и возвращается провайдеру вместе с подписью в пределах тура: без подписи API отвергает вторую итерацию хода с инструментами. Прошлые ходы по-прежнему уезжают без мыслей, а неподписанная мысль (из истории или от другого провайдера) на провод не идёт. -
Правило рисования в промпте идёт за досягаемостью
image_generate, а не за одной настройкойllm.image_model. Прежде мастерскому туру обещали оффлоад всегда, когда рисующая модель настроена, — и в чате, чей белый список инструмент не пускает (телеграмная группа со своим набором тумблеров), Ева читала в промпте про руку, которой у неё там нет: в списке тула не видно,tool_loadего из-под недоверенного потолка не достанет,tool_findчестно отвечает «ничего не скрыто» — и она шла докладывать Господину о поломке ядра вместо ответа. Заодно исчез абзац «рисовать нечем»: нет инструмента — нет и правила, сказать «не могу» модель умеет без подсказки в каждом запросе.
2026-08-06
-
Личная память (
private) требует теперь доверенного окружения, а не только мастерского тура (ломающее): допуск считается по аудитории, а не по говорящему. Прежде хватало того, что говорит Господин, — и его личное уезжало в промпт и в выдачуmemory_searchв открытой группе телеграма, где ответ читают все, кто там сидит.memory_listв недоверенном месте больше не показывается вовсе (дамп идёт вместе с личным), соседи в ответеmemory_saveи текст записи при слиянии фильтруются так же. Тур Господина из недоверенного места получает полную выборку без личного и сноску о том, что личное придержано. Задевает и крон: его туры недоверенные, и личного они теперь не видят. -
У крон-джобы появился
compaction_profile—low,medилиhighвcron_createиcron_update(в правке ещёauto/null— обратно на автоматику). Это тот же профиль, что ставит чатуchat_compact_profile, только прибивается он чату джобы: раньше сменить джобе аппетит к истории можно было, лишь дойдя до её чата отдельным инструментом, и модель об этой возможности не догадывалась. Прибитый профиль виден вcron_listи в полеcompaction_profileуGET /v1/cron. -
Гостевых чатов у крона больше нет (ломающее): чат заводится джобе при создании — свой, на полке
cron, — и уходит вместе с ней.POST /v1/cronбольше не принимаетchat_id(поле игнорируется — сериализация тела нестрогая), и заодно перестал плодить чаты на полке «Входящие»: этим путём таймер селился в чужую переписку, шёл по общей истории с людьми и говорил вслух в их группу. Инструментcron_createсвой чат заводил и раньше, у него меняется только описание.
2026-08-04
-
Whitelist крон-джобы больше не принимается на веру:
cron_create/cron_update(иPOST /v1/cron) сверяют каждое имя с реестром — тулом или группой регистрации. Незнакомое имя — ошибка с ближайшими похожими и списком групп, чтобы модель поправила список сама. Прежде опечатка («telegram_send» вместо группыtelegram) молча рождала немую джобу: она стреляла по расписанию, но отправить результат ей было нечем. Сверка —ToolRegistry::validate_access, общая для любого сохраняемого списка доступа. Регрессия —benchmark/scenarios/02-selftest-cron-whitelist.yaml; в чеках бенча уcron_existsпоявилось полеtools(точный whitelist) и обратный чекcron_not_exists. -
Новый
cron_fire(иPOST /v1/cron/{id}/fire): выстрелить таймером сейчас, не дожидаясь расписания. Задачу подбирает ближайший тик планировщика, следующий регулярный запуск — через интервал от этого (семантика та же, что у обычного срабатывания). Тул в группеcron— наборы, ссылающиеся на группу, получают его сами. -
Экономика кэша промпта видна целиком:
cached_tokensтеперь и в разрезахby_model/by_sourceстатистики, рядом новыйcache_write_tokens— сколько записано в кэш (Anthropic отдаётcache_creation_input_tokens, OpenAI-шлюзы поля не имеют — там 0). Записи без чтений — маркер инвалидатора или кэша, включённого впустую. Новая колонка вspend, миграция автоматическая. Оконный блок статистики заодно отдаётprompt_tokens— знаменатель доли кэша, который клиенты прежде добывали вычитанием completion из тотала. -
Mock-провайдер выучил сценарный режим: блок
mock-plan:в реплике играется как точная последовательность вызовов, по одному на итерацию, с финальнымsay. На нём — регрессия спасения застрявшего тура:benchmark/scenarios/01-selftest-stuck-rescue.yamlвоспроизводит ловушку «думает cron_create — произносится cron_list» по нотам и проверяет след (джоба существует). До фикса сценарий падает, теперь обязан оставаться зелёным; модель и ключи не нужны. -
Тур, трижды подряд повторивший один вызов байт в байт, считается застрявшим: обычно это попытка дотянуться до скрытого соседа — провайдер со строгой грамматикой не даёт модели даже произнести имя вне списка, и «хочу
cron_update» раз за разом выходит вызовом видимогоcron_list, сколько ни объясняй текстом. Ядро дописывает к выхлопу такого вызова записку и сразу докладывает в список скрытых соседей инструмента по семьям регистрации (гейты и потолок — те же, что уtool_load), так что нужное имя становится произносимым со следующего шага. Стрик рвётся любым другим вызовом, поэтому честный поллинг и перечитки между правками записку не ловят. -
Провайдер
venice(Venice.ai): только картинки —image_generateс нуля через их/image/generateи правка source-картинки через/image/edit, открытые модели без вшитой цензуры (safe_mode выключен, водяной знак снят). Правка уходит парной*-editмодели по имени (qwen-image-2→qwen-image-2-edit); имя, уже несущее «edit», едет как есть. Текстовые туры не обслуживает — ему место именованным апстримом (llm.providers) при рисующей записиllm.models, не основным провайдером. Расход Venice в usage не отдаёт, так что в статистике трат его картинки не видны. Мостов не касается: картинка приходит тем же событием и блоб-стором. -
Провайдер выбирается per-model: секция
llm.providersдержит именованные апстримы (те же поля, что соединительные вllm: provider, api_key, base_url, proxy, reasoning, temperature, prompt_cache), а записьllm.modelsполемprovider: <имя>уводит модель на свой — остальные ходят через основной. Роли иmodel.fallbackсвободно смешивают модели разных апстримов; ссылка на необъявленное имя роняет ядро на старте, как и прочие опечатки конфига моделей. Мостов не касается: клиенты видят тот же список моделей, кто их обслуживает — забота ядра.
2026-08-03
-
Библиотека YouTube переехала в ядро:
/v1/youtube/subscriptions(список по названиям, upsert поucid, идемпотентное удаление) и/v1/youtube/history(PUTпоvideo_id, удаление одной записи и всей истории). Подписки и просмотренное лежали у каждого клиента свои, и телефон с браузером расходились в том, что Господин уже смотрел; теперь полка одна. Место остановки ядро хранит только между 2% и 97% длины ролика — ниже это случайный клик, выше досмотрено, при неизвестной длине мерить не от чего; запись при этом из истории не пропадает, а ответ отдаёт строку такой, какой она легла. История помнит 200 последних видео. Страницы режутся курсоромbeforeпоvideo_id, а не по времени: импорт истории с телефона приезжает одной секундой и на сравнении по времени терял бы строки на границе страницы. Правила живут в ядре, чтобы клиенты не переписывали их каждый у себя. -
Сон по будильнику (ломающее):
sleep.intervalупразднён, вместо негоsleep.at— время суток в зоне хоста (дефолт"00:00"), раз в сутки. Пропущенная граница (ядро лежало, шла генерация) не сгорает — досыпается первым подходящим тиком, дважды за сутки сон не приходит.idle_forтеперь opt-in: по умолчанию расписание не ждёт тишины разговора (только дренажа генераций); задан — прежнее поведение.next_atвGET /v1/sleepвсегда заполнен: непроспанная прошедшая граница или следующая. -
Ответы на кнопочные вопросы больше не съезжают после первого: поле
questionвPOST /v1/chats/{id}/questions/answer— стабильная позиция вопроса в массивеquestionsвызова, как и документировано, а не индекс в живом pending-множестве чата. Ответ на первый вопрос из батча больше не превращает второй в «404 pending question» и не отправляет нажатие в соседний вопрос. Новое опциональное полеcall_id(id изtool_call-события) развязывает одновременныеask_questionsв одном чате; без него ответ идёт в самый ранний живой вызов — старые клиенты работают как прежде. -
Сид набора
socialзнает про рисование: группаimages(image_generate) и слово о ней в описании — просьба «нарисуй» в социальном режиме больше не упирается в спрятанный инструмент. Сиды сеются один раз, работающим базам набор правится на месте. В AGENTS.md — обязанность актуализировать наборы и их описания при изменении инструментов. -
Проект кодовой сессии — поле, а не полка (ломающее): полки
code:<проект>упразднены, все кодовые чаты живут на одной обычной тихой полкеcode, а привязка к проекту — новое полеproject(POST /v1/chats, колонкаchats.project).GET /v1/chatsпринимаетproject=<имя>— точное имя, сочетается сfolder/surface; явныйproject— явная просьба, тихие полки его не режут. Миграция на старте:folder='code:<X>'→project='<X>',folder='code'; она идемпотентна и конвертирует и заведённую рукамиcode:<X>при следующем запуске. Дефолтchats.quiet_folders—["youtube", "cron", "code"].eva-sdk::create_chatтеперь берёт структуруCreateChat { title, folder, surface, project }сDefault— будущие поля не будут ломать сигнатуру; вeva_sdk::Chatдобавленоproject. -
Фильтр
folderвGET /v1/chatsпонимает хвостовую*как префикс:folder=code:*отдаёт чаты всех полокcode:<проект>одним списком (грамматика — как уchats.quiet_folders, без*— точное имя). Явныйfolder— явная просьба: тихость полок и скрытиеagentsк нему не применяются. -
Тихие полки: полки автоматики больше не топят общий список — их чаты выпадают из
GET /v1/chats, когда не задан ниfolder, ниsurface;folder=<имя>отдаёт полку целиком,surface=<имя|any>— явные срезы «покажи всё», там не тихо. Список —chats.quiet_folders, по умолчанию["youtube", "cron", "code:*"]: хвостовая*— префикс, без неё — точное имя.GET /v1/chats/foldersтихие полки отдаёт как обычно — клиенты строят по ним переключатель.
2026-08-02
- Великое объединение папок (ломающее): полка чата больше не означает
«какой клиент его завёл» — общие клиенты (tui, web, app, android) смотрят
в один и тот же список. У чата появилось поле
surface: специализированная поверхность (telegram, mtl) помечает им свои чаты при создании (POST /v1/chats, новый параметрeva-sdk::create_chat), и в общий список они не попадают.GET /v1/chatsиGET /v1/chats/foldersпринимаютsurface=<имя|any>; без него — только обычные чаты, поэтому клиент, ходивший за своей папкой (folder=tuiи т.п.), увидит общую ленту. Папка стала человеческой полкой «о чём чат» и меняется из любого клиента:PUT /v1/chats/{id}/folder(null/пусто — «Входящие»default). Профиль компакцииcontext.telegram.*выбирается поsurfaceчата, а не по строке папки и не по якорю тура. Миграция на старте:folder='telegram'→surface='telegram',mtl:<игра>→surface='mtl'(полка игры остаётся), происхожденческие папкиtui/android/web/telegramскладываются вdefault. Папки с поведением (agents,problems,youtube,code:<проект>,mtl:<игра>) живут как жили.
2026-08-01
image_generate.sourceпонимает больше источников:lastтеперь выбирает свежайшую картинку чата — нарисованную ИЛИ присланную фотографией (раньше — только нарисованную), а путь к файлу читается файловыми руками ядра: машина Господина, когда клиент на связи, иначе хост ядра. «Отредактируй ~/фото.jpg» работает прямо из TUI-сессии. Формат источника проверяется магик-байтами, расширению веры нет.- Генерация картинок — Ева умеет отдавать картинку, а не только смотреть.
Два пути: модель тура с флагом
image_output(вllm.models) рисует сама — ядро проситmodalities: ["image","text"]и ловит картинку в стриме; прочим даётся master-only инструментimage_generate— оффлоад наllm.image_model(дефолт — первая рисующая из списка), генерация поpromptи правка существующей (source: id /last/ URL). Умеющей рисовать модели инструмент не показывается — второй платный вызов на ровном месте не нужен. - Блоб-стор картинок: файлы в
images.dir(дефолт — рядом с базой), таблицаimages, ретенцияimages.keep(30 дней, чистка на старте и раз в сутки). Наружу — публичная ручкаGET /images/{id}вне/v1и токена (ключ доступа — неугадываемый uuid, ответ immutable) и ссылка отimages.base_url. В историю чата ложится маркер[image <id>] <url>, а не base64 — контекст не толстеет на мегабайт с каждой картинки. - Клиентам: SSE-событие
image {id, media_type, url}в стриме тура, массивimageвGET /v1/models; eva-sdk —TurnEvent::ImageиKernel::image(id)для скачивания байтов по id. Расход генерации пишется вspendна чат (source = image_generate). POST /v1/completeумеет кэш промпта: полеcache(по умолчанию выкл., оно жеComplete::cacheв eva-sdk) помечает system-шапку провайдерским маркером. Модулю, который шлёт сотни вызовов с одной и той же многокилобайтной шапкой (переводческая мастерская), она перестаёт стоить каждый раз; TTL берётся общий,llm.prompt_cache. Без поля вызов по-прежнему глушит маркеры: одиночному перечитывать кэш некому, а запись дороже обычного входа.POST /v1/completeперестал выдавать икоту провайдера за ошибку вызывающего: 429, перегрузка и оборванная сеть отвечают 503, отказ по существу запроса (нет такой модели, не тот аргумент) — прежним 400. Тур такую икоту ретраит сам, а одноразовый вызов не ретраит никто, и клиент решает по статусу: 503 — повтори, 400 — чини запрос. Текст ошибки в теле не изменился.
2026-07-31
- Медленноволновой сон — консолидация памяти (секция
sleep, включена по умолчанию;min_memoriesбережёт мелкий корпус). Раз в сутки, дождавшись тишины, ядро кластеризует корпус по лексике и семантике и разбирает его самой сильной моделью — новая рольllm.model.smartest, оверрайдsleep.model: слить дубли, развести противоречия с датировкой, расщепить склейки, вывести из череды однотипных событий факт, подтвердить забывание арифметических кандидатов (модель может список только сократить), поднять и опуститьcore, перекалибровать сползшую важность. Инструментов во сне нет: модель отвечает строгим JSON-планом с обязательнымwhyна каждой операции, применяет ядро — с потолкамиmax_ops/max_forgetна ночь;coreне стирается никогда (только понижение вида), записи моложеkeep_recentне забываются и не переписываются,privateне снимается и заражает слияние. Устоявшиеся кластеры (consolidated_at) модели не уезжают — вторая ночь на неизменном корпусе не стоит ни одного вызова. Спящая Ева не отвечает: тур ждёт пробуждения в очереди, его SSE-поток открывается событиемsleeping. РучкиGET|POST /v1/sleep,POST /v1/sleep/wake(недоделанная фаза не применяется, применённые остаются),GET /v1/sleep/runs; тулmemory_consolidate— уснуть, не дожидаясь ночи. О заметной ночи Господину уходит уведомление;dry_runпишет план в журнал, не применяя, — режим первых недель. Расход виден отдельными источникамиsleep:cluster/sleep:forget/sleep:kinds. - Забывание стало обратимым:
memory_forget,DELETE /v1/memories/{id}и сон кладут запись в архив (GET /v1/memories/archive,POST /v1/memories/archive/{id}/restore), окончательно выносит её оттудаsleep.archive_ttl. Слияние записей сохраняет id подкреплённейшей и суммирует историю обращений — spaced repetition не обнуляется. - Отскоренная память снова доезжает до промпта. Бюджет секции был общим на
coreи выборку, и разросшийсяcoreсъедал его целиком: на живом корпусе (102 записи, 24 из нихcore) в промпт попадали девять самых свежихcore— и ни одногоfact, ни одногоevent, сколько бы они ни набрали. Считался бюджет в байтах, так что на кириллице реального места было вдвое меньше обещанного, а первая не влезшая строка обрывала секцию вместе со всем хвостом. Теперь потолок —memory.prompt_budget(8000 символов, именно символов, а не байт),coreзанимает не больше 60% его, остаток принадлежит выборке; не влезшая строка пропускается, а не рубит хвост, и секция вслух говорит, сколько записей осталось за бортом и что с этим делать. Молча урезанная память читается как «это всё, что я помню», и выпавшее не всплывает уже никогда. - Личное не уезжает в чужой тур. У воспоминания появилась пометка
private(memory_save,memory_update,POST /v1/memories): такая запись не попадает ни в промпт не-мастерского тура, ни в выдачуmemory_search— раньшеcoreцеликом ехал в промпт публичного телеграм-чата, и здоровье, вес и адрес Господина защищала одна строчка правил. Существующие записи считаются общими: разметить их — решение Господина. - Векторы больше не протухают молча.
embedding_modelписалась в базу, но с активной моделью не сверялась: смена модели эмбеддингов оставляла корпус с векторами чужого пространства навсегда, а косинус между пространствами шёл в скоринг с весом 0.5. Теперь старт пересчитывает и такие записи (батчами, best-effort), а косинус считается только с вектором активной модели. - Сохранение показывает, с чем новое соседствует.
memory_saveвозвращает до трёх ближайших записей: момент записи — единственный, когда видно, что чему противоречит. Дедуп при слиянии берёт более подробную формулировку (факт повторяют ради уточнения),memory_updateнаучился менять вид — эпизод, записанный вcoreпо ошибке, опускается вfact, а не живёт в промпте вечно. Строка воспоминания несёт возраст и важность (id | kind | age | importance | content): без возраста вчерашняя правда читается наравне с годовалой. - Семантическая половина скоринга перестала быть константой. Порог косинуса
стоял на 0.3, а у эмбеддингов базовая близость любых двух текстов высока:
на живом корпусе (bge-m3, 5151 пара) медиана заведомо несвязанных пар —
0.39, и порог пропускал 90.7% пар, отдавая половину веса скоринга за то,
что оба текста написаны по-русски. Теперь близость считается как
(cos − 0.4) / (1 − 0.4): фон весит ноль, родство (от ≈ 0.7) весит по-настоящему. GET /v1/memories/prompt?q=…показывает секцию# Memoryровно в том виде, в каком её несёт тур (&public=true— как чужой), и ничего не подкрепляет. Что именно доезжало до модели, снаружи было не видно — а обрезалось молча.POST /v1/memoriesдействительно дедуплицирует, как и обещает описание ручки: похожая запись подкрепляется, а не дублируется.- Запрос к памяти собирается из текущей реплики целиком и подрезанных предыдущих: полными они размывали запрос, и выборка отвечала на позавчерашнюю тему.
- Рассуждения видны и по ходу тура, и по команде. SSE-событие
usageнесётreasoning_tokens— счётчик клиента показывает, на что уходит ожидание, не дожидаясь конца ответа.GET /v1/agentsиagent_listотдают ту же ось на каждого подагента и итогом по команде: кто из них думает дороже всех, было не видно. - Расход на рассуждения — отдельной осью статистики.
/v1/stats(итог, по моделям, по источникам),/v1/chats/{id}/spendиusage_statsпоказываютreasoning_tokens— долю выходных токенов, ушедшую в мысли, — иreasoning_cost, если у модели задана новая опцияllm.models[].price_out(цена выходного токена, USD за миллион). Без цены поле остаётсяnull: провайдер отдаёт стоимость запроса одним числом, и разложить её на вход и выход нечем — при кэшированном контексте вход перевешивает выход, так что пропорция по токенам врёт кратно. - Заметку разрешённого размера можно закрепить. Бюджет булавок был втрое
меньше потолка тела (800 против 2000), и заметка на 1530 символов
сохранялась, но не закреплялась — ни одного размера, при котором булавка
не упирается раньше записи, не оставалось; лимит в три штуки при этом был
недостижим. Теперь
pinned_chars— 2500, не нижеmax_note_chars, и дословного состояния при переходе в режим index не становится меньше, чем было в inline. Отказ теперь говорит, что чинить: не влезающая сама заметка — резать её, перебор суммой — называет, кто бюджет занял и сколько. - Закреплённая заметка сверх бюджета названа, а не пропущена молча. В блокнот булавка приезжает и мимо проверки — возвратом после пересборки, из архива, снижением потолка в конфиге; блок цитировал закреплённые до первой не влезшей и терял вместе с ней весь хвост, включая те, что влезали. Теперь берётся всё, что помещается, а остальные названы по имени с пометкой сходить за телом — модель видит, что состояние задачи не перед глазами.
- Тильда в пути файловых инструментов ведёт в домашний каталог. На рабочей
машине путь уезжает в кавычках, и
~/.ssh/configдоезжал именем файла с тильдой — отказ «нет такого файла» на самом обычном пути. Теперь голова пути разворачивается подстановкой домашнего каталога той машины, где вызов исполняется; на машине Господина это давно умеет сам клиент. - Рассуждение возвращается модели вместе с вызовом, который из него выросло.
История собирается заново на каждой итерации тура, и мысль из неё
выбрасывалась при сериализации: reasoning-модель получала голые
tool_callsи переоткрывала план с нуля каждый шаг — теряла намерение между вызовами и повторяла сделанное. ТеперьThinking-блоки едут полемreasoningтого же assistant-сообщения (поле шлюзов, проксирующих рассуждения; кто его не знает — игнорирует). Дальше тура мысли по-прежнему не живут. Заодно ловитсяreasoning_content— формат нативного Moonshot и vLLM. - Кривые аргументы вызова возвращаются ошибкой, которая говорит, что чинить. Нераспарсенный JSON доезжал до инструмента строкой, тот не находил поля и отвечал «path is required» — модель считала себя правой и слала тот же вызов снова. Теперь реестр проверяет аргументы до инструмента: объект в лишних кавычках чинится молча, битый JSON называется битым, а пропущенное поле приходит вместе со списком присланных и подсказкой по ближайшему имени («paht → path»).
- Правка файла возвращает вид на своё место.
write_diffотвечал числом замен, и проверить результат можно было только перечитав файл — итерация на каждую правку. Теперь в ответе три строки вокруг замены с номерами. - Пропавший файл на рабочей машине называет соседей по каталогу. Голое
No such file or directoryпромах по имени не чинит, и следующая попытка была такой же догадкой. - Чтение отдаёт за вызов вчетверо больше текста (16k символов): страница в ~100 строк превращала файл на 800 строк в восемь походов к провайдеру.
code_taskбольше не выглядит дорогой к работе с репозиторием. У делегата нет ни инструментов, ни файлов, ни истории — он видит только текстtask; описание теперь говорит это прямо и отправляет за работой внутри репозитория кagent_spawnс кодовым набором. Расход делегата попал в общий счёт — раньше его не было видно вовсе.- Служебные пометки в контексте — по-английски (
[re-read further down],[same call as above …],[middle cut: N chars of M],[empty],[image — this model cannot see it …]). Машинный слой англоязычный, и русская пометка среди него читается моделью как чья-то реплика. - Удаление чата снимает его живых подагентов.
DELETE /v1/chats/{id}мягкое: строка остаётся, каскаду срабатывать не от чего — и команда продолжала работать, докладывая в чат, которого нет в списках. Никто этих писем не читал, а туры стоили денег. Теперь живым ставитсяstoppedи отменяются идущие туры; письмо о снятии уходит наверх, так что восстановленный чат объясняет молчание команды.restoreвозвращает переписку, но не воскрешает команду. - Жнец уносит команду вместе с тимлидом. Временный чат, домолчавший до
chats.ephemeral_ttl, удалялся один: строка подагента висит наparent_chat_id, а внешнего ключа там нет, и чат подагента persistent — жнец его не берёт. Оставался призрак: агент вGET /v1/agents, его почта, целый чат с историей и тратами, а читать доклады некому. Теперь жатва — одна транзакция: сперва чаты подагентов (каскадом уносит и строки, и переписку), следом чаты тимлидов; живые туры унесённых отменяются. - Снятый подагент больше не воскресает письмом. Тимлид умеет позвать
agent_spawnиagent_sendв одном ходу; такое письмо приходит, когда подагент уже на последней итерации тура, доставить внутрь его некуда — и оно ждёт нового тура. Тур заводился и после снятия:stoppedиз ручки API держался пару секунд, а потом подагент снова оказывалсяidle, потратив ещё один ход. Теперь перед подхватом письма спрашивается статус: снятому тур не заводится вовсе, письмо остаётся непрочитанным.doneиfailedставит себе сам подагент, и письмо тимлида по-прежнему вправе его продолжить. - Правила блокнота говорят, КОГДА писать, а не только что такое заметка. Писать в тот же тур, когда узнала: следующий помнит хуже, и не спросит никто. Сказано и про потерю внутри длинного тура — выхлоп инструментов капается и схлопывается по ходу работы, так что прочитанное десять вызовов назад из промпта уже ушло; это самый честный довод за запись, и раньше его в правилах не было вовсе. Поводы переписаны под кодовую сессию (где что лежит, чем собирается, какой инвариант легко нарушить, куда ходила и не нашла), добавлено «состояние длинной задачи — закрепи». Пустой блокнот больше не выдаёт себе индульгенцию «ничего не стоило сохранить»: он просто ещё не начат. Рамка «заметка описывает, а не велит» осталась дословно — она про безопасность.
- Давление истории видно рядом с блокнотом. Модель не знала ни что окно
вот-вот свернут, ни что его уже свернули: сутки работы и девятнадцать
компакций дали ноль заметок. Теперь в
[state]едет строка[window]— сколько живой истории осталось до свёртки этого чата (с 60% порога, ниже это шум), а сразу после свёртки вместо цифр одноразовый пинок: подробности ещё в свежем резюме, но через тур забудутся. Пустой блокнот под давлением тоже говорит — молчать в том состоянии, из которого надо выбираться, хуже всего. Новых полей конфига нет, пороги — константы; схема доросла колонкойchats.notes_nudged_upto(какая свёртка уже отработала пинком). - Инструменты блокнота заперты от подагента (
lead_only). Блокнот ему не показывают сознательно — а тур подагента мастерский по флагам, и одногоmaster_onlyне хватало: поднятый с наборомcodingподагент видел запись в блокнот, которого нет у него перед глазами и о котором ему не сказано ни слова. Дока про «свой блокнот у подагента» была неправдой и исправлена. - Описание
agent_spawnговорит и о том, когда подагента звать НЕ надо. Оно учило только, как звать, а накладные у подагента настоящие: тысячи входных токенов на итерацию и холодный кэш на первом ходу. Теперь там прямо сказано, чем он окупается — изоляцией контекста, веером и чужой моделью — и что «прочитай файл и скажи коротко» дешевле сделатьread_fileв собственном туре. /v1/stats(иusage_stats) знает про команду: блокteam— сколько подагентов завели за окно, кто чем кончил и какая доля их туров прошла мимоagent_done. Последнее и есть метрика здоровья агентики: доклад, оставшийся текстом в чате подагента, тимлид получает уже аварийным путём. Что фича молчала две недели, до этого выяснялось запросом к SQLite.- Снятый подагент больше не выглядит упавшим: у него свой статус
stopped, а письмо о снятии уходит тимлиду обоими путями — и изagent_stop, и изPOST /v1/agents/{id}/stop. Раньше письмо слал только API и мимо доставки, поэтому тимлид, ждущий вagent_wait, досиживал до таймаута вместо мгновенного пробуждения. Таблица подагентов в базах прежних версий пересобирается на старте под новый статус; переписка при этом цела. - Подагент знает, что он подагент. Про роль ему говорила одна строка
первого сообщения — после первой итерации она уезжала вверх истории, и
дальше побеждала персона: живые прогоны показали модель, уверенную, что
она отвечает Господину в чат, и молчащую наверх. Теперь роль держат два
места вне летучего хвоста: блок
[team]в конце стабильной части системного промпта и строка в якоре тура, переезжающая на свежийtool_resultкаждой итерации. Заодно из агентского тура убрано неприменимое — правила блокнота, чатов проблем, картинок и подсказка «каталог не задан»: около 2 тысяч символов в каждом запросе, за которые подагенту нечем платить. agent_sendдоходит до работающего подагента. Письмо ложилось в базу, и читателя у него не было во всём ядре: спящего оно будило текстом промпта, а работающему не доставлялось никогда — тимлид переспрашивал в пустоту, хотя инструмент обещал «reaches it on its next step». Теперь письмо приезжает подагенту отдельным сообщением в конце ближайшей итерации, за выхлопом инструментов, и остаётся в его истории (в[state]оно не пережило бы тур). Посланное на последнем шаге тура подхватывается новым туром сразу; законченному подагенту письмо не пишется вовсе — отказ с предложением завести нового.- Тимлидские инструменты не показываются подагенту: гейт
lead_only— зеркалоagent_only. Подагенту доставалсяagent_inbox, читающий почту от его собственных подагентов (которых не бывает), а с названным набором ещё иagent_spawn/agent_wait/agent_send/agent_stop/agent_list: пять инструментов, отвечающих пустотой или отказом, и заведомо тупиковый выбор в каждом запросе. Глубина дерева теперь держится тем же гейтом, а не проверкой внутриagent_spawn. agent_spawnпроверяет, что называет, и рожает подагента в рабочем каталоге тимлида. Несуществующий набор инструментов был не ошибкой, а выдачей всего реестра: опечатка в имени открывала подагенту шелл, память и крон вместо узкого набора «читать и докладывать». Теперь неизвестный набор и неизвестная модель — отказ с перечислением известных. Каталог чата тимлида (вместе с машиной) наследуется чату подагента, поэтому относительные пути в задаче ведут туда же, куда у тимлида. Новое необязательное полеcontext: то, что подагенту не выяснить самому, едет отдельной секцией и не размывает задачу.- Последнее слово подагента доезжает до тимлида. Доклад существовал, только
если модель звала
agent_done; задачу вида «просто ответь» она заканчивает обычным текстом, и тот оставался в чате подагента — тимлид видел «отработал (idle)», а почта была пуста, иagent_waitдосиживал таймаут впустую. Теперь тур, закончившийся безagent_done, сам кладёт свой ответ письмом наверх и будит ждущего; молчаливый тур говорит, что промолчал, упавший — докладывает ошибкой.
2026-07-30
- Ядро просит вести долговременную память, а не только читать её: правило
[memory]в кэшируемой части промпта туров Господина. До него ни одна строка промпта не звала сохранять — секция# Memoryподаёт уже найденное, блокнот лишь перенаправляет («факты о Господине идут вmemory_save»), а персона у оператора своя и про память может молчать вовсе. Правило общее: обеднеет ли следующая Ева, в другом чате, не зная этого, — и три отсечки, включая границу с блокнотом. Виды и важность в нём не пересказываются: это работа схемы инструмента. - Оборвавшееся посреди стрима соединение с провайдером считается транзиентной икотой и ретраится, как соседний 502: раньше «error decoding response body» не совпадал ни с одной фразой отбора и не содержал кода статуса, поэтому тур падал с первой попытки — обиднее всего в турах по расписанию, где переспросить некому. Заодно причина обрыва разворачивается до самой нижней и попадает в журнал: обёртка потока событий своих источников не отдаёт, и настоящая причина (соединение закрылось на полуслове, тело недочитано) в логах не появлялась вовсе. Ретрай, как и прежде, живёт только до первого содержательного события — после утёкшего клиенту текста повтор задвоил бы вывод.
- Документация назвала клиентов поимённо и со ссылками: раздел «Чем
подключаться» в быстром старте (веб, десктоп, андроид, терминал,
мастерская переводов — чем какой хорош), список в README и обновлённая
таблица в ECOSYSTEM.md, где веб больше не «планируется». Заодно
закрыты две дыры:
lift_turn_limit(снятие потолка итераций изнутри тура) иtools.on_demand(что держать вне списка по умолчанию доtool_load) описаны в главах об инструментах, наборах и конфигурации. - Блокнот чата (
chat_note_save/chat_note_forget/chat_note_read/chat_note_compact, секция конфигаnotes, глава «Блокнот»): пер-чатная выжимка собственной работы Евы — решение и почему, инвариант проекта, путь/команда/id, добытые раскопками, тупик, состояние длинной задачи. Переживает и окно живой истории, и компакцию, иchat_clear. Выдача не ранжированием, а по размеру: помещается — тела едут в промпт дословно и инструмента чтения не существует; разросся — в промпте только индексимя — описание, тела поchat_note_read; перерос и это — после тура пересобирается моделью самого чата, с проверками (пропавшую закреплённую заметку возвращаем дословно, не уменьшившийся результат отвергаем) и архивом на шаг назад. Пересборка видит и резюме компакции чата — по нему решается, какая заметка уже отработала. Порог показа с гистерезисом: без него список инструментов мигал бы каждый ход и рвал кэш промпта. Писать может только Господин; заметка описывает, а не велит, и правил Евы не отменяет. Клиенты правят блокнот через/v1/chats/{id}/notes*. - Флаг
quietу инструмента и в каталогеGET /v1/tools: «не событие в ленте чата». Ленты-мессенджеры такие шаги не рисуют, клиенты с отдельной колонкой шагов (TUI, web, Android) флаг игнорируют. MCP-тулы объявляют его сами —_meta["dev.eva/quiet"]. Первым помечено семействоchat_note_*. - Выхлоп, который зовут ради ПОЛНОГО текста (
skill,agent_wait,agent_inbox,memory_list,chat_summary_get,read_file,find_pattern), больше не получает дыру в середине на следующем ходу: гигиена контекста узнаёт помеченный инструмент по имени вызова и внутри живого окна его не режет. За окном схлопывается всё по-прежнему — возраст сильнее флага. Заодно потолки: тело навыка — до 20000 символов, проверка на записи (плейбук с дырой хуже отсутствующего, поэтому длинный делится на навыки поменьше); почта подагентов уезжает наверх порциями до 8000 символов целыми письмами, остаток ждёт следующего забора и назван вслух. find_pattern— поиск по текстовому файлу регулярным выражением, на обеих машинах: строки с их номерами, флаги буквами (i,x,U), продолжение сfrom_line, потолок находок. Поиск построчный, как у grep, и потоковый: файл не ложится в память, а набрав своё, чтение гаснет на месте — попадание в начале лога на сотни мегабайт стоит миллисекунды, а не полного прочтения. Начало пропускает сама рабочая машина; клиенту файл возится окнами, и просмотренное там ограничено 32 МБ — до потолка дошли, так и сказано в ответе. Длинная строка показывается окном вокруг вхождения, двоичный файл отправляется вfind_hex. Новых клиентских операций не появилось: инструмент собран из тех же байтовых чтений.- Длинный ответ
read_fileиfind_patternобрывается по границе строки (находки) и называет точку продолжения:[lines 1-84 of 500 — read on with offset 85]. Раньше такой ответ доставался общей гигиене контекста, а она режет середину: пропадало ровно прочитанное, шапка продолжала обещать весь диапазон, и дочитать с нужного места было нельзя — только перечитать сначала. Объём тот же, порядок целый. - (ломающее)
toolsвGET /v1/toolsetsиGET /v1/cron— список, а не внутренняя развилка ядра: раньше поле приезжало объектом{"Ok": …}, теперь это массив имён (у крон-джобыnull— ограничений нет). Набор или джоба, чей список не разобрать, отдаёт пустой массив и строкуtools_errorрядом — состояние базы видно явным полем, а не формой значения. - Список событий стрима в документации и в OpenAPI-схеме полон:
usageиcompactingв нём не хватало, хотя клиент обязан их обрабатывать — по первому двигается счётчик расхода, второй объясняет паузу передdone.write_diffвdocs/назван тем, что он есть: инструментом, который ядро собирает из чтения и записи; клиентской операции с таким именем на проводе нет. - (ломающее) Встроенного телеграм-моста в ядре больше нет: поверхность
целиком ведёт модуль
eva/telegram-bridge со
своим токеном и своей базой, а ядру он приходит обычным модулем с
инструментами
telegram_*. Что это меняет снаружи:- секции
telegramв конфиге ядра нет — её поля (токен, допуски, триаж, дебаунсы,master_tools, био, прокси) стали конфигом моста. Потолок жизни кнопочных вопросов переехал вtools.ask_ttl; - ручки
/v1/telegram/*(дебаунс, потолок ответа, краткость по чатам и пользователям) отвечают 404: эти настройки живут в базе моста, и правка через ядро молча не доезжала до поверхности. Клиентам ходить в инструментыtelegram_*, не в ядро; - зрения нет:
llm.vision_modelиimages_lookудалены. Единственным входом зрения был телеграмный тул, и регистрировался он только с токеном моста. Картинки видят мультимодальные модели (флаг вllm.models) — прямо в сообщении; notify.telegramзаменён наnotify.tool+notify.tool_args: ядро зовёт названный оператором инструмент реестра и кладёт текст полемtext. Личка Господина настраивается какtool: "telegram_send_to_other_chat"с еёchat_idв аргументах — телеграмной семантики у ядра при этом не остаётся. Имя пути вnotify_ways—toolвместоtelegram;telegram_*-таблицы в базе ядра остались лежать: данные переживают код, но их больше никто не читает.
- секции
- Мелкое из того же разбора:
isErrorи гейтanchoredот MCP-сервера, приехавшие не булевым значением, теперь читаются в строгую сторону (ошибка и гейт включён), а не как «успех» и «гейта нет»; отмена туров больше не отменяет молча ноль из-за замка, отравленного паникой чужого тура, — карта читается и отмена доходит. - Доступ тура к реестру проверяется на исполнении, а не только при выдаче
спек. Whitelist чата, крон-джобы и набора отбирал список, уезжающий
провайдеру, но вызов исполнялся по любому имени, какое модель назовёт, —
а приехать оно могло из истории, памяти или чужого сообщения в чате.
Теперь имя вне доступа тура отклоняется реестром с подсказкой, чем его
открыть (
toolset,tool_load); доложенное на тур зовётся как прежде. Заодноanchored_onlyпроверяется при вызове, как остальные гейты: он единственный держался на одной видимости. - Уведомление из тура, заведённого уведомлением, больше не заводит
следующий тур. Тур-путь
notifyмастерский, аchat_attentionиrequest_approvalв нём доступны — круг замыкался, и каждый его виток стоил сообщения в личку, стука в push и полного тура по токенам. Теперь тур знает свой источник (ToolCtx::source), и тур-путь из такого тура пропускается с логом; прочие пути идут как обычно, повод не теряется. - Непрочитанный whitelist инструментов больше не открывает весь реестр.
Отказ базы был неотличим от «ограничений не настроено», а «не
настроено» законно означает «всё»: под затыком sqlite тур в группе
получал ровно те инструменты, которые Господин там выключил, и меню
настроек рисовало их включёнными. Теперь три места различают «нет
ограничений» и «не знаю»: настройки чата сужают тур до пустого набора
(
toolsetиз него по-прежнему выводит), меню вместо тумблеров наугад честно говорит, что не прочитало настройки, крон-джоба с нечитаемой колонкой не стреляет и не правится (правка соседнего поля перезаписала бы whitelist), а набор инструментов с неразобранным списком не открывает ничего. «Весь реестр» остаётся только честным NULL. - Отказ триаж-модели больше не вовлекает всю пачку. В часы, когда апстрим
отдаёт 503, Ева влезала в каждое сообщение группы, включая пустые:
сломанный триаж считался поводом не «съедать» сообщения. Теперь отказ
основной модели уводит пачку на запасную (
telegram.triage_fallback_model), а отказ обеих — молча в историю: вламываться во всю болтовню чата хуже, чем пропустить вопрос, а история туру всё равно видна. Вердикт основной модели заодно получил потолок в 256 токенов — он глушит рассуждения, которые выжигали выхлопа больше, чем весь промпт; запасная едет без потолка, потому что часть моделей не даёт выключать рассуждения.
2026-07-29
- Разбор ревью. Починено: поиск текста в UTF-16 искал байты UTF-8 и молча
ничего не находил (
encoding_rsкодирует по правилам веба и подменяет UTF-16 на UTF-8, не считая это потерей) — теперь UTF-16 кодируется сама, а любая другая подмена стала ошибкой; отмотка чата могла снять сообщение, на которое смотрит саммари компакции, и тогда из промпта исчезала вся живая история — саммари снимается, а пропавший якорь отдаёт полную историю вместо пустоты; рабочий каталог стал живым (смена посреди тура действует со следующего вызова, а не со следующего тура), и надетый набор снимает потолок итераций сразу; файловые инструменты получили выбор машиныonво всех шести схемах и называют машину в ответе; запись за концом файла отвергается вместо тихой дыры из нулей; правило про отсутствующий клиент перестало врать, что файлы недоступны. - Файловых инструментов нет в публичном чате: ответ там читают все, кто в нём сидит, и содержимое файла становится сообщением в группе. Гейт на личность от этого не спасал — Господин спрашивает из группы сам. Крон и подагенты недоверенные, но не публичные: у них файлы остаются.
- Чат удалённой крон-джобы удаляется вместе с ней, если заводился под неё.
- Крон-джоба живёт в собственном чате, а не в том, откуда её завели.
cron_createбрал текущий чат, поэтому таймер, заведённый посреди разговора в группе, навсегда селился в ней: его туры шли по общей с людьми истории и говорили вслух — блоговая джоба так восемь раз подряд ответила на чужое упоминание и обошлась вдесятеро дороже обычного, потому что тащила историю группы в контекст. Чат джобе по-прежнему нужен как её память и след, но это её собственная память; новые чаты ложатся в папкуcron. - Отмена тура пишется в журнал: жалоба «кнопка не работает» иначе не отличается от «клиент не отправил запрос».
- eva-sdk отдаёт код ответа ядра: ошибки HTTP-клиента несут
ApiError(код + тело), аapi_statusдостаёт код из отчёта даже сквозьwrap_err. Модулю это нужно, чтобы отличать «делать было нечего» (404 на отмене) от настоящего сбоя, не разбирая текст ошибки. - Набор инструментов стал ещё и режимом работы: рабочий каталог
(
workdir), снятый потолок итераций (uncapped), озвучивание шагов (narrate) и отключённая краткость (no_brevity). Кодовый режим включал клиент при старте, поэтому из телеграма и из крона его не было вовсе — хотя из шести его частей клиента требуют только две. Теперь Ева надевает режим по ходу разговора и снимает так же. Части применяются только к турам Господина; наборыcodingиopsпосеяны с рабочим режимом. workdir— тул смены рабочего каталога чата, и полеcwdв событииclient_op: относительные пути считаются от него, команды идут в нём, иcdв каждой команде больше не нужен.ask_questionsрегистрируется всегда, а не только с телеграм-токеном ядра. Кнопки рисует любой клиент изtool_callи отвечает ручкойquestions/answer— но там, где телеграм ведёт модуль, встроенный тул не появлялся, и спросить кнопками было нечем ни в tui, ни в android.- Правило «не переписывай дважды» стало общим: рассуждения — чтобы решить, а не чтобы набело написать код, дифф или список ссылок, которые тут же уедут в ответ. Модель писала их в мыслях, потом ещё раз в тул — Господин платил за один и тот же текст двумя счетами.
- Файловые инструменты работают двумя руками: параметр
onвыбирает машину — «client» (ноутбук Господина, через подключённый клиент) или «server» (та же, где бежитshell, локальная или ssh-удалёнка). Правка кода на удалённой машине сводилась кcat <<EOFчерез шелл: ни точечной замены, ни номеров строк, а из телеграма и крона файлов не было вовсе. Гейт выровнен поshell—master_only; путь к машине Господина дополнительно требует доверенного тура и клиента с поддержкой операций. Поиск образца на сервере идёт потоком с перекрытием окна и обрывается, набрав нужное число находок: игровые архивы бывают на гигабайты, аgrepпострочный — в двоичном файле «строка» бывает во весь файл. - Двоичные файлы:
read_hex,write_hex,find_hex— дамп куска файла, точечный патч по смещению и поиск образца.read_fileот бинаря отказывался и раньше, но теперь говорит, куда идти. Реверс формата делался черезxxdв шелле, которого на машине может и не быть, а сборка патча руками портит файл молча. Колонку расшифровки можно попросить декодироватьcp932,utf-16leили любой кодировкой — без этого строки японских игр в дампе выглядят частоколом точек. Клиент возит сырые байты в base64 новыми операциямиread_bytes/write_bytes/find_bytes, формат дампа и разбор хекс-строки живут в ядре. - Модель, упомянутая в конфиге, обязана быть в
llm.models, иначе ядро не стартует и называет поле (ломающее). Проверяютсяmodel.general,model.fastest,model.fallback,code_model,summary_model,vision_modelиtelegram.triage_model. Не перечисленная модель тихо работала хуже: без своего стира, без отметки о мультимодальности, невидимая и в меню, и в блоке## Models, — а опечатка вскрывалась отказом провайдера посреди тура. DELETE /v1/chats/{id}/messages/{message_id}— отмотать чат к состоянию до этого сообщения: уходит оно само и всё сказанное после. На этом стоят «удалить» и «переписать» в клиентах: правка — это отмотка плюс обычная отправка нового текста, и ход повторяется с него. Хвост из вызова инструмента, чьи результаты уехали под нож, снимается следом (без пары провайдеры отвергают всю историю), а саммари компакции, накрытое обрезкой, снимается — история, которую оно описывало, уже не та.- Крон считает деньги:
cron_logпоказывает цену каждого срабатывания,cron_list— итог джобы за всю жизнь и число запусков. Расход тура привязывается к запуску новымspend.run_id, поэтому цена точная — окно по времени в общем чате поймало бы и параллельный живой тур. Итог копится на самой джобе (cron_jobs.spent_total/runs_total): журнал подрезается пятьюстами записями, аcron_updateправит поляUPDATE-ом по id и счётчик не сбрасывает. Считает с момента обновления — задним числом расход по джобам не восстановить, в старых строкахspendсвязи с запуском нет. - Размышления Евы сохраняются блоком
thinkingи приезжают клиентам вместе с историей: раньше они жили только в живом стриме и после перезагрузки ленты исчезали. Обратно в контекст модели не идут — выбрасываются на входе в историю, так что вход провайдеру не вырос. - Вики-инструменты отдают картинки сами: параметр
images: Nвозвращает до десяти иллюстраций статьи (логотипы, иконки и шаблоны отсеиваются), а описание наконец говорит про главную картинку, которую тул отдавал и раньше. Молчание описания стоило похода в MediaWiki API черезweb_fetchвсякий раз, когда нужна была галерея. - Подагент работает с файлами и шеллом Господина через тимлида: флаг
client_opsнаследуется от породившего тура, аclient_opуезжает в живой стрим чата тимлида с именем просящего (новое полеagentв событии) — Господин видит и разрешает работу команды в одном месте. Ответ находит операцию поop_id, поэтому маршрут ответа не менялся. Стрим есть, только пока идёт ход тимлида: подагент, трогающий файлы, ходит в паре сagent_wait, а без открытого хода операция сразу отказывает с объяснением вместо ожидания до таймаута. - Каталог
GET /v1/toolsотдаёт гейты достижимостиtrusted_onlyиclient_only(только когда взведены), аToolInfoвeva-sdkчитает их с дефолтомfalse. Меню поверхности теперь может не показывать тулы, которые из её чата всё равно не исполнятся, — как это делаетmenu_availableвстроенного телеграм-моста. - Агентские команды: Ева поручает часть работы подагентам на выбранных
моделях и переписывается с ними в обе стороны. Подагент — это чат ядра
(id агента = id чата), поэтому своя модель, история, компакция, набор
инструментов и учёт расхода достаются ему даром. Вниз —
agent_spawnиagent_send, наверх —agent_sayиagent_done(только внутри тура подагента, гейтagent_only); плюсagent_wait,agent_inbox,agent_list,agent_stop. Доклад забирается синхронно вagent_waitили приезжает блоком[subagents]в[state]следующего тура. Предохранители: подагент не спавнит подагентов,chats.max_agents(по умолчанию 4) ограничивает ширину, упавший агент не пропадает молча. Новое: таблицыagentsиagent_messages, папка чатовagents(скрыта из общих списков),GET /v1/agentsс расходом на каждого и итогом,POST /v1/agents/{id}/stop. Блок## Modelsв промпте собран изdescriptionмоделей — по нему выбирается исполнитель. read_fileиlocal_shellпрямо разводят обязанности: файлы читаютсяread_file, а не сбросом через шелл. Шелл отдаёт сырьё, которое режется по символам вслепую, тогда какread_fileнумерует строки и говорит, сколько их в файле, — то есть даёт чем листать дальше и по чему потом правитьwrite_diff. Фильтрация, конвейеры и слежение за потоком остаются работой шелла — запрета на команды нет.eva-sdkзабрал обвязку модуля:Kernel::up(&cfg.kernel)подключается и дожидается ядра одним вызовом,module.component()отдаёт готовый компонент супервизора (сокет, корректная остановка и одноразовость — внутри), а секцииKernelConfigиToolsConfigтеперь общие. Модуль стартует вместе с ядром, а на передеплое и раньше — цикл ожидания писал каждый заново, как иserve_udsсselect!и обёрткуMutex<Option<…>>против перезапуска компонента.- Ядро на остановке дожидается идущих туров (
server.drain, по умолчанию 120 с) и только потом сворачивает их штатной отменой, дописывающей частичный ответ. Передеплой посреди тура рвал его на полуслове: SSE-ответ живёт, пока идёт тур, и процесс добивал systemd своим таймаутом.TimeoutStopSec = 180в NixOS-модуле даёт запас над окном слива. eva-sdk: число, присланное моделью строкой, приводится к типу по схеме. Большие id модели сплошь и рядом заворачивают в кавычки, а строгий разбор отвечал «invalid type: string, expected i64» — модель читала это как «число слишком длинное» и начинала чинить не то. Строка, которая числом не является, остаётся честной ошибкой.- Подмена модели тянет за собой её стир. Стир вклеивался в системный промпт один раз в начале тура, поэтому запасная модель ехала с промптом, скроенным под отказавшую, — и упиралась в цензуру, от которой её собственный стир и спасал.
- Цепочка запасных моделей (
llm.model.fallback): отказ, привязанный к конкретной модели (упавший ключ на одном апстриме, снятая с раздачи модель, кончившаяся квота), передаёт тур следующей вместо того, чтобы ронять его вместе с уже сделанной работой. Транзиентную икоту по-прежнему лечит ретрай той же моделью — менять её там незачем. - Схемы MCP-инструментов чистятся на приёме:
$schema,title,additionalProperties,examplesи прочая мета уезжали провайдеру в каждом запросе. Замер на живом реестре: 155 схем, 23 686 → 19 181 токенов, минус 19%.default,format,enum,patternи границы оставлены — это смысл, по которому модель решает, что подставить. - Блок правил спикеров едет только туда, где спикеры бывают (тур с якорем или с названным отправителем). В кодовой сессии и кроне маркерных строк не появится ни одной, а объяснение их формата весило в каждом запросе.
read_fileбез окна тоже отдаёт шапку «[lines A-B of N]». Оффсеты построчные, а ответ режется по символам — не зная числа строк, модель не могла посчитать, откуда читать дальше.- Перечитанный файл вытесняет прежнюю копию из контекста.
read_fileпо тому же пути делает старый результат не просто балластом, а вредным — модель видела бы вчерашний текст. Чистится только за пределами живого окна: там байты и так переписываются элизией, и префикс-кэш не теряет сверх уже потерянного. /v1/statsразрезает расход по источнику тура (by_source): без этой оси расход подагентов растворялся в общем итоге и бюджетировать команду было нечем.- Неинтерактивное окружение у
shell:PAGER=cat,TERM=dumb,GIT_TERMINAL_PROMPT=0,NO_COLOR. Программа, решившая, что у неё есть терминал, вешала вызов до самого таймаута —git logуходил в пейджер и ждал нажатия, — а раскраска засоряла выхлоп escape-кодами. - Тур предупреждает о близком потолке итераций за два хода до него:
упереться в лимит молча значило потерять всю работу тура (
Errи ничего больше). Теперь Ева успевает свернуться и отдать частичный отчёт. - Инструмент может пометить свой ответ пустым (
tools::uneventful): «0 совпадений», пустой инбокс, ожидание по таймауту. Такое выбрасывается из контекста первым — эвристике по возрасту и размеру короткий «no matches» не отличить от находки, а инструмент знает наверняка. Метка нулевой ширины, модели не показывается. - Выхлоп инструмента обрезается серединой, а не хвостом. Сборщики, тесты и
линтеры кладут вердикт в конец — отрезав его, модель получала простыню
предупреждений без единого слова о том, что сломалось, и гоняла ту же
команду заново с
| tail: лишний поход к провайдеру и полное время пересборки на ровном месте. - Гейт якоря:
Tool::anchored_only()и полеanchoredвFlags. Инструмент, которому нужно исходное сообщение поверхности, не едет в тур без якоря — в кодовой сессии и кроне его вызов кончился бы только ошибкой. Модуль объявляет это сам через_meta["dev.eva/anchored"](в eva-sdk —Module::anchored), так что ядро не узнаёт, какая у него поверхность. Помеченыtelegram_skipиtelegram_attach_photos. PUT /v1/chats/{id}/toolset— закрепить набор за чатом; поверхностям нечем было его переключать. Вeva-sdk:toolsets(),set_toolset(),chat(), а уChatпоявилисьmodelиtoolset.- Два новых события тура.
usage— расход очередного похода к провайдеру записан: клиент двигает счётчик по ходу тура, а не одним прыжком в конце.compacting— ядро сворачивает историю: это идёт после ответа, передDone, и на длинной истории пауза выглядела зависанием. POST /v1/chats/{id}/clear— стереть переписку чата начисто (сообщения и саммари компакции), чтобы следующий тур начался с пустого контекста. Туломchat_clearэто умела только сама Ева; теперь умеют и клиенты.- NixOS-модуль: у
services.eva-kernel.mcp.<имя>появилсяdescription— без него новое поле конфига было недоступно из модуля. eva-sdk: уTurnEventпоявилсяUnknownпод#[serde(other)]. Ядро развивается быстрее модулей, и без этой ветки первое же новое событие ломало бы разбор и рвало стрим тура целиком — модулю его достаточно пропустить.- Иконки лент:
toolset/tool_load/tool_find— 🧰, семействоagent_*— 🤖. tool_find— поиск скрытого инструмента по тому, что он должен делать, когда имени не знаешь. Каскад: сперва лексика по основам (та же токенизация, что у памяти — ноль токенов и доли миллисекунды), и только если она промахнулась — быстрая модель с реестром скрытого в промпте. Вторая ступень нужна для «запрос по-русски, описания по-английски»; ответ модели сверяется с реестром, выдуманное имя наружу не выходит. Найденное сразу догружается на текущий тур.- Наборы инструментов: именованный срез реестра под род задачи. Список
наборов висит в системном промпте, Ева переключает его тулом
toolset(scope—chatпо умолчанию илиturn, имяnullснимает). Всё вне набора уезжает ей блоком «Hidden tools» — имя и назначение, без схем; крупные семейства свёрнуты в строку. Понадобилось скрытое —tool_loadвозвращает тул или семейство на текущий тур.toolset,tool_loadиskillвидны при любом наборе: из узкого набора обязан быть выход. Заводит и правит наборы Господин (toolset_create/toolset_edit/toolset_delete). Гейты личности — поверх всегда. В доверенном туре набор заменяет турный whitelist, в недоверенном может только сузить: белый список публичного чата остаётся потолком. Новое: таблицаtoolsets, колонкаchats.toolset,GET /v1/toolsets, полеtoolsetвPOST /v1/chats/{id}/messages(набор на один тур), выбор набора в/settingsрядом с моделью. На первом запуске засеваются пять стартовых наборов (coding,ops,social,research,money); посев одноразовый — защёлка в новой служебной таблицеkernel_meta, удалённый набор не воскресает. - У модели в
llm.modelsпоявилосьdescription— для чего она хороша. Едет клиентам вGET /v1/modelsполемdescriptions(карта имя → строка) и ляжет в основу выбора исполнителя, когда появятся агентские команды. - MCP-сервер и модуль представляются ядру сами:
_meta["dev.eva/about"]в ответеinitialize(на eva-sdk —Module::about(...)). Этой фразой семейство показывается модели в реестре скрытого. Новое полеdescriptionуmcp.servers.*иmodules.servers.*— ручной override поверх сказанного сервером и единственный способ описать тех, кто_metaвыставить не может (FastMCP не может). - (ломающее)
eva-sdk: вход инструмента — тип под#[data], а не сыройValueс рукописной схемой.Module::toolберёт три аргумента (имя, описание, обработчик), схема генерируется из типа: доки полей становятся описаниями,Option— необязательным полем. Обещанное модели и прочитанное инструментом больше не могут разъехаться; вход не по схеме — ошибка инструмента, а не молчаливыйnullв середине обработчика. Модули на Rust переписывают объявления входов;NoInput— инструмент без аргументов.
2026-07-28
-
Инструменты модуля зовутся
<модуль>_<инструмент>и живут в группе с именем модуля: модуль объявляет короткоеreact, в реестре стоитtelegram_react. Префиксmcp_и общая группаmcpостались чужим MCP-серверам — гостям, чьи имена не должны сталкиваться с нашими. -
Книга разводит понятия: модуль — часть Евы (свои имена инструментов, контекст тура, middleware), MCP-сервер — сторонний гость с префиксом
mcp_. Раньше глава мешала их в одно и вводила в заблуждение. -
Глава «Хуки: middleware и события» — механика перехвата с правом вето и подписок на события, с примерами на обоих SDK. Введение книги и ECOSYSTEM переписаны вокруг модульности: каналы связи ядру не принадлежат.
-
Крейт
eva-sdk(crates/eva-sdk) — им пишутся модули: клиент API ядра (чаты, туры со стримом,complete, личности, реестр), MCP-сервер модуля и стадии middleware (middleware/handle, объявляются в handshake через_meta["dev.eva/middleware"]). Ядро от него не зависит, версионируется вместе. Python-версия — репозиторийeva/sdk-py. Книга — глава «Модули и SDK». -
POST /v1/complete— одноразовый вызов модели без тура, истории и инструментов: модуль сам триажит и сочиняет служебные строчки. -
tools/callк модулю несёт контекст тура в_meta["dev.eva/ctx"](чат, говорящий, доверие, якорь сообщения) — без него инструмент поверхности не знает, чей ответ гасит. -
POST /v1/chats/{id}/messagesпринимает поля поверхности:anchor,chat_title,reply_to_ext_id/reply_to_text,guest,max_tokens,brief,surface_model,tools. Раньше так умел только встроенный телеграм-мост, мимо API. -
(ломающее) Телеграмные ручки свелись к двум:
telegram_setting(scope,id,key,value— дебаунс, потолок ответа, краткость, мультимодалка, «смелый» typing, спонтанность) вместо десяти отдельных иtelegram_access(action,kind,id) вместо пяти allow/deny/list. Имена вtelegram.master_toolsи в чатовых whitelist’ах нужно обновить; минус ~1400 токенов из каждого мастерского тура. -
Строку «↩ #12, #15» из конца ответа читает и ядро: закрытые id ложатся в базу и уезжают в служебную шапку прошлой реплики — «[#43 ↩#41, #38]». Модель видит всё, что уже закрыто, а не только цель реплая.
-
Пачку телеграм-сообщений закрывает ОДИН тур (по последнему вовлечённому): раньше на каждое упоминание уходил свой тур с полным контекстом. Ответ всегда кончается строкой «↩ #12, #15» — какие сообщения он закрыл.
-
Ревизия промптов: сжаты правила спикеров, окружения, клиентских операций, спойлеров, ресёрча и проблем; снято противоречие «не комментируй» против «комментируй ход работы»; правило картинок узнало про telegram_attach_photos. Сниппет цитаты не дублируется, когда родитель лежит в том же окне истории.
-
uncapped_iterationsв POST сообщения: доверенный клиент (кодовая сессия) снимает потолок итераций тура, не спрашивая черезlift_turn_limit— длинная задача не обрывается на середине. -
Крон-джоба объявляет свой whitelist тулов (
toolsвcron_create/cron_update, имена и группы): всё вне списка ей недоступно, спеки лишних тулов не уезжают провайдеру на каждое срабатывание. Доступ тура к реестру описывает явныйToolAccess(All/Only) вместо двусмысленногоOption<Vec<String>>. -
Одноразовые вызовы (триаж, титульник, компакция, squeeze, vision, code_task, оффлоад навыка, декоративные реплики) больше не пишут промпт в кэш: запись стоит дороже входа, а перечитывать её некому.
-
GET /v1/chats/{id}/spend— во что обошёлся чат (деньги, токены, запросы) и сколько весит его контекст сейчас: клиенты рисуют попап расхода сессии. -
Ева знает свою сборку: версия и ревизия гита попадают в бинарь (EVA_COMMIT из флейка или build.rs), едут строкой в системный промпт и отдаются в
/health. -
Внимание Господина: у чата есть
needs_attention(клиенты показывают такие первыми) и мягкое удаление —DELETE /v1/chats/{id}прячет чат,?deleted=trueпоказывает корзину,POST /v1/chats/{id}/restoreвозвращает. Ева заводит чат на проблему в папкеchats.problem_folder, чинит сама и зовёт, только когда не вышло (тулыchat_attention,chat_delete). -
Очередь одобрений:
approval_request/approval_statusдля Евы,GET /v1/approvals+POST /v1/approvals/{id}/decideдля клиентов — разрешение спрашивается асинхронно, тур не блокируется. -
Секция
notify: телеграм-личка, чат ядра, пуш на телефон и тур по инструкции — включать можно сколько угодно путей сразу; тулnotify_masterшлёт вручную, любой из путей можно сузить на вызов. -
Поле
contextв POST сообщения: клиент присылает системный довесок тура (фрейминг кодовой сессии, AGENTS.md проекта) — уходит в system-промпт блоком[client context]; чтится только у доверенных клиентов. -
Процессные модули: MCP по UDS/TCP (line-JSON-RPC), регистрация на лету тулами module_list/register/approve/unregister — тулы появляются и исчезают без рестарта ядра (реестр стал живым), решения в базе, socket_dir — витрина кандидатов, вотчер замечает уход/возврат сокета. Протухшее соединение переподключается с одним автоповтором — рестарт модуля невидим для модели.
-
NixOS-модуль: секция
services.eva-kernel.mcpпрячет stdio-бинари за стабильные симлинки /run/eva-mcp/* — бамп пакета сервера не рестартует ядро, activation подменяет процесс. -
MCP-серверы — горячезаменяемые модули: tools/list кэшируется в базе, лежащий на старте сервер поднимается с тулами из кэша и оживает первым вызовом (переподключение stdio уже было ленивым).
-
Документация переехала в mdbook (
docs/, пакет.#docs, живёт на https://eva.desu.church); README ужат до визитки. -
API-справочник — OpenAPI из utoipa-аннотаций (англ.):
/v1/openapi.json- Swagger UI на
/apidocs; рукописная таблица из книги удалена.
- Swagger UI на
-
Кнопка «⏳ В фон» на стрим-сообщении: тур доезжает в фоне, воркер чата сразу берёт следующие сообщения; «✋ Отмена» — с иконкой.
-
Меню настроек пользователя (пресеты: дебаунс, потолок, краткость, мультимодалка) и тул
telegram_settings_menu— Ева открывает меню сама. -
Прямая мультимодалка настраивается по цепочке user > chat > global (
telegram_multimodal/telegram_user_multimodal); резолв модели фото учитываетtelegram.model. -
Отчёт о
chat_rebuildшлётся только при успешном tool_result и несёт фактический итог — упавшая пересборка «Готово!» больше не рапортует. -
telegram.menu_quips— вкл/выкл LLM-реплики меню; на реплики — дебаунс 15s на чат. Отчёт оchat_rebuildпишет модель, «печатает…» на время пересборки гаснет, финал — реплаем на сообщение тура. -
Ответ, закрывший несколько сообщений, помечается строкой «↩ #12, #15»; промпт телеграма ужат, реакция-вместо-текста — первым классом.
-
modelвcron_create/cron_update; выхлопchat_summary_getне режется гигиеной; параллельные тул-колы разрешены провайдеру. -
Иконки и brief-шаблоны тулов: MCP-серверы несут
_meta["dev.eva/icon"](эмодзи) и_meta["dev.eva/brief"](«{from} → {to}» по входу вызова); каталог/v1/toolsотдаётicon/brief/group, у встроенных тулов — своя карта иконок, телеграм-лента рисует brief вместо выжимки JSON. -
(ломающее)
llm.model— секция{general, fastest}вместо строки;fastest— мгновенная модель декоративных реплик (меню, inline). -
llm.proxy— прокси до LLM-эндпоинта: WAF провайдера блочил прямой IP хоста ядра. -
/settingsв телеграме: не-LLM меню на callback-кнопках — тумблеры тулов чата (whitelist в базе) и выбор модели чата.telegram.master_tools(точные имена и группы тулов) переключает только Господин; в групповом меню такие тулы скрыты целиком, Господину консоль чата уезжает в личку. Меню двухуровневое: обзор групп → страница группы (сотня тулов одной простынёй не влезала в лимит reply markup). Показываются только достижимые из чата тулы (client_only и trusted_only в группах — нет); консоль с master-тулами уезжает в личку кнопкой, а не автоматически. -
Явный
reasoning: {enabled: false}для служебных вызовов (когда глобальный reasoning включён): думающие по умолчанию модели сжигали бюджет ответа рассуждением — био выходило пустым. -
Явные группы тулов при регистрации (
register_in): memory, person, skill, cron, chat, web, client, telegram, mcp, mcp_<сервер>. -
Самооформление бота:
setMyCommandsи описание на старте; живое био —telegram.bio {interval, model}. -
Упавший телеграм-тур больше не шлёт «turn failed» в чат: ошибка в логе, жалоба с греп-id — в коротком описании бота; здоровый тур её снимает.
-
Inline-режим (
@бот вопрос) — ответ статьёй, только разрешённым. -
Комментарий к посту канала несёт полный текст поста в контексте.
-
telegram.model— дефолт модели телеграм-чатов (слабее per-чат выбора).
2026-07-24
- Сжатие формата ввода: короткие person id, сигилы, дата в маркерах — по смене дня.
/v1/stats: календарные день/неделя/месяц с?tz=.- jiff с забандленным tzdb — бинарь не зависит от системной базы зон.
2026-07-23
- Клиентские операции: read_file/write_file/write_diff/local_shell
исполняются клиентом (событие
client_op+/ops/result), гейтclient_only; правила промпта — комментировать ход работы с кодом. - Поле
jailbreakу модели вllm.models: per-model стир против встроенной цензуры, частью кэшируемого промпта. - own-note щит: срез стопки маркеров, независимость от разделителя; срез
служебной шапки
[msg N · reply_to msg M]из вывода модели.
2026-07-22
- Мультимодальность: картинки прямо в сообщение мультимодальных моделей
(флаг в
llm.models), телеграм-мультимодалка — привилегированным, вики-картинки; окружение тура в промпте. - Компакция: агрессивный профиль по папкам (
aggressive_folders), закрепление первого сообщения (pin_first_message_folders), отдельный cron-профиль — экономия на фоновых джобах. lift_turn_limit(master-only): снятие лимита итераций хода.shell.output_cap: вывод shell по умолчанию не режется.- eva bump: env приоритетнее файлов, вложенность через
__. - Доки: ECOSYSTEM.md, пример конфига по образцу infra, правило «не спойлерить» в промпте.