diff --git a/main.go b/main.go index 73c2b2a..056c6e4 100644 --- a/main.go +++ b/main.go @@ -90,7 +90,6 @@ var defCfg = Cfg{"https://api.kilo.ai/api/openrouter", "openrouter/free", "-", 0 const opencodeAgentVersion = "opencode/1.18.31" const opencodeProviderUA = "ai-sdk/provider-utils/4.0.23 runtime/bun/1.3.14" -const opencodeIDAlphabet = "0123456789ABCDEFGHJKMNPQRSTVWXYZ" const opencodeProjectID = "global" // opencodeTailAlphabet is the character set used for the 14-character random @@ -110,50 +109,63 @@ func atoiD(s string, d int) int { return d } -func queryModelsContextWindow(cfg *Cfg) int { +func toInt(v any, def int) int { + switch n := v.(type) { + case int: + return n + case float64: + return int(n) + case string: + return atoiD(n, def) + } + return def +} + +func fetchModels(cfg *Cfg, timeoutSec int) ([]map[string]any, error) { client := &http.Client{ Transport: &http.Transport{ Proxy: proxyFromEnv, - DialContext: (&net.Dialer{Timeout: 3 * time.Second}).DialContext, + DialContext: (&net.Dialer{Timeout: time.Duration(timeoutSec) * time.Second}).DialContext, }, - Timeout: 3 * time.Second, + Timeout: time.Duration(timeoutSec) * time.Second, } req, err := http.NewRequest("GET", strings.TrimRight(cfg.Endpoint, "/")+"/models", nil) if err != nil { - return 0 + return nil, err } applyLLMHeaders(req, cfg, ocRequestID()) resp, err := client.Do(req) - if err != nil || resp.StatusCode >= 400 { - return 0 + if err != nil { + return nil, err } defer resp.Body.Close() + if resp.StatusCode >= 400 { + return nil, fmt.Errorf("HTTP %d from /models", resp.StatusCode) + } 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 + if err := json.NewDecoder(resp.Body).Decode(&res); err != nil { + return nil, err } - list := res.Data - if len(list) == 0 { - list = res.Models + if len(res.Data) > 0 { + return res.Data, nil + } + return res.Models, nil +} + +func queryModelsContextWindow(cfg *Cfg) int { + list, err := fetchModels(cfg, 3) + if err != nil { + return 0 } 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 - } - } + if n := toInt(item[key], 0); n > 0 { + return n } } } @@ -196,37 +208,10 @@ func listModels(cfg *Cfg) (string, error) { 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) + list, err := fetchModels(cfg, t) 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 } @@ -286,14 +271,20 @@ func parseCfgFile(path string, cfg *Cfg) { } func applyEnvCfg(cfg *Cfg) { - if v := strings.TrimSpace(os.Getenv("BANTAM_ENDPOINT")); v != "" { - cfg.Endpoint = v - cfg.Raw["endpoint"] = v + set := func(k, env string, dst *string) { + if v := strings.TrimSpace(os.Getenv(env)); v != "" { + *dst = v + cfg.Raw[k] = v + } } - if v := strings.TrimSpace(os.Getenv("BANTAM_MODEL")); v != "" { - cfg.Model = v - cfg.Raw["model"] = v + setNum := func(k, env string, dst *int) { + if v := strings.TrimSpace(os.Getenv(env)); v != "" { + *dst = atoiD(v, *dst) + cfg.Raw[k] = strconv.Itoa(*dst) + } } + set("endpoint", "BANTAM_ENDPOINT", &cfg.Endpoint) + set("model", "BANTAM_MODEL", &cfg.Model) if v := strings.TrimSpace(os.Getenv("BANTAM_TEMP")); v != "" { if f, e := strconv.ParseFloat(v, 64); e == nil { cfg.Temperature = f @@ -309,38 +300,20 @@ func applyEnvCfg(cfg *Cfg) { 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 + set("color", "BANTAM_COLOR", &cfg.Color) + setNum("timeout", "BANTAM_TIMEOUT", &cfg.Timeout) + setNum("shell_timeout", "BANTAM_SHELL_TIMEOUT", &cfg.ShellTimeout) + setNum("max_al_iterations", "BANTAM_MAX_AL_ITERATIONS", &cfg.MaxALIterations) + setNum("context_window", "BANTAM_CONTEXT_WINDOW", &cfg.ContextWindow) + setNum("max_tool_res", "BANTAM_MAX_TOOL_RES", &cfg.MaxToolRes) + for _, pair := range [][2]string{ + {"reasoning_effort", "BANTAM_REASONING_EFFORT"}, + {"bantam_tools_dir", "BANTAM_TOOLS_DIR"}, + {"bantam_skills_dir", "BANTAM_SKILLS_DIR"}, + } { + if v := strings.TrimSpace(os.Getenv(pair[1])); v != "" { + cfg.Raw[pair[0]] = v + } } } @@ -439,28 +412,21 @@ When generating code: - No emojis in code or documentation. - Respect AGENTS.md contents in the project.` +func cfgOrEnv(cfg *Cfg, key, env string) string { + if v := strings.TrimSpace(cfg.Raw[key]); v != "" { + return v + } + return strings.TrimSpace(os.Getenv(env)) +} + 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 "" + return cfgOrEnv(cfg, "bantam_tools_dir", "BANTAM_TOOLS_DIR") } // 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 "" + return cfgOrEnv(cfg, "bantam_skills_dir", "BANTAM_SKILLS_DIR") } func c(t string, cs ...int) string { @@ -598,9 +564,6 @@ func renderMDLine(line string, st *mdState) string { 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) } @@ -873,11 +836,11 @@ func renderTable(lines []string) []string { var res []string - var topParts []string + var horiz []string for _, w := range colWidths { - topParts = append(topParts, strings.Repeat("─", w+2)) + horiz = append(horiz, strings.Repeat("─", w+2)) } - res = append(res, c("┌"+strings.Join(topParts, "┬")+"┐", 2)) + res = append(res, c("┌"+strings.Join(horiz, "┬")+"┐", 2)) if hasHeader { headerCols := make([][]string, numCols) @@ -904,11 +867,7 @@ func renderTable(lines []string) []string { 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)) + res = append(res, c("├"+strings.Join(horiz, "┼")+"┤", 2)) } for _, r := range rows { @@ -937,11 +896,7 @@ func renderTable(lines []string) []string { } } - var botParts []string - for _, w := range colWidths { - botParts = append(botParts, strings.Repeat("─", w+2)) - } - res = append(res, c("└"+strings.Join(botParts, "┴")+"┘", 2)) + res = append(res, c("└"+strings.Join(horiz, "┴")+"┘", 2)) return res } @@ -963,17 +918,7 @@ func renderMD(text string) string { } 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) { + if !st.inCode && !strings.HasPrefix(strings.TrimSpace(ln), "```") && isTableLine(ln) { tbl = append(tbl, ln) continue } @@ -1115,21 +1060,18 @@ func contextPct(u Usage, cw int) float64 { func formatUsage(u Usage, cw int) string { base := ctxTokens(u) pct := float64(base) * 100.0 / float64(cw) - cached := u.Cached() - if cached > 0 { + pfx := "" + if u.Model != "" { + pfx = u.Model + ": " + } + if cached := u.Cached(); 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) + return fmt.Sprintf("[%s%d prompt (%d cached, %d uncached) + %d completion | context: %d/%d (%.1f%%)]", pfx, 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) + return fmt.Sprintf("[%s%d prompt + %d completion | context: %d/%d (%.1f%%)]", pfx, u.PromptTokens, u.CompletionTokens, base, cw, pct) } type streamDelta struct { @@ -1180,21 +1122,23 @@ func filterText(s string) string { return b.String() } +func sanitizeToolCalls(tcs []ToolCall) { + for j := range tcs { + tc := &tcs[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) + } + } +} + 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) - } - } - } + sanitizeToolCalls(msgs[i].ToolCalls) if msgs[i].ReasoningContent != "" { msgs[i].ReasoningContent = filterText(msgs[i].ReasoningContent) } @@ -1234,13 +1178,11 @@ func llm(ctx context.Context, cfg *Cfg, msgs []Message, tools []map[string]any) if internalKey(k) { continue } - { - var jv any - if err := json.Unmarshal([]byte(v), &jv); err == nil { - p[k] = jv - } else { - p[k] = v - } + var jv any + if err := json.Unmarshal([]byte(v), &jv); err == nil { + p[k] = jv + } else { + p[k] = v } } body, _ := json.Marshal(p) @@ -1503,13 +1445,7 @@ func parseStream(ctx context.Context, r io.Reader) (Message, Usage, error) { } 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) { + if !mdSt.inCode && !strings.HasPrefix(strings.TrimSpace(curLine), "```") && isTableLine(curLine) { tblBuf = append(tblBuf, curLine) } else { flushTable() @@ -1663,13 +1599,8 @@ func writeFile(path string, offset, delBytes int, content string) (string, error if err != nil { return "", err } - } else { - dir := filepath.Dir(path) - if dir != "" && dir != "." { - if err := os.MkdirAll(dir, 0755); err != nil { - return "", err - } - } + } else if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + return "", err } if offset > len(data) { padding := make([]byte, offset-len(data)) @@ -1711,16 +1642,13 @@ func readFileOrDir(path string, offset, limit int) (string, int) { if err != nil { return fmt.Sprintf("[tool error: read %s: %v]", path, err), 31 } - sort.Slice(entries, func(i, j int) bool { - return entries[i].Name() < entries[j].Name() - }) var b strings.Builder for _, e := range entries { + b.WriteString(e.Name()) if e.IsDir() { - b.WriteString(e.Name() + "/\n") - } else { - b.WriteString(e.Name() + "\n") + b.WriteByte('/') } + b.WriteByte('\n') } return strings.TrimRight(b.String(), "\n"), 2 } @@ -1766,13 +1694,17 @@ func readFileOrDir(path string, offset, limit int) (string, int) { return strings.TrimRight(b.String(), "\n"), 2 } -// toolResDir returns the directory where oversized tool results are spilled. -func toolResDir() string { +func bantamTmpDir() string { tmp := os.Getenv("TMPDIR") if tmp == "" { tmp = "/tmp" } - return filepath.Join(tmp, "bantam", "toolres") + return filepath.Join(tmp, "bantam") +} + +// toolResDir returns the directory where oversized tool results are spilled. +func toolResDir() string { + return filepath.Join(bantamTmpDir(), "toolres") } // offloadToolResult keeps tool results that fit within maxRes bytes inline. @@ -1818,6 +1750,14 @@ func cleanupTemps(files []string) { } } +func toolPath(a map[string]any) string { + p, _ := a["filePath"].(string) + if p == "" { + p, _ = a["path"].(string) + } + return filterText(p) +} + func AL(ctx context.Context, cfg *Cfg, msgs []Message) ([]Message, Usage, error) { done := false var turnUsage Usage @@ -1860,16 +1800,7 @@ func AL(ctx context.Context, cfg *Cfg, msgs []Message) ([]Message, Usage, error) 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) - } - } + sanitizeToolCalls(m.ToolCalls) msgs = append(msgs, m) if !cfg.Stream { if m.ReasoningContent != "" { @@ -1910,24 +1841,11 @@ func AL(ctx context.Context, cfg *Cfg, msgs []Message) ([]Message, Usage, error) workdir, _ := a["workdir"].(string) workdir = filterText(workdir) to := cfg.ShellTimeout - if v, ok := a["timeout"]; ok && v != nil { - switch n := v.(type) { - case float64: - if n > 0 { - if n > 1000 { - to = int(n / 1000) - } else { - to = int(n) - } - } - case int: - if n > 0 { - if n > 1000 { - to = n / 1000 - } else { - to = n - } - } + if n := toInt(a["timeout"], 0); n > 0 { + if n > 1000 { + to = n / 1000 + } else { + to = n } } res = shellWithWorkdir(ctx, cmd, workdir, to) @@ -1935,40 +1853,9 @@ func AL(ctx context.Context, cfg *Cfg, msgs []Message) ([]Message, Usage, error) return msgs, turnUsage, err } case "read": - path, _ := a["filePath"].(string) - if path == "" { - path, _ = a["path"].(string) - } - path = filterText(path) - offset := 1 - if v, ok := a["offset"]; ok && v != nil { - switch n := v.(type) { - case float64: - offset = int(n) - case int: - offset = n - case string: - offset = atoiD(n, 1) - } - } - limit := 2000 - if v, ok := a["limit"]; ok && v != nil { - switch n := v.(type) { - case float64: - limit = int(n) - case int: - limit = n - case string: - limit = atoiD(n, 2000) - } - } - res, sty = readFileOrDir(path, offset, limit) + res, sty = readFileOrDir(toolPath(a), toInt(a["offset"], 1), toInt(a["limit"], 2000)) case "write", "write_file": - path, _ := a["filePath"].(string) - if path == "" { - path, _ = a["path"].(string) - } - path = filterText(path) + path := toolPath(a) contentVal, hasContent := a["content"] var content string if hasContent && contentVal != nil { @@ -1976,63 +1863,32 @@ func AL(ctx context.Context, cfg *Cfg, msgs []Message) ([]Message, Usage, error) 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) - } - } + hasOffset := a["offset"] != nil + offset := toInt(a["offset"], 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) - } - } + hasDel := a["del_bytes"] != nil + delBytes := toInt(a["del_bytes"], 0) if strings.TrimSpace(path) == "" { res, sty = fmt.Sprintf("[tool error: %s requires 'filePath' parameter]", fn), 31 } else if !hasContent { res, sty = fmt.Sprintf("[tool error: %s requires 'content' parameter]", fn), 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: %s %s: %v]", fn, p, err), 31 - } - } - if res == "" { - if err := os.WriteFile(p, []byte(content), 0644); err != nil { - res, sty = fmt.Sprintf("[tool error: %s %s: %v]", fn, p, err), 31 - } else { - res, sty = fmt.Sprintf("Successfully wrote %d bytes to %s", len(content), p), 2 - } + if err := os.MkdirAll(filepath.Dir(p), 0755); err != nil { + res, sty = fmt.Sprintf("[tool error: %s %s: %v]", fn, p, err), 31 + } else if err := os.WriteFile(p, []byte(content), 0644); err != nil { + res, sty = fmt.Sprintf("[tool error: %s %s: %v]", fn, p, err), 31 + } else { + res = fmt.Sprintf("Successfully wrote %d bytes to %s", len(content), p) } } else { out, err := writeFile(path, offset, delBytes, content) if err != nil { res, sty = fmt.Sprintf("[tool error: %s %s: %v]", fn, path, err), 31 } else { - res, sty = out, 2 + res = out } } default: @@ -2105,6 +1961,21 @@ func projectID() string { // timestamp prefix (6 bytes, big endian) and a 14-character random Base62 tail. // When descending is true (session IDs), the timestamp value is bitwise inverted (~$), // ensuring session IDs and request IDs form bitwise inverse hex prefixes. +func ocRandomTail() string { + 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)] + } + } + var tail strings.Builder + for _, b := range buf { + tail.WriteByte(opencodeTailAlphabet[int(b)%len(opencodeTailAlphabet)]) + } + return tail.String() +} + func genOpencodeID(prefix string, descending bool) string { now := time.Now().UnixMilli() ocMu.Lock() @@ -2121,19 +1992,7 @@ func genOpencodeID(prefix string, descending bool) string { val = ^val } hexPart := fmt.Sprintf("%012x", val&0xffffffffffff) - - 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)] - } - } - var tail strings.Builder - for _, b := range buf { - tail.WriteByte(opencodeTailAlphabet[int(b)%len(opencodeTailAlphabet)]) - } - return prefix + hexPart + tail.String() + return prefix + hexPart + ocRandomTail() } func opencodeSessionID() string { @@ -2144,22 +2003,8 @@ func opencodeSessionID() string { // is the exact bitwise inverse of the given msg_ request ID. func opencodeSessionIDFromRequest(msgID string) string { if strings.HasPrefix(msgID, "msg_") && len(msgID) >= 16 { - hexPart := msgID[4:16] - if val, err := strconv.ParseUint(hexPart, 16, 64); err == nil { - invVal := (^val) & 0xffffffffffff - invHex := fmt.Sprintf("%012x", invVal) - 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)] - } - } - var tail strings.Builder - for _, b := range buf { - tail.WriteByte(opencodeTailAlphabet[int(b)%len(opencodeTailAlphabet)]) - } - return "ses_" + invHex + tail.String() + if val, err := strconv.ParseUint(msgID[4:16], 16, 64); err == nil { + return fmt.Sprintf("ses_%012x", (^val)&0xffffffffffff) + ocRandomTail() } } return genOpencodeID("ses_", true) @@ -2692,12 +2537,8 @@ func skillPrompt(u string, cfg *Cfg) (string, error) { 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]) - } + name, prompt, _ := strings.Cut(rest, " ") + prompt = strings.TrimSpace(prompt) sd := skillsDir(cfg) var path string if sd != "" { @@ -2791,14 +2632,31 @@ func doCompact(cfg *Cfg, msgs []Message) []Message { // $TMPDIR/bantam placeholder to a concrete temporary directory, substitutes the // 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" +func runTurn(cfg *Cfg, msgs []Message) ([]Message, Usage, error) { + sigCtx, cancel := signal.NotifyContext(context.Background(), os.Interrupt) + defer cancel() + resMsgs, usg, err := AL(sigCtx, cfg, msgs) + 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) } - bantamTmp := filepath.Join(tmp, "bantam") - sp = strings.ReplaceAll(sp, "$TMPDIR/bantam", bantamTmp) + if err != nil { + if sigCtx.Err() != nil || errors.Is(err, context.Canceled) { + fmt.Println(c("\n[interrupted]", 33)) + } else { + fmt.Println(c("[error: "+err.Error()+"]", 31)) + } + } + return resMsgs, usg, err +} + +// buildSystemPrompt renders the built-in system prompt for cfg: it expands the +// $TMPDIR/bantam placeholder to a concrete temporary directory, substitutes the +// configured max_tool_res value, and appends the optional extra-tools and +// skills hints. +func buildSystemPrompt(cfg *Cfg) string { + sp := strings.ReplaceAll(defaultSystemPrompt, "$TMPDIR/bantam", bantamTmpDir()) 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 @@ -2829,22 +2687,9 @@ func main() { 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() + msgs, usg, err = runTurn(&cfg, msgs) 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)) @@ -2887,14 +2732,12 @@ func main() { 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 ")) + for _, cmd := range []string{"model", "endpoint"} { + if u == "/"+cmd { + u = "/cfg " + cmd + } else if strings.HasPrefix(u, "/"+cmd+" ") { + u = "/cfg " + cmd + " " + strings.TrimSpace(u[len(cmd)+2:]) + } } switch { case u == "/quit": @@ -3023,23 +2866,9 @@ func main() { } 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() + turnMsgs := append(append([]Message{}, msgs...), Message{Role: "user", Content: strp(u)}) + resMsgs, usg, err := runTurn(&cfg, turnMsgs) 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