7d69a1f320
Loosen AntiDrift's session controller into Keel's general collect→brain→act
loop. A new internal/harness runs at most one mode.Mode at a time, fanning
async completions out to the web SSE and status-bar surfaces.
- internal/mode: the Mode contract (Kind/Command/View/Active) plus optional
EvidenceConsumer and Expirer ports, and the surfacing Envelope.
- internal/mode/focus: the former session/domain/statemachine packages moved
under the mode, now satisfying the harness contracts unchanged.
- internal/mode/offscreen: a one-shot away-from-desk mode built on the new
ai.Proposer, which turns a life-domain brief into one off-screen action.
- cmd/keeld replaces cmd/antidriftd; daemon wires focus + offscreen factories
with per-mode persistence under ~/.keel/modes/<kind>.
- Finish the rename: KEEL_* env refs in the README, /keeld build artifact
ignored, stale antidriftd binary removed.
Include the design + implementation plan this refactor was built from under
docs/superpowers/{specs,plans}/2026-06-04-controller-refactor*.md.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
93 lines
2.4 KiB
Go
93 lines
2.4 KiB
Go
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)"
|
|
}
|