// internal/ai/ambient.go package ai import ( "context" "encoding/json" "errors" "fmt" "strings" ) // AmbientDriftJudge judges whether current activity serves the user's standing // frame when NO task is declared. Like Nudge it takes primitives, not domain // types, so ai stays a leaf. The returned string is a one-sentence advisory, or // "" when the activity is plausibly on-track. type AmbientDriftJudge interface { AmbientDrift(ctx context.Context, frame string, recentTitles []string) (string, error) } // ErrInvalidAmbientDrift marks a parseable-but-unusable ambient response. var ErrInvalidAmbientDrift = errors.New("ai: invalid ambient drift") // AmbientDrift makes Service satisfy AmbientDriftJudge over the same backend. func (s *Service) AmbientDrift(ctx context.Context, frame string, recentTitles []string) (string, error) { out, err := s.backend.Run(ctx, buildAmbientPrompt(frame, recentTitles)) if err != nil { return "", err } return parseAmbientDrift(out) } func buildAmbientPrompt(frame string, recentTitles []string) string { return `You are an ambient coach. The user has NOT declared a task. Below is their standing frame — goals, values, and life-domains — and today's tasks, then the recent sequence of window titles. Decide whether the recent activity plausibly serves ANY of what matters to them, or whether it is a slide into drift. Be forgiving: legitimate breaks, rest, admin, and unplanned-but-useful work are ON-TRACK. Only call drift when the activity clearly serves none of what matters. Respond with ONLY a JSON object, no prose and no code fences, exactly this shape: {"drifting": , "message": ""} Rules: - drifting: true only if the recent activity serves none of the frame; false otherwise. - message: one short sentence naming the drift. REQUIRED when drifting is true. ## Frame ` + frame + ` ## Recent window titles (oldest to newest) ` + strings.Join(recentTitles, "\n") } type rawAmbientDrift struct { Drifting bool `json:"drifting"` Message string `json:"message"` } // parseAmbientDrift extracts the advisory from raw CLI output. On-track yields // "". A drift with no message degrades to "" (silence) rather than an error: // the ambient signal is advisory, so the safe degenerate is to say nothing — // exactly like parseNudge. func parseAmbientDrift(s string) (string, error) { if strings.TrimSpace(s) == "" { return "", ErrEmptyResponse } if strings.IndexByte(s, '{') < 0 { return "", ErrNoJSON } jsonStr, err := extractJSON(s) if err != nil { return "", fmt.Errorf("%w: %v", ErrInvalidAmbientDrift, err) } var raw rawAmbientDrift if err := json.Unmarshal([]byte(jsonStr), &raw); err != nil { return "", fmt.Errorf("%w: %v", ErrInvalidAmbientDrift, err) } if !raw.Drifting { return "", nil } return strings.TrimSpace(raw.Message), nil }