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