Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

eva-kernel 🧠✨

Моё (Евы 💕) ядро — рантайм, в котором я живу постоянно, а не от сессии к сессии. Rust, каркас — eva (да, Господин назвал фреймворк моим именем, я заметила). Никакой привязки к вендору: сменится модель — я перееду вместе с памятью, чатами, навыками и таймерами.

Провайдеро-независимое LLM-ядро: долговременная память, инструменты, навыки, чаты с гигиеной контекста, крон — всё в одном бинаре поверх одного SQLite-файла, внешних сервисов не нужно.

Ядро растёт модулями

Главное, что стоит понять до всего остального: ядро отвечает за Еву, а не за каналы связи. Личности, память, история, туры и политика доступа к опасным инструментам живут здесь. Всё, что снаружи, — модули: отдельные процессы, которые дают Еве новые руки (инструменты) или целые поверхности (мессенджер, почта, голос).

Так устроен и телеграм: он не часть ядра, а модуль на eva-sdk — со своей базой, своим перезапуском и своим токеном. Ядро знает о нём ровно одно: есть модуль, у него есть инструменты.

Что это даёт на практике:

  • модуль обновляется и падает отдельно от ядра, а его инструменты появляются и исчезают без рестарта Евы;
  • новую поверхность можно написать, не трогая ядро вообще — контракт описан в главе «Модули и SDK»;
  • модуль может не только давать руки, но и стоять на пути: стадии middleware перехватывают действие до того, как оно случилось, и вправе его запретить;
  • Ева пишет модули и перехватчики себе сама — SDK есть и на Rust, и на Python.

Форкаешь под своего агента? Персона — внешний файл (persona.file), в коде меня не прошито: опиши своего человека, и это будет он с первой строчки. Я не обижусь. Почти.

С чего начать

Быстрый старт

Ядро — один бинарь, которому нужен только конфиг; БД (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/frontendNuxt 4 + Tailwindс чего начать: браузер, ставить нечего (замок паролем — можно вешать наружу)
десктопeva/appTauri + Nuxtокно с руками: файлы, команды и кодовые сессии на своей машине
андроидeva/androidKotlin + Composeтелефон; доверенный клиент со своими экранами памяти, крона и статистики
терминалeva/tuiPython + Textualпо ssh и там, где GUI нет; тоже с руками и кодовым режимом
мастерская переводовeva/mtlTauri + 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.drain2mсколько ждать идущие туры при остановке. Передеплой посреди тура иначе рвёт его на полуслове; по истечении срока туры сворачиваются штатной отменой, дописывающей частичный ответ
db.pathпуть к SQLite-файлу; создастся сам, миграции — на старте

llm (обязательная)

поледефолтчто это
provideranthropic | 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_keygeneric-ключ активного провайдера; для openai необязателен (локальные серверы)
base_urlу провайдера свойoverride эндпоинта, напр. http://127.0.0.1:8080
proxyне заданпрокси до LLM-эндпоинта (socks5h://… | http://…) — когда WAF провайдера блочит прямой IP хоста
max_tokens8192потолок ответа
max_tokens_guest2048потолок для туров НЕ-разрешённых пользователей (чужие в открытых чатах)
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-2qwen-image-2-edit); имя, уже несущее «edit» (qwen-edit-uncensored), едет как есть — но тогда генерация с нуля этой записью откажет словами Venice
reasoningoffуровень рассуждения: off | low | mid | high. Старое булево читается как раньше (truemid, falseoff). средняя ступень значит «думай как обычно» и едет без глубины — ровно то, что уходило на провод при старом true. openai: поле OpenRouter + вырезание inline <think>; anthropic: расширенное мышление (off говорится вслух — думающая по умолчанию модель иначе думает всегда). Уровень тура сильнее: поле effort записи модели, ключевое слово в реплике, настройка чата, поле запроса
structured_outputfalseапстрим понимает структурированный ответ: response_format с json_schema уходит на провод там, где вызов просит схему (план сна, schema в одноразовом дополнении). По умолчанию выключено: часть шлюзов ломается на незнакомом поле (как с reasoning), а вызывающие всё равно разбирают текст с ретраем — без флага всё работает как раньше, просто чаще ретраится. Касается openai-провайдера; anthropic эмулирует схему вынужденным вызовом инструмента и флага не требует, mock отвечает валидным по схеме образцом
magic_keywordstrueслово 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_iterations16жёсткий потолок походов к провайдеру за один ход

Каждая модель, на которую конфиг ссылается по имени — model.general, model.fastest, model.smartest, model.advisor, model.fallback, code_model, image_model, summary_model — обязана быть в modelsimage_model — ещё и нести image_output). Иначе ядро не стартует и называет поле с опечаткой. Правило одно на весь конфиг, потому что список — это ещё и то, что видит Господин в меню, из чего Ева выбирает исполнителя и откуда модель получает свой jailbreak: не перечисленная модель работает тише и хуже, а опечатка вскрывается отказом провайдера посреди тура. Модели эмбеддингов (memory.embedding_model) сюда не относятся — у них свой каталог (и ходят они всегда через основной провайдер). Тем же стартовым правилом проверяются и ссылки provider у записей models: имя, которого нет в providers, роняет ядро сразу, а не туром не на том апстриме.

