package focus import ( "context" "keel/internal/ai" "keel/internal/enforce" "keel/internal/evidence" "keel/internal/mode/focus/domain" "keel/internal/store" "log" "strings" "time" ) const ( driftDebounce = 10 * time.Second driftTimeout = 30 * time.Second enforceTimeout = 5 * time.Second nudgeDebounce = 5 * time.Minute nudgeTimeout = 30 * time.Second ) const recentTitlesMax = 10 const ( driftIdle = "idle" driftPending = "pending" driftOnTask = "ontask" driftDrifting = "drifting" ) // SetDriftJudge injects the AI drift judge. A nil judge keeps local matching // working; unmatched windows simply stay idle. func (c *Mode) SetDriftJudge(j ai.DriftJudge) { c.mu.Lock() c.judge = j c.mu.Unlock() } // SetGuard injects the OS enforcement guard. A nil guard disables window-minimize // enforcement; everything else behaves identically. func (c *Mode) SetGuard(g enforce.Guard) { c.mu.Lock() defer c.mu.Unlock() c.guard = g } // SetNudge injects the AI semantic nudge judge. A nil nudger disables nudging; // local matching and the drift judge are unaffected. func (c *Mode) SetNudge(n ai.Nudger) { c.mu.Lock() c.nudge = n c.mu.Unlock() } // commitmentLineLocked renders the active commitment as a single line for AI // prompts, or "" if there is none. Caller holds mu. func (c *Mode) commitmentLineLocked() string { if c.commitment == nil { return "" } return c.commitment.NextAction + " — " + c.commitment.SuccessCondition } // recentTitlesForTest returns a copy of the recent-titles ring (test-only). func (c *Mode) recentTitlesForTest() []string { c.mu.Lock() defer c.mu.Unlock() return append([]string(nil), c.recentTitles...) } // resetDriftLocked clears all drift machinery for a fresh session. Caller holds mu. func (c *Mode) resetDriftLocked() { c.driftStatus = driftIdle c.driftReason = "" c.driftGen++ c.nudgeEpoch++ c.lastJudgedAt = time.Time{} c.judgedWindows = map[string]ai.Verdict{} c.recentTitles = nil c.nudgeMessage = "" c.lastNudgedAt = time.Time{} } // OnTask appends the current window class to the session allowed-context, clears // drift, drops the cached verdict for the current window, and persists. The // class now matches locally and is never re-judged. func (c *Mode) OnTask() error { c.mu.Lock() defer c.mu.Unlock() if c.runtimeState != domain.RuntimeActive { return ErrNotActive } var class string if c.stats != nil { class = c.stats.Current.Class } if strings.TrimSpace(class) != "" { ac := domain.AllowedContext{WindowClasses: c.allowedClasses} if !evidence.MatchesAllowed(ac, class, "") { c.allowedClasses = append(c.allowedClasses, strings.TrimSpace(class)) } delete(c.judgedWindows, verdictKey(class, c.stats.Current.Title)) } c.driftStatus = driftOnTask c.driftReason = "" return c.persistLocked() } // Refocus clears the current drift verdict without changing allowed-context, and // drops the cached verdict for the current window so it may be judged again later. func (c *Mode) Refocus() error { c.mu.Lock() defer c.mu.Unlock() if c.runtimeState != domain.RuntimeActive { return ErrNotActive } if c.stats != nil { delete(c.judgedWindows, verdictKey(c.stats.Current.Class, c.stats.Current.Title)) } c.driftStatus = driftIdle c.driftReason = "" return c.persistLocked() } // RecordWindow ingests one sensor observation. It accumulates time only while // Active, always tracks the latest window for display, and fires onChange after // releasing the mutex. func (c *Mode) RecordWindow(snap evidence.WindowSnapshot) { c.mu.Lock() c.latestWindow = snap if c.runtimeState != domain.RuntimeActive || c.stats == nil { c.mu.Unlock() c.notify() return } now := c.clock() _ = store.AppendFocus(c.sessionsDir, c.stats.SessionID, focusEvent(now, snap)) c.applyEvent(now, snap) c.recordTitleLocked(snap.Title) launch := c.evaluateDriftLocked(now, snap) enforceAct := c.enforceActionLocked() c.mu.Unlock() if launch != nil { go launch() } if enforceAct != nil { go enforceAct() } c.notify() } // enforceActionLocked returns the minimize thunk when this observation should be // enforced — guard wired, level is block, and drift is confirmed — else nil. The // returned func performs blocking X11 I/O and MUST run after the caller releases // c.mu, following the off-lock async-I/O discipline used by the other roles. func (c *Mode) enforceActionLocked() func() { if c.guard == nil || c.enforcementLevel != domain.EnforcementBlock || c.driftStatus != driftDrifting { return nil } guard := c.guard return func() { ctx, cancel := context.WithTimeout(context.Background(), enforceTimeout) defer cancel() if err := guard.MinimizeActive(ctx); err != nil { log.Printf("focus: enforce minimize failed: %v", err) } } } // evaluateDriftLocked runs the local-first drift pipeline for one observation // and updates synchronous drift state. When an async judgment is warranted it // returns the judging closure; the caller runs it in a goroutine after // releasing the mutex (the M2 RequestCoach discipline). Caller holds mu and has // already verified the runtime is Active. func (c *Mode) evaluateDriftLocked(now time.Time, snap evidence.WindowSnapshot) func() { class, title := snap.Class, snap.Title // 1. Local match: authoritative on-task, no LLM. This is also the ONLY path // where the semantic nudge runs — the drift judge stays silent here, so the // two are mutually exclusive per observation. ac := domain.AllowedContext{WindowClasses: c.allowedClasses} if evidence.MatchesAllowed(ac, class, title) { c.driftStatus = driftOnTask c.driftReason = "" return c.maybeNudgeLocked(now) } // Past this point the window is not a local on-task match: the on-task // stretch (if any) has ended. Void any soft nudge tied to it and advance the // epoch so an in-flight nudge result is discarded rather than surfacing stale // when the user returns. c.nudgeMessage = "" c.nudgeEpoch++ // 2. Per-window cache, keyed by class+title. A verdict for one window of an // app must not leak to a sibling window of the same class — a browser hosts // both an on-task reading page and an off-task chat under one window class, so // keying by class alone latched one tab's verdict onto every other tab. key := verdictKey(class, title) if v, ok := c.judgedWindows[key]; ok { c.applyVerdictLocked(v) return nil } // 3. No judge wired: never block; leave idle. if c.judge == nil { c.driftStatus = driftIdle c.driftReason = "" return nil } // 4. Debounce: at most one judgment per driftDebounce window. if !c.lastJudgedAt.IsZero() && now.Sub(c.lastJudgedAt) < driftDebounce { return nil } prevStatus, prevReason := c.driftStatus, c.driftReason c.driftGen++ gen := c.driftGen c.lastJudgedAt = now c.driftStatus = driftPending c.driftReason = "" judge := c.judge commitment := c.commitmentLineLocked() return func() { ctx, cancel := context.WithTimeout(context.Background(), driftTimeout) defer cancel() v, err := judge.JudgeDrift(ctx, commitment, class, title) c.mu.Lock() if gen != c.driftGen || c.runtimeState != domain.RuntimeActive { c.mu.Unlock() return // stale: a newer judgment started or the session ended } if err != nil { // Degrade: never fabricate drift. Revert the optimistic pending. if c.driftStatus == driftPending { c.driftStatus = prevStatus c.driftReason = prevReason } c.mu.Unlock() log.Printf("focus: drift judge failed: %v", err) c.notify() return } c.judgedWindows[key] = v var enforceAct func() if c.stats != nil && verdictKey(c.stats.Current.Class, c.stats.Current.Title) == key { c.applyVerdictLocked(v) enforceAct = c.enforceActionLocked() } c.mu.Unlock() if enforceAct != nil { enforceAct() } c.notify() } } // maybeNudgeLocked decides whether to launch a semantic nudge on the on-task // local-match path. It returns the nudging closure when eligible (judge wired, // at least two titles of history, debounce elapsed), else nil. The closure // follows the drift-judge discipline: it runs in a goroutine after the caller // releases the mutex, re-acquires the lock, guards on nudgeEpoch (the current // on-task-stretch id), so a nudge whose stretch has since ended — a drift // episode or a session change — is discarded rather than surfacing stale, never // fabricates a concern on error, and notifies only after unlocking. Caller holds // mu and has already verified the runtime is Active. func (c *Mode) maybeNudgeLocked(now time.Time) func() { if c.nudge == nil || len(c.recentTitles) < 2 { return nil } if !c.lastNudgedAt.IsZero() && now.Sub(c.lastNudgedAt) < nudgeDebounce { return nil } c.lastNudgedAt = now epoch := c.nudgeEpoch nudge := c.nudge commitment := c.commitmentLineLocked() titles := append([]string(nil), c.recentTitles...) return func() { ctx, cancel := context.WithTimeout(context.Background(), nudgeTimeout) defer cancel() msg, err := nudge.Nudge(ctx, commitment, titles) c.mu.Lock() if epoch != c.nudgeEpoch || c.runtimeState != domain.RuntimeActive { c.mu.Unlock() return // stale: the on-task stretch ended (drift episode) or the session changed } if err != nil { c.mu.Unlock() log.Printf("focus: nudge failed: %v", err) return // never fabricate a concern; leave any prior message intact } c.nudgeMessage = msg // "" clears a prior advisory once back on trajectory c.mu.Unlock() c.notify() } } // recordTitleLocked appends a non-empty title to the recent ring, skipping a // consecutive duplicate, and caps the ring at recentTitlesMax. Caller holds mu. func (c *Mode) recordTitleLocked(title string) { t := strings.TrimSpace(title) if t == "" { return } if n := len(c.recentTitles); n > 0 && c.recentTitles[n-1] == t { return } c.recentTitles = append(c.recentTitles, t) if len(c.recentTitles) > recentTitlesMax { c.recentTitles = c.recentTitles[len(c.recentTitles)-recentTitlesMax:] } } // verdictKey identifies the judged window for the per-window verdict cache. It // combines the window class with the scrubbed, casefolded title so sibling // windows of one app (a browser's reading tab vs its chat tab) are judged // independently, while transient title noise (clocks, counts) does not split a // window into ever-changing keys that defeat the cache. func verdictKey(class, title string) string { return strings.ToLower(strings.TrimSpace(class)) + "\x00" + strings.ToLower(evidence.ScrubTitle(title)) } // applyVerdictLocked maps a verdict onto drift state. Caller holds mu. func (c *Mode) applyVerdictLocked(v ai.Verdict) { if v.OnTask { c.driftStatus = driftOnTask c.driftReason = "" return } c.driftStatus = driftDrifting c.driftReason = v.Reason }