package ai import ( "context" "encoding/json" "errors" "fmt" "strings" ) // Nudger judges whether recent activity within an allowed app still serves the // commitment. Like Coach and DriftJudge it takes primitives, not domain/evidence // types, so ai stays a leaf package. The returned string is a one-sentence // advisory, or "" when the trajectory is still on-task. type Nudger interface { Nudge(ctx context.Context, commitment string, recentTitles []string) (string, error) } // ErrInvalidNudge marks a parseable-but-unusable nudge response. var ErrInvalidNudge = errors.New("ai: invalid nudge") // Nudge makes Service satisfy Nudger over the same backend as Coach and JudgeDrift. func (s *Service) Nudge(ctx context.Context, commitment string, recentTitles []string) (string, error) { out, err := s.backend.Run(ctx, buildNudgePrompt(commitment, recentTitles)) if err != nil { return "", err } return parseNudge(out) } func buildNudgePrompt(commitment string, recentTitles []string) string { return `You are a focus monitor. The user committed to a task and is working in an allowed application, so the application itself is fine. Judge whether the SEQUENCE of recent window titles shows them still working toward the commitment, or whether they have drifted onto unrelated work within that app. Respond with ONLY a JSON object, no prose and no code fences, exactly this shape: {"on_track": , "message": ""} Rules: - on_track: true if the recent titles plausibly serve the commitment, false if they show unrelated work. - message: one short sentence naming the drift. REQUIRED when on_track is false. Commitment: ` + commitment + ` Recent window titles (oldest to newest): ` + strings.Join(recentTitles, "\n") } type rawNudge struct { OnTrack bool `json:"on_track"` Message string `json:"message"` } // parseNudge extracts the advisory from raw CLI output. It reuses extractJSON // and the shared empty/no-JSON sentinels. An on-track result yields "". A // concern with no message degrades to "" (silence) rather than an error: the // nudge is ambient, so the safe degenerate is to say nothing, unlike the // interruptive drift verdict which rejects a reasonless drift. func parseNudge(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", ErrInvalidNudge, err) } var raw rawNudge if err := json.Unmarshal([]byte(jsonStr), &raw); err != nil { return "", fmt.Errorf("%w: %v", ErrInvalidNudge, err) } if raw.OnTrack { return "", nil } return strings.TrimSpace(raw.Message), nil }