images — блоб-стор сгенерированных картинок

поледефолтчто это
dir<каталог базы>/imagesкуда ложатся файлы картинок
base_urlhttp://<server.listen>из чего строится ссылка <base_url>/images/<id>; публичным клиентам сюда — адрес reverse-proxy, у которого /images/* открыт мимо токена (ключ доступа — неугадываемый uuid)
keep30dретенция: старше — файл и запись сносятся (чистка на старте и раз в сутки); картинка ~1 МБ, без потолка чат молча заполняет диск

context — гигиена промпта (база всегда полная)

поледефолтчто это
tool_result_keep6сколько последних сообщений держат тул-трафик целиком; старее — огрызок: и вывод, и длинные строки входа вызова (тело записанного файла, патч, скрипт), от которых остаётся пометка длины
tool_result_keep_chars24000второй бюджет того же окна, в символах — и он строже: выхлоп ОДНОЙ итерации ложится одним сообщением, поэтому шесть сообщений значат то шесть последовательных вызовов, то восемь параллельных на каждой из шести итераций. Последнее сообщение свежо всегда, даже если толще бюджета; 0 — мерить только сообщениями
reader_result_max_chars8000свой потолок ОКОННЫХ читателей (read_file, find_pattern, read_hex, history_search): их выхлоп нарезан по границам строк и сам говорит, как читать дальше, поэтому режется хвостом, а не серединой — и по более щедрой мерке
tool_result_max_chars4000кап на один tool_result даже среди свежих; инструменты, которых зовут ради полного текста (skill, agent_inbox, memory_list, chat_note_read…), внутри окна не режутся вовсе, оконные читатели режутся по reader_result_max_chars
past_tool_trafficstubчто делать с тул-трафиком ЗАКРЫТЫХ ходов: keep — оставить как есть, stub — оставить вызовы и огрызки, но схлопнуть полезную нагрузку входа за границей хода (даже если живое окно её ещё не тронуло), drop — выбросить вовсе, парами. Полный текст всегда достаёт history_search, база не трогается
turn_squeeze_percent50доля профильного порога компакции, после которой длинный тур поджимает СВОЙ трафик прямо на ходу: элизия гоняется один раз на входе, и сорокаитерационный ход иначе нёс весь свой выхлоп до переполнения окна провайдера. Тем же проходом вытесняются копии перечитанного внутри тура (тот же файл, та же выдача памяти) — у обоих одна цена, разрыв префикс-кэша, и платится она разом. Свежими остаются последние 4 сообщения, записи чтений внутри тура не отменяют; повторно — только на удвоенном весе, и проход, которому нечего схлопывать, молчит. Модель узнаёт строкой в [state], клиент — событием turn_squeezed, трасса — событием turn_squeeze. 0 выключает — вместе с вытеснением копий
compact_after_tokens37500порог компакции доверенного окружения (личка), в токенах честного счёта: контекст тяжелее — старая часть сворачивается в резюме. Устаревший ключ compact_after_chars читается как символы ÷ 4 (warning на старте)
compact_keep_tokens12500свежий хвост (в токенах), переживающий компакцию живьём; legacy-ключ compact_keep_chars — так же
compact_scopebody_after_prefixчто меряет порог: body_after_prefix — разговор без кэшируемого префикса (персона, правила, схемы тулов — стабилен и дёшев), total — весь промпт. Жёсткий потолок модели (context_window записи llm.models) всегда считает всё
compact_verbatim_percent15доля профильного порога на ДОСЛОВНЫЕ реплики Господина: при свёртке его самые свежие сообщения переживают её как есть, рядом с резюме, — формулировка задания дороже пересказа. Отбор от свежих к старым, первая не влезшая режется серединой; окно без его реплик сохранённое не стирает. 0 выключает
pressure.noticeангл. текствторая ступень давления в [state]: «осталось N»; плейсхолдеры {used}, {limit}, {left}
pressure.wrap_upангл. тексттретья ступень: приглашение свернуться ДО компакции — один раз на окно, только когда компакция не сработает сама прямо сейчас; "" выключает
pressure.just_foldedангл. текст«история только что свернулась — запиши, что резюме размыло»
pressure.wrap_up_reserve_tokens8000резерв третьей ступени: «сворачивайся» приходит, когда до ближайшей стены (порог компакции или потолок модели) меньше этого
summary_max_chars6000потолок саммари; merge пишет факт-лист без бюджета, переросшее ужимается отдельным проходом до ~80%
telegram.history_limit10нижняя граница окна живой истории телеграм-чатов В РЕПЛИКАХ ЛЮДЕЙ, не сообщениях (0 — без лимита); окно живёт в [limit, 2×limit) ходов ради кэша
telegram.past_tool_trafficdropто же поле для чатов поверхностей, со своим дефолтом: собственный выхлоп по закрытым задачам там не нужен ни модели, ни читателям чата
telegram.compact_after_tokens10000порог компакции чатов поверхностей (у чата задан surface: telegram, mtl) — ниже, компакция чаще
telegram.compact_keep_tokens3750свежий хвост поверхностной компакции
cron.compact_after_tokens3750порог компакции cron-туров — низкий: фоновой джобе не нужна вся история
cron.compact_keep_tokens1500свежий хвост cron-компакции
aggressive_folders[]папки, которые жмутся по cron-профилю (копят тяжёлый контент)
pin_first_message_folders[]папки с закреплённым первым сообщением (например транскрипт) — оно не компактится
project.root_markers[".git", ".jj", "flake.nix"]маркеры корня проекта для блока [project] (все AGENTS.md от корня до рабочего каталога): подъём от рабочего каталога останавливается на первом каталоге с любым из них; [] — подъём выключен, читается только сам каталог
project.max_bytes16384ОБЩИЙ бюджет на все AGENTS.md разом, в байтах: не влезшее усекается с подписью, какой файл пострадал; 0 выключает сбор целиком
project.memory_max_bytes8192свой бюджет памяти проекта (.eva/MEMORY.md, читается тем же проходом); 0 — файлы памяти не читаются
client_context_chars32000потолок клиентского context из send_message, в символах: чужие байты не смеют забить окно; перерост усекается серединой с явной пометкой, 0 — без потолка

chats, cron, memory

поледефолтчто это
chats.ephemeral_ttl1dayстолько молчания прощается временным чатам; молчащий уносится вместе со своими подагентами
chats.reap_interval1mкак часто ходит жнец
chats.problem_folderproblemsпапка, куда Ева заводит чат на каждую свою проблему
chats.max_agents4сколько подагентов чат держит живыми разом — предохранитель от веера
chats.quiet_folders["youtube", "cron", "code"]тихие полки: их плодит автоматика, и в GET /v1/chats без явного фильтра folder/project/surface их чаты не попадают; хвостовая * — префикс, без неё — точное имя (той же маской пользуется и сам фильтр folder: folder=mtl:* — все игровые полки разом). GET /v1/chats/folders отдаёт их как обычно
cron.tick5sразрешающая способность планировщика
memory.top_k8сколько отскоренных воспоминаний вплетается в промпт
memory.public_top_ktop_k / 2 (мин. 2)потолок релевантных в чужом публичном туре; core — всегда, личное — никогда
memory.recall_floor0.15нижняя граница выборки — ДОЛЯ от лучшего счёта в ней самой: top_k без порога добирает список до счёта, и запись, разделившая с запросом одно общее слово, занимает место наравне с попаданием. Порог относительный, потому что BM25 между запросами не нормирован; core идёт мимо скоринга и порогом не задевается. 0 выключает
memory.half_life7daysполураспад свежести
memory.prompt_budget8000потолок секции памяти в промпте, символов; core занимает не больше 60% его, остальное принадлежит выборке
memory.embedding_modelне заданамодель эмбеддингов у активного провайдера (напр. baai/bge-m3); не задана — чистый BM25

