Compare commits

..

No commits in common. "944a6594ec49f3bde80f19fc980e4fc820d94ecb" and "0098617d2606011a1ef9a257d8f88fb36401053f" have entirely different histories.

9 changed files with 1 additions and 2885 deletions

2
.gitignore vendored
View File

@ -4,6 +4,6 @@ main.js
data.json
.superpowers/
.bounded
tests/fixtures/*.m4a
tests/fixtures/*.webm
.DS_Store
.claude/worktrees/

File diff suppressed because it is too large Load Diff

View File

@ -1,2 +0,0 @@
schema: spec-driven
created: 2026-09-27

View File

@ -1,3 +0,0 @@
# core-recording-lifecycle
Core: жизненный цикл голосовой записи и очередь отправки (issue #1)

View File

@ -1,129 +0,0 @@
# 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<void>;
read(path: string): Promise<ArrayBuffer>;
}
interface NoteStore {
exists(path: string): Promise<boolean>;
create(path: string, content: string): Promise<void>;
// Все заметки vault, у которых во frontmatter есть voice-id, с их frontmatter.
// КОНТРАКТ: реализация MUST брать данные из metadataCache и MUST NOT читать
// содержимое файлов (vault.read/cachedRead/adapter). Вызывается на каждом tick,
// в т.ч. на телефоне. Данные могут отставать от диска — core перепроверяет
// состояние внутри process().
list(): Promise<{ path: string; frontmatter: Record<string, unknown> }[]>;
// атомарное чтение-изменение-запись; false, если заметки нет
process(path: string, fn: (content: string) => string): Promise<boolean>;
}
interface Transcriber {
transcribe(audio: ArrayBuffer, mime: string, signal: AbortSignal): Promise<TranscribeResult>;
}
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` сохраняется. Затем `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` зелёный на чистом клоне. Если для эталона `<name>.note.md` нет аудиофайла, golden-тест MUST падать с сообщением «нет аудиофикстуры tests/fixtures/<name>.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 минут уходит в повтор — один лишний запрос.**

View File

@ -1,32 +0,0 @@
# 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.

View File

@ -1,127 +0,0 @@
# 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/` появился файл `<id>.m4a`, в `voice/inbox/` — заметка со статусом `saved`, и это произошло без сетевых запросов
#### Scenario: Прерванная запись
- **WHEN** запись прервалась системой после 0:47 с непустыми данными
- **THEN** аудио и заметка сохранены, заметка помечена как прерванная с длительностью 0:47, и запись участвует в очереди как обычная
#### Scenario: Пустая запись
- **WHEN** запись завершилась (штатно или обрывом) с 0 байт данных
- **THEN** никаких файлов не создано
### Requirement: Отправка и результат
Перед отправкой запись MUST перейти в `sent` с отметкой времени начала отправки, записанной в заметку. Очередь MUST обрабатывать записи строго по одной. Результат провайдера — успех с текстом, временная ошибка или постоянная ошибка. Успех с непустым текстом MUST переводить запись в `inserted` с текстом в заметке. Успех с пустым (после обрезки пробелов) текстом MUST переводить запись в `failed` с причиной «нет речи»: это свойство записи, а не ошибка сервиса, повтор бесполезен.
#### Scenario: Успешное распознавание
- **WHEN** провайдер вернул текст, а запись в момент ответа в `sent`
- **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`, у которой с начала отправки прошло больше 10 минут, MUST считаться завершившейся временной ошибкой «отправка прервана» и обрабатываться по правилам повторов.
#### Scenario: Obsidian выгружен во время запроса
- **WHEN** очередь запускается в 14:30:00 и находит запись в `sent` с началом отправки 14:19:00
- **THEN** запись учитывается как временная неудача и планируется к повтору
#### Scenario: Запрос ещё может идти
- **WHEN** очередь находит запись в `sent` с началом отправки 2 минуты назад
- **THEN** запись не трогается
### Requirement: Принадлежность устройству
Каждая запись MUST хранить идентификатор создавшего её устройства. Автоматическая обработка очереди MUST брать только записи текущего устройства. Идентификатор устройства MUST NOT синхронизироваться между устройствами.
#### Scenario: Чужая запись не отправляется автоматически
- **WHEN** на маке запускается очередь, а в vault есть запись в `saved`, созданная на iPhone
- **THEN** мак не отправляет её
### Requirement: Команда «Распознать все ожидающие здесь»
Команда MUST переназначить текущему устройству все записи других устройств в состояниях `saved`, `retry` и `sent` старше 10 минут, после чего обработать очередь без ожидания времени следующей попытки. Записи в `failed` и `inserted`, а также `sent` не старше 10 минут, 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** после текущего прохода выполняется ровно один дополнительный проход

View File

@ -1,63 +0,0 @@
# Spec Delta: voice-note-format
## Purpose
Определяет, где и под какими именами лежат аудиофайлы и заметки голосовых записей, как в заметке хранится состояние записи и как плагин обновляет заметку, не затрагивая текст пользователя.
## ADDED Requirements
### Requirement: Идентификатор и имена файлов
Идентификатор записи MUST иметь вид `YYYYMMDD-HHMMSS-xxxx` (время начала записи в UTC и 4 случайных шестнадцатеричных символа в нижнем регистре). Аудио MUST сохраняться как `voice/source/<id>.<ext>`, где расширение определяется по 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 на устройстве в поясе UTC+3
- **THEN** аудио сохранено как `voice/source/20260927-111503-<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:<id>%%` и `%%/voice-note%%`. Внутри области MUST отображаться состояние: для `saved`/`sent` — `> [!info] Распознаётся…`; для `retry` — `> [!info] Не удалось отправить, повтор в HH:mm (попытка N/5)` по локальному времени; для `failed` — `> [!warning] Не распознано: <причина>. Команда «Повторить распознавание»`, кроме причины «нет речи», для которой — `> [!info] В записи не распознано речи` (без слова «ошибка» и без призыва повторить). Для прерванной записи над областью 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/<name>.<audio-ext>` + `<name>.txt` с эталоном `<name>.note.md` полный путь «захват → очередь → вставка» с провайдером, отвечающим содержимым `<name>.txt`, при фиксированных времени, идентификаторе устройства и источнике случайности MUST давать заметку, побайтно совпадающую с `<name>.note.md`. Провайдер MUST получать байты аудиофикстуры без изменений и исходный mime.
#### Scenario: test-01
- **WHEN** прогоняется фикстура `test-01.m4a` с mime `audio/mp4` и ответом из `test-01.txt`
- **THEN** итоговая заметка совпадает с `test-01.note.md`

Binary file not shown.