Changelog
Generated from the project changelog when the site is built. Entries are in the language they were written in.
Stripe: в живом аккаунте Arcada LLC заведены три продукта Subtl (
Subtl Pro,Subtl Pro BYOK,Subtl Unlimited, меткиapp=subtl) и шесть цен по прайсу из01-product.md §6— $19/$159, $9/$76, $39/$328. Идентификаторы цен прописаны в конфигурации, тест проверяет, что каждый тариф резолвится в обе стороны. Stripe Tax в аккаунте уже активен, адрес компании задан, автоналог включается в Checkout кодом. Не сделано: секретный ключ API нельзя получить программно — его нужно скопировать из дашборда; конфигурацию портала коннектор создать не даёт (нет праваPostBillingPortalConfigurations), пока используется дефолтная конфигурация аккаунта; вебхук создаётся после появления публичного адреса.BYOK: ключ пользователя запечатывается на клиенте в X25519-конверт, открывается только в памяти на время запроса, не логируется и не хранится в открытом виде; при отклонении ключа стадия падает с
byok_provider_rejectedи не переключается на наш ключ (проверено вживую: 10/10 сегментов отклонены при заведомо неверном ключе, при рабочем — все стадии прошли на ключе пользователя). Учёт токенов: каждый вызов модели пишетsubtl_tokens_total{provider,model,stage,kind}. Первое измерение (46 с, пресет с документом): 22 475 prompt и 2 014 completion токенов, из них 68 % prompt приходится на суфлёра;cachedравен нулю везде — кеширование промпта, на которое опирается расчёт себестоимости вdocs/05 §6.4, фактически не срабатывает. Причина выяснена замером и оказалась не той, что предполагалась: префикс стабилен (1 740 символов, одинаковые байты во всех вызовах), но меньше минимального порога кеша Gemini. Разбор обоих сценариев — вdocs/02 §4.Phase 3, Stream B: BYOK (
byok_proxy). Сервер публикует X25519-ключ (GET /byok/pubkey,alg = x25519-hkdf-sha256-aes256gcm, конвертSBK1 || epk || nonce || AES-256-GCM), клиент шифрует свой ключ провайдера и присылает шифротекст (PUT /byok/keys), сервер расшифровывает на каждый запрос, только в памяти; в таблицеbyok_keysнет колонки для plaintext, наружу отдаются имя провайдера иhint— последние 4 символа, посчитанные при записи. Приватный ключ сервера —BYOK_SERVER_PRIVKEY(без него в dev генерируется эфемерный, с громким предупреждением). Маршрутизация по стадиям, а не по сессии: у пользователя с одним ключом Gemini на его счёт уходят STT, перевод и классификация, суфлёр остаётся на нашем Claude. Ошибка на ключе пользователя (401/402/403/429 и 400 c упоминанием ключа — Gemini отвечает именно так) →Error{byok_provider_rejected, retryable:false}с именем провайдера, без фолбэка на наш ключ; прочая ошибка допускает фолбэк только на другой ключ того же пользователя.pro_byokбез единого ключа получаетbyok_key_requiredна Hello вместо бесплатного доступа к нашим провайдерам. Desktop: Settings → Свои ключи (password-поле, маска, удаление), ключ уходит в Keychain и шифротекстом на сервер, вsettings.jsonи в логи не попадает; веб-кабинет — только статус и удаление. Тесты, которые сломались бы при утечке: plaintext не появляется в захваченном выводе zerolog (TestAPILogsNeverContainTheKey,TestKeyNeverReachesLogsOrMessages), ни в одной колонкеbyok_keys(to_jsonb-обход строки), ни в сообщенииError(провайдер-фейк возвращает ключ в теле ошибки), ни в telemetry allow-листах (Rust и TS); шифротекст не открывается другим ключом сервера, повреждённый — не открывается вовсе; конверт, запечатанный Rust-клиентом, открывается Go-сервером (зафиксирован как фикстура). Сквозная проверка через реальный API: с заведомо неверным «ключом пользователя» 10/10 сегментов далиbyok_provider_rejected (HTTP 400, retryable=false)и ни одного транскрипта — фолбэка на наш ключ не произошло; с рабочим ключом в логе видноbyok: stage running on the user's own keyдля stt/translate/classify/suggest, транскрипты и переводы вернулись,subtl_byok_stages_total{stage,provider,outcome}посчитал стадии; grep по всему логу сервера: ни ключа, ни его хвоста, ни строкиAIzaSy.byok_directостаётся запланированным (docs/05 §7) — ключ уже лежит в keychain клиента, список провайдеров и таблица общие, ничто из сделанного ему не мешает.локальное распознавание (Phase 3, Stream A): whisper.cpp с Metal в
core-rsпод фичейlocal-stt, маршрутизация во время выполнения (вместо аудио уходит готовыйLocalTranscript), загрузка моделей с проверкой SHA-256 и политикаauto(локально только на Apple Silicon с 16 ГБ и скачанной моделью). Замер на M1/8 ГБ, та же 46-секундная фикстура, 40 сегментов за 4 прогона: p50 334 мс, p90 422 мс, p95 707 мс против 1453 мс p50 у облачного пути при равной точности — примерно вчетверо быстрее и $0 за час.large-v3-turboна этой машине медленнее реального времени (5.3 с на сегмент), поэтому по умолчанию предлагаетсяsmall-q5_1; на машине с 16 ГБ turbo не измерялся.Phase 3, Stream A: распознавание речи на устройстве.
packages/core-rs, featurelocal-stt(по умолчанию выключена) —whisper-rs0.16 с Metal на macOS:LocalStt::load(&Path)+transcribe(&[f32], lang_hint) -> Transcription{text, lang}, 16 кГц моно на входе, один экземпляр на весь звонок, потоков = физических ядер (максимум 8). Маршрутизация рантаймовая (Engine::set_local_stt), не compile-time: с распознавателем сегмент уходит однимLocalTranscript, кадрыAudioиSegmentEndне отправляются вообще; без него поведение прежнее (тестыpackages/core-rs/tests/local_stt_routing.rs). Распознавание идёт в отдельном потоке на канал, чтобы не блокировать колбэк захвата. Хост: командыmodels_status/models_download{id}(событияmodel_downloadс прогрессом) /models_delete{id}, скачивание официальных ggml-весов в app data dir с проверкой размера и SHA-256, в инсталлер модель не кладётся;sttMode=auto | cloud | local(по умолчаниюauto: локально только на Apple Silicon с ≥ 16 ГБ и скачанной моделью), политика — чистая функцияstt_route::resolveс тестами; Settings → AI показывает режим, что он даёт на этой машине, список моделей с загрузкой и фразу о том, что остаётся на устройстве (EN/RU). Измерено (spikes/03-stt-providers/README.md, M1 / 8 ГБ, та же 46-секундная фикстура и тот же сервер, WER — методомwer.go, сверено с ним до шестого знака):small-q5_1— p50 1188–1228 мс, p95 1290–1616 мс, WER 0.127; облако (gemini-2.5-flash-lite) — p50 1453–1489 мс, p95 1775–2125 мс, WER 0.119;large-v3-turbo-q5_0на этой машине 5.3 с на фразу, то есть медленнее реального времени, поэтому по умолчанию предлагаетсяsmall. Цель «p95 < 1 с» не достигнута: она берётся только с урезанным окном энкодера (with_fit_audio_ctx, p50 313–345 мс, p95 716–1091 мс), а наlarge-v3-turboэто же урезание заставляет модель повторять фразы и удваивает WER (0.127 → 0.288) — поэтому опция выключена по умолчанию. Себестоимость в02 §4не пересчитана: нет измеренных счётчиков токенов, оценка помечена как оценка. Не проверено:large-v3-turboна машине с ≥ 16 ГБ, Windows (Vulkan/CUDA), реальный звонок в приложении.качество суфлёра: в промпт добавлено правило против заполнителей — модель предлагала произнести «we have worked together for X years», когда факта нет в документах; теперь она обязана сказать, что уточнит, или задать вопрос. Разброс числа автоподсказок (от 1 до 6 на одном и том же аудио) перестал быть догадкой: каждое решение классификатора пишется в лог и в метрику
subtl_classify_verdicts_total{kind,suggested}. Замер на фикстуре: 5 подсказок из 9 реплик, пропуски обоснованы — смолток и утверждения без вопроса.наблюдаемость: события воронки в PostHog (десктоп и веб) с allow-list полей — свободный текст и содержимое реплик отбрасываются билдером, а не дисциплиной; Sentry на паники и 5xx; счётчики воронки и один JSON-лог на запрос с
request_id; профильmonitoringв compose (Prometheus + Loki + Grafana + алерты). Всё выключено без ключей. Форма waitlist на лендинге подключена кPOST /waitlist(проверено: 202 с сохранением источника и языка, 400invalid_emailна неверный адрес).Наблюдаемость (закрывает измеримую часть Phase 2): канонический список событий воронки определён по одному разу на платформу —
apps/desktop/src-tauri/src/telemetry/events.rsиapps/web/src/lib/analytics.ts, таблица «событие → где срабатывает → свойства» в10-go-to-market.md §3. Оба билдера payload отбрасывают ключи вне allow-листа и строки, не похожие на машинный токен, поэтому транскрипт/перевод/подсказка/e-mail в событие не попадают (тестыunknown_and_free_text_fields_are_stripped,strips unknown keys and free text). Desktop: модульtelemetryбатчит события в PostHog/capture(флаш каждые 30 с и на выходе, очередь 500, сброс приprivacy.telemetry=false),distinct_id— id пользователя после входа, иначе анонимный из стора настроек (перегенерируется при выходе); Sentry без встроенной panic-интеграции — свой хук шлёт только место и тип паники,before_sendвычищает message/breadcrumbs/hostname; React error boundary → командаreport_error. Сервер:sentry-goна паники и 5xx, счётчикиsubtl_sessions_total{plan},subtl_session_seconds,subtl_active_sessions,subtl_suggestions_total{trigger},subtl_provider_requests_total/subtl_provider_errors_total{provider,stage},subtl_quota_exceeded_total,subtl_stripe_events_total{type},subtl_http_requests_total{route,status}; zerolog переведён на один JSON-объект в строке сrequest_idв каждой записи запроса. Web: снипет PostHog заNEXT_PUBLIC_POSTHOG_KEY, до согласияpersistence: "memory"(без cookie), минимальная полоса согласия, на юридических страницах аналитики нет. Infra: профильmonitoringв compose (Prometheus + node-exporter, Alertmanager, Loki, promtail, Grafana с провиженингом и дашбордомsubtl-overview), правила алертов из02 §9и runbookdocs/runbooks/observability.md. Всё инертно без ключей: безPOSTHOG_KEY/SENTRY_DSNнет ни очереди, ни клиента, ни сетевых вызовов — одна INFO-строка на старте (проверено запуском сервера без ключей и сPOSTHOG_KEY=test SENTRY_DSN=). Не проверено вживую: реальная доставка в PostHog и Sentry (нет ключей), Telegram-алерты (нет бота), стек мониторинга на VPS (машины нет).Приёмка Phase 2 (
subtl-cli send --preset … --summary, 46 с фикстура, документfinancial_model.txt1271 Б → 1 чанк,readyза < 4 с): подсказки перестали быть общими и отвечают цифрами из документа — «$140K/мес, runway 22 месяца», «команда 12: 7 инженеров, 2 продажи, 1 CS, 2 основателя, найм 4+2»; на реплику «три фаундера» суфлёр поправил по документу («сооснователей двое»); на вопрос о ценах Enterprise ответил «уточню после звонка» вместо выдуманной цифры (правило из spike 05 работает)./meetings/:id/endвернул саммари иminutes_billed: 1. Латентность: перевод (первый фрагмент) p50 561 / p95 670 мс, суфлёр TTFT p50 721 / p95 753 мс, STT p50 1721 / p95 3503 мс (разброс вырос — Gemini делит квоту с эмбеддингами и суфлёром; ключ Groq остаётся главным способом это починить). Не проверено вживую: Stripe (нет ключей), Windows (нет машины), обновления (нет ключа подписи).Phase 2, день 1: сервер — файлы контекста (PDF/DOCX/TXT/MD/CSV → чанки 1600/240 → эмбеддинги Gemini 768d в pgvector), RAG top-5 в суфлёре, саммари встречи на
POST /meetings/:id/end, Stripe Checkout/Portal/webhooks с идемпотентностью,/waitlist,/me/export,DELETE /me,/devices; desktop — история в SQLite с FTS и экспортом, стор настроек, авто-детект звонка, updater (ждёт ключ подписи владельца), Windows-адаптер WASAPI (кросс-компиляция подx86_64-pc-windows-gnu+ CI-джоб, вживую не запускался); web — 31 статическая страница: лендинг EN/RU, программные/for/{app}(покаnoindexдо реальных скриншотов), сравнение с Cluely,/pricing,/security,/changelog, юридические страницы с баннером «черновик», кабинет с входом по коду, подпиской, пресетами, файлами, устройствами и удалением аккаунта.Phase 1 (macOS MVP), день 1: сервер — auth по 6-значному коду (magic code, Resend; dev: код в логе), JWT access/refresh с ротацией, Postgres-хранилища (users, entitlements, devices, presets, meetings), миграции при старте, пайплайн STT → перевод (Gemini Flash, стрим по предложениям) → классификатор (Flash-Lite, таймаут 1200 мс по замеру) → суфлёр (Claude Haiku с кешем, fallback Gemini), квоты Free 40 мин с
Quota/quota_exceeded; desktop — Swift static libSubtlCapture(SCK) + cpal-микрофон + тест звука + разрешения, все экраны 06-ux-spec (онбординг, Home, контекст, Live, история, настройки) на EN/RU, оверлей stacked/bar/sidebar со всеми состояниями, JWT в Keychain, трей, ⌥⌘S/Space/L/P; core-rs — pause/resume/regenerate. Сквозной прогон (subtl-cli → WS → сервер, 46 с фикстура, JWT): 10/10 транскриптов, 10/10 переводов, 6 подсказок по автоклассификации; STT p50 1594 мс (Gemini flash-lite, узкое место), перевод первый фрагмент p50 621 / p95 865 мс, суфлёр TTFT p50 695 / p95 839 мс. Исправлено: лимит полосы 320 кбит/с срезал pre-roll и догоняющие буферы (теперь 4 Мбит/с как защита от abuse);JWT_SECRETобязателен для стабильных сессий между рестартами. Не проверено в этой сессии: реальный захват звука в приложении (нужен клик TCC у владельца), звонки с людьми, 5 внешних тестеров.spike 06 выполнен на реальной оболочке
apps/desktop: transparent + alwaysOnTop + click-through +set_content_protectedработают вместе; оверлей не виден вscreencapture(контроль без защиты — виден), CPU 0.25 % при стриме текста, 0.14 % в простое (критерий ≤ 3 %). Шаринг в Zoom/Meet и click-through — проверка владельцем.Phase 0 (день 2): Rust собран локально, 15 тестов зелёные; DoD-прогон выполнен: 10/10 сегментов транскрибированы через core-rs → WS → Go → Gemini STT,
gemini-2.5-flash-litep50 ≈ 1.56 с, p95 1.77–2.03 с (на границе критерия 2 с, см.spikes/03); spike 07 — Silero F1 0.987 (PASS), энергетический VAD 0.75; в сегментатор добавлен pre-roll 300 мс (без него терялись первые слова фраз); исправлен контрактws_url(сервер отдаёт полный адрес); промпт Gemini STT без таймстампов.CI зелёный на
main(run 34140599194): rust fmt/clippy/test, go, ts, tauri-macos debug build с иконками изbrand/mark.svg; stealth черезWebviewWindow::set_content_protected.v0.4 Phase 0 (день 1): монорепо
arturayupov/subtl(pnpm + cargo + go), CI;packages/protocol(session.proto +SegmentEnd/Latency, TS/Go/Rust codegen, flags);apps/serverGo (health, dev-auth,/meetings, WS-сессия,whisper_http+gemini_stt, тесты зелёные);packages/core-rs(VAD-сегментатор, ресемплер, WS-клиент с буфером 30 с, Engine),tools/subtl-cli,spikes/07-vad— собраны и протестированы в CI (GitHub Actions, macOS-15), локально не собирались (диск);apps/desktopTauri 2 (main + overlay, click-through,sharingType=.none, ⌥⌘S/⌥⌘Space, demo-режим),apps/webзаглушка;packages/uiтокены,packages/promptsv1 со snapshot-тестами; infra compose + миграция. Spikes: 01 собран, TCC-блок без интерактива; 03 Gemini STT WER 0 на 5/7, 1.1–2.0 с/фраза; 04 Gemini FlashthinkingBudget:0p50 718 мс, Haiku 1182 мс; 05 Haiku TTFT p95 922 мс без кеша, 3/30 производных цифр. Не измерено: Groq/Deepgram/DeepL (нет ключей), OpenAI (нет кредитов), DoD p95 (Rust не собран).v0.3: развилки README закрыты (гибридная монетизация, macOS первым, транскрипты локально, Stealth в Pro); пакет перенесён в
docs/монорепоsubtl,CLAUDE.mdв корне; убраны дубли; 01 §6 приведён к решению по Stripe.v0.2: имя Subtl / subtl.me; дизайн A (тёмное стекло), Manrope, mint; Free 40 мин, годовые −30 %; языки лендинга 3 волны; добавлены 08–11, brand/.
v0.1 пакет спецификаций создан.