ollie/cmd/Ollie/main.go

413 lines
7.8 KiB
Go

// ollie-acme: acme interface for ollie AI sessions.
// All file operations use 9P directly (not FUSE mount) for proper
// blocking read support on streaming files like chat.
package main
import (
"io"
"log"
"os"
"path/filepath"
"sort"
"strings"
"sync"
"9fans.net/go/acme"
"9fans.net/go/plan9"
"9fans.net/go/plan9/client"
)
func main() {
acme.AutoExit(true)
if err := newSessionList(); err != nil {
log.Fatal(err)
}
select {}
}
// --- 9P helpers ---
func dial9p() *client.Fsys {
ns := os.Getenv("NAMESPACE")
if ns == "" {
ns = client.Namespace()
}
if ns == "" {
log.Fatal("cannot determine namespace; set $NAMESPACE")
}
address := ns + "/ollie"
conn, err := client.Dial("unix", address)
if err != nil {
log.Fatalf("dial %s: %v", address, err)
}
fsys, err := conn.Attach(nil, getuser(), "")
if err != nil {
log.Fatalf("attach: %v", err)
}
return fsys
}
func getuser() string {
if u := os.Getenv("USER"); u != "" {
return u
}
return "none"
}
func read9p(path string) ([]byte, error) {
fsys := dial9p()
fid, err := fsys.Open(path, plan9.OREAD)
if err != nil {
return nil, err
}
defer fid.Close()
var buf []byte
tmp := make([]byte, 8192)
for {
n, err := fid.Read(tmp)
if n > 0 {
buf = append(buf, tmp[:n]...)
}
if err != nil {
if err == io.EOF {
break
}
return buf, err
}
}
return buf, nil
}
func write9p(path string, data []byte) error {
fsys := dial9p()
fid, err := fsys.Open(path, plan9.OWRITE)
if err != nil {
return err
}
defer fid.Close()
_, err = fid.Write(data)
return err
}
// agentPath returns the 9P path to the active agent within a session.
func agentPath(sid string) string {
fsys := dial9p()
dirPath := "session/" + sid + "/agent"
fid, err := fsys.Open(dirPath, plan9.OREAD)
if err != nil {
return dirPath + "/0"
}
dirs, err := fid.Dirreadall()
fid.Close()
if err != nil || len(dirs) == 0 {
return dirPath + "/0"
}
for _, d := range dirs {
if d.Mode&plan9.DMDIR != 0 {
return dirPath + "/" + d.Name
}
}
return dirPath + "/" + dirs[0].Name
}
// --- session list window ---
type sessionList struct {
win *acme.Win
allSIDs []string
mu sync.Mutex
}
func newSessionList() error {
w, err := acme.New()
if err != nil {
return err
}
w.Name("ollie/sessions")
sl := &sessionList{win: w}
sl.refresh()
w.Ctl("cleartag")
w.Write("tag", []byte(" New Kill Refresh"))
go sl.eventLoop()
return nil
}
func (sl *sessionList) refresh() {
data, err := read9p("session/idx")
if err != nil {
sl.win.Errf("read idx: %v", err)
return
}
sl.win.Addr(",")
sl.win.Write("data", nil)
raw := strings.TrimSpace(string(data))
if raw == "" {
sl.win.Ctl("clean")
return
}
lines := strings.Split(raw, "\n")
sort.Strings(lines)
sl.mu.Lock()
sl.allSIDs = nil
for _, l := range lines {
if s := strings.TrimSpace(l); s != "" {
id, _, _ := strings.Cut(s, "\t")
sl.allSIDs = append(sl.allSIDs, id)
}
}
sl.mu.Unlock()
sl.win.Write("body", []byte(raw+"\n"))
sl.win.Ctl("clean")
}
func (sl *sessionList) resolveFullSID(text string) string {
text = strings.TrimSpace(text)
if text == "" {
return ""
}
sl.mu.Lock()
defer sl.mu.Unlock()
for _, sid := range sl.allSIDs {
parts := strings.Split(sid, "__")
if parts[len(parts)-1] == text {
return sid
}
}
return ""
}
func (sl *sessionList) eventLoop() {
for e := range sl.win.EventChan() {
switch e.C2 {
case 'x', 'X':
cmd := strings.TrimSpace(string(e.Text))
if cmd == "New" {
openNewSession()
continue
}
if cmd == "Kill" {
arg := strings.TrimSpace(string(e.Arg))
sid := sl.resolveFullSID(arg)
if sid != "" {
ab := agentPath(sid)
write9p(ab+"/ctl", []byte("kill"))
sl.refresh()
}
continue
}
if cmd == "Refresh" {
sl.refresh()
continue
}
sid := sl.resolveFullSID(cmd)
if sid != "" {
openChat(sid)
} else {
sl.win.WriteEvent(e)
}
case 'l', 'L':
sl.win.WriteEvent(e)
}
}
os.Exit(0)
}
// --- chat window ---
type chatWin struct {
win *acme.Win
sid string
mu sync.Mutex
}
var chatWindows sync.Map
func openChat(sid string) {
if v, ok := chatWindows.Load(sid); ok {
v.(*chatWin).win.Ctl("show")
return
}
w, err := acme.New()
if err != nil {
log.Printf("open chat %s: %v", sid, err)
return
}
winName := "ollie/sessions/" + sid
if data, err := read9p("session/idx"); err == nil {
for _, line := range strings.Split(string(data), "\n") {
fields := strings.Split(line, "\t")
if len(fields) >= 3 && fields[0] == sid {
winName = fields[2] + "/+Ollie"
break
}
}
}
w.Name("%s", winName)
w.Ctl("cleartag")
w.Fprintf("tag", " Prompt Stop Ctl Cfg Nudge Usage")
cw := &chatWin{win: w, sid: sid}
chatWindows.Store(sid, cw)
// Load existing log (last 64KB) for initial context
ab := agentPath(sid)
if data, err := read9p(ab + "/log"); err == nil && len(data) > 0 {
w.Write("body", data)
}
w.Ctl("clean")
go cw.streamChat(ab)
go cw.eventLoop(ab)
}
func (cw *chatWin) eventLoop(ab string) {
defer chatWindows.Delete(cw.sid)
for e := range cw.win.EventChan() {
switch e.C2 {
case 'x', 'X':
cmd := strings.TrimSpace(string(e.Text))
switch cmd {
case "Prompt":
openPrompt(ab)
case "Stop":
write9p(ab+"/ctl", []byte("stop"))
case "Ctl":
openFile9p(ab + "/ctl")
case "Cfg":
openFile9p(ab + "/cfg")
case "Nudge":
write9p(ab+"/prompt", []byte("."))
case "Usage":
if data, err := read9p(ab + "/usage"); err == nil {
cw.win.Errf("%s", string(data))
}
default:
cw.win.WriteEvent(e)
}
case 'l', 'L':
cw.win.WriteEvent(e)
}
}
}
// streamChat reads the chat file via 9P (blocking reads) and appends to the window.
// The read blocks until new tokens arrive, then returns them. Loops until EOF
// (session killed) or error.
func (cw *chatWin) streamChat(ab string) {
fsys := dial9p()
fid, err := fsys.Open(ab+"/chat", plan9.OREAD)
if err != nil {
log.Printf("open chat stream: %v", err)
return
}
defer fid.Close()
buf := make([]byte, 8192)
for {
n, err := fid.Read(buf)
if n > 0 {
cw.mu.Lock()
cw.win.Addr("$")
cw.win.Write("data", buf[:n])
cw.win.Ctl("clean")
cw.mu.Unlock()
}
if err != nil {
// EOF = session killed; any other error = done
return
}
}
}
// --- helper windows ---
func openNewSession() {
w, err := acme.New()
if err != nil {
return
}
w.Name("ollie/new")
w.Write("body", []byte("cwd="+os.Getenv("HOME")+"\n"))
w.Ctl("cleartag")
w.Fprintf("tag", " Put")
go func() {
for e := range w.EventChan() {
if e.C2 == 'x' || e.C2 == 'X' {
cmd := strings.TrimSpace(string(e.Text))
if cmd == "Put" {
w.Addr(",")
data, _ := w.ReadAll("body")
write9p("session/new", data)
w.Del(true)
return
}
}
}
}()
}
func openPrompt(ab string) {
w, err := acme.New()
if err != nil {
return
}
w.Name("%s", filepath.Base(ab)+"/prompt")
w.Ctl("cleartag")
w.Fprintf("tag", " Put")
go func() {
for e := range w.EventChan() {
if e.C2 == 'x' || e.C2 == 'X' {
cmd := strings.TrimSpace(string(e.Text))
if cmd == "Put" {
w.Addr(",")
data, _ := w.ReadAll("body")
write9p(ab+"/prompt", data)
w.Addr(",")
w.Write("data", nil)
w.Ctl("clean")
}
}
}
}()
}
func openFile9p(path string) {
w, err := acme.New()
if err != nil {
return
}
parts := strings.Split(path, "/")
w.Name("%s", parts[len(parts)-1])
if data, err := read9p(path); err == nil {
w.Write("body", data)
}
w.Ctl("cleartag")
w.Fprintf("tag", " Put Get")
go func() {
for e := range w.EventChan() {
if e.C2 == 'x' || e.C2 == 'X' {
cmd := strings.TrimSpace(string(e.Text))
switch cmd {
case "Put":
w.Addr(",")
data, _ := w.ReadAll("body")
write9p(path, data)
w.Ctl("clean")
case "Get":
if data, err := read9p(path); err == nil {
w.Addr(",")
w.Write("data", data)
w.Ctl("clean")
}
}
}
}
}()
}