Files

80 lines
2.8 KiB
Go

// 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": <true or false>, "message": "<short explanation, one sentence>"}
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
}