93 KiB
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:<id>%%…%%/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 <noreply@anthropic.com>и ссылкойRefs #1.
Review Focus
- Транскрипт содержит
$&,$',%%или---— должен вставиться дословно (Task 5: тест «special replacement patterns»). - Сообщение ошибки провайдера с переводами строк, кавычками и двоеточиями — frontmatter остаётся валидным однострочным, callout однострочный (Task 4: тест «round-trips hostile error text»; Task 5: тест «collapses multi-line error»).
metadataCacheотстаёт:list()показываетsaved, а в файле ужеsent/inserted— повторной отправки нет (Task 8: тесты «stale list»).- Заметку удалили между
list()и отправкой — пропуск без исключения, остальные записи обрабатываются (Task 8: тест «note deleted before send»). 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<void>; read(path: string): Promise<ArrayBuffer> }interface NoteStore { exists(path: string): Promise<boolean>; create(path: string, content: string): Promise<void>; list(): Promise<ListedNote[]>; process(path: string, fn: (content: string) => string): Promise<boolean> }interface ListedNote { path: string; frontmatter: Record<string, unknown> }type TranscribeResult = { ok: true; text: string } | { ok: false; kind: 'temporary' | 'permanent'; message: string }interface Transcriber { transcribe(audio: ArrayBuffer, mime: string, signal: AbortSignal): Promise<TranscribeResult> }interface Env { now(): Date; random(): number; readonly deviceId: string }- команды
npm test,npm run build,npm run dev,npm run typecheck
-
Step 1: package.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:
{
"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:
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:
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:
// Порты core. Реализации — в src/obsidian (позже) и tests/fakes.ts.
export interface RecordingStore {
write(path: string, data: ArrayBuffer): Promise<void>;
read(path: string): Promise<ArrayBuffer>;
}
export interface ListedNote {
path: string;
frontmatter: Record<string, unknown>;
}
export interface NoteStore {
exists(path: string): Promise<boolean>;
create(path: string, content: string): Promise<void>;
// Все заметки vault, у которых во frontmatter есть voice-id, с их frontmatter.
// КОНТРАКТ: реализация MUST брать данные из metadataCache и MUST NOT читать
// содержимое файлов (vault.read / cachedRead / adapter). Вызывается на каждом
// tick, в том числе на телефоне. Данные могут отставать от диска — core
// перепроверяет состояние внутри process().
list(): Promise<ListedNote[]>;
// Атомарное чтение-изменение-запись. false, если заметки нет.
process(path: string, fn: (content: string) => string): Promise<boolean>;
}
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<TranscribeResult>;
}
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:
{
"id": "voice-note",
"name": "Voice Note",
"version": "0.1.0",
"minAppVersion": "1.5.0",
"description": "Голосовые заметки: запись, распознавание внешним сервисом, текст в заметку.",
"author": "Andrey Epifancev",
"isDesktopOnly": false
}
src/main.ts:
import { Plugin } from 'obsidian';
// Обёртка Obsidian — отдельная подсистема. Пока пустой плагин, чтобы сборка работала.
export default class VoiceNotePlugin extends Plugin {
override async onload(): Promise<void> {}
}
esbuild.config.mjs:
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
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 <noreply@anthropic.com>"
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): stringextFromMime(mime: string): stringaudioPath(id: string, mime: string): stringnotePath(startedAt: Date, n: number): string(n=1 — без суффикса)formatDuration(ms: number): string→M:SSformatLocalTime(iso: string): string→HH:mmпо локальному времени
-
Step 1: Написать падающие тесты
tests/naming.test.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:
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<string, string> = {
'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
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 <noreply@anthropic.com>"
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 Errortransition(record: VoiceRecord, event: RecordEvent, now: Date): VoiceRecord— чистая, бросаетInvalidTransition
-
Step 1: Написать падающие тесты
tests/record.test.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> = {}): 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:
// Персистентные состояния. 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
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 <noreply@anthropic.com>"
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 VoiceKeyrecordToFields(r: VoiceRecord): Record<VoiceKey, string | undefined>— значения уже в YAML-видеreadVoiceFields(content: string): Record<string, string>— ключиvoice-*из frontmatter текста, значения раскавыченыparseRecord(fields: Record<string, unknown>): VoiceRecord | null— принимает и строки (изreadVoiceFields), и типизированные значения (изmetadataCache)patchFrontmatter(content: string, fields: Record<string, string | undefined>): string
-
Step 1: Написать падающие тесты
tests/note-frontmatter.test.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:
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<VoiceKey, string | undefined> {
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<string, string> {
const lines = content.split('\n');
const range = frontmatterRange(lines);
const fields: Record<string, string> = {};
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<string, unknown>): 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, string | undefined>): 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
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 <noreply@anthropic.com>"
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): stringstatusLine(r: VoiceRecord): stringinitialNote(record: VoiceRecord, durationMs: number): stringrenderNote(content: string, record: VoiceRecord, text?: string): string—textобязателен приrecord.status === 'inserted'
-
Step 1: Написать падающие тесты
tests/note-body.test.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 на:
import { formatDuration, formatLocalTime } from './naming';
import { MAX_ATTEMPTS, NO_SPEECH, STATUSES, type Status, type VoiceRecord } from './record';
Дописать в конец файла:
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
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 <noreply@anthropic.com>"
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): booleandue(items: readonly Located[], now: Date, deviceId: string): Located[]— отсортировано поrecord.idstaleOwn(items: readonly Located[], now: Date, deviceId: string): Located[]claimable(items: readonly Located[], now: Date, deviceId: string): Located[]
-
Step 1: Написать падающие тесты
tests/schedule.test.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<VoiceRecord> = {}): 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:
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
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 <noreply@anthropic.com>"
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<string, ArrayBuffer>),MemoryNotes(files: Map<string, string>,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<string | null>— путь заметки илиnullдля пустой записи
-
Step 1: Фейки портов
tests/fakes.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<string, ArrayBuffer>();
async write(path: string, data: ArrayBuffer): Promise<void> {
this.files.set(path, data.slice(0));
}
async read(path: string): Promise<ArrayBuffer> {
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<string, string>();
// Имитация отстающего metadataCache: если задано, list() возвращает это.
staleList: ListedNote[] | null = null;
listCalls = 0;
async exists(path: string): Promise<boolean> {
return this.files.has(path);
}
async create(path: string, content: string): Promise<void> {
if (this.files.has(path)) throw new Error(`already exists: ${path}`);
this.files.set(path, content);
}
async list(): Promise<ListedNote[]> {
this.listCalls++;
if (this.staleList) return this.staleList;
return [...this.files]
.map(([path, content]) => ({ path, frontmatter: readVoiceFields(content) as Record<string, unknown> }))
.filter((note) => 'voice-id' in note.frontmatter);
}
async process(path: string, fn: (content: string) => string): Promise<boolean> {
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<TranscribeResult>);
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<TranscribeResult> {
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:
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> = {}): 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:
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<string | null> {
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
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 <noreply@anthropic.com>"
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<void>; dispose(): void }и приватныйupdate(path, expect, change): Promise<VoiceRecord | null>,load(): Promise<Located[]>— используются в Task 9
-
Step 1: Написать падающие тесты
tests/queue.test.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<typeof setup>;
function rec(id: string, over: Partial<VoiceRecord> = {}): 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:
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<void> {
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<void> {
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<void> {
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<VoiceRecord | null> {
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<Located[]> {
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
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 <noreply@anthropic.com>"
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<boolean>—true, если запись была вfailed/retryи перезапущенаQueueRunner.claimAll(): Promise<number>— число переназначенных записей
-
Step 1: Написать падающие тесты
tests/queue-commands.test.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<typeof setup>;
function rec(id: string, over: Partial<VoiceRecord> = {}): 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 на:
import { claimable, due, isStaleSent, staleOwn, type Located } from './schedule';
Добавить в класс QueueRunner после dispose():
// «Повторить распознавание»: failed/retry → saved, счётчик с нуля, запись
// переходит текущему устройству (иначе чужую запись здесь не отправить).
async retry(path: string): Promise<boolean> {
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<number> {
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
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 <noreply@anthropic.com>"
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-кейс =
<name>.note.md+<name>.txt+<name>.{m4a|webm|ogg}». -
Step 1: Написать падающий тест
tests/golden.test.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<string, string> = { 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' возвращает ровно один):
{
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
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 <noreply@anthropic.com>"
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 |