calendar · v1.0.13 · 2026-10-16 · sha256 f9c8c2987858d2b1

calendar v1.0.13A

Immutable. This exact content never changes and is served at /api/v1/blob/f9c8c2987858d2b1.

---
name: calendar
description: Единый календарь владельца — свои локальные календари, Яндекс (CalDAV) и Google (личный OAuth-клиент); события, повторения, свободные окна, служебные события-распорядок, напоминания без модели, виджет День/Неделя.
version: 1.0.13
type: extension
runtime: python3
entry: plugin.py
plugin_api: "2.0"
permissions: [tool, route, widget, net, read_settings, companion_process, notify_owner]
env_from_settings: [YANDEX_CALDAV_ACCOUNTS, GOOGLE_CALENDAR_CLIENT_ID, GOOGLE_CALENDAR_CLIENT_SECRET, CALENDAR_TOKEN_KEY]
dependencies: [icalendar, python-dateutil, cryptography]
companion_processes:
  - name: calendar_worker
    command: [python3, scripts/worker.py]
    runtime: python3
    restart_policy: on_failure
    max_restarts: 5
timeout_sec: 60
when_to_use: >
  Владелец говорит «что у меня сегодня / завтра / на неделе», «поставь / создай / забей встречу, обед, тренировку на 16»,
  «найди окно на час», «перенеси … на 17», «отмени …», «напомни о …», «покажи мою занятость в рабочем календаре»,
  «обычно я завтракаю в 6:30» (служебное событие-распорядок), «подключи Яндекс / Google».
model_experience:
  what_model_sees: >
    8 tools cal_*: cal_status, cal_events, cal_create, cal_update, cal_delete, cal_free, cal_reminders, cal_settings.
    JSON {status,...}; времена ISO 8601 с offset владельца; now_local/today/timezone — в cal_status.
  token_effect: 8 компактных схем; ответы ≤50 событий / ≤10 окон, ≤15 000 символов.
ui_tab:
  tab_id: calendar
  title: Календарь
  icon: "📅"
  render:
    kind: module
    entry: widget.js
    start: auto
    appearance: host
    span: 2
---

# Календарь Уробороса

Один календарь **пользователя**: локальные календари Уробороса, аккаунты Яндекса (CalDAV) и Google (личный Cloud-проект владельца).
Собственные действия агента в календарь не попадают — для них есть Activity. Композиция с другими скиллами — только через агента:
другой сценарий просит тебя добавить или прочитать событие, ты вызываешь эти tools.

## Онбординг (делает владелец в Settings → Secrets, не в чате)

1. **Яндекс.** Создать пароль приложения «Календарь» (id.yandex.ru → Безопасность → Пароли приложений; новый пароль может активироваться до 2–3 часов).
   Положить один секрет `YANDEX_CALDAV_ACCOUNTS` — JSON-список аккаунтов:
   `[{"login": "you@yandex.ru", "app_password": "…", "alias": "личный"}]`. Несколько аккаунтов — несколько элементов списка.
   Затем: `cal_settings(action="reload_yandex", confirm=true)`.
2. **Google.** В своём Google Cloud: включить Calendar API, экран согласия External (для личного постоянного использования — In production;
   в режиме Testing токен живёт 7 дней), OAuth-клиент типа Desktop app. Положить `GOOGLE_CALENDAR_CLIENT_ID`, `GOOGLE_CALENDAR_CLIENT_SECRET`
   и `CALENDAR_TOKEN_KEY` (любая длинная парольная фраза — ею шифруются токены в данных скилла). Затем `cal_settings(action="connect_google", confirm=true)`
   → ссылка для входа; владелец открывает её в системном браузере (виджет открыть окно не может).
3. Грант на секреты выдаётся хостом после появления ключей; пока их нет — `cal_status` покажет `next_step`. Фоновый процесс получает
   секреты при запуске: после добавления ключа выключи и включи скилл (toggle) — `cal_status` сам подскажет, если фон ещё не видит ключ.

## Правила работы

- **Сначала `cal_status`.** Оттуда `now_local`, `today`, `timezone` — «сегодня/завтра/в четверг» считай от них, времена передавай ISO 8601
  (без offset = местное время владельца).
