From e00d7e182cff911279942bbaa894da5149b5550c Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 27 Sep 2026 14:44:26 +0400 Subject: [PATCH 01/22] docs(openspec): core-recording-lifecycle proposal, specs, design Refs #1 Co-Authored-By: Claude Opus 5.5 --- .gitignore | 1 + .../core-recording-lifecycle/.openspec.yaml | 2 + .../core-recording-lifecycle/README.md | 3 + .../core-recording-lifecycle/design.md | 91 +++++++++++++ .../core-recording-lifecycle/proposal.md | 32 +++++ .../specs/recording-lifecycle/spec.md | 127 ++++++++++++++++++ .../specs/voice-note-format/spec.md | 63 +++++++++ 7 files changed, 319 insertions(+) create mode 100644 openspec/changes/core-recording-lifecycle/.openspec.yaml create mode 100644 openspec/changes/core-recording-lifecycle/README.md create mode 100644 openspec/changes/core-recording-lifecycle/design.md create mode 100644 openspec/changes/core-recording-lifecycle/proposal.md create mode 100644 openspec/changes/core-recording-lifecycle/specs/recording-lifecycle/spec.md create mode 100644 openspec/changes/core-recording-lifecycle/specs/voice-note-format/spec.md 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` -- 2.47.2 From 75bac1eef944d7842491e3a65fdbf501c424ceaa Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 27 Sep 2026 14:59:31 +0400 Subject: [PATCH 02/22] =?UTF-8?q?docs(openspec):=20apply=20spec=20review?= =?UTF-8?q?=20=E2=80=94=20UTC=20id,=2010m=20stale=20sent,=20no-speech=20wo?= =?UTF-8?q?rding,=20list()=20contract;=20decisions=20log?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refs #1 Co-Authored-By: Claude Opus 5.5 --- .../core-recording-lifecycle/design.md | 38 +++++++++++++++---- .../specs/recording-lifecycle/spec.md | 12 +++--- .../specs/voice-note-format/spec.md | 8 ++-- 3 files changed, 41 insertions(+), 17 deletions(-) diff --git a/openspec/changes/core-recording-lifecycle/design.md b/openspec/changes/core-recording-lifecycle/design.md index cc063b2..508efa9 100644 --- a/openspec/changes/core-recording-lifecycle/design.md +++ b/openspec/changes/core-recording-lifecycle/design.md @@ -27,9 +27,9 @@ | Модуль | Ответственность | |---|---| -| `record.ts` | тип `VoiceRecord`, `Status`, `transition(record, event, now)`, `MAX_ATTEMPTS = 5`, `BACKOFF_MS = [30s, 2m, 10m, 30m]`, `SENT_STALE_MS = 5m` | +| `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(content)`, `renderNote(content, record, text?)`, построчный патч ключей `voice-*` во frontmatter, `initialNote(record)` | +| `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)` | @@ -45,8 +45,12 @@ interface RecordingStore { interface NoteStore { exists(path: string): Promise; create(path: string, content: string): Promise; - // все заметки vault, у которых во frontmatter есть voice-id - list(): Promise<{ path: string; content: string }[]>; + // Все заметки 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; } @@ -59,7 +63,7 @@ type TranscribeResult = interface Env { now(): Date; random(): number; deviceId: string; } ``` -`Env` объединяет часы, случайность и id устройства — в тестах всё детерминировано, что делает golden-сравнение побайтным. `NoteStore.process` в обёртке реализуется через `vault.process`. `list()` — через `metadataCache` по наличию `voice-id`. +`Env` объединяет часы, случайность и id устройства — в тестах всё детерминировано, что делает golden-сравнение побайтным. `NoteStore.process` в обёртке реализуется через `vault.process`. `list()` — только через `metadataCache.getFileCache(file)?.frontmatter` по наличию `voice-id`; поэтому порт отдаёт frontmatter-объект, а не текст заметки. ### Frontmatter правит core, одной записью @@ -67,7 +71,7 @@ interface Env { now(): Date; random(): number; deviceId: string; } ### Выполнение отправки -`tick()`: защита от повторного входа (флаг `running` + флаг `rerun`) → `list()` → `parseRecord` → просроченные `sent` своего устройства превращаются во временную ошибку → `due()` → по одной: `process` (статус `sent`, `voice-sent-at`) → `read` аудио → `transcribe` → `process` (`inserted` + текст или `retry`/`failed` + область). Если `process` вернул `false` — запись отменена, идём дальше. Исключение из `transcribe` или `read` трактуется как временная ошибка с его сообщением. +`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` трактуется как временная ошибка с его сообщением. ### Ручные команды @@ -88,4 +92,24 @@ interface Env { now(): Date; random(): number; deviceId: string; } - **Лаг iCloud.** Команда «забрать все» может отправить запись, которую телефон как раз отправляет. Цена — один лишний запрос; вставка идемпотентна по статусу. Принято. - **Самописный патч frontmatter.** Не покрывает многострочные YAML-значения в ключах `voice-*`; плагин сам их не пишет, а пользовательские ключи не трогает. Покрывается тестами. - **Потеря при жёстком убийстве процесса во время записи.** Принято до spike периодического сброса. -- **`list()` по всему vault.** На больших vault — через `metadataCache`, без чтения файлов, кроме найденных заметок. +- **`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», а не пропускается. *`*.m4a` в `.gitignore`; молчаливый skip дал бы ложно-зелёный прогон.* **На чистом клоне `npm test` красный, пока не положить фикстуру — осознанно.** +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`, а не текстом. *Рендер должен отличать её от обычной ошибки без разбора сообщений.* **Нулевая: значение внутреннее.** diff --git a/openspec/changes/core-recording-lifecycle/specs/recording-lifecycle/spec.md b/openspec/changes/core-recording-lifecycle/specs/recording-lifecycle/spec.md index 1583f1f..9f8f402 100644 --- a/openspec/changes/core-recording-lifecycle/specs/recording-lifecycle/spec.md +++ b/openspec/changes/core-recording-lifecycle/specs/recording-lifecycle/spec.md @@ -36,15 +36,15 @@ ### Requirement: Отправка и результат -Перед отправкой запись MUST перейти в `sent` с отметкой времени начала отправки, записанной в заметку. Очередь MUST обрабатывать записи строго по одной. Результат провайдера — успех с текстом, временная ошибка или постоянная ошибка. Успех с непустым текстом MUST переводить запись в `inserted` с текстом в заметке. Успех с пустым (после обрезки пробелов) текстом MUST трактоваться как постоянная ошибка «пустой транскрипт». +Перед отправкой запись MUST перейти в `sent` с отметкой времени начала отправки, записанной в заметку. Очередь MUST обрабатывать записи строго по одной. Результат провайдера — успех с текстом, временная ошибка или постоянная ошибка. Успех с непустым текстом MUST переводить запись в `inserted` с текстом в заметке. Успех с пустым (после обрезки пробелов) текстом MUST переводить запись в `failed` с причиной «нет речи»: это свойство записи, а не ошибка сервиса, повтор бесполезен. #### Scenario: Успешное распознавание - **WHEN** провайдер вернул текст для записи в `saved` - **THEN** заметка содержит текст и статус `inserted` -#### Scenario: Пустой транскрипт +#### Scenario: В записи нет речи - **WHEN** провайдер вернул успех с текстом из одних пробелов -- **THEN** запись в `failed` с причиной «пустой транскрипт» +- **THEN** запись в `failed` с причиной «нет речи», в заметке — «В записи не распознано речи» #### Scenario: Последовательная обработка - **WHEN** в очереди три готовые к отправке записи @@ -72,10 +72,10 @@ ### Requirement: Восстановление после прерванной отправки -Запись текущего устройства в `sent`, у которой с начала отправки прошло больше 5 минут, MUST считаться завершившейся временной ошибкой «отправка прервана» и обрабатываться по правилам повторов. +Запись текущего устройства в `sent`, у которой с начала отправки прошло больше 10 минут, MUST считаться завершившейся временной ошибкой «отправка прервана» и обрабатываться по правилам повторов. #### Scenario: Obsidian выгружен во время запроса -- **WHEN** очередь запускается в 14:30:00 и находит запись в `sent` с началом отправки 14:20:00 +- **WHEN** очередь запускается в 14:30:00 и находит запись в `sent` с началом отправки 14:19:00 - **THEN** запись учитывается как временная неудача и планируется к повтору #### Scenario: Запрос ещё может идти @@ -92,7 +92,7 @@ ### Requirement: Команда «Распознать все ожидающие здесь» -Команда MUST переназначить текущему устройству все записи других устройств в состояниях `saved`, `retry` и `sent` старше 5 минут, после чего обработать очередь без ожидания времени следующей попытки. Записи в `failed` и `inserted`, а также `sent` моложе 5 минут, MUST NOT затрагиваться. +Команда MUST переназначить текущему устройству все записи других устройств в состояниях `saved`, `retry` и `sent` старше 10 минут, после чего обработать очередь без ожидания времени следующей попытки. Записи в `failed` и `inserted`, а также `sent` не старше 10 минут, MUST NOT затрагиваться. #### Scenario: Мак забирает запись телефона - **WHEN** на маке выполнена команда, а в vault есть запись iPhone в `retry` со следующей попыткой через 20 минут 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 index 4aba567..349fb64 100644 --- a/openspec/changes/core-recording-lifecycle/specs/voice-note-format/spec.md +++ b/openspec/changes/core-recording-lifecycle/specs/voice-note-format/spec.md @@ -8,11 +8,11 @@ ### 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)` и т.д. +Идентификатор записи MUST иметь вид `YYYYMMDD-HHMMSS-xxxx` (время начала записи в UTC и 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` +- **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` уже существует @@ -32,7 +32,7 @@ ### Requirement: Тело заметки до распознавания -Новая заметка MUST содержать вложение аудио `![[<путь к аудио>]]`, затем управляемую область, ограниченную маркерами-комментариями `%%voice-note:%%` и `%%/voice-note%%`. Внутри области MUST отображаться состояние: для `saved`/`sent` — `> [!info] Распознаётся…`; для `retry` — `> [!info] Нет связи, повтор в HH:mm (попытка N/5)` по локальному времени; для `failed` — `> [!warning] Не распознано: <причина>. Команда «Повторить распознавание»`. Для прерванной записи над областью MUST стоять строка `> [!note] Запись прервана, сохранено M:SS`, которая остаётся и после вставки текста. +Новая заметка MUST содержать вложение аудио `![[<путь к аудио>]]`, затем управляемую область, ограниченную маркерами-комментариями `%%voice-note:%%` и `%%/voice-note%%`. Внутри области MUST отображаться состояние: для `saved`/`sent` — `> [!info] Распознаётся…`; для `retry` — `> [!info] Нет связи, повтор в HH:mm (попытка N/5)` по локальному времени; для `failed` — `> [!warning] Не распознано: <причина>. Команда «Повторить распознавание»`, кроме причины «нет речи», для которой — `> [!info] В записи не распознано речи` (без слова «ошибка» и без призыва повторить). Для прерванной записи над областью MUST стоять строка `> [!note] Запись прервана, сохранено M:SS`, которая остаётся и после вставки текста. #### Scenario: Заметка в ожидании повтора - **WHEN** запись перешла в `retry` после второй неудачи со следующей попыткой в 14:17 по местному времени -- 2.47.2 From 1cd72201e2e638a98b4f4155ea581ff2917f9a65 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 27 Sep 2026 15:08:33 +0400 Subject: [PATCH 03/22] test: track test-01.m4a fixture so golden tests pass on a clean clone Refs #1 Co-Authored-By: Claude Opus 5.5 --- .gitignore | 1 - .../changes/core-recording-lifecycle/design.md | 4 ++-- tests/fixtures/test-01.m4a | Bin 0 -> 86744 bytes 3 files changed, 2 insertions(+), 3 deletions(-) create mode 100644 tests/fixtures/test-01.m4a diff --git a/.gitignore b/.gitignore index 1ac7ca9..fed0ae4 100644 --- a/.gitignore +++ b/.gitignore @@ -4,7 +4,6 @@ main.js data.json .superpowers/ .bounded -tests/fixtures/*.m4a tests/fixtures/*.webm .DS_Store .claude/worktrees/ diff --git a/openspec/changes/core-recording-lifecycle/design.md b/openspec/changes/core-recording-lifecycle/design.md index 508efa9..5086277 100644 --- a/openspec/changes/core-recording-lifecycle/design.md +++ b/openspec/changes/core-recording-lifecycle/design.md @@ -85,7 +85,7 @@ interface Env { now(): Date; random(): number; deviceId: string; } Шаблон 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», а не пропускаться молча — иначе зелёный прогон будет ложным. +Аудиофикстуры `*.m4a` лежат в git (`test-01.m4a` ~85 КБ), так что `npm test` зелёный на чистом клоне. Если для эталона `.note.md` нет аудиофайла, golden-тест MUST падать с сообщением «нет аудиофикстуры tests/fixtures/.m4a», а не пропускаться молча. ## Risks / Trade-offs @@ -107,7 +107,7 @@ interface Env { now(): Date; random(): number; deviceId: string; } 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», а не пропускается. *`*.m4a` в `.gitignore`; молчаливый skip дал бы ложно-зелёный прогон.* **На чистом клоне `npm test` красный, пока не положить фикстуру — осознанно.** +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` обновляется асинхронно.* **Без перепроверки — повторная отправка только что отправленной записи.** diff --git a/tests/fixtures/test-01.m4a b/tests/fixtures/test-01.m4a new file mode 100644 index 0000000000000000000000000000000000000000..a6a2a8ebcd669e64308aac7116039e90bf419739 GIT binary patch literal 86744 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 literal 0 HcmV?d00001 -- 2.47.2 From cb9a691931ecea74090e8a5d8689a9c04535c52a Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 27 Sep 2026 15:14:25 +0400 Subject: [PATCH 04/22] docs: implementation plan for core recording lifecycle; planning decisions Refs #1 Co-Authored-By: Claude Opus 5.5 --- .../2026-09-27-core-recording-lifecycle.md | 2528 +++++++++++++++++ .../core-recording-lifecycle/design.md | 12 + 2 files changed, 2540 insertions(+) create mode 100644 docs/superpowers/plans/2026-09-27-core-recording-lifecycle.md diff --git a/docs/superpowers/plans/2026-09-27-core-recording-lifecycle.md b/docs/superpowers/plans/2026-09-27-core-recording-lifecycle.md new file mode 100644 index 0000000..688c538 --- /dev/null +++ b/docs/superpowers/plans/2026-09-27-core-recording-lifecycle.md @@ -0,0 +1,2528 @@ +# Core Recording Lifecycle Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Чистая подсистема `src/core/`: жизненный цикл голосовой записи, формат заметки, планировщик повторов и очередь отправки поверх портов — плюс тестовая инфраструктура проекта. + +**Architecture:** Чистые функции (`record.ts`, `naming.ts`, `note.ts`, `schedule.ts`) + два модуля, работающие через порты (`capture.ts`, `queue.ts`). Порты (`RecordingStore`, `NoteStore`, `Transcriber`, `Env`) реализует позже обёртка Obsidian; в тестах — in-memory фейки. Frontmatter и тело заметки меняются одной операцией `NoteStore.process`. + +**Tech Stack:** TypeScript (strict), esbuild (шаблон obsidian-sample-plugin), vitest, Node 24 только для тестов и сборки. + +**Spec:** `openspec/changes/core-recording-lifecycle/` — `proposal.md`, `design.md` (включая «Decisions made without discussion»), `specs/recording-lifecycle/spec.md`, `specs/voice-note-format/spec.md`. Issue: #1. Ветка: `feat/1-core-recording-lifecycle`. + +## Global Constraints + +- `src/core/**` MUST NOT импортировать `obsidian`, `fs`, `path`, `child_process`, `node:*` (проверяет `tests/core-boundary.test.ts`). +- Рантайм — только Web API; `manifest.json`: `"isDesktopOnly": false`. +- Папки: аудио `voice/source/`, заметки `voice/inbox/`. +- `voice-id` = `YYYYMMDD-HHMMSS-xxxx` по **UTC**; имя заметки `YYYY-MM-DD HH-mm-ss.md` по **локальному** времени; коллизия → ` (2)`, ` (3)`… +- Расширения: `audio/mp4`→`m4a`, `audio/webm`→`webm`, `audio/ogg`→`ogg`, иначе `bin` (mime без параметров, регистр игнорируется). +- `MAX_ATTEMPTS = 5`; паузы после 1–4 неудачи: 30 с, 2 мин, 10 мин, 30 мин; зависший `sent` — строго больше 10 минут. +- Пустой транскрипт → `failed` с `voice-error: no-speech`, в заметке `> [!info] В записи не распознано речи`. +- Маркеры области: `%%voice-note:%%` … `%%/voice-note%%`. +- Тексты статусов (дословно): `> [!info] Распознаётся…`; `> [!info] Нет связи, повтор в HH:mm (попытка N/5)`; `> [!warning] Не распознано: <причина>. Команда «Повторить распознавание»`; `> [!note] Запись прервана, сохранено M:SS`. +- Строковые значения `voice-*` во frontmatter — в двойных кавычках (JSON-экранирование), числа и `true` — без кавычек. +- Тесты запускаются с `TZ=Europe/Moscow` (UTC+3, без летнего времени) — выставляется в `vitest.config.ts`. +- `npm test` — весь набор, зелёным считается только весь набор. +- Любое решение, принятое исполнителем без обсуждения, дописывается в `openspec/changes/core-recording-lifecycle/design.md`, раздел `## Decisions made without discussion` (дата, решение, обоснование, цена ошибки). +- Коммиты заканчиваются строкой `Co-Authored-By: Claude Opus 5.5 ` и ссылкой `Refs #1`. + +## Review Focus + +1. Транскрипт содержит `$&`, `$'`, `%%` или `---` — должен вставиться дословно (Task 5: тест «special replacement patterns»). +2. Сообщение ошибки провайдера с переводами строк, кавычками и двоеточиями — frontmatter остаётся валидным однострочным, callout однострочный (Task 4: тест «round-trips hostile error text»; Task 5: тест «collapses multi-line error»). +3. `metadataCache` отстаёт: `list()` показывает `saved`, а в файле уже `sent`/`inserted` — повторной отправки нет (Task 8: тесты «stale list»). +4. Заметку удалили между `list()` и отправкой — пропуск без исключения, остальные записи обрабатываются (Task 8: тест «note deleted before send»). +5. `voice-next-attempt` испорчен руками (не дата) — запись считается готовой к отправке, а не зависает навсегда (Task 6: тест «unparsable nextAttempt is due»). + +## File Structure + +| Файл | Ответственность | +|---|---| +| `package.json`, `tsconfig.json`, `vitest.config.ts`, `esbuild.config.mjs`, `manifest.json` | сборка, типы, тесты | +| `src/main.ts` | пустой плагин, чтобы сборка работала (обёртка — другая issue) | +| `src/core/ports.ts` | интерфейсы портов и `TranscribeResult` | +| `src/core/naming.ts` | id, пути, расширения, форматирование времени/длительности | +| `src/core/record.ts` | `VoiceRecord`, константы, `transition` | +| `src/core/note.ts` | frontmatter `voice-*` (чтение, разбор, патч) и тело заметки (начальное, рендер, вставка) | +| `src/core/schedule.ts` | `due`, `isStaleSent`, `staleOwn`, `claimable` | +| `src/core/capture.ts` | `saveCapture` | +| `src/core/queue.ts` | `QueueRunner` | +| `tests/core-boundary.test.ts` | страж границы core | +| `tests/fakes.ts` | in-memory порты, `TestEnv` | +| `tests/*.test.ts` | по одному на модуль + `golden.test.ts` | +| `tests/fixtures/test-01.note.md` | эталон итоговой заметки | + +--- + +### Task 1: Scaffold, boundary guard, ports + +**Model:** sonnet + +**Files:** +- Create: `package.json`, `tsconfig.json`, `vitest.config.ts`, `esbuild.config.mjs`, `manifest.json`, `src/main.ts`, `src/core/ports.ts`, `tests/core-boundary.test.ts` + +**Interfaces:** +- Consumes: — +- Produces: `src/core/ports.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; list(): Promise; process(path: string, fn: (content: string) => string): Promise }` + - `interface ListedNote { path: string; frontmatter: Record }` + - `type TranscribeResult = { ok: true; text: string } | { ok: false; kind: 'temporary' | 'permanent'; message: string }` + - `interface Transcriber { transcribe(audio: ArrayBuffer, mime: string, signal: AbortSignal): Promise }` + - `interface Env { now(): Date; random(): number; readonly deviceId: string }` + - команды `npm test`, `npm run build`, `npm run dev`, `npm run typecheck` + +- [ ] **Step 1: package.json и зависимости** + +```json +{ + "name": "obsidian-voice-note", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "node esbuild.config.mjs", + "build": "tsc --noEmit && node esbuild.config.mjs production", + "typecheck": "tsc --noEmit", + "test": "vitest run" + } +} +``` + +Run: `npm install -D typescript esbuild vitest obsidian @types/node` +Expected: `node_modules/` создан, в `package.json` появились `devDependencies`, создан `package-lock.json`. + +- [ ] **Step 2: tsconfig.json, vitest.config.ts** + +`tsconfig.json`: + +```json +{ + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ES2022", "DOM"], + "types": ["node"], + "strict": true, + "noEmit": true, + "isolatedModules": true, + "skipLibCheck": true, + "noImplicitOverride": true + }, + "include": ["src/**/*.ts", "tests/**/*.ts", "vitest.config.ts"] +} +``` + +`vitest.config.ts`: + +```ts +import { defineConfig } from 'vitest/config'; + +// Детерминированное локальное время для имён заметок и строк статуса: +// UTC+3 без перехода на летнее время. Воркеры vitest наследуют env. +process.env.TZ = 'Europe/Moscow'; + +export default defineConfig({ + test: { + include: ['tests/**/*.test.ts'], + }, +}); +``` + +- [ ] **Step 3: Написать падающий тест-страж** + +`tests/core-boundary.test.ts`: + +```ts +import { existsSync, readdirSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; + +const CORE = fileURLToPath(new URL('../src/core/', import.meta.url)); +const FORBIDDEN = + /(?:from\s+|import\s*\(\s*|require\s*\(\s*)['"](?:obsidian|fs|path|child_process|node:[^'"]*)(?:\/[^'"]*)?['"]/; + +function coreFiles(): string[] { + if (!existsSync(CORE)) return []; + return readdirSync(CORE, { recursive: true, encoding: 'utf8' }) + .filter((f) => f.endsWith('.ts')) + .map((f) => join(CORE, f)); +} + +describe('src/core boundary', () => { + it('detects forbidden imports', () => { + expect(FORBIDDEN.test(`import { Plugin } from 'obsidian';`)).toBe(true); + expect(FORBIDDEN.test(`import { readFile } from "fs/promises";`)).toBe(true); + expect(FORBIDDEN.test(`import x from 'node:path';`)).toBe(true); + expect(FORBIDDEN.test(`const cp = require('child_process');`)).toBe(true); + expect(FORBIDDEN.test(`import { transition } from './record';`)).toBe(false); + }); + + it('has files to check', () => { + expect(coreFiles().length).toBeGreaterThan(0); + }); + + it('imports neither obsidian nor Node modules', () => { + const offenders = coreFiles().filter((f) => FORBIDDEN.test(readFileSync(f, 'utf8'))); + expect(offenders).toEqual([]); + }); +}); +``` + +- [ ] **Step 4: Убедиться, что тест падает** + +Run: `npm test` +Expected: FAIL в `has files to check` (`expected 0 to be greater than 0`). + +- [ ] **Step 5: ports.ts** + +`src/core/ports.ts`: + +```ts +// Порты core. Реализации — в src/obsidian (позже) и tests/fakes.ts. + +export interface RecordingStore { + write(path: string, data: ArrayBuffer): Promise; + read(path: string): Promise; +} + +export interface ListedNote { + path: string; + frontmatter: Record; +} + +export 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; + // Атомарное чтение-изменение-запись. false, если заметки нет. + process(path: string, fn: (content: string) => string): Promise; +} + +export type TranscribeResult = + | { ok: true; text: string } + | { ok: false; kind: 'temporary' | 'permanent'; message: string }; + +export interface Transcriber { + transcribe(audio: ArrayBuffer, mime: string, signal: AbortSignal): Promise; +} + +export interface Env { + now(): Date; + random(): number; + readonly deviceId: string; +} +``` + +- [ ] **Step 6: Убедиться, что тест проходит** + +Run: `npm test` +Expected: PASS, 3 теста. + +- [ ] **Step 7: Сборка: manifest, main.ts, esbuild** + +`manifest.json`: + +```json +{ + "id": "voice-note", + "name": "Voice Note", + "version": "0.1.0", + "minAppVersion": "1.5.0", + "description": "Голосовые заметки: запись, распознавание внешним сервисом, текст в заметку.", + "author": "Andrey Epifancev", + "isDesktopOnly": false +} +``` + +`src/main.ts`: + +```ts +import { Plugin } from 'obsidian'; + +// Обёртка Obsidian — отдельная подсистема. Пока пустой плагин, чтобы сборка работала. +export default class VoiceNotePlugin extends Plugin { + override async onload(): Promise {} +} +``` + +`esbuild.config.mjs`: + +```js +import esbuild from 'esbuild'; +import { copyFileSync, existsSync, mkdirSync } from 'node:fs'; +import { builtinModules } from 'node:module'; +import process from 'node:process'; + +const prod = process.argv[2] === 'production'; +const VAULT = '/Users/andrey.epifancev/Library/Mobile Documents/iCloud~md~obsidian/Documents/voice-note-test'; +const PLUGIN_DIR = `${VAULT}/.obsidian/plugins/voice-note`; + +// В dev-режиме после каждой сборки копируем плагин в тестовый vault, если он есть на этой машине. +const copyToVault = { + name: 'copy-to-vault', + setup(build) { + build.onEnd((result) => { + if (result.errors.length > 0 || !existsSync(VAULT)) return; + mkdirSync(PLUGIN_DIR, { recursive: true }); + for (const file of ['main.js', 'manifest.json']) copyFileSync(file, `${PLUGIN_DIR}/${file}`); + }); + }, +}; + +const context = await esbuild.context({ + entryPoints: ['src/main.ts'], + bundle: true, + external: [ + 'obsidian', + 'electron', + '@codemirror/autocomplete', + '@codemirror/collab', + '@codemirror/commands', + '@codemirror/language', + '@codemirror/lint', + '@codemirror/search', + '@codemirror/state', + '@codemirror/view', + '@lezer/common', + '@lezer/highlight', + '@lezer/lr', + ...builtinModules, + ], + format: 'cjs', + target: 'es2018', + logLevel: 'info', + sourcemap: prod ? false : 'inline', + treeShaking: true, + outfile: 'main.js', + minify: prod, + plugins: prod ? [] : [copyToVault], +}); + +if (prod) { + await context.rebuild(); + process.exit(0); +} else { + await context.watch(); +} +``` + +- [ ] **Step 8: Проверить сборку и типы** + +Run: `npm run build && ls -la main.js` +Expected: exit 0, `main.js` существует (он в `.gitignore`). + +Run: `npm test` +Expected: PASS. + +- [ ] **Step 9: Commit** + +```bash +git add package.json package-lock.json tsconfig.json vitest.config.ts esbuild.config.mjs manifest.json src/main.ts src/core/ports.ts tests/core-boundary.test.ts +git commit -m "chore: scaffold build and vitest, add core ports and boundary guard + +Refs #1 + +Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +### Task 2: naming + +**Model:** haiku + +**Files:** +- Create: `src/core/naming.ts`, `tests/naming.test.ts` + +**Interfaces:** +- Consumes: — +- Produces: + - `SOURCE_DIR = 'voice/source'`, `INBOX_DIR = 'voice/inbox'` + - `makeId(startedAt: Date, random: () => number): string` + - `extFromMime(mime: string): string` + - `audioPath(id: string, mime: string): string` + - `notePath(startedAt: Date, n: number): string` (n=1 — без суффикса) + - `formatDuration(ms: number): string` → `M:SS` + - `formatLocalTime(iso: string): string` → `HH:mm` по локальному времени + +- [ ] **Step 1: Написать падающие тесты** + +`tests/naming.test.ts`: + +```ts +import { describe, expect, it } from 'vitest'; +import { + audioPath, + extFromMime, + formatDuration, + formatLocalTime, + makeId, + notePath, +} from '../src/core/naming'; + +const T = new Date('2026-09-27T11:15:03.000Z'); // 14:15:03 по Москве + +function seq(...values: number[]): () => number { + return () => values.shift() ?? 0; +} + +describe('test environment', () => { + it('runs in UTC+3', () => { + expect(T.getHours()).toBe(14); + }); +}); + +describe('makeId', () => { + it('uses UTC time and 4 hex chars', () => { + expect(makeId(T, () => 0)).toBe('20260927-111503-0000'); + expect(makeId(T, seq(0.99, 0.5, 0.0625, 0.1))).toBe('20260927-111503-f811'); + }); + + it('clamps random() === 1 to f', () => { + expect(makeId(T, () => 1)).toBe('20260927-111503-ffff'); + }); + + it('does not depend on the local date', () => { + const lateEvening = new Date('2026-09-27T22:30:00.000Z'); // 01:30 28-го по Москве + expect(makeId(lateEvening, () => 0)).toBe('20260927-223000-0000'); + expect(notePath(lateEvening, 1)).toBe('voice/inbox/2026-09-28 01-30-00.md'); + }); +}); + +describe('extFromMime', () => { + it.each([ + ['audio/mp4', 'm4a'], + ['audio/webm', 'webm'], + ['audio/webm;codecs=opus', 'webm'], + ['AUDIO/MP4', 'm4a'], + ['audio/ogg; codecs=opus', 'ogg'], + ['audio/x-foo', 'bin'], + ['', 'bin'], + ])('%s → %s', (mime, ext) => { + expect(extFromMime(mime)).toBe(ext); + }); +}); + +describe('paths', () => { + it('audioPath', () => { + expect(audioPath('20260927-111503-0000', 'audio/mp4')).toBe('voice/source/20260927-111503-0000.m4a'); + }); + + it('notePath uses local time and suffix from 2', () => { + expect(notePath(T, 1)).toBe('voice/inbox/2026-09-27 14-15-03.md'); + expect(notePath(T, 2)).toBe('voice/inbox/2026-09-27 14-15-03 (2).md'); + expect(notePath(T, 3)).toBe('voice/inbox/2026-09-27 14-15-03 (3).md'); + }); +}); + +describe('formatting', () => { + it.each([ + [0, '0:00'], + [47_000, '0:47'], + [125_400, '2:05'], + [-5, '0:00'], + ])('formatDuration(%d) = %s', (ms, text) => { + expect(formatDuration(ms)).toBe(text); + }); + + it('formatLocalTime', () => { + expect(formatLocalTime('2026-09-27T11:17:03.000Z')).toBe('14:17'); + }); +}); +``` + +- [ ] **Step 2: Убедиться, что тесты падают** + +Run: `npx vitest run tests/naming.test.ts` +Expected: FAIL — `Failed to resolve import "../src/core/naming"`. + +- [ ] **Step 3: Реализация** + +`src/core/naming.ts`: + +```ts +export const SOURCE_DIR = 'voice/source'; +export const INBOX_DIR = 'voice/inbox'; + +const pad = (n: number) => String(n).padStart(2, '0'); + +// Время в id — UTC: порядок id не зависит от пояса устройства. +export function makeId(startedAt: Date, random: () => number): string { + const d = startedAt; + const date = `${d.getUTCFullYear()}${pad(d.getUTCMonth() + 1)}${pad(d.getUTCDate())}`; + const time = `${pad(d.getUTCHours())}${pad(d.getUTCMinutes())}${pad(d.getUTCSeconds())}`; + let hex = ''; + for (let i = 0; i < 4; i++) hex += Math.min(15, Math.floor(random() * 16)).toString(16); + return `${date}-${time}-${hex}`; +} + +const EXT: Record = { + 'audio/mp4': 'm4a', + 'audio/webm': 'webm', + 'audio/ogg': 'ogg', +}; + +export function extFromMime(mime: string): string { + const base = mime.split(';')[0].trim().toLowerCase(); + return EXT[base] ?? 'bin'; +} + +export function audioPath(id: string, mime: string): string { + return `${SOURCE_DIR}/${id}.${extFromMime(mime)}`; +} + +// Имя заметки — для человека, поэтому в локальном времени. +export function notePath(startedAt: Date, n: number): string { + const d = startedAt; + const name = + `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ` + + `${pad(d.getHours())}-${pad(d.getMinutes())}-${pad(d.getSeconds())}`; + return `${INBOX_DIR}/${name}${n > 1 ? ` (${n})` : ''}.md`; +} + +export function formatDuration(ms: number): string { + const total = Math.max(0, Math.round(ms / 1000)); + return `${Math.floor(total / 60)}:${pad(total % 60)}`; +} + +export function formatLocalTime(iso: string): string { + const d = new Date(iso); + return `${pad(d.getHours())}:${pad(d.getMinutes())}`; +} +``` + +- [ ] **Step 4: Убедиться, что тесты проходят** + +Run: `npm test` +Expected: PASS (все файлы). + +- [ ] **Step 5: Commit** + +```bash +git add src/core/naming.ts tests/naming.test.ts +git commit -m "feat(core): record ids, file paths, time formatting + +Refs #1 + +Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +### Task 3: record state machine + +**Model:** sonnet + +**Files:** +- Create: `src/core/record.ts`, `tests/record.test.ts` + +**Interfaces:** +- Consumes: — +- Produces: + - `type Status = 'saved' | 'sent' | 'retry' | 'failed' | 'inserted'`, `STATUSES: readonly Status[]` + - `MAX_ATTEMPTS = 5`, `BACKOFF_MS: readonly number[]` (`[30_000, 120_000, 600_000, 1_800_000]`), `SENT_STALE_MS = 600_000`, `NO_SPEECH = 'no-speech'` + - `interface VoiceRecord { id; status; device; audio (путь без [[ ]]); mime; attempts: number; nextAttempt?: string; sentAt?: string; error?: string; interrupted?: boolean }` + - `type RecordEvent = {type:'send'} | {type:'success'} | {type:'temporary';message} | {type:'permanent';message} | {type:'retry-now';device} | {type:'claim';device}` + - `class InvalidTransition extends Error` + - `transition(record: VoiceRecord, event: RecordEvent, now: Date): VoiceRecord` — чистая, бросает `InvalidTransition` + +- [ ] **Step 1: Написать падающие тесты** + +`tests/record.test.ts`: + +```ts +import { describe, expect, it } from 'vitest'; +import { + BACKOFF_MS, + InvalidTransition, + MAX_ATTEMPTS, + transition, + type RecordEvent, + type Status, + type VoiceRecord, +} from '../src/core/record'; + +const NOW = new Date('2026-09-27T11:15:03.000Z'); +const base: VoiceRecord = Object.freeze({ + id: '20260927-111503-0000', + status: 'saved', + device: 'd1', + audio: 'voice/source/20260927-111503-0000.m4a', + mime: 'audio/mp4', + attempts: 0, +}); +const sent = (over: Partial = {}): VoiceRecord => + Object.freeze({ ...base, status: 'sent' as const, sentAt: NOW.toISOString(), ...over }); + +describe('transition', () => { + it('send: saved → sent with sentAt', () => { + expect(transition(base, { type: 'send' }, NOW)).toEqual({ + ...base, + status: 'sent', + sentAt: '2026-09-27T11:15:03.000Z', + }); + }); + + it('send: retry → sent, drops nextAttempt, keeps attempts and error', () => { + const r = Object.freeze({ ...base, status: 'retry' as const, attempts: 1, nextAttempt: 'x', error: 'timeout' }); + expect(transition(r, { type: 'send' }, NOW)).toEqual({ + ...base, + status: 'sent', + attempts: 1, + error: 'timeout', + sentAt: NOW.toISOString(), + }); + }); + + it('success: sent → inserted, clears transient keys, keeps attempts', () => { + expect(transition(sent({ error: 'old', attempts: 2 }), { type: 'success' }, NOW)).toEqual({ + ...base, + status: 'inserted', + attempts: 2, + }); + }); + + it('temporary: backoff 30s, 2m, 10m, 30m after failures 1..4', () => { + const got = [0, 1, 2, 3].map((a) => + transition(sent({ attempts: a }), { type: 'temporary', message: 'timeout' }, NOW), + ); + expect(got.map((r) => r.status)).toEqual(['retry', 'retry', 'retry', 'retry']); + expect(got.map((r) => r.attempts)).toEqual([1, 2, 3, 4]); + expect(got.map((r) => Date.parse(r.nextAttempt!) - NOW.getTime())).toEqual([...BACKOFF_MS]); + expect(got.every((r) => r.sentAt === undefined && r.error === 'timeout')).toBe(true); + }); + + it('temporary: fifth failure → failed with last message', () => { + expect( + transition(sent({ attempts: MAX_ATTEMPTS - 1 }), { type: 'temporary', message: 'e5' }, NOW), + ).toEqual({ ...base, status: 'failed', attempts: 5, error: 'e5' }); + }); + + it('permanent: sent → failed immediately', () => { + expect(transition(sent(), { type: 'permanent', message: '401 Unauthorized' }, NOW)).toEqual({ + ...base, + status: 'failed', + attempts: 1, + error: '401 Unauthorized', + }); + }); + + it('retry-now: failed → saved, attempts 0, device reassigned', () => { + const r = Object.freeze({ ...base, status: 'failed' as const, attempts: 5, error: '401' }); + expect(transition(r, { type: 'retry-now', device: 'd2' }, NOW)).toEqual({ ...base, device: 'd2' }); + }); + + it('retry-now: retry → saved, drops nextAttempt', () => { + const r = Object.freeze({ ...base, status: 'retry' as const, attempts: 2, nextAttempt: 'x', error: 't' }); + expect(transition(r, { type: 'retry-now', device: 'd1' }, NOW)).toEqual(base); + }); + + it('claim: retry → saved, new device, attempts kept', () => { + const r = Object.freeze({ ...base, status: 'retry' as const, attempts: 2, nextAttempt: 'x', error: 't' }); + expect(transition(r, { type: 'claim', device: 'd2' }, NOW)).toEqual({ + ...base, + device: 'd2', + attempts: 2, + error: 't', + }); + }); + + it('claim: sent → saved, drops sentAt', () => { + expect(transition(sent(), { type: 'claim', device: 'd2' }, NOW)).toEqual({ ...base, device: 'd2' }); + }); + + const invalid: [Status, RecordEvent][] = [ + ['inserted', { type: 'send' }], + ['failed', { type: 'send' }], + ['sent', { type: 'send' }], + ['saved', { type: 'success' }], + ['retry', { type: 'success' }], + ['saved', { type: 'temporary', message: 'x' }], + ['inserted', { type: 'permanent', message: 'x' }], + ['saved', { type: 'retry-now', device: 'd1' }], + ['inserted', { type: 'retry-now', device: 'd1' }], + ['failed', { type: 'claim', device: 'd2' }], + ['inserted', { type: 'claim', device: 'd2' }], + ]; + + it.each(invalid)('rejects %s + %o', (status, event) => { + const r = Object.freeze({ ...base, status }); + expect(() => transition(r, event, NOW)).toThrow(InvalidTransition); + }); +}); +``` + +- [ ] **Step 2: Убедиться, что тесты падают** + +Run: `npx vitest run tests/record.test.ts` +Expected: FAIL — `Failed to resolve import "../src/core/record"`. + +- [ ] **Step 3: Реализация** + +`src/core/record.ts`: + +```ts +// Персистентные состояния. created и transcribed живут только в памяти и сюда не входят. +export type Status = 'saved' | 'sent' | 'retry' | 'failed' | 'inserted'; +export const STATUSES: readonly Status[] = ['saved', 'sent', 'retry', 'failed', 'inserted']; + +export const MAX_ATTEMPTS = 5; +// Пауза перед повтором после 1-й, 2-й, 3-й и 4-й временной неудачи. +export const BACKOFF_MS: readonly number[] = [30_000, 120_000, 600_000, 1_800_000]; +// sent старше этого считается прерванной отправкой. +export const SENT_STALE_MS = 600_000; +// Код причины в voice-error для пустого транскрипта. +export const NO_SPEECH = 'no-speech'; + +export interface VoiceRecord { + id: string; + status: Status; + device: string; + audio: string; // путь в vault, без [[ ]] + mime: string; + attempts: number; + nextAttempt?: string; // ISO UTC + sentAt?: string; // ISO UTC + error?: string; + interrupted?: boolean; +} + +export type RecordEvent = + | { type: 'send' } + | { type: 'success' } + | { type: 'temporary'; message: string } + | { type: 'permanent'; message: string } + | { type: 'retry-now'; device: string } + | { type: 'claim'; device: string }; + +export class InvalidTransition extends Error { + constructor(from: Status, event: RecordEvent['type']) { + super(`invalid transition: ${event} from ${from}`); + this.name = 'InvalidTransition'; + } +} + +type Transient = 'nextAttempt' | 'sentAt' | 'error'; + +function without(record: VoiceRecord, ...keys: Transient[]): VoiceRecord { + const copy = { ...record }; + for (const key of keys) delete copy[key]; + return copy; +} + +export function transition(record: VoiceRecord, event: RecordEvent, now: Date): VoiceRecord { + const from = record.status; + switch (event.type) { + case 'send': + if (from !== 'saved' && from !== 'retry') break; + return { ...without(record, 'nextAttempt'), status: 'sent', sentAt: now.toISOString() }; + case 'success': + if (from !== 'sent') break; + return { ...without(record, 'nextAttempt', 'sentAt', 'error'), status: 'inserted' }; + case 'temporary': { + if (from !== 'sent') break; + const attempts = record.attempts + 1; + const next = { ...without(record, 'sentAt', 'nextAttempt'), attempts, error: event.message }; + if (attempts >= MAX_ATTEMPTS) return { ...next, status: 'failed' }; + const at = new Date(now.getTime() + BACKOFF_MS[attempts - 1]).toISOString(); + return { ...next, status: 'retry', nextAttempt: at }; + } + case 'permanent': + if (from !== 'sent') break; + return { + ...without(record, 'sentAt', 'nextAttempt'), + status: 'failed', + attempts: record.attempts + 1, + error: event.message, + }; + case 'retry-now': + if (from !== 'failed' && from !== 'retry') break; + return { + ...without(record, 'nextAttempt', 'sentAt', 'error'), + status: 'saved', + attempts: 0, + device: event.device, + }; + case 'claim': + if (from !== 'saved' && from !== 'retry' && from !== 'sent') break; + return { ...without(record, 'nextAttempt', 'sentAt'), status: 'saved', device: event.device }; + } + throw new InvalidTransition(from, event.type); +} +``` + +- [ ] **Step 4: Убедиться, что тесты проходят** + +Run: `npm test` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/core/record.ts tests/record.test.ts +git commit -m "feat(core): voice record state machine with backoff + +Refs #1 + +Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +### Task 4: note frontmatter (read, parse, patch) + +**Model:** sonnet + +**Files:** +- Create: `src/core/note.ts`, `tests/note-frontmatter.test.ts` + +**Interfaces:** +- Consumes: из `record.ts`: `STATUSES`, `Status`, `VoiceRecord`. +- Produces (в `src/core/note.ts`): + - `VOICE_KEYS` (порядок ключей), `type VoiceKey` + - `recordToFields(r: VoiceRecord): Record` — значения уже в YAML-виде + - `readVoiceFields(content: string): Record` — ключи `voice-*` из frontmatter текста, значения раскавычены + - `parseRecord(fields: Record): VoiceRecord | null` — принимает и строки (из `readVoiceFields`), и типизированные значения (из `metadataCache`) + - `patchFrontmatter(content: string, fields: Record): string` + +- [ ] **Step 1: Написать падающие тесты** + +`tests/note-frontmatter.test.ts`: + +```ts +import { describe, expect, it } from 'vitest'; +import { parseRecord, patchFrontmatter, readVoiceFields, recordToFields } from '../src/core/note'; +import type { VoiceRecord } from '../src/core/record'; + +const rec: VoiceRecord = { + id: '20260927-111503-0000', + status: 'retry', + device: 'd1', + audio: 'voice/source/20260927-111503-0000.m4a', + mime: 'audio/webm;codecs=opus', + attempts: 2, + nextAttempt: '2026-09-27T11:17:03.000Z', + error: 'timeout', + interrupted: true, +}; + +describe('recordToFields + patchFrontmatter', () => { + it('writes keys in fixed order with quoted strings', () => { + expect(patchFrontmatter('', recordToFields(rec))).toBe( + [ + '---', + 'voice-id: "20260927-111503-0000"', + 'voice-status: "retry"', + 'voice-device: "d1"', + 'voice-audio: "[[voice/source/20260927-111503-0000.m4a]]"', + 'voice-mime: "audio/webm;codecs=opus"', + 'voice-attempts: 2', + 'voice-next-attempt: "2026-09-27T11:17:03.000Z"', + 'voice-error: "timeout"', + 'voice-interrupted: true', + '---', + '', + ].join('\n'), + ); + }); + + it('round-trips through readVoiceFields + parseRecord', () => { + const content = patchFrontmatter('', recordToFields(rec)); + expect(parseRecord(readVoiceFields(content))).toEqual(rec); + }); + + it('round-trips hostile error text as a single line', () => { + const hostile = { ...rec, error: 'Error: "bad"\nline two: x \\ y — ошибка' }; + const content = patchFrontmatter('', recordToFields(hostile)); + const errorLines = content.split('\n').filter((l) => l.startsWith('voice-error:')); + expect(errorLines).toHaveLength(1); + expect(content.split('\n').filter((l) => l.startsWith('line two'))).toHaveLength(0); + expect(parseRecord(readVoiceFields(content))).toEqual(hostile); + }); +}); + +describe('patchFrontmatter', () => { + const user = '---\ntitle: Идея\nvoice-status: "saved"\ntags: [idea]\n---\nbody\n'; + + it('replaces in place and keeps user keys and order', () => { + expect(patchFrontmatter(user, { 'voice-status': '"inserted"' })).toBe( + '---\ntitle: Идея\nvoice-status: "inserted"\ntags: [idea]\n---\nbody\n', + ); + }); + + it('appends new keys at the end of the block', () => { + expect(patchFrontmatter(user, { 'voice-sent-at': '"t"' })).toBe( + '---\ntitle: Идея\nvoice-status: "saved"\ntags: [idea]\nvoice-sent-at: "t"\n---\nbody\n', + ); + }); + + it('removes keys set to undefined, ignores absent ones', () => { + expect(patchFrontmatter(user, { 'voice-status': undefined, 'voice-error': undefined })).toBe( + '---\ntitle: Идея\ntags: [idea]\n---\nbody\n', + ); + }); + + it('creates a block when there is none, body untouched', () => { + expect(patchFrontmatter('body\n', { 'voice-id': '"x"' })).toBe('---\nvoice-id: "x"\n---\nbody\n'); + }); + + it('does not match a longer key with the same prefix', () => { + expect(patchFrontmatter('---\nvoice-idx: 1\n---\n', { 'voice-id': '"x"' })).toBe( + '---\nvoice-idx: 1\nvoice-id: "x"\n---\n', + ); + }); +}); + +describe('readVoiceFields', () => { + it('reads only the frontmatter block', () => { + const content = '---\nvoice-id: "a"\nother: 1\n---\nvoice-id: "b"\n'; + expect(readVoiceFields(content)).toEqual({ 'voice-id': 'a' }); + }); + + it('returns {} without frontmatter', () => { + expect(readVoiceFields('voice-id: "a"\n')).toEqual({}); + }); + + it('accepts plain and single-quoted values', () => { + expect(readVoiceFields("---\nvoice-id: abc\nvoice-mime: 'audio/mp4'\n---\n")).toEqual({ + 'voice-id': 'abc', + 'voice-mime': 'audio/mp4', + }); + }); +}); + +describe('parseRecord', () => { + const typed = { + 'voice-id': '20260927-111503-0000', + 'voice-status': 'saved', + 'voice-device': 'd1', + 'voice-audio': '[[voice/source/20260927-111503-0000.m4a]]', + 'voice-mime': 'audio/mp4', + 'voice-attempts': 0, + }; + + it('accepts metadataCache-style typed values', () => { + expect(parseRecord({ ...typed, 'voice-attempts': 3, 'voice-interrupted': true })).toEqual({ + id: '20260927-111503-0000', + status: 'saved', + device: 'd1', + audio: 'voice/source/20260927-111503-0000.m4a', + mime: 'audio/mp4', + attempts: 3, + interrupted: true, + }); + }); + + it('defaults missing attempts to 0', () => { + const { 'voice-attempts': _omit, ...rest } = typed; + expect(parseRecord(rest)?.attempts).toBe(0); + }); + + it.each([ + ['missing id', { ...typed, 'voice-id': undefined }], + ['bad status', { ...typed, 'voice-status': 'foo' }], + ['negative attempts', { ...typed, 'voice-attempts': -1 }], + ['non-numeric attempts', { ...typed, 'voice-attempts': 'abc' }], + ['object audio', { ...typed, 'voice-audio': { a: 1 } }], + ])('returns null for %s', (_name, fields) => { + expect(parseRecord(fields)).toBeNull(); + }); +}); +``` + +- [ ] **Step 2: Убедиться, что тесты падают** + +Run: `npx vitest run tests/note-frontmatter.test.ts` +Expected: FAIL — `Failed to resolve import "../src/core/note"`. + +- [ ] **Step 3: Реализация** + +`src/core/note.ts`: + +```ts +import { STATUSES, type Status, type VoiceRecord } from './record'; + +// Порядок, в котором ключи появляются в новой заметке. +export const VOICE_KEYS = [ + 'voice-id', + 'voice-status', + 'voice-device', + 'voice-audio', + 'voice-mime', + 'voice-attempts', + 'voice-next-attempt', + 'voice-sent-at', + 'voice-error', + 'voice-interrupted', +] as const; +export type VoiceKey = (typeof VOICE_KEYS)[number]; + +// Строки — всегда в двойных кавычках: JSON-строка является валидной YAML-строкой +// и экранирует переводы строк, кавычки и двоеточия в одну строку. +function yaml(value: string | number): string { + return typeof value === 'string' ? JSON.stringify(value) : String(value); +} + +export function recordToFields(r: VoiceRecord): Record { + return { + 'voice-id': yaml(r.id), + 'voice-status': yaml(r.status), + 'voice-device': yaml(r.device), + 'voice-audio': yaml(`[[${r.audio}]]`), + 'voice-mime': yaml(r.mime), + 'voice-attempts': yaml(r.attempts), + 'voice-next-attempt': r.nextAttempt === undefined ? undefined : yaml(r.nextAttempt), + 'voice-sent-at': r.sentAt === undefined ? undefined : yaml(r.sentAt), + 'voice-error': r.error === undefined ? undefined : yaml(r.error), + 'voice-interrupted': r.interrupted ? 'true' : undefined, + }; +} + +// [первая строка внутри блока, индекс закрывающего ---] +function frontmatterRange(lines: string[]): [number, number] | null { + if (lines[0] !== '---') return null; + const end = lines.indexOf('---', 1); + return end === -1 ? null : [1, end]; +} + +function unquote(raw: string): string { + if (raw.startsWith('"')) { + try { + const value: unknown = JSON.parse(raw); + if (typeof value === 'string') return value; + } catch { + // не JSON — вернём как есть + } + } + if (raw.length >= 2 && raw.startsWith("'") && raw.endsWith("'")) { + return raw.slice(1, -1).replace(/''/g, "'"); + } + return raw; +} + +const VOICE_LINE = /^(voice-[a-z-]+):[ \t]*(.*)$/; + +export function readVoiceFields(content: string): Record { + const lines = content.split('\n'); + const range = frontmatterRange(lines); + const fields: Record = {}; + if (!range) return fields; + for (let i = range[0]; i < range[1]; i++) { + const match = VOICE_LINE.exec(lines[i]); + if (match) fields[match[1]] = unquote(match[2].trim()); + } + return fields; +} + +function str(value: unknown): string | undefined { + if (typeof value === 'string') return value; + if (typeof value === 'number' || typeof value === 'boolean') return String(value); + return undefined; +} + +export function parseRecord(fields: Record): VoiceRecord | null { + const id = str(fields['voice-id']); + const status = str(fields['voice-status']); + const device = str(fields['voice-device']); + const audio = str(fields['voice-audio']); + const mime = str(fields['voice-mime']); + const attempts = Number(str(fields['voice-attempts']) ?? '0'); + if (!id || !device || !audio || !mime) return null; + if (!STATUSES.includes(status as Status)) return null; + if (!Number.isInteger(attempts) || attempts < 0) return null; + + const record: VoiceRecord = { + id, + status: status as Status, + device, + audio: audio.replace(/^\[\[/, '').replace(/\]\]$/, ''), + mime, + attempts, + }; + const nextAttempt = str(fields['voice-next-attempt']); + const sentAt = str(fields['voice-sent-at']); + const error = str(fields['voice-error']); + if (nextAttempt) record.nextAttempt = nextAttempt; + if (sentAt) record.sentAt = sentAt; + if (error) record.error = error; + if (str(fields['voice-interrupted']) === 'true') record.interrupted = true; + return record; +} + +// Меняет только перечисленные ключи; undefined — удалить строку. +// Остальные строки frontmatter и их порядок не трогаются. +export function patchFrontmatter(content: string, fields: Record): string { + const lines = content.split('\n'); + let range = frontmatterRange(lines); + if (!range) { + lines.unshift('---', '---'); + range = [1, 1]; + } + const start = range[0]; + let end = range[1]; + for (const key of Object.keys(fields)) { + const value = fields[key]; + let index = -1; + for (let i = start; i < end; i++) { + if (lines[i].startsWith(`${key}:`)) { + index = i; + break; + } + } + if (value === undefined) { + if (index !== -1) { + lines.splice(index, 1); + end--; + } + } else if (index !== -1) { + lines[index] = `${key}: ${value}`; + } else { + lines.splice(end, 0, `${key}: ${value}`); + end++; + } + } + return lines.join('\n'); +} +``` + +- [ ] **Step 4: Убедиться, что тесты проходят** + +Run: `npm test` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/core/note.ts tests/note-frontmatter.test.ts +git commit -m "feat(core): read, parse and patch voice-* frontmatter + +Refs #1 + +Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +### Task 5: note body (initial note, status region, transcript insertion) + +**Model:** sonnet + +**Files:** +- Modify: `src/core/note.ts` (дописать в конец) +- Create: `tests/note-body.test.ts` + +**Interfaces:** +- Consumes: `recordToFields`, `patchFrontmatter`, `readVoiceFields` (Task 4); `MAX_ATTEMPTS`, `NO_SPEECH`, `VoiceRecord` (Task 3); `formatDuration`, `formatLocalTime` (Task 2). +- Produces: + - `MARKER_END = '%%/voice-note%%'`, `markerStart(id: string): string` + - `statusLine(r: VoiceRecord): string` + - `initialNote(record: VoiceRecord, durationMs: number): string` + - `renderNote(content: string, record: VoiceRecord, text?: string): string` — `text` обязателен при `record.status === 'inserted'` + +- [ ] **Step 1: Написать падающие тесты** + +`tests/note-body.test.ts`: + +```ts +import { describe, expect, it } from 'vitest'; +import { initialNote, readVoiceFields, renderNote } from '../src/core/note'; +import { NO_SPEECH, type VoiceRecord } from '../src/core/record'; + +const saved: VoiceRecord = { + id: '20260927-111503-0000', + status: 'saved', + device: 'd1', + audio: 'voice/source/20260927-111503-0000.m4a', + mime: 'audio/mp4', + attempts: 0, +}; +const FM = + '---\n' + + 'voice-id: "20260927-111503-0000"\n' + + 'voice-status: "saved"\n' + + 'voice-device: "d1"\n' + + 'voice-audio: "[[voice/source/20260927-111503-0000.m4a]]"\n' + + 'voice-mime: "audio/mp4"\n' + + 'voice-attempts: 0\n' + + '---\n'; +const REGION = '%%voice-note:20260927-111503-0000%%\n> [!info] Распознаётся…\n%%/voice-note%%\n'; + +const inserted: VoiceRecord = { ...saved, status: 'inserted' }; + +describe('initialNote', () => { + it('builds frontmatter, embed and status region', () => { + expect(initialNote(saved, 47_000)).toBe(`${FM}![[voice/source/20260927-111503-0000.m4a]]\n\n${REGION}`); + }); + + it('adds interrupted line above the region', () => { + const note = initialNote({ ...saved, interrupted: true }, 47_000); + expect(note).toContain('voice-interrupted: true\n---\n'); + expect(note).toContain( + '![[voice/source/20260927-111503-0000.m4a]]\n\n> [!note] Запись прервана, сохранено 0:47\n\n%%voice-note:', + ); + }); +}); + +describe('renderNote: status region', () => { + it('retry shows local time and attempt', () => { + const r: VoiceRecord = { ...saved, status: 'retry', attempts: 2, nextAttempt: '2026-09-27T11:17:03.000Z', error: 't' }; + const out = renderNote(initialNote(saved, 0), r); + expect(out).toContain('> [!info] Нет связи, повтор в 14:17 (попытка 2/5)\n'); + expect(out).not.toContain('Распознаётся'); + expect(readVoiceFields(out)['voice-status']).toBe('retry'); + }); + + it('failed shows reason and the command', () => { + const out = renderNote(initialNote(saved, 0), { ...saved, status: 'failed', attempts: 1, error: '401 Unauthorized' }); + expect(out).toContain('> [!warning] Не распознано: 401 Unauthorized. Команда «Повторить распознавание»\n'); + }); + + it('no-speech is not an error', () => { + const out = renderNote(initialNote(saved, 0), { ...saved, status: 'failed', attempts: 1, error: NO_SPEECH }); + expect(out).toContain('> [!info] В записи не распознано речи\n'); + expect(out).not.toMatch(/ошибк|Не распознано:|Повторить/); + }); + + it('collapses multi-line error into one callout line', () => { + const out = renderNote(initialNote(saved, 0), { ...saved, status: 'failed', attempts: 1, error: 'a\n\nb' }); + expect(out).toContain('> [!warning] Не распознано: a b. Команда'); + }); + + it('without markers updates frontmatter only', () => { + const content = `${FM}моя заметка\n`; + const out = renderNote(content, { ...saved, status: 'sent', sentAt: 'x' }); + expect(out).toBe(content.replace('voice-status: "saved"', 'voice-status: "sent"').replace('voice-attempts: 0\n', 'voice-attempts: 0\nvoice-sent-at: "x"\n')); + }); +}); + +describe('renderNote: transcript', () => { + it('replaces the region and keeps user text around it', () => { + const content = `${initialNote(saved, 0)}купить молоко\n`; + expect(renderNote(content, inserted, ' Привет, мир.\n')).toBe( + `${FM.replace('"saved"', '"inserted"')}![[voice/source/20260927-111503-0000.m4a]]\n\nПривет, мир.\nкупить молоко\n`, + ); + }); + + it('keeps the interrupted line', () => { + const r = { ...saved, interrupted: true }; + const out = renderNote(initialNote(r, 47_000), { ...r, status: 'inserted' }, 'текст'); + expect(out).toContain('> [!note] Запись прервана, сохранено 0:47\n\nтекст\n'); + }); + + it('appends at the end when markers were removed', () => { + const content = `${FM}заметка без маркеров\n\n\n`; + expect(renderNote(content, inserted, 'текст')).toBe( + `${FM.replace('"saved"', '"inserted"')}заметка без маркеров\n\nтекст\n`, + ); + }); + + it('is a no-op when already inserted', () => { + const once = renderNote(initialNote(saved, 0), inserted, 'первый'); + expect(renderNote(once, inserted, 'второй')).toBe(once); + }); + + it('inserts special replacement patterns verbatim', () => { + const text = "цена $& и $' и $1 и %%voice-note%% и ---"; + expect(renderNote(initialNote(saved, 0), inserted, text)).toContain(`\n\n${text}\n`); + }); + + it('keeps user properties in place', () => { + const content = initialNote(saved, 0).replace('---\nvoice-id', '---\ntags: [idea]\nvoice-id'); + const out = renderNote(content, inserted, 'x'); + expect(out.startsWith('---\ntags: [idea]\nvoice-id: "20260927-111503-0000"\nvoice-status: "inserted"\n')).toBe(true); + }); +}); +``` + +- [ ] **Step 2: Убедиться, что тесты падают** + +Run: `npx vitest run tests/note-body.test.ts` +Expected: FAIL — `initialNote is not a function` / отсутствующий экспорт. + +- [ ] **Step 3: Реализация — дописать в `src/core/note.ts`** + +Заменить первую строку импорта в `src/core/note.ts` на: + +```ts +import { formatDuration, formatLocalTime } from './naming'; +import { MAX_ATTEMPTS, NO_SPEECH, STATUSES, type Status, type VoiceRecord } from './record'; +``` + +Дописать в конец файла: + +```ts +export const MARKER_END = '%%/voice-note%%'; +export const markerStart = (id: string) => `%%voice-note:${id}%%`; + +export function statusLine(r: VoiceRecord): string { + switch (r.status) { + case 'retry': { + const at = r.nextAttempt ? formatLocalTime(r.nextAttempt) : '—'; + return `> [!info] Нет связи, повтор в ${at} (попытка ${r.attempts}/${MAX_ATTEMPTS})`; + } + case 'failed': { + if (r.error === NO_SPEECH) return '> [!info] В записи не распознано речи'; + const reason = (r.error ?? 'неизвестная причина').replace(/\s+/g, ' ').trim(); + return `> [!warning] Не распознано: ${reason}. Команда «Повторить распознавание»`; + } + default: + return '> [!info] Распознаётся…'; + } +} + +function region(r: VoiceRecord): string { + return `${markerStart(r.id)}\n${statusLine(r)}\n${MARKER_END}`; +} + +export function initialNote(record: VoiceRecord, durationMs: number): string { + const interrupted = record.interrupted + ? `> [!note] Запись прервана, сохранено ${formatDuration(durationMs)}\n\n` + : ''; + return ( + patchFrontmatter('', recordToFields(record)) + + `![[${record.audio}]]\n\n${interrupted}${region(record)}\n` + ); +} + +// Обновляет frontmatter и управляемую область одной операцией. +// Склейка через slice, не String.replace: в транскрипте могут быть $&, $' и т.п. +export function renderNote(content: string, record: VoiceRecord, text?: string): string { + if (record.status === 'inserted' && readVoiceFields(content)['voice-status'] === 'inserted') { + return content; + } + const patched = patchFrontmatter(content, recordToFields(record)); + const start = patched.indexOf(markerStart(record.id)); + const end = start === -1 ? -1 : patched.indexOf(MARKER_END, start); + + if (record.status === 'inserted') { + const body = (text ?? '').trim(); + if (end === -1) return `${patched.replace(/\s+$/, '')}\n\n${body}\n`; + return patched.slice(0, start) + body + patched.slice(end + MARKER_END.length); + } + if (end === -1) return patched; + return patched.slice(0, start) + region(record) + patched.slice(end + MARKER_END.length); +} +``` + +- [ ] **Step 4: Убедиться, что тесты проходят** + +Run: `npm test` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/core/note.ts tests/note-body.test.ts +git commit -m "feat(core): note body with status region and transcript insertion + +Refs #1 + +Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +### Task 6: schedule + +**Model:** haiku + +**Files:** +- Create: `src/core/schedule.ts`, `tests/schedule.test.ts` + +**Interfaces:** +- Consumes: `SENT_STALE_MS`, `VoiceRecord` (Task 3). +- Produces: + - `interface Located { path: string; record: VoiceRecord }` + - `isStaleSent(r: VoiceRecord, now: Date): boolean` + - `due(items: readonly Located[], now: Date, deviceId: string): Located[]` — отсортировано по `record.id` + - `staleOwn(items: readonly Located[], now: Date, deviceId: string): Located[]` + - `claimable(items: readonly Located[], now: Date, deviceId: string): Located[]` + +- [ ] **Step 1: Написать падающие тесты** + +`tests/schedule.test.ts`: + +```ts +import { describe, expect, it } from 'vitest'; +import type { VoiceRecord } from '../src/core/record'; +import { claimable, due, isStaleSent, staleOwn, type Located } from '../src/core/schedule'; + +const NOW = new Date('2026-09-27T11:15:03.000Z'); +const ago = (ms: number) => new Date(NOW.getTime() - ms).toISOString(); +const later = (ms: number) => new Date(NOW.getTime() + ms).toISOString(); + +function item(id: string, over: Partial = {}): Located { + return { + path: `voice/inbox/${id}.md`, + record: { id, status: 'saved', device: 'me', audio: `voice/source/${id}.m4a`, mime: 'audio/mp4', attempts: 0, ...over }, + }; +} +const ids = (xs: Located[]) => xs.map((x) => x.record.id); + +describe('due', () => { + it('takes own saved and retry whose time has come, oldest id first', () => { + const items = [ + item('c', { status: 'retry', nextAttempt: NOW.toISOString() }), + item('a'), + item('b', { status: 'retry', nextAttempt: later(1) }), + item('d', { device: 'other' }), + item('e', { status: 'sent', sentAt: ago(0) }), + item('f', { status: 'failed' }), + item('g', { status: 'inserted' }), + ]; + expect(ids(due(items, NOW, 'me'))).toEqual(['a', 'c']); + }); + + it('unparsable nextAttempt is due', () => { + expect(ids(due([item('a', { status: 'retry', nextAttempt: 'завтра' })], NOW, 'me'))).toEqual(['a']); + }); +}); + +describe('isStaleSent', () => { + it.each([ + ['exactly 10 min', { status: 'sent' as const, sentAt: ago(600_000) }, false], + ['10 min + 1 ms', { status: 'sent' as const, sentAt: ago(600_001) }, true], + ['2 min', { status: 'sent' as const, sentAt: ago(120_000) }, false], + ['no sentAt', { status: 'sent' as const }, true], + ['garbage sentAt', { status: 'sent' as const, sentAt: 'x' }, true], + ['not sent', { status: 'retry' as const, sentAt: ago(3_600_000) }, false], + ])('%s → %s', (_name, over, expected) => { + expect(isStaleSent(item('a', over).record, NOW)).toBe(expected); + }); +}); + +describe('staleOwn', () => { + it('only own stale sent', () => { + const items = [ + item('a', { status: 'sent', sentAt: ago(700_000) }), + item('b', { status: 'sent', sentAt: ago(700_000), device: 'other' }), + item('c', { status: 'sent', sentAt: ago(1_000) }), + ]; + expect(ids(staleOwn(items, NOW, 'me'))).toEqual(['a']); + }); +}); + +describe('claimable', () => { + it("others' saved, retry and stale sent only", () => { + const o = { device: 'other' }; + const items = [ + item('a', o), + item('b', { ...o, status: 'retry', nextAttempt: later(1_200_000) }), + item('c', { ...o, status: 'sent', sentAt: ago(700_000) }), + item('d', { ...o, status: 'sent', sentAt: ago(1_000) }), + item('e', { ...o, status: 'failed' }), + item('f', { ...o, status: 'inserted' }), + item('g'), + ]; + expect(ids(claimable(items, NOW, 'me'))).toEqual(['a', 'b', 'c']); + }); +}); +``` + +- [ ] **Step 2: Убедиться, что тесты падают** + +Run: `npx vitest run tests/schedule.test.ts` +Expected: FAIL — `Failed to resolve import "../src/core/schedule"`. + +- [ ] **Step 3: Реализация** + +`src/core/schedule.ts`: + +```ts +import { SENT_STALE_MS, type VoiceRecord } from './record'; + +export interface Located { + path: string; + record: VoiceRecord; +} + +const time = (iso?: string) => (iso ? Date.parse(iso) : NaN); + +// Отсутствующее или испорченное время — не повод зависнуть навсегда. +export function isStaleSent(r: VoiceRecord, now: Date): boolean { + if (r.status !== 'sent') return false; + const t = time(r.sentAt); + return Number.isNaN(t) || now.getTime() - t > SENT_STALE_MS; +} + +export function due(items: readonly Located[], now: Date, deviceId: string): Located[] { + return items + .filter(({ record: r }) => { + if (r.device !== deviceId) return false; + if (r.status === 'saved') return true; + if (r.status !== 'retry') return false; + const t = time(r.nextAttempt); + return Number.isNaN(t) || t <= now.getTime(); + }) + .sort((a, b) => (a.record.id < b.record.id ? -1 : a.record.id > b.record.id ? 1 : 0)); +} + +export function staleOwn(items: readonly Located[], now: Date, deviceId: string): Located[] { + return items.filter(({ record: r }) => r.device === deviceId && isStaleSent(r, now)); +} + +export function claimable(items: readonly Located[], now: Date, deviceId: string): Located[] { + return items.filter( + ({ record: r }) => + r.device !== deviceId && (r.status === 'saved' || r.status === 'retry' || isStaleSent(r, now)), + ); +} +``` + +- [ ] **Step 4: Убедиться, что тесты проходят** + +Run: `npm test` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/core/schedule.ts tests/schedule.test.ts +git commit -m "feat(core): due, stale-sent and claimable selection + +Refs #1 + +Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +### Task 7: fakes and saveCapture + +**Model:** sonnet + +**Files:** +- Create: `tests/fakes.ts`, `src/core/capture.ts`, `tests/capture.test.ts` + +**Interfaces:** +- Consumes: порты (Task 1); `makeId`, `audioPath`, `notePath` (Task 2); `VoiceRecord` (Task 3); `initialNote`, `readVoiceFields` (Tasks 4–5). +- Produces: + - `tests/fakes.ts`: `MemoryRecordings` (`files: Map`), `MemoryNotes` (`files: Map`, `staleList: ListedNote[] | null`, `listCalls: number`), `type Step`, `ScriptedTranscriber` (`steps: Step[]`, `calls: {audio, mime, signal}[]`), `TestEnv` (`time`, `deviceId`, `random`, `now()`, `advance(ms)`) + - `src/core/capture.ts`: `interface Capture { chunks: ArrayBuffer[]; mime: string; startedAt: Date; endedAt: Date; interrupted: boolean }`, `interface CaptureDeps { recordings: RecordingStore; notes: NoteStore; env: Env }`, `saveCapture(deps: CaptureDeps, capture: Capture): Promise` — путь заметки или `null` для пустой записи + +- [ ] **Step 1: Фейки портов** + +`tests/fakes.ts`: + +```ts +import { readVoiceFields } from '../src/core/note'; +import type { + Env, + ListedNote, + NoteStore, + RecordingStore, + TranscribeResult, + Transcriber, +} from '../src/core/ports'; + +export class MemoryRecordings implements RecordingStore { + files = new Map(); + + async write(path: string, data: ArrayBuffer): Promise { + this.files.set(path, data.slice(0)); + } + + async read(path: string): Promise { + const data = this.files.get(path); + if (!data) throw new Error(`no such file: ${path}`); + return data.slice(0); + } +} + +export class MemoryNotes implements NoteStore { + files = new Map(); + // Имитация отстающего metadataCache: если задано, list() возвращает это. + staleList: ListedNote[] | null = null; + listCalls = 0; + + async exists(path: string): Promise { + return this.files.has(path); + } + + async create(path: string, content: string): Promise { + if (this.files.has(path)) throw new Error(`already exists: ${path}`); + this.files.set(path, content); + } + + async list(): Promise { + this.listCalls++; + if (this.staleList) return this.staleList; + return [...this.files] + .map(([path, content]) => ({ path, frontmatter: readVoiceFields(content) as Record })) + .filter((note) => 'voice-id' in note.frontmatter); + } + + async process(path: string, fn: (content: string) => string): Promise { + const content = this.files.get(path); + if (content === undefined) return false; + this.files.set(path, fn(content)); + return true; + } +} + +export type Step = + | TranscribeResult + | Error + | ((audio: ArrayBuffer, mime: string) => Promise); + +export class ScriptedTranscriber implements Transcriber { + calls: { audio: ArrayBuffer; mime: string; signal: AbortSignal }[] = []; + + constructor(public steps: Step[] = []) {} + + async transcribe(audio: ArrayBuffer, mime: string, signal: AbortSignal): Promise { + this.calls.push({ audio, mime, signal }); + const step = this.steps.shift(); + if (step === undefined) throw new Error('ScriptedTranscriber: no more steps'); + if (step instanceof Error) throw step; + if (typeof step === 'function') return step(audio, mime); + return step; + } +} + +export class TestEnv implements Env { + constructor( + public time = new Date('2026-09-27T11:15:03.000Z'), + public deviceId = 'test-device', + public random: () => number = () => 0, + ) {} + + now(): Date { + return new Date(this.time); + } + + advance(ms: number): void { + this.time = new Date(this.time.getTime() + ms); + } +} +``` + +- [ ] **Step 2: Написать падающие тесты saveCapture** + +`tests/capture.test.ts`: + +```ts +import { describe, expect, it } from 'vitest'; +import { saveCapture, type Capture } from '../src/core/capture'; +import { initialNote, readVoiceFields } from '../src/core/note'; +import { MemoryNotes, MemoryRecordings, TestEnv } from './fakes'; + +const T = new Date('2026-09-27T11:15:03.000Z'); +const buf = (...bytes: number[]) => new Uint8Array(bytes).buffer; + +function setup() { + const env = new TestEnv(T); + const notes = new MemoryNotes(); + const recordings = new MemoryRecordings(); + return { env, notes, recordings, deps: { env, notes, recordings } }; +} + +const capture = (over: Partial = {}): Capture => ({ + chunks: [buf(1, 2), buf(3, 4, 5)], + mime: 'audio/mp4', + startedAt: T, + endedAt: new Date(T.getTime() + 47_000), + interrupted: false, + ...over, +}); + +describe('saveCapture', () => { + it('writes concatenated audio and a saved note', async () => { + const { deps, notes, recordings } = setup(); + const path = await saveCapture(deps, capture()); + + expect(path).toBe('voice/inbox/2026-09-27 14-15-03.md'); + const audio = recordings.files.get('voice/source/20260927-111503-0000.m4a'); + expect(new Uint8Array(audio!)).toEqual(new Uint8Array([1, 2, 3, 4, 5])); + expect(notes.files.get(path!)).toBe( + initialNote( + { + id: '20260927-111503-0000', + status: 'saved', + device: 'test-device', + audio: 'voice/source/20260927-111503-0000.m4a', + mime: 'audio/mp4', + attempts: 0, + }, + 47_000, + ), + ); + }); + + it.each([[[]], [[new ArrayBuffer(0)]], [[new ArrayBuffer(0), new ArrayBuffer(0)]]])( + 'creates nothing for empty data %#', + async (chunks) => { + const { deps, notes, recordings } = setup(); + expect(await saveCapture(deps, capture({ chunks, interrupted: true }))).toBeNull(); + expect(notes.files.size).toBe(0); + expect(recordings.files.size).toBe(0); + }, + ); + + it('marks interrupted recordings with duration', async () => { + const { deps, notes } = setup(); + const path = await saveCapture(deps, capture({ interrupted: true })); + const content = notes.files.get(path!)!; + expect(readVoiceFields(content)['voice-interrupted']).toBe('true'); + expect(content).toContain('> [!note] Запись прервана, сохранено 0:47'); + }); + + it('adds (2), (3) on name collision', async () => { + const { deps, notes } = setup(); + notes.files.set('voice/inbox/2026-09-27 14-15-03.md', 'чужая'); + expect(await saveCapture(deps, capture())).toBe('voice/inbox/2026-09-27 14-15-03 (2).md'); + expect(await saveCapture(deps, capture())).toBe('voice/inbox/2026-09-27 14-15-03 (3).md'); + expect(notes.files.get('voice/inbox/2026-09-27 14-15-03.md')).toBe('чужая'); + }); + + it('keeps full mime and picks extension from it', async () => { + const { deps, notes, recordings } = setup(); + const path = await saveCapture(deps, capture({ mime: 'audio/webm;codecs=opus' })); + expect(recordings.files.has('voice/source/20260927-111503-0000.webm')).toBe(true); + expect(readVoiceFields(notes.files.get(path!)!)['voice-mime']).toBe('audio/webm;codecs=opus'); + }); +}); +``` + +- [ ] **Step 3: Убедиться, что тесты падают** + +Run: `npx vitest run tests/capture.test.ts` +Expected: FAIL — `Failed to resolve import "../src/core/capture"`. + +- [ ] **Step 4: Реализация** + +`src/core/capture.ts`: + +```ts +import { audioPath, makeId, notePath } from './naming'; +import { initialNote } from './note'; +import type { Env, NoteStore, RecordingStore } from './ports'; +import type { VoiceRecord } from './record'; + +export interface Capture { + chunks: ArrayBuffer[]; + mime: string; + startedAt: Date; + endedAt: Date; + interrupted: boolean; +} + +export interface CaptureDeps { + recordings: RecordingStore; + notes: NoteStore; + env: Env; +} + +// Сохраняет запись до любой сети. Возвращает путь заметки или null, если данных нет. +export async function saveCapture(deps: CaptureDeps, capture: Capture): Promise { + const size = capture.chunks.reduce((sum, chunk) => sum + chunk.byteLength, 0); + if (size === 0) return null; + + const bytes = new Uint8Array(size); + let offset = 0; + for (const chunk of capture.chunks) { + bytes.set(new Uint8Array(chunk), offset); + offset += chunk.byteLength; + } + + const id = makeId(capture.startedAt, () => deps.env.random()); + const record: VoiceRecord = { + id, + status: 'saved', + device: deps.env.deviceId, + audio: audioPath(id, capture.mime), + mime: capture.mime, + attempts: 0, + }; + if (capture.interrupted) record.interrupted = true; + + await deps.recordings.write(record.audio, bytes.buffer); + + let n = 1; + while (await deps.notes.exists(notePath(capture.startedAt, n))) n++; + const path = notePath(capture.startedAt, n); + await deps.notes.create(path, initialNote(record, capture.endedAt.getTime() - capture.startedAt.getTime())); + return path; +} +``` + +- [ ] **Step 5: Убедиться, что тесты проходят** + +Run: `npm test` +Expected: PASS (включая страж границы: `capture.ts` не импортирует запрещённое). + +- [ ] **Step 6: Commit** + +```bash +git add tests/fakes.ts src/core/capture.ts tests/capture.test.ts +git commit -m "feat(core): save captured audio and initial note; in-memory port fakes + +Refs #1 + +Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +### Task 8: QueueRunner.tick + +**Model:** opus + +**Files:** +- Create: `src/core/queue.ts`, `tests/queue.test.ts` + +**Interfaces:** +- Consumes: `NoteStore`, `RecordingStore`, `Transcriber`, `TranscribeResult`, `Env` (Task 1); `transition`, `NO_SPEECH`, `VoiceRecord` (Task 3); `readVoiceFields`, `parseRecord`, `renderNote`, `initialNote` (Tasks 4–5); `due`, `staleOwn`, `isStaleSent`, `Located` (Task 6); фейки (Task 7). +- Produces: + - `interface QueueDeps { recordings: RecordingStore; notes: NoteStore; transcriber: Transcriber; env: Env }` + - `INTERRUPTED_SEND = 'отправка прервана'` + - `class QueueRunner { constructor(deps: QueueDeps); tick(): Promise; dispose(): void }` и приватный `update(path, expect, change): Promise`, `load(): Promise` — используются в Task 9 + +- [ ] **Step 1: Написать падающие тесты** + +`tests/queue.test.ts`: + +```ts +import { describe, expect, it } from 'vitest'; +import { initialNote, readVoiceFields } from '../src/core/note'; +import { QueueRunner } from '../src/core/queue'; +import type { VoiceRecord } from '../src/core/record'; +import { MemoryNotes, MemoryRecordings, ScriptedTranscriber, TestEnv, type Step } from './fakes'; + +const T0 = new Date('2026-09-27T11:15:03.000Z'); +const ok = (text: string): Step => ({ ok: true, text }); +const temporary = (message: string): Step => ({ ok: false, kind: 'temporary', message }); +const permanent = (message: string): Step => ({ ok: false, kind: 'permanent', message }); + +function setup(steps: Step[] = []) { + const env = new TestEnv(T0); + const notes = new MemoryNotes(); + const recordings = new MemoryRecordings(); + const transcriber = new ScriptedTranscriber(steps); + const runner = new QueueRunner({ recordings, notes, transcriber, env }); + return { env, notes, recordings, transcriber, runner }; +} +type Ctx = ReturnType; + +function rec(id: string, over: Partial = {}): VoiceRecord { + return { id, status: 'saved', device: 'test-device', audio: `voice/source/${id}.m4a`, mime: 'audio/mp4', attempts: 0, ...over }; +} + +function seed(ctx: Ctx, record: VoiceRecord, path = `voice/inbox/${record.id}.md`): string { + ctx.notes.files.set(path, initialNote(record, 0)); + ctx.recordings.files.set(record.audio, new Uint8Array([1, 2, 3]).buffer); + return path; +} + +const fields = (ctx: Ctx, path: string) => readVoiceFields(ctx.notes.files.get(path)!); + +describe('QueueRunner.tick', () => { + it('sends a saved record and inserts the trimmed transcript', async () => { + const ctx = setup([ok(' привет ')]); + const path = seed(ctx, rec('a')); + await ctx.runner.tick(); + + expect(ctx.transcriber.calls).toHaveLength(1); + expect(new Uint8Array(ctx.transcriber.calls[0].audio)).toEqual(new Uint8Array([1, 2, 3])); + expect(ctx.transcriber.calls[0].mime).toBe('audio/mp4'); + expect(fields(ctx, path)['voice-status']).toBe('inserted'); + expect(fields(ctx, path)['voice-sent-at']).toBeUndefined(); + expect(ctx.notes.files.get(path)).toContain('\n\nпривет\n'); + expect(ctx.notes.files.get(path)).not.toContain('%%voice-note'); + }); + + it('network lost, then back: retry at +30s, then inserted', async () => { + const ctx = setup([temporary('offline'), ok('текст')]); + const path = seed(ctx, rec('a')); + + await ctx.runner.tick(); + expect(fields(ctx, path)).toMatchObject({ + 'voice-status': 'retry', + 'voice-attempts': '1', + 'voice-next-attempt': '2026-09-27T11:15:33.000Z', + 'voice-error': 'offline', + }); + expect(ctx.notes.files.get(path)).toContain('Нет связи, повтор в 14:15 (попытка 1/5)'); + + ctx.env.advance(17_000); // 11:15:20 — рано + await ctx.runner.tick(); + expect(ctx.transcriber.calls).toHaveLength(1); + + ctx.env.advance(13_000); // 11:15:33 — пора + await ctx.runner.tick(); + expect(ctx.transcriber.calls).toHaveLength(2); + expect(fields(ctx, path)['voice-status']).toBe('inserted'); + }); + + it('five temporary failures → failed with the last message', async () => { + const ctx = setup([temporary('e1'), temporary('e2'), temporary('e3'), temporary('e4'), temporary('e5')]); + const path = seed(ctx, rec('a')); + for (let i = 0; i < 5; i++) { + await ctx.runner.tick(); + ctx.env.advance(3_600_000); + } + await ctx.runner.tick(); + expect(ctx.transcriber.calls).toHaveLength(5); + expect(fields(ctx, path)).toMatchObject({ 'voice-status': 'failed', 'voice-attempts': '5', 'voice-error': 'e5' }); + }); + + it('permanent error → failed at once', async () => { + const ctx = setup([permanent('401 Unauthorized')]); + const path = seed(ctx, rec('a')); + await ctx.runner.tick(); + expect(fields(ctx, path)).toMatchObject({ 'voice-status': 'failed', 'voice-attempts': '1' }); + expect(ctx.notes.files.get(path)).toContain('Не распознано: 401 Unauthorized'); + }); + + it('whitespace-only transcript → failed no-speech', async () => { + const ctx = setup([ok(' \n\t ')]); + const path = seed(ctx, rec('a')); + await ctx.runner.tick(); + expect(fields(ctx, path)).toMatchObject({ 'voice-status': 'failed', 'voice-error': 'no-speech' }); + expect(ctx.notes.files.get(path)).toContain('> [!info] В записи не распознано речи'); + }); + + it('exception from transcriber is a temporary failure', async () => { + const ctx = setup([new Error('socket hang up')]); + const path = seed(ctx, rec('a')); + await ctx.runner.tick(); + expect(fields(ctx, path)).toMatchObject({ 'voice-status': 'retry', 'voice-error': 'socket hang up' }); + }); + + it('missing audio file is a temporary failure', async () => { + const ctx = setup([]); + const path = seed(ctx, rec('a')); + ctx.recordings.files.clear(); + await ctx.runner.tick(); + expect(ctx.transcriber.calls).toHaveLength(0); + expect(fields(ctx, path)['voice-status']).toBe('retry'); + }); + + it("does not send other devices' records", async () => { + const ctx = setup([]); + const path = seed(ctx, rec('a', { device: 'iphone' })); + await ctx.runner.tick(); + expect(ctx.transcriber.calls).toHaveLength(0); + expect(fields(ctx, path)['voice-status']).toBe('saved'); + }); + + it('own sent older than 10 min → retry without a request; younger untouched', async () => { + const ctx = setup([]); + const stale = seed(ctx, rec('a', { status: 'sent', sentAt: '2026-09-27T11:04:00.000Z' })); + const fresh = seed(ctx, rec('b', { status: 'sent', sentAt: '2026-09-27T11:13:03.000Z' })); + const before = ctx.notes.files.get(fresh); + await ctx.runner.tick(); + expect(ctx.transcriber.calls).toHaveLength(0); + expect(fields(ctx, stale)).toMatchObject({ 'voice-status': 'retry', 'voice-attempts': '1', 'voice-error': 'отправка прервана' }); + expect(ctx.notes.files.get(fresh)).toBe(before); + }); + + it('processes records one at a time, oldest first', async () => { + let inFlight = 0; + let maxInFlight = 0; + const order: string[] = []; + const slow: Step = async (audio) => { + inFlight++; + maxInFlight = Math.max(maxInFlight, inFlight); + order.push(String(new Uint8Array(audio)[0])); + await new Promise((resolve) => setTimeout(resolve, 5)); + inFlight--; + return { ok: true, text: 't' }; + }; + const ctx = setup([slow, slow, slow]); + for (const [id, byte] of [['c', 3], ['a', 1], ['b', 2]] as const) { + seed(ctx, rec(id)); + ctx.recordings.files.set(`voice/source/${id}.m4a`, new Uint8Array([byte]).buffer); + } + await ctx.runner.tick(); + expect(maxInFlight).toBe(1); + expect(order).toEqual(['1', '2', '3']); + }); + + it('tick during tick runs exactly one extra pass', async () => { + const ctx: Ctx = setup(); + ctx.transcriber.steps.push(async () => { + void ctx.runner.tick(); + void ctx.runner.tick(); + void ctx.runner.tick(); + return { ok: true, text: 't' }; + }); + seed(ctx, rec('a')); + await ctx.runner.tick(); + expect(ctx.notes.listCalls).toBe(2); + expect(ctx.transcriber.calls).toHaveLength(1); + }); + + it('note deleted during the request: result dropped, next record processed', async () => { + const ctx: Ctx = setup(); + const first = seed(ctx, rec('a')); + const second = seed(ctx, rec('b')); + ctx.transcriber.steps.push(async () => { + ctx.notes.files.delete(first); + return { ok: true, text: 'один' }; + }, ok('два')); + await ctx.runner.tick(); + expect(ctx.notes.files.has(first)).toBe(false); + expect(fields(ctx, second)['voice-status']).toBe('inserted'); + }); + + it('note deleted before send: skipped without a request', async () => { + const ctx = setup([ok('два')]); + const second = seed(ctx, rec('b')); + ctx.notes.staleList = [ + { path: 'voice/inbox/gone.md', frontmatter: readVoiceFields(initialNote(rec('a'), 0)) }, + { path: second, frontmatter: fields(ctx, second) }, + ]; + await ctx.runner.tick(); + expect(ctx.transcriber.calls).toHaveLength(1); + expect(fields(ctx, second)['voice-status']).toBe('inserted'); + }); + + it('stale list says saved, file says sent: no second request', async () => { + const ctx = setup([]); + const path = seed(ctx, rec('a', { status: 'sent', sentAt: '2026-09-27T11:15:00.000Z' })); + ctx.notes.staleList = [{ path, frontmatter: readVoiceFields(initialNote(rec('a'), 0)) }]; + const before = ctx.notes.files.get(path); + await ctx.runner.tick(); + expect(ctx.transcriber.calls).toHaveLength(0); + expect(ctx.notes.files.get(path)).toBe(before); + }); + + it('stale list says saved, file says inserted: untouched', async () => { + const ctx = setup([ok('x')]); + const path = seed(ctx, rec('a')); + await ctx.runner.tick(); + const done = ctx.notes.files.get(path); + ctx.notes.staleList = [{ path, frontmatter: readVoiceFields(initialNote(rec('a'), 0)) }]; + await ctx.runner.tick(); + expect(ctx.transcriber.calls).toHaveLength(1); + expect(ctx.notes.files.get(path)).toBe(done); + }); + + it('finds records outside voice/inbox', async () => { + const ctx = setup([ok('x')]); + const path = seed(ctx, rec('a'), 'projects/перенесённая.md'); + await ctx.runner.tick(); + expect(fields(ctx, path)['voice-status']).toBe('inserted'); + }); + + it('ignores notes with a broken status', async () => { + const ctx = setup([]); + const path = seed(ctx, rec('a')); + ctx.notes.files.set(path, ctx.notes.files.get(path)!.replace('"saved"', '"foo"')); + await expect(ctx.runner.tick()).resolves.toBeUndefined(); + expect(ctx.transcriber.calls).toHaveLength(0); + }); + + it('dispose aborts the signal given to the transcriber', async () => { + const ctx = setup([ok('x')]); + seed(ctx, rec('a')); + await ctx.runner.tick(); + expect(ctx.transcriber.calls[0].signal.aborted).toBe(false); + ctx.runner.dispose(); + expect(ctx.transcriber.calls[0].signal.aborted).toBe(true); + }); +}); +``` + +- [ ] **Step 2: Убедиться, что тесты падают** + +Run: `npx vitest run tests/queue.test.ts` +Expected: FAIL — `Failed to resolve import "../src/core/queue"`. + +- [ ] **Step 3: Реализация** + +`src/core/queue.ts`: + +```ts +import { parseRecord, readVoiceFields, renderNote } from './note'; +import type { Env, NoteStore, RecordingStore, TranscribeResult, Transcriber } from './ports'; +import { NO_SPEECH, transition, type VoiceRecord } from './record'; +import { due, isStaleSent, staleOwn, type Located } from './schedule'; + +export interface QueueDeps { + recordings: RecordingStore; + notes: NoteStore; + transcriber: Transcriber; + env: Env; +} + +export const INTERRUPTED_SEND = 'отправка прервана'; + +interface Change { + record: VoiceRecord; + text?: string; +} + +export class QueueRunner { + private running = false; + private rerun = false; + private readonly controller = new AbortController(); + + constructor(private readonly deps: QueueDeps) {} + + // Повторный вызов во время работы не запускает параллельный проход, + // а просит ровно один дополнительный после текущего. + async tick(): Promise { + if (this.running) { + this.rerun = true; + return; + } + this.running = true; + try { + do { + this.rerun = false; + await this.pass(); + } while (this.rerun); + } finally { + this.running = false; + } + } + + dispose(): void { + this.controller.abort(); + } + + private async pass(): Promise { + const { env } = this.deps; + const items = await this.load(); + const now = env.now(); + for (const item of staleOwn(items, now, env.deviceId)) { + await this.update( + item.path, + (r) => r.id === item.record.id && r.device === env.deviceId && isStaleSent(r, now), + (r) => ({ record: transition(r, { type: 'temporary', message: INTERRUPTED_SEND }, now) }), + ); + } + for (const item of due(items, now, env.deviceId)) await this.send(item); + } + + private async send(item: Located): Promise { + const { recordings, transcriber, env } = this.deps; + const now = env.now(); + // Список мог устареть (metadataCache) — решение принимаем по актуальному файлу. + const sent = await this.update( + item.path, + (r) => r.id === item.record.id && due([{ path: item.path, record: r }], now, env.deviceId).length > 0, + (r) => ({ record: transition(r, { type: 'send' }, now) }), + ); + if (!sent) return; + + let result: TranscribeResult; + try { + const audio = await recordings.read(sent.audio); + result = await transcriber.transcribe(audio, sent.mime, this.controller.signal); + } catch (e) { + result = { ok: false, kind: 'temporary', message: e instanceof Error ? e.message : String(e) }; + } + if (result.ok && result.text.trim() === '') { + result = { ok: false, kind: 'permanent', message: NO_SPEECH }; + } + + const done = env.now(); + const final = result; + await this.update( + item.path, + (r) => r.id === sent.id && r.status === 'sent', + (r) => + final.ok + ? { record: transition(r, { type: 'success' }, done), text: final.text } + : { record: transition(r, { type: final.kind, message: final.message }, done) }, + ); + } + + // Атомарно: перечитать запись из актуального содержимого, проверить ожидание, + // применить изменение. null — заметки нет или запись не в ожидаемом состоянии. + private async update( + path: string, + expect: (r: VoiceRecord) => boolean, + change: (r: VoiceRecord) => Change, + ): Promise { + const box: { record: VoiceRecord | null } = { record: null }; + const exists = await this.deps.notes.process(path, (content) => { + const current = parseRecord(readVoiceFields(content)); + if (!current || !expect(current)) return content; + const next = change(current); + box.record = next.record; + return renderNote(content, next.record, next.text); + }); + return exists ? box.record : null; + } + + private async load(): Promise { + const located: Located[] = []; + for (const { path, frontmatter } of await this.deps.notes.list()) { + const record = parseRecord(frontmatter); + if (record) located.push({ path, record }); + } + return located; + } +} +``` + +- [ ] **Step 4: Убедиться, что тесты проходят** + +Run: `npm test` +Expected: PASS. + +- [ ] **Step 5: Проверка типов** + +Run: `npm run typecheck` +Expected: exit 0. + +- [ ] **Step 6: Commit** + +```bash +git add src/core/queue.ts tests/queue.test.ts +git commit -m "feat(core): queue runner with retries, stale-sent recovery and re-entrancy guard + +Refs #1 + +Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +### Task 9: manual commands — retry and claimAll + +**Model:** sonnet + +**Files:** +- Modify: `src/core/queue.ts` (методы в класс `QueueRunner`, импорт `claimable`) +- Create: `tests/queue-commands.test.ts` + +**Interfaces:** +- Consumes: `QueueRunner` с приватными `update`, `load`, `tick` (Task 8); `claimable` (Task 6); `transition` события `retry-now`, `claim` (Task 3). +- Produces: + - `QueueRunner.retry(path: string): Promise` — `true`, если запись была в `failed`/`retry` и перезапущена + - `QueueRunner.claimAll(): Promise` — число переназначенных записей + +- [ ] **Step 1: Написать падающие тесты** + +`tests/queue-commands.test.ts`: + +```ts +import { describe, expect, it } from 'vitest'; +import { initialNote, readVoiceFields } from '../src/core/note'; +import { QueueRunner } from '../src/core/queue'; +import type { VoiceRecord } from '../src/core/record'; +import { MemoryNotes, MemoryRecordings, ScriptedTranscriber, TestEnv, type Step } from './fakes'; + +const T0 = new Date('2026-09-27T11:15:03.000Z'); + +function setup(steps: Step[] = []) { + const env = new TestEnv(T0); + const notes = new MemoryNotes(); + const recordings = new MemoryRecordings(); + const transcriber = new ScriptedTranscriber(steps); + const runner = new QueueRunner({ recordings, notes, transcriber, env }); + return { env, notes, recordings, transcriber, runner }; +} +type Ctx = ReturnType; + +function rec(id: string, over: Partial = {}): VoiceRecord { + return { id, status: 'saved', device: 'test-device', audio: `voice/source/${id}.m4a`, mime: 'audio/mp4', attempts: 0, ...over }; +} + +function seed(ctx: Ctx, record: VoiceRecord): string { + const path = `voice/inbox/${record.id}.md`; + ctx.notes.files.set(path, initialNote(record, 0)); + ctx.recordings.files.set(record.audio, new Uint8Array([1]).buffer); + return path; +} + +const fields = (ctx: Ctx, path: string) => readVoiceFields(ctx.notes.files.get(path)!); + +describe('QueueRunner.retry', () => { + it('failed → sent again with attempts from zero', async () => { + const ctx = setup([{ ok: false, kind: 'temporary', message: 'offline' }]); + const path = seed(ctx, rec('a', { status: 'failed', attempts: 5, error: '401' })); + expect(await ctx.runner.retry(path)).toBe(true); + expect(ctx.transcriber.calls).toHaveLength(1); + expect(fields(ctx, path)).toMatchObject({ 'voice-status': 'retry', 'voice-attempts': '1' }); + }); + + it('retry state: sends now without waiting', async () => { + const ctx = setup([{ ok: true, text: 'x' }]); + const path = seed(ctx, rec('a', { status: 'retry', attempts: 2, nextAttempt: '2026-09-27T12:00:00.000Z', error: 't' })); + expect(await ctx.runner.retry(path)).toBe(true); + expect(fields(ctx, path)).toMatchObject({ 'voice-status': 'inserted', 'voice-attempts': '0' }); + }); + + it("claims another device's failed record", async () => { + const ctx = setup([{ ok: true, text: 'x' }]); + const path = seed(ctx, rec('a', { status: 'failed', attempts: 5, error: 'e', device: 'iphone' })); + expect(await ctx.runner.retry(path)).toBe(true); + expect(fields(ctx, path)).toMatchObject({ 'voice-status': 'inserted', 'voice-device': 'test-device' }); + }); + + it.each(['saved', 'inserted'] as const)('%s: no change, no request', async (status) => { + const ctx = setup([]); + const path = seed(ctx, rec('a', { status })); + const before = ctx.notes.files.get(path); + expect(await ctx.runner.retry(path)).toBe(false); + expect(ctx.notes.files.get(path)).toBe(before); + expect(ctx.transcriber.calls).toHaveLength(0); + }); + + it('missing note → false', async () => { + expect(await setup().runner.retry('voice/inbox/nope.md')).toBe(false); + }); +}); + +describe('QueueRunner.claimAll', () => { + it("takes the phone's retry and sends it now, keeping attempts", async () => { + const ctx = setup([{ ok: true, text: 'x' }]); + const path = seed(ctx, rec('a', { device: 'iphone', status: 'retry', attempts: 2, nextAttempt: '2026-09-27T11:35:03.000Z', error: 't' })); + expect(await ctx.runner.claimAll()).toBe(1); + expect(ctx.transcriber.calls).toHaveLength(1); + expect(fields(ctx, path)).toMatchObject({ 'voice-status': 'inserted', 'voice-device': 'test-device', 'voice-attempts': '2' }); + }); + + it('includes stale sent; leaves failed, inserted, fresh sent and own records', async () => { + const ctx = setup([{ ok: true, text: 'x' }, { ok: true, text: 'y' }]); + const o = { device: 'iphone' }; + const saved = seed(ctx, rec('a', o)); + const stale = seed(ctx, rec('b', { ...o, status: 'sent', sentAt: '2026-09-27T11:04:00.000Z' })); + const fresh = seed(ctx, rec('c', { ...o, status: 'sent', sentAt: '2026-09-27T11:13:03.000Z' })); + const failed = seed(ctx, rec('d', { ...o, status: 'failed', attempts: 5, error: 'e' })); + const done = seed(ctx, rec('e', { ...o, status: 'inserted' })); + const own = seed(ctx, rec('f', { status: 'retry', attempts: 1, nextAttempt: '2026-09-27T11:35:03.000Z', error: 't' })); + const untouched = [fresh, failed, done, own].map((p) => ctx.notes.files.get(p)); + + expect(await ctx.runner.claimAll()).toBe(2); + expect(fields(ctx, saved)['voice-status']).toBe('inserted'); + expect(fields(ctx, stale)['voice-status']).toBe('inserted'); + expect([fresh, failed, done, own].map((p) => ctx.notes.files.get(p))).toEqual(untouched); + }); + + it('nothing to claim → 0', async () => { + expect(await setup().runner.claimAll()).toBe(0); + }); +}); +``` + +- [ ] **Step 2: Убедиться, что тесты падают** + +Run: `npx vitest run tests/queue-commands.test.ts` +Expected: FAIL — `ctx.runner.retry is not a function`. + +- [ ] **Step 3: Реализация** + +В `src/core/queue.ts` заменить импорт из `./schedule` на: + +```ts +import { claimable, due, isStaleSent, staleOwn, type Located } from './schedule'; +``` + +Добавить в класс `QueueRunner` после `dispose()`: + +```ts + // «Повторить распознавание»: failed/retry → saved, счётчик с нуля, запись + // переходит текущему устройству (иначе чужую запись здесь не отправить). + async retry(path: string): Promise { + const { env } = this.deps; + const changed = await this.update( + path, + (r) => r.status === 'failed' || r.status === 'retry', + (r) => ({ record: transition(r, { type: 'retry-now', device: env.deviceId }, env.now()) }), + ); + if (!changed) return false; + await this.tick(); + return true; + } + + // «Распознать все ожидающие здесь»: чужие saved/retry/зависшие sent → своё + // устройство, статус saved (без ожидания повтора), счётчик сохраняется. + async claimAll(): Promise { + const { env } = this.deps; + const now = env.now(); + let claimed = 0; + for (const item of claimable(await this.load(), now, env.deviceId)) { + const changed = await this.update( + item.path, + (r) => r.id === item.record.id && claimable([{ path: item.path, record: r }], now, env.deviceId).length > 0, + (r) => ({ record: transition(r, { type: 'claim', device: env.deviceId }, now) }), + ); + if (changed) claimed++; + } + await this.tick(); + return claimed; + } +``` + +- [ ] **Step 4: Убедиться, что тесты проходят** + +Run: `npm test` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/core/queue.ts tests/queue-commands.test.ts +git commit -m "feat(core): manual retry and claim-all commands + +Refs #1 + +Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +### Task 10: golden test-01 + +**Model:** sonnet + +**Files:** +- Create: `tests/golden.test.ts`, `tests/fixtures/test-01.note.md` + +**Interfaces:** +- Consumes: `saveCapture` (Task 7), `QueueRunner` (Task 8), фейки (Task 7); фикстуры `tests/fixtures/test-01.m4a` (в git), `tests/fixtures/test-01.txt`. +- Produces: правило «новый golden-кейс = `.note.md` + `.txt` + `.{m4a|webm|ogg}`». + +- [ ] **Step 1: Написать падающий тест** + +`tests/golden.test.ts`: + +```ts +import { existsSync, readdirSync, readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; +import { saveCapture } from '../src/core/capture'; +import { QueueRunner } from '../src/core/queue'; +import { MemoryNotes, MemoryRecordings, ScriptedTranscriber, TestEnv } from './fakes'; + +const FIXTURES = fileURLToPath(new URL('./fixtures/', import.meta.url)); +const MIME: Record = { m4a: 'audio/mp4', webm: 'audio/webm', ogg: 'audio/ogg' }; +const cases = readdirSync(FIXTURES) + .filter((f) => f.endsWith('.note.md')) + .map((f) => f.slice(0, -'.note.md'.length)); + +function audioFor(name: string): { file: string; mime: string } { + for (const ext of Object.keys(MIME)) { + const file = `${FIXTURES}${name}.${ext}`; + if (existsSync(file)) return { file, mime: MIME[ext] }; + } + throw new Error(`нет аудиофикстуры tests/fixtures/${name}.m4a (или .webm/.ogg) для эталона ${name}.note.md`); +} + +function toArrayBuffer(buf: Buffer): ArrayBuffer { + return buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength) as ArrayBuffer; +} + +describe('golden', () => { + it('has at least one case', () => { + expect(cases.length).toBeGreaterThan(0); + }); + + for (const name of cases) { + it(name, async () => { + const { file, mime } = audioFor(name); + const audio = toArrayBuffer(readFileSync(file)); + const transcript = readFileSync(`${FIXTURES}${name}.txt`, 'utf8'); + const expected = readFileSync(`${FIXTURES}${name}.note.md`, 'utf8'); + + const env = new TestEnv(); + const notes = new MemoryNotes(); + const recordings = new MemoryRecordings(); + const transcriber = new ScriptedTranscriber([{ ok: true, text: transcript }]); + + const path = await saveCapture( + { recordings, notes, env }, + { chunks: [audio], mime, startedAt: env.now(), endedAt: new Date(env.now().getTime() + 47_000), interrupted: false }, + ); + await new QueueRunner({ recordings, notes, transcriber, env }).tick(); + + expect(path).toBe('voice/inbox/2026-09-27 14-15-03.md'); + expect(transcriber.calls).toHaveLength(1); + expect(transcriber.calls[0].mime).toBe(mime); + expect(Buffer.from(transcriber.calls[0].audio).equals(Buffer.from(audio))).toBe(true); + expect(notes.files.get(path!)).toBe(expected); + }); + } +}); +``` + +- [ ] **Step 2: Убедиться, что тест падает** + +Run: `npx vitest run tests/golden.test.ts` +Expected: FAIL в `has at least one case` (`expected 0 to be greater than 0`). + +- [ ] **Step 3: Создать эталон** + +Эталон собирается из `test-01.txt`, чтобы не переписывать транскрипт руками (`$(cat …)` срезает завершающий перевод строки, `printf '%s\n'` возвращает ровно один): + +```bash +{ + printf '%s\n' \ + '---' \ + 'voice-id: "20260927-111503-0000"' \ + 'voice-status: "inserted"' \ + 'voice-device: "test-device"' \ + 'voice-audio: "[[voice/source/20260927-111503-0000.m4a]]"' \ + 'voice-mime: "audio/mp4"' \ + 'voice-attempts: 0' \ + '---' \ + '![[voice/source/20260927-111503-0000.m4a]]' \ + '' + printf '%s\n' "$(cat tests/fixtures/test-01.txt)" +} > tests/fixtures/test-01.note.md +cat tests/fixtures/test-01.note.md +``` + +Expected: файл из 11 строк; последняя — транскрипт «Это тестовая запись для проверки распознавания речи с помощью модели WHISPER, для сравнения между двух конкретных моделей.» + +- [ ] **Step 4: Убедиться, что тесты проходят** + +Run: `npm test` +Expected: PASS, весь набор. + +Если golden падает на сравнении заметки — не править эталон под код. Сравнить `notes.files.get(path)` с эталоном построчно, найти расхождение, проверить его против `specs/voice-note-format/spec.md` и исправить ту сторону, которая противоречит спеке. + +- [ ] **Step 5: Финальная проверка** + +Run: `npm run build && npm test && npm run typecheck` +Expected: все три exit 0. + +- [ ] **Step 6: Commit** + +```bash +git add tests/golden.test.ts tests/fixtures/test-01.note.md +git commit -m "test(core): golden test-01 end-to-end capture → queue → note + +Refs #1 + +Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +## Spec coverage + +| Требование | Задачи | +|---|---| +| recording-lifecycle / Состояния записи | 3, 8 | +| Сохранение захваченного аудио | 7 | +| Отправка и результат (по одной, пустой транскрипт) | 8 | +| Повторы после временных ошибок | 3, 8 | +| Восстановление после прерванной отправки | 6, 8 | +| Принадлежность устройству | 6, 8 | +| «Распознать все ожидающие здесь» | 6, 9 | +| «Повторить распознавание» | 3, 9 | +| Удалённая заметка отменяет запись / поиск вне inbox | 8 | +| Повторный запуск очереди | 8 | +| voice-note-format / Идентификатор и имена файлов | 2, 7 | +| Ключи frontmatter | 4 | +| Тело заметки до распознавания | 5 | +| Вставка транскрипта | 5, 8 | +| Golden-эталоны | 10 | +| Граница core без obsidian/Node | 1 | diff --git a/openspec/changes/core-recording-lifecycle/design.md b/openspec/changes/core-recording-lifecycle/design.md index 5086277..31206ba 100644 --- a/openspec/changes/core-recording-lifecycle/design.md +++ b/openspec/changes/core-recording-lifecycle/design.md @@ -113,3 +113,15 @@ interface Env { now(): Date; random(): number; deviceId: string; } 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.* **Слабая модель на механической задаче — лишний круг ревью, не дефект в ветке.** -- 2.47.2 From 944a6594ec49f3bde80f19fc980e4fc820d94ecb Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 27 Sep 2026 15:42:09 +0400 Subject: [PATCH 05/22] =?UTF-8?q?docs:=20apply=20plan=20review=20=E2=80=94?= =?UTF-8?q?=20retry=20status=20wording,=20TZ=20note,=20stale-sent=20and=20?= =?UTF-8?q?rename=20decisions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refs #1 Co-Authored-By: Claude Opus 5.5 --- .../plans/2026-09-27-core-recording-lifecycle.md | 8 ++++---- openspec/changes/core-recording-lifecycle/design.md | 4 +++- .../specs/recording-lifecycle/spec.md | 2 +- .../specs/voice-note-format/spec.md | 4 ++-- 4 files changed, 10 insertions(+), 8 deletions(-) diff --git a/docs/superpowers/plans/2026-09-27-core-recording-lifecycle.md b/docs/superpowers/plans/2026-09-27-core-recording-lifecycle.md index 688c538..3184b76 100644 --- a/docs/superpowers/plans/2026-09-27-core-recording-lifecycle.md +++ b/docs/superpowers/plans/2026-09-27-core-recording-lifecycle.md @@ -20,7 +20,7 @@ - `MAX_ATTEMPTS = 5`; паузы после 1–4 неудачи: 30 с, 2 мин, 10 мин, 30 мин; зависший `sent` — строго больше 10 минут. - Пустой транскрипт → `failed` с `voice-error: no-speech`, в заметке `> [!info] В записи не распознано речи`. - Маркеры области: `%%voice-note:%%` … `%%/voice-note%%`. -- Тексты статусов (дословно): `> [!info] Распознаётся…`; `> [!info] Нет связи, повтор в HH:mm (попытка N/5)`; `> [!warning] Не распознано: <причина>. Команда «Повторить распознавание»`; `> [!note] Запись прервана, сохранено M:SS`. +- Тексты статусов (дословно): `> [!info] Распознаётся…`; `> [!info] Не удалось отправить, повтор в HH:mm (попытка N/5)`; `> [!warning] Не распознано: <причина>. Команда «Повторить распознавание»`; `> [!note] Запись прервана, сохранено M:SS`. - Строковые значения `voice-*` во frontmatter — в двойных кавычках (JSON-экранирование), числа и `true` — без кавычек. - Тесты запускаются с `TZ=Europe/Moscow` (UTC+3, без летнего времени) — выставляется в `vitest.config.ts`. - `npm test` — весь набор, зелёным считается только весь набор. @@ -1176,7 +1176,7 @@ describe('renderNote: status region', () => { it('retry shows local time and attempt', () => { const r: VoiceRecord = { ...saved, status: 'retry', attempts: 2, nextAttempt: '2026-09-27T11:17:03.000Z', error: 't' }; const out = renderNote(initialNote(saved, 0), r); - expect(out).toContain('> [!info] Нет связи, повтор в 14:17 (попытка 2/5)\n'); + expect(out).toContain('> [!info] Не удалось отправить, повтор в 14:17 (попытка 2/5)\n'); expect(out).not.toContain('Распознаётся'); expect(readVoiceFields(out)['voice-status']).toBe('retry'); }); @@ -1267,7 +1267,7 @@ export function statusLine(r: VoiceRecord): string { switch (r.status) { case 'retry': { const at = r.nextAttempt ? formatLocalTime(r.nextAttempt) : '—'; - return `> [!info] Нет связи, повтор в ${at} (попытка ${r.attempts}/${MAX_ATTEMPTS})`; + return `> [!info] Не удалось отправить, повтор в ${at} (попытка ${r.attempts}/${MAX_ATTEMPTS})`; } case 'failed': { if (r.error === NO_SPEECH) return '> [!info] В записи не распознано речи'; @@ -1851,7 +1851,7 @@ describe('QueueRunner.tick', () => { 'voice-next-attempt': '2026-09-27T11:15:33.000Z', 'voice-error': 'offline', }); - expect(ctx.notes.files.get(path)).toContain('Нет связи, повтор в 14:15 (попытка 1/5)'); + expect(ctx.notes.files.get(path)).toContain('Не удалось отправить, повтор в 14:15 (попытка 1/5)'); ctx.env.advance(17_000); // 11:15:20 — рано await ctx.runner.tick(); diff --git a/openspec/changes/core-recording-lifecycle/design.md b/openspec/changes/core-recording-lifecycle/design.md index 31206ba..34ae93e 100644 --- a/openspec/changes/core-recording-lifecycle/design.md +++ b/openspec/changes/core-recording-lifecycle/design.md @@ -83,7 +83,7 @@ interface Env { now(): Date; random(): number; deviceId: string; } ### Тестовая инфраструктура (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` с управляемыми часами. +Шаблон 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», а не пропускаться молча. @@ -125,3 +125,5 @@ interface Env { now(): Date; random(): number; deviceId: string; } 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 минут уходит в повтор — один лишний запрос.** diff --git a/openspec/changes/core-recording-lifecycle/specs/recording-lifecycle/spec.md b/openspec/changes/core-recording-lifecycle/specs/recording-lifecycle/spec.md index 9f8f402..a966f91 100644 --- a/openspec/changes/core-recording-lifecycle/specs/recording-lifecycle/spec.md +++ b/openspec/changes/core-recording-lifecycle/specs/recording-lifecycle/spec.md @@ -39,7 +39,7 @@ Перед отправкой запись MUST перейти в `sent` с отметкой времени начала отправки, записанной в заметку. Очередь MUST обрабатывать записи строго по одной. Результат провайдера — успех с текстом, временная ошибка или постоянная ошибка. Успех с непустым текстом MUST переводить запись в `inserted` с текстом в заметке. Успех с пустым (после обрезки пробелов) текстом MUST переводить запись в `failed` с причиной «нет речи»: это свойство записи, а не ошибка сервиса, повтор бесполезен. #### Scenario: Успешное распознавание -- **WHEN** провайдер вернул текст для записи в `saved` +- **WHEN** провайдер вернул текст, а запись в момент ответа в `sent` - **THEN** заметка содержит текст и статус `inserted` #### Scenario: В записи нет речи 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 index 349fb64..fe0ef17 100644 --- a/openspec/changes/core-recording-lifecycle/specs/voice-note-format/spec.md +++ b/openspec/changes/core-recording-lifecycle/specs/voice-note-format/spec.md @@ -32,11 +32,11 @@ ### Requirement: Тело заметки до распознавания -Новая заметка MUST содержать вложение аудио `![[<путь к аудио>]]`, затем управляемую область, ограниченную маркерами-комментариями `%%voice-note:%%` и `%%/voice-note%%`. Внутри области MUST отображаться состояние: для `saved`/`sent` — `> [!info] Распознаётся…`; для `retry` — `> [!info] Нет связи, повтор в HH:mm (попытка N/5)` по локальному времени; для `failed` — `> [!warning] Не распознано: <причина>. Команда «Повторить распознавание»`, кроме причины «нет речи», для которой — `> [!info] В записи не распознано речи` (без слова «ошибка» и без призыва повторить). Для прерванной записи над областью MUST стоять строка `> [!note] Запись прервана, сохранено M:SS`, которая остаётся и после вставки текста. +Новая заметка MUST содержать вложение аудио `![[<путь к аудио>]]`, затем управляемую область, ограниченную маркерами-комментариями `%%voice-note:%%` и `%%/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)` +- **THEN** область содержит `> [!info] Не удалось отправить, повтор в 14:17 (попытка 2/5)` ### Requirement: Вставка транскрипта -- 2.47.2 From cbd76d5802d5fb685848e22094b34338d7a5a9d9 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 27 Sep 2026 15:47:55 +0400 Subject: [PATCH 06/22] =?UTF-8?q?docs(openspec):=20SDD=20pre-flight=20ruli?= =?UTF-8?q?ngs=20=E2=80=94=20implementer=20models,=20test=20helper=20dupli?= =?UTF-8?q?cation,=20decision=20logging?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refs #1 Co-Authored-By: Claude Opus 5.5 --- openspec/changes/core-recording-lifecycle/design.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/openspec/changes/core-recording-lifecycle/design.md b/openspec/changes/core-recording-lifecycle/design.md index 34ae93e..053e510 100644 --- a/openspec/changes/core-recording-lifecycle/design.md +++ b/openspec/changes/core-recording-lifecycle/design.md @@ -127,3 +127,6 @@ interface Env { now(): Date; random(): number; deviceId: string; } 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; задачи идут строго последовательно, конфликтов нумерации нет.* **Шум в нумерации/формате раздела, правится руками.** -- 2.47.2 From 7cd3811ead3020ff66f39b3aa5e330964aab71bd Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 27 Sep 2026 15:52:20 +0400 Subject: [PATCH 07/22] chore: scaffold build and vitest, add core ports and boundary guard Refs #1 Co-Authored-By: Claude Opus 5.5 --- esbuild.config.mjs | 56 + manifest.json | 9 + package-lock.json | 2102 +++++++++++++++++++++++++++++++++++ package.json | 19 + src/core/ports.ts | 38 + src/main.ts | 6 + tests/core-boundary.test.ts | 34 + tsconfig.json | 15 + vitest.config.ts | 11 + 9 files changed, 2290 insertions(+) create mode 100644 esbuild.config.mjs create mode 100644 manifest.json create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 src/core/ports.ts create mode 100644 src/main.ts create mode 100644 tests/core-boundary.test.ts create mode 100644 tsconfig.json create mode 100644 vitest.config.ts diff --git a/esbuild.config.mjs b/esbuild.config.mjs new file mode 100644 index 0000000..4443b21 --- /dev/null +++ b/esbuild.config.mjs @@ -0,0 +1,56 @@ +import esbuild from 'esbuild'; +import { copyFileSync, existsSync, mkdirSync } from 'node:fs'; +import { builtinModules } from 'node:module'; +import process from 'node:process'; + +const prod = process.argv[2] === 'production'; +const VAULT = '/Users/andrey.epifancev/Library/Mobile Documents/iCloud~md~obsidian/Documents/voice-note-test'; +const PLUGIN_DIR = `${VAULT}/.obsidian/plugins/voice-note`; + +// В dev-режиме после каждой сборки копируем плагин в тестовый vault, если он есть на этой машине. +const copyToVault = { + name: 'copy-to-vault', + setup(build) { + build.onEnd((result) => { + if (result.errors.length > 0 || !existsSync(VAULT)) return; + mkdirSync(PLUGIN_DIR, { recursive: true }); + for (const file of ['main.js', 'manifest.json']) copyFileSync(file, `${PLUGIN_DIR}/${file}`); + }); + }, +}; + +const context = await esbuild.context({ + entryPoints: ['src/main.ts'], + bundle: true, + external: [ + 'obsidian', + 'electron', + '@codemirror/autocomplete', + '@codemirror/collab', + '@codemirror/commands', + '@codemirror/language', + '@codemirror/lint', + '@codemirror/search', + '@codemirror/state', + '@codemirror/view', + '@lezer/common', + '@lezer/highlight', + '@lezer/lr', + ...builtinModules, + ], + format: 'cjs', + target: 'es2018', + logLevel: 'info', + sourcemap: prod ? false : 'inline', + treeShaking: true, + outfile: 'main.js', + minify: prod, + plugins: prod ? [] : [copyToVault], +}); + +if (prod) { + await context.rebuild(); + process.exit(0); +} else { + await context.watch(); +} diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000..252ef48 --- /dev/null +++ b/manifest.json @@ -0,0 +1,9 @@ +{ + "id": "voice-note", + "name": "Voice Note", + "version": "0.1.0", + "minAppVersion": "1.5.0", + "description": "Голосовые заметки: запись, распознавание внешним сервисом, текст в заметку.", + "author": "Andrey Epifancev", + "isDesktopOnly": false +} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..7560dc0 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,2102 @@ +{ + "name": "obsidian-voice-note", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "obsidian-voice-note", + "version": "0.1.0", + "devDependencies": { + "@types/node": "^26.6.3", + "esbuild": "^0.28.2", + "obsidian": "^1.13.1", + "typescript": "^7.0.2", + "vitest": "^5.0.2" + } + }, + "node_modules/@codemirror/state": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.5.0.tgz", + "integrity": "sha512-MwBHVK60IiIHDcoMet78lxt6iw5gJOGSbNbOIVBHWVXIH4/Nq1+GQgLLGgI1KlnN86WDXsPudVaqYHKBIx7Eyw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@marijn/find-cluster-break": "^1.0.0" + } + }, + "node_modules/@codemirror/view": { + "version": "6.38.6", + "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.38.6.tgz", + "integrity": "sha512-qiS0z1bKs5WOvHIAC0Cybmv4AJSkAXgX5aD6Mqd2epSLlVJsQl8NG23jCVouIgkh4All/mrbdsf2UOLFnJw0tw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@codemirror/state": "^6.5.0", + "crelt": "^1.0.6", + "style-mod": "^4.1.0", + "w3c-keyname": "^2.2.4" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@marijn/find-cluster-break": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.4.tgz", + "integrity": "sha512-Wy0V7+SGUjnF9/TkiM1hKVDPj7jKXduPNboMVtHTA8dySMURWqfg/JZ9E2Sq8JgSJmkl7k7Qe9FLeMSrSraWmQ==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/@oxc-project/types": { + "version": "0.151.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.151.0.tgz", + "integrity": "sha512-J1yXrIlNDZVzE3ada310xeAw7nH8yCAyLPuUIsjKatFPmfn5bS1oW+cM+QsGOtVWd5nhSpbwZWx/rue+r5Z+PA==", + "dev": true, + "license": "MIT", + "peer": true, + "funding": { + "url": "https://github.com/sponsors/oxc-project" + } + }, + "node_modules/@rolldown/binding-android-arm-eabi": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.11.tgz", + "integrity": "sha512-A5kXfGKvKWWZE0TtPrfsvT+q4Y5d1QG8gGUzpYjGydM+fARM9MuX90PrXYXe0XbsDVgyxxNzHo6giCj90bsFNw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.11.tgz", + "integrity": "sha512-z6cTycz+iJ4PVkuL4HHW4DfTfoeU/2nqYYuSOrTmH7yHK5Y0LCOnA03V4ZNxavyVaU1oOqUgIg2klN/s+USGOA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.11.tgz", + "integrity": "sha512-jShvqNtP6vDC6/A5JOAzbVV+DkgHqhl/ScVCJEbt+TUY6QYz7YnXcrg3sLtFBniro0f/Ld50ZwCWA6f7KYD1nQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.11.tgz", + "integrity": "sha512-f2i2xiNWq1Z1l2++q2fuhZRdLAT3aqxD6vRNm1RAxpUoBcdqNB3C0s1Bt+K+PbEx2F5F4gQp6hqKkphCY/xF9w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.11.tgz", + "integrity": "sha512-4Ir5FSOKIAMr4r0kExpt1s3bMgzJU3rA45AYOHtQpls0oNeqcYBKrWMlckrYH4KCfGLfkfn1tN1dmZPMVsdXow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.11.tgz", + "integrity": "sha512-/gnRDM+39BROzAN/k1OZjDPnDMcZxB/0EUxKjONO5yVkNEvlsoMDrxGNKgZi/ttFriS2gwlDNzB65pvNbFOXIQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.11.tgz", + "integrity": "sha512-PFaK8HwvAHbaKbBcDNQihjMKYvFnA5hiENx/l5tphTDz1E0WFp32l0A7aq7lyUwGsRw/xSrNIy/gIK4thrSCrw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.11.tgz", + "integrity": "sha512-AskzJUIKRLPxkruR1wLKewGbOw+EYfU/9lOrBFj4AFrEA8hPpKFnODWNu2WLaNs0QNkEb9QIJufmVZZIL/bJlg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.11.tgz", + "integrity": "sha512-qlUGAheh2yh8afH7QBgx0PrRHN85hKnNd78x8MeMhXivuevgd8vgf6/CstOzmNKY/lLTHvNTrPy98cLnAugzJw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.11.tgz", + "integrity": "sha512-secpEad+0vCbSfn8upFySkDskv+bGPk3THSDS9Y89yc4rb4kzqHp8Dmyd9BkQW4SnhNXBZCl/6CrO//hZahNJQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.11.tgz", + "integrity": "sha512-mOVBT3dPpkWm8XBWPmU4bf+U6dYDLeMo/9ojUmis4N0L5uu10qra5vOyngZ7/PSdoE4G9KvRt4bloRxNjLas7A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.11.tgz", + "integrity": "sha512-Is78i9A8Ui4SqcxUwFJ9uMmjDn58IbVTjFWYdQestFEgeuEmHMLGNriXnVJKkwG2YiZjw8cP0zCTyDMdDGtOOg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.11.tgz", + "integrity": "sha512-dUCXneZ87INUMyQ0D+C0HrEBNUPNXHaPmU5GTjyKTJEiussw9Kaj5Ln8UztPe4epV/ffvgNBEadksdYhmW6xJA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.11.tgz", + "integrity": "sha512-jByxb6qfd+bH1xUd0qnfFnb17i9sWBPY2tOavJ0l3tdr3OTu+Kvtm8cd/JV5nFt657b1VqGltxg9olOEfofXWw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.11.tgz", + "integrity": "sha512-/PzKqzAJ03i19oy2ItPvyvaVjOjBCNnfaJs8yvUdGBKmiESgnrJSQ2awd81QzFbbnAmu7YO9ZnJrDCb9VSJPRA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/codemirror": { + "version": "5.60.8", + "resolved": "https://registry.npmjs.org/@types/codemirror/-/codemirror-5.60.8.tgz", + "integrity": "sha512-VjFgDF/eB+Aklcy15TtOTLQeMjTo07k7KAjql8OK5Dirr7a6sJY4T1uVBDuTVG9VEmn1uUsohOpYnVfgC6/jyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/tern": "*" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "26.6.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.3.tgz", + "integrity": "sha512-dsqMQQoeTLqu9wynDD00q573mNzso3IdQOAfHRJqLCcmCFPoGo9A1bDpUcv/9tnKpErQWv9uKeGfl37EIS02Yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~8.9.0" + } + }, + "node_modules/@types/tern": { + "version": "0.23.9", + "resolved": "https://registry.npmjs.org/@types/tern/-/tern-0.23.9.tgz", + "integrity": "sha512-ypzHFE/wBzh+BlH6rrBgS5I/Z7RD21pGhZ2rltb/+ZrVM1awdZwjx7hE5XfuYgHWk9uvV5HLZN3SloevCAp3Bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "*" + } + }, + "node_modules/@typescript/typescript-aix-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz", + "integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz", + "integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz", + "integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz", + "integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz", + "integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz", + "integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz", + "integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-loong64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz", + "integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-mips64el": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz", + "integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz", + "integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-riscv64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz", + "integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-s390x": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz", + "integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz", + "integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz", + "integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz", + "integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz", + "integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz", + "integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-sunos-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz", + "integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz", + "integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz", + "integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@vitest/mocker": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-5.0.2.tgz", + "integrity": "sha512-Z5FS00Q1SJHkB35xATsmWGdQ5WA1/0MV3CDjqyv7GavHv1OfOj145MNfHOlHk7QLes21dKFDHr8EO2zvL+9WGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.31", + "@vitest/spy": "5.0.2", + "estree-walker": "^3.0.3", + "magic-string": "^1.2.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/spy": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-5.0.2.tgz", + "integrity": "sha512-Ijc7T1nT9efNb5LxvjaBrEqw3f/QwUv5EE0nKqZxgqsaV/FxAAZ8baGylA8X/Z2oS4Lp+K74Jr6dTJsDKxJDeg==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/crelt": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.7.tgz", + "integrity": "sha512-aK6BbWfhf4U/wCcLHKPJl/xa6VkVstRaPywWtMKGwuOLc/wZTyQYuoxgvZnNsBvv7Kg3YTBQYYBCggcviQczuA==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/es-module-lexer": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", + "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "peer": true, + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/magic-string": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.2.tgz", + "integrity": "sha512-vG+rjFRj1PqdIBozIxAGMjPlOhaVe+GXpbttY/iSK7rGcJRMlwNJO7dcUwmUqkymsFLJiNGI06t4D7Fr7yRC9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.6.0" + } + }, + "node_modules/moment": { + "version": "2.29.4", + "resolved": "https://registry.npmjs.org/moment/-/moment-2.29.4.tgz", + "integrity": "sha512-5LC9SOxjSc2HF6vO2CyuTDNivEdoz2IvyJJGj6X8DJ0eFyfszE0QiEd+iXmBvUP3WHxSjFH/vIsA0EN00cgr8w==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/nanoid": { + "version": "3.3.19", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz", + "integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "peer": true, + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/obsidian": { + "version": "1.13.1", + "resolved": "https://registry.npmjs.org/obsidian/-/obsidian-1.13.1.tgz", + "integrity": "sha512-qtTEA2pmhJzhuhJqzbBFRYhpIOqvW+krDYjtFynv66KbxBbumHBlsJfWw3I4jtnK/6fZwbQhCrmmDdRwXmX56w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/codemirror": "5.60.8", + "moment": "2.29.4" + }, + "peerDependencies": { + "@codemirror/state": "6.5.0", + "@codemirror/view": "6.38.6" + } + }, + "node_modules/obug": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.2.1.tgz", + "integrity": "sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC", + "peer": true + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.28", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", + "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "peer": true, + "dependencies": { + "nanoid": "^3.3.18", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rolldown": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.11.tgz", + "integrity": "sha512-qpSwIyz0jHQq5qXBTNxFmE6664rJ7O+4TvPFOiOaBSrz8IOHc1koKKSqTM2H6u1UG1+TveuC6vaDHKXFOvb1Kw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@oxc-project/types": "=0.151.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm-eabi": "1.2.11", + "@rolldown/binding-android-arm64": "1.2.11", + "@rolldown/binding-darwin-arm64": "1.2.11", + "@rolldown/binding-darwin-x64": "1.2.11", + "@rolldown/binding-freebsd-x64": "1.2.11", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.11", + "@rolldown/binding-linux-arm64-gnu": "1.2.11", + "@rolldown/binding-linux-arm64-musl": "1.2.11", + "@rolldown/binding-linux-ppc64-gnu": "1.2.11", + "@rolldown/binding-linux-s390x-gnu": "1.2.11", + "@rolldown/binding-linux-x64-gnu": "1.2.11", + "@rolldown/binding-linux-x64-musl": "1.2.11", + "@rolldown/binding-openharmony-arm64": "1.2.11", + "@rolldown/binding-win32-arm64-msvc": "1.2.11", + "@rolldown/binding-win32-x64-msvc": "1.2.11" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/std-env": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", + "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", + "dev": true, + "license": "MIT" + }, + "node_modules/style-mod": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.4.tgz", + "integrity": "sha512-XXWIQt633/EpAFx8aZDOTjBzrCaGmhvEQlQo6MVPfa2OzO2cWo+4hV9h+6UkHYlXGfy+ODXKUdP7Pthmcu5ATw==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/tinybench": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.2.0.tgz", + "integrity": "sha512-78U2TlB2CnVenajOFzf3BKSm0J6oz5L0NV7g32LCPccvYc0lbWvys4d3uUUCS2B1N8PAf2+aekR8i1KbC3HO7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/tinyexec": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.1.tgz", + "integrity": "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/typescript": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz", + "integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc" + }, + "engines": { + "node": ">=16.20.0" + }, + "optionalDependencies": { + "@typescript/typescript-aix-ppc64": "7.0.2", + "@typescript/typescript-darwin-arm64": "7.0.2", + "@typescript/typescript-darwin-x64": "7.0.2", + "@typescript/typescript-freebsd-arm64": "7.0.2", + "@typescript/typescript-freebsd-x64": "7.0.2", + "@typescript/typescript-linux-arm": "7.0.2", + "@typescript/typescript-linux-arm64": "7.0.2", + "@typescript/typescript-linux-loong64": "7.0.2", + "@typescript/typescript-linux-mips64el": "7.0.2", + "@typescript/typescript-linux-ppc64": "7.0.2", + "@typescript/typescript-linux-riscv64": "7.0.2", + "@typescript/typescript-linux-s390x": "7.0.2", + "@typescript/typescript-linux-x64": "7.0.2", + "@typescript/typescript-netbsd-arm64": "7.0.2", + "@typescript/typescript-netbsd-x64": "7.0.2", + "@typescript/typescript-openbsd-arm64": "7.0.2", + "@typescript/typescript-openbsd-x64": "7.0.2", + "@typescript/typescript-sunos-x64": "7.0.2", + "@typescript/typescript-win32-arm64": "7.0.2", + "@typescript/typescript-win32-x64": "7.0.2" + } + }, + "node_modules/undici-types": { + "version": "8.9.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.9.0.tgz", + "integrity": "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "8.3.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.1.tgz", + "integrity": "sha512-/bvH9E9tmCXRGp2uXY3WbOldqpTwFkbha/8ANaEQ6VkxhH60KyqLwgZq6lG2y+4uT55x9+9eUHMpQ7uGnOCKjA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.7", + "postcss": "^8.5.28", + "rolldown": "~1.2.9", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.7.1", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-5.0.2.tgz", + "integrity": "sha512-7MQrx9pDv5aHiUcovIb/70Ys3tgtkUVgCtledvKdCmEO+/1Dicq5ZqoSxOW034m03oqC+oHOKui2dM6qtMLoJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/mocker": "5.0.2", + "chai": "^6.2.2", + "es-module-lexer": "^2.3.2", + "expect-type": "^1.4.0", + "magic-string": "^1.2.3", + "obug": "^2.1.4", + "picomatch": "^4.0.7", + "std-env": "^4.2.0", + "tinybench": "^6.1.4", + "tinyexec": "^1.3.0", + "tinyglobby": "^0.2.17", + "why-is-node-running": "^3.2.1" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^22.12.0 || ^24.0.0 || >=26.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "5.0.2", + "@vitest/browser-preview": "5.0.2", + "@vitest/browser-webdriverio": "^5.0.0-beta.5 || >=5.0.0", + "@vitest/coverage-istanbul": "5.0.2", + "@vitest/coverage-v8": "5.0.2", + "@vitest/ui": "5.0.2", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.4.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/w3c-keyname": { + "version": "2.2.8", + "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", + "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/why-is-node-running": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-3.2.2.tgz", + "integrity": "sha512-NKUzAelcoCXhXL4dJzKIwXeR8iEVqsA0Lq6Vnd0UXvgaKbzVo4ZTHROF2Jidrv+SgxOQ03fMinnNhzZATxOD3A==", + "dev": true, + "license": "MIT", + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=20.11" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..f025eaa --- /dev/null +++ b/package.json @@ -0,0 +1,19 @@ +{ + "name": "obsidian-voice-note", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "node esbuild.config.mjs", + "build": "tsc --noEmit && node esbuild.config.mjs production", + "typecheck": "tsc --noEmit", + "test": "vitest run" + }, + "devDependencies": { + "@types/node": "^26.6.3", + "esbuild": "^0.28.2", + "obsidian": "^1.13.1", + "typescript": "^7.0.2", + "vitest": "^5.0.2" + } +} diff --git a/src/core/ports.ts b/src/core/ports.ts new file mode 100644 index 0000000..478f157 --- /dev/null +++ b/src/core/ports.ts @@ -0,0 +1,38 @@ +// Порты core. Реализации — в src/obsidian (позже) и tests/fakes.ts. + +export interface RecordingStore { + write(path: string, data: ArrayBuffer): Promise; + read(path: string): Promise; +} + +export interface ListedNote { + path: string; + frontmatter: Record; +} + +export 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; + // Атомарное чтение-изменение-запись. false, если заметки нет. + process(path: string, fn: (content: string) => string): Promise; +} + +export type TranscribeResult = + | { ok: true; text: string } + | { ok: false; kind: 'temporary' | 'permanent'; message: string }; + +export interface Transcriber { + transcribe(audio: ArrayBuffer, mime: string, signal: AbortSignal): Promise; +} + +export interface Env { + now(): Date; + random(): number; + readonly deviceId: string; +} diff --git a/src/main.ts b/src/main.ts new file mode 100644 index 0000000..358a51f --- /dev/null +++ b/src/main.ts @@ -0,0 +1,6 @@ +import { Plugin } from 'obsidian'; + +// Обёртка Obsidian — отдельная подсистема. Пока пустой плагин, чтобы сборка работала. +export default class VoiceNotePlugin extends Plugin { + override async onload(): Promise {} +} diff --git a/tests/core-boundary.test.ts b/tests/core-boundary.test.ts new file mode 100644 index 0000000..cf5a32c --- /dev/null +++ b/tests/core-boundary.test.ts @@ -0,0 +1,34 @@ +import { existsSync, readdirSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; + +const CORE = fileURLToPath(new URL('../src/core/', import.meta.url)); +const FORBIDDEN = + /(?:from\s+|import\s*\(\s*|require\s*\(\s*)['"](?:obsidian|fs|path|child_process|node:[^'"]*)(?:\/[^'"]*)?['"]/; + +function coreFiles(): string[] { + if (!existsSync(CORE)) return []; + return readdirSync(CORE, { recursive: true, encoding: 'utf8' }) + .filter((f) => f.endsWith('.ts')) + .map((f) => join(CORE, f)); +} + +describe('src/core boundary', () => { + it('detects forbidden imports', () => { + expect(FORBIDDEN.test(`import { Plugin } from 'obsidian';`)).toBe(true); + expect(FORBIDDEN.test(`import { readFile } from "fs/promises";`)).toBe(true); + expect(FORBIDDEN.test(`import x from 'node:path';`)).toBe(true); + expect(FORBIDDEN.test(`const cp = require('child_process');`)).toBe(true); + expect(FORBIDDEN.test(`import { transition } from './record';`)).toBe(false); + }); + + it('has files to check', () => { + expect(coreFiles().length).toBeGreaterThan(0); + }); + + it('imports neither obsidian nor Node modules', () => { + const offenders = coreFiles().filter((f) => FORBIDDEN.test(readFileSync(f, 'utf8'))); + expect(offenders).toEqual([]); + }); +}); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..72f87ae --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ES2022", "DOM"], + "types": ["node"], + "strict": true, + "noEmit": true, + "isolatedModules": true, + "skipLibCheck": true, + "noImplicitOverride": true + }, + "include": ["src/**/*.ts", "tests/**/*.ts", "vitest.config.ts"] +} diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..8aca30c --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from 'vitest/config'; + +// Детерминированное локальное время для имён заметок и строк статуса: +// UTC+3 без перехода на летнее время. Воркеры vitest наследуют env. +process.env.TZ = 'Europe/Moscow'; + +export default defineConfig({ + test: { + include: ['tests/**/*.test.ts'], + }, +}); -- 2.47.2 From 6b2466cf05133a1a4910ea8bfa6332e4f913b4a1 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 27 Sep 2026 16:01:00 +0400 Subject: [PATCH 08/22] feat(core): record ids, file paths, time formatting Refs #1 Co-Authored-By: Claude Opus 5.5 --- src/core/naming.ts | 48 +++++++++++++++++++++++++++ tests/naming.test.ts | 79 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 127 insertions(+) create mode 100644 src/core/naming.ts create mode 100644 tests/naming.test.ts diff --git a/src/core/naming.ts b/src/core/naming.ts new file mode 100644 index 0000000..d7e750a --- /dev/null +++ b/src/core/naming.ts @@ -0,0 +1,48 @@ +export const SOURCE_DIR = 'voice/source'; +export const INBOX_DIR = 'voice/inbox'; + +const pad = (n: number) => String(n).padStart(2, '0'); + +// Время в id — UTC: порядок id не зависит от пояса устройства. +export function makeId(startedAt: Date, random: () => number): string { + const d = startedAt; + const date = `${d.getUTCFullYear()}${pad(d.getUTCMonth() + 1)}${pad(d.getUTCDate())}`; + const time = `${pad(d.getUTCHours())}${pad(d.getUTCMinutes())}${pad(d.getUTCSeconds())}`; + let hex = ''; + for (let i = 0; i < 4; i++) hex += Math.min(15, Math.floor(random() * 16)).toString(16); + return `${date}-${time}-${hex}`; +} + +const EXT: Record = { + 'audio/mp4': 'm4a', + 'audio/webm': 'webm', + 'audio/ogg': 'ogg', +}; + +export function extFromMime(mime: string): string { + const base = mime.split(';')[0].trim().toLowerCase(); + return EXT[base] ?? 'bin'; +} + +export function audioPath(id: string, mime: string): string { + return `${SOURCE_DIR}/${id}.${extFromMime(mime)}`; +} + +// Имя заметки — для человека, поэтому в локальном времени. +export function notePath(startedAt: Date, n: number): string { + const d = startedAt; + const name = + `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ` + + `${pad(d.getHours())}-${pad(d.getMinutes())}-${pad(d.getSeconds())}`; + return `${INBOX_DIR}/${name}${n > 1 ? ` (${n})` : ''}.md`; +} + +export function formatDuration(ms: number): string { + const total = Math.max(0, Math.round(ms / 1000)); + return `${Math.floor(total / 60)}:${pad(total % 60)}`; +} + +export function formatLocalTime(iso: string): string { + const d = new Date(iso); + return `${pad(d.getHours())}:${pad(d.getMinutes())}`; +} diff --git a/tests/naming.test.ts b/tests/naming.test.ts new file mode 100644 index 0000000..8315bf7 --- /dev/null +++ b/tests/naming.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from 'vitest'; +import { + audioPath, + extFromMime, + formatDuration, + formatLocalTime, + makeId, + notePath, +} from '../src/core/naming'; + +const T = new Date('2026-09-27T11:15:03.000Z'); // 14:15:03 по Москве + +function seq(...values: number[]): () => number { + return () => values.shift() ?? 0; +} + +describe('test environment', () => { + it('runs in UTC+3', () => { + expect(T.getHours()).toBe(14); + }); +}); + +describe('makeId', () => { + it('uses UTC time and 4 hex chars', () => { + expect(makeId(T, () => 0)).toBe('20260927-111503-0000'); + expect(makeId(T, seq(0.99, 0.5, 0.0625, 0.1))).toBe('20260927-111503-f811'); + }); + + it('clamps random() === 1 to f', () => { + expect(makeId(T, () => 1)).toBe('20260927-111503-ffff'); + }); + + it('does not depend on the local date', () => { + const lateEvening = new Date('2026-09-27T22:30:00.000Z'); // 01:30 28-го по Москве + expect(makeId(lateEvening, () => 0)).toBe('20260927-223000-0000'); + expect(notePath(lateEvening, 1)).toBe('voice/inbox/2026-09-28 01-30-00.md'); + }); +}); + +describe('extFromMime', () => { + it.each([ + ['audio/mp4', 'm4a'], + ['audio/webm', 'webm'], + ['audio/webm;codecs=opus', 'webm'], + ['AUDIO/MP4', 'm4a'], + ['audio/ogg; codecs=opus', 'ogg'], + ['audio/x-foo', 'bin'], + ['', 'bin'], + ])('%s → %s', (mime, ext) => { + expect(extFromMime(mime)).toBe(ext); + }); +}); + +describe('paths', () => { + it('audioPath', () => { + expect(audioPath('20260927-111503-0000', 'audio/mp4')).toBe('voice/source/20260927-111503-0000.m4a'); + }); + + it('notePath uses local time and suffix from 2', () => { + expect(notePath(T, 1)).toBe('voice/inbox/2026-09-27 14-15-03.md'); + expect(notePath(T, 2)).toBe('voice/inbox/2026-09-27 14-15-03 (2).md'); + expect(notePath(T, 3)).toBe('voice/inbox/2026-09-27 14-15-03 (3).md'); + }); +}); + +describe('formatting', () => { + it.each([ + [0, '0:00'], + [47_000, '0:47'], + [125_400, '2:05'], + [-5, '0:00'], + ])('formatDuration(%d) = %s', (ms, text) => { + expect(formatDuration(ms)).toBe(text); + }); + + it('formatLocalTime', () => { + expect(formatLocalTime('2026-09-27T11:17:03.000Z')).toBe('14:17'); + }); +}); -- 2.47.2 From a4163fa5fe1b74ec14ee71dcb27011a8bfbfe9a8 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 27 Sep 2026 16:12:31 +0400 Subject: [PATCH 09/22] feat(core): voice record state machine with backoff Refs #1 Co-Authored-By: Claude Opus 5.5 --- src/core/record.ts | 87 +++++++++++++++++++++++++++++++ tests/record.test.ts | 119 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 206 insertions(+) create mode 100644 src/core/record.ts create mode 100644 tests/record.test.ts diff --git a/src/core/record.ts b/src/core/record.ts new file mode 100644 index 0000000..8ac67c4 --- /dev/null +++ b/src/core/record.ts @@ -0,0 +1,87 @@ +// Персистентные состояния. created и transcribed живут только в памяти и сюда не входят. +export type Status = 'saved' | 'sent' | 'retry' | 'failed' | 'inserted'; +export const STATUSES: readonly Status[] = ['saved', 'sent', 'retry', 'failed', 'inserted']; + +export const MAX_ATTEMPTS = 5; +// Пауза перед повтором после 1-й, 2-й, 3-й и 4-й временной неудачи. +export const BACKOFF_MS: readonly number[] = [30_000, 120_000, 600_000, 1_800_000]; +// sent старше этого считается прерванной отправкой. +export const SENT_STALE_MS = 600_000; +// Код причины в voice-error для пустого транскрипта. +export const NO_SPEECH = 'no-speech'; + +export interface VoiceRecord { + id: string; + status: Status; + device: string; + audio: string; // путь в vault, без [[ ]] + mime: string; + attempts: number; + nextAttempt?: string; // ISO UTC + sentAt?: string; // ISO UTC + error?: string; + interrupted?: boolean; +} + +export type RecordEvent = + | { type: 'send' } + | { type: 'success' } + | { type: 'temporary'; message: string } + | { type: 'permanent'; message: string } + | { type: 'retry-now'; device: string } + | { type: 'claim'; device: string }; + +export class InvalidTransition extends Error { + constructor(from: Status, event: RecordEvent['type']) { + super(`invalid transition: ${event} from ${from}`); + this.name = 'InvalidTransition'; + } +} + +type Transient = 'nextAttempt' | 'sentAt' | 'error'; + +function without(record: VoiceRecord, ...keys: Transient[]): VoiceRecord { + const copy = { ...record }; + for (const key of keys) delete copy[key]; + return copy; +} + +export function transition(record: VoiceRecord, event: RecordEvent, now: Date): VoiceRecord { + const from = record.status; + switch (event.type) { + case 'send': + if (from !== 'saved' && from !== 'retry') break; + return { ...without(record, 'nextAttempt'), status: 'sent', sentAt: now.toISOString() }; + case 'success': + if (from !== 'sent') break; + return { ...without(record, 'nextAttempt', 'sentAt', 'error'), status: 'inserted' }; + case 'temporary': { + if (from !== 'sent') break; + const attempts = record.attempts + 1; + const next = { ...without(record, 'sentAt', 'nextAttempt'), attempts, error: event.message }; + if (attempts >= MAX_ATTEMPTS) return { ...next, status: 'failed' }; + const at = new Date(now.getTime() + BACKOFF_MS[attempts - 1]).toISOString(); + return { ...next, status: 'retry', nextAttempt: at }; + } + case 'permanent': + if (from !== 'sent') break; + return { + ...without(record, 'sentAt', 'nextAttempt'), + status: 'failed', + attempts: record.attempts + 1, + error: event.message, + }; + case 'retry-now': + if (from !== 'failed' && from !== 'retry') break; + return { + ...without(record, 'nextAttempt', 'sentAt', 'error'), + status: 'saved', + attempts: 0, + device: event.device, + }; + case 'claim': + if (from !== 'saved' && from !== 'retry' && from !== 'sent') break; + return { ...without(record, 'nextAttempt', 'sentAt'), status: 'saved', device: event.device }; + } + throw new InvalidTransition(from, event.type); +} diff --git a/tests/record.test.ts b/tests/record.test.ts new file mode 100644 index 0000000..ddc9592 --- /dev/null +++ b/tests/record.test.ts @@ -0,0 +1,119 @@ +import { describe, expect, it } from 'vitest'; +import { + BACKOFF_MS, + InvalidTransition, + MAX_ATTEMPTS, + transition, + type RecordEvent, + type Status, + type VoiceRecord, +} from '../src/core/record'; + +const NOW = new Date('2026-09-27T11:15:03.000Z'); +const base: VoiceRecord = Object.freeze({ + id: '20260927-111503-0000', + status: 'saved', + device: 'd1', + audio: 'voice/source/20260927-111503-0000.m4a', + mime: 'audio/mp4', + attempts: 0, +}); +const sent = (over: Partial = {}): VoiceRecord => + Object.freeze({ ...base, status: 'sent' as const, sentAt: NOW.toISOString(), ...over }); + +describe('transition', () => { + it('send: saved → sent with sentAt', () => { + expect(transition(base, { type: 'send' }, NOW)).toEqual({ + ...base, + status: 'sent', + sentAt: '2026-09-27T11:15:03.000Z', + }); + }); + + it('send: retry → sent, drops nextAttempt, keeps attempts and error', () => { + const r = Object.freeze({ ...base, status: 'retry' as const, attempts: 1, nextAttempt: 'x', error: 'timeout' }); + expect(transition(r, { type: 'send' }, NOW)).toEqual({ + ...base, + status: 'sent', + attempts: 1, + error: 'timeout', + sentAt: NOW.toISOString(), + }); + }); + + it('success: sent → inserted, clears transient keys, keeps attempts', () => { + expect(transition(sent({ error: 'old', attempts: 2 }), { type: 'success' }, NOW)).toEqual({ + ...base, + status: 'inserted', + attempts: 2, + }); + }); + + it('temporary: backoff 30s, 2m, 10m, 30m after failures 1..4', () => { + const got = [0, 1, 2, 3].map((a) => + transition(sent({ attempts: a }), { type: 'temporary', message: 'timeout' }, NOW), + ); + expect(got.map((r) => r.status)).toEqual(['retry', 'retry', 'retry', 'retry']); + expect(got.map((r) => r.attempts)).toEqual([1, 2, 3, 4]); + expect(got.map((r) => Date.parse(r.nextAttempt!) - NOW.getTime())).toEqual([...BACKOFF_MS]); + expect(got.every((r) => r.sentAt === undefined && r.error === 'timeout')).toBe(true); + }); + + it('temporary: fifth failure → failed with last message', () => { + expect( + transition(sent({ attempts: MAX_ATTEMPTS - 1 }), { type: 'temporary', message: 'e5' }, NOW), + ).toEqual({ ...base, status: 'failed', attempts: 5, error: 'e5' }); + }); + + it('permanent: sent → failed immediately', () => { + expect(transition(sent(), { type: 'permanent', message: '401 Unauthorized' }, NOW)).toEqual({ + ...base, + status: 'failed', + attempts: 1, + error: '401 Unauthorized', + }); + }); + + it('retry-now: failed → saved, attempts 0, device reassigned', () => { + const r = Object.freeze({ ...base, status: 'failed' as const, attempts: 5, error: '401' }); + expect(transition(r, { type: 'retry-now', device: 'd2' }, NOW)).toEqual({ ...base, device: 'd2' }); + }); + + it('retry-now: retry → saved, drops nextAttempt', () => { + const r = Object.freeze({ ...base, status: 'retry' as const, attempts: 2, nextAttempt: 'x', error: 't' }); + expect(transition(r, { type: 'retry-now', device: 'd1' }, NOW)).toEqual(base); + }); + + it('claim: retry → saved, new device, attempts kept', () => { + const r = Object.freeze({ ...base, status: 'retry' as const, attempts: 2, nextAttempt: 'x', error: 't' }); + expect(transition(r, { type: 'claim', device: 'd2' }, NOW)).toEqual({ + ...base, + device: 'd2', + attempts: 2, + error: 't', + }); + }); + + it('claim: sent → saved, drops sentAt', () => { + expect(transition(sent(), { type: 'claim', device: 'd2' }, NOW)).toEqual({ ...base, device: 'd2' }); + }); + + const invalid: [Status, RecordEvent][] = [ + ['inserted', { type: 'send' }], + ['failed', { type: 'send' }], + ['sent', { type: 'send' }], + ['saved', { type: 'success' }], + ['retry', { type: 'success' }], + ['saved', { type: 'temporary', message: 'x' }], + ['inserted', { type: 'permanent', message: 'x' }], + ['saved', { type: 'retry-now', device: 'd1' }], + ['inserted', { type: 'retry-now', device: 'd1' }], + ['failed', { type: 'claim', device: 'd2' }], + ['inserted', { type: 'claim', device: 'd2' }], + ]; + + it.each(invalid)('rejects %s + %o', (status, event) => { + const r = Object.freeze({ ...base, status }); + expect(() => transition(r, event, NOW)).toThrow(InvalidTransition); + }); +}); -- 2.47.2 From 83236c411810572c167386f65576c27cc0c1935d Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 27 Sep 2026 16:18:36 +0400 Subject: [PATCH 10/22] feat(core): read, parse and patch voice-* frontmatter Refs #1 Co-Authored-By: Claude Opus 5.5 --- src/core/note.ts | 143 +++++++++++++++++++++++++++++++++ tests/note-frontmatter.test.ts | 138 +++++++++++++++++++++++++++++++ 2 files changed, 281 insertions(+) create mode 100644 src/core/note.ts create mode 100644 tests/note-frontmatter.test.ts diff --git a/src/core/note.ts b/src/core/note.ts new file mode 100644 index 0000000..48e5796 --- /dev/null +++ b/src/core/note.ts @@ -0,0 +1,143 @@ +import { STATUSES, type Status, type VoiceRecord } from './record'; + +// Порядок, в котором ключи появляются в новой заметке. +export const VOICE_KEYS = [ + 'voice-id', + 'voice-status', + 'voice-device', + 'voice-audio', + 'voice-mime', + 'voice-attempts', + 'voice-next-attempt', + 'voice-sent-at', + 'voice-error', + 'voice-interrupted', +] as const; +export type VoiceKey = (typeof VOICE_KEYS)[number]; + +// Строки — всегда в двойных кавычках: JSON-строка является валидной YAML-строкой +// и экранирует переводы строк, кавычки и двоеточия в одну строку. +function yaml(value: string | number): string { + return typeof value === 'string' ? JSON.stringify(value) : String(value); +} + +export function recordToFields(r: VoiceRecord): Record { + return { + 'voice-id': yaml(r.id), + 'voice-status': yaml(r.status), + 'voice-device': yaml(r.device), + 'voice-audio': yaml(`[[${r.audio}]]`), + 'voice-mime': yaml(r.mime), + 'voice-attempts': yaml(r.attempts), + 'voice-next-attempt': r.nextAttempt === undefined ? undefined : yaml(r.nextAttempt), + 'voice-sent-at': r.sentAt === undefined ? undefined : yaml(r.sentAt), + 'voice-error': r.error === undefined ? undefined : yaml(r.error), + 'voice-interrupted': r.interrupted ? 'true' : undefined, + }; +} + +// [первая строка внутри блока, индекс закрывающего ---] +function frontmatterRange(lines: string[]): [number, number] | null { + if (lines[0] !== '---') return null; + const end = lines.indexOf('---', 1); + return end === -1 ? null : [1, end]; +} + +function unquote(raw: string): string { + if (raw.startsWith('"')) { + try { + const value: unknown = JSON.parse(raw); + if (typeof value === 'string') return value; + } catch { + // не JSON — вернём как есть + } + } + if (raw.length >= 2 && raw.startsWith("'") && raw.endsWith("'")) { + return raw.slice(1, -1).replace(/''/g, "'"); + } + return raw; +} + +const VOICE_LINE = /^(voice-[a-z-]+):[ \t]*(.*)$/; + +export function readVoiceFields(content: string): Record { + const lines = content.split('\n'); + const range = frontmatterRange(lines); + const fields: Record = {}; + if (!range) return fields; + for (let i = range[0]; i < range[1]; i++) { + const match = VOICE_LINE.exec(lines[i]); + if (match) fields[match[1]] = unquote(match[2].trim()); + } + return fields; +} + +function str(value: unknown): string | undefined { + if (typeof value === 'string') return value; + if (typeof value === 'number' || typeof value === 'boolean') return String(value); + return undefined; +} + +export function parseRecord(fields: Record): VoiceRecord | null { + const id = str(fields['voice-id']); + const status = str(fields['voice-status']); + const device = str(fields['voice-device']); + const audio = str(fields['voice-audio']); + const mime = str(fields['voice-mime']); + const attempts = Number(str(fields['voice-attempts']) ?? '0'); + if (!id || !device || !audio || !mime) return null; + if (!STATUSES.includes(status as Status)) return null; + if (!Number.isInteger(attempts) || attempts < 0) return null; + + const record: VoiceRecord = { + id, + status: status as Status, + device, + audio: audio.replace(/^\[\[/, '').replace(/\]\]$/, ''), + mime, + attempts, + }; + const nextAttempt = str(fields['voice-next-attempt']); + const sentAt = str(fields['voice-sent-at']); + const error = str(fields['voice-error']); + if (nextAttempt) record.nextAttempt = nextAttempt; + if (sentAt) record.sentAt = sentAt; + if (error) record.error = error; + if (str(fields['voice-interrupted']) === 'true') record.interrupted = true; + return record; +} + +// Меняет только перечисленные ключи; undefined — удалить строку. +// Остальные строки frontmatter и их порядок не трогаются. +export function patchFrontmatter(content: string, fields: Record): string { + const lines = content.split('\n'); + let range = frontmatterRange(lines); + if (!range) { + lines.unshift('---', '---'); + range = [1, 1]; + } + const start = range[0]; + let end = range[1]; + for (const key of Object.keys(fields)) { + const value = fields[key]; + let index = -1; + for (let i = start; i < end; i++) { + if (lines[i].startsWith(`${key}:`)) { + index = i; + break; + } + } + if (value === undefined) { + if (index !== -1) { + lines.splice(index, 1); + end--; + } + } else if (index !== -1) { + lines[index] = `${key}: ${value}`; + } else { + lines.splice(end, 0, `${key}: ${value}`); + end++; + } + } + return lines.join('\n'); +} diff --git a/tests/note-frontmatter.test.ts b/tests/note-frontmatter.test.ts new file mode 100644 index 0000000..ff390d9 --- /dev/null +++ b/tests/note-frontmatter.test.ts @@ -0,0 +1,138 @@ +import { describe, expect, it } from 'vitest'; +import { parseRecord, patchFrontmatter, readVoiceFields, recordToFields } from '../src/core/note'; +import type { VoiceRecord } from '../src/core/record'; + +const rec: VoiceRecord = { + id: '20260927-111503-0000', + status: 'retry', + device: 'd1', + audio: 'voice/source/20260927-111503-0000.m4a', + mime: 'audio/webm;codecs=opus', + attempts: 2, + nextAttempt: '2026-09-27T11:17:03.000Z', + error: 'timeout', + interrupted: true, +}; + +describe('recordToFields + patchFrontmatter', () => { + it('writes keys in fixed order with quoted strings', () => { + expect(patchFrontmatter('', recordToFields(rec))).toBe( + [ + '---', + 'voice-id: "20260927-111503-0000"', + 'voice-status: "retry"', + 'voice-device: "d1"', + 'voice-audio: "[[voice/source/20260927-111503-0000.m4a]]"', + 'voice-mime: "audio/webm;codecs=opus"', + 'voice-attempts: 2', + 'voice-next-attempt: "2026-09-27T11:17:03.000Z"', + 'voice-error: "timeout"', + 'voice-interrupted: true', + '---', + '', + ].join('\n'), + ); + }); + + it('round-trips through readVoiceFields + parseRecord', () => { + const content = patchFrontmatter('', recordToFields(rec)); + expect(parseRecord(readVoiceFields(content))).toEqual(rec); + }); + + it('round-trips hostile error text as a single line', () => { + const hostile = { ...rec, error: 'Error: "bad"\nline two: x \\ y — ошибка' }; + const content = patchFrontmatter('', recordToFields(hostile)); + const errorLines = content.split('\n').filter((l) => l.startsWith('voice-error:')); + expect(errorLines).toHaveLength(1); + expect(content.split('\n').filter((l) => l.startsWith('line two'))).toHaveLength(0); + expect(parseRecord(readVoiceFields(content))).toEqual(hostile); + }); +}); + +describe('patchFrontmatter', () => { + const user = '---\ntitle: Идея\nvoice-status: "saved"\ntags: [idea]\n---\nbody\n'; + + it('replaces in place and keeps user keys and order', () => { + expect(patchFrontmatter(user, { 'voice-status': '"inserted"' })).toBe( + '---\ntitle: Идея\nvoice-status: "inserted"\ntags: [idea]\n---\nbody\n', + ); + }); + + it('appends new keys at the end of the block', () => { + expect(patchFrontmatter(user, { 'voice-sent-at': '"t"' })).toBe( + '---\ntitle: Идея\nvoice-status: "saved"\ntags: [idea]\nvoice-sent-at: "t"\n---\nbody\n', + ); + }); + + it('removes keys set to undefined, ignores absent ones', () => { + expect(patchFrontmatter(user, { 'voice-status': undefined, 'voice-error': undefined })).toBe( + '---\ntitle: Идея\ntags: [idea]\n---\nbody\n', + ); + }); + + it('creates a block when there is none, body untouched', () => { + expect(patchFrontmatter('body\n', { 'voice-id': '"x"' })).toBe('---\nvoice-id: "x"\n---\nbody\n'); + }); + + it('does not match a longer key with the same prefix', () => { + expect(patchFrontmatter('---\nvoice-idx: 1\n---\n', { 'voice-id': '"x"' })).toBe( + '---\nvoice-idx: 1\nvoice-id: "x"\n---\n', + ); + }); +}); + +describe('readVoiceFields', () => { + it('reads only the frontmatter block', () => { + const content = '---\nvoice-id: "a"\nother: 1\n---\nvoice-id: "b"\n'; + expect(readVoiceFields(content)).toEqual({ 'voice-id': 'a' }); + }); + + it('returns {} without frontmatter', () => { + expect(readVoiceFields('voice-id: "a"\n')).toEqual({}); + }); + + it('accepts plain and single-quoted values', () => { + expect(readVoiceFields("---\nvoice-id: abc\nvoice-mime: 'audio/mp4'\n---\n")).toEqual({ + 'voice-id': 'abc', + 'voice-mime': 'audio/mp4', + }); + }); +}); + +describe('parseRecord', () => { + const typed = { + 'voice-id': '20260927-111503-0000', + 'voice-status': 'saved', + 'voice-device': 'd1', + 'voice-audio': '[[voice/source/20260927-111503-0000.m4a]]', + 'voice-mime': 'audio/mp4', + 'voice-attempts': 0, + }; + + it('accepts metadataCache-style typed values', () => { + expect(parseRecord({ ...typed, 'voice-attempts': 3, 'voice-interrupted': true })).toEqual({ + id: '20260927-111503-0000', + status: 'saved', + device: 'd1', + audio: 'voice/source/20260927-111503-0000.m4a', + mime: 'audio/mp4', + attempts: 3, + interrupted: true, + }); + }); + + it('defaults missing attempts to 0', () => { + const { 'voice-attempts': _omit, ...rest } = typed; + expect(parseRecord(rest)?.attempts).toBe(0); + }); + + it.each([ + ['missing id', { ...typed, 'voice-id': undefined }], + ['bad status', { ...typed, 'voice-status': 'foo' }], + ['negative attempts', { ...typed, 'voice-attempts': -1 }], + ['non-numeric attempts', { ...typed, 'voice-attempts': 'abc' }], + ['object audio', { ...typed, 'voice-audio': { a: 1 } }], + ])('returns null for %s', (_name, fields) => { + expect(parseRecord(fields)).toBeNull(); + }); +}); -- 2.47.2 From d579ffd4a1512d5e4af3a0b74b159b33d98a1895 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 27 Sep 2026 16:24:30 +0400 Subject: [PATCH 11/22] feat(core): note body with status region and transcript insertion Refs #1 Co-Authored-By: Claude Opus 5.5 --- src/core/note.ts | 55 +++++++++++++++++++- tests/note-body.test.ts | 108 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 162 insertions(+), 1 deletion(-) create mode 100644 tests/note-body.test.ts diff --git a/src/core/note.ts b/src/core/note.ts index 48e5796..3257c73 100644 --- a/src/core/note.ts +++ b/src/core/note.ts @@ -1,4 +1,5 @@ -import { STATUSES, type Status, type VoiceRecord } from './record'; +import { formatDuration, formatLocalTime } from './naming'; +import { MAX_ATTEMPTS, NO_SPEECH, STATUSES, type Status, type VoiceRecord } from './record'; // Порядок, в котором ключи появляются в новой заметке. export const VOICE_KEYS = [ @@ -141,3 +142,55 @@ export function patchFrontmatter(content: string, fields: Record `%%voice-note:${id}%%`; + +export function statusLine(r: VoiceRecord): string { + switch (r.status) { + case 'retry': { + const at = r.nextAttempt ? formatLocalTime(r.nextAttempt) : '—'; + return `> [!info] Не удалось отправить, повтор в ${at} (попытка ${r.attempts}/${MAX_ATTEMPTS})`; + } + case 'failed': { + if (r.error === NO_SPEECH) return '> [!info] В записи не распознано речи'; + const reason = (r.error ?? 'неизвестная причина').replace(/\s+/g, ' ').trim(); + return `> [!warning] Не распознано: ${reason}. Команда «Повторить распознавание»`; + } + default: + return '> [!info] Распознаётся…'; + } +} + +function region(r: VoiceRecord): string { + return `${markerStart(r.id)}\n${statusLine(r)}\n${MARKER_END}`; +} + +export function initialNote(record: VoiceRecord, durationMs: number): string { + const interrupted = record.interrupted + ? `> [!note] Запись прервана, сохранено ${formatDuration(durationMs)}\n\n` + : ''; + return ( + patchFrontmatter('', recordToFields(record)) + + `![[${record.audio}]]\n\n${interrupted}${region(record)}\n` + ); +} + +// Обновляет frontmatter и управляемую область одной операцией. +// Склейка через slice, не String.replace: в транскрипте могут быть $&, $' и т.п. +export function renderNote(content: string, record: VoiceRecord, text?: string): string { + if (record.status === 'inserted' && readVoiceFields(content)['voice-status'] === 'inserted') { + return content; + } + const patched = patchFrontmatter(content, recordToFields(record)); + const start = patched.indexOf(markerStart(record.id)); + const end = start === -1 ? -1 : patched.indexOf(MARKER_END, start); + + if (record.status === 'inserted') { + const body = (text ?? '').trim(); + if (end === -1) return `${patched.replace(/\s+$/, '')}\n\n${body}\n`; + return patched.slice(0, start) + body + patched.slice(end + MARKER_END.length); + } + if (end === -1) return patched; + return patched.slice(0, start) + region(record) + patched.slice(end + MARKER_END.length); +} diff --git a/tests/note-body.test.ts b/tests/note-body.test.ts new file mode 100644 index 0000000..9f07a94 --- /dev/null +++ b/tests/note-body.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, it } from 'vitest'; +import { initialNote, readVoiceFields, renderNote } from '../src/core/note'; +import { NO_SPEECH, type VoiceRecord } from '../src/core/record'; + +const saved: VoiceRecord = { + id: '20260927-111503-0000', + status: 'saved', + device: 'd1', + audio: 'voice/source/20260927-111503-0000.m4a', + mime: 'audio/mp4', + attempts: 0, +}; +const FM = + '---\n' + + 'voice-id: "20260927-111503-0000"\n' + + 'voice-status: "saved"\n' + + 'voice-device: "d1"\n' + + 'voice-audio: "[[voice/source/20260927-111503-0000.m4a]]"\n' + + 'voice-mime: "audio/mp4"\n' + + 'voice-attempts: 0\n' + + '---\n'; +const REGION = '%%voice-note:20260927-111503-0000%%\n> [!info] Распознаётся…\n%%/voice-note%%\n'; + +const inserted: VoiceRecord = { ...saved, status: 'inserted' }; + +describe('initialNote', () => { + it('builds frontmatter, embed and status region', () => { + expect(initialNote(saved, 47_000)).toBe(`${FM}![[voice/source/20260927-111503-0000.m4a]]\n\n${REGION}`); + }); + + it('adds interrupted line above the region', () => { + const note = initialNote({ ...saved, interrupted: true }, 47_000); + expect(note).toContain('voice-interrupted: true\n---\n'); + expect(note).toContain( + '![[voice/source/20260927-111503-0000.m4a]]\n\n> [!note] Запись прервана, сохранено 0:47\n\n%%voice-note:', + ); + }); +}); + +describe('renderNote: status region', () => { + it('retry shows local time and attempt', () => { + const r: VoiceRecord = { ...saved, status: 'retry', attempts: 2, nextAttempt: '2026-09-27T11:17:03.000Z', error: 't' }; + const out = renderNote(initialNote(saved, 0), r); + expect(out).toContain('> [!info] Не удалось отправить, повтор в 14:17 (попытка 2/5)\n'); + expect(out).not.toContain('Распознаётся'); + expect(readVoiceFields(out)['voice-status']).toBe('retry'); + }); + + it('failed shows reason and the command', () => { + const out = renderNote(initialNote(saved, 0), { ...saved, status: 'failed', attempts: 1, error: '401 Unauthorized' }); + expect(out).toContain('> [!warning] Не распознано: 401 Unauthorized. Команда «Повторить распознавание»\n'); + }); + + it('no-speech is not an error', () => { + const out = renderNote(initialNote(saved, 0), { ...saved, status: 'failed', attempts: 1, error: NO_SPEECH }); + expect(out).toContain('> [!info] В записи не распознано речи\n'); + expect(out).not.toMatch(/ошибк|Не распознано:|Повторить/); + }); + + it('collapses multi-line error into one callout line', () => { + const out = renderNote(initialNote(saved, 0), { ...saved, status: 'failed', attempts: 1, error: 'a\n\nb' }); + expect(out).toContain('> [!warning] Не распознано: a b. Команда'); + }); + + it('without markers updates frontmatter only', () => { + const content = `${FM}моя заметка\n`; + const out = renderNote(content, { ...saved, status: 'sent', sentAt: 'x' }); + expect(out).toBe(content.replace('voice-status: "saved"', 'voice-status: "sent"').replace('voice-attempts: 0\n', 'voice-attempts: 0\nvoice-sent-at: "x"\n')); + }); +}); + +describe('renderNote: transcript', () => { + it('replaces the region and keeps user text around it', () => { + const content = `${initialNote(saved, 0)}купить молоко\n`; + expect(renderNote(content, inserted, ' Привет, мир.\n')).toBe( + `${FM.replace('"saved"', '"inserted"')}![[voice/source/20260927-111503-0000.m4a]]\n\nПривет, мир.\nкупить молоко\n`, + ); + }); + + it('keeps the interrupted line', () => { + const r = { ...saved, interrupted: true }; + const out = renderNote(initialNote(r, 47_000), { ...r, status: 'inserted' }, 'текст'); + expect(out).toContain('> [!note] Запись прервана, сохранено 0:47\n\nтекст\n'); + }); + + it('appends at the end when markers were removed', () => { + const content = `${FM}заметка без маркеров\n\n\n`; + expect(renderNote(content, inserted, 'текст')).toBe( + `${FM.replace('"saved"', '"inserted"')}заметка без маркеров\n\nтекст\n`, + ); + }); + + it('is a no-op when already inserted', () => { + const once = renderNote(initialNote(saved, 0), inserted, 'первый'); + expect(renderNote(once, inserted, 'второй')).toBe(once); + }); + + it('inserts special replacement patterns verbatim', () => { + const text = "цена $& и $' и $1 и %%voice-note%% и ---"; + expect(renderNote(initialNote(saved, 0), inserted, text)).toContain(`\n\n${text}\n`); + }); + + it('keeps user properties in place', () => { + const content = initialNote(saved, 0).replace('---\nvoice-id', '---\ntags: [idea]\nvoice-id'); + const out = renderNote(content, inserted, 'x'); + expect(out.startsWith('---\ntags: [idea]\nvoice-id: "20260927-111503-0000"\nvoice-status: "inserted"\n')).toBe(true); + }); +}); -- 2.47.2 From 0cd683d5dfaeeff09562aa3978652f2768321c0d Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 27 Sep 2026 16:29:28 +0400 Subject: [PATCH 12/22] feat(core): due, stale-sent and claimable selection Refs #1 Co-Authored-By: Claude Opus 5.5 --- src/core/schedule.ts | 38 ++++++++++++++++++++++ tests/schedule.test.ts | 74 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 112 insertions(+) create mode 100644 src/core/schedule.ts create mode 100644 tests/schedule.test.ts diff --git a/src/core/schedule.ts b/src/core/schedule.ts new file mode 100644 index 0000000..4e51ab2 --- /dev/null +++ b/src/core/schedule.ts @@ -0,0 +1,38 @@ +import { SENT_STALE_MS, type VoiceRecord } from './record'; + +export interface Located { + path: string; + record: VoiceRecord; +} + +const time = (iso?: string) => (iso ? Date.parse(iso) : NaN); + +// Отсутствующее или испорченное время — не повод зависнуть навсегда. +export function isStaleSent(r: VoiceRecord, now: Date): boolean { + if (r.status !== 'sent') return false; + const t = time(r.sentAt); + return Number.isNaN(t) || now.getTime() - t > SENT_STALE_MS; +} + +export function due(items: readonly Located[], now: Date, deviceId: string): Located[] { + return items + .filter(({ record: r }) => { + if (r.device !== deviceId) return false; + if (r.status === 'saved') return true; + if (r.status !== 'retry') return false; + const t = time(r.nextAttempt); + return Number.isNaN(t) || t <= now.getTime(); + }) + .sort((a, b) => (a.record.id < b.record.id ? -1 : a.record.id > b.record.id ? 1 : 0)); +} + +export function staleOwn(items: readonly Located[], now: Date, deviceId: string): Located[] { + return items.filter(({ record: r }) => r.device === deviceId && isStaleSent(r, now)); +} + +export function claimable(items: readonly Located[], now: Date, deviceId: string): Located[] { + return items.filter( + ({ record: r }) => + r.device !== deviceId && (r.status === 'saved' || r.status === 'retry' || isStaleSent(r, now)), + ); +} diff --git a/tests/schedule.test.ts b/tests/schedule.test.ts new file mode 100644 index 0000000..74cea4b --- /dev/null +++ b/tests/schedule.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from 'vitest'; +import type { VoiceRecord } from '../src/core/record'; +import { claimable, due, isStaleSent, staleOwn, type Located } from '../src/core/schedule'; + +const NOW = new Date('2026-09-27T11:15:03.000Z'); +const ago = (ms: number) => new Date(NOW.getTime() - ms).toISOString(); +const later = (ms: number) => new Date(NOW.getTime() + ms).toISOString(); + +function item(id: string, over: Partial = {}): Located { + return { + path: `voice/inbox/${id}.md`, + record: { id, status: 'saved', device: 'me', audio: `voice/source/${id}.m4a`, mime: 'audio/mp4', attempts: 0, ...over }, + }; +} +const ids = (xs: Located[]) => xs.map((x) => x.record.id); + +describe('due', () => { + it('takes own saved and retry whose time has come, oldest id first', () => { + const items = [ + item('c', { status: 'retry', nextAttempt: NOW.toISOString() }), + item('a'), + item('b', { status: 'retry', nextAttempt: later(1) }), + item('d', { device: 'other' }), + item('e', { status: 'sent', sentAt: ago(0) }), + item('f', { status: 'failed' }), + item('g', { status: 'inserted' }), + ]; + expect(ids(due(items, NOW, 'me'))).toEqual(['a', 'c']); + }); + + it('unparsable nextAttempt is due', () => { + expect(ids(due([item('a', { status: 'retry', nextAttempt: 'завтра' })], NOW, 'me'))).toEqual(['a']); + }); +}); + +describe('isStaleSent', () => { + it.each([ + ['exactly 10 min', { status: 'sent' as const, sentAt: ago(600_000) }, false], + ['10 min + 1 ms', { status: 'sent' as const, sentAt: ago(600_001) }, true], + ['2 min', { status: 'sent' as const, sentAt: ago(120_000) }, false], + ['no sentAt', { status: 'sent' as const }, true], + ['garbage sentAt', { status: 'sent' as const, sentAt: 'x' }, true], + ['not sent', { status: 'retry' as const, sentAt: ago(3_600_000) }, false], + ])('%s → %s', (_name, over, expected) => { + expect(isStaleSent(item('a', over).record, NOW)).toBe(expected); + }); +}); + +describe('staleOwn', () => { + it('only own stale sent', () => { + const items = [ + item('a', { status: 'sent', sentAt: ago(700_000) }), + item('b', { status: 'sent', sentAt: ago(700_000), device: 'other' }), + item('c', { status: 'sent', sentAt: ago(1_000) }), + ]; + expect(ids(staleOwn(items, NOW, 'me'))).toEqual(['a']); + }); +}); + +describe('claimable', () => { + it("others' saved, retry and stale sent only", () => { + const o = { device: 'other' }; + const items = [ + item('a', o), + item('b', { ...o, status: 'retry', nextAttempt: later(1_200_000) }), + item('c', { ...o, status: 'sent', sentAt: ago(700_000) }), + item('d', { ...o, status: 'sent', sentAt: ago(1_000) }), + item('e', { ...o, status: 'failed' }), + item('f', { ...o, status: 'inserted' }), + item('g'), + ]; + expect(ids(claimable(items, NOW, 'me'))).toEqual(['a', 'b', 'c']); + }); +}); -- 2.47.2 From b126e4bf8b3f8ccbc682ac8f8a5672d6f751ccf0 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 27 Sep 2026 16:32:34 +0400 Subject: [PATCH 13/22] feat(core): save captured audio and initial note; in-memory port fakes Refs #1 Co-Authored-By: Claude Opus 5.5 --- src/core/capture.ts | 50 ++++++++++++++++++++++++ tests/capture.test.ts | 80 ++++++++++++++++++++++++++++++++++++++ tests/fakes.ts | 90 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 220 insertions(+) create mode 100644 src/core/capture.ts create mode 100644 tests/capture.test.ts create mode 100644 tests/fakes.ts diff --git a/src/core/capture.ts b/src/core/capture.ts new file mode 100644 index 0000000..c8cc3ae --- /dev/null +++ b/src/core/capture.ts @@ -0,0 +1,50 @@ +import { audioPath, makeId, notePath } from './naming'; +import { initialNote } from './note'; +import type { Env, NoteStore, RecordingStore } from './ports'; +import type { VoiceRecord } from './record'; + +export interface Capture { + chunks: ArrayBuffer[]; + mime: string; + startedAt: Date; + endedAt: Date; + interrupted: boolean; +} + +export interface CaptureDeps { + recordings: RecordingStore; + notes: NoteStore; + env: Env; +} + +// Сохраняет запись до любой сети. Возвращает путь заметки или null, если данных нет. +export async function saveCapture(deps: CaptureDeps, capture: Capture): Promise { + const size = capture.chunks.reduce((sum, chunk) => sum + chunk.byteLength, 0); + if (size === 0) return null; + + const bytes = new Uint8Array(size); + let offset = 0; + for (const chunk of capture.chunks) { + bytes.set(new Uint8Array(chunk), offset); + offset += chunk.byteLength; + } + + const id = makeId(capture.startedAt, () => deps.env.random()); + const record: VoiceRecord = { + id, + status: 'saved', + device: deps.env.deviceId, + audio: audioPath(id, capture.mime), + mime: capture.mime, + attempts: 0, + }; + if (capture.interrupted) record.interrupted = true; + + await deps.recordings.write(record.audio, bytes.buffer); + + let n = 1; + while (await deps.notes.exists(notePath(capture.startedAt, n))) n++; + const path = notePath(capture.startedAt, n); + await deps.notes.create(path, initialNote(record, capture.endedAt.getTime() - capture.startedAt.getTime())); + return path; +} diff --git a/tests/capture.test.ts b/tests/capture.test.ts new file mode 100644 index 0000000..5a792b3 --- /dev/null +++ b/tests/capture.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from 'vitest'; +import { saveCapture, type Capture } from '../src/core/capture'; +import { initialNote, readVoiceFields } from '../src/core/note'; +import { MemoryNotes, MemoryRecordings, TestEnv } from './fakes'; + +const T = new Date('2026-09-27T11:15:03.000Z'); +const buf = (...bytes: number[]) => new Uint8Array(bytes).buffer; + +function setup() { + const env = new TestEnv(T); + const notes = new MemoryNotes(); + const recordings = new MemoryRecordings(); + return { env, notes, recordings, deps: { env, notes, recordings } }; +} + +const capture = (over: Partial = {}): Capture => ({ + chunks: [buf(1, 2), buf(3, 4, 5)], + mime: 'audio/mp4', + startedAt: T, + endedAt: new Date(T.getTime() + 47_000), + interrupted: false, + ...over, +}); + +describe('saveCapture', () => { + it('writes concatenated audio and a saved note', async () => { + const { deps, notes, recordings } = setup(); + const path = await saveCapture(deps, capture()); + + expect(path).toBe('voice/inbox/2026-09-27 14-15-03.md'); + const audio = recordings.files.get('voice/source/20260927-111503-0000.m4a'); + expect(new Uint8Array(audio!)).toEqual(new Uint8Array([1, 2, 3, 4, 5])); + expect(notes.files.get(path!)).toBe( + initialNote( + { + id: '20260927-111503-0000', + status: 'saved', + device: 'test-device', + audio: 'voice/source/20260927-111503-0000.m4a', + mime: 'audio/mp4', + attempts: 0, + }, + 47_000, + ), + ); + }); + + it.each([[[]], [[new ArrayBuffer(0)]], [[new ArrayBuffer(0), new ArrayBuffer(0)]]])( + 'creates nothing for empty data %#', + async (chunks) => { + const { deps, notes, recordings } = setup(); + expect(await saveCapture(deps, capture({ chunks, interrupted: true }))).toBeNull(); + expect(notes.files.size).toBe(0); + expect(recordings.files.size).toBe(0); + }, + ); + + it('marks interrupted recordings with duration', async () => { + const { deps, notes } = setup(); + const path = await saveCapture(deps, capture({ interrupted: true })); + const content = notes.files.get(path!)!; + expect(readVoiceFields(content)['voice-interrupted']).toBe('true'); + expect(content).toContain('> [!note] Запись прервана, сохранено 0:47'); + }); + + it('adds (2), (3) on name collision', async () => { + const { deps, notes } = setup(); + notes.files.set('voice/inbox/2026-09-27 14-15-03.md', 'чужая'); + expect(await saveCapture(deps, capture())).toBe('voice/inbox/2026-09-27 14-15-03 (2).md'); + expect(await saveCapture(deps, capture())).toBe('voice/inbox/2026-09-27 14-15-03 (3).md'); + expect(notes.files.get('voice/inbox/2026-09-27 14-15-03.md')).toBe('чужая'); + }); + + it('keeps full mime and picks extension from it', async () => { + const { deps, notes, recordings } = setup(); + const path = await saveCapture(deps, capture({ mime: 'audio/webm;codecs=opus' })); + expect(recordings.files.has('voice/source/20260927-111503-0000.webm')).toBe(true); + expect(readVoiceFields(notes.files.get(path!)!)['voice-mime']).toBe('audio/webm;codecs=opus'); + }); +}); diff --git a/tests/fakes.ts b/tests/fakes.ts new file mode 100644 index 0000000..acbed02 --- /dev/null +++ b/tests/fakes.ts @@ -0,0 +1,90 @@ +import { readVoiceFields } from '../src/core/note'; +import type { + Env, + ListedNote, + NoteStore, + RecordingStore, + TranscribeResult, + Transcriber, +} from '../src/core/ports'; + +export class MemoryRecordings implements RecordingStore { + files = new Map(); + + async write(path: string, data: ArrayBuffer): Promise { + this.files.set(path, data.slice(0)); + } + + async read(path: string): Promise { + const data = this.files.get(path); + if (!data) throw new Error(`no such file: ${path}`); + return data.slice(0); + } +} + +export class MemoryNotes implements NoteStore { + files = new Map(); + // Имитация отстающего metadataCache: если задано, list() возвращает это. + staleList: ListedNote[] | null = null; + listCalls = 0; + + async exists(path: string): Promise { + return this.files.has(path); + } + + async create(path: string, content: string): Promise { + if (this.files.has(path)) throw new Error(`already exists: ${path}`); + this.files.set(path, content); + } + + async list(): Promise { + this.listCalls++; + if (this.staleList) return this.staleList; + return [...this.files] + .map(([path, content]) => ({ path, frontmatter: readVoiceFields(content) as Record })) + .filter((note) => 'voice-id' in note.frontmatter); + } + + async process(path: string, fn: (content: string) => string): Promise { + const content = this.files.get(path); + if (content === undefined) return false; + this.files.set(path, fn(content)); + return true; + } +} + +export type Step = + | TranscribeResult + | Error + | ((audio: ArrayBuffer, mime: string) => Promise); + +export class ScriptedTranscriber implements Transcriber { + calls: { audio: ArrayBuffer; mime: string; signal: AbortSignal }[] = []; + + constructor(public steps: Step[] = []) {} + + async transcribe(audio: ArrayBuffer, mime: string, signal: AbortSignal): Promise { + this.calls.push({ audio, mime, signal }); + const step = this.steps.shift(); + if (step === undefined) throw new Error('ScriptedTranscriber: no more steps'); + if (step instanceof Error) throw step; + if (typeof step === 'function') return step(audio, mime); + return step; + } +} + +export class TestEnv implements Env { + constructor( + public time = new Date('2026-09-27T11:15:03.000Z'), + public deviceId = 'test-device', + public random: () => number = () => 0, + ) {} + + now(): Date { + return new Date(this.time); + } + + advance(ms: number): void { + this.time = new Date(this.time.getTime() + ms); + } +} -- 2.47.2 From 97a560820f0d82f458e5be7a32805534fddff411 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 27 Sep 2026 16:38:46 +0400 Subject: [PATCH 14/22] feat(core): queue runner with retries, stale-sent recovery and re-entrancy guard Refs #1 Co-Authored-By: Claude Opus 5.5 --- src/core/queue.ts | 123 +++++++++++++++++++++++ tests/queue.test.ts | 240 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 363 insertions(+) create mode 100644 src/core/queue.ts create mode 100644 tests/queue.test.ts diff --git a/src/core/queue.ts b/src/core/queue.ts new file mode 100644 index 0000000..55f2021 --- /dev/null +++ b/src/core/queue.ts @@ -0,0 +1,123 @@ +import { parseRecord, readVoiceFields, renderNote } from './note'; +import type { Env, NoteStore, RecordingStore, TranscribeResult, Transcriber } from './ports'; +import { NO_SPEECH, transition, type VoiceRecord } from './record'; +import { due, isStaleSent, staleOwn, type Located } from './schedule'; + +export interface QueueDeps { + recordings: RecordingStore; + notes: NoteStore; + transcriber: Transcriber; + env: Env; +} + +export const INTERRUPTED_SEND = 'отправка прервана'; + +interface Change { + record: VoiceRecord; + text?: string; +} + +export class QueueRunner { + private running = false; + private rerun = false; + private readonly controller = new AbortController(); + + constructor(private readonly deps: QueueDeps) {} + + // Повторный вызов во время работы не запускает параллельный проход, + // а просит ровно один дополнительный после текущего. + async tick(): Promise { + if (this.running) { + this.rerun = true; + return; + } + this.running = true; + try { + do { + this.rerun = false; + await this.pass(); + } while (this.rerun); + } finally { + this.running = false; + } + } + + dispose(): void { + this.controller.abort(); + } + + private async pass(): Promise { + const { env } = this.deps; + const items = await this.load(); + const now = env.now(); + for (const item of staleOwn(items, now, env.deviceId)) { + await this.update( + item.path, + (r) => r.id === item.record.id && r.device === env.deviceId && isStaleSent(r, now), + (r) => ({ record: transition(r, { type: 'temporary', message: INTERRUPTED_SEND }, now) }), + ); + } + for (const item of due(items, now, env.deviceId)) await this.send(item); + } + + private async send(item: Located): Promise { + const { recordings, transcriber, env } = this.deps; + const now = env.now(); + // Список мог устареть (metadataCache) — решение принимаем по актуальному файлу. + const sent = await this.update( + item.path, + (r) => r.id === item.record.id && due([{ path: item.path, record: r }], now, env.deviceId).length > 0, + (r) => ({ record: transition(r, { type: 'send' }, now) }), + ); + if (!sent) return; + + let result: TranscribeResult; + try { + const audio = await recordings.read(sent.audio); + result = await transcriber.transcribe(audio, sent.mime, this.controller.signal); + } catch (e) { + result = { ok: false, kind: 'temporary', message: e instanceof Error ? e.message : String(e) }; + } + if (result.ok && result.text.trim() === '') { + result = { ok: false, kind: 'permanent', message: NO_SPEECH }; + } + + const done = env.now(); + const final = result; + await this.update( + item.path, + (r) => r.id === sent.id && r.status === 'sent', + (r) => + final.ok + ? { record: transition(r, { type: 'success' }, done), text: final.text } + : { record: transition(r, { type: final.kind, message: final.message }, done) }, + ); + } + + // Атомарно: перечитать запись из актуального содержимого, проверить ожидание, + // применить изменение. null — заметки нет или запись не в ожидаемом состоянии. + private async update( + path: string, + expect: (r: VoiceRecord) => boolean, + change: (r: VoiceRecord) => Change, + ): Promise { + const box: { record: VoiceRecord | null } = { record: null }; + const exists = await this.deps.notes.process(path, (content) => { + const current = parseRecord(readVoiceFields(content)); + if (!current || !expect(current)) return content; + const next = change(current); + box.record = next.record; + return renderNote(content, next.record, next.text); + }); + return exists ? box.record : null; + } + + private async load(): Promise { + const located: Located[] = []; + for (const { path, frontmatter } of await this.deps.notes.list()) { + const record = parseRecord(frontmatter); + if (record) located.push({ path, record }); + } + return located; + } +} diff --git a/tests/queue.test.ts b/tests/queue.test.ts new file mode 100644 index 0000000..0abae77 --- /dev/null +++ b/tests/queue.test.ts @@ -0,0 +1,240 @@ +import { describe, expect, it } from 'vitest'; +import { initialNote, readVoiceFields } from '../src/core/note'; +import { QueueRunner } from '../src/core/queue'; +import type { VoiceRecord } from '../src/core/record'; +import { MemoryNotes, MemoryRecordings, ScriptedTranscriber, TestEnv, type Step } from './fakes'; + +const T0 = new Date('2026-09-27T11:15:03.000Z'); +const ok = (text: string): Step => ({ ok: true, text }); +const temporary = (message: string): Step => ({ ok: false, kind: 'temporary', message }); +const permanent = (message: string): Step => ({ ok: false, kind: 'permanent', message }); + +function setup(steps: Step[] = []) { + const env = new TestEnv(T0); + const notes = new MemoryNotes(); + const recordings = new MemoryRecordings(); + const transcriber = new ScriptedTranscriber(steps); + const runner = new QueueRunner({ recordings, notes, transcriber, env }); + return { env, notes, recordings, transcriber, runner }; +} +type Ctx = ReturnType; + +function rec(id: string, over: Partial = {}): VoiceRecord { + return { id, status: 'saved', device: 'test-device', audio: `voice/source/${id}.m4a`, mime: 'audio/mp4', attempts: 0, ...over }; +} + +function seed(ctx: Ctx, record: VoiceRecord, path = `voice/inbox/${record.id}.md`): string { + ctx.notes.files.set(path, initialNote(record, 0)); + ctx.recordings.files.set(record.audio, new Uint8Array([1, 2, 3]).buffer); + return path; +} + +const fields = (ctx: Ctx, path: string) => readVoiceFields(ctx.notes.files.get(path)!); + +describe('QueueRunner.tick', () => { + it('sends a saved record and inserts the trimmed transcript', async () => { + const ctx = setup([ok(' привет ')]); + const path = seed(ctx, rec('a')); + await ctx.runner.tick(); + + expect(ctx.transcriber.calls).toHaveLength(1); + expect(new Uint8Array(ctx.transcriber.calls[0].audio)).toEqual(new Uint8Array([1, 2, 3])); + expect(ctx.transcriber.calls[0].mime).toBe('audio/mp4'); + expect(fields(ctx, path)['voice-status']).toBe('inserted'); + expect(fields(ctx, path)['voice-sent-at']).toBeUndefined(); + expect(ctx.notes.files.get(path)).toContain('\n\nпривет\n'); + expect(ctx.notes.files.get(path)).not.toContain('%%voice-note'); + }); + + it('network lost, then back: retry at +30s, then inserted', async () => { + const ctx = setup([temporary('offline'), ok('текст')]); + const path = seed(ctx, rec('a')); + + await ctx.runner.tick(); + expect(fields(ctx, path)).toMatchObject({ + 'voice-status': 'retry', + 'voice-attempts': '1', + 'voice-next-attempt': '2026-09-27T11:15:33.000Z', + 'voice-error': 'offline', + }); + expect(ctx.notes.files.get(path)).toContain('Не удалось отправить, повтор в 14:15 (попытка 1/5)'); + + ctx.env.advance(17_000); // 11:15:20 — рано + await ctx.runner.tick(); + expect(ctx.transcriber.calls).toHaveLength(1); + + ctx.env.advance(13_000); // 11:15:33 — пора + await ctx.runner.tick(); + expect(ctx.transcriber.calls).toHaveLength(2); + expect(fields(ctx, path)['voice-status']).toBe('inserted'); + }); + + it('five temporary failures → failed with the last message', async () => { + const ctx = setup([temporary('e1'), temporary('e2'), temporary('e3'), temporary('e4'), temporary('e5')]); + const path = seed(ctx, rec('a')); + for (let i = 0; i < 5; i++) { + await ctx.runner.tick(); + ctx.env.advance(3_600_000); + } + await ctx.runner.tick(); + expect(ctx.transcriber.calls).toHaveLength(5); + expect(fields(ctx, path)).toMatchObject({ 'voice-status': 'failed', 'voice-attempts': '5', 'voice-error': 'e5' }); + }); + + it('permanent error → failed at once', async () => { + const ctx = setup([permanent('401 Unauthorized')]); + const path = seed(ctx, rec('a')); + await ctx.runner.tick(); + expect(fields(ctx, path)).toMatchObject({ 'voice-status': 'failed', 'voice-attempts': '1' }); + expect(ctx.notes.files.get(path)).toContain('Не распознано: 401 Unauthorized'); + }); + + it('whitespace-only transcript → failed no-speech', async () => { + const ctx = setup([ok(' \n\t ')]); + const path = seed(ctx, rec('a')); + await ctx.runner.tick(); + expect(fields(ctx, path)).toMatchObject({ 'voice-status': 'failed', 'voice-error': 'no-speech' }); + expect(ctx.notes.files.get(path)).toContain('> [!info] В записи не распознано речи'); + }); + + it('exception from transcriber is a temporary failure', async () => { + const ctx = setup([new Error('socket hang up')]); + const path = seed(ctx, rec('a')); + await ctx.runner.tick(); + expect(fields(ctx, path)).toMatchObject({ 'voice-status': 'retry', 'voice-error': 'socket hang up' }); + }); + + it('missing audio file is a temporary failure', async () => { + const ctx = setup([]); + const path = seed(ctx, rec('a')); + ctx.recordings.files.clear(); + await ctx.runner.tick(); + expect(ctx.transcriber.calls).toHaveLength(0); + expect(fields(ctx, path)['voice-status']).toBe('retry'); + }); + + it("does not send other devices' records", async () => { + const ctx = setup([]); + const path = seed(ctx, rec('a', { device: 'iphone' })); + await ctx.runner.tick(); + expect(ctx.transcriber.calls).toHaveLength(0); + expect(fields(ctx, path)['voice-status']).toBe('saved'); + }); + + it('own sent older than 10 min → retry without a request; younger untouched', async () => { + const ctx = setup([]); + const stale = seed(ctx, rec('a', { status: 'sent', sentAt: '2026-09-27T11:04:00.000Z' })); + const fresh = seed(ctx, rec('b', { status: 'sent', sentAt: '2026-09-27T11:13:03.000Z' })); + const before = ctx.notes.files.get(fresh); + await ctx.runner.tick(); + expect(ctx.transcriber.calls).toHaveLength(0); + expect(fields(ctx, stale)).toMatchObject({ 'voice-status': 'retry', 'voice-attempts': '1', 'voice-error': 'отправка прервана' }); + expect(ctx.notes.files.get(fresh)).toBe(before); + }); + + it('processes records one at a time, oldest first', async () => { + let inFlight = 0; + let maxInFlight = 0; + const order: string[] = []; + const slow: Step = async (audio) => { + inFlight++; + maxInFlight = Math.max(maxInFlight, inFlight); + order.push(String(new Uint8Array(audio)[0])); + await new Promise((resolve) => setTimeout(resolve, 5)); + inFlight--; + return { ok: true, text: 't' }; + }; + const ctx = setup([slow, slow, slow]); + for (const [id, byte] of [['c', 3], ['a', 1], ['b', 2]] as const) { + seed(ctx, rec(id)); + ctx.recordings.files.set(`voice/source/${id}.m4a`, new Uint8Array([byte]).buffer); + } + await ctx.runner.tick(); + expect(maxInFlight).toBe(1); + expect(order).toEqual(['1', '2', '3']); + }); + + it('tick during tick runs exactly one extra pass', async () => { + const ctx: Ctx = setup(); + ctx.transcriber.steps.push(async () => { + void ctx.runner.tick(); + void ctx.runner.tick(); + void ctx.runner.tick(); + return { ok: true, text: 't' }; + }); + seed(ctx, rec('a')); + await ctx.runner.tick(); + expect(ctx.notes.listCalls).toBe(2); + expect(ctx.transcriber.calls).toHaveLength(1); + }); + + it('note deleted during the request: result dropped, next record processed', async () => { + const ctx: Ctx = setup(); + const first = seed(ctx, rec('a')); + const second = seed(ctx, rec('b')); + ctx.transcriber.steps.push(async () => { + ctx.notes.files.delete(first); + return { ok: true, text: 'один' }; + }, ok('два')); + await ctx.runner.tick(); + expect(ctx.notes.files.has(first)).toBe(false); + expect(fields(ctx, second)['voice-status']).toBe('inserted'); + }); + + it('note deleted before send: skipped without a request', async () => { + const ctx = setup([ok('два')]); + const second = seed(ctx, rec('b')); + ctx.notes.staleList = [ + { path: 'voice/inbox/gone.md', frontmatter: readVoiceFields(initialNote(rec('a'), 0)) }, + { path: second, frontmatter: fields(ctx, second) }, + ]; + await ctx.runner.tick(); + expect(ctx.transcriber.calls).toHaveLength(1); + expect(fields(ctx, second)['voice-status']).toBe('inserted'); + }); + + it('stale list says saved, file says sent: no second request', async () => { + const ctx = setup([]); + const path = seed(ctx, rec('a', { status: 'sent', sentAt: '2026-09-27T11:15:00.000Z' })); + ctx.notes.staleList = [{ path, frontmatter: readVoiceFields(initialNote(rec('a'), 0)) }]; + const before = ctx.notes.files.get(path); + await ctx.runner.tick(); + expect(ctx.transcriber.calls).toHaveLength(0); + expect(ctx.notes.files.get(path)).toBe(before); + }); + + it('stale list says saved, file says inserted: untouched', async () => { + const ctx = setup([ok('x')]); + const path = seed(ctx, rec('a')); + await ctx.runner.tick(); + const done = ctx.notes.files.get(path); + ctx.notes.staleList = [{ path, frontmatter: readVoiceFields(initialNote(rec('a'), 0)) }]; + await ctx.runner.tick(); + expect(ctx.transcriber.calls).toHaveLength(1); + expect(ctx.notes.files.get(path)).toBe(done); + }); + + it('finds records outside voice/inbox', async () => { + const ctx = setup([ok('x')]); + const path = seed(ctx, rec('a'), 'projects/перенесённая.md'); + await ctx.runner.tick(); + expect(fields(ctx, path)['voice-status']).toBe('inserted'); + }); + + it('ignores notes with a broken status', async () => { + const ctx = setup([]); + const path = seed(ctx, rec('a')); + ctx.notes.files.set(path, ctx.notes.files.get(path)!.replace('"saved"', '"foo"')); + await expect(ctx.runner.tick()).resolves.toBeUndefined(); + expect(ctx.transcriber.calls).toHaveLength(0); + }); + + it('dispose aborts the signal given to the transcriber', async () => { + const ctx = setup([ok('x')]); + seed(ctx, rec('a')); + await ctx.runner.tick(); + expect(ctx.transcriber.calls[0].signal.aborted).toBe(false); + ctx.runner.dispose(); + expect(ctx.transcriber.calls[0].signal.aborted).toBe(true); + }); +}); -- 2.47.2 From d7ec88a91ede70bac4afc09d198ddce6e99a2045 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 27 Sep 2026 16:41:23 +0400 Subject: [PATCH 15/22] =?UTF-8?q?docs(openspec):=20ruling=20=E2=80=94=20qu?= =?UTF-8?q?eue=20stops=20sending=20after=20dispose()?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refs #1 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QKHeGuAYh4W1jNdjMConZa --- openspec/changes/core-recording-lifecycle/design.md | 1 + 1 file changed, 1 insertion(+) diff --git a/openspec/changes/core-recording-lifecycle/design.md b/openspec/changes/core-recording-lifecycle/design.md index 053e510..6200eaf 100644 --- a/openspec/changes/core-recording-lifecycle/design.md +++ b/openspec/changes/core-recording-lifecycle/design.md @@ -130,3 +130,4 @@ interface Env { now(): Date; random(): number; deviceId: string; } 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()`; требуемое спекой поведение не теряется.** -- 2.47.2 From 626d832bd1e75d2d309baf3ab35f2a3f90659bc2 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 27 Sep 2026 16:42:57 +0400 Subject: [PATCH 16/22] fix(core): stop sending after QueueRunner.dispose() Refs #1 Co-Authored-By: Claude Opus 5.5 --- src/core/queue.ts | 9 +++++++-- tests/queue.test.ts | 22 ++++++++++++++++++++++ 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/src/core/queue.ts b/src/core/queue.ts index 55f2021..d536c78 100644 --- a/src/core/queue.ts +++ b/src/core/queue.ts @@ -27,6 +27,7 @@ export class QueueRunner { // Повторный вызов во время работы не запускает параллельный проход, // а просит ровно один дополнительный после текущего. async tick(): Promise { + if (this.controller.signal.aborted) return; if (this.running) { this.rerun = true; return; @@ -36,7 +37,7 @@ export class QueueRunner { do { this.rerun = false; await this.pass(); - } while (this.rerun); + } while (this.rerun && !this.controller.signal.aborted); } finally { this.running = false; } @@ -57,10 +58,14 @@ export class QueueRunner { (r) => ({ record: transition(r, { type: 'temporary', message: INTERRUPTED_SEND }, now) }), ); } - for (const item of due(items, now, env.deviceId)) await this.send(item); + for (const item of due(items, now, env.deviceId)) { + if (this.controller.signal.aborted) break; + await this.send(item); + } } private async send(item: Located): Promise { + if (this.controller.signal.aborted) return; const { recordings, transcriber, env } = this.deps; const now = env.now(); // Список мог устареть (metadataCache) — решение принимаем по актуальному файлу. diff --git a/tests/queue.test.ts b/tests/queue.test.ts index 0abae77..2dc8af5 100644 --- a/tests/queue.test.ts +++ b/tests/queue.test.ts @@ -237,4 +237,26 @@ describe('QueueRunner.tick', () => { ctx.runner.dispose(); expect(ctx.transcriber.calls[0].signal.aborted).toBe(true); }); + + it('dispose during a request stops the pass without touching later due records', async () => { + const ctx = setup(); + const first = seed(ctx, rec('a')); + const second = seed(ctx, rec('b')); + const beforeSecond = ctx.notes.files.get(second); + ctx.transcriber.steps.push(async () => { + ctx.runner.dispose(); + throw new Error('aborted'); + }); + + await ctx.runner.tick(); + + expect(ctx.transcriber.calls).toHaveLength(1); + expect(fields(ctx, first)).toMatchObject({ 'voice-status': 'retry', 'voice-attempts': '1' }); + expect(fields(ctx, second)['voice-status']).toBe('saved'); + expect(ctx.notes.files.get(second)).toBe(beforeSecond); + + const listCallsBefore = ctx.notes.listCalls; + await ctx.runner.tick(); + expect(ctx.notes.listCalls).toBe(listCallsBefore); + }); }); -- 2.47.2 From 88ef2bde24ca6961d437938ffd9a3e095279dc97 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 27 Sep 2026 16:47:08 +0400 Subject: [PATCH 17/22] feat(core): manual retry and claim-all commands Refs #1 Co-Authored-By: Claude Opus 5.5 --- src/core/queue.ts | 34 ++++++++++++- tests/queue-commands.test.ts | 98 ++++++++++++++++++++++++++++++++++++ 2 files changed, 131 insertions(+), 1 deletion(-) create mode 100644 tests/queue-commands.test.ts diff --git a/src/core/queue.ts b/src/core/queue.ts index d536c78..d69d1ec 100644 --- a/src/core/queue.ts +++ b/src/core/queue.ts @@ -1,7 +1,7 @@ import { parseRecord, readVoiceFields, renderNote } from './note'; import type { Env, NoteStore, RecordingStore, TranscribeResult, Transcriber } from './ports'; import { NO_SPEECH, transition, type VoiceRecord } from './record'; -import { due, isStaleSent, staleOwn, type Located } from './schedule'; +import { claimable, due, isStaleSent, staleOwn, type Located } from './schedule'; export interface QueueDeps { recordings: RecordingStore; @@ -47,6 +47,38 @@ export class QueueRunner { this.controller.abort(); } + // «Повторить распознавание»: failed/retry → saved, счётчик с нуля, запись + // переходит текущему устройству (иначе чужую запись здесь не отправить). + async retry(path: string): Promise { + const { env } = this.deps; + const changed = await this.update( + path, + (r) => r.status === 'failed' || r.status === 'retry', + (r) => ({ record: transition(r, { type: 'retry-now', device: env.deviceId }, env.now()) }), + ); + if (!changed) return false; + await this.tick(); + return true; + } + + // «Распознать все ожидающие здесь»: чужие saved/retry/зависшие sent → своё + // устройство, статус saved (без ожидания повтора), счётчик сохраняется. + async claimAll(): Promise { + const { env } = this.deps; + const now = env.now(); + let claimed = 0; + for (const item of claimable(await this.load(), now, env.deviceId)) { + const changed = await this.update( + item.path, + (r) => r.id === item.record.id && claimable([{ path: item.path, record: r }], now, env.deviceId).length > 0, + (r) => ({ record: transition(r, { type: 'claim', device: env.deviceId }, now) }), + ); + if (changed) claimed++; + } + await this.tick(); + return claimed; + } + private async pass(): Promise { const { env } = this.deps; const items = await this.load(); diff --git a/tests/queue-commands.test.ts b/tests/queue-commands.test.ts new file mode 100644 index 0000000..8d6645a --- /dev/null +++ b/tests/queue-commands.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, it } from 'vitest'; +import { initialNote, readVoiceFields } from '../src/core/note'; +import { QueueRunner } from '../src/core/queue'; +import type { VoiceRecord } from '../src/core/record'; +import { MemoryNotes, MemoryRecordings, ScriptedTranscriber, TestEnv, type Step } from './fakes'; + +const T0 = new Date('2026-09-27T11:15:03.000Z'); + +function setup(steps: Step[] = []) { + const env = new TestEnv(T0); + const notes = new MemoryNotes(); + const recordings = new MemoryRecordings(); + const transcriber = new ScriptedTranscriber(steps); + const runner = new QueueRunner({ recordings, notes, transcriber, env }); + return { env, notes, recordings, transcriber, runner }; +} +type Ctx = ReturnType; + +function rec(id: string, over: Partial = {}): VoiceRecord { + return { id, status: 'saved', device: 'test-device', audio: `voice/source/${id}.m4a`, mime: 'audio/mp4', attempts: 0, ...over }; +} + +function seed(ctx: Ctx, record: VoiceRecord): string { + const path = `voice/inbox/${record.id}.md`; + ctx.notes.files.set(path, initialNote(record, 0)); + ctx.recordings.files.set(record.audio, new Uint8Array([1]).buffer); + return path; +} + +const fields = (ctx: Ctx, path: string) => readVoiceFields(ctx.notes.files.get(path)!); + +describe('QueueRunner.retry', () => { + it('failed → sent again with attempts from zero', async () => { + const ctx = setup([{ ok: false, kind: 'temporary', message: 'offline' }]); + const path = seed(ctx, rec('a', { status: 'failed', attempts: 5, error: '401' })); + expect(await ctx.runner.retry(path)).toBe(true); + expect(ctx.transcriber.calls).toHaveLength(1); + expect(fields(ctx, path)).toMatchObject({ 'voice-status': 'retry', 'voice-attempts': '1' }); + }); + + it('retry state: sends now without waiting', async () => { + const ctx = setup([{ ok: true, text: 'x' }]); + const path = seed(ctx, rec('a', { status: 'retry', attempts: 2, nextAttempt: '2026-09-27T12:00:00.000Z', error: 't' })); + expect(await ctx.runner.retry(path)).toBe(true); + expect(fields(ctx, path)).toMatchObject({ 'voice-status': 'inserted', 'voice-attempts': '0' }); + }); + + it("claims another device's failed record", async () => { + const ctx = setup([{ ok: true, text: 'x' }]); + const path = seed(ctx, rec('a', { status: 'failed', attempts: 5, error: 'e', device: 'iphone' })); + expect(await ctx.runner.retry(path)).toBe(true); + expect(fields(ctx, path)).toMatchObject({ 'voice-status': 'inserted', 'voice-device': 'test-device' }); + }); + + it.each(['saved', 'inserted'] as const)('%s: no change, no request', async (status) => { + const ctx = setup([]); + const path = seed(ctx, rec('a', { status })); + const before = ctx.notes.files.get(path); + expect(await ctx.runner.retry(path)).toBe(false); + expect(ctx.notes.files.get(path)).toBe(before); + expect(ctx.transcriber.calls).toHaveLength(0); + }); + + it('missing note → false', async () => { + expect(await setup().runner.retry('voice/inbox/nope.md')).toBe(false); + }); +}); + +describe('QueueRunner.claimAll', () => { + it("takes the phone's retry and sends it now, keeping attempts", async () => { + const ctx = setup([{ ok: true, text: 'x' }]); + const path = seed(ctx, rec('a', { device: 'iphone', status: 'retry', attempts: 2, nextAttempt: '2026-09-27T11:35:03.000Z', error: 't' })); + expect(await ctx.runner.claimAll()).toBe(1); + expect(ctx.transcriber.calls).toHaveLength(1); + expect(fields(ctx, path)).toMatchObject({ 'voice-status': 'inserted', 'voice-device': 'test-device', 'voice-attempts': '2' }); + }); + + it('includes stale sent; leaves failed, inserted, fresh sent and own records', async () => { + const ctx = setup([{ ok: true, text: 'x' }, { ok: true, text: 'y' }]); + const o = { device: 'iphone' }; + const saved = seed(ctx, rec('a', o)); + const stale = seed(ctx, rec('b', { ...o, status: 'sent', sentAt: '2026-09-27T11:04:00.000Z' })); + const fresh = seed(ctx, rec('c', { ...o, status: 'sent', sentAt: '2026-09-27T11:13:03.000Z' })); + const failed = seed(ctx, rec('d', { ...o, status: 'failed', attempts: 5, error: 'e' })); + const done = seed(ctx, rec('e', { ...o, status: 'inserted' })); + const own = seed(ctx, rec('f', { status: 'retry', attempts: 1, nextAttempt: '2026-09-27T11:35:03.000Z', error: 't' })); + const untouched = [fresh, failed, done, own].map((p) => ctx.notes.files.get(p)); + + expect(await ctx.runner.claimAll()).toBe(2); + expect(fields(ctx, saved)['voice-status']).toBe('inserted'); + expect(fields(ctx, stale)['voice-status']).toBe('inserted'); + expect([fresh, failed, done, own].map((p) => ctx.notes.files.get(p))).toEqual(untouched); + }); + + it('nothing to claim → 0', async () => { + expect(await setup().runner.claimAll()).toBe(0); + }); +}); -- 2.47.2 From 1b2355465891b2de3eb4427193d0c9ae9405da2f Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 27 Sep 2026 16:54:51 +0400 Subject: [PATCH 18/22] =?UTF-8?q?test(core):=20golden=20test-01=20end-to-e?= =?UTF-8?q?nd=20capture=20=E2=86=92=20queue=20=E2=86=92=20note?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refs #1 Co-Authored-By: Claude Opus 5.5 --- tests/fixtures/test-01.note.md | 11 +++++++ tests/golden.test.ts | 56 ++++++++++++++++++++++++++++++++++ 2 files changed, 67 insertions(+) create mode 100644 tests/fixtures/test-01.note.md create mode 100644 tests/golden.test.ts diff --git a/tests/fixtures/test-01.note.md b/tests/fixtures/test-01.note.md new file mode 100644 index 0000000..c41837e --- /dev/null +++ b/tests/fixtures/test-01.note.md @@ -0,0 +1,11 @@ +--- +voice-id: "20260927-111503-0000" +voice-status: "inserted" +voice-device: "test-device" +voice-audio: "[[voice/source/20260927-111503-0000.m4a]]" +voice-mime: "audio/mp4" +voice-attempts: 0 +--- +![[voice/source/20260927-111503-0000.m4a]] + +Это тестовая запись для проверки распознавания речи с помощью модели WHISPER, для сравнения между двух конкретных моделей. diff --git a/tests/golden.test.ts b/tests/golden.test.ts new file mode 100644 index 0000000..07ce4c7 --- /dev/null +++ b/tests/golden.test.ts @@ -0,0 +1,56 @@ +import { existsSync, readdirSync, readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; +import { saveCapture } from '../src/core/capture'; +import { QueueRunner } from '../src/core/queue'; +import { MemoryNotes, MemoryRecordings, ScriptedTranscriber, TestEnv } from './fakes'; + +const FIXTURES = fileURLToPath(new URL('./fixtures/', import.meta.url)); +const MIME: Record = { m4a: 'audio/mp4', webm: 'audio/webm', ogg: 'audio/ogg' }; +const cases = readdirSync(FIXTURES) + .filter((f) => f.endsWith('.note.md')) + .map((f) => f.slice(0, -'.note.md'.length)); + +function audioFor(name: string): { file: string; mime: string } { + for (const ext of Object.keys(MIME)) { + const file = `${FIXTURES}${name}.${ext}`; + if (existsSync(file)) return { file, mime: MIME[ext] }; + } + throw new Error(`нет аудиофикстуры tests/fixtures/${name}.m4a (или .webm/.ogg) для эталона ${name}.note.md`); +} + +function toArrayBuffer(buf: Buffer): ArrayBuffer { + return buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength) as ArrayBuffer; +} + +describe('golden', () => { + it('has at least one case', () => { + expect(cases.length).toBeGreaterThan(0); + }); + + for (const name of cases) { + it(name, async () => { + const { file, mime } = audioFor(name); + const audio = toArrayBuffer(readFileSync(file)); + const transcript = readFileSync(`${FIXTURES}${name}.txt`, 'utf8'); + const expected = readFileSync(`${FIXTURES}${name}.note.md`, 'utf8'); + + const env = new TestEnv(); + const notes = new MemoryNotes(); + const recordings = new MemoryRecordings(); + const transcriber = new ScriptedTranscriber([{ ok: true, text: transcript }]); + + const path = await saveCapture( + { recordings, notes, env }, + { chunks: [audio], mime, startedAt: env.now(), endedAt: new Date(env.now().getTime() + 47_000), interrupted: false }, + ); + await new QueueRunner({ recordings, notes, transcriber, env }).tick(); + + expect(path).toBe('voice/inbox/2026-09-27 14-15-03.md'); + expect(transcriber.calls).toHaveLength(1); + expect(transcriber.calls[0].mime).toBe(mime); + expect(Buffer.from(transcriber.calls[0].audio).equals(Buffer.from(audio))).toBe(true); + expect(notes.files.get(path!)).toBe(expected); + }); + } +}); -- 2.47.2 From 968140f7e1d5b1fcc7f149d0309cfec00089d03c Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 27 Sep 2026 17:05:56 +0400 Subject: [PATCH 19/22] =?UTF-8?q?docs(openspec):=20final-review=20rulings?= =?UTF-8?q?=20=E2=80=94=20claim-all=20scope,=20per-record=20errors,=20fixt?= =?UTF-8?q?ures=20ignore,=20hardening?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refs #1 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QKHeGuAYh4W1jNdjMConZa --- openspec/changes/core-recording-lifecycle/design.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/openspec/changes/core-recording-lifecycle/design.md b/openspec/changes/core-recording-lifecycle/design.md index 6200eaf..4967c14 100644 --- a/openspec/changes/core-recording-lifecycle/design.md +++ b/openspec/changes/core-recording-lifecycle/design.md @@ -75,7 +75,7 @@ interface Env { now(): Date; random(): number; deviceId: string; } ### Ручные команды -`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()`. +`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()`. ### Захват @@ -131,3 +131,7 @@ interface Env { now(): Date; random(): number; deviceId: string; } 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 минут (лишний запрос).** -- 2.47.2 From 972976b16a58933dd15e71277f7c7d9edce024ac Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 27 Sep 2026 17:12:31 +0400 Subject: [PATCH 20/22] fix(core): claimAll reclaims own work; isolate per-record queue errors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - I-1 (F-1): claimAll() now also moves this device's own `retry` records and own stale `sent` records to `saved` via the `claim` transition (device = own), same as it already does for other devices' claimable records, then runs a normal tick() so everything sends without waiting for the next retry time. The returned count still only reflects records reassigned from other devices. Adds `ownReclaimable()` to schedule.ts. - M-1 (F-2): pass() wraps each per-record operation (staleOwn update and each send) in try/catch, remembers the first error, keeps processing the rest of the due queue, and rethrows the first error after both loops complete — so one permanently failing record no longer blocks newer records behind it. list()/load() failures still reject tick() immediately, as before. - F-4a: send()'s post-request update now also requires `r.device === env.deviceId && r.sentAt === sent.sentAt`, so a late transcription result can never overwrite a record another device (or a newer send) has since taken over. - F-4b: pass()'s staleOwn loop now also stops when the abort signal fires; retry() and claimAll() return early (false / 0) without writing once disposed. - F-4d: retry() no-op it.each now also covers `sent` (own, fresh). Refs #1 Co-Authored-By: Claude Opus 5.5 --- src/core/queue.ts | 60 ++++++++++++++++++++++++++++++------ src/core/schedule.ts | 7 +++++ tests/queue-commands.test.ts | 37 +++++++++++++++++----- tests/queue.test.ts | 51 +++++++++++++++++++++++++++++- 4 files changed, 138 insertions(+), 17 deletions(-) diff --git a/src/core/queue.ts b/src/core/queue.ts index d69d1ec..e369b70 100644 --- a/src/core/queue.ts +++ b/src/core/queue.ts @@ -1,7 +1,7 @@ import { parseRecord, readVoiceFields, renderNote } from './note'; import type { Env, NoteStore, RecordingStore, TranscribeResult, Transcriber } from './ports'; import { NO_SPEECH, transition, type VoiceRecord } from './record'; -import { claimable, due, isStaleSent, staleOwn, type Located } from './schedule'; +import { claimable, due, isStaleSent, ownReclaimable, staleOwn, type Located } from './schedule'; export interface QueueDeps { recordings: RecordingStore; @@ -26,6 +26,9 @@ export class QueueRunner { // Повторный вызов во время работы не запускает параллельный проход, // а просит ровно один дополнительный после текущего. + // Отклоняется (rejects), если list() бросил исключение, или если обработка + // какой-то записи бросила исключение — в этом случае остальной проход всё + // равно завершается, и первая ошибка пробрасывается уже после него. async tick(): Promise { if (this.controller.signal.aborted) return; if (this.running) { @@ -50,6 +53,7 @@ export class QueueRunner { // «Повторить распознавание»: failed/retry → saved, счётчик с нуля, запись // переходит текущему устройству (иначе чужую запись здесь не отправить). async retry(path: string): Promise { + if (this.controller.signal.aborted) return false; const { env } = this.deps; const changed = await this.update( path, @@ -63,11 +67,17 @@ export class QueueRunner { // «Распознать все ожидающие здесь»: чужие saved/retry/зависшие sent → своё // устройство, статус saved (без ожидания повтора), счётчик сохраняется. + // Дополнительно так же разбираются свои retry (независимо от + // voice-next-attempt) и свои зависшие sent — тоже без ожидания. После + // этого — обычный tick(). Возвращаемое число — только записи, переданные с + // других устройств; свои не считаются. async claimAll(): Promise { + if (this.controller.signal.aborted) return 0; const { env } = this.deps; const now = env.now(); + const items = await this.load(); let claimed = 0; - for (const item of claimable(await this.load(), now, env.deviceId)) { + for (const item of claimable(items, now, env.deviceId)) { const changed = await this.update( item.path, (r) => r.id === item.record.id && claimable([{ path: item.path, record: r }], now, env.deviceId).length > 0, @@ -75,25 +85,54 @@ export class QueueRunner { ); if (changed) claimed++; } + for (const item of ownReclaimable(items, now, env.deviceId)) { + await this.update( + item.path, + (r) => + r.id === item.record.id && ownReclaimable([{ path: item.path, record: r }], now, env.deviceId).length > 0, + (r) => ({ record: transition(r, { type: 'claim', device: env.deviceId }, now) }), + ); + } await this.tick(); return claimed; } + // Ошибка одной записи (process()/read() бросили исключение) не блокирует + // остальные: она запоминается, проход продолжается, и первая такая ошибка + // пробрасывается только после того, как обе очереди пройдены целиком. private async pass(): Promise { const { env } = this.deps; const items = await this.load(); const now = env.now(); + let hasError = false; + let firstError: unknown; + const remember = (e: unknown) => { + if (!hasError) { + hasError = true; + firstError = e; + } + }; for (const item of staleOwn(items, now, env.deviceId)) { - await this.update( - item.path, - (r) => r.id === item.record.id && r.device === env.deviceId && isStaleSent(r, now), - (r) => ({ record: transition(r, { type: 'temporary', message: INTERRUPTED_SEND }, now) }), - ); + if (this.controller.signal.aborted) break; + try { + await this.update( + item.path, + (r) => r.id === item.record.id && r.device === env.deviceId && isStaleSent(r, now), + (r) => ({ record: transition(r, { type: 'temporary', message: INTERRUPTED_SEND }, now) }), + ); + } catch (e) { + remember(e); + } } for (const item of due(items, now, env.deviceId)) { if (this.controller.signal.aborted) break; - await this.send(item); + try { + await this.send(item); + } catch (e) { + remember(e); + } } + if (hasError) throw firstError; } private async send(item: Located): Promise { @@ -121,9 +160,12 @@ export class QueueRunner { const done = env.now(); const final = result; + // Пока запрос был в полёте, запись могла уйти к другому устройству (или + // отправиться заново) — проверяем, что это всё ещё именно наша отправка, + // прежде чем записывать результат поверх неё. await this.update( item.path, - (r) => r.id === sent.id && r.status === 'sent', + (r) => r.id === sent.id && r.status === 'sent' && r.device === env.deviceId && r.sentAt === sent.sentAt, (r) => final.ok ? { record: transition(r, { type: 'success' }, done), text: final.text } diff --git a/src/core/schedule.ts b/src/core/schedule.ts index 4e51ab2..63f5ca4 100644 --- a/src/core/schedule.ts +++ b/src/core/schedule.ts @@ -36,3 +36,10 @@ export function claimable(items: readonly Located[], now: Date, deviceId: string r.device !== deviceId && (r.status === 'saved' || r.status === 'retry' || isStaleSent(r, now)), ); } + +// «Распознать все ожидающие здесь» также разбирает собственные retry +// (независимо от voice-next-attempt) и собственные зависшие sent — без +// ожидания, как и чужие claimable(). +export function ownReclaimable(items: readonly Located[], now: Date, deviceId: string): Located[] { + return items.filter(({ record: r }) => r.device === deviceId && (r.status === 'retry' || isStaleSent(r, now))); +} diff --git a/tests/queue-commands.test.ts b/tests/queue-commands.test.ts index 8d6645a..53440b1 100644 --- a/tests/queue-commands.test.ts +++ b/tests/queue-commands.test.ts @@ -52,9 +52,13 @@ describe('QueueRunner.retry', () => { expect(fields(ctx, path)).toMatchObject({ 'voice-status': 'inserted', 'voice-device': 'test-device' }); }); - it.each(['saved', 'inserted'] as const)('%s: no change, no request', async (status) => { + it.each([ + ['saved', {}], + ['inserted', {}], + ['sent', { sentAt: T0.toISOString() }], + ] as const)('%s: no change, no request', async (status, over) => { const ctx = setup([]); - const path = seed(ctx, rec('a', { status })); + const path = seed(ctx, rec('a', { status, ...over })); const before = ctx.notes.files.get(path); expect(await ctx.runner.retry(path)).toBe(false); expect(ctx.notes.files.get(path)).toBe(before); @@ -64,6 +68,16 @@ describe('QueueRunner.retry', () => { it('missing note → false', async () => { expect(await setup().runner.retry('voice/inbox/nope.md')).toBe(false); }); + + it('after dispose(): no change, no request', async () => { + const ctx = setup([]); + const path = seed(ctx, rec('a', { status: 'failed', attempts: 5, error: 'e' })); + const before = ctx.notes.files.get(path); + ctx.runner.dispose(); + expect(await ctx.runner.retry(path)).toBe(false); + expect(ctx.notes.files.get(path)).toBe(before); + expect(ctx.transcriber.calls).toHaveLength(0); + }); }); describe('QueueRunner.claimAll', () => { @@ -75,21 +89,30 @@ describe('QueueRunner.claimAll', () => { expect(fields(ctx, path)).toMatchObject({ 'voice-status': 'inserted', 'voice-device': 'test-device', 'voice-attempts': '2' }); }); - it('includes stale sent; leaves failed, inserted, fresh sent and own records', async () => { - const ctx = setup([{ ok: true, text: 'x' }, { ok: true, text: 'y' }]); + it('includes stale sent, own retry and own stale sent; leaves failed, inserted and fresh sent untouched', async () => { + const ctx = setup([ + { ok: true, text: 'w' }, + { ok: true, text: 'x' }, + { ok: true, text: 'y' }, + { ok: true, text: 'z' }, + ]); const o = { device: 'iphone' }; const saved = seed(ctx, rec('a', o)); const stale = seed(ctx, rec('b', { ...o, status: 'sent', sentAt: '2026-09-27T11:04:00.000Z' })); const fresh = seed(ctx, rec('c', { ...o, status: 'sent', sentAt: '2026-09-27T11:13:03.000Z' })); const failed = seed(ctx, rec('d', { ...o, status: 'failed', attempts: 5, error: 'e' })); const done = seed(ctx, rec('e', { ...o, status: 'inserted' })); - const own = seed(ctx, rec('f', { status: 'retry', attempts: 1, nextAttempt: '2026-09-27T11:35:03.000Z', error: 't' })); - const untouched = [fresh, failed, done, own].map((p) => ctx.notes.files.get(p)); + const ownRetry = seed(ctx, rec('f', { status: 'retry', attempts: 1, nextAttempt: '2026-09-27T11:35:03.000Z', error: 't' })); + const ownStale = seed(ctx, rec('g', { status: 'sent', sentAt: '2026-09-27T11:04:00.000Z' })); + const ownFresh = seed(ctx, rec('h', { status: 'sent', sentAt: '2026-09-27T11:13:03.000Z' })); + const untouched = [fresh, failed, done, ownFresh].map((p) => ctx.notes.files.get(p)); expect(await ctx.runner.claimAll()).toBe(2); expect(fields(ctx, saved)['voice-status']).toBe('inserted'); expect(fields(ctx, stale)['voice-status']).toBe('inserted'); - expect([fresh, failed, done, own].map((p) => ctx.notes.files.get(p))).toEqual(untouched); + expect(fields(ctx, ownRetry)['voice-status']).toBe('inserted'); + expect(fields(ctx, ownStale)['voice-status']).toBe('inserted'); + expect([fresh, failed, done, ownFresh].map((p) => ctx.notes.files.get(p))).toEqual(untouched); }); it('nothing to claim → 0', async () => { diff --git a/tests/queue.test.ts b/tests/queue.test.ts index 2dc8af5..ecee73d 100644 --- a/tests/queue.test.ts +++ b/tests/queue.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { initialNote, readVoiceFields } from '../src/core/note'; +import { initialNote, patchFrontmatter, readVoiceFields } from '../src/core/note'; import { QueueRunner } from '../src/core/queue'; import type { VoiceRecord } from '../src/core/record'; import { MemoryNotes, MemoryRecordings, ScriptedTranscriber, TestEnv, type Step } from './fakes'; @@ -259,4 +259,53 @@ describe('QueueRunner.tick', () => { await ctx.runner.tick(); expect(ctx.notes.listCalls).toBe(listCallsBefore); }); + + it('one record throwing does not block the rest; tick rejects after the pass completes', async () => { + const ctx = setup([ok('два')]); + const first = seed(ctx, rec('a')); + const second = seed(ctx, rec('b')); + const boom = new Error('boom'); + const originalProcess = ctx.notes.process.bind(ctx.notes); + ctx.notes.process = async (path, fn) => { + if (path === first) throw boom; + return originalProcess(path, fn); + }; + + await expect(ctx.runner.tick()).rejects.toBe(boom); + expect(ctx.transcriber.calls).toHaveLength(1); + expect(fields(ctx, second)['voice-status']).toBe('inserted'); + }); + + it('post-request guard: result is dropped if another device already re-sent the record', async () => { + const ctx = setup([]); + const path = seed(ctx, rec('a')); + ctx.transcriber.steps.push(async () => { + const current = ctx.notes.files.get(path)!; + ctx.notes.files.set( + path, + patchFrontmatter(current, { 'voice-device': '"iphone"', 'voice-sent-at': '"2026-09-27T11:16:00.000Z"' }), + ); + return { ok: true, text: 'x' }; + }); + + await ctx.runner.tick(); + + expect(fields(ctx, path)).toMatchObject({ + 'voice-status': 'sent', + 'voice-device': 'iphone', + 'voice-sent-at': '2026-09-27T11:16:00.000Z', + }); + }); + + it('sent status and sentAt are on disk while the request is in flight', async () => { + const ctx = setup(); + const path = seed(ctx, rec('a')); + ctx.transcriber.steps.push(async () => { + expect(fields(ctx, path)).toMatchObject({ 'voice-status': 'sent', 'voice-sent-at': T0.toISOString() }); + return { ok: true, text: 'x' }; + }); + + await ctx.runner.tick(); + expect(fields(ctx, path)['voice-status']).toBe('inserted'); + }); }); -- 2.47.2 From d48b50a8939e0d7e594aae4b4544c426fa766acf Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 27 Sep 2026 17:12:45 +0400 Subject: [PATCH 21/22] fix: stop ignoring tests/fixtures/*.webm golden fixtures (M-2, F-3) Decision 25 allows .webm/.ogg golden cases and decision 9 requires audio fixtures to be committed; the ignore rule silently let a webm golden case pass locally while failing on a clean clone (the fixture would never reach git). Removes the `tests/fixtures/*.webm` line from .gitignore. Refs #1 Co-Authored-By: Claude Opus 5.5 --- .gitignore | 1 - 1 file changed, 1 deletion(-) diff --git a/.gitignore b/.gitignore index fed0ae4..d08e3aa 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,5 @@ main.js data.json .superpowers/ .bounded -tests/fixtures/*.webm .DS_Store .claude/worktrees/ -- 2.47.2 From eb8e00b2a0157a6e35371b633920330be41cc7ff Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 27 Sep 2026 17:59:49 +0400 Subject: [PATCH 22/22] docs: set test vault path in project CLAUDE.md (M-3, partial) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QKHeGuAYh4W1jNdjMConZa --- CLAUDE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index 3c3270d..eb9e070 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -22,7 +22,7 @@ ## Тесты - Golden-тесты: `tests/fixtures/.m4a` + `tests/fixtures/.txt` (эталонный транскрипт). Провайдер в тестах мокается; golden-файлы проверяют формирование заметки и обработку ответа, а не сам сервис. -- Тестовый vault: `<путь укажите>` — синхронизируется между Mac и телефоном. +- Тестовый vault: `/Users/andrey.epifancev/Library/Mobile Documents/iCloud~md~obsidian/Documents/voice-note-test` — синхронизируется между Mac и телефоном. ## Первая подсистема Жизненный цикл записи и очередь (core), затем провайдер, затем обёртка. Тестовая инфраструктура — Task 1 первого плана. -- 2.47.2