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 {
|
||||
|
||||
Reference in New Issue
Block a user