- **Любая запись — только по явной команде владельца или после его выбора (escalate) — `confirm: true`.** Из автоматических задач события не создаются.
  Уроборос может составлять внутренний план дня, но внешние записи появляются по команде или после принятия плана владельцем.
- **Куда писать.** `calendars` пустой → календарь по умолчанию; `'all'` и `'busy_set'` → календарь по умолчанию плюс сохранённый набор публикации
  занятости (5 A: «во все календари» значит «во все, где владелец велел показывать занятость», а не во все подряд);
  список имён/алиасов/id → конкретные. Первый календарь получает полное содержание, остальные — копии по правилу календаря
  (`publish_mode`: `busy` = «Занят», только интервал; `full` = всё; по умолчанию внешние календари — `busy`, свои локальные — `full`). Пожелания владельца («мою занятость показывай в рабочем Google и личном Яндексе»,
  «в рабочем — только “Занят”») сохраняй через `cal_settings(action="set_calendar", publish_busy=…, publish_mode=…)` и `preferences`, а перед записью читай их из `cal_status`.
  Смена набора не переписывает уже созданные события и серии — только по отдельной просьбе.
- **Служебные события** (`hidden=true`) — распорядок и заметки планировщика о жизни владельца: «обычно завтракаю в 6:30 по будням»
  (`availability="soft"`, `rrule="FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR"`), «дорога на встречу» (`availability="busy"`). В виджете они показываются
  по переключателю, скрытая занятость остаётся штриховкой. `soft` — предпочтение, не бронь: окно на это время предлагать можно, но скажи о нём.
  Другие сценарии (здоровье, еда, транспорт) добавляют и читают такие события через тебя; их логики в календаре нет.
- **Повторения.** Слова владельца определяют охват: «отмени в эту среду» → id вхождения (`…@дата`) и `scope=this`; «все эти встречи по средам» → `scope=all`;
  «со следующей недели пусть в 19» → id вхождения и `scope=following` (без id вхождения `following` отклоняется — непонятно, с какой даты).
  `scope=all` с id вхождения и новым временем сдвигает всю серию на ту же разницу (ранние вхождения не пропадают). Если непонятно — спроси: одну дату или всё расписание. Неразбираемое пользовательское RRULE отклоняется до записи; плавающий UNTIL читается в зоне DTSTART. Ошибка импортированной серии не останавливает напоминания остальных событий: её очередь сохраняется с предупреждением.
  Возвращённый статус `pending`/`conflict`/`deleted_partially` означает, что запись у внешнего провайдера ещё не подтверждена; не говори «удалено в Google/Яндексе», пока соответствующее назначение не стало `done`. Изменение встречи, которую организовал другой человек, и ответ на приглашение — разные действия: уточни, требуется ли убрать её из своего календаря или отклонить приглашение. У организатора отдельно уточни, нужно ли уведомить участников (`send_updates`).
- **Смена календарей события** (`cal_update(calendars=…)`) — по явной команде: добавляет копии по правилу календаря, снимает лишние копии,
  основное событие остаётся на месте; копии никогда не получают список участников и напоминания (гости не приглашаются повторно).
- **Свободные окна.** `cal_free` по календарям-источникам занятости и рабочим часам из настроек; рамки выбирай по смыслу просьбы (созвон ≠ тренировка),
  при недостатке контекста уточни. Если все источники занятости выключены, события не учитываются; явно переданные `sources` выбирают конкретные календари. При `availability_complete=false` окна предварительные: сначала проверь указанные конфликты/неразвёрнутые серии. Иначе предложи 2–4 варианта через escalate, потом `cal_create`.
