feat(offscreen): read recent proposals into the brief; prompt follows up

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-05 19:54:30 -04:00
parent 16b44c9e90
commit bd2b8a41fd
4 changed files with 122 additions and 0 deletions
+1
View File
@@ -39,6 +39,7 @@ Respond with ONLY a JSON object, no prose and no code fences, exactly this shape
Rules: Rules:
- next_action: a single concrete off-screen action, doable now. - next_action: a single concrete off-screen action, doable now.
- rationale: one short sentence naming the goal or value it serves. - 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
` + brief ` + brief
+7
View File
@@ -41,3 +41,10 @@ func TestProposePromptIncludesBrief(t *testing.T) {
t.Fatalf("prompt should embed the brief, got: %s", fb.gotPrompt) 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)
}
}
+79
View File
@@ -2,9 +2,11 @@ package offscreen
import ( import (
"context" "context"
"fmt"
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
"time"
"unicode/utf8" "unicode/utf8"
) )
@@ -17,6 +19,12 @@ const maxBriefBytes = 8 * 1024
// bugGlob matches the life-domain ("life-bug") notes under ~/owc/resources. // bugGlob matches the life-domain ("life-bug") notes under ~/owc/resources.
const bugGlob = "bug-*.md" 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 // 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 // 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 // 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(m.briefTasks())
b.WriteString("\n") b.WriteString("\n")
if hist := m.briefHistory(); hist != "" {
b.WriteString("## Recently proposed\n")
b.WriteString(hist)
b.WriteString("\n")
}
if goals := m.briefGoals(); goals != "" { if goals := m.briefGoals(); goals != "" {
b.WriteString("## Goals\n") b.WriteString("## Goals\n")
b.WriteString(goals) b.WriteString(goals)
@@ -117,6 +131,71 @@ func (m *Mode) briefLifeDomains() string {
return strings.TrimRight(b.String(), "\n") 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 // 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. // bounded. It backs up to a rune boundary so it never splits a multibyte rune.
func truncateBrief(s string, max int) string { func truncateBrief(s string, max int) string {
+35
View File
@@ -3,6 +3,7 @@ package offscreen
import ( import (
"context" "context"
"errors" "errors"
"strings"
"testing" "testing"
"time" "time"
@@ -211,3 +212,37 @@ func TestDismissRecordsProposalDismissed(t *testing.T) {
} }
_ = waitEvent(t, mem, "proposal_dismissed") _ = 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")
}
}