Rina Assistant

Протокол ядра

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

Собрано из docs/protocol/PROTOCOL-v1.md, docs/protocol/contract-v1.json (коммит 5f750e7), 30 сентября 2026. Текст документа дословный, справочник в конце собран из контракта.

  • Версия 1
  • Методов 68
  • Событий 23
  • Ошибок 30
  • Возможностей 20
Разделы
  1. 1. Общие правила
  2. 2. Каналы и кадрирование
  3. 3. Конверт
  4. 4. Рукопожатие и согласование возможностей
  5. 5. Ошибки
  6. 6. Управляющие методы
  7. 7. Потоковый текст
  8. 8. Канал данных и обратное давление
  9. 9. Долгие задачи
  10. 10. Push-события
  11. 11. Канал разрешений
  12. 12. Канал актуации — только спецификация
  13. 13. Живость, обрыв, переподключение
  14. 14. Сквозная трассировка
  15. 15. Требования к реализации
  16. 16. Что осталось открытым
  17. Справочник контракта
  • Задача плана: 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 и errorid запроса, на который это ответ
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точечный идентификатор для машины, стабилен между версиями и языками
categoryuser — пользователь может исправить; system — окружение; protocol — дефект одной из сторон
retryableимеет ли смысл повторить то же самое
messageтекст для пользователя, на языке из рукопожатия
detailsструктурированные подробности

Код и текст разделены намеренно. Текст переводится и переформулируется, код — нет; логика ветвится по коду, пользователь читает текст. Позже по details сможет осмысленно исправиться языковая модель, вместо того чтобы зациклиться (5.0-C05).

protocol.invalid_envelope добавлен при реализации 4.0-D04: требование §15.1 объявляло отсутствие обязательного поля ошибкой категории protocol, но кода для неё в каталоге не было. Поля, которых не хватило, перечисляются в details.fields — иначе сообщение «конверт неполон» отправляет отлаживающего искать вручную.

retryable значит «имеет ли смысл повторить то же самое». Поможет ли что-то другое, это поле не говорит. Просроченное подтверждение неповторяемо: тот же вызов с тем же идентификатором провалится снова. Получить новое подтверждение и позвать заново, разумеется, можно — но это уже не повтор.

Начальный каталог:

КодКатегорияПовторКогда
protocol.incompatibleprotocolнетобщей версии протокола нет
protocol.unknown_methodprotocolнетметода нет либо его возможность не объявлена
protocol.frame_too_largeprotocolнеткадр больше предела канала
protocol.not_readyprotocolдарукопожатие ещё не состоялось
protocol.invalid_envelopeprotocolнетконверт неполон или не разбирается
protocol.invalid_payloadprotocolнетнагрузка не соответствует объявленной форме события
protocol.invalid_stateprotocolнетсообщение не к месту: поток закрыт, задача уже завершена
permission.denieduserнетразрешение не выдано
permission.requireduserнетдействие требует разрешения, которого не спрашивали
confirmation.requireduserнетопасное действие вызвано без подтверждения
confirmation.invalidprotocolнетподтверждение выдано под другой вызов или аргументы
confirmation.expireduserнетсрок истёк; повтор того же не поможет, нужно новое
tool.unknownprotocolнетвызвана несуществующая возможность
tool.invalid_argumentsprotocolнетаргументы не проходят схему инструмента
transfer.wrong_kinduserнетфайл не того вида: это не выгрузка команд
transfer.too_newuserнетфайл сделан более новой версией — обновите приложение
transfer.unreadableuserнетфайл не разобрать: не похоже на выгрузку Рины
settings.unknown_keyprotocolнеттакой настройки нет
settings.invalid_valueuserнетзначение не проходит ограничения ключа
llm.remote_addressuserдаадрес модели не локальный: значение принято, пользователя предупреждают
plugin.not_foundprotocolнетплагина с таким номером нет: список устарел или его удалили
app.not_founduserнетпрограмма не найдена в индексе
app.launch_failedsystemдазапуск сорвался
stt.unavailablesystemнетраспознавания нет
stt.failedsystemдараспознать не удалось
tts.unavailablesystemнетсинтеза нет
llm.unavailablesystemдамодель недоступна
task.cancelleduserнетзадача снята по просьбе пользователя
calc.zero_divisionuserнетделение на ноль в выражении
internalsystemданепредусмотренный сбой