- **Участники.** Приглашения и ответы (`cal_update(response=…)`) — только по явной просьбе или подтверждению; `send_invites`/`send_updates` без команды не включай. `cal_update(attendees=[])` очищает участников Google-события; отсутствие параметра сохраняет их. `send_updates=true` запрашивает уведомления Google также при удалении последнего гостя, но не подтверждает доставку писем.
- **Доступность ICS.** `TRANSPARENT` читается как `free`, `OPAQUE` и отсутствие свойства — как `busy`. Запись заменяет `TRANSP` по текущей доступности. `soft` передаётся как `OPAQUE`: у импортированного внешнего события следующая изменённая версия провайдера может вернуть `busy`; постоянное предпочтение задавай отдельным локальным событием.
- **Напоминания** доставляет Уроборос без запуска модели, по правилам `cal_reminders` и полю `reminders` события
  (правило по умолчанию владелец может править и в виджете — кнопка ⚙).
  Для встречи календарь отвечает за время и системную заметку (например, «через 10 минут встреча…») через `/notify`.
  В текущем ядре это строка `Notice · calendar` в чате владельца, зеркалируемая его транспортом и видимая следующему ходу агента; отдельный системный баннер не обещается. Отдельный скилл брифов может подготовить содержательный текст о той же встрече в Main. Календарь не генерирует бриф, а бриф не заменяет календарное напоминание. Если `cal_status → reminders.channel`
  не `ready`, напоминания хранятся и видны в виджете, а доставка требует хоста с `/notify` и гранта `notify_owner` — скажи об этом честно, не подменяй её агентными задачами.
  Своё поле события сильнее правил: `cal_create` и `cal_update` с `reminders=[15]` — свои минуты, `reminders=[]` — у этого события напоминаний нет
  («не напоминай про эту встречу»); только `cal_update(reminders="default")` возвращает к правилам. При создании неуказанное поле следует правилам; все прежние события тоже.
  Очередь сверяется с текущими временами: снятое время, перенос или отмена (и у внешнего события после синхронизации) убирают ещё не отправленное напоминание. Если уже отправленное/с неизвестным исходом напоминание относится к прежнему времени, реальный перенос создаёт новое напоминание для нового времени; правка названия без переноса не повторяет прежнюю отправку.
  Пока правил нет, напоминаний нет (19 A). Для внешнего календаря напоминания Уробороса появляются только после
  `cal_reminders(action="enable_calendar", calendar_id=…, confirm=true)` — режим «напоминает Уроборос» (20 A): времена провайдера по умолчанию
  переносятся в правило календаря, индивидуальные — остаются у событий как напоминания Уробороса, напоминания провайдера глушатся
  (Google — сразу настройки календаря, индивидуальные при следующей записи события; Яндекс — при следующей записи), оставшиеся ручные
  шаги (устройства, другие приложения) перечисляются в ответе. Без этого режима внешний календарь напоминает сам, как раньше.
- **Синхронизация.** Желаемое состояние, защита от фоновой перезаписи и очередь внешних операций сохраняются одной локальной транзакцией; сеть вызывается после неё. Все части одной полученной выдачи для связанной копии ставятся вместе, до отправки; временный отказ первой части не отбрасывает остальные. При отсутствии сети событие остаётся `pending`, интервалы повторов ограничены. Старая выдача провайдера не заменяет принятое во время её чтения изменение.
  Каждая отложенная запись серии включает принятые отмены её встреч, в том числе ещё ожидающие сети; перенос в новую серию сохраняет их. Подтверждённое отсутствие источника не отправляет обратное удаление того же источника. Если незавершённая правка связанной копии мешает принять удаление, следующая синхронизация заново проверяет источник, а не забывает его вместе с курсором.
  Даты исключения и добавления из провайдера остаются абсолютными при переносе серии. Замена списка исключений снимает только ранее наблюдавшиеся даты; отдельно подтверждённые отмены конкретных встреч сохраняются и при изменении правила повторения. Подтверждённая внешняя отмена убирает источник локально, без ответного удаления у провайдера; удаления связанных копий ставятся отдельно с сохранением их неподтверждённых правок.
  Принятые строки одной выдачи и порождённые ими намерения связанных копий фиксируются вместе: отказ до фиксации оставляет прежний снимок для повторного чтения, отказ после неё не теряет очередь. Разделение серии сначала дожидается прежних изменений её ресурсов, затем подтверждённого сокращения старой серии; новая часть и зависимые правки не отправляются после конфликта предшественника. Ожидание не означает успешную запись.
  При `conflict` вызови `cal_events(id=…)`: он читает текущую версию провайдера, не заменяя локальное намерение. Покажи `.local`, `.provider`, **все** `.operations`, `.affected_events`, `.provider_events` и `.choice_scope`; `.preserved_operations` остаются отдельными. Выбор касается перечисленной зависимой цепочки в одном календаре, а не только заголовка выбранной встречи.
  После выбора владельца вызови `cal_update(id=event_id, resolution="local"|"provider", resolution_token=…, confirm=true)` без иных правок. `local` заново ставит перечисленные неподтверждённые операции в их порядке. `provider` отменяет именно их: может убрать ещё не опубликованную новую часть серии и зависимые поздние правки, но не отменяет уже подтверждённые части и не переписывает связанные копии в других календарях. История отменённых намерений сохраняется как `resolved`, не `done`; независимые прежние правки не поглощаются этим выбором.
  Версия для условной записи закрепляется в намерении, а не выбирается заново при каждом повторе. Найденное создание с потерянным ответом признаётся только при совпадении содержания; чужое изменение становится конфликтом. Изменившийся содержательный снимок требует нового выбора; техническое ожидание очереди само его не отменяет. Отсутствие у провайдера не разрешает автоматическое пересоздание. Если сравнение сокращено (`comparison_incomplete`), токен не выдаётся.
  Недоступный календарь не считается пустым. Неисправленные постоянные отказы остаются `failed`, синхронизация их не затирает. Новая команда изменения после прежнего временного отказа создания сначала проверяет исходное сохранённое создание; только его подтверждение выпускает новую правку. Счётчик попыток и исходная версия сохраняются; один найденный адрес не означает успешное создание.
  Перед повтором отложенного изменения/RSVP исключения проверяется удаление самого исключения и его серии: устаревшая операция закрывается как заменённая отменой, без обращения к провайдеру. Это не отменяет уже начатую сетевую запись; восстановление идентичности после неизвестного создания и собственно удаление остаются отдельными операциями.
