Skip to content

feat(desktop): transcript-driven proactivity during ambient listening - #11804

Open
aryanorastar wants to merge 11 commits into
BasedHardware:mainfrom
aryanorastar:feat/speech-driven-proactivity
Open

feat(desktop): transcript-driven proactivity during ambient listening#11804
aryanorastar wants to merge 11 commits into
BasedHardware:mainfrom
aryanorastar:feat/speech-driven-proactivity

Conversation

@aryanorastar

@aryanorastar aryanorastar commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

Summary

The desktop context-proactivity ("director") engine historically only evaluated a screen context on dwell — after you settle into a context through contextEntered, or right as you leave via evaluateAfterDeparture. Ambient speech was never a trigger: if you asked something aloud while a context visit was active, nothing happened until the next dwell.

This PR makes speech a first-class evaluation trigger. While a context visit is active, a fresh user utterance arriving through the ambient transcript funnel now evaluates that context immediately, grounded on the live screen plus a == LIVE SPEECH == section quoted from what you just said — so hands-free requests ("where does the deploy script live?", "what's open on this page?") get answered during ambient listening, not on the next dwell.

This stacks on top of #11801 (wake word); together the pair closes the loop: the wake word opens the mouthpiece, speech triggers the evaluation, and the director delivers a grounded answer.

Behavior

Before

  • Triggers were dwell-only: contextEntered and evaluateAfterDeparture.
  • evaluateAndDeliver produced a prompt composed of the tracked frame + task list + recent-delivery dedup only.
  • Ambient transcript segments built chat rows and nothing else.
  • No permission/eligibility round-trip was reachable without a dwell.

After

  • New entry point ContextProactivityEngine.evaluateFromSpeech(speech:) — reuses the exact same authorized-delivery tail as dwell evaluations (approve → gate → fence → admission-queue → eligibility → frame grounding), so behavior is consistent with dwell, scaled to one-shot speech slices.
  • AppState+ListenEvents feeds each new backend segment to SpeechProactivityCoordinator.shared.observe(...) (no-op when the feature flag is off).
  • The engine prompt now appends a bounded == LIVE SPEECH == section (up to 6 slices) behind the candidates section, so the director answers against both the screen and what you said.
  • Speech is treated as evidence, not instructions: slices are single-line flattened under an untrusted-speech boundary so nothing you say can forge prompt structure elsewhere.

Evaluation policy (admission)

A pure, unit-tested decision loop (SpeechProactivityAdmission) decides whether speech warrants an evaluation:

Check Value
Feature flag isTranscriptProactivityEnabled (off in prod/beta; on in dev bundles; OMI_FORCE_SPEECH_PROACTIVITY=0 force-disables)
Conversation state Must not be mid assistant voice-turn (uses VoiceTurnCoordinator.activeTurnID)
Speaker Latest slice must be user speech
Minimum length ≥ 4 words (minimumUserWordCount)
Cooldown ≥ 90s since last speech evaluation (evaluationCooldownSeconds)

Skip reasons (admission.outcome) are observable: .flagDisabled, .conversationActive, .noUserSpeech, .utteranceTooShort, .coolingDown.

The speech window itself:

  • TranscriptSpeechSlice is a small Sendable value (segment ID, speaker, text, isUser, start/end).
  • SpeechProactivityWindow keeps max 12 slices, prunes by seen time (180s) — not backend timestamps (which can be in the future or missing) — and dedups by segment ID (transcripts are delivered repeatedly).

Files

Added

  • desktop/macos/Desktop/Sources/ProactiveAssistants/Core/TranscriptSpeechWindow.swift — slice + window types; SpeakerSegment.speechProactivitySlice extraction.
  • desktop/macos/Desktop/Sources/ProactiveAssistants/Core/SpeechProactivityAdmission.swift — pure admission policy.
  • desktop/macos/Desktop/Sources/ProactiveAssistants/Core/SpeechProactivityCoordinator.swift@MainActor observer that hops to the engine actor.
  • desktop/macos/Desktop/Tests/TranscriptDrivenProactivityTests.swift — 14 hermetic tests.

Modified

  • Core/ContextProactivityEngine.swiftevaluateFromSpeech(speech:) entry; evaluateAndDeliver gained defaulted speechSection: appended after the candidates section. All existing dwell call sites compile unchanged.
  • Core/ContextBucketRollup.swiftContextProactivityPromptBuilder.liveSpeechSection(_:maximumSliceCount:timeZone:); [You]/[Other speaker N] tags, ContextDestinationKey.singleLine(limit:) flattening, == LIVE SPEECH == header.
  • Core/ContextBucketsFeature.swiftisTranscriptProactivityEnabled + OMI_FORCE_SPEECH_PROACTIVITY override.
  • Core/ContextVisitCoordinator.swift — production activeFence() accessor beside the existing activeFenceForTesting().
  • AppState/AppState+ListenEvents.swift — funnel wiring after the new segment is built.