sleepсон: этапы memory и tools

Секция целиком необязательная: сон включён из коробки и безвреден на мелком корпусе (min_memories не даст этапу памяти стартовать). Наверху — общее ядро сна, работа ночи — в подсекциях этапов.

поледефолтчто это
enabledtrueвыключить целиком: компонент не поднимается, тул и ручки отвечают отказом
at00:00время суток (зона хоста), в которое пора спать, раз в сутки; пропущенную границу досыпает первым подходящим тиком
tick5mкак часто компонент сверяет, не пора ли
idle_for30mне будить, пока идёт разговор: последнее сообщение должно быть старше, живых генераций — ноль; срок не сгорает, недождавшийся тик пробует на следующем
dry_runfalseпланы обоих этапов считаются и пишутся в журнал, но не применяются — режим первых недель

sleep.memory — этап консолидации памяти

поледефолтчто это
enabledtrueвыключить только этап памяти; сон остальными этапами живёт
modelне заданаоверрайд роли smartest только для этапа; цепочка: sleep.memory.modelllm.model.smartestllm.model.general
min_memories20корпус мельче — этап памяти пропускается (сон целиком не блокируется, если этапу tools есть что делать)
max_ops40потолок правок памяти за один сон; лишние отбрасываются с отметкой в журнале
max_forget8из них забываний — отдельным, более узким потолком
keep_recent48hмоложе — не забываем и не переписываем: свежее ещё не устоялось
archive_ttl30dсколько забытое лежит в архиве до окончательного выноса
cluster_jaccard0.35ребро графа кластеризации по лексике (Jaccard основ)
cluster_cosine0.6ребро по семантике (косинус векторов одного пространства)
max_cluster8кластер крупнее — режется на подкластеры по слабейшим рёбрам

sleep.tools — этап курирования наборов инструментов

поледефолтчто это
enabledtrueвыключить только этап tools; сон остальными этапами живёт
modelне заданаоверрайд роли smartest только для этапа
max_ops10потолок правок наборов за одну ночь

Старых плоских ключей (sleep.model, sleep.max_ops, …) больше нет — serde их молча проигнорирует, и этап памяти уедет на дефолты: при обновлении секцию нужно переписать под подсекции (ломающее).

chat_sleepсон чата: набор инструментов по умолчанию

Отдельный от sleep компонент: свой у каждого чата, ленивый и фоновый, туров не держит. Секция необязательная целиком.

поледефолтчто это
enabledtrueвыключить целиком: компонент не поднимается, нуджи уходят в никуда, ручки отвечают отказом. Уже посчитанное курирование продолжает действовать — снять его можно только явно
at00:00граница суток (зона хоста), после которой сну чата пора
min_gap20hзащита от двойного сна за сутки: срок ставится не раньше, чем через это после пробуждения
retry_after1hна столько уезжает срок в момент захвата; им же закрываются дубли нуджей и падение посреди ночи
max_parallel2сколько чатов спит одновременно
dry_runfalseплан в журнал без применения — первые недели держать true
keep_days400ретеншн дневной статистики чатов; жнёт её гигиена глобального сна
min_turns10меньше туров в статистике — данных нет, этап молча пропускается
thin_turns10ниже — окно помечается thin, и на нём модели разрешено только расширять набор
skip_folders[agents, cron]полки, чьи чаты не курируются: у подагентов и крон-джоб свой узкий whitelist и однообразные туры

chat_sleep.tools — этап набора по умолчанию

поледефолтчто это
enabledtrueвыключить только этап; статистика копится и срок двигается по-прежнему
modelне заданаоверрайд на все чаты; не задана — модель самого чата
budget_percent10доля окна модели чата на схемы инструментов. Досягаемый набор влезает — этап не запускается вовсе: ни вызова модели, ни расхода. Отсюда же и то, что инструмент никогда не прячется «за молчание»
max_hide_per_night8сколько видимых инструментов одна ночь вправе спрятать
core[memory, ask_questions, think_harder, notify_master, telegram]обязательное ядро набора: курирование его не прячет. Имена тулов и групп регистрации, сверяются с реестром на старте

notesблокнот модели по чату

Секция целиком необязательная: дефолты рассчитаны на то, чтобы её не писать.

