// Package session owns the daemon's in-memory state of truth and persists a // snapshot on every change. Transitions go through the pure statemachine. It // also owns per-session evidence stats: it accumulates active-window time while // Active, logs raw focus events, and seals each session into the audit chain. package session import ( "context" "errors" "log" "path/filepath" "sort" "strings" "sync" "time" "antidrift/internal/ai" "antidrift/internal/domain" "antidrift/internal/evidence" "antidrift/internal/statemachine" "antidrift/internal/store" "github.com/google/uuid" ) const ( unavailableTitle = "(evidence unavailable)" sessionRetention = 30 * 24 * time.Hour ) const coachTimeout = 60 * time.Second const ( coachIdle = "idle" coachPending = "pending" coachReady = "ready" coachError = "error" ) const ( driftDebounce = 10 * time.Second driftTimeout = 30 * time.Second nudgeDebounce = 5 * time.Minute nudgeTimeout = 30 * time.Second ) const recentTitlesMax = 10 const ( driftIdle = "idle" driftPending = "pending" driftOnTask = "ontask" driftDrifting = "drifting" ) var ErrNotPlanning = errors.New("session: coaching is only available while planning") var ErrNotActive = errors.New("session: only available while a commitment is active") // bucketKey identifies a time bucket; Title is the scrubbed title. type bucketKey struct{ Class, Title string } // EvidenceStats is the in-memory accounting for the current session only. type EvidenceStats struct { SessionID string StartedUnix int64 Buckets map[bucketKey]time.Duration SwitchCount int Current evidence.WindowSnapshot lastFocusAt time.Time lastKey bucketKey hasLast bool } // Controller holds runtime state and the active commitment behind a mutex. type Controller struct { mu sync.Mutex runtimeState domain.RuntimeState commitment *domain.Commitment deadline time.Time snapshotPath string auditPath string sessionsDir string clock func() time.Time onChange func() latestWindow evidence.WindowSnapshot stats *EvidenceStats outcomePending string coach ai.Coach coachStatus string coachProposal *ai.Proposal coachErr string coachGen int allowedClasses []string // durable: the active session's allowed window classes judge ai.DriftJudge driftStatus string driftReason string driftGen int nudgeEpoch int // identifies the current on-task stretch; nudge staleness guard lastJudgedAt time.Time judgedClasses map[string]ai.Verdict nudge ai.Nudger recentTitles []string // in-memory ring of recent distinct titles this session nudgeMessage string // current soft advisory ("" = none) lastNudgedAt time.Time } // CommitmentView is the UI projection of the active commitment. type CommitmentView struct { NextAction string `json:"next_action"` SuccessCondition string `json:"success_condition"` TimeboxSecs int64 `json:"timebox_secs"` DeadlineUnixSecs int64 `json:"deadline_unix_secs"` } // ProposalView / CoachView project the ephemeral planning-coach state. type ProposalView struct { NextAction string `json:"next_action"` SuccessCondition string `json:"success_condition"` TimeboxSecs int64 `json:"timebox_secs"` AllowedWindowClasses []string `json:"allowed_window_classes,omitempty"` } // DriftView is the active-only drift projection. Nudge is a separate axis from // Status: it is populated precisely when Status is "ontask" (semantic drift // inside an allowed app), so it cannot be folded into the status enum. type DriftView struct { Status string `json:"status"` Reason string `json:"reason,omitempty"` Nudge string `json:"nudge,omitempty"` } type CoachView struct { Status string `json:"status"` Proposal *ProposalView `json:"proposal,omitempty"` Error string `json:"error,omitempty"` } // WindowView / BucketView / EvidenceView are the evidence projection. type WindowView struct { Class string `json:"class"` Title string `json:"title"` } type BucketView struct { Class string `json:"class"` Title string `json:"title"` Seconds int64 `json:"seconds"` } type EvidenceView struct { Available bool `json:"available"` Reason string `json:"reason"` Current WindowView `json:"current"` SwitchCount int `json:"switch_count"` Buckets []BucketView `json:"buckets"` } // State is the broadcastable view of the controller. type State struct { RuntimeState domain.RuntimeState `json:"runtime_state"` Commitment *CommitmentView `json:"commitment"` Evidence *EvidenceView `json:"evidence"` Coach *CoachView `json:"coach,omitempty"` Drift *DriftView `json:"drift,omitempty"` } // New loads any persisted snapshot, prunes stale session logs, and rebuilds // in-memory stats from the raw log if a live session was interrupted. func New(snapshotPath string) (*Controller, error) { s, err := store.Load(snapshotPath) if err != nil { return nil, err } dir := filepath.Dir(snapshotPath) c := &Controller{ runtimeState: s.RuntimeState, commitment: s.Commitment, snapshotPath: snapshotPath, auditPath: filepath.Join(dir, "audit.jsonl"), sessionsDir: filepath.Join(dir, "sessions"), clock: time.Now, outcomePending: s.OutcomePending, } if s.DeadlineUnixSecs > 0 { c.deadline = time.Unix(s.DeadlineUnixSecs, 0) } if c.runtimeState == "" { c.runtimeState = domain.RuntimeLocked } _ = store.PruneOlderThan(c.sessionsDir, sessionRetention, c.clock()) if c.runtimeState == domain.RuntimeActive && s.SessionID != "" { c.allowedClasses = s.AllowedWindowClasses // Drift state is not persisted: recompute fresh after restart to avoid // stale interrupts. This also initializes judgedClasses (the pipeline // writes to it) so a restored Active session never panics on a nil map. c.resetDriftLocked() c.replayStats(s.SessionID) } return c, nil } // SetClock overrides the time source (tests only). Call before starting a // session. func (c *Controller) SetClock(f func() time.Time) { c.mu.Lock() c.clock = f c.mu.Unlock() } // SetOnChange registers a callback fired after an evidence-driven state change // (focus updates). It is invoked with the mutex released. func (c *Controller) SetOnChange(f func()) { c.mu.Lock() c.onChange = f c.mu.Unlock() } func (c *Controller) notify() { c.mu.Lock() f := c.onChange c.mu.Unlock() if f != nil { f() } } // State returns the current broadcastable state. Safe for concurrent use. func (c *Controller) State() State { c.mu.Lock() defer c.mu.Unlock() return c.stateLocked() } // Deadline returns the active commitment deadline, or the zero time. func (c *Controller) Deadline() time.Time { c.mu.Lock() defer c.mu.Unlock() return c.deadline } func (c *Controller) stateLocked() State { st := State{RuntimeState: c.runtimeState} if c.commitment != nil { view := &CommitmentView{ NextAction: c.commitment.NextAction, SuccessCondition: c.commitment.SuccessCondition, TimeboxSecs: c.commitment.TimeboxSecs, } if !c.deadline.IsZero() { view.DeadlineUnixSecs = c.deadline.Unix() } st.Commitment = view } if c.stats != nil { st.Evidence = &EvidenceView{ Available: c.stats.Current.Health.Available, Reason: c.stats.Current.Health.Reason, Current: WindowView{Class: c.stats.Current.Class, Title: c.stats.Current.Title}, SwitchCount: c.stats.SwitchCount, Buckets: bucketViews(c.stats.Buckets), } } if c.runtimeState == domain.RuntimePlanning { status := c.coachStatus if status == "" { status = coachIdle } cv := &CoachView{Status: status, Error: c.coachErr} if c.coachProposal != nil { cv.Proposal = &ProposalView{ NextAction: c.coachProposal.NextAction, SuccessCondition: c.coachProposal.SuccessCondition, TimeboxSecs: c.coachProposal.TimeboxSecs, AllowedWindowClasses: c.coachProposal.AllowedWindowClasses, } } st.Coach = cv } if c.runtimeState == domain.RuntimeActive { status := c.driftStatus if status == "" { status = driftIdle } st.Drift = &DriftView{Status: status, Reason: c.driftReason, Nudge: c.nudgeMessage} } return st } func bucketViews(buckets map[bucketKey]time.Duration) []BucketView { out := make([]BucketView, 0, len(buckets)) for k, d := range buckets { out = append(out, BucketView{Class: k.Class, Title: k.Title, Seconds: int64(d.Seconds())}) } sort.Slice(out, func(i, j int) bool { return out[i].Seconds > out[j].Seconds }) return out } func (c *Controller) persistLocked() error { snap := store.Snapshot{ RuntimeState: c.runtimeState, Commitment: c.commitment, OutcomePending: c.outcomePending, } if !c.deadline.IsZero() { snap.DeadlineUnixSecs = c.deadline.Unix() } if c.stats != nil { snap.SessionID = c.stats.SessionID } snap.AllowedWindowClasses = c.allowedClasses return store.Save(c.snapshotPath, snap) } // EnterPlanning moves Locked -> Planning. func (c *Controller) EnterPlanning() error { c.mu.Lock() defer c.mu.Unlock() next, err := statemachine.TransitionRuntime(c.runtimeState, statemachine.EnterPlanning) if err != nil { return err } c.runtimeState = next c.resetCoachLocked() return c.persistLocked() } // SetCoach injects the AI coach. Mirrors SetOnChange. A nil coach makes // RequestCoach degrade gracefully. func (c *Controller) SetCoach(coach ai.Coach) { c.mu.Lock() c.coach = coach c.mu.Unlock() } // resetCoachLocked returns coach state to idle and invalidates any in-flight // request. Caller holds mu. func (c *Controller) resetCoachLocked() { c.coachStatus = coachIdle c.coachProposal = nil c.coachErr = "" c.coachGen++ } // AllowedClassesForTest exposes the session allowed classes for tests. func (c *Controller) AllowedClassesForTest() []string { c.mu.Lock() defer c.mu.Unlock() return append([]string(nil), c.allowedClasses...) } // SetDriftJudge injects the AI drift judge. A nil judge keeps local matching // working; unmatched windows simply stay idle. func (c *Controller) SetDriftJudge(j ai.DriftJudge) { c.mu.Lock() c.judge = j c.mu.Unlock() } // SetNudge injects the AI semantic nudge judge. A nil nudger disables nudging; // local matching and the drift judge are unaffected. func (c *Controller) 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 *Controller) 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 *Controller) 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 *Controller) resetDriftLocked() { c.driftStatus = driftIdle c.driftReason = "" c.driftGen++ c.nudgeEpoch++ c.lastJudgedAt = time.Time{} c.judgedClasses = 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 any cached verdict for that class, and persists. The class now // matches locally and is never re-judged. func (c *Controller) 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.judgedClasses, class) } 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 class so it may be judged again later. func (c *Controller) Refocus() error { c.mu.Lock() defer c.mu.Unlock() if c.runtimeState != domain.RuntimeActive { return ErrNotActive } if c.stats != nil { delete(c.judgedClasses, c.stats.Current.Class) } c.driftStatus = driftIdle c.driftReason = "" return c.persistLocked() } // RequestCoach starts an async coach call for intent. Returns ErrNotPlanning if // not in planning; otherwise never a hard error (failures surface as coach // state). The proposal is ephemeral and never persisted. func (c *Controller) RequestCoach(intent string) error { c.mu.Lock() if c.runtimeState != domain.RuntimePlanning { c.mu.Unlock() return ErrNotPlanning } if c.coach == nil { c.coachStatus = coachError c.coachErr = "coach unavailable" c.coachProposal = nil c.mu.Unlock() c.notify() return nil } c.coachGen++ gen := c.coachGen c.coachStatus = coachPending c.coachErr = "" c.coachProposal = nil coach := c.coach c.mu.Unlock() c.notify() go func() { ctx, cancel := context.WithTimeout(context.Background(), coachTimeout) defer cancel() prop, err := coach.Coach(ctx, intent) c.mu.Lock() if gen != c.coachGen || c.runtimeState != domain.RuntimePlanning { c.mu.Unlock() return // stale or left planning: discard } if err != nil { c.coachStatus = coachError c.coachErr = coachErrorMessage(err) c.coachProposal = nil } else { c.coachStatus = coachReady c.coachProposal = &prop c.coachErr = "" } c.mu.Unlock() c.notify() }() return nil } func coachErrorMessage(err error) string { switch { case errors.Is(err, ai.ErrEmptyResponse), errors.Is(err, ai.ErrNoJSON), errors.Is(err, ai.ErrInvalidProposal): return "coach returned an unusable response" case errors.Is(err, context.DeadlineExceeded): return "coach timed out" default: return "coach unavailable" } } // StartManualCommitment validates input, activates a new commitment, mints a // session, seeds evidence stats from the latest window, and moves Planning -> // Active. func (c *Controller) StartManualCommitment(nextAction, successCondition string, timebox time.Duration, allowedClasses []string) error { c.mu.Lock() defer c.mu.Unlock() commitment, err := domain.NewManual(nextAction, successCondition, timebox) if err != nil { return err } commitment.State, err = statemachine.TransitionCommitment(commitment.State, statemachine.CommitmentActivate) if err != nil { return err } next, err := statemachine.TransitionRuntime(c.runtimeState, statemachine.ActivateAccepted) if err != nil { return err } now := c.clock() c.runtimeState = next c.commitment = &commitment c.deadline = now.Add(timebox) c.outcomePending = "" c.resetCoachLocked() c.allowedClasses = append([]string(nil), allowedClasses...) c.resetDriftLocked() sessionID := "session-" + uuid.Must(uuid.NewV7()).String() c.stats = &EvidenceStats{ SessionID: sessionID, StartedUnix: now.Unix(), Buckets: map[bucketKey]time.Duration{}, } seed := c.latestWindow _ = store.AppendFocus(c.sessionsDir, sessionID, focusEvent(now, seed)) c.applyEvent(now, seed) return c.persistLocked() } // Complete moves Active -> Review with a "completed" outcome. func (c *Controller) Complete() error { return c.enterReview("completed") } // Expire moves Active -> Review with an "expired" outcome (timebox elapsed). func (c *Controller) Expire() error { return c.enterReview("expired") } func (c *Controller) enterReview(outcome string) error { c.mu.Lock() defer c.mu.Unlock() next, err := statemachine.TransitionRuntime(c.runtimeState, statemachine.CompleteForReview) if err != nil { return err } if c.commitment != nil { completed, err := statemachine.TransitionCommitment(c.commitment.State, statemachine.Complete) if err != nil { return err } c.commitment.State = completed } // Flush the final open segment, then freeze accounting. if c.stats != nil && c.stats.hasLast { c.stats.Buckets[c.stats.lastKey] += c.clock().Sub(c.stats.lastFocusAt) c.stats.hasLast = false } c.runtimeState = next c.outcomePending = outcome return c.persistLocked() } // End moves Review -> Locked, writes the session summary to the audit chain, // and clears the commitment and stats. func (c *Controller) End() error { c.mu.Lock() defer c.mu.Unlock() next, err := statemachine.TransitionRuntime(c.runtimeState, statemachine.EndWorkPeriod) if err != nil { return err } if c.stats != nil { if err := store.AppendSession(c.auditPath, c.buildSummaryLocked()); err != nil { // State integrity over audit completeness: the transition still // completes. Surfaced for the operator; no auto-retry in M1. log.Printf("session: audit append failed: %v", err) } } c.runtimeState = next c.commitment = nil c.deadline = time.Time{} c.stats = nil c.outcomePending = "" c.allowedClasses = nil c.resetDriftLocked() return c.persistLocked() } func (c *Controller) buildSummaryLocked() store.SessionSummary { buckets := make([]store.BucketTotal, 0, len(c.stats.Buckets)) for k, d := range c.stats.Buckets { buckets = append(buckets, store.BucketTotal{Class: k.Class, Title: k.Title, Seconds: int64(d.Seconds())}) } sort.Slice(buckets, func(i, j int) bool { return buckets[i].Seconds > buckets[j].Seconds }) outcome := c.outcomePending if outcome == "" { outcome = "completed" } var na, sc string if c.commitment != nil { na, sc = c.commitment.NextAction, c.commitment.SuccessCondition } return store.SessionSummary{ SessionID: c.stats.SessionID, NextAction: na, SuccessCond: sc, Outcome: outcome, StartedUnix: c.stats.StartedUnix, EndedUnix: c.clock().Unix(), SwitchCount: c.stats.SwitchCount, Buckets: buckets, } } // 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 *Controller) 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) c.mu.Unlock() if launch != nil { go launch() } c.notify() } // 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 *Controller) 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-class cache. if v, ok := c.judgedClasses[class]; 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("session: drift judge failed: %v", err) c.notify() return } c.judgedClasses[class] = v if c.stats != nil && c.stats.Current.Class == class { c.applyVerdictLocked(v) } c.mu.Unlock() 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 *Controller) 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("session: 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 *Controller) 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:] } } // applyVerdictLocked maps a verdict onto drift state. Caller holds mu. func (c *Controller) applyVerdictLocked(v ai.Verdict) { if v.OnTask { c.driftStatus = driftOnTask c.driftReason = "" return } c.driftStatus = driftDrifting c.driftReason = v.Reason } // applyEvent advances stats by one observation: it credits the prior segment to // the prior bucket, counts a context switch on key change, and records the new // current window. Used by both live tracking and crash replay. Caller holds mu. func (c *Controller) applyEvent(now time.Time, snap evidence.WindowSnapshot) { if c.stats.hasLast { c.stats.Buckets[c.stats.lastKey] += now.Sub(c.stats.lastFocusAt) } newKey := keyFor(snap) if c.stats.hasLast && newKey != c.stats.lastKey { c.stats.SwitchCount++ } c.stats.lastKey = newKey c.stats.lastFocusAt = now c.stats.hasLast = true c.stats.Current = snap } // replayStats rebuilds in-memory stats from the raw session log after a crash. func (c *Controller) replayStats(sessionID string) { events, err := store.ReplaySession(c.sessionsDir, sessionID) if err != nil || len(events) == 0 { c.stats = &EvidenceStats{ SessionID: sessionID, StartedUnix: c.clock().Unix(), Buckets: map[bucketKey]time.Duration{}, } return } c.stats = &EvidenceStats{ SessionID: sessionID, StartedUnix: events[0].AtUnixMillis / 1000, Buckets: map[bucketKey]time.Duration{}, } for _, e := range events { c.applyEvent(time.UnixMilli(e.AtUnixMillis), snapFromEvent(e)) } } func keyFor(snap evidence.WindowSnapshot) bucketKey { if !snap.Health.Available { return bucketKey{Class: "", Title: unavailableTitle} } return bucketKey{Class: snap.Class, Title: evidence.ScrubTitle(snap.Title)} } func focusEvent(now time.Time, snap evidence.WindowSnapshot) store.FocusEvent { return store.FocusEvent{ AtUnixMillis: now.UnixMilli(), Class: snap.Class, Title: snap.Title, Available: snap.Health.Available, Reason: snap.Health.Reason, } } func snapFromEvent(e store.FocusEvent) evidence.WindowSnapshot { return evidence.WindowSnapshot{ Title: e.Title, Class: e.Class, Health: evidence.EvidenceHealth{Available: e.Available, Reason: e.Reason}, } }