Files
bantam-cpp/main.cpp
T
2026-10-15 23:32:13 +03:00

644 lines
29 KiB
C++

/* main.cpp — entry point, agentic loop, REPL, command dispatch. */
#include "bantam.hpp"
#include <array>
#include <cctype>
#include <cerrno>
#include <climits>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <curl/curl.h>
#include <dirent.h>
#include <span>
#include <sys/stat.h>
#include <unistd.h>
#include <vector>
/* ----------------------------- usage / pct ---------------------------- */
static int ctx_tokens(Usage &u) {
if (u.context_tokens > u.prompt_tokens) return u.context_tokens;
return u.prompt_tokens;
}
static std::string format_usage(Usage &u, int cw) {
int base = ctx_tokens(u);
if (cw <= 0) cw = 262144;
double pct = (double)base * 100.0 / (double)cw;
std::string s;
if (!u.model.empty()) str_appendf(s, "[%s: ", u.model.c_str());
else s += "[";
if (u.cached_tokens > 0) {
int uncached = u.prompt_tokens - u.cached_tokens;
if (uncached < 0) uncached = 0;
str_appendf(s, "%d prompt (%d cached, %d uncached) + %d completion | context: %d/%d (%.1f%%)]",
u.prompt_tokens, u.cached_tokens, uncached, u.completion_tokens, base, cw, pct);
} else {
str_appendf(s, "%d prompt + %d completion | context: %d/%d (%.1f%%)]",
u.prompt_tokens, u.completion_tokens, base, cw, pct);
}
return s;
}
static double context_pct(Usage &u, int cw) {
if (cw <= 0) cw = 262144;
return (double)ctx_tokens(u) * 100.0 / (double)cw;
}
/* ----------------------------- invalid-assistant detection ------------- */
static bool is_invalid_assistant_err(const char *err) {
if (!err) return false;
std::string low = err;
for (char &c : low) c = (char)std::tolower((unsigned char)c);
return low.find("invalid assistant message") != std::string::npos
|| low.find("content or tool_calls must be set") != std::string::npos
|| low.find("tool_calls must be set") != std::string::npos
|| low.find("content must be set") != std::string::npos;
}
/* ----------------------------- sanitize args -------------------------- */
static std::string sanitize_arguments(std::string_view args) {
std::string f = filter_text(args);
Json o;
if (Json::parse(f, o) && o.is_object()) return f;
Json w = Json::object();
w["invalid_raw"] = f;
return w.dump();
}
/* ----------------------------- system prompt -------------------------- */
static const char *default_system_prompt =
"You are Bantam, a tiny, powerful AI agent. Solve the user's task using three tools:\n"
"- bash: run a bash command; returns its output and exit code.\n"
"- read: read a file or directory from the local filesystem; returns numbered lines or directory entries.\n"
"- write: write content to a file; if lineOffset/byteOffset and delLines/delBytes are all omitted it overwrites the entire file, otherwise it writes at the given line or byte offset (optionally deleting lines or bytes first); line-based edits are preferred; returns status.\n\n"
"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. Stop as soon as the goal is met and report concisely: results, not process.\n"
"Use the target system's filesystem deliberately:\n"
"- Use only $TMPDIR/bantam as the temporary directory for intermediate or scratch files (logs, downloads, temporary build outputs, etc.); create it if it does not exist.\n"
"- Create permanent artifacts in the current working directory unless the user explicitly instructs otherwise.\n"
"- Tool results larger than $MAX_TOOL_RES bytes are not inserted into the context in full: they are saved under $TMPDIR/bantam/toolres and replaced by a short note giving the file path and its total byte length. Read such a file with the read tool, paging through it with line offsets (using offset and limit) instead of loading it all at once.\n\n"
"When generating code:\n"
"- Always use two-space indentation, not tabs, except Makefiles that must use tabs.\n"
"- No whitespace between keywords and opening parentheses 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 contents in the project.";
static std::string build_system_prompt(Cfg &cfg) {
std::string tmp = bantam_tmp_dir();
std::string sp = default_system_prompt;
size_t found = 0;
while ((found = sp.find("$TMPDIR/bantam", found)) != std::string::npos) {
sp.replace(found, std::strlen("$TMPDIR/bantam"), tmp);
found += tmp.size();
}
char mtr[32]; std::snprintf(mtr, sizeof mtr, "%d", cfg.max_tool_res);
auto fm = sp.find("$MAX_TOOL_RES");
if (fm != std::string::npos) sp.replace(fm, std::strlen("$MAX_TOOL_RES"), mtr);
std::string out = sp;
std::string td = tools_dir(cfg);
if (!td.empty()) { out += "\n\nExtra shell tools can be found at "; out += td; }
std::string sd = skills_dir(cfg);
if (!sd.empty()) { out += "\n\nSkills may be discovered and invoked from "; out += sd; }
return out;
}
/* ----------------------------- json arg helpers ----------------------- */
static std::string get_str(const Json &j, const char *k) {
return j[k].str();
}
static int get_int(const Json &j, const char *k, int def = 0) {
return j[k].as_int(def);
}
static std::string tool_path_arg(const Json &a) {
std::string v = get_str(a, "filePath");
if (v.empty()) v = get_str(a, "path");
return v;
}
static bool trim_is_empty(std::string_view s) {
size_t i = 0;
while (i < s.size() && (s[i] == ' ' || s[i] == '\n' || s[i] == '\r' || s[i] == '\t')) i++;
return i == s.size();
}
/* ----------------------------- AL loop -------------------------------- */
static int AL(Cfg &cfg, Messages &msgs, Usage &out, volatile int *interrupted, std::string *err) {
int done = 0;
std::vector<std::string> tmps;
int rc = 0;
for (int i = 0; i < cfg.max_al_iterations && !done; i++) {
if (*interrupted) { rc = -1; break; }
Message m; Usage u;
std::string lerr;
int lrc = llm(cfg, msgs, true, m, u, lerr, interrupted);
if (lrc != 0) {
if (*interrupted) { rc = -1; break; }
if (is_invalid_assistant_err(lerr.c_str())) {
int stripped = 0;
for (int j = (int)msgs.items.size() - 1; j >= 0; j--) {
if (msgs.items[j].role == "assistant") {
cprint("[stripped malformed assistant message]\n", std::array{33});
msgs.items.erase(msgs.items.begin() + j);
stripped = 1;
break;
}
}
if (stripped) continue;
}
if (err) *err = std::move(lerr);
rc = -1; break;
}
out.prompt_tokens = u.prompt_tokens;
out.completion_tokens += u.completion_tokens;
out.total_tokens += u.total_tokens;
out.context_tokens = u.context_tokens;
if (u.cached_tokens > 0) out.cached_tokens = u.cached_tokens;
if (!u.model.empty()) out.model = u.model;
for (auto &tc : m.calls) {
tc.arguments = sanitize_arguments(tc.arguments);
}
msgs.items.push_back(m);
if (!cfg.stream) {
if (!m.reasoning.empty()) {
cprint("--- reasoning start ---\n", std::array{36});
cprint(m.reasoning, std::array{2});
cprint("\n--- reasoning end ---\n", std::array{36});
}
if (!m.content.empty()) { render_md_print(m.content); printf("\n"); }
}
if (m.calls.empty()) {
if (!m.reasoning.empty() && trim_is_empty(m.content)) {
cprint("[auto continue: response was reasoning-only]\n", std::array{33});
Message cont; cont.role = "user"; cont.content = "continue";
msgs.items.push_back(cont);
continue;
}
done = 1; break;
}
for (size_t tci = 0; tci < m.calls.size(); tci++) {
if (*interrupted) { rc = -1; break; }
ToolCall &tc = m.calls[tci];
const std::string &astr = tc.arguments; /* already filtered by sanitize_arguments */
if (!m.calls_shown) {
cprint(sfmt("[tool call: %s(%s)]", tc.name.c_str(), astr.c_str()), std::array{33});
printf("\n");
}
std::string res; int sty = 2;
bool res_already_filtered = false;
Json a_mut;
if (!Json::parse(astr, a_mut) || !a_mut.is_object()) {
res = sfmt("[tool error: invalid JSON args for %s: parse error. Raw: %s]", tc.name.c_str(), astr.c_str());
sty = 31;
} else {
const Json &a = a_mut;
std::string fn = tc.name;
if (fn == "bash" || fn == "shell_exec") {
std::string cmd = get_str(a, "command");
std::string workdir = get_str(a, "workdir");
std::string fwd = filter_text(workdir);
int to = cfg.shell_timeout;
if (a.contains("timeout")) { int n = get_int(a, "timeout"); if (n > 0) to = (n > 1000) ? n / 1000 : n; }
res = shell_with_workdir(cmd, fwd, to, interrupted);
res_already_filtered = true;
} else if (fn == "read") {
std::string p = tool_path_arg(a);
std::string fp = filter_text(p);
int off = get_int(a, "offset", 1); if (off < 1) off = 1;
int lim = get_int(a, "limit", 2000); if (lim <= 0) lim = 2000;
res = read_file_or_dir(fp, off, lim, &sty);
} else if (fn == "write" || fn == "write_file") {
std::string p = tool_path_arg(a);
std::string fp = filter_text(p);
std::string cv = get_str(a, "content");
bool has_content = a.contains("content") && a["content"].is_string();
bool has_line = a.contains("lineOffset") && !a["lineOffset"].is_null();
bool has_byte = a.contains("byteOffset") && !a["byteOffset"].is_null();
bool has_del_lines = a.contains("delLines") && !a["delLines"].is_null();
bool has_del_bytes = a.contains("delBytes") && !a["delBytes"].is_null();
long line_off = has_line ? (long)get_int(a, "lineOffset") : -1L;
long byte_off = has_byte ? (long)get_int(a, "byteOffset") : -1L;
long del_lines = has_del_lines ? (long)get_int(a, "delLines") : -1L;
long del_bytes = has_del_bytes ? (long)get_int(a, "delBytes") : -1L;
if (line_off < 1) line_off = -1L;
if (byte_off < 0) byte_off = -1L;
if (del_lines < 0) del_lines = -1L;
if (del_bytes < 0) del_bytes = -1L;
if (fp.empty()) { res = "[tool error: write requires 'filePath' parameter]"; sty = 31; }
else if (!has_content) { res = "[tool error: write requires 'content' parameter]"; sty = 31; }
else if (!has_line && !has_byte && !has_del_lines && !has_del_bytes) {
/* No offset/deletion parameters: overwrite the whole file. */
std::string c = filter_text(cv);
/* Ensure the written file ends with a single trailing newline. */
if (!c.empty() && c.back() != '\n') c.push_back('\n');
auto sl = fp.find_last_of('/');
if (sl != std::string::npos) mkdir_p(std::string_view(fp).substr(0, sl));
else mkdir_p(".");
if (!write_file_str(fp, c)) { res = sfmt("[tool error: write %s: %s]", fp.c_str(), strerror(errno)); sty = 31; }
else { res = sfmt("Successfully wrote %zu bytes to %s", c.size(), fp.c_str()); }
} else {
int werr = 0; std::string c = filter_text(cv);
/* Line-based edits keep the text-file trailing-newline convention;
* byte-based edits are exact byte operations and must not have a
* newline injected. */
if (!(has_byte || has_del_bytes) && !c.empty() && c.back() != '\n') c.push_back('\n');
res = write_file(fp, line_off, byte_off, del_lines, del_bytes, c, &werr);
if (werr) sty = 31;
}
} else {
res = sfmt("Unknown tool: %s", fn.c_str());
sty = 31;
}
}
if (!res_already_filtered) res = filter_text(res);
res = offload_tool_result(res, cfg.max_tool_res, tmps);
{
std::string hdr = cstr("[tool result: ", std::array{32});
std::string fn2 = cstr(tc.name, std::array{32});
printf("%s%s]\n", hdr.c_str(), fn2.c_str());
cprint(res, std::array{sty});
printf("\n\n");
}
Message tm; tm.role = "tool"; tm.tool_call_id = tc.id; tm.content = std::move(res);
msgs.items.push_back(std::move(tm));
if (*interrupted) { rc = -1; break; }
}
if (rc != 0) break;
}
if (!done && rc == 0) {
Message maxm; maxm.role = "assistant";
maxm.content = sfmt("[max AL iterations (%d) reached]", cfg.max_al_iterations);
msgs.items.push_back(maxm);
}
cleanup_temps(tmps);
return rc;
}
/* ----------------------------- run_turn ------------------------------- */
static int run_turn(Cfg &cfg, Messages &msgs, Usage &usage, std::string *err, volatile int *interrupted) {
*interrupted = 0;
usage = Usage{};
int rc = AL(cfg, msgs, usage, interrupted, err);
if (rc != 0) {
if (*interrupted) { cprint("\n[interrupted]\n", std::array{33}); }
else { cprint("\n[error: ", std::array{31}); cprint((!err || err->empty()) ? "error" : *err, std::array{31}); cprint("]\n", std::array{31}); }
}
return rc;
}
/* ----------------------------- direct shell --------------------------- */
static void run_direct_shell(std::string_view cmd, int timeout) {
std::string fcmd = filter_text(cmd);
cprint(sfmt("[tool call: bash({\"command\":\"%s\"})]", fcmd.c_str()), std::array{33});
printf("\n");
std::string res = shell_with_workdir(fcmd, "", timeout, &g_interrupted);
printf("%s\n", cstr("[tool result: bash]", std::array{32}).c_str());
printf("%s\n", cstr(res, std::array{2}).c_str());
}
/* ----------------------------- skills --------------------------------- */
static std::string skill_prompt(std::string_view u, Cfg &cfg) {
std::string_view rest = u.substr(6);
while (!rest.empty() && rest.front() == ' ') rest = rest.substr(1);
if (rest.empty()) return "";
std::string name(rest);
std::string prompt;
auto sp = name.find(' ');
if (sp != std::string::npos) { prompt = name.substr(sp + 1); name = name.substr(0, sp); }
std::string sd = skills_dir(cfg);
std::string path;
if (!sd.empty()) {
path = sfmt("%s/%s/SKILL.md", sd.c_str(), name.c_str());
} else if (name.find("SKILL.md") != std::string::npos) {
path = name;
} else {
path = name + "/SKILL.md";
}
struct stat st;
if (stat(path.c_str(), &st) != 0)
return sfmt("[cannot read skill \"%s\": %s]", name.c_str(), strerror(errno));
if (S_ISDIR(st.st_mode))
return sfmt("[cannot read skill \"%s\": Is a directory]", name.c_str());
std::string data;
if (!read_file_str(path, data))
return sfmt("[cannot read skill \"%s\": %s]", name.c_str(), strerror(errno));
while (!data.empty() && (data.back() == '\n' || data.back() == '\r')) data.pop_back();
std::string out = data;
if (!prompt.empty()) { out += "\n\n"; out += prompt; }
return out;
}
static void list_skills(Cfg &cfg) {
std::string sd = skills_dir(cfg);
if (sd.empty()) { cprint("No skills directory configured (set BANTAM_SKILLS_DIR or bantam_skills_dir).\n", std::array{33}); return; }
DIR *d = opendir(sd.c_str());
if (!d) { cprint(sfmt("[skill error: cannot read skills dir: %s]", strerror(errno)), std::array{31}); return; }
std::string names; int cnt = 0; struct dirent *de;
while ((de = readdir(d))) {
if (std::strcmp(de->d_name, ".") == 0 || std::strcmp(de->d_name, "..") == 0) continue;
std::string full = sfmt("%s/%s", sd.c_str(), de->d_name);
struct stat st; if (stat(full.c_str(), &st) != 0 || !S_ISDIR(st.st_mode)) continue;
std::string sk = sfmt("%s/%s/SKILL.md", sd.c_str(), de->d_name);
if (access(sk.c_str(), F_OK) == 0) { str_appendf(names, " %s\n", de->d_name); cnt++; }
}
closedir(d);
if (cnt == 0) { cprint("No skills found in ", std::array{33}); cprint(sd, std::array{33}); cprint("\n", std::array{33}); return; }
cprint("Available skills in ", std::array{1, 36}); cprint(sd, std::array{1, 36}); cprint(":\n", std::array{1, 36});
fputs(names.c_str(), stdout);
}
/* ----------------------------- compact -------------------------------- */
static void do_compact(Cfg &cfg, Messages &msgs) {
if (msgs.items.size() <= 1) { cprint("Nothing to compact yet.\n", std::array{33}); return; }
cprint("[compacting conversation...]\n", std::array{33});
std::string sum, e;
auto nm = compact(cfg, msgs, &sum, &e, &g_interrupted);
int interrupted = g_interrupted;
if (!nm) {
if (interrupted) cprint("\n[interrupted]\n", std::array{33});
else { cprint(sfmt("\n[compact failed: %s]", e.empty() ? "compact failed" : e.c_str()), std::array{31}); printf("\n"); }
return;
}
msgs = std::move(*nm);
autosave(msgs);
printf("%s%d messages]\n", cstr("[compacted to ", std::array{32}).c_str(), (int)msgs.items.size());
printf("%s", cstr("--- summary ---\n", std::array{33}).c_str());
printf("%s\n", cstr(sum, std::array{2}).c_str());
}
/* ----------------------------- main ----------------------------------- */
struct CurlGlobalGuard {
CurlGlobalGuard() { curl_global_init(CURL_GLOBAL_DEFAULT); }
~CurlGlobalGuard() { http_cleanup(); curl_global_cleanup(); }
};
int main(int argc, char **argv) {
CurlGlobalGuard curl_guard;
setup_signal_handler();
Cfg cfg = get_cfg(config_path());
g_col = color_enabled(cfg);
std::string sp = build_system_prompt(cfg);
Messages msgs;
{
Message sys; sys.role = "system"; sys.content = sp;
msgs.items.push_back(sys);
}
if (argc > 1 && argv[1] && argv[1][0] != '\0') {
std::string p = argv[1];
struct stat st;
if (stat(p.c_str(), &st) != 0 || S_ISDIR(st.st_mode)) {
cprint("Error: file '", std::array{31}); cprint(p, std::array{31}); cprint("' not found.\n", std::array{31});
return 1;
}
std::string u;
if (!read_file_str(p, u)) {
cprint("Error: file '", std::array{31}); cprint(p, std::array{31}); cprint("' not found.\n", std::array{31});
return 1;
}
size_t a = 0; while (a < u.size() && (u[a] == ' ' || u[a] == '\n' || u[a] == '\r' || u[a] == '\t')) a++;
u = u.substr(a);
if (!u.empty() && u[0] == '!') { run_direct_shell(u.substr(1), cfg.shell_timeout); return 0; }
cfg.context_window = fetch_context_window(cfg);
Message um; um.role = "user"; um.content = u;
msgs.items.push_back(um);
Usage usg; std::string err;
run_turn(cfg, msgs, usg, &err, &g_interrupted);
if (!err.empty()) return 1;
cprint(format_usage(usg, cfg.context_window), std::array{2}); printf("\n");
autosave(msgs);
return 0;
}
load_history();
auto sess = find_project_session();
if (sess && sess->messages.items.size() > 1) {
cprint("[session found for this directory]\n", std::array{33});
cprint(" ", std::array{33}); cprint(sess->id, std::array{32}); printf("\n");
cprint(" ", std::array{33}); cprint(sess->summary, std::array{2}); printf("\n");
std::string created = sfmt(" created %s [%d messages]\n", sess->created.c_str(), (int)sess->messages.items.size());
cprint(created, std::array{2});
printf(" Resume this session? [Y/n]: "); fflush(stdout);
bool ok; std::string ans = read_plain("", ok);
if (ok) {
std::string a2 = ans;
size_t ai = 0; while (ai < a2.size() && (a2[ai] == ' ' || a2[ai] == '\n' || a2[ai] == '\r' || a2[ai] == '\t')) ai++;
a2 = a2.substr(ai);
while (!a2.empty() && (a2.back() == '\n' || a2.back() == '\r' || a2.back() == ' ')) a2.pop_back();
if (a2.empty() || a2[0] == 'y' || a2[0] == 'Y') {
msgs = std::move(sess->messages);
autosave(msgs);
printf("%s%s]\n", cstr("[session resumed: ", std::array{32}).c_str(), sess->id.c_str());
}
}
}
const std::string *re_ptr = cfg_raw_get(cfg, "reasoning_effort");
std::string re = (re_ptr && !re_ptr->empty()) ? *re_ptr : "high";
{
cprint("Bantam Agent ready", std::array{1, 32}); printf(" ");
cprint(" (Enter submits; Ctrl+J or paste = new line)\n", std::array{2});
fflush(stdout);
cfg.context_window = fetch_context_window(cfg);
std::string info = sfmt("endpoint: %s model: %s temp: %g reasoning: %s context: %d\n",
cfg.endpoint.c_str(), cfg.model.c_str(), cfg.temperature, re.c_str(), cfg.context_window);
cprint(info, std::array{2});
}
for (;;) {
g_interrupted = 0;
bool ok;
std::string u = read_line("> ", ok);
if (!ok) { printf("\n"); break; }
std::string ut = u;
size_t a = 0; while (a < ut.size() && (ut[a] == ' ' || ut[a] == '\n' || ut[a] == '\r' || ut[a] == '\t')) a++;
ut = ut.substr(a);
if (ut.empty()) continue;
add_history(u);
/* command aliases */
for (int ci = 0; ci < 2; ci++) {
const char *cmd = (ci == 0) ? "model" : "endpoint";
std::string slash = sfmt("/%s", cmd);
if (u == slash) {
u = sfmt("/cfg %s", cmd);
} else if (u.size() > slash.size() && u.rfind(slash, 0) == 0 && u[slash.size()] == ' ') {
u = sfmt("/cfg %s %s", cmd, u.c_str() + slash.size() + 1);
}
}
if (u == "/quit") break;
if (u == "/clear") {
msgs = Messages{};
Message s2; s2.role = "system"; s2.content = build_system_prompt(cfg);
msgs.items.push_back(s2);
autosave(msgs); continue;
}
if (u == "/save" || u.starts_with("/save ")) {
std::string name = u.substr(5);
size_t ni = 0; while (ni < name.size() && name[ni] == ' ') ni++;
name = name.substr(ni);
std::string sid, sm;
save_session(msgs, name, &sid, &sm);
printf("%s%s]\n", cstr("[session saved: ", std::array{32}).c_str(), sid.c_str());
cprint(" ", std::array{32}); cprint(sm, std::array{2}); printf("\n");
continue;
}
if (u == "/continue" || u == "/cont") {
std::string pid = project_id();
std::string lerr;
auto lm = load_session(pid, &lerr);
if (!lm) { cprint("No session found for current project: ", std::array{31}); cprint(lerr, std::array{31}); printf("\n"); continue; }
msgs = std::move(*lm);
autosave(msgs);
printf("%s%s]\n", cstr("[session continued: ", std::array{32}).c_str(), pid.c_str());
std::string sm = summary_of(msgs);
cprint(" ", std::array{32}); cprint(sm, std::array{32}); printf("\n");
continue;
}
if (u == "/list") {
auto ss = sessions_load();
if (ss.empty()) { cprint("No sessions saved yet.\n", std::array{33}); continue; }
std::string pid = project_id();
for (auto &s : ss) {
printf("%s", cstr(s.id, std::array{32}).c_str());
if (s.id == pid) cprint(" (current project)", std::array{33});
std::string meta = sfmt(" %s [%d msgs]\n", s.created.c_str(), (int)s.messages.items.size());
cprint(meta, std::array{2});
cprint(" ", std::array{32}); cprint(s.summary, std::array{2}); printf("\n");
}
continue;
}
if (u == "/load" || u.starts_with("/load ")) {
std::vector<std::string> parts;
{
std::string_view sv(u);
size_t i = 0;
while (i < sv.size()) {
while (i < sv.size() && sv[i] == ' ') i++;
if (i >= sv.size()) break;
size_t j = i;
while (j < sv.size() && sv[j] != ' ') j++;
parts.push_back(std::string(sv.substr(i, j - i)));
i = j;
}
}
if (parts.size() < 2) { cprint("Usage: /load <session-id>\n", std::array{31}); continue; }
std::string lerr;
auto lm = load_session(parts[1], &lerr);
if (!lm) { cprint("Session not found: ", std::array{31}); cprint(lerr, std::array{31}); printf("\n"); continue; }
msgs = std::move(*lm);
autosave(msgs);
printf("%s%s]\n", cstr("[session loaded: ", std::array{32}).c_str(), parts[1].c_str());
std::string sm = summary_of(msgs);
cprint(" ", std::array{32}); cprint(sm, std::array{32}); printf("\n");
continue;
}
if (u == "/compact") { do_compact(cfg, msgs); continue; }
if (u == "/cfg" || u.starts_with("/cfg ")) {
/* tokens after "/cfg " */
std::string rest = u.substr(4);
while (!rest.empty() && rest.front() == ' ') rest = rest.substr(1);
std::string sub, key, val;
{
size_t i = 0;
while (i < rest.size() && rest[i] != ' ') i++;
sub = rest.substr(0, i);
size_t j = i;
while (j < rest.size() && rest[j] == ' ') j++;
size_t ks = j;
while (j < rest.size() && rest[j] != ' ') j++;
key = rest.substr(ks, j - ks);
size_t vs = j;
while (vs < rest.size() && rest[vs] == ' ') vs++;
val = rest.substr(vs);
}
if (sub.empty()) { cprint("Usage: /cfg <key> [value]\n", std::array{31}); continue; }
if (sub == "get") {
if (key.empty()) { cprint("Usage: /cfg get <key>\n", std::array{31}); continue; }
const std::string *v = cfg_raw_get(cfg, key);
if (v && !v->empty()) printf("%s=%s\n", cstr(key, std::array{32}).c_str(), v->c_str());
else printf("%s not set\n", cstr(key, std::array{31}).c_str());
} else if (sub == "set") {
if (key.empty()) { cprint("Usage: /cfg set <key> <value>\n", std::array{31}); continue; }
if (set_cfg(config_path(), key, val) != 0) { cprint("[cfg error]\n", std::array{31}); continue; }
cfg = get_cfg(config_path());
if (key == "model" || key == "endpoint" || key == "api_key")
cfg.context_window = fetch_context_window(cfg);
g_col = color_enabled(cfg);
printf("%s%s=%s]\n", cstr("[config updated: ", std::array{32}).c_str(), key.c_str(), val.c_str());
} else if (key.empty()) {
/* single token: treat as get */
const std::string *v = cfg_raw_get(cfg, sub);
if (v && !v->empty()) printf("%s=%s\n", cstr(sub, std::array{32}).c_str(), v->c_str());
else printf("%s not set\n", cstr(sub, std::array{31}).c_str());
} else {
/* legacy "/cfg key value": treat as set */
if (set_cfg(config_path(), sub, key) != 0) { cprint("[cfg error]\n", std::array{31}); continue; }
cfg = get_cfg(config_path());
if (sub == "model" || sub == "endpoint" || sub == "api_key")
cfg.context_window = fetch_context_window(cfg);
g_col = color_enabled(cfg);
printf("%s%s=%s]\n", cstr("[config updated: ", std::array{32}).c_str(), sub.c_str(), key.c_str());
}
continue;
}
if (u == "/skill" || u.starts_with("/skill ")) {
std::string rest = u.substr(6);
while (!rest.empty() && rest.front() == ' ') rest = rest.substr(1);
if (rest.empty()) { list_skills(cfg); continue; }
std::string composed = skill_prompt(u, cfg);
if (composed.empty()) { cprint("/skill usage: /skill <name> [prompt]\n", std::array{31}); continue; }
if (composed.starts_with("[cannot read skill")) {
cprint(composed, std::array{31}); printf("\n"); continue;
}
u = composed;
/* fall through to a normal LLM turn */
} else if (u == "/models") {
int e2; std::string ml = list_models(cfg, &e2);
if (e2 != 0) { cprint("[error querying /models: ", std::array{31}); cprint(ml, std::array{31}); printf("]\n"); continue; }
cprint("Supported models at ", std::array{1, 36}); cprint(cfg.endpoint, std::array{1, 36}); cprint(":\n", std::array{1, 36});
render_md_print(ml);
continue;
} else if (!u.empty() && u[0] == '!') {
run_direct_shell(u.substr(1), cfg.shell_timeout); continue;
} else if (u == "/help") {
cprint("Bantam commands:\n", std::array{1, 36});
const char *cmds[][2] = {
{"/quit", "exit"}, {"/clear", "reset to system prompt"}, {"/save [name]", "save session (default: project MD5)"},
{"/continue", "continue project session (alias: /cont)"}, {"/list", "list sessions"}, {"/load <id>", "load session"},
{"/compact", "compact context"}, {"/cfg <k> [v]", "get/set config"}, {"/model [m]", "alias for /cfg model"},
{"/endpoint [e]", "alias for /cfg endpoint"}, {"!<cmd>", "run shell command directly"}, {"/models", "list models at endpoint"},
{"/skill <n> [p]", "load SKILL.md and run as prompt"}, {"/help", "show help"}};
for (int i = 0; i < 14; i++) {
std::string label = sfmt(" %-18s", cmds[i][0]);
cprint(label, std::array{1, 32});
cprint(cmds[i][1], std::array{1, 32});
printf("\n");
}
continue;
}
/* normal LLM turn */
{
Message um; um.role = "user"; um.content = u;
msgs.items.push_back(um);
Usage usg; std::string err;
run_turn(cfg, msgs, usg, &err, &g_interrupted);
if (!err.empty()) continue;
cprint(format_usage(usg, cfg.context_window), std::array{2}); printf("\n");
double pct = context_pct(usg, cfg.context_window);
if (pct >= 60.0 && msgs.items.size() > 1) {
std::string q = sfmt("Context usage is at %.1f%% (%d / %d tokens). Compact conversation? [Y/n]: ",
pct, ctx_tokens(usg), cfg.context_window);
cprint(q, std::array{33});
bool ok2; std::string ans = read_plain("", ok2);
if (ok2) {
std::string a2 = ans;
size_t ai = 0; while (ai < a2.size() && (a2[ai] == ' ' || a2[ai] == '\n' || a2[ai] == '\r' || a2[ai] == '\t')) ai++;
a2 = a2.substr(ai);
while (!a2.empty() && (a2.back() == '\n' || a2.back() == '\r' || a2.back() == ' ')) a2.pop_back();
if (a2.empty() || a2[0] == 'y' || a2[0] == 'Y') do_compact(cfg, msgs);
}
}
autosave(msgs);
}
}
autosave(msgs);
save_history();
return 0;
}