Compare commits

..
15 Commits
13 changed files with 2371 additions and 1228 deletions
+132 -44
View File
@@ -2,7 +2,7 @@
## About
Bantam is a minimalist, dependency-free AI agent specification with reference implementations in **Python** (`bantam.py`, ~280 SLOC), **Go** (`main.go` + `term_*.go`, module `code.luxferre.top/luxferre/bantam`), and **Perl 5** (`bantam.pl`, ~360 SLOC). It provides an agentic loop capable of autonomous tool execution, shell interaction, real-time response streaming, Fibonacci backoff network resilience, and subagent delegation using any OpenAI-compatible completions API.
Bantam is a minimalist, dependency-free AI agent specification with reference implementations in **Go** (`main.go` + `term_*.go`, module `code.luxferre.top/luxferre/bantam`) and **Perl 5** as **MicroBantam** (`mb`, under 100 SLOC). It provides an agentic loop capable of autonomous tool execution, direct shell interaction, real-time response streaming, markdown terminal rendering with box-drawing tables, Fibonacci backoff network resilience, context window auto-discovery, token usage tracking with prompt cache breakdowns, conversation compaction, and subagent delegation using any OpenAI-compatible completions API.
The entire philosophy of Bantam is built upon two principles:
@@ -14,7 +14,7 @@ Because of the second principle, Bantam itself was named after Victorinox Bantam
## Usage
### Prerequisites
- **Python 3.7+**, **Go 1.21+**, or **Perl 5.14+** (standard library / core modules only)
- **Go 1.21+** or **Perl 5.14+** (standard library / core modules only)
- An OpenAI-compatible API endpoint (or OpenAI API key)
### Installation (Go)
@@ -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_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.
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.
### Running Bantam
@@ -44,34 +44,46 @@ All implementations read the same `model.cfg` and `system.txt` from the current
temperature=0.7
api_key=your_api_key_here
stream=true
context_window=200000
```
2. Interactive mode:
```bash
bantam # Go (or: go run .)
python3 bantam.py # Python
perl bantam.pl # Perl 5
./mb # MicroBantam (Perl 5)
```
In interactive mode, prompts can span multiple lines: press **Ctrl+J** to insert a real line break (the cursor moves to the next line), then **Enter** to submit the whole multi-line prompt. The Go port ships its own raw-mode line editor (arrow keys move the cursor, Up/Down browse history, Backspace edits, Ctrl+C/Ctrl+D exit), so this works everywhere without dependencies; the Python and Perl ports use `readline` when available and fall back to single-line prompts otherwise.
In interactive mode, prompts can span multiple lines: press **Ctrl+J** to insert a real line break (the cursor moves to the next line), then **Enter** to submit the whole multi-line prompt. The Go port ships its own raw-mode line editor (arrow keys move the cursor, Up/Down browse history, Backspace edits, Ctrl+C clears line / interrupts in-flight run, Ctrl+D exits), working everywhere without third-party dependencies.
After every interaction, Bantam displays token usage (prompt tokens, cached/uncached breakdown when supported by the provider, completion tokens, and context window utilization):
```text
[tokens: 1420 prompt (1000 cached, 420 uncached) + 85 completion | context: 1420/200000 (0.7%)]
```
Sessions are saved under `~/.bantam/sessions/` and can be managed with these commands:
- `/save` — save the entire conversation to a new session file (auto-id like `20260808-190038`) and generate its summary
- `/list` — list saved sessions (newest first) with their ids, timestamps, message counts and summaries
- `/load <id>` — 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
- `/compact` — compact context down to the system message and a concise summary using the LLM; the compaction prompt is appended to the conversation to derive the summary, then the conversation is reset to `[system, summary-user-message]` (a fresh prefix, so downstream prompt-cache hits depend on the provider and are not guaranteed)
- `/cfg <param> [val]` — inspect or update a configuration parameter in `model.cfg` live
- `!<cmd>` — execute a shell command directly through `shell_exec` without adding the result to the conversation context (Go port)
- `/help` — show all supported commands
- `/clear` — reset the conversation to just the system prompt
- `/quit` — exit
When context window usage reaches **60% or higher**, Bantam automatically offers to compact the conversation:
```text
Context usage is at 62.4% (124800 / 200000 tokens). Compact conversation? [Y/n]:
```
Pressing **Ctrl+C** during an active inference run or long-running shell execution cleanly interrupts the turn without appending partial or malformed responses to the conversation context.
The current conversation is also **auto-saved** to `~/.bantam/sessions/autosave.json` after every turn, on `/clear`, `/load`, `/compact`, and on exit — so you can always `/load autosave` to resume where you left off.
3. File input mode:
```bash
python3 bantam.py prompt.txt # Python
bantam prompt.txt # Go
perl bantam.pl prompt.txt # Perl 5
./mb prompt.txt # MicroBantam (Perl 5)
```
## Rules of Bantam (The Algorithm)
@@ -80,39 +92,45 @@ Using these rules, everyone can build their own copy of Bantam from scratch in l
### High-Level Overview
1. **Initialization**: Read `system.txt` and `model.cfg`. Prepare an array of messages starting with the system prompt `{"role": "system", "content": system_prompt}`.
2. **Input Processing**: Take user prompt (via command-line file parameter or interactive stdin), append `{"role": "user", "content": prompt}`, and invoke `AL(cfg, messages)`.
1. **Initialization**: Read `system.txt` and `model.cfg`. Discover context window size from the `/models` endpoint (or fallback to `context_window` from `model.cfg` or 200,000 tokens). Prepare an array of messages starting with the system prompt `{"role": "system", "content": system_prompt}`.
2. **Input Processing**: Take user prompt (via command-line file parameter or interactive stdin). If prefixed with `!`, execute the command directly via `shell_exec` without appending to conversation context. Otherwise, append `{"role": "user", "content": prompt}`, and invoke `AL(cfg, messages)`.
3. **Agentic Loop (`AL`)**:
- Send `messages` and tool definitions to the OpenAI-compatible `/chat/completions` API endpoint with custom `User-Agent` headers.
- Send `messages` and tool definitions to the OpenAI-compatible `/chat/completions` API endpoint with custom `User-Agent` headers and `stream_options: {"include_usage": true}`.
- Support context cancellation (e.g. on `SIGINT` / Ctrl+C) to cleanly abort in-flight requests without appending incomplete messages.
- On network or HTTP failure, retry using Fibonacci backoff delays (`1s, 1s, 2s, 3s, 5s, 8s, 13s, 21s, 34s`).
- If `stream=true`, parse SSE data chunks (`data: {...}`) in real-time to stream reasoning content (`reasoning_content`) and response text directly to stdout, bracketing the reasoning block with `--- reasoning start ---` / `--- reasoning end ---` markers.
- Reconstruct the assistant message. If `tool_calls` exist, trace the call (`[tool call: name(args)]`), validate JSON arguments, execute the requested tool (`shell_exec` or `run_subagent`), trace the result (`[tool result: name]`), append the tool response `{"role": "tool", "tool_call_id": id, "content": result}`, and repeat the loop.
- If no tool calls remain or `max_al_iterations` is reached, return the updated messages list.
- If `stream=true`, parse SSE data chunks (`data: {...}`) in real-time to stream reasoning content (`reasoning_content`) and response text directly to stdout, bracketing the reasoning block with `--- reasoning start ---` / `--- reasoning end ---` markers, rendering Markdown and tables constrained to terminal width.
- Reconstruct the assistant message and track usage tokens (`prompt_tokens`, `completion_tokens`, cached tokens). If `tool_calls` exist, trace the call (`[tool call: name(args)]`), validate JSON arguments, execute the requested tool (`shell_exec` or `run_subagent`), trace the result (`[tool result: name]`), append the tool response `{"role": "tool", "tool_call_id": id, "content": result}`, and repeat the loop.
- If no tool calls remain or `max_al_iterations` is reached, return the updated messages list and turn usage stats.
4. **Post-Turn Reporting & Compaction**:
- Display token usage and context window percentage.
- If context usage is >= 60%, prompt user to compact.
- Compaction appends `"You are now acting as a compaction engine. Summarize the preceding conversation concisely but completely..."` as a user message to the conversation, invokes the LLM, and then resets the conversation to the system prompt plus a `user` message carrying the resulting summary. (The compaction prompt is not retained; the post-compaction conversation is a new prefix, so prompt-cache hits are provider-dependent.)
### Main program
1. Read system prompt from `system.txt` (default if missing).
2. Read model parameters from `model.cfg` (`key=value` format).
2. Read model parameters from `model.cfg` (`key=value` format) and discover context window size.
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/<id>.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 <param>`) or update `model.cfg` live (`/cfg <param> <val>`) 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.
4. Read the first command-line parameter. If non-empty, read user prompt from the specified file. If prefixed with `!`, execute the shell command directly via `shell_exec` and exit. Otherwise, append to `messages` (`role: "user"`), run `AL(cfg, messages)`, display token usage, 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/<id>.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 by appending the compaction prompt to derive the summary, replace `messages` with `[system, summary-user-message]`, and return to step 5. If starting with `/cfg`, display the current value (`/cfg <param>`) or update `model.cfg` live (`/cfg <param> <val>`) and return to step 5. If starting with `!`, execute the command directly via `shell_exec` without adding the result to `messages` 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)`, display token usage, check 60% context threshold for auto-compaction, and go to step 5.
### Agentic loop (`AL(cfg, messages)`) function
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.
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`, `context_window`) 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.
2. Append the assistant's response message object to `messages`. If non-streaming and response has reasoning tokens (`reasoning_content` or `reasoning`), output them wrapped in `--- reasoning start ---` / `--- reasoning end ---` markers.
3. If there are pending `tool_calls` in the assistant response:
- For each tool call, output a trace log (`[tool call: name(args)]`).
- Validate JSON arguments. If invalid, format a tool-error response so the LLM can self-correct.
- Sanitize tool arguments to filter out non-printable and space-like Unicode characters (protecting against indirect prompt injection), and validate JSON arguments. If invalid, format a tool-error response so the LLM can self-correct.
- Execute tool action (`shell_exec` or `run_subagent`).
- Sanitize the tool result output to strip any non-printable and space-like Unicode characters (leaving only ASCII space, tab, newline, and printable Unicode characters).
- Output a trace log of the result (`[tool result: name]`).
- Append tool result message (`role: "tool"`, `tool_call_id`, `content`: result string) to `messages`.
- Loop back to step 1.
4. If no pending tool calls (or if `max_al_iterations` is reached), stop and return `messages`.
4. If no pending tool calls (or if `max_al_iterations` is reached), stop and return `messages` and accumulated usage.
If the API rejects the request with an `Invalid assistant message: content or tool_calls must be set` error (usually caused by a previously cut-off stream that left an empty assistant message in the session), all implementations strip the last `assistant`-role message from the session and retry the call.
@@ -126,11 +144,12 @@ If the API rejects the request with an `Invalid assistant message: content or to
- `api_key` (API key / Bearer token, optional; fall back to `OPENAI_API_KEY` env var)
- `stream` (stream response tokens in real-time, default `true`)
- `color` (ANSI coloring: `auto` (TTY-detected, default), `always`, or `never`; also disabled by `NO_COLOR`/`BANTAM_NO_COLOR` env vars)
- `timeout` (HTTP timeout in seconds for LLM API calls, default 300; in the Go port it bounds connection setup and time-to-first-byte, so long streaming responses are not cut off mid-stream, matching the Python and Perl ports' per-operation socket timeouts)
- `timeout` (HTTP timeout in seconds for LLM API calls, default 300; in the Go port it bounds connection setup and time-to-first-byte, so long streaming responses are not cut off mid-stream)
- `shell_timeout` (timeout in seconds for `shell_exec` commands, default 120)
- `max_al_iterations` (max tool-call loop iterations per `AL()` invocation, default 1000)
- `context_window` (context window size in tokens, auto-discovered from `/models` API if available, fallback to this setting, default 200000)
All implementations keep the interactive prompt safe against the classic "long line overwrites the prompt" readline bug: the Python and Perl ports wrap the ANSI escapes in `\001`/`\002` (`RL_PROMPT_START_IGNORE`/`RL_PROMPT_END_IGNORE`) markers (disabling `Term::ReadLine` ornaments in Perl), and the Go port's built-in editor tracks the cursor with its own column math (terminal auto-wrap aware) and redraws from the first line of the buffer, so wrapped input stays clean at any terminal width.
The Go port's built-in editor tracks the cursor with its own column math (terminal auto-wrap aware) and redraws from the first line of the buffer, so wrapped input stays clean at any terminal width.
When enabled, the interactive console uses a subtle ANSI palette: the pending-request status `...requesting...` is darkened bold, reasoning markers are cyan, reasoning text is dim, `[tool call: ...]` traces are yellow, `[tool result: ...]` headers are green, tool result bodies are dim (red for tool errors/unknown tools), and errors/network retries are red. Tool result payloads fed back to the LLM are never colored.
@@ -155,30 +174,22 @@ MicroBantam (`mb`) is a compressed Perl 5 reference implementation of the same a
### Features
- Full agentic loop: LLM calls, `shell_exec` / `run_subagent` tool execution with JSON argument validation (invalid args are fed back so the model can self-correct), and the 5-level subagent recursion depth limit
- Indirect prompt injection defense: sanitizes tool parameters and tool outputs by filtering non-printable and space-like Unicode characters, preserving standard space, tab, newline, and printable Unicode characters
- A `...requesting...` in-flight indicator: in-place on a TTY (`\r` overwrite, erased on completion), a plain line when output is piped
- Session management: `/save`, `/list`, `/load <id>` (exact id only, no prefix matching), and auto-save to `~/.bantam/sessions/autosave.json` after every turn and on exit; session ids get `-1`, `-2`, ... suffixes on same-second collisions
- Interactive mode (`/quit`, `/clear`, `/save`, `/list`, `/load <id>`, `/help`) and file input mode
- Same built-in default system prompt and `OPENAI_API_KEY` fallback as the full implementation
- Automatic recovery from `Invalid assistant message: content or tool_calls must be set` API errors: strips the last assistant message and retries
- Session management: `/save`, `/list`, `/load <id>` (exact id only, no prefix matching), `/cfg <param> [val]`, and auto-save to `~/.bantam/sessions/autosave.json` after every turn and on exit; session ids get `-1`, `-2`, ... suffixes on same-second collisions
- Interactive mode (`/quit`, `/clear`, `/save`, `/list`, `/load <id>`, `/cfg`, `/help`) and file input mode
- Same built-in default system prompt and `OPENAI_API_KEY` fallback as the Go implementation
- Automatic recovery from `Invalid assistant message: content or tool_calls must be set` API errors: strips the last assistant message and retries (matches the Go port; note that a 4xx response other than this specific error aborts the run with an `API error` message, unlike the Go port which retries only on 5xx/408/429)
### What it drops
- Streaming (requests are non-streaming; `stream` is ignored)
- ANSI coloring/styling (`color` is ignored)
- `Term::ReadLine` line editing, Ctrl+J multi-line prompts and readline history (plain single-line prompts)
- Line editing, Ctrl+J multi-line prompts and history (plain single-line prompts)
- Fibonacci backoff network retries (a failed request aborts with an `API error` message)
- `/compact` context summarization
### Jim Tcl port
`mb.tcl` is a **highly experimental** Jim Tcl port of the same agent (under 100 SLOC) with the same feature set as the Perl `mb`. It differs in three ways: it ships its own minimal HTTP client (raw sockets with chunked-transfer decoding) instead of `HTTP::Tiny`; it retries failed requests up to 3 times with a 2-second backoff instead of aborting on the first failure; and it relies on the external `timeout` command for `shell_exec` timeouts instead of `SIGALRM`. Run it the same way:
```bash
./mb.tcl # interactive (or: jimsh mb.tcl)
./mb.tcl prompt.txt # file input mode
```
### Running
### Running MicroBantam
```bash
./mb # interactive (or: perl mb)
@@ -187,14 +198,91 @@ 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_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)
- `model.cfg`, `system.txt` — shared configuration and system prompt
- `README.md` — this document
## Extra tools
The `extras/` directory contains small, dependency-light shell scripts that extend Bantam without changing its core. Because Bantam's only built-in tools are `shell_exec` and `run_subagent`, these helpers can be invoked directly by the agent through `shell_exec` to give it real-world capabilities (web search, live weather) that the base model alone does not have. They are plain `/bin/sh` scripts depending only on `curl` (and `jq` where noted), so the agent can discover and run them just like any other command.
### `extras/websearch`
A simple web search tool for Bantam built on [Exa's MCP server](https://mcp.exa.ai/mcp)
(Streamable HTTP). It speaks the JSON-RPC MCP protocol (`initialize` ->
`notifications/initialized` -> `tools/call` with `web_search_exa`) and prints
the formatted result text. Usage:
```sh
extras/websearch "your query" # default 5 results
extras/websearch "your query" 10 # custom number of results
```
Environment:
- `EXA_MCP_ENDPOINT` — endpoint URL (default `https://mcp.exa.ai/mcp`).
- `EXA_API_KEY` — optional; when set, it is passed as an `?api_key=` query
parameter so no extra HTTP headers are added beyond `Content-Type`.
Dependencies: `curl`, `jq`.
### `extras/weather`
A wrapper around the [wttr.in](https://github.com/chubin/wttr.in) weather API
that queries current conditions and forecasts for any location. It supports the
graphical ANSI terminal view (default), one-line presets (`1`-`4`), custom
`%`-notation formats, and the rich JSON document (`?format=j1`). Usage:
```sh
extras/weather # auto-detect location from request IP
extras/weather London # graphical report
extras/weather -f 3 "New York" # one-line preset
extras/weather -f "%l: %c %t" Paris # custom one-line format
extras/weather -u u -L de Berlin # USCS units, German output
extras/weather -j Tokyo # raw JSON (pretty-printed via jq)
```
Options include `-l/--location`, `-u/--units` (`m`/`u`/`M`), `-L/--lang`,
`-f/--format`, `-j/--json`, `-0`/`-1`/`-2` (view depth), `-q`/`--quiet`,
`-A` (force ANSI) and `-h/--help`. Environment overrides: `WTTRAPI` (default
`https://wttr.in`) and `WEATHER_TIMEOUT` (default `20`s).
Dependencies: `curl`, `jq` (only required for the JSON format).
Dependencies: `curl`, `jq` (only required for the JSON format).
### `extras/context7`
A documentation lookup tool for Bantam backed by the
[Context7 public MCP server](https://mcp.context7.com/mcp). It speaks the same
JSON-RPC MCP protocol as `websearch` (`initialize` ->
`notifications/initialized` -> `tools/call`) and prints the returned text.
Context7 keeps up-to-date docs and code examples for thousands of libraries and
exposes two tools, both wrapped here:
- `resolve-library-id` — maps a library name to a Context7 ID
(`/org/project`).
- `query-docs` — fetches documentation and code examples for a resolved ID.
Usage:
```sh
extras/context7 resolve "React" "hooks" # list candidate library IDs
extras/context7 query "/reactjs/react.dev" "useEffect cleanup" # fetch docs
extras/context7 docs "Express" "middleware error handling" # resolve + query
extras/context7 --help
```
The `docs` subcommand is a convenience that resolves the library, auto-selects
the top-ranked match, and immediately queries it (the chosen ID is printed to
stderr so stdout stays clean for piping). Environment overrides:
`CONTEXT7_MCP_ENDPOINT` (default `https://mcp.context7.com/mcp`) and
`CONTEXT7_API_KEY` (optional; sent as the `X-Context7-API-Key` header for
higher rate limits / private docs).
Dependencies: `curl`, `jq`.
## FAQ
### Does Bantam support `AGENTS.md` etc?
@@ -219,9 +307,9 @@ You can pair Bantam with the [Dynagate](https://code.luxferre.top/luxferre/dynag
### How to run on mobiles?
On Android, any current Bantam/MicroBantam implementation is easily runnable within the Termux environment. Go implementation is preferred for performance reasons.
On Android, Bantam (Go) and MicroBantam (`mb`) are easily runnable within the Termux environment. The Go implementation is preferred for performance reasons.
On iOS/iPadOS, the easiest way to use Bantam is to run the Perl version (or MicroBantam) inside iSH. Some terminal features may not be available (run with `rlwrap` to bring them back), but the agent itself is fully functional.
On iOS/iPadOS, the easiest way to use Bantam is to run MicroBantam (`mb`) inside iSH. Some terminal features may not be available (run with `rlwrap` to bring them back), but the agent itself is fully functional.
## Credits
-476
View File
@@ -1,476 +0,0 @@
#!/usr/bin/env perl
# Bantam agent: tiny, powerful, DIY
# Created by Luxferre in 2026, released into the public domain
use strict; use warnings;
use HTTP::Tiny; use JSON::PP; use File::Spec;
# core IO::Socket::IP 0.44 emits a spurious "Use of uninitialized value $err"
# warning on every timed connect (getsockopt(SO_ERROR) returns undef when the
# connection succeeds). Filter that exact noise out on our side; pass through
# everything else.
$SIG{__WARN__} = sub {
my $m = shift;
return if $m =~ /^Use of uninitialized value \$err in numeric eq \(==\) at .*IO\/Socket\/IP\.pm line \d+/;
warn $m;
};
binmode $_ => ':encoding(UTF-8)' for *STDIN, *STDOUT, *STDERR;
use File::Path qw(make_path); use POSIX qw(strftime); use Term::ReadLine;
use constant MAX_DEPTH => 5;
my $home = $ENV{HOME} || $ENV{USERPROFILE} || '.';
my $hist_file = File::Spec->catfile($home, '.bantam_history');
my $sdir = File::Spec->catfile($home, '.bantam', 'sessions');
my $auto_file = File::Spec->catfile($sdir, 'autosave.json');
my $_col = 0;
sub trim { my $s = shift // ''; $s =~ s/^\s+|\s+$//g; $s }
sub c { my ($t, @cs) = @_; ($_col && @cs) ? "\e[" . join(';', @cs) . "m$t\e[0m" : $t }
sub cp { my ($t, @cs) = @_; ($_col && @cs) ? "\001\e[" . join(';', @cs) . "m\002$t\001\e[0m\002" : $t }
sub col {
my ($cfg) = @_;
return 0 if $ENV{NO_COLOR} || $ENV{BANTAM_NO_COLOR};
my $m = lc(trim($cfg->{color} // 'auto'));
return 1 if $m eq 'always';
return 0 if $m eq 'never';
return -t STDOUT ? 1 : 0;
}
sub get_cfg {
my ($path) = @_;
$path //= 'model.cfg';
my %d = (endpoint => 'https://opencode.ai/zen/v1', model => 'big-pickle', temperature => '0.7', api_key => '-', timeout => '300', shell_timeout => '120', max_al_iterations => '1000', stream => 'true', color => 'auto');
if (-f $path) {
open my $fh, '<:encoding(UTF-8)', $path or die "Cannot open $path: $!";
while (my $ln = <$fh>) {
$ln = trim($ln); next if !$ln || $ln =~ /^#/ || $ln !~ /=/;
my ($k, $v) = split /=/, $ln, 2;
$d{trim($k)} = trim($v);
}
}
$d{api_key} = $ENV{OPENAI_API_KEY} if (!$d{api_key} || $d{api_key} eq '-') && $ENV{OPENAI_API_KEY};
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 {
my ($p) = @_;
$p //= 'system.txt';
if (-f $p) {
open my $fh, '<:encoding(UTF-8)', $p or return $def_sp;
local $/; my $ct = trim(<$fh>);
return length $ct ? $ct : $def_sp;
}
return $def_sp;
}
sub T { my ($n, $d, $p) = @_; { type => 'function', function => { name => $n, description => $d, parameters => { type => 'object', properties => $p, required => [keys %$p] } } } }
my @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' } })
);
sub shell_exec {
my ($cmd, $t) = @_;
$t //= 120;
my ($res, $code) = ('', 0);
eval {
local $SIG{ALRM} = sub { die "timeout\n" };
alarm(int($t));
$res = `$cmd 2>&1` // '';
$code = $? >> 8;
alarm(0);
};
if ($@ && $@ =~ /timeout/) {
return trim($res) . "\n\n[shell timeout after ${t}s]\nexit: -1";
}
return trim($res) . "\n\nexit: $code";
}
sub sanitize_msgs {
my ($msgs) = @_;
return unless $msgs && ref($msgs) eq 'ARRAY';
for my $m (@$msgs) {
next unless ref($m) eq 'HASH' && ($m->{role} // '') eq 'assistant' && $m->{tool_calls};
for my $tc (@{$m->{tool_calls}}) {
next unless ref($tc) eq 'HASH' && $tc->{function};
my $astr = $tc->{function}{arguments} // '{}';
my $a = eval { decode_json($astr) };
if ($@ || ref($a) ne 'HASH') {
$tc->{function}{arguments} = encode_json({ invalid_raw => $astr // '' });
}
}
}
}
my @fib = (1, 1, 2, 3, 5, 8, 13, 21, 34);
sub llm {
my ($cfg, $msgs, $tools) = @_;
sanitize_msgs($msgs);
my $ep = $cfg->{endpoint} =~ s/\/+$//r;
my $url = "$ep/chat/completions";
my %h = ('Content-Type' => 'application/json', 'User-Agent' => 'Mozilla/5.0 (compatible; Bantam/1.0)');
$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));
my $pend = c("...requesting...", 1, 2);
for my $i (0 .. $#fib + 1) {
my $dly = $i <= $#fib ? $fib[$i] : 0;
print $_col ? "\r$pend" : "$pend\n"; STDOUT->flush();
if (!$st) {
my $res = $http->request('POST', $url, { headers => \%h, content => $body });
if ($res->{success}) {
print "\r\e[K" if $_col; STDOUT->flush();
my $d = eval { decode_json($res->{content}) };
return $d->{choices}[0]{message} if $d && $d->{choices} && @{$d->{choices}};
}
my $status = $res->{status} // 0;
my $body = $res->{content} // '';
$body = substr($body, 0, 500) if length($body) > 500;
my $err = length($body) ? "$body (HTTP $status)" : ($res->{reason} || "HTTP status $status");
print "\r\e[K" if $_col; STDOUT->flush();
die "[HTTP error: $err]\n" if $status >= 400 && $status < 500 && $status != 408 && $status != 429;
if ($i <= $#fib) { print c("[network error: $err, retrying in ${dly}s...]", 31), "\n"; sleep($dly); next; }
die "[network error: $err]\n";
} else {
my ($content, $reas, $rh, $ch, $buf) = (q{}, q{}, 0, 0, q{});
my (%tcs, @order);
my $res = $http->request('POST', $url, {
headers => \%h, content => $body,
data_callback => sub {
my ($chunk) = @_;
if ($_col && !$buf && !$content && !$reas) { print "\r\e[K"; STDOUT->flush(); }
$buf .= $chunk;
while ($buf =~ s/^(.*?)\r?\n//) {
my $ln = trim($1);
next unless $ln =~ /^data:/;
my $data = trim(substr($ln, 5));
last if $data eq '[DONE]';
my $d = eval { decode_json($data) };
next unless $d && $d->{choices} && @{$d->{choices}};
my $dl = $d->{choices}[0]{delta} || {};
my $rc = $dl->{reasoning_content} // $dl->{reasoning};
if (defined $rc && length $rc) {
print c("--- reasoning start ---", 36), "\n" if !$rh; $rh = 1;
print c($rc, 2); STDOUT->flush(); $reas .= $rc;
}
my $cc = $dl->{content};
if (defined $cc && length $cc) {
print "\n", c("--- reasoning end ---", 36), "\n\n" if $rh && !$ch; $ch = 1;
print $cc; STDOUT->flush(); $content .= $cc;
}
if ($dl->{tool_calls}) {
for my $tc (@{$dl->{tool_calls}}) {
my $ti = $tc->{index} // 0;
if (!$tcs{$ti}) { $tcs{$ti} = {id => '', type => 'function', function => {name => '', arguments => ''}}; push @order, $ti; }
$tcs{$ti}{id} = $tc->{id} if $tc->{id};
if ($tc->{function}) {
$tcs{$ti}{function}{name} .= $tc->{function}{name} if $tc->{function}{name};
$tcs{$ti}{function}{arguments} .= $tc->{function}{arguments} if $tc->{function}{arguments};
}
}
}
}
}
});
if ($res->{success}) {
print "\r\e[K" if $_col && !$content && !$reas && !%tcs;
print "\n", c("--- reasoning end ---", 36), "\n" if $rh && !$ch;
print "\n" if $ch;
STDOUT->flush();
my %msg = (role => 'assistant');
$msg{content} = $content if length $content;
$msg{reasoning_content} = $reas if length $reas;
$msg{tool_calls} = [map { $tcs{$_} } @order] if @order;
return \%msg;
}
my $status = $res->{status} // 0;
my $body = $res->{content} // '';
$body = substr($body, 0, 500) if length($body) > 500;
my $err = length($body) ? "$body (HTTP $status)" : ($res->{reason} || "HTTP status $status");
print "\r\e[K" if $_col; STDOUT->flush();
die "[HTTP error: $err]\n" if $status >= 400 && $status < 500 && $status != 408 && $status != 429;
if ($i <= $#fib) { print c("[network error: $err, retrying in ${dly}s...]", 31), "\n"; sleep($dly); next; }
die "[network error: $err]\n";
}
}
}
sub AL {
my ($cfg, $msgs, $sp, $depth) = @_;
$depth //= 0;
my $stime = 0 + ($cfg->{shell_timeout} // 120);
my $mx = 0 + ($cfg->{max_al_iterations} // 1000);
my $st = ($cfg->{stream} // 'true') =~ /^(true|1|yes)$/i;
for my $i (1 .. $mx) {
my $m = eval { llm($cfg, $msgs, \@tools) };
if ($@) {
if ($@ =~ /Invalid assistant message|content or tool_calls must be set/) {
my $stripped = 0;
for (my $j = @$msgs - 1; $j >= 0; $j--) {
if (($msgs->[$j]{role} // '') eq 'assistant') {
print c("[stripped malformed assistant message]", 33), "\n";
splice @$msgs, $j, 1;
$stripped = 1;
last;
}
}
redo if $stripped;
}
print c($@, 31); return $msgs;
}
push @$msgs, $m;
if (!$st) {
my $reas = $m->{reasoning_content} // $m->{reasoning};
print c("--- reasoning start ---", 36), "\n", c($reas, 2), "\n", c("--- reasoning end ---", 36), "\n" if defined $reas && length $reas;
print $m->{content}, "\n" if defined $m->{content} && length $m->{content};
}
my $tcs = $m->{tool_calls};
last if !$tcs || !@$tcs;
for my $tc (@$tcs) {
my $fn = $tc->{function}{name} // '';
my $astr = $tc->{function}{arguments} // '{}';
print c("[tool call: $fn($astr)]", 33), "\n";
my ($res, $sty) = ('', 2);
my $a = eval { decode_json($astr) };
if ($@ || ref($a) ne 'HASH') {
$tc->{function}{arguments} = encode_json({ invalid_raw => $astr });
$res = "[tool error: invalid JSON args for $fn: " . ($@ || 'not a JSON object') . ". Raw: '$astr']"; $sty = 31;
} elsif ($fn eq 'shell_exec') {
$res = shell_exec($a->{command} // '', $stime);
} elsif ($fn eq 'run_subagent') {
if ($depth >= MAX_DEPTH) {
$res = "[subagent depth limit (" . MAX_DEPTH . ") reached, child not spawned]"; $sty = 31;
} else {
my $sub = [{ role => 'system', content => "$sp\n\nImportant: this is a child agent" }, { role => 'user', content => $a->{prompt} // '' }];
$res = last_assistant(AL($cfg, $sub, $sp, $depth + 1));
}
} else { $res = "Unknown tool: $fn"; $sty = 31; }
print c("[tool result: $fn]", 32), "\n", c($res, $sty), "\n\n";
push @$msgs, { role => 'tool', tool_call_id => $tc->{id}, content => $res };
}
}
return $msgs;
}
sub last_assistant {
for my $m (reverse @{$_[0]}) {
return $m->{content} if $m->{role} eq 'assistant' && defined $m->{content} && length $m->{content};
}
return '';
}
sub sdir { make_path($sdir) if !-d $sdir; $sdir }
sub summary {
for my $m (@{$_[0]}) {
if ($m->{role} eq 'user' && defined $m->{content} && trim($m->{content}) ne '') {
my $t = join(' ', split(/\s+/, trim($m->{content})));
return length($t) > 80 ? substr($t, 0, 80) . '...' : $t;
}
}
return '(empty session)';
}
sub save_session {
sdir();
my $base = strftime('%Y%m%d-%H%M%S', localtime);
my ($sid, $path, $i) = ($base, File::Spec->catfile($sdir, "$base.json"), 1);
while (-f $path) { $i++; $sid = "$base-$i"; $path = File::Spec->catfile($sdir, "$sid.json"); }
my $sum = summary($_[0]);
my %data = (id => $sid, created => strftime('%Y-%m-%d %H:%M:%S', localtime), summary => $sum, messages => $_[0]);
open my $fh, '>:encoding(UTF-8)', $path or die "Cannot save session: $!";
print $fh JSON::PP->new->utf8->pretty->encode(\%data);
return ($sid, $sum);
}
sub list_sessions {
my @out;
if (-d $sdir) {
opendir my $dh, $sdir or return ();
while (my $fn = readdir $dh) {
next unless $fn =~ /\.json$/;
open my $fh, '<:encoding(UTF-8)', File::Spec->catfile($sdir, $fn) or next;
local $/; my $d = eval { decode_json(<$fh>) }; next unless $d;
push @out, [$d->{id} // substr($fn, 0, -5), $d->{created} // '', $d->{summary} // '', ref($d->{messages}) eq 'ARRAY' ? scalar(@{$d->{messages}}) : 0];
}
}
return sort { $b->[0] cmp $a->[0] } @out;
}
sub load_session {
my ($sid) = @_;
my @entries;
if (-d $sdir) {
opendir my $dh, $sdir or die "Session directory not found\n";
while (my $fn = readdir $dh) {
next unless $fn =~ /\.json$/;
open my $fh, '<:encoding(UTF-8)', File::Spec->catfile($sdir, $fn) or next;
local $/; my $d = eval { decode_json(<$fh>) }; next unless $d;
push @entries, [$d->{id} // substr($fn, 0, -5), $d->{messages}];
}
}
my ($hit) = grep { $_->[0] eq $sid } @entries;
if (!$hit) {
my @pref = grep { $_->[0] =~ /^\Q$sid\E/ } @entries;
$hit = $pref[0] if @pref == 1;
die "ambiguous prefix: " . join(', ', map { $_->[0] } @pref) . "\n" if @pref > 1;
}
die "$sid\n" if !$hit;
return $hit->[1];
}
sub autosave {
sdir();
my %data = (id => 'autosave', created => strftime('%Y-%m-%d %H:%M:%S', localtime), summary => summary($_[0]), messages => $_[0]);
open my $fh, '>:encoding(UTF-8)', $auto_file or return;
print $fh JSON::PP->new->utf8->pretty->encode(\%data);
}
sub compact_conv {
my ($cfg, $msgs) = @_;
return ($msgs, '', 'session has no system message') if !@$msgs || ($msgs->[0]{role} // '') ne 'system';
my @conv;
for my $m (@$msgs) {
next if ($m->{role} // '') eq 'system';
my $ct = $m->{content} // '';
$ct = encode_json([map { { function => { name => $_->{function}{name}, arguments => $_->{function}{arguments} } } } @{$m->{tool_calls}}]) if !length($ct) && $m->{tool_calls};
next unless length $ct;
$ct = substr($ct, 0, 4000) . '...[truncated]' if length($ct) > 4000;
push @conv, ($m->{role} // '?') . ": $ct";
}
return ($msgs, '', 'no conversation to summarize') unless @conv;
my $joined = join("\n\n", @conv);
$joined = substr($joined, -100000) . "\n...[earlier parts truncated]" if length($joined) > 100000;
my $sys = "You are a conversation summarizer for an AI agent's context window. Summarize concisely but completely, preserving all important facts, decisions, code, errors, and the current task state, so the agent can continue the work without the original messages. Output only the summary.";
my %cc = (%$cfg, stream => 'false');
my $sm = [{ role => 'system', content => $sys }, { role => 'user', content => "Summarize this conversation:\n\n$joined" }];
my $m = eval { llm(\%cc, $sm, []) };
return ($msgs, '', "LLM error: $@") if $@;
my $s = trim($m->{content} // $m->{reasoning_content} // '');
return ($msgs, '', 'LLM returned an empty summary') unless length $s;
return ([{ role => 'system', content => $msgs->[0]{content} }, { role => 'user', content => "Summary of the previous conversation:\n$s\n\nPlease continue from here." }], $s, undef);
}
sub main {
my $sp = prompt(); my $cfg = get_cfg(); $_col = col($cfg);
my $msgs = [{ role => 'system', content => $sp }];
if (@ARGV && $ARGV[0]) {
my $p = $ARGV[0];
if (!-f $p) { print c("Error: file '$p' not found.", 31), "\n"; exit 1; }
open my $fh, '<:encoding(UTF-8)', $p or die "Cannot open $p: $!";
local $/; push @$msgs, { role => 'user', content => trim(<$fh>) };
AL($cfg, $msgs, $sp); autosave($msgs); exit 0;
}
print c("Bantam Agent ready", 1, 32), c(" (Ctrl+J = new line)", 2), "\n";
print c("endpoint: $cfg->{endpoint} model: $cfg->{model} temp: " . ($cfg->{temperature} // '0.7'), 2), "\n";
my $term = Term::ReadLine->new('bantam');
$Term::ReadLine::termcap_nowarn = 1; # silence termcap warning on stub Term::ReadLine
$term->ornaments(0) if $term->can('ornaments');
my $prompt_str = ref($term) eq 'Term::ReadLine::Gnu' ? cp("> ", 1, 36) : c("> ", 1, 36);
while (1) {
my $line = -t STDIN ? $term->readline($prompt_str) : do { print c("> ", 1, 36); scalar <STDIN> };
last unless defined $line;
my $u = trim($line); next unless length $u;
if ($u eq '/quit') { last; }
elsif ($u eq '/clear') { $msgs = [{ role => 'system', content => $sp }]; autosave($msgs); next; }
elsif ($u eq '/save') { my ($sid, $sm) = save_session($msgs); print c("[session saved: $sid]", 32), " ", c($sm, 2), "\n"; next; }
elsif ($u eq '/list') {
my @ss = list_sessions();
if (!@ss) { print c("No sessions saved yet.", 33), "\n"; next; }
for my $s (@ss) {
my ($sid, $st, $sm, $n) = @$s;
my $mk = ($sid eq 'autosave') ? c(" (autosave)", 33) : '';
print c($sid, 32), $mk, c(" $st [$n msgs]", 2), "\n ", c($sm, 2), "\n";
}
next;
} elsif ($u =~ /^\/load(?:\s+(.*))?$/) {
my $target = trim($1 // '');
if (!length $target) { print c("Usage: /load <session-id>", 31), "\n"; next; }
my $loaded = eval { load_session($target) };
if ($@) { print c("Session not found: " . trim($@), 31), "\n"; next; }
$msgs = $loaded; autosave($msgs);
print c("[session loaded: $target]", 32), " ", c(summary($msgs), 2), "\n";
next;
} elsif ($u eq '/compact') {
if (@$msgs <= 1) { print c("Nothing to compact yet.", 33), "\n"; next; }
print c("[compacting conversation...]", 33), "\n";
my ($nm, $sm, $err) = compact_conv($cfg, $msgs);
if ($err) { print c("[compact failed: $err]", 31), "\n"; next; }
$msgs = $nm; autosave($msgs);
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 <param> [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 <id>", "load session"], ["/compact", "compact context"], ["/cfg <k> [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 };
AL($cfg, $msgs, $sp); autosave($msgs);
}
autosave($msgs);
}
main() if !caller();
1;
-380
View File
@@ -1,380 +0,0 @@
#!/usr/bin/env python3
# Bantam agent: tiny, powerful, DIY
# Created by Luxferre in 2026, released into the public domain
import sys, os, json, re, time, subprocess, urllib.request
try: import readline
except ImportError: readline = None
HIST = os.path.expanduser("~/.bantam_history")
SDIR = os.path.expanduser("~/.bantam/sessions")
AUTO = os.path.join(SDIR, "autosave.json")
_COL = False
def c(t, *cs): return t if not _COL or not cs else "\033[" + ";".join(map(str, cs)) + "m" + t + "\033[0m"
def cp(t, *cs): # colored prompt: hide ANSI escapes from readline's width calc (\001/\002 = RL_PROMPT_START/END_IGNORE)
if readline is None or not _COL or not cs: return c(t, *cs)
return "\001\033[" + ";".join(map(str, cs)) + "m\002" + t + "\001\033[0m\002"
def col(cfg):
if os.environ.get("NO_COLOR") or os.environ.get("BANTAM_NO_COLOR"): return False
m = str(cfg.get("color", "auto")).strip().lower()
if m == "always": return True
if m == "never": return False
try: return sys.stdout.isatty()
except Exception: return False
def get_cfg(path="model.cfg"):
d = {"endpoint": "https://opencode.ai/zen/v1", "model": "big-pickle", "temperature": "0.7", "api_key": "-", "timeout": "300", "shell_timeout": "120", "max_al_iterations": "1000", "stream": "true", "color": "auto"}
if os.path.exists(path):
for ln in open(path, encoding="utf-8"):
ln = ln.strip()
if ln and not ln.startswith("#") and "=" in ln:
k, v = ln.split("=", 1); d[k.strip()] = v.strip()
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
DEFAULT_SYSTEM_PROMPT = """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.
Work 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.
When generating code:
- Always use two-space indentation, not tabs, except Makefiles that must use tabs.
- No whitespace between keywords and opening braces in C-like languages.
- Write optimally and with as few third-party dependencies as possible.
- Always test.
- No emojis in code or documentation.
- Respect AGENTS.md, GEMINI.md, CLAUDE.md contents in the project."""
def prompt(path="system.txt"):
if os.path.exists(path): return open(path, encoding="utf-8").read().strip()
return DEFAULT_SYSTEM_PROMPT
def T(name, desc, props): return {"type": "function", "function": {"name": name, "description": desc, "parameters": {"type": "object", "properties": props, "required": list(props)}}}
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"}})]
def shell(cmd, t=120):
try:
r = subprocess.run(cmd, shell=True, capture_output=True, text=True, timeout=float(t))
return f"{(r.stdout + r.stderr).strip()}\n\nexit: {r.returncode}"
except subprocess.TimeoutExpired as e:
out, err = e.stdout or "", e.stderr or ""
if isinstance(out, bytes): out = out.decode("utf-8", "replace")
if isinstance(err, bytes): err = err.decode("utf-8", "replace")
return f"{(out + err).strip()}\n\n[shell timeout after {t}s]\nexit: -1"
def sanitize_msgs(msgs):
for m in msgs:
if isinstance(m, dict) and m.get("role") == "assistant" and "tool_calls" in m:
tcs = m.get("tool_calls") or []
for tc in tcs:
if isinstance(tc, dict) and "function" in tc:
fn = tc.get("function") or {}
astr = fn.get("arguments", "{}")
try:
p = json.loads(astr) if astr else {}
if not isinstance(p, dict):
fn["arguments"] = json.dumps({"invalid_raw": astr} if astr else {})
except Exception:
fn["arguments"] = json.dumps({"invalid_raw": astr} if astr else {})
def is_invalid_assistant_err(e):
return bool(re.search(r"Invalid assistant message|content or tool_calls must be set", str(e)))
fib = [1, 1, 2, 3, 5, 8, 13, 21, 34]
def llm(cfg, msgs, tools):
sanitize_msgs(msgs)
url = cfg["endpoint"].rstrip("/") + "/chat/completions"
h = {"Content-Type": "application/json", "User-Agent": "Mozilla/5.0 (compatible; Bantam/1.0)"}
k = cfg.get("api_key", "").strip()
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]):
try:
if _COL: sys.stdout.write("\r" + pend); sys.stdout.flush()
else: sys.stdout.write(pend + "\n"); sys.stdout.flush()
req = urllib.request.Request(url, data=json.dumps(p).encode(), headers=h, method="POST")
with urllib.request.urlopen(req, timeout=num(cfg, "timeout", 300)) as r:
if not st:
msg = json.loads(r.read().decode())["choices"][0]["message"]
if _COL: sys.stdout.write("\r\033[K"); sys.stdout.flush()
return msg
if _COL: sys.stdout.write("\r\033[K"); sys.stdout.flush()
content, reas, tcs, rh, ch = "", "", {}, False, False
for ln in r:
ln = ln.decode("utf-8").strip()
if not ln.startswith("data:"): continue
if ln[5:].strip() == "[DONE]": break
try:
dl = json.loads(ln[5:].strip())["choices"][0].get("delta", {})
rc = dl.get("reasoning_content") or dl.get("reasoning")
if rc:
if not rh: sys.stdout.write(c("--- reasoning start ---", 36) + "\n"); rh = True
sys.stdout.write(c(rc, 2)); sys.stdout.flush(); reas += rc
cc = dl.get("content")
if cc:
if rh and not ch: sys.stdout.write("\n" + c("--- reasoning end ---", 36) + "\n\n")
ch = True; sys.stdout.write(cc); sys.stdout.flush(); content += cc
for tc in dl.get("tool_calls", []):
ti = tc.get("index", 0)
if ti not in tcs: tcs[ti] = {"id": tc.get("id", ""), "type": "function", "function": {"name": "", "arguments": ""}}
if tc.get("id"): tcs[ti]["id"] = tc["id"]
fn = tc.get("function")
if fn:
if fn.get("name"): tcs[ti]["function"]["name"] += fn["name"]
if fn.get("arguments"): tcs[ti]["function"]["arguments"] += fn["arguments"]
except Exception: pass
if rh and not ch: sys.stdout.write("\n" + c("--- reasoning end ---", 36) + "\n")
elif ch: sys.stdout.write("\n")
sys.stdout.flush()
m = {"role": "assistant", "content": content or None}
if reas: m["reasoning_content"] = reas
if tcs: m["tool_calls"] = list(tcs.values())
return m
except Exception as e:
if _COL: sys.stdout.write("\r\033[K"); sys.stdout.flush()
if isinstance(e, urllib.error.HTTPError):
code = e.code
body = e.read().decode("utf-8", "replace").strip() if hasattr(e, "read") else str(e)
if 400 <= code < 500 and code not in (408, 429):
raise RuntimeError(f"HTTP {code}: {body}")
if i < len(fib): print(c(f"[network error: {e}, retrying in {dly}s...]", 31)); time.sleep(dly)
else: raise
MAX_DEPTH = 5
def AL(cfg, msgs, sp, depth=0):
stime, mx, st = num(cfg, "shell_timeout", 120), num(cfg, "max_al_iterations", 1000), cfg.get("stream", "true").lower() in ("true", "1", "yes")
for _ in range(mx):
try:
m = llm(cfg, msgs, TOOLS)
except RuntimeError as e:
if is_invalid_assistant_err(e):
for i in range(len(msgs) - 1, -1, -1):
if msgs[i].get("role") == "assistant":
print(c("[stripped malformed assistant message]", 33))
del msgs[i]
break
else:
raise
continue
raise
msgs.append(m)
if not st:
reas = m.get("reasoning_content") or m.get("reasoning")
if reas: print(c("--- reasoning start ---", 36) + "\n" + c(reas, 2) + "\n" + c("--- reasoning end ---", 36) + "\n")
if m.get("content"): print(m["content"])
tcs = m.get("tool_calls")
if not tcs: break
for tc in tcs:
fn, astr = tc["function"]["name"], tc["function"].get("arguments", "{}")
print(c(f"[tool call: {fn}({astr})]", 33))
try:
a = json.loads(astr) if astr else {}
if not isinstance(a, dict): raise ValueError("args must be a JSON object")
err = ""
except Exception as e:
err, a = str(e), {}
tc["function"]["arguments"] = json.dumps({"invalid_raw": astr} if astr else {})
if err: res, sty = f"[tool error: invalid JSON args for {fn}: {err}. Raw: {astr!r}]", 31
elif fn == "shell_exec": res, sty = shell(a.get("command", ""), stime), 2
elif fn == "run_subagent":
if depth >= MAX_DEPTH:
res, sty = f"[subagent depth limit ({MAX_DEPTH}) reached, child not spawned]", 31
else:
sub = [{"role": "system", "content": sp + "\n\nImportant: this is a child agent"}, {"role": "user", "content": a.get("prompt", "")}]
res, sty = last(AL(cfg, sub, sp, depth + 1)), 2
else: res, sty = f"Unknown tool: {fn}", 31
print(c(f"[tool result: {fn}]", 32) + "\n" + c(res, sty) + "\n")
msgs.append({"role": "tool", "tool_call_id": tc["id"], "content": res})
else: msgs.append({"role": "assistant", "content": f"[max AL iterations ({mx}) reached]"})
return msgs
def last(msgs):
for m in reversed(msgs):
if m.get("role") == "assistant" and m.get("content"): return m["content"]
return ""
def sdir(): os.makedirs(SDIR, exist_ok=True); return SDIR
def summary(msgs):
for m in msgs:
if m.get("role") == "user" and isinstance(m.get("content"), str) and m["content"].strip():
t = " ".join(m["content"].split()); return t[:80] + ("..." if len(t) > 80 else "")
return "(empty session)"
def save(msgs):
d, base, i = sdir(), time.strftime("%Y%m%d-%H%M%S"), 1
sid, path = base, os.path.join(d, base + ".json")
while os.path.exists(path): i += 1; sid = base + "-" + str(i); path = os.path.join(d, sid + ".json")
data = {"id": sid, "created": time.strftime("%Y-%m-%d %H:%M:%S"), "summary": summary(msgs), "messages": msgs}
open(path, "w", encoding="utf-8").write(json.dumps(data, ensure_ascii=False, indent=2))
return sid, data["summary"]
def sessions():
out = []
if os.path.isdir(SDIR):
for fn in os.listdir(SDIR):
if not fn.endswith(".json"): continue
try:
d = json.load(open(os.path.join(SDIR, fn), encoding="utf-8"))
out.append((d.get("id", fn[:-5]), d.get("created", ""), d.get("summary", ""), len(d.get("messages", []))))
except Exception: pass
return sorted(out, key=lambda x: x[0], reverse=True)
def load(sid):
entries = []
if os.path.isdir(SDIR):
for fn in os.listdir(SDIR):
if not fn.endswith(".json"): continue
try: entries.append((json.load(open(os.path.join(SDIR, fn), encoding="utf-8")).get("id", fn[:-5]), os.path.join(SDIR, fn)))
except Exception: pass
hit = next((e for e in entries if e[0] == sid), None)
if not hit:
pref = [e for e in entries if e[0].startswith(sid)]
if len(pref) == 1: hit = pref[0]
elif len(pref) > 1: raise KeyError("ambiguous prefix: " + ", ".join(e[0] for e in pref))
if not hit: raise KeyError(sid)
return json.load(open(hit[1], encoding="utf-8"))["messages"]
def autosave(msgs):
d = {"id": "autosave", "created": time.strftime("%Y-%m-%d %H:%M:%S"), "summary": summary(msgs), "messages": msgs}
open(os.path.join(sdir(), "autosave.json"), "w", encoding="utf-8").write(json.dumps(d, ensure_ascii=False, indent=2))
def summarize(cfg, msgs):
conv = []
for m in msgs:
if m.get("role") == "system": continue
ct = m.get("content")
if not ct and m.get("tool_calls"): ct = json.dumps([{"function": tc["function"]["name"], "arguments": tc["function"]["arguments"]} for tc in m["tool_calls"]], ensure_ascii=False)
if not ct: continue
if len(ct) > 4000: ct = ct[:4000] + "...[truncated]"
conv.append(f"{m.get('role', '?')}: {ct}")
if not conv: return "", "no conversation to summarize"
joined = "\n\n".join(conv)
if len(joined) > 100000: joined = joined[-100000:] + "\n...[earlier parts truncated]"
sm = [{"role": "system", "content": "You are a conversation summarizer for an AI agent's context window. Summarize concisely but completely, preserving all important facts, decisions, code, errors, and the current task state, so the agent can continue the work without the original messages. Output only the summary."},
{"role": "user", "content": "Summarize this conversation:\n\n" + joined}]
cc = dict(cfg); cc["stream"] = "false"
try: m = llm(cc, sm, [])
except Exception as e: return "", f"LLM error: {e}"
s = (m.get("content") or m.get("reasoning_content") or "").strip()
return (s, None) if s else ("", "LLM returned an empty summary")
def compact(cfg, msgs):
if not msgs or msgs[0].get("role") != "system": return msgs, "", "session has no system message"
s, err = summarize(cfg, msgs)
if err: return msgs, "", err
return [{"role": "system", "content": msgs[0]["content"]}, {"role": "user", "content": "Summary of the previous conversation:\n" + s + "\n\nPlease continue from here."}], s, None
def main():
global _COL
if readline:
try: readline.read_history_file(HIST)
except OSError: pass
try: readline.parse_and_bind('"\\C-j": "\\C-v\\C-j"') # real LF via quoted-insert
except Exception: pass
sp, cfg = prompt(), get_cfg()
_COL = col(cfg)
msgs = [{"role": "system", "content": sp}]
if len(sys.argv) > 1 and sys.argv[1]:
p = sys.argv[1]
if not os.path.exists(p): print(c(f"Error: file '{p}' not found.", 31)); sys.exit(1)
msgs.append({"role": "user", "content": open(p, encoding="utf-8").read().strip()})
AL(cfg, msgs, sp); autosave(msgs)
if readline:
try: readline.write_history_file(HIST)
except OSError: pass
sys.exit(0)
print(c("Bantam Agent ready", 1, 32) + c(" (Ctrl+J = new line)", 2))
print(c(f"endpoint: {cfg['endpoint']} model: {cfg['model']} temp: {cfg.get('temperature', '0.7')}", 2))
while True:
try: u = input(cp("> ", 1, 36)).strip()
except (EOFError, KeyboardInterrupt): print(); break
if not u: continue
if u == "/quit": break
elif u == "/clear": msgs = [{"role": "system", "content": sp}]; autosave(msgs); continue
elif u == "/save":
sid, sm = save(msgs); print(c(f"[session saved: {sid}]", 32) + " " + c(sm, 2)); continue
elif u == "/list":
ss = sessions()
if not ss: print(c("No sessions saved yet.", 33)); continue
for sid, st, sm, n in ss:
mk = c(" (autosave)", 33) if sid == "autosave" else ""
print(c(sid, 32) + mk + c(f" {st} [{n} msgs]", 2) + "\n " + c(sm, 2))
continue
elif u.startswith("/load"):
parts = u.split(None, 1)
if len(parts) < 2: print(c("Usage: /load <session-id>", 31)); continue
try:
msgs = load(parts[1]); autosave(msgs)
print(c(f"[session loaded: {parts[1]}]", 32) + " " + c(summary(msgs), 2))
except KeyError as e: print(c(f"Session not found: {e}", 31))
continue
elif u == "/compact":
if len(msgs) <= 1: print(c("Nothing to compact yet.", 33)); continue
print(c("[compacting conversation...]", 33))
nm, sm, err = compact(cfg, msgs)
if err: print(c(f"[compact failed: {err}]", 31)); continue
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 <param> [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 <id>", "load session"), ("/compact", "compact context"), ("/cfg <k> [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)
autosave(msgs)
if readline:
try: readline.write_history_file(HIST)
except OSError: pass
if __name__ == "__main__": main()
+199
View File
@@ -0,0 +1,199 @@
#!/bin/sh
# context7 - query library/framework documentation via the Context7 public
# MCP server (https://mcp.context7.com/mcp).
#
# Context7 keeps up-to-date docs and code examples for thousands of libraries
# and frameworks and exposes them through an MCP (Model Context Protocol)
# server. This script speaks the JSON-RPC MCP protocol over HTTP:
# 1. initialize -> obtains an Mcp-Session-Id (optional)
# 2. notifications/initialized -> no reply
# 3. tools/call -> runs a Context7 tool and prints text
#
# Context7 provides two tools:
# * resolve-library-id maps a library name to a Context7 ID (/org/project).
# * query-docs fetches docs + examples for a resolved library ID.
#
# Usage:
# context7 resolve <libraryName> [query]
# Search for a library and print the candidate Context7 IDs with their
# description, snippet count, reputation and benchmark score.
#
# context7 query <libraryId> <query>
# Fetch documentation for an explicit library ID (format /org/project
# or /org/project/version). The ID usually comes from `resolve`.
#
# context7 docs <libraryName> <query>
# Convenience: resolve the library, auto-pick the top-ranked match and
# immediately query its documentation.
#
# context7 --help
#
# Environment:
# CONTEXT7_MCP_ENDPOINT optional; default https://mcp.context7.com/mcp
# CONTEXT7_API_KEY optional; if set, sent as the X-Context7-API-Key
# request header (for higher rate limits / private
# docs). No extra headers are added when unset.
#
# Dependencies: curl, jq.
set -eu
usage() {
sed -n '/^# Usage:/,/^# Dependencies:/p' "$0" | sed 's/^# \{0,1\}//'
}
if [ "$#" -lt 1 ]; then
usage >&2
exit 1
fi
CMD="$1"
shift
case "$CMD" in
-h|--help|help)
usage
exit 0
;;
resolve|query|docs)
;;
*)
echo "context7: unknown command '$CMD' (try --help)" >&2
exit 1
;;
esac
# --- Validate argument counts per command. ---
case "$CMD" in
resolve)
if [ "$#" -lt 1 ]; then
echo "context7: resolve requires <libraryName> [query]" >&2
exit 1
fi
LIB_NAME="$1"
QUERY="${2:-$1}"
;;
query)
if [ "$#" -lt 2 ]; then
echo "context7: query requires <libraryId> <query>" >&2
exit 1
fi
LIB_ID="$1"
QUERY="$2"
;;
docs)
if [ "$#" -lt 2 ]; then
echo "context7: docs requires <libraryName> <query>" >&2
exit 1
fi
LIB_NAME="$1"
QUERY="$2"
;;
esac
ENDPOINT="${CONTEXT7_MCP_ENDPOINT:-https://mcp.context7.com/mcp}"
# Optional auth header (single token, no spaces expected in an API key).
AUTH_ARGS=""
if [ -n "${CONTEXT7_API_KEY:-}" ]; then
AUTH_ARGS="-H X-Context7-API-Key:${CONTEXT7_API_KEY}"
fi
# --- MCP handshake + tool call helper. ---
# Args: $1 = tool name, $2 = arguments JSON. Prints the parsed text content.
mcp_call() {
TOOL="$1"
ARGS="$2"
INIT_HEADERS=$(mktemp)
INIT_BODY=$(mktemp)
curl -s -D "$INIT_HEADERS" -X POST "$ENDPOINT" \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
$AUTH_ARGS \
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"context7","version":"1.0"}}}' \
> "$INIT_BODY"
SID=$(grep -i '^mcp-session-id:' "$INIT_HEADERS" | tr -d '\r' | awk '{print $2}')
if [ -n "$SID" ]; then
curl -s -X POST "$ENDPOINT" \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-H "Mcp-Session-Id: $SID" \
$AUTH_ARGS \
-d '{"jsonrpc":"2.0","method":"notifications/initialized"}' > /dev/null
SID_ARGS="-H Mcp-Session-Id:$SID"
else
SID_ARGS=""
fi
CALL_BODY=$(mktemp)
curl -s -X POST "$ENDPOINT" \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
$SID_ARGS \
$AUTH_ARGS \
-d "{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"tools/call\",\"params\":{\"name\":\"$TOOL\",\"arguments\":$ARGS}}" \
> "$CALL_BODY"
# Take the last "data:" SSE payload (or the whole body if plain JSON).
DATA=$(grep -E '^data:[[:space:]]' "$CALL_BODY" | tail -n1 | sed 's/^data:[[:space:]]*//')
if [ -z "$DATA" ]; then
DATA=$(cat "$CALL_BODY")
fi
rm -f "$INIT_HEADERS" "$INIT_BODY" "$CALL_BODY"
if [ -z "$DATA" ]; then
echo "context7: no response from MCP server" >&2
return 1
fi
# Surface protocol errors.
ERROR_MSG=$(printf '%s' "$DATA" | jq -r 'if .error then (.error|tostring) else empty end')
if [ -n "$ERROR_MSG" ]; then
echo "context7 error: $ERROR_MSG" >&2
return 1
fi
printf '%s' "$DATA" | jq -r '
(.result.content // [])
| map(select(.type == "text"))
| map(.text)
| .[]
'
}
# --- Dispatch. ---
case "$CMD" in
resolve)
ARGS=$(jq -n --arg n "$LIB_NAME" --arg q "$QUERY" \
'{libraryName:$n, query:$q}')
mcp_call "resolve-library-id" "$ARGS"
;;
query)
ARGS=$(jq -n --arg id "$LIB_ID" --arg q "$QUERY" \
'{libraryId:$id, query:$q}')
mcp_call "query-docs" "$ARGS"
;;
docs)
RARGS=$(jq -n --arg n "$LIB_NAME" --arg q "$QUERY" \
'{libraryName:$n, query:$q}')
RESOLVE_OUT=$(mcp_call "resolve-library-id" "$RARGS") || exit 1
# Auto-pick the first candidate library ID from the resolve output.
TOP_ID=$(printf '%s\n' "$RESOLVE_OUT" \
| grep -m1 -oE '/[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+' || true)
if [ -z "$TOP_ID" ]; then
echo "context7: could not extract a library ID from resolve output:" >&2
printf '%s\n' "$RESOLVE_OUT" >&2
exit 1
fi
echo "context7: using library ID $TOP_ID (from resolve of '$LIB_NAME')" >&2
QARGS=$(jq -n --arg id "$TOP_ID" --arg q "$QUERY" \
'{libraryId:$id, query:$q}')
mcp_call "query-docs" "$QARGS"
;;
esac
exit 0
Executable
+190
View File
@@ -0,0 +1,190 @@
#!/bin/sh
# weather - query current weather and forecasts for any location via wttr.in.
#
# wttr.in (https://github.com/chubin/wttr.in) is a console-oriented weather
# service backed by World Weather Online data. It supports several output
# formats: a graphical ANSI view for terminals (the default), one-line text
# formats (built-in presets 1-4 or a custom %-notation string), a rich JSON
# document (?format=j1 / j2) for scripts and APIs, plus PNG / HTML /
# Prometheus metrics.
#
# This script is a thin, friendly wrapper around the wttr.in HTTP API. It
# builds the request URL from the supplied location and options, fetches the
# data with curl and (when JSON is requested) pretty-prints it with jq.
#
# Usage:
# weather [options] [location]
#
# Options:
# -l, --location LOC Location: city, airport code, domain, IP,
# "lat,lon" coordinates, or "~Name" for a custom label.
# Default: auto-detect from the request IP.
# -u, --units U Unit system: m (metric, default), u (USCS/imperial),
# M (metric with wind speed in m/s).
# -L, --lang LANG Output language code, e.g. de, fr, ru, zh-cn.
# -f, --format FMT Output format:
# j1 -> rich JSON document (default for --json)
# j2 -> JSON document, imperial units
# 1-4 -> built-in one-line presets
# "%..." -> custom one-line %-notation format
# Omit to get the default graphical terminal view.
# -j, --json Alias for --format j1 (emit JSON).
# -0 Current weather only.
# -1 Current weather + today's forecast.
# -2 Current weather + today's + tomorrow's forecast.
# -q, --quiet Quiet: no "Weather report" header / city name.
# -A Force ANSI output (ignore User-Agent detection).
# -h, --help Show this help text.
#
# Environment:
# WTTRAPI Base endpoint. Default: https://wttr.in
# WEATHER_TIMEOUT curl connect/read timeout in seconds. Default: 20
#
# Dependencies: curl, jq (jq only needed for the JSON format).
#
# Examples:
# weather
# weather London
# weather -u u -L de Berlin
# weather -f 3 "New York"
# weather -f "%l: %c %t (feels %f), wind %w %m" Paris
# weather -j Tokyo
set -eu
WTTRAPI="${WTTRAPI:-https://wttr.in}"
TIMEOUT="${WEATHER_TIMEOUT:-20}"
# Single-letter options (view/quiet/units) concatenated into one run.
SHORT=""
LANG_CODE=""
FORMAT=""
LOCATION=""
usage() {
sed -n '/^# Usage:/,/^# Examples:/p' "$0" | sed 's/^# \{0,1\}//'
}
# --- Parse arguments. ---
while [ "$#" -gt 0 ]; do
case "$1" in
-h|--help)
usage
exit 0
;;
-l|--location)
[ "$#" -ge 2 ] || { echo "weather: $1 requires an argument" >&2; exit 1; }
LOCATION="$2"
shift 2
;;
-u|--units)
[ "$#" -ge 2 ] || { echo "weather: $1 requires an argument" >&2; exit 1; }
case "$2" in
m|u|M) SHORT="${SHORT}$2" ;;
*) echo "weather: unknown unit '$2' (use m, u or M)" >&2; exit 1 ;;
esac
shift 2
;;
-L|--lang)
[ "$#" -ge 2 ] || { echo "weather: $1 requires an argument" >&2; exit 1; }
LANG_CODE="$2"
shift 2
;;
-f|--format)
[ "$#" -ge 2 ] || { echo "weather: $1 requires an argument" >&2; exit 1; }
FORMAT="$2"
shift 2
;;
-j|--json)
FORMAT="j1"
shift
;;
-0|-1|-2|-q|-A)
SHORT="${SHORT}${1#-}"
shift
;;
--)
shift
LOCATION="${LOCATION:+$LOCATION }$(printf '%s' "$*" | sed 's/^ //')"
break
;;
-*)
echo "weather: unknown option '$1' (try --help)" >&2
exit 1
;;
*)
# Positional: append to location (preserves multi-word locations).
LOCATION="${LOCATION:+$LOCATION }$1"
shift
;;
esac
done
# --- Assemble the query string. ---
# wttr.in accepts a leading run of single-letter options, then '&'-joined
# long options (key=value). We build both parts and join them.
QUERY="$SHORT"
# Long options: format and lang.
LONG=""
if [ -n "$FORMAT" ]; then
case "$FORMAT" in
j1|j2)
LONG="${LONG:+$LONG&}format=${FORMAT}"
;;
[1-4])
LONG="${LONG:+$LONG&}format=${FORMAT}"
;;
*)
# Custom %-notation one-line format -> URL-encode it.
ENC=$(printf '%s' "$FORMAT" | jq -sRr @uri)
LONG="${LONG:+$LONG&}format=${ENC}"
;;
esac
fi
if [ -n "$LANG_CODE" ]; then
LONG="${LONG:+$LONG&}lang=$(printf '%s' "$LANG_CODE" | jq -sRr @uri)"
fi
# Combine short + long parts into a single query string.
if [ -n "$QUERY" ] && [ -n "$LONG" ]; then
QUERY="${QUERY}&${LONG}"
elif [ -z "$QUERY" ] && [ -n "$LONG" ]; then
QUERY="$LONG"
fi
# URL-encode the location (spaces -> %20, etc.). Empty means auto-detect.
if [ -n "$LOCATION" ]; then
LOC_ENC=$(printf '%s' "$LOCATION" | jq -sRr @uri)
else
LOC_ENC=""
fi
URL="${WTTRAPI}/${LOC_ENC}"
if [ -n "$QUERY" ]; then
URL="${URL}?${QUERY}"
fi
# --- Fetch. ---
# wttr.in returns the graphical ANSI art whenever the request looks like it
# comes from a console client (curl's default User-Agent), and HTML for
# browsers. So we keep curl's default identity. The "-A" flag above is a
# wttr.in URL option ("force ANSI") and is already part of the query string.
RESP=$(curl -s -L --max-time "$TIMEOUT" "$URL") || {
echo "weather: failed to reach $WTTRAPI" >&2
exit 1
}
# --- Emit. ---
if [ "$FORMAT" = "j1" ] || [ "$FORMAT" = "j2" ]; then
if command -v jq >/dev/null 2>&1; then
printf '%s\n' "$RESP" | jq . 2>/dev/null || printf '%s\n' "$RESP"
else
printf '%s\n' "$RESP"
fi
else
printf '%s\n' "$RESP"
fi
exit 0
+106
View File
@@ -0,0 +1,106 @@
#!/bin/sh
# websearch - simple web search for Bantam via Exa's MCP server (Streamable HTTP).
#
# Talks to https://mcp.exa.ai/mcp using the JSON-RPC MCP protocol:
# 1. initialize -> obtains an Mcp-Session-Id from response headers
# 2. notifications/initialized -> no reply
# 3. tools/call (web_search_exa) -> prints the formatted result text
#
# Usage:
# websearch "your query"
# websearch "your query" [num_results]
#
# Environment:
# EXA_MCP_ENDPOINT optional; default https://mcp.exa.ai/mcp
# EXA_API_KEY optional; if set, sent as an URL query parameter (?api_key=)
# so that NO extra HTTP headers are added beyond Content-Type.
#
# Dependencies: curl, jq.
set -eu
if [ "$#" -lt 1 ]; then
echo "Usage: websearch \"query\" [num_results]" >&2
exit 1
fi
QUERY="$1"
NUM="${2:-5}"
ENDPOINT="${EXA_MCP_ENDPOINT:-https://mcp.exa.ai/mcp}"
# Append API key as a query parameter (no extra headers).
if [ -n "${EXA_API_KEY:-}" ]; then
URL="${ENDPOINT}?api_key=${EXA_API_KEY}"
else
URL="${ENDPOINT}"
fi
# Build the tools/call arguments JSON safely (proper query escaping).
ARGS=$(jq -n --arg q "$QUERY" --argjson n "$NUM" \
'{query:$q, numResults:$n}')
# --- Step 1: initialize, capture headers (for Mcp-Session-Id) and body. ---
INIT_HEADERS=$(mktemp)
INIT_BODY=$(mktemp)
curl -s -D "$INIT_HEADERS" -X POST "$URL" \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"websearch","version":"1.0"}}}' \
> "$INIT_BODY"
SID=$(grep -i '^mcp-session-id:' "$INIT_HEADERS" | tr -d '\r' | awk '{print $2}')
if [ -z "$SID" ]; then
echo "websearch: failed to obtain MCP session id" >&2
cat "$INIT_BODY" >&2
rm -f "$INIT_HEADERS" "$INIT_BODY"
exit 1
fi
# --- Step 2: send initialized notification (fire and forget). ---
curl -s -X POST "$URL" \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-H "Mcp-Session-Id: $SID" \
-d '{"jsonrpc":"2.0","method":"notifications/initialized"}' > /dev/null
# --- Step 3: call web_search_exa and extract the text result. ---
CALL_BODY=$(mktemp)
curl -s -X POST "$URL" \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-H "Mcp-Session-Id: $SID" \
-d "{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"tools/call\",\"params\":{\"name\":\"web_search_exa\",\"arguments\":$ARGS}}" \
> "$CALL_BODY"
# Parse the response: take the last "data:" SSE payload (or the whole body
# if it is plain JSON) and print the text blocks from the result.
DATA=$(grep -E '^data:[[:space:]]' "$CALL_BODY" | tail -n1 | sed 's/^data:[[:space:]]*//')
if [ -z "$DATA" ]; then
DATA=$(cat "$CALL_BODY")
fi
if [ -z "$DATA" ]; then
echo "websearch: no response from MCP server" >&2
rm -f "$INIT_HEADERS" "$INIT_BODY" "$CALL_BODY"
exit 1
fi
# Surface server/protocol errors, then emit text content.
ERROR_MSG=$(printf '%s' "$DATA" | jq -r 'if .error then (.error|tostring) else empty end')
if [ -n "$ERROR_MSG" ]; then
echo "websearch error: $ERROR_MSG" >&2
rm -f "$INIT_HEADERS" "$INIT_BODY" "$CALL_BODY"
exit 1
fi
printf '%s' "$DATA" | jq -r '
(.result.content // [])
| map(select(.type == "text"))
| map(.text)
| .[]
'
rm -f "$INIT_HEADERS" "$INIT_BODY" "$CALL_BODY"
exit 0
+881 -110
View File
File diff suppressed because it is too large Load Diff
+779 -34
View File
File diff suppressed because it is too large Load Diff
+36 -62
View File
@@ -1,108 +1,83 @@
#!/usr/bin/env perl
# MicroBantam (mb): the Bantam agent in <100 SLOC - readable, core modules only
# Created by Luxferre in 2026, released into the public domain
use strict; use warnings; use HTTP::Tiny; use JSON::PP; use POSIX qw(strftime); use File::Path qw(make_path);
$SIG{__WARN__} = sub { warn $_[0] unless $_[0] =~ /^Use of uninitialized value \$err in numeric eq \(==\) at .*IO\/Socket\/IP\.pm line \d+/ };
binmode $_ => ':encoding(UTF-8)' for *STDIN, *STDOUT, *STDERR; $| = 1; # unbuffered output in UTF-8
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.";
my $SDIR = ($ENV{HOME} || $ENV{USERPROFILE} || '.') . '/.bantam/sessions';
sub cfg { my %d = (endpoint=>'https://opencode.ai/zen/v1', model=>'big-pickle', temperature=>0.7, api_key=>'-', timeout=>300, shell_timeout=>120, max_al_iterations=>1000);
if (open my $f, '<:encoding(UTF-8)', 'model.cfg') { while (<$f>) { /^(\w+)\s*=\s*(.+)$/ and $d{$1} = $2; } }
$d{api_key} = $ENV{OPENAI_API_KEY} if $d{api_key} eq q{-} && $ENV{OPENAI_API_KEY};
\%d; }
sub sp { my $p = '';
if (open my $f, '<:encoding(UTF-8)', 'system.txt') { local $/; $p = <$f>; }
$p =~ s/^\s+|\s+$//g;
length($p) ? $p : $DEF_SP; }
if (open my $f, '<:encoding(UTF-8)', 'model.cfg') { while (<$f>) { /^([^\s=]+)\s*=\s*(.+)$/ and $d{$1} = $2; } }
$d{api_key} = $ENV{OPENAI_API_KEY} if $d{api_key} eq '-' && $ENV{OPENAI_API_KEY}; \%d }
sub sp { my $p = ''; if (open my $f, '<:encoding(UTF-8)', 'system.txt') { local $/; $p = <$f>; } $p =~ s/^\s+|\s+$//g; length($p) ? $p : $DEF_SP }
sub filter_text { my $s = shift // ''; $s =~ s/[^\x20\t\n\p{L}\p{N}\p{P}\p{S}\p{M}\p{Zs}]//g; $s }
sub T { my ($n, $d, $p) = @_; {type=>'function', function=>{name=>$n, description=>$d, parameters=>{type=>'object', properties=>$p, required=>[keys %$p]}}} }
sub sanitize_msgs { for my $m (@{$_[0]}) { if (ref $m eq 'HASH' && $m->{tool_calls}) { for my $tc (@{$m->{tool_calls}}) { my $a = eval { decode_json($tc->{function}{arguments} // '{}') }; $tc->{function}{arguments} = encode_json({invalid_raw => $tc->{function}{arguments} // ''}) if ref $a ne 'HASH'; } } } }
sub llm { my ($c, $msgs) = @_; # one non-streaming chat completion
sanitize_msgs($msgs);
sub sanitize_msgs { for my $m (@{$_[0]}) { if (ref $m eq 'HASH') {
for my $tc (@{$m->{tool_calls} // []}) { $tc->{function}{arguments} = filter_text($tc->{function}{arguments});
my $a = eval { decode_json($tc->{function}{arguments} // '{}') };
$tc->{function}{arguments} = encode_json({invalid_raw => $tc->{function}{arguments} // ''}) if ref $a ne 'HASH'; }
$m->{content} = filter_text($m->{content}) if ($m->{role} // '') eq 'tool' && defined $m->{content};
} } }
sub llm { my ($c, $msgs) = @_; sanitize_msgs($msgs);
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 %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});
print "\r\e[K" if $tty; # clear the spinner line
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=>encode_json(\%p)});
print "\r\e[K" if $tty;
my $d = $r->{success} ? eval { decode_json($r->{content}) } : undef;
return $d->{choices}[0]{message} if $d && $d->{choices} && @{$d->{choices}};
my $rb = $r->{content} // '';
$rb = substr($rb, 0, 500) if length($rb) > 500;
my $rb = substr($r->{content} // '', 0, 500);
die "API error: " . (length($rb) ? "$rb (HTTP $r->{status})" : ($r->{reason} || "HTTP $r->{status}")) . "\n"; }
sub shell_exec { my ($cmd, $t) = @_; # run a command under a hard timeout
my $out = '';
sub shell_exec { my ($cmd, $t, $out) = (filter_text($_[0]), $_[1], '');
eval { local $SIG{ALRM} = sub { alarm 0; die "timeout\n" }; alarm $t; $out = `$cmd 2>&1`; alarm 0; };
$out =~ s/\s+$//;
utf8::decode($out);
$out =~ s/\s+$//; utf8::decode($out); $out = filter_text($out);
$@ ? "$out\n[timeout after ${t}s]\nexit: -1" : "$out\nexit: " . ($? >> 8); }
sub last_assistant { for my $m (reverse @{$_[0]}) { return $m->{content} if $m->{role} eq 'assistant' && defined $m->{content} && length $m->{content}; } '' }
sub AL { my ($c, $msgs, $sp, $depth) = @_; # the agentic loop: LLM <-> tools until done
$depth ||= 0;
sub last_assistant { for my $m (reverse @{$_[0]}) { return $m->{content} if ($m->{role} // '') eq 'assistant' && defined $m->{content} && length $m->{content}; } '' }
sub AL { my ($c, $msgs, $sp, $depth) = ($_[0], $_[1], $_[2], $_[3] || 0);
for (1 .. $c->{max_al_iterations}) {
my $m = eval { llm($c, $msgs) };
if ($@ && $@ =~ /Invalid assistant message|content or tool_calls must be set/ && grep({ $_->{role} eq 'assistant' } @$msgs)) {
for (my $j = @$msgs - 1; $j >= 0; $j--) { if ($msgs->[$j]{role} eq 'assistant') { splice @$msgs, $j, 1; last; } }
if ($@ && $@ =~ /Invalid assistant message|content or tool_calls must be set/ && grep({ ($_->{role} // '') eq 'assistant' } @$msgs)) {
for (my $j = @$msgs - 1; $j >= 0; $j--) { if (($msgs->[$j]{role} // '') eq 'assistant') { splice @$msgs, $j, 1; last; } }
print "[stripped malformed assistant message]\n"; redo;
}
if ($@) { print $@; return $msgs; }
push @$msgs, $m;
print $m->{content}, "\n" if defined $m->{content} && length $m->{content};
my $tcs = $m->{tool_calls};
last unless $tcs && @$tcs;
my $tcs = $m->{tool_calls}; last unless $tcs && @$tcs;
for my $tc (@$tcs) {
my $fn = $tc->{function}{name};
my ($fn, $res) = ($tc->{function}{name});
$tc->{function}{arguments} = filter_text($tc->{function}{arguments});
my $a = eval { decode_json($tc->{function}{arguments} // '{}') };
my $res;
if (ref $a ne 'HASH') { $tc->{function}{arguments} = encode_json({invalid_raw => $tc->{function}{arguments} // ''}); $res = "bad JSON args for $fn: " . ($tc->{function}{arguments} // ''); }
if (ref $a ne 'HASH') { $tc->{function}{arguments} = encode_json({invalid_raw => $tc->{function}{arguments} // ''}); $res = "bad JSON args for $fn: $tc->{function}{arguments}"; }
elsif ($fn eq 'shell_exec') { $res = shell_exec($a->{command} // '', $c->{shell_timeout}); }
elsif ($fn eq 'run_subagent') { $res = $depth >= 5 ? '[subagent depth limit (5) reached, child not spawned]' : last_assistant(AL($c, [{role=>'system', content=>"$sp\n\nImportant: this is a child agent"}, {role=>'user', content=>$a->{prompt} // ''}], $sp, $depth + 1)); }
elsif ($fn eq 'run_subagent') { $res = $depth >= 5 ? '[subagent depth limit (5) reached, child not spawned]' : filter_text(last_assistant(AL($c, [{role=>'system', content=>"$sp\n\nImportant: this is a child agent"}, {role=>'user', content=>filter_text($a->{prompt} // '')}], $sp, $depth + 1))); }
else { $res = "unknown tool: $fn"; }
$res = filter_text($res);
print "[tool] $fn: $res\n";
push @$msgs, {role=>'tool', tool_call_id=>$tc->{id}, content=>$res};
}
}
$msgs; }
sub sessions { my @s; # all saved sessions, newest first
for my $f (glob "$SDIR/*.json") { open my $fh, '<', $f or next; local $/; my $d = eval { decode_json(<$fh>) }; push @s, $d if $d; }
sort { $b->{id} cmp $a->{id} } @s; }
sub sessions { my @s; for my $f (glob "$SDIR/*.json") { open my $fh, '<', $f or next; local $/; my $d = eval { decode_json(<$fh>) }; push @s, $d if $d; } sort { $b->{id} cmp $a->{id} } @s }
sub sdir { make_path($SDIR) unless -d $SDIR; $SDIR }
sub save { sdir(); my $id = strftime('%Y%m%d-%H%M%S', localtime); my $i = 0;
$id .= '-' . ++$i while -f "$SDIR/$id.json";
open my $f, '>', "$SDIR/$id.json" or die "cannot save: $!";
print $f JSON::PP->new->utf8->pretty->encode({id=>$id, messages=>$_[0]}); close $f; $id; }
sub load { my ($want) = @_; my ($hit) = grep { $_->{id} eq $want } sessions(); die "no session: $want\n" unless $hit; $hit->{messages}; }
sub save { sdir(); my ($id, $i) = (strftime('%Y%m%d-%H%M%S', localtime), 0); $id .= '-' . ++$i while -f "$SDIR/$id.json";
open my $f, '>', "$SDIR/$id.json" or die "cannot save: $!"; print $f JSON::PP->new->utf8->pretty->encode({id=>$id, messages=>$_[0]}); close $f; $id }
sub load { my ($hit) = grep { $_->{id} eq $_[0] } sessions(); die "no session: $_[0]\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, $_; } } }
sub set_cfg { my ($k, $v, @ls, $f) = @_;
if (open my $fh, '<:encoding(UTF-8)', 'model.cfg') { while (<$fh>) { push @ls, (!/^#/ && /^([^\s=]+)\s*=/ && $1 eq $k) ? ($f = 1, "$k=$v\n") : $_; } }
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; }
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]: $!"; 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 <id> /cfg <k> [v] /help\n";
while (1) {
print "> "; my $u = <STDIN>; last unless defined $u;
$u =~ s/^\s+|\s+$//g; next unless length $u;
print "> "; my $u = <STDIN>; last unless defined $u; $u =~ s/^\s+|\s+$//g; next unless length $u;
if ($u eq '/quit') { last; }
elsif ($u eq '/clear') { $msgs = [{role=>'system', content=>$sp}]; autosave($msgs); }
elsif ($u eq '/save') { print "session saved: ", save($msgs), "\n"; }
@@ -114,5 +89,4 @@ sub main {
}
autosave($msgs);
}
main() unless caller();
-110
View File
@@ -1,110 +0,0 @@
#!/usr/bin/env jimsh
# MicroBantam (mb): the Bantam agent in Jim Tcl (under 100 SLOC)
# Created by Luxferre in 2026, released into the public domain
set 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."
set SDIR "[expr {[info exists env(HOME)] ? $env(HOME) : ([info exists env(USERPROFILE)] ? $env(USERPROFILE) : ".")} ]/.bantam/sessions"
proc is_dict {d} { return [expr {![catch {dict keys $d}] && [llength $d] % 2 == 0}] }
proc safe_get {d args} { foreach k $args { if {![is_dict $d] || ![dict exists $d $k]} { return "" }; set d [dict get $d $k] }; return $d }
proc is_tool_call {tc} { return [expr {[is_dict $tc] && [dict exists $tc function] && [is_dict [dict get $tc function]] && [dict exists [dict get $tc function] name]}] }
proc jesc {s} { set map [list \\ \\\\ \" \\" \n \\n \r \\r \t \\t \f \\f \b \\b]; set s [string map $map $s]; set res ""; for {set i 0} {$i < [string length $s]} {incr i} { set c [string index $s $i]; scan $c %c k; append res [expr {$k < 32 ? [format "\\u%04x" $k] : $c}] }; return "\"$res\"" }
proc cfg {} { global env; set d [dict create endpoint https://opencode.ai/zen/v1 model big-pickle temperature 0.7 api_key - timeout 300 shell_timeout 120 max_al_iterations 1000]; if {[file exists model.cfg] && ![catch {open model.cfg r} f]} { while {[gets $f l] >= 0} { if {[regexp {^(\w+)\s*=\s*(.+)$} $l -> k v]} { dict set d $k $v } }; close $f }; if {[dict get $d api_key] eq "-" && [info exists env(OPENAI_API_KEY)]} { dict set d api_key $env(OPENAI_API_KEY) }; return $d }
proc sp {} { global DEF_SP; set p ""; if {[file exists system.txt] && ![catch {open system.txt r} f]} { set p [string trim [read $f]]; close $f }; return [expr {[string length $p] ? $p : $DEF_SP}] }
proc decode_chunked {b} { set res ""; set pos 0; while {$pos < [string length $b]} { set idx [string first "\r\n" $b $pos]; if {$idx == -1} break; scan [lindex [split [string range $b $pos [expr {$idx - 1}]] ";"] 0] "%x" clen; if {$clen == 0} break; set st [expr {$idx + 2}]; append res [string range $b $st [expr {$st + $clen - 1}]]; set pos [expr {$st + $clen + 2}] }; return $res }
proc http_request {m url hdrs body {t 300}} { set proto http; set host ""; set port 80; set path "/"; if {[regexp {^(https?)://([^/]+)(/.*)?$} $url -> proto hp reqp]} { set port [expr {$proto eq "https" ? 443 : 80}]; if {$reqp ne ""} { set path $reqp } }; if {![regexp {^([^:]+):(\d+)$} $hp -> host port]} { set host $hp }; set s [socket stream $host:$port]; $s timeout [expr {$t * 1000}]; if {$proto eq "https"} { $s ssl -sni $host }; set req "$m $path HTTP/1.1\r\nHost: $host\r\n"; dict for {k v} $hdrs { append req "$k: $v\r\n" }; append req "Content-Length: [string length $body]\r\nConnection: close\r\n\r\n$body"; $s puts -nonewline $req; $s flush; set resp [$s read]; $s close; set sep [string first "\r\n\r\n" $resp]; set hlen 4; if {$sep == -1} { set sep [string first "\n\n" $resp]; set hlen 2 }; if {$sep == -1} { error "invalid HTTP response" }; set htxt [string range $resp 0 [expr {$sep - 1}]]; set rbody [string range $resp [expr {$sep + $hlen}] end]; set status 0; set reason ""; set chunked 0; regexp {^HTTP/\d\.\d\s+(\d+)(?:\s+(.*))?$} [string trim [lindex [split $htxt "\n"] 0]] -> status reason; foreach l [lrange [split $htxt "\n"] 1 end] { if {[regexp -nocase {^transfer-encoding:\s*chunked$} [string trim $l]]} { set chunked 1 } }; return [dict create status $status reason $reason body [expr {$chunked ? [decode_chunked $rbody] : $rbody}]] }
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"]}}}]}; 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
set ep [string trimright [dict get $c endpoint] "/"]
set payload [encode_payload $c $msgs]
if {[catch {json::decode $payload}]} { error "API error: Local JSON validation failed" }
set is_tty [expr {[catch {exec sh -c "test -t 1" >@stdout}] == 0}]
set hdrs [dict create "Content-Type" "application/json" "User-Agent" "Mozilla/5.0 (compatible; MicroBantam/1.0)"]; if {[dict get $c api_key] ne "-"} { dict set $hdrs "Authorization" "Bearer [dict get $c api_key]" }
set last ""
for {set a 1} {$a <= 3} {incr a} {
if {$a > 1} { after [expr {($a - 1) * 2000}] }; puts -nonewline [expr {$is_tty ? "\r...requesting ($a/3)..." : "...requesting ($a/3)...\n"}]; flush stdout
set code [catch {http_request POST "$ep/chat/completions" $hdrs $payload [dict get $c timeout]} res]; if {$is_tty} { puts -nonewline "\r\u001b\[K"; flush stdout }
if {$code == 0 && [dict get $res status] == 200} {
set body [dict get $res body]
if {![catch {json::decode $body} data] && [is_dict $data] && [dict exists $data choices] && [llength [set choices [dict get $data choices]]] > 0} { return [dict get [lindex $choices 0] message] }
set last "invalid response body"
} else {
set st [expr {$code != 0 ? 0 : [dict get $res status]}]; set detail [safe_get $res body]
if {[string length $detail] > 400} { set detail [string range $detail 0 399] }
set last [expr {$code != 0 ? $res : ([string length $detail] ? "$detail (HTTP $st)" : ([safe_get $res reason] ne "" ? [safe_get $res reason] : "HTTP $st"))}]
if {$st != 0 && $st != 403 && $st != 429 && $st < 500} { error "API error: $last" }
}
}
error "API error: $last (after 3 attempts)"
}
proc shell_exec_cmd {cmd t} { catch {exec timeout $t sh -c "$cmd 2>&1"} out opts; set exit_code 0; if {[dict get $opts -code] != 0} { set errcode [dict get $opts -errorcode]; set exit_code [expr {[lindex $errcode 0] eq "CHILDSTATUS" ? [lindex $errcode 2] : -1}] }; set out [string trimright [string map [list "\u0000" "\n"] $out] " \t\n\r\v\f"]; return [expr {$exit_code == 124 ? "$out\n\[timeout after ${t}s\]\nexit: -1" : "$out\nexit: $exit_code"}] }
proc last_assistant {msgs} { for {set i [expr {[llength $msgs] - 1}]} {$i >= 0} {incr i -1} { set m [lindex $msgs $i]; if {[is_dict $m] && [safe_get $m role] eq "assistant"} { set cnt [safe_get $m content]; if {$cnt ne "" && $cnt ne "null"} { return $cnt } } }; return "" }
proc AL {c msgs_var sp depth} {
upvar 1 $msgs_var msgs
for {set iter 0} {$iter < [dict get $c max_al_iterations]} {incr iter} {
if {[catch {llm $c msgs} m]} {
if {[string match "*Invalid assistant message*" $m] || [string match "*content or tool_calls must be set*" $m]} {
set stripped 0
for {set j [expr {[llength $msgs] - 1}]} {$j >= 0} {incr j -1} { set mm [lindex $msgs $j]; if {[is_dict $mm] && [safe_get $mm role] eq "assistant"} { set msgs [lreplace $msgs $j $j]; puts "\[stripped malformed assistant message\]"; set stripped 1; break } }
if {$stripped} { continue }
}
puts $m; return $msgs
}
lappend msgs $m; set cnt [safe_get $m content]; if {$cnt ne "" && $cnt ne "null"} { puts $cnt }
if {[set tcs [safe_get $m tool_calls]] eq "" || $tcs eq "null"} break
if {[is_tool_call $tcs]} { set tcs [list $tcs] }
foreach tc $tcs {
if {![is_tool_call $tc]} continue
set fn [dict get [dict get $tc function] name]; set args_raw "\{\}"
if {[dict exists $tc function arguments]} { set args_raw [dict get [dict get $tc function] arguments] }
if {[catch {json::decode $args_raw} a] || ![is_dict $a]} { set bad [encode_json_msg [dict create invalid_raw $args_raw]]; dict set tc function arguments $bad; set res "bad JSON args for $fn: $bad"
} elseif {$fn eq "shell_exec"} { set cmd [safe_get $a command]; set res [shell_exec_cmd $cmd [dict get $c shell_timeout]]
} elseif {$fn eq "run_subagent"} { set prompt [safe_get $a prompt]; set child [list [dict create role system content "$sp\n\nImportant: this is a child agent"] [dict create role user content $prompt]]; set res [expr {$depth >= 5 ? "\[subagent depth limit (5) reached, child not spawned\]" : [last_assistant [AL $c child $sp [expr {$depth + 1}]]]}]
} else { set res "unknown tool: $fn" }
puts "\[tool\] $fn: $res"; lappend msgs [dict create role tool tool_call_id [safe_get $tc id] content $res]
}
}
return $msgs
}
proc sdir {} { global SDIR; if {![file isdirectory $SDIR]} { file mkdir $SDIR }; return $SDIR }
proc sessions {} { global SDIR; set s [list]; foreach f [glob -nocomplain "$SDIR/*.json"] { if {![catch {open $f r} fh]} { set c [read $fh]; close $fh; if {![catch {json::decode $c} d] && [is_dict $d]} { lappend s $d } } }; return [lsort -command {apply {{a b} { string compare [safe_get $b id] [safe_get $a id] }}} $s] }
proc save {msgs} { set sd [sdir]; set base [clock format [clock seconds] -format "%Y%m%d-%H%M%S"]; set id $base; set i 0; while {[file exists "$sd/$id.json"]} { incr i; set id "$base-$i" }; set mjs [list]; foreach m $msgs { lappend mjs [encode_json_msg $m] }; set f [open "$sd/$id.json" w]; puts $f "\{\n \"id\": [jesc $id],\n \"messages\": \[\n [join $mjs ",\n "]\n \]\n\}"; close $f; return $id }
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]]
if {[llength $argv] > 0} {
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 <id> /cfg <k> \[v\] /help"
while {1} {
puts -nonewline "> "; flush stdout; if {[gets stdin u] < 0} break
if {[set u [string trim $u]] eq ""} continue
if {$u eq "/quit"} { break } \
elseif {$u eq "/clear"} { set msgs [list [dict create role system content $sp_text]]; autosave $msgs } \
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 <session id>" } elseif {[catch {load_session $want_id} loaded]} { puts $loaded } else { set msgs $loaded; autosave $msgs; puts "loaded: $want_id" } } \
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 <param> \[val\]" } } \
elseif {$u eq "/help"} { puts "Commands: /quit /clear /save /list /load <id> /cfg <k> \[v\] /help" } \
else { lappend msgs [dict create role user content $u]; AL $c msgs $sp_text 0; autosave $msgs }
}
autosave $msgs
}
if {[info exists argv0] && [file tail $argv0] eq "mb.tcl"} { main $argv }
+1 -1
View File
@@ -1,5 +1,5 @@
endpoint=https://opencode.ai/zen/v1
model=big-pickle
model=hy3-free
temperature=0.7
api_key=-
stream=true
+20 -2
View File
@@ -2,9 +2,27 @@
package main
import "fmt"
import (
"fmt"
"os"
"os/exec"
"strconv"
"strings"
)
func termWidth() int { return 80 }
func termWidth() int {
if w := os.Getenv("COLUMNS"); w != "" {
if n, err := strconv.Atoi(strings.TrimSpace(w)); err == nil && n > 0 {
return n
}
}
if out, err := exec.Command("tput", "cols").Output(); err == nil {
if n, err := strconv.Atoi(strings.TrimSpace(string(out))); err == nil && n > 0 {
return n
}
}
return 80
}
func isTerminal(fd int) bool { return false }
+20 -2
View File
@@ -2,9 +2,27 @@
package main
import "errors"
import (
"errors"
"os"
"os/exec"
"strconv"
"strings"
)
func termWidth() int { return 80 }
func termWidth() int {
if w := os.Getenv("COLUMNS"); w != "" {
if n, err := strconv.Atoi(strings.TrimSpace(w)); err == nil && n > 0 {
return n
}
}
if out, err := exec.Command("tput", "cols").Output(); err == nil {
if n, err := strconv.Atoi(strings.TrimSpace(string(out))); err == nil && n > 0 {
return n
}
}
return 80
}
func isTerminal(fd int) bool { return false }