package knowledge import ( "context" "errors" "fmt" "io/fs" "os" "path/filepath" "strings" "unicode/utf8" ) // maxProfileBytes caps the grounding text so the coach prompt stays bounded. const maxProfileBytes = 6 * 1024 // FileSource reads the user's profile from a single file. It is stateless: the // selected path is passed to Load, so the controller can repoint it at runtime // without a mutable field. type FileSource struct { defaultPath string // used when Load is called with path == "" } // NewFileSource builds the adapter. defaultPath is used when Load receives an // empty path; if it too is empty, Load falls back to ~/.keel/knowledge.md. func NewFileSource(defaultPath string) *FileSource { return &FileSource{defaultPath: defaultPath} } // Load reads the profile at path (or the default). A missing file yields an // empty-Text Profile and no error; only a real read failure errors. The // resolved absolute path is always returned for display. func (s *FileSource) Load(ctx context.Context, path string) (Profile, error) { resolved := s.resolve(path) data, err := os.ReadFile(resolved) if errors.Is(err, fs.ErrNotExist) { return Profile{Path: resolved}, nil } if err != nil { return Profile{Path: resolved}, fmt.Errorf("knowledge: read %s: %w", resolved, err) } text := strings.TrimSpace(string(data)) if text == "" { return Profile{Path: resolved}, nil } return Profile{Text: truncate(text, maxProfileBytes), Path: resolved}, nil } // resolve picks path, else the default, else ~/.keel/knowledge.md; expands // a leading ~; and makes the result absolute for stable display. func (s *FileSource) resolve(path string) string { p := path if p == "" { p = s.defaultPath } if p == "" { if home, err := os.UserHomeDir(); err == nil { p = filepath.Join(home, ".keel", "knowledge.md") } } p = expandTilde(p) if abs, err := filepath.Abs(p); err == nil { return abs } return p } func expandTilde(p string) string { if p != "~" && !strings.HasPrefix(p, "~/") { return p } home, err := os.UserHomeDir() if err != nil { return p } if p == "~" { return home } return filepath.Join(home, p[2:]) } // truncate clips s to max bytes on a UTF-8 rune boundary and appends a marker. 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)" }