From bd2b8a41fddf4220e3a85e6f60082e8087738222 Mon Sep 17 00:00:00 2001 From: Felix Martin Date: Fri, 5 Jun 2026 19:54:30 -0400 Subject: [PATCH] feat(offscreen): read recent proposals into the brief; prompt follows up Co-Authored-By: Claude Sonnet 4.6 --- internal/ai/proposer.go | 1 + internal/ai/proposer_test.go | 7 ++ internal/mode/offscreen/collect.go | 79 +++++++++++++++++++++++ internal/mode/offscreen/offscreen_test.go | 35 ++++++++++ 4 files changed, 122 insertions(+) diff --git a/internal/ai/proposer.go b/internal/ai/proposer.go index 3252109..ae36a3f 100644 --- a/internal/ai/proposer.go +++ b/internal/ai/proposer.go @@ -39,6 +39,7 @@ Respond with ONLY a JSON object, no prose and no code fences, exactly this shape Rules: - next_action: a single concrete off-screen action, doable now. - rationale: one short sentence naming the goal or value it serves. +- If the brief lists recently proposed actions, do not simply repeat them; if a recent one was dismissed or not done and still fits, follow up on it instead of inventing something new. ## Brief ` + brief diff --git a/internal/ai/proposer_test.go b/internal/ai/proposer_test.go index 6934f4d..6d0632d 100644 --- a/internal/ai/proposer_test.go +++ b/internal/ai/proposer_test.go @@ -41,3 +41,10 @@ func TestProposePromptIncludesBrief(t *testing.T) { t.Fatalf("prompt should embed the brief, got: %s", fb.gotPrompt) } } + +func TestProposePromptHasFollowUpRule(t *testing.T) { + p := buildProposePrompt("brief") + if !strings.Contains(p, "do not simply repeat") { + t.Fatalf("prompt missing follow-up rule:\n%s", p) + } +} diff --git a/internal/mode/offscreen/collect.go b/internal/mode/offscreen/collect.go index ecbae2f..2e4fcec 100644 --- a/internal/mode/offscreen/collect.go +++ b/internal/mode/offscreen/collect.go @@ -2,9 +2,11 @@ package offscreen import ( "context" + "fmt" "os" "path/filepath" "strings" + "time" "unicode/utf8" ) @@ -17,6 +19,12 @@ const maxBriefBytes = 8 * 1024 // bugGlob matches the life-domain ("life-bug") notes under ~/owc/resources. const bugGlob = "bug-*.md" +const ( + recentProposals = 5 // how many recent proposals to show + outcomeScan = 50 // how many outcome events to scan for correlation + recentWindow = 7 * 24 * time.Hour +) + // assembleBrief builds the off-screen prompt context from today's tasks, the // standing goals note, and the life-domain notes. It is best-effort: it never // panics and never returns an error, and it tolerates nil ports and missing @@ -28,6 +36,12 @@ func (m *Mode) assembleBrief() string { b.WriteString(m.briefTasks()) b.WriteString("\n") + if hist := m.briefHistory(); hist != "" { + b.WriteString("## Recently proposed\n") + b.WriteString(hist) + b.WriteString("\n") + } + if goals := m.briefGoals(); goals != "" { b.WriteString("## Goals\n") b.WriteString(goals) @@ -117,6 +131,71 @@ func (m *Mode) briefLifeDomains() string { return strings.TrimRight(b.String(), "\n") } +// briefHistory renders recent proposals with their outcome, newest first and +// within recentWindow. Returns "" when there is no memory or no recent history. +func (m *Mode) briefHistory() string { + if m.mem == nil { + return "" + } + ctx := context.Background() + proposals, err := m.mem.Recent(ctx, kindProposalMade, recentProposals) + if err != nil || len(proposals) == 0 { + return "" + } + taken := m.idSet(ctx, kindActionTaken) + dismissed := m.idSet(ctx, kindProposalDismissed) + now := m.now() + + var b strings.Builder + for _, ev := range proposals { + if now.Sub(ev.At) > recentWindow { + continue + } + id, _ := ev.Data["proposal_id"].(string) + action, _ := ev.Data["next_action"].(string) + if action == "" { + continue + } + outcome := "unactioned" + if taken[id] { + outcome = "confirmed" + } else if dismissed[id] { + outcome = "dismissed" + } + fmt.Fprintf(&b, "- %q (%s, %s)\n", action, outcome, relativeAge(now.Sub(ev.At))) + } + return b.String() +} + +// idSet reads recent events of a kind and returns their proposal_ids as a set. +func (m *Mode) idSet(ctx context.Context, kind string) map[string]bool { + out := map[string]bool{} + evs, err := m.mem.Recent(ctx, kind, outcomeScan) + if err != nil { + return out + } + for _, e := range evs { + if id, _ := e.Data["proposal_id"].(string); id != "" { + out[id] = true + } + } + return out +} + +// relativeAge renders a coarse human age for a duration. +func relativeAge(d time.Duration) string { + switch { + case d < time.Minute: + return "just now" + case d < time.Hour: + return fmt.Sprintf("%dm ago", int(d.Minutes())) + case d < 24*time.Hour: + return fmt.Sprintf("%dh ago", int(d.Hours())) + default: + return fmt.Sprintf("%dd ago", int(d.Hours()/24)) + } +} + // truncateBrief clips s to at most max bytes with a marker, keeping the prompt // bounded. It backs up to a rune boundary so it never splits a multibyte rune. func truncateBrief(s string, max int) string { diff --git a/internal/mode/offscreen/offscreen_test.go b/internal/mode/offscreen/offscreen_test.go index af0d78b..2991ac8 100644 --- a/internal/mode/offscreen/offscreen_test.go +++ b/internal/mode/offscreen/offscreen_test.go @@ -3,6 +3,7 @@ package offscreen import ( "context" "errors" + "strings" "testing" "time" @@ -211,3 +212,37 @@ func TestDismissRecordsProposalDismissed(t *testing.T) { } _ = waitEvent(t, mem, "proposal_dismissed") } + +func TestBriefIncludesRecentProposalsWithOutcome(t *testing.T) { + mem := memory.NewFake() + ctx := context.Background() + // at = the test clock (Unix 1000); confirmed walk, dismissed stretch. + _ = mem.Record(ctx, memory.Event{Kind: "proposal_made", At: time.Unix(1000, 0), + Data: map[string]any{"proposal_id": "w", "next_action": "walk"}}) + _ = mem.Record(ctx, memory.Event{Kind: "action_taken", At: time.Unix(1000, 0), + Data: map[string]any{"proposal_id": "w"}}) + _ = mem.Record(ctx, memory.Event{Kind: "proposal_made", At: time.Unix(1000, 0), + Data: map[string]any{"proposal_id": "s", "next_action": "stretch"}}) + _ = mem.Record(ctx, memory.Event{Kind: "proposal_dismissed", At: time.Unix(1000, 0), + Data: map[string]any{"proposal_id": "s"}}) + + m := newTestModeMem(t, &fakeTasks{}, okBackend(), mem) + brief := m.assembleBrief() + + if !strings.Contains(brief, "Recently proposed") { + t.Fatalf("brief missing history section:\n%s", brief) + } + if !strings.Contains(brief, "walk") || !strings.Contains(brief, "confirmed") { + t.Fatalf("brief missing confirmed walk:\n%s", brief) + } + if !strings.Contains(brief, "stretch") || !strings.Contains(brief, "dismissed") { + t.Fatalf("brief missing dismissed stretch:\n%s", brief) + } +} + +func TestBriefOmitsHistoryWhenNoMemory(t *testing.T) { + m := newTestMode(t, &fakeTasks{}, okBackend()) // no Memory injected + if strings.Contains(m.assembleBrief(), "Recently proposed") { + t.Fatal("history section should be absent without memory") + } +}