fix: correct context pruning order and vector alignment

- Prune context BEFORE processing (LSTM forget gate pattern)
  so LLM only sees relevant context, instead of pruning after the fact
- Fix ensureTrained() to recompute all event vectors after retraining
  vectorizer, fixing feature-space mismatch between stored vectors and
  query vector that made relevance scoring effectively random
- Add read lock to knowledge BuildTree() (data race fix)
- Log writeIndex() errors instead of discarding them
- Fix TOCTOU race in document ContextToDoc() dedup
- Fix healthcheck timing (measure elapsed before cleanup)
- Refactor waiter CLI into separate files (state, conn, config, editor,
  history, builtin) for maintainability
- Add CLI plugin API key authentication
This commit is contained in:
root
2026-07-07 17:07:38 +08:00
parent f794513b39
commit 2edc039351
13 changed files with 1139 additions and 602 deletions

70
cmd/waiter/builtin.go Normal file
View File

@ -0,0 +1,70 @@
package main
import (
"fmt"
"strings"
)
func handleBuiltin(cmd string, cfg *Config, state *State, reconnect func()) bool {
switch {
case cmd == "/help":
fmt.Println(`Built-in commands:
/help show this help
/exit, /quit exit waiter
/clear clear screen
/reconnect force reconnection
/connect <path> switch to a different unix socket
/remote <url> switch to remote HTTP mode
/local switch back to local socket mode
Server commands (sent to agent):
/status system status
/kernel kernel status
/settings [prefix] list settings
/settings set <k> <v> set a setting
/plugin list list installed plugins
/plugin install <url> install plugin
/plugin remove <name> remove plugin
/plugin info <name> plugin details
/memory query <text> query graph memory
/knowledge list knowledge base
/agents list agents
/chat <text> send to agent
Any other text is sent to the agent directly.`)
return true
case cmd == "/exit" || cmd == "/quit":
return true
case cmd == "/clear":
fmt.Print("\033[H\033[2J")
return true
case cmd == "/reconnect":
printlnC(colorYellow, "reconnecting...")
reconnect()
return true
case strings.HasPrefix(cmd, "/connect "):
cfg.Socket = strings.TrimSpace(cmd[9:])
cfg.Remote = ""
reconnect()
return true
case strings.HasPrefix(cmd, "/remote "):
cfg.Remote = strings.TrimSpace(cmd[8:])
cfg.Socket = ""
reconnect()
return true
case cmd == "/local":
cfg.Remote = ""
cfg.Socket = discoverSocket("")
reconnect()
return true
default:
return false
}
}

74
cmd/waiter/config.go Normal file
View File

@ -0,0 +1,74 @@
package main
import (
"fmt"
"os"
"path/filepath"
"gopkg.in/yaml.v3"
)
type Config struct {
Socket string `yaml:"socket"`
Remote string `yaml:"remote"`
APIKey string `yaml:"api_key"`
}
func discoverConfig(configPath string) *Config {
if configPath != "" {
if cfg := readFile(configPath); cfg != nil {
return cfg
}
}
candidates := configCandidates()
for _, p := range candidates {
if cfg := readFile(p); cfg != nil {
return cfg
}
}
return &Config{}
}
func configCandidates() []string {
var cands []string
home, _ := os.UserHomeDir()
if home != "" {
cands = append(cands, filepath.Join(home, ".config", "homeagent", "waiter.yaml"))
}
cands = append(cands, filepath.Join(".", "waiter.yaml"))
if exe, err := os.Executable(); err == nil {
cands = append(cands, filepath.Join(filepath.Dir(exe), "waiter.yaml"))
}
return cands
}
func readFile(path string) *Config {
data, err := os.ReadFile(path)
if err != nil {
return nil
}
var cfg Config
if err := yaml.Unmarshal(data, &cfg); err != nil {
fmt.Fprintf(os.Stderr, "warning: %s: %v\n", path, err)
return nil
}
return &cfg
}
func (c *Config) MergeCLI(socket, remote, apiKey string) {
if socket != "" {
c.Socket = socket
}
if remote != "" {
c.Remote = remote
}
if apiKey != "" {
c.APIKey = apiKey
}
}

