/* main.cpp — entry point, agentic loop, REPL, command dispatch. */ #include "bantam.hpp" #include #include #include #include #include #include #include #include #include #include #include #include #include /* ----------------------------- 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 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 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 \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 [value]\n", std::array{31}); continue; } if (sub == "get") { if (key.empty()) { cprint("Usage: /cfg get \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 \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 [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 ", "load session"}, {"/compact", "compact context"}, {"/cfg [v]", "get/set config"}, {"/model [m]", "alias for /cfg model"}, {"/endpoint [e]", "alias for /cfg endpoint"}, {"!", "run shell command directly"}, {"/models", "list models at endpoint"}, {"/skill [p]", "load SKILL.md and run as prompt"}, {"/help", "show help"}}; 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; }