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:
@@ -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)"
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user