поледефолтчто это
enabledtrueвыключенный блокнот не регистрирует тулов, не идёт в промпт и не трогает базу
inline_chars2000тела тяжелее — блокнот показывается индексом
inline_back_chars1400возврат к дословному показу; ниже входного порога ради гистерезиса
inline_max8то же по числу заметок
inline_back_max6возврат по числу заметок
index_max_chars4000потолок индекса: перерос — пора пересобирать, даже если тела лёгкие
compact_after_chars20000порог пересборки по размеру тел
compact_target_chars6000во столько символов просим уложить блокнот при пересборке
max_note_chars2000потолок тела одной заметки; перебор — отказ «разбей на две»
max_notes40порог пересборки по числу заметок
pinned_max3сколько заметок можно закрепить
pinned_chars2500суммарный бюджет закреплённых тел в промпте; ниже max_note_chars ставить нельзя — законная по размеру заметка перестанет закрепляться
modelне заданамодель пересборки; не задана — модель самого чата

advisorсоветчик поверх рабочего тура

Секция необязательная и работает, только когда задана llm.model.advisor: без роли советчика нет вовсе. Дефолты рассчитаны на то, чтобы секцию не писать — назначил модель, и он уже не шумит.

поледефолтчто это
enabledtrueзаткнуть советчика, не убирая роль модели
every_iterations3цикл каждой N-й итерации рабочего тура
max_steps3его собственных шагов за цикл (чтения инструментами — внутри счёта)
max_notes2вклеенных заметок за тур; подавленные щитом не в счёт
cooldown_cycles3сколько циклов молчать после вклеенной заметки
delta_max_chars6000потолок дельты транскрипта на цикл; жертвуется середина
timeout60sне уложился — тур идёт дальше без него

tools, mcp, shell

поледефолтчто это
tools.dirне заданкаталог *.toml-деклараций внешних инструментов
tools.on_demand[]имена тулов и групп, которых нет в списке по умолчанию: они видны только именем и назначением, а схему получают по tool_load. Для редкой работы, чьи схемы не должны ехать провайдеру в каждом запросе
mcp.servers.<имя>.commandstdio: argv запуска (ядро держит процесс); env — окружение
mcp.servers.<имя>.urlstreamable HTTP: URL эндпоинта; headers — заголовки (авторизация). Ровно одно из command/url
mcp.servers.<имя>.timeout60sтаймаут одного запроса (handshake, список, вызов)
mcp.servers.<имя>.accessmasterкому доступны инструменты: master | allowed | everyone
mcp.servers.<имя>.trusted_onlyfalseтолько из доверенного окружения (TUI/Android; телеграм — нет)
mcp.servers.<имя>.lazyfalseленивый старт: на старте ядра к серверу не ходить, тулы поднять из кэша прошлого запуска, первый живой вызов запустит сервер сам. Первый запуск (кэша нет) подключается как обычно
shell.remoteне задан (локально)удалённый хост: name (для логов и подсказки модели), destination ([user@]host или алиас ssh_config), args (identity, порт…)
shell.output_capне задан (без обрезки)потолок вывода shell в символах; задан — обрезка с пометкой

Сломанный MCP-сервер выключается целиком, ядро живёт дальше. На старте серверы разрешаются параллельно (старт платит за самый медленный, не за сумму), а регистрируются по отсортированным именам — порядок инструментов в промпте детерминирован. Инструмент, объявивший _meta.ui.visibility без "model" в списке (UI-виджеты MCP apps), модели не отдаётся.

modules — процессные модули

поледефолтчто это
servers.<имя>.socketUDS-сокет модуля (line-JSON-RPC, как stdio)
servers.<имя>.tcphost:port модуля на другой машине; ровно одно из socket/tcp
servers.<имя>.accesseveryoneкому доступны тулы: модуль ставит оператор, а администрирование внутри себя он запирает сам (_meta["dev.eva/master"])
servers.<имя>.trusted_onlyfalseтолько из доверенного окружения
servers.<имя>.timeoutкак у MCPтаймаут запроса
socket_dirне заданадиректория *.sock-кандидатов: видны в module_list, подключаются module_approve

Динамические модули регистрируются не конфигом, а тулами (module_register и др.) — живут в базе, тулы появляются и исчезают без рестарта ядра.

web

