Imported from zohartito/scrivo (
AGENTS.md). Install upstream withnpx skills add zohartito/scrivo. Copyright stays with the author.
Scrivo (formerly Sotto; repo zohartito/scrivo, modules still sotto*.py) — local, offline push-to-talk dictation: hold a key, speak, release; text lands at the cursor. macOS: PyObjC + mlx-whisper on Metal. Windows source alpha: sotto_win.py — pynput trigger hook (right-ctrl default), default-input capture (PortAudio's MME host API, not WASAPI), pinned faster-whisper (CUDA float16, CPU int8 fallback), paste-or-type insertion, pystray tray + Tk dialogs (--tray).
Two lanes share this code. The alpha is what users install and run. The sealed adaptive lane is the maintainer's own LaunchAgent deployment with the learning system; users never run it. Rules under "Shared code rules" apply to both.
Alpha (what users run)
Run & test
All Mac commands run from the repo root with the alpha venv venv-alpha (Apple Silicon).
- Source alpha: native Python 3.12, macOS 14+ (dependency floor; only 27.2 tested).
python3.12 -m venv venv-alpha && venv-alpha/bin/python -m pip install -r requirements-alpha.txt; keep the sealed runtime dependency policy separate. - Install/update/remove:
scripts/install-mac.sh(Mac) andscripts\install-windows.ps1(Windows). These are the alpha's only install paths;scripts/rollout.shis the sealed lane's deploy and never applies to the alpha. - App:
scripts/install-mac.sh [--update|--uninstall]buildsScrivo.app(bundle idio.github.zohartito.scrivo) in/Applicationswhen writable, else~/Applications, removing our other copy (compiled launcher fromscripts/install_app.py, ad-hoc signed,SOTTO_LAUNCHER=app); login itemio.github.zohartito.scrivovialogin_item.py(never the sealed label). - Rename (Sotto → Scrivo,
mac_app.py): the installer never removes the oldSotto.app(org.sotto.alpha), because the old process restarts through it after Update Now. Its launcher still runs this folder'ssotto.py, whosemove_from_legacy_appopensScrivo.appand exits 0; Scrivo.app's start moves theorg.sotto.alphalogin item over and deletes the old app — only when its id isorg.sotto.alphaAND its launcher runs this folder'ssotto.py. Windows:install-windows.ps1makesScrivo.lnkand removes aSotto.lnkthat starts this copy. Never rename:sotto.pyand the other modules,SOTTO_*, data folders,~/sotto, the HKCU Run valueSotto, the mutex,com.zohartito.sotto.*, branchmain; get.sh/get.ps1 accept anoriginofzohartito/sottoand move it tozohartito/scrivo. - Data folders: the installers keep alpha state in
sotto-alphaby settingSOTTO_DATA_DIR— Mac~/Library/Application Support/sotto-alpha(install-mac.shand theScrivo.applauncher), Windows%LOCALAPPDATA%\sotto-alpha(install-windows.ps1viawin_launch.py); the model cache follows as<data>/huggingface. A baresotto.py runwithoutSOTTO_DATA_DIRusessotto_paths' default (~/Library/Application Support/sotto,%APPDATA%\sotto), so it sees different history and settings than the app. - Source launch: export
SOTTO_DATA_DIRandSOTTO_HF_HOMEto dedicated alpha folders; see README. To run against the app's own data:SOTTO_DATA_DIR="$HOME/Library/Application Support/sotto-alpha" venv-alpha/bin/python sotto.py run(models then come fromsotto-alpha/huggingface). Never use personal state for tests. - Permissions check:
venv-alpha/bin/python sotto.py doctor(Accessibility + mic; sameSOTTO_DATA_DIRas the run) - Run:
venv-alpha/bin/python sotto.py run(trigger/hotkey come from Settings, default right Option;--trigger/--hotkeyoverride one run).sotto.py setup [--profile parakeet]pre-downloads pinned models + Silero VAD;sotto.py dictionarylists rules. - Full test suite:
venv-alpha/bin/python -m unittest discover -s tests(unittest; slow — loads heavy modules).tests/test_00_hermetic.pysorts first and pointsSOTTO_DATA_DIR/SOTTO_HF_HOMEat a temp folder before anything importssotto_paths, so a run never touches real user data; a single-module run skips it, so usescripts/test_alpha.py -p <file>for one module. - Isolated alpha verification:
venv-alpha/bin/python scripts/test_alpha.py(temporary data/cache; optional public calibration inputs require explicitSOTTO_CALIBRATION_FIXTURES). - One module:
venv-alpha/bin/python scripts/test_alpha.py -p test_speech_config.py(orvenv-alpha/bin/python -m unittest tests.test_00_hermetic tests.test_speech_config; do NOT runvenv-alpha/bin/python tests/<file>.pydirectly — cwd isn't onsys.path, imports fail) - Alpha tests do not install workers or enable adaptive routing. Run them from a checkout without world-writable or symlinked ancestors (not /tmp);
scripts/test_alpha.pyrefuses otherwise because the comparator-spool guards reject such paths. - Windows PC suite:
venv\Scripts\python scripts\test_alpha.py(temp state + offline flags; plainunittest discover -s testsis hermetic too, viatests/test_00_hermetic.py— do NOT add-t; a global site-packagestestspackage shadows the repo'stests/dir). macOS-only tests self-skip on win32 (each skip carries a reason string). - Windows PC run:
venv\Scripts\python sotto_win.py(console;--trayfor the tray app;--device cpu|cudaorSOTTO_DEVICE;doctor,history,history-delete --id,history-clear --yes). Testers:scripts\install-windows.ps1(venv, pinned CPU/CUDA set — a re-run keeps the recorded one, Start Menu shortcut viawin_launch.py+ pythonw; refuses while Scrivo runs from the venv or whenScrivo.lnkstarts another existing copy;-WhatIf,-Uninstall) anddocs/windows-alpha.md(Python 3.13 x64;requirements-alpha-windows.txtCPU set + lock,requirements-alpha-windows-cuda.txtadds cuBLAS + its lock).scripts\sotto-win.batis the dev shortcut. - Gesture acceptance (8 scenarios):
venv-alpha/bin/python tests/gesture_check.py(wall-clock timed — run locally, flaky on CI runners) - Syntax-only check:
venv-alpha/bin/python -m compileall -q *.py tests - Lint:
venv-alpha/bin/python -m pip install ruff==0.16.10 && venv-alpha/bin/python -m ruff check(rules inpyproject.toml: E/F/W/B at 120 columns; the sealed lane's modules and tests are excluded for now; CI joblintintests.yml). Fix findings or add a narrowly-justified per-file ignore; don't reformat whole files.
Dependencies
- The alpha's dependency truth is
requirements-alpha*.txt+constraints-alpha*.txt:constraints-alpha.txt(Mac, Python 3.12),constraints-alpha-windows.txt(Windows CPU, Python 3.13) andconstraints-alpha-windows-cuda.txt(adds cuBLAS). Every installer and manual step bootstraps one exact pip (pip==).tests/test_dependency_pins.pyfails when a package pinned in both the Mac and a Windows lock differs, unlessPLATFORM_DIFFERENCESgives the reason (today:huggingface-hub, capped below 2.0 by Windows' tokenizers). - The sealed lane's dependency truth is separate (
requirements.txt+runtime_dependency_policy.json, below). Never edit one set to fix the other.
Alpha-only rules
install-mac.sh --updatefetches, refuses a copy with its own commits, records the installed packages, installs the new version's, and only then fast-forwards the source; any failure or stop (INT/TERM/HUP) in between restores the recorded set, uninstalls what the update added and re-checks (pip has no rollback). It refuses while a Scrivo runs from the copy (this copy'ssotto.pyon a command line, absolute or resolved against the process's cwd vialsof -d cwd;get.shtoo) except the one named bySOTTO_UPDATE_FROM_PID, whichupdates.apply_macsets; only Scrivo.app (app_mode()) offers Update Now — a terminal run is shownupdates.mac_manual_updateinstead (N28); apply_mac runs it in its own process group and stops that whole group afterUPDATE_TIMEOUT_S.get.sh/get.ps1update an existing copy through the same transaction (install-mac.sh --update; the update's ownupdate-windows.ps1 -SourceDir -NoRestart).scripts/update-windows.ps1does the same on Windows with the recorded CPU/CUDA set, then reruns the installer, writesupdate-status.txt(the tray reports it) and starts this copy again (the shortcut only if it points here).updates._gitpassesCREATE_NO_WINDOWon win32. The Windows scripts compare paths by their long names (Long-Path: GetLongPathName on the existing part), never as typed: a folder's 8.3 short name (GitHub's TEMP isC:\Users\RUNNER~1) is the same folder. After an update the app closesCaptureGate(no new recordings), waits until nothing is recording, transcribing or waiting to paste (dictation_in_flight, at mostUPDATE_DRAIN_DEADLINE_S), then restarts; a failed restart reopens the gate.install_app.buildnever replaces aScrivo.appit did not build.- Check for Updates also compares the folder's HEAD with the commit
run()read once at launch (updates.started_from, local git only; Mac and Windows), because agit pull/merge in the running folder (2026-10-10: an hourly backup job) changes the files under a running Scrivo. When they differ it never says "up to date":updates.offer/describename both commits; identicalupdates.PACKAGE_LISTSoffer the drained Restart; changed ones never the plain restart (new code on old packages) — Update Now (the installer installs@{u}'s pins, which is HEAD after a "current" check) or, offline/failed, the manual command; a terminal run is only told what to do (N28). - Scrivo.app permissions (2026-09-30):
AXIsProcessTrusted()asks tccd once per process and then repeats that answer, so the app-mode dialog polls a fresh child (accessibility_granted_now) and relaunches after the grant. Scrivo.app neverexecvs in place: same-pid exec leaves the new menu bar icon blank and unclickable (2026-10-01), so restarts go throughrelaunch_app_after_exit(detached helper waits for the launcher, then kickstarts the login item oropen -ns the app). The bundle is ad-hoc signed, so TCC pins the grant to that exact build ("Failed to match existing code requirement" in tccd logs);buildkeeps an identical bundle untouched — don't churn the launcher. Alerts come from Python, soui.use_app_iconsets Scrivo's icon. - Windows ASR:
win_asr.resolve_model_dirruns once at startup — a complete cached snapshot of the pinned commit loads with no network call, a missing one downloads only when no offline flag is set, andWhisperModelonly ever gets that local directory. The first (warmup) transcription is the device probe: CTranslate2 loads cuBLAS lazily, so a CUDA failure surfaces there and falls back to CPU int8 unless--device cudawas forced. The idle rewarm runs only on CUDA (a CPU decode would queue ahead of the dictation).win_capturebinds each opened stream to its capture generation so a slow open can never leave the mic running, and stops/closes a finished stream on its own thread, registered under the same lock that detaches it (the next open waits for it); a stream that cannot open callsWinCapture.on_start_failed, which ends the gesture and notifies the tray once (the Mac'son_mic_failed); insertions run on one FIFO thread and wait while any modifier is held, each at mostINSERT_WAIT_Sfrom when its text was ready; nothing file- or icon-related runs on the keyboard-hook thread (_ConsoleLogonly queues lines; its writer thread does the I/O). A model exception keeps the capture as alive_suspectHistory row[transcription failed](Retry recovers it) and notifies the tray; when History cannot take that row (an append that raises, orUnsavedHistory'ssaved: False) the note says it is lost instead. A liveappend_livethat raises still delivers the text (the Mac's F16a) with one tray note. Restart/Speed wait for the dictation in flight up toRESTART_DRAIN_CAP_S(HANDS_FREE_MAX_S+RESTART_TRANSCRIBE_S= 15 min, so the longest hands-free recording and its CPU decode finish; past it a wedged CUDA/CT2 call is abandoned and logged). Quit (off the tray thread) ends a recording in progress like the tray's Finish (copied, orphans too) and waits up toQUIT_DRAIN_S(10 s, the Mac's; the drains arelifecycle.wait_until_idleandend_capture_nowwith a copy-only finish) until nothing is recording, transcribing, queued for insertion or waiting for the app to read a paste (win_inject.paste_settling); insertion after shutdown stays refused. Restart, Update and Quit close onesotto.CaptureGatethaton_startbegins captures under; Update closes it before its busy check and reopens it when refused or when the updater cannot start, then quits through the Quit drain. The resync poller revives the hook only under the lock the teardown stops it with, after checking shutdown, so it never revives the hook during teardown. A restart passes an absolute script path. Automatic = detect then argmax within the Settings languages (speech_config.automatic_languages), decoded with that language fixed, reusing the detection's encoder pass for clips up to 30 s (LanguageRestricted.last_language->preprocessing["detected_language"]). Live and Retry runpipeline.screenlike the Mac's (dictionary and English cleanup only on text that passed the guards; a Retry keeps a "scratch that" as words);latency.release_to_text_secondsand the sharedprogressmodule match the Mac. Paste = delayed rendering from a clipboard-owner window (compare-and-restore on that thread after the foreground app reads it, retried if busy; owner-marked private content is typed instead; a tray-finished dictation is copied, not inserted); own injected keys carrywin_inject.INJECTED_TAGso the hook ignores them. "scratch that" on Windows follows the Mac's rules below (the samevoice_commands.LastInsert) withwin_inject.foreground_identity()(foreground window + pid) andTriggerHook.keydowns(the user's non-modifier key-downs; one integer bump on the hook thread)._ConsoleLogrotatessotto.logpastLOG_ROTATE_BYTESwhile running, on its writer thread. One dictation app per Windows session (named mutex; tests setSOTTO_INSTANCE_KEY). Real-clipboard tests run in a private window station. - An unreadable
history.jsonl(damaged row, duplicate id, newer schema) raiseshistory.HistoryUnreadable. Only the dictation apps (run()when not adaptive,sotto_win.run()) open History withtolerate_unreadable=True: the file and its WAVs are left untouched (no sweep, no rewrite, no move),sotto.history_coordinatorreturnsUnsavedHistory(dictation delivered, nothing saved; every History action raises), and the user is told once. Never "recover" by deleting or moving the index: a launch without one sweeps every WAV as an orphan. Workers, CLIs and the adaptive lane stay fatal. A damagedlearning/learning.jsonlorpending-gold.jsonlraiseslearning.LearningUnreadableand is handled the same way: the same two apps open it withLearningStore(tolerate_unreadable=True)(no recovery, every save refuses),history_coordinatorreturnsUnsavedHistory, andunreadable_store_alertgives the one alert (N21).progress.recordstarts from zero only whenstats.jsonis missing; any other read error skips that update (F38). - History Delete/Clear on Windows go through the same
sotto.nonadaptive_delete/nonadaptive_clearas the Mac menu.adaptive_learninglocks viastorage_lock.lock_exclusive(fcntl on macOS, msvcrt on Windows);ComparatorSpool.scrubwithout dir_fd authority (Windows) is complete only when the spool is absent.
Maintainer / sealed adaptive lane
The maintainer's production install: venv venv with requirements.txt, pinned and verified against runtime_dependency_policy.json by sealed_release.py; the sealed LaunchAgent from launchd/; data in sotto_paths' default folder. scripts/rollout.sh runs its preflight and compileall with venv/bin/python.
- Deploy ONLY via
scripts/rollout.sh(stage → activate → preflight under the new release → back up/install/reload the sealed LaunchAgent → verify, with automatic LKG + plist rollback and re-preflight on boot failure). This rule is for the sealed maintainer deployment; the alpha installs viascripts/install-mac.sh/scripts\install-windows.ps1.kickstartalone does not reload changed ProgramArguments. Receipts are sealed toruntime_source_digest(), so any source change invalidates them; deploying by hand and skipping the preflight crash-loops the app at warmup ("adaptive baseline preflight receipt is invalid" — bit 4 rollouts before the script). maintainer/launchd_templates.pyrenders home/FFmpeg placeholders during rollout; never install raw templates.- Do not install the silver worker before public Calibration V2 and strict sealed readiness pass. Afterward it may collect shadow-only personal evidence; candidate routing remains impossible until personal evaluation authorizes at least 500 captures over at least 7 days.
- The learning system is gate-heavy on purpose (receipts, digests, revocation tombstones, one-shot promotion). Preserve those invariants; don't loosen them for convenience.
- Sealed runtime markers must survive harmless cross-boot APFS
st_devrenumbering. Persist paths, inode/size/timestamps, exact tree membership, and SHA-256 bytes; usest_devonly for within-process race/alias checks, never as durable identity. - The lane's code is the
maintainer/package (from maintainer.silver_store import …; run its CLIs from the repo root asvenv/bin/python -m maintainer.<module>): headlessadaptive_worker,adaptive_runtime,silver_store,silver_experiment,deployment_controller,inference_scheduler,speech_backends(sealed candidate backends and receipts),teacher_backends/teacher_consensus/teacher_provisionandteachers/(Qwen3-ASR + Granite adapters, isolated requirements),calibration_worker/calibration_manifest_builder,sealed_release,launchd_templates,offline_backup. Shared with the alpha, so at the root:adaptive_learning.py(the revocation guard every History mutation runs),learning.py,history.py,evaluation.py,comparator_spool.py.sealed_release_bootstrap.pystays at the root: the plists and rollout's LKG-rollback preflight name that path, and an LKG release from before the split has nomaintainer/. Entrypoints undermaintainer/find the package because the bootstrap puts the releasesource/root first onsys.path. - The alpha reaches a
maintainermodule only through gated edges:--adaptiveand the lane's learning-* commands (sotto.LANE_COMMANDS; both refuse with one CLI error whenmaintainer/is absent),learning.silver_lane_exists, and a champion manifest inAdaptiveLearning; with the lane's code missing they raise before History changes (fail closed).tests/test_alpha_without_maintainer.pyruns the alpha with the lane refused. The sealed boundary stays the fullRUNTIME_SOURCE_FILES, everymaintainer/file included (tests/test_runtime_source_manifest.pychecks the import closure). The alpha source archive shipsALPHA_FILESinscripts/build_alpha_source.py, a second list withoutmaintainer/, the lane's tests (MAINTAINER_TEST_FILES) and their calibration fixtures; a lane test that must ship elsewhere skips with a reason whenmaintainer/is absent. launchd/— plist templates;docs/— learning design notes.
Shared code rules (both lanes)
Structure
sotto.py— main entry + CLI (run/doctor/learning-*): LearningCoordinator/HistoryStore/LearningStore.transcription.py—TranscriptionWorker, the single-flight job loop (live,retry,warmup, Nemotronstream-*) thatrun()builds with its collaborators and starts on the worker thread; it callspipeline.screen()for live and Retry.tests/test_transcription_worker.pyis its golden master: drive it with fakes, never the real model.app_controller.py—AppController, every menu-bar action: History (copy, Retry, save audio, delete/clear, correct and its dictionary rules, enroll/revoke, the adaptive reviews, the History refresh), Settings (language, the engine switch, the Settings window'sSettingsModel, the Parakeet/Nemotron background installs), Start/Finish now, Restart, Quit, Check for Updates/Update Now, andmenu_callbacks()(every item but Quit inert once shutdown is requested), with every collaborator injected; it imports nothing fromsotto(run()passes in the History and engine helpers).run()builds it and wires it; the gesture callbacks and the main-loop teardown stay inrun().tests/test_app_controller.pypins each action with fakes.- Split out of
sotto.pyby verbatim moves (sottore-exports their public names; none of them importssotto;tests/test_module_split.pypins both):delivery.py— cursor injection (inject: clipboard snapshot, tagged ⌘V, changeCount-guarded restore, or typing), Scrivo's own key events,DeliveryQueue,PendingDeliveries;lifecycle.py—ShutdownBoundary,CaptureGate,Lifecycle(the drained stop for Quit/Restart/update/SIGTERM),RestartControllerand the relaunch helper, the drain helpers and the exit/drain constants (shared with Windows);gestures.py—GestureEngineand its timing constants (shared with Windows;tests/gesture_check.pyoverridesgestures.HOLD_THRESHOLD_S/DOUBLE_TAP_WINDOW_S);capture.py—CaptureService(key-scoped AVAudioEngine capture, the idle device scan, the dead-route repair ladder), the CoreAudio mic selection and pin helpers,join_capture_blocks,doctor_input_probe,SAMPLE_RATE(macOS; tests patchcapture._pin_input_to_builtin/capture.log, and sotto does not re-export its private helpers);sotto_log.py—logand the app-log rotation;hotkey.py—TRIGGERS/TRIGGER_LABELS,parse_hotkey,hands_free_hintandHotkeyListener, the session event tap (callback,revive_tap,resync_poller,rebind,keys_held,start) with Quartz injected as thequartzadapter; it never imports Quartz, so its flag masks are literals thattests/test_hotkey.pypins to Quartz's on macOS (tests/test_hotkey.pyfakes the adapter and runs on Windows too;tests/test_hotkey_tap.pydrives the tap through the realrun()). Module state they rebind (delivery._pending_restore,delivery._secure_input_probe,sotto_log._app_log) is read and patched in its own module, never throughsotto. speech_config.py,vad.py,ui.py— Whisper/Parakeet profiles (pins inMODEL_REVISIONS, 100WHISPER_LANGUAGES,automatic_languages), voice-activity detection (pinned Silero v6.2, verified on load), and the AppKit menu-bar + ember-orb overlay.- Engines in
sotto.py:LocalWhisper(one encoder pass for Automatic ≤30 s, experimental Fast = trimmed encoder,choose_languagenever translates clearly-other speech, unsure decodes and exhausted token budgets fall back to the full pipeline) andLocalParakeet(Parakeet TDT v3 on MLX, 25 European languages, array input, 120 s chunks); both resolve pinned snapshots viaresolve_pinned_snapshot(cache-first, local path only). pipeline.py— owns what happens to a transcript before delivery:screen()= no-speech verdict → hallucination guard with repetition-loop salvage → personal dictionary → English voice cleanup. Live and Retry callscreen()on macOS and Windows alike (withmove_detected_languageandTRANSCRIPTION_FAILED_TEXT).finalize_primary_live_delivery()(History row first, delivery only after it) is used by the Mac worker and Windows alike. Change transcript screening here, never in a platform entry point.speech_config.transcription_kwargsbuilds the decode options and attempt metadata for both apps (sotto._transcription_kwargsis the same function).- Shared pure modules (macOS + Windows):
dictionary.py(heard => write rules, correction suggestions),settings.py(settings.json),progress.py(History trends, no text). macOS UI:settings_window.py(PyObjC,@objc.python_methodhelpers). nemotron_backend.py,streaming_audio.py— optional, manually selected English streaming ASR using pinned NeMo-Speech.cpp Metal libraries; provision withscripts/setup_nemotron.pyfrom the lane's venv (venv-alphaorvenv) and under the sameSOTTO_DATA_DIRas the app (Apple Silicon only). Pins live inconfig/nemotron-runtime.json; runtime only loads verified local files.sotto_win.py+win_asr.py/win_capture.py/win_hotkey.py/win_inject.py/win_ui.py(tray, Settings, Correct) /win_startup.py(HKCU Run login entry) /win_launch.py(pythonw entry) — Windows app (no overlay, no adaptive lane, no Nemotron). CT2 model ids live inwin_asr.PROFILES(+FAST_REPOfor Settings speed "fast"), exact commits inwin_asr.REVISIONS, per-repo file sets inwin_asr.REPO_FILES;speech_config.MODEL_PROFILESstill names the MLX repos for the Mac. Shareddictionary.py/settings.pyare Mac-owned APIs. Policy both apps share is imported, never re-implemented insotto_win.py: transcript screening and History-before-delivery frompipeline, the "scratch that" rule fromvoice_commands.LastInsert, the drains (wait_until_idle,end_capture_now,QUIT_DRAIN_S) andCaptureGatefromlifecycle, decode options fromspeech_config.transcription_kwargs; Windows keeps its I/O and its own gate policy (Restart'sRESTART_DRAIN_CAP_S, Update refused while busy). Its live audio preparation (VAD trim, silence collapse, dead-route row) still mirrorstranscription.py.tests/— unittest suite.
Conventions & danger zones
- macOS runtime: Quartz/AppKit import at module scope (guarded in
sotto.pyso the pure logic imports on Windows; thewin_*modules raiseImportErroroff win32, and their tests skip with a reason). CI runs the unit suite on a macOS runner (.github/workflows/tests.yml, Homebrew Python 3.12); the olderci.ymlstays tombstoned because it needsactions/setup-python— see.github/workflows-disabled/README.md. Style: PEP 8, f-strings,pathlib. sotto_paths.pyis the only place that decides storage: macOS~/Library/Application Support/sotto(+Sotto/huggingface), Windows%APPDATA%\sotto(+huggingface);SOTTO_DATA_DIR/SOTTO_HF_HOMEoverride (blank = unset). Never add per-module platform path logic.sotto_paths.pycentralizes alpha data/cache overrides without moving legacy data (the alpha'ssotto-alphafolders come from the installers, above).- Fully local/offline by design — no cloud, no telemetry, no speech upload. Check for Updates (
updates.py) is the only network call outside model setup, and only on the user's click; offline flags disable it. Text delivery usesdeliver_call, never the UI-onlyui_call, so--no-overlaystill pastes. Inference must never resolve HuggingFace Hub ids;sotto.pysetsHF_HUB_DISABLE_TELEMETRY=1at import (N37). Startup resolves the exact commit pinned inspeech_config.MODEL_REVISIONScache-first (no network when that snapshot is cached), downloads it only on a first non-offline run, and passes inference a local path; offline flags forbid downloads. - The production LaunchAgent keeps only the selected ASR model resident. With
--idle-release 0, key-down starts the microphone engine/input tap and key-up stops/removes them; idle Scrivo must not hold the microphone or retain idle audio. Whisper remains the default with sealedconfig/sotto-glossary.txt; optional Nemotron is a manual English-only engine, never an adaptive candidate or promotion. - The Speech engine submenu persists the choice and restarts only its own foreground process or supervising sealed LaunchAgent while idle. Nemotron (
--profile nemotron-en) streams canonical PCM on the single transcription worker, retains exact inference bytes for History/retry, and pastes finalized text only. It disables non-English/automatic language and Whisper glossary prompts; returning to Whisper restores the saved language choice. Never feed VAD-gated chunks or run MLX/native inference on the audio tap. Native load failure restores Whisper; native streaming failure retries the full canonical capture once. - Automatic language defaults to English only; users add their languages in Settings and each capture is detected among them. No language besides English is a default or special-cased. The menu-bar Language submenu persistently selects Automatic or one chosen language by changing only the decode hint; it must not load a second model or change the push-to-talk microphone lifecycle.
- After four idle minutes, the next baseline press schedules one single-flight model rewarm on the serialized transcription worker while capture is active; never move it to a concurrent MLX thread.
- The ember overlay follows the display containing the pointer on every visible state and verifies WindowServer visibility after raising; preserve its one-shot panel rebuild and local diagnostic logging.
- The VAD is advisory only (trim + no-speech verdict AFTER transcription via
reads_as_no_speech). Never reintroduce an input-side gate that blocks captures from reaching the ASR: Silero scores real whispered dictation at 0% speech, and energy thresholds break on quiet whispering (2026-08-13/14 incidents). Exact-zero PCM is a dead mic route, not a quiet room (asr_skip_reason); it must not reach Whisper (2026-09-20: 41s of 24 kHz zeros →"ощ"x223). - Bit-exact zero PCM means THIS PROCESS's capture path is wedged, not that the room is quiet. 2026-09-20: AirPods reconnected under a new CoreAudio device id and every capture came back all-zero — including ones pinned to the built-in mic — while ffmpeg read those same AirPods at peak 18609 and a fresh process running
CaptureServicecaptured at peak 0.059. Stop/start does NOT clear it: theAVAudioEngineobject is reused for the process lifetime and carries the wedged HAL input unit across rebuilds._repair_dead_routedrops_engine_objfirst and relaunches via launchd second (guarded bySILENCE_RESTART_AFTER_Sso it cannot loop); the counter rearms only on real audio in_tap, never on engine start. Bluetooth warm-up emits ~410 ms of leading zeros, so the key-up rung needs >=1s of zeros before it fires. looks_hallucinatedis whole-transcript, so a whisper repetition loop that starts partway through used to cost the user the real dictation in front of it (2026-08-15: 403 good chars lost to"difference"x223).salvage_repetition_loopcuts at the loop and pastes the clean prefix, but only when that prefix passes the same guard on its own — keep it conservative; wall-to-wall garbage must still be quarantined, never pasted.- A failed live transcription (Whisper or Nemotron, non-adaptive) must never lose the recording:
TranscriptionWorker._keep_failed_live_captureappends alive_suspectrow reading[transcription failed]with the raw audio and the error in its receipt, logs the error type, and shows one alert so the user can Retry it (F4). Any other exception in a live job before its row reached History is kept the same way (_job_failed, F4b); a Retry that raises beforecommit_retryshows one "Retry failed" alert; later errors are logged as such, not as transcription failures (N17). Read-only History hands back a row withsaved: False: never claim it kept anything (N10). A failed History append must never lose the text: for non-adaptive dictation the delivery a saved row would have scheduled still runs (held text stays off the cursor) and one "History could not be saved" alert is shown (F16a). The adaptive lane keeps its own fallback and append-before-deliver — its row is part of its receipts. Both stay silent once shutdown is requested. - macOS disables session event taps silently (no callback notification) — 2026-09-15: process healthy, tap
enabled=False, 3 hours of dead hotkey.HotkeyListener.resync_poller(hotkey.py) checksCGEventTapIsEnabledevery second and callsrevive_tapDIRECTLY on the poller thread — never viaAppHelper.callAfter(the 2026-09-15 version did that and never fired; 2026-09-16 tap dead again). The menu bar has "Restart Scrivo" (bounded, drained exec for a foreground run; off-main-threadlaunchctl kickstart -konly when that job owns this PID; a process that looks sealed/launchd-started but cannot be confirmed refuses — never exec outside the bootstrap) because a clean Quit is a SuccessfulExit that KeepAlive does not relaunch. Diagnose withQuartz.CGGetEventTapList(look for the app pid with mask0x1c00). - Quit, the menu Restart and the first SIGINT/SIGTERM stop the same way (
Lifecyclein sotto.py): close the capture gate, end the recording as a normal finish, wait fordictation_in_flight(a model rewarm does not count) up toQUIT_DRAIN_S, orSIGTERM_DRAIN_Sbecause launchd SIGKILLs the job 5 s after SIGTERM; a second signal stops at once. The signal handler only sets an event. While the gate is closed (quitting, restarting, updating, switching engines) a key press, the menu start and Retry are refused and the gesture is left idle; the engine switch closes the gate before its last in-flight check. - The teardown (
stop_runtime_on_main) runs a pending clipboard restore before the process ends (still changeCount-guarded), keeps every live capture still untranscribed as alive_suspectHistory row with its audio for Retry (queued, refused by the queue, the one the model is still on, or one the worker abandoned;TranscriptionWorkersettles each capture id once, and only after its History append succeeds; the teardown waits, bounded, for an append of that capture already under way and keeps it if that append failed, so none is saved twice or lost). After SIGTERM the teardown's waits share one deadline (ShutdownBoundary.teardown_wait_s) that leavesSIGTERM_KEEP_RESERVE_Sbefore launchd's 5 s SIGKILL for those keeps. A foreground restart whose exec fails exitsRESTART_FAILED_EXIT(75), never 0, so KeepAlive starts Scrivo again. - N9b (Mac and Windows): each finished capture (>=0.25 s) is handed at key-up to
history.CaptureSpool(<data>/capture-spool/<capture id>-<key-up ms>.wav, raw PCM16 at the native rate, 0600 in a 0700 folder); its own writer thread writes it atomically, never the tap/hook thread, and nothing is written while idle. The file goes once the capture's History row is really saved (a read-onlysaved: Falserow keeps it) or the worker has handled the capture while not stopping (tap drop, F16a delivered text, F4 told the user); a release before the write makes the write skip it. Retry never spools. At launch, before the UI,sotto.recover_capture_spoolturns each leftover into the N9[not transcribed: Scrivo stopped]row viaappend_untranscribed_capture(the teardown keep's code) and only then deletes it; a leftover whose key-up time matches an existing row is that row's (no duplicate);.tmppartial writes are removed. WithUnsavedHistorynothing in the spool is touched and the unreadable alert says how many wait. Only the process thatclaims the folder (non-blocking lock on.owner) spools or recovers; Windowscloses the claim at the end ofrun()because its restart starts before the old process exits. - Live words (
StreamingCapture.live_text, the pill) are display only: never paste, log or store an interim. The pill shows them throughsotto.live_preview(dictionary rules only) so its spellings match the pasted text. Voice commands and filler cleanup (voice_commands.py) run after the dictionary, English only ("um" is a Portuguese word); "scratch that" undoes only Scrivo's own insert:inject()must have reported a real insert (a secure-input decline arms nothing), the same frontmost pid as at insert time, no real user key-down since (the tap counts them, excluding Scrivo's tagged events), and insideSCRATCH_WINDOW_S; otherwise do nothing and log why.voice_commands.LastInsertholds that rule for both apps; each passes its own target identity and key count and presses its own undo keys.stats.jsonholds counts, never text. - Text delivery (
DeliveryQueuein sotto.py) is one FIFO on the main thread: results paste in capture order, and nothing is posted while the trigger or any modifier is physically held (wait while a recording is active; afterDELIVERY_WAIT_MAX_Swith no recording, drop with one note — the text is in History, or "dictate again" when the worker passedin_history=False). A secure-input decline also notes (N34). The head waits without a bound while Scrivo itself is active with no text field focused (own_window_without_text_field):callAfterruns during an alert's modal loop, so a ⌘V would land on the alert (N24). The Retry and History Copy clipboard writes go throughdeliver_calltoo, and_copy_textfinishes their count (N19). Scrivo's paste write carriesorg.nspasteboard.TransientTypeso clipboard managers skip it (N33).deliver_callcounts each paste/undo inPendingDeliveriesand the queue'son_donefinishes exactly one entry per item it delivers, drops or clears at shutdown, so Quit (QUIT_DRAIN_S) and the update restart wait for the paste and never for a leaked count. Every key event Scrivo posts carriesSOTTO_EVENT_TAG(post_own_event), and the tap callback ignores tagged events. The clipboard restore after ⌘V waitsRESTORE_DELAY_Sand carries the user's original forward across overlapping pastes; a user copy in the window still wins (changeCount). - GitHub Actions must stay SHA-pinned —
tests/test_workflow_action_pinning.pyenforces this. venv/,venv-alpha/,__pycache__/,.panel/,.handoff/are gitignored — never commit them.
Agent rules (all harnesses)
- Determinism boundary: anything that must be EXACT lives in code; the model only orchestrates and judges.
- Keep this file current: when you change how this repo is run, tested, or structured, update AGENTS.md in the same commit. Keep it under ~150 lines.
