/** * Pure hands-free-conversation control logic, split out of the React hook so the * delicate "when the recognizer ends, should we reopen the mic or bail?" decision is * unit-tested without a browser. */ import { type CommandSplit } from "./command-intent.js"; /** Tunables for the restart/freeze guard. */ export interface RestartConfig { /** A recognizer end sooner than this after start counts as a "rapid" (failing) end. */ rapidMs?: number; /** After this many consecutive rapid ends, bail out of conversation mode (a mic-blocked * / instant-abort loop would otherwise peg the CPU and freeze the page). */ maxRapid?: number; } export interface RestartDecision { /** Give up on conversation mode — the recognizer keeps dying instantly. */ bail: boolean; /** The updated consecutive-rapid-end counter to carry forward. */ nextRapidEnds: number; /** How many consecutive rapid ends this decision COUNTED, including the one it just judged. * Carried out separately because `nextRapidEnds` is reset to 0 on a bail, so it cannot say * how the bail was reached — and a bail that cannot say that is the #387 defect in miniature. */ rapidEnds: number; } /** * Decide what to do when the recognizer ends mid-conversation. If it ended almost * immediately after starting we're likely in a failing restart loop — count it, and * after `maxRapid` consecutive rapid ends, bail. A healthy (long-enough) turn resets * the counter and we reopen the mic. * * @param elapsedMs time since the last listen START * @param rapidEnds the current consecutive-rapid-end count */ export declare function decideRestart(elapsedMs: number, rapidEnds: number, cfg?: RestartConfig): RestartDecision; /** * What a hands-free BAIL says and records (#387). * * Bailing out is correct — the alternative is a restart loop that pegs the CPU and freezes the * page — but it used to be the one path in the whole voice stack that did it in silence. No * notice, no durable row, no console line: hands-free simply stopped and the mode toggle flipped * back on its own, which from the outside is indistinguishable from a crash. Twenty lines away in * the same handler, an error the recognizer REPORTS is logged, surfaced and self-clearing; the * failure it expresses by dying four times in a row — the more serious of the two, because it ends * the whole session rather than one turn — said nothing at all. * * Both halves are here because they answer different questions and neither substitutes for the * other. The NOTICE is the only thing that helps the user: every cause on the list (permission * revoked mid-session, another tab or app took the microphone, the OS suspended it) is * user-fixable, and only if it is named. The REPORT is the only thing that makes "hands-free keeps * dropping out on my phone" countable — with no row in the durable log there is nothing to confirm * it against, which is the same shape as #241 and #376: the system knew and no surface read it back. * * Shaped like `planNoiseRejection` in turn.ts: a FIXED report string, because `reportClientError` * de-dups on source+message and a burst must collapse to one row, with everything that varies in * the context. */ export interface RestartBailPlan { /** Shown to the user, and deliberately NOT auto-cleared like a transcription error: the * session is gone until they act, so a message that expires restores the original silence. */ notice: string; /** Durable-log message (`client:voice`). Fixed — see above. */ report: string; /** What varies per bail, so the frequency is measurable per device / STT mode. */ context: { rapidEnds: number; sttWhisper: boolean; }; } export declare function planRestartBail(s: { rapidEnds: number; sttWhisper: boolean; }): RestartBailPlan; /** * The active interaction mode, derived from the two primitives so there is ONE source * of truth (no contradictory Talk+Speak+Hands-free combos): continuous conversation ⇒ * `"handsfree"`; replies-aloud without continuous listen ⇒ `"ptt"` (push-to-talk); * neither ⇒ `"text"`. */ export type VoiceMode = "text" | "ptt" | "handsfree"; export declare function resolveVoiceMode(convoOn: boolean, speakOn: boolean): VoiceMode; /** Presentational state for the single "voice status" pill in the chat UI. */ export interface VoiceStatus { label: string; /** `work` = transcribing/generating (accent, spinner); `live` = mic hot (green); * `speak` = agent talking back (accent); `idle` = waiting for the user (neutral). */ tone: "work" | "live" | "idle" | "speak"; /** Show a spinner (vs a mic glyph). */ spin: boolean; /** Tappable — toggles a manual talk turn (Tap-to-talk). */ tap: boolean; /** Which glyph the pill shows, so both call sites render consistently. */ icon: "mic" | "spin" | "speak"; } /** * Resolve the single, always-visible voice-status pill so the user ALWAYS knows what's * happening — Listening → Transcribing → Working → Speaking — instead of silence until * the reply lands. Pure so this branchy presentation logic is tested. `null` means show * nothing (idle text chat). `thinking` (agent generating) wins in every mode, then * `speaking` (agent talking aloud) — both show even in text chat (e.g. a manual replay). */ export declare function resolveVoiceStatus(input: { mode: VoiceMode; thinking: boolean; transcribing: boolean; talking: boolean; listening: boolean; /** The agent is talking aloud (TTS). Shown in every mode so the mic clearly isn't hot. */ speaking?: boolean; /** Hands-free mic paused via Mute — the pill must not claim it's listening. */ muted?: boolean; /** A voice session is opening the mic (#284) — see derivePhase. */ starting?: boolean; }): VoiceStatus | null; /** A spoken command the hook acts on locally instead of sending as a chat message. */ export type VoiceCommand = "repeat" | "mute" | "unmute" | "exit" | "next" | "back" | "scrap"; /** Per-instance overrides for the command keywords (Settings → Voice). Empty/absent = * use the built-in defaults for repeat/mute/unmute/exit/next; stop-words are OFF unless configured. * A phrase set on ANY of these fields outranks every built-in — see {@link commandPhrases}. */ export interface VoiceCommandWords { /** Phrases that re-speak the last reply. Overrides the built-in multilingual set. */ repeat?: string[]; /** Phrases that mute the mic. */ mute?: string[]; /** Phrases that re-open the mic while muted (#152). */ unmute?: string[]; /** Phrases that leave voice entirely and return to text mode (#165). */ exit?: string[]; /** Phrases that move you to the next agent asking for you (#277). */ next?: string[]; /** Phrases that return you to the agent you were with before this one (#279). Whole-utterance * AND final-only — see BACK_BY_LANG. */ back?: string[]; /** Phrases that scrap the last turn (#342). Whole-utterance ONLY — see SCRAP_BY_LANG. */ scrap?: string[]; /** * The stop-speech keyword (#153) — halt the agent's playback. Not a command LIST and never * returned by {@link matchVoiceCommand} (it has its own gated matcher, {@link matchesStopSpeech}), * but it is an explicit user BINDING, so the precedence rule in {@link commandPhrases} has to be * able to see it. Without it, a keyword of `"stop stop"` was outranked by the built-in EXIT list * that happens to contain the same phrase (#385). */ stopSpeech?: string; /** * Commands the user has switched OFF (#443). Default: none — an account that has configured * nothing behaves byte-for-byte as before. * * IT RIDES HERE, ON THE SAME OBJECT THE PHRASE LISTS DO, and that is the whole design. A blank * words field means "use the built-ins", and the built-ins are resolved AT MATCH TIME against * the caller's current language ({@link commandPhrases}) — so `mute` becomes `silencio` becomes * `静音` the moment you change your voice language, with no settings edit. Encoding "off" as * "blank" (#385's fix 1) would have required a backfill writing today's built-ins into every * empty field, which destroys that property permanently and unrepairably: a populated field is * never resolved, so every non-English account silently loses its own words and nothing can * afterwards distinguish a deliberate English binding from a backfilled one. * * Off is a SWITCH; blank stays an absence. The two facts — "do I have custom words" and "is * this command on" — are independent, and the bug was asking one field to carry both. * * Placed on `VoiceCommandWords` rather than passed alongside it so that {@link commandPhrases} * is the single place it takes effect. `matchVoiceCommand` and `splitTrailingCommand` both * derive their phrases from there, so they CANNOT disagree about whether a command exists — and * a disagreement there is not a no-op, it amputates the phrase off the end of a real message. */ disabled?: VoiceCommand[]; } /** * Does a transcript look like a DIFFERENT language than the configured one (#126)? Judged by * script, not content: if the configured language expects Latin but the transcript is mostly * Hangul/Kana/Han, the STT mis-detected the language — the app should ask rather than respond * in it. Conservative: returns false for an unknown configured language, empty/too-short text, * or when at most half the letters are foreign (so a stray glyph never trips it). Pure + tested. */ export declare function transcriptLanguageMismatch(text: string, lang?: string): boolean; /** * Does an already-normalized transcript match a configured command phrase? * - A SINGLE-word phrase ("mute") must BE the whole utterance — precision, so "mute" never * fires inside "mute the alarm". * - A MULTI-word phrase ("mute mute", "stop listening") matches the whole utterance OR appears * as a contiguous whole-word run inside it ("okay mute mute now" → matches). Multi-word * phrases are distinctive enough that a whole-word substring match won't hijack normal speech, * and it's what makes a two-word wake/mute phrase usable in a longer utterance. * "mute mute" is ONE two-word phrase (space-preserved by the delimiter parse), so "mute" alone * — only half the phrase — does NOT match. * * `opts.whole` forces the single-word rule onto a phrase of ANY length: the utterance must BE the * phrase. Used by the destructive `scrap` command (#342), where the substring reading would fire * on "don't scrap that, keep it" — see SCRAP_BY_LANG. */ export declare function phraseMatchesTranscript(normalizedTranscript: string, phrase: string, opts?: { whole?: boolean; }): boolean; /** * Detect a hands-free voice COMMAND ("repeat" / "mute") in a transcript. A single-word command * matches only when the whole utterance IS the command (punctuation ignored), so a normal * sentence containing the word isn't hijacked; a multi-word command phrase also matches when it * appears as a whole-word run inside the utterance (see {@link phraseMatchesTranscript}). Built-in * phrasings are scoped to the agent's `lang` (the configured voice language) — an English agent * won't trigger on a Chinese phrase. Which phrases are in force for each command — including the * rule that an explicitly bound phrase outranks a built-in (#385) — is decided in ONE place, * {@link commandPhrases}, so this matcher and `splitTrailingCommand` cannot disagree about it. */ export declare function matchVoiceCommand(text: string, words?: VoiceCommandWords, lang?: string, state?: Partial): VoiceCommand | null; /** * Which kind of transcript is being judged. A PARTIAL is the recognizer's running guess at a * sentence still being spoken; a FINAL is an utterance the user finished. */ export type TranscriptKind = "partial" | "final"; /** The matching rules in force for ONE transcript — every field derived by {@link commandStateFor} * from the kind of transcript the caller holds and the state it was captured in. */ export interface CommandMatchState { muted: boolean; canSwitch: boolean; canScrap: boolean; /** May THIS transcript reverse a transfer (#279)? False on every partial — see BACK_BY_LANG. */ canBack: boolean; /** May this transcript be judged for a command at all? False for a partial captured while the * agent's own voice may be in the microphone (#386). */ judgeable: boolean; /** Every phrase must BE the whole utterance — the `scrap` rule (#342) applied to every command, * because a phrase matched as a run INSIDE this transcript may be the agent's words (#386). */ whole: boolean; } /** * The `state` argument for {@link matchVoiceCommand}, derived from the kind of transcript the * caller holds — so the one rule that depends on that kind is applied in a single place. * * `scrap` (#342) is the only DESTRUCTIVE word in the vocabulary, and it is the only one that is * unsafe to judge on a partial: "scrap that idea and let's move on" passes through the partial * "scrap that" on its way to being said, and the whole-utterance rule that makes the command * precise cannot see a whole utterance yet. Three of the five matcher call sites feed partials * (the dictation gate, the always-on control listener, the interim keyword path) and two feed * finished turns. * * That distinction used to be an OMISSION at three call sites plus a paragraph each explaining * why the flag was missing — a shape whose failure mode is a reader "fixing" the inconsistency. * Here the kind is stated at every site and a partial cannot enable the command even when the * consumer has one: the flag is dropped on the way in, not merely never passed. * * ── `echoing`: the agent's own voice (#386) * * Four paths reason about speaker→mic bleed; the ONE that runs exclusively inside the echo window — * the always-on control listener — had no guard at all, so the agent saying *"Stop me if this is * wrong"* produced a first interim of exactly `"stop"`, which whole-utterance-matched the built-in * exit word and tore hands-free down with the user silent. Same shape for *"say mute mute to * silence me"* and *"the next agent in the chain"*. * * The blunt guard the other paths use — drop everything while echoing — cannot be applied there: it * would remove mute-during-TTS, which is the single capability that listener was built for (#153), * and `isEchoing` cannot separate the agent's voice from the user's precisely in the window where * both are possible. So the bar goes UP instead of the door closing: * * - a PARTIAL is not judged at all — it is the recognizer's guess at a sentence still being * spoken, and "the whole utterance so far" is exactly what makes a bare word match a word the * agent is in the middle of saying; * - a FINAL is judged, but whole-utterance only — no run inside a longer sentence. * * A deliberate "mute mute" still fires, on the final rather than the interim. What is paid for that: * a command spoken while the agent talks lands when the recognizer closes the utterance (a beat * later, not a keystroke later), and a command buried in a longer sentence *while the agent is * speaking* no longer fires — everywhere else it still does. Single words are NOT dropped * wholesale: #377's rule stands, and swallowing a real "commit" would be the worse failure. */ export declare function commandStateFor(kind: TranscriptKind, ctx: { muted?: boolean; canSwitch?: boolean; canScrap?: boolean; canBack?: boolean; echoing?: boolean; }): CommandMatchState; /** * Does this transcript carry the configured STOP-SPEECH keyword while the agent is actually * talking (#153)? Substring + case-insensitive, unlike every other command here: it interrupts * playback, so hearing it inside a longer sentence is the point. * * Gated on the agent SPEAKING, which is what contains a false match — there is nothing to * interrupt otherwise, and the two call sites (the control listener and the main result path) * both check it BEFORE the echo guard would drop the result as the agent's own voice. * * An empty or whitespace-only keyword means the feature is OFF. It used to mean "match any text * containing a space", because the emptiness test ran on the un-trimmed setting. */ export declare function matchesStopSpeech(s: { keyword: string | undefined; text: string; ttsSpeaking: boolean; }): boolean; /** * Should the ALWAYS-ON background control-word listener be running right now (#153)? It's a * lightweight dictation loop, SEPARATE from the main recording pipeline, whose only job is to * catch a control word (mute / stop) at ANY moment — while TTS plays, while the agent is * processing, while the mic is muted. It runs whenever voice is engaged BUT yields while the * MAIN recorder is actively capturing a user turn (the main transcription path already checks * control words then, and two concurrent recognizers on the same browser API fight). So: * engaged (voice mode, not plain text) AND NOT the main mic actively recording. */ export declare function shouldRunControlListener(s: { engaged: boolean; mainRecording: boolean; }): boolean; /** * Should a live transcript chunk be scanned for control words (#228)? * * The control listener above yields whenever the main mic is capturing, which left a hole: it * assumed the main path checks control words during capture, and that is only true for browser * DICTATION (which emits interim results live). The Whisper/OpenAI path records with * MediaRecorder and produces nothing until the clip is uploaded — so for the whole recording * window nobody was checking, and "mute" said mid-turn did nothing until end-of-turn (or never, * if it wasn't the entire utterance). * * In that mode the speech GATE is already running a browser recognizer over the same audio for * noise filtering, and already has the words. This is the predicate for using them: scan the * gate's transcript exactly when the main path can't do it itself. * * `paused` covers the reply-in-flight window (the gate keeps running across it) and `echoing` * the TTS tail, so the agent's own voice can't issue commands to it. */ export declare function shouldScanGateTranscript(s: { commandsEnabled: boolean; mainUsesBrowserSpeech: boolean; paused: boolean; echoing: boolean; }): boolean; /** * Detect a trailing STOP-WORD ("...do the thing, copy") — the user's spoken "I'm done, * process it now" marker. Returns `ended` (a stop-word closed the turn) and `text` with * the stop-word removed. A stop-word ALONE ("copy") → `{ ended: true, text: "" }` (end * the turn, nothing to send). Off unless `stopWords` is configured — the word is usually * a normal word, so it must be opt-in to avoid hijacking ordinary speech. */ export declare function stripStopWord(text: string, stopWords?: string[]): { ended: boolean; text: string; }; /** Every command the matcher knows, derived from the phrase tables so a new one cannot be * forgotten by a validator. Exported for the settings sanitizers (#443). */ export declare const ALL_VOICE_COMMANDS: readonly VoiceCommand[]; /** * The phrase list actually in force for one command — THE precedence rule, in one place (#385). * * **An explicit binding always outranks a built-in.** In two directions, and the second is the one * that was missing: * * 1. Words configured FOR this command replace the built-ins for it (unchanged). * 2. A phrase the user bound to a DIFFERENT action is removed from this command's built-ins. * * Rule (2) is the reported bug. An account with `stopSpeechKeyword: "stop stop"` and an empty * `exitWords` got both meanings for one phrase, chosen by whether TTS happened to be playing at * that instant: speaking → stop the speech (what they configured), silent → fall through to the * built-in English exit list, which contains `"stop"` and `"stop stop"` (#331), and hands-free was * torn down. The destructive reading was the one they never chose, and there was no way to say so — * the only lever was to bind `exitWords` to some other phrase, i.e. keep the feature and move it. * * ── AND THE THIRD RULE: A DISABLED COMMAND HAS NO PHRASES AT ALL (#443) * * `words.disabled` is checked FIRST and returns `[]`, which is what switches the command off * everywhere at once. Every caller — the matcher, the whole-utterance branch of the splitter, its * multi-word branch and its repeated-run branch — asks this function for a list, so one empty * answer is the whole feature and the two sides cannot disagree about whether a command exists. * * BLANK STILL MEANS "USE OURS", and it must. An earlier reading of #385 proposed making blank mean * OFF, backfilled with today's built-ins. That was measured to be the wrong primitive and is not * implemented: built-ins are resolved HERE, at match time, against the caller's current `lang`, so * a backfill would freeze language-derived data into user config permanently — `custom?.length` * short-circuits, so a populated field is never resolved again. Every non-English account would * silently lose its own words, every later language switch would get English ones, and afterwards * nothing could distinguish a deliberate binding from a backfilled one. "Do I have custom words" * and "is this command on" are two independent facts; blank carries the first, `disabled` the * second. See {@link VoiceCommandWords.disabled}. * * A disabled command KEEPS its reservations, deliberately. Switching `exit` off does not unbind * `exitWords`, so a phrase the user typed there stays inert rather than silently becoming some * other command — turning a feature off must not hand your words to a different action. * * Exported so callers can strip a spoken command out of a message without re-deriving any of it. */ export declare function commandPhrases(command: VoiceCommand, words?: VoiceCommandWords, lang?: string): string[]; /** * Split a spoken turn into "the command at the end" + "what the user actually said". * * A control word spoken at the END of a turn ("run the tests, mute") is a control word AND a * finished message — the same contract stop-words already have. Treating it as command-only * threw the message away: the user dictated a request, asked for silence, and the request * vanished. Treating it as message-only left them talking to a mic that never muted. * * Returns the command (if any) and the message with the command phrase removed. A turn that * IS the command yields an empty message, so nothing is sent. * * `scrap` (#342) is deliberately ABSENT from the candidate list. It matches whole-utterance only, * so there is never a message half to keep — and the trailing form is precisely the dangerous one: * "let's rewrite the parser, scrap that" would delete the previous exchange AND send a truncated * request. Callers check for it separately, on a final turn, before reaching here. * * `back` (#279) is absent for the same reason and one more: "ask it about the parser, then take me * back" is a sentence about a plan, not two instructions, and the trailing form cannot tell the * difference. Whole-utterance only, judged on the final — see BACK_BY_LANG. */ export declare function splitTrailingCommand(text: string, words?: VoiceCommandWords, lang?: string, state?: { muted?: boolean; canSwitch?: boolean; fired?: VoiceCommand | null; }): CommandSplit; /** * Classify a browser SpeechRecognition / STT error (issue: voice error-log spam). * - "soft" → no-speech / empty: not an error, just recycle the mic. * - "mic-unavailable"→ permission denied or no capture device. A user-ENVIRONMENT * state, not a platform bug: stop the loop (don't retry into a dead mic) and show a * clear hint — do NOT flood the durable error log. * - "error" → a genuine failure (Whisper 400/401, etc.) worth reporting. */ export type VoiceErrorKind = "soft" | "mic-unavailable" | "error"; export declare function classifyVoiceError(err: string | null | undefined): VoiceErrorKind; /** * Should a failed turn offer **Retry** (#421)? * * The user's ask was "see the message and know what to do — retry now or later", and the two cases * need different answers because the right action differs. A timeout, a 5xx, a platform deploy or a * dropped stream will very likely succeed on a second attempt with the SAME clip, which is still in * hand. A 400 or 401 will fail again identically — the key is wrong, or the audio is unusable — so * a Retry button there is a dead affordance that costs the user's own OpenAI credit to discover. * * Judged on the message rather than a code because the message is what the bubble carries, and it * is the only thing that survives into the durable log. Conservative in the safe direction: an * unrecognised failure gets no Retry, so a new error shape cannot silently start charging people * for a retry that was never going to work. */ export declare function isRetryableVoiceError(err: string | null | undefined): boolean; /** * Is this Web Speech error a PERMISSION VERDICT — i.e. terminal until the user changes something * in their browser (#425)? * * PAGS runs THREE recognizers over one microphone: the main one, the dictation gate, and the * always-on control listener. The last two re-arm themselves on every `onend`, and Chrome ends a * continuous recognizer after a short silence — so while voice is engaged they are a perpetual * start → end → start cycle, and Chrome activates the mic on every `start()`. Against a grant that * is not persistent ("Allow this time"), that is one permission prompt per RESTART, which is the * shape of "it asks me all the time". Re-arming into a denied device is what turns one denial into * an endless loop, so a denial has to stop the loop. * * Deliberately NARROW. `no-speech` and `aborted` are the ordinary churn those handlers exist to * ignore, and `audio-capture` can be produced by two recognizers contending for one device — a * transient that must NOT permanently disable the control listener, because doing so would silently * remove mute-by-voice and cause the exact ADR 0001 failure this ticket is meant to expose. These * two codes cannot be caused by contention; they are the browser saying no. */ export declare function isMicPermissionDenied(code: string | null | undefined): boolean; /** * Is this worth a durable row from a BACKGROUND recognizer (#425)? * * Wider than the above, because the question is different: a missing device is diagnostic even * though it is not a permission decision. Both background recognizers swallowed everything — * `onError: () => {}` with a comment naming "mic denied" as an expected case, and `r.onerror = () => {}` — * so a control listener dying took mute-by-voice with it and left no evidence anywhere. The * production log's 18 mic failures were all from the MAIN recognizer, which is the only one that * could report; "no errors from the other two" was never evidence that they worked. */ export declare function isReportableMicError(code: string | null | undefined): boolean; /** Human hint for a mic-unavailable error code. */ export declare function micUnavailableMessage(err: string): string; /** * Translate a `getUserMedia` failure into the SAME error vocabulary `classifyVoiceError` already * speaks, so ONE classifier covers both ways the mic can refuse to open (#284). * * The two APIs report the same conditions under different names: Web Speech says `"not-allowed"` * / `"audio-capture"`, `getUserMedia` throws a DOMException named `"NotAllowedError"` / * `"NotFoundError"`. `classifyVoiceError` only ever knew the first set, so a denied mic on the * hands-free START path fell through to the generic `"error"` branch — which is why that path * "does not reach" the existing helpers and reverted in silence instead of saying why. * * `NotReadableError` (the device is held by another tab or app) has no Web Speech equivalent at * all, hence the new `audio-busy` code: it is the case a user is least able to guess. */ export declare function normalizeMediaError(err: unknown): string; //# sourceMappingURL=convo.d.ts.map