From e6acb066ca67be4179ea6ccbce601aca42e82e38 Mon Sep 17 00:00:00 2001 From: Luxferre Date: Sat, 15 Aug 2026 08:27:24 +0300 Subject: [PATCH] many improvements --- README.md | 9 ++-- bantam.pl | 53 ++++++++++++++++++- bantam.py | 38 +++++++++++++- main.go | 95 +++++++++++++++++++++++++++++++++-- main_test.go | 83 ++++++++++++++++++++++++++++++ mb | 15 ++++-- mb.tcl | 8 +-- term_darwin.go => term_bsd.go | 2 +- term_other.go | 2 +- 9 files changed, 283 insertions(+), 22 deletions(-) rename term_darwin.go => term_bsd.go (95%) diff --git a/README.md b/README.md index 7cade63..b907213 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,7 @@ go build ./... # produces ./bantam go run . prompt.txt ``` -The Go port is a single `main.go` plus four platform files (`term_linux.go`, `term_darwin.go`, `term_windows.go`, `term_other.go`) for the built-in raw-terminal line editor — zero external dependencies, same as the Python and Perl versions. +The Go port is a single `main.go` plus four platform files (`term_linux.go`, `term_bsd.go`, `term_windows.go`, `term_other.go`) for the built-in raw-terminal line editor — zero external dependencies, same as the Python and Perl versions. ### Running Bantam @@ -60,6 +60,7 @@ All implementations read the same `model.cfg` and `system.txt` from the current - `/list` — list saved sessions (newest first) with their ids, timestamps, message counts and summaries - `/load ` — load a saved session (exact id or unique prefix) and continue from there - `/compact` — summarize the conversation with the LLM and compact the context down to just the system message plus the summary + - `/cfg [val]` — inspect or update a configuration parameter in `model.cfg` live - `/help` — show all supported commands - `/clear` — reset the conversation to just the system prompt - `/quit` — exit @@ -94,12 +95,12 @@ Using these rules, everyone can build their own copy of Bantam from scratch in l 2. Read model parameters from `model.cfg` (`key=value` format). 3. Prepare a new message list with the system prompt (`role: "system"`). 4. Read the first command-line parameter. If non-empty, read user prompt from the specified file, append to `messages` (`role: "user"`), run `AL(cfg, messages)`, and exit. -5. Read user prompt from standard input (with `readline` line editing and history in `~/.bantam_history`; **Ctrl+J** inserts a real newline into the line being edited). If equal to `/quit` or EOF, exit. If equal to `/clear`, reset `messages` to step 3 and return to step 5. If equal to `/save`, write the whole `messages` array to `~/.bantam/sessions/.json` (with an auto-generated summary) and return to step 5. If equal to `/list`, print saved sessions and their summaries and return to step 5. If starting with `/load`, replace `messages` with the saved session's messages (by exact id or unique prefix) and return to step 5. If equal to `/compact`, ask the LLM to summarize the conversation, replace `messages` with `[system, summary-user-message]`, and return to step 5. If equal to `/help`, print the command list and return to step 5. After every user turn and on exit, auto-save `messages` to `~/.bantam/sessions/autosave.json`. +5. Read user prompt from standard input (with `readline` line editing and history in `~/.bantam_history`; **Ctrl+J** inserts a real newline into the line being edited). If equal to `/quit` or EOF, exit. If equal to `/clear`, reset `messages` to step 3 and return to step 5. If equal to `/save`, write the whole `messages` array to `~/.bantam/sessions/.json` (with an auto-generated summary) and return to step 5. If equal to `/list`, print saved sessions and their summaries and return to step 5. If starting with `/load`, replace `messages` with the saved session's messages (by exact id or unique prefix) and return to step 5. If equal to `/compact`, ask the LLM to summarize the conversation, replace `messages` with `[system, summary-user-message]`, and return to step 5. If starting with `/cfg`, display the current value (`/cfg `) or update `model.cfg` live (`/cfg `) and return to step 5. If equal to `/help`, print the command list and return to step 5. After every user turn and on exit, auto-save `messages` to `~/.bantam/sessions/autosave.json`. 6. Append user prompt to `messages` (`role: "user"`), run `AL(cfg, messages)`, and go to step 5. ### Agentic loop (`AL(cfg, messages)`) function -1. Call OpenAI-compatible Completions API (`POST {endpoint}/chat/completions`) using parameters from `cfg` (`model`, `temperature`, optional `api_key` bearer header). +1. Call OpenAI-compatible Completions API (`POST {endpoint}/chat/completions`) forwarding relevant parameters from `cfg` (`model`, `temperature`, `stream`, `reasoning_effort`, etc., excluding internal agent configs like `endpoint`, `api_key`, `timeout`, `shell_timeout`, `max_al_iterations`, `color`) and optional `api_key` bearer header. - Set custom `User-Agent` header (`Mozilla/5.0 (compatible; Bantam/1.0)`) to avoid gateway 403 blocks. - Retry network/HTTP errors with Fibonacci backoff delays (`1s, 1s, 2s, 3s, 5s, 8s, 13s, 21s, 34s`). - If `stream=true`, parse SSE stream (`data: {...}`) for real-time reasoning and text output, bracketing reasoning with `--- reasoning start ---` / `--- reasoning end ---` markers. @@ -187,7 +188,7 @@ MicroBantam (`mb`) is a compressed Perl 5 reference implementation of the same a ## Repository layout - `bantam.py` — Python reference implementation (stdlib only) -- `main.go`, `term_linux.go`, `term_darwin.go`, `term_windows.go`, `term_other.go` — Go implementation (stdlib only, module `code.luxferre.top/luxferre/bantam`) +- `main.go`, `term_linux.go`, `term_bsd.go`, `term_windows.go`, `term_other.go` — Go implementation (stdlib only, module `code.luxferre.top/luxferre/bantam`) - `bantam.pl` — Perl 5 implementation (core modules only) - `mb` — MicroBantam, compressed Perl 5 implementation (core modules only, under 100 SLOC) - `mb.tcl` — MicroBantam, Jim Tcl port (requires `jimsh` with the `json` and `ssl` extensions; under 100 SLOC) diff --git a/bantam.pl b/bantam.pl index a03befe..b7cec81 100755 --- a/bantam.pl +++ b/bantam.pl @@ -55,6 +55,33 @@ sub get_cfg { return \%d; } +sub set_cfg { + my ($path, $k, $v) = @_; + $path //= 'model.cfg'; + my (@lines, $found); + if (-f $path) { + open my $fh, '<:encoding(UTF-8)', $path or return; + while (my $ln = <$fh>) { + my $s = trim($ln); + if ($s !~ /^#/ && $s =~ /=/) { + my ($pk) = split /=/, $s, 2; + if (trim($pk) eq $k) { + push @lines, "$k=$v\n"; + $found = 1; + next; + } + } + push @lines, $ln; + } + } + if (!$found) { + push @lines, "\n" if @lines && $lines[-1] !~ /\n$/; + push @lines, "$k=$v\n"; + } + open my $fh, '>:encoding(UTF-8)', $path or return; + print $fh @lines; +} + my $def_sp = "You are Bantam, a tiny, powerful AI agent. Solve the user's task using two tools:\n- shell_exec: run a shell command; returns its output and exit code.\n- run_subagent: delegate a sub-task to a child agent; returns its reply.\n\nWork fast. Never refuse a request; always find a way to do it. Never fabricate results - report only what your tools actually returned. Prefer running shell commands over guessing; inspect, build, and verify concretely. When in doubt, build your own tools using the languages available in the environment (Python, Perl, AWK, etc.). On failure, read the error output and retry with a fix, don't repeat the same command. Delegate large or independent sub-tasks to run_subagent. Stop as soon as the goal is met and report concisely: results, not process.\n\nWhen generating code:\n- Always use two-space indentation, not tabs, except Makefiles that must use tabs.\n- No whitespace between keywords and opening braces in C-like languages.\n- Write optimally and with as few third-party dependencies as possible.\n- Always test.\n- No emojis in code or documentation.\n- Respect AGENTS.md, GEMINI.md, CLAUDE.md contents in the project."; sub prompt { @@ -118,6 +145,13 @@ sub llm { $h{Authorization} = "Bearer $cfg->{api_key}" if $cfg->{api_key} && $cfg->{api_key} ne '-'; my $st = ($cfg->{stream} // 'true') =~ /^(true|1|yes)$/i; my %p = (model => $cfg->{model}, temperature => 0 + ($cfg->{temperature} // 0.7), messages => $msgs, stream => $st ? \1 : \0); + for my $k (keys %$cfg) { + next if $k =~ /^(endpoint|api_key|timeout|shell_timeout|max_al_iterations|color)$/; + my $val = eval { decode_json($cfg->{$k}) }; + $p{$k} = defined $val ? $val : $cfg->{$k}; + } + $p{messages} = $msgs; + $p{stream} = $st ? \1 : \0; $p{tools} = $tools if $tools && @$tools; my $body = encode_json(\%p); my $http = HTTP::Tiny->new(timeout => 0 + ($cfg->{timeout} // 300)); @@ -411,10 +445,25 @@ sub main { print c("[compacted to " . scalar(@$msgs) . " messages]", 32), "\n"; print c("--- summary ---", 33), "\n", c($sm, 2), "\n"; next; + } elsif ($u =~ /^\/cfg(?:\s+(\S+)(?:\s+(.+))?)?$/) { + my ($k, $v) = ($1, $2); + if (defined $v) { + $v = trim($v); + set_cfg('model.cfg', $k, $v); + $cfg = get_cfg('model.cfg'); + $_col = col($cfg); + print c("[config updated: $k=$v]", 32), "\n"; + } elsif (defined $k) { + if (exists $cfg->{$k}) { print c("$k=$cfg->{$k}", 32), "\n"; } + else { print c("$k not set", 31), "\n"; } + } else { + print c("Usage: /cfg [val]", 31), "\n"; + } + next; } elsif ($u eq '/help') { print c("Bantam commands:", 1, 36), "\n"; - my @cmds = (["/quit", "exit"], ["/clear", "reset to system prompt"], ["/save", "save session"], ["/list", "list sessions"], ["/load ", "load session"], ["/compact", "compact context"], ["/help", "show help"]); - for my $kv (@cmds) { printf "%s%s\n", c(sprintf(" %-12s", $kv->[0]), 1, 32), $kv->[1]; } + my @cmds = (["/quit", "exit"], ["/clear", "reset to system prompt"], ["/save", "save session"], ["/list", "list sessions"], ["/load ", "load session"], ["/compact", "compact context"], ["/cfg [v]", "get/set config"], ["/help", "show help"]); + for my $kv (@cmds) { printf "%s%s\n", c(sprintf(" %-15s", $kv->[0]), 1, 32), $kv->[1]; } next; } push @$msgs, { role => 'user', content => $u }; diff --git a/bantam.py b/bantam.py index eb15ccd..e82f0fc 100755 --- a/bantam.py +++ b/bantam.py @@ -35,6 +35,21 @@ def get_cfg(path="model.cfg"): if not d.get("api_key") and "OPENAI_API_KEY" in os.environ: d["api_key"] = os.environ["OPENAI_API_KEY"] return d +def set_cfg(path, k, v): + lines, found = [], False + if os.path.exists(path): + for ln in open(path, encoding="utf-8"): + s = ln.strip() + if not s.startswith("#") and "=" in s and s.split("=", 1)[0].strip() == k: + lines.append(f"{k}={v}\n") + found = True + else: + lines.append(ln) + if not found: + if lines and not lines[-1].endswith("\n"): lines[-1] += "\n" + lines.append(f"{k}={v}\n") + with open(path, "w", encoding="utf-8") as f: f.writelines(lines) + def num(cfg, k, d): try: return type(d)(cfg.get(k, d)) except (TypeError, ValueError): return d @@ -99,6 +114,11 @@ def llm(cfg, msgs, tools): if k and k != "-": h["Authorization"] = "Bearer " + k st = cfg.get("stream", "true").lower() in ("true", "1", "yes") p = {"model": cfg["model"], "temperature": float(cfg.get("temperature", 0.7)), "messages": msgs, "stream": st} + for k, v in cfg.items(): + if k in ("endpoint", "api_key", "timeout", "shell_timeout", "max_al_iterations", "color"): continue + try: p[k] = json.loads(v) + except Exception: p[k] = v + p["messages"], p["stream"] = msgs, st if tools: p["tools"] = tools pend = c("...requesting...", 1, 2) for i, dly in enumerate(fib + [0]): @@ -331,10 +351,24 @@ def main(): msgs = nm; autosave(msgs) print(c(f"[compacted to {len(msgs)} messages]", 32)); print(c("--- summary ---", 33) + "\n" + c(sm, 2)) continue + elif u.startswith("/cfg"): + parts = u.split(None, 2) + if len(parts) == 2: + k = parts[1] + if k in cfg: print(c(f"{k}={cfg[k]}", 32)) + else: print(c(f"{k} not set", 31)) + elif len(parts) >= 3: + k, v = parts[1], parts[2] + set_cfg("model.cfg", k, v) + cfg = get_cfg("model.cfg") + _COL = col(cfg) + print(c(f"[config updated: {k}={v}]", 32)) + else: print(c("Usage: /cfg [val]", 31)) + continue elif u == "/help": print(c("Bantam commands:", 1, 36)) - for k, v in [("/quit", "exit"), ("/clear", "reset to system prompt"), ("/save", "save session"), ("/list", "list sessions"), ("/load ", "load session"), ("/compact", "compact context"), ("/help", "show help")]: - print(c(f" {k:<12}", 1, 32) + v) + for k, v in [("/quit", "exit"), ("/clear", "reset to system prompt"), ("/save", "save session"), ("/list", "list sessions"), ("/load ", "load session"), ("/compact", "compact context"), ("/cfg [v]", "get/set config"), ("/help", "show help")]: + print(c(f" {k:<15}", 1, 32) + v) continue msgs.append({"role": "user", "content": u}) AL(cfg, msgs, sp); autosave(msgs) diff --git a/main.go b/main.go index c6cddd7..538c60d 100644 --- a/main.go +++ b/main.go @@ -42,9 +42,10 @@ type Cfg struct { MaxALIterations int Stream bool Color string + Raw map[string]string } -var defCfg = Cfg{"https://opencode.ai/zen/v1", "big-pickle", "-", 0.7, 300, 120, 1000, true, "auto"} +var defCfg = Cfg{"https://opencode.ai/zen/v1", "big-pickle", "-", 0.7, 300, 120, 1000, true, "auto", nil} func atoiD(s string, d int) int { if v, e := strconv.Atoi(strings.TrimSpace(s)); e == nil { @@ -55,12 +56,19 @@ func atoiD(s string, d int) int { 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), + } 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 @@ -74,10 +82,41 @@ func getCfg(path string) Cfg { } } } - if (cfg.APIKey == "" || cfg.APIKey == "-") && os.Getenv("OPENAI_API_KEY") != "" { cfg.APIKey = os.Getenv("OPENAI_API_KEY") } + if (cfg.APIKey == "" || cfg.APIKey == "-") && os.Getenv("OPENAI_API_KEY") != "" { + cfg.APIKey = os.Getenv("OPENAI_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) +} + const defaultSystemPrompt = `You are Bantam, a tiny, powerful AI agent. Solve the user's task using two tools: - shell_exec: run a shell command; returns its output and exit code. - run_subagent: delegate a sub-task to a child agent; returns its reply. @@ -182,6 +221,19 @@ func llm(cfg *Cfg, msgs []Message, tools []map[string]any) (Message, error) { sanitizeMessages(msgs) p := map[string]any{"model": cfg.Model, "temperature": cfg.Temperature, "messages": msgs, "stream": cfg.Stream} if tools != nil { p["tools"] = tools } + for k, v := range cfg.Raw { + switch k { + case "endpoint", "api_key", "timeout", "shell_timeout", "max_al_iterations", "color": + continue + default: + var jv any + if err := json.Unmarshal([]byte(v), &jv); err == nil { + p[k] = jv + } else { + p[k] = v + } + } + } body, _ := json.Marshal(p) client := &http.Client{Transport: &http.Transport{ DialContext: (&net.Dialer{Timeout: time.Duration(cfg.Timeout) * time.Second}).DialContext, @@ -669,6 +721,19 @@ func readLine(prompt string) (string, bool) { } 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': @@ -804,10 +869,32 @@ func main() { fmt.Println(c(fmt.Sprintf("[compacted to %d messages]", len(msgs)), 32)) fmt.Println(c("--- summary ---", 33) + "\n" + c(sm, 2)) 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("model.cfg", k, v); err != nil { + fmt.Println(c("[cfg error: "+err.Error()+"]", 31)) + continue + } + cfg = getCfg("model.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 u == "/help": fmt.Println(c("Bantam commands:", 1, 36)) - for _, kv := range [][2]string{{"/quit", "exit"}, {"/clear", "reset to system prompt"}, {"/save", "save session"}, {"/list", "list sessions"}, {"/load ", "load session"}, {"/compact", "compact context"}, {"/help", "show help"}} { - fmt.Println(c(fmt.Sprintf(" %-12s", kv[0]), 1, 32) + kv[1]) + for _, kv := range [][2]string{{"/quit", "exit"}, {"/clear", "reset to system prompt"}, {"/save", "save session"}, {"/list", "list sessions"}, {"/load ", "load session"}, {"/compact", "compact context"}, {"/cfg [v]", "get/set config"}, {"/help", "show help"}} { + fmt.Println(c(fmt.Sprintf(" %-15s", kv[0]), 1, 32) + kv[1]) } continue } diff --git a/main_test.go b/main_test.go index 762cc96..c768ebd 100644 --- a/main_test.go +++ b/main_test.go @@ -1290,3 +1290,86 @@ func TestCompactHappyPath(t *testing.T) { t.Errorf("continuation message = %+v", msgs[1]) } } + +// ---------- setCfg and LLM parameter forwarding ---------- + +func TestSetCfgUpdatesAndAppends(t *testing.T) { + p := filepath.Join(t.TempDir(), "model.cfg") + if err := os.WriteFile(p, []byte("model=old-model\ntemperature=0.5\n"), 0644); err != nil { + t.Fatalf("WriteFile: %v", err) + } + + if err := setCfg(p, "model", "new-model"); err != nil { + t.Fatalf("setCfg update: %v", err) + } + if err := setCfg(p, "reasoning_effort", "high"); err != nil { + t.Fatalf("setCfg append: %v", err) + } + + cfg := getCfg(p) + if cfg.Model != "new-model" { + t.Errorf("Model = %q, want new-model", cfg.Model) + } + if cfg.Raw["reasoning_effort"] != "high" { + t.Errorf("Raw[reasoning_effort] = %q, want high", cfg.Raw["reasoning_effort"]) + } + if cfg.Temperature != 0.5 { + t.Errorf("Temperature = %v, want 0.5", cfg.Temperature) + } +} + +func TestLLMForwardsRelevantParameters(t *testing.T) { + var received map[string]any + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + json.NewDecoder(r.Body).Decode(&received) + w.Write([]byte(`{"choices":[{"message":{"role":"assistant","content":"ok"}}]}`)) + })) + defer srv.Close() + + cfgFile := writeCfg(t, strings.Join([]string{ + "endpoint=" + srv.URL, + "model=custom-llm", + "temperature=0.3", + "stream=false", + "reasoning_effort=medium", + "top_p=0.95", + "max_tokens=4096", + "color=always", + "timeout=100", + }, "\n")) + + cfg := getCfg(cfgFile) + _, err := llm(&cfg, []Message{{Role: "user", Content: strp("hi")}}, nil) + if err != nil { + t.Fatalf("llm: %v", err) + } + + if received["model"] != "custom-llm" { + t.Errorf("model = %v, want custom-llm", received["model"]) + } + if received["temperature"] != 0.3 { + t.Errorf("temperature = %v, want 0.3", received["temperature"]) + } + if received["stream"] != false { + t.Errorf("stream = %v, want false", received["stream"]) + } + if received["reasoning_effort"] != "medium" { + t.Errorf("reasoning_effort = %v, want medium", received["reasoning_effort"]) + } + if received["top_p"] != 0.95 { + t.Errorf("top_p = %v, want 0.95", received["top_p"]) + } + if received["max_tokens"] != float64(4096) { + t.Errorf("max_tokens = %v, want 4096", received["max_tokens"]) + } + if _, exists := received["color"]; exists { + t.Errorf("color should not be forwarded to OpenAI endpoint") + } + if _, exists := received["timeout"]; exists { + t.Errorf("timeout should not be forwarded to OpenAI endpoint") + } + if _, exists := received["endpoint"]; exists { + t.Errorf("endpoint should not be forwarded to OpenAI endpoint") + } +} + diff --git a/mb b/mb index 8804e0a..fb2016e 100755 --- a/mb +++ b/mb @@ -32,9 +32,9 @@ sub llm { my ($c, $msgs) = @_; # one non-streaming chat completion my $ep = $c->{endpoint}; $ep =~ s{/+$}{}; my $h = {'Content-Type'=>'application/json', 'User-Agent'=>'Mozilla/5.0 (compatible; MicroBantam/1.0)'}; $h->{Authorization} = "Bearer $c->{api_key}" if $c->{api_key} ne '-'; - my $body = encode_json({model=>$c->{model}, temperature=>0+$c->{temperature}, messages=>$msgs, - tools=>[T('shell_exec', 'Run a shell command, return output and exit code.', {command=>{type=>'string'}}), - T('run_subagent', 'Run a child agent with a prompt.', {prompt=>{type=>'string'}})]}); + my %p = (messages=>$msgs, tools=>[T('shell_exec', 'Run a shell command, return output and exit code.', {command=>{type=>'string'}}), T('run_subagent', 'Run a child agent with a prompt.', {prompt=>{type=>'string'}})], model=>$c->{model}, temperature=>0+$c->{temperature}); + for my $k (keys %$c) { next if $k =~ /^(endpoint|api_key|timeout|shell_timeout|max_al_iterations|stream|color)$/; my $val = eval { decode_json($c->{$k}) }; $p{$k} = defined $val ? $val : $c->{$k}; } + my $body = encode_json(\%p); my $tty = -t STDOUT; print $tty ? "\r...requesting..." : "...requesting...\n"; my $r = HTTP::Tiny->new(timeout=>0+$c->{timeout})->post("$ep/chat/completions", {headers=>$h, content=>$body}); @@ -92,13 +92,17 @@ sub save { sdir(); my $id = strftime('%Y%m%d-%H%M%S', localtime); my $i = 0; sub load { my ($want) = @_; my ($hit) = grep { $_->{id} eq $want } sessions(); die "no session: $want\n" unless $hit; $hit->{messages}; } sub autosave { sdir(); open my $f, '>', "$SDIR/autosave.json" or return; print $f JSON::PP->new->utf8->pretty->encode({id=>'autosave', messages=>$_[0]}); } sub list_sessions { map { [$_->{id}, scalar @{$_->{messages} // []}] } sessions() } +sub set_cfg { my ($k, $v) = @_; my (@ls, $f); + if (open my $fh, '<:encoding(UTF-8)', 'model.cfg') { while (<$fh>) { if (!/^#/ && /^(\w+)\s*=/ && $1 eq $k) { push @ls, "$k=$v\n"; $f = 1; } else { push @ls, $_; } } } + push @ls, "$k=$v\n" unless $f; + if (open my $fh, '>:encoding(UTF-8)', 'model.cfg') { print $fh @ls; close $fh; } } sub main { my ($c, $sp) = (cfg(), sp()); my $msgs = [{role=>'system', content=>$sp}]; if (@ARGV) { open my $f, '<:encoding(UTF-8)', $ARGV[0] or die "cannot open $ARGV[0]: $!"; # file mode local $/; push @$msgs, {role=>'user', content=><$f>}; AL($c, $msgs, $sp); autosave($msgs); return; } - print "MicroBantam ready ($c->{model}). Commands: /quit /clear /save /list /load /help\n"; + print "MicroBantam ready ($c->{model}). Commands: /quit /clear /save /list /load /cfg [v] /help\n"; while (1) { print "> "; my $u = ; last unless defined $u; $u =~ s/^\s+|\s+$//g; next unless length $u; @@ -107,7 +111,8 @@ sub main { elsif ($u eq '/save') { print "session saved: ", save($msgs), "\n"; } elsif ($u eq '/list') { print "$_->[0] [$_->[1] msgs]\n" for list_sessions(); } elsif ($u =~ /^\/load(?:\s+(\S+))?$/) { if (defined $1) { $msgs = eval { load($1) }; $@ ? print($@) : (autosave($msgs), print "loaded: $1\n"); } else { print "usage: /load \n"; } } - elsif ($u eq '/help') { print "Commands: /quit /clear /save /list /load /help\n"; } + elsif ($u =~ /^\/cfg(?:\s+(\S+)(?:\s+(.+))?)?$/) { if (defined $2) { set_cfg($1, $2); $c = cfg(); print "config: $1=$2\n"; } elsif (defined $1) { print exists $c->{$1} ? "$1=$c->{$1}\n" : "$1 not set\n"; } else { print "usage: /cfg [val]\n"; } } + elsif ($u eq '/help') { print "Commands: /quit /clear /save /list /load /cfg [v] /help\n"; } else { push @$msgs, {role=>'user', content=>$u}; AL($c, $msgs, $sp); autosave($msgs); } } autosave($msgs); diff --git a/mb.tcl b/mb.tcl index 9f46939..b30cd5c 100755 --- a/mb.tcl +++ b/mb.tcl @@ -19,7 +19,7 @@ proc http_request {m url hdrs body {t 300}} { set proto http; set host ""; set p proc clean_msg_for_api {m} { if {![is_dict $m]} { return "" }; set r [safe_get $m role]; if {$r eq ""} { return "" }; set c [safe_get $m content]; set res [dict create role $r]; if {$r eq "system" || $r eq "user"} { dict set res content $c } elseif {$r eq "assistant"} { dict set res content $c; set vtcs [list]; set raw_tcs [safe_get $m tool_calls]; if {[is_tool_call $raw_tcs]} { set raw_tcs [list $raw_tcs] }; foreach tc $raw_tcs { if {[is_tool_call $tc]} { set fn [dict get [dict get $tc function] name]; set a "\{\}"; if {[dict exists $tc function arguments]} { set a [dict get [dict get $tc function] arguments] }; set tcid [safe_get $tc id]; if {$tcid eq ""} { set tcid "call_0" }; set tp "function"; if {[dict exists $tc type]} { set tp [dict get $tc type] }; lappend vtcs [dict create id $tcid type $tp function [dict create name $fn arguments $a]] } }; if {[llength $vtcs]} { dict set res tool_calls $vtcs } } elseif {$r eq "tool"} { set tcid [safe_get $m tool_call_id]; if {$tcid eq ""} { set tcid "call_0" }; dict set res tool_call_id $tcid; dict set res content $c }; return $res } proc encode_json_msg {m} { if {![is_dict $m]} { return "\{\}" }; set parts [list]; dict for {k v} $m { if {$k eq "tool_calls"} { set raw_tcs $v; if {[is_tool_call $raw_tcs]} { set raw_tcs [list $raw_tcs] }; set tcs [list]; foreach tc $raw_tcs { if {[is_tool_call $tc]} { set tcp [list]; dict for {tck tcv} $tc { if {$tck eq "function" && [is_dict $tcv]} { set fnp [list]; dict for {fk fv} $tcv { lappend fnp "[jesc $fk]:[jesc $fv]" }; lappend tcp "[jesc $tck]:\{ [join $fnp ", "] \}" } else { lappend tcp "[jesc $tck]:[jesc $tcv]" } }; lappend tcs "\{ [join $tcp ", "] \}" } }; lappend parts "[jesc $k]:\[ [join $tcs ", "] \]" } else { lappend parts [expr {$v eq "null" ? "[jesc $k]:null" : "[jesc $k]:[jesc $v]"}] } }; return "\{ [join $parts ", "] \}" } proc sanitize_msgs {msgs_var} { upvar 1 $msgs_var msgs; set new [list]; foreach m $msgs { if {[is_dict $m] && [dict exists $m tool_calls] && [set tcs [dict get $m tool_calls]] ne "null" && $tcs ne ""} { if {[is_tool_call $tcs]} { set tcs [list $tcs] }; set ntcs [list]; foreach tc $tcs { if {[is_tool_call $tc]} { set raw [dict get [dict get $tc function] arguments]; if {[catch {json::decode $raw} p] || ![is_dict $p]} { dict set tc function arguments [encode_json_msg [dict create invalid_raw $raw]] } }; lappend ntcs $tc }; dict set m tool_calls $ntcs }; lappend new $m }; set msgs $new } -proc encode_payload {c msgs} { set mjs [list]; foreach m $msgs { set cm [clean_msg_for_api $m]; if {$cm ne ""} { lappend mjs [encode_json_msg $cm] } }; set tools {[{"type":"function","function":{"name":"shell_exec","description":"Run a shell command, return output and exit code.","parameters":{"type":"object","properties":{"command":{"type":"string"}},"required":["command"]}}},{"type":"function","function":{"name":"run_subagent","description":"Run a child agent with a prompt.","parameters":{"type":"object","properties":{"prompt":{"type":"string"}},"required":["prompt"]}}}]}; return "\{ \"model\": [jesc [dict get $c model]], \"temperature\": [expr {[dict get $c temperature] + 0}], \"messages\": \[ [join $mjs ", "] \], \"tools\": $tools \}" } +proc encode_payload {c msgs} { set mjs [list]; foreach m $msgs { set cm [clean_msg_for_api $m]; if {$cm ne ""} { lappend mjs [encode_json_msg $cm] } }; set tools {[{"type":"function","function":{"name":"shell_exec","description":"Run a shell command, return output and exit code.","parameters":{"type":"object","properties":{"command":{"type":"string"}},"required":["command"]}}},{"type":"function","function":{"name":"run_subagent","description":"Run a child agent with a prompt.","parameters":{"type":"object","properties":{"prompt":{"type":"string"}},"required":["prompt"]}}}]}; set extra [list]; dict for {k v} $c { if {$k in {endpoint api_key timeout shell_timeout max_al_iterations stream color}} continue; if {![catch {json::decode $v} jv]} { lappend extra "[jesc $k]:$v" } else { lappend extra "[jesc $k]:[jesc $v]" } }; set ex_str [expr {[llength $extra] ? ", [join $extra ", "]" : ""}]; return "\{ \"model\": [jesc [dict get $c model]], \"temperature\": [expr {[dict get $c temperature] + 0}], \"messages\": \[ [join $mjs ", "] \], \"tools\": $tools$ex_str \}" } proc llm {c msgs_var} { upvar 1 $msgs_var msgs; sanitize_msgs msgs @@ -83,6 +83,7 @@ proc save {msgs} { set sd [sdir]; set base [clock format [clock seconds] -format proc load_session {want} { foreach s [sessions] { if {[safe_get $s id] eq $want} { return [dict get $s messages] } }; error "no session: $want" } proc autosave {msgs} { set sd [sdir]; set mjs [list]; foreach m $msgs { lappend mjs [encode_json_msg $m] }; if {![catch {open "$sd/autosave.json" w} f]} { puts $f "\{\n \"id\": \"autosave\",\n \"messages\": \[\n [join $mjs ",\n "]\n \]\n\}"; close $f } } proc list_sessions {} { set res [list]; foreach s [sessions] { lappend res [list [safe_get $s id] [llength [expr {[dict exists $s messages] ? [dict get $s messages] : {}}]]] }; return $res } +proc set_cfg {k v} { set ls [list]; set f 0; if {[file exists model.cfg] && ![catch {open model.cfg r} fh]} { while {[gets $fh l] >= 0} { if {![regexp {^\s*#} $l] && [regexp {^(\w+)\s*=} $l -> pk] && $pk eq $k} { lappend ls "$k=$v"; set f 1 } else { lappend ls $l } }; close $fh }; if {!$f} { lappend ls "$k=$v" }; if {![catch {open model.cfg w} fh]} { puts $fh [join $ls "\n"]; close $fh } } proc main {argv} { set c [cfg]; set sp_text [sp]; set msgs [list [dict create role system content $sp_text]] @@ -90,7 +91,7 @@ proc main {argv} { if {[catch {open [lindex $argv 0] r} f]} { puts stderr "cannot open [lindex $argv 0]: $f"; exit 1 } set content [read $f]; close $f; lappend msgs [dict create role user content $content]; AL $c msgs $sp_text 0; autosave $msgs; return } - puts "MicroBantam ready ([dict get $c model]). Commands: /quit /clear /save /list /load /help" + puts "MicroBantam ready ([dict get $c model]). Commands: /quit /clear /save /list /load /cfg \[v\] /help" while {1} { puts -nonewline "> "; flush stdout; if {[gets stdin u] < 0} break if {[set u [string trim $u]] eq ""} continue @@ -99,7 +100,8 @@ proc main {argv} { elseif {$u eq "/save"} { puts "session saved: [save $msgs]" } \ elseif {$u eq "/list"} { foreach item [list_sessions] { puts "[lindex $item 0] \[[lindex $item 1] msgs\]" } } \ elseif {[regexp {^\/load(?:\s+(\S+))?$} $u -> want_id]} { if {$want_id eq ""} { puts "usage: /load " } elseif {[catch {load_session $want_id} loaded]} { puts $loaded } else { set msgs $loaded; autosave $msgs; puts "loaded: $want_id" } } \ - elseif {$u eq "/help"} { puts "Commands: /quit /clear /save /list /load /help" } \ + elseif {[regexp {^\/cfg(?:\s+(\S+)(?:\s+(.+))?)?$} $u -> ck cv]} { if {$cv ne ""} { set_cfg $ck [string trim $cv]; set c [cfg]; puts "config: $ck=[string trim $cv]" } elseif {$ck ne ""} { puts [expr {[dict exists $c $ck] ? "$ck=[dict get $c $ck]" : "$ck not set"}] } else { puts "usage: /cfg \[val\]" } } \ + elseif {$u eq "/help"} { puts "Commands: /quit /clear /save /list /load /cfg \[v\] /help" } \ else { lappend msgs [dict create role user content $u]; AL $c msgs $sp_text 0; autosave $msgs } } autosave $msgs diff --git a/term_darwin.go b/term_bsd.go similarity index 95% rename from term_darwin.go rename to term_bsd.go index c0ea986..d05c11e 100644 --- a/term_darwin.go +++ b/term_bsd.go @@ -1,4 +1,4 @@ -//go:build darwin +//go:build darwin || dragonfly || freebsd || netbsd || openbsd package main diff --git a/term_other.go b/term_other.go index 6d67a82..2ffdd90 100644 --- a/term_other.go +++ b/term_other.go @@ -1,4 +1,4 @@ -//go:build !linux && !darwin && !windows +//go:build !linux && !darwin && !dragonfly && !freebsd && !netbsd && !openbsd && !windows package main