feat(ai): AmbientDrift — frame-grounded drift judge

This commit is contained in:
2026-06-05 21:30:37 -04:00
parent 2690fdd513
commit b37ee40dd2
2 changed files with 147 additions and 0 deletions
+79
View File
@@ -0,0 +1,79 @@
// 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
}
+68
View File
@@ -0,0 +1,68 @@
// internal/ai/ambient_test.go
package ai
import (
"context"
"errors"
"strings"
"testing"
)
func TestServiceAmbientDriftSuccess(t *testing.T) {
fb := &fakeBackend{out: `{"drifting": true, "message": "deep in YouTube, none of today's goals"}`}
line, err := NewService(fb).AmbientDrift(context.Background(), "## Goals\nship keel", []string{"YouTube - Brave"})
if err != nil {
t.Fatalf("ambient drift: %v", err)
}
if line == "" {
t.Fatal("expected a non-empty drift line")
}
if !strings.Contains(fb.gotPrompt, "ship keel") {
t.Fatalf("prompt should embed the frame, got: %s", fb.gotPrompt)
}
if !strings.Contains(fb.gotPrompt, "YouTube - Brave") {
t.Fatalf("prompt should embed the recent titles, got: %s", fb.gotPrompt)
}
}
func TestServiceAmbientDriftBackendError(t *testing.T) {
fb := &fakeBackend{err: errors.New("boom")}
if _, err := NewService(fb).AmbientDrift(context.Background(), "frame", []string{"x"}); err == nil {
t.Fatal("want backend error")
}
}
func TestParseAmbientDrift(t *testing.T) {
tests := []struct {
name string
in string
want string
wantErr error
}{
{"on track yields empty", `{"drifting": false, "message": ""}`, "", nil},
{"drift returns message", `{"drifting": true, "message": "watching unrelated videos"}`, "watching unrelated videos", nil},
{"drift is trimmed", `{"drifting": true, "message": " slid "}`, "slid", nil},
{"drift without message degrades to silence", `{"drifting": true, "message": ""}`, "", nil},
{"json embedded in prose", `ok: {"drifting": true, "message": "off track"} done`, "off track", nil},
{"empty response", " ", "", ErrEmptyResponse},
{"no json", "no braces here", "", ErrNoJSON},
{"malformed json", `{"drifting": true, "message":`, "", ErrInvalidAmbientDrift},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := parseAmbientDrift(tt.in)
if tt.wantErr != nil {
if !errors.Is(err, tt.wantErr) {
t.Fatalf("err = %v, want %v", err, tt.wantErr)
}
return
}
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
if got != tt.want {
t.Fatalf("got %q, want %q", got, tt.want)
}
})
}
}