Skip to content

feat(desktop): withhold proactive notifications while other people are present - #11864

Open
aryanorastar wants to merge 17 commits into
BasedHardware:mainfrom
aryanorastar:feat/presence-aware-notifications
Open

feat(desktop): withhold proactive notifications while other people are present#11864
aryanorastar wants to merge 17 commits into
BasedHardware:mainfrom
aryanorastar:feat/presence-aware-notifications

Conversation

@aryanorastar

@aryanorastar aryanorastar commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

Summary

A proactive notification is addressed to one person. Omi delivered them identically whether the user was alone, in a call, or presenting to a room. A live session put "Submit prototype for SBI Hackathon before the deadline" on screen — useful when alone, a disclosure on a shared screen, and an interruption mid-meeting.

Two distinct harms, one rule:

  • sharing a screen makes a private nudge visible to everyone on the call
  • being in a call at all makes it interrupt a conversation

I first scoped this to screen share only, reasoning that being on a call is not the same as your screen being visible. Testing against a live Google Meet call disproved that: "Meet is fine — but you said you'd submit the SBI Hackathon prototype" was delivered mid-meeting. Detection now covers both.

Where the guard sits

NotificationService.sendNotification is the single choke point every proactive surface already routes through — suggestion, memory, insight, goals, meeting action items, plugin (9 call sites) — so one guard covers all of them rather than a per-assistant exception.

respectFrequency is the existing proactive/functional split and is honoured: functional notices (screen-recording repair prompt, Crisp replies, onboarding test) pass false and still reach the user. Suppressing the capture-repair prompt during a share is precisely how a broken capture would stay broken, since that prompt is what tells the user to fix it.

Detection reuses three signals that already exist and are already trusted in production:

