calendar · v1.0.12 · 2026-10-16 · sha256 6ce5d7ee4071a13d

calendar v1.0.12A

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

---
name: calendar
description: Единый календарь владельца — свои локальные календари, Яндекс (CalDAV) и Google (личный OAuth-клиент); события, повторения, свободные окна, служебные события-распорядок, напоминания без модели, виджет День/Неделя.
version: 1.0.12
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 на час; при открытии сегодняшнего дня и смене масштаба линия текущего времени оказывается в видимой области. Для дат вне текущего дня линия не рисуется.