80 lines
2.8 KiB
Go
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
|
|
}
|