Signal Catches
activeScreenSharePresent() outgoing share (already used to pause capture during shares, #10143)
callAppIsUsingMicrophone() an active call
browserCallWindowPresent() a muted browser call, where mic input has dropped

The third is what caught the Meet case above. Detection is placed after the cheap boolean gates so the window scans never run for a notification an earlier gate already refused.

Withheld, not destroyed

This is the part worth reviewing closely.

SuggestionAssistant writes recentSuggestions immediately before delivering, and that window gates every later evaluation. Withholding only at the choke point would have recorded the suggestion as delivered and retired it permanently: the user never sees the card, and every regeneration after the call is filtered as a duplicate of something that was never shown.

The assistant therefore consults the same shared policy before the dedup write and returns early, leaving the suggestion eligible once the call ends. suppressed_presenting is a distinct delivery outcome from the filtered_* ones: those retire a suggestion on its merits, this defers a good one on audience.

Verification

swift test --filter PresenceAwareNotificationSuppressionTests10 passed, including the two that pin the deferral guarantee (remembered ⇒ duplicate forever; unremembered ⇒ still deliverable).

Live end-to-end on the dev serving plane, one session, no screen share — joined a Google Meet call in a browser:

17:23:35  delivering                                     (before the call)
17:24:22  delivering
17:25:26  withheld while others are present [commitment]  (call starts)
17:25:47  withheld while others are present
17:26:07  withheld while others are present
17:26:27  withheld while others are present
17:27:17  delivering "Google Meet is fine, but..."        (call ends)

The 17:27:17 delivery is the deferral proof: had the withheld suggestions been remembered, it would have been filtered as a duplicate.

Also included

One diagnostic commit: the model-chosen category is now named on suggestion delivery and duplicate log lines. SuggestionPacing.dedupMemory picks a suggestion's dedup depth from that category, so a repeat that should have been suppressed was previously unexplainable. It is what let me disprove my own first theory about a repeat report — the label was commitment all along, carrying full depth, which pointed at the similarity threshold in SuggestionDeduplication.isDuplicate instead. That defect is not addressed here.

Honest gaps

  • callAppIsUsingMicrophone() requires macOS 14.4+. On 14.0–14.3 a native-app call (Zoom desktop, FaceTime) is not detected and only browser calls are, via window title. This fails open — a missed suppression, never a missed notification.
  • Detection costs up to two CGWindowList scans plus an audio-process enumeration per proactive notification that reaches this gate.
  • Withheld suggestions are not queued for replay; they are re-evaluated naturally when context recurs.
  • Screen-share detection relies on window titles and covers Zoom, Teams and browser-based sharing; other conferencing apps are not recognised.
  • No user-facing control yet to snooze or opt out of this behaviour. If the team would rather this be a setting than a default, that is an easy follow-up.
  • The other proactive lane (ContextDeliveryAuthority) has its own gate reasons and is not touched here.

Product invariants affected

  • INV-CHAT-1

Cited because FloatingControlBarView.swift and NotchMomentsCoordinator.swift are
surfaces the invariant governs. Nothing here forks the shared transcript: both changes
are about whether a card is presented, not about where conversation state lives. No new
store, no per-surface continuity ring, no session identity moved into Swift — the notch
receipts still read TasksStore and the canonical action-items path exactly as before,
and routing them through NotificationService changes only the gating decision in front
of the same presentation primitive.

Failure class (fixes)

Failure-Class: none

Line-Count-Exception: desktop/macos/Desktop/Sources/ProactiveAssistants/Services/NotificationService.swift | 1255 -> 1572 | This file is the single choke point where the master toggle, frequency throttle, snooze and presence gates live, so gated entry points belong here rather than in a sibling that could be bypassed. Growth is those gates, the deferral-preserving suggestion path, presentActionableProactiveNotification, and splitting presence into its two harms — sharing withholds delivery, a call withholds only the voice.

Line-Count-Exception: desktop/macos/Desktop/Sources/FloatingControlBar/FloatingControlBarView.swift | 3012 -> 3036 | Adds the "Silence suggestions for N hours" snooze menu (Resume / 1h / 4h / 8h) to the bar's context menu, the user-facing control this PR's honest gaps called out as a follow-up.

Review in cubic

`SuggestionPacing.dedupMemory` chooses a suggestion's dedup depth from its
category, and that category is chosen by the model -- it is a decoded field on
`ExtractedSuggestion`, not something the client derives. At Maximum frequency the
depth is 0 for every category except `commitment`, so a mislabelled suggestion
silently loses dedup entirely and repeats forever.

Neither the delivery nor the duplicate log line carried the label, so a user
reporting "it keeps telling me the same thing" could not be answered without
guessing. Investigating exactly that report, the label turned out to be
`commitment` -- which carries the full depth of 10, disproving the mislabel
theory and pointing instead at the similarity threshold in
`SuggestionDeduplication.isDuplicate`:

    delivering [90%] [commitment] "Submit prototype for SBI Hackathon @ GFF 2026."
    duplicate  [commitment] "Submit prototype for SBI Hackathon @ GFF 2026"
    delivering [95%] [commitment] "The Omi Proactivity app is fine, but submit
                                   prototype for SBI Hackathon @ GFF 2026."

The same commitment passes as novel once the model prepends a context-aware
clause, because the added words drop the word overlap below the threshold. The
context-awareness that makes a suggestion feel present is what defeats the repeat
guard. That defect is not addressed here; this change is what made it visible.

Verification
- swift build -> clean
- Live session: category now present on every delivery and duplicate line.

Honest gaps
- Diagnostics only, no behaviour change.
- The `below bar` line is left alone; it already carries the confidence numbers
  that explain it.
…e present

A proactive notification is addressed to one person. Omi delivered them
identically whether the user was alone, in a call, or presenting to a room. A
live session produced "Submit prototype for SBI Hackathon before the deadline"
on screen -- useful alone, a disclosure on a shared screen and an interruption
mid-meeting.

Two distinct harms, one rule:

- sharing a screen makes a private nudge **visible** to everyone on the call
- being in a call at all makes it **interrupt a conversation**

Scoping this to screen share alone was tested against a live Google Meet call and
let "Meet is fine — but you said you'd submit the SBI Hackathon prototype"
through while the user was mid-meeting, so detection covers both.

## Where the guard sits

`NotificationService.sendNotification` is the single choke point every proactive
surface already routes through (suggestion, memory, insight, goals, meeting
action items, plugin -- 9 call sites), so one guard covers all of them rather
than a per-assistant exception.

`respectFrequency` is the existing proactive/functional split and is honoured:
functional notices (screen-recording repair prompt, Crisp replies, onboarding
test) pass `false` and still reach the user. Suppressing the capture-repair
prompt during a share is precisely how a broken capture would stay broken.

Detection reuses three signals that already exist and are already trusted:
`activeScreenSharePresent()` (used to pause capture during shares, BasedHardware#10143),
`callAppIsUsingMicrophone()`, and `browserCallWindowPresent()` -- the documented
fallback for a *muted* browser call, which is what caught the Meet case above.
It is placed after the cheap boolean gates so the window scans never run for a
notification an earlier gate already refused.

## Withheld, not destroyed

`SuggestionAssistant` writes `recentSuggestions` immediately *before* delivering,
and that window gates every later evaluation. Withholding only at the choke point
would have recorded the suggestion as delivered and retired it permanently: the
user never sees the card, and every regeneration after the call is filtered as a
duplicate of something never shown. The assistant therefore consults the same
shared policy *before* the dedup write and returns early, leaving the suggestion
eligible once the call ends.

`suppressed_presenting` is a distinct delivery outcome from the `filtered_*`
ones: those retire a suggestion on its merits, this defers a good one on audience.

## Verification

- `swift test --filter PresenceAwareNotificationSuppressionTests` -> 10 passed,
  including the two that pin the deferral guarantee (remembered => duplicate
  forever; unremembered => still deliverable).
- Live end-to-end on the dev serving plane, one session, no screen share --
  joined a Google Meet call in a browser:

      17:23:35  delivering                                     (before the call)
      17:24:22  delivering
      17:25:26  withheld while others are present [commitment]  (call starts)
      17:25:47  withheld while others are present
      17:26:07  withheld while others are present
      17:26:27  withheld while others are present
      17:27:17  delivering "Google Meet is fine, but..."        (call ends)

  The 17:27:17 delivery is the deferral proof: had the withheld suggestions been
  remembered, it would have been filtered as a duplicate.

## Honest gaps

- `callAppIsUsingMicrophone()` requires macOS 14.4+. On 14.0-14.3 a native-app
  call (Zoom desktop, FaceTime) is not detected and only browser calls are, via
  window title. This fails open -- a missed suppression, never a missed
  notification.
- Detection costs up to two `CGWindowList` scans plus an audio-process
  enumeration per proactive notification that reaches this gate.
- Withheld suggestions are not queued for replay; they are re-evaluated
  naturally when context recurs.
- Screen-share detection relies on window titles and covers Zoom, Teams and
  browser-based sharing; other conferencing apps are not recognised.
- No user-facing control yet to snooze or opt out of this behaviour.
@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.

Presence-aware suppression handles the case Omi can detect. It does not help a user
who simply does not want to be nudged right now -- the only controls were the
master toggle (off forever) and the frequency slider (permanently quieter).

Adds a bounded silence: 1, 4 or 8 hours, from Settings > Notifications & Privacy,
beside the frequency slider because it answers the same question -- how often may
Omi interrupt me -- for a window rather than forever. The row shows live state
("Silenced until 12:35 AM") and offers "Resume now" while active.

## Not the same as hiding the bar

`floatingBar_snoozedUntil` already exists and deliberately does *not* do this.
`NotificationService` documents why: "Hiding the floating bar ('Hide for 2 hours')
and disabling it are both statements about the BAR, not about notifications: an
hour of a movie with the bar hidden or off must still nudge."

This is a separate key, `notifications_snoozedUntil`, making the statement that one
is documented not to make. A test asserts the two keys stay distinct so a later
change cannot quietly merge them.

## Withheld, not destroyed

`SuggestionAssistant` consults the snooze *before* writing `recentSuggestions`, the
same ordering the presence guard uses and for the same reason: the dedup window
gates every later evaluation, so recording a suggestion that will never be shown
retires it permanently. Silencing for 8 hours must not annihilate every suggestion
generated in that window. `suppressed_snoozed` is a distinct delivery outcome from
both `filtered_*` (retired on merit) and `suppressed_presenting` (deferred on
audience).

Functional notices are unaffected: `respectFrequency: false` still passes, so a
screen-recording repair prompt reaches a user who silenced suggestions -- otherwise
the snooze swallows the message explaining why capture broke.

## Verification

- swift test --filter 'NotificationSnoozeTests|PresenceAwareNotificationSuppressionTests'
  -> 19 passed (9 snooze + 10 presence)
- Live on the dev serving plane:

      23:35:51  NotificationService: proactive notifications silenced for 60m
      23:39:53  Suggestion: withheld while notifications are silenced [commitment]
                — "You said you'd submit the SBI Hackathon prototype, but it's overdue."

  and `probe_suggestion_nudge` returned outcome `suppressed_snoozed`. The
  suggestion was generated and then withheld, which is the ordering that matters:
  it had already passed the duplicate filter on merit.
- Settings row verified on screen showing live state.

## Honest gaps

- Placement went to Settings after the floating-bar context menu proved wrong in
  practice: in notch-island mode the bar is covered by the very notification the
  user wants to silence, so the control was unreachable exactly when needed. The
  bar menu still carries the same actions as a shortcut.
- Durations are fixed at 1/4/8 hours. No "until tomorrow", no custom value.
- The snooze is global, not per-assistant; silencing quiets suggestions, insights,
  memories and task nudges together.
- No notification when a snooze lapses; suggestions simply resume.
- Withheld suggestions are not queued for replay, only left eligible.
@aryanorastar

Copy link
Copy Markdown
Contributor Author

Added the user control this PR listed as an honest gap ("No user-facing control yet to snooze or opt out"). Presence-aware suppression handles what Omi can detect; it does nothing for a user who simply does not want to be nudged right now. Before this, the only options were the master toggle (off forever) or the frequency slider (permanently quieter).

Settings → Notifications & Privacy → Silence Notifications — 1, 4 or 8 hours, with live state and a "Resume now" action while active.

Not the same as hiding the bar

floatingBar_snoozedUntil already exists and deliberately does not do this. NotificationService documents why:

"Hiding the floating bar ('Hide for 2 hours') and disabling it are both statements about the BAR, not about notifications: an hour of a movie with the bar hidden or off must still nudge."

This is a separate key, notifications_snoozedUntil, making the statement that one is documented not to make. A test asserts the two keys stay distinct, so a later change cannot quietly merge them on the assumption they mean the same thing.

Withheld, not destroyed

Same ordering as the presence guard, for the same reason: SuggestionAssistant consults the snooze before writing recentSuggestions. The dedup window gates every later evaluation, so recording a suggestion that will never be shown retires it permanently — silencing for 8 hours must not annihilate every suggestion generated in that window.

suppressed_snoozed is a distinct delivery outcome from both filtered_* (retired on merit) and suppressed_presenting (deferred on audience), so the three are separable in telemetry.

Functional notices are unaffected: respectFrequency: false still passes, so a screen-recording repair prompt reaches a user who silenced suggestions. Otherwise the snooze swallows the message explaining why capture broke.

Verification

  • swift test --filter 'NotificationSnoozeTests|PresenceAwareNotificationSuppressionTests'19 passed (9 snooze + 10 presence)
  • Live on the dev serving plane:
23:35:51  NotificationService: proactive notifications silenced for 60m
23:39:53  Suggestion: withheld while notifications are silenced [commitment]
          — "You said you'd submit the SBI Hackathon prototype, but it's overdue."

probe_suggestion_nudge returned outcome suppressed_snoozed. Note the ordering that matters: the suggestion was generated and had already passed the duplicate filter before the snooze withheld it — so this is a deferral, not a suggestion that was going to be dropped anyway.

  • Settings row verified on screen showing live state ("Silenced until 12:35 AM").

Placement, and why it moved

I first put this in the floating bar's context menu, next to "Hide for 2 hours". That was wrong in practice: in notch-island mode the bar is covered by the very notification the user wants to silence, so the control was unreachable at exactly the moment it is wanted. Settings is always reachable. The bar menu still carries the same actions as a shortcut.

Honest gaps

  • Durations are fixed at 1/4/8 hours — no "until tomorrow", no custom value.
  • The snooze is global, not per-assistant: it quiets suggestions, insights, memories and task nudges together.
  • No notification when a snooze lapses; suggestions simply resume.
  • Withheld suggestions are not queued for replay, only left eligible.
  • Unchanged from the original description: the macOS 14.4 gap in call detection, the window-scan cost, and the untouched ContextDeliveryAuthority lane.

aryanorastar added a commit to aryanorastar/omi that referenced this pull request Aug 19, 2026
@aryanorastar

Copy link
Copy Markdown
Contributor Author

Screenshot of the control, since my previous comment referenced it but the marker was an HTML comment and rendered invisible.

Settings → Notifications & Privacy, showing the row in its active state:

Silence Notifications setting

It sits directly beneath the Frequency slider on purpose — that slider answers "how often may Omi interrupt me" permanently, and this answers the same question for a bounded window. The subtitle carries live state (Silenced until 12:35 AM) and the menu offers Resume now while a snooze is active, so the control is never one-way.

@aryanorastar

Copy link
Copy Markdown
Contributor Author

@kodjima33 @Git-on-my-level review request — no reviewer has looked at this one yet.

Withholds proactive notifications while others are present (screen share or a call, including a muted browser call — the case that disproved my initial screen-share-only scope on a live Meet call). Routes through the single NotificationService.sendNotification choke point all 9 proactive call sites already share, and withholds without burning the dedup window so a good suggestion is deferred, not lost. 10 unit tests + a live Meet session in the PR description prove the deferral guarantee.

Also fixed the missing Line-Count-Exception Hygiene failure — FloatingControlBarView.swift grew for the snooze-menu control (the "no user-facing opt-out yet" gap this PR's own description flagged, built in the same diff).

Two follow-ups to the presence and snooze gates in this PR, both from reviewing
the honest-gaps list rather than from new reports.

## The director lane bypassed both gates

`presentContextDirectorNotification` delivers straight through
`FloatingControlBarManager.showNotification` and never reaches `sendNotification`,
so both gates below it were skipped:

    let speech = NotificationSpeechOnDelivery(message: message, isProactive: true)
    ...
    return FloatingControlBarManager.shared.showNotification(...)

A user who silenced notifications for four hours, or who was mid-call, still
received director cards. That path sets `isProactive: true` a few lines later, so
it is exactly the class both gates exist to withhold — the omission was reach, not
intent. Both now run at that entry point, reusing the same pure policies rather
than restating them. `respectFrequency: true` is hard-coded because every caller
of this entry point is proactive; functional notices go through `sendNotification`
with `respectFrequency: false` and are unaffected.

## "Until tomorrow"

The offered durations were fixed offsets, and the one people want at night was
missing. "Until tomorrow" is a wall-clock boundary rather than an offset, so it is
a separate entry point: it resolves to the next 9am, not midnight — silencing at
11:30pm and resuming half an hour later is not what the phrase means to anyone.

Silencing at 2am resolves to 9am the same morning rather than waiting 31 hours,
which is the boundary worth pinning. The Settings subtitle now says "tomorrow"
when the expiry is not today, because "Silenced until 9:00 AM" otherwise reads
identically for both.

Verification
- swift build -> clean
- swift test --filter 'NotificationSnoozeTests|PresenceAwareNotificationSuppressionTests'
  -> 21 passed, including boundary cases at 11:30pm, 2:15am and exactly 9:00am,
  plus a loop asserting every hour of the day yields a future expiry
- Confirmed live: "Until tomorrow" shows "Silenced until 9:00 AM tomorrow" and
  withholds; silencing now withholds director cards as well as suggestion cards.

Honest gaps
- No unit test drives `presentContextDirectorNotification` end to end; it is
  @mainactor and constructs real presentation state. The gate decision is covered
  by the shared policy tests and the wiring was verified live.
- The 9am resume hour is fixed, not user-configurable.
- `ContextDeliveryAuthority.freeGate` still evaluates master/frequency/paywall
  separately from these two gates rather than in one place.
@aryanorastar

Copy link
Copy Markdown
Contributor Author

Worked through the honest-gaps list. Two are closed, three I am deliberately not doing, and one of those was mis-listed by me as a gap when it is the correct design — correcting that here rather than leaving it standing.

Closed

The context director lane bypassed both gates

This is the one that mattered. presentContextDirectorNotification delivers straight through FloatingControlBarManager.showNotification and never reaches sendNotification, so both gates below it were skipped:

let speech = NotificationSpeechOnDelivery(message: message, isProactive: true)
...
return FloatingControlBarManager.shared.showNotification(...)

A user who silenced notifications for four hours, or who was mid-call, still received director cards. The path sets isProactive: true a few lines later, so it is exactly the class both gates exist to withhold — the omission was reach, not intent. Both now run at that entry point, reusing the same pure policies rather than restating them.

respectFrequency: true is hard-coded there because every caller of that entry point is proactive; functional notices go through sendNotification with respectFrequency: false and are unaffected.

"Until tomorrow"

Added as a wall-clock boundary rather than an offset, so it is a separate entry point. It resolves to the next 9am, not midnight — silencing at 11:30pm and resuming half an hour later is not what the phrase means to anyone.

Silencing at 2am resolves to 9am the same morning rather than waiting 31 hours, which is the boundary worth pinning. The Settings subtitle now says "tomorrow" when the expiry is not today, since "Silenced until 9:00 AM" otherwise reads identically for both.

Not doing, with reasons

Window-scan cost — I over-flagged this. Measured on a live session: 194 gate evaluations in 24 minutes, and nearly all are cooldown/dwell skips that never reach the presence check. The scan runs once or twice a minute at a few milliseconds. A TTL cache would add mutable static state and let a notification slip through for up to 2s after a share starts — a worse trade than the microseconds saved. Leaving it.

Per-assistant snooze. Nobody has asked to silence insights while keeping task nudges. That is four times the state and UI surface on a guess, and it is easy to add later if a real request arrives.

Notification when a snooze lapses. A notification announcing that notifications are back is itself an interruption, arriving at a moment the user did not choose. The subtitle already shows the expiry for anyone who wants to check.

Replay queue for withheld suggestions — I mis-listed this. A suggestion withheld four hours ago is stale; replaying it would nudge the user about a screen they left. "Left eligible, re-evaluated when context recurs" is the correct behaviour, not a shortfall. Treat that line in the earlier comment as withdrawn.

macOS 14.4 call detection. API availability; cannot be closed. It already fails open — a missed suppression, never a missed notification.

Verification

  • swift test --filter 'NotificationSnoozeTests|PresenceAwareNotificationSuppressionTests'21 passed, including boundary cases at 11:30pm, 2:15am and exactly 9:00am, plus a loop asserting every hour of the day yields a future expiry
  • Confirmed live: "Until tomorrow" displays "Silenced until 9:00 AM tomorrow" and withholds; silencing now withholds director cards as well as suggestion cards, where previously only the latter were affected

Remaining honest gaps

  • No unit test drives presentContextDirectorNotification end to end — it is @MainActor and constructs real presentation state. The gate decision is covered by the shared policy tests; the wiring was verified live.
  • The 9am resume hour is fixed, not user-configurable.
  • ContextDeliveryAuthority.freeGate still evaluates master/frequency/paywall separately from these two gates rather than in one place. Unifying them is a larger refactor than this PR should carry.
  • Durations remain 1/4/8 hours plus "until tomorrow"; no arbitrary custom value.

Second lane found bypassing the user's notification controls, and the reason this
PR now carries a guard rather than a third copy of the fix.

`NotchMomentsCoordinator` posts the Second Brain "moments" -- live receipts as Omi
writes things down, and the conversation-end follow-ups card -- off transcription
and task state. No user request sits behind them, so they are proactive by any
reading. It called the presentation primitive directly:

    _ = FloatingControlBarManager.shared.showNotification(...)

That skips every gate in `sendNotification`: the master Notifications toggle
(BasedHardware#6778), the frequency throttle, the snooze, and the presence check. A user who
silenced notifications for four hours, or who was presenting, still received
"Omi wrote this down" receipts. The coordinator's own doc comment claimed it was
"routed through the existing hardened notification path", which is what made the
gap invisible in review.

Routed through `NotificationService.sendNotification` instead of adding another
call-site guard: the boundary is the problem, not the call site.

## Guard

Two lanes shared this cause -- the context director earlier in this PR, and this
one -- so a reusable guard lands with the fix rather than a third patch later.

`scripts/check-proactive-notification-gate.py` is a **static checker**, labelled as
such: it fails the build when a file outside a documented allowlist names
`FloatingControlBarManager.shared.showNotification`. The hazard is invisible at
runtime and in review -- a new lane still shows a card, still demos correctly, and
simply ignores every control the user has.

Allowlist entries each justify why they are NOT proactive: the gated service
itself, the manager's own implementation, trial/billing banners, and onboarding
permission help. A functional notice that must reach a silenced user is added
there with its reason; the rule itself does not relax.

Comments and string literals are masked before matching, so prose about the rule
does not trip it.

Wired into `.github/checks-manifest.yaml` in both `local` and `ci` lanes, with its
own fixture tests -- a checker that has never failed is not a guard, so the
fixtures are the two lanes that actually bypassed the gates.

Verification
- python3 desktop/macos/scripts/check-proactive-notification-gate.py -> OK
- python3 desktop/macos/tests/test_check_proactive_notification_gate.py -> 7 passed
- python3 .github/scripts/pr_preflight.py --lane local -> manifest contract PASS,
  new check selected
- swift build -> clean

Honest gaps
- The checker matches `FloatingControlBarManager.shared.showNotification` by name.
  A future alias or a stored reference to the manager would evade it; it is a
  cheap tripwire, not type-level enforcement.
- `NotchMomentsCoordinator` now inherits the frequency throttle and master toggle
  as well as the two new gates. That is the correct contract for a proactive card
  but is a behaviour change beyond the snooze/presence scope, and worth calling
  out: these receipts were previously unthrottled.
…y set

`testDeliveryOutcomesAreClosedAndJoinWithoutCardContent` pins the exact
`DeliveryOutcome` vocabulary so no outcome can be added that carries card content
into telemetry. This PR adds two, so the guard fired -- which is the guard working,
not a test to loosen.

Extended by exactly the two values this PR introduces. A sixth outcome added
without touching this line still fails, so the closed-set property is intact; the
edit is the review the guard exists to force.

`suppressed_presenting` and `suppressed_snoozed` are deferrals rather than filters:
the suggestion was deliverable and was withheld on audience or on the user's
explicit silence, and stays eligible afterwards. Both carry only the existing
opaque UUID correlators, so the privacy property the test also asserts is unchanged.

Verification
- Full desktop suite before this commit: 5479 tests, 1 skipped, 2 failures
- Full desktop suite after:               5479 tests, 1 skipped, 1 failure

The remaining failure is `RewindCaptureExclusionGenerationTests`
`testOwnerSnapshotStaysCurrentWhenAuthLeadsUnresolvedRewindDatabase`. It is
pre-existing and not caused by this PR:

  * it passes when run in isolation (`--filter`), failing only in a full run,
    which points at cross-suite ordering rather than the assertion itself
  * a full run with this PR's `NotchMomentsCoordinator` change reverted -- the only
    change here that touches `RuntimeOwnerIdentity` snapshots, and therefore the
    only plausible link to an owner-snapshot test -- still fails identically:
    5479 tests, 1 skipped, 1 failure, same test

Honest gaps
- I have not diagnosed the Rewind failure, only established it is not ours. It
  looks like the hand-listed test-isolation problem the repo already tracks, and
  it deserves its own issue rather than a drive-by fix in a notifications PR.
@aryanorastar

Copy link
Copy Markdown
Contributor Author

Ran the full desktop suite rather than the focused filters, which is what turned up the rest of this. Also worked the remaining honest-gaps list — one of them uncovered a second bug.

A second lane was bypassing the gates

NotchMomentsCoordinator posts the Second Brain "moments" — live receipts as Omi writes things down, and the conversation-end follow-ups card — off transcription and task state. Nothing user-initiated sits behind them. It called the primitive directly:

_ = FloatingControlBarManager.shared.showNotification(...)

That skips every gate in sendNotification: the master Notifications toggle (#6778), the frequency throttle, the snooze, and the presence check. A user who silenced notifications for four hours, or who was presenting, still received "Omi wrote this down" receipts.

Its own doc comment claims it is "routed through the existing hardened notification path". It wasn't — and that sentence is exactly why the gap survived review.

Routed through NotificationService.sendNotification rather than adding a third call-site guard. The boundary is the problem, not the call site.

Guard, because two lanes shared the cause

The context director (earlier in this PR) and this one are the same defect twice, so a reusable guard lands with the fix instead of a third patch later.

desktop/macos/scripts/check-proactive-notification-gate.py is a static checker, labelled as such: it fails the build when a file outside a documented allowlist names FloatingControlBarManager.shared.showNotification. The hazard is invisible at runtime and in review — a new lane still shows a card, still demos correctly, and simply ignores every control the user has.

Allowlist entries each justify why they are not proactive: the gated service itself, the manager's own implementation, trial/billing banners, and onboarding permission help. A functional notice that must reach a silenced user gets added there with its reason; the rule does not relax.

Wired into .github/checks-manifest.yaml in both local and ci lanes, with fixture tests — a checker that has never failed is not a guard, so the fixtures are the two lanes that actually bypassed the gates.

Full suite

before this PR's test fix:  5479 tests, 1 skipped, 2 failures
after:                      5479 tests, 1 skipped, 1 failure

One failure was mine. testDeliveryOutcomesAreClosedAndJoinWithoutCardContent pins the exact DeliveryOutcome vocabulary so no outcome can carry card content into telemetry. This PR adds two, so it fired — the guard working, not a test to loosen. Extended by exactly the two values added here; a sixth outcome added without touching that line still fails.

The remaining failure is pre-existing. RewindCaptureExclusionGenerationTests.testOwnerSnapshotStaysCurrentWhenAuthLeadsUnresolvedRewindDatabase:

  • passes in isolation via --filter, failing only in a full run — cross-suite ordering, not the assertion
  • a full run with this PR's NotchMomentsCoordinator change reverted — the only change here touching RuntimeOwnerIdentity snapshots, and so the only plausible link to an owner-snapshot test — still fails identically: 5479 tests, 1 skipped, 1 failure, same test

I have not diagnosed it, only established it is not ours. It resembles the hand-listed test-isolation problem the repo already tracks, and deserves its own issue rather than a drive-by fix in a notifications PR.

Gaps I decided not to close, with reasons

  • Per-assistant snooze — nobody has asked to silence insights while keeping task nudges; four times the state and UI on a guess.
  • Notification when a snooze lapses — a notification announcing notifications are back is itself an interruption at a moment the user did not choose.
  • Custom snooze duration — a picker for an arbitrary interval is a lot of UI for what four presets cover.
  • Unifying ContextDeliveryAuthority.freeGate with these gates — a larger refactor than a notifications PR should carry.
  • macOS 14.4 call detection — API availability; already fails open.

Remaining honest gaps

  • The checker matches by name. A future alias or a stored reference to the manager would evade it — it is a cheap tripwire, not type-level enforcement.
  • NotchMomentsCoordinator now inherits the frequency throttle and master toggle as well as the two new gates. That is the correct contract for a proactive card, but it is a behaviour change beyond the snooze/presence scope: these receipts were previously unthrottled.
  • No unit test drives presentContextDirectorNotification end to end; it is @MainActor and builds real presentation state. The gate decision is covered by the shared policy tests, the wiring by the checker and live verification.
  • The 9am resume hour for "until tomorrow" is fixed, not configurable.

@Git-on-my-level Git-on-my-level added security-review Touches auth, provider routing, secrets, or security-sensitive surfaces workflow-review Needs maintainer review for workflow, automation, hooks, or CI behavior macOS labels Aug 19, 2026

@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 for the detailed privacy-focused work here. The direction looks useful: proactive suggestions should not leak onto a shared screen or interrupt an active call, and the snooze control is a reasonable user-facing escape hatch.

I’m requesting changes because the new static gate is currently catching an actual remaining bypass in this branch. Running python3 desktop/macos/scripts/check-proactive-notification-gate.py on the PR tree reports:

Sources/FloatingControlBar/NotchMomentsCoordinator.swift:183: calls FloatingControlBarManager.shared.showNotification directly

Specific review notes:

  • desktop/macos/scripts/check-proactive-notification-gate.py correctly encodes the intended invariant that proactive deliveries should route through NotificationService, and its unit test file passes locally, but the checker fails against the real source tree because NotchMomentsCoordinator.post still calls the floating-bar primitive directly.
  • .github/checks-manifest.yaml wires that checker into the desktop contract lane, so this is not just advisory; the PR leaves the new CI check red until the remaining direct caller is routed/gated or explicitly justified as functional.
  • desktop/macos/Desktop/Sources/FloatingControlBar/NotchMomentsCoordinator.swift posts the “wrote this down” receipt through FloatingControlBarManager.shared.showNotification at line 183. That path still bypasses the snooze/presence checks added in NotificationService, which is exactly the privacy/workflow class this PR is trying to close.
  • desktop/macos/Desktop/Sources/ProactiveAssistants/Services/NotificationService.swift adds the snooze and presence gates in sendNotification and presentContextDirectorNotification; that part is the right choke-point shape, but it only protects callers that actually enter this service path.
  • desktop/macos/Desktop/Sources/ProactiveAssistants/Assistants/Suggestions/SuggestionAssistant.swift checks snooze/presence before writing to recentSuggestions, which preserves deferred suggestions instead of turning a suppressed card into a future duplicate. The new telemetry outcomes in SuggestionAssistantTelemetry.swift and InsightAssistantTelemetry.swift make those deferrals observable.
  • desktop/macos/Desktop/Sources/MainWindow/Pages/Settings/Sections/SettingsContentView+NotificationsPrivacy.swift, SettingsPage.swift, and FloatingControlBarView.swift add the Settings and context-menu controls for snoozing/resuming; the UI state update is local and understandable.
  • The new Swift tests (NotificationSnoozeTests.swift, PresenceAwareNotificationSuppressionTests.swift, and the telemetry test update) cover the pure policy pieces and deferral semantics, while the two changelog files describe the user-facing behavior.

Please either route the NotchMomentsCoordinator receipt through NotificationService so it gets the master/frequency/snooze/presence gates, or explicitly classify it as a functional notification with a narrow allowlist rationale if maintainers want it to bypass user silence. Because this is privacy-sensitive desktop notification behavior plus a new CI gate, final product/UX sign-off should stay with a human maintainer once the failing check is green.


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

`960bd33d84` silently undid `5e63c3e49e`. The telemetry commit's message describes
only the closed-set update, but its diff also reverted
`NotchMomentsCoordinator.post` from `NotificationService.sendNotification` back to
`FloatingControlBarManager.shared.showNotification`.

Cause: during an A/B run to establish whether a pre-existing test failure was ours,
I reverted that file with `git checkout <commit> -- <file>`, which *stages* the
revert. The working tree was restored afterwards with `cp`, but the staged revert
was never cleared. `git status` showed `MM` — staged and unstaged — and I did not
read it. The next `git commit` swept the staged revert in under an unrelated
message.

Caught by this PR's own checker running against the branch, which is the outcome it
was written for: a proactive lane silently losing its gates, invisible in the diff
being reviewed and invisible at runtime.

No behaviour change relative to the intent of `5e63c3e49e`; this restores that
commit's version of the file byte for byte.

Verification
- git show 5e63c3e:...NotchMomentsCoordinator.swift restored verbatim
- python3 desktop/macos/scripts/check-proactive-notification-gate.py -> OK
- python3 desktop/macos/tests/test_check_proactive_notification_gate.py -> 7 passed
- swift build -> clean
- `git diff --cached` inspected before committing this time
@aryanorastar

Copy link
Copy Markdown
Contributor Author

You are right, and the checker was right. Fixed in bd45ea8c.

What happened. 5e63c3e4 routed NotchMomentsCoordinator.post through NotificationService.sendNotification. The very next commit, 960bd33d — whose message is only about the telemetry test — silently reverted it. That commit should not have touched this file at all.

Cause. While measuring an A/B baseline for the gate-evaluation cost I ran git checkout 96f50bbd -- NotchMomentsCoordinator.swift to get the pre-fix behaviour back. That command stages the revert, not just the worktree. I restored the worktree afterwards with cp and never cleared the index. git status showed MM on that path and I read past it, so the staged old version went into the next commit.

So the fix was never re-argued or intentionally reverted — it was overwritten by my own measurement tooling, which is worse in one specific way: nothing in the diff review of 960bd33d would have suggested looking at a notifications file.

Fix. Restored byte-identical to 5e63c3e4 via git show 5e63c3e4:<path>, and this time inspected git diff --cached before committing rather than trusting the status letters.

$ python3 desktop/macos/scripts/check-proactive-notification-gate.py
check-proactive-notification-gate: OK
$ python3 -m unittest discover -s desktop/macos/tests -p 'test_check_proactive_notification_gate.py'
Ran 7 tests — OK
$ swift build   # clean

On the other two red checks. Desktop Swift Build & Tests was not a separate failure — that job only asserts VERIFY_RESULT = success from Static & Test Contracts, so it was the same checker firing, one hop downstream. PR Metadata Preflight was mine and unrelated: my Line-Count-Exception declared 2708 -> 2732 for FloatingControlBarView.swift while the synthetic merge measures 2713 -> 2737 — main moved under it. Corrected in the PR body.

The uncomfortable part worth stating plainly: the guard this PR adds is what caught the guard being removed, one commit after it was written. That is the argument for the checker existing, made at my own expense.

@aryanorastar

aryanorastar commented Aug 20, 2026

Copy link
Copy Markdown
Contributor Author

Statistics — measured, not estimated

Numbers. Source is 78 real sessions, 32.6 h of logged runtime, Aug 18–20, on the dev serving plane with a real Firebase identity, real screen content and real Google Meet calls. No fixtures, no replay, no synthetic input.

What it is not: one user, one machine, three days, and not a controlled A/B — the app changed under me while I used it. The caveats below matter more than the totals.

Presence — true vs false positives

The question is whether "someone else is present" fires when it should and only when it should. I cross-checked every suppression against MeetingDetector, a subsystem that predates this work, runs on an independent signal and shares no code path with the presence gate. Two unrelated detectors agreeing is a stronger check than my gate agreeing with itself.

count
Proactive notifications withheld for presence 9
Confirmed by MeetingDetector reporting an active meeting at that instant 9 (100%)
False positives 0

Three of the nine fired while the foreground app was LinkedIn or Settings, not the Meet window — "LinkedIn is fine, but you said you'd submit the SBI Hackathon prototype." Those read as false positives at a glance and are not: being in a call while looking at another app is the ordinary case, and it is precisely what my original screen-share-only scope would have missed.

Misses, the other direction:

count
Total deliveries in sample 40
Delivered while MeetingDetector reported an active meeting 4
— in sessions predating the feature (baseline) 2
— within 8s of a call ending, after the feature 2

The last two are not clean passes and I am not scoring them as such. MeetingDetector runs poll=4.0s, offGrace=8.0s, so its meeting ENDED timestamp lags the real hang-up by up to 8s. Both deliveries land inside that window (7s and 12s before ENDED). I cannot separate "leaked during a call" from "call was genuinely over" at that resolution. Honest figure: 0 confirmed mid-call leaks after the feature, 2 unresolved at the boundary.

The 2 baseline leaks are the reason the feature exists — Aug 19 17:15 and 17:16, both mid-call, both "SBI Hackathon prototype is overdue" on screen during a meeting.

Triggers, before vs after

Before is 0. Proactive notifications had no presence check and no snooze; there was nothing to compare against. After: 9 withheld for presence, 130 withheld for snooze, across 40 deliveries.

Cost

Cooldown and dwell run before the model and cost nothing. Everything past them is a paid evaluation — one screenshot plus grounding on gemini-2.5-flash-lite, ≈ $0.0004 each.

outcome evaluations share cost
duplicate of a recent suggestion 205 42.8% $0.082
withheld (snooze or presence) 139 29.0% $0.056
evaluation failed (network) 87 18.2% $0.035
delivered to the user 40 8.4% $0.016
nothing worth saying 8 1.7% $0.003
total paid 479 $0.19
free gate skips (cooldown 720 / dwell 99) 819 $0

$0.19 over 32.6 h ≈ $0.006 per hour of active use, about $0.05 a day at 8 hours. Negligible in absolute terms. The ratio is not: 90% of paid evaluations never reached the user.

Three findings, in order of size. Two of them are uncomfortable and I would rather state them than have them found.

1. Deduplication is the single largest cost — 43%. 205 paid evaluations produced a suggestion the model had effectively already made. SuggestionDeduplication.isDuplicate uses Jaccard word overlap at threshold 0.6, and the same commitment reworded slips under it: "Meet is fine — but the prototype is overdue" against "You said you'd submit the prototype — it's overdue." I found this while adding the category label in this PR's diagnostic commit, and did not fix it here. On cost grounds it is the highest-value next fix in the funnel.

2. The gates in this PR save no model cost — by design, and the design is worth questioning. Presence and snooze are delivery gates: they run after the model has already produced a suggestion. 139 paid evaluations were spent on cards the user was never going to see. Silence notifications for 8 hours and you pay full price for those 8 hours. Moving the snooze check ahead of evaluation would recover ~29% of spend. I did not do it here because withholding before evaluation changes which suggestions exist rather than which are shown — the deferral guarantee this PR is built on depends on the suggestion being generated and surviving dedup, and I did not want to trade that for cost inside a PR scoped to delivery. It is a real follow-up with a real number attached, not a hypothetical.

3. 18% of evaluations failed on NSURLErrorNetworkConnectionLost. Traced to stale pooled connections on URLSession.shared. An in-process A/B confirmed it: after moving GeminiClient to its own session, all 6 remaining -1005s came from subsystems still on the shared pool and zero from Gemini. Separate fix, not in this PR.

Method and limits

  • The cross-check subsystem is independent of the gate, which is the point; its offGrace=8.0s also bounds the resolution, which is why two boundary deliveries are reported unresolved rather than scored either way.
  • $0.0004/evaluation is a rounded estimate. All calls proxy through the backend, so I have no per-call billing line to confirm against — override with --cost-per-eval.
  • 9 presence suppressions is a small sample. 100% precision against an independent detector is meaningful; it is not enough to bound the false-positive rate tightly.
  • macOS 14.0–14.3 cannot use callAppIsUsingMicrophone() (14.4+ API), so below that only browser calls are caught. Not represented in this sample — I have no 14.0–14.3 machine.
  • Single user, single machine, three days.

The static gate this PR adds caught a third bypass lane, this one landed
on main after the branch forked: IntegrationNudgeCoordinator presents its
"Connect <app>" card straight through FloatingControlBarManager, so the
master toggle, frequency throttle, snooze and presence gates never see
it. An integration pitch is a suggestion, not a functional notice — a
user who silenced suggestions, or who is on a call with a shared screen,
is exactly who should not be offered one. Allowlisting it would have
exempted the precise class of lane the checker exists to catch.

sendNotification was the wrong door: it returns Void and keeps the
presentation result, which the coordinator needs — it spends one of an
integration's three lifetime offers from onPresented rather than from the
call returning, so a queued-then-dropped card does not burn an offer, and
it reads the result to tell "bar refused, do not retry" from "queued".
Threading that through sendNotification would mean classifying all twelve
of its exit paths into the result enum, on privacy-sensitive code.

presentActionableProactiveNotification is instead a sibling of
presentContextDirectorNotification: same gate order, same result type,
same callback contract, differing only in carrying a
FloatingBarNotificationAction and throttling against the caller's own
assistantId rather than the context-director budget. Suppression composes
with the budget for free — onPresented never fires, so a withheld offer
stays unspent and the nudge is free to be made again once the user is no
longer silenced or in company.

Verification:
- swift build -> clean under swift-version 6, -strict-concurrency=complete,
  -warnings-as-errors
- swift test --filter IntegrationNudgeCoordinatorTests -> 18 passed
- swift test --filter PresenceAwareNotificationSuppressionTests -> 10 passed
- check-proactive-notification-gate.py -> OK (was the failing check)
- pytest desktop/macos/tests/test_check_proactive_notification_gate.py -> 7 passed
- new test proven live: a stub calling onPresented on a .suppressed card
  fails it (shownCount 1 vs 0); restoring the stub passes

Failure-Class: none
SwiftLint rejects four force operations in this file with force_try and
force_unwrapping, all serious. The file is not in the SwiftLint baseline,
so the violations block the desktop-swiftlint gate rather than being
grandfathered.

Both tests reached for `try!`/`!` only because they were not declared
`throws`. Marking them `throws` lets the same assertions run through
XCTUnwrap, which reports the nil rather than trapping the whole suite.

Verification:
- swift test --filter NotificationSnoozeTests -> 11 passed
- swift-format with the pinned 602.0.0 wrapper

Failure-Class: none
@aryanorastar

Copy link
Copy Markdown
Contributor Author

@Git-on-my-level requested changes addressed on tip 45aa51ad, plus a third lane the gate caught after the merge with main.

Your ask — NotchMomentsCoordinator.swift:183 — was already fixed in bd45ea8c before this round: the receipt now routes through NotificationService. That was the last remaining direct caller on the branch as it stood.

A third lane appeared from main, not from this branch. IntegrationNudgeCoordinator landed in #11729 after this branch forked, and presents its "Connect <app>" card straight through FloatingControlBarManager. Merging current main made check-proactive-notification-gate.py fail on it — which is the checker doing exactly its job, on a lane nobody wrote with these gates in mind.

I routed it rather than allowlisting it. An integration pitch is a suggestion, not a functional notice: a user who silenced suggestions, or who is on a call with a shared screen, is precisely who should not be offered one. Allowlisting would have exempted the very class of lane the checker exists to catch.

Why not sendNotification. It returns Void and keeps the presentation result to itself, and the coordinator needs that result: it spends one of an integration's three lifetime offers from onPresented rather than from the call returning — so a .queued-then-dropped card does not burn an offer — and it reads the return value to tell "bar refused, do not retry" from "queued, may still appear". Threading that through sendNotification would mean classifying all twelve of its exit paths into the result enum, on privacy-sensitive code, for one caller.

presentActionableProactiveNotification is instead a sibling of presentContextDirectorNotification: same gate order (owner → master toggle → frequency → snooze → presence), same result type, same callback contract. It differs only in carrying a FloatingBarNotificationAction and in throttling against the caller's own assistantId instead of the context-director budget. Suppression composes with the bounded budget for free — onPresented never fires, so a withheld offer stays unspent and the nudge can be made again once the user is no longer silenced or in company.

Verification

  • swift build — clean under swift-version 6, -strict-concurrency=complete, -warnings-as-errors
  • swift test --filter IntegrationNudgeCoordinatorTests — 18 passed
  • swift test --filter PresenceAwareNotificationSuppressionTests — 10 passed
  • check-proactive-notification-gate.pyOK (was the failing check); its own 7 fixtures still pass
  • New testASuppressedPresentationDoesNotSpendTheBudget proven live rather than assumed: a stub calling onPresented on a .suppressed card fails it (shownCount 1 vs 0); restoring the stub passes

Honest scope note: that new test pins that .suppressed — newly reachable now that the gates can withhold a nudge — composes correctly with the budget. It is not a regression test for the routing itself; the static checker is that guard, and it is already wired into the desktop contract lane.

Separate commit 45aa51ad clears four pre-existing force_try/force_unwrapping violations in this PR's own NotificationSnoozeTests.swift. They are not in the SwiftLint baseline and were failing desktop-swiftlint independently of the above; both tests are now throws so the same assertions run through XCTUnwrap. 11 tests still pass.

Product/UX sign-off on the hard-scope behaviour is unchanged and still yours.

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

Review

The current tip passes python3 desktop/macos/scripts/check-proactive-notification-gate.py and its 7 tests; the only production direct primitive callers are the documented functional allowlist plus NotificationService, and Notch/IntegrationNudge now route through the service.

Residual risks before product sign-off:

  • macOS 14.0–14.3 still misses native-app calls because the microphone-process signal is only available from 14.4; this is fail-open.
  • ContextDeliveryAuthority remains a separate proactive lane; the new checker does not prove its policy is equivalent.
  • Presence/snooze are evaluated before the eventual presentation boundary. The async system-banner branch rechecks owner/context eligibility but not presence/snooze, and each presence check can cost window scans plus audio-process enumeration.

This is privacy-sensitive feature behavior, so I’m leaving it reviewed rather than approving or merging.

…otifications

# Conflicts:
#	desktop/macos/Desktop/Sources/ProactiveAssistants/Services/NotificationService.swift
@aryanorastar

aryanorastar commented Aug 21, 2026

Copy link
Copy Markdown
Contributor Author

@Git-on-my-level the blocking item from your review is resolved — flagging it because the request-changes is still on file and predates the fix.

Your finding: NotchMomentsCoordinator.swift:183: calls FloatingControlBarManager.shared.showNotification directly, leaving check-proactive-notification-gate.py red.

Resolved at 5e63c3e4 (fix(desktop): route notch moments through the gated notification path), with bd45ea8c restoring it after I reverted it by accident in a merge. NotchMomentsCoordinator.post now calls NotificationService.shared.sendNotification with respectFrequency left at its default true, so the receipt is subject to the master toggle, the frequency throttle, the snooze, and the presence check — routed rather than allowlisted, which is the option you preferred.

Verified at the current head d36d7a5c, not inferred:

$ python3 desktop/macos/scripts/check-proactive-notification-gate.py
check-proactive-notification-gate: OK

grep showNotification on that file now returns only the doc comment explaining why the direct call was wrong.

All checks are green on this head with none failing or pending. What's left is the product/UX sign-off you reserved for a human maintainer on the privacy-sensitive notification behavior — please dismiss the stale request-changes when you next pass.


Updated for the current head 3d75f252. Since the above: main conflicted on .github/checks-manifest.yaml (add/add — this branch's two proactive-gate entries against desktop-flow-contract-tests, which arrived from my now-merged #11990). Resolved as a union, validated by parsing the manifest: 148 checks, no duplicate ids. Two line-count exceptions that main had moved out from under were refreshed. All checks are green with nothing failing or pending, and the gate plus its 7 fixtures pass locally.

The guard has now caught a real one, which I think is the strongest argument for landing this.

AGENTS.md asks a new guard to cite the incident it would have caught. It has one, from this week: #11807 (cloud proactive messages to desktop over the listen socket) called FloatingControlBarManager.shared.showNotification directly and skipped every control — master toggle, frequency, snooze, presence. You flagged it there independently, in review, by reading the diff.

AppState+ListenEvents.swift is not in this checker's ALLOWLIST, so the checker fails on that PR's original code. I ran it against #11807's pre-fix tree to confirm rather than assume:

$ python3 desktop/macos/scripts/check-proactive-notification-gate.py
check-proactive-notification-gate: proactive delivery must go through NotificationService.sendNotification, ...

  Sources/AppState/AppState+ListenEvents.swift:543: calls FloatingControlBarManager.shared.showNotification directly

exit=1

Against the fixed tree it returns OK, exit 0.

So the class this PR closes is not hypothetical — it recurred within days, in a different subsystem, written by me, and was caught by a human review pass rather than by CI. That is the case for the checker: it turns "a reviewer has to notice" into "the build fails". Worth noting the reverse too — had this landed first, #11807 could not have been pushed in that shape.

Nothing outstanding on my side; the blocking item (NotchMomentsCoordinator routing) was fixed in 5e63c3e4 and verified at this head. What is left is the product/UX sign-off you reserved, plus dismissing the stale request-changes.

…otifications

# Conflicts:
#	.github/checks-manifest.yaml
aryanorastar added a commit to aryanorastar/omi that referenced this pull request Aug 23, 2026
… gates

Cloud interjections went straight to `FloatingControlBarManager.showNotification`,
following the context-director path as the precedent for this surface. That
primitive enforces none of the user's controls, so a cloud card reached the
screen past the master Notifications toggle, the frequency throttle, the snooze,
and the presence check that withholds while the user is presenting or in a call.

A cloud-generated message is proactive in exactly the sense those controls mean —
the user asked for nothing — so it belongs behind the same door as every other
proactive surface. `NotificationService.sendNotification` also owns
speech-on-delivery and the proactive-presented bookkeeping, so the caller's own
`NotificationSpeechOnDelivery` was a second copy of what the gated path does.

The message body is no longer logged; it is user conversation content, and its
provenance is enough to debug delivery.

Found by the static checker added in BasedHardware#11864, which flagged this file without
being written for it:

    Sources/AppState/AppState+ListenEvents.swift:565: calls
    FloatingControlBarManager.shared.showNotification directly

Verification:
  python3 desktop/macos/scripts/check-proactive-notification-gate.py
    -> check-proactive-notification-gate: OK   (was 1 violation)
  swift build -> clean

Not exercised end to end: that needs the backend publishing a real
proactive_message over the listen socket, which I cannot trigger on demand.
The gate routing is a compile-time boundary change; the delivery path itself
is unverified here.

Failure-Class: none
aryanorastar and others added 2 commits August 24, 2026 00:06
…voice

Maintainer request: being on a call is often exactly when a nudge is worth
most — mid-call with someone is when "you owe them a task" is useful. The
guard suppressed on either presence signal, which treated the interruption as
the harm. It is not.

The two signals are different harms and now decide different things:

- Sharing a screen makes a private nudge visible to everyone on the call, and
  once seen that cannot be taken back. Still suppresses delivery.
- Being on a call means others can hear the room but cannot see the screen.
  Delivery goes through.

Speech is the part a call does change. A banner on a call is seen by the user
alone; the same text read aloud is heard by everyone in the room and everyone
on the call, with no screen share needed — and BasedHardware#11801 makes spoken output
routine. So on a call the nudge is shown and not spoken.

`currentPresence()` also reports what it detected whenever it detects
anything. Without it a delivered nudge cannot be told apart from a detector
that saw nothing, and those mean opposite things — the same silent-gate
problem that made the wake word undiagnosable for two days.

Verification:
- xcrun swift test --package-path Desktop --filter 'PresenceAware|
  NotificationSpeech|NotificationSnooze|IntegrationNudge|
  SuggestionAssistantTelemetry' -> 140 tests, 0 failures
- check-proactive-notification-gate.py -> OK
- Live on a named dev bundle during a real Discord call, screen not shared,
  through the real grounding/evaluation/delivery path (probe_suggestion_nudge):

    NotificationService: presence — screenShared=false onCall=true
    Suggestion: delivering [90%] [commitment]
      "CleanMyMac is fine — but you said you'd Call David today"

  The same state on the previous build logged "withheld while others are
  present". Not live-verified: suppression while sharing. That path's detector
  and policy inputs are unchanged by this diff, and the sharing cases are
  covered by unit tests, but the live control could not be run — see below.

Known gap, pre-existing and unrelated to this diff: `isShareIndicatorWindow`
matches Zoom, Microsoft Teams and browser stop-sharing bubbles. Discord
publishes no such window — while sharing, its only on-screen windows are
`title="Window"` and the channel title — so a Discord screen share is not
detected at all. Matching an untitled window would suppress nudges for anyone
with Discord open, so it is filed rather than guessed at.

Failure-Class: none

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

aryanorastar commented Aug 23, 2026

Copy link
Copy Markdown
Contributor Author

Done — 0cdf1cd551. Split rather than deleted, because sharing and being-on-a-call turned out to be two different harms doing one job.

Sharing makes a private nudge visible to everyone, and once seen that cannot be taken back. Still suppresses.
On a call, not sharing — nobody can see the screen, and it is often the moment the nudge is worth most. Delivers now.

What a call does change is speech. A banner on a call is seen by you alone; the same text read aloud is heard by the whole call with no screen share at all — and #11801 makes spoken output routine. So on a call the nudge is shown and not spoken.

Verified live on a real Discord call, screen not shared, through the real grounding → evaluation → delivery path:

NotificationService: presence — screenShared=false onCall=true
Suggestion: delivering [90%] [commitment]
  "CleanMyMac is fine — but you said you'd Call David today"

That is your example almost word for word, and the same state on the previous build logged withheld while others are present.

140 tests, make preflight 22/22.

Two honest notes.

The detector now logs what it saw whenever it sees anything. Without it, a delivered nudge is indistinguishable from a detector that never fired, and those mean opposite things — the same silent-gate problem that made the wake word undiagnosable for two days.

And the test turned up a gap that predates this change: activeScreenSharePresent() does not detect a Discord screen share. It matches Zoom, Teams, and the browser stop-sharing bubble; Discord publishes none of those — while sharing, its only windows are title="Window" and the channel title. So the suppression it is meant to provide does not fire there, and neither does the capture pause from #10143. Filed as #12105 rather than guessed at: the only candidate signal is a window titled Window, and matching that would suppress nudges for anyone with Discord merely open.

Which also means the one path I could not verify live is suppression-while-sharing — Discord was the call I had. That path's detector and policy inputs are unchanged by this diff and the sharing cases are unit-tested, but I would rather say so than imply I watched it.


Status on the blocking review. @Git-on-my-level's CHANGES_REQUESTED is still on file, pinned to 960bd33d84 from Aug 19. Its one blocking finding was the remaining bypass:

Sources/FloatingControlBar/NotchMomentsCoordinator.swift:183: calls FloatingControlBarManager.shared.showNotification directly

That is fixed on this head. NotchMomentsCoordinator now routes the receipt through the choke point you asked for:

guard let ownerID = RuntimeOwnerIdentity.currentOwnerId() else { return }
NotificationService.shared.sendNotification(
  ownerID: ownerID, title: title, message: message,
  assistantId: assistantId, sound: .none)

The only remaining mention of the old primitive in that file is a comment explaining why it was wrong. I ran the checker your review cited rather than assuming:

$ python3 desktop/macos/scripts/check-proactive-notification-gate.py
check-proactive-notification-gate: OK

All checks are green on 0cdf1cd551, so the CI gate the review said this PR left red is now passing.

The product/UX sign-off you reserved is unchanged and still a human's — I'm not asking you to fold that into a dismissal.

Edited to append the review-status section above rather than posting a follow-up comment. Verified the checker passes and the routing changed on this head before writing it.

undivisible pushed a commit that referenced this pull request Aug 25, 2026
…ver listen websocket (#11807)

* feat(listen): deliver cloud-generated proactive messages to desktop over listen websocket

- Add ProactiveMessageEvent model with JSON serialization contract
- Add Redis pub/sub channel and async client for proactive message dispatching
- Add process-local listen session registry and async dispatcher
- Wire runtime session registration on connect and teardown on disconnect
- Add publish seam in realtime app integrations fanout
- Handle proactive_message event in macOS AppState+ListenEvents with floating bar presentation and TTS
- Add comprehensive backend unit/integration tests (28 passed) and desktop Swift unit tests (9 passed)

* fix(listen): fix pyright host cast, remove unused import, add changelog fragment

* fix(listen): use direct async redis client to satisfy async blocker check

* style(desktop): resolve force_cast swiftlint violations in ProactiveListenEventTests

* fix(listen): subscribe the proactive dispatcher to the publisher's Redis

`proactive_message_dispatcher` built its own client from `REDIS_HOST` on 6379
with no password when none was injected. `publish_proactive_message` uses the
shared client in `database/redis_db.py`, which reads `REDIS_DB_HOST` /
`REDIS_DB_PORT` / `REDIS_DB_PASSWORD`.

Different servers, so the subscribe succeeded and no message ever arrived —
a silent failure, because nothing errors when you subscribe to a channel
nobody publishes to.

Now takes the same shared client as the publisher.

Failure-Class: none

* fix(desktop): route cloud proactive messages through the notification gates

Cloud interjections went straight to `FloatingControlBarManager.showNotification`,
following the context-director path as the precedent for this surface. That
primitive enforces none of the user's controls, so a cloud card reached the
screen past the master Notifications toggle, the frequency throttle, the snooze,
and the presence check that withholds while the user is presenting or in a call.

A cloud-generated message is proactive in exactly the sense those controls mean —
the user asked for nothing — so it belongs behind the same door as every other
proactive surface. `NotificationService.sendNotification` also owns
speech-on-delivery and the proactive-presented bookkeeping, so the caller's own
`NotificationSpeechOnDelivery` was a second copy of what the gated path does.

The message body is no longer logged; it is user conversation content, and its
provenance is enough to debug delivery.

Found by the static checker added in #11864, which flagged this file without
being written for it:

    Sources/AppState/AppState+ListenEvents.swift:565: calls
    FloatingControlBarManager.shared.showNotification directly

Verification:
  python3 desktop/macos/scripts/check-proactive-notification-gate.py
    -> check-proactive-notification-gate: OK   (was 1 violation)
  swift build -> clean

Not exercised end to end: that needs the backend publishing a real
proactive_message over the listen socket, which I cannot trigger on demand.
The gate routing is a compile-time boundary change; the delivery path itself
is unverified here.

Failure-Class: none

* test(desktop): assert proactive-listen delivery shows and suppresses

The handler tests asserted that handleListenEvent did not crash. With no
runtime owner there was nothing to observe, so neither delivery nor
suppression was covered -- the reviewer's point.

Extracts the routing decision into ProactiveListenAdmission, a pure type
in the same file (no new source file, so no flow-coverage entry), and
asserts each branch: delivered when owned and non-empty, skipped on an
empty body, skipped without a runtime owner, empty-before-owner ordering,
and the blank-app-id fallback.

The decision deliberately stops at routing. Once admitted the message goes
to NotificationService, which owns the master toggle, frequency throttle,
snooze and presence withholding; re-deciding those here would give the
cloud a second, divergent copy of the user's notification policy. One test
pins the master toggle through NotificationService.areNotificationsEnabled
so that gate is a real read rather than something this path can drift from.

Verified the tests guard rather than decorate:
  drop the runtime-owner guard  -> 1 failure
  drop the empty-message guard  -> 2 failures
  restored                      -> 14 tests, 0 failures

* fix(desktop): drop the conversation_id block that only logged

The comment said "Refresh conversations if the message is tied to a
specific conversation" and the body logged the id. Nothing refreshed.

A comment describing behavior the code does not have is worse than no
comment: the next reader takes the refresh as done. Removing it is honest;
wiring a real refresh is a separate change with its own reason to exist.
The id stays on the wire event and in ProactiveMessageEvent, so adding
that later needs no protocol change.
@aryanorastar

aryanorastar commented Aug 28, 2026

Copy link
Copy Markdown
Contributor Author

Current-main refresh + blocking-review proof

Refreshed onto origin/main at cd6765ba9c and pushed head e1edc114d0.

Scope integrity

  • Merge completed without conflicts.
  • PR diff remains exactly 19 files, all macOS plus its CI guard registration/fixtures: 1,118 additions / 11 deletions.
  • No iOS, Android, backend, or Windows changes are part of the PR diff.
  • git diff --check origin/main...HEAD passes.

Reviewer blocker: resolved on the current tree

The outstanding CHANGES_REQUESTED review was filed because NotchMomentsCoordinator still called FloatingControlBarManager.shared.showNotification directly. On this head it routes through NotificationService, and both the checker and its adversarial fixtures are green:

python3 desktop/macos/scripts/check-proactive-notification-gate.py
check-proactive-notification-gate: OK

python3 -m unittest discover -s desktop/macos/tests \
  -p 'test_check_proactive_notification_gate.py'
Ran 7 tests — OK

The static guard also covers future lanes: every proactive card must enter the shared master-toggle/frequency/snooze/presence boundary unless it has a narrowly documented functional allowlist reason.

Behavior verification on current main

xcrun swift test -c debug --package-path Desktop \
  --filter 'PresenceAwareNotificationSuppressionTests|NotificationSnoozeTests|IntegrationNudgeCoordinatorTests|SuggestionAssistantTelemetryTests'

IntegrationNudgeCoordinatorTests:              18 passed
NotificationSnoozeTests:                       11 passed
PresenceAwareNotificationSuppressionTests:     17 passed
SuggestionAssistantTelemetryTests:              8 passed
Total:                                         54 passed, 0 failed

This proves the current product split:

  • screen sharing: withhold the private proactive card;
  • active call without sharing: show the card, suppress spoken output;
  • functional notices: still deliver;
  • snoozed/presence-suppressed suggestions: do not burn dedup or integration-offer budgets;
  • 1h/4h/8h/until-next-9am snoozes expire and resume correctly;
  • suppression outcomes remain closed, content-free telemetry values.

Static/repository gates

  • PR-scoped pinned swift-format --strict: pass
  • Pinned SwiftLint: 0 violations across 1,427 files
  • Desktop test-quality ratchet: pass
  • Desktop flow contract: 74 flows / 181 actions, pass
  • Desktop e2e source coverage: 10/10 changed production Swift files covered
  • Changelog, diff hygiene, line-count exceptions, author identity, failure-class protocol, GRDB idiom, brand invariant, and deferred-work checks: pass

Known base-branch preflight issue

The aggregate preflight reaches and passes this PR's new notification guard, then stops on an unrelated origin/main legacy-memory ratchet mismatch:

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

That Windows file is not in this PR. The isolated repair is #12339, whose checks are all complete and green; I intentionally kept it out of this privacy-sensitive feature diff.

@Git-on-my-level the technical blocker from the changes-requested review is verified resolved on the current head. What remains is the human product/UX sign-off previously reserved by the reviewer: whether this privacy behavior and its default/snooze UX should ship.

@Git-on-my-level Git-on-my-level added the positive-signal Good PR — positive signal, not a formal approval label Aug 28, 2026
@Git-on-my-level

Copy link
Copy Markdown
Collaborator

Follow-up review on head e1edc114 — the blocking item from the earlier review is resolved here, and I've dismissed that stale request-changes. Thank you for the persistence across the reverts-from-main and the honest gap accounting along the way.

Blocker resolution, verified on this tree

  • NotchMomentsCoordinator.post now routes through NotificationService.shared.sendNotification (with respectFrequency left at its proactive default), so the "Omi wrote this down" receipts are subject to the master toggle, frequency throttle, snooze, and presence gates.
  • python3 desktop/macos/scripts/check-proactive-notification-gate.py on the head tree: OK. Its 7 fixtures in test_check_proactive_notification_gate.py: all pass, including the reduced notch-moments regression shape and the comment/string-literal false-positive guards.

What I verified file-by-file

  • NotificationService.swift — the pure policies keep the proactive/functional split on respectFrequency, so functional notices (screen-recording repair, Crisp, onboarding) still deliver through a snooze or share. presentActionableProactiveNotification mirrors the director path's gate order (owner → master toggle → frequency → snooze → presence) and composes with the integration-offer budget via onPresented. currentPresence() reuses the three existing production signals and logs only when something is detected.
  • SuggestionAssistant.swift — snooze and presence are consulted after the owner re-check and before recentSuggestions is written, so a withheld suggestion is deferred rather than retired by the dedup window; suppressedSnoozed/suppressedPresenting are distinct deferral outcomes, with the closed vocabulary guard updated in lockstep (SuggestionAssistantTelemetryTests).
  • NotificationSpeech.swiftothersCanHear silences the utterance while the visual delivery proceeds; the shown-but-unspoken split is exactly the tested behavior.
  • IntegrationNudgeCoordinator.swift + IntegrationNudgeCoordinatorTests.swift — routing through the new entry point made .suppressed reachable for the first time, and the new test pins that a withheld offer does not spend one of the three lifetime offers.
  • check-proactive-notification-gate.py + .github/checks-manifest.yaml — the checker masks comments/strings before matching, skips Tests, and carries a per-entry justified allowlist; wired into both lanes with platforms: [macos], and the macos-15 manifest lane ran it green on this head (Linux correctly skips it platform-only).
  • SettingsContentView+NotificationsPrivacy.swift, SettingsPage.swift, FloatingControlBarView.swift — the snooze control sits with the frequency slider, reads live expiry for the subtitle ("until 9:00 AM tomorrow" disambiguation included), and the bar menu's flat buttons deliberately avoid the bar-hide key; key distinctness is pinned by test.
  • NotificationSnoozeTests / PresenceAwareNotificationSuppressionTests — the "until tomorrow" calendar boundaries (11:30pm → next 9am; 2am → same-morning 9am; the 9:00am boundary moves to the next day), the snooze/presence matrices, and the dedup-preservation pair are the right tests for this change. Both changelog fragments match the accepted shape and describe the shipped behavior accurately.

The red Hygiene check is not from this PR

legacy-memory-surface-ratchet fails with desktop/windows/src/main/assistants/insight/prompt.ts: 4 -> 5. I ran the ratchet on both this head and its base cd6765ba: the identical single growth entry appears on both — it comes from #12236 on main (the Windows insight prompt grew Gate F markers without the baseline being bumped). This PR contributes zero growth to the tracked counters; the ProactiveAssistants/** trigger path is just what made the check run here. Nothing for you to fix; main's baseline needs the bump.

Two non-blocking observations

  • In sendNotification, the async system-banner fallback re-checks owner and authorization after the UserNotificationCallbackBridge hop but not snooze/presence — a share starting inside that callback window could still deliver a banner. The floating-bar path is gated synchronously, so this is a narrow fallback-surface window; a re-check before deliverNotification would close it.
  • The checker's masker blanks string literals but not their interpolation bodies, so a call written inside \(...) would evade it. It's a fail-closed tripwire and honestly labeled static, so fine as shipped — worth knowing.

What remains is human maintainer sign-off: the product call on the presence semantics themselves (a shared screen suppresses the private card; a call without sharing delivers it unspoken) and on shipping a new repo-wide desktop CI gate. Leaving it for that review rather than escalating anything else.


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 dismissed their stale review August 28, 2026 08:59

Dismissed as stale: the blocking finding (NotchMomentsCoordinator.swift:183 calling FloatingControlBarManager.shared.showNotification directly) is resolved on head e1edc11 — the receipt now routes through NotificationService, and check-proactive-notification-gate.py plus its 7 fixtures pass on this tree.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

macOS positive-signal Good PR — positive signal, not a formal approval security-review Touches auth, provider routing, secrets, or security-sensitive surfaces workflow-review Needs maintainer review for workflow, automation, hooks, or CI behavior

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants