// internal/frame/frame.go // Package frame assembles the standing "what matters to Felix" brief — today's // tasks plus the ~/owc goals and life-domain notes — that grounds a brain call. // It is shared by the off-screen mode (which adds proposal history) and the // ambient sentinel. Best-effort throughout: it never errors and tolerates nil // ports and missing files. package frame import ( "context" "os" "path/filepath" "strings" "unicode/utf8" "keel/internal/knowledge" "keel/internal/tasks" ) // goalsPath is the standing-goals note loaded for the brief. const goalsPath = "~/owc/goals-2026.md" // bugGlob matches the life-domain ("life-bug") notes under ~/owc/resources. const bugGlob = "bug-*.md" // MaxBriefBytes caps the assembled brief so the prompt stays bounded. const MaxBriefBytes = 8 * 1024 // Assemble builds the frame brief from today's tasks, the standing goals note, // and the life-domain notes. It never panics and never errors; nil ports and // missing files degrade to a short, mostly-empty brief. func Assemble(ctx context.Context, know knowledge.Source, tasksProvider tasks.Provider) string { var b strings.Builder b.WriteString("## Today's tasks\n") b.WriteString(briefTasks(ctx, tasksProvider)) b.WriteString("\n") if goals := briefGoals(ctx, know); goals != "" { b.WriteString("## Goals\n") b.WriteString(goals) b.WriteString("\n\n") } if domains := briefLifeDomains(ctx, know); domains != "" { b.WriteString("## Life domains\n") b.WriteString(domains) b.WriteString("\n") } return Truncate(b.String(), MaxBriefBytes) } // briefTasks lists today's task titles, or "(none)" when the port is absent, // errors, or returns nothing. func briefTasks(ctx context.Context, tp tasks.Provider) string { if tp == nil { return "(none)\n" } list, err := tp.Today(ctx) 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 briefGoals(ctx context.Context, know knowledge.Source) string { if know == nil { return "" } p, err := know.Load(ctx, 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. Returns "" // when the port is absent or no notes are readable. func briefLifeDomains(ctx context.Context, know knowledge.Source) string { if 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 := know.Load(ctx, 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") } // Truncate clips s to at most max bytes with a marker, backing up to a rune // boundary so it never splits a multibyte rune. func Truncate(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)" }