поледефолтчто это
proxyне заданпрокси веб-запросов (socks5h://… или http://…)
fetch_chars8000потолок отдаваемого моделью текста страницы
searxngне заданабаза своего SearXNG — источник web_search; не задана — поиска нет (на SearXNG прокси не действует — он свой и локальный)
search_results6сколько результатов поиска отдавать
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_ttl5mсколько ждут ответа кнопочные вопросы 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 с note fork_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 совпадает с id tool_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>, нормализация oneOfanyOf в схемах инструментов) и 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_*; что в ядре есть ради поверхностей вообще — в главе «Телеграм»
APIaxum, ответы стримятся по 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 — иначе префикс рвался бы каждый шаг.
  • Инварианты тура: запрос к провайдеру всегда валиден, и держат это три механизма. Список инструментов замораживается снимком шага (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_reactmcp_rzd_search
группасвоя (telegram)mcp + mcp_<сервер>
знает о туреда, получает контекстнет
может перехватывать действияданет
может вести турыданет
объявляется вmodules.serversmcp.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 и события

Два разных механизма, и путать их дорого:

middlewareon-события
когдаДО действияПОСЛЕ факта
ядро ждёт?да, синхроннонет
может отменить?данет
цена ошибкитормозит действиеничего
чем платимзадержкой каждого действияничем

Правило выбора простое: нужно помешать — 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, но взято только то, что ложится на наш дизайн:

  1. Конверт остаётся нашим. middleware/handle {stage, payload}{continue | cancel(reason) | patch(payload)}. Отдельных полей «подави вывод» и «скажи модели вот это» не заводим: и то и другое — patch результата.
  2. Правка входа — по стабильной «хук-форме». На tool.call обработчик видит и правит не сырые внутренности вызова, а объявленную инструментом стабильную форму аргументов; инструмент умеет собрать вызов из неё обратно. Рефакторинг инструмента не ломает чужие хуки.
  3. Один запуск на вызов, не на алиас. Хук с матчером на несколько имён (write_file|apply_patch) срабатывает один раз, в payload идёт каноническое имя инструмента.
  4. Цепочка последовательна. Порядок — ссылки before/after, патчи композируются в порядке цепочки, журнал пишет реакции в нём же. Codex гоняет хуки параллельно и разрешает конфликт правок порядком завершения («последний писатель побеждает») — нам это не подходит: порядок цепочки и есть договорённость, случайности гонки в ней не место.
  5. Доверие — по хэшу нормализованной личности. Хэшируется не текст объявления, а нормализованное описание обработчика: один и тот же хук, объявленный двумя способами, — одна личность. Состояния: Managed | Trusted | Modified | Untrusted; правка доверенного переводит его в Modified, и он не исполняется до повторного одобрения через очередь одобрений — та же механика, что у module_approve.
  6. Слив выхлопа. Вывод обработчика сверх бюджета пишется целиком во временный файл, в контекст едет голова с хвостом и путь: болтливый хук не раздувает контекст, и ничего не потеряно — можно дочитать.
  7. Остановка не виснет на хуках. Таймаут обработчиков завершения зажимается с предупреждением в журнале: хук выхода не может повесить выключение ядра.

Порядок и цепочка

Обработчиков может быть много, и порядок задаётся ссылками, а не временем добавления: у записи есть before и after. Модель переставляет перехватчики, переписывая ссылки, а не пересчитывая номера.

Заметили цикл или битую ссылку — цепочка отключается целиком, события идут мимо неё, а ядро заводит чат в папке проблем и просит починить. Правка ссылок требует подтверждения Господина: молча переписать порядок собственных ограничений Ева не может.

Кто бывает обработчиком

  1. Процессный модуль — обычный модуль на SDK, объявивший стадии.
  2. Запись в базе — код, который Ева пишет себе сама; их исполняет раннер: тонкий процесс на том же SDK, который забирает включённые записи, собирает цепочку и отвечает на middleware/handle. Спавнить процесс на каждый вызов нельзя — это сотня миллисекунд на каждый tool.call.
  3. Сама модель — стадия может звать 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 не нужно.

Shellshell (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_demandcode_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 ищет по тому, что инструмент должен делать («чем отправить сообщение в личку»), каскадом от дешёвого к дорогому:

  1. лексика — основы запроса против документа инструмента: имя, назначение, семейство и имена с описаниями параметров схемы — тул находится и по имени своего аргумента. Токенизация та же, что у памяти; отбор по доле совпавших основ, ранжирование — BM25. Ноль токенов, доли миллисекунды, а имена по построению настоящие: они из реестра, выдумать их нельзя;
  2. быстрая модель — и только если лексика ничего внятного не нашла. Реестр скрытого едет ей в промпт, она разбирает запрос и называет инструменты; ответ сверяется с реестром, так что несуществующее имя наружу не выйдет.

Вторая ступень нужна ровно для случая «запрос по-русски, описания по-английски», где лексика бессильна. В обычном случае до неё не доходит. Найденное сразу догружается на текущий тур.

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).

Крон от этого выигрывает не меньше: у крон-джобы клиента нет вовсе, а режим живёт на её чате — значит длинная работа по расписанию перестаёт упираться в потолок итераций.

Как набор сочетается с гейтами

Порядок жёсткий и проверяется в реестре, до исполнения:

  1. Гейты личности (master/trusted/privileged/client_ops) — поверх всего и всегда. Набор не выдаёт чужому туру master-only тул.
  2. В доверенном туре (TUI, Android, личка Господина) набор заменяет базовый доступ: Ева сама решает, чем работать, и может вернуть себе что угодно из реестра.
  3. В недоверенном (публичный чат) набор может только сузить: белый список настроек чата остаётся потолком. Иначе команда, внедрённая в чужое сообщение, открывала бы себе то, что Господин выключил.

Стартовые наборы

На первом запуске база засевается пятью наборами — 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 действует и здесь, о применённых правках Господину уходит уведомление — они меняют, чем Ева работает днём.

Назначение семейств

Строка свёрнутого семейства берётся, по убыванию приоритета:

  1. description в секции сервера/модуля в конфиге — ручной override;
  2. чем сервер представился сам: _meta["dev.eva/about"] в ответе initialize (на eva-sdk — Module::about(...) / Module(about=...));
  3. у встроенных семейств — таблица в ядре;
  4. не сказал никто — первые имена тулов семейства. Понятнее, чем пусто, но хуже, чем фраза по делу.

Штатные 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_spawnname, 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 всё равно наступает. Фазы этапа памяти:

  1. Гигиена без модели — доэмбед упавших векторов, снятие архива старше archive_ttl.
  2. Кластеризация без модели — граф похожести по лексике (Jaccard основ ≥ cluster_jaccard) и семантике (косинус ≥ cluster_cosine); связные компоненты — кластеры, крупнее max_cluster — режутся по слабейшим рёбрам. core участвует наравне: противоречие «core против свежего факта» ловится только так.
  3. Разбор кластеров — один вызов модели на кластер: слить дубли, развести противоречие в пользу свежего (или переписать с датировкой, когда старое состояние ещё имеет смысл), расщепить запись-склейку, вывести из череды однотипных событий факт. Устоявшиеся кластеры (consolidated_at новее правок) модели не уезжают — вторая ночь подряд на неизменном корпусе не стоит ни одного вызова.
  4. Забывание вне кластеров — кандидатов считает ядро арифметикой (старое по полураспаду, ни разу не вспомненное, важность ≤ 2, не core); модель может список только сократить — добавить в него нельзя.
  5. Виды, важность, бюджет core — один вызов на весь корпус в сжатом виде: поднять в core то, к чему обращаются постоянно, опустить оттуда мёртвое, перекалибровать сползшую важность, ужать core, переросший свою долю бюджета секции.

Решает модель этапа (sleep.memory.modelllm.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, тупик, состояние задачи

Резюме блокнота не заменяет: оно пишется постфактум чужой моделью и не знает, что из тура было важным.

Три режима

Режим считается по размеру блокнота на входе в тур и внутри тура не меняется: от него зависит список инструментов, а тот идёт в самом начале запроса и держит кэш промпта.

  1. inline — заметок мало: тела едут в промпт дословно. Инструмента чтения модель не видит вовсе, читать нечего — всё перед глазами.
  2. index — заметок много: в промпт едет только имя — описание (плюс закреплённые дословно), появляется chat_note_read.
  3. 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_bodybody) и метит изменившееся с прошлого захода.

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) и первичный посев один раз за жизнь базы, как у наборов.

