Add knowledge port and single-file adapter

The knowledge.Source port answers "who am I; what are my priorities?".
The FileSource adapter reads one profile file (path selectable per call),
treats a missing/blank file as absent (not an error), and caps the text.
Leaf package mirroring tasks.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-01 07:59:48 -04:00
parent 9e03add01b
commit 09061f8e30
3 changed files with 213 additions and 0 deletions
+92
View File
@@ -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)"
}