- **Ответы.** После записи перечитай событие и коротко подтверди: что, когда, в каких календарях, что ожидает.

Токены Google сохраняются зашифрованно: обновление, подключение и отключение аккаунта сериализуют чтение и замену хранилища. Сетевой вход идёт вне блокировки; поздний ответ старого подключения не возвращает отключённый аккаунт и не заменяет новое подключение.

## Ограничения версии

- Проверки синхронизации исполняют настоящие адаптеры с локальными имитаторами Google/CalDAV и SQLite, а виджет — через непрозрачный host iframe. Живые Google/Яндекс-аккаунты, OAuth и доставка писем этим не квалифицированы. Установка не подключает аккаунты автоматически. Один неисправный календарь может прервать текущий проход аккаунта; история операций пока не очищается автоматически.
- Связанная копия с уже неподтверждёнными изменениями защищена от следующей внешней выдачи. Если за время длительного отсутствия сети источник меняется несколько раз, более позднее изменение может не попасть в такую копию: после восстановления сравни оба календаря. Сбой локальной обработки откатывает всю текущую выдачу; большая выдача держит SQLite на время локальной обработки, не сетевых запросов.

- Ответ на приглашение в Яндексе записывается как статус участника в самом событии (CalDAV PARTSTAT); разошлёт ли Яндекс ответ организатору —
  зависит от сервера, скилл этого не обещает. Google отвечает штатно (`sendUpdates`).
- Напоминания приходят как **системные строки чата** через текущий Host Service `/notify` (контракт ядра, а не отдельный вызов модели).
  Telegram зеркалирует чат при настроенном транспорте; доставка и прочтение владельцем не гарантируются. Отдельных системных уведомлений компьютера эта версия не обещает.
  Companion перечитывает событие перед немедленным `POST /notify` (не более 400 символов). Устойчивый локальный ключ служит истории календарной очереди; хост не дедуплицирует немедленный запрос по `key`. При отсутствии `notify_version` в Host Service
  `cal_status → reminders.channel = no_route`: очередь хранится и догоняется после появления маршрута. Грант `notify_owner` выдаётся
  владельцем отдельно от установки. Подтверждённый ответ хоста означает принятие уведомления, **не прочтение владельцем**; если ответ
  потерян или процесс остановится между резервированием и отправкой, статус остаётся неизвестным и календарь не делает слепой повтор
  (по решению владельца 2B: лучше возможный пропуск, чем дубль).
- Виджет: `start: auto` оправдан — это лёгкий прибор (сетка без анимационного демона, опрос данных раз в 30 с), не программа, требующая `retain`. День и неделя используют прокручиваемую сетку высотой до 400 px с масштабами 32/44/64 px на час; при открытии сегодняшнего дня и смене масштаба линия текущего времени оказывается в видимой области. Для дат вне текущего дня линия не рисуется.