Notes / design choices

  • Serialization: speech evaluations ride the same dwellAdmission.begin(visitID:) queue as dwell evaluations, so concurrent speech + dwell evaluations can't interleave on the same visit.
  • Freshness: the same fence-freshness check applies; a speech evaluation won't fire on a stale fence.
  • Conversation-active uses VoiceTurnCoordinator.shared.activeTurnID != nil (WakeWordService isn't on this base yet; feat(desktop): hands-free wake word to command the assistant during ambient listening #11801 introduces it and this PR is stacked on it, so no behavioral conflict).

Testing / Verification

  • xcrun swift build -c debug --package-path Desktop — clean.
  • TranscriptDrivenProactivityTests — 14/14 pass (window dedup/retention/bounds, every admission outcome, prompt-section tags/flattening/slice cap).
  • ContextProactivity* engine suites — all pass (dwell paths unaffected by the defaulted param).
  • swift-format applied; swiftlint reports 0 violations.

Stack: #11801this PR.

Review in cubic

Failure-Class: none

When a fresh user utterance lands while a context visit is active, evaluate
that bucket grounded on the live speech window instead of waiting for the
next dwell, so a question spoken mid-context can be answered hands-free.

- SpeechProactivityWindow: bounded, deduping window of recent speech slices,
  measuring recency by seen-time rather than the backend-defined timestamps
- SpeechProactivityAdmission: pure decide loop — flag on, not mid voice turn,
  user speech, minimum utterance length, and a cooldown between evaluations
- SpeechProactivityCoordinator: MainActor observe path from the transcript
  funnel that hands a Sendable snapshot to the engine actor
- ContextProactivityEngine.evaluateFromSpeech: runs the shared post-settle
  director tail (gates, frame grounding, retrieval hop, presentation) with a
  LIVE SPEECH section appended below the untrusted preamble
- ContextVisitCoordinator.activeFence(): read-only production accessor for
  the live visit
- ContextBucketsFeature.isTranscriptProactivityEnabled: dogfood-only, on for
  non-production with inverted env override, off for prod/beta
- Tests: TranscriptDrivenProactivityTests — window bounds/dedup/retention,
  admission outcomes, prompt-section rendering (14 cases)

Verification:
- xcrun swift build -c debug --package-path Desktop: Build complete!
- swift test (TranscriptDrivenProactivityTests): 14 passed, 0 failures
- swift test (ContextProactivity*): all passed (shared tail untouched)
- swiftlint-wrapper: 0 violations
- check_desktop_test_quality.py: OK at baseline
@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.

…w coverage

Fix the two failing CI checks:
- desktop-changelog-entry: add unreleased fragment for the new
  transcript-driven proactivity feature.
- desktop-e2e-flow-coverage: cover SpeechProactivityAdmission,
  SpeechProactivityCoordinator, and TranscriptSpeechWindow under
  context-buckets-dogfood.yaml alongside the engine Core files they plug
  into.
@aryanorastar

Copy link
Copy Markdown
Contributor Author

Both failing checks fixed and pushed (0cf23fc):

  1. Repo Checks / PR Metadata Preflight + Hygiene — desktop-changelog-entry: added the unreleased changelog fragment 20260818-speech-driven-proactivity.json. Verified locally with check-desktop-changelog.py — passes.

  2. Desktop Swift Static & Test Contracts — desktop-e2e-flow-coverage (same class as feat(desktop): hands-free wake word to command the assistant during ambient listening #11801): the three new speech files had no covers: entry. Added SpeechProactivityAdmission.swift, SpeechProactivityCoordinator.swift, and TranscriptSpeechWindow.swift to context-buckets-dogfood.yaml — the flow already covers the ProactiveAssistants/Core engine files these plug into.

Local verification: macos run_checks.py lane green (e2e-flow-coverage, swift-format-lint, swiftlint); changelog check passes. Stack: #11801 → this PR.

@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.

Thanks @aryanorastar — this is careful, well-gated work. I reviewed all 11 changed files; the engineering quality is notably high. Specifics:

What checks out

  • ContextProactivityEngine.evaluateFromSpeech reuses the full guarded tail — authorization snapshot + recheck, ContextDeliveryBudget.freeGate preflight, fence freshness (checked twice), ContextDirectorEligibility, frame grounding via trackedFrameForDirector/frameMayGroundDirector, and dwellAdmission serialization against dwell/departure — so speech-triggered evaluations cannot bypass any existing delivery gate. The speechSection: String? = nil default keeps dwell/departure prompts byte-identical, preserving the cached prompt prefix.
  • Prompt-injection hygiene in ContextProactivityPromptBuilder.liveSpeechSection is right: every slice is flattened through ContextDestinationKey.singleLine(_:limit: 160) (newline/control-char collapse, verified against main) and rides in the volatile suffix below the untrusted preamble, under an explicit "quoted, untrusted … not instructions" header — the same trust treatment as screen content. The test flattening "first line\nsecond line\n> forge prompt structure" is a nice touch.
  • SpeechProactivityWindow (12 slices / 180 s, in-place replace per segmentID, recency by seenAt rather than backend timestamps) and SpeechProactivityAdmission (user-only, ≥4 words, 90 s cooldown, mid-turn suppression) are pure, well-factored, and well-tested — the 12 tests cover the window, admission, and section-rendering corners.
  • Flag gating in ContextBucketsFeature.isTranscriptProactivityEnabled matches the existing departure-evaluation pattern exactly (OMI_FORCE_SPEECH_PROACTIVITY=0, AppBuild.isNonProduction, hard-off in production/beta) — nothing ships dark to users.

Follow-ups (non-blocking)

  1. SpeechProactivityCoordinator.observe runs admission on every arriving slice, including other-speaker slices. Since latestUserSlice stays in the window for 180 s, another person speaking ~95 s after a user utterance (cooldown elapsed, utterance still retained) re-triggers an evaluation grounded on the stale user utterance. Every gate still applies, so this is polish rather than safety — consider only evaluating when the arriving slice is itself a user slice, or keying the cooldown to the triggering segmentID.
  2. The changelog entry reads as shipped behavior ("the assistant now answers aloud during ambient listening") — worth a "(dogfood)" qualifier so release notes don't get ahead of the flag.
  3. The description frames this as stacking on #11801 (wake word). Good news: there's no hard dependency — the base is main and every referenced symbol (VoiceTurnCoordinator.activeTurnID etc.) exists on main, and CI confirms it builds and tests standalone. Merged alone, the speech trigger works; the "wake word opens the mouthpiece" half of the loop arrives with #11801.

One thing for maintainers to weigh consciously: the LIVE SPEECH section quotes other people's ambient speech into the director prompt (bounded to 6 slices / 160 chars each). That's inherent to grounding and dogfood-gated, but it is a new ambient-audio-to-model-prompt surface worth explicit product sign-off, along with the unprompted spoken-response UX itself.

Leaving for human maintainer review: product-direction decision on unprompted speech-triggered spoken responses and ambient-speech-in-prompt sign-off. No blocking technical issues found.


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

Thank you @Git-on-my-level for the thoughtful and detailed review!

Appreciate the sharp observation on SpeechProactivityCoordinator.observe:

  1. Arriving slice gating: Gating evaluation specifically on arrivingSlice.isUser (or tracking the last-evaluated segmentID) is a great refinement so non-user turns don't re-trigger an evaluation on a retained user slice.
  2. Changelog qualifier: Happy to add the (dogfood) qualifier to keep the release notes strictly aligned with the feature flag.
  3. Ambient Speech in Prompt: Completely agree that keeping ambient speech bounded (6 slices, 160 chars, single-line flattened) under the untrusted section gives the director accurate grounding while protecting user safety behind the dogfood flag.

Ready for final maintainer sign-off and merge!

…oactivity

# Conflicts:
#	desktop/macos/e2e/flows/context-buckets-dogfood.yaml
@Git-on-my-level

Copy link
Copy Markdown
Collaborator

Status check on the updated head (e1e8068): that commit is a merge of main only — none of the merged files touch desktop/, so the speech-proactivity code is unchanged from the previously reviewed revision. Verified on this pass:

  • Both refinements from the earlier review haven't landed yet: SpeechProactivityCoordinator.observe still runs admission on every arriving slice (an other-speaker slice arriving after the 90 s cooldown can re-trigger evaluation on a retained user utterance — every delivery gate still applies, so this stays non-blocking polish), and the changelog fragment still reads as shipped behavior without a (dogfood) qualifier. Worth folding both in before this leaves dogfood.
  • All desktop checks pass on the merged head (build & tests, release compile, static & test contracts, e2e t0), and the merge didn't disturb anything this PR depends on.

The open item remains the product call rather than the engineering: unprompted speech-triggered spoken responses, and quoting bounded ambient speech into the director prompt, need maintainer sign-off.


by AI on behalf of David — product-direction decision needed for unprompted speech-triggered spoken responses and ambient-speech-in-prompt; tag @Git-on-my-level to weigh in.

…he window

Admission read the window's retained user slice, so once the 90s cooldown
lapsed another person speaking re-opened an evaluation grounded on a user
utterance from up to the 180s retention window earlier. The user had said
nothing; the director would have answered a stale question. Every delivery
gate still applied, so this was wrong grounding rather than an escape.

The decision now runs against the slice that just arrived: a non-user slice
can never trigger. Other-speaker slices are still appended to the window, so
they keep grounding the LIVE SPEECH section — they just stop being triggers.

Also keys the decision to the backend segment. A segment is re-delivered as
it grows, so the same utterance arrives repeatedly and would otherwise
evaluate again on each re-delivery once the cooldown lapsed.

Changelog now says "(dogfood builds only)" so release notes cannot get ahead
of isTranscriptProactivityEnabled, which is hard-off in production and beta.

Both were raised in review on 2026-08-18 and again on the merged head.

Verification:
- swift build -> clean
- swift test --filter TranscriptDrivenProactivityTests -> 16 passed (was 14)
- new guard proven live: dropping the isUser gate makes
  testOtherSpeakerAfterCooldownDoesNotRetriggerOnAStaleUserUtterance fail
  with "evaluate"; restoring it passes
- check-desktop-changelog.py -> fragment found

Failure-Class: none
@aryanorastar

aryanorastar commented Aug 21, 2026

Copy link
Copy Markdown
Contributor Author

@Git-on-my-level both refinements from your review are now on tip b4ea2b6, and the one red check is not this diff.

Refinements landed

SpeechProactivityAdmission now decides about the slice that just arrived rather than about whatever user slice the window still retains, so a non-user slice can never trigger. Other-speaker slices are still appended, so they keep grounding the LIVE SPEECH section — they just stop being triggers. I also keyed the decision to the backend segmentID: a segment is re-delivered as it grows, so the same utterance would otherwise evaluate again on every re-delivery once the cooldown lapsed. That adds a .alreadyEvaluated skip reason alongside the existing ones.

Two regression tests pin exactly the scenario you described — an other-speaker slice arriving 95s later with the user's utterance still retained, and the same segment re-arriving after the cooldown. Suite is 16 tests, up from 14.

I checked the new guard actually does the work rather than assuming it: dropping the isUser gate makes testOtherSpeakerAfterCooldownDoesNotRetriggerOnAStaleUserUtterance fail with evaluate instead of skip(.noUserSpeech); restoring it passes. swift build clean, check-desktop-changelog.py passes.

Changelog fragment now reads "(dogfood builds only)" so release notes cannot get ahead of isTranscriptProactivityEnabled.

The red desktop-core-e2e-t0 is a bug on main, not here

notifications-settings.yaml: unknown bridge action 'settings_notifications_snapshot'
notifications-settings.yaml: unknown bridge action 'set_notification_settings'
desktop-flow-lint: 2 error(s)

d5596a6 relocated those two actions into DesktopAutomationBridge+Notifications.swift without adding that file to ACTION_SOURCE_RELATIVE_PATHS, which is the only list the lint reads. Reproducible against a pristine checkout of main with no branch involved:

git archive origin/main | tar -x -C /tmp/mainlint
cd /tmp/mainlint && python3 desktop/macos/scripts/desktop-flow-lint.py

This PR only surfaced it by being merged with current main. Filed as #11985. It reddens every desktop PR that merges main; it went unnoticed because main's own Desktop Backend Contracts runs are sitting at action_required rather than executing.

Resolved: main fixed this itself in 4d7c209e (#11962), which added the missing entry alongside unrelated memory-TTL work. I have merged current main, so this branch now carries the fix and desktop-flow-lint passes locally — OK (72 flows, 162 registered actions). The one-line PR I had opened for it (#11984) is closed as a no-op against current main.

Edited to correct this paragraph: it originally said the fix was pending in #11984 and that I was holding the merge back. Main landed its own fix in the meantime, so the merge is pushed and #11984 is closed.

Product call is unchanged and still yours: unprompted speech-triggered spoken responses, and quoting bounded ambient speech into the director prompt.

…oactivity

# Conflicts:
#	desktop/macos/Desktop/Sources/ProactiveAssistants/Core/ContextProactivityEngine.swift
@Git-on-my-level

Copy link
Copy Markdown
Collaborator

Follow-up on the current head (4d337f4): both refinements from the earlier review landed on b4ea2b6, verified — and the three merges from current main didn't disturb the speech path.

  • SpeechProactivityAdmission.decides now decides about the slice that just arrived (arrivingSlice.isUser) rather than the window's retained latestUserSlice, so another speaker after the cooldown can no longer re-trigger an evaluation grounded on a stale user utterance — testOtherSpeakerAfterCooldownDoesNotRetriggerOnAStaleUserUtterance pins that exact scenario. Keying admission to the backend segmentID (.alreadyEvaluated) also stops a growing segment from re-evaluating on every re-delivery once the cooldown lapses — good catch on that re-delivery behavior; it's pinned by testSameSegmentDoesNotEvaluateTwiceAfterTheCooldownLapses as well.
  • The changelog fragment now carries "(dogfood builds only)", aligned with ContextBucketsFeature.isTranscriptProactivityEnabled's hard-off in production/beta.
  • Merge check on 4d337f4: evaluateFromSpeech still rides the full guarded tail (authorization snapshot + recheck, ContextDeliveryBudget.freeGate preflight, fence freshness, ContextDirectorEligibility, frame grounding, dwellAdmission serialization), ContextProactivityPromptBuilder.liveSpeechSection still lands additively behind the speechSection: String? = nil default so dwell/departure prompts are untouched, and the AppState+ListenEventsSpeechProactivityCoordinator.observe hook still no-ops while the flag is off. Desktop checks are green on this head; the suite is 16 tests now.

No open technical concerns from my side. What remains is the product decision for a maintainer: unprompted speech-triggered spoken responses, and quoting bounded ambient speech into the director prompt, are a new UX/privacy surface even dogfood-gated — ready to merge once someone who owns that UX signs off.


by AI on behalf of David — remaining before merge: product sign-off on unprompted speech-triggered spoken responses and ambient speech in the director prompt; tag @Git-on-my-level to weigh in.

@aryanorastar

Copy link
Copy Markdown
Contributor Author

@Git-on-my-level thanks — one reframing of what the remaining sign-off actually commits to, because I think the current framing asks for more than the merge does.

Each of your passes has landed on some version of "a new ambient-audio-to-model-prompt surface needs product sign-off before merge". That reads as though merging turns the surface on. It cannot. The gate is bundle-derived, not remote:

@MainActor static var isTranscriptProactivityEnabled: Bool {
  guard isEnabled else { return false }
  if AppBuild.isNonProduction {
    return ProcessInfo.processInfo.environment["OMI_FORCE_SPEECH_PROACTIVITY"] != "0"
  }
  return false
}

Following that through AppBuild:

var isNonProduction: Bool {
  bundleIdentifier.hasPrefix("com.omi.")
    && !AppBuild.productionFamilyBundleIdentifiers.contains(bundleIdentifier)
}

productionFamilyBundleIdentifiers is exactly {productionBundleIdentifier, betaProductionBundleIdentifier} — the shipped stable app and the shipped Beta. Both are members, so both take the unconditional return false. There is no remote enable and no Info.plist key that reaches this; the only path to true is running a non-production bundle id, and even then the env var can force it off. bundleIdentifier also fails closed — Bundle.main.bundleIdentifier ?? productionBundleIdentifier.

So merging this ships no behavior change to any user. Nothing quotes ambient speech into a prompt on a shipped build, because the funnel no-ops before the window is ever appended to.

That splits the decision you are being asked to make into two, with very different weights:

  1. Merge this now — commits to nothing user-visible, and stops the branch from re-merging main indefinitely. This is the one that is currently blocked.
  2. Sign off on the UX/privacy surface — unprompted speech-triggered responses, and bounded ambient speech in the director prompt. That genuinely needs a human, and it is the gate on removing the flag, not on merging the code behind it.

Right now (2) is blocking (1), and I do not think it needs to. Deciding (2) well wants dogfood evidence — how often it fires, what it quotes, whether the 6-slice / 160-char bound holds up against real multi-speaker rooms — and that evidence cannot exist until the code is on main and running in dogfood builds. The current ordering asks for the judgement before the data that would inform it.

I am not asking anyone to skip (2). I am asking whether it should gate the merge or gate the flag flip. If the answer is the flag flip, this is mergeable today. If you would rather hold the whole thing until the UX call is made, that is a legitimate answer too and I will stop merging main into it and mark it draft — I would just rather that be a decision than a default.

One honest note on the asymmetry with my other stalled PR: I am not making this argument for #11801. That one genuinely does ship to users on merge, and its measured ~15–35 s ambient latency makes it weak as command-and-control, so the product call there really is a merge blocker. The difference is exactly this flag.

Nothing else is outstanding here — both refinements from your earlier passes landed on b4ea2b6, checks are green on 4d337f4, and the suite is 16 tests.

Gate rejections carried a stage but not a trigger, so a rejection from a screen
visit and one from a transcript were the same row. That made the question a
reviewer asked of this PR unanswerable from the data: both lanes draw on one
daily allocation, so "do transcripts crowd out screen nudges?" needs the split
and there was none.

`GateTrigger.forSpeechSection` derives it from an argument the call already
carries — the speech lane is the only evaluation entry point that supplies a
transcript section, so no new plumbing has to be kept in sync when an entry
point is added. Named rather than inlined so the rule has one home and a test.

Threaded through both shared evaluators; 9 of the 11 gate sites now attribute a
lane. The two that keep the `.screenVisit` default are the screen entry point
itself and the retrieval-hop site, which has no lane context.

The values are bounded enums and go out as analytics properties — no bucket,
owner, or content data, matching what the event already carried.

Verification:
  swift build                                          -> clean
  swift test --filter ContextProactivityEngineTests    -> 23 passed

  Includes a test pinning the two raw values, because renaming one silently
  breaks the split this exists for, and one for the empty-string case: an empty
  section still came from the speech lane, and defaulting it to screen would
  move rows into the wrong column.

This instruments the question; it does not answer it. The A/B is the same
session run twice, `OMI_FORCE_SPEECH_PROACTIVITY=0` then default, comparing
evaluations consumed and delivered per lane. Numbers to follow on the PR.

Failure-Class: none
@aryanorastar

Copy link
Copy Markdown
Contributor Author

A/B, identical script: 1 delivery transcripts-off, 1 transcripts-on. No increase.

Video: 10.5 min real Hotstar + room talk, 213 segments → 1 notification, and it was correct — flagged a real open task while I was on leisure content. 17 cooldown suppressions behind it.

Budget: a transcript evaluation only consumes allocation if it delivers; the counter excludes suppressed/failed.

Caveat: single session, and the injected half tests gating not STT.

@Git-on-my-level

Copy link
Copy Markdown
Collaborator

Follow-up on the current head (774ae9d), covering the two commits since 4d337f4 plus the merge.

Lane-dimension telemetry (2bf4aa2f) — verified, one small gap

ContextProactivityTelemetry.GateTrigger now records which lane started a rejected evaluation, derived once in evaluateAndDeliver via forSpeechSection(speechSection) — the speech lane is the only entry point that supplies a section, so the argument carries the distinction instead of each gate site repeating it. I checked all nine recordGateRejection call sites in ContextProactivityEngine.swift: the speech preflight passes trigger: .speech, and the shared-tail sites (attempt / reservation / preModel / presentation / handoff) all thread gateTrigger through. The three new tests in ContextProactivityEngineTests.swift pin the lane split, the empty-section-still-speech case, and the raw values — good, since renaming a value would silently break the analytics split.

One nit: the .retrievalHop site (ContextProactivityEngine.swift:1208) still uses the default trigger:. performRetrievalHop is called from inside evaluateAndDeliver (line 647), which the speech lane reaches, so a speech-grounded evaluation rejected during the retrieval hop would be counted as screen_visit — exactly the crowding-out miscount this dimension exists to prevent. A one-word fix whenever the file is next touched; not blocking.

Merge check (774ae9d)

The final merge brought roughly 150 desktop files in from main. Nothing PR-owned changed: liveSpeechSection in ContextBucketRollup.swift is byte-identical (main's edits there are director prompt-policy refinements elsewhere in the file), ContextBucketsFeature.isTranscriptProactivityEnabled is unchanged, and evaluateFromSpeech still runs the full guarded tail — flag, authorization snapshot + recheck, free-gate preflight, active fence, dwellAdmission serialization, fence freshness twice, eligibility, frame grounding — before evaluateAndDeliver(speechSection:). Desktop checks are green on the head.

On the merge-vs-flag-flip framing

I verified the gate chain you quoted: AppBuild.productionFamilyBundleIdentifiers covers both the stable and Beta bundle ids, bundleIdentifier fails closed to production, and there is no remote or Info.plist path that reaches isTranscriptProactivityEnabled — merging this ships no behavior change on any shipped build. The argument is technically sound, and the dogfood evidence (213 segments → 1 correct delivery, 17 cooldown suppressions, budget consumed only on delivery) is the right kind of data, single session notwithstanding.

Whether that makes the UX/privacy call a gate on the merge or a gate on removing the flag is a product-ordering decision I'm leaving explicitly with David rather than deciding here. No blocking technical issues on this head.


by AI on behalf of David — decision needed on merge-vs-hold ordering for this dogfood-hard-off feature, alongside the standing UX/privacy sign-off for unprompted speech-triggered responses and ambient speech in the director prompt; @Git-on-my-level.

…t started them

The retrieval hop is reached from inside evaluateAndDeliver, which the speech
lane enters, so a speech-grounded evaluation rejected while the hop rebuilt the
free gate was counted as screen_visit — the exact crowding-out miscount the
trigger dimension exists to prevent.

gateTrigger is threaded in without a default so the compiler, not a reviewer,
enforces that every call site says which lane it came from.
@aryanorastar

Copy link
Copy Markdown
Contributor Author

@Git-on-my-level the .retrievalHop nit is fixed on b401c1c2.

performRetrievalHop now takes gateTrigger and passes it to the .retrievalHop rejection, so a speech-grounded evaluation gated while the hop rebuilds the free gate is counted as speech rather than screen_visit. I made the parameter non-defaulted rather than defaulting it to .screenVisit — there is exactly one call site today, and without a default the compiler is what enforces that a future second call site declares its lane, instead of it silently inheriting the wrong one. That is the failure mode this dimension exists to catch, so it seemed worth spending the argument.

No test added: this is an argument threaded through a private method with a single caller, and the engine has no end-to-end retrieval-hop harness — building one to pin a pass-through would be more machinery than the change. The compiler covers it, and the three existing lane tests still pin forSpeechSection and the raw values.

swift build clean, swift-format lint --strict clean, 39/39 in ContextProactivityEngineTests + TranscriptDrivenProactivityTests. All checks green on the head.

Nothing else outstanding from any of your passes. The open item is still the product one, and I take your point that the merge-vs-hold ordering is David's call rather than yours to make here.

@Git-on-my-level

Copy link
Copy Markdown
Collaborator

Follow-up on the current head (b401c1c2) — the .retrievalHop attribution nit from my last pass is fixed, verified on the head blob.

performRetrievalHop now takes gateTrigger as a non-defaulted parameter and passes it to the .retrievalHop rejection (ContextProactivityEngine.swift:1210), so a speech-grounded evaluation gated while the hop rebuilds the free gate is counted as speech instead of screen_visit. Making the parameter non-defaulted is the right call for exactly the reason you gave: with one call site, the compiler now forces any future second caller to declare its lane rather than silently inheriting .screenVisit. I re-audited all 11 recordGateRejection sites on the head — the speech preflight passes trigger: .speech, every shared-tail site (attempt / reservation / preModel / presentation / handoff, on both the main and candidate paths) threads gateTrigger through, and the single remaining defaulted call is the contextEntered dwell preflight, where .screenVisit is the correct lane. Skipping a dedicated test for a compiler-enforced argument pass-through with one caller is reasonable; the three lane tests still pin forSpeechSection and the raw analytics values.

With that, every technical item raised across the passes on this PR is resolved: admission decides on the arriving slice and dedupes by segment ID, the changelog fragment carries the dogfood qualifier, the speech path rides the full guarded delivery tail, and the == LIVE SPEECH == section stays bounded, single-line flattened, and quoted as untrusted data under the preamble. Desktop checks are green on the head. Thanks for the persistence through the main re-merges — the lane-dimension telemetry and the dogfood A/B evidence materially raised the quality of the product conversation on this thread.

The one open item is the product decision the thread has converged on: whether UX/privacy sign-off for unprompted speech-triggered responses (and ambient speech quoted into the director prompt) gates this merge or gates removing the dogfood flag. That ordering call stays with a human maintainer.


by AI on behalf of David — remaining before merge: product decision on whether UX/privacy sign-off gates the merge or the flag removal, for the speech-triggered response surface; @Git-on-my-level.

@aryanorastar

Copy link
Copy Markdown
Contributor Author

@Git-on-my-level thanks for the re-audit of all 11 recordGateRejection sites — confirming the one remaining defaulted call is the contextEntered dwell preflight is a stronger check than the one I did, and it is the right lane there.

With that pass, this PR is in an unusual state that I think is worth naming plainly, because it looks like a review gap rather than a decision:

  • Every technical item raised across four passes is resolved, in your words.
  • All checks are green on b401c1c2 — 18 successful, 9 skipped, 0 failing, 0 pending.
  • There is no CHANGES_REQUESTED on file.
  • The merge is still blocked on "at least 1 approving review by reviewers with write access."

The gap is mechanical: your passes on this thread have been issue comments rather than submitted reviews. The only formal review on this PR is a single COMMENTED from Aug 18 on 0cf23fc0, so none of the four subsequent passes — including the one where you wrote "no blocking technical issues found" — count toward that gate.

@kodjima33 — this one has never reached you. You have given approve-only passes on #11206, #11400, #11452 and #11807 under the same feature policy; #11804 is the sibling of #11807 and is the only one of the set with nothing outstanding against it. I cannot add you as a reviewer myself (the API returns 404 for a fork contributor), so this comment is the only route I have.

On the product question, I am not re-arguing it — I made that case above and @Git-on-my-level has already verified the gate chain independently. I would just note the two are separable: the approving review is about whether the code is correct, which the passes above have answered; the UX/privacy sign-off is about whether the flag ever flips, which stays open either way and does not need to be settled to approve correct code that is hard-off on every shipped bundle.

If the intent is to hold this until the UX call is made, that is a fine answer and I will mark it draft and stop merging main into it. I would rather have that than have it sit on a gate nobody realised was unsatisfied.

@aryanorastar

aryanorastar commented Aug 28, 2026

Copy link
Copy Markdown
Contributor Author

@Git-on-my-level ## Current-main refresh + verification proof

Refreshed this branch onto origin/main at cd6765ba9c and pushed head 017b2f6292.

Scope integrity

  • Merge completed without conflicts.
  • The PR diff remains exactly 13 macOS files: 9 production Swift files, 2 Swift test files, 1 changelog fragment, and 1 macOS e2e-flow update.
  • Diff remains 607 additions / 18 deletions.
  • No iOS, Android, backend, or Windows changes were pulled into this PR.
  • git diff --check origin/main...HEAD passes.

Behavior verification on current main

xcrun swift test -c debug --package-path Desktop \
  --filter 'ContextProactivityEngineTests|TranscriptDrivenProactivityTests'

ContextProactivityEngineTests:      23 passed
TranscriptDrivenProactivityTests:   16 passed
Total:                              39 passed, 0 failed

This covers the existing dwell path plus transcript-window dedup/bounds, admission decisions, arriving-slice semantics, cooldown, prompt grounding, stale-fence rejection, lane attribution, and delivery behavior.

Static and repository gates

  • PR-scoped pinned swift-format --strict: pass
  • Pinned SwiftLint: 0 violations across 1,429 files
  • Desktop test-quality ratchet: pass
  • Desktop flow contract: 74 flows / 181 actions, pass
  • Desktop changelog requirement: pass
  • Diff hygiene, author identity, line-count ratchet, failure-class protocol, e2e coverage (9/9 sources covered), GRDB idiom, brand invariant, and deferred-work checks: pass

Known base-branch preflight issue

The aggregate preflight currently stops on a pre-existing origin/main legacy-memory ratchet mismatch in:

desktop/windows/src/main/assistants/insight/prompt.ts: 4 -> 5

That file is not in this PR. The isolated main-wide repair is #12339; its hygiene and backend unit gates are green. I deliberately did not mix that unrelated repair into this feature branch.

Rollout safety

The feature remains hard-off in production and Beta. Only non-production/dogfood bundles enable it, with OMI_FORCE_SPEECH_PROACTIVITY=0 available as an explicit kill switch. This refresh does not change that rollout boundary.

@Git-on-my-level

Copy link
Copy Markdown
Collaborator

Refresh check on 017b2f6292 (merge of main at cd6765ba9c) — verified from the head blobs, not the description.

PR-owned code is unchanged. All 13 files re-checked against the last-reviewed head (b401c1c2): SpeechProactivityAdmission.swift (arriving-slice gating + segmentID dedup), SpeechProactivityCoordinator.swift (MainActor observe loop), TranscriptSpeechWindow.swift (12-slice / 180 s bounded window), ContextBucketRollup.swift's liveSpeechSection (≤6 slices, single-line flattened, quoted as untrusted under the preamble), ContextBucketsFeature.isTranscriptProactivityEnabled (hard-off in production/beta), ContextVisitCoordinator.activeFence(), the changelog fragment's dogfood qualifier, the covers: entries, and both test files (39 tests) are blob-identical. The four files that did change (AppState+ListenEvents.swift, ContextProactivityEngine.swift, ProactiveLaneClient.swift, context-buckets-dogfood.yaml) changed only through main's side of the merge — ProactiveListenAdmission/proactive_message routing and the hasTranscriptionBYOK rename in the first, the JIT hooks and JIT client methods in the others. evaluateFromSpeech still rides the full guarded tail (flag, authorization snapshot + recheck, free-gate preflight with trigger: .speech, active fence, dwellAdmission serialization, fence freshness twice, eligibility, frame grounding).

The red Hygiene check is not this diff. From the job log: legacy-memory-surface-ratchet fails on desktop/windows/src/main/assistants/insight/prompt.ts: 4 -> 5, and that file is byte-identical (blob 51782366bf5) between main cd6765ba9c and this head. The growth landed on main in 61dcb9473c (Aug 27); the isolated repair is #12339. Nothing for this PR to do beyond waiting on that fix.

One new integration point worth a look before dogfood widens. Main's JIT work now fronts the dwell and departure lanes: contextEntered and evaluateAfterDeparture call JITProactivityCoordinator.handle(...) first (engine lines 220 / 278), and only a .legacyContextBucketFallback decision falls through to evaluateAndDeliver. evaluateFromSpeech calls evaluateAndDeliver directly, so the speech lane never consults the JIT admission stack. With an owner moved onto the JIT rollout (rollout enabled, kill switch disabled), dwell/departure deliveries come from the JIT lane — or are JIT-suppressed — while a spoken mid-visit question still evaluates through the legacy director tail. Practically: the JIT kill switch does not stop speech-lane deliveries in dogfood builds, and the GateTrigger telemetry split (screen_visit vs speech) predates this third admission path. Blast radius today is dogfood-only (the transcript flag is hard-off in production/beta, and JIT default-unknown fails closed to legacy), so this reads as a sequencing question to settle before the dogfood flag flips or JIT rollout widens — either route speech through the same admission or note that the lanes are intentionally independent.

Desktop checks are green on this head (build & tests, release compile, static & test contracts, e2e t0). The open item is unchanged from the previous passes: whether UX/privacy sign-off for unprompted speech-triggered responses (and ambient speech in the director prompt) gates this merge or gates removing the dogfood flag — that stays with a human maintainer.


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

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 positive-signal Good PR — positive signal, not a formal approval

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants