feat(nudges): Phase 6 — Margot soft-touch nudges via frontend nudge bus
Some checks failed
check / cargo check (macos-latest) (push) Has been cancelled
check / cargo check (ubuntu-22.04) (push) Has been cancelled
check / cargo check (windows-latest) (push) Has been cancelled
check / svelte build + lint (push) Has been cancelled

Frontend-owned nudge bus that consumes in-app signals Corbie already
produces, applies suppression, and fans out to OS notification + an
optional TTS read-aloud. OS-wide keyboard/window activity detection
stays deferred per the revised roadmap — the plan before rewrite
would have been brittle on Wayland, permission-heavy on macOS, and
low-quality everywhere.

Triggers (v1, all in-app signals):
- inactivity_with_active_timer — timer running, window blurred ≥ 90 s,
  at least 60 s into the timer.
- pending_morning_triage — ritual enabled, past 10:00 local, last
  shown ≠ today. Polls every 5 min while focused.
- micro_step_idle — micro-step decomposition created, no child step
  or parent task completed within 15 min.

Suppression:
- Respects nudgesEnabled + nudgesMuted.
- No nudge while the app has focus (document.hasFocus).
- Hard cap 3 per rolling hour.
- Permission requested via @tauri-apps/plugin-notification on first
  delivery; denial is silently respected.

