diff --git a/cmd/waiter/builtin.go b/cmd/waiter/builtin.go new file mode 100644 index 0000000..d091d82 --- /dev/null +++ b/cmd/waiter/builtin.go @@ -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 switch to a different unix socket + /remote 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 set a setting + /plugin list list installed plugins + /plugin install install plugin + /plugin remove remove plugin + /plugin info plugin details + /memory query query graph memory + /knowledge list knowledge base + /agents list agents + /chat 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 + } +} diff --git a/cmd/waiter/config.go b/cmd/waiter/config.go new file mode 100644 index 0000000..0f612fa --- /dev/null +++ b/cmd/waiter/config.go @@ -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 + } +} diff --git a/cmd/waiter/conn.go b/cmd/waiter/conn.go new file mode 100644 index 0000000..61bc1d6 --- /dev/null +++ b/cmd/waiter/conn.go @@ -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 +} diff --git a/cmd/waiter/editor.go b/cmd/waiter/editor.go new file mode 100644 index 0000000..ff51c94 --- /dev/null +++ b/cmd/waiter/editor.go @@ -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) + } +} diff --git a/cmd/waiter/history.go b/cmd/waiter/history.go new file mode 100644 index 0000000..f2cd279 --- /dev/null +++ b/cmd/waiter/history.go @@ -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 +} diff --git a/cmd/waiter/main.go b/cmd/waiter/main.go index b090cdb..dde5725 100644 --- a/cmd/waiter/main.go +++ b/cmd/waiter/main.go @@ -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 switch to a different unix socket - /remote 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 set a setting - /plugin list list installed plugins - /plugin install install plugin - /plugin remove remove plugin - /plugin info plugin details - /memory query query graph memory - /knowledge list knowledge base - /agents list agents - /chat 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()) diff --git a/cmd/waiter/state.go b/cmd/waiter/state.go new file mode 100644 index 0000000..c3a8671 --- /dev/null +++ b/cmd/waiter/state.go @@ -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 + } + } +} diff --git a/internal/agent/core/agent.go b/internal/agent/core/agent.go index 3f647c4..f3eb67a 100644 --- a/internal/agent/core/agent.go +++ b/internal/agent/core/agent.go @@ -2,6 +2,7 @@ package core import ( "context" + "errors" "fmt" "log" "strings" @@ -251,16 +252,27 @@ func (a *Agent) interceptLoop() { a.llmMu.Unlock() if hasActiveLLM { - select { - case a.interceptCh <- clone: - default: - log.Printf("[agent] intercept channel full, queuing input for %s", evt.Source) + // 后台整理任务被打断:中断消息重新注入为独立输入(consolidation 的 process 不会路由回复) + if a.currentOutputChannel == "_consolidation_" { + log.Printf("[agent] consolidation interrupted, re-injecting input for %s/%s", evt.Source, evt.OutputChannel) a.io.InjectInputTo(evt.Source, evt.OutputChannel, "text", map[string]interface{}{ "content": text, "interrupt": true, "interrupt_source": evt.Source, "interrupt_channel": evt.OutputChannel, }) + } else { + select { + case a.interceptCh <- clone: + default: + log.Printf("[agent] intercept channel full, queuing input for %s", evt.Source) + a.io.InjectInputTo(evt.Source, evt.OutputChannel, "text", map[string]interface{}{ + "content": text, + "interrupt": true, + "interrupt_source": evt.Source, + "interrupt_channel": evt.OutputChannel, + }) + } } } else { a.io.InjectInputTo(evt.Source, evt.OutputChannel, "text", map[string]interface{}{ @@ -325,6 +337,12 @@ func (a *Agent) processMediaInput(evt *agentIO.InputEvent) { blocks, fallback := a.mediaToBlocks(evt.Payload, evt.Type, evt.Source) + // 先遗忘再输入 + archived := a.context.Prune(fallback, a.maxContextSize-1, a.docStore) + if archived > 0 { + log.Printf("[agent] pruned %d low-relevance events to document memory", archived) + } + a.context.Append(ContextEvent{ Timestamp: start, Source: evt.Source, @@ -371,11 +389,6 @@ func (a *Agent) processMediaInput(evt *agentIO.InputEvent) { ToolsUsed: toolsUsed, }) - archived := a.context.Prune(response, a.maxContextSize, a.docStore) - if archived > 0 { - log.Printf("[agent] pruned %d low-relevance events to document memory", archived) - } - a.emitResponse(evt, response) } @@ -466,6 +479,12 @@ func (a *Agent) processTextInput(evt *agentIO.InputEvent, input string) { input = stageCtx.RawMessage + // 先"遗忘"再输入:用当前输入决定淘汰哪些不相关旧事件(LSTM forget gate 模式) + archived := a.context.Prune(input, a.maxContextSize-1, a.docStore) + if archived > 0 { + log.Printf("[agent] pruned %d low-relevance events to document memory", archived) + } + a.context.Append(ContextEvent{ Timestamp: start, Source: evt.Source, @@ -492,12 +511,6 @@ func (a *Agent) processTextInput(evt *agentIO.InputEvent, input string) { ToolsUsed: toolsUsed, }) - // 基于相关性裁剪上下文:保留与当前输入最相关的 maxContextSize 条 - archived := a.context.Prune(response, a.maxContextSize, a.docStore) - if archived > 0 { - log.Printf("[agent] pruned %d low-relevance events to document memory", archived) - } - a.emitResponse(evt, response) if !stageCtx.NoMemory { @@ -661,6 +674,14 @@ func (a *Agent) process(input string, stageCtx *sdk.StageContext) (response stri } if llmErr != nil { + if errors.Is(llmErr, context.Canceled) && a.ctx.Err() == nil { + // 后台整理任务被打断:中断已重新注入为独立输入,直接返回 + if a.currentOutputChannel == "_consolidation_" { + return "", toolsUsed, fmt.Errorf("interrupted by user input") + } + // 用户对话被打断:继续下一轮 drain 打断消息,注入到当前对话上下文 + continue + } return "", toolsUsed, fmt.Errorf("all %d providers failed, last error: %w", len(providers), llmErr) } @@ -1159,7 +1180,11 @@ func (a *Agent) executeKnowledgeTool(tc agentAPI.ToolCall) string { if i >= topK { break } - parts = append(parts, fmt.Sprintf("[%s]\n%s", k.Name, truncateStr(k.Content, 200))) + label := k.Name + if k.Category != "" { + label = k.Category + "/" + k.Name + } + parts = append(parts, fmt.Sprintf("[%s]\n%s", label, truncateStr(k.Content, 200))) } return strings.Join(parts, "\n---\n") @@ -1175,11 +1200,8 @@ func (a *Agent) executeKnowledgeTool(tc agentAPI.ToolCall) string { return fmt.Sprintf("知识「%s」已创建并向量化索引(%d 字符)", name, len(content)) case "knowledge_list": - names := a.knowledge.List() - if len(names) == 0 { - return "知识库为空" - } - return "知识分类: " + strings.Join(names, ", ") + tree := a.knowledge.BuildTree() + return formatTree(tree, 0) default: return fmt.Sprintf("未知的知识工具: %s", tc.Name) @@ -1802,9 +1824,14 @@ func (a *Agent) distillContext() { if a.docStore == nil { return } - // 心跳时执行一次安全裁剪(兜底) - // 上下文的主要裁剪在 processTextInput 中基于相关性执行 - _ = a.context.Len() + // 心跳时执行安全裁剪:上下文超过 maxContextSize*2 时强制归档 + n := a.context.Len() + if n > a.maxContextSize*2 { + archived := a.context.Prune("", a.maxContextSize, a.docStore) + if archived > 0 { + log.Printf("[agent] distill: pruned %d low-relevance events to document memory (total=%d)", archived, n) + } + } } // syncGraphToDocs — 将图记忆的实体和关系注入文档记忆层 @@ -2104,6 +2131,13 @@ func (a *Agent) emitMemoryCandidate(source, input, response string, toolsUsed [] func (a *Agent) processConsolidation(input string) { start := time.Now() a.currentOutputChannel = "_consolidation_" + + // 遗忘不相关的旧事件 + archived := a.context.Prune(input, a.maxContextSize-1, a.docStore) + if archived > 0 { + log.Printf("[agent] consolidation: pruned %d low-relevance events", archived) + } + a.context.Append(ContextEvent{ Timestamp: start, Source: "system", @@ -2121,7 +2155,6 @@ func (a *Agent) processConsolidation(input string) { Response: response, ToolsUsed: toolsUsed, }) - _ = a.context.Prune(response, a.maxContextSize, a.docStore) a.emitMemoryCandidate("system", input, response, toolsUsed) log.Printf("[agent] consolidation done (%dms, tools=%v)", time.Since(start).Milliseconds(), toolsUsed) } @@ -2585,6 +2618,45 @@ func truncateStr(s string, max int) string { return s } +func formatTree(node *knowledge.TreeIndex, depth int) string { + var sb strings.Builder + indent := strings.Repeat(" ", depth) + for _, child := range node.Children { + sb.WriteString(fmt.Sprintf("%s%s/\n", indent, child.Name)) + if len(child.Items) > 0 { + for _, item := range child.Items { + preview := item.Preview + if len([]rune(preview)) > 60 { + preview = string([]rune(preview)[:60]) + "..." + } + tags := "" + if len(item.Tags) > 0 { + tags = " [" + strings.Join(item.Tags, ", ") + "]" + } + sb.WriteString(fmt.Sprintf("%s · %s%s\n %s\n", indent, item.Name, tags, preview)) + } + } + sb.WriteString(formatTree(child, depth+1)) + } + if depth > 0 && len(node.Items) > 0 { + for _, item := range node.Items { + preview := item.Preview + if len([]rune(preview)) > 60 { + preview = string([]rune(preview)[:60]) + "..." + } + tags := "" + if len(item.Tags) > 0 { + tags = " [" + strings.Join(item.Tags, ", ") + "]" + } + sb.WriteString(fmt.Sprintf(" · %s%s\n %s\n", item.Name, tags, preview)) + } + } + if sb.Len() == 0 { + sb.WriteString("(空)") + } + return sb.String() +} + func (a *Agent) resolveToolPlugin(name string) string { if a.stageHost != nil { if plugin := a.stageHost.ToolPlugin(name); plugin != "" { diff --git a/internal/agent/core/context.go b/internal/agent/core/context.go index 1324f53..422e9fa 100644 --- a/internal/agent/core/context.go +++ b/internal/agent/core/context.go @@ -223,6 +223,10 @@ func (c *RelevanceContext) ensureTrained() { texts[i] = evt.Input + " " + evt.Response } c.veczer.Train(texts) + // 重算所有事件向量,与新的向量化器特征空间对齐 + for _, evt := range c.events { + evt.Vector = c.veczer.Vectorize(evt.Input + " " + evt.Response) + } c.trained = true } } diff --git a/internal/knowledge/knowledge.go b/internal/knowledge/knowledge.go index f8d7475..c883e7e 100644 --- a/internal/knowledge/knowledge.go +++ b/internal/knowledge/knowledge.go @@ -1,6 +1,7 @@ package knowledge import ( + "encoding/json" "fmt" "log" "os" @@ -13,17 +14,67 @@ import ( "gitcode.com/JianFeeeee/HomeAgent/internal/memory/vector" ) -// Knowledge — 单条知识 type Knowledge struct { Name string `json:"name"` Content string `json:"content"` Path string `json:"path"` + Category string `json:"category,omitempty"` // 父路径,如 "tech/go" Tags []string `json:"tags"` UpdatedAt time.Time `json:"updated_at"` Meta map[string]string `json:"meta,omitempty"` } -// Store — 知识库,文件系统 + 向量索引 +// IndexItem — 索引条目,包含向量特征和内容摘要 +type IndexItem struct { + Name string `json:"name"` + Preview string `json:"preview"` // 前 200 字摘要 + Tags []string `json:"tags"` + Vector map[string]float64 `json:"vector"` // TF-IDF 特征向量(top-N 特征) + Size int `json:"size"` // 内容总字节数 +} + +// TreeIndex — 树状索引节点 +type TreeIndex struct { + Name string `json:"name"` + Children map[string]*TreeIndex `json:"children,omitempty"` + Items []IndexItem `json:"items,omitempty"` // 此节点下的知识条目(含向量) +} + +func newTreeIndex(name string) *TreeIndex { + return &TreeIndex{Name: name, Children: make(map[string]*TreeIndex)} +} + +// compressVector 压缩向量:保留 topN 个权重最高的特征 +func compressVector(v vector.Vector, topN int) map[string]float64 { + if len(v) <= topN { + out := make(map[string]float64, len(v)) + for k, w := range v { + out[k] = w + } + return out + } + type kv struct { + k string + v float64 + } + sorted := make([]kv, 0, len(v)) + for k, w := range v { + sorted = append(sorted, kv{k, w}) + } + sort.Slice(sorted, func(i, j int) bool { + return sorted[i].v > sorted[j].v + }) + if topN > len(sorted) { + topN = len(sorted) + } + sorted = sorted[:topN] + out := make(map[string]float64, topN) + for _, kv := range sorted { + out[kv.k] = kv.v + } + return out +} + type Store struct { root string vec *vector.Store @@ -31,15 +82,17 @@ type Store struct { mu sync.RWMutex items map[string]*Knowledge + indexPath string summaries []string } func NewStore(root string) *Store { return &Store{ - root: root, - vec: vector.NewStore(), - veczer: vector.NewTFIDFVectorizer(3), - items: make(map[string]*Knowledge), + root: root, + indexPath: filepath.Join(root, ".index.json"), + vec: vector.NewStore(), + veczer: vector.NewTFIDFVectorizer(3), + items: make(map[string]*Knowledge), } } @@ -50,13 +103,16 @@ func (s *Store) Start() error { if err := s.scanAll(); err != nil { log.Printf("[knowledge] scan error: %v", err) } + // 重建索引文件 + if err := s.writeIndex(); err != nil { + log.Printf("[knowledge] write index error: %v", err) + } log.Printf("[knowledge] started with %d items, %d vectors", len(s.items), s.vec.Size()) return nil } func (s *Store) Stop() {} -// Search — 向量查询知识 func (s *Store) Search(query string, topK int) []*Knowledge { s.mu.RLock() defer s.mu.RUnlock() @@ -77,48 +133,56 @@ func (s *Store) Search(query string, topK int) []*Knowledge { return out } -// Add — 添加或更新知识 func (s *Store) Add(name, content string) error { s.mu.Lock() defer s.mu.Unlock() - // 创建知识目录 - dir := filepath.Join(s.root, sanitize(name)) + // 解析层级:将 "/" 作为路径分隔符 + category := "" + leaf := name + if idx := strings.LastIndex(name, "/"); idx >= 0 { + category = name[:idx] + leaf = name[idx+1:] + } + dirName := sanitize(leaf) + if category != "" { + dirName = sanitize(category) + "/" + dirName + } + dir := filepath.Join(s.root, dirName) if err := os.MkdirAll(dir, 0755); err != nil { return fmt.Errorf("create knowledge dir: %w", err) } - // 写入知识文件 path := filepath.Join(dir, "content.md") if err := os.WriteFile(path, []byte(content), 0644); err != nil { return fmt.Errorf("write knowledge: %w", err) } now := time.Now() + id := sanitize(name) k := &Knowledge{ - Name: name, + Name: id, Content: content, Path: path, + Category: sanitize(category), Tags: extractKeywords(name + " " + content), UpdatedAt: now, } - - // 生成 ID = 目录名 - id := sanitize(name) s.items[id] = k vec := s.veczer.Vectorize(name + " " + content) s.vec.Insert(id, name+": "+content, vec, map[string]string{ "name": name, "path": path, }) - s.summaries = append(s.summaries, name+" "+content) + if err := s.writeIndex(); err != nil { + log.Printf("[knowledge] write index error after adding %s: %v", name, err) + } log.Printf("[knowledge] added: %s (%d bytes)", name, len(content)) return nil } -// SearchCategories — 返回所有知识类别 func (s *Store) SearchCategories(query string, topK int) []string { s.mu.RLock() defer s.mu.RUnlock() @@ -157,6 +221,9 @@ func (s *Store) Remove(name string) error { } delete(s.items, id) s.vec.Remove(id) + if err := s.writeIndex(); err != nil { + log.Printf("[knowledge] write index error after removing %s: %v", name, err) + } return nil } @@ -167,6 +234,7 @@ func (s *Store) Stats() map[string]interface{} { "knowledge_count": len(s.items), "vector_count": s.vec.Size(), "root": s.root, + "index_file": s.indexPath, } } @@ -181,6 +249,89 @@ func (s *Store) List() []string { return names } +// BuildTree 从当前知识库构建树状索引(含向量特征) +func (s *Store) BuildTree() *TreeIndex { + s.mu.RLock() + defer s.mu.RUnlock() + root := newTreeIndex("root") + for _, k := range s.items { + node := root + if k.Category != "" { + parts := strings.Split(k.Category, "/") + for _, part := range parts { + if part == "" { + continue + } + if _, ok := node.Children[part]; !ok { + node.Children[part] = newTreeIndex(part) + } + node = node.Children[part] + } + } + // 获取该条目的向量并压缩 + vec := s.veczer.Vectorize(k.Name + " " + k.Content) + preview := []rune(k.Content) + previewStr := "" + if len(preview) > 200 { + previewStr = string(preview[:200]) + "..." + } else { + previewStr = string(preview) + } + item := IndexItem{ + Name: k.Name, + Preview: previewStr, + Tags: k.Tags, + Vector: compressVector(vec, 20), + Size: len(k.Content), + } + node.Items = append(node.Items, item) + } + return root +} + +// SearchTree 树状搜索:在树节点下搜索,返回按分类聚合的结果 +func (s *Store) SearchTree(query string, topK int) map[string][]*Knowledge { + s.mu.RLock() + defer s.mu.RUnlock() + + if topK <= 0 { + topK = 10 + } + + vec := s.veczer.Vectorize(query) + results := s.vec.Search(vec, topK*2) + + categorized := make(map[string][]*Knowledge) + for _, r := range results { + if k, ok := s.items[r.ID]; ok { + cat := k.Category + if cat == "" { + cat = "未分类" + } + categorized[cat] = append(categorized[cat], k) + } + } + + out := make(map[string][]*Knowledge) + for cat, items := range categorized { + if len(items) > topK { + items = items[:topK] + } + out[cat] = items + } + return out +} + +// writeIndex 写入 .index.json 树状索引文件(含向量和摘要) +func (s *Store) writeIndex() error { + tree := s.BuildTree() + data, err := json.MarshalIndent(tree, "", " ") + if err != nil { + return err + } + return os.WriteFile(s.indexPath, data, 0644) +} + // ——— internal ——— func (s *Store) scanAll() error { @@ -193,34 +344,17 @@ func (s *Store) scanAll() error { if !entry.IsDir() { continue } - dir := filepath.Join(s.root, entry.Name()) - contentPath := filepath.Join(dir, "content.md") - data, err := os.ReadFile(contentPath) - if err != nil { + // skip hidden dirs + if strings.HasPrefix(entry.Name(), ".") { continue } - - name := entry.Name() - content := string(data) - now := time.Now() - - k := &Knowledge{ - Name: name, - Content: content, - Path: contentPath, - Tags: extractKeywords(name + " " + content), - UpdatedAt: now, - } - s.items[name] = k - s.summaries = append(s.summaries, name+" "+content) + s.scanDir("", entry.Name()) } - // 训练向量化器 if len(s.summaries) > 0 { s.veczer.Train(s.summaries) } - // 构建向量索引 for _, k := range s.items { vec := s.veczer.Vectorize(k.Name + " " + k.Content) s.vec.Insert(k.Name, k.Name+": "+k.Content, vec, map[string]string{ @@ -231,11 +365,48 @@ func (s *Store) scanAll() error { return nil } +// scanDir 递归扫描目录 +// category: 父级路径(从知识库根目录算起),如 "tech/go" +// dirName: 当前目录相对路径(从知识库根目录算起) +func (s *Store) scanDir(category, dirName string) { + dir := filepath.Join(s.root, dirName) + contentPath := filepath.Join(dir, "content.md") + data, err := os.ReadFile(contentPath) + if err == nil { + name := dirName + content := string(data) + now := time.Now() + k := &Knowledge{ + Name: name, + Content: content, + Path: contentPath, + Category: category, + Tags: extractKeywords(dirName + " " + content), + UpdatedAt: now, + } + s.items[name] = k + s.summaries = append(s.summaries, name+" "+content) + return + } + + // 无 content.md => 是分类目录,递归子目录 + subEntries, err := os.ReadDir(dir) + if err != nil { + return + } + for _, sub := range subEntries { + if !sub.IsDir() || strings.HasPrefix(sub.Name(), ".") { + continue + } + childDir := dirName + "/" + sub.Name() + s.scanDir(dirName, childDir) + } +} + func sanitize(name string) string { name = strings.ToLower(name) name = strings.TrimSpace(name) name = strings.ReplaceAll(name, " ", "_") - name = strings.ReplaceAll(name, "/", "_") name = strings.ReplaceAll(name, "\\", "_") return name } @@ -255,10 +426,8 @@ func extractKeywords(text string) []string { var keywords []string runes := []rune(text) - seen := make(map[string]bool) - // bi-gram for i := 0; i < len(runes)-1; i++ { word := string(runes[i : i+2]) if !stopWords[word] && len(strings.TrimSpace(word)) == len(word) && !seen[word] { diff --git a/internal/memory/document/document.go b/internal/memory/document/document.go index 83a9ca1..a1edabb 100644 --- a/internal/memory/document/document.go +++ b/internal/memory/document/document.go @@ -93,7 +93,7 @@ func (s *Store) Insert(doc *Doc) error { return nil } -// ContextToDoc — 将一段上下文对话历史提炼为文档 +// ContextToDoc — 将一段上下文对话历史提炼为文档(带内容去重) func (s *Store) ContextToDoc(source string, entries []ContextEntry) (*Doc, error) { if len(entries) == 0 { return nil, nil @@ -108,26 +108,46 @@ func (s *Store) ContextToDoc(source string, entries []ContextEntry) (*Doc, error parts = append(parts, line) } content := strings.Join(parts, "\n") + contentHash := simpleHash(content) + // 去重:检查是否已有相同 hash 的文档(在锁内完成创建/更新) summary := summarizeEntries(entries) tags := extractTags(entries) entities := extractEntities(entries) + s.mu.Lock() + for _, d := range s.docs { + if d.Meta != nil && d.Meta["content_hash"] == contentHash { + d.UpdatedAt = time.Now() + d.LastAccess = time.Now() + d.Content = content + d.Source = source + d.Summary = summary + d.Tags = tags + d.Entities = entities + s.dirty = true + s.mu.Unlock() + return d, nil + } + } + + id := fmt.Sprintf("doc_%d", time.Now().UnixNano()) doc := &Doc{ - ID: fmt.Sprintf("doc_%d", time.Now().UnixNano()), - Summary: summary, - Content: content, - Tags: tags, - Entities: entities, - CreatedAt: time.Now(), - UpdatedAt: time.Now(), - Source: source, + ID: id, + Summary: summary, + Content: content, + Tags: tags, + Entities: entities, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + LastAccess: time.Now(), + AccessCount: 1, + Source: source, + Meta: map[string]string{"content_hash": contentHash}, } - - if err := s.Insert(doc); err != nil { - return nil, err - } - + s.docs[id] = doc + s.dirty = true + s.mu.Unlock() return doc, nil } @@ -422,3 +442,12 @@ func truncate(s string, max int) string { } return s } + +func simpleHash(s string) string { + // 简单的基于内容的哈希,用于去重 + h := 0 + for _, r := range s { + h = h*31 + int(r) + } + return fmt.Sprintf("h%08x", h) +} diff --git a/internal/plugins/cli/plugin.go b/internal/plugins/cli/plugin.go index e919462..249585b 100644 --- a/internal/plugins/cli/plugin.go +++ b/internal/plugins/cli/plugin.go @@ -108,6 +108,26 @@ func (p *Plugin) handleConn(conn net.Conn, s *sdk.PluginSDK) { defer p.wg.Done() scanner := bufio.NewScanner(conn) + + apiKey := p.webuiAPIKey() + if apiKey != "" { + if !scanner.Scan() { + return + } + line := scanner.Text() + if !strings.HasPrefix(line, "/auth ") || strings.TrimSpace(line[6:]) != apiKey { + writeLine(conn, map[string]interface{}{ + "type": "error", + "error": "unauthorized", + }) + return + } + writeLine(conn, map[string]interface{}{ + "type": "response", + "content": "authenticated", + }) + } + for scanner.Scan() { line := scanner.Text() if line == "" { @@ -136,6 +156,19 @@ func (p *Plugin) handleConn(conn net.Conn, s *sdk.PluginSDK) { } } +func (p *Plugin) webuiAPIKey() string { + if cfgReg == nil { + return "" + } + ps := cfgReg.PluginConfig("webui") + if v, _ := ps.Get("api_key"); v != nil { + if s, ok := v.(string); ok { + return s + } + } + return "" +} + func (p *Plugin) handleBuiltin(conn net.Conn, line string, s *sdk.PluginSDK) bool { parts := strings.Fields(line) if len(parts) == 0 { diff --git a/internal/plugins/healthcheck/plugin.go b/internal/plugins/healthcheck/plugin.go index 8746b2c..57c0355 100644 --- a/internal/plugins/healthcheck/plugin.go +++ b/internal/plugins/healthcheck/plugin.go @@ -456,8 +456,12 @@ func (p *Plugin) testKnowledgeRaw() checkResult { } results := hcKnowledge.Search("健康检查测试标记", 3) + elapsed := time.Since(start) + // 清理测试条目,避免积累 + hcKnowledge.Remove(marker) + if len(results) > 0 { return checkResult{ Name: "knowledge",