141
cmd/waiter/conn.go Normal file
View File

@ -0,0 +1,141 @@
package main
import (
"bufio"
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"strings"
"sync"
"time"
)
type Conn interface {
Send(line string) error
ReadLine() (string, error)
Close() error
}
func dial(cfg *Config) (Conn, error) {
if cfg.Remote != "" {
return dialRemote(cfg.Remote, cfg.APIKey)
}
return dialLocal(cfg.Socket, cfg.APIKey)
}
func dialLocal(socket, apiKey string) (Conn, error) {
if socket == "" {
socket = discoverSocket("")
}
c, err := net.DialTimeout("unix", socket, 5*time.Second)
if err != nil {
return nil, fmt.Errorf("unix %s: %w", socket, err)
}
lc := &localConn{conn: c, r: bufio.NewReader(c)}
if apiKey != "" {
if err := lc.Send("/auth " + apiKey); err != nil {
c.Close()
return nil, fmt.Errorf("auth send: %w", err)
}
line, err := lc.ReadLine()
if err != nil {
c.Close()
return nil, fmt.Errorf("auth read: %w", err)
}
if strings.Contains(line, "unauthorized") {
c.Close()
return nil, fmt.Errorf("auth rejected: %s", line)
}
}
return lc, nil
}
type localConn struct {
conn net.Conn
r *bufio.Reader
}
func (c *localConn) Send(line string) error {
_, err := fmt.Fprintf(c.conn, "%s\n", line)
return err
}
func (c *localConn) ReadLine() (string, error) {
s, err := c.r.ReadString('\n')
if err != nil {
return "", err
}
return strings.TrimSuffix(s, "\n"), nil
}
func (c *localConn) Close() error {
return c.conn.Close()
}
func dialRemote(baseURL, apiKey string) (Conn, error) {
baseURL = strings.TrimRight(baseURL, "/")
return &remoteConn{url: baseURL + "/api/v1/chat", apiKey: apiKey}, nil
}
type remoteConn struct {
url string
apiKey string
mu sync.Mutex
buf []string
closed bool
}
func (c *remoteConn) Send(line string) error {
req, err := http.NewRequest("POST", c.url, strings.NewReader(
fmt.Sprintf(`{"message":%q}`, line),
))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
if c.apiKey != "" {
req.Header.Set("X-API-Key", c.apiKey)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
var result map[string]interface{}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return err
}
content, _ := result["response"].(string)
c.mu.Lock()
c.buf = append(c.buf, content)
c.mu.Unlock()
return nil
}
func (c *remoteConn) ReadLine() (string, error) {
c.mu.Lock()
defer c.mu.Unlock()
for len(c.buf) == 0 && !c.closed {
c.mu.Unlock()
time.Sleep(50 * time.Millisecond)
c.mu.Lock()
}
if c.closed && len(c.buf) == 0 {
return "", io.EOF
}
s := c.buf[0]
c.buf = c.buf[1:]
return s, nil
}
func (c *remoteConn) Close() error {
c.mu.Lock()
c.closed = true
c.mu.Unlock()
return nil
}

188
cmd/waiter/editor.go Normal file
View File

