- Задача плана:
4.0-D02 - Дата: 1 сентября 2026
- Статус: черновик контракта. Написан до реализации — обе стороны пишутся по нему независимо.
- Транспорт: именованный канал, решение в ADR 0002
- Поведение, которое протокол обязан сохранить:
INVENTORY-3.1.0.md
Половина описанного здесь в 4.0 не используется. Это сделано намеренно: добавить потом стоит переписывания обеих сторон, а заложить сейчас — стоит абзаца в документе.
1. Общие правила
Стороны. Оболочка — процесс на C#, владеет окном, устройствами вывода и каналом. Ядро — процесс на Python, принимает решения. Оболочка запускает ядро, следит за ним и перезапускает; ядро подключается клиентом.
Направления. Оболочка шлёт запросы («сделай»), ядро отвечает и шлёт события («случилось»). Ядро тоже может слать запросы — их ровно два вида: запрос разрешения и запрос данных, которыми владеет оболочка (индекс программ).
Ядро решает, оболочка показывает. Ядро не умеет открывать окна и не знает, как выглядит диалог. Оно сообщает о намерении, оболочка решает, как это выглядит и показывать ли вообще.
Всё сериализуемо. Значение любого поля — строка, число, логическое, массив или объект. Исключений нет: то, что нельзя записать в JSON, остаётся внутри стороны и наружу не выходит.
2. Каналы и кадрирование
Два канала на сессию:
| Канал | Имя | Содержимое |
|---|---|---|
| Управляющий | \\.\pipe\rina.<session>.control | сообщения JSON |
| Данных | \\.\pipe\rina.<session>.data | двоичные кадры |
Раздельные, чтобы всплеск звука не задерживал управляющее сообщение за собой. Это требование 4.0-D07, и его проверяет тест: поток PCM не должен ухудшать задержку управляющего канала.
<session> — случайный идентификатор, порождаемый оболочкой при запуске и передаваемый ядру аргументом командной строки. Дескриптор безопасности канала ограничивает доступ пользователем сессии.
Кадр управляющего канала
[4 байта: длина полезной нагрузки, big-endian, без знака][полезная нагрузка: JSON в UTF-8]
Предел одного сообщения — 1 МиБ. Больше — ошибка protocol.frame_too_large и разрыв: сообщение такого размера означает либо дефект, либо попытку исчерпать память.
Кадр канала данных
[4 байта: длина остатка кадра][4 байта: stream_id][8 байт: порядковый номер][полезная нагрузка: байты]
Порядковый номер растёт в пределах потока и служит для обнаружения потерь при отладке: без него потерянный кусок звука выглядит как «Рина расслышала не всё», и причину ищут в распознавании, хотя она в канале. Пропуск номера приём не обрывает — канал не теряет кадров сам по себе, и записать факт с номерами полезнее, чем упасть: упавший приёмник не расскажет, где именно порвалось.
Предел кадра — 256 КиБ, меньше управляющего: кадр звука не обязан быть большим, а мелкая нарезка даёт отзывчивость и ровное давление.
Двоичные данные никогда не кодируются внутри JSON. Base64 раздувает объём на треть и заставляет звук делить очередь с управлением.
3. Конверт
Поля конверта присутствуют в каждом сообщении управляющего канала без исключений.
| Поле | Тип | Обязательно | Смысл |
|---|---|---|---|
v | число | да | версия протокола — наибольшая общая, выбранная при рукопожатии |
type | строка | да | request, response, event, error |
id | строка | да | идентификатор сообщения, уникален в пределах сессии |
method | строка | для request и event | имя метода или события |
correlation_id | строка | для response и error | id запроса, на который это ответ |
stream_id | число | нет | поток, к которому относится сообщение |
timestamp | число | да | время отправки, секунды с эпохи, дробные |
trace_id | строка | да | сквозная трассировка, см. §13 |
payload | объект | да | содержимое; пустой объект, если нечего сказать |
Пример:
{
"v": 1,
"type": "request",
"id": "s-0042",
"method": "command.handle",
"timestamp": 1756750000.123,
"trace_id": "t-8f21",
"payload": {"text": "запусти телеграм", "source": "typed", "require_wake": false}
}
Неизвестный method в запросе → ошибка protocol.unknown_method. Неизвестное событие → получатель молча его игнорирует. Эта асимметрия намеренна: пропущенный запрос — потерянное действие, пропущенное событие — потерянное уведомление.
4. Рукопожатие и согласование возможностей
Первое сообщение в сессии. До успешного рукопожатия любой другой метод отвечает ошибкой protocol.not_ready.
Оболочка → ядро:
{"method": "hello", "payload": {
"protocol_versions": [1],
"shell_version": "4.0.0",
"capabilities": ["audio.input", "audio.output", "permissions", "window.actions"],
"locale": "ru"
}}
Ядро → оболочка:
{"type": "response", "correlation_id": "...", "payload": {
"protocol_versions": [1],
"protocol_version": 1,
"core_version": "4.0.0",
"capabilities": ["stt", "tts", "reminders", "llm", "plugins"],
"session_id": "3f0c…"
}}
Каждая сторона объявляет набор версий, которые реализует, даже если версия одна. Совместимы, если наборы пересекаются; работают по наибольшей общей, и ядро называет её в protocol_version ответа — дальше это значение стоит в поле v каждого сообщения.
Набор нужен потому, что оболочка обязана держать предыдущую версию один выпуск (ADR 0004): она объявляет [1, 2] и одинаково работает и с новым ядром, и со старым. С одним числом ступенчатое обновление невозможно — пришлось бы менять обе части одновременно.
Возможность — это обещание понимать группу методов. Сторона не вызывает метод, чью возможность собеседник не объявил. Так ядро и оболочка обновляются независимо: новая оболочка со старым ядром просто не покажет то, чего старое ядро не умеет.
Несовместимость версий протокола → protocol.incompatible с понятным текстом; загадочного обрыва быть не должно. Требование 4.0-D03: старая оболочка с новым ядром обязана сказать, что именно не так.
Каталог возможностей
Списки выше — пример. Полный перечень заведён при реализации 4.0-D03: без него каждая сторона придумала бы свой словарь, и обещание «спецификация написана до реализации» сломалось бы при первом расхождении.
| Возможность | Объявляет | Отпирает |
|---|---|---|
audio.input | оболочка | поток stream.open вида audio.input |
audio.output | оболочка | поток stream.open вида audio.output |
permissions | оболочка | permission.request |
window.actions | оболочка | — |
apps | оболочка | apps.index, apps.launch |
stt | ядро | speech.listen_once, speech.set_always_listen |
tts | ядро | speech.say |
reminders | ядро | reminders.list, reminders.cancel |
plugins | ядро | plugins.list, plugins.set_enabled, plugins.page, plugins.home, plugins.action, plugins.install |
system.context | оболочка | system.context |
privacy | ядро | privacy.inventory, privacy.forget, privacy.export |
commands | ядро | commands.list, commands.save, commands.try, commands.delete, commands.set_enabled, commands.export, commands.import |
todo | ядро | todo.list, todo.add, todo.close, todo.remove |
sessions | ядро | sessions.list, sessions.finish |
history | ядро | history.list, history.clear, history.export |
llm | ядро | — |
tasks | ядро | task.cancel |
actuation | ядро | §12; в 4.0 не объявляется никем |
Базовые методы, доступные без объявленной возможности, — те, без которых сессия бессмысленна: command.handle, command.run_by_id, settings.describe, settings.options, settings.get, settings.set, settings.reset, hotkeys.actions, core.shutdown, ping, pong.
Возможность бывает без методов. llm не отпирает ничего: отдельного метода у языковой модели нет, но по её наличию оболочка решает, показывать ли соответствующие настройки. window.actions относится к событию, а событие не «вызывают». Такая возможность только сообщает, ничего не отпирая, и это законная форма.
Метод необъявленной возможности отвечает protocol.unknown_method; кода «нет права» здесь нет. Для собеседника разницы нет: метода, которого не обещали, всё равно что не существует. Это же правило делает §12 не особым случаем — методы актуации отвечают «неизвестный метод» просто потому, что actuation в 4.0 никто не объявляет.
Правила совместимости (4.0-D17)
Схема версий в целом — ADR 0004. Здесь только то, что относится к самому протоколу: у него нет минорной версии, номер растёт исключительно при ломающем изменении, и совместимость двух процессов решается пересечением объявленных наборов. Числовые диапазоны из метаданных обновления в этом не участвуют.
Можно, не меняя версию: добавить необязательное поле; добавить метод или событие; добавить значение в перечисление, если у получателя есть определённое поведение для незнакомого значения; добавить возможность.
Нельзя без новой версии: удалить или переименовать поле; изменить тип поля; изменить смысл существующего значения; сделать необязательное поле обязательным.
Депрекация: поле помечается в этом документе, продолжает отправляться не менее одного выпуска, и только потом исчезает вместе с ростом версии протокола. Минорной версии у протокола нет, поэтому мерой служит выпуск.
Правила записаны и проверяются (4.0-D17). Снимок контракта — contract-v1.json: методы, события с полями, коды ошибок, возможности, виды потоков. tools/check_contract.py сравнивает код со снимком и разбирает изменения на разрешённые и ломающие. Иначе правила остались бы словами: тот, кто добавляет поле, смотрит на соседнюю строчку кода, спецификацию он в этот момент не открывает.
5. Ошибки
Ошибка — часть контракта, такая же, как ответ.
{"type": "error", "correlation_id": "s-0042", "payload": {
"code": "app.not_found",
"category": "user",
"retryable": false,
"message": "Не нашла программу «фотошоп».",
"details": {"query": "фотошоп"}
}}
| Поле | Смысл |
|---|---|
code | точечный идентификатор для машины, стабилен между версиями и языками |
category | user — пользователь может исправить; system — окружение; protocol — дефект одной из сторон |
retryable | имеет ли смысл повторить то же самое |
message | текст для пользователя, на языке из рукопожатия |
details | структурированные подробности |
Код и текст разделены намеренно. Текст переводится и переформулируется, код — нет; логика ветвится по коду, пользователь читает текст. Позже по details сможет осмысленно исправиться языковая модель, вместо того чтобы зациклиться (5.0-C05).
protocol.invalid_envelope добавлен при реализации 4.0-D04: требование §15.1 объявляло отсутствие обязательного поля ошибкой категории protocol, но кода для неё в каталоге не было. Поля, которых не хватило, перечисляются в details.fields — иначе сообщение «конверт неполон» отправляет отлаживающего искать вручную.
retryable значит «имеет ли смысл повторить то же самое». Поможет ли что-то другое, это поле не говорит. Просроченное подтверждение неповторяемо: тот же вызов с тем же идентификатором провалится снова. Получить новое подтверждение и позвать заново, разумеется, можно — но это уже не повтор.
Начальный каталог:
| Код | Категория | Повтор | Когда |
|---|---|---|---|
protocol.incompatible | protocol | нет | общей версии протокола нет |
protocol.unknown_method | protocol | нет | метода нет либо его возможность не объявлена |
protocol.frame_too_large | protocol | нет | кадр больше предела канала |
protocol.not_ready | protocol | да | рукопожатие ещё не состоялось |
protocol.invalid_envelope | protocol | нет | конверт неполон или не разбирается |
protocol.invalid_payload | protocol | нет | нагрузка не соответствует объявленной форме события |
protocol.invalid_state | protocol | нет | сообщение не к месту: поток закрыт, задача уже завершена |
permission.denied | user | нет | разрешение не выдано |
permission.required | user | нет | действие требует разрешения, которого не спрашивали |
confirmation.required | user | нет | опасное действие вызвано без подтверждения |
confirmation.invalid | protocol | нет | подтверждение выдано под другой вызов или аргументы |
confirmation.expired | user | нет | срок истёк; повтор того же не поможет, нужно новое |
tool.unknown | protocol | нет | вызвана несуществующая возможность |
tool.invalid_arguments | protocol | нет | аргументы не проходят схему инструмента |
transfer.wrong_kind | user | нет | файл не того вида: это не выгрузка команд |
transfer.too_new | user | нет | файл сделан более новой версией — обновите приложение |
transfer.unreadable | user | нет | файл не разобрать: не похоже на выгрузку Рины |
settings.unknown_key | protocol | нет | такой настройки нет |
settings.invalid_value | user | нет | значение не проходит ограничения ключа |
llm.remote_address | user | да | адрес модели не локальный: значение принято, пользователя предупреждают |
plugin.not_found | protocol | нет | плагина с таким номером нет: список устарел или его удалили |
app.not_found | user | нет | программа не найдена в индексе |
app.launch_failed | system | да | запуск сорвался |
stt.unavailable | system | нет | распознавания нет |
stt.failed | system | да | распознать не удалось |
tts.unavailable | system | нет | синтеза нет |
llm.unavailable | system | да | модель недоступна |
task.cancelled | user | нет | задача снята по просьбе пользователя |
calc.zero_division | user | нет | деление на ноль в выражении |
internal | system | да | непредусмотренный сбой |
Каталог сверяется с кодом, на веру его никто не держит. tools/test_wire.py читает эту таблицу и сравнивает её с core/wire/errors.py в обе стороны, а сверх того требует, чтобы каждый код, объявленный любым инструментом реестра, здесь присутствовал. Первый прогон нашёл пять пропусков: tool.unknown, tool.invalid_arguments, confirmation.required, confirmation.expired и calc.zero_division — ядро их отправляло, а документ о них не знал.
Подтверждения и разрешения — разные вещи и разные коды. permission.* относится к каталогу разрешений (4.0-C04): есть ли у действия право в принципе. confirmation.* относится к предъявленному подтверждению (4.0-C05): спросили ли пользователя про этот конкретный вызов с этими аргументами. Одно не заменяет другого, и слить их в один код значило бы потерять различие между «так нельзя» и «так можно, но спросите».
6. Управляющие методы
Выведены из фактического поведения 3.1.0 (см. инвентарь) — не придуманы заново.
Оболочка → ядро
| Метод | Полезная нагрузка | Ответ |
|---|---|---|
command.handle | text, source (typed/voice/always), require_wake | принято в обработку |
command.run_by_id | command_id | принято |
speech.listen_once | — | принято |
speech.set_always_listen | enabled | фактическое состояние |
speech.say | text | принято |
speech.test | — | произнести пробную фразу: получилось ли и какую |
settings.describe | — | описание настроек, см. 4.0-E06a |
settings.options | keys | какие значения ключ принимает сейчас |
settings.get | keys | значения |
settings.reset | — | сброс группы настроек; команды, история и плагины не трогаются |
settings.set | values | применённые значения |
reminders.list | — | список |
reminders.create | text, fire_at, kind | заведённое напоминание |
reminders.cancel | id либо all | сколько снято |
system.foreground | launch | сколько напоминаний сработало и следим ли (4.0b-A03) |
plugins.list | — | список с состоянием |
plugins.set_enabled | plugin_id, enabled | состояние |
todo.list | — | дела целиком, вместе с закрытыми (4.0b-A13) |
todo.add | text | записать дело |
todo.close | todo_id, done | пометить сделанным или вернуть в работу |
todo.remove | todo_id | убрать дело совсем |
sessions.list | — | сессии целиком, открытая помечена (4.0b-A02) |
sessions.finish | note | закрыть открытую сессию |
plugins.page | plugin_id | декларативное описание страницы |
plugins.home | — | плитки включённых плагинов для главного экрана (4.0b-A07); всё сразу, потому что главная рисует их вместе |
plugins.action | plugin_id, action, values | новое описание страницы |
plugins.install | source | установленный плагин |
commands.list | — | список своих команд |
commands.kinds | — | из чего команда бывает сделана: виды, системные действия, режимы совпадения |
commands.builtin | — | что Рина умеет без настройки: фразы и что они делают |
hotkeys.actions | — | чему можно назначить сочетание клавиш |
setup.state | — | первый ли это запуск |
setup.finish | — | мастер пройден, больше не показывать |
models.catalogue | — | какие модели можно скачать, чего они стоят |
models.fetch | ids | начать скачивание; ход — обычными task.* |
commands.save | command | сохранённая команда; создание и правка — один метод |
commands.try | command — карточка целиком | accepted. Выполнить несохранённое — проба из конструктора (4.0b-A09). Ничего не сохраняет: ни команды, ни номера, ни счётчика. Единственный метод, принимающий саму карточку вместо ссылки на неё; почему это безопасно, разобрано в T-21 |
commands.delete | id | удалено ли |
commands.set_enabled | id, enabled | список после изменения |
commands.export | — | содержимое файла целиком: kind, format, app_version, exported_at, payload.commands, payload.stats. Файл пишет оболочка |
commands.import | file — содержимое, прочитанное оболочкой | added, skipped. Чужой вид файла отвергается кодом transfer.wrong_kind |
system.context | question (foreground или running), about | answer — строка. Что сейчас открыто и запущено, для условий сценария (4.0b-A09). Спрашивает ядро, отвечает оболочка: машина её (ADR 0009). Ответ решает ветвь и не хранится нигде (T-19) |
privacy.inventory | — | groups, gathered_at. Опись всего, что хранится о пользователе (4.0b-B01). Группа — id, count, items; запись — id, what, detail, where, when. Группы приходят без названий: как их звать, решает оболочка (ADR 0006), и она обязана показать незнакомую группу под её же id — иначе новый вид хранимого исчезнет с той единственной страницы, которая обещает полноту |
privacy.forget | group и ids — эти записи; один group — группу целиком; everything — всё | forgotten — сколько записей ушло (4.0b-B02). Число, потому что «сделано» и «там ничего и не было» — разные ответы. Незнакомая группа тоже забывается: то, что пользователь видит и не может убрать, хуже непоказанного |
privacy.export | — | содержимое файла целиком: kind (rina.everything), format, app_version, exported_at, payload.groups (4.0b-B03). Файл пишет оболочка. Выгрузка равна описи: файл, показывающий меньше страницы, превратил бы страницу в пересказ самой себя |
history.list | limit | items, total |
history.clear | — | сколько стёрто |
history.export | — | содержимое файла целиком: kind, format, app_version, exported_at, payload.history. Файл пишет оболочка |
task.cancel | task_id | принята ли просьба, см. §9 |
core.shutdown | — | подтверждение |
Команды и история появились в 4.0-F04, и это была дыра. Методы §6 выведены из инвентаря поведения, но инвентарь поверхности проверялся отдельно — и на стыке потерялись шесть возможностей: список своих команд, их создание и правка, включение, импорт и экспорт, и вся история. Место в информационной архитектуре у них было (4.0-R11 это проверял), а метода, которым оболочка это сделает, не было. Место отвечает на вопрос «где кнопка», протокол — на вопрос «что произойдёт, когда её нажмут», и между ними уместилась пропасть. Теперь её стережёт tools/check_surface_reachable.py.
Создать можно не только голосом. reminders.create и конструктор команд появились в 4.0-F04, когда обе страницы оказались пусты: список был, а завести первую запись из окна было нечем. Голосом напоминание заводится разбором фразы, и казалось, что окну хватит того же пути; не хватает: у экрана время выбирают в поле, словами его никто не проговаривает, и составлять русскую фразу ради обратного разбора значит проверять разбор вместо намерения. Время поэтому приходит меткой: часовой пояс у оболочки и ядра один, они на одной машине.
commands.builtin — по той же причине. Перечень встроенного — это фразы, которые говорят Рине, то есть часть её словаря; подписями интерфейса они не являются (4.0-F08). Оболочка, знающая их наизусть, показала бы то, чего ядро уже не понимает, и пользователь сказал бы это вслух впустую.
setup.state — вопрос о сеансе. Ключ first_run помечен секретным и наружу через settings.get не отдаётся, и правильно: это состояние хранилища, пользователь его не правит. «Прошёл ли пользователь настройку» — вопрос той же природы, что «на связи ли ядро», и у него своя дверь; проделывать для него дыру в чужой незачем.
models.fetch возвращается сразу и своих событий не заводит. Скачивание — минуты, и вызов, ждущий его, держал бы канал управления всё это время. Ход идёт обычным task.progress, остановка — обычным task.cancel (§9, и см. «Скачивание модели» в §10). Своё событие прогресса было написано здесь во второй раз и снова убрано: правило §10 старше этой задачи, и проверка test_wire поймала расхождение раньше, чем оно дожило до оболочки.
Что скачивает движок сам — в каталоге есть, но models.fetch его пропускает. Whisper тянет свою модель при первом обращении, и этой передачей мы не управляем. Сказать «начал», не начав, значило бы оставить оболочку ждать прогресса, которого не будет, и завести кнопку отмены, которая ничего не отменяет.
commands.kinds — то же правило, что у settings.options. Виды команд и системные действия перечисляет ядро, потому что выполнять их ему; оболочка, знающая список наизусть, разошлась бы с ядром молча — показала бы действие, которого больше нет, или спрятала бы новое. Там же сказано, какое действие необратимо: подтверждение спрашивает ядро (§11), но пользователь должен видеть это ещё в конструкторе, до первого срабатывания.
settings.options отделяет постоянное от изменчивого. describe говорит, что верно всегда: тип, умолчание, зависимость. Какие голоса установлены и какие движки собрались — верно сегодня и на этой машине, и меняется от того, какой движок выбран прямо сейчас. Держать изменчивое полем схемы значило бы однажды закешировать его вместе с постоянным и показать пользователю голоса удалённого движка. Схема поэтому говорит лишь dynamic: true — «набор есть, спроси отдельно».
Перечисляет тот, кто знает. Голоса и движки перечисляет ядро: у оболочки нет ни моделей, ни их каталогов. Устройства записи и воспроизведения перечисляет оболочка: звуковая подсистема в 4.0 принадлежит ей (4.0-F09), ядро их не видит вовсе. Это не исключение из ADR 0006, а его прямое следствие — смысл у того, кто им владеет.
Экспорт отдаёт содержимое; файл пишет оболочка. Диалог выбора места — работа оболочки, ядро его и не умеет; ядро отдаёт то, что выгружают. То же для импорта, с одной оговоркой: команда, чей идентификатор уже есть, пропускается. «Перенести на другую машину» и «затереть настроенное» — разные намерения, и по умолчанию верно второго не делать.
Ядро → оболочка (события)
Соответствуют событиям core/protocol.py версии 3.1.0. Сверяется автоматически (4.0-D11): каталог провода, перечень 3.1.0 и эта таблица обязаны совпадать — три списка, которые никто не сверяет, разъезжаются.
Нагрузка события проверяется у отправителя. Событие с неверными полями — дефект отправляющей стороны, и узнать о нём лучше дома; несоответствие даёт protocol.invalid_payload. У получателя же сломанное событие роняет ровно себя: обрывать канал из-за одного испорченного уведомления хуже, чем это уведомление потерять.
Лишнее поле в нагрузке — ошибка, в отличие от лишнего поля конверта (§3). Разница в том, что растёт по-разному: конверт общий для всех сообщений и меняется версиями протокола, а нагрузка принадлежит конкретному событию и растёт вместе с ним. Лишнее поле здесь значит, что отправитель считал, будто сообщает одно, а сообщает другое.
| Событие | Полезная нагрузка | |
|---|---|---|
listening.started / listening.stopped | — | |
listening.capturing | active | |
listening.always | enabled | |
listening.conversation | open, seconds | Разговор открыт: слово активации пока не нужно (4.0b-E06). seconds — сколько его осталось. Открытое ухо обязано быть видимым, поэтому о нём сообщает отдельное событие |
speech.recognized | text | |
assistant.response | text | |
assistant.error | text | |
assistant.thinking | active | |
history.changed | — | |
reminder.fired | item | |
command.step | path, state | Какой шаг сценария идёт сейчас (4.0b-A09). Путь — индексы через точку: 2.steps.0 есть первый шаг внутри третьего узла. state: running, done, failed. Нужен окну, чтобы показать ход пробы на холсте; ничего не хранится |
apps.not_found | query | |
window.action | action (screenshot, minimize, show, quit) |
Ядро → оболочка (запросы)
| Метод | Зачем |
|---|---|
apps.index | индекс программ живёт в оболочке, решение о запуске — в ядре (4.0-G06) |
apps.launch | запуск процесса — работа системного слоя оболочки |
system.do | громкость, медиа, питание, снимок экрана (4.0-G01..G03) |
permission.request | см. §11 |
stream.open / stream.close | поток речи из ядра (§8) |
Возможность system объявляет оболочка, и объявляет честно: у оболочки без Windows её не будет, и ядро узнает об этом из рукопожатия, ему не придётся гадать по молчанию в ответ. Решение — ADR 0009: машину трогает оболочка, решает, что с ней сделать, ядро.
Это не §12. Канал актуации — управление компьютером как таковым: движения мыши, ввод текста, чтение экрана; его правила строже, и в 4.0 он не включён. Системные действия — короткий закрытый список, каждый пункт которого назван заранее и ничем не параметризован: «прибавить громкость» нельзя направить не туда, а «переместить мышь в точку» — можно. Смешивать их в одну возможность значило бы либо ослабить правила актуации, либо потребовать подтверждения на каждое «сделай потише». Необратимое из списка (выключение, перезагрузка, сон) идёт через подтверждение (§11), как и раньше.
Ответ оболочки — факт. system.do отвечает ok и подробностью; «Прибавила громкость» составляет ядро, потому что это реплика Рины (ADR 0007).
7. Потоковый текст
Ответ модели приходит по частям, чтобы речь начиналась раньше, чем ответ дописан. Реализуется в 4.0 (4.0-D06), потребитель появляется в 5.0.
{"type": "event", "method": "stream.chunk", "stream_id": 7,
"payload": {"text": "Столица Австралии — "}}
{"type": "event", "method": "stream.end", "stream_id": 7,
"payload": {"reason": "done"}}
reason: done, cancelled, failed. При failed рядом идёт error с тем же stream_id.
Поток открывается ответом на запрос, который его породил: {"stream_id": 7}. Получатель обязан выдержать stream.chunk до того, как обработал ответ, открывший поток — сообщения асинхронны.
8. Канал данных и обратное давление
Двоичный поток открывается управляющим сообщением и живёт до закрытия.
{"method": "stream.open", "payload": {
"kind": "audio.input", "stream_id": 11,
"format": {"encoding": "pcm_s16le", "rate": 16000, "channels": 1}
}}
Виды: audio.input (микрофон, оболочка → ядро), audio.output (синтез, ядро → оболочка), screen.frame (5.0, оболочка → ядро).
Кредитная схема (4.0-D08). Приёмник объявляет, сколько байт готов принять; отправитель не имеет права держать в полёте больше:
{"method": "stream.credit", "stream_id": 11, "payload": {"bytes": 65536}}
Начальный кредит — 0. Отправитель молчит, пока не получит первый кредит.
Именно ноль, без аванса: приёмник, который ещё не готов, не должен получать данные вовсе. Аванс превратил бы ошибку «забыли выдать кредит» в редко воспроизводимую — она проявлялась бы только на потоках длиннее аванса.
Кредит выдаётся по мере обработки. Получение кадра кредита не даёт. Кредит за то, что лежит необработанным в буфере, — это и есть та неограниченная очередь, ради устранения которой схема существует.
Кредит считается по потоку. Микрофон и синтез идут одновременно и в разные стороны; общий счёт связал бы их скорости друг с другом без всякой на то причины. И считается он в байтах: память съедают байты, и поток из тысячи мелких кадров ничем не лучше десяти крупных того же объёма.
Без всего этого быстрый источник кадров или звука переполняет очередь и утаскивает память — отказ, который снаружи выглядит как «программа съела гигабайт», а изнутри не выглядит никак.
Закрытие: stream.close с stream_id. Обрыв канала данных закрывает все потоки.
Перебивание — не закрытие потока (4.0b-E12). Событие speech.stop означает «оборви речь и выброси недоигранное»: поток остаётся открытым, потому что следующая реплика пойдёт по нему же. Закрытие — это конец разговора с устройством, а перебивание — реплика в этом разговоре.
{"type": "event", "method": "speech.stop", "payload": {}}
Обратного подтверждения нет: перебивание либо случилось у получателя, либо играть уже было нечего, и третьего исхода не бывает.
9. Долгие задачи
Кодинг-задача в RinaNeuro идёт минутами. Модель «запрос-ответ» этого не выражает, поэтому lifecycle закладывается сейчас (4.0-D09), а проверяется заглушкой на 60 секунд.
accepted ──> running ──┬──> done
├──> failed
└──> cancelled
(progress, partial — сколько угодно раз)
| Сообщение | Тип | Содержимое |
|---|---|---|
| ответ на запрос | response | task_id, status: "accepted" |
task.progress | event | task_id, fraction (0..1, необязательно), note |
task.partial | event | task_id, result — промежуточный результат |
task.done | event | task_id, result |
task.failed | event | task_id, error |
task.cancelled | event | task_id |
Ровно одно из done/failed/cancelled завершает задачу.
Отмена (4.0-D10)
запрос task.cancel ──> response {accepted: true} ──> … фактическая остановка … ──> event task.cancelled
Шагов три: «отменено» приходит, когда задача действительно остановилась. Подтверждение получения запроса — не то же самое, что остановка, и путать их нельзя: отмена, которая молча ничего не делает, хуже отсутствия отмены.
Ответ на task.cancel говорит, принята ли просьба, и называет состояние задачи:
{"accepted": true, "status": "running"}
{"accepted": false, "status": "done"}
{"accepted": false, "status": "unknown"}
Если задача завершилась сама раньше, чем отмена доехала, приходит done, а task.cancelled не приходит вовсе. Запрашивающая сторона обязана это выдержать — и accepted: false говорит ей об этом сразу, вместо true, за которым никогда не последует task.cancelled. Обещание, которого никто не собирался выполнять, хуже честного отказа.
На неизвестный task_id приходит accepted: false со статусом unknown, ошибки нет. Чаще всего это задача, о которой сторона уже забыла; отвечать на обычную гонку ошибкой значит отправлять отладку по ложному следу.
Отмена кооперативная. Задача сама замечает просьбу и останавливается там, где это безопасно: прервать чужую работу в произвольной точке значит оставить после себя недописанный файл. Проверять просьбу задача обязана перед каждым шагом — та, что замечает её после последнего, формально кооперативна и практически бесполезна.
10. Push-события
Ядро отправляет без запроса. Первый настоящий потребитель — сработавшее напоминание (4.0-E05), и хорошо, что он появляется уже в 4.0: канал проверяется на живой задаче.
Позже сюда приходят инициатива Рины и смена эмоционального состояния (этап B RinaNeuro).
Скачивание модели (4.0-E05)
Модель распознавания — сто сорок мегабайт для Whisper, около сорока пяти для Vosk. На небыстрой линии это минуты, а минуты без единой строки на экране неотличимы от зависшей программы. Поэтому ход объявляется событиями, внутри вызова, который собирался распознать фразу, он не прячется.
share даётся вместе с done и total, чтобы оболочке не пришлось делить и решать, что показывать при неизвестном размере; при неизвестном он ноль. name — чтобы можно было сказать, что качается; «идёт загрузка» ничего не объясняет.
Своих событий у скачивания нет, и это решение. Ход идёт обычным task.progress (§9), отмена — обычным task.cancel. Скачивание — такая же длящаяся работа, как ответ модели или разбор архива: доля в fraction, пояснение в note — «скачиваю модель Whisper, 12 из 140 МБ». Три события model.* были написаны и убраны до первого применения: они говорили бы то же самое вторым способом, а два способа сказать одно — это ровно тот случай, когда один из них однажды забудут поддержать.
Скачивание никогда не начинается само. Выбор движка в списке не есть согласие потратить сто сорок мегабайт чужого трафика; согласие спрашивается отдельно.
Форма reminder.fired.item (4.0-E05)
| Поле | Тип | Смысл |
|---|---|---|
id | строка | rem_ и шесть знаков |
kind | строка | timer, reminder или alarm |
text | строка | о чём напомнить; пустая у голого таймера |
fire_at | число | когда сработать, секунды с эпохи; 0 у привязанного к поводу |
on | объект или null | повод вместо часов (4.0b-A03): {kind, app, launch} |
created_at | число | когда завели |
done | логическое | сработало ли; в событии всегда true |
warned | список чисел | какие предупреждения заранее уже сказаны, в секундах до срока |
Предупреждения помнит сама запись. Планировщик перезапускается вместе с программой, и пользователь, оставивший Рину на ночь, слышал бы «через три часа» каждое утро заново. Список закрыт: секунды берутся из AHEAD, и значение не из него ничего не означает.
У on и fire_at ровно один хозяин. Напоминание срабатывает либо по часам, либо по поводу; у привязанного к поводу часов нет вовсе, и 0 здесь означает «не по часам». 1970 год тут ни при чём. Если бы оно значило время, планировщик счёл бы такую запись просроченной на полвека и разбудил бы пользователя через секунду после того, как тот её завёл.
Повод сравнивается по пути запуска. Имя окна для этого не годится: заголовок пользователь меняет сам, открыв в редакторе чужой файл; имя программы он говорит как придётся. Путь разрешается один раз, в момент заведения, тем же индексом, что и запуск, — дальше сравнение точное и без догадок. Регистр не учитывается: Windows его не различает.
Форма сверяется с тем, что действительно кладёт хранилище: набор полей у события и у ReminderStore.add обязан совпадать, иначе оболочка однажды получит то, чего не ждала. Проверка — в tools/test_wire.py.
Срабатывание открывает свою цепочку трассировки. Напоминание никем не вызвано — оно само начало действия, и всё, что за ним последует, принадлежит одной цепочке. Иначе сработавший ночью будильник останется в журнале набором несвязанных строк.
Планировщик живёт в ядре. Напоминание, поставленное голосом, обязано сработать независимо от того, открыто ли окно: оболочка вправе быть свёрнутой в трей или перезапускаться.
Событие ничего не гарантирует о доставке: если оболочка перезапускается, событие теряется. То, что терять нельзя, хранится в состоянии и запрашивается после переподключения.
11. Канал разрешений
Ядро не может выполнить опасное действие само и не умеет показывать окна. Оно просит, оболочка спрашивает пользователя, решение возвращается ядру.
{"type": "request", "method": "permission.request", "payload": {
"request_id": "ask-7Kd2p",
"permission": "system.power",
"action": "shutdown",
"reason": "Пользователь сказал «выключи компьютер»",
"preview": "Компьютер будет выключен немедленно.",
"ttl": 60
}}
Ответ оболочки:
{"payload": {"request_id": "ask-7Kd2p", "granted": true, "scope": "once"}}
confirmation_id по каналу не ходит вовсе. Первая редакция этого раздела возвращала его в ответе оболочки — то есть оболочка его и порождала. Это прямо противоречило 4.0-C05, где записано: решение о том, подтверждено ли действие, не должно приниматься на стороне оболочки, иначе его можно обойти со стороны интерфейса. Оболочка, выпускающая идентификаторы, может выпустить любой, и ядру нечем отличить настоящий от выдуманного, — а вся однократность и привязка к аргументам держатся ровно на том, что идентификатор выдан ядром под конкретный вызов.
Поэтому: номер просьбы (request_id) выпускает ядро, оболочка отвечает «да» или «нет», и только после «да» ядро выписывает подтверждение — себе, внутрь. Оболочка не может создать подтверждение; она может лишь разрешить его создать.
confirmation_id одноразовый. Инструмент, требующий подтверждения, отклоняет вызов без действительного идентификатора — даже если вызов пришёл изнутри ядра. Это 4.0-C05, и его критерий приёмки: попытка выполнить power_action без подтверждения падает с явной ошибкой в тесте.
Аргументы вызова в просьбу не входят. Оболочке нужно показать пользователю preview, то есть словами, что именно произойдёт; сериализованный вызов ему ни к чему; отпечаток аргументов остаётся в ядре, там же, где проверяется.
scope: once — одно исполнение; until — до истечения срока, сколько угодно раз.
Областей две. «До конца сессии» убрано: §13 и так говорит, что выданные разрешения переподключение не переживают, то есть сессия — верхняя граница любой области. Третье имя для того же поведения — приглашение считать, будто оно другое.
Опасному действию until не выдаётся. Разрешение, действующее полчаса на выключение компьютера, — это и есть тот случай, ради которого подтверждение заводили. Если оболочка попросит такую область, ядро понижает её до once и отмечает это флагом downgraded: пользователь согласился на это действие, и терять его согласие незачем, а расширять — незачем тем более.
Оболочка обязана показать preview, то есть что именно произойдёт; одного названия действия мало. Отказ по умолчанию: истёкшее окно равносильно «нет», и поздний ответ «да» разрешения не даёт. Не «ждём дальше» и не «раз молчит, значит согласен»: молчание может означать, что окна вообще никто не увидел.
12. Канал актуации — только спецификация
Форма сообщений синтеза ввода и захвата экрана описывается сейчас, реализуется в 5.0 (4.0-D13). В 4.0 методы существуют и отвечают protocol.unknown_method, а возможность actuation не объявляется.
| Метод | Полезная нагрузка |
|---|---|
actuation.session.begin | scope (окно или область), duration, confirmation_id |
actuation.session.end | session_id |
actuation.input.click | session_id, x, y, button |
actuation.input.type | session_id, text |
actuation.input.key | session_id, combo |
actuation.screen.capture | session_id, monitor или window_id |
window.list / window.focus | — |
Смысл описывать заранее: модель разрешений вокруг этих сообщений продумывается, пока это стоит абзаца. Каждый метод требует действительного confirmation_id и живой сессии актуации; вне области действия вызов отклоняется и записывается в журнал.
13. Живость, обрыв, переподключение
Heartbeat. Обе стороны шлют ping при тишине дольше 5 секунд; получатель отвечает pong. Три пропущенных подряд — сторона считается мёртвой.
Молчание — не признак смерти. Признак смерти — молчание в ответ на прямой вопрос, поэтому ping шлётся только после паузы, а мёртвой сторона считается после трёх неотвеченных подряд: одна потеря может случиться от чего угодно, три подряд — уже закономерность.
Любое сообщение считается за pong. Занятый канал пинговать незачем: собеседник, только что приславший событие, жив не менее убедительно. Отсюда счётчик тишины вместо таймера по расписанию.
Ядро упало. Оболочка обнаруживает обрыв, показывает состояние (4.0-F12), перезапускает ядро и повторяет рукопожатие. Окно не должно выглядеть зависшим — это требование 4.0-D14.
Оболочка упала. Ядро видит обрыв и завершается: без оболочки оно не нужно и не должно оставаться висеть.
Что переживает переподключение. Настройки, команды, история, напоминания, плагины — они в хранилище. Что не переживает: незакрытый уточняющий вопрос, открытые потоки, выданные разрешения, незавершённые задачи. После рукопожатия состояние собирается заново запросами; по памяти оно не восстанавливается.
Так решено намеренно, реализацию это не упрощает: после обрыва неизвестно, что успело произойти на той стороне, и разрешение, выданное до обрыва, относится к разговору, которого больше нет. Память пережившей стороны — не источник правды о том, что происходит у собеседника.
Задачи при обрыве забываются, их не отменяют: отменить — значит отправить task.cancelled, а отправлять некому.
Перечисленное собрано в одном месте (core/wire/liveness.py) намеренно. Разложенное по владельцам, оно сбрасывалось бы в нескольких местах, и однажды где-то не сбросилось бы — причём незаметно: пережившее обрыв разрешение выглядит как обычное разрешение, и обнаружится оно только тем, что сработает.
14. Сквозная трассировка
trace_id рождается там, где началось действие — нажатие в оболочке или распознанная фраза, — и переносится во все сообщения, порождённые этим действием, включая события и ошибки. Оба слоя пишут его в журнал.
Это единственный способ отлаживать двухпроцессную систему: без него в двух журналах лежат два несвязанных набора строк.
15. Требования к реализации
Из этой спецификации выводятся conformance-тесты (4.0-D16): mock-оболочка против настоящего ядра и mock-ядро против настоящей оболочки. Набор — tools/conformance.py; обе стороны в нём видят только байты, потому что требование, выполненное вызовом функции, но не выполненное через провод, не выполнено.
Транспорт — внутри процесса, без именованного канала: ADR 0002 требует, чтобы протокол от транспорта не зависел, и conformance обязан это доказывать.
Но независимость протокола от транспорта не отменяет проверки самого транспорта. 4.0-F02 завёл вторую проверку — shell/Rina.Protocol.Probe, где настоящая оболочка идёт против настоящего ядра через именованный канал. Первый же её прогон нашёл то, чего внутрипроцессный набор поймать не мог по построению: у синхронного дескриптора Windows операции сериализуются, и пока ядро висело в чтении, его же запись ждала завершения этого чтения. Push-события §10 не работали вовсе — ядро молчало, пока оболочка чего-нибудь не пришлёт, и выглядело это как «задумалось».
Проверяется как минимум следующее.
3a. Оболочка, объявляющая [1, 2], работает с ядром, объявляющим [1], по версии 1. Без этой проверки обязательство ADR 0004 держать предыдущую версию остаётся декоративным.
- Каждое сообщение несёт полный конверт; отсутствие обязательного поля — ошибка
protocol. - Неизвестный метод даёт
protocol.unknown_method; неизвестное событие игнорируется молча. - Несовместимая версия протокола даёт понятное сообщение; обрывом она не заканчивается.
- Возможность, не объявленная в рукопожатии, не вызывается.
- Отправитель не превышает выданный кредит.
- Поток PCM не ухудшает задержку управляющего канала.
- Задача на 60 секунд отдаёт прогресс, промежуточный результат и завершается ровно одним финальным событием.
- Отмена даёт подтверждение получения, затем
task.cancelled; задача, завершившаяся раньше, даётdoneи не даётcancelled. - Опасное действие без действительного
confirmation_idотклоняется. - Просроченный
confirmation_idотклоняется. - Падение ядра приводит к перезапуску и новому рукопожатию без потери пользовательских данных.
trace_idприсутствует во всей цепочке от запроса до финального события.
16. Что осталось открытым
~~Формат reminder.fired.item~~ — закрыт в 4.0-E05, форма описана в §10. Отдельного приведения к JSON не потребовалось: хранилище с самого начала клало простые значения, и это оказалось проверяемым фактом.
4.0-E06a— владеет ли ядро описанием формы настроек или только значениями. От этого зависит содержимоеsettings.describe, и до решения метод описан лишь по имени.- Формат декларативной страницы плагина —
4.0-H01. Словарь API v2 из 3.1.0 (title,text,note,items,button,input,table,progress,badge,divider) семантичен и переносится почти целиком, но он плоский: контейнеров, колонок и группировки в нём нет. Решается после4.0-R05, когда станет известно, из чего состоит новый дизайн.
Справочник контракта
Собран из docs/protocol/contract-v1.json: по этому файлу сверяются обе стороны, и из него же порождается часть протокола на C#. Версия протокола — 1.
Возможности и методы
Метод принадлежит возможности; сторона — кто его исполняет.
| Возможность | Исполняет | Методы |
|---|---|---|
actuation | ядро | actuation.input.click, actuation.input.key, actuation.input.type, actuation.screen.capture, actuation.session.begin, actuation.session.end, window.focus, window.list |
apps | оболочка | apps.index, apps.launch |
apps.watch | ядро | system.foreground |
audio.input | оболочка | — |
audio.output | оболочка | — |
commands | ядро | commands.builtin, commands.delete, commands.export, commands.import, commands.kinds, commands.list, commands.save, commands.set_enabled, commands.try |
history | ядро | history.clear, history.export, history.list |
llm | ядро | — |
permissions | оболочка | permission.request |
plugins | ядро | plugins.action, plugins.home, plugins.install, plugins.list, plugins.page, plugins.set_enabled |
privacy | ядро | privacy.export, privacy.forget, privacy.inventory |
reminders | ядро | reminders.cancel, reminders.create, reminders.list |
sessions | ядро | sessions.finish, sessions.list |
stt | ядро | speech.listen_once, speech.set_always_listen |
system | оболочка | system.do |
system.context | оболочка | system.context |
tasks | ядро | task.cancel |
todo | ядро | todo.add, todo.close, todo.list, todo.remove |
tts | ядро | speech.say, speech.test |
window.actions | оболочка | — |
События и их поля
| Событие | Поля |
|---|---|
apps.not_found | query: string |
assistant.error | text: string |
assistant.response | text: string |
assistant.thinking | active: boolean |
command.step | path: stringstate: string, одно из done, failed, running |
history.changed | — |
listening.always | enabled: boolean |
listening.capturing | active: boolean |
listening.conversation | open: booleanseconds: number |
listening.started | — |
listening.stopped | — |
reminder.fired | item: object |
speech.recognized | text: string |
speech.stop | — |
stream.chunk | text: string |
stream.credit | bytes: integer, от 1 |
stream.end | reason: string, одно из cancelled, done, failed |
task.cancelled | task_id: string |
task.done | result: anytask_id: string |
task.failed | error: objecttask_id: string |
task.partial | result: anytask_id: string |
task.progress | fraction: number, необязательное, от 0.0, до 1.0note: stringtask_id: string |
window.action | action: string, одно из minimize, quit, screenshot, show |
Ошибки
Категория говорит, чья это ошибка; «повторять» — имеет ли смысл попытка с теми же данными.
| Код | Категория | Повторять |
|---|---|---|
app.launch_failed | system | да |
app.not_found | user | нет |
calc.zero_division | user | нет |
confirmation.expired | user | нет |
confirmation.invalid | protocol | нет |
confirmation.required | user | нет |
internal | system | да |
llm.remote_address | user | да |
llm.unavailable | system | да |
permission.denied | user | нет |
permission.required | user | нет |
plugin.not_found | protocol | нет |
protocol.frame_too_large | protocol | нет |
protocol.incompatible | protocol | нет |
protocol.invalid_envelope | protocol | нет |
protocol.invalid_payload | protocol | нет |
protocol.invalid_state | protocol | нет |
protocol.not_ready | protocol | да |
protocol.unknown_method | protocol | нет |
settings.invalid_value | user | нет |
settings.unknown_key | protocol | нет |
stt.failed | system | да |
stt.unavailable | system | нет |
task.cancelled | user | нет |
tool.invalid_arguments | protocol | нет |
tool.unknown | protocol | нет |
transfer.too_new | user | нет |
transfer.unreadable | user | нет |
transfer.wrong_kind | user | нет |
tts.unavailable | system | нет |
Виды потоков
| Вид | Возможность |
|---|---|
audio.input | audio.input |
audio.output | audio.output |
screen.frame | actuation |
Области разрешения (§11): once, until.