diff --git a/internal/frame/frame.go b/internal/frame/frame.go new file mode 100644 index 0000000..1802d50 --- /dev/null +++ b/internal/frame/frame.go @@ -0,0 +1,139 @@ +// 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)" +} diff --git a/internal/frame/frame_test.go b/internal/frame/frame_test.go new file mode 100644 index 0000000..237ebc3 --- /dev/null +++ b/internal/frame/frame_test.go @@ -0,0 +1,70 @@ +// internal/frame/frame_test.go +package frame + +import ( + "context" + "strings" + "testing" + + "keel/internal/knowledge" + "keel/internal/tasks" +) + +// fakeTasks is a tasks.Provider returning a fixed list. +type fakeTasks struct{ list []tasks.Task } + +// emptyKnowledge is a knowledge.Source that returns no text for any path, so +// Assemble emits neither a Goals nor a Life-domains section regardless of what +// files exist on the host. +type emptyKnowledge struct{} + +func (emptyKnowledge) Load(context.Context, string) (knowledge.Profile, error) { + return knowledge.Profile{}, nil +} + +func (f fakeTasks) Today(context.Context) ([]tasks.Task, error) { return f.list, nil } +func (f fakeTasks) Create(context.Context, tasks.Task) error { return nil } + +func TestAssembleListsTodaysTasks(t *testing.T) { + tp := fakeTasks{list: []tasks.Task{{Title: "write the keel spec"}}} + got := Assemble(context.Background(), nil, tp) + if !strings.Contains(got, "## Today's tasks") { + t.Fatalf("missing tasks header:\n%s", got) + } + if !strings.Contains(got, "write the keel spec") { + t.Fatalf("missing task title:\n%s", got) + } +} + +// With no knowledge source and no tasks, Assemble degrades to a short brief and +// never panics — both ports may be nil. +func TestAssembleToleratesNilPorts(t *testing.T) { + got := Assemble(context.Background(), nil, nil) + if !strings.Contains(got, "## Today's tasks") { + t.Fatalf("expected a tasks header even with no ports:\n%s", got) + } + if !strings.Contains(got, "(none)") { + t.Fatalf("expected (none) for absent tasks:\n%s", got) + } +} + +func TestAssembleWithEmptyKnowledge(t *testing.T) { + got := Assemble(context.Background(), emptyKnowledge{}, fakeTasks{}) + if strings.Contains(got, "## Goals") { + t.Fatalf("empty knowledge should not emit a Goals section:\n%s", got) + } + if strings.Contains(got, "## Life domains") { + t.Fatalf("empty knowledge should not emit a Life domains section:\n%s", got) + } +} + +func TestTruncateClipsWithMarker(t *testing.T) { + in := strings.Repeat("x", MaxBriefBytes+100) + got := Truncate(in, MaxBriefBytes) + if len(got) > MaxBriefBytes+len("\n…(truncated)") { + t.Fatalf("truncate did not clip: len=%d", len(got)) + } + if !strings.HasSuffix(got, "(truncated)") { + t.Fatalf("missing truncation marker: %q", got[len(got)-20:]) + } +}