@ -0,0 +1,188 @@
package main
import (
"bufio"
"fmt"
"io"
"os"
"strings"
)
type LineEditor struct {
buf []rune
pos int
hist *History
histI int
pending string
}
func newLineEditor(h *History) *LineEditor {
return &LineEditor{hist: h, histI: -1}
}
func (e *LineEditor) clear() {
e.buf = e.buf[:0]
e.pos = 0
e.histI = -1
}
func (e *LineEditor) redrawPending(text string) {
e.pending = text
}
func (e *LineEditor) read() (string, error) {
if e.pending != "" {
t := e.pending
e.pending = ""
return t, nil
}
e.buf = e.buf[:0]
e.pos = 0
e.histI = -1
in := bufio.NewReader(os.Stdin)
for {
b := make([]byte, 1)
_, err := in.Read(b)
if err != nil {
return "", err
}
switch b[0] {
case '\r', '\n':
fmt.Print("\n")
return string(e.buf), nil
case 0x03:
fmt.Print("^C\n")
os.Exit(130)
return "", nil
case 0x04:
if len(e.buf) == 0 {
return "", io.EOF
}
continue
case 0x08, 0x7f:
if e.pos > 0 {
e.pos--
e.buf = append(e.buf[:e.pos], e.buf[e.pos+1:]...)
e.redraw()
}
case 0x1b:
seq := make([]byte, 2)
if _, err := io.ReadFull(in, seq); err != nil {
continue
}
if seq[0] != '[' {
continue
}
switch seq[1] {
case 'A':
e.historyPrev()
case 'B':
e.historyNext()
case 'C':
if e.pos < len(e.buf) {
e.pos++
e.redraw()
}
case 'D':
if e.pos > 0 {
e.pos--
e.redraw()
}
case 'H', '1':
if seq[1] == '1' {
io.ReadFull(in, make([]byte, 1))
}
e.pos = 0
e.redraw()
case 'F', '4':
if seq[1] == '4' {
io.ReadFull(in, make([]byte, 1))
}
e.pos = len(e.buf)
e.redraw()
case '3':
io.ReadFull(in, make([]byte, 1))
if e.pos < len(e.buf) {
e.buf = append(e.buf[:e.pos], e.buf[e.pos+1:]...)
e.redraw()
}
}
case '\t':
e.doCompletion()
default:
if b[0] >= 0x20 {
e.buf = append(e.buf, 0)
copy(e.buf[e.pos+1:], e.buf[e.pos:])
e.buf[e.pos] = rune(b[0])
e.pos++
e.redraw()
}
}
}
}
func (e *LineEditor) historyPrev() {
all := e.hist.all()
if len(all) == 0 {
return
}
if e.histI == -1 {
e.histI = len(all) - 1
} else if e.histI > 0 {
e.histI--
}
e.buf = []rune(all[e.histI])
e.pos = len(e.buf)
e.redraw()
}
func (e *LineEditor) historyNext() {
if e.histI == -1 {
return
}
all := e.hist.all()
e.histI++
if e.histI >= len(all) {
e.histI = -1
e.buf = e.buf[:0]
e.pos = 0
} else {
e.buf = []rune(all[e.histI])
e.pos = len(e.buf)
}
e.redraw()
}
func (e *LineEditor) doCompletion() {
cmds := []string{"/help", "/exit", "/quit", "/clear", "/reconnect", "/connect ", "/remote ", "/local",
"/status", "/kernel", "/settings ", "/settings set ", "/chat ",
"/plugin ", "/plugin list", "/plugin install ", "/plugin remove ", "/plugin info ",
"/memory ", "/memory query ", "/knowledge", "/agents"}
prefix := string(e.buf)
for _, c := range cmds {
if strings.HasPrefix(c, prefix) && c != prefix {
e.buf = []rune(c)
e.pos = len(e.buf)
e.redraw()
return
}
}
}
func (e *LineEditor) redraw() {
fmt.Print("\r\033[K")
fmt.Print(string(e.buf))
if e.pos < len(e.buf) {
skip := len(e.buf) - e.pos
fmt.Printf("\033[%dD", skip)
}
}

58
cmd/waiter/history.go Normal file
View File

@ -0,0 +1,58 @@
package main
import (
"os"
"strings"
"sync"
)
type History struct {
path string
lines []string
max int
mu sync.Mutex
}
func newHistory(path string, max int) History {
return History{path: path, max: max}
}
func (h *History) load() {
h.mu.Lock()
defer h.mu.Unlock()
data, err := os.ReadFile(h.path)
if err != nil {
return
}
h.lines = strings.Split(strings.TrimSpace(string(data)), "\n")
if len(h.lines) > h.max {
h.lines = h.lines[len(h.lines)-h.max:]
}
}
func (h *History) save() {
h.mu.Lock()
defer h.mu.Unlock()
data := strings.Join(h.lines, "\n") + "\n"
os.WriteFile(h.path, []byte(data), 0644)
}
func (h *History) add(line string) {
h.mu.Lock()
defer h.mu.Unlock()
if len(h.lines) > 0 && h.lines[len(h.lines)-1] == line {
return
}
h.lines = append(h.lines, line)
if len(h.lines) > h.max {
h.lines = h.lines[len(h.lines)-h.max:]
}
}
func (h *History) all() []string {
h.mu.Lock()
defer h.mu.Unlock()
r := make([]string, len(h.lines))
copy(r, h.lines)
return r
}

