diff --git a/.gitignore b/.gitignore index ca0fe32..1ac7ca9 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,4 @@ data.json tests/fixtures/*.m4a tests/fixtures/*.webm .DS_Store +.claude/worktrees/ diff --git a/openspec/changes/core-recording-lifecycle/.openspec.yaml b/openspec/changes/core-recording-lifecycle/.openspec.yaml new file mode 100644 index 0000000..7f2ad57 --- /dev/null +++ b/openspec/changes/core-recording-lifecycle/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-27 diff --git a/openspec/changes/core-recording-lifecycle/README.md b/openspec/changes/core-recording-lifecycle/README.md new file mode 100644 index 0000000..baffb85 --- /dev/null +++ b/openspec/changes/core-recording-lifecycle/README.md @@ -0,0 +1,3 @@ +# core-recording-lifecycle + +Core: жизненный цикл голосовой записи и очередь отправки (issue #1) diff --git a/openspec/changes/core-recording-lifecycle/design.md b/openspec/changes/core-recording-lifecycle/design.md new file mode 100644 index 0000000..cc063b2 --- /dev/null +++ b/openspec/changes/core-recording-lifecycle/design.md @@ -0,0 +1,91 @@ +# 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 = 5m` | +| `schedule.ts` | `due(records, now, deviceId)`, `isStaleSent(record, now)`, `claimable(records, now, deviceId)` | +| `note.ts` | `parseRecord(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 + list(): Promise<{ path: string; content: string }[]>; + // атомарное чтение-изменение-запись; 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` по наличию `voice-id`. + +### Frontmatter правит core, одной записью + +Статус и тело должны меняться атомарно, иначе обрыв между `processFrontMatter` и `process` оставит `inserted` без текста или текст без статуса. Поэтому core сам построчно патчит только строки `voice-*: …` в блоке `---`, не трогая остальные строки и их порядок, без YAML-библиотеки. Значения пишутся скалярами; строки с `:`/кавычками экранируются в двойных кавычках. + +### Выполнение отправки + +`tick()`: защита от повторного входа (флаг `running` + флаг `rerun`) → `list()` → `parseRecord` → просроченные `sent` своего устройства превращаются во временную ошибку → `due()` → по одной: `process` (статус `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` сохраняется. Затем `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:*`. Фейки в `tests/fakes/`: in-memory `RecordingStore`/`NoteStore`, скриптуемый `Transcriber`, `Env` с управляемыми часами. + +Аудиофикстуры (`*.m4a`) в `.gitignore` и в git не лежат. Golden-тест при отсутствии аудиофайла MUST падать с сообщением «положите tests/fixtures/.m4a», а не пропускаться молча — иначе зелёный прогон будет ложным. + +## Risks / Trade-offs + +- **Лаг iCloud.** Команда «забрать все» может отправить запись, которую телефон как раз отправляет. Цена — один лишний запрос; вставка идемпотентна по статусу. Принято. +- **Самописный патч frontmatter.** Не покрывает многострочные YAML-значения в ключах `voice-*`; плагин сам их не пишет, а пользовательские ключи не трогает. Покрывается тестами. +- **Потеря при жёстком убийстве процесса во время записи.** Принято до spike периодического сброса. +- **`list()` по всему vault.** На больших vault — через `metadataCache`, без чтения файлов, кроме найденных заметок. diff --git a/openspec/changes/core-recording-lifecycle/proposal.md b/openspec/changes/core-recording-lifecycle/proposal.md new file mode 100644 index 0000000..d12d750 --- /dev/null +++ b/openspec/changes/core-recording-lifecycle/proposal.md @@ -0,0 +1,32 @@ +# Proposal: core-recording-lifecycle + +Issue: #1 — Core: жизненный цикл голосовой записи и очередь отправки. + +## Why + +Плагин должен превращать голосовую диктовку (в первую очередь с iPhone) в текстовую заметку через внешний сервис распознавания. Главный риск мобильного сценария — потеря надиктованного: нет сети, Obsidian выгружен из памяти, запись оборвалась. Нужна независимая от Obsidian и покрытая тестами основа, которая сохраняет запись сразу, доводит её до текста через повторы и никогда не портит заметку. + +## What Changes + +- Новая чистая подсистема `src/core/` (без импортов `obsidian` и Node API): жизненный цикл записи, планировщик повторов, формат заметки, очередь отправки поверх портов. +- Тестовая инфраструктура проекта: сборка по шаблону obsidian-sample-plugin, vitest, фейки портов, golden-тест `test-01`, тест-страж границы `src/core`. +- Контракт провайдера распознавания (интерфейс), который реализует следующая подсистема. +- Без реального провайдера, UI, команд и `MediaRecorder` — это отдельные issue. + +## Capabilities + +### New Capabilities + +- `recording-lifecycle`: состояния записи, сохранение захваченного аудио, отправка, классификация ошибок, повторы с нарастающей паузой, принадлежность записи устройству, ручные команды «повторить» и «забрать все ожидающие». +- `voice-note-format`: размещение и имена файлов аудио и заметок, ключи frontmatter, управляемая плагином область тела заметки, правила вставки транскрипта без порчи пользовательского текста. + +### Modified Capabilities + +Нет. + +## Impact + +- Новые файлы: `package.json`, `tsconfig.json`, `esbuild.config.mjs`, `manifest.json`, `vitest.config.ts`, `src/core/*`, `tests/*`. +- Зависимости (dev): `typescript`, `esbuild`, `vitest`, `obsidian` (типы), `@types/node` только для тестов. +- `.gitignore`: добавляется `.claude/worktrees/`. +- Следующие подсистемы (провайдер, обёртка Obsidian) зависят от портов и контракта `Transcriber`, описанных в design.md. diff --git a/openspec/changes/core-recording-lifecycle/specs/recording-lifecycle/spec.md b/openspec/changes/core-recording-lifecycle/specs/recording-lifecycle/spec.md new file mode 100644 index 0000000..1583f1f --- /dev/null +++ b/openspec/changes/core-recording-lifecycle/specs/recording-lifecycle/spec.md @@ -0,0 +1,127 @@ +# Spec Delta: recording-lifecycle + +## Purpose + +Гарантирует, что каждая завершённая или прерванная голосовая запись сохраняется немедленно и доводится до текста через отправку во внешний сервис с повторами, без дублей между устройствами и без потери данных. + +## ADDED Requirements + +### Requirement: Состояния записи + +Запись MUST находиться ровно в одном из состояний: `created` (идёт запись, данные только в памяти), `saved` (аудио и заметка сохранены, ожидает отправки), `sent` (запрос в полёте), `transcribed` (текст получен, не записан; не сохраняется), `retry` (ожидает повтора после временной ошибки), `failed` (требуется ручное действие), `inserted` (финал). Во frontmatter заметки MUST сохраняться только `saved`, `sent`, `retry`, `failed`, `inserted`. Переход, не описанный в этой спецификации, MUST отклоняться ошибкой без изменения записи. + +#### Scenario: Недопустимый переход отклонён +- **WHEN** к записи в состоянии `inserted` применяется событие «отправка начата» +- **THEN** операция завершается ошибкой, запись не изменена + +#### Scenario: Транзитное состояние не попадает в заметку +- **WHEN** получен текст транскрипта +- **THEN** заметка за одну запись переходит из `sent` сразу в `inserted`, значение `transcribed` во frontmatter не появляется + +### Requirement: Сохранение захваченного аудио + +По окончании записи плагин MUST сохранить аудио в `voice/source/` и создать заметку в `voice/inbox/` в состоянии `saved` до любой сетевой операции. Если захвачено 0 байт, MUST NOT создаваться ни аудиофайл, ни заметка. Если запись была прервана (событие остановки не от пользователя), сохраняется всё накопленное, а запись помечается как прерванная. + +#### Scenario: Обычная запись +- **WHEN** пользователь остановил запись длиной 0:47 с непустыми данными в формате `audio/mp4` +- **THEN** в `voice/source/` появился файл `.m4a`, в `voice/inbox/` — заметка со статусом `saved`, и это произошло без сетевых запросов + +#### Scenario: Прерванная запись +- **WHEN** запись прервалась системой после 0:47 с непустыми данными +- **THEN** аудио и заметка сохранены, заметка помечена как прерванная с длительностью 0:47, и запись участвует в очереди как обычная + +#### Scenario: Пустая запись +- **WHEN** запись завершилась (штатно или обрывом) с 0 байт данных +- **THEN** никаких файлов не создано + +### Requirement: Отправка и результат + +Перед отправкой запись MUST перейти в `sent` с отметкой времени начала отправки, записанной в заметку. Очередь MUST обрабатывать записи строго по одной. Результат провайдера — успех с текстом, временная ошибка или постоянная ошибка. Успех с непустым текстом MUST переводить запись в `inserted` с текстом в заметке. Успех с пустым (после обрезки пробелов) текстом MUST трактоваться как постоянная ошибка «пустой транскрипт». + +#### Scenario: Успешное распознавание +- **WHEN** провайдер вернул текст для записи в `saved` +- **THEN** заметка содержит текст и статус `inserted` + +#### Scenario: Пустой транскрипт +- **WHEN** провайдер вернул успех с текстом из одних пробелов +- **THEN** запись в `failed` с причиной «пустой транскрипт» + +#### Scenario: Последовательная обработка +- **WHEN** в очереди три готовые к отправке записи +- **THEN** второй запрос к провайдеру начинается только после завершения первого + +### Requirement: Повторы после временных ошибок + +Временная ошибка MUST увеличивать счётчик попыток и переводить запись в `retry` со временем следующей попытки через 30 с, 2 мин, 10 мин и 30 мин после 1-й, 2-й, 3-й и 4-й неудачи соответственно. Пятая неудача подряд MUST переводить запись в `failed`. Постоянная ошибка MUST переводить запись в `failed` сразу, независимо от счётчика. Запись в `retry` MUST NOT отправляться раньше своего времени следующей попытки. + +#### Scenario: Сеть пропала и вернулась +- **WHEN** первая отправка завершилась временной ошибкой в 14:15:03, а следующая попытка после 14:15:33 — успехом +- **THEN** после первой попытки запись в `retry` со следующей попыткой 14:15:33, после второй — в `inserted` + +#### Scenario: Исчерпание попыток +- **WHEN** пять отправок подряд завершились временной ошибкой +- **THEN** запись в `failed`, причина — последняя ошибка + +#### Scenario: Постоянная ошибка +- **WHEN** первая отправка завершилась постоянной ошибкой «401 Unauthorized» +- **THEN** запись сразу в `failed` с этой причиной + +#### Scenario: Время повтора не наступило +- **WHEN** очередь запускается в 14:15:20 для записи со следующей попыткой 14:15:33 +- **THEN** запрос к провайдеру не отправляется + +### Requirement: Восстановление после прерванной отправки + +Запись текущего устройства в `sent`, у которой с начала отправки прошло больше 5 минут, MUST считаться завершившейся временной ошибкой «отправка прервана» и обрабатываться по правилам повторов. + +#### Scenario: Obsidian выгружен во время запроса +- **WHEN** очередь запускается в 14:30:00 и находит запись в `sent` с началом отправки 14:20:00 +- **THEN** запись учитывается как временная неудача и планируется к повтору + +#### Scenario: Запрос ещё может идти +- **WHEN** очередь находит запись в `sent` с началом отправки 2 минуты назад +- **THEN** запись не трогается + +### Requirement: Принадлежность устройству + +Каждая запись MUST хранить идентификатор создавшего её устройства. Автоматическая обработка очереди MUST брать только записи текущего устройства. Идентификатор устройства MUST NOT синхронизироваться между устройствами. + +#### Scenario: Чужая запись не отправляется автоматически +- **WHEN** на маке запускается очередь, а в vault есть запись в `saved`, созданная на iPhone +- **THEN** мак не отправляет её + +### Requirement: Команда «Распознать все ожидающие здесь» + +Команда MUST переназначить текущему устройству все записи других устройств в состояниях `saved`, `retry` и `sent` старше 5 минут, после чего обработать очередь без ожидания времени следующей попытки. Записи в `failed` и `inserted`, а также `sent` моложе 5 минут, MUST NOT затрагиваться. + +#### Scenario: Мак забирает запись телефона +- **WHEN** на маке выполнена команда, а в vault есть запись iPhone в `retry` со следующей попыткой через 20 минут +- **THEN** запись переназначена маку и отправлена сейчас + +### Requirement: Команда «Повторить распознавание» + +Команда для записи в `failed` или `retry` MUST сбросить счётчик попыток в 0, перевести запись в `saved` и запустить очередь. Для записей в других состояниях команда MUST ничего не менять. + +#### Scenario: Повтор после исправления ключа +- **WHEN** запись в `failed` после 401, пользователь исправил ключ и выполнил команду +- **THEN** запись отправлена заново со счётчиком попыток, начатым с нуля + +### Requirement: Удалённая заметка отменяет запись + +Состояние записи живёт только во frontmatter её заметки; очередь находит записи по наличию ключа `voice-id` в любом месте vault, а не по папке. Если заметку удалили, запись MUST считаться отменённой, и её аудиофайл MUST NOT удаляться. Если заметку удалили во время запроса, пришедший результат MUST отбрасываться без ошибки и без воссоздания заметки. + +#### Scenario: Заметка удалена во время запроса +- **WHEN** провайдер вернул текст после того, как заметку удалили +- **THEN** заметка не создана заново, очередь продолжает работу со следующей записью + +#### Scenario: Заметка перенесена в другую папку +- **WHEN** пользователь перенёс заметку в `saved` из `voice/inbox/` в `projects/` +- **THEN** запись по-прежнему обрабатывается очередью + +### Requirement: Повторный запуск очереди + +Если запуск очереди запрошен, пока она уже работает, новый параллельный проход MUST NOT начинаться; вместо этого MUST выполниться ровно один дополнительный проход после завершения текущего. + +#### Scenario: Три запроса во время работы +- **WHEN** очередь обрабатывает запись и за это время запуск запрошен три раза +- **THEN** после текущего прохода выполняется ровно один дополнительный проход diff --git a/openspec/changes/core-recording-lifecycle/specs/voice-note-format/spec.md b/openspec/changes/core-recording-lifecycle/specs/voice-note-format/spec.md new file mode 100644 index 0000000..4aba567 --- /dev/null +++ b/openspec/changes/core-recording-lifecycle/specs/voice-note-format/spec.md @@ -0,0 +1,63 @@ +# Spec Delta: voice-note-format + +## Purpose + +Определяет, где и под какими именами лежат аудиофайлы и заметки голосовых записей, как в заметке хранится состояние записи и как плагин обновляет заметку, не затрагивая текст пользователя. + +## ADDED Requirements + +### Requirement: Идентификатор и имена файлов + +Идентификатор записи MUST иметь вид `YYYYMMDD-HHMMSS-xxxx` (локальное время начала записи и 4 случайных шестнадцатеричных символа в нижнем регистре). Аудио MUST сохраняться как `voice/source/.`, где расширение определяется по mime без параметров: `audio/mp4` → `m4a`, `audio/webm` → `webm`, `audio/ogg` → `ogg`, любой другой → `bin`. Заметка MUST создаваться как `voice/inbox/YYYY-MM-DD HH-mm-ss.md` по локальному времени начала записи; при занятом имени MUST добавляться суффикс ` (2)`, ` (3)` и т.д. + +#### Scenario: Mime с параметрами +- **WHEN** запись в формате `audio/webm;codecs=opus` начата 27.09.2026 в 14:15:03 +- **THEN** аудио сохранено как `voice/source/20260927-141503-<4 hex>.webm`, заметка — `voice/inbox/2026-09-27 14-15-03.md` + +#### Scenario: Коллизия имени заметки +- **WHEN** заметка `voice/inbox/2026-09-27 14-15-03.md` уже существует +- **THEN** новая заметка создана как `voice/inbox/2026-09-27 14-15-03 (2).md` + +### Requirement: Ключи frontmatter + +Состояние записи MUST храниться в плоских ключах frontmatter: `voice-id`, `voice-status`, `voice-device`, `voice-audio` (wiki-ссылка на аудио), `voice-mime` (исходный mime с параметрами), `voice-attempts`, а также, когда применимо, `voice-next-attempt` и `voice-sent-at` (ISO 8601 UTC), `voice-error`, `voice-interrupted`. Плагин MUST изменять только ключи с префиксом `voice-`, а остальные ключи и их порядок MUST оставаться нетронутыми. При переходе в `inserted` ключи `voice-next-attempt`, `voice-sent-at` и `voice-error` MUST удаляться. Заметка с отсутствующим или некорректным `voice-status` MUST игнорироваться очередью. + +#### Scenario: Пользовательские свойства сохраняются +- **WHEN** пользователь добавил в заметку свойство `tags: [idea]`, а затем запись перешла в `inserted` +- **THEN** `tags: [idea]` на месте в прежней позиции, ключи `voice-*` обновлены + +#### Scenario: Испорченный статус +- **WHEN** пользователь вручную записал `voice-status: foo` +- **THEN** очередь не обрабатывает заметку и не падает + +### Requirement: Тело заметки до распознавания + +Новая заметка MUST содержать вложение аудио `![[<путь к аудио>]]`, затем управляемую область, ограниченную маркерами-комментариями `%%voice-note:%%` и `%%/voice-note%%`. Внутри области MUST отображаться состояние: для `saved`/`sent` — `> [!info] Распознаётся…`; для `retry` — `> [!info] Нет связи, повтор в HH:mm (попытка N/5)` по локальному времени; для `failed` — `> [!warning] Не распознано: <причина>. Команда «Повторить распознавание»`. Для прерванной записи над областью MUST стоять строка `> [!note] Запись прервана, сохранено M:SS`, которая остаётся и после вставки текста. + +#### Scenario: Заметка в ожидании повтора +- **WHEN** запись перешла в `retry` после второй неудачи со следующей попыткой в 14:17 по местному времени +- **THEN** область содержит `> [!info] Нет связи, повтор в 14:17 (попытка 2/5)` + +### Requirement: Вставка транскрипта + +При переходе в `inserted` управляемая область вместе с маркерами MUST заменяться текстом транскрипта. Весь текст за пределами области MUST оставаться без изменений. Если маркеры не найдены, транскрипт MUST дописываться в конец заметки через пустую строку. Если запись уже в `inserted`, повторная вставка MUST не менять заметку. Обновление статуса и тела MUST выполняться одной операцией записи. + +#### Scenario: Пользователь дописал текст под заметкой +- **WHEN** под управляемой областью пользователь написал «купить молоко», затем пришёл транскрипт +- **THEN** область заменена транскриптом, «купить молоко» осталось на месте + +#### Scenario: Маркеры удалены вручную +- **WHEN** пользователь стёр маркеры, затем пришёл транскрипт +- **THEN** транскрипт дописан в конец заметки, статус `inserted` + +#### Scenario: Дубль ответа +- **WHEN** транскрипт приходит для записи, уже находящейся в `inserted` +- **THEN** содержимое заметки не изменилось + +### Requirement: Golden-эталоны + +Для каждой пары фикстур `tests/fixtures/.` + `.txt` с эталоном `.note.md` полный путь «захват → очередь → вставка» с провайдером, отвечающим содержимым `.txt`, при фиксированных времени, идентификаторе устройства и источнике случайности MUST давать заметку, побайтно совпадающую с `.note.md`. Провайдер MUST получать байты аудиофикстуры без изменений и исходный mime. + +#### Scenario: test-01 +- **WHEN** прогоняется фикстура `test-01.m4a` с mime `audio/mp4` и ответом из `test-01.txt` +- **THEN** итоговая заметка совпадает с `test-01.note.md`