From b37ee40dd230ebf8507765a8d43223dc7ef05638 Mon Sep 17 00:00:00 2001 From: Felix Martin Date: Fri, 5 Jun 2026 21:30:37 -0400 Subject: [PATCH] =?UTF-8?q?feat(ai):=20AmbientDrift=20=E2=80=94=20frame-gr?= =?UTF-8?q?ounded=20drift=20judge?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/ai/ambient.go | 79 +++++++++++++++++++++++++++++++++++++ internal/ai/ambient_test.go | 68 +++++++++++++++++++++++++++++++ 2 files changed, 147 insertions(+) create mode 100644 internal/ai/ambient.go create mode 100644 internal/ai/ambient_test.go diff --git a/internal/ai/ambient.go b/internal/ai/ambient.go new file mode 100644 index 0000000..4d0dbde --- /dev/null +++ b/internal/ai/ambient.go @@ -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": , "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 +} diff --git a/internal/ai/ambient_test.go b/internal/ai/ambient_test.go new file mode 100644 index 0000000..97462d1 --- /dev/null +++ b/internal/ai/ambient_test.go @@ -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) + } + }) + } +}