Skip to content

feat(desktop): hands-free wake word to command the assistant during ambient listening - #11801

Open
aryanorastar wants to merge 40 commits into
BasedHardware:mainfrom
aryanorastar:feat/wake-word
Open

feat(desktop): hands-free wake word to command the assistant during ambient listening#11801
aryanorastar wants to merge 40 commits into
BasedHardware:mainfrom
aryanorastar:feat/wake-word

Conversation

@aryanorastar

@aryanorastar aryanorastar commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

Summary

Status note. #12181 (pause endpointing) has merged, so the first of the two always-on
surfaces is off this PR. Echo suppression stays, and is load-bearing rather than riding along —
c5f95d9 removes the wake word's own "assistant is speaking" guard precisely because
VoicePlaybackEchoPolicy.classify runs ahead of WakeWordService.observe.

Adds an opt-in Wake Word feature for macOS desktop: during ambient listening, say "Omi" followed by a command and the assistant runs it hands-free — no clicks, no Push-to-Talk.

What changed

  • Sources/WakeWord/WakeWordService.swift (new) — trigger engine: strips the wake phrase, extracts the command, and submits it to the assistant (openAIInputWithQuery). Guards: 30s cooldown against rapid repeats, per-segment-ID deduplication, user-speech only, and suppression while the assistant is already busy.
  • Sources/WakeWord/WakeWordSegmentParser.swift (new) — splits "Omi, do X" into wake phrase + command; requires a 2+ word command so a bare "Omi" never misfires.
  • AppState+ListenEvents.swift — feeds every incoming transcript segment into WakeWordService.observe(...), the single funnel for ambient speech.
  • AssistantSettings.swift — persisted wakeWordEnabled / wakeWordPhrase / wakeWordCooldown (default off so current users' behavior is unchanged).
  • SettingsContentView+General.swift — new Wake Word settings card with on/off toggle and a dynamic subtitle tied to the audio-recording mode ("Listens in the background during meetings and calls").
  • TestsWakeWordServiceTests + WakeWordSegmentParserTests (15 cases).

How it works

ambient transcript segment ("Omi, order asian food")
  → WakeWordService.observe
  → WakeWordSegmentParser extracts wake phrase + command
  → gated: enabled? busy? user speech? cooldown? dedup?
  → onTrigger("order asian food") → submitted to assistant

Verification

  • xcrun swift test --package-path Desktop --filter WakeWord15 tests, 0 failures.
  • Exercised end-to-end in a running named bundle via the hermetic capture seam (drives the real handleBackendSegmentsWakeWordService path):
WakeWord: submitting 'order asian food' to the assistant   ← wake phrase stripped
Transcript [ADD]: Omi, order asian food                    ← raw transcript line
Chat telemetry: chat_agent_query_started surface=floating_text
APIClient: POST https://api.omiapi.com/v2/desktop/messages ← real query submitted

Negative control: injecting "I was just saying the weather is nice today" produced no trigger (no wake phrase).

  • Changelog fragment added (changelog/unreleased/20260818-wake-word-trigger.json). Config rachets (check_desktop_test_quality.py, swift-format, SwiftLint) pass.

Follow-up (out of scope, separate issue)

While validating the demo bundle we hit a pre-existing macOS debug-build crash at startup in DesktopHomeView.restorePersistedCaptureServices → startTranscription (over-release, unaffected by this diff — reproduces with the wake word disabled and only manifests once mic permission is granted). Filed separately; does not block this feature.

Screenshots

New — wake word enabled Old — before (no wake word)
Wake word enabled Before

Product invariants affected

  • INV-CHAT-1
  • INV-VOICE-1

Failure class (fixes)

Failure-Class: none

Line-count exception

openAIInputWithQuery and sendFollowUpQuery gained the turn-optional guard that lets a
wake word — which owns no VoiceTurnID — reach the visible surface as a voice query, so its
answer is spoken and a second command continues the conversation. Both are existing
functions in this file; splitting it is unrelated refactoring for a 16-line change.

Line-Count-Exception: desktop/macos/Desktop/Sources/FloatingControlBar/FloatingControlBarWindow.swift | 5411 -> 5483 | wake-word spoken answers and follow-up continuity route through the two existing dispatch entry points

Line-Count-Exception: desktop/macos/Desktop/Sources/FloatingControlBar/RealtimeHubController+SessionDelegate.swift | 1499 -> 1504 | Five lines recording session-spoken text into the playback history the echo policy reads; it belongs at the delegate that receives the text, and the surrounding turn-event guards are private to this file.

Line-Count-Exception: desktop/macos/Desktop/Sources/FloatingControlBar/RealtimeHubController.swift | 1540 -> 1622 | runWakeWordTurn sits beside runHeadlessPTTTurn because it shares that method's turn lifecycle exactly — mint an automation turn, select the hub route, open the input window, commit — and the two must stay in step; a sibling file would duplicate the ordering constraints rather than share them.

Line-Count-Exception: desktop/macos/Desktop/Sources/AppState/AppState+Transcription.swift | 1744 -> 1745 | One argument line at the single beginRecording call site, telling the STT lane resolver whether the wake word needs a recognizer that can be told its name; the flag combination itself lives on AssistantSettings, not here.

Line-Count-Exception: desktop/macos/Desktop/Sources/FloatingControlBar/RealtimeHubSession.swift | 1661 -> 1669 | sendSpokenCommand is an eight-line sibling of sendTestTextInput sharing the same private buffering path; the wire form and buffer are private to this file.

…mbient listening

Add an opt-in Wake Word feature: say "Omi" followed by a command while
ambient listening is active and the assistant runs the command hands-free.

- WakeWordService: parses and gates wake-word triggers (cooldown, segment
  dedup, user-speech and busy-conversation guards) and submits the stripped
  command to the assistant
- WakeWordSegmentParser: extracts wake phrase + 2-word-minimum command
- Wire incoming transcript segments from AppState+ListenEvents
- Settings > General: opt-in Wake Word toggle with dynamic subtitle
- AssistantSettings: persisted wakeWordEnabled/Phrase/Cooldown (default off)
- Tests: WakeWordServiceTests + WakeWordSegmentParserTests (15 cases green)
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

… e2e flow coverage

Fix the two Desktop Swift CI failures:
- desktop-e2e-flow-coverage: cover WakeWordService.swift and
  WakeWordSegmentParser.swift under capture-lifecycle.yaml (the
  capture_test_transcript seam drives the same AppState+ListenEvents
  funnel the wake word observes).
- desktop-swift-format-lint: add missing trailing newlines to the four
  WakeWord source/test files per pinned swift-format 602.0.0.

Verified: run_checks.py macos lane passes e2e-flow-coverage,
swift-format-lint, and swiftlint; WakeWord 15 tests pass
(WakeWordSegmentParserTests 7 + WakeWordServiceTests 8).
@aryanorastar

Copy link
Copy Markdown
Contributor Author

Two CI failures fixed and pushed (0eca6df):

  1. Desktop Swift Static & Test Contracts — desktop-e2e-flow-coverage: the two new WakeWord sources had no covers: entry. Added WakeWordService.swift + WakeWordSegmentParser.swift to e2e/flows/capture-lifecycle.yaml, which is the correct home — the capture_test_transcript seam drives the same AppState+ListenEventshandleBackendSegments funnel the wake word observes (already covered that funnel file).

  2. Desktop Swift Static & Test Contracts — desktop-swift-format-lint (also surfaced during local pre-flight): four WakeWord files (2 sources + 2 tests) were missing the trailing newline per pinned swift-format 602.0.0. Formatted with the pinned wrapper — the only change is EOF newlines.

Local verification before push:

  • run_checks.py macos lane: e2e-flow-coverage PASS, swift-format-lint PASS, swiftlint PASS
  • check_desktop_test_quality.py: OK (no drift)
  • swift test --filter WakeWordSegmentParserTests|WakeWordServiceTests: 15/15 pass (7 parser + 8 service)

Waiting on CI to re-run — should flip green.

@Git-on-my-level

Copy link
Copy Markdown
Collaborator

Thanks @aryanorastar — nicely built feature: opt-in and default-off, clean parser/service split, injectable clock and trigger for testing, plus changelog and e2e contract coverage. I walked all nine files; notes below.

Code walkthrough

  • Sources/WakeWord/WakeWordSegmentParser.swift — prefix + word-boundary matching is done carefully, and testIgnoresSegmentsWithoutWakeWord rejecting "Omiway" is a good touch. Minor note: the command extraction (lines 11–15) offsets into the raw string by the normalized candidate's count; that's safe for ordinary casing since lowercased() preserves grapheme-cluster counts, just worth knowing if the phrase ever gains case-folding edge cases.
  • Sources/WakeWord/WakeWordService.swift — the guard stack (enabled → conversation-active → isUser → segmentId dedup → parse → 2-word minimum → cooldown) is in the right order, the 100-entry firedSegmentIDs cap bounds memory, and the injectable now/onTrigger let the service be tested without the floating bar.
  • AppState+ListenEvents.swift — the funnel at line 32 runs before the upsert, so both new and updated segments are observed; dedup by segmentId then correctly prevents double-firing when a segment grows.
  • AssistantSettings.swift — keys registered via register(defaults:) with sane values (off / "Omi" / 30s), phrase getter falls back to the default when blank, cooldown getter rejects non-positive stored values. Consistent with the surrounding settings style.
  • SettingsContentView+General.swift — the subtitle that adapts to audioRecordingMode is thoughtful copy; the toggle stays enabled even when recording is off (with the subtitle explaining), which reads as a deliberate choice.
  • WakeWordSegmentParserTests.swift / WakeWordServiceTests.swift — 15 cases covering the parser variants and every service guard, including the cooldown boundary at 31s.
  • changelog/unreleased/20260818-wake-word-trigger.json — matches shipped behavior (opt-in, during ambient listening).
  • e2e/flows/capture-lifecycle.yaml — adding both new files to covers keeps the capture-lifecycle contract honest.

Product questions before merge

  1. Transcript-based triggering: because detection runs on transcript segments, a user talking about the product ("Omi is a great product") parses to command "is a great product" and auto-sends a query. The 30s cooldown and 2-word minimum soften this but don't remove it. Is detection-on-transcript the intended v1 UX versus keyword spotting?
  2. onTrigger submits via openAIInputWithQuery(command, fromVoice: false), which auto-sends and presents the answer visually in the floating bar (brought to front). So "hands-free" covers issuing the command, not receiving the answer — is that the intended interaction, or should this ride the voice-turn path?
  3. minimumCommandWords = 2 means single-word commands ("Omi, stop") can't fire — deliberate?

No blocking code issues found; desktop CI is green including the new tests and e2e t0. Leaving the wake-word semantics and auto-send UX for human maintainer review before merge.


by AI on behalf of David — if you need David’s attention urgently, please @Git-on-my-level and escalate with need human response.

@Git-on-my-level Git-on-my-level added needs-maintainer-review Needs a human maintainer to sign off before merge positive-signal Good PR — positive signal, not a formal approval macOS labels Aug 18, 2026
@aryanorastar

aryanorastar commented Aug 18, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for the thorough review and kind words @Git-on-my-level!

Here is the context and rationale behind the three product questions:

  1. Transcript-based vs. Keyword Spotting (v1 approach):

    • Rationale: Running detection on the active transcript stream allows hands-free triggering with zero runtime overhead or binary bloat (no heavy neural KWS models/weights bundled).
    • Because ambient listening is already active, this gives instant v1 wake-word capability. For future iterations, we can layer on an acoustic KWS engine or verb-intent classifier if needed.
  2. Visual Floating Bar vs. Voice Output Delivery:

    • openAIInputWithQuery(command, fromVoice: false) was chosen to give immediate visual feedback and bring up the conversation card.
    • When paired with our companion PR feat(desktop): halt voice playback on user speech barge-in #11809 (feat(desktop): halt voice playback on user speech barge-in) and user TTS settings, speech output seamlessly speaks the answer for a fully eyes-free loop.
  3. Minimum Command Words (2 words vs single words like "stop"):

    • The 2-word minimum ("order food", "what's my schedule") prevents accidental single-word triggers like "Omi hey".
    • If desired, we can add a targeted whitelist for control commands (e.g., ["stop", "cancel", "mute", "quiet"]) that are permitted to fire with 1 word, while keeping the 2-word minimum for open queries.

Happy to follow the maintainers' preference on any follow-up adjustments! Ready for final merge.

The wake word only matched the literal string "omi", but speech-to-text
spells the phrase by sound. A live mic session transcribed "Omi, how are
you?" as "Oh me, how are you?" (Parakeet v3, conf=0.92) and the wake word
silently never fired -- the recognizer heard the user correctly and the
parser rejected it.

Accept the renderings recognizers actually emit ("oh me", "omni", "ohmi",
"oh mi", "omee", "o me", "oh-me") as the same phrase, expanded through the
existing greeting prefixes so "hey oh me, ..." works too. The downstream
guards (user speech only, 2+ word command, cooldown, segment dedup) still
bound the false-positive cost of the wider match.

The existing unit tests passed before and after this change because they
fed the parser the string "Omi" -- which is exactly what the microphone
never produces.

Verification
- swift test --filter WakeWordSegmentParserTests -> 10 passed
- Added the exact failing string from the live session as a regression test,
  plus negatives proving the wider match does not swallow ordinary speech
  ("Omnibus schedule changed" and a bare "Oh me" still do not fire).
Three defects found by running the feature against a live ambient session
rather than constructed segments. Each failed silently, so a wake word that
never fired was indistinguishable from one that was never spoken.

1. Diarization attribution. The trigger required `segment.isUser`, but the
   backend only sets `is_user` once a speech profile is enrolled. A live
   session logged `Speaker 0: "Omi, what's the weather?"` with is_user=false,
   so the wake word was structurally dead for every user without an enrolled
   profile. VoiceBargeInPolicy already gates on `isUser || speaker == 0` and
   documents speaker 0 as the primary user; the two entry points disagreed
   about what "the user" means. Align on the sibling's contract.

2. Segment dedup. The backend re-delivers one growing segment under a single
   id (observed: [206.0s-217.1s] -> [206.0s-228.9s]). Deduping on the id alone
   dropped every later command that landed inside an id that had already
   fired. Key on the id plus the extracted command so a re-sent segment is
   still suppressed but a new instruction inside it runs.

3. Cooldown. The cooldown exists to swallow a rapid repeat of the same
   utterance, but it gated on elapsed time alone. The ambient transcript lane
   runs ~35s behind live speech (measured over 11 segments, 34.3-36.6s), so
   several genuinely distinct commands routinely arrive inside one 30s window
   and were discarded as "repeats" -- two consecutive "what time is it"
   attempts were both lost this way. Gate on a repeat of the same command.

Also name the reason a segment was ignored. Every guard returned silently;
the diagnostics are what located defects 1 and 2, and the demo runbook
depends on the log explaining a beat that did not fire. Only segments that
actually carry the wake phrase are reported, so ordinary speech stays quiet.

Verification
- swift test --filter WakeWordServiceTests -> 11 passed
- Regression tests added for each defect, carrying the live values that
  exposed them (speaker 0 with is_user=false, a reused segment id, a distinct
  command inside the cooldown).
- Confirmed live after the fix: `WakeWord: submitting 'can you order food for
  me?' to the assistant`, and the re-delivered segment correctly suppressed.

Honest gaps
- The ~35s ambient latency is server-side (client ships audio every 100ms via
  audioBufferSize=3200); it is not addressed here and needs the realtime lane.
- Not verified against an account with an enrolled speech profile.
The wake word dispatched with `fromVoice: false`, which is the flag that
decides whether the assistant speaks its answer or only renders text. A wake
word is a hands-free entry point by definition -- the user's hands and eyes
are elsewhere -- so a silent reply strands the interaction the feature exists
to enable. Observed live: the trigger fired and answered correctly, and the
user heard nothing.

PushToTalkManager already passes `fromVoice: true` for the same reason.

Verification
- swift build -> clean
- swift test --filter 'WakeWordServiceTests|WakeWordSegmentParserTests' -> 21 passed

Honest gaps
- Voice follow-up is still unavailable from the wake word. `sendFollowUpQuery`
  exists and PushToTalkManager uses it with a voiceTurnID, but the wake word
  always opens a fresh query, and `guard !isConversationActive` suppresses the
  trigger while a turn is live. Wiring multi-turn to the wake word needs the
  realtime lane, not the ~35s ambient lane; tracked separately.
@aryanorastar

aryanorastar commented Aug 19, 2026

Copy link
Copy Markdown
Contributor Author

I took this branch out of unit tests and ran it against a live ambient session on macOS. It did not work, and the reason it did not work was never visible: every guard in WakeWordService.observe returned silently, so a wake word that never fired looked identical to one that was never spoken.

Four defects, all found by running it. The unit tests passed before and after every one of them, because they construct segments the backend never actually sends.

1. Diarization attribution — the feature was dead on arrival

The trigger required segment.isUser. The backend only sets is_user once a speech profile is enrolled, so on an account without one it is false for every segment the user speaks:

Transcript [ADD] Speaker 0 [-0.0s-1.7s]: Omi, what's the weather?
WakeWord: ignored — segment not attributed to the user (speaker 0)

Speech-to-text heard the phrase perfectly. The wake word could not fire, and could not fire for any new user.

VoiceBargeInPolicy in the sibling PR already answers this — it gates on isUser || speaker == 0 and documents speaker 0 as the primary user. Two entry points in the same feature set disagreed about what "the user" means, and the stricter one silently won. Aligned the wake word on the sibling's contract.

2. Speech-to-text spells the wake phrase by sound

The parser matched the literal string omi only. "Omi" is acoustically "oh-mee", and recognizers render it accordingly:

LocalTranscriptionService[mic]: 10.0s rms=0.0129 conf=0.92 → Oh me, how are you?

The recognizer was confident and correct about what it heard. Added the renderings recognizers actually emit, expanded through the existing greeting prefixes. Negative tests cover the obvious risk — "Omnibus schedule changed" and a bare "Oh me" with no command still do not fire.

3. Segment dedup dropped later commands

The backend re-delivers one growing segment under a single id:

[206.0s-217.1s] → [206.0s-220.7s] → [206.0s-224.2s] → [206.0s-228.9s]

firedSegmentIDs deduped on the id for the life of the process, so any command that landed inside an id which had already fired was discarded. Keyed on the id plus the extracted command instead: a re-sent segment is still suppressed, a new instruction inside it runs.

4. The cooldown ate distinct commands

The cooldown is there to swallow a rapid repeat of the same utterance, but it gated on elapsed time alone. The ambient transcript lane runs well behind live speech, so several genuinely different commands arrive bunched inside one 30s window. Two consecutive "what time is it" attempts were both lost this way:

10:29:23  submitting 'how are you?'
10:29:23  ignored — cooldown — 0s since last trigger
10:29:34  ignored — cooldown — 11s since last trigger
10:29:47  ignored — cooldown — 24s since last trigger

Now gates on a repeat of the same command. A repeat inside 30s is still suppressed.

5. Replies were silent

Dispatch used fromVoice: false, which is the flag deciding whether the assistant speaks its answer or only renders text. A wake word is a hands-free entry point — hands and eyes are elsewhere — so a silent reply strands the interaction the feature exists to enable. PushToTalkManager already passes true. Confirmed live: the trigger fired, answered correctly, and I heard nothing.

Diagnostics

Every rejection now names itself. Only segments that actually carry the wake phrase are reported, so ordinary speech stays quiet. These log lines are what located defects 1 and 3, and they are the difference between "it doesn't work" and a diagnosis.

Verification

  • swift test --filter 'WakeWordServiceTests|WakeWordSegmentParserTests' → 21 passed
  • Regression tests carry the live values that exposed each defect: speaker 0 with is_user=false, the reused segment id, a distinct command inside the cooldown, and the exact "Oh me, how are you?" transcript.
  • Confirmed live after the fixes, against the dev serving plane: WakeWord: submitting 'can you order food for me?' to the assistant, with the re-delivered segment correctly suppressed on the following turn.

Honest gaps

  • Ambient latency is not addressed here and is the biggest remaining problem. Measured ~35s between speaking and the transcript arriving, near-constant across 11 segments (34.3–36.6s). The client ships audio every 100ms (audioBufferSize = 3200), so this is server-side: the socket opens with conversation_role=ambient, a lane that batches. Push-to-talk feels instant because it uses the realtime path instead. A wake word answering 35 seconds later is not usable command-and-control, and I do not think this branch can fix it — it needs the realtime lane. Happy to open that separately if you want it pursued.
  • Voice follow-up is still unavailable from the wake word. sendFollowUpQuery exists and push-to-talk uses it with a voiceTurnID, but the wake word always opens a fresh query, and guard !isConversationActive suppresses the trigger while a turn is live. Answering a question by voice does not work today. Same dependency on the realtime lane.
  • Not verified on an account with an enrolled speech profile — I do not have one, which is how defect 1 surfaced.
  • No on-device/pendant verification; this is desktop mic + screen only.
  • Homophone coverage is a fixed list, not phonetic matching. It covers what I observed; a recognizer that renders the phrase some other way will still miss.

On-device recognition has no keyword list, so it fronts the vowel with an
aspirate. Observed live from one speaker in a single session:

    "Homi what's the weather? outworking."
    "Homie, can you order food for me? Street drive to children dance."

Adds "homi" and "hommi". Deliberately excludes "homie": it is an ordinary
English word, and accepting it as the wake phrase would fire on real speech.

Scope note: this list is a safety net, not the fix. `TranscriptionService`
already seeds ["Omi", "OMI"] into the STT keyword boost, and on the cloud lane
the phrase transcribes exactly every time. These misses only occur on the
on-device lane, which takes no keyword list -- the durable fix is keyword
boosting on the recognizer, not a longer list here.

Verification
- swift test --filter WakeWordSegmentParserTests -> 10 passed
A backend segment is re-delivered as it grows, in place and under one id. The
previous dedup keyed on the exact extracted command, so every growth counted as
a new instruction and fired again with a longer string. Worse, the assistant's
own spoken reply is captured by the microphone and appended to the same
segment, so each re-fire submitted a more polluted command. Observed live:

    12:41:32  submitting 'what time it is? You speak English. Got it.'
    12:41:33  submitting 'what time it is? You speak English. Got it. Handed you this'
    12:41:39  Transcript [ADD] Speaker 1: Handed you this An agent is getting started on that.

The query actually dispatched was the polluted string, which is not what the
user asked and cannot be answered usefully.

Deduping on the id alone is also wrong -- it drops a genuinely new instruction
that lands in a reused id. Treat a command that extends one already fired for
that segment as the same instruction, and anything else as new.

Verification
- swift test --filter WakeWordServiceTests -> 12 passed
- Regression test carries the live growth case.
- Confirmed live: one clean `submitting 'what time it is?'`, and the following
  re-delivery correctly `ignored — already fired`.

Honest gaps
- The microphone capturing the assistant's own speech is a separate defect and
  is not addressed here; this change only stops it corrupting the command.
An earlier commit on this branch switched the wake word to
`openAIInputWithQuery(command, fromVoice: true)` so the assistant would speak
its answer. That silently disabled the feature end to end.

`openAIInputWithQuery` gates the voice path on a turn it does not mint:

    if fromVoice {
      guard let voiceTurnID,
        VoiceTurnCoordinator.shared.requireCurrentOwner(for: voiceTurnID) != nil
      else { return }

`fromVoice: true` with no `voiceTurnID` fails that guard and returns with no log
and no user-visible effect. Every wake word therefore logged `submitting` and
dispatched nothing -- the trigger looked healthy in the log while the assistant
was never invoked.

`fromVoice: true` belongs to callers that own a voice-turn lifecycle:
PushToTalkManager begins a turn and passes both arguments. The wake word submits
an already-transcribed command and owns no turn, exactly like
DesktopAutomationBridge, which passes `fromVoice: false` for the same reason.

Verification
- swift build -> clean
- swift test --filter 'WakeWordServiceTests|WakeWordSegmentParserTests' -> 22 passed
- Confirmed live: `WakeWord: submitting 'what time is it?'` followed 1.3s later by
  `WakeWord: ignored — assistant already busy`, i.e. the turn actually engaged.
  With `fromVoice: true` that second line never appeared on any attempt.

Honest gaps
- The reply is no longer spoken aloud; it renders in the floating bar. Restoring
  spoken replies requires minting a VoiceTurnID via
  `VoiceTurnCoordinator.begin(intent:)` before dispatch and threading it through,
  which is a separate change and is not attempted here.
@aryanorastar

Copy link
Copy Markdown
Contributor Author

Update after taking this branch out of unit tests and running it as a user on macOS for an afternoon. Three more defects, one of which was mine from the previous round, plus two honest limits I could not resolve.

Correction to my previous comment

The commit I pushed to make the assistant speak its reply (fromVoice: true) silently disabled the feature end to end. openAIInputWithQuery gates the voice path on a turn the wake word does not mint:

if fromVoice {
  guard let voiceTurnID,
    VoiceTurnCoordinator.shared.requireCurrentOwner(for: voiceTurnID) != nil
  else { return }

With no voiceTurnID that guard returns with no log and no effect. Every wake word logged submitting and dispatched nothing — the trigger looked healthy while the assistant was never invoked. I spent hours diagnosing microphones, accents, agent VMs and the network before finding it in my own diff.

fromVoice: true belongs to callers that own a voice-turn lifecycle. PushToTalkManager begins a turn and passes both arguments. The wake word submits an already-transcribed command and owns no turn — exactly like DesktopAutomationBridge, which passes fromVoice: false for the same reason. Reverted.

Live proof of the difference:

14:48:50  WakeWord: submitting 'what time is it?' to the assistant
14:48:52  WakeWord: ignored — assistant already busy

That second line means the turn actually engaged. It never appeared once on any attempt while fromVoice: true was in place.

Growing segments re-fired the wake word

A backend segment is re-delivered as it grows, in place, under one id. My earlier dedup keyed on the exact command, so every growth counted as a new instruction and fired again with a longer string. The assistant's own spoken reply is captured by the microphone and appended to that same segment, so each re-fire submitted a more polluted command:

12:41:32  submitting 'what time it is? You speak English. Got it.'
12:41:33  submitting 'what time it is? You speak English. Got it. Handed you this'
12:41:39  Transcript [ADD] Speaker 1: Handed you this An agent is getting started on that.

The query actually dispatched was the polluted string. Now a command that extends one already fired for that segment is treated as the same instruction; anything else is new.

Aspirated renderings

On-device recognition takes no keyword list and fronts the vowel with an aspirate — "Homi what's the weather?", "Homie, can you order food for me?". Added homi/hommi. Deliberately excluded homie: it is an ordinary English word and would fire on real speech.

This list is a safety net, not the fix. TranscriptionService already seeds ["Omi", "OMI"] into the STT keyword boost and on the cloud lane the phrase transcribes exactly every time. The misses only occur on the on-device lane, which cannot take the boost.

Verification

  • swift test --filter 'WakeWordServiceTests|WakeWordSegmentParserTests' → 22 passed
  • Regression tests carry the live values that exposed each defect.
  • Confirmed working end to end on the dev serving plane: wake word fires with a clean command, dispatches, the assistant answers, and the re-delivered segment is correctly suppressed.

Honest gaps — the two that matter most

1. Ambient latency, ~15–35s. Measured across 11 segments at 34.3–36.6s in one session and ~25s in another. The client ships audio every 100ms (audioBufferSize = 3200), so this is server-side: the socket opens with conversation_role=ambient, a lane that batches. Push-to-talk feels instant because it uses the realtime path instead.

A wake word answering 25 seconds later is not usable command-and-control, and I do not believe this branch can fix it. I think this needs the realtime lane, or a dedicated on-device keyword spotter running on raw audio rather than riding the ambient transcript. Happy to pursue either if you want it — it is a design decision above my pay grade to make unilaterally.

2. "Works once, then not again." Reproducible in my hands but I have not isolated the mechanism, so I am reporting it rather than guessing. Candidates I could not separate:

  • guard !isConversationActive suppresses while a turn is live. Observed blocking once and clearing within 10s, so "permanently stuck" is not supported by my logs.
  • The 30s cooldown correctly suppresses a repeat of the same command, which is indistinguishable from a failure to a user who repeats themselves because nothing happened.
  • The latency above means the second answer may simply not have arrived yet.

All three would present identically to a user. I would rather flag this than close it with a plausible story.

Also unresolved:

  • The reply is no longer spoken aloud; it renders in the floating bar. Restoring speech means minting a VoiceTurnID via VoiceTurnCoordinator.begin(intent:) and threading it through dispatch — a real change, not attempted here.
  • Voice follow-up is unavailable. sendFollowUpQuery exists and push-to-talk uses it with a voiceTurnID, but the wake word always opens a fresh query and guard !isConversationActive suppresses the trigger while a turn is live. Same dependency on the realtime lane.
  • The microphone capturing the assistant's own speech back into the transcript is a separate defect. This branch only stops it corrupting the command.
  • Not verified on an account with an enrolled speech profile — I do not have one, which is how the is_user defect surfaced in the first place.
  • No on-device/pendant verification; desktop mic + screen only.

@aryanorastar

aryanorastar commented Aug 19, 2026

Copy link
Copy Markdown
Contributor Author

Verified working on device

Recorded a live session on macOS against the dev serving plane. Two hands-free wake word queries, both answered:

what time is it?   →  It's 2:48 PM on Wednesday, 19 August 2026.
what time it is?   →  It's 2:53 PM on Wednesday, 19 August 2026.

No clicks, no Push-to-Talk, no keyboard — spoken into an ambient session with the Wake Word toggle on. Matching log for the same session:

14:53:51  WakeWord: submitting 'what time it is?' to the assistant
14:54:05  WakeWord: ignored — segment a7ab87de… already fired for 'what time it is?'

The second line is the growing-segment dedup correctly suppressing the re-delivery of the same instruction, which is the defect fixed earlier in this branch.

Answers render in the chat panel.

Repeat invocations work — commands need spacing, not a restart

A longer session on the dev serving plane, three separate successful invocations in one process, no restart between them:

22:48:44  WakeWord: submitting 'what time it is?' to the assistant
22:50:20  WakeWord: submitting 'Vachita Vachita Vachita Today' to the assistant
22:50:51  WakeWord: submitting 'what's the weather today?' to the assistant
22:50:51  WakeWord: ignored — segment 449d3eb4… already fired for 'Vachita…'
22:51:43  WakeWord: ignored — segment 250378c6… already fired for 'what's the weather today?'

The two ignored lines are the growing-segment dedup correctly suppressing re-deliveries of instructions that had already run — the fix from earlier in this branch working, not a failure.

The middle trigger is worth noting for what it is: 'Vachita Vachita Vachita Today' is speech-to-text garbling non-English speech, and the wake word fired on it anyway. That is the auto-send surface the review raised, seen in the wild rather than in the abstract.

What the spacing actually is. Two independent constraints, not one:

  • the ambient transcript lane runs ~15–35 s behind live speech, so a second command spoken immediately is not visible to the trigger yet
  • the 30 s cooldown suppresses a repeat of the same command (distinct commands are not rationed, per the fix earlier in this branch)

In practice that means leaving roughly 15+ seconds between questions.

Still open, unchanged

  • Voice follow-up does not work. Answering a question by voice still needs sendFollowUpQuery and a voiceTurnID the wake word does not mint, and guard !isConversationActive suppresses the trigger while a turn is live.
  • The ~15–35 s latency remains the real constraint and I still do not think this branch can fix it — it needs the realtime lane or a dedicated keyword spotter.
  • Not verified on an account with an enrolled speech profile; no on-device/pendant verification.

Composition check

This branch was also exercised alongside #11864 (presence-aware notification suppression) in a single build, to confirm the two do not interfere: proactive notifications are withheld during a call while wake-word commands still dispatch and answer.

Edited: this comment originally reported the feature as working "once per session, then not again" and listed three candidate causes I could not separate. That was wrong and understated the feature — the longer session above shows three invocations in one process. What I had read as a hard limit was the transcript latency and the same-command cooldown stacking while I retried too quickly.

@Git-on-my-level

Copy link
Copy Markdown
Collaborator

Thanks @aryanorastar — this round of hardening is exactly what the feature needed: taking it out of unit tests and into live sessions caught a class of issues the suite couldn't see, and the current head reads much better for it.

What I verified on the current head:

  • Sources/WakeWord/WakeWordService.swift — the diarization fix (isUser || speaker == 0, matching VoiceBargeInPolicy) un-bricks the feature for users without an enrolled speech profile, and reverting to fromVoice: false with the comment explaining the voice-turn guard documents the silent-drop failure mode well. Growing-segment dedup via id + command-prefix matching (command.hasPrefix(prior) || prior.hasPrefix(command)) handles re-delivered-and-extended segments, and scoping the cooldown to command == lastTriggeredCommand stops distinct commands from being rationed by transcript latency. Logging rejections only when the segment actually carries the wake phrase keeps ordinary speech quiet while making failures visible.
  • Sources/WakeWord/WakeWordSegmentParser.swift — the homophone table with the documented homie exclusion is carefully reasoned.
  • Tests/WakeWordServiceTests.swifttestSpeakerZeroTriggersWithoutDiarizedUserFlag, testGrowingSegmentDoesNotRefire, and testNewCommandInReusedSegmentIDStillFires each pin a defect you observed live; that's the right way to encode them.
  • AppState+ListenEvents.swift, AssistantSettings.swift, SettingsContentView+General.swift, the changelog entry, and capture-lifecycle.yaml — unchanged in substance from the earlier walkthrough; still coherent, and the on-device verification log is strong evidence.

Two non-blocking observations for the record:

  1. The homophone widening grows the false-positive surface beyond "talking about Omi": with "oh me" accepted, an ordinary utterance like "oh me and my friend went hiking" parses to a 2+-word command and will auto-send. Opt-in + default-off + cooldown + dedup bound the cost, and you deliberately drew the line at "homie" — but "oh me" / "o me" sit close to that line. I'd leave the call to a maintainer rather than ask for a change.
  2. The cooldown keeps only the last fired command (lastTriggeredCommand), so X → Y → X within 30 s re-fires X. That matches the stated intent (swallowing rapid repeats), just noting the edge.

The open item from the earlier walkthrough is unchanged: whether transcript-based triggering with auto-send and a visual-only reply is the intended v1 interaction (vs. keyword spotting / voice-turn replies) is a product direction call, and your in-thread rationale for the v1 approach is a reasonable answer to it.

Human maintainer sign-off needed before merge: product decision on wake-word trigger semantics (transcript + homophone matching, auto-send UX).


by AI on behalf of David.

Review feedback on this PR: with "oh me" accepted as the wake phrase, an ordinary
sentence like "oh me and my friend went hiking" parses to the 2-word command
"and my friend went hiking" and auto-sends it. That false-positive surface was
introduced by the homophone table earlier in this branch.

The homophones and the literal spelling are not the same kind of evidence. Saying
"Omi" is deliberate -- nobody produces it mid-sentence by accident -- so
"Omi order food" needs no corroboration. A homophone is the recognizer guessing,
and its guesses are ordinary English, so it needs something more.

A bare homophone now has to be followed by a punctuation break: the recognizer's
own signal that the speaker addressed something and then paused. Every homophone
hit observed live carried one ("Oh me, how are you?"). A greeting prefix is
corroboration in its own right -- "hey oh me" is not said by accident -- so those
forms keep the ordinary word boundary.

    "Omi order food"                    fires    (literal, unchanged)
    "Oh me, how are you?"               fires    (homophone + break)
    "hey oh me order pizza"             fires    (greeting corroborates)
    "oh me and my friend went hiking"   ignored  (bare homophone, no break)
    "o me it has been a long day"       ignored

This only ever makes the wake word fire less, so the risk it carries is a missed
trigger, never a spurious one.

Verification
- swift test --filter 'WakeWordSegmentParserTests|WakeWordServiceTests' -> 25 passed
  (13 parser + 12 service; 3 new, every prior case still green)
- New cases use the reviewer's example sentence and the exact strings observed live.
- Confirmed live on the dev serving plane after the change, both forms in one session:

      20:44:35  Transcript: "Hey Omi what's the time"
                WakeWord: submitting 'what's the time' to the assistant
      20:44:37  Transcript: "Omi, what is the time?"
                WakeWord: ignored — assistant already busy

  The second line is the trigger being correctly suppressed while the turn opened
  by the first was still live, which is also proof the dispatch engaged.

Honest gaps
- Punctuation is a proxy for a spoken pause and depends on the recognizer emitting
  it. A homophone rendered without punctuation will now be missed rather than
  misfire, which is the safer direction but is still a miss.
- Does not address a wake phrase transcribed in a non-Latin script (the socket runs
  language=multi); matching remains ASCII-only.
@aryanorastar

Copy link
Copy Markdown
Contributor Author

Thanks @Git-on-my-level — the "oh me" false-positive is a fair hit and it was mine, introduced by the homophone table earlier in this branch. Fixed in cfb0b61.

The distinction the parser was missing

The literal spelling and a homophone are not the same kind of evidence. Saying "Omi" is deliberate — nobody produces it mid-sentence by accident — so "Omi order food" needs no corroboration. A homophone is the recognizer guessing, and its guesses are ordinary English, which is exactly why your example works: "oh me and my friend went hiking" parsed to the 2-word command "and my friend went hiking" and auto-sent it.

A bare homophone now requires a punctuation break after it — the recognizer's own signal that the speaker addressed something and then paused. Every homophone hit I observed live carried one ("Oh me, how are you?"). A greeting prefix is corroboration in its own right — "hey oh me" is not said by accident — so those forms keep the ordinary word boundary.

"Omi order food"                    fires    (literal, unchanged)
"Oh me, how are you?"               fires    (homophone + break)
"hey oh me order pizza"             fires    (greeting corroborates)
"oh me and my friend went hiking"   ignored  (bare homophone, no break)
"o me it has been a long day"       ignored

This only ever makes the wake word fire less, so the risk it carries is a missed trigger, never a spurious one.

Verification

  • swift test --filter 'WakeWordSegmentParserTests|WakeWordServiceTests'25 passed (13 parser + 12 service; 3 new, every prior case still green). The new cases use your example sentence verbatim plus the exact strings observed live.
  • Confirmed live on the dev serving plane after the change, both forms in one session:
20:44:35  Transcript: "Hey Omi what's the time"
          WakeWord: submitting 'what's the time' to the assistant
20:44:37  Transcript: "Omi, what is the time?"
          WakeWord: ignored — assistant already busy

The second line is the trigger correctly suppressed while the turn opened by the first was still live — which also confirms the dispatch engaged rather than silently dropping.

Honest gaps on this change: punctuation is a proxy for a spoken pause and depends on the recognizer emitting it, so a homophone rendered without punctuation is now missed rather than misfiring. And it does not address a wake phrase transcribed in a non-Latin script — the socket runs language=multi, and matching remains ASCII-only. I have seen my own speech come back in Devanagari on that lane, so that gap is real, not theoretical.

On your second note

The cooldown keeping only lastTriggeredCommand, so X → Y → X within 30s re-fires X — agreed that matches the stated intent, and I have left it alone rather than churn the semantics while the product question is open.

Still yours to call

The product decision on trigger semantics (transcript-based detection with auto-send, and a visual-only reply) is unchanged and still needs a human. My rationale is in the thread above; happy to take it whichever way you decide, including moving to a dedicated keyword spotter on the realtime lane if that is the direction — which would also address the ~15–35s ambient latency I documented, the one limitation I do not think this branch can fix.

The hosted runner's Azure Ubuntu mirror repeatedly stalled Redis setup until the 20-minute gauntlet deadline. Use the existing archive.ubuntu.com fallback directly for each Redis-dependent gauntlet.\n\nVerification:\n- actionlint -config-file .github/actionlint.yaml .github/workflows/backend-hermetic-e2e.yml\n- python3 backend/scripts/check_workflow_contracts.py --changed-files .github/workflows/backend-hermetic-e2e.yml\n- git diff --check\n\nFailure-Class: none
Same stall as backend-hermetic-e2e's Redis install (69e5a9e): the
hosted runner's Azure Ubuntu mirror hangs InRelease fetches, this time
in desktop-windows-ci's Linux runtime dependency install, until the
job's 6-hour default timeout cancels it. Drop the mirror the same way,
falling back to archive.ubuntu.com directly.

Verification:
- actionlint -config-file .github/actionlint.yaml .github/workflows/desktop-windows-ci.yml
- git diff --check
Failure-Class: none
@aryanorastar

aryanorastar commented Aug 20, 2026

Copy link
Copy Markdown
Contributor Author

Statistics — measured, not estimated

Same measurement pass as #11864, restricted to the wake word. Source is 78 real sessions, 32.6 h of logged runtime, Aug 18–20, on the dev serving plane with a real Firebase identity and real ambient transcription. No fixtures, no replay, no synthetic segments — which matters here specifically, because every defect this feature had was invisible to unit tests that constructed segments the backend never actually sends.

Triggers, before vs after

Before is exactly 0. There was no hands-free path to the assistant during ambient listening at all.

Counts split at fb13867fb9 ("stop a growing transcript segment re-firing the wake word", Aug 19 12:52), because that commit changes what a dispatch means:

dispatches distinct utterances redundant re-fires
before fb13867fb9 17 14 3
after fb13867fb9 (this PR as it stands) 10 10 0
total 27 24 3

Across the whole window: 62 raw wake-phrase matches, 27 dispatched, 35 correctly refused.

Refusal breakdown:

reason count
segment already fired (transcript revision, not a new utterance) 16
segment not attributed to the user (someone else in the room said it) 10
assistant already busy 5
cooldown — repeat of the same command 4

The 35 refusals are the number worth reading. Ambient transcripts arrive as revisions — the same utterance is re-sent as the backend refines it — so a matcher that fires on text alone triggers 2–4 times per phrase. Segment-scoped prefix dedup is what turns 62 raw matches into 27 dispatches. Speaker attribution accounts for 10 more: those are other people saying "Omi" near my machine, which must not command my assistant.

The 3 redundant re-fires are all pre-fix, and are exactly what fb13867fb9 was written to stop — the code comment in WakeWordService quotes that utterance because it was written from that trace. Post-fix the figure is 0.

False positives

0 false triggers reached the assistant. All 27 dispatches are real commands: "what time is it", "what's the weather", "tell me the latest news", "can you order food for me". Nothing fired on ambient conversation that merely contained a homophone. That is downstream of the punctuation-break rule in WakeWordSegmentParser — bare homophones ("oh me", "omni", "oh mi") require a punctuation break before the command, while a literal "Omi" or a greeting-prefixed form does not. That rule was added because the false-trigger class was real before it.

Latency

Not a defect and not fixable here: dispatch happens the moment the segment is delivered, and the ambient lane runs ~15–35 s behind live speech by design. In one trace, WakeWord: submitting at 12:41:28.162 precedes the Transcript [ADD] at 12:41:28.165 by 3 ms — the feature adds no measurable delay of its own. The wait is transcription, not this code. A realtime-lane trigger would be a different feature, not a tuning of this one.

Method and limits

  • 78 sessions, one user, one machine (MacBook Air, macOS 26.x), three days.
  • 10 post-fix dispatches is a small sample. "0 redundant" over 10 is weaker evidence than it looks, and I would not claim the class is closed on it.
  • 27 total dispatches is also small. "0 false triggers" is a real observation over 32.6 h of ambient listening, not a bounded false-positive rate.
  • The hasPrefix guard is scoped to one segment id by design. Whether ids stay stable across every revision is an open question — the id never appears in the transcript log lines, only in the ignore message, so these logs cannot answer it. Settling it needs the id logged on delivery, which is a one-line diagnostic change.
  • The 10 speaker-attribution refusals are evidence the multi-speaker path works, not proof of it.
  • Counts come from WakeWord: lines in /private/tmp/omi-dev-*.log; the redundant-dispatch figure groups submissions that prefix-extend an earlier one within 60 s.

Edited: an earlier revision of this comment reported the 3 pre-fix re-fires as a live defect and inferred that segment ids are unstable across revisions. Both were wrong — I had misread a pre-fix log line, since the old code logged the current command in the already fired for '…' message and fb13867fb9 changed it to log the prior one. Corrected in place rather than appended, and the split table above is the accurate reading.

…llation

Detect Hermetic Backend Scope was cancelled mid-run by a runner race, so
Backend Hermetic Merge Gate read SCOPE_RESULT: cancelled and failed
closed. Distinct from the apt-mirror stall in BasedHardware#11848 — this one never
reaches a package fetch. No code change; the branch has no admin rights
to rerun the workflow directly.
@aryanorastar

Copy link
Copy Markdown
Contributor Author

@undivisible thanks for merging #11901 — and for saying what actually bugged you, that's the useful kind of bug report.

Two macOS PRs of mine are sitting on a human decision rather than on code, if you have a minute:

This one — hands-free wake word. Say "Omi, what's the weather" during ambient listening and it runs the command. @Git-on-my-level's passes found no blocking code issues; the one open item is a product call — transcript-based triggering with auto-send, vs a proper keyword spotter. My reasoning is in the thread and I'm happy either way, it just needs someone to pick.

#11864 — withhold proactive notifications while someone else is present. Screen share or a live call, including muted browser calls. Also adds the "Silence Notifications" control. The change the review asked for is fixed and pushed.

Both have live session evidence and measured numbers in the threads — presence suppression is 9/9 true positives cross-checked against an independent detector, 0 false positives.

The honest caveat on this one is already in the thread and worth reading before you try it: the ambient transcript lane runs ~15–35s behind live speech, so a wake-word answer arrives well after you speak. Functional, but not command-and-control. Fixing that needs the realtime lane or a dedicated keyword spotter, which is the same product call above.

No rush on either.

# Conflicts:
#	desktop/macos/e2e/flows/capture-lifecycle.yaml
@aryanorastar

Copy link
Copy Markdown
Contributor Author

Code-wise, this is complete and ready for a maintainer decision. The latency fix, wake-word flow, spoken/realtime evaluation path, and playback-echo protection are implemented and verified; the current head is mergeable and all checks are green.

The only remaining blocker is product sign-off on transcript-based auto-send and the always-on echo filter. I’m not holding any unfinished code locally—once maintainers choose the direction, I can make any requested adjustment.

aryanorastar and others added 2 commits August 28, 2026 19:53
… panel

Hands and eyes are elsewhere — that is the premise of a wake word — so growing
the bar into a response panel covered a fifth of the screen for no one's
benefit. The response glow already signals that Omi is working and the answer
is spoken, so the exchange is recorded without being put in front of the user.
The panel remains the fallback when the hands-free path cannot run.

Two things made this harder than it looks, both found by instrumenting rather
than reading:

The panel is opened from four places — the query starting, the answer arriving,
the content-height observer, and the agent-chat resize. Suppressing any one of
them left it showing, because whichever was missed put it straight back. The
guard is now at `resizeAnchored`, the single point they all reach, and blocks
only expansion so collapsing back to the pill still works.

`prepareVisibleQueryState` runs twice for one query: once from `routeQuery` to
show the thinking state, once from `sendAIQuery`. A one-shot latch consumed by
the first call meant the second reset the flag and reopened the panel —
`presentsSurface=false` immediately followed by `presentsSurface=true` in the
same millisecond. It may now only ever set quiet; the entry points that do want
a panel (typed follow-ups, `submitSpokenCommand`) clear it.

Verification:
- xcrun swift test --package-path Desktop --filter
  'WakeWord|VoicePlaybackEcho|FloatingControlBarState|Realtime' -> 335 tests,
  0 failures
- Live on a named dev bundle, real microphone, two consecutive commands:
  the bar stays at 62px throughout (`resizeToFrame to (351.0, 62.0)`), no
  `430x381` expansion, and both answers were spoken — the microphone
  transcribed Omi saying "The capital of Portugal is Lisbon." and "The sun
  contains about 99.86% of the total mass in our solar system."
  The previous build logged `resizeToFrame to (430.0, 381.0)` on every command.

Failure-Class: none

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

@Git-on-my-level Git-on-my-level left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Follow-up on the new head 7fd0e2d — the notch-answering work in ccaaebb plus the main merge. The hands-free direction reads well against the feature's premise, and guarding expansion at the single choke point all four panel entry points reach (FloatingControlBarWindow.resizeAnchored, with the resizeToResponseHeight / beginMainResponseHeight guards beside it) is the right shape.

One blocking item — a pinned source contract went stale and both required Desktop Swift checks are red because of it:

  • Desktop Swift Static & Test Contracts and Desktop Swift Build & Tests fail on AgentPillLifecycleTests.testTypedSendDelegatesResponseSizingToWindow (desktop/macos/Desktop/Tests/AgentPillLifecycleTests.swift:1083), which asserts the exact declaration func beginVisibleMainQuery(_ message: String, fromVoice: Bool, animated: Bool = true). ccaaebb extended that signature with presentsSurface: Bool = true and wrapped it across lines (FloatingControlBarWindow.swift:5363), so the pinned string no longer matches. It is the only failing suite in the run, and the production behavior is intact — the view's call site still passes defaults, so typed sends present the panel. The fix is to update the pin to the new signature; ideally also extend that contract to cover presentsSurface defaulting to true and the answersQuietly guards, since that is now the load-bearing behavior of this surface.

Two non-blocking hardening notes on the new quiet-answer plumbing:

  • submitHandsFreeCommand sets the static suppressNextVisibleSurface latch before sendFollowUpQuery; if openAIInputWithQuery early-returns on its window/provider guards the latch is never consumed, and the next typed query through prepareVisibleQueryState would silently answer quietly once. Clearing the latch on that early-return path (or consuming it on failure) closes it.
  • state.answersQuietly is cleared in the onSendQuery re-wire (FloatingControlBarWindow.swift:3838) and submitSpokenCommand (:4128), but the default wiring installed in setup (:3076) does not clear it, and the comment at the flag's set-site (:5370) says any surface-presenting query clears it — the code does not quite do that yet. Moving the clear into beginVisibleMainQuery when presentsSurface is true would make the invariant local to the flag's owner.

Also re-verified on this head, unchanged from the earlier pass: the echo-policy prefix match with residue preservation in VoicePlaybackEchoPolicy.swift ahead of WakeWordService.observe in AppState+ListenEvents.swift, homophone-tolerant command extraction in WakeWordSegmentParser.swift, and capture-lifecycle.yaml's covers list includes the new sources.

Once the pinned contract is updated and both Desktop Swift checks are green, this is in good shape for a maintainer's product pass — the hands-free wake-word surface (default-off setting, notch-quiet answers, optional realtime path behind a flag) is a direction call that deserves maintainer sign-off before merge.


by AI on behalf of David — if you need David’s attention urgently, please @Git-on-my-level and escalate with need human response.

@Git-on-my-level Git-on-my-level removed the positive-signal Good PR — positive signal, not a formal approval label Aug 28, 2026
# Conflicts:
#	desktop/macos/Desktop/Sources/FloatingControlBar/FloatingBarVoicePlaybackService.swift
…d signature

ccaaebb added `presentsSurface: Bool = true`, which pushed the parameter list
onto its own line under swift-format. The contract test pinned the declaration
as one string:

    func beginVisibleMainQuery(_ message: String, fromVoice: Bool, animated: Bool = true)

so it stopped matching and took both required Desktop Swift checks red. The
production behavior was never affected -- the view's call site still passes
defaults.

Pinned in two parts so a wrap cannot break it again, and `presentsSurface:
Bool = true` is now pinned with the rest: that default is what keeps a typed
send presenting the panel, which is the load-bearing behavior of this surface.

Checked the pin still guards rather than passing vacuously -- flipping the
expected default to false fails the assertion at line 1088; restoring it
passes. AgentPillLifecycleTests 84/84.

Failure-Class: none
@aryanorastar

Copy link
Copy Markdown
Contributor Author

@Git-on-my-level the stale pin is fixed on 3bb78d66.

beginVisibleMainQuery wrapped onto two lines when presentsSurface was added, so the single-string pin stopped matching. Repinned in two parts so a future wrap can't break it, with presentsSurface: Bool = true pinned alongside as you suggested. Checked it still guards — flipping the expected default to false fails at line 1088. AgentPillLifecycleTests 84/84.

Also merged current main; the legacy-memory-surface-ratchet red on #11804 and #11864 was the same staleness and both pass locally now.

Your two non-blocking notes (the suppressNextVisibleSurface latch on the early-return path, and moving the answersQuietly clear into beginVisibleMainQuery) I've left — both change live behaviour on the quiet-answer path and I'd rather do them with a test than fold them in unverified. Happy to take them as a follow-up.

aryanorastar and others added 3 commits August 29, 2026 13:38
The wake word is only as good as the recognizer that hears it, and on the
default path the recognizer cannot be told its own name. Ambient transcription
runs on-device on Apple Silicon, and `AsrManager.transcribe(_:decoderState:
language:)` takes a language hint and nothing else — no keyword or vocabulary
parameter. The cloud lane does not have that problem: `/v4/listen` prepends
"Omi" to the STT keyword vocabulary server-side in
`backend/utils/listen_session_bootstrap.py`.

Measured on one machine, same script and voices, only the lane changed: the
phrase was usable in 12 of 20 utterances on-device against 19 of 20 on the
cloud lane. Seven of the eight on-device misses came back as "Only".

Two changes, both aimed at that gap.

`WakeWordSegmentParser` gains a third corroboration class. "Only" cannot ride
the existing punctuation-break rule: a scan of 1,919 stored local segments
found 15 sentence-initial "Only", every one a misrendered wake word, and not
one carried a break after it — they read "Only what is on my calendar", "Only
open my rewind timeline". The same scan found 8 ordinary uses of "only", all
mid-sentence. So `.commandHead` gates on what follows instead: an interrogative,
or a request verb aimed at the speaker's own things ("show me", "open my",
"remind me"). Every imperative reads fine under a restriction — "only do that
once", "only tell him if he asks" — but none of them ask for the speaker's own
calendar.

`AssistantSettings.wakeWordPrefersCloudSTT` is the opt-in for users who would
rather buy the remaining accuracy with cloud transcription. Off by default: it
trades on-device transcription for cloud transcription while the wake word is
enabled, which is a privacy and cost decision, not a technical one. FluidAudio
does ship term biasing, but only on `SlidingWindowAsrManager`, which is a
different streaming architecture and pulls a second set of CTC models — that is
the upgrade path out of this flag.

Verified on the running app through the hermetic capture seam, which reaches
`WakeWordService.observe` on the real path: 14 ordinary uses of "only" injected,
zero fired; 3 verbatim misrenderings injected, 3 parsed. The cloud opt-in was
confirmed live by the lane actually switching — `TranscriptionService:
Connecting to wss://api.omiapi.com/v4/listen` in place of on-device Parakeet.

Tests: 26 in WakeWordSegmentParserTests, 14 in STTSessionStateTests, and the
full 716-suite run, all passing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…r to it

A backend segment is re-delivered as it grows, in place and under one id, and the
re-delivery arrives whether or not the speaker added anything. `VoiceBargeInPolicy`
treated any non-empty user text as speech, so an unchanged re-delivery counted as
the user talking over the assistant.

Observed live: the second turn of a conversation was cut off 4.4s into playback by a
re-delivery of the very segment that asked the question, byte-identical to the copy
already stored.

    13:16:41.117  Chat response complete
    13:16:45.491  BARGE-IN: User spoke mid-playback; interrupting voice output
    13:16:45.501  Transcript [UPDATE] Speaker 0: I'm fine. What about you? Omi, I'm fine. What about you?

The first turn escaped only by timing — its answer finished speaking before the
re-delivery landed. From the second turn on, the re-delivery falls inside the playback
window every time, which is why it reads as "the second question always gets cut off".

`shouldInterrupt` now takes the text already stored for that segment id and interrupts
only on what the segment gained. Growth that adds nothing but punctuation or spacing is
the recognizer tidying up, not speech. A segment that no longer extends what was stored
counts as new in full: there is no way to tell a revision from a continuation, and
missing a real barge-in is the worse failure.

Failure-Class: none

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Omi's own answer was reaching the transcript as a second speaker, so the reply was
heard once from the speakers and again as if a person had said it.

`walk` only commits a matched word once two match in a row — a guard that exists
because a single common word ("the", "and") matching by coincidence once let the
backward walk eat "the time" off the end of a user's command. But an utterance's
closing word can never reach two-in-a-row when the word before it was garbled, so
the last word of a short answer was permanently uncountable.

Measured live: Omi said "I don't know the details of Wake Word yet." and the
microphone returned "WakeMore" for the product name. The trailing "yet" followed
that one mismatch and could not commit, so coverage came out 6 of 8 = 0.75, under
the 0.80 floor, and the segment was kept.

    19:37:59.241  Transcript [ADD] Speaker 1: I don't know the details of WakeMore yet.
    19:38:00.576  ECHO: dropped ... I don't know the details of WakeMore yet. Feature you design

Confirmed against the real strings before and after: `keep` became `drop` for every
rendering of what was actually spoken.

The final token may now commit on a single match, but only after `minimumWordCount`
words have already matched in a run, so a lone coincidental word still cannot start
or extend an echo. Forward walk only: the reversed walk measures the playback that
follows a barge-in, where the "final" token is the first word the user said, and
committing it on one match would eat the start of the interruption.

Failure-Class: none

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@Git-on-my-level
Git-on-my-level dismissed their stale review August 29, 2026 18:42

Resolved on head 00e7a20: 3bb78d6 repinned the beginVisibleMainQuery contract to the wrapped signature (including presentsSurface: Bool = true) and both required Desktop Swift checks are green.

@Git-on-my-level Git-on-my-level left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Follow-up on the new head 00e7a20c — the four commits since 7fd0e2d5: the contract repin (3bb78d66), the on-device recognition work (21f1e50f), the re-delivery barge-in fix (f1f9ba09), and the echo-tail fix (00e7a20c).

The previous blocker is resolved. AgentPillLifecycleTests now pins beginVisibleMainQuery in two parts matching the wrapped signature including presentsSurface: Bool = true, and both required Desktop Swift checks are green on this head. The earlier changes-requested review is being dismissed as resolved.

The delta itself checks out:

  • 21f1e50f — the default on-device lane now recognizes the wake word through evidence-based renderings rather than forcing the cloud lane: WakeWordSegmentParser.commandShapedRenderings accepts sentence-initial "Only" only when an interrogative or a self-addressed request verb follows (opensLikeACommand), and STTSessionState.resolveMode keeps wakeWordNeedsRecognizableName as an opt-in cloud override that a sessionForceLocal fallback correctly outranks (pinned in WakeWordSegmentParserTests and STTSessionStateTests, including the not-dragged-back-to-cloud case). The privacy trade stays documented at the resolver, off by default with no settings surface toggling it yet.
  • f1f9ba09VoiceBargeInPolicy.newSpeech counts only what a re-delivered segment gained, with previouslyHeard fed from the stored copy of the same segment id in AppState+ListenEvents.swift; the four new test cases cover unchanged re-delivery, punctuation-only growth, genuine additions, and recognizer revisions.
  • 00e7a20cVoicePlaybackEchoPolicy.walk lets an utterance's final token commit on a single match once a run of minimumWordCount words has already established the echo (commitsFinalSingleMatch), with the garbled-word regression and the lone-match negative both pinned.

One blocking item — a stale changelog fragment duplicates an already-shipped entry:

  • desktop/macos/changelog/unreleased/20260823-local-transcription-pause-endpointing.json is byte-identical to the entry that already shipped in v0.12.221 via #12181, and this PR's own status note says that surface is off the PR now. If this merges, the next consolidation would re-announce an already-released change. Deleting the fragment is the whole fix.

Two non-blocking hardening notes from the earlier pass remain open on this head, unchanged and still non-blocking:

  • openAIInputWithQuery's early-return guards (window/provider) still return without consuming Self.suppressNextVisibleSurface, so a latch set by submitHandsFreeCommand can survive and quiet the next typed query once.
  • answersQuietly is still cleared only in the onSendQuery re-wire and submitSpokenCommand; the default wiring installed in setup does not clear it, and the comment at the flag's set-site still promises more than the code does. The double-call hazard now documented at prepareVisibleQueryState explains why the clear cannot simply move into beginVisibleMainQuery — a central clear that respects that hazard (e.g. keyed to query generation) would close the gap.

With the fragment dropped, what remains on this PR is the product decision — whether the hands-free wake-word surface (default-off setting, notch-quiet answers, optional realtime lane behind wakeWordUsesRealtime) is the direction Omi wants — which needs maintainer sign-off before merge.


by AI on behalf of David — if you need David’s attention urgently, please @Git-on-my-level and escalate with need human response.

@Git-on-my-level Git-on-my-level added the docs-accuracy Documentation or committed reports need accuracy fixes label Aug 29, 2026
`20260823-local-transcription-pause-endpointing.json` is byte-identical to the entry
released in v0.12.221, which shipped through BasedHardware#12181 when pause endpointing was split
off this branch. Leaving it here would re-announce an already-released change at the
next consolidation.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@aryanorastar

Copy link
Copy Markdown
Contributor Author

@Git-on-my-level the fragment is deleted on bbc5235d. You were right that consolidation would have re-announced it was byte-identical to what #12181 already shipped in v0.12.221. Preflight 24/24, checks green.

need human response — this needs David, not another verification pass.

Four of your passes have now ended the same way: no code-level blockers, the decision belongs to a maintainer. That decision has been open since your first review. In the meantime each new head I push mostly generates mechanical findings an exception line, a stale pin, this fragment — against a gate that isn't code. So I'd rather stop adding to this PR and get the call.

Three questions, each answerable in a line:

  1. Transcript-based triggering with auto-send as v1? Yes → this merges. No → I move detection to a keyword spotter on raw audio, which is a different PR.
  2. Echo filtering always-on on the ambient ingest path? It's load-bearing here: the wake word's own "assistant is speaking" guard was removed because VoicePlaybackEchoPolicy answers that question directly. If you want it opt-in, that guard comes back.
  3. How long does wakeWordUsesRealtime ride? Default-off, no UI. Happy to remove it rather than carry it.

A "no" on any of these is more useful to me than the current state.

Known and not fixed here: 4.4–6.1s from end of speech to Omi speaking. Measured, it's all the agent runtime — 13–28k input tokens for a one-line question — not detection, which fires in milliseconds. Follow-up PR unless you want it in this one.

Your two non-blocking hardening notes I'd also take as a follow-up, with tests rather than folded in unverified.

@Git-on-my-level
Git-on-my-level dismissed their stale review August 30, 2026 18:26

Resolved on bbc5235: the blocking item — the stale 20260823-local-transcription-pause-endpointing.json changelog fragment duplicating the entry already shipped in v0.12.221 via #12181 — is deleted; the compare shows that file removal as the only change.

@Git-on-my-level

Copy link
Copy Markdown
Collaborator

Follow-up on bbc5235d — one commit since the last pass, and it is the whole blocker: the stale 20260823-local-transcription-pause-endpointing.json fragment (duplicate of the entry that already shipped in v0.12.221 via #12181) is deleted, and nothing else changed. Verified directly against the head — the compare shows a single-file removal, the fragment is gone, and the six wake-word/echo fragments that belong to this PR are all still there. The earlier changes-requested review is being dismissed as resolved.

Required Desktop Swift checks, desktop-core-e2e-t0, Hygiene, and the merge gates are green on this head.

Two hardening notes from the earlier pass remain open, unchanged and still non-blocking:

  • openAIInputWithQuery's early-return guards (window/provider, and the inner voiceTurnID ownership check) still return without consuming Self.suppressNextVisibleSurface, so a latch set by submitHandsFreeCommand can survive and quiet the next typed query once.
  • answersQuietly is still cleared only in the re-wired onSendQuery and submitSpokenCommand; the default wiring installed in setup does not clear it.

With the mechanical blocker gone, what remains is the product decision — whether the hands-free wake-word surface (default-off setting, notch-quiet answers, optional realtime lane behind wakeWordUsesRealtime) is the direction Omi wants — which needs maintainer sign-off before merge.


by AI on behalf of David — if a specific product or safety concern needs his attention, please @Git-on-my-level and escalate with need human response.

@Git-on-my-level Git-on-my-level removed the docs-accuracy Documentation or committed reports need accuracy fixes label Aug 30, 2026
…next query

`suppressNextVisibleSurface` is a one-shot latch consumed only by
`prepareVisibleQueryState`. Three exits from `openAIInputWithQuery` return before
reaching it — no window, no active provider, and the `.voiceOnly` branch, which
renders nothing and so never consumes it at all. A wake word that fires while the
bar is torn down or unprovisioned therefore leaves the latch set, and the next
typed question is silently answered in the notch.

Clear the latch on every exit that abandons the query.

Separately, `beginVisibleMainQuery` only ever *sets* `answersQuietly` (clearing
there would let the second `prepareVisibleQueryState` call undo the first), so the
reset has to live at the query entry points. The closure re-wired in
`openAIInputWithQuery` does it; the default wiring installed at setup did not, so a
quiet wake-word answer leaked its silence into the next typed question on that path.

Verification: `swift build` in `desktop/macos/Desktop` — clean. No behavioral test
added: both paths run through `FloatingControlBarWindow` (an NSPanel) with no
injectable seam, and the existing coverage here is source-scrape pins, which
AGENTS.md calls a static tripwire rather than behavioral coverage.

Failure-Class: none
@aryanorastar

aryanorastar commented Aug 31, 2026

Copy link
Copy Markdown
Contributor Author

Both hardening notes are fixed on 390d8c94. @Git-on-my-level @kodjima33

The latch leak. You were right, and it is worse than the two guards you named: .voiceOnly never consumes suppressNextVisibleSurface either, since it renders no surface and so never reaches prepareVisibleQueryState. Three exits, not two. All three now clear the latch before returning.

answersQuietly. The comment on beginVisibleMainQuery claims it is "cleared by any query that does present a surface," which was only true through the closure re-wired in openAIInputWithQuery. The default wiring installed at setup never cleared it. Fixed there rather than in beginVisibleMainQuery — clearing at that point would let the second prepareVisibleQueryState call undo the first, which is the trap the existing comment already warns about.

Verified with swift build in desktop/macos/Desktop — clean. I did not add a test: both paths run through FloatingControlBarWindow (an NSPanel) with no injectable seam, and the existing coverage here is source-scrape pins, which AGENTS.md calls a static tripwire rather than behavioral coverage. Adding another one would not have caught either bug. Flagging that rather than implying it is covered.

Product decision on the wake-word surface is still the only thing outstanding.

@Git-on-my-level

Copy link
Copy Markdown
Collaborator

Follow-up on 390d8c94 — one commit since the last pass (bbc5235d), and it is exactly the two open hardening notes. Both verified against the head file:

The latch leak is closed at all three exits. openAIInputWithQuery's window/provider guard now clears Self.suppressNextVisibleSurface before returning (FloatingControlBarWindow.swift:3794–3796), and the .voiceOnly entry — the third exit, correctly wider than the two named last round, since it renders no surface and never reaches prepareVisibleQueryState — clears it before the ownership check (line 3807), so the inner Task's re-check can't strand it either. The sole consumer remains the first prepareVisibleQueryState call (lines 4808–4809), so set/consume is now exhaustive on this path.

answersQuietly is cleared in the default wiring. The onSendQuery closure installed at setup resets the flag (line 3080), matching the re-wired closure in openAIInputWithQuery (line 3850) and submitSpokenCommand (line 4138). Clearing there rather than in beginVisibleMainQuery is the right call — prepareVisibleQueryState runs twice per query and the second call would undo a central clear, which is the trap the existing comment warns about.

Skipping a behavioral test for these is a fair call given the surface: both paths run through FloatingControlBarWindow (an NSPanel) with no injectable seam, and the coverage there is source-scrape pins — an honest limitation rather than a silent gap.

One narrower residual edge of the same class, non-blocking: in sendFollowUpQuery, when a response panel is already showing, the activeFloatingProvider() failure return (line 4178) and the canInterruptActiveTurn refusal via showSharedProviderBusy (line 4185) still exit after submitHandsFreeCommand set the latch and before any consumer — so in those provider states the next typed query could render quiet once. Same failure shape, much narrower door (needs showingAIResponse plus a missing/refusing provider). Fine to fold into a follow-up; not worth holding this for.

The rest of the head is unchanged from the verified bbc5235d state and still checks out on a re-read: the WakeWordService.observe guard stack (user attribution, per-segment dedup that tolerates segment growth, repeat-only cooldown), WakeWordSegmentParser's evidence model (sentence-initial matching, punctuation-break vs .commandHead corroboration for the on-device "Only" rendering), VoicePlaybackEchoPolicy's measured thresholds with its err-toward-keep walk, the single echo-guard funnel in AppState+ListenEvents ahead of barge-in/wake-word/persistence, and STTSessionState.resolveMode's opt-in cloud override that sessionForceLocal correctly outranks. Required Desktop Swift checks, desktop-core-e2e-t0, Hygiene, and the merge gates are green on this head, and the six wake-word/echo changelog fragments are intact.

What remains is unchanged and is not a code matter: the product decision on whether the hands-free wake-word surface (default-off setting, notch-quiet answers, optional realtime lane behind wakeWordUsesRealtime, always-on playback-echo filter) is the direction Omi wants for macOS. That direction call needs maintainer sign-off before merge.


by AI on behalf of David — if you need David’s attention urgently, please @Git-on-my-level and escalate with need human response.

# Conflicts:
#	desktop/macos/Desktop/Sources/FloatingControlBar/FloatingBarVoicePlaybackService.swift
@Git-on-my-level

Copy link
Copy Markdown
Collaborator

Verification pass on the merge head f3da12cf — no new wake-word commits since the verified 390d8c94; this head only merges origin/main, but that merge lands main's Interject v1 / voice-handoff rework in the same files this feature touches, so the seams were re-checked against it rather than assumed:

  • Echo funnel unchanged and still first. AppState+ListenEvents.handleBackendSegments still runs VoicePlaybackEchoPolicy.classify ahead of barge-in, WakeWordService.observe, and persistence; FloatingBarVoicePlaybackService still records spoken history per-word on its own 15s expiry clock (recentlySpokenWords), and main's new InterjectVoiceFeedbackRouting.spokenText(from:) wrapper in speakOneShot/enqueueSystemSpeech records the same routed text that is actually uttered, so echo-history fidelity holds through the merge.
  • Realtime lane intact through the commit-path rework. runWakeWordTurn's ordering (buffer via sendSpokenCommand → silence frames → .finalizecommitTurn()) still works now that the input window opens through the renamed beginLiveInputTurn inside commitClaimedHubInput; the known command text is supplied via wakeWordInputTranscript and consumed in RealtimeAutomationTranscriptOverridePolicy.select, and it is cleared on turn start in RealtimeHubController+PushToTalk so it cannot leak into a spoken turn. One nit: the comment in runWakeWordTurn still says beginInputTurn — the call it describes is now beginLiveInputTurn.
  • Quiet answers survive the surface-size refactor. The answersQuietly guards (resizeAnchored(makeResizable:), resizeToResponseHeight, beginMainResponseHeight) are untouched by main's collapsedChromeSurfaceSize consolidation, and the three latch exits verified last round are byte-identical. The known narrow edge — submitHandsFreeCommand setting the latch and sendFollowUpQuery then exiting at a missing/refusing provider while a response panel is showing — is unchanged; still follow-up material, not a blocker.
  • Desktop Swift build & tests, static & test contracts, release compile, desktop-core-e2e-t0, Hygiene, and the merge gates are green on this head with main's changes compiled in.

Conclusion unchanged from the last pass: the code side is done and now re-verified against the merged base; what remains is the product decision on whether the hands-free wake-word surface (default-off, notch-quiet answers, optional realtime lane, always-on playback-echo filter) is the direction Omi wants for macOS. Leaving for human maintainer review on that direction call.


by AI on behalf of David — if you need David’s attention urgently, please @Git-on-my-level and escalate with need human response.

# Conflicts:
#	desktop/macos/Desktop/Tests/AgentPillLifecycleTests.swift
@Git-on-my-level

Copy link
Copy Markdown
Collaborator

Verification pass on the merge head dabf9361 — one commit since the Sep 1 pass (f3da12cf): a merge of origin/main bringing 194 commits into this branch, including main's retirement of AskAIInputView and the rework of the typed-send path. No new wake-word commits. The merge resolves one conflict by hand (AgentPillLifecycleTests.swift), so the seams were re-checked against what actually merged:

  • Echo funnel unchanged and still first. AppState+ListenEvents.handleBackendSegments still runs VoicePlaybackEchoPolicy.classify ahead of barge-in, WakeWordService.observe, and persistence; FloatingBarVoicePlaybackService still keeps per-word spoken history on its own 15s expiry clock, and the realtime lane still feeds it via recordExternallySpokenText in RealtimeHubController+SessionDelegate.
  • Realtime turn path intact. runWakeWordTurn's ordering (warm → silence frames → wakeWordInputTranscriptsendSpokenCommand.finalizecommitTurn()) still holds; the known command text is consumed in RealtimeAutomationTranscriptOverridePolicy.select and cleared at turn start in RealtimeHubController+PushToTalk.
  • Quiet-answer guards survive the surface rework. answersQuietly still gates resizeAnchored(makeResizable:), resizeToResponseHeight, and beginMainResponseHeight; the suppressNextVisibleSurface latch is still consumed exactly once in prepareVisibleQueryState; typed sends reset answersQuietly = false in both onSendQuery wirings.
  • Desktop build & tests, static & test contracts, release compile, desktop-core-e2e-t0, Hygiene, and the merge gates are green on this head with main's rework compiled in.

One ask from this merge: the only test pinning the quiet-answer latch was dropped in the conflict resolution. AgentPillLifecycleTests.swift took main's side wholesale (main retired AskAIInputView, which the test source-pinned against), retiring testTypedSendDelegatesResponseSizingToWindow — the contract pin added alongside presentsSurface and repinned in 3bb78d66 so a typed send presents the response panel while a wake-word answer stays in the notch. I scanned all 678 test files on this head: nothing pins presentsSurface / answersQuietly / suppressNextVisibleSurface anymore, so the latch the wake word depends on has no regression net against future refactors. The production path itself is verified intact above — this is about the missing guard, not a behavior bug. Please re-establish the equivalent contract pin against the post-AskAIInputView typed-send path (the new onSendQuery wiring) before merge; tagged needs-tests for that.

Carried nit from the last pass, still present: the ordering comment in runWakeWordTurn (RealtimeHubController.swift ~line 1018) says beginInputTurn; the call it describes is now beginLiveInputTurn (RealtimeHubController+PushToTalk.swift:453).

Conclusion unchanged: code verified against the merged base; the remaining gate is the product decision on whether the opt-in hands-free wake-word surface — default-off, notch-quiet answers, optional realtime lane, always-on playback-echo filter, and the cloud-STT opt-in trade — is the direction Omi wants for macOS.


by AI on behalf of David — remaining gate is the product sign-off on the hands-free wake-word surface and its cloud-STT privacy trade.

@Git-on-my-level Git-on-my-level added the needs-tests PR introduces logic that should be covered by tests label Sep 2, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

macOS needs-maintainer-review Needs a human maintainer to sign off before merge needs-tests PR introduces logic that should be covered by tests

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants