# Design: core-recording-lifecycle ## Context Репозиторий пуст (каркас OpenSpec, одна фикстура `test-01`). Ограничения из CLAUDE.md: mobile-first (iPhone), `isDesktopOnly: false`, никакого Node API в рантайме, `src/core/` без импортов `obsidian`, core покрыт vitest полностью, `src/obsidian/` — тонкая обёртка без юнит-тестов. Vault синхронизируется через iCloud между маком и iPhone, плагин стоит на обоих. ## Goals / Non-Goals **Goals:** - Запись не теряется: аудио и заметка сохраняются до любой сети. - Вся логика состояний, повторов, устройств и формата заметки — в core и под тестами. - Обёртка сводится к реализации четырёх портов и вызову `tick()` по событиям. **Non-Goals:** - Реальный провайдер, HTTP, коды ответа, переключение провайдеров (issue провайдера). - Команды, настройки, UI, `MediaRecorder`, реализация портов на `app.vault` (issue обёртки). - Периодический сброс записи на диск во время записи (отдельный spike на реальном iPhone: неизвестно, читается ли частичный mp4 из Safari `MediaRecorder`). - Конвертация форматов, удаление аудио, daily notes. ## Decisions ### Подход: чистое ядро + оркестратор на портах Машина состояний, планировщик и формат заметки — чистые функции. Поверх них асинхронный `QueueRunner` в core работает через порты. Отвергнуто: (A) полностью чистый редьюсер с эффектами — оркестрация уехала бы в нетестируемую обёртку; (C) XState — лишняя зависимость и вес бандла для ~8 состояний. ### Модули `src/core/` | Модуль | Ответственность | |---|---| | `record.ts` | тип `VoiceRecord`, `Status`, `transition(record, event, now)`, `MAX_ATTEMPTS = 5`, `BACKOFF_MS = [30s, 2m, 10m, 30m]`, `SENT_STALE_MS = 10m`, `NO_SPEECH = 'no-speech'` | | `schedule.ts` | `due(records, now, deviceId)`, `isStaleSent(record, now)`, `claimable(records, now, deviceId)` | | `note.ts` | `parseRecord(fields)`, `readVoiceFields(content)`, `renderNote(content, record, text?)`, построчный патч ключей `voice-*` во frontmatter, `initialNote(record)` | | `naming.ts` | `makeId(date, random)`, `extFromMime`, `audioPath`, `notePath(date, n)` | | `ports.ts` | интерфейсы портов (ниже) | | `capture.ts` | `saveCapture(ports, capture)` | | `queue.ts` | `QueueRunner` с `tick()`, `retry(notePath)`, `claimAll()` | ### Порты ```ts interface RecordingStore { write(path: string, data: ArrayBuffer): Promise; read(path: string): Promise; } interface NoteStore { exists(path: string): Promise; create(path: string, content: string): Promise; // Все заметки vault, у которых во frontmatter есть voice-id, с их frontmatter. // КОНТРАКТ: реализация MUST брать данные из metadataCache и MUST NOT читать // содержимое файлов (vault.read/cachedRead/adapter). Вызывается на каждом tick, // в т.ч. на телефоне. Данные могут отставать от диска — core перепроверяет // состояние внутри process(). list(): Promise<{ path: string; frontmatter: Record }[]>; // атомарное чтение-изменение-запись; false, если заметки нет process(path: string, fn: (content: string) => string): Promise; } interface Transcriber { transcribe(audio: ArrayBuffer, mime: string, signal: AbortSignal): Promise; } type TranscribeResult = | { ok: true; text: string } | { ok: false; kind: 'temporary' | 'permanent'; message: string }; interface Env { now(): Date; random(): number; deviceId: string; } ``` `Env` объединяет часы, случайность и id устройства — в тестах всё детерминировано, что делает golden-сравнение побайтным. `NoteStore.process` в обёртке реализуется через `vault.process`. `list()` — только через `metadataCache.getFileCache(file)?.frontmatter` по наличию `voice-id`; поэтому порт отдаёт frontmatter-объект, а не текст заметки. ### Frontmatter правит core, одной записью Статус и тело должны меняться атомарно, иначе обрыв между `processFrontMatter` и `process` оставит `inserted` без текста или текст без статуса. Поэтому core сам построчно патчит только строки `voice-*: …` в блоке `---`, не трогая остальные строки и их порядок, без YAML-библиотеки. Значения пишутся скалярами; строки с `:`/кавычками экранируются в двойных кавычках. ### Выполнение отправки `tick()`: защита от повторного входа (флаг `running` + флаг `rerun`) → `list()` → `parseRecord(frontmatter)` → просроченные `sent` своего устройства превращаются во временную ошибку → `due()` → по одной: `process` (внутри функция заново читает `voice-*` из актуального `content` через `readVoiceFields`; если запись уже не в ожидаемом состоянии или не своего устройства — возвращает content без изменений и запись пропускается; иначе статус `sent`, `voice-sent-at`) → `read` аудио → `transcribe` → `process` (`inserted` + текст или `retry`/`failed` + область). Если `process` вернул `false` — запись отменена, идём дальше. Исключение из `transcribe` или `read` трактуется как временная ошибка с его сообщением. ### Ручные команды `claimAll()`: для каждой записи из `claimable()` одной записью `voice-device := текущее`, `voice-status := saved`, удалить `voice-next-attempt` и `voice-sent-at`; `voice-attempts` сохраняется. Собственные записи устройства в `retry` и зависшие `sent` тоже переводятся в `saved` (без `voice-next-attempt`/`voice-sent-at`, счётчик сохраняется), чтобы отправиться сразу (решение 33). Затем `tick()`. `retry(notePath)`: только из `failed`/`retry` → `saved`, `voice-attempts := 0`, удалить `voice-next-attempt`/`voice-error`; затем `tick()`. ### Захват `saveCapture({ chunks: ArrayBuffer[], mime, startedAt, endedAt, interrupted })`: суммарно 0 байт → `null`; иначе склейка → `write` аудио → подбор свободного имени заметки через `exists` → `create` с `initialNote` → возвращает путь. `MediaRecorder` и детекция обрыва — в обёртке. ### Тестовая инфраструктура (Task 1) Шаблон obsidian-sample-plugin (esbuild, `manifest.json` с `isDesktopOnly: false`), vitest. Тест-страж сканирует `src/core/**/*.ts` и падает на импорт `obsidian`, `fs`, `path`, `child_process`, `node:*`. Тесты запускаются с фиксированным `TZ=Europe/Moscow` (задаётся в `vitest.config.ts`): имена заметок и строка статуса `retry` зависят от локального времени, а golden-сравнение побайтное. Фейки в `tests/fakes.ts`: in-memory `RecordingStore`/`NoteStore`, скриптуемый `Transcriber`, `Env` с управляемыми часами. Аудиофикстуры `*.m4a` лежат в git (`test-01.m4a` ~85 КБ), так что `npm test` зелёный на чистом клоне. Если для эталона `.note.md` нет аудиофайла, golden-тест MUST падать с сообщением «нет аудиофикстуры tests/fixtures/.m4a», а не пропускаться молча. ## Risks / Trade-offs - **Лаг iCloud.** Команда «забрать все» может отправить запись, которую телефон как раз отправляет. Цена — один лишний запрос; вставка идемпотентна по статусу. Принято. - **Самописный патч frontmatter.** Не покрывает многострочные YAML-значения в ключах `voice-*`; плагин сам их не пишет, а пользовательские ключи не трогает. Покрывается тестами. - **Потеря при жёстком убийстве процесса во время записи.** Принято до spike периодического сброса. - **`list()` по всему vault.** Дёшево только при реализации через `metadataCache` (закреплено в контракте порта). Читаются лишь заметки, которые реально обрабатываются, через `process`. - **Отставание `metadataCache`.** После записи кэш обновляется асинхронно, следующий tick может увидеть старый статус. Защита — перепроверка состояния внутри `process` по актуальному содержимому. ## Decisions made without discussion Формат: дата — решение. *Обоснование.* **Цена ошибки.** Пометка «пересмотрено» — решение скорректировано человеком на ревью. 1. 2026-09-27 — Пустой (после обрезки пробелов) транскрипт переводит запись в `failed` с причиной `no-speech`; в заметке `> [!info] В записи не распознано речи`, без слова «ошибка» и без призыва повторить. Аудио остаётся. *Повтор тишины бесполезен; это свойство записи, а не сбой сервиса* (формулировка пересмотрена на ревью). **Если провайдер изредка возвращает пустоту на нормальной записи — пользователь увидит «нет речи» и должен сам запустить «Повторить распознавание»; потери данных нет.** 2. 2026-09-27 — Очередь ищет записи по `voice-id` во всём vault, а не только в `voice/inbox/`. *Пользователь может перенести заметку до распознавания.* Пересмотрено: `NoteStore.list()` обязан работать через `metadataCache` без чтения файлов, это записано в контракт порта. **Если обёртка нарушит контракт — тормоза на телефоне на каждом tick; ловится ревью обёртки, в core не проверяемо.** 3. 2026-09-27 — Добавлены ключи `voice-mime` (исходный mime с параметрами) и `voice-sent-at` (ISO UTC). *Провайдеру нужен точный mime, а не восстановленный по расширению; зависший `sent` нужно отличать от идущего.* **Лишние поля во frontmatter — косметика; без них — неверный mime у провайдера или вечный `sent`.** 4. 2026-09-27 — Порог зависшего `sent` — 10 минут (пересмотрено с 5). Автоматически касается только своих записей, чужие — только через `claimAll`. *Двухминутная запись через агрегатор на плохом канале может идти дольше 5 минут.* **Слишком мал — дубль запроса (текст не портится: вставка идемпотентна); слишком велик — запись после убийства Obsidian ждёт повтора до 10 минут.** 5. 2026-09-27 — `claimAll` сохраняет `voice-attempts`, меняет только устройство, статус (в `saved`) и удаляет `voice-next-attempt`/`voice-sent-at`. «Повторить распознавание» работает для `failed` и `retry` и сбрасывает счётчик в 0. *`claimAll` — про «где», а не «заново»; ручной повтор — осознанный перезапуск.* **Если `claimAll` должен был давать 5 свежих попыток — запись с телефона уйдёт в `failed` раньше; исправляется ручным повтором.** 6. 2026-09-27 — Неизвестный mime сохраняется с расширением `.bin`, запись не отвергается. *Сохранить аудио важнее красивого расширения; принимать ли формат, решает провайдер.* **Файл `.bin` не проигрывается встроенным плеером Obsidian; аудио при этом не потеряно.** 7. 2026-09-27 — При переходе в `inserted` удаляются `voice-next-attempt`, `voice-sent-at`, `voice-error`; `voice-attempts` остаётся. *Итоговая заметка чище; счётчик полезен как история.* **Косметика.** 8. 2026-09-27 — Frontmatter правит сам core построчно, только ключи `voice-*`, без YAML-библиотеки; статус и тело меняются одной операцией `process`. *Два вызова (`processFrontMatter` + `process`) не атомарны: обрыв между ними даёт `inserted` без текста.* **Самописный патч может сломаться на экзотическом YAML в ключах `voice-*` — плагин такой не пишет; сохранность чужих ключей закрыта тестами.** 9. 2026-09-27 — Golden-сравнение побайтное при фиксированном `Env` (время, случайность, устройство). Нет аудиофикстуры для эталона — тест падает с понятным сообщением, а не пропускается. Пересмотрено на ревью: `tests/fixtures/*.m4a` убран из `.gitignore`, `test-01.m4a` закоммичен, чтобы на чистом клоне тесты были зелёными. *Молчаливый skip дал бы ложно-зелёный прогон.* **Нулевая: фикстура в репозитории; падение возможно только если добавить эталон без аудио — это и должно быть ошибкой.** 10. 2026-09-27 — В `.gitignore` добавлен `.claude/worktrees/`. *Хук guard-main запретил правку `.git/info/exclude` в main; вложенные worktree иначе видны как неотслеживаемые.* **Нулевая.** 11. 2026-09-27 — `voice-id` строится по UTC (пересмотрено на ревью, было локальное время); имя заметки в inbox — по локальному времени. Имя аудиофайла следует `voice-id`, т.е. тоже UTC. *Порядок id не зависит от пояса устройства; имя заметки — для человека.* **Имена аудио и заметки одной записи расходятся на смещение пояса — можно спутать при ручном поиске; связь всё равно хранится ссылкой `voice-audio`.** 12. 2026-09-27 — `NoteStore.list()` отдаёт frontmatter-объект, а не текст заметки; core перепроверяет состояние внутри `process` по актуальному содержимому и пропускает запись, если она уже не в ожидаемом состоянии или не своего устройства. *Следствие правки 2: без чтения файлов текста нет, а `metadataCache` обновляется асинхронно.* **Без перепроверки — повторная отправка только что отправленной записи.** 13. 2026-09-27 — Исключение из `RecordingStore.read` или `Transcriber.transcribe` трактуется как временная ошибка со своим сообщением. *Классифицирует провайдер, но непредвиденный throw не должен ронять очередь.* **Если исключение на деле постоянное (например, аудиофайл удалён) — 5 бесполезных попыток за ~42 минуты, затем `failed`.** 14. 2026-09-27 — Причина «нет речи» хранится кодом `voice-error: no-speech`, а не текстом. *Рендер должен отличать её от обычной ошибки без разбора сообщений.* **Нулевая: значение внутреннее.** 15. 2026-09-27 (план) — Все строковые значения `voice-*` пишутся в двойных кавычках через `JSON.stringify`; числа (`voice-attempts`) и `true` — без кавычек. *JSON-строка — валидная YAML-строка: переводы строк, кавычки и двоеточия из сообщений провайдера экранируются в одну строку; id вида `20260927-...` не рискует распарситься YAML как число/дата.* **Кавычки видны в режиме исходника — косметика.** 16. 2026-09-27 (план) — Из `retry` запись переходит сразу в `sent`; промежуточное `saved` (на схеме «срок наступил → saved») не записывается. *Лишняя запись в файл без наблюдаемой разницы.* **Нулевая: `saved` и «`retry` с наступившим сроком» обрабатываются одинаково.** 17. 2026-09-27 (план) — «Повторить распознавание» переназначает запись текущему устройству. *Иначе `failed`-запись с телефона, повторённая на маке, вернулась бы в `saved` с устройством телефона, и мак её не отправил бы — команда выглядела бы сломанной.* **Если телефон одновременно тоже повторит ту же запись — один лишний запрос; текст не портится.** 18. 2026-09-27 (план) — Тесты идут с `TZ=Europe/Moscow` (задаётся в `vitest.config.ts`). *Имена заметок и строки статуса зависят от локального времени; UTC+3 без летнего времени даёт детерминизм и заметно отличается от UTC.* **Если CI окажется на Windows, где `TZ` игнорируется — тесты с локальным временем упадут; сейчас CI нет.** 19. 2026-09-27 (план) — id плагина `voice-note`; `npm run dev` после каждой сборки копирует `main.js` и `manifest.json` в `<тестовый vault>/.obsidian/plugins/voice-note/`, если vault есть на машине. *CLAUDE.md: «dev — сборка в тестовый vault».* **Смена id позже — переустановка плагина в vault; данные core живут во frontmatter, не теряются.** 20. 2026-09-27 (план) — Если маркеры области стёрты, а запись ещё не `inserted` (retry/failed), плагин обновляет только frontmatter, строку статуса в тело не дописывает; транскрипт при успехе дописывается в конец. *Не мусорить в заметке, которую пользователь сознательно переделал.* **Пользователь не видит в теле статус ожидания/ошибки такой заметки — он остаётся в Properties.** 21. 2026-09-27 (план) — Причина ошибки в callout сжимается в одну строку (пробельные символы → один пробел). *Многострочное сообщение ломает callout.* **Потеря переносов в тексте ошибки — косметика; полное сообщение остаётся во frontmatter.** 22. 2026-09-27 (план) — Готовые к отправке записи обрабатываются по возрастанию `voice-id` (старые первыми). *Предсказуемо; id по UTC упорядочен во времени.* **Нулевая.** 23. 2026-09-27 (план) — Нераспознаваемое `voice-next-attempt` — запись готова к отправке; отсутствующее или нераспознаваемое `voice-sent-at` у `sent` — отправка считается зависшей. *Ручная правка frontmatter не должна вешать запись навсегда.* **Один досрочный запрос.** 24. 2026-09-27 (план) — `QueueRunner.dispose()` отменяет `AbortSignal`, переданный провайдеру; прерванный запрос обрабатывается как любое исключение — временная ошибка. *Контракт `Transcriber` принимает signal; выгрузка плагина должна обрывать загрузку.* **Выгрузка посреди запроса тратит одну попытку из пяти.** 25. 2026-09-27 (план) — Golden-кейсы находятся по файлам `tests/fixtures/*.note.md`; аудио ищется как `.m4a`, `.webm`, `.ogg` с mime `audio/mp4`, `audio/webm`, `audio/ogg`. *Добавить кейс — положить три файла, без правки теста.* **Нулевая.** 26. 2026-09-27 (план) — Модели исполнителей: haiku — `naming`, `schedule` (механика по готовому коду); opus — `QueueRunner.tick` (асинхронность, гонки); sonnet — остальное. Режим — subagent-driven-development (10 задач > 5). *Правила Model Selection SDD и глобальный CLAUDE.md.* **Слабая модель на механической задаче — лишний круг ревью, не дефект в ветке.** 27. 2026-09-27 (план, зафиксировано на ревью) — Зависший `sent` засчитывается как попытка (временная ошибка «отправка прервана»). *Иначе запись, на которой Obsidian каждый раз выгружается, повторялась бы бесконечно.* **Пять выгрузок Obsidian подряд во время отправки дадут `failed` без единого ответа сервиса; спасает ручной повтор.** 28. 2026-09-27 (план, зафиксировано на ревью) — Результат запроса пишется по пути, полученному из `list()`. Если заметку переименовали или перенесли между первым `process` (`sent`) и вторым (результат), второй `process` не находит файл и результат отбрасывается. *Порт `NoteStore` адресует заметки путём; отслеживание переименований — усложнение обёртки ради редкого случая.* **Запись остаётся в `sent`, через 10 минут уходит в повтор — один лишний запрос.** 29. 2026-09-27 (исполнение, контроллер) — Модели исполнителей пересмотрены по правилам Model Selection SDD: haiku — задачи 2, 3, 4, 5, 6, 7, 9 (в плане полный код и тесты, 1–2 файла); sonnet — задача 1 (установка зависимостей, сборка), задача 8 (самый большой асинхронный модуль, при падении тестов нужна отладка) и задача 10 (генерация эталона и разбор расхождений со спекой). Ревьюеры задач — sonnet, для задачи 8 — opus; финальное ревью — opus. Заменяет решение 26 и строки **Model** в плане. *Перенос готового кода — дешёвый уровень; opus — для эскалации в цикле исправлений и финального ревью.* **Лишний круг исправлений, если слабая модель ошибётся при переносе; дефект в ветку не попадает — каждая задача проходит ревью.** 30. 2026-09-27 (исполнение, контроллер) — Почти одинаковые тестовые помощники (`setup`/`rec`/`seed`/`fields`) в `tests/queue.test.ts` и `tests/queue-commands.test.ts` реализуются как в плане, без выноса в `tests/fakes.ts`. *Это небольшая тестовая обвязка, а не логика; помощники уже различаются (сигнатура `seed`, байты аудио); превентивная правка меняла бы дословный текст двух задач.* **~20 строк дублированной настройки тестов, которые надо держать в согласии; вынос позже механический.** 31. 2026-09-27 (исполнение, контроллер) — Решения исполнителей задач дописываются ими в этот раздел в коммите задачи (продолжая нумерацию, пометка «исполнение»); рулинги контроллера — отдельными docs-коммитами с пометкой «исполнение, контроллер». *Требование глобальных ограничений плана и CLAUDE.md; задачи идут строго последовательно, конфликтов нумерации нет.* **Шум в нумерации/формате раздела, правится руками.** 32. 2026-09-27 (исполнение, контроллер) — После `QueueRunner.dispose()` очередь больше не отправляет: `tick()` не начинает проход, цикл `do…while` и цикл отправки прерываются, `send()` выходит до записи `sent`; запрос, который был в полёте, по-прежнему завершается временной ошибкой (решение 24 не меняется). Отступление от дословного кода плана, добавлен тест. *Код плана после `dispose()` продолжал обходить готовые записи: каждой писал `sent`, вызывал провайдера с уже отменённым сигналом и тратил попытку, а также писал в vault после выгрузки плагина — это больше, чем цена «одна попытка из пяти», принятая в решении 24.* **Несколько строк защитного кода и тест, которые окажутся лишними, если обёртка никогда не вызывает `tick()` после `dispose()`; требуемое спекой поведение не теряется.** 33. 2026-09-27 (исполнение, контроллер, финальное ревью) — «Распознать все ожидающие здесь» (`claimAll`) кроме чужих записей переводит в `saved` и собственные записи устройства в `retry` и зависшие `sent` (удаляются `voice-next-attempt`/`voice-sent-at`, счётчик сохраняется), затем `tick()` отправляет их сразу. Возвращаемое число — только переназначенные с других устройств. Тест плана изменён. *Спека: «после чего обработать очередь без ожидания времени следующей попытки»; абзац «Ручные команды» и тест плана сузили это без записанного решения; пользователь ждёт, что уйдёт всё ожидающее на этом устройстве.* **Если имелось в виду узкое поведение — свои повторы уходят до 30 минут раньше (лишний запрос на запись, без потери данных); откат — вернуть прежний `claimAll` и тест, переформулировать спеку.** 34. 2026-09-27 (исполнение, контроллер, финальное ревью) — В проходе очереди ошибка обработки одной записи (исключение из `NoteStore.process` и т.п.) не останавливает остальные: запоминается первая ошибка, проход продолжается, в конце ошибка пробрасывается, так что `tick()` всё равно отклоняется. Ошибка `list()` по-прежнему отклоняет `tick()` сразу. *Иначе одна «сломанная» старейшая заметка блокирует все новые на каждом tick; проброс сохраняет видимость ошибки для обёртки.* **Чуть сложнее `pass()`; `retry()`/`claimAll()` могут отклониться, уже изменив свою запись, если упала обработка другой.** 35. 2026-09-27 (исполнение, контроллер, финальное ревью) — Из `.gitignore` убрано правило `tests/fixtures/*.webm`. *Решения 9 и 25: аудиофикстуры любых поддерживаемых расширений лежат в git; правило давало бы зелёный прогон локально и красный на чистом клоне.* **Можно случайно закоммитить большой webm; иначе нулевая.** 36. 2026-09-27 (исполнение, контроллер, финальное ревью) — Дешёвое усиление по итогам финального ревью: результат запроса записывается, только если запись всё ещё `sent` этим устройством с тем же `voice-sent-at`; после `dispose()` не пишут ни восстановление зависших `sent`, ни `retry()` (возвращает `false`), ни `claimAll()` (возвращает `0`); добавлены тесты на `sent` + `voice-sent-at` во время запроса и на `sent` в «Повторить распознавание». Путь vault в `esbuild.config.mjs`, Node-глобалы в типах `src/core` и CRLF остаются отложенными. *Каждое — несколько строк, закрывает названный крайний случай или фиксирует MUST спеки; другой волны исправлений в этой ветке не будет.* **~20 строк защит и тестов; если проверка устройства и `voice-sent-at` слишком строга — законный поздний результат своей отправки отбрасывается и запись повторяется через 10 минут (лишний запрос).**