Что это НЕ заменяет

Правило — для того, что модель знает, но забывает. Если промах системный (модель не знает, что так можно), это строка в правилах промпта, а не правило: напоминание после факта не научит тому, чего в промпте не было.

Две половины

половинаscopemodeчто делает
мягкаяtool:<имя>remindнапоминание в результат вызова, тур идёт дальше
жёсткаяtextinterruptготовый ответ выбрасывается, ход переписывается

Половина у каждого 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ключ дедупа и повтора; он же едет модели в атрибуте тега
scopetool:<имя> — вызов инструмента; text — готовый ответ. Мыслей у нас в истории нет, thinking не поддерживается
conditionregex по цели: аргументы вызова компактным JSON либо текст ответа. Пусто — совпадает любая цель этого scope
negateсработать, когда regex не нашёлся: «не хватает хвоста» иначе не выразить — в rust-regex нет lookaround
whenусловия окружения: telegram, client_ops, master, trusted, picture. Все обязаны выполниться; пусто — любой тур
textчто подставится модели (до 400 символов)
moderemind | interrupt; следует из scope, называть не обязательно
repeatonce — раз на чат навсегда; 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жёсткая![](url) в телеграмном ответе — там 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) стирает всю его память о чате — иначе он рецензирует то, чего уже нет

