diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0fd4d30 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +# build artifacts +/bantam +*.exe +__pycache__/ +*.pyc diff --git a/README.md b/README.md index 1c05c75..a9342c8 100644 --- a/README.md +++ b/README.md @@ -123,7 +123,7 @@ Using these rules, everyone can build their own copy of Bantam from scratch in l - `api_key` (API key / Bearer token, optional; fall back to `OPENAI_API_KEY` env var) - `stream` (stream response tokens in real-time, default `true`) - `color` (ANSI coloring: `auto` (TTY-detected, default), `always`, or `never`; also disabled by `NO_COLOR`/`BANTAM_NO_COLOR` env vars) -- `timeout` (HTTP timeout in seconds for LLM API calls, default 60) +- `timeout` (HTTP timeout in seconds for LLM API calls, default 300; in the Go port it bounds connection setup and time-to-first-byte, so long streaming responses are not cut off mid-stream, matching the Python port's per-operation socket timeout) - `shell_timeout` (timeout in seconds for `shell_exec` commands, default 120) - `max_al_iterations` (max tool-call loop iterations per `AL()` invocation, default 1000) diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..2a44762 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module code.luxferre.top/luxferre/bantam + +go 1.21 diff --git a/main.go b/main.go new file mode 100644 index 0000000..ec807ed --- /dev/null +++ b/main.go @@ -0,0 +1,752 @@ +// Bantam agent, tiny, powerful, DIY. Public domain. Created by Luxferre in 2026. +// Go port: zero external dependencies, stdlib only. + +package main + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net" + "net/http" + "os" + "os/exec" + "path/filepath" + "sort" + "strconv" + "strings" + "time" + "unicode/utf8" +) + +const MAX_DEPTH = 5 // subagent recursion depth limit + +var ( + COL bool // ANSI color enabled + hist []string // line history (mirrors ~/.bantam_history) + histF string // history file path + stdin *bufio.Reader // stdin reader for the REPL +) + +type Cfg struct { + Endpoint string + Model string + APIKey string + Temperature float64 + Timeout int + ShellTimeout int + MaxALIterations int + Stream bool + Color string +} + +var defCfg = Cfg{"https://opencode.ai/zen/v1", "deepseek-v4-flash-free", "-", 0.7, 300, 120, 1000, true, "auto"} + +func atoiD(s string, d int) int { + if v, e := strconv.Atoi(strings.TrimSpace(s)); e == nil { + return v + } + return d +} + +func getCfg(path string) Cfg { + cfg := defCfg + if d, err := os.ReadFile(path); err == nil { + for _, ln := range strings.Split(string(d), "\n") { + ln = strings.TrimSpace(ln) + if ln == "" || ln[0] == '#' || !strings.Contains(ln, "=") { continue } + k, v, _ := strings.Cut(ln, "=") + k, v = strings.TrimSpace(k), strings.TrimSpace(v) + switch k { + case "endpoint": cfg.Endpoint = v + case "model": cfg.Model = v + case "api_key": cfg.APIKey = v + case "temperature": if f, e := strconv.ParseFloat(v, 64); e == nil { cfg.Temperature = f } + case "timeout": cfg.Timeout = atoiD(v, cfg.Timeout) + case "shell_timeout": cfg.ShellTimeout = atoiD(v, cfg.ShellTimeout) + case "max_al_iterations": cfg.MaxALIterations = atoiD(v, cfg.MaxALIterations) + case "stream": cfg.Stream = v == "true" || v == "1" || v == "yes" + case "color": cfg.Color = v + } + } + } + if cfg.APIKey == "" { cfg.APIKey = os.Getenv("OPENAI_API_KEY") } + return cfg +} + +func prompt(path string) string { + if d, err := os.ReadFile(path); err == nil { + return strings.TrimSpace(string(d)) + } + return "You are Bantam, a tiny, powerful AI agent." +} + +func c(t string, cs ...int) string { + if !COL || len(cs) == 0 { return t } + s := make([]string, len(cs)) + for i, x := range cs { s[i] = strconv.Itoa(x) } + return "\033[" + strings.Join(s, ";") + "m" + t + "\033[0m" +} + +func col(cfg Cfg) bool { + if os.Getenv("NO_COLOR") != "" || os.Getenv("BANTAM_NO_COLOR") != "" { return false } + switch strings.ToLower(cfg.Color) { + case "always": return true + case "never": return false + } + return isTerminal(int(os.Stdout.Fd())) +} + +type Message struct { + Role string `json:"role"` + Content *string `json:"content"` + ReasoningContent string `json:"reasoning_content,omitempty"` + ToolCalls []ToolCall `json:"tool_calls,omitempty"` + ToolCallID string `json:"tool_call_id,omitempty"` +} + +type ToolCall struct { + ID string `json:"id"` + Type string `json:"type"` + Function struct { + Name string `json:"name"` + Arguments string `json:"arguments"` + } `json:"function"` +} + +var TOOLS = []map[string]any{ + {"type": "function", "function": map[string]any{"name": "shell_exec", "description": "Run a shell command, return output and exit code.", "parameters": map[string]any{"type": "object", "properties": map[string]any{"command": map[string]any{"type": "string"}}, "required": []string{"command"}}}}, + {"type": "function", "function": map[string]any{"name": "run_subagent", "description": "Run a child agent with a prompt.", "parameters": map[string]any{"type": "object", "properties": map[string]any{"prompt": map[string]any{"type": "string"}}, "required": []string{"prompt"}}}}, +} + +func strp(s string) *string { return &s } + +type streamDelta struct { + Choices []struct { + Delta struct { + ReasoningContent string `json:"reasoning_content"` + Reasoning string `json:"reasoning"` + Content string `json:"content"` + ToolCalls []struct { + Index int `json:"index"` + ID string `json:"id"` + Function struct { + Name string `json:"name"` + Arguments string `json:"arguments"` + } `json:"function"` + } `json:"tool_calls"` + } `json:"delta"` + } `json:"choices"` +} + +func llm(cfg *Cfg, msgs []Message, tools []map[string]any) (Message, error) { + p := map[string]any{"model": cfg.Model, "temperature": cfg.Temperature, "messages": msgs, "stream": cfg.Stream} + if tools != nil { p["tools"] = tools } + body, _ := json.Marshal(p) + req, _ := http.NewRequest("POST", strings.TrimRight(cfg.Endpoint, "/")+"/chat/completions", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("User-Agent", "Mozilla/5.0 (compatible; Bantam/1.0)") + if cfg.APIKey != "" && cfg.APIKey != "-" { req.Header.Set("Authorization", "Bearer "+cfg.APIKey) } + client := &http.Client{Transport: &http.Transport{ + DialContext: (&net.Dialer{Timeout: time.Duration(cfg.Timeout) * time.Second}).DialContext, + ResponseHeaderTimeout: time.Duration(cfg.Timeout) * time.Second, + }} + fib := []int{1, 1, 2, 3, 5, 8, 13, 21, 34} + pend := c("...requesting...", 1, 2) + var resp *http.Response + var err error + for i := 0; i <= len(fib); i++ { + if COL { fmt.Print("\r" + pend) } else { fmt.Println(pend) } + resp, err = client.Do(req) + if err == nil && resp.StatusCode >= 400 { + b, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) + resp.Body.Close() + err = fmt.Errorf("HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(b))) + resp = nil + } + if err == nil { break } + if COL { fmt.Print("\r\033[K") } + if i < len(fib) { + fmt.Println(c(fmt.Sprintf("[network error: %v, retrying in %ds...]", err, fib[i]), 31)) + time.Sleep(time.Duration(fib[i]) * time.Second) + } + } + if err != nil { + if COL { fmt.Print("\r\033[K") } + return Message{}, err + } + defer resp.Body.Close() + if COL { fmt.Print("\r\033[K") } + if !cfg.Stream { + var cr struct { + Choices []struct { + Message Message `json:"message"` + } `json:"choices"` + } + if err := json.NewDecoder(resp.Body).Decode(&cr); err != nil { return Message{}, err } + if len(cr.Choices) == 0 { return Message{}, errors.New("empty choices in LLM response") } + return cr.Choices[0].Message, nil + } + return parseStream(resp.Body) +} + +func parseStream(r io.Reader) (Message, error) { + var content, reas string + var rh, ch bool + tcs := map[int]*ToolCall{} + var order []int + sc := bufio.NewScanner(r) + sc.Buffer(make([]byte, 0, 64*1024), 4*1024*1024) + var d streamDelta + for sc.Scan() { + ln := strings.TrimSpace(sc.Text()) + if !strings.HasPrefix(ln, "data:") { continue } + data := strings.TrimSpace(ln[5:]) + if data == "[DONE]" { break } + if json.Unmarshal([]byte(data), &d) != nil || len(d.Choices) == 0 { continue } + dl := d.Choices[0].Delta + rc := dl.ReasoningContent + if rc == "" { rc = dl.Reasoning } + if rc != "" { + if !rh { fmt.Println(c("--- reasoning start ---", 36)); rh = true } + fmt.Print(c(rc, 2)) + reas += rc + } + if dl.Content != "" { + if rh && !ch { fmt.Print("\n" + c("--- reasoning end ---", 36) + "\n\n") } + ch = true + fmt.Print(dl.Content) + content += dl.Content + } + for _, tc := range dl.ToolCalls { + t, ok := tcs[tc.Index] + if !ok { + t = &ToolCall{Type: "function"} + tcs[tc.Index] = t + order = append(order, tc.Index) + } + if tc.ID != "" { t.ID = tc.ID } + if tc.Function.Name != "" { t.Function.Name += tc.Function.Name } + if tc.Function.Arguments != "" { t.Function.Arguments += tc.Function.Arguments } + } + } + if rh && !ch { + fmt.Print("\n" + c("--- reasoning end ---", 36) + "\n") + } else if ch { + fmt.Print("\n") + } + m := Message{Role: "assistant"} + if content != "" { m.Content = strp(content) } + if reas != "" { m.ReasoningContent = reas } + if len(tcs) > 0 { + m.ToolCalls = make([]ToolCall, 0, len(order)) + for _, idx := range order { m.ToolCalls = append(m.ToolCalls, *tcs[idx]) } + } + return m, sc.Err() +} + +func shell(cmd string, timeout int) string { + ctx, cancel := context.WithTimeout(context.Background(), time.Duration(timeout)*time.Second) + defer cancel() + out, err := exec.CommandContext(ctx, "sh", "-c", cmd).CombinedOutput() + res := strings.TrimSpace(string(out)) + if ctx.Err() == context.DeadlineExceeded { + return fmt.Sprintf("%s\n\n[shell timeout after %ds]\nexit: -1", res, timeout) + } + code := 0 + if err != nil { + if ee, ok := err.(*exec.ExitError); ok { + code = ee.ExitCode() + } else { + code = -1 + } + } + return fmt.Sprintf("%s\n\nexit: %d", res, code) +} + +func last(msgs []Message) string { + for i := len(msgs) - 1; i >= 0; i-- { + if msgs[i].Role == "assistant" && msgs[i].Content != nil && *msgs[i].Content != "" { + return *msgs[i].Content + } + } + return "" +} + +func AL(cfg *Cfg, msgs []Message, sp string, depth int) ([]Message, error) { + done := false + for i := 0; i < cfg.MaxALIterations && !done; i++ { + m, err := llm(cfg, msgs, TOOLS) + if err != nil { return msgs, err } + msgs = append(msgs, m) + if !cfg.Stream { + if m.ReasoningContent != "" { + fmt.Println(c("--- reasoning start ---", 36) + "\n" + c(m.ReasoningContent, 2) + "\n" + c("--- reasoning end ---", 36)) + } + if m.Content != nil { fmt.Println(*m.Content) } + } + if len(m.ToolCalls) == 0 { done = true; break } + for _, tc := range m.ToolCalls { + fn, astr := tc.Function.Name, tc.Function.Arguments + fmt.Println(c(fmt.Sprintf("[tool call: %s(%s)]", fn, astr), 33)) + res, sty := "", 2 + var a map[string]any + if err := json.Unmarshal([]byte(astr), &a); err != nil || a == nil { + res, sty = fmt.Sprintf("[tool error: invalid JSON args for %s: %v. Raw: %q]", fn, err, astr), 31 + } else { + switch fn { + case "shell_exec": + cmd, _ := a["command"].(string) + res = shell(cmd, cfg.ShellTimeout) + case "run_subagent": + pr, _ := a["prompt"].(string) + if depth >= MAX_DEPTH { + res, sty = fmt.Sprintf("[subagent depth limit (%d) reached, child not spawned]", MAX_DEPTH), 31 + } else if subr, err := AL(cfg, []Message{{Role: "system", Content: strp(sp + "\n\nImportant: this is a child agent")}, {Role: "user", Content: strp(pr)}}, sp, depth+1); err != nil { + res, sty = "[subagent error: "+err.Error()+"]", 31 + } else { + res, sty = last(subr), 2 + } + default: + res, sty = "Unknown tool: "+fn, 31 + } + } + fmt.Println(c("[tool result: "+fn+"]", 32) + "\n" + c(res, sty) + "\n") + msgs = append(msgs, Message{Role: "tool", ToolCallID: tc.ID, Content: strp(res)}) + } + } + if !done { + msgs = append(msgs, Message{Role: "assistant", Content: strp(fmt.Sprintf("[max AL iterations (%d) reached]", cfg.MaxALIterations))}) + } + return msgs, nil +} + +func homeDir() string { + if h, err := os.UserHomeDir(); err == nil && h != "" { + return h + } + return "." +} + +func sdir() string { + d := filepath.Join(homeDir(), ".bantam", "sessions") + os.MkdirAll(d, 0755) + return d +} + +type Session struct { + ID string `json:"id"` + Created string `json:"created"` + Summary string `json:"summary"` + Messages []Message `json:"messages"` +} + +func summary(msgs []Message) string { + for _, m := range msgs { + if m.Role == "user" && m.Content != nil && strings.TrimSpace(*m.Content) != "" { + t := strings.Join(strings.Fields(*m.Content), " ") + if len(t) > 80 { t = t[:80] + "..." } + return t + } + } + return "(empty session)" +} + +func fileExists(p string) bool { + _, err := os.Stat(p) + return err == nil +} + +func saveSession(msgs []Message) (string, string) { + d := sdir() + base := time.Now().Format("20060102-150405") + sid, path := base, filepath.Join(d, base+".json") + for i := 1; fileExists(path); i++ { + sid = fmt.Sprintf("%s-%d", base, i) + path = filepath.Join(d, sid+".json") + } + s := Session{sid, time.Now().Format("2006-01-02 15:04:05"), summary(msgs), msgs} + b, _ := json.MarshalIndent(s, "", " ") + os.WriteFile(path, b, 0644) + return sid, s.Summary +} + +func sessions() []Session { + out := []Session{} + d := filepath.Join(homeDir(), ".bantam", "sessions") + entries, err := os.ReadDir(d) + if err != nil { + return out + } + for _, e := range entries { + if e.IsDir() || !strings.HasSuffix(e.Name(), ".json") { continue } + b, err := os.ReadFile(filepath.Join(d, e.Name())) + if err != nil { continue } + var s Session + if json.Unmarshal(b, &s) != nil { continue } + if s.ID == "" { s.ID = strings.TrimSuffix(e.Name(), ".json") } + out = append(out, s) + } + sort.Slice(out, func(i, j int) bool { return out[i].ID > out[j].ID }) + return out +} + +func loadSession(sid string) ([]Message, error) { + ss := sessions() + for i := range ss { + if ss[i].ID == sid { return ss[i].Messages, nil } + } + var pref []*Session + for i := range ss { + if strings.HasPrefix(ss[i].ID, sid) { pref = append(pref, &ss[i]) } + } + if len(pref) == 1 { return pref[0].Messages, nil } + if len(pref) > 1 { + names := make([]string, len(pref)) + for i, s := range pref { names[i] = s.ID } + return nil, fmt.Errorf("ambiguous prefix: %s", strings.Join(names, ", ")) + } + return nil, fmt.Errorf("session not found: %s", sid) +} + +func autosave(msgs []Message) { + s := Session{"autosave", time.Now().Format("2006-01-02 15:04:05"), summary(msgs), msgs} + b, _ := json.MarshalIndent(s, "", " ") + os.WriteFile(filepath.Join(sdir(), "autosave.json"), b, 0644) +} + +func summarize(cfg *Cfg, msgs []Message) (string, error) { + var sb strings.Builder + for _, m := range msgs { + if m.Role == "system" { continue } + ct := "" + if m.Content != nil { ct = *m.Content } + if ct == "" && len(m.ToolCalls) > 0 { + jc := make([]map[string]any, 0, len(m.ToolCalls)) + for _, tc := range m.ToolCalls { + jc = append(jc, map[string]any{"function": map[string]any{"name": tc.Function.Name, "arguments": tc.Function.Arguments}}) + } + b, _ := json.Marshal(jc) + ct = string(b) + } + if ct == "" { continue } + if len(ct) > 4000 { ct = ct[:4000] + "...[truncated]" } + sb.WriteString(m.Role + ": " + ct + "\n\n") + } + if sb.Len() == 0 { return "", errors.New("no conversation to summarize") } + joined := sb.String() + if len(joined) > 100000 { joined = joined[len(joined)-100000:] + "\n...[earlier parts truncated]" } + sys := "You are a conversation summarizer for an AI agent's context window. Summarize concisely but completely, preserving all important facts, decisions, code, errors, and the current task state, so the agent can continue the work without the original messages. Output only the summary." + cc := *cfg + cc.Stream = false + m, err := llm(&cc, []Message{{Role: "system", Content: strp(sys)}, {Role: "user", Content: strp("Summarize this conversation:\n\n" + joined)}}, nil) + if err != nil { return "", err } + s := "" + if m.Content != nil { s = *m.Content } + if s == "" { s = m.ReasoningContent } + if strings.TrimSpace(s) == "" { return "", errors.New("LLM returned an empty summary") } + return strings.TrimSpace(s), nil +} + +func compact(cfg *Cfg, msgs []Message) ([]Message, string, error) { + if len(msgs) == 0 || msgs[0].Role != "system" { + return msgs, "", errors.New("session has no system message") + } + s, err := summarize(cfg, msgs) + if err != nil { return msgs, "", err } + return []Message{{Role: "system", Content: msgs[0].Content}, {Role: "user", Content: strp("Summary of the previous conversation:\n" + s + "\n\nPlease continue from here.")}}, s, nil +} + +func loadHistory() { + histF = filepath.Join(homeDir(), ".bantam_history") + b, err := os.ReadFile(histF) + if err != nil { return } + for _, ln := range strings.Split(string(b), "\n") { + if ln = strings.TrimRight(ln, "\r"); ln != "" { hist = append(hist, ln) } + } +} + +func addHistory(s string) { + if s == "" || (len(hist) > 0 && hist[len(hist)-1] == s) { return } + hist = append(hist, s) +} + +func saveHistory() { + os.WriteFile(histF, []byte(strings.Join(hist, "\n")+"\n"), 0644) +} + +func visibleLen(s string) int { + n := 0 + for i := 0; i < len(s); { + switch s[i] { + case 0x1b: + j := i + 1 + if j < len(s) && s[j] == '[' { + j++ + for j < len(s) && !(s[j] >= 0x40 && s[j] <= 0x7e) { j++ } + if j < len(s) { j++ } + i = j + } else { + i++ + } + case 0x01, 0x02: + i++ + default: + _, size := utf8.DecodeRuneInString(s[i:]) + n++ + i += size + } + } + return n +} + +type editor struct { + prompt string + buf []rune + pos int + hist []string + hpos int + draft string + crow int +} + +func textPos(promptLen, W int, s string, pos int) (row, col int) { + row, col = 0, promptLen + pend := false + for i, r := range []rune(s) { + if i == pos { return row, col } + if r == '\n' { + row++ + col = 0 + pend = false + continue + } + if pend { + row++ + col = 0 + pend = false + } + if col == W-1 { + pend = true + } else { + col++ + } + } + return row, col +} + +func (e *editor) draw() { + W := termWidth() + s := string(e.buf) + P := visibleLen(e.prompt) + er, _ := textPos(P, W, s, len([]rune(s))) + pr, pc := textPos(P, W, s, e.pos) + if e.crow > 0 { fmt.Printf("\033[%dA", e.crow) } + fmt.Print("\r\033[J" + e.prompt + s) + if up := er - pr; up > 0 { fmt.Printf("\033[%dA", up) } + fmt.Print("\r") + if pc > 0 { fmt.Printf("\033[%dC", pc) } + e.crow = pr +} + +func (e *editor) histNav(up bool) { + if up { + if len(e.hist) == 0 { return } + if e.hpos < 0 { + e.draft = string(e.buf) + e.hpos = len(e.hist) - 1 + } else if e.hpos > 0 { + e.hpos-- + } + e.buf = []rune(e.hist[e.hpos]) + } else { + if e.hpos < 0 { return } + e.hpos++ + if e.hpos >= len(e.hist) { + e.hpos = -1 + e.buf = []rune(e.draft) + } else { + e.buf = []rune(e.hist[e.hpos]) + } + } + e.pos = len(e.buf) +} + +func readPlain(prompt string) (string, bool) { + fmt.Print(prompt) + line, err := stdin.ReadString('\n') + if err != nil && line == "" { return "", false } + return strings.TrimRight(line, "\r\n"), true +} + +func readLine(prompt string) (string, bool) { + if !isTerminal(int(os.Stdin.Fd())) { return readPlain(prompt) } + restore, err := makeRaw(int(os.Stdin.Fd())) + if err != nil { return readPlain(prompt) } + defer restore() + e := &editor{prompt: prompt, hpos: -1, hist: hist} + for { + e.draw() + rn, _, err := stdin.ReadRune() + if err != nil { + fmt.Println() + return "", false + } + switch rn { + case '\r': + fmt.Println() + return string(e.buf), true + case '\n': + e.buf = append(e.buf, 0) + copy(e.buf[e.pos+1:], e.buf[e.pos:]) + e.buf[e.pos] = '\n' + e.pos++ + case 0x03: + fmt.Println() + return "", false + case 0x04: + if len(e.buf) == 0 { + fmt.Println() + return "", false + } + case 0x7f, 0x08: + if e.pos > 0 { + e.buf = append(e.buf[:e.pos-1], e.buf[e.pos:]...) + e.pos-- + } + case 0x1b: + b1, err1 := stdin.ReadByte() + b2, err2 := stdin.ReadByte() + if err1 != nil || err2 != nil { continue } + if b1 == '[' { + switch b2 { + case 'A': + e.histNav(true) + case 'B': + e.histNav(false) + case 'C': + if e.pos < len(e.buf) { e.pos++ } + case 'D': + if e.pos > 0 { e.pos-- } + } + } + default: + if rn >= 32 { + e.buf = append(e.buf, 0) + copy(e.buf[e.pos+1:], e.buf[e.pos:]) + e.buf[e.pos] = rn + e.pos++ + } + } + } +} + +func main() { + sp := prompt("system.txt") + cfg := getCfg("model.cfg") + COL = col(cfg) + stdin = bufio.NewReader(os.Stdin) + msgs := []Message{{Role: "system", Content: strp(sp)}} + if len(os.Args) > 1 && os.Args[1] != "" { + p := os.Args[1] + data, err := os.ReadFile(p) + if err != nil { + fmt.Println(c("Error: file '"+p+"' not found.", 31)) + os.Exit(1) + } + msgs = append(msgs, Message{Role: "user", Content: strp(strings.TrimSpace(string(data)))}) + if msgs, err = AL(&cfg, msgs, sp, 0); err != nil { + fmt.Println(c("[error: "+err.Error()+"]", 31)) + os.Exit(1) + } + autosave(msgs) + return + } + loadHistory() + fmt.Println(c("Bantam Agent ready", 1, 32) + c(" (Ctrl+J = new line)", 2)) + for { + u, ok := readLine(c("> ", 1, 36)) + if !ok { + fmt.Println() + break + } + u = strings.TrimSpace(u) + if u == "" { continue } + addHistory(u) + switch { + case u == "/quit": + goto done + case u == "/clear": + msgs = []Message{{Role: "system", Content: strp(sp)}} + autosave(msgs) + continue + case u == "/save": + sid, sm := saveSession(msgs) + fmt.Println(c("[session saved: "+sid+"]", 32) + " " + c(sm, 2)) + continue + case u == "/list": + ss := sessions() + if len(ss) == 0 { + fmt.Println(c("No sessions saved yet.", 33)) + continue + } + for _, s := range ss { + mk := c(" (autosave)", 33) + if s.ID != "autosave" { mk = "" } + fmt.Println(c(s.ID, 32) + mk + c(fmt.Sprintf(" %s [%d msgs]", s.Created, len(s.Messages)), 2)) + fmt.Println(" " + c(s.Summary, 2)) + } + continue + case strings.HasPrefix(u, "/load"): + parts := strings.Fields(u) + if len(parts) < 2 { + fmt.Println(c("Usage: /load ", 31)) + continue + } + lm, err := loadSession(parts[1]) + if err != nil { + fmt.Println(c("Session not found: "+err.Error(), 31)) + continue + } + msgs = lm + autosave(msgs) + fmt.Println(c("[session loaded: "+parts[1]+"]", 32) + " " + c(summary(msgs), 2)) + continue + case u == "/compact": + if len(msgs) <= 1 { + fmt.Println(c("Nothing to compact yet.", 33)) + continue + } + fmt.Println(c("[compacting conversation...]", 33)) + nm, sm, err := compact(&cfg, msgs) + if err != nil { + fmt.Println(c("[compact failed: "+err.Error()+"]", 31)) + continue + } + msgs = nm + autosave(msgs) + fmt.Println(c(fmt.Sprintf("[compacted to %d messages]", len(msgs)), 32)) + fmt.Println(c("--- summary ---", 33) + "\n" + c(sm, 2)) + continue + case u == "/help": + fmt.Println(c("Bantam commands:", 1, 36)) + for _, kv := range [][2]string{{"/quit", "quit"}, {"/clear", "reset to system prompt"}, {"/save", "save session"}, {"/list", "list sessions"}, {"/load ", "load session"}, {"/compact", "compact context"}, {"/help", "show help"}} { + fmt.Println(c(fmt.Sprintf("%-12s", kv[0]), 1, 32) + kv[1]) + } + continue + } + msgs = append(msgs, Message{Role: "user", Content: strp(u)}) + var err error + if msgs, err = AL(&cfg, msgs, sp, 0); err != nil { + fmt.Println(c("[error: "+err.Error()+"]", 31)) + } + autosave(msgs) + } +done: + autosave(msgs) + saveHistory() +} diff --git a/model.cfg b/model.cfg index c547157..d986281 100644 --- a/model.cfg +++ b/model.cfg @@ -5,7 +5,7 @@ api_key=- stream=true # Optional tuning (defaults shown): -# timeout=60 +# timeout=300 # shell_timeout=120 # max_al_iterations=1000 color=auto diff --git a/term_darwin.go b/term_darwin.go new file mode 100644 index 0000000..c0ea986 --- /dev/null +++ b/term_darwin.go @@ -0,0 +1,42 @@ +//go:build darwin + +package main + +import ( + "os" + "syscall" + "unsafe" +) + +func termWidth() int { + var ws struct{ Row, Col, Xpixel, Ypixel uint16 } + _, _, e := syscall.Syscall(syscall.SYS_IOCTL, uintptr(os.Stdin.Fd()), syscall.TIOCGWINSZ, uintptr(unsafe.Pointer(&ws))) + if e != 0 || ws.Col == 0 { + return 80 + } + return int(ws.Col) +} + +func isTerminal(fd int) bool { + var t syscall.Termios + _, _, e := syscall.Syscall(syscall.SYS_IOCTL, uintptr(fd), syscall.TIOCGETA, uintptr(unsafe.Pointer(&t))) + return e == 0 +} + +func makeRaw(fd int) (func(), error) { + var old syscall.Termios + if _, _, e := syscall.Syscall(syscall.SYS_IOCTL, uintptr(fd), syscall.TIOCGETA, uintptr(unsafe.Pointer(&old))); e != 0 { + return nil, e + } + raw := old + raw.Iflag &^= syscall.ICRNL | syscall.IXON | syscall.BRKINT | syscall.INPCK | syscall.ISTRIP + raw.Oflag &^= syscall.OPOST + raw.Lflag &^= syscall.ECHO | syscall.ICANON | syscall.ISIG | syscall.IEXTEN + raw.Cflag |= syscall.CS8 + if _, _, e := syscall.Syscall(syscall.SYS_IOCTL, uintptr(fd), syscall.TIOCSETA, uintptr(unsafe.Pointer(&raw))); e != 0 { + return nil, e + } + return func() { + syscall.Syscall(syscall.SYS_IOCTL, uintptr(fd), syscall.TIOCSETA, uintptr(unsafe.Pointer(&old))) + }, nil +} diff --git a/term_linux.go b/term_linux.go new file mode 100644 index 0000000..d5edcd7 --- /dev/null +++ b/term_linux.go @@ -0,0 +1,42 @@ +//go:build linux + +package main + +import ( + "os" + "syscall" + "unsafe" +) + +func termWidth() int { + var ws struct{ Row, Col, Xpixel, Ypixel uint16 } + _, _, e := syscall.Syscall(syscall.SYS_IOCTL, uintptr(os.Stdin.Fd()), syscall.TIOCGWINSZ, uintptr(unsafe.Pointer(&ws))) + if e != 0 || ws.Col == 0 { + return 80 + } + return int(ws.Col) +} + +func isTerminal(fd int) bool { + var t syscall.Termios + _, _, e := syscall.Syscall(syscall.SYS_IOCTL, uintptr(fd), syscall.TCGETS, uintptr(unsafe.Pointer(&t))) + return e == 0 +} + +func makeRaw(fd int) (func(), error) { + var old syscall.Termios + if _, _, e := syscall.Syscall(syscall.SYS_IOCTL, uintptr(fd), syscall.TCGETS, uintptr(unsafe.Pointer(&old))); e != 0 { + return nil, e + } + raw := old + raw.Iflag &^= syscall.ICRNL | syscall.IXON | syscall.BRKINT | syscall.INPCK | syscall.ISTRIP + raw.Oflag &^= syscall.OPOST + raw.Lflag &^= syscall.ECHO | syscall.ICANON | syscall.ISIG | syscall.IEXTEN + raw.Cflag |= syscall.CS8 + if _, _, e := syscall.Syscall(syscall.SYS_IOCTL, uintptr(fd), syscall.TCSETS, uintptr(unsafe.Pointer(&raw))); e != 0 { + return nil, e + } + return func() { + syscall.Syscall(syscall.SYS_IOCTL, uintptr(fd), syscall.TCSETS, uintptr(unsafe.Pointer(&old))) + }, nil +} diff --git a/term_other.go b/term_other.go new file mode 100644 index 0000000..6d67a82 --- /dev/null +++ b/term_other.go @@ -0,0 +1,13 @@ +//go:build !linux && !darwin && !windows + +package main + +import "fmt" + +func termWidth() int { return 80 } + +func isTerminal(fd int) bool { return false } + +func makeRaw(fd int) (func(), error) { + return nil, fmt.Errorf("raw terminal not supported on this platform") +} diff --git a/term_windows.go b/term_windows.go new file mode 100644 index 0000000..17a0425 --- /dev/null +++ b/term_windows.go @@ -0,0 +1,11 @@ +//go:build windows + +package main + +import "errors" + +func termWidth() int { return 80 } + +func isTerminal(fd int) bool { return false } + +func makeRaw(fd int) (func(), error) { return nil, errors.New("raw terminal not supported on windows") }