package offscreen import ( "context" "fmt" "os" "path/filepath" "strings" "time" "unicode/utf8" ) // goalsPath is the standing-goals note loaded for the off-screen brief. const goalsPath = "~/owc/goals-2026.md" // maxBriefBytes caps the assembled brief so the propose prompt stays bounded. 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 // files (degrading to a short, mostly-empty brief). func (m *Mode) assembleBrief() string { var b strings.Builder b.WriteString("## Today's tasks\n") 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) b.WriteString("\n\n") } if domains := m.briefLifeDomains(); domains != "" { b.WriteString("## Life domains\n") b.WriteString(domains) b.WriteString("\n") } return truncateBrief(b.String(), maxBriefBytes) } // briefTasks lists today's task titles, or "(none)" when the port is absent, // errors, or returns nothing. func (m *Mode) briefTasks() string { if m.tasks == nil { return "(none)\n" } list, err := m.tasks.Today(context.Background()) if err != nil || len(list) == 0 { return "(none)\n" } var b strings.Builder for _, t := range list { title := strings.TrimSpace(t.Title) if title == "" { continue } b.WriteString("- ") b.WriteString(title) b.WriteString("\n") } if b.Len() == 0 { return "(none)\n" } return b.String() } // briefGoals returns the goals note text, or "" when the port is absent or the // file is missing/empty. func (m *Mode) briefGoals() string { if m.know == nil { return "" } p, err := m.know.Load(context.Background(), goalsPath) if err != nil { return "" } return strings.TrimSpace(p.Text) } // briefLifeDomains loads every ~/owc/resources/bug-*.md note via the knowledge // port (so reads honor its truncation) and concatenates their text under one // section. Returns "" when the port is absent or no notes are readable. File // access stays behind the port; when the port is nil it is skipped entirely. func (m *Mode) briefLifeDomains() string { if m.know == nil { return "" } home, err := os.UserHomeDir() if err != nil { return "" } matches, err := filepath.Glob(filepath.Join(home, "owc", "resources", bugGlob)) if err != nil || len(matches) == 0 { return "" } var b strings.Builder for _, path := range matches { p, err := m.know.Load(context.Background(), path) if err != nil { continue } text := strings.TrimSpace(p.Text) if text == "" { continue } b.WriteString("### ") b.WriteString(filepath.Base(path)) b.WriteString("\n") b.WriteString(text) b.WriteString("\n\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 // bounded. It backs up to a rune boundary so it never splits a multibyte rune. func truncateBrief(s string, max int) string { if len(s) <= max { return s } cut := max for cut > 0 && !utf8.RuneStart(s[cut]) { cut-- } return s[:cut] + "\n…(truncated)" }