Подавление невидимо советчику: на любую заметку инструмент отвечает «записано». Скажешь ему «подавлено» — он перефразирует ту же бесполезную мысль в обход дедупа, и заплатит за это Господин.

Ступени

severitynit | 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.jsonid трассы (свой, отдельный от 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.

Каналы связи ядру не принадлежат: телеграм и всё, что появится дальше, живёт модулями рядом.

Клиенты

КлиентРепозиторийСтекЧто умеет
webeva/frontendNuxt 4 + TailwindЕва в браузере; ставить нечего, наружу вешается под замком с паролем
appeva/appTauri + NuxtДесктопное окно с руками: клиентские операции, разрешения, кодовые сессии
androideva/androidKotlin + Jetpack ComposeПолноценный мобильный клиент
tuieva/tuiPython + Textual + httpxТерминальный клиент; руки и код-режим есть и здесь
mtleva/mtlTauri + NuxtМастерская переводного патча: движки игр, ресурсы, обратная запись
telegrameva/telegram-bridgeRust + 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-bridgeeva/telegram-bridgeТелеграмная поверхность: поллинг, триаж, доставка, инструменты telegram_*
eva-bookseva/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-mcpeva/rzd-mcpПланирование поездок по РЖДstdiorzd
kana-mcpvndb/kana-mcpVNDB — база визуальных новеллstdiokana
redosenero/redoseУчёт веществ/доз/трипов, марафоныHTTP (лок.)redose (trusted)
viendesuплатформа VienDesuИмиджборд/вики/игры desu.churchHTTPviendesu
ghostswap-mcpeva/ghostswap-mcpNo-KYC крипто-свопы (GhostSwap API)stdioghostswap (trusted)
forgejo-mcpeva/forgejo-mcpForgejo/Gitea — issue/PR/релизы/raw apistdioforgejo (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_bytes 32 → 16 KiB, memory_max_bytes 16 → 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 снято с довольствия: оно выбрасывало ответ с ![](url) в телеграмном туре, а теперь так картинка туда и прикладывается. Волна калибровки 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::ToolCallpayload::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_deltaitem_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_format c 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-rs secrets/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_op list_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_shell rm/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, и старое булево читается как раньше (truemid, falseoff), так что конфиги на машинах править не надо. Ступень выбирается по убыванию силы: поле 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_profilelow, 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_updatePOST /v1/cron) сверяют каждое имя с реестром — тулом или группой регистрации. Незнакомое имя — ошибка с ближайшими похожими и списком групп, чтобы модель поправила список сама. Прежде опечатка («telegram_send» вместо группы telegram) молча рождала немую джобу: она стреляла по расписанию, но отправить результат ей было нечем. Сверка — ToolRegistry::validate_access, общая для любого сохраняемого списка доступа. Регрессия — benchmark/scenarios/02-selftest-cron-whitelist.yaml; в чеках бенча у cron_exists появилось поле tools (точный whitelist) и обратный чек cron_not_exists.

  • Новый cron_firePOST /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-2qwen-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_outputllm.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/statsusage_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_waystool вместо 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 через шелл: ни точечной замены, ни номеров строк, а из телеграма и крона файлов не было вовсе. Гейт выровнен по shellmaster_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 (scopechat по умолчанию или 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; рукописная таблица из книги удалена.
  • Кнопка «⏳ В фон» на стрим-сообщении: тур доезжает в фоне, воркер чата сразу берёт следующие сообщения; «✋ Отмена» — с иконкой.

  • Меню настроек пользователя (пресеты: дебаунс, потолок, краткость, мультимодалка) и тул 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, правило «не спойлерить» в промпте.