View File

@ -1,18 +1,14 @@
package main
import (
"bufio"
"context"
"encoding/json"
"flag"
"fmt"
"io"
"net"
"net/http"
"os"
"os/signal"
"path/filepath"
"strings"
"sync"
"syscall"
"time"
"unsafe"
@ -27,6 +23,24 @@ const (
colorDim = "\033[2m"
)
const clearLine = "\033[2K\r"
var colors = true
func init() {
if os.Getenv("NO_COLOR") != "" {
colors = false
}
}
func printlnC(color, msg string) {
if !colors {
fmt.Println(msg)
return
}
fmt.Printf("%s%s%s\n", color, msg, colorReset)
}
func historyPath() string {
home, _ := os.UserHomeDir()
xdgData := os.Getenv("XDG_DATA_HOME")
@ -61,110 +75,51 @@ func discoverSocket(configured string) string {
return candidates[0]
}
type respLine struct {
Type string `json:"type"`
Content string `json:"content"`
Error string `json:"error"`
}
var colors = true
func init() {
if os.Getenv("NO_COLOR") != "" {
colors = false
}
}
func printlnC(color, msg string) {
if !colors {
fmt.Println(msg)
return
}
fmt.Printf("%s%s%s\n", color, msg, colorReset)
}
func main() {
socket := flag.String("socket", "", "unix socket path")
remote := flag.String("remote", "", "remote webui URL (e.g. http://127.0.0.1:8080)")
remote := flag.String("remote", "", "remote webui URL")
apiKey := flag.String("api-key", "", "API key for remote mode")
configPath := flag.String("config", "", "config file path")
chat := flag.String("chat", "", "send a message and print final text (one-shot)")
say := flag.String("say", "", "deprecated alias of -chat")
flag.Parse()
sockAddr := discoverSocket(*socket)
mode := "local"
addr := sockAddr
if *remote != "" {
mode = "remote"
addr = *remote
} else if *socket != "" {
mode = "local"
addr = *socket
cfg := discoverConfig(*configPath)
cfg.MergeCLI(*socket, *remote, *apiKey)
if cfg.Socket == "" && cfg.Remote == "" {
cfg.Socket = discoverSocket("")
}
oneShotMsg := *chat
if oneShotMsg == "" {
oneShotMsg = *say
}
if oneShotMsg != "" {
oneShot(mode, addr, oneShotMsg)
return
}
runInteractive(mode, addr)
}
func oneShot(mode, addr, message string) {
if mode == "remote" {
resp, err := doRemoteOnce(addr, message)
if err != nil {
printlnC(colorRed, fmt.Sprintf("error: %v", err))
os.Exit(1)
}
fmt.Println(resp)
return
}
conn, err := net.DialTimeout("unix", addr, 5*time.Second)
if err != nil {
printlnC(colorRed, fmt.Sprintf("connect to %s: %v", addr, err))
state := &State{}
if err := state.Connect(cfg); err != nil {
printlnC(colorRed, fmt.Sprintf("connect: %v", err))
os.Exit(1)
}
defer conn.Close()
fmt.Fprintf(conn, "%s\n", message)
scanner := bufio.NewScanner(conn)
if scanner.Scan() {
var rl respLine
if err := json.Unmarshal(scanner.Bytes(), &rl); err != nil {
fmt.Println(scanner.Text())
return
}
switch rl.Type {
case "response":
fmt.Println(rl.Content)
case "error":
printlnC(colorRed, fmt.Sprintf("error: %s", rl.Error))
os.Exit(1)
default:
fmt.Println(scanner.Text())
}
defer state.Disconnect()
if oneShotMsg != "" {
oneshot(state, oneShotMsg)
return
}
runInteractive(state, cfg)
}
func doRemoteOnce(baseURL, message string) (string, error) {
baseURL = strings.TrimRight(baseURL, "/")
body := fmt.Sprintf(`{"message":%q}`, message)
resp, err := http.Post(baseURL+"/api/v1/chat", "application/json", strings.NewReader(body))
func oneshot(state *State, msg string) {
resp, err := state.SendChat(msg)
if err != nil {
return "", err
printlnC(colorRed, fmt.Sprintf("error: %v", err))
os.Exit(1)
}
defer resp.Body.Close()
var result map[string]interface{}
json.NewDecoder(resp.Body).Decode(&result)
content, _ := result["response"].(string)
return content, nil
fmt.Println(resp)
}
const clearLine = "\033[2K\r"
func runInteractive(mode, addr string) {
func runInteractive(state *State, cfg *Config) {
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
@ -179,63 +134,47 @@ func runInteractive(mode, addr string) {
}
defer restore()
modeLabel := "local"
addrLabel := cfg.Socket
if cfg.Remote != "" {
modeLabel = "remote"
addrLabel = cfg.Remote
}
if colors {
fmt.Printf("%sHomeAgent CLI%s %s(%s://%s)%s\n", colorBold, colorReset, colorDim, mode, addr, colorReset)
fmt.Printf("%sHomeAgent CLI%s %s(%s://%s)%s\n", colorBold, colorReset, colorDim, modeLabel, addrLabel, colorReset)
} else {
fmt.Printf("HomeAgent CLI (%s://%s)\n", mode, addr)
fmt.Printf("HomeAgent CLI (%s://%s)\n", modeLabel, addrLabel)
}
fmt.Println("Type /help for commands.")
var conn io.ReadWriteCloser
var readerDone chan struct{}
connMu := &sync.Mutex{}
connect := func() error {
connMu.Lock()
defer connMu.Unlock()
if conn != nil {
conn.Close()
}
if readerDone != nil {
<-readerDone
}
c, err := dial(mode, addr)
if err != nil {
return err
}
conn = c
readerDone = make(chan struct{})
go readLoop(conn, readerDone)
return nil
var readerCancel func()
startReader := func() {
ctx, cancel := context.WithCancel(context.Background())
readerCancel = cancel
go state.ReadLoop(ctx, printServerOutput)
}
startReader()
reconnect := func() {
if readerCancel != nil {
readerCancel()
}
state.Disconnect()
for i := 0; i < 30; i++ {
if err := connect(); err != nil {
if err := state.Connect(cfg); err != nil {
printlnC(colorYellow, fmt.Sprintf("reconnecting (%d/30): %v", i+1, err))
time.Sleep(2 * time.Second)
continue
}
printlnC(colorGreen, "reconnected")
startReader()
return
}
printlnC(colorRed, "giving up after 30 attempts")
}
// initial connect
loop:
for {
if err := connect(); err != nil {
printlnC(colorYellow, fmt.Sprintf("connect: %v, retrying in 2s...", err))
time.Sleep(2 * time.Second)
continue
}
break
}
inputMu := &sync.Mutex{}
for {
fmt.Print("waiter> ")
text, err := line.read()
if err != nil {
break
@ -248,7 +187,10 @@ func runInteractive(mode, addr string) {
}
if cmd[0] == '/' {
if handleBuiltin(cmd, &mode, &addr, reconnect) {
if handleBuiltin(cmd, cfg, state, reconnect) {
if cmd == "/exit" || cmd == "/quit" {
break loop
}
continue
}
}
@ -256,424 +198,45 @@ func runInteractive(mode, addr string) {
history.add(cmd)
history.save()
connMu.Lock()
c := conn
connMu.Unlock()
if c == nil {
printlnC(colorYellow, "not connected, reconnecting...")
reconnect()
connMu.Lock()
c = conn
connMu.Unlock()
}
_, err = fmt.Fprintf(c, "%s\n", cmd)
if err != nil {
if err := state.Send(cmd); err != nil {
printlnC(colorYellow, "connection lost, reconnecting...")
line.redrawPending(cmd)
reconnect()
connMu.Lock()
c = conn
connMu.Unlock()
if c != nil {
fmt.Fprintf(c, "%s\n", cmd)
}
state.Send(cmd)
}
select {
case <-sigCh:
goto exit
break loop
default:
}
_ = inputMu
}
exit:
connMu.Lock()
if conn != nil {
conn.Close()
}
connMu.Unlock()
if readerDone != nil {
<-readerDone
if readerCancel != nil {
readerCancel()
}
}
func dial(mode, addr string) (io.ReadWriteCloser, error) {
if mode == "remote" {
return newHTTPConn(strings.TrimRight(addr, "/")), nil
func printServerOutput(content string) {
if !colors {
fmt.Printf("%s%s\n", clearLine, content)
return
}
return net.DialTimeout("unix", addr, 5*time.Second)
}
func readLoop(conn io.ReadWriteCloser, done chan struct{}) {
defer close(done)
scanner := bufio.NewScanner(conn)
for scanner.Scan() {
if !colors {
fmt.Printf("%s%s\n", clearLine, scanner.Text())
continue
}
var rl respLine
if err := json.Unmarshal(scanner.Bytes(), &rl); err != nil {
fmt.Printf("%s%s%s\n", clearLine, scanner.Text(), colorReset)
continue
}
switch rl.Type {
case "response":
fmt.Printf("%s%s%s%s\n", clearLine, colorGreen, rl.Content, colorReset)
case "error":
fmt.Printf("%s%s%s%s\n", clearLine, colorRed, rl.Error, colorReset)
default:
fmt.Printf("%s%s%s\n", clearLine, scanner.Text(), colorReset)
}
var rl respLine
if err := json.Unmarshal([]byte(content), &rl); err != nil {
fmt.Printf("%s%s%s\n", clearLine, content, colorReset)
return
}
}
// httpConn wraps an HTTP endpoint as a read/write/closer for chat.
type httpConn struct {
url string
mu sync.Mutex
buf []byte
closed bool
}
func newHTTPConn(baseURL string) *httpConn {
return &httpConn{url: baseURL + "/api/v1/chat"}
}
func (c *httpConn) Read(p []byte) (int, error) {
c.mu.Lock()
defer c.mu.Unlock()
for len(c.buf) == 0 && !c.closed {
c.mu.Unlock()
time.Sleep(100 * time.Millisecond)
c.mu.Lock()
}
if c.closed && len(c.buf) == 0 {
return 0, io.EOF
}
n := copy(p, c.buf)
c.buf = c.buf[n:]
return n, nil
}
func (c *httpConn) Write(p []byte) (int, error) {
msg := strings.TrimSpace(string(p))
resp, err := doRemoteOnce(c.url, msg)
if err != nil {
return 0, err
}
data, _ := json.Marshal(respLine{Type: "response", Content: resp})
data = append(data, '\n')
c.mu.Lock()
c.buf = append(c.buf, data...)
c.mu.Unlock()
return len(p), nil
}
func (c *httpConn) Close() error {
c.mu.Lock()
c.closed = true
c.mu.Unlock()
return nil
}
func handleBuiltin(cmd string, mode, addr *string, reconnect func()) bool {
switch {
case cmd == "/help":
fmt.Println(`Built-in commands:
/help show this help
/exit, /quit exit waiter
/clear clear screen
/reconnect force reconnection
/connect <path> switch to a different unix socket
/remote <url> switch to remote HTTP mode
/local switch back to local socket mode
Structured commands (processed server-side):
/status system status
/kernel kernel status
/settings [prefix] list settings
/settings set <k> <v> set a setting
/plugin list list installed plugins
/plugin install <url> install plugin
/plugin remove <name> remove plugin
/plugin info <name> plugin details
/memory query <text> query graph memory
/knowledge list knowledge base
/agents list agents
/chat <text> send to agent
Any other text is sent to the agent.`)
return true
case cmd == "/exit" || cmd == "/quit":
os.Exit(0)
return true
case cmd == "/clear":
fmt.Print("\033[H\033[2J")
return true
case cmd == "/reconnect":
printlnC(colorYellow, "reconnecting...")
reconnect()
return true
case strings.HasPrefix(cmd, "/connect "):
*mode = "local"
*addr = strings.TrimSpace(cmd[9:])
reconnect()
return true
case strings.HasPrefix(cmd, "/remote "):
*mode = "remote"
*addr = strings.TrimSpace(cmd[8:])
reconnect()
return true
case cmd == "/local":
*mode = "local"
*addr = discoverSocket("")
reconnect()
return true
switch rl.Type {
case "response":
fmt.Printf("%s%s%s%s\n", clearLine, colorGreen, rl.Content, colorReset)
case "error":
fmt.Printf("%s%s%s%s\n", clearLine, colorRed, rl.Error, colorReset)
default:
return false
fmt.Printf("%s%s%s\n", clearLine, content, colorReset)
}
}
type LineEditor struct {
buf []rune
pos int
hist *History
histI int
pending string
}
func newLineEditor(h *History) *LineEditor {
return &LineEditor{hist: h, histI: -1}
}
func (e *LineEditor) clear() {
e.buf = e.buf[:0]
e.pos = 0
e.histI = -1
}
func (e *LineEditor) redrawPending(text string) {
e.pending = text
}
func (e *LineEditor) read() (string, error) {
if e.pending != "" {
t := e.pending
e.pending = ""
return t, nil
}
e.buf = e.buf[:0]
e.pos = 0
e.histI = -1
in := bufio.NewReader(os.Stdin)
for {
b := make([]byte, 1)
_, err := in.Read(b)
if err != nil {
return "", err
}
switch b[0] {
case '\r', '\n':
fmt.Print("\n")
return string(e.buf), nil
case 0x03: // Ctrl+C
fmt.Print("^C\n")
os.Exit(130)
return "", nil
case 0x04: // Ctrl+D
if len(e.buf) == 0 {
return "", io.EOF
}
continue
case 0x08, 0x7f: // Backspace
if e.pos > 0 {
e.pos--
e.buf = append(e.buf[:e.pos], e.buf[e.pos+1:]...)
e.redraw()
}
case 0x1b: // Escape sequence
seq := make([]byte, 2)
if _, err := io.ReadFull(in, seq); err != nil {
continue
}
if seq[0] != '[' {
continue
}
switch seq[1] {
case 'A':
e.historyPrev()
case 'B':
e.historyNext()
case 'C':
if e.pos < len(e.buf) {
e.pos++
e.redraw()
}
case 'D':
if e.pos > 0 {
e.pos--
e.redraw()
}
case 'H', '1':
if seq[1] == '1' {
io.ReadFull(in, make([]byte, 1))
}
e.pos = 0
e.redraw()
case 'F', '4':
if seq[1] == '4' {
io.ReadFull(in, make([]byte, 1))
}
e.pos = len(e.buf)
e.redraw()
case '3':
io.ReadFull(in, make([]byte, 1))
if e.pos < len(e.buf) {
e.buf = append(e.buf[:e.pos], e.buf[e.pos+1:]...)
e.redraw()
}
}
case '\t':
e.doCompletion()
default:
if b[0] >= 0x20 {
e.buf = append(e.buf, 0)
copy(e.buf[e.pos+1:], e.buf[e.pos:])
e.buf[e.pos] = rune(b[0])
e.pos++
e.redraw()
}
}
}
}
func (e *LineEditor) historyPrev() {
all := e.hist.all()
if len(all) == 0 {
return
}
if e.histI == -1 {
e.histI = len(all) - 1
} else if e.histI > 0 {
e.histI--
}
e.buf = []rune(all[e.histI])
e.pos = len(e.buf)
e.redraw()
}
func (e *LineEditor) historyNext() {
if e.histI == -1 {
return
}
all := e.hist.all()
e.histI++
if e.histI >= len(all) {
e.histI = -1
e.buf = e.buf[:0]
e.pos = 0
} else {
e.buf = []rune(all[e.histI])
e.pos = len(e.buf)
}
e.redraw()
}
func (e *LineEditor) doCompletion() {
cmds := []string{"/help", "/exit", "/quit", "/clear", "/reconnect", "/connect ", "/remote ", "/local",
"/status", "/kernel", "/settings ", "/settings set ", "/chat ",
"/plugin ", "/plugin list", "/plugin install ", "/plugin remove ", "/plugin info ",
"/memory ", "/memory query ", "/knowledge", "/agents"}
prefix := string(e.buf)
for _, c := range cmds {
if strings.HasPrefix(c, prefix) && c != prefix {
e.buf = []rune(c)
e.pos = len(e.buf)
e.redraw()
return
}
}
}
func (e *LineEditor) redraw() {
fmt.Print("\r\033[K")
fmt.Print(string(e.buf))
if e.pos < len(e.buf) {
skip := len(e.buf) - e.pos
fmt.Printf("\033[%dD", skip)
}
}
type History struct {
path string
lines []string
max int
mu sync.Mutex
}
func newHistory(path string, max int) History {
return History{path: path, max: max}
}
func (h *History) load() {
h.mu.Lock()
defer h.mu.Unlock()
data, err := os.ReadFile(h.path)
if err != nil {
return
}
h.lines = strings.Split(strings.TrimSpace(string(data)), "\n")
if len(h.lines) > h.max {
h.lines = h.lines[len(h.lines)-h.max:]
}
}
func (h *History) save() {
h.mu.Lock()
defer h.mu.Unlock()
data := strings.Join(h.lines, "\n") + "\n"
os.WriteFile(h.path, []byte(data), 0644)
}
func (h *History) add(line string) {
h.mu.Lock()
defer h.mu.Unlock()
if len(h.lines) > 0 && h.lines[len(h.lines)-1] == line {
return
}
h.lines = append(h.lines, line)
if len(h.lines) > h.max {
h.lines = h.lines[len(h.lines)-h.max:]
}
}
func (h *History) all() []string {
h.mu.Lock()
defer h.mu.Unlock()
r := make([]string, len(h.lines))
copy(r, h.lines)
return r
}
func setRawMode(fd int) (func(), error) {
if fd == 0 {
fd = int(os.Stdin.Fd())

132
cmd/waiter/state.go Normal file
View File

@ -0,0 +1,132 @@
package main
import (
"context"
"encoding/json"
"fmt"
"sync"
)
type State struct {
conn Conn
mu sync.Mutex
}
func (s *State) Connect(cfg *Config) error {
s.mu.Lock()
defer s.mu.Unlock()
if s.conn != nil {
s.conn.Close()
}
c, err := dial(cfg)
if err != nil {
return err
}
s.conn = c
return nil
}
func (s *State) Disconnect() {
s.mu.Lock()
defer s.mu.Unlock()
if s.conn != nil {
s.conn.Close()
s.conn = nil
}
}
func (s *State) Connected() bool {
s.mu.Lock()
defer s.mu.Unlock()
return s.conn != nil
}
func (s *State) Send(line string) error {
s.mu.Lock()
c := s.conn
s.mu.Unlock()
if c == nil {
return fmt.Errorf("not connected")
}
return c.Send(line)
}
func (s *State) SendChat(msg string) (string, error) {
if err := s.Send(msg); err != nil {
return "", err
}
line, err := s.readLine()
if err != nil {
return "", err
}
return parseRespLine(line)
}
func (s *State) SendBuiltin(cmd string) (string, error) {
if err := s.Send(cmd); err != nil {
return "", err
}
line, err := s.readLine()
if err != nil {
return "", err
}
return parseRespLine(line)
}
func (s *State) readLine() (string, error) {
s.mu.Lock()
c := s.conn
s.mu.Unlock()
if c == nil {
return "", fmt.Errorf("not connected")
}
return c.ReadLine()
}
type respLine struct {
Type string `json:"type"`
Content string `json:"content"`
Error string `json:"error"`
}
func parseRespLine(line string) (string, error) {
var rl respLine
if err := json.Unmarshal([]byte(line), &rl); err != nil {
return line, nil
}
switch rl.Type {
case "response":
return rl.Content, nil
case "error":
return "", fmt.Errorf("%s", rl.Error)
default:
return line, nil
}
}
func (s *State) ReadLoop(ctx context.Context, cb func(string)) {
for {
s.mu.Lock()
c := s.conn
s.mu.Unlock()
if c == nil {
return
}
done := make(chan struct{})
var line string
var readErr error
go func() {
line, readErr = c.ReadLine()
close(done)
}()
select {
case <-done:
if readErr != nil {
return
}
cb(line)
case <-ctx.Done():
return
}
}
}