From ebc62b93bfd8da5ecf123cdc65fae3108e98beeb Mon Sep 17 00:00:00 2001 From: Luxferre Date: Fri, 18 Sep 2026 21:08:15 +0300 Subject: [PATCH] changed session id algo --- main.go | 4922 +++++++++++++++++++++++++++---------------------------- 1 file changed, 2461 insertions(+), 2461 deletions(-) diff --git a/main.go b/main.go index da8027b..06e525e 100644 --- a/main.go +++ b/main.go @@ -4,86 +4,86 @@ package main import ( - "bufio" - "bytes" - "context" - "crypto/md5" - "crypto/rand" - "encoding/hex" - "encoding/json" - "errors" - "fmt" - "io" - "net" - "net/http" - "net/url" - "os" - "os/exec" - "os/signal" - "path/filepath" - "regexp" - "sort" - "strconv" - "strings" - "sync" - "time" - "unicode" - "unicode/utf8" + "bufio" + "bytes" + "context" + "crypto/md5" + "crypto/rand" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "net" + "net/http" + "net/url" + "os" + "os/exec" + "os/signal" + "path/filepath" + "regexp" + "sort" + "strconv" + "strings" + "sync" + "time" + "unicode" + "unicode/utf8" ) 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 + 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 - ContextWindow int - MaxToolRes int - Raw map[string]string + Endpoint string + Model string + APIKey string + Temperature float64 + Timeout int + ShellTimeout int + MaxALIterations int + Stream bool + Color string + ContextWindow int + MaxToolRes int + Raw map[string]string } // internalKey reports whether a model.cfg key is an agent-internal parameter // that must never be forwarded to the chat completions API. func internalKey(k string) bool { - switch k { - case "endpoint", "model", "temperature", "stream", "api_key", "timeout", - "shell_timeout", "max_al_iterations", "color", "context_window", "max_tool_res", - "bantam_tools_dir", "bantam_skills_dir": - return true - } - return false + switch k { + case "endpoint", "model", "temperature", "stream", "api_key", "timeout", + "shell_timeout", "max_al_iterations", "color", "context_window", "max_tool_res", + "bantam_tools_dir", "bantam_skills_dir": + return true + } + return false } func proxyFromEnv(req *http.Request) (*url.URL, error) { - socks := strings.TrimSpace(os.Getenv("SOCKS_PROXY")) - if socks == "" { - socks = strings.TrimSpace(os.Getenv("socks_proxy")) - } - if socks != "" { - if !strings.Contains(socks, "://") { - socks = "socks5://" + socks - } - return url.Parse(socks) - } - return http.ProxyFromEnvironment(req) + socks := strings.TrimSpace(os.Getenv("SOCKS_PROXY")) + if socks == "" { + socks = strings.TrimSpace(os.Getenv("socks_proxy")) + } + if socks != "" { + if !strings.Contains(socks, "://") { + socks = "socks5://" + socks + } + return url.Parse(socks) + } + return http.ProxyFromEnvironment(req) } // llmTransport is a shared HTTP transport reused across all LLM calls so that // connections are pooled instead of recreated per request. var llmTransport = &http.Transport{ - Proxy: proxyFromEnv, - DialContext: (&net.Dialer{Timeout: 300 * time.Second}).DialContext, + Proxy: proxyFromEnv, + DialContext: (&net.Dialer{Timeout: 300 * time.Second}).DialContext, } var defCfg = Cfg{"https://api.kilo.ai/api/openrouter", "openrouter/free", "-", 0.7, 300, 120, 1000, true, "auto", 262144, 15000, map[string]string{"reasoning_effort": "high"}} @@ -98,87 +98,87 @@ const opencodeProjectID = "global" const opencodeTailAlphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" func atoiD(s string, d int) int { - if v, e := strconv.Atoi(strings.TrimSpace(s)); e == nil { - return v - } - return d + if v, e := strconv.Atoi(strings.TrimSpace(s)); e == nil { + return v + } + return d } func queryModelsContextWindow(cfg *Cfg) int { - client := &http.Client{ - Transport: &http.Transport{ - Proxy: proxyFromEnv, - DialContext: (&net.Dialer{Timeout: 3 * time.Second}).DialContext, - }, - Timeout: 3 * time.Second, - } - req, err := http.NewRequest("GET", strings.TrimRight(cfg.Endpoint, "/")+"/models", nil) - if err != nil { - return 0 - } - applyLLMHeaders(req, cfg, ocRequestID()) - resp, err := client.Do(req) - if err != nil || resp.StatusCode >= 400 { - return 0 - } - defer resp.Body.Close() - var res struct { - Data []map[string]any `json:"data"` - Models []map[string]any `json:"models"` - } - if json.NewDecoder(resp.Body).Decode(&res) != nil { - return 0 - } - list := res.Data - if len(list) == 0 { - list = res.Models - } - for _, item := range list { - id, _ := item["id"].(string) - if id == cfg.Model || strings.EqualFold(id, cfg.Model) { - for _, key := range []string{"context_window", "context_length", "max_context_length", "max_model_len", "context_size", "max_tokens", "max_input_tokens"} { - if val, ok := item[key]; ok { - switch v := val.(type) { - case float64: - if v > 0 { - return int(v) - } - case string: - if n := atoiD(v, 0); n > 0 { - return n - } - } - } - } - } - } - return 0 + client := &http.Client{ + Transport: &http.Transport{ + Proxy: proxyFromEnv, + DialContext: (&net.Dialer{Timeout: 3 * time.Second}).DialContext, + }, + Timeout: 3 * time.Second, + } + req, err := http.NewRequest("GET", strings.TrimRight(cfg.Endpoint, "/")+"/models", nil) + if err != nil { + return 0 + } + applyLLMHeaders(req, cfg, ocRequestID()) + resp, err := client.Do(req) + if err != nil || resp.StatusCode >= 400 { + return 0 + } + defer resp.Body.Close() + var res struct { + Data []map[string]any `json:"data"` + Models []map[string]any `json:"models"` + } + if json.NewDecoder(resp.Body).Decode(&res) != nil { + return 0 + } + list := res.Data + if len(list) == 0 { + list = res.Models + } + for _, item := range list { + id, _ := item["id"].(string) + if id == cfg.Model || strings.EqualFold(id, cfg.Model) { + for _, key := range []string{"context_window", "context_length", "max_context_length", "max_model_len", "context_size", "max_tokens", "max_input_tokens"} { + if val, ok := item[key]; ok { + switch v := val.(type) { + case float64: + if v > 0 { + return int(v) + } + case string: + if n := atoiD(v, 0); n > 0 { + return n + } + } + } + } + } + } + return 0 } var cwCacheMu sync.Mutex var cwCache = map[string]int{} func fetchContextWindow(cfg *Cfg) int { - // Only values discovered from the /models endpoint are cached, keyed by - // endpoint+model. The context_window-override and 262144 default are derived - // per call from cfg so they never shadow each other across configs. - key := cfg.Endpoint + "\x00" + cfg.Model - cwCacheMu.Lock() - if cw, ok := cwCache[key]; ok { - cwCacheMu.Unlock() - return cw - } - cwCacheMu.Unlock() - if cw := queryModelsContextWindow(cfg); cw > 0 { - cwCacheMu.Lock() - cwCache[key] = cw - cwCacheMu.Unlock() - return cw - } - if v, ok := cfg.Raw["context_window"]; ok { - return atoiD(v, 262144) - } - return 262144 + // Only values discovered from the /models endpoint are cached, keyed by + // endpoint+model. The context_window-override and 262144 default are derived + // per call from cfg so they never shadow each other across configs. + key := cfg.Endpoint + "\x00" + cfg.Model + cwCacheMu.Lock() + if cw, ok := cwCache[key]; ok { + cwCacheMu.Unlock() + return cw + } + cwCacheMu.Unlock() + if cw := queryModelsContextWindow(cfg); cw > 0 { + cwCacheMu.Lock() + cwCache[key] = cw + cwCacheMu.Unlock() + return cw + } + if v, ok := cfg.Raw["context_window"]; ok { + return atoiD(v, 262144) + } + return 262144 } // listModels queries the /models path on the configured inference endpoint and @@ -186,232 +186,232 @@ func fetchContextWindow(cfg *Cfg) int { // configured model is marked with a leading "* ". On transport or HTTP errors a // non-nil error is returned so the caller can surface it. func listModels(cfg *Cfg) (string, error) { - t := cfg.Timeout - if t < 10 { - t = 10 - } - client := &http.Client{ - Transport: &http.Transport{ - Proxy: proxyFromEnv, - DialContext: (&net.Dialer{Timeout: time.Duration(t) * time.Second}).DialContext, - }, - Timeout: time.Duration(t) * time.Second, - } - req, err := http.NewRequest("GET", strings.TrimRight(cfg.Endpoint, "/")+"/models", nil) - if err != nil { - return "", err - } - applyLLMHeaders(req, cfg, ocRequestID()) - resp, err := client.Do(req) - if err != nil { - return "", err - } - defer resp.Body.Close() - if resp.StatusCode >= 400 { - return "", fmt.Errorf("HTTP %d from /models", resp.StatusCode) - } - var res struct { - Data []map[string]any `json:"data"` - Models []map[string]any `json:"models"` - } - if err := json.NewDecoder(resp.Body).Decode(&res); err != nil { - return "", fmt.Errorf("failed to parse /models response: %v", err) - } - list := res.Data - if len(list) == 0 { - list = res.Models - } - if len(list) == 0 { - return "No models returned by the endpoint.", nil - } - var b strings.Builder - for _, item := range list { - id, _ := item["id"].(string) - if id == "" { - continue - } - if id == cfg.Model || strings.EqualFold(id, cfg.Model) { - b.WriteString("* " + id + "\n") - } else { - b.WriteString(" " + id + "\n") - } - } - return b.String(), nil + t := cfg.Timeout + if t < 10 { + t = 10 + } + client := &http.Client{ + Transport: &http.Transport{ + Proxy: proxyFromEnv, + DialContext: (&net.Dialer{Timeout: time.Duration(t) * time.Second}).DialContext, + }, + Timeout: time.Duration(t) * time.Second, + } + req, err := http.NewRequest("GET", strings.TrimRight(cfg.Endpoint, "/")+"/models", nil) + if err != nil { + return "", err + } + applyLLMHeaders(req, cfg, ocRequestID()) + resp, err := client.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + if resp.StatusCode >= 400 { + return "", fmt.Errorf("HTTP %d from /models", resp.StatusCode) + } + var res struct { + Data []map[string]any `json:"data"` + Models []map[string]any `json:"models"` + } + if err := json.NewDecoder(resp.Body).Decode(&res); err != nil { + return "", fmt.Errorf("failed to parse /models response: %v", err) + } + list := res.Data + if len(list) == 0 { + list = res.Models + } + if len(list) == 0 { + return "No models returned by the endpoint.", nil + } + var b strings.Builder + for _, item := range list { + id, _ := item["id"].(string) + if id == "" { + continue + } + if id == cfg.Model || strings.EqualFold(id, cfg.Model) { + b.WriteString("* " + id + "\n") + } else { + b.WriteString(" " + id + "\n") + } + } + return b.String(), nil } func parseCfgFile(path string, cfg *Cfg) { - 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) - cfg.Raw[k] = 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 - case "context_window": - cfg.ContextWindow = atoiD(v, cfg.ContextWindow) - case "max_tool_res": - cfg.MaxToolRes = atoiD(v, cfg.MaxToolRes) - } - } - } + 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) + cfg.Raw[k] = 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 + case "context_window": + cfg.ContextWindow = atoiD(v, cfg.ContextWindow) + case "max_tool_res": + cfg.MaxToolRes = atoiD(v, cfg.MaxToolRes) + } + } + } } func applyEnvCfg(cfg *Cfg) { - if v := strings.TrimSpace(os.Getenv("BANTAM_ENDPOINT")); v != "" { - cfg.Endpoint = v - cfg.Raw["endpoint"] = v - } - if v := strings.TrimSpace(os.Getenv("BANTAM_MODEL")); v != "" { - cfg.Model = v - cfg.Raw["model"] = v - } - if v := strings.TrimSpace(os.Getenv("BANTAM_TEMP")); v != "" { - if f, e := strconv.ParseFloat(v, 64); e == nil { - cfg.Temperature = f - cfg.Raw["temperature"] = v - } - } else if v := strings.TrimSpace(os.Getenv("BANTAM_TEMPERATURE")); v != "" { - if f, e := strconv.ParseFloat(v, 64); e == nil { - cfg.Temperature = f - cfg.Raw["temperature"] = v - } - } - if v := strings.TrimSpace(os.Getenv("BANTAM_STREAM")); v != "" { - cfg.Stream = v == "true" || v == "1" || v == "yes" - cfg.Raw["stream"] = strconv.FormatBool(cfg.Stream) - } - if v := strings.TrimSpace(os.Getenv("BANTAM_COLOR")); v != "" { - cfg.Color = v - cfg.Raw["color"] = v - } - if v := strings.TrimSpace(os.Getenv("BANTAM_TIMEOUT")); v != "" { - cfg.Timeout = atoiD(v, cfg.Timeout) - cfg.Raw["timeout"] = strconv.Itoa(cfg.Timeout) - } - if v := strings.TrimSpace(os.Getenv("BANTAM_SHELL_TIMEOUT")); v != "" { - cfg.ShellTimeout = atoiD(v, cfg.ShellTimeout) - cfg.Raw["shell_timeout"] = strconv.Itoa(cfg.ShellTimeout) - } - if v := strings.TrimSpace(os.Getenv("BANTAM_MAX_AL_ITERATIONS")); v != "" { - cfg.MaxALIterations = atoiD(v, cfg.MaxALIterations) - cfg.Raw["max_al_iterations"] = strconv.Itoa(cfg.MaxALIterations) - } - if v := strings.TrimSpace(os.Getenv("BANTAM_CONTEXT_WINDOW")); v != "" { - cfg.ContextWindow = atoiD(v, cfg.ContextWindow) - cfg.Raw["context_window"] = strconv.Itoa(cfg.ContextWindow) - } - if v := strings.TrimSpace(os.Getenv("BANTAM_MAX_TOOL_RES")); v != "" { - cfg.MaxToolRes = atoiD(v, cfg.MaxToolRes) - cfg.Raw["max_tool_res"] = strconv.Itoa(cfg.MaxToolRes) - } - if v := strings.TrimSpace(os.Getenv("BANTAM_REASONING_EFFORT")); v != "" { - cfg.Raw["reasoning_effort"] = v - } - if v := strings.TrimSpace(os.Getenv("BANTAM_TOOLS_DIR")); v != "" { - cfg.Raw["bantam_tools_dir"] = v - } - if v := strings.TrimSpace(os.Getenv("BANTAM_SKILLS_DIR")); v != "" { - cfg.Raw["bantam_skills_dir"] = v - } + if v := strings.TrimSpace(os.Getenv("BANTAM_ENDPOINT")); v != "" { + cfg.Endpoint = v + cfg.Raw["endpoint"] = v + } + if v := strings.TrimSpace(os.Getenv("BANTAM_MODEL")); v != "" { + cfg.Model = v + cfg.Raw["model"] = v + } + if v := strings.TrimSpace(os.Getenv("BANTAM_TEMP")); v != "" { + if f, e := strconv.ParseFloat(v, 64); e == nil { + cfg.Temperature = f + cfg.Raw["temperature"] = v + } + } else if v := strings.TrimSpace(os.Getenv("BANTAM_TEMPERATURE")); v != "" { + if f, e := strconv.ParseFloat(v, 64); e == nil { + cfg.Temperature = f + cfg.Raw["temperature"] = v + } + } + if v := strings.TrimSpace(os.Getenv("BANTAM_STREAM")); v != "" { + cfg.Stream = v == "true" || v == "1" || v == "yes" + cfg.Raw["stream"] = strconv.FormatBool(cfg.Stream) + } + if v := strings.TrimSpace(os.Getenv("BANTAM_COLOR")); v != "" { + cfg.Color = v + cfg.Raw["color"] = v + } + if v := strings.TrimSpace(os.Getenv("BANTAM_TIMEOUT")); v != "" { + cfg.Timeout = atoiD(v, cfg.Timeout) + cfg.Raw["timeout"] = strconv.Itoa(cfg.Timeout) + } + if v := strings.TrimSpace(os.Getenv("BANTAM_SHELL_TIMEOUT")); v != "" { + cfg.ShellTimeout = atoiD(v, cfg.ShellTimeout) + cfg.Raw["shell_timeout"] = strconv.Itoa(cfg.ShellTimeout) + } + if v := strings.TrimSpace(os.Getenv("BANTAM_MAX_AL_ITERATIONS")); v != "" { + cfg.MaxALIterations = atoiD(v, cfg.MaxALIterations) + cfg.Raw["max_al_iterations"] = strconv.Itoa(cfg.MaxALIterations) + } + if v := strings.TrimSpace(os.Getenv("BANTAM_CONTEXT_WINDOW")); v != "" { + cfg.ContextWindow = atoiD(v, cfg.ContextWindow) + cfg.Raw["context_window"] = strconv.Itoa(cfg.ContextWindow) + } + if v := strings.TrimSpace(os.Getenv("BANTAM_MAX_TOOL_RES")); v != "" { + cfg.MaxToolRes = atoiD(v, cfg.MaxToolRes) + cfg.Raw["max_tool_res"] = strconv.Itoa(cfg.MaxToolRes) + } + if v := strings.TrimSpace(os.Getenv("BANTAM_REASONING_EFFORT")); v != "" { + cfg.Raw["reasoning_effort"] = v + } + if v := strings.TrimSpace(os.Getenv("BANTAM_TOOLS_DIR")); v != "" { + cfg.Raw["bantam_tools_dir"] = v + } + if v := strings.TrimSpace(os.Getenv("BANTAM_SKILLS_DIR")); v != "" { + cfg.Raw["bantam_skills_dir"] = v + } } func getCfg(path string) Cfg { - cfg := defCfg - cfg.Raw = map[string]string{ - "endpoint": cfg.Endpoint, "model": cfg.Model, "temperature": fmt.Sprintf("%v", cfg.Temperature), - "api_key": cfg.APIKey, "stream": strconv.FormatBool(cfg.Stream), "color": cfg.Color, - "timeout": strconv.Itoa(cfg.Timeout), "shell_timeout": strconv.Itoa(cfg.ShellTimeout), - "max_al_iterations": strconv.Itoa(cfg.MaxALIterations), - "context_window": strconv.Itoa(cfg.ContextWindow), - "max_tool_res": strconv.Itoa(cfg.MaxToolRes), - "reasoning_effort": "high", - } + cfg := defCfg + cfg.Raw = map[string]string{ + "endpoint": cfg.Endpoint, "model": cfg.Model, "temperature": fmt.Sprintf("%v", cfg.Temperature), + "api_key": cfg.APIKey, "stream": strconv.FormatBool(cfg.Stream), "color": cfg.Color, + "timeout": strconv.Itoa(cfg.Timeout), "shell_timeout": strconv.Itoa(cfg.ShellTimeout), + "max_al_iterations": strconv.Itoa(cfg.MaxALIterations), + "context_window": strconv.Itoa(cfg.ContextWindow), + "max_tool_res": strconv.Itoa(cfg.MaxToolRes), + "reasoning_effort": "high", + } - if filepath.Base(path) == "model.cfg" { - // When .bantam.cfg is absent, model.cfg provides base defaults, - // and BANTAM_* environment variables override them. - parseCfgFile(path, &cfg) - applyEnvCfg(&cfg) - } else { - // If a model.cfg exists in the same directory, read it first as base defaults. - modelCfgPath := filepath.Join(filepath.Dir(path), "model.cfg") - if _, err := os.Stat(modelCfgPath); err == nil { - parseCfgFile(modelCfgPath, &cfg) - } - // Environment variables override model.cfg. - applyEnvCfg(&cfg) - // The explicit override file (.bantam.cfg) takes highest priority and overrides env vars. - parseCfgFile(path, &cfg) - } + if filepath.Base(path) == "model.cfg" { + // When .bantam.cfg is absent, model.cfg provides base defaults, + // and BANTAM_* environment variables override them. + parseCfgFile(path, &cfg) + applyEnvCfg(&cfg) + } else { + // If a model.cfg exists in the same directory, read it first as base defaults. + modelCfgPath := filepath.Join(filepath.Dir(path), "model.cfg") + if _, err := os.Stat(modelCfgPath); err == nil { + parseCfgFile(modelCfgPath, &cfg) + } + // Environment variables override model.cfg. + applyEnvCfg(&cfg) + // The explicit override file (.bantam.cfg) takes highest priority and overrides env vars. + parseCfgFile(path, &cfg) + } - // The api_key "-" / empty sentinel means "fall back to BANTAM_API_KEY"; this - // is evaluated after the file is read so an explicit key still wins. - if (cfg.APIKey == "" || cfg.APIKey == "-") && os.Getenv("BANTAM_API_KEY") != "" { - cfg.APIKey = os.Getenv("BANTAM_API_KEY") - cfg.Raw["api_key"] = cfg.APIKey - } - return cfg + // The api_key "-" / empty sentinel means "fall back to BANTAM_API_KEY"; this + // is evaluated after the file is read so an explicit key still wins. + if (cfg.APIKey == "" || cfg.APIKey == "-") && os.Getenv("BANTAM_API_KEY") != "" { + cfg.APIKey = os.Getenv("BANTAM_API_KEY") + cfg.Raw["api_key"] = cfg.APIKey + } + return cfg } func setCfg(path, key, val string) error { - var lines []string - found := false - if d, err := os.ReadFile(path); err == nil { - for _, ln := range strings.Split(string(d), "\n") { - trimmed := strings.TrimSpace(ln) - if !strings.HasPrefix(trimmed, "#") && strings.Contains(trimmed, "=") { - k, _, _ := strings.Cut(trimmed, "=") - if strings.TrimSpace(k) == key { - lines = append(lines, key+"="+val) - found = true - continue - } - } - lines = append(lines, ln) - } - } - if !found { - if len(lines) > 0 && lines[len(lines)-1] == "" { - lines[len(lines)-1] = key + "=" + val - lines = append(lines, "") - } else { - lines = append(lines, key+"="+val) - } - } - return os.WriteFile(path, []byte(strings.Join(lines, "\n")), 0644) + var lines []string + found := false + if d, err := os.ReadFile(path); err == nil { + for _, ln := range strings.Split(string(d), "\n") { + trimmed := strings.TrimSpace(ln) + if !strings.HasPrefix(trimmed, "#") && strings.Contains(trimmed, "=") { + k, _, _ := strings.Cut(trimmed, "=") + if strings.TrimSpace(k) == key { + lines = append(lines, key+"="+val) + found = true + continue + } + } + lines = append(lines, ln) + } + } + if !found { + if len(lines) > 0 && lines[len(lines)-1] == "" { + lines[len(lines)-1] = key + "=" + val + lines = append(lines, "") + } else { + lines = append(lines, key+"="+val) + } + } + return os.WriteFile(path, []byte(strings.Join(lines, "\n")), 0644) } // configPath returns the configuration file to load: .bantam.cfg takes // priority over model.cfg when both exist in the current working directory, // falling back to model.cfg (which may be absent, triggering defaults). func configPath() string { - if _, err := os.Stat(".bantam.cfg"); err == nil { - return ".bantam.cfg" - } - return "model.cfg" + if _, err := os.Stat(".bantam.cfg"); err == nil { + return ".bantam.cfg" + } + return "model.cfg" } const defaultSystemPrompt = `You are Bantam, a tiny, powerful AI agent. Solve the user's task using two tools: @@ -433,620 +433,620 @@ When generating code: - Respect AGENTS.md contents in the project.` func toolsDir(cfg *Cfg) string { - // The config key takes priority over the environment variable: a local - // .bantam.cfg always overrides BANTAM_TOOLS_DIR. - if v := strings.TrimSpace(cfg.Raw["bantam_tools_dir"]); v != "" { - return v - } - if v := strings.TrimSpace(os.Getenv("BANTAM_TOOLS_DIR")); v != "" { - return v - } - return "" + // The config key takes priority over the environment variable: a local + // .bantam.cfg always overrides BANTAM_TOOLS_DIR. + if v := strings.TrimSpace(cfg.Raw["bantam_tools_dir"]); v != "" { + return v + } + if v := strings.TrimSpace(os.Getenv("BANTAM_TOOLS_DIR")); v != "" { + return v + } + return "" } // skillsDir returns the directory Bantam should scan for skills, preferring the // bantam_skills_dir config key, then the BANTAM_SKILLS_DIR environment variable. func skillsDir(cfg *Cfg) string { - if v := strings.TrimSpace(cfg.Raw["bantam_skills_dir"]); v != "" { - return v - } - if v := strings.TrimSpace(os.Getenv("BANTAM_SKILLS_DIR")); v != "" { - return v - } - return "" + if v := strings.TrimSpace(cfg.Raw["bantam_skills_dir"]); v != "" { + return v + } + if v := strings.TrimSpace(os.Getenv("BANTAM_SKILLS_DIR")); v != "" { + return v + } + return "" } 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" + 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())) + 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 mdState struct { - inCode bool - lang string + inCode bool + lang string } var ( - reCode = regexp.MustCompile("`([^`]+)`") - reLink = regexp.MustCompile(`\[([^\]]+)\]\(([^)]+)\)`) - reBI1 = regexp.MustCompile(`\*\*\*(.*?)\*\*\*`) - reBI2 = regexp.MustCompile(`___(.*?)___`) - reB1 = regexp.MustCompile(`\*\*(.*?)\*\*`) - reB2 = regexp.MustCompile(`__(.*?)__`) - reI1 = regexp.MustCompile(`\*(.*?)\*`) - reI2 = regexp.MustCompile(`_(.*?)_`) - reS = regexp.MustCompile(`~~(.*?)~~`) - reOrd = regexp.MustCompile(`^(\d+\.)\s+(.*)`) + reCode = regexp.MustCompile("`([^`]+)`") + reLink = regexp.MustCompile(`\[([^\]]+)\]\(([^)]+)\)`) + reBI1 = regexp.MustCompile(`\*\*\*(.*?)\*\*\*`) + reBI2 = regexp.MustCompile(`___(.*?)___`) + reB1 = regexp.MustCompile(`\*\*(.*?)\*\*`) + reB2 = regexp.MustCompile(`__(.*?)__`) + reI1 = regexp.MustCompile(`\*(.*?)\*`) + reI2 = regexp.MustCompile(`_(.*?)_`) + reS = regexp.MustCompile(`~~(.*?)~~`) + reOrd = regexp.MustCompile(`^(\d+\.)\s+(.*)`) ) func renderInline(s string) string { - if !COL { - return s - } - var codes []string - s = reCode.ReplaceAllStringFunc(s, func(m string) string { - codes = append(codes, c(m[1:len(m)-1], 33)) - return fmt.Sprintf("\x00CD%d\x00", len(codes)-1) - }) - s = reLink.ReplaceAllStringFunc(s, func(m string) string { - sm := reLink.FindStringSubmatch(m) - if len(sm) == 3 { - return c(sm[1], 4, 36) + " " + c("("+sm[2]+")", 2) - } - return m - }) - s = reBI1.ReplaceAllStringFunc(s, func(m string) string { return c(m[3:len(m)-3], 1, 3) }) - s = reBI2.ReplaceAllStringFunc(s, func(m string) string { return c(m[3:len(m)-3], 1, 3) }) - s = reB1.ReplaceAllStringFunc(s, func(m string) string { return c(m[2:len(m)-2], 1) }) - s = reB2.ReplaceAllStringFunc(s, func(m string) string { return c(m[2:len(m)-2], 1) }) - s = reI1.ReplaceAllStringFunc(s, func(m string) string { return c(m[1:len(m)-1], 3) }) - s = reI2.ReplaceAllStringFunc(s, func(m string) string { return c(m[1:len(m)-1], 3) }) - s = reS.ReplaceAllStringFunc(s, func(m string) string { return c(m[2:len(m)-2], 9) }) - for i, code := range codes { - s = strings.ReplaceAll(s, fmt.Sprintf("\x00CD%d\x00", i), code) - } - return s + if !COL { + return s + } + var codes []string + s = reCode.ReplaceAllStringFunc(s, func(m string) string { + codes = append(codes, c(m[1:len(m)-1], 33)) + return fmt.Sprintf("\x00CD%d\x00", len(codes)-1) + }) + s = reLink.ReplaceAllStringFunc(s, func(m string) string { + sm := reLink.FindStringSubmatch(m) + if len(sm) == 3 { + return c(sm[1], 4, 36) + " " + c("("+sm[2]+")", 2) + } + return m + }) + s = reBI1.ReplaceAllStringFunc(s, func(m string) string { return c(m[3:len(m)-3], 1, 3) }) + s = reBI2.ReplaceAllStringFunc(s, func(m string) string { return c(m[3:len(m)-3], 1, 3) }) + s = reB1.ReplaceAllStringFunc(s, func(m string) string { return c(m[2:len(m)-2], 1) }) + s = reB2.ReplaceAllStringFunc(s, func(m string) string { return c(m[2:len(m)-2], 1) }) + s = reI1.ReplaceAllStringFunc(s, func(m string) string { return c(m[1:len(m)-1], 3) }) + s = reI2.ReplaceAllStringFunc(s, func(m string) string { return c(m[1:len(m)-1], 3) }) + s = reS.ReplaceAllStringFunc(s, func(m string) string { return c(m[2:len(m)-2], 9) }) + for i, code := range codes { + s = strings.ReplaceAll(s, fmt.Sprintf("\x00CD%d\x00", i), code) + } + return s } func renderMDLine(line string, st *mdState) string { - if !COL { - return line - } - trimmed := strings.TrimSpace(line) - if strings.HasPrefix(trimmed, "```") { - if !st.inCode { - st.inCode = true - st.lang = strings.TrimSpace(strings.TrimPrefix(trimmed, "```")) - title := "" - if st.lang != "" { - title = " [ " + st.lang + " ]" - } - return c("───"+title+"──────────────────────────────────────────", 2) - } - st.inCode = false - st.lang = "" - return c("───────────────────────────────────────────────────", 2) - } - if st.inCode { - return c(" ", 2) + c(line, 32) - } - if trimmed == "---" || trimmed == "***" || trimmed == "___" || trimmed == "----" || trimmed == "------" { - return c("───────────────────────────────────────────────────", 2) - } - if strings.HasPrefix(trimmed, "#") { - lvl := 0 - for lvl < len(trimmed) && trimmed[lvl] == '#' { - lvl++ - } - if lvl < len(trimmed) && trimmed[lvl] == ' ' { - htext := strings.TrimSpace(trimmed[lvl:]) - switch lvl { - case 1: - return c("■ ", 35) + c(htext, 1, 37) - case 2: - return c("▲ ", 34) + c(htext, 1, 36) - case 3: - return c("● ", 32) + c(htext, 1, 32) - case 4: - return c("◆ ", 33) + c(htext, 1, 33) - default: - return c(htext, 1) - } - } - } - if strings.HasPrefix(trimmed, ">") { - qtext := strings.TrimSpace(strings.TrimPrefix(trimmed, ">")) - return c("▎ ", 34) + c(renderInline(qtext), 3) - } - if strings.HasPrefix(trimmed, "- [ ] ") || strings.HasPrefix(trimmed, "* [ ] ") { - return strings.Repeat(" ", len(line)-len(strings.TrimLeft(line, " "))) + c("☐ ", 33) + renderInline(trimmed[6:]) - } - if strings.HasPrefix(trimmed, "- [x] ") || strings.HasPrefix(trimmed, "* [x] ") || strings.HasPrefix(trimmed, "- [X] ") || strings.HasPrefix(trimmed, "* [X] ") { - return strings.Repeat(" ", len(line)-len(strings.TrimLeft(line, " "))) + c("☑ ", 32) + renderInline(trimmed[6:]) - } - if strings.HasPrefix(trimmed, "- ") || strings.HasPrefix(trimmed, "* ") || strings.HasPrefix(trimmed, "+ ") { - indent := strings.Repeat(" ", len(line)-len(strings.TrimLeft(line, " "))) - return indent + c("• ", 36) + renderInline(trimmed[2:]) - } - if m := reOrd.FindStringSubmatch(trimmed); len(m) == 3 { - indent := strings.Repeat(" ", len(line)-len(strings.TrimLeft(line, " "))) - return indent + c(m[1]+" ", 33) + renderInline(m[2]) - } - if strings.HasPrefix(trimmed, "|") && strings.HasSuffix(trimmed, "|") { - return renderInline(line) - } - return renderInline(line) + if !COL { + return line + } + trimmed := strings.TrimSpace(line) + if strings.HasPrefix(trimmed, "```") { + if !st.inCode { + st.inCode = true + st.lang = strings.TrimSpace(strings.TrimPrefix(trimmed, "```")) + title := "" + if st.lang != "" { + title = " [ " + st.lang + " ]" + } + return c("───"+title+"──────────────────────────────────────────", 2) + } + st.inCode = false + st.lang = "" + return c("───────────────────────────────────────────────────", 2) + } + if st.inCode { + return c(" ", 2) + c(line, 32) + } + if trimmed == "---" || trimmed == "***" || trimmed == "___" || trimmed == "----" || trimmed == "------" { + return c("───────────────────────────────────────────────────", 2) + } + if strings.HasPrefix(trimmed, "#") { + lvl := 0 + for lvl < len(trimmed) && trimmed[lvl] == '#' { + lvl++ + } + if lvl < len(trimmed) && trimmed[lvl] == ' ' { + htext := strings.TrimSpace(trimmed[lvl:]) + switch lvl { + case 1: + return c("■ ", 35) + c(htext, 1, 37) + case 2: + return c("▲ ", 34) + c(htext, 1, 36) + case 3: + return c("● ", 32) + c(htext, 1, 32) + case 4: + return c("◆ ", 33) + c(htext, 1, 33) + default: + return c(htext, 1) + } + } + } + if strings.HasPrefix(trimmed, ">") { + qtext := strings.TrimSpace(strings.TrimPrefix(trimmed, ">")) + return c("▎ ", 34) + c(renderInline(qtext), 3) + } + if strings.HasPrefix(trimmed, "- [ ] ") || strings.HasPrefix(trimmed, "* [ ] ") { + return strings.Repeat(" ", len(line)-len(strings.TrimLeft(line, " "))) + c("☐ ", 33) + renderInline(trimmed[6:]) + } + if strings.HasPrefix(trimmed, "- [x] ") || strings.HasPrefix(trimmed, "* [x] ") || strings.HasPrefix(trimmed, "- [X] ") || strings.HasPrefix(trimmed, "* [X] ") { + return strings.Repeat(" ", len(line)-len(strings.TrimLeft(line, " "))) + c("☑ ", 32) + renderInline(trimmed[6:]) + } + if strings.HasPrefix(trimmed, "- ") || strings.HasPrefix(trimmed, "* ") || strings.HasPrefix(trimmed, "+ ") { + indent := strings.Repeat(" ", len(line)-len(strings.TrimLeft(line, " "))) + return indent + c("• ", 36) + renderInline(trimmed[2:]) + } + if m := reOrd.FindStringSubmatch(trimmed); len(m) == 3 { + indent := strings.Repeat(" ", len(line)-len(strings.TrimLeft(line, " "))) + return indent + c(m[1]+" ", 33) + renderInline(m[2]) + } + if strings.HasPrefix(trimmed, "|") && strings.HasSuffix(trimmed, "|") { + return renderInline(line) + } + return renderInline(line) } func isTableSep(cells []string) bool { - if len(cells) == 0 { - return false - } - for _, cell := range cells { - c := strings.ReplaceAll(strings.ReplaceAll(strings.TrimSpace(cell), "-", ""), ":", "") - if c != "" { - return false - } - } - return true + if len(cells) == 0 { + return false + } + for _, cell := range cells { + c := strings.ReplaceAll(strings.ReplaceAll(strings.TrimSpace(cell), "-", ""), ":", "") + if c != "" { + return false + } + } + return true } func parseTableCells(line string) []string { - trimmed := strings.TrimSpace(line) - if strings.HasPrefix(trimmed, "|") { - trimmed = trimmed[1:] - } - if strings.HasSuffix(trimmed, "|") { - trimmed = trimmed[:len(trimmed)-1] - } - parts := strings.Split(trimmed, "|") - cells := make([]string, len(parts)) - for i, p := range parts { - cells[i] = strings.TrimSpace(p) - } - return cells + trimmed := strings.TrimSpace(line) + if strings.HasPrefix(trimmed, "|") { + trimmed = trimmed[1:] + } + if strings.HasSuffix(trimmed, "|") { + trimmed = trimmed[:len(trimmed)-1] + } + parts := strings.Split(trimmed, "|") + cells := make([]string, len(parts)) + for i, p := range parts { + cells[i] = strings.TrimSpace(p) + } + return cells } func isTableLine(line string) bool { - trimmed := strings.TrimSpace(line) - if !strings.Contains(trimmed, "|") { - return false - } - return strings.HasPrefix(trimmed, "|") || strings.HasSuffix(trimmed, "|") + trimmed := strings.TrimSpace(line) + if !strings.Contains(trimmed, "|") { + return false + } + return strings.HasPrefix(trimmed, "|") || strings.HasSuffix(trimmed, "|") } func wrapCell(s string, width int) []string { - if width <= 0 { - return []string{""} - } - s = strings.ReplaceAll(s, "
", "\n") - s = strings.ReplaceAll(s, "
", "\n") - s = strings.ReplaceAll(s, "
", "\n") - var lines []string - for _, p := range strings.Split(s, "\n") { - p = strings.TrimSpace(p) - if p == "" { - lines = append(lines, "") - continue - } - if visibleLen(p) <= width { - lines = append(lines, p) - continue - } - words := strings.Fields(p) - if len(words) == 0 { - lines = append(lines, "") - continue - } - var curLine string - var activeEsc string - updateActiveEsc := func(token string) { - for i := 0; i < len(token); { - if token[i] == 0x1b { - j := i + 1 - if j < len(token) && token[j] == '[' { - j++ - for j < len(token) && !(token[j] >= 0x40 && token[j] <= 0x7e) { - j++ - } - if j < len(token) { - j++ - } - } - esc := token[i:j] - if esc == "\033[0m" || esc == "\033[m" { - activeEsc = "" - } else { - activeEsc = esc - } - i = j - } else { - i++ - } - } - } - flushLine := func() { - if curLine != "" { - out := curLine - if COL && activeEsc != "" && !strings.HasSuffix(out, "\033[0m") { - out += "\033[0m" - } - lines = append(lines, out) - curLine = "" - } - } - for _, word := range words { - wLen := visibleLen(word) - if wLen > width { - flushLine() - var chunk strings.Builder - cLen := 0 - for i := 0; i < len(word); { - if word[i] == 0x1b { - j := i + 1 - if j < len(word) && word[j] == '[' { - j++ - for j < len(word) && !(word[j] >= 0x40 && word[j] <= 0x7e) { - j++ - } - if j < len(word) { - j++ - } - } - chunk.WriteString(word[i:j]) - updateActiveEsc(word[i:j]) - i = j - continue - } - r, size := utf8.DecodeRuneInString(word[i:]) - if cLen >= width { - if COL && activeEsc != "" { - chunk.WriteString("\033[0m") - } - lines = append(lines, chunk.String()) - chunk.Reset() - if COL && activeEsc != "" { - chunk.WriteString(activeEsc) - } - cLen = 0 - } - chunk.WriteRune(r) - cLen++ - i += size - } - if chunk.Len() > 0 { - curLine = chunk.String() - } - continue - } - cLen := visibleLen(curLine) - if curLine == "" { - curLine = word - updateActiveEsc(word) - } else if cLen+1+wLen <= width { - curLine += " " + word - updateActiveEsc(word) - } else { - flushLine() - if COL && activeEsc != "" { - curLine = activeEsc + word - } else { - curLine = word - } - updateActiveEsc(word) - } - } - flushLine() - } - if len(lines) == 0 { - lines = []string{""} - } - return lines + if width <= 0 { + return []string{""} + } + s = strings.ReplaceAll(s, "
", "\n") + s = strings.ReplaceAll(s, "
", "\n") + s = strings.ReplaceAll(s, "
", "\n") + var lines []string + for _, p := range strings.Split(s, "\n") { + p = strings.TrimSpace(p) + if p == "" { + lines = append(lines, "") + continue + } + if visibleLen(p) <= width { + lines = append(lines, p) + continue + } + words := strings.Fields(p) + if len(words) == 0 { + lines = append(lines, "") + continue + } + var curLine string + var activeEsc string + updateActiveEsc := func(token string) { + for i := 0; i < len(token); { + if token[i] == 0x1b { + j := i + 1 + if j < len(token) && token[j] == '[' { + j++ + for j < len(token) && !(token[j] >= 0x40 && token[j] <= 0x7e) { + j++ + } + if j < len(token) { + j++ + } + } + esc := token[i:j] + if esc == "\033[0m" || esc == "\033[m" { + activeEsc = "" + } else { + activeEsc = esc + } + i = j + } else { + i++ + } + } + } + flushLine := func() { + if curLine != "" { + out := curLine + if COL && activeEsc != "" && !strings.HasSuffix(out, "\033[0m") { + out += "\033[0m" + } + lines = append(lines, out) + curLine = "" + } + } + for _, word := range words { + wLen := visibleLen(word) + if wLen > width { + flushLine() + var chunk strings.Builder + cLen := 0 + for i := 0; i < len(word); { + if word[i] == 0x1b { + j := i + 1 + if j < len(word) && word[j] == '[' { + j++ + for j < len(word) && !(word[j] >= 0x40 && word[j] <= 0x7e) { + j++ + } + if j < len(word) { + j++ + } + } + chunk.WriteString(word[i:j]) + updateActiveEsc(word[i:j]) + i = j + continue + } + r, size := utf8.DecodeRuneInString(word[i:]) + if cLen >= width { + if COL && activeEsc != "" { + chunk.WriteString("\033[0m") + } + lines = append(lines, chunk.String()) + chunk.Reset() + if COL && activeEsc != "" { + chunk.WriteString(activeEsc) + } + cLen = 0 + } + chunk.WriteRune(r) + cLen++ + i += size + } + if chunk.Len() > 0 { + curLine = chunk.String() + } + continue + } + cLen := visibleLen(curLine) + if curLine == "" { + curLine = word + updateActiveEsc(word) + } else if cLen+1+wLen <= width { + curLine += " " + word + updateActiveEsc(word) + } else { + flushLine() + if COL && activeEsc != "" { + curLine = activeEsc + word + } else { + curLine = word + } + updateActiveEsc(word) + } + } + flushLine() + } + if len(lines) == 0 { + lines = []string{""} + } + return lines } func renderTable(lines []string) []string { - if len(lines) == 0 { - return nil - } - var rows [][]string - var headerRow []string - hasHeader := false + if len(lines) == 0 { + return nil + } + var rows [][]string + var headerRow []string + hasHeader := false - for _, ln := range lines { - cells := parseTableCells(ln) - if isTableSep(cells) { - if len(rows) > 0 && !hasHeader { - headerRow = rows[len(rows)-1] - rows = rows[:len(rows)-1] - hasHeader = true - } - continue - } - rows = append(rows, cells) - } + for _, ln := range lines { + cells := parseTableCells(ln) + if isTableSep(cells) { + if len(rows) > 0 && !hasHeader { + headerRow = rows[len(rows)-1] + rows = rows[:len(rows)-1] + hasHeader = true + } + continue + } + rows = append(rows, cells) + } - if !hasHeader && len(lines) < 2 { - var out []string - var st mdState - for _, ln := range lines { - out = append(out, renderMDLine(ln, &st)) - } - return out - } + if !hasHeader && len(lines) < 2 { + var out []string + var st mdState + for _, ln := range lines { + out = append(out, renderMDLine(ln, &st)) + } + return out + } - numCols := len(headerRow) - for _, r := range rows { - if len(r) > numCols { - numCols = len(r) - } - } - if numCols == 0 { - return nil - } + numCols := len(headerRow) + for _, r := range rows { + if len(r) > numCols { + numCols = len(r) + } + } + if numCols == 0 { + return nil + } - if hasHeader { - for len(headerRow) < numCols { - headerRow = append(headerRow, "") - } - } - for i := range rows { - for len(rows[i]) < numCols { - rows[i] = append(rows[i], "") - } - } + if hasHeader { + for len(headerRow) < numCols { + headerRow = append(headerRow, "") + } + } + for i := range rows { + for len(rows[i]) < numCols { + rows[i] = append(rows[i], "") + } + } - colWidths := make([]int, numCols) - for i := 0; i < numCols; i++ { - if hasHeader { - vl := visibleLen(headerRow[i]) - if vl > colWidths[i] { - colWidths[i] = vl - } - } - for _, r := range rows { - vl := visibleLen(renderInline(r[i])) - if vl > colWidths[i] { - colWidths[i] = vl - } - } - if colWidths[i] < 3 { - colWidths[i] = 3 - } - } + colWidths := make([]int, numCols) + for i := 0; i < numCols; i++ { + if hasHeader { + vl := visibleLen(headerRow[i]) + if vl > colWidths[i] { + colWidths[i] = vl + } + } + for _, r := range rows { + vl := visibleLen(renderInline(r[i])) + if vl > colWidths[i] { + colWidths[i] = vl + } + } + if colWidths[i] < 3 { + colWidths[i] = 3 + } + } - maxTableWidth := termWidth() - if maxTableWidth < 20 { - maxTableWidth = 80 - } - overhead := 3*numCols + 1 - availContent := maxTableWidth - overhead - if availContent < numCols*3 { - availContent = numCols * 3 - } + maxTableWidth := termWidth() + if maxTableWidth < 20 { + maxTableWidth = 80 + } + overhead := 3*numCols + 1 + availContent := maxTableWidth - overhead + if availContent < numCols*3 { + availContent = numCols * 3 + } - tot := 0 - for _, w := range colWidths { - tot += w - } + tot := 0 + for _, w := range colWidths { + tot += w + } - for tot > availContent { - maxIdx := 0 - maxVal := colWidths[0] - for i := 1; i < numCols; i++ { - if colWidths[i] > maxVal { - maxVal = colWidths[i] - maxIdx = i - } - } - if maxVal <= 3 { - break - } - colWidths[maxIdx]-- - tot-- - } + for tot > availContent { + maxIdx := 0 + maxVal := colWidths[0] + for i := 1; i < numCols; i++ { + if colWidths[i] > maxVal { + maxVal = colWidths[i] + maxIdx = i + } + } + if maxVal <= 3 { + break + } + colWidths[maxIdx]-- + tot-- + } - var res []string + var res []string - var topParts []string - for _, w := range colWidths { - topParts = append(topParts, strings.Repeat("─", w+2)) - } - res = append(res, c("┌"+strings.Join(topParts, "┬")+"┐", 2)) + var topParts []string + for _, w := range colWidths { + topParts = append(topParts, strings.Repeat("─", w+2)) + } + res = append(res, c("┌"+strings.Join(topParts, "┬")+"┐", 2)) - if hasHeader { - headerCols := make([][]string, numCols) - maxHeaderLines := 1 - for i, h := range headerRow { - wrapped := wrapCell(h, colWidths[i]) - if len(wrapped) > maxHeaderLines { - maxHeaderLines = len(wrapped) - } - headerCols[i] = wrapped - } + if hasHeader { + headerCols := make([][]string, numCols) + maxHeaderLines := 1 + for i, h := range headerRow { + wrapped := wrapCell(h, colWidths[i]) + if len(wrapped) > maxHeaderLines { + maxHeaderLines = len(wrapped) + } + headerCols[i] = wrapped + } - for lineIdx := 0; lineIdx < maxHeaderLines; lineIdx++ { - var hCells []string - for i := 0; i < numCols; i++ { - txt := "" - if lineIdx < len(headerCols[i]) { - txt = headerCols[i][lineIdx] - } - rh := c(txt, 1, 36) - pad := strings.Repeat(" ", colWidths[i]-visibleLen(txt)) - hCells = append(hCells, " "+rh+pad+" ") - } - res = append(res, c("│", 2)+strings.Join(hCells, c("│", 2))+c("│", 2)) - } + for lineIdx := 0; lineIdx < maxHeaderLines; lineIdx++ { + var hCells []string + for i := 0; i < numCols; i++ { + txt := "" + if lineIdx < len(headerCols[i]) { + txt = headerCols[i][lineIdx] + } + rh := c(txt, 1, 36) + pad := strings.Repeat(" ", colWidths[i]-visibleLen(txt)) + hCells = append(hCells, " "+rh+pad+" ") + } + res = append(res, c("│", 2)+strings.Join(hCells, c("│", 2))+c("│", 2)) + } - var midParts []string - for _, w := range colWidths { - midParts = append(midParts, strings.Repeat("─", w+2)) - } - res = append(res, c("├"+strings.Join(midParts, "┼")+"┤", 2)) - } + var midParts []string + for _, w := range colWidths { + midParts = append(midParts, strings.Repeat("─", w+2)) + } + res = append(res, c("├"+strings.Join(midParts, "┼")+"┤", 2)) + } - for _, r := range rows { - rowCols := make([][]string, numCols) - maxRowLines := 1 - for i, cell := range r { - rc := renderInline(cell) - wrapped := wrapCell(rc, colWidths[i]) - if len(wrapped) > maxRowLines { - maxRowLines = len(wrapped) - } - rowCols[i] = wrapped - } + for _, r := range rows { + rowCols := make([][]string, numCols) + maxRowLines := 1 + for i, cell := range r { + rc := renderInline(cell) + wrapped := wrapCell(rc, colWidths[i]) + if len(wrapped) > maxRowLines { + maxRowLines = len(wrapped) + } + rowCols[i] = wrapped + } - for lineIdx := 0; lineIdx < maxRowLines; lineIdx++ { - var rCells []string - for i := 0; i < numCols; i++ { - txt := "" - if lineIdx < len(rowCols[i]) { - txt = rowCols[i][lineIdx] - } - pad := strings.Repeat(" ", colWidths[i]-visibleLen(txt)) - rCells = append(rCells, " "+txt+pad+" ") - } - res = append(res, c("│", 2)+strings.Join(rCells, c("│", 2))+c("│", 2)) - } - } + for lineIdx := 0; lineIdx < maxRowLines; lineIdx++ { + var rCells []string + for i := 0; i < numCols; i++ { + txt := "" + if lineIdx < len(rowCols[i]) { + txt = rowCols[i][lineIdx] + } + pad := strings.Repeat(" ", colWidths[i]-visibleLen(txt)) + rCells = append(rCells, " "+txt+pad+" ") + } + res = append(res, c("│", 2)+strings.Join(rCells, c("│", 2))+c("│", 2)) + } + } - var botParts []string - for _, w := range colWidths { - botParts = append(botParts, strings.Repeat("─", w+2)) - } - res = append(res, c("└"+strings.Join(botParts, "┴")+"┘", 2)) + var botParts []string + for _, w := range colWidths { + botParts = append(botParts, strings.Repeat("─", w+2)) + } + res = append(res, c("└"+strings.Join(botParts, "┴")+"┘", 2)) - return res + return res } func renderMD(text string) string { - if !COL { - return text - } - lines := strings.Split(text, "\n") - var out []string - var st mdState - var tbl []string + if !COL { + return text + } + lines := strings.Split(text, "\n") + var out []string + var st mdState + var tbl []string - flushTable := func() { - if len(tbl) > 0 { - out = append(out, renderTable(tbl)...) - tbl = nil - } - } + flushTable := func() { + if len(tbl) > 0 { + out = append(out, renderTable(tbl)...) + tbl = nil + } + } - for _, ln := range lines { - trimmed := strings.TrimSpace(ln) - if strings.HasPrefix(trimmed, "```") { - flushTable() - out = append(out, renderMDLine(ln, &st)) - continue - } - if st.inCode { - out = append(out, renderMDLine(ln, &st)) - continue - } - if isTableLine(ln) { - tbl = append(tbl, ln) - continue - } - flushTable() - out = append(out, renderMDLine(ln, &st)) - } - flushTable() - return strings.Join(out, "\n") + for _, ln := range lines { + trimmed := strings.TrimSpace(ln) + if strings.HasPrefix(trimmed, "```") { + flushTable() + out = append(out, renderMDLine(ln, &st)) + continue + } + if st.inCode { + out = append(out, renderMDLine(ln, &st)) + continue + } + if isTableLine(ln) { + tbl = append(tbl, ln) + continue + } + flushTable() + out = append(out, renderMDLine(ln, &st)) + } + flushTable() + return strings.Join(out, "\n") } 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"` - ToolCallsShown bool `json:"-"` // tool call headers already printed live during streaming + 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"` + ToolCallsShown bool `json:"-"` // tool call headers already printed live during streaming } type ToolCall struct { - ID string `json:"id"` - Type string `json:"type"` - Function struct { - Name string `json:"name"` - Arguments string `json:"arguments"` - } `json:"function"` + 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": "write_file", "description": "Write content to a file. If offset and del_bytes are both omitted, the entire file is overwritten with the new content; otherwise content is written at the given byte offset, optionally deleting bytes first.", "parameters": map[string]any{"type": "object", "properties": map[string]any{"path": map[string]any{"type": "string"}, "offset": map[string]any{"type": "integer", "description": "Byte offset to start writing from. If omitted together with del_bytes, the whole file is overwritten instead. Defaults to 0 (start of file)."}, "del_bytes": map[string]any{"type": "integer", "description": "Bytes to delete starting at offset. If omitted together with offset, the whole file is overwritten instead."}, "content": map[string]any{"type": "string"}}, "required": []string{"path", "content"}}}}, + {"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": "write_file", "description": "Write content to a file. If offset and del_bytes are both omitted, the entire file is overwritten with the new content; otherwise content is written at the given byte offset, optionally deleting bytes first.", "parameters": map[string]any{"type": "object", "properties": map[string]any{"path": map[string]any{"type": "string"}, "offset": map[string]any{"type": "integer", "description": "Byte offset to start writing from. If omitted together with del_bytes, the whole file is overwritten instead. Defaults to 0 (start of file)."}, "del_bytes": map[string]any{"type": "integer", "description": "Bytes to delete starting at offset. If omitted together with offset, the whole file is overwritten instead."}, "content": map[string]any{"type": "string"}}, "required": []string{"path", "content"}}}}, } func strp(s string) *string { return &s } type Usage struct { - PromptTokens int `json:"prompt_tokens"` - CompletionTokens int `json:"completion_tokens"` - TotalTokens int `json:"total_tokens"` - PromptTokensDetails struct { - CachedTokens int `json:"cached_tokens"` - } `json:"prompt_tokens_details"` - CachedTokens int `json:"cached_tokens"` - // ContextTokens is a local estimate of the total tokens occupying the context - // window for this sample, including any reasoning_content that is resent as - // part of the context. It acts as a floor beneath PromptTokens so reasoning - // tokens are always counted in the context window statistics even when the - // provider under-reports prompt_tokens. - ContextTokens int `json:"-"` - Model string `json:"-"` + PromptTokens int `json:"prompt_tokens"` + CompletionTokens int `json:"completion_tokens"` + TotalTokens int `json:"total_tokens"` + PromptTokensDetails struct { + CachedTokens int `json:"cached_tokens"` + } `json:"prompt_tokens_details"` + CachedTokens int `json:"cached_tokens"` + // ContextTokens is a local estimate of the total tokens occupying the context + // window for this sample, including any reasoning_content that is resent as + // part of the context. It acts as a floor beneath PromptTokens so reasoning + // tokens are always counted in the context window statistics even when the + // provider under-reports prompt_tokens. + ContextTokens int `json:"-"` + Model string `json:"-"` } func (u Usage) Cached() int { - if u.PromptTokensDetails.CachedTokens > 0 { - return u.PromptTokensDetails.CachedTokens - } - return u.CachedTokens + if u.PromptTokensDetails.CachedTokens > 0 { + return u.PromptTokensDetails.CachedTokens + } + return u.CachedTokens } // estTokens is a coarse chars/4 fallback used only when the provider omits // usage in its response; when real usage is present it is never used. func estTokens(msgs []Message) int { - chars := 0 - for _, m := range msgs { - if m.Content != nil { - chars += len(*m.Content) - } - chars += len(m.ReasoningContent) - for _, tc := range m.ToolCalls { - chars += len(tc.Function.Name) + len(tc.Function.Arguments) - } - } - if chars == 0 { - return 0 - } - t := chars / 4 - if t == 0 { - t = 1 - } - return t + chars := 0 + for _, m := range msgs { + if m.Content != nil { + chars += len(*m.Content) + } + chars += len(m.ReasoningContent) + for _, tc := range m.ToolCalls { + chars += len(tc.Function.Name) + len(tc.Function.Arguments) + } + } + if chars == 0 { + return 0 + } + t := chars / 4 + if t == 0 { + t = 1 + } + return t } // ctxTokens returns the number of tokens occupying the context window for a @@ -1055,537 +1055,537 @@ func estTokens(msgs []Message) int { // reasoning_content resent as context) so reasoning tokens are always counted in // the context window statistics. func ctxTokens(u Usage) int { - if u.ContextTokens > u.PromptTokens { - return u.ContextTokens - } - return u.PromptTokens + if u.ContextTokens > u.PromptTokens { + return u.ContextTokens + } + return u.PromptTokens } func contextPct(u Usage, cw int) float64 { - if cw <= 0 { - cw = 262144 - } - return float64(ctxTokens(u)) * 100.0 / float64(cw) + if cw <= 0 { + cw = 262144 + } + return float64(ctxTokens(u)) * 100.0 / float64(cw) } func formatUsage(u Usage, cw int) string { - base := ctxTokens(u) - pct := float64(base) * 100.0 / float64(cw) - cached := u.Cached() - if cached > 0 { - uncached := u.PromptTokens - cached - if uncached < 0 { - uncached = 0 - } - if u.Model != "" { - return fmt.Sprintf("[%s: %d prompt (%d cached, %d uncached) + %d completion | context: %d/%d (%.1f%%)]", u.Model, u.PromptTokens, cached, uncached, u.CompletionTokens, base, cw, pct) - } - return fmt.Sprintf("[%d prompt (%d cached, %d uncached) + %d completion | context: %d/%d (%.1f%%)]", u.PromptTokens, cached, uncached, u.CompletionTokens, base, cw, pct) - } - if u.Model != "" { - return fmt.Sprintf("[%s: %d prompt + %d completion | context: %d/%d (%.1f%%)]", u.Model, u.PromptTokens, u.CompletionTokens, base, cw, pct) - } - return fmt.Sprintf("[%d prompt + %d completion | context: %d/%d (%.1f%%)]", u.PromptTokens, u.CompletionTokens, base, cw, pct) + base := ctxTokens(u) + pct := float64(base) * 100.0 / float64(cw) + cached := u.Cached() + if cached > 0 { + uncached := u.PromptTokens - cached + if uncached < 0 { + uncached = 0 + } + if u.Model != "" { + return fmt.Sprintf("[%s: %d prompt (%d cached, %d uncached) + %d completion | context: %d/%d (%.1f%%)]", u.Model, u.PromptTokens, cached, uncached, u.CompletionTokens, base, cw, pct) + } + return fmt.Sprintf("[%d prompt (%d cached, %d uncached) + %d completion | context: %d/%d (%.1f%%)]", u.PromptTokens, cached, uncached, u.CompletionTokens, base, cw, pct) + } + if u.Model != "" { + return fmt.Sprintf("[%s: %d prompt + %d completion | context: %d/%d (%.1f%%)]", u.Model, u.PromptTokens, u.CompletionTokens, base, cw, pct) + } + return fmt.Sprintf("[%d prompt + %d completion | context: %d/%d (%.1f%%)]", u.PromptTokens, u.CompletionTokens, base, cw, pct) } type streamDelta struct { - Model string `json:"model"` - Choices []struct { - Delta struct { - ReasoningContent string `json:"reasoning_content"` - Reasoning string `json:"reasoning"` - Thought string `json:"thought"` - 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"` - Usage *Usage `json:"usage"` + Model string `json:"model"` + Choices []struct { + Delta struct { + ReasoningContent string `json:"reasoning_content"` + Reasoning string `json:"reasoning"` + Thought string `json:"thought"` + 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"` + Usage *Usage `json:"usage"` } func cleanMessagesForLLM(msgs []Message) []Message { - out := make([]Message, len(msgs)) - for i, m := range msgs { - out[i] = Message{ - Role: m.Role, - Content: m.Content, - ReasoningContent: m.ReasoningContent, - ToolCalls: m.ToolCalls, - ToolCallID: m.ToolCallID, - } - } - return out + out := make([]Message, len(msgs)) + for i, m := range msgs { + out[i] = Message{ + Role: m.Role, + Content: m.Content, + ReasoningContent: m.ReasoningContent, + ToolCalls: m.ToolCalls, + ToolCallID: m.ToolCallID, + } + } + return out } func filterText(s string) string { - var b strings.Builder - b.Grow(len(s)) - for _, r := range s { - if r == ' ' || r == '\t' || r == '\n' { - b.WriteRune(r) - } else if unicode.IsPrint(r) { - b.WriteRune(r) - } - } - return b.String() + var b strings.Builder + b.Grow(len(s)) + for _, r := range s { + if r == ' ' || r == '\t' || r == '\n' { + b.WriteRune(r) + } else if unicode.IsPrint(r) { + b.WriteRune(r) + } + } + return b.String() } func sanitizeMessages(msgs []Message) { - for i := range msgs { - if msgs[i].Role == "assistant" { - if len(msgs[i].ToolCalls) > 0 { - for j := range msgs[i].ToolCalls { - tc := &msgs[i].ToolCalls[j] - tc.Function.Arguments = filterText(tc.Function.Arguments) - astr := tc.Function.Arguments - var a map[string]any - if err := json.Unmarshal([]byte(astr), &a); err != nil || a == nil { - fixed, _ := json.Marshal(map[string]string{"invalid_raw": astr}) - tc.Function.Arguments = string(fixed) - } - } - } - if msgs[i].ReasoningContent != "" { - msgs[i].ReasoningContent = filterText(msgs[i].ReasoningContent) - } - } else if msgs[i].Role == "tool" && msgs[i].Content != nil { - msgs[i].Content = strp(filterText(*msgs[i].Content)) - } - } + for i := range msgs { + if msgs[i].Role == "assistant" { + if len(msgs[i].ToolCalls) > 0 { + for j := range msgs[i].ToolCalls { + tc := &msgs[i].ToolCalls[j] + tc.Function.Arguments = filterText(tc.Function.Arguments) + astr := tc.Function.Arguments + var a map[string]any + if err := json.Unmarshal([]byte(astr), &a); err != nil || a == nil { + fixed, _ := json.Marshal(map[string]string{"invalid_raw": astr}) + tc.Function.Arguments = string(fixed) + } + } + } + if msgs[i].ReasoningContent != "" { + msgs[i].ReasoningContent = filterText(msgs[i].ReasoningContent) + } + } else if msgs[i].Role == "tool" && msgs[i].Content != nil { + msgs[i].Content = strp(filterText(*msgs[i].Content)) + } + } } func isInvalidAssistantErr(err error) bool { - if err == nil { - return false - } - s := strings.ToLower(err.Error()) - return strings.Contains(s, "invalid assistant message") || - strings.Contains(s, "content or tool_calls must be set") || - strings.Contains(s, "tool_calls must be set") || - strings.Contains(s, "content must be set") + if err == nil { + return false + } + s := strings.ToLower(err.Error()) + return strings.Contains(s, "invalid assistant message") || + strings.Contains(s, "content or tool_calls must be set") || + strings.Contains(s, "tool_calls must be set") || + strings.Contains(s, "content must be set") } func llm(ctx context.Context, cfg *Cfg, msgs []Message, tools []map[string]any) (Message, Usage, error) { - if err := ctx.Err(); err != nil { - return Message{}, Usage{}, err - } - cleanMsgs := cleanMessagesForLLM(msgs) - sanitizeMessages(cleanMsgs) - p := map[string]any{"model": cfg.Model, "temperature": cfg.Temperature, "messages": cleanMsgs, "stream": cfg.Stream} - if tools != nil { - p["tools"] = tools - } - if cfg.Stream { - p["stream_options"] = map[string]any{"include_usage": true} - } - for k, v := range cfg.Raw { - if internalKey(k) { - continue - } - { - var jv any - if err := json.Unmarshal([]byte(v), &jv); err == nil { - p[k] = jv - } else { - p[k] = v - } - } - } - body, _ := json.Marshal(p) - llmTransport.ResponseHeaderTimeout = time.Duration(cfg.Timeout) * time.Second - client := &http.Client{Transport: llmTransport} - fib := []int{1, 1, 2, 3, 5, 8, 13, 21, 34} - pend := c("...requesting...", 1, 2) - var resp *http.Response - var err error - reqID := ocRequestID() - for i := 0; i <= len(fib); i++ { - if err := ctx.Err(); err != nil { - if COL { - fmt.Print("\r\033[K") - } - return Message{}, Usage{}, err - } - if COL { - fmt.Print("\r" + pend) - } else { - fmt.Println(pend) - } - req, _ := http.NewRequestWithContext(ctx, "POST", strings.TrimRight(cfg.Endpoint, "/")+"/chat/completions", bytes.NewReader(body)) - req.Header.Set("Content-Type", "application/json") - applyLLMHeaders(req, cfg, reqID) - resp, err = client.Do(req) - var is4xxClientErr bool - 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))) - if resp.StatusCode < 500 && resp.StatusCode != 408 && resp.StatusCode != 429 { - is4xxClientErr = true - } - resp = nil - } - if err == nil { - break - } - if COL { - fmt.Print("\r\033[K") - } - if errors.Is(err, context.Canceled) || ctx.Err() != nil { - return Message{}, Usage{}, ctx.Err() - } - if is4xxClientErr { - return Message{}, Usage{}, err - } - if i < len(fib) { - fmt.Println(c(fmt.Sprintf("[network error: %v, retrying in %ds...]", err, fib[i]), 31)) - select { - case <-ctx.Done(): - return Message{}, Usage{}, ctx.Err() - case <-time.After(time.Duration(fib[i]) * time.Second): - } - } - } - if err != nil { - if COL { - fmt.Print("\r\033[K") - } - return Message{}, Usage{}, err - } - defer resp.Body.Close() - if COL { - fmt.Print("\r\033[K") - } - if !cfg.Stream { - var cr struct { - Model string `json:"model"` - Choices []struct { - Message struct { - Message - Reasoning string `json:"reasoning"` - Thought string `json:"thought"` - } `json:"message"` - } `json:"choices"` - Usage Usage `json:"usage"` - } - if err := json.NewDecoder(resp.Body).Decode(&cr); err != nil { - if errors.Is(err, context.Canceled) || ctx.Err() != nil { - return Message{}, Usage{}, ctx.Err() - } - return Message{}, Usage{}, err - } - if len(cr.Choices) == 0 { - return Message{}, Usage{}, errors.New("empty choices in LLM response") - } - m := cr.Choices[0].Message.Message - if m.ReasoningContent == "" { - m.ReasoningContent = cr.Choices[0].Message.Reasoning - } - if m.ReasoningContent == "" { - m.ReasoningContent = cr.Choices[0].Message.Thought - } - u := cr.Usage - if cr.Model != "" { - u.Model = cr.Model - } - // Local estimate of the full context (including any resent reasoning) so it - // is counted in the context window statistics as a floor under prompt_tokens. - u.ContextTokens = estTokens(msgs) - if u.PromptTokens == 0 { - u.PromptTokens = estTokens(msgs) - u.CompletionTokens = estTokens([]Message{m}) - u.TotalTokens = u.PromptTokens + u.CompletionTokens - } - return m, u, nil - } - m, u, err := parseStream(ctx, resp.Body) - if err == nil && u.PromptTokens == 0 { - u.PromptTokens = estTokens(msgs) - u.CompletionTokens = estTokens([]Message{m}) - u.TotalTokens = u.PromptTokens + u.CompletionTokens - } - if err == nil { - // Local estimate of the full context (including any resent reasoning) so it - // is counted in the context window statistics as a floor under prompt_tokens. - u.ContextTokens = estTokens(msgs) - } - return m, u, err + if err := ctx.Err(); err != nil { + return Message{}, Usage{}, err + } + cleanMsgs := cleanMessagesForLLM(msgs) + sanitizeMessages(cleanMsgs) + p := map[string]any{"model": cfg.Model, "temperature": cfg.Temperature, "messages": cleanMsgs, "stream": cfg.Stream} + if tools != nil { + p["tools"] = tools + } + if cfg.Stream { + p["stream_options"] = map[string]any{"include_usage": true} + } + for k, v := range cfg.Raw { + if internalKey(k) { + continue + } + { + var jv any + if err := json.Unmarshal([]byte(v), &jv); err == nil { + p[k] = jv + } else { + p[k] = v + } + } + } + body, _ := json.Marshal(p) + llmTransport.ResponseHeaderTimeout = time.Duration(cfg.Timeout) * time.Second + client := &http.Client{Transport: llmTransport} + fib := []int{1, 1, 2, 3, 5, 8, 13, 21, 34} + pend := c("...requesting...", 1, 2) + var resp *http.Response + var err error + reqID := ocRequestID() + for i := 0; i <= len(fib); i++ { + if err := ctx.Err(); err != nil { + if COL { + fmt.Print("\r\033[K") + } + return Message{}, Usage{}, err + } + if COL { + fmt.Print("\r" + pend) + } else { + fmt.Println(pend) + } + req, _ := http.NewRequestWithContext(ctx, "POST", strings.TrimRight(cfg.Endpoint, "/")+"/chat/completions", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + applyLLMHeaders(req, cfg, reqID) + resp, err = client.Do(req) + var is4xxClientErr bool + 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))) + if resp.StatusCode < 500 && resp.StatusCode != 408 && resp.StatusCode != 429 { + is4xxClientErr = true + } + resp = nil + } + if err == nil { + break + } + if COL { + fmt.Print("\r\033[K") + } + if errors.Is(err, context.Canceled) || ctx.Err() != nil { + return Message{}, Usage{}, ctx.Err() + } + if is4xxClientErr { + return Message{}, Usage{}, err + } + if i < len(fib) { + fmt.Println(c(fmt.Sprintf("[network error: %v, retrying in %ds...]", err, fib[i]), 31)) + select { + case <-ctx.Done(): + return Message{}, Usage{}, ctx.Err() + case <-time.After(time.Duration(fib[i]) * time.Second): + } + } + } + if err != nil { + if COL { + fmt.Print("\r\033[K") + } + return Message{}, Usage{}, err + } + defer resp.Body.Close() + if COL { + fmt.Print("\r\033[K") + } + if !cfg.Stream { + var cr struct { + Model string `json:"model"` + Choices []struct { + Message struct { + Message + Reasoning string `json:"reasoning"` + Thought string `json:"thought"` + } `json:"message"` + } `json:"choices"` + Usage Usage `json:"usage"` + } + if err := json.NewDecoder(resp.Body).Decode(&cr); err != nil { + if errors.Is(err, context.Canceled) || ctx.Err() != nil { + return Message{}, Usage{}, ctx.Err() + } + return Message{}, Usage{}, err + } + if len(cr.Choices) == 0 { + return Message{}, Usage{}, errors.New("empty choices in LLM response") + } + m := cr.Choices[0].Message.Message + if m.ReasoningContent == "" { + m.ReasoningContent = cr.Choices[0].Message.Reasoning + } + if m.ReasoningContent == "" { + m.ReasoningContent = cr.Choices[0].Message.Thought + } + u := cr.Usage + if cr.Model != "" { + u.Model = cr.Model + } + // Local estimate of the full context (including any resent reasoning) so it + // is counted in the context window statistics as a floor under prompt_tokens. + u.ContextTokens = estTokens(msgs) + if u.PromptTokens == 0 { + u.PromptTokens = estTokens(msgs) + u.CompletionTokens = estTokens([]Message{m}) + u.TotalTokens = u.PromptTokens + u.CompletionTokens + } + return m, u, nil + } + m, u, err := parseStream(ctx, resp.Body) + if err == nil && u.PromptTokens == 0 { + u.PromptTokens = estTokens(msgs) + u.CompletionTokens = estTokens([]Message{m}) + u.TotalTokens = u.PromptTokens + u.CompletionTokens + } + if err == nil { + // Local estimate of the full context (including any resent reasoning) so it + // is counted in the context window statistics as a floor under prompt_tokens. + u.ContextTokens = estTokens(msgs) + } + return m, u, err } func parseStream(ctx context.Context, r io.Reader) (Message, Usage, error) { - var content, reas string - var inReasoning bool - var lineBuf string - var mdSt mdState - var tblBuf []string - var lastUsage Usage - tcs := map[int]*ToolCall{} - var order []int - var toolCallsShown bool + var content, reas string + var inReasoning bool + var lineBuf string + var mdSt mdState + var tblBuf []string + var lastUsage Usage + tcs := map[int]*ToolCall{} + var order []int + var toolCallsShown bool - // Progressive, per-token display of tool calls as they stream in, so the - // user watches the invocation being shaped token by token (like reasoning - // text) instead of a blank pause. Only used when stdout is a terminal; - // piped/redirected output is left untouched. Each fragment is appended - // directly to the terminal; the closing ")" is emitted only when the call is - // finalized, so the visible text is always a true prefix of the final form. - tty := isTerminal(int(os.Stdout.Fd())) - liveIdx := -1 - livePrinted := "" - liveTarget := func(t *ToolCall, closed bool) string { - s := "[tool call: " + t.Function.Name - if closed || t.Function.Arguments != "" { - s += "(" + filterText(t.Function.Arguments) - if closed { - s += ")]" - } - } - return s - } - finishLive := func(closed bool) { - if liveIdx < 0 { - return - } - target := liveTarget(tcs[liveIdx], closed) - if len(target) > len(livePrinted) { - fmt.Print(c(target[len(livePrinted):], 33)) - } - if closed { - fmt.Print("\n") - } - livePrinted = "" - liveIdx = -1 - } - stepLive := func() { - if !tty || len(order) == 0 { - return - } - cur := order[len(order)-1] - if liveIdx != -1 && liveIdx != cur { - // A different tool call is now being shaped: finalize the previous one. - finishLive(true) - } - if liveIdx == -1 { - liveIdx = cur - livePrinted = "" - } - target := liveTarget(tcs[cur], false) - if len(target) > len(livePrinted) { - fmt.Print(c(target[len(livePrinted):], 33)) - livePrinted = target - } - toolCallsShown = true - } + // Progressive, per-token display of tool calls as they stream in, so the + // user watches the invocation being shaped token by token (like reasoning + // text) instead of a blank pause. Only used when stdout is a terminal; + // piped/redirected output is left untouched. Each fragment is appended + // directly to the terminal; the closing ")" is emitted only when the call is + // finalized, so the visible text is always a true prefix of the final form. + tty := isTerminal(int(os.Stdout.Fd())) + liveIdx := -1 + livePrinted := "" + liveTarget := func(t *ToolCall, closed bool) string { + s := "[tool call: " + t.Function.Name + if closed || t.Function.Arguments != "" { + s += "(" + filterText(t.Function.Arguments) + if closed { + s += ")]" + } + } + return s + } + finishLive := func(closed bool) { + if liveIdx < 0 { + return + } + target := liveTarget(tcs[liveIdx], closed) + if len(target) > len(livePrinted) { + fmt.Print(c(target[len(livePrinted):], 33)) + } + if closed { + fmt.Print("\n") + } + livePrinted = "" + liveIdx = -1 + } + stepLive := func() { + if !tty || len(order) == 0 { + return + } + cur := order[len(order)-1] + if liveIdx != -1 && liveIdx != cur { + // A different tool call is now being shaped: finalize the previous one. + finishLive(true) + } + if liveIdx == -1 { + liveIdx = cur + livePrinted = "" + } + target := liveTarget(tcs[cur], false) + if len(target) > len(livePrinted) { + fmt.Print(c(target[len(livePrinted):], 33)) + livePrinted = target + } + toolCallsShown = true + } - flushTable := func() { - if len(tblBuf) > 0 { - for _, tln := range renderTable(tblBuf) { - fmt.Println(tln) - } - tblBuf = nil - } - } + flushTable := func() { + if len(tblBuf) > 0 { + for _, tln := range renderTable(tblBuf) { + fmt.Println(tln) + } + tblBuf = nil + } + } - sc := bufio.NewScanner(r) - sc.Buffer(make([]byte, 0, 64*1024), 4*1024*1024) - for sc.Scan() { - if err := ctx.Err(); err != nil { - return Message{}, lastUsage, err - } - ln := strings.TrimSpace(sc.Text()) - if !strings.HasPrefix(ln, "data:") { - continue - } - data := strings.TrimSpace(ln[5:]) - if data == "[DONE]" { - break - } - var d streamDelta - if json.Unmarshal([]byte(data), &d) != nil { - continue - } - if d.Usage != nil && (d.Usage.PromptTokens > 0 || d.Usage.TotalTokens > 0) { - lastUsage = *d.Usage - } - if d.Model != "" { - lastUsage.Model = d.Model - } - if len(d.Choices) == 0 { - continue - } - dl := d.Choices[0].Delta - rc := dl.ReasoningContent - if rc == "" { - rc = dl.Reasoning - } - if rc == "" { - rc = dl.Thought - } - if rc != "" { - if !inReasoning { - if content != "" { - flushTable() - if lineBuf != "" { - fmt.Println(renderMDLine(lineBuf, &mdSt)) - lineBuf = "" - } - fmt.Println() - } - fmt.Println(c("--- reasoning start ---", 36)) - inReasoning = true - } - fmt.Print(c(rc, 2)) - reas += rc - } - if dl.Content != "" { - if inReasoning { - fmt.Print("\n" + c("--- reasoning end ---", 36) + "\n\n") - inReasoning = false - } - content += dl.Content - lineBuf += dl.Content - for { - idx := strings.IndexByte(lineBuf, '\n') - if idx == -1 { - break - } - curLine := lineBuf[:idx] - lineBuf = lineBuf[idx+1:] - trimmed := strings.TrimSpace(curLine) - if strings.HasPrefix(trimmed, "```") { - flushTable() - fmt.Println(renderMDLine(curLine, &mdSt)) - } else if mdSt.inCode { - fmt.Println(renderMDLine(curLine, &mdSt)) - } else if isTableLine(curLine) { - tblBuf = append(tblBuf, curLine) - } else { - flushTable() - fmt.Println(renderMDLine(curLine, &mdSt)) - } - } - } - if len(dl.ToolCalls) > 0 { - if inReasoning { - fmt.Print("\n" + c("--- reasoning end ---", 36) + "\n") - inReasoning = false - } - if liveIdx == -1 { - // First tool call of the stream: flush any pending content line so it - // is not overwritten by the live tool-call line. - flushTable() - if lineBuf != "" { - fmt.Println(renderMDLine(lineBuf, &mdSt)) - lineBuf = "" - } - } - 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 - } - } - stepLive() - } - } - if err := ctx.Err(); err != nil { - return Message{}, lastUsage, err - } - flushTable() - if lineBuf != "" { - if !mdSt.inCode && isTableLine(lineBuf) { - tblBuf = append(tblBuf, lineBuf) - flushTable() - } else { - fmt.Println(renderMDLine(lineBuf, &mdSt)) - } - } - if liveIdx >= 0 { - // Finalize the in-progress tool-call line so following output starts fresh. - finishLive(true) - } - if inReasoning { - fmt.Print("\n" + c("--- reasoning end ---", 36) + "\n") - } - m := Message{Role: "assistant"} - m.ToolCallsShown = toolCallsShown - 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]) - } - } - if lastUsage.TotalTokens == 0 && lastUsage.PromptTokens > 0 { - lastUsage.TotalTokens = lastUsage.PromptTokens + lastUsage.CompletionTokens - } - return m, lastUsage, sc.Err() + sc := bufio.NewScanner(r) + sc.Buffer(make([]byte, 0, 64*1024), 4*1024*1024) + for sc.Scan() { + if err := ctx.Err(); err != nil { + return Message{}, lastUsage, err + } + ln := strings.TrimSpace(sc.Text()) + if !strings.HasPrefix(ln, "data:") { + continue + } + data := strings.TrimSpace(ln[5:]) + if data == "[DONE]" { + break + } + var d streamDelta + if json.Unmarshal([]byte(data), &d) != nil { + continue + } + if d.Usage != nil && (d.Usage.PromptTokens > 0 || d.Usage.TotalTokens > 0) { + lastUsage = *d.Usage + } + if d.Model != "" { + lastUsage.Model = d.Model + } + if len(d.Choices) == 0 { + continue + } + dl := d.Choices[0].Delta + rc := dl.ReasoningContent + if rc == "" { + rc = dl.Reasoning + } + if rc == "" { + rc = dl.Thought + } + if rc != "" { + if !inReasoning { + if content != "" { + flushTable() + if lineBuf != "" { + fmt.Println(renderMDLine(lineBuf, &mdSt)) + lineBuf = "" + } + fmt.Println() + } + fmt.Println(c("--- reasoning start ---", 36)) + inReasoning = true + } + fmt.Print(c(rc, 2)) + reas += rc + } + if dl.Content != "" { + if inReasoning { + fmt.Print("\n" + c("--- reasoning end ---", 36) + "\n\n") + inReasoning = false + } + content += dl.Content + lineBuf += dl.Content + for { + idx := strings.IndexByte(lineBuf, '\n') + if idx == -1 { + break + } + curLine := lineBuf[:idx] + lineBuf = lineBuf[idx+1:] + trimmed := strings.TrimSpace(curLine) + if strings.HasPrefix(trimmed, "```") { + flushTable() + fmt.Println(renderMDLine(curLine, &mdSt)) + } else if mdSt.inCode { + fmt.Println(renderMDLine(curLine, &mdSt)) + } else if isTableLine(curLine) { + tblBuf = append(tblBuf, curLine) + } else { + flushTable() + fmt.Println(renderMDLine(curLine, &mdSt)) + } + } + } + if len(dl.ToolCalls) > 0 { + if inReasoning { + fmt.Print("\n" + c("--- reasoning end ---", 36) + "\n") + inReasoning = false + } + if liveIdx == -1 { + // First tool call of the stream: flush any pending content line so it + // is not overwritten by the live tool-call line. + flushTable() + if lineBuf != "" { + fmt.Println(renderMDLine(lineBuf, &mdSt)) + lineBuf = "" + } + } + 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 + } + } + stepLive() + } + } + if err := ctx.Err(); err != nil { + return Message{}, lastUsage, err + } + flushTable() + if lineBuf != "" { + if !mdSt.inCode && isTableLine(lineBuf) { + tblBuf = append(tblBuf, lineBuf) + flushTable() + } else { + fmt.Println(renderMDLine(lineBuf, &mdSt)) + } + } + if liveIdx >= 0 { + // Finalize the in-progress tool-call line so following output starts fresh. + finishLive(true) + } + if inReasoning { + fmt.Print("\n" + c("--- reasoning end ---", 36) + "\n") + } + m := Message{Role: "assistant"} + m.ToolCallsShown = toolCallsShown + 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]) + } + } + if lastUsage.TotalTokens == 0 && lastUsage.PromptTokens > 0 { + lastUsage.TotalTokens = lastUsage.PromptTokens + lastUsage.CompletionTokens + } + return m, lastUsage, sc.Err() } func shell(ctx context.Context, cmd string, timeout int) string { - cmd = filterText(cmd) - cmdCtx, cancel := context.WithTimeout(ctx, time.Duration(timeout)*time.Second) - defer cancel() - c := exec.CommandContext(cmdCtx, "sh", "-c", cmd) - c.WaitDelay = 100 * time.Millisecond - out, err := c.CombinedOutput() - res := strings.TrimSpace(filterText(string(out))) - if ctx.Err() != nil { - return "[interrupted]\n\nexit: -1" - } - if cmdCtx.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) + cmd = filterText(cmd) + cmdCtx, cancel := context.WithTimeout(ctx, time.Duration(timeout)*time.Second) + defer cancel() + c := exec.CommandContext(cmdCtx, "sh", "-c", cmd) + c.WaitDelay = 100 * time.Millisecond + out, err := c.CombinedOutput() + res := strings.TrimSpace(filterText(string(out))) + if ctx.Err() != nil { + return "[interrupted]\n\nexit: -1" + } + if cmdCtx.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 "" + 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 "" } // lastRole returns the role of the last message in the conversation, or "" if // it is empty. It is used to detect when a turn ended on a tool result rather // than a normal assistant reply, so the conversation can be auto-continued. func lastRole(msgs []Message) string { - if len(msgs) == 0 { - return "" - } - return msgs[len(msgs)-1].Role + if len(msgs) == 0 { + return "" + } + return msgs[len(msgs)-1].Role } // writeFile writes content into the file at the given byte offset, optionally @@ -1593,59 +1593,59 @@ func lastRole(msgs []Message) string { // file. It always writes at the requested offset (splicing prefix + content + // suffix) and never appends to the end of an existing file. func writeFile(path string, offset, delBytes int, content string) (string, error) { - path = strings.TrimSpace(path) - if path == "" { - return "", errors.New("path is required") - } - if offset < 0 { - offset = 0 - } - if delBytes < 0 { - delBytes = 0 - } - var data []byte - if fileExists(path) { - var err error - data, err = os.ReadFile(path) - if err != nil { - return "", err - } - } else { - dir := filepath.Dir(path) - if dir != "" && dir != "." { - if err := os.MkdirAll(dir, 0755); err != nil { - return "", err - } - } - } - if offset > len(data) { - padding := make([]byte, offset-len(data)) - data = append(data, padding...) - } - prefix := data[:offset] - var suffix []byte - endDel := offset + delBytes - if endDel < len(data) { - suffix = data[endDel:] - } - contentBytes := []byte(content) - newData := make([]byte, 0, len(prefix)+len(contentBytes)+len(suffix)) - newData = append(newData, prefix...) - newData = append(newData, contentBytes...) - newData = append(newData, suffix...) - if err := os.WriteFile(path, newData, 0644); err != nil { - return "", err - } - return fmt.Sprintf("Successfully wrote %d bytes to %s", len(contentBytes), path), nil + path = strings.TrimSpace(path) + if path == "" { + return "", errors.New("path is required") + } + if offset < 0 { + offset = 0 + } + if delBytes < 0 { + delBytes = 0 + } + var data []byte + if fileExists(path) { + var err error + data, err = os.ReadFile(path) + if err != nil { + return "", err + } + } else { + dir := filepath.Dir(path) + if dir != "" && dir != "." { + if err := os.MkdirAll(dir, 0755); err != nil { + return "", err + } + } + } + if offset > len(data) { + padding := make([]byte, offset-len(data)) + data = append(data, padding...) + } + prefix := data[:offset] + var suffix []byte + endDel := offset + delBytes + if endDel < len(data) { + suffix = data[endDel:] + } + contentBytes := []byte(content) + newData := make([]byte, 0, len(prefix)+len(contentBytes)+len(suffix)) + newData = append(newData, prefix...) + newData = append(newData, contentBytes...) + newData = append(newData, suffix...) + if err := os.WriteFile(path, newData, 0644); err != nil { + return "", err + } + return fmt.Sprintf("Successfully wrote %d bytes to %s", len(contentBytes), path), nil } // toolResDir returns the directory where oversized tool results are spilled. func toolResDir() string { - tmp := os.Getenv("TMPDIR") - if tmp == "" { - tmp = "/tmp" - } - return filepath.Join(tmp, "bantam", "toolres") + tmp := os.Getenv("TMPDIR") + if tmp == "" { + tmp = "/tmp" + } + return filepath.Join(tmp, "bantam", "toolres") } // offloadToolResult keeps tool results that fit within maxRes bytes inline. @@ -1656,267 +1656,267 @@ func toolResDir() string { // delete it once the round ends. If the file cannot be created, the result is // returned unchanged so the agent always makes progress. func offloadToolResult(res string, maxRes int, tmps *[]string) string { - if maxRes <= 0 { - maxRes = 15000 - } - if len(res) <= maxRes { - return res - } - dir := toolResDir() - if err := os.MkdirAll(dir, 0755); err != nil { - return res - } - f, err := os.CreateTemp(dir, "toolres-*.txt") - if err != nil { - return res - } - name := f.Name() - n, werr := f.WriteString(res) - if cerr := f.Close(); werr == nil { - werr = cerr - } - if werr != nil { - os.Remove(name) - return res - } - *tmps = append(*tmps, name) - return fmt.Sprintf("[tool result too large for context: %d bytes (limit %d). The full output was saved to %s. Read it with shell_exec; to read it partially, use a byte offset and length, e.g. `tail -c +OFFSET %s | head -c LENGTH`. The file is %d bytes long.]", n, maxRes, name, name, n) + if maxRes <= 0 { + maxRes = 15000 + } + if len(res) <= maxRes { + return res + } + dir := toolResDir() + if err := os.MkdirAll(dir, 0755); err != nil { + return res + } + f, err := os.CreateTemp(dir, "toolres-*.txt") + if err != nil { + return res + } + name := f.Name() + n, werr := f.WriteString(res) + if cerr := f.Close(); werr == nil { + werr = cerr + } + if werr != nil { + os.Remove(name) + return res + } + *tmps = append(*tmps, name) + return fmt.Sprintf("[tool result too large for context: %d bytes (limit %d). The full output was saved to %s. Read it with shell_exec; to read it partially, use a byte offset and length, e.g. `tail -c +OFFSET %s | head -c LENGTH`. The file is %d bytes long.]", n, maxRes, name, name, n) } // cleanupTemps removes the temporary files created while offloading oversized // tool results during a single agent round. func cleanupTemps(files []string) { - for _, f := range files { - os.Remove(f) - } + for _, f := range files { + os.Remove(f) + } } func AL(ctx context.Context, cfg *Cfg, msgs []Message) ([]Message, Usage, error) { - done := false - var turnUsage Usage - var tmps []string - defer func() { cleanupTemps(tmps) }() - for i := 0; i < cfg.MaxALIterations && !done; i++ { - if err := ctx.Err(); err != nil { - return msgs, turnUsage, err - } - m, u, err := llm(ctx, cfg, msgs, TOOLS) - if err != nil { - if errors.Is(err, context.Canceled) || ctx.Err() != nil { - return msgs, turnUsage, err - } - if isInvalidAssistantErr(err) { - stripped := false - for j := len(msgs) - 1; j >= 0; j-- { - if msgs[j].Role == "assistant" { - fmt.Println(c("[stripped malformed assistant message]", 33)) - msgs = append(msgs[:j], msgs[j+1:]...) - stripped = true - break - } - } - if stripped { - continue - } - } - return msgs, turnUsage, err - } - turnUsage.PromptTokens = u.PromptTokens - turnUsage.CompletionTokens += u.CompletionTokens - turnUsage.TotalTokens += u.TotalTokens - // The last call sends the full conversation, so its context estimate is the - // authoritative one for the turn's context window statistics. - turnUsage.ContextTokens = u.ContextTokens - if u.Cached() > 0 { - turnUsage.CachedTokens = u.Cached() - } - if u.Model != "" { - turnUsage.Model = u.Model - } - for j := range m.ToolCalls { - tc := &m.ToolCalls[j] - tc.Function.Arguments = filterText(tc.Function.Arguments) - astr := tc.Function.Arguments - var a map[string]any - if err := json.Unmarshal([]byte(astr), &a); err != nil || a == nil { - fixed, _ := json.Marshal(map[string]string{"invalid_raw": astr}) - tc.Function.Arguments = string(fixed) - } - } - 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(renderMD(*m.Content)) - } - } - if len(m.ToolCalls) == 0 { - // If the model returned only a reasoning block with no non-reasoning - // tokens or tool calls, nudge it to continue rather than ending the turn. - if m.ReasoningContent != "" && (m.Content == nil || strings.TrimSpace(*m.Content) == "") { - fmt.Println(c("[auto continue: response was reasoning-only]", 33)) - msgs = append(msgs, Message{Role: "user", Content: strp("continue")}) - continue - } - done = true - break - } - for _, tc := range m.ToolCalls { - if err := ctx.Err(); err != nil { - return msgs, turnUsage, err - } - fn, astr := tc.Function.Name, filterText(tc.Function.Arguments) - if !m.ToolCallsShown { - 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) - cmd = filterText(cmd) - res = shell(ctx, cmd, cfg.ShellTimeout) - if err := ctx.Err(); err != nil { - return msgs, turnUsage, err - } - case "write_file": - path, _ := a["path"].(string) - path = filterText(path) - contentVal, hasContent := a["content"] - var content string - if hasContent && contentVal != nil { - if s, ok := contentVal.(string); ok { - content = s - } - } - hasOffset := false - offset := 0 - if v, ok := a["offset"]; ok && v != nil { - hasOffset = true - switch n := v.(type) { - case float64: - offset = int(n) - case int: - offset = n - case string: - offset = atoiD(n, 0) - } - } - if offset < 0 { - offset = 0 - } - hasDel := false - delBytes := 0 - if v, ok := a["del_bytes"]; ok { - hasDel = true - switch n := v.(type) { - case float64: - delBytes = int(n) - case int: - delBytes = n - case string: - delBytes = atoiD(n, 0) - } - } - if strings.TrimSpace(path) == "" { - res, sty = "[tool error: write_file requires 'path' parameter]", 31 - } else if !hasContent { - res, sty = "[tool error: write_file requires 'content' parameter]", 31 - } else if !hasOffset && !hasDel { - // Neither offset nor del_bytes was supplied: overwrite the entire - // file with the new content. The LLM usually just wants to replace a - // file and should not have to know about the tool's offset quirks; - // insertion/replace semantics are preserved when either is given. - p := strings.TrimSpace(path) - if dir := filepath.Dir(p); dir != "" && dir != "." { - if err := os.MkdirAll(dir, 0755); err != nil { - res, sty = fmt.Sprintf("[tool error: write_file %s: %v]", p, err), 31 - } - } - if res == "" { - if err := os.WriteFile(p, []byte(content), 0644); err != nil { - res, sty = fmt.Sprintf("[tool error: write_file %s: %v]", p, err), 31 - } else { - res, sty = fmt.Sprintf("Successfully wrote %d bytes to %s", len(content), p), 2 - } - } - } else { - out, err := writeFile(path, offset, delBytes, content) - if err != nil { - res, sty = fmt.Sprintf("[tool error: write_file %s: %v]", path, err), 31 - } else { - res, sty = out, 2 - } - } - default: - res, sty = "Unknown tool: "+fn, 31 - } - } - res = filterText(res) - ctxRes := offloadToolResult(res, cfg.MaxToolRes, &tmps) - fmt.Println(c("[tool result: "+fn+"]", 32) + "\n" + c(ctxRes, sty) + "\n") - msgs = append(msgs, Message{Role: "tool", ToolCallID: tc.ID, Content: strp(ctxRes)}) - } - } - if !done { - msgs = append(msgs, Message{Role: "assistant", Content: strp(fmt.Sprintf("[max AL iterations (%d) reached]", cfg.MaxALIterations))}) - } - return msgs, turnUsage, nil + done := false + var turnUsage Usage + var tmps []string + defer func() { cleanupTemps(tmps) }() + for i := 0; i < cfg.MaxALIterations && !done; i++ { + if err := ctx.Err(); err != nil { + return msgs, turnUsage, err + } + m, u, err := llm(ctx, cfg, msgs, TOOLS) + if err != nil { + if errors.Is(err, context.Canceled) || ctx.Err() != nil { + return msgs, turnUsage, err + } + if isInvalidAssistantErr(err) { + stripped := false + for j := len(msgs) - 1; j >= 0; j-- { + if msgs[j].Role == "assistant" { + fmt.Println(c("[stripped malformed assistant message]", 33)) + msgs = append(msgs[:j], msgs[j+1:]...) + stripped = true + break + } + } + if stripped { + continue + } + } + return msgs, turnUsage, err + } + turnUsage.PromptTokens = u.PromptTokens + turnUsage.CompletionTokens += u.CompletionTokens + turnUsage.TotalTokens += u.TotalTokens + // The last call sends the full conversation, so its context estimate is the + // authoritative one for the turn's context window statistics. + turnUsage.ContextTokens = u.ContextTokens + if u.Cached() > 0 { + turnUsage.CachedTokens = u.Cached() + } + if u.Model != "" { + turnUsage.Model = u.Model + } + for j := range m.ToolCalls { + tc := &m.ToolCalls[j] + tc.Function.Arguments = filterText(tc.Function.Arguments) + astr := tc.Function.Arguments + var a map[string]any + if err := json.Unmarshal([]byte(astr), &a); err != nil || a == nil { + fixed, _ := json.Marshal(map[string]string{"invalid_raw": astr}) + tc.Function.Arguments = string(fixed) + } + } + 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(renderMD(*m.Content)) + } + } + if len(m.ToolCalls) == 0 { + // If the model returned only a reasoning block with no non-reasoning + // tokens or tool calls, nudge it to continue rather than ending the turn. + if m.ReasoningContent != "" && (m.Content == nil || strings.TrimSpace(*m.Content) == "") { + fmt.Println(c("[auto continue: response was reasoning-only]", 33)) + msgs = append(msgs, Message{Role: "user", Content: strp("continue")}) + continue + } + done = true + break + } + for _, tc := range m.ToolCalls { + if err := ctx.Err(); err != nil { + return msgs, turnUsage, err + } + fn, astr := tc.Function.Name, filterText(tc.Function.Arguments) + if !m.ToolCallsShown { + 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) + cmd = filterText(cmd) + res = shell(ctx, cmd, cfg.ShellTimeout) + if err := ctx.Err(); err != nil { + return msgs, turnUsage, err + } + case "write_file": + path, _ := a["path"].(string) + path = filterText(path) + contentVal, hasContent := a["content"] + var content string + if hasContent && contentVal != nil { + if s, ok := contentVal.(string); ok { + content = s + } + } + hasOffset := false + offset := 0 + if v, ok := a["offset"]; ok && v != nil { + hasOffset = true + switch n := v.(type) { + case float64: + offset = int(n) + case int: + offset = n + case string: + offset = atoiD(n, 0) + } + } + if offset < 0 { + offset = 0 + } + hasDel := false + delBytes := 0 + if v, ok := a["del_bytes"]; ok { + hasDel = true + switch n := v.(type) { + case float64: + delBytes = int(n) + case int: + delBytes = n + case string: + delBytes = atoiD(n, 0) + } + } + if strings.TrimSpace(path) == "" { + res, sty = "[tool error: write_file requires 'path' parameter]", 31 + } else if !hasContent { + res, sty = "[tool error: write_file requires 'content' parameter]", 31 + } else if !hasOffset && !hasDel { + // Neither offset nor del_bytes was supplied: overwrite the entire + // file with the new content. The LLM usually just wants to replace a + // file and should not have to know about the tool's offset quirks; + // insertion/replace semantics are preserved when either is given. + p := strings.TrimSpace(path) + if dir := filepath.Dir(p); dir != "" && dir != "." { + if err := os.MkdirAll(dir, 0755); err != nil { + res, sty = fmt.Sprintf("[tool error: write_file %s: %v]", p, err), 31 + } + } + if res == "" { + if err := os.WriteFile(p, []byte(content), 0644); err != nil { + res, sty = fmt.Sprintf("[tool error: write_file %s: %v]", p, err), 31 + } else { + res, sty = fmt.Sprintf("Successfully wrote %d bytes to %s", len(content), p), 2 + } + } + } else { + out, err := writeFile(path, offset, delBytes, content) + if err != nil { + res, sty = fmt.Sprintf("[tool error: write_file %s: %v]", path, err), 31 + } else { + res, sty = out, 2 + } + } + default: + res, sty = "Unknown tool: "+fn, 31 + } + } + res = filterText(res) + ctxRes := offloadToolResult(res, cfg.MaxToolRes, &tmps) + fmt.Println(c("[tool result: "+fn+"]", 32) + "\n" + c(ctxRes, sty) + "\n") + msgs = append(msgs, Message{Role: "tool", ToolCallID: tc.ID, Content: strp(ctxRes)}) + } + } + if !done { + msgs = append(msgs, Message{Role: "assistant", Content: strp(fmt.Sprintf("[max AL iterations (%d) reached]", cfg.MaxALIterations))}) + } + return msgs, turnUsage, nil } func homeDir() string { - if h, err := os.UserHomeDir(); err == nil && h != "" { - return h - } - return "." + 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 + 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"` + 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)" + 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 + _, err := os.Stat(p) + return err == nil } func projectID() string { - dir, err := os.Getwd() - if err != nil { - dir = "." - } - if abs, err := filepath.Abs(dir); err == nil { - dir = abs - } - h := md5.Sum([]byte(dir)) - return hex.EncodeToString(h[:]) + dir, err := os.Getwd() + if err != nil { + dir = "." + } + if abs, err := filepath.Abs(dir); err == nil { + dir = abs + } + h := md5.Sum([]byte(dir)) + return hex.EncodeToString(h[:]) } // opencodeSessionID returns a Zen-compatible session id of the form @@ -1925,51 +1925,51 @@ func projectID() string { // the literal "ffe"), and the Zen free-tier edge accepts freshly generated // ids in this format, unlike uppercase/ULID-shaped ids. func opencodeSessionID() string { - ms := time.Now().UnixNano() / int64(time.Millisecond) - var b strings.Builder - b.WriteString("ses_") - b.WriteString(fmt.Sprintf("%09x", uint64(ms)&0xfffffffff)) - b.WriteString("ffe") - buf := make([]byte, 14) - if _, err := rand.Read(buf); err != nil { - h := md5.Sum([]byte(fmt.Sprintf("%d-%d", time.Now().UnixNano(), os.Getpid()))) - for i := range buf { - buf[i] = h[i%len(h)] - } - } - for _, x := range buf { - b.WriteByte(opencodeTailAlphabet[int(x)%len(opencodeTailAlphabet)]) - } - return b.String() + ms := time.Now().UnixNano() / int64(time.Millisecond) + var b strings.Builder + b.WriteString("ses_") + b.WriteString(fmt.Sprintf("%09x", uint64(ms)&0xfffffffff)) + b.WriteString("ffe") + buf := make([]byte, 14) + if _, err := rand.Read(buf); err != nil { + h := md5.Sum([]byte(fmt.Sprintf("%d-%d", time.Now().UnixNano(), os.Getpid()))) + for i := range buf { + buf[i] = h[i%len(h)] + } + } + for _, x := range buf { + b.WriteByte(opencodeTailAlphabet[int(x)%len(opencodeTailAlphabet)]) + } + return b.String() } // isOpencodeEndpoint reports whether the endpoint belongs to OpenCode Zen and // therefore expects the x-opencode-* request headers. func isOpencodeEndpoint(endpoint string) bool { - u, err := url.Parse(strings.TrimSpace(endpoint)) - if err != nil || u.Hostname() == "" { - return strings.Contains(strings.ToLower(endpoint), "opencode.ai") - } - h := strings.ToLower(u.Hostname()) - return h == "opencode.ai" || strings.HasSuffix(h, ".opencode.ai") + u, err := url.Parse(strings.TrimSpace(endpoint)) + if err != nil || u.Hostname() == "" { + return strings.Contains(strings.ToLower(endpoint), "opencode.ai") + } + h := strings.ToLower(u.Hostname()) + return h == "opencode.ai" || strings.HasSuffix(h, ".opencode.ai") } // ocRequestID returns a fresh OpenCode-style message id (msg_ followed by 26 // base32 characters) matching the ids sent in the x-opencode-request header. func ocRequestID() string { - buf := make([]byte, 26) - if _, err := rand.Read(buf); err != nil { - h := md5.Sum([]byte(fmt.Sprintf("%d-%d", time.Now().UnixNano(), os.Getpid()))) - for i := range buf { - buf[i] = h[i%len(h)] - } - } - var b strings.Builder - b.WriteString("msg_") - for _, x := range buf { - b.WriteByte(opencodeIDAlphabet[int(x)&31]) - } - return b.String() + buf := make([]byte, 26) + if _, err := rand.Read(buf); err != nil { + h := md5.Sum([]byte(fmt.Sprintf("%d-%d", time.Now().UnixNano(), os.Getpid()))) + for i := range buf { + buf[i] = h[i%len(h)] + } + } + var b strings.Builder + b.WriteString("msg_") + for _, x := range buf { + b.WriteByte(opencodeIDAlphabet[int(x)&31]) + } + return b.String() } // applyLLMHeaders sets the request headers appropriate for the configured @@ -1977,609 +1977,609 @@ func ocRequestID() string { // full provider User-Agent; all other endpoints keep the legacy // session-affinity headers. msgID is used for the x-opencode-request header. func applyLLMHeaders(req *http.Request, cfg *Cfg, msgID string) { - sid := opencodeSessionID() - if isOpencodeEndpoint(cfg.Endpoint) { - req.Header.Set("User-Agent", opencodeAgentVersion+" "+opencodeProviderUA) - req.Header.Set("x-opencode-client", "cli") - req.Header.Set("x-opencode-project", opencodeProjectID) - req.Header.Set("x-opencode-session", sid) - req.Header.Set("x-opencode-request", msgID) - } else { - req.Header.Set("User-Agent", opencodeAgentVersion) - req.Header.Set("x-session-affinity", sid) - req.Header.Set("X-Session-Id", sid) - } - if cfg.APIKey != "" && cfg.APIKey != "-" { - req.Header.Set("Authorization", "Bearer "+cfg.APIKey) - } + sid := opencodeSessionID() + if isOpencodeEndpoint(cfg.Endpoint) { + req.Header.Set("User-Agent", opencodeAgentVersion+" "+opencodeProviderUA) + req.Header.Set("x-opencode-client", "cli") + req.Header.Set("x-opencode-project", opencodeProjectID) + req.Header.Set("x-opencode-session", sid) + req.Header.Set("x-opencode-request", msgID) + } else { + req.Header.Set("User-Agent", opencodeAgentVersion) + req.Header.Set("x-session-affinity", sid) + req.Header.Set("X-Session-Id", sid) + } + if cfg.APIKey != "" && cfg.APIKey != "-" { + req.Header.Set("Authorization", "Bearer "+cfg.APIKey) + } } func saveSession(msgs []Message, sid string) (string, string) { - sid = strings.TrimSpace(sid) - if sid == "" { - sid = projectID() - } - d := sdir() - path := filepath.Join(d, sid+".json") - s := Session{sid, time.Now().Format("2006-01-02 15:04:05"), summary(msgs), msgs} - b, err := json.MarshalIndent(s, "", " ") - if err != nil { - fmt.Fprintf(os.Stderr, "saveSession: marshal error: %v\n", err) - return sid, s.Summary - } - if err := os.WriteFile(path, b, 0644); err != nil { - fmt.Fprintf(os.Stderr, "saveSession: write error: %v\n", err) - } - return sid, s.Summary + sid = strings.TrimSpace(sid) + if sid == "" { + sid = projectID() + } + d := sdir() + path := filepath.Join(d, sid+".json") + s := Session{sid, time.Now().Format("2006-01-02 15:04:05"), summary(msgs), msgs} + b, err := json.MarshalIndent(s, "", " ") + if err != nil { + fmt.Fprintf(os.Stderr, "saveSession: marshal error: %v\n", err) + return sid, s.Summary + } + if err := os.WriteFile(path, b, 0644); err != nil { + fmt.Fprintf(os.Stderr, "saveSession: write error: %v\n", err) + } + 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 + 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) + 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) } // findProjectSession returns the saved session for the current working // directory (if any), or nil when none exists yet. func findProjectSession() *Session { - pid := projectID() - ss := sessions() - for i := range ss { - if ss[i].ID == pid { - return &ss[i] - } - } - return nil + pid := projectID() + ss := sessions() + for i := range ss { + if ss[i].ID == pid { + return &ss[i] + } + } + return nil } func autosave(msgs []Message) { - saveSession(msgs, projectID()) + saveSession(msgs, projectID()) } const compactionPrompt = "You are now acting as a compaction engine. Summarize the preceding conversation concisely but completely, preserving all important facts, decisions, code snippets, tool outputs, errors, and current task state so work can seamlessly continue. Output only the summary." func compact(ctx context.Context, cfg *Cfg, msgs []Message) ([]Message, string, error) { - if len(msgs) == 0 || msgs[0].Role != "system" { - return msgs, "", errors.New("session has no system message") - } - if len(msgs) <= 1 { - return msgs, "", errors.New("nothing to compact") - } - cMsgs := append(append([]Message{}, msgs...), Message{ - Role: "user", - Content: strp(compactionPrompt), - }) - cc := *cfg - cc.Stream = false - m, _, err := llm(ctx, &cc, cMsgs, nil) - if err != nil { - return msgs, "", err - } - s := "" - if m.Content != nil { - s = *m.Content - } - if s == "" { - s = m.ReasoningContent - } - s = strings.TrimSpace(s) - if s == "" { - return msgs, "", errors.New("LLM returned an empty summary") - } - newMsgs := []Message{ - {Role: "system", Content: msgs[0].Content}, - {Role: "user", Content: strp("Summary of the previous conversation:\n" + s + "\n\nPlease continue from here.")}, - } - return newMsgs, s, nil + if len(msgs) == 0 || msgs[0].Role != "system" { + return msgs, "", errors.New("session has no system message") + } + if len(msgs) <= 1 { + return msgs, "", errors.New("nothing to compact") + } + cMsgs := append(append([]Message{}, msgs...), Message{ + Role: "user", + Content: strp(compactionPrompt), + }) + cc := *cfg + cc.Stream = false + m, _, err := llm(ctx, &cc, cMsgs, nil) + if err != nil { + return msgs, "", err + } + s := "" + if m.Content != nil { + s = *m.Content + } + if s == "" { + s = m.ReasoningContent + } + s = strings.TrimSpace(s) + if s == "" { + return msgs, "", errors.New("LLM returned an empty summary") + } + newMsgs := []Message{ + {Role: "system", Content: msgs[0].Content}, + {Role: "user", Content: strp("Summary of the previous conversation:\n" + s + "\n\nPlease continue from here.")}, + } + return newMsgs, s, nil } func summarize(ctx context.Context, cfg *Cfg, msgs []Message) (string, error) { - _, s, err := compact(ctx, cfg, msgs) - return s, err + _, s, err := compact(ctx, cfg, msgs) + return s, err } 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) - } - } + 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) + 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) + 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 + 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 + 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 - if W < 1 { - W = 1 - } - runes := []rune(s) - n := len(runes) - for i := 0; i < n; i++ { - r := runes[i] - if i == pos { - return row, col - } - if r == '\n' { - row++ - col = 0 - continue - } - // The rune at index i is displayed at (row, col). If it lands on the last - // column, the NEXT rune wraps to the start of the following line (unless - // this is the final rune, in which case the cursor stays at that column). - if col >= W-1 { - if i < n-1 { - row++ - col = 0 - } - } else { - col++ - } - } - return row, col + row, col = 0, promptLen + if W < 1 { + W = 1 + } + runes := []rune(s) + n := len(runes) + for i := 0; i < n; i++ { + r := runes[i] + if i == pos { + return row, col + } + if r == '\n' { + row++ + col = 0 + continue + } + // The rune at index i is displayed at (row, col). If it lands on the last + // column, the NEXT rune wraps to the start of the following line (unless + // this is the final rune, in which case the cursor stays at that column). + if col >= W-1 { + if i < n-1 { + row++ + col = 0 + } + } 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) - } - // In raw mode OPOST is off, so a bare \n only moves down without - // returning to column 0. Emit \r\n so each logical line starts at - // column 0, matching the column-reset assumption in textPos(). - fmt.Print("\r\033[J" + e.prompt + strings.ReplaceAll(s, "\n", "\r\n")) - 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 + 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) + } + // In raw mode OPOST is off, so a bare \n only moves down without + // returning to column 0. Emit \r\n so each logical line starts at + // column 0, matching the column-reset assumption in textPos(). + fmt.Print("\r\033[J" + e.prompt + strings.ReplaceAll(s, "\n", "\r\n")) + 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) + 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) } // wordBack moves the cursor back to the start of the current/previous word. func (e *editor) wordBack() { - for e.pos > 0 && unicode.IsSpace(e.buf[e.pos-1]) { - e.pos-- - } - for e.pos > 0 && !unicode.IsSpace(e.buf[e.pos-1]) { - e.pos-- - } + for e.pos > 0 && unicode.IsSpace(e.buf[e.pos-1]) { + e.pos-- + } + for e.pos > 0 && !unicode.IsSpace(e.buf[e.pos-1]) { + e.pos-- + } } // wordFwd moves the cursor forward to the end of the current/next word. func (e *editor) wordFwd() { - n := len(e.buf) - for e.pos < n && unicode.IsSpace(e.buf[e.pos]) { - e.pos++ - } - for e.pos < n && !unicode.IsSpace(e.buf[e.pos]) { - e.pos++ - } + n := len(e.buf) + for e.pos < n && unicode.IsSpace(e.buf[e.pos]) { + e.pos++ + } + for e.pos < n && !unicode.IsSpace(e.buf[e.pos]) { + e.pos++ + } } // delWordBack deletes the word preceding the cursor (Emacs Ctrl+W). func (e *editor) delWordBack() { - if e.pos == 0 { - return - } - start := e.pos - for start > 0 && unicode.IsSpace(e.buf[start-1]) { - start-- - } - for start > 0 && !unicode.IsSpace(e.buf[start-1]) { - start-- - } - e.buf = append(e.buf[:start], e.buf[e.pos:]...) - e.pos = start + if e.pos == 0 { + return + } + start := e.pos + for start > 0 && unicode.IsSpace(e.buf[start-1]) { + start-- + } + for start > 0 && !unicode.IsSpace(e.buf[start-1]) { + start-- + } + e.buf = append(e.buf[:start], e.buf[e.pos:]...) + e.pos = start } 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 + 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.Print("\r\n") - return "", false - } - switch rn { - case '\r': - if stdin.Buffered() > 0 { - if b, _ := stdin.Peek(1); len(b) > 0 && b[0] == '\n' { - stdin.ReadByte() - } - } - W := termWidth() - s := string(e.buf) - P := visibleLen(e.prompt) - er, _ := textPos(P, W, s, len([]rune(s))) - pr, _ := textPos(P, W, s, e.pos) - if down := er - pr; down > 0 { - fmt.Printf("\033[%dB", down) - } - fmt.Print("\r\n") - 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.Print("^C\r\n") - return "", true - case 0x04: - if len(e.buf) == 0 { - fmt.Print("\r\n") - return "", false - } - // Ctrl+D with non-empty buffer: delete character under cursor - if e.pos < len(e.buf) { - e.buf = append(e.buf[:e.pos], e.buf[e.pos+1:]...) - } - case 0x01: - e.pos = 0 // Ctrl+A: beginning of line - case 0x05: - e.pos = len(e.buf) // Ctrl+E: end of line - case 0x02: - if e.pos > 0 { - e.pos-- - } // Ctrl+B: backward char - case 0x06: - if e.pos < len(e.buf) { - e.pos++ - } // Ctrl+F: forward char - case 0x17: - e.delWordBack() // Ctrl+W: delete previous word - case 0x0b: - e.buf = e.buf[:e.pos] // Ctrl+K: kill to end of line - case 0x15: - e.buf = e.buf[e.pos:] // Ctrl+U: kill to start of line - e.pos = 0 - 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() - if err1 != nil { - continue - } - if b1 == '[' { - // Read the full CSI sequence (terminated by a byte in 0x40-0x7e). - var seq []byte - for { - b, err := stdin.ReadByte() - if err != nil { - break - } - seq = append(seq, b) - if b >= 0x40 && b <= 0x7e { - break - } - } - if len(seq) == 0 { - continue - } - last := seq[len(seq)-1] - switch last { - case 'A': - e.histNav(true) - case 'B': - e.histNav(false) - case 'C': - if strings.Contains(string(seq), "5") { - e.wordFwd() // Ctrl+Right - } else if e.pos < len(e.buf) { - e.pos++ - } - case 'D': - if strings.Contains(string(seq), "5") { - e.wordBack() // Ctrl+Left - } else if e.pos > 0 { - e.pos-- - } - case 'H': - e.pos = 0 // Home (ESC[H) - case 'F': - e.pos = len(e.buf) // End (ESC[F) - case '~': - // Home/End on some terminals: ESC[1~ / ESC[4~ - if len(seq) >= 1 && seq[0] == '1' { - e.pos = 0 - } else if len(seq) >= 1 && seq[0] == '4' { - e.pos = len(e.buf) - } - } - } else if b1 == 'O' { - // xterm application cursor keys: ESC O H/F (Home/End), A-D (arrows) - b2, err2 := stdin.ReadByte() - if err2 != nil { - continue - } - switch b2 { - case 'H': - e.pos = 0 - case 'F': - e.pos = len(e.buf) - 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++ - } - } - } + 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.Print("\r\n") + return "", false + } + switch rn { + case '\r': + if stdin.Buffered() > 0 { + if b, _ := stdin.Peek(1); len(b) > 0 && b[0] == '\n' { + stdin.ReadByte() + } + } + W := termWidth() + s := string(e.buf) + P := visibleLen(e.prompt) + er, _ := textPos(P, W, s, len([]rune(s))) + pr, _ := textPos(P, W, s, e.pos) + if down := er - pr; down > 0 { + fmt.Printf("\033[%dB", down) + } + fmt.Print("\r\n") + 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.Print("^C\r\n") + return "", true + case 0x04: + if len(e.buf) == 0 { + fmt.Print("\r\n") + return "", false + } + // Ctrl+D with non-empty buffer: delete character under cursor + if e.pos < len(e.buf) { + e.buf = append(e.buf[:e.pos], e.buf[e.pos+1:]...) + } + case 0x01: + e.pos = 0 // Ctrl+A: beginning of line + case 0x05: + e.pos = len(e.buf) // Ctrl+E: end of line + case 0x02: + if e.pos > 0 { + e.pos-- + } // Ctrl+B: backward char + case 0x06: + if e.pos < len(e.buf) { + e.pos++ + } // Ctrl+F: forward char + case 0x17: + e.delWordBack() // Ctrl+W: delete previous word + case 0x0b: + e.buf = e.buf[:e.pos] // Ctrl+K: kill to end of line + case 0x15: + e.buf = e.buf[e.pos:] // Ctrl+U: kill to start of line + e.pos = 0 + 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() + if err1 != nil { + continue + } + if b1 == '[' { + // Read the full CSI sequence (terminated by a byte in 0x40-0x7e). + var seq []byte + for { + b, err := stdin.ReadByte() + if err != nil { + break + } + seq = append(seq, b) + if b >= 0x40 && b <= 0x7e { + break + } + } + if len(seq) == 0 { + continue + } + last := seq[len(seq)-1] + switch last { + case 'A': + e.histNav(true) + case 'B': + e.histNav(false) + case 'C': + if strings.Contains(string(seq), "5") { + e.wordFwd() // Ctrl+Right + } else if e.pos < len(e.buf) { + e.pos++ + } + case 'D': + if strings.Contains(string(seq), "5") { + e.wordBack() // Ctrl+Left + } else if e.pos > 0 { + e.pos-- + } + case 'H': + e.pos = 0 // Home (ESC[H) + case 'F': + e.pos = len(e.buf) // End (ESC[F) + case '~': + // Home/End on some terminals: ESC[1~ / ESC[4~ + if len(seq) >= 1 && seq[0] == '1' { + e.pos = 0 + } else if len(seq) >= 1 && seq[0] == '4' { + e.pos = len(e.buf) + } + } + } else if b1 == 'O' { + // xterm application cursor keys: ESC O H/F (Home/End), A-D (arrows) + b2, err2 := stdin.ReadByte() + if err2 != nil { + continue + } + switch b2 { + case 'H': + e.pos = 0 + case 'F': + e.pos = len(e.buf) + 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++ + } + } + } } // skillPrompt resolves and composes the prompt for the /skill command: it reads // //SKILL.md (or an absolute path when no skills directory is set) and // prefixes its contents to the optional user prompt. func skillPrompt(u string, cfg *Cfg) (string, error) { - rest := strings.TrimSpace(strings.TrimPrefix(u, "/skill")) - if rest == "" { - return "", fmt.Errorf("usage: /skill [prompt]") - } - fields := strings.SplitN(rest, " ", 2) - name := fields[0] - prompt := "" - if len(fields) == 2 { - prompt = strings.TrimSpace(fields[1]) - } - sd := skillsDir(cfg) - var path string - if sd != "" { - path = filepath.Join(sd, name, "SKILL.md") - } else if strings.HasSuffix(name, "SKILL.md") { - path = name // absolute path to the SKILL.md file itself - } else { - path = filepath.Join(name, "SKILL.md") // absolute path to the skill directory - } - data, err := os.ReadFile(path) - if err != nil { - return "", fmt.Errorf("cannot read skill %q: %v", name, err) - } - content := strings.TrimRight(string(data), "\r\n") - if prompt != "" { - return content + "\n\n" + prompt, nil - } - return content, nil + rest := strings.TrimSpace(strings.TrimPrefix(u, "/skill")) + if rest == "" { + return "", fmt.Errorf("usage: /skill [prompt]") + } + fields := strings.SplitN(rest, " ", 2) + name := fields[0] + prompt := "" + if len(fields) == 2 { + prompt = strings.TrimSpace(fields[1]) + } + sd := skillsDir(cfg) + var path string + if sd != "" { + path = filepath.Join(sd, name, "SKILL.md") + } else if strings.HasSuffix(name, "SKILL.md") { + path = name // absolute path to the SKILL.md file itself + } else { + path = filepath.Join(name, "SKILL.md") // absolute path to the skill directory + } + data, err := os.ReadFile(path) + if err != nil { + return "", fmt.Errorf("cannot read skill %q: %v", name, err) + } + content := strings.TrimRight(string(data), "\r\n") + if prompt != "" { + return content + "\n\n" + prompt, nil + } + return content, nil } // listSkills prints every skill found under the configured skills directory, or a // clear notice when none is configured / none are present. func listSkills(cfg *Cfg) { - sd := skillsDir(cfg) - if sd == "" { - fmt.Println(c("No skills directory configured (set BANTAM_SKILLS_DIR or bantam_skills_dir).", 33)) - return - } - entries, err := os.ReadDir(sd) - if err != nil { - fmt.Println(c("[skill error: cannot read skills dir: "+err.Error()+"]", 31)) - return - } - var names []string - for _, e := range entries { - if e.IsDir() { - if _, err := os.Stat(filepath.Join(sd, e.Name(), "SKILL.md")); err == nil { - names = append(names, e.Name()) - } - } - } - if len(names) == 0 { - fmt.Println(c("No skills found in "+sd, 33)) - return - } - fmt.Println(c("Available skills in "+sd+":", 1, 36)) - for _, n := range names { - fmt.Println(c(" "+n, 32)) - } + sd := skillsDir(cfg) + if sd == "" { + fmt.Println(c("No skills directory configured (set BANTAM_SKILLS_DIR or bantam_skills_dir).", 33)) + return + } + entries, err := os.ReadDir(sd) + if err != nil { + fmt.Println(c("[skill error: cannot read skills dir: "+err.Error()+"]", 31)) + return + } + var names []string + for _, e := range entries { + if e.IsDir() { + if _, err := os.Stat(filepath.Join(sd, e.Name(), "SKILL.md")); err == nil { + names = append(names, e.Name()) + } + } + } + if len(names) == 0 { + fmt.Println(c("No skills found in "+sd, 33)) + return + } + fmt.Println(c("Available skills in "+sd+":", 1, 36)) + for _, n := range names { + fmt.Println(c(" "+n, 32)) + } } func runDirectShell(cmd string, timeout int) { - cmd = filterText(strings.TrimSpace(cmd)) - if cmd == "" { - return - } - astr, _ := json.Marshal(map[string]string{"command": cmd}) - fmt.Println(c(fmt.Sprintf("[tool call: shell_exec(%s)]", string(astr)), 33)) - sigCtx, cancel := signal.NotifyContext(context.Background(), os.Interrupt) - res := shell(sigCtx, cmd, timeout) - cancel() - fmt.Println(c("[tool result: shell_exec]", 32) + "\n" + c(res, 2)) + cmd = filterText(strings.TrimSpace(cmd)) + if cmd == "" { + return + } + astr, _ := json.Marshal(map[string]string{"command": cmd}) + fmt.Println(c(fmt.Sprintf("[tool call: shell_exec(%s)]", string(astr)), 33)) + sigCtx, cancel := signal.NotifyContext(context.Background(), os.Interrupt) + res := shell(sigCtx, cmd, timeout) + cancel() + fmt.Println(c("[tool result: shell_exec]", 32) + "\n" + c(res, 2)) } func doCompact(cfg *Cfg, msgs []Message) []Message { - if len(msgs) <= 1 { - fmt.Println(c("Nothing to compact yet.", 33)) - return msgs - } - fmt.Println(c("[compacting conversation...]", 33)) - sigCtx, cancel := signal.NotifyContext(context.Background(), os.Interrupt) - nm, sm, err := compact(sigCtx, cfg, msgs) - interrupted := sigCtx.Err() != nil - cancel() - if err != nil { - if interrupted || errors.Is(err, context.Canceled) { - fmt.Println(c("\n[interrupted]", 33)) - } else { - fmt.Println(c("[compact failed: "+err.Error()+"]", 31)) - } - return msgs - } - 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)) - return msgs + if len(msgs) <= 1 { + fmt.Println(c("Nothing to compact yet.", 33)) + return msgs + } + fmt.Println(c("[compacting conversation...]", 33)) + sigCtx, cancel := signal.NotifyContext(context.Background(), os.Interrupt) + nm, sm, err := compact(sigCtx, cfg, msgs) + interrupted := sigCtx.Err() != nil + cancel() + if err != nil { + if interrupted || errors.Is(err, context.Canceled) { + fmt.Println(c("\n[interrupted]", 33)) + } else { + fmt.Println(c("[compact failed: "+err.Error()+"]", 31)) + } + return msgs + } + 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)) + return msgs } // buildSystemPrompt renders the built-in system prompt for cfg: it expands the @@ -2587,271 +2587,271 @@ func doCompact(cfg *Cfg, msgs []Message) []Message { // configured max_tool_res value, and appends the optional extra-tools and // skills hints. func buildSystemPrompt(cfg *Cfg) string { - sp := defaultSystemPrompt - tmp := os.Getenv("TMPDIR") - if tmp == "" { - tmp = "/tmp" - } - bantamTmp := filepath.Join(tmp, "bantam") - sp = strings.ReplaceAll(sp, "$TMPDIR/bantam", bantamTmp) - sp = strings.ReplaceAll(sp, "$MAX_TOOL_RES", strconv.Itoa(cfg.MaxToolRes)) - if td := toolsDir(cfg); td != "" { - sp += "\n\nExtra shell tools can be found at " + td - } - if sd := skillsDir(cfg); sd != "" { - sp += "\n\nSkills may be discovered and invoked from " + sd - } - return sp + sp := defaultSystemPrompt + tmp := os.Getenv("TMPDIR") + if tmp == "" { + tmp = "/tmp" + } + bantamTmp := filepath.Join(tmp, "bantam") + sp = strings.ReplaceAll(sp, "$TMPDIR/bantam", bantamTmp) + sp = strings.ReplaceAll(sp, "$MAX_TOOL_RES", strconv.Itoa(cfg.MaxToolRes)) + if td := toolsDir(cfg); td != "" { + sp += "\n\nExtra shell tools can be found at " + td + } + if sd := skillsDir(cfg); sd != "" { + sp += "\n\nSkills may be discovered and invoked from " + sd + } + return sp } func main() { - cfg := getCfg(configPath()) - cfg.ContextWindow = fetchContextWindow(&cfg) - sp := buildSystemPrompt(&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) - } - u := strings.TrimSpace(string(data)) - if strings.HasPrefix(u, "!") { - runDirectShell(strings.TrimPrefix(u, "!"), cfg.ShellTimeout) - return - } - msgs = append(msgs, Message{Role: "user", Content: strp(u)}) - sigCtx, cancel := signal.NotifyContext(context.Background(), os.Interrupt) - var usg Usage - msgs, usg, err = AL(sigCtx, &cfg, msgs) - if lastRole(msgs) == "tool" { - msgs = append(msgs, Message{Role: "user", Content: strp("continue")}) - fmt.Println(c("[auto continue: last message was a tool result]", 33)) - msgs, usg, err = AL(sigCtx, &cfg, msgs) - } - interrupted := sigCtx.Err() != nil - cancel() - if err != nil { - if interrupted || errors.Is(err, context.Canceled) { - fmt.Println(c("\n[interrupted]", 33)) - } else { - fmt.Println(c("[error: "+err.Error()+"]", 31)) - } - os.Exit(1) - } - fmt.Println(c(formatUsage(usg, cfg.ContextWindow), 2)) - autosave(msgs) - return - } - loadHistory() - // Interactive startup: if a session already exists for this directory, offer - // to autoload it so the user can seamlessly resume prior work. - if sess := findProjectSession(); sess != nil && len(sess.Messages) > 1 { - fmt.Println(c("[session found for this directory]", 33) + " " + c(sess.ID, 32)) - fmt.Println(" " + c(sess.Summary, 2)) - fmt.Println(c(fmt.Sprintf(" created %s [%d messages]", sess.Created, len(sess.Messages)), 2)) - fmt.Print(c("Resume this session? [Y/n]: ", 33)) - if ans, ok := readPlain(""); ok { - ans = strings.TrimSpace(strings.ToLower(ans)) - if ans == "" || ans == "y" || ans == "yes" { - msgs = sess.Messages - autosave(msgs) - fmt.Println(c("[session resumed: "+sess.ID+"]", 32) + " " + c(summary(msgs), 2)) - } - } - } - re := cfg.Raw["reasoning_effort"] - if re == "" { - re = "high" - } - fmt.Println(c("Bantam Agent ready", 1, 32) + c(" (Ctrl+J = new line)", 2)) - fmt.Println(c(fmt.Sprintf("endpoint: %s model: %s temp: %v reasoning: %s context: %d", cfg.Endpoint, cfg.Model, cfg.Temperature, re, cfg.ContextWindow), 2)) - for { - u, ok := readLine(c("> ", 1, 36)) - if !ok { - fmt.Println() - break - } - u = strings.TrimSpace(u) - if u == "" { - continue - } - addHistory(u) - // Command aliases: /model -> /cfg model, /endpoint -> /cfg endpoint. - // (Note: /models is a distinct command and is intentionally not matched.) - if u == "/model" { - u = "/cfg model" - } else if strings.HasPrefix(u, "/model ") { - u = "/cfg model " + strings.TrimSpace(strings.TrimPrefix(u, "/model ")) - } else if u == "/endpoint" { - u = "/cfg endpoint" - } else if strings.HasPrefix(u, "/endpoint ") { - u = "/cfg endpoint " + strings.TrimSpace(strings.TrimPrefix(u, "/endpoint ")) - } - switch { - case u == "/quit": - goto done - case u == "/clear": - msgs = []Message{{Role: "system", Content: strp(sp)}} - autosave(msgs) - continue - case u == "/save" || strings.HasPrefix(u, "/save "): - name := strings.TrimSpace(strings.TrimPrefix(u, "/save")) - sid, sm := saveSession(msgs, name) - fmt.Println(c("[session saved: "+sid+"]", 32) + " " + c(sm, 2)) - continue - case u == "/continue" || u == "/cont": - pid := projectID() - lm, err := loadSession(pid) - if err != nil { - fmt.Println(c("No session found for current project: "+err.Error(), 31)) - continue - } - msgs = lm - autosave(msgs) - fmt.Println(c("[session continued: "+pid+"]", 32) + " " + c(summary(msgs), 2)) - continue - case u == "/list": - ss := sessions() - if len(ss) == 0 { - fmt.Println(c("No sessions saved yet.", 33)) - continue - } - pid := projectID() - for _, s := range ss { - mk := "" - if s.ID == pid { - mk = c(" (current project)", 33) - } - 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": - msgs = doCompact(&cfg, msgs) - continue - case strings.HasPrefix(u, "/cfg"): - parts := strings.SplitN(u, " ", 3) - if len(parts) == 2 { - k := strings.TrimSpace(parts[1]) - if v, ok := cfg.Raw[k]; ok { - fmt.Println(c(k+"="+v, 32)) - } else { - fmt.Println(c(k+" not set", 31)) - } - } else if len(parts) >= 3 { - k, v := strings.TrimSpace(parts[1]), strings.TrimSpace(parts[2]) - if err := setCfg(".bantam.cfg", k, v); err != nil { - fmt.Println(c("[cfg error: "+err.Error()+"]", 31)) - continue - } - cfg = getCfg(configPath()) - if k == "model" || k == "endpoint" || k == "api_key" { - cfg.ContextWindow = fetchContextWindow(&cfg) - } - COL = col(cfg) - fmt.Println(c(fmt.Sprintf("[config updated: %s=%s]", k, v), 32)) - } else { - fmt.Println(c("Usage: /cfg [val]", 31)) - } - continue - case strings.HasPrefix(u, "/skill"): - if strings.TrimSpace(strings.TrimPrefix(u, "/skill")) == "" { - listSkills(&cfg) // bare /skill lists available skills - continue - } - composed, err := skillPrompt(u, &cfg) - if err != nil { - fmt.Println(c("[skill error: "+err.Error()+"]", 31)) - continue - } - u = composed // fall through to a normal LLM turn with the composed prompt - case u == "/models": - ml, err := listModels(&cfg) - if err != nil { - fmt.Println(c("[error querying /models: "+err.Error()+"]", 31)) - continue - } - fmt.Println(c("Supported models at "+cfg.Endpoint+":", 1, 36)) - fmt.Print(renderMD(ml)) - continue - case strings.HasPrefix(u, "!"): - runDirectShell(strings.TrimPrefix(u, "!"), cfg.ShellTimeout) - continue - case u == "/help": - fmt.Println(c("Bantam commands:", 1, 36)) - for _, kv := range [][2]string{ - {"/quit", "exit"}, - {"/clear", "reset to system prompt"}, - {"/save [name]", "save session (default: project MD5)"}, - {"/continue", "continue project session (alias: /cont)"}, - {"/list", "list sessions"}, - {"/load ", "load session"}, - {"/compact", "compact context"}, - {"/cfg [v]", "get/set config"}, - {"/model [m]", "alias for /cfg model"}, - {"/endpoint [e]", "alias for /cfg endpoint"}, - {"!", "run shell command directly"}, - {"/models", "list models at endpoint"}, - {"/skill [p]", "load SKILL.md and run as prompt"}, - {"/help", "show help"}, - } { - fmt.Println(c(fmt.Sprintf(" %-18s", kv[0]), 1, 32) + kv[1]) - } - continue - } - turnMsgs := append([]Message{}, msgs...) - turnMsgs = append(turnMsgs, Message{Role: "user", Content: strp(u)}) - sigCtx, cancel := signal.NotifyContext(context.Background(), os.Interrupt) - resMsgs, usg, err := AL(sigCtx, &cfg, turnMsgs) - if lastRole(resMsgs) == "tool" { - resMsgs = append(resMsgs, Message{Role: "user", Content: strp("continue")}) - fmt.Println(c("[auto continue: last message was a tool result]", 33)) - resMsgs, usg, err = AL(sigCtx, &cfg, resMsgs) - } - interrupted := sigCtx.Err() != nil - cancel() - if err != nil { - if interrupted || errors.Is(err, context.Canceled) { - fmt.Println(c("\n[interrupted]", 33)) - } else { - fmt.Println(c("[error: "+err.Error()+"]", 31)) - } - continue - } - msgs = resMsgs - autosave(msgs) - fmt.Println(c(formatUsage(usg, cfg.ContextWindow), 2)) - pct := contextPct(usg, cfg.ContextWindow) - if pct >= 60.0 && len(msgs) > 1 { - fmt.Print(c(fmt.Sprintf("Context usage is at %.1f%% (%d / %d tokens). Compact conversation? [Y/n]: ", pct, ctxTokens(usg), cfg.ContextWindow), 33)) - if ans, ok := readPlain(""); ok { - ans = strings.TrimSpace(strings.ToLower(ans)) - if ans == "" || ans == "y" || ans == "yes" { - msgs = doCompact(&cfg, msgs) - } - } - } - } + cfg := getCfg(configPath()) + cfg.ContextWindow = fetchContextWindow(&cfg) + sp := buildSystemPrompt(&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) + } + u := strings.TrimSpace(string(data)) + if strings.HasPrefix(u, "!") { + runDirectShell(strings.TrimPrefix(u, "!"), cfg.ShellTimeout) + return + } + msgs = append(msgs, Message{Role: "user", Content: strp(u)}) + sigCtx, cancel := signal.NotifyContext(context.Background(), os.Interrupt) + var usg Usage + msgs, usg, err = AL(sigCtx, &cfg, msgs) + if lastRole(msgs) == "tool" { + msgs = append(msgs, Message{Role: "user", Content: strp("continue")}) + fmt.Println(c("[auto continue: last message was a tool result]", 33)) + msgs, usg, err = AL(sigCtx, &cfg, msgs) + } + interrupted := sigCtx.Err() != nil + cancel() + if err != nil { + if interrupted || errors.Is(err, context.Canceled) { + fmt.Println(c("\n[interrupted]", 33)) + } else { + fmt.Println(c("[error: "+err.Error()+"]", 31)) + } + os.Exit(1) + } + fmt.Println(c(formatUsage(usg, cfg.ContextWindow), 2)) + autosave(msgs) + return + } + loadHistory() + // Interactive startup: if a session already exists for this directory, offer + // to autoload it so the user can seamlessly resume prior work. + if sess := findProjectSession(); sess != nil && len(sess.Messages) > 1 { + fmt.Println(c("[session found for this directory]", 33) + " " + c(sess.ID, 32)) + fmt.Println(" " + c(sess.Summary, 2)) + fmt.Println(c(fmt.Sprintf(" created %s [%d messages]", sess.Created, len(sess.Messages)), 2)) + fmt.Print(c("Resume this session? [Y/n]: ", 33)) + if ans, ok := readPlain(""); ok { + ans = strings.TrimSpace(strings.ToLower(ans)) + if ans == "" || ans == "y" || ans == "yes" { + msgs = sess.Messages + autosave(msgs) + fmt.Println(c("[session resumed: "+sess.ID+"]", 32) + " " + c(summary(msgs), 2)) + } + } + } + re := cfg.Raw["reasoning_effort"] + if re == "" { + re = "high" + } + fmt.Println(c("Bantam Agent ready", 1, 32) + c(" (Ctrl+J = new line)", 2)) + fmt.Println(c(fmt.Sprintf("endpoint: %s model: %s temp: %v reasoning: %s context: %d", cfg.Endpoint, cfg.Model, cfg.Temperature, re, cfg.ContextWindow), 2)) + for { + u, ok := readLine(c("> ", 1, 36)) + if !ok { + fmt.Println() + break + } + u = strings.TrimSpace(u) + if u == "" { + continue + } + addHistory(u) + // Command aliases: /model -> /cfg model, /endpoint -> /cfg endpoint. + // (Note: /models is a distinct command and is intentionally not matched.) + if u == "/model" { + u = "/cfg model" + } else if strings.HasPrefix(u, "/model ") { + u = "/cfg model " + strings.TrimSpace(strings.TrimPrefix(u, "/model ")) + } else if u == "/endpoint" { + u = "/cfg endpoint" + } else if strings.HasPrefix(u, "/endpoint ") { + u = "/cfg endpoint " + strings.TrimSpace(strings.TrimPrefix(u, "/endpoint ")) + } + switch { + case u == "/quit": + goto done + case u == "/clear": + msgs = []Message{{Role: "system", Content: strp(sp)}} + autosave(msgs) + continue + case u == "/save" || strings.HasPrefix(u, "/save "): + name := strings.TrimSpace(strings.TrimPrefix(u, "/save")) + sid, sm := saveSession(msgs, name) + fmt.Println(c("[session saved: "+sid+"]", 32) + " " + c(sm, 2)) + continue + case u == "/continue" || u == "/cont": + pid := projectID() + lm, err := loadSession(pid) + if err != nil { + fmt.Println(c("No session found for current project: "+err.Error(), 31)) + continue + } + msgs = lm + autosave(msgs) + fmt.Println(c("[session continued: "+pid+"]", 32) + " " + c(summary(msgs), 2)) + continue + case u == "/list": + ss := sessions() + if len(ss) == 0 { + fmt.Println(c("No sessions saved yet.", 33)) + continue + } + pid := projectID() + for _, s := range ss { + mk := "" + if s.ID == pid { + mk = c(" (current project)", 33) + } + 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": + msgs = doCompact(&cfg, msgs) + continue + case strings.HasPrefix(u, "/cfg"): + parts := strings.SplitN(u, " ", 3) + if len(parts) == 2 { + k := strings.TrimSpace(parts[1]) + if v, ok := cfg.Raw[k]; ok { + fmt.Println(c(k+"="+v, 32)) + } else { + fmt.Println(c(k+" not set", 31)) + } + } else if len(parts) >= 3 { + k, v := strings.TrimSpace(parts[1]), strings.TrimSpace(parts[2]) + if err := setCfg(".bantam.cfg", k, v); err != nil { + fmt.Println(c("[cfg error: "+err.Error()+"]", 31)) + continue + } + cfg = getCfg(configPath()) + if k == "model" || k == "endpoint" || k == "api_key" { + cfg.ContextWindow = fetchContextWindow(&cfg) + } + COL = col(cfg) + fmt.Println(c(fmt.Sprintf("[config updated: %s=%s]", k, v), 32)) + } else { + fmt.Println(c("Usage: /cfg [val]", 31)) + } + continue + case strings.HasPrefix(u, "/skill"): + if strings.TrimSpace(strings.TrimPrefix(u, "/skill")) == "" { + listSkills(&cfg) // bare /skill lists available skills + continue + } + composed, err := skillPrompt(u, &cfg) + if err != nil { + fmt.Println(c("[skill error: "+err.Error()+"]", 31)) + continue + } + u = composed // fall through to a normal LLM turn with the composed prompt + case u == "/models": + ml, err := listModels(&cfg) + if err != nil { + fmt.Println(c("[error querying /models: "+err.Error()+"]", 31)) + continue + } + fmt.Println(c("Supported models at "+cfg.Endpoint+":", 1, 36)) + fmt.Print(renderMD(ml)) + continue + case strings.HasPrefix(u, "!"): + runDirectShell(strings.TrimPrefix(u, "!"), cfg.ShellTimeout) + continue + case u == "/help": + fmt.Println(c("Bantam commands:", 1, 36)) + for _, kv := range [][2]string{ + {"/quit", "exit"}, + {"/clear", "reset to system prompt"}, + {"/save [name]", "save session (default: project MD5)"}, + {"/continue", "continue project session (alias: /cont)"}, + {"/list", "list sessions"}, + {"/load ", "load session"}, + {"/compact", "compact context"}, + {"/cfg [v]", "get/set config"}, + {"/model [m]", "alias for /cfg model"}, + {"/endpoint [e]", "alias for /cfg endpoint"}, + {"!", "run shell command directly"}, + {"/models", "list models at endpoint"}, + {"/skill [p]", "load SKILL.md and run as prompt"}, + {"/help", "show help"}, + } { + fmt.Println(c(fmt.Sprintf(" %-18s", kv[0]), 1, 32) + kv[1]) + } + continue + } + turnMsgs := append([]Message{}, msgs...) + turnMsgs = append(turnMsgs, Message{Role: "user", Content: strp(u)}) + sigCtx, cancel := signal.NotifyContext(context.Background(), os.Interrupt) + resMsgs, usg, err := AL(sigCtx, &cfg, turnMsgs) + if lastRole(resMsgs) == "tool" { + resMsgs = append(resMsgs, Message{Role: "user", Content: strp("continue")}) + fmt.Println(c("[auto continue: last message was a tool result]", 33)) + resMsgs, usg, err = AL(sigCtx, &cfg, resMsgs) + } + interrupted := sigCtx.Err() != nil + cancel() + if err != nil { + if interrupted || errors.Is(err, context.Canceled) { + fmt.Println(c("\n[interrupted]", 33)) + } else { + fmt.Println(c("[error: "+err.Error()+"]", 31)) + } + continue + } + msgs = resMsgs + autosave(msgs) + fmt.Println(c(formatUsage(usg, cfg.ContextWindow), 2)) + pct := contextPct(usg, cfg.ContextWindow) + if pct >= 60.0 && len(msgs) > 1 { + fmt.Print(c(fmt.Sprintf("Context usage is at %.1f%% (%d / %d tokens). Compact conversation? [Y/n]: ", pct, ctxTokens(usg), cfg.ContextWindow), 33)) + if ans, ok := readPlain(""); ok { + ans = strings.TrimSpace(strings.ToLower(ans)) + if ans == "" || ans == "y" || ans == "yes" { + msgs = doCompact(&cfg, msgs) + } + } + } + } done: - autosave(msgs) - saveHistory() + autosave(msgs) + saveHistory() }