Rust side:
- tauri-plugin-notification registered + ACL entries on the main-
  window capability only (secondary windows can't fire nudges).
- commands::nudges::deliver_nudge — thin wrapper, security-guarded
  via ensure_main_window, delegates to the plugin. No DB writes —
  the roadmap's nudges-audit table is deferred until a concrete need
  emerges.

Frontend glue:
- nudgeBus.svelte.ts — subscribes to window events, applies
  suppression, calls deliver_nudge (+ tts_speak when speakAloud is
  on).
- kon:task-completed now dispatched on complete_task_cmd success.
- kon:microstep-generated + kon:step-completed dispatched from
  MicroSteps so the idle trigger can clear itself on any engagement.
- kon:focus-timer-cancelled added to focusTimer so the bus can reset
  its inactivity state on cancel, not only on natural completion.
- nudgeBus started from +layout.svelte onMount, stopped on destroy.

Settings:
- New "Nudges" section with three toggles: Enable nudges, Mute for
  now (separate so a hard mute doesn't lose preferences),
  Speak nudges aloud (reuses Phase 4 TTS).
- All default OFF. No first-run prompt — nudges are a Settings-found
  feature rather than a walkthrough step.

Out of scope per the revised Phase 6 spec: OS-wide keyboard/window
hooks, biometric signals, custom trigger editor (Phase 7), notification
sound (platform variance too high for Layer-1 — revisit in Phase 9
polish with a bundled .wav).

Gates: fmt clean, clippy -D warnings clean, cargo test 262/0 (4
existing + 262 current), npm run check 0/0, npm run build green.
This commit is contained in:
2026-04-24 19:05:21 +01:00
parent b333c6229e
commit eebea8cb9a
19 changed files with 980 additions and 152 deletions

View File

@@ -0,0 +1,292 @@
// Phase 6 Margot nudge bus. Frontend-owned, subscribes to in-app
// signals Corbie already produces, applies suppression, and fans out
// to OS notification (via Rust's deliver_nudge) + optional TTS.
//
// Why frontend-only: OS-wide keyboard/window detection is fragile
// across Wayland/macOS/Windows (no sanctioned global-keyboard API on
// Wayland, accessibility permission on macOS, message-loop hook on
// Windows). The app-local signals we already have — timer state,
// task completion, micro-step creation, app focus/visibility — cover
// the Phase 6 trigger set without the platform pain.
//
// Suppression rules:
// - nudgesEnabled && !nudgesMuted
// - document has focus → never nudge (user is already looking)
// - rolling 1-hour cap of 3 nudges
// - timer-specific: no nudge in first 60 s of a running timer
//
// Trigger set v1 (in-app only):
// - inactivity_with_active_timer
// - pending_morning_triage
// - micro_step_idle
import { invoke } from "@tauri-apps/api/core";
import { hasTauriRuntime } from "$lib/utils/runtime.js";
import { settings } from "$lib/stores/page.svelte.js";
const HOUR_MS = 60 * 60 * 1000;
const INACTIVITY_TIMER_THRESHOLD_MS = 90_000;
const TIMER_WARMUP_MS = 60_000;
const MICRO_STEP_IDLE_MS = 15 * 60_000;
const MORNING_TRIAGE_CHECK_INTERVAL_MS = 5 * 60_000;
const MORNING_TRIAGE_TRIGGER_HOUR = 10;
interface TimerStartPayload {
taskId?: string;
seconds?: number;
}
interface MicroStepPayload {
parentTaskId: string;
}
let started = false;
let permissionRequested = false;
let permissionGranted = false;
// Rolling timestamps of recent nudge deliveries (ms since epoch).
// Older-than-one-hour entries are pruned lazily on each check.
const recentNudges: number[] = [];
// Per-trigger state.
let timerRunning = false;
let timerStartedAt = 0;
let timerNudgeFiredThisSession = false;
let blurredAt: number | null = null;
let blurCheckHandle: ReturnType<typeof setInterval> | null = null;
let triagePollHandle: ReturnType<typeof setInterval> | null = null;
// parentTaskId → scheduled timeout handle for "idle since step created".
// Cleared when a step or parent task gets marked done in the same session.
const microStepTimers = new Map<string, ReturnType<typeof setTimeout>>();
// --- Permission ------------------------------------------------------
async function ensurePermission(): Promise<boolean> {
if (!hasTauriRuntime()) return false;
if (permissionRequested) return permissionGranted;
permissionRequested = true;
try {
const plugin = await import("@tauri-apps/plugin-notification");
const already = await plugin.isPermissionGranted();
if (already) {
permissionGranted = true;
return true;
}
const result = await plugin.requestPermission();
permissionGranted = result === "granted";
return permissionGranted;
} catch {
permissionGranted = false;
return false;
}
}
// --- Suppression -----------------------------------------------------
function pruneRecentNudges(now: number): void {
while (recentNudges.length > 0 && now - recentNudges[0] > HOUR_MS) {
recentNudges.shift();
}
}
function canNudgeNow(now: number): boolean {
if (!settings.nudgesEnabled) return false;
if (settings.nudgesMuted) return false;
if (typeof document !== "undefined" && document.hasFocus()) return false;
pruneRecentNudges(now);
if (recentNudges.length >= 3) return false;
return true;
}
// --- Delivery --------------------------------------------------------
async function deliver(title: string, body: string): Promise<void> {
const now = Date.now();
if (!canNudgeNow(now)) return;
const ok = await ensurePermission();
if (!ok) return;
recentNudges.push(now);
try {
await invoke("deliver_nudge", { input: { title, body } });
} catch {
// Nudges are fire-and-forget — surfacing an error to the user
// would defeat the "anticipatory, not push-notification" framing.
}
if (settings.nudgesSpeakAloud) {
try {
await invoke("tts_speak", {
text: body || title,
rate: settings.ttsRate,
voice: settings.ttsVoice ?? null,
});
} catch {
// Same reasoning — never surface TTS failures through a nudge.
}
}
}
// --- Trigger: inactivity with active timer ---------------------------
function onTimerStart(event: Event) {
const detail = (event as CustomEvent<TimerStartPayload>).detail;
timerRunning = true;
timerStartedAt = Date.now();
timerNudgeFiredThisSession = false;
// If the user switched away before starting the timer, the next
// blur check (below) will pick it up.
void detail;
}
function resetTimerState() {
timerRunning = false;
timerStartedAt = 0;
timerNudgeFiredThisSession = false;
}
function onFocus() {
blurredAt = null;
}
function onBlur() {
blurredAt = Date.now();
}
function checkInactivityWithActiveTimer(now: number) {
if (!timerRunning || timerNudgeFiredThisSession) return;
if (now - timerStartedAt < TIMER_WARMUP_MS) return;
if (blurredAt === null) return;
if (now - blurredAt < INACTIVITY_TIMER_THRESHOLD_MS) return;
timerNudgeFiredThisSession = true;
void deliver(
"Still on that timer?",
"It's been ticking while you've been away. Pick up where you left off, or stop it.",
);
}
// --- Trigger: pending morning triage ---------------------------------
function todayLocalKey(): string {
const d = new Date();
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
}
let triageNudgeFiredOnKey: string | null = null;
async function checkPendingMorningTriage() {
if (!settings.ritualsMorning) return;
if (!hasTauriRuntime()) return;
const now = new Date();
if (now.getHours() < MORNING_TRIAGE_TRIGGER_HOUR) return;
const today = todayLocalKey();
if (triageNudgeFiredOnKey === today) return;
let lastShown: string | null = null;
try {
lastShown = await invoke<string | null>("get_last_morning_triage");
} catch {
return;
}
if (lastShown === today) return;
triageNudgeFiredOnKey = today;
void deliver(
"A few things waiting",
"When you're ready, Corbie has your morning list. No rush.",
);
}
// --- Trigger: micro-step idle ----------------------------------------
function onMicroStepGenerated(event: Event) {
const detail = (event as CustomEvent<MicroStepPayload>).detail;
if (!detail?.parentTaskId) return;
const parentTaskId = detail.parentTaskId;
clearMicroStepTimer(parentTaskId);
const handle = setTimeout(() => {
microStepTimers.delete(parentTaskId);
void deliver(
"Still with that one?",
"Your breakdown is here when you want to pick the first step.",
);
}, MICRO_STEP_IDLE_MS);
microStepTimers.set(parentTaskId, handle);
}
function clearMicroStepTimer(parentTaskId: string) {
const handle = microStepTimers.get(parentTaskId);
if (handle !== undefined) {
clearTimeout(handle);
microStepTimers.delete(parentTaskId);
}
}
function onStepOrTaskCompleted(event: Event) {
const detail = (event as CustomEvent<{ id?: string; parentTaskId?: string }>).detail;
// A completed subtask clears its parent's idle timer; a completed
// parent task clears its own idle timer (the step decomposition
// attached to that task is no longer waiting on anyone).
if (detail?.parentTaskId) clearMicroStepTimer(detail.parentTaskId);
if (detail?.id) clearMicroStepTimer(detail.id);
}
// --- Lifecycle -------------------------------------------------------
export function startNudgeBus(): void {
if (started) return;
if (typeof window === "undefined") return;
started = true;
window.addEventListener("kon:start-timer", onTimerStart);
window.addEventListener("kon:focus-timer-complete", resetTimerState);
window.addEventListener("kon:focus-timer-cancelled", resetTimerState);
window.addEventListener("kon:microstep-generated", onMicroStepGenerated);
window.addEventListener("kon:step-completed", onStepOrTaskCompleted);
window.addEventListener("kon:task-completed", onStepOrTaskCompleted);
window.addEventListener("focus", onFocus);
window.addEventListener("blur", onBlur);
// Inactivity check runs while a timer is live. Low frequency is fine
// — the nudge itself only fires once the 90 s threshold is crossed.
blurCheckHandle = setInterval(() => {
checkInactivityWithActiveTimer(Date.now());
}, 10_000);
// Morning triage check runs on an interval + one immediate probe on
// start. The Rust `get_last_morning_triage` call is cheap (one
// SQLite read), so polling every 5 min costs nothing and means we
// catch the 10:00 threshold without a scheduler.
void checkPendingMorningTriage();
triagePollHandle = setInterval(() => {
void checkPendingMorningTriage();
}, MORNING_TRIAGE_CHECK_INTERVAL_MS);
}
export function stopNudgeBus(): void {
if (!started) return;
started = false;
window.removeEventListener("kon:start-timer", onTimerStart);
window.removeEventListener("kon:focus-timer-complete", resetTimerState);
window.removeEventListener("kon:focus-timer-cancelled", resetTimerState);
window.removeEventListener("kon:microstep-generated", onMicroStepGenerated);
window.removeEventListener("kon:step-completed", onStepOrTaskCompleted);
window.removeEventListener("kon:task-completed", onStepOrTaskCompleted);
window.removeEventListener("focus", onFocus);
window.removeEventListener("blur", onBlur);
if (blurCheckHandle !== null) {
clearInterval(blurCheckHandle);
blurCheckHandle = null;
}
if (triagePollHandle !== null) {
clearInterval(triagePollHandle);
triagePollHandle = null;
}
for (const handle of microStepTimers.values()) clearTimeout(handle);
microStepTimers.clear();
resetTimerState();
blurredAt = null;
triageNudgeFiredOnKey = null;
permissionRequested = false;
}