Каталог сверяется с кодом, на веру его никто не держит. 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.handletext, source (typed/voice/always), require_wakeпринято в обработку
command.run_by_idcommand_idпринято
speech.listen_once—принято
speech.set_always_listenenabledфактическое состояние
speech.saytextпринято
speech.test—произнести пробную фразу: получилось ли и какую
settings.describe—описание настроек, см. 4.0-E06a
settings.optionskeysкакие значения ключ принимает сейчас
settings.getkeysзначения
settings.reset—сброс группы настроек; команды, история и плагины не трогаются
settings.setvaluesприменённые значения
reminders.list—список
reminders.createtext, fire_at, kindзаведённое напоминание
reminders.cancelid либо allсколько снято
system.foregroundlaunchсколько напоминаний сработало и следим ли (4.0b-A03)
plugins.list—список с состоянием
plugins.set_enabledplugin_id, enabledсостояние
todo.list—дела целиком, вместе с закрытыми (4.0b-A13)
todo.addtextзаписать дело
todo.closetodo_id, doneпометить сделанным или вернуть в работу
todo.removetodo_idубрать дело совсем
sessions.list—сессии целиком, открытая помечена (4.0b-A02)
sessions.finishnoteзакрыть открытую сессию
plugins.pageplugin_idдекларативное описание страницы
plugins.home—плитки включённых плагинов для главного экрана (4.0b-A07); всё сразу, потому что главная рисует их вместе
plugins.actionplugin_id, action, valuesновое описание страницы
plugins.installsourceустановленный плагин
commands.list—список своих команд
commands.kinds—из чего команда бывает сделана: виды, системные действия, режимы совпадения
commands.builtin—что Рина умеет без настройки: фразы и что они делают
hotkeys.actions—чему можно назначить сочетание клавиш
setup.state—первый ли это запуск
setup.finish—мастер пройден, больше не показывать
models.catalogue—какие модели можно скачать, чего они стоят
models.fetchidsначать скачивание; ход — обычными task.*
commands.savecommandсохранённая команда; создание и правка — один метод
commands.trycommand — карточка целикомaccepted. Выполнить несохранённое — проба из конструктора (4.0b-A09). Ничего не сохраняет: ни команды, ни номера, ни счётчика. Единственный метод, принимающий саму карточку вместо ссылки на неё; почему это безопасно, разобрано в T-21
commands.deleteidудалено ли
commands.set_enabledid, enabledсписок после изменения
commands.export—содержимое файла целиком: kind, format, app_version, exported_at, payload.commands, payload.stats. Файл пишет оболочка
commands.importfile — содержимое, прочитанное оболочкойadded, skipped. Чужой вид файла отвергается кодом transfer.wrong_kind
system.contextquestion (foreground или running), aboutanswer — строка. Что сейчас открыто и запущено, для условий сценария (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.forgetgroup и ids — эти записи; один group — группу целиком; everything — всёforgotten — сколько записей ушло (4.0b-B02). Число, потому что «сделано» и «там ничего и не было» — разные ответы. Незнакомая группа тоже забывается: то, что пользователь видит и не может убрать, хуже непоказанного
privacy.export—содержимое файла целиком: kind (rina.everything), format, app_version, exported_at, payload.groups (4.0b-B03). Файл пишет оболочка. Выгрузка равна описи: файл, показывающий меньше страницы, превратил бы страницу в пересказ самой себя
history.listlimititems, total
history.clear—сколько стёрто
history.export—содержимое файла целиком: kind, format, app_version, exported_at, payload.history. Файл пишет оболочка
task.canceltask_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.capturingactive
listening.alwaysenabled
listening.conversationopen, secondsРазговор открыт: слово активации пока не нужно (4.0b-E06). seconds — сколько его осталось. Открытое ухо обязано быть видимым, поэтому о нём сообщает отдельное событие
speech.recognizedtext
assistant.responsetext
assistant.errortext
assistant.thinkingactive
history.changed—
reminder.fireditem
command.steppath, stateКакой шаг сценария идёт сейчас (4.0b-A09). Путь — индексы через точку: 2.steps.0 есть первый шаг внутри третьего узла. state: running, done, failed. Нужен окну, чтобы показать ход пробы на холсте; ничего не хранится
apps.not_foundquery
window.actionaction (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 — сколько угодно раз)
СообщениеТипСодержимое
ответ на запросresponsetask_id, status: "accepted"
task.progresseventtask_id, fraction (0..1, необязательно), note
task.partialeventtask_id, result — промежуточный результат
task.doneeventtask_id, result
task.failedeventtask_id, error
task.cancelledeventtask_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.beginscope (окно или область), duration, confirmation_id
actuation.session.endsession_id
actuation.input.clicksession_id, x, y, button
actuation.input.typesession_id, text
actuation.input.keysession_id, combo
actuation.screen.capturesession_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 держать предыдущую версию остаётся декоративным.

  1. Каждое сообщение несёт полный конверт; отсутствие обязательного поля — ошибка protocol.
  2. Неизвестный метод даёт protocol.unknown_method; неизвестное событие игнорируется молча.
  3. Несовместимая версия протокола даёт понятное сообщение; обрывом она не заканчивается.
  1. Возможность, не объявленная в рукопожатии, не вызывается.
  2. Отправитель не превышает выданный кредит.
  3. Поток PCM не ухудшает задержку управляющего канала.
  4. Задача на 60 секунд отдаёт прогресс, промежуточный результат и завершается ровно одним финальным событием.
  5. Отмена даёт подтверждение получения, затем task.cancelled; задача, завершившаяся раньше, даёт done и не даёт cancelled.
  6. Опасное действие без действительного confirmation_id отклоняется.
  7. Просроченный confirmation_id отклоняется.
  8. Падение ядра приводит к перезапуску и новому рукопожатию без потери пользовательских данных.
  9. 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_foundquery: string
assistant.errortext: string
assistant.responsetext: string
assistant.thinkingactive: boolean
command.steppath: string
state: string, одно из done, failed, running
history.changed—
listening.alwaysenabled: boolean
listening.capturingactive: boolean
listening.conversationopen: boolean
seconds: number
listening.started—
listening.stopped—
reminder.fireditem: object
speech.recognizedtext: string
speech.stop—
stream.chunktext: string
stream.creditbytes: integer, от 1
stream.endreason: string, одно из cancelled, done, failed
task.cancelledtask_id: string
task.doneresult: any
task_id: string
task.failederror: object
task_id: string
task.partialresult: any
task_id: string
task.progressfraction: number, необязательное, от 0.0, до 1.0
note: string
task_id: string
window.actionaction: string, одно из minimize, quit, screenshot, show

Ошибки

Категория говорит, чья это ошибка; «повторять» — имеет ли смысл попытка с теми же данными.

КодКатегорияПовторять
app.launch_failedsystemда
app.not_founduserнет
calc.zero_divisionuserнет
confirmation.expireduserнет
confirmation.invalidprotocolнет
confirmation.requireduserнет
internalsystemда
llm.remote_addressuserда
llm.unavailablesystemда
permission.denieduserнет
permission.requireduserнет
plugin.not_foundprotocolнет
protocol.frame_too_largeprotocolнет
protocol.incompatibleprotocolнет
protocol.invalid_envelopeprotocolнет
protocol.invalid_payloadprotocolнет
protocol.invalid_stateprotocolнет
protocol.not_readyprotocolда
protocol.unknown_methodprotocolнет
settings.invalid_valueuserнет
settings.unknown_keyprotocolнет
stt.failedsystemда
stt.unavailablesystemнет
task.cancelleduserнет
tool.invalid_argumentsprotocolнет
tool.unknownprotocolнет
transfer.too_newuserнет
transfer.unreadableuserнет
transfer.wrong_kinduserнет
tts.unavailablesystemнет

Виды потоков

ВидВозможность
audio.inputaudio.input
audio.outputaudio.output
screen.frameactuation

Области разрешения (§11): once, until.