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:
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user