diff --git a/internal/knowledge/file.go b/internal/knowledge/file.go new file mode 100644 index 0000000..afaf5a7 --- /dev/null +++ b/internal/knowledge/file.go @@ -0,0 +1,92 @@ +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 ~/.antidrift/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 ~/.antidrift/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, ".antidrift", "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)" +} diff --git a/internal/knowledge/file_test.go b/internal/knowledge/file_test.go new file mode 100644 index 0000000..3d6f331 --- /dev/null +++ b/internal/knowledge/file_test.go @@ -0,0 +1,98 @@ +package knowledge + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestLoadPresentFile(t *testing.T) { + dir := t.TempDir() + p := filepath.Join(dir, "knowledge.md") + if err := os.WriteFile(p, []byte(" I am a focus-tool author.\n"), 0o644); err != nil { + t.Fatal(err) + } + got, err := NewFileSource(p).Load(context.Background(), "") + if err != nil { + t.Fatalf("Load: %v", err) + } + if got.Text != "I am a focus-tool author." { + t.Errorf("Text = %q (whitespace should be trimmed)", got.Text) + } + if got.Path != p { + t.Errorf("Path = %q, want %q", got.Path, p) + } +} + +func TestLoadAbsentFileIsNotError(t *testing.T) { + p := filepath.Join(t.TempDir(), "missing.md") + got, err := NewFileSource(p).Load(context.Background(), "") + if err != nil { + t.Fatalf("absent file must not error: %v", err) + } + if got.Text != "" { + t.Errorf("Text = %q, want empty for absent file", got.Text) + } + if got.Path != p { + t.Errorf("Path = %q, want %q even when absent", got.Path, p) + } +} + +func TestLoadExplicitPathBeatsDefault(t *testing.T) { + dir := t.TempDir() + def := filepath.Join(dir, "default.md") + other := filepath.Join(dir, "other.md") + if err := os.WriteFile(def, []byte("default"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(other, []byte("explicit"), 0o644); err != nil { + t.Fatal(err) + } + got, err := NewFileSource(def).Load(context.Background(), other) + if err != nil { + t.Fatalf("Load: %v", err) + } + if got.Text != "explicit" || got.Path != other { + t.Fatalf("explicit path ignored: %+v", got) + } +} + +func TestLoadTruncatesOversize(t *testing.T) { + p := filepath.Join(t.TempDir(), "big.md") + if err := os.WriteFile(p, []byte(strings.Repeat("a", maxProfileBytes+500)), 0o644); err != nil { + t.Fatal(err) + } + got, err := NewFileSource(p).Load(context.Background(), "") + if err != nil { + t.Fatalf("Load: %v", err) + } + if len(got.Text) > maxProfileBytes+len("\n…(truncated)") { + t.Errorf("Text not truncated: %d bytes", len(got.Text)) + } + if !strings.HasSuffix(got.Text, "(truncated)") { + t.Errorf("missing truncation marker: %q", got.Text[len(got.Text)-20:]) + } +} + +func TestLoadWhitespaceOnlyIsAbsent(t *testing.T) { + p := filepath.Join(t.TempDir(), "blank.md") + if err := os.WriteFile(p, []byte(" \n\t\n"), 0o644); err != nil { + t.Fatal(err) + } + got, err := NewFileSource(p).Load(context.Background(), "") + if err != nil { + t.Fatalf("Load: %v", err) + } + if got.Text != "" { + t.Errorf("whitespace-only should be empty, got %q", got.Text) + } +} + +func TestLoadReadErrorIsError(t *testing.T) { + dir := t.TempDir() // a directory is not readable as a file + if _, err := NewFileSource(dir).Load(context.Background(), ""); err == nil { + t.Fatal("reading a directory should error") + } +} diff --git a/internal/knowledge/knowledge.go b/internal/knowledge/knowledge.go new file mode 100644 index 0000000..4b57b26 --- /dev/null +++ b/internal/knowledge/knowledge.go @@ -0,0 +1,23 @@ +// Package knowledge is the Knowledge port: it answers "who am I; what are my +// priorities?" by loading the user's standing profile. It imports nothing from +// the rest of the app, so it stays a leaf package. +package knowledge + +import "context" + +// Profile is the user's standing context. Primitives only, so knowledge stays a +// leaf package. +type Profile struct { + Text string // grounding text; "" when no profile is available + Path string // resolved source location, for display +} + +// Source answers "who am I; what are my priorities?" — the user's standing +// profile that grounds the advisor. +type Source interface { + // Load returns the user's profile. path selects an explicit location; "" + // means the adapter's configured default. A missing source is NOT an error: + // it yields an empty-Text Profile so the caller degrades to ungrounded. Only + // a real read failure (permissions, unreadable) returns an error. + Load(ctx context.Context, path string) (Profile, error) +}