diff --git a/main.go b/main.go index 6e5e00d..fce81bf 100644 --- a/main.go +++ b/main.go @@ -354,7 +354,7 @@ func NewRootStore() *fs.Tree { if _, ok := readEntries[name]; ok { return name, nil } - if name == "ctl" { + if name == "ctl" || name == "complete" { return name, nil } return "", fmt.Errorf("%s: not found", name) @@ -368,6 +368,14 @@ func NewRootStore() *fs.Tree { }, nil }), fs.WithOpener(func(_ []string, name string) (fs.File, error) { + if name == "complete" { + return &fs.FileConfig{ + StatFn: func() (os.FileInfo, error) { return &fs.SyntheticFileInfo{Name_: "complete", Mode_: 0666}, nil }, + ReadFn: func() ([]byte, error) { return nil, nil }, + WriteFn: func([]byte) error { return nil }, + BlockingReadFn: notBlocking, + }, nil + } if name == "ctl" { return &fs.FileConfig{ StatFn: func() (os.FileInfo, error) { return &fs.SyntheticFileInfo{Name_: "ctl", Mode_: 0666}, nil }, diff --git a/server/server.go b/server/server.go index c5a6ca6..e5e4deb 100644 --- a/server/server.go +++ b/server/server.go @@ -4,6 +4,7 @@ package server import ( "context" + "encoding/json" "fmt" "hash/fnv" "io" @@ -38,12 +39,13 @@ const ( // fid tracks per-descriptor state for a single 9P connection. type fid struct { - path string - qid plan9.Qid - mode uint8 - writeBuf []byte - waitBase string // for *wait files: value snapshotted at open time - dirCache []byte // cached serialized dir entries for readdir + path string + qid plan9.Qid + mode uint8 + writeBuf []byte + waitBase string // for *wait files: value snapshotted at open time + dirCache []byte // cached serialized dir entries for readdir + completeResult []byte // for /complete: result of last write (per-fid state) } // connState tracks all open fids for a single 9P connection. @@ -753,6 +755,12 @@ func (s *Server) read(cs *connState, fc *plan9.Fcall, ctx context.Context) *plan return &plan9.Fcall{Type: plan9.Rread, Tag: fc.Tag, Count: uint32(len(data)), Data: data} } + // /complete: return per-fid result from previous write + if path == "/complete" { + s.log.Debug("Tread path=%q offset=%d count=%d", path, fc.Offset, fc.Count) + return s.readSlice(fc, f.completeResult) + } + s.log.Debug("Tread path=%q offset=%d count=%d", path, fc.Offset, fc.Count) entry, err := s.openEntry(path) if err != nil { @@ -840,6 +848,29 @@ func (s *Server) write(cs *connState, fc *plan9.Fcall) *plan9.Fcall { return errFcall(fc, "bad fid") } s.log.Debug("Twrite fid=%d path=%q offset=%d len=%d", fc.Fid, f.path, fc.Offset, len(fc.Data)) + + // /complete: synchronous request-response (per-fid state, like /net/dns) + if f.path == "/complete" { + cs.mu.Unlock() + var req struct { + CWD string `json:"cwd"` + FilePath string `json:"file"` + Prefix string `json:"prefix"` + Suffix string `json:"suffix"` + ExtraContext string `json:"context"` + } + if err := json.Unmarshal(fc.Data, &req); err != nil { + // If not JSON, treat entire write as prefix (simple mode) + req.Prefix = strings.TrimSpace(string(fc.Data)) + } + result, err := s.sessionMgr.Complete(req.CWD, req.FilePath, req.Prefix, req.Suffix, req.ExtraContext) + if err != nil { + return errFcall(fc, err.Error()) + } + f.completeResult = []byte(result) + return &plan9.Fcall{Type: plan9.Rwrite, Tag: fc.Tag, Count: uint32(len(fc.Data))} + } + // Accumulate; the 9P client may split large writes across multiple Twrite messages. end := int(fc.Offset) + len(fc.Data) if end > len(f.writeBuf) { @@ -1013,7 +1044,7 @@ func (s *Server) buildDirData(path string) []byte { var dirs []plan9.Dir if path == "/" { - rootEntries := []string{"agents", "backends", "ctl", "help", "models", "s"} + rootEntries := []string{"agents", "backends", "complete", "ctl", "help", "models", "s"} for _, name := range rootEntries { fpath := "/" + name st := s.makeStat(fpath) diff --git a/session/session.go b/session/session.go index f16fa02..4451c6a 100644 --- a/session/session.go +++ b/session/session.go @@ -3,6 +3,7 @@ package session import ( "context" "fmt" + "hash/crc32" "os" "path/filepath" "sort" @@ -1081,3 +1082,164 @@ func FormatEvent(ev agent.Event) []byte { func squashWhitespace(s string) string { return strings.Join(strings.Fields(s), " ") } + +// --- Code Completion --- + +// Complete performs a single-shot code completion using a dedicated copilot session. +func (s *Manager) Complete(cwd, filePath, prefix, suffix, extraContext string) (string, error) { + if cwd == "" { + cwd, _ = os.Getwd() + } + cwd = paths.ExpandHome(os.ExpandEnv(cwd)) + + model := os.Getenv("OLLIE_COMPLETE_MODEL") + backendName := os.Getenv("OLLIE_COMPLETE_BACKEND") + if model == "" || backendName == "" { + return "", fmt.Errorf("OLLIE_COMPLETE_MODEL and OLLIE_COMPLETE_BACKEND must be set") + } + + const prefixMax = 12000 + const suffixMax = 1000 + if len(prefix) > prefixMax { + prefix = prefix[len(prefix)-prefixMax:] + } + if len(suffix) > suffixMax { + suffix = suffix[:suffixMax] + } + + sessID := s.findOrCreateCopilot(cwd, backendName, model) + if sessID == "" { + return "", fmt.Errorf("failed to create copilot session") + } + + s.mu.RLock() + sess, ok := s.sessions[sessID] + s.mu.RUnlock() + if !ok { + return "", fmt.Errorf("copilot session disappeared") + } + + fileHint := "" + if filePath != "" { + fileHint = " in " + filePath + } + contextBlock := "" + if extraContext != "" { + contextBlock = "\n" + extraContext + } + prompt := fmt.Sprintf(`Implement the code at the cursor%s. The prefix ends at the point where new code is needed. Write the implementation — do not echo stubs, TODOs, or placeholder returns from the prefix. Output ONLY raw code. No reasoning, no shell commands, no explanations, no markdown fences, no backticks, no preamble. Your entire response must be valid code that can be inserted directly into the file. +%s + +%s + + +%s +`, fileHint, contextBlock, prefix, suffix) + + sess.Core.Submit(sess.Ctx, "/clear") + sess.Core.Submit(sess.Ctx, prompt) + + for { + state := sess.Core.State() + if state == "idle" { + break + } + next, ok := sess.Core.WaitChange(sess.Ctx, agent.WatchState, state) + if !ok { + return "", fmt.Errorf("copilot session cancelled") + } + if next == "idle" { + break + } + } + + result := sess.Core.Reply() + result = stripCompletionNoise(result) + result = stripPrefixEcho(prefix, result) + return result, nil +} + +func (s *Manager) findOrCreateCopilot(cwd, backendName, modelName string) string { + sessID := fmt.Sprintf("%d-copilot", crc32Str(cwd)) + + s.mu.RLock() + _, exists := s.sessions[sessID] + s.mu.RUnlock() + if exists { + return sessID + } + + cfg := LoadAgentConfig(s.cfg.AgentsDir, "copilot", nil) + be, err := backend.NewWithName(backendName) + if err != nil { + return "" + } + if modelName != "" { + be.SetModel(modelName) + } + + uname := s.nextUname() + newDisp := tools.NewDispatcherFunc(map[string]func() tools.Server{ + "execute": execute.Decl(cwd), + }) + rt := agent.BuildRuntime(cfg, newDisp(), cwd, []string{"OLLIE_SESSION_ID=" + sessID}) + + core := agent.NewAgentCore(agent.AgentCoreConfig{ + Backend: be, + AgentName: "copilot", + AgentsDir: s.cfg.AgentsDir, + SessionsDir: s.cfg.SessionsDir, + SessionID: sessID, + Uname: uname, + CWD: cwd, + Runtime: rt, + NewDispatcher: newDisp, + Log: s.cfg.Sink.NewLogger("copilot"), + }) + + ctx, cancel := context.WithCancel(context.Background()) + sess := NewSession(sessID, core, ctx, cancel) + sess.uname = uname + + s.mu.Lock() + s.sessions[sessID] = sess + s.mu.Unlock() + return sessID +} + +func stripCompletionNoise(s string) string { + var lines []string + for _, line := range strings.Split(s, "\n") { + trimmed := strings.TrimSpace(line) + if strings.HasPrefix(trimmed, "```") { + continue + } + if trimmed == "" || trimmed == "" || + trimmed == "" || trimmed == "" { + continue + } + if strings.HasPrefix(trimmed, ":: ") { + continue + } + lines = append(lines, line) + } + return strings.Join(lines, "\n") +} + +func stripPrefixEcho(prefix, result string) string { + tailMax := 200 + if len(prefix) < tailMax { + tailMax = len(prefix) + } + for i := tailMax; i > 0; i-- { + tail := prefix[len(prefix)-i:] + if strings.HasPrefix(result, tail) { + return result[len(tail):] + } + } + return result +} + +func crc32Str(s string) uint32 { + return crc32.ChecksumIEEE([]byte(s)) +}