Repository navigation
Description
Saying "mute" once works. Saying it twice does nothing — and ships the word to the agent as a chat message
Live from bd43f4de-… today:
10:58:14 user | "Yes, of course, push, everything has to be pushed."
10:58:54 user | "Mute, mute, mute, mute, mute"
The second message is the command, five times over, and it was delivered to the model as a chat turn.
Measured against the real matcher
"mute" split=mute text="" final=mute partial=mute
"Mute, mute" split=null text="Mute, mute" final=null partial=null
"Mute, mute, mute, mute, mute" split=null text="Mute, mute, mute, mute, mute" final=null partial=null
"mute mute" split=null text="mute mute" final=null partial=null
"Yes push everything, mute" split=null text="Yes push everything, mute" final=null partial=null
One "mute" matches. Two or more match nothing at all — no command fires, and the full text is sent.
This is the worst possible shape for the failure, because repetition is what a user does when the first attempt appears not to work. The natural recovery action is the one guaranteed to fail, and it publishes the attempt into the conversation. ADR 0001 M1 says mute is reachable in every phase; here it is unreachable precisely when the user is trying hardest to reach it.
Why
convo.ts:834-838, the whole-utterance branch of splitTrailingCommand, requires exact equality after normalisation:
if (commandPhrases(cmd, words, lang).some((p) => normalizeTranscript(p) === norm)) {
return { command: cmd, text: "" };
}The built-in phrase is "mute"; the transcript normalises to "mute mute mute mute mute". Not equal → no match. matchVoiceCommand uses the same rule, so the partial path fails identically once a second "mute" lands.
The trailing branch then declines too, because it is restricted to multi-word phrases (:846-853) — deliberately, so "don't forget to mute" stays a message. "mute mute" is not in the built-in phrase list, so nothing matches it either.
Every individual rule here is defensible. The combination means a repeated command is less recognisable than a single one.
Answering the question directly: can it be cut off or filtered?
Yes, and the safest lever is repetition itself as the disambiguator. The reason a bare trailing "mute" is not stripped today is genuine ambiguity — "don't forget to mute" is prose. A repeated bare command word is not ambiguous. No English sentence ends "…, mute mute", and no sentence is "mute mute mute mute mute".
Three changes, cheapest first. (1) alone fixes the reported case.
1. Collapse consecutive repeats before matching (whole-utterance path). In normalizeTranscript, or in a wrapper used by the matcher, fold a run of identical adjacent tokens to one: "mute mute mute mute mute" → "mute". Then the existing exact-equality rule matches and returns {command:"mute", text:""} with no other rule touched. This is narrow — it only affects utterances that are literally a word repeated — and it changes no behaviour for ordinary prose.
2. Strip a repeated trailing command run (trailing path). Extend the multi-word restriction with a repetition clause: a trailing bare command word is kept when it appears once (protecting "don't forget to mute"), and stripped + fired when it appears twice or more:
"push everything, mute"→ message stays whole, no command (unchanged, correct)"push everything, mute mute"→ sends"push everything", fires mute
This is the "smart cut-off" asked for, and it is defensible precisely because repetition is the signal that distinguishes an instruction from a mention.
3. Make the two halves agree. Today the gate can fire mute from a partial (use-voice.ts:336-351) while finalize keeps the word in the message — the inconsistency already noted on #420. Whatever the matcher decides, the same decision should govern both firing and stripping. If mute fired during capture, the word that fired it must not reach the agent.
An open question I could not resolve from the data
Did mute actually fire here? Two possibilities, and they need different fixes:
- The gate saw a partial of exactly
"mute", firedmuteFromCommand, and the user kept speaking because nothing observable changed — in which case (3) is the important half. - The gate never ran at all, so nothing was scanned. The dictation gate's
onerroris() => {}(gate.ts:137) and its restart failure iscatch {}(:145-149), so a denied or dead gate is completely silent — see [bug] Repeated microphone prompts: three recognizers share one mic and two re-arm forever — and a denial in either is completely unlogged, so mute-by-voice can die silently #425. In that case the user got no mute at any point and (1)/(2) are the fix.
The transcript cannot distinguish them, which is itself the argument for #425: a gate that fails leaves no trace anywhere.
Alternatives considered and rejected
- Add
"mute mute"to the built-in phrase list. Fixes exactly two repeats and fails at three. The repetition-collapse in (1) handles any count. - Match a single bare "mute" anywhere in an utterance. Rejected — this is the rule
convo.ts:846-853exists to prevent, and it would silently truncate"don't forget to mute"while muting. The comment there is right; the fix is to add a repetition signal, not to remove the precision rule. - Filter command words out of the message text but still send it. Rejected as a general rule: for a single trailing "mute" we do not know it was a command, so filtering would corrupt a legitimate sentence. Filtering is only safe once the matcher is confident, which is what (1) and (2) establish.
- Do it in the model prompt ("ignore trailing mute words"). Rejected — a mute that reaches the model has already failed to mute the microphone, which is the actual request.
Acceptance criteria
-
"mute","mute mute", and"mute mute mute mute mute"all fire mute and send no message. -
"don't forget to mute"is still sent whole and fires nothing. -
"push everything, mute mute"sends"push everything"and fires mute. - The same rule governs the partial/gate path and
finalize, so a fired command never reaches the agent as text. - Equivalent coverage for the other repeatable commands (
unmute,stop), in every configured language.
Regression risk
- Repetition-collapsing inside
normalizeTranscriptwould affect every consumer of it, including transcript comparison and the noise filter, where a legitimate doubled word ("very very") should survive. Safer to collapse only when evaluating a command match, not in the shared normaliser. convo.test.tsholds the precision cases for the single-word rule; the new repetition cases belong beside them so the two rules are read together.mute-invariant.test.tsasserts reachability per phase, not per phrasing. Its guard table is the natural place to add "a repeated mute utterance dispatches mute" so this cannot silently regress.
Related: #420 (the same finalize path; my earlier comment there covers the single trailing "mute" that fires but is not stripped), #425 (why we cannot tell whether the gate ran), ADR 0001 M1 (mute reachable at every moment), #228 (why the trailing word must not simply be discarded with the turn).
Activity
fix(voice): a repeated command word is the command it obviously is, n…
feat(voice): a command can be switched off, without freezing its words (
fix(voice): a command that fired mid-capture stops reaching the agent…
serge-ivo commentedon Aug 8, 2026
Shipped in
c95479d, and the last acceptance item in37b6524. Both live; CI + both deploys green.Fix (1) and fix (2) as you specified them; fix (3) needed evidence rather than a matcher rule and is #457's step 3, now also delivered.
Where the collapse lives, and where it does not
collapseRepeatedRunsis innormalize.ts, not insidenormalizeSpeech— your regression note called this exactly right. It sits in that file because that is where a reader would be tempted to fold the repetition once and for all, so that is where the reason not to has to be. The shared normaliser still feeds the transcript comparison and the noise filter, where "very very" must survive.Two rules, because adjacent-token folding only sees a script that writes spaces: identical adjacent tokens, plus a single spaceless token that is one unit repeated — which is how
"静音静音静音"and"停停"arrive from an engine that emits no separator. Covered in all ten languages, derived from the phrase tables rather than transcribed.The collapse reopened #385, and #385's own tests caught it
Worth recording, because "strictly additive" is exactly what made it dangerous. An account with
stopSpeechKeyword: "stop stop"says its own keyword; the fold reads it as"stop"; and"stop"is still a built-in EXIT phrase, because #385 correctly reserved only the phrases that contain the keyword. So hands-free was torn down by the destructive reading the user never chose, through the door the fold had just opened.foldedReadingtherefore declines to fold anything the user has bound, andstripRepeatedTaildeclines a run that is a bound phrase.boundByUseris one predicate thatreservedElsewherenow delegates to, so the two readings of "the user bound this" cannot drift.Everything else is additive: the raw transcript is matched first and unchanged; the folded reading is a second attempt; the new trailing pass runs last so it cannot preempt a rule above it.
Your open question, answered
For the reported utterance (
"Mute, mute, mute, mute, mute"as a whole turn) — measured on the shipped matcher,matchVoiceCommandreturnednullfor two repeats and above, on the partial path as well as the final. So no command fired at any point, and (1)/(2) are the fix. The gate-was-dead branch is not needed to explain this one; #425 stands on its own merits.Verification
origin/main: 7 failures, includingMute, mute: expected null to be 'mute'and the M1 guard's"mute mute" reaches nothing via the main transcription path. The two precision-preserving tests pass there, because they hold ground rather than take it.mute-invariant.test.tsgains the repetition case. Why it was missing is worth naming:reachable()asks whether any phrasing reaches mute — the right question for M1 — andSAYS_MUTE's doubled form matched nothing while.somehid it behind the bare word that did. The new assertion is per phrasing, per phase, at one/two/five repetitions.docs:drift.Acceptance criteria
"mute","mute mute","mute mute mute mute mute"all fire mute and send no message"don't forget to mute"still sent whole, fires nothing"push everything, mute mute"sends"push everything"and fires mutefinalize— delivered in37b6524([design] Command-vs-content is one decision serving two actions with asymmetric costs — split the verdict and park the uncertain case instead of adding a fifth matcher rule #457 step 3). It is not a matcher rule: nothing in the string says what the app already did, so the gate now records what it fired andfinalizekeeps that word out of the message. The gate's own comment already claimed this worked, and named the example it got wrong.unmuteandstop, in every configured languageClosing.
fix(voice): every listener sees the same command word set, so a phras…
feat(voice): split command-vs-content classification into fire/park/n…