v2.39.1 — the guard that ate your dictation¶
Released 2026-09-24. Upgrade:
sudo snap refresh yazses # snap
pipx upgrade yazses # or: uv tool upgrade yazses
scoop update yazses # Windows
A bug-fix release. Both fixes are about the same failure shape: a check that fires on a machine where nothing is wrong. ADR-021 puts it as rule 9 — a guard is judged on how rarely it fires — and two of ours were failing that test in opposite directions. One was loud and permanent; the other was completely silent, which is worse.
The hallucination guard deleted real speech¶
With [hallucination] enabled = true, these were all discarded before they reached your editor:
| You said | What happened |
|---|---|
| "No no no." | deleted |
| "Okay okay okay." | deleted |
| "Wait, wait, wait." | deleted |
| "Yeah yeah yeah." | deleted |
| "What what what?" | deleted |
The loop rule flagged any unit repeated three times — including a single word. At three, a one-word repeat stops describing a decoder fault and starts describing English.
Measured on a real machine, five of seven consecutive dictations ended in
at input levels up to 0.1786, roughly twenty times that machine's vad_threshold of 0.0036. That is loud, deliberate speech — not silence, not breath. Nothing was typed, no sound played, and nothing said why.
Two things make that the wrong default:
- ADR-v2-025 promised it would not happen. Its Consequences say the guard "avoids dropping legitimate short utterances". The ghost-phrase rule earned that by matching whole transcripts only, never substrings. The loop rule never got the same conservatism.
- It hit hardest the people the ADR says are worst affected. The same ADR cites atypical and aphasic speech as suffering the most hallucination — and repeating a word is the dysfluency. The guard also runs before
filters.disfluency, so on adysfluency_friendlyinstall it deleted the stutter before the filter built to tidy it ever saw it.
A one-word unit now needs four repeats. Repeated phrases still go at three, and every degenerate loop the test suite pins is still caught, including the thirteen-word partial-tail loop taken from a live corpus.
The trade is deliberate and asymmetric, which is what ADR-021 asks for: a ghost phrase that reaches your editor is visible and one keystroke to delete. A silently deleted sentence is unrecoverable, and you cannot even tell it happened.
A discard now says why, and you can hear it¶
This is the reason the above went unnoticed for so long. The daemon logged one bland line naming neither the rule nor the text, so a guard eating real dictation and a guard working correctly produced identical logs and identical silence.
Now:
- The rule is named at
INFO—Hallucination guard (repetition_loop)or(ghost_phrase). - The text is logged at
DEBUG, behind the same gate asInjecting text(ADR-011 — it is content, so it stays off by default). - The error earcon plays, exactly as the empty-transcription branch beside it always did. You held the key and spoke; you are owed the one bit of feedback that says heard, dropped.
Every Wayland start demanded a daemon that was already running¶
yazses start printed this on a fully provisioned Wayland desktop:
⚠ ACTION NEEDED
Missing prerequisites: ydotoold (Wayland injection).
Fix everything in one step: yazses setup
It was wrong, and nothing could ever clear it — running the yazses setup it recommended changed no input the check looked at.
build_plan().setup_ydotoold answers "will setup write the ydotoold unit?", and on Wayland that is unconditionally true, because writing it is idempotent and the planner never needs to probe. The preflight read that flag as "ydotoold is missing".
On the machine this was found on, ydotoold was installed at /usr/bin/ydotoold, its user unit was enabled and active and logging accepted client, the udev rule was byte-identical to the one we ship, and input-group membership was in effect. The daemon correctly chose YdotoolInjector — while the warning insisted the "Remote Desktop" portal would be used instead.
So it fired on 100% of Wayland starts, forever. That is precisely how a warning trains people to skip the whole block, including the days it is telling the truth.
The preflight now consults ydotool_ready() — the single readiness function doctor and the injector already share, so the warning can no longer name a backend different from the one the daemon picks. It still fires, unchanged, when ydotoold is genuinely absent or its socket is dead.
yazses doctor names the socket that actually exists¶
Ubuntu ships ydotool 0.1.8, which predates --socket-path and ignores it silently, binding /tmp/.ydotool_socket instead. Readiness already checked both candidates, but doctor printed the path we requested — so on exactly the machines the fallback exists for, it reported running (/run/user/1000/.ydotool_socket), naming a file that is not there.
Two tests were reading the developer's own desktop¶
test_portal_consent_is_explained.py asked the host whether ydotoold was live, so it passed or failed on an accident of whoever ran it — and it is the provisioned host that produces no hint at all, which is how this defect survived a green suite. Both call sites now pin the probe.
Verification¶
Gates at release: ruff clean, mypy clean across 526 source files, 15,531 tests passed / 300 skipped.
Both fixes were proven in both directions — the warning clears on a ready machine and still fires on an unready one; the guard spares emphatic repetition and still catches every degenerate loop.
One frozen test vector was changed deliberately rather than worked around. test_hallucination_loop_with_partial_tail.py pinned "the the the" as a loop; it now pins "the the the the". At three repeats the rule genuinely cannot tell "the the the" from "no no no" — and that same file already listed "very very very good" as speech that must survive. The only thing separating those two vectors was whether the speaker trailed off into a fourth word, which is an arbitrary line to delete a sentence on.