Files
Lumotia/src/lib/stores/nudgeBus.svelte.ts
Jake 0a8cb55447 feat(copy): PR 1.1 — research-grounded copy + prompt corrections
Add cue-anchored "When [cue], [action]" framing to the task-decomposition
prompt where natural cues are present (Gollwitzer-style implementation
intentions, d=0.65 effect size). Soften Bionic Reading and accessibility-
font copy to honest preference framing per the v3 audit (Strukelj 2024;
Doyon n=2,074). Update timer nudge from "Still on that timer?" (which
read as judgmental) to "Timer's still running." Replace stale Tasks
page header copy promising automatic extraction.

Audio envelopes (focusTimer 20ms ramp, sounds.ts 10ms attack) verified
correct per memo §B; no code change needed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 18:12:58 +01:00

293 lines
9.6 KiB
TypeScript

// 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(
"Timer's still running.",
"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;
}